pcbjam/tests/tools/screenshots/noise.ts
Viktor Vaczi 13551f4f22 feat(tests): screenshot regression + Discord review, perf-tracked
New tooling in tests/tools/screenshots/ (TypeScript via tsx):
- compare.ts: one pixelmatch engine (AA-excluded), connected-component
  "where to look" boxes, old|new+boxes|heatmap triptych, per-engine floors.
- promote.ts: churn-free updater — overwrite a baseline only when decoded
  pixels differ beyond the floor, copying CI bytes verbatim (no re-encode
  churn); pulls a CI run via `gh run download` or a local --from dir.
- post-discord.ts: always-on CI-on-main report (SHA + e2e status + the
  track-only runtime-perf table), then screenshot triptychs, batched +
  size-capped + flood-collapsed + 429-aware.
- perf-report.ts: perf table with Δ vs the previous main run (via gh).
- changelog.ts: no-build git-history baseline differ (Discord trigger B).
- noise.ts / gen-manifest.ts: calibration + manifest generation.

CI wiring:
- wasm-build.yml: post-test step runs the gate + report on the already-
  produced test-results (no extra build); report-only (continue-on-error),
  posts only on push to main, inert without DISCORD_WEBHOOK_URL.
- ci-ubicloud.yml: secrets: inherit (pass the webhook through).
- screenshot-changelog.yml: ~30s no-build changelog on baseline changes.

screenshot-manifest.json: canonical 354-name set + best-effort engine tags
(313 chromium-swiftshader / 41 firefox-llvmpipe).

Normalize scale:'device'->'css' across 18 spec files (no-op at CI DSF=1)
so committed baselines are uniformly css-scaled.

Design: CI's Linux render is the single source of truth; no pinned
container (accept rare env drift -> re-promote); dev commits via promote.
Replaces the byte-cmp compare-screenshots.sh + file-size-proxy
update-baseline-screenshots.sh (kept for now until the first re-baseline).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 18:17:20 +02:00

76 lines
3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Calibration: measure the intra-CI noise floor.
*
* Render the suite twice on the same host (into two dirs), then:
* tsx tools/screenshots/noise.ts <run1-dir> <run2-dir>
* prints, per engine (via the manifest) and globally, the max / p95 / mean
* changed-pixel ratio between the two identical-input renders. Set
* config.ts FLOORS.<engine>.changedRatio ≈ (max × 3) from this.
*
* A near-zero floor means the render is deterministic enough to catch real
* changes tightly; a large floor exposes nondeterminism to chase before the
* gate can be trusted.
*/
import * as fs from 'fs';
import * as path from 'path';
import { MANIFEST_PATH, type Manifest } from './config';
import { diffImages, loadPng } from './image-ops';
function listPngs(dir: string): string[] {
return fs.existsSync(dir) ? fs.readdirSync(dir).filter((f) => f.toLowerCase().endsWith('.png')) : [];
}
function engineOf(name: string, manifest?: Manifest): string {
return manifest?.screenshots.find((e) => e.name === name)?.engine ?? 'default';
}
function stats(values: number[]): { n: number; max: number; p95: number; mean: number } {
if (!values.length) return { n: 0, max: 0, p95: 0, mean: 0 };
const sorted = [...values].sort((a, b) => a - b);
const p95 = sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95))];
return {
n: values.length,
max: sorted[sorted.length - 1],
p95,
mean: values.reduce((s, v) => s + v, 0) / values.length,
};
}
function main(): void {
const [dir1, dir2] = process.argv.slice(2);
if (!dir1 || !dir2) {
console.error('usage: noise.ts <run1-dir> <run2-dir>');
process.exitCode = 2;
return;
}
const root = process.cwd();
const manifestPath = path.join(root, MANIFEST_PATH);
const manifest = fs.existsSync(manifestPath)
? (JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as Manifest)
: undefined;
const common = listPngs(dir1).filter((n) => fs.existsSync(path.join(dir2, n)));
const byEngine = new Map<string, number[]>();
const all: number[] = [];
for (const name of common) {
const d = diffImages(loadPng(path.join(dir1, name)), loadPng(path.join(dir2, name)));
const ratio = d.dimsMatch ? d.changedRatio : 1;
all.push(ratio);
const eng = engineOf(name, manifest);
(byEngine.get(eng) ?? byEngine.set(eng, []).get(eng)!).push(ratio);
}
const report = (label: string, values: number[]) => {
const s = stats(values);
console.log(
`${label.padEnd(24)} n=${String(s.n).padStart(4)} max=${(s.max * 100).toFixed(4)}% ` +
`p95=${(s.p95 * 100).toFixed(4)}% mean=${(s.mean * 100).toFixed(4)}% ` +
`→ suggest changedRatio=${(s.max * 3).toExponential(2)}`
);
};
console.log(`[noise] compared ${common.length} screenshots rendered twice`);
for (const [eng, values] of byEngine) report(eng, values);
report('ALL', all);
}
if (require.main === module) main();