Skip to content

Capture & viewports

View as Markdown

Capture is server-side. The CLI uploads a zipped static Storybook; the server extracts it, serves it locally, and screenshots the stories impacted by the change — unaffected stories are inherited from their baselines (see Affected capture). For the parts of a built Storybook the render relies on, see How Storybook works.

A build's overview: snapshot stats, bulk actions, and a card per snapshot A build's overview: snapshot stats, bulk actions, and a card per snapshot

Enabling testing requires a long-running server (a container, VM, Fly Machine or Kubernetes pod). It cannot run on serverless platforms that freeze or end the process after each request (Vercel Functions, AWS Lambda, Cloudflare Workers), because capture jobs run for minutes in the background and the job consumer must keep running. A serverless deployment can host Storybooks only, by leaving testing unset.

There is no separate worker to deploy: the instance that takes the upload also renders it. Without testing the instance only hosts Storybooks.

flowchart LR
    A[Upload zip] --> B[Extract to scratchDir]
    B --> C[Serve statically]
    C --> D[Discover stories]
    D --> E[Partition affected]
    E --> F[Playwright/Puppeteer render affected]
    F --> G[Diff vs baseline]
    G --> H[Persist snapshots + inherit rest]

The orchestrator (core/capture/orchestrator.ts) owns loading, extraction (scratchDir), discovery (StorySourceAdapter), affected partitioning, and persistence. The CaptureRunner is pure — it only returns PNG buffers. Sources expose an optional loadDependencyGraph hook so the orchestrator can trace changes; sources without one render everything.

interface CaptureRunner {
render(input: {
buildId;
storybookDir;
stories;
viewports;
browser?;
executePlay?;
playTimeoutMs?;
}): Promise<RenderResult>;
cancel(buildId, runId?): Promise<void>; // one render (a shard), or all of the build
}

Two runners ship — switch with one import:

RunnerPackageEngines
Playwright@storyshelf/runner-playwrightchromium (default); firefox, webkit, chrome when listed
Puppeteer@storyshelf/runner-puppeteerchromium/chrome only, chrome-headless-shell — smaller image

The runner decides which capture targets exist: its browsers option lists them (see the runner pages), and System → Capture browsers shows each as ready or unavailable with the reason (a declared engine that is not installed, a target that needs a public origin that is not known yet). A target is an engine (chromium) or, for a remote browser, <engine>@<name> (chromium@grid), which has its own baselines and is never compared with a local render. A remote target connects to a browser on another machine (a self-hosted Playwright server, a hosted service) instead of launching one; see Remote browsers. Its fingerprint is measured inside that browser, not on the host. Per-project target lives in Settings → General → Capture browser or PATCH /api/v1/projects/:slug { browser }. Set browsers (up to 3 targets, primary first) to capture every build on each target in turn; baselines, diffs and storage paths are kept per target (<mode>~<target> for non-Chromium). Saving a target the runner does not offer, or that is unavailable, is refused with 400 and the list of what it does offer; a build of a project that already names one fails at once with the same reason. A project keeps the targets it has: the runner’s default applies to new projects only. Every snapshot records an infraHash: a hash of its capture fingerprint (engine, browser build, platform, device scale factor, installed fonts, viewport and sizing defaults; for a remote target the browser reports the platform, ratio and a font digest itself), with the components kept on the baseline so a change can be named. When a component of the fingerprint differs from the baseline’s (a browser upgrade, a different base image, new fonts), the same code would render differently. On the default branch StoryShelf does not diff: the snapshot is accepted as an environment migration, the review page says what changed, and the baseline is updated to the new rendering. On other branches the snapshot is still diffed, and a difference needs review as usual; the review page notes that it may come from the environment. Components a capture did not report are treated as unknown, not changed, and a viewport size change (it comes from story parameters) always diffs. A baseline recorded before fingerprints is compared normally and given its fingerprint on the next default-branch capture. A real regression landing on the default branch in the same build as an environment change is not caught, so upgrade the browser in its own commit, and keep every instance that captures on the same image, architecture and fonts. A newly added engine has no baselines yet, so its first snapshots are auto-accepted where the primary engine’s snapshot of the same story and mode is clean, and queued for review as new otherwise.

Global viewports live in ShelfConfig.viewports (or project settings). Per-story parameters.viewport adds to the union (deduped by name). The runner auto-crops small components (≈60% whitespace) with 16px padding and clamp (minWidth/maxWidth etc.), and falls back to fullPage on overflow — see core/capture/sizing.ts.

Configure via server config or project patch. The queue then fans out stories × viewports renders at testing.concurrency.

