Files
yellowjacket/e2e/specs/playback.spec.ts
T
logan 254646da5e
Build & publish Arch package / arch-package (push) Successful in 2m2s
CI / check (push) Successful in 2m18s
Search index maintenance / maintain-index (push) Successful in 7s
CI / e2e (push) Canceled after 3m51s
fix(a11y): mark the playing row with a shape, not only a colour
a11y.22, WCAG 1.4.1: `.track-row.active` was a background tint and a
text colour, and the row markup carried no aria-current either — so a
colour-blind user could not find the playing row and AT had no signal
at all. The queue panel had aria-current from Phase 1 and the same
colour-only visual.

A triangle drawn in each row's own left padding by `::before`. It is a
shape that is present or absent, and it costs no layout: track-list's
grid columns are computed from the host width, so a marker in the flow
would move every cell on the playing row and nothing else.

Both directions are asserted in both tiers. A marker that renders on
every row satisfies "the playing row has one" for free, which is this
plan's oldest rule.

And one thing the reproduction found: a track started from the *list*
leaves the queue's currentIndex at -1, so the panel has no current row
in that flow at all. Pre-existing, and the reason this looked broken
the first time it was checked in the running app.
2026-08-13 02:14:55 -04:00

179 lines
6.0 KiB
TypeScript

import {
test,
expect,
callBinding,
resetEvents,
waitForEvent,
LONG_TRACK,
} from '../support/fixtures.js';
/** The one fixture long enough to still be playing on the next line. */
const longRow = (app: import('@playwright/test').Page) =>
app.getByTestId('track-row').filter({ hasText: LONG_TRACK }).first();
/**
* Playback and the queue, driven through the UI and asserted on the
* events the backend actually emits.
*
* Audio really is initialised here: under `dbus-run-session` + Xvfb the
* PulseAudio socket in /run/user is untouched, so InitSpeaker succeeds
* and these tracks genuinely play. A CI container without /run/user
* needs a null sink; everything except the audio itself still works
* without one.
*/
test.describe('playback', () => {
test.beforeEach(async ({ app }) => {
// The app lands on Home now (H-8), so the track list is a
// navigation away rather than the first thing on screen.
await app.getByTestId('nav-tracks').click();
await callBinding(app, 'queue.Queue.Clear').catch(() => {
/* older builds may not expose Clear; the specs below do not need it */
});
await resetEvents(app);
});
test('double-clicking a track plays it', async ({ app }) => {
await longRow(app).dblclick();
const changed = await waitForEvent(app, 'TrackChanged');
expect(changed.data[0]).toBeTruthy();
// The transport flips to Pause, which is the only place the UI
// states "we are playing" in a way a user can see. `exact` is not
// optional: "Add queue to playlist" also matches /play/i.
await expect(
app.getByRole('button', { name: 'Pause', exact: true }),
).toBeVisible();
await expect(app.getByTestId('now-playing-title')).toContainText(
LONG_TRACK,
);
});
test('the elapsed time advances', async ({ app }) => {
await longRow(app).dblclick();
await waitForEvent(app, 'TrackChanged');
// Not a fixed sleep on a fixed value: assert the observable
// outcome, which is that the clock is no longer at zero.
await expect(app.getByTestId('elapsed-time')).not.toHaveText('--:--');
await expect(app.getByTestId('elapsed-time')).not.toHaveText('00:00', {
timeout: 15_000,
});
});
test('pause and play round-trip through the backend', async ({ app }) => {
await longRow(app).dblclick();
await waitForEvent(app, 'TrackChanged');
await resetEvents(app);
await app.getByRole('button', { name: 'Pause', exact: true }).click();
await waitForEvent(app, 'PlaybackStateChanged');
await expect(
app.getByRole('button', { name: 'Play', exact: true }),
).toBeVisible();
});
test('volume changes are pushed back from Go', async ({ app }) => {
await resetEvents(app);
await callBinding(app, 'player.Player.SetVolume', [55]);
const ev = await waitForEvent(app, 'VolumeChanged');
expect(ev.data).toEqual([55]);
});
});
test.describe('queue', () => {
test.beforeEach(async ({ app }) => {
// The app lands on Home now (H-8).
await app.getByTestId('nav-tracks').click();
});
test('playing a track populates the queue panel', async ({ app }) => {
await resetEvents(app);
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[] }>(
app,
'queue.Queue.GetState',
);
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 }) => {
const shuffle = app.getByRole('button', { name: 'Shuffle' });
await resetEvents(app);
await shuffle.click();
await waitForEvent(app, 'QueueModeChanged');
await expect(shuffle).toHaveAttribute('aria-pressed', 'true');
await shuffle.click();
await expect(shuffle).toHaveAttribute('aria-pressed', 'false');
});
});
test.describe('the playing row is findable without colour vision', () => {
test('the queue marks its current row with a shape and aria-current', async ({
app,
}) => {
await app.getByTestId('nav-tracks').click();
await app.getByTestId('track-row').first().dblclick();
const queueToggle = app.locator('#queue-button');
await queueToggle.click();
const row = app.getByTestId('queue-row').first();
await expect(row).toBeVisible();
// Played *from the queue*, because a track started from the list
// leaves `currentIndex` at -1 — so the panel has no current row at
// all in that flow, which is what made this marker look broken the
// first time it was checked.
await row.dblclick();
await expect(row).toHaveAttribute('aria-current', 'true');
const marker = await row.evaluate(
(el) => getComputedStyle(el, '::before').borderLeftWidth,
);
// `a11y.22`: a background tint and a text colour were the only two
// signals, and both are hue (WCAG 1.4.1).
expect(parseFloat(marker)).toBeGreaterThan(0);
await queueToggle.click();
await expect(app.getByTestId('queue-row')).toHaveCount(0);
});
});