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
+30 -23
View File
@@ -2,28 +2,36 @@
#
# Start YellowJacket headless, in the background, and return.
#
# `wails dev` binds an HTTP + WebSocket dev server on :34115 that serves
# the real frontend with the real generated bindings on `window.go` and
# bridges every call and every runtime.EventsEmit to the *same* Go
# backend the desktop window uses. A browser pointed at it is not a
# mock. That is what makes this app drivable by a coding agent.
# Wails v3's server mode binds an HTTP server on :34115 that serves the
# real frontend with the real generated bindings and bridges every call
# and every event to the *same* Go backend a desktop window would use.
# A browser pointed at it is not a mock. That is what makes this app
# drivable by a coding agent.
#
# Three details are load-bearing:
#
# * We run the dev *binary*, not `wails dev`. app_dev.go parses
# -devserver / -assetdir / -loglevel straight from os.Args, so a
# `go build -tags "dev"` binary serves the identical
# devserver with no file watcher, no rebuild supervisor and no
# reload broadcast: one process, one PID, deterministic startup.
# * We build with `-tags dev,server` and run the binary. This is a
# first-class Wails mode ("a pure HTTP server without native GUI
# dependencies"), not something hand-rolled: v2 had no such thing,
# so this script used to run a dev binary whose app_dev.go parsed
# -devserver / -assetdir out of os.Args. That file is gone with v2.
# The port comes from WAILS_SERVER_PORT.
#
# * Xvfb is not optional. devserver.Run ends in Frontend.Run(ctx),
# which opens the GTK window and blocks; no flag suppresses it.
# * Xvfb is gone, and that is the point of server mode. v2's
# devserver.Run ended in Frontend.Run(ctx), which opened the GTK
# window and blocked with no flag to suppress it; server mode opens
# no window, so there is no display to fake.
#
# * dbus-run-session is not incidental. A private session bus makes
# backend/mediacontrols register MPRIS for real, so it becomes
# assertable with busctl. It replaces the bus, not /run/user, so
# PulseAudio still works and InitSpeaker succeeds.
#
# * The frontend is *embedded*, not served from disk. main.go has
# //go:embed all:frontend/dist, so a frontend change needs the
# rebuild this script does anyway; there is no -assetdir to point
# at a live directory.
#
# Usage:
# scripts/dev-headless.sh [--seed NAME|--fresh] [--port N] [--no-build]
#
@@ -47,7 +55,7 @@ LOG_LEVEL="${YJ_LOG_LEVEL:-debug}"
STARTUP_TIMEOUT=60
usage() {
sed -n '3,28p' "$0" | sed 's/^# \{0,1\}//'
sed -n '3,38p' "$0" | sed 's/^# \{0,1\}//'
exit "${1:-0}"
}
@@ -129,9 +137,9 @@ echo "$YJ_HOME" >"$HOME_FILE"
# ── Build ────────────────────────────────────────────────────────────
if [ "$BUILD" = 1 ]; then
echo "dev-headless: building frontend + dev binary..."
echo "dev-headless: building frontend + dev server binary..."
(cd frontend && pnpm install --silent && pnpm build >/dev/null)
go build -tags "dev" -o "$BIN" .
go build -tags "dev,server" -o "$BIN" .
fi
if [ ! -x "$BIN" ]; then
@@ -141,7 +149,7 @@ fi
# ── Launch ───────────────────────────────────────────────────────────
# setsid puts the app in its own process group so dev-stop can kill the
# whole tree (xvfb-run, dbus-daemon, the app) by group id. Never
# whole tree (dbus-daemon, the app) by group id. Never
# `pkill -f`: the pattern matches the invoking shell's own command line
# and silently drops the rest of the chain.
: >"$LOG_FILE"
@@ -150,11 +158,9 @@ fi
# rather than implied by the dev build so that a human's `make dev` does
# not carry an arbitrary-SQL endpoint on a listening port.
YJ_TESTCTL=1 \
YJ_LOG_LEVEL="$LOG_LEVEL" setsid dbus-run-session -- xvfb-run -a \
WAILS_SERVER_PORT="$PORT" \
YJ_LOG_LEVEL="$LOG_LEVEL" setsid dbus-run-session -- \
"$BIN" \
-devserver "localhost:$PORT" \
-assetdir "$REPO_ROOT/frontend/dist" \
-loglevel Debug \
>>"$LOG_FILE" 2>&1 &
APP_PID=$!
@@ -191,7 +197,8 @@ Drive it with:
playwright-cli -s=yj snapshot
playwright-cli -s=yj eval "() => window.__yjEvents.call('queue.Queue.GetState', [], 5000)"
A binding call that never settles means wrong argument types: the
backend logs 'error parsing arguments' and never fires the callback.
The app log is the only place that shows up, so always use a timeout.
A bad binding call now rejects rather than hanging: v3 answers wrong
argument types with a TypeError naming the argument and an unknown
method with a ReferenceError. The timeout in __yjEvents.call is a
backstop for a hung request, not how a mistake becomes visible.
EOF
+26 -27
View File
@@ -36,7 +36,6 @@ MANIFEST="$REPO_ROOT/test_data/music_library_test.manifest.json"
NAME="default"
PORT=34115
SESSION="yj-seed"
BUILD_ARGS=()
# Replaced below once the manifest says how many tracks are coming: a
# 50 000-track scan is minutes, and a fixed 180 s deadline would abort
@@ -74,7 +73,7 @@ need() {
exit 1
}
}
need playwright-cli
need curl
need jq
if [ ! -f "$MANIFEST" ]; then
@@ -89,7 +88,6 @@ SCAN_TIMEOUT=$((180 + WANT_TRACKS / 50))
FIXTURE_HASH="$(jq -r .hash "$MANIFEST")"
cleanup() {
playwright-cli -s="$SESSION" close >/dev/null 2>&1 || true
./scripts/dev-stop.sh >/dev/null 2>&1 || true
}
trap cleanup EXIT
@@ -108,24 +106,34 @@ YJ_CORE_INDEX_URL="http://127.0.0.1:1/none.tar.zst" \
YJ_HOME="$(cat "$RUN_DIR/app.home")"
playwright-cli -s="$SESSION" open "http://localhost:$PORT" >/dev/null
# Every binding call gets a timeout. A call with wrong argument types
# makes the backend log 'error parsing arguments' and never fire the
# callback, so the in-page promise never settles and a naive await
# hangs forever.
# A binding is called over the runtime's own HTTP endpoint, by method
# name — the same request the bundle makes, minus the browser.
#
# This used to drive a real page through playwright-cli, because v2's
# only way in was `window.go`. v3 answers the same call over HTTP, so a
# browser (and a global npm install of the CLI, in CI) buys nothing
# here: seeding needs the *app* to run and the *real* scanner to finish,
# which it still does. It also fails properly now — v3 answers a bad
# argument with a 422 and a TypeError naming it, where v2 logged
# "error parsing arguments" and never fired the callback.
call() {
playwright-cli -s="$SESSION" eval "async () => {
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error('binding timeout')), 15000));
return await Promise.race([(async () => { $1 })(), timeout]);
}"
local method="$1"
local args="${2:-[]}"
local body
body="$(jq -nc --arg m "yellowjacket/backend/$method" --argjson a "$args" \
'{object: 0, method: 0, args: {"call-id": "seed", methodName: $m, args: $a}}')"
curl -sS --fail-with-body --max-time 30 \
-X POST "http://localhost:$PORT/wails/runtime" \
-H 'Content-Type: application/json' \
-d "$body"
}
echo "seed-sandbox: registering library $LIBRARY_DIR"
if ! call "return await window.go.library.Library.AddLibrary(
${LIBRARY_DIR@Q});" >/dev/null; then
if ! call library.Library.AddLibrary \
"$(jq -nc --arg d "$LIBRARY_DIR" '[$d]')" >/dev/null; then
echo "seed-sandbox: AddLibrary failed; app log:" >&2
tail -n 40 "$LOG_FILE" >&2
exit 1
@@ -137,19 +145,12 @@ fi
# a fixture is not being ingested and the seed is wrong.
echo "seed-sandbox: waiting for the scan to reach $WANT_TRACKS tracks"
# The result is tagged rather than scraped for bare digits:
# playwright-cli echoes the evaluated source back, and that source
# contains numbers of its own (the binding timeout, for one).
deadline=$((SECONDS + SCAN_TIMEOUT))
got=0
while [ "$SECONDS" -lt "$deadline" ]; do
got="$(call "const libs =
await window.go.library.Library.GetAllLibrariesWithTrackCounts();
const total = (libs ?? []).reduce(
(n, l) => n + (l.trackCount ?? 0), 0);
return 'YJTRACKS' + '=' + total;" |
grep -oE 'YJTRACKS=[0-9]+' | head -n 1 | cut -d= -f2)"
got="$(call library.Library.GetAllLibrariesWithTrackCounts |
jq '[(. // [])[].trackCount // 0] | add // 0')"
got="${got:-0}"
[ "$got" = "$WANT_TRACKS" ] && break
@@ -170,8 +171,6 @@ if [ "$got" != "$WANT_TRACKS" ]; then
exit 1
fi
playwright-cli -s="$SESSION" close >/dev/null 2>&1 || true
# SIGTERM, so OnBeforeClose / OnShutdown persist window, player and
# queue state. A seed built from a killed app is missing exactly the
# state those hooks write.