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,570 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { DEAD_CLICK_TIMEOUT_MS, DEFAULT_DEAD_CLICK_ELEMENTS, DEFAULT_DEAD_CLICK_SETTLE_MS, DEFAULT_DEAD_CLICK_WAIT_MS, DEFAULT_VIEWPORT, HIGHLIGHT_BROKEN_COLOUR, HIGHLIGHT_DEAD_COLOUR, MAX_DEAD_CLICK_EFFECTS, MAX_DEAD_CLICK_ELEMENTS, MAX_DEAD_CLICK_LISTED, MAX_DEAD_CLICK_SELECTOR_LENGTH, MAX_HIGHLIGHTS, MAX_VIEWPORT_HEIGHT, MAX_VIEWPORT_WIDTH, NAVIGATION_TIMEOUT_MS, SELECTOR_TIMEOUT_MS, } from "../constants.js";
|
|
3
|
+
import { withPage } from "../engine/browser.js";
|
|
4
|
+
import { NO_NOISE, describeNoise, diffPageState, discoverClickables, installClickWatcher, measureNoise, probeHover, readPageState, resetClickWatcher, resolveClickable, sameUrl, } from "../engine/clicks.js";
|
|
5
|
+
import { ConsoleCollector, NetworkCollector } from "../engine/layers/index.js";
|
|
6
|
+
import { resizeForOutput, toBase64 } from "../utils/image.js";
|
|
7
|
+
import { highlightElements } from "../utils/highlight.js";
|
|
8
|
+
import { resolveStorageState, storageStateField, withAuthNote } from "../utils/storage-state.js";
|
|
9
|
+
export const DEAD_CLICKS_TOOL_NAME = "framewatch_dead_clicks";
|
|
10
|
+
export const deadClicksInputShape = {
|
|
11
|
+
url: z
|
|
12
|
+
.string()
|
|
13
|
+
.url()
|
|
14
|
+
.describe("URL to sweep, e.g. http://localhost:3000 (http, https and file URLs are accepted)"),
|
|
15
|
+
wait_ms: z
|
|
16
|
+
.number()
|
|
17
|
+
.int()
|
|
18
|
+
.min(0)
|
|
19
|
+
.default(DEFAULT_DEAD_CLICK_WAIT_MS)
|
|
20
|
+
.describe("Wait time (ms) after each page load before anything is clicked. The page is reloaded whenever a click " +
|
|
21
|
+
"changes it, so this is paid once per element that does something — lower it on a fast app."),
|
|
22
|
+
wait_for: z.string().optional().describe("CSS selector to wait for (visible) after each load"),
|
|
23
|
+
wait_for_timeout_ms: z
|
|
24
|
+
.number()
|
|
25
|
+
.int()
|
|
26
|
+
.min(1)
|
|
27
|
+
.default(SELECTOR_TIMEOUT_MS)
|
|
28
|
+
.describe("Max time (ms) to wait for `wait_for` to appear (must be > 0)"),
|
|
29
|
+
settle_ms: z
|
|
30
|
+
.number()
|
|
31
|
+
.int()
|
|
32
|
+
.min(0)
|
|
33
|
+
.default(DEFAULT_DEAD_CLICK_SETTLE_MS)
|
|
34
|
+
.describe("How long to watch the page after each click before deciding nothing happened. Raise it for an app whose " +
|
|
35
|
+
"handlers are slow; every element costs this much time."),
|
|
36
|
+
selector: z
|
|
37
|
+
.string()
|
|
38
|
+
.optional()
|
|
39
|
+
.describe("Only sweep inside this container, e.g. 'nav' or '#app main'. Omit to sweep the whole page."),
|
|
40
|
+
exclude: z
|
|
41
|
+
.string()
|
|
42
|
+
.optional()
|
|
43
|
+
.describe("CSS selector for controls that must never be clicked, and nothing inside them either — 'Delete', " +
|
|
44
|
+
"'Place order', anything that spends money. This tool presses every button it finds."),
|
|
45
|
+
include_pointer: z
|
|
46
|
+
.boolean()
|
|
47
|
+
.default(true)
|
|
48
|
+
.describe("Also test elements that are only clickable-looking because their CSS cursor is a pointer. That is where " +
|
|
49
|
+
"dead clicks actually live (a <div> has no default behaviour to fall back on), so leave it on unless the " +
|
|
50
|
+
"page is full of decorative pointer styling."),
|
|
51
|
+
include_hover: z
|
|
52
|
+
.boolean()
|
|
53
|
+
.default(true)
|
|
54
|
+
.describe("For each dead element, also check whether the page reacts to hovering it. A dead control that lights up " +
|
|
55
|
+
"under the pointer is actively inviting the click that does nothing."),
|
|
56
|
+
max_elements: z
|
|
57
|
+
.number()
|
|
58
|
+
.int()
|
|
59
|
+
.min(1)
|
|
60
|
+
.max(MAX_DEAD_CLICK_ELEMENTS)
|
|
61
|
+
.default(DEFAULT_DEAD_CLICK_ELEMENTS)
|
|
62
|
+
.describe("Maximum elements to click. They are clicked in document order; the rest are counted and named."),
|
|
63
|
+
full_page: z
|
|
64
|
+
.boolean()
|
|
65
|
+
.default(false)
|
|
66
|
+
.describe("Photograph the whole document instead of the viewport, so dead elements below the fold are visible too"),
|
|
67
|
+
viewport: z
|
|
68
|
+
.object({
|
|
69
|
+
width: z.number().int().min(1).max(MAX_VIEWPORT_WIDTH).default(DEFAULT_VIEWPORT.width),
|
|
70
|
+
height: z.number().int().min(1).max(MAX_VIEWPORT_HEIGHT).default(DEFAULT_VIEWPORT.height),
|
|
71
|
+
})
|
|
72
|
+
.optional()
|
|
73
|
+
.describe("Viewport size (defaults to 1280x720)"),
|
|
74
|
+
storage_state: storageStateField,
|
|
75
|
+
};
|
|
76
|
+
export const deadClicksInputSchema = z.object(deadClicksInputShape);
|
|
77
|
+
/**
|
|
78
|
+
* Find the elements on a page that look clickable and do nothing.
|
|
79
|
+
*
|
|
80
|
+
* Every candidate is clicked for real, and the page is then interrogated from
|
|
81
|
+
* every angle at once — URL, DOM mutations, form state, storage, scroll,
|
|
82
|
+
* focus, console, network, dialogs, popups, downloads. An element is only
|
|
83
|
+
* called dead when all of them stayed silent (see engine/clicks.ts).
|
|
84
|
+
*
|
|
85
|
+
* Two things make the answer trustworthy rather than merely plausible. The
|
|
86
|
+
* page is measured once with nobody clicking, so a ticking clock or a polling
|
|
87
|
+
* fetch cannot make every element look alive. And the page is reloaded after
|
|
88
|
+
* any click that changed it, so element five is judged on the page as it
|
|
89
|
+
* shipped rather than on whatever elements one to four left behind — a click
|
|
90
|
+
* that changed nothing needs no reload, which is exactly the case this tool is
|
|
91
|
+
* looking for, so a page full of dead controls is also the fastest to sweep.
|
|
92
|
+
*
|
|
93
|
+
* This is the one read-only-looking tool that is not read-only: it presses
|
|
94
|
+
* every button on the page, including the one that deletes the account. Use
|
|
95
|
+
* `exclude` (or `selector`) on anything that matters.
|
|
96
|
+
*/
|
|
97
|
+
export async function findDeadClicks(rawInput) {
|
|
98
|
+
const parsed = deadClicksInputSchema.safeParse(rawInput);
|
|
99
|
+
if (!parsed.success) {
|
|
100
|
+
const issues = parsed.error.issues.map((i) => `${i.path.join(".") || "input"}: ${i.message}`).join("; ");
|
|
101
|
+
return errorResult(`Dead-click sweep failed: invalid input — ${issues}`);
|
|
102
|
+
}
|
|
103
|
+
const input = parsed.data;
|
|
104
|
+
const viewport = input.viewport ?? { ...DEFAULT_VIEWPORT };
|
|
105
|
+
let contextOptions = {};
|
|
106
|
+
let auth = null;
|
|
107
|
+
try {
|
|
108
|
+
auth = await resolveStorageState(input.storage_state);
|
|
109
|
+
if (auth)
|
|
110
|
+
contextOptions = { storageState: auth.state };
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
return errorResult(`Dead-click sweep of ${input.url} failed: ${firstLine(error)}`);
|
|
114
|
+
}
|
|
115
|
+
let sweep;
|
|
116
|
+
try {
|
|
117
|
+
sweep = await withPage({ viewport, contextOptions }, (page) => runSweep(input, page));
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
return errorResult(describeDeadClicksFailure(input, error));
|
|
121
|
+
}
|
|
122
|
+
return withAuthNote(await render(input, sweep), auth);
|
|
123
|
+
}
|
|
124
|
+
/* ── The sweep ────────────────────────────────────────────────────────── */
|
|
125
|
+
async function runSweep(input, page) {
|
|
126
|
+
const consoleLog = new ConsoleCollector(page).attach();
|
|
127
|
+
const network = new NetworkCollector(page).attach();
|
|
128
|
+
const dialogs = [];
|
|
129
|
+
const popups = [];
|
|
130
|
+
const downloads = [];
|
|
131
|
+
// A page that opens an alert would otherwise be dismissed silently by
|
|
132
|
+
// Playwright, and "it opened a dialog" is the whole answer for that element.
|
|
133
|
+
page.on("dialog", (dialog) => {
|
|
134
|
+
dialogs.push(`${dialog.type()}: ${dialog.message()}`);
|
|
135
|
+
void dialog.dismiss().catch(() => { });
|
|
136
|
+
});
|
|
137
|
+
page.on("popup", (popup) => {
|
|
138
|
+
popups.push(safely(() => popup.url()) ?? "a new tab");
|
|
139
|
+
void popup.close().catch(() => { });
|
|
140
|
+
});
|
|
141
|
+
page.on("download", (download) => {
|
|
142
|
+
downloads.push(download.suggestedFilename());
|
|
143
|
+
void download.cancel().catch(() => { });
|
|
144
|
+
});
|
|
145
|
+
// Before the navigation: the watcher has to be installed at the start of
|
|
146
|
+
// every document, including the first one and every reload after it.
|
|
147
|
+
await installClickWatcher(page);
|
|
148
|
+
try {
|
|
149
|
+
await load(input, page);
|
|
150
|
+
const candidates = await discoverClickables(page, {
|
|
151
|
+
...(input.selector !== undefined ? { selector: input.selector } : {}),
|
|
152
|
+
...(input.exclude !== undefined ? { exclude: input.exclude } : {}),
|
|
153
|
+
include_pointer: input.include_pointer,
|
|
154
|
+
});
|
|
155
|
+
if (candidates.length === 0) {
|
|
156
|
+
throw new Error(input.selector
|
|
157
|
+
? `nothing inside \`${input.selector}\` looks clickable — no link, button, click role, onclick or pointer cursor`
|
|
158
|
+
: "nothing on this page looks clickable — no link, button, click role, onclick or pointer cursor");
|
|
159
|
+
}
|
|
160
|
+
const clear = () => {
|
|
161
|
+
consoleLog.clear();
|
|
162
|
+
network.clear();
|
|
163
|
+
dialogs.length = 0;
|
|
164
|
+
popups.length = 0;
|
|
165
|
+
downloads.length = 0;
|
|
166
|
+
};
|
|
167
|
+
const evidence = (focusSelf, since) => ({
|
|
168
|
+
console: consoleLog.entries(since),
|
|
169
|
+
network: network.events(since),
|
|
170
|
+
dialogs: [...dialogs],
|
|
171
|
+
popups: [...popups],
|
|
172
|
+
downloads: [...downloads],
|
|
173
|
+
focus_self: focusSelf,
|
|
174
|
+
});
|
|
175
|
+
const noise = await measureIdle(page, input, clear, evidence);
|
|
176
|
+
const skipped = candidates.filter((candidate) => candidate.skip !== undefined);
|
|
177
|
+
const testable = candidates.filter((candidate) => candidate.skip === undefined);
|
|
178
|
+
const tested = testable.slice(0, input.max_elements);
|
|
179
|
+
const untested = testable.slice(input.max_elements);
|
|
180
|
+
const verdicts = skipped.map((candidate) => ({
|
|
181
|
+
clickable: candidate,
|
|
182
|
+
effects: [],
|
|
183
|
+
skipped: candidate.skip,
|
|
184
|
+
}));
|
|
185
|
+
let dirty = false;
|
|
186
|
+
for (const candidate of tested) {
|
|
187
|
+
if (dirty) {
|
|
188
|
+
await load(input, page);
|
|
189
|
+
dirty = false;
|
|
190
|
+
}
|
|
191
|
+
const verdict = await testOne(page, input, candidate, clear, evidence, noise);
|
|
192
|
+
verdicts.push(verdict);
|
|
193
|
+
// A click that changed nothing left the page exactly as it was, so there
|
|
194
|
+
// is nothing to restore — which is why a page full of dead controls is
|
|
195
|
+
// the fastest kind to sweep.
|
|
196
|
+
if (verdict.effects.some((effect) => DISTURBING.has(effect.kind)))
|
|
197
|
+
dirty = true;
|
|
198
|
+
}
|
|
199
|
+
// The overlay is drawn on a page nobody has touched: the boxes are a DOM
|
|
200
|
+
// mutation of their own, and the report is about a page in its shipped state.
|
|
201
|
+
await load(input, page);
|
|
202
|
+
const marked = verdicts.filter(isFlagged);
|
|
203
|
+
const highlights = marked.slice(0, MAX_HIGHLIGHTS).map((verdict, index) => ({
|
|
204
|
+
selector: verdict.clickable.selector,
|
|
205
|
+
match_index: verdict.clickable.match_index,
|
|
206
|
+
label: String(index + 1),
|
|
207
|
+
colour: isBroken(verdict) ? HIGHLIGHT_BROKEN_COLOUR : HIGHLIGHT_DEAD_COLOUR,
|
|
208
|
+
wash: isBroken(verdict) ? "rgba(240, 140, 0, 0.16)" : "rgba(229, 25, 75, 0.16)",
|
|
209
|
+
}));
|
|
210
|
+
const drawn = await highlightElements(page, highlights);
|
|
211
|
+
const png = await safeScreenshot(page, input.full_page);
|
|
212
|
+
return {
|
|
213
|
+
verdicts,
|
|
214
|
+
found: candidates.length,
|
|
215
|
+
untested,
|
|
216
|
+
noise,
|
|
217
|
+
...(png ? { png } : {}),
|
|
218
|
+
unhighlighted: marked.length - drawn.drawn.length,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
finally {
|
|
222
|
+
consoleLog.detach();
|
|
223
|
+
network.detach();
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
/** Effects that leave the page in a state the next element must not be judged against. */
|
|
227
|
+
const DISTURBING = new Set(["navigated", "reloaded", "dom", "fields", "storage", "scroll", "title", "dialog"]);
|
|
228
|
+
/** Open the page (or put it back the way it started). */
|
|
229
|
+
async function load(input, page) {
|
|
230
|
+
await page.goto(input.url, { waitUntil: "load", timeout: NAVIGATION_TIMEOUT_MS });
|
|
231
|
+
if (input.wait_for) {
|
|
232
|
+
await page.waitForSelector(input.wait_for, { state: "visible", timeout: input.wait_for_timeout_ms });
|
|
233
|
+
}
|
|
234
|
+
if (input.wait_ms > 0) {
|
|
235
|
+
await page.waitForTimeout(input.wait_ms);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Watch the page for one settle window with nobody clicking.
|
|
240
|
+
*
|
|
241
|
+
* Whatever it does in that window it does by itself, and none of it can be
|
|
242
|
+
* evidence that a click did something. Without this one measurement, a page
|
|
243
|
+
* with a clock in the corner has no dead elements at all.
|
|
244
|
+
*/
|
|
245
|
+
async function measureIdle(page, input, clear, evidence) {
|
|
246
|
+
await resetClickWatcher(page);
|
|
247
|
+
clear();
|
|
248
|
+
const since = Date.now();
|
|
249
|
+
const before = await readPageState(page);
|
|
250
|
+
if (!before)
|
|
251
|
+
return NO_NOISE;
|
|
252
|
+
if (input.settle_ms > 0)
|
|
253
|
+
await page.waitForTimeout(input.settle_ms);
|
|
254
|
+
const after = await readPageState(page);
|
|
255
|
+
if (!after)
|
|
256
|
+
return NO_NOISE;
|
|
257
|
+
return measureNoise(before, after, evidence(true, since));
|
|
258
|
+
}
|
|
259
|
+
/** Click one element and work out what it did. Never throws. */
|
|
260
|
+
async function testOne(page, input, candidate, clear, evidence, noise) {
|
|
261
|
+
const handle = await resolveClickable(page, candidate);
|
|
262
|
+
if (handle === null) {
|
|
263
|
+
return { clickable: candidate, effects: [], skipped: "it was not on the page any more" };
|
|
264
|
+
}
|
|
265
|
+
// Scroll first, then measure: Playwright would scroll to reach the element
|
|
266
|
+
// anyway, and a scroll it caused must not be read back as something the
|
|
267
|
+
// click did.
|
|
268
|
+
await handle.scrollIntoViewIfNeeded({ timeout: DEAD_CLICK_TIMEOUT_MS }).catch(() => { });
|
|
269
|
+
await resetClickWatcher(page);
|
|
270
|
+
clear();
|
|
271
|
+
const since = Date.now();
|
|
272
|
+
const before = await readPageState(page);
|
|
273
|
+
if (before === null) {
|
|
274
|
+
return { clickable: candidate, effects: [], skipped: "the page could not be read before the click" };
|
|
275
|
+
}
|
|
276
|
+
try {
|
|
277
|
+
// Playwright will not click an `aria-disabled` element: it waits for it to
|
|
278
|
+
// become "enabled" and times out. But whether such an element still works
|
|
279
|
+
// is precisely the question — an aria-disabled control with a live handler
|
|
280
|
+
// tells a screen-reader user it is unavailable and everyone else that it
|
|
281
|
+
// is not — so those are clicked with the actionability checks off.
|
|
282
|
+
await handle.click({ timeout: DEAD_CLICK_TIMEOUT_MS, ...(candidate.aria_disabled ? { force: true } : {}) });
|
|
283
|
+
}
|
|
284
|
+
catch (error) {
|
|
285
|
+
// A click that navigates can detach the element underneath Playwright.
|
|
286
|
+
// The navigation proves the click landed, whatever the error says.
|
|
287
|
+
if (sameUrl(safely(() => page.url()) ?? before.url, before.url)) {
|
|
288
|
+
return { clickable: candidate, effects: [], skipped: `could not be clicked — ${firstLine(error)}` };
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
if (input.settle_ms > 0)
|
|
292
|
+
await page.waitForTimeout(input.settle_ms);
|
|
293
|
+
const focusSelf = await focusStayedPut(handle);
|
|
294
|
+
// A page mid-navigation cannot be read; its URL and everything Playwright
|
|
295
|
+
// saw still can, so the comparison falls back to those.
|
|
296
|
+
const after = (await readPageState(page)) ?? { ...before, url: safely(() => page.url()) ?? before.url };
|
|
297
|
+
const effects = diffPageState(before, after, evidence(focusSelf, since), noise);
|
|
298
|
+
const verdict = { clickable: candidate, effects };
|
|
299
|
+
if (effects.length === 0 && input.include_hover) {
|
|
300
|
+
verdict.hover = await probeHover(page, handle, { timeout_ms: DEAD_CLICK_TIMEOUT_MS });
|
|
301
|
+
}
|
|
302
|
+
return verdict;
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Did focus stay on the element that was clicked?
|
|
306
|
+
*
|
|
307
|
+
* Clicking anything focusable focuses it, so that says nothing. Focus landing
|
|
308
|
+
* somewhere else — a `<label>` handing it to its input, a skip link — is a
|
|
309
|
+
* real effect, and for some controls it is the only one.
|
|
310
|
+
*/
|
|
311
|
+
async function focusStayedPut(handle) {
|
|
312
|
+
return handle
|
|
313
|
+
.evaluate((node) => {
|
|
314
|
+
const doc = globalThis.document;
|
|
315
|
+
const active = doc?.activeElement;
|
|
316
|
+
// Focus falling back to <body> is a blur, not a destination.
|
|
317
|
+
if (!active || active === doc?.body || active === doc?.documentElement)
|
|
318
|
+
return true;
|
|
319
|
+
return active === node || (node.contains ? node.contains(active) === true : false);
|
|
320
|
+
})
|
|
321
|
+
.catch(() => true);
|
|
322
|
+
}
|
|
323
|
+
/* ── Verdict shape ────────────────────────────────────────────────────── */
|
|
324
|
+
function isDead(verdict) {
|
|
325
|
+
return verdict.skipped === undefined && verdict.effects.length === 0;
|
|
326
|
+
}
|
|
327
|
+
function isBroken(verdict) {
|
|
328
|
+
return verdict.skipped === undefined && verdict.effects.some((effect) => effect.kind === "error");
|
|
329
|
+
}
|
|
330
|
+
/** Dead or broken — the two kinds the overlay paints and the report leads with. */
|
|
331
|
+
function isFlagged(verdict) {
|
|
332
|
+
return isDead(verdict) || isBroken(verdict);
|
|
333
|
+
}
|
|
334
|
+
/* ── Rendering ────────────────────────────────────────────────────────── */
|
|
335
|
+
async function render(input, sweep) {
|
|
336
|
+
const content = [{ type: "text", text: formatSweep(input, sweep) }];
|
|
337
|
+
if (sweep.png) {
|
|
338
|
+
try {
|
|
339
|
+
content.push({ type: "image", data: toBase64(await resizeForOutput(sweep.png)), mimeType: "image/png" });
|
|
340
|
+
const marked = sweep.verdicts.filter(isFlagged).length;
|
|
341
|
+
content.push({
|
|
342
|
+
type: "text",
|
|
343
|
+
text: marked > 0
|
|
344
|
+
? `${input.url}, with every dead element boxed in red and every broken one in orange, numbered as above.` +
|
|
345
|
+
(sweep.unhighlighted > 0
|
|
346
|
+
? ` ${sweep.unhighlighted} of them could not be boxed — the page had re-rendered by the time the overlay was drawn.`
|
|
347
|
+
: "")
|
|
348
|
+
: `${input.url} — nothing to mark up.`,
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
catch {
|
|
352
|
+
// Measured already; failing to re-encode the frame is not worth failing
|
|
353
|
+
// the sweep for.
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return { content };
|
|
357
|
+
}
|
|
358
|
+
function formatSweep(input, sweep) {
|
|
359
|
+
const dead = sweep.verdicts.filter(isDead);
|
|
360
|
+
const broken = sweep.verdicts.filter(isBroken);
|
|
361
|
+
const alive = sweep.verdicts.filter((verdict) => verdict.skipped === undefined && verdict.effects.length > 0 && !isBroken(verdict));
|
|
362
|
+
const skipped = sweep.verdicts.filter((verdict) => verdict.skipped !== undefined);
|
|
363
|
+
const clicked = dead.length + broken.length + alive.length;
|
|
364
|
+
const lines = [];
|
|
365
|
+
lines.push(`Dead-click sweep of ${input.url} — ${count(sweep.found, "clickable element", "clickable elements")} found, ` +
|
|
366
|
+
`${clicked} clicked, ${dead.length} dead, ${broken.length} broken, ` +
|
|
367
|
+
`${skipped.length + sweep.untested.length} not clicked.`);
|
|
368
|
+
const noise = describeNoise(sweep.noise);
|
|
369
|
+
if (noise !== undefined) {
|
|
370
|
+
lines.push(`This page changes on its own — ${noise} in a ${input.settle_ms}ms window with nobody clicking. That much ` +
|
|
371
|
+
"was subtracted from every element below, so a click has to do more than the page already does.");
|
|
372
|
+
}
|
|
373
|
+
if (clicked === 0) {
|
|
374
|
+
lines.push("Nothing was clicked: every element found was one this tool will not press.");
|
|
375
|
+
}
|
|
376
|
+
else if (dead.length === 0 && broken.length === 0) {
|
|
377
|
+
lines.push(`Every one of the ${clicked} elements that was clicked did something.`);
|
|
378
|
+
}
|
|
379
|
+
const numbered = sweep.verdicts.filter(isFlagged);
|
|
380
|
+
const badge = (verdict) => numbered.indexOf(verdict) + 1;
|
|
381
|
+
if (dead.length > 0) {
|
|
382
|
+
lines.push("");
|
|
383
|
+
lines.push("Dead — nothing at all happened when these were clicked:");
|
|
384
|
+
for (const verdict of dead.slice(0, MAX_DEAD_CLICK_LISTED)) {
|
|
385
|
+
lines.push(` ${badge(verdict)}. ${nameOf(verdict)}`);
|
|
386
|
+
for (const note of deadNotes(verdict))
|
|
387
|
+
lines.push(` ${note}`);
|
|
388
|
+
}
|
|
389
|
+
if (dead.length > MAX_DEAD_CLICK_LISTED) {
|
|
390
|
+
lines.push(` … and ${dead.length - MAX_DEAD_CLICK_LISTED} more dead elements`);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
if (broken.length > 0) {
|
|
394
|
+
lines.push("");
|
|
395
|
+
lines.push("Broken — the handler ran and threw:");
|
|
396
|
+
for (const verdict of broken.slice(0, MAX_DEAD_CLICK_LISTED)) {
|
|
397
|
+
lines.push(` ${badge(verdict)}. ${nameOf(verdict)}`);
|
|
398
|
+
for (const effect of verdict.effects.slice(0, MAX_DEAD_CLICK_EFFECTS)) {
|
|
399
|
+
lines.push(` ${effect.detail}`);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
if (broken.length > MAX_DEAD_CLICK_LISTED) {
|
|
403
|
+
lines.push(` … and ${broken.length - MAX_DEAD_CLICK_LISTED} more`);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
const lying = alive.filter((verdict) => verdict.clickable.aria_disabled);
|
|
407
|
+
if (lying.length > 0) {
|
|
408
|
+
lines.push("");
|
|
409
|
+
lines.push("Marked aria-disabled, but they still work — a screen reader is told these are unavailable, and everyone " +
|
|
410
|
+
"else can use them (they are clicked with Playwright's actionability checks off, which is the only way " +
|
|
411
|
+
"to reach one):");
|
|
412
|
+
for (const verdict of lying.slice(0, MAX_DEAD_CLICK_LISTED)) {
|
|
413
|
+
lines.push(` ${nameOf(verdict)} — ${verdict.effects[0].detail}`);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
// Everything else that worked. The aria-disabled ones have just had their
|
|
417
|
+
// own section and are not repeated here.
|
|
418
|
+
const ordinary = alive.filter((verdict) => !verdict.clickable.aria_disabled);
|
|
419
|
+
if (ordinary.length > 0) {
|
|
420
|
+
lines.push("");
|
|
421
|
+
lines.push(`Alive — ${count(ordinary.length, "element", "elements")} did something:`);
|
|
422
|
+
for (const verdict of ordinary.slice(0, MAX_DEAD_CLICK_LISTED)) {
|
|
423
|
+
const what = verdict.effects
|
|
424
|
+
.slice(0, MAX_DEAD_CLICK_EFFECTS)
|
|
425
|
+
.map((effect) => effect.detail)
|
|
426
|
+
.join("; ");
|
|
427
|
+
lines.push(` ${nameOf(verdict)} — ${what}`);
|
|
428
|
+
}
|
|
429
|
+
if (ordinary.length > MAX_DEAD_CLICK_LISTED) {
|
|
430
|
+
lines.push(` … and ${ordinary.length - MAX_DEAD_CLICK_LISTED} more`);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
if (skipped.length > 0 || sweep.untested.length > 0) {
|
|
434
|
+
lines.push("");
|
|
435
|
+
lines.push(`Not clicked (${skipped.length + sweep.untested.length}):`);
|
|
436
|
+
for (const verdict of skipped.slice(0, MAX_DEAD_CLICK_LISTED)) {
|
|
437
|
+
lines.push(` ${nameOf(verdict)} — ${verdict.skipped}`);
|
|
438
|
+
}
|
|
439
|
+
if (skipped.length > MAX_DEAD_CLICK_LISTED) {
|
|
440
|
+
lines.push(` … and ${skipped.length - MAX_DEAD_CLICK_LISTED} more`);
|
|
441
|
+
}
|
|
442
|
+
if (sweep.untested.length > 0) {
|
|
443
|
+
lines.push(` ${count(sweep.untested.length, "element", "elements")} past \`max_elements\` (${input.max_elements}), ` +
|
|
444
|
+
`starting at ${describe(sweep.untested[0])} — raise it, or narrow the sweep with \`selector\`.`);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
return lines.join("\n");
|
|
448
|
+
}
|
|
449
|
+
/** `1. a "Pricing" (#nav-pricing)` — what it is and how to find it in the source. */
|
|
450
|
+
function nameOf(verdict) {
|
|
451
|
+
return describe(verdict.clickable);
|
|
452
|
+
}
|
|
453
|
+
function describe(clickable) {
|
|
454
|
+
return `${clickable.description} (${elide(clickable.selector, MAX_DEAD_CLICK_SELECTOR_LENGTH)})`;
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* The lines under a dead element: why it looked clickable in the first place.
|
|
458
|
+
*
|
|
459
|
+
* A dead control that also lights up under the pointer is the worst case —
|
|
460
|
+
* the page is actively inviting a click it will not answer — and one whose
|
|
461
|
+
* only claim to being a button is a `cursor: pointer` is the classic
|
|
462
|
+
* handler-never-attached bug. Both are the difference between "this is
|
|
463
|
+
* missing" and "this is broken".
|
|
464
|
+
*/
|
|
465
|
+
function deadNotes(verdict) {
|
|
466
|
+
const notes = [];
|
|
467
|
+
const { clickable, hover } = verdict;
|
|
468
|
+
const looks = [];
|
|
469
|
+
if (clickable.kind === "pointer")
|
|
470
|
+
looks.push("it is a plain element styled with a pointer cursor — nothing else says it is a control");
|
|
471
|
+
else if (clickable.cursor === "pointer")
|
|
472
|
+
looks.push("the cursor is a pointer");
|
|
473
|
+
if (clickable.kind === "link" && clickable.href) {
|
|
474
|
+
const href = clickable.href;
|
|
475
|
+
if (href.endsWith("#"))
|
|
476
|
+
looks.push('its href is "#", so it relies entirely on a handler');
|
|
477
|
+
else if (href.toLowerCase().startsWith("javascript:"))
|
|
478
|
+
looks.push(`its href is "${elide(href, 40)}", so it relies entirely on a handler`);
|
|
479
|
+
else
|
|
480
|
+
looks.push(`it links to ${elide(href, 60)}, and going there was prevented`);
|
|
481
|
+
}
|
|
482
|
+
if (clickable.role)
|
|
483
|
+
looks.push(`it is marked role="${clickable.role}"`);
|
|
484
|
+
if (hover) {
|
|
485
|
+
if (hover.failed !== undefined)
|
|
486
|
+
looks.push(`hover could not be checked (${hover.failed})`);
|
|
487
|
+
else if (hover.changed.length > 0)
|
|
488
|
+
looks.push(`hovering it changes ${hover.changed.join(", ")}`);
|
|
489
|
+
else
|
|
490
|
+
looks.push("hovering it changes nothing");
|
|
491
|
+
}
|
|
492
|
+
if (looks.length > 0)
|
|
493
|
+
notes.push(`Looks clickable: ${looks.join("; ")}.`);
|
|
494
|
+
if (clickable.aria_disabled) {
|
|
495
|
+
notes.push('It is marked aria-disabled="true", so doing nothing may well be deliberate.');
|
|
496
|
+
}
|
|
497
|
+
return notes;
|
|
498
|
+
}
|
|
499
|
+
/* ── Helpers ──────────────────────────────────────────────────────────── */
|
|
500
|
+
/** A screenshot is always worth having and never worth failing the sweep over. */
|
|
501
|
+
async function safeScreenshot(page, fullPage) {
|
|
502
|
+
try {
|
|
503
|
+
if (page.isClosed())
|
|
504
|
+
return undefined;
|
|
505
|
+
return await page.screenshot({ type: "png", fullPage });
|
|
506
|
+
}
|
|
507
|
+
catch {
|
|
508
|
+
return undefined;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
function elide(value, max) {
|
|
512
|
+
const flat = value.replace(/\s+/g, " ");
|
|
513
|
+
return flat.length > max ? `${flat.slice(0, max)}…` : flat;
|
|
514
|
+
}
|
|
515
|
+
function count(n, singular, plural) {
|
|
516
|
+
return `${n} ${n === 1 ? singular : plural}`;
|
|
517
|
+
}
|
|
518
|
+
function safely(read) {
|
|
519
|
+
try {
|
|
520
|
+
return read();
|
|
521
|
+
}
|
|
522
|
+
catch {
|
|
523
|
+
return undefined;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
function firstLine(error) {
|
|
527
|
+
return (error instanceof Error ? error.message : String(error)).split("\n")[0];
|
|
528
|
+
}
|
|
529
|
+
function errorResult(text) {
|
|
530
|
+
return { isError: true, content: [{ type: "text", text }] };
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* One actionable line for a sweep that could not run. Mirrors `describeFailure`
|
|
534
|
+
* in screenshot.ts: match on the failing Playwright call, never on substrings
|
|
535
|
+
* of a user-supplied selector.
|
|
536
|
+
*/
|
|
537
|
+
export function describeDeadClicksFailure(input, error) {
|
|
538
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
539
|
+
const line = firstLine(message);
|
|
540
|
+
const prefix = `Dead-click sweep of ${input.url} failed:`;
|
|
541
|
+
if (/Executable doesn't exist|browserType\.launch/i.test(message)) {
|
|
542
|
+
return (`${prefix} Playwright's Chromium browser is not installed. ` +
|
|
543
|
+
`Run \`npx playwright install chromium\` and try again. (${line})`);
|
|
544
|
+
}
|
|
545
|
+
if (input.wait_for && /^page\.waitForSelector:/.test(message)) {
|
|
546
|
+
return `${prefix} selector "${input.wait_for}" did not become visible within ${input.wait_for_timeout_ms}ms.`;
|
|
547
|
+
}
|
|
548
|
+
if (/^page\.goto:/.test(message)) {
|
|
549
|
+
return `${prefix} the page could not be opened — ${line}`;
|
|
550
|
+
}
|
|
551
|
+
return `${prefix} ${line}`;
|
|
552
|
+
}
|
|
553
|
+
export function registerDeadClicksTool(server) {
|
|
554
|
+
server.registerTool(DEAD_CLICKS_TOOL_NAME, {
|
|
555
|
+
title: "Dead clicks",
|
|
556
|
+
description: "Find the elements on a page that look clickable and do nothing — a button whose handler never got " +
|
|
557
|
+
"attached, a link to '#', a <div> with a pointer cursor and no listener. Every candidate (links, " +
|
|
558
|
+
"buttons, ARIA click roles, onclick attributes, pointer-cursor elements) is really clicked, and the " +
|
|
559
|
+
"page is then checked from every angle at once: URL, DOM mutations, form state, storage, scroll, focus, " +
|
|
560
|
+
"console, network, dialogs, popups and downloads. Only silence on all of them counts as dead. What the " +
|
|
561
|
+
"page does on its own is measured first and subtracted, so a clock or a polling fetch cannot hide the " +
|
|
562
|
+
"findings. Returns the list plus a screenshot with the dead elements boxed in red and any that threw in " +
|
|
563
|
+
"orange. NOTE: this presses every button it finds, which is a real write to the app under test — pass " +
|
|
564
|
+
"`exclude` (or `selector`) to keep it away from anything destructive. A sweep costs roughly " +
|
|
565
|
+
"`settle_ms` per element, plus a page reload after each element that did something.",
|
|
566
|
+
inputSchema: deadClicksInputShape,
|
|
567
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
568
|
+
}, async (args) => findDeadClicks(args));
|
|
569
|
+
}
|
|
570
|
+
//# sourceMappingURL=dead-clicks.js.map
|