diff --git a/docs/scripts-index.md b/docs/scripts-index.md index 411ad1216f..313772ddfc 100644 --- a/docs/scripts-index.md +++ b/docs/scripts-index.md @@ -1,6 +1,6 @@ # Scripts index -Curated map of `scripts/` (219 files) and the `package.json` script surface (231 entries), +Curated map of `scripts/` (220 files) and the `package.json` script surface (232 entries), grouped by purpose. This is orientation, not an exhaustive per-file listing — the authoritative command list is `package.json`, and `npm run docs:check-scripts` verifies every `npm run ` referenced in docs resolves to a real script. `npm run docs:update` refreshes the exact counts above. diff --git a/package.json b/package.json index fa1757c5d9..327d6f226f 100644 --- a/package.json +++ b/package.json @@ -239,7 +239,8 @@ "sync:pr-branches": "node scripts/sync-open-pr-branches.mjs", "sync:pr-branches:apply": "node scripts/sync-open-pr-branches.mjs --apply", "check:assets": "node scripts/check-assets.mjs", - "check:pr-mergeability": "node scripts/pr-mergeability.mjs --self-test && node scripts/check-pr-mergeability-workflow.mjs" + "check:pr-mergeability": "node scripts/pr-mergeability.mjs --self-test && node scripts/check-pr-mergeability-workflow.mjs", + "design-system:baselines:adopt": "node scripts/adopt-visual-baselines.mjs" }, "dependencies": { "@next/env": "16.2.12", diff --git a/scripts/adopt-visual-baselines.mjs b/scripts/adopt-visual-baselines.mjs new file mode 100644 index 0000000000..72aa4309b3 --- /dev/null +++ b/scripts/adopt-visual-baselines.mjs @@ -0,0 +1,329 @@ +#!/usr/bin/env node +/** + * Adopt or refresh the Linux visual baselines from a hosted-CI artifact. + * + * Everything this writes was hand-assembled before: six PNG copies, then a + * `provenance.json` carrying a SHA-256 and pixel dimensions per candidate, the + * capture commit, the run id, and the reviewer attestation. Hand-assembling that + * on every design change is the friction that makes people skip the refresh and + * leave a red advisory standing, which is how a pixel gate stops being read. + * + * What it deliberately does NOT do: + * + * - It never captures screenshots. Baselines are platform-scoped, and a Windows + * or macOS shot lands in a directory ubuntu CI never reads; font hinting alone + * would make every later run red. The artifact is the only supported source. + * - It never invents the review. `--reviewed-by` is required and the caller is + * asserting they looked at the images. A baseline of a broken render silently + * blesses the break, and that is exactly what the human-review field in the + * provenance contract exists to prevent. + * + * Usage: + * node scripts/adopt-visual-baselines.mjs \ + * --from --run-id --head <40-char-sha> \ + * --reviewed-by "" [--write] + * + * Without `--write` it reports what would change and touches nothing. + */ +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; + +import { visualBaselineAwaitingIds } from "./generate-design-system-adoption.mjs"; + +const ROOT = process.cwd(); +const BASELINE_DIR = "tests/__screenshots__/linux"; +const VISUAL_SUITE_FILE = "tests/ui-visual-baseline.spec.ts"; +const PROVENANCE = `${BASELINE_DIR}/provenance.json`; +const CANONICAL = [ + "dashboard-shell", + "dashboard-shell-phone", + "search-results-band", + "search-results-band-phone", + "document-viewer", + "therapy-compass-home", +]; + +function arg(name) { + const index = process.argv.indexOf(`--${name}`); + return index === -1 ? undefined : process.argv[index + 1]; +} +const WRITE = process.argv.includes("--write"); + +function fail(message) { + console.error(`adopt-visual-baselines: ${message}`); + process.exit(1); +} + +function gitSucceeds(args) { + try { + execFileSync("git", args, { cwd: ROOT, stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +function gitOutput(args) { + try { + return execFileSync("git", args, { cwd: ROOT, encoding: "utf8" }).trim(); + } catch { + return null; + } +} + +const from = arg("from"); +const runId = arg("run-id"); +const head = arg("head"); +const reviewedBy = arg("reviewed-by"); + +if (!from) fail("--from is required (the extracted visual-baseline- artifact)"); +if (!runId || !/^\d+$/.test(runId)) fail("--run-id is required and must match the artifact name"); +if (!head || !/^[0-9a-f]{40}$/.test(head)) fail("--head must be the full 40-character capture commit"); +if (!reviewedBy || !reviewedBy.trim()) { + fail("--reviewed-by '' is required — this records a HUMAN review of the six images, so look at them first"); +} + +// The capture commit must be real and reachable, or the provenance describes a +// tree nobody can check the images against. +if (!gitSucceeds(["cat-file", "-e", `${head}^{commit}`])) { + fail(`--head ${head} is not a commit in this repository`); +} + +const candidateSuite = gitOutput(["show", `${head}:${VISUAL_SUITE_FILE}`]); +if (!candidateSuite) fail(`--head ${head} does not contain ${VISUAL_SUITE_FILE}`); +const candidateAwaiting = visualBaselineAwaitingIds(candidateSuite, VISUAL_SUITE_FILE); +if (!candidateAwaiting.valid) { + fail(`--head ${head} ${VISUAL_SUITE_FILE} AWAITING_BASELINE is invalid: ${candidateAwaiting.failure ?? "unknown"}`); +} +const canonicalAwaiting = [...CANONICAL].sort(); +const candidateAwaitingIds = [...candidateAwaiting.ids].sort(); +const captureKind = + JSON.stringify(candidateAwaitingIds) === JSON.stringify(canonicalAwaiting) + ? "first-adoption" + : candidateAwaitingIds.length === 0 + ? "refresh" + : null; +if (!captureKind) { + fail( + `--head ${head} AWAITING_BASELINE must be either the canonical six ids (first adoption) or empty (refresh); ` + + `found ${candidateAwaitingIds.join(", ") || "(none)"}`, + ); +} + +const currentSuite = fs.existsSync(path.join(ROOT, VISUAL_SUITE_FILE)) + ? fs.readFileSync(path.join(ROOT, VISUAL_SUITE_FILE), "utf8") + : null; +const currentAwaiting = currentSuite ? visualBaselineAwaitingIds(currentSuite, VISUAL_SUITE_FILE) : null; +if (!currentAwaiting?.valid) { + fail(`current ${VISUAL_SUITE_FILE} AWAITING_BASELINE is invalid: ${currentAwaiting?.failure ?? "missing suite"}`); +} +if (captureKind === "first-adoption" && currentAwaiting.ids.length !== 0) { + fail( + "first adoption requires the current tree to empty AWAITING_BASELINE in the same commit as the adopted baselines", + ); +} +if (captureKind === "refresh" && currentAwaiting.ids.length !== 0) { + fail("refresh adoption requires AWAITING_BASELINE to already be empty in the current tree"); +} + +/** + * Candidates land in one of several places depending on capture outcome: + * + * - `visual-candidates/` when the target had no baseline at capture time + * - Playwright output (`*-actual.png`) when it compared and differed + * - `tests/__screenshots__/linux/` inside the artifact when it compared and passed + * - the committed baseline in this repository when a partial refresh left the + * target unchanged and the artifact carried no replacement image + * + * Retained baselines (the last two) are only trusted when visual-junit.xml shows + * that target passed. The artifact always uploads `tests/__screenshots__/`, even + * for a target that failed before producing an actual — without the junit gate a + * partial refresh could silently keep a stale PNG and claim it came from this run. + */ +function findCandidateSource(id) { + const candidate = path.join(from, "test-results", "visual-candidates", "linux", `${id}.png`); + if (fs.existsSync(candidate)) return { source: candidate, origin: "candidate" }; + + const results = path.join(from, "test-results"); + if (fs.existsSync(results)) { + // Only Playwright's `*-actual.png` counts as a fresh diff under test-results. + // A bare `.png` here can be an expected-snapshot copy and must not override + // a retained baseline for an unchanged target. + const wanted = `${id}-actual.png`; + const stack = [results]; + while (stack.length > 0) { + const dir = stack.pop(); + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) stack.push(full); + else if (entry.name === wanted) return { source: full, origin: "diff" }; + } + } + } + + const artifactBaseline = path.join(from, BASELINE_DIR, `${id}.png`); + if (fs.existsSync(artifactBaseline)) return { source: artifactBaseline, origin: "artifact-baseline" }; + + const committedBaseline = path.join(ROOT, BASELINE_DIR, `${id}.png`); + if (fs.existsSync(committedBaseline)) return { source: committedBaseline, origin: "committed" }; + + return null; +} + +/** + * Map canonical baseline ids to pass/fail/skip from the artifact's visual-junit.xml. + * Missing file or missing case → null (caller decides whether that is fatal). + */ +function readJunitBaselineOutcomes() { + const junitPath = path.join(from, "test-results", "visual-junit.xml"); + if (!fs.existsSync(junitPath)) return null; + const xml = fs.readFileSync(junitPath, "utf8"); + const outcomes = new Map(); + const casePattern = + /]*\bname="([^"]+)"[^>]*>([\s\S]*?)<\/testcase>|]*\bname="([^"]+)"[^>]*\/>/g; + let match; + while ((match = casePattern.exec(xml)) !== null) { + const name = match[1] ?? match[3] ?? ""; + const body = match[2] ?? ""; + const id = CANONICAL.find((candidate) => name.includes(`${candidate} matches its baseline`)); + if (!id) continue; + if (body.includes(" 0) { + fail( + `no candidate image found for: ${missing.join(", ")}. The artifact must contain either ` + + `test-results/visual-candidates/linux/.png (target was awaiting a baseline), ` + + `-actual.png under test-results/ (target compared and differed), ` + + `${BASELINE_DIR}/.png inside the artifact (target compared and passed), or an existing ` + + `committed baseline when refreshing unchanged targets.`, + ); +} + +const replacedCandidateIds = resolved + .filter((entry) => entry.origin === "candidate" || entry.origin === "diff") + .map((entry) => entry.id) + .sort(); +const retainedIds = resolved + .filter((entry) => entry.origin === "artifact-baseline" || entry.origin === "committed") + .map((entry) => entry.id) + .sort(); + +if (captureKind === "refresh" && replacedCandidateIds.length === 0) { + fail( + "refresh requires at least one fresh candidate or actual image — refusing an all-green run with nothing to adopt", + ); +} + +if (retainedIds.length > 0) { + const outcomes = readJunitBaselineOutcomes(); + if (!outcomes) { + fail( + `retained baselines (${retainedIds.join(", ")}) require test-results/visual-junit.xml in the artifact ` + + "so each can be confirmed passed rather than assumed from a stale screenshots upload", + ); + } + const unproven = retainedIds.filter((id) => outcomes.get(id) !== "passed"); + if (unproven.length > 0) { + fail( + `cannot retain baseline(s) without a passing visual-junit result: ${unproven + .map((id) => `${id}=${outcomes.get(id) ?? "missing"}`) + .join(", ")}`, + ); + } +} + +const changed = resolved.filter((entry) => entry.sha256 !== entry.previous); +for (const entry of resolved) { + const state = entry.previous === null ? "NEW" : entry.sha256 === entry.previous ? "unchanged" : "CHANGED"; + const origin = entry.origin === "committed" || entry.origin === "artifact-baseline" ? "retained" : "replaced"; + console.log( + `${state.padEnd(9)} ${entry.id} ${entry.width}x${entry.height} ${entry.sha256.slice(0, 12)} (${origin})`, + ); +} +console.log(`\n${changed.length} of ${resolved.length} baselines would change.`); +if (captureKind === "refresh") { + console.log(`Capture kind: refresh (${replacedCandidateIds.length} replaced from artifact).`); +} else { + console.log("Capture kind: first-adoption."); +} + +if (!WRITE) { + console.log("Dry run. Re-run with --write to update the baselines and provenance."); + process.exit(0); +} + +fs.mkdirSync(path.join(ROOT, BASELINE_DIR), { recursive: true }); +for (const entry of resolved) fs.writeFileSync(path.join(ROOT, BASELINE_DIR, `${entry.id}.png`), entry.image); + +const provenance = { + schemaVersion: 2, + platform: "linux", + runnerImage: "ubuntu-24.04", + candidateSourceHead: head, + capture: { + kind: captureKind, + ...(captureKind === "refresh" ? { replacedCandidateIds } : {}), + }, + source: { kind: "hosted-ci-artifact", runId, artifactName: `visual-baseline-${runId}`, candidateSourceHead: head }, + review: { + status: "approved", + reviewerType: "human", + reviewedBy: reviewedBy.trim(), + reviewedAt: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"), + candidateSourceHead: head, + }, + candidates: resolved.map(({ id, sha256, width, height }) => ({ + id, + path: `${BASELINE_DIR}/${id}.png`, + sha256, + width, + height, + })), +}; +fs.writeFileSync(path.join(ROOT, PROVENANCE), `${JSON.stringify(provenance, null, 2)}\n`); + +console.log(`\nWrote ${resolved.length} baselines and ${PROVENANCE}.`); +console.log("Next: npm run check:design-system-adoption, then npm run format before committing."); diff --git a/tests/__screenshots__/linux/document-viewer.png b/tests/__screenshots__/linux/document-viewer.png index 1fc1206ff1..e9ab016939 100644 Binary files a/tests/__screenshots__/linux/document-viewer.png and b/tests/__screenshots__/linux/document-viewer.png differ diff --git a/tests/__screenshots__/linux/provenance.json b/tests/__screenshots__/linux/provenance.json index 716348ac5c..beecff555b 100644 --- a/tests/__screenshots__/linux/provenance.json +++ b/tests/__screenshots__/linux/provenance.json @@ -2,19 +2,23 @@ "schemaVersion": 2, "platform": "linux", "runnerImage": "ubuntu-24.04", - "candidateSourceHead": "f20e908723efe6a6266a82f3d42e148eb6749dd2", + "candidateSourceHead": "d0bd2f60d0d2c514194b6481e0dedf4dd9babf5c", + "capture": { + "kind": "refresh", + "replacedCandidateIds": ["document-viewer"] + }, "source": { "kind": "hosted-ci-artifact", - "runId": "31265543648", - "artifactName": "visual-baseline-31265543648", - "candidateSourceHead": "f20e908723efe6a6266a82f3d42e148eb6749dd2" + "runId": "31268982766", + "artifactName": "visual-baseline-31268982766", + "candidateSourceHead": "d0bd2f60d0d2c514194b6481e0dedf4dd9babf5c" }, "review": { "status": "approved", "reviewerType": "human", "reviewedBy": "BigSimmo", - "reviewedAt": "2026-08-08T16:43:29Z", - "candidateSourceHead": "f20e908723efe6a6266a82f3d42e148eb6749dd2" + "reviewedAt": "2026-08-08T17:27:05Z", + "candidateSourceHead": "d0bd2f60d0d2c514194b6481e0dedf4dd9babf5c" }, "candidates": [ { @@ -48,7 +52,7 @@ { "id": "document-viewer", "path": "tests/__screenshots__/linux/document-viewer.png", - "sha256": "4c7ce8fad2780b22770ceeed6afc518a95350fd99e93d0a72ed57ce01e6c5cfd", + "sha256": "6e7971341b71da120a0cfe8087d58a0f3cf8f187ade1ee3d4c646b9108ecfd60", "width": 1196, "height": 2816 }, diff --git a/tests/adopt-visual-baselines.test.ts b/tests/adopt-visual-baselines.test.ts new file mode 100644 index 0000000000..329acb1482 --- /dev/null +++ b/tests/adopt-visual-baselines.test.ts @@ -0,0 +1,244 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createHash } from "node:crypto"; +import { execFileSync } from "node:child_process"; + +import { describe, expect, it } from "vitest"; + +import { validateLinuxVisualBaselineSet } from "../scripts/generate-design-system-adoption.mjs"; + +const root = path.resolve(__dirname, ".."); +const scriptPath = path.join(root, "scripts/adopt-visual-baselines.mjs"); +const validPng = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAAGklEQVQ4jWP4TyFgGDXg/2gY/B8Ng//DIgwAXXj8LuMlDaEAAAAASUVORK5CYII=", + "base64", +); +const baselineIds = [ + "dashboard-shell", + "dashboard-shell-phone", + "search-results-band", + "search-results-band-phone", + "document-viewer", + "therapy-compass-home", +]; + +function read(relativePath: string) { + return fs.readFileSync(path.join(root, relativePath), "utf8"); +} + +function withAwaitingValues(sourceText: string, values: string) { + return sourceText.replace( + /(const\s+AWAITING_BASELINE(?:\s*:\s*[^=]+)?\s*=\s*new Set\()\[[\s\S]*?\](\);)/, + `$1[${values}]$2`, + ); +} + +function git(fixtureRoot: string, args: string[]) { + return execFileSync("git", args, { + cwd: fixtureRoot, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); +} + +function commitFixture(fixtureRoot: string, message: string) { + git(fixtureRoot, ["add", "-A"]); + git(fixtureRoot, ["commit", "-q", "-m", message]); + return git(fixtureRoot, ["rev-parse", "HEAD"]); +} + +function writePng(fixtureRoot: string, relativePath: string, content: Buffer = validPng) { + const absolutePath = path.join(fixtureRoot, relativePath); + fs.mkdirSync(path.dirname(absolutePath), { recursive: true }); + fs.writeFileSync(absolutePath, content); +} + +function writeVisualJunit(artifactDir: string, outcomes: Record) { + const cases = baselineIds + .map((id) => { + const outcome = outcomes[id] ?? "passed"; + if (outcome === "failed") { + return `failed`; + } + if (outcome === "skipped") { + return ``; + } + return ``; + }) + .join(""); + const xml = `${cases}`; + const absolutePath = path.join(artifactDir, "test-results/visual-junit.xml"); + fs.mkdirSync(path.dirname(absolutePath), { recursive: true }); + fs.writeFileSync(absolutePath, xml); +} + +function adoptBaselines( + fixtureRoot: string, + { + artifactDir, + head, + write = true, + }: { + artifactDir: string; + head: string; + write?: boolean; + }, +) { + const args = [ + scriptPath, + "--from", + artifactDir, + "--run-id", + "424242", + "--head", + head, + "--reviewed-by", + "fixture-reviewer", + ]; + if (write) args.push("--write"); + return execFileSync("node", args, { + cwd: fixtureRoot, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); +} + +function seedRepository(fixtureRoot: string, awaitingValues: string) { + git(fixtureRoot, ["init", "-q"]); + git(fixtureRoot, ["config", "user.email", "fixture@example.invalid"]); + git(fixtureRoot, ["config", "user.name", "Fixture"]); + fs.mkdirSync(path.join(fixtureRoot, "src/app"), { recursive: true }); + fs.mkdirSync(path.join(fixtureRoot, "tests"), { recursive: true }); + fs.writeFileSync(path.join(fixtureRoot, "src/app/page.tsx"), "export default function Page() { return null; }\n"); + fs.writeFileSync( + path.join(fixtureRoot, "tests/ui-visual-baseline.spec.ts"), + withAwaitingValues(read("tests/ui-visual-baseline.spec.ts"), awaitingValues), + ); + return commitFixture(fixtureRoot, "candidate source"); +} + +describe("adopt-visual-baselines.mjs", () => { + it("retains unchanged baselines during a partial refresh artifact", () => { + const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "adopt-visual-partial-")); + const artifactDir = fs.mkdtempSync(path.join(os.tmpdir(), "adopt-visual-artifact-")); + try { + const head = seedRepository(fixtureRoot, ""); + for (const id of baselineIds) writePng(fixtureRoot, `tests/__screenshots__/linux/${id}.png`, validPng); + commitFixture(fixtureRoot, "seed committed baselines"); + + writePng(artifactDir, "test-results/ui-visual-baseline/dashboard-shell-actual.png", validPng); + for (const id of baselineIds.slice(1)) { + writePng(artifactDir, `tests/__screenshots__/linux/${id}.png`, validPng); + } + writeVisualJunit(artifactDir, { + "dashboard-shell": "failed", + "dashboard-shell-phone": "passed", + "search-results-band": "passed", + "search-results-band-phone": "passed", + "document-viewer": "passed", + "therapy-compass-home": "passed", + }); + + const output = adoptBaselines(fixtureRoot, { artifactDir, head }); + expect(output).toContain("dashboard-shell"); + expect(output).toContain("retained"); + expect(output).toContain("Capture kind: refresh (1 replaced from artifact)"); + + const provenance = JSON.parse( + fs.readFileSync(path.join(fixtureRoot, "tests/__screenshots__/linux/provenance.json"), "utf8"), + ); + expect(provenance.capture).toEqual({ kind: "refresh", replacedCandidateIds: ["dashboard-shell"] }); + expect(provenance.candidateSourceHead).toBe(head); + + const baselinePaths = baselineIds.map((id) => `tests/__screenshots__/linux/${id}.png`); + const trackedFiles = new Set([...baselinePaths, "tests/__screenshots__/linux/provenance.json"]); + expect(validateLinuxVisualBaselineSet(baselinePaths, { root: fixtureRoot, trackedFiles })).toEqual([]); + } finally { + fs.rmSync(fixtureRoot, { force: true, recursive: true }); + fs.rmSync(artifactDir, { force: true, recursive: true }); + } + }); + + it("accepts a refresh capture head with empty AWAITING_BASELINE", () => { + const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "adopt-visual-refresh-")); + const artifactDir = fs.mkdtempSync(path.join(os.tmpdir(), "adopt-visual-artifact-")); + try { + const head = seedRepository(fixtureRoot, ""); + for (const id of baselineIds) writePng(fixtureRoot, `tests/__screenshots__/linux/${id}.png`, validPng); + commitFixture(fixtureRoot, "seed committed baselines"); + + writePng(artifactDir, "test-results/ui-visual-baseline/dashboard-shell-actual.png", validPng); + for (const id of baselineIds.slice(1)) { + writePng(artifactDir, `tests/__screenshots__/linux/${id}.png`, validPng); + } + writeVisualJunit(artifactDir, { + "dashboard-shell": "failed", + "dashboard-shell-phone": "passed", + "search-results-band": "passed", + "search-results-band-phone": "passed", + "document-viewer": "passed", + "therapy-compass-home": "passed", + }); + + adoptBaselines(fixtureRoot, { artifactDir, head }); + + const baselinePaths = baselineIds.map((id) => `tests/__screenshots__/linux/${id}.png`); + const trackedFiles = new Set([...baselinePaths, "tests/__screenshots__/linux/provenance.json"]); + expect(validateLinuxVisualBaselineSet(baselinePaths, { root: fixtureRoot, trackedFiles })).toEqual([]); + } finally { + fs.rmSync(fixtureRoot, { force: true, recursive: true }); + fs.rmSync(artifactDir, { force: true, recursive: true }); + } + }); + + it("refuses to retain a baseline without a passing visual-junit result", () => { + const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "adopt-visual-nojunit-")); + const artifactDir = fs.mkdtempSync(path.join(os.tmpdir(), "adopt-visual-artifact-")); + try { + const head = seedRepository(fixtureRoot, ""); + for (const id of baselineIds) writePng(fixtureRoot, `tests/__screenshots__/linux/${id}.png`, validPng); + commitFixture(fixtureRoot, "seed committed baselines"); + + writePng(artifactDir, "test-results/ui-visual-baseline/dashboard-shell-actual.png", validPng); + for (const id of baselineIds.slice(1)) { + writePng(artifactDir, `tests/__screenshots__/linux/${id}.png`, validPng); + } + + expect(() => adoptBaselines(fixtureRoot, { artifactDir, head })).toThrow(/visual-junit\.xml/); + } finally { + fs.rmSync(fixtureRoot, { force: true, recursive: true }); + fs.rmSync(artifactDir, { force: true, recursive: true }); + } + }); + + it("records per-candidate SHA-256 values that match the written PNGs", () => { + const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "adopt-visual-hash-")); + const artifactDir = fs.mkdtempSync(path.join(os.tmpdir(), "adopt-visual-artifact-")); + try { + const awaitingValues = baselineIds.map((id) => JSON.stringify(id)).join(", "); + const head = seedRepository(fixtureRoot, awaitingValues); + fs.writeFileSync( + path.join(fixtureRoot, "tests/ui-visual-baseline.spec.ts"), + withAwaitingValues(read("tests/ui-visual-baseline.spec.ts"), ""), + ); + for (const id of baselineIds) { + writePng(artifactDir, `test-results/visual-candidates/linux/${id}.png`, validPng); + } + + adoptBaselines(fixtureRoot, { artifactDir, head }); + + const provenance = JSON.parse( + fs.readFileSync(path.join(fixtureRoot, "tests/__screenshots__/linux/provenance.json"), "utf8"), + ); + expect(provenance.capture).toEqual({ kind: "first-adoption" }); + for (const candidate of provenance.candidates) { + const image = fs.readFileSync(path.join(fixtureRoot, candidate.path)); + expect(candidate.sha256).toBe(createHash("sha256").update(image).digest("hex")); + } + } finally { + fs.rmSync(fixtureRoot, { force: true, recursive: true }); + fs.rmSync(artifactDir, { force: true, recursive: true }); + } + }); +}); diff --git a/tests/ui-visual-baseline.spec.ts b/tests/ui-visual-baseline.spec.ts index e82fb160b6..7eb8df3ddf 100644 --- a/tests/ui-visual-baseline.spec.ts +++ b/tests/ui-visual-baseline.spec.ts @@ -86,6 +86,26 @@ const targets: readonly BaselineTarget[] = [ route: documentPath, selector: "#main-content", viewport: desktop, + /** + * The document header and the search composer are viewport-pinned + * (`sm:sticky sm:top-0` and `sm:fixed` in `DocumentViewer.tsx`), and this + * target clips a ~2900px region against a 900px viewport. Playwright stitches + * an oversized element capture, so both land partway DOWN the image, overlap + * whatever content sits behind them at that offset, and move whenever the + * content above them changes height. That made an unrelated edit anywhere on + * the page redraw two bands of the golden and inflated every diff (#278). + * + * Masked rather than clipped away: both are real chrome that belongs in the + * frame, and narrowing the selector would drop the rail panels this target + * exists to watch. A mask is a hole in the gate, so it is limited to the two + * pinned elements — their own geometry is covered by the phone chrome + * contracts in `docs/search-chrome-behaviour.md`, not by this pixel gate. + * The header selector is the document-specific `data-document-sticky-header` + * attribute, not `.edge-glass-header`, because the universal search header + * also carries that class and would keep the fail-loud mask guard green after + * a DocumentViewer rename. + */ + mask: ["[data-document-sticky-header]", ".document-viewer-composer"], prepare: async (page) => { const sectionIndex = page.getByTestId("document-section-index"); const sourceText = sectionIndex.getByRole("button", { name: /Indexed source text/ }); @@ -116,6 +136,20 @@ const targets: readonly BaselineTarget[] = [ }, ]; +async function assertMaskSelectors(page: Page, target: BaselineTarget): Promise { + // A mask selector that matches nothing masks nothing, silently — the golden + // simply keeps comparing the region the mask was meant to exclude, and the + // declaration reads as protection that is not there. Renaming a class is + // enough to cause it, so each declared mask must resolve to at least one + // element before capture or comparison is trusted. + for (const selector of target.mask ?? []) { + await expect( + page.locator(selector), + `mask selector "${selector}" on target "${target.name}" matched no element`, + ).not.toHaveCount(0); + } +} + async function settle(page: Page, target: BaselineTarget): Promise { await page.setViewportSize({ ...target.viewport }); await page.goto(target.route, { waitUntil: "domcontentloaded" }); @@ -132,6 +166,7 @@ async function settle(page: Page, target: BaselineTarget): Promise { // Playwright cannot serialise back out of the page. await page.evaluate(() => document.fonts.ready.then(() => undefined)); await target.prepare?.(page); + await assertMaskSelectors(page, target); return region; } @@ -210,6 +245,7 @@ test.describe("visual baselines", () => { } const region = await settle(page, target); + await expect(region).toHaveScreenshot(`${target.name}.png`, { animations: "disabled", caret: "hide",