@storyshelf/runner-playwright 0.3.2 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,10 +16,20 @@ import { createPlaywrightCaptureRunner } from "@storyshelf/runner-playwright";
16
16
  const capture = createPlaywrightCaptureRunner();
17
17
  ```
18
18
 
19
- Supply it to `createShelfRouter({ capture, config: { scratchDir } })` — the router builds the orchestrator that loads the build, extracts the uploaded archive into `scratchDir`, discovers stories, calls `capture.render(...)`, and persists. `server` is the assembly point that hard-wires this renderer today (`storyshelf-server serve`, passing `--data-dir` as `scratchDir`). A future alternative (e.g. a remote runner offloading capture to a worker fleet) implements the same pure `CaptureRunner` interface from `@storyshelf/core/adapters/capture-runner` as its own package and is swapped in at the `serve` assembly site — the router, orchestrator, and pipeline never change.
19
+ Supply it to `createShelfApp({ capture, config: { scratchDir } })` — the router builds the orchestrator that loads the build, extracts the uploaded archive into `scratchDir`, discovers stories, calls `capture.render(...)`, and persists. `server` is the assembly point that hard-wires this renderer today (`storyshelf-server serve`, passing `--data-dir` as `scratchDir`). A future alternative (e.g. a remote runner offloading capture to a worker fleet) implements the same pure `CaptureRunner` interface from `@storyshelf/core/adapters/capture-runner` as its own package and is swapped in at the `serve` assembly site — the router, orchestrator, and pipeline never change.
20
+
21
+ ## Browsers
22
+
23
+ This package depends on `playwright-core` only — it never downloads browsers. Provision Chromium once per machine (pinned to the client version):
24
+
25
+ ```sh
26
+ npx -y playwright@1.63.0 install chromium
27
+ ```
28
+
29
+ The Fly image installs browsers at build time (`playwright install --with-deps chromium`); CI and local runs use the same command.
20
30
 
21
31
  ## How it fits
22
32
 
23
- Core's capture logic is browser-agnostic: the **orchestrator** (`@storyshelf/core/capture`) owns loading, extraction, discovery, and persistence, and delegates only the actual screenshotting to this package's `render(input) => RenderResult`. This package supplies the pure renderer and the ephemeral Storybook static server it needs. It is the only place that depends on `playwright`.
33
+ Core's capture logic is browser-agnostic: the **orchestrator** (`@storyshelf/core/capture`) owns loading, extraction, discovery, and persistence, and delegates only the actual screenshotting to this package's `render(input) => RenderResult`. This package supplies the pure renderer and the ephemeral Storybook static server it needs. It is the only place that depends on `playwright-core` (never full `playwright` — browsers are provisioned separately, see above).
24
34
 
25
- See `docs/architecture.md` for the capture workflow and `docs/testing.md` for the gated browser integration suite.
35
+ See `docs/architecture.md` for the capture workflow and `docs/testing.md` for the gated browser integration suite.
package/dist/a11y.mjs ADDED
@@ -0,0 +1,31 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ //#region src/a11y.ts
5
+ /** Run lightweight a11y checks inside the page context. */
6
+ async function checkA11y(page) {
7
+ return await page.evaluate(inPageA11y);
8
+ }
9
+ /**
10
+ * Runs inside the browser. Playwright/Puppeteer serialize only this function's source, so it must
11
+ * not reference anything from this module (helpers, imports): split it up and the page throws
12
+ * `ReferenceError`. `a11y.test.ts` evaluates it in a scope without module bindings to guard that.
13
+ */
14
+ function inPageA11y() {
15
+ const doc = globalThis.document;
16
+ const violations = [];
17
+ const root = doc?.querySelector("#storybook-root");
18
+ if (!root) return violations;
19
+ for (const img of root.querySelectorAll("img:not([alt])")) violations.push(`img missing alt: ${img.outerHTML.slice(0, 120)}`);
20
+ for (const btn of root.querySelectorAll("button")) if (!(btn.hasAttribute("aria-label") || btn.hasAttribute("aria-labelledby") || (btn.textContent ?? "").trim() !== "")) violations.push(`button missing label: ${btn.outerHTML.slice(0, 120)}`);
21
+ for (const anchor of root.querySelectorAll("a")) if (!anchor.hasAttribute("href")) violations.push(`a missing href: ${anchor.outerHTML.slice(0, 120)}`);
22
+ for (const input of root.querySelectorAll("input, select, textarea")) {
23
+ const el = input;
24
+ if (!(el.type === "hidden" || el.hasAttribute("aria-label") || el.hasAttribute("aria-labelledby") || el.id === "" || Boolean(doc.querySelector(`label[for="${el.id}"]`)))) violations.push(`input missing label: ${el.outerHTML.slice(0, 120)}`);
25
+ }
26
+ return violations.slice(0, 10);
27
+ }
28
+ //#endregion
29
+ export { checkA11y, inPageA11y };
30
+
31
+ //# sourceMappingURL=a11y.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"a11y.mjs","names":[],"sources":["../src/a11y.ts"],"sourcesContent":["/** Accessibility checks for captured pages. */\n\nimport type { Page } from \"playwright-core\";\n\n/** Run lightweight a11y checks inside the page context. */\nexport async function checkA11y(page: Page): Promise<string[]> {\n return await page.evaluate(inPageA11y);\n}\n\n/**\n * Runs inside the browser. Playwright/Puppeteer serialize only this function's source, so it must\n * not reference anything from this module (helpers, imports): split it up and the page throws\n * `ReferenceError`. `a11y.test.ts` evaluates it in a scope without module bindings to guard that.\n */\n// oxlint-disable-next-line eslint/max-statements, eslint/max-lines-per-function, eslint/complexity -- must stay one self-contained function (see above)\nexport function inPageA11y(): string[] {\n const doc = globalThis.document;\n const violations: string[] = [];\n const root = doc?.querySelector(\"#storybook-root\");\n if (!root) return violations;\n for (const img of root.querySelectorAll(\"img:not([alt])\")) {\n violations.push(`img missing alt: ${img.outerHTML.slice(0, 120)}`);\n }\n for (const btn of root.querySelectorAll(\"button\")) {\n const labelled =\n btn.hasAttribute(\"aria-label\") ||\n btn.hasAttribute(\"aria-labelledby\") ||\n (btn.textContent ?? \"\").trim() !== \"\";\n if (!labelled) violations.push(`button missing label: ${btn.outerHTML.slice(0, 120)}`);\n }\n for (const anchor of root.querySelectorAll(\"a\")) {\n if (!anchor.hasAttribute(\"href\"))\n violations.push(`a missing href: ${anchor.outerHTML.slice(0, 120)}`);\n }\n for (const input of root.querySelectorAll(\"input, select, textarea\")) {\n const el = input as HTMLInputElement;\n const labelled =\n el.type === \"hidden\" ||\n el.hasAttribute(\"aria-label\") ||\n el.hasAttribute(\"aria-labelledby\") ||\n el.id === \"\" ||\n Boolean(doc.querySelector(`label[for=\"${el.id}\"]`));\n if (!labelled) violations.push(`input missing label: ${el.outerHTML.slice(0, 120)}`);\n }\n return violations.slice(0, 10);\n}\n"],"mappings":";;;;;AAKA,eAAsB,UAAU,MAA+B;CAC7D,OAAO,MAAM,KAAK,SAAS,UAAU;AACvC;;;;;;AAQA,SAAgB,aAAuB;CACrC,MAAM,MAAM,WAAW;CACvB,MAAM,aAAuB,CAAC;CAC9B,MAAM,OAAO,KAAK,cAAc,iBAAiB;CACjD,IAAI,CAAC,MAAM,OAAO;CAClB,KAAK,MAAM,OAAO,KAAK,iBAAiB,gBAAgB,GACtD,WAAW,KAAK,oBAAoB,IAAI,UAAU,MAAM,GAAG,GAAG,GAAG;CAEnE,KAAK,MAAM,OAAO,KAAK,iBAAiB,QAAQ,GAK9C,IAAI,EAHF,IAAI,aAAa,YAAY,KAC7B,IAAI,aAAa,iBAAiB,MACjC,IAAI,eAAe,GAAA,CAAI,KAAK,MAAM,KACtB,WAAW,KAAK,yBAAyB,IAAI,UAAU,MAAM,GAAG,GAAG,GAAG;CAEvF,KAAK,MAAM,UAAU,KAAK,iBAAiB,GAAG,GAC5C,IAAI,CAAC,OAAO,aAAa,MAAM,GAC7B,WAAW,KAAK,mBAAmB,OAAO,UAAU,MAAM,GAAG,GAAG,GAAG;CAEvE,KAAK,MAAM,SAAS,KAAK,iBAAiB,yBAAyB,GAAG;EACpE,MAAM,KAAK;EAOX,IAAI,EALF,GAAG,SAAS,YACZ,GAAG,aAAa,YAAY,KAC5B,GAAG,aAAa,iBAAiB,KACjC,GAAG,OAAO,MACV,QAAQ,IAAI,cAAc,cAAc,GAAG,GAAG,GAAG,CAAC,IACrC,WAAW,KAAK,wBAAwB,GAAG,UAAU,MAAM,GAAG,GAAG,GAAG;CACrF;CACA,OAAO,WAAW,MAAM,GAAG,EAAE;AAC/B"}
@@ -0,0 +1,34 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ //#region src/browser.ts
5
+ /** Tracks in-flight renders so `cancel` can abort them. */
6
+ const activeRuns = /* @__PURE__ */ new Map();
7
+ /** Close a browser if present; `cancel` must never throw. */
8
+ async function closeBrowser(browser) {
9
+ if (!browser) return;
10
+ await tryCloseBrowser(browser);
11
+ }
12
+ async function tryCloseBrowser(browser) {
13
+ try {
14
+ await browser.close();
15
+ } catch {}
16
+ }
17
+ /** Best-effort close for the render's `finally` block. */
18
+ async function safeCloseBrowser(browser) {
19
+ await tryCloseBrowser(browser);
20
+ }
21
+ /** Best-effort close for the static server. */
22
+ async function safeCloseServer(server) {
23
+ try {
24
+ await server.close();
25
+ } catch {}
26
+ }
27
+ /** Normalize any thrown value to a string. */
28
+ function messageOf(error) {
29
+ return error instanceof Error ? error.message : String(error);
30
+ }
31
+ //#endregion
32
+ export { activeRuns, closeBrowser, messageOf, safeCloseBrowser, safeCloseServer };
33
+
34
+ //# sourceMappingURL=browser.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"browser.mjs","names":[],"sources":["../src/browser.ts"],"sourcesContent":["/** Browser lifecycle and active-run tracking for Playwright. */\n\nimport type { Browser } from \"playwright-core\";\nimport type { ActiveRun } from \"./types.ts\";\n\n/** Tracks in-flight renders so `cancel` can abort them. */\nexport const activeRuns = new Map<string, ActiveRun>();\n\n/** Close a browser if present; `cancel` must never throw. */\nexport async function closeBrowser(browser: Browser | null): Promise<void> {\n if (!browser) return;\n await tryCloseBrowser(browser);\n}\n\nasync function tryCloseBrowser(browser: Browser): Promise<void> {\n try {\n await browser.close();\n } catch {\n // The run's own `finally` performs final cleanup; cancel must never throw.\n }\n}\n\n/** Best-effort close for the render's `finally` block. */\nexport async function safeCloseBrowser(browser: Browser): Promise<void> {\n await tryCloseBrowser(browser);\n}\n\n/** Best-effort close for the static server. */\nexport async function safeCloseServer(server: { close(): Promise<void> }): Promise<void> {\n try {\n await server.close();\n } catch {\n // Best-effort; teardown must never mask a render result.\n }\n}\n\n/** Normalize any thrown value to a string. */\nexport function messageOf(error: unknown): string {\n return error instanceof Error ? error.message : String(error);\n}\n"],"mappings":";;;;;AAMA,MAAa,6BAAa,IAAI,IAAuB;;AAGrD,eAAsB,aAAa,SAAwC;CACzE,IAAI,CAAC,SAAS;CACd,MAAM,gBAAgB,OAAO;AAC/B;AAEA,eAAe,gBAAgB,SAAiC;CAC9D,IAAI;EACF,MAAM,QAAQ,MAAM;CACtB,QAAQ,CAER;AACF;;AAGA,eAAsB,iBAAiB,SAAiC;CACtE,MAAM,gBAAgB,OAAO;AAC/B;;AAGA,eAAsB,gBAAgB,QAAmD;CACvF,IAAI;EACF,MAAM,OAAO,MAAM;CACrB,QAAQ,CAER;AACF;;AAGA,SAAgB,UAAU,OAAwB;CAChD,OAAO,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;AAC9D"}
@@ -1,10 +1,14 @@
1
1
  import __tsdown_shims_path from 'node:path';
