Four specs still called `library.Library.GetTracks` after #281 replaced it, and the suite reported twenty failures across transport, bottom-bar and reduced-motion specs — none of which mention the library list. A binding call carries only a method id, so a stale name fails at runtime in whichever spec happens to call it. `binding-names.spec.ts` reads every spec and harness file, extracts the names they pass as strings, and checks them against the map derived from the generated bindings tree. It asserts first that it read something: a sweep over an empty glob passes and proves nothing. It immediately found a second thing: `play-count.spec.ts` asserted that a play refetched no collection by matching a `GetAll` prefix, which matches exactly one real name (`GetAllLibrariesWithTrackCounts`) — so the assertion held whatever the app refetched. It names the four collection bindings now.
322 lines
10 KiB
TypeScript
322 lines
10 KiB
TypeScript
import { fileURLToPath } from 'node:url';
|
||
import { dirname, resolve } from 'node:path';
|
||
|
||
import { test as base, expect, type Page } from '@playwright/test';
|
||
|
||
import { nameOf } from './method-ids.mjs';
|
||
|
||
const here = dirname(fileURLToPath(import.meta.url));
|
||
|
||
/** The same bridge `playwright-cli` loads, so an exploratory session and
|
||
* a committed spec see an identical page. */
|
||
const INIT_SCRIPT = resolve(here, '../../.playwright/init-events.js');
|
||
|
||
/**
|
||
* The fourth argument to `queue.Queue.SetQueue`, for a queue with no
|
||
* single source — the whole library, or a handful of ad-hoc tracks,
|
||
* which is what every spec here builds.
|
||
*
|
||
* It is passed explicitly because v3 rejects a call with the wrong
|
||
* argument count (`expects 4 arguments, got 3`) where v2 accepted one
|
||
* and filled the gap with a zero value. The specs had been three-arg
|
||
* since the parameter was added; nothing said so.
|
||
*/
|
||
export const NO_QUEUE_SOURCE = { type: '', id: 0, label: '' };
|
||
|
||
/**
|
||
* The 90-second fixture track (`cmd/gentestdata`, case `edge-lengths`).
|
||
*
|
||
* Every other fixture is 2–6 seconds, which is shorter than the time a
|
||
* spec takes to click something — a "pause it" test against one of
|
||
* those races the track finishing and fails on a UI that is correct.
|
||
*/
|
||
export const LONG_TRACK = 'Long Player';
|
||
|
||
/** Shape of the recorder installed by .playwright/init-events.js. */
|
||
export type YjEvent = {
|
||
seq: number;
|
||
name: string;
|
||
data: unknown[];
|
||
dir: 'in' | 'out';
|
||
t: number;
|
||
};
|
||
|
||
/**
|
||
* Await a backend event instead of a timeout.
|
||
*
|
||
* Half of this app is push-driven, and the events that matter
|
||
* (scan progress, job updates, playback state) arrive whenever the
|
||
* backend gets to them. `waitForEvent` resolves against events already
|
||
* buffered as well as future ones, so there is no race between doing
|
||
* the thing and starting to listen.
|
||
*/
|
||
export async function waitForEvent(
|
||
page: Page,
|
||
name: string,
|
||
opts: { timeoutMs?: number; since?: number } = {},
|
||
): Promise<YjEvent> {
|
||
return page.evaluate(
|
||
([n, o]) => window.__yjEvents.wait(n as string, o as object),
|
||
[name, { timeoutMs: 10_000, ...opts }] as const,
|
||
) as Promise<YjEvent>;
|
||
}
|
||
|
||
/** Drop the event buffer. Never re-register a recorder: listeners
|
||
* survive across evaluate calls and a second recorder double-counts. */
|
||
export async function resetEvents(page: Page): Promise<void> {
|
||
await page.evaluate(() => void window.__yjEvents.reset());
|
||
}
|
||
|
||
/** name -> count, for asserting on (or debugging) what actually fired. */
|
||
export async function eventNames(
|
||
page: Page,
|
||
): Promise<Record<string, number>> {
|
||
return page.evaluate(() => window.__yjEvents.names());
|
||
}
|
||
|
||
/**
|
||
* Call a bound Go method by name, over the runtime's own endpoint.
|
||
*
|
||
* v3 rejects a bad call rather than never firing its callback the way
|
||
* v2 did: a wrong argument type comes back as a TypeError naming the
|
||
* argument, a wrong count as `expects 4 arguments, got 3`, an unknown
|
||
* method as a ReferenceError. The timeout is a backstop for a hung
|
||
* request, not the mechanism that makes a mistake visible.
|
||
*/
|
||
export async function callBinding<T = unknown>(
|
||
page: Page,
|
||
path: string,
|
||
args: unknown[] = [],
|
||
timeoutMs = 10_000,
|
||
): Promise<T> {
|
||
return page.evaluate(
|
||
([p, a, t]) =>
|
||
window.__yjEvents.call(p as string, a as unknown[], t as number),
|
||
[path, args, timeoutMs] as const,
|
||
) as Promise<T>;
|
||
}
|
||
|
||
/** A library track as the specs use it: the path and its names. */
|
||
export interface LibraryTrack {
|
||
FilePath: string;
|
||
TrackName: string;
|
||
ArtistName: string;
|
||
Album: string;
|
||
}
|
||
|
||
/**
|
||
* Every track in the library, in the order the backend lists them.
|
||
*
|
||
* The list arrives as a columnar `TrackTable` since #281 (repeated
|
||
* strings sent once, as indexes into `strings`), so it cannot be read
|
||
* as an array of tracks any more. This reads the three columns the
|
||
* specs use; `frontend/src/utils/track-table.ts` is the real decoder.
|
||
*/
|
||
export async function libraryTracks(page: Page): Promise<LibraryTrack[]> {
|
||
const t = await callBinding<{
|
||
strings: string[];
|
||
filePath: string[];
|
||
trackName: number[];
|
||
artistName: number[];
|
||
album: number[];
|
||
}>(page, 'library.Library.GetTrackTable', [0]);
|
||
|
||
return (t.filePath ?? []).map((FilePath, i) => ({
|
||
FilePath,
|
||
TrackName: t.strings[t.trackName[i]!] ?? '',
|
||
ArtistName: t.strings[t.artistName[i]!] ?? '',
|
||
Album: t.strings[t.album[i]!] ?? '',
|
||
}));
|
||
}
|
||
|
||
/**
|
||
* The binding calls the *app* made, newest last, as `pkg.Type.Method`.
|
||
*
|
||
* This is what replaces v2's trick of wrapping `window.go` in place:
|
||
* `.playwright/init-events.js` records every call off the one POST v3
|
||
* routes them all through, and `method-ids.ts` names them from the
|
||
* generated tree. It sees calls from any module and cannot miss one
|
||
* made before a wrapper was installed.
|
||
*/
|
||
export async function bindingCalls(page: Page): Promise<string[]> {
|
||
const calls = await page.evaluate(() => window.__yjEvents.bindings);
|
||
|
||
return calls.map(nameOf);
|
||
}
|
||
|
||
/**
|
||
* Go to a view without going through the navigation.
|
||
*
|
||
* `navigate` is the event the shell listens for and every nav item, card
|
||
* and detail view dispatches, so this is the app's own mechanism rather
|
||
* than a test-only door. It exists because a destination is not
|
||
* guaranteed to have a nav item any more (#25): Autotag is hidden until
|
||
* the user asks for it and Downloads until a client exists, and a spec
|
||
* about what a *view* does should not also be asserting that the
|
||
* sidebar offers it.
|
||
*/
|
||
export async function navigateTo(page: Page, view: string): Promise<void> {
|
||
await page.evaluate(
|
||
(v) =>
|
||
void document.dispatchEvent(
|
||
new CustomEvent('navigate', {
|
||
detail: { view: v },
|
||
bubbles: true,
|
||
composed: true,
|
||
}),
|
||
),
|
||
view,
|
||
);
|
||
|
||
await page
|
||
.getByTestId('main-content')
|
||
.waitFor({ state: 'attached' });
|
||
}
|
||
|
||
/**
|
||
* Open the queue the way a user at this viewport would.
|
||
*
|
||
* **The route differs by width and that is the feature, not an
|
||
* inconvenience.** Above 600px the bottom bar carries a queue button.
|
||
* Below it that button is gone (#59) and the queue is reached from the
|
||
* full-screen Now Playing view, which the mini player's art opens —
|
||
* "reachable only from Now Playing", which is what the issue asks for.
|
||
*
|
||
* It is here rather than in one spec because four files need it, and
|
||
* because a spec that hard-codes `#queue-button` is quietly asserting
|
||
* *which* route exists as well as what the queue does. Four of them
|
||
* were, which is how hiding one button failed ten tests about
|
||
* something else.
|
||
*
|
||
* The width is read from the page rather than passed, so a caller that
|
||
* resizes and then opens does not have to say so twice.
|
||
*/
|
||
export async function openTheQueue(page: Page): Promise<void> {
|
||
const toggle = page.locator('#queue-button');
|
||
|
||
if (await toggle.isVisible()) {
|
||
if ((await toggle.getAttribute('aria-expanded')) !== 'true') {
|
||
await toggle.click();
|
||
}
|
||
|
||
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
|
||
|
||
return;
|
||
}
|
||
|
||
// The phone: through Now Playing. `open-now-playing` is the mini
|
||
// player's art, which is a button only below 600px.
|
||
if (
|
||
(await page.getByTestId('main-content').getAttribute('data-active-view')) !==
|
||
'now-playing'
|
||
) {
|
||
await page.getByTestId('open-now-playing').click();
|
||
}
|
||
|
||
await page.getByTestId('npv-queue').click();
|
||
await expect(page.locator('#queue-panel')).toHaveAttribute('open', '');
|
||
}
|
||
|
||
/** Thin client for the dev-only /__test/ surface (backend/testctl). */
|
||
export class TestCtl {
|
||
constructor(private readonly baseURL: string) {}
|
||
|
||
private async req(path: string, init?: RequestInit) {
|
||
const res = await fetch(`${this.baseURL}${path}`, {
|
||
signal: AbortSignal.timeout(120_000),
|
||
...init,
|
||
});
|
||
const body = await res.json();
|
||
|
||
if (!res.ok) {
|
||
throw new Error(`testctl ${path}: ${body.error ?? res.status}`);
|
||
}
|
||
|
||
return body;
|
||
}
|
||
|
||
health() {
|
||
return this.req('/__test/health');
|
||
}
|
||
|
||
snapshot(name: string) {
|
||
return this.req(`/__test/db/snapshot?name=${name}`, { method: 'POST' });
|
||
}
|
||
|
||
restore(name: string) {
|
||
return this.req(`/__test/db/restore?name=${name}`, { method: 'POST' });
|
||
}
|
||
|
||
emit(name: string, ...data: unknown[]) {
|
||
return this.req('/__test/emit', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ name, data }),
|
||
});
|
||
}
|
||
|
||
sql(sql: string, args: unknown[] = []) {
|
||
return this.req('/__test/sql', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ sql, args }),
|
||
});
|
||
}
|
||
}
|
||
|
||
export const test = base.extend<{ app: Page; testctl: TestCtl }>({
|
||
/** A page with the event bridge installed and the app loaded and
|
||
* actually talking to the backend — not merely DOM-ready, which is
|
||
* earlier and lies. */
|
||
app: async ({ page, baseURL }, use) => {
|
||
await page.addInitScript({ path: INIT_SCRIPT });
|
||
await page.goto(baseURL!);
|
||
await page.evaluate(() => window.__yjEvents.ready(20_000));
|
||
await use(page);
|
||
},
|
||
|
||
testctl: async ({ baseURL }, use) => {
|
||
await use(new TestCtl(baseURL!));
|
||
},
|
||
});
|
||
|
||
export { expect };
|
||
|
||
declare global {
|
||
interface Window {
|
||
__yjEvents: {
|
||
version: number;
|
||
seq: number;
|
||
log: YjEvent[];
|
||
reset(): number;
|
||
all(name?: string): YjEvent[];
|
||
count(name?: string): number;
|
||
last(name?: string): YjEvent | null;
|
||
since(seq: number): YjEvent[];
|
||
names(): Record<string, number>;
|
||
wait(
|
||
name: string,
|
||
opts?: {
|
||
timeoutMs?: number;
|
||
since?: number;
|
||
match?: (data: unknown[], entry: YjEvent) => boolean;
|
||
},
|
||
): Promise<YjEvent>;
|
||
ready(timeoutMs?: number): Promise<boolean>;
|
||
call(path: string, args?: unknown[], timeoutMs?: number): Promise<any>;
|
||
/** Every binding call the app itself made; see init-events.js. */
|
||
bindings: {
|
||
methodID: number | null;
|
||
methodName: string | null;
|
||
start: number;
|
||
ms: number;
|
||
bytes: number;
|
||
}[];
|
||
measureBytes: boolean;
|
||
};
|
||
/** The v3 runtime's own namespace, installed by @wailsio/runtime. */
|
||
_wails?: {
|
||
dispatchWailsEvent?: (event: unknown) => void;
|
||
clientId?: string;
|
||
};
|
||
}
|
||
}
|