Files
yellowjacket/e2e/specs/playback.spec.ts
logan 18aba34c08
Build & publish Arch package / arch-package (push) Successful in 2m31s
CI / check (push) Successful in 2m46s
Search index maintenance / maintain-index (push) Failing after 5s
CI / e2e (push) Successful in 6m30s
test(e2e): a track plays the list it is in, not a queue of one
e7748f1 made double-click and single-row Play queue the list as
displayed with startIndex on that row; this frozen spec still asserted
a queue of one, and was the only failure in both the chromium and
webkit runs on main.

It asserts the new contract instead: more than one track queued,
currentIndex on the row that was activated, and the panel showing that
queue rather than some other one.
2026-08-16 14:51:07 -04:00

187 lines
6.5 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 queues the list it is in', 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();
// Activating one row plays the list that row is in, from that row —
// the library as displayed, not a queue of one. The row's own
// position is what `currentIndex` points at.
const state = await callBinding<{
tracks: { title: string }[];
currentIndex: number;
}>(app, 'queue.Queue.GetState');
expect(state.tracks.length).toBeGreaterThan(1);
expect(state.tracks[state.currentIndex]?.title).toContain(LONG_TRACK);
// The panel shows that queue rather than some other one: every
// fixture track fits on screen, so the counts are comparable.
await expect(app.getByTestId('queue-row')).toHaveCount(
state.tracks.length,
);
// 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);
});
});