176 lines
8.9 KiB
Markdown
176 lines
8.9 KiB
Markdown
# 005 — Agent development harness
|
||
|
||
**Status:** implemented
|
||
**Branch:** main
|
||
**Created:** 2026-08-10
|
||
**Shipped:** 2026-08-11 (`5ca6cad`, `ccacd67`)
|
||
**Follows:** 004-wanted-list
|
||
|
||
## Problem
|
||
|
||
A coding agent could develop this repo's Go packages competently and
|
||
could not develop the *application* at all. It could read 66k lines of
|
||
backend, run 31k lines of tests and lint two build configurations. It
|
||
could not start the app, see a window, click anything, or find out
|
||
whether a change to a Lit component rendered.
|
||
|
||
The gap was not missing tests. Every path to running YellowJacket ended
|
||
in a blocking GTK window — `make dev`, `make sandbox <n>` and
|
||
`make fresh-install` all launch a WebKit window and never return the
|
||
shell. So 265 bound methods across 11 services, 46 backend events, 33
|
||
component directories, 13 reactive stores and a 357-line keyboard
|
||
shortcut service had exactly one form of verification available:
|
||
`tsc --noEmit`.
|
||
|
||
Three secondary facts made it worse. `test_data/music_library_test/` was
|
||
referenced by three test files, gitignored, absent, and had no
|
||
generator, so the audio path was unreachable from a clean clone. No
|
||
workflow ran `make test` or `make lint` — gating existed only in
|
||
`lefthook.yml`, which is local and `--no-verify`-skippable. And there
|
||
was no `.pi/`, so none of the awkward invocations were wrapped in
|
||
anything an agent could call.
|
||
|
||
## The unlock
|
||
|
||
`wails dev` already runs an HTTP + WebSocket dev server on
|
||
`localhost:34115` (`internal/frontend/devserver/`). It serves the real
|
||
frontend assets, injects the real generated bindings, and bridges every
|
||
method call and every event over a websocket to the **same running Go
|
||
backend** a desktop window attaches to. A plain Chromium pointed at
|
||
that port gets a fully functional YellowJacket — not a mock, not a stub
|
||
`wailsjs` layer. This is the sanctioned approach; Wails v3 ships a guide
|
||
for it and the v2 community reached the same answer independently
|
||
(discussion #4205).
|
||
|
||
The one caveat: `devserver.Run` still calls `d.Frontend.Run(ctx)`, which
|
||
opens the GTK window and blocks, with no flag to suppress it. So the app
|
||
needs a display — a virtual one.
|
||
|
||
## What shipped
|
||
|
||
117 files, ~14.6k lines. Four test tiers, cheapest first:
|
||
|
||
| Tier | Command | Cost | Needs the app? |
|
||
|---|---|---|---|
|
||
| Components and stores | `make ui-test` | ~2 s, 313 tests | no |
|
||
| Services, in-process | `make test` | 3 passes | no |
|
||
| Exploration | `make dev-headless` + `playwright-cli` | interactive | yes |
|
||
| Frozen regressions | `make e2e` | ~20 s, 19 specs × 2 browsers | yes |
|
||
|
||
**Fixtures** (`cmd/gentestdata`, `make testdata`, `internal/testfixtures`).
|
||
31 tracks across MP3/FLAC/OGG/WAV in ~1 s, deterministic, gitignored,
|
||
covering the cases the app has code for: shared album art (dedup),
|
||
missing and partial tags, unicode and RTL, multi-disc, various artists,
|
||
a deliberate duplicate pair. Tests select by *case*
|
||
(`CaseCoverDedup`, `CaseUnicode`, …) rather than by path, and skip
|
||
themselves when the library has not been generated.
|
||
|
||
**Headless launch** (`scripts/dev-headless.sh`, `dev-stop.sh`,
|
||
`seed-sandbox.sh`). `dbus-run-session -- xvfb-run -a` around the
|
||
`dev`-tagged binary, backgrounded, writing `.dev/app.pid` and
|
||
`.dev/app.log` and returning once `:34115` answers. The dev binary is
|
||
run directly rather than through `wails dev`: `app_dev.go` parses
|
||
`-devserver`/`-assetdir` from `os.Args`, so one process with a
|
||
deterministic startup replaces a file watcher and rebuild supervisor an
|
||
agent does not want. `dbus-run-session` is not incidental — a private
|
||
session bus makes MPRIS actually register.
|
||
|
||
**Driving and seeing.** `.playwright/init-events.js` records every
|
||
backend event on `window.__yjEvents` by wrapping
|
||
`window.wails.EventsNotify`, the single choke point all 46 events pass
|
||
through, so assertions await an event rather than a timeout. It also
|
||
provides `ready()` and a `call()` that times out. `backend/testctl`
|
||
mounts `/__test/` on the existing asset handler — `health`,
|
||
`db/snapshot`, `db/restore`, `emit`, `sql` — gated twice, behind the
|
||
`dev` build tag and behind `YJ_TESTCTL=1`. A `data-testid`/aria pass
|
||
turned out to be mostly an accessibility fix: the five transport
|
||
buttons had no accessible name at all.
|
||
|
||
**Component coverage** (`frontend/test/`, Vitest 4 browser mode).
|
||
`frontend/wailsjs/` is a pure passthrough to `window.go` /
|
||
`window.runtime`, so faking just those two globals runs the *real*
|
||
generated bindings and the *real* store code — no module mocking, and
|
||
no second description of the Wails layer free to drift.
|
||
`make bindings-check` regenerates `frontend/wailsjs` in ~1.5 s and
|
||
fails on a dirty tree, closing the gap where a renamed Go field first
|
||
appeared at runtime in a window.
|
||
|
||
**`events.Emit`** (`backend/events/`). `runtime.getEvents` `log.Fatalf`s
|
||
on any context lacking wails' internal `"events"` value — any
|
||
`context.Background()` — so 35 emit sites could not run under test and a
|
||
background worker could take the app down. All 35 now route through one
|
||
wrapper that drops at debug level instead. Four packages had each
|
||
hand-rolled the same guard; nine more guarded on `ctx != nil`, which
|
||
does not help. The test sink rides in the context
|
||
(`events.WithSink`), and `TestNoDirectRuntimeEmits` walks the tree —
|
||
not a lint rule, because lint runs once per build configuration and
|
||
would miss a stray emit in a tagged-out file.
|
||
|
||
**pi affordances** (`.pi/`). `skills/yellowjacket-dev/` is the
|
||
operational manual; `prompts/e2e.md` promotes a hand-driven session
|
||
into a spec; `journal.md` is the work log. `make skill-check` fails a
|
||
commit if the skill cites a make target that does not exist.
|
||
|
||
**CI that gates** (`.gitea/workflows/ci.yml`). Two jobs in
|
||
`ubuntu:24.04`: `check` (lint ×3, test ×3, `tsc --noEmit`, `ui-test`,
|
||
`bindings-check`, `skill-check`) and `e2e` (Xvfb + private bus +
|
||
fixtures + seed + `dev-headless` + Playwright on **Chromium and
|
||
WebKit**). The other three workflows only package, so `gitea_ci`
|
||
previously reported nothing about whether a push was healthy.
|
||
|
||
## Decisions worth keeping
|
||
|
||
- **The split between the three docs is grammatical, not topical.**
|
||
`NOTES.md` past, `CLAUDE.md` present, the skill imperative. A topical
|
||
split rots because every new fact gets two plausible homes.
|
||
- **Seeds are produced by running the app**, never by hand-writing
|
||
`config.toml` and DB rows — the same discipline `sql/schemas/` gets,
|
||
for the same reason. A hand-built `YJ_HOME` is a second description
|
||
of a valid one and will drift.
|
||
- **The Makefile is the source of truth for *how* to invoke something**;
|
||
the skill only decides *which* and *in what order*, and
|
||
`make skill-check` enforces it.
|
||
- **Verify in a fresh clone, not a copy of the working tree.** The CI
|
||
prototype ran both jobs in one mounted directory and so consumed a
|
||
`frontend/dist` an earlier job had built — hiding that `main.go`
|
||
embeds it and every Go typecheck needs it. The question is not
|
||
clean-vs-dirty but *whose* dirt.
|
||
- **`make lint`'s tag sets must equal `make test`'s.** Without
|
||
`webkit2_41` wails resolves `webkit2gtk-4.0`, which Arch ships and
|
||
Ubuntu 24.04 does not, so lint was checking a configuration that only
|
||
built on one distro. CI caught this on its first run.
|
||
- **Playwright's WebKit gates** because it was measured (19/19) rather
|
||
than assumed, and because nothing in `e2e/` compares pixels — so a
|
||
failure is an engine difference, not baseline noise. It is the only
|
||
WebKit2GTK signal obtainable, since it cannot start on Arch at all.
|
||
|
||
## Known blind spots
|
||
|
||
- **Xvfb is X11**, and `main.go` carries a Wayland-specific NVIDIA
|
||
DMABuf workaround. CI never exercises that path. Acceptable — it is a
|
||
crash workaround, not a feature — but it is a blind spot, not a
|
||
surprise.
|
||
- **Playwright's WebKit is not WebKit2GTK.** Closer than Chromium,
|
||
still not the shipped renderer. A GTK-specific rendering bug can
|
||
escape, and will for any view not in the smoke suite.
|
||
- **The fixture hash is deterministic per ffmpeg, not across versions**
|
||
(`5425fbb454a2` on Arch, `599a8dd4f152` on Ubuntu 24.04). Nothing
|
||
asserts a literal hash; a test that did would be portable by accident.
|
||
|
||
## Left open, deliberately
|
||
|
||
- **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. Found by
|
||
the fixtures and pinned by `TestWAVTagsAreNotReadableYet`. The fix is
|
||
small: unwrap the chunk, hand the payload to `tag.ReadFrom`.
|
||
- **`themeStore.loadFromBackend`'s failure handler cannot recover** — it
|
||
re-derives the colour ramp from the state that just failed it. One
|
||
line; reachable only if the backend returns an empty accent.
|
||
- **`backend/playlist` has no CRUD suite.** 2,900 lines; phase 5 added
|
||
four emit-focused tests. Its own piece of work.
|
||
- **Driving the real WebKit2GTK window.**
|
||
`WEBKIT_INSPECTOR_SERVER` 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.
|