@storyshelf/runner-playwright 0.6.2 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -5
- package/dist/a11y.mjs +22 -22
- package/dist/a11y.mjs.map +1 -1
- package/dist/availability.mjs +63 -0
- package/dist/availability.mjs.map +1 -0
- package/dist/browser.mjs +35 -10
- package/dist/browser.mjs.map +1 -1
- package/dist/capture-retry.mjs +25 -0
- package/dist/capture-retry.mjs.map +1 -0
- package/dist/capture-runner.d.mts +20 -5
- package/dist/capture-runner.mjs +92 -16
- package/dist/capture-runner.mjs.map +1 -1
- package/dist/connect.mjs +61 -0
- package/dist/connect.mjs.map +1 -0
- package/dist/index.d.mts +2 -1
- package/dist/index.mjs +2 -1
- package/dist/launch.mjs +63 -0
- package/dist/launch.mjs.map +1 -0
- package/dist/pages.d.mts +4 -0
- package/dist/pages.mjs +90 -0
- package/dist/pages.mjs.map +1 -0
- package/dist/pipeline.mjs +220 -39
- package/dist/pipeline.mjs.map +1 -1
- package/dist/play.mjs +15 -3
- package/dist/play.mjs.map +1 -1
- package/dist/remote/azure.d.mts +24 -0
- package/dist/remote/azure.mjs +36 -0
- package/dist/remote/azure.mjs.map +1 -0
- package/dist/remote/browserstack.d.mts +31 -0
- package/dist/remote/browserstack.mjs +50 -0
- package/dist/remote/browserstack.mjs.map +1 -0
- package/dist/remote/index.d.mts +8 -0
- package/dist/remote/index.mjs +9 -0
- package/dist/remote/playwright-server.d.mts +45 -0
- package/dist/remote/playwright-server.mjs +35 -0
- package/dist/remote/playwright-server.mjs.map +1 -0
- package/dist/remote/remote.d.mts +38 -0
- package/dist/remote/remote.mjs +55 -0
- package/dist/remote/remote.mjs.map +1 -0
- package/dist/remote-environment.mjs +49 -0
- package/dist/remote-environment.mjs.map +1 -0
- package/dist/runtime-params.mjs +9 -3
- package/dist/runtime-params.mjs.map +1 -1
- package/dist/screenshot.mjs +102 -28
- package/dist/screenshot.mjs.map +1 -1
- package/dist/settle.mjs +157 -0
- package/dist/settle.mjs.map +1 -0
- package/dist/static-server.mjs +72 -10
- package/dist/static-server.mjs.map +1 -1
- package/dist/targets.d.mts +24 -0
- package/dist/targets.mjs +72 -0
- package/dist/targets.mjs.map +1 -0
- package/dist/tests.mjs +111 -0
- package/dist/tests.mjs.map +1 -0
- package/dist/types.d.mts +3 -1
- package/dist/version.mjs +15 -0
- package/dist/version.mjs.map +1 -0
- package/package.json +19 -13
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"connect.mjs","names":[],"sources":["../src/connect.ts"],"sourcesContent":["/** Connecting to a remote browser: retried when the service is unreachable or full, a clear error when it is not. */\nimport {\n connectLeased,\n scrubConnection,\n type ConnectLeasedContext,\n type RemoteConnection,\n type RemoteTarget,\n} from \"@storyshelf/core/adapter/capture-runner\";\nimport type { Logger } from \"@storyshelf/core/logger\";\nimport type { Browser } from \"playwright-core\";\nimport { holdLease } from \"./browser.ts\";\nimport { loadPlaywright } from \"./launch.ts\";\nimport { playwrightVersion } from \"./version.ts\";\n\n/**\n * Connect to `entry`'s service and return its browser, holding the service's lease (a function\n * `connect`'s `lease`) until the browser is closed through `closeBrowser`/`safeCloseBrowser`.\n * Connection-level failures are retried with backoff, so a provider outage or a full plan costs\n * time and not a story crash; what is left is a `TargetLaunchError`, which fails the job at once\n * (a wrong version or credential also marks the target unavailable, a service that is merely down\n * does not). Nothing in a message carries the endpoint, its credentials or the lease.\n */\nexport async function connectRemote(\n entry: RemoteTarget,\n context: ConnectLeasedContext,\n logger?: Logger,\n): Promise<Browser> {\n const playwright = await loadPlaywright();\n const { value, lease } = await connectLeased(entry, context, {\n logger,\n open: async (connection) => await connectOnce(playwright, entry, connection),\n reasonOf,\n });\n holdLease(value, lease);\n return value;\n}\n\nasync function connectOnce(\n playwright: typeof import(\"playwright-core\"),\n entry: RemoteTarget,\n connection: RemoteConnection,\n): Promise<Browser> {\n const { headers, endpoint } = connection;\n const timeout = entry.remote.timeouts.connectMs;\n if (entry.remote.protocol === \"cdp\") {\n return await playwright.chromium.connectOverCDP(endpoint, {\n timeout,\n ...(headers === undefined ? {} : { headers }),\n });\n }\n return await playwright[entry.engine === \"chrome\" ? \"chromium\" : entry.engine].connect(endpoint, {\n timeout,\n ...(headers === undefined ? {} : { headers }),\n ...(entry.reach === \"loopback\" ? { exposeNetwork: \"<loopback>\" } : {}),\n });\n}\n\n/** What failed, in one line, with the version mismatch spelled out and the credentials removed. */\nfunction reasonOf(error: unknown, connection: RemoteConnection & { lease?: unknown }): string {\n const text = error instanceof Error ? error.message : String(error);\n const mismatch = versionMismatch(text);\n if (mismatch !== null) {\n return mismatch;\n }\n const line =\n text\n .split(\"\\n\")\n .find((candidate) => candidate.trim() !== \"\")\n ?.trim() ?? \"the connection failed\";\n return scrubConnection(line, connection);\n}\n\n/**\n * A Playwright server of another version answers the handshake with a box naming both versions;\n * restate it with the one this runner has, so the operator knows which side to move.\n */\nfunction versionMismatch(text: string): string | null {\n const server = /server version:\\s*v?([\\d.]+)/u.exec(text)?.[1];\n const client = /client version:\\s*v?([\\d.]+)/u.exec(text)?.[1];\n if (server === undefined || client === undefined) {\n return null;\n }\n return `Playwright version mismatch: the server runs v${server} but this runner's playwright-core is ${playwrightVersion()}; run the server at exactly ${playwrightVersion()} (see the self-hosted farm guide)`;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;AAsBA,eAAsB,cACpB,OACA,SACA,QACkB;CAClB,MAAM,aAAa,MAAM,eAAe;CACxC,MAAM,EAAE,OAAO,UAAU,MAAM,cAAc,OAAO,SAAS;EAC3D;EACA,MAAM,OAAO,eAAe,MAAM,YAAY,YAAY,OAAO,UAAU;EAC3E;CACF,CAAC;CACD,UAAU,OAAO,KAAK;CACtB,OAAO;AACT;AAEA,eAAe,YACb,YACA,OACA,YACkB;CAClB,MAAM,EAAE,SAAS,aAAa;CAC9B,MAAM,UAAU,MAAM,OAAO,SAAS;CACtC,IAAI,MAAM,OAAO,aAAa,OAC5B,OAAO,MAAM,WAAW,SAAS,eAAe,UAAU;EACxD;EACA,GAAI,YAAY,KAAA,IAAY,CAAC,IAAI,EAAE,QAAQ;CAC7C,CAAC;CAEH,OAAO,MAAM,WAAW,MAAM,WAAW,WAAW,aAAa,MAAM,OAAO,CAAC,QAAQ,UAAU;EAC/F;EACA,GAAI,YAAY,KAAA,IAAY,CAAC,IAAI,EAAE,QAAQ;EAC3C,GAAI,MAAM,UAAU,aAAa,EAAE,eAAe,aAAa,IAAI,CAAC;CACtE,CAAC;AACH;;AAGA,SAAS,SAAS,OAAgB,YAA4D;CAC5F,MAAM,OAAO,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;CAClE,MAAM,WAAW,gBAAgB,IAAI;CACrC,IAAI,aAAa,MACf,OAAO;CAET,MAAM,OACJ,KACG,MAAM,IAAI,CAAC,CACX,MAAM,cAAc,UAAU,KAAK,MAAM,EAAE,CAAC,EAC3C,KAAK,KAAK;CAChB,OAAO,gBAAgB,MAAM,UAAU;AACzC;;;;;AAMA,SAAS,gBAAgB,MAA6B;CACpD,MAAM,SAAS,gCAAgC,KAAK,IAAI,CAAC,GAAG;CAC5D,MAAM,SAAS,gCAAgC,KAAK,IAAI,CAAC,GAAG;CAC5D,IAAI,WAAW,KAAA,KAAa,WAAW,KAAA,GACrC,OAAO;CAET,OAAO,iDAAiD,OAAO,wCAAwC,kBAAkB,EAAE,8BAA8B,kBAAkB,EAAE;AAC/K"}
|
package/dist/index.d.mts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import __tsdown_shims_path from 'node:path';
|
|
2
2
|
import __tsdown_shims_url from 'node:url';
|
|
3
|
+
import { BrowserEntry, LocalBrowser, LocalOptions, local } from "./targets.mjs";
|
|
3
4
|
import { createPlaywrightCaptureRunner } from "./capture-runner.mjs";
|
|
4
|
-
export { createPlaywrightCaptureRunner };
|
|
5
|
+
export { type BrowserEntry, type LocalBrowser, type LocalOptions, createPlaywrightCaptureRunner, local };
|
package/dist/index.mjs
CHANGED
package/dist/launch.mjs
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import "node:path";
|
|
2
|
+
import "node:url";
|
|
3
|
+
import.meta.url;
|
|
4
|
+
import { TargetLaunchError } from "@storyshelf/core/adapter/capture-runner";
|
|
5
|
+
// src/launch.ts
|
|
6
|
+
/** Starting a local engine, and telling a browser that would not start from one that crashed. */
|
|
7
|
+
/**
|
|
8
|
+
* Low-memory Chromium flags. Deliberately excludes rendering-affecting flags
|
|
9
|
+
* (`--disable-gpu`, `--single-process`): they can shift pixels or crash pages,
|
|
10
|
+
* which would show up as false visual diffs.
|
|
11
|
+
*/
|
|
12
|
+
const CHROMIUM_ARGS = [
|
|
13
|
+
"--disable-dev-shm-usage",
|
|
14
|
+
"--disable-extensions",
|
|
15
|
+
"--disable-background-networking",
|
|
16
|
+
"--mute-audio"
|
|
17
|
+
];
|
|
18
|
+
/**
|
|
19
|
+
* Playwright closes its browsers on SIGINT/SIGTERM/SIGHUP by default, which fails a render that a
|
|
20
|
+
* graceful drain is still finishing (`TargetClosedError`). The host owns shutdown: it drains, then
|
|
21
|
+
* closes every browser through `teardown`.
|
|
22
|
+
*/
|
|
23
|
+
const HOST_OWNS_SIGNALS = {
|
|
24
|
+
handleSIGINT: false,
|
|
25
|
+
handleSIGTERM: false,
|
|
26
|
+
handleSIGHUP: false
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Launch `entry`'s engine. Playwright is loaded here, on first use, not at import: it costs
|
|
30
|
+
* ~150 ms and tens of MB, which a server that only serves reviews or hosts Storybooks should not
|
|
31
|
+
* pay at boot. A failure is a {@link TargetLaunchError}: the browser never started, so no story is
|
|
32
|
+
* at fault and the job fails at once instead of going through crash isolation.
|
|
33
|
+
*/
|
|
34
|
+
async function launchLocal(entry) {
|
|
35
|
+
try {
|
|
36
|
+
const playwright = await loadPlaywright();
|
|
37
|
+
return await launcherOf(entry.target, playwright)(entry.channel);
|
|
38
|
+
} catch (error) {
|
|
39
|
+
throw new TargetLaunchError(entry.target, firstLine(error), { cause: error });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
let loaded;
|
|
43
|
+
/** Load Playwright once, however many renders ask at the same time. */
|
|
44
|
+
async function loadPlaywright() {
|
|
45
|
+
loaded ??= import("playwright-core");
|
|
46
|
+
return await loaded;
|
|
47
|
+
}
|
|
48
|
+
function launcherOf(engine, playwright) {
|
|
49
|
+
if (engine === "firefox") return async () => await playwright.firefox.launch(HOST_OWNS_SIGNALS);
|
|
50
|
+
if (engine === "webkit") return async () => await playwright.webkit.launch(HOST_OWNS_SIGNALS);
|
|
51
|
+
return async (channel) => await playwright.chromium.launch({
|
|
52
|
+
...HOST_OWNS_SIGNALS,
|
|
53
|
+
args: CHROMIUM_ARGS,
|
|
54
|
+
...channel === void 0 ? {} : { channel }
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
/** Playwright's launch errors run to a screenful of install hints; the first line says what failed. */
|
|
58
|
+
function firstLine(error) {
|
|
59
|
+
return (error instanceof Error ? error.message : String(error)).split("\n").find((line) => line.trim() !== "")?.trim() ?? "the browser did not start";
|
|
60
|
+
}
|
|
61
|
+
export { launchLocal, loadPlaywright };
|
|
62
|
+
|
|
63
|
+
//# sourceMappingURL=launch.mjs.map
|
|
@@ -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"}
|
package/dist/pages.d.mts
ADDED
package/dist/pages.mjs
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import "node:path";
|
|
2
|
+
import "node:url";
|
|
3
|
+
import.meta.url;
|
|
4
|
+
import { installSettleRecorder } from "./settle.mjs";
|
|
5
|
+
// 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
|
+
export { PagePool, acquirePage };
|
|
89
|
+
|
|
90
|
+
//# 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
|
@@ -1,80 +1,262 @@
|
|
|
1
1
|
import "node:path";
|
|
2
2
|
import "node:url";
|
|
3
3
|
import.meta.url;
|
|
4
|
-
import { messageOf, safeCloseBrowser, safeCloseServer } from "./browser.mjs";
|
|
5
|
-
import {
|
|
4
|
+
import { finishing, messageOf, outcomeOf, safeCloseBrowser, safeCloseServer } from "./browser.mjs";
|
|
5
|
+
import { TransientCaptureError } from "./capture-retry.mjs";
|
|
6
|
+
import { PagePool } from "./pages.mjs";
|
|
7
|
+
import { measureRemote } from "./remote-environment.mjs";
|
|
8
|
+
import { createRuntimeParamsState, extractAllParameters } from "./runtime-params.mjs";
|
|
6
9
|
import { captureScreenshot } from "./screenshot.mjs";
|
|
7
10
|
import { createStaticServer } from "./static-server.mjs";
|
|
11
|
+
import { TargetLaunchError, engineOf } from "@storyshelf/core/adapter/capture-runner";
|
|
12
|
+
import { DEFAULT_RECYCLE_BROWSER_EVERY, chunk, createPageLimiter, describeSizing, detectCaptureEnvironment, isTimeoutError, sessionGateFor, storybookBase } from "@storyshelf/core/utils";
|
|
8
13
|
import { StorybookAdapter, resolveStoryViewports } from "@storyshelf/core/capture";
|
|
9
|
-
|
|
14
|
+
// src/pipeline.ts
|
|
15
|
+
/** Render orchestration: static server, browser, and per-story captures. */
|
|
10
16
|
/** Render all story/viewport combinations for a build. */
|
|
11
|
-
async function renderAll(
|
|
12
|
-
const
|
|
13
|
-
|
|
17
|
+
async function renderAll(rawInput, active, driver, defaultTarget) {
|
|
18
|
+
const { input, name, remote } = planRun(rawInput, driver, defaultTarget);
|
|
19
|
+
assertReachable(name, remote, input.storybookUrl);
|
|
20
|
+
const launch = async () => await driver.launch(name, input.logger, launchContextOf(input, "render", active.abort?.signal));
|
|
21
|
+
const browser = await launch();
|
|
22
|
+
const server = await serve(input, browser);
|
|
23
|
+
input.logger?.info(describeSizing({
|
|
24
|
+
...input,
|
|
25
|
+
browser: engineOf(name)
|
|
26
|
+
}), "capture sizing");
|
|
27
|
+
const measured = remote ? await measureRemote(browser, remote.remote, {
|
|
28
|
+
logger: input.logger,
|
|
29
|
+
signal: active.abort?.signal
|
|
30
|
+
}).catch(async (error) => {
|
|
31
|
+
await Promise.all([safeCloseBrowser(browser, outcomeOf(active, true), error), safeCloseServer(server)]);
|
|
32
|
+
throw error;
|
|
33
|
+
}) : void 0;
|
|
34
|
+
const result = await runWithBrowser({
|
|
35
|
+
...input,
|
|
36
|
+
retryRemoteCapture: remote !== void 0
|
|
37
|
+
}, active, browser, server, launch);
|
|
38
|
+
for (const row of [...result.captures, ...result.failures]) row.browser = name;
|
|
39
|
+
return {
|
|
40
|
+
...result,
|
|
41
|
+
environment: measured ?? await localEnvironment(browser)
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/** What a remote service's `acquire` is told about the run a browser is for. */
|
|
45
|
+
function launchContextOf(input, purpose, signal) {
|
|
46
|
+
return {
|
|
47
|
+
buildId: input.buildId,
|
|
48
|
+
runId: input.runId ?? input.buildId,
|
|
49
|
+
shardIndex: input.shardIndex,
|
|
50
|
+
purpose,
|
|
51
|
+
signal
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/** The input with its target resolved, and the session gate when the target is remote. */
|
|
55
|
+
function planRun(rawInput, driver, defaultTarget) {
|
|
56
|
+
const name = rawInput.target ?? defaultTarget;
|
|
57
|
+
const remote = driver.remote(name);
|
|
58
|
+
const gate = remote && sessionGateFor(name, remote.remote.sessions, (value) => {
|
|
59
|
+
rawInput.logger?.warn({
|
|
60
|
+
target: name,
|
|
61
|
+
value: String(value)
|
|
62
|
+
}, "sessions() returned an invalid value; keeping the last good one");
|
|
63
|
+
});
|
|
64
|
+
return {
|
|
65
|
+
input: {
|
|
66
|
+
...rawInput,
|
|
67
|
+
target: name,
|
|
68
|
+
gate,
|
|
69
|
+
maxPagesPerConnection: remote?.remote.maxPagesPerConnection
|
|
70
|
+
},
|
|
71
|
+
name,
|
|
72
|
+
remote
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/** Apply the connection's cap without changing the target-wide session budget. */
|
|
76
|
+
function connectionConcurrency(input) {
|
|
77
|
+
return input.maxPagesPerConnection === void 0 ? input.concurrency : Math.min(input.concurrency ?? Infinity, input.maxPagesPerConnection);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Load the preview once and read every story's resolved parameters
|
|
81
|
+
* (`__STORYBOOK_PREVIEW__.extract()`), so the orchestrator can plan modes.
|
|
82
|
+
*/
|
|
83
|
+
async function extractAll(input, driver, defaultTarget) {
|
|
84
|
+
const first = input.stories[0];
|
|
85
|
+
if (!first) return {};
|
|
86
|
+
const name = input.target ?? defaultTarget;
|
|
87
|
+
assertReachable(name, driver.remote(name), input.storybookUrl);
|
|
88
|
+
const browser = await driver.launch(name, input.logger, launchContextOf(input, "extract"));
|
|
89
|
+
const server = await serve(input, browser);
|
|
90
|
+
return await finishing(async () => await readParameters(browser, server.url, first.id, input.adapter), async (failed, error) => {
|
|
91
|
+
await Promise.all([safeCloseBrowser(browser, failed ? "failed" : "finished", error), safeCloseServer(server)]);
|
|
92
|
+
});
|
|
14
93
|
}
|
|
15
|
-
|
|
94
|
+
/** A target whose browser cannot route back to this host needs a URL; failing here names why. */
|
|
95
|
+
function assertReachable(target, remote, storybookUrl) {
|
|
96
|
+
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 });
|
|
97
|
+
if (storybookUrl !== void 0) assertStorybookRoot(target, storybookUrl);
|
|
98
|
+
}
|
|
99
|
+
/** The URL must be the build's directory root; refused before any browser connects. */
|
|
100
|
+
function assertStorybookRoot(target, storybookUrl) {
|
|
101
|
+
try {
|
|
102
|
+
storybookBase(storybookUrl);
|
|
103
|
+
} catch (error) {
|
|
104
|
+
throw new TargetLaunchError(target, error instanceof Error ? error.message : String(error));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/** The loopback server, or the URL given; a server that cannot start closes the browser it was for. */
|
|
108
|
+
async function serve(input, browser) {
|
|
109
|
+
if (input.storybookUrl !== void 0) return {
|
|
110
|
+
url: storybookBase(input.storybookUrl),
|
|
111
|
+
close: async () => {
|
|
112
|
+
await Promise.resolve();
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
return await createStaticServer(input.storybookDir, { compression: input.staticCompression }).catch(async (error) => {
|
|
116
|
+
await safeCloseBrowser(browser, "failed", error);
|
|
117
|
+
throw error;
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
async function readParameters(browser, baseUrl, storyId, custom) {
|
|
121
|
+
const adapter = custom ?? new StorybookAdapter();
|
|
122
|
+
const page = await browser.newPage();
|
|
123
|
+
await page.goto(adapter.buildUrl(baseUrl, storyId), { waitUntil: "networkidle" });
|
|
124
|
+
await adapter.waitForReady?.(page);
|
|
125
|
+
return await extractAllParameters(page);
|
|
126
|
+
}
|
|
127
|
+
/** The driver's browser version; undefined for a driver (or test double) that does not report one. */
|
|
128
|
+
function browserVersionOf(browser) {
|
|
129
|
+
return typeof browser.version === "function" ? browser.version() : void 0;
|
|
130
|
+
}
|
|
131
|
+
/** This host's environment: a local browser renders on the machine the runner runs on. */
|
|
132
|
+
async function localEnvironment(browser) {
|
|
133
|
+
return await detectCaptureEnvironment(browserVersionOf(browser));
|
|
134
|
+
}
|
|
135
|
+
async function runWithBrowser(input, active, browser, server, relaunch) {
|
|
16
136
|
active.browser = browser;
|
|
17
137
|
const ctx = buildContext(browser, server.url, input);
|
|
18
138
|
const captures = [];
|
|
19
139
|
const failures = [];
|
|
20
|
-
|
|
21
|
-
await runAllCaptures(input, active, ctx, captures, failures);
|
|
140
|
+
return await finishing(async () => {
|
|
141
|
+
await runAllCaptures(input, active, ctx, captures, failures, relaunch);
|
|
22
142
|
return {
|
|
23
143
|
captures,
|
|
24
144
|
failures
|
|
25
145
|
};
|
|
26
|
-
}
|
|
146
|
+
}, async (failed, error) => {
|
|
27
147
|
active.browser = null;
|
|
28
|
-
await
|
|
29
|
-
|
|
30
|
-
}
|
|
31
|
-
/**
|
|
32
|
-
* Playwright is loaded on first render, not at import: it costs ~150 ms and tens
|
|
33
|
-
* of MB, which a server that only serves reviews (or hands renders to a remote
|
|
34
|
-
* worker) should not pay at boot.
|
|
35
|
-
*/
|
|
36
|
-
async function launchBrowser(name) {
|
|
37
|
-
const playwright = await import("playwright-core");
|
|
38
|
-
if (name === "firefox") return await playwright.firefox.launch();
|
|
39
|
-
if (name === "webkit") return await playwright.webkit.launch();
|
|
40
|
-
return await playwright.chromium.launch();
|
|
148
|
+
await ctx.pagePool?.dispose();
|
|
149
|
+
await Promise.all([safeCloseBrowser(ctx.browser, outcomeOf(active, failed), error), safeCloseServer(server)]);
|
|
150
|
+
});
|
|
41
151
|
}
|
|
42
152
|
function buildContext(browser, baseUrl, input) {
|
|
43
153
|
return {
|
|
44
154
|
browser,
|
|
45
155
|
adapter: input.adapter ?? new StorybookAdapter(),
|
|
46
156
|
baseUrl,
|
|
157
|
+
navigationTimeoutMs: input.navigationTimeoutMs,
|
|
47
158
|
executePlay: input.executePlay,
|
|
48
159
|
playTimeoutMs: input.playTimeoutMs,
|
|
49
160
|
runA11y: input.runA11y,
|
|
50
|
-
|
|
161
|
+
...input.logger ? { logger: input.logger } : {},
|
|
162
|
+
runtimeParams: createRuntimeParamsState(),
|
|
163
|
+
...input.reusePages ? { pagePool: new PagePool(browser) } : {}
|
|
51
164
|
};
|
|
52
165
|
}
|
|
53
|
-
async function runAllCaptures(input, active, ctx, captures, failures) {
|
|
54
|
-
const
|
|
55
|
-
|
|
166
|
+
async function runAllCaptures(input, active, ctx, captures, failures, relaunch) {
|
|
167
|
+
const jobs = input.stories.flatMap((story) => resolveStoryViewports(story, input.viewports).map((viewport) => ({
|
|
168
|
+
story,
|
|
169
|
+
viewport
|
|
170
|
+
})));
|
|
171
|
+
const batches = chunk(jobs, input.recycleBrowserEvery ?? DEFAULT_RECYCLE_BROWSER_EVERY);
|
|
172
|
+
const pages = createPageLimiter({
|
|
173
|
+
concurrency: connectionConcurrency(input),
|
|
174
|
+
...input.gate === void 0 ? {} : { gate: input.gate },
|
|
175
|
+
browser: engineOf(input.target ?? "chromium"),
|
|
176
|
+
logger: input.logger
|
|
177
|
+
});
|
|
178
|
+
try {
|
|
179
|
+
await runBatches(batches, pages, input, active, ctx, {
|
|
180
|
+
captures,
|
|
181
|
+
failures,
|
|
182
|
+
relaunch
|
|
183
|
+
});
|
|
184
|
+
} finally {
|
|
185
|
+
pages.dispose();
|
|
186
|
+
}
|
|
56
187
|
}
|
|
57
|
-
function
|
|
58
|
-
|
|
188
|
+
async function runBatches(batches, pages, input, active, ctx, out) {
|
|
189
|
+
for (const [index, batch] of batches.entries()) {
|
|
190
|
+
if (index > 0) await recycleBrowser(active, ctx, out.relaunch, input.logger);
|
|
191
|
+
await pages.each(batch, async (job) => await captureOne(input, active, ctx, job.story, job.viewport, out));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
/** Swap in a fresh browser; the old one is closed first so two never coexist. */
|
|
195
|
+
async function recycleBrowser(active, ctx, relaunch, logger) {
|
|
196
|
+
if (active.cancelled) throw new Error("Capture cancelled");
|
|
197
|
+
await ctx.pagePool?.dispose();
|
|
198
|
+
await safeCloseBrowser(ctx.browser);
|
|
199
|
+
if (active.cancelled) throw new Error("Capture cancelled");
|
|
200
|
+
ctx.browser = await relaunch();
|
|
201
|
+
if (ctx.pagePool) ctx.pagePool = new PagePool(ctx.browser);
|
|
202
|
+
active.browser = ctx.browser;
|
|
203
|
+
logger?.info({}, "browser recycled");
|
|
59
204
|
}
|
|
60
|
-
|
|
205
|
+
/** Capture one story x viewport; resolves to whether it timed out (failures are recorded, not thrown). */
|
|
206
|
+
async function captureOne(input, active, ctx, story, viewport, out) {
|
|
61
207
|
if (active.cancelled) throw new Error("Capture cancelled");
|
|
62
208
|
try {
|
|
63
|
-
await pushCapture(ctx, story, viewport, captures, failures, input);
|
|
209
|
+
await pushCapture(ctx, story, viewport, out.captures, out.failures, input, active);
|
|
210
|
+
return false;
|
|
64
211
|
} catch (error) {
|
|
65
|
-
pushRenderFailure(failures, input, story.id, viewport.name, error);
|
|
212
|
+
pushRenderFailure(out.failures, input, story.id, viewport.name, error);
|
|
213
|
+
return isTimeoutError(error instanceof TransientCaptureError ? error.cause : error);
|
|
66
214
|
}
|
|
67
215
|
}
|
|
68
|
-
async function pushCapture(ctx, story, viewport, captures, failures, input) {
|
|
69
|
-
const
|
|
70
|
-
|
|
216
|
+
async function pushCapture(ctx, story, viewport, captures, failures, input, active) {
|
|
217
|
+
const started = performance.now();
|
|
218
|
+
const result = await screenshotWithRetry(ctx, story, viewport, input, active);
|
|
219
|
+
const snapshot = {
|
|
71
220
|
story: result.story,
|
|
72
221
|
viewportName: viewport.name,
|
|
73
222
|
viewport,
|
|
74
|
-
screenshot: result.screenshot
|
|
75
|
-
|
|
223
|
+
screenshot: result.screenshot,
|
|
224
|
+
durationMs: Math.round(performance.now() - started),
|
|
225
|
+
...result.a11yViolations.length > 0 ? { a11y: result.a11yViolations } : {}
|
|
226
|
+
};
|
|
227
|
+
if (input.onCapture) await input.onCapture(snapshot);
|
|
228
|
+
else captures.push(snapshot);
|
|
76
229
|
if (result.a11yViolations.length > 0) handleA11y(input, failures, story, viewport, result.a11yViolations);
|
|
77
230
|
}
|
|
231
|
+
/** Retry capture only: delivery and accessibility reporting must never be repeated. */
|
|
232
|
+
async function screenshotWithRetry(ctx, story, viewport, input, active) {
|
|
233
|
+
try {
|
|
234
|
+
const result = await captureScreenshot(ctx, story, viewport);
|
|
235
|
+
if (active.cancelled || active.abort?.signal.aborted) throw new Error("Capture cancelled");
|
|
236
|
+
return result;
|
|
237
|
+
} catch (error) {
|
|
238
|
+
if (!input.retryRemoteCapture || !(error instanceof TransientCaptureError) || active.cancelled || active.abort?.signal.aborted || !ctx.browser.isConnected()) throw error;
|
|
239
|
+
return await freshScreenshot(ctx, story, viewport, input, active, error);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
async function freshScreenshot(ctx, story, viewport, input, active, firstError) {
|
|
243
|
+
const detail = {
|
|
244
|
+
storyId: story.id,
|
|
245
|
+
viewport: viewport.name,
|
|
246
|
+
phase: firstError.phase,
|
|
247
|
+
attempt: 2
|
|
248
|
+
};
|
|
249
|
+
input.logger?.warn(detail, "retrying remote capture on a fresh page");
|
|
250
|
+
try {
|
|
251
|
+
const result = await captureScreenshot(ctx, story, viewport, true);
|
|
252
|
+
if (active.cancelled || active.abort?.signal.aborted) throw new Error("Capture cancelled", { cause: firstError });
|
|
253
|
+
input.logger?.info(detail, "remote capture retry succeeded");
|
|
254
|
+
return result;
|
|
255
|
+
} catch (retryError) {
|
|
256
|
+
input.logger?.warn(detail, "remote capture retry failed");
|
|
257
|
+
throw retryError;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
78
260
|
function handleA11y(input, failures, story, viewport, violations) {
|
|
79
261
|
failures.push({
|
|
80
262
|
storyId: story.id,
|
|
@@ -99,7 +281,6 @@ function pushRenderFailure(failures, input, storyId, viewportName, error) {
|
|
|
99
281
|
err: error
|
|
100
282
|
}, "render failed for story");
|
|
101
283
|
}
|
|
102
|
-
|
|
103
|
-
export { renderAll };
|
|
284
|
+
export { assertReachable, connectionConcurrency, extractAll, launchContextOf, planRun, renderAll, screenshotWithRetry, serve };
|
|
104
285
|
|
|
105
286
|
//# sourceMappingURL=pipeline.mjs.map
|
package/dist/pipeline.mjs.map
CHANGED
|
@@ -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 { 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\n/**\n * Playwright is loaded on first render, not at import: it costs ~150 ms and tens\n * of MB, which a server that only serves reviews (or hands renders to a remote\n * worker) should not pay at boot.\n */\nasync function launchBrowser(name: BrowserName): Promise<Browser> {\n const playwright = await import(\"playwright-core\");\n if (name === \"firefox\") return await playwright.firefox.launch();\n if (name === \"webkit\") return await playwright.webkit.launch();\n return await playwright.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":";;;;;;;;;;AAkBA,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;;;;;;AAOA,eAAe,cAAc,MAAqC;CAChE,MAAM,aAAa,MAAM,OAAO;CAChC,IAAI,SAAS,WAAW,OAAO,MAAM,WAAW,QAAQ,OAAO;CAC/D,IAAI,SAAS,UAAU,OAAO,MAAM,WAAW,OAAO,OAAO;CAC7D,OAAO,MAAM,WAAW,SAAS,OAAO;AAC1C;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 { finishing, messageOf, outcomeOf, safeCloseBrowser, safeCloseServer } from \"./browser.ts\";\nimport { TransientCaptureError } from \"./capture-retry.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 LaunchContext,\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: RunInput,\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 launch = async (): Promise<Browser> =>\n await driver.launch(name, input.logger, launchContextOf(input, \"render\", active.abort?.signal));\n const browser = await launch();\n const server = await serve(input, browser);\n input.logger?.info(describeSizing({ ...input, browser: engineOf(name) }), \"capture sizing\");\n const measured = remote\n ? await measureRemote(browser, remote.remote, {\n logger: input.logger,\n signal: active.abort?.signal,\n }).catch(async (error: unknown) => {\n // A cancel that interrupted the probe releases the lease as cancelled, not failed.\n await Promise.all([\n safeCloseBrowser(browser, outcomeOf(active, true), error),\n safeCloseServer(server),\n ]);\n throw error;\n })\n : undefined;\n const result = await runWithBrowser(\n { ...input, retryRemoteCapture: remote !== undefined },\n active,\n browser,\n server,\n launch,\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/** What a remote service's `acquire` is told about the run a browser is for. */\nexport function launchContextOf(\n input: { buildId: string; runId?: string | undefined; shardIndex?: number | undefined },\n purpose: LaunchContext[\"purpose\"],\n signal?: AbortSignal,\n): LaunchContext {\n return {\n buildId: input.buildId,\n runId: input.runId ?? input.buildId,\n shardIndex: input.shardIndex,\n purpose,\n signal,\n };\n}\n\n/** The input with its target resolved, and the session gate when the target is remote. */\nexport function planRun<\n T extends { target?: CaptureTarget | undefined; logger?: PlaywrightRenderInput[\"logger\"] },\n>(\n rawInput: T,\n driver: TargetDriver,\n defaultTarget: CaptureTarget,\n): {\n input: T & {\n target: CaptureTarget;\n gate?: SessionGate | undefined;\n maxPagesPerConnection?: number | undefined;\n };\n name: CaptureTarget;\n remote: RemoteTarget | undefined;\n} {\n const name = rawInput.target ?? defaultTarget;\n const remote = driver.remote(name);\n const gate =\n remote &&\n sessionGateFor(name, remote.remote.sessions, (value) => {\n rawInput.logger?.warn(\n { target: name, value: String(value) },\n \"sessions() returned an invalid value; keeping the last good one\",\n );\n });\n return {\n input: {\n ...rawInput,\n target: name,\n gate,\n maxPagesPerConnection: remote?.remote.maxPagesPerConnection,\n },\n name,\n remote,\n };\n}\n\n/** Apply the connection's cap without changing the target-wide session budget. */\nexport function connectionConcurrency(input: {\n concurrency?: number | undefined;\n maxPagesPerConnection?: number | undefined;\n}): number | undefined {\n return input.maxPagesPerConnection === undefined\n ? input.concurrency\n : Math.min(input.concurrency ?? Infinity, input.maxPagesPerConnection);\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, launchContextOf(input, \"extract\"));\n const server = await serve(input, browser);\n return await finishing(\n async () => await readParameters(browser, server.url, first.id, input.adapter),\n async (failed, error) => {\n await Promise.all([\n safeCloseBrowser(browser, failed ? \"failed\" : \"finished\", error),\n safeCloseServer(server),\n ]);\n },\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\" | \"staticCompression\">,\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, {\n compression: input.staticCompression,\n }).catch(async (error: unknown) => {\n await safeCloseBrowser(browser, \"failed\", error);\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 return await finishing(\n async () => {\n await runAllCaptures(input, active, ctx, captures, failures, relaunch);\n return { captures, failures };\n },\n async (failed, error) => {\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([\n safeCloseBrowser(ctx.browser, outcomeOf(active, failed), error),\n safeCloseServer(server),\n ]);\n },\n );\n}\n\nfunction buildContext(browser: Browser, baseUrl: string, input: RunInput): 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: connectionConcurrency(input),\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 // A cancel that arrived while the old browser closed must not start a new one.\n if (active.cancelled) throw new Error(\"Capture cancelled\");\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: RunInput,\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, active);\n return false;\n } catch (error) {\n pushRenderFailure(out.failures, input, story.id, viewport.name, error);\n return isTimeoutError(error instanceof TransientCaptureError ? error.cause : 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: RunInput,\n active: ActiveRun,\n): Promise<void> {\n const started = performance.now();\n const result = await screenshotWithRetry(ctx, story, viewport, input, active);\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}\n\n/** Retry capture only: delivery and accessibility reporting must never be repeated. */\nexport async function screenshotWithRetry(\n ctx: CaptureContext,\n story: StoryEntry,\n viewport: Viewport,\n input: RunInput,\n active: ActiveRun,\n): ReturnType<typeof captureScreenshot> {\n try {\n const result = await captureScreenshot(ctx, story, viewport);\n if (active.cancelled || active.abort?.signal.aborted) throw new Error(\"Capture cancelled\");\n return result;\n } catch (error) {\n if (\n !input.retryRemoteCapture ||\n !(error instanceof TransientCaptureError) ||\n active.cancelled ||\n active.abort?.signal.aborted ||\n !ctx.browser.isConnected()\n )\n throw error;\n return await freshScreenshot(ctx, story, viewport, input, active, error);\n }\n}\n\nasync function freshScreenshot(\n ctx: CaptureContext,\n story: StoryEntry,\n viewport: Viewport,\n input: RunInput,\n active: ActiveRun,\n firstError: TransientCaptureError,\n): ReturnType<typeof captureScreenshot> {\n const detail = {\n storyId: story.id,\n viewport: viewport.name,\n phase: firstError.phase,\n attempt: 2,\n };\n input.logger?.warn(detail, \"retrying remote capture on a fresh page\");\n try {\n const result = await captureScreenshot(ctx, story, viewport, true);\n if (active.cancelled || active.abort?.signal.aborted)\n throw new Error(\"Capture cancelled\", { cause: firstError });\n input.logger?.info(detail, \"remote capture retry succeeded\");\n return result;\n } catch (retryError) {\n input.logger?.warn(detail, \"remote capture retry failed\");\n throw retryError;\n }\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
|
@@ -2,7 +2,7 @@ import "node:path";
|
|
|
2
2
|
import "node:url";
|
|
3
3
|
import.meta.url;
|
|
4
4
|
import { messageOf } from "./browser.mjs";
|
|
5
|
-
|
|
5
|
+
// src/play.ts
|
|
6
6
|
/** Execute the story's play function with a timeout race. */
|
|
7
7
|
async function runPlay(page, storyId, timeoutMs) {
|
|
8
8
|
try {
|
|
@@ -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
|
|
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) => {
|
|
@@ -44,7 +57,6 @@ async function inPagePlay({ storyId, timeoutMs }) {
|
|
|
44
57
|
}
|
|
45
58
|
if (playError) throw playError;
|
|
46
59
|
}
|
|
47
|
-
//#endregion
|
|
48
60
|
export { inPagePlay, runPlay };
|
|
49
61
|
|
|
50
62
|
//# sourceMappingURL=play.mjs.map
|