@codepress/design-kit 0.0.0-stage → 0.1.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.
@@ -0,0 +1,1226 @@
1
+ /**
2
+ * `design-kit capture stories`: every story of a Storybook 7/8 static build
3
+ * rendered to static HTML, the CSS the preview loaded, the fonts, an index
4
+ * and kit.mjs.
5
+ *
6
+ * DESIGN_KIT_CONCURRENCY caps the browser pages working at once (default 4);
7
+ * DESIGN_KIT_STORY_TIMEOUT_MS bounds each capture (default 15000);
8
+ * DESIGN_KIT_QUIET_MS is how long the DOM must stay unchanged (default 600);
9
+ * DESIGN_KIT_TIMINGS_FILE, when set, receives the per-phase time breakdown.
10
+ * Needs Playwright's Chromium (`npx playwright install chromium`).
11
+ */
12
+ import { execFileSync, spawnSync } from "node:child_process";
13
+ import {
14
+ copyFileSync,
15
+ createReadStream,
16
+ existsSync,
17
+ mkdirSync,
18
+ mkdtempSync,
19
+ readFileSync,
20
+ readdirSync,
21
+ rmSync,
22
+ statSync,
23
+ writeFileSync,
24
+ } from "node:fs";
25
+ import { createServer } from "node:http";
26
+ import { tmpdir } from "node:os";
27
+ import { extname, join, relative, resolve, sep } from "node:path";
28
+ import { fileURLToPath } from "node:url";
29
+
30
+ import { globToRegExp, matchFile } from "./glob.mjs";
31
+ import { loadPlaywright, playwrightVersion } from "./playwright.mjs";
32
+
33
+ export { playwrightVersion };
34
+
35
+ const here = fileURLToPath(new URL(".", import.meta.url));
36
+
37
+ export const DEFAULT_VIEWPORT = { width: 1280, height: 800 };
38
+ const QUIET_FRAMES = 6;
39
+ /**
40
+ * DESIGN_KIT_QUIET_MS: how long the DOM must stay unchanged, after fonts,
41
+ * frames, images and requests are done (default 600). Stories on real timers
42
+ * (slow routes, loading states) render a different state under a shorter
43
+ * floor, so it stays at 600.
44
+ */
45
+ const quietMs =
46
+ Number.parseInt(process.env.DESIGN_KIT_QUIET_MS ?? "600", 10) || 0;
47
+
48
+ /** Storybook's built-in viewport names (minimal set plus the iPhones in use). */
49
+ const NAMED_VIEWPORTS = {
50
+ mobile1: { width: 320, height: 568 },
51
+ mobile2: { width: 414, height: 896 },
52
+ tablet: { width: 834, height: 1112 },
53
+ iphone12: { width: 390, height: 844 },
54
+ iphone12promax: { width: 428, height: 926 },
55
+ iphonex: { width: 375, height: 812 },
56
+ iphone6: { width: 375, height: 667 },
57
+ };
58
+
59
+ /** The kit ids are Storybook's with a width suffix. */
60
+ export const kitId = (storyId, width) => `story:${storyId}@${width}`;
61
+ /** File names carry no ':' (Actions artifacts reject it): `story--<id>@<w>`. */
62
+ export const kitFile = (id) => `html/${id.replace(/^story:/, "story--")}.html`;
63
+ export const storyIdOf = (id) => id.replace(/^story:/, "").replace(/@\d+$/, "");
64
+
65
+ /**
66
+ * Sheets in a cascade-respecting order: each capture's sheets keep their
67
+ * document order, and a sheet first seen later is placed after the sheet
68
+ * that preceded it in that capture.
69
+ */
70
+ export function mergeSheetOrder(order, keys) {
71
+ let last = -1;
72
+ for (const key of keys) {
73
+ let at = order.indexOf(key);
74
+ if (at < 0) {
75
+ order.splice(last + 1, 0, key);
76
+ at = last + 1;
77
+ }
78
+ last = at;
79
+ }
80
+ return order;
81
+ }
82
+
83
+ /** All rules once, first occurrence wins, in sheet order. */
84
+ export function buildCss(fontCss, order, sheets) {
85
+ const seen = new Set();
86
+ const out = [fontCss.trim()];
87
+ for (const key of order) {
88
+ for (const rule of sheets[key] ?? []) {
89
+ if (seen.has(rule)) continue;
90
+ seen.add(rule);
91
+ out.push(rule);
92
+ }
93
+ }
94
+ return `${out.join("\n")}\n`;
95
+ }
96
+
97
+ /** Keeps another writer's rows (routes); ours are replaced wholesale. */
98
+ export function mergeGaps(existing, ours) {
99
+ const theirs = existing.filter(
100
+ (row) =>
101
+ row.route !== undefined || String(row.id ?? "").startsWith("route:"),
102
+ );
103
+ return [...ours, ...theirs].sort((a, b) => {
104
+ const ka = JSON.stringify([a.id ?? "", a.component ?? "", a.route ?? ""]);
105
+ const kb = JSON.stringify([b.id ?? "", b.component ?? "", b.route ?? ""]);
106
+ return ka < kb ? -1 : ka > kb ? 1 : 0;
107
+ });
108
+ }
109
+
110
+ export function mergeManifest(existing, { sha, builtAt, stories, theme }) {
111
+ const sources = { ...(existing?.sources ?? {}), stories };
112
+ const failed = Object.values(sources).reduce(
113
+ (sum, source) => sum + (Number(source?.failed) || 0),
114
+ 0,
115
+ );
116
+ return {
117
+ kit_version: 1,
118
+ sha,
119
+ built_at: builtAt,
120
+ counts: {
121
+ ...(existing?.counts ?? {}),
122
+ stories: stories.captured,
123
+ routes: existing?.counts?.routes ?? 0,
124
+ failed,
125
+ },
126
+ sources,
127
+ ...(theme ? { theme } : {}),
128
+ };
129
+ }
130
+
131
+ /**
132
+ * Which captures a story gets: its declared viewport or the default, plus
133
+ * each configured width whose `titles` pattern matches the story's title.
134
+ */
135
+ export function plannedViewports(
136
+ title,
137
+ declared,
138
+ { viewport = DEFAULT_VIEWPORT, widths = [] } = {},
139
+ ) {
140
+ const first = declared ?? viewport;
141
+ const list = [first];
142
+ for (const extra of widths) {
143
+ if (
144
+ extra.titles.test(title) &&
145
+ !list.some((planned) => planned.width === extra.width)
146
+ ) {
147
+ list.push({ width: extra.width, height: extra.height });
148
+ }
149
+ }
150
+ return list;
151
+ }
152
+
153
+ /** `--only` / `--only-file` text: comma- or newline-separated story ids. */
154
+ export function parseOnly(text) {
155
+ return text
156
+ .split(/[,\n]/)
157
+ .map((id) => storyIdOf(id.trim()))
158
+ .filter(Boolean);
159
+ }
160
+
161
+ const TYPES = {
162
+ ".html": "text/html; charset=utf-8",
163
+ ".js": "text/javascript; charset=utf-8",
164
+ ".mjs": "text/javascript; charset=utf-8",
165
+ ".css": "text/css; charset=utf-8",
166
+ ".json": "application/json; charset=utf-8",
167
+ ".svg": "image/svg+xml",
168
+ ".png": "image/png",
169
+ ".jpg": "image/jpeg",
170
+ ".webp": "image/webp",
171
+ ".woff2": "font/woff2",
172
+ ".woff": "font/woff",
173
+ };
174
+
175
+ function serve(buildDir, fontsDir) {
176
+ const server = createServer((request, response) => {
177
+ const path = decodeURIComponent(new URL(request.url, "http://x").pathname);
178
+ const fonts = fontsDir !== null && path.startsWith("/__fonts/");
179
+ const root = fonts ? fontsDir : buildDir;
180
+ const file = resolve(
181
+ root,
182
+ `.${fonts ? path.slice("/__fonts".length) : path}`,
183
+ );
184
+ if (
185
+ !file.startsWith(root + sep) ||
186
+ !existsSync(file) ||
187
+ !statSync(file).isFile()
188
+ ) {
189
+ response.writeHead(404).end();
190
+ return;
191
+ }
192
+ response.setHeader(
193
+ "content-type",
194
+ TYPES[extname(file)] ?? "application/octet-stream",
195
+ );
196
+ createReadStream(file).pipe(response);
197
+ });
198
+ return new Promise((done) =>
199
+ server.listen(0, "127.0.0.1", () => done(server)),
200
+ );
201
+ }
202
+
203
+ /** No animation or transition runs, and no caret blinks, in a capture. */
204
+ const STILL_CSS =
205
+ "*,*::before,*::after{caret-color:transparent !important;animation:none !important;transition:none !important}";
206
+
207
+ const FONT_FORMATS = {
208
+ ".woff2": "woff2",
209
+ ".woff": "woff",
210
+ ".ttf": "truetype",
211
+ ".otf": "opentype",
212
+ };
213
+
214
+ /**
215
+ * Each configured face's file in `fonts.dir`: the first name, in sorted
216
+ * order, that its `file` glob matches. The kit keeps the file's name.
217
+ */
218
+ export function fontFiles(fonts) {
219
+ if (!fonts) return [];
220
+ const names = readdirSync(fonts.dir).sort();
221
+ return fonts.faces.map((face, index) => {
222
+ const pattern = globToRegExp(face.file);
223
+ const name = names.find((candidate) => pattern.test(candidate));
224
+ if (!name) {
225
+ throw new Error(
226
+ `stories.fonts.faces[${index}].file "${face.file}" matches nothing in ${fonts.dir}`,
227
+ );
228
+ }
229
+ return name;
230
+ });
231
+ }
232
+
233
+ /**
234
+ * The CSS every capture is given first: the configured faces from `base`,
235
+ * the CSS variables that name them, and the rule that stops motion.
236
+ */
237
+ export function fontCss(base, fonts, files = fontFiles(fonts)) {
238
+ const faces = fonts?.faces ?? [];
239
+ const lines = faces.map((face, index) => {
240
+ const file = files[index];
241
+ const format = FONT_FORMATS[extname(file).toLowerCase()] ?? "woff2";
242
+ return `@font-face{font-family:"${face.family}";${face.weight ? `font-weight:${face.weight};` : ""}${face.style ? `font-style:${face.style};` : ""}font-display:block;src:url("${base}${file}") format("${format}")}`;
243
+ });
244
+ const variables = faces
245
+ .filter((face) => face.variable)
246
+ .map(
247
+ (face) =>
248
+ `${face.variable}:"${face.family}"${face.fallback ? `,${face.fallback}` : ""} !important`,
249
+ );
250
+ if (variables.length) lines.push(`:root{${variables.join(";")}}`);
251
+ lines.push(STILL_CSS);
252
+ return lines.join("\n");
253
+ }
254
+
255
+ /** Runs before any page script: a seeded Math.random, the font CSS, and a channel tap. */
256
+ function pageInit({ css, families }) {
257
+ let seed = 0x2f6b9a1d;
258
+ Math.random = () => {
259
+ seed = (seed + 0x6d2b79f5) | 0;
260
+ let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
261
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
262
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
263
+ };
264
+ if (globalThis.crypto) {
265
+ let n = 0;
266
+ crypto.randomUUID = () =>
267
+ `00000000-0000-4000-8000-${(++n).toString(16).padStart(12, "0")}`;
268
+ crypto.getRandomValues = (array) => {
269
+ for (let i = 0; i < array.length; i += 1) {
270
+ array[i] = Math.floor(Math.random() * 256);
271
+ }
272
+ return array;
273
+ };
274
+ }
275
+ const kit = { outcome: null, detail: "", pending: 0 };
276
+ window.__kit = kit;
277
+ // In-flight requests, so settling can wait for them rather than a timer.
278
+ const track = (promise) => {
279
+ kit.pending += 1;
280
+ return promise.finally(() => {
281
+ kit.pending -= 1;
282
+ });
283
+ };
284
+ if (window.fetch) {
285
+ const fetch = window.fetch;
286
+ window.fetch = function (...args) {
287
+ return track(fetch.apply(this, args));
288
+ };
289
+ }
290
+ const send = XMLHttpRequest.prototype.send;
291
+ XMLHttpRequest.prototype.send = function (...args) {
292
+ track(
293
+ new Promise((done) =>
294
+ this.addEventListener("loadend", done, { once: true }),
295
+ ),
296
+ );
297
+ return send.apply(this, args);
298
+ };
299
+ const finish = (outcome, detail) => {
300
+ if (!kit.outcome) {
301
+ kit.outcome = outcome;
302
+ kit.detail = String(detail ?? "").slice(0, 500);
303
+ }
304
+ };
305
+ const describe = (value) =>
306
+ value && typeof value === "object"
307
+ ? (value.description ??
308
+ value.message ??
309
+ value.title ??
310
+ JSON.stringify(value))
311
+ : value;
312
+ let channel;
313
+ Object.defineProperty(window, "__STORYBOOK_ADDONS_CHANNEL__", {
314
+ configurable: true,
315
+ get: () => channel,
316
+ set(value) {
317
+ channel = value;
318
+ value.on("storyRendered", () => finish("rendered"));
319
+ value.on("storyErrored", (e) => finish("threw", describe(e)));
320
+ value.on("storyThrewException", (e) => finish("threw", describe(e)));
321
+ value.on("playFunctionThrewException", (e) =>
322
+ finish("threw", `play: ${describe(e)}`),
323
+ );
324
+ value.on("storyMissing", () => finish("missing"));
325
+ },
326
+ });
327
+ window.__kitLoaded = new WeakSet();
328
+ // `load` never propagates through window (DOM spec), so listen on document.
329
+ document.addEventListener(
330
+ "load",
331
+ (event) => {
332
+ if (event.target?.localName === "iframe")
333
+ window.__kitLoaded.add(event.target);
334
+ },
335
+ true,
336
+ );
337
+ document.addEventListener("DOMContentLoaded", () => {
338
+ const style = document.createElement("style");
339
+ style.setAttribute("data-kit-inject", "");
340
+ style.textContent = css;
341
+ document.head.append(style);
342
+ for (const family of families) document.fonts.load(`400 16px "${family}"`);
343
+ });
344
+ }
345
+
346
+ /**
347
+ * Waits for fonts, frame and image loads, no pending requests and a quiet
348
+ * DOM, bounded. Answers whether it
349
+ * settled and whether anything changed while it waited.
350
+ */
351
+ function settle({ frames, quietMs, timeout }) {
352
+ let mutated = false;
353
+ return Promise.race([
354
+ (async () => {
355
+ await document.fonts.ready;
356
+ // Frames report their size after they load; wait for every one.
357
+ await Promise.all(
358
+ [...document.querySelectorAll("iframe")].map((frame) =>
359
+ window.__kitLoaded?.has(frame)
360
+ ? null
361
+ : new Promise((done) =>
362
+ frame.addEventListener("load", done, { once: true }),
363
+ ),
364
+ ),
365
+ );
366
+ await Promise.all(
367
+ [...document.images].map((image) =>
368
+ image.complete
369
+ ? null
370
+ : new Promise((done) => {
371
+ image.addEventListener("load", done, { once: true });
372
+ image.addEventListener("error", done, { once: true });
373
+ }),
374
+ ),
375
+ );
376
+ // Quiet means no request in flight and no DOM change for `frames`
377
+ // frames AND `quietMs` ms: a loaded machine paints slowly, so frames
378
+ // alone can pass before the async work (timers, postMessage) lands.
379
+ await new Promise((done) => {
380
+ let quiet = 0;
381
+ let since = performance.now();
382
+ const observer = new MutationObserver(() => {
383
+ mutated = true;
384
+ quiet = 0;
385
+ since = performance.now();
386
+ });
387
+ observer.observe(document.documentElement, {
388
+ subtree: true,
389
+ childList: true,
390
+ attributes: true,
391
+ characterData: true,
392
+ });
393
+ const tick = () => {
394
+ if (window.__kit?.pending > 0) {
395
+ quiet = 0;
396
+ since = performance.now();
397
+ } else quiet += 1;
398
+ if (quiet >= frames && performance.now() - since >= quietMs) {
399
+ observer.disconnect();
400
+ done();
401
+ } else requestAnimationFrame(tick);
402
+ };
403
+ requestAnimationFrame(tick);
404
+ });
405
+ return { settled: true, mutated };
406
+ })(),
407
+ new Promise((done) =>
408
+ setTimeout(() => done({ settled: false, mutated: true }), timeout),
409
+ ),
410
+ ]);
411
+ }
412
+
413
+ /**
414
+ * In the page: the story's parameters, the head's style sheets, and the body
415
+ * serialized with scaffolding and scripts removed and portals kept.
416
+ */
417
+ async function extract() {
418
+ const SCAFFOLD =
419
+ ".sb-preparing-story,.sb-preparing-docs,.sb-nopreview,.sb-errordisplay,#storybook-docs,script,noscript,template,link,[data-kit-inject]";
420
+ const story = window.__STORYBOOK_PREVIEW__?.currentRender?.story;
421
+ const parameters = story?.parameters ?? {};
422
+ const viewport = parameters.viewport ?? {};
423
+ let declared = null;
424
+ const name = viewport.defaultViewport;
425
+ if (name && name !== "responsive") {
426
+ const custom = viewport.viewports?.[name]?.styles;
427
+ declared = custom
428
+ ? {
429
+ name,
430
+ width: parseInt(custom.width, 10),
431
+ height: parseInt(custom.height, 10),
432
+ }
433
+ : { name };
434
+ }
435
+
436
+ const sheets = [];
437
+ for (const sheet of document.styleSheets) {
438
+ const owner = sheet.ownerNode;
439
+ if (
440
+ owner &&
441
+ (owner.hasAttribute?.("data-kit-inject") || document.body.contains(owner))
442
+ ) {
443
+ continue;
444
+ }
445
+ let rules;
446
+ try {
447
+ rules = [...sheet.cssRules].map((rule) => rule.cssText);
448
+ } catch {
449
+ continue;
450
+ }
451
+ const base = sheet.href ?? location.href;
452
+ const absolute = rules.map((text) =>
453
+ text.replace(/url\(\s*(["']?)([^"')]+)\1\s*\)/g, (whole, q, url) =>
454
+ url.startsWith("data:") ? whole : `url("${new URL(url, base).href}")`,
455
+ ),
456
+ );
457
+ const key = sheet.href
458
+ ? new URL(sheet.href).pathname
459
+ : `inline:${absolute.join("\n")}`;
460
+ sheets.push({ key, rules: absolute });
461
+ }
462
+
463
+ // Storybook 7 and later render into #storybook-root, Storybook 6 into #root.
464
+ const root =
465
+ document.getElementById("storybook-root") ??
466
+ document.getElementById("root");
467
+ const live = [...document.body.querySelectorAll("*")];
468
+ const clone = document.body.cloneNode(true);
469
+ const copies = [...clone.querySelectorAll("*")];
470
+ const toData = async (url) => {
471
+ try {
472
+ const blob = await (await fetch(url)).blob();
473
+ if (blob.size > 512 * 1024) return null;
474
+ return await new Promise((done) => {
475
+ const reader = new FileReader();
476
+ reader.onload = () => done(reader.result);
477
+ reader.onerror = () => done(null);
478
+ reader.readAsDataURL(blob);
479
+ });
480
+ } catch {
481
+ return null;
482
+ }
483
+ };
484
+ for (let i = 0; i < live.length; i += 1) {
485
+ const element = live[i];
486
+ const copy = copies[i];
487
+ const tag = element.localName;
488
+ if (tag === "input") {
489
+ if (element.type === "checkbox" || element.type === "radio") {
490
+ copy.toggleAttribute("checked", element.checked);
491
+ } else if (element.type !== "file" && element.type !== "password") {
492
+ copy.setAttribute("value", element.value);
493
+ }
494
+ } else if (tag === "textarea") {
495
+ copy.textContent = element.value;
496
+ } else if (tag === "option") {
497
+ copy.toggleAttribute("selected", element.selected);
498
+ } else if (tag === "canvas") {
499
+ try {
500
+ const image = document.createElement("img");
501
+ for (const attr of element.attributes)
502
+ image.setAttribute(attr.name, attr.value);
503
+ image.setAttribute("src", element.toDataURL("image/png"));
504
+ image.setAttribute("width", String(element.clientWidth));
505
+ image.setAttribute("height", String(element.clientHeight));
506
+ copy.replaceWith(image);
507
+ } catch {
508
+ // A tainted canvas stays an empty canvas.
509
+ }
510
+ } else if (tag === "iframe") {
511
+ copy.removeAttribute("src");
512
+ copy.removeAttribute("srcdoc");
513
+ } else if (tag === "img" && element.getAttribute("src")) {
514
+ const src = element.currentSrc || element.src;
515
+ if (src && new URL(src).origin === location.origin) {
516
+ const data = await toData(src);
517
+ if (data) copy.setAttribute("src", data);
518
+ else copy.removeAttribute("src");
519
+ copy.removeAttribute("srcset");
520
+ }
521
+ }
522
+ for (const attr of [...(copy.attributes ?? [])]) {
523
+ if (attr.name.startsWith("on")) copy.removeAttribute(attr.name);
524
+ }
525
+ }
526
+ for (const node of clone.querySelectorAll(SCAFFOLD)) node.remove();
527
+ // Attribute order follows the order code set them in, which can race;
528
+ // sort it so the markup depends only on the final state.
529
+ for (const element of clone.querySelectorAll("*")) {
530
+ const attrs = [...element.attributes].map((attr) => [
531
+ attr.name,
532
+ attr.value,
533
+ ]);
534
+ if (attrs.length < 2) continue;
535
+ for (const [name] of attrs) element.removeAttribute(name);
536
+ attrs.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
537
+ for (const [name, value] of attrs) element.setAttribute(name, value);
538
+ }
539
+ const html = clone.innerHTML.trim();
540
+
541
+ const rootHasContent =
542
+ !!root &&
543
+ (root.children.length > 0 || (root.textContent ?? "").trim().length > 0);
544
+ const portalHasContent = [...clone.children].some(
545
+ (node) =>
546
+ node.id !== "storybook-root" &&
547
+ node.id !== "root" &&
548
+ (node.textContent ?? "").trim().length > 0,
549
+ );
550
+ const bodyClass = [...document.body.classList]
551
+ .filter((name) => name === "sb-show-main" || name.startsWith("sb-main-"))
552
+ .join(" ");
553
+ return {
554
+ html,
555
+ sheets,
556
+ declared,
557
+ bodyClass,
558
+ empty: !rootHasContent && !portalHasContent,
559
+ };
560
+ }
561
+
562
+ /** Capture phases, timed per capture for the run's breakdown. */
563
+ export const PHASES = ["boot", "render", "settle", "serialize", "close"];
564
+
565
+ /** One capture, with the milliseconds it spent in each phase. */
566
+ async function captureOne(args) {
567
+ const timing = Object.fromEntries(PHASES.map((name) => [name, 0]));
568
+ let phase = "boot";
569
+ let last = Date.now();
570
+ const step = (next) => {
571
+ const now = Date.now();
572
+ timing[phase] += now - last;
573
+ last = now;
574
+ phase = next;
575
+ };
576
+ const out = await captureTimed(args, step);
577
+ step("close");
578
+ return { ...out, timing };
579
+ }
580
+
581
+ /**
582
+ * Pages waiting for their next story, per viewport. A page is reused only
583
+ * after a capture that succeeded, and starts each story from about:blank
584
+ * with the origin's storage (session storage included) cleared and
585
+ * window.name reset, so a story sees what a fresh context would show it.
586
+ * At most `parkLimit` pages wait at once (the lane count); the rest close.
587
+ */
588
+ const idlePages = new Map();
589
+ let parkLimit = 4;
590
+ const parkedCount = () =>
591
+ [...idlePages.values()].reduce((sum, list) => sum + list.length, 0);
592
+
593
+ async function freshPage({
594
+ browser,
595
+ origin,
596
+ viewport,
597
+ css,
598
+ families,
599
+ fixedTime,
600
+ }) {
601
+ const context = await browser.newContext({
602
+ viewport: { width: viewport.width, height: viewport.height },
603
+ deviceScaleFactor: 1,
604
+ colorScheme: "light",
605
+ reducedMotion: "reduce",
606
+ timezoneId: "UTC",
607
+ locale: "en-US",
608
+ });
609
+ await context.route("**/*", (route) =>
610
+ route.request().url().startsWith(origin) ||
611
+ route.request().url().startsWith("data:")
612
+ ? route.continue()
613
+ : route.abort(),
614
+ );
615
+ const page = await context.newPage();
616
+ page.on("pageerror", () => {});
617
+ await page.clock.setFixedTime(new Date(fixedTime));
618
+ await page.addInitScript(pageInit, { css, families });
619
+ return { context, page, cdp: await context.newCDPSession(page) };
620
+ }
621
+
622
+ async function captureTimed(
623
+ { browser, origin, story, viewport, timeout, css, families, fixedTime },
624
+ step,
625
+ ) {
626
+ const key = `${viewport.width}x${viewport.height}`;
627
+ const slot =
628
+ idlePages.get(key)?.pop() ??
629
+ (await freshPage({
630
+ browser,
631
+ origin,
632
+ viewport,
633
+ css,
634
+ families,
635
+ fixedTime,
636
+ }));
637
+ const { context, page, cdp } = slot;
638
+ let reusable = false;
639
+ try {
640
+ await cdp.send("Storage.clearDataForOrigin", {
641
+ origin,
642
+ storageTypes: "all",
643
+ });
644
+ step("render");
645
+ const deadline = Date.now() + timeout;
646
+ // `a11y.manual` keeps @storybook/addon-a11y from running axe after every
647
+ // render (when a preview sets `a11y.test`). Axe reads the DOM without
648
+ // changing it, so the markup is the same; it was a quarter of the CPU.
649
+ await page.goto(
650
+ `${origin}/iframe.html?id=${encodeURIComponent(story.id)}&viewMode=story&globals=a11y.manual:!true`,
651
+ { waitUntil: "domcontentloaded", timeout },
652
+ );
653
+ const outcome = await page
654
+ .waitForFunction(() => window.__kit?.outcome, null, {
655
+ timeout: Math.max(1000, deadline - Date.now()),
656
+ polling: 50,
657
+ })
658
+ .then(() => page.evaluate(() => ({ ...window.__kit })))
659
+ .catch(() => ({
660
+ outcome: "timeout",
661
+ detail: `no storyRendered within ${timeout}ms`,
662
+ }));
663
+ if (outcome.outcome !== "rendered") {
664
+ return {
665
+ failure: {
666
+ reason: outcome.outcome === "missing" ? "missing" : outcome.outcome,
667
+ detail: outcome.detail,
668
+ },
669
+ };
670
+ }
671
+ // Settling has its own budget, so a slow render under load does not eat
672
+ // into it. A story whose DOM is still changing when it runs out has no
673
+ // reproducible snapshot (a live ticker, say), so it is a gap, not a row.
674
+ step("settle");
675
+ const quiet = { frames: QUIET_FRAMES, quietMs, timeout };
676
+ // Sandboxed frames render in their own process, so the host can look
677
+ // quiet while a guest has yet to report its size, and a host may bring in
678
+ // guests one after another. Settle the guests, then the host, until a
679
+ // round brings no new frame and no host change.
680
+ const settleBy = Date.now() + timeout;
681
+ let settled = (await page.evaluate(settle, quiet)).settled;
682
+ let known = new Set();
683
+ while (settled) {
684
+ const guests = page
685
+ .frames()
686
+ .filter((frame) => frame !== page.mainFrame());
687
+ const fresh = guests.some((frame) => !known.has(frame));
688
+ if (!fresh && known.size === 0) break;
689
+ known = new Set(guests);
690
+ const results = await Promise.all(
691
+ // A frame that navigated away or detached has nothing left to wait for.
692
+ guests.map((frame) =>
693
+ frame.evaluate(settle, quiet).catch(() => ({ settled: true })),
694
+ ),
695
+ );
696
+ const host = await page.evaluate(settle, quiet);
697
+ settled =
698
+ results.every((result) => result.settled) &&
699
+ host.settled &&
700
+ Date.now() < settleBy;
701
+ if (!fresh && !host.mutated) break;
702
+ }
703
+ if (!settled) {
704
+ return {
705
+ failure: {
706
+ reason: "timeout",
707
+ detail: `DOM still changing ${timeout}ms after storyRendered`,
708
+ },
709
+ };
710
+ }
711
+ step("serialize");
712
+ const result = await page.evaluate(extract);
713
+ // An inline sheet is keyed by its text, which resolved its url()s against
714
+ // this run's server; the port must not reach the kit.
715
+ for (const sheet of result.sheets)
716
+ sheet.key = sheet.key.split(origin).join("");
717
+ if (result.empty) {
718
+ return {
719
+ failure: {
720
+ reason: "empty root",
721
+ detail: "nothing rendered in the root or a portal",
722
+ },
723
+ };
724
+ }
725
+ reusable = true;
726
+ return { result };
727
+ } catch (error) {
728
+ return {
729
+ failure: {
730
+ reason: /Timeout/i.test(String(error)) ? "timeout" : "threw",
731
+ detail: String(error?.message ?? error)
732
+ .split("\n")[0]
733
+ .slice(0, 500),
734
+ },
735
+ };
736
+ } finally {
737
+ step("close");
738
+ const parked =
739
+ reusable &&
740
+ parkedCount() < parkLimit &&
741
+ (await page
742
+ // Session storage lives with the tab, not the origin's data, so it
743
+ // is cleared on the story's own page before leaving it.
744
+ .evaluate(() => sessionStorage.clear())
745
+ .then(() => page.goto("about:blank"))
746
+ .then(() =>
747
+ page.evaluate(() => {
748
+ window.name = "";
749
+ }),
750
+ )
751
+ .then(() => true)
752
+ .catch(() => false));
753
+ if (parked) {
754
+ const list = idlePages.get(key) ?? [];
755
+ list.push(slot);
756
+ idlePages.set(key, list);
757
+ } else await context.close().catch(() => {});
758
+ }
759
+ }
760
+
761
+ /**
762
+ * Where the lanes' time went: per phase over all captures, and the time
763
+ * failed captures spent before they failed, by reason.
764
+ */
765
+ export function timingBreakdown(captures) {
766
+ const phases = Object.fromEntries(PHASES.map((name) => [name, 0]));
767
+ const failed = {};
768
+ let ok = 0;
769
+ for (const { timing, failure } of captures) {
770
+ const total = PHASES.reduce((sum, name) => sum + (timing?.[name] ?? 0), 0);
771
+ for (const name of PHASES) phases[name] += timing?.[name] ?? 0;
772
+ if (failure) {
773
+ const row = (failed[failure.reason] ??= { count: 0, ms: 0 });
774
+ row.count += 1;
775
+ row.ms += total;
776
+ } else ok += 1;
777
+ }
778
+ return { captures: captures.length, ok, phases_ms: phases, failed };
779
+ }
780
+
781
+ /** Runs `work` over `items` with at most `limit` in flight. */
782
+ async function pool(items, limit, work) {
783
+ let next = 0;
784
+ const runners = Array.from(
785
+ { length: Math.min(limit, items.length) },
786
+ async () => {
787
+ while (next < items.length) {
788
+ const index = next++;
789
+ await work(items[index], index);
790
+ }
791
+ },
792
+ );
793
+ await Promise.all(runners);
794
+ }
795
+
796
+ function viewportFor(declared) {
797
+ if (!declared) return null;
798
+ if (declared.width && declared.height) return declared;
799
+ return NAMED_VIEWPORTS[declared.name] ?? null;
800
+ }
801
+
802
+ function storybookStories(buildDir) {
803
+ const index = JSON.parse(readFileSync(join(buildDir, "index.json"), "utf8"));
804
+ return Object.values(index.entries ?? {})
805
+ .filter((entry) => entry.type === "story")
806
+ .sort((a, b) => (a.id < b.id ? -1 : 1));
807
+ }
808
+
809
+ /**
810
+ * Component files nothing documents: files under `gaps.sourceRoot` whose path
811
+ * from the Storybook project matches `gaps.componentGlob`, minus stories,
812
+ * tests and specs, that no story names as its component or import. Paths in
813
+ * the rows are relative to the config file's folder.
814
+ */
815
+ export function componentsWithoutStories(stories, { gaps, root, configDir }) {
816
+ const documented = new Set(
817
+ stories
818
+ .flatMap((s) => [s.componentPath, s.importPath])
819
+ .filter(Boolean)
820
+ .map((path) =>
821
+ path.replace(/^\.\//, "").replace(/\.stories\.([cm]?[jt]sx?)$/, ".$1"),
822
+ ),
823
+ );
824
+ if (!gaps) return [];
825
+ const pattern = globToRegExp(gaps.componentGlob);
826
+ const out = [];
827
+ const walk = (dir) => {
828
+ for (const name of readdirSync(dir, { withFileTypes: true })) {
829
+ const path = join(dir, name.name);
830
+ if (name.isDirectory()) {
831
+ if (name.name !== "node_modules") walk(path);
832
+ } else if (!/\.(stories|test|spec)\.[cm]?[jt]sx?$/.test(name.name)) {
833
+ const rel = relative(root, path).split(sep).join("/");
834
+ if (matchFile(pattern, rel) && !documented.has(rel)) {
835
+ out.push({
836
+ component: relative(configDir, path).split(sep).join("/"),
837
+ reason: "no story",
838
+ });
839
+ }
840
+ }
841
+ }
842
+ };
843
+ if (existsSync(gaps.sourceRoot)) walk(gaps.sourceRoot);
844
+ return out;
845
+ }
846
+
847
+ /** The commit the kit was built from, or "unknown" outside a git checkout. */
848
+ function gitSha(cwd) {
849
+ try {
850
+ return execFileSync("git", ["rev-parse", "HEAD"], {
851
+ cwd,
852
+ encoding: "utf8",
853
+ stdio: ["ignore", "pipe", "ignore"],
854
+ }).trim();
855
+ } catch {
856
+ return "unknown";
857
+ }
858
+ }
859
+
860
+ /** Font files a captured sheet loads from the capture server, by URL. */
861
+ const FONT_URL = /url\("([^"]+\.(?:woff2?|ttf|otf|eot))(?:[?#][^"]*)?"\)/gi;
862
+
863
+ /**
864
+ * Without configured fonts, the preview's own `@font-face` files travel in
865
+ * the kit: each one the build serves is copied to `fonts/` and the rule
866
+ * points at `../fonts/<name>`.
867
+ */
868
+ function collectFonts(rules, { origin, buildDir, out, copied }) {
869
+ return rules.map((rule) =>
870
+ rule.replace(FONT_URL, (whole, url) => {
871
+ if (!url.startsWith(`${origin}/`)) return whole;
872
+ const path = decodeURIComponent(new URL(url).pathname);
873
+ const file = resolve(buildDir, `.${path}`);
874
+ if (!file.startsWith(buildDir + sep) || !existsSync(file)) return whole;
875
+ const name = file.split(sep).pop();
876
+ if (!copied.has(name)) {
877
+ copyFileSync(file, join(out, "fonts", name));
878
+ copied.add(name);
879
+ }
880
+ return `url("../fonts/${name}")`;
881
+ }),
882
+ );
883
+ }
884
+
885
+ /**
886
+ * Captures the stories half of a kit.
887
+ *
888
+ * `config` is a loaded design-kit.config.json (see config.mjs); `options`
889
+ * carries the command line: `out`, `storybookDir`, `only` (story ids) and
890
+ * `base` (a previous kit to copy the other rows from).
891
+ */
892
+ export async function captureStories(config, options = {}) {
893
+ const stories_ = config.stories;
894
+ if (!stories_) {
895
+ throw new Error(
896
+ "design-kit.config.json has no stories section; add one to capture stories",
897
+ );
898
+ }
899
+ const log = options.log ?? console.log;
900
+ const started = Date.now();
901
+ const cpuStart = process.cpuUsage();
902
+ const tmp = process.env.TMPDIR ?? tmpdir();
903
+ const out = resolve(options.out ?? config.out ?? join(tmp, "design-kit"));
904
+ const concurrency = Math.max(
905
+ 1,
906
+ Number.parseInt(process.env.DESIGN_KIT_CONCURRENCY ?? "4", 10) || 4,
907
+ );
908
+ parkLimit = concurrency;
909
+ const timeout =
910
+ Number.parseInt(process.env.DESIGN_KIT_STORY_TIMEOUT_MS ?? "15000", 10) ||
911
+ 15000;
912
+
913
+ const root = stories_.root;
914
+ const fonts = stories_.fonts;
915
+ const fixedTime = stories_.fixedTime;
916
+ const families = (fonts?.faces ?? []).map((face) => face.family);
917
+ const planning = { viewport: stories_.viewport, widths: stories_.widths };
918
+ const fontNames = fontFiles(fonts);
919
+
920
+ let buildDir = options.storybookDir
921
+ ? resolve(options.storybookDir)
922
+ : stories_.storybookDir;
923
+ let ownedBuild = null;
924
+ let server = null;
925
+ let browser = null;
926
+ let buildSeconds = 0;
927
+ try {
928
+ if (!buildDir && stories_.buildCommand) {
929
+ // The configured build writes `staticDir`; a build with no staticDir
930
+ // goes to a temporary folder, removed afterwards ({out} names it).
931
+ if (stories_.staticDir) buildDir = stories_.staticDir;
932
+ else {
933
+ ownedBuild = mkdtempSync(join(tmp, "design-kit-storybook-"));
934
+ buildDir = ownedBuild;
935
+ }
936
+ const command = stories_.buildCommand.split("{out}").join(buildDir);
937
+ log(`building Storybook: ${command}`);
938
+ const built = spawnSync(command, {
939
+ cwd: root,
940
+ shell: true,
941
+ stdio: "inherit",
942
+ env: { ...process.env, STORYBOOK_DISABLE_TELEMETRY: "1" },
943
+ });
944
+ if (built.status !== 0) {
945
+ throw new Error(`the Storybook build exited ${built.status}`);
946
+ }
947
+ buildSeconds = (Date.now() - started) / 1000;
948
+ }
949
+ if (!buildDir) buildDir = stories_.staticDir;
950
+ if (!buildDir || !existsSync(join(buildDir, "index.json"))) {
951
+ throw new Error(
952
+ `no Storybook static build${buildDir ? ` at ${buildDir} (it has no index.json)` : ""}: pass --storybook-dir, or set stories.buildCommand and stories.staticDir`,
953
+ );
954
+ }
955
+
956
+ const stories = storybookStories(buildDir);
957
+ const only = options.only ? new Set(options.only) : null;
958
+ if (only) {
959
+ const known = new Set(stories.map((s) => s.id));
960
+ const missing = [...only].filter((id) => !known.has(id));
961
+ if (missing.length)
962
+ throw new Error(`--only: no such story ${missing.join(", ")}`);
963
+ }
964
+ const targets = only ? stories.filter((s) => only.has(s.id)) : stories;
965
+
966
+ server = await serve(buildDir, fonts ? fonts.dir : null);
967
+ const origin = `http://127.0.0.1:${server.address().port}`;
968
+ const css = fontCss(`${origin}/__fonts/`, fonts, fontNames);
969
+ const playwright = loadPlaywright(config.configDir);
970
+ browser = await playwright.chromium.launch();
971
+
972
+ // One task per story; it can add a second capture (390) or move to the
973
+ // story's declared viewport once the story's parameters are known.
974
+ const captures = new Map(); // kit id -> {story, viewport, result}
975
+ const failures = [];
976
+ const attempts = []; // every capture's timing, kept or not
977
+ let done = 0;
978
+ await pool(targets, concurrency, async (story) => {
979
+ const first = await captureOne({
980
+ browser,
981
+ origin,
982
+ story,
983
+ viewport: planning.viewport,
984
+ timeout,
985
+ css,
986
+ families,
987
+ fixedTime,
988
+ });
989
+ attempts.push(first);
990
+ let viewports = plannedViewports(story.title, null, planning);
991
+ const results = new Map([[planning.viewport.width, first]]);
992
+ if (first.result) {
993
+ const declared = viewportFor(first.result.declared);
994
+ viewports = plannedViewports(story.title, declared, planning);
995
+ if (declared) {
996
+ results.delete(planning.viewport.width);
997
+ first.discarded = true;
998
+ }
999
+ }
1000
+ for (const viewport of viewports) {
1001
+ if (!results.has(viewport.width)) {
1002
+ const capture = await captureOne({
1003
+ browser,
1004
+ origin,
1005
+ story,
1006
+ viewport,
1007
+ timeout,
1008
+ css,
1009
+ families,
1010
+ fixedTime,
1011
+ });
1012
+ attempts.push(capture);
1013
+ results.set(viewport.width, capture);
1014
+ }
1015
+ const capture = results.get(viewport.width);
1016
+ const id = kitId(story.id, viewport.width);
1017
+ if (capture.failure) {
1018
+ failures.push({ id, ...capture.failure });
1019
+ } else {
1020
+ captures.set(id, { story, viewport, result: capture.result });
1021
+ }
1022
+ }
1023
+ done += 1;
1024
+ if (done % 50 === 0 || done === targets.length) {
1025
+ log(
1026
+ `captured ${done}/${targets.length} stories (${failures.length} failed)`,
1027
+ );
1028
+ }
1029
+ });
1030
+
1031
+ // ── assemble ───────────────────────────────────────────────────────────
1032
+ const baseIndex = options.base
1033
+ ? readJsonOr(join(options.base, "index.json"), [])
1034
+ : [];
1035
+ const baseGaps = options.base
1036
+ ? readJsonOr(join(options.base, "gaps.json"), [])
1037
+ : [];
1038
+ const baseSheets = options.base
1039
+ ? readJsonOr(join(options.base, "css/stories.sheets.json"), {
1040
+ order: [],
1041
+ sheets: {},
1042
+ })
1043
+ : { order: [], sheets: {} };
1044
+ const current = new Set(stories.map((s) => s.id));
1045
+ const recaptured = new Set(targets.map((s) => s.id));
1046
+ const keptFromBase = baseIndex.filter(
1047
+ (row) =>
1048
+ row.kind === "story" &&
1049
+ current.has(storyIdOf(row.id)) &&
1050
+ !recaptured.has(storyIdOf(row.id)),
1051
+ );
1052
+
1053
+ mkdirSync(join(out, "html"), { recursive: true });
1054
+ mkdirSync(join(out, "css"), { recursive: true });
1055
+ mkdirSync(join(out, "fonts"), { recursive: true });
1056
+
1057
+ const rows = [];
1058
+ const sheetOrder = [...baseSheets.order];
1059
+ const sheets = { ...baseSheets.sheets };
1060
+ for (const id of [...captures.keys()].sort()) {
1061
+ const { story, viewport, result } = captures.get(id);
1062
+ const file = kitFile(id);
1063
+ const html = `${result.html}\n`;
1064
+ writeFileSync(join(out, file), html);
1065
+ for (const sheet of result.sheets) sheets[sheet.key] ??= sheet.rules;
1066
+ mergeSheetOrder(
1067
+ sheetOrder,
1068
+ result.sheets.map((sheet) => sheet.key),
1069
+ );
1070
+ rows.push({
1071
+ id,
1072
+ kind: "story",
1073
+ title: `${story.title}/${story.name}`,
1074
+ ...(story.componentPath
1075
+ ? {
1076
+ component: story.componentPath
1077
+ .split("/")
1078
+ .pop()
1079
+ .replace(/\.[cm]?[jt]sx?$/, ""),
1080
+ }
1081
+ : {}),
1082
+ file,
1083
+ width: viewport.width,
1084
+ height: viewport.height,
1085
+ bytes: Buffer.byteLength(html),
1086
+ source_file: relative(config.configDir, join(root, story.importPath))
1087
+ .split(sep)
1088
+ .join("/"),
1089
+ ...(result.bodyClass ? { body_class: result.bodyClass } : {}),
1090
+ });
1091
+ }
1092
+ for (const row of keptFromBase) {
1093
+ if (resolve(options.base) !== resolve(out)) {
1094
+ copyFileSync(join(options.base, row.file), join(out, row.file));
1095
+ }
1096
+ rows.push(row);
1097
+ }
1098
+ rows.sort((a, b) => (a.id < b.id ? -1 : 1));
1099
+
1100
+ const keepIds = new Set(rows.map((row) => row.file.slice("html/".length)));
1101
+ for (const name of readdirSync(join(out, "html"))) {
1102
+ if (name.startsWith("story--") && !keepIds.has(name)) {
1103
+ rmSync(join(out, "html", name));
1104
+ }
1105
+ }
1106
+
1107
+ // Every sheet that resolved to the local server becomes a kit-relative
1108
+ // reference or is dropped; the kit never points at the capture server.
1109
+ const absolute = new RegExp(
1110
+ `url\\("${origin.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[^"]*"\\)`,
1111
+ "g",
1112
+ );
1113
+ // Configured fonts replace the preview's own faces; otherwise those
1114
+ // faces' files are copied into the kit.
1115
+ const copied = new Set();
1116
+ for (const key of Object.keys(sheets)) {
1117
+ const rules = fonts
1118
+ ? sheets[key]
1119
+ : collectFonts(sheets[key], { origin, buildDir, out, copied });
1120
+ sheets[key] = rules
1121
+ .filter(
1122
+ (rule) => !(rule.startsWith("@font-face") && rule.includes(origin)),
1123
+ )
1124
+ .map((rule) => rule.replace(absolute, "none"));
1125
+ }
1126
+ writeFileSync(
1127
+ join(out, "css/stories.css"),
1128
+ buildCss(fontCss("../fonts/", fonts, fontNames), sheetOrder, sheets),
1129
+ );
1130
+ writeFileSync(
1131
+ join(out, "css/stories.sheets.json"),
1132
+ `${JSON.stringify({ order: sheetOrder, sheets }, null, 1)}\n`,
1133
+ );
1134
+ for (const name of fontNames) {
1135
+ copyFileSync(join(fonts.dir, name), join(out, "fonts", name));
1136
+ }
1137
+ copyFileSync(join(here, "kit.mjs"), join(out, "kit.mjs"));
1138
+
1139
+ writeFileSync(
1140
+ join(out, "index.json"),
1141
+ `${JSON.stringify(rows, null, 1)}\n`,
1142
+ );
1143
+
1144
+ const storyGaps = [
1145
+ ...failures,
1146
+ ...baseGaps.filter(
1147
+ (row) =>
1148
+ row.id?.startsWith("story:") &&
1149
+ current.has(storyIdOf(row.id)) &&
1150
+ !recaptured.has(storyIdOf(row.id)),
1151
+ ),
1152
+ ];
1153
+ const gaps = mergeGaps(readJsonOr(join(out, "gaps.json"), []), [
1154
+ ...storyGaps,
1155
+ ...componentsWithoutStories(stories, {
1156
+ gaps: stories_.gaps,
1157
+ root,
1158
+ configDir: config.configDir,
1159
+ }),
1160
+ ]);
1161
+ writeFileSync(join(out, "gaps.json"), `${JSON.stringify(gaps, null, 1)}\n`);
1162
+
1163
+ const sha = gitSha(config.configDir);
1164
+ const cpu = process.cpuUsage(cpuStart);
1165
+ const wallSeconds = (Date.now() - started) / 1000;
1166
+ const manifest = mergeManifest(
1167
+ readJsonOr(join(out, "manifest.json"), null),
1168
+ {
1169
+ sha,
1170
+ builtAt: new Date().toISOString(),
1171
+ stories: {
1172
+ stories: stories.length,
1173
+ captured: rows.length,
1174
+ failed: storyGaps.length,
1175
+ recaptured: targets.length,
1176
+ ...(options.base ? { base: options.base } : {}),
1177
+ fixed_time: fixedTime,
1178
+ playwright: playwright.version,
1179
+ wall_seconds: Math.round(wallSeconds),
1180
+ build_seconds: Math.round(buildSeconds),
1181
+ },
1182
+ theme: config.theme,
1183
+ },
1184
+ );
1185
+ writeFileSync(
1186
+ join(out, "manifest.json"),
1187
+ `${JSON.stringify(manifest, null, 2)}\n`,
1188
+ );
1189
+
1190
+ log(
1191
+ [
1192
+ `design kit: ${out}`,
1193
+ `stories ${stories.length}, captured ${targets.length} this run, rows ${rows.length}, failed ${storyGaps.length}`,
1194
+ `wall ${wallSeconds.toFixed(1)}s (storybook build ${buildSeconds.toFixed(1)}s), capture-process CPU ${((cpu.user + cpu.system) / 1e6).toFixed(1)}s, concurrency ${concurrency}`,
1195
+ ].join("\n"),
1196
+ );
1197
+ const timings = {
1198
+ ...timingBreakdown(attempts),
1199
+ discarded: attempts.filter((a) => a.discarded).length,
1200
+ lanes: concurrency,
1201
+ quiet_ms: quietMs,
1202
+ timeout_ms: timeout,
1203
+ wall_ms: Date.now() - started,
1204
+ };
1205
+ log(`capture timings: ${JSON.stringify(timings)}`);
1206
+ if (process.env.DESIGN_KIT_TIMINGS_FILE)
1207
+ writeFileSync(
1208
+ process.env.DESIGN_KIT_TIMINGS_FILE,
1209
+ `${JSON.stringify(timings, null, 2)}\n`,
1210
+ );
1211
+ const byReason = {};
1212
+ for (const failure of storyGaps)
1213
+ byReason[failure.reason] = (byReason[failure.reason] ?? 0) + 1;
1214
+ if (storyGaps.length)
1215
+ log(`failures by reason: ${JSON.stringify(byReason)}`);
1216
+ return { out, rows: rows.length, failed: storyGaps.length };
1217
+ } finally {
1218
+ await browser?.close().catch(() => {});
1219
+ server?.close();
1220
+ if (ownedBuild) rmSync(ownedBuild, { recursive: true, force: true });
1221
+ }
1222
+ }
1223
+
1224
+ function readJsonOr(file, fallback) {
1225
+ return existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : fallback;
1226
+ }