Skip to content

@storyshelf/runner-puppeteer

View as Markdown

@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.

Terminal window
npm i @storyshelf/runner-puppeteer

JSR JSR Score

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() },
});

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.

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.

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.

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-main and page-has-heading-one need the whole document in scope, so they do not fire for the default scan of #storybook-root and no rule is switched off by default. A story that sets parameters.a11y.element or context to the whole page gets them; switch one off with config.rules or options.rules.
  • parameters.a11y is honoured. disable: true (and test: "off") skip the story, config and options (including rules and runOnly) are applied, and element or context replace the scanned root. An explicit options.runOnly replaces the default rule set (WCAG A/AA and best-practice). test: "todo" and "error" still only annotate. Function values (for example a config.rules[].matches callback) 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 its browsers list), 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-contrast dominates.

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.

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.

Aspectrunner-puppeteerrunner-playwright
Enginepuppeteer-core + chrome-headless-shell (Chromium only)Playwright (chromium/firefox/webkit/chrome)
Selected viabrowsers runner option (Chromium family only); projects pick from what it offersbrowsers runner option; projects pick from it
Image sizeSmaller — no bundled Firefox/WebKitLarger
CapabilitiesSame orchestrator-owned controls: play, delay, diffThreshold, viewports, a11y (axe), burstIdentical 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.

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.