/** * 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(); }