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,239 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { hoverBeforeClick, inspectState } from "./navigation.mjs";
|
|
4
|
+
import { defaultSettings } from "./settings.mjs";
|
|
5
|
+
const HTML_LIMIT = 60000;
|
|
6
|
+
const LINK_LIMIT = 200;
|
|
7
|
+
const HEADING_LIMIT = 40;
|
|
8
|
+
const PRINTED_LINK_LIMIT = 40;
|
|
9
|
+
const EVALUATE_RETRIES = 50;
|
|
10
|
+
const AFTER_CLICK_CAPTURES = [
|
|
11
|
+
["after-click", 0],
|
|
12
|
+
["after-1s", 1000],
|
|
13
|
+
["after-5s", 4000],
|
|
14
|
+
];
|
|
15
|
+
/**
|
|
16
|
+
* Resolves an inspect target (a path or absolute URL) against the audit origin and refuses
|
|
17
|
+
* anything outside it.
|
|
18
|
+
*/
|
|
19
|
+
export function resolveInspectUrl(target, origin) {
|
|
20
|
+
if (!URL.canParse(target) && !origin) {
|
|
21
|
+
throw new Error("Pass a full URL, or set --origin, AUDIT_ORIGIN or a config origin");
|
|
22
|
+
}
|
|
23
|
+
const url = new URL(target, origin ?? undefined);
|
|
24
|
+
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) {
|
|
25
|
+
throw new Error("Inspect URLs must be credential-free HTTP(S) URLs");
|
|
26
|
+
}
|
|
27
|
+
if (origin && url.origin !== new URL(origin).origin) {
|
|
28
|
+
throw new Error(`${url.href} is outside the audit origin ${new URL(origin).origin}`);
|
|
29
|
+
}
|
|
30
|
+
return url.href;
|
|
31
|
+
}
|
|
32
|
+
export function formatInspectCapture(capture) {
|
|
33
|
+
const lines = [
|
|
34
|
+
`${capture.name}${capture.status === null ? "" : ` · HTTP ${capture.status}`} · ${capture.url} · "${capture.title}"`,
|
|
35
|
+
];
|
|
36
|
+
if (capture.redirectedFrom) {
|
|
37
|
+
lines.push(` redirected from ${capture.redirectedFrom}; use ${new URL(capture.url).pathname} as the page path`);
|
|
38
|
+
}
|
|
39
|
+
if (capture.page) {
|
|
40
|
+
const milestones = capture.page.activeMilestones.length
|
|
41
|
+
? ` · milestones ${capture.page.activeMilestones.join(", ")}`
|
|
42
|
+
: "";
|
|
43
|
+
const counts = Object.entries(capture.page.counts)
|
|
44
|
+
.map(([name, count]) => `${name}=${count}`)
|
|
45
|
+
.join(" ");
|
|
46
|
+
lines.push(` page ${capture.page.id}: ${capture.page.state}${milestones} · ${counts}`);
|
|
47
|
+
}
|
|
48
|
+
for (const check of capture.selectors) {
|
|
49
|
+
lines.push(check.error
|
|
50
|
+
? ` selector ${check.selector}: invalid (${check.error})`
|
|
51
|
+
: ` selector ${check.selector}: ${check.matches} matches, ${check.visible} audit-visible`);
|
|
52
|
+
}
|
|
53
|
+
lines.push(` headings: ${capture.headings.map((heading) => `${heading.tag} ${heading.text}`).join(" · ") || "none"}`);
|
|
54
|
+
lines.push(` links (${capture.links.length} visible, same-origin):`);
|
|
55
|
+
for (const link of capture.links.slice(0, PRINTED_LINK_LIMIT)) {
|
|
56
|
+
lines.push(` ${link.href} "${link.text}" ${link.width}×${link.height}`);
|
|
57
|
+
}
|
|
58
|
+
if (capture.links.length > PRINTED_LINK_LIMIT) {
|
|
59
|
+
lines.push(` … ${capture.links.length - PRINTED_LINK_LIMIT} more in ${capture.name}.json`);
|
|
60
|
+
}
|
|
61
|
+
return `${lines.join("\n")}\n`;
|
|
62
|
+
}
|
|
63
|
+
// Runs in the page; must stay self-contained.
|
|
64
|
+
function snapshotPage({ selectors, htmlLimit, linkLimit, headingLimit }) {
|
|
65
|
+
const rendered = (el) => {
|
|
66
|
+
const rect = el.getBoundingClientRect();
|
|
67
|
+
return (rect.width > 0 &&
|
|
68
|
+
rect.height > 0 &&
|
|
69
|
+
el.checkVisibility({ opacityProperty: true, visibilityProperty: true }));
|
|
70
|
+
};
|
|
71
|
+
const label = (el) => (el.innerText?.trim() || el.getAttribute("aria-label") || "").replace(/\s+/g, " ").slice(0, 160);
|
|
72
|
+
const size = (el) => {
|
|
73
|
+
const rect = el.getBoundingClientRect();
|
|
74
|
+
return { width: Math.round(rect.width), height: Math.round(rect.height) };
|
|
75
|
+
};
|
|
76
|
+
const sameOrigin = (el) => {
|
|
77
|
+
if (el.getAttribute("href").startsWith("#"))
|
|
78
|
+
return false;
|
|
79
|
+
try {
|
|
80
|
+
return new URL(el.href).origin === location.origin;
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
return {
|
|
87
|
+
url: location.href,
|
|
88
|
+
title: document.title,
|
|
89
|
+
headings: [...document.querySelectorAll("h1, h2, h3")]
|
|
90
|
+
.filter(rendered)
|
|
91
|
+
.slice(0, headingLimit)
|
|
92
|
+
.map((el) => ({ tag: el.tagName.toLowerCase(), text: label(el) })),
|
|
93
|
+
links: [...document.querySelectorAll("a[href]")]
|
|
94
|
+
.filter((el) => rendered(el) && sameOrigin(el))
|
|
95
|
+
.slice(0, linkLimit)
|
|
96
|
+
.map((el) => ({ href: el.getAttribute("href"), text: label(el), ...size(el) })),
|
|
97
|
+
selectors: selectors.map((selector) => {
|
|
98
|
+
try {
|
|
99
|
+
return { selector, matches: document.querySelectorAll(selector).length, error: null };
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
return { selector, matches: 0, error: String(error.message ?? error) };
|
|
103
|
+
}
|
|
104
|
+
}),
|
|
105
|
+
html: (document.querySelector("main") ?? document.body).outerHTML.slice(0, htmlLimit),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
async function evaluateSettled(page, callback, argument) {
|
|
109
|
+
for (let attempt = 0;; attempt++) {
|
|
110
|
+
try {
|
|
111
|
+
return await page.evaluate(callback, argument);
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
const navigating = /Execution context was destroyed|Cannot find context/.test(error.message);
|
|
115
|
+
if (!navigating || attempt >= EVALUATE_RETRIES)
|
|
116
|
+
throw error;
|
|
117
|
+
await page.waitForTimeout(100);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
function selectorSpec(selectors) {
|
|
122
|
+
return {
|
|
123
|
+
path: "",
|
|
124
|
+
markers: Object.fromEntries(selectors.map((selector) => [selector, { selector, min: 1 }])),
|
|
125
|
+
primary: [],
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
async function capture(page, name, { directory, selectors, pages, status, requestedPath = null }) {
|
|
129
|
+
await page.screenshot({ path: `${directory}/${name}.png`, timeout: 10000 });
|
|
130
|
+
const snapshot = await evaluateSettled(page, snapshotPage, {
|
|
131
|
+
selectors,
|
|
132
|
+
htmlLimit: HTML_LIMIT,
|
|
133
|
+
linkLimit: LINK_LIMIT,
|
|
134
|
+
headingLimit: HEADING_LIMIT,
|
|
135
|
+
});
|
|
136
|
+
const valid = snapshot.selectors.filter((check) => !check.error).map((check) => check.selector);
|
|
137
|
+
const audited = valid.length
|
|
138
|
+
? (await evaluateSettled(page, inspectState, selectorSpec(valid))).counts
|
|
139
|
+
: {};
|
|
140
|
+
const selectorChecks = snapshot.selectors.map((check) => ({
|
|
141
|
+
...check,
|
|
142
|
+
visible: check.error ? 0 : audited[check.selector],
|
|
143
|
+
}));
|
|
144
|
+
const pathname = new URL(snapshot.url).pathname;
|
|
145
|
+
const spec = pages.find((candidate) => candidate.path === pathname);
|
|
146
|
+
const state = spec ? await evaluateSettled(page, inspectState, spec) : null;
|
|
147
|
+
const result = {
|
|
148
|
+
name,
|
|
149
|
+
status,
|
|
150
|
+
redirectedFrom: requestedPath !== null && requestedPath !== pathname ? requestedPath : null,
|
|
151
|
+
...snapshot,
|
|
152
|
+
selectors: selectorChecks,
|
|
153
|
+
page: spec
|
|
154
|
+
? {
|
|
155
|
+
id: spec.id,
|
|
156
|
+
state: state.state,
|
|
157
|
+
shell: state.shell,
|
|
158
|
+
primary: state.primary,
|
|
159
|
+
complete: state.complete,
|
|
160
|
+
activeMilestones: state.activeMilestones,
|
|
161
|
+
counts: state.counts,
|
|
162
|
+
}
|
|
163
|
+
: null,
|
|
164
|
+
screenshot: `${name}.png`,
|
|
165
|
+
};
|
|
166
|
+
await writeFile(`${directory}/${name}.json`, JSON.stringify(result, null, 2));
|
|
167
|
+
return result;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Opens one URL like an audit case and saves evidence for writing markers and journeys:
|
|
171
|
+
* a screenshot, visible headings and same-origin links (with rendered size), ad-hoc selector
|
|
172
|
+
* counts, the configured page's marker state, and the main HTML. With `click`, it hovers and
|
|
173
|
+
* clicks the first visible `a[href]` whose attribute exactly equals `click` — the same target a
|
|
174
|
+
* journey uses — and captures again after the click, 1s and 5s.
|
|
175
|
+
*
|
|
176
|
+
* @param {object} options
|
|
177
|
+
* @param {string} options.url
|
|
178
|
+
* @param {string | null} [options.click]
|
|
179
|
+
* @param {string[]} [options.selectors]
|
|
180
|
+
* @param {readonly import("../config/types.ts").PageSpec[]} [options.pages]
|
|
181
|
+
* @param {import("../config/types.ts").AuditSettings} [options.settings]
|
|
182
|
+
* @param {string} [options.outputDirectory]
|
|
183
|
+
* @param {boolean} [options.headed]
|
|
184
|
+
* @param {(capture: any) => void} [options.onCapture] Called as each capture is saved.
|
|
185
|
+
*/
|
|
186
|
+
export async function inspectUrl({ url, click = null, selectors = [], pages = [], settings = defaultSettings, outputDirectory = "artifacts/inspect", headed = false, onCapture = () => { }, }) {
|
|
187
|
+
const target = new URL(url);
|
|
188
|
+
if (click && new URL(click, target).origin !== target.origin) {
|
|
189
|
+
throw new Error(`Refusing to click a cross-origin link: ${click}`);
|
|
190
|
+
}
|
|
191
|
+
const directory = resolve(outputDirectory, `inspect-${new Date().toISOString().replaceAll(":", "-")}`);
|
|
192
|
+
await mkdir(directory, { recursive: true });
|
|
193
|
+
const { chromium } = await import("@playwright/test");
|
|
194
|
+
const browser = await chromium.launch({ headless: !headed });
|
|
195
|
+
try {
|
|
196
|
+
const context = await browser.newContext({
|
|
197
|
+
viewport: settings.viewport,
|
|
198
|
+
colorScheme: "light",
|
|
199
|
+
timezoneId: "UTC",
|
|
200
|
+
locale: "en-US",
|
|
201
|
+
});
|
|
202
|
+
const page = await context.newPage();
|
|
203
|
+
const response = await page.goto(target.href, { waitUntil: "domcontentloaded", timeout: 60000 });
|
|
204
|
+
await page.waitForTimeout(settings.pageObservationMs);
|
|
205
|
+
const options = { directory, selectors, pages };
|
|
206
|
+
const captures = [];
|
|
207
|
+
const record = async (name, status, requestedPath = null) => {
|
|
208
|
+
const result = await capture(page, name, { ...options, status, requestedPath });
|
|
209
|
+
captures.push(result);
|
|
210
|
+
onCapture(result);
|
|
211
|
+
};
|
|
212
|
+
await record("initial", response?.status() ?? null, target.pathname);
|
|
213
|
+
if (click) {
|
|
214
|
+
const link = page
|
|
215
|
+
.locator(`a[href=${JSON.stringify(click)}]`)
|
|
216
|
+
.filter({ visible: true })
|
|
217
|
+
.first();
|
|
218
|
+
if (!(await link.count())) {
|
|
219
|
+
throw new Error(`No visible link with href exactly "${click}" on ${target.pathname}; a journey to it would fail. Evidence: ${directory}`);
|
|
220
|
+
}
|
|
221
|
+
try {
|
|
222
|
+
await hoverBeforeClick(link, settings);
|
|
223
|
+
await link.click({ timeout: 10000, noWaitAfter: true });
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
const reason = error.message.match(/<\w.*? intercepts pointer events/)?.[0];
|
|
227
|
+
throw new Error(`Could not hover and click "${click}" (${reason ?? error.message.split("\n")[0]}); a journey to it would fail. Evidence: ${directory}`);
|
|
228
|
+
}
|
|
229
|
+
for (const [name, delayMs] of AFTER_CLICK_CAPTURES) {
|
|
230
|
+
await page.waitForTimeout(delayMs);
|
|
231
|
+
await record(name, null);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return { directory, captures };
|
|
235
|
+
}
|
|
236
|
+
finally {
|
|
237
|
+
await browser.close();
|
|
238
|
+
}
|
|
239
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export declare function classifyNavigationRsc({ requestHeaders, startMs, initialPriority }: {
|
|
2
|
+
initialPriority: any;
|
|
3
|
+
requestHeaders?: {} | undefined;
|
|
4
|
+
startMs: any;
|
|
5
|
+
}): {
|
|
6
|
+
role: string;
|
|
7
|
+
confidence: string;
|
|
8
|
+
};
|
|
9
|
+
export declare function startNavigationRequestCapture(page: any, destination: any): Promise<{
|
|
10
|
+
finish(clickEpochMs: any): Promise<{
|
|
11
|
+
id: any;
|
|
12
|
+
url: string;
|
|
13
|
+
role: string;
|
|
14
|
+
confidence: string;
|
|
15
|
+
segmentPath: any;
|
|
16
|
+
prefetchKind: any;
|
|
17
|
+
hasRouterState: boolean;
|
|
18
|
+
nextUrl: string | null;
|
|
19
|
+
responseHeaders: any;
|
|
20
|
+
matchedPath: any;
|
|
21
|
+
rewrittenPath: any;
|
|
22
|
+
postponed: any;
|
|
23
|
+
prerender: any;
|
|
24
|
+
cache: any;
|
|
25
|
+
resourceType: any;
|
|
26
|
+
status: any;
|
|
27
|
+
statusCode: any;
|
|
28
|
+
startMs: number;
|
|
29
|
+
responseMs: number | null;
|
|
30
|
+
endMs: number | null;
|
|
31
|
+
durationMs: number | null;
|
|
32
|
+
timingStartMs: number | null;
|
|
33
|
+
ttfbMs: any;
|
|
34
|
+
timingSource: any;
|
|
35
|
+
resourceTiming: any;
|
|
36
|
+
protocol: any;
|
|
37
|
+
connectionId: any;
|
|
38
|
+
connectionReused: any;
|
|
39
|
+
remoteIPAddress: any;
|
|
40
|
+
encodedDataLength: any;
|
|
41
|
+
initialPriority: any;
|
|
42
|
+
finalPriority: any;
|
|
43
|
+
fromDiskCache: any;
|
|
44
|
+
fromPrefetchCache: any;
|
|
45
|
+
fromServiceWorker: any;
|
|
46
|
+
canceled: any;
|
|
47
|
+
blockedReason: any;
|
|
48
|
+
}[]>;
|
|
49
|
+
dispose: () => Promise<void>;
|
|
50
|
+
}>;
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { responseTiming, sanitizeUrl } from "./page-diagnostics.mjs";
|
|
2
|
+
const REQUEST_LIMIT = 200;
|
|
3
|
+
const MAX_METADATA_LENGTH = 4096;
|
|
4
|
+
const REQUEST_HEADERS = [
|
|
5
|
+
"rsc",
|
|
6
|
+
"next-router-prefetch",
|
|
7
|
+
"next-router-segment-prefetch",
|
|
8
|
+
"next-router-state-tree",
|
|
9
|
+
"next-url",
|
|
10
|
+
"next-hmr-refresh",
|
|
11
|
+
];
|
|
12
|
+
const RESPONSE_HEADERS = [
|
|
13
|
+
"age",
|
|
14
|
+
"cache-control",
|
|
15
|
+
"content-type",
|
|
16
|
+
"server-timing",
|
|
17
|
+
"x-matched-path",
|
|
18
|
+
"x-nextjs-postponed",
|
|
19
|
+
"x-nextjs-prerender",
|
|
20
|
+
"x-nextjs-cache",
|
|
21
|
+
"x-nextjs-rewritten-path",
|
|
22
|
+
"x-vercel-cache",
|
|
23
|
+
"x-vercel-id",
|
|
24
|
+
];
|
|
25
|
+
function lowerHeaders(headers = {}) {
|
|
26
|
+
return Object.fromEntries(Object.entries(headers).map(([name, content]) => [name.toLowerCase(), String(content)]));
|
|
27
|
+
}
|
|
28
|
+
function selectHeaders(headers, names) {
|
|
29
|
+
const selected = {};
|
|
30
|
+
for (const name of names) {
|
|
31
|
+
if (headers[name] !== undefined)
|
|
32
|
+
selected[name] = headers[name].slice(0, MAX_METADATA_LENGTH);
|
|
33
|
+
}
|
|
34
|
+
return selected;
|
|
35
|
+
}
|
|
36
|
+
function normalizedDestination(raw) {
|
|
37
|
+
try {
|
|
38
|
+
const url = new URL(raw);
|
|
39
|
+
url.hash = "";
|
|
40
|
+
url.searchParams.delete("_rsc");
|
|
41
|
+
return url.href;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function isFlightUrl(raw) {
|
|
48
|
+
try {
|
|
49
|
+
const url = new URL(raw);
|
|
50
|
+
return (url.searchParams.has("_rsc") ||
|
|
51
|
+
/(?:\.rsc|\.segment\.rsc|__next\..*\.txt)$/i.test(url.pathname));
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
export function classifyNavigationRsc({ requestHeaders = {}, startMs, initialPriority }) {
|
|
58
|
+
const segmentPath = requestHeaders["next-router-segment-prefetch"] ?? null;
|
|
59
|
+
const prefetchKind = requestHeaders["next-router-prefetch"] ?? null;
|
|
60
|
+
if (segmentPath === "/_tree")
|
|
61
|
+
return { role: "route-tree", confidence: "explicit" };
|
|
62
|
+
if (segmentPath)
|
|
63
|
+
return { role: "segment-prefetch", confidence: "explicit" };
|
|
64
|
+
if (prefetchKind === "3")
|
|
65
|
+
return { role: "app-shell-prefetch", confidence: "explicit" };
|
|
66
|
+
if (prefetchKind === "2")
|
|
67
|
+
return { role: "runtime-prefetch", confidence: "explicit" };
|
|
68
|
+
if (prefetchKind === "1")
|
|
69
|
+
return { role: "loading-boundary-prefetch", confidence: "explicit" };
|
|
70
|
+
if (startMs < 0 && String(initialPriority).toLowerCase() === "low") {
|
|
71
|
+
return { role: "full-prefetch", confidence: "probable" };
|
|
72
|
+
}
|
|
73
|
+
if (requestHeaders["next-router-state-tree"]) {
|
|
74
|
+
return { role: "navigation", confidence: "probable" };
|
|
75
|
+
}
|
|
76
|
+
return { role: "flight", confidence: "target-match" };
|
|
77
|
+
}
|
|
78
|
+
export async function startNavigationRequestCapture(page, destination) {
|
|
79
|
+
const destinationUrl = normalizedDestination(destination);
|
|
80
|
+
if (!destinationUrl)
|
|
81
|
+
throw new Error("Navigation request capture requires an absolute destination URL");
|
|
82
|
+
const session = await page.context().newCDPSession(page);
|
|
83
|
+
const requests = [];
|
|
84
|
+
const active = new Map();
|
|
85
|
+
const listeners = [];
|
|
86
|
+
let disposed = false;
|
|
87
|
+
const listen = (name, callback) => {
|
|
88
|
+
session.on(name, callback);
|
|
89
|
+
listeners.push([name, callback]);
|
|
90
|
+
};
|
|
91
|
+
const eventEpoch = (request, timestamp) => request.startEpochMs + (timestamp - request.monotonic) * 1000;
|
|
92
|
+
const applyResponse = (request, response, epochMs) => {
|
|
93
|
+
const headers = lowerHeaders(response.headers);
|
|
94
|
+
request.responseHeaders = selectHeaders(headers, RESPONSE_HEADERS);
|
|
95
|
+
request.responseEpochMs = epochMs;
|
|
96
|
+
request.statusCode = response.status;
|
|
97
|
+
request.mimeType = response.mimeType ?? null;
|
|
98
|
+
request.fromDiskCache = response.fromDiskCache ?? false;
|
|
99
|
+
request.fromPrefetchCache = response.fromPrefetchCache ?? false;
|
|
100
|
+
request.fromServiceWorker = response.fromServiceWorker ?? false;
|
|
101
|
+
request.protocol = response.protocol ?? null;
|
|
102
|
+
request.connectionId = response.connectionId ?? null;
|
|
103
|
+
request.connectionReused = response.connectionReused ?? null;
|
|
104
|
+
request.remoteIPAddress = response.remoteIPAddress ?? null;
|
|
105
|
+
request.resourceTiming = response.timing ?? null;
|
|
106
|
+
Object.assign(request, responseTiming(response.timing));
|
|
107
|
+
};
|
|
108
|
+
listen("Network.requestWillBeSent", (event) => {
|
|
109
|
+
if (disposed || normalizedDestination(event.request.url) !== destinationUrl)
|
|
110
|
+
return;
|
|
111
|
+
const previous = active.get(event.requestId);
|
|
112
|
+
if (previous && event.redirectResponse) {
|
|
113
|
+
applyResponse(previous, event.redirectResponse, eventEpoch(previous, event.timestamp));
|
|
114
|
+
previous.status = "redirected";
|
|
115
|
+
previous.endEpochMs = eventEpoch(previous, event.timestamp);
|
|
116
|
+
}
|
|
117
|
+
active.delete(event.requestId);
|
|
118
|
+
if (requests.length >= REQUEST_LIMIT)
|
|
119
|
+
return;
|
|
120
|
+
const headers = lowerHeaders(event.request.headers);
|
|
121
|
+
const request = {
|
|
122
|
+
id: `${event.requestId}:${requests.length}`,
|
|
123
|
+
requestId: event.requestId,
|
|
124
|
+
url: event.request.url,
|
|
125
|
+
resourceType: event.type ?? "Other",
|
|
126
|
+
startEpochMs: event.wallTime * 1000,
|
|
127
|
+
monotonic: event.timestamp,
|
|
128
|
+
requestHeaders: selectHeaders(headers, REQUEST_HEADERS),
|
|
129
|
+
initialPriority: event.request.initialPriority ?? null,
|
|
130
|
+
finalPriority: event.request.initialPriority ?? null,
|
|
131
|
+
status: "pending",
|
|
132
|
+
ttfbMs: null,
|
|
133
|
+
source: "unavailable",
|
|
134
|
+
};
|
|
135
|
+
requests.push(request);
|
|
136
|
+
active.set(event.requestId, request);
|
|
137
|
+
});
|
|
138
|
+
listen("Network.requestWillBeSentExtraInfo", (event) => {
|
|
139
|
+
const request = active.get(event.requestId);
|
|
140
|
+
if (!request || disposed)
|
|
141
|
+
return;
|
|
142
|
+
request.requestHeaders = {
|
|
143
|
+
...request.requestHeaders,
|
|
144
|
+
...selectHeaders(lowerHeaders(event.headers), REQUEST_HEADERS),
|
|
145
|
+
};
|
|
146
|
+
});
|
|
147
|
+
listen("Network.resourceChangedPriority", (event) => {
|
|
148
|
+
const request = active.get(event.requestId);
|
|
149
|
+
if (request && !disposed)
|
|
150
|
+
request.finalPriority = event.newPriority;
|
|
151
|
+
});
|
|
152
|
+
listen("Network.responseReceived", (event) => {
|
|
153
|
+
const request = active.get(event.requestId);
|
|
154
|
+
if (!request || disposed)
|
|
155
|
+
return;
|
|
156
|
+
request.resourceType = event.type ?? request.resourceType;
|
|
157
|
+
applyResponse(request, event.response, eventEpoch(request, event.timestamp));
|
|
158
|
+
});
|
|
159
|
+
listen("Network.loadingFinished", (event) => {
|
|
160
|
+
const request = active.get(event.requestId);
|
|
161
|
+
if (!request || disposed)
|
|
162
|
+
return;
|
|
163
|
+
request.endEpochMs = eventEpoch(request, event.timestamp);
|
|
164
|
+
request.encodedDataLength = event.encodedDataLength;
|
|
165
|
+
request.status = "finished";
|
|
166
|
+
active.delete(event.requestId);
|
|
167
|
+
});
|
|
168
|
+
listen("Network.loadingFailed", (event) => {
|
|
169
|
+
const request = active.get(event.requestId);
|
|
170
|
+
if (!request || disposed)
|
|
171
|
+
return;
|
|
172
|
+
request.endEpochMs = eventEpoch(request, event.timestamp);
|
|
173
|
+
request.status = "failed";
|
|
174
|
+
request.canceled = event.canceled ?? false;
|
|
175
|
+
request.blockedReason = event.blockedReason ?? null;
|
|
176
|
+
active.delete(event.requestId);
|
|
177
|
+
});
|
|
178
|
+
async function dispose() {
|
|
179
|
+
if (disposed)
|
|
180
|
+
return;
|
|
181
|
+
disposed = true;
|
|
182
|
+
for (const [name, callback] of listeners)
|
|
183
|
+
session.off(name, callback);
|
|
184
|
+
active.clear();
|
|
185
|
+
await session.detach().catch(() => { });
|
|
186
|
+
}
|
|
187
|
+
try {
|
|
188
|
+
await session.send("Network.enable", { maxPostDataSize: 0 });
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
await dispose();
|
|
192
|
+
throw error;
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
async finish(clickEpochMs) {
|
|
196
|
+
const serialized = requests.flatMap((request) => {
|
|
197
|
+
const contentType = request.responseHeaders?.["content-type"] ?? request.mimeType ?? "";
|
|
198
|
+
const rsc = request.requestHeaders.rsc === "1" ||
|
|
199
|
+
isFlightUrl(request.url) ||
|
|
200
|
+
contentType.toLowerCase().startsWith("text/x-component");
|
|
201
|
+
const document = request.resourceType === "Document";
|
|
202
|
+
if ((!rsc && !document) || request.requestHeaders["next-hmr-refresh"])
|
|
203
|
+
return [];
|
|
204
|
+
const startMs = request.startEpochMs - clickEpochMs;
|
|
205
|
+
if (document && startMs < -50)
|
|
206
|
+
return [];
|
|
207
|
+
const timingStartMs = Number.isFinite(request.resourceTiming?.requestTime)
|
|
208
|
+
? startMs + (request.resourceTiming.requestTime - request.monotonic) * 1000
|
|
209
|
+
: null;
|
|
210
|
+
const endMs = Number.isFinite(request.endEpochMs) ? request.endEpochMs - clickEpochMs : null;
|
|
211
|
+
const responseMs = Number.isFinite(request.responseEpochMs)
|
|
212
|
+
? request.responseEpochMs - clickEpochMs
|
|
213
|
+
: null;
|
|
214
|
+
const classification = document
|
|
215
|
+
? { role: "document", confidence: "explicit" }
|
|
216
|
+
: classifyNavigationRsc({
|
|
217
|
+
requestHeaders: request.requestHeaders,
|
|
218
|
+
startMs,
|
|
219
|
+
initialPriority: request.initialPriority,
|
|
220
|
+
});
|
|
221
|
+
return [
|
|
222
|
+
{
|
|
223
|
+
id: request.id,
|
|
224
|
+
url: sanitizeUrl(request.url),
|
|
225
|
+
role: classification.role,
|
|
226
|
+
confidence: classification.confidence,
|
|
227
|
+
segmentPath: request.requestHeaders["next-router-segment-prefetch"] ?? null,
|
|
228
|
+
prefetchKind: request.requestHeaders["next-router-prefetch"] ?? null,
|
|
229
|
+
hasRouterState: Boolean(request.requestHeaders["next-router-state-tree"]),
|
|
230
|
+
nextUrl: request.requestHeaders["next-url"]
|
|
231
|
+
? sanitizeUrl(request.requestHeaders["next-url"])
|
|
232
|
+
: null,
|
|
233
|
+
responseHeaders: request.responseHeaders ?? {},
|
|
234
|
+
matchedPath: request.responseHeaders?.["x-matched-path"] ?? null,
|
|
235
|
+
rewrittenPath: request.responseHeaders?.["x-nextjs-rewritten-path"] ?? null,
|
|
236
|
+
postponed: request.responseHeaders?.["x-nextjs-postponed"] ?? null,
|
|
237
|
+
prerender: request.responseHeaders?.["x-nextjs-prerender"] ?? null,
|
|
238
|
+
cache: request.responseHeaders?.["x-vercel-cache"] ??
|
|
239
|
+
request.responseHeaders?.["x-nextjs-cache"] ??
|
|
240
|
+
null,
|
|
241
|
+
resourceType: request.resourceType,
|
|
242
|
+
status: request.status,
|
|
243
|
+
statusCode: request.statusCode ?? null,
|
|
244
|
+
startMs,
|
|
245
|
+
responseMs,
|
|
246
|
+
endMs,
|
|
247
|
+
durationMs: endMs === null ? null : endMs - startMs,
|
|
248
|
+
timingStartMs,
|
|
249
|
+
ttfbMs: request.ttfbMs,
|
|
250
|
+
timingSource: request.source,
|
|
251
|
+
resourceTiming: request.resourceTiming,
|
|
252
|
+
protocol: request.protocol ?? null,
|
|
253
|
+
connectionId: request.connectionId ?? null,
|
|
254
|
+
connectionReused: request.connectionReused ?? null,
|
|
255
|
+
remoteIPAddress: request.remoteIPAddress ?? null,
|
|
256
|
+
encodedDataLength: request.encodedDataLength ?? null,
|
|
257
|
+
initialPriority: request.initialPriority,
|
|
258
|
+
finalPriority: request.finalPriority,
|
|
259
|
+
fromDiskCache: request.fromDiskCache ?? false,
|
|
260
|
+
fromPrefetchCache: request.fromPrefetchCache ?? false,
|
|
261
|
+
fromServiceWorker: request.fromServiceWorker ?? false,
|
|
262
|
+
canceled: request.canceled ?? null,
|
|
263
|
+
blockedReason: request.blockedReason ?? null,
|
|
264
|
+
},
|
|
265
|
+
];
|
|
266
|
+
});
|
|
267
|
+
await dispose();
|
|
268
|
+
return serialized;
|
|
269
|
+
},
|
|
270
|
+
dispose,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
export declare function inspectState(spec: any): {
|
|
2
|
+
path: any;
|
|
3
|
+
route: boolean;
|
|
4
|
+
shell: any;
|
|
5
|
+
primary: any;
|
|
6
|
+
complete: any;
|
|
7
|
+
activeMilestones: any;
|
|
8
|
+
counts: {};
|
|
9
|
+
fallbackCount: number | null;
|
|
10
|
+
indicators: any;
|
|
11
|
+
state: string;
|
|
12
|
+
};
|
|
13
|
+
export declare function waitForContent(page: any, spec: any, timeout?: number): Promise<void>;
|
|
14
|
+
export declare function summarizeNavigation(samples: any, clickEpochMs: any, observationMs?: number, milestones?: any[]): {
|
|
15
|
+
clickToFeedbackMs: any;
|
|
16
|
+
firstFeedbackReasons: any;
|
|
17
|
+
clickToUrlMs: any;
|
|
18
|
+
clickToShellMs: any;
|
|
19
|
+
clickToUsefulMs: any;
|
|
20
|
+
clickToCompleteMs: any;
|
|
21
|
+
milestoneTimings: any;
|
|
22
|
+
checkpoints: {
|
|
23
|
+
requestedMs: any;
|
|
24
|
+
lastObservedMs: any;
|
|
25
|
+
nextObservedMs: any;
|
|
26
|
+
state: any;
|
|
27
|
+
activeMilestones: any;
|
|
28
|
+
counts: any;
|
|
29
|
+
}[];
|
|
30
|
+
timeline: any[];
|
|
31
|
+
};
|
|
32
|
+
export declare function hoverBeforeClick(target: any, settings?: {
|
|
33
|
+
repeats: number;
|
|
34
|
+
viewport: {
|
|
35
|
+
width: number;
|
|
36
|
+
height: number;
|
|
37
|
+
};
|
|
38
|
+
sourceDwellMs: number;
|
|
39
|
+
preClickHoverMs: number;
|
|
40
|
+
pageObservationMs: number;
|
|
41
|
+
navigationObservationMs: number;
|
|
42
|
+
}): Promise<void>;
|
|
43
|
+
export declare function waitForClientReadyLink(target: any, timeout?: number): Promise<void>;
|
|
44
|
+
/**
|
|
45
|
+
* @param {import('@playwright/test').Page} page
|
|
46
|
+
* @param {object} spec
|
|
47
|
+
* @param {object} [options]
|
|
48
|
+
* @param {string} [options.directory]
|
|
49
|
+
* @param {number} [options.observeMs]
|
|
50
|
+
* @param {boolean} [options.requireClientReady]
|
|
51
|
+
* @param {object} [options.requestCapture]
|
|
52
|
+
* @param {object} [options.settings]
|
|
53
|
+
*/
|
|
54
|
+
export declare function measureNavigation(page: import('@playwright/test').Page, spec: object, { directory, observeMs, requireClientReady, requestCapture: suppliedRequestCapture, settings, }?: {
|
|
55
|
+
directory?: string;
|
|
56
|
+
observeMs?: number;
|
|
57
|
+
requireClientReady?: boolean;
|
|
58
|
+
requestCapture?: object;
|
|
59
|
+
settings?: object;
|
|
60
|
+
}): Promise<{
|
|
61
|
+
clickToFeedbackMs: any;
|
|
62
|
+
firstFeedbackReasons: any;
|
|
63
|
+
clickToUrlMs: any;
|
|
64
|
+
clickToShellMs: any;
|
|
65
|
+
clickToUsefulMs: any;
|
|
66
|
+
clickToCompleteMs: any;
|
|
67
|
+
milestoneTimings: any;
|
|
68
|
+
checkpoints: {
|
|
69
|
+
requestedMs: any;
|
|
70
|
+
lastObservedMs: any;
|
|
71
|
+
nextObservedMs: any;
|
|
72
|
+
state: any;
|
|
73
|
+
activeMilestones: any;
|
|
74
|
+
counts: any;
|
|
75
|
+
}[];
|
|
76
|
+
timeline: any[];
|
|
77
|
+
type: string;
|
|
78
|
+
observationMs: number;
|
|
79
|
+
endReason: any;
|
|
80
|
+
preClickHoverMs: any;
|
|
81
|
+
clientReadyWaitMs: number | null;
|
|
82
|
+
collectedAtMs: number | null;
|
|
83
|
+
finalState: any;
|
|
84
|
+
collectionErrors: string[];
|
|
85
|
+
droppedFrames: number;
|
|
86
|
+
clickEpochMs: any;
|
|
87
|
+
clickHandlerDelayMs: number;
|
|
88
|
+
finalUrl: string;
|
|
89
|
+
requests: any;
|
|
90
|
+
frames: any[];
|
|
91
|
+
}>;
|