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.
- package/AGENTS.md +345 -0
- package/LICENSE +25 -0
- package/README.md +621 -2
- package/harness.mjs +4860 -0
- package/lib/build.mjs +57 -0
- package/lib/cdp.mjs +217 -0
- package/lib/chunk-discovery.mjs +226 -0
- package/lib/coverage.mjs +498 -0
- package/lib/field/analysis.mjs +225 -0
- package/lib/field/browser.mjs +232 -0
- package/lib/field/build-match.mjs +86 -0
- package/lib/field/capture.mjs +501 -0
- package/lib/field/config.mjs +84 -0
- package/lib/field/run.mjs +612 -0
- package/lib/gen-app.mjs +399 -0
- package/lib/graph-diff.mjs +136 -0
- package/lib/interrupt.mjs +25 -0
- package/lib/measure.mjs +540 -0
- package/lib/min-cut.mjs +218 -0
- package/lib/module-graph.mjs +318 -0
- package/lib/network-evidence.mjs +84 -0
- package/lib/node/launch.mjs +351 -0
- package/lib/node/measure.mjs +289 -0
- package/lib/paint-diagnostics.mjs +76 -0
- package/lib/parity/anchor.mjs +186 -0
- package/lib/parity/capture.mjs +527 -0
- package/lib/parity/compare.mjs +813 -0
- package/lib/parity/png.mjs +166 -0
- package/lib/parity/run.mjs +145 -0
- package/lib/profile.mjs +281 -0
- package/lib/replay/cert.mjs +45 -0
- package/lib/replay/model.mjs +656 -0
- package/lib/replay/run.mjs +1140 -0
- package/lib/replay/server.mjs +499 -0
- package/lib/replay/shaper.mjs +158 -0
- package/lib/rolldown-support.mjs +161 -0
- package/lib/serve.mjs +142 -0
- package/lib/worker/attrib.mjs +125 -0
- package/lib/worker/config.mjs +116 -0
- package/lib/worker/launch.mjs +337 -0
- package/lib/worker/measure.mjs +296 -0
- package/package.json +38 -4
- package/replay.mjs +79 -0
package/lib/measure.mjs
ADDED
|
@@ -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
|
+
}
|