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.
208 lines
5.5 KiB
Go
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
|
|
}
|