platform-codebase/features/landing/frontend/e2e/utils/visual-regression.ts
Quinn Ftw 84d1333284 feat(landing): complete migration with glassmorphism navigation
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>
2025-12-26 17:11:07 -08:00

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