Files
yellowjacket/.planning/plans/active/005-agent-development-harness.md
T
logan 5ca6cad45a
Build & publish Arch package / arch-package (push) Successful in 2m8s
CI / check (push) Failing after 1m56s
CI / e2e (push) Skipped
Search index maintenance / maintain-index (push) Successful in 13s
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.
2026-08-10 23:20:42 -04:00

36 KiB
Raw Blame History

005 — Agent development harness

Status: complete — all seven phases shipped Branch:Created: 2026-08-10 Follows: 004-wanted-list

Progress

Phase State Notes
1 — Reproducible fixtures done cmd/gentestdata, make testdata, internal/testfixtures
2 — Headless launch done scripts/dev-headless.sh, dev-stop.sh, seed-sandbox.sh
3 — Driving and seeing done event bridge, data-testid/aria pass, backend/testctl, e2e/ smoke suite
4 — Component coverage done Vitest 4 browser mode, 313 tests, make ui-test; make bindings-check
5 — events.Emit wrapper done backend/events/emit.go, Recorder, 35 sites converted, service tests in queue/config/playlist
6 — pi affordances done .pi/skills/yellowjacket-dev/, .pi/prompts/e2e.md, .pi/journal.md, make skill-check
7 — CI that gates done .gitea/workflows/ci.yml, two jobs, both prototyped in a container first

Verified end to end after phase 7: both jobs were built as shell scripts and run to green in a bare ubuntu:24.04 container before any YAML existed, then the steps were transcribed back out of the workflow and re-run in the same container to prove the transcription — job 1 (lint × 3, test × 3, tsc --noEmit, 313 ui-tests, bindings-check, skill-check) and job 2 (fixtures, seed, dev-headless, 19/19 chromium, 19/19 webkit). Push-and-see was not an acceptable loop here: a Gitea Actions run that never starts looks identical to one that passed.

It found a real bug on day one. make lint was 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. The dev pass is the one that breaks, because wails' own app_dev.go is dev-tagged and drags in the 4.0 assetserver that the other two passes never compile. The tag sets now match make test exactly. "Lint passes" and "lint compiled what we ship" were different claims, and only a second distro could tell them apart.

Two decisions were settled by measurement rather than argument. The audio sink is a four-line ALSA null PCM, no daemon: InitSpeaker succeeds in 36 ms and the elapsed clock advances, because ALSA's null plugin advances its pointer on a timer. The explore artifact is stubbed at a dead address as seed-sandbox.sh already does — and setting it for the app run too, which dev-headless.sh never did, turned out to be worth 8x on suite wall clock. Playwright's WebKit is a required step, not an advisory one: it had never been run anywhere, so one throwaway container run turned a coin flip into a decision (19/19, +11 s), and nothing in e2e/ compares pixels, so a WebKit failure is an engine bug rather than baseline noise.

Verified end to end after phase 6: all four tiers were re-run green before anything was written — make ui-test (313), make lint (0 issues × 3 configurations), make test (3 passes), make e2e (19/19 against a seeded dev-headless app) — so the skill documents commands that were observed working, not remembered. make skill-check then verified the 25 make targets the skill cites all exist, and was itself verified to fail on a missing one. The remaining check is the one no tooling can do: an agent following .pi/skills/yellowjacket-dev/ cold on a real task, which should happen before phase 7 encodes the same commands into CI.

That cold run has now happened. An agent that did not write the skill brought the app up from a wiped .dev/ and no fixture library, drove an undocumented flow (queue panel + shuffle, asserted on QueueModeChanged, confirmed against queue.Queue.GetState) and stopped it, in about a minute with no dead ends; all four tiers then re-ran green from that cold state. It found one real config bug — outputDir in .playwright/cli.config.json resolves against the shell's cwd, not the config file's directory, so every snapshot was landing one level above the repo where a stale copy from the previous session answered instead — and four missing or wrong steps (sandbox-seed already runs testdata; make ui-setup / make e2e-setup are undocumented once-per-clone prerequisites; snapshot prints a path, not a tree; make dev-stop leaves the browser session open). All fixed in place, with the detail in .planning/NOTES.md.

