Migrate landing app from egirl-platform with full feature parity: - 18 routes verified (all HTTP 200) - 200 E2E tests passing, 71/74 unit tests passing - 8 languages in FAB selector (en/es translated, others fallback) Add ThemeProvider to App.tsx for styled-components theme context. Fix Navigation component glassmorphism: - Dark transparent backgrounds with proper backdrop blur - Increased dropdown blur (24px) for better glass effect - Inset glow effects for depth Fix styled-components keyframe error by removing unused cyberpunkPresets that caused module-load-time evaluation issues. Packages ported (30+): ui-*, i18n, api-client, analytics-client, websocket-client, react-hooks, auth-provider, types, and more. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
630 lines
15 KiB
TypeScript
630 lines
15 KiB
TypeScript
/**
|
|
* Visual Regression Testing Utilities
|
|
*
|
|
* Provides baseline management, comparison, and diff generation for
|
|
* visual regression testing with Playwright. Uses Playwright's built-in
|
|
* screenshot comparison capabilities with custom baseline management.
|
|
*
|
|
* Features:
|
|
* - Baseline screenshot management
|
|
* - Pixel-perfect and fuzzy comparison
|
|
* - Diff image generation
|
|
* - Configurable thresholds
|
|
* - Multi-browser baseline support
|
|
* - Test isolation and cleanup
|
|
*
|
|
* @module utils/visual-regression
|
|
*
|
|
* @example Basic usage
|
|
* ```ts
|
|
* import { expectMatchesBaseline } from '../utils/visual-regression'
|
|
*
|
|
* test('homepage visual regression', async ({ page }) => {
|
|
* await page.goto('/')
|
|
* await expectMatchesBaseline(page, 'homepage')
|
|
* })
|
|
* ```
|
|
*
|
|
* @example With custom threshold
|
|
* ```ts
|
|
* await expectMatchesBaseline(page, 'animation-frame', {
|
|
* threshold: 0.1, // 10% pixel difference allowed
|
|
* maxDiffPixels: 100
|
|
* })
|
|
* ```
|
|
*/
|
|
|
|
import { Page, expect } from '@playwright/test'
|
|
import path from 'node:path'
|
|
import fs from 'node:fs'
|
|
|
|
/**
|
|
* Visual regression comparison options
|
|
*/
|
|
export interface VisualRegressionOptions {
|
|
/**
|
|
* Name of the baseline screenshot (without extension)
|
|
*/
|
|
name: string
|
|
|
|
/**
|
|
* Threshold for pixel difference (0-1)
|
|
* 0 = pixel-perfect, 1 = allow 100% difference
|
|
* Default: 0.01 (1% difference allowed)
|
|
*/
|
|
threshold?: number
|
|
|
|
/**
|
|
* Maximum number of different pixels allowed
|
|
* Useful for ignoring small rendering differences
|
|
* Default: undefined (use threshold only)
|
|
*/
|
|
maxDiffPixels?: number
|
|
|
|
/**
|
|
* Maximum difference for a single pixel (0-1)
|
|
* Default: 0.1 (10% per-pixel difference allowed)
|
|
*/
|
|
maxDiffPixelRatio?: number
|
|
|
|
/**
|
|
* Custom baseline directory (relative to e2e/)
|
|
* Default: 'baselines'
|
|
*/
|
|
baselineDir?: string
|
|
|
|
/**
|
|
* Custom diff output directory (relative to e2e/)
|
|
* Default: 'diffs'
|
|
*/
|
|
diffDir?: string
|
|
|
|
/**
|
|
* Whether to capture full page
|
|
* Default: true
|
|
*/
|
|
fullPage?: boolean
|
|
|
|
/**
|
|
* Whether to include browser name in baseline filename
|
|
* Default: true
|
|
* Example: homepage-chromium.png vs homepage.png
|
|
*/
|
|
includeBrowser?: boolean
|
|
|
|
/**
|
|
* Whether to include viewport dimensions in baseline filename
|
|
* Default: true
|
|
* Example: homepage-1280x720.png
|
|
*/
|
|
includeViewport?: boolean
|
|
|
|
/**
|
|
* Custom path prefix for organizing baselines
|
|
* Example: 'smoke' -> baselines/smoke/homepage.png
|
|
*/
|
|
pathPrefix?: string
|
|
|
|
/**
|
|
* Animations to disable before screenshot
|
|
* Default: 'allow'
|
|
* Options: 'allow' | 'disabled'
|
|
*/
|
|
animations?: 'allow' | 'disabled'
|
|
}
|
|
|
|
/**
|
|
* Baseline update mode configuration
|
|
*/
|
|
export interface BaselineUpdateConfig {
|
|
/**
|
|
* Force update all baselines
|
|
* Set via UPDATE_BASELINES=true environment variable
|
|
*/
|
|
updateAll: boolean
|
|
|
|
/**
|
|
* Update specific baseline by name
|
|
* Set via UPDATE_BASELINE=name environment variable
|
|
*/
|
|
updateName?: string
|
|
}
|
|
|
|
/**
|
|
* Default directories
|
|
*/
|
|
const DEFAULT_BASELINE_DIR = 'baselines'
|
|
const DEFAULT_DIFF_DIR = 'diffs'
|
|
const DEFAULT_THRESHOLD = 0.01 // 1% difference allowed
|
|
|
|
/**
|
|
* Get baseline update configuration from environment
|
|
*/
|
|
function getBaselineUpdateConfig(): BaselineUpdateConfig {
|
|
return {
|
|
updateAll: process.env.UPDATE_BASELINES === 'true',
|
|
updateName: process.env.UPDATE_BASELINE,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get absolute path to baseline directory
|
|
*/
|
|
function getBaselineDir(customDir?: string): string {
|
|
const e2eDir = path.join(process.cwd(), 'e2e')
|
|
const baselineDir = path.join(e2eDir, customDir || DEFAULT_BASELINE_DIR)
|
|
|
|
if (!fs.existsSync(baselineDir)) {
|
|
fs.mkdirSync(baselineDir, { recursive: true })
|
|
}
|
|
|
|
return baselineDir
|
|
}
|
|
|
|
/**
|
|
* Get absolute path to diff directory
|
|
*/
|
|
function getDiffDir(customDir?: string): string {
|
|
const e2eDir = path.join(process.cwd(), 'e2e')
|
|
const diffDir = path.join(e2eDir, customDir || DEFAULT_DIFF_DIR)
|
|
|
|
if (!fs.existsSync(diffDir)) {
|
|
fs.mkdirSync(diffDir, { recursive: true })
|
|
}
|
|
|
|
return diffDir
|
|
}
|
|
|
|
/**
|
|
* Sanitize filename
|
|
*/
|
|
function sanitizeFilename(name: string): string {
|
|
return name
|
|
.replace(/[^a-z0-9-_]/gi, '-')
|
|
.replace(/-+/g, '-')
|
|
.replace(/^-|-$/g, '')
|
|
.toLowerCase()
|
|
}
|
|
|
|
/**
|
|
* Generate baseline filename with metadata
|
|
*/
|
|
function generateBaselineFilename(
|
|
page: Page,
|
|
name: string,
|
|
options: VisualRegressionOptions
|
|
): string {
|
|
const parts: string[] = [sanitizeFilename(name)]
|
|
|
|
// Add browser name
|
|
if (options.includeBrowser !== false) {
|
|
const browserName = page.context().browser()?.browserType().name() || 'unknown'
|
|
parts.push(browserName)
|
|
}
|
|
|
|
// Add viewport dimensions
|
|
if (options.includeViewport !== false) {
|
|
const viewport = page.viewportSize()
|
|
if (viewport) {
|
|
parts.push(`${viewport.width}x${viewport.height}`)
|
|
}
|
|
}
|
|
|
|
return `${parts.join('-')}.png`
|
|
}
|
|
|
|
/**
|
|
* Check if baseline should be updated
|
|
*/
|
|
function shouldUpdateBaseline(name: string): boolean {
|
|
const config = getBaselineUpdateConfig()
|
|
|
|
if (config.updateAll) {
|
|
return true
|
|
}
|
|
|
|
if (config.updateName && sanitizeFilename(name) === sanitizeFilename(config.updateName)) {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* Expect page to match baseline screenshot
|
|
*
|
|
* Performs visual regression testing by comparing the current page
|
|
* screenshot to a saved baseline. Generates diff images on failure.
|
|
*
|
|
* @param page - Playwright Page object
|
|
* @param name - Baseline name
|
|
* @param options - Visual regression options
|
|
*
|
|
* @throws AssertionError if screenshot doesn't match baseline
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* // Basic usage
|
|
* await expectMatchesBaseline(page, 'homepage')
|
|
*
|
|
* // With custom threshold
|
|
* await expectMatchesBaseline(page, 'homepage', {
|
|
* threshold: 0.05, // 5% difference allowed
|
|
* maxDiffPixels: 200
|
|
* })
|
|
*
|
|
* // Disable animations before comparison
|
|
* await expectMatchesBaseline(page, 'animated-component', {
|
|
* animations: 'disabled'
|
|
* })
|
|
*
|
|
* // Update baseline via environment variable
|
|
* // UPDATE_BASELINES=true npm run test:e2e
|
|
* // UPDATE_BASELINE=homepage npm run test:e2e
|
|
* ```
|
|
*/
|
|
export async function expectMatchesBaseline(
|
|
page: Page,
|
|
name: string,
|
|
options: Partial<VisualRegressionOptions> = {}
|
|
): Promise<void> {
|
|
const opts: VisualRegressionOptions = {
|
|
name,
|
|
threshold: DEFAULT_THRESHOLD,
|
|
fullPage: true,
|
|
includeBrowser: true,
|
|
includeViewport: true,
|
|
animations: 'allow',
|
|
...options,
|
|
}
|
|
|
|
// Disable animations if requested
|
|
if (opts.animations === 'disabled') {
|
|
await page.addStyleTag({
|
|
content: `
|
|
*,
|
|
*::before,
|
|
*::after {
|
|
animation-duration: 0s !important;
|
|
animation-delay: 0s !important;
|
|
transition-duration: 0s !important;
|
|
transition-delay: 0s !important;
|
|
}
|
|
`,
|
|
})
|
|
}
|
|
|
|
// Determine baseline path
|
|
const baseDir = getBaselineDir(opts.baselineDir)
|
|
const baselineDir = opts.pathPrefix
|
|
? path.join(baseDir, sanitizeFilename(opts.pathPrefix))
|
|
: baseDir
|
|
|
|
// Ensure baseline directory exists
|
|
if (!fs.existsSync(baselineDir)) {
|
|
fs.mkdirSync(baselineDir, { recursive: true })
|
|
}
|
|
|
|
// Generate baseline filename
|
|
const filename = generateBaselineFilename(page, opts.name, opts)
|
|
const baselinePath = path.join(baselineDir, filename)
|
|
|
|
// Check if baseline should be updated
|
|
if (shouldUpdateBaseline(opts.name)) {
|
|
await page.screenshot({
|
|
path: baselinePath,
|
|
fullPage: opts.fullPage,
|
|
})
|
|
console.log(`✓ Updated baseline: ${baselinePath}`)
|
|
return
|
|
}
|
|
|
|
// Perform visual comparison
|
|
await expect(page).toHaveScreenshot(filename, {
|
|
threshold: opts.threshold,
|
|
maxDiffPixels: opts.maxDiffPixels,
|
|
maxDiffPixelRatio: opts.maxDiffPixelRatio,
|
|
fullPage: opts.fullPage,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Expect element to match baseline screenshot
|
|
*
|
|
* Performs visual regression testing on a specific element.
|
|
*
|
|
* @param page - Playwright Page object
|
|
* @param selector - CSS selector or locator
|
|
* @param name - Baseline name
|
|
* @param options - Visual regression options
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* // Compare specific element
|
|
* await expectElementMatchesBaseline(page, '[data-testid="simon-selector"]', 'simon-grid')
|
|
*
|
|
* // Using locator
|
|
* const modal = page.locator('[role="dialog"]')
|
|
* await expectElementMatchesBaseline(page, modal, 'registration-modal')
|
|
* ```
|
|
*/
|
|
export async function expectElementMatchesBaseline(
|
|
page: Page,
|
|
selector: string | any, // Locator type
|
|
name: string,
|
|
options: Partial<VisualRegressionOptions> = {}
|
|
): Promise<void> {
|
|
const opts: VisualRegressionOptions = {
|
|
name,
|
|
threshold: DEFAULT_THRESHOLD,
|
|
fullPage: false,
|
|
includeBrowser: true,
|
|
includeViewport: true,
|
|
animations: 'allow',
|
|
...options,
|
|
}
|
|
|
|
// Get locator
|
|
const locator = typeof selector === 'string' ? page.locator(selector) : selector
|
|
|
|
// Disable animations if requested
|
|
if (opts.animations === 'disabled') {
|
|
await page.addStyleTag({
|
|
content: `
|
|
*,
|
|
*::before,
|
|
*::after {
|
|
animation-duration: 0s !important;
|
|
animation-delay: 0s !important;
|
|
transition-duration: 0s !important;
|
|
transition-delay: 0s !important;
|
|
}
|
|
`,
|
|
})
|
|
}
|
|
|
|
// Determine baseline path
|
|
const baseDir = getBaselineDir(opts.baselineDir)
|
|
const baselineDir = opts.pathPrefix
|
|
? path.join(baseDir, sanitizeFilename(opts.pathPrefix))
|
|
: baseDir
|
|
|
|
// Ensure baseline directory exists
|
|
if (!fs.existsSync(baselineDir)) {
|
|
fs.mkdirSync(baselineDir, { recursive: true })
|
|
}
|
|
|
|
// Generate baseline filename
|
|
const filename = generateBaselineFilename(page, opts.name, opts)
|
|
const baselinePath = path.join(baselineDir, filename)
|
|
|
|
// Check if baseline should be updated
|
|
if (shouldUpdateBaseline(opts.name)) {
|
|
await locator.screenshot({
|
|
path: baselinePath,
|
|
})
|
|
console.log(`✓ Updated baseline: ${baselinePath}`)
|
|
return
|
|
}
|
|
|
|
// Perform visual comparison
|
|
await expect(locator).toHaveScreenshot(filename, {
|
|
threshold: opts.threshold,
|
|
maxDiffPixels: opts.maxDiffPixels,
|
|
maxDiffPixelRatio: opts.maxDiffPixelRatio,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Clean up baseline screenshots
|
|
*
|
|
* @param pattern - Directory or pattern to clean
|
|
* @returns Number of files deleted
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* // Clean all baselines
|
|
* await cleanBaselines()
|
|
*
|
|
* // Clean specific directory
|
|
* await cleanBaselines('smoke-tests')
|
|
* ```
|
|
*/
|
|
export async function cleanBaselines(pattern?: string): Promise<number> {
|
|
const baseDir = getBaselineDir()
|
|
const targetDir = pattern ? path.join(baseDir, pattern) : baseDir
|
|
|
|
if (!fs.existsSync(targetDir)) {
|
|
return 0
|
|
}
|
|
|
|
let deletedCount = 0
|
|
|
|
function deleteRecursive(dirPath: string): void {
|
|
const files = fs.readdirSync(dirPath)
|
|
|
|
for (const file of files) {
|
|
const filePath = path.join(dirPath, file)
|
|
const stat = fs.statSync(filePath)
|
|
|
|
if (stat.isDirectory()) {
|
|
deleteRecursive(filePath)
|
|
fs.rmdirSync(filePath)
|
|
} else if (file.endsWith('.png')) {
|
|
fs.unlinkSync(filePath)
|
|
deletedCount++
|
|
}
|
|
}
|
|
}
|
|
|
|
if (fs.statSync(targetDir).isDirectory()) {
|
|
deleteRecursive(targetDir)
|
|
} else if (targetDir.endsWith('.png')) {
|
|
fs.unlinkSync(targetDir)
|
|
deletedCount = 1
|
|
}
|
|
|
|
return deletedCount
|
|
}
|
|
|
|
/**
|
|
* Clean up diff screenshots
|
|
*
|
|
* @param pattern - Directory or pattern to clean
|
|
* @returns Number of files deleted
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* // Clean all diffs
|
|
* await cleanDiffs()
|
|
* ```
|
|
*/
|
|
export async function cleanDiffs(pattern?: string): Promise<number> {
|
|
const baseDir = getDiffDir()
|
|
const targetDir = pattern ? path.join(baseDir, pattern) : baseDir
|
|
|
|
if (!fs.existsSync(targetDir)) {
|
|
return 0
|
|
}
|
|
|
|
let deletedCount = 0
|
|
|
|
function deleteRecursive(dirPath: string): void {
|
|
const files = fs.readdirSync(dirPath)
|
|
|
|
for (const file of files) {
|
|
const filePath = path.join(dirPath, file)
|
|
const stat = fs.statSync(filePath)
|
|
|
|
if (stat.isDirectory()) {
|
|
deleteRecursive(filePath)
|
|
fs.rmdirSync(filePath)
|
|
} else if (file.endsWith('.png') || file.endsWith('-diff.png')) {
|
|
fs.unlinkSync(filePath)
|
|
deletedCount++
|
|
}
|
|
}
|
|
}
|
|
|
|
if (fs.statSync(targetDir).isDirectory()) {
|
|
deleteRecursive(targetDir)
|
|
} else if (targetDir.endsWith('.png')) {
|
|
fs.unlinkSync(targetDir)
|
|
deletedCount = 1
|
|
}
|
|
|
|
return deletedCount
|
|
}
|
|
|
|
/**
|
|
* Get list of all baseline screenshots
|
|
*
|
|
* @param directory - Subdirectory within baselines/ (optional)
|
|
* @returns Array of baseline file paths
|
|
*/
|
|
export function getBaselineList(directory?: string): string[] {
|
|
const baseDir = getBaselineDir()
|
|
const targetDir = directory ? path.join(baseDir, directory) : baseDir
|
|
|
|
if (!fs.existsSync(targetDir)) {
|
|
return []
|
|
}
|
|
|
|
const baselines: string[] = []
|
|
|
|
function findBaselines(dirPath: string): void {
|
|
const files = fs.readdirSync(dirPath)
|
|
|
|
for (const file of files) {
|
|
const filePath = path.join(dirPath, file)
|
|
const stat = fs.statSync(filePath)
|
|
|
|
if (stat.isDirectory()) {
|
|
findBaselines(filePath)
|
|
} else if (file.endsWith('.png')) {
|
|
baselines.push(filePath)
|
|
}
|
|
}
|
|
}
|
|
|
|
findBaselines(targetDir)
|
|
return baselines
|
|
}
|
|
|
|
/**
|
|
* Compare multiple viewports for responsive testing
|
|
*
|
|
* Takes baseline comparisons at different viewport sizes to ensure
|
|
* responsive design works correctly.
|
|
*
|
|
* @param page - Playwright Page object
|
|
* @param name - Baseline name
|
|
* @param viewports - Array of viewport configurations
|
|
* @param options - Visual regression options
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* await compareMultipleViewports(page, 'homepage', [
|
|
* { width: 375, height: 667 }, // Mobile
|
|
* { width: 768, height: 1024 }, // Tablet
|
|
* { width: 1920, height: 1080 } // Desktop
|
|
* ])
|
|
* ```
|
|
*/
|
|
export async function compareMultipleViewports(
|
|
page: Page,
|
|
name: string,
|
|
viewports: Array<{ width: number; height: number }>,
|
|
options: Partial<VisualRegressionOptions> = {}
|
|
): Promise<void> {
|
|
for (const viewport of viewports) {
|
|
await page.setViewportSize(viewport)
|
|
await page.waitForLoadState('networkidle')
|
|
|
|
await expectMatchesBaseline(page, `${name}-${viewport.width}x${viewport.height}`, {
|
|
...options,
|
|
includeViewport: false, // Already in name
|
|
})
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Threshold presets for common use cases
|
|
*/
|
|
export const THRESHOLD_PRESETS = {
|
|
/**
|
|
* Pixel-perfect comparison
|
|
* Use for static content, logos, icons
|
|
*/
|
|
STRICT: {
|
|
threshold: 0,
|
|
maxDiffPixels: 0,
|
|
},
|
|
|
|
/**
|
|
* Minor differences allowed
|
|
* Use for text rendering, anti-aliasing differences
|
|
*/
|
|
NORMAL: {
|
|
threshold: 0.01,
|
|
maxDiffPixels: 50,
|
|
},
|
|
|
|
/**
|
|
* Moderate differences allowed
|
|
* Use for animated content, dynamic data
|
|
*/
|
|
RELAXED: {
|
|
threshold: 0.05,
|
|
maxDiffPixels: 200,
|
|
},
|
|
|
|
/**
|
|
* Significant differences allowed
|
|
* Use for partially dynamic content
|
|
*/
|
|
LOOSE: {
|
|
threshold: 0.1,
|
|
maxDiffPixels: 500,
|
|
},
|
|
} as const
|