Files
yellowjacket/.playwright/init-events.js
yonluandClaude Opus 5 deb3f3da7e feat(wails): move the e2e harness and headless launch onto v3
make e2e is green on chromium: 92 passed. The harness is rebuilt on
what v3 actually offers, and three of the four things it replaced turn
out to be better than what they replaced.

The headless launch is v3's own server mode. scripts/dev-headless.sh
ran a `-tags dev` binary whose app_dev.go parsed -devserver/-assetdir
out of os.Args; that file went with v2, so the harness had no server at
all. `-tags dev,server` is a first-class mode and needs no display, so
Xvfb is gone from the script and from CI.

The bridge hooks two places, neither of them EventsOn. Inbound is
window._wails.dispatchWailsEvent, wrapped by pre-creating the object
the runtime keeps and putting an accessor on the one property.
Outbound is fetch: v3 routes every runtime call through one POST, so
the bridge sees binding calls and event emits from any module, needs no
walk of an object graph, and cannot miss a call made before it looked.

__yjEvents.call posts to that endpoint by method name, so it depends on
nothing in the app's bundle and works on a page with no init script.
That is what lets seed-sandbox.sh drop playwright-cli entirely — it
drove AddLibrary through a browser only because window.go was v2's one
way in — and with it a global npm install and a second Chromium in CI.

measure.mjs and one spec lose their window.go walks and read the
bridge's log instead; e2e/support/method-ids.mjs derives id -> name
from frontend/bindings/ (phase 6b option 1, so it cannot go stale
silently). Plain .mjs because measure.mjs runs under bare node and one
derivation beats two that can disagree.

Four bugs surfaced, and the migration is how.

The cross-service wiring never ran headless. It hung off
Common.ApplicationStarted, which server mode never emits —
setupCommonEvents is an explicit no-op there — so the queue had no
TrackLoader and playing a track changed the queue and then silently did
nothing. It is a service registered last now (backend/startup.go):
services start in registration order, which is the ordering the wiring
needs, in every mode.

Six specs called SetQueue with 3 of its 4 arguments. v2 accepted that
and filled the gap; v3 answers "expects 4 arguments, got 3".

requested-badge's cleanup read window.go and returned early on
`if (!svc)` — the silent cleanup its own comment was written to
prevent, one migration later. It posts to the runtime endpoint now,
which any page can do.

SearchIndex.Search trusted a startup latch, so rows a spec staged
afterwards were unsearchable and three specs passed only when an
earlier one happened to flip it. shelves.go fixed exactly this and left
hasCatalogRows behind; the search path now uses it as the fallback,
with the latch still the fast path.

Two spec edits are deletions of assertions about v2. harness.spec
checked Object.keys(window.go) and that a bad call *hung*; it now
checks the real runtime is loaded and that the backend rejects with a
TypeError naming the argument. album-actions asserted a tracklist
legend that dcc40b1 deleted on main — that spec has been failing since,
and what replaced it is covered in frontend/test/components.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UDCbcCZQepnpSQYJ6SxxZm
2026-08-14 20:58:20 -04:00

420 lines
13 KiB
JavaScript

