/** * Suite complète de capture visuelle pour régression pixel-perfect. * * - Boucle sur URLs critiques (Login, Dashboard, Playlists, etc.) * - Auth via storageState pour pages protégées ; pas d'auth pour login/register * - Full page + screenshots ciblés (Sidebar, Player, Header) * - waitForStableNetwork, masquage éléments dynamiques (dates, avatars) * - Nommage : {screen-name}-desktop-dark.png * * Sortie : visual-tests/current/ (visual:capture) ou visual-tests/baselines/ (visual:update) */ import { test } from '@playwright/test'; import fs from 'fs'; import path from 'path'; import { visualOutputDir, screenshotName } from '../playwright.config.visual'; const BASE_URL = process.env.PLAYWRIGHT_BASE_URL || process.env.VITE_FRONTEND_URL || 'http://localhost:5173'; const ANIMATION_SETTLE_MS = 800; const NETWORK_IDLE_MS = 500; /** Désactive les animations/transitions CSS pour captures stables */ async function disableAnimations(page: import('@playwright/test').Page) { await page.addStyleTag({ content: ` *, *::before, *::after { animation-duration: 0s !important; animation-delay: 0s !important; transition-duration: 0s !important; transition-delay: 0s !important; } `, }); } /** Force le thème sombre sur le document */ async function ensureDarkTheme(page: import('@playwright/test').Page) { await page.evaluate(() => { document.documentElement.classList.add('dark'); document.documentElement.setAttribute('data-theme', 'dark'); }); await page.waitForTimeout(100); } /** Attend réseau inactif puis un court délai pour éviter skeletons / images en cours de chargement */ async function waitForStableNetwork(page: import('@playwright/test').Page) { await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {}); await page.waitForTimeout(NETWORK_IDLE_MS); } /** Locators des éléments dynamiques à masquer (dates, avatars, temps) */ async function getDynamicMasks(page: import('@playwright/test').Page): Promise { const candidates = [ page.locator('img[alt="Avatar"], img[alt*="avatar"]').first(), page.locator('[role="timer"]').first(), page.locator('time').first(), ]; const out: import('@playwright/test').Locator[] = []; for (const loc of candidates) { if ((await loc.count()) > 0) out.push(loc); } return out; } /** Écrit un screenshot dans visual-tests/current ou baselines */ async function saveScreenshot( page: import('@playwright/test').Page, name: string, options: { fullPage?: boolean; locator?: import('@playwright/test').Locator } = {} ) { fs.mkdirSync(visualOutputDir, { recursive: true }); const filePath = path.join(visualOutputDir, screenshotName(name)); const mask = await getDynamicMasks(page); const screenshotOpts = { path: filePath, mask: mask.length > 0 ? mask : undefined }; if (options.locator) { await options.locator.screenshot(screenshotOpts); } else { await page.screenshot({ fullPage: options.fullPage ?? true, ...screenshotOpts }); } test.info().attach(name, { path: filePath, contentType: 'image/png' }); } const SCREENS: Array<{ name: string; url: string; auth: boolean; full: boolean; locator?: string; }> = [ { name: 'login', url: '/login', auth: false, full: true }, { name: 'register', url: '/register', auth: false, full: true }, { name: 'dashboard', url: '/dashboard', auth: true, full: true }, { name: 'playlists', url: '/playlists', auth: true, full: true }, { name: 'library', url: '/library', auth: true, full: true }, { name: '404', url: '/non-existent-route-404', auth: false, full: true }, ]; const COMPONENT_CAPTURES: Array<{ name: string; url: string; locator: string }> = [ { name: 'sidebar', url: '/dashboard', locator: '[data-testid="app-sidebar"]' }, { name: 'header', url: '/dashboard', locator: 'header' }, { name: 'player', url: '/dashboard', locator: '[data-testid="global-player"]' }, ]; test.describe('Visual capture (complete)', () => { test.beforeEach(async ({ page }) => { await page.emulateMedia({ reducedMotion: 'reduce', colorScheme: 'dark' }); }); test.describe('Full-page screens (no auth)', () => { test.use({ storageState: { cookies: [], origins: [] } }); for (const screen of SCREENS.filter((s) => !s.auth)) { test(`${screen.name}`, async ({ page }) => { const url = BASE_URL.replace(/\/$/, '') + screen.url; await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 20000 }); await waitForStableNetwork(page); await page.waitForSelector('body', { timeout: 8000 }).catch(() => {}); await disableAnimations(page); await ensureDarkTheme(page); await page.waitForTimeout(ANIMATION_SETTLE_MS); await saveScreenshot(page, screen.name, { fullPage: true }); }); } }); test.describe('Full-page screens (authenticated)', () => { for (const screen of SCREENS.filter((s) => s.auth)) { test(`${screen.name}`, async ({ page }) => { const url = BASE_URL.replace(/\/$/, '') + screen.url; await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 20000 }); await waitForStableNetwork(page); await page.waitForSelector('body', { timeout: 8000 }).catch(() => {}); await disableAnimations(page); await ensureDarkTheme(page); await page.waitForTimeout(ANIMATION_SETTLE_MS); await saveScreenshot(page, screen.name, { fullPage: true }); }); } }); test.describe('Component screenshots (authenticated)', () => { for (const comp of COMPONENT_CAPTURES) { test(comp.name, async ({ page }) => { const url = BASE_URL.replace(/\/$/, '') + comp.url; await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 20000 }); await waitForStableNetwork(page); const loc = page.locator(comp.locator).first(); const visible = await loc.waitFor({ state: 'visible', timeout: 10000 }).then(() => true).catch(() => false); if (!visible) { test.skip(true, `${comp.name} locator not visible`); return; } await disableAnimations(page); await ensureDarkTheme(page); await page.waitForTimeout(ANIMATION_SETTLE_MS); const mask = await getDynamicMasks(page); fs.mkdirSync(visualOutputDir, { recursive: true }); const filePath = path.join(visualOutputDir, screenshotName(comp.name)); await loc.screenshot({ path: filePath, mask: mask.length > 0 ? mask : undefined }); test.info().attach(comp.name, { path: filePath, contentType: 'image/png' }); }); } }); });