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:
parent
29dadead61
commit
b0a5872ec6
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
});
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue