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,427 @@
|
|
|
1
|
+
import { writeFile } from "node:fs/promises";
|
|
2
|
+
import { startNavigationRequestCapture } from "./navigation-requests.mjs";
|
|
3
|
+
import { sanitizeUrl } from "./page-diagnostics.mjs";
|
|
4
|
+
import { defaultSettings } from "./settings.mjs";
|
|
5
|
+
// Self-contained so the same predicates run in Playwright waits and the in-page sampler.
|
|
6
|
+
export function inspectState(spec) {
|
|
7
|
+
const visible = (el, minSize = 3) => {
|
|
8
|
+
if (!el.checkVisibility({
|
|
9
|
+
opacityProperty: true,
|
|
10
|
+
visibilityProperty: true,
|
|
11
|
+
contentVisibilityAuto: true,
|
|
12
|
+
}))
|
|
13
|
+
return false;
|
|
14
|
+
const rect = el.getBoundingClientRect();
|
|
15
|
+
return (rect.width > minSize &&
|
|
16
|
+
rect.height > minSize &&
|
|
17
|
+
rect.bottom > 0 &&
|
|
18
|
+
rect.right > 0 &&
|
|
19
|
+
rect.top < innerHeight &&
|
|
20
|
+
rect.left < innerWidth);
|
|
21
|
+
};
|
|
22
|
+
const matches = (root, rule) => {
|
|
23
|
+
const pattern = rule.pattern
|
|
24
|
+
? typeof rule.pattern === "string"
|
|
25
|
+
? new RegExp(rule.pattern, "i")
|
|
26
|
+
: new RegExp(rule.pattern.source, rule.pattern.flags)
|
|
27
|
+
: null;
|
|
28
|
+
return [...root.querySelectorAll(rule.selector)].filter((el) => {
|
|
29
|
+
if (!visible(el))
|
|
30
|
+
return false;
|
|
31
|
+
const text = el.innerText?.trim().slice(0, 3000) || el.getAttribute("aria-label") || "";
|
|
32
|
+
if (rule.text !== undefined && text !== rule.text)
|
|
33
|
+
return false;
|
|
34
|
+
if (pattern && !pattern.test(text))
|
|
35
|
+
return false;
|
|
36
|
+
return (rule.contains || []).every((child) => matches(el, child).length >= (child.min || 1));
|
|
37
|
+
});
|
|
38
|
+
};
|
|
39
|
+
const counts = {};
|
|
40
|
+
for (const [name, rule] of Object.entries(spec.markers))
|
|
41
|
+
counts[name] = matches(document, rule).length;
|
|
42
|
+
const route = location.pathname === spec.path;
|
|
43
|
+
const has = (name) => counts[name] >= (spec.markers[name].min || 1);
|
|
44
|
+
const primary = route && spec.primary.every(has);
|
|
45
|
+
const complete = primary && (spec.complete || []).every(has);
|
|
46
|
+
const shell = route && (spec.shell || []).every(has);
|
|
47
|
+
const activeMilestones = (spec.milestones || [])
|
|
48
|
+
.filter((milestone) => milestone.markers.every(has))
|
|
49
|
+
.map((milestone) => milestone.id);
|
|
50
|
+
const fallbackCount = spec.fallback ? matches(document, spec.fallback).length : null;
|
|
51
|
+
const indicators = (spec.feedbackIndicators || []).flatMap((selector) => [...document.querySelectorAll(selector)]
|
|
52
|
+
.filter((el) => visible(el, 0))
|
|
53
|
+
.map((el) => {
|
|
54
|
+
const rect = el.getBoundingClientRect();
|
|
55
|
+
return { selector, x: rect.x, width: rect.width };
|
|
56
|
+
}));
|
|
57
|
+
return {
|
|
58
|
+
path: location.pathname,
|
|
59
|
+
route,
|
|
60
|
+
shell,
|
|
61
|
+
primary,
|
|
62
|
+
complete,
|
|
63
|
+
activeMilestones,
|
|
64
|
+
counts,
|
|
65
|
+
fallbackCount,
|
|
66
|
+
indicators,
|
|
67
|
+
state: !route
|
|
68
|
+
? "previous-route"
|
|
69
|
+
: complete
|
|
70
|
+
? "useful-complete"
|
|
71
|
+
: primary
|
|
72
|
+
? "useful-partial"
|
|
73
|
+
: shell
|
|
74
|
+
? "shell-only"
|
|
75
|
+
: "destination-not-ready",
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
export async function waitForContent(page, spec, timeout = 20000) {
|
|
79
|
+
const deadline = performance.now() + timeout;
|
|
80
|
+
while (performance.now() < deadline) {
|
|
81
|
+
try {
|
|
82
|
+
if ((await page.evaluate(inspectState, spec)).primary)
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
if (!error.message.includes("Execution context was destroyed"))
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
await page.waitForTimeout(100);
|
|
90
|
+
}
|
|
91
|
+
throw new Error(`Useful content not observed on ${spec.path} within ${timeout}ms`);
|
|
92
|
+
}
|
|
93
|
+
function monitor(spec, inspect, binding, autoStart) {
|
|
94
|
+
if (window !== window.top)
|
|
95
|
+
return;
|
|
96
|
+
const clock = () => performance.timeOrigin + performance.now();
|
|
97
|
+
const samples = [];
|
|
98
|
+
let active = autoStart;
|
|
99
|
+
let previous = "";
|
|
100
|
+
let lastStored = 0;
|
|
101
|
+
let frame;
|
|
102
|
+
let startEpochMs = null;
|
|
103
|
+
let baseline = autoStart ? null : inspect(spec);
|
|
104
|
+
function sample() {
|
|
105
|
+
if (!active)
|
|
106
|
+
return;
|
|
107
|
+
const state = inspect(spec);
|
|
108
|
+
const feedbackReasons = [];
|
|
109
|
+
if (state.shell)
|
|
110
|
+
feedbackReasons.push("destination-shell");
|
|
111
|
+
else if (state.primary)
|
|
112
|
+
feedbackReasons.push("useful-content");
|
|
113
|
+
if (state.route && state.fallbackCount > (baseline?.fallbackCount || 0))
|
|
114
|
+
feedbackReasons.push("new-destination-fallback");
|
|
115
|
+
if (baseline &&
|
|
116
|
+
state.indicators.some((indicator, i) => {
|
|
117
|
+
const before = baseline.indicators[i];
|
|
118
|
+
return (!before ||
|
|
119
|
+
indicator.selector !== before.selector ||
|
|
120
|
+
Math.abs(indicator.x - before.x) >= 1 ||
|
|
121
|
+
Math.abs(indicator.width - before.width) >= 1);
|
|
122
|
+
}))
|
|
123
|
+
feedbackReasons.push("navigation-indicator-moved");
|
|
124
|
+
state.feedback = feedbackReasons.length > 0;
|
|
125
|
+
state.feedbackReasons = feedbackReasons;
|
|
126
|
+
const epochMs = clock();
|
|
127
|
+
const signature = JSON.stringify(state);
|
|
128
|
+
if (signature !== previous || epochMs - lastStored >= 100) {
|
|
129
|
+
const observation = { epochMs, ...state };
|
|
130
|
+
samples.push(observation);
|
|
131
|
+
if (signature !== previous)
|
|
132
|
+
window[binding]({ type: "samples", samples: [observation] });
|
|
133
|
+
previous = signature;
|
|
134
|
+
lastStored = epochMs;
|
|
135
|
+
}
|
|
136
|
+
if (samples.length < 1000)
|
|
137
|
+
frame = requestAnimationFrame(sample);
|
|
138
|
+
}
|
|
139
|
+
function click(event) {
|
|
140
|
+
const link = event.target.closest?.("a[href]");
|
|
141
|
+
if (!link || new URL(link.href).pathname !== spec.path || active)
|
|
142
|
+
return;
|
|
143
|
+
startEpochMs = performance.timeOrigin + event.timeStamp;
|
|
144
|
+
baseline = inspect(spec);
|
|
145
|
+
active = true;
|
|
146
|
+
window[binding]({
|
|
147
|
+
type: "click",
|
|
148
|
+
epochMs: startEpochMs,
|
|
149
|
+
handlerEpochMs: clock(),
|
|
150
|
+
timeOrigin: performance.timeOrigin,
|
|
151
|
+
initialSample: {
|
|
152
|
+
epochMs: clock(),
|
|
153
|
+
...baseline,
|
|
154
|
+
preClickBaseline: true,
|
|
155
|
+
feedback: false,
|
|
156
|
+
feedbackReasons: [],
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
sample();
|
|
160
|
+
}
|
|
161
|
+
document.addEventListener("click", click, { capture: true });
|
|
162
|
+
const flush = () => window[binding]({ type: "samples", samples });
|
|
163
|
+
window.addEventListener("pagehide", flush, { once: true });
|
|
164
|
+
window.__auditNavigation = {
|
|
165
|
+
finish() {
|
|
166
|
+
active = false;
|
|
167
|
+
cancelAnimationFrame(frame);
|
|
168
|
+
document.removeEventListener("click", click, { capture: true });
|
|
169
|
+
window.removeEventListener("pagehide", flush);
|
|
170
|
+
return {
|
|
171
|
+
samples,
|
|
172
|
+
timeOrigin: performance.timeOrigin,
|
|
173
|
+
collectedEpochMs: clock(),
|
|
174
|
+
finalState: inspect(spec),
|
|
175
|
+
};
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
if (autoStart)
|
|
179
|
+
frame = requestAnimationFrame(sample);
|
|
180
|
+
}
|
|
181
|
+
export function summarizeNavigation(samples, clickEpochMs, observationMs = Infinity, milestones = []) {
|
|
182
|
+
const uniqueSamples = [...new Map(samples.map((sample) => [sample.epochMs, sample])).values()];
|
|
183
|
+
const timeline = uniqueSamples
|
|
184
|
+
.filter((sample) => sample.epochMs >= clickEpochMs && sample.epochMs <= clickEpochMs + observationMs)
|
|
185
|
+
.sort((a, b) => a.epochMs - b.epochMs)
|
|
186
|
+
.map(({ epochMs, ...sample }) => ({ atMs: epochMs - clickEpochMs, ...sample }));
|
|
187
|
+
const first = (key) => timeline.find((sample) => sample[key])?.atMs ?? null;
|
|
188
|
+
const preClickBaseline = timeline.find((sample) => sample.preClickBaseline);
|
|
189
|
+
const milestoneTimings = Object.fromEntries(milestones.map((milestone) => [
|
|
190
|
+
milestone.id,
|
|
191
|
+
preClickBaseline?.activeMilestones?.includes(milestone.id)
|
|
192
|
+
? 0
|
|
193
|
+
: (timeline.find((sample) => sample.activeMilestones?.includes(milestone.id))?.atMs ??
|
|
194
|
+
null),
|
|
195
|
+
]));
|
|
196
|
+
const stateAt = (cutoff) => {
|
|
197
|
+
const before = timeline.filter((sample) => sample.atMs <= cutoff).at(-1);
|
|
198
|
+
const after = timeline.find((sample) => sample.atMs > cutoff);
|
|
199
|
+
return {
|
|
200
|
+
requestedMs: cutoff,
|
|
201
|
+
lastObservedMs: before?.atMs ?? null,
|
|
202
|
+
nextObservedMs: after?.atMs ?? null,
|
|
203
|
+
state: before?.state ?? "not-observed",
|
|
204
|
+
activeMilestones: before?.activeMilestones ?? [],
|
|
205
|
+
counts: before?.counts ?? {},
|
|
206
|
+
};
|
|
207
|
+
};
|
|
208
|
+
return {
|
|
209
|
+
clickToFeedbackMs: first("feedback"),
|
|
210
|
+
firstFeedbackReasons: timeline.find((sample) => sample.feedback)?.feedbackReasons ?? [],
|
|
211
|
+
clickToUrlMs: first("route"),
|
|
212
|
+
clickToShellMs: first("shell"),
|
|
213
|
+
clickToUsefulMs: first("primary"),
|
|
214
|
+
clickToCompleteMs: first("complete"),
|
|
215
|
+
milestoneTimings,
|
|
216
|
+
checkpoints: [50, 100, 500, 1000, 2000, 3000].map(stateAt),
|
|
217
|
+
timeline,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
export async function hoverBeforeClick(target, settings = defaultSettings) {
|
|
221
|
+
await target.scrollIntoViewIfNeeded({ timeout: 10000 });
|
|
222
|
+
await target.hover({ timeout: 10000 });
|
|
223
|
+
await target.page().waitForTimeout(settings.preClickHoverMs);
|
|
224
|
+
}
|
|
225
|
+
export async function waitForClientReadyLink(target, timeout = 20000) {
|
|
226
|
+
const element = await target.elementHandle({ timeout });
|
|
227
|
+
if (!element)
|
|
228
|
+
throw new Error("Navigation link was not available for client-readiness detection");
|
|
229
|
+
await target.page().waitForFunction((link) => {
|
|
230
|
+
const propsKey = Object.keys(link).find((key) => key.startsWith("__reactProps$"));
|
|
231
|
+
return Boolean(propsKey && typeof link[propsKey]?.onClick === "function");
|
|
232
|
+
}, element, { timeout });
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* @param {import('@playwright/test').Page} page
|
|
236
|
+
* @param {object} spec
|
|
237
|
+
* @param {object} [options]
|
|
238
|
+
* @param {string} [options.directory]
|
|
239
|
+
* @param {number} [options.observeMs]
|
|
240
|
+
* @param {boolean} [options.requireClientReady]
|
|
241
|
+
* @param {object} [options.requestCapture]
|
|
242
|
+
* @param {object} [options.settings]
|
|
243
|
+
*/
|
|
244
|
+
export async function measureNavigation(page, spec, { directory, observeMs = 5000, requireClientReady = false, requestCapture: suppliedRequestCapture, settings = defaultSettings, } = {}) {
|
|
245
|
+
const binding = `auditClick${Date.now()}`;
|
|
246
|
+
let clickEvent;
|
|
247
|
+
let resolveClick;
|
|
248
|
+
let completionObserved = false;
|
|
249
|
+
let resolveCompletion;
|
|
250
|
+
const clickPromise = new Promise((resolve) => {
|
|
251
|
+
resolveClick = resolve;
|
|
252
|
+
});
|
|
253
|
+
const completionPromise = new Promise((resolve) => {
|
|
254
|
+
resolveCompletion = resolve;
|
|
255
|
+
});
|
|
256
|
+
const previousSamples = [];
|
|
257
|
+
const observeCompletion = (sample) => {
|
|
258
|
+
if (!completionObserved &&
|
|
259
|
+
clickEvent &&
|
|
260
|
+
sample.complete &&
|
|
261
|
+
sample.epochMs >= clickEvent.epochMs) {
|
|
262
|
+
completionObserved = true;
|
|
263
|
+
resolveCompletion(sample);
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
await page.exposeBinding(binding, (_, event) => {
|
|
267
|
+
if (event.type === "click") {
|
|
268
|
+
clickEvent = event;
|
|
269
|
+
previousSamples.push(event.initialSample);
|
|
270
|
+
observeCompletion(event.initialSample);
|
|
271
|
+
resolveClick(event);
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
previousSamples.push(...event.samples);
|
|
275
|
+
for (const sample of event.samples)
|
|
276
|
+
observeCompletion(sample);
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
const session = await page.context().newCDPSession(page);
|
|
280
|
+
const requestCapture = suppliedRequestCapture ??
|
|
281
|
+
(await startNavigationRequestCapture(page, new URL(spec.path, page.url()).href));
|
|
282
|
+
let requests = [];
|
|
283
|
+
await session.send("Page.enable");
|
|
284
|
+
const source = `(${monitor.toString()})(${JSON.stringify(spec)}, (${inspectState.toString()}), ${JSON.stringify(binding)}, true)`;
|
|
285
|
+
const { identifier } = await session.send("Page.addScriptToEvaluateOnNewDocument", { source });
|
|
286
|
+
await page.evaluate(`(${monitor.toString()})(${JSON.stringify(spec)}, (${inspectState.toString()}), ${JSON.stringify(binding)}, false)`);
|
|
287
|
+
const frames = [];
|
|
288
|
+
const pendingWrites = [];
|
|
289
|
+
const collectionErrors = [];
|
|
290
|
+
const captureFrames = Boolean(directory);
|
|
291
|
+
let frameCaptureStarted = false;
|
|
292
|
+
let droppedFrames = 0;
|
|
293
|
+
const onFrame = (event) => {
|
|
294
|
+
void session.send("Page.screencastFrameAck", { sessionId: event.sessionId }).catch(() => { });
|
|
295
|
+
if (!event.metadata.timestamp)
|
|
296
|
+
return;
|
|
297
|
+
const epochMs = event.metadata.timestamp * 1000;
|
|
298
|
+
if (!clickEvent && frames.length)
|
|
299
|
+
return;
|
|
300
|
+
if (frames.length >= 100) {
|
|
301
|
+
droppedFrames++;
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (frames.length && epochMs - frames.at(-1).epochMs < 100)
|
|
305
|
+
return;
|
|
306
|
+
const name = `frame-${String(frames.length).padStart(3, "0")}.jpg`;
|
|
307
|
+
frames.push({ file: name, epochMs });
|
|
308
|
+
pendingWrites.push(writeFile(`${directory}/${name}`, Buffer.from(event.data, "base64")).catch(() => {
|
|
309
|
+
collectionErrors.push(`Could not write ${name}`);
|
|
310
|
+
}));
|
|
311
|
+
};
|
|
312
|
+
const stopFrameCapture = async () => {
|
|
313
|
+
if (!frameCaptureStarted)
|
|
314
|
+
return;
|
|
315
|
+
await session
|
|
316
|
+
.send("Page.stopScreencast")
|
|
317
|
+
.catch(() => collectionErrors.push("Could not stop screencast"));
|
|
318
|
+
session.off("Page.screencastFrame", onFrame);
|
|
319
|
+
frameCaptureStarted = false;
|
|
320
|
+
await Promise.all(pendingWrites);
|
|
321
|
+
};
|
|
322
|
+
try {
|
|
323
|
+
if (captureFrames) {
|
|
324
|
+
session.on("Page.screencastFrame", onFrame);
|
|
325
|
+
await session.send("Page.startScreencast", {
|
|
326
|
+
format: "jpeg",
|
|
327
|
+
quality: 65,
|
|
328
|
+
maxWidth: 1440,
|
|
329
|
+
maxHeight: 1000,
|
|
330
|
+
everyNthFrame: 1,
|
|
331
|
+
});
|
|
332
|
+
frameCaptureStarted = true;
|
|
333
|
+
}
|
|
334
|
+
const target = page
|
|
335
|
+
.locator(`a[href=${JSON.stringify(spec.path)}]`)
|
|
336
|
+
.filter({ visible: true })
|
|
337
|
+
.first();
|
|
338
|
+
const href = await target.getAttribute("href", { timeout: 10000 });
|
|
339
|
+
if (new URL(href, page.url()).origin !== new URL(page.url()).origin)
|
|
340
|
+
throw new Error("Cross-origin navigation refused");
|
|
341
|
+
let clientReadyWaitMs = null;
|
|
342
|
+
if (requireClientReady) {
|
|
343
|
+
const clientReadyStartedAt = performance.now();
|
|
344
|
+
await waitForClientReadyLink(target);
|
|
345
|
+
clientReadyWaitMs = performance.now() - clientReadyStartedAt;
|
|
346
|
+
}
|
|
347
|
+
await hoverBeforeClick(target, settings);
|
|
348
|
+
await target.click({ timeout: 10000, noWaitAfter: true });
|
|
349
|
+
let clickTimeout;
|
|
350
|
+
try {
|
|
351
|
+
await Promise.race([
|
|
352
|
+
clickPromise,
|
|
353
|
+
new Promise((_, reject) => {
|
|
354
|
+
clickTimeout = setTimeout(() => reject(new Error("Expected click was not observed")), 2000);
|
|
355
|
+
}),
|
|
356
|
+
]);
|
|
357
|
+
}
|
|
358
|
+
finally {
|
|
359
|
+
clearTimeout(clickTimeout);
|
|
360
|
+
}
|
|
361
|
+
if (!clickEvent)
|
|
362
|
+
throw new Error("No click timestamp recorded");
|
|
363
|
+
let observationTimer;
|
|
364
|
+
const endReason = await Promise.race([
|
|
365
|
+
completionPromise.then(() => "complete"),
|
|
366
|
+
new Promise((resolve) => {
|
|
367
|
+
observationTimer = setTimeout(() => resolve("timeout"), Math.max(0, clickEvent.epochMs + observeMs - Date.now()));
|
|
368
|
+
}),
|
|
369
|
+
]);
|
|
370
|
+
clearTimeout(observationTimer);
|
|
371
|
+
let snapshot;
|
|
372
|
+
try {
|
|
373
|
+
snapshot = await page.evaluate(() => window.__auditNavigation?.finish());
|
|
374
|
+
}
|
|
375
|
+
catch {
|
|
376
|
+
collectionErrors.push("Document changed during final collection; retained outgoing samples only");
|
|
377
|
+
}
|
|
378
|
+
if (!snapshot)
|
|
379
|
+
collectionErrors.push("Final navigation observer unavailable");
|
|
380
|
+
const summary = summarizeNavigation([...previousSamples, ...(snapshot?.samples || [])], clickEvent.epochMs, observeMs, spec.milestones);
|
|
381
|
+
try {
|
|
382
|
+
requests = await requestCapture.finish(clickEvent.epochMs);
|
|
383
|
+
}
|
|
384
|
+
catch {
|
|
385
|
+
collectionErrors.push("Could not finish navigation request collection");
|
|
386
|
+
}
|
|
387
|
+
await stopFrameCapture();
|
|
388
|
+
return {
|
|
389
|
+
...summary,
|
|
390
|
+
type: !snapshot
|
|
391
|
+
? "unknown"
|
|
392
|
+
: snapshot.timeOrigin === clickEvent.timeOrigin
|
|
393
|
+
? "same-document"
|
|
394
|
+
: "full-document",
|
|
395
|
+
observationMs: observeMs,
|
|
396
|
+
endReason,
|
|
397
|
+
preClickHoverMs: settings.preClickHoverMs,
|
|
398
|
+
clientReadyWaitMs,
|
|
399
|
+
collectedAtMs: snapshot ? snapshot.collectedEpochMs - clickEvent.epochMs : null,
|
|
400
|
+
finalState: snapshot?.finalState ?? null,
|
|
401
|
+
collectionErrors,
|
|
402
|
+
droppedFrames,
|
|
403
|
+
clickEpochMs: clickEvent.epochMs,
|
|
404
|
+
clickHandlerDelayMs: clickEvent.handlerEpochMs - clickEvent.epochMs,
|
|
405
|
+
finalUrl: sanitizeUrl(page.url()),
|
|
406
|
+
requests,
|
|
407
|
+
frames: frames.map(({ epochMs, ...frame }) => ({
|
|
408
|
+
...frame,
|
|
409
|
+
atMs: epochMs - clickEvent.epochMs,
|
|
410
|
+
})),
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
finally {
|
|
414
|
+
await stopFrameCapture();
|
|
415
|
+
await Promise.all(pendingWrites);
|
|
416
|
+
await requestCapture
|
|
417
|
+
.dispose()
|
|
418
|
+
.catch(() => collectionErrors.push("Could not dispose navigation request collection"));
|
|
419
|
+
await session
|
|
420
|
+
.send("Page.removeScriptToEvaluateOnNewDocument", { identifier })
|
|
421
|
+
.catch(() => collectionErrors.push("Could not remove navigation observer"));
|
|
422
|
+
await page.evaluate(() => window.__auditNavigation?.finish()).catch(() => { });
|
|
423
|
+
await session
|
|
424
|
+
.detach()
|
|
425
|
+
.catch(() => collectionErrors.push("Could not detach navigation CDP session"));
|
|
426
|
+
}
|
|
427
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export declare function sanitizeUrl(raw: any): string;
|
|
2
|
+
export declare function responseTiming(timing: any): {
|
|
3
|
+
ttfbMs: null;
|
|
4
|
+
source: string;
|
|
5
|
+
} | {
|
|
6
|
+
ttfbMs: number;
|
|
7
|
+
source: string;
|
|
8
|
+
};
|
|
9
|
+
export declare function classifyResourceTiming(entry: any, documentUrl: any, cdpType: any): {
|
|
10
|
+
timingAvailability: string;
|
|
11
|
+
ttfbMs: number | null;
|
|
12
|
+
renderBlockingScript: boolean;
|
|
13
|
+
};
|
|
14
|
+
export declare function classifyFontFace(face: any): any;
|
|
15
|
+
export declare function startPageDiagnostics(page: any): Promise<{
|
|
16
|
+
finish(): Promise<any>;
|
|
17
|
+
dispose: () => Promise<any>;
|
|
18
|
+
}>;
|