@storyshelf/runner-playwright 0.7.0 → 0.9.0-next-d2cc082

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 (48) hide show
  1. package/README.md +7 -2
  2. package/dist/a11y.mjs +7 -5
  3. package/dist/a11y.mjs.map +1 -1
  4. package/dist/availability.mjs +2 -4
  5. package/dist/availability.mjs.map +1 -1
  6. package/dist/browser.mjs +31 -8
  7. package/dist/browser.mjs.map +1 -1
  8. package/dist/capture-retry.mjs +25 -0
  9. package/dist/capture-retry.mjs.map +1 -0
  10. package/dist/capture-runner.d.mts +2 -2
  11. package/dist/capture-runner.mjs +29 -12
  12. package/dist/capture-runner.mjs.map +1 -1
  13. package/dist/connect.mjs +23 -41
  14. package/dist/connect.mjs.map +1 -1
  15. package/dist/launch.mjs +1 -2
  16. package/dist/pages.mjs +1 -2
  17. package/dist/pipeline.mjs +96 -30
  18. package/dist/pipeline.mjs.map +1 -1
  19. package/dist/play.mjs +1 -2
  20. package/dist/remote/azure.d.mts +4 -3
  21. package/dist/remote/azure.mjs +12 -9
  22. package/dist/remote/azure.mjs.map +1 -1
  23. package/dist/remote/browserstack.d.mts +2 -3
  24. package/dist/remote/browserstack.mjs +8 -8
  25. package/dist/remote/browserstack.mjs.map +1 -1
  26. package/dist/remote/index.d.mts +2 -1
  27. package/dist/remote/index.mjs +2 -1
  28. package/dist/remote/playwright-server.d.mts +10 -5
  29. package/dist/remote/playwright-server.mjs +9 -8
  30. package/dist/remote/playwright-server.mjs.map +1 -1
  31. package/dist/remote/remote.d.mts +20 -4
  32. package/dist/remote/remote.mjs +20 -3
  33. package/dist/remote/remote.mjs.map +1 -1
  34. package/dist/remote-environment.mjs +31 -18
  35. package/dist/remote-environment.mjs.map +1 -1
  36. package/dist/runtime-params.mjs +1 -2
  37. package/dist/screenshot.mjs +65 -13
  38. package/dist/screenshot.mjs.map +1 -1
  39. package/dist/settle.mjs +15 -8
  40. package/dist/settle.mjs.map +1 -1
  41. package/dist/static-server.mjs +72 -10
  42. package/dist/static-server.mjs.map +1 -1
  43. package/dist/targets.d.mts +0 -2
  44. package/dist/targets.mjs +1 -2
  45. package/dist/tests.mjs +9 -12
  46. package/dist/tests.mjs.map +1 -1
  47. package/dist/version.mjs +1 -2
  48. package/package.json +8 -17