/*
* 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.
*
* Four 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, by name, over the runtime's own HTTP
* endpoint — no dependence on the app's bundle
* bindings every binding call the *app* made, which is what turns
* "did that refetch the library" from an inference into a
* fact (e2e/perf/measure.mjs labels and reads these)
*
* WHERE IT HOOKS. Two places, and neither is `EventsOn`.
*
* Inbound, `window._wails.dispatchWailsEvent`: v3's runtime assigns it
* at module scope and it is the single point every backend event enters
* the page through, so wrapping it captures all 46 whether or not the
* app subscribes to them. The runtime does
* `window._wails = window._wails || {}`, so this script creates that
* object first and puts an accessor on the *property*, wrapping at
* assignment time — v2 needed the accessor on `window` itself, because
* there the whole object was replaced.
*
* Outbound, `fetch`: v3 routes every runtime call — binding calls, event
* emits, window and dialog calls — through one POST to /wails/runtime.
* There is no global to wrap the way v2's `window.runtime` could be, and
* this is better anyway: it sees calls from any module, needs no walk of
* an object graph, and cannot miss one made before the harness looked.
*
* 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;
// Every bound service in this app lives under this Go module path,
// so specs name a binding the short way — 'queue.Queue.GetState' —
// and this is what makes that the same thing the backend calls
// 'yellowjacket/backend/queue.Queue.GetState'.
const FQN_PREFIX = "yellowjacket/backend/";
// The runtime's own object and method ids (objectNames in
// @wailsio/runtime): 0 is Call, 3 is Events, and method 0 on each is
// CallBinding and Emit respectively.
const OBJECT_CALL = 0;
const OBJECT_EVENTS = 3;
// Captured before the wrap below, and used for the harness's own
// calls: `__yjEvents.call` is this file talking to the backend, not
// the app, and counting it would make "did that action refetch the
// library" answer for the question as well as the app.
const nativeFetch = window.fetch.bind(window);
let seq = 0;
const log = [];
const bindings = [];
const waiters = new Set();
const summarize = () => {
const counts = {};
for (const e of log) {
counts[e.name] = (counts[e.name] || 0) + 1;
}
return counts;
};
/*
* `data` is recorded as the argument list Go emitted, which is the
* shape every spec reads (`ev.data[0]`).
*
* v3's EventManager.Emit packs a variadic call into one field: no
* arguments is null, one is the value itself, more than one is the
* slice. Unpacking that back into a list is exact except for a
* single argument that is itself an array, which is indistinguishable
* from several arguments — an ambiguity v3 introduced and no
* assertion here depends on, since nothing in backend/events emits
* more than one value.
*/
const argsOf = (data) => {
if (data === null || data === undefined) {
return [];
}
return Array.isArray(data) ? data : [data];
};
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: 2,
/** Every recorded event, oldest first. */
get log() {
return log.slice();
},
/** The sequence number of the most recent event. */
get seq() {
return seq;
},
/**
* Every binding call the app made, oldest first. Each is
* { methodID, methodName, start, ms, bytes } — the id is what the
* generated bindings send, and turning it back into a name is
* e2e/perf/measure.mjs's job, which derives the map from
* frontend/bindings/.
*/
get bindings() {
return bindings.slice();
},
/**
* Read the size of every binding response. Off by default: it
* costs a clone-and-read of each body, which only a measurement
* wants to pay. With it off, `bytes` is the Content-Length when
* the server sent one and -1 otherwise.
*/
measureBytes: false,
/** Drop the buffers. Does NOT touch the recorder or waiters. */
reset() {
const n = log.length;
log.length = 0;
bindings.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 (;;) {
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.
*
* await __yjEvents.call('player.Player.SetVolume', [42])
*
* This posts to the runtime's own endpoint rather than reaching
* into the page for a binding function, because v3 has no
* `window.go` and the generated bindings are ordinary bundled
* modules an initScript cannot import. It calls *by name*, which
* the backend resolves the same way it resolves the id the
* bundle sends.
*
* v3 rejects a bad call rather than silently never firing its
* callback the way v2 did — wrong argument types come back as a
* TypeError naming the argument, an unknown method as a
* ReferenceError. The timeout below is therefore a backstop for
* a genuinely hung request, not the mechanism that makes a
* mistake visible.
*/
call(path, args, timeoutMs) {
const request = nativeFetch("/wails/runtime", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-wails-client-id": window._wails?.clientId ?? "",
},
body: JSON.stringify({
object: OBJECT_CALL,
method: 0,
args: {
"call-id": `yj-${Math.random().toString(36).slice(2)}`,
methodName: FQN_PREFIX + String(path),
args: args || [],
},
}),
}).then(async (res) => {
const type = res.headers.get("Content-Type") || "";
const json = type.includes("application/json");
if (!res.ok) {
const body = json ? await res.json() : { message: await res.text() };
throw new Error(
`__yjEvents.call(${path}) failed: ` +
`${body.kind || "Error"}: ${body.message}`,
);
}
return json ? res.json() : res.text();
});
return Promise.race([
request,
new Promise((_, reject) =>
setTimeout(
() =>
reject(
new Error(
`__yjEvents.call(${path}) did not settle in ` +
`${timeoutMs || 10000}ms — the runtime endpoint ` +
`hung, which is not how a bad argument fails; ` +
`check .dev/app.log`,
),
),
timeoutMs || 10000,
),
),
]);
},
};
Object.defineProperty(window, "__yjEvents", {
value: api,
configurable: false,
enumerable: false,
writable: false,
});
// ── Inbound ──────────────────────────────────────────────────────
//
// The runtime keeps whatever `window._wails` already is, so creating
// it here and defining an accessor on the one property we care about
// means the wrap happens the moment the runtime module is evaluated.
window._wails = window._wails || {};
let dispatch;
Object.defineProperty(window._wails, "dispatchWailsEvent", {
configurable: true,
enumerable: true,
get: () => dispatch,
set: (fn) => {
dispatch = function (event) {
try {
record(event?.name, argsOf(event?.data), "in");
} catch {
/* a broken recorder must never break the app */
}
return fn.apply(this, arguments);
};
},
});
// ── Outbound ─────────────────────────────────────────────────────
//
// One POST per runtime call. Only two of the thirteen object ids
// are interesting here; the rest (window, dialogs, clipboard) pass
// through untouched and unrecorded.
window.fetch = function (input, init) {
let call = null;
try {
// The runtime passes a **URL object**, not a string — it
// builds `new URL(runtimeURL())` — and a URL has no `.url`,
// only a Request does. Reading the wrong one matched
// nothing and recorded no calls at all, which looks
// identical to an app that made none.
const url =
input && typeof input === "object" && "url" in input
? input.url
: String(input ?? "");
if (
url.includes("/wails/runtime") &&
init?.method === "POST" &&
typeof init.body === "string"
) {
const body = JSON.parse(init.body);
if (body.object === OBJECT_EVENTS && body.method === 0) {
record(body.args?.name, argsOf(body.args?.data), "out");
} else if (body.object === OBJECT_CALL && body.method === 0) {
call = {
methodID: body.args?.methodID ?? null,
methodName: body.args?.methodName ?? null,
start: performance.now(),
};
}
}
} catch {
/* ditto */
}
const response = nativeFetch(input, init);
if (!call) {
return response;
}
return response.then(async (res) => {
try {
call.ms = performance.now() - call.start;
call.bytes = api.measureBytes
? (await res.clone().text()).length
: Number(res.headers.get("Content-Length") ?? -1);
bindings.push(call);
if (bindings.length > LIMIT) {
bindings.splice(0, bindings.length - LIMIT);
}
} catch {
/* ditto */
}
return res;
});
};
})();