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>
1273 lines
40 KiB
TypeScript
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();
|
|
});
|
|
});
|