@scrymore/scry-sbcov 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -11
- package/dist/cli/config.d.ts.map +1 -1
- package/dist/cli/config.js +32 -1
- package/dist/cli/config.js.map +1 -1
- package/dist/cli/index.js +11 -1
- package/dist/cli/index.js.map +1 -1
- package/dist/core/analyzer.d.ts.map +1 -1
- package/dist/core/analyzer.js +4 -0
- package/dist/core/analyzer.js.map +1 -1
- package/dist/core/capture-summary.d.ts +9 -1
- package/dist/core/capture-summary.d.ts.map +1 -1
- package/dist/core/capture-summary.js +29 -0
- package/dist/core/capture-summary.js.map +1 -1
- package/dist/core/capture-target.d.ts +12 -6
- package/dist/core/capture-target.d.ts.map +1 -1
- package/dist/core/capture-target.js +15 -6
- package/dist/core/capture-target.js.map +1 -1
- package/dist/core/execution-defaults.d.ts +19 -0
- package/dist/core/execution-defaults.d.ts.map +1 -0
- package/dist/core/execution-defaults.js +19 -0
- package/dist/core/execution-defaults.js.map +1 -0
- package/dist/core/story-executor.d.ts +27 -1
- package/dist/core/story-executor.d.ts.map +1 -1
- package/dist/core/story-executor.js +349 -23
- package/dist/core/story-executor.js.map +1 -1
- package/dist/core/zip-generator.d.ts.map +1 -1
- package/dist/core/zip-generator.js +4 -0
- package/dist/core/zip-generator.js.map +1 -1
- package/dist/types/index.d.ts +63 -4
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -19,7 +19,8 @@ function nonEmpty(b) {
|
|
|
19
19
|
* 1. The innermost `[data-scry-root]` — the marker with the smallest area. A
|
|
20
20
|
* global decorator may mark the phone frame and a component story may mark
|
|
21
21
|
* its own wrapper; the component wins.
|
|
22
|
-
* 2. Otherwise `#storybook-root`'s first child,
|
|
22
|
+
* 2. Otherwise `#storybook-root`'s first child (or, for a portal story, the element it
|
|
23
|
+
* rendered into <body>), but only when it is actually
|
|
23
24
|
* smaller than the viewport in at least one dimension — a full-bleed layout
|
|
24
25
|
* gains nothing from a crop and would only lose fixed-position chrome.
|
|
25
26
|
* 3. Otherwise null: capture the viewport.
|
|
@@ -30,7 +31,7 @@ export function chooseCaptureBox(candidates, viewport) {
|
|
|
30
31
|
const innermost = markers.reduce((best, c) => (c.box.width * c.box.height < best.box.width * best.box.height ? c : best));
|
|
31
32
|
return { kind: innermost.kind, index: innermost.index, box: innermost.box };
|
|
32
33
|
}
|
|
33
|
-
const child = candidates.find((c) => c.kind === 'storybook-root-child' && nonEmpty(c.box));
|
|
34
|
+
const child = candidates.find((c) => (c.kind === 'storybook-root-child' || c.kind === 'portal') && nonEmpty(c.box));
|
|
34
35
|
if (child && (child.box.width < viewport.width - EPSILON || child.box.height < viewport.height - EPSILON)) {
|
|
35
36
|
return { kind: child.kind, index: child.index, box: child.box };
|
|
36
37
|
}
|
|
@@ -44,12 +45,20 @@ export function imageSizeFor(box, dpr) {
|
|
|
44
45
|
* The `capture` block written per story into metadata.json.
|
|
45
46
|
*
|
|
46
47
|
* `scale` repeats `dpr` under the name the diff service reads; `root_found` is
|
|
47
|
-
* true only when the image was cropped to a root
|
|
48
|
+
* true only when the image was cropped to an element (a root, or a portal story's
|
|
49
|
+
* dialog; false for viewport
|
|
48
50
|
* mode, and for root mode that found nothing to crop to and fell back to the
|
|
49
|
-
* viewport).
|
|
51
|
+
* viewport). `rendered_into` (since 0.6) says whether the story showed itself in
|
|
52
|
+
* `#storybook-root` or only in a portal.
|
|
50
53
|
*/
|
|
51
|
-
export function buildCaptureInfo(opts, choice, sbcovVersion) {
|
|
52
|
-
const base = {
|
|
54
|
+
export function buildCaptureInfo(opts, choice, sbcovVersion, renderedInto) {
|
|
55
|
+
const base = {
|
|
56
|
+
viewport: opts.viewport,
|
|
57
|
+
dpr: opts.dpr,
|
|
58
|
+
scale: opts.dpr,
|
|
59
|
+
sbcov_version: sbcovVersion,
|
|
60
|
+
...(renderedInto ? { rendered_into: renderedInto } : {}),
|
|
61
|
+
};
|
|
53
62
|
if (opts.mode === 'root' && choice) {
|
|
54
63
|
return { mode: 'root', ...base, target: choice.kind, root_found: true, box: choice.box, imageSize: imageSizeFor(choice.box, opts.dpr) };
|
|
55
64
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"capture-target.js","sourceRoot":"","sources":["../../src/core/capture-target.ts"],"names":[],"mappings":"AAUA;;;;;GAKG;AACH,MAAM,CAAC,MAAM,eAAe,GAAoE;IAC9F,IAAI,EAAE,MAAM;IACZ,QAAQ,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE;IACtC,KAAK,EAAE,CAAC;CACT,CAAC;
|
|
1
|
+
{"version":3,"file":"capture-target.js","sourceRoot":"","sources":["../../src/core/capture-target.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Story execution defaults (since 0.6, ISSUES.md #54). Kept apart from the executor so
|
|
3
|
+
* the config loader can use them without loading Playwright.
|
|
4
|
+
*/
|
|
5
|
+
/** Stories rendered at once, each in a fresh browser context. 1 = one at a time. */
|
|
6
|
+
export declare const DEFAULT_CONCURRENCY = 4;
|
|
7
|
+
/** Upper bound for --concurrency (the deployer accepts the same range). Each worker is a browser page at a time. */
|
|
8
|
+
export declare const MAX_CONCURRENCY = 32;
|
|
9
|
+
/**
|
|
10
|
+
* How long a story may take to show something after navigation. The slowest of 25,052
|
|
11
|
+
* passing stories in September 2026 took 2.5 s; navigation and play functions keep
|
|
12
|
+
* their own 15 s bound (`executionTimeout`).
|
|
13
|
+
*/
|
|
14
|
+
export declare const DEFAULT_RENDER_TIMEOUT_MS = 5000;
|
|
15
|
+
/** Navigation and play-function bound (`executionTimeout`), unchanged since 0.1. */
|
|
16
|
+
export declare const DEFAULT_EXECUTION_TIMEOUT_MS = 15000;
|
|
17
|
+
/** Failure reasons that mean "waited out a bound": counted as timeouts in the duration line and the timing block. */
|
|
18
|
+
export declare const TIMEOUT_REASONS: ReadonlySet<string>;
|
|
19
|
+
//# sourceMappingURL=execution-defaults.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"execution-defaults.d.ts","sourceRoot":"","sources":["../../src/core/execution-defaults.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,oFAAoF;AACpF,eAAO,MAAM,mBAAmB,IAAI,CAAC;AAErC,oHAAoH;AACpH,eAAO,MAAM,eAAe,KAAK,CAAC;AAElC;;;;GAIG;AACH,eAAO,MAAM,yBAAyB,OAAO,CAAC;AAE9C,oFAAoF;AACpF,eAAO,MAAM,4BAA4B,QAAQ,CAAC;AAElD,qHAAqH;AACrH,eAAO,MAAM,eAAe,EAAE,WAAW,CAAC,MAAM,CAA0C,CAAC"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Story execution defaults (since 0.6, ISSUES.md #54). Kept apart from the executor so
|
|
3
|
+
* the config loader can use them without loading Playwright.
|
|
4
|
+
*/
|
|
5
|
+
/** Stories rendered at once, each in a fresh browser context. 1 = one at a time. */
|
|
6
|
+
export const DEFAULT_CONCURRENCY = 4;
|
|
7
|
+
/** Upper bound for --concurrency (the deployer accepts the same range). Each worker is a browser page at a time. */
|
|
8
|
+
export const MAX_CONCURRENCY = 32;
|
|
9
|
+
/**
|
|
10
|
+
* How long a story may take to show something after navigation. The slowest of 25,052
|
|
11
|
+
* passing stories in September 2026 took 2.5 s; navigation and play functions keep
|
|
12
|
+
* their own 15 s bound (`executionTimeout`).
|
|
13
|
+
*/
|
|
14
|
+
export const DEFAULT_RENDER_TIMEOUT_MS = 5000;
|
|
15
|
+
/** Navigation and play-function bound (`executionTimeout`), unchanged since 0.1. */
|
|
16
|
+
export const DEFAULT_EXECUTION_TIMEOUT_MS = 15000;
|
|
17
|
+
/** Failure reasons that mean "waited out a bound": counted as timeouts in the duration line and the timing block. */
|
|
18
|
+
export const TIMEOUT_REASONS = new Set(['timeout', 'render_timeout']);
|
|
19
|
+
//# sourceMappingURL=execution-defaults.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"execution-defaults.js","sourceRoot":"","sources":["../../src/core/execution-defaults.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,oFAAoF;AACpF,MAAM,CAAC,MAAM,mBAAmB,GAAG,CAAC,CAAC;AAErC,oHAAoH;AACpH,MAAM,CAAC,MAAM,eAAe,GAAG,EAAE,CAAC;AAElC;;;;GAIG;AACH,MAAM,CAAC,MAAM,yBAAyB,GAAG,IAAI,CAAC;AAE9C,oFAAoF;AACpF,MAAM,CAAC,MAAM,4BAA4B,GAAG,KAAK,CAAC;AAElD,qHAAqH;AACrH,MAAM,CAAC,MAAM,eAAe,GAAwB,IAAI,GAAG,CAAC,CAAC,SAAS,EAAE,gBAAgB,CAAC,CAAC,CAAC"}
|
|
@@ -2,11 +2,33 @@
|
|
|
2
2
|
* Story Executor
|
|
3
3
|
* Executes stories in a real browser to detect runtime failures using Playwright
|
|
4
4
|
*/
|
|
5
|
-
import type { ParsedStoryFile, ExecutionConfig, ExecutionResults, RegressionAnalysis } from '../types/index.js';
|
|
5
|
+
import type { ParsedStoryFile, ExecutionConfig, StoryExecutionResult, ExecutionResults, RegressionAnalysis, ExecutionTiming } from '../types/index.js';
|
|
6
|
+
export { DEFAULT_CONCURRENCY, DEFAULT_RENDER_TIMEOUT_MS } from './execution-defaults.js';
|
|
6
7
|
/**
|
|
7
8
|
* Execute stories and capture failures
|
|
8
9
|
*/
|
|
9
10
|
export declare function executeStories(storyFiles: ParsedStoryFile[], config: ExecutionConfig, verbose?: boolean): Promise<ExecutionResults>;
|
|
11
|
+
/**
|
|
12
|
+
* Run `run` over `items` with `workers` workers pulling the next index. Results are
|
|
13
|
+
* returned in item order. A throw from `run` never escapes: `onCrash` turns it into that
|
|
14
|
+
* item's result (so one crashed page costs one story, and the other workers carry on).
|
|
15
|
+
* `onResult` is called as each item finishes.
|
|
16
|
+
*/
|
|
17
|
+
export declare function runStoryPool<T, R>(items: readonly T[], workers: number, run: (item: T, index: number) => Promise<R>, onCrash: (item: T, index: number, error: unknown, ms: number) => R, onResult?: (result: R, index: number) => void): Promise<R[]>;
|
|
18
|
+
/**
|
|
19
|
+
* Where the execute time went: counts, story time lost per failure reason, and the share
|
|
20
|
+
* of story time spent on stories that failed. Story times are summed, so with several
|
|
21
|
+
* workers they add up to more than the wall time; the share is of summed story time.
|
|
22
|
+
* `timeLostMs` is `{}` (never absent) when everything passed: absent means "older sbcov".
|
|
23
|
+
*/
|
|
24
|
+
export declare function summarizeTiming(results: Pick<StoryExecutionResult, 'status' | 'duration' | 'failure'>[], run: {
|
|
25
|
+
durationMs: number;
|
|
26
|
+
concurrency: number;
|
|
27
|
+
timeoutMs: number;
|
|
28
|
+
renderTimeoutMs: number;
|
|
29
|
+
declared: number;
|
|
30
|
+
notIndexed: number;
|
|
31
|
+
}): ExecutionTiming;
|
|
10
32
|
/** `https://user:pass@host/x` → `https://host/x`. */
|
|
11
33
|
export declare function stripUrlCredentials(text: string): string;
|
|
12
34
|
/**
|
|
@@ -21,4 +43,8 @@ export declare function loadBaseline(baselinePath: string): ExecutionResults | n
|
|
|
21
43
|
* Check if Playwright is available
|
|
22
44
|
*/
|
|
23
45
|
export declare function isPlaywrightAvailable(): Promise<boolean>;
|
|
46
|
+
/** A story that showed nothing within the render timeout (reason `render_timeout`). */
|
|
47
|
+
export declare class RenderTimeoutError extends Error {
|
|
48
|
+
constructor(ms: number);
|
|
49
|
+
}
|
|
24
50
|
//# sourceMappingURL=story-executor.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"story-executor.d.ts","sourceRoot":"","sources":["../../src/core/story-executor.ts"],"names":[],"mappings":"AAAA;;;GAGG;
|
|
1
|
+
{"version":3,"file":"story-executor.d.ts","sourceRoot":"","sources":["../../src/core/story-executor.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAeH,OAAO,KAAK,EACV,eAAe,EAEf,eAAe,EACf,oBAAoB,EAGpB,gBAAgB,EAChB,kBAAkB,EAElB,eAAe,EAChB,MAAM,mBAAmB,CAAC;AAE3B,OAAO,EAAE,mBAAmB,EAAE,yBAAyB,EAAE,MAAM,yBAAyB,CAAC;AAyBzF;;GAEG;AACH,wBAAsB,cAAc,CAClC,UAAU,EAAE,eAAe,EAAE,EAC7B,MAAM,EAAE,eAAe,EACvB,OAAO,UAAQ,GACd,OAAO,CAAC,gBAAgB,CAAC,CAyO3B;AAED;;;;;GAKG;AACH,wBAAsB,YAAY,CAAC,CAAC,EAAE,CAAC,EACrC,KAAK,EAAE,SAAS,CAAC,EAAE,EACnB,OAAO,EAAE,MAAM,EACf,GAAG,EAAE,CAAC,IAAI,EAAE,CAAC,EAAE,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,CAAC,CAAC,EAC3C,OAAO,EAAE,CAAC,IAAI,EAAE,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,EAAE,EAAE,EAAE,MAAM,KAAK,CAAC,EAClE,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,EAAE,KAAK,EAAE,MAAM,KAAK,IAAI,GAC5C,OAAO,CAAC,CAAC,EAAE,CAAC,CAoBd;AAgCD;;;;;GAKG;AACH,wBAAgB,eAAe,CAC7B,OAAO,EAAE,IAAI,CAAC,oBAAoB,EAAE,QAAQ,GAAG,UAAU,GAAG,SAAS,CAAC,EAAE,EACxE,GAAG,EAAE;IAAE,UAAU,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,eAAe,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE,GACjI,eAAe,CAkCjB;AASD,qDAAqD;AACrD,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAExD;AAoTD;;GAEG;AACH,wBAAgB,iBAAiB,CAC/B,cAAc,EAAE,gBAAgB,EAChC,eAAe,EAAE,gBAAgB,GAAG,IAAI,EACxC,WAAW,EAAE,MAAM,GAClB,kBAAkB,CAyCpB;AAED;;GAEG;AACH,wBAAgB,YAAY,CAAC,YAAY,EAAE,MAAM,GAAG,gBAAgB,GAAG,IAAI,CAa1E;AAED;;GAEG;AACH,wBAAsB,qBAAqB,IAAI,OAAO,CAAC,OAAO,CAAC,CAG9D;AAwED,uFAAuF;AACvF,qBAAa,kBAAmB,SAAQ,KAAK;gBAC/B,EAAE,EAAE,MAAM;CAIvB"}
|
|
@@ -8,6 +8,8 @@ import * as fs from 'node:fs';
|
|
|
8
8
|
import * as path from 'node:path';
|
|
9
9
|
import { buildCaptureInfo, chooseCaptureBox, DEFAULT_CAPTURE } from './capture-target.js';
|
|
10
10
|
import { SBCOV_VERSION } from '../version.js';
|
|
11
|
+
import { DEFAULT_CONCURRENCY, DEFAULT_EXECUTION_TIMEOUT_MS, DEFAULT_RENDER_TIMEOUT_MS, TIMEOUT_REASONS } from './execution-defaults.js';
|
|
12
|
+
export { DEFAULT_CONCURRENCY, DEFAULT_RENDER_TIMEOUT_MS } from './execution-defaults.js';
|
|
11
13
|
// Dynamic import for Playwright (optional dependency)
|
|
12
14
|
let playwright = null;
|
|
13
15
|
function sanitizeStoryIdForPath(storyId) {
|
|
@@ -39,7 +41,11 @@ export async function executeStories(storyFiles, config, verbose = false) {
|
|
|
39
41
|
return createEmptyExecutionResults();
|
|
40
42
|
}
|
|
41
43
|
const storybookUrl = config.storybookUrl || 'http://localhost:6006';
|
|
42
|
-
const timeout = config.timeout ||
|
|
44
|
+
const timeout = config.timeout || DEFAULT_EXECUTION_TIMEOUT_MS;
|
|
45
|
+
const renderTimeout = config.renderTimeout || DEFAULT_RENDER_TIMEOUT_MS;
|
|
46
|
+
const concurrency = Math.max(1, Math.floor(config.concurrency || DEFAULT_CONCURRENCY));
|
|
47
|
+
// Workers actually started: min(concurrency, stories); reported in the timing block.
|
|
48
|
+
let workersStarted = 0;
|
|
43
49
|
const screenshotDir = config.screenshotDir || './screenshots';
|
|
44
50
|
const captureFailureScreenshots = config.screenshotOnFailure !== false;
|
|
45
51
|
const captureAllScreenshots = config.captureAllScreenshots === true;
|
|
@@ -125,17 +131,42 @@ export async function executeStories(storyFiles, config, verbose = false) {
|
|
|
125
131
|
// Launch browser
|
|
126
132
|
const browser = await pw.chromium.launch({ headless: true });
|
|
127
133
|
try {
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
|
|
131
|
-
//
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
134
|
+
// A pool of workers (ISSUES.md #54). Every story gets a fresh browser context, so
|
|
135
|
+
// storage, cookies and service workers never leak from one story into the next
|
|
136
|
+
// (stronger than before 0.6, when all stories shared one context). Results land in
|
|
137
|
+
// their story's slot, so everything written from here on (report, dropped list,
|
|
138
|
+
// archive) is in story order whatever the timing. A story whose page or context
|
|
139
|
+
// crashes costs that story only (render_error with the message), never the run.
|
|
140
|
+
workersStarted = Math.min(concurrency, Math.max(1, allStories.length));
|
|
141
|
+
const contextOptions = { viewport: captureOpts.viewport, deviceScaleFactor: captureOpts.dpr };
|
|
142
|
+
const slots = await runStoryPool(allStories, workersStarted, async ({ story, file }) => {
|
|
143
|
+
// Viewport and scale are the two halves of framing that a crop cannot fix
|
|
144
|
+
// afterwards: a 390×844 screen in a 720px-tall window is simply cut off.
|
|
145
|
+
const context = await browser.newContext(contextOptions);
|
|
146
|
+
try {
|
|
147
|
+
return await executeStory(story, file, context, effectiveUrl, { timeout, renderTimeout }, screenshotDir, captureFailureScreenshots, captureAllScreenshots, captureOpts, verbose);
|
|
148
|
+
}
|
|
149
|
+
finally {
|
|
150
|
+
await context.close().catch((closeError) => {
|
|
151
|
+
console.warn(` could not close the browser context for ${story.storyId}: ${closeError instanceof Error ? closeError.message : String(closeError)}`);
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}, ({ story, file }, _index, error, ms) => crashedStoryResult(story, file, effectiveUrl, error, ms), (result) => {
|
|
135
155
|
if (result.status !== 'passed' && result.failure) {
|
|
136
156
|
// Not behind --verbose: a story that will not be searchable is named when it happens.
|
|
137
157
|
const detail = firstLineOf(result.failure.message);
|
|
138
|
-
console.warn(` ✗ ${
|
|
158
|
+
console.warn(` ✗ ${result.storyId} will not be indexed: ${result.failure.failureType}${detail ? ` (${detail})` : ''}`);
|
|
159
|
+
}
|
|
160
|
+
if (verbose) {
|
|
161
|
+
const icon = result.status === 'passed' ? '✓' : '✗';
|
|
162
|
+
console.log(` ${icon} ${result.componentName}/${result.storyName} (${result.duration}ms)`);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
allStories.forEach(({ story, file }, index) => {
|
|
166
|
+
const result = slots[index];
|
|
167
|
+
results.push(result);
|
|
168
|
+
if (result.status !== 'passed' && result.failure) {
|
|
169
|
+
const detail = firstLineOf(result.failure.message);
|
|
139
170
|
dropped.push({
|
|
140
171
|
storyId: story.storyId,
|
|
141
172
|
storyTitle: file.meta.title || '',
|
|
@@ -143,11 +174,7 @@ export async function executeStories(storyFiles, config, verbose = false) {
|
|
|
143
174
|
...(detail ? { detail } : {}),
|
|
144
175
|
});
|
|
145
176
|
}
|
|
146
|
-
|
|
147
|
-
const icon = result.status === 'passed' ? '✓' : '✗';
|
|
148
|
-
console.log(` ${icon} ${result.componentName}/${result.storyName} (${result.duration}ms)`);
|
|
149
|
-
}
|
|
150
|
-
}
|
|
177
|
+
});
|
|
151
178
|
}
|
|
152
179
|
finally {
|
|
153
180
|
await browser.close();
|
|
@@ -166,6 +193,14 @@ export async function executeStories(storyFiles, config, verbose = false) {
|
|
|
166
193
|
const passed = results.filter(r => r.status === 'passed').length;
|
|
167
194
|
const failed = results.filter(r => r.status === 'failed').length;
|
|
168
195
|
const skipped = results.filter(r => r.status === 'skipped').length;
|
|
196
|
+
const timing = summarizeTiming(results, {
|
|
197
|
+
durationMs: duration,
|
|
198
|
+
concurrency: workersStarted || Math.min(concurrency, Math.max(1, allStories.length)),
|
|
199
|
+
timeoutMs: timeout,
|
|
200
|
+
renderTimeoutMs: renderTimeout,
|
|
201
|
+
declared: allStories.length + missedIds.length,
|
|
202
|
+
notIndexed: dropped.length,
|
|
203
|
+
});
|
|
169
204
|
return {
|
|
170
205
|
executed: true,
|
|
171
206
|
executedAt: new Date().toISOString(),
|
|
@@ -181,9 +216,110 @@ export async function executeStories(storyFiles, config, verbose = false) {
|
|
|
181
216
|
stories: results,
|
|
182
217
|
failures,
|
|
183
218
|
dropped,
|
|
219
|
+
timing,
|
|
184
220
|
regression: createEmptyRegressionAnalysis(),
|
|
185
221
|
};
|
|
186
222
|
}
|
|
223
|
+
/**
|
|
224
|
+
* Run `run` over `items` with `workers` workers pulling the next index. Results are
|
|
225
|
+
* returned in item order. A throw from `run` never escapes: `onCrash` turns it into that
|
|
226
|
+
* item's result (so one crashed page costs one story, and the other workers carry on).
|
|
227
|
+
* `onResult` is called as each item finishes.
|
|
228
|
+
*/
|
|
229
|
+
export async function runStoryPool(items, workers, run, onCrash, onResult) {
|
|
230
|
+
const slots = new Array(items.length);
|
|
231
|
+
let next = 0;
|
|
232
|
+
const worker = async () => {
|
|
233
|
+
for (;;) {
|
|
234
|
+
const index = next++;
|
|
235
|
+
if (index >= items.length)
|
|
236
|
+
return;
|
|
237
|
+
const t0 = Date.now();
|
|
238
|
+
let result;
|
|
239
|
+
try {
|
|
240
|
+
result = await run(items[index], index);
|
|
241
|
+
}
|
|
242
|
+
catch (error) {
|
|
243
|
+
result = onCrash(items[index], index, error, Date.now() - t0);
|
|
244
|
+
}
|
|
245
|
+
slots[index] = result;
|
|
246
|
+
onResult?.(result, index);
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
await Promise.all(Array.from({ length: Math.max(1, Math.min(workers, items.length || 1)) }, () => worker()));
|
|
250
|
+
return slots;
|
|
251
|
+
}
|
|
252
|
+
/** The result for a story whose page or browser context threw outside the story's own checks. */
|
|
253
|
+
function crashedStoryResult(story, file, storybookUrl, error, ms) {
|
|
254
|
+
const componentName = file.meta.componentName || 'Unknown';
|
|
255
|
+
const message = `Story could not be executed (browser page or context failed): ${error instanceof Error ? error.message : String(error)}`;
|
|
256
|
+
return {
|
|
257
|
+
storyId: story.storyId,
|
|
258
|
+
componentName,
|
|
259
|
+
storyName: story.storyName,
|
|
260
|
+
status: 'failed',
|
|
261
|
+
duration: ms,
|
|
262
|
+
failure: {
|
|
263
|
+
storyId: story.storyId,
|
|
264
|
+
componentName,
|
|
265
|
+
storyName: story.storyName,
|
|
266
|
+
failureType: 'render_error',
|
|
267
|
+
message,
|
|
268
|
+
stack: error instanceof Error ? error.stack || null : null,
|
|
269
|
+
screenshotPath: null,
|
|
270
|
+
consoleErrors: [],
|
|
271
|
+
isNewFailure: true,
|
|
272
|
+
storyFilePath: file.relativePath,
|
|
273
|
+
storybookUrl: `${storybookUrl}/iframe.html?id=${story.storyId}&viewMode=story`,
|
|
274
|
+
},
|
|
275
|
+
warnings: [],
|
|
276
|
+
hasPlayFunction: story.hasPlayFunction,
|
|
277
|
+
playFunctionStatus: story.hasPlayFunction ? 'failed' : 'none',
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Where the execute time went: counts, story time lost per failure reason, and the share
|
|
282
|
+
* of story time spent on stories that failed. Story times are summed, so with several
|
|
283
|
+
* workers they add up to more than the wall time; the share is of summed story time.
|
|
284
|
+
* `timeLostMs` is `{}` (never absent) when everything passed: absent means "older sbcov".
|
|
285
|
+
*/
|
|
286
|
+
export function summarizeTiming(results, run) {
|
|
287
|
+
const timeLostMs = {};
|
|
288
|
+
let storyMs = 0;
|
|
289
|
+
let failedMs = 0;
|
|
290
|
+
let passed = 0;
|
|
291
|
+
let failed = 0;
|
|
292
|
+
let timeouts = 0;
|
|
293
|
+
for (const r of results) {
|
|
294
|
+
const ms = Math.max(0, Math.round(r.duration || 0));
|
|
295
|
+
storyMs += ms;
|
|
296
|
+
if (r.status === 'passed') {
|
|
297
|
+
passed++;
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
if (r.status !== 'failed' && r.status !== 'timeout')
|
|
301
|
+
continue;
|
|
302
|
+
failed++;
|
|
303
|
+
failedMs += ms;
|
|
304
|
+
const reason = r.failure?.failureType ?? 'render_error';
|
|
305
|
+
if (TIMEOUT_REASONS.has(reason) || r.status === 'timeout')
|
|
306
|
+
timeouts++;
|
|
307
|
+
timeLostMs[reason] = (timeLostMs[reason] ?? 0) + ms;
|
|
308
|
+
}
|
|
309
|
+
return {
|
|
310
|
+
durationMs: Math.max(0, Math.round(run.durationMs)),
|
|
311
|
+
concurrency: run.concurrency,
|
|
312
|
+
timeoutMs: run.timeoutMs,
|
|
313
|
+
renderTimeoutMs: run.renderTimeoutMs,
|
|
314
|
+
declared: run.declared,
|
|
315
|
+
passed,
|
|
316
|
+
failed,
|
|
317
|
+
timeouts,
|
|
318
|
+
notIndexed: run.notIndexed,
|
|
319
|
+
timeLostMs,
|
|
320
|
+
failedTimeShare: storyMs > 0 ? Math.round((failedMs / storyMs) * 1000) / 1000 : 0,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
187
323
|
/** First line of a message, bounded, for one-line log output and the manifest. */
|
|
188
324
|
function firstLineOf(message, max = 200) {
|
|
189
325
|
// The detail goes into the uploaded archive and the CI log: never carry URL credentials.
|
|
@@ -197,11 +333,14 @@ export function stripUrlCredentials(text) {
|
|
|
197
333
|
/**
|
|
198
334
|
* Execute a single story
|
|
199
335
|
*/
|
|
200
|
-
async function executeStory(story, file, context, storybookUrl,
|
|
336
|
+
async function executeStory(story, file, context, storybookUrl, bounds, screenshotDir, captureFailureScreenshot, captureAllScreenshots, captureOpts, verbose = false) {
|
|
337
|
+
const { timeout, renderTimeout } = bounds;
|
|
201
338
|
const startTime = Date.now();
|
|
202
339
|
const consoleErrors = [];
|
|
203
340
|
const warnings = [];
|
|
204
341
|
const page = await context.newPage();
|
|
342
|
+
// Defines the "has the story shown anything" probe in every document the page loads.
|
|
343
|
+
await page.addInitScript(installStoryProbe);
|
|
205
344
|
// Capture console messages
|
|
206
345
|
page.on('console', msg => {
|
|
207
346
|
if (msg.type() === 'error') {
|
|
@@ -223,10 +362,24 @@ async function executeStory(story, file, context, storybookUrl, timeout, screens
|
|
|
223
362
|
try {
|
|
224
363
|
// Navigate to story
|
|
225
364
|
await page.goto(storyUrl, { timeout });
|
|
226
|
-
// Wait for the story to
|
|
227
|
-
|
|
365
|
+
// Wait for the story to show something (ISSUES.md #54). `#storybook-root` being
|
|
366
|
+
// visible was the only signal before 0.6, so a story that renders into a portal
|
|
367
|
+
// (a dialog: the root stays empty) waited out the whole 15 s and was never
|
|
368
|
+
// captured. Now: the root's content, or what the story added to <body>, or
|
|
369
|
+
// Storybook's own error display, bounded by the render timeout.
|
|
370
|
+
let rendered = await waitForStoryContent(page, renderTimeout);
|
|
228
371
|
// Small delay for React hydration
|
|
229
372
|
await page.waitForTimeout(100);
|
|
373
|
+
if (rendered === 'portal' && (await rootShows(page))) {
|
|
374
|
+
// The root rendered after all (a slow first render next to a decorator's portal): a root story.
|
|
375
|
+
rendered = 'root';
|
|
376
|
+
}
|
|
377
|
+
if (rendered === 'portal') {
|
|
378
|
+
// Dialogs animate in; wait (bounded) for that to finish so the image shows the open dialog.
|
|
379
|
+
const settled = await waitForAnimations(page, PORTAL_SETTLE_MS);
|
|
380
|
+
if (!settled)
|
|
381
|
+
warnings.push(`sbcov: animations still running ${PORTAL_SETTLE_MS} ms after a portal story rendered; captured as it was`);
|
|
382
|
+
}
|
|
230
383
|
// Check for render errors in the page
|
|
231
384
|
const errorBoundary = await page.$('[data-story-error]');
|
|
232
385
|
if (errorBoundary) {
|
|
@@ -259,6 +412,11 @@ async function executeStory(story, file, context, storybookUrl, timeout, screens
|
|
|
259
412
|
console.log(` [DEBUG] Ignoring hidden Storybook error: ${errorText.trim().substring(0, 50)}...`);
|
|
260
413
|
}
|
|
261
414
|
}
|
|
415
|
+
if (rendered === 'error') {
|
|
416
|
+
// Storybook's own error display is what showed (the story threw while rendering).
|
|
417
|
+
const errorText = (await page.textContent('.sb-errordisplay').catch(() => null))?.trim();
|
|
418
|
+
throw new Error(`Storybook error: ${errorText || 'the story failed to render (Storybook error display shown)'}`);
|
|
419
|
+
}
|
|
262
420
|
// If there was a page error, treat as failure
|
|
263
421
|
if (pageError) {
|
|
264
422
|
throw pageError;
|
|
@@ -299,7 +457,7 @@ async function executeStory(story, file, context, storybookUrl, timeout, screens
|
|
|
299
457
|
if (captureAllScreenshots) {
|
|
300
458
|
try {
|
|
301
459
|
screenshotPath = path.join(screenshotDir, `${safeStoryId}.png`);
|
|
302
|
-
capture = await captureStoryImage(page, screenshotPath, captureOpts);
|
|
460
|
+
capture = await captureStoryImage(page, screenshotPath, captureOpts, rendered);
|
|
303
461
|
}
|
|
304
462
|
catch (screenshotError) {
|
|
305
463
|
// The story rendered, but without an image it cannot be indexed: report it
|
|
@@ -356,7 +514,10 @@ async function executeStory(story, file, context, storybookUrl, timeout, screens
|
|
|
356
514
|
const errorStack = error instanceof Error ? error.stack || null : null;
|
|
357
515
|
// Determine failure type
|
|
358
516
|
let failureType = 'render_error';
|
|
359
|
-
if (
|
|
517
|
+
if (error instanceof RenderTimeoutError) {
|
|
518
|
+
failureType = 'render_timeout';
|
|
519
|
+
}
|
|
520
|
+
else if (errorMessage.includes('timeout') || errorMessage.includes('Timeout')) {
|
|
360
521
|
failureType = 'timeout';
|
|
361
522
|
}
|
|
362
523
|
else if (errorMessage.includes('Play function')) {
|
|
@@ -378,7 +539,9 @@ async function executeStory(story, file, context, storybookUrl, timeout, screens
|
|
|
378
539
|
}
|
|
379
540
|
}
|
|
380
541
|
}
|
|
381
|
-
await page.close()
|
|
542
|
+
await page.close().catch((closeError) => {
|
|
543
|
+
console.warn(` could not close the page for ${story.storyId}: ${closeError instanceof Error ? closeError.message : String(closeError)}`);
|
|
544
|
+
});
|
|
382
545
|
const failure = {
|
|
383
546
|
storyId: story.storyId,
|
|
384
547
|
componentName,
|
|
@@ -508,7 +671,27 @@ const CAPTURE_MARK = 'data-scry-capture';
|
|
|
508
671
|
* even when taller than the window). Falls back to the viewport when nothing
|
|
509
672
|
* qualifies, and always reports what it did.
|
|
510
673
|
*/
|
|
511
|
-
async function captureStoryImage(page, screenshotPath, opts) {
|
|
674
|
+
async function captureStoryImage(page, screenshotPath, opts, rendered) {
|
|
675
|
+
const into = rendered === 'portal' ? 'portal' : 'root';
|
|
676
|
+
if (opts.mode === 'root' && rendered === 'portal') {
|
|
677
|
+
// The root is empty: frame what the story put in <body> (the dialog), or the viewport.
|
|
678
|
+
const box = (await page.evaluate(({ mark }) => {
|
|
679
|
+
document.querySelectorAll(`[${mark}]`).forEach((el) => el.removeAttribute(mark));
|
|
680
|
+
const el = window.__scryPortalTarget?.() ?? null;
|
|
681
|
+
if (!el)
|
|
682
|
+
return null;
|
|
683
|
+
el.setAttribute(mark, '1');
|
|
684
|
+
const r = el.getBoundingClientRect();
|
|
685
|
+
return { x: r.x, y: r.y, width: r.width, height: r.height };
|
|
686
|
+
}, { mark: CAPTURE_MARK }));
|
|
687
|
+
const choice = box ? chooseCaptureBox([{ kind: 'portal', index: 0, box }], opts.viewport) : null;
|
|
688
|
+
if (choice) {
|
|
689
|
+
await page.locator(`[${CAPTURE_MARK}="1"]`).first().screenshot({ path: screenshotPath });
|
|
690
|
+
return buildCaptureInfo(opts, choice, SBCOV_VERSION, into);
|
|
691
|
+
}
|
|
692
|
+
await page.screenshot({ path: screenshotPath });
|
|
693
|
+
return buildCaptureInfo(opts, null, SBCOV_VERSION, into);
|
|
694
|
+
}
|
|
512
695
|
if (opts.mode === 'root') {
|
|
513
696
|
const candidates = (await page.evaluate(() => {
|
|
514
697
|
const out = [];
|
|
@@ -530,10 +713,153 @@ async function captureStoryImage(page, screenshotPath, opts) {
|
|
|
530
713
|
el.setAttribute(mark, '1');
|
|
531
714
|
}, { kind: choice.kind, index: choice.index, mark: CAPTURE_MARK });
|
|
532
715
|
await page.locator(`[${CAPTURE_MARK}="1"]`).first().screenshot({ path: screenshotPath });
|
|
533
|
-
return buildCaptureInfo(opts, choice, SBCOV_VERSION);
|
|
716
|
+
return buildCaptureInfo(opts, choice, SBCOV_VERSION, into);
|
|
534
717
|
}
|
|
535
718
|
}
|
|
536
719
|
await page.screenshot({ path: screenshotPath });
|
|
537
|
-
return buildCaptureInfo(opts, null, SBCOV_VERSION);
|
|
720
|
+
return buildCaptureInfo(opts, null, SBCOV_VERSION, into);
|
|
721
|
+
}
|
|
722
|
+
/** A story that showed nothing within the render timeout (reason `render_timeout`). */
|
|
723
|
+
export class RenderTimeoutError extends Error {
|
|
724
|
+
constructor(ms) {
|
|
725
|
+
super(`Story showed nothing within ${ms} ms (render timeout: #storybook-root stayed empty and nothing was added to <body>)`);
|
|
726
|
+
this.name = 'RenderTimeoutError';
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
/** Bound on waiting for a portal story's open animation, ms. */
|
|
730
|
+
const PORTAL_SETTLE_MS = 1000;
|
|
731
|
+
/**
|
|
732
|
+
* Installed in every document the page loads (addInitScript): finds what a story shows.
|
|
733
|
+
*
|
|
734
|
+
* - 'root' when `#storybook-root` is visible exactly as Playwright's `waitForSelector`
|
|
735
|
+
* judged it before 0.6 (a non-empty box, not visibility:hidden), so every story that
|
|
736
|
+
* rendered into its root is detected, and captured, as before.
|
|
737
|
+
* - 'portal' only when the root does not show, and a <body> child that
|
|
738
|
+
* is neither Storybook's own (`#storybook-*`, `.sb-*`, scripts, styles) nor already in
|
|
739
|
+
* the page when it finished parsing (preview-body.html markup) has a visible descendant
|
|
740
|
+
* at least 2×2 px and not opacity 0 (a Radix/shadcn portal; 1 px screen-reader
|
|
741
|
+
* announcers do not count), on three polls in a row (~100 ms), so a story whose root is
|
|
742
|
+
* about to render is not taken for a portal story. (The root may hold decorator wrappers
|
|
743
|
+
* or a closed trigger: real portal stories often do, so its children are not a signal.)
|
|
744
|
+
* The executor checks the root once more just before the capture.
|
|
745
|
+
* - 'error' when Storybook shows its error display.
|
|
746
|
+
*
|
|
747
|
+
* Also exposes `__scryPortalTarget()` (framing: the largest visible dialog-like element
|
|
748
|
+
* in the portal, else a popup, else its largest element that is not a full-window
|
|
749
|
+
* overlay) and `__scryPortalAnimating()` (a finite animation still running in the portal).
|
|
750
|
+
*/
|
|
751
|
+
function installStoryProbe() {
|
|
752
|
+
const w = window;
|
|
753
|
+
if (w.__scryProbe)
|
|
754
|
+
return;
|
|
755
|
+
// Body children present when the document finished parsing are the page's own markup
|
|
756
|
+
// (preview-body.html), not something a story portalled.
|
|
757
|
+
let staticChildren = null;
|
|
758
|
+
const recordStatic = () => { if (!staticChildren && document.body)
|
|
759
|
+
staticChildren = new Set(Array.from(document.body.children)); };
|
|
760
|
+
if (document.readyState === 'loading')
|
|
761
|
+
document.addEventListener('DOMContentLoaded', recordStatic, { once: true });
|
|
762
|
+
else
|
|
763
|
+
recordStatic();
|
|
764
|
+
let portalPolls = 0;
|
|
765
|
+
const box = (el) => el.getBoundingClientRect();
|
|
766
|
+
const hiddenStyle = (el) => {
|
|
767
|
+
const s = getComputedStyle(el);
|
|
768
|
+
return s.visibility === 'hidden' || s.display === 'none';
|
|
769
|
+
};
|
|
770
|
+
const shows = (el, min) => {
|
|
771
|
+
const r = box(el);
|
|
772
|
+
return r.width >= min && r.height >= min && !hiddenStyle(el);
|
|
773
|
+
};
|
|
774
|
+
const showsOpaque = (el) => shows(el, 2) && getComputedStyle(el).opacity !== '0';
|
|
775
|
+
const isStorybookOwn = (el) => /^(SCRIPT|STYLE|LINK|TEMPLATE|NOSCRIPT|META)$/.test(el.tagName) ||
|
|
776
|
+
el.id.startsWith('storybook-') ||
|
|
777
|
+
Array.from(el.classList).some((c) => c.startsWith('sb-'));
|
|
778
|
+
const portalRoots = () => Array.from(document.body?.children ?? []).filter((el) => !isStorybookOwn(el) && !(staticChildren?.has(el) ?? false));
|
|
779
|
+
const anyShowing = (roots) => roots.some((r) => showsOpaque(r) || Array.from(r.querySelectorAll('*')).some(showsOpaque));
|
|
780
|
+
const visibleIn = (roots, selector) => {
|
|
781
|
+
const out = [];
|
|
782
|
+
for (const r of roots) {
|
|
783
|
+
if (r.matches(selector) && showsOpaque(r))
|
|
784
|
+
out.push(r);
|
|
785
|
+
r.querySelectorAll(selector).forEach((el) => { if (showsOpaque(el))
|
|
786
|
+
out.push(el); });
|
|
787
|
+
}
|
|
788
|
+
return out;
|
|
789
|
+
};
|
|
790
|
+
const area = (el) => { const r = box(el); return r.width * r.height; };
|
|
791
|
+
const largest = (els) => els.reduce((best, el) => (!best || area(el) > area(best) ? el : best), null);
|
|
792
|
+
w.__scryProbe = () => {
|
|
793
|
+
const root = document.getElementById('storybook-root');
|
|
794
|
+
if (root && box(root).width > 0 && box(root).height > 0 && getComputedStyle(root).visibility !== 'hidden')
|
|
795
|
+
return 'root';
|
|
796
|
+
if (anyShowing(portalRoots())) {
|
|
797
|
+
portalPolls++;
|
|
798
|
+
if (portalPolls >= 3)
|
|
799
|
+
return 'portal';
|
|
800
|
+
return null;
|
|
801
|
+
}
|
|
802
|
+
else {
|
|
803
|
+
portalPolls = 0;
|
|
804
|
+
}
|
|
805
|
+
const err = document.querySelector('.sb-errordisplay');
|
|
806
|
+
if (err && shows(err, 1) && (err.textContent ?? '').trim().length > 0)
|
|
807
|
+
return 'error';
|
|
808
|
+
return null;
|
|
809
|
+
};
|
|
810
|
+
w.__scryPortalTarget = () => {
|
|
811
|
+
const roots = portalRoots();
|
|
812
|
+
const dialog = largest(visibleIn(roots, '[role="dialog"], [role="alertdialog"]'));
|
|
813
|
+
if (dialog)
|
|
814
|
+
return dialog;
|
|
815
|
+
const popup = largest(visibleIn(roots, '[role="menu"], [role="listbox"], [role="tooltip"], [data-radix-popper-content-wrapper]'));
|
|
816
|
+
if (popup)
|
|
817
|
+
return popup;
|
|
818
|
+
const vw = window.innerWidth;
|
|
819
|
+
const vh = window.innerHeight;
|
|
820
|
+
const all = visibleIn(roots, '*').filter((el) => { const r = box(el); return r.width < vw - 0.5 || r.height < vh - 0.5; });
|
|
821
|
+
return largest(all);
|
|
822
|
+
};
|
|
823
|
+
w.__scryPortalAnimating = () => portalRoots().some((r) => r.getAnimations({ subtree: true }).some((a) => a.playState === 'running' && (a.effect?.getComputedTiming().iterations ?? 1) !== Infinity));
|
|
824
|
+
}
|
|
825
|
+
/**
|
|
826
|
+
* Wait (bounded by `renderTimeout`) until the story shows something; throws
|
|
827
|
+
* RenderTimeoutError when it does not.
|
|
828
|
+
*/
|
|
829
|
+
async function waitForStoryContent(page, renderTimeout) {
|
|
830
|
+
try {
|
|
831
|
+
const handle = await page.waitForFunction(() => window.__scryProbe?.() ?? null, undefined, { timeout: renderTimeout, polling: 50 });
|
|
832
|
+
return (await handle.jsonValue());
|
|
833
|
+
}
|
|
834
|
+
catch (error) {
|
|
835
|
+
if (error instanceof Error && /Timeout .*exceeded/i.test(error.message)) {
|
|
836
|
+
throw new RenderTimeoutError(renderTimeout);
|
|
837
|
+
}
|
|
838
|
+
throw error;
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
/** `#storybook-root` visible, by the same rule as the probe. */
|
|
842
|
+
async function rootShows(page) {
|
|
843
|
+
return page.evaluate(() => {
|
|
844
|
+
const root = document.getElementById('storybook-root');
|
|
845
|
+
if (!root)
|
|
846
|
+
return false;
|
|
847
|
+
const r = root.getBoundingClientRect();
|
|
848
|
+
return r.width > 0 && r.height > 0 && getComputedStyle(root).visibility !== 'hidden';
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
/** Wait until no finite animation is running in the portal, up to `ms`. True when settled, false when the bound was hit. */
|
|
852
|
+
async function waitForAnimations(page, ms) {
|
|
853
|
+
try {
|
|
854
|
+
await page.waitForFunction(
|
|
855
|
+
// Only the portal's own, finite animations: a spinner elsewhere on the page never ends.
|
|
856
|
+
() => !window.__scryPortalAnimating?.(), undefined, { timeout: ms, polling: 50 });
|
|
857
|
+
return true;
|
|
858
|
+
}
|
|
859
|
+
catch (error) {
|
|
860
|
+
if (error instanceof Error && /Timeout .*exceeded/i.test(error.message))
|
|
861
|
+
return false;
|
|
862
|
+
throw error;
|
|
863
|
+
}
|
|
538
864
|
}
|
|
539
865
|
//# sourceMappingURL=story-executor.js.map
|