Files
yellowjacket/e2e/specs/play-count.spec.ts
yonluandClaude Opus 5 deb3f3da7e feat(wails): move the e2e harness and headless launch onto v3
make e2e is green on chromium: 92 passed. The harness is rebuilt on
what v3 actually offers, and three of the four things it replaced turn
out to be better than what they replaced.

The headless launch is v3's own server mode. scripts/dev-headless.sh
ran a `-tags dev` binary whose app_dev.go parsed -devserver/-assetdir
out of os.Args; that file went with v2, so the harness had no server at
all. `-tags dev,server` is a first-class mode and needs no display, so
Xvfb is gone from the script and from CI.

The bridge hooks two places, neither of them EventsOn. Inbound is
window._wails.dispatchWailsEvent, wrapped by pre-creating the object
the runtime keeps and putting an accessor on the one property.
Outbound is fetch: v3 routes every runtime call through one POST, so
the bridge sees binding calls and event emits from any module, needs no
walk of an object graph, and cannot miss a call made before it looked.

__yjEvents.call posts to that endpoint by method name, so it depends on
nothing in the app's bundle and works on a page with no init script.
That is what lets seed-sandbox.sh drop playwright-cli entirely — it
drove AddLibrary through a browser only because window.go was v2's one
way in — and with it a global npm install and a second Chromium in CI.

measure.mjs and one spec lose their window.go walks and read the
bridge's log instead; e2e/support/method-ids.mjs derives id -> name
from frontend/bindings/ (phase 6b option 1, so it cannot go stale
silently). Plain .mjs because measure.mjs runs under bare node and one
derivation beats two that can disagree.

Four bugs surfaced, and the migration is how.

The cross-service wiring never ran headless. It hung off
Common.ApplicationStarted, which server mode never emits —
setupCommonEvents is an explicit no-op there — so the queue had no
TrackLoader and playing a track changed the queue and then silently did
nothing. It is a service registered last now (backend/startup.go):
services start in registration order, which is the ordering the wiring
needs, in every mode.

Six specs called SetQueue with 3 of its 4 arguments. v2 accepted that
and filled the gap; v3 answers "expects 4 arguments, got 3".

requested-badge's cleanup read window.go and returned early on
`if (!svc)` — the silent cleanup its own comment was written to
prevent, one migration later. It posts to the runtime endpoint now,
which any page can do.

SearchIndex.Search trusted a startup latch, so rows a spec staged
afterwards were unsearchable and three specs passed only when an
earlier one happened to flip it. shelves.go fixed exactly this and left
hasCatalogRows behind; the search path now uses it as the fallback,
with the latch still the fast path.

Two spec edits are deletions of assertions about v2. harness.spec
checked Object.keys(window.go) and that a bad call *hung*; it now
checks the real runtime is loaded and that the backend rejects with a
TypeError naming the argument. album-actions asserted a tracklist
legend that dcc40b1 deleted on main — that spec has been failing since,
and what replaced it is covered in frontend/test/components.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UDCbcCZQepnpSQYJ6SxxZm
2026-08-14 20:58:20 -04:00

