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,540 @@
1
+ // Timed loading runs: throttled navigations collecting web vitals via buffered
2
+ // PerformanceObservers injected before any page script runs.
3
+
4
+ import { openPage, sleep } from './cdp.mjs';
5
+ import { recordNetworkEvidence } from './network-evidence.mjs';
6
+
7
+ // Roughly Lighthouse's mobile lab shape: fast-3G-ish network, 4x CPU slowdown.
8
+ // Absolute numbers are lab-only; the signal is the delta between builds.
9
+ export const DEFAULT_THROTTLE = {
10
+ latencyMs: 150,
11
+ downloadBps: 200_000, // 1.6 Mbps
12
+ uploadBps: 94_000,
13
+ cpuRate: 4,
14
+ };
15
+
16
+ /**
17
+ * Bandwidth-only scaling of a throttle shape, for pathologically heavy apps
18
+ * (a 3MB entry costs 15s of pure transfer wait per navigation at 1x). RTT and
19
+ * CPU pressure stay Lighthouse-real - waterfalls and boot CPU keep their
20
+ * weight; only the transfer WAIT shortens. Percentage deltas of transfer-bound
21
+ * fixes are scale-invariant (half the bytes = half the transfer time at any
22
+ * bandwidth, and the noise floor is 2% of the baseline), so decisions survive.
23
+ * The scale is CALIBRATED ONCE per target and pinned - it is part of the
24
+ * measurement's identity, never adapted mid-session.
25
+ */
26
+ export function scaleThrottle(base, netScale = 1) {
27
+ if (!base) return base;
28
+ if (!netScale || netScale === 1) return { ...base, netScale: 1 };
29
+ return {
30
+ ...base,
31
+ downloadBps: base.downloadBps * netScale,
32
+ uploadBps: base.uploadBps * netScale,
33
+ netScale,
34
+ };
35
+ }
36
+
37
+ /**
38
+ * A fixed network in real units instead of a scale step: the bandwidth and RTT a
39
+ * deployed environment measured (a record's network model), and the CPU slowdown of
40
+ * the device to model (1 = this machine). Bandwidth is in Mbit/s (SI: 1 Mbit =
41
+ * 1,000,000 bits); CDP takes bytes per second. Upload keeps the base shape's ratio.
42
+ * `rttMs` / `cpuRate` default to the base shape. Like the net scale, the network is
43
+ * part of the measurement identity (`throttleId`).
44
+ */
45
+ export function networkThrottle(
46
+ base,
47
+ { bandwidthMbps, rttMs = base.latencyMs, cpuRate = base.cpuRate },
48
+ ) {
49
+ const downloadBps = Math.round((bandwidthMbps * 1_000_000) / 8);
50
+ return {
51
+ ...base,
52
+ latencyMs: rttMs,
53
+ downloadBps,
54
+ uploadBps: Math.round((downloadBps * base.uploadBps) / base.downloadBps),
55
+ cpuRate,
56
+ network: { bandwidthMbps, rttMs, cpuRate },
57
+ };
58
+ }
59
+
60
+ /**
61
+ * Read `--bandwidth <Mbit/s> [--rtt <ms>] [--cpu <rate>]` into a network, or null when
62
+ * `--bandwidth` is absent. `--rtt` / `--cpu` only shape a `--bandwidth` network, and the
63
+ * network replaces the net-scale ladder, so both misuses throw instead of being ignored.
64
+ */
65
+ export function parseNetworkFlags(opts) {
66
+ const { bandwidth, rtt, cpu } = opts;
67
+ if (bandwidth == null) {
68
+ if (rtt != null || cpu != null) {
69
+ throw new Error(
70
+ '--rtt and --cpu shape a --bandwidth network - pass --bandwidth <Mbit/s> too',
71
+ );
72
+ }
73
+ return null;
74
+ }
75
+ if (opts['net-scale'] != null) {
76
+ throw new Error(
77
+ '--bandwidth replaces the net-scale ladder - pass one of --bandwidth or --net-scale',
78
+ );
79
+ }
80
+ const read = (flag, value, ok, expected) => {
81
+ const n = Number(value);
82
+ if (!Number.isFinite(n) || !ok(n))
83
+ throw new Error(`${flag} must be ${expected}, got '${value}'`);
84
+ return n;
85
+ };
86
+ return {
87
+ bandwidthMbps: read('--bandwidth', bandwidth, (n) => n > 0, 'a number > 0 (Mbit/s)'),
88
+ rttMs:
89
+ rtt == null
90
+ ? DEFAULT_THROTTLE.latencyMs
91
+ : read('--rtt', rtt, (n) => n >= 0, 'a number >= 0 (ms)'),
92
+ cpuRate:
93
+ cpu == null
94
+ ? DEFAULT_THROTTLE.cpuRate
95
+ : read('--cpu', cpu, (n) => n >= 1, 'a number >= 1 (1 = no slowdown)'),
96
+ };
97
+ }
98
+
99
+ /** The throttle a pinned profile (`throttle-profile.json`) describes. */
100
+ export function throttleFromProfile(profile) {
101
+ return profile?.network
102
+ ? networkThrottle(DEFAULT_THROTTLE, profile.network)
103
+ : scaleThrottle(DEFAULT_THROTTLE, profile?.netScale ?? 1);
104
+ }
105
+
106
+ /**
107
+ * The measurement identity of a throttle, a pinned profile or a saved measurement state
108
+ * (all carry `network` or `netScale`): two measurements compare only when it is equal.
109
+ * A state without either was measured at net x1, as before networks existed.
110
+ */
111
+ export function throttleId(value) {
112
+ if (!value) return 'unthrottled';
113
+ const n = value.network;
114
+ if (n) return `bw ${n.bandwidthMbps}Mbit/s rtt ${n.rttMs}ms cpu ${n.cpuRate}x`;
115
+ return `net x${value.netScale ?? 1}`;
116
+ }
117
+
118
+ /**
119
+ * Element identity helpers shared by every injected script that names elements:
120
+ * a structural path (tag:nth-of-type chain from <body>, stable across builds that
121
+ * keep the DOM shape) and a short human label.
122
+ */
123
+ export const ELEMENT_PATH_JS = `const pathOf = (el) => {
124
+ const parts = [];
125
+ for (let n = el; n && n !== document.documentElement; n = n.parentElement) {
126
+ let i = 1;
127
+ for (let s = n.previousElementSibling; s; s = s.previousElementSibling) if (s.tagName === n.tagName) i++;
128
+ parts.push(n.tagName.toLowerCase() + ':' + i);
129
+ }
130
+ return parts.reverse().join('>');
131
+ };
132
+ const labelOf = (el) => {
133
+ let label = el.tagName.toLowerCase();
134
+ if (el.id) label += '#' + el.id;
135
+ else if (typeof el.className === 'string' && el.className.trim()) {
136
+ label += '.' + el.className.trim().split(/\\s+/).slice(0, 2).join('.');
137
+ }
138
+ return label.slice(0, 60);
139
+ };`;
140
+
141
+ export const OBSERVER_JS = `(() => {
142
+ const M = (window.__perfMetrics = { fcp: null, lcp: null, lcpEntry: null, lcpNode: null, cls: 0, longtasks: [], resourceBufferFull: false });
143
+ performance.setResourceTimingBufferSize(10000);
144
+ performance.addEventListener('resourcetimingbufferfull', () => { M.resourceBufferFull = true; });
145
+ ${ELEMENT_PATH_JS}
146
+ try {
147
+ new PerformanceObserver((list) => {
148
+ for (const e of list.getEntries()) {
149
+ M.lcp = e.startTime;
150
+ // What the timestamp belongs to, as of the entry - parity compares it with
151
+ // the original build's and with what the element shows once settled. No
152
+ // layout reads here: this runs inside the timed, throttled load.
153
+ const el = e.element;
154
+ M.lcpNode = el;
155
+ M.lcpEntry = {
156
+ size: e.size,
157
+ url: e.url || '',
158
+ tag: el ? el.tagName.toLowerCase() : null,
159
+ path: el ? pathOf(el) : null,
160
+ label: el ? labelOf(el) : null,
161
+ text: el ? (el.textContent || '').replace(/\\s+/g, ' ').trim().slice(0, 80) : '',
162
+ };
163
+ }
164
+ }).observe({ type: 'largest-contentful-paint', buffered: true });
165
+ new PerformanceObserver((list) => {
166
+ for (const e of list.getEntries()) if (e.name === 'first-contentful-paint') M.fcp = e.startTime;
167
+ }).observe({ type: 'paint', buffered: true });
168
+ new PerformanceObserver((list) => {
169
+ for (const e of list.getEntries()) if (!e.hadRecentInput) M.cls += e.value;
170
+ }).observe({ type: 'layout-shift', buffered: true });
171
+ new PerformanceObserver((list) => {
172
+ for (const e of list.getEntries()) M.longtasks.push({ start: e.startTime, duration: e.duration });
173
+ }).observe({ type: 'longtask', buffered: true });
174
+ } catch {}
175
+ })();`;
176
+
177
+ export const COLLECT_JS = `(() => {
178
+ const nav = performance.getEntriesByType('navigation')[0];
179
+ const res = performance.getEntriesByType('resource');
180
+ const M = window.__perfMetrics || {};
181
+ // What a resource IS (font/image/script/...), not just who initiated it -
182
+ // fonts arrive with initiatorType 'css' or 'link', which hides them.
183
+ const classify = (r) => {
184
+ if (r.initiatorType === 'fetch') return 'fetch';
185
+ if (r.initiatorType === 'xmlhttprequest') return 'xhr';
186
+ const clean = r.name.split('?')[0].split('#')[0].toLowerCase();
187
+ const ext = clean.slice(clean.lastIndexOf('.') + 1);
188
+ if (['woff2', 'woff', 'ttf', 'otf', 'eot'].includes(ext)) return 'font';
189
+ if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'svg', 'ico', 'bmp'].includes(ext)) return 'image';
190
+ if (['js', 'mjs', 'cjs'].includes(ext)) return 'script';
191
+ if (ext === 'css') return 'css';
192
+ if (ext === 'wasm' || ext === 'json') return ext;
193
+ return r.initiatorType || 'other';
194
+ };
195
+ const all = res.map((r) => ({
196
+ name: r.name.replace(location.origin, ''),
197
+ type: classify(r),
198
+ start: Math.round(r.startTime),
199
+ end: Math.round(r.responseEnd),
200
+ bytes: r.encodedBodySize || 0,
201
+ initiatorType: r.initiatorType,
202
+ }));
203
+ // Uncapped per-type totals (+ the pre-first-paint share): real apps load
204
+ // hundreds of resources, so any capped detail list under-reports by design.
205
+ const typeAgg = {};
206
+ for (const r of all) {
207
+ const a = typeAgg[r.type] || (typeAgg[r.type] = { count: 0, bytes: 0, preFcpCount: 0, preFcpBytes: 0 });
208
+ a.count += 1;
209
+ a.bytes += r.bytes;
210
+ // end 0 = still in flight or failed, not "finished before paint". Bytes stay
211
+ // 0 for cross-origin responses without Timing-Allow-Origin - count anyway.
212
+ if (typeof M.fcp === 'number' && r.end > 0 && r.end <= M.fcp) {
213
+ a.preFcpCount += 1;
214
+ a.preFcpBytes += r.bytes;
215
+ }
216
+ }
217
+ // Render-blocking resources gate FCP by spec: nothing paints until the last
218
+ // blocking stylesheet arrives and parses. Aggregate uncapped (the detail list
219
+ // is capped) - the JS-side signals can never see this class of gate.
220
+ const blocking = res.filter((r) => r.renderBlockingStatus === 'blocking');
221
+ const blockingGate = {
222
+ count: blocking.length,
223
+ bytes: blocking.reduce((a, r) => a + (r.encodedBodySize || 0), 0),
224
+ lastEndMs: Math.round(blocking.reduce((a, r) => Math.max(a, r.responseEnd || 0), 0)),
225
+ worst: blocking.slice().sort((a, b) => (b.responseEnd || 0) - (a.responseEnd || 0)).slice(0, 4)
226
+ .map((r) => ({ name: r.name.replace(location.origin, ''), end: Math.round(r.responseEnd || 0), bytes: r.encodedBodySize || 0 })),
227
+ };
228
+ return {
229
+ blockingGate,
230
+ fcp: M.fcp,
231
+ lcp: M.lcp,
232
+ cls: M.cls,
233
+ ttfb: nav ? nav.responseStart : null,
234
+ dcl: nav ? nav.domContentLoadedEventEnd : null,
235
+ load: nav ? nav.loadEventEnd : null,
236
+ bytes: (nav ? nav.encodedBodySize : 0) + res.reduce((a, r) => a + (r.encodedBodySize || 0), 0),
237
+ jsRequests: res.filter((r) => r.name.endsWith('.js')).length,
238
+ // A tiny facade can initiate a large download graph. Do not rank or cap
239
+ // request details by size; report browser buffer overflow explicitly.
240
+ resources: all,
241
+ resourceDetailsComplete: M.resourceBufferFull === false,
242
+ resourceTypes: typeAgg,
243
+ longtasks: (M.longtasks || []).map((t) => ({ start: Math.round(t.start), duration: Math.round(t.duration) })),
244
+ ready: Object.assign({}, window.__ready || {}),
245
+ heroTitle: (() => { const el = document.getElementById('hero-title'); return el ? el.textContent || '' : null; })(),
246
+ heroSubtitle: (() => { const el = document.getElementById('hero-subtitle'); return el ? el.textContent || '' : null; })(),
247
+ // The LCP entry plus what its element shows after settle. A same-size swap
248
+ // (placeholder image -> real image, skeleton -> copy) emits no new LCP entry,
249
+ // so the timestamp keeps pointing at the placeholder - visible only here.
250
+ lcpElement: (() => {
251
+ const d = M.lcpEntry;
252
+ if (!d) return null;
253
+ const el = M.lcpNode;
254
+ if (!el || !el.isConnected) return Object.assign({}, d, { detached: true });
255
+ const r = el.getBoundingClientRect();
256
+ let finalUrl = null;
257
+ if (d.url) {
258
+ if (el.tagName === 'IMG') finalUrl = el.currentSrc || el.src;
259
+ else if (el.tagName === 'image') finalUrl = el.href && el.href.baseVal;
260
+ else finalUrl = (getComputedStyle(el).backgroundImage.match(/url\\("?(.*?)"?\\)/) || [])[1] || null;
261
+ finalUrl = finalUrl ? finalUrl.replace(location.origin, '') : d.url;
262
+ }
263
+ return Object.assign({}, d, {
264
+ detached: false,
265
+ rect: { w: Math.round(r.width), h: Math.round(r.height) },
266
+ finalUrl,
267
+ finalText: (el.textContent || '').replace(/\\s+/g, ' ').trim().slice(0, 80),
268
+ });
269
+ })(),
270
+ };
271
+ })()`;
272
+
273
+ function readyExpr(expectedFeatures) {
274
+ return `(() => {
275
+ const nav = performance.getEntriesByType('navigation')[0];
276
+ const M = window.__perfMetrics || {};
277
+ const r = window.__ready || {};
278
+ return {
279
+ load: nav ? nav.loadEventEnd : 0,
280
+ lcp: M.lcp,
281
+ ready: ${JSON.stringify(expectedFeatures)}.every((f) => r[f] === true),
282
+ };
283
+ })()`;
284
+ }
285
+
286
+ /** One navigation; resolves with the collected sample (missing vitals stay null). */
287
+ export async function timedRun(
288
+ cdp,
289
+ { url, throttle, expectedFeatures = [], settleMs = 1500, timeoutMs = 60_000 },
290
+ ) {
291
+ const page = await openPage(cdp, { throttle, injectScript: OBSERVER_JS });
292
+ const network = recordNetworkEvidence(page);
293
+ try {
294
+ await page.navigate(url);
295
+ const deadline = Date.now() + timeoutMs;
296
+ for (;;) {
297
+ const state = await page.evaluate(readyExpr(expectedFeatures));
298
+ if (state.load > 0 && state.lcp != null && state.ready) break;
299
+ if (Date.now() > deadline) break; // collect what we have; the guard will flag it
300
+ await sleep(100);
301
+ }
302
+ await sleep(settleMs);
303
+ const sample = await page.evaluate(COLLECT_JS);
304
+ network.attach(sample.resources, new URL(url).origin);
305
+ return sample;
306
+ } finally {
307
+ network.stop();
308
+ await page.close().catch(() => {});
309
+ }
310
+ }
311
+
312
+ export function median(values) {
313
+ if (values.length === 0) return null;
314
+ const sorted = [...values].sort((a, b) => a - b);
315
+ const mid = Math.floor(sorted.length / 2);
316
+ return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
317
+ }
318
+
319
+ export function quantile(values, q) {
320
+ if (values.length === 0) return null;
321
+ const sorted = [...values].sort((a, b) => a - b);
322
+ const idx = Math.min(sorted.length - 1, Math.ceil(q * sorted.length) - 1);
323
+ return sorted[Math.max(0, idx)];
324
+ }
325
+
326
+ const round1 = (v) => (v == null ? null : Math.round(v * 10) / 10);
327
+ const round3 = (v) => (v == null ? null : Math.round(v * 1000) / 1000);
328
+
329
+ /** Fetch/XHR requests that completed before first paint — the render-gating suspects. */
330
+ function gatingFetches(samples) {
331
+ const seen = new Map();
332
+ for (const sample of samples) {
333
+ if (typeof sample.fcp !== 'number') continue;
334
+ for (const resource of sample.resources ?? []) {
335
+ if (resource.type !== 'fetch' && resource.type !== 'xhr') continue;
336
+ if (resource.end <= 0 || resource.end > sample.fcp) continue;
337
+ seen.set(
338
+ resource.name,
339
+ `${resource.name} (${resource.type}, finished ${Math.round(sample.fcp - resource.end)}ms before first paint)`,
340
+ );
341
+ }
342
+ }
343
+ return [...seen.values()];
344
+ }
345
+
346
+ /** Per-type resource totals folded across samples (median per field, missing = 0). */
347
+ function resourceWeight(samples) {
348
+ const types = new Set();
349
+ for (const sample of samples) {
350
+ for (const type of Object.keys(sample.resourceTypes ?? {})) types.add(type);
351
+ }
352
+ // preFcp fields are only meaningful in runs where FCP was observed.
353
+ const painted = samples.filter((s) => typeof s.fcp === 'number');
354
+ const kb = (bytes) => Math.round(((bytes ?? 0) / 1024) * 10) / 10;
355
+ const rows = [];
356
+ for (const type of types) {
357
+ const field = (pool, name) =>
358
+ median(pool.map((s) => s.resourceTypes?.[type]?.[name] ?? 0)) ?? 0;
359
+ const row = {
360
+ type,
361
+ count: Math.round(field(samples, 'count')),
362
+ kb: kb(field(samples, 'bytes')),
363
+ preFcpCount: painted.length ? Math.round(field(painted, 'preFcpCount')) : 0,
364
+ preFcpKb: painted.length ? kb(field(painted, 'preFcpBytes')) : 0,
365
+ };
366
+ if (row.count > 0) rows.push(row);
367
+ }
368
+ return rows.sort((a, b) => b.preFcpKb - a.preFcpKb || b.kb - a.kb);
369
+ }
370
+
371
+ /** "3 font 240KB" — or "3 font" when sizes are hidden (cross-origin without Timing-Allow-Origin). */
372
+ export function weightLabel(row) {
373
+ const kb = Math.round(row.preFcpKb);
374
+ return `${row.preFcpCount} ${row.type}${kb >= 1 ? ` ${kb}KB` : ''}`;
375
+ }
376
+
377
+ /**
378
+ * Non-fetch resource types heavy enough before first paint to plausibly gate
379
+ * rendering — the fonts/images a fetch-only render-gap analysis is blind to.
380
+ */
381
+ export function heavyPrepaintTypes(weightRows) {
382
+ return (weightRows ?? []).filter(
383
+ (row) =>
384
+ (row.type === 'font' && (row.preFcpKb >= 50 || row.preFcpCount >= 5)) ||
385
+ (row.type === 'image' && row.preFcpKb >= 100),
386
+ );
387
+ }
388
+
389
+ /**
390
+ * FCP cannot precede the last render-blocking stylesheet (spec: nothing paints
391
+ * until blocking CSS arrives and parses). When that stylesheet occupies a large
392
+ * share of the FCP timeline, CSS is the paint gate — a class every JS-side
393
+ * signal is blind to. (jellyfin A/B 2026-07-11: unblocking CSS scored −50.1%
394
+ * where the JS-lead path got −36.5%; the harness had no CSS signal at all, and
395
+ * a "finished within 300ms of FCP" rule ALSO missed it — the CSS finished 1s
396
+ * before FCP yet held 77% of its timeline. Judge by share, not adjacency.)
397
+ */
398
+ export function renderBlockingGate(samples) {
399
+ const painted = samples.filter((s) => typeof s.fcp === 'number' && s.blockingGate);
400
+ if (!painted.length) return null;
401
+ const count = Math.max(...painted.map((s) => s.blockingGate.count ?? 0));
402
+ if (!count) return null;
403
+ const fcpMs = median(painted.map((s) => s.fcp));
404
+ const lastEndMs = median(painted.map((s) => s.blockingGate.lastEndMs ?? 0));
405
+ const kb = Math.round((median(painted.map((s) => s.blockingGate.bytes ?? 0)) ?? 0) / 1024);
406
+ const mid = painted.slice().sort((a, b) => a.fcp - b.fcp)[Math.floor(painted.length / 2)];
407
+ const shareOfFcp = fcpMs > 0 ? (lastEndMs ?? 0) / fcpMs : 0;
408
+ return {
409
+ count,
410
+ kb,
411
+ lastEndMs: Math.round(lastEndMs ?? 0),
412
+ fcpMs: Math.round(fcpMs ?? 0),
413
+ shareOfFcp: Math.round(shareOfFcp * 100) / 100,
414
+ gating: shareOfFcp >= 0.4 && kb >= 8,
415
+ worst: mid.blockingGate.worst ?? [],
416
+ };
417
+ }
418
+
419
+ /**
420
+ * The element the LCP timestamp belongs to, by majority across runs (a page whose
421
+ * LCP element flips between runs reports the common one and how often it won).
422
+ */
423
+ export function lcpElementOf(samples) {
424
+ const tally = new Map();
425
+ for (const sample of samples) {
426
+ const d = sample.lcpElement;
427
+ if (!d?.tag) continue;
428
+ const key = `${d.path}|${d.url}`;
429
+ const entry = tally.get(key) ?? { d, runs: 0 };
430
+ entry.runs += 1;
431
+ tally.set(key, entry);
432
+ }
433
+ let best = null;
434
+ for (const entry of tally.values()) if (!best || entry.runs > best.runs) best = entry;
435
+ return best ? { ...best.d, runs: best.runs, of: samples.length } : null;
436
+ }
437
+
438
+ /** Fold N samples into the flat runtime metric-id map plus the correctness guard. */
439
+ export function summarize(samples, expectedFeatures = []) {
440
+ const nums = (key) => samples.map((s) => s[key]).filter((v) => typeof v === 'number');
441
+ const clsValues = nums('cls');
442
+ const renderGaps = samples
443
+ .filter((s) => typeof s.lcp === 'number' && typeof s.load === 'number')
444
+ .map((s) => s.lcp - s.load);
445
+ const prepaintLongtask = samples
446
+ .filter((s) => typeof s.fcp === 'number')
447
+ .map((s) =>
448
+ (s.longtasks ?? []).filter((t) => t.start < s.fcp).reduce((sum, t) => sum + t.duration, 0),
449
+ );
450
+ return {
451
+ runs: samples.length,
452
+ metrics: {
453
+ 'runtime.lcp_ms': round1(median(nums('lcp'))),
454
+ 'runtime.lcp_p75_ms': round1(quantile(nums('lcp'), 0.75)),
455
+ 'runtime.fcp_ms': round1(median(nums('fcp'))),
456
+ 'runtime.ttfb_ms': round1(median(nums('ttfb'))),
457
+ 'runtime.load_ms': round1(median(nums('load'))),
458
+ 'runtime.cls': round3(clsValues.length ? Math.max(...clsValues) : null),
459
+ 'runtime.transfer_bytes': Math.round(median(nums('bytes')) ?? 0),
460
+ 'runtime.js_request_count': Math.round(median(nums('jsRequests')) ?? 0),
461
+ // How long after `load` the largest paint landed: a big gap means rendering
462
+ // is gated on post-load work (an awaited fetch, a lazily fetched chunk, CPU).
463
+ 'runtime.render_gap_ms': round1(median(renderGaps)),
464
+ // Long-task time before first paint: boot CPU running ahead of render.
465
+ 'runtime.prepaint_longtask_ms': round1(median(prepaintLongtask)),
466
+ },
467
+ gatingFetches: gatingFetches(samples),
468
+ resourceWeight: resourceWeight(samples),
469
+ renderBlockingGate: renderBlockingGate(samples),
470
+ postLoadScripts: postLoadScripts(samples),
471
+ resourceDetailsComplete:
472
+ samples.length > 0 && samples.every((s) => s.resourceDetailsComplete === true),
473
+ lcpElement: lcpElementOf(samples),
474
+ guard: {
475
+ allFeaturesReady: samples.every((s) =>
476
+ expectedFeatures.every((f) => s.ready && s.ready[f] === true),
477
+ ),
478
+ // null when the page has no hero probe elements (measuring an arbitrary app
479
+ // via --dist); the demo app must render both.
480
+ heroRendered: samples.some((s) => s.heroTitle !== null || s.heroSubtitle !== null)
481
+ ? samples.every(
482
+ (s) =>
483
+ (s.heroTitle ?? '').length > 0 &&
484
+ (s.heroSubtitle === null || s.heroSubtitle.length > 0),
485
+ )
486
+ : null,
487
+ fcpObservedInAllRuns: samples.every((s) => typeof s.fcp === 'number'),
488
+ lcpObservedInAllRuns: samples.every((s) => typeof s.lcp === 'number'),
489
+ },
490
+ samples,
491
+ };
492
+ }
493
+
494
+ /** Scripts discovered after load that finished before FCP or LCP. Not proof of a paint dependency. */
495
+ export function postLoadScripts(samples) {
496
+ const result = {};
497
+ for (const paint of ['fcp', 'lcp']) {
498
+ const valid = samples.filter((s) => typeof s[paint] === 'number' && s.load > 0);
499
+ if (!valid.length) {
500
+ result[paint] = null;
501
+ continue;
502
+ }
503
+ const rows = valid.map((s) => {
504
+ const scripts = (s.resources ?? []).filter(
505
+ (r) => r.type === 'script' && r.start > s.load && r.end > 0 && r.end <= s[paint],
506
+ );
507
+ return {
508
+ count: scripts.length,
509
+ bytes: scripts.reduce((sum, r) => sum + r.bytes, 0),
510
+ firstStartMs: scripts.length ? Math.min(...scripts.map((r) => r.start)) : null,
511
+ lastEndMs: scripts.length ? Math.max(...scripts.map((r) => r.end)) : null,
512
+ };
513
+ });
514
+ const mid = (key) => median(rows.map((r) => r[key]).filter((v) => typeof v === 'number'));
515
+ result[paint] = {
516
+ count: mid('count'),
517
+ bytes: mid('bytes'),
518
+ firstStartMs: mid('firstStartMs'),
519
+ lastEndMs: mid('lastEndMs'),
520
+ };
521
+ }
522
+ return result;
523
+ }
524
+
525
+ /** Per-metric prev/curr/delta/pct — the same shape as build metrics.json deltas. */
526
+ export function deltaSection(prevMetrics, currMetrics) {
527
+ const out = {};
528
+ for (const [id, curr] of Object.entries(currMetrics)) {
529
+ const prev = prevMetrics?.[id];
530
+ if (typeof prev !== 'number' || typeof curr !== 'number') continue;
531
+ const delta = curr - prev;
532
+ out[id] = {
533
+ prev,
534
+ curr,
535
+ delta: Math.round(delta * 1000) / 1000,
536
+ pct: prev === 0 ? null : Math.round((delta / prev) * 1000) / 10,
537
+ };
538
+ }
539
+ return out;
540
+ }