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>
222 lines
9.6 KiB
TypeScript
222 lines
9.6 KiB
TypeScript
/**
|
|
* Central config for the screenshot regression + review tooling.
|
|
*
|
|
* The comparison engine (compare.ts), the churn-free updater (promote.ts) and
|
|
* the Discord reporter (post-discord.ts) all read their knobs from here so
|
|
* there is exactly one place to tune thresholds and paths.
|
|
*
|
|
* Paths are relative to the `tests/` directory (that is the working directory
|
|
* the npm scripts and CI steps run from).
|
|
*/
|
|
import * as nodeFs from 'fs';
|
|
import * as nodePath from 'path';
|
|
|
|
/**
|
|
* Baseline root — a GITIGNORED local cache, one PER-ENGINE subdirectory per
|
|
* browser family: `baseline-screenshots/{chromium,firefox}/<name>.png`.
|
|
* Renders mirror the layout (`test-results/<engine>/<name>.png`, written by
|
|
* stableShot/shotPath), so each engine is gated against its OWN baselines —
|
|
* the old flat namespace let two engines running the same spec overwrite each
|
|
* other's PNG, and the surviving file was whichever parallel worker wrote last.
|
|
*
|
|
* The baseline PNGs are NOT committed: they live in a private R2 bucket,
|
|
* content-addressed by sha256, pinned by the R2-HOSTED manifest
|
|
* (baselines/pcbjam/manifest.json — nothing manifest-related is in git).
|
|
* `npm run screenshots:fetch-manifest && npm run screenshots:fetch`
|
|
* (tools/screenshots/r2-sync.ts) materializes this tree. Baselines are
|
|
* promoted from a CI run in the morelli review app
|
|
* (https://pcbjam-morelli-staging.pcbjam-staging.workers.dev) — the old
|
|
* promote.ts/git-manifest flow is retired.
|
|
*/
|
|
export const BASELINE_ROOT = 'baseline-screenshots';
|
|
|
|
/** Manifest format version — v3 = the R2-hosted manifest written by morelli (v2 was the committed-in-git era). */
|
|
export const MANIFEST_VERSION = 3;
|
|
|
|
/** Default private R2 bucket holding the content-addressed baselines. */
|
|
export const R2_DEFAULT_BUCKET = 'pcbjam-ci-screenshots';
|
|
|
|
/** Key prefix for content-addressed objects: `sha256/<64-hex>.png`. */
|
|
export const R2_KEY_PREFIX = 'sha256/';
|
|
|
|
/** The R2-hosted baseline manifest — THE source of truth. Written only by morelli (promote) and its seed script. */
|
|
export const R2_BASELINES_MANIFEST_KEY = 'baselines/pcbjam/manifest.json';
|
|
|
|
/**
|
|
* Env vars for the bucket's S3 API (read-only keypair in CI, read-write for
|
|
* devs running promote). Endpoint form: https://<account-id>.r2.cloudflarestorage.com
|
|
*/
|
|
export const R2_ENV = {
|
|
endpoint: 'CI_SCREENSHOTS_S3_ENDPOINT',
|
|
bucket: 'CI_SCREENSHOTS_S3_BUCKET',
|
|
accessKeyId: 'CI_SCREENSHOTS_S3_ACCESS_KEY_ID',
|
|
secretAccessKey: 'CI_SCREENSHOTS_S3_SECRET_ACCESS_KEY',
|
|
} as const;
|
|
|
|
/** Engine subdirectories the tooling recognizes (a browser family per dir). */
|
|
export const ENGINES = ['chromium', 'firefox', 'webkit'] as const;
|
|
|
|
/**
|
|
* The canonical screenshot key is `<engine>/<name>.png` — the path of both the
|
|
* render (under test-results/) and the baseline (under baseline-screenshots/)
|
|
* relative to their root.
|
|
*/
|
|
export function splitKey(key: string): { engine: string; name: string } {
|
|
const i = key.indexOf('/');
|
|
return i < 0 ? { engine: 'unknown', name: key } : { engine: key.slice(0, i), name: key.slice(i + 1) };
|
|
}
|
|
|
|
/** Engine-qualified keys (`<engine>/<name>.png`) found under a root's engine subdirs. */
|
|
export function listEngineKeys(base: string): string[] {
|
|
const keys: string[] = [];
|
|
for (const engine of ENGINES) {
|
|
const dir = nodePath.join(base, engine);
|
|
if (!nodeFs.existsSync(dir)) continue;
|
|
for (const f of nodeFs.readdirSync(dir)) {
|
|
if (f.toLowerCase().endsWith('.png')) keys.push(`${engine}/${f}`);
|
|
}
|
|
}
|
|
return keys;
|
|
}
|
|
|
|
/** Where Playwright writes the current run's screenshots (gitignored). */
|
|
export const RESULTS_DIR = 'test-results';
|
|
|
|
/** Where compare.ts writes diff/heatmap/triptych artifacts (gitignored). */
|
|
export const DIFF_OUT_DIR = 'test-results/screenshot-diff';
|
|
|
|
/**
|
|
* Local, GITIGNORED copy of the R2-hosted baseline manifest, downloaded by
|
|
* `npm run screenshots:fetch-manifest` (r2-sync --manifest). Everything
|
|
* downstream (fetch, compare, verify) reads this file; its absence means "no
|
|
* manifest fetched" and the screenshot gate skips. Deliberately NOT inside
|
|
* test-results/ — Playwright wipes that dir at the start of every invocation.
|
|
*/
|
|
export const MANIFEST_PATH = '.baseline-manifest.json';
|
|
|
|
/**
|
|
* pixelmatch per-pixel settings.
|
|
* - `threshold` is the YIQ perceptual distance (0..1) below which two pixels
|
|
* are considered equal. 0.1 tolerates gamma/AA jitter but catches real colour
|
|
* change.
|
|
* - `includeAA: false` (the pixelmatch default) means anti-aliased edge pixels
|
|
* are DETECTED AND IGNORED — exactly the sub-pixel/AA noise the old
|
|
* `maxDiff>16` counter was dominated by (see screenshot-compare.ts:36-43).
|
|
*/
|
|
export const PIXELMATCH = { threshold: 0.1, includeAA: false } as const;
|
|
|
|
/** Colour pixelmatch paints a real (non-AA) diff pixel with — the mask reads this back. */
|
|
export const DIFF_COLOR: [number, number, number] = [255, 0, 0];
|
|
|
|
/** Connected-component clustering ("where to look") parameters. */
|
|
export const CLUSTER = {
|
|
dilate: 2, // grow the mask so fragmented glyph pixels merge into one box
|
|
minBoxArea: 16, // drop specks smaller than this (px²)
|
|
maxBoxes: 6, // draw at most this many (largest-first) red boxes
|
|
boxColor: [255, 0, 0] as [number, number, number],
|
|
} as const;
|
|
|
|
/** Horizontal montage layout for the old | new+boxes | heatmap triptych. */
|
|
export const TRIPTYCH = {
|
|
gap: 8,
|
|
bg: [24, 24, 24, 255] as [number, number, number, number],
|
|
padFill: [40, 0, 40, 255] as [number, number, number, number], // magenta pad on dim-mismatch
|
|
} as const;
|
|
|
|
/**
|
|
* Per-engine verdict floors. A screenshot is CHANGED when its AA-excluded
|
|
* changed-pixel ratio exceeds `changedRatio`. `meanChannelGuard` is recorded
|
|
* for the drift-vs-regression heuristic (broad + low-intensity ⇒ environment
|
|
* drift, not a localized regression), not for the primary verdict.
|
|
*
|
|
* Set to 0.5% — the re-baseline run showed 355/356 images intra-CI-stable well
|
|
* under this, but a couple of runs since had sub-1% inter-run flakiness, so 0.5%
|
|
* gives headroom while still catching real localized changes. (`npm run
|
|
* screenshots:noise` on two CI renders can refine per-engine numbers later.)
|
|
*/
|
|
export type EngineFloor = { changedRatio: number; meanChannelGuard: number };
|
|
|
|
// Keyed by engine dir name (CI renderers: firefox = Mesa llvmpipe, chromium = SwiftShader).
|
|
export const FLOORS: Record<string, EngineFloor> = {
|
|
firefox: { changedRatio: 0.005, meanChannelGuard: 2.0 },
|
|
chromium: { changedRatio: 0.005, meanChannelGuard: 2.0 },
|
|
default: { changedRatio: 0.005, meanChannelGuard: 2.0 },
|
|
};
|
|
|
|
/**
|
|
* Optional per-file rectangles to ignore before diffing (e.g. a live clock).
|
|
* Keyed by screenshot filename. Empty for now.
|
|
*/
|
|
export const IGNORE_REGIONS: Record<string, Array<{ x: number; y: number; width: number; height: number }>> = {};
|
|
|
|
/**
|
|
* Screenshots excluded from comparison entirely (not compared, not counted as
|
|
* changed/added/removed, not put in the manifest). For nondeterministic captures
|
|
* that can't be a stable baseline — e.g. `retinascale-01-loaded` is a `fullPage`
|
|
* HiDPI test whose captured height + DPR scaling vary run-to-run (~60% inter-run
|
|
* diff observed), a flaky test rather than render noise.
|
|
*/
|
|
export const IGNORE_SCREENSHOTS = new Set<string>(['retinascale-01-loaded.png']);
|
|
|
|
/** True if a screenshot is excluded from comparison. Matches the bare name, so an
|
|
* ignored screenshot is ignored in every engine. */
|
|
export function isIgnored(key: string): boolean {
|
|
return IGNORE_SCREENSHOTS.has(splitKey(key).name);
|
|
}
|
|
|
|
/** Bottom caption strip baked onto each posted composite (status + name + spec). */
|
|
export const LABEL = {
|
|
maxScale: 3, // bitmap-font scale; auto-fit picks the largest that fits the width
|
|
vpad: 5,
|
|
hpad: 8,
|
|
text: [255, 255, 255] as [number, number, number], // white
|
|
colors: {
|
|
added: [46, 125, 50] as [number, number, number], // green
|
|
removed: [198, 40, 40] as [number, number, number], // red
|
|
changed: [239, 108, 0] as [number, number, number], // orange
|
|
unchanged: [69, 90, 100] as [number, number, number], // blue-grey (review-only artifacts)
|
|
},
|
|
};
|
|
|
|
export type LabelStatus = 'added' | 'removed' | 'changed' | 'unchanged';
|
|
|
|
/** Caption text: `CHANGED [chromium] name.png · kicad/pcbnew.spec.ts` (spec omitted if
|
|
* unknown). The engine is PREPENDED — the auto-fit strip truncates the tail, so a
|
|
* trailing tag could be cut off on long names. Accepts an engine-qualified key. */
|
|
export function labelText(status: LabelStatus, key: string, spec: string | null): string {
|
|
const s = status.toUpperCase();
|
|
const { engine, name } = splitKey(key);
|
|
const head = `${s} [${engine}] ${name}`;
|
|
return spec ? `${head} · ${spec}` : head;
|
|
}
|
|
|
|
/**
|
|
* One expected screenshot. `sha256` is the load-bearing field — it resolves the
|
|
* R2 object (`sha256/<hex>.png`) holding the baseline bytes; `bytes`/`width`/
|
|
* `height` are sanity metadata (cheap pre-hash check on fetch, dimension info
|
|
* in reviews).
|
|
*/
|
|
export type ManifestEntry = {
|
|
name: string;
|
|
engine: string;
|
|
sha256: string;
|
|
bytes: number;
|
|
width: number;
|
|
height: number;
|
|
/** morelli-side provenance (which run/branch/user promoted this) — opaque to the CI tooling. */
|
|
source?: unknown;
|
|
};
|
|
export type Manifest = {
|
|
version: number;
|
|
pipeline?: string;
|
|
storage: { bucket: string; keyPrefix: string };
|
|
updatedAt?: string;
|
|
updatedBy?: string;
|
|
screenshots: ManifestEntry[];
|
|
};
|
|
|
|
/** Verdict floor for an engine-qualified key — the engine IS the key prefix now,
|
|
* no manifest lookup needed. */
|
|
export function floorFor(key: string): EngineFloor {
|
|
return FLOORS[splitKey(key).engine] || FLOORS.default;
|
|
}
|