feat(wails): rebuild the Vitest fake on v3's transport seam
v2 installed two globals and the fake replaced both. v3 has neither — the runtime is an npm module and the generated bindings call into it. What it has instead is better: setTransport() is a public seam for replacing the IPC transport, and *every* runtime call goes through it, so the fake is smaller than v2's and covers strictly more. The event dispatcher is no longer mirrored 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; emit() now goes through window._wails.dispatchWailsEvent, which is the entry point the backend's own push uses. What is mirrored instead is one line of Go: how EventManager.Emit packs variadic data into an event's single data field. Registration and unregistration are the public Events API. The one non-public thing left is the listener registry, aliased in vitest.config.mts and used only by listenerNames() — a test asks whether importing a store subscribed it, which nothing public can answer. A binding carries a method ID, not a name, so the fake derives the ID -> path map from the generated tree: FNV-1a over the FQN, with the Go type's casing recovered from each package's index.ts, which is the only place it survives (library/library.ts cannot tell you it is FrontendUtil). The map has to be complete rather than lazy because 21 assertions read calls() with no argument and compare the whole list. Two things had to move that are not the fake. fixture() drains microtasks between two renders: a v3 binding settles several hops later than v2's, and tests were already written as though fixture() meant "mounted and loaded". Microtasks and not a timer, which would hang under the suites that install fake ones. tracklist-store keeps its defaults on an empty answer instead of emptying the column list. GetTrackListColumns substitutes DefaultColumns only when the whole config section is missing; a section that exists with no columns returns nothing. Until now this was accidental — the binding was typed Column[], an absent answer arrived as undefined, and .map threw into the catch. 757 tests pass across all 63 files. They are run in batches: a single browser session dies partway through the 58 it queues, which reproduces unchanged at the pre-migration commit and is a resource limit on this machine rather than anything here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UDCbcCZQepnpSQYJ6SxxZm
This commit is contained in:
@@ -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 `#<id>`
|
||||
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 `<queue-panel>` 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.
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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<T extends LitElement>(
|
||||
tag: string,
|
||||
@@ -27,9 +41,22 @@ export async function fixture<T extends LitElement>(
|
||||
|
||||
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<T extends LitElement>(
|
||||
el: T,
|
||||
|
||||
+215
-158
@@ -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(<id>, …)` 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<number, string> {
|
||||
const modules = import.meta.glob(
|
||||
'../../bindings/yellowjacket/**/index.ts',
|
||||
{ eager: true },
|
||||
) as Record<string, Record<string, unknown>>;
|
||||
const byID = new Map<number, string>();
|
||||
|
||||
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<string, Listener[]>();
|
||||
private stubs = new Map<string, StubValue>();
|
||||
private readonly stubs = new Map<string, StubValue>();
|
||||
private methodIDs = new Map<number, string>();
|
||||
|
||||
/** 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<unknown> {
|
||||
/**
|
||||
* 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<unknown> {
|
||||
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<unknown> {
|
||||
// An unmapped ID means the derivation above missed a method, not
|
||||
// that the call did not happen. Recording it as `#<id>` 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<number, string>(
|
||||
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<string, unknown>, {
|
||||
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<string, unknown> = {
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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/*"]
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
},
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user