feat(harness): agent-drivable dev harness and CI that gates
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

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.
This commit is contained in:
2026-08-10 23:20:42 -04:00
parent 65333857e2
commit 5ca6cad45a
117 changed files with 14585 additions and 262 deletions
@@ -0,0 +1,692 @@
# 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.Fatalf`s 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.go``callBinding`, 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.