167 lines
5.9 KiB
TypeScript
Raw Permalink 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.
import type { Page } from '@playwright/test';
import {
test,
expect,
resetEvents,
callBinding,
bindingCalls,
NO_QUEUE_SOURCE,
} from '../support/fixtures.js';
/**
* Finishing a track is cheap, and does not disturb the user.
*
* `recordPlay` used to emit `TrackMetadataChanged` — the event that
* means "the tags on disk were rewritten" — so the library store threw
* away its whole cache and refetched tracks, albums, artists and genres
* once per song. Measured on a 50 000-track library that is ~37 MB
* across the IPC and ~0.8 s of blocked main thread *per track*
* (audit perf.C1), and `track-list` answered the new array by clearing
* the selection, which made selecting forty tracks to drag into a
* playlist impossible while music played (perf.C2).
*
* Both halves are asserted here, and both assertions are negative:
* what makes this a fix is the work that no longer happens.
*/
/** Long enough for a fixture track (26 s) to finish by itself. */
const FINISH_TIMEOUT = 60_000;
/**
* "Was anything refetched" is a fact, not an inference.
*
* This used to wrap every method on `window.go.library.Library` in
* place. v3 has no such object, and does not need one: the harness
* bridge records every binding call off the single POST they all go
* through, so the question is answered by reading that log rather than
* by instrumenting a target first. `resetEvents` clears it.
*/
const libraryCalls = async (app: Page): Promise<string[]> =>
(await bindingCalls(app)).filter((c) => c.startsWith('library.Library.'));
/**
* Select rows by dispatching on the row rather than clicking it.
*
* A real click in the middle of a row lands on the track *title*, and
* a title navigates — that is `utils/explore-link.ts` working as
* designed, not a bug. Which pixel selects a row is not what this spec
* is about; that the selection survives a track change is.
*/
const selectRows = (indices: number[]): void => {
const list = document.querySelector('track-list');
const rows = Array.from(
list?.shadowRoot?.querySelectorAll('[data-testid="track-row"]') ?? [],
);
indices.forEach((i, n) => {
rows[i]?.dispatchEvent(new MouseEvent('click', {
bubbles: true, composed: true, ctrlKey: n > 0,
}));
});
};
/** The file paths of every row currently showing as selected. */
const selectedPaths = (): string[] => Array.from(
document.querySelectorAll('track-list'),
).flatMap((l) => Array.from(
l.shadowRoot?.querySelectorAll('[aria-selected="true"]') ?? [],
)).map((r) => r.getAttribute('data-file-path') ?? '');
/** The first n file paths in the list. */
const firstPaths = (n: number): string[] => Array.from(
document.querySelector('track-list')
?.shadowRoot?.querySelectorAll('[data-file-path]') ?? [],
).map((r) => r.getAttribute('data-file-path') ?? '').slice(0, n);
test.describe('a finished track', () => {
test.beforeEach(async ({ app }) => {
await app.getByTestId('nav-tracks').click();
await expect(app.getByTestId('track-row').first()).toBeVisible();
});
test('reports a play count, not a metadata change', async ({ app }) => {
const paths = await app.evaluate(firstPaths, 2);
expect(paths.length).toBeGreaterThanOrEqual(2);
await callBinding(app, 'queue.Queue.SetQueue', [paths, 0, false, NO_QUEUE_SOURCE]);
await resetEvents(app);
await callBinding(app, 'queue.Queue.PlayIndex', [0]);
const event = await app.evaluate(
(ms) => window.__yjEvents.wait('TrackPlayCountChanged', {
timeoutMs: ms,
}),
FINISH_TIMEOUT,
);
// Enough for a refetch, if one were going to happen, to be recorded.
await app.waitForTimeout(2500);
const payload = (event.data as Array<Record<string, unknown>>)[0]!;
expect(payload['filePath']).toBe(paths[0]);
expect(typeof payload['playCount']).toBe('number');
expect(payload['playCount']).toBeGreaterThan(0);
const names = await app.evaluate(() => window.__yjEvents.names());
expect(
names['TrackMetadataChanged'] ?? 0,
'a play emitted the retag event, which invalidates every cache',
).toBe(0);
const refetched = await libraryCalls(app);
expect(
refetched.filter((c) => c.startsWith('library.Library.GetAll')),
'a play refetched a collection',
).toEqual([]);
await callBinding(app, 'player.Player.Pause', []);
});
test('leaves the track-list selection alone', async ({ app }) => {
// Rows away from the top, so the tracks selected are not the ones
// playing and the assertion is about the selection rather than
// about what happens to be on screen.
await app.evaluate(selectRows, [3, 4, 5]);
await app.waitForTimeout(300);
const selectedBefore = await app.evaluate(selectedPaths);
expect(selectedBefore).toHaveLength(3);
const paths = await app.evaluate(firstPaths, 2);
await callBinding(app, 'queue.Queue.SetQueue', [paths, 0, false, NO_QUEUE_SOURCE]);
await resetEvents(app);
await callBinding(app, 'queue.Queue.PlayIndex', [0]);
// `PlaybackFinished`, deliberately, for two reasons. Starting
// playback emits `TrackChanged` immediately, so waiting for that
// would assert about a click rather than about a track *finishing*
// — the only transition that used to clear the selection. And
// unlike `TrackPlayCountChanged` it exists on both sides of this
// fix, so reverting the fix makes this spec fail by reporting a
// cleared selection rather than by timing out on an event that was
// never introduced.
await app.evaluate(
(ms) => window.__yjEvents.wait('PlaybackFinished', { timeoutMs: ms }),
FINISH_TIMEOUT,
);
await app.waitForTimeout(2000);
const selectedAfter = await app.evaluate(selectedPaths);
expect(
selectedAfter,
'the selection was cleared by a track finishing',
).toEqual(selectedBefore);
await callBinding(app, 'player.Player.Pause', []);
});
});