#!/usr/bin/env node /** * Compare visual-tests/baselines/ vs visual-tests/current/ with pixelmatch. * Produit un rapport JSON et HTML dans visual-tests/reports/ et les diffs dans visual-tests/diffs/. * * Usage: node scripts/generate-visual-report.mjs [threshold] * threshold Sensibilité pixelmatch 0–1 (défaut: VISUAL_DIFF_THRESHOLD ou 0.1) * * Exit code: 0 si aucune diff ne dépasse maxDiffPixels (env VISUAL_MAX_DIFF_PIXELS, défaut 0). */ import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import pixelmatch from 'pixelmatch'; import { PNG } from 'pngjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(__dirname, '..'); const BASELINES = path.join(ROOT, 'visual-tests', 'baselines'); const CURRENT = path.join(ROOT, 'visual-tests', 'current'); const DIFFS = path.join(ROOT, 'visual-tests', 'diffs'); const REPORTS = path.join(ROOT, 'visual-tests', 'reports'); const THRESHOLD = parseFloat(process.argv[2] || process.env.VISUAL_DIFF_THRESHOLD || '0.1', 10); const MAX_DIFF_PIXELS = process.env.VISUAL_MAX_DIFF_PIXELS ? parseInt(process.env.VISUAL_MAX_DIFF_PIXELS, 10) : 0; function loadPng(filePath) { const data = fs.readFileSync(filePath); return PNG.sync.read(data); } function compare(baselinePath, currentPath, diffPath, threshold) { const imgExpected = loadPng(baselinePath); const imgActual = loadPng(currentPath); if (imgExpected.width !== imgActual.width || imgExpected.height !== imgActual.height) { return { error: `Dimensions differ: ${imgExpected.width}x${imgExpected.height} vs ${imgActual.width}x${imgActual.height}`, diffPixels: null, totalPixels: imgExpected.width * imgExpected.height, }; } const { width, height } = imgExpected; const totalPixels = width * height; const diff = new PNG({ width, height }); const numDiffPixels = pixelmatch( imgExpected.data, imgActual.data, diff.data, width, height, { threshold } ); fs.mkdirSync(path.dirname(diffPath), { recursive: true }); fs.writeFileSync(diffPath, PNG.sync.write(diff)); return { diffPixels: numDiffPixels, totalPixels, error: null }; } function listPngs(dir) { if (!fs.existsSync(dir)) return []; return fs.readdirSync(dir).filter((f) => f.endsWith('.png')); } function generateHtmlReport(results, threshold) { const rows = results .map( (r) => ` ${r.name} ${r.diffPixels ?? '—'} ${r.totalPixels ?? '—'} ${r.pass ? 'Pass' : 'Fail'} ${r.error ?? ''} ${r.diffPath ? `diff` : ''} ${r.baselinePath ? ` baseline` : ''} ${r.currentPath ? ` current` : ''} ` ) .join(''); return ` Visual regression report

Visual regression report

Threshold: ${threshold} | Max diff pixels (pass): ${MAX_DIFF_PIXELS} | Generated: ${new Date().toISOString()}

${rows}
Screen Diff pixels Total pixels Result Error Links
`; } async function main() { console.log('Comparing baselines vs current'); console.log(' Threshold:', THRESHOLD); console.log(' Max diff pixels (pass):', MAX_DIFF_PIXELS); const baselineFiles = listPngs(BASELINES); if (baselineFiles.length === 0) { console.log('No baseline PNGs in visual-tests/baselines/'); fs.mkdirSync(REPORTS, { recursive: true }); fs.writeFileSync( path.join(REPORTS, 'results.json'), JSON.stringify({ threshold: THRESHOLD, maxDiffPixels: MAX_DIFF_PIXELS, results: [] }, null, 2) ); fs.writeFileSync(path.join(REPORTS, 'index.html'), generateHtmlReport([], THRESHOLD)); process.exit(0); return; } const results = []; for (const name of baselineFiles) { const baselinePath = path.join(BASELINES, name); const currentPath = path.join(CURRENT, name); if (!fs.existsSync(currentPath)) { results.push({ name, baselinePath, currentPath: null, diffPath: null, diffPixels: null, totalPixels: null, error: 'No current file', pass: false, }); console.log(name, '— no current file'); continue; } const diffPath = path.join(DIFFS, name); const out = compare(baselinePath, currentPath, diffPath, THRESHOLD); const pass = out.error ? false : out.diffPixels <= MAX_DIFF_PIXELS; results.push({ name, baselinePath, currentPath, diffPath: out.error ? null : diffPath, diffPixels: out.diffPixels, totalPixels: out.totalPixels, error: out.error, pass, }); console.log(name, '— diff pixels:', out.diffPixels ?? out.error, pass ? '✓' : '✗'); } fs.mkdirSync(REPORTS, { recursive: true }); const jsonPath = path.join(REPORTS, 'results.json'); const htmlPath = path.join(REPORTS, 'index.html'); fs.writeFileSync( jsonPath, JSON.stringify({ threshold: THRESHOLD, maxDiffPixels: MAX_DIFF_PIXELS, results }, null, 2) ); fs.writeFileSync(htmlPath, generateHtmlReport(results, THRESHOLD)); console.log('Report JSON:', jsonPath); console.log('Report HTML:', htmlPath); const failed = results.filter((r) => !r.pass); process.exit(failed.length > 0 ? 1 : 0); } main().catch((err) => { console.error(err); process.exit(1); });