/** * E2E tests — Playlist Detail Page (/playlists/:id) * Covers: loading, features, security, a11y, i18n, responsive, regression */ import { test, expect } from '@playwright/test'; import { loginViaAPI, CONFIG, navigateTo } from './helpers'; // We need a valid playlist ID. The seeded database has playlists owned by various users. // We'll navigate to /playlists first to discover a valid ID dynamically. let validPlaylistId: string; test.describe('Playlist Detail (/playlists/:id)', () => { test.beforeEach(async ({ page }) => { await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password); }); test.describe('Chargement & Rendu', () => { test('page loads without crash @critical', async ({ page }) => { // Navigate to playlists list to find a valid playlist await navigateTo(page, '/playlists'); await page.waitForLoadState('networkidle').catch(() => {}); // Find a playlist link const playlistLink = page.locator('a[href^="/playlists/"]').first(); const href = await playlistLink.getAttribute('href').catch(() => null); if (href && href !== '/playlists/favoris') { validPlaylistId = href.replace('/playlists/', ''); await navigateTo(page, `/playlists/${validPlaylistId}`); } else { // Fallback: try a known seeded playlist page await navigateTo(page, '/playlists'); // If no playlists exist, the test still validates page load return; } // Page should load with main content const main = page.locator('main'); await expect(main).toBeVisible({ timeout: 15_000 }); // Should not show error state const notFound = page.getByText('Playlist Not Found'); await expect(notFound).not.toBeVisible(); }); test('no JS errors in console on valid playlist', async ({ page }) => { const jsErrors: string[] = []; page.on('pageerror', (error) => { jsErrors.push(error.message); }); await navigateTo(page, '/playlists'); await page.waitForLoadState('networkidle').catch(() => {}); const playlistLink = page.locator('a[href^="/playlists/"]').filter({ hasNot: page.locator('[href="/playlists/favoris"]'), }).first(); const href = await playlistLink.getAttribute('href').catch(() => null); if (href) { await navigateTo(page, href); await page.waitForLoadState('networkidle').catch(() => {}); // Filter out known non-critical errors (React DevTools, etc.) const criticalErrors = jsErrors.filter( (e) => !e.includes('React DevTools') && !e.includes('Download the React DevTools'), ); expect(criticalErrors).toHaveLength(0); } }); test('all key visual elements are present', async ({ page }) => { await navigateTo(page, '/playlists'); await page.waitForLoadState('networkidle').catch(() => {}); const playlistLink = page.locator('a[href^="/playlists/"]').filter({ hasNot: page.locator('[href="/playlists/favoris"]'), }).first(); const href = await playlistLink.getAttribute('href').catch(() => null); if (!href) return; await navigateTo(page, href); // Heading (playlist title) const heading = page.locator('h1'); await expect(heading).toBeVisible({ timeout: 10_000 }); // Play All button const playAll = page.getByRole('button', { name: /play all/i }); await expect(playAll).toBeVisible(); // Shuffle button const shuffle = page.getByRole('button', { name: /shuffle/i }); await expect(shuffle).toBeVisible(); // Tabs const tracksTab = page.getByRole('tab', { name: /tracks/i }); await expect(tracksTab).toBeVisible(); }); }); test.describe('Fonctionnalites', () => { test('track list displays with 1-based numbering', async ({ page }) => { await navigateTo(page, '/playlists'); await page.waitForLoadState('networkidle').catch(() => {}); const playlistLink = page.locator('a[href^="/playlists/"]').filter({ hasNot: page.locator('[href="/playlists/favoris"]'), }).first(); const href = await playlistLink.getAttribute('href').catch(() => null); if (!href) return; await navigateTo(page, href); // First track should be numbered 1 (not 0) const firstTrack = page.getByRole('listitem').filter({ hasText: /^1/ }).first(); // Also check via aria-label pattern "Track 1: ..." const track1 = page.locator('[aria-label^="Track 1:"]'); const isVisible = await track1.isVisible({ timeout: 5_000 }).catch(() => false); if (isVisible) { await expect(track1).toBeVisible(); } else { // Fallback: check that "Track 0:" does NOT appear const track0 = page.locator('[aria-label^="Track 0:"]'); await expect(track0).not.toBeVisible(); } }); test('tabs switch content correctly', async ({ page }) => { await navigateTo(page, '/playlists'); await page.waitForLoadState('networkidle').catch(() => {}); const playlistLink = page.locator('a[href^="/playlists/"]').filter({ hasNot: page.locator('[href="/playlists/favoris"]'), }).first(); const href = await playlistLink.getAttribute('href').catch(() => null); if (!href) return; await navigateTo(page, href); // Tracks tab should be selected by default const tracksTab = page.getByRole('tab', { name: /tracks/i }); await expect(tracksTab).toHaveAttribute('aria-selected', 'true'); // Click Recommendations tab const recoTab = page.getByRole('tab', { name: /recommendations/i }); if (await recoTab.isVisible().catch(() => false)) { await recoTab.click(); await expect(recoTab).toHaveAttribute('aria-selected', 'true'); await expect(tracksTab).toHaveAttribute('aria-selected', 'false'); } }); test('filter tracks input has aria-label', async ({ page }) => { await navigateTo(page, '/playlists'); await page.waitForLoadState('networkidle').catch(() => {}); const playlistLink = page.locator('a[href^="/playlists/"]').filter({ hasNot: page.locator('[href="/playlists/favoris"]'), }).first(); const href = await playlistLink.getAttribute('href').catch(() => null); if (!href) return; await navigateTo(page, href); const filterInput = page.locator('input[aria-label]').filter({ hasText: '' }); const trackFilter = page.locator('input[placeholder]').first(); if (await trackFilter.isVisible().catch(() => false)) { const ariaLabel = await trackFilter.getAttribute('aria-label'); expect(ariaLabel).toBeTruthy(); } }); }); test.describe('Securite', () => { test('invalid UUID shows Playlist Not Found', async ({ page }) => { await navigateTo(page, '/playlists/00000000-0000-0000-0000-000000000000'); await page.waitForLoadState('networkidle').catch(() => {}); const notFound = page.getByText('Playlist Not Found'); await expect(notFound).toBeVisible({ timeout: 10_000 }); // Should have a "Back to Library" link const backLink = page.getByRole('link', { name: /back to library/i }); await expect(backLink).toBeVisible(); }); test('XSS injection in ID is handled safely', async ({ page }) => { const jsErrors: string[] = []; page.on('pageerror', (error) => { jsErrors.push(error.message); }); await navigateTo(page, '/playlists/%3Cscript%3Ealert(1)%3C%2Fscript%3E'); await page.waitForLoadState('networkidle').catch(() => {}); // Should show not found, not execute script const notFound = page.getByText('Playlist Not Found'); await expect(notFound).toBeVisible({ timeout: 10_000 }); // No JS errors from XSS const xssErrors = jsErrors.filter((e) => e.includes('alert')); expect(xssErrors).toHaveLength(0); }); test('no sensitive data in URL or page source', async ({ page }) => { await navigateTo(page, '/playlists'); await page.waitForLoadState('networkidle').catch(() => {}); const playlistLink = page.locator('a[href^="/playlists/"]').filter({ hasNot: page.locator('[href="/playlists/favoris"]'), }).first(); const href = await playlistLink.getAttribute('href').catch(() => null); if (!href) return; await navigateTo(page, href); const url = page.url(); // URL should not contain tokens, emails, or API keys expect(url).not.toMatch(/token=/i); expect(url).not.toMatch(/@.*\./); expect(url).not.toMatch(/api[_-]?key/i); }); }); test.describe('Accessibilite', () => { test('track list items have descriptive aria-labels', async ({ page }) => { await navigateTo(page, '/playlists'); await page.waitForLoadState('networkidle').catch(() => {}); const playlistLink = page.locator('a[href^="/playlists/"]').filter({ hasNot: page.locator('[href="/playlists/favoris"]'), }).first(); const href = await playlistLink.getAttribute('href').catch(() => null); if (!href) return; await navigateTo(page, href); // Track items should have aria-labels like "Track 1: Title" const trackItems = page.locator('[role="listitem"][aria-label^="Track"]'); const count = await trackItems.count(); if (count > 0) { const firstLabel = await trackItems.first().getAttribute('aria-label'); expect(firstLabel).toMatch(/^Track \d+: .+/); } }); test('action buttons have accessible names', async ({ page }) => { await navigateTo(page, '/playlists'); await page.waitForLoadState('networkidle').catch(() => {}); const playlistLink = page.locator('a[href^="/playlists/"]').filter({ hasNot: page.locator('[href="/playlists/favoris"]'), }).first(); const href = await playlistLink.getAttribute('href').catch(() => null); if (!href) return; await navigateTo(page, href); // Play All and Shuffle should have accessible text const playAll = page.getByRole('button', { name: /play all/i }); await expect(playAll).toBeVisible(); const shuffle = page.getByRole('button', { name: /shuffle/i }); await expect(shuffle).toBeVisible(); }); test('playlist actions group has aria-label', async ({ page }) => { await navigateTo(page, '/playlists'); await page.waitForLoadState('networkidle').catch(() => {}); const playlistLink = page.locator('a[href^="/playlists/"]').filter({ hasNot: page.locator('[href="/playlists/favoris"]'), }).first(); const href = await playlistLink.getAttribute('href').catch(() => null); if (!href) return; await navigateTo(page, href); const actionsGroup = page.locator('[role="group"][aria-label]'); if (await actionsGroup.isVisible().catch(() => false)) { const label = await actionsGroup.getAttribute('aria-label'); expect(label).toBeTruthy(); // Should not be a raw i18n key expect(label).not.toMatch(/^playlists\./); } }); }); test.describe('i18n', () => { test('no raw i18n keys displayed on page', async ({ page }) => { await navigateTo(page, '/playlists'); await page.waitForLoadState('networkidle').catch(() => {}); const playlistLink = page.locator('a[href^="/playlists/"]').filter({ hasNot: page.locator('[href="/playlists/favoris"]'), }).first(); const href = await playlistLink.getAttribute('href').catch(() => null); if (!href) return; await navigateTo(page, href); await page.waitForLoadState('networkidle').catch(() => {}); // Check that no raw i18n keys like "playlists.detail.xxx" are visible const bodyText = await page.textContent('main') || ''; expect(bodyText).not.toMatch(/playlists\.detail\./); expect(bodyText).not.toMatch(/playlists\.shared\./); expect(bodyText).not.toMatch(/playlists\.actions\./); expect(bodyText).not.toMatch(/playlists\.duplicate\./); expect(bodyText).not.toMatch(/playlists\.export\./); expect(bodyText).not.toMatch(/playlists\.followBtn\./); }); }); test.describe('Responsive', () => { test('mobile layout (375px) does not overflow', async ({ page }) => { await page.setViewportSize({ width: 375, height: 812 }); await navigateTo(page, '/playlists'); await page.waitForLoadState('networkidle').catch(() => {}); const playlistLink = page.locator('a[href^="/playlists/"]').filter({ hasNot: page.locator('[href="/playlists/favoris"]'), }).first(); const href = await playlistLink.getAttribute('href').catch(() => null); if (!href) return; await navigateTo(page, href); // Main content should be visible const main = page.locator('main'); await expect(main).toBeVisible({ timeout: 10_000 }); // Check no horizontal overflow const overflowX = await page.evaluate(() => { return document.documentElement.scrollWidth > document.documentElement.clientWidth; }); expect(overflowX).toBe(false); }); test('tablet layout (768px) renders correctly', async ({ page }) => { await page.setViewportSize({ width: 768, height: 1024 }); await navigateTo(page, '/playlists'); await page.waitForLoadState('networkidle').catch(() => {}); const playlistLink = page.locator('a[href^="/playlists/"]').filter({ hasNot: page.locator('[href="/playlists/favoris"]'), }).first(); const href = await playlistLink.getAttribute('href').catch(() => null); if (!href) return; await navigateTo(page, href); const heading = page.locator('h1'); await expect(heading).toBeVisible({ timeout: 10_000 }); }); }); test.describe('Regression', () => { test('BUG #2: track numbering starts at 1 not 0', async ({ page }) => { await navigateTo(page, '/playlists'); await page.waitForLoadState('networkidle').catch(() => {}); const playlistLink = page.locator('a[href^="/playlists/"]').filter({ hasNot: page.locator('[href="/playlists/favoris"]'), }).first(); const href = await playlistLink.getAttribute('href').catch(() => null); if (!href) return; await navigateTo(page, href); // "Track 0:" should NOT exist const track0 = page.locator('[aria-label^="Track 0:"]'); await expect(track0).toHaveCount(0); // "Track 1:" SHOULD exist (if playlist has tracks) const track1 = page.locator('[aria-label^="Track 1:"]'); const count = await track1.count(); // If playlist has tracks, track 1 should be present if (count > 0) { await expect(track1).toBeVisible(); } }); test('BUG #3: page title includes playlist name', async ({ page }) => { await navigateTo(page, '/playlists'); await page.waitForLoadState('networkidle').catch(() => {}); const playlistLink = page.locator('a[href^="/playlists/"]').filter({ hasNot: page.locator('[href="/playlists/favoris"]'), }).first(); const href = await playlistLink.getAttribute('href').catch(() => null); if (!href) return; await navigateTo(page, href); await page.waitForLoadState('networkidle').catch(() => {}); // Wait a bit for the useEffect to update the title await page.waitForTimeout(1_000); const title = await page.title(); // Title should contain "Veza" AND not be just "Veza" expect(title).toContain('Veza'); expect(title).not.toBe('Veza'); expect(title).toMatch(/.+ — Veza/); }); test('BUG #1: no hardcoded FR/EN mix on playlist detail', async ({ page }) => { await navigateTo(page, '/playlists'); await page.waitForLoadState('networkidle').catch(() => {}); const playlistLink = page.locator('a[href^="/playlists/"]').filter({ hasNot: page.locator('[href="/playlists/favoris"]'), }).first(); const href = await playlistLink.getAttribute('href').catch(() => null); if (!href) return; await navigateTo(page, href); // These French strings should NOT appear when locale is EN const mainText = await page.textContent('main') || ''; expect(mainText).not.toContain('Dupliquer la playlist'); expect(mainText).not.toContain('Réorganiser'); expect(mainText).not.toContain('Aucun track dans cette playlist'); expect(mainText).not.toContain('Chargement des pistes'); }); }); });