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>
260 lines
9.6 KiB
TypeScript
260 lines
9.6 KiB
TypeScript
/**
|
|
* E2E Tests — Page Découverte musicale (/discover)
|
|
*
|
|
* Tests exhaustifs : chargement, fonctionnalités, sécurité, a11y, i18n, responsive, régression.
|
|
*/
|
|
import { test, expect } from '@playwright/test';
|
|
import { CONFIG, loginViaAPI, navigateTo } from './helpers';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// HELPERS
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function goToDiscover(page: import('@playwright/test').Page): Promise<void> {
|
|
await loginViaAPI(page, 'user@veza.music', 'User123!');
|
|
await navigateTo(page, '/discover');
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// TESTS
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test.describe('Découverte musicale (/discover)', () => {
|
|
test.describe('Chargement & Rendu', () => {
|
|
test('la page se charge sans erreur', async ({ page }) => {
|
|
const errors: string[] = [];
|
|
page.on('pageerror', (err) => errors.push(err.message));
|
|
|
|
await goToDiscover(page);
|
|
|
|
// Page loaded, not redirected
|
|
expect(page.url()).toContain('/discover');
|
|
|
|
// Title visible
|
|
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
|
|
|
|
// No critical JS errors (filter out audio playback errors)
|
|
const criticalErrors = errors.filter(
|
|
(e) => !e.includes('audio') && !e.includes('playback') && !e.includes('download'),
|
|
);
|
|
expect(criticalErrors).toHaveLength(0);
|
|
});
|
|
|
|
test('le titre "Discover" et le sous-titre sont visibles', async ({ page }) => {
|
|
await goToDiscover(page);
|
|
|
|
await expect(page.getByRole('heading', { name: /discover/i, level: 1 })).toBeVisible();
|
|
// Subtitle
|
|
const body = await page.textContent('body');
|
|
expect(body).toMatch(/explore by genre|explorez par genre|explora por/i);
|
|
});
|
|
|
|
test('les genre cards sont affichées', async ({ page }) => {
|
|
await goToDiscover(page);
|
|
|
|
// At least some genre buttons should be visible
|
|
const genreButtons = page.locator('button[aria-label*="Browse"]');
|
|
const count = await genreButtons.count();
|
|
expect(count).toBeGreaterThan(5);
|
|
});
|
|
|
|
test('la section Editorial Playlists est présente', async ({ page }) => {
|
|
await goToDiscover(page);
|
|
|
|
await expect(page.getByRole('heading', { name: /editorial playlist/i })).toBeVisible();
|
|
});
|
|
});
|
|
|
|
test.describe('Fonctionnalités', () => {
|
|
test('cliquer sur un genre navigue vers ?genre=slug', async ({ page }) => {
|
|
await goToDiscover(page);
|
|
|
|
// Click a genre button
|
|
const genreBtn = page.locator('button[aria-label*="Browse"]').first();
|
|
await genreBtn.click();
|
|
await page.waitForTimeout(1_000);
|
|
|
|
// URL should have ?genre= parameter
|
|
expect(page.url()).toMatch(/\?genre=/);
|
|
|
|
// Title should change to the genre name
|
|
const h1 = page.getByRole('heading', { level: 1 });
|
|
await expect(h1).toBeVisible();
|
|
const title = await h1.textContent();
|
|
expect(title).not.toBe('Discover');
|
|
});
|
|
|
|
test('le bouton Back ramène à la liste des genres', async ({ page }) => {
|
|
await goToDiscover(page);
|
|
|
|
// Click genre
|
|
await page.locator('button[aria-label*="Browse"]').first().click();
|
|
await page.waitForTimeout(1_000);
|
|
expect(page.url()).toMatch(/\?genre=/);
|
|
|
|
// Click Back
|
|
await page.getByRole('button', { name: /back|retour|volver/i }).click();
|
|
await page.waitForTimeout(1_000);
|
|
|
|
// Should be back on /discover without query params
|
|
expect(page.url()).toBe(`${CONFIG.baseURL}/discover`);
|
|
await expect(page.getByRole('heading', { name: /discover/i, level: 1 })).toBeVisible();
|
|
});
|
|
|
|
test('la section genres a un empty state quand no tracks', async ({ page }) => {
|
|
await goToDiscover(page);
|
|
|
|
// Click a genre
|
|
await page.locator('button[aria-label*="Browse"]').first().click();
|
|
await page.waitForTimeout(2_000);
|
|
|
|
// Should show empty message or tracks
|
|
const body = await page.textContent('body');
|
|
expect(body).toMatch(/no tracks|aucune piste|no hay pistas|track/i);
|
|
});
|
|
|
|
test('editorial playlists empty state affiché quand vide', async ({ page }) => {
|
|
await goToDiscover(page);
|
|
|
|
// The empty state message should be visible when no editorial playlists
|
|
const section = page.getByRole('region', { name: /editorial/i });
|
|
const sectionText = await section.textContent();
|
|
// Either has playlist cards or the empty message
|
|
expect(sectionText).toMatch(
|
|
/no editorial|aucune playlist|no hay playlist|playlist/i,
|
|
);
|
|
});
|
|
});
|
|
|
|
test.describe('Sécurité', () => {
|
|
test('pas de fuite de tokens sensibles dans le DOM', async ({ page }) => {
|
|
await goToDiscover(page);
|
|
|
|
const html = await page.content();
|
|
expect(html).not.toMatch(/access_token/);
|
|
expect(html).not.toMatch(/refresh_token/);
|
|
});
|
|
|
|
test('XSS via query param genre ne s\'exécute pas', async ({ page }) => {
|
|
let alertTriggered = false;
|
|
page.on('dialog', (dialog) => {
|
|
alertTriggered = true;
|
|
dialog.dismiss();
|
|
});
|
|
|
|
await loginViaAPI(page, 'user@veza.music', 'User123!');
|
|
await navigateTo(page, '/discover?genre=<script>alert(1)</script>');
|
|
|
|
expect(alertTriggered).toBe(false);
|
|
// Page should not crash
|
|
expect(page.url()).toContain('/discover');
|
|
});
|
|
});
|
|
|
|
test.describe('Accessibilité', () => {
|
|
test('les genre buttons ont des aria-labels descriptifs', async ({ page }) => {
|
|
await goToDiscover(page);
|
|
|
|
const genreButtons = page.locator('button[aria-label*="Browse"]');
|
|
const count = await genreButtons.count();
|
|
expect(count).toBeGreaterThan(0);
|
|
|
|
// Check first button has descriptive label
|
|
const label = await genreButtons.first().getAttribute('aria-label');
|
|
expect(label).toMatch(/browse .+ tracks/i);
|
|
});
|
|
|
|
test('les sections ont des rôles region avec aria-label', async ({ page }) => {
|
|
await goToDiscover(page);
|
|
|
|
await expect(page.getByRole('region', { name: /by genre|par genre/i })).toBeVisible();
|
|
await expect(page.getByRole('region', { name: /editorial/i })).toBeVisible();
|
|
});
|
|
|
|
test('focus order logique via Tab', async ({ page }) => {
|
|
await goToDiscover(page);
|
|
|
|
// Tab should hit interactive elements
|
|
await page.keyboard.press('Tab');
|
|
const firstFocused = page.locator(':focus');
|
|
const tag = await firstFocused.evaluate((el) => el.tagName.toLowerCase());
|
|
// Should be a link or button (skip to content or sidebar)
|
|
expect(['a', 'button']).toContain(tag);
|
|
});
|
|
});
|
|
|
|
test.describe('i18n', () => {
|
|
test('pas de clés i18n brutes affichées', async ({ page }) => {
|
|
await goToDiscover(page);
|
|
|
|
const bodyText = await page.textContent('body') ?? '';
|
|
expect(bodyText).not.toMatch(/discover\.title/);
|
|
expect(bodyText).not.toMatch(/discover\.subtitle/);
|
|
expect(bodyText).not.toMatch(/discover\.byGenre/);
|
|
expect(bodyText).not.toMatch(/discover\.editorialPlaylists/);
|
|
});
|
|
|
|
test('pas de mélange FR/EN', async ({ page }) => {
|
|
await goToDiscover(page);
|
|
|
|
const bodyText = await page.textContent('body') ?? '';
|
|
// In English mode, should not have hardcoded French
|
|
if (bodyText.includes('Discover')) {
|
|
expect(bodyText).not.toContain('Explorez par genre');
|
|
expect(bodyText).not.toContain('Par genre');
|
|
expect(bodyText).not.toContain('Playlists éditoriales');
|
|
}
|
|
});
|
|
});
|
|
|
|
test.describe('Responsive', () => {
|
|
test('tablet 768px — la page se charge correctement', async ({ page }) => {
|
|
await page.setViewportSize({ width: 768, height: 1024 });
|
|
await goToDiscover(page);
|
|
|
|
await expect(page.getByRole('heading', { name: /discover/i, level: 1 })).toBeVisible();
|
|
const genreButtons = page.locator('button[aria-label*="Browse"]');
|
|
expect(await genreButtons.count()).toBeGreaterThan(0);
|
|
});
|
|
|
|
test('desktop 1280px — layout complet avec grille 5 colonnes', async ({ page }) => {
|
|
await page.setViewportSize({ width: 1280, height: 800 });
|
|
await goToDiscover(page);
|
|
|
|
await expect(page.getByRole('heading', { name: /discover/i, level: 1 })).toBeVisible();
|
|
const genreButtons = page.locator('button[aria-label*="Browse"]');
|
|
expect(await genreButtons.count()).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
test.describe('Régression', () => {
|
|
test('[BUG#1] i18n — textes traduits, pas de hardcoded English', async ({ page }) => {
|
|
await goToDiscover(page);
|
|
|
|
// Title uses i18n key
|
|
await expect(page.getByRole('heading', { name: /discover/i, level: 1 })).toBeVisible();
|
|
// Subtitle uses i18n key
|
|
const body = await page.textContent('body');
|
|
expect(body).toMatch(/explore by genre|explorez par genre|explora por/i);
|
|
});
|
|
|
|
test('[BUG#2] editorial playlists — pas de heading orphelin quand vide', async ({ page }) => {
|
|
await goToDiscover(page);
|
|
|
|
const section = page.getByRole('region', { name: /editorial/i });
|
|
await expect(section).toBeVisible();
|
|
const text = await section.textContent();
|
|
// Must have either playlist content or empty state text (not just heading)
|
|
expect(text!.length).toBeGreaterThan(25);
|
|
});
|
|
|
|
test('[BUG#3] genre buttons — ont des aria-labels descriptifs', async ({ page }) => {
|
|
await goToDiscover(page);
|
|
|
|
const firstBtn = page.locator('button[aria-label*="Browse"]').first();
|
|
await expect(firstBtn).toBeVisible();
|
|
const label = await firstBtn.getAttribute('aria-label');
|
|
expect(label).toMatch(/browse .+ tracks/i);
|
|
});
|
|
});
|
|
});
|