132 lines
4.2 KiB
JavaScript
132 lines
4.2 KiB
JavaScript
|
|
#!/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);
|
|||
|
|
});
|