package/README.md CHANGED
@@ -46,7 +46,7 @@ createPlaywrightCaptureRunner({
46
46
 
47
47
  ## Remote browsers
48
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.
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. The vendor presets (`azurePlaywright`, `browserstack`) are experimental: not verified against live infrastructure.
50
50
 
51
51
  ```ts
52
52
  import { createPlaywrightCaptureRunner } from "@storyshelf/runner-playwright";
@@ -64,9 +64,14 @@ createPlaywrightCaptureRunner({
64
64
  });
65
65
  ```
66
66
 
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.
67
+ - `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.
68
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
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
+ - `maxPagesPerConnection` on `remote()` or `playwrightServer()` additionally limits pages on each Playwright connection. It does not replace the shared `sessions` limit. Two pages per connection was a useful starting point on one measured 2 GB browser host; tune it for your workload.
71
+ - The per-connection cap is not a per-computer cap: multiple connections assigned to one computer can each use that many pages. `sessions` is shared across the target in this process, rather than tracked separately by computer.
72
+ - Remote captures retry a navigation timeout, screenshot timeout, or observed page crash once on a fresh page and context, within the same concurrency limits. A disconnected browser is reported as a failure rather than reconnected while other captures may be active. Play/assertion errors, cancellation, and capture delivery errors are not retried. Retry events are logged with story, viewport, phase, attempt, and outcome.
73
+ - The capture-only loopback Storybook server sends a one-hour cache policy for successful files. It serves the immutable build for one render and closes with that render; signed Storybook URLs use their own security and cache policy.
74
+ - Set `staticCompression: true` on `createPlaywrightCaptureRunner` to gzip compressible loopback assets when the browser accepts gzip. It is off by default. Files larger than 512 KiB are served uncompressed, and the cache reserves at most 4 MiB per server; compression adds CPU and memory use at cold start. It does not affect signed Storybook URLs.
70
75
  - With no local entry in `browsers` the host needs no installed browsers (only `playwright-core`).
71
76
 
72
77
  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.
package/dist/a11y.mjs CHANGED
@@ -2,19 +2,22 @@ import "node:path";
2
2
  import "node:url";
3
3
  import.meta.url;
4
4
  import { axeInjectionScript, inPageReadA11yParameters, inPageRunAxe, loadAxeSource, mapAxeViolations, resolveA11yRun } from "@storyshelf/core/capture";
5
- //#region src/a11y.ts
5
+ // src/a11y.ts
6
6
  /** axe-core accessibility checks for captured pages (ADR 0004 §6). */
7
7
  /**
8
8
  * Run axe over the story root and return one annotation string per violation (worst first).
9
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
10
+ * function values cannot be read from the page and are dropped, with one warning). Results are annotations: nothing
11
11
  * here fails a build. If axe cannot be loaded or injected the result is one visible annotation
12
12
  * (`axe unavailable: <reason>`) instead of a silent pass.
13
13
  */
14
- async function checkA11y(page, logger) {
14
+ async function checkA11y(page, logger, storyId) {
15
15
  try {
16
16
  const read = await page.evaluate(inPageReadA11yParameters);
17
- if (read && read.dropped > 0) logger?.debug({ dropped: read.dropped }, "parameters.a11y: function values left out");
17
+ if (read && read.dropped > 0) logger?.warn({
18
+ storyId,
19
+ dropped: read.dropped
20
+ }, "parameters.a11y: function, symbol or undefined values were omitted; axe uses the remaining configuration");
18
21
  const plan = resolveA11yRun(read?.params);
19
22
  if (plan.skip) return [];
20
23
  await page.evaluate(axeInjectionScript(await loadAxeSource()));
@@ -23,7 +26,6 @@ async function checkA11y(page, logger) {
23
26
  return [`axe unavailable: ${error instanceof Error ? error.message : String(error)}`];
24
27
  }
25
28
  }
26
- //#endregion
27
29
  export { checkA11y };
28
30
 
29
31
  //# sourceMappingURL=a11y.mjs.map
package/dist/a11y.mjs.map CHANGED
@@ -1 +1 @@
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"}
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 warning). 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, storyId?: string): Promise<string[]> {\n try {\n const read = await page.evaluate(inPageReadA11yParameters);\n if (read && read.dropped > 0)\n logger?.warn(\n { storyId, dropped: read.dropped },\n \"parameters.a11y: function, symbol or undefined values were omitted; axe uses the remaining configuration\",\n );\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,QAAiB,SAAqC;CAChG,IAAI;EACF,MAAM,OAAO,MAAM,KAAK,SAAS,wBAAwB;EACzD,IAAI,QAAQ,KAAK,UAAU,GACzB,QAAQ,KACN;GAAE;GAAS,SAAS,KAAK;EAAQ,GACjC,0GACF;EACF,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"}
@@ -1,9 +1,8 @@
1
- import "node:path";
1
+ import { basename, dirname, join } from "node:path";
2
2
  import "node:url";
3
3
  import.meta.url;
4
4
  import { existsSync } from "node:fs";
5
- import { basename, dirname, join } from "node:path";
6
- //#region src/availability.ts
5
+ // src/availability.ts
7
6
  /**
8
7
  * Each local target as a {@link RunnerTarget}: `ready`, or `unavailable` with why. Playwright's
9
8
  * `executablePath()` is where the engine would be installed; installing writes an
@@ -59,7 +58,6 @@ function channelPaths(channel) {
59
58
  });
60
59
  return null;
61
60
  }
62
- //#endregion
63
61
  export { verifyLocalTargets };
64
62
 
65
63
  //# sourceMappingURL=availability.mjs.map
@@ -1 +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"}
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
@@ -2,23 +2,47 @@ import "node:path";
2
2
  import "node:url";
3
3
  import.meta.url;
4
4
  import { RenderRuns } from "@storyshelf/core/adapter/capture-runner";
5
- //#region src/browser.ts
5
+ // src/browser.ts
6
6
  /** Browser lifecycle and active-run tracking for Playwright. */
7
7
  /** Tracks in-flight renders, each by its own handle, so `cancel` aborts exactly the runs it names. */
8
8
  const activeRuns = new RenderRuns();
9
- /** Close a browser if present; `cancel` must never throw. */
9
+ /** The lease each remote browser holds, released when the browser is closed. */
10
+ const leases = /* @__PURE__ */ new WeakMap();
11
+ /** Tie a lease to the browser opened over it: closing the browser through this module releases it. */
12
+ function holdLease(browser, lease) {
13
+ leases.set(browser, lease);
14
+ }
15
+ /** Close a browser if present, for a cancel (its lease is released as such); never throws. */
10
16
  async function closeBrowser(browser) {
11
17
  if (!browser) return;
12
- await tryCloseBrowser(browser);
18
+ await tryCloseBrowser(browser, "cancelled");
13
19
  }
14
- async function tryCloseBrowser(browser) {
20
+ async function tryCloseBrowser(browser, outcome, error) {
15
21
  try {
16
22
  await browser.close();
17
23
  } catch {}
24
+ await leases.get(browser)?.end(outcome, error);
25
+ }
26
+ /** The outcome a render's `finally` reports: a cancel wins over a failure, a failure over a finish. */
27
+ function outcomeOf(active, failed) {
28
+ if (active.cancelled) return "cancelled";
29
+ return failed ? "failed" : "finished";
30
+ }
31
+ /** Run `work`, then `finish` with whether it threw, so a browser is closed with how its run went. */
32
+ async function finishing(work, finish) {
33
+ let failure;
34
+ try {
35
+ return await work();
36
+ } catch (error) {
37
+ failure = { error };
38
+ throw error;
39
+ } finally {
40
+ await finish(failure !== void 0, failure?.error);
41
+ }
18
42
  }
19
43
  /** Best-effort close for the render's `finally` block. */
20
- async function safeCloseBrowser(browser) {
21
- await tryCloseBrowser(browser);
44
+ async function safeCloseBrowser(browser, outcome = "finished", error) {
45
+ await tryCloseBrowser(browser, outcome, error);
22
46
  }
23
47
  /** Best-effort close for the static server. */
24
48
  async function safeCloseServer(server) {
@@ -30,7 +54,6 @@ async function safeCloseServer(server) {
30
54
  function messageOf(error) {
31
55
  return error instanceof Error ? error.message : String(error);
32
56
  }
33
- //#endregion
34
- export { activeRuns, closeBrowser, messageOf, safeCloseBrowser, safeCloseServer };
57
+ export { activeRuns, closeBrowser, finishing, holdLease, messageOf, outcomeOf, safeCloseBrowser, safeCloseServer };
35
58
 
36
59
  //# sourceMappingURL=browser.mjs.map
@@ -1 +1 @@
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
+ {"version":3,"file":"browser.mjs","names":[],"sources":["../src/browser.ts"],"sourcesContent":["/** Browser lifecycle and active-run tracking for Playwright. */\n\nimport {\n RenderRuns,\n type HeldConnection,\n type RemoteLeaseOutcome,\n} 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/** The lease each remote browser holds, released when the browser is closed. */\nconst leases = new WeakMap<Browser, HeldConnection>();\n\n/** Tie a lease to the browser opened over it: closing the browser through this module releases it. */\nexport function holdLease(browser: Browser, lease: HeldConnection): void {\n leases.set(browser, lease);\n}\n\n/** Close a browser if present, for a cancel (its lease is released as such); never throws. */\nexport async function closeBrowser(browser: Browser | null): Promise<void> {\n if (!browser) return;\n await tryCloseBrowser(browser, \"cancelled\");\n}\n\nasync function tryCloseBrowser(\n browser: Browser,\n outcome: RemoteLeaseOutcome,\n error?: unknown,\n): 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 // The first outcome wins: a render cancelled mid-way closes its browser as `cancelled` first.\n await leases.get(browser)?.end(outcome, error);\n}\n\n/** The outcome a render's `finally` reports: a cancel wins over a failure, a failure over a finish. */\nexport function outcomeOf(active: { cancelled: boolean }, failed: boolean): RemoteLeaseOutcome {\n if (active.cancelled) return \"cancelled\";\n return failed ? \"failed\" : \"finished\";\n}\n\n/** Run `work`, then `finish` with whether it threw, so a browser is closed with how its run went. */\nexport async function finishing<T>(\n work: () => Promise<T>,\n finish: (failed: boolean, error?: unknown) => Promise<void>,\n): Promise<T> {\n let failure: { error: unknown } | undefined;\n try {\n return await work();\n } catch (error) {\n failure = { error };\n throw error;\n } finally {\n await finish(failure !== undefined, failure?.error);\n }\n}\n\n/** Best-effort close for the render's `finally` block. */\nexport async function safeCloseBrowser(\n browser: Browser,\n outcome: RemoteLeaseOutcome = \"finished\",\n error?: unknown,\n): Promise<void> {\n await tryCloseBrowser(browser, outcome, error);\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":";;;;;;;AAWA,MAAa,aAAa,IAAI,WAAsB;;AAGpD,MAAM,yBAAS,IAAI,QAAiC;;AAGpD,SAAgB,UAAU,SAAkB,OAA6B;CACvE,OAAO,IAAI,SAAS,KAAK;AAC3B;;AAGA,eAAsB,aAAa,SAAwC;CACzE,IAAI,CAAC,SAAS;CACd,MAAM,gBAAgB,SAAS,WAAW;AAC5C;AAEA,eAAe,gBACb,SACA,SACA,OACe;CACf,IAAI;EACF,MAAM,QAAQ,MAAM;CACtB,QAAQ,CAER;CAEA,MAAM,OAAO,IAAI,OAAO,CAAC,EAAE,IAAI,SAAS,KAAK;AAC/C;;AAGA,SAAgB,UAAU,QAAgC,QAAqC;CAC7F,IAAI,OAAO,WAAW,OAAO;CAC7B,OAAO,SAAS,WAAW;AAC7B;;AAGA,eAAsB,UACpB,MACA,QACY;CACZ,IAAI;CACJ,IAAI;EACF,OAAO,MAAM,KAAK;CACpB,SAAS,OAAO;EACd,UAAU,EAAE,MAAM;EAClB,MAAM;CACR,UAAU;EACR,MAAM,OAAO,YAAY,KAAA,GAAW,SAAS,KAAK;CACpD;AACF;;AAGA,eAAsB,iBACpB,SACA,UAA8B,YAC9B,OACe;CACf,MAAM,gBAAgB,SAAS,SAAS,KAAK;AAC/C;;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"}
@@ -0,0 +1,25 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ // src/capture-retry.ts
5
+ /** A narrowly classified page failure that may be retried on a fresh context. */
6
+ var TransientCaptureError = class extends Error {
7
+ phase;
8
+ constructor(phase, cause) {
9
+ super(cause instanceof Error ? cause.message : String(cause), { cause });
10
+ this.name = "TransientCaptureError";
11
+ this.phase = phase;
12
+ }
13
+ };
14
+ /** Only Playwright timeouts from the specified operation are eligible. */
15
+ async function capturePhase(phase, operation) {
16
+ try {
17
+ return await operation();
18
+ } catch (error) {
19
+ if (error instanceof Error && error.name === "TimeoutError") throw new TransientCaptureError(phase, error);
20
+ throw error;
21
+ }
22
+ }
23
+ export { TransientCaptureError, capturePhase };
24
+
25
+ //# sourceMappingURL=capture-retry.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"capture-retry.mjs","names":[],"sources":["../src/capture-retry.ts"],"sourcesContent":["/** A narrowly classified page failure that may be retried on a fresh context. */\nexport class TransientCaptureError extends Error {\n readonly phase: \"navigation\" | \"screenshot\" | \"crash\";\n\n constructor(phase: \"navigation\" | \"screenshot\" | \"crash\", cause: unknown) {\n super(cause instanceof Error ? cause.message : String(cause), { cause });\n this.name = \"TransientCaptureError\";\n this.phase = phase;\n }\n}\n\n/** Only Playwright timeouts from the specified operation are eligible. */\nexport async function capturePhase<T>(\n phase: \"navigation\" | \"screenshot\",\n operation: () => Promise<T>,\n): Promise<T> {\n try {\n return await operation();\n } catch (error) {\n if (error instanceof Error && error.name === \"TimeoutError\") {\n throw new TransientCaptureError(phase, error);\n }\n throw error;\n }\n}\n"],"mappings":";;;;;AACA,IAAa,wBAAb,cAA2C,MAAM;CAC/C;CAEA,YAAY,OAA8C,OAAgB;EACxE,MAAM,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK,GAAG,EAAE,MAAM,CAAC;EACvE,KAAK,OAAO;EACZ,KAAK,QAAQ;CACf;AACF;;AAGA,eAAsB,aACpB,OACA,WACY;CACZ,IAAI;EACF,OAAO,MAAM,UAAU;CACzB,SAAS,OAAO;EACd,IAAI,iBAAiB,SAAS,MAAM,SAAS,gBAC3C,MAAM,IAAI,sBAAsB,OAAO,KAAK;EAE9C,MAAM;CACR;AACF"}
@@ -4,7 +4,6 @@ import { BrowserEntry } from "./targets.mjs";
4
4
  import "./types.mjs";
5
5
  import "./runtime-params.mjs";
6
6
  import { CaptureRunner } from "@storyshelf/core/adapter/capture-runner";
7
- //#region src/capture-runner.d.ts
8
7
  /** Create a CaptureRunner that renders Storybook stories with Playwright. */
9
8
  declare function createPlaywrightCaptureRunner(options?: {
10
9
  /**
@@ -23,7 +22,8 @@ declare function createPlaywrightCaptureRunner(options?: {
23
22
  recycleBrowserEvery?: number;
24
23
  /** Reuse pages between captures (opt-in); falls back to `STORYSHELF_REUSE_PAGES=1`. */
25
24
  reusePages?: boolean;
25
+ /** Gzip small loopback Storybook assets when requested by the browser (default off). */
26
+ staticCompression?: boolean;
26
27
  }): CaptureRunner;
27
- //#endregion
28
28
  export { createPlaywrightCaptureRunner };
29
29
  //# sourceMappingURL=capture-runner.d.mts.map
@@ -9,18 +9,18 @@ import "./runtime-params.mjs";
9
9
  import { extractAll, renderAll } from "./pipeline.mjs";
10
10
  import { flattenBrowsers } from "./targets.mjs";
11
11
  import { runTestEntries } from "./tests.mjs";
12
- import { createTargetOffers } from "@storyshelf/core/adapter/capture-runner";
12
+ import { createTargetOffers, shutdownRemote } from "@storyshelf/core/adapter/capture-runner";
13
13
  import { recommendedNavigationTimeoutMs } from "@storyshelf/core/utils";
14
- //#region src/capture-runner.ts
14
+ // src/capture-runner.ts
15
15
  /** Playwright capture runner — composition root (factory wiring). */
16
16
  /** Create a CaptureRunner that renders Storybook stories with Playwright. */
17
17
  function createPlaywrightCaptureRunner(options = {}) {
18
18
  const { declared, locals, remotes } = flattenBrowsers(options.browsers ?? ["chromium"]);
19
19
  const offers = createTargetOffers(declared);
20
20
  const driver = {
21
- launch: offers.guard(async (target, logger) => {
21
+ launch: offers.guard(async (target, logger, context) => {
22
22
  const remote = remotes.find((entry) => entry.target === target);
23
- return remote ? await connectRemote(remote, logger) : await launchLocal(locals.find((entry) => entry.target === target));
23
+ return remote ? await connectRemote(remote, context, logger) : await launchLocal(locals.find((entry) => entry.target === target));
24
24
  }),
25
25
  remote: (target) => remotes.find((entry) => entry.target === target)
26
26
  };
@@ -32,7 +32,7 @@ function createPlaywrightCaptureRunner(options = {}) {
32
32
  return {
33
33
  metadata: {
34
34
  name: "Playwright",
35
- version: "0.7.0",
35
+ version: "0.9.0-next-d2cc082",
36
36
  description: "Playwright capture runner",
37
37
  kind: "playwright",
38
38
  category: "capture-runner",
@@ -40,11 +40,12 @@ function createPlaywrightCaptureRunner(options = {}) {
40
40
  return offers.current();
41
41
  }
42
42
  },
43
- lifecycle: offers.lifecycle(async () => await verifyTargets(declared, locals)),
43
+ lifecycle: withLeaseRelease(offers.lifecycle(async () => await verifyTargets(declared, locals))),
44
44
  async render(input) {
45
45
  const active = {
46
46
  cancelled: false,
47
- browser: null
47
+ browser: null,
48
+ abort: new AbortController()
48
49
  };
49
50
  const remove = activeRuns.add(input.buildId, input.runId, active);
50
51
  try {
@@ -53,26 +54,32 @@ function createPlaywrightCaptureRunner(options = {}) {
53
54
  navigationTimeoutMs,
54
55
  recycleBrowserEvery,
55
56
  reusePages,
56
- ...input
57
+ ...input,
58
+ staticCompression: options.staticCompression
57
59
  }, active, driver, defaultTarget);
58
60
  } finally {
59
61
  remove();
60
62
  }
61
63
  },
62
64
  async extractParameters(input) {
63
- return await extractAll(input, driver, defaultTarget);
65
+ return await extractAll({
66
+ ...input,
67
+ staticCompression: options.staticCompression
68
+ }, driver, defaultTarget);
64
69
  },
65
70
  async runTests(input) {
66
71
  const active = {
67
72
  cancelled: false,
68
- browser: null
73
+ browser: null,
74
+ abort: new AbortController()
69
75
  };
70
76
  const remove = activeRuns.add(input.buildId, input.runId, active);
71
77
  try {
72
78
  return await runTestEntries({
73
79
  concurrency,
74
80
  navigationTimeoutMs,
75
- ...input
81
+ ...input,
82
+ staticCompression: options.staticCompression
76
83
  }, active, driver, defaultTarget);
77
84
  } finally {
78
85
  remove();
@@ -81,11 +88,22 @@ function createPlaywrightCaptureRunner(options = {}) {
81
88
  async cancel(buildId, runId) {
82
89
  await Promise.all(activeRuns.matching(buildId, runId).map(async (active) => {
83
90
  active.cancelled = true;
91
+ active.abort?.abort();
84
92
  await closeBrowser(active.browser);
85
93
  }));
86
94
  }
87
95
  };
88
96
  }
97
+ /** Teardown also releases every lease still held, so a stopped process leaves no server running. */
98
+ function withLeaseRelease(lifecycle) {
99
+ return {
100
+ ...lifecycle,
101
+ teardown: async () => {
102
+ await shutdownRemote();
103
+ await lifecycle.teardown();
104
+ }
105
+ };
106
+ }
89
107
  /** Local targets are checked for an installed engine; a remote one is not probed (that would open a session). */
90
108
  async function verifyTargets(declared, locals) {
91
109
  const checked = new Map(verifyLocalTargets(locals, await loadPlaywright()).map((entry) => [entry.target, entry]));
@@ -98,7 +116,6 @@ function envInt(name, min) {
98
116
  const parsed = Number.parseInt(process.env[name] ?? "", 10);
99
117
  return parsed >= min ? parsed : void 0;
100
118
  }
101
- //#endregion
102
119
  export { createPlaywrightCaptureRunner };
103
120
 
104
121
  //# sourceMappingURL=capture-runner.mjs.map
@@ -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 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"}
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 shutdownRemote,\n type CaptureRunner,\n type RunnerTarget,\n} from \"@storyshelf/core/adapter/capture-runner\";\nimport type { AdapterLifecycle } from \"@storyshelf/core/adapter/metadata\";\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 /** Gzip small loopback Storybook assets when requested by the browser (default off). */\n staticCompression?: 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, context) => {\n const remote = remotes.find((entry) => entry.target === target);\n return remote\n ? await connectRemote(remote, context, 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: withLeaseRelease(\n offers.lifecycle(async () => await verifyTargets(declared, locals)),\n ),\n async render(input: PlaywrightRenderInput) {\n const active: ActiveRun = { cancelled: false, browser: null, abort: new AbortController() };\n const remove = activeRuns.add(input.buildId, input.runId, active);\n try {\n return await renderAll(\n {\n concurrency,\n navigationTimeoutMs,\n recycleBrowserEvery,\n reusePages,\n ...input,\n staticCompression: options.staticCompression,\n },\n active,\n driver,\n defaultTarget,\n );\n } finally {\n remove();\n }\n },\n async extractParameters(input: ExtractInput) {\n return await extractAll(\n { ...input, staticCompression: options.staticCompression },\n driver,\n defaultTarget,\n );\n },\n async runTests(input: PlaywrightTestsInput) {\n const active: ActiveRun = { cancelled: false, browser: null, abort: new AbortController() };\n const remove = activeRuns.add(input.buildId, input.runId, active);\n try {\n return await runTestEntries(\n {\n concurrency,\n navigationTimeoutMs,\n ...input,\n staticCompression: options.staticCompression,\n },\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 active.abort?.abort();\n await closeBrowser(active.browser);\n }),\n );\n },\n };\n}\n\n/** Teardown also releases every lease still held, so a stopped process leaves no server running. */\nfunction withLeaseRelease(lifecycle: AdapterLifecycle): AdapterLifecycle {\n return {\n ...lifecycle,\n teardown: async () => {\n await shutdownRemote();\n await lifecycle.teardown();\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":";;;;;;;;;;;;;;;;AA4BA,SAAgB,8BACd,UAmBI,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,QAAQ,YAAY;GACtD,MAAM,SAAS,QAAQ,MAAM,UAAU,MAAM,WAAW,MAAM;GAC9D,OAAO,SACH,MAAM,cAAc,QAAQ,SAAS,MAAM,IAC3C,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,iBACT,OAAO,UAAU,YAAY,MAAM,cAAc,UAAU,MAAM,CAAC,CACpE;EACA,MAAM,OAAO,OAA8B;GACzC,MAAM,SAAoB;IAAE,WAAW;IAAO,SAAS;IAAM,OAAO,IAAI,gBAAgB;GAAE;GAC1F,MAAM,SAAS,WAAW,IAAI,MAAM,SAAS,MAAM,OAAO,MAAM;GAChE,IAAI;IACF,OAAO,MAAM,UACX;KACE;KACA;KACA;KACA;KACA,GAAG;KACH,mBAAmB,QAAQ;IAC7B,GACA,QACA,QACA,aACF;GACF,UAAU;IACR,OAAO;GACT;EACF;EACA,MAAM,kBAAkB,OAAqB;GAC3C,OAAO,MAAM,WACX;IAAE,GAAG;IAAO,mBAAmB,QAAQ;GAAkB,GACzD,QACA,aACF;EACF;EACA,MAAM,SAAS,OAA6B;GAC1C,MAAM,SAAoB;IAAE,WAAW;IAAO,SAAS;IAAM,OAAO,IAAI,gBAAgB;GAAE;GAC1F,MAAM,SAAS,WAAW,IAAI,MAAM,SAAS,MAAM,OAAO,MAAM;GAChE,IAAI;IACF,OAAO,MAAM,eACX;KACE;KACA;KACA,GAAG;KACH,mBAAmB,QAAQ;IAC7B,GACA,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,OAAO,OAAO,MAAM;IACpB,MAAM,aAAa,OAAO,OAAO;GACnC,CAAC,CACH;EACF;CACF;AACF;;AAGA,SAAS,iBAAiB,WAA+C;CACvE,OAAO;EACL,GAAG;EACH,UAAU,YAAY;GACpB,MAAM,eAAe;GACrB,MAAM,UAAU,SAAS;EAC3B;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"}
package/dist/connect.mjs CHANGED
@@ -1,53 +1,41 @@
1
1
  import "node:path";
2
2
  import "node:url";
3
3
  import.meta.url;
4
+ import { holdLease } from "./browser.mjs";
4
5
  import { loadPlaywright } from "./launch.mjs";
5
6
  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
7
+ import { connectLeased, scrubConnection } from "@storyshelf/core/adapter/capture-runner";
8
+ // src/connect.ts
9
9
  /** Connecting to a remote browser: retried when the service is unreachable or full, a clear error when it is not. */
10
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.
11
+ * Connect to `entry`'s service and return its browser, holding the service's lease (a function
12
+ * `connect`'s `lease`) until the browser is closed through `closeBrowser`/`safeCloseBrowser`.
13
+ * Connection-level failures are retried with backoff, so a provider outage or a full plan costs
14
+ * time and not a story crash; what is left is a `TargetLaunchError`, which fails the job at once
15
+ * (a wrong version or credential also marks the target unavailable, a service that is merely down
16
+ * does not). Nothing in a message carries the endpoint, its credentials or the lease.
16
17
  */
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
- }
18
+ async function connectRemote(entry, context, logger) {
19
+ const playwright = await loadPlaywright();
20
+ const { value, lease } = await connectLeased(entry, context, {
21
+ logger,
22
+ open: async (connection) => await connectOnce(playwright, entry, connection),
23
+ reasonOf
24
+ });
25
+ holdLease(value, lease);
26
+ return value;
40
27
  }
41
28
  async function connectOnce(playwright, entry, connection) {
42
29
  const { headers, endpoint } = connection;
43
- if (connection.protocol === "cdp") return await playwright.chromium.connectOverCDP(endpoint, {
44
- timeout: entry.remote.connectTimeoutMs,
30
+ const timeout = entry.remote.timeouts.connectMs;
31
+ if (entry.remote.protocol === "cdp") return await playwright.chromium.connectOverCDP(endpoint, {
32
+ timeout,
45
33
  ...headers === void 0 ? {} : { headers }
46
34
  });
47
35
  return await playwright[entry.engine === "chrome" ? "chromium" : entry.engine].connect(endpoint, {
48
- timeout: entry.remote.connectTimeoutMs,
36
+ timeout,
49
37
  ...headers === void 0 ? {} : { headers },
50
- ...connection.exposeNetwork === void 0 ? {} : { exposeNetwork: connection.exposeNetwork }
38
+ ...entry.reach === "loopback" ? { exposeNetwork: "<loopback>" } : {}
51
39
  });
52
40
  }
53
41
  /** What failed, in one line, with the version mismatch spelled out and the credentials removed. */
@@ -68,12 +56,6 @@ function versionMismatch(text) {
68
56
  if (server === void 0 || client === void 0) return null;
69
57
  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
58
  }
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
59
  export { connectRemote };
78
60
 
79
61
  //# sourceMappingURL=connect.mjs.map
@@ -1 +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"}
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 connectLeased,\n scrubConnection,\n type ConnectLeasedContext,\n type RemoteConnection,\n type RemoteTarget,\n} from \"@storyshelf/core/adapter/capture-runner\";\nimport type { Logger } from \"@storyshelf/core/logger\";\nimport type { Browser } from \"playwright-core\";\nimport { holdLease } from \"./browser.ts\";\nimport { loadPlaywright } from \"./launch.ts\";\nimport { playwrightVersion } from \"./version.ts\";\n\n/**\n * Connect to `entry`'s service and return its browser, holding the service's lease (a function\n * `connect`'s `lease`) until the browser is closed through `closeBrowser`/`safeCloseBrowser`.\n * Connection-level failures are retried with backoff, so a provider outage or a full plan costs\n * time and not a story crash; what is left is a `TargetLaunchError`, which fails the job at once\n * (a wrong version or credential also marks the target unavailable, a service that is merely down\n * does not). Nothing in a message carries the endpoint, its credentials or the lease.\n */\nexport async function connectRemote(\n entry: RemoteTarget,\n context: ConnectLeasedContext,\n logger?: Logger,\n): Promise<Browser> {\n const playwright = await loadPlaywright();\n const { value, lease } = await connectLeased(entry, context, {\n logger,\n open: async (connection) => await connectOnce(playwright, entry, connection),\n reasonOf,\n });\n holdLease(value, lease);\n return value;\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 const timeout = entry.remote.timeouts.connectMs;\n if (entry.remote.protocol === \"cdp\") {\n return await playwright.chromium.connectOverCDP(endpoint, {\n timeout,\n ...(headers === undefined ? {} : { headers }),\n });\n }\n return await playwright[entry.engine === \"chrome\" ? \"chromium\" : entry.engine].connect(endpoint, {\n timeout,\n ...(headers === undefined ? {} : { headers }),\n ...(entry.reach === \"loopback\" ? { exposeNetwork: \"<loopback>\" } : {}),\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 & { lease?: unknown }): 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"],"mappings":";;;;;;;;;;;;;;;;;AAsBA,eAAsB,cACpB,OACA,SACA,QACkB;CAClB,MAAM,aAAa,MAAM,eAAe;CACxC,MAAM,EAAE,OAAO,UAAU,MAAM,cAAc,OAAO,SAAS;EAC3D;EACA,MAAM,OAAO,eAAe,MAAM,YAAY,YAAY,OAAO,UAAU;EAC3E;CACF,CAAC;CACD,UAAU,OAAO,KAAK;CACtB,OAAO;AACT;AAEA,eAAe,YACb,YACA,OACA,YACkB;CAClB,MAAM,EAAE,SAAS,aAAa;CAC9B,MAAM,UAAU,MAAM,OAAO,SAAS;CACtC,IAAI,MAAM,OAAO,aAAa,OAC5B,OAAO,MAAM,WAAW,SAAS,eAAe,UAAU;EACxD;EACA,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;EACA,GAAI,YAAY,KAAA,IAAY,CAAC,IAAI,EAAE,QAAQ;EAC3C,GAAI,MAAM,UAAU,aAAa,EAAE,eAAe,aAAa,IAAI,CAAC;CACtE,CAAC;AACH;;AAGA,SAAS,SAAS,OAAgB,YAA4D;CAC5F,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"}
package/dist/launch.mjs CHANGED
@@ -2,7 +2,7 @@ import "node:path";
2
2
  import "node:url";
3
3
  import.meta.url;
4
4
  import { TargetLaunchError } from "@storyshelf/core/adapter/capture-runner";
5
- //#region src/launch.ts
5
+ // src/launch.ts
6
6
  /** Starting a local engine, and telling a browser that would not start from one that crashed. */
7
7
  /**
8
8
  * Low-memory Chromium flags. Deliberately excludes rendering-affecting flags
@@ -58,7 +58,6 @@ function launcherOf(engine, playwright) {
58
58
  function firstLine(error) {
59
59
  return (error instanceof Error ? error.message : String(error)).split("\n").find((line) => line.trim() !== "")?.trim() ?? "the browser did not start";
60
60
  }
61
- //#endregion
62
61
  export { launchLocal, loadPlaywright };
63
62
 
64
63
  //# sourceMappingURL=launch.mjs.map
package/dist/pages.mjs CHANGED
@@ -2,7 +2,7 @@ import "node:path";
2
2
  import "node:url";
3
3
  import.meta.url;
4
4
  import { installSettleRecorder } from "./settle.mjs";
5
- //#region src/pages.ts
5
+ // src/pages.ts
6
6
  /** Lease a page for `viewport`: pooled when the context has a pool, otherwise a throwaway. */
7
7
  async function acquirePage(ctx, viewport) {
8
8
  if (ctx.pagePool) return await ctx.pagePool.acquire(viewport);
@@ -85,7 +85,6 @@ async function clearBrowserState() {
85
85
  const databases = await scope.indexedDB?.databases?.() ?? [];
86
86
  for (const database of databases) if (database.name) scope.indexedDB?.deleteDatabase(database.name);
87
87
  }
88
- //#endregion
89
88
  export { PagePool, acquirePage };
90
89
 
91
90
  //# sourceMappingURL=pages.mjs.map