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>
76 lines
3 KiB
TypeScript
76 lines
3 KiB
TypeScript
/**
|
||
* 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();
|