rolldown-perf-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.
Files changed (43) hide show
  1. package/AGENTS.md +345 -0
  2. package/LICENSE +25 -0
  3. package/README.md +621 -2
  4. package/harness.mjs +4860 -0
  5. package/lib/build.mjs +57 -0
  6. package/lib/cdp.mjs +217 -0
  7. package/lib/chunk-discovery.mjs +226 -0
  8. package/lib/coverage.mjs +498 -0
  9. package/lib/field/analysis.mjs +225 -0
  10. package/lib/field/browser.mjs +232 -0
  11. package/lib/field/build-match.mjs +86 -0
  12. package/lib/field/capture.mjs +501 -0
  13. package/lib/field/config.mjs +84 -0
  14. package/lib/field/run.mjs +612 -0
  15. package/lib/gen-app.mjs +399 -0
  16. package/lib/graph-diff.mjs +136 -0
  17. package/lib/interrupt.mjs +25 -0
  18. package/lib/measure.mjs +540 -0
  19. package/lib/min-cut.mjs +218 -0
  20. package/lib/module-graph.mjs +318 -0
  21. package/lib/network-evidence.mjs +84 -0
  22. package/lib/node/launch.mjs +351 -0
  23. package/lib/node/measure.mjs +289 -0
  24. package/lib/paint-diagnostics.mjs +76 -0
  25. package/lib/parity/anchor.mjs +186 -0
  26. package/lib/parity/capture.mjs +527 -0
  27. package/lib/parity/compare.mjs +813 -0
  28. package/lib/parity/png.mjs +166 -0
  29. package/lib/parity/run.mjs +145 -0
  30. package/lib/profile.mjs +281 -0
  31. package/lib/replay/cert.mjs +45 -0
  32. package/lib/replay/model.mjs +656 -0
  33. package/lib/replay/run.mjs +1140 -0
  34. package/lib/replay/server.mjs +499 -0
  35. package/lib/replay/shaper.mjs +158 -0
  36. package/lib/rolldown-support.mjs +161 -0
  37. package/lib/serve.mjs +142 -0
  38. package/lib/worker/attrib.mjs +125 -0
  39. package/lib/worker/config.mjs +116 -0
  40. package/lib/worker/launch.mjs +337 -0
  41. package/lib/worker/measure.mjs +296 -0
  42. package/package.json +38 -4
  43. package/replay.mjs +79 -0
