#!/usr/bin/env node /** * Generate pixel diffs for the side-by-side Angular/React screenshots * captured into `comparison-report/visual/screenshots/full/` and emit a * JSON file the report.html consumes for the quantitative side of the * parity comparison. * * Pairs are inferred by filename: every `react-{stem}.png` is matched * against `angular-{stem}.png` in the same directory. Diffs are written * to `comparison-report/visual/diffs-full/{stem}.png` (white = match, * red overlay = mismatched pixel) and stats land in * `comparison-report/visual/diff-stats.json`. * * Image-dimension mismatches are handled by padding both sides to the * larger canvas with white before pixel-matching — same approach as * `tests/parity/visual/screenshot-diff-multi.ts`. */ import { readdirSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; import { resolve, join } from "node:path"; import { PNG } from "pngjs"; import pixelmatch from "pixelmatch"; const ROOT = resolve(process.cwd()); const SRC_DIR = join(ROOT, "comparison-report/visual/screenshots/full"); const DIFFS_DIR = join(ROOT, "comparison-report/visual/diffs-full"); const STATS_PATH = join(ROOT, "comparison-report/visual/diff-stats.json"); mkdirSync(DIFFS_DIR, { recursive: true }); /** * Paint a rectangle white. Used to mask Angular-only test-env chrome * (orange `Тестовая версия` badge top-left, `rc/2026-04-06` build tag * top-right, chat-widget bubble bottom-right) before pixel-matching * so those regions don't pollute the parity score. */ function maskRect(png, x, y, w, h) { const x0 = Math.max(0, x); const y0 = Math.max(0, y); const x1 = Math.min(png.width, x + w); const y1 = Math.min(png.height, y + h); for (let yy = y0; yy < y1; yy++) { for (let xx = x0; xx < x1; xx++) { const idx = (yy * png.width + xx) * 4; png.data[idx] = 255; png.data[idx + 1] = 255; png.data[idx + 2] = 255; png.data[idx + 3] = 255; } } } /** Apply the standard chrome masks to both PNGs in-place. */ function applyChromeMasks(png) { // Top-left debug counter / orange "Тестовая версия" badge. maskRect(png, 0, 0, 200, 90); // Top-right `rc/...` build tag. maskRect(png, png.width - 240, 0, 240, 50); // Bottom-right chat widget. maskRect(png, png.width - 90, png.height - 90, 90, 90); } /** * Shift the contents of a PNG vertically by `dy` pixels in-place. * Negative `dy` moves content up (rows above are dropped, the gap at * the bottom is filled white). Used to compensate for the Angular * test-env chrome that pushes the main layout down by a fixed * amount, so that content rows align with React's chrome-less render * before pixel-matching. */ function shiftUp(png, dy) { if (dy <= 0) return; const w = png.width; const h = png.height; const stride = w * 4; // Move row y+dy → row y. for (let y = 0; y < h - dy; y++) { png.data.copyWithin(y * stride, (y + dy) * stride, (y + dy + 1) * stride); } // Fill last `dy` rows with white. for (let y = h - dy; y < h; y++) { for (let x = 0; x < w; x++) { const idx = (y * w + x) * 4; png.data[idx] = 255; png.data[idx + 1] = 255; png.data[idx + 2] = 255; png.data[idx + 3] = 255; } } } /** * Find the y-coordinate of the orange `Тестовая версия` badge in an * Angular screenshot, if present. The badge is solid #ff9000 / similar * orange — distinctive enough to detect by row-scanning. Returns -1 * when no badge band is found. */ function findOrangeBadgeBottom(png) { const w = png.width; const h = Math.min(png.height, 200); let bandStart = -1; let bandEnd = -1; for (let y = 0; y < h; y++) { let orangeCount = 0; // Sample first 200px of the row for orange pixels. for (let x = 0; x < Math.min(200, w); x++) { const idx = (y * w + x) * 4; const r = png.data[idx]; const g = png.data[idx + 1]; const b = png.data[idx + 2]; // ~ #ff9000 / #f37b09 family. if (r > 220 && g > 100 && g < 180 && b < 60) orangeCount++; } if (orangeCount > 30) { if (bandStart === -1) bandStart = y; bandEnd = y; } } return bandEnd === -1 ? -1 : bandEnd; } const files = readdirSync(SRC_DIR); const reactFiles = files.filter((f) => f.startsWith("react-") && f.endsWith(".png")); const stats = {}; let processed = 0; for (const reactFile of reactFiles) { const stem = reactFile.slice("react-".length); // e.g. "onlineboard-start.png" const angularFile = `angular-${stem}`; if (!files.includes(angularFile)) { console.log(` skip (no angular pair): ${reactFile}`); continue; } const aBuf = readFileSync(join(SRC_DIR, angularFile)); const rBuf = readFileSync(join(SRC_DIR, reactFile)); const aPng = PNG.sync.read(aBuf); const rPng = PNG.sync.read(rBuf); const width = Math.max(aPng.width, rPng.width); const height = Math.max(aPng.height, rPng.height); const pad = (src) => { const out = new PNG({ width, height }); for (let i = 0; i < out.data.length; i += 4) { out.data[i] = 255; out.data[i + 1] = 255; out.data[i + 2] = 255; out.data[i + 3] = 255; } for (let y = 0; y < src.height; y++) { for (let x = 0; x < src.width; x++) { const srcIdx = (y * src.width + x) * 4; const dstIdx = (y * width + x) * 4; out.data[dstIdx] = src.data[srcIdx]; out.data[dstIdx + 1] = src.data[srcIdx + 1]; out.data[dstIdx + 2] = src.data[srcIdx + 2]; out.data[dstIdx + 3] = src.data[srcIdx + 3]; } } return PNG.sync.read(PNG.sync.write(out)); }; const aP = pad(aPng); const rP = pad(rPng); // Strip Angular-only chrome (test-env badge, build tag, chat widget) // from both sides — those regions are environmental noise that // shouldn't drag the parity score down. applyChromeMasks(aP); applyChromeMasks(rP); const diffPng = new PNG({ width, height }); const mismatchCount = pixelmatch( aP.data, rP.data, diffPng.data, width, height, { threshold: 0.1, alpha: 0.3 }, ); const totalPixels = width * height; const mismatchPct = (mismatchCount / totalPixels) * 100; const heightDiff = rPng.height - aPng.height; const diffName = stem; writeFileSync(join(DIFFS_DIR, diffName), PNG.sync.write(diffPng)); const key = stem.replace(/\.png$/, ""); stats[key] = { angular: `screenshots/full/${angularFile}`, react: `screenshots/full/${reactFile}`, diff: `diffs-full/${diffName}`, mismatchPct: Number(mismatchPct.toFixed(3)), mismatchCount, totalPixels, heightDiff, angular_w: aPng.width, angular_h: aPng.height, react_w: rPng.width, react_h: rPng.height, }; processed++; console.log(` ${stem}: ${mismatchPct.toFixed(2)}% diff (${mismatchCount.toLocaleString()} / ${totalPixels.toLocaleString()} px)`); } writeFileSync(STATS_PATH, JSON.stringify(stats, null, 2)); console.log(`\nDone — ${processed} pairs diffed.`); console.log(` Diffs: ${DIFFS_DIR}/`); console.log(` Stats: ${STATS_PATH}`);