framewatch-mcp-server 0.1.0 → 0.2.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/README.md +895 -15
- package/dist/constants.d.ts +274 -0
- package/dist/constants.js +279 -0
- package/dist/constants.js.map +1 -1
- package/dist/engine/browser.d.ts +20 -4
- package/dist/engine/browser.js +26 -9
- package/dist/engine/browser.js.map +1 -1
- package/dist/engine/clicks.d.ts +221 -0
- package/dist/engine/clicks.js +801 -0
- package/dist/engine/clicks.js.map +1 -0
- package/dist/engine/forms.d.ts +137 -0
- package/dist/engine/forms.js +474 -0
- package/dist/engine/forms.js.map +1 -0
- package/dist/engine/hmr.d.ts +41 -0
- package/dist/engine/hmr.js +91 -0
- package/dist/engine/hmr.js.map +1 -0
- package/dist/engine/inspect.d.ts +31 -0
- package/dist/engine/inspect.js +383 -0
- package/dist/engine/inspect.js.map +1 -0
- package/dist/engine/interaction.d.ts +12 -7
- package/dist/engine/interaction.js +110 -18
- package/dist/engine/interaction.js.map +1 -1
- package/dist/engine/links.d.ts +134 -0
- package/dist/engine/links.js +384 -0
- package/dist/engine/links.js.map +1 -0
- package/dist/engine/mocks.d.ts +53 -0
- package/dist/engine/mocks.js +148 -0
- package/dist/engine/mocks.js.map +1 -0
- package/dist/engine/rtl.d.ts +129 -0
- package/dist/engine/rtl.js +540 -0
- package/dist/engine/rtl.js.map +1 -0
- package/dist/engine/seo.d.ts +189 -0
- package/dist/engine/seo.js +398 -0
- package/dist/engine/seo.js.map +1 -0
- package/dist/engine/snapshot.d.ts +29 -0
- package/dist/engine/snapshot.js +10 -0
- package/dist/engine/snapshot.js.map +1 -0
- package/dist/engine/vue.d.ts +54 -0
- package/dist/engine/vue.js +419 -0
- package/dist/engine/vue.js.map +1 -0
- package/dist/index.js +45 -1
- package/dist/index.js.map +1 -1
- package/dist/tools/accessibility.d.ts +4 -0
- package/dist/tools/accessibility.js +9 -2
- package/dist/tools/accessibility.js.map +1 -1
- package/dist/tools/api-mock.d.ts +405 -0
- package/dist/tools/api-mock.js +186 -0
- package/dist/tools/api-mock.js.map +1 -0
- package/dist/tools/capture.d.ts +90 -26
- package/dist/tools/capture.js +109 -58
- package/dist/tools/capture.js.map +1 -1
- package/dist/tools/compare.d.ts +4 -0
- package/dist/tools/compare.js +16 -5
- package/dist/tools/compare.js.map +1 -1
- package/dist/tools/dead-clicks.d.ts +128 -0
- package/dist/tools/dead-clicks.js +570 -0
- package/dist/tools/dead-clicks.js.map +1 -0
- package/dist/tools/form-test.d.ts +112 -0
- package/dist/tools/form-test.js +477 -0
- package/dist/tools/form-test.js.map +1 -0
- package/dist/tools/index.d.ts +17 -1
- package/dist/tools/index.js +45 -1
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/inspect.d.ts +78 -0
- package/dist/tools/inspect.js +136 -0
- package/dist/tools/inspect.js.map +1 -0
- package/dist/tools/interact.d.ts +37 -18
- package/dist/tools/interact.js +113 -13
- package/dist/tools/interact.js.map +1 -1
- package/dist/tools/links.d.ts +129 -0
- package/dist/tools/links.js +640 -0
- package/dist/tools/links.js.map +1 -0
- package/dist/tools/responsive.d.ts +10 -6
- package/dist/tools/responsive.js +21 -4
- package/dist/tools/responsive.js.map +1 -1
- package/dist/tools/rtl.d.ts +241 -0
- package/dist/tools/rtl.js +410 -0
- package/dist/tools/rtl.js.map +1 -0
- package/dist/tools/save-auth.d.ts +263 -0
- package/dist/tools/save-auth.js +253 -0
- package/dist/tools/save-auth.js.map +1 -0
- package/dist/tools/screenshot.d.ts +4 -0
- package/dist/tools/screenshot.js +15 -4
- package/dist/tools/screenshot.js.map +1 -1
- package/dist/tools/seo.d.ts +113 -0
- package/dist/tools/seo.js +281 -0
- package/dist/tools/seo.js.map +1 -0
- package/dist/tools/snapshot.d.ts +122 -0
- package/dist/tools/snapshot.js +183 -0
- package/dist/tools/snapshot.js.map +1 -0
- package/dist/tools/wait-for.d.ts +107 -0
- package/dist/tools/wait-for.js +167 -0
- package/dist/tools/wait-for.js.map +1 -0
- package/dist/utils/arabic-text.d.ts +14 -0
- package/dist/utils/arabic-text.js +193 -0
- package/dist/utils/arabic-text.js.map +1 -0
- package/dist/utils/budget.d.ts +41 -0
- package/dist/utils/budget.js +182 -0
- package/dist/utils/budget.js.map +1 -0
- package/dist/utils/format.d.ts +11 -1
- package/dist/utils/format.js +27 -4
- package/dist/utils/format.js.map +1 -1
- package/dist/utils/highlight.d.ts +69 -0
- package/dist/utils/highlight.js +181 -0
- package/dist/utils/highlight.js.map +1 -0
- package/dist/utils/link-rules.d.ts +100 -0
- package/dist/utils/link-rules.js +284 -0
- package/dist/utils/link-rules.js.map +1 -0
- package/dist/utils/mock-rules.d.ts +144 -0
- package/dist/utils/mock-rules.js +224 -0
- package/dist/utils/mock-rules.js.map +1 -0
- package/dist/utils/rtl-rules.d.ts +142 -0
- package/dist/utils/rtl-rules.js +296 -0
- package/dist/utils/rtl-rules.js.map +1 -0
- package/dist/utils/seo-rules.d.ts +129 -0
- package/dist/utils/seo-rules.js +726 -0
- package/dist/utils/seo-rules.js.map +1 -0
- package/dist/utils/snapshot-rules.d.ts +33 -0
- package/dist/utils/snapshot-rules.js +111 -0
- package/dist/utils/snapshot-rules.js.map +1 -0
- package/dist/utils/storage-state.d.ts +76 -0
- package/dist/utils/storage-state.js +195 -0
- package/dist/utils/storage-state.js.map +1 -0
- package/dist/utils/style-rules.d.ts +107 -0
- package/dist/utils/style-rules.js +223 -0
- package/dist/utils/style-rules.js.map +1 -0
- package/dist/utils/test-data.d.ts +75 -0
- package/dist/utils/test-data.js +294 -0
- package/dist/utils/test-data.js.map +1 -0
- package/dist/utils/vue-rules.d.ts +72 -0
- package/dist/utils/vue-rules.js +108 -0
- package/dist/utils/vue-rules.js.map +1 -0
- package/package.json +6 -4
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
import { MAX_LINK_REDIRECTS, MAX_LINK_SCAN, MAX_LINK_TEXT_LENGTH } from "../constants.js";
|
|
2
|
+
/** Every element that carries an address, and the attribute it carries it in. */
|
|
3
|
+
const SOURCES = [
|
|
4
|
+
{ selector: "a[href]", attribute: "href", role: "link" },
|
|
5
|
+
{ selector: "area[href]", attribute: "href", role: "link" },
|
|
6
|
+
{ selector: "img[src]", attribute: "src", role: "resource" },
|
|
7
|
+
{ selector: "img[srcset]", attribute: "srcset", role: "resource", list: true },
|
|
8
|
+
{ selector: "source[src]", attribute: "src", role: "resource" },
|
|
9
|
+
{ selector: "source[srcset]", attribute: "srcset", role: "resource", list: true },
|
|
10
|
+
{ selector: "script[src]", attribute: "src", role: "resource" },
|
|
11
|
+
{ selector: "link[href]", attribute: "href", role: "resource" },
|
|
12
|
+
{ selector: "iframe[src]", attribute: "src", role: "resource" },
|
|
13
|
+
{ selector: "embed[src]", attribute: "src", role: "resource" },
|
|
14
|
+
{ selector: "object[data]", attribute: "data", role: "resource" },
|
|
15
|
+
{ selector: "video[src]", attribute: "src", role: "resource" },
|
|
16
|
+
{ selector: "video[poster]", attribute: "poster", role: "resource" },
|
|
17
|
+
{ selector: "audio[src]", attribute: "src", role: "resource" },
|
|
18
|
+
{ selector: "track[src]", attribute: "src", role: "resource" },
|
|
19
|
+
];
|
|
20
|
+
/**
|
|
21
|
+
* Read every address out of the rendered document, in document order.
|
|
22
|
+
*
|
|
23
|
+
* Anchors come first because they are what the tool is about; a page's own
|
|
24
|
+
* resources are collected after them so that a `max_links` cap spends itself
|
|
25
|
+
* on links before it spends itself on sprite sheets.
|
|
26
|
+
*/
|
|
27
|
+
export async function collectLinks(page, options = {}) {
|
|
28
|
+
return page.evaluate(collectInPage, {
|
|
29
|
+
root: options.selector ?? "",
|
|
30
|
+
include_resources: options.include_resources !== false,
|
|
31
|
+
sources: SOURCES.map((source) => ({ ...source, list: source.list === true })),
|
|
32
|
+
max_scan: MAX_LINK_SCAN,
|
|
33
|
+
max_text: MAX_LINK_TEXT_LENGTH,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Runs inside the page. Written against `globalThis` because this package
|
|
38
|
+
* compiles with the Node lib only, and defensively throughout because a
|
|
39
|
+
* document can be mid-teardown when this lands in it.
|
|
40
|
+
*/
|
|
41
|
+
function collectInPage(args) {
|
|
42
|
+
const g = globalThis;
|
|
43
|
+
const doc = g.document;
|
|
44
|
+
const found = [];
|
|
45
|
+
if (!doc)
|
|
46
|
+
return found;
|
|
47
|
+
const flatten = (value) => String(value ?? "")
|
|
48
|
+
.replace(/\s+/g, " ")
|
|
49
|
+
.trim();
|
|
50
|
+
const elide = (value, max) => (value.length > max ? `${value.slice(0, max)}…` : value);
|
|
51
|
+
const escape = (value) => {
|
|
52
|
+
try {
|
|
53
|
+
if (g.CSS && typeof g.CSS.escape === "function")
|
|
54
|
+
return g.CSS.escape(value);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// Fall through to the conservative test below.
|
|
58
|
+
}
|
|
59
|
+
return /^[A-Za-z][\w-]*$/.test(value) ? value : "";
|
|
60
|
+
};
|
|
61
|
+
/** A unique-enough CSS selector; `match_index` settles the rest. Mirrors engine/clicks.ts. */
|
|
62
|
+
const selectorFor = (node) => {
|
|
63
|
+
const parts = [];
|
|
64
|
+
let current = node;
|
|
65
|
+
for (let depth = 0; current && current.nodeType === 1 && depth < 6; depth++) {
|
|
66
|
+
const tag = String(current.tagName || "").toLowerCase();
|
|
67
|
+
if (tag === "body" || tag === "html") {
|
|
68
|
+
parts.unshift(tag);
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
const id = current.id ? escape(String(current.id)) : "";
|
|
72
|
+
if (id !== "") {
|
|
73
|
+
parts.unshift(`#${id}`);
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
let part = tag;
|
|
77
|
+
const parent = current.parentElement;
|
|
78
|
+
if (parent) {
|
|
79
|
+
let position = 0;
|
|
80
|
+
let total = 0;
|
|
81
|
+
for (let i = 0; i < parent.children.length; i++) {
|
|
82
|
+
const sibling = parent.children[i];
|
|
83
|
+
if (sibling.tagName !== current.tagName)
|
|
84
|
+
continue;
|
|
85
|
+
total++;
|
|
86
|
+
if (sibling === current)
|
|
87
|
+
position = total;
|
|
88
|
+
}
|
|
89
|
+
if (total > 1 && position > 0)
|
|
90
|
+
part += `:nth-of-type(${position})`;
|
|
91
|
+
}
|
|
92
|
+
parts.unshift(part);
|
|
93
|
+
current = parent;
|
|
94
|
+
}
|
|
95
|
+
return parts.join(" > ");
|
|
96
|
+
};
|
|
97
|
+
/** What the element says it is — its text, its alt, or the rel that explains it. */
|
|
98
|
+
const describe = (node, tag) => {
|
|
99
|
+
let label = "";
|
|
100
|
+
try {
|
|
101
|
+
label =
|
|
102
|
+
flatten(node.innerText) ||
|
|
103
|
+
flatten(node.textContent) ||
|
|
104
|
+
flatten(node.getAttribute("alt")) ||
|
|
105
|
+
flatten(node.getAttribute("aria-label")) ||
|
|
106
|
+
flatten(node.getAttribute("title"));
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
label = "";
|
|
110
|
+
}
|
|
111
|
+
if (label)
|
|
112
|
+
return `${tag} "${elide(label, args.max_text)}"`;
|
|
113
|
+
const rel = flatten(node.getAttribute?.("rel"));
|
|
114
|
+
return rel ? `${tag} rel="${elide(rel, args.max_text)}"` : tag;
|
|
115
|
+
};
|
|
116
|
+
/**
|
|
117
|
+
* A `srcset` is a comma-separated list of candidates, each an address
|
|
118
|
+
* followed by an optional descriptor. Every candidate is a real request the
|
|
119
|
+
* browser may make, so every one of them is a link.
|
|
120
|
+
*/
|
|
121
|
+
const candidates = (value, list) => {
|
|
122
|
+
if (!list)
|
|
123
|
+
return [value];
|
|
124
|
+
return value
|
|
125
|
+
.split(",")
|
|
126
|
+
.map((part) => part.trim().split(/\s+/)[0])
|
|
127
|
+
.filter((part) => part !== "");
|
|
128
|
+
};
|
|
129
|
+
let root = doc;
|
|
130
|
+
if (args.root) {
|
|
131
|
+
try {
|
|
132
|
+
root = doc.querySelector(args.root) ?? null;
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
root = null;
|
|
136
|
+
}
|
|
137
|
+
if (!root)
|
|
138
|
+
return found;
|
|
139
|
+
}
|
|
140
|
+
const nodes = [];
|
|
141
|
+
for (const source of args.sources) {
|
|
142
|
+
if (!args.include_resources && source.role === "resource")
|
|
143
|
+
continue;
|
|
144
|
+
let matches = [];
|
|
145
|
+
try {
|
|
146
|
+
matches = Array.prototype.slice.call(root.querySelectorAll(source.selector));
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
for (const node of matches) {
|
|
152
|
+
if (nodes.length >= args.max_scan)
|
|
153
|
+
break;
|
|
154
|
+
const tag = String(node.tagName || "").toLowerCase();
|
|
155
|
+
const value = String(node.getAttribute(source.attribute) ?? "");
|
|
156
|
+
const rel = flatten(node.getAttribute?.("rel"));
|
|
157
|
+
for (const href of candidates(value, source.list)) {
|
|
158
|
+
nodes.push({
|
|
159
|
+
node,
|
|
160
|
+
raw: {
|
|
161
|
+
href,
|
|
162
|
+
role: source.role,
|
|
163
|
+
tag,
|
|
164
|
+
attribute: source.attribute,
|
|
165
|
+
description: describe(node, tag),
|
|
166
|
+
selector: "",
|
|
167
|
+
match_index: 0,
|
|
168
|
+
...(rel ? { rel } : {}),
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
// Selectors last: each one costs a querySelectorAll to index, and only the
|
|
175
|
+
// elements that survived the scan need one.
|
|
176
|
+
for (const entry of nodes) {
|
|
177
|
+
const selector = selectorFor(entry.node);
|
|
178
|
+
entry.raw.selector = selector;
|
|
179
|
+
try {
|
|
180
|
+
const matches = doc.querySelectorAll(selector);
|
|
181
|
+
for (let i = 0; i < matches.length; i++) {
|
|
182
|
+
if (matches[i] === entry.node)
|
|
183
|
+
entry.raw.match_index = i;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
entry.raw.selector = "";
|
|
188
|
+
}
|
|
189
|
+
found.push(entry.raw);
|
|
190
|
+
}
|
|
191
|
+
return found;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Which of these fragments actually point at something.
|
|
195
|
+
*
|
|
196
|
+
* A `#pricing` that matches no element is a broken link that no amount of HTTP
|
|
197
|
+
* checking will ever find — the request succeeds, the page loads, and the
|
|
198
|
+
* visitor simply does not go where the link said. It is only answerable with
|
|
199
|
+
* the document in hand, which is the one thing a link checker without a
|
|
200
|
+
* browser does not have.
|
|
201
|
+
*
|
|
202
|
+
* Both spellings count: an `id`, and the `<a name>` that predates it and still
|
|
203
|
+
* works in every browser.
|
|
204
|
+
*/
|
|
205
|
+
export async function findFragmentTargets(page, fragments) {
|
|
206
|
+
if (fragments.length === 0)
|
|
207
|
+
return [];
|
|
208
|
+
try {
|
|
209
|
+
return await page.evaluate((wanted) => {
|
|
210
|
+
const g = globalThis;
|
|
211
|
+
const doc = g.document;
|
|
212
|
+
const present = [];
|
|
213
|
+
for (const fragment of wanted) {
|
|
214
|
+
let found = false;
|
|
215
|
+
try {
|
|
216
|
+
found = doc.getElementById(fragment) !== null;
|
|
217
|
+
if (!found) {
|
|
218
|
+
const named = doc.getElementsByName ? doc.getElementsByName(fragment) : null;
|
|
219
|
+
found = named !== null && named.length > 0;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
found = false;
|
|
224
|
+
}
|
|
225
|
+
if (found)
|
|
226
|
+
present.push(fragment);
|
|
227
|
+
}
|
|
228
|
+
return present;
|
|
229
|
+
}, [...fragments]);
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
// A page that died before this ran simply has no answer; every fragment
|
|
233
|
+
// then goes unreported rather than being reported as missing.
|
|
234
|
+
return [...fragments];
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Record what the browser makes of every request it issues.
|
|
239
|
+
*
|
|
240
|
+
* The page has already fetched its own stylesheets, scripts and images by the
|
|
241
|
+
* time anything is checked, and Chromium's answer is better than a second
|
|
242
|
+
* request would be: it is what actually happened, in the right order, with the
|
|
243
|
+
* right headers, and it costs nothing. Re-requesting them all would double the
|
|
244
|
+
* load on the site under test and could easily disagree with the browser —
|
|
245
|
+
* which is the one verdict that matters.
|
|
246
|
+
*
|
|
247
|
+
* Intermediate redirects are skipped and recorded from the far end instead, so
|
|
248
|
+
* a resource that 301s to a working URL is not reported as a bare 301. An
|
|
249
|
+
* aborted request is recorded as nothing at all: `net::ERR_ABORTED` is what a
|
|
250
|
+
* cancelled preload and a navigation away both look like, and neither is a
|
|
251
|
+
* broken link.
|
|
252
|
+
*/
|
|
253
|
+
export function watchLoads(page) {
|
|
254
|
+
const seen = new Map();
|
|
255
|
+
const chainOf = (request) => {
|
|
256
|
+
const chain = [];
|
|
257
|
+
let current = request;
|
|
258
|
+
for (let hop = 0; current && hop <= MAX_LINK_REDIRECTS; hop++) {
|
|
259
|
+
chain.unshift(String(current.url()));
|
|
260
|
+
current = typeof current.redirectedFrom === "function" ? current.redirectedFrom() : null;
|
|
261
|
+
}
|
|
262
|
+
return chain;
|
|
263
|
+
};
|
|
264
|
+
const onResponse = (response) => {
|
|
265
|
+
try {
|
|
266
|
+
const status = response.status();
|
|
267
|
+
// An intermediate hop: the response at the end of the chain records the
|
|
268
|
+
// verdict for every URL that led to it.
|
|
269
|
+
if (status >= 300 && status < 400)
|
|
270
|
+
return;
|
|
271
|
+
const chain = chainOf(response.request());
|
|
272
|
+
seen.set(chain[0], { status, chain });
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
// A response that cannot be read tells us nothing; the URL is checked
|
|
276
|
+
// over HTTP like any other.
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
const onFailed = (request) => {
|
|
280
|
+
try {
|
|
281
|
+
const failure = request.failure()?.errorText ?? "the request failed";
|
|
282
|
+
if (/ERR_ABORTED/i.test(failure))
|
|
283
|
+
return;
|
|
284
|
+
const chain = chainOf(request);
|
|
285
|
+
seen.set(chain[0], { failure, chain });
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
/* as above */
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
page.on("response", onResponse);
|
|
292
|
+
page.on("requestfailed", onFailed);
|
|
293
|
+
return {
|
|
294
|
+
get: (url) => seen.get(url),
|
|
295
|
+
detach: () => {
|
|
296
|
+
page.off("response", onResponse);
|
|
297
|
+
page.off("requestfailed", onFailed);
|
|
298
|
+
},
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Ask a server about one URL, following its redirects by hand.
|
|
303
|
+
*
|
|
304
|
+
* Redirects are followed one hop at a time (`maxRedirects: 0`) rather than
|
|
305
|
+
* letting Playwright chase them, because the chain *is* part of the finding: a
|
|
306
|
+
* link that reaches its destination through four hops works and is still worth
|
|
307
|
+
* fixing, and a link that redirects into a 404 is a different bug from a plain
|
|
308
|
+
* 404.
|
|
309
|
+
*
|
|
310
|
+
* HEAD is an optimisation, never the test. A great many servers — CDNs, WAFs,
|
|
311
|
+
* a fair number of frameworks — answer 405 or 404 to a HEAD and serve the very
|
|
312
|
+
* same URL to a GET, so anything that comes back an error is asked for again
|
|
313
|
+
* properly before it is called broken. That costs a second request only on
|
|
314
|
+
* links that were about to be reported, which is the right place to spend it.
|
|
315
|
+
*/
|
|
316
|
+
export async function checkUrl(context, url, options) {
|
|
317
|
+
const maxRedirects = options.max_redirects ?? MAX_LINK_REDIRECTS;
|
|
318
|
+
const chain = [url];
|
|
319
|
+
let current = url;
|
|
320
|
+
let method = "HEAD";
|
|
321
|
+
for (let hop = 0;; hop++) {
|
|
322
|
+
let status;
|
|
323
|
+
let headers;
|
|
324
|
+
try {
|
|
325
|
+
const head = await context.request.head(current, {
|
|
326
|
+
timeout: options.timeout_ms,
|
|
327
|
+
maxRedirects: 0,
|
|
328
|
+
failOnStatusCode: false,
|
|
329
|
+
});
|
|
330
|
+
status = head.status();
|
|
331
|
+
headers = head.headers();
|
|
332
|
+
void head.dispose().catch(() => { });
|
|
333
|
+
}
|
|
334
|
+
catch (error) {
|
|
335
|
+
return { chain, error: firstLine(error), method };
|
|
336
|
+
}
|
|
337
|
+
if (status >= 400) {
|
|
338
|
+
try {
|
|
339
|
+
const get = await context.request.get(current, {
|
|
340
|
+
timeout: options.timeout_ms,
|
|
341
|
+
maxRedirects: 0,
|
|
342
|
+
failOnStatusCode: false,
|
|
343
|
+
});
|
|
344
|
+
status = get.status();
|
|
345
|
+
headers = get.headers();
|
|
346
|
+
method = "GET";
|
|
347
|
+
void get.dispose().catch(() => { });
|
|
348
|
+
}
|
|
349
|
+
catch (error) {
|
|
350
|
+
return { chain, error: firstLine(error), method: "GET" };
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
const location = status >= 300 && status < 400 ? headers["location"] : undefined;
|
|
354
|
+
if (location === undefined || location === "") {
|
|
355
|
+
return {
|
|
356
|
+
chain,
|
|
357
|
+
status,
|
|
358
|
+
method,
|
|
359
|
+
...(headers["content-type"] ? { content_type: headers["content-type"] } : {}),
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
if (hop >= maxRedirects)
|
|
363
|
+
return { chain, status, method, hops_exceeded: true };
|
|
364
|
+
let next;
|
|
365
|
+
try {
|
|
366
|
+
next = new URL(location, current).href;
|
|
367
|
+
}
|
|
368
|
+
catch {
|
|
369
|
+
// A Location header that is not a URL is a redirect to nowhere, which is
|
|
370
|
+
// exactly what a missing one is.
|
|
371
|
+
return { chain, status, method };
|
|
372
|
+
}
|
|
373
|
+
chain.push(next);
|
|
374
|
+
// Back somewhere we have already been: the rules module reads the repeat
|
|
375
|
+
// out of the chain, so the loop is reported rather than merely stopped.
|
|
376
|
+
if (chain.indexOf(next) !== chain.length - 1)
|
|
377
|
+
return { chain, status, method };
|
|
378
|
+
current = next;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
function firstLine(error) {
|
|
382
|
+
return (error instanceof Error ? error.message : String(error)).split("\n")[0];
|
|
383
|
+
}
|
|
384
|
+
//# sourceMappingURL=links.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"links.js","sourceRoot":"","sources":["../../src/engine/links.ts"],"names":[],"mappings":"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","sourcesContent":["import type { BrowserContext, Page } from \"playwright\";\nimport { MAX_LINK_REDIRECTS, MAX_LINK_SCAN, MAX_LINK_TEXT_LENGTH } from \"../constants.js\";\n\n/**\n * Link engine.\n *\n * Three jobs, and nothing that judges: collect every address the rendered page\n * refers to, remember what the browser itself made of the ones it loaded, and\n * check the rest over HTTP. What any of that *means* is `utils/link-rules.ts`,\n * which is pure and therefore testable without a socket.\n *\n * Two decisions shape everything here.\n *\n * The page is read after it has rendered, not from its HTML source, for the\n * same reason the SEO engine does: a component framework ships an empty\n * `<div id=\"app\">` and writes every link into it afterwards, so reading the\n * source would report a client-rendered site as having no links at all.\n *\n * And the checks go out through the browser context rather than through\n * `fetch`, so they carry the same cookies, headers and proxy settings the page\n * itself was loaded with. A link behind a login is then checked as the\n * signed-in visitor sees it instead of coming back 401 for everyone.\n */\n\n/* ── What the page points at ──────────────────────────────────────────── */\n\n/** A link a person clicks, or a resource the page pulls in on its own. */\nexport type LinkRole = \"link\" | \"resource\";\n\n/** One address the rendered page refers to, exactly as it was written. */\nexport interface RawLink {\n /** The href/src as authored — relative, empty, `javascript:` and all. */\n href: string;\n role: LinkRole;\n tag: string;\n /** Which attribute carried it, so a `srcset` candidate is not mistaken for a `src`. */\n attribute: string;\n /** How the element is named in the report — `a \"Pricing\"`, `img#hero`. */\n description: string;\n /** A CSS selector for the element, recomputed each visit rather than remembered. */\n selector: string;\n /** Which match of `selector` this is, for the common case where it is not unique. */\n match_index: number;\n rel?: string;\n}\n\n/** Every element that carries an address, and the attribute it carries it in. */\nconst SOURCES: ReadonlyArray<{ selector: string; attribute: string; role: LinkRole; list?: boolean }> = [\n { selector: \"a[href]\", attribute: \"href\", role: \"link\" },\n { selector: \"area[href]\", attribute: \"href\", role: \"link\" },\n { selector: \"img[src]\", attribute: \"src\", role: \"resource\" },\n { selector: \"img[srcset]\", attribute: \"srcset\", role: \"resource\", list: true },\n { selector: \"source[src]\", attribute: \"src\", role: \"resource\" },\n { selector: \"source[srcset]\", attribute: \"srcset\", role: \"resource\", list: true },\n { selector: \"script[src]\", attribute: \"src\", role: \"resource\" },\n { selector: \"link[href]\", attribute: \"href\", role: \"resource\" },\n { selector: \"iframe[src]\", attribute: \"src\", role: \"resource\" },\n { selector: \"embed[src]\", attribute: \"src\", role: \"resource\" },\n { selector: \"object[data]\", attribute: \"data\", role: \"resource\" },\n { selector: \"video[src]\", attribute: \"src\", role: \"resource\" },\n { selector: \"video[poster]\", attribute: \"poster\", role: \"resource\" },\n { selector: \"audio[src]\", attribute: \"src\", role: \"resource\" },\n { selector: \"track[src]\", attribute: \"src\", role: \"resource\" },\n];\n\nexport interface CollectLinksOptions {\n /** Only look inside this. Defaults to the whole document. */\n selector?: string;\n /** Also collect the things the page loads for itself — images, scripts, stylesheets. */\n include_resources?: boolean;\n}\n\n/**\n * Read every address out of the rendered document, in document order.\n *\n * Anchors come first because they are what the tool is about; a page's own\n * resources are collected after them so that a `max_links` cap spends itself\n * on links before it spends itself on sprite sheets.\n */\nexport async function collectLinks(page: Page, options: CollectLinksOptions = {}): Promise<RawLink[]> {\n return page.evaluate(collectInPage, {\n root: options.selector ?? \"\",\n include_resources: options.include_resources !== false,\n sources: SOURCES.map((source) => ({ ...source, list: source.list === true })),\n max_scan: MAX_LINK_SCAN,\n max_text: MAX_LINK_TEXT_LENGTH,\n });\n}\n\ninterface CollectArgs {\n root: string;\n include_resources: boolean;\n sources: { selector: string; attribute: string; role: LinkRole; list: boolean }[];\n max_scan: number;\n max_text: number;\n}\n\n/**\n * Runs inside the page. Written against `globalThis` because this package\n * compiles with the Node lib only, and defensively throughout because a\n * document can be mid-teardown when this lands in it.\n */\nfunction collectInPage(args: CollectArgs): RawLink[] {\n const g = globalThis as any;\n const doc = g.document;\n const found: RawLink[] = [];\n if (!doc) return found;\n\n const flatten = (value: unknown): string =>\n String(value ?? \"\")\n .replace(/\\s+/g, \" \")\n .trim();\n const elide = (value: string, max: number): string => (value.length > max ? `${value.slice(0, max)}…` : value);\n\n const escape = (value: string): string => {\n try {\n if (g.CSS && typeof g.CSS.escape === \"function\") return g.CSS.escape(value);\n } catch {\n // Fall through to the conservative test below.\n }\n return /^[A-Za-z][\\w-]*$/.test(value) ? value : \"\";\n };\n\n /** A unique-enough CSS selector; `match_index` settles the rest. Mirrors engine/clicks.ts. */\n const selectorFor = (node: any): string => {\n const parts: string[] = [];\n let current = node;\n for (let depth = 0; current && current.nodeType === 1 && depth < 6; depth++) {\n const tag = String(current.tagName || \"\").toLowerCase();\n if (tag === \"body\" || tag === \"html\") {\n parts.unshift(tag);\n break;\n }\n const id = current.id ? escape(String(current.id)) : \"\";\n if (id !== \"\") {\n parts.unshift(`#${id}`);\n break;\n }\n let part = tag;\n const parent = current.parentElement;\n if (parent) {\n let position = 0;\n let total = 0;\n for (let i = 0; i < parent.children.length; i++) {\n const sibling = parent.children[i];\n if (sibling.tagName !== current.tagName) continue;\n total++;\n if (sibling === current) position = total;\n }\n if (total > 1 && position > 0) part += `:nth-of-type(${position})`;\n }\n parts.unshift(part);\n current = parent;\n }\n return parts.join(\" > \");\n };\n\n /** What the element says it is — its text, its alt, or the rel that explains it. */\n const describe = (node: any, tag: string): string => {\n let label = \"\";\n try {\n label =\n flatten(node.innerText) ||\n flatten(node.textContent) ||\n flatten(node.getAttribute(\"alt\")) ||\n flatten(node.getAttribute(\"aria-label\")) ||\n flatten(node.getAttribute(\"title\"));\n } catch {\n label = \"\";\n }\n if (label) return `${tag} \"${elide(label, args.max_text)}\"`;\n const rel = flatten(node.getAttribute?.(\"rel\"));\n return rel ? `${tag} rel=\"${elide(rel, args.max_text)}\"` : tag;\n };\n\n /**\n * A `srcset` is a comma-separated list of candidates, each an address\n * followed by an optional descriptor. Every candidate is a real request the\n * browser may make, so every one of them is a link.\n */\n const candidates = (value: string, list: boolean): string[] => {\n if (!list) return [value];\n return value\n .split(\",\")\n .map((part) => part.trim().split(/\\s+/)[0])\n .filter((part) => part !== \"\");\n };\n\n let root: any = doc;\n if (args.root) {\n try {\n root = doc.querySelector(args.root) ?? null;\n } catch {\n root = null;\n }\n if (!root) return found;\n }\n\n const nodes: { node: any; raw: RawLink }[] = [];\n for (const source of args.sources) {\n if (!args.include_resources && source.role === \"resource\") continue;\n let matches: any[] = [];\n try {\n matches = Array.prototype.slice.call(root.querySelectorAll(source.selector));\n } catch {\n continue;\n }\n for (const node of matches) {\n if (nodes.length >= args.max_scan) break;\n const tag = String(node.tagName || \"\").toLowerCase();\n const value = String(node.getAttribute(source.attribute) ?? \"\");\n const rel = flatten(node.getAttribute?.(\"rel\"));\n for (const href of candidates(value, source.list)) {\n nodes.push({\n node,\n raw: {\n href,\n role: source.role,\n tag,\n attribute: source.attribute,\n description: describe(node, tag),\n selector: \"\",\n match_index: 0,\n ...(rel ? { rel } : {}),\n },\n });\n }\n }\n }\n\n // Selectors last: each one costs a querySelectorAll to index, and only the\n // elements that survived the scan need one.\n for (const entry of nodes) {\n const selector = selectorFor(entry.node);\n entry.raw.selector = selector;\n try {\n const matches = doc.querySelectorAll(selector);\n for (let i = 0; i < matches.length; i++) {\n if (matches[i] === entry.node) entry.raw.match_index = i;\n }\n } catch {\n entry.raw.selector = \"\";\n }\n found.push(entry.raw);\n }\n\n return found;\n}\n\n/**\n * Which of these fragments actually point at something.\n *\n * A `#pricing` that matches no element is a broken link that no amount of HTTP\n * checking will ever find — the request succeeds, the page loads, and the\n * visitor simply does not go where the link said. It is only answerable with\n * the document in hand, which is the one thing a link checker without a\n * browser does not have.\n *\n * Both spellings count: an `id`, and the `<a name>` that predates it and still\n * works in every browser.\n */\nexport async function findFragmentTargets(page: Page, fragments: readonly string[]): Promise<string[]> {\n if (fragments.length === 0) return [];\n try {\n return await page.evaluate((wanted: string[]): string[] => {\n const g = globalThis as any;\n const doc = g.document;\n const present: string[] = [];\n for (const fragment of wanted) {\n let found = false;\n try {\n found = doc.getElementById(fragment) !== null;\n if (!found) {\n const named = doc.getElementsByName ? doc.getElementsByName(fragment) : null;\n found = named !== null && named.length > 0;\n }\n } catch {\n found = false;\n }\n if (found) present.push(fragment);\n }\n return present;\n }, [...fragments]);\n } catch {\n // A page that died before this ran simply has no answer; every fragment\n // then goes unreported rather than being reported as missing.\n return [...fragments];\n }\n}\n\n/* ── What the browser already knows ───────────────────────────────────── */\n\n/** What Chromium made of one address it fetched for itself. */\nexport interface ObservedLoad {\n status?: number;\n /** Chromium's own error text, e.g. `net::ERR_NAME_NOT_RESOLVED`. */\n failure?: string;\n /** Every URL in the redirect chain, first to last. */\n chain: string[];\n}\n\nexport interface LoadWatch {\n get(url: string): ObservedLoad | undefined;\n detach(): void;\n}\n\n/**\n * Record what the browser makes of every request it issues.\n *\n * The page has already fetched its own stylesheets, scripts and images by the\n * time anything is checked, and Chromium's answer is better than a second\n * request would be: it is what actually happened, in the right order, with the\n * right headers, and it costs nothing. Re-requesting them all would double the\n * load on the site under test and could easily disagree with the browser —\n * which is the one verdict that matters.\n *\n * Intermediate redirects are skipped and recorded from the far end instead, so\n * a resource that 301s to a working URL is not reported as a bare 301. An\n * aborted request is recorded as nothing at all: `net::ERR_ABORTED` is what a\n * cancelled preload and a navigation away both look like, and neither is a\n * broken link.\n */\nexport function watchLoads(page: Page): LoadWatch {\n const seen = new Map<string, ObservedLoad>();\n\n const chainOf = (request: any): string[] => {\n const chain: string[] = [];\n let current = request;\n for (let hop = 0; current && hop <= MAX_LINK_REDIRECTS; hop++) {\n chain.unshift(String(current.url()));\n current = typeof current.redirectedFrom === \"function\" ? current.redirectedFrom() : null;\n }\n return chain;\n };\n\n const onResponse = (response: any): void => {\n try {\n const status = response.status();\n // An intermediate hop: the response at the end of the chain records the\n // verdict for every URL that led to it.\n if (status >= 300 && status < 400) return;\n const chain = chainOf(response.request());\n seen.set(chain[0], { status, chain });\n } catch {\n // A response that cannot be read tells us nothing; the URL is checked\n // over HTTP like any other.\n }\n };\n\n const onFailed = (request: any): void => {\n try {\n const failure = request.failure()?.errorText ?? \"the request failed\";\n if (/ERR_ABORTED/i.test(failure)) return;\n const chain = chainOf(request);\n seen.set(chain[0], { failure, chain });\n } catch {\n /* as above */\n }\n };\n\n page.on(\"response\", onResponse);\n page.on(\"requestfailed\", onFailed);\n\n return {\n get: (url) => seen.get(url),\n detach: () => {\n page.off(\"response\", onResponse);\n page.off(\"requestfailed\", onFailed);\n },\n };\n}\n\n/* ── Checking a URL ───────────────────────────────────────────────────── */\n\n/** The outcome of one check, as the checker saw it — no judgement attached. */\nexport interface CheckOutcome {\n /**\n * Every URL requested, starting with the one that was asked for. One entry\n * means no redirect was followed; a repeat means the chain came back round.\n */\n chain: string[];\n /** The final status, when one arrived. */\n status?: number;\n /** The transport error, when the request never completed. */\n error?: string;\n /** Which verb produced the final answer. */\n method: \"HEAD\" | \"GET\";\n /** The chain was still going when `max_redirects` ran out. */\n hops_exceeded?: boolean;\n content_type?: string;\n /** This verdict came from the browser's own load rather than from a fresh request. */\n observed?: boolean;\n}\n\nexport interface CheckOptions {\n timeout_ms: number;\n max_redirects?: number;\n}\n\n/**\n * Ask a server about one URL, following its redirects by hand.\n *\n * Redirects are followed one hop at a time (`maxRedirects: 0`) rather than\n * letting Playwright chase them, because the chain *is* part of the finding: a\n * link that reaches its destination through four hops works and is still worth\n * fixing, and a link that redirects into a 404 is a different bug from a plain\n * 404.\n *\n * HEAD is an optimisation, never the test. A great many servers — CDNs, WAFs,\n * a fair number of frameworks — answer 405 or 404 to a HEAD and serve the very\n * same URL to a GET, so anything that comes back an error is asked for again\n * properly before it is called broken. That costs a second request only on\n * links that were about to be reported, which is the right place to spend it.\n */\nexport async function checkUrl(context: BrowserContext, url: string, options: CheckOptions): Promise<CheckOutcome> {\n const maxRedirects = options.max_redirects ?? MAX_LINK_REDIRECTS;\n const chain = [url];\n let current = url;\n let method: \"HEAD\" | \"GET\" = \"HEAD\";\n\n for (let hop = 0; ; hop++) {\n let status: number;\n let headers: Record<string, string>;\n\n try {\n const head = await context.request.head(current, {\n timeout: options.timeout_ms,\n maxRedirects: 0,\n failOnStatusCode: false,\n });\n status = head.status();\n headers = head.headers();\n void head.dispose().catch(() => {});\n } catch (error) {\n return { chain, error: firstLine(error), method };\n }\n\n if (status >= 400) {\n try {\n const get = await context.request.get(current, {\n timeout: options.timeout_ms,\n maxRedirects: 0,\n failOnStatusCode: false,\n });\n status = get.status();\n headers = get.headers();\n method = \"GET\";\n void get.dispose().catch(() => {});\n } catch (error) {\n return { chain, error: firstLine(error), method: \"GET\" };\n }\n }\n\n const location = status >= 300 && status < 400 ? headers[\"location\"] : undefined;\n if (location === undefined || location === \"\") {\n return {\n chain,\n status,\n method,\n ...(headers[\"content-type\"] ? { content_type: headers[\"content-type\"] } : {}),\n };\n }\n\n if (hop >= maxRedirects) return { chain, status, method, hops_exceeded: true };\n\n let next: string;\n try {\n next = new URL(location, current).href;\n } catch {\n // A Location header that is not a URL is a redirect to nowhere, which is\n // exactly what a missing one is.\n return { chain, status, method };\n }\n\n chain.push(next);\n // Back somewhere we have already been: the rules module reads the repeat\n // out of the chain, so the loop is reported rather than merely stopped.\n if (chain.indexOf(next) !== chain.length - 1) return { chain, status, method };\n current = next;\n }\n}\n\nfunction firstLine(error: unknown): string {\n return (error instanceof Error ? error.message : String(error)).split(\"\\n\")[0];\n}\n"]}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { Page } from "playwright";
|
|
2
|
+
import { type MockActivity, type ResolvedMock, type UnmatchedRequest } from "../utils/mock-rules.js";
|
|
3
|
+
/**
|
|
4
|
+
* Request interception for `framewatch_api_mock`.
|
|
5
|
+
*
|
|
6
|
+
* Installs one Playwright route per mock, answers the requests they match,
|
|
7
|
+
* and keeps the tally the report is built from: what each mock served, what it
|
|
8
|
+
* never served, and what the page asked for that no mock had an opinion about.
|
|
9
|
+
*
|
|
10
|
+
* Two rules do most of the work here:
|
|
11
|
+
*
|
|
12
|
+
* The page's own navigation is never mocked. A catch-all pattern is a
|
|
13
|
+
* perfectly reasonable thing to write, and if the main document went through
|
|
14
|
+
* the mock the page under test would be replaced by a JSON body — there would
|
|
15
|
+
* be nothing left to photograph. The document is how the page got here, not
|
|
16
|
+
* something the page asked for, so it is passed through untouched and left out
|
|
17
|
+
* of the unmatched tally as well. Sub-resources and everything an iframe or the
|
|
18
|
+
* app itself fetches are fair game.
|
|
19
|
+
*
|
|
20
|
+
* `times` is counted here rather than handed to Playwright. Playwright spends a
|
|
21
|
+
* route's remaining uses whenever the pattern matches, which would let the
|
|
22
|
+
* document request consume a `times: 1` mock before the app ever fetched
|
|
23
|
+
* anything.
|
|
24
|
+
*/
|
|
25
|
+
export interface MockRouterOptions {
|
|
26
|
+
/** The mocks in the order the caller wrote them; the first match wins. */
|
|
27
|
+
mocks: ResolvedMock[];
|
|
28
|
+
/** Abort anything no mock matched instead of letting it reach the network. */
|
|
29
|
+
block_unmatched: boolean;
|
|
30
|
+
}
|
|
31
|
+
export declare class MockRouter {
|
|
32
|
+
#private;
|
|
33
|
+
constructor(options: MockRouterOptions);
|
|
34
|
+
/**
|
|
35
|
+
* Install the routes. Call before the page navigates.
|
|
36
|
+
*
|
|
37
|
+
* The catch-all goes on first *because* Playwright checks routes
|
|
38
|
+
* last-registered-first: registering it before the mocks is what puts it
|
|
39
|
+
* behind them. The mocks themselves go on in reverse (see `installOrder`),
|
|
40
|
+
* which leaves the first one the caller wrote at the front of the queue.
|
|
41
|
+
*/
|
|
42
|
+
install(page: Page): Promise<void>;
|
|
43
|
+
/** What each mock did, in the order the caller wrote them. */
|
|
44
|
+
get activity(): MockActivity[];
|
|
45
|
+
/** Requests no mock matched, in the order the page made them. */
|
|
46
|
+
get unmatched(): UnmatchedRequest[];
|
|
47
|
+
/**
|
|
48
|
+
* Stop answering. Called once the recording is over and before the page is
|
|
49
|
+
* closed: a mock still sleeping out a long delay has nothing left to answer,
|
|
50
|
+
* and its timer would otherwise outlive the tool call.
|
|
51
|
+
*/
|
|
52
|
+
dispose(): void;
|
|
53
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { MAX_MOCK_URLS_LISTED, MAX_UNMATCHED_TRACKED } from "../constants.js";
|
|
2
|
+
import { shortenUrl } from "./layers/network.js";
|
|
3
|
+
import { installOrder, } from "../utils/mock-rules.js";
|
|
4
|
+
export class MockRouter {
|
|
5
|
+
#blockUnmatched;
|
|
6
|
+
#activity;
|
|
7
|
+
/** Unmatched requests keyed by identity, so a response can fill in its status later. */
|
|
8
|
+
#unmatched = new Map();
|
|
9
|
+
#page = null;
|
|
10
|
+
/** Sleeping delays, so a recording that ends first does not leave timers behind. */
|
|
11
|
+
#waits = new Set();
|
|
12
|
+
#disposed = false;
|
|
13
|
+
#onResponse = (response) => {
|
|
14
|
+
const record = this.#unmatched.get(response.request());
|
|
15
|
+
if (record && record.blocked !== true)
|
|
16
|
+
record.status = response.status();
|
|
17
|
+
};
|
|
18
|
+
constructor(options) {
|
|
19
|
+
this.#blockUnmatched = options.block_unmatched;
|
|
20
|
+
this.#activity = options.mocks.map((mock) => ({ mock, hits: 0, answered: 0, urls: [] }));
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Install the routes. Call before the page navigates.
|
|
24
|
+
*
|
|
25
|
+
* The catch-all goes on first *because* Playwright checks routes
|
|
26
|
+
* last-registered-first: registering it before the mocks is what puts it
|
|
27
|
+
* behind them. The mocks themselves go on in reverse (see `installOrder`),
|
|
28
|
+
* which leaves the first one the caller wrote at the front of the queue.
|
|
29
|
+
*/
|
|
30
|
+
async install(page) {
|
|
31
|
+
this.#page = page;
|
|
32
|
+
page.on("response", this.#onResponse);
|
|
33
|
+
await page.route("**/*", (route, request) => this.#handleUnmatched(route, request));
|
|
34
|
+
for (const entry of installOrder(this.#activity)) {
|
|
35
|
+
await page.route(entry.mock.url_pattern, (route, request) => this.#handleMock(entry, route, request));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** What each mock did, in the order the caller wrote them. */
|
|
39
|
+
get activity() {
|
|
40
|
+
return this.#activity;
|
|
41
|
+
}
|
|
42
|
+
/** Requests no mock matched, in the order the page made them. */
|
|
43
|
+
get unmatched() {
|
|
44
|
+
return [...this.#unmatched.values()];
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Stop answering. Called once the recording is over and before the page is
|
|
48
|
+
* closed: a mock still sleeping out a long delay has nothing left to answer,
|
|
49
|
+
* and its timer would otherwise outlive the tool call.
|
|
50
|
+
*/
|
|
51
|
+
dispose() {
|
|
52
|
+
if (this.#disposed)
|
|
53
|
+
return;
|
|
54
|
+
this.#disposed = true;
|
|
55
|
+
this.#page?.off("response", this.#onResponse);
|
|
56
|
+
for (const wait of this.#waits) {
|
|
57
|
+
clearTimeout(wait.timer);
|
|
58
|
+
wait.resolve();
|
|
59
|
+
}
|
|
60
|
+
this.#waits.clear();
|
|
61
|
+
}
|
|
62
|
+
/** One mock's turn at a request. */
|
|
63
|
+
async #handleMock(entry, route, request) {
|
|
64
|
+
const { mock } = entry;
|
|
65
|
+
// Not ours: the page's own document, or a mock that has already been spent.
|
|
66
|
+
if (this.#isMainDocument(request) || (mock.times !== undefined && entry.hits >= mock.times)) {
|
|
67
|
+
await this.#safely(() => route.fallback());
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
entry.hits++;
|
|
71
|
+
if (entry.urls.length < MAX_MOCK_URLS_LISTED)
|
|
72
|
+
entry.urls.push(shortenUrl(request.url()));
|
|
73
|
+
if (mock.kind === "abort") {
|
|
74
|
+
if (await this.#safely(() => route.abort(mock.reason)))
|
|
75
|
+
entry.answered++;
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (mock.delay_ms > 0)
|
|
79
|
+
await this.#wait(mock.delay_ms);
|
|
80
|
+
// The recording ended while this was sleeping; there is nobody left to answer.
|
|
81
|
+
if (this.#disposed)
|
|
82
|
+
return;
|
|
83
|
+
const answered = await this.#safely(() => route.fulfill({ status: mock.status, headers: mock.headers, body: mock.body }));
|
|
84
|
+
if (answered)
|
|
85
|
+
entry.answered++;
|
|
86
|
+
}
|
|
87
|
+
/** Everything no mock wanted. */
|
|
88
|
+
async #handleUnmatched(route, request) {
|
|
89
|
+
if (this.#isMainDocument(request)) {
|
|
90
|
+
await this.#safely(() => route.continue());
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (this.#unmatched.size < MAX_UNMATCHED_TRACKED) {
|
|
94
|
+
this.#unmatched.set(request, {
|
|
95
|
+
method: request.method(),
|
|
96
|
+
url: shortenUrl(request.url()),
|
|
97
|
+
...(this.#blockUnmatched ? { blocked: true } : {}),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
await this.#safely(() => (this.#blockUnmatched ? route.abort("failed") : route.continue()));
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* The page's own navigation, which is never mocked and never counted. A
|
|
104
|
+
* request whose frame has already gone cannot be the main document any more,
|
|
105
|
+
* so a throw here means "no".
|
|
106
|
+
*/
|
|
107
|
+
#isMainDocument(request) {
|
|
108
|
+
try {
|
|
109
|
+
return request.resourceType() === "document" && request.frame() === this.#page?.mainFrame();
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/** A delay that a `dispose()` can cut short. */
|
|
116
|
+
#wait(ms) {
|
|
117
|
+
if (this.#disposed)
|
|
118
|
+
return Promise.resolve();
|
|
119
|
+
return new Promise((resolve) => {
|
|
120
|
+
const wait = {
|
|
121
|
+
timer: setTimeout(() => {
|
|
122
|
+
this.#waits.delete(wait);
|
|
123
|
+
resolve();
|
|
124
|
+
}, ms),
|
|
125
|
+
resolve,
|
|
126
|
+
};
|
|
127
|
+
this.#waits.add(wait);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Run one route call, swallowing the failure.
|
|
132
|
+
*
|
|
133
|
+
* A route whose page has navigated away, closed or crashed rejects, and none
|
|
134
|
+
* of that is worth failing a recording over — the frames are the point. It
|
|
135
|
+
* also has to be caught rather than left floating: an unhandled rejection
|
|
136
|
+
* from a route handler would surface as a crash somewhere unrelated.
|
|
137
|
+
*/
|
|
138
|
+
async #safely(run) {
|
|
139
|
+
try {
|
|
140
|
+
await run();
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
//# sourceMappingURL=mocks.js.map
|