@storyshelf/runner-playwright 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +48 -5
  2. package/dist/a11y.mjs +18 -20
  3. package/dist/a11y.mjs.map +1 -1
  4. package/dist/availability.mjs +65 -0
  5. package/dist/availability.mjs.map +1 -0
  6. package/dist/browser.mjs +4 -2
  7. package/dist/browser.mjs.map +1 -1
  8. package/dist/capture-runner.d.mts +18 -3
  9. package/dist/capture-runner.mjs +72 -13
  10. package/dist/capture-runner.mjs.map +1 -1
  11. package/dist/connect.mjs +79 -0
  12. package/dist/connect.mjs.map +1 -0
  13. package/dist/index.d.mts +2 -1
  14. package/dist/index.mjs +2 -1
  15. package/dist/launch.mjs +64 -0
  16. package/dist/launch.mjs.map +1 -0
  17. package/dist/pages.d.mts +4 -0
  18. package/dist/pages.mjs +91 -0
  19. package/dist/pages.mjs.map +1 -0
  20. package/dist/pipeline.mjs +154 -26
  21. package/dist/pipeline.mjs.map +1 -1
  22. package/dist/play.mjs +14 -1
  23. package/dist/play.mjs.map +1 -1
  24. package/dist/remote/azure.d.mts +26 -0
  25. package/dist/remote/azure.mjs +37 -0
  26. package/dist/remote/azure.mjs.map +1 -0
  27. package/dist/remote/browserstack.d.mts +33 -0
  28. package/dist/remote/browserstack.mjs +52 -0
  29. package/dist/remote/browserstack.mjs.map +1 -0
  30. package/dist/remote/index.d.mts +7 -0
  31. package/dist/remote/index.mjs +8 -0
  32. package/dist/remote/playwright-server.d.mts +40 -0
  33. package/dist/remote/playwright-server.mjs +34 -0
  34. package/dist/remote/playwright-server.mjs.map +1 -0
  35. package/dist/remote/remote.d.mts +22 -0
  36. package/dist/remote/remote.mjs +38 -0
  37. package/dist/remote/remote.mjs.map +1 -0
  38. package/dist/remote-environment.mjs +36 -0
  39. package/dist/remote-environment.mjs.map +1 -0
  40. package/dist/runtime-params.mjs +8 -1
  41. package/dist/runtime-params.mjs.map +1 -1
  42. package/dist/screenshot.mjs +40 -18
  43. package/dist/screenshot.mjs.map +1 -1
  44. package/dist/settle.mjs +150 -0
  45. package/dist/settle.mjs.map +1 -0
  46. package/dist/static-server.mjs +1 -1
  47. package/dist/targets.d.mts +26 -0
  48. package/dist/targets.mjs +73 -0
  49. package/dist/targets.mjs.map +1 -0
  50. package/dist/tests.mjs +114 -0
  51. package/dist/tests.mjs.map +1 -0
  52. package/dist/types.d.mts +5 -3
  53. package/dist/version.mjs +16 -0
  54. package/dist/version.mjs.map +1 -0
  55. package/package.json +17 -8
package/README.md CHANGED
@@ -16,17 +16,60 @@ import { createPlaywrightCaptureRunner } from "@storyshelf/runner-playwright";
16
16
  const capture = createPlaywrightCaptureRunner();
17
17
  ```
18
18
 
19
- Supply it to `createShelfApp({ capture, config: { scratchDir } })` — the router builds the orchestrator that loads the build, extracts the uploaded archive into `scratchDir`, discovers stories, calls `capture.render(...)`, and persists. `server` is the assembly point that hard-wires this renderer today (`storyshelf-server serve`, passing `--data-dir` as `scratchDir`). A future alternative (e.g. a remote runner offloading capture to a worker fleet) 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.
19
+ 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.
20
+
21
+ 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.
22
+
23
+ ## Concurrency and memory
24
+
25
+ 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.
26
+
27
+ Rule of thumb: total pages = `testing.concurrency` (builds at once, auto: 1 unless ≥ 2 CPUs and ≥ 3 GB) × runner `concurrency`.
20
28
 
21
29
  ## Browsers
22
30
 
23
- This package depends on `playwright-core` only — it never downloads browsers. Provision Chromium once per machine (pinned to the client version):
31
+ `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.
24
32
 
25
- ```sh
26
- npx -y playwright@1.63.0 install chromium
33
+ ```ts
34
+ import { createPlaywrightCaptureRunner, local } from "@storyshelf/runner-playwright";
35
+
36
+ createPlaywrightCaptureRunner({
37
+ browsers: ["chromium", "firefox", "webkit", local("chrome", { channel: "chrome" })],
38
+ });
39
+ ```
40
+
41
+ - 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).
42
+ - 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.
43
+ - 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.
44
+ - `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`.
45
+ - 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.
46
+
47
+ ## Remote browsers
48
+
49
+ 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.
50
+
51
+ ```ts
52
+ import { createPlaywrightCaptureRunner } from "@storyshelf/runner-playwright";
53
+ import { azurePlaywright, playwrightServer } from "@storyshelf/runner-playwright/remote";
54
+
55
+ createPlaywrightCaptureRunner({
56
+ browsers: [
57
+ playwrightServer({ name: "grid", endpoint: "ws://playwright:3000/storyshelf", sessions: 8 }),
58
+ azurePlaywright({
59
+ endpoint: process.env.PLAYWRIGHT_SERVICE_URL!,
60
+ token: process.env.PLAYWRIGHT_SERVICE_ACCESS_TOKEN!,
61
+ sessions: 20,
62
+ }),
63
+ ],
64
+ });
27
65
  ```
28
66
 
29
- The Fly image installs browsers at build time (`playwright install --with-deps chromium`); CI and local runs use the same command.
67
+ - `playwrightServer`, `azurePlaywright`, `browserstack` and the generic `remote({ name, engines, connect, sessions })` use `browserType.connect` (all three engines); a `connect` that answers `protocol: "cdp"` uses `chromium.connectOverCDP` (Chromium only, lower fidelity). Every preset is plain configuration: no vendor SDK is installed.
68
+ - `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.
69
+ - 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.
70
+ - With no local entry in `browsers` the host needs no installed browsers (only `playwright-core`).
71
+
72
+ 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.
30
73
 
31
74
  ## How it fits
32
75
 
package/dist/a11y.mjs CHANGED
@@ -1,31 +1,29 @@
1
1
  import "node:path";
2
2
  import "node:url";
3
3
  import.meta.url;
4
+ import { axeInjectionScript, inPageReadA11yParameters, inPageRunAxe, loadAxeSource, mapAxeViolations, resolveA11yRun } from "@storyshelf/core/capture";
4
5
  //#region src/a11y.ts
5
- /** Run lightweight a11y checks inside the page context. */
6
- async function checkA11y(page) {
7
- return await page.evaluate(inPageA11y);
8
- }
6
+ /** axe-core accessibility checks for captured pages (ADR 0004 §6). */
9
7
  /**
10
- * Runs inside the browser. Playwright/Puppeteer serialize only this function's source, so it must
11
- * not reference anything from this module (helpers, imports): split it up and the page throws
12
- * `ReferenceError`. `a11y.test.ts` evaluates it in a scope without module bindings to guard that.
8
+ * Run axe over the story root and return one annotation string per violation (worst first).
9
+ * Honours `parameters.a11y` (`disable`, `test: "off"`, `config`, `options`, `element`/`context`;
10
+ * function values cannot be read from the page and are dropped, with one debug line). Results are annotations: nothing
11
+ * here fails a build. If axe cannot be loaded or injected the result is one visible annotation
12
+ * (`axe unavailable: <reason>`) instead of a silent pass.
13
13
  */
