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
This commit is contained in:
2026-08-14 20:58:20 -04:00
co-authored by Claude Opus 5
parent 60779c41c3
commit deb3f3da7e
22 changed files with 728 additions and 328 deletions
+8 -10
View File
@@ -16,6 +16,14 @@ import type { Page } from '@playwright/test';
* none, so Play was wired, labelled correctly, clicked cleanly and
* queued **nothing**. Every component test still passed.
*/
// There is no test for the tracklist legend, and there should not be:
// `dcc40b1` inverted the mark — rows *not* in the library are dimmed in
// place and nothing marks the ones that are — and deleted
// `.tracklist-legend` with it. The spec asserting it survived that
// commit and has been failing on main ever since. What replaced it
// (the dimming, and the `aria-disabled` that carries it to anyone not
// seeing the page) is covered at the component tier, in
// frontend/test/components/album-actions.test.ts.
test.describe('playing an album from its page', () => {
test.beforeEach(async ({ app }) => {
await openFirstAlbum(app);
@@ -55,16 +63,6 @@ test.describe('playing an album from its page', () => {
await expect.poll(() => queueLength(app)).toBe(before * 2);
});
test('the ticks against the tracks have a legend', async ({ app }) => {
// `H-13` calls them unexplained. They were never *unlabelled* — the
// indicator has carried a title and an aria-label reading
// "Track “X” is in your library" all along — but a sighted user
// scanning the page got a column of green circles and no key.
await expect(
app.locator('explore-album-details').locator('.tracklist-legend'),
).toContainText('in your library');
});
test('the ticks are badges, not keyboard stops', async ({ app }) => {
// Every one of them was a <button> whose click handler was a
// stopPropagation() and a comment saying to wire up the download
+25 -10
View File
@@ -15,12 +15,18 @@ import {
*/
test.describe('harness', () => {
test('the app is the real app, not a mock', async ({ app }) => {
// All 11 bound services land on window.go through the dev server.
const services = await app.evaluate(() => Object.keys(window.go));
// v2 landed every bound service on `window.go`, so "is this real"
// could be asked of an object. v3 has no such global — the
// bindings are ordinary bundled modules — so the question is asked
// of the runtime instead, which is a better question anyway: the
// real runtime is loaded, and it answers for real methods and
// refuses invented ones.
const runtime = await app.evaluate(() => ({
dispatch: typeof window._wails?.dispatchWailsEvent,
client: typeof window._wails?.clientId,
}));
expect(services).toEqual(
expect.arrayContaining(['queue', 'player', 'library', 'explore']),
);
expect(runtime).toEqual({ dispatch: 'function', client: 'string' });
const state = await callBinding<{ tracks: unknown[] }>(
app,
@@ -28,6 +34,12 @@ test.describe('harness', () => {
);
expect(state).toHaveProperty('tracks');
const unknown = await app
.evaluate(() => window.__yjEvents.call('queue.Queue.Nope', [], 2_000))
.catch((err: Error) => err.message);
expect(unknown).toContain('unknown bound method');
});
test('backend events are recorded, in order, with payloads', async ({
@@ -58,10 +70,12 @@ test.describe('harness', () => {
});
test('a binding called with wrong types fails fast', async ({ app }) => {
// player.UserVolume is an int. Passing a float makes the backend
// log "error parsing arguments" and never fire the callback; without
// a timeout the promise never settles and the spec hangs until the
// suite gives up.
// player.UserVolume is an int. Under v2 a float made the backend
// log "error parsing arguments" and never fire the callback, so
// this asserted that the harness's own timeout fired — the failure
// was visible only because the harness invented a deadline. v3
// answers 422 with a TypeError naming the argument, so the
// assertion is now on the backend's own words.
const failure = await app.evaluate(async () => {
try {
await window.__yjEvents.call(
@@ -76,7 +90,8 @@ test.describe('harness', () => {
}
});
expect(failure).toContain('did not settle');
expect(failure).toContain('TypeError');
expect(failure).toContain('player.UserVolume');
});
test('the control surface is mounted and seeded', async ({ testctl }) => {
+25 -25
View File
@@ -1,4 +1,13 @@
import { test, expect, resetEvents, callBinding } from '../support/fixtures.js';
import type { Page } from '@playwright/test';
import {
test,
expect,
resetEvents,
callBinding,
bindingCalls,
NO_QUEUE_SOURCE,
} from '../support/fixtures.js';
/**
* Finishing a track is cheap, and does not disturb the user.
@@ -19,23 +28,17 @@ import { test, expect, resetEvents, callBinding } from '../support/fixtures.js';
/** Long enough for a fixture track (26 s) to finish by itself. */
const FINISH_TIMEOUT = 60_000;
/** Instrument the library bindings so "was anything refetched" is a fact. */
const COUNT_LIBRARY_CALLS = `(() => {
const w = window;
if (w.__yjCalls) { w.__yjCalls.length = 0; return; }
w.__yjCalls = [];
const lib = w.go.library.Library;
for (const key of Object.keys(lib)) {
const fn = lib[key];
if (typeof fn !== 'function' || fn.__counted) continue;
const wrapped = function (...args) {
w.__yjCalls.push(key);
return fn.apply(this, args);
};
wrapped.__counted = true;
lib[key] = wrapped;
}
})()`;
/**
* "Was anything refetched" is a fact, not an inference.
*
* This used to wrap every method on `window.go.library.Library` in
* place. v3 has no such object, and does not need one: the harness
* bridge records every binding call off the single POST they all go
* through, so the question is answered by reading that log rather than
* by instrumenting a target first. `resetEvents` clears it.
*/
const libraryCalls = async (app: Page): Promise<string[]> =>
(await bindingCalls(app)).filter((c) => c.startsWith('library.Library.'));
/**
* Select rows by dispatching on the row rather than clicking it.
@@ -82,8 +85,7 @@ test.describe('a finished track', () => {
expect(paths.length).toBeGreaterThanOrEqual(2);
await callBinding(app, 'queue.Queue.SetQueue', [paths, 0, false]);
await app.evaluate(COUNT_LIBRARY_CALLS);
await callBinding(app, 'queue.Queue.SetQueue', [paths, 0, false, NO_QUEUE_SOURCE]);
await resetEvents(app);
await callBinding(app, 'queue.Queue.PlayIndex', [0]);
@@ -111,12 +113,10 @@ test.describe('a finished track', () => {
'a play emitted the retag event, which invalidates every cache',
).toBe(0);
const refetched = await app.evaluate(
() => (window as unknown as { __yjCalls: string[] }).__yjCalls,
);
const refetched = await libraryCalls(app);
expect(
refetched.filter((c) => c.startsWith('GetAll')),
refetched.filter((c) => c.startsWith('library.Library.GetAll')),
'a play refetched a collection',
).toEqual([]);
@@ -136,7 +136,7 @@ test.describe('a finished track', () => {
const paths = await app.evaluate(firstPaths, 2);
await callBinding(app, 'queue.Queue.SetQueue', [paths, 0, false]);
await callBinding(app, 'queue.Queue.SetQueue', [paths, 0, false, NO_QUEUE_SOURCE]);
await resetEvents(app);
await callBinding(app, 'queue.Queue.PlayIndex', [0]);
+3 -1
View File
@@ -7,6 +7,7 @@ import {
resetEvents,
waitForEvent,
LONG_TRACK,
NO_QUEUE_SOURCE,
} from '../support/fixtures.js';
import type { Page } from '@playwright/test';
@@ -161,6 +162,7 @@ test.describe('a finished queue keeps its context', () => {
[rows[0].file_path],
0,
false,
NO_QUEUE_SOURCE,
]);
await waitForEvent(app, 'QueueChanged');
await callBinding(app, 'queue.Queue.Play');
@@ -199,7 +201,7 @@ test.describe('a track that will not play says so', () => {
try {
await callBinding(app, 'queue.Queue.Clear');
await resetEvents(app);
await callBinding(app, 'queue.Queue.SetQueue', [paths, 0, false]);
await callBinding(app, 'queue.Queue.SetQueue', [paths, 0, false, NO_QUEUE_SOURCE]);
await waitForEvent(app, 'QueueChanged');
await callBinding(app, 'queue.Queue.Play');
+7 -2
View File
@@ -1,4 +1,9 @@
import { test, expect, callBinding } from '../support/fixtures.js';
import {
test,
expect,
callBinding,
NO_QUEUE_SOURCE,
} from '../support/fixtures.js';
import type { Page } from '@playwright/test';
/**
@@ -35,7 +40,7 @@ async function queueFourAndOpen(app: Page): Promise<string[]> {
return (tracks as { FilePath: string }[]).slice(0, 4).map((t) => t.FilePath);
});
await callBinding(app, 'queue.Queue.SetQueue', [paths, 0, false]);
await callBinding(app, 'queue.Queue.SetQueue', [paths, 0, false, NO_QUEUE_SOURCE]);
// A closed panel renders no list at all, so there is no row to focus.
await app.locator('#queue-button').click();
+8 -2
View File
@@ -1,4 +1,10 @@
import { test, expect, callBinding, waitForEvent } from '../support/fixtures.js';
import {
test,
expect,
callBinding,
waitForEvent,
NO_QUEUE_SOURCE,
} from '../support/fixtures.js';
import type { Page } from '@playwright/test';
/**
@@ -64,7 +70,7 @@ async function playTheLongOne(app: Page): Promise<void> {
expect(paths.length).toBeGreaterThan(0);
await callBinding(app, 'queue.Queue.SetQueue', [paths, 0, false]);
await callBinding(app, 'queue.Queue.SetQueue', [paths, 0, false, NO_QUEUE_SOURCE]);
await waitForEvent(app, 'TrackChanged');
// The scroll cycle is armed 1500 ms after the geometry is measured,
+33 -23
View File
@@ -199,39 +199,49 @@ function addRequest(
}
/**
* Drop any request for this album, through the raw binding.
* Drop any request for this album, over the runtime endpoint.
*
* Deliberately not `callBinding`: that goes through `window.__yjEvents`,
* which only exists on a page the `app` fixture created — a bare
* `browser.newPage()` has no init script, so the bridge is undefined and
* the cleanup throws where nobody is looking. The first version of this
* did exactly that and left the request behind, which failed the *next*
* run of this same spec.
* Deliberately not `callBinding`: that goes through
* `window.__yjEvents`, which only exists on a page the `app` fixture
* created — a bare `browser.newPage()` has no init script, so the
* bridge is undefined and the cleanup throws where nobody is looking.
* The first version of this did exactly that and left the request
* behind, which failed the *next* run of this same spec.
*
* v2's answer was `window.go`, which every page had. v3 has no such
* global, and the version of this that kept reading it did not throw —
* it returned early on `if (!svc)`, which is the same silent cleanup
* with a different cause. A POST to `/wails/runtime` needs neither:
* it is the same request the bundle makes, and any page can make it.
*/
async function clearRequest(page: import('@playwright/test').Page) {
await page.evaluate(async (mbid) => {
const go = (
window as unknown as {
go?: {
download?: {
Service?: {
ListRequests(): Promise<{ id: number; mbid: string }[]>;
RemoveRequest(id: number): Promise<void>;
};
};
};
}
).go;
const call = async (method: string, args: unknown[]) => {
const res = await fetch('/wails/runtime', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
object: 0,
method: 0,
args: {
'call-id': `clear-${method}`,
methodName: `yellowjacket/backend/${method}`,
args,
},
}),
});
const svc = go?.download?.Service;
if (!res.ok) throw new Error(`${method}: ${await res.text()}`);
if (!svc) return;
return res.json();
};
const rows = (await svc.ListRequests()) ?? [];
const rows: { id: number; mbid: string }[] =
(await call('download.Service.ListRequests', [])) ?? [];
for (const row of rows) {
if (row.mbid?.toLowerCase() === mbid.toLowerCase()) {
await svc.RemoveRequest(row.id);
await call('download.Service.RemoveRequest', [row.id]);
}
}
}, MBID);