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,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