@@ -0,0 +1,498 @@
1
+ // First-paint vs settled V8 precise coverage for the INITIAL LOAD — the entry
2
+ // chunk plus every same-origin chunk that executed before first paint (preloaded
3
+ // siblings are critical-path transfer too) — attributed to source modules through
4
+ // each chunk's sourcemap (hand-rolled VLQ decode, no deps). Chunks that first
5
+ // execute after paint are already deferred and are reported, not analyzed.
6
+ //
7
+ // Two snapshots per run (takePreciseCoverage RESETS counters, so the second one
8
+ // covers only the window between the snapshots):
9
+ // atPaint — taken the moment FCP is observed: bytes that HAD to run to paint.
10
+ // atSettle — bytes executed between first paint and settle.
11
+ // Per-module "by settle" usage is the UNION of both snapshots' executed intervals.
12
+ // A module with ~0 executed bytes atPaint but real weight is a lazy-load candidate;
13
+ // a module hot atPaint is critical-path and must not be deferred.
14
+ //
15
+ // Known blind spot (inherent to V8 coverage, worth remembering for real apps): a
16
+ // module's top level counts as executed when it evaluates, so weight kept in
17
+ // top-level literals looks "used" even if nothing reads it before paint. The demo
18
+ // app keeps weight inside function bodies so the signal is clean.
19
+
20
+ import { openPage, sleep } from './cdp.mjs';
21
+ import { OBSERVER_JS } from './measure.mjs';
22
+
23
+ // --- sourcemap VLQ ---------------------------------------------------------
24
+
25
+ const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
26
+ const B64_LOOKUP = new Map();
27
+ for (let i = 0; i < B64.length; i++) B64_LOOKUP.set(B64[i], i);
28
+
29
+ function decodeVlqSegment(str) {
30
+ const out = [];
31
+ let value = 0;
32
+ let shift = 0;
33
+ for (const ch of str) {
34
+ const digit = B64_LOOKUP.get(ch);
35
+ if (digit === undefined) throw new Error(`bad VLQ char: ${ch}`);
36
+ value += (digit & 31) << shift;
37
+ if (digit & 32) {
38
+ shift += 5;
39
+ } else {
40
+ const negate = value & 1;
41
+ value >>>= 1;
42
+ out.push(negate ? -value : value);
43
+ value = 0;
44
+ shift = 0;
45
+ }
46
+ }
47
+ return out;
48
+ }
49
+
50
+ /**
51
+ * Flatten sourcemap mappings into contiguous generated-offset spans:
52
+ * [{ start, end, srcIdx }] covering the whole generated file (srcIdx -1 = unmapped).
53
+ * Offsets are UTF-16 units; the generated chunks are ASCII, so they equal bytes.
54
+ */
55
+ export function generatedSpans(code, map) {
56
+ const lineStarts = [0];
57
+ for (let i = 0; i < code.length; i++) {
58
+ if (code.charCodeAt(i) === 10) lineStarts.push(i + 1);
59
+ }
60
+
61
+ const points = [];
62
+ let srcIdx = 0;
63
+ let srcLine = 0;
64
+ let srcCol = 0;
65
+ let nameIdx = 0;
66
+ const mappingLines = map.mappings.split(';');
67
+ for (let line = 0; line < mappingLines.length; line++) {
68
+ const lineStr = mappingLines[line];
69
+ if (!lineStr) continue;
70
+ let genCol = 0;
71
+ for (const seg of lineStr.split(',')) {
72
+ if (!seg) continue;
73
+ const nums = decodeVlqSegment(seg);
74
+ genCol += nums[0];
75
+ let mapped = -1;
76
+ if (nums.length >= 4) {
77
+ srcIdx += nums[1];
78
+ srcLine += nums[2];
79
+ srcCol += nums[3];
80
+ if (nums.length >= 5) nameIdx += nums[4];
81
+ mapped = srcIdx;
82
+ }
83
+ const lineStart = lineStarts[line];
84
+ if (lineStart === undefined) continue;
85
+ points.push({ start: lineStart + genCol, srcIdx: mapped });
86
+ }
87
+ }
88
+ points.sort((a, b) => a.start - b.start);
89
+
90
+ const spans = [];
91
+ if (points.length === 0 || points[0].start > 0) {
92
+ spans.push({ start: 0, end: points.length ? points[0].start : code.length, srcIdx: -1 });
93
+ }
94
+ for (let i = 0; i < points.length; i++) {
95
+ const end = i + 1 < points.length ? points[i + 1].start : code.length;
96
+ if (end > points[i].start)
97
+ spans.push({ start: points[i].start, end, srcIdx: points[i].srcIdx });
98
+ }
99
+ return spans;
100
+ }
101
+
102
+ // --- coverage range painting ------------------------------------------------
103
+
104
+ /**
105
+ * V8 block-coverage ranges are properly nested; painting parents before children
106
+ * (start asc, end desc) leaves every offset with its innermost range's count.
107
+ * Returns a piecewise-constant boundary list [{ pos, count }].
108
+ */
109
+ export function coverageBounds(functions) {
110
+ const ranges = [];
111
+ for (const fn of functions) {
112
+ for (const r of fn.ranges) ranges.push(r);
113
+ }
114
+ ranges.sort((a, b) => a.startOffset - b.startOffset || b.endOffset - a.endOffset);
115
+ let bounds = [{ pos: 0, count: 0 }];
116
+ for (const r of ranges) bounds = overwrite(bounds, r.startOffset, r.endOffset, r.count);
117
+ return bounds;
118
+ }
119
+
120
+ function overwrite(bounds, start, end, count) {
121
+ if (end <= start) return bounds;
122
+ const out = [];
123
+ let i = 0;
124
+ let current = 0;
125
+ while (i < bounds.length && bounds[i].pos < start) {
126
+ out.push(bounds[i]);
127
+ current = bounds[i].count;
128
+ i++;
129
+ }
130
+ let countAtEnd = current;
131
+ while (i < bounds.length && bounds[i].pos < end) {
132
+ countAtEnd = bounds[i].count;
133
+ i++;
134
+ }
135
+ out.push({ pos: start, count });
136
+ out.push({ pos: end, count: countAtEnd });
137
+ while (i < bounds.length) {
138
+ out.push(bounds[i]);
139
+ i++;
140
+ }
141
+ return out;
142
+ }
143
+
144
+ /** Merge count>0 stretches of a boundary list into sorted [start, end) intervals. */
145
+ export function executedIntervals(bounds) {
146
+ const intervals = [];
147
+ for (let i = 0; i < bounds.length; i++) {
148
+ if (bounds[i].count <= 0) continue;
149
+ const end = i + 1 < bounds.length ? bounds[i + 1].pos : bounds[i].pos;
150
+ if (end <= bounds[i].pos) continue;
151
+ const last = intervals[intervals.length - 1];
152
+ if (last && last.end >= bounds[i].pos) last.end = Math.max(last.end, end);
153
+ else intervals.push({ start: bounds[i].pos, end });
154
+ }
155
+ return intervals;
156
+ }
157
+
158
+ function overlapBytes(spans, intervals) {
159
+ const executed = Array.from({ length: spans.length }, () => 0);
160
+ let j = 0;
161
+ for (let i = 0; i < spans.length; i++) {
162
+ const span = spans[i];
163
+ while (j < intervals.length && intervals[j].end <= span.start) j++;
164
+ for (let k = j; k < intervals.length && intervals[k].start < span.end; k++) {
165
+ executed[i] +=
166
+ Math.min(span.end, intervals[k].end) - Math.max(span.start, intervals[k].start);
167
+ if (intervals[k].end >= span.end) break;
168
+ }
169
+ }
170
+ return executed;
171
+ }
172
+
173
+ /** Union two sorted interval lists into one sorted, non-overlapping list. */
174
+ function unionIntervals(a, b) {
175
+ const all = [...a, ...b].sort((x, y) => x.start - y.start);
176
+ const out = [];
177
+ for (const interval of all) {
178
+ const last = out[out.length - 1];
179
+ if (last && last.end >= interval.start) last.end = Math.max(last.end, interval.end);
180
+ else out.push({ ...interval });
181
+ }
182
+ return out;
183
+ }
184
+
185
+ /**
186
+ * Aggregate both snapshots per source module. `settleBytes` is cumulative
187
+ * ("executed at any point by settle"), i.e. the union of the two windows.
188
+ * Returns Map<source, { totalBytes, paintBytes, settleBytes }>.
189
+ */
190
+ export function coverageBySource({ code, map, atPaint, atSettle }) {
191
+ const spans = generatedSpans(code, map);
192
+ const paintIntervals = executedIntervals(coverageBounds(atPaint));
193
+ const paintExec = overlapBytes(spans, paintIntervals);
194
+ const settleExec = overlapBytes(
195
+ spans,
196
+ unionIntervals(paintIntervals, executedIntervals(coverageBounds(atSettle))),
197
+ );
198
+ const rows = new Map();
199
+ spans.forEach((span, i) => {
200
+ const source =
201
+ span.srcIdx >= 0
202
+ ? String(map.sources[span.srcIdx] ?? `#${span.srcIdx}`).replaceAll('\\', '/')
203
+ : '(unmapped)';
204
+ const row = rows.get(source) ?? { totalBytes: 0, paintBytes: 0, settleBytes: 0 };
205
+ row.totalBytes += span.end - span.start;
206
+ row.paintBytes += paintExec[i];
207
+ row.settleBytes += settleExec[i];
208
+ rows.set(source, row);
209
+ });
210
+ return rows;
211
+ }
212
+
213
+ /** URL pathnames arrive percent-encoded (%40scope), disk files hold the literal
214
+ * characters — decode before touching the filesystem or names never match. */
215
+ function toFile(pathname) {
216
+ try {
217
+ return decodeURIComponent(pathname).replace(/^\/+/, '');
218
+ } catch {
219
+ return pathname.replace(/^\/+/, '');
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Attribute the whole initial load: the entry chunk plus every same-origin JS
225
+ * chunk that executed before first paint. Chunks first executing after paint
226
+ * are split by their FETCH timing: fetched pre-paint (static tags, preloads) =
227
+ * reason 'static-prepaint-transfer' with byte cost — the paint paid for their
228
+ * download; fetched after paint = reason 'post-paint' (genuinely deferred).
229
+ * `readChunk(file)` returns { code, map } or null when no sourcemap exists
230
+ * (skipped with reason 'no-sourcemap' — never silently).
231
+ */
232
+ export function attributeChunks({ scripts, entryName, readChunk, prePaintFetches = new Map() }) {
233
+ const chunks = [];
234
+ const modules = [];
235
+ const skipped = [];
236
+ for (const script of scripts) {
237
+ const isEntry = script.pathname.endsWith(entryName);
238
+ // Inline scripts surface under the document URL; only .js files are chunks.
239
+ if (!isEntry && !/\.[mc]?js$/.test(script.pathname)) continue;
240
+ const file = toFile(script.pathname);
241
+ if (!isEntry && !script.atPaint) {
242
+ if (prePaintFetches.has(file)) {
243
+ skipped.push({
244
+ file,
245
+ reason: 'static-prepaint-transfer',
246
+ bytes: prePaintFetches.get(file),
247
+ });
248
+ } else {
249
+ skipped.push({ file, reason: 'post-paint' });
250
+ }
251
+ continue;
252
+ }
253
+ const loaded = readChunk(file);
254
+ if (!loaded) {
255
+ skipped.push({ file, reason: 'no-sourcemap' });
256
+ continue;
257
+ }
258
+ const rows = coverageBySource({
259
+ code: loaded.code,
260
+ map: loaded.map,
261
+ atPaint: script.atPaint ?? [],
262
+ atSettle: script.atSettle ?? [],
263
+ });
264
+ const totals = { totalBytes: 0, paintBytes: 0, settleBytes: 0 };
265
+ for (const [source, row] of rows.entries()) {
266
+ modules.push({
267
+ source,
268
+ chunk: file,
269
+ totalBytes: row.totalBytes,
270
+ paintBytes: row.paintBytes,
271
+ settleBytes: row.settleBytes,
272
+ paintRatio: row.totalBytes ? row.paintBytes / row.totalBytes : 0,
273
+ settleRatio: row.totalBytes ? row.settleBytes / row.totalBytes : 0,
274
+ });
275
+ totals.totalBytes += row.totalBytes;
276
+ totals.paintBytes += row.paintBytes;
277
+ totals.settleBytes += row.settleBytes;
278
+ }
279
+ chunks.push({
280
+ file,
281
+ entry: isEntry,
282
+ ...totals,
283
+ paintRatio: totals.totalBytes ? totals.paintBytes / totals.totalBytes : 0,
284
+ });
285
+ }
286
+ // Pre-paint-fetched scripts the profiler never saw execute by settle: pure
287
+ // dead transfer on the paint's critical path (they can't even claim "needed
288
+ // later this session").
289
+ const seen = new Set([...chunks.map((c) => c.file), ...skipped.map((s) => s.file)]);
290
+ for (const [file, bytes] of prePaintFetches) {
291
+ if (seen.has(file)) continue;
292
+ skipped.push({ file, reason: 'static-prepaint-transfer', bytes, neverExecuted: true });
293
+ }
294
+ chunks.sort((a, b) => (b.entry ? 1 : 0) - (a.entry ? 1 : 0) || b.totalBytes - a.totalBytes);
295
+ modules.sort((a, b) => b.totalBytes - a.totalBytes);
296
+ return { chunks, modules, skipped };
297
+ }
298
+
299
+ // --- lead analyses over per-module coverage rows -------------------------------
300
+
301
+ export const LARGE_AT_PAINT_MIN_BYTES = 8 * 1024;
302
+ export const SIBLING_MIN_FILES = 3;
303
+ export const SIBLING_MIN_BYTES = 6 * 1024;
304
+ export const COLD_MIN_BYTES = 12 * 1024; // per-module floor to appear in the cold list
305
+ export const COLD_OPEN_MIN_BYTES = 25 * 1024; // a non-framework module this cold keeps the lead OPEN
306
+ // Framework runtimes carry real cold bytes (unreached branches) but their import
307
+ // edge can't move — annotate instead of flagging, so agents don't chase them.
308
+ const FRAMEWORK_RE = /node_modules\/(react-dom|react|scheduler|vue|@vue\/[^/]+|svelte)\//;
309
+
310
+ /**
311
+ * Big modules that executed at paint. "Executed" does NOT prove the first paint
312
+ * needs their contents — top-level data evaluates the moment its module is
313
+ * imported — so these are verify-need leads, not certainties.
314
+ */
315
+ export function largeAtPaintModules(modules) {
316
+ return modules.filter(
317
+ (mod) =>
318
+ mod.totalBytes >= LARGE_AT_PAINT_MIN_BYTES &&
319
+ mod.paintRatio >= 0.5 &&
320
+ mod.source !== '(unmapped)',
321
+ );
322
+ }
323
+
324
+ /**
325
+ * Cold bytes at paint: totalBytes − paintBytes — weight downloaded and parsed
326
+ * before first paint but not executed by it. This is the unified byte view: the
327
+ * <2% defer candidates are its extreme, but a vendor SDK that PARTIALLY
328
+ * initializes at boot (e.g. firestore executing 31%) matches neither the
329
+ * candidate bucket nor the ≥50% large-at-paint bucket while carrying the most
330
+ * recoverable weight on the page. (excalidraw A/B 2026-07-10: firestore's
331
+ * 158KB cold sat exactly in that blind mid-band and decided the eval.)
332
+ */
333
+ export function coldAtPaintModules(modules, { minBytes = COLD_MIN_BYTES } = {}) {
334
+ return modules
335
+ .filter((mod) => mod.source !== '(unmapped)')
336
+ .map((mod) => ({
337
+ ...mod,
338
+ coldBytes: Math.max(0, mod.totalBytes - mod.paintBytes),
339
+ framework: FRAMEWORK_RE.test(mod.source),
340
+ }))
341
+ .filter((mod) => mod.coldBytes >= minBytes)
342
+ .sort((a, b) => b.coldBytes - a.coldBytes);
343
+ }
344
+
345
+ /**
346
+ * Same-shaped sibling families (locales, themes, per-tenant configs) mostly
347
+ * executed at paint. Size uniformity over the sizeable members keeps grab-bag
348
+ * utility directories from tripping this; tiny index/barrel files don't count.
349
+ */
350
+ export function siblingVariantGroups(modules) {
351
+ const byDir = new Map();
352
+ for (const mod of modules) {
353
+ const slash = mod.source.lastIndexOf('/');
354
+ if (slash <= 0) continue;
355
+ const dir = mod.source.slice(0, slash + 1);
356
+ const group = byDir.get(dir) ?? { dir, files: 0, bytes: 0, paintBytes: 0, sizes: [] };
357
+ group.files += 1;
358
+ group.bytes += mod.totalBytes;
359
+ group.paintBytes += mod.paintBytes;
360
+ group.sizes.push(mod.totalBytes);
361
+ byDir.set(dir, group);
362
+ }
363
+ return [...byDir.values()].filter((group) => {
364
+ const sizeable = group.sizes.filter((size) => size >= 1024);
365
+ return (
366
+ sizeable.length >= SIBLING_MIN_FILES &&
367
+ group.bytes >= SIBLING_MIN_BYTES &&
368
+ group.paintBytes / group.bytes >= 0.5 &&
369
+ Math.max(...sizeable) <= Math.min(...sizeable) * 2
370
+ );
371
+ });
372
+ }
373
+
374
+ // --- browser driver ----------------------------------------------------------
375
+
376
+ /**
377
+ * One coverage-instrumented navigation. Arms the profiler on a same-origin blank
378
+ * page, snapshots at first paint (25ms poll) and again after load + readiness +
379
+ * settle. Retries once: an unlucky arming race loses the run, not the command.
380
+ */
381
+ export async function coverageRun(cdp, options) {
382
+ try {
383
+ return await coverageAttempt(cdp, options);
384
+ } catch (err) {
385
+ if (!String(err.message).includes('not seen')) throw err;
386
+ return await coverageAttempt(cdp, options);
387
+ }
388
+ }
389
+
390
+ async function coverageAttempt(
391
+ cdp,
392
+ {
393
+ origin,
394
+ throttle,
395
+ expectedFeatures = [],
396
+ entryName = '/main.js',
397
+ settleMs = 2000,
398
+ timeoutMs = 60_000,
399
+ },
400
+ ) {
401
+ const debug = process.env.COVERAGE_DEBUG ? (msg) => console.error(`[coverage] ${msg}`) : () => {};
402
+ const page = await openPage(cdp, { throttle, injectScript: OBSERVER_JS });
403
+ try {
404
+ await page.navigate(`${origin}/blank.html`);
405
+ await sleep(150);
406
+ await page.send('Profiler.enable');
407
+ const armed = await page.send('Profiler.startPreciseCoverage', {
408
+ callCount: false,
409
+ detailed: true,
410
+ });
411
+ debug(`armed on blank (v8 timestamp ${armed?.timestamp})`);
412
+ // '/' not '/index.html': router-strict SPAs 404 on the literal file path.
413
+ await page.navigate(`${origin}/`);
414
+ debug('navigate(index) resolved');
415
+
416
+ const deadline = Date.now() + timeoutMs;
417
+ for (;;) {
418
+ // Pathname guard: until the index.html navigation commits, evaluate still
419
+ // runs against the blank warm-up document — never read paint entries there.
420
+ const fcp = await page.evaluate(
421
+ "(location.pathname !== '/blank.html' && window.__perfMetrics) ? window.__perfMetrics.fcp : null",
422
+ );
423
+ if (fcp != null) {
424
+ debug(`FCP=${fcp} href=${await page.evaluate('location.href')}`);
425
+ break;
426
+ }
427
+ if (Date.now() > deadline) throw new Error('coverage run: FCP never fired');
428
+ await sleep(25);
429
+ }
430
+ const atPaint = await page.send('Profiler.takePreciseCoverage');
431
+ debug(
432
+ `atPaint scripts: ${atPaint.result.map((s) => `${s.url || '(inline)'}#${s.functions.length}`).join(', ') || '(none)'}`,
433
+ );
434
+
435
+ for (;;) {
436
+ const done = await page.evaluate(`(() => {
437
+ if (location.pathname === '/blank.html') return false;
438
+ const nav = performance.getEntriesByType('navigation')[0];
439
+ if (!nav || !nav.loadEventEnd) return false;
440
+ const r = window.__ready || {};
441
+ return ${JSON.stringify(expectedFeatures)}.every((f) => r[f] === true);
442
+ })()`);
443
+ if (done) break;
444
+ if (Date.now() > deadline) throw new Error('coverage run: page never became ready');
445
+ await sleep(100);
446
+ }
447
+ await sleep(settleMs);
448
+ const atSettle = await page.send('Profiler.takePreciseCoverage');
449
+ await page.send('Profiler.stopPreciseCoverage').catch(() => {});
450
+
451
+ // Each take only reports functions executed since the previous take (counters
452
+ // reset), and V8 omits scripts with no new execution entirely. So any script
453
+ // may be legitimately absent from one snapshot: from atSettle when it runs
454
+ // nothing after first paint, or from atPaint when it first executed later.
455
+ // A snapshot a script is absent from contributes null (≠ empty coverage).
456
+ const byUrl = new Map();
457
+ const collect = (take, key) => {
458
+ for (const script of take.result) {
459
+ if (!script.url || !script.url.startsWith(origin)) continue;
460
+ const pathname = new URL(script.url).pathname;
461
+ const rec = byUrl.get(script.url) ?? {
462
+ url: script.url,
463
+ pathname,
464
+ atPaint: null,
465
+ atSettle: null,
466
+ };
467
+ rec[key] = (rec[key] ?? []).concat(script.functions);
468
+ byUrl.set(script.url, rec);
469
+ }
470
+ };
471
+ collect(atPaint, 'atPaint');
472
+ collect(atSettle, 'atSettle');
473
+ const scripts = [...byUrl.values()];
474
+ // The entry absent from BOTH snapshots means the page never ran it — error.
475
+ if (!scripts.some((script) => script.pathname.endsWith(entryName))) {
476
+ const seen = scripts.map((script) => script.pathname).join(', ') || '(none)';
477
+ throw new Error(`coverage: entry script ${entryName} not seen (scripts: ${seen})`);
478
+ }
479
+ // Resource timing for every same-origin script: a chunk whose EXECUTION is
480
+ // post-paint is still critical-path cost when its FETCH started before the
481
+ // paint (static <script> tags, modulepreload) — the download competes with
482
+ // the paint for bandwidth. Execution timing alone cannot see that.
483
+ const scriptFetches = JSON.parse(
484
+ await page.evaluate(`JSON.stringify((() => {
485
+ const fcp = (window.__perfMetrics || {}).fcp ?? null;
486
+ return performance.getEntriesByType('resource')
487
+ .filter((r) => r.name.startsWith(location.origin))
488
+ .map((r) => ({ pathname: new URL(r.name).pathname, startMs: r.startTime,
489
+ bytes: r.encodedBodySize || r.transferSize || 0 }))
490
+ .filter((r) => /\\.[mc]?js$/.test(r.pathname))
491
+ .map((r) => ({ ...r, prePaint: fcp != null && r.startMs < fcp }));
492
+ })())`),
493
+ );
494
+ return { scripts, scriptFetches };
495
+ } finally {
496
+ await page.close().catch(() => {});
497
+ }
498
+ }