Refine auth, player, tracks, playlists, search, workflows, edge cases, forms, responsive, network errors, error boundary, performance, visual regression, cross-browser, profile, smoke, storybook, chat, and session tests. Add audit test suite (accessibility, ethical, functional, design tokens). Update test helpers and visual snapshots. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
453 lines
17 KiB
TypeScript
453 lines
17 KiB
TypeScript
import { type Page, type Locator, expect } from '@playwright/test';
|
|
|
|
// =============================================================================
|
|
// CONFIGURATION — Basée sur le code source réel de Veza
|
|
// =============================================================================
|
|
|
|
export const CONFIG = {
|
|
/** Base URL du frontend Vite dev server */
|
|
baseURL: process.env.PLAYWRIGHT_BASE_URL || `http://localhost:${process.env.PORT || '5173'}`,
|
|
|
|
/** Base URL de l'API backend (proxied via Vite en dev) */
|
|
apiURL: process.env.PLAYWRIGHT_API_URL || `http://localhost:${process.env.PORT || '5173'}`,
|
|
|
|
/** Base URL du stream server Rust */
|
|
streamURL: process.env.PLAYWRIGHT_STREAM_URL || 'http://localhost:18082',
|
|
|
|
/** Comptes de test (seed: veza-backend-api/cmd/tools/seed/main.go) */
|
|
users: {
|
|
listener: {
|
|
email: 'listener1@veza.fr',
|
|
password: 'Password123!',
|
|
username: 'music_lover',
|
|
},
|
|
creator: {
|
|
email: 'amelie@veza.fr',
|
|
password: 'Password123!',
|
|
username: 'amelie_dubois',
|
|
},
|
|
admin: {
|
|
email: 'admin@veza.fr',
|
|
password: 'Password123!',
|
|
username: 'admin_veza',
|
|
},
|
|
moderator: {
|
|
email: 'mod@veza.fr',
|
|
password: 'Password123!',
|
|
username: 'moderator_veza',
|
|
},
|
|
},
|
|
|
|
/** Timeouts (ms) */
|
|
timeouts: {
|
|
navigation: 15_000,
|
|
action: 5_000,
|
|
animation: 1_000,
|
|
networkIdle: 10_000,
|
|
},
|
|
} as const;
|
|
|
|
// =============================================================================
|
|
// AUTH HELPERS
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Login via l'interface utilisateur (page /login).
|
|
* Utilise les vrais sélecteurs du composant LoginPage.tsx.
|
|
*
|
|
* Le formulaire a :
|
|
* - Input email : label="Email", type="email"
|
|
* - Input password : label="Password", type="password"
|
|
* - Bouton submit : type="submit", texte "Sign In" (en) ou "Se connecter" (fr)
|
|
* - Checkbox remember_me : id="remember_me"
|
|
*/
|
|
export async function loginViaUI(
|
|
page: Page,
|
|
email: string,
|
|
password: string,
|
|
options: { rememberMe?: boolean } = {},
|
|
): Promise<void> {
|
|
await page.goto('/login', { waitUntil: 'domcontentloaded' });
|
|
await page.waitForLoadState('networkidle').catch(() => {});
|
|
// Wait for the app to finish initializing (splash → login form)
|
|
await page.locator('main, [role="main"]').first().waitFor({
|
|
state: 'visible',
|
|
timeout: CONFIG.timeouts.navigation,
|
|
}).catch(() => {});
|
|
|
|
// DOM réel (vérifié via snapshot) :
|
|
// textbox "Email" → input[type="email"] (peut avoir une valeur pré-remplie "remember me")
|
|
// textbox "Password" → input[type="password"]
|
|
// button "Sign In" → data-testid="login-submit"
|
|
const emailInput = page.locator('input[type="email"]');
|
|
await emailInput.waitFor({ state: 'visible', timeout: CONFIG.timeouts.navigation });
|
|
await emailInput.clear();
|
|
await emailInput.fill(email);
|
|
|
|
const passwordInput = page.locator('input[type="password"]');
|
|
await passwordInput.clear();
|
|
await passwordInput.fill(password);
|
|
|
|
// Remember me checkbox (optionnel)
|
|
if (options.rememberMe) {
|
|
const rememberMe = page.locator('#remember_me');
|
|
if (await rememberMe.isVisible().catch(() => false)) {
|
|
await rememberMe.check();
|
|
}
|
|
}
|
|
|
|
// Soumettre — le bouton est data-testid="login-submit" avec texte "Sign In"
|
|
const submitBtn = page.getByTestId('login-submit');
|
|
await submitBtn.waitFor({ state: 'visible', timeout: CONFIG.timeouts.action });
|
|
await submitBtn.click();
|
|
|
|
// Attendre la redirection (quitte /login)
|
|
const redirected = await page.waitForURL((url) => !url.pathname.includes('/login'), {
|
|
timeout: CONFIG.timeouts.navigation,
|
|
}).then(() => true).catch(() => false);
|
|
|
|
if (!redirected) {
|
|
// Retry once — rate limiting or slow API may have blocked the first attempt
|
|
const bodyText = await page.textContent('body').catch(() => '') || '';
|
|
if (/rate limit|trop de requêtes|429|too many|error|erreur/i.test(bodyText)) {
|
|
await page.waitForTimeout(2_000);
|
|
// Re-fill in case form was reset
|
|
const emailRetry = page.locator('input[type="email"]');
|
|
if (await emailRetry.isVisible().catch(() => false)) {
|
|
await emailRetry.clear();
|
|
await emailRetry.fill(email);
|
|
const pwRetry = page.locator('input[type="password"]').first();
|
|
await pwRetry.clear();
|
|
await pwRetry.fill(password);
|
|
}
|
|
await submitBtn.click();
|
|
await page.waitForURL((url) => !url.pathname.includes('/login'), {
|
|
timeout: CONFIG.timeouts.navigation,
|
|
}).catch(() => {});
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Login via l'API directement (plus rapide, pour les tests qui n'ont pas besoin de tester le login).
|
|
* Beaucoup plus rapide que loginViaUI car évite le rendu complet de la SPA.
|
|
*
|
|
* POST /api/v1/auth/login → set cookies + localStorage auth-storage
|
|
*/
|
|
export async function loginViaAPI(
|
|
page: Page,
|
|
email: string,
|
|
password: string,
|
|
): Promise<void> {
|
|
// Naviguer vers une page minimale pour initialiser le contexte navigateur (cookies, localStorage)
|
|
// about:blank ne permet pas localStorage, donc on utilise / avec un timeout court
|
|
await page.goto('/', { waitUntil: 'commit', timeout: CONFIG.timeouts.navigation });
|
|
|
|
// Appeler l'API login directement (bypass le rendu UI, juste un POST HTTP)
|
|
const response = await page.request.post('/api/v1/auth/login', {
|
|
data: { email, password, remember_me: false },
|
|
});
|
|
|
|
if (!response.ok()) {
|
|
// Ne pas throw — le test appelant vérifiera si on est authentifié
|
|
console.warn(`loginViaAPI failed: ${response.status()}`);
|
|
return;
|
|
}
|
|
|
|
const body = await response.json();
|
|
const token = body?.data?.token?.access_token;
|
|
|
|
// Stocker l'état auth dans le Zustand store (auth-storage) pour que le frontend
|
|
// reconnaisse la session immédiatement au prochain chargement de page
|
|
await page.evaluate((_token: string | undefined) => {
|
|
const authState = {
|
|
state: { isAuthenticated: true, isLoading: false, error: null },
|
|
version: 1,
|
|
};
|
|
localStorage.setItem('auth-storage', JSON.stringify(authState));
|
|
}, token);
|
|
|
|
// Naviguer vers le dashboard — la SPA détecte isAuthenticated et affiche le layout authentifié
|
|
await page.goto('/dashboard', { waitUntil: 'domcontentloaded', timeout: 30_000 });
|
|
await page.waitForLoadState('networkidle').catch(() => {});
|
|
// Wait for the app to finish auth initialization
|
|
await page.waitForTimeout(1_000);
|
|
}
|
|
|
|
// =============================================================================
|
|
// NAVIGATION HELPERS
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Navigue vers un path et attend que l'app soit prête (splash screen disparu).
|
|
*
|
|
* L'app affiche un splash "Veza" pendant l'initialisation auth (refreshUser → getMe).
|
|
* Une fois prête, elle rend soit AuthLayout (role="main") soit DashboardLayout (<main>).
|
|
* On attend donc qu'un élément `main` ou `[role="main"]` apparaisse.
|
|
*/
|
|
export async function navigateTo(page: Page, path: string): Promise<void> {
|
|
await page.goto(path, { waitUntil: 'domcontentloaded', timeout: 30_000 });
|
|
await page.waitForLoadState('networkidle').catch(() => {});
|
|
// Wait for the app to finish initializing (loading splash → actual page)
|
|
await page.locator('main, [role="main"]').first().waitFor({
|
|
state: 'visible',
|
|
timeout: 20_000,
|
|
}).catch(() => {});
|
|
}
|
|
|
|
/**
|
|
* Vérifie qu'une page se charge sans erreur critique.
|
|
* Retourne les erreurs console collectées.
|
|
*/
|
|
export async function assertPageLoads(page: Page, path: string): Promise<string[]> {
|
|
const errors: string[] = [];
|
|
|
|
page.on('console', (msg) => {
|
|
if (msg.type() === 'error') {
|
|
errors.push(msg.text());
|
|
}
|
|
});
|
|
|
|
page.on('pageerror', (err) => {
|
|
errors.push(err.message);
|
|
});
|
|
|
|
await navigateTo(page, path);
|
|
|
|
// Vérifier pas de crash
|
|
const body = await page.textContent('body').catch(() => '') || '';
|
|
expect(body).not.toMatch(/500|Internal Server Error/i);
|
|
|
|
return errors;
|
|
}
|
|
|
|
// =============================================================================
|
|
// FORM HELPERS
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Remplit un formulaire avec les champs donnés.
|
|
* Les clés sont les labels ou placeholders des champs.
|
|
*/
|
|
export async function fillForm(
|
|
page: Page,
|
|
fields: Record<string, string>,
|
|
): Promise<void> {
|
|
for (const [label, value] of Object.entries(fields)) {
|
|
const input = page.getByLabel(new RegExp(label, 'i'))
|
|
.or(page.getByPlaceholder(new RegExp(label, 'i')));
|
|
await input.first().fill(value);
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// ASSERTION HELPERS
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Vérifie qu'il n'y a pas de texte de debug visible (undefined, null, NaN, [object Object], etc.)
|
|
*/
|
|
export async function assertNoDebugText(page: Page): Promise<void> {
|
|
const body = await page.textContent('body').catch(() => '') || '';
|
|
// Patterns de debug courants
|
|
expect(body).not.toContain('[object Object]');
|
|
// Note: "undefined" et "null" peuvent apparaître dans du texte légitime,
|
|
// donc on vérifie seulement les occurrences suspectes
|
|
const suspiciousPatterns = /\bundefined\b(?!.*password)|\bnull\b.*\bnull\b|\bNaN\b/g;
|
|
const matches = body.match(suspiciousPatterns);
|
|
if (matches && matches.length > 2) {
|
|
console.warn(` ⚠ Texte suspect trouvé: ${matches.slice(0, 3).join(', ')}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Vérifie que la page n'a pas d'erreur serveur visible.
|
|
*/
|
|
export async function assertNotBroken(page: Page): Promise<void> {
|
|
const body = await page.textContent('body').catch(() => '') || '';
|
|
expect(body).not.toMatch(/500|Internal Server Error|unexpected error/i);
|
|
expect(body.length).toBeGreaterThan(50);
|
|
}
|
|
|
|
/**
|
|
* Collecte les erreurs réseau (5xx) pendant une période.
|
|
*/
|
|
export async function collectNetworkErrors(
|
|
page: Page,
|
|
action: () => Promise<void>,
|
|
): Promise<string[]> {
|
|
const errors: string[] = [];
|
|
|
|
const handler = (response: { status: () => number; url: () => string }) => {
|
|
if (response.status() >= 500) {
|
|
errors.push(`${response.status()} ${response.url()}`);
|
|
}
|
|
};
|
|
|
|
page.on('response', handler);
|
|
await action();
|
|
page.off('response', handler);
|
|
|
|
return errors;
|
|
}
|
|
|
|
// =============================================================================
|
|
// LAYOUT HELPERS
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Dismiss the mobile sidebar if it's open.
|
|
* The sidebar overlay is wrapped in a FocusTrap that intercepts pointer events,
|
|
* so clicking the overlay fails. Instead we press Escape which the FocusTrap handles.
|
|
*/
|
|
export async function dismissMobileSidebar(page: Page): Promise<void> {
|
|
const sidebarOverlay = page.locator('div[aria-hidden="true"][role="presentation"].fixed.inset-0');
|
|
if (await sidebarOverlay.isVisible({ timeout: 1_000 }).catch(() => false)) {
|
|
await page.keyboard.press('Escape');
|
|
await sidebarOverlay.waitFor({ state: 'hidden', timeout: 3_000 }).catch(() => {});
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// PLAYER HELPERS
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Vérifie que le player global est visible et le retourne.
|
|
* Le player a data-testid="global-player" et role="region" aria-label="Global player".
|
|
*/
|
|
export async function assertPlayerVisible(page: Page): Promise<Locator> {
|
|
const player = page.getByTestId('global-player')
|
|
.or(page.locator('[role="region"][aria-label="Global player"]'));
|
|
|
|
await expect(player.first()).toBeVisible({ timeout: CONFIG.timeouts.action });
|
|
return player.first();
|
|
}
|
|
|
|
/**
|
|
* Navigate to a page that actually displays track cards (role="article").
|
|
*
|
|
* Page rendering details:
|
|
* - /feed uses TrackGrid → TrackCard (role="article"). Best for listener accounts
|
|
* who follow creators (seed: listener1 follows amelie, marcus, renzo).
|
|
* - /discover shows genre buttons by default; clicking a genre loads tracks via
|
|
* TrackGrid → TrackCard (role="article").
|
|
* - /library uses its own LibraryPageGrid (NOT TrackCard), so no role="article".
|
|
* It also only shows the current user's OWN tracks (empty for listeners).
|
|
*/
|
|
export async function navigateToPageWithTracks(page: Page): Promise<boolean> {
|
|
// Fermer la sidebar mobile si ouverte (son FocusTrap intercepte les clics)
|
|
await dismissMobileSidebar(page);
|
|
|
|
// Try /feed first — it uses TrackGrid/TrackCard (role="article")
|
|
// and shows tracks from followed users + by_genres section
|
|
await navigateTo(page, '/feed');
|
|
const feedTrack = page.locator('[role="article"]').first();
|
|
if (await feedTrack.isVisible({ timeout: 5_000 }).catch(() => false)) {
|
|
return true;
|
|
}
|
|
|
|
// Fallback: /discover → genre buttons are <button> with .font-heading.font-bold spans
|
|
// Clicking a genre sets ?genre=slug which loads tracks via TrackGrid/TrackCard
|
|
await navigateTo(page, '/discover');
|
|
const genreBtn = page.locator('button').filter({ has: page.locator('.font-heading.font-bold') }).first();
|
|
|
|
if (await genreBtn.isVisible({ timeout: 5_000 }).catch(() => false)) {
|
|
await genreBtn.click();
|
|
await page.waitForLoadState('networkidle').catch(() => {});
|
|
const genreTrack = page.locator('[role="article"]').first();
|
|
if (await genreTrack.isVisible({ timeout: 5_000 }).catch(() => false)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Lance la lecture du premier track disponible.
|
|
* Navigates to a page with tracks if none are visible on the current page.
|
|
* Les TrackCards ont un bouton play avec aria-label="Lire {title}" ou "Play {title}".
|
|
*/
|
|
export async function playFirstTrack(page: Page): Promise<void> {
|
|
// Fermer la sidebar mobile si ouverte (son FocusTrap intercepte les clics)
|
|
await dismissMobileSidebar(page);
|
|
|
|
// If no track cards are visible on the current page, navigate to one that has them
|
|
const currentTrack = page.locator('[role="article"]').first();
|
|
if (!await currentTrack.isVisible({ timeout: 3_000 }).catch(() => false)) {
|
|
await navigateToPageWithTracks(page);
|
|
}
|
|
|
|
// Hover sur le premier track card pour faire apparaître le bouton play
|
|
const trackCard = page.locator('[role="article"]').first()
|
|
.or(page.getByRole('button', { name: /piste:/i }).first());
|
|
|
|
if (await trackCard.isVisible().catch(() => false)) {
|
|
await trackCard.hover();
|
|
await page.waitForTimeout(300);
|
|
}
|
|
|
|
// Cliquer le bouton play (aria-label="Lire ...")
|
|
const playBtn = page.getByRole('button', { name: /^lire |^play /i }).first()
|
|
.or(page.locator('[aria-label*="Lire"]').first())
|
|
.or(page.locator('[aria-label*="Play"]').first());
|
|
|
|
await playBtn.waitFor({ state: 'visible', timeout: CONFIG.timeouts.action }).catch(() => {});
|
|
|
|
if (await playBtn.isVisible().catch(() => false)) {
|
|
await playBtn.click();
|
|
// Attendre que le player apparaisse
|
|
await page.waitForTimeout(CONFIG.timeouts.animation);
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// COMPONENT SELECTORS — Basés sur le code source réel
|
|
// =============================================================================
|
|
|
|
export const SELECTORS = {
|
|
// Layout (vérifié via DOM snapshot)
|
|
sidebar: '[data-testid="app-sidebar"]', // complementary "Main sidebar"
|
|
header: 'header, [data-testid="app-header"], [role="banner"]',
|
|
playerBar: '[data-testid="global-player"]', // region "Global player"
|
|
|
|
// Auth
|
|
loginForm: '[data-testid="login-form"]',
|
|
registerForm: '[data-testid="register-form"]',
|
|
|
|
// Player (vérifié: les boutons n'ont PAS d'aria-labels)
|
|
audioElement: '[data-testid="audio-element"]',
|
|
progressBar: '[role="slider"][aria-label="Progression"]',
|
|
volumeSlider: '[data-testid="volume-control"] [role="slider"]',
|
|
|
|
// Toast
|
|
toast: '[data-testid="toast-alert"]',
|
|
|
|
// Cards — TrackCard component (used by TrackGrid on /feed, /discover?genre=...)
|
|
// Note: /library uses LibraryPageGrid which does NOT use TrackCard (no role="article")
|
|
trackCard: '[role="article"]',
|
|
|
|
// Search — Header search uses data-testid="search-input" type="search"
|
|
searchInput: '[data-testid="search-input"], [role="search"] input, input[type="search"], input[role="searchbox"]',
|
|
} as const;
|
|
|
|
// =============================================================================
|
|
// UTILITY
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Attend qu'un toast soit visible, puis retourne son texte.
|
|
*/
|
|
export async function waitForToast(page: Page): Promise<string> {
|
|
const toast = page.getByTestId('toast-alert').first();
|
|
await toast.waitFor({ state: 'visible', timeout: CONFIG.timeouts.action }).catch(() => {});
|
|
return (await toast.textContent()) || '';
|
|
}
|
|
|
|
/**
|
|
* Génère un identifiant unique pour les données de test.
|
|
*/
|
|
export function testId(prefix = 'e2e'): string {
|
|
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
}
|