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:
2026-08-12 01:20:03 -04:00
parent 5830b1ba17
commit 1ac919d228
6 changed files with 778 additions and 0 deletions
+66
View File
@@ -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:');
});
});
+139
View File
@@ -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([]);
});
});
+166
View File
@@ -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 (26 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', []);
});
});
+21
View File
@@ -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 }) => {
+232
View File
@@ -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);
}
});
});
+154
View File
@@ -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');
});
});