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.
This commit is contained in:
@@ -168,3 +168,473 @@ built from scratch rather than restored; the old implementation gated
|
||||
several component code paths in ad hoc ways. A separate
|
||||
`deep_catalog_enabled` backend flag briefly existed for the same idea and
|
||||
was removed earlier, when the dump importer left the app binary.
|
||||
|
||||
## The dev server is a real, drivable app (verified 2026-08-10)
|
||||
|
||||
`wails dev` binds an HTTP + WebSocket server on `localhost:34115`
|
||||
(`internal/frontend/devserver/devserver.go`) that serves the frontend
|
||||
with the 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 — measured:
|
||||
`queue.Queue.GetState()` returned real JSON, `SetVolume(42)` produced a
|
||||
real `VolumeChanged`. Multiple clients are supported by design
|
||||
(`notifyExcludingSender` fans events to the other web clients *and* the
|
||||
desktop frontend). See plan 005.
|
||||
|
||||
Four facts that cost time to find:
|
||||
|
||||
- **The GTK window cannot be suppressed.** `devserver.Run` ends in
|
||||
`d.Frontend.Run(ctx)` with no flag to skip it, so headless needs
|
||||
Xvfb. Nobody upstream has a way around this.
|
||||
- **Build the dev binary directly.** `app_dev.go` parses `-devserver`,
|
||||
`-assetdir`, `-loglevel` from `os.Args`, so
|
||||
`go build -tags "dev webkit2_41"` plus those flags gives the same
|
||||
server with no watcher, no reload broadcast and one PID.
|
||||
- **A binding call with wrong argument types hangs forever.** The
|
||||
backend logs `error parsing arguments` and never fires the callback,
|
||||
so the caller's promise never settles. Always use a timeout; the app
|
||||
log is the only place the reason shows up.
|
||||
- **`dbus-run-session` does not break audio, and fixes MPRIS.** It
|
||||
replaces the bus, not `/run/user/1000`, so PulseAudio still works and
|
||||
`org.mpris.MediaPlayer2.yellowjacket` registers on the private bus.
|
||||
|
||||
## Playwright's WebKit does not run on Arch (measured 2026-08-10)
|
||||
|
||||
`playwright-cli install-browser webkit` downloads fine and then fails to
|
||||
link: its Linux build wants Ubuntu 24.04 libraries (`libicu74`,
|
||||
`libWPEWebKit-2.0.so.1`, `libflite`) that Arch does not provide, and the
|
||||
dependency check emits `apt-get` advice. So `--browser=webkit` — the
|
||||
cheap way to approximate the WebKit2GTK renderer we actually ship — is a
|
||||
CI-only capability. Local browser work is Chromium, which is unaffected.
|
||||
|
||||
## The fixture library is generated, and generated by our own writers
|
||||
|
||||
`test_data/music_library_test/` is produced by `cmd/gentestdata`
|
||||
(`make testdata`), not committed — 31 tracks across MP3, FLAC, Ogg
|
||||
Vorbis and WAV, ~700 KB, ~1 s to build. Two rules keep it honest:
|
||||
|
||||
- **Tags are written by `backend/tagwriter`, not by ffmpeg.** ffmpeg
|
||||
only encodes (with `-map_metadata -1`); every tag comes from the same
|
||||
writers the app uses, so a fixture and the reader under test cannot
|
||||
drift into agreeing with each other and disagreeing with reality.
|
||||
`tagwriter.WriteFileTags` exists for this — it is the format switch
|
||||
`WriteUntrackedFileTags` already had, lifted out so tooling with no
|
||||
app to construct can call it.
|
||||
- **The manifest hash covers the spec, not the bytes.** ffmpeg stamps
|
||||
encoder version strings, so identical specs produce different bytes
|
||||
on different ffmpeg builds. `test_data/music_library_test.manifest.json`
|
||||
hashes paths, formats, durations, tags and cover identity instead,
|
||||
and lives *outside* the library root so the scanner never sees it.
|
||||
|
||||
Fixtures are selected in tests by *case* (`testfixtures.CaseCoverDedup`,
|
||||
`CaseUnicode`, `CaseDuplicates`, …) rather than by path. Deliberately
|
||||
malformed files live in a sibling root, `test_data/music_library_broken/`
|
||||
— the clean library's track count has to stay at exactly 31 for seeds
|
||||
to be verifiable, and a zero-byte `.flac` in the scanned tree used to
|
||||
get swept into `testFlacFiles` and fail the duration parser.
|
||||
|
||||
## WAV tags are write-only (found 2026-08-10)
|
||||
|
||||
`backend/tagwriter` writes WAV tags into a RIFF `id3 ` chunk, and
|
||||
`backend/metadata` reads through `dhowden/tag`, which recognises MP3,
|
||||
FLAC, OGG, MP4 and DSF and has **no RIFF parser at all**. So every tag
|
||||
the app writes to a WAV is invisible to the app that wrote it, and WAV
|
||||
tracks always scan in untitled — visible in the Artists view, where the
|
||||
fixture library's WAV tracks produce no "Field Recordings" artist.
|
||||
|
||||
The fix is small (unwrap the `id3 ` chunk and hand the payload to
|
||||
`tag.ReadFrom`) but was out of scope for plan 005.
|
||||
`TestWAVTagsAreNotReadableYet` asserts the gap so that fixing the
|
||||
reader turns into a failing test rather than nothing at all.
|
||||
|
||||
## The headless harness: how to run this app without a window
|
||||
|
||||
`make dev-headless [SEED=<name>]` starts the app in the background and
|
||||
returns; `make dev-stop`, `make dev-logs`. It runs the dev *binary*
|
||||
(`go build -tags "dev webkit2_41"`), not `wails dev`, under
|
||||
`dbus-run-session -- xvfb-run`. See plan 005 and
|
||||
`scripts/dev-headless.sh` for why each of those three is load-bearing.
|
||||
|
||||
**Seeds are built by running the app.** The first-run wizard's
|
||||
dismissal condition is not a config file — it is
|
||||
`GetAllLibrariesWithTrackCounts()` returning something — so
|
||||
`make sandbox-seed NAME=<n>` boots a fresh `YJ_HOME`, calls the real
|
||||
`AddLibrary` binding through `playwright-cli`, waits for the real scan
|
||||
to reach the manifest's track count, stops the app with SIGTERM so the
|
||||
shutdown hooks persist state, and tars the result. Never hand-write a
|
||||
`config.toml` and DB rows: that is a second description of a valid
|
||||
`YJ_HOME`, free to drift, exactly like the migration chain was.
|
||||
|
||||
Seeding points `YJ_CORE_INDEX_URL` at a dead address on purpose, so no
|
||||
seed depends on what the explore artifact server was serving that day.
|
||||
|
||||
Two parsing traps, both already paid for:
|
||||
|
||||
- `playwright-cli` echoes the evaluated source back after the result,
|
||||
so scraping its output for bare digits picks up numbers from your own
|
||||
JavaScript. Return a tagged sentinel (`'YJTRACKS' + '=' + n`) and
|
||||
grep for that.
|
||||
- Waiting on a fixed sleep or on a scan event is worse than waiting on
|
||||
the observable outcome. Polling the track count the app itself
|
||||
reports also validates the fixture manifest against the real scanner.
|
||||
|
||||
## Restoring a database needs foreign keys *off*, not deferred
|
||||
|
||||
`/__test/db/restore` (`backend/testctl`) copies every ordinary table
|
||||
out of an ATTACHed snapshot. The obvious implementation — one
|
||||
transaction with `PRAGMA defer_foreign_keys = ON` — fails at COMMIT
|
||||
with a bare `FOREIGN KEY constraint failed (787)` that names nothing.
|
||||
|
||||
Deferring postpones the *check*; it does not stop `ON DELETE CASCADE`
|
||||
from firing. Tables are copied in name order, which is not dependency
|
||||
order, so `DELETE FROM libraries` cascades away rows of a child table
|
||||
that was already restored earlier in the loop, and the final state is
|
||||
genuinely inconsistent.
|
||||
|
||||
`PRAGMA foreign_keys` is a no-op inside a transaction, so it has to be
|
||||
set on the connection around it. That is safe only because the writer
|
||||
is a single connection (`SetMaxOpenConns(1)`); the restore re-enables
|
||||
enforcement afterwards and runs `PRAGMA foreign_key_check`, so a bad
|
||||
restore is reported instead of left in place.
|
||||
`TestRestoreRoundTrip` pins it.
|
||||
|
||||
Two related traps in the same path: FTS5 virtual tables cannot be
|
||||
written with `SELECT *` and their shadow tables (`_data`, `_idx`,
|
||||
`_docsize`, `_config`) must be rebuilt rather than copied — but the
|
||||
prefix test that excludes them must not swallow `explore_index`, an
|
||||
ordinary table whose name is a prefix of two virtual ones.
|
||||
|
||||
## The event bridge hooks EventsNotify, not EventsOn
|
||||
|
||||
`.playwright/init-events.js` records backend events by wrapping
|
||||
`window.wails.EventsNotify`. That is the single choke point: wails'
|
||||
`ipc_websocket.js` does `case "n": window.wails.EventsNotify(msg)` and
|
||||
fans out to listeners from there, so one wrap captures all 46 events
|
||||
whether or not the app subscribes to them. Wrapping `EventsOn` would
|
||||
have needed 46 registrations and would have missed anything the app
|
||||
does not listen for.
|
||||
|
||||
`window.wails` does not exist when an initScript runs, so the script
|
||||
installs an accessor on `window` and wraps at assignment time (wails'
|
||||
`main.js` does a plain `window.wails = {...}`), then redefines the
|
||||
property as a plain value so nothing downstream can tell.
|
||||
|
||||
The buffer lives on `window.__yjEvents` with `wait()`, `reset()`,
|
||||
`names()`, a `ready()` that resolves only when a binding actually
|
||||
round-trips, and a `call()` that **times out** — a binding invoked with
|
||||
wrong argument types never fires its callback, and a 2s rejection
|
||||
naming `.dev/app.log` is worth more than an infinite hang.
|
||||
|
||||
## Small harness traps, each of which cost a cycle
|
||||
|
||||
- **Paths in `.playwright/cli.config.json` resolve against the config
|
||||
file's directory**, not the repo root. `".playwright/init-events.js"`
|
||||
becomes `.playwright/.playwright/init-events.js`.
|
||||
- **`playwright-cli` and `@playwright/test` have separate browser
|
||||
caches.** The CLI working is no guarantee `npx playwright test` can
|
||||
launch; it needs its own `npx playwright install chromium` (the
|
||||
runner wants `chrome-headless-shell`, which the CLI never fetched).
|
||||
- **`getByRole('button', { name: 'Play' })` also matches "Add queue to
|
||||
playlist".** Accessible-name matching is substring by default; the
|
||||
transport controls need `exact: true`.
|
||||
- **Every fixture track except one is 2–6 seconds.** A spec that plays
|
||||
a track and then clicks pause races the track ending and fails
|
||||
against a correct UI. Use the 90-second `Long Player`
|
||||
(`edge-lengths`), exported as `LONG_TRACK` from `e2e/support`.
|
||||
- **`e2e/` needs `"type": "module"`** or Playwright transpiles the
|
||||
specs to CJS and every `import.meta` in the support code throws
|
||||
"Cannot use 'import.meta' outside a module" — reported as
|
||||
"No tests found".
|
||||
|
||||
## The component tier fakes two globals, and that is all it fakes
|
||||
|
||||
`frontend/wailsjs/` is a pure passthrough: every generated binding is
|
||||
`window['go'][svc][Type][Method](args)` and every runtime call is
|
||||
`window.runtime.X(...)`. So `frontend/test/support/wails-fake.ts`
|
||||
replaces those two globals and nothing else, and the tests then run the
|
||||
*real* generated bindings and the *real* store code. No module mocking,
|
||||
and no second description of the Wails layer to drift from the first —
|
||||
the same discipline `sql/schemas/` and the seeds get.
|
||||
|
||||
The dispatcher mirrors wails' own `internal/frontend/runtime/desktop/
|
||||
events.js`, which matters in two places: listeners registered with
|
||||
`maxCallbacks` expire and are removed mid-iteration, and `EventsEmit`
|
||||
from the frontend notifies local JS listeners *before* it notifies Go
|
||||
(so a frontend emit is observable in-page).
|
||||
|
||||
Four things that cost time:
|
||||
|
||||
- **Store singletons are constructed at module import**, so the fake
|
||||
must be installed from `setupFiles`, and any store that reads config
|
||||
in its constructor loads before a test can stub it. `test/setup.ts`
|
||||
carries import-time defaults for exactly those. Without them a store
|
||||
caches `undefined` where Go would have sent `[]`, and four
|
||||
components then crash on `.length` — which reads as a component bug
|
||||
and is not one.
|
||||
- **`vitest.config.mts`, not `.ts`.** The repo's vite config is
|
||||
`vite.config.mts`; a `.ts` sibling cannot import it, and `mergeConfig`
|
||||
is how the `@go`/`@store`/`@components` aliases get reused rather
|
||||
than restated.
|
||||
- **Vitest 4 takes a provider factory, not a string.**
|
||||
`provider: playwright()` from `@vitest/browser-playwright`, which is
|
||||
a third package beyond `vitest` and `@vitest/browser`, and needs its
|
||||
own `npx playwright install chromium` — a third browser cache after
|
||||
`playwright-cli`'s and `@playwright/test`'s.
|
||||
- **Pre-bundle Web Awesome or Vite reloads mid-test.** Its components
|
||||
are one deep import per element; discovering them lazily makes Vite
|
||||
re-optimise and reload the page underneath a running test. The glob
|
||||
`@awesome.me/webawesome/dist/components/*/*.js` in `optimizeDeps.
|
||||
include` settles it.
|
||||
|
||||
Screenshots need the app's surface, not the default white page: the
|
||||
setup file imports `@store/theme-store` for its side effect (it applies
|
||||
the `--yj-*` ramp to `:root`) and sets the two `index.css` declarations
|
||||
that matter, or a component renders white-on-white and the baseline is
|
||||
blank. And a `@lit-labs/virtualizer` list never produces two identical
|
||||
frames, so `toMatchScreenshot` on `<queue-panel>` fails with "could not
|
||||
capture a stable screenshot" rather than a diff — assert its rows
|
||||
instead.
|
||||
|
||||
## Binding drift is now checked, and it is fast
|
||||
|
||||
`frontend/wailsjs/` is generated by `wails`, **not** by `go generate`,
|
||||
so the pre-commit codegen check never covered it: a renamed Go bound
|
||||
method or struct field first showed up at runtime, in a window, as a
|
||||
call that never settles. `wails generate module` (v2.10.2) rebuilds it
|
||||
in ~1.5 s, which is cheap enough to gate a commit on —
|
||||
`scripts/bindings-check.sh`, `make bindings-check`, and a `lefthook`
|
||||
pre-commit entry. Verified by renaming `queue.GetState` and watching it
|
||||
fail.
|
||||
|
||||
One quirk: the generator rewrites the three `wailsjs/runtime/` files as
|
||||
mode 755 every run. That is not drift, so the check compares with
|
||||
`git -c core.fileMode=false` and restores the modes afterwards.
|
||||
|
||||
## Emitting an event is now one call, and it cannot kill the process
|
||||
|
||||
`events.Emit(ctx, name, data...)` (`backend/events/emit.go`) is the
|
||||
only supported way to push a Wails event; `TestNoDirectRuntimeEmits`
|
||||
fails the build on any other `runtime.EventsEmit` in the tree.
|
||||
|
||||
The reason is that `runtime.getEvents` (wails `runtime.go:47`)
|
||||
`log.Fatalf`s — `os.Exit`, unrecoverable — whenever the context lacks
|
||||
its internal `"events"` value. That is any `context.Background()`, so
|
||||
in-process service tests were impossible and background workers that
|
||||
outlived their context could take the app down at launch.
|
||||
|
||||
Four packages had independently discovered this and hand-rolled a
|
||||
guard (`library.emit`, `download.emit`, `playlist.emitEvent`, and
|
||||
`autotagservice.emitEvent` with a whole `ctxReady` field). Nine other
|
||||
sites guarded on `ctx != nil`, **which does not help** — a non-nil
|
||||
context without the runtime is exactly the fatal case. The wrapper
|
||||
replicates wails' own precondition once and drops at debug level.
|
||||
|
||||
Three things worth knowing:
|
||||
|
||||
- **The test sink rides in the context**, `events.WithSink(ctx, rec)`,
|
||||
not in a package global. A global cannot survive `t.Parallel()` and
|
||||
would put a mutex on every production emit.
|
||||
- **`events.Deliver` is `Emit` that returns `ErrNoRuntime`**, and has
|
||||
exactly one caller: `/__test/emit` in `backend/testctl`. That
|
||||
endpoint exists to *impersonate* a backend emit, so answering `200`
|
||||
for an event that reached nobody would send you debugging the
|
||||
frontend for a backend no-op. Ordinary emitters want `Emit`.
|
||||
- **Enforcement is a walk of the tree, not a lint rule.**
|
||||
golangci-lint runs once per build configuration, so a stray emit in
|
||||
an `indexbuild`- or `dev`-tagged file is only visible to the pass
|
||||
that compiles it. One text walk sees all three, plus anything tagged
|
||||
out entirely. Two traps if you touch that test: the needle has to be
|
||||
built at runtime or the file matches itself, and it has to be the
|
||||
qualified selector (`.EventsEmit(`) or it matches the test's own
|
||||
function name.
|
||||
|
||||
What it unblocks is a fourth test tier — services, in-process, with no
|
||||
app: `backend/queue/emit_test.go`, `backend/config/emit_test.go`,
|
||||
`backend/playlist/emit_test.go` assert on the payload the *frontend
|
||||
receives*, which had never been covered. Two gotchas found writing
|
||||
them: `config.Save` refuses to write a config that was never
|
||||
`Load`ed (so a test that only calls `applyDefaults` sees its second
|
||||
setter fail, not its first), and `queue.SetQueue` resolves anything
|
||||
over `initialBatchSize` in a background phase, so assert with
|
||||
`rec.Wait` rather than immediately after the call.
|
||||
|
||||
## Docs are split by tense, and the split is checkable
|
||||
|
||||
Three places now describe this repo, and the rule for which one a new
|
||||
paragraph goes in is **grammatical, not topical** — a topical split
|
||||
("architecture here, testing there") is what rots, because every new
|
||||
fact has two plausible homes.
|
||||
|
||||
- `.planning/NOTES.md` — **past**: measured, dated, append-only.
|
||||
- `CLAUDE.md` — **present**: what the system is, and why.
|
||||
- `.pi/skills/yellowjacket-dev/` — **imperative**: what to run, in what
|
||||
order, and what it looks like when it fails.
|
||||
|
||||
So the skill carries the checklist for a schema change and CLAUDE.md
|
||||
carries the reasoning behind the two-file rule; the skill carries the
|
||||
headless lifecycle and CLAUDE.md carries only the invariant that seeds
|
||||
are produced by running the app. Phase 6 deleted about half of
|
||||
CLAUDE.md's harness section on those grounds.
|
||||
|
||||
`make skill-check` (`scripts/skill-check.sh`, pre-commit) makes it
|
||||
enforceable: every `make <target>` mentioned under `.pi/**/*.md` must
|
||||
exist. That is the actual anti-drift mechanism — the Makefile is the
|
||||
source of truth for *how* to invoke something and the skill only decides
|
||||
*which*, so a renamed target fails a commit instead of sending an agent
|
||||
confidently at a command that no longer exists. A skill that documents a
|
||||
command slightly wrong is worse than no skill.
|
||||
|
||||
One shell trap it cost: under `set -euo pipefail`,
|
||||
`x="$(make -pqRr | awk … )"` sinks the whole assignment, because
|
||||
`make -q` exits non-zero whenever a target is out of date and `pipefail`
|
||||
propagates that. Wrap it in `{ …; || true; }`.
|
||||
|
||||
## The skill was followed cold, and lost time in exactly one place
|
||||
|
||||
An agent that did not write `.pi/skills/yellowjacket-dev/` brought the
|
||||
app up from a wiped `.dev/` and no fixture library, drove a flow the
|
||||
skill does not describe (open the queue panel, toggle shuffle, assert
|
||||
on `QueueModeChanged`, confirm against `queue.Queue.GetState`) and
|
||||
stopped it — about a minute of wall clock, no dead ends. All four
|
||||
tiers then re-ran green from that cold state: 313 ui-test, 0 issues ×
|
||||
3 lint configurations, 3 test passes, 19/19 e2e.
|
||||
|
||||
The one expensive thing was a genuine config bug, not a doc error.
|
||||
`.playwright/cli.config.json` had `outputDir: "../.playwright-cli"`,
|
||||
written on the belief — which `references/harness.md` stated as a flat
|
||||
rule — that every path in that file resolves against the config file's
|
||||
directory. **Only `initScript` does.** `outputDir` resolves against the
|
||||
shell's cwd, so every snapshot and console log was landing in
|
||||
`/home/logan/Development/.playwright-cli`, one level *above* the repo:
|
||||
outside `.gitignore`, outside `find`, and invisible to the obvious
|
||||
`ls .playwright-cli/`. That directory still held a stale snapshot from
|
||||
the previous session, so the obvious `ls -t | head -1` returned it
|
||||
silently, and the transport buttons appeared to have lost their
|
||||
accessible names — a fabricated regression in phase 3's work that took
|
||||
a DOM walk to disprove. Reading a *stale* artifact is much worse than
|
||||
reading none, because it answers.
|
||||
|
||||
Four smaller corrections, all now in the skill:
|
||||
|
||||
- `make sandbox-seed` already depends on `make testdata`, so listing
|
||||
both made the fixture step look separately required. It also takes
|
||||
~10 s with warm caches, not the ~30 s claimed.
|
||||
- `make ui-setup` and `make e2e-setup` are once-per-clone
|
||||
prerequisites and are *not* dependencies of `make ui-test` /
|
||||
`make e2e`. The skill never mentioned them; on a fresh clone both
|
||||
fail with a missing-browser error that reads like a broken test.
|
||||
This matters for CI, which has no warm caches by definition.
|
||||
- `snapshot` prints a *path*, not the tree. Not said anywhere.
|
||||
- `make dev-stop` does not close the browser session;
|
||||
`playwright-cli -s=yj close` is a separate step.
|
||||
|
||||
And one place the tooling taught the opposite of the skill:
|
||||
`scripts/dev-headless.sh`'s own success banner suggested
|
||||
`eval "async () => await window.go.queue.Queue.GetState()"` — a bare
|
||||
`window.go` call with no timeout, which is precisely the hang the
|
||||
banner's next paragraph warns about. A gotcha documented in prose and
|
||||
contradicted by the copy-pasteable line three inches above it will lose
|
||||
every time; the banner now prints the `__yjEvents.call` form.
|
||||
|
||||
## An ALSA null PCM is enough for CI audio, and it clocks
|
||||
|
||||
Phase 7's job 2 needs playback to actually advance, because
|
||||
`e2e/specs/playback.spec.ts` asserts the elapsed clock moves — a
|
||||
missing audio device fails it in a way that reads like flake, since
|
||||
`app.go` joins `InitSpeaker` failure into `startupErr` and lets
|
||||
everything else work.
|
||||
|
||||
Measured locally, with PulseAudio made unreachable
|
||||
(`XDG_RUNTIME_DIR` pointed at an empty dir, `PULSE_SERVER=none`) and
|
||||
`ALSA_CONFIG_PATH` pointing at four lines:
|
||||
|
||||
```
|
||||
</usr/share/alsa/alsa.conf>
|
||||
pcm.!default { type null }
|
||||
ctl.!default { type null }
|
||||
```
|
||||
|
||||
`InitSpeaker` succeeded in 36 ms and all six playback/queue specs
|
||||
passed, including "the elapsed time advances". oto/v3 talks to
|
||||
libasound directly, and ALSA's `null` plugin advances its pointer on a
|
||||
timer rather than discarding instantly, so beep's stream is consumed at
|
||||
real-time rate. **No PipeWire, no PulseAudio and no daemon of any kind
|
||||
is required in the container** — one env var and a file.
|
||||
|
||||
Also found while setting this up: `scripts/dev-headless.sh` does *not*
|
||||
set `YJ_CORE_INDEX_URL`; only `scripts/seed-sandbox.sh` does. So a
|
||||
seeded run started by hand still reaches for the real explore artifact.
|
||||
Harmless locally, a network dependency and a minute of wall clock in
|
||||
CI — job 2 must set the dead-address override itself.
|
||||
|
||||
## CI was prototyped in a container before it was written, and it found a real bug
|
||||
|
||||
Both jobs of `.gitea/workflows/ci.yml` were built as shell scripts and
|
||||
run to green in a bare `ubuntu:24.04` container (`docker run -v
|
||||
repo:/src -v cache:/cache`) before a line of YAML existed, then the
|
||||
YAML was transcribed back out of the workflow and re-run in the same
|
||||
container to prove the transcription. That is worth the extra half
|
||||
hour on a self-hosted runner: the alternative is push-and-see, and a
|
||||
Gitea Actions run that never starts looks exactly like one that passed.
|
||||
|
||||
**`make lint` was linting three configurations nothing builds.** All
|
||||
three passes omitted `webkit2_41`, so wails resolved `webkit2gtk-4.0`.
|
||||
Arch still ships `webkit2gtk-4.0.pc`, so it passed locally and had
|
||||
done for the life of the repo; Ubuntu 24.04 dropped 4.0, and the
|
||||
**`dev` pass** fails there — wails' own `app_dev.go` is `dev`-tagged
|
||||
and drags in the 4.0 assetserver, which the other two passes never
|
||||
compile. The tag sets now match `make test` exactly
|
||||
(`webkit2_41`, `webkit2_41 indexbuild`, `webkit2_41 dev`). Still 0
|
||||
issues × 3 on Arch, and now 0 × 3 on Ubuntu too. Note what this means:
|
||||
"lint passes" and "the thing lint compiled is the thing we ship" were
|
||||
different claims, and only a second distro could tell them apart.
|
||||
|
||||
Five smaller container facts, all now comments in the workflow:
|
||||
|
||||
- **`libasound2-dev`, not just `libasound2t64`.** oto/v3 dies at
|
||||
`pkg-config --cflags -- alsa` before a line is compiled.
|
||||
- **`PLAYWRIGHT_BROWSERS_PATH` unifies the location, not the
|
||||
revisions.** `@playwright/cli` bundles its own `playwright-core`
|
||||
pinned to a different Chromium build than `e2e/`'s
|
||||
`@playwright/test`, so both must install into the shared directory.
|
||||
Installing one gives the other "Browser chromium is not installed;
|
||||
expected executable at …/chromium-1237/…". The "three separate
|
||||
browser caches" trap survives being pointed at one path.
|
||||
- **`git config --global --add safe.directory`** or `bindings-check`
|
||||
fails on a clone the container user does not own.
|
||||
- **The runner already mounts and exports `GOMODCACHE`, `GOCACHE` and
|
||||
`GOLANGCI_LINT_CACHE`** for every job via `container.options`, and
|
||||
`valid_volumes` is a glob over the cache root
|
||||
(`/home/logan/docker/gitea/data/runner/cache/**`), so new caches need
|
||||
no runner-side change. Only the Node-side ones had to be declared.
|
||||
- **The fixture hash is deterministic per ffmpeg, not across
|
||||
versions**: `5425fbb454a2` on Arch (ffmpeg n8.1.2), `599a8dd4f152`
|
||||
on Ubuntu 24.04. Nothing asserts a literal hash, so this is
|
||||
harmless — but a test that pinned one would be portable only by
|
||||
accident.
|
||||
|
||||
**Setting `YJ_CORE_INDEX_URL` for the *app* run, not just for seeding,
|
||||
is worth 8x on the suite.** `scripts/dev-headless.sh` never set it —
|
||||
only `seed-sandbox.sh` did — so a seeded local run still fetches the
|
||||
real explore artifact, and `testctl.spec.ts`'s restore then copies
|
||||
every table of a database full of catalogue: 42 s locally, versus a
|
||||
whole 19-spec suite in 7.3 s in CI with the artifact stubbed out.
|
||||
|
||||
## Playwright's WebKit passes, so it gates
|
||||
|
||||
19/19, in the same container, ~11 s on top of Chromium's ~7 s. It had
|
||||
never been run anywhere before — Arch cannot start it — so the honest
|
||||
default would have been advisory. Running it once in a throwaway
|
||||
container turned a coin flip into a decision: it is a **required**
|
||||
step.
|
||||
|
||||
Two things make that safe rather than brave. Nothing in `e2e/`
|
||||
compares pixels — every assertion is an event payload, a `data-testid`,
|
||||
an attribute or backend state, and the `toMatchScreenshot` baselines
|
||||
live in the Chromium-only Vitest tier — so a WebKit failure cannot be
|
||||
antialiasing noise; it is an engine difference in custom-element
|
||||
upgrade, a11y-tree shape or event ordering, which is exactly the
|
||||
WebKit2GTK signal we otherwise have no way to get. And it is cheap
|
||||
enough that the earlier plan to scope it (skip `testctl.spec.ts`,
|
||||
which tests Go and has no engine content) is not worth the
|
||||
complexity at 11 s.
|
||||
|
||||
Reference in New Issue
Block a user