@storyshelf/runner-playwright 0.6.1 → 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.
Files changed (55) hide show
  1. package/README.md +48 -5
  2. package/dist/a11y.mjs +18 -20
  3. package/dist/a11y.mjs.map +1 -1
  4. package/dist/availability.mjs +65 -0
  5. package/dist/availability.mjs.map +1 -0
  6. package/dist/browser.mjs +4 -2
  7. package/dist/browser.mjs.map +1 -1
  8. package/dist/capture-runner.d.mts +18 -3
  9. package/dist/capture-runner.mjs +72 -13
  10. package/dist/capture-runner.mjs.map +1 -1
  11. package/dist/connect.mjs +79 -0
  12. package/dist/connect.mjs.map +1 -0
  13. package/dist/index.d.mts +2 -1
  14. package/dist/index.mjs +2 -1
  15. package/dist/launch.mjs +64 -0
  16. package/dist/launch.mjs.map +1 -0
  17. package/dist/pages.d.mts +4 -0
  18. package/dist/pages.mjs +91 -0
  19. package/dist/pages.mjs.map +1 -0
  20. package/dist/pipeline.mjs +154 -26
  21. package/dist/pipeline.mjs.map +1 -1
  22. package/dist/play.mjs +14 -1
  23. package/dist/play.mjs.map +1 -1
  24. package/dist/remote/azure.d.mts +26 -0
  25. package/dist/remote/azure.mjs +37 -0
  26. package/dist/remote/azure.mjs.map +1 -0
  27. package/dist/remote/browserstack.d.mts +33 -0
  28. package/dist/remote/browserstack.mjs +52 -0
  29. package/dist/remote/browserstack.mjs.map +1 -0
  30. package/dist/remote/index.d.mts +7 -0
  31. package/dist/remote/index.mjs +8 -0
  32. package/dist/remote/playwright-server.d.mts +40 -0
  33. package/dist/remote/playwright-server.mjs +34 -0
  34. package/dist/remote/playwright-server.mjs.map +1 -0
  35. package/dist/remote/remote.d.mts +22 -0
  36. package/dist/remote/remote.mjs +38 -0
  37. package/dist/remote/remote.mjs.map +1 -0
  38. package/dist/remote-environment.mjs +36 -0
  39. package/dist/remote-environment.mjs.map +1 -0
  40. package/dist/runtime-params.mjs +8 -1
  41. package/dist/runtime-params.mjs.map +1 -1
  42. package/dist/screenshot.mjs +40 -18
  43. package/dist/screenshot.mjs.map +1 -1
  44. package/dist/settle.mjs +150 -0
  45. package/dist/settle.mjs.map +1 -0
  46. package/dist/static-server.mjs +1 -1
  47. package/dist/targets.d.mts +26 -0
  48. package/dist/targets.mjs +73 -0
  49. package/dist/targets.mjs.map +1 -0
  50. package/dist/tests.mjs +114 -0
  51. package/dist/tests.mjs.map +1 -0
  52. package/dist/types.d.mts +5 -3
  53. package/dist/version.mjs +16 -0
  54. package/dist/version.mjs.map +1 -0
  55. package/package.json +17 -8
