@storyshelf/runner-playwright
@storyshelf/runner-playwright is the default pure CaptureRunner implementation for StoryShelf. It serves an already-extracted Storybook directory over a local HTTP server, launches the project’s browser target (Chromium by default; Firefox, WebKit and Chrome when you list them), renders every story at each configured viewport, and returns the screenshot buffers. It performs no database, storage, or build-state management — core’s capture orchestrator (ADR 0015) owns that.
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-playwrightpnpm add @storyshelf/runner-playwrightyarn add @storyshelf/runner-playwright- 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.
You normally never install this directly — your server scaffold uses it via storyshelf server init. Programmatically:
import { createPlaywrightCaptureRunner } from "@storyshelf/runner-playwright";
const capture = createPlaywrightCaptureRunner();Supply it to createShelfApp({ testing: { runner }, config: { scratchDir } }) — the router builds the orchestrator that loads the build, extracts the uploaded archive into scratchDir, discovers stories, calls runner.render(...), and persists. server is the assembly point that hard-wires this renderer today (storyshelf-server serve, passing --data-dir as scratchDir). An alternative renderer (for example a remote browser) implements the same pure CaptureRunner interface from @storyshelf/core/adapters/capture-runner as its own package and is swapped in at the serve assembly site — the router, orchestrator, and pipeline never change.
The factory takes no dependencies. A pino Logger is an optional per-render input: the orchestrator derives a logger.child({ buildId, reqId }) and passes it into each render call so per-build work is traced and correlated with the HTTP request that triggered it.
Browsers
Section titled “Browsers”browsers lists every target the runner offers, default first (ADR 0007). Omitted, it is ["chromium"]: Firefox and WebKit are offered only when you list them, and a server whose projects use them stops offering them until you do.
import { createPlaywrightCaptureRunner, local } from "@storyshelf/runner-playwright";
createPlaywrightCaptureRunner({ browsers: ["chromium", "firefox", "webkit", local("chrome", { channel: "chrome" })],});- An entry is a bare engine name, or
local(engine, { channel })for a Playwright channel (chrome,chrome-beta,msedge, …; onlychromiumandchrometake one). - The first entry is what a project with no
browserssetting resolves to. A list that is empty, names an unknown engine or produces one target twice throws when the runner is created, not when the first build runs. - Bare
"chrome"is real Chrome: it launches with thechromechannel, so it needsplaywright install chrome. It is no longer a fallback to bundled Chromium. playwright-corenever downloads browsers. Provision them once per machine or image, pinned to theplaywright-coreversion:npx -y playwright@<version> install --with-deps chromium firefox webkit.- Declaring an engine does not prove it is installed. When the server starts, the runner checks each target without launching anything (the install marker next to Playwright's
executablePath(), or the system Chrome for a channel), and reports it asreadyorunavailablewith a reason on System → Capture browsers. A launch that fails anyway marks its target unavailable and fails the job at once with the launch error, instead of being retried as a story crash.
Remote browsers
Section titled “Remote browsers”An entry can also be a remote service, from the /remote subpath: the runner then connects to a browser on another machine instead of launching one, and offers <engine>@<name> targets (chromium@grid) with their own baselines.
import { createPlaywrightCaptureRunner } from "@storyshelf/runner-playwright";import { azurePlaywright, playwrightServer } from "@storyshelf/runner-playwright/remote";
createPlaywrightCaptureRunner({ browsers: [ playwrightServer({ name: "grid", endpoint: "ws://playwright:3000/storyshelf", sessions: 8 }), azurePlaywright({ endpoint: process.env.PLAYWRIGHT_SERVICE_URL!, token: process.env.PLAYWRIGHT_SERVICE_ACCESS_TOKEN!, sessions: 20, }), ],});playwrightServer,azurePlaywright,browserstackand the genericremote({ name, engines, connect, sessions })usebrowserType.connect(all three engines); aconnectthat answersprotocol: "cdp"useschromium.connectOverCDP(Chromium only, lower fidelity). Every preset is plain configuration: no vendor SDK is installed.exposeNetwork: "<loopback>"routes the remote browser's requests for the host's loopback Storybook server back through the connection (a self-hosted Playwright server, Azure). A service that cannot is areach: "url"target and needs a signed Storybook URL.- The target's environment (browser version, platform, device scale factor, fonts digest) is measured inside the browser, never from the host. Page concurrency is the service's
sessions, shared per target across the process; connection failures are retried with backoff before the render sees them. - With no local entry in
browsersthe host needs no installed browsers (onlyplaywright-core).
See Remote browsers for the farm guide, the presets and what bare run-server does and does not guard.
Concurrency and memory
Section titled “Concurrency and memory”Each open Chromium page costs roughly 100–300 MB, so render captures a bounded number of story/viewport pairs at once instead of all of them. The default is sized from the detected CPU and memory (container limits included): the smaller of the CPU count rounded up, (memory − 512 MB) / 300 MB, and 4, never below 1. Override it with createPlaywrightCaptureRunner({ concurrency }) or the STORYSHELF_CAPTURE_CONCURRENCY environment variable (the option wins). On a 0.5 vCPU or 1 vCPU host that means one page at a time, because extra pages only compete for the same CPU and trip timeouts. The per-page timeout scales the same way (30 s, 60 s under 2 CPUs, 90 s under 1 CPU); override it 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. Chromium is launched with low-memory flags (--disable-dev-shm-usage so small container /dev/shm mounts don't crash it, plus --disable-extensions, --disable-background-networking, --mute-audio). Flags that can change rendered pixels (--disable-gpu, --single-process) are deliberately left out so baselines stay stable.
Rule of thumb: total pages = testing.concurrency (builds at once, auto: 1 unless ≥ 2 CPUs and ≥ 3 GB) × runner concurrency.
Interaction testing
Section titled “Interaction testing”When the project’s Tests setting has Enable interaction tests (play) checked, the runner executes each story’s play function before the screenshot (with playTimeoutMs, default 10000, and per-story delay if set). A throwing play fails the story:
- Blocking (
!flaky) → whole buildfailed, GitHub statusfailure. - Flaky (
tags: ['flaky-test']case-insensitive whole-story, orparameters.flakyTestinchromatic/storyshelfkeys withstoryshelfwinning) → build staysreviewing/approved, UI shows a warning, GitHub postssuccesswith a warning comment⚠️ flaky story failed — not blocking. - Disabled (
parameters.disableSnapshotortags: ['skip']) → skip capture + play entirely.
Per-story delay, diffThreshold, and pauseAnimationAtEnd are also read from the same dual-key parameters (merged as {...chromatic, ...storyshelf}), with stories.json preferred over index.json plus a runtime __STORYBOOK_PREVIEW__ fallback for Storybook 8. See Interaction testing guide.
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.
How it fits
Section titled “How it fits”Core's capture logic is browser-agnostic: the orchestrator (@storyshelf/core/capture) owns loading, extraction, discovery, and persistence, and delegates only the actual screenshotting to this package's render(input) => RenderResult. This package supplies the pure renderer and the ephemeral Storybook static server it needs. It is the only place that depends on playwright-core (never full playwright — browsers are provisioned separately, see above).
See docs/architecture.md for the capture workflow and docs/testing.md for the gated browser integration suite.