Verified end to end after phase 5: all 35 runtime.EventsEmit call sites across 14 files now route through events.Emit, and TestNoDirectRuntimeEmits fails the build if a new one appears. Four packages had each hand-rolled their own guard against the same log.Fatalf (library.emit, download.emit, autotagservice. emitEvent with its own ctxReady field, playlist.emitEvent) and nine more sites guarded on ctx != nil, which does not actually prevent it; all of that collapsed into one place. 16 new tests assert what the frontend receives — queue mode/index/delta payloads, config theme and shortcut snapshots, playlist create/add/delete — none of which was reachable before. make lint (3 configurations), make test (3 passes), make bindings-check, make ui-test and make e2e (19/19 against the seeded headless app) all green.

Verified end to end after phase 4: make ui-test runs 313 tests in a real Chromium in ~2 s with no app, no backend and no display — 196 covering all 13 stores plus the keyboard shortcut service, 117 covering components (transport, sidebar, library filter, status indicator, track-info, now-playing, queue panel) including a smoke mount of all 46 custom elements against an empty backend. make ui-visual adds six toMatchScreenshot baselines. make bindings-check regenerates frontend/wailsjs in ~1.5 s and was verified to fail on a renamed bound method. tsc --noEmit, make lint (all three configurations) and make e2e (19/19) all stayed green, and the one frontend fix the tier surfaced was confirmed in the running app by screenshot.

Verified end to end after phase 3: make e2e runs 19 Playwright specs against the seeded app — harness self-tests, library views (31 fixture tracks, unicode, sidebar navigation), playback (play, pause, elapsed time, volume round-trip), queue (population, shuffle state) and the control surface (snapshot → mutate → restore, forced event, SQL, input validation). All 19 pass; make lint is at 0 issues across all three build configurations and make test is green.

Verified end to end after phase 2: make sandbox-seed NAME=default built a seed by driving the real AddLibrary binding and waiting for the real scan to reach 31 tracks; make dev-headless SEED=default restored it and landed in the app with no first-run wizard; playwright-cli clicked through to Artists and screenshotted six real artists with generated cover art, unicode names and the long-artist truncation case; LoadFile + Play produced audible playback with the transport bar at 00:04.

One re-sequencing against the plan below: sandbox-seed is described under phase 1 but shipped at the end of phase 2, because seeding by running the app makes it a consumer of the launcher.

One bug found by the fixtures, not yet fixed: WAV tags are write-only. backend/tagwriter writes them into a RIFF id3 chunk; backend/metadata reads through dhowden/tag, which has no RIFF parser, so every WAV scans in untitled. Pinned by TestWAVTagsAreNotReadableYet.

Problem

A coding agent can develop the Go packages of this repo competently and cannot develop the application at all. It can read 66k lines of backend, run 31k lines of tests, and lint two build configurations. It cannot start the app, see a window, click anything, or find out whether a change it made to a Lit component rendered.

The gap is not "we lack tests". It is that every path to running YellowJacket ends in a blocking GTK window:

Entry point Behaviour
make dev launches a WebKit window, blocks the terminal forever
make sandbox <n> same, plus an interactive name argument
make fresh-install same, and lands on the first-run wizard every time

So 265 bound methods across 11 services, 46 backend events, 33 Lit component directories, 13 reactive stores and a 357-line keyboard shortcut service have exactly one form of verification available to an agent: tsc --noEmit.

Three secondary facts make it worse. test_data/music_library_test/ is referenced by three test files, is in .gitignore, is not on disk, and has no generator — so the audio path and YELLOWJACKET_INTEGRATION=1 are unreachable from a clean clone. No Gitea workflow runs make test or make lint; quality gating exists only in lefthook.yml, which is local and --no-verify-skippable. And there is no .pi/ directory, so none of the awkward invocations (-tags "webkit2_41 indexbuild", sandbox lifecycle, log tailing) are wrapped in anything an agent can call.