@@ -0,0 +1 @@
1
+ {"version":3,"file":"launch.mjs","names":[],"sources":["../src/launch.ts"],"sourcesContent":["/** Starting a local engine, and telling a browser that would not start from one that crashed. */\nimport { TargetLaunchError, type BrowserName } from \"@storyshelf/core/adapter/capture-runner\";\nimport type { Browser } from \"playwright-core\";\nimport type { LocalTarget } from \"./targets.ts\";\n\n/**\n * Low-memory Chromium flags. Deliberately excludes rendering-affecting flags\n * (`--disable-gpu`, `--single-process`): they can shift pixels or crash pages,\n * which would show up as false visual diffs.\n */\nconst CHROMIUM_ARGS = [\n \"--disable-dev-shm-usage\",\n \"--disable-extensions\",\n \"--disable-background-networking\",\n \"--mute-audio\",\n];\n\n/**\n * Playwright closes its browsers on SIGINT/SIGTERM/SIGHUP by default, which fails a render that a\n * graceful drain is still finishing (`TargetClosedError`). The host owns shutdown: it drains, then\n * closes every browser through `teardown`.\n */\nconst HOST_OWNS_SIGNALS = {\n handleSIGINT: false,\n handleSIGTERM: false,\n handleSIGHUP: false,\n} as const;\n\n/**\n * Launch `entry`'s engine. Playwright is loaded here, on first use, not at import: it costs\n * ~150 ms and tens of MB, which a server that only serves reviews or hosts Storybooks should not\n * pay at boot. A failure is a {@link TargetLaunchError}: the browser never started, so no story is\n * at fault and the job fails at once instead of going through crash isolation.\n */\nexport async function launchLocal(entry: LocalTarget): Promise<Browser> {\n try {\n const playwright = await loadPlaywright();\n return await launcherOf(entry.target, playwright)(entry.channel);\n } catch (error) {\n throw new TargetLaunchError(entry.target, firstLine(error), { cause: error });\n }\n}\n\nlet loaded: Promise<typeof import(\"playwright-core\")> | undefined;\n\n/** Load Playwright once, however many renders ask at the same time. */\nexport async function loadPlaywright(): Promise<typeof import(\"playwright-core\")> {\n loaded ??= import(\"playwright-core\");\n return await loaded;\n}\n\nfunction launcherOf(\n engine: BrowserName,\n playwright: typeof import(\"playwright-core\"),\n): (channel: string | undefined) => Promise<Browser> {\n if (engine === \"firefox\") {\n return async () => await playwright.firefox.launch(HOST_OWNS_SIGNALS);\n }\n if (engine === \"webkit\") {\n return async () => await playwright.webkit.launch(HOST_OWNS_SIGNALS);\n }\n // Containers ship a tiny /dev/shm; Chromium crashes (looks like OOM) unless it uses /tmp.\n return async (channel) =>\n await playwright.chromium.launch({\n ...HOST_OWNS_SIGNALS,\n args: CHROMIUM_ARGS,\n ...(channel === undefined ? {} : { channel }),\n });\n}\n\n/** Playwright's launch errors run to a screenful of install hints; the first line says what failed. */\nfunction firstLine(error: unknown): string {\n const text = error instanceof Error ? error.message : String(error);\n return (\n text\n .split(\"\\n\")\n .find((line) => line.trim() !== \"\")\n ?.trim() ?? \"the browser did not start\"\n );\n}\n"],"mappings":";;;;;;;;;;;AAUA,MAAM,gBAAgB;CACpB;CACA;CACA;CACA;AACF;;;;;;AAOA,MAAM,oBAAoB;CACxB,cAAc;CACd,eAAe;CACf,cAAc;AAChB;;;;;;;AAQA,eAAsB,YAAY,OAAsC;CACtE,IAAI;EACF,MAAM,aAAa,MAAM,eAAe;EACxC,OAAO,MAAM,WAAW,MAAM,QAAQ,UAAU,CAAC,CAAC,MAAM,OAAO;CACjE,SAAS,OAAO;EACd,MAAM,IAAI,kBAAkB,MAAM,QAAQ,UAAU,KAAK,GAAG,EAAE,OAAO,MAAM,CAAC;CAC9E;AACF;AAEA,IAAI;;AAGJ,eAAsB,iBAA4D;CAChF,WAAW,OAAO;CAClB,OAAO,MAAM;AACf;AAEA,SAAS,WACP,QACA,YACmD;CACnD,IAAI,WAAW,WACb,OAAO,YAAY,MAAM,WAAW,QAAQ,OAAO,iBAAiB;CAEtE,IAAI,WAAW,UACb,OAAO,YAAY,MAAM,WAAW,OAAO,OAAO,iBAAiB;CAGrE,OAAO,OAAO,YACZ,MAAM,WAAW,SAAS,OAAO;EAC/B,GAAG;EACH,MAAM;EACN,GAAI,YAAY,KAAA,IAAY,CAAC,IAAI,EAAE,QAAQ;CAC7C,CAAC;AACL;;AAGA,SAAS,UAAU,OAAwB;CAEzC,QADa,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK,EAAA,CAG7D,MAAM,IAAI,CAAC,CACX,MAAM,SAAS,KAAK,KAAK,MAAM,EAAE,CAAC,EACjC,KAAK,KAAK;AAElB"}
@@ -0,0 +1,4 @@
1
+ import __tsdown_shims_path from 'node:path';
2
+ import __tsdown_shims_url from 'node:url';
3
+ import "@storyshelf/core/adapter/capture-runner";
4
+ import "playwright-core";
package/dist/pages.mjs ADDED
@@ -0,0 +1,91 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { installSettleRecorder } from "./settle.mjs";
5
+ //#region src/pages.ts
6
+ /** Lease a page for `viewport`: pooled when the context has a pool, otherwise a throwaway. */
7
+ async function acquirePage(ctx, viewport) {
8
+ if (ctx.pagePool) return await ctx.pagePool.acquire(viewport);
9
+ const page = await ctx.browser.newPage({ viewport: {
10
+ width: viewport.width,
11
+ height: viewport.height
12
+ } });
13
+ await installSettleRecorder(page);
14
+ return {
15
+ page,
16
+ release: async () => {
17
+ await page.close();
18
+ }
19
+ };
20
+ }
21
+ /**
22
+ * Reuses pages (each in its own context) across captures to skip per-capture
23
+ * context/page startup and keep Storybook's JS in the HTTP cache. A page is
24
+ * reset between captures (storage and cookies cleared, blank document) and
25
+ * discarded, never reused, after a failed capture.
26
+ */
27
+ var PagePool = class {
28
+ free = [];
29
+ browser;
30
+ constructor(browser) {
31
+ this.browser = browser;
32
+ }
33
+ async acquire(viewport) {
34
+ const entry = this.free.pop() ?? await this.open(viewport);
35
+ await entry.page.setViewportSize({
36
+ width: viewport.width,
37
+ height: viewport.height
38
+ });
39
+ return {
40
+ page: entry.page,
41
+ release: async (reusable) => {
42
+ if (reusable && await reset(entry)) this.free.push(entry);
43
+ else await closeEntry(entry);
44
+ }
45
+ };
46
+ }
47
+ /** Close every idle page; call before the browser is replaced or closed. */
48
+ async dispose() {
49
+ await Promise.all(this.free.splice(0).map(async (entry) => {
50
+ await closeEntry(entry);
51
+ }));
52
+ }
53
+ async open(viewport) {
54
+ const context = await this.browser.newContext({ viewport: {
55
+ width: viewport.width,
56
+ height: viewport.height
57
+ } });
58
+ const page = await context.newPage();
59
+ await installSettleRecorder(page);
60
+ return {
61
+ context,
62
+ page
63
+ };
64
+ }
65
+ };
66
+ /** Wipe everything a story could leave behind; false when the page can't be trusted again. */
67
+ async function reset(entry) {
68
+ try {
69
+ await entry.page.evaluate(clearBrowserState);
70
+ await entry.context.clearCookies();
71
+ await entry.page.goto("about:blank");
72
+ return true;
73
+ } catch {
74
+ return false;
75
+ }
76
+ }
77
+ async function closeEntry(entry) {
78
+ await entry.context.close().catch(() => {});
79
+ }
80
+ /** Runs in the page: clears web storage and IndexedDB for the current origin. */
81
+ async function clearBrowserState() {
82
+ const scope = globalThis;
83
+ scope.localStorage.clear();
84
+ scope.sessionStorage.clear();
85
+ const databases = await scope.indexedDB?.databases?.() ?? [];
86
+ for (const database of databases) if (database.name) scope.indexedDB?.deleteDatabase(database.name);
87
+ }
88
+ //#endregion
89
+ export { PagePool, acquirePage };
90
+
91
+ //# sourceMappingURL=pages.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pages.mjs","names":[],"sources":["../src/pages.ts"],"sourcesContent":["/** Page leasing for captures: a fresh page each time, or a small reusable pool (opt-in). */\n\nimport type { Viewport } from \"@storyshelf/core/adapter/capture-runner\";\nimport type { Browser, BrowserContext, Page } from \"playwright-core\";\nimport { installSettleRecorder } from \"./settle.ts\";\n\n/** A page on loan for one capture; `release(false)` discards it instead of reusing it. */\nexport interface PageLease {\n page: Page;\n release(reusable: boolean): Promise<void>;\n}\n\n/** Lease a page for `viewport`: pooled when the context has a pool, otherwise a throwaway. */\nexport async function acquirePage(\n ctx: { browser: Browser; pagePool?: PagePool },\n viewport: Viewport,\n): Promise<PageLease> {\n if (ctx.pagePool) return await ctx.pagePool.acquire(viewport);\n const page = await ctx.browser.newPage({\n viewport: { width: viewport.width, height: viewport.height },\n });\n await installSettleRecorder(page);\n return {\n page,\n release: async () => {\n await page.close();\n },\n };\n}\n\ninterface Entry {\n context: BrowserContext;\n page: Page;\n}\n\n/**\n * Reuses pages (each in its own context) across captures to skip per-capture\n * context/page startup and keep Storybook's JS in the HTTP cache. A page is\n * reset between captures (storage and cookies cleared, blank document) and\n * discarded, never reused, after a failed capture.\n */\nexport class PagePool {\n private readonly free: Entry[] = [];\n\n private readonly browser: Browser;\n\n constructor(browser: Browser) {\n this.browser = browser;\n }\n\n async acquire(viewport: Viewport): Promise<PageLease> {\n const entry = this.free.pop() ?? (await this.open(viewport));\n await entry.page.setViewportSize({ width: viewport.width, height: viewport.height });\n return {\n page: entry.page,\n release: async (reusable) => {\n if (reusable && (await reset(entry))) this.free.push(entry);\n else await closeEntry(entry);\n },\n };\n }\n\n /** Close every idle page; call before the browser is replaced or closed. */\n async dispose(): Promise<void> {\n await Promise.all(\n this.free.splice(0).map(async (entry) => {\n await closeEntry(entry);\n }),\n );\n }\n\n private async open(viewport: Viewport): Promise<Entry> {\n const context = await this.browser.newContext({\n viewport: { width: viewport.width, height: viewport.height },\n });\n const page = await context.newPage();\n await installSettleRecorder(page);\n return { context, page };\n }\n}\n\n/** Wipe everything a story could leave behind; false when the page can't be trusted again. */\nasync function reset(entry: Entry): Promise<boolean> {\n try {\n await entry.page.evaluate(clearBrowserState);\n await entry.context.clearCookies();\n await entry.page.goto(\"about:blank\");\n return true;\n } catch {\n return false;\n }\n}\n\nasync function closeEntry(entry: Entry): Promise<void> {\n await entry.context.close().catch(() => {});\n}\n\n/** Runs in the page: clears web storage and IndexedDB for the current origin. */\nasync function clearBrowserState(): Promise<void> {\n const scope = globalThis as unknown as {\n localStorage: { clear(): void };\n sessionStorage: { clear(): void };\n indexedDB?: {\n databases?: () => Promise<{ name?: string }[]>;\n deleteDatabase(name: string): unknown;\n };\n };\n scope.localStorage.clear();\n scope.sessionStorage.clear();\n const databases = (await scope.indexedDB?.databases?.()) ?? [];\n for (const database of databases) {\n if (database.name) scope.indexedDB?.deleteDatabase(database.name);\n }\n}\n"],"mappings":";;;;;;AAaA,eAAsB,YACpB,KACA,UACoB;CACpB,IAAI,IAAI,UAAU,OAAO,MAAM,IAAI,SAAS,QAAQ,QAAQ;CAC5D,MAAM,OAAO,MAAM,IAAI,QAAQ,QAAQ,EACrC,UAAU;EAAE,OAAO,SAAS;EAAO,QAAQ,SAAS;CAAO,EAC7D,CAAC;CACD,MAAM,sBAAsB,IAAI;CAChC,OAAO;EACL;EACA,SAAS,YAAY;GACnB,MAAM,KAAK,MAAM;EACnB;CACF;AACF;;;;;;;AAaA,IAAa,WAAb,MAAsB;CACpB,OAAiC,CAAC;CAElC;CAEA,YAAY,SAAkB;EAC5B,KAAK,UAAU;CACjB;CAEA,MAAM,QAAQ,UAAwC;EACpD,MAAM,QAAQ,KAAK,KAAK,IAAI,KAAM,MAAM,KAAK,KAAK,QAAQ;EAC1D,MAAM,MAAM,KAAK,gBAAgB;GAAE,OAAO,SAAS;GAAO,QAAQ,SAAS;EAAO,CAAC;EACnF,OAAO;GACL,MAAM,MAAM;GACZ,SAAS,OAAO,aAAa;IAC3B,IAAI,YAAa,MAAM,MAAM,KAAK,GAAI,KAAK,KAAK,KAAK,KAAK;SACrD,MAAM,WAAW,KAAK;GAC7B;EACF;CACF;;CAGA,MAAM,UAAyB;EAC7B,MAAM,QAAQ,IACZ,KAAK,KAAK,OAAO,CAAC,CAAC,CAAC,IAAI,OAAO,UAAU;GACvC,MAAM,WAAW,KAAK;EACxB,CAAC,CACH;CACF;CAEA,MAAc,KAAK,UAAoC;EACrD,MAAM,UAAU,MAAM,KAAK,QAAQ,WAAW,EAC5C,UAAU;GAAE,OAAO,SAAS;GAAO,QAAQ,SAAS;EAAO,EAC7D,CAAC;EACD,MAAM,OAAO,MAAM,QAAQ,QAAQ;EACnC,MAAM,sBAAsB,IAAI;EAChC,OAAO;GAAE;GAAS;EAAK;CACzB;AACF;;AAGA,eAAe,MAAM,OAAgC;CACnD,IAAI;EACF,MAAM,MAAM,KAAK,SAAS,iBAAiB;EAC3C,MAAM,MAAM,QAAQ,aAAa;EACjC,MAAM,MAAM,KAAK,KAAK,aAAa;EACnC,OAAO;CACT,QAAQ;EACN,OAAO;CACT;AACF;AAEA,eAAe,WAAW,OAA6B;CACrD,MAAM,MAAM,QAAQ,MAAM,CAAC,CAAC,YAAY,CAAC,CAAC;AAC5C;;AAGA,eAAe,oBAAmC;CAChD,MAAM,QAAQ;CAQd,MAAM,aAAa,MAAM;CACzB,MAAM,eAAe,MAAM;CAC3B,MAAM,YAAa,MAAM,MAAM,WAAW,YAAY,KAAM,CAAC;CAC7D,KAAK,MAAM,YAAY,WACrB,IAAI,SAAS,MAAM,MAAM,WAAW,eAAe,SAAS,IAAI;AAEpE"}
package/dist/pipeline.mjs CHANGED
@@ -2,72 +2,200 @@ import "node:path";
2
2
  import "node:url";
