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,540 @@
1
+ import { MAX_ARABIC_INJECTION_NODES, MAX_RTL_ELEMENTS, MAX_RTL_SCAN, MAX_RTL_SELECTOR_LENGTH, MAX_RTL_TEXT_LENGTH, } from "../constants.js";
2
+ import { arabicFor } from "../utils/arabic-text.js";
3
+ /** The trigger used when the caller names none: the way the platform itself defines RTL. */
4
+ export const DEFAULT_RTL_TRIGGER = {
5
+ type: "attribute",
6
+ attr: "dir",
7
+ value: "rtl",
8
+ target: "html",
9
+ };
10
+ /**
11
+ * Apply the trigger to an already-loaded page.
12
+ *
13
+ * `url` is not applied here — it is a different page and is navigated to by
14
+ * the caller before this is reached. `locale` likewise is a context option,
15
+ * fixed when the browser context is created; what this does for it is set
16
+ * `dir` as well, because a locale alone changes number and date formatting but
17
+ * does not flip the layout: `Accept-Language: ar` does not put a page in RTL,
18
+ * and a tool that assumed it did would report a clean bill of health on a
19
+ * completely broken page.
20
+ */
21
+ export async function applyRtlTrigger(page, trigger) {
22
+ if (trigger.type === "url")
23
+ return;
24
+ const request = trigger.type === "attribute"
25
+ ? { target: trigger.target, attr: trigger.attr, value: trigger.value, class_name: "" }
26
+ : trigger.type === "class"
27
+ ? { target: trigger.target, attr: "", value: "", class_name: trigger.class }
28
+ : // A locale is carried by the context; the layout still has to be flipped.
29
+ { target: "html", attr: "dir", value: "rtl", class_name: "" };
30
+ await page.evaluate(applyTrigger, request);
31
+ }
32
+ /**
33
+ * Check that the page really is rendering right-to-left.
34
+ *
35
+ * The single most damaging way this tool can fail is quietly: a trigger that
36
+ * does not match the app under test measures LTR twice, finds every element
37
+ * identical, and reports that the page has no RTL problems. That is worse than
38
+ * an error, because it is a confident wrong answer. So the direction that
39
+ * actually took effect is read back off the document and the body, and the
40
+ * caller refuses to report anything if it is still `ltr`.
41
+ */
42
+ export async function readDirection(page) {
43
+ try {
44
+ return await page.evaluate(() => {
45
+ const doc = globalThis.document;
46
+ const view = doc?.defaultView;
47
+ const read = (node) => {
48
+ if (!node || !view?.getComputedStyle)
49
+ return "";
50
+ try {
51
+ return String(view.getComputedStyle(node).direction ?? "");
52
+ }
53
+ catch {
54
+ return "";
55
+ }
56
+ };
57
+ return { html: read(doc?.documentElement), body: read(doc?.body) };
58
+ });
59
+ }
60
+ catch {
61
+ return { html: "", body: "" };
62
+ }
63
+ }
64
+ /* ── Arabic injection ─────────────────────────────────────────────────── */
65
+ /**
66
+ * Replace every visible text node with Arabic of about the same length.
67
+ *
68
+ * Run in *both* passes, not just the RTL one. That is not obvious and it is
69
+ * essential: the two passes are compared to each other, so they have to differ
70
+ * in exactly one variable. If only the RTL pass got Arabic, every box on the
71
+ * page would change width for reasons of text metrics rather than direction,
72
+ * and the mirroring comparison — which asks whether a box moved — would be
73
+ * measuring the font, not the layout.
74
+ *
75
+ * Text inside `<script>`, `<style>` and friends is left alone, as is anything
76
+ * inside an element the caller excluded. Input `value`s and `placeholder`s are
77
+ * replaced too, since a form is where RTL text is most likely to be mishandled.
78
+ */
79
+ export async function injectArabic(page, exclude) {
80
+ try {
81
+ const replaced = await page.evaluate(replaceWithArabic, {
82
+ exclude: exclude ?? "",
83
+ max_nodes: MAX_ARABIC_INJECTION_NODES,
84
+ // The generator is pure and lives in Node, so the vocabulary has to be
85
+ // carried into the page. Passing the built strings instead of the
86
+ // function keeps one implementation of "what Arabic looks like".
87
+ words: buildVocabulary(),
88
+ });
89
+ return { replaced };
90
+ }
91
+ catch (error) {
92
+ // Never swallowed into a plausible-looking zero. "0 strings replaced"
93
+ // reads as "this page had no text", and a caller that believed it would
94
+ // report a page tested with Arabic that never saw any — so the reason
95
+ // comes back and the tool says which of the two happened.
96
+ return { replaced: 0, error: (error instanceof Error ? error.message : String(error)).split("\n")[0] };
97
+ }
98
+ }
99
+ /**
100
+ * Pre-build the replacement strings the page will choose from.
101
+ *
102
+ * The in-page code cannot call `arabicFor` (it lives in Node and would have to
103
+ * be serialised along with its imports), so instead the Node side builds one
104
+ * replacement per length bucket and the page picks the bucket that fits the
105
+ * string it is replacing. Same vocabulary, same determinism, one implementation.
106
+ */
107
+ function buildVocabulary() {
108
+ // One entry per length from 1 to the longest string worth matching. Index
109
+ // `n` holds Arabic text of about `n` characters, so the page's lookup is a
110
+ // single array access on the original's length.
111
+ const lengths = [];
112
+ for (let length = 0; length <= 160; length += 1) {
113
+ lengths.push(arabicFor("x".repeat(Math.max(1, length)), length));
114
+ }
115
+ return lengths;
116
+ }
117
+ /**
118
+ * Measure every laid-out element on the page.
119
+ *
120
+ * "Laid out" is doing a lot of work: elements with no box, no size, or no
121
+ * visibility are skipped, because an element that is not rendered cannot have
122
+ * mirrored and reporting it would be noise. So are the structural wrappers
123
+ * that carry no text and exactly one child — a `<div>` whose only job is to
124
+ * hold another `<div>` has no independent layout to get wrong, and including
125
+ * them would multiply every real finding by the depth of the tree it sits in.
126
+ */
127
+ export async function measureElements(page, options = {}) {
128
+ const raw = await page.evaluate(collectMeasurements, {
129
+ root: options.selector ?? "",
130
+ exclude: options.exclude ?? "",
131
+ max_scan: MAX_RTL_SCAN,
132
+ max_elements: Math.min(options.max_elements ?? MAX_RTL_ELEMENTS, MAX_RTL_ELEMENTS),
133
+ max_text: MAX_RTL_TEXT_LENGTH,
134
+ max_selector: MAX_RTL_SELECTOR_LENGTH,
135
+ });
136
+ return raw;
137
+ }
138
+ /**
139
+ * Pair up two measurements of the same page.
140
+ *
141
+ * An element is only paired when its structural key appears exactly once in
142
+ * each pass. A key that appears twice in either pass is ambiguous — pairing it
143
+ * by order would be a guess, and a wrong guess invents a mirroring bug out of
144
+ * two different elements — so it is dropped along with anything that appears
145
+ * in only one direction. `unpaired` counts what was dropped so the report can
146
+ * say so rather than silently measuring less than it claims.
147
+ */
148
+ export function pairMeasurements(ltr, rtl) {
149
+ const ltrByKey = indexUnique(ltr);
150
+ const rtlByKey = indexUnique(rtl);
151
+ const pairs = [];
152
+ for (const [key, left] of ltrByKey) {
153
+ const right = rtlByKey.get(key);
154
+ if (right)
155
+ pairs.push({ ltr: left, rtl: right });
156
+ }
157
+ const unpaired = ltr.length + rtl.length - pairs.length * 2;
158
+ return { pairs, unpaired: Math.max(0, unpaired) };
159
+ }
160
+ /** Keys seen more than once are removed entirely — an ambiguous pairing is worse than none. */
161
+ function indexUnique(items) {
162
+ const byKey = new Map();
163
+ const duplicates = new Set();
164
+ for (const item of items) {
165
+ if (byKey.has(item.key)) {
166
+ duplicates.add(item.key);
167
+ continue;
168
+ }
169
+ byKey.set(item.key, item);
170
+ }
171
+ for (const key of duplicates)
172
+ byKey.delete(key);
173
+ return byKey;
174
+ }
175
+ function applyTrigger(request) {
176
+ const doc = globalThis.document;
177
+ if (!doc)
178
+ return;
179
+ let node = null;
180
+ try {
181
+ node = request.target === "html" ? doc.documentElement : doc.querySelector(request.target);
182
+ }
183
+ catch {
184
+ node = null;
185
+ }
186
+ if (!node)
187
+ node = doc.documentElement;
188
+ if (!node)
189
+ return;
190
+ if (request.attr !== "") {
191
+ try {
192
+ node.setAttribute(request.attr, request.value);
193
+ }
194
+ catch {
195
+ // A frozen attribute map; the caller's direction check will catch it.
196
+ }
197
+ }
198
+ if (request.class_name !== "") {
199
+ try {
200
+ node.classList.add(request.class_name);
201
+ }
202
+ catch {
203
+ // Same.
204
+ }
205
+ }
206
+ }
207
+ function replaceWithArabic(request) {
208
+ const doc = globalThis.document;
209
+ if (!doc || !doc.body)
210
+ return 0;
211
+ // Declared inside the function, not at module scope: this body is
212
+ // serialised and evaluated inside Chromium, where nothing from this module
213
+ // exists. A reference to an outer constant here is a ReferenceError in the
214
+ // page — and one that `injectArabic`'s catch would turn into a silent "0
215
+ // strings replaced", which is the worst kind of failure this tool can have.
216
+ const skip = new Set(["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE", "CODE", "PRE", "SVG"]);
217
+ let excluded = [];
218
+ if (request.exclude !== "") {
219
+ try {
220
+ excluded = Array.prototype.slice.call(doc.querySelectorAll(request.exclude));
221
+ }
222
+ catch {
223
+ excluded = [];
224
+ }
225
+ }
226
+ const isExcluded = (node) => {
227
+ for (const root of excluded) {
228
+ try {
229
+ if (root === node || root.contains(node))
230
+ return true;
231
+ }
232
+ catch {
233
+ // A node from another document; not excluded.
234
+ }
235
+ }
236
+ return false;
237
+ };
238
+ // Same string, same replacement, wherever it appears — so a nav label that
239
+ // occurs twice does not become two different words and change the layout.
240
+ const pick = (original) => {
241
+ const trimmed = original.trim();
242
+ if (trimmed === "")
243
+ return original;
244
+ if (!/\p{L}/u.test(trimmed)) {
245
+ const digits = ["٠", "١", "٢", "٣", "٤", "٥", "٦", "٧", "٨", "٩"];
246
+ return original.replace(/[0-9]/g, (d) => digits[Number(d)]);
247
+ }
248
+ const index = Math.min(trimmed.length, request.words.length - 1);
249
+ const built = request.words[index] ?? request.words[request.words.length - 1];
250
+ const leading = original.slice(0, original.length - original.trimStart().length);
251
+ const trailing = original.slice(original.trimEnd().length);
252
+ return `${leading}${built}${trailing}`;
253
+ };
254
+ let replaced = 0;
255
+ // Text nodes.
256
+ const walker = doc.createTreeWalker(doc.body, 4 /* NodeFilter.SHOW_TEXT */);
257
+ const nodes = [];
258
+ let current = walker.nextNode();
259
+ while (current && nodes.length < request.max_nodes) {
260
+ nodes.push(current);
261
+ current = walker.nextNode();
262
+ }
263
+ for (const node of nodes) {
264
+ const parent = node.parentElement;
265
+ if (!parent)
266
+ continue;
267
+ if (skip.has(String(parent.tagName ?? "").toUpperCase()))
268
+ continue;
269
+ if (isExcluded(parent))
270
+ continue;
271
+ const text = String(node.nodeValue ?? "");
272
+ if (text.trim() === "")
273
+ continue;
274
+ try {
275
+ node.nodeValue = pick(text);
276
+ replaced += 1;
277
+ }
278
+ catch {
279
+ // Read-only node; skip it.
280
+ }
281
+ }
282
+ // Form fields: a placeholder and a value are text the user reads too, and a
283
+ // form is where RTL text most often goes wrong.
284
+ let fields = [];
285
+ try {
286
+ fields = Array.prototype.slice.call(doc.querySelectorAll("input,textarea,[placeholder]"));
287
+ }
288
+ catch {
289
+ fields = [];
290
+ }
291
+ for (const field of fields) {
292
+ if (isExcluded(field))
293
+ continue;
294
+ const type = String(field.type ?? "").toLowerCase();
295
+ // Only free text. A date, a number or a colour has a syntax, and Arabic
296
+ // is not it — the browser would simply reject the value.
297
+ if (type !== "" && type !== "text" && type !== "search" && type !== "textarea")
298
+ continue;
299
+ try {
300
+ const placeholder = String(field.placeholder ?? "");
301
+ if (placeholder.trim() !== "") {
302
+ field.placeholder = pick(placeholder);
303
+ replaced += 1;
304
+ }
305
+ const value = String(field.value ?? "");
306
+ if (value.trim() !== "") {
307
+ field.value = pick(value);
308
+ replaced += 1;
309
+ }
310
+ }
311
+ catch {
312
+ // A controlled component that refuses the write; skip it.
313
+ }
314
+ }
315
+ return replaced;
316
+ }
317
+ function collectMeasurements(request) {
318
+ const doc = globalThis.document;
319
+ const view = doc?.defaultView;
320
+ if (!doc || !doc.body || !view)
321
+ return [];
322
+ let root = doc.body;
323
+ if (request.root !== "") {
324
+ try {
325
+ root = doc.querySelector(request.root) ?? doc.body;
326
+ }
327
+ catch {
328
+ root = doc.body;
329
+ }
330
+ }
331
+ let excluded = [];
332
+ if (request.exclude !== "") {
333
+ try {
334
+ excluded = Array.prototype.slice.call(doc.querySelectorAll(request.exclude));
335
+ }
336
+ catch {
337
+ excluded = [];
338
+ }
339
+ }
340
+ const viewportWidth = Math.round(doc.documentElement?.clientWidth ?? view.innerWidth ?? 0);
341
+ const scrollX = view.scrollX ?? 0;
342
+ const scrollY = view.scrollY ?? 0;
343
+ const elide = (value, max) => {
344
+ const text = String(value ?? "").replace(/\s+/g, " ").trim();
345
+ return text.length <= max ? text : `${text.slice(0, Math.max(0, max - 1))}…`;
346
+ };
347
+ /**
348
+ * A CSS selector for the element.
349
+ *
350
+ * An id wins outright; otherwise the path is walked up to the nearest
351
+ * identified ancestor with `:nth-child` at each step. This is for the
352
+ * *report* and the highlight overlay — the pairing uses `keyFor` instead,
353
+ * because a selector is not stable across a reload when the page renders a
354
+ * different number of siblings.
355
+ */
356
+ const selectorFor = (node) => {
357
+ const id = String(node.id ?? "");
358
+ if (id !== "" && /^[A-Za-z][\w-]*$/.test(id))
359
+ return `#${id}`;
360
+ const parts = [];
361
+ let current = node;
362
+ let depth = 0;
363
+ while (current && current.nodeType === 1 && depth < 6) {
364
+ const tag = String(current.tagName ?? "").toLowerCase();
365
+ const currentId = String(current.id ?? "");
366
+ if (currentId !== "" && /^[A-Za-z][\w-]*$/.test(currentId)) {
367
+ parts.unshift(`#${currentId}`);
368
+ break;
369
+ }
370
+ const parent = current.parentElement;
371
+ if (!parent) {
372
+ parts.unshift(tag);
373
+ break;
374
+ }
375
+ let index = 1;
376
+ let sibling = current.previousElementSibling;
377
+ while (sibling) {
378
+ index += 1;
379
+ sibling = sibling.previousElementSibling;
380
+ }
381
+ parts.unshift(`${tag}:nth-child(${index})`);
382
+ current = parent;
383
+ depth += 1;
384
+ }
385
+ return elide(parts.join(" > "), request.max_selector);
386
+ };
387
+ /**
388
+ * The identity that survives a reload.
389
+ *
390
+ * Tag, id, classes and sibling path — everything about *what* the element
391
+ * is, and nothing about where it ended up, since where it ended up is the
392
+ * measurement under test. Classes are sorted so a framework that reorders
393
+ * them between renders does not break the pairing.
394
+ */
395
+ const keyFor = (node) => {
396
+ const parts = [];
397
+ let current = node;
398
+ let depth = 0;
399
+ while (current && current.nodeType === 1 && depth < 12) {
400
+ const tag = String(current.tagName ?? "").toLowerCase();
401
+ const id = String(current.id ?? "");
402
+ if (id !== "") {
403
+ parts.unshift(`#${id}`);
404
+ break;
405
+ }
406
+ let classes = "";
407
+ try {
408
+ classes = Array.prototype.slice
409
+ .call(current.classList ?? [])
410
+ .map((c) => String(c))
411
+ // A class that carries the direction is not part of the element's
412
+ // identity — it is the thing being toggled between the two passes.
413
+ .filter((c) => !/^(rtl|ltr|dir-rtl|dir-ltr)$/i.test(c))
414
+ .sort()
415
+ .join(".");
416
+ }
417
+ catch {
418
+ classes = "";
419
+ }
420
+ let index = 1;
421
+ let sibling = current.previousElementSibling;
422
+ while (sibling) {
423
+ index += 1;
424
+ sibling = sibling.previousElementSibling;
425
+ }
426
+ parts.unshift(`${tag}${classes === "" ? "" : `.${classes}`}[${index}]`);
427
+ current = current.parentElement;
428
+ depth += 1;
429
+ }
430
+ return parts.join("/");
431
+ };
432
+ /** Text belonging to this element itself, not to its descendants. */
433
+ const ownText = (node) => {
434
+ let text = "";
435
+ for (const child of Array.prototype.slice.call(node.childNodes ?? [])) {
436
+ if (child.nodeType === 3)
437
+ text += String(child.nodeValue ?? "");
438
+ }
439
+ return text;
440
+ };
441
+ /**
442
+ * Would an author expect this to be mirrored?
443
+ *
444
+ * Named by what it is — an arrow, a chevron, a "next" control — rather than
445
+ * by what it looks like, because deciding from pixels whether a glyph is
446
+ * directional is not something worth guessing at. A false positive here is
447
+ * only a warning, and the evidence is printed next to it.
448
+ */
449
+ const looksDirectional = (node) => {
450
+ const haystack = [
451
+ String(node.className ?? ""),
452
+ String(node.id ?? ""),
453
+ String(node.getAttribute?.("aria-label") ?? ""),
454
+ String(node.getAttribute?.("data-icon") ?? ""),
455
+ ]
456
+ .join(" ")
457
+ .toLowerCase();
458
+ if (haystack === "")
459
+ return false;
460
+ return /(^|[\s_-])(arrow|chevron|caret|next|prev|previous|back|forward|angle)([\s_-]|$)|arrow-|chevron-/.test(haystack);
461
+ };
462
+ const out = [];
463
+ const walker = doc.createTreeWalker(root, 1 /* NodeFilter.SHOW_ELEMENT */);
464
+ let node = root.nodeType === 1 ? root : walker.nextNode();
465
+ let scanned = 0;
466
+ while (node && scanned < request.max_scan && out.length < request.max_elements) {
467
+ scanned += 1;
468
+ const element = node;
469
+ node = walker.nextNode();
470
+ const tag = String(element.tagName ?? "").toLowerCase();
471
+ if (tag === "script" || tag === "style" || tag === "noscript" || tag === "template")
472
+ continue;
473
+ let skip = false;
474
+ for (const root2 of excluded) {
475
+ try {
476
+ if (root2 === element || root2.contains(element)) {
477
+ skip = true;
478
+ break;
479
+ }
480
+ }
481
+ catch {
482
+ // Not excluded.
483
+ }
484
+ }
485
+ if (skip)
486
+ continue;
487
+ let rect;
488
+ let style;
489
+ try {
490
+ rect = element.getBoundingClientRect();
491
+ style = view.getComputedStyle(element);
492
+ }
493
+ catch {
494
+ continue;
495
+ }
496
+ if (!rect || !style)
497
+ continue;
498
+ // Not rendered: nothing to mirror, and reporting it would be noise.
499
+ if (rect.width <= 0 || rect.height <= 0)
500
+ continue;
501
+ if (style.visibility === "hidden" || style.display === "none" || Number(style.opacity) === 0)
502
+ continue;
503
+ const text = ownText(element);
504
+ const childCount = element.childElementCount ?? 0;
505
+ // A wrapper with one child and no text of its own has no independent
506
+ // layout to get wrong; its child is measured instead.
507
+ if (text.trim() === "" && childCount === 1 && tag !== "img" && tag !== "svg")
508
+ continue;
509
+ const x = rect.left + scrollX;
510
+ const y = rect.top + scrollY;
511
+ const right = rect.left + rect.width;
512
+ out.push({
513
+ key: keyFor(element),
514
+ selector: selectorFor(element),
515
+ match_index: 0,
516
+ tag,
517
+ text: elide(text, request.max_text),
518
+ x,
519
+ y,
520
+ width: rect.width,
521
+ height: rect.height,
522
+ viewport_width: viewportWidth,
523
+ text_align: String(style.textAlign ?? ""),
524
+ direction: String(style.direction ?? ""),
525
+ flex_direction: String(style.display ?? "").includes("flex") ? String(style.flexDirection ?? "") : "",
526
+ padding_left: parseFloat(style.paddingLeft) || 0,
527
+ padding_right: parseFloat(style.paddingRight) || 0,
528
+ margin_left: parseFloat(style.marginLeft) || 0,
529
+ margin_right: parseFloat(style.marginRight) || 0,
530
+ scroll_width: Math.round(element.scrollWidth ?? 0),
531
+ client_width: Math.round(element.clientWidth ?? 0),
532
+ overflow_right: Math.max(0, Math.round(right - viewportWidth)),
533
+ overflow_left: Math.max(0, Math.round(-rect.left)),
534
+ transform: String(style.transform ?? "none"),
535
+ ...(looksDirectional(element) ? { mirrorable: true } : {}),
536
+ });
537
+ }
538
+ return out;
539
+ }
540
+ //# sourceMappingURL=rtl.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rtl.js","sourceRoot":"","sources":["../../src/engine/rtl.ts"],"names":[],"mappings":"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","sourcesContent":["import type { Page } from \"playwright\";\nimport {\n MAX_ARABIC_INJECTION_NODES,\n MAX_RTL_ELEMENTS,\n MAX_RTL_SCAN,\n MAX_RTL_SELECTOR_LENGTH,\n MAX_RTL_TEXT_LENGTH,\n} from \"../constants.js\";\nimport { arabicFor } from \"../utils/arabic-text.js\";\nimport type { ElementMeasurement, ElementPair } from \"../utils/rtl-rules.js\";\n\n/**\n * RTL engine.\n *\n * Puts a page into RTL, optionally replaces its text with Arabic, and measures\n * every laid-out element. Nothing here decides what is *wrong*; that is\n * `utils/rtl-rules.ts`, which is pure and unit-tested without a browser.\n *\n * The whole design rests on one idea: an RTL bug is something that failed to\n * change. So the same page is measured twice — once as it ships, once flipped\n * — and the two measurements are paired element by element. That pairing is\n * the hard part, because the two renders are two separate page loads: element\n * identity has to survive a reload, and it cannot be an ElementHandle, an\n * index, or a position (position is precisely the thing under test).\n *\n * `keyFor` solves that with a structural key — tag, id, class, and the\n * element's path among its siblings — computed identically in both passes.\n * Anything that does not appear in both is simply dropped: an element that\n * exists in only one direction is a page that renders differently, which is\n * interesting but is not a mirroring bug, and guessing at a pairing would\n * invent findings.\n */\n\n/* ── How the page is put into RTL ─────────────────────────────────────── */\n\n/**\n * How to make the page render right-to-left.\n *\n * Four triggers, because apps do this four different ways and picking the\n * wrong one silently measures the page twice in LTR — which reports no\n * findings at all and looks exactly like a page with no RTL bugs. That failure\n * is why `verifyDirection` exists.\n */\nexport type RtlTrigger =\n | { type: \"attribute\"; attr: string; value: string; target: string }\n | { type: \"class\"; class: string; target: string }\n | { type: \"locale\"; locale: string }\n | { type: \"url\"; rtl_url: string };\n\n/** The trigger used when the caller names none: the way the platform itself defines RTL. */\nexport const DEFAULT_RTL_TRIGGER: RtlTrigger = {\n type: \"attribute\",\n attr: \"dir\",\n value: \"rtl\",\n target: \"html\",\n};\n\n/**\n * Apply the trigger to an already-loaded page.\n *\n * `url` is not applied here — it is a different page and is navigated to by\n * the caller before this is reached. `locale` likewise is a context option,\n * fixed when the browser context is created; what this does for it is set\n * `dir` as well, because a locale alone changes number and date formatting but\n * does not flip the layout: `Accept-Language: ar` does not put a page in RTL,\n * and a tool that assumed it did would report a clean bill of health on a\n * completely broken page.\n */\nexport async function applyRtlTrigger(page: Page, trigger: RtlTrigger): Promise<void> {\n if (trigger.type === \"url\") return;\n\n const request =\n trigger.type === \"attribute\"\n ? { target: trigger.target, attr: trigger.attr, value: trigger.value, class_name: \"\" }\n : trigger.type === \"class\"\n ? { target: trigger.target, attr: \"\", value: \"\", class_name: trigger.class }\n : // A locale is carried by the context; the layout still has to be flipped.\n { target: \"html\", attr: \"dir\", value: \"rtl\", class_name: \"\" };\n\n await page.evaluate(applyTrigger, request);\n}\n\n/**\n * Check that the page really is rendering right-to-left.\n *\n * The single most damaging way this tool can fail is quietly: a trigger that\n * does not match the app under test measures LTR twice, finds every element\n * identical, and reports that the page has no RTL problems. That is worse than\n * an error, because it is a confident wrong answer. So the direction that\n * actually took effect is read back off the document and the body, and the\n * caller refuses to report anything if it is still `ltr`.\n */\nexport async function readDirection(page: Page): Promise<{ html: string; body: string }> {\n try {\n return await page.evaluate(() => {\n const doc = (globalThis as any).document;\n const view = doc?.defaultView;\n const read = (node: any): string => {\n if (!node || !view?.getComputedStyle) return \"\";\n try {\n return String(view.getComputedStyle(node).direction ?? \"\");\n } catch {\n return \"\";\n }\n };\n return { html: read(doc?.documentElement), body: read(doc?.body) };\n });\n } catch {\n return { html: \"\", body: \"\" };\n }\n}\n\n/* ── Arabic injection ─────────────────────────────────────────────────── */\n\n/**\n * Replace every visible text node with Arabic of about the same length.\n *\n * Run in *both* passes, not just the RTL one. That is not obvious and it is\n * essential: the two passes are compared to each other, so they have to differ\n * in exactly one variable. If only the RTL pass got Arabic, every box on the\n * page would change width for reasons of text metrics rather than direction,\n * and the mirroring comparison — which asks whether a box moved — would be\n * measuring the font, not the layout.\n *\n * Text inside `<script>`, `<style>` and friends is left alone, as is anything\n * inside an element the caller excluded. Input `value`s and `placeholder`s are\n * replaced too, since a form is where RTL text is most likely to be mishandled.\n */\nexport async function injectArabic(page: Page, exclude?: string): Promise<InjectionResult> {\n try {\n const replaced = await page.evaluate(replaceWithArabic, {\n exclude: exclude ?? \"\",\n max_nodes: MAX_ARABIC_INJECTION_NODES,\n // The generator is pure and lives in Node, so the vocabulary has to be\n // carried into the page. Passing the built strings instead of the\n // function keeps one implementation of \"what Arabic looks like\".\n words: buildVocabulary(),\n });\n return { replaced };\n } catch (error) {\n // Never swallowed into a plausible-looking zero. \"0 strings replaced\"\n // reads as \"this page had no text\", and a caller that believed it would\n // report a page tested with Arabic that never saw any — so the reason\n // comes back and the tool says which of the two happened.\n return { replaced: 0, error: (error instanceof Error ? error.message : String(error)).split(\"\\n\")[0] };\n }\n}\n\n/** What an injection did, or why it did nothing. */\nexport interface InjectionResult {\n replaced: number;\n /** Set when the injection could not run at all — never confused with \"no text to replace\". */\n error?: string;\n}\n\n/**\n * Pre-build the replacement strings the page will choose from.\n *\n * The in-page code cannot call `arabicFor` (it lives in Node and would have to\n * be serialised along with its imports), so instead the Node side builds one\n * replacement per length bucket and the page picks the bucket that fits the\n * string it is replacing. Same vocabulary, same determinism, one implementation.\n */\nfunction buildVocabulary(): string[] {\n // One entry per length from 1 to the longest string worth matching. Index\n // `n` holds Arabic text of about `n` characters, so the page's lookup is a\n // single array access on the original's length.\n const lengths: string[] = [];\n for (let length = 0; length <= 160; length += 1) {\n lengths.push(arabicFor(\"x\".repeat(Math.max(1, length)), length));\n }\n return lengths;\n}\n\n/* ── Measuring ────────────────────────────────────────────────────────── */\n\nexport interface MeasureOptions {\n /** Only measure inside this. Defaults to the whole document. */\n selector?: string;\n /** Never measure this, or anything inside it. */\n exclude?: string;\n /** Stop after this many elements. */\n max_elements?: number;\n}\n\n/**\n * Measure every laid-out element on the page.\n *\n * \"Laid out\" is doing a lot of work: elements with no box, no size, or no\n * visibility are skipped, because an element that is not rendered cannot have\n * mirrored and reporting it would be noise. So are the structural wrappers\n * that carry no text and exactly one child — a `<div>` whose only job is to\n * hold another `<div>` has no independent layout to get wrong, and including\n * them would multiply every real finding by the depth of the tree it sits in.\n */\nexport async function measureElements(page: Page, options: MeasureOptions = {}): Promise<ElementMeasurement[]> {\n const raw = await page.evaluate(collectMeasurements, {\n root: options.selector ?? \"\",\n exclude: options.exclude ?? \"\",\n max_scan: MAX_RTL_SCAN,\n max_elements: Math.min(options.max_elements ?? MAX_RTL_ELEMENTS, MAX_RTL_ELEMENTS),\n max_text: MAX_RTL_TEXT_LENGTH,\n max_selector: MAX_RTL_SELECTOR_LENGTH,\n });\n return raw;\n}\n\n/**\n * Pair up two measurements of the same page.\n *\n * An element is only paired when its structural key appears exactly once in\n * each pass. A key that appears twice in either pass is ambiguous — pairing it\n * by order would be a guess, and a wrong guess invents a mirroring bug out of\n * two different elements — so it is dropped along with anything that appears\n * in only one direction. `unpaired` counts what was dropped so the report can\n * say so rather than silently measuring less than it claims.\n */\nexport function pairMeasurements(\n ltr: readonly ElementMeasurement[],\n rtl: readonly ElementMeasurement[],\n): { pairs: ElementPair[]; unpaired: number } {\n const ltrByKey = indexUnique(ltr);\n const rtlByKey = indexUnique(rtl);\n\n const pairs: ElementPair[] = [];\n for (const [key, left] of ltrByKey) {\n const right = rtlByKey.get(key);\n if (right) pairs.push({ ltr: left, rtl: right });\n }\n\n const unpaired = ltr.length + rtl.length - pairs.length * 2;\n return { pairs, unpaired: Math.max(0, unpaired) };\n}\n\n/** Keys seen more than once are removed entirely — an ambiguous pairing is worse than none. */\nfunction indexUnique(items: readonly ElementMeasurement[]): Map<string, ElementMeasurement> {\n const byKey = new Map<string, ElementMeasurement>();\n const duplicates = new Set<string>();\n for (const item of items) {\n if (byKey.has(item.key)) {\n duplicates.add(item.key);\n continue;\n }\n byKey.set(item.key, item);\n }\n for (const key of duplicates) byKey.delete(key);\n return byKey;\n}\n\n/* ── In-page ──────────────────────────────────────────────────────────────\n * Everything below runs inside Chromium, so it is written against\n * `globalThis` and untyped nodes: this package is compiled with the Node lib\n * only, and the page it lands in may have patched half of these properties.\n */\n\ninterface TriggerRequest {\n target: string;\n attr: string;\n value: string;\n class_name: string;\n}\n\nfunction applyTrigger(request: TriggerRequest): void {\n const doc = (globalThis as any).document;\n if (!doc) return;\n let node: any = null;\n try {\n node = request.target === \"html\" ? doc.documentElement : doc.querySelector(request.target);\n } catch {\n node = null;\n }\n if (!node) node = doc.documentElement;\n if (!node) return;\n\n if (request.attr !== \"\") {\n try {\n node.setAttribute(request.attr, request.value);\n } catch {\n // A frozen attribute map; the caller's direction check will catch it.\n }\n }\n if (request.class_name !== \"\") {\n try {\n node.classList.add(request.class_name);\n } catch {\n // Same.\n }\n }\n}\n\ninterface InjectRequest {\n exclude: string;\n max_nodes: number;\n words: string[];\n}\n\nfunction replaceWithArabic(request: InjectRequest): number {\n const doc = (globalThis as any).document;\n if (!doc || !doc.body) return 0;\n\n // Declared inside the function, not at module scope: this body is\n // serialised and evaluated inside Chromium, where nothing from this module\n // exists. A reference to an outer constant here is a ReferenceError in the\n // page — and one that `injectArabic`'s catch would turn into a silent \"0\n // strings replaced\", which is the worst kind of failure this tool can have.\n const skip = new Set([\"SCRIPT\", \"STYLE\", \"NOSCRIPT\", \"TEMPLATE\", \"CODE\", \"PRE\", \"SVG\"]);\n let excluded: any[] = [];\n if (request.exclude !== \"\") {\n try {\n excluded = Array.prototype.slice.call(doc.querySelectorAll(request.exclude));\n } catch {\n excluded = [];\n }\n }\n\n const isExcluded = (node: any): boolean => {\n for (const root of excluded) {\n try {\n if (root === node || root.contains(node)) return true;\n } catch {\n // A node from another document; not excluded.\n }\n }\n return false;\n };\n\n // Same string, same replacement, wherever it appears — so a nav label that\n // occurs twice does not become two different words and change the layout.\n const pick = (original: string): string => {\n const trimmed = original.trim();\n if (trimmed === \"\") return original;\n if (!/\\p{L}/u.test(trimmed)) {\n const digits = [\"٠\", \"١\", \"٢\", \"٣\", \"٤\", \"٥\", \"٦\", \"٧\", \"٨\", \"٩\"];\n return original.replace(/[0-9]/g, (d: string) => digits[Number(d)]);\n }\n const index = Math.min(trimmed.length, request.words.length - 1);\n const built = request.words[index] ?? request.words[request.words.length - 1];\n const leading = original.slice(0, original.length - original.trimStart().length);\n const trailing = original.slice(original.trimEnd().length);\n return `${leading}${built}${trailing}`;\n };\n\n let replaced = 0;\n\n // Text nodes.\n const walker = doc.createTreeWalker(doc.body, 4 /* NodeFilter.SHOW_TEXT */);\n const nodes: any[] = [];\n let current = walker.nextNode();\n while (current && nodes.length < request.max_nodes) {\n nodes.push(current);\n current = walker.nextNode();\n }\n\n for (const node of nodes) {\n const parent = node.parentElement;\n if (!parent) continue;\n if (skip.has(String(parent.tagName ?? \"\").toUpperCase())) continue;\n if (isExcluded(parent)) continue;\n const text = String(node.nodeValue ?? \"\");\n if (text.trim() === \"\") continue;\n try {\n node.nodeValue = pick(text);\n replaced += 1;\n } catch {\n // Read-only node; skip it.\n }\n }\n\n // Form fields: a placeholder and a value are text the user reads too, and a\n // form is where RTL text most often goes wrong.\n let fields: any[] = [];\n try {\n fields = Array.prototype.slice.call(doc.querySelectorAll(\"input,textarea,[placeholder]\"));\n } catch {\n fields = [];\n }\n for (const field of fields) {\n if (isExcluded(field)) continue;\n const type = String(field.type ?? \"\").toLowerCase();\n // Only free text. A date, a number or a colour has a syntax, and Arabic\n // is not it — the browser would simply reject the value.\n if (type !== \"\" && type !== \"text\" && type !== \"search\" && type !== \"textarea\") continue;\n try {\n const placeholder = String(field.placeholder ?? \"\");\n if (placeholder.trim() !== \"\") {\n field.placeholder = pick(placeholder);\n replaced += 1;\n }\n const value = String(field.value ?? \"\");\n if (value.trim() !== \"\") {\n field.value = pick(value);\n replaced += 1;\n }\n } catch {\n // A controlled component that refuses the write; skip it.\n }\n }\n\n return replaced;\n}\n\ninterface CollectRequest {\n root: string;\n exclude: string;\n max_scan: number;\n max_elements: number;\n max_text: number;\n max_selector: number;\n}\n\nfunction collectMeasurements(request: CollectRequest): ElementMeasurement[] {\n const doc = (globalThis as any).document;\n const view = doc?.defaultView;\n if (!doc || !doc.body || !view) return [];\n\n let root: any = doc.body;\n if (request.root !== \"\") {\n try {\n root = doc.querySelector(request.root) ?? doc.body;\n } catch {\n root = doc.body;\n }\n }\n\n let excluded: any[] = [];\n if (request.exclude !== \"\") {\n try {\n excluded = Array.prototype.slice.call(doc.querySelectorAll(request.exclude));\n } catch {\n excluded = [];\n }\n }\n\n const viewportWidth = Math.round(doc.documentElement?.clientWidth ?? view.innerWidth ?? 0);\n const scrollX = view.scrollX ?? 0;\n const scrollY = view.scrollY ?? 0;\n\n const elide = (value: string, max: number): string => {\n const text = String(value ?? \"\").replace(/\\s+/g, \" \").trim();\n return text.length <= max ? text : `${text.slice(0, Math.max(0, max - 1))}…`;\n };\n\n /**\n * A CSS selector for the element.\n *\n * An id wins outright; otherwise the path is walked up to the nearest\n * identified ancestor with `:nth-child` at each step. This is for the\n * *report* and the highlight overlay — the pairing uses `keyFor` instead,\n * because a selector is not stable across a reload when the page renders a\n * different number of siblings.\n */\n const selectorFor = (node: any): string => {\n const id = String(node.id ?? \"\");\n if (id !== \"\" && /^[A-Za-z][\\w-]*$/.test(id)) return `#${id}`;\n\n const parts: string[] = [];\n let current: any = node;\n let depth = 0;\n while (current && current.nodeType === 1 && depth < 6) {\n const tag = String(current.tagName ?? \"\").toLowerCase();\n const currentId = String(current.id ?? \"\");\n if (currentId !== \"\" && /^[A-Za-z][\\w-]*$/.test(currentId)) {\n parts.unshift(`#${currentId}`);\n break;\n }\n const parent = current.parentElement;\n if (!parent) {\n parts.unshift(tag);\n break;\n }\n let index = 1;\n let sibling = current.previousElementSibling;\n while (sibling) {\n index += 1;\n sibling = sibling.previousElementSibling;\n }\n parts.unshift(`${tag}:nth-child(${index})`);\n current = parent;\n depth += 1;\n }\n return elide(parts.join(\" > \"), request.max_selector);\n };\n\n /**\n * The identity that survives a reload.\n *\n * Tag, id, classes and sibling path — everything about *what* the element\n * is, and nothing about where it ended up, since where it ended up is the\n * measurement under test. Classes are sorted so a framework that reorders\n * them between renders does not break the pairing.\n */\n const keyFor = (node: any): string => {\n const parts: string[] = [];\n let current: any = node;\n let depth = 0;\n while (current && current.nodeType === 1 && depth < 12) {\n const tag = String(current.tagName ?? \"\").toLowerCase();\n const id = String(current.id ?? \"\");\n if (id !== \"\") {\n parts.unshift(`#${id}`);\n break;\n }\n let classes = \"\";\n try {\n classes = Array.prototype.slice\n .call(current.classList ?? [])\n .map((c: any) => String(c))\n // A class that carries the direction is not part of the element's\n // identity — it is the thing being toggled between the two passes.\n .filter((c: string) => !/^(rtl|ltr|dir-rtl|dir-ltr)$/i.test(c))\n .sort()\n .join(\".\");\n } catch {\n classes = \"\";\n }\n let index = 1;\n let sibling = current.previousElementSibling;\n while (sibling) {\n index += 1;\n sibling = sibling.previousElementSibling;\n }\n parts.unshift(`${tag}${classes === \"\" ? \"\" : `.${classes}`}[${index}]`);\n current = current.parentElement;\n depth += 1;\n }\n return parts.join(\"/\");\n };\n\n /** Text belonging to this element itself, not to its descendants. */\n const ownText = (node: any): string => {\n let text = \"\";\n for (const child of Array.prototype.slice.call(node.childNodes ?? [])) {\n if (child.nodeType === 3) text += String(child.nodeValue ?? \"\");\n }\n return text;\n };\n\n /**\n * Would an author expect this to be mirrored?\n *\n * Named by what it is — an arrow, a chevron, a \"next\" control — rather than\n * by what it looks like, because deciding from pixels whether a glyph is\n * directional is not something worth guessing at. A false positive here is\n * only a warning, and the evidence is printed next to it.\n */\n const looksDirectional = (node: any): boolean => {\n const haystack = [\n String(node.className ?? \"\"),\n String(node.id ?? \"\"),\n String(node.getAttribute?.(\"aria-label\") ?? \"\"),\n String(node.getAttribute?.(\"data-icon\") ?? \"\"),\n ]\n .join(\" \")\n .toLowerCase();\n if (haystack === \"\") return false;\n return /(^|[\\s_-])(arrow|chevron|caret|next|prev|previous|back|forward|angle)([\\s_-]|$)|arrow-|chevron-/.test(\n haystack,\n );\n };\n\n const out: ElementMeasurement[] = [];\n const walker = doc.createTreeWalker(root, 1 /* NodeFilter.SHOW_ELEMENT */);\n let node: any = root.nodeType === 1 ? root : walker.nextNode();\n let scanned = 0;\n\n while (node && scanned < request.max_scan && out.length < request.max_elements) {\n scanned += 1;\n const element = node;\n node = walker.nextNode();\n\n const tag = String(element.tagName ?? \"\").toLowerCase();\n if (tag === \"script\" || tag === \"style\" || tag === \"noscript\" || tag === \"template\") continue;\n\n let skip = false;\n for (const root2 of excluded) {\n try {\n if (root2 === element || root2.contains(element)) {\n skip = true;\n break;\n }\n } catch {\n // Not excluded.\n }\n }\n if (skip) continue;\n\n let rect: any;\n let style: any;\n try {\n rect = element.getBoundingClientRect();\n style = view.getComputedStyle(element);\n } catch {\n continue;\n }\n if (!rect || !style) continue;\n // Not rendered: nothing to mirror, and reporting it would be noise.\n if (rect.width <= 0 || rect.height <= 0) continue;\n if (style.visibility === \"hidden\" || style.display === \"none\" || Number(style.opacity) === 0) continue;\n\n const text = ownText(element);\n const childCount = element.childElementCount ?? 0;\n // A wrapper with one child and no text of its own has no independent\n // layout to get wrong; its child is measured instead.\n if (text.trim() === \"\" && childCount === 1 && tag !== \"img\" && tag !== \"svg\") continue;\n\n const x = rect.left + scrollX;\n const y = rect.top + scrollY;\n const right = rect.left + rect.width;\n\n out.push({\n key: keyFor(element),\n selector: selectorFor(element),\n match_index: 0,\n tag,\n text: elide(text, request.max_text),\n x,\n y,\n width: rect.width,\n height: rect.height,\n viewport_width: viewportWidth,\n text_align: String(style.textAlign ?? \"\"),\n direction: String(style.direction ?? \"\"),\n flex_direction: String(style.display ?? \"\").includes(\"flex\") ? String(style.flexDirection ?? \"\") : \"\",\n padding_left: parseFloat(style.paddingLeft) || 0,\n padding_right: parseFloat(style.paddingRight) || 0,\n margin_left: parseFloat(style.marginLeft) || 0,\n margin_right: parseFloat(style.marginRight) || 0,\n scroll_width: Math.round(element.scrollWidth ?? 0),\n client_width: Math.round(element.clientWidth ?? 0),\n overflow_right: Math.max(0, Math.round(right - viewportWidth)),\n overflow_left: Math.max(0, Math.round(-rect.left)),\n transform: String(style.transform ?? \"none\"),\n ...(looksDirectional(element) ? { mirrorable: true } : {}),\n });\n }\n\n return out;\n}\n"]}