#!/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()}
| Screen |
Diff pixels |
Total pixels |
Result |
Error |
Links |
${rows}
`;
}
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);
});