#!/usr/bin/env node /** * Capture visual baseline or current screenshots for pixel-accurate regression. * Usage: * node scripts/capture-visual-baseline.mjs [--baseline] * --baseline Write to visual-tests/baselines/ (default: visual-tests/current/) * * Requires: dev server running at baseURL (default http://localhost:5173). * Set PLAYWRIGHT_BASE_URL or VITE_FRONTEND_URL to override. * * Viewport: 1280×720, dark theme, reduced motion for stability. */ import { chromium } from 'playwright'; import path from 'path'; import fs from 'fs'; import { fileURLToPath } from 'url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(__dirname, '..'); const BASE_URL = process.env.PLAYWRIGHT_BASE_URL || process.env.VITE_FRONTEND_URL || 'http://localhost:5173'; const OUT_DIR = process.argv.includes('--baseline') ? path.join(ROOT, 'visual-tests', 'baselines') : path.join(ROOT, 'visual-tests', 'current'); const VIEWPORT = { width: 1280, height: 720 }; const ANIMATION_SETTLE_MS = 800; function ensureDarkTheme(page) { return page.evaluate(() => { document.documentElement.classList.add('dark'); document.documentElement.setAttribute('data-theme', 'dark'); }); } async function waitSettle(page, ms = ANIMATION_SETTLE_MS) { await page.waitForTimeout(ms); } async function captureFull(page, name) { await ensureDarkTheme(page); await waitSettle(page); const file = path.join(OUT_DIR, `${name}-desktop.png`); fs.mkdirSync(OUT_DIR, { recursive: true }); await page.screenshot({ path: file, fullPage: true }); console.log(' captured:', file); } async function captureLocator(page, locator, name) { await ensureDarkTheme(page); await waitSettle(page); const file = path.join(OUT_DIR, `${name}-desktop.png`); fs.mkdirSync(OUT_DIR, { recursive: true }); await locator.screenshot({ path: file }); console.log(' captured:', file); } const CRITICAL_SCREENS = [ { 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: 'sidebar', url: '/dashboard', auth: true, locator: 'aside.fixed' }, { 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 }, ]; async function main() { console.log('Capture target:', OUT_DIR); console.log('Base URL:', BASE_URL); const browser = await chromium.launch({ headless: true }); const storageStatePath = path.join(ROOT, 'e2e', '.auth', 'user.json'); const hasAuth = fs.existsSync(storageStatePath); const contextOptions = { viewport: VIEWPORT, deviceScaleFactor: 1, locale: 'en-US', reducedMotion: 'reduce', }; if (hasAuth) { try { contextOptions.storageState = storageStatePath; } catch (e) { console.warn('Could not set auth state:', e.message); } } else { console.warn('No e2e/.auth/user.json — authenticated screens may redirect to login.'); } const context = await browser.newContext(contextOptions); const page = await context.newPage(); for (const screen of CRITICAL_SCREENS) { if (screen.auth && !hasAuth) { console.log('Skip (auth required):', screen.name); continue; } if (!screen.auth) { await context.clearCookies(); } const fullUrl = BASE_URL.replace(/\/$/, '') + screen.url; console.log('Open:', screen.name, fullUrl); await page.goto(fullUrl, { waitUntil: 'networkidle', timeout: 20000 }).catch(() => {}); if (screen.locator) { const el = page.locator(screen.locator).first(); const visible = await el.waitFor({ state: 'visible', timeout: 8000 }).then(() => true).catch(() => false); if (visible) { await captureLocator(page, el, screen.name); } else { console.log(' skipped: locator not visible'); } } else { await page.waitForSelector('body', { timeout: 5000 }).catch(() => {}); await captureFull(page, screen.name); } } await browser.close(); console.log('Done.'); } main().catch((err) => { console.error(err); process.exit(1); });