Files
dashfront/test/compare.mjs
2026-10-08 04:51:28 +02:00

277 lines
8.8 KiB
JavaScript

/**
* Compare current screenshots against the reference set with very low tolerance.
*
* Usage:
* node test/compare.mjs [--ref test/screenshots/reference] [--cur test/screenshots/current]
* [--tolerance 0.001] [--diff test/screenshots/diff]
*
* Pure Node.js PNG decoder + comparator (no npm deps): parses PNG (8-bit
* RGB/RGBA, non-interlaced — what Chrome headless writes), compares pixels
* with per-channel tolerance, fails when the fraction of differing pixels
* exceeds --tolerance (default 0.1%).
*
* Exit 0 when all match, 1 with a per-file report otherwise. Writes red-tinted
* diff PNGs for mismatches when sizes are equal.
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import zlib from 'node:zlib';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const FRONT_ROOT = path.resolve(__dirname, '..');
const args = Object.fromEntries(
process.argv.slice(2).map((a) => {
const [k, v] = a.split('=');
return [k.replace(/^--/, ''), v ?? true];
}),
);
const REF_DIR = path.resolve(FRONT_ROOT, args.ref ?? 'test/screenshots/reference');
const CUR_DIR = path.resolve(FRONT_ROOT, args.cur ?? 'test/screenshots/current');
const DIFF_DIR = path.resolve(FRONT_ROOT, args.diff ?? 'test/screenshots/diff');
const TOLERANCE = Number(args.tolerance ?? 0.001); // fraction of pixels allowed to differ
const CHANNEL_TOL = Number(args['channel-tol'] ?? 8); // per-channel 0-255 tolerance
function parsePng(buf) {
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
if (!buf.subarray(0, 8).equals(sig)) throw new Error('not a PNG');
let off = 8;
let width;
let height;
let bitDepth;
let colorType;
let interlace;
const idat = [];
while (off < buf.length) {
const len = buf.readUInt32BE(off);
const type = buf.toString('ascii', off + 4, off + 8);
const data = buf.subarray(off + 8, off + 8 + len);
if (type === 'IHDR') {
width = data.readUInt32BE(0);
height = data.readUInt32BE(4);
bitDepth = data[8];
colorType = data[9];
interlace = data[12];
} else if (type === 'IDAT') {
idat.push(data);
} else if (type === 'IEND') {
break;
}
off += 12 + len;
}
if (bitDepth !== 8 || interlace !== 0 || ![2, 6].includes(colorType)) {
throw new Error(`unsupported PNG (depth=${bitDepth} type=${colorType} interlace=${interlace})`);
}
const channels = colorType === 6 ? 4 : 3;
const raw = zlib.inflateSync(Buffer.concat(idat));
const stride = width * channels;
const pixels = Buffer.alloc(width * height * channels);
let p = 0;
let prev = Buffer.alloc(stride);
for (let y = 0; y < height; y++) {
const filter = raw[p++];
const cur = raw.subarray(p, p + stride);
p += stride;
const out = Buffer.alloc(stride);
for (let i = 0; i < stride; i++) {
const a = i >= channels ? out[i - channels] : 0;
const b = prev[i];
const c = i >= channels ? prev[i - channels] : 0;
let v;
switch (filter) {
case 0: v = cur[i]; break;
case 1: v = (cur[i] + a) & 255; break;
case 2: v = (cur[i] + b) & 255; break;
case 3: v = (cur[i] + ((a + b) >> 1)) & 255; break;
case 4: {
const pa = Math.abs(b - c);
const pb = Math.abs(a - c);
const pc = Math.abs(a + b - 2 * c);
const pr = pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
v = (cur[i] + pr) & 255;
break;
}
default: throw new Error(`bad filter ${filter}`);
}
out[i] = v;
}
out.copy(pixels, y * stride);
prev = out;
}
return { width, height, channels, pixels };
}
function crc32(buf) {
let table = crc32.table;
if (!table) {
table = crc32.table = new Int32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
table[n] = c;
}
}
let crc = -1;
for (let i = 0; i < buf.length; i++) crc = (crc >>> 8) ^ table[(crc ^ buf[i]) & 255];
return (crc ^ -1) >>> 0;
}
function chunk(type, data) {
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length);
const td = Buffer.concat([Buffer.from(type, 'ascii'), data]);
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(td));
return Buffer.concat([len, td, crc]);
}
function encodePng(width, height, rgba) {
const stride = width * 4;
const raw = Buffer.alloc((stride + 1) * height);
for (let y = 0; y < height; y++) {
raw[y * (stride + 1)] = 0;
rgba.copy(raw, y * (stride + 1) + 1, y * stride, (y + 1) * stride);
}
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(width, 0);
ihdr.writeUInt32BE(height, 4);
ihdr[8] = 8;
ihdr[9] = 6;
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
return Buffer.concat([
sig,
chunk('IHDR', ihdr),
chunk('IDAT', zlib.deflateSync(raw)),
chunk('IEND', Buffer.alloc(0)),
]);
}
function toRgba(parsed) {
if (parsed.channels === 4) return parsed.pixels;
const out = Buffer.alloc(parsed.width * parsed.height * 4);
for (let i = 0, j = 0; i < parsed.pixels.length; i += 3, j += 4) {
out[j] = parsed.pixels[i];
out[j + 1] = parsed.pixels[i + 1];
out[j + 2] = parsed.pixels[i + 2];
out[j + 3] = 255;
}
return out;
}
export function compareBuffers(refBuf, curBuf, { tolerance = TOLERANCE, channelTol = CHANNEL_TOL } = {}) {
const ref = parsePng(refBuf);
const cur = parsePng(curBuf);
if (ref.width !== cur.width || ref.height !== cur.height) {
return {
ok: false,
reason: `size mismatch ref=${ref.width}x${ref.height} cur=${cur.width}x${cur.height}`,
frac: 1,
maxDelta: 255,
diffPixels: ref.width * ref.height,
totalPixels: ref.width * ref.height,
};
}
const a = toRgba(ref);
const b = toRgba(cur);
const total = ref.width * ref.height;
let diff = 0;
let maxDelta = 0;
const diffImg = Buffer.alloc(a.length);
for (let i = 0; i < total; i++) {
const o = i * 4;
const d = Math.max(
Math.abs(a[o] - b[o]),
Math.abs(a[o + 1] - b[o + 1]),
Math.abs(a[o + 2] - b[o + 2]),
);
if (d > maxDelta) maxDelta = d;
if (d > channelTol) {
diff++;
diffImg[o] = 255;
diffImg[o + 1] = 0;
diffImg[o + 2] = 0;
diffImg[o + 3] = 255;
} else {
// dim grayscale of reference for context
const g = Math.round((a[o] + a[o + 1] + a[o + 2]) / 6);
diffImg[o] = g;
diffImg[o + 1] = g;
diffImg[o + 2] = g;
diffImg[o + 3] = 255;
}
}
const frac = diff / total;
return {
ok: frac <= tolerance,
diffPixels: diff,
totalPixels: total,
frac,
maxDelta,
diffImg: diff > 0 ? encodePng(ref.width, ref.height, diffImg) : null,
};
}
export function compareFiles(refFile, curFile, opts = {}) {
return compareBuffers(fs.readFileSync(refFile), fs.readFileSync(curFile), opts);
}
export { parsePng, toRgba, encodePng };
function main() {
if (!fs.existsSync(REF_DIR)) {
console.error(`[compare] no reference dir ${REF_DIR} — run: npm run rig:reference`);
process.exit(1);
}
if (!fs.existsSync(CUR_DIR)) {
console.error(`[compare] no current dir ${CUR_DIR} — run: npm run rig:screenshots`);
process.exit(1);
}
fs.mkdirSync(DIFF_DIR, { recursive: true });
const files = fs.readdirSync(REF_DIR).filter((f) => f.endsWith('.png')).sort();
if (!files.length) {
console.error('[compare] reference dir is empty');
process.exit(1);
}
let failed = 0;
for (const f of files) {
const cur = path.join(CUR_DIR, f);
if (!fs.existsSync(cur)) {
console.log(`[compare] MISSING ${f} (no current screenshot)`);
failed++;
continue;
}
let result;
try {
result = compareFiles(path.join(REF_DIR, f), cur);
} catch (err) {
console.log(`[compare] ERROR ${f}: ${err.message}`);
failed++;
continue;
}
if (result.ok) {
console.log(
`[compare] OK ${f} (${result.diffPixels}/${result.totalPixels} px, maxΔ=${result.maxDelta})`,
);
} else {
failed++;
const detail = result.reason
?? `${result.diffPixels}/${result.totalPixels} px differ (${(result.frac * 100).toFixed(3)}% > ${(TOLERANCE * 100).toFixed(3)}%), maxΔ=${result.maxDelta}`;
console.log(`[compare] FAIL ${f}: ${detail}`);
if (result.diffImg) {
fs.writeFileSync(path.join(DIFF_DIR, f), result.diffImg);
}
}
}
if (failed > 0) {
console.log(`[compare] ${failed}/${files.length} mismatched (tolerance ${(TOLERANCE * 100).toFixed(2)}%, channel±${CHANNEL_TOL}). Diffs → ${DIFF_DIR}`);
process.exit(1);
}
console.log(`[compare] all ${files.length} screenshots match (tolerance ${(TOLERANCE * 100).toFixed(2)}%, channel±${CHANNEL_TOL})`);
}
const isMain = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
if (isMain) {
main();
}