framewatch-mcp-server 0.1.1 → 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.
Files changed (127) hide show
  1. package/README.md +797 -8
  2. package/dist/constants.d.ts +264 -0
  3. package/dist/constants.js +268 -0
  4. package/dist/constants.js.map +1 -1
  5. package/dist/engine/browser.js +4 -0
  6. package/dist/engine/browser.js.map +1 -1
  7. package/dist/engine/clicks.d.ts +221 -0
  8. package/dist/engine/clicks.js +801 -0
  9. package/dist/engine/clicks.js.map +1 -0
  10. package/dist/engine/forms.d.ts +137 -0
  11. package/dist/engine/forms.js +474 -0
  12. package/dist/engine/forms.js.map +1 -0
  13. package/dist/engine/hmr.d.ts +41 -0
  14. package/dist/engine/hmr.js +91 -0
  15. package/dist/engine/hmr.js.map +1 -0
  16. package/dist/engine/inspect.d.ts +31 -0
  17. package/dist/engine/inspect.js +383 -0
  18. package/dist/engine/inspect.js.map +1 -0
  19. package/dist/engine/interaction.d.ts +6 -1
  20. package/dist/engine/interaction.js +21 -6
  21. package/dist/engine/interaction.js.map +1 -1
  22. package/dist/engine/links.d.ts +134 -0
  23. package/dist/engine/links.js +384 -0
  24. package/dist/engine/links.js.map +1 -0
  25. package/dist/engine/mocks.d.ts +53 -0
  26. package/dist/engine/mocks.js +148 -0
  27. package/dist/engine/mocks.js.map +1 -0
  28. package/dist/engine/rtl.d.ts +129 -0
  29. package/dist/engine/rtl.js +540 -0
  30. package/dist/engine/rtl.js.map +1 -0
  31. package/dist/engine/seo.d.ts +189 -0
  32. package/dist/engine/seo.js +398 -0
  33. package/dist/engine/seo.js.map +1 -0
  34. package/dist/engine/snapshot.d.ts +29 -0
  35. package/dist/engine/snapshot.js +10 -0
  36. package/dist/engine/snapshot.js.map +1 -0
  37. package/dist/engine/vue.d.ts +54 -0
  38. package/dist/engine/vue.js +419 -0
  39. package/dist/engine/vue.js.map +1 -0
  40. package/dist/index.js +42 -1
  41. package/dist/index.js.map +1 -1
  42. package/dist/tools/accessibility.js +4 -3
  43. package/dist/tools/accessibility.js.map +1 -1
  44. package/dist/tools/api-mock.d.ts +405 -0
  45. package/dist/tools/api-mock.js +186 -0
  46. package/dist/tools/api-mock.js.map +1 -0
  47. package/dist/tools/capture.d.ts +83 -23
  48. package/dist/tools/capture.js +105 -60
  49. package/dist/tools/capture.js.map +1 -1
  50. package/dist/tools/compare.js +4 -3
  51. package/dist/tools/compare.js.map +1 -1
  52. package/dist/tools/dead-clicks.d.ts +128 -0
  53. package/dist/tools/dead-clicks.js +570 -0
  54. package/dist/tools/dead-clicks.js.map +1 -0
  55. package/dist/tools/form-test.d.ts +112 -0
  56. package/dist/tools/form-test.js +477 -0
  57. package/dist/tools/form-test.js.map +1 -0
  58. package/dist/tools/index.d.ts +16 -1
  59. package/dist/tools/index.js +42 -1
  60. package/dist/tools/index.js.map +1 -1
  61. package/dist/tools/inspect.d.ts +78 -0
  62. package/dist/tools/inspect.js +136 -0
  63. package/dist/tools/inspect.js.map +1 -0
  64. package/dist/tools/interact.d.ts +29 -16
  65. package/dist/tools/interact.js +99 -13
  66. package/dist/tools/interact.js.map +1 -1
  67. package/dist/tools/links.d.ts +129 -0
  68. package/dist/tools/links.js +640 -0
  69. package/dist/tools/links.js.map +1 -0
  70. package/dist/tools/responsive.d.ts +6 -6
  71. package/dist/tools/responsive.js +9 -9
  72. package/dist/tools/responsive.js.map +1 -1
  73. package/dist/tools/rtl.d.ts +241 -0
  74. package/dist/tools/rtl.js +410 -0
  75. package/dist/tools/rtl.js.map +1 -0
  76. package/dist/tools/save-auth.d.ts +22 -22
  77. package/dist/tools/screenshot.js +13 -5
  78. package/dist/tools/screenshot.js.map +1 -1
  79. package/dist/tools/seo.d.ts +113 -0
  80. package/dist/tools/seo.js +281 -0
  81. package/dist/tools/seo.js.map +1 -0
  82. package/dist/tools/snapshot.d.ts +122 -0
  83. package/dist/tools/snapshot.js +183 -0
  84. package/dist/tools/snapshot.js.map +1 -0
  85. package/dist/tools/wait-for.d.ts +107 -0
  86. package/dist/tools/wait-for.js +167 -0
  87. package/dist/tools/wait-for.js.map +1 -0
  88. package/dist/utils/arabic-text.d.ts +14 -0
  89. package/dist/utils/arabic-text.js +193 -0
  90. package/dist/utils/arabic-text.js.map +1 -0
  91. package/dist/utils/budget.d.ts +41 -0
  92. package/dist/utils/budget.js +182 -0
  93. package/dist/utils/budget.js.map +1 -0
  94. package/dist/utils/format.d.ts +11 -1
  95. package/dist/utils/format.js +27 -4
  96. package/dist/utils/format.js.map +1 -1
  97. package/dist/utils/highlight.d.ts +69 -0
  98. package/dist/utils/highlight.js +181 -0
  99. package/dist/utils/highlight.js.map +1 -0
  100. package/dist/utils/link-rules.d.ts +100 -0
  101. package/dist/utils/link-rules.js +284 -0
  102. package/dist/utils/link-rules.js.map +1 -0
  103. package/dist/utils/mock-rules.d.ts +144 -0
  104. package/dist/utils/mock-rules.js +224 -0
  105. package/dist/utils/mock-rules.js.map +1 -0
  106. package/dist/utils/rtl-rules.d.ts +142 -0
  107. package/dist/utils/rtl-rules.js +296 -0
  108. package/dist/utils/rtl-rules.js.map +1 -0
  109. package/dist/utils/seo-rules.d.ts +129 -0
  110. package/dist/utils/seo-rules.js +726 -0
  111. package/dist/utils/seo-rules.js.map +1 -0
  112. package/dist/utils/snapshot-rules.d.ts +33 -0
  113. package/dist/utils/snapshot-rules.js +111 -0
  114. package/dist/utils/snapshot-rules.js.map +1 -0
  115. package/dist/utils/storage-state.d.ts +42 -1
  116. package/dist/utils/storage-state.js +115 -4
  117. package/dist/utils/storage-state.js.map +1 -1
  118. package/dist/utils/style-rules.d.ts +107 -0
  119. package/dist/utils/style-rules.js +223 -0
  120. package/dist/utils/style-rules.js.map +1 -0
  121. package/dist/utils/test-data.d.ts +75 -0
  122. package/dist/utils/test-data.js +294 -0
  123. package/dist/utils/test-data.js.map +1 -0
  124. package/dist/utils/vue-rules.d.ts +72 -0
  125. package/dist/utils/vue-rules.js +108 -0
  126. package/dist/utils/vue-rules.js.map +1 -0
  127. package/package.json +6 -4
