framewatch-mcp-server 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +797 -8
- package/dist/constants.d.ts +264 -0
- package/dist/constants.js +268 -0
- package/dist/constants.js.map +1 -1
- package/dist/engine/browser.js +4 -0
- 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 +6 -1
- package/dist/engine/interaction.js +21 -6
- 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 +42 -1
- package/dist/index.js.map +1 -1
- package/dist/tools/accessibility.js +4 -3
- 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 +83 -23
- package/dist/tools/capture.js +105 -60
- package/dist/tools/capture.js.map +1 -1
- package/dist/tools/compare.js +4 -3
- 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 +16 -1
- package/dist/tools/index.js +42 -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 +29 -16
- package/dist/tools/interact.js +99 -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 +6 -6
- package/dist/tools/responsive.js +9 -9
- 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 +22 -22
- package/dist/tools/screenshot.js +13 -5
- 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 +42 -1
- package/dist/utils/storage-state.js +115 -4
- package/dist/utils/storage-state.js.map +1 -1
- 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,801 @@
|
|
|
1
|
+
import { DEAD_CLICK_HOVER_SETTLE_MS, DEAD_CLICK_TIMEOUT_MS, MAX_DEAD_CLICK_CHANGES, MAX_DEAD_CLICK_CHANGE_SAMPLES, MAX_DEAD_CLICK_SCAN, MAX_DEAD_CLICK_TEXT_LENGTH, } from "../constants.js";
|
|
2
|
+
/**
|
|
3
|
+
* Click engine.
|
|
4
|
+
*
|
|
5
|
+
* Finds everything on a page that looks clickable, watches what a click
|
|
6
|
+
* actually does, and decides whether anything happened at all. Nothing here
|
|
7
|
+
* decides how to *report* it; that is `tools/dead-clicks.ts`.
|
|
8
|
+
*
|
|
9
|
+
* The hard part is not clicking — it is knowing that nothing happened. A
|
|
10
|
+
* handler can navigate, mutate the DOM, fetch, open a dialog, toggle a
|
|
11
|
+
* checkbox, scroll, move focus, write to localStorage or throw, and only some
|
|
12
|
+
* of those are visible. So the answer is assembled from every channel at once:
|
|
13
|
+
* the URL, an in-page MutationObserver, a signature of every field's value, of
|
|
14
|
+
* storage and of scroll position, plus Playwright's own view of the console,
|
|
15
|
+
* the network, dialogs, popups and downloads. "Dead" means every one of them
|
|
16
|
+
* was silent.
|
|
17
|
+
*
|
|
18
|
+
* Pages are rarely silent on their own, though — a clock ticks, a carousel
|
|
19
|
+
* advances, analytics beacons fire — so the same measurement is taken once
|
|
20
|
+
* with nobody clicking, and whatever the page does by itself is subtracted
|
|
21
|
+
* from every result (see `IdleNoise`). Without that, one `setInterval` makes
|
|
22
|
+
* every element on the page look alive and the tool finds nothing.
|
|
23
|
+
*/
|
|
24
|
+
/** The `window` key the in-page watcher keeps its counters under. */
|
|
25
|
+
export const CLICK_WATCH_KEY = "__framewatch_clicks";
|
|
26
|
+
/**
|
|
27
|
+
* Find everything on the page a user could reasonably expect to click.
|
|
28
|
+
*
|
|
29
|
+
* Links, buttons, ARIA click roles and `onclick` attributes are the semantic
|
|
30
|
+
* half. The other half is `cursor: pointer`, which is how a `<div>` announces
|
|
31
|
+
* itself as a button — and which is where dead clicks actually live, because a
|
|
32
|
+
* div has no default behaviour to fall back on when its handler never got
|
|
33
|
+
* attached.
|
|
34
|
+
*
|
|
35
|
+
* `cursor` is an inherited property, so a pointer-styled card makes every one
|
|
36
|
+
* of its descendants look clickable too. Only the outermost element of each
|
|
37
|
+
* pointer subtree is kept, and a pointer element that wraps (or sits inside) a
|
|
38
|
+
* real link or button is dropped entirely — that link is the thing being
|
|
39
|
+
* clicked, and it is tested on its own.
|
|
40
|
+
*/
|
|
41
|
+
export async function discoverClickables(page, options = {}) {
|
|
42
|
+
const raw = await page.evaluate(collectClickables, {
|
|
43
|
+
root: options.selector ?? "",
|
|
44
|
+
exclude: options.exclude ?? "",
|
|
45
|
+
include_pointer: options.include_pointer !== false,
|
|
46
|
+
max_scan: MAX_DEAD_CLICK_SCAN,
|
|
47
|
+
max_text: MAX_DEAD_CLICK_TEXT_LENGTH,
|
|
48
|
+
});
|
|
49
|
+
return raw.map((item, index) => ({
|
|
50
|
+
index: index + 1,
|
|
51
|
+
selector: item.selector,
|
|
52
|
+
match_index: item.match_index,
|
|
53
|
+
description: describeClickable(item),
|
|
54
|
+
kind: item.kind,
|
|
55
|
+
tag: item.tag,
|
|
56
|
+
text: item.text,
|
|
57
|
+
...(item.href ? { href: item.href } : {}),
|
|
58
|
+
...(item.role ? { role: item.role } : {}),
|
|
59
|
+
cursor: item.cursor,
|
|
60
|
+
aria_disabled: item.aria_disabled,
|
|
61
|
+
...(item.skip ? { skip: item.skip } : {}),
|
|
62
|
+
}));
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* How an element is named in the report: `button "Save draft"`, `a "Pricing"`.
|
|
66
|
+
*
|
|
67
|
+
* An icon-only control has no text at all, and a bare selector is the least
|
|
68
|
+
* recognisable thing to call it by — so a link falls back to where it points,
|
|
69
|
+
* and everything else to its selector.
|
|
70
|
+
*/
|
|
71
|
+
export function describeClickable(item) {
|
|
72
|
+
if (item.text)
|
|
73
|
+
return `${item.tag} "${item.text}"`;
|
|
74
|
+
if (item.kind === "link" && item.href)
|
|
75
|
+
return `${item.tag} → ${item.href}`;
|
|
76
|
+
return `${item.tag} ${item.selector}`;
|
|
77
|
+
}
|
|
78
|
+
/** Re-find a candidate on the page as it is now. Null when it is no longer there. */
|
|
79
|
+
export async function resolveClickable(page, clickable) {
|
|
80
|
+
const handles = await page.$$(clickable.selector).catch(() => []);
|
|
81
|
+
return handles[clickable.match_index] ?? handles[0] ?? null;
|
|
82
|
+
}
|
|
83
|
+
export const EMPTY_EVIDENCE = {
|
|
84
|
+
console: [],
|
|
85
|
+
network: [],
|
|
86
|
+
dialogs: [],
|
|
87
|
+
popups: [],
|
|
88
|
+
downloads: [],
|
|
89
|
+
focus_self: true,
|
|
90
|
+
};
|
|
91
|
+
export const NO_NOISE = { mutations: 0, changes: [], network: [], console: [], unstable: [] };
|
|
92
|
+
/** The order effects are reported in: the loudest thing the click did comes first. */
|
|
93
|
+
const EFFECT_ORDER = [
|
|
94
|
+
"error",
|
|
95
|
+
"navigated",
|
|
96
|
+
"reloaded",
|
|
97
|
+
"dialog",
|
|
98
|
+
"popup",
|
|
99
|
+
"download",
|
|
100
|
+
"dom",
|
|
101
|
+
"fields",
|
|
102
|
+
"storage",
|
|
103
|
+
"scroll",
|
|
104
|
+
"focus",
|
|
105
|
+
"title",
|
|
106
|
+
"network",
|
|
107
|
+
"console",
|
|
108
|
+
];
|
|
109
|
+
/** Signals a noisy page can never invalidate — none of them happen by accident. */
|
|
110
|
+
const ALWAYS_TRUSTED = new Set([
|
|
111
|
+
"error",
|
|
112
|
+
"navigated",
|
|
113
|
+
"reloaded",
|
|
114
|
+
"dialog",
|
|
115
|
+
"popup",
|
|
116
|
+
"download",
|
|
117
|
+
]);
|
|
118
|
+
/**
|
|
119
|
+
* Signals judged by *what* happened rather than by whether the page is restless.
|
|
120
|
+
*
|
|
121
|
+
* A DOM change is compared against the changes the page makes on its own, a
|
|
122
|
+
* request against the URLs it fetches on its own, a log line against what it
|
|
123
|
+
* logs on its own. Declaring those channels unusable the moment a page has a
|
|
124
|
+
* clock in it would throw away the three most common things a working button
|
|
125
|
+
* does — so they are filtered by content instead, and only the signals with
|
|
126
|
+
* nothing to compare (a hash, a scroll position) are ever declared unstable.
|
|
127
|
+
*/
|
|
128
|
+
const CONTENT_FILTERED = new Set(["dom", "network", "console"]);
|
|
129
|
+
/**
|
|
130
|
+
* Everything the click did. An empty list is a dead click.
|
|
131
|
+
*
|
|
132
|
+
* Pure, so every verdict this tool reaches is unit-testable without a browser.
|
|
133
|
+
*
|
|
134
|
+
* When the URL changed, the two states describe two different documents and
|
|
135
|
+
* comparing their innards would be meaningless — the navigation is the answer,
|
|
136
|
+
* and only the out-of-page evidence is still read.
|
|
137
|
+
*/
|
|
138
|
+
export function diffPageState(before, after, evidence = EMPTY_EVIDENCE, idle = NO_NOISE) {
|
|
139
|
+
const effects = [];
|
|
140
|
+
const unstable = new Set(idle.unstable);
|
|
141
|
+
const add = (kind, detail) => {
|
|
142
|
+
if (!ALWAYS_TRUSTED.has(kind) && unstable.has(kind))
|
|
143
|
+
return;
|
|
144
|
+
effects.push({ kind, detail });
|
|
145
|
+
};
|
|
146
|
+
const navigated = !sameUrl(before.url, after.url);
|
|
147
|
+
if (navigated) {
|
|
148
|
+
effects.push({ kind: "navigated", detail: `went to ${after.url}` });
|
|
149
|
+
}
|
|
150
|
+
else if (before.doc !== "" && after.doc !== "" && before.doc !== after.doc) {
|
|
151
|
+
// Same address, new document: the click reloaded the page. Worth saying
|
|
152
|
+
// out loud — it is what an <a href=""> does, and it usually is not meant.
|
|
153
|
+
effects.push({ kind: "reloaded", detail: "the page reloaded itself" });
|
|
154
|
+
}
|
|
155
|
+
for (const dialog of evidence.dialogs) {
|
|
156
|
+
effects.push({ kind: "dialog", detail: `opened a dialog — ${dialog}` });
|
|
157
|
+
}
|
|
158
|
+
for (const popup of evidence.popups) {
|
|
159
|
+
effects.push({ kind: "popup", detail: `opened a new tab — ${popup}` });
|
|
160
|
+
}
|
|
161
|
+
for (const download of evidence.downloads) {
|
|
162
|
+
effects.push({ kind: "download", detail: `started a download — ${download}` });
|
|
163
|
+
}
|
|
164
|
+
if (!navigated) {
|
|
165
|
+
// What the click changed, as opposed to what the page changes anyway. A
|
|
166
|
+
// count alone cannot tell those apart on a page with a clock in it: two
|
|
167
|
+
// windows of the same length never catch the same number of ticks.
|
|
168
|
+
const fresh = after.changes.filter((change) => !idle.changes.includes(change));
|
|
169
|
+
const extra = after.mutations - Math.max(0, idle.mutations);
|
|
170
|
+
if (after.watching && (fresh.length > 0 || (after.changes.length === 0 && extra > 0))) {
|
|
171
|
+
const many = Math.max(extra, fresh.length, 1);
|
|
172
|
+
add("dom", `${count(many, "DOM change", "DOM changes")}${describeChanges(fresh)}`);
|
|
173
|
+
}
|
|
174
|
+
else if (!after.watching && after.elements !== before.elements) {
|
|
175
|
+
// No observer (a page that blocked it, or one replaced mid-sweep): the
|
|
176
|
+
// element count is the crude fallback that is left.
|
|
177
|
+
add("dom", `the page has ${after.elements - before.elements > 0 ? "gained" : "lost"} ${Math.abs(after.elements - before.elements)} elements`);
|
|
178
|
+
}
|
|
179
|
+
if (after.fields !== before.fields)
|
|
180
|
+
add("fields", "a form field's value or checked state changed");
|
|
181
|
+
if (after.storage !== before.storage)
|
|
182
|
+
add("storage", "it wrote to localStorage or sessionStorage");
|
|
183
|
+
if (Math.abs(after.scroll_y - before.scroll_y) > 2 || Math.abs(after.scroll_x - before.scroll_x) > 2) {
|
|
184
|
+
add("scroll", `the page scrolled to ${after.scroll_x},${after.scroll_y}`);
|
|
185
|
+
}
|
|
186
|
+
if (!evidence.focus_self)
|
|
187
|
+
add("focus", "it moved focus somewhere else");
|
|
188
|
+
if (after.title !== before.title)
|
|
189
|
+
add("title", `the title became "${after.title}"`);
|
|
190
|
+
}
|
|
191
|
+
// Network and console are filtered by *content* rather than by
|
|
192
|
+
// `unstable`: a page that polls one endpoint should not lose the whole
|
|
193
|
+
// channel, because a request is often the only thing a working button does,
|
|
194
|
+
// and calling that one dead would be the worse mistake.
|
|
195
|
+
const requests = evidence.network.filter((event) => !idle.network.includes(event.url));
|
|
196
|
+
if (requests.length > 0) {
|
|
197
|
+
const first = requests[0];
|
|
198
|
+
const outcome = first.status > 0 ? String(first.status) : (first.error ?? "no response");
|
|
199
|
+
effects.push({
|
|
200
|
+
kind: "network",
|
|
201
|
+
detail: `${count(requests.length, "request", "requests")} — ${first.method} ${first.url} → ${outcome}` +
|
|
202
|
+
(requests.length > 1 ? ", and others" : ""),
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
const logged = evidence.console.filter((entry) => !idle.console.includes(entry.text));
|
|
206
|
+
const errors = logged.filter((entry) => entry.level === "error");
|
|
207
|
+
if (errors.length > 0) {
|
|
208
|
+
effects.push({ kind: "error", detail: `the handler threw — ${errors[0].text}` });
|
|
209
|
+
}
|
|
210
|
+
const quiet = logged.filter((entry) => entry.level !== "error");
|
|
211
|
+
if (quiet.length > 0) {
|
|
212
|
+
effects.push({ kind: "console", detail: `wrote to the console — [${quiet[0].level}] ${quiet[0].text}` });
|
|
213
|
+
}
|
|
214
|
+
return effects.sort((a, b) => EFFECT_ORDER.indexOf(a.kind) - EFFECT_ORDER.indexOf(b.kind));
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* What the page did to itself. Anything here fires without a click, so it
|
|
218
|
+
* cannot be evidence that a click did something.
|
|
219
|
+
*/
|
|
220
|
+
export function measureNoise(before, after, evidence) {
|
|
221
|
+
const effects = diffPageState(before, after, { ...evidence, focus_self: true }, NO_NOISE);
|
|
222
|
+
return {
|
|
223
|
+
mutations: Math.max(0, after.mutations - before.mutations),
|
|
224
|
+
changes: after.changes,
|
|
225
|
+
network: evidence.network.map((event) => event.url),
|
|
226
|
+
console: evidence.console.map((entry) => entry.text),
|
|
227
|
+
unstable: effects
|
|
228
|
+
.map((effect) => effect.kind)
|
|
229
|
+
.filter((kind) => !ALWAYS_TRUSTED.has(kind) && !CONTENT_FILTERED.has(kind)),
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
/** One line describing what a page does on its own, or undefined when it does nothing. */
|
|
233
|
+
export function describeNoise(noise) {
|
|
234
|
+
const parts = [];
|
|
235
|
+
if (noise.mutations > 0)
|
|
236
|
+
parts.push(count(noise.mutations, "DOM change", "DOM changes"));
|
|
237
|
+
if (noise.network.length > 0)
|
|
238
|
+
parts.push(count(noise.network.length, "request", "requests"));
|
|
239
|
+
if (noise.console.length > 0)
|
|
240
|
+
parts.push(count(noise.console.length, "console entry", "console entries"));
|
|
241
|
+
if (noise.unstable.length > 0)
|
|
242
|
+
parts.push(noise.unstable.join(", "));
|
|
243
|
+
return parts.length > 0 ? parts.join(", ") : undefined;
|
|
244
|
+
}
|
|
245
|
+
/* ── The in-page watcher ──────────────────────────────────────────────── */
|
|
246
|
+
/**
|
|
247
|
+
* Install the mutation counter, on this document and on every one after it.
|
|
248
|
+
*
|
|
249
|
+
* An init script alone would only reach the *next* document, and the page is
|
|
250
|
+
* usually already open by the time the sweep starts; running it once by hand
|
|
251
|
+
* covers the one that is there. The script refuses to install twice, so the
|
|
252
|
+
* two paths cannot double-count.
|
|
253
|
+
*/
|
|
254
|
+
export async function installClickWatcher(page) {
|
|
255
|
+
const config = { key: CLICK_WATCH_KEY, max_changes: MAX_DEAD_CLICK_CHANGE_SAMPLES };
|
|
256
|
+
await page.addInitScript(clickWatcher, config);
|
|
257
|
+
await page.evaluate(clickWatcher, config).catch(() => { });
|
|
258
|
+
}
|
|
259
|
+
/** Zero the counters, so the next reading measures one click and nothing before it. */
|
|
260
|
+
export async function resetClickWatcher(page) {
|
|
261
|
+
await page
|
|
262
|
+
.evaluate((key) => {
|
|
263
|
+
const state = globalThis[key];
|
|
264
|
+
if (state) {
|
|
265
|
+
state.mutations = 0;
|
|
266
|
+
state.changes = [];
|
|
267
|
+
}
|
|
268
|
+
}, CLICK_WATCH_KEY)
|
|
269
|
+
.catch(() => { });
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Read everything the page can say about its own state.
|
|
273
|
+
*
|
|
274
|
+
* Returns null when the page cannot be asked at all — mid-navigation, closed,
|
|
275
|
+
* crashed. A caller that gets null has still learned something (the URL, the
|
|
276
|
+
* console, the network), so this is not an error.
|
|
277
|
+
*/
|
|
278
|
+
export async function readPageState(page) {
|
|
279
|
+
const url = safely(() => page.url()) ?? "";
|
|
280
|
+
try {
|
|
281
|
+
const state = await page.evaluate(readState, {
|
|
282
|
+
key: CLICK_WATCH_KEY,
|
|
283
|
+
max_changes: MAX_DEAD_CLICK_CHANGE_SAMPLES,
|
|
284
|
+
max_text: MAX_DEAD_CLICK_TEXT_LENGTH,
|
|
285
|
+
});
|
|
286
|
+
return { url, ...state };
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
/** Computed properties read before and after hovering, and what to call each one. */
|
|
293
|
+
const HOVER_PROPERTIES = [
|
|
294
|
+
{ property: "backgroundColor", name: "background" },
|
|
295
|
+
{ property: "backgroundImage", name: "background image" },
|
|
296
|
+
{ property: "color", name: "text colour" },
|
|
297
|
+
{ property: "borderTopColor", name: "border" },
|
|
298
|
+
{ property: "borderTopWidth", name: "border width" },
|
|
299
|
+
{ property: "boxShadow", name: "shadow" },
|
|
300
|
+
{ property: "opacity", name: "opacity" },
|
|
301
|
+
{ property: "transform", name: "transform" },
|
|
302
|
+
{ property: "textDecorationLine", name: "underline" },
|
|
303
|
+
{ property: "outlineStyle", name: "outline" },
|
|
304
|
+
{ property: "filter", name: "filter" },
|
|
305
|
+
];
|
|
306
|
+
/**
|
|
307
|
+
* Does the page react when the pointer is over this element?
|
|
308
|
+
*
|
|
309
|
+
* Asked only of elements that turned out dead, and for one reason: an element
|
|
310
|
+
* that does nothing *and* lights up under the pointer is actively inviting the
|
|
311
|
+
* click that will do nothing, which is the difference between a missing
|
|
312
|
+
* feature and a broken one.
|
|
313
|
+
*
|
|
314
|
+
* Safe to run afterwards because a dead element, by definition, left the page
|
|
315
|
+
* exactly as it was.
|
|
316
|
+
*/
|
|
317
|
+
export async function probeHover(page, handle, options = {}) {
|
|
318
|
+
const timeout = options.timeout_ms ?? DEAD_CLICK_TIMEOUT_MS;
|
|
319
|
+
const settle = options.settle_ms ?? DEAD_CLICK_HOVER_SETTLE_MS;
|
|
320
|
+
const properties = HOVER_PROPERTIES.map((entry) => entry.property);
|
|
321
|
+
try {
|
|
322
|
+
// Start from a known state: the pointer may be sitting on this very
|
|
323
|
+
// element from the click that has just happened.
|
|
324
|
+
await page.mouse.move(0, 0);
|
|
325
|
+
await page.waitForTimeout(settle);
|
|
326
|
+
const before = await handle.evaluate(readComputed, properties);
|
|
327
|
+
await handle.hover({ timeout });
|
|
328
|
+
await page.waitForTimeout(settle);
|
|
329
|
+
const after = await handle.evaluate(readComputed, properties);
|
|
330
|
+
await page.mouse.move(0, 0);
|
|
331
|
+
const changed = HOVER_PROPERTIES.filter((entry) => before[entry.property] !== after[entry.property]).map((entry) => entry.name);
|
|
332
|
+
return { cursor: after.cursor ?? "", changed };
|
|
333
|
+
}
|
|
334
|
+
catch (error) {
|
|
335
|
+
await page.mouse.move(0, 0).catch(() => { });
|
|
336
|
+
return { cursor: "", changed: [], failed: firstLine(error) };
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
function collectClickables(options) {
|
|
340
|
+
const g = globalThis;
|
|
341
|
+
const doc = g.document;
|
|
342
|
+
if (!doc || !doc.body)
|
|
343
|
+
return [];
|
|
344
|
+
const SEMANTIC = 'a[href], button, summary, input[type="button"], input[type="submit"], input[type="reset"], ' +
|
|
345
|
+
'input[type="image"], [role="button"], [role="link"], [role="menuitem"], [role="menuitemcheckbox"], ' +
|
|
346
|
+
'[role="menuitemradio"], [role="tab"], [role="switch"], [role="checkbox"], [role="radio"], [role="option"], [onclick]';
|
|
347
|
+
const CLICK_ROLES = [
|
|
348
|
+
"button",
|
|
349
|
+
"link",
|
|
350
|
+
"menuitem",
|
|
351
|
+
"menuitemcheckbox",
|
|
352
|
+
"menuitemradio",
|
|
353
|
+
"tab",
|
|
354
|
+
"switch",
|
|
355
|
+
"checkbox",
|
|
356
|
+
"radio",
|
|
357
|
+
"option",
|
|
358
|
+
];
|
|
359
|
+
const BUTTON_TYPES = ["button", "submit", "reset", "image"];
|
|
360
|
+
// Not clickable, and nothing inside them is worth walking into.
|
|
361
|
+
const SKIP_TAGS = ["script", "style", "head", "meta", "link", "title", "template", "noscript", "svg", "iframe"];
|
|
362
|
+
// Schemes that hand the click to something that is not this page.
|
|
363
|
+
const AWAY_SCHEMES = ["mailto:", "tel:", "sms:", "callto:", "ftp:"];
|
|
364
|
+
const flatten = (text) => String(text ?? "")
|
|
365
|
+
.replace(/\s+/g, " ")
|
|
366
|
+
.trim();
|
|
367
|
+
const escape = (value) => {
|
|
368
|
+
try {
|
|
369
|
+
if (g.CSS && typeof g.CSS.escape === "function")
|
|
370
|
+
return g.CSS.escape(value);
|
|
371
|
+
}
|
|
372
|
+
catch {
|
|
373
|
+
// Fall through to the conservative test below.
|
|
374
|
+
}
|
|
375
|
+
return /^[A-Za-z][\w-]*$/.test(value) ? value : "";
|
|
376
|
+
};
|
|
377
|
+
/** A unique-enough CSS selector. Depth is capped; `match_index` settles the rest. */
|
|
378
|
+
const selectorFor = (node) => {
|
|
379
|
+
const parts = [];
|
|
380
|
+
let current = node;
|
|
381
|
+
for (let depth = 0; current && current.nodeType === 1 && depth < 6; depth++) {
|
|
382
|
+
const tag = String(current.tagName || "").toLowerCase();
|
|
383
|
+
if (tag === "body" || tag === "html") {
|
|
384
|
+
parts.unshift(tag);
|
|
385
|
+
break;
|
|
386
|
+
}
|
|
387
|
+
const id = current.id ? escape(String(current.id)) : "";
|
|
388
|
+
if (id !== "") {
|
|
389
|
+
parts.unshift(`#${id}`);
|
|
390
|
+
break;
|
|
391
|
+
}
|
|
392
|
+
let part = tag;
|
|
393
|
+
const parent = current.parentElement;
|
|
394
|
+
if (parent) {
|
|
395
|
+
let position = 0;
|
|
396
|
+
let total = 0;
|
|
397
|
+
for (let i = 0; i < parent.children.length; i++) {
|
|
398
|
+
const sibling = parent.children[i];
|
|
399
|
+
if (sibling.tagName !== current.tagName)
|
|
400
|
+
continue;
|
|
401
|
+
total++;
|
|
402
|
+
if (sibling === current)
|
|
403
|
+
position = total;
|
|
404
|
+
}
|
|
405
|
+
if (total > 1 && position > 0)
|
|
406
|
+
part += `:nth-of-type(${position})`;
|
|
407
|
+
}
|
|
408
|
+
parts.unshift(part);
|
|
409
|
+
current = parent;
|
|
410
|
+
}
|
|
411
|
+
return parts.join(" > ");
|
|
412
|
+
};
|
|
413
|
+
/** What the element says it is, for a report a human reads. */
|
|
414
|
+
const labelFor = (node, tag) => {
|
|
415
|
+
let text = "";
|
|
416
|
+
try {
|
|
417
|
+
text = flatten(node.innerText) || flatten(node.textContent);
|
|
418
|
+
}
|
|
419
|
+
catch {
|
|
420
|
+
text = "";
|
|
421
|
+
}
|
|
422
|
+
if (!text)
|
|
423
|
+
text = flatten(node.getAttribute && node.getAttribute("aria-label"));
|
|
424
|
+
if (!text)
|
|
425
|
+
text = flatten(node.getAttribute && node.getAttribute("title"));
|
|
426
|
+
if (!text && node.value !== undefined && tag === "input")
|
|
427
|
+
text = flatten(node.value);
|
|
428
|
+
if (!text) {
|
|
429
|
+
try {
|
|
430
|
+
const image = node.querySelector ? node.querySelector("img[alt], [aria-label]") : null;
|
|
431
|
+
if (image)
|
|
432
|
+
text = flatten(image.getAttribute("alt") || image.getAttribute("aria-label"));
|
|
433
|
+
}
|
|
434
|
+
catch {
|
|
435
|
+
// No inner label to borrow.
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
return text.length > options.max_text ? `${text.slice(0, options.max_text)}…` : text;
|
|
439
|
+
};
|
|
440
|
+
let roots = [doc.body];
|
|
441
|
+
if (options.root !== "") {
|
|
442
|
+
try {
|
|
443
|
+
roots = Array.prototype.slice.call(doc.querySelectorAll(options.root));
|
|
444
|
+
}
|
|
445
|
+
catch {
|
|
446
|
+
roots = [];
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
const found = [];
|
|
450
|
+
const seen = [];
|
|
451
|
+
let scanned = 0;
|
|
452
|
+
const consider = (node, tag, style, cursor, parentCursor) => {
|
|
453
|
+
const attribute = (name) => {
|
|
454
|
+
try {
|
|
455
|
+
const value = node.getAttribute ? node.getAttribute(name) : null;
|
|
456
|
+
return value === null || value === undefined ? "" : String(value);
|
|
457
|
+
}
|
|
458
|
+
catch {
|
|
459
|
+
return "";
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
const role = attribute("role").toLowerCase();
|
|
463
|
+
const type = String(node.type ?? "").toLowerCase();
|
|
464
|
+
let kind = null;
|
|
465
|
+
if (tag === "a" && node.hasAttribute && node.hasAttribute("href"))
|
|
466
|
+
kind = "link";
|
|
467
|
+
else if (tag === "button" || tag === "summary" || (tag === "input" && BUTTON_TYPES.indexOf(type) !== -1))
|
|
468
|
+
kind = "button";
|
|
469
|
+
else if (CLICK_ROLES.indexOf(role) !== -1)
|
|
470
|
+
kind = "role";
|
|
471
|
+
else if (node.hasAttribute && node.hasAttribute("onclick"))
|
|
472
|
+
kind = "handler";
|
|
473
|
+
// `cursor` is inherited, so only the element the pointer style starts at
|
|
474
|
+
// is a candidate — every descendant of a pointer-styled card would
|
|
475
|
+
// otherwise be reported as its own button.
|
|
476
|
+
else if (options.include_pointer && cursor === "pointer" && parentCursor !== "pointer")
|
|
477
|
+
kind = "pointer";
|
|
478
|
+
if (kind === null)
|
|
479
|
+
return;
|
|
480
|
+
if (seen.indexOf(node) !== -1)
|
|
481
|
+
return;
|
|
482
|
+
seen.push(node);
|
|
483
|
+
if (kind === "pointer") {
|
|
484
|
+
// A pointer wrapper around a real control is not the control; and a
|
|
485
|
+
// pointer-styled span inside a button is part of the button.
|
|
486
|
+
try {
|
|
487
|
+
if (node.querySelector && node.querySelector(SEMANTIC))
|
|
488
|
+
return;
|
|
489
|
+
if (node.closest && node.closest(SEMANTIC))
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
catch {
|
|
493
|
+
// A selector this browser will not parse; keep the candidate.
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
let rect = { width: 0, height: 0 };
|
|
497
|
+
try {
|
|
498
|
+
rect = node.getBoundingClientRect();
|
|
499
|
+
}
|
|
500
|
+
catch {
|
|
501
|
+
rect = { width: 0, height: 0 };
|
|
502
|
+
}
|
|
503
|
+
const href = tag === "a" ? String(node.href ?? "") : "";
|
|
504
|
+
const raw = {
|
|
505
|
+
selector: "",
|
|
506
|
+
match_index: 0,
|
|
507
|
+
kind,
|
|
508
|
+
tag,
|
|
509
|
+
text: labelFor(node, tag),
|
|
510
|
+
...(href ? { href } : {}),
|
|
511
|
+
...(role ? { role } : {}),
|
|
512
|
+
cursor,
|
|
513
|
+
aria_disabled: attribute("aria-disabled").toLowerCase() === "true",
|
|
514
|
+
};
|
|
515
|
+
const skip = (() => {
|
|
516
|
+
if (node.disabled === true)
|
|
517
|
+
return "disabled";
|
|
518
|
+
if (style && (style.visibility === "hidden" || style.display === "none"))
|
|
519
|
+
return "not visible";
|
|
520
|
+
if (rect.width < 1 || rect.height < 1)
|
|
521
|
+
return "has no size on screen";
|
|
522
|
+
if (options.exclude !== "") {
|
|
523
|
+
try {
|
|
524
|
+
if (node.closest && node.closest(options.exclude))
|
|
525
|
+
return "excluded";
|
|
526
|
+
}
|
|
527
|
+
catch {
|
|
528
|
+
// An `exclude` this browser will not parse excludes nothing; the
|
|
529
|
+
// tool validates it separately and says so.
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
if (tag === "a") {
|
|
533
|
+
if (node.hasAttribute && node.hasAttribute("download"))
|
|
534
|
+
return "downloads a file";
|
|
535
|
+
const scheme = href.slice(0, href.indexOf(":") + 1).toLowerCase();
|
|
536
|
+
if (AWAY_SCHEMES.indexOf(scheme) !== -1)
|
|
537
|
+
return `hands the click to ${scheme.replace(":", "")}`;
|
|
538
|
+
// A link to another site is alive by construction, and following it
|
|
539
|
+
// would send a request to a third party this tool has no business
|
|
540
|
+
// making on the user's behalf. Only an http(s) link can be off-site:
|
|
541
|
+
// `javascript:void(0)` has no origin of its own, and it is exactly the
|
|
542
|
+
// link most worth clicking here.
|
|
543
|
+
try {
|
|
544
|
+
const parsed = href === "" ? null : new g.URL(href);
|
|
545
|
+
const scheme_ = parsed ? String(parsed.protocol) : "";
|
|
546
|
+
if (parsed && (scheme_ === "http:" || scheme_ === "https:") && parsed.origin !== g.location.origin) {
|
|
547
|
+
return `links to another site (${parsed.origin})`;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
catch {
|
|
551
|
+
// An href that will not parse cannot be off-site.
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
return undefined;
|
|
555
|
+
})();
|
|
556
|
+
if (skip !== undefined)
|
|
557
|
+
raw.skip = skip;
|
|
558
|
+
found.push({ node, raw });
|
|
559
|
+
};
|
|
560
|
+
const walk = (node, parentCursor) => {
|
|
561
|
+
if (scanned >= options.max_scan)
|
|
562
|
+
return;
|
|
563
|
+
scanned++;
|
|
564
|
+
const tag = String(node.tagName || "").toLowerCase();
|
|
565
|
+
if (SKIP_TAGS.indexOf(tag) !== -1)
|
|
566
|
+
return;
|
|
567
|
+
let style = null;
|
|
568
|
+
try {
|
|
569
|
+
style = g.getComputedStyle(node);
|
|
570
|
+
}
|
|
571
|
+
catch {
|
|
572
|
+
style = null;
|
|
573
|
+
}
|
|
574
|
+
const cursor = style ? String(style.cursor || "") : parentCursor;
|
|
575
|
+
consider(node, tag, style, cursor, parentCursor);
|
|
576
|
+
// Nothing inside a `display: none` subtree can be seen, let alone clicked.
|
|
577
|
+
if (style && style.display === "none")
|
|
578
|
+
return;
|
|
579
|
+
const children = node.children;
|
|
580
|
+
if (!children)
|
|
581
|
+
return;
|
|
582
|
+
for (let i = 0; i < children.length; i++)
|
|
583
|
+
walk(children[i], cursor);
|
|
584
|
+
};
|
|
585
|
+
for (const root of roots) {
|
|
586
|
+
if (root)
|
|
587
|
+
walk(root, "");
|
|
588
|
+
}
|
|
589
|
+
// Selectors are worked out last: they are only needed for the candidates
|
|
590
|
+
// that survived, and each one costs a querySelectorAll to index.
|
|
591
|
+
for (const entry of found) {
|
|
592
|
+
const selector = selectorFor(entry.node);
|
|
593
|
+
entry.raw.selector = selector;
|
|
594
|
+
try {
|
|
595
|
+
const matches = doc.querySelectorAll(selector);
|
|
596
|
+
let index = -1;
|
|
597
|
+
for (let i = 0; i < matches.length; i++) {
|
|
598
|
+
if (matches[i] === entry.node)
|
|
599
|
+
index = i;
|
|
600
|
+
}
|
|
601
|
+
if (index === -1) {
|
|
602
|
+
entry.raw.match_index = 0;
|
|
603
|
+
if (entry.raw.skip === undefined)
|
|
604
|
+
entry.raw.skip = "could not be addressed by a selector";
|
|
605
|
+
}
|
|
606
|
+
else {
|
|
607
|
+
entry.raw.match_index = index;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
catch {
|
|
611
|
+
if (entry.raw.skip === undefined)
|
|
612
|
+
entry.raw.skip = "could not be addressed by a selector";
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
return found.map((entry) => entry.raw);
|
|
616
|
+
}
|
|
617
|
+
/**
|
|
618
|
+
* The page-side watcher: a per-document id and a mutation counter.
|
|
619
|
+
*
|
|
620
|
+
* Head churn is skipped for the same reason the DOM layer skips it — every
|
|
621
|
+
* CSS-in-JS library rewrites `<style>` elements continuously, and a click is
|
|
622
|
+
* not what caused that.
|
|
623
|
+
*/
|
|
624
|
+
const clickWatcher = (config) => {
|
|
625
|
+
const g = globalThis;
|
|
626
|
+
const key = config.key;
|
|
627
|
+
if (g[key] && g[key].installed)
|
|
628
|
+
return;
|
|
629
|
+
const state = {
|
|
630
|
+
installed: true,
|
|
631
|
+
// Identifies this document: a reload keeps the URL and replaces this.
|
|
632
|
+
doc: `${Math.random()}`.slice(2) + "-" + Date.now(),
|
|
633
|
+
watching: false,
|
|
634
|
+
mutations: 0,
|
|
635
|
+
changes: [],
|
|
636
|
+
};
|
|
637
|
+
g[key] = state;
|
|
638
|
+
const doc = g.document;
|
|
639
|
+
if (!doc || typeof g.MutationObserver !== "function")
|
|
640
|
+
return;
|
|
641
|
+
const desc = (node) => {
|
|
642
|
+
if (!node)
|
|
643
|
+
return "?";
|
|
644
|
+
if (node.nodeType === 3)
|
|
645
|
+
return "#text";
|
|
646
|
+
if (node.nodeType !== 1)
|
|
647
|
+
return "#node";
|
|
648
|
+
let out = String(node.tagName || "?").toLowerCase();
|
|
649
|
+
if (node.id)
|
|
650
|
+
out += "#" + String(node.id);
|
|
651
|
+
else if (node.classList && node.classList.length)
|
|
652
|
+
out += "." + String(node.classList[0]);
|
|
653
|
+
return out.length > 48 ? out.slice(0, 48) + "…" : out;
|
|
654
|
+
};
|
|
655
|
+
const inHead = (node) => {
|
|
656
|
+
try {
|
|
657
|
+
return Boolean(doc.head) && (node === doc.head || doc.head.contains(node) === true);
|
|
658
|
+
}
|
|
659
|
+
catch {
|
|
660
|
+
return false;
|
|
661
|
+
}
|
|
662
|
+
};
|
|
663
|
+
const note = (line) => {
|
|
664
|
+
if (state.changes.length < config.max_changes && state.changes.indexOf(line) === -1)
|
|
665
|
+
state.changes.push(line);
|
|
666
|
+
};
|
|
667
|
+
try {
|
|
668
|
+
new g.MutationObserver((records) => {
|
|
669
|
+
for (const record of records) {
|
|
670
|
+
const target = record.target;
|
|
671
|
+
if (inHead(target))
|
|
672
|
+
continue;
|
|
673
|
+
state.mutations++;
|
|
674
|
+
if (record.type === "childList") {
|
|
675
|
+
const added = record.addedNodes;
|
|
676
|
+
const removed = record.removedNodes;
|
|
677
|
+
if (added && added.length)
|
|
678
|
+
note("+ " + desc(added[0]) + " in " + desc(target));
|
|
679
|
+
else if (removed && removed.length)
|
|
680
|
+
note("- " + desc(removed[0]) + " from " + desc(target));
|
|
681
|
+
}
|
|
682
|
+
else if (record.type === "attributes") {
|
|
683
|
+
note("~ " + desc(target) + " [" + String(record.attributeName || "") + "]");
|
|
684
|
+
}
|
|
685
|
+
else {
|
|
686
|
+
note("~ text in " + desc(target.parentNode || target));
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
}).observe(doc, { subtree: true, childList: true, attributes: true, characterData: true });
|
|
690
|
+
state.watching = true;
|
|
691
|
+
}
|
|
692
|
+
catch {
|
|
693
|
+
// No observer: `elements` in the state reading is the fallback.
|
|
694
|
+
}
|
|
695
|
+
};
|
|
696
|
+
function readState(options) {
|
|
697
|
+
const g = globalThis;
|
|
698
|
+
const doc = g.document;
|
|
699
|
+
const state = g[options.key] ?? {};
|
|
700
|
+
// A 32-bit rolling hash. The values themselves are never wanted — only
|
|
701
|
+
// whether they are the same as they were a moment ago — and a page can hold
|
|
702
|
+
// megabytes of them.
|
|
703
|
+
const hash = (text) => {
|
|
704
|
+
let value = 0;
|
|
705
|
+
for (let i = 0; i < text.length; i++)
|
|
706
|
+
value = (Math.imul(value, 31) + text.charCodeAt(i)) | 0;
|
|
707
|
+
return value;
|
|
708
|
+
};
|
|
709
|
+
let fields = "";
|
|
710
|
+
try {
|
|
711
|
+
const nodes = doc.querySelectorAll("input, select, textarea");
|
|
712
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
713
|
+
const node = nodes[i];
|
|
714
|
+
const type = String(node.type ?? "").toLowerCase();
|
|
715
|
+
const value = type === "checkbox" || type === "radio" ? (node.checked ? "1" : "0") : String(node.value ?? "");
|
|
716
|
+
fields += `${i}:${value};`;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
catch {
|
|
720
|
+
fields = "?";
|
|
721
|
+
}
|
|
722
|
+
let storage = "";
|
|
723
|
+
try {
|
|
724
|
+
for (const store of [g.localStorage, g.sessionStorage]) {
|
|
725
|
+
if (!store)
|
|
726
|
+
continue;
|
|
727
|
+
for (let i = 0; i < store.length; i++) {
|
|
728
|
+
const name = store.key(i);
|
|
729
|
+
storage += `${name}=${store.getItem(name)};`;
|
|
730
|
+
}
|
|
731
|
+
storage += "|";
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
catch {
|
|
735
|
+
// Storage can be disabled outright (a sandboxed frame, a strict setting).
|
|
736
|
+
// A constant stands in: the signal is lost, not corrupted.
|
|
737
|
+
storage = "?";
|
|
738
|
+
}
|
|
739
|
+
return {
|
|
740
|
+
doc: String(state.doc ?? ""),
|
|
741
|
+
watching: state.watching === true,
|
|
742
|
+
mutations: Number(state.mutations ?? 0),
|
|
743
|
+
changes: Array.isArray(state.changes) ? state.changes.slice(0, options.max_changes).map(String) : [],
|
|
744
|
+
elements: doc ? doc.getElementsByTagName("*").length : 0,
|
|
745
|
+
fields: hash(fields),
|
|
746
|
+
storage: hash(storage),
|
|
747
|
+
scroll_x: Math.round(Number(g.scrollX ?? 0)),
|
|
748
|
+
scroll_y: Math.round(Number(g.scrollY ?? 0)),
|
|
749
|
+
title: String(doc?.title ?? "").slice(0, options.max_text),
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
function readComputed(node, properties) {
|
|
753
|
+
const out = {};
|
|
754
|
+
try {
|
|
755
|
+
const style = globalThis.getComputedStyle(node);
|
|
756
|
+
for (const property of properties)
|
|
757
|
+
out[property] = String(style[property] ?? "");
|
|
758
|
+
out.cursor = String(style.cursor ?? "");
|
|
759
|
+
}
|
|
760
|
+
catch {
|
|
761
|
+
// A detached node has no computed style; every property reads as empty,
|
|
762
|
+
// which compares equal to itself and reports no hover feedback.
|
|
763
|
+
}
|
|
764
|
+
return out;
|
|
765
|
+
}
|
|
766
|
+
/* ── Helpers ──────────────────────────────────────────────────────────── */
|
|
767
|
+
/**
|
|
768
|
+
* Two URLs that differ only by an empty fragment are the same place.
|
|
769
|
+
*
|
|
770
|
+
* `<a href="#">` is the canonical dead link, and clicking one appends a bare
|
|
771
|
+
* `#` to the address bar. Counting that as a navigation would report every
|
|
772
|
+
* dead link in the world as working.
|
|
773
|
+
*/
|
|
774
|
+
export function sameUrl(a, b) {
|
|
775
|
+
return stripEmptyHash(a) === stripEmptyHash(b);
|
|
776
|
+
}
|
|
777
|
+
function stripEmptyHash(url) {
|
|
778
|
+
return url.endsWith("#") ? url.slice(0, -1) : url;
|
|
779
|
+
}
|
|
780
|
+
function describeChanges(changes) {
|
|
781
|
+
if (changes.length === 0)
|
|
782
|
+
return "";
|
|
783
|
+
const shown = changes.slice(0, MAX_DEAD_CLICK_CHANGES);
|
|
784
|
+
const rest = changes.length - shown.length;
|
|
785
|
+
return ` (${shown.join("; ")}${rest > 0 ? `; and ${rest} more` : ""})`;
|
|
786
|
+
}
|
|
787
|
+
function count(n, singular, plural) {
|
|
788
|
+
return `${n} ${n === 1 ? singular : plural}`;
|
|
789
|
+
}
|
|
790
|
+
function safely(read) {
|
|
791
|
+
try {
|
|
792
|
+
return read();
|
|
793
|
+
}
|
|
794
|
+
catch {
|
|
795
|
+
return undefined;
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
function firstLine(error) {
|
|
799
|
+
return (error instanceof Error ? error.message : String(error)).split("\n")[0];
|
|
800
|
+
}
|
|
801
|
+
//# sourceMappingURL=clicks.js.map
|