Captures are jobs on a database queue (the capture_jobs table of your own database), not on an in-memory list. Uploading a build inserts a job and wakes this instance’s consumer at once, so a single instance starts the capture immediately; other instances find the job on their next poll. Every instance with testing both enqueues and consumes, and testing.concurrency is how many jobs it runs at once.

  • Leases, not sweeps. A job is claimed for a lease that its own progress renews (every attempt-log line counts), never a timer, so a hung capture loses its lease and another instance picks the job up. Nothing resets builds at boot: a second instance can start while the first is capturing without touching its builds, and an instance that is killed mid-capture is covered by lease expiry and redelivery.
  • One retry limit. testing.retries (default 2, three attempts in all) covers failed attempts, lost leases and forked-child crashes. A failing job waits 1 s × 2^attempts (at most 30 s) before it is claimed again. On the last attempt the build takes the usual terminal path (failed, git status, admin alert), so a crash never leaves a build in capturing.
  • Cancel. A cancel flag on the job row is read by the instance that runs it, so it works across instances and for a hung capture.
  • Polling. An instance that has every slot busy does not poll. Idle, it probes the table once every 15 s (one indexed query that reads no rows); for a minute after a job was found or queued it polls every 2 s.

The Compute jobs page reads the same table, so it shows the jobs of every instance.

By default the server renders in its own process, so a browser crash or a runaway capture shares a process with the web UI. Set testing.workerEntry to run captures in a forked child instead:

// capture-worker.ts — the module named by testing.workerEntry
import { runCaptureChild } from "@storyshelf/core/capture";
await runCaptureChild({ db: database, storage, runner, scratchDir });
const app = createShelfApp({
database,
storage,
testing: { runner, workerEntry: new URL("./capture-worker.ts", import.meta.url).pathname },
});

The server stays the consumer of the queue: it holds the concurrency slots and the lease, and pushes each leased job to the child over IPC. The child runs the whole pipeline with the server’s database, storage and runner, and reports only progress, done or failed; every progress message renews the job’s lease. The child never reads the queue. When a big build fans out into shards, the child inserts the shard jobs into the database queue itself.

A crash counts against the job like any failed attempt (one limit, testing.retries), and the child is restarted for the next job. A crash is charged only to the job that caused it; jobs that shared a crashed child are pushed again without a charge, one at a time, so a repeat crash names its culprit. After repeated crashes (3 in 5 minutes) or a failed fork, capture falls back to running in the server process. A child that hangs without dying is caught too: if a running job goes a whole lease (testing.leaseSeconds, 5 minutes by default) without progress, the server kills the child and treats it like a crash.

testing.mode controls this: auto (default) forks only when workerEntry is set and the host has at least 2 CPUs and 2 GB; in-process never forks; fork always forks and fails at boot if it cannot. testing.runner is still required, since it serves the fallback. On SIGTERM the server stops polling, lets the jobs in flight finish, then stops the child; the child itself also lets its current captures finish on SIGTERM and exits at once if the server disappears. Forked capture is an opt-in for hosts that run local browsers; to capture more at once, run more instances against the same database.

A build with thousands of captures does not have to render in one job. Set testing.shardSize to the number of captures (story × viewport × browser engine) one shard should hold:

createShelfApp({ ..., testing: { runner, shardSize: 500 } });

The build’s own job becomes a plan: it decides what to render, records the shards in capture_shards and puts one queue job per shard on the queue. Any instance can take a shard; the one that finishes the last shard claims the finalize (a unique insert, so exactly one instance wins), runs the build’s diffing wrap-up and sets the final status. A build of up to twice shardSize captures still renders as one piece, so small Storybooks pay no overhead.

Details worth knowing:

  • All of a story’s engines stay in one shard, so engine bootstrapping never races across shards.
  • Shards are balanced by what each story cost last time, not just by how many captures they hold — see Shard ordering.
  • A story that crashes the browser is failed on its own and the rest of its shard is still captured — see Crashing stories.
  • A shard that cannot render at all is retried by the queue like any job; once its retries are used up it fails every story in it and the build ends failed.
  • Re-running a build starts from a clean slate: old shard rows are replaced by the new plan.
  • With testing.workerEntry the forked child plans the build and inserts its shard jobs into the database queue itself; the server picks them up like any other job and pushes them back to the child. The server passes testing.shardSize and testing.retries to the child; set shardSize in the entry module only to override it. On a single host this buys retries and isolation per shard rather than speed.

Shards of equal capture count are not shards of equal duration: a story with a heavy play function or a slow data mock can cost many times what a plain one does, so a shard that collects a few of them straggles and sets the whole build’s finish time.

StoryShelf records how long each capture took (snapshots.duration_ms, reported by the runner) and the next build’s plan uses it. Stories are packed longest-first — the dearest placed first, each into the shard with the least work that still has room — and the shards are handed to the queue heaviest first, so the slow work starts while there are still instances free. A story nothing is known about is priced at the average cost of one capture, and the capture ceiling still applies, so shards stay about shardSize.

