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,248 @@
|
||||
// Command gentestdata generates the deterministic fixture library used
|
||||
// by tests and by seeded development sandboxes.
|
||||
//
|
||||
// The fixtures are audio the app can actually decode, tagged by
|
||||
// backend/tagwriter — the same writers the application uses — so the
|
||||
// fixtures and the reader under test cannot drift apart. Everything is
|
||||
// derived from the spec in spec.go, so two machines running
|
||||
// `make testdata` get libraries that agree on every logical property
|
||||
// (paths, durations, tags, cover identity). Encoded bytes may differ
|
||||
// between ffmpeg builds; the manifest hash covers the spec, not bytes.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// go run ./cmd/gentestdata # generate if out of date
|
||||
// go run ./cmd/gentestdata -force # regenerate unconditionally
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"yellowjacket/backend/tagwriter"
|
||||
)
|
||||
|
||||
// Fixed file modes and mtime. The scanner keys incremental rescan off
|
||||
// modified_at, so pinning mtime makes "changed since last scan"
|
||||
// reproducible rather than a function of when generation ran.
|
||||
const (
|
||||
filePerm = 0o644
|
||||
dirPerm = 0o755
|
||||
)
|
||||
|
||||
//nolint:gochecknoglobals // a package-level constant time value.
|
||||
var fixedMTime = time.Date(2024, time.January, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
func main() {
|
||||
var (
|
||||
outDir string
|
||||
brokenDir string
|
||||
manifestOut string
|
||||
force bool
|
||||
)
|
||||
|
||||
flag.StringVar(
|
||||
&outDir, "out", "test_data/music_library_test",
|
||||
"library root to generate",
|
||||
)
|
||||
flag.StringVar(
|
||||
&brokenDir, "broken", "test_data/music_library_broken",
|
||||
"root for deliberately malformed files",
|
||||
)
|
||||
flag.StringVar(
|
||||
&manifestOut, "manifest", "test_data/music_library_test.manifest.json",
|
||||
"manifest path (kept outside the library root)",
|
||||
)
|
||||
flag.BoolVar(
|
||||
&force, "force", false,
|
||||
"regenerate even when the manifest is already up to date",
|
||||
)
|
||||
flag.Parse()
|
||||
|
||||
if err := run(outDir, brokenDir, manifestOut, force); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "gentestdata:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(outDir, brokenDir, manifestOut string, force bool) error {
|
||||
want, err := buildManifest(outDir, brokenDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !force && upToDate(manifestOut, want, outDir, brokenDir) {
|
||||
fmt.Printf(
|
||||
"up to date (%d tracks, hash %s)\n",
|
||||
len(want.Tracks), want.Hash[:12],
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := requireFFmpeg(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, dir := range []string{outDir, brokenDir} {
|
||||
if err := os.RemoveAll(dir); err != nil {
|
||||
return fmt.Errorf("clean %s: %w", dir, err)
|
||||
}
|
||||
}
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
|
||||
Level: slog.LevelError,
|
||||
}))
|
||||
|
||||
for _, f := range libraryFixtures {
|
||||
if err := generateFixture(logger, outDir, f); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := writeAuxFiles(outDir, outDir, libraryExtras); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := writeAuxFiles(outDir, brokenDir, brokenFiles); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := writeManifest(manifestOut, want); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf(
|
||||
"generated %d tracks + %d broken files in %s (hash %s)\n",
|
||||
len(want.Tracks), len(want.Broken), outDir, want.Hash[:12],
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// upToDate reports whether the recorded manifest matches the spec and
|
||||
// both roots still exist. Cheap enough to run on every make invocation.
|
||||
func upToDate(manifestOut string, want *manifest, roots ...string) bool {
|
||||
if readManifestHash(manifestOut) != want.Hash {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, root := range roots {
|
||||
if _, err := os.Stat(root); err != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// generateFixture synthesizes, encodes and tags a single fixture.
|
||||
//
|
||||
// Tags are written after encoding, by backend/tagwriter, rather than
|
||||
// handed to ffmpeg: the fixtures must be tagged by the code the app
|
||||
// reads back with, or a tag bug becomes invisible to every test.
|
||||
func generateFixture(
|
||||
logger *slog.Logger,
|
||||
root string,
|
||||
f fixture,
|
||||
) error {
|
||||
dst := filepath.Join(root, filepath.FromSlash(f.Rel))
|
||||
|
||||
if err := ensureDir(dst); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
wav := dst
|
||||
if f.Format != tagwriter.FormatWAV {
|
||||
wav = dst + ".src.wav"
|
||||
}
|
||||
|
||||
if err := synthesizeWAV(wav, f.Duration, f.FreqHz); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if f.Format != tagwriter.FormatWAV {
|
||||
if err := transcode(wav, dst, f.Format); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.Remove(wav); err != nil {
|
||||
return fmt.Errorf("remove scratch wav: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
changes := f.Tags.changes()
|
||||
|
||||
if f.Cover != "" {
|
||||
img, err := coverJPEG(f.Cover)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
changes[tagwriter.FieldCoverArt] = img
|
||||
}
|
||||
|
||||
if len(changes) > 0 {
|
||||
if err := tagwriter.WriteFileTags(logger, dst, changes); err != nil {
|
||||
return fmt.Errorf("tag %s: %w", f.Rel, err)
|
||||
}
|
||||
}
|
||||
|
||||
return stampMTime(dst)
|
||||
}
|
||||
|
||||
// writeAuxFiles writes non-audio and malformed files into dstRoot.
|
||||
//
|
||||
// Truncated fixtures are cut from an already-encoded file under
|
||||
// libraryRoot, so this must run after the audio has been generated.
|
||||
// The malformed set lands outside the library root on purpose: the
|
||||
// clean library's track count has to stay deterministic, so a test
|
||||
// that wants the scanner's error paths registers the broken root as a
|
||||
// second library deliberately.
|
||||
func writeAuxFiles(libraryRoot, dstRoot string, files []auxFile) error {
|
||||
for _, b := range files {
|
||||
dst := filepath.Join(dstRoot, filepath.FromSlash(b.Rel))
|
||||
|
||||
if err := ensureDir(dst); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var content []byte
|
||||
|
||||
switch {
|
||||
case b.Source != "":
|
||||
src := filepath.Join(libraryRoot, filepath.FromSlash(b.Source))
|
||||
|
||||
raw, err := os.ReadFile(src) //nolint:gosec // generated path.
|
||||
if err != nil {
|
||||
return fmt.Errorf("read source %s: %w", src, err)
|
||||
}
|
||||
|
||||
content = raw[:min(b.Bytes, len(raw))]
|
||||
case strings.HasSuffix(b.Rel, ".jpg"):
|
||||
img, err := coverJPEG(b.Rel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
content = img
|
||||
default:
|
||||
content = []byte(b.Literal)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(dst, content, filePerm); err != nil {
|
||||
return fmt.Errorf("write %s: %w", dst, err)
|
||||
}
|
||||
|
||||
if err := stampMTime(dst); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user