@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.
@@ -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, but only when it is actually
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 element (false for viewport
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 = { viewport: opts.viewport, dpr: opts.dpr, scale: opts.dpr, sbcov_version: sbcovVersion };
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":"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"}
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;AAcH,OAAO,KAAK,EACV,eAAe,EAEf,eAAe,EAIf,gBAAgB,EAChB,kBAAkB,EAEnB,MAAM,mBAAmB,CAAC;AAyB3B;;GAEG;AACH,wBAAsB,cAAc,CAClC,UAAU,EAAE,eAAe,EAAE,EAC7B,MAAM,EAAE,eAAe,EACvB,OAAO,UAAQ,GACd,OAAO,CAAC,gBAAgB,CAAC,CAmM3B;AASD,qDAAqD;AACrD,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAExD;AAwRD;;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"}
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 || 15000;
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
- // Viewport and scale are the two halves of framing that a crop cannot fix
129
- // afterwards: a 390×844 screen in a 720px-tall window is simply cut off.
130
- const context = await browser.newContext({ viewport: captureOpts.viewport, deviceScaleFactor: captureOpts.dpr });
131
- // Execute each story
132
- for (const { story, file } of allStories) {
133
- const result = await executeStory(story, file, context, effectiveUrl, timeout, screenshotDir, captureFailureScreenshots, captureAllScreenshots, captureOpts, verbose);
134
- results.push(result);
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(` ✗ ${story.storyId} will not be indexed: ${result.failure.failureType}${detail ? ` (${detail})` : ''}`);
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
- if (verbose) {
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, timeout, screenshotDir, captureFailureScreenshot, captureAllScreenshots, captureOpts, verbose = false) {
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 render
227
- await page.waitForSelector('#storybook-root', { timeout });
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 (errorMessage.includes('timeout') || errorMessage.includes('Timeout')) {
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