diff --git a/.gitignore b/.gitignore index 09af52ce16..b3b2a90e11 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/package.json b/package.json index 75007b518d..9c6eeb4d37 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/serve-storybook-static.mjs b/scripts/serve-storybook-static.mjs new file mode 100644 index 0000000000..e57c428fbf --- /dev/null +++ b/scripts/serve-storybook-static.mjs @@ -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}`); +}); diff --git a/tests/storybook-visual/playwright.config.ts b/tests/storybook-visual/playwright.config.ts new file mode 100644 index 0000000000..38ee805f39 --- /dev/null +++ b/tests/storybook-visual/playwright.config.ts @@ -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, + }, +}); diff --git a/tests/storybook-visual/storybook-visual.spec.ts b/tests/storybook-visual/storybook-visual.spec.ts new file mode 100644 index 0000000000..f37237754c --- /dev/null +++ b/tests/storybook-visual/storybook-visual.spec.ts @@ -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 }) + .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 = { + // 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 = { + // 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, + }); + }); + } +}