@codepress/design-kit 0.5.0 → 0.6.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 +20 -4
- package/package.json +1 -1
- package/src/capture-stories.mjs +257 -46
package/README.md
CHANGED
|
@@ -252,7 +252,12 @@ temporary folder that is removed afterwards.
|
|
|
252
252
|
- `viewport`: each story's first capture, unless the story sets
|
|
253
253
|
`parameters.viewport.defaultViewport` (default 1280×800).
|
|
254
254
|
- `widths`: more captures for stories whose title matches the `titles`
|
|
255
|
-
regular expression.
|
|
255
|
+
regular expression. A story that only works at some widths (a phone-only
|
|
256
|
+
drawer whose `play` opens what the desktop layout never shows) lists its
|
|
257
|
+
own with `parameters: { designKit: { widths: [390] } }` (numbers or
|
|
258
|
+
`{width, height}`): it is captured at exactly those, and a width it leaves
|
|
259
|
+
out is neither a row nor a gap. A failure at a width it lists is still a
|
|
260
|
+
gap, and an empty or malformed list is ignored.
|
|
256
261
|
- `fonts`: `{dir, faces: [{family, file, weight?, style?, variable?,
|
|
257
262
|
fallback?}]}` gives every story these faces (`file` is a name glob in `dir`;
|
|
258
263
|
`variable` sets a CSS variable on `:root` for apps that read their font from
|
|
@@ -261,6 +266,11 @@ fallback?}]}` gives every story these faces (`file` is a name glob in `dir`;
|
|
|
261
266
|
- `fixedTime`: the instant `Date` returns (default `2026-01-01T12:00:00.000Z`).
|
|
262
267
|
- `gaps`: lists the component files no story covers in `gaps.json`.
|
|
263
268
|
`componentGlob` is relative to `root`; stories, tests and specs never count.
|
|
269
|
+
A file with nothing to show (a context provider, a controller, a hook
|
|
270
|
+
wrapper) says so with a comment, `// design-kit: no-story <reason>` (a block
|
|
271
|
+
or JSX comment works too): its row has reason `exempt` and the reason as
|
|
272
|
+
`detail`, so a report can list it apart. A marker with no reason exempts
|
|
273
|
+
nothing; the row stays `no story`.
|
|
264
274
|
|
|
265
275
|
### `pages`
|
|
266
276
|
|
|
@@ -362,12 +372,15 @@ both with the same `--out`.
|
|
|
362
372
|
Exit codes: 0 done, 1 a capture could not run (no Storybook build, the app
|
|
363
373
|
did not start, a forbidden string) or `init` found the repository already set
|
|
364
374
|
up, 2 a config or usage error. A story or page that fails is a
|
|
365
|
-
`gaps.json` row and does not fail the run.
|
|
375
|
+
`gaps.json` row and does not fail the run. That includes a story that holds
|
|
376
|
+
the page's main thread: every wait on its page has a timer outside the page,
|
|
377
|
+
so it becomes a `timeout` gap instead of stalling the run.
|
|
366
378
|
|
|
367
379
|
Operational knobs: `DESIGN_KIT_CONCURRENCY` (browser pages at once, default
|
|
368
380
|
4), `DESIGN_KIT_STORY_TIMEOUT_MS` (render and, separately, settle budget per
|
|
369
381
|
story, default 15000), `DESIGN_KIT_QUIET_MS` (how long the DOM must stay
|
|
370
|
-
unchanged, default 600), `
|
|
382
|
+
unchanged, default 600), `DESIGN_KIT_PARAMS_READ_MS` (how long a failed capture
|
|
383
|
+
waits to read the story's `designKit.widths`, default 1000), `DESIGN_KIT_TIMINGS_FILE` (per-phase capture times),
|
|
371
384
|
`DESIGN_KIT_STOP_TIMEOUT_MS` (how long stopping the app may take).
|
|
372
385
|
|
|
373
386
|
## Capture rules
|
|
@@ -378,7 +391,10 @@ unchanged, default 600), `DESIGN_KIT_TIMINGS_FILE` (per-phase capture times),
|
|
|
378
391
|
- A story is captured after Storybook's `storyRendered` event (its `play`
|
|
379
392
|
function has finished), fonts, frame and image loads, no request in flight,
|
|
380
393
|
and six frames plus `DESIGN_KIT_QUIET_MS` with no DOM change. A story that
|
|
381
|
-
throws, never renders or never stops changing is a gap.
|
|
394
|
+
throws, never renders or never stops changing is a gap. A story driven by a
|
|
395
|
+
live timer or a looping script can tell it is being captured from
|
|
396
|
+
`window.__DESIGN_KIT_CAPTURE__ === true`, set before any page script runs,
|
|
397
|
+
and render one still state of it instead.
|
|
382
398
|
- Attributes are serialized in sorted order, and React `useId` values are
|
|
383
399
|
renumbered in pages, so the markup depends on the final state, not on timing.
|
|
384
400
|
- Story captures only reach the local Storybook server. Same-origin images
|
package/package.json
CHANGED
package/src/capture-stories.mjs
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* DESIGN_KIT_CONCURRENCY caps the browser pages working at once (default 4);
|
|
7
7
|
* DESIGN_KIT_STORY_TIMEOUT_MS bounds each capture (default 15000);
|
|
8
8
|
* DESIGN_KIT_QUIET_MS is how long the DOM must stay unchanged (default 600);
|
|
9
|
+
* DESIGN_KIT_PARAMS_READ_MS bounds reading a failed story's widths (default 1000);
|
|
9
10
|
* DESIGN_KIT_TIMINGS_FILE, when set, receives the per-phase time breakdown.
|
|
10
11
|
* Needs Playwright's Chromium (`npx playwright install chromium`).
|
|
11
12
|
*/
|
|
@@ -45,6 +46,38 @@ const QUIET_FRAMES = 6;
|
|
|
45
46
|
*/
|
|
46
47
|
const quietMs =
|
|
47
48
|
Number.parseInt(process.env.DESIGN_KIT_QUIET_MS ?? "600", 10) || 0;
|
|
49
|
+
/**
|
|
50
|
+
* DESIGN_KIT_PARAMS_READ_MS: how long a failed capture waits to read the
|
|
51
|
+
* story's `designKit.widths` from the preview (default 1000).
|
|
52
|
+
*/
|
|
53
|
+
const paramsReadMs =
|
|
54
|
+
Number.parseInt(process.env.DESIGN_KIT_PARAMS_READ_MS ?? "1000", 10) || 1000;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The console prefix a story's page announces its own widths with at
|
|
58
|
+
* storyPrepared. A console message reaches node even when the story holds
|
|
59
|
+
* the main thread right after, which a read of the page never would.
|
|
60
|
+
*/
|
|
61
|
+
const WIDTHS_ANNOUNCEMENT = "design-kit:widths ";
|
|
62
|
+
|
|
63
|
+
/** A page that stopped answering: its main thread is held by the story. */
|
|
64
|
+
class Unresponsive extends Error {}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Settles with `promise`, or rejects with Unresponsive after `ms`.
|
|
68
|
+
* Playwright's evaluate has no timeout, and one set in the page cannot fire
|
|
69
|
+
* while a story holds the page's main thread, so every wait on a story's
|
|
70
|
+
* page goes through this node-side timer. The timer is cleared either way.
|
|
71
|
+
*/
|
|
72
|
+
function bounded(promise, ms, detail) {
|
|
73
|
+
let timer;
|
|
74
|
+
return Promise.race([
|
|
75
|
+
promise,
|
|
76
|
+
new Promise((_, fail) => {
|
|
77
|
+
timer = setTimeout(() => fail(new Unresponsive(detail)), ms);
|
|
78
|
+
}),
|
|
79
|
+
]).finally(() => clearTimeout(timer));
|
|
80
|
+
}
|
|
48
81
|
|
|
49
82
|
/** Storybook's built-in viewport names (minimal set plus the iPhones in use). */
|
|
50
83
|
const NAMED_VIEWPORTS = {
|
|
@@ -151,15 +184,50 @@ export function mergeManifest(existing, { sha, builtAt, stories, theme }) {
|
|
|
151
184
|
};
|
|
152
185
|
}
|
|
153
186
|
|
|
187
|
+
/**
|
|
188
|
+
* A story's own widths, from `parameters.designKit.widths`: numbers or
|
|
189
|
+
* `{width, height}` entries. Anything else, or an empty list, is no list,
|
|
190
|
+
* so a malformed parameter never drops a story.
|
|
191
|
+
*/
|
|
192
|
+
export function ownWidths(raw) {
|
|
193
|
+
if (!Array.isArray(raw)) return null;
|
|
194
|
+
const list = [];
|
|
195
|
+
for (const entry of raw) {
|
|
196
|
+
const width = typeof entry === "number" ? entry : entry?.width;
|
|
197
|
+
const height = typeof entry === "number" ? undefined : entry?.height;
|
|
198
|
+
if (!Number.isInteger(width) || width <= 0) continue;
|
|
199
|
+
if (list.some((planned) => planned.width === width)) continue;
|
|
200
|
+
list.push(
|
|
201
|
+
Number.isInteger(height) && height > 0 ? { width, height } : { width },
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
return list.length ? list : null;
|
|
205
|
+
}
|
|
206
|
+
|
|
154
207
|
/**
|
|
155
208
|
* Which captures a story gets: its declared viewport or the default, plus
|
|
156
209
|
* each configured width whose `titles` pattern matches the story's title.
|
|
210
|
+
* A story that lists its own widths (`parameters.designKit.widths`, see
|
|
211
|
+
* ownWidths) gets exactly those, each with its listed height, else the
|
|
212
|
+
* height the declared viewport, default or a configured width has for it,
|
|
213
|
+
* else the default height.
|
|
157
214
|
*/
|
|
158
215
|
export function plannedViewports(
|
|
159
216
|
title,
|
|
160
217
|
declared,
|
|
161
218
|
{ viewport = DEFAULT_VIEWPORT, widths = [] } = {},
|
|
219
|
+
own = null,
|
|
162
220
|
) {
|
|
221
|
+
if (own?.length) {
|
|
222
|
+
const known = [declared, viewport, ...widths].filter(Boolean);
|
|
223
|
+
return own.map(({ width, height }) => ({
|
|
224
|
+
width,
|
|
225
|
+
height:
|
|
226
|
+
height ??
|
|
227
|
+
known.find((size) => size.width === width && size.height)?.height ??
|
|
228
|
+
viewport.height,
|
|
229
|
+
}));
|
|
230
|
+
}
|
|
163
231
|
const first = declared ?? viewport;
|
|
164
232
|
const list = [first];
|
|
165
233
|
for (const extra of widths) {
|
|
@@ -276,7 +344,7 @@ export function fontCss(base, fonts, files = fontFiles(fonts)) {
|
|
|
276
344
|
}
|
|
277
345
|
|
|
278
346
|
/** Runs before any page script: a seeded Math.random, the font CSS, and a channel tap. */
|
|
279
|
-
function pageInit({ css, families }) {
|
|
347
|
+
function pageInit({ css, families, width, announcement }) {
|
|
280
348
|
let seed = 0x2f6b9a1d;
|
|
281
349
|
Math.random = () => {
|
|
282
350
|
seed = (seed + 0x6d2b79f5) | 0;
|
|
@@ -297,6 +365,9 @@ function pageInit({ css, families }) {
|
|
|
297
365
|
}
|
|
298
366
|
const kit = { outcome: null, detail: "", pending: 0 };
|
|
299
367
|
window.__kit = kit;
|
|
368
|
+
// Public: a story with live data or a running animation can render a
|
|
369
|
+
// still state of it when this is set (README "Capture rules").
|
|
370
|
+
window.__DESIGN_KIT_CAPTURE__ = true;
|
|
300
371
|
// In-flight requests, so settling can wait for them rather than a timer.
|
|
301
372
|
const track = (promise) => {
|
|
302
373
|
kit.pending += 1;
|
|
@@ -345,6 +416,25 @@ function pageInit({ css, families }) {
|
|
|
345
416
|
finish("threw", `play: ${describe(e)}`),
|
|
346
417
|
);
|
|
347
418
|
value.on("storyMissing", () => finish("missing"));
|
|
419
|
+
// A story that lists its own widths and leaves this one out is not
|
|
420
|
+
// captured here: no render, no play, no gap (ownWidths).
|
|
421
|
+
value.on("storyPrepared", (event) => {
|
|
422
|
+
const own = event?.parameters?.designKit?.widths;
|
|
423
|
+
if (!Array.isArray(own)) return;
|
|
424
|
+
kit.widths = own;
|
|
425
|
+
try {
|
|
426
|
+
console.debug(`${announcement}${JSON.stringify(own)}`);
|
|
427
|
+
} catch {
|
|
428
|
+
// A list that cannot be serialized is read from __kit instead.
|
|
429
|
+
}
|
|
430
|
+
// The same rule as ownWidths: positive integer widths only, and no
|
|
431
|
+
// usable width means no list, so a malformed one never skips.
|
|
432
|
+
const listed = own
|
|
433
|
+
.map((entry) => (typeof entry === "number" ? entry : entry?.width))
|
|
434
|
+
.filter((value) => Number.isInteger(value) && value > 0);
|
|
435
|
+
if (listed.length && !listed.includes(width))
|
|
436
|
+
finish("skipped", `not listed in designKit.widths`);
|
|
437
|
+
});
|
|
348
438
|
},
|
|
349
439
|
});
|
|
350
440
|
window.__kitLoaded = new WeakSet();
|
|
@@ -442,6 +532,7 @@ async function extract() {
|
|
|
442
532
|
".sb-preparing-story,.sb-preparing-docs,.sb-nopreview,.sb-errordisplay,#storybook-docs,script,noscript,template,link,[data-kit-inject]";
|
|
443
533
|
const story = window.__STORYBOOK_PREVIEW__?.currentRender?.story;
|
|
444
534
|
const parameters = story?.parameters ?? {};
|
|
535
|
+
const widths = parameters.designKit?.widths ?? null;
|
|
445
536
|
const viewport = parameters.viewport ?? {};
|
|
446
537
|
let declared = null;
|
|
447
538
|
const name = viewport.defaultViewport;
|
|
@@ -577,6 +668,7 @@ async function extract() {
|
|
|
577
668
|
html,
|
|
578
669
|
sheets,
|
|
579
670
|
declared,
|
|
671
|
+
widths,
|
|
580
672
|
bodyClass,
|
|
581
673
|
empty: !rootHasContent && !portalHasContent,
|
|
582
674
|
};
|
|
@@ -638,7 +730,12 @@ async function freshPage({
|
|
|
638
730
|
const page = await context.newPage();
|
|
639
731
|
page.on("pageerror", () => {});
|
|
640
732
|
await page.clock.setFixedTime(new Date(fixedTime));
|
|
641
|
-
await page.addInitScript(pageInit, {
|
|
733
|
+
await page.addInitScript(pageInit, {
|
|
734
|
+
css,
|
|
735
|
+
families,
|
|
736
|
+
width: viewport.width,
|
|
737
|
+
announcement: WIDTHS_ANNOUNCEMENT,
|
|
738
|
+
});
|
|
642
739
|
return { context, page, cdp: await context.newCDPSession(page) };
|
|
643
740
|
}
|
|
644
741
|
|
|
@@ -659,6 +756,20 @@ async function captureTimed(
|
|
|
659
756
|
}));
|
|
660
757
|
const { context, page, cdp } = slot;
|
|
661
758
|
let reusable = false;
|
|
759
|
+
// The widths this story announced, heard even if its page then stops
|
|
760
|
+
// answering (WIDTHS_ANNOUNCEMENT).
|
|
761
|
+
let announced;
|
|
762
|
+
const hear = (message) => {
|
|
763
|
+
if (message.type() !== "debug") return;
|
|
764
|
+
const text = message.text();
|
|
765
|
+
if (!text.startsWith(WIDTHS_ANNOUNCEMENT)) return;
|
|
766
|
+
try {
|
|
767
|
+
announced = JSON.parse(text.slice(WIDTHS_ANNOUNCEMENT.length));
|
|
768
|
+
} catch {
|
|
769
|
+
// Not ours after all; the bounded read below still runs.
|
|
770
|
+
}
|
|
771
|
+
};
|
|
772
|
+
page.on("console", hear);
|
|
662
773
|
try {
|
|
663
774
|
await cdp.send("Storage.clearDataForOrigin", {
|
|
664
775
|
origin,
|
|
@@ -673,18 +784,50 @@ async function captureTimed(
|
|
|
673
784
|
`${origin}/iframe.html?id=${encodeURIComponent(story.id)}&viewMode=story&globals=a11y.manual:!true`,
|
|
674
785
|
{ waitUntil: "domcontentloaded", timeout },
|
|
675
786
|
);
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
}
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
787
|
+
// The node-side timer bounds the wait too: Playwright's own timeout never
|
|
788
|
+
// fires while a story holds the page's main thread.
|
|
789
|
+
const notRendered = {
|
|
790
|
+
outcome: "timeout",
|
|
791
|
+
detail: `no storyRendered within ${timeout}ms`,
|
|
792
|
+
};
|
|
793
|
+
const outcome = await bounded(
|
|
794
|
+
page
|
|
795
|
+
.waitForFunction(() => window.__kit?.outcome, null, {
|
|
796
|
+
timeout: Math.max(1000, deadline - Date.now()),
|
|
797
|
+
polling: 50,
|
|
798
|
+
})
|
|
799
|
+
.then(() => page.evaluate(() => ({ ...window.__kit }))),
|
|
800
|
+
Math.max(1000, deadline - Date.now()) + 1000,
|
|
801
|
+
).catch(() => notRendered);
|
|
686
802
|
if (outcome.outcome !== "rendered") {
|
|
803
|
+
// The story's own widths: from storyPrepared, or read from the preview
|
|
804
|
+
// when this Storybook sends no parameters with it.
|
|
805
|
+
// Bounded: a story that holds the main thread (the usual reason for a
|
|
806
|
+
// timeout) would never answer, and the lane would never finish.
|
|
807
|
+
const own = ownWidths(
|
|
808
|
+
outcome.widths ??
|
|
809
|
+
announced ??
|
|
810
|
+
(await bounded(
|
|
811
|
+
page.evaluate(
|
|
812
|
+
() =>
|
|
813
|
+
window.__STORYBOOK_PREVIEW__?.currentRender?.story?.parameters
|
|
814
|
+
?.designKit?.widths ?? null,
|
|
815
|
+
),
|
|
816
|
+
paramsReadMs,
|
|
817
|
+
).catch(() => null)),
|
|
818
|
+
);
|
|
819
|
+
// Left out of the story's own widths: not a capture and not a gap.
|
|
820
|
+
// Any other failure stays one.
|
|
821
|
+
if (
|
|
822
|
+
outcome.outcome === "skipped" ||
|
|
823
|
+
(own && !own.some((entry) => entry.width === viewport.width))
|
|
824
|
+
) {
|
|
825
|
+
// A page that timed out may still be held: never park it.
|
|
826
|
+
reusable = outcome.outcome !== "timeout";
|
|
827
|
+
return { skipped: true, widths: own };
|
|
828
|
+
}
|
|
687
829
|
return {
|
|
830
|
+
widths: own,
|
|
688
831
|
failure: {
|
|
689
832
|
reason: outcome.outcome === "missing" ? "missing" : outcome.outcome,
|
|
690
833
|
detail: outcome.detail,
|
|
@@ -701,7 +844,12 @@ async function captureTimed(
|
|
|
701
844
|
// guests one after another. Settle the guests, then the host, until a
|
|
702
845
|
// round brings no new frame and no host change.
|
|
703
846
|
const settleBy = Date.now() + timeout;
|
|
704
|
-
|
|
847
|
+
// `settle` gives up in the page after `timeout`; a second more covers the
|
|
848
|
+
// round trip, so only a page that stopped answering reaches the bound.
|
|
849
|
+
const unanswered = `page stopped answering after storyRendered (${timeout}ms)`;
|
|
850
|
+
const settleOn = (target) =>
|
|
851
|
+
bounded(target.evaluate(settle, quiet), timeout + 1000, unanswered);
|
|
852
|
+
let settled = (await settleOn(page)).settled;
|
|
705
853
|
let known = new Set();
|
|
706
854
|
while (settled) {
|
|
707
855
|
const guests = page
|
|
@@ -711,12 +859,16 @@ async function captureTimed(
|
|
|
711
859
|
if (!fresh && known.size === 0) break;
|
|
712
860
|
known = new Set(guests);
|
|
713
861
|
const results = await Promise.all(
|
|
714
|
-
// A frame that navigated away or detached has nothing left to wait
|
|
862
|
+
// A frame that navigated away or detached has nothing left to wait
|
|
863
|
+
// for; one that stopped answering is a timeout like its host.
|
|
715
864
|
guests.map((frame) =>
|
|
716
|
-
frame
|
|
865
|
+
settleOn(frame).catch((error) => {
|
|
866
|
+
if (error instanceof Unresponsive) throw error;
|
|
867
|
+
return { settled: true };
|
|
868
|
+
}),
|
|
717
869
|
),
|
|
718
870
|
);
|
|
719
|
-
const host = await page
|
|
871
|
+
const host = await settleOn(page);
|
|
720
872
|
settled =
|
|
721
873
|
results.every((result) => result.settled) &&
|
|
722
874
|
host.settled &&
|
|
@@ -732,7 +884,11 @@ async function captureTimed(
|
|
|
732
884
|
};
|
|
733
885
|
}
|
|
734
886
|
step("serialize");
|
|
735
|
-
const result = await
|
|
887
|
+
const result = await bounded(
|
|
888
|
+
page.evaluate(extract),
|
|
889
|
+
timeout,
|
|
890
|
+
`page stopped answering while serializing (${timeout}ms)`,
|
|
891
|
+
);
|
|
736
892
|
// An inline sheet is keyed by its text, which resolved its url()s against
|
|
737
893
|
// this run's server; the port must not reach the kit.
|
|
738
894
|
for (const sheet of result.sheets)
|
|
@@ -746,38 +902,48 @@ async function captureTimed(
|
|
|
746
902
|
};
|
|
747
903
|
}
|
|
748
904
|
reusable = true;
|
|
749
|
-
return { result };
|
|
905
|
+
return { result, widths: ownWidths(result.widths ?? outcome.widths) };
|
|
750
906
|
} catch (error) {
|
|
751
907
|
return {
|
|
752
908
|
failure: {
|
|
753
|
-
reason:
|
|
909
|
+
reason:
|
|
910
|
+
error instanceof Unresponsive || /Timeout/i.test(String(error))
|
|
911
|
+
? "timeout"
|
|
912
|
+
: "threw",
|
|
754
913
|
detail: String(error?.message ?? error)
|
|
755
914
|
.split("\n")[0]
|
|
756
915
|
.slice(0, 500),
|
|
757
916
|
},
|
|
758
917
|
};
|
|
759
918
|
} finally {
|
|
919
|
+
page.off("console", hear);
|
|
760
920
|
step("close");
|
|
761
921
|
const parked =
|
|
762
922
|
reusable &&
|
|
763
923
|
parkedCount() < parkLimit &&
|
|
764
|
-
(await
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
924
|
+
(await bounded(
|
|
925
|
+
page
|
|
926
|
+
// Session storage lives with the tab, not the origin's data, so it
|
|
927
|
+
// is cleared on the story's own page before leaving it.
|
|
928
|
+
.evaluate(() => sessionStorage.clear())
|
|
929
|
+
.then(() => page.goto("about:blank", { timeout }))
|
|
930
|
+
.then(() =>
|
|
931
|
+
page.evaluate(() => {
|
|
932
|
+
window.name = "";
|
|
933
|
+
}),
|
|
934
|
+
),
|
|
935
|
+
timeout,
|
|
936
|
+
)
|
|
774
937
|
.then(() => true)
|
|
775
938
|
.catch(() => false));
|
|
776
939
|
if (parked) {
|
|
777
940
|
const list = idlePages.get(key) ?? [];
|
|
778
941
|
list.push(slot);
|
|
779
942
|
idlePages.set(key, list);
|
|
780
|
-
} else
|
|
943
|
+
} else {
|
|
944
|
+
// A context whose page is held is left to browser.close() at the end.
|
|
945
|
+
await bounded(context.close(), timeout).catch(() => {});
|
|
946
|
+
}
|
|
781
947
|
}
|
|
782
948
|
}
|
|
783
949
|
|
|
@@ -829,11 +995,32 @@ function storybookStories(buildDir) {
|
|
|
829
995
|
.sort((a, b) => (a.id < b.id ? -1 : 1));
|
|
830
996
|
}
|
|
831
997
|
|
|
998
|
+
/**
|
|
999
|
+
* The reason a `design-kit: no-story <reason>` comment gives (a `//`, block or
|
|
1000
|
+
* JSX comment anywhere in the file), or null when there is none or its reason
|
|
1001
|
+
* is empty: a bare marker exempts nothing.
|
|
1002
|
+
*/
|
|
1003
|
+
export function noStoryReason(source) {
|
|
1004
|
+
const marker =
|
|
1005
|
+
/(?:\/\/|\/\*|^[ \t]*\*)[ \t]*design-kit:[ \t]*no-story\b([^\n]*)/gm;
|
|
1006
|
+
for (const match of String(source).matchAll(marker)) {
|
|
1007
|
+
const reason = match[1]
|
|
1008
|
+
.replace(/\*\/.*$/, "")
|
|
1009
|
+
.replace(/[}\s]+$/, "")
|
|
1010
|
+
.trim();
|
|
1011
|
+
if (reason) return reason;
|
|
1012
|
+
}
|
|
1013
|
+
return null;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
832
1016
|
/**
|
|
833
1017
|
* Component files nothing documents: files under `gaps.sourceRoot` whose path
|
|
834
1018
|
* from the Storybook project matches `gaps.componentGlob`, minus stories,
|
|
835
1019
|
* tests and specs, that no story names as its component or import. Paths in
|
|
836
|
-
* the rows are relative to the config file's folder.
|
|
1020
|
+
* the rows are relative to the config file's folder. A file carrying a
|
|
1021
|
+
* `design-kit: no-story <reason>` comment (a context, a controller, a hook
|
|
1022
|
+
* wrapper) is an `exempt` row with that reason as its detail, listed apart
|
|
1023
|
+
* so a person can check the reason; with an empty reason it stays `no story`.
|
|
837
1024
|
*/
|
|
838
1025
|
export function componentsWithoutStories(stories, { gaps, root, configDir }) {
|
|
839
1026
|
const documented = new Set(
|
|
@@ -855,10 +1042,13 @@ export function componentsWithoutStories(stories, { gaps, root, configDir }) {
|
|
|
855
1042
|
} else if (!/\.(stories|test|spec)\.[cm]?[jt]sx?$/.test(name.name)) {
|
|
856
1043
|
const rel = relative(root, path).split(sep).join("/");
|
|
857
1044
|
if (matchFile(pattern, rel) && !documented.has(rel)) {
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
1045
|
+
const component = relative(configDir, path).split(sep).join("/");
|
|
1046
|
+
const exempt = noStoryReason(readFileSync(path, "utf8"));
|
|
1047
|
+
out.push(
|
|
1048
|
+
exempt
|
|
1049
|
+
? { component, reason: "exempt", detail: exempt }
|
|
1050
|
+
: { component, reason: "no story" },
|
|
1051
|
+
);
|
|
862
1052
|
}
|
|
863
1053
|
}
|
|
864
1054
|
}
|
|
@@ -1002,7 +1192,11 @@ export async function captureStories(config, options = {}) {
|
|
|
1002
1192
|
const keepSheetsOnce = sheetStore();
|
|
1003
1193
|
// What the timings report needs from a capture, without its result.
|
|
1004
1194
|
const attempt = (capture) => {
|
|
1005
|
-
const record = {
|
|
1195
|
+
const record = {
|
|
1196
|
+
timing: capture.timing,
|
|
1197
|
+
failure: capture.failure,
|
|
1198
|
+
skipped: capture.skipped,
|
|
1199
|
+
};
|
|
1006
1200
|
attempts.push(record);
|
|
1007
1201
|
return record;
|
|
1008
1202
|
};
|
|
@@ -1019,16 +1213,28 @@ export async function captureStories(config, options = {}) {
|
|
|
1019
1213
|
fixedTime,
|
|
1020
1214
|
});
|
|
1021
1215
|
const firstAttempt = attempt(first);
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1216
|
+
// The story's declared viewport and own widths are known once it has
|
|
1217
|
+
// loaded at the default one; that first capture is kept only when the
|
|
1218
|
+
// plan still has the default viewport in it.
|
|
1219
|
+
const declared = first.result ? viewportFor(first.result.declared) : null;
|
|
1220
|
+
const viewports = plannedViewports(
|
|
1221
|
+
story.title,
|
|
1222
|
+
declared,
|
|
1223
|
+
planning,
|
|
1224
|
+
first.widths,
|
|
1225
|
+
);
|
|
1226
|
+
const results = new Map();
|
|
1227
|
+
if (
|
|
1228
|
+
!first.skipped &&
|
|
1229
|
+
!declared &&
|
|
1230
|
+
viewports.some(
|
|
1231
|
+
(viewport) =>
|
|
1232
|
+
viewport.width === planning.viewport.width &&
|
|
1233
|
+
viewport.height === planning.viewport.height,
|
|
1234
|
+
)
|
|
1235
|
+
) {
|
|
1236
|
+
results.set(planning.viewport.width, first);
|
|
1237
|
+
} else if (!first.skipped) firstAttempt.discarded = true;
|
|
1032
1238
|
for (const viewport of viewports) {
|
|
1033
1239
|
if (!results.has(viewport.width)) {
|
|
1034
1240
|
const capture = await captureOne({
|
|
@@ -1046,6 +1252,7 @@ export async function captureStories(config, options = {}) {
|
|
|
1046
1252
|
}
|
|
1047
1253
|
const capture = results.get(viewport.width);
|
|
1048
1254
|
const id = kitId(story.id, viewport.width);
|
|
1255
|
+
if (capture.skipped) continue;
|
|
1049
1256
|
if (capture.failure) {
|
|
1050
1257
|
failures.push({ id, ...capture.failure });
|
|
1051
1258
|
} else {
|
|
@@ -1236,6 +1443,7 @@ export async function captureStories(config, options = {}) {
|
|
|
1236
1443
|
const timings = {
|
|
1237
1444
|
...timingBreakdown(attempts),
|
|
1238
1445
|
discarded: attempts.filter((a) => a.discarded).length,
|
|
1446
|
+
skipped: attempts.filter((a) => a.skipped).length,
|
|
1239
1447
|
lanes: concurrency,
|
|
1240
1448
|
quiet_ms: quietMs,
|
|
1241
1449
|
timeout_ms: timeout,
|
|
@@ -1255,6 +1463,9 @@ export async function captureStories(config, options = {}) {
|
|
|
1255
1463
|
return { out, rows: rows.length, failed: storyGaps.length };
|
|
1256
1464
|
} finally {
|
|
1257
1465
|
await browser?.close().catch(() => {});
|
|
1466
|
+
// Parked pages belong to this browser; a later capture in the same
|
|
1467
|
+
// process must not pick one up.
|
|
1468
|
+
idlePages.clear();
|
|
1258
1469
|
server?.close();
|
|
1259
1470
|
if (ownedBuild) rmSync(ownedBuild, { recursive: true, force: true });
|
|
1260
1471
|
}
|