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.
345 lines
9.9 KiB
TypeScript
345 lines
9.9 KiB
TypeScript
/**
|
||
* 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',
|
||
);
|
||
});
|
||
});
|