audit0 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/dist/cli.d.ts +3 -0
- package/dist/cli.js +264 -0
- package/dist/config/defaults.d.ts +2 -0
- package/dist/config/defaults.js +2 -0
- package/dist/config/define.d.ts +2 -0
- package/dist/config/define.js +112 -0
- package/dist/config/errors.d.ts +6 -0
- package/dist/config/errors.js +10 -0
- package/dist/config/marker.d.ts +3 -0
- package/dist/config/marker.js +27 -0
- package/dist/config/schema.d.ts +3 -0
- package/dist/config/schema.js +66 -0
- package/dist/config/types.d.ts +107 -0
- package/dist/config/types.js +1 -0
- package/dist/engine/artifacts.d.mts +11 -0
- package/dist/engine/artifacts.mjs +92 -0
- package/dist/engine/audit-archive.d.mts +7 -0
- package/dist/engine/audit-archive.mjs +183 -0
- package/dist/engine/findings.d.mts +59 -0
- package/dist/engine/findings.mjs +111 -0
- package/dist/engine/inspect.d.mts +36 -0
- package/dist/engine/inspect.mjs +239 -0
- package/dist/engine/navigation-requests.d.mts +50 -0
- package/dist/engine/navigation-requests.mjs +272 -0
- package/dist/engine/navigation.d.mts +91 -0
- package/dist/engine/navigation.mjs +427 -0
- package/dist/engine/page-diagnostics.d.mts +18 -0
- package/dist/engine/page-diagnostics.mjs +997 -0
- package/dist/engine/report.d.mts +1 -0
- package/dist/engine/report.mjs +577 -0
- package/dist/engine/settings.d.mts +11 -0
- package/dist/engine/settings.mjs +8 -0
- package/dist/findings.d.ts +1 -0
- package/dist/findings.js +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +5 -0
- package/dist/infra.d.ts +2 -0
- package/dist/infra.js +2 -0
- package/dist/run.d.ts +30 -0
- package/dist/run.js +319 -0
- package/package.json +66 -0
- package/schema/audit-v1.schema.json +82 -0
|
@@ -0,0 +1,997 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
const LIMITS = {
|
|
3
|
+
requests: 2000,
|
|
4
|
+
resources: 2000,
|
|
5
|
+
priorities: 32,
|
|
6
|
+
paints: 16,
|
|
7
|
+
longTasks: 500,
|
|
8
|
+
images: 100,
|
|
9
|
+
imagePreloads: 100,
|
|
10
|
+
imageElements: 5000,
|
|
11
|
+
elements: 5000,
|
|
12
|
+
fonts: 200,
|
|
13
|
+
fontCssRules: 10000,
|
|
14
|
+
fontStyleSheets: 200,
|
|
15
|
+
fontEvents: 200,
|
|
16
|
+
scripts: 200,
|
|
17
|
+
};
|
|
18
|
+
const MAX_URL_LENGTH = 16384;
|
|
19
|
+
const CACHE_HEADERS = new Set(["cache-control", "age", "x-vercel-cache", "x-matched-path"]);
|
|
20
|
+
export function sanitizeUrl(raw) {
|
|
21
|
+
if (typeof raw !== "string" || !raw || raw.length > MAX_URL_LENGTH)
|
|
22
|
+
return "[invalid-url]";
|
|
23
|
+
try {
|
|
24
|
+
const relative = !/^[a-z][a-z\d+.-]*:/i.test(raw) && !raw.startsWith("//");
|
|
25
|
+
const url = new URL(raw, "https://diagnostics.invalid");
|
|
26
|
+
if (!["http:", "https:"].includes(url.protocol))
|
|
27
|
+
return `${url.protocol}[redacted]`;
|
|
28
|
+
url.username = "";
|
|
29
|
+
url.password = "";
|
|
30
|
+
url.hash = "";
|
|
31
|
+
const imageParams = [...url.searchParams].filter(([key, content]) => ["w", "q"].includes(key) && /^\d{1,5}$/.test(content));
|
|
32
|
+
url.search = "";
|
|
33
|
+
for (const [key, content] of imageParams)
|
|
34
|
+
url.searchParams.append(key, content);
|
|
35
|
+
return relative ? `${url.pathname}${url.search}` : url.href;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return "[invalid-url]";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export function responseTiming(timing) {
|
|
42
|
+
if (!timing)
|
|
43
|
+
return { ttfbMs: null, source: "unavailable" };
|
|
44
|
+
const headers = timing.receiveHeadersStart >= 0 ? timing.receiveHeadersStart : timing.receiveHeadersEnd;
|
|
45
|
+
if (headers >= 0 && timing.sendEnd >= 0 && headers >= timing.sendEnd) {
|
|
46
|
+
return {
|
|
47
|
+
ttfbMs: headers - timing.sendEnd,
|
|
48
|
+
source: timing.receiveHeadersStart >= 0
|
|
49
|
+
? "cdp.receiveHeadersStart-sendEnd"
|
|
50
|
+
: "cdp.receiveHeadersEnd-sendEnd",
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
return { ttfbMs: null, source: "unavailable" };
|
|
54
|
+
}
|
|
55
|
+
export function classifyResourceTiming(entry, documentUrl, cdpType) {
|
|
56
|
+
let crossOrigin = false;
|
|
57
|
+
try {
|
|
58
|
+
crossOrigin = new URL(entry.name).origin !== new URL(documentUrl).origin;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return { timingAvailability: "unavailable", ttfbMs: null, renderBlockingScript: false };
|
|
62
|
+
}
|
|
63
|
+
const detailedTiming = Number.isFinite(entry.requestStart) && Number.isFinite(entry.responseStart);
|
|
64
|
+
const hiddenTiming = entry.requestStart === 0 && entry.responseStart === 0;
|
|
65
|
+
const timingAvailability = !detailedTiming || hiddenTiming
|
|
66
|
+
? crossOrigin
|
|
67
|
+
? "unavailable-cross-origin"
|
|
68
|
+
: "unavailable"
|
|
69
|
+
: "available";
|
|
70
|
+
return {
|
|
71
|
+
timingAvailability,
|
|
72
|
+
ttfbMs: timingAvailability === "available" && entry.responseStart >= entry.requestStart
|
|
73
|
+
? entry.responseStart - entry.requestStart
|
|
74
|
+
: null,
|
|
75
|
+
renderBlockingScript: entry.renderBlockingStatus === "blocking" &&
|
|
76
|
+
(entry.initiatorType === "script" || cdpType === "Script"),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
export function classifyFontFace(face) {
|
|
80
|
+
return {
|
|
81
|
+
...face,
|
|
82
|
+
sourceType: face.sourceType ?? "unknown",
|
|
83
|
+
potentialInvisibleTextRisk: face.sourceType !== "local-only" && ["auto", "block"].includes(face.display),
|
|
84
|
+
blockingProven: false,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
function installObservers(key, limits, maxUrlLength) {
|
|
88
|
+
if (window !== window.top || window[key])
|
|
89
|
+
return;
|
|
90
|
+
const state = {
|
|
91
|
+
timeOrigin: performance.timeOrigin,
|
|
92
|
+
documentUrl: location.href.slice(0, maxUrlLength),
|
|
93
|
+
paints: [],
|
|
94
|
+
lcp: null,
|
|
95
|
+
resources: [],
|
|
96
|
+
longTasks: [],
|
|
97
|
+
fontEvents: [],
|
|
98
|
+
fontsAtFcp: [],
|
|
99
|
+
fontSetStatusAtFcp: null,
|
|
100
|
+
fontsAtFcpObservedMs: null,
|
|
101
|
+
early: null,
|
|
102
|
+
domContentLoadedMs: null,
|
|
103
|
+
loadMs: null,
|
|
104
|
+
unsupported: [],
|
|
105
|
+
truncated: {},
|
|
106
|
+
};
|
|
107
|
+
const observers = [];
|
|
108
|
+
let raf = null;
|
|
109
|
+
let stopped = false;
|
|
110
|
+
const boundedUrl = (raw) => (raw && raw.length <= maxUrlLength ? raw : "");
|
|
111
|
+
const append = (list, entry, cap, name) => {
|
|
112
|
+
if (list.length < cap)
|
|
113
|
+
list.push(entry);
|
|
114
|
+
else
|
|
115
|
+
state.truncated[name] = true;
|
|
116
|
+
};
|
|
117
|
+
const fontFaces = (selectedFaces = document.fonts) => {
|
|
118
|
+
const sources = new Map();
|
|
119
|
+
const faceCounts = new Map();
|
|
120
|
+
const pending = [];
|
|
121
|
+
const seenSheets = new Set();
|
|
122
|
+
let complete = true;
|
|
123
|
+
let ruleCount = 0;
|
|
124
|
+
const signature = (family, style, weight) => JSON.stringify([
|
|
125
|
+
family
|
|
126
|
+
.trim()
|
|
127
|
+
.replace(/^(["'])(.*)\1$/, "$2")
|
|
128
|
+
.toLowerCase(),
|
|
129
|
+
(style || "normal").trim().toLowerCase(),
|
|
130
|
+
weight === "bold" ? "700" : !weight || weight === "normal" ? "400" : weight.trim(),
|
|
131
|
+
]);
|
|
132
|
+
const inspectSheet = (sheet) => {
|
|
133
|
+
if (!sheet || sheet.disabled || seenSheets.has(sheet))
|
|
134
|
+
return;
|
|
135
|
+
if (sheet.media.mediaText && !matchMedia(sheet.media.mediaText).matches)
|
|
136
|
+
return;
|
|
137
|
+
if (seenSheets.size >= limits.fontStyleSheets) {
|
|
138
|
+
state.truncated.fontStyleSheets = true;
|
|
139
|
+
complete = false;
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
seenSheets.add(sheet);
|
|
143
|
+
try {
|
|
144
|
+
if (sheet.href && new URL(sheet.href).origin !== location.origin) {
|
|
145
|
+
complete = false;
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
pending.push(sheet.cssRules[Symbol.iterator]());
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
complete = false;
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
for (const sheet of document.styleSheets)
|
|
155
|
+
inspectSheet(sheet);
|
|
156
|
+
for (const sheet of document.adoptedStyleSheets)
|
|
157
|
+
inspectSheet(sheet);
|
|
158
|
+
while (pending.length && ruleCount < limits.fontCssRules) {
|
|
159
|
+
const next = pending.at(-1).next();
|
|
160
|
+
if (next.done) {
|
|
161
|
+
pending.pop();
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
ruleCount++;
|
|
165
|
+
const rule = next.value;
|
|
166
|
+
if (rule.type === CSSRule.FONT_FACE_RULE) {
|
|
167
|
+
const src = rule.style.getPropertyValue("src");
|
|
168
|
+
const functions = src.length <= maxUrlLength
|
|
169
|
+
? src.replace(/"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/g, '""').match(/\b[a-z-]+\s*\(/gi) ||
|
|
170
|
+
[]
|
|
171
|
+
: [];
|
|
172
|
+
const local = functions.some((token) => /^local\s*\($/i.test(token));
|
|
173
|
+
const network = functions.some((token) => /^url\s*\($/i.test(token));
|
|
174
|
+
let sourceType = "unknown";
|
|
175
|
+
if (functions.every((token) => /^(local|url|format|tech)\s*\($/i.test(token))) {
|
|
176
|
+
if (local && network)
|
|
177
|
+
sourceType = "mixed";
|
|
178
|
+
else if (local)
|
|
179
|
+
sourceType = "local-only";
|
|
180
|
+
else if (network)
|
|
181
|
+
sourceType = "network";
|
|
182
|
+
}
|
|
183
|
+
const key = signature(rule.style.getPropertyValue("font-family"), rule.style.getPropertyValue("font-style"), rule.style.getPropertyValue("font-weight"));
|
|
184
|
+
if (!sources.has(key))
|
|
185
|
+
sources.set(key, []);
|
|
186
|
+
sources.get(key).push(sourceType);
|
|
187
|
+
}
|
|
188
|
+
else if (rule.type === CSSRule.IMPORT_RULE)
|
|
189
|
+
inspectSheet(rule.styleSheet);
|
|
190
|
+
else if (rule.cssRules) {
|
|
191
|
+
if (rule.type === CSSRule.MEDIA_RULE && !matchMedia(rule.conditionText).matches)
|
|
192
|
+
continue;
|
|
193
|
+
if (rule.type === CSSRule.SUPPORTS_RULE && !CSS.supports(rule.conditionText))
|
|
194
|
+
continue;
|
|
195
|
+
pending.push(rule.cssRules[Symbol.iterator]());
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (pending.length) {
|
|
199
|
+
state.truncated.fontCssRules = true;
|
|
200
|
+
complete = false;
|
|
201
|
+
}
|
|
202
|
+
let countedFaces = 0;
|
|
203
|
+
for (const face of document.fonts) {
|
|
204
|
+
if (countedFaces++ === limits.fonts) {
|
|
205
|
+
complete = false;
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
const key = signature(face.family, face.style, face.weight);
|
|
209
|
+
faceCounts.set(key, (faceCounts.get(key) || 0) + 1);
|
|
210
|
+
}
|
|
211
|
+
const faces = [];
|
|
212
|
+
for (const face of selectedFaces) {
|
|
213
|
+
if (faces.length === limits.fonts) {
|
|
214
|
+
state.truncated.fonts = true;
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
const key = signature(face.family, face.style, face.weight);
|
|
218
|
+
const candidates = sources.get(key) || [];
|
|
219
|
+
const sourceType = complete &&
|
|
220
|
+
candidates.length > 0 &&
|
|
221
|
+
candidates.length === faceCounts.get(key) &&
|
|
222
|
+
candidates.every((type) => type === candidates[0])
|
|
223
|
+
? candidates[0]
|
|
224
|
+
: "unknown";
|
|
225
|
+
faces.push({
|
|
226
|
+
family: face.family.slice(0, 500),
|
|
227
|
+
display: face.display,
|
|
228
|
+
status: face.status,
|
|
229
|
+
style: face.style,
|
|
230
|
+
weight: face.weight,
|
|
231
|
+
sourceType,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
return faces;
|
|
235
|
+
};
|
|
236
|
+
const snapshot = () => {
|
|
237
|
+
const images = [];
|
|
238
|
+
const backgrounds = [];
|
|
239
|
+
const preloads = [];
|
|
240
|
+
const scripts = [];
|
|
241
|
+
const inlineRenderBlockers = [];
|
|
242
|
+
const geometryFor = (element) => {
|
|
243
|
+
const rect = element.getBoundingClientRect();
|
|
244
|
+
if (rect.width <= 0 || rect.height <= 0)
|
|
245
|
+
return null;
|
|
246
|
+
let visible;
|
|
247
|
+
if (typeof element.checkVisibility === "function") {
|
|
248
|
+
visible = element.checkVisibility({
|
|
249
|
+
opacityProperty: true,
|
|
250
|
+
visibilityProperty: true,
|
|
251
|
+
contentVisibilityAuto: true,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
const style = getComputedStyle(element);
|
|
256
|
+
visible =
|
|
257
|
+
style.visibility !== "hidden" &&
|
|
258
|
+
style.visibility !== "collapse" &&
|
|
259
|
+
style.display !== "none" &&
|
|
260
|
+
Number(style.opacity) !== 0;
|
|
261
|
+
}
|
|
262
|
+
const intersectionWidth = Math.max(0, Math.min(rect.right, innerWidth) - Math.max(rect.left, 0));
|
|
263
|
+
const intersectionHeight = Math.max(0, Math.min(rect.bottom, innerHeight) - Math.max(rect.top, 0));
|
|
264
|
+
return {
|
|
265
|
+
x: rect.x,
|
|
266
|
+
y: rect.y,
|
|
267
|
+
width: rect.width,
|
|
268
|
+
height: rect.height,
|
|
269
|
+
intersectionArea: intersectionWidth * intersectionHeight,
|
|
270
|
+
intersectsViewport: intersectionWidth > 0 && intersectionHeight > 0,
|
|
271
|
+
visible,
|
|
272
|
+
};
|
|
273
|
+
};
|
|
274
|
+
const imageCount = document.images.length;
|
|
275
|
+
const imagesScanned = Math.min(imageCount, limits.imageElements);
|
|
276
|
+
for (let i = 0; i < imagesScanned; i++) {
|
|
277
|
+
const image = document.images[i];
|
|
278
|
+
const geometry = geometryFor(image);
|
|
279
|
+
if (!geometry?.visible)
|
|
280
|
+
continue;
|
|
281
|
+
images.push({
|
|
282
|
+
url: boundedUrl(image.currentSrc || image.src),
|
|
283
|
+
alt: image.alt.slice(0, 200),
|
|
284
|
+
...geometry,
|
|
285
|
+
naturalWidth: image.naturalWidth,
|
|
286
|
+
naturalHeight: image.naturalHeight,
|
|
287
|
+
complete: image.complete,
|
|
288
|
+
loading: image.loading,
|
|
289
|
+
fetchPriority: image.fetchPriority || image.getAttribute("fetchpriority") || "auto",
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
if (imageCount > imagesScanned)
|
|
293
|
+
state.truncated.imageElements = true;
|
|
294
|
+
const walker = document.createTreeWalker(document.documentElement, NodeFilter.SHOW_ELEMENT);
|
|
295
|
+
let element = walker.currentNode;
|
|
296
|
+
let scanned = 0;
|
|
297
|
+
while (element && scanned < limits.elements) {
|
|
298
|
+
scanned++;
|
|
299
|
+
if (element.tagName === "LINK" &&
|
|
300
|
+
element.relList.contains("preload") &&
|
|
301
|
+
element.as === "image") {
|
|
302
|
+
const responsive = Boolean(element.getAttribute("imagesrcset")?.trim());
|
|
303
|
+
append(preloads, {
|
|
304
|
+
href: element.hasAttribute("href") ? boundedUrl(element.href) : null,
|
|
305
|
+
responsive,
|
|
306
|
+
hasImageSizes: Boolean(element.getAttribute("imagesizes")?.trim()),
|
|
307
|
+
status: responsive ? "responsive-unverified" : "declaration-only",
|
|
308
|
+
}, limits.imagePreloads, "imagePreloads");
|
|
309
|
+
}
|
|
310
|
+
if (element.tagName === "SCRIPT") {
|
|
311
|
+
const script = {
|
|
312
|
+
url: boundedUrl(element.src),
|
|
313
|
+
async: element.async,
|
|
314
|
+
defer: element.defer,
|
|
315
|
+
type: element.type,
|
|
316
|
+
fetchPriority: element.fetchPriority || element.getAttribute("fetchpriority") || "auto",
|
|
317
|
+
blocking: element.getAttribute("blocking"),
|
|
318
|
+
};
|
|
319
|
+
if (element.hasAttribute("src")) {
|
|
320
|
+
append(scripts, script, limits.scripts, "scripts");
|
|
321
|
+
}
|
|
322
|
+
else if (element.getAttribute("blocking")?.split(/\s+/).includes("render")) {
|
|
323
|
+
append(inlineRenderBlockers, {
|
|
324
|
+
type: element.type,
|
|
325
|
+
blocking: "render",
|
|
326
|
+
observedDeclarationOnly: true,
|
|
327
|
+
}, limits.scripts, "inlineRenderBlockers");
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
const geometry = geometryFor(element);
|
|
331
|
+
if (geometry?.visible && geometry.intersectsViewport) {
|
|
332
|
+
const backgroundImage = getComputedStyle(element).backgroundImage;
|
|
333
|
+
if (backgroundImage !== "none") {
|
|
334
|
+
for (const match of backgroundImage.matchAll(/url\((?:"([^"]*)"|'([^']*)'|([^)]*))\)/g)) {
|
|
335
|
+
if (backgrounds.length >= limits.images) {
|
|
336
|
+
state.truncated.backgrounds = true;
|
|
337
|
+
break;
|
|
338
|
+
}
|
|
339
|
+
backgrounds.push({ url: boundedUrl(match[1] ?? match[2] ?? match[3]), ...geometry });
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
element = walker.nextNode();
|
|
344
|
+
}
|
|
345
|
+
if (element)
|
|
346
|
+
state.truncated.elements = true;
|
|
347
|
+
images.sort((a, b) => Number(b.visible && b.intersectsViewport) - Number(a.visible && a.intersectsViewport) ||
|
|
348
|
+
b.intersectionArea - a.intersectionArea ||
|
|
349
|
+
b.width * b.height - a.width * a.height);
|
|
350
|
+
if (images.length > limits.images)
|
|
351
|
+
state.truncated.images = true;
|
|
352
|
+
return {
|
|
353
|
+
observedMs: performance.now(),
|
|
354
|
+
viewport: { width: innerWidth, height: innerHeight, scrollX, scrollY },
|
|
355
|
+
imageScan: {
|
|
356
|
+
total: imageCount,
|
|
357
|
+
scanned: imagesScanned,
|
|
358
|
+
truncated: imageCount > imagesScanned,
|
|
359
|
+
},
|
|
360
|
+
visibilityMethod: typeof Element.prototype.checkVisibility === "function"
|
|
361
|
+
? "checkVisibility"
|
|
362
|
+
: "computed-style-fallback",
|
|
363
|
+
images: images.slice(0, limits.images),
|
|
364
|
+
backgrounds,
|
|
365
|
+
preloads,
|
|
366
|
+
scripts,
|
|
367
|
+
inlineRenderBlockers,
|
|
368
|
+
};
|
|
369
|
+
};
|
|
370
|
+
const consume = (type, entries) => {
|
|
371
|
+
for (const entry of entries) {
|
|
372
|
+
if (type === "paint") {
|
|
373
|
+
append(state.paints, { name: entry.name, startTime: entry.startTime }, limits.paints, "paints");
|
|
374
|
+
if (entry.name === "first-contentful-paint" && state.fontsAtFcpObservedMs === null) {
|
|
375
|
+
state.fontsAtFcp = fontFaces();
|
|
376
|
+
state.fontSetStatusAtFcp = document.fonts.status;
|
|
377
|
+
state.fontsAtFcpObservedMs = performance.now();
|
|
378
|
+
raf = requestAnimationFrame(() => {
|
|
379
|
+
state.early = snapshot();
|
|
380
|
+
raf = null;
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
else if (type === "largest-contentful-paint") {
|
|
385
|
+
state.lcp = {
|
|
386
|
+
startTime: entry.startTime,
|
|
387
|
+
renderTime: entry.renderTime,
|
|
388
|
+
loadTime: entry.loadTime,
|
|
389
|
+
size: entry.size,
|
|
390
|
+
url: boundedUrl(entry.url),
|
|
391
|
+
tagName: entry.element?.tagName ?? null,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
else if (type === "resource") {
|
|
395
|
+
append(state.resources, {
|
|
396
|
+
name: boundedUrl(entry.name),
|
|
397
|
+
initiatorType: entry.initiatorType,
|
|
398
|
+
startTime: entry.startTime,
|
|
399
|
+
duration: entry.duration,
|
|
400
|
+
fetchStart: entry.fetchStart,
|
|
401
|
+
requestStart: entry.requestStart,
|
|
402
|
+
responseStart: entry.responseStart,
|
|
403
|
+
responseEnd: entry.responseEnd,
|
|
404
|
+
transferSize: entry.transferSize,
|
|
405
|
+
encodedBodySize: entry.encodedBodySize,
|
|
406
|
+
decodedBodySize: entry.decodedBodySize,
|
|
407
|
+
renderBlockingStatus: entry.renderBlockingStatus ?? null,
|
|
408
|
+
}, limits.resources, "resources");
|
|
409
|
+
}
|
|
410
|
+
else if (type === "longtask") {
|
|
411
|
+
append(state.longTasks, {
|
|
412
|
+
startTime: entry.startTime,
|
|
413
|
+
duration: entry.duration,
|
|
414
|
+
name: entry.name,
|
|
415
|
+
}, limits.longTasks, "longTasks");
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
};
|
|
419
|
+
for (const type of ["paint", "largest-contentful-paint", "resource", "longtask"]) {
|
|
420
|
+
if (!PerformanceObserver.supportedEntryTypes.includes(type)) {
|
|
421
|
+
state.unsupported.push(type);
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
const observer = new PerformanceObserver((list) => consume(type, list.getEntries()));
|
|
425
|
+
observer.observe({ type, buffered: true });
|
|
426
|
+
observers.push({ type, observer });
|
|
427
|
+
}
|
|
428
|
+
const fontEvent = (event) => append(state.fontEvents, {
|
|
429
|
+
type: event.type,
|
|
430
|
+
observedMs: performance.now(),
|
|
431
|
+
status: document.fonts.status,
|
|
432
|
+
faces: state.fontEvents.length < limits.fontEvents ? fontFaces(event.fontfaces || []) : [],
|
|
433
|
+
}, limits.fontEvents, "fontEvents");
|
|
434
|
+
for (const type of ["loading", "loadingdone", "loadingerror"])
|
|
435
|
+
document.fonts.addEventListener(type, fontEvent);
|
|
436
|
+
const domReady = () => {
|
|
437
|
+
state.domContentLoadedMs = performance.now();
|
|
438
|
+
};
|
|
439
|
+
const loaded = () => {
|
|
440
|
+
state.loadMs = performance.now();
|
|
441
|
+
};
|
|
442
|
+
document.addEventListener("DOMContentLoaded", domReady, { once: true });
|
|
443
|
+
window.addEventListener("load", loaded, { once: true });
|
|
444
|
+
const stop = () => {
|
|
445
|
+
if (stopped)
|
|
446
|
+
return;
|
|
447
|
+
stopped = true;
|
|
448
|
+
for (const { type, observer } of observers) {
|
|
449
|
+
consume(type, observer.takeRecords());
|
|
450
|
+
observer.disconnect();
|
|
451
|
+
}
|
|
452
|
+
if (raf !== null)
|
|
453
|
+
cancelAnimationFrame(raf);
|
|
454
|
+
for (const type of ["loading", "loadingdone", "loadingerror"])
|
|
455
|
+
document.fonts.removeEventListener(type, fontEvent);
|
|
456
|
+
document.removeEventListener("DOMContentLoaded", domReady);
|
|
457
|
+
window.removeEventListener("load", loaded);
|
|
458
|
+
};
|
|
459
|
+
window[key] = {
|
|
460
|
+
finish() {
|
|
461
|
+
stop();
|
|
462
|
+
const navigation = performance.getEntriesByType("navigation")[0];
|
|
463
|
+
return {
|
|
464
|
+
...state,
|
|
465
|
+
finalUrl: boundedUrl(location.href),
|
|
466
|
+
finishedMs: performance.now(),
|
|
467
|
+
final: snapshot(),
|
|
468
|
+
faces: fontFaces(),
|
|
469
|
+
fontSetStatus: document.fonts.status,
|
|
470
|
+
navigation: navigation
|
|
471
|
+
? {
|
|
472
|
+
name: boundedUrl(navigation.name),
|
|
473
|
+
type: navigation.type,
|
|
474
|
+
startTime: navigation.startTime,
|
|
475
|
+
requestStart: navigation.requestStart,
|
|
476
|
+
responseStart: navigation.responseStart,
|
|
477
|
+
responseEnd: navigation.responseEnd,
|
|
478
|
+
domContentLoadedEventEnd: navigation.domContentLoadedEventEnd,
|
|
479
|
+
loadEventEnd: navigation.loadEventEnd,
|
|
480
|
+
transferSize: navigation.transferSize,
|
|
481
|
+
}
|
|
482
|
+
: null,
|
|
483
|
+
};
|
|
484
|
+
},
|
|
485
|
+
dispose() {
|
|
486
|
+
stop();
|
|
487
|
+
delete window[key];
|
|
488
|
+
},
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
export async function startPageDiagnostics(page) {
|
|
492
|
+
const key = `__pageDiagnostics_${randomUUID().replaceAll("-", "")}`;
|
|
493
|
+
const session = await page.context().newCDPSession(page);
|
|
494
|
+
const requests = [];
|
|
495
|
+
const active = new Map();
|
|
496
|
+
const listeners = [];
|
|
497
|
+
const notes = new Set([
|
|
498
|
+
"Chromium CDP network events cover this target; out-of-process frames and worker requests may be absent. Browser observations cover the top document only.",
|
|
499
|
+
"Query parameters are removed except numeric w/q; credentials, fragments and non-HTTP URL payloads are removed. URL paths and allowlisted cache headers may still contain application identifiers.",
|
|
500
|
+
"FCP font status is sampled in the observer callback; early image geometry is sampled in its next animation frame, not at the exact paint instant.",
|
|
501
|
+
"Image snapshots are DOM geometry, not screenshots. Visibility does not prove lack of occlusion or account for every ancestor effect. CSS backgrounds exclude pseudo-elements.",
|
|
502
|
+
`Images scan document.images independently (up to ${LIMITS.imageElements}), prioritizing visible viewport images before the ${LIMITS.images}-record cap. CSS backgrounds and script declarations still scan only the first ${LIMITS.elements} DOM elements and can miss later elements.`,
|
|
503
|
+
"DOM geometry/style scans add measurement overhead. Script hints and inline declarations reflect elements still present at the early/final snapshots.",
|
|
504
|
+
"Matching uses unredacted URLs internally, with nearest request start for Resource Timing and latest request before image snapshots. Repeated identical URLs can be ambiguous. Early image final priorities/status reflect the finish cutoff, not FCP.",
|
|
505
|
+
"Image sourceUrl unwraps one url query parameter via URLSearchParams and sanitizes it, without recursive decoding. A not-observed network or priority status means unknown, not proof of no request or low priority.",
|
|
506
|
+
"Image requestStartMs is relative to document timeOrigin: CDP response timing requestTime+sendStart, then matched detailed Resource Timing requestStart, then CDP requestWillBeSent as a discovery-time fallback. requestStartAfterTtfbMs subtracts navigation.responseStart and may be negative; missing timing is null.",
|
|
507
|
+
"Image fetchStartMs measures fetch discovery/initiation relative to document timeOrigin using CDP requestWillBeSent, or matched Resource Timing startTime as a fallback. Unlike detailed requestStartMs (HTTP send), it does not wait for queueing, DNS or TLS to complete. fetchStartAfterTtfbMs subtracts document navigation.responseStart; negative values are preserved and unavailable timing is null.",
|
|
508
|
+
"Image requestOrder is a 1-based chronological ordinal of observed main-frame subresource requestWillBeSent timestamps for this document, excluding Document and including redirect hops and pending requests. It is not a priority rank; collection caps can omit requests.",
|
|
509
|
+
"Image initiatorType prefers CDP initiator.type, falling back to matched Resource Timing initiatorType. isLcpCandidate matches the raw URL to the last observed LCP URL, not a finalized LCP or element identity.",
|
|
510
|
+
"Preload evidence reports only CDP request.isLinkPreload, CDP initiator.type=preload, matched Image Resource Timing initiatorType=link, or exact raw DOM preload href matches without imagesrcset. High priority is never preload evidence; DOM matches are declarations, not proof of use.",
|
|
511
|
+
"Preload evidence may be missing or unsupported; absence is not proof of no preload. HTTP Link headers and early-hints declarations are not collected. DOM preloads use bounded early/final element scans and can miss removed links. Responsive declarations are unverified and excluded from DOM matched evidence; only imagesrcset/imagesizes presence is exposed, not their contents.",
|
|
512
|
+
"Font auto/block indicates potential invisible text risk only; no font display mode or font request duration proves text blocked rendering.",
|
|
513
|
+
"Font sourceType matches normalized family/style/weight against accessible same-origin CSS rules. Inaccessible/truncated CSS, unmatched/programmatic faces or ambiguous matches remain unknown; unknown does not rule out network use. Network/mixed describe declared URL sources, not confirmed downloads. Only proven local-only faces suppress the display-mode risk.",
|
|
514
|
+
"Render-blocking scripts require Resource Timing renderBlockingStatus=blocking plus script evidence. Inline blocking=render declarations are reported separately, not proven blockers.",
|
|
515
|
+
"Missing detailed cross-origin Resource Timing can reflect Timing-Allow-Origin restrictions. Zero byte sizes alone do not prove a cache hit.",
|
|
516
|
+
"finish() takes a bounded snapshot without waiting for network idle, fonts, LCP finalization or live streams. Call before navigation to preserve a baseline; full navigations replace browser observations.",
|
|
517
|
+
]);
|
|
518
|
+
let scriptIdentifier;
|
|
519
|
+
let mainFrameId;
|
|
520
|
+
let disposed = false;
|
|
521
|
+
let finishPromise;
|
|
522
|
+
let disposePromise;
|
|
523
|
+
const listen = (name, callback) => {
|
|
524
|
+
session.on(name, callback);
|
|
525
|
+
listeners.push([name, callback]);
|
|
526
|
+
};
|
|
527
|
+
const eventEpoch = (request, timestamp) => request.startEpochMs + (timestamp - request.timestamp) * 1000;
|
|
528
|
+
const applyResponse = (request, response, observedEpochMs) => {
|
|
529
|
+
request.responseObservedEpochMs = observedEpochMs;
|
|
530
|
+
request.statusCode = response.status;
|
|
531
|
+
request.cache = {
|
|
532
|
+
fromDiskCache: response.fromDiskCache ?? false,
|
|
533
|
+
fromPrefetchCache: response.fromPrefetchCache ?? false,
|
|
534
|
+
fromServiceWorker: response.fromServiceWorker ?? false,
|
|
535
|
+
headers: {},
|
|
536
|
+
};
|
|
537
|
+
for (const [name, content] of Object.entries(response.headers || {})) {
|
|
538
|
+
if (CACHE_HEADERS.has(name.toLowerCase())) {
|
|
539
|
+
request.cache.headers[name.toLowerCase()] = String(content).slice(0, 2048);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
const timing = responseTiming(response.timing);
|
|
543
|
+
request.ttfbMs = timing.ttfbMs;
|
|
544
|
+
request.ttfbSource = timing.source;
|
|
545
|
+
if (response.timing?.sendStart >= 0 && Number.isFinite(response.timing.requestTime)) {
|
|
546
|
+
request.requestStartEpochMs = eventEpoch(request, response.timing.requestTime + response.timing.sendStart / 1000);
|
|
547
|
+
}
|
|
548
|
+
const headersStart = response.timing?.receiveHeadersStart;
|
|
549
|
+
if (headersStart >= 0 && Number.isFinite(response.timing.requestTime)) {
|
|
550
|
+
request.firstByteEpochMs = eventEpoch(request, response.timing.requestTime + headersStart / 1000);
|
|
551
|
+
}
|
|
552
|
+
};
|
|
553
|
+
listen("Network.requestWillBeSent", (event) => {
|
|
554
|
+
if (disposed)
|
|
555
|
+
return;
|
|
556
|
+
const previous = active.get(event.requestId);
|
|
557
|
+
if (previous && event.redirectResponse) {
|
|
558
|
+
applyResponse(previous, event.redirectResponse, eventEpoch(previous, event.timestamp));
|
|
559
|
+
previous.status = "redirected";
|
|
560
|
+
previous.endEpochMs = eventEpoch(previous, event.timestamp);
|
|
561
|
+
}
|
|
562
|
+
active.delete(event.requestId);
|
|
563
|
+
if (requests.length >= LIMITS.requests) {
|
|
564
|
+
notes.add(`CDP request collection capped at ${LIMITS.requests}; subsequent requests omitted.`);
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
const priority = event.request.initialPriority ?? null;
|
|
568
|
+
const request = {
|
|
569
|
+
id: `${event.requestId}:${requests.length}`,
|
|
570
|
+
rawUrl: event.request.url.length <= MAX_URL_LENGTH ? event.request.url : "",
|
|
571
|
+
frameId: event.frameId,
|
|
572
|
+
loaderId: event.loaderId,
|
|
573
|
+
timestamp: event.timestamp,
|
|
574
|
+
startEpochMs: event.wallTime * 1000,
|
|
575
|
+
resourceType: event.type ?? "Other",
|
|
576
|
+
initiatorType: event.initiator?.type ?? null,
|
|
577
|
+
isLinkPreload: event.request.isLinkPreload === true,
|
|
578
|
+
initialPriority: priority,
|
|
579
|
+
finalPriority: priority,
|
|
580
|
+
priorityTimeline: priority
|
|
581
|
+
? [{ priority, epochMs: event.wallTime * 1000, source: "initial" }]
|
|
582
|
+
: [],
|
|
583
|
+
status: "pending",
|
|
584
|
+
ttfbMs: null,
|
|
585
|
+
ttfbSource: "unavailable",
|
|
586
|
+
};
|
|
587
|
+
requests.push(request);
|
|
588
|
+
active.set(event.requestId, request);
|
|
589
|
+
});
|
|
590
|
+
listen("Network.resourceChangedPriority", (event) => {
|
|
591
|
+
const request = active.get(event.requestId);
|
|
592
|
+
if (!request || disposed)
|
|
593
|
+
return;
|
|
594
|
+
request.finalPriority = event.newPriority;
|
|
595
|
+
if (request.priorityTimeline.length < LIMITS.priorities) {
|
|
596
|
+
request.priorityTimeline.push({
|
|
597
|
+
priority: event.newPriority,
|
|
598
|
+
epochMs: eventEpoch(request, event.timestamp),
|
|
599
|
+
source: "changed",
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
else {
|
|
603
|
+
request.priorityTimelineTruncated = true;
|
|
604
|
+
notes.add(`Priority timelines capped at ${LIMITS.priorities}; finalPriority still tracks the last event.`);
|
|
605
|
+
}
|
|
606
|
+
});
|
|
607
|
+
listen("Network.responseReceived", (event) => {
|
|
608
|
+
const request = active.get(event.requestId);
|
|
609
|
+
if (!request || disposed)
|
|
610
|
+
return;
|
|
611
|
+
request.resourceType = event.type ?? request.resourceType;
|
|
612
|
+
applyResponse(request, event.response, eventEpoch(request, event.timestamp));
|
|
613
|
+
});
|
|
614
|
+
for (const eventName of ["loadingFinished", "loadingFailed"]) {
|
|
615
|
+
listen(`Network.${eventName}`, (event) => {
|
|
616
|
+
const request = active.get(event.requestId);
|
|
617
|
+
if (!request || disposed)
|
|
618
|
+
return;
|
|
619
|
+
request.endEpochMs = eventEpoch(request, event.timestamp);
|
|
620
|
+
request.status = eventName === "loadingFinished" ? "finished" : "failed";
|
|
621
|
+
if (eventName === "loadingFinished")
|
|
622
|
+
request.encodedDataLength = event.encodedDataLength;
|
|
623
|
+
else {
|
|
624
|
+
request.canceled = event.canceled ?? false;
|
|
625
|
+
request.blockedReason = event.blockedReason ?? null;
|
|
626
|
+
}
|
|
627
|
+
active.delete(event.requestId);
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
async function dispose() {
|
|
631
|
+
if (disposePromise)
|
|
632
|
+
return disposePromise;
|
|
633
|
+
disposed = true;
|
|
634
|
+
disposePromise = (async () => {
|
|
635
|
+
for (const [name, callback] of listeners)
|
|
636
|
+
session.off(name, callback);
|
|
637
|
+
const cleanup = await Promise.allSettled([
|
|
638
|
+
page.evaluate((key) => window[key]?.dispose(), key),
|
|
639
|
+
scriptIdentifier
|
|
640
|
+
? session.send("Page.removeScriptToEvaluateOnNewDocument", {
|
|
641
|
+
identifier: scriptIdentifier,
|
|
642
|
+
})
|
|
643
|
+
: Promise.resolve(),
|
|
644
|
+
]);
|
|
645
|
+
if (cleanup.some((operation) => operation.status === "rejected")) {
|
|
646
|
+
notes.add("Some browser cleanup was unavailable, usually because the page closed or navigated.");
|
|
647
|
+
}
|
|
648
|
+
try {
|
|
649
|
+
await session.detach();
|
|
650
|
+
}
|
|
651
|
+
catch {
|
|
652
|
+
notes.add("CDP detach was unavailable; the target may already be closed.");
|
|
653
|
+
}
|
|
654
|
+
active.clear();
|
|
655
|
+
requests.length = 0;
|
|
656
|
+
})();
|
|
657
|
+
return disposePromise;
|
|
658
|
+
}
|
|
659
|
+
try {
|
|
660
|
+
await session.send("Page.enable");
|
|
661
|
+
const { frameTree } = await session.send("Page.getFrameTree");
|
|
662
|
+
mainFrameId = frameTree.frame.id;
|
|
663
|
+
await session.send("Network.enable", { maxPostDataSize: 0 });
|
|
664
|
+
const source = `(${installObservers.toString()})(${JSON.stringify(key)}, ${JSON.stringify(LIMITS)}, ${MAX_URL_LENGTH})`;
|
|
665
|
+
const installed = await session.send("Page.addScriptToEvaluateOnNewDocument", { source });
|
|
666
|
+
scriptIdentifier = installed.identifier;
|
|
667
|
+
await page.evaluate(source);
|
|
668
|
+
}
|
|
669
|
+
catch (error) {
|
|
670
|
+
await dispose();
|
|
671
|
+
throw error;
|
|
672
|
+
}
|
|
673
|
+
async function collect() {
|
|
674
|
+
let browser = null;
|
|
675
|
+
try {
|
|
676
|
+
browser = await page.evaluate((key) => window[key]?.finish() ?? null, key);
|
|
677
|
+
}
|
|
678
|
+
catch {
|
|
679
|
+
notes.add("Browser snapshot unavailable: the page closed or its execution context changed during finish().");
|
|
680
|
+
}
|
|
681
|
+
if (!browser)
|
|
682
|
+
notes.add("No browser observations available; navigation and paint measurements may be unavailable.");
|
|
683
|
+
const origin = browser?.timeOrigin ?? requests[0]?.startEpochMs ?? null;
|
|
684
|
+
const relative = (epoch) => (Number.isFinite(epoch) && origin !== null ? epoch - origin : null);
|
|
685
|
+
const matchingUrl = (raw) => {
|
|
686
|
+
try {
|
|
687
|
+
const url = new URL(raw);
|
|
688
|
+
url.hash = "";
|
|
689
|
+
return url.href;
|
|
690
|
+
}
|
|
691
|
+
catch {
|
|
692
|
+
return null;
|
|
693
|
+
}
|
|
694
|
+
};
|
|
695
|
+
const index = new Map();
|
|
696
|
+
for (const request of requests) {
|
|
697
|
+
const url = matchingUrl(request.rawUrl);
|
|
698
|
+
if (!url)
|
|
699
|
+
continue;
|
|
700
|
+
if (!index.has(url))
|
|
701
|
+
index.set(url, []);
|
|
702
|
+
index.get(url).push(request);
|
|
703
|
+
}
|
|
704
|
+
const match = (raw, atMs, type, completedResource = false) => {
|
|
705
|
+
const candidates = (index.get(matchingUrl(raw)) || []).filter((request) => (!type || request.resourceType === type) &&
|
|
706
|
+
(!request.frameId || request.frameId === mainFrameId) &&
|
|
707
|
+
(!browser || request.startEpochMs >= browser.timeOrigin - 100) &&
|
|
708
|
+
(completedResource || atMs === undefined || relative(request.startEpochMs) <= atMs + 2));
|
|
709
|
+
if (!candidates.length)
|
|
710
|
+
return null;
|
|
711
|
+
if (completedResource) {
|
|
712
|
+
return candidates.reduce((closest, request) => Math.abs(relative(request.startEpochMs) - atMs) <
|
|
713
|
+
Math.abs(relative(closest.startEpochMs) - atMs)
|
|
714
|
+
? request
|
|
715
|
+
: closest);
|
|
716
|
+
}
|
|
717
|
+
return candidates.at(-1);
|
|
718
|
+
};
|
|
719
|
+
const serialize = (request) => ({
|
|
720
|
+
id: request.id,
|
|
721
|
+
url: sanitizeUrl(request.rawUrl),
|
|
722
|
+
resourceType: request.resourceType,
|
|
723
|
+
isMainDocument: request.resourceType === "Document" && request.frameId === mainFrameId,
|
|
724
|
+
status: request.status,
|
|
725
|
+
startEpochMs: request.startEpochMs,
|
|
726
|
+
startMs: relative(request.startEpochMs),
|
|
727
|
+
responseObservedMs: relative(request.responseObservedEpochMs),
|
|
728
|
+
firstByteMs: relative(request.firstByteEpochMs),
|
|
729
|
+
requestToFirstByteMs: Number.isFinite(request.firstByteEpochMs)
|
|
730
|
+
? request.firstByteEpochMs - request.startEpochMs
|
|
731
|
+
: null,
|
|
732
|
+
endMs: relative(request.endEpochMs),
|
|
733
|
+
ttfbMs: request.ttfbMs,
|
|
734
|
+
ttfbSource: request.ttfbSource,
|
|
735
|
+
statusCode: request.statusCode ?? null,
|
|
736
|
+
cache: request.cache ?? null,
|
|
737
|
+
encodedDataLength: request.encodedDataLength ?? null,
|
|
738
|
+
canceled: request.canceled ?? null,
|
|
739
|
+
blockedReason: request.blockedReason ?? null,
|
|
740
|
+
initialPriority: request.initialPriority,
|
|
741
|
+
finalPriority: request.finalPriority,
|
|
742
|
+
priorityStatus: request.finalPriority === null ? "not-observed" : "observed",
|
|
743
|
+
finalPriorityStatus: request.finalPriority === null
|
|
744
|
+
? "not-observed"
|
|
745
|
+
: request.status === "pending"
|
|
746
|
+
? "pending"
|
|
747
|
+
: "complete",
|
|
748
|
+
priorityTimeline: request.priorityTimeline.map(({ epochMs, ...event }) => ({
|
|
749
|
+
...event,
|
|
750
|
+
atMs: relative(epochMs),
|
|
751
|
+
})),
|
|
752
|
+
priorityTimelineTruncated: request.priorityTimelineTruncated ?? false,
|
|
753
|
+
});
|
|
754
|
+
const network = requests.map(serialize);
|
|
755
|
+
const resources = (browser?.resources || []).map((entry) => {
|
|
756
|
+
const request = match(entry.name, entry.startTime, undefined, true);
|
|
757
|
+
const { name, ...timing } = entry;
|
|
758
|
+
return {
|
|
759
|
+
...timing,
|
|
760
|
+
url: sanitizeUrl(name),
|
|
761
|
+
...classifyResourceTiming(entry, browser.documentUrl, request?.resourceType),
|
|
762
|
+
networkId: request?.id ?? null,
|
|
763
|
+
networkMatch: request ? "url-and-nearest-start" : "not-observed",
|
|
764
|
+
resourceType: request?.resourceType ?? null,
|
|
765
|
+
};
|
|
766
|
+
});
|
|
767
|
+
const mainRequests = network.filter((request) => request.isMainDocument);
|
|
768
|
+
const documentRequest = browser?.navigation
|
|
769
|
+
? match(browser.navigation.name, browser.navigation.startTime, "Document", true)
|
|
770
|
+
: null;
|
|
771
|
+
const documentNetwork = documentRequest && documentRequest.frameId === mainFrameId ? serialize(documentRequest) : null;
|
|
772
|
+
const navigation = browser?.navigation;
|
|
773
|
+
const navigationTimingAvailable = navigation && navigation.responseStart > 0;
|
|
774
|
+
const requestOrders = new Map(requests
|
|
775
|
+
.filter((request) => request.frameId === mainFrameId &&
|
|
776
|
+
request.resourceType !== "Document" &&
|
|
777
|
+
(!documentRequest?.loaderId || request.loaderId === documentRequest.loaderId) &&
|
|
778
|
+
(!browser || request.startEpochMs >= browser.timeOrigin - 100))
|
|
779
|
+
.sort((a, b) => a.timestamp - b.timestamp)
|
|
780
|
+
.map((request, i) => [request.id, i + 1]));
|
|
781
|
+
const preloads = [browser?.early, browser?.final].filter(Boolean).flatMap((snapshot) => (snapshot.preloads || []).map((preload) => ({
|
|
782
|
+
...preload,
|
|
783
|
+
observedMs: snapshot.observedMs,
|
|
784
|
+
snapshot: snapshot === browser.early ? "early" : "final",
|
|
785
|
+
})));
|
|
786
|
+
const imageSnapshot = (snapshot, field) => (snapshot?.[field] || []).map((image) => {
|
|
787
|
+
const request = match(image.url, snapshot.observedMs, "Image");
|
|
788
|
+
const resource = request ? resources.find((entry) => entry.networkId === request.id) : null;
|
|
789
|
+
const fetchResource = request
|
|
790
|
+
? resource
|
|
791
|
+
: browser?.resources.findLast((entry) => image.url &&
|
|
792
|
+
matchingUrl(entry.name) === matchingUrl(image.url) &&
|
|
793
|
+
entry.startTime <= snapshot.observedMs + 2);
|
|
794
|
+
const fetchStartMs = relative(request?.startEpochMs) ?? fetchResource?.startTime ?? null;
|
|
795
|
+
const requestStartMs = request
|
|
796
|
+
? (relative(request.requestStartEpochMs) ??
|
|
797
|
+
(resource?.timingAvailability === "available" ? resource.requestStart : null) ??
|
|
798
|
+
relative(request.startEpochMs))
|
|
799
|
+
: null;
|
|
800
|
+
const preloadEvidence = [];
|
|
801
|
+
if (request?.isLinkPreload)
|
|
802
|
+
preloadEvidence.push("cdp.request.isLinkPreload");
|
|
803
|
+
if (request?.initiatorType === "preload")
|
|
804
|
+
preloadEvidence.push("cdp.initiator.type=preload");
|
|
805
|
+
if (resource?.initiatorType === "link")
|
|
806
|
+
preloadEvidence.push("resourceTiming.initiatorType=link");
|
|
807
|
+
if (image.url &&
|
|
808
|
+
preloads.some((preload) => !preload.responsive &&
|
|
809
|
+
preload.href &&
|
|
810
|
+
preload.observedMs <= snapshot.observedMs &&
|
|
811
|
+
matchingUrl(preload.href) === matchingUrl(image.url))) {
|
|
812
|
+
preloadEvidence.push("dom.link[rel~=preload][as=image].href");
|
|
813
|
+
}
|
|
814
|
+
let sourceUrl = null;
|
|
815
|
+
try {
|
|
816
|
+
const source = new URL(image.url).searchParams.get("url");
|
|
817
|
+
if (source) {
|
|
818
|
+
if (!source.startsWith("/"))
|
|
819
|
+
new URL(source);
|
|
820
|
+
sourceUrl = sanitizeUrl(source);
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
catch {
|
|
824
|
+
sourceUrl = null;
|
|
825
|
+
}
|
|
826
|
+
return {
|
|
827
|
+
...image,
|
|
828
|
+
url: sanitizeUrl(image.url),
|
|
829
|
+
sourceUrl,
|
|
830
|
+
networkId: request?.id ?? null,
|
|
831
|
+
networkMatch: request ? "url-before-snapshot" : "not-observed",
|
|
832
|
+
requestStatus: request?.status ?? "not-observed",
|
|
833
|
+
fetchStartMs,
|
|
834
|
+
fetchStartAfterTtfbMs: fetchStartMs !== null && navigationTimingAvailable
|
|
835
|
+
? fetchStartMs - navigation.responseStart
|
|
836
|
+
: null,
|
|
837
|
+
requestStartMs,
|
|
838
|
+
requestStartAfterTtfbMs: requestStartMs !== null && navigationTimingAvailable
|
|
839
|
+
? requestStartMs - navigation.responseStart
|
|
840
|
+
: null,
|
|
841
|
+
requestOrder: requestOrders.get(request?.id) ?? null,
|
|
842
|
+
isLcpCandidate: Boolean(image.url &&
|
|
843
|
+
browser?.lcp?.url &&
|
|
844
|
+
matchingUrl(image.url) === matchingUrl(browser.lcp.url)),
|
|
845
|
+
preloadEvidence,
|
|
846
|
+
initiatorType: request?.initiatorType ?? resource?.initiatorType ?? null,
|
|
847
|
+
initialPriority: request?.initialPriority ?? null,
|
|
848
|
+
finalPriority: request?.finalPriority ?? null,
|
|
849
|
+
finalPriorityStatus: request ? serialize(request).finalPriorityStatus : "not-observed",
|
|
850
|
+
priorityTimeline: request ? serialize(request).priorityTimeline : [],
|
|
851
|
+
};
|
|
852
|
+
});
|
|
853
|
+
const scriptSnapshots = [browser?.early, browser?.final].filter(Boolean);
|
|
854
|
+
const scriptHints = (resource) => {
|
|
855
|
+
for (const snapshot of scriptSnapshots) {
|
|
856
|
+
const script = snapshot.scripts.find((script) => matchingUrl(script.url) === matchingUrl(resource));
|
|
857
|
+
if (script)
|
|
858
|
+
return {
|
|
859
|
+
async: script.async,
|
|
860
|
+
defer: script.defer,
|
|
861
|
+
type: script.type,
|
|
862
|
+
fetchPriority: script.fetchPriority,
|
|
863
|
+
blocking: script.blocking,
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
return null;
|
|
867
|
+
};
|
|
868
|
+
const renderBlockingScripts = resources
|
|
869
|
+
.filter((resource) => resource.renderBlockingScript)
|
|
870
|
+
.map((resource) => {
|
|
871
|
+
const entry = browser.resources[resources.indexOf(resource)];
|
|
872
|
+
const request = network.find((request) => request.id === resource.networkId);
|
|
873
|
+
return {
|
|
874
|
+
...resource,
|
|
875
|
+
hints: scriptHints(entry.name),
|
|
876
|
+
initialPriority: request?.initialPriority ?? null,
|
|
877
|
+
finalPriority: request?.finalPriority ?? null,
|
|
878
|
+
};
|
|
879
|
+
});
|
|
880
|
+
if (browser) {
|
|
881
|
+
for (const unsupported of browser.unsupported)
|
|
882
|
+
notes.add(`PerformanceObserver ${unsupported} is unsupported.`);
|
|
883
|
+
for (const name of Object.keys(browser.truncated))
|
|
884
|
+
notes.add(`Browser ${name} collection was truncated by its configured limit.`);
|
|
885
|
+
if (!browser.early)
|
|
886
|
+
notes.add("Early image snapshot not observed: FCP or its following animation frame had not run at finish().");
|
|
887
|
+
if (browser.final?.visibilityMethod === "computed-style-fallback")
|
|
888
|
+
notes.add("checkVisibility unavailable; fallback visibility checks do not exclude opacity-zero ancestors or skipped content-visibility layers.");
|
|
889
|
+
if (browser.resources.some((entry) => entry.renderBlockingStatus === null))
|
|
890
|
+
notes.add("renderBlockingStatus unavailable for some resources; they are not classified as render blockers.");
|
|
891
|
+
}
|
|
892
|
+
const report = {
|
|
893
|
+
timeOriginEpochMs: origin,
|
|
894
|
+
finishedMs: browser?.finishedMs ?? null,
|
|
895
|
+
documentUrl: browser ? sanitizeUrl(browser.documentUrl) : null,
|
|
896
|
+
finalUrl: browser ? sanitizeUrl(browser.finalUrl) : null,
|
|
897
|
+
navigation: {
|
|
898
|
+
type: navigation?.type ?? null,
|
|
899
|
+
documentRequestId: documentNetwork?.id ?? null,
|
|
900
|
+
ttfbMs: documentNetwork?.ttfbMs ?? null,
|
|
901
|
+
ttfbSource: documentNetwork?.ttfbSource ?? "unavailable",
|
|
902
|
+
requestToFirstByteMs: documentNetwork?.requestToFirstByteMs ?? null,
|
|
903
|
+
browserRequestToFirstByteMs: navigationTimingAvailable
|
|
904
|
+
? navigation.responseStart - navigation.requestStart
|
|
905
|
+
: null,
|
|
906
|
+
navigationToFirstByteMs: navigationTimingAvailable
|
|
907
|
+
? navigation.responseStart - navigation.startTime
|
|
908
|
+
: null,
|
|
909
|
+
requestStartMs: navigationTimingAvailable ? navigation.requestStart : null,
|
|
910
|
+
responseStartMs: navigationTimingAvailable ? navigation.responseStart : null,
|
|
911
|
+
responseEndMs: navigation?.responseEnd > 0 ? navigation.responseEnd : null,
|
|
912
|
+
domContentLoadedMs: navigation?.domContentLoadedEventEnd > 0 ? navigation.domContentLoadedEventEnd : null,
|
|
913
|
+
loadMs: navigation?.loadEventEnd > 0 ? navigation.loadEventEnd : null,
|
|
914
|
+
clientObserved: {
|
|
915
|
+
domContentLoadedMs: browser?.domContentLoadedMs ?? null,
|
|
916
|
+
loadMs: browser?.loadMs ?? null,
|
|
917
|
+
responseReceivedMs: documentNetwork?.responseObservedMs ?? null,
|
|
918
|
+
},
|
|
919
|
+
cache: documentNetwork?.cache ?? null,
|
|
920
|
+
documentRequests: mainRequests,
|
|
921
|
+
},
|
|
922
|
+
paints: {
|
|
923
|
+
entries: browser?.paints ?? [],
|
|
924
|
+
fcpMs: browser?.paints.find((paint) => paint.name === "first-contentful-paint")?.startTime ??
|
|
925
|
+
null,
|
|
926
|
+
lcp: browser?.lcp
|
|
927
|
+
? {
|
|
928
|
+
...browser.lcp,
|
|
929
|
+
url: browser.lcp.url ? sanitizeUrl(browser.lcp.url) : null,
|
|
930
|
+
isFinal: false,
|
|
931
|
+
}
|
|
932
|
+
: null,
|
|
933
|
+
},
|
|
934
|
+
images: {
|
|
935
|
+
preloads: preloads.map((preload) => ({
|
|
936
|
+
...preload,
|
|
937
|
+
href: preload.href ? sanitizeUrl(preload.href) : null,
|
|
938
|
+
})),
|
|
939
|
+
early: imageSnapshot(browser?.early, "images"),
|
|
940
|
+
final: imageSnapshot(browser?.final, "images"),
|
|
941
|
+
earlyObservedMs: browser?.early?.observedMs ?? null,
|
|
942
|
+
finalObservedMs: browser?.final?.observedMs ?? null,
|
|
943
|
+
earlyViewport: browser?.early?.viewport ?? null,
|
|
944
|
+
finalViewport: browser?.final?.viewport ?? null,
|
|
945
|
+
earlyScan: browser?.early?.imageScan ?? null,
|
|
946
|
+
finalScan: browser?.final?.imageScan ?? null,
|
|
947
|
+
visibilityMethod: browser?.final?.visibilityMethod ?? "not-observed",
|
|
948
|
+
backgrounds: {
|
|
949
|
+
early: imageSnapshot(browser?.early, "backgrounds"),
|
|
950
|
+
final: imageSnapshot(browser?.final, "backgrounds"),
|
|
951
|
+
},
|
|
952
|
+
},
|
|
953
|
+
fonts: {
|
|
954
|
+
faces: (browser?.faces ?? []).map(classifyFontFace),
|
|
955
|
+
events: (browser?.fontEvents ?? []).map((event) => ({
|
|
956
|
+
...event,
|
|
957
|
+
faces: event.faces.map(classifyFontFace),
|
|
958
|
+
})),
|
|
959
|
+
resources: network
|
|
960
|
+
.filter((request) => request.resourceType === "Font")
|
|
961
|
+
.map((request) => ({
|
|
962
|
+
...request,
|
|
963
|
+
performanceTimings: resources.filter((resource) => resource.networkId === request.id),
|
|
964
|
+
})),
|
|
965
|
+
atFcp: (browser?.fontsAtFcp ?? []).map(classifyFontFace),
|
|
966
|
+
atFcpObservedMs: browser?.fontsAtFcpObservedMs ?? null,
|
|
967
|
+
statusAtFcp: browser?.fontSetStatusAtFcp ?? "not-observed",
|
|
968
|
+
statusAtFinish: browser?.fontSetStatus ?? "not-observed",
|
|
969
|
+
},
|
|
970
|
+
renderBlockingScripts,
|
|
971
|
+
explicitInlineRenderBlockers: scriptSnapshots.flatMap((snapshot) => snapshot.inlineRenderBlockers.map((script) => ({
|
|
972
|
+
...script,
|
|
973
|
+
observedMs: snapshot.observedMs,
|
|
974
|
+
snapshot: snapshot === browser.early ? "early" : "final",
|
|
975
|
+
}))),
|
|
976
|
+
resources,
|
|
977
|
+
network,
|
|
978
|
+
longTasks: browser?.longTasks ?? [],
|
|
979
|
+
collectionLimits: { ...LIMITS },
|
|
980
|
+
measurementNotes: [],
|
|
981
|
+
};
|
|
982
|
+
await dispose();
|
|
983
|
+
report.measurementNotes = [...notes];
|
|
984
|
+
return report;
|
|
985
|
+
}
|
|
986
|
+
return {
|
|
987
|
+
async finish() {
|
|
988
|
+
if (finishPromise)
|
|
989
|
+
return finishPromise;
|
|
990
|
+
if (disposed)
|
|
991
|
+
throw new Error("Page diagnostics already disposed");
|
|
992
|
+
finishPromise = collect().finally(dispose);
|
|
993
|
+
return finishPromise;
|
|
994
|
+
},
|
|
995
|
+
dispose,
|
|
996
|
+
};
|
|
997
|
+
}
|