277 lines
8.8 KiB
JavaScript
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();
|
|
}
|