@storyshelf/runner-puppeteer 0.6.2 → 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.
Files changed (47) hide show
  1. package/README.md +73 -1
  2. package/dist/a11y.mjs +22 -22
  3. package/dist/a11y.mjs.map +1 -1
  4. package/dist/availability.mjs +30 -0
  5. package/dist/availability.mjs.map +1 -0
  6. package/dist/browser.mjs +27 -16
  7. package/dist/browser.mjs.map +1 -1
  8. package/dist/capture-runner.d.mts +20 -5
  9. package/dist/capture-runner.mjs +87 -14
  10. package/dist/capture-runner.mjs.map +1 -1
  11. package/dist/connect.mjs +45 -0
  12. package/dist/connect.mjs.map +1 -0
  13. package/dist/index.d.mts +2 -1
  14. package/dist/index.mjs +2 -1
  15. package/dist/launch.mjs +52 -0
  16. package/dist/launch.mjs.map +1 -0
  17. package/dist/pages.d.mts +5 -0
  18. package/dist/pages.mjs +90 -0
  19. package/dist/pages.mjs.map +1 -0
  20. package/dist/pipeline.mjs +178 -39
  21. package/dist/pipeline.mjs.map +1 -1
  22. package/dist/play.mjs +16 -4
  23. package/dist/play.mjs.map +1 -1
  24. package/dist/remote/cdp.d.mts +54 -0
  25. package/dist/remote/cdp.mjs +48 -0
  26. package/dist/remote/cdp.mjs.map +1 -0
  27. package/dist/remote/index.d.mts +5 -0
  28. package/dist/remote/index.mjs +6 -0
  29. package/dist/remote-environment.mjs +59 -0
  30. package/dist/remote-environment.mjs.map +1 -0
  31. package/dist/runtime-params.mjs +1 -2
  32. package/dist/screenshot.mjs +77 -24
  33. package/dist/screenshot.mjs.map +1 -1
  34. package/dist/session.d.mts +3 -0
  35. package/dist/session.mjs +141 -0
  36. package/dist/session.mjs.map +1 -0
  37. package/dist/settle.mjs +157 -0
  38. package/dist/settle.mjs.map +1 -0
  39. package/dist/static-server.mjs +2 -4
  40. package/dist/static-server.mjs.map +1 -1
  41. package/dist/targets.d.mts +23 -0
  42. package/dist/targets.mjs +79 -0
  43. package/dist/targets.mjs.map +1 -0
  44. package/dist/tests.mjs +119 -0
  45. package/dist/tests.mjs.map +1 -0
  46. package/dist/types.d.mts +4 -2
  47. package/package.json +18 -11
@@ -0,0 +1,59 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { messageOf } from "./browser.mjs";
5
+ import { FONT_PROBE_FAMILIES, boundedProbe, inPageProbe, remoteEnvironment } from "@storyshelf/core/utils";
6
+ // src/remote-environment.ts
7
+ /**
8
+ * The fingerprint of the browser a render drove: its version, and what a blank page in it reports
9
+ * (OS, pixel ratio, installed fonts). Never `process.platform` or `fc-list`, which describe this
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.
14
+ */
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);
35
+ return remoteEnvironment({
36
+ browserVersion: seen.version,
37
+ probe: seen.probe,
38
+ declaredPlatform: remote.platform
39
+ });
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
+ }
50
+ async function versionOf(browser) {
51
+ try {
52
+ return typeof browser.version === "function" ? await browser.version() : void 0;
53
+ } catch {
54
+ return;
55
+ }
56
+ }
57
+ export { measureRemote };
58
+
59
+ //# sourceMappingURL=remote-environment.mjs.map
@@ -0,0 +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 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
@@ -1,30 +1,33 @@
1
1
  import "node:path";
2
2
  import "node:url";
3
3
  import.meta.url;
4
+ import { awaitPaint, settleTimeoutOf, waitForSettled } from "./settle.mjs";
5
+ import { acquirePage } from "./pages.mjs";
4
6
  import { runtimeParametersForStory } from "./runtime-params.mjs";
5
7
  import { checkA11y } from "./a11y.mjs";
6
8
  import { runPlay } from "./play.mjs";
7
- import { getScreenshotPlan } from "@storyshelf/core/capture";
8
- //#region src/screenshot.ts
9
+ import { cleanIgnoreSelectors, getScreenshotPlan, ignoreStylesheet } from "@storyshelf/core/capture";
10
+ // src/screenshot.ts
9
11
  /** Capture a screenshot for one story/viewport, returning buffer + a11y violations. */
10
12
  async function captureScreenshot(ctx, inputStory, viewport) {
11
- const page = await ctx.browser.newPage();
12
- await page.setViewport({
13
- width: viewport.width,
14
- height: viewport.height
15
- });
13
+ const lease = await acquirePage(ctx, viewport);
14
+ let reusable = false;
15
+ if (ctx.navigationTimeoutMs) lease.page.setDefaultTimeout(ctx.navigationTimeoutMs);
16
16
  try {
17
- return await runCapture(page, ctx, inputStory, viewport);
17
+ const result = await runCapture(lease.page, ctx, inputStory, viewport);
18
+ reusable = true;
19
+ return result;
18
20
  } finally {
19
- await page.close();
21
+ await lease.release(reusable);
20
22
  }
21
23
  }
