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.
Files changed (133) hide show
  1. package/README.md +895 -15
  2. package/dist/constants.d.ts +274 -0
  3. package/dist/constants.js +279 -0
  4. package/dist/constants.js.map +1 -1
  5. package/dist/engine/browser.d.ts +20 -4
  6. package/dist/engine/browser.js +26 -9
  7. package/dist/engine/browser.js.map +1 -1
  8. package/dist/engine/clicks.d.ts +221 -0
  9. package/dist/engine/clicks.js +801 -0
  10. package/dist/engine/clicks.js.map +1 -0
  11. package/dist/engine/forms.d.ts +137 -0
  12. package/dist/engine/forms.js +474 -0
  13. package/dist/engine/forms.js.map +1 -0
  14. package/dist/engine/hmr.d.ts +41 -0
  15. package/dist/engine/hmr.js +91 -0
  16. package/dist/engine/hmr.js.map +1 -0
  17. package/dist/engine/inspect.d.ts +31 -0
  18. package/dist/engine/inspect.js +383 -0
  19. package/dist/engine/inspect.js.map +1 -0
  20. package/dist/engine/interaction.d.ts +12 -7
  21. package/dist/engine/interaction.js +110 -18
  22. package/dist/engine/interaction.js.map +1 -1
  23. package/dist/engine/links.d.ts +134 -0
  24. package/dist/engine/links.js +384 -0
  25. package/dist/engine/links.js.map +1 -0
  26. package/dist/engine/mocks.d.ts +53 -0
  27. package/dist/engine/mocks.js +148 -0
  28. package/dist/engine/mocks.js.map +1 -0
  29. package/dist/engine/rtl.d.ts +129 -0
  30. package/dist/engine/rtl.js +540 -0
  31. package/dist/engine/rtl.js.map +1 -0
  32. package/dist/engine/seo.d.ts +189 -0
  33. package/dist/engine/seo.js +398 -0
  34. package/dist/engine/seo.js.map +1 -0
  35. package/dist/engine/snapshot.d.ts +29 -0
  36. package/dist/engine/snapshot.js +10 -0
  37. package/dist/engine/snapshot.js.map +1 -0
  38. package/dist/engine/vue.d.ts +54 -0
  39. package/dist/engine/vue.js +419 -0
  40. package/dist/engine/vue.js.map +1 -0
  41. package/dist/index.js +45 -1
  42. package/dist/index.js.map +1 -1
  43. package/dist/tools/accessibility.d.ts +4 -0
  44. package/dist/tools/accessibility.js +9 -2
  45. package/dist/tools/accessibility.js.map +1 -1
  46. package/dist/tools/api-mock.d.ts +405 -0
  47. package/dist/tools/api-mock.js +186 -0
  48. package/dist/tools/api-mock.js.map +1 -0
  49. package/dist/tools/capture.d.ts +90 -26
  50. package/dist/tools/capture.js +109 -58
  51. package/dist/tools/capture.js.map +1 -1
  52. package/dist/tools/compare.d.ts +4 -0
  53. package/dist/tools/compare.js +16 -5
  54. package/dist/tools/compare.js.map +1 -1
  55. package/dist/tools/dead-clicks.d.ts +128 -0
  56. package/dist/tools/dead-clicks.js +570 -0
  57. package/dist/tools/dead-clicks.js.map +1 -0
  58. package/dist/tools/form-test.d.ts +112 -0
  59. package/dist/tools/form-test.js +477 -0
  60. package/dist/tools/form-test.js.map +1 -0
  61. package/dist/tools/index.d.ts +17 -1
  62. package/dist/tools/index.js +45 -1
  63. package/dist/tools/index.js.map +1 -1
  64. package/dist/tools/inspect.d.ts +78 -0
  65. package/dist/tools/inspect.js +136 -0
  66. package/dist/tools/inspect.js.map +1 -0
  67. package/dist/tools/interact.d.ts +37 -18
  68. package/dist/tools/interact.js +113 -13
  69. package/dist/tools/interact.js.map +1 -1
  70. package/dist/tools/links.d.ts +129 -0
  71. package/dist/tools/links.js +640 -0
  72. package/dist/tools/links.js.map +1 -0
  73. package/dist/tools/responsive.d.ts +10 -6
  74. package/dist/tools/responsive.js +21 -4
  75. package/dist/tools/responsive.js.map +1 -1
  76. package/dist/tools/rtl.d.ts +241 -0
  77. package/dist/tools/rtl.js +410 -0
  78. package/dist/tools/rtl.js.map +1 -0
  79. package/dist/tools/save-auth.d.ts +263 -0
  80. package/dist/tools/save-auth.js +253 -0
  81. package/dist/tools/save-auth.js.map +1 -0
  82. package/dist/tools/screenshot.d.ts +4 -0
  83. package/dist/tools/screenshot.js +15 -4
  84. package/dist/tools/screenshot.js.map +1 -1
  85. package/dist/tools/seo.d.ts +113 -0
  86. package/dist/tools/seo.js +281 -0
  87. package/dist/tools/seo.js.map +1 -0
  88. package/dist/tools/snapshot.d.ts +122 -0
  89. package/dist/tools/snapshot.js +183 -0
  90. package/dist/tools/snapshot.js.map +1 -0
  91. package/dist/tools/wait-for.d.ts +107 -0
  92. package/dist/tools/wait-for.js +167 -0
  93. package/dist/tools/wait-for.js.map +1 -0
  94. package/dist/utils/arabic-text.d.ts +14 -0
  95. package/dist/utils/arabic-text.js +193 -0
  96. package/dist/utils/arabic-text.js.map +1 -0
  97. package/dist/utils/budget.d.ts +41 -0
  98. package/dist/utils/budget.js +182 -0
  99. package/dist/utils/budget.js.map +1 -0
  100. package/dist/utils/format.d.ts +11 -1
  101. package/dist/utils/format.js +27 -4
  102. package/dist/utils/format.js.map +1 -1
  103. package/dist/utils/highlight.d.ts +69 -0
  104. package/dist/utils/highlight.js +181 -0
  105. package/dist/utils/highlight.js.map +1 -0
  106. package/dist/utils/link-rules.d.ts +100 -0
  107. package/dist/utils/link-rules.js +284 -0
  108. package/dist/utils/link-rules.js.map +1 -0
  109. package/dist/utils/mock-rules.d.ts +144 -0
  110. package/dist/utils/mock-rules.js +224 -0
  111. package/dist/utils/mock-rules.js.map +1 -0
  112. package/dist/utils/rtl-rules.d.ts +142 -0
  113. package/dist/utils/rtl-rules.js +296 -0
  114. package/dist/utils/rtl-rules.js.map +1 -0
  115. package/dist/utils/seo-rules.d.ts +129 -0
  116. package/dist/utils/seo-rules.js +726 -0
  117. package/dist/utils/seo-rules.js.map +1 -0
  118. package/dist/utils/snapshot-rules.d.ts +33 -0
  119. package/dist/utils/snapshot-rules.js +111 -0
  120. package/dist/utils/snapshot-rules.js.map +1 -0
  121. package/dist/utils/storage-state.d.ts +76 -0
  122. package/dist/utils/storage-state.js +195 -0
  123. package/dist/utils/storage-state.js.map +1 -0
  124. package/dist/utils/style-rules.d.ts +107 -0
  125. package/dist/utils/style-rules.js +223 -0
  126. package/dist/utils/style-rules.js.map +1 -0
  127. package/dist/utils/test-data.d.ts +75 -0
  128. package/dist/utils/test-data.js +294 -0
  129. package/dist/utils/test-data.js.map +1 -0
  130. package/dist/utils/vue-rules.d.ts +72 -0
  131. package/dist/utils/vue-rules.js +108 -0
  132. package/dist/utils/vue-rules.js.map +1 -0
  133. package/package.json +6 -4