The timings come from the newest of the last few builds that recorded any. A fresh project, a runner that reports no durations, or a retention purge that removed the timed builds simply means no timings: the plan then slices by capture count in story order, exactly as it did before.

A story can take the whole browser down with it — an out-of-memory render, a native crash, a hung page the runner cannot recover. The render call then throws and loses the stories it had not reached yet, which without care fails every story that happened to share the pass.

StoryShelf narrows the crash down instead. The screenshots already taken are kept, the stories the crash never reached are re-rendered as two halves, and that halving repeats until one story is left holding it. That story is failed on its own with browser crashed: …, its neighbours are captured normally, and the build fails only on the one story (unless it is marked flaky, which stays non-blocking). Finding one bad story costs about log₂(n) extra render passes, not one browser per story, and the attempt log records each step.

If no pass produces a single screenshot, the problem is read as the environment rather than any one story — a browser that will not start, missing statics — and the whole pass fails as before: the queue retries it, and its final failure fails the build. This applies to sharded and unsharded builds alike.

Published Storybooks are deduplicated by content hash. persistStorybookStatics (core/capture/statics.ts) sha256s every file, writes once to content/<hash> (storage.write(content/<hash>, buffer) if missing), upserts content_refs { hash, refCount, lastSeenAt } via db.tables.contentRefs (type-safe DatabaseAdapter.tables, no as never), and writes a per-build manifest.json ({ "assets/index.js": "abc..." }). Serving prefers manifest.json → content/<hash> with readWithManifestFallback, falling back to the legacy per-build copy during migration. Uploads use the same dedup: POST /dedup { hashes } → needed, batch POST /content (multipart, 80% bytes fallback neededBytes/totalBytes > 0.8 → PUT zip), then POST /manifest — see Publishing and ADR 0003.

Screenshots are stored by the SHA-256 of their bytes, per project: <project>/content/<hash>.png. Snapshots and baselines both point at the object, so approving a snapshot repoints the baseline instead of copying it, and a story that renders the same bytes build after build uploads nothing. When a new screenshot has the same path as its baseline the bytes are identical, so the diff is skipped without reading either image.

Nothing deletes a content object directly, because snapshots and baselines share them. The retention purge sweeps instead: an object that no snapshot or baseline references is deleted after it has stayed unreferenced across two purges at least 24 hours apart (a marker object <project>/content/.sweep.json remembers when each was first seen). Screenshots written by earlier versions keep their old per-build and per-baseline paths and are cleaned up as before.

pixelmatch + pngjs compare against the selected baseline (see Baselines). Per-pixel pixelThreshold (default 0.1) and per-snapshot maxDiffRatio (default 0.01) decide changed vs unchanged; per-story parameters.diffThreshold (or chromatic.diffThreshold with storyshelf winning) overrides. Overlay is stored at diffs/{storyId}/{viewport}.png — 50% dim + red heatmap.

Before each screenshot the runner waits until Storybook says the story is done: it listens to the preview’s channel from before the story renders and continues on storyRendered (or the story’s error event), then lets fonts load and two frames paint, then waits parameters.delay if the story sets one. A page that never announces it (an older or custom Storybook, or a story stuck rendering) gets the earlier fixed wait instead: delay ?? 500 ms after #storybook-root appears, once a 5 second timeout (or the play timeout, when plays run) has passed. When Enable interaction tests (play) is on (per-project), the runner then runs page.evaluate(executePlay) with playTimeoutMs before the screenshot. A throwing play maps to failed (blocking) or warning (flaky) — see Interaction testing guide.

With Run Storybook tests (.test()) on, the Storybook 10 .test() entries run once per build in the same browser, with no screenshot, and a failing one fails the build like a failing play; the build page lists them under Storybook tests. See Interaction testing.

A server that is stopped or killed while a build is capturing does not strand it. Every capture job holds a lease (testing.leaseSeconds, five minutes by default) that the running job renews on a timer and on progress; when the lease runs out, the next instance that polls claims the job again (and retires the dead attempt). The new attempt starts from the start of the capture: the previous attempt is recorded as failed and the build runs again. If every allowed attempt (testing.retries + 1) is lost, the job fails, the build is marked failed and its git status reports failure, so a crash loop never repeats captures forever. A graceful stop (SIGTERM) drains first: the instance stops polling and lets the jobs in flight finish. The Compute jobs page lets you retry a failed build.

Capture scales by adding instances, not by adding a second kind of deployable: every instance with testing enqueues and consumes on the shared database queue, so a second container pointed at the same database and storage takes its share of jobs. See Deployment and Docker Compose.

There is no queue option: testing needs a runner, and captures always queue on the database.