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
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
// One field navigation, in three kinds. They stay separate on purpose: precise
|
|
2
|
+
// coverage, the sampling profiler and body buffers slow the page down, so a run
|
|
3
|
+
// that collects them cannot also be a timing run.
|
|
4
|
+
// timingRun - web vitals, every request with CDP timing and initiator stack
|
|
5
|
+
// diagnosticRun - V8 precise coverage at a cut point and at settle, a CPU
|
|
6
|
+
// profile to the cut, and the body of each same-origin script
|
|
7
|
+
// (for build matching)
|
|
8
|
+
// bodyRun - every response body, response headers and request bodies,
|
|
9
|
+
// for a replay archive (record command)
|
|
10
|
+
|
|
11
|
+
import { sleep } from '../cdp.mjs';
|
|
12
|
+
import { COLLECT_JS, OBSERVER_JS } from '../measure.mjs';
|
|
13
|
+
import { openFieldPage } from './browser.mjs';
|
|
14
|
+
|
|
15
|
+
/** Every LCP entry (the lab observer keeps only the last), page visibility. */
|
|
16
|
+
export function fieldObserverJs(ignoreSelector) {
|
|
17
|
+
return `(() => {
|
|
18
|
+
const F = (window.__fieldMetrics = { lcp: [], hidden: document.visibilityState === 'hidden' });
|
|
19
|
+
const sel = ${JSON.stringify(ignoreSelector ?? null)};
|
|
20
|
+
addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') F.hidden = true; }, true);
|
|
21
|
+
try {
|
|
22
|
+
new PerformanceObserver((list) => {
|
|
23
|
+
for (const e of list.getEntries()) {
|
|
24
|
+
let ignored = false;
|
|
25
|
+
try { ignored = Boolean(sel && e.element && e.element.closest(sel)); } catch {}
|
|
26
|
+
F.lcp.push({ t: e.startTime, size: e.size, ignored });
|
|
27
|
+
}
|
|
28
|
+
}).observe({ type: 'largest-contentful-paint', buffered: true });
|
|
29
|
+
} catch {}
|
|
30
|
+
})();`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const STATE_JS = `(() => {
|
|
34
|
+
const nav = performance.getEntriesByType('navigation')[0];
|
|
35
|
+
const F = window.__fieldMetrics || { lcp: [], hidden: false };
|
|
36
|
+
const counted = F.lcp.filter((e) => !e.ignored);
|
|
37
|
+
return {
|
|
38
|
+
load: nav ? nav.loadEventEnd : 0,
|
|
39
|
+
lcp: counted.length ? counted[counted.length - 1].t : null,
|
|
40
|
+
fcp: (window.__perfMetrics || {}).fcp ?? null,
|
|
41
|
+
hidden: F.hidden,
|
|
42
|
+
};
|
|
43
|
+
})()`;
|
|
44
|
+
|
|
45
|
+
const PAGE_INFO_JS = `(() => {
|
|
46
|
+
const nav = performance.getEntriesByType('navigation')[0];
|
|
47
|
+
const F = window.__fieldMetrics || { lcp: [] };
|
|
48
|
+
const entry = [...document.querySelectorAll('script[type="module"][src]')]
|
|
49
|
+
.map((s) => new URL(s.src, location.href))
|
|
50
|
+
.find((u) => u.origin === location.origin);
|
|
51
|
+
const c = navigator.connection;
|
|
52
|
+
return {
|
|
53
|
+
path: location.pathname,
|
|
54
|
+
navUrl: nav ? nav.name.split('#')[0] : null,
|
|
55
|
+
requestStart: nav ? nav.requestStart : null,
|
|
56
|
+
lcpEntries: F.lcp,
|
|
57
|
+
hidden: F.hidden,
|
|
58
|
+
entry: entry ? entry.pathname : null,
|
|
59
|
+
connection: c ? { effectiveType: c.effectiveType, rttMs: c.rtt, downlinkMbps: c.downlink } : null,
|
|
60
|
+
};
|
|
61
|
+
})()`;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Requests with CDP timing, in CDP seconds until `finish` converts them to page
|
|
65
|
+
* time. Keeps URL, method, type, priority and the initiator's call frames - never
|
|
66
|
+
* headers, cookies or bodies. For the document it also keeps when its bytes
|
|
67
|
+
* arrived (sizes only): a host that flushes the head early starts the page
|
|
68
|
+
* earlier than one that sends the HTML at once.
|
|
69
|
+
*/
|
|
70
|
+
export function recordFieldNetwork(page, { maxFrames = 8 } = {}) {
|
|
71
|
+
const byId = new Map();
|
|
72
|
+
const order = [];
|
|
73
|
+
const stops = [
|
|
74
|
+
page.on('Network.requestWillBeSent', (e) => {
|
|
75
|
+
const frames = [];
|
|
76
|
+
for (let st = e.initiator?.stack; st && frames.length < maxFrames; st = st.parent) {
|
|
77
|
+
for (const f of st.callFrames ?? []) {
|
|
78
|
+
if (f.url && frames.length < maxFrames) {
|
|
79
|
+
frames.push({
|
|
80
|
+
url: f.url,
|
|
81
|
+
functionName: f.functionName,
|
|
82
|
+
lineNumber: f.lineNumber,
|
|
83
|
+
columnNumber: f.columnNumber,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const previous = byId.get(e.requestId);
|
|
89
|
+
const row = {
|
|
90
|
+
id: e.requestId,
|
|
91
|
+
url: e.request.url.split('#')[0],
|
|
92
|
+
method: e.request.method,
|
|
93
|
+
type: e.type ?? null,
|
|
94
|
+
ts: e.timestamp,
|
|
95
|
+
priority: e.request.initialPriority ?? null,
|
|
96
|
+
redirected: Boolean(e.redirectResponse) || Boolean(previous?.redirected),
|
|
97
|
+
initiator: {
|
|
98
|
+
type: e.initiator?.type ?? null,
|
|
99
|
+
url: frames[0]?.url ?? e.initiator?.url ?? null,
|
|
100
|
+
frames,
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
if (!previous) order.push(e.requestId);
|
|
104
|
+
byId.set(e.requestId, row);
|
|
105
|
+
}),
|
|
106
|
+
page.on('Network.responseReceived', (e) => {
|
|
107
|
+
const r = byId.get(e.requestId);
|
|
108
|
+
if (!r) return;
|
|
109
|
+
r.status = e.response.status;
|
|
110
|
+
r.protocol = e.response.protocol ?? null;
|
|
111
|
+
r.type = e.type ?? r.type;
|
|
112
|
+
const t = e.response.timing;
|
|
113
|
+
if (t) {
|
|
114
|
+
r.sendStart = t.requestTime + t.sendStart / 1000;
|
|
115
|
+
r.headersEnd = t.requestTime + t.receiveHeadersEnd / 1000;
|
|
116
|
+
}
|
|
117
|
+
}),
|
|
118
|
+
page.on('Network.resourceChangedPriority', (e) => {
|
|
119
|
+
const r = byId.get(e.requestId);
|
|
120
|
+
if (r) r.lastPriority = e.newPriority;
|
|
121
|
+
}),
|
|
122
|
+
page.on('Network.dataReceived', (e) => {
|
|
123
|
+
const r = byId.get(e.requestId);
|
|
124
|
+
if (r?.type === 'Document') (r.chunks ??= []).push({ ts: e.timestamp, bytes: e.dataLength });
|
|
125
|
+
}),
|
|
126
|
+
page.on('Network.loadingFinished', (e) => {
|
|
127
|
+
const r = byId.get(e.requestId);
|
|
128
|
+
if (!r) return;
|
|
129
|
+
r.end = e.timestamp;
|
|
130
|
+
r.encodedBytes = e.encodedDataLength;
|
|
131
|
+
}),
|
|
132
|
+
page.on('Network.loadingFailed', (e) => {
|
|
133
|
+
const r = byId.get(e.requestId);
|
|
134
|
+
if (!r) return;
|
|
135
|
+
r.end = e.timestamp;
|
|
136
|
+
r.failed = e.errorText;
|
|
137
|
+
r.blocked = e.blockedReason ?? null;
|
|
138
|
+
}),
|
|
139
|
+
];
|
|
140
|
+
return {
|
|
141
|
+
rows: () => order.map((id) => byId.get(id)),
|
|
142
|
+
stop: () => stops.forEach((stop) => stop()),
|
|
143
|
+
/**
|
|
144
|
+
* Page time (ms since timeOrigin, the clock LCP uses) for every request. The
|
|
145
|
+
* offset comes from the document request: its CDP sendStart equals the
|
|
146
|
+
* navigation entry's requestStart. After a client-side redirect the page is
|
|
147
|
+
* the second document, so `navUrl` (the navigation entry's URL) picks it; that
|
|
148
|
+
* row is marked `navigation`.
|
|
149
|
+
*/
|
|
150
|
+
finish(requestStartPageMs, redact, navUrl = null) {
|
|
151
|
+
const rows = order.map((id) => byId.get(id));
|
|
152
|
+
const docs = rows.filter((r) => r.type === 'Document' && r.sendStart != null);
|
|
153
|
+
const doc = docs.find((r) => r.url === navUrl) ?? docs[0];
|
|
154
|
+
const offset =
|
|
155
|
+
doc && typeof requestStartPageMs === 'number'
|
|
156
|
+
? doc.sendStart * 1000 - requestStartPageMs
|
|
157
|
+
: rows[0]
|
|
158
|
+
? rows[0].ts * 1000
|
|
159
|
+
: 0;
|
|
160
|
+
const pt = (s) => (typeof s === 'number' ? Math.round((s * 1000 - offset) * 10) / 10 : null);
|
|
161
|
+
return rows.map((r) => ({
|
|
162
|
+
url: redact(r.url),
|
|
163
|
+
method: r.method,
|
|
164
|
+
type: r.type,
|
|
165
|
+
status: r.status ?? null,
|
|
166
|
+
protocol: r.protocol ?? null,
|
|
167
|
+
priority: r.priority,
|
|
168
|
+
lastPriority: r.lastPriority ?? r.priority,
|
|
169
|
+
startMs: pt(r.ts),
|
|
170
|
+
sendStartMs: pt(r.sendStart),
|
|
171
|
+
headersEndMs: pt(r.headersEnd),
|
|
172
|
+
endMs: pt(r.end),
|
|
173
|
+
encodedBytes: r.encodedBytes ?? null,
|
|
174
|
+
failed: r.failed ?? null,
|
|
175
|
+
blocked: r.blocked ?? null,
|
|
176
|
+
redirected: r.redirected,
|
|
177
|
+
...(r === doc ? { navigation: true } : {}),
|
|
178
|
+
...(r.chunks ? { chunks: r.chunks.map((c) => ({ ms: pt(c.ts), bytes: c.bytes })) } : {}),
|
|
179
|
+
initiator: {
|
|
180
|
+
type: r.initiator.type,
|
|
181
|
+
url: redact(r.initiator.url),
|
|
182
|
+
frames: r.initiator.frames.map((f) => ({ ...f, url: redact(f.url) })),
|
|
183
|
+
},
|
|
184
|
+
}));
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function waitFor(page, predicate, timeoutMs) {
|
|
190
|
+
const deadline = Date.now() + timeoutMs;
|
|
191
|
+
for (;;) {
|
|
192
|
+
const state = await page.evaluate(STATE_JS).catch(() => null);
|
|
193
|
+
if (state && (predicate(state) || state.hidden)) return state;
|
|
194
|
+
if (Date.now() > deadline) return state;
|
|
195
|
+
await sleep(25);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** The lab sample shape, without the text an LCP element shows (real account data). */
|
|
200
|
+
function scrubSample(sample, redact) {
|
|
201
|
+
const out = { ...sample };
|
|
202
|
+
out.resources = (sample.resources ?? []).map((r) => ({ ...r, name: redact(r.name) }));
|
|
203
|
+
if (out.lcpElement) {
|
|
204
|
+
const { text, finalText, ...rest } = out.lcpElement;
|
|
205
|
+
out.lcpElement = {
|
|
206
|
+
...rest,
|
|
207
|
+
url: redact(rest.url),
|
|
208
|
+
finalUrl: redact(rest.finalUrl),
|
|
209
|
+
textLength: (finalText ?? text ?? '').length,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
if (out.blockingGate?.worst) {
|
|
213
|
+
out.blockingGate = {
|
|
214
|
+
...out.blockingGate,
|
|
215
|
+
worst: out.blockingGate.worst.map((w) => ({ ...w, name: redact(w.name) })),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
delete out.heroTitle;
|
|
219
|
+
delete out.heroSubtitle;
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** `keepRaw` adds `rawRequests`: the same rows with raw URLs (archives, chunk names). */
|
|
224
|
+
export async function timingRun(
|
|
225
|
+
cdp,
|
|
226
|
+
{
|
|
227
|
+
variant,
|
|
228
|
+
config,
|
|
229
|
+
context,
|
|
230
|
+
saved,
|
|
231
|
+
configured,
|
|
232
|
+
redact,
|
|
233
|
+
settleMs,
|
|
234
|
+
timeoutMs,
|
|
235
|
+
throttle,
|
|
236
|
+
keepRaw = false,
|
|
237
|
+
},
|
|
238
|
+
) {
|
|
239
|
+
const page = await openFieldPage(cdp, {
|
|
240
|
+
browserContextId: context,
|
|
241
|
+
origin: config.origin,
|
|
242
|
+
cookies: variant.cookies,
|
|
243
|
+
saved,
|
|
244
|
+
configured,
|
|
245
|
+
block: config.block,
|
|
246
|
+
scripts: [OBSERVER_JS, fieldObserverJs(config.ignoreLcp)],
|
|
247
|
+
throttle,
|
|
248
|
+
});
|
|
249
|
+
const net = recordFieldNetwork(page);
|
|
250
|
+
try {
|
|
251
|
+
await page.navigate(variant.url);
|
|
252
|
+
await waitFor(page, (s) => s.load > 0 && s.lcp != null, timeoutMs);
|
|
253
|
+
await sleep(settleMs);
|
|
254
|
+
const raw = await page.evaluate(COLLECT_JS);
|
|
255
|
+
const info = await page.evaluate(PAGE_INFO_JS);
|
|
256
|
+
const counted = info.lcpEntries.filter((e) => !e.ignored);
|
|
257
|
+
const sample = scrubSample(raw, redact);
|
|
258
|
+
// The lab observer keeps the LAST entry, which may sit in an ignored region
|
|
259
|
+
// (a splash/loader). Field LCP is the last entry outside it.
|
|
260
|
+
sample.lcp = counted.length ? counted[counted.length - 1].t : null;
|
|
261
|
+
sample.lcpIgnoredEntries = info.lcpEntries.length - counted.length;
|
|
262
|
+
const requests = net.finish(info.requestStart, redact, info.navUrl);
|
|
263
|
+
let invalid = null;
|
|
264
|
+
if (info.hidden) invalid = 'page was hidden (window covered or in the background)';
|
|
265
|
+
else if (sample.lcp == null) invalid = 'no LCP outside the ignored region';
|
|
266
|
+
return {
|
|
267
|
+
sample,
|
|
268
|
+
requests,
|
|
269
|
+
...(keepRaw ? { rawRequests: net.finish(info.requestStart, (v) => v, info.navUrl) } : {}),
|
|
270
|
+
path: redact(info.path),
|
|
271
|
+
entry: info.entry,
|
|
272
|
+
connection: info.connection,
|
|
273
|
+
invalid,
|
|
274
|
+
};
|
|
275
|
+
} finally {
|
|
276
|
+
net.stop();
|
|
277
|
+
await page.close().catch(() => {});
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Recording run for a replay archive. Returns every request (page time, raw URL)
|
|
283
|
+
* with its response headers, decoded response body and request body. The caller
|
|
284
|
+
* keeps only safelisted headers and body hashes, and writes the bodies outside
|
|
285
|
+
* the repo. Nothing here is printed. Page state that the replay cannot copy is
|
|
286
|
+
* described by names and counts only: script-visible cookie names and the
|
|
287
|
+
* number of localStorage keys.
|
|
288
|
+
*/
|
|
289
|
+
export async function bodyRun(
|
|
290
|
+
cdp,
|
|
291
|
+
{ variant, config, context, saved, configured, settleMs, timeoutMs },
|
|
292
|
+
) {
|
|
293
|
+
const page = await openFieldPage(cdp, {
|
|
294
|
+
browserContextId: context,
|
|
295
|
+
origin: config.origin,
|
|
296
|
+
cookies: variant.cookies,
|
|
297
|
+
saved,
|
|
298
|
+
configured,
|
|
299
|
+
block: config.block,
|
|
300
|
+
scripts: [OBSERVER_JS, fieldObserverJs(config.ignoreLcp)],
|
|
301
|
+
bodies: true,
|
|
302
|
+
});
|
|
303
|
+
const net = recordFieldNetwork(page);
|
|
304
|
+
const extra = new Map();
|
|
305
|
+
const of = (id) => extra.get(id) ?? extra.set(id, {}).get(id);
|
|
306
|
+
const stops = [
|
|
307
|
+
page.on('Network.requestWillBeSent', (e) => {
|
|
308
|
+
const x = of(e.requestId);
|
|
309
|
+
x.postData = e.request.postData ?? null;
|
|
310
|
+
x.hasPostData = Boolean(e.request.hasPostData);
|
|
311
|
+
}),
|
|
312
|
+
page.on('Network.responseReceived', (e) => {
|
|
313
|
+
of(e.requestId).headers = e.response.headers;
|
|
314
|
+
}),
|
|
315
|
+
];
|
|
316
|
+
try {
|
|
317
|
+
await page.navigate(variant.url);
|
|
318
|
+
await waitFor(page, (s) => s.load > 0 && s.lcp != null, timeoutMs);
|
|
319
|
+
await sleep(settleMs);
|
|
320
|
+
const info = await page.evaluate(PAGE_INFO_JS);
|
|
321
|
+
const state = await page
|
|
322
|
+
.evaluate(
|
|
323
|
+
`({ cookieNames: document.cookie.split(';').map((c) => c.split('=')[0].trim()).filter(Boolean),
|
|
324
|
+
localStorageKeys: (() => { try { return localStorage.length; } catch { return null; } })() })`,
|
|
325
|
+
)
|
|
326
|
+
.catch(() => ({ cookieNames: [], localStorageKeys: null }));
|
|
327
|
+
const raw = net.rows();
|
|
328
|
+
const timed = net.finish(info.requestStart, (v) => v, info.navUrl);
|
|
329
|
+
const rows = [];
|
|
330
|
+
for (let i = 0; i < raw.length; i++) {
|
|
331
|
+
const x = extra.get(raw[i].id) ?? {};
|
|
332
|
+
let body = null;
|
|
333
|
+
if (!raw[i].failed && raw[i].end != null && /^https?:/.test(raw[i].url)) {
|
|
334
|
+
try {
|
|
335
|
+
const res = await page.send('Network.getResponseBody', { requestId: raw[i].id });
|
|
336
|
+
body = Buffer.from(res.body, res.base64Encoded ? 'base64' : 'utf8');
|
|
337
|
+
} catch {
|
|
338
|
+
/* no body (204, redirect) or evicted from the buffer */
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
let postData = x.postData ?? null;
|
|
342
|
+
if (postData == null && x.hasPostData) {
|
|
343
|
+
postData = await page
|
|
344
|
+
.send('Network.getRequestPostData', { requestId: raw[i].id })
|
|
345
|
+
.then((r) => r.postData)
|
|
346
|
+
.catch(() => null);
|
|
347
|
+
}
|
|
348
|
+
rows.push({ ...timed[i], headers: x.headers ?? {}, body, postData });
|
|
349
|
+
}
|
|
350
|
+
let invalid = null;
|
|
351
|
+
if (info.hidden) invalid = 'page was hidden (window covered or in the background)';
|
|
352
|
+
return { rows, page: { path: info.path, entry: info.entry, ...state }, invalid };
|
|
353
|
+
} finally {
|
|
354
|
+
stops.forEach((stop) => stop());
|
|
355
|
+
net.stop();
|
|
356
|
+
await page.close().catch(() => {});
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Coverage + CPU profile run. Cut points:
|
|
362
|
+
* fcp - first contentful paint (the lab's cut)
|
|
363
|
+
* lcp - the first LCP entry outside the ignored region
|
|
364
|
+
* gate - the moment the config's gate request is sent: "what ran before the
|
|
365
|
+
* request the LCP waits for". The snapshot is async, so a few ms of
|
|
366
|
+
* later execution can land in it.
|
|
367
|
+
* Coverage is armed on about:blank and survives the navigation; if the entry
|
|
368
|
+
* script is missing from the result, the caller reports it.
|
|
369
|
+
*/
|
|
370
|
+
export async function diagnosticRun(
|
|
371
|
+
cdp,
|
|
372
|
+
{ variant, config, context, saved, configured, redact, cut, settleMs, timeoutMs, throttle },
|
|
373
|
+
) {
|
|
374
|
+
const page = await openFieldPage(cdp, {
|
|
375
|
+
browserContextId: context,
|
|
376
|
+
origin: config.origin,
|
|
377
|
+
cookies: variant.cookies,
|
|
378
|
+
saved,
|
|
379
|
+
configured,
|
|
380
|
+
block: config.block,
|
|
381
|
+
scripts: [OBSERVER_JS, fieldObserverJs(config.ignoreLcp)],
|
|
382
|
+
bodies: true,
|
|
383
|
+
throttle,
|
|
384
|
+
});
|
|
385
|
+
const net = recordFieldNetwork(page, { maxFrames: 64 });
|
|
386
|
+
let gateSeen = false;
|
|
387
|
+
// The timing runs find the gate in redacted URLs, so the cut matches the same way.
|
|
388
|
+
const gate = redact(config.gate);
|
|
389
|
+
const stopGate =
|
|
390
|
+
cut === 'gate' && gate
|
|
391
|
+
? page.on('Network.requestWillBeSent', (e) => {
|
|
392
|
+
if (redact(e.request.url).includes(gate)) gateSeen = true;
|
|
393
|
+
})
|
|
394
|
+
: () => {};
|
|
395
|
+
// With the debugger on, a `debugger` statement or a breakpoint stops the page
|
|
396
|
+
// until a client resumes it. Resume at once: Debugger.setSkipAllPauses does
|
|
397
|
+
// not survive the navigation.
|
|
398
|
+
const stopPauses = page.on('Debugger.paused', () => {
|
|
399
|
+
page.send('Debugger.resume').catch(() => {});
|
|
400
|
+
});
|
|
401
|
+
try {
|
|
402
|
+
// Async parent stacks make the initiator of a fetch name its real caller,
|
|
403
|
+
// not only the wrapper or microtask that issued it. They cost CPU, which is
|
|
404
|
+
// one more reason this run never feeds timing numbers.
|
|
405
|
+
await page.send('Debugger.enable');
|
|
406
|
+
await page.send('Debugger.setAsyncCallStackDepth', { maxDepth: 32 });
|
|
407
|
+
await page.send('Profiler.enable');
|
|
408
|
+
await page.send('Profiler.setSamplingInterval', { interval: 200 });
|
|
409
|
+
await page.send('Profiler.startPreciseCoverage', { callCount: false, detailed: true });
|
|
410
|
+
await page.send('Profiler.start');
|
|
411
|
+
await page.navigate(variant.url);
|
|
412
|
+
const deadline = Date.now() + timeoutMs;
|
|
413
|
+
let state = null;
|
|
414
|
+
let reached = false;
|
|
415
|
+
for (;;) {
|
|
416
|
+
state = await page.evaluate(STATE_JS).catch(() => null);
|
|
417
|
+
reached = cut === 'gate' ? gateSeen : cut === 'fcp' ? state?.fcp != null : state?.lcp != null;
|
|
418
|
+
if (reached || state?.hidden || Date.now() > deadline) break;
|
|
419
|
+
await sleep(cut === 'gate' ? 5 : 25);
|
|
420
|
+
}
|
|
421
|
+
const atCut = await page.send('Profiler.takePreciseCoverage');
|
|
422
|
+
const cutMs = await page.evaluate('performance.now()');
|
|
423
|
+
const { profile } = await page.send('Profiler.stop');
|
|
424
|
+
await waitFor(page, (s) => s.load > 0 && s.lcp != null, Math.max(0, deadline - Date.now()));
|
|
425
|
+
await sleep(settleMs);
|
|
426
|
+
const atSettle = await page.send('Profiler.takePreciseCoverage');
|
|
427
|
+
await page.send('Profiler.stopPreciseCoverage').catch(() => {});
|
|
428
|
+
const info = await page.evaluate(PAGE_INFO_JS);
|
|
429
|
+
const requests = net.finish(info.requestStart, redact, info.navUrl);
|
|
430
|
+
|
|
431
|
+
// V8 omits a script from a snapshot when it ran nothing new in that window.
|
|
432
|
+
const byUrl = new Map();
|
|
433
|
+
const collect = (take, key) => {
|
|
434
|
+
for (const s of take.result) {
|
|
435
|
+
if (!s.url || !s.url.startsWith(config.origin)) continue;
|
|
436
|
+
const rec = byUrl.get(s.url) ?? {
|
|
437
|
+
url: s.url,
|
|
438
|
+
pathname: new URL(s.url).pathname,
|
|
439
|
+
atPaint: null,
|
|
440
|
+
atSettle: null,
|
|
441
|
+
};
|
|
442
|
+
rec[key] = (rec[key] ?? []).concat(s.functions);
|
|
443
|
+
byUrl.set(s.url, rec);
|
|
444
|
+
}
|
|
445
|
+
};
|
|
446
|
+
collect(atCut, 'atPaint');
|
|
447
|
+
collect(atSettle, 'atSettle');
|
|
448
|
+
|
|
449
|
+
// Bodies for build matching: only same-origin scripts that executed.
|
|
450
|
+
const rows = net.rows();
|
|
451
|
+
const bodies = new Map();
|
|
452
|
+
for (const url of byUrl.keys()) {
|
|
453
|
+
if (!/\.[mc]?js$/.test(new URL(url).pathname)) continue;
|
|
454
|
+
const row = rows.find((r) => r.url === url && !r.failed);
|
|
455
|
+
if (!row) continue;
|
|
456
|
+
try {
|
|
457
|
+
const { body, base64Encoded } = await page.send('Network.getResponseBody', {
|
|
458
|
+
requestId: row.id,
|
|
459
|
+
});
|
|
460
|
+
bodies.set(url, base64Encoded ? Buffer.from(body, 'base64').toString('utf8') : body);
|
|
461
|
+
} catch {
|
|
462
|
+
/* evicted from the buffer: chunk-level coverage still works, matching does not */
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
// Raw URLs: a redacted chunk name (a long hex hash) would not match its script.
|
|
466
|
+
const scriptFetches = net
|
|
467
|
+
.finish(info.requestStart, (v) => v, info.navUrl)
|
|
468
|
+
.filter((r) => r.url.startsWith(config.origin) && /\.[mc]?js$/.test(new URL(r.url).pathname))
|
|
469
|
+
.map((r) => ({
|
|
470
|
+
pathname: new URL(r.url).pathname,
|
|
471
|
+
startMs: r.startMs,
|
|
472
|
+
endMs: r.endMs,
|
|
473
|
+
bytes: r.encodedBytes ?? 0,
|
|
474
|
+
prePaint: typeof r.startMs === 'number' && r.startMs < cutMs,
|
|
475
|
+
}));
|
|
476
|
+
let invalid = null;
|
|
477
|
+
if (info.hidden || state?.hidden)
|
|
478
|
+
invalid = 'page was hidden (window covered or in the background)';
|
|
479
|
+
else if (cut === 'gate' && !reached)
|
|
480
|
+
invalid = `gate request "${gate}" was not sent within ${timeoutMs}ms`;
|
|
481
|
+
else if (!reached) invalid = `no ${cut.toUpperCase()} within ${timeoutMs}ms`;
|
|
482
|
+
return {
|
|
483
|
+
scripts: [...byUrl.values()],
|
|
484
|
+
bodies,
|
|
485
|
+
requests,
|
|
486
|
+
scriptFetches,
|
|
487
|
+
profile: { ...profile, origin: config.origin },
|
|
488
|
+
cut,
|
|
489
|
+
cutMs,
|
|
490
|
+
entry: info.entry,
|
|
491
|
+
path: redact(info.path),
|
|
492
|
+
invalid,
|
|
493
|
+
};
|
|
494
|
+
} finally {
|
|
495
|
+
await page.send('Debugger.disable').catch(() => {});
|
|
496
|
+
stopGate();
|
|
497
|
+
stopPauses();
|
|
498
|
+
net.stop();
|
|
499
|
+
await page.close().catch(() => {});
|
|
500
|
+
}
|
|
501
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Field config: which deployed URL to load, how to ask the site for each build
|
|
2
|
+
// (variant), what to block, and which local build explains each variant.
|
|
3
|
+
//
|
|
4
|
+
// {
|
|
5
|
+
// "url": "https://app.example.com/",
|
|
6
|
+
// "variants": {
|
|
7
|
+
// "base": { "cookies": [{ "name": "version", "value": "stable" }], "build": "/path/to/dist" },
|
|
8
|
+
// "candidate": { "query": { "preview": "pr-123" } }
|
|
9
|
+
// },
|
|
10
|
+
// "block": ["analytics.example.com"], // URL substrings; the page cannot send these
|
|
11
|
+
// "ignoreLcp": "#splash", // LCP entries inside this selector do not count
|
|
12
|
+
// "gate": "/api/items", // optional: the request the LCP waits for
|
|
13
|
+
// "redact": ["tenant-[a-z0-9]+"] // extra regexes removed from written URLs
|
|
14
|
+
// }
|
|
15
|
+
//
|
|
16
|
+
// A variant may set cookies, query parameters or both. Variant cookie values are
|
|
17
|
+
// treated as secrets: they are never printed or written to the report.
|
|
18
|
+
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
|
|
22
|
+
export function loadFieldConfig(file) {
|
|
23
|
+
const resolved = path.resolve(file);
|
|
24
|
+
if (!fs.existsSync(resolved)) throw new Error(`no field config at ${resolved}`);
|
|
25
|
+
const config = JSON.parse(fs.readFileSync(resolved, 'utf8'));
|
|
26
|
+
return validateFieldConfig(config, path.dirname(resolved));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function validateFieldConfig(config, baseDir = process.cwd()) {
|
|
30
|
+
let url;
|
|
31
|
+
try {
|
|
32
|
+
url = new URL(config.url);
|
|
33
|
+
} catch {
|
|
34
|
+
throw new Error('field config: "url" must be an absolute URL');
|
|
35
|
+
}
|
|
36
|
+
const names = Object.keys(config.variants ?? {});
|
|
37
|
+
if (names.length < 1) throw new Error('field config: "variants" needs at least one entry');
|
|
38
|
+
const variants = {};
|
|
39
|
+
for (const name of names) {
|
|
40
|
+
const v = config.variants[name] ?? {};
|
|
41
|
+
for (const cookie of v.cookies ?? []) {
|
|
42
|
+
if (!cookie?.name || typeof cookie.value !== 'string') {
|
|
43
|
+
throw new Error(`field config: variant "${name}" has a cookie without name/value`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const target = new URL(url);
|
|
47
|
+
for (const [key, value] of Object.entries(v.query ?? {})) target.searchParams.set(key, value);
|
|
48
|
+
variants[name] = {
|
|
49
|
+
name,
|
|
50
|
+
cookies: v.cookies ?? [],
|
|
51
|
+
url: target.toString(),
|
|
52
|
+
build: v.build ? path.resolve(baseDir, v.build) : null,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
url: url.toString(),
|
|
57
|
+
origin: url.origin,
|
|
58
|
+
variants,
|
|
59
|
+
block: config.block ?? [],
|
|
60
|
+
ignoreLcp: config.ignoreLcp ?? null,
|
|
61
|
+
gate: config.gate ?? null,
|
|
62
|
+
redact: config.redact ?? [],
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** What the report may say about the config: cookie names, never values. */
|
|
67
|
+
export function describeConfig(config, redact) {
|
|
68
|
+
return {
|
|
69
|
+
url: redact(config.url),
|
|
70
|
+
variants: Object.fromEntries(
|
|
71
|
+
Object.values(config.variants).map((v) => [
|
|
72
|
+
v.name,
|
|
73
|
+
{
|
|
74
|
+
url: redact(v.url),
|
|
75
|
+
cookies: v.cookies.map((c) => c.name),
|
|
76
|
+
build: v.build,
|
|
77
|
+
},
|
|
78
|
+
]),
|
|
79
|
+
),
|
|
80
|
+
block: config.block,
|
|
81
|
+
ignoreLcp: config.ignoreLcp,
|
|
82
|
+
gate: config.gate,
|
|
83
|
+
};
|
|
84
|
+
}
|