Files
yellowjacket/backend/testctl/dbstate_dev_test.go
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

208 lines
5.5 KiB
Go

//go:build dev
package testctl
import (
"context"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"os"
"testing"
"yellowjacket/backend/database"
)
// newDeps wires the control surface to an in-memory database and a
// throwaway YJ_HOME, so snapshots land somewhere the test owns.
func newDeps(t *testing.T) Deps {
t.Helper()
t.Setenv("YJ_HOME", t.TempDir())
return Deps{
Logger: slog.New(slog.DiscardHandler),
DB: database.NewTestDB(t),
Context: context.Background,
}
}
// TestRestoreRoundTrip is the regression test for the failure this
// endpoint shipped with first: copying tables in *name* order deletes a
// parent row whose ON DELETE CASCADE then wipes a child table already
// restored earlier in the loop, and the commit fails with a bare
// "FOREIGN KEY constraint failed (787)" naming nothing. Deferring the
// check is not enough — the cascade still fires — so the copy runs with
// foreign keys off and is verified afterwards.
func TestRestoreRoundTrip(t *testing.T) {
// No t.Parallel: newDeps uses t.Setenv (YJ_HOME), which the testing
// package forbids in parallel tests because the environment is
// process-wide.
d := newDeps(t)
if _, err := d.DB.ExecContext(
`INSERT INTO libraries (id, name, path) VALUES (1, 'fixtures', '/music')`,
); err != nil {
t.Fatalf("seed library: %v", err)
}
if _, err := d.DB.ExecContext(
`INSERT INTO artist_credit (id, text) VALUES (1, 'Fixture Artist')`,
); err != nil {
t.Fatalf("seed artist credit: %v", err)
}
if _, err := d.DB.ExecContext(
`INSERT INTO recordings (id, name, artist_credit_id)
VALUES (1, 'A', 1)`,
); err != nil {
t.Fatalf("seed recording: %v", err)
}
if _, err := d.DB.ExecContext(
`INSERT INTO audio_files
(file_path, length_milliseconds, file_type_id, recording_id, library_id)
VALUES ('/music/a.mp3', 2000, 1, 1, 1)`,
); err != nil {
t.Fatalf("seed track: %v", err)
}
snapReq := httptest.NewRequest(http.MethodPost, "/__test/db/snapshot?name=unit", nil)
if _, err := handleSnapshot(d, snapReq); err != nil {
t.Fatalf("snapshot: %v", err)
}
if _, err := d.DB.ExecContext(`DELETE FROM audio_files`); err != nil {
t.Fatalf("mutate: %v", err)
}
if got := countTracks(t, d); got != 0 {
t.Fatalf("after delete: got %d tracks, want 0", got)
}
restoreReq := httptest.NewRequest(http.MethodPost, "/__test/db/restore?name=unit", nil)
if _, err := handleRestore(d, restoreReq); err != nil {
t.Fatalf("restore: %v", err)
}
if got := countTracks(t, d); got != 1 {
t.Fatalf("after restore: got %d tracks, want 1", got)
}
// Enforcement must be back on afterwards; leaving it off would let
// every later test — and the running app — write garbage silently.
var fk int
if err := d.DB.QueryRowWriter("PRAGMA foreign_keys").Scan(&fk); err != nil {
t.Fatalf("read pragma: %v", err)
}
if fk != 1 {
t.Fatal("foreign keys left disabled after restore")
}
}
// TestRestorableTablesSkipsFTSInternals guards the other half of the
// copy: FTS5 virtual tables cannot be written with SELECT *, and their
// shadow tables (_data, _idx, _docsize, _config) are storage details
// that must be rebuilt rather than copied.
func TestRestorableTablesSkipsFTSInternals(t *testing.T) {
tables, err := restorableTables(newDeps(t))
if err != nil {
t.Fatalf("restorableTables: %v", err)
}
if len(tables) == 0 {
t.Fatal("no restorable tables found")
}
for _, name := range tables {
switch name {
case "search_index", "lyrics_index", "explore_index_fts",
"explore_champion_fts":
t.Errorf("virtual table %q must not be copied", name)
case "search_index_data", "lyrics_index_idx",
"explore_index_fts_config":
t.Errorf("shadow table %q must not be copied", name)
}
}
// explore_index is an ordinary table whose name is a prefix of two
// virtual ones; excluding it would silently drop the catalog.
if !contains(tables, "explore_index") {
t.Error("explore_index was wrongly treated as an FTS internal")
}
}
func TestSnapshotNameIsValidated(t *testing.T) {
d := newDeps(t)
for _, name := range []string{"", "../escape", "has space", "a/b"} {
req := httptest.NewRequest(
http.MethodPost,
"/__test/db/snapshot?name="+url.QueryEscape(name),
nil,
)
if _, err := handleSnapshot(d, req); err == nil {
t.Errorf("snapshot(%q) was accepted", name)
}
}
}
// TestRegisterRequiresOptIn pins the second gate: a dev build alone must
// not expose the surface, because `make dev` is something a human runs.
func TestRegisterRequiresOptIn(t *testing.T) {
_ = os.Unsetenv(EnvEnable)
var r recordingRegistrar
Register(&r, Deps{Logger: slog.New(slog.DiscardHandler)})
if len(r.patterns) != 0 {
t.Fatalf("registered %v without %s=1", r.patterns, EnvEnable)
}
t.Setenv(EnvEnable, "1")
Register(&r, Deps{Logger: slog.New(slog.DiscardHandler)})
if len(r.patterns) != 1 || r.patterns[0] != Prefix {
t.Fatalf("got patterns %v, want [%s]", r.patterns, Prefix)
}
}
// recordingRegistrar stands in for *assets.Handler and remembers what
// was mounted.
type recordingRegistrar struct {
patterns []string
}
func (r *recordingRegistrar) RegisterHandler(pattern string, _ http.Handler) {
r.patterns = append(r.patterns, pattern)
}
func countTracks(t *testing.T, d Deps) int {
t.Helper()
var n int
if err := d.DB.QueryRowWriter(
"SELECT COUNT(*) FROM audio_files",
).Scan(&n); err != nil {
t.Fatalf("count: %v", err)
}
return n
}
func contains(haystack []string, needle string) bool {
for _, s := range haystack {
if s == needle {
return true
}
}
return false
}