test: Storybook visual snapshot suite (Phase 0 tooling)

Playwright-based screenshot suite over the built Storybook: one test per
story per theme (dark/light), byte-identical comparison (maxDiffPixels 0),
Date frozen via init-script shim (page.clock broke React rendering in
several stories), animations disabled, per-story settle map for delayed
state flips and a mask for one bimodal ::highlight race.

Run with pnpm test:storybook-visual (or :update to re-baseline).
No new dependencies: reuses the repo's @playwright/test and a
dependency-free static server script.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
scotttong 2026-07-06 13:48:28 -07:00
parent 29dadead61
commit b0a5872ec6
5 changed files with 212 additions and 0 deletions

2
.gitignore vendored
View File

@ -56,6 +56,8 @@ tests/e2e/test-results/
tests/e2e/playwright-report/
tests/release-smoke/test-results/
tests/release-smoke/playwright-report/
tests/storybook-visual/test-results/
tests/storybook-visual/playwright-report/
.superset/
.superpowers/
.claude/worktrees/

View File

@ -48,6 +48,8 @@
"smoke:pipelines-tutorial": "./scripts/smoke/pipelines-tutorial-smoke.sh",
"smoke:terminal-bench-loop-skill": "node scripts/smoke/terminal-bench-loop-skill-smoke.mjs",
"test:release-registry": "node --test scripts/verify-release-registry-state.test.mjs scripts/release-package-map.test.mjs scripts/check-release-package-bootstrap.test.mjs scripts/check-no-git-push.test.mjs scripts/release-lib.test.mjs scripts/link-plugin-dev-sdk.test.js",
"test:storybook-visual": "pnpm build-storybook && npx playwright test --config tests/storybook-visual/playwright.config.ts",
"test:storybook-visual:update": "pnpm build-storybook && npx playwright test --config tests/storybook-visual/playwright.config.ts --update-snapshots",
"test:e2e": "npx playwright test --config tests/e2e/playwright.config.ts",
"test:e2e:headed": "npx playwright test --config tests/e2e/playwright.config.ts --headed",
"test:e2e:multiuser-authenticated": "npx playwright test --config tests/e2e/playwright-multiuser-authenticated.config.ts",

View File

@ -0,0 +1,69 @@
#!/usr/bin/env node
// Tiny dependency-free static file server for the built Storybook
// (ui/storybook-static). Used by the visual snapshot suite's webServer so we
// don't add an http-server dependency.
import { createServer } from "node:http";
import { createReadStream, existsSync, statSync } from "node:fs";
import { extname, join, normalize, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const root = resolve(
fileURLToPath(new URL(".", import.meta.url)),
"..",
"ui",
"storybook-static",
);
const port = Number(process.env.PORT ?? 6106);
if (!existsSync(join(root, "index.html"))) {
console.error(
`No built Storybook at ${root}. Run \`pnpm build-storybook\` first.`,
);
process.exit(1);
}
const MIME = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".mjs": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json; charset=utf-8",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".svg": "image/svg+xml",
".ico": "image/x-icon",
".woff": "font/woff",
".woff2": "font/woff2",
".ttf": "font/ttf",
".map": "application/json; charset=utf-8",
".txt": "text/plain; charset=utf-8",
".webm": "video/webm",
".mp4": "video/mp4",
};
const server = createServer((req, res) => {
const url = new URL(req.url ?? "/", `http://localhost:${port}`);
let filePath = normalize(join(root, decodeURIComponent(url.pathname)));
if (!filePath.startsWith(root)) {
res.writeHead(403).end("forbidden");
return;
}
if (existsSync(filePath) && statSync(filePath).isDirectory()) {
filePath = join(filePath, "index.html");
}
if (!existsSync(filePath)) {
res.writeHead(404).end("not found");
return;
}
res.writeHead(200, {
"content-type": MIME[extname(filePath)] ?? "application/octet-stream",
"cache-control": "no-store",
});
createReadStream(filePath).pipe(res);
});
server.listen(port, () => {
console.log(`storybook-static served at http://localhost:${port}`);
});

View File

