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
187 lines
6.5 KiB
TypeScript
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', exact: true });
|
|
|
|
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);
|
|
});
|
|
});
|