#!/usr/bin/env node /** * Visual Regression Testing Script * * Compares current screenshots with baseline using pixelmatch * Reports visual differences: overlaps, font shifts, color mismatches * * Usage: node compare-screenshots.js [options] * Options: * --threshold 0.05 - Pixel difference threshold (default: 5%) * --baseline ./baseline - Baseline directory * --current ./current - Current screenshots directory * --diff ./diff - Diff output directory */ const fs = require('fs'); const path = require('path'); const { execSync } = require('child_process'); // Configuration const config = { baselineDir: process.env.BASELINE_DIR || './tests/visual/baseline', currentDir: process.env.CURRENT_DIR || './tests/visual/current', diffDir: process.env.DIFF_DIR || './tests/visual/diff', reportsDir: process.env.REPORTS_DIR || './tests/reports', threshold: parseFloat(process.env.PIXELMATCH_THRESHOLD || '0.05'), }; // Ensure directories exist [config.diffDir, config.reportsDir].forEach(dir => { if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } }); /** * Compare two PNG images using pixelmatch */ async function compareImages(baselinePath, currentPath, diffPath) { const pixelmatch = require('pixelmatch'); const PNG = require('pngjs').PNG; const baselineImg = PNG.sync.read(fs.readFileSync(baselinePath)); const currentImg = PNG.sync.read(fs.readFileSync(currentPath)); const { width, height } = baselineImg; // Check if sizes match if (width !== currentImg.width || height !== currentImg.height) { return { success: false, error: `Size mismatch: baseline ${width}x${height} vs current ${currentImg.width}x${currentImg.height}`, diffPixels: -1, totalPixels: width * height, }; } // Create diff image const diffImg = new PNG({ width, height }); // Compare const diffPixels = pixelmatch( baselineImg.data, currentImg.data, diffImg.data, width, height, { threshold: 0.1, // Pixel similarity threshold diffColor: [255, 0, 0], // Red for differences diffColorAlt: [255, 255, 0], // Yellow for anti-aliased } ); // Save diff image fs.writeFileSync(diffPath, PNG.sync.write(diffImg)); const diffPercent = (diffPixels / (width * height)) * 100; return { success: diffPercent <= (config.threshold * 100), diffPixels, totalPixels: width * height, diffPercent: diffPercent.toFixed(2), width, height, }; } /** * Detect specific visual issues */ function detectVisualIssues(baselinePath, currentPath) { // This would ideally use Playwright for element-level analysis // For now, return generic analysis return { potentialIssues: [ 'element_overlap', 'font_shift', 'color_mismatch', 'layout_break', ] }; } /** * Get all PNG files from a directory */ function getPNGFiles(dir) { if (!fs.existsSync(dir)) return []; return fs.readdirSync(dir) .filter(f => f.endsWith('.png')) .map(f => path.basename(f, '.png')); } /** * Main comparison function */ async function main() { console.log('=== Visual Regression Testing ===\n'); console.log(`Baseline: ${config.baselineDir}`); console.log(`Current: ${config.currentDir}`); console.log(`Diff: ${config.diffDir}`); console.log(`Threshold: ${config.threshold * 100}%\n`); const baselineFiles = getPNGFiles(config.baselineDir); const currentFiles = getPNGFiles(config.currentDir); const results = []; let passed = 0; let failed = 0; let missing = 0; // Check for missing baselines for (const file of currentFiles) { if (!baselineFiles.includes(file)) { console.log(`āš ļø New screenshot: ${file}`); missing++; results.push({ name: file, status: 'NEW', message: 'No baseline exists - will be created as baseline', }); } } // Compare existing baselines for (const file of baselineFiles) { const baselinePath = path.join(config.baselineDir, `${file}.png`); const currentPath = path.join(config.currentDir, `${file}.png`); const diffPath = path.join(config.diffDir, `${file}_diff.png`); if (!fs.existsSync(currentPath)) { console.log(`āŒ Missing: ${file}`); failed++; results.push({ name: file, status: 'MISSING', message: 'Current screenshot not found', }); continue; } try { console.log(`šŸ” Comparing: ${file}...`); const result = await compareImages(baselinePath, currentPath, diffPath); if (result.success) { console.log(`āœ… PASS: ${file} (${result.diffPercent}% diff)`); passed++; } else { console.log(`āŒ FAIL: ${file} (${result.diffPercent}% diff)`); console.log(` ${result.diffPixels} pixels changed of ${result.totalPixels}`); failed++; } results.push({ name: file, status: result.success ? 'PASS' : 'FAIL', diffPercent: result.diffPercent, diffPixels: result.diffPixels, totalPixels: result.totalPixels, width: result.width, height: result.height, diffPath: diffPath, }); } catch (error) { console.log(`āŒ ERROR: ${file} - ${error.message}`); failed++; results.push({ name: file, status: 'ERROR', message: error.message, }); } } // Generate report const report = { timestamp: new Date().toISOString(), threshold: config.threshold, summary: { total: baselineFiles.length, passed, failed, missing, newScreenshots: missing, }, results, }; const reportPath = path.join(config.reportsDir, 'visual-regression-report.json'); fs.writeFileSync(reportPath, JSON.stringify(report, null, 2)); console.log(`\nšŸ“Š Summary:`); console.log(` Total: ${baselineFiles.length}`); console.log(` āœ… Pass: ${passed}`); console.log(` āŒ Fail: ${failed}`); console.log(` āš ļø New: ${missing}`); console.log(`\nšŸ“„ Report saved to: ${reportPath}`); // Exit with error code if failures process.exit(failed > 0 ? 1 : 0); } main().catch(err => { console.error('Fatal error:', err); process.exit(1); });