@@ -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;
@@ -0,0 +1,224 @@
1
+ import { MAX_MOCK_URLS_LISTED, MAX_UNMATCHED_LISTED, MOCK_SLOW_DELAY_MS, } from "../constants.js";
2
+ /**
3
+ * What a mock means, and what one run of them is worth reporting.
4
+ *
5
+ * Everything here is pure: no Playwright, no page, no socket. The engine turns
6
+ * a `ResolvedMock` into a real route and counts what happened; this file
7
+ * decides what a scenario expands to, how a body is encoded, which mock wins
8
+ * when two patterns match, and how the result reads.
9
+ */
10
+ /* ── Scenarios ────────────────────────────────────────────────────────── */
11
+ /**
12
+ * The shorthands. Each expands to a response the caller can then override
13
+ * field by field, which is what makes them worth having: `slow` with your own
14
+ * body is one word plus a body, not a hand-written response.
15
+ */
16
+ export const MOCK_SCENARIOS = [
17
+ "empty",
18
+ "error",
19
+ "unauthorized",
20
+ "not_found",
21
+ "slow",
22
+ "malformed",
23
+ "offline",
24
+ ];
25
+ /**
26
+ * Failures the browser can be told to report. These four are the ones a
27
+ * developer actually means: the request failed, it hung, nothing was
28
+ * listening, or there is no network at all.
29
+ */
30
+ export const ABORT_REASONS = ["failed", "timedout", "connectionrefused", "internetdisconnected"];
31
+ /** What each scenario means, before the caller's own `response` is laid over it. */
32
+ const SCENARIOS = {
33
+ // An empty array, not `{ items: [] }`: no shape is right for every API, so
34
+ // the shorthand covers the common one and the report prints what it served,
35
+ // which is how a mismatch becomes visible instead of mysterious.
36
+ empty: { status: 200, body: [] },
37
+ error: { status: 500, body: { error: "Internal server error" } },
38
+ unauthorized: { status: 401, body: { error: "Unauthorized" } },
39
+ not_found: { status: 404, body: { error: "Not found" } },
40
+ slow: { status: 200, delay_ms: MOCK_SLOW_DELAY_MS },
41
+ // Deliberately not JSON, deliberately served as JSON. See `encodeBody`.
42
+ malformed: { status: 200, body: "not json" },
43
+ offline: { abort: "failed" },
44
+ };
45
+ /**
46
+ * Turn one written mock into one the engine can install.
47
+ *
48
+ * The scenario supplies defaults and the caller's own fields win one at a
49
+ * time, so `{ scenario: "slow", response: { body } }` keeps the delay and takes
50
+ * the body. An explicit `abort` beats a scenario that would have answered: a
51
+ * caller who asked for a dead request means it.
52
+ */
53
+ export function resolveMock(spec) {
54
+ const scenario = spec.scenario;
55
+ const preset = scenario ? SCENARIOS[scenario] : {};
56
+ const response = spec.response ?? {};
57
+ const reason = spec.abort ?? preset.abort;
58
+ if (reason !== undefined) {
59
+ return {
60
+ kind: "abort",
61
+ url_pattern: spec.url_pattern ?? "",
62
+ reason,
63
+ ...(spec.times !== undefined ? { times: spec.times } : {}),
64
+ ...(scenario !== undefined ? { scenario } : {}),
65
+ };
66
+ }
67
+ const body = response.body !== undefined ? response.body : preset.body;
68
+ const encoded = encodeBody(body);
69
+ const headers = {};
70
+ if (encoded.content_type !== undefined)
71
+ headers["content-type"] = encoded.content_type;
72
+ // The caller's headers land last and are lower-cased on the way in, so a
73
+ // `Content-Type` of their own replaces the one the body implied rather than
74
+ // sitting next to it as a second header.
75
+ for (const [name, value] of Object.entries(response.headers ?? {})) {
76
+ headers[name.toLowerCase()] = value;
77
+ }
78
+ return {
79
+ kind: "fulfill",
80
+ url_pattern: spec.url_pattern ?? "",
81
+ status: response.status ?? preset.status ?? 200,
82
+ body: encoded.body,
83
+ headers,
84
+ delay_ms: response.delay_ms ?? preset.delay_ms ?? 0,
85
+ ...(spec.times !== undefined ? { times: spec.times } : {}),
86
+ ...(scenario !== undefined ? { scenario } : {}),
87
+ };
88
+ }
89
+ /**
90
+ * Encode a body into the bytes the page will actually receive.
91
+ *
92
+ * A string is sent verbatim. This is the whole reason the malformed scenario
93
+ * works: `JSON.stringify("not json")` is `"not json"`, which parses perfectly,
94
+ * so encoding a string would make a broken API impossible to simulate. It goes
95
+ * out as `application/json` because these stand in for API responses — anyone
96
+ * mocking a page or a script says so in `headers`.
97
+ *
98
+ * Anything else is JSON, including `null`. Only an absent body means "no body",
99
+ * and it carries no content type at all.
100
+ */
101
+ export function encodeBody(body) {
102
+ if (body === undefined)
103
+ return { body: "" };
104
+ if (typeof body === "string")
105
+ return { body, content_type: "application/json" };
106
+ return { body: JSON.stringify(body), content_type: "application/json" };
107
+ }
108
+ /**
109
+ * The order to hand mocks to Playwright.
110
+ *
111
+ * Playwright checks routes last-registered-first; a caller reads their list
112
+ * top-down and expects the first pattern that matches to win. Reversing on the
113
+ * way in is what reconciles the two, and is the only reason this is not just a
114
+ * loop in the engine.
115
+ */
116
+ export function installOrder(mocks) {
117
+ return [...mocks].reverse();
118
+ }
119
+ /**
120
+ * The advice for a pattern that matched nothing, when the pattern itself looks
121
+ * like the reason.
122
+ *
123
+ * Patterns are matched against the whole URL, so `/api/orders` matches nothing
124
+ * on a page served from `http://localhost:3000` — by far the most common way
125
+ * to write a mock that silently does nothing. A pattern that already starts
126
+ * with a wildcard, or that is a full URL, is left alone: a wrong guess here is
127
+ * worse than no guess.
128
+ */
129
+ export function patternHint(pattern) {
130
+ if (pattern.startsWith("*") || pattern.includes("://"))
131
+ return undefined;
132
+ const path = pattern.startsWith("/") ? pattern : `/${pattern}`;
133
+ const suggestion = `**${path}${path.endsWith("*") ? "" : "*"}`;
134
+ return `patterns are matched against the whole URL, so a path needs a leading \`**\` — try \`${suggestion}\``;
135
+ }
136
+ /**
137
+ * The mock report, as lines for the capture summary.
138
+ *
139
+ * A mock that never matched leads, because it is the finding this tool exists
140
+ * to surface: mock the products endpoint, watch the app call `/api/product-list`,
141
+ * see a page that looks perfectly fine, and learn nothing at all. The same
142
+ * reasoning puts the unmatched requests in — a run that quietly used the real
143
+ * backend for half its data is not the test anybody thought they ran.
144
+ */
145
+ export function formatMockReport(input) {
146
+ const { activity, unmatched, block_unmatched, duration_ms } = input;
147
+ const served = activity.filter((entry) => entry.hits > 0);
148
+ const missed = activity.filter((entry) => entry.hits === 0);
149
+ const requests = served.reduce((total, entry) => total + entry.hits, 0);
150
+ const headline = `API mocks — ${activity.length} declared, ` +
151
+ `${served.length} served ${count(requests, "request")}` +
152
+ (missed.length > 0 ? `, ${missed.length} never matched.` : ".");
153
+ const lines = [headline];
154
+ for (const entry of activity) {
155
+ lines.push(` ${formatActivity(entry, duration_ms)}`);
156
+ }
157
+ if (unmatched.length > 0) {
158
+ lines.push(block_unmatched
159
+ ? " Unmatched, blocked before they left the browser:"
160
+ : " Unmatched, answered by the real server:");
161
+ for (const request of unmatched.slice(0, MAX_UNMATCHED_LISTED)) {
162
+ const outcome = request.blocked ? "" : request.status !== undefined ? ` → ${request.status}` : " → no response";
163
+ lines.push(` ${request.method} ${request.url}${outcome}`);
164
+ }
165
+ if (unmatched.length > MAX_UNMATCHED_LISTED) {
166
+ lines.push(` … and ${unmatched.length - MAX_UNMATCHED_LISTED} more`);
167
+ }
168
+ }
169
+ else {
170
+ lines.push(" Every request the page made was matched by a mock.");
171
+ }
172
+ return lines;
173
+ }
174
+ /** One mock's line in the report: the mark, the pattern, what it serves, and what it did. */
175
+ function formatActivity(entry, duration_ms) {
176
+ const { mock, hits, answered, urls } = entry;
177
+ if (hits === 0) {
178
+ const hint = patternHint(mock.url_pattern);
179
+ return `✗ ${mock.url_pattern} — no request matched it${hint ? ` (${hint})` : ""}.`;
180
+ }
181
+ const late = mock.kind === "fulfill" && answered < hits && mock.delay_ms >= duration_ms;
182
+ const mark = late ? "!" : "✓";
183
+ let line = `${mark} ${mock.url_pattern} → ${describeMock(mock)} ×${hits}`;
184
+ if (late && mock.kind === "fulfill") {
185
+ line +=
186
+ ` — delayed ${mock.delay_ms}ms, longer than the ${duration_ms}ms recording, ` +
187
+ `so ${hits === 1 ? "the request" : `${hits - answered} of them`} never got an answer.`;
188
+ }
189
+ else if (urls.length > 0) {
190
+ // Only a handful of URLs are ever kept, so the remainder is counted off the
191
+ // hits — a mock that answered fifty polls must not report "and 1 more"
192
+ // simply because three is all the sample it has.
193
+ const listed = urls.slice(0, MAX_MOCK_URLS_LISTED);
194
+ const more = hits > listed.length ? `, … and ${hits - listed.length} more` : "";
195
+ line += ` (${listed.join(", ")}${more})`;
196
+ }
197
+ return line;
198
+ }
199
+ /**
200
+ * What a mock serves, in a few words. Names the scenario when there was one —
201
+ * "200 empty" says more than "200" and is what the caller wrote.
202
+ */
203
+ export function describeMock(mock) {
204
+ const parts = [];
205
+ if (mock.kind === "abort") {
206
+ parts.push(`abort ${mock.reason}`);
207
+ }
208
+ else {
209
+ parts.push(String(mock.status));
210
+ if (mock.scenario !== undefined && mock.scenario !== "slow")
211
+ parts.push(mock.scenario);
212
+ if (mock.delay_ms > 0)
213
+ parts.push(`after ${mock.delay_ms}ms`);
214
+ }
215
+ let text = parts.join(" ");
216
+ if (mock.times !== undefined) {
217
+ text += mock.times === 1 ? ", first request only" : `, first ${mock.times} requests only`;
218
+ }
219
+ return text;
220
+ }
221
+ function count(n, noun) {
222
+ return `${n} ${noun}${n === 1 ? "" : "s"}`;
223
+ }
224
+ //# sourceMappingURL=mock-rules.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mock-rules.js","sourceRoot":"","sources":["../../src/utils/mock-rules.ts"],"names":[],"mappings":"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","sourcesContent":["import {\n MAX_MOCK_URLS_LISTED,\n MAX_UNMATCHED_LISTED,\n MOCK_SLOW_DELAY_MS,\n} from \"../constants.js\";\n\n/**\n * What a mock means, and what one run of them is worth reporting.\n *\n * Everything here is pure: no Playwright, no page, no socket. The engine turns\n * a `ResolvedMock` into a real route and counts what happened; this file\n * decides what a scenario expands to, how a body is encoded, which mock wins\n * when two patterns match, and how the result reads.\n */\n\n/* ── Scenarios ────────────────────────────────────────────────────────── */\n\n/**\n * The shorthands. Each expands to a response the caller can then override\n * field by field, which is what makes them worth having: `slow` with your own\n * body is one word plus a body, not a hand-written response.\n */\nexport const MOCK_SCENARIOS = [\n \"empty\",\n \"error\",\n \"unauthorized\",\n \"not_found\",\n \"slow\",\n \"malformed\",\n \"offline\",\n] as const;\n\nexport type MockScenario = (typeof MOCK_SCENARIOS)[number];\n\n/**\n * Failures the browser can be told to report. These four are the ones a\n * developer actually means: the request failed, it hung, nothing was\n * listening, or there is no network at all.\n */\nexport const ABORT_REASONS = [\"failed\", \"timedout\", \"connectionrefused\", \"internetdisconnected\"] as const;\n\nexport type AbortReason = (typeof ABORT_REASONS)[number];\n\n/** One mock exactly as the caller wrote it. */\nexport interface MockSpec {\n url_pattern?: string;\n scenario?: MockScenario;\n response?: {\n status?: number;\n body?: unknown;\n delay_ms?: number;\n headers?: Record<string, string>;\n };\n abort?: AbortReason;\n times?: number;\n}\n\n/** A mock with every question answered, ready for the engine to install. */\nexport type ResolvedMock =\n | {\n kind: \"abort\";\n url_pattern: string;\n reason: AbortReason;\n times?: number;\n scenario?: MockScenario;\n }\n | {\n kind: \"fulfill\";\n url_pattern: string;\n status: number;\n /** Already encoded — the exact bytes the page will receive. */\n body: string;\n /** Header names lower-cased, so `content-type` can only be set once. */\n headers: Record<string, string>;\n delay_ms: number;\n times?: number;\n scenario?: MockScenario;\n };\n\n/** What each scenario means, before the caller's own `response` is laid over it. */\nconst SCENARIOS: Readonly<Record<MockScenario, { status?: number; body?: unknown; delay_ms?: number; abort?: AbortReason }>> =\n {\n // An empty array, not `{ items: [] }`: no shape is right for every API, so\n // the shorthand covers the common one and the report prints what it served,\n // which is how a mismatch becomes visible instead of mysterious.\n empty: { status: 200, body: [] },\n error: { status: 500, body: { error: \"Internal server error\" } },\n unauthorized: { status: 401, body: { error: \"Unauthorized\" } },\n not_found: { status: 404, body: { error: \"Not found\" } },\n slow: { status: 200, delay_ms: MOCK_SLOW_DELAY_MS },\n // Deliberately not JSON, deliberately served as JSON. See `encodeBody`.\n malformed: { status: 200, body: \"not json\" },\n offline: { abort: \"failed\" },\n };\n\n/**\n * Turn one written mock into one the engine can install.\n *\n * The scenario supplies defaults and the caller's own fields win one at a\n * time, so `{ scenario: \"slow\", response: { body } }` keeps the delay and takes\n * the body. An explicit `abort` beats a scenario that would have answered: a\n * caller who asked for a dead request means it.\n */\nexport function resolveMock(spec: MockSpec): ResolvedMock {\n const scenario = spec.scenario;\n const preset = scenario ? SCENARIOS[scenario] : {};\n const response = spec.response ?? {};\n\n const reason = spec.abort ?? preset.abort;\n if (reason !== undefined) {\n return {\n kind: \"abort\",\n url_pattern: spec.url_pattern ?? \"\",\n reason,\n ...(spec.times !== undefined ? { times: spec.times } : {}),\n ...(scenario !== undefined ? { scenario } : {}),\n };\n }\n\n const body = response.body !== undefined ? response.body : preset.body;\n const encoded = encodeBody(body);\n const headers: Record<string, string> = {};\n if (encoded.content_type !== undefined) headers[\"content-type\"] = encoded.content_type;\n // The caller's headers land last and are lower-cased on the way in, so a\n // `Content-Type` of their own replaces the one the body implied rather than\n // sitting next to it as a second header.\n for (const [name, value] of Object.entries(response.headers ?? {})) {\n headers[name.toLowerCase()] = value;\n }\n\n return {\n kind: \"fulfill\",\n url_pattern: spec.url_pattern ?? \"\",\n status: response.status ?? preset.status ?? 200,\n body: encoded.body,\n headers,\n delay_ms: response.delay_ms ?? preset.delay_ms ?? 0,\n ...(spec.times !== undefined ? { times: spec.times } : {}),\n ...(scenario !== undefined ? { scenario } : {}),\n };\n}\n\n/**\n * Encode a body into the bytes the page will actually receive.\n *\n * A string is sent verbatim. This is the whole reason the malformed scenario\n * works: `JSON.stringify(\"not json\")` is `\"not json\"`, which parses perfectly,\n * so encoding a string would make a broken API impossible to simulate. It goes\n * out as `application/json` because these stand in for API responses — anyone\n * mocking a page or a script says so in `headers`.\n *\n * Anything else is JSON, including `null`. Only an absent body means \"no body\",\n * and it carries no content type at all.\n */\nexport function encodeBody(body: unknown): { body: string; content_type?: string } {\n if (body === undefined) return { body: \"\" };\n if (typeof body === \"string\") return { body, content_type: \"application/json\" };\n return { body: JSON.stringify(body), content_type: \"application/json\" };\n}\n\n/**\n * The order to hand mocks to Playwright.\n *\n * Playwright checks routes last-registered-first; a caller reads their list\n * top-down and expects the first pattern that matches to win. Reversing on the\n * way in is what reconciles the two, and is the only reason this is not just a\n * loop in the engine.\n */\nexport function installOrder<T>(mocks: readonly T[]): T[] {\n return [...mocks].reverse();\n}\n\n/**\n * The advice for a pattern that matched nothing, when the pattern itself looks\n * like the reason.\n *\n * Patterns are matched against the whole URL, so `/api/orders` matches nothing\n * on a page served from `http://localhost:3000` — by far the most common way\n * to write a mock that silently does nothing. A pattern that already starts\n * with a wildcard, or that is a full URL, is left alone: a wrong guess here is\n * worse than no guess.\n */\nexport function patternHint(pattern: string): string | undefined {\n if (pattern.startsWith(\"*\") || pattern.includes(\"://\")) return undefined;\n const path = pattern.startsWith(\"/\") ? pattern : `/${pattern}`;\n const suggestion = `**${path}${path.endsWith(\"*\") ? \"\" : \"*\"}`;\n return `patterns are matched against the whole URL, so a path needs a leading \\`**\\` — try \\`${suggestion}\\``;\n}\n\n/* ── The report ───────────────────────────────────────────────────────── */\n\n/** What one mock did during a run. */\nexport interface MockActivity {\n mock: ResolvedMock;\n /** Requests this mock intercepted. */\n hits: number;\n /**\n * Requests it actually answered. Lower than `hits` when a delayed response\n * outlived the recording — the request was caught but the page never got a\n * reply, which is a finding rather than a bug in the count.\n */\n answered: number;\n /** The first few URLs it intercepted, for the report. */\n urls: string[];\n}\n\n/** A request no mock matched. */\nexport interface UnmatchedRequest {\n method: string;\n url: string;\n /** What the real server answered, when it was allowed to. */\n status?: number;\n /** Set when `block_unmatched` killed it before it left the browser. */\n blocked?: boolean;\n}\n\nexport interface MockReportInput {\n activity: MockActivity[];\n unmatched: UnmatchedRequest[];\n block_unmatched: boolean;\n /** The recording length, so a delay that outlives it can be named. */\n duration_ms: number;\n}\n\n/**\n * The mock report, as lines for the capture summary.\n *\n * A mock that never matched leads, because it is the finding this tool exists\n * to surface: mock the products endpoint, watch the app call `/api/product-list`,\n * see a page that looks perfectly fine, and learn nothing at all. The same\n * reasoning puts the unmatched requests in — a run that quietly used the real\n * backend for half its data is not the test anybody thought they ran.\n */\nexport function formatMockReport(input: MockReportInput): string[] {\n const { activity, unmatched, block_unmatched, duration_ms } = input;\n const served = activity.filter((entry) => entry.hits > 0);\n const missed = activity.filter((entry) => entry.hits === 0);\n const requests = served.reduce((total, entry) => total + entry.hits, 0);\n\n const headline =\n `API mocks — ${activity.length} declared, ` +\n `${served.length} served ${count(requests, \"request\")}` +\n (missed.length > 0 ? `, ${missed.length} never matched.` : \".\");\n const lines = [headline];\n\n for (const entry of activity) {\n lines.push(` ${formatActivity(entry, duration_ms)}`);\n }\n\n if (unmatched.length > 0) {\n lines.push(\n block_unmatched\n ? \" Unmatched, blocked before they left the browser:\"\n : \" Unmatched, answered by the real server:\",\n );\n for (const request of unmatched.slice(0, MAX_UNMATCHED_LISTED)) {\n const outcome = request.blocked ? \"\" : request.status !== undefined ? ` → ${request.status}` : \" → no response\";\n lines.push(` ${request.method} ${request.url}${outcome}`);\n }\n if (unmatched.length > MAX_UNMATCHED_LISTED) {\n lines.push(` … and ${unmatched.length - MAX_UNMATCHED_LISTED} more`);\n }\n } else {\n lines.push(\" Every request the page made was matched by a mock.\");\n }\n\n return lines;\n}\n\n/** One mock's line in the report: the mark, the pattern, what it serves, and what it did. */\nfunction formatActivity(entry: MockActivity, duration_ms: number): string {\n const { mock, hits, answered, urls } = entry;\n\n if (hits === 0) {\n const hint = patternHint(mock.url_pattern);\n return `✗ ${mock.url_pattern} — no request matched it${hint ? ` (${hint})` : \"\"}.`;\n }\n\n const late = mock.kind === \"fulfill\" && answered < hits && mock.delay_ms >= duration_ms;\n const mark = late ? \"!\" : \"✓\";\n let line = `${mark} ${mock.url_pattern} → ${describeMock(mock)} ×${hits}`;\n\n if (late && mock.kind === \"fulfill\") {\n line +=\n ` — delayed ${mock.delay_ms}ms, longer than the ${duration_ms}ms recording, ` +\n `so ${hits === 1 ? \"the request\" : `${hits - answered} of them`} never got an answer.`;\n } else if (urls.length > 0) {\n // Only a handful of URLs are ever kept, so the remainder is counted off the\n // hits — a mock that answered fifty polls must not report \"and 1 more\"\n // simply because three is all the sample it has.\n const listed = urls.slice(0, MAX_MOCK_URLS_LISTED);\n const more = hits > listed.length ? `, … and ${hits - listed.length} more` : \"\";\n line += ` (${listed.join(\", \")}${more})`;\n }\n\n return line;\n}\n\n/**\n * What a mock serves, in a few words. Names the scenario when there was one —\n * \"200 empty\" says more than \"200\" and is what the caller wrote.\n */\nexport function describeMock(mock: ResolvedMock): string {\n const parts: string[] = [];\n\n if (mock.kind === \"abort\") {\n parts.push(`abort ${mock.reason}`);\n } else {\n parts.push(String(mock.status));\n if (mock.scenario !== undefined && mock.scenario !== \"slow\") parts.push(mock.scenario);\n if (mock.delay_ms > 0) parts.push(`after ${mock.delay_ms}ms`);\n }\n\n let text = parts.join(\" \");\n if (mock.times !== undefined) {\n text += mock.times === 1 ? \", first request only\" : `, first ${mock.times} requests only`;\n }\n return text;\n}\n\nfunction count(n: number, noun: string): string {\n return `${n} ${noun}${n === 1 ? \"\" : \"s\"}`;\n}\n"]}
@@ -0,0 +1,142 @@
1
+ /**
2
+ * What counts as an RTL bug.
3
+ *
4
+ * Everything here is pure: it takes one element measured in both directions
5
+ * and returns a verdict. Nothing in this file opens a browser or touches a
6
+ * page, which is the point — "did this box mirror?", "is this alignment
7
+ * deliberate or forgotten?" and "is that overflow new?" are all judgements
8
+ * with edge cases, and judgements need unit tests that run in milliseconds.
9
+ *
10
+ * The bias throughout is against false alarms, and it is stronger here than
11
+ * anywhere else in this codebase, because static RTL analysis is notorious for
12
+ * crying wolf. `text-align: left` is *correct* on a code block, a number
13
+ * column and a Latin brand name; `padding-left` is correct on anything that
14
+ * should not mirror; `flex-direction: row` is correct wherever the order is
15
+ * physical rather than semantic. A report that flags all of those is a report
16
+ * whose real findings are never read.
17
+ *
18
+ * So nothing is judged from the RTL rendering alone. Every verdict is a
19
+ * *comparison*: the element is measured in LTR and again in RTL, and the only
20
+ * findings are the things that failed to change when the LTR measurement
21
+ * proves they should have. An element that is left-aligned in both directions
22
+ * has forgotten to mirror; one that is left in LTR and right in RTL is
23
+ * working exactly as intended, and this file says nothing about it.
24
+ */
25
+ /**
26
+ * One element as measured in one direction.
27
+ *
28
+ * Deliberately flat and JSON-ish: this is what crosses the boundary out of
29
+ * `page.evaluate`, so it can hold nothing but structured-cloneable values.
30
+ */
31
+ export interface ElementMeasurement {
32
+ /** Stable identity across the two renders — see `keyFor` in engine/rtl.ts. */
33
+ key: string;
34
+ /** A CSS selector for the element, for the report and the highlight overlay. */
35
+ selector: string;
36
+ /** Which match of `selector` this is, when the selector is not unique. */
37
+ match_index: number;
38
+ tag: string;
39
+ /** Visible text, elided. Named in the report so a finding is recognisable. */
40
+ text: string;
41
+ /** Border-box in document coordinates. */
42
+ x: number;
43
+ y: number;
44
+ width: number;
45
+ height: number;
46
+ /** The viewport this was measured in, so mirroring can be computed. */
47
+ viewport_width: number;
48
+ /** Computed `text-align`, already resolved from `start`/`end` by the browser. */
49
+ text_align: string;
50
+ /** Computed `direction` on the element itself. */
51
+ direction: string;
52
+ /** Computed `flex-direction`, or "" when the element is not a flex container. */
53
+ flex_direction: string;
54
+ /** Physical padding and margin, to spot asymmetry that never mirrored. */
55
+ padding_left: number;
56
+ padding_right: number;
57
+ margin_left: number;
58
+ margin_right: number;
59
+ /** Overflow of this element past its own content box, and past the viewport. */
60
+ scroll_width: number;
61
+ client_width: number;
62
+ /** How far the box sticks out past the right/left edge of the viewport, 0 when it fits. */
63
+ overflow_right: number;
64
+ overflow_left: number;
65
+ /** A transform that already mirrors the element — `scaleX(-1)` on an icon. */
66
+ transform: string;
67
+ /** Set when this element is one an author would expect to mirror: an arrow, a chevron. */
68
+ mirrorable?: boolean;
69
+ }
70
+ /** One element measured in both directions. */
71
+ export interface ElementPair {
72
+ ltr: ElementMeasurement;
73
+ rtl: ElementMeasurement;
74
+ }
75
+ /**
76
+ * What can be wrong with an element in RTL.
77
+ *
78
+ * Each of these is a *failure to change*, except `overflow`, which is a change
79
+ * that should not have happened. That asymmetry is the whole design: RTL bugs
80
+ * are things that stayed put.
81
+ */
82
+ export type RtlIssueKind = "not_mirrored" | "alignment" | "flex_not_reversed" | "padding_not_mirrored" | "overflow" | "icon_not_mirrored" | "direction_not_applied";
83
+ export interface RtlIssue {
84
+ kind: RtlIssueKind;
85
+ /** How bad it is. `problem` leads the report; `warning` follows it. */
86
+ severity: "problem" | "warning";
87
+ /** One line naming what is wrong, in the report's voice. */
88
+ message: string;
89
+ /** The measured evidence, quoted so the reader can check the verdict. */
90
+ evidence: string;
91
+ }
92
+ /** Every issue found on one element, with the element itself. */
93
+ export interface ElementFinding {
94
+ /** 1-based, and the number the highlight overlay is labelled with. */
95
+ index: number;
96
+ selector: string;
97
+ match_index: number;
98
+ /** `button "Add to cart"` — how the element is named in the report. */
99
+ description: string;
100
+ issues: RtlIssue[];
101
+ /** The worst severity among `issues`, so the report can sort and colour. */
102
+ severity: "problem" | "warning";
103
+ }
104
+ /**
105
+ * Judge one element, having seen it in both directions.
106
+ *
107
+ * Returns every issue it has; an element with none is not a finding and is
108
+ * never mentioned. The checks are deliberately independent — an element can
109
+ * both fail to mirror and newly overflow, and those are two different bugs
110
+ * with two different fixes.
111
+ */
112
+ export declare function judgeElement(pair: ElementPair): RtlIssue[];
113
+ /** Where a box's mirror image starts, in the same coordinate space. */
114
+ export declare function mirroredX(m: ElementMeasurement): number;
115
+ /**
116
+ * Does this transform flip the element horizontally?
117
+ *
118
+ * A computed transform is always a matrix, so the sign of `a` (the horizontal
119
+ * scale) is the answer. `scaleX(-1)` computes to `matrix(-1, 0, 0, 1, 0, 0)`.
120
+ */
121
+ export declare function isMirroredTransform(transform: string): boolean;
122
+ /**
123
+ * How an element is named in the report: `button "Add to cart"`, `div .card`.
124
+ *
125
+ * An icon-only control has no text at all, and a bare selector is the least
126
+ * recognisable thing to call it by — so text wins when there is any, and the
127
+ * selector is the fallback.
128
+ */
129
+ export declare function describeElement(m: Pick<ElementMeasurement, "tag" | "text" | "selector">): string;
130
+ /**
131
+ * Turn judged elements into the findings the report prints, numbered and
132
+ * ordered worst-first.
133
+ *
134
+ * Order is by severity, then by how many things are wrong with the element,
135
+ * then down the page. The numbering happens *after* that sort, so the numbers
136
+ * in the report count from the top of the list and match the labels drawn on
137
+ * the screenshot.
138
+ */
139
+ export declare function buildFindings(pairs: readonly ElementPair[]): ElementFinding[];
140
+ /** What each kind of issue is called as a section heading, and how it is explained. */
141
+ export declare function describeIssueKind(kind: RtlIssueKind): string;
142
+ export declare function elide(value: string, max: number): string;