Interaction testing
StoryShelf can run your Storybook play functions before each screenshot. When enabled, a failing play blocks the build (failed) so you can drop a separate storybook test / vitest CI job. Flaky stories can be marked non-blocking.
Enable it (opt-in)
Section titled “Enable it (opt-in)”Interaction tests are off by default; with them off, a screenshot still waits for Storybook to report the story rendered (see below).
- Open your project → Settings → Tests
- Check Enable interaction tests (play)
- Set Play timeout (ms) (1000–30000, default 10000)
- Save
Only that project’s builds will execute play. Global viewports (ShelfConfig.viewports) stay the default; per-story viewports remain deferred.
Writing play functions
Section titled “Writing play functions”play is standard Storybook CSF:
import type { Meta, StoryObj } from "@storybook/react";import { expect, userEvent, within } from "@storybook/test";import { Button } from "./Button";
type Story = StoryObj<typeof Button>;
export const Primary: Story = { args: { variant: "primary" }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); await userEvent.click(canvas.getByRole("button")); await expect(canvas.getByText("Clicked")).toBeVisible(); },};The runner does page.goto(iframe.html?id=...&viewMode=story) → waitForSelector(#storybook-root, attached) → wait for Storybook’s render-finished event → if enabled page.evaluate(executePlay) with playTimeoutMs → screenshot({animations: disabled}). No code change in .storybook/main.ts is required.
When the screenshot is taken
Section titled “When the screenshot is taken”The runner subscribes to the preview’s channel before the story renders and waits for Storybook’s own signal (storyRendered, or the error event of a story that fails to render) instead of a fixed sleep. After the signal it lets web fonts load and two frames paint (at most 250 ms), then waits delay if you set one. A story with no delay therefore no longer waits a fixed 500 ms, and a slow story is waited for until it is done.
If the page never sends the signal (a Storybook without the channel, or a story that never finishes rendering), the runner falls back to the earlier behaviour: after 5 seconds (the play timeout, when plays run and it is longer) it waits delay ?? 500 ms and takes the screenshot, and the log notes that the story did not signal. A story that throws while rendering or in play is still photographed; one log line names the render phase and Storybook’s message.
Per-story controls
Section titled “Per-story controls”StoryShelf reads parameters from the Storybook build. Both chromatic and storyshelf keys are supported; storyshelf wins on conflict so existing Chromatic users work unchanged.
| Parameter | Where | Effect |
|---|---|---|
disableSnapshot | parameters: { storyshelf: { disableSnapshot: true } } or chromatic:{disableSnapshot:true} | Skip capture + play entirely — not counted, no failed |
flakyTest | parameters: { storyshelf: { flakyTest: true } } or chromatic:{flakyTest:true} | Failure is non-blocking: build stays reviewing/approved, UI shows warning, GitHub status stays success with a warning comment |
delay | parameters: { storyshelf: { delay: 300 } } | Extra wait (ms) after Storybook reports the story rendered, before the screenshot (no extra wait when unset; 500 only as the fallback) |
diffThreshold | parameters: { storyshelf: { diffThreshold: 0.2 } } | Per-story pixelThreshold override (0–1) |
pauseAnimationAtEnd | parameters: { storyshelf: { pauseAnimationAtEnd: true } } | Pause CSS animations before screenshot; otherwise animations:"disabled" |
Tags are story-level and case-insensitive. Whole-story non-blocking:
// via tags (works even when index.json has no parameters)export const Flaky: Story = { tags: ["flaky-test"], play: async () => { throw new Error("flake"); },};
// via parameters (both keys work)export const FlakyParam: Story = { parameters: { storyshelf: { flakyTest: true } }, play: async () => { throw new Error("flake"); },};export const ChromaticFlaky: Story = { parameters: { chromatic: { flakyTest: true } }, play: async ({ canvasElement }) => { /* ... */ },};
export const Hidden: Story = { parameters: { storyshelf: { disableSnapshot: true } }, // or tags: ["skip"] — also disables via isDisabledStory};disableSnapshot just disables; there is no reviewer waive in the build review UI (Approve/Reject only) — mark flaky-test/flakyTest in code or fix the test.
Storybook 10 .test() entries
Section titled “Storybook 10 .test() entries”Storybook 10’s CSF-Next story.test("name", async ({ canvas, userEvent }) => { ... }) adds a test to a story. Write it as a statement of its own, not an exported call (Storybook’s indexer does not see the latter):
Primary.test("renders its label", async ({ canvas }) => { await expect(canvas.getByRole("button")).toHaveTextContent("Button");});Each test is an index entry with subtype: "test". StoryShelf never screenshots them, and by default ignores them. Under Settings → Tests check Run Storybook tests (.test()) (runTests, default off, also on the project API) and each test runs once per build, in the same browser as the project’s primary target: the runner opens the test’s own iframe URL, Storybook renders the story and runs the test body, and the runner reads pass or fail from Storybook’s events (a body that throws, a story that fails to render, or no finish within the play timeout fails the test).
- A failing test fails the build, exactly like a failing
play; a test of aflaky-teststory does not, and the tests of a story markedskipordisableSnapshotare not run. - The build page shows a Storybook tests card: failures first with their message, passes behind a disclosure. The same results are in the
testResultsJSON of the build on the API. - Tests take no screenshot, write no baseline and add no build status. Storybook 8 and 9 have no test entries, so the setting does nothing there. Tests run on every build, also with affected capture.
How parameters are discovered
Section titled “How parameters are discovered”For the background on the preview page this reads from, see How Storybook works.
Storybook 8’s index.json (v5) omits parameters; StoryShelf prefers stories.json (which has them) then falls back to runtime page.evaluate(() => __STORYBOOK_PREVIEW__.extract()[id].parameters) for SB 8 without buildStoriesJson.
You do not need to edit .storybook/main.ts. When storyshelf upload builds Storybook for you, it sets STORYBOOK_BUILD_STORIES_JSON=true automatically. If you build outside upload (storybook build -o storybook-static then upload without rebuild), the runtime fallback still covers it. For a persistent file, you may add:
export default { // ... features: { buildStoriesJson: true },};Build status and GitHub
Section titled “Build status and GitHub”- Blocking
playfailure (!flaky) →build.status='failed', GitHub statusfailure, PR blocked. - Flaky failure (
flaky-test/flakyTest) →build.statusstaysreviewing/approved(visual diff decides), GitHub statussuccesswith a warning comment⚠️ flaky story failed — not blocking, UI shows a warning banner/badge. - Disabled → not counted in
snapshotCount, no baseline update.
Retry a flaky capture with Retry on the build page (POST /api/v1/projects/:slug/builds/:id/retry).
See Project settings → Tests for the toggle, ADR 0017 for the full decision, and Configuration for buildStoriesJson details.