@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/dist/pipeline.mjs CHANGED
@@ -1,40 +1,73 @@
1
1
  import "node:path";
2
2
  import "node:url";
3
3
  import.meta.url;
4
- import { messageOf, safeCloseBrowser, safeCloseServer } from "./browser.mjs";
4
+ import { abortIfCancelled, finishing, messageOf, outcomeOf, safeCloseServer } from "./browser.mjs";
5
5
  import { PagePool } from "./pages.mjs";
6
6
  import { measureRemote } from "./remote-environment.mjs";
7
7
  import { createRuntimeParamsState } from "./runtime-params.mjs";
8
8
  import { captureScreenshot } from "./screenshot.mjs";
9
+ import { createBrowserSession } from "./session.mjs";
9
10
  import { createStaticServer } from "./static-server.mjs";
10
11
  import { TargetLaunchError, engineOf } from "@storyshelf/core/adapter/capture-runner";
11
12
  import { DEFAULT_RECYCLE_BROWSER_EVERY, chunk, createPageLimiter, describeSizing, detectCaptureEnvironment, isTimeoutError, sessionGateFor, storybookBase } from "@storyshelf/core/utils";
12
13
  import { StorybookAdapter, resolveStoryViewports } from "@storyshelf/core/capture";
13
- //#region src/pipeline.ts
14
+ // src/pipeline.ts
14
15
  /** Render orchestration: static server, browser, and per-story captures. */
15
16
  /** Render all story/viewport combinations for a build. */
16
17
  async function renderAll(rawInput, active, driver, defaultTarget) {
17
18
  const { input, name, remote } = planRun(rawInput, driver, defaultTarget);
18
19
  assertReachable(name, remote, input.storybookUrl);
19
- const browser = await driver.launch(name, input.logger);
20
- const server = await serve(input, browser);
21
- input.logger?.info(describeSizing({
22
- ...input,
23
- browser: engineOf(name)
24
- }), "capture sizing");
25
- const environment = remote ? await measureRemote(browser, remote.remote, input.logger) : await localEnvironment(browser);
26
- const result = await runWithBrowser(input, active, browser, server, async () => await driver.launch(name, input.logger));
27
- for (const row of [...result.captures, ...result.failures]) row.browser = name;
20
+ const relaunch = async () => createBrowserSession(await driver.launch(name, input.logger, launchContextOf(input, "render", active.abort?.signal)));
21
+ const session = await relaunch();
22
+ active.session = session;
23
+ await abortIfCancelled(active, session);
24
+ let server;
25
+ let ctx;
26
+ return await finishing(async () => {
27
+ server = await serve(input, session);
28
+ input.logger?.info(describeSizing({
29
+ ...input,
30
+ browser: engineOf(name)
31
+ }), "capture sizing");
32
+ const environment = await measureEnvironment(session, remote, input, active.abort?.signal);
33
+ ctx = buildContext(session, server.url, input);
34
+ const { captures, failures } = await runCaptures(input, active, ctx, relaunch);
35
+ for (const row of [...captures, ...failures]) row.browser = name;
36
+ return {
37
+ captures,
38
+ failures,
39
+ environment
40
+ };
41
+ }, async (failed, error) => {
42
+ await ctx?.pagePool?.dispose();
43
+ const current = active.session ?? session;
44
+ active.session = null;
45
+ await Promise.all([current.release(outcomeOf(active, failed), error), closeServer(server)]);
46
+ });
47
+ }
48
+ async function closeServer(server) {
49
+ if (server !== void 0) await safeCloseServer(server);
50
+ }
51
+ /** What a remote service's `connect` is told about the run a browser is for. */
52
+ function launchContextOf(input, purpose, signal) {
28
53
  return {
29
- ...result,
30
- environment
54
+ buildId: input.buildId,
55
+ runId: input.runId ?? input.buildId,
56
+ shardIndex: input.shardIndex,
57
+ purpose,
58
+ signal
31
59
  };
32
60
  }
33
61
  /** The input with its target resolved, and the session gate when the target is remote. */