@ -0,0 +1,39 @@
import { defineConfig } from "@playwright/test";
// Visual snapshot suite for the design-token extraction run: screenshots every
// built Storybook story in both themes and compares against the committed
// Phase 0 baseline. Run via `pnpm test:storybook-visual` (builds Storybook
// first) or `SKIP_SB_BUILD=1` + `npx playwright test -c tests/storybook-visual`
// against an existing ui/storybook-static build.
export default defineConfig({
testDir: ".",
outputDir: "./test-results",
timeout: 60_000,
retries: 1,
workers: 4,
fullyParallel: true,
reporter: [["list"], ["html", { open: "never", outputFolder: "playwright-report" }]],
expect: {
toHaveScreenshot: {
animations: "disabled",
caret: "hide",
scale: "css",
// Same machine, same browser build: require byte-identical rendering.
maxDiffPixels: 0,
},
},
snapshotPathTemplate: "{testDir}/__snapshots__/{arg}{ext}",
use: {
browserName: "chromium",
viewport: { width: 1200, height: 800 },
deviceScaleFactor: 1,
reducedMotion: "reduce",
baseURL: "http://localhost:6106",
},
webServer: {
command: "node ../../scripts/serve-storybook-static.mjs",
url: "http://localhost:6106/index.json",
reuseExistingServer: true,
timeout: 30_000,
},
});

View File

@ -0,0 +1,100 @@
import { existsSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { expect, test, type Page } from "@playwright/test";
// One screenshot test per story per theme, generated from the built
// Storybook's index.json. The committed __snapshots__/ folder is the Phase 0
// zero-visual-change baseline; any later refactor must keep every image
// byte-identical.
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
const indexJsonPath = join(repoRoot, "ui", "storybook-static", "index.json");
if (!existsSync(indexJsonPath)) {
throw new Error(
`Missing ${indexJsonPath} — run \`pnpm build-storybook\` before the visual suite.`,
);
}
type IndexEntry = { id: string; type: string; title: string; name: string };
const entries = Object.values(
(JSON.parse(readFileSync(indexJsonPath, "utf8")) as { entries: Record<string, IndexEntry> })
.entries,
).filter((entry) => entry.type === "story");
// Freeze wall-clock time so relative timestamps, spinners driven by
// setInterval, and Date.now()-based rendering are deterministic.
const FIXED_TIME = new Date("2026-06-24T12:00:00.000Z");
const THEMES = ["dark", "light"] as const;
// Stories whose components schedule a delayed state flip (e.g. a "recently
// focused" highlight that clears via setTimeout). Wait past the flip so the
// screenshot always captures the settled terminal state.
const EXTRA_SETTLE_MS: Record<string, number> = {
// IssueContinuationHandoff clears its focus highlight after 3s + 1s fade.
"product-issue-management--full-surface-matrix": 4500,
};
// Stories with a genuinely bimodal render race that cannot be settled by
// waiting. The affected element is masked (solid overlay in both baseline and
// comparison) so the rest of the story still snapshot-verifies.
const MASKED_SELECTORS: Record<string, string> = {
// DocumentAnnotationLayer's ::highlight range over "two selectors" ends 1-2
// characters short on ~half of renders (anchor offsets race). Mask only the
// paragraph that carries that highlight.
"product-documents-annotations--integrated-mobile-bottom-sheet":
'p:has-text("Use a sidecar anchor made from")',
};
async function renderStory(page: Page, storyId: string, theme: (typeof THEMES)[number]) {
// Freeze Date only (not timers): page.clock.setFixedTime breaks React
// rendering in several stories (intermittent "must be used within Provider"
// errors), so shim the Date constructor instead.
await page.addInitScript(`{
const fixedNow = ${FIXED_TIME.getTime()};
const RealDate = Date;
class FixedDate extends RealDate {
constructor(...args) {
if (args.length === 0) { super(fixedNow); } else { super(...args); }
}
static now() { return fixedNow; }
}
FixedDate.parse = RealDate.parse;
FixedDate.UTC = RealDate.UTC;
window.Date = FixedDate;
}`);
await page.goto(
`/iframe.html?id=${encodeURIComponent(storyId)}&viewMode=story&globals=theme:${theme}`,
{ waitUntil: "load" },
);
// Wait for Storybook to finish rendering (sb-show-main) or error out.
// Don't check #storybook-root children: portal-only stories (open dialogs,
// sheets) render into document.body and leave the root empty.
await page.waitForFunction(() => {
const body = document.body;
return (
body.classList.contains("sb-show-main") ||
body.classList.contains("sb-show-errordisplay")
);
});
const errored = await page.locator(".sb-show-errordisplay").count();
expect(errored, `story ${storyId} threw during render`).toBe(0);
await page.evaluate(() => document.fonts.ready.then(() => undefined));
const settleMs = EXTRA_SETTLE_MS[storyId];
if (settleMs) await page.waitForTimeout(settleMs);
}
for (const entry of entries) {
for (const theme of THEMES) {
test(`${entry.id} [${theme}]`, async ({ page }) => {
await renderStory(page, entry.id, theme);
const maskSelector = MASKED_SELECTORS[entry.id];
await expect(page).toHaveScreenshot(`${entry.id}--${theme}.png`, {
fullPage: true,
mask: maskSelector ? [page.locator(maskSelector)] : undefined,
});
});
}
}