Generator-first Playwright screenshot testing for Storybook.
npm install --save-dev @crvy/strybkGenerate screenshot specs from a Storybook index:
crvy-strybk generate --config ./strybk.config.tsUse --dry-run to compute outputs without writing files:
crvy-strybk generate --config ./strybk.config.ts --dry-runThe config module should export a StrybkConfig object, typically as the default export from defineConfig(...).
Minimal strybk.config.ts:
import { defineConfig } from "@crvy/strybk";
export default defineConfig({
storybookUrl: "http://localhost:6006",
storyGlobs: ["src/**/*.stories.tsx"],
resolveSpecPath: ({ storyFilePath }) => storyFilePath.replace(/\.stories\.tsx$/, ".spec.ts"),
});Each generated spec renders a Playwright test per story. At runtime, parameters.creevey (read from the running Storybook, merged across global / kind / story levels) drives capture and skip behavior — no Storybook addon required:
Generated suites import describe, test, expect, switchStory from @crvy/strybk and nest one describe per title segment — Components/Button becomes describe('Components') > describe('Button') — so the Playwright HTML reporter shows a collapsible tree. Snapshot filenames are unaffected. Note that --grep patterns containing / no longer match (Playwright greps the space-joined title path); grep by a single segment instead, e.g. --grep CommentLine.
captureElement: '<selector>'— capturespage.locator('<selector>').captureElement: null(or unset) — captures the viewport.skip: { '<reason>': { in, kinds, stories } }— marks the test skipped with<reason>. Note:inmatches the Playwright project name (not the browser engine), so with the default project namechromium, a rule like{ in: 'chrome' }won't match — name your Playwright projects to line up with yourin:rules, or scope rules viakinds/stories.ignoreElements: '<selector>' | ['<selector>']— masks those elements viatoHaveScreenshot({ mask }).delay: <ms>— waits<ms>milliseconds after switching to the story before resolving the capture target. Non-positive or non-numeric values are treated as0.
Example:
// stories/MyModal.stories.tsx
export default {
title: "MyModal",
parameters: { creevey: { captureElement: "#storybook-root" } },
};
export const Default = {
parameters: { creevey: { ignoreElements: ".timestamp" } },
};Prerequisites for running specs: @playwright/test (peer dependency) and browser binaries (npx playwright install). The crvy-strybk generate command itself needs neither — only a running Storybook to fetch index.json. To run the same specs under Vitest Browser Mode instead, see Vitest Browser Mode: vitest + @vitest/browser-playwright (optional peers) and a built Storybook.
The same generated spec files run under either runner — no generator flag, no config change. playwright test and vitest run both discover *.spec.ts by default; the @crvy/strybk import resolves to the Playwright runtime in Node and to the Vitest runtime inside Vitest Browser Mode (via the browser package export condition). Pick a runner by choosing which command you execute.
Requirements and wiring:
-
Build your Storybook. The Vitest path serves
storybook buildoutput through a proxy; dev-mode Storybook servers are not supported (their absolute vite dev asset URLs cannot resolve under the proxy). The dev loop stays on Playwright.npx storybook build npx http-server storybook-static -p 6007 # or any static server -
Wire the proxy plugin in
vitest.config.ts. It mounts the Storybook origin under a reserved prefix (default/storybook, configurable) on the Vitest dev server so the embedded preview iframe is same-origin:import { strybkStorybookProxy } from "@crvy/strybk/vite"; import { playwright } from "@vitest/browser-playwright"; import { defineConfig } from "vitest/config"; export default defineConfig({ plugins: [strybkStorybookProxy({ target: "http://127.0.0.1:6007" })], test: { browser: { enabled: true, provider: playwright(), headless: true, instances: [{ browser: "chromium", viewport: { width: 1280, height: 720 } }], }, provide: { strybkBrowser: "chromium" }, }, });
Reserve the prefix: requests under
/storybookare proxied to the target, so keep it free of your own routes (or pass a differentprefix). -
Provide the browser identity (
strybkBrowser) soskipin:rules match like Playwright project names — the default ischromium. Optionally providestrybkGlobals(applied to the preview through the Storybook channel before capture), mirroring the Playwright path'sstorybookGlobalsproject metadata.
Notes:
- Baselines live under
__screenshots__/next to the specs. The first run writes the baseline and fails ("No existing reference screenshot found; a new one was created"); seed baselines without failing withvitest run --update. Each runner keeps its own baseline tree — do not share them across runners. - Capture parity is pinned by the runtime: CSS-pixel scale (
scale: "css"), Playwright's default comparator threshold (0.2), and animations disabled. One caveat: keep the instance viewport within the headless browser window — Vitest scales its tester iframe when the viewport does not fit, which shrinks captures (this affects Vitest's ownpage.screenshot()identically). - Generated specs import
describefrom the package. Regenerate to adopt the new shape; un-regenerated specs that calltest.describekeep collecting through the runtime bridge on every supported vitest 4.x. - Tested against vitest 4.0.18 and 4.1.11 (peer range
vitest >=4 <5).
Portable interaction subset for manual regions (sharedPage.locator(...)):
| Portable API | Behavior |
|---|---|
click, dblClick, hover, fill, clear |
Delegates to the Vitest locator; options (for example hover({ position })) pass through to the provider |
press |
Accepts Playwright key chords (Control+A) and maps them to testing-library keyboard syntax |
type |
Types text into the element (Vitest's userEvent.type) |
nth(i), first(), last(), locator(selector) |
Re-wrapped locators; nested locator() scopes inside the parent, and masks/capture targets accept them |
dragTo(target, options) |
Maps to Vitest's dropTo; accepts Playwright-style sourcePosition/targetPosition, force, timeout |
sharedPage.waitForTimeout(ms) |
Waits before capture; backs the generated delay support |
Playwright-only APIs — manual regions using them stay single-runner:
sharedPage.mouse- Web-first element assertions (
expect(locator).toHaveText(...)and friends) waitForLoadStateand the rest of the PlaywrightPage/Locatorsurface
For pointer positioning, the portable replacement for sharedPage.mouse is sharedPage.locator('body').hover({ position: { x, y } }).
metadataExtractors has been removed. skip and captureElement now resolve automatically at runtime — remove any metadataExtractors: ["creevey"] line from your strybk.config.ts and re-run crvy-strybk generate. Skipped stories now appear as skipped in Playwright reports rather than being omitted from the spec file.
Preview the next changelog entry:
bun run changelog:previewRegenerate the full changelog from git history:
bun run changelog:generateBuild the package:
bun run buildRegister this package for local linking:
cd /path/to/strybk
bun linkLink it into the consumer project:
cd /path/to/consumer-project
bun link @crvy/strybkIf you want to persist the link in the consumer's manifest, Bun supports a link: dependency entry:
{
"dependencies": {
"@crvy/strybk": "link:strybk"
}
}Install dependencies with Bun:
bun installCommon local commands:
bun run lint
bun run typecheck
bun run test:bun
bun run build
bun run check