3
3
  import.meta.url;
4
4
  import { messageOf, safeCloseBrowser, safeCloseServer } from "./browser.mjs";
5
- import { createRuntimeParamsState } from "./runtime-params.mjs";
5
+ import { PagePool } from "./pages.mjs";
6
+ import { measureRemote } from "./remote-environment.mjs";
7
+ import { createRuntimeParamsState, extractAllParameters } from "./runtime-params.mjs";
6
8
  import { captureScreenshot } from "./screenshot.mjs";
7
9
  import { createStaticServer } from "./static-server.mjs";
10
+ import { TargetLaunchError, engineOf } from "@storyshelf/core/adapter/capture-runner";
11
+ import { DEFAULT_RECYCLE_BROWSER_EVERY, chunk, createPageLimiter, describeSizing, detectCaptureEnvironment, isTimeoutError, sessionGateFor, storybookBase } from "@storyshelf/core/utils";
8
12
  import { StorybookAdapter, resolveStoryViewports } from "@storyshelf/core/capture";
9
- import { chromium, firefox, webkit } from "playwright-core";
10
13
  //#region src/pipeline.ts
14
+ /** Render orchestration: static server, browser, and per-story captures. */
11
15
  /** 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);
16
+ async function renderAll(rawInput, active, driver, defaultTarget) {
17
+ const { input, name, remote } = planRun(rawInput, driver, defaultTarget);
18
+ assertReachable(name, remote, input.storybookUrl);
19
+ const browser = await driver.launch(name, input.logger);
20
+ const server = await serve(input, browser);
21
+ input.logger?.info(describeSizing({
22
+ ...input,
23
+ browser: engineOf(name)
24
+ }), "capture sizing");
25
+ const measured = remote ? await measureRemote(browser, remote.remote, input.logger) : void 0;
26
+ const result = await runWithBrowser(input, active, browser, server, async () => await driver.launch(name, input.logger));
27
+ for (const row of [...result.captures, ...result.failures]) row.browser = name;
28
+ return {
29
+ ...result,
30
+ environment: measured ?? await localEnvironment(browser)
31
+ };
32
+ }
33
+ /** The input with its target resolved, and the session gate when the target is remote. */
34
+ function planRun(rawInput, driver, defaultTarget) {
35
+ const name = rawInput.target ?? defaultTarget;
36
+ const remote = driver.remote(name);
37
+ const gate = remote && sessionGateFor(name, remote.remote.sessions);
38
+ return {
39
+ input: {
40
+ ...rawInput,
41
+ target: name,
42
+ gate
43
+ },
44
+ name,
45
+ remote
46
+ };
15
47
  }
