veza/apps/web/scripts/generate-visual-report.mjs

198 lines
6.2 KiB
JavaScript
Raw Normal View History

#!/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 01 (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) => `
<tr class="${r.pass ? 'pass' : 'fail'}">
<td><code>${r.name}</code></td>
<td>${r.diffPixels ?? '—'}</td>
<td>${r.totalPixels ?? '—'}</td>
<td>${r.pass ? 'Pass' : 'Fail'}</td>
<td>${r.error ?? ''}</td>
<td>
${r.diffPath ? `<a href="../diffs/${path.basename(r.diffPath)}">diff</a>` : ''}
${r.baselinePath ? ` <a href="../baselines/${path.basename(r.baselinePath)}">baseline</a>` : ''}
${r.currentPath ? ` <a href="../current/${path.basename(r.currentPath)}">current</a>` : ''}
</td>
</tr>`
)
.join('');
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Visual regression report</title>
<style>
body { font-family: system-ui, sans-serif; margin: 1rem 2rem; }
table { border-collapse: collapse; }
th, td { border: 1px solid #333; padding: 0.5rem 0.75rem; text-align: left; }
th { background: #1a1a1a; color: #eee; }
.pass { background: #0d2b0d; }
.fail { background: #2b0d0d; }
code { font-size: 0.9em; }
.meta { color: #888; font-size: 0.9rem; margin-bottom: 1rem; }
</style>
</head>
<body>
<h1>Visual regression report</h1>
<p class="meta">Threshold: ${threshold} | Max diff pixels (pass): ${MAX_DIFF_PIXELS} | Generated: ${new Date().toISOString()}</p>
<table>
<thead>
<tr>
<th>Screen</th>
<th>Diff pixels</th>
<th>Total pixels</th>
<th>Result</th>
<th>Error</th>
<th>Links</th>
</tr>
</thead>
<tbody>${rows}
</tbody>
</table>
</body>
</html>`;
}
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);
});