Update auth, playlists, tracks, search, profile, dashboard, player, settings, and social features. Add e2e audit specs for all major pages. Update ESLint config, vitest config, and route configuration. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
430 lines
16 KiB
TypeScript
430 lines
16 KiB
TypeScript
/**
|
|
* 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');
|
|
});
|
|
});
|
|
});
|