2
2
  import __tsdown_shims_url from 'node:url';
3
- import { CaptureRunner } from "@storyshelf/core/adapter/capture-runner";
4
- import "@storyshelf/core/logger";
3
+ import "./types.mjs";
4
+ import "./runtime-params.mjs";
5
+ import { BrowserName, CaptureRunner } from "@storyshelf/core/adapter/capture-runner";
5
6
  //#region src/capture-runner.d.ts
6
7
  /** Create a CaptureRunner that renders Storybook stories with Playwright. */
7
- declare function createPlaywrightCaptureRunner(): CaptureRunner;
8
+ declare function createPlaywrightCaptureRunner(options?: {
9
+ browser?: BrowserName;
10
+ supportedBrowsers?: readonly BrowserName[];
11
+ }): CaptureRunner;
8
12
  //#endregion
9
13
  export { createPlaywrightCaptureRunner };
10
14
  //# sourceMappingURL=capture-runner.d.mts.map
@@ -1,20 +1,23 @@
1
1
  import "node:path";
2
2
  import "node:url";
3
3
  import.meta.url;
4
- import { createStaticServer } from "./static-server.mjs";
5
- import { StorybookAdapter } from "@storyshelf/core/capture";
6
- import { chromium } from "playwright";
4
+ import { activeRuns, closeBrowser } from "./browser.mjs";
5
+ import "./runtime-params.mjs";
6
+ import { renderAll } from "./pipeline.mjs";
7
+ import { BROWSER_NAMES } from "@storyshelf/core/adapter/capture-runner";
7
8
  //#region src/capture-runner.ts
8
- const activeRuns = /* @__PURE__ */ new Map();
9
+ /** Playwright capture runner — composition root (factory wiring). */
9
10
  /** Create a CaptureRunner that renders Storybook stories with Playwright. */
