feat(harness): agent-drivable dev harness and CI that gates
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

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:
2026-08-10 23:20:42 -04:00
parent 65333857e2
commit 5ca6cad45a
117 changed files with 14585 additions and 262 deletions
+13 -1
View File
@@ -33,6 +33,7 @@ import (
"yellowjacket/backend/queue"
"yellowjacket/backend/system"
"yellowjacket/backend/tagwriter"
"yellowjacket/backend/testctl"
)
// YellowJacketApp is the main application struct for Wails.
@@ -131,6 +132,17 @@ func NewYellowJacketApp(
yjApp.assetHandler.RegisterHandler("/artist-images/", artistImgHandler)
}
// Dev-only /__test/ control surface: the residue of harness work the
// browser cannot reach (snapshot/restore the DB mid-run, force a
// backend event). Compiled out of non-dev builds entirely, and even
// in a dev build it registers nothing unless YJ_TESTCTL=1. The
// context is read lazily because it only exists after OnStartup.
testctl.Register(yjApp.assetHandler, testctl.Deps{
Logger: logger,
DB: yjApp.database,
Context: func() context.Context { return yjApp.appContext },
})
// create playlist service
yjApp.playlist = playlist.NewService(
yjApp.logger, yjApp.database, yjApp.appConfig,
@@ -290,7 +302,7 @@ func (yj *YellowJacketApp) initDownloadRuntime(ctx context.Context) {
yj.wanted.SetInterval(cfg.WantedInterval())
yj.wanted.SetBatch(cfg.WantedBatch)
yj.wanted.SetOnChange(func() {
wailsruntime.EventsEmit(ctx, events.RequestsChanged)
events.Emit(ctx, events.RequestsChanged)
})
yj.wanted.Start(ctx)
}
+5 -28
View File
@@ -19,8 +19,6 @@ import (
"sync"
"time"
wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
"yellowjacket/backend/autotag"
"yellowjacket/backend/database"
"yellowjacket/backend/database/sql/sqlcgen"
@@ -85,13 +83,6 @@ type Service struct {
exp *explore.Service
logger *slog.Logger
ctx context.Context
// ctxReady reports whether ctx is the Wails lifecycle context set
// via SetContext (rather than the context.Background() default). It
// gates event emission: calling wailsruntime.EventsEmit with a
// non-runtime context triggers log.Fatalf (os.Exit) inside Wails, so
// a background worker that fires before OnStartup wires the context
// would otherwise take the whole app down on launch.
ctxReady bool
// Queue cursor — the group_key of the last item returned.
// GetNextPending uses it to advance. Reset by StartAutotagQueue.
@@ -210,32 +201,18 @@ func (s *Service) SetContext(ctx context.Context) {
defer s.mu.Unlock()
s.ctx = ctx
s.ctxReady = ctx != nil
}
// emitEvent emits a Wails runtime event, but only when the stored
// context actually carries the Wails runtime. Wails' EventsEmit calls
// log.Fatalf — which os.Exit()s the process and cannot be recovered —
// whenever the context lacks its internal "events" value (e.g. the
// context.Background() default, or any non-lifecycle context). A
// background worker (the prefetch/apply sweeps) that emits before, or
// independently of, OnStartup wiring the real context would otherwise
// take the whole app down on launch. We replicate Wails' own
// precondition here so a not-yet-ready context degrades to a no-op
// instead of a crash.
// emitEvent emits a Wails runtime event under the service lock, which
// the background prefetch/apply sweeps need because they can emit
// before OnStartup has wired the real context. events.Emit tolerates
// that; see its doc comment.
func (s *Service) emitEvent(eventName string, data any) {
s.mu.Lock()
ready := s.ctxReady
ctx := s.ctx
s.mu.Unlock()
// hasWailsRuntime mirrors the check in wails/pkg/runtime.getEvents:
// the runtime is present only when ctx.Value("events") is non-nil.
if !ready || ctx == nil || ctx.Value("events") == nil {
return
}
wailsruntime.EventsEmit(ctx, eventName, data)
events.Emit(ctx, eventName, data)
}
// StartBackgroundPrefetch kicks off (or restarts) the prefetch
+27 -36
View File
@@ -10,7 +10,6 @@ import (
"path"
"github.com/BurntSushi/toml"
"github.com/wailsapp/wails/v2/pkg/runtime"
"yellowjacket/backend/download"
"yellowjacket/backend/events"
@@ -306,15 +305,13 @@ func (c *Config) SetLibraryDirectory(dir string) error {
)
}
if c.ctx != nil {
runtime.EventsEmit(
c.ctx,
events.LibraryConfigChanged,
map[string]any{
"DirectoryPath": dir,
},
)
}
events.Emit(
c.ctx,
events.LibraryConfigChanged,
map[string]any{
"DirectoryPath": dir,
},
)
c.logger.Info(
"library directory updated",
@@ -494,11 +491,11 @@ func (c *Config) SetThemeBackgroundShade(
// emitThemeChanged sends the ThemeConfigChanged event to the frontend.
func (c *Config) emitThemeChanged() {
if c.ctx == nil || c.Theme == nil {
if c.Theme == nil {
return
}
runtime.EventsEmit(
events.Emit(
c.ctx,
events.ThemeConfigChanged,
map[string]any{
@@ -564,7 +561,7 @@ func (c *Config) emitTrackListChanged() {
})
}
runtime.EventsEmit(
events.Emit(
c.ctx,
events.TrackListConfigChanged,
map[string]any{
@@ -688,11 +685,11 @@ func (c *Config) SetPinDefaultPlaylist(pin bool) error {
// emitFavoritesChanged sends the FavoritesConfigChanged event
// to the frontend.
func (c *Config) emitFavoritesChanged() {
if c.ctx == nil || c.Favorites == nil {
if c.Favorites == nil {
return
}
runtime.EventsEmit(
events.Emit(
c.ctx,
events.FavoritesConfigChanged,
map[string]any{
@@ -729,13 +726,11 @@ func (c *Config) SetShortcuts(
)
}
if c.ctx != nil {
runtime.EventsEmit(
c.ctx,
events.ShortcutsConfigChanged,
bindings,
)
}
events.Emit(
c.ctx,
events.ShortcutsConfigChanged,
bindings,
)
c.logger.Info("shortcuts config updated")
@@ -759,13 +754,11 @@ func (c *Config) SetShortcut(
)
}
if c.ctx != nil {
runtime.EventsEmit(
c.ctx,
events.ShortcutsConfigChanged,
c.Shortcuts.Bindings,
)
}
events.Emit(
c.ctx,
events.ShortcutsConfigChanged,
c.Shortcuts.Bindings,
)
c.logger.Info(
"shortcut updated",
@@ -788,13 +781,11 @@ func (c *Config) ResetShortcuts() error {
)
}
if c.ctx != nil {
runtime.EventsEmit(
c.ctx,
events.ShortcutsConfigChanged,
c.Shortcuts.Bindings,
)
}
events.Emit(
c.ctx,
events.ShortcutsConfigChanged,
c.Shortcuts.Bindings,
)
c.logger.Info("shortcuts reset to defaults")
+185
View File
@@ -0,0 +1,185 @@
package config
import (
"context"
"log/slog"
"path/filepath"
"testing"
"yellowjacket/backend/events"
)
// setupRecordedConfig builds a Config that saves to a temp directory
// and records the events it would push to the frontend.
func setupRecordedConfig(t *testing.T) (*Config, *events.Recorder) {
t.Helper()
conf := &Config{
logger: slog.Default(),
filePath: filepath.Join(t.TempDir(), "config.toml"),
}
conf.applyDefaults()
// Load, not just applyDefaults: Save refuses to write a config that
// was never hydrated from disk, so without this only the first
// setter in a test succeeds.
if err := conf.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
rec := events.NewRecorder()
conf.SetContext(events.WithSink(context.Background(), rec))
return conf, rec
}
// payloadMap returns the map payload of the most recent named event.
func payloadMap(
t *testing.T,
rec *events.Recorder,
name string,
) map[string]any {
t.Helper()
ev, ok := rec.Last(name)
if !ok {
t.Fatalf("no %s emitted; got %v", name, rec.Names())
}
data, ok := ev.Payload().(map[string]any)
if !ok {
t.Fatalf("%s payload is %T, want map[string]any", name, ev.Payload())
}
return data
}
// TestEmit_ThemeChangeCarriesBothFields pins that the theme event is a
// snapshot of both fields, not a delta: the frontend applies the whole
// colour ramp from it, so an accent change that omitted the shade would
// re-derive the ramp against a default background.
func TestEmit_ThemeChangeCarriesBothFields(t *testing.T) {
t.Parallel()
conf, rec := setupRecordedConfig(t)
if err := conf.SetThemeBackgroundShade("light"); err != nil {
t.Fatalf("SetThemeBackgroundShade: %v", err)
}
if err := conf.SetThemeAccentColor("#ff0000"); err != nil {
t.Fatalf("SetThemeAccentColor: %v", err)
}
if got := rec.Count(events.ThemeConfigChanged); got != 2 {
t.Errorf("emitted %d ThemeConfigChanged, want 2", got)
}
data := payloadMap(t, rec, events.ThemeConfigChanged)
if data["AccentColor"] != "#ff0000" {
t.Errorf("AccentColor = %v, want #ff0000", data["AccentColor"])
}
if data["BackgroundShade"] != "light" {
t.Errorf("BackgroundShade = %v, want light", data["BackgroundShade"])
}
}
// TestEmit_ThemeChangeIsNotEmittedOnRejectedValue pins that a rejected
// write does not tell the frontend the theme changed.
func TestEmit_ThemeChangeIsNotEmittedOnRejectedValue(t *testing.T) {
t.Parallel()
conf, rec := setupRecordedConfig(t)
if err := conf.SetThemeAccentColor("not-a-colour"); err == nil {
t.Fatal("SetThemeAccentColor accepted an invalid colour")
}
if got := rec.Count(events.ThemeConfigChanged); got != 0 {
t.Errorf("emitted %d ThemeConfigChanged for a rejected write, want 0", got)
}
}
// TestEmit_ShortcutChangeSendsWholeBindingMap covers the surface the
// 357-line frontend shortcut service rebuilds itself from.
func TestEmit_ShortcutChangeSendsWholeBindingMap(t *testing.T) {
t.Parallel()
conf, rec := setupRecordedConfig(t)
if err := conf.SetShortcut("playPause", "k"); err != nil {
t.Fatalf("SetShortcut: %v", err)
}
ev, ok := rec.Last(events.ShortcutsConfigChanged)
if !ok {
t.Fatalf("no ShortcutsConfigChanged; got %v", rec.Names())
}
bindings, ok := ev.Payload().(map[string]string)
if !ok {
t.Fatalf("payload is %T, want map[string]string", ev.Payload())
}
if bindings["playPause"] != "k" {
t.Errorf("playPause = %q, want k", bindings["playPause"])
}
// The whole map, not just the changed key — the frontend replaces
// its binding table wholesale on this event.
if len(bindings) < 2 {
t.Errorf("emitted %d bindings, want the full default set", len(bindings))
}
}
func TestEmit_ResetShortcutsRepublishesDefaults(t *testing.T) {
t.Parallel()
conf, rec := setupRecordedConfig(t)
if err := conf.SetShortcut("playPause", "k"); err != nil {
t.Fatalf("SetShortcut: %v", err)
}
rec.Reset()
if err := conf.ResetShortcuts(); err != nil {
t.Fatalf("ResetShortcuts: %v", err)
}
ev, ok := rec.Last(events.ShortcutsConfigChanged)
if !ok {
t.Fatalf("no ShortcutsConfigChanged after reset; got %v", rec.Names())
}
bindings, ok := ev.Payload().(map[string]string)
if !ok {
t.Fatalf("payload is %T, want map[string]string", ev.Payload())
}
if bindings["playPause"] == "k" {
t.Error("reset emitted the overridden binding, not the default")
}
}
func TestEmit_FavoritesChangeCarriesFullConfig(t *testing.T) {
t.Parallel()
conf, rec := setupRecordedConfig(t)
if err := conf.SetFavoritesPlaylistID(7); err != nil {
t.Fatalf("SetFavoritesPlaylistID: %v", err)
}
data := payloadMap(t, rec, events.FavoritesConfigChanged)
if data["PlaylistID"] != int64(7) {
t.Errorf("PlaylistID = %#v, want int64(7)", data["PlaylistID"])
}
for _, key := range []string{"IconStyle", "PinDefault"} {
if _, ok := data[key]; !ok {
t.Errorf("payload is missing %q; the settings page reads it", key)
}
}
}
+2 -9
View File
@@ -7,8 +7,6 @@ import (
"log/slog"
"strconv"
"github.com/wailsapp/wails/v2/pkg/runtime"
"yellowjacket/backend/events"
)
@@ -55,14 +53,9 @@ func (s *Service) SetContext(ctx context.Context) {
}
// emit publishes an event, tolerating a service that has no runtime
// context yet. Emitting on a non-runtime context is fatal in Wails, so
// the nil check is load-bearing rather than defensive.
// context yet.
func (s *Service) emit(name string, data ...any) {
if s.ctx == nil {
return
}
runtime.EventsEmit(s.ctx, name, data...)
events.Emit(s.ctx, name, data...)
}
// ---------------------------------------------------------------------------
+90
View File
@@ -0,0 +1,90 @@
package events
import (
"context"
"errors"
"log/slog"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
// ErrNoRuntime is returned by Deliver when the context carries neither
// a test Sink nor a live Wails runtime, so the event went nowhere.
var ErrNoRuntime = errors.New(
"no Wails runtime or event sink in context",
)
// Sink receives events in place of the Wails runtime.
//
// Installing one with WithSink is what makes a service that emits
// events testable in-process: see Deliver for why the real runtime
// cannot be used there.
type Sink interface {
Emit(name string, data ...any)
}
// sinkKey is the private context key an installed Sink is stored under.
type sinkKey struct{}
// WithSink returns a context whose events are recorded by sink rather
// than pushed to the frontend.
//
// The sink travels in the context rather than in a package-level
// variable so that parallel tests cannot observe each other's events
// and so that production emits pay no synchronisation cost.
func WithSink(ctx context.Context, sink Sink) context.Context {
return context.WithValue(ctx, sinkKey{}, sink)
}
// sinkFrom returns the Sink installed in ctx, or nil.
func sinkFrom(ctx context.Context) Sink {
sink, _ := ctx.Value(sinkKey{}).(Sink)
return sink
}
// Emit publishes a Wails event, tolerating any context.
//
// This is the only supported way to emit an event: nothing outside this
// package may call runtime.EventsEmit, which TestNoDirectEventsEmit
// enforces.
//
// runtime.EventsEmit calls log.Fatalf when the context is nil or lacks
// the runtime's "events" value, terminating the process rather than
// returning an error. Background workers that outlive a context, and
// any test that constructs a service directly, both hit that path — so
// an event with nowhere to go is dropped and logged here instead.
func Emit(ctx context.Context, name string, data ...any) {
if err := Deliver(ctx, name, data...); err != nil {
slog.Default().Debug(
"dropping event, no Wails runtime in context",
"event", name,
)
}
}
// Deliver is Emit for the one caller that must know whether delivery
// happened: the dev control surface (backend/testctl), whose whole
// purpose is to impersonate a backend emit and which would otherwise
// report success for an event that went nowhere.
//
// Ordinary emitters want Emit.
func Deliver(ctx context.Context, name string, data ...any) error {
if ctx == nil {
return ErrNoRuntime
}
if sink := sinkFrom(ctx); sink != nil {
sink.Emit(name, data...)
return nil
}
if ctx.Value("events") == nil {
return ErrNoRuntime
}
runtime.EventsEmit(ctx, name, data...)
return nil
}
+186
View File
@@ -0,0 +1,186 @@
package events_test
import (
"context"
"errors"
"sync"
"testing"
"time"
"yellowjacket/backend/events"
)
func TestEmitDropsWithoutRuntimeOrSink(t *testing.T) {
t.Parallel()
// The point of the wrapper: neither of these may reach
// runtime.EventsEmit, which would log.Fatalf and take the test
// binary down with it.
events.Emit(context.Background(), events.QueueChanged, "payload")
//nolint:staticcheck // a nil context is the case under test.
events.Emit(nil, events.QueueChanged, "payload")
}
func TestDeliverReportsMissingRuntime(t *testing.T) {
t.Parallel()
tests := []struct {
name string
ctx context.Context
}{
{name: "nil context", ctx: nil},
{name: "no runtime", ctx: context.Background()},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := events.Deliver(tt.ctx, events.QueueChanged)
if !errors.Is(err, events.ErrNoRuntime) {
t.Fatalf("got %v, want ErrNoRuntime", err)
}
})
}
}
func TestSinkReceivesEmittedEvents(t *testing.T) {
t.Parallel()
rec := events.NewRecorder()
ctx := events.WithSink(context.Background(), rec)
events.Emit(ctx, events.QueueChanged, "one")
events.Emit(ctx, events.VolumeChanged, 42)
if err := events.Deliver(ctx, events.TrackChanged); err != nil {
t.Fatalf("Deliver with a sink installed: %v", err)
}
want := []string{
events.QueueChanged,
events.VolumeChanged,
events.TrackChanged,
}
got := rec.Names()
if len(got) != len(want) {
t.Fatalf("recorded %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("event %d = %q, want %q", i, got[i], want[i])
}
}
ev, ok := rec.Last(events.VolumeChanged)
if !ok {
t.Fatal("VolumeChanged not recorded")
}
if ev.Payload() != 42 {
t.Errorf("VolumeChanged payload = %v, want 42", ev.Payload())
}
}
func TestRecorderPayloadOfArgumentlessEvent(t *testing.T) {
t.Parallel()
rec := events.NewRecorder()
rec.Emit(events.SeekFailed)
ev, ok := rec.Last(events.SeekFailed)
if !ok {
t.Fatal("SeekFailed not recorded")
}
if ev.Payload() != nil {
t.Errorf("payload = %v, want nil", ev.Payload())
}
}
func TestRecorderWaitSeesEventsAlreadyRecorded(t *testing.T) {
t.Parallel()
rec := events.NewRecorder()
rec.Emit(events.LibraryScanComplete, 31)
ev, ok := rec.Wait(events.LibraryScanComplete, time.Second)
if !ok {
t.Fatal("Wait missed an event recorded before the call")
}
if ev.Payload() != 31 {
t.Errorf("payload = %v, want 31", ev.Payload())
}
}
func TestRecorderWaitBlocksForBackgroundEmit(t *testing.T) {
t.Parallel()
rec := events.NewRecorder()
ctx := events.WithSink(context.Background(), rec)
go func() {
time.Sleep(10 * time.Millisecond)
events.Emit(ctx, events.LibraryScanProgress, 1)
events.Emit(ctx, events.LibraryScanComplete, 2)
}()
if _, ok := rec.Wait(events.LibraryScanComplete, 2*time.Second); !ok {
t.Fatal("Wait timed out on a background emit")
}
}
func TestRecorderWaitTimesOut(t *testing.T) {
t.Parallel()
rec := events.NewRecorder()
if _, ok := rec.Wait(events.QueueChanged, 20*time.Millisecond); ok {
t.Fatal("Wait returned an event that was never emitted")
}
}
func TestRecorderIsConcurrencySafe(t *testing.T) {
t.Parallel()
rec := events.NewRecorder()
ctx := events.WithSink(context.Background(), rec)
const emitters, each = 8, 25
var wg sync.WaitGroup
wg.Add(emitters)
for range emitters {
go func() {
defer wg.Done()
for range each {
events.Emit(ctx, events.QueueChanged, 1)
}
}()
}
wg.Wait()
if got := rec.Count(events.QueueChanged); got != emitters*each {
t.Errorf("recorded %d events, want %d", got, emitters*each)
}
}
func TestRecorderReset(t *testing.T) {
t.Parallel()
rec := events.NewRecorder()
rec.Emit(events.QueueChanged)
rec.Reset()
if got := rec.Events(); len(got) != 0 {
t.Errorf("after Reset: %v, want empty", got)
}
}
+85
View File
@@ -0,0 +1,85 @@
package events_test
import (
"os"
"path/filepath"
"strings"
"testing"
)
// allowedEmitters are the only files permitted to call the Wails
// runtime's event emitter directly.
var allowedEmitters = map[string]bool{
filepath.Join("backend", "events", "emit.go"): true,
}
// TestNoDirectRuntimeEmits fails if anything outside backend/events
// calls the Wails runtime's event emitter directly.
//
// This is a text walk rather than a golangci-lint rule because
// golangci-lint runs once per build configuration, so a call in an
// indexbuild- or dev-tagged file is only seen by the pass that compiles
// it. Walking the tree sees all three, plus anything tagged out
// entirely.
func TestNoDirectRuntimeEmits(t *testing.T) {
// A selector, not a bare name: built at runtime so this file does
// not match itself, and qualified so it catches every import alias
// the tree uses (plain runtime., and wailsruntime.) without also
// matching an identifier that merely ends in the same letters.
needle := ".Events" + "Emit("
root := filepath.Join("..", "..")
skipDirs := map[string]bool{
".git": true,
"node_modules": true,
"frontend": true,
"build": true,
}
err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
if skipDirs[d.Name()] {
return filepath.SkipDir
}
return nil
}
if filepath.Ext(path) != ".go" {
return nil
}
rel, relErr := filepath.Rel(root, path)
if relErr != nil {
return relErr
}
if allowedEmitters[rel] {
return nil
}
src, readErr := os.ReadFile(path)
if readErr != nil {
return readErr
}
for i, line := range strings.Split(string(src), "\n") {
if strings.Contains(line, needle) {
t.Errorf(
"%s:%d calls the Wails emitter directly; use events.Emit\n\t%s",
rel, i+1, strings.TrimSpace(line),
)
}
}
return nil
})
if err != nil {
t.Fatalf("walking %s: %v", root, err)
}
}
+153
View File
@@ -0,0 +1,153 @@
package events
import (
"sync"
"time"
)
// Event is one recorded emission.
type Event struct {
Name string
Data []any
}
// Payload returns the single data argument almost every event carries,
// or nil for the handful emitted with none.
func (e Event) Payload() any {
if len(e.Data) == 0 {
return nil
}
return e.Data[0]
}
// Recorder is a Sink that buffers events for later assertion.
//
// It is safe for concurrent use: several services emit from background
// goroutines, and Wait exists so a test can block on one of those
// rather than sleep.
type Recorder struct {
mu sync.Mutex
events []Event
// notify is closed and replaced on every emit, so waiters wake
// without the Recorder having to track them individually.
notify chan struct{}
}
// NewRecorder returns an empty Recorder.
func NewRecorder() *Recorder {
return &Recorder{notify: make(chan struct{})}
}
// Emit implements Sink.
func (r *Recorder) Emit(name string, data ...any) {
r.mu.Lock()
defer r.mu.Unlock()
r.events = append(r.events, Event{Name: name, Data: data})
close(r.notify)
r.notify = make(chan struct{})
}
// Events returns every event recorded so far, in order.
func (r *Recorder) Events() []Event {
r.mu.Lock()
defer r.mu.Unlock()
return append([]Event(nil), r.events...)
}
// Named returns every recorded event with the given name, in order.
func (r *Recorder) Named(name string) []Event {
r.mu.Lock()
defer r.mu.Unlock()
var out []Event
for _, ev := range r.events {
if ev.Name == name {
out = append(out, ev)
}
}
return out
}
// Names returns the name of every recorded event, in order.
//
// Assertions read better against this than against Events when what
// matters is which events fired and in what order.
func (r *Recorder) Names() []string {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]string, 0, len(r.events))
for _, ev := range r.events {
out = append(out, ev.Name)
}
return out
}
// Count returns how many times the named event was recorded.
func (r *Recorder) Count(name string) int {
return len(r.Named(name))
}
// Last returns the most recent event with the given name.
func (r *Recorder) Last(name string) (Event, bool) {
r.mu.Lock()
defer r.mu.Unlock()
for i := len(r.events) - 1; i >= 0; i-- {
if r.events[i].Name == name {
return r.events[i], true
}
}
return Event{}, false
}
// Reset discards everything recorded so far.
func (r *Recorder) Reset() {
r.mu.Lock()
defer r.mu.Unlock()
r.events = nil
}
// Wait blocks until an event with the given name is recorded, and
// returns it. It returns false if timeout elapses first.
//
// Events already recorded count, so a test cannot lose a race by
// calling Wait after the emit it is waiting for.
func (r *Recorder) Wait(name string, timeout time.Duration) (Event, bool) {
deadline := time.After(timeout)
from := 0
for {
r.mu.Lock()
for i := from; i < len(r.events); i++ {
if r.events[i].Name == name {
ev := r.events[i]
r.mu.Unlock()
return ev, true
}
}
from = len(r.events)
notify := r.notify
r.mu.Unlock()
select {
case <-notify:
case <-deadline:
return Event{}, false
}
}
}
+4 -9
View File
@@ -9,7 +9,6 @@ import (
"sync"
"time"
"github.com/wailsapp/wails/v2/pkg/runtime"
"golang.org/x/sync/singleflight"
"yellowjacket/backend/database"
@@ -650,8 +649,8 @@ func (e *Service) ensureReleasesAsync(releaseGroupMBID string) {
go func() {
_, _, _ = e.releasesSF.Do(releaseGroupMBID, func() (any, error) {
_, err := e.mb.BrowseReleases(e.ctx, releaseGroupMBID)
if err == nil && e.ctx != nil {
runtime.EventsEmit(e.ctx, events.AlbumReleasesReady, releaseGroupMBID)
if err == nil {
events.Emit(e.ctx, events.AlbumReleasesReady, releaseGroupMBID)
}
return nil, nil
@@ -804,9 +803,7 @@ func (e *Service) ensureDiscographyAsync(artistMBID string) {
_, _, _ = e.discogSF.Do(artistMBID, func() (any, error) {
e.index.EnsureArtistDiscography(e.ctx, artistMBID)
if e.ctx != nil {
runtime.EventsEmit(e.ctx, events.ArtistDiscographyReady, artistMBID)
}
events.Emit(e.ctx, events.ArtistDiscographyReady, artistMBID)
return nil, nil
})
@@ -864,9 +861,7 @@ func (e *Service) ensureSimilarArtistsAsync(artistMBID string) {
if err == nil {
e.index.PersistSimilarArtists(artistMBID, similar)
if e.ctx != nil {
runtime.EventsEmit(e.ctx, events.ArtistSimilarReady, artistMBID)
}
events.Emit(e.ctx, events.ArtistSimilarReady, artistMBID)
}
return nil, nil
+1 -2
View File
@@ -12,7 +12,6 @@ import (
"sync"
"time"
"github.com/wailsapp/wails/v2/pkg/runtime"
"golang.org/x/sync/singleflight"
"yellowjacket/backend/database"
@@ -690,7 +689,7 @@ func (si *SearchIndex) emitStatus() {
status.Building = si.cancel != nil
si.mu.RUnlock()
runtime.EventsEmit(si.runtimeCtx, events.IndexStatusChanged, status)
events.Emit(si.runtimeCtx, events.IndexStatusChanged, status)
// Mirror into the shared job registry. Every status mutation goes
// through emitStatus, so hooking here covers all update paths.
+1 -3
View File
@@ -13,8 +13,6 @@ import (
"sync/atomic"
"time"
"github.com/wailsapp/wails/v2/pkg/runtime"
"yellowjacket/backend/events"
)
@@ -245,7 +243,7 @@ func (r *Registry) emit() {
return
}
runtime.EventsEmit(ctx, events.JobsChanged, r.Snapshot())
events.Emit(ctx, events.JobsChanged, r.Snapshot())
}
// touch marks the registry dirty so the next emitter tick publishes it.
+5 -19
View File
@@ -16,7 +16,6 @@ import (
"sync/atomic"
"time"
"github.com/wailsapp/wails/v2/pkg/runtime"
"golang.org/x/sync/errgroup"
"yellowjacket/backend/autotag"
@@ -193,29 +192,16 @@ func (l *Library) SetContext(ctx context.Context) {
l.registerEventHandlers()
}
// emit publishes a Wails event, tolerating a context that carries no
// Wails runtime.
//
// runtime.EventsEmit calls log.Fatalf when the context is nil or lacks
// the runtime's "events" value, which terminates the process rather
// than returning an error. Background workers that outlive a context
// and tests that construct a Library directly both hit that path, so
// every emit in this package routes through here.
// emit publishes a Wails event under the library lock, which the
// background scan workers need because they outlive the context that
// started them. events.Emit tolerates a context with no Wails runtime;
// see its doc comment.
func (l *Library) emit(event string, data ...any) {
l.mu.Lock()
ctx := l.ctx
l.mu.Unlock()
if ctx == nil || ctx.Value("events") == nil {
l.logger.Debug(
"skipping event emit, no Wails runtime in context",
"event", event,
)
return
}
runtime.EventsEmit(ctx, event, data...)
events.Emit(ctx, event, data...)
}
// registerEventHandlers sets up Wails runtime event listeners.
+14 -22
View File
@@ -4,40 +4,32 @@ import (
"os"
"path/filepath"
"testing"
"yellowjacket/internal/testfixtures"
)
// testFlacFiles returns the paths to all .flac files in the
// test_data directory. It skips the test if none are found.
// testFlacFiles returns every .flac in the generated fixture library
// (`make testdata`).
//
// Sourced from the manifest rather than by walking test_data/, which
// used to sweep up the deliberately malformed fixtures — a zero-byte
// .flac is there to prove the scanner survives it, not to be handed to
// a duration parser.
func testFlacFiles(t *testing.T) []string {
t.Helper()
root := filepath.Join("..", "..", "test_data")
if _, err := os.Stat(root); os.IsNotExist(err) {
t.Skip("test_data directory not present, skipping")
}
m := testfixtures.Load(t)
var files []string
err := filepath.Walk(root, func(
path string, info os.FileInfo, err error,
) error {
if err != nil {
return err
for _, track := range m.Tracks {
if track.Format == "flac" {
files = append(files, m.Abs(track.Path))
}
if !info.IsDir() && filepath.Ext(path) == ".flac" {
files = append(files, path)
}
return nil
})
if err != nil {
t.Fatalf("walking test_data: %v", err)
}
if len(files) == 0 {
t.Skip("no .flac test fixtures found in test_data/")
t.Skip("no .flac fixtures in the manifest")
}
return files
+12 -26
View File
@@ -4,44 +4,30 @@ import (
"os"
"path/filepath"
"testing"
"yellowjacket/internal/testfixtures"
)
// testMP3Files returns the paths to all .mp3 files in the curated
// fixture library (`test_data/music_library_test/`). Scoped
// narrowly so that ad-hoc scramble / autotag fixtures placed
// elsewhere under `test_data/` (e.g. `test_data/mb-tag/`) don't
// get pulled into the assertion and fail on non-curated codecs.
// Skips the test when the directory isn't present.
// testMP3Files returns every .mp3 in the generated fixture library
// (`make testdata`). Scoped to that manifest so ad-hoc scramble /
// autotag fixtures elsewhere under `test_data/` don't get pulled into
// the assertion and fail on non-curated codecs. Skips when the
// fixtures haven't been generated.
func testMP3Files(t *testing.T) []string {
t.Helper()
root := filepath.Join("..", "..", "test_data", "music_library_test")
if _, err := os.Stat(root); os.IsNotExist(err) {
t.Skip("test_data/music_library_test not present, skipping")
}
m := testfixtures.Load(t)
var files []string
err := filepath.Walk(root, func(
path string, info os.FileInfo, err error,
) error {
if err != nil {
return err
for _, track := range m.Tracks {
if track.Format == "mp3" {
files = append(files, m.Abs(track.Path))
}
if !info.IsDir() && filepath.Ext(path) == ".mp3" {
files = append(files, path)
}
return nil
})
if err != nil {
t.Fatalf("walking test_data: %v", err)
}
if len(files) == 0 {
t.Skip("no .mp3 test fixtures found in test_data/")
t.Skip("no .mp3 fixtures in the manifest")
}
return files
+11 -14
View File
@@ -16,7 +16,6 @@ import (
"github.com/gopxl/beep/v2/effects"
"github.com/gopxl/beep/v2/generators"
"github.com/gopxl/beep/v2/speaker"
"github.com/wailsapp/wails/v2/pkg/runtime"
"yellowjacket/backend/coverart"
"yellowjacket/backend/database"
@@ -192,7 +191,7 @@ func (p *Player) emitPlaybackStateChanged(state State) {
"Emitting PlaybackStateChangedEvent", "state", state,
)
runtime.EventsEmit(
events.Emit(
p.ctx,
events.PlaybackStateChanged,
map[string]string{"state": string(state)},
@@ -214,7 +213,7 @@ func (p *Player) emitPlaybackFinished() {
}
p.logger.Info("Emitting PlaybackFinishedEvent")
runtime.EventsEmit(p.ctx, events.PlaybackFinished, nil)
events.Emit(p.ctx, events.PlaybackFinished, nil)
}
func (p *Player) emitVolumeChanged() {
@@ -229,7 +228,7 @@ func (p *Player) emitVolumeChanged() {
"Emitting VolumeChangedEvent", "volume", volume,
)
runtime.EventsEmit(p.ctx, events.VolumeChanged, volume)
events.Emit(p.ctx, events.VolumeChanged, volume)
if p.mediaControls != nil {
// MPRIS volume is 0.01.0 linear.
@@ -263,7 +262,7 @@ func (p *Player) emitTrackChanged() {
p.trackChangeID++
trackInfo.TrackChangeID = p.trackChangeID
runtime.EventsEmit(
events.Emit(
p.ctx, events.TrackChanged, trackInfo,
)
@@ -381,13 +380,11 @@ func (p *Player) onPlaybackFinished() {
// calls that don't need player state.
p.emitPlaybackFinished()
if p.ctx != nil {
runtime.EventsEmit(
p.ctx,
events.PlaybackStateChanged,
map[string]string{"state": string(Stopped)},
)
}
events.Emit(
p.ctx,
events.PlaybackStateChanged,
map[string]string{"state": string(Stopped)},
)
// Notify media controls outside the lock. The track just
// ended so position is 0.
@@ -643,7 +640,7 @@ func (p *Player) UnloadTrack() {
// Notify frontend that there is no longer a current track.
p.emitPlaybackStateChanged(p.state)
runtime.EventsEmit(p.ctx, events.TrackChanged, nil)
events.Emit(p.ctx, events.TrackChanged, nil)
if p.mediaControls != nil {
p.mediaControls.UpdateMetadata(mediacontrols.Metadata{})
@@ -754,7 +751,7 @@ func (p *Player) Seek(targetSeconds int) error {
func (p *Player) seekLocked(targetSeconds int) error {
if p.seeker == nil {
runtime.EventsEmit(p.ctx, events.SeekFailed)
events.Emit(p.ctx, events.SeekFailed)
return errNoAudioFileLoaded
}
+13 -6
View File
@@ -4,13 +4,9 @@ import (
"log/slog"
"os"
"testing"
)
var testQueue = []string{
"../../test_data/music_library_test/other_music/03 PONPONPON.mp3",
"../../test_data/music_library_test/01 Some Chords.mp3",
"../../test_data/music_library_test/03 anything.mp3",
}
"yellowjacket/internal/testfixtures"
)
func TestPlayer(t *testing.T) {
// This is an integration test that requires:
@@ -24,6 +20,17 @@ func TestPlayer(t *testing.T) {
)
}
// One track per supported container, so a decoder regression in
// any of the four shows up here rather than only in whichever
// format the fixtures happened to lead with.
m := testfixtures.Load(t)
testQueue := []string{
m.Case(t, testfixtures.CaseCoverDedup)[0],
m.Case(t, testfixtures.CaseFLACAlbum)[0],
m.Case(t, testfixtures.CaseOGGAlbum)[0],
m.Case(t, testfixtures.CaseWAVTracks)[0],
}
t.Logf("Starting test")
p := NewPlayer(slog.Default(), nil)
+199
View File
@@ -0,0 +1,199 @@
package playlist
import (
"context"
"fmt"
"log/slog"
"testing"
"yellowjacket/backend/database"
"yellowjacket/backend/events"
)
// stubLibraryDir satisfies LibraryDirProvider.
type stubLibraryDir struct{ dir string }
func (s stubLibraryDir) GetLibraryDirectory() string { return s.dir }
// setupRecordedService builds a playlist service on an in-memory DB
// that writes its M3U8 files to a temp directory and records the events
// it would push to the frontend.
func setupRecordedService(
t *testing.T,
) (*Service, *database.DB, *events.Recorder) {
t.Helper()
db := database.NewTestDB(t)
libDir := t.TempDir()
svc := NewService(slog.Default(), db, stubLibraryDir{dir: libDir})
svc.dataDirOverride = t.TempDir()
rec := events.NewRecorder()
svc.SetContext(events.WithSink(context.Background(), rec))
return svc, db, rec
}
// seedPlaylistTracks inserts `count` audio_file rows and returns their
// paths.
func seedPlaylistTracks(t *testing.T, db *database.DB, count int) []string {
t.Helper()
_, err := db.ExecContext(
"INSERT OR IGNORE INTO artist_credit (id, text) VALUES (1, 'Test Artist')",
)
if err != nil {
t.Fatalf("insert artist_credit: %v", err)
}
paths := make([]string, count)
for i := range count {
id := i + 1
paths[i] = fmt.Sprintf("/test/pl-track%d.mp3", id)
if _, err := db.ExecContext(
"INSERT OR IGNORE INTO recordings (id, name, artist_credit_id) "+
"VALUES (?, ?, 1)",
id, fmt.Sprintf("Track %d", id),
); err != nil {
t.Fatalf("insert recording %d: %v", id, err)
}
if _, err := db.ExecContext(
"INSERT OR IGNORE INTO audio_files (id, file_path, "+
"length_milliseconds, file_type_id, recording_id) "+
"VALUES (?, ?, 180000, 0, ?)",
id, paths[i], id,
); err != nil {
t.Fatalf("insert audio_file %d: %v", id, err)
}
}
return paths
}
func TestEmit_CreatePlaylistAnnouncesTheNewRow(t *testing.T) {
t.Parallel()
svc, _, rec := setupRecordedService(t)
created, err := svc.CreatePlaylist("Road Trip")
if err != nil {
t.Fatalf("CreatePlaylist: %v", err)
}
ev, ok := rec.Last(events.PlaylistCreated)
if !ok {
t.Fatalf("no PlaylistCreated; got %v", rec.Names())
}
summary, ok := ev.Payload().(Summary)
if !ok {
t.Fatalf("payload is %T, want playlist.Summary", ev.Payload())
}
// The frontend adds the sidebar entry straight from this payload
// rather than re-fetching, so an empty field here is a blank row.
if summary.ID != created.ID {
t.Errorf("emitted ID %d, want %d", summary.ID, created.ID)
}
if summary.Name != "Road Trip" {
t.Errorf("emitted name %q, want Road Trip", summary.Name)
}
if summary.CreatedAt == "" || summary.UpdatedAt == "" {
t.Errorf("emitted empty timestamps: %+v", summary)
}
}
func TestEmit_RejectedCreateIsSilent(t *testing.T) {
t.Parallel()
svc, _, rec := setupRecordedService(t)
if _, err := svc.CreatePlaylist(" "); err == nil {
t.Fatal("CreatePlaylist accepted a blank name")
}
if got := rec.Count(events.PlaylistCreated); got != 0 {
t.Errorf("emitted %d PlaylistCreated for a rejected create, want 0", got)
}
}
// TestEmit_TracksChangedFiresAfterTheWriteIsVisible is the reason this
// package is worth covering as well as queue: the frontend re-reads the
// playlist when it sees PlaylistTracksChanged, so an event emitted
// before the rows were committed would have it read the old contents.
func TestEmit_TracksChangedFiresAfterTheWriteIsVisible(t *testing.T) {
t.Parallel()
svc, db, rec := setupRecordedService(t)
paths := seedPlaylistTracks(t, db, 3)
created, err := svc.CreatePlaylist("Mix")
if err != nil {
t.Fatalf("CreatePlaylist: %v", err)
}
rec.Reset()
if err := svc.AddTracksToPlaylist(created.ID, paths); err != nil {
t.Fatalf("AddTracksToPlaylist: %v", err)
}
ev, ok := rec.Last(events.PlaylistTracksChanged)
if !ok {
t.Fatalf("no PlaylistTracksChanged; got %v", rec.Names())
}
id, ok := ev.Payload().(int64)
if !ok {
t.Fatalf("payload is %T, want int64", ev.Payload())
}
if id != created.ID {
t.Errorf("emitted playlist ID %d, want %d", id, created.ID)
}
// Read back the way the frontend would on receipt of the event.
tracks, err := svc.GetPlaylistTracks(created.ID)
if err != nil {
t.Fatalf("GetPlaylistTracks: %v", err)
}
if len(tracks) != len(paths) {
t.Errorf(
"a frontend reacting to the event reads %d tracks, want %d",
len(tracks), len(paths),
)
}
}
func TestEmit_DeletePlaylistAnnouncesTheID(t *testing.T) {
t.Parallel()
svc, _, rec := setupRecordedService(t)
created, err := svc.CreatePlaylist("Temp")
if err != nil {
t.Fatalf("CreatePlaylist: %v", err)
}
rec.Reset()
if err := svc.DeletePlaylist(created.ID); err != nil {
t.Fatalf("DeletePlaylist: %v", err)
}
ev, ok := rec.Last(events.PlaylistDeleted)
if !ok {
t.Fatalf("no PlaylistDeleted; got %v", rec.Names())
}
if id, _ := ev.Payload().(int64); id != created.ID {
t.Errorf("emitted ID %v, want %d", ev.Payload(), created.ID)
}
}
+1 -7
View File
@@ -15,8 +15,6 @@ import (
"sync"
"time"
"github.com/wailsapp/wails/v2/pkg/runtime"
"yellowjacket/backend/coverart"
"yellowjacket/backend/database"
"yellowjacket/backend/database/sql/sqlcgen"
@@ -1451,11 +1449,7 @@ func (s *Service) emitEvent(
eventName string,
data any,
) {
if s.ctx == nil {
return
}
runtime.EventsEmit(s.ctx, eventName, data)
events.Emit(s.ctx, eventName, data)
}
// migrateExistingPlaylists generates M3U8 files for any
+4 -22
View File
@@ -1,17 +1,11 @@
package queue
import (
"github.com/wailsapp/wails/v2/pkg/runtime"
"yellowjacket/backend/events"
)
// emitQueueChanged emits the full queue state to the frontend.
func (q *Queue) emitQueueChanged() {
if q.ctx == nil {
return
}
state := State{
Tracks: q.tracks,
CurrentIndex: q.currentIndex,
@@ -25,16 +19,12 @@ func (q *Queue) emitQueueChanged() {
state.Tracks = []Track{}
}
runtime.EventsEmit(q.ctx, events.QueueChanged, state)
events.Emit(q.ctx, events.QueueChanged, state)
}
// emitIndexChanged emits only the current index to the frontend.
func (q *Queue) emitIndexChanged() {
if q.ctx == nil {
return
}
runtime.EventsEmit(
events.Emit(
q.ctx,
events.QueueIndexChanged,
IndexChanged{CurrentIndex: q.currentIndex},
@@ -43,11 +33,7 @@ func (q *Queue) emitIndexChanged() {
// emitModeChanged emits only the shuffle/repeat mode to the frontend.
func (q *Queue) emitModeChanged() {
if q.ctx == nil {
return
}
runtime.EventsEmit(
events.Emit(
q.ctx,
events.QueueModeChanged,
ModeChanged{
@@ -64,11 +50,7 @@ func (q *Queue) emitTracksModified(
index int,
positions []int,
) {
if q.ctx == nil {
return
}
runtime.EventsEmit(
events.Emit(
q.ctx,
events.QueueTracksModified,
TracksModified{
+283
View File
@@ -0,0 +1,283 @@
package queue
import (
"context"
"log/slog"
"testing"
"time"
"yellowjacket/backend/database"
"yellowjacket/backend/events"
)
// waitFor is how long a test waits for an event emitted from a
// background goroutine (SetQueue resolves large queues in phases).
const waitFor = 5 * time.Second
// setupRecordedQueue is setupTestQueue with an event sink installed, so
// what the frontend would receive is assertable.
//
// Before events.Emit existed this was impossible: SetContext with a
// context.Background() made every emit call log.Fatalf inside Wails,
// and SetContext(nil) made the queue skip emitting entirely — so the
// payloads below have never been covered.
func setupRecordedQueue(t *testing.T) (*Queue, *database.DB, *events.Recorder) {
t.Helper()
db := database.NewTestDB(t)
q := NewQueue(slog.Default(), db)
q.SetPlayer(&mockTrackLoader{})
rec := events.NewRecorder()
q.SetContext(events.WithSink(context.Background(), rec))
return q, db, rec
}
// stateOf returns the State payload of the most recent QueueChanged.
func stateOf(t *testing.T, rec *events.Recorder) State {
t.Helper()
ev, ok := rec.Last(events.QueueChanged)
if !ok {
t.Fatalf("no QueueChanged emitted; got %v", rec.Names())
}
state, ok := ev.Payload().(State)
if !ok {
t.Fatalf("QueueChanged payload is %T, want queue.State", ev.Payload())
}
return state
}
// modifiedOf returns the TracksModified payload of the most recent
// QueueTracksModified.
func modifiedOf(t *testing.T, rec *events.Recorder) TracksModified {
t.Helper()
ev, ok := rec.Last(events.QueueTracksModified)
if !ok {
t.Fatalf("no QueueTracksModified emitted; got %v", rec.Names())
}
mod, ok := ev.Payload().(TracksModified)
if !ok {
t.Fatalf(
"QueueTracksModified payload is %T, want queue.TracksModified",
ev.Payload(),
)
}
return mod
}
func TestEmit_SetQueuePushesFullState(t *testing.T) {
t.Parallel()
q, db, rec := setupRecordedQueue(t)
paths := seedAudioFiles(t, db, 5)
q.SetQueue(paths, 2, false)
if _, ok := rec.Wait(events.QueueChanged, waitFor); !ok {
t.Fatalf("no QueueChanged after SetQueue; got %v", rec.Names())
}
state := stateOf(t, rec)
if len(state.Tracks) != 5 {
t.Errorf("emitted %d tracks, want 5", len(state.Tracks))
}
if state.CurrentIndex != 2 {
t.Errorf("emitted currentIndex %d, want 2", state.CurrentIndex)
}
}
// TestEmit_ClearSendsEmptyNotNilTrackList pins a frontend contract that
// only exists in the emitted payload: the queue's own tracks field is
// nil after Clear, and the store does `state.tracks.length` on receipt.
func TestEmit_ClearSendsEmptyNotNilTrackList(t *testing.T) {
t.Parallel()
q, db, rec := setupRecordedQueue(t)
paths := seedAudioFiles(t, db, 3)
q.SetQueue(paths, 0, false)
rec.Reset()
q.Clear()
state := stateOf(t, rec)
if state.Tracks == nil {
t.Error("emitted tracks is nil; the frontend reads .length on it")
}
if len(state.Tracks) != 0 {
t.Errorf("emitted %d tracks after Clear, want 0", len(state.Tracks))
}
if state.CurrentIndex != -1 {
t.Errorf("emitted currentIndex %d after Clear, want -1", state.CurrentIndex)
}
}
func TestEmit_CycleRepeatWalksAllModes(t *testing.T) {
t.Parallel()
q, _, rec := setupRecordedQueue(t)
want := []RepeatMode{RepeatAll, RepeatOne, RepeatOff}
for i, wantMode := range want {
q.CycleRepeat()
modeEvents := rec.Named(events.QueueModeChanged)
if len(modeEvents) != i+1 {
t.Fatalf("after %d cycles: %d QueueModeChanged events, want %d",
i+1, len(modeEvents), i+1)
}
mode, ok := modeEvents[i].Payload().(ModeChanged)
if !ok {
t.Fatalf("payload is %T, want queue.ModeChanged", modeEvents[i].Payload())
}
if mode.RepeatMode != wantMode {
t.Errorf("cycle %d emitted %v, want %v", i+1, mode.RepeatMode, wantMode)
}
if mode.ShuffleMode {
t.Errorf("cycle %d emitted shuffleMode true; only repeat changed", i+1)
}
}
}
func TestEmit_ToggleShuffleReportsBothModes(t *testing.T) {
t.Parallel()
q, db, rec := setupRecordedQueue(t)
q.SetQueue(seedAudioFiles(t, db, 5), 0, false)
rec.Reset()
q.ToggleShuffle()
ev, ok := rec.Last(events.QueueModeChanged)
if !ok {
t.Fatalf("no QueueModeChanged; got %v", rec.Names())
}
mode, ok := ev.Payload().(ModeChanged)
if !ok {
t.Fatalf("payload is %T, want queue.ModeChanged", ev.Payload())
}
if !mode.ShuffleMode {
t.Error("emitted shuffleMode false after ToggleShuffle")
}
// The mode event carries both modes, so a frontend that renders the
// two toggles from one event cannot desync them.
if mode.RepeatMode != RepeatOff {
t.Errorf("emitted repeatMode %v, want RepeatOff", mode.RepeatMode)
}
}
// TestEmit_AddTrackSendsDeltaNotSnapshot pins the distinction the whole
// TracksModified type exists for: appending must not re-push the queue.
func TestEmit_AddTrackSendsDeltaNotSnapshot(t *testing.T) {
t.Parallel()
q, db, rec := setupRecordedQueue(t)
paths := seedAudioFiles(t, db, 4)
q.SetQueue(paths[:3], 0, false)
if _, ok := rec.Wait(events.QueueChanged, waitFor); !ok {
t.Fatalf("no QueueChanged after SetQueue; got %v", rec.Names())
}
rec.Reset()
q.AddTrack(paths[3])
if got := rec.Count(events.QueueChanged); got != 0 {
t.Errorf("AddTrack emitted %d QueueChanged; want a delta only", got)
}
mod := modifiedOf(t, rec)
if mod.Action != "add" {
t.Errorf("action = %q, want \"add\"", mod.Action)
}
if mod.Index != 3 {
t.Errorf("index = %d, want 3 (appended at the end)", mod.Index)
}
if len(mod.Tracks) != 1 || mod.Tracks[0].FilePath != paths[3] {
t.Errorf("tracks = %v, want just %s", mod.Tracks, paths[3])
}
}
func TestEmit_RemoveTracksReportsPositions(t *testing.T) {
t.Parallel()
q, db, rec := setupRecordedQueue(t)
paths := seedAudioFiles(t, db, 5)
q.SetQueue(paths, 0, false)
if _, ok := rec.Wait(events.QueueChanged, waitFor); !ok {
t.Fatalf("no QueueChanged after SetQueue; got %v", rec.Names())
}
rec.Reset()
q.RemoveTracks([]int{3, 1})
mod := modifiedOf(t, rec)
if mod.Action != "remove" {
t.Errorf("action = %q, want \"remove\"", mod.Action)
}
if len(mod.Positions) != 2 {
t.Fatalf("positions = %v, want two entries", mod.Positions)
}
}
// TestEmit_NextPushesIndexOnly covers auto-advance, which is what the
// player calls at the end of a track: the frontend must be able to move
// the now-playing highlight without re-rendering the queue.
func TestEmit_NextPushesIndexOnly(t *testing.T) {
t.Parallel()
q, db, rec := setupRecordedQueue(t)
paths := seedAudioFiles(t, db, 3)
q.SetQueue(paths, 0, false)
if _, ok := rec.Wait(events.QueueChanged, waitFor); !ok {
t.Fatalf("no QueueChanged after SetQueue; got %v", rec.Names())
}
rec.Reset()
q.Next()
q.Next()
idxEvents := rec.Named(events.QueueIndexChanged)
if len(idxEvents) != 2 {
t.Fatalf("got %d QueueIndexChanged, want 2 (%v)", len(idxEvents), rec.Names())
}
for i, ev := range idxEvents {
idx, ok := ev.Payload().(IndexChanged)
if !ok {
t.Fatalf("payload is %T, want queue.IndexChanged", ev.Payload())
}
if idx.CurrentIndex != i+1 {
t.Errorf("advance %d emitted index %d, want %d", i+1, idx.CurrentIndex, i+1)
}
}
if got := rec.Count(events.QueueChanged); got != 0 {
t.Errorf("advancing emitted %d QueueChanged; want index deltas only", got)
}
}
+1 -7
View File
@@ -3,8 +3,6 @@ package queue
import (
"time"
"github.com/wailsapp/wails/v2/pkg/runtime"
"yellowjacket/backend/events"
)
@@ -62,9 +60,5 @@ func (q *Queue) recordPlay(audioFileID int64) {
)
// Notify frontend so the track list refreshes play count.
if q.ctx != nil {
runtime.EventsEmit(
q.ctx, events.TrackMetadataChanged,
)
}
events.Emit(q.ctx, events.TrackMetadataChanged)
}
+38 -28
View File
@@ -7,8 +7,6 @@ import (
"log/slog"
"time"
wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
"yellowjacket/backend/database"
"yellowjacket/backend/events"
)
@@ -175,8 +173,8 @@ func (tw *TagWriter) WriteTrackTags(trackID int64, changes TagChanges) error {
}
// 7. Emit event (suppressed during batch writes).
if tw.ctx != nil && !tw.suppressEvents {
wailsruntime.EventsEmit(tw.ctx, events.TrackMetadataChanged,
if !tw.suppressEvents {
events.Emit(tw.ctx, events.TrackMetadataChanged,
map[string]any{
"trackId": trackID,
"filePath": audioFile.FilePath,
@@ -214,6 +212,22 @@ func (tw *TagWriter) WriteUntrackedFileTags(
return errNoChanges
}
return WriteFileTags(tw.logger, filePath, changes)
}
// WriteFileTags writes tags straight to an audio file, with no
// database, player or lock involvement. It is the format-dispatch
// half of WriteUntrackedFileTags, exported so tooling that has no
// app to construct — the fixture generator in cmd/gentestdata — can
// tag files with the same writers the app uses, rather than growing a
// second tagger that is free to drift from this one.
//
// Callers owning a *TagWriter should use WriteUntrackedFileTags.
func WriteFileTags(
logger *slog.Logger,
filePath string,
changes TagChanges,
) error {
format, err := DetectFormat(filePath)
if err != nil {
return fmt.Errorf("detect format: %w", err)
@@ -221,13 +235,13 @@ func (tw *TagWriter) WriteUntrackedFileTags(
switch format {
case FormatMP3:
err = writeMp3Tags(tw.logger, filePath, changes)
err = writeMp3Tags(logger, filePath, changes)
case FormatFLAC:
err = writeFlacTags(tw.logger, filePath, changes)
err = writeFlacTags(logger, filePath, changes)
case FormatWAV:
err = writeWavTags(tw.logger, filePath, changes)
err = writeWavTags(logger, filePath, changes)
case FormatOGG:
err = writeOggTags(tw.logger, filePath, changes)
err = writeOggTags(logger, filePath, changes)
default:
err = fmt.Errorf("%w: %s", errUnsupportedFormat, format)
}
@@ -334,30 +348,26 @@ func (tw *TagWriter) BatchWriteTrackTags(
}
// Emit progress after each track (success or failure).
if tw.ctx != nil {
wailsruntime.EventsEmit(tw.ctx,
events.BatchWriteProgress,
map[string]any{
"current": i + 1,
"total": total,
"filePath": filePath,
"succeeded": result.Succeeded,
"failed": result.Failed,
},
)
}
events.Emit(tw.ctx,
events.BatchWriteProgress,
map[string]any{
"current": i + 1,
"total": total,
"filePath": filePath,
"succeeded": result.Succeeded,
"failed": result.Failed,
},
)
}
// Emit a single TrackMetadataChanged after the batch completes
// so the library store invalidates once rather than per-track.
if tw.ctx != nil {
wailsruntime.EventsEmit(tw.ctx, events.TrackMetadataChanged,
map[string]any{
"batch": true,
"total": result.Succeeded,
},
)
}
events.Emit(tw.ctx, events.TrackMetadataChanged,
map[string]any{
"batch": true,
"total": result.Succeeded,
},
)
tw.logger.Info("batch write complete",
"total", total,
+326
View File
@@ -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()
}
+207
View File
@@ -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
}
+199
View File
@@ -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")
}
+98
View File
@@ -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
}
+8
View File
@@ -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) {}
+53
View File
@@ -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
}