34
62
  function planRun(rawInput, driver, defaultTarget) {
35
63
  const name = rawInput.target ?? defaultTarget;
36
64
  const remote = driver.remote(name);
37
- const gate = remote && sessionGateFor(name, remote.remote.sessions);
65
+ const gate = remote && sessionGateFor(name, remote.remote.sessions, (value) => {
66
+ rawInput.logger?.warn({
67
+ target: name,
68
+ value: String(value)
69
+ }, "sessions() returned an invalid value; keeping the last good one");
70
+ });
38
71
  return {
39
72
  input: {
40
73
  ...rawInput,
@@ -58,8 +91,8 @@ function assertStorybookRoot(target, storybookUrl) {
58
91
  throw new TargetLaunchError(target, error instanceof Error ? error.message : String(error));
59
92
  }
60
93
  }
61
- /** The loopback server, or the URL given; a server that cannot start closes the browser it was for. */
62
- async function serve(input, browser) {
94
+ /** The loopback server, or the URL given; a server that cannot start releases the browser it was for. */
95
+ async function serve(input, session) {
63
96
  if (input.storybookUrl !== void 0) return {
64
97
  url: storybookBase(input.storybookUrl),
65
98
  close: async () => {
@@ -67,35 +100,32 @@ async function serve(input, browser) {
67
100
  }
68
101
  };
69
102
  return await createStaticServer(input.storybookDir).catch(async (error) => {
70
- await safeCloseBrowser(browser);
103
+ await session.release("failed", error);
71
104
  throw error;
72
105
  });
73
106
  }
74
107
  /** This host's environment: a local browser renders on the machine the runner runs on. */
75
- async function localEnvironment(browser) {
108
+ async function measureEnvironment(session, remote, input, signal) {
109
+ if (remote) return await measureRemote(session, remote.remote, {
110
+ logger: input.logger,
111
+ signal
112
+ });
113
+ const browser = session.browser;
76
114
  const version = typeof browser.version === "function" ? await browser.version() : void 0;
77
115
  return await detectCaptureEnvironment(version);
78
116
  }
79
- async function runWithBrowser(input, active, browser, server, relaunch) {
80
- active.browser = browser;
81
- const ctx = buildContext(browser, server.url, input);
117
+ async function runCaptures(input, active, ctx, relaunch) {
82
118
  const captures = [];
83
119
  const failures = [];
84
- try {
85
- await runAllCaptures(input, active, ctx, captures, failures, relaunch);
86
- return {
87
- captures,
88
- failures
89
- };
90
- } finally {
91
- active.browser = null;
92
- await ctx.pagePool?.dispose();
93
- await Promise.all([safeCloseBrowser(ctx.browser), safeCloseServer(server)]);
94
- }
120
+ await runAllCaptures(input, active, ctx, captures, failures, relaunch);
121
+ return {
122
+ captures,
123
+ failures
124
+ };
95
125
  }
96
- function buildContext(browser, baseUrl, input) {
126
+ function buildContext(session, baseUrl, input) {
97
127
  return {
98
- browser,
128
+ session,
99
129
  adapter: input.adapter ?? new StorybookAdapter(),
100
130
  baseUrl,
101
131
  navigationTimeoutMs: input.navigationTimeoutMs,
@@ -104,7 +134,7 @@ function buildContext(browser, baseUrl, input) {
104
134
  runA11y: input.runA11y,
105
135
  ...input.logger ? { logger: input.logger } : {},
106
136
  runtimeParams: createRuntimeParamsState(),
107
- ...input.reusePages ? { pagePool: new PagePool(browser) } : {}
137
+ ...input.reusePages ? { pagePool: new PagePool(session) } : {}
108
138
  };
109
139
  }
110
140
  async function runAllCaptures(input, active, ctx, captures, failures, relaunch) {
@@ -135,16 +165,29 @@ async function runBatches(batches, pages, input, active, ctx, out) {
135
165
  await pages.each(batch, async (job) => await captureOne(input, active, ctx, job.story, job.viewport, out));
136
166
  }
137
167
  }
138
- /** Swap in a fresh browser; the old one is closed first so two never coexist. */
168
+ /** Swap in a fresh browser; the old one is released first so two never coexist. */
139
169
  async function recycleBrowser(active, ctx, relaunch, logger) {
140
- if (active.cancelled) throw new Error("Capture cancelled");
170
+ assertNotCancelled(active);
141
171
  await ctx.pagePool?.dispose();
142
- await safeCloseBrowser(ctx.browser);
143
- ctx.browser = await relaunch();
144
- if (ctx.pagePool) ctx.pagePool = new PagePool(ctx.browser);
145
- active.browser = ctx.browser;
172
+ await ctx.session.release(active.cancelled ? "cancelled" : "finished");
173
+ assertNotCancelled(active);
174
+ await acquireReplacement(active, ctx, relaunch);
146
175
  logger?.info({}, "browser recycled");
147
176
  }
177
+ function assertNotCancelled(active) {
178
+ if (active.cancelled) throw new Error("Capture cancelled");
179
+ }
180
+ async function acquireReplacement(active, ctx, relaunch) {
181
+ const next = await relaunch();
182
+ if (active.cancelled) {
183
+ await next.release("cancelled");
184
+ throw new Error("Capture cancelled");
185
+ }
186
+ ctx.session = next;
187
+ if (ctx.pagePool) ctx.pagePool = new PagePool(next);
188
+ active.session = next;
189
+ return next;
190
+ }
148
191
  /** Capture one story x viewport; resolves to whether it timed out (failures are recorded, not thrown). */
149
192
  async function captureOne(input, active, ctx, story, viewport, out) {
150
193
  if (active.cancelled) throw new Error("Capture cancelled");
@@ -193,7 +236,6 @@ function pushRenderFailure(failures, input, storyId, viewportName, error) {
193
236
  err: error
194
237
  }, "render failed for story");
195
238
  }
196
- //#endregion
197
- export { assertReachable, planRun, renderAll, serve };
239
+ export { assertReachable, launchContextOf, planRun, renderAll, serve };
198
240
 
199
241
  //# sourceMappingURL=pipeline.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"pipeline.mjs","names":[],"sources":["../src/pipeline.ts"],"sourcesContent":["/** Render orchestration: static server, browser, and per-story captures. */\n\nimport {\n TargetLaunchError,\n engineOf,\n type CaptureEnvironment,\n type CaptureTarget,\n type RemoteTarget,\n type RenderResult,\n type RenderedSnapshot,\n type StoryEntry,\n type Viewport,\n} from \"@storyshelf/core/adapter/capture-runner\";\nimport { StorybookAdapter, resolveStoryViewports } from \"@storyshelf/core/capture\";\nimport {\n DEFAULT_RECYCLE_BROWSER_EVERY,\n chunk,\n describeSizing,\n createPageLimiter,\n detectCaptureEnvironment,\n isTimeoutError,\n sessionGateFor,\n storybookBase,\n type SessionGate,\n} from \"@storyshelf/core/utils\";\nimport type { Browser } from \"puppeteer-core\";\nimport { messageOf, safeCloseBrowser, safeCloseServer } from \"./browser.ts\";\nimport { PagePool } from \"./pages.ts\";\nimport { measureRemote } from \"./remote-environment.ts\";\nimport { createRuntimeParamsState } from \"./runtime-params.ts\";\nimport { captureScreenshot } from \"./screenshot.ts\";\nimport { createStaticServer } from \"./static-server.ts\";\nimport type {\n ActiveRun,\n CaptureContext,\n PuppeteerRenderInput,\n RunInput,\n TargetDriver,\n} from \"./types.ts\";\n\n/** Where the browser finds the Storybook: the loopback server, or the URL core handed over. */\ninterface Serving {\n url: string;\n close(): Promise<void>;\n}\n\n/** Render all story/viewport combinations for a build. */\nexport async function renderAll(\n rawInput: PuppeteerRenderInput,\n active: ActiveRun,\n driver: TargetDriver,\n defaultTarget: CaptureTarget,\n): Promise<RenderResult> {\n const { input, name, remote } = planRun(rawInput, driver, defaultTarget);\n assertReachable(name, remote, input.storybookUrl);\n // Launch first: a target that will not start must fail before a static server is bound.\n const browser = await driver.launch(name, input.logger);\n const server = await serve(input, browser);\n input.logger?.info(describeSizing({ ...input, browser: engineOf(name) }), \"capture sizing\");\n // Measured while the browser is open: Puppeteer cannot read the version of a closed one.\n const environment = remote\n ? await measureRemote(browser, remote.remote, input.logger)\n : await localEnvironment(browser);\n const result = await runWithBrowser(\n input,\n active,\n browser,\n server,\n async () => await driver.launch(name, input.logger),\n );\n // Echo the target so persisted rows are attributable without the request.\n for (const row of [...result.captures, ...result.failures]) row.browser = name;\n return { ...result, environment };\n}\n\n/** The input with its target resolved, and the session gate when the target is remote. */\nexport function planRun<T extends { target?: CaptureTarget | undefined }>(\n rawInput: T,\n driver: TargetDriver,\n defaultTarget: CaptureTarget,\n): {\n input: T & { target: CaptureTarget; gate?: SessionGate | undefined };\n name: CaptureTarget;\n remote: RemoteTarget | undefined;\n} {\n const name = rawInput.target ?? defaultTarget;\n const remote = driver.remote(name);\n const gate = remote && sessionGateFor(name, remote.remote.sessions);\n return { input: { ...rawInput, target: name, gate }, name, remote };\n}\n\n/** A target whose browser cannot route back to this host needs a URL; failing here names why. */\nexport function assertReachable(\n target: CaptureTarget,\n remote: RemoteTarget | undefined,\n storybookUrl: string | undefined,\n): void {\n if (remote?.reach === \"url\" && storybookUrl === undefined) {\n throw new TargetLaunchError(\n target,\n \"its browser cannot reach this host, so it needs a Storybook URL (a public origin and a secret to sign it) and none was given\",\n { transient: true },\n );\n }\n if (storybookUrl !== undefined) {\n assertStorybookRoot(target, storybookUrl);\n }\n}\n\n/** The URL must be the build's directory root; refused before any browser connects. */\nfunction assertStorybookRoot(target: CaptureTarget, storybookUrl: string): void {\n try {\n storybookBase(storybookUrl);\n } catch (error) {\n throw new TargetLaunchError(target, error instanceof Error ? error.message : String(error));\n }\n}\n\n/** The loopback server, or the URL given; a server that cannot start closes the browser it was for. */\nexport async function serve(\n input: Pick<RunInput, \"storybookDir\" | \"storybookUrl\">,\n browser: Browser,\n): Promise<Serving> {\n if (input.storybookUrl !== undefined) {\n return {\n url: storybookBase(input.storybookUrl),\n close: async () => {\n await Promise.resolve();\n },\n };\n }\n return await createStaticServer(input.storybookDir).catch(async (error: unknown) => {\n await safeCloseBrowser(browser);\n throw error;\n });\n}\n\n/** This host's environment: a local browser renders on the machine the runner runs on. */\nasync function localEnvironment(browser: Browser): Promise<CaptureEnvironment> {\n const version = typeof browser.version === \"function\" ? await browser.version() : undefined;\n return await detectCaptureEnvironment(version);\n}\n\nasync function runWithBrowser(\n input: RunInput,\n active: ActiveRun,\n browser: Browser,\n server: Serving,\n relaunch: () => Promise<Browser>,\n): Promise<RenderResult> {\n active.browser = browser;\n const ctx = buildContext(browser, server.url, input);\n const captures: RenderedSnapshot[] = [];\n const failures: RenderResult[\"failures\"] = [];\n try {\n await runAllCaptures(input, active, ctx, captures, failures, relaunch);\n return { captures, failures };\n } finally {\n active.browser = null;\n await ctx.pagePool?.dispose();\n // ctx.browser is the latest one: recycling replaces the browser mid-run.\n await Promise.all([safeCloseBrowser(ctx.browser), safeCloseServer(server)]);\n }\n}\n\nfunction buildContext(\n browser: Browser,\n baseUrl: string,\n input: PuppeteerRenderInput,\n): CaptureContext {\n return {\n browser,\n adapter: input.adapter ?? new StorybookAdapter(),\n baseUrl,\n navigationTimeoutMs: input.navigationTimeoutMs,\n executePlay: input.executePlay,\n playTimeoutMs: input.playTimeoutMs,\n runA11y: input.runA11y,\n ...(input.logger ? { logger: input.logger } : {}),\n runtimeParams: createRuntimeParamsState(),\n ...(input.reusePages ? { pagePool: new PagePool(browser) } : {}),\n };\n}\n\nasync function runAllCaptures(\n input: RunInput,\n active: ActiveRun,\n ctx: CaptureContext,\n captures: RenderedSnapshot[],\n failures: RenderResult[\"failures\"],\n relaunch: () => Promise<Browser>,\n): Promise<void> {\n // Bounded: one page per story x viewport all at once exhausts memory on large Storybooks.\n const jobs = input.stories.flatMap((story) =>\n resolveStoryViewports(story, input.viewports).map((viewport) => ({ story, viewport })),\n );\n // Batches drain in-flight pages, so the browser can be swapped between them.\n const batches = chunk(jobs, input.recycleBrowserEvery ?? DEFAULT_RECYCLE_BROWSER_EVERY);\n const pages = createPageLimiter({\n concurrency: input.concurrency,\n ...(input.gate === undefined ? {} : { gate: input.gate }),\n browser: engineOf(input.target ?? \"chromium\"),\n logger: input.logger,\n });\n try {\n await runBatches(batches, pages, input, active, ctx, { captures, failures, relaunch });\n } finally {\n pages.dispose();\n }\n}\n\nasync function runBatches(\n batches: { story: StoryEntry; viewport: Viewport }[][],\n pages: ReturnType<typeof createPageLimiter>,\n input: RunInput,\n active: ActiveRun,\n ctx: CaptureContext,\n out: {\n captures: RenderedSnapshot[];\n failures: RenderResult[\"failures\"];\n relaunch: () => Promise<Browser>;\n },\n): Promise<void> {\n for (const [index, batch] of batches.entries()) {\n if (index > 0) {\n // oxlint-disable-next-line eslint/no-await-in-loop -- recycle only between drained batches\n await recycleBrowser(active, ctx, out.relaunch, input.logger);\n }\n // oxlint-disable-next-line eslint/no-await-in-loop -- batches run one after another\n await pages.each(\n batch,\n async (job) => await captureOne(input, active, ctx, job.story, job.viewport, out),\n );\n }\n}\n\n/** Swap in a fresh browser; the old one is closed first so two never coexist. */\nasync function recycleBrowser(\n active: ActiveRun,\n ctx: CaptureContext,\n relaunch: () => Promise<Browser>,\n logger: PuppeteerRenderInput[\"logger\"],\n): Promise<void> {\n if (active.cancelled) throw new Error(\"Capture cancelled\");\n await ctx.pagePool?.dispose();\n await safeCloseBrowser(ctx.browser);\n ctx.browser = await relaunch();\n if (ctx.pagePool) ctx.pagePool = new PagePool(ctx.browser);\n active.browser = ctx.browser;\n logger?.info({}, \"browser recycled\");\n}\n\n/** Capture one story x viewport; resolves to whether it timed out (failures are recorded, not thrown). */\nasync function captureOne(\n input: PuppeteerRenderInput,\n active: ActiveRun,\n ctx: CaptureContext,\n story: StoryEntry,\n viewport: Viewport,\n out: { captures: RenderedSnapshot[]; failures: RenderResult[\"failures\"] },\n): Promise<boolean> {\n if (active.cancelled) throw new Error(\"Capture cancelled\");\n try {\n await pushCapture(ctx, story, viewport, out.captures, out.failures, input);\n return false;\n } catch (error) {\n pushRenderFailure(out.failures, input, story.id, viewport.name, error);\n return isTimeoutError(error);\n }\n}\n\nasync function pushCapture(\n ctx: CaptureContext,\n story: StoryEntry,\n viewport: Viewport,\n captures: RenderedSnapshot[],\n failures: RenderResult[\"failures\"],\n input: PuppeteerRenderInput,\n): Promise<void> {\n const result = await captureScreenshot(ctx, story, viewport);\n const snapshot: RenderedSnapshot = {\n story: result.story,\n viewportName: viewport.name,\n viewport,\n screenshot: result.screenshot,\n ...(result.a11yViolations.length > 0 ? { a11y: result.a11yViolations } : {}),\n };\n // Streaming: hand it over and drop the buffer rather than holding every screenshot.\n if (input.onCapture) await input.onCapture(snapshot);\n else captures.push(snapshot);\n if (result.a11yViolations.length > 0)\n handleA11y(input, failures, story, viewport, result.a11yViolations);\n}\nfunction handleA11y(\n input: PuppeteerRenderInput,\n failures: RenderResult[\"failures\"],\n story: StoryEntry,\n viewport: Viewport,\n violations: string[],\n): void {\n failures.push({\n storyId: story.id,\n viewportName: viewport.name,\n error: `a11y: ${violations.join(\"; \")}`,\n });\n input.logger?.warn(\n { storyId: story.id, viewport: viewport.name, violations },\n \"a11y violations found\",\n );\n}\n\nfunction pushRenderFailure(\n failures: RenderResult[\"failures\"],\n input: PuppeteerRenderInput,\n storyId: string,\n viewportName: string,\n error: unknown,\n): void {\n failures.push({ storyId, viewportName, error: messageOf(error) });\n input.logger?.error({ storyId, viewport: viewportName, err: error }, \"render failed for story\");\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"pipeline.mjs","names":[],"sources":["../src/pipeline.ts"],"sourcesContent":["/** Render orchestration: static server, browser, and per-story captures. */\n\nimport {\n TargetLaunchError,\n engineOf,\n type CaptureEnvironment,\n type CaptureTarget,\n type RemoteTarget,\n type RenderResult,\n type RenderedSnapshot,\n type StoryEntry,\n type Viewport,\n} from \"@storyshelf/core/adapter/capture-runner\";\nimport { StorybookAdapter, resolveStoryViewports } from \"@storyshelf/core/capture\";\nimport {\n DEFAULT_RECYCLE_BROWSER_EVERY,\n chunk,\n describeSizing,\n createPageLimiter,\n detectCaptureEnvironment,\n isTimeoutError,\n sessionGateFor,\n storybookBase,\n type SessionGate,\n} from \"@storyshelf/core/utils\";\nimport type { Browser } from \"puppeteer-core\";\nimport { abortIfCancelled, finishing, messageOf, outcomeOf, safeCloseServer } from \"./browser.ts\";\nimport { PagePool } from \"./pages.ts\";\nimport { measureRemote } from \"./remote-environment.ts\";\nimport { createRuntimeParamsState } from \"./runtime-params.ts\";\nimport { captureScreenshot } from \"./screenshot.ts\";\nimport { createBrowserSession, type BrowserSession } from \"./session.ts\";\nimport { createStaticServer } from \"./static-server.ts\";\nimport type {\n ActiveRun,\n CaptureContext,\n LaunchContext,\n PuppeteerRenderInput,\n RunInput,\n TargetDriver,\n} from \"./types.ts\";\n\n/** Where the browser finds the Storybook: the loopback server, or the URL core handed over. */\ninterface Serving {\n url: string;\n close(): Promise<void>;\n}\n\n/** Render all story/viewport combinations for a build. */\nexport async function renderAll(\n rawInput: PuppeteerRenderInput,\n active: ActiveRun,\n driver: TargetDriver,\n defaultTarget: CaptureTarget,\n): Promise<RenderResult> {\n const { input, name, remote } = planRun(rawInput, driver, defaultTarget);\n assertReachable(name, remote, input.storybookUrl);\n const relaunch = async (): Promise<BrowserSession> =>\n createBrowserSession(\n await driver.launch(\n name,\n input.logger,\n launchContextOf(input, \"render\", active.abort?.signal),\n ),\n );\n // Launch first: a target that will not start must fail before a static server is bound.\n const session = await relaunch();\n active.session = session;\n await abortIfCancelled(active, session);\n let server: Serving | undefined;\n let ctx: CaptureContext | undefined;\n return await finishing(\n async () => {\n server = await serve(input, session);\n input.logger?.info(describeSizing({ ...input, browser: engineOf(name) }), \"capture sizing\");\n // Measured while the browser is open: Puppeteer cannot read the version of a closed one.\n const environment = await measureEnvironment(session, remote, input, active.abort?.signal);\n ctx = buildContext(session, server.url, input);\n const { captures, failures } = await runCaptures(input, active, ctx, relaunch);\n // Echo the target so persisted rows are attributable without the request.\n for (const row of [...captures, ...failures]) row.browser = name;\n return { captures, failures, environment };\n },\n async (failed, error) => {\n await ctx?.pagePool?.dispose();\n const current = active.session ?? session;\n active.session = null;\n await Promise.all([current.release(outcomeOf(active, failed), error), closeServer(server)]);\n },\n );\n}\n\nasync function closeServer(server: Serving | undefined): Promise<void> {\n if (server !== undefined) await safeCloseServer(server);\n}\n\n/** What a remote service's `connect` is told about the run a browser is for. */\nexport function launchContextOf(\n input: { buildId: string; runId?: string | undefined; shardIndex?: number | undefined },\n purpose: LaunchContext[\"purpose\"],\n signal?: AbortSignal,\n): LaunchContext {\n return {\n buildId: input.buildId,\n runId: input.runId ?? input.buildId,\n shardIndex: input.shardIndex,\n purpose,\n signal,\n };\n}\n\n/** The input with its target resolved, and the session gate when the target is remote. */\nexport function planRun<\n T extends { target?: CaptureTarget | undefined; logger?: PuppeteerRenderInput[\"logger\"] },\n>(\n rawInput: T,\n driver: TargetDriver,\n defaultTarget: CaptureTarget,\n): {\n input: T & { target: CaptureTarget; gate?: SessionGate | undefined };\n name: CaptureTarget;\n remote: RemoteTarget | undefined;\n} {\n const name = rawInput.target ?? defaultTarget;\n const remote = driver.remote(name);\n const gate =\n remote &&\n sessionGateFor(name, remote.remote.sessions, (value) => {\n rawInput.logger?.warn(\n { target: name, value: String(value) },\n \"sessions() returned an invalid value; keeping the last good one\",\n );\n });\n return { input: { ...rawInput, target: name, gate }, name, remote };\n}\n\n/** A target whose browser cannot route back to this host needs a URL; failing here names why. */\nexport function assertReachable(\n target: CaptureTarget,\n remote: RemoteTarget | undefined,\n storybookUrl: string | undefined,\n): void {\n if (remote?.reach === \"url\" && storybookUrl === undefined) {\n throw new TargetLaunchError(\n target,\n \"its browser cannot reach this host, so it needs a Storybook URL (a public origin and a secret to sign it) and none was given\",\n { transient: true },\n );\n }\n if (storybookUrl !== undefined) {\n assertStorybookRoot(target, storybookUrl);\n }\n}\n\n/** The URL must be the build's directory root; refused before any browser connects. */\nfunction assertStorybookRoot(target: CaptureTarget, storybookUrl: string): void {\n try {\n storybookBase(storybookUrl);\n } catch (error) {\n throw new TargetLaunchError(target, error instanceof Error ? error.message : String(error));\n }\n}\n\n/** The loopback server, or the URL given; a server that cannot start releases the browser it was for. */\nexport async function serve(\n input: Pick<RunInput, \"storybookDir\" | \"storybookUrl\">,\n session: BrowserSession,\n): Promise<Serving> {\n if (input.storybookUrl !== undefined) {\n return {\n url: storybookBase(input.storybookUrl),\n close: async () => {\n await Promise.resolve();\n },\n };\n }\n return await createStaticServer(input.storybookDir).catch(async (error: unknown) => {\n await session.release(\"failed\", error);\n throw error;\n });\n}\n\n/** This host's environment: a local browser renders on the machine the runner runs on. */\nasync function measureEnvironment(\n session: BrowserSession,\n remote: RemoteTarget | undefined,\n input: RunInput,\n signal: AbortSignal | undefined,\n): Promise<CaptureEnvironment> {\n if (remote) return await measureRemote(session, remote.remote, { logger: input.logger, signal });\n const browser: Browser = session.browser;\n const version = typeof browser.version === \"function\" ? await browser.version() : undefined;\n return await detectCaptureEnvironment(version);\n}\n\nasync function runCaptures(\n input: RunInput,\n active: ActiveRun,\n ctx: CaptureContext,\n relaunch: () => Promise<BrowserSession>,\n): Promise<Pick<RenderResult, \"captures\" | \"failures\">> {\n const captures: RenderedSnapshot[] = [];\n const failures: RenderResult[\"failures\"] = [];\n await runAllCaptures(input, active, ctx, captures, failures, relaunch);\n return { captures, failures };\n}\n\nfunction buildContext(\n session: BrowserSession,\n baseUrl: string,\n input: PuppeteerRenderInput,\n): CaptureContext {\n return {\n session,\n adapter: input.adapter ?? new StorybookAdapter(),\n baseUrl,\n navigationTimeoutMs: input.navigationTimeoutMs,\n executePlay: input.executePlay,\n playTimeoutMs: input.playTimeoutMs,\n runA11y: input.runA11y,\n ...(input.logger ? { logger: input.logger } : {}),\n runtimeParams: createRuntimeParamsState(),\n ...(input.reusePages ? { pagePool: new PagePool(session) } : {}),\n };\n}\n\nasync function runAllCaptures(\n input: RunInput,\n active: ActiveRun,\n ctx: CaptureContext,\n captures: RenderedSnapshot[],\n failures: RenderResult[\"failures\"],\n relaunch: () => Promise<BrowserSession>,\n): Promise<void> {\n // Bounded: one page per story x viewport all at once exhausts memory on large Storybooks.\n const jobs = input.stories.flatMap((story) =>\n resolveStoryViewports(story, input.viewports).map((viewport) => ({ story, viewport })),\n );\n // Batches drain in-flight pages, so the browser can be swapped between them.\n const batches = chunk(jobs, input.recycleBrowserEvery ?? DEFAULT_RECYCLE_BROWSER_EVERY);\n const pages = createPageLimiter({\n concurrency: input.concurrency,\n ...(input.gate === undefined ? {} : { gate: input.gate }),\n browser: engineOf(input.target ?? \"chromium\"),\n logger: input.logger,\n });\n try {\n await runBatches(batches, pages, input, active, ctx, { captures, failures, relaunch });\n } finally {\n pages.dispose();\n }\n}\n\nasync function runBatches(\n batches: { story: StoryEntry; viewport: Viewport }[][],\n pages: ReturnType<typeof createPageLimiter>,\n input: RunInput,\n active: ActiveRun,\n ctx: CaptureContext,\n out: {\n captures: RenderedSnapshot[];\n failures: RenderResult[\"failures\"];\n relaunch: () => Promise<BrowserSession>;\n },\n): Promise<void> {\n for (const [index, batch] of batches.entries()) {\n if (index > 0) {\n // oxlint-disable-next-line eslint/no-await-in-loop -- recycle only between drained batches\n await recycleBrowser(active, ctx, out.relaunch, input.logger);\n }\n // oxlint-disable-next-line eslint/no-await-in-loop -- batches run one after another\n await pages.each(\n batch,\n async (job) => await captureOne(input, active, ctx, job.story, job.viewport, out),\n );\n }\n}\n\n/** Swap in a fresh browser; the old one is released first so two never coexist. */\nasync function recycleBrowser(\n active: ActiveRun,\n ctx: CaptureContext,\n relaunch: () => Promise<BrowserSession>,\n logger: PuppeteerRenderInput[\"logger\"],\n): Promise<void> {\n assertNotCancelled(active);\n await ctx.pagePool?.dispose();\n const previous = ctx.session;\n await previous.release(active.cancelled ? \"cancelled\" : \"finished\");\n assertNotCancelled(active);\n await acquireReplacement(active, ctx, relaunch);\n logger?.info({}, \"browser recycled\");\n}\n\nfunction assertNotCancelled(active: ActiveRun): void {\n if (active.cancelled) throw new Error(\"Capture cancelled\");\n}\n\nasync function acquireReplacement(\n active: ActiveRun,\n ctx: CaptureContext,\n relaunch: () => Promise<BrowserSession>,\n): Promise<BrowserSession> {\n const next = await relaunch();\n if (active.cancelled) {\n await next.release(\"cancelled\");\n throw new Error(\"Capture cancelled\");\n }\n ctx.session = next;\n if (ctx.pagePool) ctx.pagePool = new PagePool(next);\n active.session = next;\n return next;\n}\n\n/** Capture one story x viewport; resolves to whether it timed out (failures are recorded, not thrown). */\nasync function captureOne(\n input: PuppeteerRenderInput,\n active: ActiveRun,\n ctx: CaptureContext,\n story: StoryEntry,\n viewport: Viewport,\n out: { captures: RenderedSnapshot[]; failures: RenderResult[\"failures\"] },\n): Promise<boolean> {\n if (active.cancelled) throw new Error(\"Capture cancelled\");\n try {\n await pushCapture(ctx, story, viewport, out.captures, out.failures, input);\n return false;\n } catch (error) {\n pushRenderFailure(out.failures, input, story.id, viewport.name, error);\n return isTimeoutError(error);\n }\n}\n\nasync function pushCapture(\n ctx: CaptureContext,\n story: StoryEntry,\n viewport: Viewport,\n captures: RenderedSnapshot[],\n failures: RenderResult[\"failures\"],\n input: PuppeteerRenderInput,\n): Promise<void> {\n const result = await captureScreenshot(ctx, story, viewport);\n const snapshot: RenderedSnapshot = {\n story: result.story,\n viewportName: viewport.name,\n viewport,\n screenshot: result.screenshot,\n ...(result.a11yViolations.length > 0 ? { a11y: result.a11yViolations } : {}),\n };\n // Streaming: hand it over and drop the buffer rather than holding every screenshot.\n if (input.onCapture) await input.onCapture(snapshot);\n else captures.push(snapshot);\n if (result.a11yViolations.length > 0)\n handleA11y(input, failures, story, viewport, result.a11yViolations);\n}\nfunction handleA11y(\n input: PuppeteerRenderInput,\n failures: RenderResult[\"failures\"],\n story: StoryEntry,\n viewport: Viewport,\n violations: string[],\n): void {\n failures.push({\n storyId: story.id,\n viewportName: viewport.name,\n error: `a11y: ${violations.join(\"; \")}`,\n });\n input.logger?.warn(\n { storyId: story.id, viewport: viewport.name, violations },\n \"a11y violations found\",\n );\n}\n\nfunction pushRenderFailure(\n failures: RenderResult[\"failures\"],\n input: PuppeteerRenderInput,\n storyId: string,\n viewportName: string,\n error: unknown,\n): void {\n failures.push({ storyId, viewportName, error: messageOf(error) });\n input.logger?.error({ storyId, viewport: viewportName, err: error }, \"render failed for story\");\n}\n"],"mappings":"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"}
package/dist/play.mjs CHANGED
@@ -2,7 +2,7 @@ import "node:path";
2
2
  import "node:url";
3
3
  import.meta.url;
4
4
  import { messageOf } from "./browser.mjs";
5
- //#region src/play.ts
5
+ // src/play.ts
6
6
  /** Execute the story's play function with a timeout race. */
7
7
  async function runPlay(page, storyId, timeoutMs) {
8
8
  try {
@@ -57,7 +57,6 @@ async function inPagePlay({ storyId, timeoutMs }) {
57
57
  }
58
58
  if (playError) throw playError;
59
59
  }
60
- //#endregion
61
60
  export { inPagePlay, runPlay };
62
61
 
63
62
  //# sourceMappingURL=play.mjs.map
@@ -1,29 +1,54 @@
1
1
  import __tsdown_shims_path from 'node:path';
2
2
  import __tsdown_shims_url from 'node:url';
3
- import { BrowserName, RemoteBrowser } from "@storyshelf/core/adapter/capture-runner";
4
- //#region src/remote/cdp.d.ts
5
- /** Options of {@link cdp}. */
6
- interface CdpOptions {
3
+ import { BrowserName, RemoteBrowser, RemoteOptions } from "@storyshelf/core/adapter/capture-runner";
4
+ /** What both forms of {@link cdp} share. */
5
+ interface CdpBase {
7
6
  /** The target name half (`chromium@<name>`): lowercase letters, digits and hyphens, at most 32. */
8
7
  name: string;
9
- /** `ws(s)://…`, or `http(s)://…` of a Chrome with remote debugging (its WebSocket is looked up). May carry a key: it is never logged. */
10
- endpoint: string;
11
- /** Handshake headers, such as `authorization`. Never logged. */
12
- headers?: Record<string, string>;
13
- /** Parallel pages the service allows (default 1: set it to the plan's cap). */
14
- sessions?: number;
8
+ /** Parallel pages the service allows (default 1: set it to the plan's cap). A function is read live. */
9
+ sessions?: number | (() => number);
15
10
  /** Engines to offer (default `chromium`; `chrome` when the service is real Chrome). */
16
11
  engines?: readonly BrowserName[];
17
12
  /** OS the service runs on, reported when the page cannot tell. */
18
13
  platform?: string;
19
14
  /** How long one connect attempt waits in ms (default 30 000). */
20
15
  connectTimeoutMs?: number;
16
+ /**
17
+ * Maximum connections this process holds to the service at once (default: no cap), separate from
18
+ * the pages in `sessions`. Renders over the cap wait; per process, not per deployment.
19
+ */
20
+ maxConnections?: number;
21
+ }
22
+ /** A service at a fixed address. */
23
+ interface CdpFixedOptions extends CdpBase {
24
+ /** `ws(s)://…`, or `http(s)://…` of a Chrome with remote debugging (its WebSocket is looked up). May carry a key: it is never logged. */
25
+ endpoint: string;
26
+ /** Handshake headers, such as `authorization`. Never logged. */
27
+ headers?: Record<string, string>;
28
+ connect?: undefined;
29
+ release?: undefined;
21
30
  }
31
+ /**
32
+ * A service that must be woken, started or chosen per connection: `connect` receives the run's
33
+ * context (`buildId`, `runId`, `shardIndex`, `purpose`, `attempt`, `signal`) and returns
34
+ * `{ endpoint, headers, lease }`; `release` gets the lease back once, when the render ends.
35
+ */
36
+ interface CdpLeasedOptions<TLease> extends CdpBase {
37
+ connect: Extract<RemoteOptions<TLease>["connect"], (...args: never[]) => unknown>;
38
+ release?: RemoteOptions<TLease>["release"];
39
+ /** Retries after the first connect attempt (default 3) and the first backoff in ms (default 500). */
40
+ retry?: RemoteOptions<TLease>["retry"];
41
+ /** `acquireMs` bounds `connect`, `connectMs` the handshake, `releaseMs` `release` (all default 30 000). */
42
+ timeouts?: RemoteOptions<TLease>["timeouts"];
43
+ endpoint?: undefined;
44
+ headers?: undefined;
45
+ }
46
+ /** Options of {@link cdp}. */
47
+ type CdpOptions<TLease = unknown> = CdpFixedOptions | CdpLeasedOptions<TLease>;
22
48
  /**
23
49
  * Targets on a CDP service, for example `chromium@cloudflare`. The browser cannot route back to
24
50
  * this host, so every target needs a signed Storybook URL (ADR 0007, decision 5).
25
51
  */
26
- declare function cdp(options: CdpOptions): RemoteBrowser;
27
- //#endregion
28
- export { CdpOptions, cdp };
52
+ declare function cdp<TLease = unknown>(options: CdpOptions<TLease>): RemoteBrowser;
53
+ export { CdpFixedOptions, CdpLeasedOptions, CdpOptions, cdp };
29
54
  //# sourceMappingURL=cdp.d.mts.map
@@ -2,7 +2,7 @@ import "node:path";
2
2
  import "node:url";
3
3
  import.meta.url;
4
4
  import { defineRemote } from "@storyshelf/core/adapter/capture-runner";
5
- //#region src/remote/cdp.ts
5
+ // src/remote/cdp.ts
6
6
  /** A Chrome DevTools Protocol endpoint: Browserless, Cloudflare Browser Rendering, a bare Chrome, … */
7
7
  /** Puppeteer drives the Chromium family, over CDP only (ADR 0007, decision 1). */
8
8
  const SUPPORT = {
@@ -15,20 +15,34 @@ const SUPPORT = {
15
15
  * this host, so every target needs a signed Storybook URL (ADR 0007, decision 5).
16
16
  */
17
17
  function cdp(options) {
18
- return defineRemote({
18
+ const base = {
19
19
  name: options.name,
20
20
  engines: options.engines ?? ["chromium"],
21
- connect: () => ({
22
- protocol: "cdp",
21
+ protocol: "cdp",
22
+ reach: "url",
23
+ sessions: options.sessions ?? 1,
24
+ maxConnections: options.maxConnections,
25
+ platform: options.platform
26
+ };
27
+ if (typeof options.connect === "function") return defineRemote({
28
+ ...base,
29
+ connect: options.connect,
30
+ release: options.release,
31
+ retry: options.retry,
32
+ timeouts: {
33
+ ...options.timeouts,
34
+ connectMs: options.timeouts?.connectMs ?? options.connectTimeoutMs
35
+ }
36
+ }, SUPPORT);
37
+ return defineRemote({
38
+ ...base,
39
+ connect: {
23
40
  endpoint: options.endpoint,
24
41
  ...options.headers === void 0 ? {} : { headers: options.headers }
25
- }),
26
- sessions: options.sessions ?? 1,
27
- platform: options.platform,
28
- connectTimeoutMs: options.connectTimeoutMs
42
+ },
43
+ timeouts: { connectMs: options.connectTimeoutMs }
29
44
  }, SUPPORT);
30
45
  }
31
- //#endregion
32
46
  export { cdp };
33
47
 
34
48
  //# sourceMappingURL=cdp.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"cdp.mjs","names":[],"sources":["../../src/remote/cdp.ts"],"sourcesContent":["/** A Chrome DevTools Protocol endpoint: Browserless, Cloudflare Browser Rendering, a bare Chrome, … */\nimport {\n defineRemote,\n type BrowserName,\n type RemoteBrowser,\n type RemoteSupport,\n} from \"@storyshelf/core/adapter/capture-runner\";\n\n/** Puppeteer drives the Chromium family, over CDP only (ADR 0007, decision 1). */\nconst SUPPORT: RemoteSupport = {\n engines: [\"chromium\", \"chrome\"],\n protocols: [\"cdp\"],\n label: \"cdp()\",\n};\n\n/** Options of {@link cdp}. */\nexport interface CdpOptions {\n /** The target name half (`chromium@<name>`): lowercase letters, digits and hyphens, at most 32. */\n name: string;\n /** `ws(s)://…`, or `http(s)://…` of a Chrome with remote debugging (its WebSocket is looked up). May carry a key: it is never logged. */\n endpoint: string;\n /** Handshake headers, such as `authorization`. Never logged. */\n headers?: Record<string, string>;\n /** Parallel pages the service allows (default 1: set it to the plan's cap). */\n sessions?: number;\n /** Engines to offer (default `chromium`; `chrome` when the service is real Chrome). */\n engines?: readonly BrowserName[];\n /** OS the service runs on, reported when the page cannot tell. */\n platform?: string;\n /** How long one connect attempt waits in ms (default 30 000). */\n connectTimeoutMs?: number;\n}\n\n/**\n * Targets on a CDP service, for example `chromium@cloudflare`. The browser cannot route back to\n * this host, so every target needs a signed Storybook URL (ADR 0007, decision 5).\n */\nexport function cdp(options: CdpOptions): RemoteBrowser {\n return defineRemote(\n {\n name: options.name,\n engines: options.engines ?? [\"chromium\"],\n connect: () => ({\n protocol: \"cdp\",\n endpoint: options.endpoint,\n ...(options.headers === undefined ? {} : { headers: options.headers }),\n }),\n sessions: options.sessions ?? 1,\n platform: options.platform,\n connectTimeoutMs: options.connectTimeoutMs,\n },\n SUPPORT,\n );\n}\n"],"mappings":";;;;;;;AASA,MAAM,UAAyB;CAC7B,SAAS,CAAC,YAAY,QAAQ;CAC9B,WAAW,CAAC,KAAK;CACjB,OAAO;AACT;;;;;AAwBA,SAAgB,IAAI,SAAoC;CACtD,OAAO,aACL;EACE,MAAM,QAAQ;EACd,SAAS,QAAQ,WAAW,CAAC,UAAU;EACvC,gBAAgB;GACd,UAAU;GACV,UAAU,QAAQ;GAClB,GAAI,QAAQ,YAAY,KAAA,IAAY,CAAC,IAAI,EAAE,SAAS,QAAQ,QAAQ;EACtE;EACA,UAAU,QAAQ,YAAY;EAC9B,UAAU,QAAQ;EAClB,kBAAkB,QAAQ;CAC5B,GACA,OACF;AACF"}
1
+ {"version":3,"file":"cdp.mjs","names":[],"sources":["../../src/remote/cdp.ts"],"sourcesContent":["/** A Chrome DevTools Protocol endpoint: Browserless, Cloudflare Browser Rendering, a bare Chrome, … */\nimport {\n defineRemote,\n type BrowserName,\n type RemoteBrowser,\n type RemoteOptions,\n type RemoteSupport,\n} from \"@storyshelf/core/adapter/capture-runner\";\n\n/** Puppeteer drives the Chromium family, over CDP only (ADR 0007, decision 1). */\nconst SUPPORT: RemoteSupport = {\n engines: [\"chromium\", \"chrome\"],\n protocols: [\"cdp\"],\n label: \"cdp()\",\n};\n\n/** What both forms of {@link cdp} share. */\ninterface CdpBase {\n /** The target name half (`chromium@<name>`): lowercase letters, digits and hyphens, at most 32. */\n name: string;\n /** Parallel pages the service allows (default 1: set it to the plan's cap). A function is read live. */\n sessions?: number | (() => number);\n /** Engines to offer (default `chromium`; `chrome` when the service is real Chrome). */\n engines?: readonly BrowserName[];\n /** OS the service runs on, reported when the page cannot tell. */\n platform?: string;\n /** How long one connect attempt waits in ms (default 30 000). */\n connectTimeoutMs?: number;\n /**\n * Maximum connections this process holds to the service at once (default: no cap), separate from\n * the pages in `sessions`. Renders over the cap wait; per process, not per deployment.\n */\n maxConnections?: number;\n}\n\n/** A service at a fixed address. */\nexport interface CdpFixedOptions extends CdpBase {\n /** `ws(s)://…`, or `http(s)://…` of a Chrome with remote debugging (its WebSocket is looked up). May carry a key: it is never logged. */\n endpoint: string;\n /** Handshake headers, such as `authorization`. Never logged. */\n headers?: Record<string, string>;\n connect?: undefined;\n release?: undefined;\n}\n\n/**\n * A service that must be woken, started or chosen per connection: `connect` receives the run's\n * context (`buildId`, `runId`, `shardIndex`, `purpose`, `attempt`, `signal`) and returns\n * `{ endpoint, headers, lease }`; `release` gets the lease back once, when the render ends.\n */\nexport interface CdpLeasedOptions<TLease> extends CdpBase {\n connect: Extract<RemoteOptions<TLease>[\"connect\"], (...args: never[]) => unknown>;\n release?: RemoteOptions<TLease>[\"release\"];\n /** Retries after the first connect attempt (default 3) and the first backoff in ms (default 500). */\n retry?: RemoteOptions<TLease>[\"retry\"];\n /** `acquireMs` bounds `connect`, `connectMs` the handshake, `releaseMs` `release` (all default 30 000). */\n timeouts?: RemoteOptions<TLease>[\"timeouts\"];\n endpoint?: undefined;\n headers?: undefined;\n}\n\n/** Options of {@link cdp}. */\nexport type CdpOptions<TLease = unknown> = CdpFixedOptions | CdpLeasedOptions<TLease>;\n\n/**\n * Targets on a CDP service, for example `chromium@cloudflare`. The browser cannot route back to\n * this host, so every target needs a signed Storybook URL (ADR 0007, decision 5).\n */\nexport function cdp<TLease = unknown>(options: CdpOptions<TLease>): RemoteBrowser {\n const base = {\n name: options.name,\n engines: options.engines ?? [\"chromium\"],\n protocol: \"cdp\",\n reach: \"url\",\n sessions: options.sessions ?? 1,\n maxConnections: options.maxConnections,\n platform: options.platform,\n } as const;\n if (typeof options.connect === \"function\") {\n return defineRemote<TLease>(\n {\n ...base,\n connect: options.connect,\n release: options.release,\n retry: options.retry,\n timeouts: {\n ...options.timeouts,\n connectMs: options.timeouts?.connectMs ?? options.connectTimeoutMs,\n },\n },\n SUPPORT,\n );\n }\n return defineRemote(\n {\n ...base,\n connect: {\n endpoint: options.endpoint,\n ...(options.headers === undefined ? {} : { headers: options.headers }),\n },\n timeouts: { connectMs: options.connectTimeoutMs },\n },\n SUPPORT,\n );\n}\n"],"mappings":";;;;;;;AAUA,MAAM,UAAyB;CAC7B,SAAS,CAAC,YAAY,QAAQ;CAC9B,WAAW,CAAC,KAAK;CACjB,OAAO;AACT;;;;;AAsDA,SAAgB,IAAsB,SAA4C;CAChF,MAAM,OAAO;EACX,MAAM,QAAQ;EACd,SAAS,QAAQ,WAAW,CAAC,UAAU;EACvC,UAAU;EACV,OAAO;EACP,UAAU,QAAQ,YAAY;EAC9B,gBAAgB,QAAQ;EACxB,UAAU,QAAQ;CACpB;CACA,IAAI,OAAO,QAAQ,YAAY,YAC7B,OAAO,aACL;EACE,GAAG;EACH,SAAS,QAAQ;EACjB,SAAS,QAAQ;EACjB,OAAO,QAAQ;EACf,UAAU;GACR,GAAG,QAAQ;GACX,WAAW,QAAQ,UAAU,aAAa,QAAQ;EACpD;CACF,GACA,OACF;CAEF,OAAO,aACL;EACE,GAAG;EACH,SAAS;GACP,UAAU,QAAQ;GAClB,GAAI,QAAQ,YAAY,KAAA,IAAY,CAAC,IAAI,EAAE,SAAS,QAAQ,QAAQ;EACtE;EACA,UAAU,EAAE,WAAW,QAAQ,iBAAiB;CAClD,GACA,OACF;AACF"}
@@ -1,5 +1,5 @@
1
1
  import __tsdown_shims_path from 'node:path';
2
2
  import __tsdown_shims_url from 'node:url';
3
- import { CdpOptions, cdp } from "./cdp.mjs";
4
- import { RemoteBrowser } from "@storyshelf/core/adapter/capture-runner";
5
- export { type CdpOptions, type RemoteBrowser, cdp };
3
+ import { CdpFixedOptions, CdpLeasedOptions, CdpOptions, cdp } from "./cdp.mjs";
4
+ import { PoolLease, RefCounted, RefCountedOptions, RemoteBrowser, RemoteConnectContext, RemoteConnectError, RemoteReleaseInfo, refCounted } from "@storyshelf/core/adapter/capture-runner";
5
+ export { type CdpFixedOptions, type CdpLeasedOptions, type CdpOptions, type PoolLease, type RefCounted, type RefCountedOptions, type RemoteBrowser, type RemoteConnectContext, RemoteConnectError, type RemoteReleaseInfo, cdp, refCounted };
@@ -2,4 +2,5 @@ import "node:path";
2
2
  import "node:url";
3
3
  import.meta.url;
4
4
  import { cdp } from "./cdp.mjs";
5
- export { cdp };
5
+ import { RemoteConnectError, refCounted } from "@storyshelf/core/adapter/capture-runner";
6
+ export { RemoteConnectError, cdp, refCounted };
@@ -2,21 +2,51 @@ import "node:path";
2
2
  import "node:url";
3
3
  import.meta.url;
4
4
  import { messageOf } from "./browser.mjs";
5
- import { FONT_PROBE_FAMILIES, inPageProbe, remoteEnvironment } from "@storyshelf/core/utils";
6
- //#region src/remote-environment.ts
5
+ import { FONT_PROBE_FAMILIES, boundedProbe, inPageProbe, remoteEnvironment } from "@storyshelf/core/utils";
6
+ // src/remote-environment.ts
7
7
  /**
8
8
  * The fingerprint of the browser a render drove: its version, and what a blank page in it reports
9
9
  * (OS, pixel ratio, installed fonts). Never `process.platform` or `fc-list`, which describe this
10
- * host. A page that cannot be probed leaves those fields out, and an unknown component is not a
11
- * change (ADR 0005), so a service outage here cannot rewrite baselines.
10
+ * host. The whole measurement is bounded by `remote.timeouts.probeMs`: whatever could not be read
11
+ * in time is left out with a warning, and an unknown component is not a change (ADR 0005), so a
12
+ * service outage here cannot rewrite baselines. A cancel is not an outage: it throws
13
+ * `Capture cancelled` so the run stops and is never stored as an unknown environment.
12
14
  */
13
- async function measureRemote(browser, remote, logger) {
15
+ async function measureRemote(session, remote, options = {}) {
16
+ const seen = {};
17
+ let page;
18
+ const outcome = await boundedProbe({
19
+ timeoutMs: remote.timeouts.probeMs,
20
+ signal: options.signal,
21
+ run: async ({ stopped }) => {
22
+ seen.version = await versionOf(session.browser);
23
+ const opened = await session.newPage();
24
+ page = opened;
25
+ if (stopped()) {
26
+ await closePage(session, opened);
27
+ throw new Error("the measurement stopped before its page opened");
28
+ }
29
+ seen.probe = await opened.evaluate(inPageProbe, [...FONT_PROBE_FAMILIES]);
30
+ }
31
+ });
32
+ await closePage(session, page);
33
+ if (outcome.status === "cancelled") throw new Error("Capture cancelled");
34
+ warnUnlessDone(outcome, remote.timeouts.probeMs, options.logger);
14
35
  return remoteEnvironment({
15
- browserVersion: await versionOf(browser),
16
- probe: await probeBlankPage(browser, logger),
36
+ browserVersion: seen.version,
37
+ probe: seen.probe,
17
38
  declaredPlatform: remote.platform
18
39
  });
19
40
  }
41
+ function warnUnlessDone(outcome, timeoutMs, logger) {
42
+ if (outcome.status === "timeout") logger?.warn({ timeoutMs }, "measuring the remote browser timed out; its environment is unknown");
43
+ else if (outcome.status === "failed") logger?.warn({ reason: messageOf(outcome.error) }, "could not measure the remote browser");
44
+ }
45
+ async function closePage(session, page) {
46
+ if (page === void 0) return;
47
+ await page.close().catch(() => {});
48
+ session.forgetPage(page);
49
+ }
20
50
  async function versionOf(browser) {
21
51
  try {
22
52
  return typeof browser.version === "function" ? await browser.version() : void 0;
@@ -24,20 +54,6 @@ async function versionOf(browser) {
24
54
  return;
25
55
  }
26
56
  }
27
- async function probeBlankPage(browser, logger) {
28
- try {
29
- const page = await browser.newPage();
30
- try {
31
- return await page.evaluate(inPageProbe, [...FONT_PROBE_FAMILIES]);
32
- } finally {
33
- await page.close();
34
- }
35
- } catch (error) {
36
- logger?.warn({ reason: messageOf(error) }, "could not measure the remote browser");
37
- return;
38
- }
39
- }
40
- //#endregion
41
57
  export { measureRemote };
42
58
 
43
59
  //# sourceMappingURL=remote-environment.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"remote-environment.mjs","names":[],"sources":["../src/remote-environment.ts"],"sourcesContent":["/** Measuring a remote browser's environment from inside it (ADR 0007, decision 2). */\nimport type { CaptureEnvironment, RemoteBrowser } from \"@storyshelf/core/adapter/capture-runner\";\nimport type { Logger } from \"@storyshelf/core/logger\";\nimport {\n FONT_PROBE_FAMILIES,\n inPageProbe,\n remoteEnvironment,\n type BrowserProbe,\n} from \"@storyshelf/core/utils\";\nimport type { Browser } from \"puppeteer-core\";\nimport { messageOf } from \"./browser.ts\";\n\n/**\n * The fingerprint of the browser a render drove: its version, and what a blank page in it reports\n * (OS, pixel ratio, installed fonts). Never `process.platform` or `fc-list`, which describe this\n * host. A page that cannot be probed leaves those fields out, and an unknown component is not a\n * change (ADR 0005), so a service outage here cannot rewrite baselines.\n */\nexport async function measureRemote(\n browser: Browser,\n remote: RemoteBrowser,\n logger?: Logger,\n): Promise<CaptureEnvironment> {\n return remoteEnvironment({\n browserVersion: await versionOf(browser),\n probe: await probeBlankPage(browser, logger),\n declaredPlatform: remote.platform,\n });\n}\n\nasync function versionOf(browser: Browser): Promise<string | undefined> {\n try {\n return typeof browser.version === \"function\" ? await browser.version() : undefined;\n } catch {\n return undefined;\n }\n}\n\nasync function probeBlankPage(\n browser: Browser,\n logger?: Logger,\n): Promise<BrowserProbe | undefined> {\n try {\n const page = await browser.newPage();\n try {\n return await page.evaluate(inPageProbe, [...FONT_PROBE_FAMILIES]);\n } finally {\n await page.close();\n }\n } catch (error) {\n logger?.warn({ reason: messageOf(error) }, \"could not measure the remote browser\");\n return undefined;\n }\n}\n"],"mappings":";;;;;;;;;;;;AAkBA,eAAsB,cACpB,SACA,QACA,QAC6B;CAC7B,OAAO,kBAAkB;EACvB,gBAAgB,MAAM,UAAU,OAAO;EACvC,OAAO,MAAM,eAAe,SAAS,MAAM;EAC3C,kBAAkB,OAAO;CAC3B,CAAC;AACH;AAEA,eAAe,UAAU,SAA+C;CACtE,IAAI;EACF,OAAO,OAAO,QAAQ,YAAY,aAAa,MAAM,QAAQ,QAAQ,IAAI,KAAA;CAC3E,QAAQ;EACN;CACF;AACF;AAEA,eAAe,eACb,SACA,QACmC;CACnC,IAAI;EACF,MAAM,OAAO,MAAM,QAAQ,QAAQ;EACnC,IAAI;GACF,OAAO,MAAM,KAAK,SAAS,aAAa,CAAC,GAAG,mBAAmB,CAAC;EAClE,UAAU;GACR,MAAM,KAAK,MAAM;EACnB;CACF,SAAS,OAAO;EACd,QAAQ,KAAK,EAAE,QAAQ,UAAU,KAAK,EAAE,GAAG,sCAAsC;EACjF;CACF;AACF"}
1
+ {"version":3,"file":"remote-environment.mjs","names":[],"sources":["../src/remote-environment.ts"],"sourcesContent":["/** Measuring a remote browser's environment from inside it (ADR 0007, decision 2). */\nimport type { CaptureEnvironment, RemoteBrowser } from \"@storyshelf/core/adapter/capture-runner\";\nimport type { Logger } from \"@storyshelf/core/logger\";\nimport {\n FONT_PROBE_FAMILIES,\n boundedProbe,\n inPageProbe,\n remoteEnvironment,\n type BrowserProbe,\n} from \"@storyshelf/core/utils\";\nimport type { Browser, Page } from \"puppeteer-core\";\nimport { messageOf } from \"./browser.ts\";\nimport type { BrowserSession } from \"./session.ts\";\n\n/** What stops a measurement: the run's cancel signal, and where warnings go. */\nexport interface MeasureOptions {\n logger?: Logger | undefined;\n /** Aborted when the run is cancelled; the measurement then stops the run. */\n signal?: AbortSignal | undefined;\n}\n\n/**\n * The fingerprint of the browser a render drove: its version, and what a blank page in it reports\n * (OS, pixel ratio, installed fonts). Never `process.platform` or `fc-list`, which describe this\n * host. The whole measurement is bounded by `remote.timeouts.probeMs`: whatever could not be read\n * in time is left out with a warning, and an unknown component is not a change (ADR 0005), so a\n * service outage here cannot rewrite baselines. A cancel is not an outage: it throws\n * `Capture cancelled` so the run stops and is never stored as an unknown environment.\n */\nexport async function measureRemote(\n session: BrowserSession,\n remote: RemoteBrowser,\n options: MeasureOptions = {},\n): Promise<CaptureEnvironment> {\n const seen: { version?: string | undefined; probe?: BrowserProbe | undefined } = {};\n let page: Page | undefined;\n const outcome = await boundedProbe({\n timeoutMs: remote.timeouts.probeMs,\n signal: options.signal,\n run: async ({ stopped }) => {\n seen.version = await versionOf(session.browser);\n const opened = await session.newPage();\n page = opened;\n if (stopped()) {\n await closePage(session, opened);\n throw new Error(\"the measurement stopped before its page opened\");\n }\n seen.probe = await opened.evaluate(inPageProbe, [...FONT_PROBE_FAMILIES]);\n },\n });\n // Closed on every path; a page that opens after the limit is closed here too, once it is known.\n await closePage(session, page);\n if (outcome.status === \"cancelled\") {\n throw new Error(\"Capture cancelled\");\n }\n warnUnlessDone(outcome, remote.timeouts.probeMs, options.logger);\n return remoteEnvironment({\n browserVersion: seen.version,\n probe: seen.probe,\n declaredPlatform: remote.platform,\n });\n}\n\nfunction warnUnlessDone(\n outcome: { status: \"done\" | \"timeout\" | \"failed\"; error?: unknown },\n timeoutMs: number,\n logger: Logger | undefined,\n): void {\n if (outcome.status === \"timeout\") {\n logger?.warn(\n { timeoutMs },\n \"measuring the remote browser timed out; its environment is unknown\",\n );\n } else if (outcome.status === \"failed\") {\n logger?.warn({ reason: messageOf(outcome.error) }, \"could not measure the remote browser\");\n }\n}\n\nasync function closePage(session: BrowserSession, page: Page | undefined): Promise<void> {\n if (page === undefined) return;\n await page.close().catch(() => {});\n session.forgetPage(page);\n}\n\nasync function versionOf(browser: Browser): Promise<string | undefined> {\n try {\n return typeof browser.version === \"function\" ? await browser.version() : undefined;\n } catch {\n return undefined;\n }\n}\n"],"mappings":";;;;;;;;;;;;;;AA6BA,eAAsB,cACpB,SACA,QACA,UAA0B,CAAC,GACE;CAC7B,MAAM,OAA2E,CAAC;CAClF,IAAI;CACJ,MAAM,UAAU,MAAM,aAAa;EACjC,WAAW,OAAO,SAAS;EAC3B,QAAQ,QAAQ;EAChB,KAAK,OAAO,EAAE,cAAc;GAC1B,KAAK,UAAU,MAAM,UAAU,QAAQ,OAAO;GAC9C,MAAM,SAAS,MAAM,QAAQ,QAAQ;GACrC,OAAO;GACP,IAAI,QAAQ,GAAG;IACb,MAAM,UAAU,SAAS,MAAM;IAC/B,MAAM,IAAI,MAAM,gDAAgD;GAClE;GACA,KAAK,QAAQ,MAAM,OAAO,SAAS,aAAa,CAAC,GAAG,mBAAmB,CAAC;EAC1E;CACF,CAAC;CAED,MAAM,UAAU,SAAS,IAAI;CAC7B,IAAI,QAAQ,WAAW,aACrB,MAAM,IAAI,MAAM,mBAAmB;CAErC,eAAe,SAAS,OAAO,SAAS,SAAS,QAAQ,MAAM;CAC/D,OAAO,kBAAkB;EACvB,gBAAgB,KAAK;EACrB,OAAO,KAAK;EACZ,kBAAkB,OAAO;CAC3B,CAAC;AACH;AAEA,SAAS,eACP,SACA,WACA,QACM;CACN,IAAI,QAAQ,WAAW,WACrB,QAAQ,KACN,EAAE,UAAU,GACZ,oEACF;MACK,IAAI,QAAQ,WAAW,UAC5B,QAAQ,KAAK,EAAE,QAAQ,UAAU,QAAQ,KAAK,EAAE,GAAG,sCAAsC;AAE7F;AAEA,eAAe,UAAU,SAAyB,MAAuC;CACvF,IAAI,SAAS,KAAA,GAAW;CACxB,MAAM,KAAK,MAAM,CAAC,CAAC,YAAY,CAAC,CAAC;CACjC,QAAQ,WAAW,IAAI;AACzB;AAEA,eAAe,UAAU,SAA+C;CACtE,IAAI;EACF,OAAO,OAAO,QAAQ,YAAY,aAAa,MAAM,QAAQ,QAAQ,IAAI,KAAA;CAC3E,QAAQ;EACN;CACF;AACF"}
@@ -2,7 +2,7 @@ import "node:path";
2
2
  import "node:url";
3
3
  import.meta.url;
4
4
  import { mergeParameters } from "@storyshelf/core/capture";
5
- //#region src/runtime-params.ts
5
+ // src/runtime-params.ts
6
6
  /** Per-run runtime-parameter extraction cache (ADR 0017). */
7
7
  /** Create the per-run runtime-parameter cache shared across captures. */
8
8
  function createRuntimeParamsState() {
@@ -54,7 +54,6 @@ async function readExtractedParameters(page) {
54
54
  function hasEntries(map) {
55
55
  return Boolean(map && Object.keys(map).length > 0);
56
56
  }
57
- //#endregion
58
57
  export { createRuntimeParamsState, runtimeParametersForStory };
59
58
 
60
59
  //# sourceMappingURL=runtime-params.mjs.map