10
- function createPlaywrightCaptureRunner() {
11
+ function createPlaywrightCaptureRunner(options = {}) {
12
+ const defaultBrowser = options.browser ?? "chromium";
11
13
  return {
12
14
  metadata: {
13
15
  name: "Playwright",
14
16
  version: globalThis.__PKG_VERSION__ ?? "0.0.0",
15
17
  description: "Playwright capture runner",
16
18
  kind: "playwright",
17
- category: "capture-runner"
19
+ category: "capture-runner",
20
+ supportedBrowsers: options.supportedBrowsers ?? BROWSER_NAMES
18
21
  },
19
22
  async render(input) {
20
23
  const active = {
@@ -23,7 +26,7 @@ function createPlaywrightCaptureRunner() {
23
26
  };
24
27
  activeRuns.set(input.buildId, active);
25
28
  try {
26
- return await renderAll(input, active);
29
+ return await renderAll(input, active, defaultBrowser);
27
30
  } finally {
28
31
  activeRuns.delete(input.buildId);
29
32
  }
@@ -36,127 +39,6 @@ function createPlaywrightCaptureRunner() {
36
39
  }
37
40
  };
38
41
  }
39
- async function closeBrowser(browser) {
40
- if (!browser) return;
41
- try {
42
- await browser.close();
43
- } catch {}
44
- }
45
- async function renderAll(input, active) {
46
- const server = await createStaticServer(input.storybookDir);
47
- const browser = await chromium.launch();
48
- active.browser = browser;
49
- const ctx = {
50
- browser,
51
- adapter: new StorybookAdapter(),
52
- baseUrl: server.url,
53
- executePlay: input.executePlay,
54
- playTimeoutMs: input.playTimeoutMs
55
- };
56
- const captures = [];
57
- const failures = [];
58
- try {
59
- const tasks = input.viewports.flatMap((viewport) => input.stories.map(async (story) => {
60
- if (active.cancelled) throw new Error("Capture cancelled");
61
- try {
62
- const screenshot = await captureScreenshot(ctx, story, viewport);
63
- captures.push({
64
- story,
65
- viewportName: viewport.name,
66
- screenshot
67
- });
68
- } catch (error) {
69
- failures.push({
70
- storyId: story.id,
71
- viewportName: viewport.name,
72
- error: messageOf(error)
73
- });
74
- input.logger?.error({
75
- storyId: story.id,
76
- viewport: viewport.name,
77
- err: error
78
- }, "render failed for story");
79
- }
80
- }));
81
- await Promise.all(tasks);
82
- return {
83
- captures,
84
- failures
85
- };
86
- } finally {
87
- active.browser = null;
88
- await Promise.all([safeCloseBrowser(browser), safeCloseServer(server)]);
89
- }
90
- }
91
- function messageOf(error) {
92
- return error instanceof Error ? error.message : String(error);
93
- }
94
- async function safeCloseBrowser(browser) {
95
- try {
96
- await browser.close();
97
- } catch {}
98
- }
99
- async function safeCloseServer(server) {
100
- try {
101
- await server.close();
102
- } catch {}
103
- }
104
- async function captureScreenshot(ctx, story, viewport) {
105
- const page = await ctx.browser.newPage({ viewport: {
106
- width: viewport.width,
107
- height: viewport.height
108
- } });
109
- try {
110
- await page.goto(ctx.adapter.buildUrl(ctx.baseUrl, story.id), { waitUntil: "networkidle" });
111
- if (ctx.adapter.screenshotSelector) {
112
- await page.waitForSelector(ctx.adapter.screenshotSelector, { state: "attached" });
113
- const delay = story.parameters?.delay ?? 500;
114
- await page.waitForTimeout(delay);
115
- if (story.parameters?.pauseAnimationAtEnd) await page.evaluate(() => {
116
- const doc = globalThis.document;
117
- for (const anim of doc.getAnimations()) anim.pause();
118
- });
119
- }
120
- if (ctx.executePlay) {
121
- const timeout = ctx.playTimeoutMs ?? 1e4;
122
- try {
123
- await page.evaluate(async ({ storyId, timeoutMs }) => {
124
- const preview = globalThis["__STORYBOOK_PREVIEW__"];
125
- if (!preview) return;
126
- let playError = null;
127
- const handler = (err) => {
128
- playError = err;
129
- };
130
- try {
131
- preview.channel?.on("playFunctionThrewException", handler);
132
- } catch {}
133
- if (preview.executePlay) await Promise.race([preview.executePlay(storyId), new Promise((_, reject) => {
134
- setTimeout(() => {
135
- reject(/* @__PURE__ */ new Error(`play timeout after ${timeoutMs}ms`));
136
- }, timeoutMs);
137
- })]);
138
- else if (preview.storyStore?.fromId) {
139
- const loaded = preview.storyStore.fromId(storyId);
140
- if (loaded?.play) await Promise.race([loaded.play({ canvasElement: globalThis.document.querySelector("#storybook-root") }), new Promise((_, reject) => {
141
- setTimeout(() => {
142
- reject(/* @__PURE__ */ new Error(`play timeout after ${timeoutMs}ms`));
143
- }, timeoutMs);
144
- })]);
145
- }
146
- if (playError) throw playError;
147
- }, {
148
- storyId: story.id,
149
- timeoutMs: timeout
150
- });
151
- } catch (error) {
152
- throw new Error(`play failed: ${messageOf(error)}`, { cause: error });
153
- }
154
- }
155
- return await page.screenshot({ animations: story.parameters?.pauseAnimationAtEnd ? "allow" : "disabled" });
156
- } finally {
157
- await page.close();
158
- }
159
- }
160
42
  //#endregion
161
43
  export { createPlaywrightCaptureRunner };
162
44
 
@@ -1 +1 @@
1
- {"version":3,"file":"capture-runner.mjs","names":["win"],"sources":["../src/capture-runner.ts"],"sourcesContent":["import type {\n CaptureRunner,\n RenderResult,\n RenderedSnapshot,\n StoryEntry,\n StorySourceAdapter,\n Viewport,\n} from \"@storyshelf/core/adapter/capture-runner\";\nimport { StorybookAdapter } from \"@storyshelf/core/capture\";\nimport type { Logger } from \"@storyshelf/core/logger\";\nimport { chromium, type Browser } from \"playwright\";\n\ndeclare const __PKG_VERSION__: string | undefined;\n\nimport { createStaticServer } from \"./static-server.ts\";\n\ninterface ScreenshotContext {\n browser: Browser;\n adapter: StorySourceAdapter;\n baseUrl: string;\n}\n\n/** A render that may currently be in flight, so that `cancel` can abort it. */\ninterface ActiveRun {\n cancelled: boolean;\n browser: Browser | null;\n}\n\nconst activeRuns = new Map<string, ActiveRun>();\n\n/** Input for a Playwright capture run over a built Storybook directory. */\nexport interface PlaywrightRenderInput {\n buildId: string;\n storybookDir: string;\n stories: StoryEntry[];\n viewports: Viewport[];\n logger?: Logger;\n executePlay?: boolean;\n playTimeoutMs?: number;\n}\n\n/** Create a CaptureRunner that renders Storybook stories with Playwright. */\nexport function createPlaywrightCaptureRunner(): CaptureRunner {\n return {\n metadata: {\n name: \"Playwright\",\n version: (globalThis as unknown as { __PKG_VERSION__?: string }).__PKG_VERSION__ ?? \"0.0.0\",\n description: \"Playwright capture runner\",\n kind: \"playwright\",\n category: \"capture-runner\",\n },\n async render(input: PlaywrightRenderInput) {\n const active: ActiveRun = { cancelled: false, browser: null };\n activeRuns.set(input.buildId, active);\n try {\n return await renderAll(input, active);\n } finally {\n activeRuns.delete(input.buildId);\n }\n },\n async cancel(buildId) {\n const active = activeRuns.get(buildId);\n if (!active) {\n return;\n }\n active.cancelled = true;\n await closeBrowser(active.browser);\n },\n };\n}\n\nasync function closeBrowser(browser: Browser | null): Promise<void> {\n if (!browser) {\n return;\n }\n try {\n await browser.close();\n } catch {\n // The run's own `finally` performs final cleanup; cancel must never throw.\n }\n}\n\nasync function renderAll(input: PlaywrightRenderInput, active: ActiveRun): Promise<RenderResult> {\n const server = await createStaticServer(input.storybookDir);\n const browser = await chromium.launch();\n active.browser = browser;\n const adapter = new StorybookAdapter();\n const ctx: ScreenshotContext & { executePlay?: boolean; playTimeoutMs?: number } = {\n browser,\n adapter,\n baseUrl: server.url,\n executePlay: input.executePlay,\n playTimeoutMs: input.playTimeoutMs,\n };\n const captures: RenderedSnapshot[] = [];\n const failures: RenderResult[\"failures\"] = [];\n try {\n const tasks = input.viewports.flatMap((viewport) =>\n input.stories.map(async (story) => {\n if (active.cancelled) {\n throw new Error(\"Capture cancelled\");\n }\n try {\n const screenshot = await captureScreenshot(ctx, story, viewport);\n captures.push({ story, viewportName: viewport.name, screenshot });\n } catch (error) {\n failures.push({\n storyId: story.id,\n viewportName: viewport.name,\n error: messageOf(error),\n });\n input.logger?.error(\n { storyId: story.id, viewport: viewport.name, err: error },\n \"render failed for story\",\n );\n }\n }),\n );\n await Promise.all(tasks);\n return { captures, failures };\n } finally {\n active.browser = null;\n await Promise.all([safeCloseBrowser(browser), safeCloseServer(server)]);\n }\n}\n\nfunction messageOf(error: unknown): string {\n return error instanceof Error ? error.message : String(error);\n}\n\nasync function safeCloseBrowser(browser: Browser): Promise<void> {\n try {\n await browser.close();\n } catch {\n // Already closed by `cancel`; the run must complete without throwing.\n }\n}\n\nasync function safeCloseServer(server: { close(): Promise<void> }): Promise<void> {\n try {\n await server.close();\n } catch {\n // Best-effort; teardown must never mask a render result.\n }\n}\n\nasync function captureScreenshot(\n ctx: ScreenshotContext & { executePlay?: boolean; playTimeoutMs?: number },\n story: StoryEntry,\n viewport: Viewport,\n): Promise<Buffer> {\n const page = await ctx.browser.newPage({\n viewport: { width: viewport.width, height: viewport.height },\n });\n try {\n await page.goto(ctx.adapter.buildUrl(ctx.baseUrl, story.id), { waitUntil: \"networkidle\" });\n if (ctx.adapter.screenshotSelector) {\n await page.waitForSelector(ctx.adapter.screenshotSelector, { state: \"attached\" });\n const delay = story.parameters?.delay ?? 500;\n await page.waitForTimeout(delay);\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 if (ctx.executePlay) {\n const timeout = ctx.playTimeoutMs ?? 10_000;\n try {\n await page.evaluate(\n async ({ storyId, timeoutMs }: { storyId: string; timeoutMs: number }) => {\n const win = globalThis as unknown as {\n __STORYBOOK_PREVIEW__?: {\n executePlay?: (id: string) => Promise<void>;\n storyStore?: { fromId?: (id: string) => { play?: () => Promise<void> } };\n channel?: { on: (e: string, cb: (err: unknown) => void) => void };\n };\n };\n const preview = win[\"__STORYBOOK_PREVIEW__\"];\n if (!preview) return;\n // Try channel-based error capture for play failures\n let playError: unknown = null;\n const handler = (err: unknown): void => {\n playError = err;\n };\n // Storybook channel emits playFunctionThrewException on failure\n try {\n preview.channel?.on(\"playFunctionThrewException\", handler);\n } catch {\n // Channel hookup is best-effort; play still runs without it.\n }\n // Try direct executePlay if available (custom StoryShelf preview addition)\n if (preview.executePlay) {\n await Promise.race([\n preview.executePlay(storyId),\n new Promise((_, reject) => {\n setTimeout(() => {\n reject(new Error(`play timeout after ${timeoutMs}ms`));\n }, timeoutMs);\n }),\n ]);\n } else if (preview.storyStore?.fromId) {\n const loaded = preview.storyStore.fromId(storyId) as unknown as {\n play?: (ctx: unknown) => Promise<void>;\n };\n if (loaded?.play) {\n await Promise.race([\n loaded.play({\n canvasElement: globalThis.document.querySelector(\"#storybook-root\"),\n }),\n new Promise((_, reject) => {\n setTimeout(() => {\n reject(new Error(`play timeout after ${timeoutMs}ms`));\n }, timeoutMs);\n }),\n ]);\n }\n }\n if (playError) throw playError;\n },\n { storyId: story.id, timeoutMs: timeout },\n );\n } catch (error) {\n throw new Error(`play failed: ${messageOf(error)}`, { cause: error });\n }\n }\n // Playwright page.screenshot supports animations: disabled to freeze CSS animations\n return await page.screenshot({\n animations: story.parameters?.pauseAnimationAtEnd ? \"allow\" : \"disabled\",\n });\n } finally {\n await page.close();\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"capture-runner.mjs","names":[],"sources":["../src/capture-runner.ts"],"sourcesContent":["/** Playwright capture runner — composition root (factory wiring). */\n\nimport {\n BROWSER_NAMES,\n type BrowserName,\n type CaptureRunner,\n} from \"@storyshelf/core/adapter/capture-runner\";\nimport { activeRuns, closeBrowser } from \"./browser.ts\";\nimport { renderAll } from \"./pipeline.ts\";\nimport type { ActiveRun, PlaywrightRenderInput } from \"./types.ts\";\n\ndeclare const __PKG_VERSION__: string | undefined;\n\n/** Create a CaptureRunner that renders Storybook stories with Playwright. */\nexport function createPlaywrightCaptureRunner(\n options: {\n browser?: BrowserName;\n supportedBrowsers?: readonly BrowserName[];\n } = {},\n): CaptureRunner {\n const defaultBrowser = options.browser ?? \"chromium\";\n return {\n metadata: {\n name: \"Playwright\",\n version: (globalThis as unknown as { __PKG_VERSION__?: string }).__PKG_VERSION__ ?? \"0.0.0\",\n description: \"Playwright capture runner\",\n kind: \"playwright\",\n category: \"capture-runner\",\n supportedBrowsers: options.supportedBrowsers ?? BROWSER_NAMES,\n },\n async render(input: PlaywrightRenderInput) {\n const active: ActiveRun = { cancelled: false, browser: null };\n activeRuns.set(input.buildId, active);\n try {\n return await renderAll(input, active, defaultBrowser);\n } finally {\n activeRuns.delete(input.buildId);\n }\n },\n async cancel(buildId) {\n const active = activeRuns.get(buildId);\n if (!active) return;\n active.cancelled = true;\n await closeBrowser(active.browser);\n },\n };\n}\n\n// Re-exports for backward compatibility (tests import these from capture-runner).\nexport type { PlaywrightRenderInput, RuntimeParamsState } from \"./types.ts\";\nexport { createRuntimeParamsState, runtimeParametersForStory } from \"./runtime-params.ts\";\n"],"mappings":";;;;;;;;;;AAcA,SAAgB,8BACd,UAGI,CAAC,GACU;CACf,MAAM,iBAAiB,QAAQ,WAAW;CAC1C,OAAO;EACL,UAAU;GACR,MAAM;GACN,SAAU,WAAuD,mBAAmB;GACpF,aAAa;GACb,MAAM;GACN,UAAU;GACV,mBAAmB,QAAQ,qBAAqB;EAClD;EACA,MAAM,OAAO,OAA8B;GACzC,MAAM,SAAoB;IAAE,WAAW;IAAO,SAAS;GAAK;GAC5D,WAAW,IAAI,MAAM,SAAS,MAAM;GACpC,IAAI;IACF,OAAO,MAAM,UAAU,OAAO,QAAQ,cAAc;GACtD,UAAU;IACR,WAAW,OAAO,MAAM,OAAO;GACjC;EACF;EACA,MAAM,OAAO,SAAS;GACpB,MAAM,SAAS,WAAW,IAAI,OAAO;GACrC,IAAI,CAAC,QAAQ;GACb,OAAO,YAAY;GACnB,MAAM,aAAa,OAAO,OAAO;EACnC;CACF;AACF"}
@@ -0,0 +1,100 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { messageOf, safeCloseBrowser, safeCloseServer } from "./browser.mjs";
5
+ import { createRuntimeParamsState } from "./runtime-params.mjs";
6
+ import { captureScreenshot } from "./screenshot.mjs";
7
+ import { createStaticServer } from "./static-server.mjs";
8
+ import { StorybookAdapter, resolveStoryViewports } from "@storyshelf/core/capture";
9
+ import { chromium, firefox, webkit } from "playwright-core";
10
+ //#region src/pipeline.ts
11
+ /** Render all story/viewport combinations for a build. */
12
+ async function renderAll(input, active, defaultBrowser) {
13
+ const server = await createStaticServer(input.storybookDir);
14
+ return await runWithBrowser(input, active, await launchBrowser(input.browser ?? defaultBrowser), server);
15
+ }
16
+ async function runWithBrowser(input, active, browser, server) {
17
+ active.browser = browser;
18
+ const ctx = buildContext(browser, server.url, input);
19
+ const captures = [];
20
+ const failures = [];
21
+ try {
22
+ await runAllCaptures(input, active, ctx, captures, failures);
23
+ return {
24
+ captures,
25
+ failures
26
+ };
27
+ } finally {
28
+ active.browser = null;
29
+ await Promise.all([safeCloseBrowser(browser), safeCloseServer(server)]);
30
+ }
31
+ }
32
+ async function launchBrowser(name) {
33
+ if (name === "firefox") return await firefox.launch();
34
+ if (name === "webkit") return await webkit.launch();
35
+ return await chromium.launch();
36
+ }
37
+ function buildContext(browser, baseUrl, input) {
38
+ return {
39
+ browser,
40
+ adapter: input.adapter ?? new StorybookAdapter(),
41
+ baseUrl,
42
+ executePlay: input.executePlay,
43
+ playTimeoutMs: input.playTimeoutMs,
44
+ runA11y: input.runA11y,
45
+ runtimeParams: createRuntimeParamsState()
46
+ };
47
+ }
48
+ async function runAllCaptures(input, active, ctx, captures, failures) {
49
+ const tasks = input.stories.flatMap((story) => viewportsTasks(story, input, active, ctx, captures, failures));
50
+ await Promise.all(tasks);
51
+ }
52
+ function viewportsTasks(story, input, active, ctx, captures, failures) {
53
+ return resolveStoryViewports(story, input.viewports).map((viewport) => captureOne(input, active, ctx, story, viewport, captures, failures));
54
+ }
55
+ async function captureOne(input, active, ctx, story, viewport, captures, failures) {
56
+ if (active.cancelled) throw new Error("Capture cancelled");
57
+ try {
58
+ await pushCapture(ctx, story, viewport, captures, failures, input);
59
+ } catch (error) {
60
+ pushRenderFailure(failures, input, story.id, viewport.name, error);
61
+ }
62
+ }
63
+ async function pushCapture(ctx, story, viewport, captures, failures, input) {
64
+ const result = await captureScreenshot(ctx, story, viewport);
65
+ captures.push({
66
+ story: result.story,
67
+ viewportName: viewport.name,
68
+ viewport,
69
+ screenshot: result.screenshot
70
+ });
71
+ if (result.a11yViolations.length > 0) handleA11y(input, failures, story, viewport, result.a11yViolations);
72
+ }
73
+ function handleA11y(input, failures, story, viewport, violations) {
74
+ failures.push({
75
+ storyId: story.id,
76
+ viewportName: viewport.name,
77
+ error: `a11y: ${violations.join("; ")}`
78
+ });
79
+ input.logger?.warn({
80
+ storyId: story.id,
81
+ viewport: viewport.name,
82
+ violations
83
+ }, "a11y violations found");
84
+ }
85
+ function pushRenderFailure(failures, input, storyId, viewportName, error) {
86
+ failures.push({
87
+ storyId,
88
+ viewportName,
89
+ error: messageOf(error)
90
+ });
91
+ input.logger?.error({
92
+ storyId,
93
+ viewport: viewportName,
94
+ err: error
95
+ }, "render failed for story");
96
+ }
97
+ //#endregion
98
+ export { renderAll };
99
+
100
+ //# sourceMappingURL=pipeline.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pipeline.mjs","names":[],"sources":["../src/pipeline.ts"],"sourcesContent":["/** Render orchestration: static server, browser, and per-story captures. */\n\nimport type {\n BrowserName,\n RenderResult,\n RenderedSnapshot,\n StoryEntry,\n Viewport,\n} from \"@storyshelf/core/adapter/capture-runner\";\nimport { StorybookAdapter, resolveStoryViewports } from \"@storyshelf/core/capture\";\nimport type { Browser } from \"playwright-core\";\nimport { chromium, firefox, webkit } from \"playwright-core\";\nimport { messageOf, safeCloseBrowser, safeCloseServer } from \"./browser.ts\";\nimport { createRuntimeParamsState } from \"./runtime-params.ts\";\nimport { captureScreenshot } from \"./screenshot.ts\";\nimport { createStaticServer } from \"./static-server.ts\";\nimport type { ActiveRun, CaptureContext, PlaywrightRenderInput } from \"./types.ts\";\n\n/** Render all story/viewport combinations for a build. */\nexport async function renderAll(\n input: PlaywrightRenderInput,\n active: ActiveRun,\n defaultBrowser: BrowserName,\n): Promise<RenderResult> {\n const server = await createStaticServer(input.storybookDir);\n const browser = await launchBrowser(input.browser ?? defaultBrowser);\n return await runWithBrowser(input, active, browser, server);\n}\n\nasync function runWithBrowser(\n input: PlaywrightRenderInput,\n active: ActiveRun,\n browser: Browser,\n server: Awaited<ReturnType<typeof createStaticServer>>,\n): Promise<RenderResult> {\n active.browser = browser;\n const ctx = buildContext(browser, server.url, input);\n const captures: RenderedSnapshot[] = [];\n const failures: RenderResult[\"failures\"] = [];\n try {\n await runAllCaptures(input, active, ctx, captures, failures);\n return { captures, failures };\n } finally {\n active.browser = null;\n await Promise.all([safeCloseBrowser(browser), safeCloseServer(server)]);\n }\n}\n\nasync function launchBrowser(name: BrowserName): Promise<Browser> {\n if (name === \"firefox\") return await firefox.launch();\n if (name === \"webkit\") return await webkit.launch();\n return await chromium.launch();\n}\n\nfunction buildContext(\n browser: Browser,\n baseUrl: string,\n input: PlaywrightRenderInput,\n): CaptureContext {\n return {\n browser,\n adapter: input.adapter ?? new StorybookAdapter(),\n baseUrl,\n executePlay: input.executePlay,\n playTimeoutMs: input.playTimeoutMs,\n runA11y: input.runA11y,\n runtimeParams: createRuntimeParamsState(),\n };\n}\n\nasync function runAllCaptures(\n input: PlaywrightRenderInput,\n active: ActiveRun,\n ctx: CaptureContext,\n captures: RenderedSnapshot[],\n failures: RenderResult[\"failures\"],\n): Promise<void> {\n const tasks = input.stories.flatMap((story) =>\n viewportsTasks(story, input, active, ctx, captures, failures),\n );\n await Promise.all(tasks);\n}\nfunction viewportsTasks(\n story: StoryEntry,\n input: PlaywrightRenderInput,\n active: ActiveRun,\n ctx: CaptureContext,\n captures: RenderedSnapshot[],\n failures: RenderResult[\"failures\"],\n): Promise<void>[] {\n // oxlint-disable-next-line typescript/promise-function-async -- returns array of promises, not a single promise\n return resolveStoryViewports(story, input.viewports).map((viewport) =>\n captureOne(input, active, ctx, story, viewport, captures, failures),\n );\n}\n\nasync function captureOne(\n input: PlaywrightRenderInput,\n active: ActiveRun,\n ctx: CaptureContext,\n story: StoryEntry,\n viewport: Viewport,\n captures: RenderedSnapshot[],\n failures: RenderResult[\"failures\"],\n): Promise<void> {\n if (active.cancelled) throw new Error(\"Capture cancelled\");\n try {\n await pushCapture(ctx, story, viewport, captures, failures, input);\n } catch (error) {\n pushRenderFailure(failures, input, story.id, viewport.name, error);\n }\n}\n\nasync function pushCapture(\n ctx: CaptureContext,\n story: StoryEntry,\n viewport: Viewport,\n captures: RenderedSnapshot[],\n failures: RenderResult[\"failures\"],\n input: PlaywrightRenderInput,\n): Promise<void> {\n const result = await captureScreenshot(ctx, story, viewport);\n captures.push({\n story: result.story,\n viewportName: viewport.name,\n viewport,\n screenshot: result.screenshot,\n });\n if (result.a11yViolations.length > 0)\n handleA11y(input, failures, story, viewport, result.a11yViolations);\n}\nfunction handleA11y(\n input: PlaywrightRenderInput,\n failures: RenderResult[\"failures\"],\n story: StoryEntry,\n viewport: Viewport,\n violations: string[],\n): void {\n failures.push({\n storyId: story.id,\n viewportName: viewport.name,\n error: `a11y: ${violations.join(\"; \")}`,\n });\n input.logger?.warn(\n { storyId: story.id, viewport: viewport.name, violations },\n \"a11y violations found\",\n );\n}\n\nfunction pushRenderFailure(\n failures: RenderResult[\"failures\"],\n input: PlaywrightRenderInput,\n storyId: string,\n viewportName: string,\n error: unknown,\n): void {\n failures.push({ storyId, viewportName, error: messageOf(error) });\n input.logger?.error({ storyId, viewport: viewportName, err: error }, \"render failed for story\");\n}\n"],"mappings":";;;;;;;;;;;AAmBA,eAAsB,UACpB,OACA,QACA,gBACuB;CACvB,MAAM,SAAS,MAAM,mBAAmB,MAAM,YAAY;CAE1D,OAAO,MAAM,eAAe,OAAO,QAAQ,MADrB,cAAc,MAAM,WAAW,cAAc,GACf,MAAM;AAC5D;AAEA,eAAe,eACb,OACA,QACA,SACA,QACuB;CACvB,OAAO,UAAU;CACjB,MAAM,MAAM,aAAa,SAAS,OAAO,KAAK,KAAK;CACnD,MAAM,WAA+B,CAAC;CACtC,MAAM,WAAqC,CAAC;CAC5C,IAAI;EACF,MAAM,eAAe,OAAO,QAAQ,KAAK,UAAU,QAAQ;EAC3D,OAAO;GAAE;GAAU;EAAS;CAC9B,UAAU;EACR,OAAO,UAAU;EACjB,MAAM,QAAQ,IAAI,CAAC,iBAAiB,OAAO,GAAG,gBAAgB,MAAM,CAAC,CAAC;CACxE;AACF;AAEA,eAAe,cAAc,MAAqC;CAChE,IAAI,SAAS,WAAW,OAAO,MAAM,QAAQ,OAAO;CACpD,IAAI,SAAS,UAAU,OAAO,MAAM,OAAO,OAAO;CAClD,OAAO,MAAM,SAAS,OAAO;AAC/B;AAEA,SAAS,aACP,SACA,SACA,OACgB;CAChB,OAAO;EACL;EACA,SAAS,MAAM,WAAW,IAAI,iBAAiB;EAC/C;EACA,aAAa,MAAM;EACnB,eAAe,MAAM;EACrB,SAAS,MAAM;EACf,eAAe,yBAAyB;CAC1C;AACF;AAEA,eAAe,eACb,OACA,QACA,KACA,UACA,UACe;CACf,MAAM,QAAQ,MAAM,QAAQ,SAAS,UACnC,eAAe,OAAO,OAAO,QAAQ,KAAK,UAAU,QAAQ,CAC9D;CACA,MAAM,QAAQ,IAAI,KAAK;AACzB;AACA,SAAS,eACP,OACA,OACA,QACA,KACA,UACA,UACiB;CAEjB,OAAO,sBAAsB,OAAO,MAAM,SAAS,CAAC,CAAC,KAAK,aACxD,WAAW,OAAO,QAAQ,KAAK,OAAO,UAAU,UAAU,QAAQ,CACpE;AACF;AAEA,eAAe,WACb,OACA,QACA,KACA,OACA,UACA,UACA,UACe;CACf,IAAI,OAAO,WAAW,MAAM,IAAI,MAAM,mBAAmB;CACzD,IAAI;EACF,MAAM,YAAY,KAAK,OAAO,UAAU,UAAU,UAAU,KAAK;CACnE,SAAS,OAAO;EACd,kBAAkB,UAAU,OAAO,MAAM,IAAI,SAAS,MAAM,KAAK;CACnE;AACF;AAEA,eAAe,YACb,KACA,OACA,UACA,UACA,UACA,OACe;CACf,MAAM,SAAS,MAAM,kBAAkB,KAAK,OAAO,QAAQ;CAC3D,SAAS,KAAK;EACZ,OAAO,OAAO;EACd,cAAc,SAAS;EACvB;EACA,YAAY,OAAO;CACrB,CAAC;CACD,IAAI,OAAO,eAAe,SAAS,GACjC,WAAW,OAAO,UAAU,OAAO,UAAU,OAAO,cAAc;AACtE;AACA,SAAS,WACP,OACA,UACA,OACA,UACA,YACM;CACN,SAAS,KAAK;EACZ,SAAS,MAAM;EACf,cAAc,SAAS;EACvB,OAAO,SAAS,WAAW,KAAK,IAAI;CACtC,CAAC;CACD,MAAM,QAAQ,KACZ;EAAE,SAAS,MAAM;EAAI,UAAU,SAAS;EAAM;CAAW,GACzD,uBACF;AACF;AAEA,SAAS,kBACP,UACA,OACA,SACA,cACA,OACM;CACN,SAAS,KAAK;EAAE;EAAS;EAAc,OAAO,UAAU,KAAK;CAAE,CAAC;CAChE,MAAM,QAAQ,MAAM;EAAE;EAAS,UAAU;EAAc,KAAK;CAAM,GAAG,yBAAyB;AAChG"}
package/dist/play.mjs ADDED
@@ -0,0 +1,50 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { messageOf } from "./browser.mjs";
5
+ //#region src/play.ts
6
+ /** Execute the story's play function with a timeout race. */
7
+ async function runPlay(page, storyId, timeoutMs) {
8
+ try {
9
+ await page.evaluate(inPagePlay, {
10
+ storyId,
11
+ timeoutMs
12
+ });
13
+ } catch (error) {
14
+ throw new Error(`play failed: ${messageOf(error)}`, { cause: error });
15
+ }
16
+ }
17
+ /**
18
+ * Runs inside the browser. Playwright serializes only this function's source, so it must not
19
+ * reference anything from this module (helpers, imports): split it up and the page throws
20
+ * `ReferenceError`. `play.test.ts` evaluates it in a scope without module bindings to guard that.
21
+ */
22
+ async function inPagePlay({ storyId, timeoutMs }) {
23
+ const preview = globalThis["__STORYBOOK_PREVIEW__"];
24
+ if (!preview) return;
25
+ let playError = null;
26
+ try {
27
+ preview.channel?.on("playFunctionThrewException", (err) => {
28
+ playError = err;
29
+ });
30
+ } catch {}
31
+ const canvasElement = globalThis.document?.querySelector("#storybook-root");
32
+ const work = preview.executePlay ? preview.executePlay(storyId) : preview.storyStore?.fromId?.(storyId)?.play?.({ canvasElement });
33
+ if (!work) return;
34
+ let timer;
35
+ const timeout = new Promise((_resolve, reject) => {
36
+ timer = setTimeout(() => {
37
+ reject(/* @__PURE__ */ new Error(`play timeout after ${timeoutMs}ms`));
38
+ }, timeoutMs);
39
+ });
40
+ try {
41
+ await Promise.race([work, timeout]);
42
+ } finally {
43
+ clearTimeout(timer);
44
+ }
45
+ if (playError) throw playError;
46
+ }
47
+ //#endregion
48
+ export { inPagePlay, runPlay };
49
+
50
+ //# sourceMappingURL=play.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"play.mjs","names":[],"sources":["../src/play.ts"],"sourcesContent":["/** Storybook `play` function execution inside the page. */\n\nimport type { Page } from \"playwright-core\";\nimport { messageOf } from \"./browser.ts\";\n\n/** Arguments serialized into the page for {@link inPagePlay}. */\nexport interface PlayArgs {\n storyId: string;\n timeoutMs: number;\n}\n\n/** Execute the story's play function with a timeout race. */\nexport async function runPlay(page: Page, storyId: string, timeoutMs: number): Promise<void> {\n try {\n await page.evaluate(inPagePlay, { storyId, timeoutMs });\n } catch (error) {\n throw new Error(`play failed: ${messageOf(error)}`, { cause: error });\n }\n}\n\n/**\n * Runs inside the browser. Playwright serializes only this function's source, so it must not\n * reference anything from this module (helpers, imports): split it up and the page throws\n * `ReferenceError`. `play.test.ts` evaluates it in a scope without module bindings to guard that.\n */\n// oxlint-disable-next-line eslint/max-statements -- must stay one self-contained function (see above)\nexport async function inPagePlay({ storyId, timeoutMs }: PlayArgs): Promise<void> {\n type Preview = {\n executePlay?: (id: string) => Promise<void>;\n storyStore?: { fromId?: (id: string) => { play?: (ctx: unknown) => Promise<void> } };\n channel?: { on: (event: string, cb: (err: unknown) => void) => void };\n };\n const preview = (globalThis as unknown as Record<string, Preview | undefined>)[\n \"__STORYBOOK_PREVIEW__\"\n ];\n if (!preview) return;\n let playError: unknown = null;\n try {\n // Best-effort: play still runs without the channel hookup.\n preview.channel?.on(\"playFunctionThrewException\", (err) => {\n playError = err;\n });\n } catch {\n // ignore\n }\n const canvasElement = globalThis.document?.querySelector(\"#storybook-root\");\n const work = preview.executePlay\n ? preview.executePlay(storyId)\n : preview.storyStore?.fromId?.(storyId)?.play?.({ canvasElement });\n if (!work) return;\n let timer: ReturnType<typeof setTimeout> | undefined;\n const timeout = new Promise<never>((_resolve, reject) => {\n timer = setTimeout(() => {\n reject(new Error(`play timeout after ${timeoutMs}ms`));\n }, timeoutMs);\n });\n try {\n await Promise.race([work, timeout]);\n } finally {\n clearTimeout(timer);\n }\n if (playError) throw playError;\n}\n"],"mappings":";;;;;;AAYA,eAAsB,QAAQ,MAAY,SAAiB,WAAkC;CAC3F,IAAI;EACF,MAAM,KAAK,SAAS,YAAY;GAAE;GAAS;EAAU,CAAC;CACxD,SAAS,OAAO;EACd,MAAM,IAAI,MAAM,gBAAgB,UAAU,KAAK,KAAK,EAAE,OAAO,MAAM,CAAC;CACtE;AACF;;;;;;AAQA,eAAsB,WAAW,EAAE,SAAS,aAAsC;CAMhF,MAAM,UAAW,WACf;CAEF,IAAI,CAAC,SAAS;CACd,IAAI,YAAqB;CACzB,IAAI;EAEF,QAAQ,SAAS,GAAG,+BAA+B,QAAQ;GACzD,YAAY;EACd,CAAC;CACH,QAAQ,CAER;CACA,MAAM,gBAAgB,WAAW,UAAU,cAAc,iBAAiB;CAC1E,MAAM,OAAO,QAAQ,cACjB,QAAQ,YAAY,OAAO,IAC3B,QAAQ,YAAY,SAAS,OAAO,CAAC,EAAE,OAAO,EAAE,cAAc,CAAC;CACnE,IAAI,CAAC,MAAM;CACX,IAAI;CACJ,MAAM,UAAU,IAAI,SAAgB,UAAU,WAAW;EACvD,QAAQ,iBAAiB;GACvB,uBAAO,IAAI,MAAM,sBAAsB,UAAU,GAAG,CAAC;EACvD,GAAG,SAAS;CACd,CAAC;CACD,IAAI;EACF,MAAM,QAAQ,KAAK,CAAC,MAAM,OAAO,CAAC;CACpC,UAAU;EACR,aAAa,KAAK;CACpB;CACA,IAAI,WAAW,MAAM;AACvB"}
@@ -0,0 +1,4 @@
1
+ import __tsdown_shims_path from 'node:path';
2
+ import __tsdown_shims_url from 'node:url';
3
+ import "./types.mjs";
4
+ import "@storyshelf/core/capture";
@@ -0,0 +1,60 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { mergeParameters } from "@storyshelf/core/capture";
5
+ //#region src/runtime-params.ts
6
+ /** Per-run runtime-parameter extraction cache (ADR 0017). */
7
+ /** Create the per-run runtime-parameter cache shared across captures. */
8
+ function createRuntimeParamsState() {
9
+ return {};
10
+ }
11
+ /**
12
+ * Read story parameters from the running preview via
13
+ * `__STORYBOOK_PREVIEW__.extract()`, once per run and lazily.
14
+ */
15
+ async function runtimeParametersForStory(state, page, storyId) {
16
+ return (state.extractFailed ? void 0 : await extractedParametersMap(state, page))?.get(storyId);
17
+ }
18
+ async function extractedParametersMap(state, page) {
19
+ state.extractPromise ??= loadExtractedParametersMap(state, page);
20
+ return await state.extractPromise;
21
+ }
22
+ async function loadExtractedParametersMap(state, page) {
23
+ return buildParamsMap(state, await safeReadExtracted(state, page));
24
+ }
25
+ async function safeReadExtracted(state, page) {
26
+ try {
27
+ return await readExtractedParameters(page);
28
+ } catch {
29
+ state.extractFailed = true;
30
+ return null;
31
+ }
32
+ }
33
+ function buildParamsMap(state, raw) {
34
+ const out = /* @__PURE__ */ new Map();
35
+ if (raw) fillParamsMap(out, raw);
36
+ if (out.size === 0) state.extractFailed = true;
37
+ return out;
38
+ }
39
+ function fillParamsMap(out, raw) {
40
+ for (const [id, entry] of Object.entries(raw)) {
41
+ const merged = mergeParameters(entry);
42
+ if (merged) out.set(id, merged);
43
+ }
44
+ }
45
+ async function readExtractedParameters(page) {
46
+ const map = await page.evaluate(() => {
47
+ const preview = globalThis["__STORYBOOK_PREVIEW__"];
48
+ if (!preview || typeof preview.extract !== "function") return null;
49
+ const extracted = preview.extract();
50
+ return extracted && typeof extracted === "object" ? extracted : null;
51
+ });
52
+ return hasEntries(map) ? map : null;
53
+ }
54
+ function hasEntries(map) {
55
+ return Boolean(map && Object.keys(map).length > 0);
56
+ }
57
+ //#endregion
58
+ export { createRuntimeParamsState, runtimeParametersForStory };
59
+
60
+ //# sourceMappingURL=runtime-params.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"runtime-params.mjs","names":["win"],"sources":["../src/runtime-params.ts"],"sourcesContent":["/** Per-run runtime-parameter extraction cache (ADR 0017). */\n\nimport { mergeParameters } from \"@storyshelf/core/capture\";\nimport type { StoryParameters } from \"@storyshelf/core/capture\";\nimport type { ExtractedEntry, RuntimeParamsPage, RuntimeParamsState } from \"./types.ts\";\n\n/** Create the per-run runtime-parameter cache shared across captures. */\nexport function createRuntimeParamsState(): RuntimeParamsState {\n return {};\n}\n\n/**\n * Read story parameters from the running preview via\n * `__STORYBOOK_PREVIEW__.extract()`, once per run and lazily.\n */\nexport async function runtimeParametersForStory(\n state: RuntimeParamsState,\n page: RuntimeParamsPage,\n storyId: string,\n): Promise<StoryParameters | undefined> {\n const map = state.extractFailed ? undefined : await extractedParametersMap(state, page);\n return map?.get(storyId);\n}\n\nasync function extractedParametersMap(\n state: RuntimeParamsState,\n page: RuntimeParamsPage,\n): Promise<Map<string, StoryParameters>> {\n state.extractPromise ??= loadExtractedParametersMap(state, page);\n return await state.extractPromise;\n}\n\nasync function loadExtractedParametersMap(\n state: RuntimeParamsState,\n page: RuntimeParamsPage,\n): Promise<Map<string, StoryParameters>> {\n const raw = await safeReadExtracted(state, page);\n return buildParamsMap(state, raw);\n}\n\nasync function safeReadExtracted(\n state: RuntimeParamsState,\n page: RuntimeParamsPage,\n): Promise<Record<string, ExtractedEntry> | null> {\n try {\n return await readExtractedParameters(page);\n } catch {\n state.extractFailed = true;\n return null;\n }\n}\n\nfunction buildParamsMap(\n state: RuntimeParamsState,\n raw: Record<string, ExtractedEntry> | null,\n): Map<string, StoryParameters> {\n const out = new Map<string, StoryParameters>();\n if (raw) fillParamsMap(out, raw);\n if (out.size === 0) state.extractFailed = true;\n return out;\n}\n\nfunction fillParamsMap(\n out: Map<string, StoryParameters>,\n raw: Record<string, ExtractedEntry>,\n): void {\n for (const [id, entry] of Object.entries(raw)) {\n const merged = mergeParameters(entry);\n if (merged) out.set(id, merged);\n }\n}\n\nasync function readExtractedParameters(\n page: RuntimeParamsPage,\n): Promise<Record<string, ExtractedEntry> | null> {\n const map = (await page.evaluate(() => {\n const win = globalThis as unknown as {\n __STORYBOOK_PREVIEW__?: { extract?: () => unknown };\n };\n // oxlint-disable-next-line typescript/dot-notation -- __STORYBOOK_PREVIEW__ is a Storybook global\n const preview = win[\"__STORYBOOK_PREVIEW__\"];\n if (!preview || typeof preview.extract !== \"function\") return null;\n const extracted = preview.extract();\n return extracted && typeof extracted === \"object\"\n ? (extracted as Record<string, ExtractedEntry>)\n : null;\n })) as Record<string, ExtractedEntry> | null;\n return hasEntries(map) ? map : null;\n}\n\nfunction hasEntries(map: Record<string, ExtractedEntry> | null): boolean {\n return Boolean(map && Object.keys(map).length > 0);\n}\n"],"mappings":";;;;;;;AAOA,SAAgB,2BAA+C;CAC7D,OAAO,CAAC;AACV;;;;;AAMA,eAAsB,0BACpB,OACA,MACA,SACsC;CAEtC,QADY,MAAM,gBAAgB,KAAA,IAAY,MAAM,uBAAuB,OAAO,IAAI,EAAA,EAC1E,IAAI,OAAO;AACzB;AAEA,eAAe,uBACb,OACA,MACuC;CACvC,MAAM,mBAAmB,2BAA2B,OAAO,IAAI;CAC/D,OAAO,MAAM,MAAM;AACrB;AAEA,eAAe,2BACb,OACA,MACuC;CAEvC,OAAO,eAAe,OAAO,MADX,kBAAkB,OAAO,IAAI,CACf;AAClC;AAEA,eAAe,kBACb,OACA,MACgD;CAChD,IAAI;EACF,OAAO,MAAM,wBAAwB,IAAI;CAC3C,QAAQ;EACN,MAAM,gBAAgB;EACtB,OAAO;CACT;AACF;AAEA,SAAS,eACP,OACA,KAC8B;CAC9B,MAAM,sBAAM,IAAI,IAA6B;CAC7C,IAAI,KAAK,cAAc,KAAK,GAAG;CAC/B,IAAI,IAAI,SAAS,GAAG,MAAM,gBAAgB;CAC1C,OAAO;AACT;AAEA,SAAS,cACP,KACA,KACM;CACN,KAAK,MAAM,CAAC,IAAI,UAAU,OAAO,QAAQ,GAAG,GAAG;EAC7C,MAAM,SAAS,gBAAgB,KAAK;EACpC,IAAI,QAAQ,IAAI,IAAI,IAAI,MAAM;CAChC;AACF;AAEA,eAAe,wBACb,MACgD;CAChD,MAAM,MAAO,MAAM,KAAK,eAAe;EAKrC,MAAM,UAAUA,WAAI;EACpB,IAAI,CAAC,WAAW,OAAO,QAAQ,YAAY,YAAY,OAAO;EAC9D,MAAM,YAAY,QAAQ,QAAQ;EAClC,OAAO,aAAa,OAAO,cAAc,WACpC,YACD;CACN,CAAC;CACD,OAAO,WAAW,GAAG,IAAI,MAAM;AACjC;AAEA,SAAS,WAAW,KAAqD;CACvE,OAAO,QAAQ,OAAO,OAAO,KAAK,GAAG,CAAC,CAAC,SAAS,CAAC;AACnD"}
@@ -0,0 +1,113 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { runtimeParametersForStory } from "./runtime-params.mjs";
5
+ import { checkA11y } from "./a11y.mjs";
6
+ import { runPlay } from "./play.mjs";
7
+ import { getScreenshotPlan } from "@storyshelf/core/capture";
8
+ //#region src/screenshot.ts
9
+ /** Capture a screenshot for one story/viewport, returning buffer + a11y violations. */
10
+ async function captureScreenshot(ctx, inputStory, viewport) {
11
+ const page = await ctx.browser.newPage({ viewport: {
12
+ width: viewport.width,
13
+ height: viewport.height
14
+ } });
15
+ try {
16
+ return await runCapture(page, ctx, inputStory, viewport);
17
+ } finally {
18
+ await page.close();
19
+ }
20
+ }
21
+ async function runCapture(page, ctx, inputStory, viewport) {
22
+ const story = await navigateAndEnrich(ctx, page, inputStory);
23
+ await maybeWaitForSelector(ctx, page, story);
24
+ if (ctx.executePlay) await runPlay(page, story.id, ctx.playTimeoutMs ?? 1e4);
25
+ const a11yViolations = await getA11y(ctx, page);
26
+ await settlePage(page, story);
27
+ const plan = await resolvePlan(page, viewport, story);
28
+ if (plan.viewport.width !== viewport.width || plan.viewport.height !== viewport.height) await page.setViewportSize(plan.viewport);
29
+ return {
30
+ screenshot: await burstShots(page, plan, story),
31
+ a11yViolations,
32
+ story
33
+ };
34
+ }
35
+ async function navigateAndEnrich(ctx, page, story) {
36
+ await page.goto(ctx.adapter.buildUrl(ctx.baseUrl, story.id), { waitUntil: "networkidle" });
37
+ await ctx.adapter.waitForReady?.(page);
38
+ if (story.parameters) return story;
39
+ const rpPage = page;
40
+ const params = await runtimeParametersForStory(ctx.runtimeParams, rpPage, story.id);
41
+ return params ? {
42
+ ...story,
43
+ parameters: params
44
+ } : story;
45
+ }
46
+ async function maybeWaitForSelector(ctx, page, story) {
47
+ if (!ctx.adapter.screenshotSelector) return;
48
+ await page.waitForSelector(ctx.adapter.screenshotSelector, { state: "attached" });
49
+ await page.waitForTimeout(story.parameters?.delay ?? 500);
50
+ if (story.parameters?.pauseAnimationAtEnd) await page.evaluate(() => {
51
+ const doc = globalThis.document;
52
+ for (const anim of doc.getAnimations()) anim.pause();
53
+ });
54
+ }
55
+ async function getA11y(ctx, page) {
56
+ if (!ctx.runA11y) return [];
57
+ try {
58
+ return await checkA11y(page);
59
+ } catch {
60
+ return [];
61
+ }
62
+ }
63
+ async function settlePage(page, story) {
64
+ const burst = story.parameters?.burst ?? 1;
65
+ if (story.parameters?.waitForFonts ?? burst > 1) try {
66
+ await page.evaluate(async () => {
67
+ await globalThis.document.fonts.ready;
68
+ });
69
+ } catch {}
70
+ try {
71
+ await page.evaluate(() => {
72
+ for (const vid of globalThis.document.querySelectorAll("video")) vid.pause();
73
+ });
74
+ } catch {}
75
+ }
76
+ async function resolvePlan(page, viewport, story) {
77
+ const box = await page.locator("#storybook-root").boundingBox().catch(() => null);
78
+ return getScreenshotPlan(viewport, box, {
79
+ autoCrop: story.parameters?.autoCrop,
80
+ whitespaceThreshold: story.parameters?.autoCropThreshold,
81
+ fullPageOnOverflow: story.parameters?.fullPageOnOverflow,
82
+ minWidth: story.parameters?.minWidth,
83
+ minHeight: story.parameters?.minHeight,
84
+ maxWidth: story.parameters?.maxWidth,
85
+ maxHeight: story.parameters?.maxHeight
86
+ });
87
+ }
88
+ async function burstShots(page, plan, story) {
89
+ const count = Math.max(1, Math.min(5, story.parameters?.burst ?? 1));
90
+ const shots = [];
91
+ for (let i = 0; i < count; i += 1) {
92
+ if (i > 0) await page.waitForTimeout(100);
93
+ shots.push(await shotOne(page, plan, story));
94
+ }
95
+ const preferred = shots[Math.floor(shots.length / 2)] ?? shots[0];
96
+ if (preferred) return preferred;
97
+ return await page.screenshot({
98
+ animations: story.parameters?.pauseAnimationAtEnd ? "allow" : "disabled",
99
+ ...plan.fullPage ? { fullPage: true } : {},
100
+ ...plan.clip ? { clip: plan.clip } : {}
101
+ });
102
+ }
103
+ async function shotOne(page, plan, story) {
104
+ return await page.screenshot({
105
+ animations: story.parameters?.pauseAnimationAtEnd ? "allow" : "disabled",
106
+ ...plan.fullPage ? { fullPage: true } : {},
107
+ ...plan.clip ? { clip: plan.clip } : {}
108
+ });
109
+ }
110
+ //#endregion
111
+ export { captureScreenshot };
112
+
113
+ //# sourceMappingURL=screenshot.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"screenshot.mjs","names":[],"sources":["../src/screenshot.ts"],"sourcesContent":["/** Screenshot pipeline for a single story + viewport. */\n\nimport type { StoryEntry, Viewport } from \"@storyshelf/core/adapter/capture-runner\";\nimport { getScreenshotPlan } from \"@storyshelf/core/capture\";\nimport type { Page } from \"playwright-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 viewport: { width: viewport.width, height: viewport.height },\n });\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.setViewportSize(plan.viewport);\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: \"networkidle\" });\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, { state: \"attached\" });\n await page.waitForTimeout(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 page\n .locator(\"#storybook-root\")\n .boundingBox()\n .catch(() => null);\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}\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 page.waitForTimeout(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 animations: story.parameters?.pauseAnimationAtEnd ? \"allow\" : \"disabled\",\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 animations: story.parameters?.pauseAnimationAtEnd ? \"allow\" : \"disabled\",\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"],"mappings":"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"}
@@ -1,26 +1,33 @@
1
1
  import "node:path";
2
2
  import "node:url";
3
3
  import.meta.url;
4
+ import { isPathSafe, mimeFor } from "@storyshelf/core/utils";
4
5
  import { createReadStream } from "node:fs";
5
6
  import { stat } from "node:fs/promises";
6
7
  import { createServer } from "node:http";
7
- import { extname, join, normalize, sep } from "node:path";
8
+ import { join, normalize } from "node:path";
8
9
  //#region src/static-server.ts
9
- const MIME_TYPES = {
10
- ".css": "text/css; charset=utf-8",
11
- ".html": "text/html; charset=utf-8",
12
- ".ico": "image/x-icon",
13
- ".jpeg": "image/jpeg",
14
- ".jpg": "image/jpeg",
15
- ".js": "text/javascript; charset=utf-8",
16
- ".json": "application/json; charset=utf-8",
17
- ".map": "application/json; charset=utf-8",
18
- ".mjs": "text/javascript; charset=utf-8",
19
- ".png": "image/png",
20
- ".svg": "image/svg+xml"
21
- };
22
- function mimeFor(file) {
23
- return MIME_TYPES[extname(file).toLowerCase()] ?? "application/octet-stream";
10
+ /**
11
+ * Serve a built Storybook directory over HTTP on 127.0.0.1 for capture.
12
+ *
13
+ * @param rootDir - Directory containing the built Storybook (`index.html` entry).
14
+ * @returns The server URL and a `close` function to stop it.
15
+ */
16
+ async function createStaticServer(rootDir) {
17
+ const root = normalize(rootDir);
18
+ const info = await stat(root).catch(() => null);
19
+ if (!info || !info.isDirectory()) throw new Error(`Storybook directory not found: ${rootDir}. Upload the built Storybook before capturing.`);
20
+ const server = createServer((req, res) => {
21
+ handleRequest(root, req.url ?? "/", res).catch(() => {
22
+ sendNotFound(res);
23
+ });
24
+ });
25
+ return {
26
+ url: `http://127.0.0.1:${await listen(server)}`,
27
+ close: async () => {
28
+ await closeServer(server);
29
+ }
30
+ };
24
31
  }
25
32
  function sendNotFound(res) {
26
33
  res.writeHead(404, { "content-type": "text/plain" });
@@ -29,7 +36,7 @@ function sendNotFound(res) {
29
36
  async function resolveFile(root, urlPath) {
30
37
  const clean = (urlPath.split("?")[0] ?? "/").replace(/^\/+/u, "");
31
38
  const target = normalize(join(root, clean === "" ? "index.html" : clean));
32
- if (target !== root && !target.startsWith(root + sep)) return null;
39
+ if (!isPathSafe(root, target)) return null;
33
40
  const info = await stat(target).catch(() => null);
34
41
  if (!info) return null;
35
42
  if (info.isDirectory()) return resolveFile(root, `${clean}/index.html`);
@@ -61,28 +68,6 @@ async function closeServer(server) {
61
68
  });
62
69
  });
63
70
  }
64
- /**
65
- * Serve a built Storybook directory over HTTP on 127.0.0.1 for capture.
66
- *
67
- * @param rootDir - Directory containing the built Storybook (`index.html` entry).
68
- * @returns The server URL and a `close` function to stop it.
69
- */
70
- async function createStaticServer(rootDir) {
71
- const root = normalize(rootDir);
72
- const info = await stat(root).catch(() => null);
73
- if (!info || !info.isDirectory()) throw new Error(`Storybook directory not found: ${rootDir}. Upload the built Storybook before capturing.`);
74
- const server = createServer((req, res) => {
75
- handleRequest(root, req.url ?? "/", res).catch(() => {
76
- sendNotFound(res);
77
- });
78
- });
79
- return {
80
- url: `http://127.0.0.1:${await listen(server)}`,
81
- close: async () => {
82
- await closeServer(server);
83
- }
84
- };
85
- }
86
71
  //#endregion
87
72
  export { createStaticServer };
88
73
 
@@ -1 +1 @@
1
- {"version":3,"file":"static-server.mjs","names":[],"sources":["../src/static-server.ts"],"sourcesContent":["import { createReadStream } from \"node:fs\";\nimport { stat } from \"node:fs/promises\";\nimport { createServer, type Server, type ServerResponse } from \"node:http\";\nimport { extname, join, normalize, sep } from \"node:path\";\n\n/** A local HTTP server serving a built Storybook directory for capture. */\nexport interface StaticServer {\n url: string;\n close(): Promise<void>;\n}\n\nconst MIME_TYPES: Record<string, string> = {\n \".css\": \"text/css; charset=utf-8\",\n \".html\": \"text/html; charset=utf-8\",\n \".ico\": \"image/x-icon\",\n \".jpeg\": \"image/jpeg\",\n \".jpg\": \"image/jpeg\",\n \".js\": \"text/javascript; charset=utf-8\",\n \".json\": \"application/json; charset=utf-8\",\n \".map\": \"application/json; charset=utf-8\",\n \".mjs\": \"text/javascript; charset=utf-8\",\n \".png\": \"image/png\",\n \".svg\": \"image/svg+xml\",\n};\n\nfunction mimeFor(file: string): string {\n return MIME_TYPES[extname(file).toLowerCase()] ?? \"application/octet-stream\";\n}\n\nfunction sendNotFound(res: ServerResponse): void {\n res.writeHead(404, { \"content-type\": \"text/plain\" });\n res.end(\"Not found\");\n}\n\nasync function resolveFile(root: string, urlPath: string): Promise<string | null> {\n const clean = (urlPath.split(\"?\")[0] ?? \"/\").replace(/^\\/+/u, \"\");\n const target = normalize(join(root, clean === \"\" ? \"index.html\" : clean));\n if (target !== root && !target.startsWith(root + sep)) {\n return null;\n }\n const info = await stat(target).catch(() => null);\n if (!info) {\n return null;\n }\n if (info.isDirectory()) {\n return resolveFile(root, `${clean}/index.html`);\n }\n return info.isFile() ? target : null;\n}\n\nasync function handleRequest(root: string, url: string, res: ServerResponse): Promise<void> {\n const file = await resolveFile(root, url);\n if (!file) {\n sendNotFound(res);\n return;\n }\n res.writeHead(200, { \"content-type\": mimeFor(file) });\n createReadStream(file).pipe(res);\n}\n\nasync function listen(server: Server): Promise<number> {\n return await new Promise((resolve, reject) => {\n server.once(\"error\", reject);\n server.listen(0, \"127.0.0.1\", () => {\n const address = server.address();\n resolve(typeof address === \"object\" && address !== null ? address.port : 0);\n });\n });\n}\n\nasync function closeServer(server: Server): Promise<void> {\n await new Promise<void>((resolve, reject) => {\n server.close((error) => {\n if (error) {\n reject(error);\n } else {\n resolve();\n }\n });\n });\n}\n\n/**\n * Serve a built Storybook directory over HTTP on 127.0.0.1 for capture.\n *\n * @param rootDir - Directory containing the built Storybook (`index.html` entry).\n * @returns The server URL and a `close` function to stop it.\n */\nexport async function createStaticServer(rootDir: string): Promise<StaticServer> {\n const root = normalize(rootDir);\n const info = await stat(root).catch(() => null);\n if (!info || !info.isDirectory()) {\n throw new Error(\n `Storybook directory not found: ${rootDir}. Upload the built Storybook before capturing.`,\n );\n }\n\n const server = createServer((req, res) => {\n handleRequest(root, req.url ?? \"/\", res).catch(() => {\n sendNotFound(res);\n });\n });\n\n const port = await listen(server);\n\n return {\n url: `http://127.0.0.1:${port}`,\n close: async () => {\n await closeServer(server);\n },\n };\n}\n"],"mappings":";;;;;;;;AAWA,MAAM,aAAqC;CACzC,QAAQ;CACR,SAAS;CACT,QAAQ;CACR,SAAS;CACT,QAAQ;CACR,OAAO;CACP,SAAS;CACT,QAAQ;CACR,QAAQ;CACR,QAAQ;CACR,QAAQ;AACV;AAEA,SAAS,QAAQ,MAAsB;CACrC,OAAO,WAAW,QAAQ,IAAI,CAAC,CAAC,YAAY,MAAM;AACpD;AAEA,SAAS,aAAa,KAA2B;CAC/C,IAAI,UAAU,KAAK,EAAE,gBAAgB,aAAa,CAAC;CACnD,IAAI,IAAI,WAAW;AACrB;AAEA,eAAe,YAAY,MAAc,SAAyC;CAChF,MAAM,SAAS,QAAQ,MAAM,GAAG,CAAC,CAAC,MAAM,IAAA,CAAK,QAAQ,SAAS,EAAE;CAChE,MAAM,SAAS,UAAU,KAAK,MAAM,UAAU,KAAK,eAAe,KAAK,CAAC;CACxE,IAAI,WAAW,QAAQ,CAAC,OAAO,WAAW,OAAO,GAAG,GAClD,OAAO;CAET,MAAM,OAAO,MAAM,KAAK,MAAM,CAAC,CAAC,YAAY,IAAI;CAChD,IAAI,CAAC,MACH,OAAO;CAET,IAAI,KAAK,YAAY,GACnB,OAAO,YAAY,MAAM,GAAG,MAAM,YAAY;CAEhD,OAAO,KAAK,OAAO,IAAI,SAAS;AAClC;AAEA,eAAe,cAAc,MAAc,KAAa,KAAoC;CAC1F,MAAM,OAAO,MAAM,YAAY,MAAM,GAAG;CACxC,IAAI,CAAC,MAAM;EACT,aAAa,GAAG;EAChB;CACF;CACA,IAAI,UAAU,KAAK,EAAE,gBAAgB,QAAQ,IAAI,EAAE,CAAC;CACpD,iBAAiB,IAAI,CAAC,CAAC,KAAK,GAAG;AACjC;AAEA,eAAe,OAAO,QAAiC;CACrD,OAAO,MAAM,IAAI,SAAS,SAAS,WAAW;EAC5C,OAAO,KAAK,SAAS,MAAM;EAC3B,OAAO,OAAO,GAAG,mBAAmB;GAClC,MAAM,UAAU,OAAO,QAAQ;GAC/B,QAAQ,OAAO,YAAY,YAAY,YAAY,OAAO,QAAQ,OAAO,CAAC;EAC5E,CAAC;CACH,CAAC;AACH;AAEA,eAAe,YAAY,QAA+B;CACxD,MAAM,IAAI,SAAe,SAAS,WAAW;EAC3C,OAAO,OAAO,UAAU;GACtB,IAAI,OACF,OAAO,KAAK;QAEZ,QAAQ;EAEZ,CAAC;CACH,CAAC;AACH;;;;;;;AAQA,eAAsB,mBAAmB,SAAwC;CAC/E,MAAM,OAAO,UAAU,OAAO;CAC9B,MAAM,OAAO,MAAM,KAAK,IAAI,CAAC,CAAC,YAAY,IAAI;CAC9C,IAAI,CAAC,QAAQ,CAAC,KAAK,YAAY,GAC7B,MAAM,IAAI,MACR,kCAAkC,QAAQ,+CAC5C;CAGF,MAAM,SAAS,cAAc,KAAK,QAAQ;EACxC,cAAc,MAAM,IAAI,OAAO,KAAK,GAAG,CAAC,CAAC,YAAY;GACnD,aAAa,GAAG;EAClB,CAAC;CACH,CAAC;CAID,OAAO;EACL,KAAK,oBAAoB,MAHR,OAAO,MAAM;EAI9B,OAAO,YAAY;GACjB,MAAM,YAAY,MAAM;EAC1B;CACF;AACF"}
1
+ {"version":3,"file":"static-server.mjs","names":[],"sources":["../src/static-server.ts"],"sourcesContent":["import { isPathSafe, mimeFor } from \"@storyshelf/core/utils\";\nimport { createReadStream } from \"node:fs\";\nimport { stat } from \"node:fs/promises\";\nimport { createServer, type Server, type ServerResponse } from \"node:http\";\nimport { join, normalize } from \"node:path\";\n\n/** A local HTTP server serving a built Storybook directory for capture. */\nexport interface StaticServer {\n url: string;\n close(): Promise<void>;\n}\n\n/**\n * Serve a built Storybook directory over HTTP on 127.0.0.1 for capture.\n *\n * @param rootDir - Directory containing the built Storybook (`index.html` entry).\n * @returns The server URL and a `close` function to stop it.\n */\nexport async function createStaticServer(rootDir: string): Promise<StaticServer> {\n const root = normalize(rootDir);\n const info = await stat(root).catch(() => null);\n if (!info || !info.isDirectory()) {\n throw new Error(\n `Storybook directory not found: ${rootDir}. Upload the built Storybook before capturing.`,\n );\n }\n\n const server = createServer((req, res) => {\n handleRequest(root, req.url ?? \"/\", res).catch(() => {\n sendNotFound(res);\n });\n });\n\n const port = await listen(server);\n\n return {\n url: `http://127.0.0.1:${port}`,\n close: async () => {\n await closeServer(server);\n },\n };\n}\n\nfunction sendNotFound(res: ServerResponse): void {\n res.writeHead(404, { \"content-type\": \"text/plain\" });\n res.end(\"Not found\");\n}\n\nasync function resolveFile(root: string, urlPath: string): Promise<string | null> {\n const clean = (urlPath.split(\"?\")[0] ?? \"/\").replace(/^\\/+/u, \"\");\n const target = normalize(join(root, clean === \"\" ? \"index.html\" : clean));\n if (!isPathSafe(root, target)) {\n return null;\n }\n const info = await stat(target).catch(() => null);\n if (!info) {\n return null;\n }\n if (info.isDirectory()) {\n return resolveFile(root, `${clean}/index.html`);\n }\n return info.isFile() ? target : null;\n}\n\nasync function handleRequest(root: string, url: string, res: ServerResponse): Promise<void> {\n const file = await resolveFile(root, url);\n if (!file) {\n sendNotFound(res);\n return;\n }\n res.writeHead(200, { \"content-type\": mimeFor(file) });\n createReadStream(file).pipe(res);\n}\n\nasync function listen(server: Server): Promise<number> {\n return await new Promise((resolve, reject) => {\n server.once(\"error\", reject);\n server.listen(0, \"127.0.0.1\", () => {\n const address = server.address();\n resolve(typeof address === \"object\" && address !== null ? address.port : 0);\n });\n });\n}\n\nasync function closeServer(server: Server): Promise<void> {\n await new Promise<void>((resolve, reject) => {\n server.close((error) => {\n if (error) {\n reject(error);\n } else {\n resolve();\n }\n });\n });\n}\n"],"mappings":";;;;;;;;;;;;;;;AAkBA,eAAsB,mBAAmB,SAAwC;CAC/E,MAAM,OAAO,UAAU,OAAO;CAC9B,MAAM,OAAO,MAAM,KAAK,IAAI,CAAC,CAAC,YAAY,IAAI;CAC9C,IAAI,CAAC,QAAQ,CAAC,KAAK,YAAY,GAC7B,MAAM,IAAI,MACR,kCAAkC,QAAQ,+CAC5C;CAGF,MAAM,SAAS,cAAc,KAAK,QAAQ;EACxC,cAAc,MAAM,IAAI,OAAO,KAAK,GAAG,CAAC,CAAC,YAAY;GACnD,aAAa,GAAG;EAClB,CAAC;CACH,CAAC;CAID,OAAO;EACL,KAAK,oBAAoB,MAHR,OAAO,MAAM;EAI9B,OAAO,YAAY;GACjB,MAAM,YAAY,MAAM;EAC1B;CACF;AACF;AAEA,SAAS,aAAa,KAA2B;CAC/C,IAAI,UAAU,KAAK,EAAE,gBAAgB,aAAa,CAAC;CACnD,IAAI,IAAI,WAAW;AACrB;AAEA,eAAe,YAAY,MAAc,SAAyC;CAChF,MAAM,SAAS,QAAQ,MAAM,GAAG,CAAC,CAAC,MAAM,IAAA,CAAK,QAAQ,SAAS,EAAE;CAChE,MAAM,SAAS,UAAU,KAAK,MAAM,UAAU,KAAK,eAAe,KAAK,CAAC;CACxE,IAAI,CAAC,WAAW,MAAM,MAAM,GAC1B,OAAO;CAET,MAAM,OAAO,MAAM,KAAK,MAAM,CAAC,CAAC,YAAY,IAAI;CAChD,IAAI,CAAC,MACH,OAAO;CAET,IAAI,KAAK,YAAY,GACnB,OAAO,YAAY,MAAM,GAAG,MAAM,YAAY;CAEhD,OAAO,KAAK,OAAO,IAAI,SAAS;AAClC;AAEA,eAAe,cAAc,MAAc,KAAa,KAAoC;CAC1F,MAAM,OAAO,MAAM,YAAY,MAAM,GAAG;CACxC,IAAI,CAAC,MAAM;EACT,aAAa,GAAG;EAChB;CACF;CACA,IAAI,UAAU,KAAK,EAAE,gBAAgB,QAAQ,IAAI,EAAE,CAAC;CACpD,iBAAiB,IAAI,CAAC,CAAC,KAAK,GAAG;AACjC;AAEA,eAAe,OAAO,QAAiC;CACrD,OAAO,MAAM,IAAI,SAAS,SAAS,WAAW;EAC5C,OAAO,KAAK,SAAS,MAAM;EAC3B,OAAO,OAAO,GAAG,mBAAmB;GAClC,MAAM,UAAU,OAAO,QAAQ;GAC/B,QAAQ,OAAO,YAAY,YAAY,YAAY,OAAO,QAAQ,OAAO,CAAC;EAC5E,CAAC;CACH,CAAC;AACH;AAEA,eAAe,YAAY,QAA+B;CACxD,MAAM,IAAI,SAAe,SAAS,WAAW;EAC3C,OAAO,OAAO,UAAU;GACtB,IAAI,OACF,OAAO,KAAK;QAEZ,QAAQ;EAEZ,CAAC;CACH,CAAC;AACH"}
@@ -0,0 +1,6 @@
1
+ import __tsdown_shims_path from 'node:path';
2
+ import __tsdown_shims_url from 'node:url';
3
+ import { BrowserName } from "@storyshelf/core/adapter/capture-runner";
4
+ import "@storyshelf/core/capture";
5
+ import "playwright-core";
6
+ import "@storyshelf/core/logger";
package/package.json CHANGED
@@ -1,7 +1,14 @@
1
1
  {
2
2
  "name": "@storyshelf/runner-playwright",
3
- "version": "0.3.2",
3
+ "version": "0.5.3",
4
4
  "description": "Pure Playwright CaptureRunner implementation for StoryShelf: renders Storybook screenshots in-process (no db/storage coupling).",
5
+ "keywords": [
6
+ "playwright",
7
+ "screenshot",
8
+ "self-hosted",
9
+ "storybook",
10
+ "visual-testing"
11
+ ],
5
12
  "homepage": "https://github.com/GuptaSiddhant/StoryShelf#readme",
6
13
  "bugs": {
7
14
  "url": "https://github.com/GuptaSiddhant/StoryShelf/issues"
@@ -42,25 +49,28 @@
42
49
  "fmt": "oxfmt ./src",
43
50
  "lint": "oxlint --type-aware --type-check ./src",
44
51
  "test": "vitest run",
45
- "test:integration": "RUN_INTEGRATION=1 vitest run integration.test.ts",
52
+ "test:integration": "RUN_INTEGRATION=1 vitest run integration.test.ts passkey.integration.test.ts",
46
53
  "prepublishOnly": "nub run build"
47
54
  },
48
55
  "dependencies": {
49
- "@storyshelf/core": "^0.3.2",
50
- "adm-zip": "^0.6.0",
51
- "playwright": "^1.52.0"
56
+ "@storyshelf/core": "0.5.3",
57
+ "playwright-core": "1.63.0"
52
58
  },
53
59
  "devDependencies": {
54
- "@storyshelf/db-sqlite": "workspace:*",
55
- "@storyshelf/storage-local": "workspace:*",
56
- "@types/node": "catalog:",
57
- "@vitest/coverage-v8": "catalog:",
58
- "oxfmt": "catalog:",
59
- "oxlint": "catalog:",
60
- "oxlint-tsgolint": "catalog:",
61
- "tsdown": "catalog:",
62
- "typescript": "catalog:",
63
- "vitest": "catalog:"
60
+ "@hono/node-server": "^1.17.0",
61
+ "@storyshelf/app": "0.5.3",
62
+ "@storyshelf/auth": "0.5.3",
63
+ "@storyshelf/db-sqlite": "0.5.3",
64
+ "@storyshelf/storage-local": "0.5.3",
65
+ "@types/node": "^26.0.0",
66
+ "@vitest/coverage-v8": "^4.0.0",
67
+ "adm-zip": "0.6.0",
68
+ "oxfmt": "^0.65.0",
69
+ "oxlint": "^1.80.0",
70
+ "oxlint-tsgolint": "^7.0.2001",
71
+ "tsdown": "^0.22.14",
72
+ "typescript": "^7.0.2",
73
+ "vitest": "^5.0.0"
64
74
  },
65
75
  "jsr": {
66
76
  "runtimeCompat": {