diff --git a/.pi/skills/yellowjacket-dev/references/ui-tier.md b/.pi/skills/yellowjacket-dev/references/ui-tier.md index 37f7d6d..f25904d 100644 --- a/.pi/skills/yellowjacket-dev/references/ui-tier.md +++ b/.pi/skills/yellowjacket-dev/references/ui-tier.md @@ -1,8 +1,8 @@ # The component and store tier (`make ui-test`) -313 tests in a real Chromium in ~2 s with no Wails, no backend, no -seeded library and no virtual display. This is the cheapest coverage -available and where the bulk of UI regression belongs. +757 tests in a real Chromium with no Wails, no backend, no seeded +library and no virtual display. This is the cheapest coverage available +and where the bulk of UI regression belongs. ```bash make ui-setup # once: the Vitest provider's own Chromium @@ -15,12 +15,20 @@ make ui-test UI_ARGS='store/queue' # filter ## How it works -`frontend/wailsjs/` is a pure passthrough — every binding is -`window.go[svc][Type][Method](args)`, every runtime call is -`window.runtime.X(...)`. So `frontend/test/support/wails-fake.ts` -replaces **those two globals and nothing else**, and the tests then -exercise the *real* generated bindings and the *real* store code. No -module mocking, and no second description of the Wails layer. +Wails v3 routes every runtime call — bindings, event emits, window, +dialogs, clipboard — through one IPC transport, and `setTransport()` is +a public seam for replacing it. So +`frontend/test/support/wails-fake.ts` replaces **that and nothing +else**, and the tests then exercise the *real* generated bindings, the +*real* runtime and the *real* store code. No module mocking, and no +second description of the Wails layer. + +A binding call carries a *method ID* (an FNV-1a hash of the Go method's +fully-qualified name), not a name, so the fake derives the ID → path +map from the generated tree at setup: each package's `index.ts` +re-exports its service under the Go type's real name, which is the one +place that casing survives. A path that never maps records as `#` +and fails the assertion naming it. ```ts emit(Events.QueueChanged, payload); // push a backend event @@ -31,11 +39,19 @@ lastArgs('queue.Queue.SetQueue'); const el = await fixture('now-playing'); // mount; shadow()/text() query it ``` -The dispatcher mirrors wails' own `desktop/events.js`, including -`maxCallbacks` expiry and the fact that a frontend `EventsEmit` -notifies local listeners *before* Go. +Delivery is not mirrored — `emit()` goes through the runtime's own +`window._wails.dispatchWailsEvent`, which is the entry point the +backend's push uses, so listener expiry and ordering are the runtime's +real code. What *is* mirrored is one line of Go: how +`EventManager.Emit` packs variadic data into an event's single `data` +field (none is null, one is the value, more is the slice). -## Four things that will cost you time +A frontend `Events.Emit` no longer notifies local listeners before Go — +v3 calls the backend, which sends the event back out to every window. +The page still sees its own emit, one round trip later rather than +synchronously. + +## Five things that will cost you time - **Store singletons are constructed at module import**, before any test can stub. `test/setup.ts` therefore carries import-time defaults for @@ -54,6 +70,13 @@ notifies local listeners *before* Go. - **`@lit-labs/virtualizer` never produces two identical frames**, so `toMatchScreenshot` on `` fails with "could not capture a stable screenshot" rather than a diff. Assert on its rows instead. +- **A v3 binding settles several microtasks after a v2 one did** — it + goes through `Call()`, an async `runtimeCallWithID`, the transport and + a `CancellablePromise`, where v2's `window.go` proxy resolved one + promise. `fixture()` drains microtasks between two renders so a + component that loads in `firstUpdated` is loaded when it returns. + Microtasks and not a timer, deliberately: a timer hangs forever under + the suites that install fake ones. Visual baselines are font-hinting and compositing sensitive, which is why they are opt-in: they only mean anything on the machine that @@ -61,14 +84,15 @@ recorded them. ## Bindings -`frontend/wailsjs/` is generated by `wails`, **not** by `go generate`, +`frontend/bindings/` is generated by `wails3`, **not** by `go generate`, so the pre-commit codegen check does not cover it — a renamed Go bound method first shows up at runtime, as a call that never settles. ```bash -make bindings-check # ~1.5 s, also a pre-commit hook +make bindings-check # ~3.5 s warm, also a pre-commit hook make bindings # regenerate for real ``` -The generator rewrites `wailsjs/runtime/*` as mode 755 every run; that -is churn, not drift, and the check ignores it. +No build tags are passed: the generator is a static analyser that sees +only the configuration it is told about, and the one that matters is +the one users run, which is the default tag set. diff --git a/frontend/src/store/tracklist-store.ts b/frontend/src/store/tracklist-store.ts index e550912..ed1438f 100644 --- a/frontend/src/store/tracklist-store.ts +++ b/frontend/src/store/tracklist-store.ts @@ -50,6 +50,17 @@ class TrackListStore { try { const columns = await list(GetTrackListColumns()); + // An empty answer keeps the defaults rather than emptying + // the list. `GetTrackListColumns` substitutes + // `tracklist.DefaultColumns` only when the whole config + // section is missing — a section that exists with no + // columns in it returns nothing, and a track list with no + // columns is not what that means. Until v3 this was + // accidental: the binding's `[]Column` was typed `Column[]` + // and an absent answer arrived as `undefined`, so `.map` + // threw into the catch below. + if (columns.length === 0) return; + this.update({ columnIds: columns.map((c) => c.id), }); diff --git a/frontend/test/harness.test.ts b/frontend/test/harness.test.ts index e9d0daa..11e482f 100644 --- a/frontend/test/harness.test.ts +++ b/frontend/test/harness.test.ts @@ -63,17 +63,25 @@ describe('component-tier harness', () => { expect(fired).toBe(1); }); - it('notifies local listeners on a frontend-side EventsEmit', async () => { - // Wails' own runtime notifies JS listeners before it notifies Go - // (desktop/events.js), so a frontend emit is observable in-page. - const { EventsEmit } = await import('@runtime/runtime'); + it('returns a frontend emit to in-page listeners, one round trip later', async () => { + // v2 notified JS listeners *before* Go, so a frontend emit was + // observable synchronously. v3's Events.Emit does not touch the + // local registry at all — it calls the backend, and + // EventProcessor.Emit sends the event back out to every window, + // including the one that emitted it. So the page still sees it, + // just not on the same tick. + const { Events } = await import('@wailsio/runtime'); let seen: unknown; wails.on('SyntheticEmit', (data) => { seen = data; }, -1); - EventsEmit('SyntheticEmit', 42); + Events.Emit('SyntheticEmit', 42); + + expect(seen).toBeUndefined(); + + await flush(); expect(seen).toBe(42); }); diff --git a/frontend/test/support/render.ts b/frontend/test/support/render.ts index a260d43..bdf8488 100644 --- a/frontend/test/support/render.ts +++ b/frontend/test/support/render.ts @@ -11,9 +11,23 @@ import { expect } from 'vitest'; const mounted: HTMLElement[] = []; /** - * Create an element, apply properties, mount it and wait for Lit's - * first render. Properties are set as *properties*, not attributes, so + * Create an element, apply properties, mount it and wait for it to + * settle. Properties are set as *properties*, not attributes, so * non-string values survive. + * + * "Settle" drains the microtask queue between two renders rather than + * awaiting one render. A component that loads from the backend in + * `firstUpdated` is only renderable once that call resolves, and under + * v3 a binding takes several microtasks longer to settle than v2's + * did: the generated function goes through `Call()`, an async + * `runtimeCallWithID`, the transport and a `CancellablePromise`, where + * v2's `window.go` proxy resolved one promise. Tests were already + * written as though `fixture()` meant "mounted and loaded" — it now + * does, rather than each of them counting ticks. + * + * Microtasks, deliberately, and not `setTimeout`: every hop in that + * chain is a promise, and a timer would hang forever in the suites + * that install fake ones. */ export async function fixture( tag: string, @@ -27,9 +41,22 @@ export async function fixture( await el.updateComplete; + for (let hop = 0; hop < MICROTASK_HOPS; hop += 1) { + await Promise.resolve(); + } + + await el.updateComplete; + return el; } +/** + * How many promise hops to drain before the second render. Generously + * over the ~6 a binding actually takes, since an unused hop costs + * nothing and a missing one is a flake. + */ +const MICROTASK_HOPS = 20; + /** Apply properties to a mounted element and wait for the re-render. */ export async function update( el: T, diff --git a/frontend/test/support/wails-fake.ts b/frontend/test/support/wails-fake.ts index eacac94..4948244 100644 --- a/frontend/test/support/wails-fake.ts +++ b/frontend/test/support/wails-fake.ts @@ -1,48 +1,121 @@ /** - * A fake of the two globals the Wails runtime installs: `window.runtime` - * and `window.go`. + * A fake of the one thing v3 routes every runtime call through: the + * IPC transport. * - * Everything in `frontend/wailsjs/` is a pure passthrough — every binding - * is `window['go'][svc][Type][Method](args)` and every runtime call is - * `window.runtime.X(...)`. So faking the globals means tests exercise the - * *real* generated bindings and the *real* store code, and there is no - * second description of the Wails layer free to drift from the first. + * v2 installed two globals, `window.go` and `window.runtime`, and the + * fake replaced both. v3 has neither — the runtime is an npm module and + * the generated bindings call `$Call.ByID(, …)` into it. What it + * does have is better: `setTransport()` is a public, documented seam + * for replacing the transport wholesale, and *every* runtime call goes + * through it — bindings, event emits, window, dialogs, clipboard. So + * this fake is smaller than v2's and covers strictly more, and tests + * still exercise the real generated bindings and the real store code. * - * The event dispatcher mirrors wails v2's - * `internal/frontend/runtime/desktop/events.js` exactly, including - * `maxCallbacks` expiry and the fact that `EventsEmit` notifies local JS - * listeners *before* it notifies Go. + * The event *dispatcher* is no longer mirrored here at all. v2's fake + * reimplemented `desktop/events.js` — the listener list, `maxCallbacks` + * expiry, the reverse iteration — because there was no way to reach the + * real one. v3 exposes `window._wails.dispatchWailsEvent`, which is the + * exact entry point the backend's own push uses, so delivery, expiry + * and the post-dispatch filter are the runtime's real code. What is + * mirrored instead is one line of Go: how `EventManager.Emit` packs its + * variadic data into an event's single `data` field. + * + * One thing v2 did that v3 does not: a frontend `Events.Emit` no longer + * notifies in-page listeners before it notifies Go. It calls the + * backend, and `EventProcessor.Emit` sends the event back out to every + * window — so the emitting page does see it, one round trip later + * rather than synchronously. That is reproduced below. */ +import { Events, objectNames, setTransport } from '@wailsio/runtime'; +// Not public API: `listener.js` has no entry in the package's exports +// map, and this import only resolves because vitest.config.mts aliases +// it. It is the *only* thing here that is not public, and it buys one +// thing — `listenerNames()`, which is what lets a test assert that +// importing a store subscribes it. Registration, dispatch and +// unregistration all go through the public API above. If Wails moves +// the file the import throws at setup, which is loud rather than +// silent. +import { eventListeners } from '@wailsio/listener'; + // =================================================================== -// EVENT DISPATCH (mirrors desktop/events.js) +// METHOD IDS // =================================================================== -type Callback = (...data: unknown[]) => void; +/** + * FNV-1a, 32-bit — `internal/hash.Fnv`, which is what the binding + * generator hashes a method's fully-qualified name with to produce the + * `$Call.ByID` argument. + */ +function fnv1a(value: string): number { + let hash = 0x811c9dc5; -class Listener { - private remaining: number; - - constructor( - readonly eventName: string, - private readonly callback: Callback, - maxCallbacks: number, - ) { - this.remaining = maxCallbacks || -1; + for (const byte of new TextEncoder().encode(value)) { + hash ^= byte; + hash = Math.imul(hash, 0x01000193) >>> 0; } - /** Invokes the callback; returns true if this listener is spent. */ - fire(data: unknown[]): boolean { - this.callback(...data); - - if (this.remaining === -1) return false; - - this.remaining -= 1; - - return this.remaining === 0; - } + return hash >>> 0; } +/** + * Maps a method ID back to the dotted path tests name a binding by + * (`queue.Queue.GetState`). + * + * The map has to be complete rather than built as paths are mentioned, + * because 21 assertions read `calls()` with no argument and compare the + * whole list of paths — including methods no test ever stubbed. + * + * It is derived from the generated tree rather than written down: each + * package's `index.ts` re-exports its service module under the Go type's + * real name (`export { Library }`), which is the one place that casing + * survives — the file is `library/library.ts`, and `frontendutil.ts` + * cannot tell you it is `FrontendUtil`. The directory path under + * `bindings/` *is* the Go import path, so the FQN needs nothing + * hardcoded. + */ +function buildMethodIDs(): Map { + const modules = import.meta.glob( + '../../bindings/yellowjacket/**/index.ts', + { eager: true }, + ) as Record>; + const byID = new Map(); + + for (const [file, mod] of Object.entries(modules)) { + // '../../bindings/yellowjacket/backend/queue/index.ts' + // -> importPath 'yellowjacket/backend/queue', pkg 'queue' + const importPath = file + .replace(/^.*\/bindings\//, '') + .replace(/\/index\.ts$/, ''); + const pkg = importPath.split('/').pop() ?? importPath; + + for (const [typeName, namespace] of Object.entries(mod)) { + if (typeName === 'default' || typeName !== typeName.replace(/\W/g, '')) { + continue; + } + + if (typeof namespace !== 'object' || namespace === null) continue; + + for (const [method, value] of Object.entries(namespace)) { + // An enum is also an object of exports; only a function is a + // bound method. + if (typeof value !== 'function') continue; + + byID.set( + fnv1a(`${importPath}.${typeName}.${method}`), + `${pkg}.${typeName}.${method}`, + ); + } + } + } + + return byID; +} + +// =================================================================== +// THE FAKE +// =================================================================== + /** Records one bound-method invocation. */ export interface BindingCall { /** Dotted path, e.g. `queue.Queue.SetQueue`. */ @@ -52,81 +125,73 @@ export interface BindingCall { type StubValue = unknown | ((...args: unknown[]) => unknown); +type Callback = (...data: unknown[]) => void; + class WailsFake { - private listeners = new Map(); - private stubs = new Map(); + private readonly stubs = new Map(); + private methodIDs = new Map(); /** Every bound-method call made since the last `reset()`. */ readonly calls: BindingCall[] = []; - /** Every runtime (non-binding) call, e.g. `WindowSetTitle`. */ + /** Every runtime (non-binding) call, e.g. `Window.SetTitle`. */ readonly runtimeCalls: BindingCall[] = []; - // -- listener registry -- - - on(eventName: string, callback: Callback, maxCallbacks: number): () => void { - const listener = new Listener(eventName, callback, maxCallbacks); - const existing = this.listeners.get(eventName); - - if (existing) { - existing.push(listener); - } else { - this.listeners.set(eventName, [listener]); - } - - return () => this.off(eventName, listener); + install(): void { + this.methodIDs = buildMethodIDs(); + setTransport({ call: (object, method, _window, args) => + this.route(object, method, args) }); } - private off(eventName: string, listener: Listener): void { - const list = this.listeners.get(eventName); + // -- listeners -- - if (!list) return; - - const idx = list.indexOf(listener); - - if (idx >= 0) list.splice(idx, 1); - if (list.length === 0) this.listeners.delete(eventName); + /** + * Registers a listener through the real runtime, so a test's own + * listener expires and unregisters exactly as a store's does. The + * only translation is the callback shape: the runtime hands over a + * WailsEvent, and this tier's callers want the payload. + */ + on(eventName: string, callback: Callback, maxCallbacks: number): () => void { + return Events.OnMultiple( + eventName, + (event) => { callback(event.data); }, + maxCallbacks, + ); } offNamed(eventName: string, ...more: string[]): void { - for (const name of [eventName, ...more]) { - this.listeners.delete(name); - } + Events.Off(eventName, ...more); } offAll(): void { - this.listeners.clear(); - } - - /** - * Deliver an event exactly as the backend push does. Iterates in - * reverse and drops spent listeners, like `notifyListeners`. - */ - notify(eventName: string, data: unknown[]): void { - const list = this.listeners.get(eventName); - - if (!list || list.length === 0) return; - - const snapshot = list.slice(); - - for (let i = snapshot.length - 1; i >= 0; i -= 1) { - const listener = snapshot[i]; - - if (!listener) continue; - - if (listener.fire(data)) snapshot.splice(i, 1); - } - - if (snapshot.length === 0) { - this.listeners.delete(eventName); - } else { - this.listeners.set(eventName, snapshot); - } + Events.OffAll(); } /** Names with at least one live listener — useful for assertions. */ listenerNames(): string[] { - return [...this.listeners.keys()].sort(); + return [...eventListeners.keys()].sort(); + } + + /** + * Deliver an event exactly as the backend push does, through the + * runtime's own dispatcher. + * + * The packing mirrors `application.EventManager.Emit`: no data at all + * is null, one value is that value, and more than one is the slice. + * Getting this wrong is invisible in the fake and shows up as a store + * reading `undefined` off its payload. + */ + notify(eventName: string, data: unknown[]): void { + const wails = (window as unknown as { + _wails?: { dispatchWailsEvent?: (e: unknown) => void }; + })._wails; + + let payload: unknown = null; + + if (data.length === 1) payload = data[0]; + else if (data.length > 1) payload = data; + + wails?.dispatchWailsEvent?.({ name: eventName, data: payload }); } // -- binding stubs -- @@ -135,7 +200,55 @@ class WailsFake { this.stubs.set(path, value); } - invoke(path: string, args: unknown[]): Promise { + /** + * Routes one transport call. + * + * `objectNames.Call` is a bound method; `objectNames.Events` is a + * frontend emit; everything else — window, dialogs, clipboard, + * screens — is recorded and answers undefined, which is what v2's + * `window.runtime` proxy did for the same surface. + */ + private route(object: number, method: number, args: unknown): Promise { + if (object === objectNames.Call) { + const { methodID, args: callArgs } = + (args ?? {}) as { methodID?: number; args?: unknown[] }; + + return this.invokeID(methodID, callArgs ?? []); + } + + if (object === objectNames.Events) { + const event = (args ?? {}) as { name?: string; data?: unknown }; + + this.runtimeCalls.push({ + path: `EventsEmit:${event.name}`, + args: event.data === undefined ? [] : [event.data], + }); + + // The backend re-broadcasts a custom event to every window, + // including the one that emitted it — so the page does see its + // own emit, a round trip later rather than synchronously. The + // microtask is that round trip. + queueMicrotask(() => { + this.notify(event.name ?? '', event.data === undefined ? [] : [event.data]); + }); + + return Promise.resolve(undefined); + } + + const name = OBJECT_LABELS.get(object) ?? `object${object}`; + + this.runtimeCalls.push({ path: `${name}.${method}`, args: [args] }); + + return Promise.resolve(undefined); + } + + private invokeID(methodID: number | undefined, args: unknown[]): Promise { + // An unmapped ID means the derivation above missed a method, not + // that the call did not happen. Recording it as `#` fails the + // assertion that names it, which is the right kind of loud. + const path = (methodID !== undefined && this.methodIDs.get(methodID)) + || `#${methodID}`; + this.calls.push({ path, args }); const stub = this.stubs.get(path); @@ -145,21 +258,17 @@ class WailsFake { // bridge: a Go method returning an error rejects, it does not // throw synchronously into the caller. try { - return Promise.resolve( - (stub as (...a: unknown[]) => unknown)(...args), - ); + return Promise.resolve((stub as (...a: unknown[]) => unknown)(...args)); } catch (err) { - return Promise.reject(err instanceof Error ? err : new Error(String(err))); + return Promise.reject( + err instanceof Error ? err : new Error(String(err)), + ); } } return Promise.resolve(stub); } - recordRuntime(path: string, args: unknown[]): void { - this.runtimeCalls.push({ path, args }); - } - /** Clears recorded calls and stubs. Listeners survive — the store * singletons that registered them are never re-imported. */ reset(): void { @@ -169,75 +278,23 @@ class WailsFake { } } +/** Reverse of `objectNames`, for labelling a recorded runtime call. */ +const OBJECT_LABELS = new Map( + Object.entries(objectNames).map(([name, id]) => [id as number, name]), +); + // =================================================================== -// GLOBAL INSTALLATION +// INSTALLATION // =================================================================== export const wails = new WailsFake(); -/** A `window.go` that materialises `svc.Type.Method` lazily. */ -function makeGoProxy(): unknown { - const level = (prefix: string): unknown => - new Proxy(function () {} as unknown as Record, { - get(_target, prop: string | symbol) { - if (typeof prop !== 'string') return undefined; - - return level(prefix ? `${prefix}.${prop}` : prop); - }, - apply(_target, _thisArg, args: unknown[]) { - return wails.invoke(prefix, args); - }, - }); - - return level(''); -} - -/** A `window.runtime` with real event plumbing and recorded no-ops - * for everything else (window, clipboard, browser, log). */ -function makeRuntimeProxy(): unknown { - const real: Record = { - EventsOnMultiple: (name: string, cb: Callback, max: number) => - wails.on(name, cb, max), - EventsOn: (name: string, cb: Callback) => wails.on(name, cb, -1), - EventsOnce: (name: string, cb: Callback) => wails.on(name, cb, 1), - EventsOff: (name: string, ...more: string[]) => - wails.offNamed(name, ...more), - EventsOffAll: () => wails.offAll(), - // The real runtime notifies local JS listeners first, then Go. - EventsEmit: (name: string, ...data: unknown[]) => { - wails.recordRuntime(`EventsEmit:${name}`, data); - wails.notify(name, data); - }, - }; - - return new Proxy(real, { - get(target, prop: string | symbol) { - if (typeof prop !== 'string') return undefined; - if (prop in target) return target[prop]; - - return (...args: unknown[]) => { - wails.recordRuntime(prop, args); - - return undefined; - }; - }, - }); -} - -declare global { - interface Window { - go: unknown; - runtime: unknown; - } -} - /** * Installs the fake. Must run before any module that imports a store, - * because the store singletons call `EventsOn` in their constructors at - * import time. `setupFiles` runs before test modules, which is exactly - * the window we need. + * because the store singletons call `EventsOn` and load from the + * backend in their constructors at import time. `setupFiles` runs + * before test modules, which is exactly the window we need. */ export function installWailsFake(): void { - window.go = makeGoProxy(); - window.runtime = makeRuntimeProxy(); + wails.install(); } diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index a668663..282e6c0 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -30,6 +30,7 @@ "@assets/*": ["./src/assets/*"], "@pages/*": ["./src/pages/*"], "@runtime/*": ["./src/wails/*"], + "@wailsio/listener": ["./node_modules/@wailsio/runtime/types/listener.d.ts"], "@utils/*": ["./src/utils/*"], "@store/*": ["./src/store/*"], "@test/*": ["./test/*"] diff --git a/frontend/vitest.config.mts b/frontend/vitest.config.mts index 6bdbc7b..b8e0686 100644 --- a/frontend/vitest.config.mts +++ b/frontend/vitest.config.mts @@ -43,6 +43,13 @@ export default mergeConfig( resolve: { alias: { '@test': path.resolve(__dirname, 'test'), + // The runtime's listener registry, which its package exports + // map does not expose. wails-fake.ts needs it to answer "did + // importing a store subscribe it"; see the note there. + '@wailsio/listener': path.resolve( + __dirname, + 'node_modules/@wailsio/runtime/dist/listener.js', + ), }, }, // Pre-bundle what the components pull in, or Vite discovers it @@ -58,6 +65,11 @@ export default mergeConfig( // one the first time a component under test imports it. '@awesome.me/webawesome/dist/components/*/*.js', '@awesome.me/webawesome/dist/webawesome.js', + // The runtime every generated binding imports. Left out, Vite + // discovers it from the first binding module a suite touches + // and reloads the page — which surfaces as several suites + // failing to import at all while each passes on its own. + '@wailsio/runtime', ], }, }),