veza/apps/web/e2e/tests/visual/visual-regression.spec.ts
senke be7d7b02cc feat(e2e): Playwright + pixelmatch stack for pixel-perfect visual regression
- playwright.config.visual.ts: dedicated config, viewport 1280x720, Chromium only,
  snapshots in e2e/tests/visual/__snapshots__
- e2e/tests/visual/visual-regression.spec.ts: login, register, dashboard (full/header/sidebar),
  player bar, playlists, 404, mobile/tablet viewports; dark theme + reduceMotion
- scripts/visual-diff.js: optional pixelmatch script to generate diff image from two PNGs
- docs/VISUAL_TESTING_STRATEGY.md: strategy, commands, CI, workflow
- npm scripts: test:visual, test:visual:update, test:visual:report
- deps: pixelmatch, pngjs; @playwright/test aligned to 1.58.1
- baseline snapshots added for login, dashboard, playlists, 404, viewports

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-07 20:01:30 +01:00

167 lines
6 KiB
TypeScript

import { test, expect } from '@playwright/test';
import { TEST_CONFIG } from '../../utils/test-helpers';
/** Pixel-perfect visual regression: strict by default. Relax in CI if needed via VISUAL_MAX_DIFF_PIXELS. */
const MAX_DIFF_PIXELS = process.env.VISUAL_MAX_DIFF_PIXELS ? parseInt(process.env.VISUAL_MAX_DIFF_PIXELS, 10) : 0;
const ANIMATION_SETTLE_MS = 800;
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);
}
test.describe('Visual regression (pixel-perfect)', () => {
test.beforeEach(async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'reduce' });
});
test.describe('Auth pages (no storage)', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('login page', async ({ page }) => {
await page.goto(`${TEST_CONFIG.FRONTEND_URL}/login`);
await page.waitForLoadState('networkidle');
await page.waitForSelector('form', { timeout: 10000 });
await ensureDarkTheme(page);
await page.waitForTimeout(ANIMATION_SETTLE_MS);
await expect(page).toHaveScreenshot('login-page.png', {
fullPage: true,
maxDiffPixels: MAX_DIFF_PIXELS,
});
});
test('register page', async ({ page }) => {
await page.goto(`${TEST_CONFIG.FRONTEND_URL}/register`);
await page.waitForLoadState('networkidle');
await page.waitForSelector('form, [role="form"], input[type="email"]', { timeout: 15000 }).catch(() => {});
await ensureDarkTheme(page);
await page.waitForTimeout(ANIMATION_SETTLE_MS);
await expect(page).toHaveScreenshot('register-page.png', {
fullPage: true,
maxDiffPixels: MAX_DIFF_PIXELS,
});
});
});
test.describe('App shell (authenticated)', () => {
test('dashboard full page', async ({ page }) => {
await page.goto(`${TEST_CONFIG.FRONTEND_URL}/dashboard`);
await page.waitForLoadState('networkidle');
await page.waitForSelector('main, [role="main"]', { timeout: 15000 });
await ensureDarkTheme(page);
await page.waitForTimeout(ANIMATION_SETTLE_MS);
await expect(page).toHaveScreenshot('dashboard-full.png', {
fullPage: true,
maxDiffPixels: MAX_DIFF_PIXELS,
});
});
test('dashboard header only', async ({ page }) => {
await page.goto(`${TEST_CONFIG.FRONTEND_URL}/dashboard`);
await page.waitForLoadState('networkidle');
const header = page.locator('header').first();
await header.waitFor({ timeout: 10000 });
await ensureDarkTheme(page);
await page.waitForTimeout(ANIMATION_SETTLE_MS);
await expect(header).toHaveScreenshot('dashboard-header.png', {
maxDiffPixels: MAX_DIFF_PIXELS,
});
});
test('dashboard sidebar only', async ({ page }) => {
await page.goto(`${TEST_CONFIG.FRONTEND_URL}/dashboard`);
await page.waitForLoadState('networkidle');
const sidebar = page.locator('aside').first();
const visible = await sidebar.waitFor({ state: 'visible', timeout: 12000 }).then(() => true).catch(() => false);
if (!visible) {
test.skip(true, 'Sidebar not visible (e.g. not authenticated or mobile layout)');
return;
}
await ensureDarkTheme(page);
await page.waitForTimeout(ANIMATION_SETTLE_MS);
await expect(sidebar).toHaveScreenshot('dashboard-sidebar.png', {
maxDiffPixels: MAX_DIFF_PIXELS,
});
});
test('global player bar', async ({ page }) => {
await page.goto(`${TEST_CONFIG.FRONTEND_URL}/dashboard`);
await page.waitForLoadState('networkidle');
await ensureDarkTheme(page);
await page.waitForTimeout(ANIMATION_SETTLE_MS);
const playerBar = page.locator('div.fixed.bottom-0.left-0.right-0').first();
await playerBar.waitFor({ state: 'visible', timeout: 5000 }).catch(() => {});
if ((await playerBar.count()) === 0) {
test.skip();
return;
}
await expect(playerBar).toHaveScreenshot('player-bar.png', {
maxDiffPixels: MAX_DIFF_PIXELS,
});
});
});
test.describe('Key routes', () => {
test('playlists page', async ({ page }) => {
await page.goto(`${TEST_CONFIG.FRONTEND_URL}/playlists`);
await page.waitForLoadState('networkidle');
await page.waitForSelector('main, [role="main"]', { timeout: 10000 }).catch(() => {});
await ensureDarkTheme(page);
await page.waitForTimeout(ANIMATION_SETTLE_MS);
await expect(page).toHaveScreenshot('playlists-page.png', {
fullPage: true,
maxDiffPixels: MAX_DIFF_PIXELS,
});
});
test('404 page', async ({ page }) => {
await page.goto(`${TEST_CONFIG.FRONTEND_URL}/non-existent-route-404`);
await page.waitForLoadState('networkidle');
await ensureDarkTheme(page);
await page.waitForTimeout(ANIMATION_SETTLE_MS);
await expect(page).toHaveScreenshot('404-page.png', {
fullPage: true,
maxDiffPixels: MAX_DIFF_PIXELS,
});
});
});
test.describe('Viewports', () => {
test('dashboard mobile 375x667', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 });
await page.goto(`${TEST_CONFIG.FRONTEND_URL}/dashboard`);
await page.waitForLoadState('networkidle');
await page.waitForTimeout(ANIMATION_SETTLE_MS);
await ensureDarkTheme(page);
await expect(page).toHaveScreenshot('dashboard-mobile.png', {
fullPage: true,
maxDiffPixels: MAX_DIFF_PIXELS,
});
});
test('dashboard tablet 768x1024', async ({ page }) => {
await page.setViewportSize({ width: 768, height: 1024 });
await page.goto(`${TEST_CONFIG.FRONTEND_URL}/dashboard`);
await page.waitForLoadState('networkidle');
await page.waitForTimeout(ANIMATION_SETTLE_MS);
await ensureDarkTheme(page);
await expect(page).toHaveScreenshot('dashboard-tablet.png', {
fullPage: true,
maxDiffPixels: MAX_DIFF_PIXELS,
});
});
});
});