@storyshelf/runner-puppeteer 0.7.0 → 0.8.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 +3 -1
- package/dist/a11y.mjs +7 -5
- package/dist/a11y.mjs.map +1 -1
- package/dist/availability.mjs +1 -2
- package/dist/browser.mjs +24 -15
- package/dist/browser.mjs.map +1 -1
- package/dist/capture-runner.d.mts +0 -2
- package/dist/capture-runner.mjs +26 -11
- package/dist/capture-runner.mjs.map +1 -1
- package/dist/connect.mjs +21 -31
- package/dist/connect.mjs.map +1 -1
- package/dist/launch.mjs +3 -4
- package/dist/launch.mjs.map +1 -1
- package/dist/pages.d.mts +1 -0
- package/dist/pages.mjs +14 -13
- package/dist/pages.mjs.map +1 -1
- package/dist/pipeline.mjs +85 -43
- package/dist/pipeline.mjs.map +1 -1
- package/dist/play.mjs +1 -2
- package/dist/remote/cdp.d.mts +38 -13
- package/dist/remote/cdp.mjs +23 -9
- package/dist/remote/cdp.mjs.map +1 -1
- package/dist/remote/index.d.mts +3 -3
- package/dist/remote/index.mjs +2 -1
- package/dist/remote-environment.mjs +37 -21
- package/dist/remote-environment.mjs.map +1 -1
- package/dist/runtime-params.mjs +1 -2
- package/dist/screenshot.mjs +38 -6
- package/dist/screenshot.mjs.map +1 -1
- package/dist/session.d.mts +3 -0
- package/dist/session.mjs +141 -0
- package/dist/session.mjs.map +1 -0
- package/dist/settle.mjs +15 -8
- package/dist/settle.mjs.map +1 -1
- package/dist/static-server.mjs +2 -4
- package/dist/static-server.mjs.map +1 -1
- package/dist/targets.d.mts +0 -2
- package/dist/targets.mjs +1 -2
- package/dist/tests.mjs +23 -18
- package/dist/tests.mjs.map +1 -1
- package/dist/types.d.mts +1 -1
- package/package.json +8 -8
package/README.md
CHANGED
|
@@ -75,11 +75,13 @@ createPuppeteerCaptureRunner({
|
|
|
75
75
|
});
|
|
76
76
|
```
|
|
77
77
|
|
|
78
|
+
A service that must be woken or started first passes `connect` as a function (with `release`, `retry` and `timeouts`) instead of `endpoint`: it receives the run's `buildId`, `runId`, `shardIndex`, `purpose` and a cancel `signal` and returns `{ endpoint, headers, lease }`, and `release(lease, { outcome, error })` runs once when the render ends, fails or is cancelled. `RemoteConnectError` and `refCounted` come from the same `./remote` subpath; see "Scale to zero" in [Remote browsers](https://storyshelf.js.org/guides/remote-browsers/).
|
|
79
|
+
|
|
78
80
|
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
81
|
|
|
80
82
|
## Concurrency and memory
|
|
81
83
|
|
|
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`.
|
|
84
|
+
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`. Every 150 captures the local browser is closed and relaunched to bound its slow memory growth (`recycleBrowserEvery`, or `STORYSHELF_BROWSER_RECYCLE_EVERY`; `0` disables). A connected remote target is instead disconnected and reconnected, never sent `Browser.close`, so sibling clients and the provider machine survive; a run closes only the pages and contexts it created and releases any provider lease once with the first outcome. 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
85
|
|
|
84
86
|
See the [StoryShelf documentation](https://github.com/GuptaSiddhant/StoryShelf#readme) for configuration details.
|
|
85
87
|
|
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
|
-
|
|
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
|
|
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?.
|
|
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 \"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
|
|
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 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"}
|
package/dist/availability.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import "node:path";
|
|
|
2
2
|
import "node:url";
|
|
3
3
|
import.meta.url;
|
|
4
4
|
import { existsSync } from "node:fs";
|
|
5
|
-
|
|
5
|
+
// src/availability.ts
|
|
6
6
|
/**
|
|
7
7
|
* Each local target as a {@link RunnerTarget}. `puppeteer-core` ships no browser, so a target is
|
|
8
8
|
* `ready` only when it has an executable that exists: its own, the runner's, or the one named by
|
|
@@ -25,7 +25,6 @@ function envPath(entry, env) {
|
|
|
25
25
|
const path = env["PUPPETEER_EXECUTABLE_PATH"];
|
|
26
26
|
return entry.target === "chromium" && path ? path : void 0;
|
|
27
27
|
}
|
|
28
|
-
//#endregion
|
|
29
28
|
export { verifyLocalTargets };
|
|
30
29
|
|
|
31
30
|
//# sourceMappingURL=availability.mjs.map
|
package/dist/browser.mjs
CHANGED
|
@@ -2,23 +2,26 @@ import "node:path";
|
|
|
2
2
|
import "node:url";
|
|
3
3
|
import.meta.url;
|
|
4
4
|
import { RenderRuns } from "@storyshelf/core/adapter/capture-runner";
|
|
5
|
-
|
|
6
|
-
/**
|
|
5
|
+
// src/browser.ts
|
|
6
|
+
/** Active-run tracking and best-effort cleanup helpers for Puppeteer. */
|
|
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
|
-
/**
|
|
10
|
-
|
|
11
|
-
if (
|
|
12
|
-
|
|
9
|
+
/** The outcome a render's `finally` reports: a cancel wins over a failure, a failure over a finish. */
|
|
10
|
+
function outcomeOf(active, failed) {
|
|
11
|
+
if (active.cancelled) return "cancelled";
|
|
12
|
+
return failed ? "failed" : "finished";
|
|
13
13
|
}
|
|
14
|
-
|
|
14
|
+
/** Run `work`, then `finish` with whether it threw, so a browser is released with how its run went. */
|
|
15
|
+
async function finishing(work, finish) {
|
|
16
|
+
let failure;
|
|
15
17
|
try {
|
|
16
|
-
await
|
|
17
|
-
} catch {
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
return await work();
|
|
19
|
+
} catch (error) {
|
|
20
|
+
failure = { error };
|
|
21
|
+
throw error;
|
|
22
|
+
} finally {
|
|
23
|
+
await finish(failure !== void 0, failure?.error);
|
|
24
|
+
}
|
|
22
25
|
}
|
|
23
26
|
/** Best-effort close for the static server. */
|
|
24
27
|
async function safeCloseServer(server) {
|
|
@@ -26,11 +29,17 @@ async function safeCloseServer(server) {
|
|
|
26
29
|
await server.close();
|
|
27
30
|
} catch {}
|
|
28
31
|
}
|
|
32
|
+
/** Release and throw when the run was cancelled while acquiring the browser. */
|
|
33
|
+
async function abortIfCancelled(active, session) {
|
|
34
|
+
if (!active.cancelled) return;
|
|
35
|
+
await session.release("cancelled");
|
|
36
|
+
if (active.session === session) active.session = null;
|
|
37
|
+
throw new Error("Capture cancelled");
|
|
38
|
+
}
|
|
29
39
|
/** Normalize any thrown value to a string. */
|
|
30
40
|
function messageOf(error) {
|
|
31
41
|
return error instanceof Error ? error.message : String(error);
|
|
32
42
|
}
|
|
33
|
-
|
|
34
|
-
export { activeRuns, closeBrowser, messageOf, safeCloseBrowser, safeCloseServer };
|
|
43
|
+
export { abortIfCancelled, activeRuns, finishing, messageOf, outcomeOf, safeCloseServer };
|
|
35
44
|
|
|
36
45
|
//# sourceMappingURL=browser.mjs.map
|
package/dist/browser.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"browser.mjs","names":[],"sources":["../src/browser.ts"],"sourcesContent":["/**
|
|
1
|
+
{"version":3,"file":"browser.mjs","names":[],"sources":["../src/browser.ts"],"sourcesContent":["/** Active-run tracking and best-effort cleanup helpers for Puppeteer. */\n\nimport { RenderRuns, type RemoteLeaseOutcome } from \"@storyshelf/core/adapter/capture-runner\";\nimport type { ActiveRun } from \"./types.ts\";\nimport type { BrowserSession } from \"./session.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 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 released 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 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/** Release and throw when the run was cancelled while acquiring the browser. */\nexport async function abortIfCancelled(\n active: { cancelled: boolean; session: BrowserSession | null },\n session: BrowserSession,\n): Promise<void> {\n if (!active.cancelled) return;\n await session.release(\"cancelled\");\n if (active.session === session) active.session = null;\n throw new Error(\"Capture cancelled\");\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,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,gBAAgB,QAAmD;CACvF,IAAI;EACF,MAAM,OAAO,MAAM;CACrB,QAAQ,CAER;AACF;;AAGA,eAAsB,iBACpB,QACA,SACe;CACf,IAAI,CAAC,OAAO,WAAW;CACvB,MAAM,QAAQ,QAAQ,WAAW;CACjC,IAAI,OAAO,YAAY,SAAS,OAAO,UAAU;CACjD,MAAM,IAAI,MAAM,mBAAmB;AACrC;;AAGA,SAAgB,UAAU,OAAwB;CAChD,OAAO,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;AAC9D"}
|
|
@@ -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 Puppeteer (Chromium via chrome-headless-shell). */
|
|
9
8
|
declare function createPuppeteerCaptureRunner(options?: {
|
|
10
9
|
/**
|
|
@@ -28,6 +27,5 @@ declare function createPuppeteerCaptureRunner(options?: {
|
|
|
28
27
|
/** Reuse pages between captures (opt-in); falls back to `STORYSHELF_REUSE_PAGES=1`. */
|
|
29
28
|
reusePages?: boolean;
|
|
30
29
|
}): CaptureRunner;
|
|
31
|
-
//#endregion
|
|
32
30
|
export { createPuppeteerCaptureRunner };
|
|
33
31
|
//# sourceMappingURL=capture-runner.d.mts.map
|
package/dist/capture-runner.mjs
CHANGED
|
@@ -2,25 +2,28 @@ import "node:path";
|
|
|
2
2
|
import "node:url";
|
|
3
3
|
import.meta.url;
|
|
4
4
|
import { verifyLocalTargets } from "./availability.mjs";
|
|
5
|
-
import { activeRuns
|
|
5
|
+
import { activeRuns } from "./browser.mjs";
|
|
6
6
|
import { connectRemote } from "./connect.mjs";
|
|
7
7
|
import { launchLocal } from "./launch.mjs";
|
|
8
8
|
import "./runtime-params.mjs";
|
|
9
9
|
import { 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
|
-
|
|
14
|
+
// src/capture-runner.ts
|
|
15
15
|
/** Puppeteer capture runner — composition root (factory wiring). */
|
|
16
16
|
/** Create a CaptureRunner that renders Storybook stories with Puppeteer (Chromium via chrome-headless-shell). */
|
|
17
17
|
function createPuppeteerCaptureRunner(options = {}) {
|
|
18
18
|
const { declared, locals, remotes } = flattenBrowsers(options.browsers ?? ["chromium"], options.executablePath);
|
|
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) :
|
|
23
|
+
return remote ? await connectRemote(remote, context, logger) : {
|
|
24
|
+
browser: await launchLocal(locals.find((entry) => entry.target === target), options.args),
|
|
25
|
+
ownership: "launched"
|
|
26
|
+
};
|
|
24
27
|
}),
|
|
25
28
|
remote: (target) => remotes.find((entry) => entry.target === target)
|
|
26
29
|
};
|
|
@@ -31,7 +34,7 @@ function createPuppeteerCaptureRunner(options = {}) {
|
|
|
31
34
|
return {
|
|
32
35
|
metadata: {
|
|
33
36
|
name: "Puppeteer",
|
|
34
|
-
version: "0.
|
|
37
|
+
version: "0.8.0",
|
|
35
38
|
description: "Puppeteer capture runner (chrome-headless-shell)",
|
|
36
39
|
kind: "puppeteer",
|
|
37
40
|
category: "capture-runner",
|
|
@@ -39,11 +42,12 @@ function createPuppeteerCaptureRunner(options = {}) {
|
|
|
39
42
|
return offers.current();
|
|
40
43
|
}
|
|
41
44
|
},
|
|
42
|
-
lifecycle: offers.lifecycle(async () => await Promise.resolve(verifyTargets(declared, locals))),
|
|
45
|
+
lifecycle: withShutdown(offers.lifecycle(async () => await Promise.resolve(verifyTargets(declared, locals)))),
|
|
43
46
|
async render(input) {
|
|
44
47
|
const active = {
|
|
45
48
|
cancelled: false,
|
|
46
|
-
|
|
49
|
+
session: null,
|
|
50
|
+
abort: new AbortController()
|
|
47
51
|
};
|
|
48
52
|
const remove = activeRuns.add(input.buildId, input.runId, active);
|
|
49
53
|
try {
|
|
@@ -61,7 +65,8 @@ function createPuppeteerCaptureRunner(options = {}) {
|
|
|
61
65
|
async runTests(input) {
|
|
62
66
|
const active = {
|
|
63
67
|
cancelled: false,
|
|
64
|
-
|
|
68
|
+
session: null,
|
|
69
|
+
abort: new AbortController()
|
|
65
70
|
};
|
|
66
71
|
const remove = activeRuns.add(input.buildId, input.runId, active);
|
|
67
72
|
try {
|
|
@@ -77,7 +82,8 @@ function createPuppeteerCaptureRunner(options = {}) {
|
|
|
77
82
|
async cancel(buildId, runId) {
|
|
78
83
|
await Promise.all(activeRuns.matching(buildId, runId).map(async (active) => {
|
|
79
84
|
active.cancelled = true;
|
|
80
|
-
|
|
85
|
+
active.abort?.abort();
|
|
86
|
+
await active.session?.release("cancelled");
|
|
81
87
|
}));
|
|
82
88
|
}
|
|
83
89
|
};
|
|
@@ -87,6 +93,16 @@ function verifyTargets(declared, locals) {
|
|
|
87
93
|
const checked = new Map(verifyLocalTargets(locals).map((entry) => [entry.target, entry]));
|
|
88
94
|
return declared.map((entry) => checked.get(entry.target) ?? entry);
|
|
89
95
|
}
|
|
96
|
+
/** Teardown also releases every lease still held and closes every pool, so a stopped process leaves no server running. */
|
|
97
|
+
function withShutdown(lifecycle) {
|
|
98
|
+
return {
|
|
99
|
+
...lifecycle,
|
|
100
|
+
teardown: async () => {
|
|
101
|
+
await shutdownRemote();
|
|
102
|
+
await lifecycle.teardown();
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
}
|
|
90
106
|
function envPositiveInt(name) {
|
|
91
107
|
return envInt(name, 1);
|
|
92
108
|
}
|
|
@@ -94,7 +110,6 @@ function envInt(name, min) {
|
|
|
94
110
|
const parsed = Number.parseInt(process.env[name] ?? "", 10);
|
|
95
111
|
return parsed >= min ? parsed : void 0;
|
|
96
112
|
}
|
|
97
|
-
//#endregion
|
|
98
113
|
export { createPuppeteerCaptureRunner };
|
|
99
114
|
|
|
100
115
|
//# sourceMappingURL=capture-runner.mjs.map
|
|
@@ -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 {\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
|
|
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 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 } 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, context) => {\n const remote = remotes.find((entry) => entry.target === target);\n return remote\n ? await connectRemote(remote, context, logger)\n : {\n browser: await launchLocal(\n locals.find((entry) => entry.target === target) as LocalTarget,\n options.args,\n ),\n ownership: \"launched\",\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: withShutdown(\n offers.lifecycle(async () => await Promise.resolve(verifyTargets(declared, locals))),\n ),\n async render(input: PuppeteerRenderInput) {\n const active: ActiveRun = { cancelled: false, session: null, abort: new AbortController() };\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, session: null, abort: new AbortController() };\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 active.abort?.abort();\n await active.session?.release(\"cancelled\");\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\n/** Teardown also releases every lease still held and closes every pool, so a stopped process leaves no server running. */\nfunction withShutdown(lifecycle: AdapterLifecycle): AdapterLifecycle {\n return {\n ...lifecycle,\n teardown: async () => {\n await shutdownRemote();\n await lifecycle.teardown();\n },\n };\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":";;;;;;;;;;;;;;;;AA2BA,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,QAAQ,YAAY;GACtD,MAAM,SAAS,QAAQ,MAAM,UAAU,MAAM,WAAW,MAAM;GAC9D,OAAO,SACH,MAAM,cAAc,QAAQ,SAAS,MAAM,IAC3C;IACE,SAAS,MAAM,YACb,OAAO,MAAM,UAAU,MAAM,WAAW,MAAM,GAC9C,QAAQ,IACV;IACA,WAAW;GACb;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,aACT,OAAO,UAAU,YAAY,MAAM,QAAQ,QAAQ,cAAc,UAAU,MAAM,CAAC,CAAC,CACrF;EACA,MAAM,OAAO,OAA6B;GACxC,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;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;IAAM,OAAO,IAAI,gBAAgB;GAAE;GAC1F,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,OAAO,OAAO,MAAM;IACpB,MAAM,OAAO,SAAS,QAAQ,WAAW;GAC3C,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;;AAGA,SAAS,aAAa,WAA+C;CACnE,OAAO;EACL,GAAG;EACH,UAAU,YAAY;GACpB,MAAM,eAAe;GACrB,MAAM,UAAU,SAAS;EAC3B;CACF;AACF;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,40 +1,31 @@
|
|
|
1
1
|
import "node:path";
|
|
2
2
|
import "node:url";
|
|
3
3
|
import.meta.url;
|
|
4
|
-
import {
|
|
5
|
-
import { isRetryableConnectError, retryConnect } from "@storyshelf/core/utils";
|
|
4
|
+
import { connectLeased, scrubConnection } from "@storyshelf/core/adapter/capture-runner";
|
|
6
5
|
import puppeteer from "puppeteer-core";
|
|
7
|
-
|
|
6
|
+
// src/connect.ts
|
|
8
7
|
/** Connecting to a remote browser: retried when the service is unreachable or full, a clear error when it is not. */
|
|
9
8
|
/**
|
|
10
|
-
* Connect to `entry`'s service and return
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
9
|
+
* Connect to `entry`'s service and return the browser with its ownership: the run must disconnect
|
|
10
|
+
* it, never close it, and releases the service's lease (a function `connect`'s `lease`) exactly
|
|
11
|
+
* once when the session ends. Connection-level failures are retried with backoff, so a provider
|
|
12
|
+
* outage or a full plan costs time and not a story crash; what is left is a `TargetLaunchError`,
|
|
13
|
+
* which fails the job at once (a refused credential also marks the target unavailable, a service
|
|
14
|
+
* that is merely down does not). No message carries the endpoint, its credentials or the lease.
|
|
15
15
|
*/
|
|
16
|
-
async function connectRemote(entry, logger) {
|
|
17
|
-
const {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
}
|
|
16
|
+
async function connectRemote(entry, context, logger) {
|
|
17
|
+
const { value, lease } = await connectLeased(entry, context, {
|
|
18
|
+
logger,
|
|
19
|
+
open: async (connection) => await connectOnce(connection, entry.remote.timeouts.connectMs),
|
|
20
|
+
reasonOf
|
|
21
|
+
});
|
|
22
|
+
return {
|
|
23
|
+
browser: value,
|
|
24
|
+
ownership: "connected",
|
|
25
|
+
releaseLease: async (outcome, error) => {
|
|
26
|
+
await lease.end(outcome, error);
|
|
27
|
+
}
|
|
28
|
+
};
|
|
38
29
|
}
|
|
39
30
|
async function connectOnce(connection, timeoutMs) {
|
|
40
31
|
const isHttp = connection.endpoint.startsWith("http");
|
|
@@ -49,7 +40,6 @@ function reasonOf(error, connection) {
|
|
|
49
40
|
const line = (error instanceof Error ? error.message : String(error)).split("\n").find((candidate) => candidate.trim() !== "")?.trim() ?? "the connection failed";
|
|
50
41
|
return scrubConnection(line, connection);
|
|
51
42
|
}
|
|
52
|
-
//#endregion
|
|
53
43
|
export { connectRemote };
|
|
54
44
|
|
|
55
45
|
//# sourceMappingURL=connect.mjs.map
|
package/dist/connect.mjs.map
CHANGED
|
@@ -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
|
|
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 \"puppeteer-core\";\nimport puppeteer from \"puppeteer-core\";\nimport type { BrowserAcquisition } from \"./session.ts\";\n\n/**\n * Connect to `entry`'s service and return the browser with its ownership: the run must disconnect\n * it, never close it, and releases the service's lease (a function `connect`'s `lease`) exactly\n * once when the session ends. Connection-level failures are retried with backoff, so a provider\n * outage or a full plan costs time and not a story crash; what is left is a `TargetLaunchError`,\n * which fails the job at once (a refused credential also marks the target unavailable, a service\n * that is merely down does not). No 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<BrowserAcquisition> {\n const { value, lease } = await connectLeased(entry, context, {\n logger,\n open: async (connection) => await connectOnce(connection, entry.remote.timeouts.connectMs),\n reasonOf,\n });\n return {\n browser: value,\n ownership: \"connected\",\n releaseLease: async (outcome, error) => {\n await lease.end(outcome, 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 & { lease?: unknown }): 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":";;;;;;;;;;;;;;;AAqBA,eAAsB,cACpB,OACA,SACA,QAC6B;CAC7B,MAAM,EAAE,OAAO,UAAU,MAAM,cAAc,OAAO,SAAS;EAC3D;EACA,MAAM,OAAO,eAAe,MAAM,YAAY,YAAY,MAAM,OAAO,SAAS,SAAS;EACzF;CACF,CAAC;CACD,OAAO;EACL,SAAS;EACT,WAAW;EACX,cAAc,OAAO,SAAS,UAAU;GACtC,MAAM,MAAM,IAAI,SAAS,KAAK;EAChC;CACF;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,YAA4D;CAE5F,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/launch.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import "node:url";
|
|
|
3
3
|
import.meta.url;
|
|
4
4
|
import { TargetLaunchError } from "@storyshelf/core/adapter/capture-runner";
|
|
5
5
|
import puppeteer from "puppeteer-core";
|
|
6
|
-
|
|
6
|
+
// src/launch.ts
|
|
7
7
|
/** Starting a local browser, and telling one that would not start from one that crashed. */
|
|
8
8
|
/**
|
|
9
9
|
* Sandbox flags for containers plus low-memory flags. Rendering-affecting flags
|
|
@@ -19,8 +19,8 @@ const DEFAULT_ARGS = [
|
|
|
19
19
|
];
|
|
20
20
|
/**
|
|
21
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,
|
|
23
|
-
*
|
|
22
|
+
* graceful drain is still finishing. The host owns shutdown: it drains, each render releases the
|
|
23
|
+
* browser it launched (or disconnects the one it connected to), then `teardown` releases leases.
|
|
24
24
|
*/
|
|
25
25
|
const HOST_OWNS_SIGNALS = {
|
|
26
26
|
handleSIGINT: false,
|
|
@@ -47,7 +47,6 @@ async function launchLocal(entry, args) {
|
|
|
47
47
|
function firstLine(error) {
|
|
48
48
|
return (error instanceof Error ? error.message : String(error)).split("\n").find((line) => line.trim() !== "")?.trim() ?? "the browser did not start";
|
|
49
49
|
}
|
|
50
|
-
//#endregion
|
|
51
50
|
export { launchLocal };
|
|
52
51
|
|
|
53
52
|
//# sourceMappingURL=launch.mjs.map
|
package/dist/launch.mjs.map
CHANGED
|
@@ -1 +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,
|
|
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, each render releases the\n * browser it launched (or disconnects the one it connected to), then `teardown` releases leases.\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
CHANGED
package/dist/pages.mjs
CHANGED
|
@@ -2,11 +2,11 @@ import "node:path";
|
|
|
2
2
|
import "node:url";
|
|
3
3
|
import.meta.url;
|
|
4
4
|
import { installSettleRecorder } from "./settle.mjs";
|
|
5
|
-
|
|
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);
|
|
9
|
-
const page = await ctx.
|
|
9
|
+
const page = await ctx.session.newPage();
|
|
10
10
|
await page.setViewport({
|
|
11
11
|
width: viewport.width,
|
|
12
12
|
height: viewport.height
|
|
@@ -15,7 +15,8 @@ async function acquirePage(ctx, viewport) {
|
|
|
15
15
|
return {
|
|
16
16
|
page,
|
|
17
17
|
release: async () => {
|
|
18
|
-
await page.close();
|
|
18
|
+
await page.close().catch(() => {});
|
|
19
|
+
ctx.session.forgetPage(page);
|
|
19
20
|
}
|
|
20
21
|
};
|
|
21
22
|
}
|
|
@@ -27,9 +28,9 @@ async function acquirePage(ctx, viewport) {
|
|
|
27
28
|
*/
|
|
28
29
|
var PagePool = class {
|
|
29
30
|
free = [];
|
|
30
|
-
|
|
31
|
-
constructor(
|
|
32
|
-
this.
|
|
31
|
+
session;
|
|
32
|
+
constructor(session) {
|
|
33
|
+
this.session = session;
|
|
33
34
|
}
|
|
34
35
|
async acquire(viewport) {
|
|
35
36
|
const entry = this.free.pop() ?? await this.open();
|
|
@@ -41,18 +42,18 @@ var PagePool = class {
|
|
|
41
42
|
page: entry.page,
|
|
42
43
|
release: async (reusable) => {
|
|
43
44
|
if (reusable && await reset(entry)) this.free.push(entry);
|
|
44
|
-
else await
|
|
45
|
+
else await this.close(entry);
|
|
45
46
|
}
|
|
46
47
|
};
|
|
47
48
|
}
|
|
48
49
|
/** Close every idle page; call before the browser is replaced or closed. */
|
|
49
50
|
async dispose() {
|
|
50
51
|
await Promise.all(this.free.splice(0).map(async (entry) => {
|
|
51
|
-
await
|
|
52
|
+
await this.close(entry);
|
|
52
53
|
}));
|
|
53
54
|
}
|
|
54
55
|
async open() {
|
|
55
|
-
const context = await this.
|
|
56
|
+
const context = await this.session.createBrowserContext();
|
|
56
57
|
const page = await context.newPage();
|
|
57
58
|
await installSettleRecorder(page);
|
|
58
59
|
return {
|
|
@@ -60,6 +61,10 @@ var PagePool = class {
|
|
|
60
61
|
page
|
|
61
62
|
};
|
|
62
63
|
}
|
|
64
|
+
async close(entry) {
|
|
65
|
+
await entry.context.close().catch(() => {});
|
|
66
|
+
this.session.forgetContext(entry.context);
|
|
67
|
+
}
|
|
63
68
|
};
|
|
64
69
|
/** Wipe everything a story could leave behind; false when the page can't be trusted again. */
|
|
65
70
|
async function reset(entry) {
|
|
@@ -72,9 +77,6 @@ async function reset(entry) {
|
|
|
72
77
|
return false;
|
|
73
78
|
}
|
|
74
79
|
}
|
|
75
|
-
async function closeEntry(entry) {
|
|
76
|
-
await entry.context.close().catch(() => {});
|
|
77
|
-
}
|
|
78
80
|
/** Runs in the page: clears web storage and IndexedDB for the current origin. */
|
|
79
81
|
async function clearBrowserState() {
|
|
80
82
|
const scope = globalThis;
|
|
@@ -83,7 +85,6 @@ async function clearBrowserState() {
|
|
|
83
85
|
const databases = await scope.indexedDB?.databases?.() ?? [];
|
|
84
86
|
for (const database of databases) if (database.name) scope.indexedDB?.deleteDatabase(database.name);
|
|
85
87
|
}
|
|
86
|
-
//#endregion
|
|
87
88
|
export { PagePool, acquirePage };
|
|
88
89
|
|
|
89
90
|
//# sourceMappingURL=pages.mjs.map
|
package/dist/pages.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"pages.mjs","names":[],"sources":["../src/pages.ts"],"sourcesContent":["/** Page leasing for captures: a fresh page each time, or a small reusable pool (opt-in). */\n\nimport type { Viewport } from \"@storyshelf/core/adapter/capture-runner\";\nimport type {
|
|
1
|
+
{"version":3,"file":"pages.mjs","names":[],"sources":["../src/pages.ts"],"sourcesContent":["/** Page leasing for captures: a fresh page each time, or a small reusable pool (opt-in). */\n\nimport type { Viewport } from \"@storyshelf/core/adapter/capture-runner\";\nimport type { BrowserContext, Page } from \"puppeteer-core\";\nimport type { BrowserSession } from \"./session.ts\";\nimport { installSettleRecorder } from \"./settle.ts\";\n\n/** A page on loan for one capture; `release(false)` discards it instead of reusing it. */\nexport interface PageLease {\n page: Page;\n release(reusable: boolean): Promise<void>;\n}\n\n/** Lease a page for `viewport`: pooled when the context has a pool, otherwise a throwaway. */\nexport async function acquirePage(\n ctx: { session: BrowserSession; pagePool?: PagePool },\n viewport: Viewport,\n): Promise<PageLease> {\n if (ctx.pagePool) return await ctx.pagePool.acquire(viewport);\n const page = await ctx.session.newPage();\n await page.setViewport({ width: viewport.width, height: viewport.height });\n await installSettleRecorder(page);\n return {\n page,\n release: async () => {\n await page.close().catch(() => {});\n ctx.session.forgetPage(page);\n },\n };\n}\n\ninterface Entry {\n context: BrowserContext;\n page: Page;\n}\n\n/**\n * Reuses pages (each in its own browser context) across captures to skip\n * per-capture startup and keep Storybook's JS in the HTTP cache. A page is\n * reset between captures (storage and cookies cleared, blank document) and\n * discarded, never reused, after a failed capture.\n */\nexport class PagePool {\n private readonly free: Entry[] = [];\n\n private readonly session: BrowserSession;\n\n constructor(session: BrowserSession) {\n this.session = session;\n }\n\n async acquire(viewport: Viewport): Promise<PageLease> {\n const entry = this.free.pop() ?? (await this.open());\n await entry.page.setViewport({ width: viewport.width, height: viewport.height });\n return {\n page: entry.page,\n release: async (reusable) => {\n if (reusable && (await reset(entry))) this.free.push(entry);\n else await this.close(entry);\n },\n };\n }\n\n /** Close every idle page; call before the browser is replaced or closed. */\n async dispose(): Promise<void> {\n await Promise.all(\n this.free.splice(0).map(async (entry) => {\n await this.close(entry);\n }),\n );\n }\n\n private async open(): Promise<Entry> {\n const context = await this.session.createBrowserContext();\n const page = await context.newPage();\n await installSettleRecorder(page);\n return { context, page };\n }\n\n private async close(entry: Entry): Promise<void> {\n await entry.context.close().catch(() => {});\n this.session.forgetContext(entry.context);\n }\n}\n\n/** Wipe everything a story could leave behind; false when the page can't be trusted again. */\nasync function reset(entry: Entry): Promise<boolean> {\n try {\n await entry.page.evaluate(clearBrowserState);\n await entry.context.deleteCookie(...(await entry.context.cookies()));\n await entry.page.goto(\"about:blank\");\n return true;\n } catch {\n return false;\n }\n}\n\n/** Runs in the page: clears web storage and IndexedDB for the current origin. */\nasync function clearBrowserState(): Promise<void> {\n const scope = globalThis as unknown as {\n localStorage: { clear(): void };\n sessionStorage: { clear(): void };\n indexedDB?: {\n databases?: () => Promise<{ name?: string }[]>;\n deleteDatabase(name: string): unknown;\n };\n };\n scope.localStorage.clear();\n scope.sessionStorage.clear();\n const databases = (await scope.indexedDB?.databases?.()) ?? [];\n for (const database of databases) {\n if (database.name) scope.indexedDB?.deleteDatabase(database.name);\n }\n}\n"],"mappings":";;;;;;AAcA,eAAsB,YACpB,KACA,UACoB;CACpB,IAAI,IAAI,UAAU,OAAO,MAAM,IAAI,SAAS,QAAQ,QAAQ;CAC5D,MAAM,OAAO,MAAM,IAAI,QAAQ,QAAQ;CACvC,MAAM,KAAK,YAAY;EAAE,OAAO,SAAS;EAAO,QAAQ,SAAS;CAAO,CAAC;CACzE,MAAM,sBAAsB,IAAI;CAChC,OAAO;EACL;EACA,SAAS,YAAY;GACnB,MAAM,KAAK,MAAM,CAAC,CAAC,YAAY,CAAC,CAAC;GACjC,IAAI,QAAQ,WAAW,IAAI;EAC7B;CACF;AACF;;;;;;;AAaA,IAAa,WAAb,MAAsB;CACpB,OAAiC,CAAC;CAElC;CAEA,YAAY,SAAyB;EACnC,KAAK,UAAU;CACjB;CAEA,MAAM,QAAQ,UAAwC;EACpD,MAAM,QAAQ,KAAK,KAAK,IAAI,KAAM,MAAM,KAAK,KAAK;EAClD,MAAM,MAAM,KAAK,YAAY;GAAE,OAAO,SAAS;GAAO,QAAQ,SAAS;EAAO,CAAC;EAC/E,OAAO;GACL,MAAM,MAAM;GACZ,SAAS,OAAO,aAAa;IAC3B,IAAI,YAAa,MAAM,MAAM,KAAK,GAAI,KAAK,KAAK,KAAK,KAAK;SACrD,MAAM,KAAK,MAAM,KAAK;GAC7B;EACF;CACF;;CAGA,MAAM,UAAyB;EAC7B,MAAM,QAAQ,IACZ,KAAK,KAAK,OAAO,CAAC,CAAC,CAAC,IAAI,OAAO,UAAU;GACvC,MAAM,KAAK,MAAM,KAAK;EACxB,CAAC,CACH;CACF;CAEA,MAAc,OAAuB;EACnC,MAAM,UAAU,MAAM,KAAK,QAAQ,qBAAqB;EACxD,MAAM,OAAO,MAAM,QAAQ,QAAQ;EACnC,MAAM,sBAAsB,IAAI;EAChC,OAAO;GAAE;GAAS;EAAK;CACzB;CAEA,MAAc,MAAM,OAA6B;EAC/C,MAAM,MAAM,QAAQ,MAAM,CAAC,CAAC,YAAY,CAAC,CAAC;EAC1C,KAAK,QAAQ,cAAc,MAAM,OAAO;CAC1C;AACF;;AAGA,eAAe,MAAM,OAAgC;CACnD,IAAI;EACF,MAAM,MAAM,KAAK,SAAS,iBAAiB;EAC3C,MAAM,MAAM,QAAQ,aAAa,GAAI,MAAM,MAAM,QAAQ,QAAQ,CAAE;EACnE,MAAM,MAAM,KAAK,KAAK,aAAa;EACnC,OAAO;CACT,QAAQ;EACN,OAAO;CACT;AACF;;AAGA,eAAe,oBAAmC;CAChD,MAAM,QAAQ;CAQd,MAAM,aAAa,MAAM;CACzB,MAAM,eAAe,MAAM;CAC3B,MAAM,YAAa,MAAM,MAAM,WAAW,YAAY,KAAM,CAAC;CAC7D,KAAK,MAAM,YAAY,WACrB,IAAI,SAAS,MAAM,MAAM,WAAW,eAAe,SAAS,IAAI;AAEpE"}
|