@@ -0,0 +1,100 @@
1
+ import type { CheckOutcome } from "../engine/links.js";
2
+ /**
3
+ * What counts as a broken link.
4
+ *
5
+ * Everything here is pure: it takes an href and a base, or the outcome of one
6
+ * HTTP check, and returns a verdict. Nothing in this file opens a browser or a
7
+ * socket, which is the point — "is this href even a request?", "did that chain
8
+ * of redirects end somewhere real?" and "is a 403 a broken link?" are all
9
+ * judgements with edge cases, and judgements need unit tests that run in
10
+ * milliseconds.
11
+ *
12
+ * The bias throughout is against false alarms. A report that calls a working
13
+ * link broken is worse than one that says nothing: it sends somebody to look
14
+ * at a page that is fine, and the next real finding gets ignored. So a status
15
+ * that means "I would not answer that" is separated from one that means "there
16
+ * is nothing here", and anything unrecognised is quoted rather than judged.
17
+ */
18
+ /**
19
+ * What kind of thing an href is.
20
+ *
21
+ * Only `http` is a request. `same_page` is answered by the DOM, the scheme
22
+ * kinds are handed to something that is not a browser, and `malformed` never
23
+ * leaves the page at all.
24
+ */
25
+ export type HrefKind = "http" | "same_page" | "empty" | "mailto" | "tel" | "javascript" | "data" | "scheme" | "malformed";
26
+ export interface ClassifiedHref {
27
+ kind: HrefKind;
28
+ /** The absolute URL, for `http` and `same_page`. */
29
+ resolved?: string;
30
+ /** The `#…` part, decoded and without its hash, when there is one. */
31
+ fragment?: string;
32
+ /** The scheme, for `scheme` — `ftp`, `chrome-extension`, whatever the page used. */
33
+ scheme?: string;
34
+ /** Why this one cannot be checked, or is suspect. */
35
+ reason?: string;
36
+ }
37
+ /**
38
+ * Work out what an href points at, resolved against the page it was found on.
39
+ *
40
+ * Order matters. An empty href and a bare `#` both resolve to the current
41
+ * document, so they are recognised from the raw text before the URL parser
42
+ * gets a chance to turn them into the page's own address and hide what the
43
+ * author actually wrote — and `<a href="#">` is the single most common dead
44
+ * link there is.
45
+ */
46
+ export declare function classifyHref(rawHref: string, base: string): ClassifiedHref;
47
+ /**
48
+ * The identity of a request.
49
+ *
50
+ * The fragment is dropped: it never reaches the server, so `/help#returns` and
51
+ * `/help#shipping` are one request and must not be checked twice. Everything
52
+ * else is kept, including the trailing slash — `/a` and `/a/` are different
53
+ * URLs and routinely redirect differently, so collapsing them would hide a
54
+ * redirect the tool exists to report.
55
+ */
56
+ export declare function dedupeKey(url: string): string;
57
+ /** Same scheme, host and port — the only definition a crawl can safely act on. */
58
+ export declare function isInternal(url: string, origin: string): boolean;
59
+ /**
60
+ * The two fragments every browser resolves without an element to scroll to:
61
+ * an empty one (the top of the document, which is what `href="#"` means) and
62
+ * `#top`, which HTML defines as the same thing.
63
+ */
64
+ export declare function isAlwaysValidFragment(fragment: string): boolean;
65
+ /** Where a checked link ended up. */
66
+ export type LinkCategory = "ok" | "redirect" | "broken" | "blocked" | "timeout" | "error";
67
+ export interface LinkVerdict {
68
+ category: LinkCategory;
69
+ /** What happened, in one phrase: "404 Not Found", "redirected to … (2 hops)". */
70
+ detail: string;
71
+ /** What to do about it. Only where there is something to do. */
72
+ fix?: string;
73
+ /** Redirects followed. */
74
+ hops: number;
75
+ /** Where the chain actually ended, when that is not where it started. */
76
+ final_url?: string;
77
+ }
78
+ /**
79
+ * Decide what one check means.
80
+ *
81
+ * The order is the order the failures shadow each other: a loop makes the
82
+ * final status meaningless, a chain that ran out of hops never got one, a
83
+ * transport error means there is no status at all, and only then is the status
84
+ * itself worth reading.
85
+ */
86
+ export declare function judgeCheck(outcome: CheckOutcome): LinkVerdict;
87
+ /**
88
+ * Turn a transport failure into something a reader can act on.
89
+ *
90
+ * These are the five that a link check actually hits. Anything else is quoted
91
+ * verbatim rather than guessed at — an error nobody predicted is still useful,
92
+ * and a wrong paraphrase of it is not.
93
+ */
94
+ export declare function classifyFailure(message: string): {
95
+ category: LinkCategory;
96
+ detail: string;
97
+ fix?: string;
98
+ };
99
+ /** The registered name of a status code, or "" for the ones nobody standardised. */
100
+ export declare function statusText(status: number): string;
@@ -0,0 +1,284 @@
1
+ import { STATUS_CODES } from "node:http";
2
+ import { MAX_LINK_REDIRECTS } from "../constants.js";
3
+ /**
4
+ * Work out what an href points at, resolved against the page it was found on.
5
+ *
6
+ * Order matters. An empty href and a bare `#` both resolve to the current
7
+ * document, so they are recognised from the raw text before the URL parser
8
+ * gets a chance to turn them into the page's own address and hide what the
9
+ * author actually wrote — and `<a href="#">` is the single most common dead
10
+ * link there is.
11
+ */
12
+ export function classifyHref(rawHref, base) {
13
+ const href = String(rawHref ?? "").trim();
14
+ // Per RFC 3986 an empty reference is the current document. Browsers reload
15
+ // the page; it is almost never what the author meant.
16
+ if (href === "") {
17
+ return { kind: "empty", reason: "an empty href reloads the current page" };
18
+ }
19
+ // A fragment-only reference never leaves the document, whatever the base is.
20
+ if (href.startsWith("#")) {
21
+ return { kind: "same_page", fragment: decodeFragment(href.slice(1)) };
22
+ }
23
+ let url;
24
+ try {
25
+ url = new URL(href, base);
26
+ }
27
+ catch {
28
+ return { kind: "malformed", reason: `"${href}" is not a URL a browser can resolve` };
29
+ }
30
+ const scheme = url.protocol.replace(/:$/, "").toLowerCase();
31
+ switch (scheme) {
32
+ case "http":
33
+ case "https":
34
+ break;
35
+ case "mailto":
36
+ return {
37
+ kind: "mailto",
38
+ ...(url.pathname.trim() === "" ? { reason: "a mailto: with no address after it" } : {}),
39
+ };
40
+ case "tel":
41
+ return {
42
+ kind: "tel",
43
+ ...(url.pathname.trim() === "" ? { reason: "a tel: with no number after it" } : {}),
44
+ };
45
+ case "javascript":
46
+ return { kind: "javascript", reason: "runs a script instead of going anywhere" };
47
+ case "data":
48
+ return { kind: "data" };
49
+ default:
50
+ return { kind: "scheme", scheme, reason: `${scheme}: is handed to the operating system, not fetched` };
51
+ }
52
+ const fragment = url.hash ? decodeFragment(url.hash.slice(1)) : undefined;
53
+ const kind = dedupeKey(url.href) === dedupeKey(base) ? "same_page" : "http";
54
+ return {
55
+ kind,
56
+ resolved: url.href,
57
+ ...(fragment !== undefined ? { fragment } : {}),
58
+ };
59
+ }
60
+ /** `%C3%A9` in an href is `é` in an id — browsers match the decoded form. */
61
+ function decodeFragment(fragment) {
62
+ try {
63
+ return decodeURIComponent(fragment);
64
+ }
65
+ catch {
66
+ return fragment;
67
+ }
68
+ }
69
+ /**
70
+ * The identity of a request.
71
+ *
72
+ * The fragment is dropped: it never reaches the server, so `/help#returns` and
73
+ * `/help#shipping` are one request and must not be checked twice. Everything
74
+ * else is kept, including the trailing slash — `/a` and `/a/` are different
75
+ * URLs and routinely redirect differently, so collapsing them would hide a
76
+ * redirect the tool exists to report.
77
+ */
78
+ export function dedupeKey(url) {
79
+ try {
80
+ const parsed = new URL(url);
81
+ parsed.hash = "";
82
+ return parsed.href;
83
+ }
84
+ catch {
85
+ return url;
86
+ }
87
+ }
88
+ /** Same scheme, host and port — the only definition a crawl can safely act on. */
89
+ export function isInternal(url, origin) {
90
+ try {
91
+ return new URL(url).origin === new URL(origin).origin;
92
+ }
93
+ catch {
94
+ return false;
95
+ }
96
+ }
97
+ /**
98
+ * The two fragments every browser resolves without an element to scroll to:
99
+ * an empty one (the top of the document, which is what `href="#"` means) and
100
+ * `#top`, which HTML defines as the same thing.
101
+ */
102
+ export function isAlwaysValidFragment(fragment) {
103
+ return fragment === "" || fragment.toLowerCase() === "top";
104
+ }
105
+ /**
106
+ * Statuses that mean "I will not answer that", not "there is nothing here".
107
+ *
108
+ * A link behind a login answers 401, a WAF answers 403 to anything without a
109
+ * browser's fingerprint, a rate limiter answers 429, and LinkedIn answers 999
110
+ * to automated checks. Every one of those links works perfectly for a person
111
+ * with a browser, and reporting them as broken is how a link report gets
112
+ * ignored. 405 is here for the same reason from the other direction: the
113
+ * server refused the *method*, so the check failed, not the link.
114
+ */
115
+ const REFUSED = {
116
+ 401: "the server wants credentials before it will answer — the link is likely fine for a signed-in visitor",
117
+ 403: "the server refused the check (bot protection usually answers this) — the link may well work in a browser",
118
+ 405: "the server refused the request method",
119
+ 429: "the check was rate-limited, not the link broken",
120
+ 999: "a non-standard status some sites return to automated checks — not a broken link",
121
+ };
122
+ /**
123
+ * Decide what one check means.
124
+ *
125
+ * The order is the order the failures shadow each other: a loop makes the
126
+ * final status meaningless, a chain that ran out of hops never got one, a
127
+ * transport error means there is no status at all, and only then is the status
128
+ * itself worth reading.
129
+ */
130
+ export function judgeCheck(outcome) {
131
+ const chain = outcome.chain.length > 0 ? outcome.chain : [""];
132
+ const hops = chain.length - 1;
133
+ const final = chain[chain.length - 1];
134
+ const landed = hops > 0 ? { final_url: final } : {};
135
+ const repeat = firstRepeat(chain);
136
+ if (repeat !== undefined) {
137
+ return {
138
+ category: "error",
139
+ detail: `the redirects loop — ${repeat} is visited twice`,
140
+ fix: "Follow the chain by hand: a redirect loop is a page nobody can reach.",
141
+ hops,
142
+ ...landed,
143
+ };
144
+ }
145
+ if (outcome.hops_exceeded) {
146
+ return {
147
+ category: "error",
148
+ detail: `still redirecting after ${MAX_LINK_REDIRECTS} redirects, last at ${final}`,
149
+ fix: "A chain this long is a configuration bug — most crawlers give up after five.",
150
+ hops,
151
+ ...landed,
152
+ };
153
+ }
154
+ if (outcome.error !== undefined) {
155
+ const failure = classifyFailure(outcome.error);
156
+ return { ...failure, hops, ...landed };
157
+ }
158
+ const status = outcome.status;
159
+ if (status === undefined) {
160
+ return { category: "error", detail: "the check produced no answer at all", hops, ...landed };
161
+ }
162
+ const named = label(status);
163
+ // A 3xx that is still a 3xx after the chain was followed had nowhere to go.
164
+ if (status >= 300 && status < 400) {
165
+ return {
166
+ category: "broken",
167
+ detail: `${named} — a redirect with no Location header to follow`,
168
+ fix: "Give the redirect a Location, or serve the page directly.",
169
+ hops,
170
+ ...landed,
171
+ };
172
+ }
173
+ if (status >= 400) {
174
+ const refused = REFUSED[status];
175
+ if (refused !== undefined) {
176
+ const method = status === 405 ? ` (${outcome.method} was refused too)` : "";
177
+ return {
178
+ category: "blocked",
179
+ detail: `${named} — ${refused}${method}`,
180
+ ...(status === 429
181
+ ? { fix: "Lower `concurrency` or raise `timeout_ms` and check this one again." }
182
+ : {}),
183
+ hops,
184
+ ...landed,
185
+ };
186
+ }
187
+ return {
188
+ category: "broken",
189
+ detail: hops > 0
190
+ ? `${named} — after ${plural(hops, "redirect")}, ending at ${final}`
191
+ : named,
192
+ fix: status === 404
193
+ ? "Point the link somewhere that exists, or restore the page."
194
+ : "This link answers an error to every visitor.",
195
+ hops,
196
+ ...landed,
197
+ };
198
+ }
199
+ if (hops === 0) {
200
+ return { category: "ok", detail: named, hops };
201
+ }
202
+ const upgraded = isUpgrade(chain);
203
+ return {
204
+ category: "redirect",
205
+ detail: `redirected to ${final} (${plural(hops, "hop")}), which answered ${named}`,
206
+ ...(hops > 1
207
+ ? { fix: "Link straight to the final address — every hop in the chain is another round trip." }
208
+ : upgraded
209
+ ? { fix: "Write the link as https:// — the http:// form costs an extra round trip on every visit." }
210
+ : {}),
211
+ hops,
212
+ final_url: final,
213
+ };
214
+ }
215
+ /** Did this chain exist only to swap http for https? */
216
+ function isUpgrade(chain) {
217
+ try {
218
+ return new URL(chain[0]).protocol === "http:" && new URL(chain[chain.length - 1]).protocol === "https:";
219
+ }
220
+ catch {
221
+ return false;
222
+ }
223
+ }
224
+ /** The first URL that appears twice, which is a loop however long the chain is. */
225
+ function firstRepeat(chain) {
226
+ const seen = new Set();
227
+ for (const url of chain) {
228
+ if (seen.has(url))
229
+ return url;
230
+ seen.add(url);
231
+ }
232
+ return undefined;
233
+ }
234
+ /**
235
+ * Turn a transport failure into something a reader can act on.
236
+ *
237
+ * These are the five that a link check actually hits. Anything else is quoted
238
+ * verbatim rather than guessed at — an error nobody predicted is still useful,
239
+ * and a wrong paraphrase of it is not.
240
+ */
241
+ export function classifyFailure(message) {
242
+ const line = String(message).split("\n")[0];
243
+ if (/Timeout\s+\d+\s*ms\s+exceeded|timed?\s?out|ETIMEDOUT/i.test(line)) {
244
+ return {
245
+ category: "timeout",
246
+ detail: "no answer within the timeout",
247
+ fix: "Raise `timeout_ms` if the host is simply slow; a link nobody's browser will wait for is broken in practice.",
248
+ };
249
+ }
250
+ if (/ENOTFOUND|EAI_AGAIN|getaddrinfo|NAME_NOT_RESOLVED|ERR_NAME/i.test(line)) {
251
+ return {
252
+ category: "error",
253
+ detail: "the host name does not resolve",
254
+ fix: "Check the domain for a typo, and that it has not expired.",
255
+ };
256
+ }
257
+ if (/ECONNREFUSED|CONNECTION_REFUSED/i.test(line)) {
258
+ return { category: "error", detail: "nothing is listening on that address — the connection was refused" };
259
+ }
260
+ if (/ECONNRESET|socket hang up|EPIPE/i.test(line)) {
261
+ return { category: "error", detail: "the connection was closed before an answer arrived (socket hang up)" };
262
+ }
263
+ if (/certificate|CERT_|ERR_CERT|SSL|TLS|self-signed/i.test(line)) {
264
+ return {
265
+ category: "error",
266
+ detail: "the TLS certificate was rejected",
267
+ fix: "A browser shows an interstitial for this — to a visitor it is a broken link.",
268
+ };
269
+ }
270
+ return { category: "error", detail: line };
271
+ }
272
+ /** The registered name of a status code, or "" for the ones nobody standardised. */
273
+ export function statusText(status) {
274
+ return STATUS_CODES[status] ?? "";
275
+ }
276
+ /** `404 Not Found`, or just `999` where inventing a name would be worse than having none. */
277
+ function label(status) {
278
+ const name = statusText(status);
279
+ return name === "" ? String(status) : `${status} ${name}`;
280
+ }
281
+ function plural(n, noun) {
282
+ return `${n} ${noun}${n === 1 ? "" : "s"}`;
283
+ }
284
+ //# sourceMappingURL=link-rules.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"link-rules.js","sourceRoot":"","sources":["../../src/utils/link-rules.ts"],"names":[],"mappings":"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","sourcesContent":["import { STATUS_CODES } from \"node:http\";\nimport { MAX_LINK_REDIRECTS } from \"../constants.js\";\nimport type { CheckOutcome } from \"../engine/links.js\";\n\n/**\n * What counts as a broken link.\n *\n * Everything here is pure: it takes an href and a base, or the outcome of one\n * HTTP check, and returns a verdict. Nothing in this file opens a browser or a\n * socket, which is the point — \"is this href even a request?\", \"did that chain\n * of redirects end somewhere real?\" and \"is a 403 a broken link?\" are all\n * judgements with edge cases, and judgements need unit tests that run in\n * milliseconds.\n *\n * The bias throughout is against false alarms. A report that calls a working\n * link broken is worse than one that says nothing: it sends somebody to look\n * at a page that is fine, and the next real finding gets ignored. So a status\n * that means \"I would not answer that\" is separated from one that means \"there\n * is nothing here\", and anything unrecognised is quoted rather than judged.\n */\n\n/* ── Classifying an href ──────────────────────────────────────────────── */\n\n/**\n * What kind of thing an href is.\n *\n * Only `http` is a request. `same_page` is answered by the DOM, the scheme\n * kinds are handed to something that is not a browser, and `malformed` never\n * leaves the page at all.\n */\nexport type HrefKind =\n | \"http\"\n | \"same_page\"\n | \"empty\"\n | \"mailto\"\n | \"tel\"\n | \"javascript\"\n | \"data\"\n | \"scheme\"\n | \"malformed\";\n\nexport interface ClassifiedHref {\n kind: HrefKind;\n /** The absolute URL, for `http` and `same_page`. */\n resolved?: string;\n /** The `#…` part, decoded and without its hash, when there is one. */\n fragment?: string;\n /** The scheme, for `scheme` — `ftp`, `chrome-extension`, whatever the page used. */\n scheme?: string;\n /** Why this one cannot be checked, or is suspect. */\n reason?: string;\n}\n\n/**\n * Work out what an href points at, resolved against the page it was found on.\n *\n * Order matters. An empty href and a bare `#` both resolve to the current\n * document, so they are recognised from the raw text before the URL parser\n * gets a chance to turn them into the page's own address and hide what the\n * author actually wrote — and `<a href=\"#\">` is the single most common dead\n * link there is.\n */\nexport function classifyHref(rawHref: string, base: string): ClassifiedHref {\n const href = String(rawHref ?? \"\").trim();\n\n // Per RFC 3986 an empty reference is the current document. Browsers reload\n // the page; it is almost never what the author meant.\n if (href === \"\") {\n return { kind: \"empty\", reason: \"an empty href reloads the current page\" };\n }\n\n // A fragment-only reference never leaves the document, whatever the base is.\n if (href.startsWith(\"#\")) {\n return { kind: \"same_page\", fragment: decodeFragment(href.slice(1)) };\n }\n\n let url: URL;\n try {\n url = new URL(href, base);\n } catch {\n return { kind: \"malformed\", reason: `\"${href}\" is not a URL a browser can resolve` };\n }\n\n const scheme = url.protocol.replace(/:$/, \"\").toLowerCase();\n switch (scheme) {\n case \"http\":\n case \"https\":\n break;\n case \"mailto\":\n return {\n kind: \"mailto\",\n ...(url.pathname.trim() === \"\" ? { reason: \"a mailto: with no address after it\" } : {}),\n };\n case \"tel\":\n return {\n kind: \"tel\",\n ...(url.pathname.trim() === \"\" ? { reason: \"a tel: with no number after it\" } : {}),\n };\n case \"javascript\":\n return { kind: \"javascript\", reason: \"runs a script instead of going anywhere\" };\n case \"data\":\n return { kind: \"data\" };\n default:\n return { kind: \"scheme\", scheme, reason: `${scheme}: is handed to the operating system, not fetched` };\n }\n\n const fragment = url.hash ? decodeFragment(url.hash.slice(1)) : undefined;\n const kind: HrefKind = dedupeKey(url.href) === dedupeKey(base) ? \"same_page\" : \"http\";\n return {\n kind,\n resolved: url.href,\n ...(fragment !== undefined ? { fragment } : {}),\n };\n}\n\n/** `%C3%A9` in an href is `é` in an id — browsers match the decoded form. */\nfunction decodeFragment(fragment: string): string {\n try {\n return decodeURIComponent(fragment);\n } catch {\n return fragment;\n }\n}\n\n/**\n * The identity of a request.\n *\n * The fragment is dropped: it never reaches the server, so `/help#returns` and\n * `/help#shipping` are one request and must not be checked twice. Everything\n * else is kept, including the trailing slash — `/a` and `/a/` are different\n * URLs and routinely redirect differently, so collapsing them would hide a\n * redirect the tool exists to report.\n */\nexport function dedupeKey(url: string): string {\n try {\n const parsed = new URL(url);\n parsed.hash = \"\";\n return parsed.href;\n } catch {\n return url;\n }\n}\n\n/** Same scheme, host and port — the only definition a crawl can safely act on. */\nexport function isInternal(url: string, origin: string): boolean {\n try {\n return new URL(url).origin === new URL(origin).origin;\n } catch {\n return false;\n }\n}\n\n/**\n * The two fragments every browser resolves without an element to scroll to:\n * an empty one (the top of the document, which is what `href=\"#\"` means) and\n * `#top`, which HTML defines as the same thing.\n */\nexport function isAlwaysValidFragment(fragment: string): boolean {\n return fragment === \"\" || fragment.toLowerCase() === \"top\";\n}\n\n/* ── Judging the answer ───────────────────────────────────────────────── */\n\n/** Where a checked link ended up. */\nexport type LinkCategory = \"ok\" | \"redirect\" | \"broken\" | \"blocked\" | \"timeout\" | \"error\";\n\nexport interface LinkVerdict {\n category: LinkCategory;\n /** What happened, in one phrase: \"404 Not Found\", \"redirected to … (2 hops)\". */\n detail: string;\n /** What to do about it. Only where there is something to do. */\n fix?: string;\n /** Redirects followed. */\n hops: number;\n /** Where the chain actually ended, when that is not where it started. */\n final_url?: string;\n}\n\n/**\n * Statuses that mean \"I will not answer that\", not \"there is nothing here\".\n *\n * A link behind a login answers 401, a WAF answers 403 to anything without a\n * browser's fingerprint, a rate limiter answers 429, and LinkedIn answers 999\n * to automated checks. Every one of those links works perfectly for a person\n * with a browser, and reporting them as broken is how a link report gets\n * ignored. 405 is here for the same reason from the other direction: the\n * server refused the *method*, so the check failed, not the link.\n */\nconst REFUSED: Readonly<Record<number, string>> = {\n 401: \"the server wants credentials before it will answer — the link is likely fine for a signed-in visitor\",\n 403: \"the server refused the check (bot protection usually answers this) — the link may well work in a browser\",\n 405: \"the server refused the request method\",\n 429: \"the check was rate-limited, not the link broken\",\n 999: \"a non-standard status some sites return to automated checks — not a broken link\",\n};\n\n/**\n * Decide what one check means.\n *\n * The order is the order the failures shadow each other: a loop makes the\n * final status meaningless, a chain that ran out of hops never got one, a\n * transport error means there is no status at all, and only then is the status\n * itself worth reading.\n */\nexport function judgeCheck(outcome: CheckOutcome): LinkVerdict {\n const chain = outcome.chain.length > 0 ? outcome.chain : [\"\"];\n const hops = chain.length - 1;\n const final = chain[chain.length - 1];\n const landed = hops > 0 ? { final_url: final } : {};\n\n const repeat = firstRepeat(chain);\n if (repeat !== undefined) {\n return {\n category: \"error\",\n detail: `the redirects loop — ${repeat} is visited twice`,\n fix: \"Follow the chain by hand: a redirect loop is a page nobody can reach.\",\n hops,\n ...landed,\n };\n }\n\n if (outcome.hops_exceeded) {\n return {\n category: \"error\",\n detail: `still redirecting after ${MAX_LINK_REDIRECTS} redirects, last at ${final}`,\n fix: \"A chain this long is a configuration bug — most crawlers give up after five.\",\n hops,\n ...landed,\n };\n }\n\n if (outcome.error !== undefined) {\n const failure = classifyFailure(outcome.error);\n return { ...failure, hops, ...landed };\n }\n\n const status = outcome.status;\n if (status === undefined) {\n return { category: \"error\", detail: \"the check produced no answer at all\", hops, ...landed };\n }\n\n const named = label(status);\n\n // A 3xx that is still a 3xx after the chain was followed had nowhere to go.\n if (status >= 300 && status < 400) {\n return {\n category: \"broken\",\n detail: `${named} — a redirect with no Location header to follow`,\n fix: \"Give the redirect a Location, or serve the page directly.\",\n hops,\n ...landed,\n };\n }\n\n if (status >= 400) {\n const refused = REFUSED[status];\n if (refused !== undefined) {\n const method = status === 405 ? ` (${outcome.method} was refused too)` : \"\";\n return {\n category: \"blocked\",\n detail: `${named} — ${refused}${method}`,\n ...(status === 429\n ? { fix: \"Lower `concurrency` or raise `timeout_ms` and check this one again.\" }\n : {}),\n hops,\n ...landed,\n };\n }\n return {\n category: \"broken\",\n detail:\n hops > 0\n ? `${named} — after ${plural(hops, \"redirect\")}, ending at ${final}`\n : named,\n fix:\n status === 404\n ? \"Point the link somewhere that exists, or restore the page.\"\n : \"This link answers an error to every visitor.\",\n hops,\n ...landed,\n };\n }\n\n if (hops === 0) {\n return { category: \"ok\", detail: named, hops };\n }\n\n const upgraded = isUpgrade(chain);\n return {\n category: \"redirect\",\n detail: `redirected to ${final} (${plural(hops, \"hop\")}), which answered ${named}`,\n ...(hops > 1\n ? { fix: \"Link straight to the final address — every hop in the chain is another round trip.\" }\n : upgraded\n ? { fix: \"Write the link as https:// — the http:// form costs an extra round trip on every visit.\" }\n : {}),\n hops,\n final_url: final,\n };\n}\n\n/** Did this chain exist only to swap http for https? */\nfunction isUpgrade(chain: string[]): boolean {\n try {\n return new URL(chain[0]).protocol === \"http:\" && new URL(chain[chain.length - 1]).protocol === \"https:\";\n } catch {\n return false;\n }\n}\n\n/** The first URL that appears twice, which is a loop however long the chain is. */\nfunction firstRepeat(chain: string[]): string | undefined {\n const seen = new Set<string>();\n for (const url of chain) {\n if (seen.has(url)) return url;\n seen.add(url);\n }\n return undefined;\n}\n\n/**\n * Turn a transport failure into something a reader can act on.\n *\n * These are the five that a link check actually hits. Anything else is quoted\n * verbatim rather than guessed at — an error nobody predicted is still useful,\n * and a wrong paraphrase of it is not.\n */\nexport function classifyFailure(message: string): { category: LinkCategory; detail: string; fix?: string } {\n const line = String(message).split(\"\\n\")[0];\n\n if (/Timeout\\s+\\d+\\s*ms\\s+exceeded|timed?\\s?out|ETIMEDOUT/i.test(line)) {\n return {\n category: \"timeout\",\n detail: \"no answer within the timeout\",\n fix: \"Raise `timeout_ms` if the host is simply slow; a link nobody's browser will wait for is broken in practice.\",\n };\n }\n if (/ENOTFOUND|EAI_AGAIN|getaddrinfo|NAME_NOT_RESOLVED|ERR_NAME/i.test(line)) {\n return {\n category: \"error\",\n detail: \"the host name does not resolve\",\n fix: \"Check the domain for a typo, and that it has not expired.\",\n };\n }\n if (/ECONNREFUSED|CONNECTION_REFUSED/i.test(line)) {\n return { category: \"error\", detail: \"nothing is listening on that address — the connection was refused\" };\n }\n if (/ECONNRESET|socket hang up|EPIPE/i.test(line)) {\n return { category: \"error\", detail: \"the connection was closed before an answer arrived (socket hang up)\" };\n }\n if (/certificate|CERT_|ERR_CERT|SSL|TLS|self-signed/i.test(line)) {\n return {\n category: \"error\",\n detail: \"the TLS certificate was rejected\",\n fix: \"A browser shows an interstitial for this — to a visitor it is a broken link.\",\n };\n }\n return { category: \"error\", detail: line };\n}\n\n/** The registered name of a status code, or \"\" for the ones nobody standardised. */\nexport function statusText(status: number): string {\n return STATUS_CODES[status] ?? \"\";\n}\n\n/** `404 Not Found`, or just `999` where inventing a name would be worse than having none. */\nfunction label(status: number): string {\n const name = statusText(status);\n return name === \"\" ? String(status) : `${status} ${name}`;\n}\n\nfunction plural(n: number, noun: string): string {\n return `${n} ${noun}${n === 1 ? \"\" : \"s\"}`;\n}\n"]}
@@ -0,0 +1,144 @@
1
+ /**
2
+ * What a mock means, and what one run of them is worth reporting.
3
+ *
4
+ * Everything here is pure: no Playwright, no page, no socket. The engine turns
5
+ * a `ResolvedMock` into a real route and counts what happened; this file
6
+ * decides what a scenario expands to, how a body is encoded, which mock wins
7
+ * when two patterns match, and how the result reads.
8
+ */
9
+ /**
10
+ * The shorthands. Each expands to a response the caller can then override
11
+ * field by field, which is what makes them worth having: `slow` with your own
12
+ * body is one word plus a body, not a hand-written response.
13
+ */
14
+ export declare const MOCK_SCENARIOS: readonly ["empty", "error", "unauthorized", "not_found", "slow", "malformed", "offline"];
15
+ export type MockScenario = (typeof MOCK_SCENARIOS)[number];
16
+ /**
17
+ * Failures the browser can be told to report. These four are the ones a
18
+ * developer actually means: the request failed, it hung, nothing was
19
+ * listening, or there is no network at all.
20
+ */
21
+ export declare const ABORT_REASONS: readonly ["failed", "timedout", "connectionrefused", "internetdisconnected"];
22
+ export type AbortReason = (typeof ABORT_REASONS)[number];
23
+ /** One mock exactly as the caller wrote it. */
24
+ export interface MockSpec {
25
+ url_pattern?: string;
26
+ scenario?: MockScenario;
27
+ response?: {
28
+ status?: number;
29
+ body?: unknown;
30
+ delay_ms?: number;
31
+ headers?: Record<string, string>;
32
+ };
33
+ abort?: AbortReason;
34
+ times?: number;
35
+ }
36
+ /** A mock with every question answered, ready for the engine to install. */
37
+ export type ResolvedMock = {
38
+ kind: "abort";
39
+ url_pattern: string;
40
+ reason: AbortReason;
41
+ times?: number;
42
+ scenario?: MockScenario;
43
+ } | {
44
+ kind: "fulfill";
45
+ url_pattern: string;
46
+ status: number;
47
+ /** Already encoded — the exact bytes the page will receive. */
48
+ body: string;
49
+ /** Header names lower-cased, so `content-type` can only be set once. */
50
+ headers: Record<string, string>;
51
+ delay_ms: number;
52
+ times?: number;
53
+ scenario?: MockScenario;
54
+ };
55
+ /**
56
+ * Turn one written mock into one the engine can install.
57
+ *
58
+ * The scenario supplies defaults and the caller's own fields win one at a
59
+ * time, so `{ scenario: "slow", response: { body } }` keeps the delay and takes
60
+ * the body. An explicit `abort` beats a scenario that would have answered: a
61
+ * caller who asked for a dead request means it.
62
+ */
63
+ export declare function resolveMock(spec: MockSpec): ResolvedMock;
64
+ /**
65
+ * Encode a body into the bytes the page will actually receive.
66
+ *
67
+ * A string is sent verbatim. This is the whole reason the malformed scenario
68
+ * works: `JSON.stringify("not json")` is `"not json"`, which parses perfectly,
69
+ * so encoding a string would make a broken API impossible to simulate. It goes
70
+ * out as `application/json` because these stand in for API responses — anyone
71
+ * mocking a page or a script says so in `headers`.
72
+ *
73
+ * Anything else is JSON, including `null`. Only an absent body means "no body",
74
+ * and it carries no content type at all.
75
+ */
76
+ export declare function encodeBody(body: unknown): {
77
+ body: string;
78
+ content_type?: string;
79
+ };
80
+ /**
81
+ * The order to hand mocks to Playwright.
82
+ *
83
+ * Playwright checks routes last-registered-first; a caller reads their list
84
+ * top-down and expects the first pattern that matches to win. Reversing on the
85
+ * way in is what reconciles the two, and is the only reason this is not just a
86
+ * loop in the engine.
87
+ */
88
+ export declare function installOrder<T>(mocks: readonly T[]): T[];
89
+ /**
90
+ * The advice for a pattern that matched nothing, when the pattern itself looks
91
+ * like the reason.
92
+ *
93
+ * Patterns are matched against the whole URL, so `/api/orders` matches nothing
94
+ * on a page served from `http://localhost:3000` — by far the most common way
95
+ * to write a mock that silently does nothing. A pattern that already starts
96
+ * with a wildcard, or that is a full URL, is left alone: a wrong guess here is
97
+ * worse than no guess.
98
+ */
99
+ export declare function patternHint(pattern: string): string | undefined;
100
+ /** What one mock did during a run. */
101
+ export interface MockActivity {
102
+ mock: ResolvedMock;
103
+ /** Requests this mock intercepted. */
104
+ hits: number;
105
+ /**
106
+ * Requests it actually answered. Lower than `hits` when a delayed response
107
+ * outlived the recording — the request was caught but the page never got a
108
+ * reply, which is a finding rather than a bug in the count.
109
+ */
110
+ answered: number;
111
+ /** The first few URLs it intercepted, for the report. */
112
+ urls: string[];
113
+ }
114
+ /** A request no mock matched. */
115
+ export interface UnmatchedRequest {
116
+ method: string;
117
+ url: string;
118
+ /** What the real server answered, when it was allowed to. */
119
+ status?: number;
120
+ /** Set when `block_unmatched` killed it before it left the browser. */
121
+ blocked?: boolean;
122
+ }
123
+ export interface MockReportInput {
124
+ activity: MockActivity[];
125
+ unmatched: UnmatchedRequest[];
126
+ block_unmatched: boolean;
127
+ /** The recording length, so a delay that outlives it can be named. */
128
+ duration_ms: number;
129
+ }
130
+ /**
131
+ * The mock report, as lines for the capture summary.
132
+ *
133
+ * A mock that never matched leads, because it is the finding this tool exists
134
+ * to surface: mock the products endpoint, watch the app call `/api/product-list`,
135
+ * see a page that looks perfectly fine, and learn nothing at all. The same
136
+ * reasoning puts the unmatched requests in — a run that quietly used the real
137
+ * backend for half its data is not the test anybody thought they ran.
138
+ */
139
+ export declare function formatMockReport(input: MockReportInput): string[];
140
+ /**
141
+ * What a mock serves, in a few words. Names the scenario when there was one —
142
+ * "200 empty" says more than "200" and is what the caller wrote.
143
+ */
144
+ export declare function describeMock(mock: ResolvedMock): string;