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:
@@ -0,0 +1,326 @@
|
||||
//go:build dev
|
||||
|
||||
package testctl
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// snapshotDir keeps snapshots inside the sandbox's own YJ_HOME, so
|
||||
// deleting the home deletes them and nothing leaks between runs.
|
||||
func snapshotDir() (string, error) {
|
||||
dir := filepath.Join(filepath.Dir(dbPath()), "testctl")
|
||||
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
func snapshotPath(name string) (string, error) {
|
||||
if !safeName.MatchString(name) {
|
||||
return "", errBadName
|
||||
}
|
||||
|
||||
dir, err := snapshotDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return filepath.Join(dir, name+".db"), nil
|
||||
}
|
||||
|
||||
// handleSnapshot copies the live database with VACUUM INTO, which takes
|
||||
// a consistent copy without stopping the app or closing the handle.
|
||||
//
|
||||
// POST /__test/db/snapshot?name=pristine
|
||||
func handleSnapshot(d Deps, r *http.Request) (any, error) {
|
||||
path, err := snapshotPath(r.URL.Query().Get("name"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// VACUUM INTO refuses to overwrite, and a spec re-snapshotting the
|
||||
// same name means "replace", not "fail".
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, err := d.DB.ExecContext("VACUUM INTO ?", path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return map[string]any{"path": path, "bytes": info.Size()}, nil
|
||||
}
|
||||
|
||||
// handleRestore puts the database back to a previous snapshot without
|
||||
// restarting the app.
|
||||
//
|
||||
// It copies rows rather than files because the app holds the file open
|
||||
// (two connection pools, WAL) and cannot be made to reopen it from
|
||||
// here. ATTACH runs on the writer connection — an attachment is
|
||||
// invisible to the read pool, which is a separate sql.DB over the same
|
||||
// file, so anything touching `snap.` must avoid QueryContext.
|
||||
//
|
||||
// POST /__test/db/restore?name=pristine
|
||||
func handleRestore(d Deps, r *http.Request) (any, error) {
|
||||
path, err := snapshotPath(r.URL.Query().Get("name"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return nil, errNoSnapshot
|
||||
}
|
||||
|
||||
if _, err := d.DB.ExecContext("ATTACH DATABASE ? AS snap", path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if _, err := d.DB.ExecContext("DETACH DATABASE snap"); err != nil {
|
||||
d.Logger.Error("testctl could not detach snapshot",
|
||||
"err", err.Error())
|
||||
}
|
||||
}()
|
||||
|
||||
tables, err := restorableTables(d)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := copyTables(d, tables); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := checkForeignKeys(d); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// search_index and lyrics_index are FTS5 tables maintained by Go,
|
||||
// not by triggers, so a row copy leaves them stale. The explore
|
||||
// FTS tables *are* trigger-maintained off explore_index and
|
||||
// re-synced by the copy above.
|
||||
if err := d.DB.RebuildSearchIndex(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := d.DB.RebuildLyricsIndex(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return map[string]any{"restored": path, "tables": len(tables)}, nil
|
||||
}
|
||||
|
||||
// restorableTables lists the ordinary tables to copy.
|
||||
//
|
||||
// Two kinds are excluded. FTS5 virtual tables cannot be written by
|
||||
// SELECT * (their column shape is not their storage shape), and every
|
||||
// shadow table backing one — <name>_data, _idx, _content, _docsize,
|
||||
// _config — is an implementation detail that must be rebuilt rather
|
||||
// than copied.
|
||||
func restorableTables(d Deps) ([]string, error) {
|
||||
// main.sqlite_master is readable from the read pool; only `snap.`
|
||||
// requires the writer connection.
|
||||
rows, err := d.DB.QueryContext(
|
||||
`SELECT name, COALESCE(sql, '') FROM main.sqlite_master
|
||||
WHERE type = 'table' AND name NOT LIKE 'sqlite_%'
|
||||
ORDER BY name`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var (
|
||||
ordinary []string
|
||||
virtual []string
|
||||
)
|
||||
|
||||
for rows.Next() {
|
||||
var name, ddl string
|
||||
if err := rows.Scan(&name, &ddl); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if strings.HasPrefix(strings.ToUpper(ddl), "CREATE VIRTUAL TABLE") {
|
||||
virtual = append(virtual, name)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
ordinary = append(ordinary, name)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]string, 0, len(ordinary))
|
||||
|
||||
for _, name := range ordinary {
|
||||
if isShadowTable(name, virtual) {
|
||||
continue
|
||||
}
|
||||
|
||||
out = append(out, name)
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// isShadowTable reports whether name is storage belonging to one of the
|
||||
// given virtual tables.
|
||||
func isShadowTable(name string, virtual []string) bool {
|
||||
for _, v := range virtual {
|
||||
if strings.HasPrefix(name, v+"_") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// copyTables replaces the contents of every named table from `snap`.
|
||||
//
|
||||
// Foreign keys are switched **off** for the duration, not merely
|
||||
// deferred. Deferring only postpones the *check*; it does not stop
|
||||
// ON DELETE CASCADE from firing, and the tables are copied in name
|
||||
// order, which is not dependency order — so `DELETE FROM libraries`
|
||||
// cascades away the rows of a child table that was restored earlier in
|
||||
// the loop, and the commit then fails with a bare "FOREIGN KEY
|
||||
// constraint failed (787)" that points at nothing. Measured, not
|
||||
// theorised.
|
||||
//
|
||||
// PRAGMA foreign_keys is a no-op inside a transaction, so it has to be
|
||||
// set on the connection around it. That is safe here only because the
|
||||
// writer is a single connection and this is a dev-only endpoint; the
|
||||
// caller re-enables and then verifies with PRAGMA foreign_key_check,
|
||||
// so an inconsistent restore is reported rather than left in place.
|
||||
func copyTables(d Deps, tables []string) error {
|
||||
if _, err := d.DB.ExecContext("PRAGMA foreign_keys = OFF"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if _, err := d.DB.ExecContext("PRAGMA foreign_keys = ON"); err != nil {
|
||||
d.Logger.Error("testctl could not re-enable foreign keys",
|
||||
"err", err.Error())
|
||||
}
|
||||
}()
|
||||
|
||||
tx, err := d.DB.BeginTx()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
for _, name := range tables {
|
||||
quoted := `"` + strings.ReplaceAll(name, `"`, `""`) + `"`
|
||||
|
||||
if _, err := tx.Exec("DELETE FROM main." + quoted); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(
|
||||
"INSERT INTO main." + quoted + " SELECT * FROM snap." + quoted,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// checkForeignKeys verifies the restored database is self-consistent,
|
||||
// since the copy ran with enforcement off.
|
||||
func checkForeignKeys(d Deps) error {
|
||||
rows, err := d.DB.QueryContext("PRAGMA main.foreign_key_check")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var tables []string
|
||||
|
||||
for rows.Next() {
|
||||
var (
|
||||
table, parent string
|
||||
rowid, fkid sql.NullInt64
|
||||
)
|
||||
|
||||
if err := rows.Scan(&table, &rowid, &parent, &fkid); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tables = append(tables, table+"->"+parent)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(tables) > 0 {
|
||||
return fmt.Errorf("%w: %s", errInconsistent,
|
||||
strings.Join(tables[:min(len(tables), 5)], ", "))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// scanAll turns a result set into JSON-shaped rows. Values arrive as
|
||||
// any so that a spec can assert on them without the endpoint having to
|
||||
// know the schema.
|
||||
func scanAll(rows *sql.Rows) (any, error) {
|
||||
cols, err := rows.Columns()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := []map[string]any{}
|
||||
|
||||
for rows.Next() {
|
||||
cells := make([]any, len(cols))
|
||||
ptrs := make([]any, len(cols))
|
||||
|
||||
for i := range cells {
|
||||
ptrs[i] = &cells[i]
|
||||
}
|
||||
|
||||
if err := rows.Scan(ptrs...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
row := make(map[string]any, len(cols))
|
||||
|
||||
for i, col := range cols {
|
||||
// []byte encodes as base64 in JSON, which is unreadable
|
||||
// for the text columns this mostly returns.
|
||||
if b, ok := cells[i].([]byte); ok {
|
||||
row[col] = string(b)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
row[col] = cells[i]
|
||||
}
|
||||
|
||||
out = append(out, row)
|
||||
}
|
||||
|
||||
return map[string]any{"columns": cols, "rows": out}, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
//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
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
//go:build dev
|
||||
|
||||
package testctl
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"yellowjacket/backend/events"
|
||||
"yellowjacket/backend/system"
|
||||
)
|
||||
|
||||
// handleHealth answers the one question every spec starts with: is the
|
||||
// backend up, and is it looking at the library it should be?
|
||||
//
|
||||
// The frontend can answer parts of this, but only after it has rendered
|
||||
// — which is exactly the thing under test. This answers before a
|
||||
// single component has mounted, so it is usable as a gate.
|
||||
func handleHealth(d Deps, _ *http.Request) (any, error) {
|
||||
out := map[string]any{
|
||||
"ok": true,
|
||||
"home": os.Getenv("YJ_HOME"),
|
||||
"dbPath": dbPath(),
|
||||
"pid": os.Getpid(),
|
||||
"context": d.Context() != nil,
|
||||
}
|
||||
|
||||
counts := map[string]int64{}
|
||||
|
||||
for table, query := range map[string]string{
|
||||
"tracks": "SELECT COUNT(*) FROM audio_files",
|
||||
"libraries": "SELECT COUNT(*) FROM libraries",
|
||||
"playlists": "SELECT COUNT(*) FROM playlists",
|
||||
"queueTracks": "SELECT COUNT(*) FROM queue_tracks",
|
||||
"exploreIndex": "SELECT COUNT(*) FROM explore_index",
|
||||
} {
|
||||
var n int64
|
||||
if err := d.DB.QueryRowWriter(query).Scan(&n); err != nil {
|
||||
counts[table] = -1
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
counts[table] = n
|
||||
}
|
||||
|
||||
out["counts"] = counts
|
||||
|
||||
libs, err := libraryRows(d)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out["libraries"] = libs
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// libraryRows lists the configured libraries by name and path, so a
|
||||
// spec can assert it is driving the fixture library and not somebody's
|
||||
// real music collection.
|
||||
func libraryRows(d Deps) ([]map[string]any, error) {
|
||||
rows, err := d.DB.QueryContext(
|
||||
"SELECT id, name, path FROM libraries ORDER BY id",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
out := []map[string]any{}
|
||||
|
||||
for rows.Next() {
|
||||
var (
|
||||
id int64
|
||||
name, path string
|
||||
)
|
||||
|
||||
if err := rows.Scan(&id, &name, &path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out = append(out, map[string]any{
|
||||
"id": id, "name": name, "path": path,
|
||||
})
|
||||
}
|
||||
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// handleEmit pushes a backend event into every connected frontend.
|
||||
//
|
||||
// This is the biggest lever the surface has. Half this app is
|
||||
// push-driven, and several of those events are only produced by work
|
||||
// that takes minutes to hours (a full scan, a download, an artifact
|
||||
// import). Emitting one directly renders the view that consumes it
|
||||
// without staging the work that would normally produce it.
|
||||
//
|
||||
// POST /__test/emit {"name":"LibraryScanProgress","data":[{"...":1}]}
|
||||
func handleEmit(d Deps, r *http.Request) (any, error) {
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
Data []any `json:"data"`
|
||||
}
|
||||
|
||||
if err := decode(r, &body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if body.Name == "" {
|
||||
return nil, errNoEventName
|
||||
}
|
||||
|
||||
// events.Deliver rather than events.Emit: an ordinary emitter wants
|
||||
// an event with nowhere to go dropped, but this endpoint exists to
|
||||
// impersonate one, and reporting a 200 for an event that never
|
||||
// reached a frontend would send a caller debugging the wrong half of
|
||||
// the app.
|
||||
if err := events.Deliver(d.Context(), body.Name, body.Data...); err != nil {
|
||||
return nil, fmt.Errorf("emit %s: %w", body.Name, err)
|
||||
}
|
||||
|
||||
return map[string]any{"emitted": body.Name, "args": len(body.Data)}, nil
|
||||
}
|
||||
|
||||
// handleSQL runs a statement against the writer connection.
|
||||
//
|
||||
// One general escape hatch rather than a bespoke endpoint per piece of
|
||||
// forced state — "mark this track played", "insert a wanted-list row",
|
||||
// "age this cache entry" — each of which would otherwise arrive one at
|
||||
// a time and never be removed.
|
||||
//
|
||||
// POST /__test/sql {"sql":"UPDATE ...","args":[1,"x"]}
|
||||
func handleSQL(d Deps, r *http.Request) (any, error) {
|
||||
var body struct {
|
||||
SQL string `json:"sql"`
|
||||
Args []any `json:"args"`
|
||||
}
|
||||
|
||||
if err := decode(r, &body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if body.SQL == "" {
|
||||
return nil, errNoSQL
|
||||
}
|
||||
|
||||
// Route by statement kind rather than by trying one and falling
|
||||
// back: the read pool is opened query_only, so sending a write
|
||||
// there fails in a way that looks like a bug in the caller's SQL.
|
||||
if isQuery(body.SQL) {
|
||||
rows, err := d.DB.QueryContextWith(r.Context(), body.SQL, body.Args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
return scanAll(rows)
|
||||
}
|
||||
|
||||
res, err := d.DB.ExecContext(body.SQL, body.Args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
affected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return map[string]any{"rowsAffected": affected}, nil
|
||||
}
|
||||
|
||||
// isQuery reports whether a statement returns rows.
|
||||
func isQuery(sql string) bool {
|
||||
first, _, _ := strings.Cut(strings.TrimSpace(sql), " ")
|
||||
|
||||
switch strings.ToUpper(first) {
|
||||
case "SELECT", "WITH", "PRAGMA", "EXPLAIN":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// dbPath reports where the SQLite file lives, mirroring database.NewDB.
|
||||
func dbPath() string {
|
||||
dir, err := system.GetUserDataDirPath()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return filepath.Join(dir, "yj.db")
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
//go:build dev
|
||||
|
||||
package testctl
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// Static errors — err113 forbids fmt.Errorf with a dynamic message at
|
||||
// the point of failure, and these are all conditions a caller may want
|
||||
// to match on anyway.
|
||||
var (
|
||||
errBadName = errors.New("name must match [A-Za-z0-9_-]{1,64}")
|
||||
errNoSnapshot = errors.New("no such snapshot")
|
||||
errNoEventName = errors.New("emit needs a non-empty name")
|
||||
errNoSQL = errors.New("sql must be non-empty")
|
||||
errBadBody = errors.New("request body is not valid JSON")
|
||||
errInconsistent = errors.New(
|
||||
"restore left foreign key violations")
|
||||
)
|
||||
|
||||
// safeName keeps snapshot names to something that cannot escape the
|
||||
// snapshot directory or surprise a shell.
|
||||
var safeName = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
|
||||
|
||||
// Register mounts the control surface, if and only if this is a dev
|
||||
// build *and* YJ_TESTCTL=1. A human running `make dev` gets neither
|
||||
// the routes nor the risk.
|
||||
func Register(r Registrar, d Deps) {
|
||||
if os.Getenv(EnvEnable) != "1" {
|
||||
return
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /__test/health", jsonHandler(d, handleHealth))
|
||||
mux.HandleFunc("POST /__test/db/snapshot", jsonHandler(d, handleSnapshot))
|
||||
mux.HandleFunc("POST /__test/db/restore", jsonHandler(d, handleRestore))
|
||||
mux.HandleFunc("POST /__test/emit", jsonHandler(d, handleEmit))
|
||||
mux.HandleFunc("POST /__test/sql", jsonHandler(d, handleSQL))
|
||||
|
||||
r.RegisterHandler(Prefix, mux)
|
||||
|
||||
d.Logger.Warn(
|
||||
"test control surface enabled — dev build with YJ_TESTCTL=1",
|
||||
"prefix", Prefix,
|
||||
)
|
||||
}
|
||||
|
||||
// handlerFunc is the shape every endpoint has: take the request,
|
||||
// return something JSON-encodable or an error.
|
||||
type handlerFunc func(Deps, *http.Request) (any, error)
|
||||
|
||||
// jsonHandler centralises encoding, status codes and logging so each
|
||||
// endpoint is only its own logic. A failure is a 400 with the reason
|
||||
// in the body — an agent reading a spec failure needs the reason, not
|
||||
// a bare status code.
|
||||
func jsonHandler(d Deps, fn handlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
result, err := fn(d, r)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err != nil {
|
||||
d.Logger.Error("testctl request failed",
|
||||
"path", r.URL.Path, "err", err.Error())
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": err.Error(),
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
_ = json.NewEncoder(w).Encode(result)
|
||||
}
|
||||
}
|
||||
|
||||
// decode reads a JSON request body into v. An empty body is not an
|
||||
// error: several endpoints take everything in the query string.
|
||||
func decode(r *http.Request, v any) error {
|
||||
if r.Body == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
dec := json.NewDecoder(r.Body)
|
||||
|
||||
if err := dec.Decode(v); err != nil && !errors.Is(err, io.EOF) {
|
||||
return errBadBody
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//go:build !dev
|
||||
|
||||
package testctl
|
||||
|
||||
// Register is a no-op in non-dev builds: the control surface's entire
|
||||
// implementation is behind the `dev` build tag, so a release binary
|
||||
// contains neither the handlers nor the routes.
|
||||
func Register(_ Registrar, _ Deps) {}
|
||||
@@ -0,0 +1,53 @@
|
||||
// Package testctl mounts a dev-only HTTP control surface at /__test/
|
||||
// on the app's own asset server.
|
||||
//
|
||||
// It exists for the residue of what an end-to-end harness genuinely
|
||||
// cannot reach from the browser. Everything the frontend can do is
|
||||
// already reachable through the generated bindings on `window.go` —
|
||||
// clicking, reading the DOM, calling a service — so this deliberately
|
||||
// does *not* re-expose any of that. What is left is server-side state:
|
||||
// snapshotting and restoring the SQLite database mid-run, forcing a
|
||||
// backend event so a push-driven view can be rendered without staging
|
||||
// hours of real work, and reading a single authoritative "is the
|
||||
// backend actually ready" answer.
|
||||
//
|
||||
// It is gated twice. The implementation lives behind the `dev` build
|
||||
// tag (the non-dev twin is an empty function, so nothing links into a
|
||||
// release binary), and even in a dev build it refuses to register
|
||||
// unless YJ_TESTCTL=1 — otherwise every `make dev` session a human runs
|
||||
// would carry an arbitrary-SQL endpoint on a listening port.
|
||||
package testctl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"yellowjacket/backend/database"
|
||||
)
|
||||
|
||||
// EnvEnable must be set to "1" for the surface to register, even in a
|
||||
// dev build. scripts/dev-headless.sh sets it; `make dev` does not.
|
||||
const EnvEnable = "YJ_TESTCTL"
|
||||
|
||||
// Prefix is the single mount point. One pattern, one ServeMux entry.
|
||||
const Prefix = "/__test/"
|
||||
|
||||
// Registrar is the slice of *assets.Handler this package needs, taken as
|
||||
// an interface so testctl does not import the asset server (which would
|
||||
// make the non-dev build's import graph differ from the dev one).
|
||||
type Registrar interface {
|
||||
RegisterHandler(pattern string, handler http.Handler)
|
||||
}
|
||||
|
||||
// Deps is everything the control surface is allowed to touch. It is
|
||||
// deliberately small: a database handle and a way to reach the Wails
|
||||
// runtime context for event emission.
|
||||
type Deps struct {
|
||||
Logger *slog.Logger
|
||||
DB *database.DB
|
||||
// Context returns the live Wails application context. It is a
|
||||
// function rather than a value because the context only exists
|
||||
// after OnStartup, which is later than registration.
|
||||
Context func() context.Context
|
||||
}
|
||||
Reference in New Issue
Block a user