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 (use 127.0.0.1 to avoid IPv6 ::1 resolution issues) */ baseURL: process.env.PLAYWRIGHT_BASE_URL || `http://127.0.0.1:${process.env.PORT || '5173'}`, /** Base URL de l'API backend (proxied via Vite en dev) */ apiURL: process.env.PLAYWRIGHT_API_URL || `http://127.0.0.1:${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 { await page.goto(`${CONFIG.baseURL}/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 { // 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 const base = CONFIG.baseURL; await page.goto(`${base}/`, { 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(`${base}/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(`${CONFIG.baseURL}/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 (
). * On attend donc qu'un élément `main` ou `[role="main"]` apparaisse. */ export async function navigateTo(page: Page, path: string): Promise { const url = path.startsWith('http') ? path : `${CONFIG.baseURL}${path}`; await page.goto(url, { 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 { 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, ): Promise { 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 { 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 { 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, ): Promise { 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 { 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 { 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 { // 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