Files
yellowjacket/frontend/test/stores/download-store.test.ts
T
logan 5ca6cad45a
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
feat(harness): agent-drivable dev harness and CI that gates
A coding agent could develop this repo's Go packages and could not
develop the application: every path to running YellowJacket ended in a
blocking GTK window, so 265 bound methods, 46 events, 33 component
directories and 13 stores had exactly one form of verification
available — `tsc --noEmit`.

The unlock is that `wails dev`'s dev server on :34115 serves the real
frontend with the real generated bindings against the same Go backend a
desktop window attaches to, so a plain Chromium under Xvfb gets a fully
functional app. Four test tiers now exist, cheapest first:

- `make ui-test` — 313 Vitest tests in a real browser in ~2 s, no app,
  no backend, no display. Works because `frontend/wailsjs/` is a pure
  passthrough to `window.go`/`window.runtime`, so faking just those two
  globals runs the real bindings and the real store code.
- `make test` — services in-process, asserting on the payload the
  frontend would receive, via a new `events.Emit` wrapper.
- `make dev-headless` + `playwright-cli` — the real app, driven
  interactively, with an event bridge on `window.__yjEvents` and a
  dev-only control surface at `/__test/`.
- `make e2e` — 19 of those flows frozen as Playwright specs.

`events.Emit(ctx, …)` replaces all 35 direct `runtime.EventsEmit` call
sites: wails' `getEvents` `log.Fatalf`s on any context without its
runtime, so those paths could not run under test and a background
worker could take the app down. Four packages had each hand-rolled the
same guard; nine more guarded on `ctx != nil`, which does not help.
`TestNoDirectRuntimeEmits` fails the build on a new one.

Fixtures are generated, not committed (`make testdata`), and seeds are
built by *running the app* — never by hand-writing config and DB rows,
which would be a second description of a valid YJ_HOME.

`.gitea/workflows/ci.yml` is the first workflow here that tests
anything; the other three only package, so `gitea_ci` reported only
packaging jobs and misled anyone asking whether a push was healthy.
Both jobs were prototyped to green in a bare ubuntu:24.04 container
before the YAML was written, which immediately caught `make lint`
linting three configurations that nothing builds: all three passes
omitted `webkit2_41`, so wails resolved webkit2gtk-4.0 — which Arch
still ships and Ubuntu 24.04 dropped.

Operational instructions live in `.pi/skills/yellowjacket-dev/`,
measured discoveries in `.planning/NOTES.md`, and architecture in
`CLAUDE.md` — split by tense, not by topic, because a topical split
gives every new fact two plausible homes. `make skill-check` fails a
commit if the skill cites a make target that does not exist.
2026-08-10 23:20:42 -04:00

345 lines
9.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 users 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 users only clue', async () => {
stub('download.Service.TestProvider', () => {
throw new Error('connection refused');
});
await expect(downloadStore.testProvider(1)).rejects.toThrow(
'connection refused',
);
});
});