@storyshelf/runner-puppeteer 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.
- package/README.md +71 -1
- package/dist/a11y.mjs +18 -20
- package/dist/a11y.mjs.map +1 -1
- package/dist/availability.mjs +31 -0
- package/dist/availability.mjs.map +1 -0
- package/dist/browser.mjs +4 -2
- package/dist/browser.mjs.map +1 -1
- package/dist/capture-runner.d.mts +20 -3
- package/dist/capture-runner.mjs +68 -10
- package/dist/capture-runner.mjs.map +1 -1
- package/dist/connect.mjs +55 -0
- package/dist/connect.mjs.map +1 -0
- package/dist/index.d.mts +2 -1
- package/dist/index.mjs +2 -1
- package/dist/launch.mjs +53 -0
- package/dist/launch.mjs.map +1 -0
- package/dist/pages.d.mts +4 -0
- package/dist/pages.mjs +89 -0
- package/dist/pages.mjs.map +1 -0
- package/dist/pipeline.mjs +124 -27
- package/dist/pipeline.mjs.map +1 -1
- package/dist/play.mjs +15 -2
- package/dist/play.mjs.map +1 -1
- package/dist/remote/cdp.d.mts +29 -0
- package/dist/remote/cdp.mjs +34 -0
- package/dist/remote/cdp.mjs.map +1 -0
- package/dist/remote/index.d.mts +5 -0
- package/dist/remote/index.mjs +5 -0
- package/dist/remote-environment.mjs +43 -0
- package/dist/remote-environment.mjs.map +1 -0
- package/dist/screenshot.mjs +40 -19
- package/dist/screenshot.mjs.map +1 -1
- package/dist/settle.mjs +150 -0
- package/dist/settle.mjs.map +1 -0
- package/dist/targets.d.mts +25 -0
- package/dist/targets.mjs +80 -0
- package/dist/targets.mjs.map +1 -0
- package/dist/tests.mjs +114 -0
- package/dist/tests.mjs.map +1 -0
- package/dist/types.d.mts +4 -2
- package/package.json +16 -6
package/README.md
CHANGED
|
@@ -19,11 +19,81 @@ npm install @storyshelf/runner-puppeteer
|
|
|
19
19
|
```ts
|
|
20
20
|
import { createPuppeteerCaptureRunner } from "@storyshelf/runner-puppeteer";
|
|
21
21
|
|
|
22
|
-
const
|
|
22
|
+
const runner = createPuppeteerCaptureRunner({
|
|
23
|
+
// defaults to the test-bundled chrome-headless-shell; point at your own Chromium when needed
|
|
24
|
+
executablePath: process.env.CHROME_PATH,
|
|
25
|
+
});
|
|
23
26
|
```
|
|
24
27
|
|
|
28
|
+
Hand the runner to the server:
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
import { createShelfApp } from "@storyshelf/app";
|
|
32
|
+
import { createPuppeteerCaptureRunner } from "@storyshelf/runner-puppeteer";
|
|
33
|
+
|
|
34
|
+
const app = createShelfApp({
|
|
35
|
+
database,
|
|
36
|
+
storage,
|
|
37
|
+
testing: { runner: createPuppeteerCaptureRunner() },
|
|
38
|
+
});
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Browsers
|
|
42
|
+
|
|
43
|
+
`browsers` lists the targets the runner offers, default first (ADR 0007); omitted it is `["chromium"]`.
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
import { createPuppeteerCaptureRunner, local } from "@storyshelf/runner-puppeteer";
|
|
47
|
+
|
|
48
|
+
createPuppeteerCaptureRunner({
|
|
49
|
+
executablePath: "/usr/bin/chromium", // what the bare "chromium" entry launches
|
|
50
|
+
browsers: ["chromium", local("chrome", { executablePath: "/usr/bin/google-chrome" })],
|
|
51
|
+
});
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
`puppeteer-core` ships no browser, so a target is `ready` only when it has an executable that exists (the entry's own, the runner's `executablePath` for `chromium`, or `PUPPETEER_EXECUTABLE_PATH`); otherwise it is `unavailable` with that reason on **System → Capture browsers**. Bare `"chrome"` without its own `executablePath` throws when the runner is created, and `local("firefox")` and `local("webkit")` throw too: this runner drives Chromium-family engines only. A launch that fails marks its target unavailable and fails the job at once.
|
|
55
|
+
|
|
56
|
+
## Remote browsers
|
|
57
|
+
|
|
58
|
+
`cdp({ name, endpoint, headers, sessions })` from `@storyshelf/runner-puppeteer/remote` is a Chrome DevTools Protocol endpoint (Browserless, Cloudflare Browser Rendering, a Chrome with remote debugging). The runner calls `puppeteer.connect` and offers `chromium@<name>` (and `chrome@<name>` with `engines`):
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
import { createPuppeteerCaptureRunner } from "@storyshelf/runner-puppeteer";
|
|
62
|
+
import { cdp } from "@storyshelf/runner-puppeteer/remote";
|
|
63
|
+
|
|
64
|
+
createPuppeteerCaptureRunner({
|
|
65
|
+
executablePath: "/usr/bin/chromium",
|
|
66
|
+
browsers: [
|
|
67
|
+
"chromium",
|
|
68
|
+
cdp({
|
|
69
|
+
name: "cloudflare",
|
|
70
|
+
endpoint: "wss://…",
|
|
71
|
+
headers: { authorization: `Bearer ${token}` },
|
|
72
|
+
sessions: 4,
|
|
73
|
+
}),
|
|
74
|
+
],
|
|
75
|
+
});
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
A Puppeteer remote browser cannot route back to the host, so every remote target needs a signed Storybook URL (`reach: "url"`); it stays `unavailable` until a `secret` and a confirmed public origin exist, then the server hands the runner a short-lived signed URL (`<public origin>/_s/<token>/`) for every render pass, and the runner refuses to start one without it. See [Signed Storybook URLs](https://storyshelf.js.org/guides/remote-browsers/#signed-storybook-urls). The target's environment is measured inside the browser, `sessions` is the per-target page cap shared across the process, and connection failures are retried with backoff. Details in [Remote browsers](https://storyshelf.js.org/guides/remote-browsers/).
|
|
79
|
+
|
|
80
|
+
## Concurrency and memory
|
|
81
|
+
|
|
82
|
+
Pages render through a bounded pool instead of all at once. The default is sized from the detected CPU and memory, container limits included (one page on 0.5–1 vCPU hosts, up to 4); override with `createPuppeteerCaptureRunner({ concurrency })` or `STORYSHELF_CAPTURE_CONCURRENCY`. The per-page timeout scales with CPU (30 s, 60 s under 2 CPUs, 90 s under 1 CPU); override with `navigationTimeoutMs` or `STORYSHELF_NAVIGATION_TIMEOUT_MS`. The browser is restarted every 150 captures to bound its slow memory growth (`recycleBrowserEvery`, or `STORYSHELF_BROWSER_RECYCLE_EVERY`; `0` disables), and each render logs a `capture sizing` line with the detected CPUs, memory and resulting settings. When the orchestrator passes `onCapture`, each screenshot is handed over as soon as it is taken instead of being collected in the result, so memory does not grow with the story count. Set `reusePages: true` (or `STORYSHELF_REUSE_PAGES=1`) to reuse a pool of reset pages (own context each; storage, cookies and IndexedDB cleared between captures; failed pages discarded). It is off by default. The default launch args add low-memory flags (`--disable-dev-shm-usage`, `--disable-extensions`, `--disable-background-networking`, `--mute-audio`); passing your own `args` replaces them. See the deployment guide's "Sizing memory for capture" section.
|
|
83
|
+
|
|
25
84
|
See the [StoryShelf documentation](https://github.com/GuptaSiddhant/StoryShelf#readme) for configuration details.
|
|
26
85
|
|
|
86
|
+
## Puppeteer vs Playwright
|
|
87
|
+
|
|
88
|
+
| Aspect | `runner-puppeteer` | `runner-playwright` |
|
|
89
|
+
| ------------ | -------------------------------------------------------------------------------------------------- | --------------------------------------------------- |
|
|
90
|
+
| Engine | `puppeteer-core` + `chrome-headless-shell` (Chromium only) | Playwright (`chromium`/`firefox`/`webkit`/`chrome`) |
|
|
91
|
+
| Selected via | `browsers` runner option (Chromium family only); projects pick from what it offers | `browsers` runner option; projects pick from it |
|
|
92
|
+
| Image size | Smaller — no bundled Firefox/WebKit | Larger |
|
|
93
|
+
| Capabilities | Same orchestrator-owned controls: `play`, `delay`, `diffThreshold`, `viewports`, a11y (axe), burst | Identical controls |
|
|
94
|
+
|
|
95
|
+
Both runners implement the same pure `CaptureRunner` contract. The orchestrator (`@storyshelf/core`, ADR 0015) owns build loading, archive extraction, story discovery, and persistence and delegates only the screenshotting to the runner, so switching is a one-line factory swap.
|
|
96
|
+
|
|
27
97
|
## License
|
|
28
98
|
|
|
29
99
|
MIT
|
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
|
-
/**
|
|
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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
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":["/**
|
|
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 \"puppeteer-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,31 @@
|
|
|
1
|
+
import "node:path";
|
|
2
|
+
import "node:url";
|
|
3
|
+
import.meta.url;
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
//#region src/availability.ts
|
|
6
|
+
/**
|
|
7
|
+
* Each local target as a {@link RunnerTarget}. `puppeteer-core` ships no browser, so a target is
|
|
8
|
+
* `ready` only when it has an executable that exists: its own, the runner's, or the one named by
|
|
9
|
+
* `PUPPETEER_EXECUTABLE_PATH`, which `puppeteer-core` itself reads.
|
|
10
|
+
*/
|
|
11
|
+
function verifyLocalTargets(targets, env = process.env, exists = existsSync) {
|
|
12
|
+
return targets.map((entry) => ({
|
|
13
|
+
target: entry.target,
|
|
14
|
+
reach: "loopback",
|
|
15
|
+
status: statusOf(entry, env, exists)
|
|
16
|
+
}));
|
|
17
|
+
}
|
|
18
|
+
function statusOf(entry, env, exists) {
|
|
19
|
+
const path = entry.executablePath ?? envPath(entry, env);
|
|
20
|
+
if (path === void 0) return { unavailable: "no executablePath (puppeteer-core ships no browser): set `executablePath`" };
|
|
21
|
+
return exists(path) ? "ready" : { unavailable: `executable not found at ${path}` };
|
|
22
|
+
}
|
|
23
|
+
/** `PUPPETEER_EXECUTABLE_PATH` is for the bundled engine, never for `chrome`. */
|
|
24
|
+
function envPath(entry, env) {
|
|
25
|
+
const path = env["PUPPETEER_EXECUTABLE_PATH"];
|
|
26
|
+
return entry.target === "chromium" && path ? path : void 0;
|
|
27
|
+
}
|
|
28
|
+
//#endregion
|
|
29
|
+
export { verifyLocalTargets };
|
|
30
|
+
|
|
31
|
+
//# sourceMappingURL=availability.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"availability.mjs","names":[],"sources":["../src/availability.ts"],"sourcesContent":["/** Telling which declared local engines can launch, without launching anything (ADR 0007, decision 7). */\nimport type { RunnerTarget } from \"@storyshelf/core/adapter/capture-runner\";\nimport { existsSync } from \"node:fs\";\nimport type { LocalTarget } from \"./targets.ts\";\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}. `puppeteer-core` ships no browser, so a target is\n * `ready` only when it has an executable that exists: its own, the runner's, or the one named by\n * `PUPPETEER_EXECUTABLE_PATH`, which `puppeteer-core` itself reads.\n */\nexport function verifyLocalTargets(\n targets: readonly LocalTarget[],\n env: Readonly<Record<string, string | undefined>> = process.env,\n exists: Exists = existsSync,\n): RunnerTarget[] {\n return targets.map((entry) => ({\n target: entry.target,\n reach: \"loopback\",\n status: statusOf(entry, env, exists),\n }));\n}\n\nfunction statusOf(\n entry: LocalTarget,\n env: Readonly<Record<string, string | undefined>>,\n exists: Exists,\n): RunnerTarget[\"status\"] {\n const path = entry.executablePath ?? envPath(entry, env);\n if (path === undefined) {\n return {\n unavailable: \"no executablePath (puppeteer-core ships no browser): set `executablePath`\",\n };\n }\n return exists(path) ? \"ready\" : { unavailable: `executable not found at ${path}` };\n}\n\n/** `PUPPETEER_EXECUTABLE_PATH` is for the bundled engine, never for `chrome`. */\nfunction envPath(\n entry: LocalTarget,\n env: Readonly<Record<string, string | undefined>>,\n): string | undefined {\n const path = env[\"PUPPETEER_EXECUTABLE_PATH\"];\n return entry.target === \"chromium\" && path ? path : undefined;\n}\n"],"mappings":";;;;;;;;;;AAaA,SAAgB,mBACd,SACA,MAAoD,QAAQ,KAC5D,SAAiB,YACD;CAChB,OAAO,QAAQ,KAAK,WAAW;EAC7B,QAAQ,MAAM;EACd,OAAO;EACP,QAAQ,SAAS,OAAO,KAAK,MAAM;CACrC,EAAE;AACJ;AAEA,SAAS,SACP,OACA,KACA,QACwB;CACxB,MAAM,OAAO,MAAM,kBAAkB,QAAQ,OAAO,GAAG;CACvD,IAAI,SAAS,KAAA,GACX,OAAO,EACL,aAAa,4EACf;CAEF,OAAO,OAAO,IAAI,IAAI,UAAU,EAAE,aAAa,2BAA2B,OAAO;AACnF;;AAGA,SAAS,QACP,OACA,KACoB;CACpB,MAAM,OAAO,IAAI;CACjB,OAAO,MAAM,WAAW,cAAc,OAAO,OAAO,KAAA;AACtD"}
|
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
|
-
/**
|
|
6
|
-
|
|
6
|
+
/** Browser lifecycle and active-run tracking for Puppeteer. */
|
|
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;
|
package/dist/browser.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"browser.mjs","names":[],"sources":["../src/browser.ts"],"sourcesContent":["/** Browser lifecycle and active-run tracking for Puppeteer. */\n\nimport type { Browser } from \"puppeteer-core\";\nimport type { ActiveRun } from \"./types.ts\";\n\n/** Tracks in-flight renders so `cancel`
|
|
1
|
+
{"version":3,"file":"browser.mjs","names":[],"sources":["../src/browser.ts"],"sourcesContent":["/** Browser lifecycle and active-run tracking for Puppeteer. */\n\nimport { RenderRuns } from \"@storyshelf/core/adapter/capture-runner\";\nimport type { Browser } from \"puppeteer-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,15 +1,32 @@
|
|
|
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 {
|
|
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 Puppeteer (Chromium via chrome-headless-shell). */
|
|
8
9
|
declare function createPuppeteerCaptureRunner(options?: {
|
|
9
|
-
|
|
10
|
-
|
|
10
|
+
/**
|
|
11
|
+
* Every target the runner offers, default first (ADR 0007, decision 1): `"chromium"`,
|
|
12
|
+
* `local(engine, { executablePath })`, or remote CDP services from
|
|
13
|
+
* `@storyshelf/runner-puppeteer/remote` (`cdp(...)`: `<engine>@<name>` targets that need a
|
|
14
|
+
* Storybook URL they can reach). Default `["chromium"]`. Chromium-family engines only; bare
|
|
15
|
+
* `"chrome"` needs `local("chrome", { executablePath })`.
|
|
16
|
+
*/
|
|
17
|
+
browsers?: readonly BrowserEntry[];
|
|
18
|
+
/** The executable bare entries launch (`puppeteer-core` ships no browser). */
|
|
11
19
|
executablePath?: string;
|
|
20
|
+
/** Launch flags for local engines; replaces the default sandbox and low-memory flags. */
|
|
12
21
|
args?: string[];
|
|
22
|
+
/** Pages rendered at once; falls back to `STORYSHELF_CAPTURE_CONCURRENCY`, then CPU/memory detection. */
|
|
23
|
+
concurrency?: number;
|
|
24
|
+
/** Per-page timeout in ms; falls back to `STORYSHELF_NAVIGATION_TIMEOUT_MS`, then CPU detection. */
|
|
25
|
+
navigationTimeoutMs?: number;
|
|
26
|
+
/** Captures between browser restarts (0 disables); falls back to `STORYSHELF_BROWSER_RECYCLE_EVERY`, then 150. */
|
|
27
|
+
recycleBrowserEvery?: number;
|
|
28
|
+
/** Reuse pages between captures (opt-in); falls back to `STORYSHELF_REUSE_PAGES=1`. */
|
|
29
|
+
reusePages?: boolean;
|
|
13
30
|
}): CaptureRunner;
|
|
14
31
|
//#endregion
|
|
15
32
|
export { createPuppeteerCaptureRunner };
|
package/dist/capture-runner.mjs
CHANGED
|
@@ -1,41 +1,99 @@
|
|
|
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 { connectRemote } from "./connect.mjs";
|
|
7
|
+
import { launchLocal } from "./launch.mjs";
|
|
5
8
|
import "./runtime-params.mjs";
|
|
6
9
|
import { 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";
|
|
7
14
|
//#region src/capture-runner.ts
|
|
15
|
+
/** Puppeteer capture runner — composition root (factory wiring). */
|
|
8
16
|
/** Create a CaptureRunner that renders Storybook stories with Puppeteer (Chromium via chrome-headless-shell). */
|
|
9
17
|
function createPuppeteerCaptureRunner(options = {}) {
|
|
18
|
+
const { declared, locals, remotes } = flattenBrowsers(options.browsers ?? ["chromium"], options.executablePath);
|
|
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), options.args);
|
|
24
|
+
}),
|
|
25
|
+
remote: (target) => remotes.find((entry) => entry.target === target)
|
|
26
|
+
};
|
|
27
|
+
const concurrency = options.concurrency ?? envPositiveInt("STORYSHELF_CAPTURE_CONCURRENCY");
|
|
28
|
+
const navigationTimeoutMs = options.navigationTimeoutMs ?? envPositiveInt("STORYSHELF_NAVIGATION_TIMEOUT_MS") ?? recommendedNavigationTimeoutMs();
|
|
29
|
+
const recycleBrowserEvery = options.recycleBrowserEvery ?? envInt("STORYSHELF_BROWSER_RECYCLE_EVERY", 0);
|
|
30
|
+
const reusePages = options.reusePages ?? ["1", "true"].includes(process.env["STORYSHELF_REUSE_PAGES"] ?? "");
|
|
10
31
|
return {
|
|
11
32
|
metadata: {
|
|
12
33
|
name: "Puppeteer",
|
|
13
|
-
version:
|
|
34
|
+
version: "0.7.0",
|
|
14
35
|
description: "Puppeteer capture runner (chrome-headless-shell)",
|
|
15
36
|
kind: "puppeteer",
|
|
16
37
|
category: "capture-runner",
|
|
17
|
-
|
|
38
|
+
get targets() {
|
|
39
|
+
return offers.current();
|
|
40
|
+
}
|
|
18
41
|
},
|
|
42
|
+
lifecycle: offers.lifecycle(async () => await Promise.resolve(verifyTargets(declared, locals))),
|
|
19
43
|
async render(input) {
|
|
20
44
|
const active = {
|
|
21
45
|
cancelled: false,
|
|
22
46
|
browser: null
|
|
23
47
|
};
|
|
24
|
-
activeRuns.
|
|
48
|
+
const remove = activeRuns.add(input.buildId, input.runId, active);
|
|
25
49
|
try {
|
|
26
|
-
return await renderAll(
|
|
50
|
+
return await renderAll({
|
|
51
|
+
concurrency,
|
|
52
|
+
navigationTimeoutMs,
|
|
53
|
+
recycleBrowserEvery,
|
|
54
|
+
reusePages,
|
|
55
|
+
...input
|
|
56
|
+
}, active, driver, offers.defaultTarget);
|
|
27
57
|
} finally {
|
|
28
|
-
|
|
58
|
+
remove();
|
|
29
59
|
}
|
|
30
60
|
},
|
|
31
|
-
async
|
|
32
|
-
const active =
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
61
|
+
async runTests(input) {
|
|
62
|
+
const active = {
|
|
63
|
+
cancelled: false,
|
|
64
|
+
browser: null
|
|
65
|
+
};
|
|
66
|
+
const remove = activeRuns.add(input.buildId, input.runId, active);
|
|
67
|
+
try {
|
|
68
|
+
return await runTestEntries({
|
|
69
|
+
concurrency,
|
|
70
|
+
navigationTimeoutMs,
|
|
71
|
+
...input
|
|
72
|
+
}, active, driver, offers.defaultTarget);
|
|
73
|
+
} finally {
|
|
74
|
+
remove();
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
async cancel(buildId, runId) {
|
|
78
|
+
await Promise.all(activeRuns.matching(buildId, runId).map(async (active) => {
|
|
79
|
+
active.cancelled = true;
|
|
80
|
+
await closeBrowser(active.browser);
|
|
81
|
+
}));
|
|
36
82
|
}
|
|
37
83
|
};
|
|
38
84
|
}
|
|
85
|
+
/** Local targets are checked for an executable; a remote one is not probed (that would open a session). */
|
|
86
|
+
function verifyTargets(declared, locals) {
|
|
87
|
+
const checked = new Map(verifyLocalTargets(locals).map((entry) => [entry.target, entry]));
|
|
88
|
+
return declared.map((entry) => checked.get(entry.target) ?? entry);
|
|
89
|
+
}
|
|
90
|
+
function envPositiveInt(name) {
|
|
91
|
+
return envInt(name, 1);
|
|
92
|
+
}
|
|
93
|
+
function envInt(name, min) {
|
|
94
|
+
const parsed = Number.parseInt(process.env[name] ?? "", 10);
|
|
95
|
+
return parsed >= min ? parsed : void 0;
|
|
96
|
+
}
|
|
39
97
|
//#endregion
|
|
40
98
|
export { createPuppeteerCaptureRunner };
|
|
41
99
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"capture-runner.mjs","names":[],"sources":["../src/capture-runner.ts"],"sourcesContent":["/** Puppeteer capture runner — composition root (factory wiring). */\n\nimport type
|
|
1
|
+
{"version":3,"file":"capture-runner.mjs","names":[],"sources":["../src/capture-runner.ts"],"sourcesContent":["/** Puppeteer 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 } from \"./launch.ts\";\nimport { renderAll } from \"./pipeline.ts\";\nimport { flattenBrowsers, type BrowserEntry, type LocalTarget } from \"./targets.ts\";\nimport { runTestEntries } from \"./tests.ts\";\nimport type {\n ActiveRun,\n PuppeteerRenderInput,\n PuppeteerTestsInput,\n TargetDriver,\n} from \"./types.ts\";\n\ndeclare const __PKG_VERSION__: string | undefined;\n\n/** Create a CaptureRunner that renders Storybook stories with Puppeteer (Chromium via chrome-headless-shell). */\nexport function createPuppeteerCaptureRunner(\n options: {\n /**\n * Every target the runner offers, default first (ADR 0007, decision 1): `\"chromium\"`,\n * `local(engine, { executablePath })`, or remote CDP services from\n * `@storyshelf/runner-puppeteer/remote` (`cdp(...)`: `<engine>@<name>` targets that need a\n * Storybook URL they can reach). Default `[\"chromium\"]`. Chromium-family engines only; bare\n * `\"chrome\"` needs `local(\"chrome\", { executablePath })`.\n */\n browsers?: readonly BrowserEntry[];\n /** The executable bare entries launch (`puppeteer-core` ships no browser). */\n executablePath?: string;\n /** Launch flags for local engines; replaces the default sandbox and low-memory flags. */\n args?: string[];\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(\n options.browsers ?? [\"chromium\"],\n options.executablePath,\n );\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(\n locals.find((entry) => entry.target === target) as LocalTarget,\n options.args,\n );\n }),\n remote: (target) => remotes.find((entry) => entry.target === target),\n };\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: \"Puppeteer\",\n version: typeof __PKG_VERSION__ === \"string\" ? __PKG_VERSION__ : \"0.0.0\",\n description: \"Puppeteer capture runner (chrome-headless-shell)\",\n kind: \"puppeteer\",\n category: \"capture-runner\",\n get targets() {\n return offers.current();\n },\n },\n lifecycle: offers.lifecycle(async () => await Promise.resolve(verifyTargets(declared, locals))),\n async render(input: PuppeteerRenderInput) {\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 offers.defaultTarget,\n );\n } finally {\n remove();\n }\n },\n async runTests(input: PuppeteerTestsInput) {\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 offers.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 executable; a remote one is not probed (that would open a session). */\nfunction verifyTargets(\n declared: readonly RunnerTarget[],\n locals: readonly LocalTarget[],\n): RunnerTarget[] {\n const checked = new Map(verifyLocalTargets(locals).map((entry) => [entry.target, entry]));\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 { PuppeteerRenderInput, RuntimeParamsState } from \"./types.ts\";\nexport { createRuntimeParamsState, runtimeParametersForStory } from \"./runtime-params.ts\";\n"],"mappings":";;;;;;;;;;;;;;;;AAyBA,SAAgB,6BACd,UAqBI,CAAC,GACU;CACf,MAAM,EAAE,UAAU,QAAQ,YAAY,gBACpC,QAAQ,YAAY,CAAC,UAAU,GAC/B,QAAQ,cACV;CAEA,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,YACJ,OAAO,MAAM,UAAU,MAAM,WAAW,MAAM,GAC9C,QAAQ,IACV;EACN,CAAC;EACD,SAAS,WAAW,QAAQ,MAAM,UAAU,MAAM,WAAW,MAAM;CACrE;CACA,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,QAAQ,QAAQ,cAAc,UAAU,MAAM,CAAC,CAAC;EAC9F,MAAM,OAAO,OAA6B;GACxC,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,OAAO,aACT;GACF,UAAU;IACR,OAAO;GACT;EACF;EACA,MAAM,SAAS,OAA4B;GACzC,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,OAAO,aACT;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,SAAS,cACP,UACA,QACgB;CAChB,MAAM,UAAU,IAAI,IAAI,mBAAmB,MAAM,CAAC,CAAC,KAAK,UAAU,CAAC,MAAM,QAAQ,KAAK,CAAC,CAAC;CACxF,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
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import "node:path";
|
|
2
|
+
import "node:url";
|
|
3
|
+
import.meta.url;
|
|
4
|
+
import { TargetLaunchError, scrubConnection } from "@storyshelf/core/adapter/capture-runner";
|
|
5
|
+
import { isRetryableConnectError, retryConnect } from "@storyshelf/core/utils";
|
|
6
|
+
import puppeteer from "puppeteer-core";
|
|
7
|
+
//#region src/connect.ts
|
|
8
|
+
/** Connecting to a remote browser: retried when the service is unreachable or full, a clear error when it is not. */
|
|
9
|
+
/**
|
|
10
|
+
* Connect to `entry`'s service and return its browser. Connection-level failures are retried with
|
|
11
|
+
* backoff, so a provider outage or a full plan costs time and not a story crash; what is left is
|
|
12
|
+
* a {@link TargetLaunchError}, which fails the job at once (a refused credential also marks the
|
|
13
|
+
* target unavailable, a service that is merely down does not). No message carries the endpoint
|
|
14
|
+
* or its credentials.
|
|
15
|
+
*/
|
|
16
|
+
async function connectRemote(entry, logger) {
|
|
17
|
+
const { remote, target } = entry;
|
|
18
|
+
const connection = remote.connect(entry.engine);
|
|
19
|
+
try {
|
|
20
|
+
return await retryConnect(async () => await connectOnce(connection, remote.connectTimeoutMs), {
|
|
21
|
+
retries: remote.connectRetries,
|
|
22
|
+
backoffMs: remote.connectBackoffMs,
|
|
23
|
+
onRetry: (error, retry, delayMs) => {
|
|
24
|
+
logger?.warn({
|
|
25
|
+
target,
|
|
26
|
+
retry,
|
|
27
|
+
delayMs,
|
|
28
|
+
reason: reasonOf(error, connection)
|
|
29
|
+
}, "remote browser connect failed, retrying");
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
} catch (error) {
|
|
33
|
+
throw new TargetLaunchError(target, reasonOf(error, connection), {
|
|
34
|
+
cause: new Error(reasonOf(error, connection)),
|
|
35
|
+
transient: isRetryableConnectError(error)
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
async function connectOnce(connection, timeoutMs) {
|
|
40
|
+
const isHttp = connection.endpoint.startsWith("http");
|
|
41
|
+
return await puppeteer.connect({
|
|
42
|
+
...isHttp ? { browserURL: connection.endpoint } : { browserWSEndpoint: connection.endpoint },
|
|
43
|
+
protocolTimeout: timeoutMs,
|
|
44
|
+
...connection.headers === void 0 ? {} : { headers: connection.headers }
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
/** What failed, in one line, with the endpoint and credentials removed. */
|
|
48
|
+
function reasonOf(error, connection) {
|
|
49
|
+
const line = (error instanceof Error ? error.message : String(error)).split("\n").find((candidate) => candidate.trim() !== "")?.trim() ?? "the connection failed";
|
|
50
|
+
return scrubConnection(line, connection);
|
|
51
|
+
}
|
|
52
|
+
//#endregion
|
|
53
|
+
export { connectRemote };
|
|
54
|
+
|
|
55
|
+
//# 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 \"puppeteer-core\";\nimport puppeteer from \"puppeteer-core\";\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 is\n * a {@link TargetLaunchError}, which fails the job at once (a refused credential also marks the\n * target unavailable, a service that is merely down does not). No message carries the endpoint\n * 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 return await retryConnect(async () => await connectOnce(connection, remote.connectTimeoutMs), {\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: new Error(reasonOf(error, connection)),\n transient: isRetryableConnectError(error),\n });\n }\n}\n\nasync function connectOnce(connection: RemoteConnection, timeoutMs: number): Promise<Browser> {\n const isHttp = connection.endpoint.startsWith(\"http\");\n return await puppeteer.connect({\n ...(isHttp ? { browserURL: connection.endpoint } : { browserWSEndpoint: connection.endpoint }),\n protocolTimeout: timeoutMs,\n ...(connection.headers === undefined ? {} : { headers: connection.headers }),\n });\n}\n\n/** What failed, in one line, with the endpoint and credentials removed. */\nfunction reasonOf(error: unknown, connection: RemoteConnection): string {\n const text = error instanceof Error ? error.message : String(error);\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"],"mappings":";;;;;;;;;;;;;;;AAmBA,eAAsB,cAAc,OAAqB,QAAmC;CAC1F,MAAM,EAAE,QAAQ,WAAW;CAC3B,MAAM,aAAa,OAAO,QAAQ,MAAM,MAAM;CAC9C,IAAI;EACF,OAAO,MAAM,aAAa,YAAY,MAAM,YAAY,YAAY,OAAO,gBAAgB,GAAG;GAC5F,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,IAAI,MAAM,SAAS,OAAO,UAAU,CAAC;GAC5C,WAAW,wBAAwB,KAAK;EAC1C,CAAC;CACH;AACF;AAEA,eAAe,YAAY,YAA8B,WAAqC;CAC5F,MAAM,SAAS,WAAW,SAAS,WAAW,MAAM;CACpD,OAAO,MAAM,UAAU,QAAQ;EAC7B,GAAI,SAAS,EAAE,YAAY,WAAW,SAAS,IAAI,EAAE,mBAAmB,WAAW,SAAS;EAC5F,iBAAiB;EACjB,GAAI,WAAW,YAAY,KAAA,IAAY,CAAC,IAAI,EAAE,SAAS,WAAW,QAAQ;CAC5E,CAAC;AACH;;AAGA,SAAS,SAAS,OAAgB,YAAsC;CAEtE,MAAM,QADO,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK,EAAA,CAG7D,MAAM,IAAI,CAAC,CACX,MAAM,cAAc,UAAU,KAAK,MAAM,EAAE,CAAC,EAC3C,KAAK,KAAK;CAChB,OAAO,gBAAgB,MAAM,UAAU;AACzC"}
|
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 { createPuppeteerCaptureRunner } from "./capture-runner.mjs";
|
|
4
|
-
export { createPuppeteerCaptureRunner };
|
|
5
|
+
export { type BrowserEntry, type LocalBrowser, type LocalOptions, createPuppeteerCaptureRunner, local };
|
package/dist/index.mjs
CHANGED
package/dist/launch.mjs
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import "node:path";
|
|
2
|
+
import "node:url";
|
|
3
|
+
import.meta.url;
|
|
4
|
+
import { TargetLaunchError } from "@storyshelf/core/adapter/capture-runner";
|
|
5
|
+
import puppeteer from "puppeteer-core";
|
|
6
|
+
//#region src/launch.ts
|
|
7
|
+
/** Starting a local browser, and telling one that would not start from one that crashed. */
|
|
8
|
+
/**
|
|
9
|
+
* Sandbox flags for containers plus low-memory flags. Rendering-affecting flags
|
|
10
|
+
* (`--disable-gpu`, `--single-process`) are excluded: they can shift pixels.
|
|
11
|
+
*/
|
|
12
|
+
const DEFAULT_ARGS = [
|
|
13
|
+
"--no-sandbox",
|
|
14
|
+
"--disable-setuid-sandbox",
|
|
15
|
+
"--disable-dev-shm-usage",
|
|
16
|
+
"--disable-extensions",
|
|
17
|
+
"--disable-background-networking",
|
|
18
|
+
"--mute-audio"
|
|
19
|
+
];
|
|
20
|
+
/**
|
|
21
|
+
* Puppeteer closes its browser on SIGINT/SIGTERM/SIGHUP by default, which fails a render that a
|
|
22
|
+
* graceful drain is still finishing. The host owns shutdown: it drains, then closes every browser
|
|
23
|
+
* through `teardown`.
|
|
24
|
+
*/
|
|
25
|
+
const HOST_OWNS_SIGNALS = {
|
|
26
|
+
handleSIGINT: false,
|
|
27
|
+
handleSIGTERM: false,
|
|
28
|
+
handleSIGHUP: false
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Launch `entry`'s browser. A failure is a {@link TargetLaunchError}: the browser never started, so
|
|
32
|
+
* no story is at fault and the job fails at once instead of going through crash isolation.
|
|
33
|
+
*/
|
|
34
|
+
async function launchLocal(entry, args) {
|
|
35
|
+
try {
|
|
36
|
+
return await puppeteer.launch({
|
|
37
|
+
...HOST_OWNS_SIGNALS,
|
|
38
|
+
headless: true,
|
|
39
|
+
executablePath: entry.executablePath,
|
|
40
|
+
args: args ?? DEFAULT_ARGS
|
|
41
|
+
});
|
|
42
|
+
} catch (error) {
|
|
43
|
+
throw new TargetLaunchError(entry.target, firstLine(error), { cause: error });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** Launch errors carry a long log tail; the first line says what failed. */
|
|
47
|
+
function firstLine(error) {
|
|
48
|
+
return (error instanceof Error ? error.message : String(error)).split("\n").find((line) => line.trim() !== "")?.trim() ?? "the browser did not start";
|
|
49
|
+
}
|
|
50
|
+
//#endregion
|
|
51
|
+
export { launchLocal };
|
|
52
|
+
|
|
53
|
+
//# sourceMappingURL=launch.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"launch.mjs","names":[],"sources":["../src/launch.ts"],"sourcesContent":["/** Starting a local browser, and telling one that would not start from one that crashed. */\nimport { TargetLaunchError } from \"@storyshelf/core/adapter/capture-runner\";\nimport type { Browser } from \"puppeteer-core\";\nimport puppeteer from \"puppeteer-core\";\nimport type { LocalTarget } from \"./targets.ts\";\n\n/**\n * Sandbox flags for containers plus low-memory flags. Rendering-affecting flags\n * (`--disable-gpu`, `--single-process`) are excluded: they can shift pixels.\n */\nconst DEFAULT_ARGS = [\n \"--no-sandbox\",\n \"--disable-setuid-sandbox\",\n \"--disable-dev-shm-usage\",\n \"--disable-extensions\",\n \"--disable-background-networking\",\n \"--mute-audio\",\n];\n\n/**\n * Puppeteer closes its browser on SIGINT/SIGTERM/SIGHUP by default, which fails a render that a\n * graceful drain is still finishing. The host owns shutdown: it drains, then closes every browser\n * through `teardown`.\n */\nconst HOST_OWNS_SIGNALS = {\n handleSIGINT: false,\n handleSIGTERM: false,\n handleSIGHUP: false,\n} as const;\n\n/**\n * Launch `entry`'s browser. A failure is a {@link TargetLaunchError}: the browser never started, so\n * no story is at fault and the job fails at once instead of going through crash isolation.\n */\nexport async function launchLocal(\n entry: LocalTarget,\n args: string[] | undefined,\n): Promise<Browser> {\n try {\n return await puppeteer.launch({\n ...HOST_OWNS_SIGNALS,\n headless: true,\n executablePath: entry.executablePath,\n args: args ?? DEFAULT_ARGS,\n });\n } catch (error) {\n throw new TargetLaunchError(entry.target, firstLine(error), { cause: error });\n }\n}\n\n/** Launch errors carry a long log tail; the first line says what failed. */\nfunction firstLine(error: unknown): string {\n const text = error instanceof Error ? error.message : String(error);\n return (\n text\n .split(\"\\n\")\n .find((line) => line.trim() !== \"\")\n ?.trim() ?? \"the browser did not start\"\n );\n}\n"],"mappings":";;;;;;;;;;;AAUA,MAAM,eAAe;CACnB;CACA;CACA;CACA;CACA;CACA;AACF;;;;;;AAOA,MAAM,oBAAoB;CACxB,cAAc;CACd,eAAe;CACf,cAAc;AAChB;;;;;AAMA,eAAsB,YACpB,OACA,MACkB;CAClB,IAAI;EACF,OAAO,MAAM,UAAU,OAAO;GAC5B,GAAG;GACH,UAAU;GACV,gBAAgB,MAAM;GACtB,MAAM,QAAQ;EAChB,CAAC;CACH,SAAS,OAAO;EACd,MAAM,IAAI,kBAAkB,MAAM,QAAQ,UAAU,KAAK,GAAG,EAAE,OAAO,MAAM,CAAC;CAC9E;AACF;;AAGA,SAAS,UAAU,OAAwB;CAEzC,QADa,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK,EAAA,CAG7D,MAAM,IAAI,CAAC,CACX,MAAM,SAAS,KAAK,KAAK,MAAM,EAAE,CAAC,EACjC,KAAK,KAAK;AAElB"}
|
package/dist/pages.d.mts
ADDED