import { type Page, type Locator, expect } from '@playwright/test'; // ============================================================================= // CONFIGURATION — Basee sur le code source reel 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/seed_users.go) */ users: { listener: { email: 'user@veza.music', password: 'User123!', username: 'music_fan', }, creator: { email: 'artist@veza.music', password: 'Artist123!', username: 'top_artist', }, admin: { email: 'admin@veza.music', password: 'Admin123!', username: 'admin_veza', }, moderator: { email: 'mod@veza.music', password: 'Mod123!', username: 'mod_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). * STRICT: echoue si le login ne redirige pas hors de /login. */ export async function loginViaUI( page: Page, email: string, password: string, options: { rememberMe?: boolean } = {}, ): Promise { await page.goto(`${CONFIG.baseURL}/login`, { waitUntil: 'domcontentloaded' }); // Wait for the app to finish initializing (splash -> login form) await page.locator('main, [role="main"]').first().waitFor({ state: 'visible', timeout: CONFIG.timeouts.navigation, }); 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); if (options.rememberMe) { const rememberMe = page.locator('#remember_me'); if (await rememberMe.isVisible()) { await rememberMe.check(); } } const submitBtn = page.getByTestId('login-submit'); await expect(submitBtn).toBeVisible({ timeout: CONFIG.timeouts.action }); await submitBtn.click(); // STRICT: must redirect away from /login await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: CONFIG.timeouts.navigation, }); } /** * Login via l'API directement (plus rapide, pour les tests qui n'ont pas besoin de tester le login). * STRICT: echoue si l'API retourne une erreur. */ export async function loginViaAPI( page: Page, email: string, password: string, ): Promise { const base = CONFIG.baseURL; await page.goto(`${base}/`, { waitUntil: 'commit', timeout: CONFIG.timeouts.navigation }); const response = await page.request.post(`${base}/api/v1/auth/login`, { data: { email, password, remember_me: false }, }); // STRICT: login must succeed expect(response.ok(), `Login API failed: ${response.status()} for ${email}`).toBeTruthy(); await page.evaluate(() => { const authState = { state: { isAuthenticated: true, isLoading: false, error: null }, version: 1, }; localStorage.setItem('auth-storage', JSON.stringify(authState)); }); await page.goto(`${CONFIG.baseURL}/dashboard`, { waitUntil: 'domcontentloaded', timeout: 30_000 }); // Wait for auth initialization to complete await page.locator('main, [role="main"]').first().waitFor({ state: 'visible', timeout: 20_000, }); } // ============================================================================= // NAVIGATION HELPERS // ============================================================================= /** * Navigue vers un path et attend que l'app soit prete. * STRICT: echoue si la page ne charge pas (main element must appear). */ 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(() => { // networkidle can legitimately timeout on pages with websockets/polling — not a test failure }); // App must render a main content area await page.locator('main, [role="main"]').first().waitFor({ state: 'visible', timeout: 20_000, }); } /** * Verifie qu'une page se charge sans erreur critique. * STRICT: fails on 500 errors visible in the page. */ 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); const body = await page.textContent('body') || ''; expect(body).not.toMatch(/500|Internal Server Error/i); return errors; } // ============================================================================= // FORM HELPERS // ============================================================================= /** * Remplit un formulaire avec les champs donnes. */ 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 // ============================================================================= /** * Verifie qu'il n'y a pas de texte de debug visible. * STRICT: fails if [object Object] or excessive undefined/null/NaN found. */ export async function assertNoDebugText(page: Page): Promise { const body = await page.textContent('body') || ''; expect(body).not.toContain('[object Object]'); const suspiciousPatterns = /\bundefined\b(?!.*password)|\bnull\b.*\bnull\b|\bNaN\b/g; const matches = body.match(suspiciousPatterns); expect( matches?.length ?? 0, `Debug text found in page: ${matches?.slice(0, 3).join(', ')}`, ).toBeLessThanOrEqual(2); } /** * Verifie que la page n'a pas d'erreur serveur visible. * STRICT: fails on 500 errors or empty body. */ export async function assertNotBroken(page: Page): Promise { const body = await page.textContent('body') || ''; expect(body).not.toMatch(/500|Internal Server Error|unexpected error/i); expect(body.length).toBeGreaterThan(50); } /** * Collecte les erreurs reseau (5xx) pendant une action. */ 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. */ 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 }); } } // ============================================================================= // PLAYER HELPERS // ============================================================================= /** * Verifie que le player global est visible et le retourne. * STRICT: fails if player is not visible. */ 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 displays track cards (role="article"). * Returns true if tracks are found, false if the database has no tracks. * Does NOT swallow errors — navigation failures will throw. */ export async function navigateToPageWithTracks(page: Page): Promise { await dismissMobileSidebar(page); // Try /feed first await navigateTo(page, '/feed'); const feedTrack = page.locator('[role="article"]').first(); if (await feedTrack.isVisible({ timeout: 5_000 }).catch(() => false)) { return true; } // Fallback: /discover -> click first genre 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. * STRICT: fails if no play button is found or if it can't be clicked. */ export async function playFirstTrack(page: Page): Promise { await dismissMobileSidebar(page); // If no track cards on current page, navigate to one that has them const currentTrack = page.locator('[role="article"]').first(); if (!await currentTrack.isVisible({ timeout: 3_000 }).catch(() => false)) { const found = await navigateToPageWithTracks(page); expect(found, 'No tracks found in feed or discover — database may need seeding').toBeTruthy(); } // Hover to reveal play button const trackCard = page.locator('[role="article"]').first(); await expect(trackCard).toBeVisible({ timeout: CONFIG.timeouts.action }); await trackCard.hover(); await page.waitForTimeout(300); // Click play 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 expect(playBtn).toBeVisible({ timeout: CONFIG.timeouts.action }); await playBtn.click(); // Wait for player to appear await page.waitForTimeout(CONFIG.timeouts.animation); } // ============================================================================= // COMPONENT SELECTORS — Bases sur le code source reel // ============================================================================= export const SELECTORS = { sidebar: '[data-testid="app-sidebar"]', header: 'header, [data-testid="app-header"], [role="banner"]', playerBar: '[data-testid="global-player"]', loginForm: '[data-testid="login-form"]', registerForm: '[data-testid="register-form"]', audioElement: '[data-testid="audio-element"]', progressBar: '[role="slider"][aria-label="Progression"]', volumeSlider: '[data-testid="volume-control"] [role="slider"]', toast: '[data-testid="toast-alert"]', trackCard: '[role="article"]', 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. * STRICT: fails if no toast appears within timeout. */ export async function waitForToast(page: Page): Promise { const toast = page.getByTestId('toast-alert').first(); await expect(toast).toBeVisible({ timeout: CONFIG.timeouts.action }); return (await toast.textContent()) || ''; } /** * Genere un identifiant unique pour les donnees de test. */ export function testId(prefix = 'e2e'): string { return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; }