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
+344
View File
@@ -0,0 +1,344 @@
/**
* The download store is mostly event-driven refreshes plus a set of
* pure formatters the downloads list and the picker share — they exist
* so those two views cannot disagree about what a state is called, and
* that only holds if both are tested against the same expectations.
*/
import { describe, expect, it, beforeEach } from 'vitest';
import {
downloadStore,
isDownloadTerminal,
stateLabel,
scorePercent,
candidateSummary,
formatBytes,
type DownloadView,
type DownloadCandidate,
type DownloadProvider,
type Request,
} from '@store/download-store';
import { Events } from '../../src/events';
import {
emit,
calls,
stub,
flush,
lastArgs,
resetHarness,
} from '@test/support/harness';
function view(id: string, state: string): DownloadView {
return { id, state } as DownloadView;
}
function provider(id: number, enabled: boolean): DownloadProvider {
return { id, name: `p${id}`, enabled, kind: 'test' } as DownloadProvider;
}
function request(overrides: Partial<Request>): Request {
return {
id: 1,
mbid: 'abc',
entity: 'release-group',
state: 'wanted',
...overrides,
} as Request;
}
function candidate(overrides: Partial<DownloadCandidate>): DownloadCandidate {
return { id: 'c', totalSize: 0, origin: '', ...overrides } as DownloadCandidate;
}
describe('download formatters', () => {
it('treats complete, cancelled and failed as terminal', () => {
expect(
['complete', 'cancelled', 'failed', 'grabbing'].map((s) =>
isDownloadTerminal(view('d', s)),
),
).toEqual([true, true, true, false]);
});
it('labels every lifecycle state in the user’s terms', () => {
expect([
stateLabel('found'),
stateLabel('grabbing'),
stateLabel('importing'),
]).toEqual(['Waiting for you to choose', 'Downloading', 'Importing']);
});
it('passes an unknown state through rather than showing a blank', () => {
expect(stateLabel('teleporting')).toBe('teleporting');
});
it('rounds a score to whole percent', () => {
expect([scorePercent(0), scorePercent(0.876), scorePercent(1)]).toEqual([
'0%',
'88%',
'100%',
]);
});
it('scales bytes to the largest unit that fits', () => {
expect([
formatBytes(512),
formatBytes(1024),
formatBytes(5.5 * 1024 * 1024),
formatBytes(20 * 1024 * 1024 * 1024),
]).toEqual(['512 B', '1.0 KB', '5.5 MB', '20 GB']);
});
it('renders no size at all rather than "0 B"', () => {
expect([formatBytes(0), formatBytes(-1)]).toEqual(['', '']);
});
it('summarises a single-format candidate', () => {
expect(
candidateSummary(
candidate({
files: [
{ isAudio: true, format: 'flac' },
{ isAudio: true, format: 'flac' },
{ isAudio: false, format: 'jpg' },
],
totalSize: 300 * 1024 * 1024,
origin: 'Example',
} as Partial<DownloadCandidate>),
),
).toBe('FLAC · 2 tracks · 300 MB · Example');
});
it('flags a mixed-format candidate instead of naming one format', () => {
expect(
candidateSummary(
candidate({
files: [
{ isAudio: true, format: 'flac' },
{ isAudio: true, format: 'mp3' },
],
} as Partial<DownloadCandidate>),
),
).toBe('Mixed formats · 2 tracks');
});
it('singularises a one-track candidate', () => {
expect(
candidateSummary(
candidate({
files: [{ isAudio: true, format: 'mp3' }],
} as Partial<DownloadCandidate>),
),
).toBe('MP3 · 1 track');
});
it('survives a candidate with no file list at all', () => {
expect(candidateSummary(candidate({}))).toBe('');
});
});
describe('download store: event-driven refresh', () => {
beforeEach(async () => {
stub('download.Service.ListProviders', []);
stub('download.Service.ListDownloads', []);
stub('download.Service.ListRequests', []);
stub('download.Service.ProviderKinds', []);
await downloadStore.init();
await flush();
resetHarness();
stub('download.Service.ListProviders', [
provider(1, true),
provider(2, false),
]);
stub('download.Service.ListDownloads', [
view('a', 'grabbing'),
view('b', 'complete'),
]);
stub('download.Service.ListRequests', [
request({ id: 1, mbid: 'abc', state: 'wanted' }),
request({ id: 2, mbid: 'def', state: 'satisfied' }),
request({ id: 3, mbid: 'ghi', entity: 'artist' }),
]);
});
it('reloads providers when the backend says they changed', async () => {
emit(Events.DownloadProvidersChanged);
await flush();
expect(downloadStore.providers).toHaveLength(2);
});
it('reloads downloads on DownloadsChanged', async () => {
emit(Events.DownloadsChanged);
await flush();
expect(downloadStore.downloads.map((d) => d.id)).toEqual(['a', 'b']);
});
it('reloads requests, which change without the user doing anything', async () => {
// A background reconcile pass expands an artist or retires a want,
// so the list is push-driven rather than fetched on mount.
emit(Events.RequestsChanged);
await flush();
expect(downloadStore.requests).toHaveLength(3);
});
it('offers downloading only when a provider is enabled', async () => {
emit(Events.DownloadProvidersChanged);
await flush();
const withEnabled = downloadStore.available;
stub('download.Service.ListProviders', [provider(2, false)]);
emit(Events.DownloadProvidersChanged);
await flush();
expect([withEnabled, downloadStore.available]).toEqual([true, false]);
});
it('separates active downloads from finished ones', async () => {
emit(Events.DownloadsChanged);
await flush();
expect(downloadStore.activeDownloads.map((d) => d.id)).toEqual(['a']);
});
it('separates outstanding requests and artist subscriptions', async () => {
emit(Events.RequestsChanged);
await flush();
expect({
active: downloadStore.activeRequests.map((r) => r.id),
subscriptions: downloadStore.subscriptions.map((r) => r.id),
}).toEqual({ active: [1, 3], subscriptions: [3] });
});
it('survives a null list, which Go sends when nothing exists', async () => {
stub('download.Service.ListDownloads', null);
emit(Events.DownloadsChanged);
await flush();
expect(downloadStore.downloads).toEqual([]);
});
it('keeps the last good list when a refresh fails', async () => {
emit(Events.DownloadsChanged);
await flush();
stub('download.Service.ListDownloads', () => {
throw new Error('backend down');
});
emit(Events.DownloadsChanged);
await flush();
expect(downloadStore.downloads).toHaveLength(2);
});
it('coalesces three refreshes into one notification', async () => {
let notifications = 0;
const off = downloadStore.subscribe(() => {
notifications += 1;
});
emit(Events.DownloadProvidersChanged);
emit(Events.DownloadsChanged);
emit(Events.RequestsChanged);
await flush();
off();
expect(notifications).toBe(1);
});
});
describe('download store: request lookup', () => {
beforeEach(async () => {
stub('download.Service.ListRequests', [
request({ id: 1, mbid: 'abc-123', state: 'wanted' }),
]);
emit(Events.RequestsChanged);
await flush();
resetHarness();
stub('download.Service.ListRequests', [
request({ id: 1, mbid: 'abc-123', state: 'wanted' }),
]);
});
it('answers from the cached list, synchronously enough to render with', () => {
expect([
downloadStore.isRequested('abc-123'),
downloadStore.isRequested('nope'),
]).toEqual([true, false]);
});
it('normalises case and whitespace in the MBID it is given', () => {
expect(downloadStore.isRequested(' ABC-123 ')).toBe(true);
});
it('returns the request itself for the caller that needs its state', () => {
expect(downloadStore.requestFor('abc-123')?.id).toBe(1);
});
});
describe('download store: writes refresh what they changed', () => {
beforeEach(async () => {
stub('download.Service.ListProviders', []);
stub('download.Service.ListDownloads', []);
stub('download.Service.ListRequests', []);
await flush();
resetHarness();
stub('download.Service.ListProviders', []);
stub('download.Service.ListDownloads', []);
stub('download.Service.ListRequests', []);
});
it('refreshes providers after adding one', async () => {
stub('download.Service.AddProvider', 5);
await expect(
downloadStore.addProvider('sab', 'Local', { url: 'http://x' }),
).resolves.toBe(5);
expect(calls().map((c) => c.path)).toEqual([
'download.Service.AddProvider',
'download.Service.ListProviders',
]);
});
it('refreshes downloads after picking a candidate', async () => {
await downloadStore.pick('d1', 'c1');
expect([
lastArgs('download.Service.Pick'),
calls('download.Service.ListDownloads'),
]).toEqual([['d1', 'c1'], [{ path: 'download.Service.ListDownloads', args: [50] }]]);
});
it('refreshes requests after removing one', async () => {
await downloadStore.removeRequest(3);
expect(calls().map((c) => c.path)).toEqual([
'download.Service.RemoveRequest',
'download.Service.ListRequests',
]);
});
it('refreshes both lists after a manual reconcile, since it can start downloads', async () => {
stub('download.Service.ReconcileRequests', { added: 1 });
await downloadStore.reconcileRequests();
expect(calls().map((c) => c.path).sort()).toEqual([
'download.Service.ListDownloads',
'download.Service.ListRequests',
'download.Service.ReconcileRequests',
]);
});
it('propagates a provider test failure, which is the user’s only clue', async () => {
stub('download.Service.TestProvider', () => {
throw new Error('connection refused');
});
await expect(downloadStore.testProvider(1)).rejects.toThrow(
'connection refused',
);
});
});