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.
327 lines
7.8 KiB
Go
327 lines
7.8 KiB
Go
//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()
|
|
}
|