@storyshelf/runner-playwright 0.7.0 → 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 +6 -1
- package/dist/a11y.mjs +7 -5
- package/dist/a11y.mjs.map +1 -1
- package/dist/availability.mjs +2 -4
- package/dist/availability.mjs.map +1 -1
- package/dist/browser.mjs +31 -8
- 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 +2 -2
- package/dist/capture-runner.mjs +29 -12
- package/dist/capture-runner.mjs.map +1 -1
- package/dist/connect.mjs +23 -41
- package/dist/connect.mjs.map +1 -1
- package/dist/launch.mjs +1 -2
- package/dist/pages.mjs +1 -2
- package/dist/pipeline.mjs +84 -26
- package/dist/pipeline.mjs.map +1 -1
- package/dist/play.mjs +1 -2
- package/dist/remote/azure.d.mts +0 -2
- package/dist/remote/azure.mjs +6 -7
- package/dist/remote/azure.mjs.map +1 -1
- package/dist/remote/browserstack.d.mts +0 -2
- package/dist/remote/browserstack.mjs +4 -6
- package/dist/remote/browserstack.mjs.map +1 -1
- package/dist/remote/index.d.mts +2 -1
- package/dist/remote/index.mjs +2 -1
- package/dist/remote/playwright-server.d.mts +10 -5
- package/dist/remote/playwright-server.mjs +9 -8
- package/dist/remote/playwright-server.mjs.map +1 -1
- package/dist/remote/remote.d.mts +20 -4
- package/dist/remote/remote.mjs +20 -3
- package/dist/remote/remote.mjs.map +1 -1
- package/dist/remote-environment.mjs +31 -18
- package/dist/remote-environment.mjs.map +1 -1
- package/dist/runtime-params.mjs +1 -2
- package/dist/screenshot.mjs +65 -13
- package/dist/screenshot.mjs.map +1 -1
- package/dist/settle.mjs +15 -8
- package/dist/settle.mjs.map +1 -1
- package/dist/static-server.mjs +72 -10
- package/dist/static-server.mjs.map +1 -1
- package/dist/targets.d.mts +0 -2
- package/dist/targets.mjs +1 -2
- package/dist/tests.mjs +9 -12
- package/dist/tests.mjs.map +1 -1
- package/dist/version.mjs +1 -2
- package/package.json +8 -8
package/dist/remote/remote.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import "node:path";
|
|
|
2
2
|
import "node:url";
|
|
3
3
|
import.meta.url;
|
|
4
4
|
import { defineRemote } from "@storyshelf/core/adapter/capture-runner";
|
|
5
|
-
|
|
5
|
+
// src/remote/remote.ts
|
|
6
6
|
/** The generic remote browser entry: every preset is sugar over it (ADR 0007, decision 1). */
|
|
7
7
|
/** What the Playwright runner can drive: every engine, over its own protocol or CDP (Chromium only). */
|
|
8
8
|
const SUPPORT = {
|
|
@@ -21,18 +21,35 @@ const SUPPORT = {
|
|
|
21
21
|
* throws when the runner is created for a bad name, engine, protocol or endpoint.
|
|
22
22
|
*
|
|
23
23
|
* ```ts
|
|
24
|
+
* // A fixed service:
|
|
24
25
|
* remote({
|
|
25
26
|
* name: "mygrid",
|
|
26
27
|
* engines: ["chromium", "firefox", "webkit"],
|
|
27
|
-
*
|
|
28
|
+
* protocol: "playwright",
|
|
29
|
+
* reach: "loopback", // its browser can route back to this host
|
|
30
|
+
* connect: { endpoint: "wss://grid.example/" },
|
|
28
31
|
* sessions: 4,
|
|
32
|
+
* maxPagesPerConnection: 2,
|
|
33
|
+
* });
|
|
34
|
+
*
|
|
35
|
+
* // A service that must be woken first, with a lease handed back to `release`:
|
|
36
|
+
* remote<{ id: string }>({
|
|
37
|
+
* name: "pool",
|
|
38
|
+
* engines: ["chromium"],
|
|
39
|
+
* protocol: "playwright",
|
|
40
|
+
* reach: "loopback",
|
|
41
|
+
* sessions: 4,
|
|
42
|
+
* connect: async ({ shardIndex, signal }) => {
|
|
43
|
+
* const server = await wake(shardIndex, signal);
|
|
44
|
+
* return { endpoint: server.endpoint, lease: { id: server.id } };
|
|
45
|
+
* },
|
|
46
|
+
* release: async (lease, { outcome }) => await suspend(lease.id, outcome),
|
|
29
47
|
* });
|
|
30
48
|
* ```
|
|
31
49
|
*/
|
|
32
50
|
function remote(options) {
|
|
33
51
|
return defineRemote(options, SUPPORT);
|
|
34
52
|
}
|
|
35
|
-
//#endregion
|
|
36
53
|
export { remote };
|
|
37
54
|
|
|
38
55
|
//# sourceMappingURL=remote.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"remote.mjs","names":[],"sources":["../../src/remote/remote.ts"],"sourcesContent":["/** The generic remote browser entry: every preset is sugar over it (ADR 0007, decision 1). */\nimport {\n defineRemote,\n type BrowserName,\n type RemoteBrowser,\n type RemoteConnection,\n type RemoteOptions,\n type RemoteSupport,\n} from \"@storyshelf/core/adapter/capture-runner\";\n\n/** What the Playwright runner can drive: every engine, over its own protocol or CDP (Chromium only). */\nconst SUPPORT: RemoteSupport = {\n engines: [\"chromium\", \"firefox\", \"webkit\", \"chrome\"],\n protocols: [\"playwright\", \"cdp\"],\n label: \"remote()\",\n};\n\n/**\n * A browser service the runner connects to instead of launching, for `browsers`. It offers one\n * target per engine, `<engine>@<name>`, each with its own baselines (ADR 0007, decision 3), and\n * throws when the runner is created for a bad name, engine, protocol or endpoint.\n *\n * ```ts\n * remote({\n * name: \"mygrid\",\n * engines: [\"chromium\", \"firefox\", \"webkit\"],\n *
|
|
1
|
+
{"version":3,"file":"remote.mjs","names":[],"sources":["../../src/remote/remote.ts"],"sourcesContent":["/** The generic remote browser entry: every preset is sugar over it (ADR 0007, decision 1). */\nimport {\n defineRemote,\n type BrowserName,\n type RemoteBrowser,\n type RemoteConnection,\n type RemoteOptions,\n type RemoteSupport,\n} from \"@storyshelf/core/adapter/capture-runner\";\n\n/** What the Playwright runner can drive: every engine, over its own protocol or CDP (Chromium only). */\nconst SUPPORT: RemoteSupport = {\n engines: [\"chromium\", \"firefox\", \"webkit\", \"chrome\"],\n protocols: [\"playwright\", \"cdp\"],\n label: \"remote()\",\n};\n\n/**\n * A browser service the runner connects to instead of launching, for `browsers`. It offers one\n * target per engine, `<engine>@<name>`, each with its own baselines (ADR 0007, decision 3), and\n * throws when the runner is created for a bad name, engine, protocol or endpoint.\n *\n * ```ts\n * // A fixed service:\n * remote({\n * name: \"mygrid\",\n * engines: [\"chromium\", \"firefox\", \"webkit\"],\n * protocol: \"playwright\",\n * reach: \"loopback\", // its browser can route back to this host\n * connect: { endpoint: \"wss://grid.example/\" },\n * sessions: 4,\n * maxPagesPerConnection: 2,\n * });\n *\n * // A service that must be woken first, with a lease handed back to `release`:\n * remote<{ id: string }>({\n * name: \"pool\",\n * engines: [\"chromium\"],\n * protocol: \"playwright\",\n * reach: \"loopback\",\n * sessions: 4,\n * connect: async ({ shardIndex, signal }) => {\n * const server = await wake(shardIndex, signal);\n * return { endpoint: server.endpoint, lease: { id: server.id } };\n * },\n * release: async (lease, { outcome }) => await suspend(lease.id, outcome),\n * });\n * ```\n */\nexport function remote<TLease = unknown>(options: RemoteOptions<TLease>): RemoteBrowser {\n return defineRemote(options, SUPPORT);\n}\n\nexport type { BrowserName, RemoteBrowser, RemoteConnection, RemoteOptions };\n"],"mappings":";;;;;;;AAWA,MAAM,UAAyB;CAC7B,SAAS;EAAC;EAAY;EAAW;EAAU;CAAQ;CACnD,WAAW,CAAC,cAAc,KAAK;CAC/B,OAAO;AACT;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAkCA,SAAgB,OAAyB,SAA+C;CACtF,OAAO,aAAa,SAAS,OAAO;AACtC"}
|
|
@@ -2,35 +2,48 @@ import "node:path";
|
|
|
2
2
|
import "node:url";
|
|
3
3
|
import.meta.url;
|
|
4
4
|
import { messageOf } from "./browser.mjs";
|
|
5
|
-
import { FONT_PROBE_FAMILIES, inPageProbe, remoteEnvironment } from "@storyshelf/core/utils";
|
|
6
|
-
|
|
5
|
+
import { FONT_PROBE_FAMILIES, boundedProbe, inPageProbe, remoteEnvironment } from "@storyshelf/core/utils";
|
|
6
|
+
// src/remote-environment.ts
|
|
7
7
|
/**
|
|
8
8
|
* The fingerprint of the browser a render drove: its version, and what a blank page in it reports
|
|
9
9
|
* (OS, pixel ratio, installed fonts). Never `process.platform` or `fc-list`, which describe this
|
|
10
|
-
* host.
|
|
11
|
-
*
|
|
10
|
+
* host. The probe is bounded by `remote.timeouts.probeMs`: a page that cannot be probed, or that
|
|
11
|
+
* overruns, leaves those fields out with a warning, and an unknown component is not a change
|
|
12
|
+
* (ADR 0005), so a service outage here cannot rewrite baselines. A cancel is not an outage: it
|
|
13
|
+
* throws `Capture cancelled` so the run stops and is never stored as an unknown environment.
|
|
12
14
|
*/
|
|
13
|
-
async function measureRemote(browser, remote,
|
|
15
|
+
async function measureRemote(browser, remote, options = {}) {
|
|
14
16
|
return remoteEnvironment({
|
|
15
17
|
browserVersion: typeof browser.version === "function" ? browser.version() : void 0,
|
|
16
|
-
probe: await probeBlankPage(browser,
|
|
18
|
+
probe: await probeBlankPage(browser, remote.timeouts.probeMs, options),
|
|
17
19
|
declaredPlatform: remote.platform
|
|
18
20
|
});
|
|
19
21
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Open a blank page, read the probe and close the page, within the limit. The page is closed on
|
|
24
|
+
* every path: after a result, after a failure, and after a timeout or cancel, when the work that
|
|
25
|
+
* opened it may still be pending (a page it opens late is closed by the work itself).
|
|
26
|
+
*/
|
|
27
|
+
async function probeBlankPage(browser, timeoutMs, options) {
|
|
28
|
+
let page;
|
|
29
|
+
const outcome = await boundedProbe({
|
|
30
|
+
timeoutMs,
|
|
31
|
+
signal: options.signal,
|
|
32
|
+
run: async ({ stopped }) => {
|
|
33
|
+
const opened = await browser.newPage();
|
|
34
|
+
page = opened;
|
|
35
|
+
if (stopped()) {
|
|
36
|
+
await opened.close().catch(() => {});
|
|
37
|
+
throw new Error("the measurement stopped before its page opened");
|
|
38
|
+
}
|
|
39
|
+
return await opened.evaluate(inPageProbe, [...FONT_PROBE_FAMILIES]);
|
|
27
40
|
}
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
41
|
+
});
|
|
42
|
+
await page?.close().catch(() => {});
|
|
43
|
+
if (outcome.status === "done") return outcome.value;
|
|
44
|
+
if (outcome.status === "cancelled") throw new Error("Capture cancelled");
|
|
45
|
+
options.logger?.warn(outcome.status === "timeout" ? { timeoutMs } : { reason: messageOf(outcome.error) }, outcome.status === "timeout" ? "measuring the remote browser timed out; its environment is unknown" : "could not measure the remote browser");
|
|
32
46
|
}
|
|
33
|
-
//#endregion
|
|
34
47
|
export { measureRemote };
|
|
35
48
|
|
|
36
49
|
//# sourceMappingURL=remote-environment.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"remote-environment.mjs","names":[],"sources":["../src/remote-environment.ts"],"sourcesContent":["/** Measuring a remote browser's environment from inside it (ADR 0007, decision 2). */\nimport type { CaptureEnvironment, RemoteBrowser } from \"@storyshelf/core/adapter/capture-runner\";\nimport type { Logger } from \"@storyshelf/core/logger\";\nimport {\n FONT_PROBE_FAMILIES,\n inPageProbe,\n remoteEnvironment,\n type BrowserProbe,\n} from \"@storyshelf/core/utils\";\nimport type { Browser } from \"playwright-core\";\nimport { messageOf } from \"./browser.ts\";\n\n/**\n * The fingerprint of the browser a render drove: its version, and what a blank page in it reports\n * (OS, pixel ratio, installed fonts). Never `process.platform` or `fc-list`, which describe this\n * host.
|
|
1
|
+
{"version":3,"file":"remote-environment.mjs","names":[],"sources":["../src/remote-environment.ts"],"sourcesContent":["/** Measuring a remote browser's environment from inside it (ADR 0007, decision 2). */\nimport type { CaptureEnvironment, RemoteBrowser } from \"@storyshelf/core/adapter/capture-runner\";\nimport type { Logger } from \"@storyshelf/core/logger\";\nimport {\n FONT_PROBE_FAMILIES,\n boundedProbe,\n inPageProbe,\n remoteEnvironment,\n type BrowserProbe,\n} from \"@storyshelf/core/utils\";\nimport type { Browser, Page } from \"playwright-core\";\nimport { messageOf } from \"./browser.ts\";\n\n/** What stops a measurement: the run's cancel signal, and where warnings go. */\nexport interface MeasureOptions {\n logger?: Logger | undefined;\n /** Aborted when the run is cancelled; the measurement then stops the run. */\n signal?: AbortSignal | undefined;\n}\n\n/**\n * The fingerprint of the browser a render drove: its version, and what a blank page in it reports\n * (OS, pixel ratio, installed fonts). Never `process.platform` or `fc-list`, which describe this\n * host. The probe is bounded by `remote.timeouts.probeMs`: a page that cannot be probed, or that\n * overruns, leaves those fields out with a warning, and an unknown component is not a change\n * (ADR 0005), so a service outage here cannot rewrite baselines. A cancel is not an outage: it\n * throws `Capture cancelled` so the run stops and is never stored as an unknown environment.\n */\nexport async function measureRemote(\n browser: Browser,\n remote: RemoteBrowser,\n options: MeasureOptions = {},\n): Promise<CaptureEnvironment> {\n return remoteEnvironment({\n browserVersion: typeof browser.version === \"function\" ? browser.version() : undefined,\n probe: await probeBlankPage(browser, remote.timeouts.probeMs, options),\n declaredPlatform: remote.platform,\n });\n}\n\n/**\n * Open a blank page, read the probe and close the page, within the limit. The page is closed on\n * every path: after a result, after a failure, and after a timeout or cancel, when the work that\n * opened it may still be pending (a page it opens late is closed by the work itself).\n */\nasync function probeBlankPage(\n browser: Browser,\n timeoutMs: number,\n options: MeasureOptions,\n): Promise<BrowserProbe | undefined> {\n let page: Page | undefined;\n const outcome = await boundedProbe({\n timeoutMs,\n signal: options.signal,\n run: async ({ stopped }) => {\n const opened = await browser.newPage();\n page = opened;\n if (stopped()) {\n await opened.close().catch(() => {});\n throw new Error(\"the measurement stopped before its page opened\");\n }\n return await opened.evaluate(inPageProbe, [...FONT_PROBE_FAMILIES]);\n },\n });\n await page?.close().catch(() => {});\n if (outcome.status === \"done\") {\n return outcome.value;\n }\n if (outcome.status === \"cancelled\") {\n throw new Error(\"Capture cancelled\");\n }\n options.logger?.warn(\n outcome.status === \"timeout\" ? { timeoutMs } : { reason: messageOf(outcome.error) },\n outcome.status === \"timeout\"\n ? \"measuring the remote browser timed out; its environment is unknown\"\n : \"could not measure the remote browser\",\n );\n return undefined;\n}\n"],"mappings":";;;;;;;;;;;;;;AA4BA,eAAsB,cACpB,SACA,QACA,UAA0B,CAAC,GACE;CAC7B,OAAO,kBAAkB;EACvB,gBAAgB,OAAO,QAAQ,YAAY,aAAa,QAAQ,QAAQ,IAAI,KAAA;EAC5E,OAAO,MAAM,eAAe,SAAS,OAAO,SAAS,SAAS,OAAO;EACrE,kBAAkB,OAAO;CAC3B,CAAC;AACH;;;;;;AAOA,eAAe,eACb,SACA,WACA,SACmC;CACnC,IAAI;CACJ,MAAM,UAAU,MAAM,aAAa;EACjC;EACA,QAAQ,QAAQ;EAChB,KAAK,OAAO,EAAE,cAAc;GAC1B,MAAM,SAAS,MAAM,QAAQ,QAAQ;GACrC,OAAO;GACP,IAAI,QAAQ,GAAG;IACb,MAAM,OAAO,MAAM,CAAC,CAAC,YAAY,CAAC,CAAC;IACnC,MAAM,IAAI,MAAM,gDAAgD;GAClE;GACA,OAAO,MAAM,OAAO,SAAS,aAAa,CAAC,GAAG,mBAAmB,CAAC;EACpE;CACF,CAAC;CACD,MAAM,MAAM,MAAM,CAAC,CAAC,YAAY,CAAC,CAAC;CAClC,IAAI,QAAQ,WAAW,QACrB,OAAO,QAAQ;CAEjB,IAAI,QAAQ,WAAW,aACrB,MAAM,IAAI,MAAM,mBAAmB;CAErC,QAAQ,QAAQ,KACd,QAAQ,WAAW,YAAY,EAAE,UAAU,IAAI,EAAE,QAAQ,UAAU,QAAQ,KAAK,EAAE,GAClF,QAAQ,WAAW,YACf,uEACA,sCACN;AAEF"}
|
package/dist/runtime-params.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import "node:path";
|
|
|
2
2
|
import "node:url";
|
|
3
3
|
import.meta.url;
|
|
4
4
|
import { mergeParameters } from "@storyshelf/core/capture";
|
|
5
|
-
|
|
5
|
+
// src/runtime-params.ts
|
|
6
6
|
/** Per-run runtime-parameter extraction cache (ADR 0017). */
|
|
7
7
|
/** Create the per-run runtime-parameter cache shared across captures. */
|
|
8
8
|
function createRuntimeParamsState() {
|
|
@@ -61,7 +61,6 @@ async function readExtractedParameters(page) {
|
|
|
61
61
|
function hasEntries(map) {
|
|
62
62
|
return Boolean(map && Object.keys(map).length > 0);
|
|
63
63
|
}
|
|
64
|
-
//#endregion
|
|
65
64
|
export { createRuntimeParamsState, extractAllParameters, runtimeParametersForStory };
|
|
66
65
|
|
|
67
66
|
//# sourceMappingURL=runtime-params.mjs.map
|
package/dist/screenshot.mjs
CHANGED
|
@@ -1,32 +1,53 @@
|
|
|
1
1
|
import "node:path";
|
|
2
2
|
import "node:url";
|
|
3
3
|
import.meta.url;
|
|
4
|
+
import { TransientCaptureError, capturePhase } from "./capture-retry.mjs";
|
|
4
5
|
import { awaitPaint, settleTimeoutOf, waitForSettled } from "./settle.mjs";
|
|
5
6
|
import { acquirePage } from "./pages.mjs";
|
|
6
7
|
import { runtimeParametersForStory } from "./runtime-params.mjs";
|
|
7
8
|
import { checkA11y } from "./a11y.mjs";
|
|
8
9
|
import { runPlay } from "./play.mjs";
|
|
9
|
-
import { getScreenshotPlan } from "@storyshelf/core/capture";
|
|
10
|
-
|
|
10
|
+
import { cleanIgnoreSelectors, getScreenshotPlan, ignoreStylesheet } from "@storyshelf/core/capture";
|
|
11
|
+
// src/screenshot.ts
|
|
11
12
|
/** Capture a screenshot for one story/viewport, returning buffer + a11y violations. */
|
|
12
|
-
async function captureScreenshot(ctx, inputStory, viewport) {
|
|
13
|
-
|
|
13
|
+
async function captureScreenshot(ctx, inputStory, viewport, fresh = false) {
|
|
14
|
+
return await captureLeased(ctx, inputStory, viewport, await acquirePage(fresh ? { browser: ctx.browser } : ctx, viewport));
|
|
15
|
+
}
|
|
16
|
+
async function captureLeased(ctx, inputStory, viewport, lease) {
|
|
14
17
|
let reusable = false;
|
|
18
|
+
const crash = watchCrash(lease.page);
|
|
15
19
|
if (ctx.navigationTimeoutMs) lease.page.setDefaultTimeout(ctx.navigationTimeoutMs);
|
|
16
20
|
try {
|
|
17
21
|
const result = await runCapture(lease.page, ctx, inputStory, viewport);
|
|
18
22
|
reusable = true;
|
|
19
23
|
return result;
|
|
24
|
+
} catch (error) {
|
|
25
|
+
throw crash.classify(error);
|
|
20
26
|
} finally {
|
|
27
|
+
crash.dispose();
|
|
21
28
|
await lease.release(reusable);
|
|
22
29
|
}
|
|
23
30
|
}
|
|
31
|
+
function watchCrash(page) {
|
|
32
|
+
let crashed = false;
|
|
33
|
+
const onCrash = () => {
|
|
34
|
+
crashed = true;
|
|
35
|
+
};
|
|
36
|
+
page.on?.("crash", onCrash);
|
|
37
|
+
return {
|
|
38
|
+
classify: (error) => crashed ? new TransientCaptureError("crash", error) : error,
|
|
39
|
+
dispose: () => {
|
|
40
|
+
page.off?.("crash", onCrash);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
}
|
|
24
44
|
async function runCapture(page, ctx, inputStory, viewport) {
|
|
25
45
|
const story = await navigateAndEnrich(ctx, page, inputStory, viewport);
|
|
26
46
|
await waitUntilReady(ctx, page, story);
|
|
27
47
|
if (ctx.executePlay) await runPlay(page, story.id, ctx.playTimeoutMs ?? 1e4);
|
|
28
|
-
const a11yViolations = await getA11y(ctx, page);
|
|
48
|
+
const a11yViolations = await getA11y(ctx, page, story);
|
|
29
49
|
await settlePage(page, story);
|
|
50
|
+
await maskIgnoredRegions(ctx, page, story);
|
|
30
51
|
const plan = await resolvePlan(page, viewport, story);
|
|
31
52
|
if (plan.viewport.width !== viewport.width || plan.viewport.height !== viewport.height) await page.setViewportSize(plan.viewport);
|
|
32
53
|
return {
|
|
@@ -36,7 +57,7 @@ async function runCapture(page, ctx, inputStory, viewport) {
|
|
|
36
57
|
};
|
|
37
58
|
}
|
|
38
59
|
async function navigateAndEnrich(ctx, page, story, viewport) {
|
|
39
|
-
await page.goto(ctx.adapter.buildUrl(ctx.baseUrl, story.id, viewport.globals), { waitUntil: "networkidle" });
|
|
60
|
+
await capturePhase("navigation", async () => await page.goto(ctx.adapter.buildUrl(ctx.baseUrl, story.id, viewport.globals), { waitUntil: "networkidle" }));
|
|
40
61
|
await ctx.adapter.waitForReady?.(page);
|
|
41
62
|
if (story.parameters) return story;
|
|
42
63
|
const rpPage = page;
|
|
@@ -79,8 +100,8 @@ function logSettle(ctx, story, report) {
|
|
|
79
100
|
if (!report.settled && report.hooked) ctx.logger?.warn(detail, "story did not signal that it finished rendering; used a fixed wait");
|
|
80
101
|
else if (problem !== void 0) ctx.logger?.warn(detail, "story reported an error while rendering");
|
|
81
102
|
}
|
|
82
|
-
async function getA11y(ctx, page) {
|
|
83
|
-
return ctx.runA11y ? await checkA11y(page, ctx.logger) : [];
|
|
103
|
+
async function getA11y(ctx, page, story) {
|
|
104
|
+
return ctx.runA11y ? await checkA11y(page, ctx.logger, story.id) : [];
|
|
84
105
|
}
|
|
85
106
|
async function settlePage(page, story) {
|
|
86
107
|
const burst = story.parameters?.burst ?? 1;
|
|
@@ -95,6 +116,38 @@ async function settlePage(page, story) {
|
|
|
95
116
|
});
|
|
96
117
|
} catch {}
|
|
97
118
|
}
|
|
119
|
+
/**
|
|
120
|
+
* Hide the story's ignore regions (`ignoreSelectors` plus the attribute markers) so a changing
|
|
121
|
+
* timestamp or avatar cannot cause a diff. A selector the browser rejects is skipped with a
|
|
122
|
+
* warning on the snapshot log; it never fails the build.
|
|
123
|
+
*/
|
|
124
|
+
async function maskIgnoredRegions(ctx, page, story) {
|
|
125
|
+
const { selectors: declared, skipped } = cleanIgnoreSelectors(story.parameters?.ignoreSelectors);
|
|
126
|
+
try {
|
|
127
|
+
const refused = await page.evaluate((list) => {
|
|
128
|
+
const doc = globalThis.document;
|
|
129
|
+
return list.filter((selector) => {
|
|
130
|
+
try {
|
|
131
|
+
doc.querySelector(selector);
|
|
132
|
+
return false;
|
|
133
|
+
} catch {
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
}, declared);
|
|
138
|
+
const rejected = [...skipped, ...refused];
|
|
139
|
+
if (rejected.length > 0) ctx.logger?.warn({
|
|
140
|
+
storyId: story.id,
|
|
141
|
+
selectors: rejected
|
|
142
|
+
}, "skipped invalid ignore selectors");
|
|
143
|
+
await page.addStyleTag({ content: ignoreStylesheet(declared.filter((selector) => !rejected.includes(selector))) });
|
|
144
|
+
} catch (err) {
|
|
145
|
+
ctx.logger?.warn({
|
|
146
|
+
storyId: story.id,
|
|
147
|
+
err
|
|
148
|
+
}, "could not mask ignore regions");
|
|
149
|
+
}
|
|
150
|
+
}
|
|
98
151
|
async function resolvePlan(page, viewport, story) {
|
|
99
152
|
const box = await page.locator("#storybook-root").boundingBox().catch(() => null);
|
|
100
153
|
return getScreenshotPlan(viewport, box, {
|
|
@@ -116,20 +169,19 @@ async function burstShots(page, plan, story) {
|
|
|
116
169
|
}
|
|
117
170
|
const preferred = shots[Math.floor(shots.length / 2)] ?? shots[0];
|
|
118
171
|
if (preferred) return preferred;
|
|
119
|
-
return await page.screenshot({
|
|
172
|
+
return await capturePhase("screenshot", async () => await page.screenshot({
|
|
120
173
|
animations: story.parameters?.pauseAnimationAtEnd ? "allow" : "disabled",
|
|
121
174
|
...plan.fullPage ? { fullPage: true } : {},
|
|
122
175
|
...plan.clip ? { clip: plan.clip } : {}
|
|
123
|
-
});
|
|
176
|
+
}));
|
|
124
177
|
}
|
|
125
178
|
async function shotOne(page, plan, story) {
|
|
126
|
-
return await page.screenshot({
|
|
179
|
+
return await capturePhase("screenshot", async () => await page.screenshot({
|
|
127
180
|
animations: story.parameters?.pauseAnimationAtEnd ? "allow" : "disabled",
|
|
128
181
|
...plan.fullPage ? { fullPage: true } : {},
|
|
129
182
|
...plan.clip ? { clip: plan.clip } : {}
|
|
130
|
-
});
|
|
183
|
+
}));
|
|
131
184
|
}
|
|
132
|
-
//#endregion
|
|
133
185
|
export { captureScreenshot };
|
|
134
186
|
|
|
135
187
|
//# sourceMappingURL=screenshot.mjs.map
|
package/dist/screenshot.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"screenshot.mjs","names":[],"sources":["../src/screenshot.ts"],"sourcesContent":["/** Screenshot pipeline for a single story + viewport. */\n\nimport type { StoryEntry, Viewport } from \"@storyshelf/core/adapter/capture-runner\";\nimport { getScreenshotPlan } from \"@storyshelf/core/capture\";\nimport type { Page } from \"playwright-core\";\nimport { checkA11y } from \"./a11y.ts\";\nimport { acquirePage } from \"./pages.ts\";\nimport { runPlay } from \"./play.ts\";\nimport { runtimeParametersForStory } from \"./runtime-params.ts\";\nimport { awaitPaint, settleTimeoutOf, waitForSettled, type SettleReport } from \"./settle.ts\";\nimport type { CaptureContext, RuntimeParamsPage } from \"./types.ts\";\n\n/** Capture a screenshot for one story/viewport, returning buffer + a11y violations. */\nexport async function captureScreenshot(\n ctx: CaptureContext,\n inputStory: StoryEntry,\n viewport: Viewport,\n): Promise<{ screenshot: Buffer; a11yViolations: string[]; story: StoryEntry }> {\n const lease = await acquirePage(ctx, viewport);\n let reusable = false;\n if (ctx.navigationTimeoutMs) lease.page.setDefaultTimeout(ctx.navigationTimeoutMs);\n try {\n const result = await runCapture(lease.page, ctx, inputStory, viewport);\n reusable = true;\n return result;\n } finally {\n await lease.release(reusable);\n }\n}\n\nasync function runCapture(\n page: Page,\n ctx: CaptureContext,\n inputStory: StoryEntry,\n viewport: Viewport,\n): Promise<{ screenshot: Buffer; a11yViolations: string[]; story: StoryEntry }> {\n const story = await navigateAndEnrich(ctx, page, inputStory, viewport);\n await waitUntilReady(ctx, page, story);\n if (ctx.executePlay) await runPlay(page, story.id, ctx.playTimeoutMs ?? 10_000);\n const a11yViolations = await getA11y(ctx, page);\n await settlePage(page, story);\n const plan = await resolvePlan(page, viewport, story);\n if (plan.viewport.width !== viewport.width || plan.viewport.height !== viewport.height)\n await page.setViewportSize(plan.viewport);\n const screenshot = await burstShots(page, plan, story);\n return { screenshot, a11yViolations, story };\n}\n\nasync function navigateAndEnrich(\n ctx: CaptureContext,\n page: Page,\n story: StoryEntry,\n viewport: Viewport,\n): Promise<StoryEntry> {\n await page.goto(ctx.adapter.buildUrl(ctx.baseUrl, story.id, viewport.globals), {\n waitUntil: \"networkidle\",\n });\n await ctx.adapter.waitForReady?.(page);\n if (story.parameters) return story;\n const rpPage = page as unknown as RuntimeParamsPage;\n const params = await runtimeParametersForStory(ctx.runtimeParams, rpPage, story.id);\n return params ? { ...story, parameters: params } : story;\n}\n/**\n * Wait until the story is ready to be photographed: the selector exists, then Storybook says the\n * render (and its play function) finished, then an explicit `parameters.delay` runs on top. A page\n * that never signals (no channel, or no event before the timeout) gets today's fixed wait instead:\n * `delay ?? 500` ms.\n */\nasync function waitUntilReady(ctx: CaptureContext, page: Page, story: StoryEntry): Promise<void> {\n if (!ctx.adapter.screenshotSelector) return;\n await page.waitForSelector(ctx.adapter.screenshotSelector, { state: \"attached\" });\n const report = await waitForSettled(page, settleTimeoutOf(ctx));\n if (report.settled) {\n await awaitPaint(page);\n if (story.parameters?.delay !== undefined) await page.waitForTimeout(story.parameters.delay);\n } else {\n await page.waitForTimeout(story.parameters?.delay ?? 500);\n }\n logSettle(ctx, story, report);\n if (story.parameters?.pauseAnimationAtEnd) {\n await page.evaluate(() => {\n const doc = globalThis.document as unknown as {\n getAnimations: () => { pause: () => void }[];\n };\n for (const anim of doc.getAnimations()) anim.pause();\n });\n }\n}\n\n/** One line per story that did not render cleanly: where it stopped, and what Storybook said. */\nfunction logSettle(ctx: CaptureContext, story: StoryEntry, report: SettleReport): void {\n const problem = report.renderError ?? report.playError ?? report.unhandled?.[0];\n if (report.settled && problem === undefined) return;\n const detail = { storyId: story.id, phase: report.phase, waitedMs: report.elapsedMs, problem };\n if (!report.settled && report.hooked) {\n ctx.logger?.warn(detail, \"story did not signal that it finished rendering; used a fixed wait\");\n } else if (problem !== undefined) {\n ctx.logger?.warn(detail, \"story reported an error while rendering\");\n }\n}\nasync function getA11y(ctx: CaptureContext, page: Page): Promise<string[]> {\n // checkA11y reports its own failures as an annotation, so it never throws.\n return ctx.runA11y ? await checkA11y(page, ctx.logger) : [];\n}\nasync function settlePage(page: Page, story: StoryEntry): Promise<void> {\n const burst = story.parameters?.burst ?? 1;\n const waitFonts = story.parameters?.waitForFonts ?? burst > 1;\n if (waitFonts) {\n try {\n await page.evaluate(async () => {\n await (globalThis as unknown as { document: { fonts: { ready: Promise<void> } } }).document\n .fonts.ready;\n });\n } catch {\n // best-effort\n }\n }\n try {\n await page.evaluate(() => {\n for (const vid of (globalThis as unknown as { document: Document }).document.querySelectorAll(\n \"video\",\n ))\n // oxlint-disable-next-line typescript/no-unnecessary-type-assertion -- narrow to HTMLVideoElement\n (vid as unknown as HTMLVideoElement).pause();\n });\n } catch {\n // best-effort\n }\n}\nasync function resolvePlan(\n page: Page,\n viewport: Viewport,\n story: StoryEntry,\n): Promise<ReturnType<typeof getScreenshotPlan>> {\n const box = await page\n .locator(\"#storybook-root\")\n .boundingBox()\n .catch(() => null);\n return getScreenshotPlan(viewport, box, {\n autoCrop: story.parameters?.autoCrop,\n whitespaceThreshold: story.parameters?.autoCropThreshold,\n fullPageOnOverflow: story.parameters?.fullPageOnOverflow,\n minWidth: story.parameters?.minWidth,\n minHeight: story.parameters?.minHeight,\n maxWidth: story.parameters?.maxWidth,\n maxHeight: story.parameters?.maxHeight,\n });\n}\nasync function burstShots(\n page: Page,\n plan: ReturnType<typeof getScreenshotPlan>,\n story: StoryEntry,\n): Promise<Buffer> {\n const count = Math.max(1, Math.min(5, story.parameters?.burst ?? 1));\n const shots: Buffer[] = [];\n for (let i = 0; i < count; i += 1) {\n if (i > 0) {\n // oxlint-disable-next-line eslint/no-await-in-loop -- sequential burst\n await page.waitForTimeout(100);\n }\n // oxlint-disable-next-line eslint/no-await-in-loop -- sequential burst\n shots.push(await shotOne(page, plan, story));\n }\n const preferred = shots[Math.floor(shots.length / 2)] ?? shots[0];\n if (preferred) return preferred;\n const fallback: unknown = await page.screenshot({\n animations: story.parameters?.pauseAnimationAtEnd ? \"allow\" : \"disabled\",\n ...(plan.fullPage ? { fullPage: true } : {}),\n ...(plan.clip ? { clip: plan.clip } : {}),\n });\n // oxlint-disable-next-line typescript/no-unnecessary-type-assertion -- Buffer narrowing\n return fallback as unknown as Buffer;\n}\nasync function shotOne(\n page: Page,\n plan: ReturnType<typeof getScreenshotPlan>,\n story: StoryEntry,\n): Promise<Buffer> {\n const shot: unknown = await page.screenshot({\n animations: story.parameters?.pauseAnimationAtEnd ? \"allow\" : \"disabled\",\n ...(plan.fullPage ? { fullPage: true } : {}),\n ...(plan.clip ? { clip: plan.clip } : {}),\n });\n // oxlint-disable-next-line typescript/no-unnecessary-type-assertion -- Buffer narrowing\n return shot as unknown as Buffer;\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"screenshot.mjs","names":[],"sources":["../src/screenshot.ts"],"sourcesContent":["/** Screenshot pipeline for a single story + viewport. */\n\nimport type { StoryEntry, Viewport } from \"@storyshelf/core/adapter/capture-runner\";\nimport {\n cleanIgnoreSelectors,\n getScreenshotPlan,\n ignoreStylesheet,\n} from \"@storyshelf/core/capture\";\nimport type { Page } from \"playwright-core\";\nimport { checkA11y } from \"./a11y.ts\";\nimport { capturePhase, TransientCaptureError } from \"./capture-retry.ts\";\nimport { acquirePage, type PageLease } from \"./pages.ts\";\nimport { runPlay } from \"./play.ts\";\nimport { runtimeParametersForStory } from \"./runtime-params.ts\";\nimport { awaitPaint, settleTimeoutOf, waitForSettled, type SettleReport } from \"./settle.ts\";\nimport type { CaptureContext, RuntimeParamsPage } from \"./types.ts\";\n\n/** Capture a screenshot for one story/viewport, returning buffer + a11y violations. */\nexport async function captureScreenshot(\n ctx: CaptureContext,\n inputStory: StoryEntry,\n viewport: Viewport,\n fresh = false,\n): Promise<{ screenshot: Buffer; a11yViolations: string[]; story: StoryEntry }> {\n const lease = await acquirePage(fresh ? { browser: ctx.browser } : ctx, viewport);\n return await captureLeased(ctx, inputStory, viewport, lease);\n}\n\nasync function captureLeased(\n ctx: CaptureContext,\n inputStory: StoryEntry,\n viewport: Viewport,\n lease: PageLease,\n): Promise<{ screenshot: Buffer; a11yViolations: string[]; story: StoryEntry }> {\n let reusable = false;\n const crash = watchCrash(lease.page);\n if (ctx.navigationTimeoutMs) lease.page.setDefaultTimeout(ctx.navigationTimeoutMs);\n try {\n const result = await runCapture(lease.page, ctx, inputStory, viewport);\n reusable = true;\n return result;\n } catch (error) {\n throw crash.classify(error);\n } finally {\n crash.dispose();\n await lease.release(reusable);\n }\n}\n\nfunction watchCrash(page: Page): { classify(error: unknown): unknown; dispose(): void } {\n let crashed = false;\n const onCrash = (): void => {\n crashed = true;\n };\n page.on?.(\"crash\", onCrash);\n return {\n classify: (error) => (crashed ? new TransientCaptureError(\"crash\", error) : error),\n dispose: () => {\n page.off?.(\"crash\", onCrash);\n },\n };\n}\n\nasync function runCapture(\n page: Page,\n ctx: CaptureContext,\n inputStory: StoryEntry,\n viewport: Viewport,\n): Promise<{ screenshot: Buffer; a11yViolations: string[]; story: StoryEntry }> {\n const story = await navigateAndEnrich(ctx, page, inputStory, viewport);\n await waitUntilReady(ctx, page, story);\n if (ctx.executePlay) await runPlay(page, story.id, ctx.playTimeoutMs ?? 10_000);\n const a11yViolations = await getA11y(ctx, page, story);\n await settlePage(page, story);\n await maskIgnoredRegions(ctx, page, story);\n const plan = await resolvePlan(page, viewport, story);\n if (plan.viewport.width !== viewport.width || plan.viewport.height !== viewport.height)\n await page.setViewportSize(plan.viewport);\n const screenshot = await burstShots(page, plan, story);\n return { screenshot, a11yViolations, story };\n}\n\nasync function navigateAndEnrich(\n ctx: CaptureContext,\n page: Page,\n story: StoryEntry,\n viewport: Viewport,\n): Promise<StoryEntry> {\n await capturePhase(\n \"navigation\",\n async () =>\n await page.goto(ctx.adapter.buildUrl(ctx.baseUrl, story.id, viewport.globals), {\n waitUntil: \"networkidle\",\n }),\n );\n await ctx.adapter.waitForReady?.(page);\n if (story.parameters) return story;\n const rpPage = page as unknown as RuntimeParamsPage;\n const params = await runtimeParametersForStory(ctx.runtimeParams, rpPage, story.id);\n return params ? { ...story, parameters: params } : story;\n}\n/**\n * Wait until the story is ready to be photographed: the selector exists, then Storybook says the\n * render (and its play function) finished, then an explicit `parameters.delay` runs on top. A page\n * that never signals (no channel, or no event before the timeout) gets today's fixed wait instead:\n * `delay ?? 500` ms.\n */\nasync function waitUntilReady(ctx: CaptureContext, page: Page, story: StoryEntry): Promise<void> {\n if (!ctx.adapter.screenshotSelector) return;\n await page.waitForSelector(ctx.adapter.screenshotSelector, { state: \"attached\" });\n const report = await waitForSettled(page, settleTimeoutOf(ctx));\n if (report.settled) {\n await awaitPaint(page);\n if (story.parameters?.delay !== undefined) await page.waitForTimeout(story.parameters.delay);\n } else {\n await page.waitForTimeout(story.parameters?.delay ?? 500);\n }\n logSettle(ctx, story, report);\n if (story.parameters?.pauseAnimationAtEnd) {\n await page.evaluate(() => {\n const doc = globalThis.document as unknown as {\n getAnimations: () => { pause: () => void }[];\n };\n for (const anim of doc.getAnimations()) anim.pause();\n });\n }\n}\n\n/** One line per story that did not render cleanly: where it stopped, and what Storybook said. */\nfunction logSettle(ctx: CaptureContext, story: StoryEntry, report: SettleReport): void {\n const problem = report.renderError ?? report.playError ?? report.unhandled?.[0];\n if (report.settled && problem === undefined) return;\n const detail = { storyId: story.id, phase: report.phase, waitedMs: report.elapsedMs, problem };\n if (!report.settled && report.hooked) {\n ctx.logger?.warn(detail, \"story did not signal that it finished rendering; used a fixed wait\");\n } else if (problem !== undefined) {\n ctx.logger?.warn(detail, \"story reported an error while rendering\");\n }\n}\nasync function getA11y(ctx: CaptureContext, page: Page, story: StoryEntry): Promise<string[]> {\n // checkA11y reports its own failures as an annotation, so it never throws.\n return ctx.runA11y ? await checkA11y(page, ctx.logger, story.id) : [];\n}\nasync function settlePage(page: Page, story: StoryEntry): Promise<void> {\n const burst = story.parameters?.burst ?? 1;\n const waitFonts = story.parameters?.waitForFonts ?? burst > 1;\n if (waitFonts) {\n try {\n await page.evaluate(async () => {\n await (globalThis as unknown as { document: { fonts: { ready: Promise<void> } } }).document\n .fonts.ready;\n });\n } catch {\n // best-effort\n }\n }\n try {\n await page.evaluate(() => {\n for (const vid of (globalThis as unknown as { document: Document }).document.querySelectorAll(\n \"video\",\n ))\n // oxlint-disable-next-line typescript/no-unnecessary-type-assertion -- narrow to HTMLVideoElement\n (vid as unknown as HTMLVideoElement).pause();\n });\n } catch {\n // best-effort\n }\n}\n/**\n * Hide the story's ignore regions (`ignoreSelectors` plus the attribute markers) so a changing\n * timestamp or avatar cannot cause a diff. A selector the browser rejects is skipped with a\n * warning on the snapshot log; it never fails the build.\n */\nasync function maskIgnoredRegions(\n ctx: CaptureContext,\n page: Page,\n story: StoryEntry,\n): Promise<void> {\n const { selectors: declared, skipped } = cleanIgnoreSelectors(story.parameters?.ignoreSelectors);\n try {\n const refused = await page.evaluate((list: string[]) => {\n const doc = (globalThis as unknown as { document: { querySelector(s: string): unknown } })\n .document;\n return list.filter((selector) => {\n try {\n doc.querySelector(selector);\n return false;\n } catch {\n return true;\n }\n });\n }, declared);\n const rejected = [...skipped, ...refused];\n if (rejected.length > 0)\n ctx.logger?.warn(\n { storyId: story.id, selectors: rejected },\n \"skipped invalid ignore selectors\",\n );\n await page.addStyleTag({\n content: ignoreStylesheet(declared.filter((selector) => !rejected.includes(selector))),\n });\n } catch (err) {\n ctx.logger?.warn({ storyId: story.id, err }, \"could not mask ignore regions\");\n }\n}\nasync function resolvePlan(\n page: Page,\n viewport: Viewport,\n story: StoryEntry,\n): Promise<ReturnType<typeof getScreenshotPlan>> {\n const box = await page\n .locator(\"#storybook-root\")\n .boundingBox()\n .catch(() => null);\n return getScreenshotPlan(viewport, box, {\n autoCrop: story.parameters?.autoCrop,\n whitespaceThreshold: story.parameters?.autoCropThreshold,\n fullPageOnOverflow: story.parameters?.fullPageOnOverflow,\n minWidth: story.parameters?.minWidth,\n minHeight: story.parameters?.minHeight,\n maxWidth: story.parameters?.maxWidth,\n maxHeight: story.parameters?.maxHeight,\n });\n}\nasync function burstShots(\n page: Page,\n plan: ReturnType<typeof getScreenshotPlan>,\n story: StoryEntry,\n): Promise<Buffer> {\n const count = Math.max(1, Math.min(5, story.parameters?.burst ?? 1));\n const shots: Buffer[] = [];\n for (let i = 0; i < count; i += 1) {\n if (i > 0) {\n // oxlint-disable-next-line eslint/no-await-in-loop -- sequential burst\n await page.waitForTimeout(100);\n }\n // oxlint-disable-next-line eslint/no-await-in-loop -- sequential burst\n shots.push(await shotOne(page, plan, story));\n }\n const preferred = shots[Math.floor(shots.length / 2)] ?? shots[0];\n if (preferred) return preferred;\n const fallback: unknown = await capturePhase(\n \"screenshot\",\n async () =>\n await page.screenshot({\n animations: story.parameters?.pauseAnimationAtEnd ? \"allow\" : \"disabled\",\n ...(plan.fullPage ? { fullPage: true } : {}),\n ...(plan.clip ? { clip: plan.clip } : {}),\n }),\n );\n // oxlint-disable-next-line typescript/no-unnecessary-type-assertion -- Buffer narrowing\n return fallback as unknown as Buffer;\n}\nasync function shotOne(\n page: Page,\n plan: ReturnType<typeof getScreenshotPlan>,\n story: StoryEntry,\n): Promise<Buffer> {\n const shot: unknown = await capturePhase(\n \"screenshot\",\n async () =>\n await page.screenshot({\n animations: story.parameters?.pauseAnimationAtEnd ? \"allow\" : \"disabled\",\n ...(plan.fullPage ? { fullPage: true } : {}),\n ...(plan.clip ? { clip: plan.clip } : {}),\n }),\n );\n // oxlint-disable-next-line typescript/no-unnecessary-type-assertion -- Buffer narrowing\n return shot as unknown as Buffer;\n}\n"],"mappings":"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"}
|
package/dist/settle.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import "node:path";
|
|
2
2
|
import "node:url";
|
|
3
3
|
import.meta.url;
|
|
4
|
-
|
|
4
|
+
// src/settle.ts
|
|
5
5
|
/** How long to wait for the event when nothing else says (the play timeout raises it). */
|
|
6
6
|
const DEFAULT_SETTLE_TIMEOUT_MS = 5e3;
|
|
7
7
|
/** The wait for the event: the context's override, else the play timeout when plays run, else 5 s. */
|
|
@@ -14,9 +14,12 @@ async function installSettleRecorder(page) {
|
|
|
14
14
|
await page.addInitScript(inPageRecorder);
|
|
15
15
|
}
|
|
16
16
|
/** Wait until Storybook says the story is done (or `timeoutMs` passes); never throws. */
|
|
17
|
-
async function waitForSettled(page, timeoutMs) {
|
|
17
|
+
async function waitForSettled(page, timeoutMs, requireFinished = false) {
|
|
18
18
|
try {
|
|
19
|
-
const report = await page.evaluate(inPageWaitSettled, {
|
|
19
|
+
const report = await page.evaluate(inPageWaitSettled, {
|
|
20
|
+
timeoutMs,
|
|
21
|
+
...requireFinished ? { requireFinished } : {}
|
|
22
|
+
});
|
|
20
23
|
return isSettleReport(report) ? report : unsettled(false);
|
|
21
24
|
} catch {
|
|
22
25
|
return unsettled(false);
|
|
@@ -48,6 +51,7 @@ function inPageRecorder() {
|
|
|
48
51
|
const state = {
|
|
49
52
|
hooked: false,
|
|
50
53
|
settled: false,
|
|
54
|
+
finished: false,
|
|
51
55
|
phase: null,
|
|
52
56
|
unhandled: [],
|
|
53
57
|
hook: () => false
|
|
@@ -71,7 +75,11 @@ function inPageRecorder() {
|
|
|
71
75
|
if (typeof phase === "string") state.phase = phase;
|
|
72
76
|
},
|
|
73
77
|
storyRendered: markSettled,
|
|
74
|
-
storyFinished:
|
|
78
|
+
storyFinished: (payload) => {
|
|
79
|
+
if (payload?.status === "error" && state["playError"] === void 0) state["playError"] = "Storybook reported that the test failed";
|
|
80
|
+
state.finished = true;
|
|
81
|
+
markSettled();
|
|
82
|
+
},
|
|
75
83
|
storyThrewException: failRender,
|
|
76
84
|
storyErrored: failRender,
|
|
77
85
|
storyMissing: failRender,
|
|
@@ -101,7 +109,7 @@ function inPageRecorder() {
|
|
|
101
109
|
* Runs in the browser: resolves when the recorder saw a terminal event, the page has no channel
|
|
102
110
|
* to listen to, or `timeoutMs` passed. Self-contained for the same reason as the recorder.
|
|
103
111
|
*/
|
|
104
|
-
async function inPageWaitSettled({ timeoutMs }) {
|
|
112
|
+
async function inPageWaitSettled({ timeoutMs, requireFinished }) {
|
|
105
113
|
const state = globalThis["__STORYSHELF_SETTLE__"];
|
|
106
114
|
const started = performance.now();
|
|
107
115
|
const report = (settled, hooked) => ({
|
|
@@ -116,12 +124,12 @@ async function inPageWaitSettled({ timeoutMs }) {
|
|
|
116
124
|
if (!state) return report(false, false);
|
|
117
125
|
if (!state.hook() && globalThis.document?.readyState === "complete") return report(false, false);
|
|
118
126
|
while (performance.now() - started < timeoutMs) {
|
|
119
|
-
if (state.settled) return report(true, true);
|
|
127
|
+
if (state.settled && (!requireFinished || state.finished || state.renderError !== void 0)) return report(true, true);
|
|
120
128
|
await new Promise((resolve) => {
|
|
121
129
|
setTimeout(resolve, 10);
|
|
122
130
|
});
|
|
123
131
|
}
|
|
124
|
-
return report(state.settled, state.hook());
|
|
132
|
+
return report(state.settled && (!requireFinished || state.finished || state.renderError !== void 0), state.hook());
|
|
125
133
|
}
|
|
126
134
|
/** After the signal: let web fonts finish and two frames paint, so the screenshot sees the final DOM. */
|
|
127
135
|
async function awaitPaint(page) {
|
|
@@ -144,7 +152,6 @@ async function inPagePaint() {
|
|
|
144
152
|
});
|
|
145
153
|
await Promise.race([Promise.all([scope.document.fonts?.ready, frames]), cap]);
|
|
146
154
|
}
|
|
147
|
-
//#endregion
|
|
148
155
|
export { DEFAULT_SETTLE_TIMEOUT_MS, awaitPaint, inPagePaint, inPageRecorder, inPageWaitSettled, installSettleRecorder, settleTimeoutOf, unsettled, waitForSettled };
|
|
149
156
|
|
|
150
157
|
//# sourceMappingURL=settle.mjs.map
|
package/dist/settle.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"settle.mjs","names":[],"sources":["../src/settle.ts"],"sourcesContent":["/** Render-settled signalling: wait for Storybook's own \"render finished\" event, not a clock. */\n\nimport type { Page } from \"playwright-core\";\n\n/** What the page knew when the wait ended (all of it from Storybook's public channel events). */\nexport interface SettleReport {\n /** Storybook announced the story rendered, finished, errored or missing before the timeout. */\n settled: boolean;\n /** The preview's channel was found, so events could have arrived at all. */\n hooked: boolean;\n /** The last `storyRenderPhaseChanged` phase seen (`playing`, `errored`, `completed` ...). */\n phase: string | null;\n /** Milliseconds the wait took. */\n elapsedMs: number;\n /** The story failed to render (`storyThrewException`, `storyErrored`, `storyMissing`). */\n renderError?: string;\n /** The play function (or a `.test()` body) threw (`playFunctionThrewException`). */\n playError?: string;\n /** Errors Storybook reported after the play function (`unhandledErrorsWhilePlaying`). */\n unhandled?: string[];\n}\n\n/** How long to wait for the event when nothing else says (the play timeout raises it). */\nexport const DEFAULT_SETTLE_TIMEOUT_MS = 5000;\n\n/** The wait for the event: the context's override, else the play timeout when plays run, else 5 s. */\nexport function settleTimeoutOf(ctx: {\n settleTimeoutMs?: number | undefined;\n executePlay?: boolean | undefined;\n playTimeoutMs?: number | undefined;\n}): number {\n if (ctx.settleTimeoutMs !== undefined) return ctx.settleTimeoutMs;\n return ctx.executePlay\n ? Math.max(DEFAULT_SETTLE_TIMEOUT_MS, ctx.playTimeoutMs ?? 0)\n : DEFAULT_SETTLE_TIMEOUT_MS;\n}\n\n/** Register the recorder on a page, once; it runs before the page's own scripts on every load. */\nexport async function installSettleRecorder(page: Page): Promise<void> {\n await page.addInitScript(inPageRecorder);\n}\n\n/** Wait until Storybook says the story is done (or `timeoutMs` passes); never throws. */\nexport async function waitForSettled(page: Page, timeoutMs: number): Promise<SettleReport> {\n try {\n const report: unknown = await page.evaluate(inPageWaitSettled, { timeoutMs });\n return isSettleReport(report) ? report : unsettled(false);\n } catch {\n return unsettled(false);\n }\n}\n\n/** A report for a wait that saw no signal. */\nexport function unsettled(hooked: boolean): SettleReport {\n return { settled: false, hooked, phase: null, elapsedMs: 0 };\n}\n\nfunction isSettleReport(value: unknown): value is SettleReport {\n if (typeof value !== \"object\" || value === null) return false;\n const record = value as Record<string, unknown>;\n return typeof record[\"settled\"] === \"boolean\" && typeof record[\"hooked\"] === \"boolean\";\n}\n\n/**\n * Runs in the browser before the page's scripts, on every document. It watches the preview's\n * channel (`__STORYBOOK_ADDONS_CHANNEL__`, the public `Channel.on` API; nothing is patched) and\n * records the events StoryShelf needs. Playwright serializes only this function's source, so it\n * must not reference anything from this module; `settle.test.ts` evaluates it without bindings.\n */\n// oxlint-disable-next-line eslint/max-statements, eslint/max-lines-per-function -- must stay one self-contained function (see above)\nexport function inPageRecorder(): void {\n type Channel = { on: (event: string, cb: (payload: unknown) => void) => void };\n type State = Record<string, unknown> & {\n hooked: boolean;\n settled: boolean;\n phase: string | null;\n unhandled: string[];\n hook: () => boolean;\n };\n const scope = globalThis as unknown as Record<string, unknown>;\n if (scope[\"__STORYSHELF_SETTLE__\"]) return;\n const state: State = {\n hooked: false,\n settled: false,\n phase: null,\n unhandled: [],\n hook: () => false,\n };\n const text = {\n of(payload: unknown): string {\n if (typeof payload === \"string\") return payload;\n const record = (payload ?? {}) as Record<string, unknown>;\n const found = record[\"message\"] ?? record[\"description\"] ?? record[\"title\"];\n return typeof found === \"string\" ? found : \"unknown error\";\n },\n };\n const markSettled = (): void => {\n state.settled = true;\n };\n const failRender = (payload: unknown): void => {\n state[\"renderError\"] = text.of(payload);\n markSettled();\n };\n const handlers: Record<string, (payload: unknown) => void> = {\n storyRenderPhaseChanged: (payload) => {\n const phase = (payload as { newPhase?: unknown } | undefined)?.newPhase;\n if (typeof phase === \"string\") state.phase = phase;\n },\n storyRendered: markSettled,\n storyFinished: markSettled,\n storyThrewException: failRender,\n storyErrored: failRender,\n storyMissing: failRender,\n playFunctionThrewException: (payload) => {\n state[\"playError\"] = text.of(payload);\n },\n unhandledErrorsWhilePlaying: (payload) => {\n for (const error of Array.isArray(payload) ? payload : [payload]) {\n state.unhandled.push(text.of(error));\n }\n },\n };\n state.hook = (): boolean => {\n if (state.hooked) return true;\n const found = (scope[\"__STORYBOOK_ADDONS_CHANNEL__\"] ??\n (scope[\"__STORYBOOK_PREVIEW__\"] as { channel?: unknown } | undefined)?.channel) as\n | Channel\n | undefined;\n if (!found || typeof found.on !== \"function\") return false;\n for (const [event, handler] of Object.entries(handlers)) found.on(event, handler);\n state.hooked = true;\n return true;\n };\n scope[\"__STORYSHELF_SETTLE__\"] = state;\n // The channel is created by iframe.html's bundle, before the first story renders: catch it\n // within a few ms. Give up after a while (a page that is not Storybook never gets one).\n if (state.hook()) return;\n const started = Date.now();\n const timer = setInterval(() => {\n if (state.hook() || Date.now() - started > 30_000) clearInterval(timer);\n }, 2);\n}\n\n/** Arguments serialized into the page for {@link inPageWaitSettled}. */\nexport interface SettleArgs {\n timeoutMs: number;\n}\n\n/**\n * Runs in the browser: resolves when the recorder saw a terminal event, the page has no channel\n * to listen to, or `timeoutMs` passed. Self-contained for the same reason as the recorder.\n */\n// oxlint-disable-next-line eslint/max-statements, eslint/max-lines-per-function -- must stay one self-contained function (see above)\nexport async function inPageWaitSettled({ timeoutMs }: SettleArgs): Promise<SettleReport> {\n type State = {\n hooked: boolean;\n settled: boolean;\n phase: string | null;\n unhandled: string[];\n hook: () => boolean;\n renderError?: string;\n playError?: string;\n };\n const state = (globalThis as unknown as Record<string, State | undefined>)[\n \"__STORYSHELF_SETTLE__\"\n ];\n const started = performance.now();\n const report = (settled: boolean, hooked: boolean): SettleReport => ({\n settled,\n hooked,\n phase: state?.phase ?? null,\n elapsedMs: Math.round(performance.now() - started),\n ...(state?.renderError === undefined ? {} : { renderError: state.renderError }),\n ...(state?.playError === undefined ? {} : { playError: state.playError }),\n ...(state && state.unhandled.length > 0 ? { unhandled: [...state.unhandled] } : {}),\n });\n if (!state) return report(false, false);\n // After `load` every script has run: no channel by now means there will never be one.\n if (!state.hook() && globalThis.document?.readyState === \"complete\") return report(false, false);\n while (performance.now() - started < timeoutMs) {\n if (state.settled) return report(true, true);\n // oxlint-disable-next-line eslint/no-await-in-loop -- polling the recorded state\n await new Promise<void>((resolve) => {\n setTimeout(resolve, 10);\n });\n }\n return report(state.settled, state.hook());\n}\n\n/** After the signal: let web fonts finish and two frames paint, so the screenshot sees the final DOM. */\nexport async function awaitPaint(page: Page): Promise<void> {\n try {\n await page.evaluate(inPagePaint);\n } catch {\n // best-effort: the screenshot still follows\n }\n}\n\n/** Runs in the browser; bounded so a page that never paints (hidden tab) cannot hang the capture. */\nexport async function inPagePaint(): Promise<void> {\n const scope = globalThis as unknown as {\n document: { fonts?: { ready: Promise<unknown> } };\n requestAnimationFrame: (cb: () => void) => void;\n };\n const frames = new Promise<void>((resolve) => {\n scope.requestAnimationFrame(() => {\n scope.requestAnimationFrame(() => {\n resolve();\n });\n });\n });\n const cap = new Promise<void>((resolve) => {\n setTimeout(resolve, 250);\n });\n await Promise.race([Promise.all([scope.document.fonts?.ready, frames]), cap]);\n}\n"],"mappings":";;;;;AAuBA,MAAa,4BAA4B;;AAGzC,SAAgB,gBAAgB,KAIrB;CACT,IAAI,IAAI,oBAAoB,KAAA,GAAW,OAAO,IAAI;CAClD,OAAO,IAAI,cACP,KAAK,IAAI,2BAA2B,IAAI,iBAAiB,CAAC,IAC1D;AACN;;AAGA,eAAsB,sBAAsB,MAA2B;CACrE,MAAM,KAAK,cAAc,cAAc;AACzC;;AAGA,eAAsB,eAAe,MAAY,WAA0C;CACzF,IAAI;EACF,MAAM,SAAkB,MAAM,KAAK,SAAS,mBAAmB,EAAE,UAAU,CAAC;EAC5E,OAAO,eAAe,MAAM,IAAI,SAAS,UAAU,KAAK;CAC1D,QAAQ;EACN,OAAO,UAAU,KAAK;CACxB;AACF;;AAGA,SAAgB,UAAU,QAA+B;CACvD,OAAO;EAAE,SAAS;EAAO;EAAQ,OAAO;EAAM,WAAW;CAAE;AAC7D;AAEA,SAAS,eAAe,OAAuC;CAC7D,IAAI,OAAO,UAAU,YAAY,UAAU,MAAM,OAAO;CACxD,MAAM,SAAS;CACf,OAAO,OAAO,OAAO,eAAe,aAAa,OAAO,OAAO,cAAc;AAC/E;;;;;;;AASA,SAAgB,iBAAuB;CASrC,MAAM,QAAQ;CACd,IAAI,MAAM,0BAA0B;CACpC,MAAM,QAAe;EACnB,QAAQ;EACR,SAAS;EACT,OAAO;EACP,WAAW,CAAC;EACZ,YAAY;CACd;CACA,MAAM,OAAO,EACX,GAAG,SAA0B;EAC3B,IAAI,OAAO,YAAY,UAAU,OAAO;EACxC,MAAM,SAAU,WAAW,CAAC;EAC5B,MAAM,QAAQ,OAAO,cAAc,OAAO,kBAAkB,OAAO;EACnE,OAAO,OAAO,UAAU,WAAW,QAAQ;CAC7C,EACF;CACA,MAAM,oBAA0B;EAC9B,MAAM,UAAU;CAClB;CACA,MAAM,cAAc,YAA2B;EAC7C,MAAM,iBAAiB,KAAK,GAAG,OAAO;EACtC,YAAY;CACd;CACA,MAAM,WAAuD;EAC3D,0BAA0B,YAAY;GACpC,MAAM,QAAS,SAAgD;GAC/D,IAAI,OAAO,UAAU,UAAU,MAAM,QAAQ;EAC/C;EACA,eAAe;EACf,eAAe;EACf,qBAAqB;EACrB,cAAc;EACd,cAAc;EACd,6BAA6B,YAAY;GACvC,MAAM,eAAe,KAAK,GAAG,OAAO;EACtC;EACA,8BAA8B,YAAY;GACxC,KAAK,MAAM,SAAS,MAAM,QAAQ,OAAO,IAAI,UAAU,CAAC,OAAO,GAC7D,MAAM,UAAU,KAAK,KAAK,GAAG,KAAK,CAAC;EAEvC;CACF;CACA,MAAM,aAAsB;EAC1B,IAAI,MAAM,QAAQ,OAAO;EACzB,MAAM,QAAS,MAAM,mCAClB,MAAM,wBAAwB,EAAwC;EAGzE,IAAI,CAAC,SAAS,OAAO,MAAM,OAAO,YAAY,OAAO;EACrD,KAAK,MAAM,CAAC,OAAO,YAAY,OAAO,QAAQ,QAAQ,GAAG,MAAM,GAAG,OAAO,OAAO;EAChF,MAAM,SAAS;EACf,OAAO;CACT;CACA,MAAM,2BAA2B;CAGjC,IAAI,MAAM,KAAK,GAAG;CAClB,MAAM,UAAU,KAAK,IAAI;CACzB,MAAM,QAAQ,kBAAkB;EAC9B,IAAI,MAAM,KAAK,KAAK,KAAK,IAAI,IAAI,UAAU,KAAQ,cAAc,KAAK;CACxE,GAAG,CAAC;AACN;;;;;AAYA,eAAsB,kBAAkB,EAAE,aAAgD;CAUxF,MAAM,QAAS,WACb;CAEF,MAAM,UAAU,YAAY,IAAI;CAChC,MAAM,UAAU,SAAkB,YAAmC;EACnE;EACA;EACA,OAAO,OAAO,SAAS;EACvB,WAAW,KAAK,MAAM,YAAY,IAAI,IAAI,OAAO;EACjD,GAAI,OAAO,gBAAgB,KAAA,IAAY,CAAC,IAAI,EAAE,aAAa,MAAM,YAAY;EAC7E,GAAI,OAAO,cAAc,KAAA,IAAY,CAAC,IAAI,EAAE,WAAW,MAAM,UAAU;EACvE,GAAI,SAAS,MAAM,UAAU,SAAS,IAAI,EAAE,WAAW,CAAC,GAAG,MAAM,SAAS,EAAE,IAAI,CAAC;CACnF;CACA,IAAI,CAAC,OAAO,OAAO,OAAO,OAAO,KAAK;CAEtC,IAAI,CAAC,MAAM,KAAK,KAAK,WAAW,UAAU,eAAe,YAAY,OAAO,OAAO,OAAO,KAAK;CAC/F,OAAO,YAAY,IAAI,IAAI,UAAU,WAAW;EAC9C,IAAI,MAAM,SAAS,OAAO,OAAO,MAAM,IAAI;EAE3C,MAAM,IAAI,SAAe,YAAY;GACnC,WAAW,SAAS,EAAE;EACxB,CAAC;CACH;CACA,OAAO,OAAO,MAAM,SAAS,MAAM,KAAK,CAAC;AAC3C;;AAGA,eAAsB,WAAW,MAA2B;CAC1D,IAAI;EACF,MAAM,KAAK,SAAS,WAAW;CACjC,QAAQ,CAER;AACF;;AAGA,eAAsB,cAA6B;CACjD,MAAM,QAAQ;CAId,MAAM,SAAS,IAAI,SAAe,YAAY;EAC5C,MAAM,4BAA4B;GAChC,MAAM,4BAA4B;IAChC,QAAQ;GACV,CAAC;EACH,CAAC;CACH,CAAC;CACD,MAAM,MAAM,IAAI,SAAe,YAAY;EACzC,WAAW,SAAS,GAAG;CACzB,CAAC;CACD,MAAM,QAAQ,KAAK,CAAC,QAAQ,IAAI,CAAC,MAAM,SAAS,OAAO,OAAO,MAAM,CAAC,GAAG,GAAG,CAAC;AAC9E"}
|
|
1
|
+
{"version":3,"file":"settle.mjs","names":[],"sources":["../src/settle.ts"],"sourcesContent":["/** Render-settled signalling: wait for Storybook's own \"render finished\" event, not a clock. */\n\nimport type { Page } from \"playwright-core\";\n\n/** What the page knew when the wait ended (all of it from Storybook's public channel events). */\nexport interface SettleReport {\n /** Storybook announced the story rendered, finished, errored or missing before the timeout. */\n settled: boolean;\n /** The preview's channel was found, so events could have arrived at all. */\n hooked: boolean;\n /** The last `storyRenderPhaseChanged` phase seen (`playing`, `errored`, `completed` ...). */\n phase: string | null;\n /** Milliseconds the wait took. */\n elapsedMs: number;\n /** The story failed to render (`storyThrewException`, `storyErrored`, `storyMissing`). */\n renderError?: string;\n /** The play function (or a `.test()` body) threw (`playFunctionThrewException`). */\n playError?: string;\n /** Errors Storybook reported after the play function (`unhandledErrorsWhilePlaying`). */\n unhandled?: string[];\n}\n\n/** How long to wait for the event when nothing else says (the play timeout raises it). */\nexport const DEFAULT_SETTLE_TIMEOUT_MS = 5000;\n\n/** The wait for the event: the context's override, else the play timeout when plays run, else 5 s. */\nexport function settleTimeoutOf(ctx: {\n settleTimeoutMs?: number | undefined;\n executePlay?: boolean | undefined;\n playTimeoutMs?: number | undefined;\n}): number {\n if (ctx.settleTimeoutMs !== undefined) return ctx.settleTimeoutMs;\n return ctx.executePlay\n ? Math.max(DEFAULT_SETTLE_TIMEOUT_MS, ctx.playTimeoutMs ?? 0)\n : DEFAULT_SETTLE_TIMEOUT_MS;\n}\n\n/** Register the recorder on a page, once; it runs before the page's own scripts on every load. */\nexport async function installSettleRecorder(page: Page): Promise<void> {\n await page.addInitScript(inPageRecorder);\n}\n\n/** Wait until Storybook says the story is done (or `timeoutMs` passes); never throws. */\nexport async function waitForSettled(\n page: Page,\n timeoutMs: number,\n requireFinished = false,\n): Promise<SettleReport> {\n try {\n const report: unknown = await page.evaluate(inPageWaitSettled, {\n timeoutMs,\n ...(requireFinished ? { requireFinished } : {}),\n });\n return isSettleReport(report) ? report : unsettled(false);\n } catch {\n return unsettled(false);\n }\n}\n\n/** A report for a wait that saw no signal. */\nexport function unsettled(hooked: boolean): SettleReport {\n return { settled: false, hooked, phase: null, elapsedMs: 0 };\n}\n\nfunction isSettleReport(value: unknown): value is SettleReport {\n if (typeof value !== \"object\" || value === null) return false;\n const record = value as Record<string, unknown>;\n return typeof record[\"settled\"] === \"boolean\" && typeof record[\"hooked\"] === \"boolean\";\n}\n\n/**\n * Runs in the browser before the page's scripts, on every document. It watches the preview's\n * channel (`__STORYBOOK_ADDONS_CHANNEL__`, the public `Channel.on` API; nothing is patched) and\n * records the events StoryShelf needs. Playwright serializes only this function's source, so it\n * must not reference anything from this module; `settle.test.ts` evaluates it without bindings.\n */\n// oxlint-disable-next-line eslint/max-statements, eslint/max-lines-per-function -- must stay one self-contained function (see above)\nexport function inPageRecorder(): void {\n type Channel = { on: (event: string, cb: (payload: unknown) => void) => void };\n type State = Record<string, unknown> & {\n hooked: boolean;\n settled: boolean;\n finished: boolean;\n phase: string | null;\n unhandled: string[];\n hook: () => boolean;\n };\n const scope = globalThis as unknown as Record<string, unknown>;\n if (scope[\"__STORYSHELF_SETTLE__\"]) return;\n const state: State = {\n hooked: false,\n settled: false,\n finished: false,\n phase: null,\n unhandled: [],\n hook: () => false,\n };\n const text = {\n of(payload: unknown): string {\n if (typeof payload === \"string\") return payload;\n const record = (payload ?? {}) as Record<string, unknown>;\n const found = record[\"message\"] ?? record[\"description\"] ?? record[\"title\"];\n return typeof found === \"string\" ? found : \"unknown error\";\n },\n };\n const markSettled = (): void => {\n state.settled = true;\n };\n const failRender = (payload: unknown): void => {\n state[\"renderError\"] = text.of(payload);\n markSettled();\n };\n const handlers: Record<string, (payload: unknown) => void> = {\n storyRenderPhaseChanged: (payload) => {\n const phase = (payload as { newPhase?: unknown } | undefined)?.newPhase;\n if (typeof phase === \"string\") state.phase = phase;\n },\n storyRendered: markSettled,\n storyFinished: (payload) => {\n const status = (payload as { status?: unknown } | undefined)?.status;\n if (status === \"error\" && state[\"playError\"] === undefined) {\n state[\"playError\"] = \"Storybook reported that the test failed\";\n }\n state.finished = true;\n markSettled();\n },\n storyThrewException: failRender,\n storyErrored: failRender,\n storyMissing: failRender,\n playFunctionThrewException: (payload) => {\n state[\"playError\"] = text.of(payload);\n },\n unhandledErrorsWhilePlaying: (payload) => {\n for (const error of Array.isArray(payload) ? payload : [payload]) {\n state.unhandled.push(text.of(error));\n }\n },\n };\n state.hook = (): boolean => {\n if (state.hooked) return true;\n const found = (scope[\"__STORYBOOK_ADDONS_CHANNEL__\"] ??\n (scope[\"__STORYBOOK_PREVIEW__\"] as { channel?: unknown } | undefined)?.channel) as\n | Channel\n | undefined;\n if (!found || typeof found.on !== \"function\") return false;\n for (const [event, handler] of Object.entries(handlers)) found.on(event, handler);\n state.hooked = true;\n return true;\n };\n scope[\"__STORYSHELF_SETTLE__\"] = state;\n // The channel is created by iframe.html's bundle, before the first story renders: catch it\n // within a few ms. Give up after a while (a page that is not Storybook never gets one).\n if (state.hook()) return;\n const started = Date.now();\n const timer = setInterval(() => {\n if (state.hook() || Date.now() - started > 30_000) clearInterval(timer);\n }, 2);\n}\n\n/** Arguments serialized into the page for {@link inPageWaitSettled}. */\nexport interface SettleArgs {\n timeoutMs: number;\n requireFinished?: boolean;\n}\n\n/**\n * Runs in the browser: resolves when the recorder saw a terminal event, the page has no channel\n * to listen to, or `timeoutMs` passed. Self-contained for the same reason as the recorder.\n */\n// oxlint-disable-next-line eslint/max-statements, eslint/max-lines-per-function -- must stay one self-contained function (see above)\nexport async function inPageWaitSettled({\n timeoutMs,\n requireFinished,\n}: SettleArgs): Promise<SettleReport> {\n type State = {\n hooked: boolean;\n settled: boolean;\n finished: boolean;\n phase: string | null;\n unhandled: string[];\n hook: () => boolean;\n renderError?: string;\n playError?: string;\n };\n const state = (globalThis as unknown as Record<string, State | undefined>)[\n \"__STORYSHELF_SETTLE__\"\n ];\n const started = performance.now();\n const report = (settled: boolean, hooked: boolean): SettleReport => ({\n settled,\n hooked,\n phase: state?.phase ?? null,\n elapsedMs: Math.round(performance.now() - started),\n ...(state?.renderError === undefined ? {} : { renderError: state.renderError }),\n ...(state?.playError === undefined ? {} : { playError: state.playError }),\n ...(state && state.unhandled.length > 0 ? { unhandled: [...state.unhandled] } : {}),\n });\n if (!state) return report(false, false);\n // After `load` every script has run: no channel by now means there will never be one.\n if (!state.hook() && globalThis.document?.readyState === \"complete\") return report(false, false);\n while (performance.now() - started < timeoutMs) {\n if (state.settled && (!requireFinished || state.finished || state.renderError !== undefined)) {\n return report(true, true);\n }\n // oxlint-disable-next-line eslint/no-await-in-loop -- polling the recorded state\n await new Promise<void>((resolve) => {\n setTimeout(resolve, 10);\n });\n }\n return report(\n state.settled && (!requireFinished || state.finished || state.renderError !== undefined),\n state.hook(),\n );\n}\n\n/** After the signal: let web fonts finish and two frames paint, so the screenshot sees the final DOM. */\nexport async function awaitPaint(page: Page): Promise<void> {\n try {\n await page.evaluate(inPagePaint);\n } catch {\n // best-effort: the screenshot still follows\n }\n}\n\n/** Runs in the browser; bounded so a page that never paints (hidden tab) cannot hang the capture. */\nexport async function inPagePaint(): Promise<void> {\n const scope = globalThis as unknown as {\n document: { fonts?: { ready: Promise<unknown> } };\n requestAnimationFrame: (cb: () => void) => void;\n };\n const frames = new Promise<void>((resolve) => {\n scope.requestAnimationFrame(() => {\n scope.requestAnimationFrame(() => {\n resolve();\n });\n });\n });\n const cap = new Promise<void>((resolve) => {\n setTimeout(resolve, 250);\n });\n await Promise.race([Promise.all([scope.document.fonts?.ready, frames]), cap]);\n}\n"],"mappings":"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"}
|
package/dist/static-server.mjs
CHANGED
|
@@ -1,24 +1,30 @@
|
|
|
1
|
-
import "node:path";
|
|
1
|
+
import { join, normalize } from "node:path";
|
|
2
2
|
import "node:url";
|
|
3
3
|
import.meta.url;
|
|
4
4
|
import { isPathSafe, mimeFor } from "@storyshelf/core/utils";
|
|
5
5
|
import { createReadStream } from "node:fs";
|
|
6
|
-
import {
|
|
7
|
-
import { stat } from "node:fs/promises";
|
|
6
|
+
import { readFile, stat } from "node:fs/promises";
|
|
8
7
|
import { createServer } from "node:http";
|
|
9
|
-
|
|
8
|
+
import { promisify } from "node:util";
|
|
9
|
+
import { gzip } from "node:zlib";
|
|
10
|
+
// src/static-server.ts
|
|
11
|
+
const compress = promisify(gzip);
|
|
12
|
+
const MAX_COMPRESS_FILE = 524288;
|
|
13
|
+
const MAX_COMPRESS_CACHE = 4194304;
|
|
14
|
+
const MAX_UNCOMPRESSIBLE_FILES = 128;
|
|
10
15
|
/**
|
|
11
16
|
* Serve a built Storybook directory over HTTP on 127.0.0.1 for capture.
|
|
12
17
|
*
|
|
13
18
|
* @param rootDir - Directory containing the built Storybook (`index.html` entry).
|
|
14
19
|
* @returns The server URL and a `close` function to stop it.
|
|
15
20
|
*/
|
|
16
|
-
async function createStaticServer(rootDir) {
|
|
21
|
+
async function createStaticServer(rootDir, options = {}) {
|
|
17
22
|
const root = normalize(rootDir);
|
|
18
23
|
const info = await stat(root).catch(() => null);
|
|
19
24
|
if (!info || !info.isDirectory()) throw new Error(`Storybook directory not found: ${rootDir}. Upload the built Storybook before capturing.`);
|
|
25
|
+
const cache = new CompressionCache();
|
|
20
26
|
const server = createServer((req, res) => {
|
|
21
|
-
handleRequest(root, req.
|
|
27
|
+
handleRequest(root, req, res, options.compression === true ? cache : void 0).catch(() => {
|
|
22
28
|
sendNotFound(res);
|
|
23
29
|
});
|
|
24
30
|
});
|
|
@@ -42,15 +48,72 @@ async function resolveFile(root, urlPath) {
|
|
|
42
48
|
if (info.isDirectory()) return resolveFile(root, `${clean}/index.html`);
|
|
43
49
|
return info.isFile() ? target : null;
|
|
44
50
|
}
|
|
45
|
-
async function handleRequest(root,
|
|
46
|
-
const file = await resolveFile(root, url);
|
|
51
|
+
async function handleRequest(root, req, res, cache) {
|
|
52
|
+
const file = await resolveFile(root, req.url ?? "/");
|
|
47
53
|
if (!file) {
|
|
48
54
|
sendNotFound(res);
|
|
49
55
|
return;
|
|
50
56
|
}
|
|
51
|
-
|
|
57
|
+
const zipped = cache && acceptsGzip(req.headers["accept-encoding"]) && isCompressible(mimeFor(file)) ? await cache.get(file) : void 0;
|
|
58
|
+
res.writeHead(200, {
|
|
59
|
+
"content-type": mimeFor(file),
|
|
60
|
+
"cache-control": "public, max-age=3600",
|
|
61
|
+
...cache ? { vary: "Accept-Encoding" } : {},
|
|
62
|
+
...zipped ? {
|
|
63
|
+
"content-encoding": "gzip",
|
|
64
|
+
"content-length": zipped.length
|
|
65
|
+
} : {}
|
|
66
|
+
});
|
|
67
|
+
if (zipped) {
|
|
68
|
+
res.end(zipped);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
52
71
|
createReadStream(file).pipe(res);
|
|
53
72
|
}
|
|
73
|
+
function acceptsGzip(header) {
|
|
74
|
+
if (!header) return false;
|
|
75
|
+
const values = header.split(",").map((part) => part.trim().split(";"));
|
|
76
|
+
const entry = values.find(([name]) => name?.trim().toLowerCase() === "gzip") ?? values.find(([name]) => name?.trim() === "*");
|
|
77
|
+
if (!entry) return false;
|
|
78
|
+
const quality = entry.slice(1).find((part) => /^\s*q\s*=/iu.test(part));
|
|
79
|
+
if (quality === void 0) return true;
|
|
80
|
+
const value = Number(quality.split("=")[1]);
|
|
81
|
+
return Number.isFinite(value) && value > 0 && value <= 1;
|
|
82
|
+
}
|
|
83
|
+
function isCompressible(mime) {
|
|
84
|
+
return /^(?:text\/|application\/(?:javascript|json|xml|wasm)|image\/svg\+xml)/iu.test(mime);
|
|
85
|
+
}
|
|
86
|
+
/** Budget reservations include in-flight reads so concurrent requests stay bounded. */
|
|
87
|
+
var CompressionCache = class {
|
|
88
|
+
entries = /* @__PURE__ */ new Map();
|
|
89
|
+
uncompressible = /* @__PURE__ */ new Set();
|
|
90
|
+
reserved = 0;
|
|
91
|
+
async get(file) {
|
|
92
|
+
if (this.uncompressible.has(file)) return void 0;
|
|
93
|
+
const existing = this.entries.get(file);
|
|
94
|
+
if (existing) return await existing;
|
|
95
|
+
return await this.reserve(file, (await stat(file)).size);
|
|
96
|
+
}
|
|
97
|
+
async reserve(file, size) {
|
|
98
|
+
if (this.uncompressible.has(file)) return void 0;
|
|
99
|
+
const pending = this.entries.get(file);
|
|
100
|
+
if (pending) return await pending;
|
|
101
|
+
if (size > MAX_COMPRESS_FILE || this.reserved + size > MAX_COMPRESS_CACHE) return void 0;
|
|
102
|
+
this.reserved += size;
|
|
103
|
+
const result = this.encode(file, size);
|
|
104
|
+
this.entries.set(file, result);
|
|
105
|
+
return await result;
|
|
106
|
+
}
|
|
107
|
+
async encode(file, size) {
|
|
108
|
+
try {
|
|
109
|
+
const compressed = await compress(await readFile(file));
|
|
110
|
+
if (compressed.length < size) return compressed;
|
|
111
|
+
if (this.uncompressible.size < MAX_UNCOMPRESSIBLE_FILES) this.uncompressible.add(file);
|
|
112
|
+
} catch {}
|
|
113
|
+
this.reserved -= size;
|
|
114
|
+
this.entries.delete(file);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
54
117
|
async function listen(server) {
|
|
55
118
|
return await new Promise((resolve, reject) => {
|
|
56
119
|
server.once("error", reject);
|
|
@@ -68,7 +131,6 @@ async function closeServer(server) {
|
|
|
68
131
|
});
|
|
69
132
|
});
|
|
70
133
|
}
|
|
71
|
-
//#endregion
|
|
72
134
|
export { createStaticServer };
|
|
73
135
|
|
|
74
136
|
//# sourceMappingURL=static-server.mjs.map
|