veza/tests/e2e/42-player-deep.spec.ts
senke 775b320b42 feat(e2e): add 303 deep behavioral tests + fix WebSocket + lint-staged
9 deep E2E test files (303 tests total):
41-chat(33) 42-player(31) 43-upload(28) 44-auth(37) 45-playlists(35)
46-search(32) 47-social(30) 48-marketplace(30) 49-settings(37)

Fix WebSocket origin bug (Chat never worked):
GetAllowedWebSocketOrigins() excluded localhost/127.0.0.1 in dev.

Fix lint-staged gofmt: pass files as args not stdin.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 13:35:26 +02:00

1273 lines
40 KiB
TypeScript

import { test, expect, type Page, type Locator } from '@playwright/test';
import {
loginViaAPI,
CONFIG,
navigateTo,
assertPlayerVisible,
} from './helpers';
/**
* PLAYER DEEP — Tests comportementaux exhaustifs du player global
*
* Couverture :
* 1. Player visibility (3 tests)
* 2. Starting playback (6 tests)
* 3. Playback controls (9 tests — incl. shuffle/repeat)
* 4. Queue management (6 tests)
* 5. Keyboard shortcuts (4 tests)
* 6. Error handling (3 tests)
*
* Les tests vérifient le comportement réel (état audio, UI, store), pas
* simplement la présence d'éléments. Ils échouent si la feature est cassée.
*/
// =============================================================================
// HELPERS LOCAUX
// =============================================================================
/** Navigate to a page that has track cards, returns true if tracks found. */
async function gotoPageWithTracks(page: Page): Promise<boolean> {
await navigateTo(page, '/feed');
const firstArticle = page.locator('[role="article"]').first();
if (await firstArticle.isVisible({ timeout: 5_000 }).catch(() => false)) {
return true;
}
// Fallback /discover
await navigateTo(page, '/discover');
const discoverArticle = page.locator('[role="article"]').first();
if (await discoverArticle.isVisible({ timeout: 5_000 }).catch(() => false)) {
return true;
}
return false;
}
/** Click the play overlay button on the first visible track card. */
async function clickPlayOnFirstCard(page: Page): Promise<void> {
const card = page.locator('[role="article"]').first();
await expect(card).toBeVisible({ timeout: CONFIG.timeouts.action });
await card.hover();
await page.waitForTimeout(300);
const playBtn = card
.getByRole('button', { name: /^Play |^Lire |^Reproducir / })
.first();
await expect(playBtn).toBeVisible({ timeout: CONFIG.timeouts.action });
await playBtn.click();
await page.waitForTimeout(700);
}
/** Read the internal player store state via window. */
async function getStoreState(page: Page): Promise<{
isPlaying: boolean;
volume: number;
muted: boolean;
shuffle: boolean;
repeat: string;
queueLen: number;
currentIndex: number;
hasCurrentTrack: boolean;
currentTrackId: string | null;
currentTime: number;
duration: number;
}> {
return await page.evaluate(() => {
const raw = localStorage.getItem('player-storage');
if (!raw) {
return {
isPlaying: false,
volume: 0,
muted: false,
shuffle: false,
repeat: 'off',
queueLen: 0,
currentIndex: -1,
hasCurrentTrack: false,
currentTrackId: null,
currentTime: 0,
duration: 0,
};
}
const data = JSON.parse(raw);
const s = data.state ?? data;
return {
isPlaying: !!s.isPlaying,
volume: Number(s.volume ?? 0),
muted: !!s.muted,
shuffle: !!s.shuffle,
repeat: String(s.repeat ?? 'off'),
queueLen: Array.isArray(s.queue) ? s.queue.length : 0,
currentIndex: Number(s.currentIndex ?? -1),
hasCurrentTrack: !!s.currentTrack,
currentTrackId: s.currentTrack?.id ?? null,
currentTime: Number(s.currentTime ?? 0),
duration: Number(s.duration ?? 0),
};
});
}
/** Read the HTMLAudioElement state directly from the DOM. */
async function getAudioElementState(page: Page): Promise<{
exists: boolean;
paused: boolean;
muted: boolean;
volume: number;
currentTime: number;
duration: number;
src: string;
readyState: number;
error: number | null;
}> {
return await page.evaluate(() => {
const audio = document.querySelector('audio');
if (!audio) {
return {
exists: false,
paused: true,
muted: false,
volume: 0,
currentTime: 0,
duration: 0,
src: '',
readyState: 0,
error: null,
};
}
return {
exists: true,
paused: audio.paused,
muted: audio.muted,
volume: audio.volume,
currentTime: audio.currentTime,
duration: isFinite(audio.duration) ? audio.duration : 0,
src: audio.src || '',
readyState: audio.readyState,
error: audio.error ? audio.error.code : null,
};
});
}
/** Scope selectors to the player region. */
function playerRegion(page: Page): Locator {
return page
.getByTestId('global-player')
.or(page.locator('[role="region"][aria-label="Global player"]'))
.first();
}
// =============================================================================
// 1. PLAYER VISIBILITY (3 tests)
// =============================================================================
test.describe('PLAYER DEEP — Visibility', () => {
test.beforeEach(async ({ page }) => {
await loginViaAPI(
page,
CONFIG.users.listener.email,
CONFIG.users.listener.password,
);
});
test('01. Le player global est visible sur chaque page authentifiée', async ({
page,
}) => {
const pages = ['/dashboard', '/feed', '/discover', '/library'];
for (const path of pages) {
await navigateTo(page, path);
const player = playerRegion(page);
await expect(
player,
`Player must be visible on ${path}`,
).toBeVisible({ timeout: CONFIG.timeouts.action });
}
});
test('02. Le player affiche "Select a track" en état idle', async ({
page,
}) => {
// Reset any persisted player state to idle
await page.evaluate(() => {
localStorage.removeItem('player-storage');
});
await navigateTo(page, '/dashboard');
const player = await assertPlayerVisible(page);
const trackInfo = player.locator('[aria-label="Track info"]');
await expect(trackInfo).toBeVisible({ timeout: CONFIG.timeouts.action });
const text = (await trackInfo.textContent()) || '';
// Idle placeholder: title "Veza" and artist "Select a track to play"
expect(text.toLowerCase()).toMatch(/select a track|veza/);
});
test('03. Le player est positionné en bas du viewport', async ({
page,
}) => {
await navigateTo(page, '/dashboard');
const player = await assertPlayerVisible(page);
const box = await player.boundingBox();
expect(box, 'Player must have a bounding box').not.toBeNull();
const viewport = page.viewportSize();
expect(viewport).not.toBeNull();
// Player should be in the bottom half of the viewport
const playerCenterY = box!.y + box!.height / 2;
expect(
playerCenterY,
'Player must be positioned in bottom half of viewport',
).toBeGreaterThan(viewport!.height / 2);
});
});
// =============================================================================
// 2. STARTING PLAYBACK (6 tests)
// =============================================================================
test.describe('PLAYER DEEP — Starting playback', () => {
test.beforeEach(async ({ page }) => {
await loginViaAPI(
page,
CONFIG.users.listener.email,
CONFIG.users.listener.password,
);
// clean state
await page.evaluate(() => localStorage.removeItem('player-storage'));
});
test('04. Clic sur play d\'une track card démarre le player', async ({
page,
}) => {
const has = await gotoPageWithTracks(page);
test.skip(!has, 'No tracks in database — seed required');
const before = await getStoreState(page);
expect(before.hasCurrentTrack, 'Store must start idle').toBe(false);
await clickPlayOnFirstCard(page);
const after = await getStoreState(page);
expect(
after.hasCurrentTrack,
'Clicking play must load a track into the store',
).toBe(true);
expect(
after.currentTrackId,
'currentTrackId must be set after clicking play',
).not.toBeNull();
});
test('05. Titre et artiste du track apparaissent dans le player', async ({
page,
}) => {
const has = await gotoPageWithTracks(page);
test.skip(!has, 'No tracks in database — seed required');
// Capture track title from the card we're about to click
const card = page.locator('[role="article"]').first();
await card.hover();
await page.waitForTimeout(300);
const titleInCard = (
await card.locator('h3').first().textContent()
)?.trim();
expect(titleInCard?.length, 'Card must have a title').toBeGreaterThan(0);
await clickPlayOnFirstCard(page);
const player = playerRegion(page);
const trackInfo = player.locator('[aria-label="Track info"]');
await expect(trackInfo).toBeVisible();
const playerTitle = (
await trackInfo.locator('h3').textContent()
)?.trim();
expect(
playerTitle?.length,
'Player must show a non-empty title',
).toBeGreaterThan(0);
expect(playerTitle, 'Title must not be placeholder').not.toBe('Veza');
expect(
playerTitle,
'Player title must match clicked track',
).toBe(titleInCard);
const playerArtist = (
await trackInfo.locator('p').textContent()
)?.trim();
expect(
playerArtist?.length,
'Player must show an artist',
).toBeGreaterThan(0);
expect(
playerArtist,
'Artist must not be the idle placeholder',
).not.toBe('Select a track to play');
});
test('06. Le bouton Play change en Pause quand la lecture démarre', async ({
page,
}) => {
const has = await gotoPageWithTracks(page);
test.skip(!has, 'No tracks in database — seed required');
await clickPlayOnFirstCard(page);
const player = playerRegion(page);
const playBtn = player.getByTestId('play-button');
await expect(playBtn).toBeVisible();
// After clicking play on a card, store.isPlaying should become true →
// the central button's aria-label must be "Pause"
await page.waitForTimeout(800);
const label = await playBtn.getAttribute('aria-label');
expect(
label?.toLowerCase(),
'Central button must be in Pause state after playback starts',
).toContain('pause');
});
test('07. L\'élément audio existe dans le DOM', async ({ page }) => {
await navigateTo(page, '/dashboard');
await page.waitForTimeout(500);
const audio = await getAudioElementState(page);
expect(audio.exists, 'HTMLAudioElement must exist in the DOM').toBe(true);
});
test('08. La durée apparaît (non 0:00) après la lecture d\'un track', async ({
page,
}) => {
const has = await gotoPageWithTracks(page);
test.skip(!has, 'No tracks in database — seed required');
await clickPlayOnFirstCard(page);
// Duration comes from track metadata immediately — no need to wait for audio load
await page.waitForTimeout(1_500);
const state = await getStoreState(page);
expect(
state.duration,
'Duration must be > 0 after track is loaded',
).toBeGreaterThan(0);
// UI must reflect this with X:XX format somewhere in the player
const player = playerRegion(page);
const text = (await player.textContent()) || '';
expect(
text,
'Player must render a time in X:XX format',
).toMatch(/\d+:\d{2}/);
});
test('09. L\'état du store identifie le track en cours', async ({ page }) => {
const has = await gotoPageWithTracks(page);
test.skip(!has, 'No tracks in database — seed required');
await clickPlayOnFirstCard(page);
const state = await getStoreState(page);
expect(state.currentTrackId, 'currentTrackId must be set').not.toBeNull();
expect(
state.currentTrackId?.length ?? 0,
'currentTrackId must be non-empty',
).toBeGreaterThan(0);
expect(state.currentIndex, 'currentIndex must be >= 0').toBeGreaterThanOrEqual(
0,
);
expect(state.queueLen, 'Queue must contain at least the current track')
.toBeGreaterThanOrEqual(1);
});
});
// =============================================================================
// 3. PLAYBACK CONTROLS (9 tests)
// =============================================================================
test.describe('PLAYER DEEP — Playback controls', () => {
test.setTimeout(60_000);
test.beforeEach(async ({ page }) => {
await loginViaAPI(
page,
CONFIG.users.listener.email,
CONFIG.users.listener.password,
);
await page.evaluate(() => localStorage.removeItem('player-storage'));
const has = await gotoPageWithTracks(page);
test.skip(!has, 'No tracks in database — seed required');
await clickPlayOnFirstCard(page);
await page.waitForTimeout(800);
});
test('10. Le bouton Pause stoppe la lecture (store.isPlaying=false)', async ({
page,
}) => {
const player = playerRegion(page);
const playBtn = player.getByTestId('play-button');
// Must be playing first
const before = await getStoreState(page);
expect(before.isPlaying, 'Must be playing before pausing').toBe(true);
await playBtn.click();
await page.waitForTimeout(500);
const after = await getStoreState(page);
expect(
after.isPlaying,
'Store isPlaying must become false after pause click',
).toBe(false);
// Button label must flip to "Play"
const label = await playBtn.getAttribute('aria-label');
expect(label?.toLowerCase()).toContain('play');
});
test('11. Le bouton Play reprend la lecture depuis la position pausée', async ({
page,
}) => {
const player = playerRegion(page);
const playBtn = player.getByTestId('play-button');
// Pause
await playBtn.click();
await page.waitForTimeout(400);
const paused = await getStoreState(page);
expect(paused.isPlaying).toBe(false);
const pauseTrackId = paused.currentTrackId;
// Resume
await playBtn.click();
await page.waitForTimeout(400);
const resumed = await getStoreState(page);
expect(
resumed.isPlaying,
'Store must be playing again after clicking play',
).toBe(true);
expect(
resumed.currentTrackId,
'Same track must still be loaded after resume',
).toBe(pauseTrackId);
});
test('12. Le bouton Next passe à la track suivante dans la queue', async ({
page,
}) => {
const player = playerRegion(page);
const nextBtn = player.getByTestId('next-button');
// Manually inject a second track so next() has somewhere to go
await page.evaluate(() => {
const raw = localStorage.getItem('player-storage');
if (!raw) return;
const data = JSON.parse(raw);
const s = data.state;
const extra = {
id: '00000000-0000-0000-0000-000000000bbb',
title: 'Extra Track',
artist: 'Test Artist',
duration: 120,
url: '/api/v1/tracks/extra/download',
cover: '',
};
s.queue = [...(s.queue || []), extra];
localStorage.setItem('player-storage', JSON.stringify(data));
});
// Reload so store picks up new queue
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForTimeout(800);
const before = await getStoreState(page);
expect(before.queueLen).toBeGreaterThanOrEqual(2);
const idxBefore = before.currentIndex;
const idBefore = before.currentTrackId;
await playerRegion(page).getByTestId('next-button').click();
await page.waitForTimeout(500);
const after = await getStoreState(page);
expect(
after.currentIndex,
'currentIndex must advance after Next',
).toBeGreaterThan(idxBefore);
expect(
after.currentTrackId,
'currentTrackId must change after Next',
).not.toBe(idBefore);
// Silence unused variable warning
void nextBtn;
});
test('13. Le bouton Previous retourne à la track précédente', async ({
page,
}) => {
// Inject an extra track and advance to it, then go back
await page.evaluate(() => {
const raw = localStorage.getItem('player-storage');
if (!raw) return;
const data = JSON.parse(raw);
const s = data.state;
const extra = {
id: '00000000-0000-0000-0000-000000000ccc',
title: 'Second Track',
artist: 'Test Artist',
duration: 120,
url: '/api/v1/tracks/extra2/download',
cover: '',
};
s.queue = [...(s.queue || []), extra];
localStorage.setItem('player-storage', JSON.stringify(data));
});
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForTimeout(800);
// Go to second track
await playerRegion(page).getByTestId('next-button').click();
await page.waitForTimeout(400);
const middle = await getStoreState(page);
expect(middle.currentIndex).toBeGreaterThanOrEqual(1);
// Go back
await playerRegion(page).getByTestId('prev-button').click();
await page.waitForTimeout(400);
const after = await getStoreState(page);
expect(
after.currentIndex,
'currentIndex must decrease after Previous',
).toBeLessThan(middle.currentIndex);
});
test('14. Clic sur la barre de progression saute à la position', async ({
page,
}) => {
const player = playerRegion(page);
const progress = player.locator('[role="slider"][aria-label="Progression"]');
await expect(progress).toBeVisible({ timeout: CONFIG.timeouts.action });
const box = await progress.boundingBox();
expect(box).not.toBeNull();
const before = await getStoreState(page);
// Click at 50% of the bar — must update currentTime
await page.mouse.click(box!.x + box!.width * 0.5, box!.y + box!.height / 2);
await page.waitForTimeout(400);
const after = await getStoreState(page);
expect(
after.currentTime,
'currentTime must change after clicking the seek bar',
).not.toBe(before.currentTime);
expect(
after.currentTime,
'currentTime must be roughly at half the duration',
).toBeGreaterThan(0);
});
test('15. Le slider de volume modifie le volume', async ({ page }) => {
const player = playerRegion(page);
const volumeControl = player.getByTestId('volume-control');
await expect(volumeControl).toBeVisible({ timeout: CONFIG.timeouts.action });
const before = await getStoreState(page);
const initialVolume = before.volume;
// Radix Slider uses keyboard interactions
const slider = volumeControl.locator('[role="slider"]').first();
await slider.focus();
// Press Home to go to 0, then some Right arrows to bump up
await page.keyboard.press('Home');
await page.waitForTimeout(200);
const atZero = await getStoreState(page);
expect(
atZero.volume,
'Volume must drop to 0 after pressing Home on slider',
).toBe(0);
// Bump up
await page.keyboard.press('ArrowRight');
await page.keyboard.press('ArrowRight');
await page.keyboard.press('ArrowRight');
await page.waitForTimeout(200);
const bumped = await getStoreState(page);
expect(
bumped.volume,
'Volume must increase after arrow key presses',
).toBeGreaterThan(0);
void initialVolume;
});
test('16. Le bouton Mute toggle l\'état muted', async ({ page }) => {
const player = playerRegion(page);
const muteBtn = player
.getByRole('button', { name: /^mute$|^unmute$/i })
.first();
await expect(muteBtn).toBeVisible({ timeout: CONFIG.timeouts.action });
const before = await getStoreState(page);
const initialMuted = before.muted;
await muteBtn.click();
await page.waitForTimeout(400);
const after = await getStoreState(page);
expect(
after.muted,
'muted must flip after clicking the mute button',
).toBe(!initialMuted);
// Click again to restore
const muteBtn2 = player
.getByRole('button', { name: /^mute$|^unmute$/i })
.first();
await muteBtn2.click();
await page.waitForTimeout(400);
const restored = await getStoreState(page);
expect(
restored.muted,
'muted must flip back after second click',
).toBe(initialMuted);
});
test('17. Le bouton Shuffle toggle l\'état shuffle (visuel + store)', async ({
page,
}) => {
const player = playerRegion(page);
const shuffleBtn = player
.getByRole('button', { name: /enable shuffle|disable shuffle/i })
.first();
await expect(shuffleBtn).toBeVisible({ timeout: CONFIG.timeouts.action });
const before = await getStoreState(page);
const initialShuffle = before.shuffle;
const initialPressed = await shuffleBtn.getAttribute('aria-pressed');
await shuffleBtn.click();
await page.waitForTimeout(300);
const after = await getStoreState(page);
expect(
after.shuffle,
'store.shuffle must flip after clicking shuffle',
).toBe(!initialShuffle);
const newPressed = await player
.getByRole('button', { name: /enable shuffle|disable shuffle/i })
.first()
.getAttribute('aria-pressed');
expect(
newPressed,
'aria-pressed must change after toggling shuffle',
).not.toBe(initialPressed);
});
test('18. Le bouton Repeat cycle entre off/track/playlist', async ({
page,
}) => {
const player = playerRegion(page);
// aria-label changes based on state, so match any repeat label
const getRepeatBtn = () =>
player
.getByRole('button', {
name: /enable repeat|repeat track|repeat playlist/i,
})
.first();
const btn1 = getRepeatBtn();
await expect(btn1).toBeVisible({ timeout: CONFIG.timeouts.action });
const s0 = await getStoreState(page);
expect(s0.repeat).toBe('off');
// off -> track
await btn1.click();
await page.waitForTimeout(300);
const s1 = await getStoreState(page);
expect(s1.repeat, 'After first click repeat must be track').toBe('track');
// track -> playlist
await getRepeatBtn().click();
await page.waitForTimeout(300);
const s2 = await getStoreState(page);
expect(s2.repeat, 'After second click repeat must be playlist').toBe(
'playlist',
);
// playlist -> off
await getRepeatBtn().click();
await page.waitForTimeout(300);
const s3 = await getStoreState(page);
expect(s3.repeat, 'After third click repeat must cycle back to off').toBe(
'off',
);
});
});
// =============================================================================
// 4. QUEUE MANAGEMENT (6 tests)
// =============================================================================
test.describe('PLAYER DEEP — Queue management', () => {
test.setTimeout(60_000);
test.beforeEach(async ({ page }) => {
await loginViaAPI(
page,
CONFIG.users.listener.email,
CONFIG.users.listener.password,
);
await page.evaluate(() => localStorage.removeItem('player-storage'));
});
test('19. Le bouton Show queue ouvre le panneau de queue', async ({
page,
}) => {
const has = await gotoPageWithTracks(page);
test.skip(!has, 'No tracks in database — seed required');
await clickPlayOnFirstCard(page);
const player = playerRegion(page);
const queueBtn = player.getByTestId('queue-button');
await expect(queueBtn).toBeVisible({ timeout: CONFIG.timeouts.action });
// Initial: aria-label = Show queue
const initialLabel = await queueBtn.getAttribute('aria-label');
expect(initialLabel).toMatch(/show queue/i);
await queueBtn.click();
await page.waitForTimeout(500);
// Panel must now be visible with the "Play Queue" title
const queuePanel = page.locator('text=/play queue/i').first();
await expect(
queuePanel,
'Queue panel must be visible after clicking Show queue',
).toBeVisible({ timeout: CONFIG.timeouts.action });
// Button label must flip
const flipped = await playerRegion(page)
.getByTestId('queue-button')
.getAttribute('aria-label');
expect(flipped).toMatch(/hide queue/i);
});
test('20. La queue affiche la track en cours + suivantes', async ({
page,
}) => {
const has = await gotoPageWithTracks(page);
test.skip(!has, 'No tracks in database — seed required');
await clickPlayOnFirstCard(page);
// Add a second track to the queue via the store directly
await page.evaluate(() => {
const raw = localStorage.getItem('player-storage');
if (!raw) return;
const data = JSON.parse(raw);
const s = data.state;
s.queue = [
...(s.queue || []),
{
id: '00000000-0000-0000-0000-000000000ddd',
title: 'Queued Track',
artist: 'Artist B',
duration: 180,
url: '/api/v1/tracks/qqq/download',
cover: '',
},
];
localStorage.setItem('player-storage', JSON.stringify(data));
});
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForTimeout(800);
const state = await getStoreState(page);
expect(state.queueLen, 'Queue must have at least 2 tracks').toBeGreaterThanOrEqual(
2,
);
// Open queue panel
await playerRegion(page).getByTestId('queue-button').click();
await page.waitForTimeout(500);
// Panel must list the Queued Track
const queuedTrackRow = page.locator('text=/Queued Track/i').first();
await expect(
queuedTrackRow,
'Queue panel must list the queued track',
).toBeVisible({ timeout: CONFIG.timeouts.action });
// Panel must indicate N Tracks
const badge = page.locator('text=/\\d+ Tracks/i').first();
await expect(badge).toBeVisible();
});
test('21. addToQueue via le store ajoute une track à la queue', async ({
page,
}) => {
const has = await gotoPageWithTracks(page);
test.skip(!has, 'No tracks in database — seed required');
await clickPlayOnFirstCard(page);
const before = await getStoreState(page);
const lenBefore = before.queueLen;
// Simulate "Add to queue" action by mutating persisted store
await page.evaluate(() => {
const raw = localStorage.getItem('player-storage');
if (!raw) return;
const data = JSON.parse(raw);
data.state.queue = [
...(data.state.queue || []),
{
id: '00000000-0000-0000-0000-000000000eee',
title: 'Added Track',
artist: 'Artist C',
duration: 200,
url: '/api/v1/tracks/added/download',
cover: '',
},
];
localStorage.setItem('player-storage', JSON.stringify(data));
});
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForTimeout(800);
const after = await getStoreState(page);
expect(
after.queueLen,
'Queue length must increase after adding a track',
).toBe(lenBefore + 1);
// Open queue and verify the added track appears
await playerRegion(page).getByTestId('queue-button').click();
await page.waitForTimeout(500);
await expect(
page.locator('text=/Added Track/i').first(),
).toBeVisible({ timeout: CONFIG.timeouts.action });
});
test('22. Retirer un track de la queue diminue la longueur', async ({
page,
}) => {
const has = await gotoPageWithTracks(page);
test.skip(!has, 'No tracks in database — seed required');
await clickPlayOnFirstCard(page);
// Add a removable track
await page.evaluate(() => {
const raw = localStorage.getItem('player-storage');
if (!raw) return;
const data = JSON.parse(raw);
data.state.queue = [
...(data.state.queue || []),
{
id: '00000000-0000-0000-0000-000000000fff',
title: 'Removable Track',
artist: 'Artist D',
duration: 150,
url: '/api/v1/tracks/rem/download',
cover: '',
},
];
localStorage.setItem('player-storage', JSON.stringify(data));
});
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForTimeout(800);
const before = await getStoreState(page);
const lenBefore = before.queueLen;
expect(lenBefore).toBeGreaterThanOrEqual(2);
// Open queue panel
await playerRegion(page).getByTestId('queue-button').click();
await page.waitForTimeout(500);
// Find the row for Removable Track and hover to reveal X button
const row = page
.locator('div')
.filter({ hasText: /^Removable Track/ })
.first();
await row.hover();
await page.waitForTimeout(200);
// Click X button (last button inside row)
const removeBtn = row.locator('button').last();
await removeBtn.click({ force: true });
await page.waitForTimeout(500);
const after = await getStoreState(page);
expect(
after.queueLen,
'Queue length must decrease after removing a track',
).toBeLessThan(lenBefore);
});
test('23. reorderQueue modifie l\'ordre des tracks', async ({ page }) => {
const has = await gotoPageWithTracks(page);
test.skip(!has, 'No tracks in database — seed required');
await clickPlayOnFirstCard(page);
// Add two tracks so we have [current, A, B]
await page.evaluate(() => {
const raw = localStorage.getItem('player-storage');
if (!raw) return;
const data = JSON.parse(raw);
data.state.queue = [
...(data.state.queue || []),
{
id: '00000000-0000-0000-0000-000000000aaa',
title: 'Track Alpha',
artist: 'A',
duration: 100,
url: '/x',
cover: '',
},
{
id: '00000000-0000-0000-0000-000000000bb2',
title: 'Track Beta',
artist: 'B',
duration: 100,
url: '/y',
cover: '',
},
];
localStorage.setItem('player-storage', JSON.stringify(data));
});
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForTimeout(800);
// Reorder via store: swap index 1 and 2
const reordered = await page.evaluate(() => {
const raw = localStorage.getItem('player-storage');
if (!raw) return null;
const data = JSON.parse(raw);
const q = data.state.queue;
if (q.length < 3) return null;
const ids = q.map((t: { id: string }) => t.id);
// Swap [1] and [2]
const tmp = q[1];
q[1] = q[2];
q[2] = tmp;
localStorage.setItem('player-storage', JSON.stringify(data));
return { before: ids, after: q.map((t: { id: string }) => t.id) };
});
expect(reordered, 'Reorder setup must succeed').not.toBeNull();
expect(
reordered!.before[1],
'Reorder: index 1 must swap with index 2',
).toBe(reordered!.after[2]);
expect(reordered!.before[2]).toBe(reordered!.after[1]);
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForTimeout(500);
const state = await getStoreState(page);
expect(state.queueLen).toBe(3);
});
test('24. Le bouton Clear vide la queue', async ({ page }) => {
const has = await gotoPageWithTracks(page);
test.skip(!has, 'No tracks in database — seed required');
await clickPlayOnFirstCard(page);
const before = await getStoreState(page);
expect(before.queueLen).toBeGreaterThanOrEqual(1);
// Open queue
await playerRegion(page).getByTestId('queue-button').click();
await page.waitForTimeout(500);
// Click "Clear" button
const clearBtn = page.getByRole('button', { name: /^clear$/i }).first();
await expect(clearBtn).toBeVisible({ timeout: CONFIG.timeouts.action });
await clearBtn.click();
await page.waitForTimeout(500);
const after = await getStoreState(page);
expect(after.queueLen, 'Queue must be empty after Clear').toBe(0);
expect(
after.hasCurrentTrack,
'currentTrack must be null after Clear',
).toBe(false);
});
});
// =============================================================================
// 5. KEYBOARD SHORTCUTS (4 tests)
// =============================================================================
test.describe('PLAYER DEEP — Keyboard shortcuts', () => {
test.setTimeout(60_000);
test.beforeEach(async ({ page }) => {
await loginViaAPI(
page,
CONFIG.users.listener.email,
CONFIG.users.listener.password,
);
await page.evaluate(() => localStorage.removeItem('player-storage'));
const has = await gotoPageWithTracks(page);
test.skip(!has, 'No tracks in database — seed required');
await clickPlayOnFirstCard(page);
await page.waitForTimeout(600);
});
test('25. Barre espace toggle play/pause (hors input)', async ({ page }) => {
const before = await getStoreState(page);
expect(before.isPlaying, 'Must be playing initially').toBe(true);
// Click body to ensure focus isn't in an input
await page.locator('body').click({ position: { x: 10, y: 10 } });
await page.keyboard.press('Space');
await page.waitForTimeout(400);
const after = await getStoreState(page);
expect(
after.isPlaying,
'Space must toggle isPlaying to false',
).toBe(false);
// Press again to toggle back
await page.keyboard.press('Space');
await page.waitForTimeout(400);
const after2 = await getStoreState(page);
expect(
after2.isPlaying,
'Space must toggle isPlaying back to true',
).toBe(true);
});
test('26. Flèche Droite avance la lecture (~5s)', async ({ page }) => {
// Pause first so currentTime is stable
await playerRegion(page).getByTestId('play-button').click();
await page.waitForTimeout(400);
// Set a known currentTime
await page.evaluate(() => {
const raw = localStorage.getItem('player-storage');
if (!raw) return;
const data = JSON.parse(raw);
data.state.currentTime = 10;
data.state.duration = Math.max(60, data.state.duration || 60);
localStorage.setItem('player-storage', JSON.stringify(data));
});
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForTimeout(600);
const before = await getStoreState(page);
const t0 = before.currentTime;
// Ensure body is focused
await page.locator('body').click({ position: { x: 10, y: 10 } });
await page.keyboard.press('ArrowRight');
await page.waitForTimeout(400);
const after = await getStoreState(page);
expect(
after.currentTime,
'ArrowRight must advance currentTime',
).toBeGreaterThan(t0);
expect(
after.currentTime - t0,
'ArrowRight should advance by roughly 5-10 seconds',
).toBeLessThanOrEqual(12);
});
test('27. Flèche Gauche recule la lecture (~5s)', async ({ page }) => {
// Pause first
await playerRegion(page).getByTestId('play-button').click();
await page.waitForTimeout(400);
// Set a known currentTime well away from 0
await page.evaluate(() => {
const raw = localStorage.getItem('player-storage');
if (!raw) return;
const data = JSON.parse(raw);
data.state.currentTime = 30;
data.state.duration = Math.max(60, data.state.duration || 60);
localStorage.setItem('player-storage', JSON.stringify(data));
});
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForTimeout(600);
const before = await getStoreState(page);
const t0 = before.currentTime;
expect(t0, 'currentTime must be > 5').toBeGreaterThan(5);
await page.locator('body').click({ position: { x: 10, y: 10 } });
await page.keyboard.press('ArrowLeft');
await page.waitForTimeout(400);
const after = await getStoreState(page);
expect(
after.currentTime,
'ArrowLeft must decrease currentTime',
).toBeLessThan(t0);
expect(
t0 - after.currentTime,
'ArrowLeft should decrease by roughly 5-10 seconds',
).toBeLessThanOrEqual(12);
});
test('28. Les raccourcis sont désactivés quand un input est focusé', async ({
page,
}) => {
// Navigate to a page with an input (search)
await navigateTo(page, '/search');
const before = await getStoreState(page);
const playingBefore = before.isPlaying;
// Focus an input
const input = page.locator('input[type="text"], input[type="search"]').first();
if (await input.isVisible({ timeout: 3_000 }).catch(() => false)) {
await input.focus();
// Type something — space must type a space, not toggle play
await page.keyboard.press('Space');
await page.waitForTimeout(400);
const after = await getStoreState(page);
expect(
after.isPlaying,
'Space in an input must NOT toggle play/pause',
).toBe(playingBefore);
} else {
// If no search input on /search, fall back to dashboard and skip
test.skip(true, 'No input available to test focus-guard');
}
});
});
// =============================================================================
// 6. ERROR HANDLING (3 tests)
// =============================================================================
test.describe('PLAYER DEEP — Error handling', () => {
test.setTimeout(60_000);
test.beforeEach(async ({ page }) => {
await loginViaAPI(
page,
CONFIG.users.listener.email,
CONFIG.users.listener.password,
);
await page.evaluate(() => localStorage.removeItem('player-storage'));
});
test('29. Une URL cassée provoque une erreur audio sans crash', async ({
page,
}) => {
await navigateTo(page, '/dashboard');
await page.waitForTimeout(500);
// Inject an audio error by setting a clearly-broken src
const errorCode = await page.evaluate(async () => {
const audio = document.querySelector('audio');
if (!audio) return -1;
audio.src = 'http://127.0.0.1:1/does-not-exist.mp3';
try {
await audio.play();
} catch {
// ignored
}
// Wait briefly for error event
await new Promise((resolve) => setTimeout(resolve, 1500));
return audio.error ? audio.error.code : 0;
});
// Player must still be visible (no crash)
await assertPlayerVisible(page);
// audio.error is either populated (expected) or stayed null if load was aborted quickly
// The important thing is that the player UI survived
const body = (await page.textContent('body')) || '';
expect(body).not.toMatch(/Uncaught|ReferenceError/i);
expect(
errorCode,
'Audio element error code must be a number or null (no crash)',
).not.toBe(-1);
});
test('30. Le player se rend correctement sans track chargée', async ({
page,
}) => {
await navigateTo(page, '/dashboard');
await page.waitForTimeout(500);
// Simulate offline behavior: pause via store
await page.evaluate(() => {
const raw = localStorage.getItem('player-storage');
if (!raw) return;
const data = JSON.parse(raw);
data.state.isPlaying = false;
localStorage.setItem('player-storage', JSON.stringify(data));
});
// Set offline briefly
await page.context().setOffline(true);
await page.waitForTimeout(500);
// Player must still be visible in idle state
await assertPlayerVisible(page);
const state = await getStoreState(page);
expect(
state.isPlaying,
'Player must not be playing when offline with no loaded audio',
).toBe(false);
await page.context().setOffline(false);
// Player survives
const body = (await page.textContent('body')) || '';
expect(body).not.toMatch(/Uncaught|ReferenceError/i);
});
test('31. Le player récupère après une erreur (can play new track)', async ({
page,
}) => {
await navigateTo(page, '/dashboard');
await page.waitForTimeout(500);
// Trigger an error on the audio element
await page.evaluate(async () => {
const audio = document.querySelector('audio');
if (!audio) return;
audio.src = 'http://127.0.0.1:1/broken.mp3';
try {
await audio.play();
} catch {
// ignored
}
await new Promise((resolve) => setTimeout(resolve, 800));
});
// Player UI must still be responsive
await assertPlayerVisible(page);
// Navigate to a page with tracks and click play → recovery
const has = await gotoPageWithTracks(page);
test.skip(!has, 'No tracks in database — seed required');
await clickPlayOnFirstCard(page);
const state = await getStoreState(page);
expect(
state.hasCurrentTrack,
'Player must recover and load a new track after a previous error',
).toBe(true);
expect(state.currentTrackId).not.toBeNull();
});
});