Four pages that list tracks — Tracks, genre, artist, and both playlist views — had no way to start the whole list, or had a broken one. One shared helper (utils/play-all.ts) now owns what "shuffle this collection" means: SetQueue's shuffleStart only picks a random first track when shuffle mode is already on, it does not turn it on, so the mode is toggled before the queue is set. Each host passes an honest queue Source (#14): anything that builds a queue names what it built it from, so "Playing from" stops lying. Two behaviour changes ride along, both flagged: smart-playlist-details' Shuffle was a live no-op (shuffleStart without enabling mode played track 1 in order) and is fixed; playlist-details' Play all drops its shuffleStart:true, so with shuffle mode already on it now starts at the first row instead of a random one — the album page's existing semantics. Verified: make ui-test (1147, incl. a case that fails when the smart-playlist fix is reverted), npx tsc --noEmit, make e2e (255, incl. new play-all and header-fit specs), make lint, make test, make bindings-check, make css-check; artist header read from screenshots at 424/320/900 (the pair wraps below the name on a phone). Closes #31
374 lines
13 KiB
TypeScript
374 lines
13 KiB
TypeScript
import { test, expect } from '../support/fixtures.js';
|
||
|
||
/**
|
||
* #69: the Playlists header's buttons could not be reached.
|
||
*
|
||
* Three text buttons — Import (91px), New Playlist (122px), New Smart
|
||
* Playlist (162px), 390px in total — inside a header that gets 700px at
|
||
* 900×600. "New Smart Playlist" rendered **114 of its 162px**, and at
|
||
* phone width the Android report was the plain version of it: you
|
||
* cannot scroll to reach them, and scrolling is not how page controls
|
||
* should be exposed anyway.
|
||
*
|
||
* **`layout-overflow.spec.ts` passes on the broken build**, which is why
|
||
* this file exists rather than a case being added there. That spec
|
||
* asserts the *shell* needs no sideways scrolling; clipping *inside* a
|
||
* component is invisible to it. So the measurement here is per-button
|
||
* and per-header, against the widths the app promises.
|
||
*
|
||
* Plan 018's size matrix is the promise being kept: **no action is ever
|
||
* unreachable at any supported size.** These are its three bands.
|
||
*/
|
||
|
||
const VIEWPORTS = [
|
||
// Desktop's worst case, and not the enforced minimum: the sidebar
|
||
// collapses to icons *below* 900, so the content area is 843px at 899
|
||
// and 700px at 900. Testing "the minimum" and stopping misses it.
|
||
{ name: '900×600 (widest sidebar, narrowest content)', width: 900, height: 600 },
|
||
{ name: '800×600 (the enforced minimum)', width: 800, height: 600 },
|
||
{ name: '390×780 (phone)', width: 390, height: 780 },
|
||
// WCAG 1.4.10's reflow target, which plan 018 promises the app fits.
|
||
{ name: '320×600 (400% zoom)', width: 320, height: 600 },
|
||
];
|
||
|
||
/** Every action the Playlists header can offer, in declared order. */
|
||
const ACTIONS = ['Import', 'New Playlist', 'New Smart Playlist'];
|
||
|
||
/**
|
||
* What the header is actually rendering, measured rather than inferred.
|
||
*
|
||
* A shadow query is the wrong tool for *asserting* — that is what
|
||
* `getByRole` below is for — but it is the right one for a measurement,
|
||
* because the number this issue is about (a button 48px wider than the
|
||
* box holding it) is not in the accessibility tree at all.
|
||
*/
|
||
const headerFit = (page: import('@playwright/test').Page, view = 'playlist-view') =>
|
||
page.evaluate((tag) => {
|
||
const root = document
|
||
.querySelector(`[data-testid="main-content"] ${tag}`)
|
||
?.shadowRoot?.querySelector('page-header')?.shadowRoot;
|
||
|
||
if (!root) return null;
|
||
|
||
const header = root.querySelector<HTMLElement>('.page-header')!;
|
||
const box = header.getBoundingClientRect();
|
||
const title = root.querySelector<HTMLElement>('h1')!;
|
||
|
||
const clipped = [
|
||
...root.querySelectorAll<HTMLElement>('.action, .more-button'),
|
||
]
|
||
.filter((b) => !b.hidden)
|
||
.filter((b) => {
|
||
const r = b.getBoundingClientRect();
|
||
|
||
return r.right > box.right + 1 || r.left < box.left - 1;
|
||
})
|
||
.map((b) => b.dataset['actionId'] ?? 'more');
|
||
|
||
return {
|
||
overflow: header.scrollWidth - header.clientWidth,
|
||
clipped,
|
||
titleTruncated: title.scrollWidth > title.clientWidth + 1,
|
||
buttons: [...root.querySelectorAll<HTMLElement>('.action')]
|
||
.filter((b) => !b.hidden)
|
||
.map((b) => b.textContent?.trim() ?? ''),
|
||
menu: [
|
||
...root.querySelectorAll('#page-header-overflow wa-dropdown-item'),
|
||
].map((i) => i.textContent?.trim() ?? ''),
|
||
};
|
||
}, view);
|
||
|
||
test.describe('the page header never clips an action', () => {
|
||
test.beforeEach(async ({ app }) => {
|
||
await app.getByTestId('nav-playlists').click();
|
||
await expect(app.getByTestId('main-content')).toHaveAttribute(
|
||
'data-active-view',
|
||
'playlists',
|
||
);
|
||
});
|
||
|
||
test.afterEach(async ({ app }) => {
|
||
await app.setViewportSize({ width: 1280, height: 800 });
|
||
});
|
||
|
||
for (const vp of VIEWPORTS) {
|
||
test(`every action is reachable at ${vp.name}`, async ({ app }) => {
|
||
await app.setViewportSize({ width: vp.width, height: vp.height });
|
||
|
||
// Polled: the fit is decided by a ResizeObserver, so it settles a
|
||
// frame after the resize rather than with it.
|
||
await expect
|
||
.poll(async () => (await headerFit(app))?.clipped)
|
||
.toEqual([]);
|
||
|
||
const fit = (await headerFit(app))!;
|
||
|
||
expect(fit.overflow).toBeLessThanOrEqual(0);
|
||
|
||
// Between them, buttons and menu account for all three. This is
|
||
// the assertion the issue asks for: not "it fits" but "nothing
|
||
// was dropped to make it fit".
|
||
expect([...fit.buttons, ...fit.menu].sort()).toEqual([...ACTIONS].sort());
|
||
});
|
||
}
|
||
|
||
/**
|
||
* The title gives way before an action does.
|
||
*
|
||
* Once the heading can ellipsis it absorbs the pressure, and
|
||
* `scrollWidth` then reports a header that fits perfectly while the
|
||
* heading reads "Playlis…" — this issue's own failure mode moved from
|
||
* the button to the title, and invisible to exactly the measurement
|
||
* that missed it the first time. At the desktop sizes there is always
|
||
* an action to collapse instead.
|
||
*/
|
||
test('does not truncate the heading to keep a button', async ({ app }) => {
|
||
for (const vp of VIEWPORTS.slice(0, 2)) {
|
||
await app.setViewportSize({ width: vp.width, height: vp.height });
|
||
|
||
await expect
|
||
.poll(async () => (await headerFit(app))?.titleTruncated)
|
||
.toBe(false);
|
||
}
|
||
});
|
||
|
||
/**
|
||
* Asserted through the accessibility tree, never a shadow query. An
|
||
* overflow menu is exactly the shape that grows a nameless control,
|
||
* and this repo has shipped one four times — most recently the
|
||
* queue's own close button.
|
||
*/
|
||
test('the overflow is a named control that opens a named menu', async ({
|
||
app,
|
||
}) => {
|
||
await app.setViewportSize({ width: 900, height: 600 });
|
||
|
||
const more = app.getByRole('button', { name: 'More actions' });
|
||
|
||
await expect(more).toBeVisible();
|
||
await expect(more).toHaveAttribute('aria-expanded', 'false');
|
||
|
||
await more.click();
|
||
|
||
await expect(more).toHaveAttribute('aria-expanded', 'true');
|
||
|
||
const menu = app.getByRole('menu', { name: 'More actions' });
|
||
|
||
await expect(menu).toBeVisible();
|
||
|
||
// Collapsed at 900×600: Import (lowest priority) and New Smart
|
||
// Playlist. New Playlist stays a button because it is the drop
|
||
// target, and a closed menu cannot be one.
|
||
await expect(
|
||
menu.getByRole('menuitem', { name: 'Import' }),
|
||
).toBeVisible();
|
||
await expect(
|
||
app.getByRole('button', { name: 'New Playlist', exact: true }),
|
||
).toBeVisible();
|
||
});
|
||
|
||
/**
|
||
* The phone case is the original report. Every action is in the menu
|
||
* at 390px, and the menu is reachable by name — which is the whole of
|
||
* "these need to be reachable in a sensible way".
|
||
*/
|
||
test('offers every action from the menu on a phone', async ({ app }) => {
|
||
await app.setViewportSize({ width: 390, height: 780 });
|
||
|
||
const more = app.getByRole('button', { name: 'More actions' });
|
||
|
||
await expect(more).toBeVisible();
|
||
await more.click();
|
||
|
||
const menu = app.getByRole('menu', { name: 'More actions' });
|
||
|
||
for (const label of ACTIONS) {
|
||
await expect(menu.getByRole('menuitem', { name: label })).toBeVisible();
|
||
}
|
||
});
|
||
|
||
/**
|
||
* Escape closes it and focus goes back to the trigger — `MenuKeyboard`
|
||
* is shared with every other menu in the app precisely so this is not
|
||
* a second keyboard model, and this is what proves it was wired up
|
||
* rather than merely imported.
|
||
*/
|
||
test('takes the keyboard, and gives it back', async ({ app }) => {
|
||
await app.setViewportSize({ width: 900, height: 600 });
|
||
|
||
const more = app.getByRole('button', { name: 'More actions' });
|
||
|
||
await more.click();
|
||
|
||
const menu = app.getByRole('menu', { name: 'More actions' });
|
||
|
||
await expect(menu).toBeVisible();
|
||
|
||
// The first item takes focus on open. `wa-dropdown-item` sets its
|
||
// own role in its own first update, so this is polled rather than
|
||
// read: a query at the host's updateComplete finds nothing, which
|
||
// reads exactly like a menu that refused to take focus.
|
||
await expect
|
||
.poll(async () =>
|
||
app.evaluate(() => {
|
||
// Stops where `MenuKeyboard`'s own `deepActiveElement` stops:
|
||
// on the *host* whose shadow root has no active element.
|
||
// Descending unconditionally lands inside the focused
|
||
// `wa-dropdown-item`'s own shadow root, where nothing is
|
||
// focused — which reads exactly like a menu that refused the
|
||
// keyboard, on a build where it did not.
|
||
let el = document.activeElement;
|
||
|
||
while (el?.shadowRoot?.activeElement) el = el.shadowRoot.activeElement;
|
||
|
||
return el?.textContent?.trim() ?? null;
|
||
}),
|
||
)
|
||
.toBe('Import');
|
||
|
||
await app.keyboard.press('Escape');
|
||
|
||
await expect(more).toHaveAttribute('aria-expanded', 'false');
|
||
await expect(more).toBeFocused();
|
||
});
|
||
|
||
/**
|
||
* New Playlist is a drop target, and declaring it as data must not
|
||
* take that away — which is why a `PageAction` carries the drop
|
||
* handlers rather than the header owning a notion of dropping.
|
||
*
|
||
* Nothing covered this before, in either tier, and it is the one
|
||
* behaviour the migration could plausibly have destroyed silently:
|
||
* dragging still *looks* fine against a button that no longer
|
||
* accepts anything.
|
||
*/
|
||
test('New Playlist still accepts a dropped track', async ({ app }) => {
|
||
await app.setViewportSize({ width: 1280, height: 800 });
|
||
|
||
const button = app.getByRole('button', {
|
||
name: 'New Playlist',
|
||
exact: true,
|
||
});
|
||
|
||
await expect(button).toBeVisible();
|
||
|
||
const result = await app.evaluate(async () => {
|
||
const view = document.querySelector(
|
||
'[data-testid="main-content"] playlist-view',
|
||
)!;
|
||
const target = view.shadowRoot!
|
||
.querySelector('page-header')!
|
||
.shadowRoot!.querySelector('[data-testid="page-action-new-playlist"]')!;
|
||
|
||
const data = new DataTransfer();
|
||
|
||
data.setData(
|
||
'application/x-yj-tracks',
|
||
JSON.stringify({ filePaths: ['/tmp/dropped.mp3'] }),
|
||
);
|
||
|
||
const fire = (type: string) =>
|
||
target.dispatchEvent(
|
||
new DragEvent(type, {
|
||
bubbles: true,
|
||
cancelable: true,
|
||
dataTransfer: data,
|
||
}),
|
||
);
|
||
|
||
fire('dragover');
|
||
await new Promise((r) => setTimeout(r, 50));
|
||
|
||
// The affordance is the host's state reaching the header's
|
||
// button, which is the half a plain handler call would not prove.
|
||
const highlighted = target.classList.contains('drag-over');
|
||
|
||
fire('drop');
|
||
await new Promise((r) => setTimeout(r, 200));
|
||
|
||
return {
|
||
highlighted,
|
||
opened: view.shadowRoot!.querySelector('.create-form') !== null,
|
||
};
|
||
});
|
||
|
||
expect(result).toEqual({ highlighted: true, opened: true });
|
||
|
||
// Leave the view as it was found.
|
||
await app.keyboard.press('Escape');
|
||
});
|
||
|
||
/**
|
||
* An action given back when the window widens again. The collapsed
|
||
* set is a function of the current width and not of how it got there
|
||
* — a rule that only ever *added* to it would never widen.
|
||
*/
|
||
test('gives the buttons back when the window grows', async ({ app }) => {
|
||
await app.setViewportSize({ width: 390, height: 780 });
|
||
|
||
await expect.poll(async () => (await headerFit(app))?.buttons).toEqual([]);
|
||
|
||
await app.setViewportSize({ width: 1440, height: 900 });
|
||
|
||
await expect
|
||
.poll(async () => (await headerFit(app))?.buttons)
|
||
.toEqual(ACTIONS);
|
||
await expect.poll(async () => (await headerFit(app))?.menu).toEqual([]);
|
||
});
|
||
});
|
||
|
||
/**
|
||
* The Tracks header carries the play-all/shuffle-all pair (#31), so
|
||
* the promise above has to hold for it too — the same per-button
|
||
* measurement, one view over. Its two actions are the whole of the
|
||
* header's declared set, and the pair is what plays the list the row
|
||
* is in, so a button rendered 20px of its 90px is a queue of nothing.
|
||
*/
|
||
const TRACK_ACTIONS = ['Play all', 'Shuffle all'];
|
||
|
||
test.describe('the Tracks header never clips an action', () => {
|
||
test.beforeEach(async ({ app }) => {
|
||
await app.getByTestId('nav-tracks').click();
|
||
await expect(app.getByTestId('main-content')).toHaveAttribute(
|
||
'data-active-view',
|
||
'tracks',
|
||
);
|
||
});
|
||
|
||
test.afterEach(async ({ app }) => {
|
||
await app.setViewportSize({ width: 1280, height: 800 });
|
||
});
|
||
|
||
for (const vp of VIEWPORTS) {
|
||
test(`every action is reachable at ${vp.name}`, async ({ app }) => {
|
||
await app.setViewportSize({ width: vp.width, height: vp.height });
|
||
|
||
await expect
|
||
.poll(async () => (await headerFit(app, 'track-list'))?.clipped)
|
||
.toEqual([]);
|
||
|
||
const fit = (await headerFit(app, 'track-list'))!;
|
||
|
||
expect(fit.overflow).toBeLessThanOrEqual(0);
|
||
|
||
// Between them, buttons and menu account for both actions —
|
||
// not "it fits" but "nothing was dropped to make it fit".
|
||
expect([...fit.buttons, ...fit.menu].sort()).toEqual(
|
||
[...TRACK_ACTIONS].sort(),
|
||
);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* The pair's names, through the accessibility tree — a shadow query
|
||
* measures, but it cannot say what a screen reader is offered.
|
||
*/
|
||
test('both actions are named controls', async ({ app }) => {
|
||
for (const label of TRACK_ACTIONS) {
|
||
await expect(
|
||
app.getByRole('button', { name: label, exact: true }),
|
||
).toBeVisible();
|
||
}
|
||
});
|
||
});
|