feat(harness): agent-drivable dev harness and CI that gates
Build & publish Arch package / arch-package (push) Successful in 2m8s
CI / check (push) Failing after 1m56s
CI / e2e (push) Skipped
Search index maintenance / maintain-index (push) Successful in 13s

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:
2026-08-10 23:20:42 -04:00
parent 65333857e2
commit 5ca6cad45a
117 changed files with 14585 additions and 262 deletions
+71
View File
@@ -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();
}
+123
View File
@@ -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);
}
+243
View File
@@ -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();
}