pcbjam/tests/tools/screenshots/compare.ts
Istvan Matejcsok ec7a1b0787 screenshots: R2-hosted manifest becomes the baseline source of truth (morelli cutover)
The committed tests/screenshot-manifest.json is retired. CI now downloads
baselines/pcbjam/manifest.json (manifest v3, written only by the morelli
review app + its seed script) to the gitignored .baseline-manifest.json,
and everything downstream (pull, verify, compare) reads that copy:

- config.ts: MANIFEST_VERSION 3, MANIFEST_PATH .baseline-manifest.json,
  R2_BASELINES_MANIFEST_KEY; ManifestEntry grows opaque provenance
- r2-sync.ts: new --manifest mode (atomic fetch; no-creds skip DELETES a
  stale copy so the gate skips rather than using old baselines); --push
  gone (bytes enter the CAS only via morelli's promote)
- compare.ts: hard-skips when no manifest was fetched — a stale warm
  cache can never gate
- wasm-build.yml: fetch-manifest step before the baselines cache; cache
  key now hashes the fetched manifest; the gen-manifest --check lint gate
  goes with the committed manifest
- deleted: screenshot-manifest.json, promote.ts, changelog.ts,
  gen-manifest.ts, screenshot-changelog.yml, promote-screenshots skill
- docs (CLAUDE/README/TESTING/WHATWORKS/tools README): promote flow is
  now https://pcbjam-morelli-staging.pcbjam-staging.workers.dev

Validated locally against the real bucket: fetch-manifest (492), cold
pull 492 / warm pull cached=492, no-creds skip chain, compare gate skip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 14:35:08 +02:00

268 lines
12 KiB
TypeScript

/**
* The one screenshot comparison engine + its CLI.
*
* Run modes (from the `tests/` directory):
* tsx tools/screenshots/compare.ts # gate: baselines vs test-results
* tsx tools/screenshots/compare.ts --fail-on-change # same, but exit 1 on any change
* tsx tools/screenshots/compare.ts --pair OLD NEW --name N --out DIR # diff two files
*
* The gate mode classifies every screenshot into changed / added / removed /
* unchanged, writes per-change triptych + heatmap PNGs and a machine-readable
* report.json into DIFF_OUT_DIR, and (unless --fail-on-change) exits 0 so it can
* run report-only first. post-discord.ts imports the exported helpers rather
* than re-deriving the diff.
*/
import * as fs from 'fs';
import * as path from 'path';
import { PNG } from 'pngjs';
import {
BASELINE_ROOT,
RESULTS_DIR,
DIFF_OUT_DIR,
MANIFEST_PATH,
type EngineFloor,
type Manifest,
floorFor,
isIgnored,
labelText,
listEngineKeys,
splitKey,
LABEL,
} from './config';
import { diffImages, cluster, drawBoxes, composite, loadPng, savePng, withBottomLabel, type Box } from './image-ops';
import { buildSpecResolver } from './spec-map';
export type PairVerdict = 'unchanged' | 'changed';
export type PairResult = {
name: string;
verdict: PairVerdict;
dimsMatch: boolean;
diffPixels: number;
changedRatio: number;
meanChannelDiff: number;
boxes: Box[];
/** heuristic: a broad, low-intensity change smells like host env drift, not a code regression */
driftHint: 'regression-like' | 'drift-like' | null;
};
/** Broad + low-intensity ⇒ likely environment drift rather than a localized regression. */
function driftHint(changed: boolean, changedRatio: number, meanChannelDiff: number): PairResult['driftHint'] {
if (!changed) return null;
return changedRatio > 0.05 && meanChannelDiff < 4 ? 'drift-like' : 'regression-like';
}
/** Compare two decoded PNGs; returns the verdict/metrics plus the heatmap and old|new+boxes|heatmap triptych. */
export function comparePair(
oldPng: PNG,
newPng: PNG,
name: string,
floor: EngineFloor
): { result: PairResult; heatmap: PNG; triptych: PNG } {
const d = diffImages(oldPng, newPng);
const changed = !d.dimsMatch || d.changedRatio > floor.changedRatio;
const boxes = changed ? cluster(d.mask, d.width, d.height) : [];
const triptych = composite([oldPng, drawBoxes(newPng, boxes), d.heatmap]);
return {
result: {
name,
verdict: changed ? 'changed' : 'unchanged',
dimsMatch: d.dimsMatch,
diffPixels: d.diffPixels,
changedRatio: d.changedRatio,
meanChannelDiff: d.meanChannelDiff,
boxes,
driftHint: driftHint(changed, d.changedRatio, d.meanChannelDiff),
},
heatmap: d.heatmap,
triptych,
};
}
export type ChangedEntry = PairResult & { triptych: string; heatmap: string };
export type Report = {
generatedFor: string | null;
changed: ChangedEntry[];
added: Array<{ name: string; image: string }>;
removed: Array<{ name: string; image: string }>;
unchangedCount: number;
/** many changes, mostly drift-like ⇒ probably a host Mesa/font refresh; re-promote rather than debug */
driftLikely: boolean;
};
const DRIFT_BULK = 20;
/** key (`<engine>/<name>`) → absolute baseline path. */
function baselineIndex(root: string): Map<string, string> {
const abs = path.join(root, BASELINE_ROOT);
const index = new Map<string, string>();
for (const key of listEngineKeys(abs)) index.set(key, path.join(abs, key));
return index;
}
function loadManifest(root: string): Manifest | undefined {
const p = path.join(root, MANIFEST_PATH);
if (!fs.existsSync(p)) return undefined;
try {
return JSON.parse(fs.readFileSync(p, 'utf8')) as Manifest;
} catch (e) {
console.warn(`[compare] could not parse ${MANIFEST_PATH}: ${(e as Error).message}`);
return undefined;
}
}
/** Flat, collision-free artifact filename for an engine-qualified key. */
function artifactName(key: string, suffix: string): string {
return `${key.replace('/', '_')}.${suffix}.png`;
}
/** Full gate run: classify baselines vs the current run's screenshots, per engine. */
export function classify(root: string, sha: string | null): Report {
const baselines = baselineIndex(root);
const resultsDir = path.join(root, RESULTS_DIR);
const actuals = new Set(listEngineKeys(resultsDir));
// Drop excluded screenshots from both sides so they're never compared or counted.
for (const key of [...baselines.keys()]) if (isIgnored(key)) baselines.delete(key);
for (const key of [...actuals]) if (isIgnored(key)) actuals.delete(key);
const manifest = loadManifest(root);
const outDir = path.join(root, DIFF_OUT_DIR);
fs.mkdirSync(outDir, { recursive: true });
const { specFor } = buildSpecResolver(root); // bare name → spec, for the caption strip
const report: Report = {
generatedFor: sha,
changed: [],
added: [],
removed: [],
unchangedCount: 0,
driftLikely: false,
};
// Changed / unchanged / removed: iterate the committed baselines.
for (const [key, baselinePath] of baselines) {
const { engine, name } = splitKey(key);
if (!actuals.has(key)) {
// Missing output. Only call it REMOVED when the manifest expects it — otherwise
// a flaky/OOM'd/skipped spec that simply didn't write a PNG would masquerade as
// an intentional removal. (The stronger "did the spec actually run" cross-check
// against the Playwright JSON report lands with the manifest work.)
if (manifest?.screenshots.some((e) => e.name === name && e.engine === engine)) {
// Removed now gets a captioned image (the old baseline) so it's visible in Discord.
const imageRel = path.join(DIFF_OUT_DIR, artifactName(key, 'removed'));
const labeled = withBottomLabel(loadPng(baselinePath), labelText('removed', key, specFor(name)), LABEL.colors.removed);
savePng(path.join(root, imageRel), labeled);
report.removed.push({ name: key, image: imageRel });
}
continue;
}
const { result, heatmap, triptych } = comparePair(
loadPng(baselinePath),
loadPng(path.join(resultsDir, key)),
key,
floorFor(key)
);
if (result.verdict === 'unchanged') {
report.unchangedCount++;
continue;
}
const triptychRel = path.join(DIFF_OUT_DIR, artifactName(key, 'triptych'));
const heatmapRel = path.join(DIFF_OUT_DIR, artifactName(key, 'heatmap'));
savePng(path.join(root, triptychRel), withBottomLabel(triptych, labelText('changed', key, specFor(name)), LABEL.colors.changed));
savePng(path.join(root, heatmapRel), heatmap);
report.changed.push({ ...result, triptych: triptychRel, heatmap: heatmapRel });
}
// Added: an actual with no committed baseline.
for (const key of actuals) {
if (baselines.has(key)) continue;
const imageRel = path.join(DIFF_OUT_DIR, artifactName(key, 'added'));
const labeled = withBottomLabel(loadPng(path.join(resultsDir, key)), labelText('added', key, specFor(splitKey(key).name)), LABEL.colors.added);
savePng(path.join(root, imageRel), labeled);
report.added.push({ name: key, image: imageRel });
}
const driftLike = report.changed.filter((c) => c.driftHint === 'drift-like').length;
report.driftLikely = report.changed.length >= DRIFT_BULK && driftLike * 2 >= report.changed.length;
report.changed.sort((a, b) => b.changedRatio - a.changedRatio);
report.added.sort((a, b) => a.name.localeCompare(b.name));
report.removed.sort((a, b) => a.name.localeCompare(b.name));
return report;
}
function parseArgs(argv: string[]): Record<string, string | boolean> {
const out: Record<string, string | boolean> = {};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--fail-on-change') out.failOnChange = true;
else if (a === '--pair') {
out.oldPath = argv[++i];
out.newPath = argv[++i];
} else if (a === '--name') out.name = argv[++i];
else if (a === '--out') out.out = argv[++i];
else if (a === '--sha') out.sha = argv[++i];
}
return out;
}
function runPair(args: Record<string, string | boolean>): void {
const name = (args.name as string) || 'pair';
const outDir = (args.out as string) || path.join(RESULTS_DIR, 'screenshot-diff');
fs.mkdirSync(outDir, { recursive: true });
const oldPng = fs.existsSync(args.oldPath as string)
? loadPng(args.oldPath as string)
: new PNG({ width: 1, height: 1 });
const newPng = fs.existsSync(args.newPath as string)
? loadPng(args.newPath as string)
: new PNG({ width: 1, height: 1 });
const { result, heatmap, triptych } = comparePair(oldPng, newPng, name, floorFor(name));
savePng(path.join(outDir, `${name}.triptych.png`), triptych);
savePng(path.join(outDir, `${name}.heatmap.png`), heatmap);
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
}
function main(): void {
const args = parseArgs(process.argv.slice(2));
if (args.oldPath && args.newPath) {
runPair(args);
return;
}
const root = process.cwd();
// Baselines are a local cache fetched from R2 (`npm run screenshots:fetch`).
// The gate only runs against a COMPLETE cache: a partial one (an R2 blip on
// a few objects — the CI fetch step is continue-on-error) would misreport
// the un-fetched baselines' renders as ADDED and silently disable removed-
// detection for them. Fully missing = fetch skipped (no credentials) or a
// dev who hasn't fetched. Either way skip without writing report.json —
// post-discord posts a distinct "gate SKIPPED" line when it's absent.
const gateManifest = loadManifest(root);
// No fetched manifest at all (creds absent, R2 down, or morelli never
// seeded) ⇒ the gate has no source of truth — skip rather than diff
// against whatever stale cache a previous run left behind.
if (!gateManifest) {
console.log(`[compare] no fetched ${MANIFEST_PATH} — run \`npm run screenshots:fetch-manifest\` (needs R2 credentials); skipping`);
return;
}
const wanted = gateManifest.screenshots.filter((e) => !isIgnored(`${e.engine}/${e.name}`));
const missing = wanted.filter((e) => !fs.existsSync(path.join(root, BASELINE_ROOT, e.engine, e.name)));
if (wanted.length && missing.length) {
const what = missing.length === wanted.length ? 'empty' : `INCOMPLETE (${missing.length}/${wanted.length} missing — partial fetch?)`;
console.log(`[compare] baseline cache is ${what} — run \`npm run screenshots:fetch\` (needs R2 credentials); skipping`);
return;
}
const report = classify(root, (args.sha as string) || process.env.GITHUB_SHA || null);
fs.writeFileSync(path.join(root, DIFF_OUT_DIR, 'report.json'), JSON.stringify(report, null, 2));
const { changed, added, removed, unchangedCount, driftLikely } = report;
console.log(
`[compare] changed=${changed.length} added=${added.length} removed=${removed.length} ` +
`unchanged=${unchangedCount}${driftLikely ? ' (looks like environment drift → re-promote)' : ''}`
);
for (const c of changed.slice(0, 10)) {
console.log(` CHANGED ${c.name} ratio=${(c.changedRatio * 100).toFixed(3)}% ${c.driftHint}`);
}
if (args.failOnChange && (changed.length || added.length || removed.length)) {
process.exitCode = 1;
}
}
if (require.main === module) main();