veza/apps/web/scripts/compare-visual.mjs

190 lines
5.8 KiB
JavaScript
Raw Normal View History

#!/usr/bin/env node
/**
* Compare visual-tests/baselines/ vs visual-tests/current/ using pixelmatch.
* Writes diffs to visual-tests/diffs/ and report to visual-tests/reports/.
*
* Usage: node scripts/compare-visual.mjs [threshold]
* threshold Pixelmatch threshold 01 (default: VISUAL_DIFF_THRESHOLD or 0.1)
*
* Exit code: 0 if no diffs exceed maxDiffPixels (env VISUAL_MAX_DIFF_PIXELS, default 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.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 diff 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 diff 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() {
const threshold = parseFloat(process.argv[2] || process.env.VISUAL_DIFF_THRESHOLD || '0.1', 10);
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/');
process.exit(0);
}
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 });
fs.writeFileSync(
path.join(REPORTS, 'results.json'),
JSON.stringify({ threshold, maxDiffPixels: MAX_DIFF_PIXELS, results }, null, 2)
);
fs.writeFileSync(path.join(REPORTS, 'index.html'), generateHtmlReport(results, threshold));
console.log('Report:', path.join(REPORTS, 'index.html'));
const failed = results.filter((r) => !r.pass);
process.exit(failed.length > 0 ? 1 : 0);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});