22
24
  async function runCapture(page, ctx, inputStory, viewport) {
23
- const story = await navigateAndEnrich(ctx, page, inputStory);
24
- await maybeWaitForSelector(ctx, page, story);
25
+ const story = await navigateAndEnrich(ctx, page, inputStory, viewport);
26
+ await waitUntilReady(ctx, page, story);
25
27
  if (ctx.executePlay) await runPlay(page, story.id, ctx.playTimeoutMs ?? 1e4);
26
- const a11yViolations = await getA11y(ctx, page);
28
+ const a11yViolations = await getA11y(ctx, page, story);
27
29
  await settlePage(page, story);
30
+ await maskIgnoredRegions(ctx, page, story);
28
31
  const plan = await resolvePlan(page, viewport, story);
29
32
  if (plan.viewport.width !== viewport.width || plan.viewport.height !== viewport.height) await page.setViewport({
30
33
  width: plan.viewport.width,
@@ -36,8 +39,8 @@ async function runCapture(page, ctx, inputStory, viewport) {
36
39
  story
37
40
  };
38
41
  }
39
- async function navigateAndEnrich(ctx, page, story) {
40
- await page.goto(ctx.adapter.buildUrl(ctx.baseUrl, story.id), { waitUntil: "networkidle0" });
42
+ async function navigateAndEnrich(ctx, page, story, viewport) {
43
+ await page.goto(ctx.adapter.buildUrl(ctx.baseUrl, story.id, viewport.globals), { waitUntil: "networkidle0" });
41
44
  await ctx.adapter.waitForReady?.(page);
42
45
  if (story.parameters) return story;
43
46
  const rpPage = page;
@@ -47,22 +50,41 @@ async function navigateAndEnrich(ctx, page, story) {
47
50
  parameters: params
48
51
  } : story;
49
52
  }
50
- async function maybeWaitForSelector(ctx, page, story) {
53
+ /**
54
+ * Wait until the story is ready to be photographed: the selector exists, then Storybook says the
55
+ * render (and its play function) finished, then an explicit `parameters.delay` runs on top. A page
56
+ * that never signals (no channel, or no event before the timeout) gets today's fixed wait instead:
57
+ * `delay ?? 500` ms.
58
+ */
59
+ async function waitUntilReady(ctx, page, story) {
51
60
  if (!ctx.adapter.screenshotSelector) return;
52
61
  await page.waitForSelector(ctx.adapter.screenshotSelector, { visible: false });
53
- await delay(story.parameters?.delay ?? 500);
62
+ const report = await waitForSettled(page, settleTimeoutOf(ctx));
63
+ if (report.settled) {
64
+ await awaitPaint(page);
65
+ if (story.parameters?.delay !== void 0) await delay(story.parameters.delay);
66
+ } else await delay(story.parameters?.delay ?? 500);
67
+ logSettle(ctx, story, report);
54
68
  if (story.parameters?.pauseAnimationAtEnd) await page.evaluate(() => {
55
69
  const doc = globalThis.document;
56
70
  for (const anim of doc.getAnimations()) anim.pause();
57
71
  });
58
72
  }
59
- async function getA11y(ctx, page) {
60
- if (!ctx.runA11y) return [];
61
- try {
62
- return await checkA11y(page);
63
- } catch {
64
- return [];
65
- }
73
+ /** One line per story that did not render cleanly: where it stopped, and what Storybook said. */
74
+ function logSettle(ctx, story, report) {
75
+ const problem = report.renderError ?? report.playError ?? report.unhandled?.[0];
76
+ if (report.settled && problem === void 0) return;
77
+ const detail = {
78
+ storyId: story.id,
79
+ phase: report.phase,
80
+ waitedMs: report.elapsedMs,
81
+ problem
82
+ };
83
+ if (!report.settled && report.hooked) ctx.logger?.warn(detail, "story did not signal that it finished rendering; used a fixed wait");
84
+ else if (problem !== void 0) ctx.logger?.warn(detail, "story reported an error while rendering");
85
+ }
86
+ async function getA11y(ctx, page, story) {
87
+ return ctx.runA11y ? await checkA11y(page, ctx.logger, story.id) : [];
66
88
  }
67
89
  async function settlePage(page, story) {
68
90
  const burst = story.parameters?.burst ?? 1;
@@ -77,6 +99,38 @@ async function settlePage(page, story) {
77
99
  });
78
100
  } catch {}
79
101
  }
102
+ /**
103
+ * Hide the story's ignore regions (`ignoreSelectors` plus the attribute markers) so a changing
104
+ * timestamp or avatar cannot cause a diff. A selector the browser rejects is skipped with a
105
+ * warning on the snapshot log; it never fails the build.
106
+ */
107
+ async function maskIgnoredRegions(ctx, page, story) {
108
+ const { selectors: declared, skipped } = cleanIgnoreSelectors(story.parameters?.ignoreSelectors);
109
+ try {
110
+ const refused = await page.evaluate((list) => {
111
+ const doc = globalThis.document;
112
+ return list.filter((selector) => {
113
+ try {
114
+ doc.querySelector(selector);
115
+ return false;
116
+ } catch {
117
+ return true;
118
+ }
119
+ });
120
+ }, declared);
121
+ const rejected = [...skipped, ...refused];
122
+ if (rejected.length > 0) ctx.logger?.warn({
123
+ storyId: story.id,
124
+ selectors: rejected
125
+ }, "skipped invalid ignore selectors");
126
+ await page.addStyleTag({ content: ignoreStylesheet(declared.filter((selector) => !rejected.includes(selector))) });
127
+ } catch (err) {
128
+ ctx.logger?.warn({
129
+ storyId: story.id,
130
+ err
131
+ }, "could not mask ignore regions");
132
+ }
133
+ }
80
134
  async function resolvePlan(page, viewport, story) {
81
135
  const box = await readBoundingBox(page);
82
136
  return getScreenshotPlan(viewport, box, {
@@ -127,7 +181,6 @@ async function delay(ms) {
127
181
  }, ms);
128
182
  });
129
183
  }
130
- //#endregion
131
184
  export { captureScreenshot };
132
185
 
133
186
  //# sourceMappingURL=screenshot.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"screenshot.mjs","names":[],"sources":["../src/screenshot.ts"],"sourcesContent":["/** Screenshot pipeline for a single story + viewport (Puppeteer). */\n\nimport type { StoryEntry, Viewport } from \"@storyshelf/core/adapter/capture-runner\";\nimport { getScreenshotPlan } from \"@storyshelf/core/capture\";\nimport type { Page } from \"puppeteer-core\";\nimport { checkA11y } from \"./a11y.ts\";\nimport { runPlay } from \"./play.ts\";\nimport { runtimeParametersForStory } from \"./runtime-params.ts\";\nimport type { CaptureContext, RuntimeParamsPage } from \"./types.ts\";\n\n/** Capture a screenshot for one story/viewport, returning buffer + a11y violations. */\nexport async function captureScreenshot(\n ctx: CaptureContext,\n inputStory: StoryEntry,\n viewport: Viewport,\n): Promise<{ screenshot: Buffer; a11yViolations: string[]; story: StoryEntry }> {\n const page = await ctx.browser.newPage();\n await page.setViewport({ width: viewport.width, height: viewport.height });\n try {\n return await runCapture(page, ctx, inputStory, viewport);\n } finally {\n await page.close();\n }\n}\n\nasync function runCapture(\n page: Page,\n ctx: CaptureContext,\n inputStory: StoryEntry,\n viewport: Viewport,\n): Promise<{ screenshot: Buffer; a11yViolations: string[]; story: StoryEntry }> {\n const story = await navigateAndEnrich(ctx, page, inputStory);\n await maybeWaitForSelector(ctx, page, story);\n if (ctx.executePlay) await runPlay(page, story.id, ctx.playTimeoutMs ?? 10_000);\n const a11yViolations = await getA11y(ctx, page);\n await settlePage(page, story);\n const plan = await resolvePlan(page, viewport, story);\n if (plan.viewport.width !== viewport.width || plan.viewport.height !== viewport.height)\n await page.setViewport({ width: plan.viewport.width, height: plan.viewport.height });\n const screenshot = await burstShots(page, plan, story);\n return { screenshot, a11yViolations, story };\n}\n\nasync function navigateAndEnrich(\n ctx: CaptureContext,\n page: Page,\n story: StoryEntry,\n): Promise<StoryEntry> {\n await page.goto(ctx.adapter.buildUrl(ctx.baseUrl, story.id), { waitUntil: \"networkidle0\" });\n await ctx.adapter.waitForReady?.(page);\n if (story.parameters) return story;\n const rpPage = page as unknown as RuntimeParamsPage;\n const params = await runtimeParametersForStory(ctx.runtimeParams, rpPage, story.id);\n return params ? { ...story, parameters: params } : story;\n}\nasync function maybeWaitForSelector(\n ctx: CaptureContext,\n page: Page,\n story: StoryEntry,\n): Promise<void> {\n if (!ctx.adapter.screenshotSelector) return;\n await page.waitForSelector(ctx.adapter.screenshotSelector, { visible: false });\n await delay(story.parameters?.delay ?? 500);\n if (story.parameters?.pauseAnimationAtEnd) {\n await page.evaluate(() => {\n const doc = globalThis.document as unknown as {\n getAnimations: () => { pause: () => void }[];\n };\n for (const anim of doc.getAnimations()) anim.pause();\n });\n }\n}\nasync function getA11y(ctx: CaptureContext, page: Page): Promise<string[]> {\n if (!ctx.runA11y) return [];\n try {\n return await checkA11y(page);\n } catch {\n return [];\n }\n}\nasync function settlePage(page: Page, story: StoryEntry): Promise<void> {\n const burst = story.parameters?.burst ?? 1;\n const waitFonts = story.parameters?.waitForFonts ?? burst > 1;\n if (waitFonts) {\n try {\n await page.evaluate(async () => {\n await (globalThis as unknown as { document: { fonts: { ready: Promise<void> } } }).document\n .fonts.ready;\n });\n } catch {\n // best-effort\n }\n }\n try {\n await page.evaluate(() => {\n for (const vid of (globalThis as unknown as { document: Document }).document.querySelectorAll(\n \"video\",\n ))\n // oxlint-disable-next-line typescript/no-unnecessary-type-assertion -- narrow to HTMLVideoElement\n (vid as unknown as HTMLVideoElement).pause();\n });\n } catch {\n // best-effort\n }\n}\nasync function resolvePlan(\n page: Page,\n viewport: Viewport,\n story: StoryEntry,\n): Promise<ReturnType<typeof getScreenshotPlan>> {\n const box = await readBoundingBox(page);\n return getScreenshotPlan(viewport, box, {\n autoCrop: story.parameters?.autoCrop,\n whitespaceThreshold: story.parameters?.autoCropThreshold,\n fullPageOnOverflow: story.parameters?.fullPageOnOverflow,\n minWidth: story.parameters?.minWidth,\n minHeight: story.parameters?.minHeight,\n maxWidth: story.parameters?.maxWidth,\n maxHeight: story.parameters?.maxHeight,\n });\n}\n\nasync function readBoundingBox(\n page: Page,\n): Promise<{ x: number; y: number; width: number; height: number } | null> {\n try {\n const el = await page.$(\"#storybook-root\");\n if (!el) return null;\n return await el.boundingBox();\n } catch {\n return null;\n }\n}\nasync function burstShots(\n page: Page,\n plan: ReturnType<typeof getScreenshotPlan>,\n story: StoryEntry,\n): Promise<Buffer> {\n const count = Math.max(1, Math.min(5, story.parameters?.burst ?? 1));\n const shots: Buffer[] = [];\n for (let i = 0; i < count; i += 1) {\n if (i > 0) {\n // oxlint-disable-next-line eslint/no-await-in-loop -- sequential burst\n await delay(100);\n }\n // oxlint-disable-next-line eslint/no-await-in-loop -- sequential burst\n shots.push(await shotOne(page, plan, story));\n }\n const preferred = shots[Math.floor(shots.length / 2)] ?? shots[0];\n if (preferred) return preferred;\n const fallback: unknown = await page.screenshot({\n type: \"png\",\n ...(plan.fullPage ? { fullPage: true } : {}),\n ...(plan.clip ? { clip: plan.clip } : {}),\n });\n // oxlint-disable-next-line typescript/no-unnecessary-type-assertion -- Buffer narrowing\n return fallback as unknown as Buffer;\n}\nasync function shotOne(\n page: Page,\n plan: ReturnType<typeof getScreenshotPlan>,\n _story: StoryEntry,\n): Promise<Buffer> {\n const shot: unknown = await page.screenshot({\n type: \"png\",\n ...(plan.fullPage ? { fullPage: true } : {}),\n ...(plan.clip ? { clip: plan.clip } : {}),\n });\n // oxlint-disable-next-line typescript/no-unnecessary-type-assertion -- Buffer narrowing\n return shot as unknown as Buffer;\n}\n\nasync function delay(ms: number): Promise<void> {\n await new Promise<void>((resolve) => {\n setTimeout(() => {\n resolve();\n }, ms);\n });\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"screenshot.mjs","names":[],"sources":["../src/screenshot.ts"],"sourcesContent":["/** Screenshot pipeline for a single story + viewport (Puppeteer). */\n\nimport type { StoryEntry, Viewport } from \"@storyshelf/core/adapter/capture-runner\";\nimport {\n cleanIgnoreSelectors,\n getScreenshotPlan,\n ignoreStylesheet,\n} from \"@storyshelf/core/capture\";\nimport type { Page } from \"puppeteer-core\";\nimport { checkA11y } from \"./a11y.ts\";\nimport { acquirePage } from \"./pages.ts\";\nimport { runPlay } from \"./play.ts\";\nimport { runtimeParametersForStory } from \"./runtime-params.ts\";\nimport { awaitPaint, settleTimeoutOf, waitForSettled, type SettleReport } from \"./settle.ts\";\nimport type { CaptureContext, RuntimeParamsPage } from \"./types.ts\";\n\n/** Capture a screenshot for one story/viewport, returning buffer + a11y violations. */\nexport async function captureScreenshot(\n ctx: CaptureContext,\n inputStory: StoryEntry,\n viewport: Viewport,\n): Promise<{ screenshot: Buffer; a11yViolations: string[]; story: StoryEntry }> {\n const lease = await acquirePage(ctx, viewport);\n let reusable = false;\n if (ctx.navigationTimeoutMs) lease.page.setDefaultTimeout(ctx.navigationTimeoutMs);\n try {\n const result = await runCapture(lease.page, ctx, inputStory, viewport);\n reusable = true;\n return result;\n } finally {\n await lease.release(reusable);\n }\n}\n\nasync function runCapture(\n page: Page,\n ctx: CaptureContext,\n inputStory: StoryEntry,\n viewport: Viewport,\n): Promise<{ screenshot: Buffer; a11yViolations: string[]; story: StoryEntry }> {\n const story = await navigateAndEnrich(ctx, page, inputStory, viewport);\n await waitUntilReady(ctx, page, story);\n if (ctx.executePlay) await runPlay(page, story.id, ctx.playTimeoutMs ?? 10_000);\n const a11yViolations = await getA11y(ctx, page, story);\n await settlePage(page, story);\n await maskIgnoredRegions(ctx, page, story);\n const plan = await resolvePlan(page, viewport, story);\n if (plan.viewport.width !== viewport.width || plan.viewport.height !== viewport.height)\n await page.setViewport({ width: plan.viewport.width, height: plan.viewport.height });\n const screenshot = await burstShots(page, plan, story);\n return { screenshot, a11yViolations, story };\n}\n\nasync function navigateAndEnrich(\n ctx: CaptureContext,\n page: Page,\n story: StoryEntry,\n viewport: Viewport,\n): Promise<StoryEntry> {\n await page.goto(ctx.adapter.buildUrl(ctx.baseUrl, story.id, viewport.globals), {\n waitUntil: \"networkidle0\",\n });\n await ctx.adapter.waitForReady?.(page);\n if (story.parameters) return story;\n const rpPage = page as unknown as RuntimeParamsPage;\n const params = await runtimeParametersForStory(ctx.runtimeParams, rpPage, story.id);\n return params ? { ...story, parameters: params } : story;\n}\n/**\n * Wait until the story is ready to be photographed: the selector exists, then Storybook says the\n * render (and its play function) finished, then an explicit `parameters.delay` runs on top. A page\n * that never signals (no channel, or no event before the timeout) gets today's fixed wait instead:\n * `delay ?? 500` ms.\n */\nasync function waitUntilReady(ctx: CaptureContext, page: Page, story: StoryEntry): Promise<void> {\n if (!ctx.adapter.screenshotSelector) return;\n await page.waitForSelector(ctx.adapter.screenshotSelector, { visible: false });\n const report = await waitForSettled(page, settleTimeoutOf(ctx));\n if (report.settled) {\n await awaitPaint(page);\n if (story.parameters?.delay !== undefined) await delay(story.parameters.delay);\n } else {\n await delay(story.parameters?.delay ?? 500);\n }\n logSettle(ctx, story, report);\n if (story.parameters?.pauseAnimationAtEnd) {\n await page.evaluate(() => {\n const doc = globalThis.document as unknown as {\n getAnimations: () => { pause: () => void }[];\n };\n for (const anim of doc.getAnimations()) anim.pause();\n });\n }\n}\n\n/** One line per story that did not render cleanly: where it stopped, and what Storybook said. */\nfunction logSettle(ctx: CaptureContext, story: StoryEntry, report: SettleReport): void {\n const problem = report.renderError ?? report.playError ?? report.unhandled?.[0];\n if (report.settled && problem === undefined) return;\n const detail = { storyId: story.id, phase: report.phase, waitedMs: report.elapsedMs, problem };\n if (!report.settled && report.hooked) {\n ctx.logger?.warn(detail, \"story did not signal that it finished rendering; used a fixed wait\");\n } else if (problem !== undefined) {\n ctx.logger?.warn(detail, \"story reported an error while rendering\");\n }\n}\nasync function getA11y(ctx: CaptureContext, page: Page, story: StoryEntry): Promise<string[]> {\n // checkA11y reports its own failures as an annotation, so it never throws.\n return ctx.runA11y ? await checkA11y(page, ctx.logger, story.id) : [];\n}\nasync function settlePage(page: Page, story: StoryEntry): Promise<void> {\n const burst = story.parameters?.burst ?? 1;\n const waitFonts = story.parameters?.waitForFonts ?? burst > 1;\n if (waitFonts) {\n try {\n await page.evaluate(async () => {\n await (globalThis as unknown as { document: { fonts: { ready: Promise<void> } } }).document\n .fonts.ready;\n });\n } catch {\n // best-effort\n }\n }\n try {\n await page.evaluate(() => {\n for (const vid of (globalThis as unknown as { document: Document }).document.querySelectorAll(\n \"video\",\n ))\n // oxlint-disable-next-line typescript/no-unnecessary-type-assertion -- narrow to HTMLVideoElement\n (vid as unknown as HTMLVideoElement).pause();\n });\n } catch {\n // best-effort\n }\n}\n/**\n * Hide the story's ignore regions (`ignoreSelectors` plus the attribute markers) so a changing\n * timestamp or avatar cannot cause a diff. A selector the browser rejects is skipped with a\n * warning on the snapshot log; it never fails the build.\n */\nasync function maskIgnoredRegions(\n ctx: CaptureContext,\n page: Page,\n story: StoryEntry,\n): Promise<void> {\n const { selectors: declared, skipped } = cleanIgnoreSelectors(story.parameters?.ignoreSelectors);\n try {\n const refused = await page.evaluate((list: string[]) => {\n const doc = (globalThis as unknown as { document: { querySelector(s: string): unknown } })\n .document;\n return list.filter((selector) => {\n try {\n doc.querySelector(selector);\n return false;\n } catch {\n return true;\n }\n });\n }, declared);\n const rejected = [...skipped, ...refused];\n if (rejected.length > 0)\n ctx.logger?.warn(\n { storyId: story.id, selectors: rejected },\n \"skipped invalid ignore selectors\",\n );\n await page.addStyleTag({\n content: ignoreStylesheet(declared.filter((selector) => !rejected.includes(selector))),\n });\n } catch (err) {\n ctx.logger?.warn({ storyId: story.id, err }, \"could not mask ignore regions\");\n }\n}\nasync function resolvePlan(\n page: Page,\n viewport: Viewport,\n story: StoryEntry,\n): Promise<ReturnType<typeof getScreenshotPlan>> {\n const box = await readBoundingBox(page);\n return getScreenshotPlan(viewport, box, {\n autoCrop: story.parameters?.autoCrop,\n whitespaceThreshold: story.parameters?.autoCropThreshold,\n fullPageOnOverflow: story.parameters?.fullPageOnOverflow,\n minWidth: story.parameters?.minWidth,\n minHeight: story.parameters?.minHeight,\n maxWidth: story.parameters?.maxWidth,\n maxHeight: story.parameters?.maxHeight,\n });\n}\n\nasync function readBoundingBox(\n page: Page,\n): Promise<{ x: number; y: number; width: number; height: number } | null> {\n try {\n const el = await page.$(\"#storybook-root\");\n if (!el) return null;\n return await el.boundingBox();\n } catch {\n return null;\n }\n}\nasync function burstShots(\n page: Page,\n plan: ReturnType<typeof getScreenshotPlan>,\n story: StoryEntry,\n): Promise<Buffer> {\n const count = Math.max(1, Math.min(5, story.parameters?.burst ?? 1));\n const shots: Buffer[] = [];\n for (let i = 0; i < count; i += 1) {\n if (i > 0) {\n // oxlint-disable-next-line eslint/no-await-in-loop -- sequential burst\n await delay(100);\n }\n // oxlint-disable-next-line eslint/no-await-in-loop -- sequential burst\n shots.push(await shotOne(page, plan, story));\n }\n const preferred = shots[Math.floor(shots.length / 2)] ?? shots[0];\n if (preferred) return preferred;\n const fallback: unknown = await page.screenshot({\n type: \"png\",\n ...(plan.fullPage ? { fullPage: true } : {}),\n ...(plan.clip ? { clip: plan.clip } : {}),\n });\n // oxlint-disable-next-line typescript/no-unnecessary-type-assertion -- Buffer narrowing\n return fallback as unknown as Buffer;\n}\nasync function shotOne(\n page: Page,\n plan: ReturnType<typeof getScreenshotPlan>,\n _story: StoryEntry,\n): Promise<Buffer> {\n const shot: unknown = await page.screenshot({\n type: \"png\",\n ...(plan.fullPage ? { fullPage: true } : {}),\n ...(plan.clip ? { clip: plan.clip } : {}),\n });\n // oxlint-disable-next-line typescript/no-unnecessary-type-assertion -- Buffer narrowing\n return shot as unknown as Buffer;\n}\n\nasync function delay(ms: number): Promise<void> {\n await new Promise<void>((resolve) => {\n setTimeout(() => {\n resolve();\n }, ms);\n });\n}\n"],"mappings":"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"}
@@ -0,0 +1,3 @@
1
+ import __tsdown_shims_path from 'node:path';
2
+ import __tsdown_shims_url from 'node:url';
3
+ import "puppeteer-core";
@@ -0,0 +1,141 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ // src/session.ts
5
+ /** How long one cleanup step may take before release moves on. */
6
+ const RELEASE_TIMEOUT_MS = 5e3;
7
+ /** Wrap an acquisition so the run tracks the pages and contexts it creates. */
8
+ function createBrowserSession(acquisition) {
9
+ return new OwnedSession(acquisition);
10
+ }
11
+ var OwnedSession = class {
12
+ browser;
13
+ ownership;
14
+ #lease;
15
+ #pages = /* @__PURE__ */ new Set();
16
+ #contexts = /* @__PURE__ */ new Set();
17
+ #pending = /* @__PURE__ */ new Set();
18
+ #released;
19
+ #outcome;
20
+ #error;
21
+ constructor({ browser, ownership, releaseLease: lease }) {
22
+ this.browser = browser;
23
+ this.ownership = ownership;
24
+ this.#lease = lease;
25
+ }
26
+ async newPage() {
27
+ this.#assertOpen();
28
+ return await this.#track(this.#openPage());
29
+ }
30
+ async createBrowserContext() {
31
+ this.#assertOpen();
32
+ return await this.#track(this.#openContext());
33
+ }
34
+ async #openPage() {
35
+ const page = await this.browser.newPage();
36
+ if (this.#released !== void 0) {
37
+ await closePage(page);
38
+ throw releasedError();
39
+ }
40
+ this.#pages.add(page);
41
+ return page;
42
+ }
43
+ async #openContext() {
44
+ const context = await this.browser.createBrowserContext();
45
+ if (this.#released !== void 0) {
46
+ await closeContext(context);
47
+ throw releasedError();
48
+ }
49
+ this.#contexts.add(context);
50
+ return context;
51
+ }
52
+ async #track(work) {
53
+ this.#pending.add(work);
54
+ try {
55
+ return await work;
56
+ } finally {
57
+ this.#pending.delete(work);
58
+ }
59
+ }
60
+ forgetPage(page) {
61
+ this.#pages.delete(page);
62
+ }
63
+ forgetContext(context) {
64
+ this.#contexts.delete(context);
65
+ }
66
+ async release(outcome, error) {
67
+ if (this.#outcome === void 0) {
68
+ this.#outcome = outcome;
69
+ this.#error = error;
70
+ }
71
+ this.#released ??= this.#releaseAll().catch(() => {});
72
+ await this.#released;
73
+ }
74
+ #assertOpen() {
75
+ if (this.#released !== void 0) throw releasedError();
76
+ }
77
+ async #releaseAll() {
78
+ await (this.ownership === "launched" ? closeLaunched(this.browser) : this.#releaseConnected());
79
+ await releaseLease(this.#lease, this.#outcome ?? "finished", this.#error);
80
+ }
81
+ async #releaseConnected() {
82
+ await bounded(async () => {
83
+ await Promise.allSettled(this.#pending);
84
+ });
85
+ await Promise.all([...this.#pages].map(async (page) => {
86
+ await this.#closePage(page);
87
+ }));
88
+ await Promise.all([...this.#contexts].map(async (context) => {
89
+ await this.#closeContext(context);
90
+ }));
91
+ await bounded(async () => {
92
+ await this.browser.disconnect();
93
+ });
94
+ }
95
+ async #closePage(page) {
96
+ await closePage(page);
97
+ this.#pages.delete(page);
98
+ }
99
+ async #closeContext(context) {
100
+ await closeContext(context);
101
+ this.#contexts.delete(context);
102
+ }
103
+ };
104
+ function releasedError() {
105
+ return /* @__PURE__ */ new Error("browser session released");
106
+ }
107
+ async function closeLaunched(browser) {
108
+ await bounded(async () => {
109
+ await browser.close();
110
+ });
111
+ }
112
+ async function closePage(page) {
113
+ await bounded(async () => {
114
+ await page.close();
115
+ });
116
+ }
117
+ async function closeContext(context) {
118
+ await bounded(async () => {
119
+ await context.close();
120
+ });
121
+ }
122
+ async function releaseLease(lease, outcome, error) {
123
+ if (lease === void 0) return;
124
+ await bounded(async () => {
125
+ await lease(outcome, error);
126
+ });
127
+ }
128
+ async function bounded(work) {
129
+ let timer;
130
+ const timeout = new Promise((resolve) => {
131
+ timer = setTimeout(resolve, RELEASE_TIMEOUT_MS);
132
+ });
133
+ try {
134
+ await Promise.race([work().catch(() => {}), timeout]);
135
+ } finally {
136
+ clearTimeout(timer);
137
+ }
138
+ }
139
+ export { RELEASE_TIMEOUT_MS, createBrowserSession };
140
+
141
+ //# sourceMappingURL=session.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session.mjs","names":[],"sources":["../src/session.ts"],"sourcesContent":["/** Ownership of a browser a run drives: one it launched, or a connection it must leave alive. */\n\nimport type { Browser, BrowserContext, Page } from \"puppeteer-core\";\n\n/** Who owns the browser process: this run launched it, or it connected to someone else's. */\nexport type BrowserOwnership = \"launched\" | \"connected\";\n\n/** Why a session is being released; the first reason wins. */\nexport type ReleaseOutcome = \"finished\" | \"failed\" | \"cancelled\";\n\n/** A provider's final release, run once after the transport is gone. */\nexport type ReleaseLease = (outcome: ReleaseOutcome, error?: unknown) => Promise<void>;\n\n/** A browser plus what the run may do to it when the run ends. */\nexport interface BrowserAcquisition {\n browser: Browser;\n ownership: BrowserOwnership;\n releaseLease?: ReleaseLease;\n}\n\n/** The run's handle on a browser; it closes or disconnects only what the run owns. */\nexport interface BrowserSession {\n readonly browser: Browser;\n readonly ownership: BrowserOwnership;\n newPage(): Promise<Page>;\n createBrowserContext(): Promise<BrowserContext>;\n forgetPage(page: Page): void;\n forgetContext(context: BrowserContext): void;\n release(outcome: ReleaseOutcome, error?: unknown): Promise<void>;\n}\n\n/** How long one cleanup step may take before release moves on. */\nexport const RELEASE_TIMEOUT_MS = 5000;\n\n/** Wrap an acquisition so the run tracks the pages and contexts it creates. */\nexport function createBrowserSession(acquisition: BrowserAcquisition): BrowserSession {\n return new OwnedSession(acquisition);\n}\n\nclass OwnedSession implements BrowserSession {\n readonly browser: Browser;\n readonly ownership: BrowserOwnership;\n\n readonly #lease: ReleaseLease | undefined;\n readonly #pages = new Set<Page>();\n readonly #contexts = new Set<BrowserContext>();\n readonly #pending = new Set<Promise<unknown>>();\n #released: Promise<void> | undefined;\n #outcome: ReleaseOutcome | undefined;\n #error: unknown;\n\n constructor({ browser, ownership, releaseLease: lease }: BrowserAcquisition) {\n this.browser = browser;\n this.ownership = ownership;\n this.#lease = lease;\n }\n\n async newPage(): Promise<Page> {\n this.#assertOpen();\n return await this.#track(this.#openPage());\n }\n\n async createBrowserContext(): Promise<BrowserContext> {\n this.#assertOpen();\n return await this.#track(this.#openContext());\n }\n\n async #openPage(): Promise<Page> {\n const page = await this.browser.newPage();\n if (this.#released !== undefined) {\n await closePage(page);\n throw releasedError();\n }\n this.#pages.add(page);\n return page;\n }\n\n async #openContext(): Promise<BrowserContext> {\n const context = await this.browser.createBrowserContext();\n if (this.#released !== undefined) {\n await closeContext(context);\n throw releasedError();\n }\n this.#contexts.add(context);\n return context;\n }\n\n async #track<T>(work: Promise<T>): Promise<T> {\n this.#pending.add(work);\n try {\n return await work;\n } finally {\n this.#pending.delete(work);\n }\n }\n\n forgetPage(page: Page): void {\n this.#pages.delete(page);\n }\n\n forgetContext(context: BrowserContext): void {\n this.#contexts.delete(context);\n }\n\n async release(outcome: ReleaseOutcome, error?: unknown): Promise<void> {\n if (this.#outcome === undefined) {\n this.#outcome = outcome;\n this.#error = error;\n }\n this.#released ??= this.#releaseAll().catch(() => {});\n await this.#released;\n }\n\n #assertOpen(): void {\n if (this.#released !== undefined) throw releasedError();\n }\n\n async #releaseAll(): Promise<void> {\n await (this.ownership === \"launched\" ? closeLaunched(this.browser) : this.#releaseConnected());\n await releaseLease(this.#lease, this.#outcome ?? \"finished\", this.#error);\n }\n\n async #releaseConnected(): Promise<void> {\n // Let creations already in flight finish so their post-release branch can close them\n // while the transport is still connected.\n await bounded(async () => {\n await Promise.allSettled(this.#pending);\n });\n await Promise.all(\n [...this.#pages].map(async (page) => {\n await this.#closePage(page);\n }),\n );\n await Promise.all(\n [...this.#contexts].map(async (context) => {\n await this.#closeContext(context);\n }),\n );\n await bounded(async () => {\n await this.browser.disconnect();\n });\n }\n\n async #closePage(page: Page): Promise<void> {\n await closePage(page);\n this.#pages.delete(page);\n }\n\n async #closeContext(context: BrowserContext): Promise<void> {\n await closeContext(context);\n this.#contexts.delete(context);\n }\n}\n\nfunction releasedError(): Error {\n return new Error(\"browser session released\");\n}\n\nasync function closeLaunched(browser: Browser): Promise<void> {\n await bounded(async () => {\n await browser.close();\n });\n}\n\nasync function closePage(page: Page): Promise<void> {\n await bounded(async () => {\n await page.close();\n });\n}\n\nasync function closeContext(context: BrowserContext): Promise<void> {\n await bounded(async () => {\n await context.close();\n });\n}\n\nasync function releaseLease(\n lease: ReleaseLease | undefined,\n outcome: ReleaseOutcome,\n error: unknown,\n): Promise<void> {\n if (lease === undefined) return;\n await bounded(async () => {\n await lease(outcome, error);\n });\n}\n\nasync function bounded(work: () => Promise<void>): Promise<void> {\n let timer: ReturnType<typeof setTimeout> | undefined;\n const timeout = new Promise<void>((resolve) => {\n timer = setTimeout(resolve, RELEASE_TIMEOUT_MS);\n });\n try {\n await Promise.race([work().catch(() => {}), timeout]);\n } finally {\n clearTimeout(timer);\n }\n}\n"],"mappings":"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"}
@@ -0,0 +1,157 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ // src/settle.ts
5
+ /** How long to wait for the event when nothing else says (the play timeout raises it). */
6
+ const DEFAULT_SETTLE_TIMEOUT_MS = 5e3;
7
+ /** The wait for the event: the context's override, else the play timeout when plays run, else 5 s. */
8
+ function settleTimeoutOf(ctx) {
9
+ if (ctx.settleTimeoutMs !== void 0) return ctx.settleTimeoutMs;
10
+ return ctx.executePlay ? Math.max(DEFAULT_SETTLE_TIMEOUT_MS, ctx.playTimeoutMs ?? 0) : DEFAULT_SETTLE_TIMEOUT_MS;
11
+ }
12
+ /** Register the recorder on a page, once; it runs before the page's own scripts on every load. */
13
+ async function installSettleRecorder(page) {
14
+ await page.evaluateOnNewDocument(inPageRecorder);
15
+ }
16
+ /** Wait until Storybook says the story is done (or `timeoutMs` passes); never throws. */
17
+ async function waitForSettled(page, timeoutMs, requireFinished = false) {
18
+ try {
19
+ const report = await page.evaluate(inPageWaitSettled, {
20
+ timeoutMs,
21
+ ...requireFinished ? { requireFinished } : {}
22
+ });
23
+ return isSettleReport(report) ? report : unsettled(false);
24
+ } catch {
25
+ return unsettled(false);
26
+ }
27
+ }
28
+ /** A report for a wait that saw no signal. */
29
+ function unsettled(hooked) {
30
+ return {
31
+ settled: false,
32
+ hooked,
33
+ phase: null,
34
+ elapsedMs: 0
35
+ };
36
+ }
37
+ function isSettleReport(value) {
38
+ if (typeof value !== "object" || value === null) return false;
39
+ const record = value;
40
+ return typeof record["settled"] === "boolean" && typeof record["hooked"] === "boolean";
41
+ }
42
+ /**
43
+ * Runs in the browser before the page's scripts, on every document. It watches the preview's
44
+ * channel (`__STORYBOOK_ADDONS_CHANNEL__`, the public `Channel.on` API; nothing is patched) and
45
+ * records the events StoryShelf needs. Puppeteer serializes only this function's source, so it
46
+ * must not reference anything from this module; `settle.test.ts` evaluates it without bindings.
47
+ */
48
+ function inPageRecorder() {
49
+ const scope = globalThis;
50
+ if (scope["__STORYSHELF_SETTLE__"]) return;
51
+ const state = {
52
+ hooked: false,
53
+ settled: false,
54
+ finished: false,
55
+ phase: null,
56
+ unhandled: [],
57
+ hook: () => false
58
+ };
59
+ const text = { of(payload) {
60
+ if (typeof payload === "string") return payload;
61
+ const record = payload ?? {};
62
+ const found = record["message"] ?? record["description"] ?? record["title"];
63
+ return typeof found === "string" ? found : "unknown error";
64
+ } };
65
+ const markSettled = () => {
66
+ state.settled = true;
67
+ };
68
+ const failRender = (payload) => {
69
+ state["renderError"] = text.of(payload);
70
+ markSettled();
71
+ };
72
+ const handlers = {
73
+ storyRenderPhaseChanged: (payload) => {
74
+ const phase = payload?.newPhase;
75
+ if (typeof phase === "string") state.phase = phase;
76
+ },
77
+ storyRendered: markSettled,
78
+ storyFinished: (payload) => {
79
+ if (payload?.status === "error" && state["playError"] === void 0) state["playError"] = "Storybook reported that the test failed";
80
+ state.finished = true;
81
+ markSettled();
82
+ },
83
+ storyThrewException: failRender,
84
+ storyErrored: failRender,
85
+ storyMissing: failRender,
86
+ playFunctionThrewException: (payload) => {
87
+ state["playError"] = text.of(payload);
88
+ },
89
+ unhandledErrorsWhilePlaying: (payload) => {
90
+ for (const error of Array.isArray(payload) ? payload : [payload]) state.unhandled.push(text.of(error));
91
+ }
92
+ };
93
+ state.hook = () => {
94
+ if (state.hooked) return true;
95
+ const found = scope["__STORYBOOK_ADDONS_CHANNEL__"] ?? scope["__STORYBOOK_PREVIEW__"]?.channel;
96
+ if (!found || typeof found.on !== "function") return false;
97
+ for (const [event, handler] of Object.entries(handlers)) found.on(event, handler);
98
+ state.hooked = true;
99
+ return true;
100
+ };
101
+ scope["__STORYSHELF_SETTLE__"] = state;
102
+ if (state.hook()) return;
103
+ const started = Date.now();
104
+ const timer = setInterval(() => {
105
+ if (state.hook() || Date.now() - started > 3e4) clearInterval(timer);
106
+ }, 2);
107
+ }
108
+ /**
109
+ * Runs in the browser: resolves when the recorder saw a terminal event, the page has no channel
110
+ * to listen to, or `timeoutMs` passed. Self-contained for the same reason as the recorder.
111
+ */
112
+ async function inPageWaitSettled({ timeoutMs, requireFinished }) {
113
+ const state = globalThis["__STORYSHELF_SETTLE__"];
114
+ const started = performance.now();
115
+ const report = (settled, hooked) => ({
116
+ settled,
117
+ hooked,
118
+ phase: state?.phase ?? null,
119
+ elapsedMs: Math.round(performance.now() - started),
120
+ ...state?.renderError === void 0 ? {} : { renderError: state.renderError },
121
+ ...state?.playError === void 0 ? {} : { playError: state.playError },
122
+ ...state && state.unhandled.length > 0 ? { unhandled: [...state.unhandled] } : {}
123
+ });
124
+ if (!state) return report(false, false);
125
+ if (!state.hook() && globalThis.document?.readyState === "complete") return report(false, false);
126
+ while (performance.now() - started < timeoutMs) {
127
+ if (state.settled && (!requireFinished || state.finished || state.renderError !== void 0)) return report(true, true);
128
+ await new Promise((resolve) => {
129
+ setTimeout(resolve, 10);
130
+ });
131
+ }
132
+ return report(state.settled && (!requireFinished || state.finished || state.renderError !== void 0), state.hook());
133
+ }
134
+ /** After the signal: let web fonts finish and two frames paint, so the screenshot sees the final DOM. */
135
+ async function awaitPaint(page) {
136
+ try {
137
+ await page.evaluate(inPagePaint);
138
+ } catch {}
139
+ }
140
+ /** Runs in the browser; bounded so a page that never paints (hidden tab) cannot hang the capture. */
141
+ async function inPagePaint() {
142
+ const scope = globalThis;
143
+ const frames = new Promise((resolve) => {
144
+ scope.requestAnimationFrame(() => {
145
+ scope.requestAnimationFrame(() => {
146
+ resolve();
147
+ });
148
+ });
149
+ });
150
+ const cap = new Promise((resolve) => {
151
+ setTimeout(resolve, 250);
152
+ });
153
+ await Promise.race([Promise.all([scope.document.fonts?.ready, frames]), cap]);
154
+ }
155
+ export { DEFAULT_SETTLE_TIMEOUT_MS, awaitPaint, inPagePaint, inPageRecorder, inPageWaitSettled, installSettleRecorder, settleTimeoutOf, unsettled, waitForSettled };
156
+
157
+ //# sourceMappingURL=settle.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"settle.mjs","names":[],"sources":["../src/settle.ts"],"sourcesContent":["/** Render-settled signalling: wait for Storybook's own \"render finished\" event, not a clock. */\n\nimport type { Page } from \"puppeteer-core\";\n\n/** What the page knew when the wait ended (all of it from Storybook's public channel events). */\nexport interface SettleReport {\n /** Storybook announced the story rendered, finished, errored or missing before the timeout. */\n settled: boolean;\n /** The preview's channel was found, so events could have arrived at all. */\n hooked: boolean;\n /** The last `storyRenderPhaseChanged` phase seen (`playing`, `errored`, `completed` ...). */\n phase: string | null;\n /** Milliseconds the wait took. */\n elapsedMs: number;\n /** The story failed to render (`storyThrewException`, `storyErrored`, `storyMissing`). */\n renderError?: string;\n /** The play function (or a `.test()` body) threw (`playFunctionThrewException`). */\n playError?: string;\n /** Errors Storybook reported after the play function (`unhandledErrorsWhilePlaying`). */\n unhandled?: string[];\n}\n\n/** How long to wait for the event when nothing else says (the play timeout raises it). */\nexport const DEFAULT_SETTLE_TIMEOUT_MS = 5000;\n\n/** The wait for the event: the context's override, else the play timeout when plays run, else 5 s. */\nexport function settleTimeoutOf(ctx: {\n settleTimeoutMs?: number | undefined;\n executePlay?: boolean | undefined;\n playTimeoutMs?: number | undefined;\n}): number {\n if (ctx.settleTimeoutMs !== undefined) return ctx.settleTimeoutMs;\n return ctx.executePlay\n ? Math.max(DEFAULT_SETTLE_TIMEOUT_MS, ctx.playTimeoutMs ?? 0)\n : DEFAULT_SETTLE_TIMEOUT_MS;\n}\n\n/** Register the recorder on a page, once; it runs before the page's own scripts on every load. */\nexport async function installSettleRecorder(page: Page): Promise<void> {\n await page.evaluateOnNewDocument(inPageRecorder);\n}\n\n/** Wait until Storybook says the story is done (or `timeoutMs` passes); never throws. */\nexport async function waitForSettled(\n page: Page,\n timeoutMs: number,\n requireFinished = false,\n): Promise<SettleReport> {\n try {\n const report: unknown = await page.evaluate(inPageWaitSettled, {\n timeoutMs,\n ...(requireFinished ? { requireFinished } : {}),\n });\n return isSettleReport(report) ? report : unsettled(false);\n } catch {\n return unsettled(false);\n }\n}\n\n/** A report for a wait that saw no signal. */\nexport function unsettled(hooked: boolean): SettleReport {\n return { settled: false, hooked, phase: null, elapsedMs: 0 };\n}\n\nfunction isSettleReport(value: unknown): value is SettleReport {\n if (typeof value !== \"object\" || value === null) return false;\n const record = value as Record<string, unknown>;\n return typeof record[\"settled\"] === \"boolean\" && typeof record[\"hooked\"] === \"boolean\";\n}\n\n/**\n * Runs in the browser before the page's scripts, on every document. It watches the preview's\n * channel (`__STORYBOOK_ADDONS_CHANNEL__`, the public `Channel.on` API; nothing is patched) and\n * records the events StoryShelf needs. Puppeteer serializes only this function's source, so it\n * must not reference anything from this module; `settle.test.ts` evaluates it without bindings.\n */\n// oxlint-disable-next-line eslint/max-statements, eslint/max-lines-per-function -- must stay one self-contained function (see above)\nexport function inPageRecorder(): void {\n type Channel = { on: (event: string, cb: (payload: unknown) => void) => void };\n type State = Record<string, unknown> & {\n hooked: boolean;\n settled: boolean;\n finished: boolean;\n phase: string | null;\n unhandled: string[];\n hook: () => boolean;\n };\n const scope = globalThis as unknown as Record<string, unknown>;\n if (scope[\"__STORYSHELF_SETTLE__\"]) return;\n const state: State = {\n hooked: false,\n settled: false,\n finished: false,\n phase: null,\n unhandled: [],\n hook: () => false,\n };\n const text = {\n of(payload: unknown): string {\n if (typeof payload === \"string\") return payload;\n const record = (payload ?? {}) as Record<string, unknown>;\n const found = record[\"message\"] ?? record[\"description\"] ?? record[\"title\"];\n return typeof found === \"string\" ? found : \"unknown error\";\n },\n };\n const markSettled = (): void => {\n state.settled = true;\n };\n const failRender = (payload: unknown): void => {\n state[\"renderError\"] = text.of(payload);\n markSettled();\n };\n const handlers: Record<string, (payload: unknown) => void> = {\n storyRenderPhaseChanged: (payload) => {\n const phase = (payload as { newPhase?: unknown } | undefined)?.newPhase;\n if (typeof phase === \"string\") state.phase = phase;\n },\n storyRendered: markSettled,\n storyFinished: (payload) => {\n const status = (payload as { status?: unknown } | undefined)?.status;\n if (status === \"error\" && state[\"playError\"] === undefined) {\n state[\"playError\"] = \"Storybook reported that the test failed\";\n }\n state.finished = true;\n markSettled();\n },\n storyThrewException: failRender,\n storyErrored: failRender,\n storyMissing: failRender,\n playFunctionThrewException: (payload) => {\n state[\"playError\"] = text.of(payload);\n },\n unhandledErrorsWhilePlaying: (payload) => {\n for (const error of Array.isArray(payload) ? payload : [payload]) {\n state.unhandled.push(text.of(error));\n }\n },\n };\n state.hook = (): boolean => {\n if (state.hooked) return true;\n const found = (scope[\"__STORYBOOK_ADDONS_CHANNEL__\"] ??\n (scope[\"__STORYBOOK_PREVIEW__\"] as { channel?: unknown } | undefined)?.channel) as\n | Channel\n | undefined;\n if (!found || typeof found.on !== \"function\") return false;\n for (const [event, handler] of Object.entries(handlers)) found.on(event, handler);\n state.hooked = true;\n return true;\n };\n scope[\"__STORYSHELF_SETTLE__\"] = state;\n // The channel is created by iframe.html's bundle, before the first story renders: catch it\n // within a few ms. Give up after a while (a page that is not Storybook never gets one).\n if (state.hook()) return;\n const started = Date.now();\n const timer = setInterval(() => {\n if (state.hook() || Date.now() - started > 30_000) clearInterval(timer);\n }, 2);\n}\n\n/** Arguments serialized into the page for {@link inPageWaitSettled}. */\nexport interface SettleArgs {\n timeoutMs: number;\n requireFinished?: boolean;\n}\n\n/**\n * Runs in the browser: resolves when the recorder saw a terminal event, the page has no channel\n * to listen to, or `timeoutMs` passed. Self-contained for the same reason as the recorder.\n */\n// oxlint-disable-next-line eslint/max-statements, eslint/max-lines-per-function -- must stay one self-contained function (see above)\nexport async function inPageWaitSettled({\n timeoutMs,\n requireFinished,\n}: SettleArgs): Promise<SettleReport> {\n type State = {\n hooked: boolean;\n settled: boolean;\n finished: boolean;\n phase: string | null;\n unhandled: string[];\n hook: () => boolean;\n renderError?: string;\n playError?: string;\n };\n const state = (globalThis as unknown as Record<string, State | undefined>)[\n \"__STORYSHELF_SETTLE__\"\n ];\n const started = performance.now();\n const report = (settled: boolean, hooked: boolean): SettleReport => ({\n settled,\n hooked,\n phase: state?.phase ?? null,\n elapsedMs: Math.round(performance.now() - started),\n ...(state?.renderError === undefined ? {} : { renderError: state.renderError }),\n ...(state?.playError === undefined ? {} : { playError: state.playError }),\n ...(state && state.unhandled.length > 0 ? { unhandled: [...state.unhandled] } : {}),\n });\n if (!state) return report(false, false);\n // After `load` every script has run: no channel by now means there will never be one.\n if (!state.hook() && globalThis.document?.readyState === \"complete\") return report(false, false);\n while (performance.now() - started < timeoutMs) {\n if (state.settled && (!requireFinished || state.finished || state.renderError !== undefined)) {\n return report(true, true);\n }\n // oxlint-disable-next-line eslint/no-await-in-loop -- polling the recorded state\n await new Promise<void>((resolve) => {\n setTimeout(resolve, 10);\n });\n }\n return report(\n state.settled && (!requireFinished || state.finished || state.renderError !== undefined),\n state.hook(),\n );\n}\n\n/** After the signal: let web fonts finish and two frames paint, so the screenshot sees the final DOM. */\nexport async function awaitPaint(page: Page): Promise<void> {\n try {\n await page.evaluate(inPagePaint);\n } catch {\n // best-effort: the screenshot still follows\n }\n}\n\n/** Runs in the browser; bounded so a page that never paints (hidden tab) cannot hang the capture. */\nexport async function inPagePaint(): Promise<void> {\n const scope = globalThis as unknown as {\n document: { fonts?: { ready: Promise<unknown> } };\n requestAnimationFrame: (cb: () => void) => void;\n };\n const frames = new Promise<void>((resolve) => {\n scope.requestAnimationFrame(() => {\n scope.requestAnimationFrame(() => {\n resolve();\n });\n });\n });\n const cap = new Promise<void>((resolve) => {\n setTimeout(resolve, 250);\n });\n await Promise.race([Promise.all([scope.document.fonts?.ready, frames]), cap]);\n}\n"],"mappings":"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"}