14
- function inPageA11y() {
15
- const doc = globalThis.document;
16
- const violations = [];
17
- const root = doc?.querySelector("#storybook-root");
18
- if (!root) return violations;
19
- for (const img of root.querySelectorAll("img:not([alt])")) violations.push(`img missing alt: ${img.outerHTML.slice(0, 120)}`);
20
- for (const btn of root.querySelectorAll("button")) if (!(btn.hasAttribute("aria-label") || btn.hasAttribute("aria-labelledby") || (btn.textContent ?? "").trim() !== "")) violations.push(`button missing label: ${btn.outerHTML.slice(0, 120)}`);
21
- for (const anchor of root.querySelectorAll("a")) if (!anchor.hasAttribute("href")) violations.push(`a missing href: ${anchor.outerHTML.slice(0, 120)}`);
22
- for (const input of root.querySelectorAll("input, select, textarea")) {
23
- const el = input;
24
- if (!(el.type === "hidden" || el.hasAttribute("aria-label") || el.hasAttribute("aria-labelledby") || el.id === "" || Boolean(doc.querySelector(`label[for="${el.id}"]`)))) violations.push(`input missing label: ${el.outerHTML.slice(0, 120)}`);
14
+ async function checkA11y(page, logger) {
15
+ try {
16
+ const read = await page.evaluate(inPageReadA11yParameters);
17
+ if (read && read.dropped > 0) logger?.debug({ dropped: read.dropped }, "parameters.a11y: function values left out");
18
+ const plan = resolveA11yRun(read?.params);
19
+ if (plan.skip) return [];
20
+ await page.evaluate(axeInjectionScript(await loadAxeSource()));
21
+ return mapAxeViolations(await page.evaluate(inPageRunAxe, plan));
22
+ } catch (error) {
23
+ return [`axe unavailable: ${error instanceof Error ? error.message : String(error)}`];
25
24
  }
26
- return violations.slice(0, 10);
27
25
  }
28
26
  //#endregion
29
- export { checkA11y, inPageA11y };
27
+ export { checkA11y };
30
28
 
31
29
  //# sourceMappingURL=a11y.mjs.map
package/dist/a11y.mjs.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"a11y.mjs","names":[],"sources":["../src/a11y.ts"],"sourcesContent":["/** Accessibility checks for captured pages. */\n\nimport type { Page } from \"playwright-core\";\n\n/** Run lightweight a11y checks inside the page context. */\nexport async function checkA11y(page: Page): Promise<string[]> {\n return await page.evaluate(inPageA11y);\n}\n\n/**\n * Runs inside the browser. Playwright/Puppeteer serialize only this function's source, so it must\n * not reference anything from this module (helpers, imports): split it up and the page throws\n * `ReferenceError`. `a11y.test.ts` evaluates it in a scope without module bindings to guard that.\n */\n// oxlint-disable-next-line eslint/max-statements, eslint/max-lines-per-function, eslint/complexity -- must stay one self-contained function (see above)\nexport function inPageA11y(): string[] {\n const doc = globalThis.document;\n const violations: string[] = [];\n const root = doc?.querySelector(\"#storybook-root\");\n if (!root) return violations;\n for (const img of root.querySelectorAll(\"img:not([alt])\")) {\n violations.push(`img missing alt: ${img.outerHTML.slice(0, 120)}`);\n }\n for (const btn of root.querySelectorAll(\"button\")) {\n const labelled =\n btn.hasAttribute(\"aria-label\") ||\n btn.hasAttribute(\"aria-labelledby\") ||\n (btn.textContent ?? \"\").trim() !== \"\";\n if (!labelled) violations.push(`button missing label: ${btn.outerHTML.slice(0, 120)}`);\n }\n for (const anchor of root.querySelectorAll(\"a\")) {\n if (!anchor.hasAttribute(\"href\"))\n violations.push(`a missing href: ${anchor.outerHTML.slice(0, 120)}`);\n }\n for (const input of root.querySelectorAll(\"input, select, textarea\")) {\n const el = input as HTMLInputElement;\n const labelled =\n el.type === \"hidden\" ||\n el.hasAttribute(\"aria-label\") ||\n el.hasAttribute(\"aria-labelledby\") ||\n el.id === \"\" ||\n Boolean(doc.querySelector(`label[for=\"${el.id}\"]`));\n if (!labelled) violations.push(`input missing label: ${el.outerHTML.slice(0, 120)}`);\n }\n return violations.slice(0, 10);\n}\n"],"mappings":";;;;;AAKA,eAAsB,UAAU,MAA+B;CAC7D,OAAO,MAAM,KAAK,SAAS,UAAU;AACvC;;;;;;AAQA,SAAgB,aAAuB;CACrC,MAAM,MAAM,WAAW;CACvB,MAAM,aAAuB,CAAC;CAC9B,MAAM,OAAO,KAAK,cAAc,iBAAiB;CACjD,IAAI,CAAC,MAAM,OAAO;CAClB,KAAK,MAAM,OAAO,KAAK,iBAAiB,gBAAgB,GACtD,WAAW,KAAK,oBAAoB,IAAI,UAAU,MAAM,GAAG,GAAG,GAAG;CAEnE,KAAK,MAAM,OAAO,KAAK,iBAAiB,QAAQ,GAK9C,IAAI,EAHF,IAAI,aAAa,YAAY,KAC7B,IAAI,aAAa,iBAAiB,MACjC,IAAI,eAAe,GAAA,CAAI,KAAK,MAAM,KACtB,WAAW,KAAK,yBAAyB,IAAI,UAAU,MAAM,GAAG,GAAG,GAAG;CAEvF,KAAK,MAAM,UAAU,KAAK,iBAAiB,GAAG,GAC5C,IAAI,CAAC,OAAO,aAAa,MAAM,GAC7B,WAAW,KAAK,mBAAmB,OAAO,UAAU,MAAM,GAAG,GAAG,GAAG;CAEvE,KAAK,MAAM,SAAS,KAAK,iBAAiB,yBAAyB,GAAG;EACpE,MAAM,KAAK;EAOX,IAAI,EALF,GAAG,SAAS,YACZ,GAAG,aAAa,YAAY,KAC5B,GAAG,aAAa,iBAAiB,KACjC,GAAG,OAAO,MACV,QAAQ,IAAI,cAAc,cAAc,GAAG,GAAG,GAAG,CAAC,IACrC,WAAW,KAAK,wBAAwB,GAAG,UAAU,MAAM,GAAG,GAAG,GAAG;CACrF;CACA,OAAO,WAAW,MAAM,GAAG,EAAE;AAC/B"}
1
+ {"version":3,"file":"a11y.mjs","names":[],"sources":["../src/a11y.ts"],"sourcesContent":["/** axe-core accessibility checks for captured pages (ADR 0004 §6). */\n\nimport {\n axeInjectionScript,\n inPageReadA11yParameters,\n inPageRunAxe,\n loadAxeSource,\n mapAxeViolations,\n resolveA11yRun,\n} from \"@storyshelf/core/capture\";\nimport type { Logger } from \"@storyshelf/core/logger\";\nimport type { Page } from \"playwright-core\";\n\n/**\n * Run axe over the story root and return one annotation string per violation (worst first).\n * Honours `parameters.a11y` (`disable`, `test: \"off\"`, `config`, `options`, `element`/`context`;\n * function values cannot be read from the page and are dropped, with one debug line). Results are annotations: nothing\n * here fails a build. If axe cannot be loaded or injected the result is one visible annotation\n * (`axe unavailable: <reason>`) instead of a silent pass.\n */\nexport async function checkA11y(page: Page, logger?: Logger): Promise<string[]> {\n try {\n const read = await page.evaluate(inPageReadA11yParameters);\n if (read && read.dropped > 0)\n logger?.debug({ dropped: read.dropped }, \"parameters.a11y: function values left out\");\n const plan = resolveA11yRun(read?.params);\n if (plan.skip) return [];\n // Source string over CDP `Runtime.evaluate`: not CSP-bound and works on remote browsers.\n await page.evaluate(axeInjectionScript(await loadAxeSource()));\n return mapAxeViolations(await page.evaluate(inPageRunAxe, plan));\n } catch (error) {\n return [`axe unavailable: ${error instanceof Error ? error.message : String(error)}`];\n }\n}\n"],"mappings":";;;;;;;;;;;;;AAoBA,eAAsB,UAAU,MAAY,QAAoC;CAC9E,IAAI;EACF,MAAM,OAAO,MAAM,KAAK,SAAS,wBAAwB;EACzD,IAAI,QAAQ,KAAK,UAAU,GACzB,QAAQ,MAAM,EAAE,SAAS,KAAK,QAAQ,GAAG,2CAA2C;EACtF,MAAM,OAAO,eAAe,MAAM,MAAM;EACxC,IAAI,KAAK,MAAM,OAAO,CAAC;EAEvB,MAAM,KAAK,SAAS,mBAAmB,MAAM,cAAc,CAAC,CAAC;EAC7D,OAAO,iBAAiB,MAAM,KAAK,SAAS,cAAc,IAAI,CAAC;CACjE,SAAS,OAAO;EACd,OAAO,CAAC,oBAAoB,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK,GAAG;CACtF;AACF"}
@@ -0,0 +1,65 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { existsSync } from "node:fs";
5
+ import { basename, dirname, join } from "node:path";
6
+ //#region src/availability.ts
7
+ /**
8
+ * Each local target as a {@link RunnerTarget}: `ready`, or `unavailable` with why. Playwright's
9
+ * `executablePath()` is where the engine would be installed; installing writes an
10
+ * `INSTALLATION_COMPLETE` marker next to it, which also covers Chromium's headless shell, the
11
+ * binary headless launches use and whose path Playwright does not expose.
12
+ */
13
+ function verifyLocalTargets(targets, types, exists = existsSync) {
14
+ return targets.map((entry) => ({
15
+ target: entry.target,
16
+ reach: "loopback",
17
+ status: statusOf(entry, types, exists)
18
+ }));
19
+ }
20
+ function statusOf(entry, types, exists) {
21
+ if (entry.channel !== void 0 && entry.channel !== "chromium") return channelStatus(entry.channel, exists);
22
+ const engine = entry.target === "chrome" ? "chromium" : entry.target;
23
+ return installedPaths(engine, types[engine].executablePath()).some((path) => exists(path)) ? "ready" : { unavailable: `declared, not installed (run \`playwright install ${engine}\`, pinned to this playwright-core)` };
24
+ }
25
+ /** Where `executablePath` and the install markers of `engine` live. */
26
+ function installedPaths(engine, executable) {
27
+ const installDir = revisionDir(executable);
28
+ if (installDir === null) return [executable];
29
+ const paths = [executable, join(installDir, "INSTALLATION_COMPLETE")];
30
+ if (engine === "chromium") {
31
+ const shell = join(dirname(installDir), basename(installDir).replace(/^chromium-/u, "chromium_headless_shell-"));
32
+ paths.push(join(shell, "INSTALLATION_COMPLETE"));
33
+ }
34
+ return paths;
35
+ }
36
+ /** The `<engine>-<revision>` directory an executable sits in (its depth differs per engine). */
37
+ function revisionDir(executable) {
38
+ for (let dir = dirname(executable); dirname(dir) !== dir; dir = dirname(dir)) if (/^(?:chromium|firefox|webkit)-\d+$/u.test(basename(dir))) return dir;
39
+ return null;
40
+ }
41
+ /** Browsers a channel installs (system Chrome and Edge) are checked where Playwright looks for them. */
42
+ function channelStatus(channel, exists) {
43
+ const known = channelPaths(channel);
44
+ if (known === null || known.some((path) => exists(path))) return "ready";
45
+ return { unavailable: `channel "${channel}" is not installed (run \`playwright install ${channel}\`)` };
46
+ }
47
+ /** Install locations per platform; `null` for a channel this check cannot place (a launch will tell). */
48
+ function channelPaths(channel) {
49
+ if (channel !== "chrome") return null;
50
+ if (process.platform === "linux") return ["/opt/google/chrome/chrome"];
51
+ if (process.platform === "darwin") return ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"];
52
+ if (process.platform === "win32") return [
53
+ "PROGRAMFILES",
54
+ "PROGRAMFILES(X86)",
55
+ "LOCALAPPDATA"
56
+ ].flatMap((name) => {
57
+ const root = process.env[name];
58
+ return root ? [join(root, "Google", "Chrome", "Application", "chrome.exe")] : [];
59
+ });
60
+ return null;
61
+ }
62
+ //#endregion
63
+ export { verifyLocalTargets };
64
+
65
+ //# sourceMappingURL=availability.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"availability.mjs","names":[],"sources":["../src/availability.ts"],"sourcesContent":["/** Telling which declared local engines are installed, without launching anything (ADR 0007, decision 7). */\nimport type { BrowserName, RunnerTarget } from \"@storyshelf/core/adapter/capture-runner\";\nimport { existsSync } from \"node:fs\";\nimport { basename, dirname, join } from \"node:path\";\nimport type { LocalTarget } from \"./targets.ts\";\n\n/** The part of Playwright's browser types this check reads. */\nexport interface BrowserTypes {\n chromium: { executablePath(): string };\n firefox: { executablePath(): string };\n webkit: { executablePath(): string };\n}\n\n/** Injectable file check, so tests need no browsers installed. */\ntype Exists = (path: string) => boolean;\n\n/**\n * Each local target as a {@link RunnerTarget}: `ready`, or `unavailable` with why. Playwright's\n * `executablePath()` is where the engine would be installed; installing writes an\n * `INSTALLATION_COMPLETE` marker next to it, which also covers Chromium's headless shell, the\n * binary headless launches use and whose path Playwright does not expose.\n */\nexport function verifyLocalTargets(\n targets: readonly LocalTarget[],\n types: BrowserTypes,\n exists: Exists = existsSync,\n): RunnerTarget[] {\n return targets.map((entry) => ({\n target: entry.target,\n reach: \"loopback\",\n status: statusOf(entry, types, exists),\n }));\n}\n\nfunction statusOf(entry: LocalTarget, types: BrowserTypes, exists: Exists): RunnerTarget[\"status\"] {\n if (entry.channel !== undefined && entry.channel !== \"chromium\") {\n return channelStatus(entry.channel, exists);\n }\n const engine = entry.target === \"chrome\" ? \"chromium\" : entry.target;\n const installed = installedPaths(engine, types[engine].executablePath());\n return installed.some((path) => exists(path))\n ? \"ready\"\n : {\n unavailable: `declared, not installed (run \\`playwright install ${engine}\\`, pinned to this playwright-core)`,\n };\n}\n\n/** Where `executablePath` and the install markers of `engine` live. */\nfunction installedPaths(engine: BrowserName, executable: string): string[] {\n const installDir = revisionDir(executable);\n if (installDir === null) {\n return [executable];\n }\n const paths = [executable, join(installDir, \"INSTALLATION_COMPLETE\")];\n if (engine === \"chromium\") {\n const shell = join(\n dirname(installDir),\n basename(installDir).replace(/^chromium-/u, \"chromium_headless_shell-\"),\n );\n paths.push(join(shell, \"INSTALLATION_COMPLETE\"));\n }\n return paths;\n}\n\n/** The `<engine>-<revision>` directory an executable sits in (its depth differs per engine). */\nfunction revisionDir(executable: string): string | null {\n for (let dir = dirname(executable); dirname(dir) !== dir; dir = dirname(dir)) {\n if (/^(?:chromium|firefox|webkit)-\\d+$/u.test(basename(dir))) {\n return dir;\n }\n }\n return null;\n}\n\n/** Browsers a channel installs (system Chrome and Edge) are checked where Playwright looks for them. */\nfunction channelStatus(channel: string, exists: Exists): RunnerTarget[\"status\"] {\n const known = channelPaths(channel);\n if (known === null || known.some((path) => exists(path))) {\n return \"ready\";\n }\n return {\n unavailable: `channel \"${channel}\" is not installed (run \\`playwright install ${channel}\\`)`,\n };\n}\n\n/** Install locations per platform; `null` for a channel this check cannot place (a launch will tell). */\nfunction channelPaths(channel: string): string[] | null {\n if (channel !== \"chrome\") {\n return null;\n }\n if (process.platform === \"linux\") {\n return [\"/opt/google/chrome/chrome\"];\n }\n if (process.platform === \"darwin\") {\n return [\"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome\"];\n }\n if (process.platform === \"win32\") {\n return [\"PROGRAMFILES\", \"PROGRAMFILES(X86)\", \"LOCALAPPDATA\"].flatMap((name) => {\n const root = process.env[name];\n return root ? [join(root, \"Google\", \"Chrome\", \"Application\", \"chrome.exe\")] : [];\n });\n }\n return null;\n}\n"],"mappings":";;;;;;;;;;;;AAsBA,SAAgB,mBACd,SACA,OACA,SAAiB,YACD;CAChB,OAAO,QAAQ,KAAK,WAAW;EAC7B,QAAQ,MAAM;EACd,OAAO;EACP,QAAQ,SAAS,OAAO,OAAO,MAAM;CACvC,EAAE;AACJ;AAEA,SAAS,SAAS,OAAoB,OAAqB,QAAwC;CACjG,IAAI,MAAM,YAAY,KAAA,KAAa,MAAM,YAAY,YACnD,OAAO,cAAc,MAAM,SAAS,MAAM;CAE5C,MAAM,SAAS,MAAM,WAAW,WAAW,aAAa,MAAM;CAE9D,OADkB,eAAe,QAAQ,MAAM,OAAO,CAAC,eAAe,CACvD,CAAC,CAAC,MAAM,SAAS,OAAO,IAAI,CAAC,IACxC,UACA,EACE,aAAa,qDAAqD,OAAO,qCAC3E;AACN;;AAGA,SAAS,eAAe,QAAqB,YAA8B;CACzE,MAAM,aAAa,YAAY,UAAU;CACzC,IAAI,eAAe,MACjB,OAAO,CAAC,UAAU;CAEpB,MAAM,QAAQ,CAAC,YAAY,KAAK,YAAY,uBAAuB,CAAC;CACpE,IAAI,WAAW,YAAY;EACzB,MAAM,QAAQ,KACZ,QAAQ,UAAU,GAClB,SAAS,UAAU,CAAC,CAAC,QAAQ,eAAe,0BAA0B,CACxE;EACA,MAAM,KAAK,KAAK,OAAO,uBAAuB,CAAC;CACjD;CACA,OAAO;AACT;;AAGA,SAAS,YAAY,YAAmC;CACtD,KAAK,IAAI,MAAM,QAAQ,UAAU,GAAG,QAAQ,GAAG,MAAM,KAAK,MAAM,QAAQ,GAAG,GACzE,IAAI,qCAAqC,KAAK,SAAS,GAAG,CAAC,GACzD,OAAO;CAGX,OAAO;AACT;;AAGA,SAAS,cAAc,SAAiB,QAAwC;CAC9E,MAAM,QAAQ,aAAa,OAAO;CAClC,IAAI,UAAU,QAAQ,MAAM,MAAM,SAAS,OAAO,IAAI,CAAC,GACrD,OAAO;CAET,OAAO,EACL,aAAa,YAAY,QAAQ,+CAA+C,QAAQ,KAC1F;AACF;;AAGA,SAAS,aAAa,SAAkC;CACtD,IAAI,YAAY,UACd,OAAO;CAET,IAAI,QAAQ,aAAa,SACvB,OAAO,CAAC,2BAA2B;CAErC,IAAI,QAAQ,aAAa,UACvB,OAAO,CAAC,8DAA8D;CAExE,IAAI,QAAQ,aAAa,SACvB,OAAO;EAAC;EAAgB;EAAqB;CAAc,CAAC,CAAC,SAAS,SAAS;EAC7E,MAAM,OAAO,QAAQ,IAAI;EACzB,OAAO,OAAO,CAAC,KAAK,MAAM,UAAU,UAAU,eAAe,YAAY,CAAC,IAAI,CAAC;CACjF,CAAC;CAEH,OAAO;AACT"}
package/dist/browser.mjs CHANGED
@@ -1,9 +1,11 @@
1
1
  import "node:path";
2
2
  import "node:url";
3
3
  import.meta.url;
4
+ import { RenderRuns } from "@storyshelf/core/adapter/capture-runner";
4
5
  //#region src/browser.ts
5
- /** Tracks in-flight renders so `cancel` can abort them. */
6
- const activeRuns = /* @__PURE__ */ new Map();
6
+ /** Browser lifecycle and active-run tracking for Playwright. */
7
+ /** Tracks in-flight renders, each by its own handle, so `cancel` aborts exactly the runs it names. */
8
+ const activeRuns = new RenderRuns();
7
9
  /** Close a browser if present; `cancel` must never throw. */
8
10
  async function closeBrowser(browser) {
9
11
  if (!browser) return;
@@ -1 +1 @@
1
- {"version":3,"file":"browser.mjs","names":[],"sources":["../src/browser.ts"],"sourcesContent":["/** Browser lifecycle and active-run tracking for Playwright. */\n\nimport type { Browser } from \"playwright-core\";\nimport type { ActiveRun } from \"./types.ts\";\n\n/** Tracks in-flight renders so `cancel` can abort them. */\nexport const activeRuns = new Map<string, ActiveRun>();\n\n/** Close a browser if present; `cancel` must never throw. */\nexport async function closeBrowser(browser: Browser | null): Promise<void> {\n if (!browser) return;\n await tryCloseBrowser(browser);\n}\n\nasync function tryCloseBrowser(browser: Browser): Promise<void> {\n try {\n await browser.close();\n } catch {\n // The run's own `finally` performs final cleanup; cancel must never throw.\n }\n}\n\n/** Best-effort close for the render's `finally` block. */\nexport async function safeCloseBrowser(browser: Browser): Promise<void> {\n await tryCloseBrowser(browser);\n}\n\n/** Best-effort close for the static server. */\nexport async function safeCloseServer(server: { close(): Promise<void> }): Promise<void> {\n try {\n await server.close();\n } catch {\n // Best-effort; teardown must never mask a render result.\n }\n}\n\n/** Normalize any thrown value to a string. */\nexport function messageOf(error: unknown): string {\n return error instanceof Error ? error.message : String(error);\n}\n"],"mappings":";;;;;AAMA,MAAa,6BAAa,IAAI,IAAuB;;AAGrD,eAAsB,aAAa,SAAwC;CACzE,IAAI,CAAC,SAAS;CACd,MAAM,gBAAgB,OAAO;AAC/B;AAEA,eAAe,gBAAgB,SAAiC;CAC9D,IAAI;EACF,MAAM,QAAQ,MAAM;CACtB,QAAQ,CAER;AACF;;AAGA,eAAsB,iBAAiB,SAAiC;CACtE,MAAM,gBAAgB,OAAO;AAC/B;;AAGA,eAAsB,gBAAgB,QAAmD;CACvF,IAAI;EACF,MAAM,OAAO,MAAM;CACrB,QAAQ,CAER;AACF;;AAGA,SAAgB,UAAU,OAAwB;CAChD,OAAO,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;AAC9D"}
1
+ {"version":3,"file":"browser.mjs","names":[],"sources":["../src/browser.ts"],"sourcesContent":["/** Browser lifecycle and active-run tracking for Playwright. */\n\nimport { RenderRuns } from \"@storyshelf/core/adapter/capture-runner\";\nimport type { Browser } from \"playwright-core\";\nimport type { ActiveRun } from \"./types.ts\";\n\n/** Tracks in-flight renders, each by its own handle, so `cancel` aborts exactly the runs it names. */\nexport const activeRuns = new RenderRuns<ActiveRun>();\n\n/** Close a browser if present; `cancel` must never throw. */\nexport async function closeBrowser(browser: Browser | null): Promise<void> {\n if (!browser) return;\n await tryCloseBrowser(browser);\n}\n\nasync function tryCloseBrowser(browser: Browser): Promise<void> {\n try {\n await browser.close();\n } catch {\n // The run's own `finally` performs final cleanup; cancel must never throw.\n }\n}\n\n/** Best-effort close for the render's `finally` block. */\nexport async function safeCloseBrowser(browser: Browser): Promise<void> {\n await tryCloseBrowser(browser);\n}\n\n/** Best-effort close for the static server. */\nexport async function safeCloseServer(server: { close(): Promise<void> }): Promise<void> {\n try {\n await server.close();\n } catch {\n // Best-effort; teardown must never mask a render result.\n }\n}\n\n/** Normalize any thrown value to a string. */\nexport function messageOf(error: unknown): string {\n return error instanceof Error ? error.message : String(error);\n}\n"],"mappings":";;;;;;;AAOA,MAAa,aAAa,IAAI,WAAsB;;AAGpD,eAAsB,aAAa,SAAwC;CACzE,IAAI,CAAC,SAAS;CACd,MAAM,gBAAgB,OAAO;AAC/B;AAEA,eAAe,gBAAgB,SAAiC;CAC9D,IAAI;EACF,MAAM,QAAQ,MAAM;CACtB,QAAQ,CAER;AACF;;AAGA,eAAsB,iBAAiB,SAAiC;CACtE,MAAM,gBAAgB,OAAO;AAC/B;;AAGA,eAAsB,gBAAgB,QAAmD;CACvF,IAAI;EACF,MAAM,OAAO,MAAM;CACrB,QAAQ,CAER;AACF;;AAGA,SAAgB,UAAU,OAAwB;CAChD,OAAO,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;AAC9D"}
@@ -1,13 +1,28 @@
1
1
  import __tsdown_shims_path from 'node:path';
2
2
  import __tsdown_shims_url from 'node:url';
3
+ import { BrowserEntry } from "./targets.mjs";
3
4
  import "./types.mjs";
4
5
  import "./runtime-params.mjs";
5
- import { BrowserName, CaptureRunner } from "@storyshelf/core/adapter/capture-runner";
6
+ import { CaptureRunner } from "@storyshelf/core/adapter/capture-runner";
6
7
  //#region src/capture-runner.d.ts
7
8
  /** Create a CaptureRunner that renders Storybook stories with Playwright. */
8
9
  declare function createPlaywrightCaptureRunner(options?: {
9
- browser?: BrowserName;
10
- supportedBrowsers?: readonly BrowserName[];
10
+ /**
11
+ * Every target the runner offers, default first (ADR 0007, decision 1): engine names,
12
+ * `local(engine, options)`, or remote services from `@storyshelf/runner-playwright/remote`
13
+ * (`<engine>@<name>` targets). Default `["chromium"]`; Firefox and WebKit are offered only when
14
+ * listed, and their binaries provisioned (`playwright install`), which `playwright-core` never
15
+ * does. A list with no local entry needs no installed browsers.
16
+ */
17
+ browsers?: readonly BrowserEntry[];
18
+ /** Pages rendered at once; falls back to `STORYSHELF_CAPTURE_CONCURRENCY`, then CPU/memory detection. */
19
+ concurrency?: number;
20
+ /** Per-page timeout in ms; falls back to `STORYSHELF_NAVIGATION_TIMEOUT_MS`, then CPU detection. */
21
+ navigationTimeoutMs?: number;
22
+ /** Captures between browser restarts (0 disables); falls back to `STORYSHELF_BROWSER_RECYCLE_EVERY`, then 150. */
23
+ recycleBrowserEvery?: number;
24
+ /** Reuse pages between captures (opt-in); falls back to `STORYSHELF_REUSE_PAGES=1`. */
25
+ reusePages?: boolean;
11
26
  }): CaptureRunner;
12
27
  //#endregion
13
28
  export { createPlaywrightCaptureRunner };
@@ -1,44 +1,103 @@
1
1
  import "node:path";
2
2
  import "node:url";
3
3
  import.meta.url;
4
+ import { verifyLocalTargets } from "./availability.mjs";
4
5
  import { activeRuns, closeBrowser } from "./browser.mjs";
6
+ import { launchLocal, loadPlaywright } from "./launch.mjs";
7
+ import { connectRemote } from "./connect.mjs";
5
8
  import "./runtime-params.mjs";
6
- import { renderAll } from "./pipeline.mjs";
7
- import { BROWSER_NAMES } from "@storyshelf/core/adapter/capture-runner";
9
+ import { extractAll, renderAll } from "./pipeline.mjs";
10
+ import { flattenBrowsers } from "./targets.mjs";
11
+ import { runTestEntries } from "./tests.mjs";
12
+ import { createTargetOffers } from "@storyshelf/core/adapter/capture-runner";
13
+ import { recommendedNavigationTimeoutMs } from "@storyshelf/core/utils";
8
14
  //#region src/capture-runner.ts
9
15
  /** Playwright capture runner — composition root (factory wiring). */
10
16
  /** Create a CaptureRunner that renders Storybook stories with Playwright. */
11
17
  function createPlaywrightCaptureRunner(options = {}) {
12
- const defaultBrowser = options.browser ?? "chromium";
18
+ const { declared, locals, remotes } = flattenBrowsers(options.browsers ?? ["chromium"]);
19
+ const offers = createTargetOffers(declared);
20
+ const driver = {
21
+ launch: offers.guard(async (target, logger) => {
22
+ const remote = remotes.find((entry) => entry.target === target);
23
+ return remote ? await connectRemote(remote, logger) : await launchLocal(locals.find((entry) => entry.target === target));
24
+ }),
25
+ remote: (target) => remotes.find((entry) => entry.target === target)
26
+ };
27
+ const defaultTarget = offers.defaultTarget;
28
+ const concurrency = options.concurrency ?? envPositiveInt("STORYSHELF_CAPTURE_CONCURRENCY");
29
+ const navigationTimeoutMs = options.navigationTimeoutMs ?? envPositiveInt("STORYSHELF_NAVIGATION_TIMEOUT_MS") ?? recommendedNavigationTimeoutMs();
30
+ const recycleBrowserEvery = options.recycleBrowserEvery ?? envInt("STORYSHELF_BROWSER_RECYCLE_EVERY", 0);
31
+ const reusePages = options.reusePages ?? ["1", "true"].includes(process.env["STORYSHELF_REUSE_PAGES"] ?? "");
13
32
  return {
14
33
  metadata: {
15
34
  name: "Playwright",
16
- version: globalThis.__PKG_VERSION__ ?? "0.0.0",
35
+ version: "0.7.0",
17
36
  description: "Playwright capture runner",
18
37
  kind: "playwright",
19
38
  category: "capture-runner",
20
- supportedBrowsers: options.supportedBrowsers ?? BROWSER_NAMES
39
+ get targets() {
40
+ return offers.current();
41
+ }
21
42
  },
43
+ lifecycle: offers.lifecycle(async () => await verifyTargets(declared, locals)),
22
44
  async render(input) {
23
45
  const active = {
24
46
  cancelled: false,
25
47
  browser: null
26
48
  };
27
- activeRuns.set(input.buildId, active);
49
+ const remove = activeRuns.add(input.buildId, input.runId, active);
50
+ try {
51
+ return await renderAll({
52
+ concurrency,
53
+ navigationTimeoutMs,
54
+ recycleBrowserEvery,
55
+ reusePages,
56
+ ...input
57
+ }, active, driver, defaultTarget);
58
+ } finally {
59
+ remove();
60
+ }
61
+ },
62
+ async extractParameters(input) {
63
+ return await extractAll(input, driver, defaultTarget);
64
+ },
65
+ async runTests(input) {
66
+ const active = {
67
+ cancelled: false,
68
+ browser: null
69
+ };
70
+ const remove = activeRuns.add(input.buildId, input.runId, active);
28
71
  try {
29
- return await renderAll(input, active, defaultBrowser);
72
+ return await runTestEntries({
73
+ concurrency,
74
+ navigationTimeoutMs,
75
+ ...input
76
+ }, active, driver, defaultTarget);
30
77
  } finally {
31
- activeRuns.delete(input.buildId);
78
+ remove();
32
79
  }
33
80
  },
34
- async cancel(buildId) {
35
- const active = activeRuns.get(buildId);
36
- if (!active) return;
37
- active.cancelled = true;
38
- await closeBrowser(active.browser);
81
+ async cancel(buildId, runId) {
82
+ await Promise.all(activeRuns.matching(buildId, runId).map(async (active) => {
83
+ active.cancelled = true;
84
+ await closeBrowser(active.browser);
85
+ }));
39
86
  }
40
87
  };
41
88
  }
89
+ /** Local targets are checked for an installed engine; a remote one is not probed (that would open a session). */
90
+ async function verifyTargets(declared, locals) {
91
+ const checked = new Map(verifyLocalTargets(locals, await loadPlaywright()).map((entry) => [entry.target, entry]));
92
+ return declared.map((entry) => checked.get(entry.target) ?? entry);
93
+ }
94
+ function envPositiveInt(name) {
95
+ return envInt(name, 1);
96
+ }
97
+ function envInt(name, min) {
98
+ const parsed = Number.parseInt(process.env[name] ?? "", 10);
99
+ return parsed >= min ? parsed : void 0;
100
+ }
42
101
  //#endregion
43
102
  export { createPlaywrightCaptureRunner };
44
103
 
@@ -1 +1 @@
1
- {"version":3,"file":"capture-runner.mjs","names":[],"sources":["../src/capture-runner.ts"],"sourcesContent":["/** Playwright capture runner — composition root (factory wiring). */\n\nimport {\n BROWSER_NAMES,\n type BrowserName,\n type CaptureRunner,\n} from \"@storyshelf/core/adapter/capture-runner\";\nimport { activeRuns, closeBrowser } from \"./browser.ts\";\nimport { renderAll } from \"./pipeline.ts\";\nimport type { ActiveRun, PlaywrightRenderInput } from \"./types.ts\";\n\ndeclare const __PKG_VERSION__: string | undefined;\n\n/** Create a CaptureRunner that renders Storybook stories with Playwright. */\nexport function createPlaywrightCaptureRunner(\n options: {\n browser?: BrowserName;\n supportedBrowsers?: readonly BrowserName[];\n } = {},\n): CaptureRunner {\n const defaultBrowser = options.browser ?? \"chromium\";\n return {\n metadata: {\n name: \"Playwright\",\n version: (globalThis as unknown as { __PKG_VERSION__?: string }).__PKG_VERSION__ ?? \"0.0.0\",\n description: \"Playwright capture runner\",\n kind: \"playwright\",\n category: \"capture-runner\",\n supportedBrowsers: options.supportedBrowsers ?? BROWSER_NAMES,\n },\n async render(input: PlaywrightRenderInput) {\n const active: ActiveRun = { cancelled: false, browser: null };\n activeRuns.set(input.buildId, active);\n try {\n return await renderAll(input, active, defaultBrowser);\n } finally {\n activeRuns.delete(input.buildId);\n }\n },\n async cancel(buildId) {\n const active = activeRuns.get(buildId);\n if (!active) return;\n active.cancelled = true;\n await closeBrowser(active.browser);\n },\n };\n}\n\n// Re-exports for backward compatibility (tests import these from capture-runner).\nexport type { PlaywrightRenderInput, RuntimeParamsState } from \"./types.ts\";\nexport { createRuntimeParamsState, runtimeParametersForStory } from \"./runtime-params.ts\";\n"],"mappings":";;;;;;;;;;AAcA,SAAgB,8BACd,UAGI,CAAC,GACU;CACf,MAAM,iBAAiB,QAAQ,WAAW;CAC1C,OAAO;EACL,UAAU;GACR,MAAM;GACN,SAAU,WAAuD,mBAAmB;GACpF,aAAa;GACb,MAAM;GACN,UAAU;GACV,mBAAmB,QAAQ,qBAAqB;EAClD;EACA,MAAM,OAAO,OAA8B;GACzC,MAAM,SAAoB;IAAE,WAAW;IAAO,SAAS;GAAK;GAC5D,WAAW,IAAI,MAAM,SAAS,MAAM;GACpC,IAAI;IACF,OAAO,MAAM,UAAU,OAAO,QAAQ,cAAc;GACtD,UAAU;IACR,WAAW,OAAO,MAAM,OAAO;GACjC;EACF;EACA,MAAM,OAAO,SAAS;GACpB,MAAM,SAAS,WAAW,IAAI,OAAO;GACrC,IAAI,CAAC,QAAQ;GACb,OAAO,YAAY;GACnB,MAAM,aAAa,OAAO,OAAO;EACnC;CACF;AACF"}
1
+ {"version":3,"file":"capture-runner.mjs","names":[],"sources":["../src/capture-runner.ts"],"sourcesContent":["/** Playwright capture runner — composition root (factory wiring). */\n\nimport {\n createTargetOffers,\n type CaptureRunner,\n type RunnerTarget,\n} from \"@storyshelf/core/adapter/capture-runner\";\nimport { recommendedNavigationTimeoutMs } from \"@storyshelf/core/utils\";\nimport { verifyLocalTargets } from \"./availability.ts\";\nimport { activeRuns, closeBrowser } from \"./browser.ts\";\nimport { connectRemote } from \"./connect.ts\";\nimport { launchLocal, loadPlaywright } from \"./launch.ts\";\nimport { extractAll, renderAll } from \"./pipeline.ts\";\nimport { flattenBrowsers, type BrowserEntry, type LocalTarget } from \"./targets.ts\";\nimport { runTestEntries } from \"./tests.ts\";\nimport type {\n ActiveRun,\n ExtractInput,\n PlaywrightRenderInput,\n PlaywrightTestsInput,\n TargetDriver,\n} from \"./types.ts\";\n\ndeclare const __PKG_VERSION__: string | undefined;\n\n/** Create a CaptureRunner that renders Storybook stories with Playwright. */\nexport function createPlaywrightCaptureRunner(\n options: {\n /**\n * Every target the runner offers, default first (ADR 0007, decision 1): engine names,\n * `local(engine, options)`, or remote services from `@storyshelf/runner-playwright/remote`\n * (`<engine>@<name>` targets). Default `[\"chromium\"]`; Firefox and WebKit are offered only when\n * listed, and their binaries provisioned (`playwright install`), which `playwright-core` never\n * does. A list with no local entry needs no installed browsers.\n */\n browsers?: readonly BrowserEntry[];\n /** Pages rendered at once; falls back to `STORYSHELF_CAPTURE_CONCURRENCY`, then CPU/memory detection. */\n concurrency?: number;\n /** Per-page timeout in ms; falls back to `STORYSHELF_NAVIGATION_TIMEOUT_MS`, then CPU detection. */\n navigationTimeoutMs?: number;\n /** Captures between browser restarts (0 disables); falls back to `STORYSHELF_BROWSER_RECYCLE_EVERY`, then 150. */\n recycleBrowserEvery?: number;\n /** Reuse pages between captures (opt-in); falls back to `STORYSHELF_REUSE_PAGES=1`. */\n reusePages?: boolean;\n } = {},\n): CaptureRunner {\n const { declared, locals, remotes } = flattenBrowsers(options.browsers ?? [\"chromium\"]);\n // Until `lifecycle.setup` verifies them, every declared target reads as ready.\n const offers = createTargetOffers(declared);\n const driver: TargetDriver = {\n launch: offers.guard(async (target, logger) => {\n const remote = remotes.find((entry) => entry.target === target);\n return remote\n ? await connectRemote(remote, logger)\n : await launchLocal(locals.find((entry) => entry.target === target) as LocalTarget);\n }),\n remote: (target) => remotes.find((entry) => entry.target === target),\n };\n const defaultTarget = offers.defaultTarget;\n const concurrency = options.concurrency ?? envPositiveInt(\"STORYSHELF_CAPTURE_CONCURRENCY\");\n const navigationTimeoutMs =\n options.navigationTimeoutMs ??\n envPositiveInt(\"STORYSHELF_NAVIGATION_TIMEOUT_MS\") ??\n recommendedNavigationTimeoutMs();\n const recycleBrowserEvery =\n options.recycleBrowserEvery ?? envInt(\"STORYSHELF_BROWSER_RECYCLE_EVERY\", 0);\n const reusePages =\n options.reusePages ?? [\"1\", \"true\"].includes(process.env[\"STORYSHELF_REUSE_PAGES\"] ?? \"\");\n return {\n metadata: {\n name: \"Playwright\",\n version: typeof __PKG_VERSION__ === \"string\" ? __PKG_VERSION__ : \"0.0.0\",\n description: \"Playwright capture runner\",\n kind: \"playwright\",\n category: \"capture-runner\",\n get targets() {\n return offers.current();\n },\n },\n lifecycle: offers.lifecycle(async () => await verifyTargets(declared, locals)),\n async render(input: PlaywrightRenderInput) {\n const active: ActiveRun = { cancelled: false, browser: null };\n const remove = activeRuns.add(input.buildId, input.runId, active);\n try {\n return await renderAll(\n { concurrency, navigationTimeoutMs, recycleBrowserEvery, reusePages, ...input },\n active,\n driver,\n defaultTarget,\n );\n } finally {\n remove();\n }\n },\n async extractParameters(input: ExtractInput) {\n return await extractAll(input, driver, defaultTarget);\n },\n async runTests(input: PlaywrightTestsInput) {\n const active: ActiveRun = { cancelled: false, browser: null };\n const remove = activeRuns.add(input.buildId, input.runId, active);\n try {\n return await runTestEntries(\n { concurrency, navigationTimeoutMs, ...input },\n active,\n driver,\n defaultTarget,\n );\n } finally {\n remove();\n }\n },\n async cancel(buildId, runId) {\n await Promise.all(\n activeRuns.matching(buildId, runId).map(async (active) => {\n active.cancelled = true;\n await closeBrowser(active.browser);\n }),\n );\n },\n };\n}\n\n/** Local targets are checked for an installed engine; a remote one is not probed (that would open a session). */\nasync function verifyTargets(\n declared: readonly RunnerTarget[],\n locals: readonly LocalTarget[],\n): Promise<RunnerTarget[]> {\n const checked = new Map(\n verifyLocalTargets(locals, await loadPlaywright()).map((entry) => [entry.target, entry]),\n );\n return declared.map((entry) => checked.get(entry.target) ?? entry);\n}\n\nfunction envPositiveInt(name: string): number | undefined {\n return envInt(name, 1);\n}\n\nfunction envInt(name: string, min: number): number | undefined {\n const parsed = Number.parseInt(process.env[name] ?? \"\", 10);\n return parsed >= min ? parsed : undefined;\n}\n\n// Re-exports for backward compatibility (tests import these from capture-runner).\nexport type { PlaywrightRenderInput, RuntimeParamsState } from \"./types.ts\";\nexport { createRuntimeParamsState, runtimeParametersForStory } from \"./runtime-params.ts\";\n"],"mappings":";;;;;;;;;;;;;;;;AA0BA,SAAgB,8BACd,UAiBI,CAAC,GACU;CACf,MAAM,EAAE,UAAU,QAAQ,YAAY,gBAAgB,QAAQ,YAAY,CAAC,UAAU,CAAC;CAEtF,MAAM,SAAS,mBAAmB,QAAQ;CAC1C,MAAM,SAAuB;EAC3B,QAAQ,OAAO,MAAM,OAAO,QAAQ,WAAW;GAC7C,MAAM,SAAS,QAAQ,MAAM,UAAU,MAAM,WAAW,MAAM;GAC9D,OAAO,SACH,MAAM,cAAc,QAAQ,MAAM,IAClC,MAAM,YAAY,OAAO,MAAM,UAAU,MAAM,WAAW,MAAM,CAAgB;EACtF,CAAC;EACD,SAAS,WAAW,QAAQ,MAAM,UAAU,MAAM,WAAW,MAAM;CACrE;CACA,MAAM,gBAAgB,OAAO;CAC7B,MAAM,cAAc,QAAQ,eAAe,eAAe,gCAAgC;CAC1F,MAAM,sBACJ,QAAQ,uBACR,eAAe,kCAAkC,KACjD,+BAA+B;CACjC,MAAM,sBACJ,QAAQ,uBAAuB,OAAO,oCAAoC,CAAC;CAC7E,MAAM,aACJ,QAAQ,cAAc,CAAC,KAAK,MAAM,CAAC,CAAC,SAAS,QAAQ,IAAI,6BAA6B,EAAE;CAC1F,OAAO;EACL,UAAU;GACR,MAAM;GACN,SAAA;GACA,aAAa;GACb,MAAM;GACN,UAAU;GACV,IAAI,UAAU;IACZ,OAAO,OAAO,QAAQ;GACxB;EACF;EACA,WAAW,OAAO,UAAU,YAAY,MAAM,cAAc,UAAU,MAAM,CAAC;EAC7E,MAAM,OAAO,OAA8B;GACzC,MAAM,SAAoB;IAAE,WAAW;IAAO,SAAS;GAAK;GAC5D,MAAM,SAAS,WAAW,IAAI,MAAM,SAAS,MAAM,OAAO,MAAM;GAChE,IAAI;IACF,OAAO,MAAM,UACX;KAAE;KAAa;KAAqB;KAAqB;KAAY,GAAG;IAAM,GAC9E,QACA,QACA,aACF;GACF,UAAU;IACR,OAAO;GACT;EACF;EACA,MAAM,kBAAkB,OAAqB;GAC3C,OAAO,MAAM,WAAW,OAAO,QAAQ,aAAa;EACtD;EACA,MAAM,SAAS,OAA6B;GAC1C,MAAM,SAAoB;IAAE,WAAW;IAAO,SAAS;GAAK;GAC5D,MAAM,SAAS,WAAW,IAAI,MAAM,SAAS,MAAM,OAAO,MAAM;GAChE,IAAI;IACF,OAAO,MAAM,eACX;KAAE;KAAa;KAAqB,GAAG;IAAM,GAC7C,QACA,QACA,aACF;GACF,UAAU;IACR,OAAO;GACT;EACF;EACA,MAAM,OAAO,SAAS,OAAO;GAC3B,MAAM,QAAQ,IACZ,WAAW,SAAS,SAAS,KAAK,CAAC,CAAC,IAAI,OAAO,WAAW;IACxD,OAAO,YAAY;IACnB,MAAM,aAAa,OAAO,OAAO;GACnC,CAAC,CACH;EACF;CACF;AACF;;AAGA,eAAe,cACb,UACA,QACyB;CACzB,MAAM,UAAU,IAAI,IAClB,mBAAmB,QAAQ,MAAM,eAAe,CAAC,CAAC,CAAC,KAAK,UAAU,CAAC,MAAM,QAAQ,KAAK,CAAC,CACzF;CACA,OAAO,SAAS,KAAK,UAAU,QAAQ,IAAI,MAAM,MAAM,KAAK,KAAK;AACnE;AAEA,SAAS,eAAe,MAAkC;CACxD,OAAO,OAAO,MAAM,CAAC;AACvB;AAEA,SAAS,OAAO,MAAc,KAAiC;CAC7D,MAAM,SAAS,OAAO,SAAS,QAAQ,IAAI,SAAS,IAAI,EAAE;CAC1D,OAAO,UAAU,MAAM,SAAS,KAAA;AAClC"}
@@ -0,0 +1,79 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { loadPlaywright } from "./launch.mjs";
5
+ import { playwrightVersion } from "./version.mjs";
6
+ import { TargetLaunchError, scrubConnection } from "@storyshelf/core/adapter/capture-runner";
7
+ import { isRetryableConnectError, retryConnect } from "@storyshelf/core/utils";
8
+ //#region src/connect.ts
9
+ /** Connecting to a remote browser: retried when the service is unreachable or full, a clear error when it is not. */
10
+ /**
11
+ * Connect to `entry`'s service and return its browser. Connection-level failures are retried with
12
+ * backoff, so a provider outage or a full plan costs time and not a story crash; what is left
13
+ * is a {@link TargetLaunchError}, which fails the job at once (a wrong version or credential also
14
+ * marks the target unavailable, a service that is merely down does not). Nothing in a message
15
+ * carries the endpoint or its credentials.
16
+ */
17
+ async function connectRemote(entry, logger) {
18
+ const { remote, target } = entry;
19
+ const connection = remote.connect(entry.engine);
20
+ try {
21
+ const playwright = await loadPlaywright();
22
+ return await retryConnect(async () => await connectOnce(playwright, entry, connection), {
23
+ retries: remote.connectRetries,
24
+ backoffMs: remote.connectBackoffMs,
25
+ onRetry: (error, retry, delayMs) => {
26
+ logger?.warn({
27
+ target,
28
+ retry,
29
+ delayMs,
30
+ reason: reasonOf(error, connection)
31
+ }, "remote browser connect failed, retrying");
32
+ }
33
+ });
34
+ } catch (error) {
35
+ throw new TargetLaunchError(target, reasonOf(error, connection), {
36
+ cause: scrubbedCause(error, connection),
37
+ transient: isRetryableConnectError(error)
38
+ });
39
+ }
40
+ }
41
+ async function connectOnce(playwright, entry, connection) {
42
+ const { headers, endpoint } = connection;
43
+ if (connection.protocol === "cdp") return await playwright.chromium.connectOverCDP(endpoint, {
44
+ timeout: entry.remote.connectTimeoutMs,
45
+ ...headers === void 0 ? {} : { headers }
46
+ });
47
+ return await playwright[entry.engine === "chrome" ? "chromium" : entry.engine].connect(endpoint, {
48
+ timeout: entry.remote.connectTimeoutMs,
49
+ ...headers === void 0 ? {} : { headers },
50
+ ...connection.exposeNetwork === void 0 ? {} : { exposeNetwork: connection.exposeNetwork }
51
+ });
52
+ }
53
+ /** What failed, in one line, with the version mismatch spelled out and the credentials removed. */
54
+ function reasonOf(error, connection) {
55
+ const text = error instanceof Error ? error.message : String(error);
56
+ const mismatch = versionMismatch(text);
57
+ if (mismatch !== null) return mismatch;
58
+ const line = text.split("\n").find((candidate) => candidate.trim() !== "")?.trim() ?? "the connection failed";
59
+ return scrubConnection(line, connection);
60
+ }
61
+ /**
62
+ * A Playwright server of another version answers the handshake with a box naming both versions;
63
+ * restate it with the one this runner has, so the operator knows which side to move.
64
+ */
65
+ function versionMismatch(text) {
66
+ const server = /server version:\s*v?([\d.]+)/u.exec(text)?.[1];
67
+ const client = /client version:\s*v?([\d.]+)/u.exec(text)?.[1];
68
+ if (server === void 0 || client === void 0) return null;
69
+ return `Playwright version mismatch: the server runs v${server} but this runner's playwright-core is ${playwrightVersion()}; run the server at exactly ${playwrightVersion()} (see the self-hosted farm guide)`;
70
+ }
71
+ function scrubbedCause(error, connection) {
72
+ const copy = new Error(reasonOf(error, connection));
73
+ copy.name = error instanceof Error ? error.name : "Error";
74
+ return copy;
75
+ }
76
+ //#endregion
77
+ export { connectRemote };
78
+
79
+ //# sourceMappingURL=connect.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"connect.mjs","names":[],"sources":["../src/connect.ts"],"sourcesContent":["/** Connecting to a remote browser: retried when the service is unreachable or full, a clear error when it is not. */\nimport {\n TargetLaunchError,\n scrubConnection,\n type RemoteConnection,\n type RemoteTarget,\n} from \"@storyshelf/core/adapter/capture-runner\";\nimport type { Logger } from \"@storyshelf/core/logger\";\nimport { isRetryableConnectError, retryConnect } from \"@storyshelf/core/utils\";\nimport type { Browser } from \"playwright-core\";\nimport { loadPlaywright } from \"./launch.ts\";\nimport { playwrightVersion } from \"./version.ts\";\n\n/**\n * Connect to `entry`'s service and return its browser. Connection-level failures are retried with\n * backoff, so a provider outage or a full plan costs time and not a story crash; what is left\n * is a {@link TargetLaunchError}, which fails the job at once (a wrong version or credential also\n * marks the target unavailable, a service that is merely down does not). Nothing in a message\n * carries the endpoint or its credentials.\n */\nexport async function connectRemote(entry: RemoteTarget, logger?: Logger): Promise<Browser> {\n const { remote, target } = entry;\n const connection = remote.connect(entry.engine);\n try {\n const playwright = await loadPlaywright();\n return await retryConnect(async () => await connectOnce(playwright, entry, connection), {\n retries: remote.connectRetries,\n backoffMs: remote.connectBackoffMs,\n onRetry: (error, retry, delayMs) => {\n logger?.warn(\n { target, retry, delayMs, reason: reasonOf(error, connection) },\n \"remote browser connect failed, retrying\",\n );\n },\n });\n } catch (error) {\n throw new TargetLaunchError(target, reasonOf(error, connection), {\n cause: scrubbedCause(error, connection),\n transient: isRetryableConnectError(error),\n });\n }\n}\n\nasync function connectOnce(\n playwright: typeof import(\"playwright-core\"),\n entry: RemoteTarget,\n connection: RemoteConnection,\n): Promise<Browser> {\n const { headers, endpoint } = connection;\n if (connection.protocol === \"cdp\") {\n return await playwright.chromium.connectOverCDP(endpoint, {\n timeout: entry.remote.connectTimeoutMs,\n ...(headers === undefined ? {} : { headers }),\n });\n }\n return await playwright[entry.engine === \"chrome\" ? \"chromium\" : entry.engine].connect(endpoint, {\n timeout: entry.remote.connectTimeoutMs,\n ...(headers === undefined ? {} : { headers }),\n ...(connection.exposeNetwork === undefined ? {} : { exposeNetwork: connection.exposeNetwork }),\n });\n}\n\n/** What failed, in one line, with the version mismatch spelled out and the credentials removed. */\nfunction reasonOf(error: unknown, connection: RemoteConnection): string {\n const text = error instanceof Error ? error.message : String(error);\n const mismatch = versionMismatch(text);\n if (mismatch !== null) {\n return mismatch;\n }\n const line =\n text\n .split(\"\\n\")\n .find((candidate) => candidate.trim() !== \"\")\n ?.trim() ?? \"the connection failed\";\n return scrubConnection(line, connection);\n}\n\n/**\n * A Playwright server of another version answers the handshake with a box naming both versions;\n * restate it with the one this runner has, so the operator knows which side to move.\n */\nfunction versionMismatch(text: string): string | null {\n const server = /server version:\\s*v?([\\d.]+)/u.exec(text)?.[1];\n const client = /client version:\\s*v?([\\d.]+)/u.exec(text)?.[1];\n if (server === undefined || client === undefined) {\n return null;\n }\n return `Playwright version mismatch: the server runs v${server} but this runner's playwright-core is ${playwrightVersion()}; run the server at exactly ${playwrightVersion()} (see the self-hosted farm guide)`;\n}\n\nfunction scrubbedCause(error: unknown, connection: RemoteConnection): Error {\n const copy = new Error(reasonOf(error, connection));\n copy.name = error instanceof Error ? error.name : \"Error\";\n return copy;\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAoBA,eAAsB,cAAc,OAAqB,QAAmC;CAC1F,MAAM,EAAE,QAAQ,WAAW;CAC3B,MAAM,aAAa,OAAO,QAAQ,MAAM,MAAM;CAC9C,IAAI;EACF,MAAM,aAAa,MAAM,eAAe;EACxC,OAAO,MAAM,aAAa,YAAY,MAAM,YAAY,YAAY,OAAO,UAAU,GAAG;GACtF,SAAS,OAAO;GAChB,WAAW,OAAO;GAClB,UAAU,OAAO,OAAO,YAAY;IAClC,QAAQ,KACN;KAAE;KAAQ;KAAO;KAAS,QAAQ,SAAS,OAAO,UAAU;IAAE,GAC9D,yCACF;GACF;EACF,CAAC;CACH,SAAS,OAAO;EACd,MAAM,IAAI,kBAAkB,QAAQ,SAAS,OAAO,UAAU,GAAG;GAC/D,OAAO,cAAc,OAAO,UAAU;GACtC,WAAW,wBAAwB,KAAK;EAC1C,CAAC;CACH;AACF;AAEA,eAAe,YACb,YACA,OACA,YACkB;CAClB,MAAM,EAAE,SAAS,aAAa;CAC9B,IAAI,WAAW,aAAa,OAC1B,OAAO,MAAM,WAAW,SAAS,eAAe,UAAU;EACxD,SAAS,MAAM,OAAO;EACtB,GAAI,YAAY,KAAA,IAAY,CAAC,IAAI,EAAE,QAAQ;CAC7C,CAAC;CAEH,OAAO,MAAM,WAAW,MAAM,WAAW,WAAW,aAAa,MAAM,OAAO,CAAC,QAAQ,UAAU;EAC/F,SAAS,MAAM,OAAO;EACtB,GAAI,YAAY,KAAA,IAAY,CAAC,IAAI,EAAE,QAAQ;EAC3C,GAAI,WAAW,kBAAkB,KAAA,IAAY,CAAC,IAAI,EAAE,eAAe,WAAW,cAAc;CAC9F,CAAC;AACH;;AAGA,SAAS,SAAS,OAAgB,YAAsC;CACtE,MAAM,OAAO,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;CAClE,MAAM,WAAW,gBAAgB,IAAI;CACrC,IAAI,aAAa,MACf,OAAO;CAET,MAAM,OACJ,KACG,MAAM,IAAI,CAAC,CACX,MAAM,cAAc,UAAU,KAAK,MAAM,EAAE,CAAC,EAC3C,KAAK,KAAK;CAChB,OAAO,gBAAgB,MAAM,UAAU;AACzC;;;;;AAMA,SAAS,gBAAgB,MAA6B;CACpD,MAAM,SAAS,gCAAgC,KAAK,IAAI,CAAC,GAAG;CAC5D,MAAM,SAAS,gCAAgC,KAAK,IAAI,CAAC,GAAG;CAC5D,IAAI,WAAW,KAAA,KAAa,WAAW,KAAA,GACrC,OAAO;CAET,OAAO,iDAAiD,OAAO,wCAAwC,kBAAkB,EAAE,8BAA8B,kBAAkB,EAAE;AAC/K;AAEA,SAAS,cAAc,OAAgB,YAAqC;CAC1E,MAAM,OAAO,IAAI,MAAM,SAAS,OAAO,UAAU,CAAC;CAClD,KAAK,OAAO,iBAAiB,QAAQ,MAAM,OAAO;CAClD,OAAO;AACT"}
package/dist/index.d.mts CHANGED
@@ -1,4 +1,5 @@
1
1
  import __tsdown_shims_path from 'node:path';
2
2
  import __tsdown_shims_url from 'node:url';
3
+ import { BrowserEntry, LocalBrowser, LocalOptions, local } from "./targets.mjs";
3
4
  import { createPlaywrightCaptureRunner } from "./capture-runner.mjs";
4
- export { createPlaywrightCaptureRunner };
5
+ export { type BrowserEntry, type LocalBrowser, type LocalOptions, createPlaywrightCaptureRunner, local };
package/dist/index.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import "node:path";
2
2
  import "node:url";
3
3
  import.meta.url;
4
+ import { local } from "./targets.mjs";
4
5
  import { createPlaywrightCaptureRunner } from "./capture-runner.mjs";
5
- export { createPlaywrightCaptureRunner };
6
+ export { createPlaywrightCaptureRunner, local };
@@ -0,0 +1,64 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { TargetLaunchError } from "@storyshelf/core/adapter/capture-runner";
5
+ //#region src/launch.ts
6
+ /** Starting a local engine, and telling a browser that would not start from one that crashed. */
7
+ /**
8
+ * Low-memory Chromium flags. Deliberately excludes rendering-affecting flags
9
+ * (`--disable-gpu`, `--single-process`): they can shift pixels or crash pages,
10
+ * which would show up as false visual diffs.
11
+ */
12
+ const CHROMIUM_ARGS = [
13
+ "--disable-dev-shm-usage",
14
+ "--disable-extensions",
15
+ "--disable-background-networking",
16
+ "--mute-audio"
17
+ ];
18
+ /**
19
+ * Playwright closes its browsers on SIGINT/SIGTERM/SIGHUP by default, which fails a render that a
20
+ * graceful drain is still finishing (`TargetClosedError`). The host owns shutdown: it drains, then
21
+ * closes every browser through `teardown`.
22
+ */
23
+ const HOST_OWNS_SIGNALS = {
24
+ handleSIGINT: false,
25
+ handleSIGTERM: false,
26
+ handleSIGHUP: false
27
+ };
28
+ /**
29
+ * Launch `entry`'s engine. Playwright is loaded here, on first use, not at import: it costs
30
+ * ~150 ms and tens of MB, which a server that only serves reviews or hosts Storybooks should not
31
+ * pay at boot. A failure is a {@link TargetLaunchError}: the browser never started, so no story is
32
+ * at fault and the job fails at once instead of going through crash isolation.
33
+ */
34
+ async function launchLocal(entry) {
35
+ try {
36
+ const playwright = await loadPlaywright();
37
+ return await launcherOf(entry.target, playwright)(entry.channel);
38
+ } catch (error) {
39
+ throw new TargetLaunchError(entry.target, firstLine(error), { cause: error });
40
+ }
41
+ }
42
+ let loaded;
43
+ /** Load Playwright once, however many renders ask at the same time. */
44
+ async function loadPlaywright() {
45
+ loaded ??= import("playwright-core");
46
+ return await loaded;
47
+ }
48
+ function launcherOf(engine, playwright) {
49
+ if (engine === "firefox") return async () => await playwright.firefox.launch(HOST_OWNS_SIGNALS);
50
+ if (engine === "webkit") return async () => await playwright.webkit.launch(HOST_OWNS_SIGNALS);
51
+ return async (channel) => await playwright.chromium.launch({
52
+ ...HOST_OWNS_SIGNALS,
53
+ args: CHROMIUM_ARGS,
54
+ ...channel === void 0 ? {} : { channel }
55
+ });
56
+ }
57
+ /** Playwright's launch errors run to a screenful of install hints; the first line says what failed. */
58
+ function firstLine(error) {
59
+ return (error instanceof Error ? error.message : String(error)).split("\n").find((line) => line.trim() !== "")?.trim() ?? "the browser did not start";
60
+ }
61
+ //#endregion
62
+ export { launchLocal, loadPlaywright };
63
+
64
+ //# sourceMappingURL=launch.mjs.map