test(e2e): freeze the four reproductions this work started from
Each was written first and watched fail first: - `view-lifecycle` — pressing `s` on Settings must not skip an album out of the Autotag queue, and on Autotag the same key must not also toggle shuffle. - `player-truth` — the elapsed clock tracks the backend through steady playback and four keyboard seeks (the measurement that failed by 30 s), a finished queue keeps the track on the bar at 0:00, and auto-advance skips a missing file and reaches the next track. - `offline-icons` — blocks every non-local request and asserts on the <svg> *inside* each icon's shadow root, since asserting the element exists would have passed before the fix too. Verified red: 24 empty icons. - `failure-voice` — induces a real binding failure through /__test/sql and asserts a sentence appears. Its first version renamed the decoy library to its own name, which the backend accepts: it failed at the right assertion while never inducing the failure, so it now picks the row by the seeded library's name from /__test/health. - `play-count` — a finished track does not refetch the library. The queue spec now opens the panel before asserting on its rows and waits for it to close again: the panel's width is animated and the transport slides with it, so a click issued during the close lands on whichever button moved under the pointer.
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
import { test, expect } from '../support/fixtures.js';
|
||||
|
||||
/**
|
||||
* Plan 007 phase 3: a failure the user caused has to reach the user.
|
||||
*
|
||||
* The failure is induced through `/__test/sql` rather than staged in the
|
||||
* UI: a second library called "Decoy" makes `RenameLibrary` reject with
|
||||
* its duplicate-name error, which is a real binding rejection on a path
|
||||
* the audit found ends at `console.error` (errors.m5).
|
||||
*/
|
||||
test.describe('a failed binding says so', () => {
|
||||
const DECOY = 'Decoy';
|
||||
|
||||
test.afterEach(async ({ testctl }) => {
|
||||
await testctl.sql('DELETE FROM libraries WHERE name = ?', [DECOY]);
|
||||
});
|
||||
|
||||
test('a rejected rename reaches the user, not the console', async ({
|
||||
app,
|
||||
testctl,
|
||||
}) => {
|
||||
// The library that was seeded by running the app, i.e. the one row
|
||||
// that is not the decoy.
|
||||
const health = await testctl.health();
|
||||
const seeded = health.libraries[0].name as string;
|
||||
|
||||
await testctl.sql(
|
||||
'INSERT INTO libraries (name, path) VALUES (?, ?)',
|
||||
[DECOY, '/tmp/yj-decoy-library'],
|
||||
);
|
||||
|
||||
await app.getByTestId('nav-settings').click();
|
||||
|
||||
const page = app.locator('config-page');
|
||||
|
||||
// Config sections start collapsed.
|
||||
await page
|
||||
.locator('config-section[heading="Libraries"]')
|
||||
.locator('.header')
|
||||
.click();
|
||||
|
||||
// Renaming the decoy to its own name is a no-op the backend
|
||||
// accepts, so the rename has to happen on the other row.
|
||||
const row = page.locator('.library-row').filter({ hasText: seeded });
|
||||
|
||||
// Through the overflow menu, not by clicking the name: the name's
|
||||
// own click bubbles to config-page's document handler, which closes
|
||||
// the editor it just opened.
|
||||
await expect(row).toBeVisible();
|
||||
await row.locator('.overflow-btn').click();
|
||||
await row.getByText('Rename', { exact: true }).click();
|
||||
|
||||
const input = row.locator('.edit-input');
|
||||
|
||||
await input.fill(DECOY);
|
||||
await input.press('Enter');
|
||||
|
||||
// The message is the assertion: a name it can act on, and none of
|
||||
// the Go error's wrapping.
|
||||
const notice = app.getByTestId('notification').first();
|
||||
|
||||
await expect(notice).toBeVisible();
|
||||
await expect(notice).toContainText(DECOY);
|
||||
await expect(notice).not.toContainText('could not rename library:');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
|
||||
import { test, expect } from '../support/fixtures.js';
|
||||
|
||||
/**
|
||||
* The app must work with no network.
|
||||
*
|
||||
* Every `<wa-icon>` used to resolve to ka-f.fontawesome.com at runtime
|
||||
* (audit H-4 / perf.M9), so a desktop music player offline, on a
|
||||
* captive portal or behind a firewall rendered no icons at all — while
|
||||
* playing files sitting on the local disk. `src/icons/` bundles them
|
||||
* and overrides Web Awesome's `default` library.
|
||||
*
|
||||
* The reproduction is the point. Asserting that a `<wa-icon>` *exists*
|
||||
* would have passed before the fix too: the element is always in the
|
||||
* DOM and only its contents came from the network. So this asserts on
|
||||
* the `<svg>` inside each icon's shadow root, with everything that is
|
||||
* not the app's own origin blocked — which is what a closed network
|
||||
* actually looks like to a local server.
|
||||
*/
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const INIT_SCRIPT = resolve(here, '../../.playwright/init-events.js');
|
||||
|
||||
/** Icons on the first screen. Fewer than this means nothing rendered. */
|
||||
const EXPECTED_MIN_ICONS = 5;
|
||||
|
||||
const VIEWS = [
|
||||
'home', 'tracks', 'albums', 'artists', 'genres', 'playlists',
|
||||
'explore', 'downloads', 'jobs', 'settings',
|
||||
];
|
||||
|
||||
type IconState = { name: string; hasSvg: boolean };
|
||||
|
||||
const collectIcons = (): IconState[] => {
|
||||
const icons: IconState[] = [];
|
||||
|
||||
const walk = (root: Document | ShadowRoot): void => {
|
||||
for (const el of Array.from(root.querySelectorAll('*'))) {
|
||||
if (el.tagName === 'WA-ICON') {
|
||||
icons.push({
|
||||
name: el.getAttribute('name') ?? '(unnamed)',
|
||||
hasSvg: !!el.shadowRoot?.querySelector('svg'),
|
||||
});
|
||||
}
|
||||
|
||||
if (el.shadowRoot) walk(el.shadowRoot);
|
||||
}
|
||||
};
|
||||
|
||||
walk(document);
|
||||
|
||||
return icons;
|
||||
};
|
||||
|
||||
test.describe('offline', () => {
|
||||
test('icons render with every external request blocked', async ({
|
||||
page,
|
||||
baseURL,
|
||||
}) => {
|
||||
const blocked: string[] = [];
|
||||
|
||||
// Deliberately not `context.setOffline(true)`: the app *is* a local
|
||||
// server, so taking the whole stack down would break the bindings
|
||||
// rather than the icons. A closed network is precisely "the app's
|
||||
// own origin still answers, nothing else does".
|
||||
await page.route('**/*', (route) => {
|
||||
const url = route.request().url();
|
||||
|
||||
if (url.startsWith(baseURL!) || url.startsWith('data:')) {
|
||||
return route.continue();
|
||||
}
|
||||
|
||||
blocked.push(url);
|
||||
|
||||
return route.abort();
|
||||
});
|
||||
|
||||
await page.addInitScript({ path: INIT_SCRIPT });
|
||||
await page.goto(baseURL!);
|
||||
await page.evaluate(() => window.__yjEvents.ready(20_000));
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const drawn = await page.evaluate(collectIcons);
|
||||
|
||||
expect(
|
||||
drawn.length,
|
||||
'no <wa-icon> on screen at all — the app did not render',
|
||||
).toBeGreaterThanOrEqual(EXPECTED_MIN_ICONS);
|
||||
|
||||
expect(
|
||||
drawn.filter((i) => !i.hasSvg).map((i) => i.name),
|
||||
'icons that did not draw with the network closed',
|
||||
).toEqual([]);
|
||||
|
||||
// The complementary half: nothing should have *wanted* the network.
|
||||
// An icon drawing from a warm module cache would satisfy the
|
||||
// assertion above on a machine that happens to be online.
|
||||
expect(
|
||||
blocked.filter((u) => u.includes('fontawesome')),
|
||||
'still reaching for the icon CDN',
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test('every icon the app asks for is bundled', async ({ app }) => {
|
||||
for (const view of VIEWS) {
|
||||
await app.evaluate((v) => document.dispatchEvent(
|
||||
new CustomEvent('navigate', { detail: { view: v } }),
|
||||
), view);
|
||||
await app.waitForTimeout(600);
|
||||
}
|
||||
|
||||
const drawn = await app.evaluate(collectIcons);
|
||||
|
||||
expect(drawn.length).toBeGreaterThan(EXPECTED_MIN_ICONS);
|
||||
|
||||
// Covers the icons that only exist on views past the first screen,
|
||||
// which the offline test above never reaches.
|
||||
expect(
|
||||
drawn.filter((i) => !i.hasSvg).map((i) => i.name),
|
||||
'icons that did not draw on some view',
|
||||
).toEqual([]);
|
||||
|
||||
// The resolver records what it could not find rather than failing
|
||||
// silently, because twenty call sites compute their icon name from
|
||||
// state and no static check can enumerate them.
|
||||
const misses = await app.evaluate(
|
||||
() => (window as unknown as { __yjIconMisses?: string[] })
|
||||
.__yjIconMisses ?? [],
|
||||
);
|
||||
|
||||
expect(
|
||||
misses,
|
||||
'add these to frontend/src/icons/names.txt, then run: ' +
|
||||
'node frontend/scripts/fetch-icons.mjs',
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
import { test, expect, resetEvents, callBinding } 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 (2–6 s) to finish by itself. */
|
||||
const FINISH_TIMEOUT = 60_000;
|
||||
|
||||
/** Instrument the library bindings so "was anything refetched" is a fact. */
|
||||
const COUNT_LIBRARY_CALLS = `(() => {
|
||||
const w = window;
|
||||
if (w.__yjCalls) { w.__yjCalls.length = 0; return; }
|
||||
w.__yjCalls = [];
|
||||
const lib = w.go.library.Library;
|
||||
for (const key of Object.keys(lib)) {
|
||||
const fn = lib[key];
|
||||
if (typeof fn !== 'function' || fn.__counted) continue;
|
||||
const wrapped = function (...args) {
|
||||
w.__yjCalls.push(key);
|
||||
return fn.apply(this, args);
|
||||
};
|
||||
wrapped.__counted = true;
|
||||
lib[key] = wrapped;
|
||||
}
|
||||
})()`;
|
||||
|
||||
/**
|
||||
* 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]);
|
||||
await app.evaluate(COUNT_LIBRARY_CALLS);
|
||||
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 app.evaluate(
|
||||
() => (window as unknown as { __yjCalls: string[] }).__yjCalls,
|
||||
);
|
||||
|
||||
expect(
|
||||
refetched.filter((c) => c.startsWith('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]);
|
||||
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', []);
|
||||
});
|
||||
});
|
||||
@@ -89,6 +89,14 @@ test.describe('queue', () => {
|
||||
await longRow(app).dblclick();
|
||||
await waitForEvent(app, 'QueueChanged');
|
||||
|
||||
// The panel has to be open to have rows. A closed one is `width: 0`
|
||||
// and now renders no list at all (perf.m7) — before that it kept a
|
||||
// virtualizer measuring its window on every queue change, and this
|
||||
// assertion passed against a panel nobody could see.
|
||||
const queueToggle = app.getByRole('button', { name: 'Toggle queue' });
|
||||
|
||||
await queueToggle.click();
|
||||
|
||||
await expect(app.getByTestId('queue-row')).toHaveCount(1);
|
||||
|
||||
const state = await callBinding<{ tracks: unknown[] }>(
|
||||
@@ -97,6 +105,19 @@ test.describe('queue', () => {
|
||||
);
|
||||
|
||||
expect(state.tracks).toHaveLength(1);
|
||||
|
||||
// Shut it again, and wait until it really is shut. These specs
|
||||
// share one backend process in file order, the panel's width is
|
||||
// animated, and the transport slides while it closes — a click
|
||||
// issued during that lands on whichever button has moved under the
|
||||
// pointer, which for the very next test was Repeat rather than
|
||||
// Shuffle. Both emit QueueModeChanged, so it failed on the
|
||||
// assertion rather than on the wait, one run in two.
|
||||
//
|
||||
// Waiting on the row count rather than a timeout is also the m7
|
||||
// assertion: a closed panel renders no list at all.
|
||||
await queueToggle.click();
|
||||
await expect(app.getByTestId('queue-row')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('shuffle and repeat toggles report their state', async ({ app }) => {
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
import { rename } from 'node:fs/promises';
|
||||
|
||||
import {
|
||||
test,
|
||||
expect,
|
||||
callBinding,
|
||||
resetEvents,
|
||||
waitForEvent,
|
||||
LONG_TRACK,
|
||||
} from '../support/fixtures.js';
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Phase 2 of plan 007: the player tells the truth.
|
||||
*
|
||||
* Both specs here are reproductions of measured findings, written
|
||||
* before the fix and failing against the code that shipped Phase 1:
|
||||
*
|
||||
* - `H-3`: the seek bar is a local `setInterval` counter that only
|
||||
* reconciles with the backend on a track change. Measured 3 s behind
|
||||
* during steady playback and **29 s** behind after four keyboard
|
||||
* seeks, because the seek shortcut moves the backend and never tells
|
||||
* the bar.
|
||||
* - `errors.C1`: a track whose file has moved is a silent no-op.
|
||||
* Auto-advance onto it reverts `currentIndex`, stopping the queue
|
||||
* dead with nothing emitted, so playback never reaches the track
|
||||
* after it.
|
||||
*/
|
||||
|
||||
const longRow = (app: Page) =>
|
||||
app.getByTestId('track-row').filter({ hasText: LONG_TRACK }).first();
|
||||
|
||||
/**
|
||||
* Read the displayed clock and the backend position in one round trip.
|
||||
*
|
||||
* Two sequential calls would measure a moving target: at 1 Hz, the
|
||||
* skew between reading the DOM and awaiting a binding is enough to
|
||||
* turn a correct player into a one-second failure.
|
||||
*/
|
||||
async function readClocks(
|
||||
app: Page,
|
||||
): Promise<{ ui: number; backend: number }> {
|
||||
return app.evaluate(async () => {
|
||||
const deep = (root: Document | ShadowRoot): Element | null => {
|
||||
const hit = root.querySelector('[data-testid="elapsed-time"]');
|
||||
|
||||
if (hit) return hit;
|
||||
|
||||
for (const el of root.querySelectorAll('*')) {
|
||||
if (el.shadowRoot) {
|
||||
const nested = deep(el.shadowRoot);
|
||||
|
||||
if (nested) return nested;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const text = deep(document)?.textContent?.trim() ?? '';
|
||||
const [mins, secs] = text.split(':').map(Number);
|
||||
|
||||
return {
|
||||
ui: Number.isFinite(mins) && Number.isFinite(secs)
|
||||
? mins * 60 + secs
|
||||
: Number.NaN,
|
||||
backend: (await window.__yjEvents.call(
|
||||
'player.Player.CurrentPositionSeconds',
|
||||
[],
|
||||
5000,
|
||||
)) as number,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Move focus off the track row.
|
||||
*
|
||||
* Phase 1 made rows a real grid with roving tabindex, and global
|
||||
* single-key bindings yield to a focused control that owns the key —
|
||||
* so with a row focused the arrows navigate the list rather than
|
||||
* seeking, which is correct and is not what this spec is about.
|
||||
*/
|
||||
async function blurDeepActive(app: Page): Promise<void> {
|
||||
await app.evaluate(() => {
|
||||
const deepActive = (root: Document | ShadowRoot): Element | null => {
|
||||
const active = root.activeElement;
|
||||
|
||||
return active?.shadowRoot ? deepActive(active.shadowRoot) : active;
|
||||
};
|
||||
|
||||
(deepActive(document) as HTMLElement | null)?.blur?.();
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('the player reports its real position', () => {
|
||||
test.beforeEach(async ({ app }) => {
|
||||
await callBinding(app, 'queue.Queue.Clear');
|
||||
await resetEvents(app);
|
||||
});
|
||||
|
||||
test('the elapsed clock tracks the backend during steady playback', async ({
|
||||
app,
|
||||
}) => {
|
||||
await longRow(app).dblclick();
|
||||
await waitForEvent(app, 'TrackChanged');
|
||||
|
||||
await expect
|
||||
.poll(async () => (await readClocks(app)).backend, {
|
||||
timeout: 15_000,
|
||||
})
|
||||
.toBeGreaterThan(4);
|
||||
|
||||
const { ui, backend } = await readClocks(app);
|
||||
|
||||
expect(Math.abs(ui - backend)).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
test('the elapsed clock survives four keyboard seeks', async ({ app }) => {
|
||||
await longRow(app).dblclick();
|
||||
await waitForEvent(app, 'TrackChanged');
|
||||
|
||||
await blurDeepActive(app);
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await app.keyboard.press('ArrowRight');
|
||||
}
|
||||
|
||||
// The seek is asynchronous through the backend; give the tick that
|
||||
// reports it a chance to arrive before comparing.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const { ui, backend } = await readClocks(app);
|
||||
|
||||
return Math.abs(ui - backend);
|
||||
})
|
||||
.toBeLessThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('a finished queue keeps its context', () => {
|
||||
test('the bar still shows what just played, at 0:00', async ({
|
||||
app,
|
||||
testctl,
|
||||
}) => {
|
||||
const { rows } = (await testctl.sql(
|
||||
"SELECT file_path FROM audio_files WHERE file_path LIKE '%Salt Air%' " +
|
||||
'LIMIT 1',
|
||||
)) as { rows: { file_path: string }[] };
|
||||
|
||||
expect(rows).toHaveLength(1);
|
||||
|
||||
await callBinding(app, 'queue.Queue.Clear');
|
||||
await resetEvents(app);
|
||||
await callBinding(app, 'queue.Queue.SetQueue', [
|
||||
[rows[0].file_path],
|
||||
0,
|
||||
false,
|
||||
]);
|
||||
await waitForEvent(app, 'QueueChanged');
|
||||
await callBinding(app, 'queue.Queue.Play');
|
||||
|
||||
await waitForEvent(app, 'PlaybackFinished', { timeoutMs: 30_000 });
|
||||
|
||||
// H-18: the bar used to blank completely while the queue panel
|
||||
// still listed the track that had just played.
|
||||
await expect(app.getByTestId('now-playing-title')).toContainText(
|
||||
'Salt Air',
|
||||
);
|
||||
await expect(app.getByTestId('elapsed-time')).toHaveText('00:00');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('a track that will not play says so', () => {
|
||||
test('auto-advance skips a missing file and reaches the next track', async ({
|
||||
app,
|
||||
testctl,
|
||||
}) => {
|
||||
const { rows } = (await testctl.sql(
|
||||
"SELECT file_path FROM audio_files WHERE file_path LIKE '%Glass Harbour%' " +
|
||||
'ORDER BY file_path LIMIT 3',
|
||||
)) as { rows: { file_path: string }[] };
|
||||
|
||||
expect(rows).toHaveLength(3);
|
||||
|
||||
const paths = rows.map((r) => r.file_path);
|
||||
const missing = paths[1];
|
||||
const hidden = `${missing}.e2e-hidden`;
|
||||
|
||||
test.setTimeout(90_000);
|
||||
|
||||
await rename(missing, hidden);
|
||||
|
||||
try {
|
||||
await callBinding(app, 'queue.Queue.Clear');
|
||||
await resetEvents(app);
|
||||
await callBinding(app, 'queue.Queue.SetQueue', [paths, 0, false]);
|
||||
await waitForEvent(app, 'QueueChanged');
|
||||
await callBinding(app, 'queue.Queue.Play');
|
||||
|
||||
// The first fixture is ~6 s long; the whole hop through the bad
|
||||
// file and onto the third track has to happen inside that plus
|
||||
// the third track's own length.
|
||||
const failed = await waitForEvent(app, 'PlaybackFailed', {
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
|
||||
expect(failed.data[0]).toMatchObject({ filePath: missing });
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(
|
||||
await callBinding<{ currentIndex: number }>(
|
||||
app,
|
||||
'queue.Queue.GetState',
|
||||
)
|
||||
).currentIndex,
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe(2);
|
||||
|
||||
// Not just skipped — said so. A failure the user cannot see is
|
||||
// the finding, not the fix.
|
||||
await expect(app.getByTestId('player-message')).toContainText(
|
||||
/could not (be )?play/i,
|
||||
);
|
||||
} finally {
|
||||
await rename(hidden, missing);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import {
|
||||
test,
|
||||
expect,
|
||||
eventNames,
|
||||
resetEvents,
|
||||
waitForEvent,
|
||||
} from '../support/fixtures.js';
|
||||
|
||||
/**
|
||||
* Primary views are cached, not unmounted, so that `scrollTop` survives
|
||||
* navigation (`frontend/index.ts`). The cost of that decision is that
|
||||
* `disconnectedCallback` never fires, so anything a view registered on
|
||||
* `document` keeps running from every other page.
|
||||
*
|
||||
* H-1 in `.planning/audits/2026-08-11-ui/hands-on.md` is the worst case:
|
||||
* `autotag-view`'s document keydown handler binds `s` to "skip this
|
||||
* album", so pressing `s` on Settings — where `s` is the global shuffle
|
||||
* shortcut — silently removed albums from the autotag queue. The same
|
||||
* handler binds `a` to Apply, which rewrites tags on disk.
|
||||
*
|
||||
* The invariant this asserts is the whole of Phase 1: a view that is not
|
||||
* on screen is not listening.
|
||||
*/
|
||||
test.describe('view lifecycle', () => {
|
||||
/** The autotag sidebar's "Pending (N)" header.
|
||||
*
|
||||
* Read with `textContent`, not Playwright's text matchers: once the
|
||||
* view is off-screen it is `.view-hidden`, so every visibility-aware
|
||||
* API reports it as empty — which would make this spec pass for the
|
||||
* wrong reason. */
|
||||
const pendingCount = (page: import('@playwright/test').Page) =>
|
||||
page.evaluate(() => {
|
||||
const view = document.querySelector('autotag-view');
|
||||
const header = view?.shadowRoot?.querySelector('.folders-header');
|
||||
|
||||
return header?.textContent?.trim() ?? '';
|
||||
});
|
||||
|
||||
test('a keypress on Settings does not reach the Autotag queue', async ({
|
||||
app,
|
||||
}) => {
|
||||
await app.getByTestId('nav-autotag').click();
|
||||
await expect(app.getByTestId('main-content')).toHaveAttribute(
|
||||
'data-active-view',
|
||||
'autotag',
|
||||
);
|
||||
await expect
|
||||
.poll(() => pendingCount(app))
|
||||
.toMatch(/^Pending \(\d+\)$/);
|
||||
|
||||
const before = await pendingCount(app);
|
||||
|
||||
await app.getByTestId('nav-settings').click();
|
||||
await expect(app.getByTestId('main-content')).toHaveAttribute(
|
||||
'data-active-view',
|
||||
'settings',
|
||||
);
|
||||
|
||||
await resetEvents(app);
|
||||
await app.keyboard.press('s');
|
||||
|
||||
// The global binding is shuffle, and it must be the *only* thing
|
||||
// that happened.
|
||||
await expect
|
||||
.poll(() => eventNames(app).then((n) => n.QueueModeChanged ?? 0))
|
||||
.toBe(1);
|
||||
|
||||
expect(await pendingCount(app)).toBe(before);
|
||||
});
|
||||
|
||||
test('on Autotag, the same key skips and does not also shuffle', async ({
|
||||
app,
|
||||
}) => {
|
||||
// The other half of the same bug (H-2): two document keydown handlers
|
||||
// with no arbitration meant `s` on this page skipped the album *and*
|
||||
// toggled shuffle. As a panel binding it can only mean one thing.
|
||||
await app.getByTestId('nav-autotag').click();
|
||||
await expect
|
||||
.poll(() => pendingCount(app))
|
||||
.toMatch(/^Pending \(\d+\)$/);
|
||||
|
||||
const before = Number(/\((\d+)\)/.exec(await pendingCount(app))![1]);
|
||||
|
||||
await resetEvents(app);
|
||||
await app.keyboard.press('s');
|
||||
|
||||
await expect.poll(() => pendingCount(app)).toBe(`Pending (${before - 1})`);
|
||||
expect((await eventNames(app)).QueueModeChanged ?? 0).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* H-5: tabbing through the app produced fourteen stops and not one of
|
||||
* them was navigation or content. These are the two that matter most —
|
||||
* getting *into* the app, and doing the app's primary action once there.
|
||||
*/
|
||||
test.describe('keyboard reach', () => {
|
||||
/** The deepest focused element, resolved through shadow roots the way
|
||||
* the shortcut service does — `document.activeElement` stops at the
|
||||
* host and would report every stop as the same element. */
|
||||
const focused = (page: import('@playwright/test').Page) =>
|
||||
page.evaluate(() => {
|
||||
let el: Element | null = document.activeElement;
|
||||
|
||||
while (el?.shadowRoot?.activeElement) el = el.shadowRoot.activeElement;
|
||||
|
||||
return {
|
||||
tag: el?.tagName ?? '',
|
||||
testid: (el as HTMLElement | null)?.dataset?.['testid'] ?? '',
|
||||
role: el?.getAttribute('role') ?? '',
|
||||
};
|
||||
});
|
||||
|
||||
test('tabs out of the header straight into the sidebar', async ({
|
||||
app,
|
||||
}) => {
|
||||
// Started from the search box rather than from the top of the page:
|
||||
// the header's leading controls come and go (the index-status button
|
||||
// is only there while the index builds), so counting stops from the
|
||||
// start makes the assertion about the header, not about the nav.
|
||||
await app.evaluate(() => {
|
||||
document
|
||||
.querySelector('search-bar')
|
||||
?.shadowRoot?.querySelector('input')
|
||||
?.focus();
|
||||
});
|
||||
|
||||
await app.keyboard.press('Tab');
|
||||
|
||||
expect(await focused(app)).toMatchObject({ testid: 'nav-home' });
|
||||
});
|
||||
|
||||
test('a track row can be reached and played without a mouse', async ({
|
||||
app,
|
||||
}) => {
|
||||
await app.getByTestId('nav-tracks').click();
|
||||
|
||||
// Tab until the list's single stop — the roving tabindex means there
|
||||
// is exactly one, however many thousand rows there are.
|
||||
for (let i = 0; i < 25; i += 1) {
|
||||
await app.keyboard.press('Tab');
|
||||
|
||||
if ((await focused(app)).role === 'row') break;
|
||||
}
|
||||
|
||||
expect(await focused(app)).toMatchObject({ role: 'row' });
|
||||
|
||||
await app.keyboard.press('ArrowDown');
|
||||
await resetEvents(app);
|
||||
await app.keyboard.press('Enter');
|
||||
|
||||
await waitForEvent(app, 'TrackChanged');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user