feat(harness): agent-drivable dev harness and CI that gates
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.
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Helpers on top of the Wails fake: pushing backend events, stubbing
|
||||
* bound methods, and inspecting what the frontend called back.
|
||||
*/
|
||||
import { wails, type BindingCall } from './wails-fake';
|
||||
|
||||
export { wails } from './wails-fake';
|
||||
|
||||
/**
|
||||
* Push a backend event into the page, exactly as `runtime.EventsEmit`
|
||||
* on the Go side would. Extra arguments become the event's data array.
|
||||
*/
|
||||
export function emit(name: string, ...data: unknown[]): void {
|
||||
wails.notify(name, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the return value of a bound method. The path is the one the
|
||||
* generated bindings use — `service.Type.Method`, e.g.
|
||||
* `config.Config.GetShortcuts`.
|
||||
*
|
||||
* A function value is called with the invocation's arguments, so a stub
|
||||
* can vary by input.
|
||||
*/
|
||||
export function stub(path: string, value: unknown): void {
|
||||
wails.stub(path, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a bound method fail, as a Go method returning an error does:
|
||||
* the promise rejects, it does not throw into the caller.
|
||||
*/
|
||||
export function stubFailure(path: string, message = 'backend error'): void {
|
||||
wails.stub(path, () => {
|
||||
throw new Error(message);
|
||||
});
|
||||
}
|
||||
|
||||
/** Every call made to a bound method, in order. */
|
||||
export function calls(path?: string): BindingCall[] {
|
||||
if (path === undefined) return wails.calls.slice();
|
||||
|
||||
return wails.calls.filter((c) => c.path === path);
|
||||
}
|
||||
|
||||
/** The most recent call to `path`, or undefined. */
|
||||
export function lastCall(path: string): BindingCall | undefined {
|
||||
return calls(path).at(-1);
|
||||
}
|
||||
|
||||
/** The argument list of the most recent call to `path`. */
|
||||
export function lastArgs(path: string): unknown[] | undefined {
|
||||
return lastCall(path)?.args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush pending microtasks. Stores coalesce subscriber notification
|
||||
* through `queueMicrotask`, so state is observable immediately but
|
||||
* subscribers are not — anything asserting on a subscriber must await
|
||||
* this first.
|
||||
*/
|
||||
export async function flush(): Promise<void> {
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
}
|
||||
|
||||
/** Clears recorded calls and stubs between tests. */
|
||||
export function resetHarness(): void {
|
||||
wails.reset();
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Mounting helpers for component tests.
|
||||
*
|
||||
* Components are mounted into a real document and queried through their
|
||||
* real (open) shadow roots — nothing here approximates the DOM, which
|
||||
* is the whole reason this tier runs in a browser.
|
||||
*/
|
||||
import type { LitElement } from 'lit';
|
||||
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
|
||||
* non-string values survive.
|
||||
*/
|
||||
export async function fixture<T extends LitElement>(
|
||||
tag: string,
|
||||
props: Record<string, unknown> = {},
|
||||
): Promise<T> {
|
||||
const el = document.createElement(tag) as T;
|
||||
|
||||
Object.assign(el, props);
|
||||
document.body.append(el);
|
||||
mounted.push(el);
|
||||
|
||||
await el.updateComplete;
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
/** Apply properties to a mounted element and wait for the re-render. */
|
||||
export async function update<T extends LitElement>(
|
||||
el: T,
|
||||
props: Record<string, unknown>,
|
||||
): Promise<T> {
|
||||
Object.assign(el, props);
|
||||
el.requestUpdate();
|
||||
await el.updateComplete;
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
/** Remove everything mounted by this module. Called from setup. */
|
||||
export function cleanupFixtures(): void {
|
||||
while (mounted.length > 0) mounted.pop()?.remove();
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// SHADOW DOM QUERIES
|
||||
// ===================================================================
|
||||
|
||||
/** Query one element inside a component's shadow root. */
|
||||
export function shadow<E extends Element = Element>(
|
||||
host: Element,
|
||||
selector: string,
|
||||
): E | null {
|
||||
return host.shadowRoot?.querySelector<E>(selector) ?? null;
|
||||
}
|
||||
|
||||
/** Query all matching elements inside a component's shadow root. */
|
||||
export function shadowAll<E extends Element = Element>(
|
||||
host: Element,
|
||||
selector: string,
|
||||
): E[] {
|
||||
return [...(host.shadowRoot?.querySelectorAll<E>(selector) ?? [])];
|
||||
}
|
||||
|
||||
/** Trimmed text content of the first match, or null if absent. */
|
||||
export function text(host: Element, selector: string): string | null {
|
||||
return shadow(host, selector)?.textContent?.trim() ?? null;
|
||||
}
|
||||
|
||||
/** Trimmed text content of every match. */
|
||||
export function texts(host: Element, selector: string): string[] {
|
||||
return shadowAll(host, selector).map((el) => el.textContent?.trim() ?? '');
|
||||
}
|
||||
|
||||
/** The accessible names of every match, for assertions that mirror
|
||||
* what a screen reader — and a Playwright selector — would see. */
|
||||
export function labels(host: Element, selector: string): string[] {
|
||||
return shadowAll(host, selector).map(
|
||||
(el) => el.getAttribute('aria-label') ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
/** Click something inside a shadow root and let the update settle. */
|
||||
export async function click(
|
||||
host: LitElement,
|
||||
selector: string,
|
||||
): Promise<void> {
|
||||
const target = shadow<HTMLElement>(host, selector);
|
||||
|
||||
if (!target) throw new Error(`no element matching ${selector}`);
|
||||
|
||||
target.click();
|
||||
await host.updateComplete;
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// VISUAL REGRESSION
|
||||
// ===================================================================
|
||||
|
||||
/**
|
||||
* Visual regression is opt-in: `toMatchScreenshot` baselines depend on
|
||||
* font hinting and compositing, so a baseline taken on one machine
|
||||
* fails on another for reasons that have nothing to do with the
|
||||
* component. `make ui-visual` sets YJ_VISUAL=1; the default run
|
||||
* asserts behaviour only.
|
||||
*/
|
||||
export const visualEnabled = import.meta.env['YJ_VISUAL'] === '1';
|
||||
|
||||
/**
|
||||
* Screenshot a component against its baseline, when visual regression
|
||||
* is enabled. A no-op otherwise — deliberately not a skipped test, so
|
||||
* the behavioural assertions around it still run.
|
||||
*/
|
||||
export async function visual(el: Element, name: string): Promise<void> {
|
||||
if (!visualEnabled) return;
|
||||
|
||||
await expect(el).toMatchScreenshot(name);
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
Reference in New Issue
Block a user