Files
yellowjacket/e2e/specs/view-lifecycle.spec.ts
T
logan b7dc368d7c
Build & publish Arch package / arch-package (push) Successful in 2m0s
CI / check (push) Canceled after 14s
CI / e2e (push) Canceled after 0s
Search index maintenance / maintain-index (push) Canceled after 0s
feat(ui): give every primary view the same page header
Four views had a heading and four did not, two had a sort control and
none showed a count, so the app changed shape as you moved through it
and "how many albums have I got" could only be answered by counting.
The reason they disagreed is that each had written its own arrangement:
the sort toolbar existed three times, in track-list, cover-grid and
playlist-view, as the same twenty lines with different bugs.

<page-header> is that arrangement once - title, count, sort, actions -
and nine views adopt it. Artists and Genres gain the sort control they
never had; Artists sorts by name only, because library.Artist carries
nothing countable, so the header renders a label and a direction button
rather than a select with one option in it. The header keeps its place
while a view loads: a heading that appears only once the data does is
the shifting layout this is meant to stop. The count is omitted, not
zero, until the view has an answer.

The header search box keeps its slot on every view instead of vanishing
on the ones it cannot serve - which is what moved the library filter
and the job indicator on every navigation. It is view-scoped by
decision and now says so: "Search albums" in the placeholder, the scope
named in the header ("Showing artists matching 'tide'"), and disabled
with a reason where there is nothing to search or the page has a search
of its own.

Also fixes an e2e trap this uncovered: the view-lifecycle spec toggled
shuffle and never toggled it back, so a second run against the same app
failed playback.spec's shuffle assertion - a failure that reads exactly
like a regression in whatever you are holding.
2026-08-12 02:35:35 -04:00

164 lines
5.6 KiB
TypeScript

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);
// Toggle it back. Shuffle is backend state that outlives the page,
// so leaving it on fails `playback.spec`'s shuffle assertion on the
// *next* run against the same app — the specs share one process,
// and this one was quietly spending state it never returned.
await app.keyboard.press('s');
await expect
.poll(() => eventNames(app).then((n) => n.QueueModeChanged ?? 0))
.toBe(2);
});
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');
});
});