From 1ac919d228abf1cd5d241093c646b5c7b4d99c92 Mon Sep 17 00:00:00 2001 From: Logan Date: Wed, 12 Aug 2026 01:20:03 -0400 Subject: [PATCH] test(e2e): freeze the four reproductions this work started from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 *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. --- e2e/specs/failure-voice.spec.ts | 66 +++++++++ e2e/specs/offline-icons.spec.ts | 139 ++++++++++++++++++ e2e/specs/play-count.spec.ts | 166 ++++++++++++++++++++++ e2e/specs/playback.spec.ts | 21 +++ e2e/specs/player-truth.spec.ts | 232 +++++++++++++++++++++++++++++++ e2e/specs/view-lifecycle.spec.ts | 154 ++++++++++++++++++++ 6 files changed, 778 insertions(+) create mode 100644 e2e/specs/failure-voice.spec.ts create mode 100644 e2e/specs/offline-icons.spec.ts create mode 100644 e2e/specs/play-count.spec.ts create mode 100644 e2e/specs/player-truth.spec.ts create mode 100644 e2e/specs/view-lifecycle.spec.ts diff --git a/e2e/specs/failure-voice.spec.ts b/e2e/specs/failure-voice.spec.ts new file mode 100644 index 0000000..70be358 --- /dev/null +++ b/e2e/specs/failure-voice.spec.ts @@ -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:'); + }); +}); diff --git a/e2e/specs/offline-icons.spec.ts b/e2e/specs/offline-icons.spec.ts new file mode 100644 index 0000000..cf882ff --- /dev/null +++ b/e2e/specs/offline-icons.spec.ts @@ -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 `` 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 `` *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 `` 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 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([]); + }); +}); diff --git a/e2e/specs/play-count.spec.ts b/e2e/specs/play-count.spec.ts new file mode 100644 index 0000000..1ddd51e --- /dev/null +++ b/e2e/specs/play-count.spec.ts @@ -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>)[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', []); + }); +}); diff --git a/e2e/specs/playback.spec.ts b/e2e/specs/playback.spec.ts index fd03e11..613f906 100644 --- a/e2e/specs/playback.spec.ts +++ b/e2e/specs/playback.spec.ts @@ -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 }) => { diff --git a/e2e/specs/player-truth.spec.ts b/e2e/specs/player-truth.spec.ts new file mode 100644 index 0000000..3220612 --- /dev/null +++ b/e2e/specs/player-truth.spec.ts @@ -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 { + 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); + } + }); +}); diff --git a/e2e/specs/view-lifecycle.spec.ts b/e2e/specs/view-lifecycle.spec.ts new file mode 100644 index 0000000..5777b15 --- /dev/null +++ b/e2e/specs/view-lifecycle.spec.ts @@ -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'); + }); +});