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,21 @@
|
||||
{
|
||||
"browser": {
|
||||
"browserName": "chromium",
|
||||
"launchOptions": {
|
||||
"channel": "chromium"
|
||||
},
|
||||
"contextOptions": {
|
||||
"viewport": { "width": 1440, "height": 900 }
|
||||
},
|
||||
"initScript": ["init-events.js"]
|
||||
},
|
||||
"testIdAttribute": "data-testid",
|
||||
"outputDir": ".playwright-cli",
|
||||
"console": {
|
||||
"level": "warning"
|
||||
},
|
||||
"timeouts": {
|
||||
"action": 10000,
|
||||
"navigation": 30000
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
/*
|
||||
* YellowJacket harness bridge — installed as a Playwright initScript, so
|
||||
* it runs in every page *before* any application script.
|
||||
*
|
||||
* Why this file exists: half of what this app does is push-driven. Scan
|
||||
* progress, job updates, download progress, WantedListChanged and 40-odd
|
||||
* other events arrive from Go whenever they arrive. An assertion that
|
||||
* sleeps and hopes is flaky; an assertion that awaits the event is not.
|
||||
*
|
||||
* Three things it provides on `window.__yjEvents`:
|
||||
*
|
||||
* record every backend -> frontend event, in order, with payloads
|
||||
* wait a promise that settles on a matching event (or rejects
|
||||
* with the list of events that *did* arrive, which is the
|
||||
* single most useful failure message this harness can give)
|
||||
* call a bound Go method that is guaranteed to settle: a binding
|
||||
* invoked with wrong argument types makes the backend log
|
||||
* "error parsing arguments" and never fire the callback, so
|
||||
* the in-page promise hangs forever. Timing out here fixes
|
||||
* that once instead of in every eval.
|
||||
*
|
||||
* WHERE IT HOOKS. Not EventsOn. Every backend event enters the page
|
||||
* at exactly one place — wails' ipc_websocket.js does
|
||||
*
|
||||
* case "n": window.wails.EventsNotify(message)
|
||||
*
|
||||
* and EventsNotify fans out to listeners from there. Wrapping that
|
||||
* single choke point captures all 46 events whether or not the app
|
||||
* subscribes to them, and needs one wrap rather than 46.
|
||||
*
|
||||
* `window.wails` does not exist yet when this script runs, so we install
|
||||
* an accessor on `window` and wrap at assignment time (wails' main.js
|
||||
* does a plain `window.wails = {...}`), then collapse the accessor back
|
||||
* to a data property so nothing downstream can tell.
|
||||
*
|
||||
* INSTALL EXACTLY ONCE. Listeners registered by one `eval` survive into
|
||||
* the next, so a recorder that re-registers double-counts. Tests call
|
||||
* `__yjEvents.reset()`; they never re-install.
|
||||
*/
|
||||
(() => {
|
||||
if (window.__yjEvents) {
|
||||
return;
|
||||
}
|
||||
|
||||
const LIMIT = 2000;
|
||||
|
||||
let seq = 0;
|
||||
const log = [];
|
||||
const waiters = new Set();
|
||||
|
||||
const summarize = () => {
|
||||
const counts = {};
|
||||
for (const e of log) {
|
||||
counts[e.name] = (counts[e.name] || 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
};
|
||||
|
||||
const record = (name, data, dir) => {
|
||||
const entry = { seq: ++seq, name, data, dir, t: Date.now() };
|
||||
log.push(entry);
|
||||
if (log.length > LIMIT) {
|
||||
log.splice(0, log.length - LIMIT);
|
||||
}
|
||||
for (const w of Array.from(waiters)) {
|
||||
let hit = false;
|
||||
try {
|
||||
hit = w.test(entry);
|
||||
} catch {
|
||||
hit = false;
|
||||
}
|
||||
if (hit) {
|
||||
waiters.delete(w);
|
||||
clearTimeout(w.timer);
|
||||
w.resolve(entry);
|
||||
}
|
||||
}
|
||||
return entry;
|
||||
};
|
||||
|
||||
// `name` is a string, or "*" for any event. `match` is an optional
|
||||
// predicate over (data, entry) — only usable from an eval'd function,
|
||||
// which is how every harness call is written anyway.
|
||||
const makeTest = (name, match) => (entry) => {
|
||||
if (name && name !== "*" && entry.name !== name) {
|
||||
return false;
|
||||
}
|
||||
return match ? !!match(entry.data, entry) : true;
|
||||
};
|
||||
|
||||
const api = {
|
||||
version: 1,
|
||||
|
||||
/** Every recorded event, oldest first. */
|
||||
get log() {
|
||||
return log.slice();
|
||||
},
|
||||
|
||||
/** The sequence number of the most recent event. */
|
||||
get seq() {
|
||||
return seq;
|
||||
},
|
||||
|
||||
/** Drop the buffer. Does NOT touch the recorder or waiters. */
|
||||
reset() {
|
||||
const n = log.length;
|
||||
log.length = 0;
|
||||
return n;
|
||||
},
|
||||
|
||||
/** Every recorded event, optionally filtered by name. */
|
||||
all(name) {
|
||||
return name ? log.filter((e) => e.name === name) : log.slice();
|
||||
},
|
||||
|
||||
/** How many of `name` (or of everything) have arrived. */
|
||||
count(name) {
|
||||
return this.all(name).length;
|
||||
},
|
||||
|
||||
/** The most recent matching event, or null. */
|
||||
last(name) {
|
||||
const hits = this.all(name);
|
||||
return hits.length ? hits[hits.length - 1] : null;
|
||||
},
|
||||
|
||||
/** Everything after a sequence number — pairs with `.seq`. */
|
||||
since(n) {
|
||||
return log.filter((e) => e.seq > n);
|
||||
},
|
||||
|
||||
/** name -> count, for "what actually happened?" */
|
||||
names() {
|
||||
return summarize();
|
||||
},
|
||||
|
||||
/**
|
||||
* Settle on the next (or already-buffered) matching event.
|
||||
*
|
||||
* await __yjEvents.wait('LibraryScanComplete', { timeoutMs: 60000 })
|
||||
* await __yjEvents.wait('JobsChanged', { match: (d) => d.length > 0 })
|
||||
*
|
||||
* Rejects on timeout with the names that did arrive, because
|
||||
* "timed out waiting for X" without that list is a dead end.
|
||||
*/
|
||||
wait(name, opts) {
|
||||
const o = opts || {};
|
||||
const test = makeTest(name, o.match);
|
||||
const since = o.since || 0;
|
||||
|
||||
for (const entry of log) {
|
||||
if (entry.seq > since && test(entry)) {
|
||||
return Promise.resolve(entry);
|
||||
}
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const w = { test, resolve };
|
||||
w.timer = setTimeout(() => {
|
||||
waiters.delete(w);
|
||||
reject(
|
||||
new Error(
|
||||
`__yjEvents.wait(${JSON.stringify(name)}) timed out after ` +
|
||||
`${o.timeoutMs || 5000}ms; events seen: ` +
|
||||
JSON.stringify(summarize()),
|
||||
),
|
||||
);
|
||||
}, o.timeoutMs || 5000);
|
||||
waiters.add(w);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Resolve when the backend is actually answering calls — not
|
||||
* when the DOM is ready, which is earlier and lies.
|
||||
*/
|
||||
async ready(timeoutMs) {
|
||||
const deadline = Date.now() + (timeoutMs || 15000);
|
||||
for (;;) {
|
||||
if (window.go?.queue?.Queue?.GetState) {
|
||||
try {
|
||||
await api.call("queue.Queue.GetState", [], 2000);
|
||||
return true;
|
||||
} catch {
|
||||
/* backend not up yet */
|
||||
}
|
||||
}
|
||||
if (Date.now() > deadline) {
|
||||
throw new Error("__yjEvents.ready timed out");
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Call a bound Go method by dotted path, with a timeout.
|
||||
*
|
||||
* await __yjEvents.call('player.Player.SetVolume', [42])
|
||||
*
|
||||
* A binding called with the wrong argument types never fires its
|
||||
* callback — the reason appears only in .dev/app.log. Without a
|
||||
* timeout the caller waits forever; with one it gets told where
|
||||
* to look.
|
||||
*/
|
||||
call(path, args, timeoutMs) {
|
||||
const parts = String(path).split(".");
|
||||
let fn = window.go;
|
||||
for (const p of parts) {
|
||||
fn = fn?.[p];
|
||||
}
|
||||
if (typeof fn !== "function") {
|
||||
return Promise.reject(
|
||||
new Error(`__yjEvents.call: no such binding: ${path}`),
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.race([
|
||||
Promise.resolve(fn(...(args || []))),
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(
|
||||
() =>
|
||||
reject(
|
||||
new Error(
|
||||
`__yjEvents.call(${path}) did not settle in ` +
|
||||
`${timeoutMs || 10000}ms — almost always wrong ` +
|
||||
`argument types; check .dev/app.log for ` +
|
||||
`"error parsing arguments"`,
|
||||
),
|
||||
),
|
||||
timeoutMs || 10000,
|
||||
),
|
||||
),
|
||||
]);
|
||||
},
|
||||
};
|
||||
|
||||
Object.defineProperty(window, "__yjEvents", {
|
||||
value: api,
|
||||
configurable: false,
|
||||
enumerable: false,
|
||||
writable: false,
|
||||
});
|
||||
|
||||
// Wrap `obj[method]` once, routing every invocation through `tap`.
|
||||
const wrap = (obj, method, tap) => {
|
||||
const original = obj[method];
|
||||
if (typeof original !== "function" || original.__yjWrapped) {
|
||||
return;
|
||||
}
|
||||
const wrapped = function (...args) {
|
||||
try {
|
||||
tap(args);
|
||||
} catch {
|
||||
/* a broken recorder must never break the app */
|
||||
}
|
||||
return original.apply(this, args);
|
||||
};
|
||||
wrapped.__yjWrapped = true;
|
||||
obj[method] = wrapped;
|
||||
};
|
||||
|
||||
// Install an accessor that wraps on first assignment, then collapses
|
||||
// back into an ordinary property.
|
||||
const hookOnAssign = (name, onAssign) => {
|
||||
let value;
|
||||
Object.defineProperty(window, name, {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get: () => value,
|
||||
set: (v) => {
|
||||
value = v;
|
||||
try {
|
||||
onAssign(v);
|
||||
} catch {
|
||||
/* ditto */
|
||||
}
|
||||
Object.defineProperty(window, name, {
|
||||
value: v,
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
writable: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Inbound: every backend -> frontend event.
|
||||
hookOnAssign("wails", (w) => {
|
||||
wrap(w, "EventsNotify", ([message]) => {
|
||||
const parsed = JSON.parse(message);
|
||||
record(parsed.name, parsed.data, "in");
|
||||
});
|
||||
});
|
||||
|
||||
// Outbound: events the frontend emits, so a flow that round-trips
|
||||
// through Go is legible from one buffer.
|
||||
hookOnAssign("runtime", (r) => {
|
||||
wrap(r, "EventsEmit", (args) => {
|
||||
record(args[0], args.slice(1), "out");
|
||||
});
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user