framewatch-mcp-server 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +895 -15
- package/dist/constants.d.ts +274 -0
- package/dist/constants.js +279 -0
- package/dist/constants.js.map +1 -1
- package/dist/engine/browser.d.ts +20 -4
- package/dist/engine/browser.js +26 -9
- package/dist/engine/browser.js.map +1 -1
- package/dist/engine/clicks.d.ts +221 -0
- package/dist/engine/clicks.js +801 -0
- package/dist/engine/clicks.js.map +1 -0
- package/dist/engine/forms.d.ts +137 -0
- package/dist/engine/forms.js +474 -0
- package/dist/engine/forms.js.map +1 -0
- package/dist/engine/hmr.d.ts +41 -0
- package/dist/engine/hmr.js +91 -0
- package/dist/engine/hmr.js.map +1 -0
- package/dist/engine/inspect.d.ts +31 -0
- package/dist/engine/inspect.js +383 -0
- package/dist/engine/inspect.js.map +1 -0
- package/dist/engine/interaction.d.ts +12 -7
- package/dist/engine/interaction.js +110 -18
- package/dist/engine/interaction.js.map +1 -1
- package/dist/engine/links.d.ts +134 -0
- package/dist/engine/links.js +384 -0
- package/dist/engine/links.js.map +1 -0
- package/dist/engine/mocks.d.ts +53 -0
- package/dist/engine/mocks.js +148 -0
- package/dist/engine/mocks.js.map +1 -0
- package/dist/engine/rtl.d.ts +129 -0
- package/dist/engine/rtl.js +540 -0
- package/dist/engine/rtl.js.map +1 -0
- package/dist/engine/seo.d.ts +189 -0
- package/dist/engine/seo.js +398 -0
- package/dist/engine/seo.js.map +1 -0
- package/dist/engine/snapshot.d.ts +29 -0
- package/dist/engine/snapshot.js +10 -0
- package/dist/engine/snapshot.js.map +1 -0
- package/dist/engine/vue.d.ts +54 -0
- package/dist/engine/vue.js +419 -0
- package/dist/engine/vue.js.map +1 -0
- package/dist/index.js +45 -1
- package/dist/index.js.map +1 -1
- package/dist/tools/accessibility.d.ts +4 -0
- package/dist/tools/accessibility.js +9 -2
- package/dist/tools/accessibility.js.map +1 -1
- package/dist/tools/api-mock.d.ts +405 -0
- package/dist/tools/api-mock.js +186 -0
- package/dist/tools/api-mock.js.map +1 -0
- package/dist/tools/capture.d.ts +90 -26
- package/dist/tools/capture.js +109 -58
- package/dist/tools/capture.js.map +1 -1
- package/dist/tools/compare.d.ts +4 -0
- package/dist/tools/compare.js +16 -5
- package/dist/tools/compare.js.map +1 -1
- package/dist/tools/dead-clicks.d.ts +128 -0
- package/dist/tools/dead-clicks.js +570 -0
- package/dist/tools/dead-clicks.js.map +1 -0
- package/dist/tools/form-test.d.ts +112 -0
- package/dist/tools/form-test.js +477 -0
- package/dist/tools/form-test.js.map +1 -0
- package/dist/tools/index.d.ts +17 -1
- package/dist/tools/index.js +45 -1
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/inspect.d.ts +78 -0
- package/dist/tools/inspect.js +136 -0
- package/dist/tools/inspect.js.map +1 -0
- package/dist/tools/interact.d.ts +37 -18
- package/dist/tools/interact.js +113 -13
- package/dist/tools/interact.js.map +1 -1
- package/dist/tools/links.d.ts +129 -0
- package/dist/tools/links.js +640 -0
- package/dist/tools/links.js.map +1 -0
- package/dist/tools/responsive.d.ts +10 -6
- package/dist/tools/responsive.js +21 -4
- package/dist/tools/responsive.js.map +1 -1
- package/dist/tools/rtl.d.ts +241 -0
- package/dist/tools/rtl.js +410 -0
- package/dist/tools/rtl.js.map +1 -0
- package/dist/tools/save-auth.d.ts +263 -0
- package/dist/tools/save-auth.js +253 -0
- package/dist/tools/save-auth.js.map +1 -0
- package/dist/tools/screenshot.d.ts +4 -0
- package/dist/tools/screenshot.js +15 -4
- package/dist/tools/screenshot.js.map +1 -1
- package/dist/tools/seo.d.ts +113 -0
- package/dist/tools/seo.js +281 -0
- package/dist/tools/seo.js.map +1 -0
- package/dist/tools/snapshot.d.ts +122 -0
- package/dist/tools/snapshot.js +183 -0
- package/dist/tools/snapshot.js.map +1 -0
- package/dist/tools/wait-for.d.ts +107 -0
- package/dist/tools/wait-for.js +167 -0
- package/dist/tools/wait-for.js.map +1 -0
- package/dist/utils/arabic-text.d.ts +14 -0
- package/dist/utils/arabic-text.js +193 -0
- package/dist/utils/arabic-text.js.map +1 -0
- package/dist/utils/budget.d.ts +41 -0
- package/dist/utils/budget.js +182 -0
- package/dist/utils/budget.js.map +1 -0
- package/dist/utils/format.d.ts +11 -1
- package/dist/utils/format.js +27 -4
- package/dist/utils/format.js.map +1 -1
- package/dist/utils/highlight.d.ts +69 -0
- package/dist/utils/highlight.js +181 -0
- package/dist/utils/highlight.js.map +1 -0
- package/dist/utils/link-rules.d.ts +100 -0
- package/dist/utils/link-rules.js +284 -0
- package/dist/utils/link-rules.js.map +1 -0
- package/dist/utils/mock-rules.d.ts +144 -0
- package/dist/utils/mock-rules.js +224 -0
- package/dist/utils/mock-rules.js.map +1 -0
- package/dist/utils/rtl-rules.d.ts +142 -0
- package/dist/utils/rtl-rules.js +296 -0
- package/dist/utils/rtl-rules.js.map +1 -0
- package/dist/utils/seo-rules.d.ts +129 -0
- package/dist/utils/seo-rules.js +726 -0
- package/dist/utils/seo-rules.js.map +1 -0
- package/dist/utils/snapshot-rules.d.ts +33 -0
- package/dist/utils/snapshot-rules.js +111 -0
- package/dist/utils/snapshot-rules.js.map +1 -0
- package/dist/utils/storage-state.d.ts +76 -0
- package/dist/utils/storage-state.js +195 -0
- package/dist/utils/storage-state.js.map +1 -0
- package/dist/utils/style-rules.d.ts +107 -0
- package/dist/utils/style-rules.js +223 -0
- package/dist/utils/style-rules.js.map +1 -0
- package/dist/utils/test-data.d.ts +75 -0
- package/dist/utils/test-data.js +294 -0
- package/dist/utils/test-data.js.map +1 -0
- package/dist/utils/vue-rules.d.ts +72 -0
- package/dist/utils/vue-rules.js +108 -0
- package/dist/utils/vue-rules.js.map +1 -0
- package/package.json +6 -4
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { DEFAULT_RTL_ELEMENTS, DEFAULT_RTL_WAIT_MS, DEFAULT_VIEWPORT, HIGHLIGHT_RTL_PROBLEM_COLOUR, HIGHLIGHT_RTL_WARNING_COLOUR, MAX_HIGHLIGHTS, MAX_RTL_ELEMENTS, MAX_RTL_ISSUES_PER_ELEMENT, MAX_RTL_LISTED, MAX_VIEWPORT_HEIGHT, MAX_VIEWPORT_WIDTH, NAVIGATION_TIMEOUT_MS, SELECTOR_TIMEOUT_MS, } from "../constants.js";
|
|
3
|
+
import { withPage } from "../engine/browser.js";
|
|
4
|
+
import { DEFAULT_RTL_TRIGGER, applyRtlTrigger, injectArabic, measureElements, pairMeasurements, readDirection, } from "../engine/rtl.js";
|
|
5
|
+
import { resizeForOutput, toBase64 } from "../utils/image.js";
|
|
6
|
+
import { highlightElements } from "../utils/highlight.js";
|
|
7
|
+
import { buildFindings, describeIssueKind } from "../utils/rtl-rules.js";
|
|
8
|
+
import { resolveStorageState, storageStateField, withAuthNote } from "../utils/storage-state.js";
|
|
9
|
+
export const RTL_TOOL_NAME = "framewatch_rtl";
|
|
10
|
+
/**
|
|
11
|
+
* The four ways an app is put into RTL.
|
|
12
|
+
*
|
|
13
|
+
* A discriminated union rather than a free-form object, so an agent that picks
|
|
14
|
+
* the wrong shape is told which field is missing instead of having its trigger
|
|
15
|
+
* silently ignored — which would measure the page twice in LTR and report a
|
|
16
|
+
* clean bill of health on a broken page.
|
|
17
|
+
*/
|
|
18
|
+
const triggerSchema = z
|
|
19
|
+
.discriminatedUnion("type", [
|
|
20
|
+
z.object({
|
|
21
|
+
type: z.literal("attribute"),
|
|
22
|
+
attr: z.string().min(1).default("dir").describe("Attribute to set, e.g. `dir`"),
|
|
23
|
+
value: z.string().min(1).default("rtl").describe("Value to set it to"),
|
|
24
|
+
target: z.string().min(1).default("html").describe("CSS selector for the element to set it on"),
|
|
25
|
+
}),
|
|
26
|
+
z.object({
|
|
27
|
+
type: z.literal("class"),
|
|
28
|
+
class: z.string().min(1).describe("Class to add, e.g. `rtl` — for apps that switch on a class"),
|
|
29
|
+
target: z.string().min(1).default("html"),
|
|
30
|
+
}),
|
|
31
|
+
z.object({
|
|
32
|
+
type: z.literal("locale"),
|
|
33
|
+
locale: z
|
|
34
|
+
.string()
|
|
35
|
+
.min(2)
|
|
36
|
+
.default("ar")
|
|
37
|
+
.describe("BCP-47 locale for the browser context, e.g. `ar`. `dir=rtl` is set as well — a locale alone does not flip a layout"),
|
|
38
|
+
}),
|
|
39
|
+
z.object({
|
|
40
|
+
type: z.literal("url"),
|
|
41
|
+
rtl_url: z.string().url().describe("A separate URL that serves the RTL version, e.g. https://example.com/ar/"),
|
|
42
|
+
}),
|
|
43
|
+
])
|
|
44
|
+
.describe("How to put the page into RTL. Defaults to setting `dir=rtl` on <html>");
|
|
45
|
+
export const rtlInputShape = {
|
|
46
|
+
url: z
|
|
47
|
+
.string()
|
|
48
|
+
.url()
|
|
49
|
+
.describe("URL to test, e.g. http://localhost:3000 (http, https and file URLs are accepted)"),
|
|
50
|
+
rtl_trigger: triggerSchema.optional(),
|
|
51
|
+
inject_arabic: z
|
|
52
|
+
.boolean()
|
|
53
|
+
.default(true)
|
|
54
|
+
.describe("Replace visible text with Arabic of about the same length, in BOTH passes, so the comparison changes " +
|
|
55
|
+
"direction and nothing else. Turn it off to test a page that is already translated."),
|
|
56
|
+
selector: z.string().optional().describe("Only measure inside this container"),
|
|
57
|
+
exclude: z.string().optional().describe("Never measure this, or anything inside it"),
|
|
58
|
+
wait_ms: z
|
|
59
|
+
.number()
|
|
60
|
+
.int()
|
|
61
|
+
.min(0)
|
|
62
|
+
.default(DEFAULT_RTL_WAIT_MS)
|
|
63
|
+
.describe("Wait time (ms) after each page load before the page is measured — paid twice, once per direction"),
|
|
64
|
+
wait_for: z.string().optional().describe("CSS selector to wait for (visible) after each load"),
|
|
65
|
+
wait_for_timeout_ms: z
|
|
66
|
+
.number()
|
|
67
|
+
.int()
|
|
68
|
+
.min(1)
|
|
69
|
+
.default(SELECTOR_TIMEOUT_MS)
|
|
70
|
+
.describe("Max time (ms) to wait for `wait_for` to appear (must be > 0)"),
|
|
71
|
+
max_elements: z
|
|
72
|
+
.number()
|
|
73
|
+
.int()
|
|
74
|
+
.min(1)
|
|
75
|
+
.max(MAX_RTL_ELEMENTS)
|
|
76
|
+
.default(DEFAULT_RTL_ELEMENTS)
|
|
77
|
+
.describe("Elements measured in each direction"),
|
|
78
|
+
full_page: z.boolean().default(false).describe("Screenshot the whole page rather than just the viewport"),
|
|
79
|
+
viewport: z
|
|
80
|
+
.object({
|
|
81
|
+
width: z.number().int().min(1).max(MAX_VIEWPORT_WIDTH).default(DEFAULT_VIEWPORT.width),
|
|
82
|
+
height: z.number().int().min(1).max(MAX_VIEWPORT_HEIGHT).default(DEFAULT_VIEWPORT.height),
|
|
83
|
+
})
|
|
84
|
+
.optional(),
|
|
85
|
+
storage_state: storageStateField,
|
|
86
|
+
};
|
|
87
|
+
export const rtlInputSchema = z.object(rtlInputShape);
|
|
88
|
+
/**
|
|
89
|
+
* Test a page in LTR and RTL and report what failed to mirror.
|
|
90
|
+
*
|
|
91
|
+
* The page is loaded twice — once as it ships, once flipped — and every
|
|
92
|
+
* element is measured in both. A finding is something that *did not change*
|
|
93
|
+
* when the LTR measurement proves it should have: a box that sits at the same
|
|
94
|
+
* x in both directions, text still hugging the left edge, physical padding
|
|
95
|
+
* that never swapped. Plus the one thing that should not change and did:
|
|
96
|
+
* content that fits in LTR and hangs off the edge in RTL, where it is silently
|
|
97
|
+
* cropped and therefore invisible in a screenshot.
|
|
98
|
+
*
|
|
99
|
+
* Nothing is judged from the RTL rendering alone, which is what separates this
|
|
100
|
+
* from a linter. `text-align: left` is correct on a code block and a number
|
|
101
|
+
* column; `padding-left` is correct on anything that should not mirror. Only
|
|
102
|
+
* the comparison can tell a deliberate physical value from a forgotten one,
|
|
103
|
+
* and a report that cannot tell them apart is one nobody reads twice.
|
|
104
|
+
*/
|
|
105
|
+
export async function testRtl(rawInput) {
|
|
106
|
+
const parsed = rtlInputSchema.safeParse(rawInput);
|
|
107
|
+
if (!parsed.success) {
|
|
108
|
+
const issues = parsed.error.issues.map((i) => `${i.path.join(".") || "input"}: ${i.message}`).join("; ");
|
|
109
|
+
return errorResult(`RTL test failed: invalid input — ${issues}`);
|
|
110
|
+
}
|
|
111
|
+
const input = parsed.data;
|
|
112
|
+
const trigger = input.rtl_trigger ?? DEFAULT_RTL_TRIGGER;
|
|
113
|
+
let storageState;
|
|
114
|
+
let auth = null;
|
|
115
|
+
try {
|
|
116
|
+
auth = await resolveStorageState(input.storage_state);
|
|
117
|
+
storageState = auth?.state;
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
return errorResult(`RTL test of ${input.url} failed: ${describeRtlFailure(input, error)}`);
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
// The two passes run one after the other rather than at once, so both get
|
|
124
|
+
// an unloaded machine and the same `wait_ms` means the same thing for
|
|
125
|
+
// each. A difference in the result has to come from the direction, not
|
|
126
|
+
// from which pass was competing for the CPU — and every finding here is a
|
|
127
|
+
// difference between the two.
|
|
128
|
+
const ltr = await runPass(input, trigger, storageState, false);
|
|
129
|
+
// The RTL pass carries the LTR measurements in, so it can judge and draw
|
|
130
|
+
// the overlay on the page it actually measured — see `Judged`.
|
|
131
|
+
const { pass: rtl, judged } = await runRtlPass(input, trigger, storageState, ltr.measurements);
|
|
132
|
+
return withAuthNote(await render(input, trigger, ltr, rtl, judged), auth);
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
return errorResult(`RTL test of ${input.url} failed: ${describeRtlFailure(input, error)}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* The LTR pass: load the page as it ships, measure it, photograph it.
|
|
140
|
+
*
|
|
141
|
+
* No overlay is ever drawn here — the findings are reported against the RTL
|
|
142
|
+
* rendering, and the LTR shot is the reference the reader compares it to.
|
|
143
|
+
*/
|
|
144
|
+
async function runPass(input, trigger, storageState, isRtl) {
|
|
145
|
+
return withPass(input, trigger, storageState, isRtl, async (page, pass) => {
|
|
146
|
+
const png = await safeScreenshot(page, input.full_page);
|
|
147
|
+
return { ...pass, ...(png ? { png } : {}) };
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* The RTL pass: everything `runPass` does, and then the judging.
|
|
152
|
+
*
|
|
153
|
+
* The pairing and the verdicts happen while this page is still open so the
|
|
154
|
+
* overlay can be drawn on the very rendering that was measured, and the boxes
|
|
155
|
+
* therefore land where the measurements said they would.
|
|
156
|
+
*/
|
|
157
|
+
async function runRtlPass(input, trigger, storageState, ltrMeasurements) {
|
|
158
|
+
let judged = { findings: [], pairs: 0, unpaired: 0 };
|
|
159
|
+
const pass = await withPass(input, trigger, storageState, true, async (page, measured) => {
|
|
160
|
+
const { pairs, unpaired } = pairMeasurements(ltrMeasurements, measured.measurements);
|
|
161
|
+
const findings = buildFindings(pairs);
|
|
162
|
+
judged = { findings, pairs: pairs.length, unpaired };
|
|
163
|
+
// Drawn only now: the overlay is a DOM mutation, and everything that
|
|
164
|
+
// needed measuring has been measured.
|
|
165
|
+
if (findings.length > 0) {
|
|
166
|
+
await highlightElements(page, highlightsFor(findings));
|
|
167
|
+
}
|
|
168
|
+
const png = await safeScreenshot(page, input.full_page);
|
|
169
|
+
return { ...measured, ...(png ? { png } : {}) };
|
|
170
|
+
});
|
|
171
|
+
return { pass, judged };
|
|
172
|
+
}
|
|
173
|
+
/** Load the page, optionally flip it, inject Arabic, measure — then hand the open page to `then`. */
|
|
174
|
+
async function withPass(input, trigger, storageState, isRtl, then) {
|
|
175
|
+
const options = {
|
|
176
|
+
viewport: input.viewport ?? { ...DEFAULT_VIEWPORT },
|
|
177
|
+
contextOptions: {
|
|
178
|
+
...(storageState ? { storageState } : {}),
|
|
179
|
+
// A locale is a context option: it has to be set before the page loads,
|
|
180
|
+
// because the app reads it on boot to decide what to render.
|
|
181
|
+
...(isRtl && trigger.type === "locale" ? { locale: trigger.locale } : {}),
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
// The `url` trigger is a different page, not a different rendering of this
|
|
185
|
+
// one — so it is navigated to rather than applied.
|
|
186
|
+
const target = isRtl && trigger.type === "url" ? trigger.rtl_url : input.url;
|
|
187
|
+
return withPage(options, async (page) => {
|
|
188
|
+
await page.goto(target, { waitUntil: "load", timeout: NAVIGATION_TIMEOUT_MS });
|
|
189
|
+
if (isRtl && trigger.type !== "url") {
|
|
190
|
+
// Applied before `wait_for` and the settle: an app that lays itself out
|
|
191
|
+
// on boot has to see the direction it is meant to render in, and one
|
|
192
|
+
// that watches for the attribute gets the whole settle to react.
|
|
193
|
+
await applyRtlTrigger(page, trigger);
|
|
194
|
+
}
|
|
195
|
+
if (input.wait_for) {
|
|
196
|
+
await page.waitForSelector(input.wait_for, { state: "visible", timeout: input.wait_for_timeout_ms });
|
|
197
|
+
}
|
|
198
|
+
if (input.wait_ms > 0) {
|
|
199
|
+
await page.waitForTimeout(input.wait_ms);
|
|
200
|
+
}
|
|
201
|
+
// Injected in BOTH passes — see engine/rtl.ts. If only the RTL pass got
|
|
202
|
+
// Arabic, every box would change width for reasons of text metrics rather
|
|
203
|
+
// than direction, and the mirroring comparison would be measuring the font.
|
|
204
|
+
const injection = input.inject_arabic
|
|
205
|
+
? await injectArabic(page, input.exclude)
|
|
206
|
+
: { replaced: 0, error: undefined };
|
|
207
|
+
if (injection.replaced > 0) {
|
|
208
|
+
// Give the page a frame to reflow around the new text before measuring.
|
|
209
|
+
await page.waitForTimeout(RELAYOUT_MS);
|
|
210
|
+
}
|
|
211
|
+
const measurements = await measureElements(page, {
|
|
212
|
+
...(input.selector ? { selector: input.selector } : {}),
|
|
213
|
+
...(input.exclude ? { exclude: input.exclude } : {}),
|
|
214
|
+
max_elements: input.max_elements,
|
|
215
|
+
});
|
|
216
|
+
const direction = await readDirection(page);
|
|
217
|
+
return then(page, { measurements, direction, injection, url: page.url() });
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
/** Settle time after the text is swapped, so the page reflows before it is measured. */
|
|
221
|
+
const RELAYOUT_MS = 120;
|
|
222
|
+
/**
|
|
223
|
+
* Build the report.
|
|
224
|
+
*
|
|
225
|
+
* Order is deliberate: the verdict line, then the one thing that invalidates
|
|
226
|
+
* everything else (RTL never took effect), then the findings worst-first, then
|
|
227
|
+
* both screenshots — LTR as it ships, RTL with every finding boxed and
|
|
228
|
+
* numbered to match the list.
|
|
229
|
+
*/
|
|
230
|
+
async function render(input, trigger, ltr, rtl, judged) {
|
|
231
|
+
const { findings, unpaired } = judged;
|
|
232
|
+
const problems = findings.filter((f) => f.severity === "problem");
|
|
233
|
+
const warnings = findings.filter((f) => f.severity === "warning");
|
|
234
|
+
const lines = [];
|
|
235
|
+
const applied = isRtlApplied(rtl);
|
|
236
|
+
lines.push(`Tested ${input.url} in both directions — compared ${judged.pairs} element${judged.pairs === 1 ? "" : "s"}, ` +
|
|
237
|
+
`${problems.length} problem${problems.length === 1 ? "" : "s"}, ` +
|
|
238
|
+
`${warnings.length} warning${warnings.length === 1 ? "" : "s"}.`);
|
|
239
|
+
lines.push(`RTL applied by ${describeTrigger(trigger)}.`);
|
|
240
|
+
if (!applied) {
|
|
241
|
+
// The failure that matters most, because it is silent: a trigger that did
|
|
242
|
+
// not take renders LTR twice, finds every element identical, and looks
|
|
243
|
+
// exactly like a page with no RTL bugs at all.
|
|
244
|
+
lines.push("", "✗ RTL was never applied — the document still computes `direction: " +
|
|
245
|
+
`${rtl.direction.html || "unknown"}\` after the trigger ran. Everything below compares the page with ` +
|
|
246
|
+
"itself, so it proves nothing. Check that `rtl_trigger` matches how this app switches direction " +
|
|
247
|
+
"(an app that switches on a class needs `{ type: \"class\", class: \"…\" }`, one with a separate " +
|
|
248
|
+
'Arabic build needs `{ type: "url", rtl_url: "…" }`).');
|
|
249
|
+
}
|
|
250
|
+
if (input.inject_arabic) {
|
|
251
|
+
const failure = rtl.injection.error ?? ltr.injection.error;
|
|
252
|
+
lines.push(failure !== undefined
|
|
253
|
+
? `! Arabic injection could not run (${failure}) — the page was tested with its own text instead, ` +
|
|
254
|
+
"so nothing here has been checked against real right-to-left content."
|
|
255
|
+
: rtl.injection.replaced > 0
|
|
256
|
+
? `Text replaced with Arabic in both passes (${rtl.injection.replaced} strings in RTL, ` +
|
|
257
|
+
`${ltr.injection.replaced} in LTR), matched to the original lengths so any overflow found is the ` +
|
|
258
|
+
"layout rather than the substitution."
|
|
259
|
+
: "No text was replaced — `inject_arabic` was on but the page had no replaceable text.");
|
|
260
|
+
}
|
|
261
|
+
if (unpaired > 0) {
|
|
262
|
+
lines.push(`${unpaired} element${unpaired === 1 ? " was" : "s were"} measured in only one direction and could not be ` +
|
|
263
|
+
"compared — the page renders a different tree in RTL, which is worth a look on its own.");
|
|
264
|
+
}
|
|
265
|
+
if (findings.length === 0 && applied) {
|
|
266
|
+
lines.push("", "Nothing failed to mirror. Every element that was off-centre in LTR moved to its mirror position, text " +
|
|
267
|
+
"alignment followed the direction, and nothing new overflows the viewport.");
|
|
268
|
+
}
|
|
269
|
+
if (problems.length > 0) {
|
|
270
|
+
lines.push("", `Problems — ${problems.length} (these are visible to an Arabic reader):`);
|
|
271
|
+
lines.push(...renderFindings(problems));
|
|
272
|
+
}
|
|
273
|
+
if (warnings.length > 0) {
|
|
274
|
+
lines.push("", `Warnings — ${warnings.length} (worth checking, may be deliberate):`);
|
|
275
|
+
lines.push(...renderFindings(warnings));
|
|
276
|
+
}
|
|
277
|
+
const byKind = countByKind(findings);
|
|
278
|
+
if (byKind.length > 0) {
|
|
279
|
+
lines.push("", `By kind: ${byKind.map(([kind, n]) => `${describeIssueKind(kind)} ${n}`).join(", ")}.`);
|
|
280
|
+
}
|
|
281
|
+
const content = [{ type: "text", text: lines.join("\n") }];
|
|
282
|
+
if (ltr.png) {
|
|
283
|
+
content.push({ type: "image", data: toBase64(await resizeForOutput(ltr.png)), mimeType: "image/png" });
|
|
284
|
+
content.push({ type: "text", text: `LTR — ${ltr.url} as it ships.` });
|
|
285
|
+
}
|
|
286
|
+
if (rtl.png) {
|
|
287
|
+
content.push({ type: "image", data: toBase64(await resizeForOutput(rtl.png)), mimeType: "image/png" });
|
|
288
|
+
content.push({
|
|
289
|
+
type: "text",
|
|
290
|
+
text: `RTL — ${rtl.url}` +
|
|
291
|
+
(findings.length === 0
|
|
292
|
+
? "."
|
|
293
|
+
: `, with the first ${Math.min(findings.length, MAX_HIGHLIGHTS)} finding${Math.min(findings.length, MAX_HIGHLIGHTS) === 1 ? "" : "s"} boxed and numbered to match the list above — ` +
|
|
294
|
+
`red is a problem, orange a warning.`),
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
return { content };
|
|
298
|
+
}
|
|
299
|
+
/** Each finding as a numbered block: what it is, then one line per issue. */
|
|
300
|
+
function renderFindings(findings) {
|
|
301
|
+
const lines = [];
|
|
302
|
+
for (const finding of findings.slice(0, MAX_RTL_LISTED)) {
|
|
303
|
+
lines.push(` ${finding.index}. ${finding.description} ${finding.selector}`);
|
|
304
|
+
for (const issue of finding.issues.slice(0, MAX_RTL_ISSUES_PER_ELEMENT)) {
|
|
305
|
+
lines.push(` ${issue.severity === "problem" ? "✗" : "!"} ${issue.message}`);
|
|
306
|
+
lines.push(` ${issue.evidence}`);
|
|
307
|
+
}
|
|
308
|
+
const hidden = finding.issues.length - MAX_RTL_ISSUES_PER_ELEMENT;
|
|
309
|
+
if (hidden > 0)
|
|
310
|
+
lines.push(` … and ${hidden} more issue${hidden === 1 ? "" : "s"} on this element`);
|
|
311
|
+
}
|
|
312
|
+
const rest = findings.length - MAX_RTL_LISTED;
|
|
313
|
+
if (rest > 0)
|
|
314
|
+
lines.push(` … and ${rest} more`);
|
|
315
|
+
return lines;
|
|
316
|
+
}
|
|
317
|
+
/** A tally per kind, so a page with one bug repeated 40 times reads as one bug. */
|
|
318
|
+
function countByKind(findings) {
|
|
319
|
+
const counts = new Map();
|
|
320
|
+
for (const finding of findings) {
|
|
321
|
+
for (const issue of finding.issues) {
|
|
322
|
+
counts.set(issue.kind, (counts.get(issue.kind) ?? 0) + 1);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return [...counts.entries()].sort((a, b) => b[1] - a[1]);
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Did the flip take effect?
|
|
329
|
+
*
|
|
330
|
+
* `html` is what the trigger sets and `body` is what the page actually renders
|
|
331
|
+
* in; either one computing to `rtl` is enough, because an app may set the
|
|
332
|
+
* direction on a wrapper rather than the root.
|
|
333
|
+
*/
|
|
334
|
+
function isRtlApplied(rtl) {
|
|
335
|
+
return rtl.direction.html === "rtl" || rtl.direction.body === "rtl";
|
|
336
|
+
}
|
|
337
|
+
function describeTrigger(trigger) {
|
|
338
|
+
switch (trigger.type) {
|
|
339
|
+
case "attribute":
|
|
340
|
+
return `setting \`${trigger.attr}="${trigger.value}"\` on \`${trigger.target}\``;
|
|
341
|
+
case "class":
|
|
342
|
+
return `adding the class \`${trigger.class}\` to \`${trigger.target}\``;
|
|
343
|
+
case "locale":
|
|
344
|
+
return `loading the page with locale \`${trigger.locale}\` and \`dir=rtl\``;
|
|
345
|
+
case "url":
|
|
346
|
+
return `loading ${trigger.rtl_url}`;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
/** A screenshot is worth having but never worth failing the audit for. */
|
|
350
|
+
async function safeScreenshot(page, fullPage) {
|
|
351
|
+
try {
|
|
352
|
+
return await page.screenshot({ type: "png", fullPage });
|
|
353
|
+
}
|
|
354
|
+
catch {
|
|
355
|
+
return undefined;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Draw the findings onto the RTL page before it is photographed.
|
|
360
|
+
*
|
|
361
|
+
* Numbered to match the list, and coloured by severity. Drawn after everything
|
|
362
|
+
* has been measured, because the overlay is itself a DOM mutation.
|
|
363
|
+
*/
|
|
364
|
+
export function highlightsFor(findings) {
|
|
365
|
+
return findings.slice(0, MAX_HIGHLIGHTS).map((finding) => ({
|
|
366
|
+
selector: finding.selector,
|
|
367
|
+
match_index: finding.match_index,
|
|
368
|
+
label: String(finding.index),
|
|
369
|
+
colour: finding.severity === "problem" ? HIGHLIGHT_RTL_PROBLEM_COLOUR : HIGHLIGHT_RTL_WARNING_COLOUR,
|
|
370
|
+
wash: finding.severity === "problem" ? "rgba(229, 25, 75, 0.16)" : "rgba(240, 140, 0, 0.16)",
|
|
371
|
+
}));
|
|
372
|
+
}
|
|
373
|
+
function errorResult(text) {
|
|
374
|
+
return { isError: true, content: [{ type: "text", text }] };
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* One actionable line for a failed run. Mirrors `describeFailure` in
|
|
378
|
+
* screenshot.ts: match on the failing Playwright call, never on substrings of
|
|
379
|
+
* a user-supplied selector.
|
|
380
|
+
*/
|
|
381
|
+
export function describeRtlFailure(input, error) {
|
|
382
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
383
|
+
const firstLine = message.split("\n")[0];
|
|
384
|
+
if (/Executable doesn't exist|browserType\.launch/i.test(message)) {
|
|
385
|
+
return "Playwright's Chromium browser is not installed. Run `npx playwright install chromium` and try again.";
|
|
386
|
+
}
|
|
387
|
+
if (/^page\.goto:/.test(message)) {
|
|
388
|
+
return `could not open the page — ${firstLine}`;
|
|
389
|
+
}
|
|
390
|
+
if (input.wait_for && /^page\.waitForSelector:/.test(message)) {
|
|
391
|
+
return `selector "${input.wait_for}" did not become visible within ${input.wait_for_timeout_ms}ms`;
|
|
392
|
+
}
|
|
393
|
+
return firstLine;
|
|
394
|
+
}
|
|
395
|
+
export function registerRtlTool(server) {
|
|
396
|
+
server.registerTool(RTL_TOOL_NAME, {
|
|
397
|
+
title: "RTL",
|
|
398
|
+
description: "Test a page in LTR and RTL and report what failed to mirror. The page is loaded twice — once as it " +
|
|
399
|
+
"ships, once flipped — every element is measured in both, and a finding is something that did NOT " +
|
|
400
|
+
"change when the LTR measurement proves it should have: a box at the same x in both directions, text " +
|
|
401
|
+
"still hugging the left edge, physical padding that never swapped, a directional icon that did not " +
|
|
402
|
+
"flip. Plus the one thing that should not change and did — content that fits in LTR and hangs off the " +
|
|
403
|
+
"edge in RTL, where it is cropped and invisible in a screenshot. Optionally replaces visible text with " +
|
|
404
|
+
"length-matched Arabic in both passes, so the comparison changes direction and nothing else. Returns " +
|
|
405
|
+
"both screenshots, the RTL one with every finding boxed and numbered.",
|
|
406
|
+
inputSchema: rtlInputShape,
|
|
407
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
408
|
+
}, async (args) => testRtl(args));
|
|
409
|
+
}
|
|
410
|
+
//# sourceMappingURL=rtl.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rtl.js","sourceRoot":"","sources":["../../src/tools/rtl.ts"],"names":[],"mappings":"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","sourcesContent":["import { z } from \"zod\";\nimport type { Page } from \"playwright\";\nimport type { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\";\nimport type { CallToolResult } from \"@modelcontextprotocol/sdk/types.js\";\nimport {\n DEFAULT_RTL_ELEMENTS,\n DEFAULT_RTL_WAIT_MS,\n DEFAULT_VIEWPORT,\n HIGHLIGHT_RTL_PROBLEM_COLOUR,\n HIGHLIGHT_RTL_WARNING_COLOUR,\n MAX_HIGHLIGHTS,\n MAX_RTL_ELEMENTS,\n MAX_RTL_ISSUES_PER_ELEMENT,\n MAX_RTL_LISTED,\n MAX_VIEWPORT_HEIGHT,\n MAX_VIEWPORT_WIDTH,\n NAVIGATION_TIMEOUT_MS,\n SELECTOR_TIMEOUT_MS,\n} from \"../constants.js\";\nimport { withPage } from \"../engine/browser.js\";\nimport {\n DEFAULT_RTL_TRIGGER,\n applyRtlTrigger,\n injectArabic,\n measureElements,\n pairMeasurements,\n readDirection,\n type RtlTrigger,\n} from \"../engine/rtl.js\";\nimport { resizeForOutput, toBase64 } from \"../utils/image.js\";\nimport { highlightElements, type Highlight } from \"../utils/highlight.js\";\nimport { buildFindings, describeIssueKind, type ElementFinding, type RtlIssueKind } from \"../utils/rtl-rules.js\";\nimport { resolveStorageState, storageStateField, withAuthNote, type StorageState } from \"../utils/storage-state.js\";\nimport type { ElementMeasurement } from \"../utils/rtl-rules.js\";\n\nexport const RTL_TOOL_NAME = \"framewatch_rtl\";\n\n/**\n * The four ways an app is put into RTL.\n *\n * A discriminated union rather than a free-form object, so an agent that picks\n * the wrong shape is told which field is missing instead of having its trigger\n * silently ignored — which would measure the page twice in LTR and report a\n * clean bill of health on a broken page.\n */\nconst triggerSchema = z\n .discriminatedUnion(\"type\", [\n z.object({\n type: z.literal(\"attribute\"),\n attr: z.string().min(1).default(\"dir\").describe(\"Attribute to set, e.g. `dir`\"),\n value: z.string().min(1).default(\"rtl\").describe(\"Value to set it to\"),\n target: z.string().min(1).default(\"html\").describe(\"CSS selector for the element to set it on\"),\n }),\n z.object({\n type: z.literal(\"class\"),\n class: z.string().min(1).describe(\"Class to add, e.g. `rtl` — for apps that switch on a class\"),\n target: z.string().min(1).default(\"html\"),\n }),\n z.object({\n type: z.literal(\"locale\"),\n locale: z\n .string()\n .min(2)\n .default(\"ar\")\n .describe(\"BCP-47 locale for the browser context, e.g. `ar`. `dir=rtl` is set as well — a locale alone does not flip a layout\"),\n }),\n z.object({\n type: z.literal(\"url\"),\n rtl_url: z.string().url().describe(\"A separate URL that serves the RTL version, e.g. https://example.com/ar/\"),\n }),\n ])\n .describe(\"How to put the page into RTL. Defaults to setting `dir=rtl` on <html>\");\n\nexport const rtlInputShape = {\n url: z\n .string()\n .url()\n .describe(\"URL to test, e.g. http://localhost:3000 (http, https and file URLs are accepted)\"),\n rtl_trigger: triggerSchema.optional(),\n inject_arabic: z\n .boolean()\n .default(true)\n .describe(\n \"Replace visible text with Arabic of about the same length, in BOTH passes, so the comparison changes \" +\n \"direction and nothing else. Turn it off to test a page that is already translated.\",\n ),\n selector: z.string().optional().describe(\"Only measure inside this container\"),\n exclude: z.string().optional().describe(\"Never measure this, or anything inside it\"),\n wait_ms: z\n .number()\n .int()\n .min(0)\n .default(DEFAULT_RTL_WAIT_MS)\n .describe(\"Wait time (ms) after each page load before the page is measured — paid twice, once per direction\"),\n wait_for: z.string().optional().describe(\"CSS selector to wait for (visible) after each load\"),\n wait_for_timeout_ms: z\n .number()\n .int()\n .min(1)\n .default(SELECTOR_TIMEOUT_MS)\n .describe(\"Max time (ms) to wait for `wait_for` to appear (must be > 0)\"),\n max_elements: z\n .number()\n .int()\n .min(1)\n .max(MAX_RTL_ELEMENTS)\n .default(DEFAULT_RTL_ELEMENTS)\n .describe(\"Elements measured in each direction\"),\n full_page: z.boolean().default(false).describe(\"Screenshot the whole page rather than just the viewport\"),\n viewport: z\n .object({\n width: z.number().int().min(1).max(MAX_VIEWPORT_WIDTH).default(DEFAULT_VIEWPORT.width),\n height: z.number().int().min(1).max(MAX_VIEWPORT_HEIGHT).default(DEFAULT_VIEWPORT.height),\n })\n .optional(),\n storage_state: storageStateField,\n};\n\nexport const rtlInputSchema = z.object(rtlInputShape);\nexport type RtlInput = z.input<typeof rtlInputSchema>;\ntype ParsedRtlInput = z.output<typeof rtlInputSchema>;\n\n/** One direction's pass: what was measured, and the picture of it. */\ninterface Pass {\n measurements: ElementMeasurement[];\n png?: Buffer;\n direction: { html: string; body: string };\n injection: { replaced: number; error?: string };\n url: string;\n}\n\n/**\n * What the RTL pass needs from the LTR one to finish its own work.\n *\n * The overlay has to be drawn on the live RTL page, and what to draw is only\n * known once both passes have been compared — so the RTL pass is handed the\n * LTR measurements and does the pairing, the judging and the highlighting\n * before its context closes. Doing it the other way round would mean\n * reopening and re-rendering the page just to draw boxes on it, and a second\n * render of a page is not guaranteed to lay out identically to the one that\n * was measured.\n */\ninterface Judged {\n findings: ElementFinding[];\n pairs: number;\n unpaired: number;\n}\n\n/**\n * Test a page in LTR and RTL and report what failed to mirror.\n *\n * The page is loaded twice — once as it ships, once flipped — and every\n * element is measured in both. A finding is something that *did not change*\n * when the LTR measurement proves it should have: a box that sits at the same\n * x in both directions, text still hugging the left edge, physical padding\n * that never swapped. Plus the one thing that should not change and did:\n * content that fits in LTR and hangs off the edge in RTL, where it is silently\n * cropped and therefore invisible in a screenshot.\n *\n * Nothing is judged from the RTL rendering alone, which is what separates this\n * from a linter. `text-align: left` is correct on a code block and a number\n * column; `padding-left` is correct on anything that should not mirror. Only\n * the comparison can tell a deliberate physical value from a forgotten one,\n * and a report that cannot tell them apart is one nobody reads twice.\n */\nexport async function testRtl(rawInput: RtlInput): Promise<CallToolResult> {\n const parsed = rtlInputSchema.safeParse(rawInput);\n if (!parsed.success) {\n const issues = parsed.error.issues.map((i) => `${i.path.join(\".\") || \"input\"}: ${i.message}`).join(\"; \");\n return errorResult(`RTL test failed: invalid input — ${issues}`);\n }\n const input = parsed.data;\n const trigger: RtlTrigger = (input.rtl_trigger as RtlTrigger | undefined) ?? DEFAULT_RTL_TRIGGER;\n\n let storageState: StorageState | undefined;\n let auth = null;\n try {\n auth = await resolveStorageState(input.storage_state);\n storageState = auth?.state;\n } catch (error) {\n return errorResult(`RTL test of ${input.url} failed: ${describeRtlFailure(input, error)}`);\n }\n\n try {\n // The two passes run one after the other rather than at once, so both get\n // an unloaded machine and the same `wait_ms` means the same thing for\n // each. A difference in the result has to come from the direction, not\n // from which pass was competing for the CPU — and every finding here is a\n // difference between the two.\n const ltr = await runPass(input, trigger, storageState, false);\n // The RTL pass carries the LTR measurements in, so it can judge and draw\n // the overlay on the page it actually measured — see `Judged`.\n const { pass: rtl, judged } = await runRtlPass(input, trigger, storageState, ltr.measurements);\n return withAuthNote(await render(input, trigger, ltr, rtl, judged), auth);\n } catch (error) {\n return errorResult(`RTL test of ${input.url} failed: ${describeRtlFailure(input, error)}`);\n }\n}\n\n/**\n * The LTR pass: load the page as it ships, measure it, photograph it.\n *\n * No overlay is ever drawn here — the findings are reported against the RTL\n * rendering, and the LTR shot is the reference the reader compares it to.\n */\nasync function runPass(\n input: ParsedRtlInput,\n trigger: RtlTrigger,\n storageState: StorageState | undefined,\n isRtl: boolean,\n): Promise<Pass> {\n return withPass(input, trigger, storageState, isRtl, async (page, pass) => {\n const png = await safeScreenshot(page, input.full_page);\n return { ...pass, ...(png ? { png } : {}) };\n });\n}\n\n/**\n * The RTL pass: everything `runPass` does, and then the judging.\n *\n * The pairing and the verdicts happen while this page is still open so the\n * overlay can be drawn on the very rendering that was measured, and the boxes\n * therefore land where the measurements said they would.\n */\nasync function runRtlPass(\n input: ParsedRtlInput,\n trigger: RtlTrigger,\n storageState: StorageState | undefined,\n ltrMeasurements: readonly ElementMeasurement[],\n): Promise<{ pass: Pass; judged: Judged }> {\n let judged: Judged = { findings: [], pairs: 0, unpaired: 0 };\n\n const pass = await withPass(input, trigger, storageState, true, async (page, measured) => {\n const { pairs, unpaired } = pairMeasurements(ltrMeasurements, measured.measurements);\n const findings = buildFindings(pairs);\n judged = { findings, pairs: pairs.length, unpaired };\n\n // Drawn only now: the overlay is a DOM mutation, and everything that\n // needed measuring has been measured.\n if (findings.length > 0) {\n await highlightElements(page, highlightsFor(findings));\n }\n const png = await safeScreenshot(page, input.full_page);\n return { ...measured, ...(png ? { png } : {}) };\n });\n\n return { pass, judged };\n}\n\n/** Load the page, optionally flip it, inject Arabic, measure — then hand the open page to `then`. */\nasync function withPass(\n input: ParsedRtlInput,\n trigger: RtlTrigger,\n storageState: StorageState | undefined,\n isRtl: boolean,\n then: (page: Page, pass: Pass) => Promise<Pass>,\n): Promise<Pass> {\n const options = {\n viewport: input.viewport ?? { ...DEFAULT_VIEWPORT },\n contextOptions: {\n ...(storageState ? { storageState } : {}),\n // A locale is a context option: it has to be set before the page loads,\n // because the app reads it on boot to decide what to render.\n ...(isRtl && trigger.type === \"locale\" ? { locale: trigger.locale } : {}),\n },\n };\n\n // The `url` trigger is a different page, not a different rendering of this\n // one — so it is navigated to rather than applied.\n const target = isRtl && trigger.type === \"url\" ? trigger.rtl_url : input.url;\n\n return withPage(options, async (page) => {\n await page.goto(target, { waitUntil: \"load\", timeout: NAVIGATION_TIMEOUT_MS });\n\n if (isRtl && trigger.type !== \"url\") {\n // Applied before `wait_for` and the settle: an app that lays itself out\n // on boot has to see the direction it is meant to render in, and one\n // that watches for the attribute gets the whole settle to react.\n await applyRtlTrigger(page, trigger);\n }\n\n if (input.wait_for) {\n await page.waitForSelector(input.wait_for, { state: \"visible\", timeout: input.wait_for_timeout_ms });\n }\n if (input.wait_ms > 0) {\n await page.waitForTimeout(input.wait_ms);\n }\n\n // Injected in BOTH passes — see engine/rtl.ts. If only the RTL pass got\n // Arabic, every box would change width for reasons of text metrics rather\n // than direction, and the mirroring comparison would be measuring the font.\n const injection = input.inject_arabic\n ? await injectArabic(page, input.exclude)\n : { replaced: 0 as number, error: undefined as string | undefined };\n if (injection.replaced > 0) {\n // Give the page a frame to reflow around the new text before measuring.\n await page.waitForTimeout(RELAYOUT_MS);\n }\n\n const measurements = await measureElements(page, {\n ...(input.selector ? { selector: input.selector } : {}),\n ...(input.exclude ? { exclude: input.exclude } : {}),\n max_elements: input.max_elements,\n });\n const direction = await readDirection(page);\n\n return then(page, { measurements, direction, injection, url: page.url() });\n });\n}\n\n/** Settle time after the text is swapped, so the page reflows before it is measured. */\nconst RELAYOUT_MS = 120;\n\n/**\n * Build the report.\n *\n * Order is deliberate: the verdict line, then the one thing that invalidates\n * everything else (RTL never took effect), then the findings worst-first, then\n * both screenshots — LTR as it ships, RTL with every finding boxed and\n * numbered to match the list.\n */\nasync function render(\n input: ParsedRtlInput,\n trigger: RtlTrigger,\n ltr: Pass,\n rtl: Pass,\n judged: Judged,\n): Promise<CallToolResult> {\n const { findings, unpaired } = judged;\n const problems = findings.filter((f) => f.severity === \"problem\");\n const warnings = findings.filter((f) => f.severity === \"warning\");\n\n const lines: string[] = [];\n const applied = isRtlApplied(rtl);\n\n lines.push(\n `Tested ${input.url} in both directions — compared ${judged.pairs} element${judged.pairs === 1 ? \"\" : \"s\"}, ` +\n `${problems.length} problem${problems.length === 1 ? \"\" : \"s\"}, ` +\n `${warnings.length} warning${warnings.length === 1 ? \"\" : \"s\"}.`,\n );\n lines.push(`RTL applied by ${describeTrigger(trigger)}.`);\n\n if (!applied) {\n // The failure that matters most, because it is silent: a trigger that did\n // not take renders LTR twice, finds every element identical, and looks\n // exactly like a page with no RTL bugs at all.\n lines.push(\n \"\",\n \"✗ RTL was never applied — the document still computes `direction: \" +\n `${rtl.direction.html || \"unknown\"}\\` after the trigger ran. Everything below compares the page with ` +\n \"itself, so it proves nothing. Check that `rtl_trigger` matches how this app switches direction \" +\n \"(an app that switches on a class needs `{ type: \\\"class\\\", class: \\\"…\\\" }`, one with a separate \" +\n 'Arabic build needs `{ type: \"url\", rtl_url: \"…\" }`).',\n );\n }\n\n if (input.inject_arabic) {\n const failure = rtl.injection.error ?? ltr.injection.error;\n lines.push(\n failure !== undefined\n ? `! Arabic injection could not run (${failure}) — the page was tested with its own text instead, ` +\n \"so nothing here has been checked against real right-to-left content.\"\n : rtl.injection.replaced > 0\n ? `Text replaced with Arabic in both passes (${rtl.injection.replaced} strings in RTL, ` +\n `${ltr.injection.replaced} in LTR), matched to the original lengths so any overflow found is the ` +\n \"layout rather than the substitution.\"\n : \"No text was replaced — `inject_arabic` was on but the page had no replaceable text.\",\n );\n }\n\n if (unpaired > 0) {\n lines.push(\n `${unpaired} element${unpaired === 1 ? \" was\" : \"s were\"} measured in only one direction and could not be ` +\n \"compared — the page renders a different tree in RTL, which is worth a look on its own.\",\n );\n }\n\n if (findings.length === 0 && applied) {\n lines.push(\n \"\",\n \"Nothing failed to mirror. Every element that was off-centre in LTR moved to its mirror position, text \" +\n \"alignment followed the direction, and nothing new overflows the viewport.\",\n );\n }\n\n if (problems.length > 0) {\n lines.push(\"\", `Problems — ${problems.length} (these are visible to an Arabic reader):`);\n lines.push(...renderFindings(problems));\n }\n if (warnings.length > 0) {\n lines.push(\"\", `Warnings — ${warnings.length} (worth checking, may be deliberate):`);\n lines.push(...renderFindings(warnings));\n }\n\n const byKind = countByKind(findings);\n if (byKind.length > 0) {\n lines.push(\"\", `By kind: ${byKind.map(([kind, n]) => `${describeIssueKind(kind)} ${n}`).join(\", \")}.`);\n }\n\n const content: CallToolResult[\"content\"] = [{ type: \"text\", text: lines.join(\"\\n\") }];\n\n if (ltr.png) {\n content.push({ type: \"image\", data: toBase64(await resizeForOutput(ltr.png)), mimeType: \"image/png\" });\n content.push({ type: \"text\", text: `LTR — ${ltr.url} as it ships.` });\n }\n if (rtl.png) {\n content.push({ type: \"image\", data: toBase64(await resizeForOutput(rtl.png)), mimeType: \"image/png\" });\n content.push({\n type: \"text\",\n text:\n `RTL — ${rtl.url}` +\n (findings.length === 0\n ? \".\"\n : `, with the first ${Math.min(findings.length, MAX_HIGHLIGHTS)} finding${\n Math.min(findings.length, MAX_HIGHLIGHTS) === 1 ? \"\" : \"s\"\n } boxed and numbered to match the list above — ` +\n `red is a problem, orange a warning.`),\n });\n }\n\n return { content };\n}\n\n/** Each finding as a numbered block: what it is, then one line per issue. */\nfunction renderFindings(findings: readonly ElementFinding[]): string[] {\n const lines: string[] = [];\n for (const finding of findings.slice(0, MAX_RTL_LISTED)) {\n lines.push(` ${finding.index}. ${finding.description} ${finding.selector}`);\n for (const issue of finding.issues.slice(0, MAX_RTL_ISSUES_PER_ELEMENT)) {\n lines.push(` ${issue.severity === \"problem\" ? \"✗\" : \"!\"} ${issue.message}`);\n lines.push(` ${issue.evidence}`);\n }\n const hidden = finding.issues.length - MAX_RTL_ISSUES_PER_ELEMENT;\n if (hidden > 0) lines.push(` … and ${hidden} more issue${hidden === 1 ? \"\" : \"s\"} on this element`);\n }\n const rest = findings.length - MAX_RTL_LISTED;\n if (rest > 0) lines.push(` … and ${rest} more`);\n return lines;\n}\n\n/** A tally per kind, so a page with one bug repeated 40 times reads as one bug. */\nfunction countByKind(findings: readonly ElementFinding[]): Array<[RtlIssueKind, number]> {\n const counts = new Map<RtlIssueKind, number>();\n for (const finding of findings) {\n for (const issue of finding.issues) {\n counts.set(issue.kind, (counts.get(issue.kind) ?? 0) + 1);\n }\n }\n return [...counts.entries()].sort((a, b) => b[1] - a[1]);\n}\n\n/**\n * Did the flip take effect?\n *\n * `html` is what the trigger sets and `body` is what the page actually renders\n * in; either one computing to `rtl` is enough, because an app may set the\n * direction on a wrapper rather than the root.\n */\nfunction isRtlApplied(rtl: Pass): boolean {\n return rtl.direction.html === \"rtl\" || rtl.direction.body === \"rtl\";\n}\n\nfunction describeTrigger(trigger: RtlTrigger): string {\n switch (trigger.type) {\n case \"attribute\":\n return `setting \\`${trigger.attr}=\"${trigger.value}\"\\` on \\`${trigger.target}\\``;\n case \"class\":\n return `adding the class \\`${trigger.class}\\` to \\`${trigger.target}\\``;\n case \"locale\":\n return `loading the page with locale \\`${trigger.locale}\\` and \\`dir=rtl\\``;\n case \"url\":\n return `loading ${trigger.rtl_url}`;\n }\n}\n\n/** A screenshot is worth having but never worth failing the audit for. */\nasync function safeScreenshot(page: Page, fullPage: boolean): Promise<Buffer | undefined> {\n try {\n return await page.screenshot({ type: \"png\", fullPage });\n } catch {\n return undefined;\n }\n}\n\n/**\n * Draw the findings onto the RTL page before it is photographed.\n *\n * Numbered to match the list, and coloured by severity. Drawn after everything\n * has been measured, because the overlay is itself a DOM mutation.\n */\nexport function highlightsFor(findings: readonly ElementFinding[]): Highlight[] {\n return findings.slice(0, MAX_HIGHLIGHTS).map((finding) => ({\n selector: finding.selector,\n match_index: finding.match_index,\n label: String(finding.index),\n colour: finding.severity === \"problem\" ? HIGHLIGHT_RTL_PROBLEM_COLOUR : HIGHLIGHT_RTL_WARNING_COLOUR,\n wash:\n finding.severity === \"problem\" ? \"rgba(229, 25, 75, 0.16)\" : \"rgba(240, 140, 0, 0.16)\",\n }));\n}\n\nfunction errorResult(text: string): CallToolResult {\n return { isError: true, content: [{ type: \"text\", text }] };\n}\n\n/**\n * One actionable line for a failed run. Mirrors `describeFailure` in\n * screenshot.ts: match on the failing Playwright call, never on substrings of\n * a user-supplied selector.\n */\nexport function describeRtlFailure(\n input: { url: string; wait_for?: string; wait_for_timeout_ms: number },\n error: unknown,\n): string {\n const message = error instanceof Error ? error.message : String(error);\n const firstLine = message.split(\"\\n\")[0];\n\n if (/Executable doesn't exist|browserType\\.launch/i.test(message)) {\n return \"Playwright's Chromium browser is not installed. Run `npx playwright install chromium` and try again.\";\n }\n if (/^page\\.goto:/.test(message)) {\n return `could not open the page — ${firstLine}`;\n }\n if (input.wait_for && /^page\\.waitForSelector:/.test(message)) {\n return `selector \"${input.wait_for}\" did not become visible within ${input.wait_for_timeout_ms}ms`;\n }\n return firstLine;\n}\n\nexport function registerRtlTool(server: McpServer): void {\n server.registerTool(\n RTL_TOOL_NAME,\n {\n title: \"RTL\",\n description:\n \"Test a page in LTR and RTL and report what failed to mirror. The page is loaded twice — once as it \" +\n \"ships, once flipped — every element is measured in both, and a finding is something that did NOT \" +\n \"change when the LTR measurement proves it should have: a box at the same x in both directions, text \" +\n \"still hugging the left edge, physical padding that never swapped, a directional icon that did not \" +\n \"flip. Plus the one thing that should not change and did — content that fits in LTR and hangs off the \" +\n \"edge in RTL, where it is cropped and invisible in a screenshot. Optionally replaces visible text with \" +\n \"length-matched Arabic in both passes, so the comparison changes direction and nothing else. Returns \" +\n \"both screenshots, the RTL one with every finding boxed and numbered.\",\n inputSchema: rtlInputShape,\n annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },\n },\n async (args) => testRtl(args),\n );\n}\n"]}
|