16
- async function runWithBrowser(input, active, browser, server) {
48
+ /**
49
+ * Load the preview once and read every story's resolved parameters
50
+ * (`__STORYBOOK_PREVIEW__.extract()`), so the orchestrator can plan modes.
51
+ */
52
+ async function extractAll(input, driver, defaultTarget) {
53
+ const first = input.stories[0];
54
+ if (!first) return {};
55
+ const name = input.target ?? defaultTarget;
56
+ assertReachable(name, driver.remote(name), input.storybookUrl);
57
+ const browser = await driver.launch(name, input.logger);
58
+ const server = await serve(input, browser);
59
+ try {
60
+ return await readParameters(browser, server.url, first.id, input.adapter);
61
+ } finally {
62
+ await Promise.all([safeCloseBrowser(browser), safeCloseServer(server)]);
63
+ }
64
+ }
65
+ /** A target whose browser cannot route back to this host needs a URL; failing here names why. */
66
+ function assertReachable(target, remote, storybookUrl) {
67
+ if (remote?.reach === "url" && storybookUrl === void 0) throw new TargetLaunchError(target, "its browser cannot reach this host, so it needs a Storybook URL (a public origin and a secret to sign it) and none was given", { transient: true });
68
+ if (storybookUrl !== void 0) assertStorybookRoot(target, storybookUrl);
69
+ }
70
+ /** The URL must be the build's directory root; refused before any browser connects. */
71
+ function assertStorybookRoot(target, storybookUrl) {
72
+ try {
73
+ storybookBase(storybookUrl);
74
+ } catch (error) {
75
+ throw new TargetLaunchError(target, error instanceof Error ? error.message : String(error));
76
+ }
77
+ }
78
+ /** The loopback server, or the URL given; a server that cannot start closes the browser it was for. */
79
+ async function serve(input, browser) {
80
+ if (input.storybookUrl !== void 0) return {
81
+ url: storybookBase(input.storybookUrl),
82
+ close: async () => {
83
+ await Promise.resolve();
84
+ }
85
+ };
86
+ return await createStaticServer(input.storybookDir).catch(async (error) => {
87
+ await safeCloseBrowser(browser);
88
+ throw error;
89
+ });
90
+ }
91
+ async function readParameters(browser, baseUrl, storyId, custom) {
92
+ const adapter = custom ?? new StorybookAdapter();
93
+ const page = await browser.newPage();
94
+ await page.goto(adapter.buildUrl(baseUrl, storyId), { waitUntil: "networkidle" });
95
+ await adapter.waitForReady?.(page);
96
+ return await extractAllParameters(page);
97
+ }
98
+ /** The driver's browser version; undefined for a driver (or test double) that does not report one. */
99
+ function browserVersionOf(browser) {
100
+ return typeof browser.version === "function" ? browser.version() : void 0;
101
+ }
102
+ /** This host's environment: a local browser renders on the machine the runner runs on. */
103
+ async function localEnvironment(browser) {
104
+ return await detectCaptureEnvironment(browserVersionOf(browser));
105
+ }
106
+ async function runWithBrowser(input, active, browser, server, relaunch) {
17
107
  active.browser = browser;
18
108
  const ctx = buildContext(browser, server.url, input);
19
109
  const captures = [];
20
110
  const failures = [];
21
111
  try {
22
- await runAllCaptures(input, active, ctx, captures, failures);
112
+ await runAllCaptures(input, active, ctx, captures, failures, relaunch);
23
113
  return {
24
114
  captures,
25
115
  failures
26
116
  };
27
117
  } finally {
28
118
  active.browser = null;
29
- await Promise.all([safeCloseBrowser(browser), safeCloseServer(server)]);
119
+ await ctx.pagePool?.dispose();
120
+ await Promise.all([safeCloseBrowser(ctx.browser), safeCloseServer(server)]);
30
121
  }
31
122
  }
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
123
  function buildContext(browser, baseUrl, input) {
38
124
  return {
39
125
  browser,
40
126
  adapter: input.adapter ?? new StorybookAdapter(),
41
127
  baseUrl,
128
+ navigationTimeoutMs: input.navigationTimeoutMs,
42
129
  executePlay: input.executePlay,
43
130
  playTimeoutMs: input.playTimeoutMs,
44
131
  runA11y: input.runA11y,
45
- runtimeParams: createRuntimeParamsState()
132
+ ...input.logger ? { logger: input.logger } : {},
133
+ runtimeParams: createRuntimeParamsState(),
134
+ ...input.reusePages ? { pagePool: new PagePool(browser) } : {}
46
135
  };
47
136
  }
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);
137
+ async function runAllCaptures(input, active, ctx, captures, failures, relaunch) {
138
+ const jobs = input.stories.flatMap((story) => resolveStoryViewports(story, input.viewports).map((viewport) => ({
139
+ story,
140
+ viewport
141
+ })));
142
+ const batches = chunk(jobs, input.recycleBrowserEvery ?? DEFAULT_RECYCLE_BROWSER_EVERY);
143
+ const pages = createPageLimiter({
144
+ concurrency: input.concurrency,
145
+ ...input.gate === void 0 ? {} : { gate: input.gate },
146
+ browser: engineOf(input.target ?? "chromium"),
147
+ logger: input.logger
148
+ });
149
+ try {
150
+ await runBatches(batches, pages, input, active, ctx, {
151
+ captures,
152
+ failures,
153
+ relaunch
154
+ });
155
+ } finally {
156
+ pages.dispose();
157
+ }
158
+ }
159
+ async function runBatches(batches, pages, input, active, ctx, out) {
160
+ for (const [index, batch] of batches.entries()) {
161
+ if (index > 0) await recycleBrowser(active, ctx, out.relaunch, input.logger);
162
+ await pages.each(batch, async (job) => await captureOne(input, active, ctx, job.story, job.viewport, out));
163
+ }
51
164
  }
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));
165
+ /** Swap in a fresh browser; the old one is closed first so two never coexist. */
166
+ async function recycleBrowser(active, ctx, relaunch, logger) {
167
+ if (active.cancelled) throw new Error("Capture cancelled");
168
+ await ctx.pagePool?.dispose();
169
+ await safeCloseBrowser(ctx.browser);
170
+ ctx.browser = await relaunch();
171
+ if (ctx.pagePool) ctx.pagePool = new PagePool(ctx.browser);
172
+ active.browser = ctx.browser;
173
+ logger?.info({}, "browser recycled");
54
174
  }
55
- async function captureOne(input, active, ctx, story, viewport, captures, failures) {
175
+ /** Capture one story x viewport; resolves to whether it timed out (failures are recorded, not thrown). */
176
+ async function captureOne(input, active, ctx, story, viewport, out) {
56
177
  if (active.cancelled) throw new Error("Capture cancelled");
57
178
  try {
58
- await pushCapture(ctx, story, viewport, captures, failures, input);
179
+ await pushCapture(ctx, story, viewport, out.captures, out.failures, input);
180
+ return false;
59
181
  } catch (error) {
60
- pushRenderFailure(failures, input, story.id, viewport.name, error);
182
+ pushRenderFailure(out.failures, input, story.id, viewport.name, error);
183
+ return isTimeoutError(error);
61
184
  }
62
185
  }
63
186
  async function pushCapture(ctx, story, viewport, captures, failures, input) {
187
+ const started = performance.now();
64
188
  const result = await captureScreenshot(ctx, story, viewport);
65
- captures.push({
189
+ const snapshot = {
66
190
  story: result.story,
67
191
  viewportName: viewport.name,
68
192
  viewport,
69
- screenshot: result.screenshot
70
- });
193
+ screenshot: result.screenshot,
194
+ durationMs: Math.round(performance.now() - started),
195
+ ...result.a11yViolations.length > 0 ? { a11y: result.a11yViolations } : {}
196
+ };
197
+ if (input.onCapture) await input.onCapture(snapshot);
198
+ else captures.push(snapshot);
71
199
  if (result.a11yViolations.length > 0) handleA11y(input, failures, story, viewport, result.a11yViolations);
72
200
  }
73
201
  function handleA11y(input, failures, story, viewport, violations) {
@@ -95,6 +223,6 @@ function pushRenderFailure(failures, input, storyId, viewportName, error) {
95
223
  }, "render failed for story");
96
224
  }
97
225
  //#endregion
98
- export { renderAll };
226
+ export { assertReachable, extractAll, planRun, renderAll, serve };
99
227
 
100
228
  //# sourceMappingURL=pipeline.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"pipeline.mjs","names":[],"sources":["../src/pipeline.ts"],"sourcesContent":["/** Render orchestration: static server, browser, and per-story captures. */\n\nimport 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"}
