A coding agent could develop this repo's Go packages and could not develop the application: every path to running YellowJacket ended in a blocking GTK window, so 265 bound methods, 46 events, 33 component directories and 13 stores had exactly one form of verification available — `tsc --noEmit`. The unlock is that `wails dev`'s dev server on :34115 serves the real frontend with the real generated bindings against the same Go backend a desktop window attaches to, so a plain Chromium under Xvfb gets a fully functional app. Four test tiers now exist, cheapest first: - `make ui-test` — 313 Vitest tests in a real browser in ~2 s, no app, no backend, no display. Works because `frontend/wailsjs/` is a pure passthrough to `window.go`/`window.runtime`, so faking just those two globals runs the real bindings and the real store code. - `make test` — services in-process, asserting on the payload the frontend would receive, via a new `events.Emit` wrapper. - `make dev-headless` + `playwright-cli` — the real app, driven interactively, with an event bridge on `window.__yjEvents` and a dev-only control surface at `/__test/`. - `make e2e` — 19 of those flows frozen as Playwright specs. `events.Emit(ctx, …)` replaces all 35 direct `runtime.EventsEmit` call sites: wails' `getEvents` `log.Fatalf`s on any context without its runtime, so those paths could not run under test and a background worker could take the app down. Four packages had each hand-rolled the same guard; nine more guarded on `ctx != nil`, which does not help. `TestNoDirectRuntimeEmits` fails the build on a new one. Fixtures are generated, not committed (`make testdata`), and seeds are built by *running the app* — never by hand-writing config and DB rows, which would be a second description of a valid YJ_HOME. `.gitea/workflows/ci.yml` is the first workflow here that tests anything; the other three only package, so `gitea_ci` reported only packaging jobs and misled anyone asking whether a push was healthy. Both jobs were prototyped to green in a bare ubuntu:24.04 container before the YAML was written, which immediately caught `make lint` linting three configurations that nothing builds: all three passes omitted `webkit2_41`, so wails resolved webkit2gtk-4.0 — which Arch still ships and Ubuntu 24.04 dropped. Operational instructions live in `.pi/skills/yellowjacket-dev/`, measured discoveries in `.planning/NOTES.md`, and architecture in `CLAUDE.md` — split by tense, not by topic, because a topical split gives every new fact two plausible homes. `make skill-check` fails a commit if the skill cites a make target that does not exist.
244 lines
6.9 KiB
TypeScript
244 lines
6.9 KiB
TypeScript
/**
|
|
* A fake of the two globals the Wails runtime installs: `window.runtime`
|
|
* and `window.go`.
|
|
*
|
|
* 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.
|
|
*
|
|
* 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.
|
|
*/
|
|
|
|
// ===================================================================
|
|
// EVENT DISPATCH (mirrors desktop/events.js)
|
|
// ===================================================================
|
|
|
|
type Callback = (...data: unknown[]) => void;
|
|
|
|
class Listener {
|
|
private remaining: number;
|
|
|
|
constructor(
|
|
readonly eventName: string,
|
|
private readonly callback: Callback,
|
|
maxCallbacks: number,
|
|
) {
|
|
this.remaining = maxCallbacks || -1;
|
|
}
|
|
|
|
/** 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;
|
|
}
|
|
}
|
|
|
|
/** Records one bound-method invocation. */
|
|
export interface BindingCall {
|
|
/** Dotted path, e.g. `queue.Queue.SetQueue`. */
|
|
path: string;
|
|
args: unknown[];
|
|
}
|
|
|
|
type StubValue = unknown | ((...args: unknown[]) => unknown);
|
|
|
|
class WailsFake {
|
|
private listeners = new Map<string, Listener[]>();
|
|
private stubs = new Map<string, StubValue>();
|
|
|
|
/** Every bound-method call made since the last `reset()`. */
|
|
readonly calls: BindingCall[] = [];
|
|
|
|
/** Every runtime (non-binding) call, e.g. `WindowSetTitle`. */
|
|
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);
|
|
}
|
|
|
|
private off(eventName: string, listener: Listener): void {
|
|
const list = this.listeners.get(eventName);
|
|
|
|
if (!list) return;
|
|
|
|
const idx = list.indexOf(listener);
|
|
|
|
if (idx >= 0) list.splice(idx, 1);
|
|
if (list.length === 0) this.listeners.delete(eventName);
|
|
}
|
|
|
|
offNamed(eventName: string, ...more: string[]): void {
|
|
for (const name of [eventName, ...more]) {
|
|
this.listeners.delete(name);
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
/** Names with at least one live listener — useful for assertions. */
|
|
listenerNames(): string[] {
|
|
return [...this.listeners.keys()].sort();
|
|
}
|
|
|
|
// -- binding stubs --
|
|
|
|
stub(path: string, value: StubValue): void {
|
|
this.stubs.set(path, value);
|
|
}
|
|
|
|
invoke(path: string, args: unknown[]): Promise<unknown> {
|
|
this.calls.push({ path, args });
|
|
|
|
const stub = this.stubs.get(path);
|
|
|
|
if (typeof stub === 'function') {
|
|
// A throwing stub becomes a rejected promise, matching the real
|
|
// 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),
|
|
);
|
|
} catch (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 {
|
|
this.calls.length = 0;
|
|
this.runtimeCalls.length = 0;
|
|
this.stubs.clear();
|
|
}
|
|
}
|
|
|
|
// ===================================================================
|
|
// GLOBAL 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.
|
|
*/
|
|
export function installWailsFake(): void {
|
|
window.go = makeGoProxy();
|
|
window.runtime = makeRuntimeProxy();
|
|
}
|