@storyshelf/runner-puppeteer 0.6.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,8 @@
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";
@@ -8,20 +10,20 @@ import { getScreenshotPlan } from "@storyshelf/core/capture";
8
10
  //#region 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
28
  const a11yViolations = await getA11y(ctx, page);
27
29
  await settlePage(page, story);
@@ -36,8 +38,8 @@ async function runCapture(page, ctx, inputStory, viewport) {
36
38
  story
37
39
  };
38
40
  }
39
- async function navigateAndEnrich(ctx, page, story) {
40
- await page.goto(ctx.adapter.buildUrl(ctx.baseUrl, story.id), { waitUntil: "networkidle0" });
41
+ async function navigateAndEnrich(ctx, page, story, viewport) {
42
+ await page.goto(ctx.adapter.buildUrl(ctx.baseUrl, story.id, viewport.globals), { waitUntil: "networkidle0" });
41
43
  await ctx.adapter.waitForReady?.(page);
42
44
  if (story.parameters) return story;
43
45
  const rpPage = page;
@@ -47,22 +49,41 @@ async function navigateAndEnrich(ctx, page, story) {
47
49
  parameters: params
48
50
  } : story;
49
51
  }
50
- async function maybeWaitForSelector(ctx, page, story) {
52
+ /**
53
+ * Wait until the story is ready to be photographed: the selector exists, then Storybook says the
54
+ * render (and its play function) finished, then an explicit `parameters.delay` runs on top. A page
55
+ * that never signals (no channel, or no event before the timeout) gets today's fixed wait instead:
56
+ * `delay ?? 500` ms.
57
+ */
58
+ async function waitUntilReady(ctx, page, story) {
51
59
  if (!ctx.adapter.screenshotSelector) return;
52
60
  await page.waitForSelector(ctx.adapter.screenshotSelector, { visible: false });
53
- await delay(story.parameters?.delay ?? 500);
61
+ const report = await waitForSettled(page, settleTimeoutOf(ctx));
62
+ if (report.settled) {
63
+ await awaitPaint(page);
64
+ if (story.parameters?.delay !== void 0) await delay(story.parameters.delay);
65
+ } else await delay(story.parameters?.delay ?? 500);
66
+ logSettle(ctx, story, report);
54
67
  if (story.parameters?.pauseAnimationAtEnd) await page.evaluate(() => {
55
68
  const doc = globalThis.document;
56
69
  for (const anim of doc.getAnimations()) anim.pause();
57
70
  });
58
71
  }
72
+ /** One line per story that did not render cleanly: where it stopped, and what Storybook said. */
73
+ function logSettle(ctx, story, report) {
74
+ const problem = report.renderError ?? report.playError ?? report.unhandled?.[0];
75
+ if (report.settled && problem === void 0) return;
76
+ const detail = {
77
+ storyId: story.id,
78
+ phase: report.phase,
79
+ waitedMs: report.elapsedMs,
80
+ problem
81
+ };
82
+ if (!report.settled && report.hooked) ctx.logger?.warn(detail, "story did not signal that it finished rendering; used a fixed wait");
83
+ else if (problem !== void 0) ctx.logger?.warn(detail, "story reported an error while rendering");
84
+ }
59
85
  async function getA11y(ctx, page) {
60
- if (!ctx.runA11y) return [];
61
- try {
62
- return await checkA11y(page);
63
- } catch {
64
- return [];
65
- }
86
+ return ctx.runA11y ? await checkA11y(page, ctx.logger) : [];
66
87
  }
67
88
  async function settlePage(page, story) {
68
89
  const burst = story.parameters?.burst ?? 1;
@@ -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 { getScreenshotPlan } 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);\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 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): Promise<string[]> {\n // checkA11y reports its own failures as an annotation, so it never throws.\n return ctx.runA11y ? await checkA11y(page, ctx.logger) : [];\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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"}
@@ -0,0 +1,150 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ //#region 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) {
18
+ try {
19
+ const report = await page.evaluate(inPageWaitSettled, { timeoutMs });
20
+ return isSettleReport(report) ? report : unsettled(false);
21
+ } catch {
22
+ return unsettled(false);
23
+ }
24
+ }
25
+ /** A report for a wait that saw no signal. */
26
+ function unsettled(hooked) {
27
+ return {
28
+ settled: false,
29
+ hooked,
30
+ phase: null,
31
+ elapsedMs: 0
32
+ };
33
+ }
34
+ function isSettleReport(value) {
35
+ if (typeof value !== "object" || value === null) return false;
36
+ const record = value;
37
+ return typeof record["settled"] === "boolean" && typeof record["hooked"] === "boolean";
38
+ }
39
+ /**
40
+ * Runs in the browser before the page's scripts, on every document. It watches the preview's
41
+ * channel (`__STORYBOOK_ADDONS_CHANNEL__`, the public `Channel.on` API; nothing is patched) and
42
+ * records the events StoryShelf needs. Puppeteer serializes only this function's source, so it
43
+ * must not reference anything from this module; `settle.test.ts` evaluates it without bindings.
44
+ */
45
+ function inPageRecorder() {
46
+ const scope = globalThis;
47
+ if (scope["__STORYSHELF_SETTLE__"]) return;
48
+ const state = {
49
+ hooked: false,
50
+ settled: false,
51
+ phase: null,
52
+ unhandled: [],
53
+ hook: () => false
54
+ };
55
+ const text = { of(payload) {
56
+ if (typeof payload === "string") return payload;
57
+ const record = payload ?? {};
58
+ const found = record["message"] ?? record["description"] ?? record["title"];
59
+ return typeof found === "string" ? found : "unknown error";
60
+ } };
61
+ const markSettled = () => {
62
+ state.settled = true;
63
+ };
64
+ const failRender = (payload) => {
65
+ state["renderError"] = text.of(payload);
66
+ markSettled();
67
+ };
68
+ const handlers = {
69
+ storyRenderPhaseChanged: (payload) => {
70
+ const phase = payload?.newPhase;
71
+ if (typeof phase === "string") state.phase = phase;
72
+ },
73
+ storyRendered: markSettled,
74
+ storyFinished: markSettled,
75
+ storyThrewException: failRender,
76
+ storyErrored: failRender,
77
+ storyMissing: failRender,
78
+ playFunctionThrewException: (payload) => {
79
+ state["playError"] = text.of(payload);
80
+ },
81
+ unhandledErrorsWhilePlaying: (payload) => {
82
+ for (const error of Array.isArray(payload) ? payload : [payload]) state.unhandled.push(text.of(error));
83
+ }
84
+ };
85
+ state.hook = () => {
86
+ if (state.hooked) return true;
87
+ const found = scope["__STORYBOOK_ADDONS_CHANNEL__"] ?? scope["__STORYBOOK_PREVIEW__"]?.channel;
88
+ if (!found || typeof found.on !== "function") return false;
89
+ for (const [event, handler] of Object.entries(handlers)) found.on(event, handler);
90
+ state.hooked = true;
91
+ return true;
92
+ };
93
+ scope["__STORYSHELF_SETTLE__"] = state;
94
+ if (state.hook()) return;
95
+ const started = Date.now();
96
+ const timer = setInterval(() => {
97
+ if (state.hook() || Date.now() - started > 3e4) clearInterval(timer);
98
+ }, 2);
99
+ }
100
+ /**
101
+ * Runs in the browser: resolves when the recorder saw a terminal event, the page has no channel
102
+ * to listen to, or `timeoutMs` passed. Self-contained for the same reason as the recorder.
103
+ */
104
+ async function inPageWaitSettled({ timeoutMs }) {
105
+ const state = globalThis["__STORYSHELF_SETTLE__"];
106
+ const started = performance.now();
107
+ const report = (settled, hooked) => ({
108
+ settled,
109
+ hooked,
110
+ phase: state?.phase ?? null,
111
+ elapsedMs: Math.round(performance.now() - started),
112
+ ...state?.renderError === void 0 ? {} : { renderError: state.renderError },
113
+ ...state?.playError === void 0 ? {} : { playError: state.playError },
114
+ ...state && state.unhandled.length > 0 ? { unhandled: [...state.unhandled] } : {}
115
+ });
116
+ if (!state) return report(false, false);
117
+ if (!state.hook() && globalThis.document?.readyState === "complete") return report(false, false);
118
+ while (performance.now() - started < timeoutMs) {
119
+ if (state.settled) return report(true, true);
120
+ await new Promise((resolve) => {
121
+ setTimeout(resolve, 10);
122
+ });
123
+ }
124
+ return report(state.settled, state.hook());
125
+ }
126
+ /** After the signal: let web fonts finish and two frames paint, so the screenshot sees the final DOM. */
127
+ async function awaitPaint(page) {
128
+ try {
129
+ await page.evaluate(inPagePaint);
130
+ } catch {}
131
+ }
132
+ /** Runs in the browser; bounded so a page that never paints (hidden tab) cannot hang the capture. */
133
+ async function inPagePaint() {
134
+ const scope = globalThis;
135
+ const frames = new Promise((resolve) => {
136
+ scope.requestAnimationFrame(() => {
137
+ scope.requestAnimationFrame(() => {
138
+ resolve();
139
+ });
140
+ });
141
+ });
142
+ const cap = new Promise((resolve) => {
143
+ setTimeout(resolve, 250);
144
+ });
145
+ await Promise.race([Promise.all([scope.document.fonts?.ready, frames]), cap]);
146
+ }
147
+ //#endregion
148
+ export { DEFAULT_SETTLE_TIMEOUT_MS, awaitPaint, inPagePaint, inPageRecorder, inPageWaitSettled, installSettleRecorder, settleTimeoutOf, unsettled, waitForSettled };
149
+
150
+ //# 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(page: Page, timeoutMs: number): Promise<SettleReport> {\n try {\n const report: unknown = await page.evaluate(inPageWaitSettled, { timeoutMs });\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 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 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: markSettled,\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}\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({ timeoutMs }: SettleArgs): Promise<SettleReport> {\n type State = {\n hooked: boolean;\n settled: 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) return report(true, true);\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(state.settled, state.hook());\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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"}
@@ -0,0 +1,25 @@
1
+ import __tsdown_shims_path from 'node:path';
2
+ import __tsdown_shims_url from 'node:url';
3
+ import { BrowserName, RemoteBrowser } from "@storyshelf/core/adapter/capture-runner";
4
+ //#region src/targets.d.ts
5
+ /** Options of {@link local}. */
6
+ interface LocalOptions {
7
+ /** The browser binary this target launches (`puppeteer-core` ships none). */
8
+ executablePath?: string;
9
+ }
10
+ /** A local engine with options (ADR 0007, decision 1). */
11
+ interface LocalBrowser {
12
+ readonly kind: "local";
13
+ readonly engine: BrowserName;
14
+ readonly options: LocalOptions;
15
+ }
16
+ /**
17
+ * One entry of `browsers`: a bare engine name, {@link local}, or a remote CDP service from
18
+ * `@storyshelf/runner-puppeteer/remote` (`cdp(...)`), which stands for one target per engine.
19
+ */
20
+ type BrowserEntry = BrowserName | LocalBrowser | RemoteBrowser;
21
+ /** A local engine with options, for `browsers`: `local("chrome", { executablePath: "/usr/bin/google-chrome" })`. */
22
+ declare function local(engine: BrowserName, options?: LocalOptions): LocalBrowser;
23
+ //#endregion
24
+ export { BrowserEntry, LocalBrowser, LocalOptions, local };
25
+ //# sourceMappingURL=targets.d.mts.map
@@ -0,0 +1,80 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { isBrowserName, isRemoteBrowser, remoteTargetsOf } from "@storyshelf/core/adapter/capture-runner";
5
+ //#region src/targets.ts
6
+ /** The runner's `browsers` option: what an operator lists, and the local targets it flattens to. */
7
+ /** Engines Puppeteer drives: the Chromium family. */
8
+ const SUPPORTED = ["chromium", "chrome"];
9
+ /** A local engine with options, for `browsers`: `local("chrome", { executablePath: "/usr/bin/google-chrome" })`. */
10
+ function local(engine, options = {}) {
11
+ if (!isBrowserName(engine)) throw new Error(`local(): unknown engine "${String(engine)}"`);
12
+ if (!SUPPORTED.includes(engine)) throw new Error(`local("${engine}"): the Puppeteer runner drives Chromium-family engines only (${SUPPORTED.join(", ")})`);
13
+ return {
14
+ kind: "local",
15
+ engine,
16
+ options
17
+ };
18
+ }
19
+ /**
20
+ * Flatten the `browsers` list into targets, first = default. Throws on an empty list, an engine
21
+ * Puppeteer cannot drive, bare `chrome` (it needs its own `executablePath`, since the runner's
22
+ * would launch whatever it points at under Chrome's name), or two entries producing one target,
23
+ * so a mistake fails when the runner is created and not when the first build runs.
24
+ */
25
+ function flattenBrowsers(entries, runnerExecutablePath) {
26
+ if (entries.length === 0) throw new Error("browsers must list at least one target");
27
+ const seen = /* @__PURE__ */ new Map();
28
+ const flat = {
29
+ declared: [],
30
+ locals: [],
31
+ remotes: []
32
+ };
33
+ for (const [index, entry] of entries.entries()) {
34
+ const produced = isRemoteBrowser(entry) ? remoteTargetsOf(entry) : [localOf(entry, runnerExecutablePath)];
35
+ for (const target of produced) {
36
+ claim(seen, target.target, index);
37
+ add(flat, target);
38
+ }
39
+ }
40
+ return flat;
41
+ }
42
+ /** Record that entry `index` produces `target`, refusing a second entry that does. */
43
+ function claim(seen, target, index) {
44
+ const first = seen.get(target);
45
+ if (first !== void 0) throw new Error(`browsers entries ${first + 1} and ${index + 1} both produce the target "${target}"`);
46
+ seen.set(target, index);
47
+ }
48
+ function localOf(entry, runnerExecutablePath) {
49
+ const { engine, options } = typeof entry === "string" ? local(entry) : entry;
50
+ return {
51
+ target: engine,
52
+ executablePath: pathOf(engine, options, runnerExecutablePath)
53
+ };
54
+ }
55
+ function add(flat, target) {
56
+ if ("kind" in target) {
57
+ flat.remotes.push(target);
58
+ flat.declared.push({
59
+ target: target.target,
60
+ reach: target.reach,
61
+ status: "ready"
62
+ });
63
+ } else {
64
+ flat.locals.push(target);
65
+ flat.declared.push({
66
+ target: target.target,
67
+ reach: "loopback",
68
+ status: "ready"
69
+ });
70
+ }
71
+ }
72
+ function pathOf(engine, options, runnerExecutablePath) {
73
+ if (options.executablePath !== void 0) return options.executablePath;
74
+ if (engine === "chrome") throw new Error(`"chrome" needs an executablePath: use local("chrome", { executablePath: "/usr/bin/google-chrome" })`);
75
+ return runnerExecutablePath;
76
+ }
77
+ //#endregion
78
+ export { flattenBrowsers, local };
79
+
80
+ //# sourceMappingURL=targets.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"targets.mjs","names":[],"sources":["../src/targets.ts"],"sourcesContent":["/** The runner's `browsers` option: what an operator lists, and the local targets it flattens to. */\nimport {\n isBrowserName,\n isRemoteBrowser,\n remoteTargetsOf,\n type BrowserName,\n type RemoteBrowser,\n type RemoteTarget,\n type RunnerTarget,\n} from \"@storyshelf/core/adapter/capture-runner\";\n\n/** Options of {@link local}. */\nexport interface LocalOptions {\n /** The browser binary this target launches (`puppeteer-core` ships none). */\n executablePath?: string;\n}\n\n/** A local engine with options (ADR 0007, decision 1). */\nexport interface LocalBrowser {\n readonly kind: \"local\";\n readonly engine: BrowserName;\n readonly options: LocalOptions;\n}\n\n/**\n * One entry of `browsers`: a bare engine name, {@link local}, or a remote CDP service from\n * `@storyshelf/runner-puppeteer/remote` (`cdp(...)`), which stands for one target per engine.\n */\nexport type BrowserEntry = BrowserName | LocalBrowser | RemoteBrowser;\n\n/** A local engine this runner launches. */\nexport interface LocalTarget {\n /** The engine name, which is also the target. */\n target: BrowserName;\n /** The binary to launch: the entry's own, else the runner's for `chromium`; none for bare `chrome`. */\n executablePath: string | undefined;\n}\n\n/** Engines Puppeteer drives: the Chromium family. */\nconst SUPPORTED: readonly BrowserName[] = [\"chromium\", \"chrome\"];\n\n/** A local engine with options, for `browsers`: `local(\"chrome\", { executablePath: \"/usr/bin/google-chrome\" })`. */\nexport function local(engine: BrowserName, options: LocalOptions = {}): LocalBrowser {\n if (!isBrowserName(engine)) {\n throw new Error(`local(): unknown engine \"${String(engine)}\"`);\n }\n if (!SUPPORTED.includes(engine)) {\n throw new Error(\n `local(\"${engine}\"): the Puppeteer runner drives Chromium-family engines only (${SUPPORTED.join(\", \")})`,\n );\n }\n return { kind: \"local\", engine, options };\n}\n\n/** The `browsers` list, flattened: what the runner offers and how to start each target. */\nexport interface FlatTargets {\n /** Every target as the runner declares it, default first (all `ready` until verified). */\n declared: RunnerTarget[];\n locals: LocalTarget[];\n remotes: RemoteTarget[];\n}\n\n/**\n * Flatten the `browsers` list into targets, first = default. Throws on an empty list, an engine\n * Puppeteer cannot drive, bare `chrome` (it needs its own `executablePath`, since the runner's\n * would launch whatever it points at under Chrome's name), or two entries producing one target,\n * so a mistake fails when the runner is created and not when the first build runs.\n */\nexport function flattenBrowsers(\n entries: readonly BrowserEntry[],\n runnerExecutablePath?: string,\n): FlatTargets {\n if (entries.length === 0) {\n throw new Error(\"browsers must list at least one target\");\n }\n const seen = new Map<string, number>();\n const flat: FlatTargets = { declared: [], locals: [], remotes: [] };\n for (const [index, entry] of entries.entries()) {\n const produced = isRemoteBrowser(entry)\n ? remoteTargetsOf(entry)\n : [localOf(entry, runnerExecutablePath)];\n for (const target of produced) {\n claim(seen, target.target, index);\n add(flat, target);\n }\n }\n return flat;\n}\n\n/** Record that entry `index` produces `target`, refusing a second entry that does. */\nfunction claim(seen: Map<string, number>, target: string, index: number): void {\n const first = seen.get(target);\n if (first !== undefined) {\n throw new Error(\n `browsers entries ${first + 1} and ${index + 1} both produce the target \"${target}\"`,\n );\n }\n seen.set(target, index);\n}\n\nfunction localOf(entry: BrowserName | LocalBrowser, runnerExecutablePath?: string): LocalTarget {\n const { engine, options } = typeof entry === \"string\" ? local(entry) : entry;\n return { target: engine, executablePath: pathOf(engine, options, runnerExecutablePath) };\n}\n\nfunction add(flat: FlatTargets, target: LocalTarget | RemoteTarget): void {\n if (\"kind\" in target) {\n flat.remotes.push(target);\n flat.declared.push({ target: target.target, reach: target.reach, status: \"ready\" });\n } else {\n flat.locals.push(target);\n flat.declared.push({ target: target.target, reach: \"loopback\", status: \"ready\" });\n }\n}\n\nfunction pathOf(\n engine: BrowserName,\n options: LocalOptions,\n runnerExecutablePath?: string,\n): string | undefined {\n if (options.executablePath !== undefined) {\n return options.executablePath;\n }\n if (engine === \"chrome\") {\n throw new Error(\n `\"chrome\" needs an executablePath: use local(\"chrome\", { executablePath: \"/usr/bin/google-chrome\" })`,\n );\n }\n return runnerExecutablePath;\n}\n"],"mappings":";;;;;;;AAuCA,MAAM,YAAoC,CAAC,YAAY,QAAQ;;AAG/D,SAAgB,MAAM,QAAqB,UAAwB,CAAC,GAAiB;CACnF,IAAI,CAAC,cAAc,MAAM,GACvB,MAAM,IAAI,MAAM,4BAA4B,OAAO,MAAM,EAAE,EAAE;CAE/D,IAAI,CAAC,UAAU,SAAS,MAAM,GAC5B,MAAM,IAAI,MACR,UAAU,OAAO,gEAAgE,UAAU,KAAK,IAAI,EAAE,EACxG;CAEF,OAAO;EAAE,MAAM;EAAS;EAAQ;CAAQ;AAC1C;;;;;;;AAgBA,SAAgB,gBACd,SACA,sBACa;CACb,IAAI,QAAQ,WAAW,GACrB,MAAM,IAAI,MAAM,wCAAwC;CAE1D,MAAM,uBAAO,IAAI,IAAoB;CACrC,MAAM,OAAoB;EAAE,UAAU,CAAC;EAAG,QAAQ,CAAC;EAAG,SAAS,CAAC;CAAE;CAClE,KAAK,MAAM,CAAC,OAAO,UAAU,QAAQ,QAAQ,GAAG;EAC9C,MAAM,WAAW,gBAAgB,KAAK,IAClC,gBAAgB,KAAK,IACrB,CAAC,QAAQ,OAAO,oBAAoB,CAAC;EACzC,KAAK,MAAM,UAAU,UAAU;GAC7B,MAAM,MAAM,OAAO,QAAQ,KAAK;GAChC,IAAI,MAAM,MAAM;EAClB;CACF;CACA,OAAO;AACT;;AAGA,SAAS,MAAM,MAA2B,QAAgB,OAAqB;CAC7E,MAAM,QAAQ,KAAK,IAAI,MAAM;CAC7B,IAAI,UAAU,KAAA,GACZ,MAAM,IAAI,MACR,oBAAoB,QAAQ,EAAE,OAAO,QAAQ,EAAE,4BAA4B,OAAO,EACpF;CAEF,KAAK,IAAI,QAAQ,KAAK;AACxB;AAEA,SAAS,QAAQ,OAAmC,sBAA4C;CAC9F,MAAM,EAAE,QAAQ,YAAY,OAAO,UAAU,WAAW,MAAM,KAAK,IAAI;CACvE,OAAO;EAAE,QAAQ;EAAQ,gBAAgB,OAAO,QAAQ,SAAS,oBAAoB;CAAE;AACzF;AAEA,SAAS,IAAI,MAAmB,QAA0C;CACxE,IAAI,UAAU,QAAQ;EACpB,KAAK,QAAQ,KAAK,MAAM;EACxB,KAAK,SAAS,KAAK;GAAE,QAAQ,OAAO;GAAQ,OAAO,OAAO;GAAO,QAAQ;EAAQ,CAAC;CACpF,OAAO;EACL,KAAK,OAAO,KAAK,MAAM;EACvB,KAAK,SAAS,KAAK;GAAE,QAAQ,OAAO;GAAQ,OAAO;GAAY,QAAQ;EAAQ,CAAC;CAClF;AACF;AAEA,SAAS,OACP,QACA,SACA,sBACoB;CACpB,IAAI,QAAQ,mBAAmB,KAAA,GAC7B,OAAO,QAAQ;CAEjB,IAAI,WAAW,UACb,MAAM,IAAI,MACR,qGACF;CAEF,OAAO;AACT"}
package/dist/tests.mjs ADDED
@@ -0,0 +1,114 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { messageOf, safeCloseBrowser, safeCloseServer } from "./browser.mjs";
5
+ import { waitForSettled } from "./settle.mjs";
6
+ import { acquirePage } from "./pages.mjs";
7
+ import { assertReachable, planRun, serve } from "./pipeline.mjs";
8
+ import { engineOf } from "@storyshelf/core/adapter/capture-runner";
9
+ import { createPageLimiter, isTimeoutError } from "@storyshelf/core/utils";
10
+ import { StorybookAdapter } from "@storyshelf/core/capture";
11
+ //#region src/tests.ts
12
+ /** Storybook 10 `.test()` entries run as interaction tests in the iframe (ADR 0004, decision 10). */
13
+ /** How long one test may take (render plus body) when the project sets nothing. */
14
+ const DEFAULT_TEST_TIMEOUT_MS = 1e4;
15
+ /** A viewport for tests: they take no screenshot, so one size serves every entry. */
16
+ const TEST_VIEWPORT = {
17
+ name: "test",
18
+ width: 1280,
19
+ height: 720
20
+ };
21
+ /** Run every test entry once and report pass or fail for each; a failing test never throws. */
22
+ async function runTestEntries(rawInput, active, driver, defaultTarget) {
23
+ if (rawInput.tests.length === 0) return [];
24
+ const { input, name, remote } = planRun(rawInput, driver, defaultTarget);
25
+ assertReachable(name, remote, input.storybookUrl);
26
+ const browser = await driver.launch(name, input.logger);
27
+ const server = await serve(input, browser);
28
+ active.browser = browser;
29
+ try {
30
+ return await runAll(browser, server.url, input, active);
31
+ } finally {
32
+ active.browser = null;
33
+ await Promise.all([safeCloseBrowser(browser), safeCloseServer(server)]);
34
+ }
35
+ }
36
+ async function runAll(browser, baseUrl, input, active) {
37
+ const outcomes = /* @__PURE__ */ new Map();
38
+ const pages = createPageLimiter({
39
+ concurrency: input.concurrency,
40
+ ...input.gate === void 0 ? {} : { gate: input.gate },
41
+ browser: engineOf(input.target),
42
+ logger: input.logger
43
+ });
44
+ try {
45
+ await pages.each(input.tests, async (test) => {
46
+ if (active.cancelled) throw new Error("Capture cancelled");
47
+ const outcome = await runTestEntry(browser, baseUrl, test, input);
48
+ outcomes.set(test.id, outcome);
49
+ return outcome.timedOut === true;
50
+ });
51
+ } finally {
52
+ pages.dispose();
53
+ }
54
+ return input.tests.flatMap((test) => outcomes.get(test.id) ?? []);
55
+ }
56
+ /** One test: open its iframe URL, let Storybook render and run it, then read the verdict. */
57
+ async function runTestEntry(browser, baseUrl, test, input) {
58
+ const started = performance.now();
59
+ const lease = await acquirePage({ browser }, TEST_VIEWPORT);
60
+ let reusable = false;
61
+ try {
62
+ const report = await openAndWait(lease.page, baseUrl, test, input);
63
+ reusable = true;
64
+ const timeoutMs = input.playTimeoutMs ?? 1e4;
65
+ return verdictOf(test.id, report, timeoutMs, elapsedSince(started));
66
+ } catch (error) {
67
+ return crashed(test.id, error, elapsedSince(started));
68
+ } finally {
69
+ await lease.release(reusable);
70
+ }
71
+ }
72
+ function elapsedSince(started) {
73
+ return Math.round(performance.now() - started);
74
+ }
75
+ /** The page or navigation failed before Storybook could say anything: the test failed. */
76
+ function crashed(storyId, error, durationMs) {
77
+ return {
78
+ storyId,
79
+ status: "failed",
80
+ error: messageOf(error),
81
+ durationMs,
82
+ timedOut: isTimeoutError(error)
83
+ };
84
+ }
85
+ async function openAndWait(page, baseUrl, test, input) {
86
+ if (input.navigationTimeoutMs) page.setDefaultTimeout(input.navigationTimeoutMs);
87
+ const adapter = input.adapter ?? new StorybookAdapter();
88
+ await page.goto(adapter.buildUrl(baseUrl, test.id), { waitUntil: "load" });
89
+ return await waitForSettled(page, input.playTimeoutMs ?? 1e4);
90
+ }
91
+ /** Read Storybook's own account of the run: a thrown play body or render error fails the test. */
92
+ function verdictOf(storyId, report, timeoutMs, durationMs) {
93
+ const failed = (error, timedOut = false) => ({
94
+ storyId,
95
+ status: "failed",
96
+ error,
97
+ durationMs,
98
+ ...timedOut ? { timedOut } : {}
99
+ });
100
+ if (!report.hooked) return failed("Storybook's preview did not start (no channel to listen to)");
101
+ if (report.renderError !== void 0) return failed(`render failed: ${report.renderError}`);
102
+ if (report.playError !== void 0) return failed(report.playError);
103
+ if (report.unhandled?.[0] !== void 0) return failed(report.unhandled[0]);
104
+ if (!report.settled) return failed(`test did not finish within ${timeoutMs}ms (phase: ${report.phase ?? "unknown"})`, true);
105
+ return {
106
+ storyId,
107
+ status: "passed",
108
+ durationMs
109
+ };
110
+ }
111
+ //#endregion
112
+ export { DEFAULT_TEST_TIMEOUT_MS, runTestEntries, verdictOf };
113
+
114
+ //# sourceMappingURL=tests.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tests.mjs","names":[],"sources":["../src/tests.ts"],"sourcesContent":["/** Storybook 10 `.test()` entries run as interaction tests in the iframe (ADR 0004, decision 10). */\n\nimport {\n engineOf,\n type CaptureTarget,\n type StoryEntry,\n type TestOutcome,\n} from \"@storyshelf/core/adapter/capture-runner\";\nimport { StorybookAdapter } from \"@storyshelf/core/capture\";\nimport { createPageLimiter, isTimeoutError, type SessionGate } from \"@storyshelf/core/utils\";\nimport type { Browser, Page } from \"puppeteer-core\";\nimport { messageOf, safeCloseBrowser, safeCloseServer } from \"./browser.ts\";\nimport { acquirePage } from \"./pages.ts\";\nimport { assertReachable, planRun, serve } from \"./pipeline.ts\";\nimport { waitForSettled, type SettleReport } from \"./settle.ts\";\nimport type { ActiveRun, PuppeteerTestsInput, TargetDriver } from \"./types.ts\";\n\n/** How long one test may take (render plus body) when the project sets nothing. */\nexport const DEFAULT_TEST_TIMEOUT_MS = 10_000;\n\n/** A viewport for tests: they take no screenshot, so one size serves every entry. */\nconst TEST_VIEWPORT = { name: \"test\", width: 1280, height: 720 };\n\n/** Run every test entry once and report pass or fail for each; a failing test never throws. */\nexport async function runTestEntries(\n rawInput: PuppeteerTestsInput,\n active: ActiveRun,\n driver: TargetDriver,\n defaultTarget: CaptureTarget,\n): Promise<TestOutcome[]> {\n if (rawInput.tests.length === 0) return [];\n const { input, name, remote } = planRun(rawInput, driver, defaultTarget);\n assertReachable(name, remote, input.storybookUrl);\n const browser = await driver.launch(name, input.logger);\n const server = await serve(input, browser);\n active.browser = browser;\n try {\n return await runAll(browser, server.url, input, active);\n } finally {\n active.browser = null;\n await Promise.all([safeCloseBrowser(browser), safeCloseServer(server)]);\n }\n}\n\nasync function runAll(\n browser: Browser,\n baseUrl: string,\n input: PuppeteerTestsInput & { target: CaptureTarget; gate?: SessionGate | undefined },\n active: ActiveRun,\n): Promise<TestOutcome[]> {\n const outcomes = new Map<string, TestOutcome>();\n const pages = createPageLimiter({\n concurrency: input.concurrency,\n ...(input.gate === undefined ? {} : { gate: input.gate }),\n browser: engineOf(input.target),\n logger: input.logger,\n });\n try {\n await pages.each(input.tests, async (test) => {\n if (active.cancelled) throw new Error(\"Capture cancelled\");\n const outcome = await runTestEntry(browser, baseUrl, test, input);\n outcomes.set(test.id, outcome);\n return outcome.timedOut === true;\n });\n } finally {\n pages.dispose();\n }\n return input.tests.flatMap((test) => outcomes.get(test.id) ?? []);\n}\n\n/** One test: open its iframe URL, let Storybook render and run it, then read the verdict. */\nasync function runTestEntry(\n browser: Browser,\n baseUrl: string,\n test: StoryEntry,\n input: PuppeteerTestsInput,\n): Promise<TestOutcome & { timedOut?: boolean }> {\n const started = performance.now();\n const lease = await acquirePage({ browser }, TEST_VIEWPORT);\n let reusable = false;\n try {\n const report = await openAndWait(lease.page, baseUrl, test, input);\n reusable = true;\n const timeoutMs = input.playTimeoutMs ?? DEFAULT_TEST_TIMEOUT_MS;\n return verdictOf(test.id, report, timeoutMs, elapsedSince(started));\n } catch (error) {\n return crashed(test.id, error, elapsedSince(started));\n } finally {\n await lease.release(reusable);\n }\n}\n\nfunction elapsedSince(started: number): number {\n return Math.round(performance.now() - started);\n}\n\n/** The page or navigation failed before Storybook could say anything: the test failed. */\nfunction crashed(\n storyId: string,\n error: unknown,\n durationMs: number,\n): TestOutcome & { timedOut?: boolean } {\n return {\n storyId,\n status: \"failed\",\n error: messageOf(error),\n durationMs,\n timedOut: isTimeoutError(error),\n };\n}\n\nasync function openAndWait(\n page: Page,\n baseUrl: string,\n test: StoryEntry,\n input: PuppeteerTestsInput,\n): Promise<SettleReport> {\n if (input.navigationTimeoutMs) page.setDefaultTimeout(input.navigationTimeoutMs);\n const adapter = input.adapter ?? new StorybookAdapter();\n await page.goto(adapter.buildUrl(baseUrl, test.id), { waitUntil: \"load\" });\n return await waitForSettled(page, input.playTimeoutMs ?? DEFAULT_TEST_TIMEOUT_MS);\n}\n\n/** Read Storybook's own account of the run: a thrown play body or render error fails the test. */\nexport function verdictOf(\n storyId: string,\n report: SettleReport,\n timeoutMs: number,\n durationMs: number,\n): TestOutcome & { timedOut?: boolean } {\n const failed = (error: string, timedOut = false): TestOutcome & { timedOut?: boolean } => ({\n storyId,\n status: \"failed\",\n error,\n durationMs,\n ...(timedOut ? { timedOut } : {}),\n });\n if (!report.hooked) return failed(\"Storybook's preview did not start (no channel to listen to)\");\n if (report.renderError !== undefined) return failed(`render failed: ${report.renderError}`);\n if (report.playError !== undefined) return failed(report.playError);\n if (report.unhandled?.[0] !== undefined) return failed(report.unhandled[0]);\n if (!report.settled) {\n return failed(\n `test did not finish within ${timeoutMs}ms (phase: ${report.phase ?? \"unknown\"})`,\n true,\n );\n }\n return { storyId, status: \"passed\", durationMs };\n}\n"],"mappings":"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"}
package/dist/types.d.mts CHANGED
@@ -1,6 +1,8 @@
1
1
  import __tsdown_shims_path from 'node:path';
2
2
  import __tsdown_shims_url from 'node:url';
3
- import "@storyshelf/core/capture";
3
+ import "./pages.mjs";
4
+ import "@storyshelf/core/adapter/capture-runner";
5
+ import "@storyshelf/core/utils";
4
6
  import "puppeteer-core";
5
- import { BrowserName } from "@storyshelf/core/adapter/capture-runner";
7
+ import "@storyshelf/core/capture";
6
8
  import "@storyshelf/core/logger";