1
+ {"version":3,"file":"pipeline.mjs","names":[],"sources":["../src/pipeline.ts"],"sourcesContent":["/** Render orchestration: static server, browser, and per-story captures. */\n\nimport {\n TargetLaunchError,\n engineOf,\n type CaptureEnvironment,\n type CaptureTarget,\n type RemoteTarget,\n type RenderResult,\n type RenderedSnapshot,\n type StoryEntry,\n type StorySourceAdapter,\n type Viewport,\n} from \"@storyshelf/core/adapter/capture-runner\";\nimport { StorybookAdapter, resolveStoryViewports } from \"@storyshelf/core/capture\";\nimport type { StoryParameters } from \"@storyshelf/core/capture\";\nimport {\n DEFAULT_RECYCLE_BROWSER_EVERY,\n chunk,\n describeSizing,\n createPageLimiter,\n detectCaptureEnvironment,\n isTimeoutError,\n sessionGateFor,\n storybookBase,\n type SessionGate,\n} from \"@storyshelf/core/utils\";\nimport type { Browser } from \"playwright-core\";\nimport { messageOf, safeCloseBrowser, safeCloseServer } from \"./browser.ts\";\nimport { PagePool } from \"./pages.ts\";\nimport { measureRemote } from \"./remote-environment.ts\";\nimport { createRuntimeParamsState, extractAllParameters } from \"./runtime-params.ts\";\nimport { captureScreenshot } from \"./screenshot.ts\";\nimport { createStaticServer } from \"./static-server.ts\";\nimport type {\n ActiveRun,\n CaptureContext,\n ExtractInput,\n PlaywrightRenderInput,\n RunInput,\n RuntimeParamsPage,\n TargetDriver,\n} from \"./types.ts\";\n\n/** Where the browser finds the Storybook: the loopback server, or the URL core handed over. */\ninterface Serving {\n url: string;\n close(): Promise<void>;\n}\n\n/** Render all story/viewport combinations for a build. */\nexport async function renderAll(\n rawInput: PlaywrightRenderInput,\n active: ActiveRun,\n driver: TargetDriver,\n defaultTarget: CaptureTarget,\n): Promise<RenderResult> {\n const { input, name, remote } = planRun(rawInput, driver, defaultTarget);\n assertReachable(name, remote, input.storybookUrl);\n // Launch first: a target that will not start must fail before a static server is bound.\n const browser = await driver.launch(name, input.logger);\n const server = await serve(input, browser);\n input.logger?.info(describeSizing({ ...input, browser: engineOf(name) }), \"capture sizing\");\n const measured = remote ? await measureRemote(browser, remote.remote, input.logger) : undefined;\n const result = await runWithBrowser(\n input,\n active,\n browser,\n server,\n async () => await driver.launch(name, input.logger),\n );\n // Echo the target so persisted rows are attributable without the request.\n for (const row of [...result.captures, ...result.failures]) row.browser = name;\n return { ...result, environment: measured ?? (await localEnvironment(browser)) };\n}\n\n/** The input with its target resolved, and the session gate when the target is remote. */\nexport function planRun<T extends { target?: CaptureTarget | undefined }>(\n rawInput: T,\n driver: TargetDriver,\n defaultTarget: CaptureTarget,\n): {\n input: T & { target: CaptureTarget; gate?: SessionGate | undefined };\n name: CaptureTarget;\n remote: RemoteTarget | undefined;\n} {\n const name = rawInput.target ?? defaultTarget;\n const remote = driver.remote(name);\n const gate = remote && sessionGateFor(name, remote.remote.sessions);\n return { input: { ...rawInput, target: name, gate }, name, remote };\n}\n\n/**\n * Load the preview once and read every story's resolved parameters\n * (`__STORYBOOK_PREVIEW__.extract()`), so the orchestrator can plan modes.\n */\nexport async function extractAll(\n input: ExtractInput,\n driver: TargetDriver,\n defaultTarget: CaptureTarget,\n): Promise<Record<string, StoryParameters>> {\n const first = input.stories[0];\n if (!first) return {};\n const name = input.target ?? defaultTarget;\n assertReachable(name, driver.remote(name), input.storybookUrl);\n const browser = await driver.launch(name, input.logger);\n const server = await serve(input, browser);\n try {\n return await readParameters(browser, server.url, first.id, input.adapter);\n } finally {\n await Promise.all([safeCloseBrowser(browser), safeCloseServer(server)]);\n }\n}\n\n/** A target whose browser cannot route back to this host needs a URL; failing here names why. */\nexport function assertReachable(\n target: CaptureTarget,\n remote: RemoteTarget | undefined,\n storybookUrl: string | undefined,\n): void {\n if (remote?.reach === \"url\" && storybookUrl === undefined) {\n throw new TargetLaunchError(\n target,\n \"its browser cannot reach this host, so it needs a Storybook URL (a public origin and a secret to sign it) and none was given\",\n { transient: true },\n );\n }\n if (storybookUrl !== undefined) {\n assertStorybookRoot(target, storybookUrl);\n }\n}\n\n/** The URL must be the build's directory root; refused before any browser connects. */\nfunction assertStorybookRoot(target: CaptureTarget, storybookUrl: string): void {\n try {\n storybookBase(storybookUrl);\n } catch (error) {\n throw new TargetLaunchError(target, error instanceof Error ? error.message : String(error));\n }\n}\n\n/** The loopback server, or the URL given; a server that cannot start closes the browser it was for. */\nexport async function serve(\n input: Pick<RunInput, \"storybookDir\" | \"storybookUrl\">,\n browser: Browser,\n): Promise<Serving> {\n if (input.storybookUrl !== undefined) {\n return {\n url: storybookBase(input.storybookUrl),\n close: async () => {\n await Promise.resolve();\n },\n };\n }\n return await createStaticServer(input.storybookDir).catch(async (error: unknown) => {\n await safeCloseBrowser(browser);\n throw error;\n });\n}\n\nasync function readParameters(\n browser: Browser,\n baseUrl: string,\n storyId: string,\n custom: StorySourceAdapter | undefined,\n): Promise<Record<string, StoryParameters>> {\n const adapter: StorySourceAdapter = custom ?? new StorybookAdapter();\n const page = await browser.newPage();\n await page.goto(adapter.buildUrl(baseUrl, storyId), { waitUntil: \"networkidle\" });\n await adapter.waitForReady?.(page);\n return await extractAllParameters(page as unknown as RuntimeParamsPage);\n}\n\n/** The driver's browser version; undefined for a driver (or test double) that does not report one. */\nfunction browserVersionOf(browser: Browser): string | undefined {\n return typeof browser.version === \"function\" ? browser.version() : undefined;\n}\n\n/** This host's environment: a local browser renders on the machine the runner runs on. */\nasync function localEnvironment(browser: Browser): Promise<CaptureEnvironment> {\n return await detectCaptureEnvironment(browserVersionOf(browser));\n}\n\nasync function runWithBrowser(\n input: RunInput,\n active: ActiveRun,\n browser: Browser,\n server: Serving,\n relaunch: () => Promise<Browser>,\n): Promise<RenderResult> {\n active.browser = browser;\n const ctx = buildContext(browser, server.url, input);\n const captures: RenderedSnapshot[] = [];\n const failures: RenderResult[\"failures\"] = [];\n try {\n await runAllCaptures(input, active, ctx, captures, failures, relaunch);\n return { captures, failures };\n } finally {\n active.browser = null;\n await ctx.pagePool?.dispose();\n // ctx.browser is the latest one: recycling replaces the browser mid-run.\n await Promise.all([safeCloseBrowser(ctx.browser), safeCloseServer(server)]);\n }\n}\n\nfunction buildContext(\n browser: Browser,\n baseUrl: string,\n input: PlaywrightRenderInput,\n): CaptureContext {\n return {\n browser,\n adapter: input.adapter ?? new StorybookAdapter(),\n baseUrl,\n navigationTimeoutMs: input.navigationTimeoutMs,\n executePlay: input.executePlay,\n playTimeoutMs: input.playTimeoutMs,\n runA11y: input.runA11y,\n ...(input.logger ? { logger: input.logger } : {}),\n runtimeParams: createRuntimeParamsState(),\n ...(input.reusePages ? { pagePool: new PagePool(browser) } : {}),\n };\n}\n\nasync function runAllCaptures(\n input: RunInput,\n active: ActiveRun,\n ctx: CaptureContext,\n captures: RenderedSnapshot[],\n failures: RenderResult[\"failures\"],\n relaunch: () => Promise<Browser>,\n): Promise<void> {\n // Bounded: one page per story x viewport all at once exhausts memory on large Storybooks.\n const jobs = input.stories.flatMap((story) =>\n resolveStoryViewports(story, input.viewports).map((viewport) => ({ story, viewport })),\n );\n // Batches drain in-flight pages, so the browser can be swapped between them.\n const batches = chunk(jobs, input.recycleBrowserEvery ?? DEFAULT_RECYCLE_BROWSER_EVERY);\n const pages = createPageLimiter({\n concurrency: input.concurrency,\n ...(input.gate === undefined ? {} : { gate: input.gate }),\n browser: engineOf(input.target ?? \"chromium\"),\n logger: input.logger,\n });\n try {\n await runBatches(batches, pages, input, active, ctx, { captures, failures, relaunch });\n } finally {\n pages.dispose();\n }\n}\n\nasync function runBatches(\n batches: { story: StoryEntry; viewport: Viewport }[][],\n pages: ReturnType<typeof createPageLimiter>,\n input: RunInput,\n active: ActiveRun,\n ctx: CaptureContext,\n out: {\n captures: RenderedSnapshot[];\n failures: RenderResult[\"failures\"];\n relaunch: () => Promise<Browser>;\n },\n): Promise<void> {\n for (const [index, batch] of batches.entries()) {\n if (index > 0) {\n // oxlint-disable-next-line eslint/no-await-in-loop -- recycle only between drained batches\n await recycleBrowser(active, ctx, out.relaunch, input.logger);\n }\n // oxlint-disable-next-line eslint/no-await-in-loop -- batches run one after another\n await pages.each(\n batch,\n async (job) => await captureOne(input, active, ctx, job.story, job.viewport, out),\n );\n }\n}\n\n/** Swap in a fresh browser; the old one is closed first so two never coexist. */\nasync function recycleBrowser(\n active: ActiveRun,\n ctx: CaptureContext,\n relaunch: () => Promise<Browser>,\n logger: PlaywrightRenderInput[\"logger\"],\n): Promise<void> {\n if (active.cancelled) throw new Error(\"Capture cancelled\");\n await ctx.pagePool?.dispose();\n await safeCloseBrowser(ctx.browser);\n ctx.browser = await relaunch();\n if (ctx.pagePool) ctx.pagePool = new PagePool(ctx.browser);\n active.browser = ctx.browser;\n logger?.info({}, \"browser recycled\");\n}\n\n/** Capture one story x viewport; resolves to whether it timed out (failures are recorded, not thrown). */\nasync function captureOne(\n input: PlaywrightRenderInput,\n active: ActiveRun,\n ctx: CaptureContext,\n story: StoryEntry,\n viewport: Viewport,\n out: { captures: RenderedSnapshot[]; failures: RenderResult[\"failures\"] },\n): Promise<boolean> {\n if (active.cancelled) throw new Error(\"Capture cancelled\");\n try {\n await pushCapture(ctx, story, viewport, out.captures, out.failures, input);\n return false;\n } catch (error) {\n pushRenderFailure(out.failures, input, story.id, viewport.name, error);\n return isTimeoutError(error);\n }\n}\n\nasync function pushCapture(\n ctx: CaptureContext,\n story: StoryEntry,\n viewport: Viewport,\n captures: RenderedSnapshot[],\n failures: RenderResult[\"failures\"],\n input: PlaywrightRenderInput,\n): Promise<void> {\n const started = performance.now();\n const result = await captureScreenshot(ctx, story, viewport);\n const snapshot: RenderedSnapshot = {\n story: result.story,\n viewportName: viewport.name,\n viewport,\n screenshot: result.screenshot,\n durationMs: Math.round(performance.now() - started),\n ...(result.a11yViolations.length > 0 ? { a11y: result.a11yViolations } : {}),\n };\n // Streaming: hand it over and drop the buffer rather than holding every screenshot.\n if (input.onCapture) await input.onCapture(snapshot);\n else captures.push(snapshot);\n if (result.a11yViolations.length > 0)\n handleA11y(input, failures, story, viewport, result.a11yViolations);\n}\nfunction handleA11y(\n input: 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":"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"}
package/dist/play.mjs CHANGED
@@ -29,7 +29,20 @@ async function inPagePlay({ storyId, timeoutMs }) {
29
29
  });
