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:
+49
-6
@@ -3,12 +3,26 @@ import { dirname, resolve } from 'node:path';
|
||||
|
||||
import { test as base, expect, type Page } from '@playwright/test';
|
||||
|
||||
import { nameOf } from './method-ids.mjs';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/** The same bridge `playwright-cli` loads, so an exploratory session and
|
||||
* a committed spec see an identical page. */
|
||||
const INIT_SCRIPT = resolve(here, '../../.playwright/init-events.js');
|
||||
|
||||
/**
|
||||
* The fourth argument to `queue.Queue.SetQueue`, for a queue with no
|
||||
* single source — the whole library, or a handful of ad-hoc tracks,
|
||||
* which is what every spec here builds.
|
||||
*
|
||||
* It is passed explicitly because v3 rejects a call with the wrong
|
||||
* argument count (`expects 4 arguments, got 3`) where v2 accepted one
|
||||
* and filled the gap with a zero value. The specs had been three-arg
|
||||
* since the parameter was added; nothing said so.
|
||||
*/
|
||||
export const NO_QUEUE_SOURCE = { type: '', id: 0, label: '' };
|
||||
|
||||
/**
|
||||
* The 90-second fixture track (`cmd/gentestdata`, case `edge-lengths`).
|
||||
*
|
||||
@@ -61,12 +75,13 @@ export async function eventNames(
|
||||
}
|
||||
|
||||
/**
|
||||
* Call a bound Go method with a timeout.
|
||||
* Call a bound Go method by name, over the runtime's own endpoint.
|
||||
*
|
||||
* Wrong argument types make the backend log "error parsing arguments"
|
||||
* and never fire the callback, so an unguarded call hangs until the
|
||||
* whole spec times out with no clue why. This fails in seconds and
|
||||
* says where to look.
|
||||
* v3 rejects a bad call rather than never firing its callback the way
|
||||
* v2 did: a wrong argument type comes back as a TypeError naming the
|
||||
* argument, a wrong count as `expects 4 arguments, got 3`, an unknown
|
||||
* method as a ReferenceError. The timeout is a backstop for a hung
|
||||
* request, not the mechanism that makes a mistake visible.
|
||||
*/
|
||||
export async function callBinding<T = unknown>(
|
||||
page: Page,
|
||||
@@ -81,6 +96,21 @@ export async function callBinding<T = unknown>(
|
||||
) as Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The binding calls the *app* made, newest last, as `pkg.Type.Method`.
|
||||
*
|
||||
* This is what replaces v2's trick of wrapping `window.go` in place:
|
||||
* `.playwright/init-events.js` records every call off the one POST v3
|
||||
* routes them all through, and `method-ids.ts` names them from the
|
||||
* generated tree. It sees calls from any module and cannot miss one
|
||||
* made before a wrapper was installed.
|
||||
*/
|
||||
export async function bindingCalls(page: Page): Promise<string[]> {
|
||||
const calls = await page.evaluate(() => window.__yjEvents.bindings);
|
||||
|
||||
return calls.map(nameOf);
|
||||
}
|
||||
|
||||
/** Thin client for the dev-only /__test/ surface (backend/testctl). */
|
||||
export class TestCtl {
|
||||
constructor(private readonly baseURL: string) {}
|
||||
@@ -166,7 +196,20 @@ declare global {
|
||||
): Promise<YjEvent>;
|
||||
ready(timeoutMs?: number): Promise<boolean>;
|
||||
call(path: string, args?: unknown[], timeoutMs?: number): Promise<any>;
|
||||
/** Every binding call the app itself made; see init-events.js. */
|
||||
bindings: {
|
||||
methodID: number | null;
|
||||
methodName: string | null;
|
||||
start: number;
|
||||
ms: number;
|
||||
bytes: number;
|
||||
}[];
|
||||
measureBytes: boolean;
|
||||
};
|
||||
/** The v3 runtime's own namespace, installed by @wailsio/runtime. */
|
||||
_wails?: {
|
||||
dispatchWailsEvent?: (event: unknown) => void;
|
||||
clientId?: string;
|
||||
};
|
||||
go: Record<string, Record<string, Record<string, (...a: any[]) => any>>>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
|
||||
/**
|
||||
* Turns the method ids a binding call carries back into the names a
|
||||
* spec (or a measurement) wants to read.
|
||||
*
|
||||
* Plain JavaScript, not TypeScript, because `e2e/perf/measure.mjs` runs
|
||||
* under bare `node` and both it and the specs need exactly this map —
|
||||
* and one derivation with a `.mjs` extension is better than two that
|
||||
* can disagree.
|
||||
*
|
||||
* v2 put every bound method on `window.go`, so a harness could walk
|
||||
* that object to wrap or enumerate them. v3 has no such surface: the
|
||||
* generated bindings are ordinary bundled modules calling
|
||||
* `$Call.ByID(<fnv hash of the fully-qualified name>)`, and what
|
||||
* reaches the wire — and therefore what `.playwright/init-events.js`
|
||||
* can record — is the number.
|
||||
*
|
||||
* Plan 009 phase 6b named two ways to get the list back. This is the
|
||||
* preferred one: derive it from `frontend/bindings/`, which is a real
|
||||
* generated tree and is already gated by `make bindings-check`. The
|
||||
* alternative — a hand-maintained list — goes stale silently, which is
|
||||
* the failure mode that check exists to prevent.
|
||||
*
|
||||
* Nothing here hashes anything. The generated source carries the id as
|
||||
* a literal beside the function that sends it, so this reads the two
|
||||
* together rather than recomputing one from the other and hoping the
|
||||
* hash still matches.
|
||||
*/
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const BINDINGS_ROOT = resolve(here, '../../frontend/bindings/yellowjacket');
|
||||
|
||||
/** `export function Name(…) { return $Call.ByID(123, …) }` */
|
||||
const BOUND_METHOD = /export function (\w+)\([\s\S]*?\$Call\.ByID\((\d+)/g;
|
||||
|
||||
/** `import * as Library from "./library.js";` — the only place the Go
|
||||
* type's casing survives; the file is `library.ts`, and
|
||||
* `frontendutil.ts` cannot tell you it is `FrontendUtil`. */
|
||||
const SERVICE_EXPORT = /import \* as (\w+) from "\.\/([\w.]+)\.js"/g;
|
||||
|
||||
function* walk(dir) {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const path = join(dir, entry);
|
||||
|
||||
if (statSync(path).isDirectory()) yield* walk(path);
|
||||
else if (entry.endsWith('.ts')) yield path;
|
||||
}
|
||||
}
|
||||
|
||||
let cached;
|
||||
|
||||
/**
|
||||
* methodIDs maps a binding's method id to `pkg.Type.Method` — the same
|
||||
* short path `callBinding` takes, so a spec never has to know that the
|
||||
* backend calls it `yellowjacket/backend/queue.Queue.GetState`.
|
||||
*/
|
||||
export function methodIDs() {
|
||||
if (cached) return cached;
|
||||
|
||||
const byID = new Map();
|
||||
|
||||
for (const file of walk(BINDINGS_ROOT)) {
|
||||
if (!file.endsWith('index.ts')) continue;
|
||||
|
||||
const dir = dirname(file);
|
||||
const pkg = dir.split('/').pop() ?? '';
|
||||
const index = readFileSync(file, 'utf8');
|
||||
|
||||
for (const [, typeName, base] of index.matchAll(SERVICE_EXPORT)) {
|
||||
let source;
|
||||
|
||||
try {
|
||||
source = readFileSync(join(dir, `${base}.ts`), 'utf8');
|
||||
} catch {
|
||||
continue; // a models-only re-export, which binds nothing
|
||||
}
|
||||
|
||||
for (const [, method, id] of source.matchAll(BOUND_METHOD)) {
|
||||
byID.set(Number(id), `${pkg}.${typeName}.${method}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (byID.size === 0) {
|
||||
throw new Error(
|
||||
`method-ids: no bound methods under ${BINDINGS_ROOT}; ` +
|
||||
`run 'make bindings'`,
|
||||
);
|
||||
}
|
||||
|
||||
cached = byID;
|
||||
|
||||
return byID;
|
||||
}
|
||||
|
||||
/** Names one recorded binding call, or `#<id>` if the map has no entry
|
||||
* — which fails the assertion naming it rather than passing quietly. */
|
||||
export function nameOf(call) {
|
||||
if (call.methodName) {
|
||||
return call.methodName.replace(/^yellowjacket\/backend\//, '');
|
||||
}
|
||||
|
||||
return call.methodID === null
|
||||
? '#unknown'
|
||||
: (methodIDs().get(call.methodID) ?? `#${call.methodID}`);
|
||||
}
|
||||
Reference in New Issue
Block a user