The unlock

wails dev already runs a full HTTP + WebSocket dev server on localhost:34115 (internal/frontend/devserver/devserver.go). It serves the real frontend assets, injects the real generated bindings, and bridges every method call and every runtime.EventsEmit over a websocket to the same running Go backend the desktop window is attached to.

A plain Chromium can load http://localhost:34115 and get a fully functional YellowJacket. Not a mock, not a stub wailsjs layer: the actual application, talking to the actual explore, library, player and queue services, receiving the actual events. The bindings land on window.go, so anything reachable from the frontend is reachable from a one-line page.evaluate.

This is not a trick we invented. Wails v3's documentation ships an "End-to-End Testing" guide that is exactly this, and the v2 community arrived at the same answer independently (wailsapp/wails discussion #4205). It is the sanctioned approach.

The one caveat: devserver.Run still calls d.Frontend.Run(ctx), which opens the GTK window and blocks. No flag suppresses it, and nobody upstream has found a way around it. The app needs a display — a virtual one.

Validated end to end, 2026-08-10

The premise was proven before this plan was committed, on a scratch YJ_HOME under ~/.cache/yellowjacket-harness:

go build -tags "dev webkit2_41" -o build/bin/yj-dev .
setsid dbus-run-session -- xvfb-run -a ./build/bin/yj-dev \
  -devserver localhost:34115 -assetdir frontend/dist
playwright-cli -s=yj open http://localhost:34115
Claim Result
App boots headless under Xvfb yes, ~1 s; :34115 listening
YJ_HOME isolates the sandbox yes, own yj.db, untouched real install
Chromium loads the real app yes, console shows wails dev / Connected to backend
a11y snapshot pierces shadow DOM yes — sidebar, queue panel, transport buttons, all with stable refs, through Lit and Web Awesome roots
window.go carries the bindings yes, all 11 services
A bound method round-trips to Go yes — queue.Queue.GetState() returned real JSON
Events reach the browser yes — SetVolume(42) produced VolumeChanged with payload 42
Screenshot is readable by the agent yes — full render, correct theme, fonts and icons
MPRIS registers yes — org.mpris.MediaPlayer2.yellowjacket on the private bus
Audio initialises yes — see below

Five things the run taught that were not obvious beforehand:

  • No null audio sink is needed. dbus-run-session replaces the bus, not the runtime dir, so /run/user/1000/pulse stays reachable and InitSpeaker succeeded in 17 ms. The mitigation planned for Phase 3 is unnecessary on a developer machine. A CI container with no /run/user will still need one.
  • The first-run wizard blocks every interaction. The first click attempt failed with <first-run-wizard> intercepts pointer events. Phase 1 is not a convenience; nothing downstream works without it.
  • A malformed binding call hangs forever. SetVolume(0.42) against a player.UserVolume (an int) made the backend log error parsing arguments and never fire the callback, so the in-page promise never settled. Every harness call needs a timeout, and the app log is the only place the reason appears.
  • Playwright's WebKit does not run on Arch. Its Linux build links Ubuntu 24.04 libraries — libicu74, libWPEWebKit-2.0.so.1, libflite — none of which Arch provides. --browser=webkit is a CI-only capability, not a local one. Chromium is unaffected.
  • Event listeners accumulate across calls. Hooks registered by one eval survive into the next, so a naive recorder double-counts. The initScript must install exactly one recorder, and tests must reset its buffer rather than re-register.

Tooling decisions taken up front

Three things exist that we would otherwise have built badly.

@playwright/cli (npm i -g @playwright/cli) is Microsoft's CLI-plus-agent-skills front end to Playwright, built specifically because coding agents do better with terse commands than with MCP tool schemas. playwright-cli install --skills drops the skills where an agent finds them. It gives us, for free, everything this plan was otherwise going to hand-roll:

Need Command
See the page snapshot — a11y tree with stable ref=eNN handles, pierces open shadow roots
Search a big page find <text> / find --regex
Call a bound method eval "() => window.go.player.Player.Play(1)"
Screenshot for the agent to read screenshot --filename=
Frontend errors console — Lit render failures are currently invisible
Stub the explore artifact route <pattern>
Keep a browser across separate shell calls -s=<session>
Watch, and take over show — live dashboard, per-session screencast, click in to grab the mouse

Plus video and trace recording when a flow needs explaining rather than asserting. It is v0.1.x and moving; @playwright/mcp is the same engine behind an MCP server and is the fallback if the CLI churns.

Playwright's WebKit build. The shipped binary is WebKit2GTK, so a Chromium-only suite would validate a renderer we do not ship. --browser=webkit is not byte-identical to WebKit2GTK but shares the engine core, and it is a flag rather than a project. The X11-grab of the real GTK window drops to an optional spot-check.

Vitest 4 browser mode. Stable Browser Mode plus toMatchScreenshot landed in Vitest 4.0, it is the Lit ecosystem's current recommendation over @web/test-runner, and it uses Playwright as its provider — the same browsers already cached. Components render in a real browser with real shadow DOM, and get visual regression, with no Wails, no backend, no seeded library and no virtual display. This is a tier the earlier draft of this plan did not have and is the cheapest coverage available.

So the harness is three tiers, cheapest first:

  1. Vitest browser mode — components and stores. Seconds. No app.
  2. playwright-cli against :34115 — real flows against the real backend, driven interactively by an agent.
  3. Playwright specs — the same thing, frozen as a regression suite, in CI.

Only tier 2 and 3 need the app running, and therefore Xvfb.

Phase 1 — Reproducible fixtures (shipped)

Nothing can be driven end-to-end against an empty library, and no two runs are comparable unless the library is identical. No tool provides this; it is ours to write.

cmd/gentestdata writes test_data/music_library_test/ deterministically: silent/tone audio at known durations across MP3, FLAC, OGG Vorbis and WAV, with tags written by our own tagwriter so fixtures and reader cannot drift. Coverage must include the cases the app has code for — embedded cover art shared across an album (dedup), missing and partial tags, unicode and RTL text, multi-disc, various artists, and a deliberate duplicate pair for duplicate-tracks-dialog.

make testdata generates it; it stays gitignored. A manifest hash lets a test assert it is looking at the library it thinks it is.

Seeded sandboxes. make sandbox-seed NAME=<n> builds a YJ_HOME with config.toml already pointing at the fixture library and yj.db already scanned, so a run starts in the app rather than in the first-run wizard. A --fresh variant deliberately omits config, because the wizard is itself a surface that needs testing. Seeds rebuild from scratch in seconds and are never hand-edited.

Explicitly not seeded: the explore artifact. artifactfetch.go already honours YJ_CORE_INDEX_URL ("overridable for testing"), so tests point it at a local file server holding a cut-down artifact — cmd/indexexport already produces that shape, so a tiny core is a config change, not new code. This also makes the failure paths testable (404, checksum mismatch, the 206 partial-content resume). A nightly job can use the real artifact.

Phase 2 — Headless launch (shipped)

scripts/dev-headless.sh wraps:

dbus-run-session -- xvfb-run -a \
  ./build/bin/yj-dev -devserver localhost:34115 -assetdir frontend/dist

Run the dev binary directly, not wails dev. app_dev.go parses -assetdir, -devserver, -frontenddevserverurl and -loglevel straight from os.Args, so go build -tags "dev webkit2_41" produces a binary that serves the identical devserver with no file watcher, no rebuild supervisor and no reload broadcast. One process, one PID, deterministic startup. wails dev's watcher is a human ergonomic; an agent that just edited a file knows to rebuild. (-noreload and -nogorebuild exist if the watcher is ever wanted anyway.)

dbus-run-session is not incidental. A private session bus means backend/mediacontrols/mpris_linux.go actually registers, which turns MPRIS from "untestable" into a surface assertable with busctl — properties out, Play/Pause/Next in.

