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 { 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 { 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(); }); });