# @storyshelf/runner-playwright

> Pure Playwright CaptureRunner — renders Storybook screenshots in-process for the StoryShelf server.

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

## Install

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

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

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

## Usage

You normally never install this directly — your server scaffold uses it via `storyshelf server init`. Programmatically:

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

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

```ts
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`, …; only `chromium` and `chrome` take one).
- The first entry is what a project with no `browsers` setting 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 the `chrome` channel, so it needs `playwright install chrome`. It is no longer a fallback to bundled Chromium.
- `playwright-core` never downloads browsers. Provision them once per machine or image, pinned to the `playwright-core` version: `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 as `ready` or `unavailable` with 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

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.

```ts
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`, `browserstack` and the generic `remote({ name, engines, protocol, reach, connect, sessions })` use `browserType.connect` (all three engines); `protocol: "cdp"` uses `chromium.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 a `reach: "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 `browsers` the host needs no installed browsers (only `playwright-core`).

See [Remote browsers](https://storyshelf.js.org/guides/remote-browsers/) for the farm guide, the presets and what bare `run-server` does and does not guard.

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

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 build `failed`, GitHub status `failure`.
- **Flaky** (`tags: ['flaky-test']` case-insensitive whole-story, or `parameters.flakyTest` in `chromatic`/`storyshelf` keys with `storyshelf` winning) → build stays `reviewing`/`approved`, UI shows a warning, GitHub posts `success` with a warning comment `⚠️ flaky story failed — not blocking`.
- **Disabled** (`parameters.disableSnapshot` or `tags: ['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

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.

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