30
30
  } catch {}
31
31
  const canvasElement = globalThis.document?.querySelector("#storybook-root");
32
- const work = preview.executePlay ? preview.executePlay(storyId) : preview.storyStore?.fromId?.(storyId)?.play?.({ canvasElement });
32
+ const store = preview.storyStoreValue ?? preview.storyStore;
33
+ const runLoaded = async () => {
34
+ const story = await store?.loadStory?.({ storyId });
35
+ if (!story?.playFunction) return;
36
+ const context = (preview.storyRenders?.find((r) => r.id === storyId))?.storyContext?.() ?? store?.getStoryContext?.(story) ?? {};
37
+ await story.playFunction({
38
+ ...context,
39
+ canvasElement
40
+ });
41
+ };
42
+ let work;
43
+ if (preview.executePlay) work = preview.executePlay(storyId);
44
+ else if (store?.loadStory) work = runLoaded();
45
+ else work = store?.fromId?.(storyId)?.play?.({ canvasElement });
33
46
  if (!work) return;
34
47
  let timer;
35
48
  const timeout = new Promise((_resolve, reject) => {
package/dist/play.mjs.map CHANGED
@@ -1 +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"}
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, eslint/max-lines-per-function -- must stay one self-contained function (see above)\nexport async function inPagePlay({ storyId, timeoutMs }: PlayArgs): Promise<void> {\n type Story = { playFunction?: (ctx: unknown) => Promise<void> };\n type Store = {\n loadStory?: (arg: { storyId: string }) => Promise<Story>;\n getStoryContext?: (story: Story) => Record<string, unknown>;\n fromId?: (id: string) => { play?: (ctx: unknown) => Promise<void> };\n };\n type Preview = {\n executePlay?: (id: string) => Promise<void>;\n storyStoreValue?: Store;\n storyStore?: Store;\n storyRenders?: { id: string; storyContext?: () => Record<string, unknown> }[];\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 // SB 8+ has no `executePlay`, and `fromId` throws until every CSF file is cached, so load just\n // this story and run its `playFunction` with the context of the render that is already mounted.\n const store = preview.storyStoreValue ?? preview.storyStore;\n const runLoaded = async (): Promise<void> => {\n const story = await store?.loadStory?.({ storyId });\n if (!story?.playFunction) return;\n const render = preview.storyRenders?.find((r) => r.id === storyId);\n const context = render?.storyContext?.() ?? store?.getStoryContext?.(story) ?? {};\n await story.playFunction({ ...context, canvasElement });\n };\n let work: Promise<void> | undefined;\n if (preview.executePlay) work = preview.executePlay(storyId);\n else if (store?.loadStory) work = runLoaded();\n else work = store?.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;CAchF,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;CAG1E,MAAM,QAAQ,QAAQ,mBAAmB,QAAQ;CACjD,MAAM,YAAY,YAA2B;EAC3C,MAAM,QAAQ,MAAM,OAAO,YAAY,EAAE,QAAQ,CAAC;EAClD,IAAI,CAAC,OAAO,cAAc;EAE1B,MAAM,WADS,QAAQ,cAAc,MAAM,MAAM,EAAE,OAAO,OAAO,EAAA,EACzC,eAAe,KAAK,OAAO,kBAAkB,KAAK,KAAK,CAAC;EAChF,MAAM,MAAM,aAAa;GAAE,GAAG;GAAS;EAAc,CAAC;CACxD;CACA,IAAI;CACJ,IAAI,QAAQ,aAAa,OAAO,QAAQ,YAAY,OAAO;MACtD,IAAI,OAAO,WAAW,OAAO,UAAU;MACvC,OAAO,OAAO,SAAS,OAAO,CAAC,EAAE,OAAO,EAAE,cAAc,CAAC;CAC9D,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,26 @@
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/remote/azure.d.ts
5
+ /** Options of {@link azurePlaywright}. */
6
+ interface AzurePlaywrightOptions {
7
+ /** The target name half; default `azure`. */
8
+ name?: string;
9
+ /** The workspace's browser endpoint (`PLAYWRIGHT_SERVICE_URL`): `wss://<region>.api.playwright.microsoft.com/playwrightworkspaces/<id>/browsers`. */
10
+ endpoint: string;
11
+ /** Access token (`PLAYWRIGHT_SERVICE_ACCESS_TOKEN`). */
12
+ token: string;
13
+ /** OS of the cloud browsers (default `linux`). */
14
+ os?: "linux" | "windows";
15
+ /** API version the service is called with (default `2025-09-01`). */
16
+ apiVersion?: string;
17
+ /** Parallel pages (the workspace's parallel-worker limit; default 1). */
18
+ sessions?: number;
19
+ /** Engines to offer (default all three). */
20
+ engines?: readonly BrowserName[];
21
+ }
22
+ /** Targets on a workspace: `chromium@azure`, `firefox@azure`, `webkit@azure`. */
23
+ declare function azurePlaywright(options: AzurePlaywrightOptions): RemoteBrowser;
24
+ //#endregion
25
+ export { AzurePlaywrightOptions, azurePlaywright };
26
+ //# sourceMappingURL=azure.d.mts.map
@@ -0,0 +1,37 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { remote } from "./remote.mjs";
5
+ //#region src/remote/azure.ts
6
+ /** Targets on a workspace: `chromium@azure`, `firefox@azure`, `webkit@azure`. */
7
+ function azurePlaywright(options) {
8
+ const os = options.os ?? "linux";
9
+ return remote({
10
+ name: options.name ?? "azure",
11
+ engines: options.engines ?? [
12
+ "chromium",
13
+ "firefox",
14
+ "webkit"
15
+ ],
16
+ connect: () => ({
17
+ protocol: "playwright",
18
+ endpoint: withParams(options.endpoint, {
19
+ os,
20
+ "api-version": options.apiVersion ?? "2025-09-01"
21
+ }),
22
+ headers: { authorization: `Bearer ${options.token}` },
23
+ exposeNetwork: "<loopback>"
24
+ }),
25
+ sessions: options.sessions ?? 1,
26
+ platform: os
27
+ });
28
+ }
29
+ function withParams(endpoint, params) {
30
+ const url = new URL(endpoint);
31
+ for (const [key, value] of Object.entries(params)) if (!url.searchParams.has(key)) url.searchParams.set(key, value);
32
+ return url.toString();
33
+ }
34
+ //#endregion
35
+ export { azurePlaywright };
36
+
37
+ //# sourceMappingURL=azure.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"azure.mjs","names":[],"sources":["../../src/remote/azure.ts"],"sourcesContent":["/** Microsoft Azure Playwright Workspaces (Azure App Testing). */\nimport type { BrowserName, RemoteBrowser } from \"@storyshelf/core/adapter/capture-runner\";\nimport { remote } from \"./remote.ts\";\n\n/** Options of {@link azurePlaywright}. */\nexport interface AzurePlaywrightOptions {\n /** The target name half; default `azure`. */\n name?: string;\n /** The workspace's browser endpoint (`PLAYWRIGHT_SERVICE_URL`): `wss://<region>.api.playwright.microsoft.com/playwrightworkspaces/<id>/browsers`. */\n endpoint: string;\n /** Access token (`PLAYWRIGHT_SERVICE_ACCESS_TOKEN`). */\n token: string;\n /** OS of the cloud browsers (default `linux`). */\n os?: \"linux\" | \"windows\";\n /** API version the service is called with (default `2025-09-01`). */\n apiVersion?: string;\n /** Parallel pages (the workspace's parallel-worker limit; default 1). */\n sessions?: number;\n /** Engines to offer (default all three). */\n engines?: readonly BrowserName[];\n}\n\n/** Targets on a workspace: `chromium@azure`, `firefox@azure`, `webkit@azure`. */\nexport function azurePlaywright(options: AzurePlaywrightOptions): RemoteBrowser {\n const os = options.os ?? \"linux\";\n return remote({\n name: options.name ?? \"azure\",\n engines: options.engines ?? [\"chromium\", \"firefox\", \"webkit\"],\n connect: () => ({\n protocol: \"playwright\",\n endpoint: withParams(options.endpoint, {\n os,\n \"api-version\": options.apiVersion ?? \"2025-09-01\",\n }),\n headers: { authorization: `Bearer ${options.token}` },\n // The service routes requests for the host's loopback back through the connection.\n exposeNetwork: \"<loopback>\",\n }),\n sessions: options.sessions ?? 1,\n platform: os,\n });\n}\n\nfunction withParams(endpoint: string, params: Record<string, string>): string {\n const url = new URL(endpoint);\n for (const [key, value] of Object.entries(params)) {\n if (!url.searchParams.has(key)) url.searchParams.set(key, value);\n }\n return url.toString();\n}\n"],"mappings":";;;;;;AAuBA,SAAgB,gBAAgB,SAAgD;CAC9E,MAAM,KAAK,QAAQ,MAAM;CACzB,OAAO,OAAO;EACZ,MAAM,QAAQ,QAAQ;EACtB,SAAS,QAAQ,WAAW;GAAC;GAAY;GAAW;EAAQ;EAC5D,gBAAgB;GACd,UAAU;GACV,UAAU,WAAW,QAAQ,UAAU;IACrC;IACA,eAAe,QAAQ,cAAc;GACvC,CAAC;GACD,SAAS,EAAE,eAAe,UAAU,QAAQ,QAAQ;GAEpD,eAAe;EACjB;EACA,UAAU,QAAQ,YAAY;EAC9B,UAAU;CACZ,CAAC;AACH;AAEA,SAAS,WAAW,UAAkB,QAAwC;CAC5E,MAAM,MAAM,IAAI,IAAI,QAAQ;CAC5B,KAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,MAAM,GAC9C,IAAI,CAAC,IAAI,aAAa,IAAI,GAAG,GAAG,IAAI,aAAa,IAAI,KAAK,KAAK;CAEjE,OAAO,IAAI,SAAS;AACtB"}
@@ -0,0 +1,33 @@
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/remote/browserstack.d.ts
5
+ /** Options of {@link browserstack}. */
6
+ interface BrowserStackOptions {
7
+ /** The target name half; default `browserstack`. A second entry needs its own. */
8
+ name?: string;
9
+ username: string;
10
+ accessKey: string;
11
+ /** OS the browsers run on, as BrowserStack names it (`{ name: "Windows", version: "11" }`). */
12
+ os?: {
13
+ name: string;
14
+ version: string;
15
+ };
16
+ /** Browser version (default `latest`). Pin it: a moving build is a baseline migration. */
17
+ browserVersion?: string;
18
+ /** Engines to offer (default `chromium`, `firefox`, `webkit`; `chrome` is real Chrome). */
19
+ engines?: readonly BrowserName[];
20
+ /** Parallel sessions of the plan (default 1). */
21
+ sessions?: number;
22
+ /** Build name shown in BrowserStack's dashboard. */
23
+ build?: string;
24
+ }
25
+ /**
26
+ * Targets on BrowserStack, for example `chromium@browserstack`. BrowserStack cannot route back to
27
+ * this host, so each target needs a signed Storybook URL (ADR 0007, decision 5). The credentials
28
+ * travel in the endpoint's capabilities and are scrubbed from every message and log.
29
+ */
30
+ declare function browserstack(options: BrowserStackOptions): RemoteBrowser;
31
+ //#endregion
32
+ export { BrowserStackOptions, browserstack };
33
+ //# sourceMappingURL=browserstack.d.mts.map
@@ -0,0 +1,52 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { playwrightVersion } from "../version.mjs";
5
+ import { remote } from "./remote.mjs";
6
+ //#region src/remote/browserstack.ts
7
+ /** BrowserStack's `browser` capability per engine. */
8
+ const BROWSER = {
9
+ chromium: "playwright-chromium",
10
+ firefox: "playwright-firefox",
11
+ webkit: "playwright-webkit",
12
+ chrome: "chrome"
13
+ };
14
+ /**
15
+ * Targets on BrowserStack, for example `chromium@browserstack`. BrowserStack cannot route back to
16
+ * this host, so each target needs a signed Storybook URL (ADR 0007, decision 5). The credentials
17
+ * travel in the endpoint's capabilities and are scrubbed from every message and log.
18
+ */
19
+ function browserstack(options) {
20
+ return remote({
21
+ name: options.name ?? "browserstack",
22
+ engines: options.engines ?? [
23
+ "chromium",
24
+ "firefox",
25
+ "webkit"
26
+ ],
27
+ connect: (engine) => ({
28
+ protocol: "playwright",
29
+ endpoint: `wss://cdp.browserstack.com/playwright?caps=${encodeURIComponent(JSON.stringify(capabilities(options, engine)))}`
30
+ }),
31
+ sessions: options.sessions ?? 1,
32
+ platform: options.os?.name.toLowerCase().replace(/^os x$/u, "macos")
33
+ });
34
+ }
35
+ function capabilities(options, engine) {
36
+ return {
37
+ browser: BROWSER[engine],
38
+ browser_version: options.browserVersion ?? "latest",
39
+ ...options.os === void 0 ? {} : {
40
+ os: options.os.name,
41
+ os_version: options.os.version
42
+ },
43
+ "browserstack.username": options.username,
44
+ "browserstack.accessKey": options.accessKey,
45
+ "client.playwrightVersion": playwrightVersion(),
46
+ ...options.build === void 0 ? {} : { build: options.build }
47
+ };
48
+ }
49
+ //#endregion
50
+ export { browserstack };
51
+
52
+ //# sourceMappingURL=browserstack.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"browserstack.mjs","names":[],"sources":["../../src/remote/browserstack.ts"],"sourcesContent":["/** BrowserStack Automate over Playwright's protocol. */\nimport type { BrowserName, RemoteBrowser } from \"@storyshelf/core/adapter/capture-runner\";\nimport { playwrightVersion } from \"../version.ts\";\nimport { remote } from \"./remote.ts\";\n\n/** Options of {@link browserstack}. */\nexport interface BrowserStackOptions {\n /** The target name half; default `browserstack`. A second entry needs its own. */\n name?: string;\n username: string;\n accessKey: string;\n /** OS the browsers run on, as BrowserStack names it (`{ name: \"Windows\", version: \"11\" }`). */\n os?: { name: string; version: string };\n /** Browser version (default `latest`). Pin it: a moving build is a baseline migration. */\n browserVersion?: string;\n /** Engines to offer (default `chromium`, `firefox`, `webkit`; `chrome` is real Chrome). */\n engines?: readonly BrowserName[];\n /** Parallel sessions of the plan (default 1). */\n sessions?: number;\n /** Build name shown in BrowserStack's dashboard. */\n build?: string;\n}\n\n/** BrowserStack's `browser` capability per engine. */\nconst BROWSER: Record<BrowserName, string> = {\n chromium: \"playwright-chromium\",\n firefox: \"playwright-firefox\",\n webkit: \"playwright-webkit\",\n chrome: \"chrome\",\n};\n\n/**\n * Targets on BrowserStack, for example `chromium@browserstack`. BrowserStack cannot route back to\n * this host, so each target needs a signed Storybook URL (ADR 0007, decision 5). The credentials\n * travel in the endpoint's capabilities and are scrubbed from every message and log.\n */\nexport function browserstack(options: BrowserStackOptions): RemoteBrowser {\n return remote({\n name: options.name ?? \"browserstack\",\n engines: options.engines ?? [\"chromium\", \"firefox\", \"webkit\"],\n connect: (engine) => ({\n protocol: \"playwright\",\n endpoint: `wss://cdp.browserstack.com/playwright?caps=${encodeURIComponent(\n JSON.stringify(capabilities(options, engine)),\n )}`,\n }),\n sessions: options.sessions ?? 1,\n platform: options.os?.name.toLowerCase().replace(/^os x$/u, \"macos\"),\n });\n}\n\nfunction capabilities(options: BrowserStackOptions, engine: BrowserName): Record<string, unknown> {\n return {\n browser: BROWSER[engine],\n browser_version: options.browserVersion ?? \"latest\",\n ...(options.os === undefined ? {} : { os: options.os.name, os_version: options.os.version }),\n \"browserstack.username\": options.username,\n \"browserstack.accessKey\": options.accessKey,\n \"client.playwrightVersion\": playwrightVersion(),\n ...(options.build === undefined ? {} : { build: options.build }),\n };\n}\n"],"mappings":";;;;;;;AAwBA,MAAM,UAAuC;CAC3C,UAAU;CACV,SAAS;CACT,QAAQ;CACR,QAAQ;AACV;;;;;;AAOA,SAAgB,aAAa,SAA6C;CACxE,OAAO,OAAO;EACZ,MAAM,QAAQ,QAAQ;EACtB,SAAS,QAAQ,WAAW;GAAC;GAAY;GAAW;EAAQ;EAC5D,UAAU,YAAY;GACpB,UAAU;GACV,UAAU,8CAA8C,mBACtD,KAAK,UAAU,aAAa,SAAS,MAAM,CAAC,CAC9C;EACF;EACA,UAAU,QAAQ,YAAY;EAC9B,UAAU,QAAQ,IAAI,KAAK,YAAY,CAAC,CAAC,QAAQ,WAAW,OAAO;CACrE,CAAC;AACH;AAEA,SAAS,aAAa,SAA8B,QAA8C;CAChG,OAAO;EACL,SAAS,QAAQ;EACjB,iBAAiB,QAAQ,kBAAkB;EAC3C,GAAI,QAAQ,OAAO,KAAA,IAAY,CAAC,IAAI;GAAE,IAAI,QAAQ,GAAG;GAAM,YAAY,QAAQ,GAAG;EAAQ;EAC1F,yBAAyB,QAAQ;EACjC,0BAA0B,QAAQ;EAClC,4BAA4B,kBAAkB;EAC9C,GAAI,QAAQ,UAAU,KAAA,IAAY,CAAC,IAAI,EAAE,OAAO,QAAQ,MAAM;CAChE;AACF"}
@@ -0,0 +1,7 @@
1
+ import __tsdown_shims_path from 'node:path';
2
+ import __tsdown_shims_url from 'node:url';
3
+ import { BrowserName, RemoteBrowser, RemoteConnection, RemoteOptions, remote } from "./remote.mjs";
4
+ import { PlaywrightServerOptions, playwrightServer } from "./playwright-server.mjs";
5
+ import { AzurePlaywrightOptions, azurePlaywright } from "./azure.mjs";
6
+ import { BrowserStackOptions, browserstack } from "./browserstack.mjs";
7
+ export { type AzurePlaywrightOptions, type BrowserName, type BrowserStackOptions, type PlaywrightServerOptions, type RemoteBrowser, type RemoteConnection, type RemoteOptions, azurePlaywright, browserstack, playwrightServer, remote };