@storyshelf/runner-puppeteer
@storyshelf/runner-puppeteer is a lightweight CaptureRunner that renders Storybook screenshots with Puppeteer (puppeteer-core + chrome-headless-shell). It produces Chromium-only captures at a smaller image size than the Playwright runner. Wire it into createShelfApp through the testing option.
For what the runner reads from a built Storybook (the preview page, the story index, play), see How Storybook works.
Install
Section titled “Install”npm i @storyshelf/runner-puppeteerpnpm add @storyshelf/runner-puppeteeryarn add @storyshelf/runner-puppeteer- npm package — install tarballs and version history.
- JSR package — TypeScript-first registry page.
- Public API reference — generated docs for every export; start here to learn the API. (The same reference is on JSR.)
- Source on GitHub — package directory on
main.
import { createPuppeteerCaptureRunner } from "@storyshelf/runner-puppeteer";
const runner = createPuppeteerCaptureRunner({ // defaults to the test-bundled chrome-headless-shell; point at your own Chromium when needed executablePath: process.env.CHROME_PATH,});Hand the runner to the server:
import { createShelfApp } from "@storyshelf/app";import { createPuppeteerCaptureRunner } from "@storyshelf/runner-puppeteer";
const app = createShelfApp({ database, storage, testing: { runner: createPuppeteerCaptureRunner() },});Concurrency and memory
Section titled “Concurrency and memory”Pages render through a bounded pool instead of all at once. The default is sized from the detected CPU and memory, container limits included (one page on 0.5–1 vCPU hosts, up to 4); override with createPuppeteerCaptureRunner({ concurrency }) or STORYSHELF_CAPTURE_CONCURRENCY. The per-page timeout scales with CPU (30 s, 60 s under 2 CPUs, 90 s under 1 CPU); override with navigationTimeoutMs or STORYSHELF_NAVIGATION_TIMEOUT_MS. The browser is restarted every 150 captures to bound its slow memory growth (recycleBrowserEvery, or STORYSHELF_BROWSER_RECYCLE_EVERY; 0 disables), and each render logs a capture sizing line with the detected CPUs, memory and resulting settings. When the orchestrator passes onCapture, each screenshot is handed over as soon as it is taken instead of being collected in the result, so memory does not grow with the story count. Set reusePages: true (or STORYSHELF_REUSE_PAGES=1) to reuse a pool of reset pages (own context each; storage, cookies and IndexedDB cleared between captures; failed pages discarded). It is off by default. The default launch args add low-memory flags (--disable-dev-shm-usage, --disable-extensions, --disable-background-networking, --mute-audio); passing your own args replaces them. See the deployment guide's "Sizing memory for capture" section.
See the StoryShelf documentation for configuration details.
Render-settled signalling
Section titled “Render-settled signalling”Before each screenshot the runner waits for Storybook’s own render-finished event, not a fixed sleep. A small script, registered before the page’s scripts run, listens on the preview’s channel (__STORYBOOK_ADDONS_CHANNEL__, through its public on method; nothing is patched) and records storyRendered, storyFinished and the story error events, the render phase, and any error the story or its play function raised. The capture resumes on the first terminal event, lets fonts load and two frames paint, then honours parameters.delay if the story sets one. With no channel on the page the runner falls back at once, and when the channel stays silent it falls back after 5 seconds (or the play timeout when plays run and it is longer) to delay ?? 500 ms after #storybook-root appears. A story that errors while rendering or playing is still photographed and logged with its last render phase and Storybook’s message. Screenshots of stable stories are byte-identical to the fixed-wait capture, and a build renders about 30 % faster.
Storybook 10 tests
Section titled “Storybook 10 tests”runTests (the optional CaptureRunner.runTests) opens each .test() entry’s iframe URL on the project’s primary target, waits for the same signal, and reports passed or failed with Storybook’s message and a duration; it takes no screenshot. The orchestrator calls it once per build when the project’s Run Storybook tests (.test()) setting is on. See Interaction testing.
Accessibility checks (axe)
Section titled “Accessibility checks (axe)”With the project’s runA11y flag on (the default; a project admin turns it off under Settings > Tests, or runA11y: false in the API), the runner injects axe-core (owned and lazily loaded by @storyshelf/core, pinned to one version) into each story page after the story settles and before the screenshot, once per story, mode and viewport. It scans #storybook-root with the WCAG 2.0/2.1/2.2 A and AA rules plus axe’s best-practice rules (for example heading-order) and reports one line per violation, worst impact first: rule id, impact, help text, a short selector and the node count. At most 10 lines are kept (…and N more for the rest).
- Visible in review, never blocking. The lines are written to the attempt log (
a11y violations found), stored on the story’s snapshot and shown on the build review page and on the component page; the story is listed as non-blocking and an accessibility result never fails a build or changes its status. - Page-level rules stay quiet.
region,landmark-one-mainandpage-has-heading-oneneed the whole document in scope, so they do not fire for the default scan of#storybook-rootand no rule is switched off by default. A story that setsparameters.a11y.elementorcontextto the whole page gets them; switch one off withconfig.rulesoroptions.rules. parameters.a11yis honoured.disable: true(andtest: "off") skip the story,configandoptions(includingrulesandrunOnly) are applied, andelementorcontextreplace the scanned root. An explicitoptions.runOnlyreplaces the default rule set (WCAG A/AA and best-practice).test: "todo"and"error"still only annotate. Function values (for example aconfig.rules[].matchescallback) cannot be read from the page, so they are left out, and a rule item that held only a function is dropped; axe runs with what remains and the runner writes one debug log line. axe runs on a project’s primary browser target only (the first entry of itsbrowserslist), so several targets do not repeat the scan.- No silent fallback. If axe cannot be loaded or run, the story gets one visible
axe unavailable: <reason>annotation, also non-blocking. - Works over remote browsers. The axe source is sent to the page as a string, so no file path in the browser process is needed.
- Cost. Measured on the repository’s Storybook fixtures with one page at a time on Chromium: about 150 to 160 ms more per story and viewport (a story takes roughly 1.2 to 1.3 s without axe; 10 stories, three timed runs each, Storybook 8 and 10 fixtures). Dense pages cost more, and
color-contrastdominates.
Browsers
Section titled “Browsers”browsers lists the targets the runner offers, default first (ADR 0007); omitted it is ["chromium"].
import { createPuppeteerCaptureRunner, local } from "@storyshelf/runner-puppeteer";
createPuppeteerCaptureRunner({ executablePath: "/usr/bin/chromium", // what the bare "chromium" entry launches browsers: ["chromium", local("chrome", { executablePath: "/usr/bin/google-chrome" })],});puppeteer-core ships no browser, so a target is ready only when it has an executable that exists (the entry's own, the runner's executablePath for chromium, or PUPPETEER_EXECUTABLE_PATH); otherwise it is unavailable with that reason on System → Capture browsers. Bare "chrome" without its own executablePath throws when the runner is created, and local("firefox") and local("webkit") throw too: this runner drives Chromium-family engines only. A launch that fails marks its target unavailable and fails the job at once.
Remote browsers
Section titled “Remote browsers”cdp({ name, endpoint, headers, sessions }) from @storyshelf/runner-puppeteer/remote is a Chrome DevTools Protocol endpoint (Browserless, Cloudflare Browser Rendering, a Chrome with remote debugging). The runner calls puppeteer.connect and offers chromium@<name> (and chrome@<name> with engines):
import { createPuppeteerCaptureRunner } from "@storyshelf/runner-puppeteer";import { cdp } from "@storyshelf/runner-puppeteer/remote";
createPuppeteerCaptureRunner({ executablePath: "/usr/bin/chromium", browsers: [ "chromium", cdp({ name: "cloudflare", endpoint: "wss://…", headers: { authorization: `Bearer ${token}` }, sessions: 4, }), ],});A Puppeteer remote browser cannot route back to the host, so every remote target needs a signed Storybook URL (reach: "url"); it stays unavailable until a secret and a confirmed public origin exist, then the server hands the runner a short-lived signed URL (<public origin>/_s/<token>/) for every render pass, and the runner refuses to start one without it. See Signed Storybook URLs. The target's environment is measured inside the browser, sessions is the per-target page cap shared across the process, and connection failures are retried with backoff. Details in Remote browsers.
Puppeteer vs Playwright
Section titled “Puppeteer vs Playwright”| Aspect | runner-puppeteer | runner-playwright |
|---|---|---|
| Engine | puppeteer-core + chrome-headless-shell (Chromium only) | Playwright (chromium/firefox/webkit/chrome) |
| Selected via | browsers runner option (Chromium family only); projects pick from what it offers | browsers runner option; projects pick from it |
| Image size | Smaller — no bundled Firefox/WebKit | Larger |
| Capabilities | Same orchestrator-owned controls: play, delay, diffThreshold, viewports, a11y (axe), burst | Identical controls |
Both runners implement the same pure CaptureRunner contract. The orchestrator (@storyshelf/core, ADR 0015) owns build loading, archive extraction, story discovery, and persistence and delegates only the screenshotting to the runner, so switching is a one-line factory swap.
When to use it
Section titled “When to use it”Choose Puppeteer when image size or cold-start time dominates — single-browser deploys, Fly/Railway deployments, or CI images where you already ship a system Chromium. Choose Playwright when you need Firefox, WebKit, or Chrome channel selection per project.