import { test, expect, callBinding, waitForEvent, NO_QUEUE_SOURCE, } from '../support/fixtures.js'; import type { Page } from '@playwright/test'; /** * `a11y.15` — WCAG 2.2.2. The bottom bar's title and artist scroll * continuously while a track plays, re-armed in a loop by * `transitionend`, with no pause mechanism and no reduced-motion guard. * * The component test for this fakes `window.matchMedia`, which is a * stub of the thing being tested. This spec sets the real context * option, so the real media query answers. * * Both directions are here on purpose. A guard that suppressed * everything would pass the reduce case for free, and so would a bar * whose text simply does not overflow at this viewport — which is what * the component test failed on first. */ /** The fixture track whose title is long enough to overflow the bar. */ const LONG_TITLE = 'An Exhaustively Overlong Track Title'; /** * Read the title line's classes from inside `now-playing`'s shadow root. * * `will-scroll` is the class that carries both the transition and the * `padding-right` the scroll distance is measured against, so its * absence is the whole fix: suppressing only the animation leaves the * text translated off its own box with nothing to bring it back. */ async function titleClasses(app: Page): Promise { return app.evaluate(() => { const np = document.querySelector('now-playing'); const title = np?.shadowRoot?.querySelector('.track-title'); return title?.className ?? ''; }); } async function playTheLongOne(app: Page): Promise { await app.getByTestId('nav-tracks').click(); // The scroll mode defaults to `hover`, and a fresh context has no // persisted setting — so without this the positive case never // scrolls and reports the same thing a broken build would. Set it // rather than hovering, because `always` is also the mode the // finding is about: continuous motion for as long as the track // plays, with nothing the user has to do to provoke it. await app.evaluate(() => { localStorage.setItem('yj-now-playing-scroll-mode', 'always'); window.dispatchEvent(new CustomEvent('yj-scroll-mode-changed')); }); const paths: string[] = await app.evaluate(async (needle) => { // One argument, and 0 means every library: the scoped and // unscoped list queries collapsed into one when the schema did // (plan 013 R3), so `GetTracks()` no longer exists to call. const tracks = await window.__yjEvents.call( 'library.Library.GetTracks', [0], 10_000, ); return (tracks as { TrackName: string; FilePath: string }[]) .filter((t) => t.TrackName.startsWith(needle)) .map((t) => t.FilePath); }, LONG_TITLE); expect(paths.length).toBeGreaterThan(0); await callBinding(app, 'queue.Queue.SetQueue', [paths, 0, false, NO_QUEUE_SOURCE]); await waitForEvent(app, 'TrackChanged'); // The scroll cycle is armed 1500 ms after the geometry is measured, // and the geometry is measured after the render that puts the title // on screen. Reading before that reports "not scrolling" on a build // that scrolls — the same shape as every probe read too early in // plan 007. await expect .poll(() => titleClasses(app), { timeout: 10_000 }) .toContain('track-title'); } test.describe('the marquee under prefers-reduced-motion', () => { test.use({ contextOptions: { reducedMotion: 'reduce' } }); test('does not scroll the now-playing text at all', async ({ app }) => { await playTheLongOne(app); // Give the cycle longer than the 1500 ms arming delay to prove it // never arms, rather than catching it before it would have. await app.waitForTimeout(2500); expect(await titleClasses(app)).not.toContain('will-scroll'); }); }); test.describe('the marquee without a motion preference', () => { test.use({ contextOptions: { reducedMotion: 'no-preference' } }); test('still scrolls an overflowing title', async ({ app }) => { await playTheLongOne(app); await expect .poll(() => titleClasses(app), { timeout: 10_000 }) .toContain('will-scroll'); }); });