# @storyshelf/runner-puppeteer

> Lightweight Puppeteer CaptureRunner — Chromium-only screenshots with a smaller image.

Source: https://storyshelf.js.org/packages/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](https://storyshelf.js.org/concepts/how-storybook-works/).

## Install

```sh
npm i @storyshelf/runner-puppeteer
```

[![JSR](https://jsr.io/badges/@storyshelf/runner-puppeteer)](https://jsr.io/@storyshelf/runner-puppeteer) [![JSR Score](https://jsr.io/badges/@storyshelf/runner-puppeteer/score)](https://jsr.io/@storyshelf/runner-puppeteer)

- [npm package](https://www.npmjs.com/package/@storyshelf/runner-puppeteer) — install tarballs and version history.
- [JSR package](https://jsr.io/@storyshelf/runner-puppeteer) — TypeScript-first registry page.
- [Public API reference](https://storyshelf.js.org/reference/runner-puppeteer/) — generated docs for every export; start here to learn the API. (The same reference is on [JSR](https://jsr.io/@storyshelf/runner-puppeteer/doc).)
- [Source on GitHub](https://github.com/GuptaSiddhant/storyshelf/tree/main/packages/runner-puppeteer) — package directory on `main`.

## Usage

```ts
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:

```ts
import { createShelfApp } from "@storyshelf/app";
import { createPuppeteerCaptureRunner } from "@storyshelf/runner-puppeteer";

const app = createShelfApp({
  database,
  storage,
  testing: { runner: createPuppeteerCaptureRunner() },
});
```

## 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](https://github.com/GuptaSiddhant/StoryShelf#readme) for configuration details.

## 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

`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](https://storyshelf.js.org/guides/interaction-testing/#storybook-10-test-entries).

## 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](https://github.com/dequelabs/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

`browsers` lists the targets the runner offers, default first (ADR 0007); omitted it is `["chromium"]`.

```ts
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

`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`):

```ts
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](https://storyshelf.js.org/guides/remote-browsers/#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](https://storyshelf.js.org/guides/remote-browsers/).

## 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

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.