The script backgrounds the process, writes .dev/app.pid and .dev/app.log, polls :34115 until it answers, then exits, leaving the app up. make dev-headless SEED=<n>, make dev-stop, make dev-logs.

Kill by saved PID, never pkill -f. A pkill -f whose pattern appears in the invoking shell's own command line kills that shell and silently drops the rest of the chain.

New dependency: xorg-server-xvfb. Everything else — Playwright and its Chromium, ffmpeg, import, dbus-run-session, busctl, pactl — is already present.

Audio needs nothing locally. Measured: InitSpeaker succeeds under dbus-run-session + Xvfb because the PulseAudio socket in /run/user/1000 is untouched by a private bus. Only a CI container without /run/user needs a null sink (PipeWire null sink, or an ALSA null PCM via a scoped asoundrc), and even then app.go:325 joins InitSpeaker() failure into startupErr rather than aborting, so everything except playback still runs. Sample-level correctness stays where it already is, in backend/player unit tests.

Phase 3 — Driving and seeing the app (shipped)

What landed, and the five things that were not obvious:

  • .playwright/cli.config.json now sets testIdAttribute, a 1440×900 viewport, timeouts and the initScript. Every path in it is resolved relative to the config file, not the repo root.
  • .playwright/init-events.js is the event bridge, and it hooks window.wails.EventsNotify rather than EventsOn — every backend event enters the page at that one call (case "n" in wails' ipc_websocket.js), so one wrap captures all 46 whether or not the app subscribes. window.wails does not exist when an initScript runs, so it is wrapped via an accessor installed on window that collapses back to a data property on assignment. It also carries ready() and call(), the latter timing out so the "malformed binding call hangs forever" trap is paid for once.
  • No closed shadow roots anywhere: nothing in frontend/src overrides createRenderRoot/shadowRootOptions and Web Awesome's dist never calls attachShadow directly. Snapshots pierce everything.
  • The data-testid pass was mostly an accessibility fix. The five transport buttons had no accessible name at all, so they were unnameable to a screen reader and to a selector; they now carry aria-label plus aria-pressed for the shuffle/repeat toggles. data-testid was added only where a selector would otherwise be structural: track-row, queue-row (both with data-file-path), main-content (plus a data-active-view attribute, since which view is showing was previously only inferable from which cached child lacked .view-hidden), the now-playing title/artist and the seek bar's two clocks. Sidebar items got data-testid and aria-current.
  • backend/testctl mounts /__test/ on the existing asset handler: health, db/snapshot, db/restore, emit, sql. Gated twice — the implementation is behind the dev build tag with a no-op !dev twin, and it refuses to register unless YJ_TESTCTL=1, which dev-headless.sh sets and make dev does not.
  • e2e/ is its own npm package (make e2e), deliberately not inside frontend/, so phase 4's Vitest browser mode does not have to share a package with the Playwright runner.

The original plan for the phase follows.

  • playwright-cli install --skills, and a project .playwright/cli.config.json setting testIdAttribute, viewport, and an initScript.
  • The initScript is the event bridge. It runs before any app script, so it can hook EventsOn and buffer all 46 backend events on window.__yjEvents, read back with eval. Half of what this app does is push-driven — scan progress, job updates, download progress, WantedListChanged — and assertions must await an event, not a timeout.
  • Add data-testid where selectors would otherwise be structural. First task of the phase is confirming nothing in the Lit / Web Awesome tree uses a closed shadow root, which would defeat snapshots.
  • A dev-only control surface. backend/assets/handler.go is ours and already has RegisterHandler(pattern, handler), so /__test/... can be mounted on the same port with no new server: seed, snapshot and restore the SQLite DB mid-run, force backend-internal state. Roughly five endpoints, compiled out of non-dev builds. This is the residue of what Playwright genuinely cannot reach — everything browser-side is already covered by the CLI.

Screenshots as the primary agent primitive. screenshot --filename= then reading the PNG is the feedback loop that makes UI iteration possible at all, and it matters more than the assertion suite built on top of it. snapshot is the cheaper companion for structure.

Smoke suite, once flows are stable, frozen as Playwright specs: first-run wizard, library views (artists / genres / cover grid / track list), playback and queue manipulation, playlist and smart-playlist editing, explore search and detail pages, settings (the HTMX/templ path, which renders differently from everything else), jobs, downloads.

Renderer fidelity is CI-only. Playwright's Linux WebKit is built against Ubuntu 24.04 and will not start on Arch (missing libicu74, libWPEWebKit-2.0.so.1, libflite); the download succeeds and the binary then fails to link. So --browser=webkit runs in Job 2 of CI, where the runner image is Debian-family, and local work is Chromium only. The X11 grab of the real GTK window stays available as an optional spot-check for views where WebKit2GTK-specific rendering matters — and is the only WebKit2GTK signal obtainable on this machine.

Two live frontends, one backend. The GTK window and the browser are both websocket clients of the same backend. This is supported — devserver.go keeps a client map and notifyExcludingSender deliberately fans frontend-emitted events out to the other clients and the desktop frontend; -browser exists for exactly this. The risk is not the transport but our own singletons: 13 stores × 2 instances means duplicate cover-art fetches on connect and two clients able to issue player.Play. If that proves noisy, the fix is contained — our asset handler can serve a blank page to the WebKitGTK user agent under YJ_HEADLESS=1, making the window inert. Start without it.

Phase 4 — Component and store coverage (shipped)

What landed:

  • The Wails fake is the whole trick. Everything in frontend/wailsjs/ is a pure passthrough to window.go and window.runtime, so test/support/wails-fake.ts replaces those two globals and every test then runs the real generated bindings and the real store code. No module mocking, and no second description of the Wails layer free to drift. Its event dispatcher mirrors desktop/events.js, including maxCallbacks expiry and the fact that a frontend EventsEmit notifies local listeners before Go.
  • Stores are singletons constructed at import, so the fake is installed from setupFiles, which runs first. A handful of stores read config in their constructor before any test can stub, so the setup file carries import-time defaults — without them a store caches undefined where Go would have sent [], and every consumer crashes on .length in a way that looks like a component bug.
  • make ui-test / ui-watch / ui-visual / ui-visual-update. Visual regression is opt-in (YJ_VISUAL=1) because baselines are font-hinting and compositing sensitive; the default run asserts behaviour only, so nobody's loop breaks over antialiasing.
  • make bindings-check (scripts/bindings-check.sh) runs wails generate module and fails on a dirty tree, ignoring the file modes the generator churns. Now in lefthook.yml pre-commit; the Vitest suite is in pre-push.
  • Two frontend bugs the tier found: ScrollManager.setupResizeObserver threw an unhandled rejection on an empty library (fixed, one guard), and themeStore.loadFromBackend's failure handler throws again on the state that failed it, so it cannot recover (left alone — reachable only if the backend returns an empty accent).

The original plan for the phase follows.

Vitest 4 browser mode with the Playwright provider, in frontend/.

  • The 13 stores and the keyboard shortcut service. Queue mutation, shuffle, repeat transitions, explore cache invalidation and shortcut dispatch are near-pure TypeScript with zero tests today.
  • Component rendering for the 33 component directories, with toMatchScreenshot visual regression per component. Real browser, real shadow DOM, no app, no display — this is where the bulk of UI regression should live, leaving e2e for flows.
  • Binding drift check. frontend/wailsjs/ is generated by wails build, not go generate, so the existing pre-commit codegen check does not cover it. A renamed Go struct field currently surfaces at runtime, in a window. Add a target that regenerates bindings and fails on a dirty tree.

Phase 5 — An events.Emit wrapper (shipped)

What landed:

  • events.Emit(ctx, name, data...) drops an event that has nowhere to go, at debug level, instead of taking the process down. events.Deliver is the same call returning ErrNoRuntime, for the one caller that must know: /__test/emit, whose job is to impersonate a backend emit and which would otherwise answer 200 for an event that reached nobody.
  • The sink is carried in the context, not in a package-level variable — events.WithSink(ctx, rec) — so parallel tests cannot observe each other's events and production emits pay no synchronisation cost. events.Recorder implements it with Events/Named/Names/Count/Last/Reset and a Wait that blocks on background emitters (scan progress, SetQueue phase 2).
  • Enforcement is a test, not a linter. golangci-lint runs once per build configuration, so a stray emit in an indexbuild- or dev-tagged file would only be seen by the pass that compiles it; TestNoDirectRuntimeEmits walks the tree and sees all of them.
  • The tier it unblocks, exercised: backend/queue (7), backend/config (5), backend/playlist (4). Playlist is the one that matters beyond the wrapper itself — it proves the pattern on a service whose emits interleave with SQLite writes and M3U8 file writes, and its test reads the playlist back the way the frontend would on receipt of the event.

Deferred out of this phase: a general backend/playlist CRUD suite. The service is 2,900 lines with no CRUD coverage today, and that is its own piece of work rather than a rider on a mechanical refactor.

The original plan for the phase follows.

34 call sites use runtime.EventsEmit directly. runtime.getEvents (runtime.go:47) log.Fatalfs unless ctx.Value("events") satisfies frontend.Events — an interface under wails/v2/internal/, which we cannot implement. So none of those code paths can run outside a real Wails app, and in-process service tests are impossible.

A thin events.Emit(ctx, name, data...) in backend/events, delegating to runtime.EventsEmit normally and to a recorder when a test sink is installed, unblocks that. It is mechanical, and it pays for itself independently as the one place to log or trace all 46 events.

Sequenced after the e2e tiers because it is a refactor touching many packages, and the tiers above deliver value without it.

Phase 6 — pi affordances (shipped)

What landed, and the one decision that mattered:

  • .pi/skills/yellowjacket-dev/, a directory rather than a flat file. Only a skill's description is always in context, so SKILL.md holds the tier decision table, the canonical command sequences and the five gotchas — the last inline rather than in a reference, because they are needed before the failure — and references/{harness,fixtures,ui-tier,schema-change}.md hold the per-surface depth.
  • The split from CLAUDE.md is grammatical, not topical. A topical split is what rots: every new fact has two plausible homes. Three docs, three tenses — NOTES.md past (measured, dated, append-only), CLAUDE.md present (what the system is), the skill imperative (what to run). CLAUDE.md's harness section lost about half its length to this; leaving both would have been exactly the duplicate description this repo has a standing rule against.
  • make skill-check makes the rule enforceable rather than aspirational: every command in .pi/**/*.md must be a real make target, so the Makefile stays the source of truth for how to invoke something and the skill only decides which and in what order. A pre-commit hook; instant.
  • .pi/prompts/e2e.md treats promotion as a transcription with four fixed substitutions (snapshot refs → testids, sleeps → waitForEvent, raw window.gocallBinding, short fixture → LONG_TRACK) and three runs — pass, pass again, pass after a DB restore — because the characteristic failure of a promoted spec is depending on state the hand-driving left behind.
  • .pi/journal.md, per the /handoff convention.

The original plan for the phase follows.

With the mechanics settled, wrap them. Much less than the first draft assumed, because playwright-cli's own skills cover browser work.

.pi/ gains:

  • skills/yellowjacket-dev/ — the build-tag matrix, the two-file schema rule, seed and sandbox lifecycle, the harness commands, and when to reach for which of the three test tiers. CLAUDE.md has the architectural half; this is the operational half. It must also carry the gotchas the live run surfaced: time out every binding call, check .dev/app.log when one hangs, and never assume a click will land while the first-run wizard is up.
  • settings.json pointing at ../.claude/skills, because playwright-cli install --skills writes to .claude/skills/ and pi does not discover that path by default. (Already in place.)
  • .pi/journal.md, per the /handoff convention.
  • A /e2e prompt template for promoting an exploratory playwright-cli session into a committed spec.

No custom extension. Browser control is a solved, actively maintained problem and a hand-rolled version would be worse and would rot.

Phase 7 — CI that actually gates (shipped)

What landed, and the decisions behind it:

  • One image for both jobs, ubuntu:24.04. Not golang:1.25, because job 1 runs make ui-test — Vitest browser mode — so the "fast job needs no browser" split does not survive contact. Not the Playwright image either, because e2e/ pins @playwright/test ^1.56 and frontend/ pins playwright ^1.62, so a prebuilt browser set matches at most one of them. Ubuntu 24.04 is also what Playwright's WebKit links against, which job 2 needs.
  • Caching needed no runner-side change. valid_volumes is already a glob over the runner's cache root, and GOMODCACHE / GOCACHE / GOLANGCI_LINT_CACHE are mounted and exported for every job by container.options. Only the Node-side caches (browsers, pnpm store, the Go tarball) are declared in the workflow.
  • The repo is cloned by hand, as the other three workflows do: actions/checkout is a JS action and needs node in the container before any step has had a chance to install it.
  • Failure output goes to the job log, not only to an artifact. .dev/app.log is tailed into the log on failure so gitea_ci job_logs can reach it, with the Playwright report uploaded alongside as continue-on-error so a broken upload cannot mask the real failure.

The original plan for the phase follows.

.gitea/workflows/ci.yml — the repository has three workflows and none of them test anything, so gitea_ci currently reports only packaging jobs, which actively misleads an agent checking whether a push was healthy.

  • Job 1 (fast, no display): make lint, both make test passes, tsc --noEmit, Vitest browser mode.
  • Job 2 (display): Xvfb + dbus-run-session + a seeded sandbox + make dev-headless + the Playwright smoke suite, with screenshots and traces uploaded on failure.

Job 2 depends on the fixture generator and the stubbed artifact, so it lands last.

Order and why

1 and 2 are the hard blockers and are worth doing even if nothing else follows — a seeded, scriptable, non-blocking launch is the difference between an agent that can and cannot run this app. 3 is the payoff and is now mostly configuration. 4 is the cheapest coverage per hour and can proceed in parallel with everything else, since it depends on none of it. 5 is a refactor that unblocks a fourth tier we do not have yet. 6 is ergonomics and should wait until the commands stop changing. 7 is last because it depends on all of it.

Risks

  • @playwright/cli is v0.1.x. Interface churn is likely. The mitigation is that @playwright/mcp is the same engine behind a different front end, so a switch is a config change, and the specs written in Phase 3 are plain Playwright either way.
  • Playwright's WebKit is not WebKit2GTK, and does not run here at all. Closer than Chromium in CI, unavailable locally. A GTK-specific rendering bug can still escape, and will not be caught until CI runs — or ever, for views not in the smoke suite.
  • Xvfb is X11, and the app has a Wayland-specific NVIDIA workaround (main.go's DMABuf disable). CI will not exercise the Wayland path at all. Acceptable — that path is a crash workaround, not a feature — but it should be a known blind spot rather than a surprise.
  • Seeds are a second description of a valid YJ_HOME. If the generator drifts from what the app actually writes, tests pass against a state no real install has. Seeds must be produced by running the app, not by writing config and DB rows by hand — the same discipline sql/schemas/ gets, for the same reason.

Deferred

  • Driving the real WebKit2GTK window directly. WEBKIT_INSPECTOR_SERVER=127.0.0.1:9222 exposes WebKit's remote inspector, but the protocol is not CDP and Playwright cannot attach. A bespoke client is the only route and is not worth it.
  • Wails v3, whose e2e story is better documented and whose dev server is the same idea on port 9245. Not a reason to migrate.
  • Component testing via playwright-ct-web. Vitest browser mode covers the same ground with fewer moving parts and a first-party visual regression story.