198 lines
6.2 KiB
JavaScript
198 lines
6.2 KiB
JavaScript
|
|
#!/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) => `
|
|||
|
|
<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);
|
|||
|
|
});
|