This repository has no description
7.9 kB
253 lines
1/* Browser MCP content-side helpers.
2 *
3 * Injected on demand into the extension's isolated content-script world of a
4 * page. Element refs are kept here (not as DOM attributes), so the page cannot
5 * observe them; they live as long as the document.
6 */
7
8// eslint-disable-next-line no-var
9var __browserMcp = (() => {
10 const refs = new Map(); // ref -> WeakRef(element)
11 const elementRefs = new WeakMap(); // element -> ref
12 let nextRef = 1;
13
14 const INTERACTIVE = [
15 "a[href]",
16 "button",
17 "input:not([type=hidden])",
18 "select",
19 "textarea",
20 "summary",
21 "[contenteditable=''], [contenteditable=true]",
22 "[role=button], [role=link], [role=checkbox], [role=radio], [role=switch]",
23 "[role=tab], [role=menuitem], [role=menuitemcheckbox], [role=option]",
24 "[role=combobox], [role=textbox], [role=searchbox], [role=slider]",
25 "[tabindex]:not([tabindex='-1'])",
26 "[onclick]",
27 ].join(", ");
28 const HEADINGS = "h1, h2, h3, h4, h5, h6";
29
30 function refFor(el) {
31 let ref = elementRefs.get(el);
32 if (ref === undefined) {
33 ref = nextRef++;
34 elementRefs.set(el, ref);
35 refs.set(ref, new WeakRef(el));
36 }
37 return ref;
38 }
39
40 function lookup(ref) {
41 const el = refs.get(ref)?.deref();
42 if (!el || !el.isConnected) {
43 throw new Error(`ref ${ref} is gone; take a new snapshot`);
44 }
45 return el;
46 }
47
48 function clip(s, n) {
49 s = (s || "").replace(/\s+/g, " ").trim();
50 return s.length > n ? s.slice(0, n - 1) + "…" : s;
51 }
52
53 function accessibleName(el) {
54 const labelledby = el.getAttribute("aria-labelledby");
55 if (labelledby) {
56 const text = labelledby
57 .split(/\s+/)
58 .map((id) => document.getElementById(id)?.innerText || "")
59 .join(" ");
60 if (text.trim()) return text;
61 }
62 const direct =
63 el.getAttribute("aria-label") ||
64 (el.labels && [...el.labels].map((l) => l.innerText).join(" "));
65 if (direct && direct.trim()) return direct;
66 if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") {
67 return el.placeholder || el.title || "";
68 }
69 return (
70 el.innerText ||
71 el.getAttribute("title") ||
72 el.getAttribute("alt") ||
73 el.querySelector?.("img[alt]")?.alt ||
74 ""
75 );
76 }
77
78 function roleOf(el) {
79 const role = el.getAttribute("role");
80 if (role) return role;
81 const tag = el.tagName.toLowerCase();
82 if (tag === "a") return "link";
83 if (tag === "input") return `input[${el.type}]`;
84 if (/^h[1-6]$/.test(tag)) return `heading${tag[1]}`;
85 if (el.isContentEditable) return "editable";
86 return tag;
87 }
88
89 function visible(el) {
90 if (
91 el.checkVisibility &&
92 !el.checkVisibility({ visibilityProperty: true })
93 ) {
94 return false;
95 }
96 const r = el.getBoundingClientRect();
97 return r.width > 0 && r.height > 0;
98 }
99
100 // Walks the document and open shadow roots in document order.
101 function* walk(root) {
102 for (const el of root.querySelectorAll("*")) {
103 yield el;
104 if (el.shadowRoot) yield* walk(el.shadowRoot);
105 }
106 }
107
108 function describe(el) {
109 const parts = [`[${refFor(el)}]`, roleOf(el)];
110 const name = clip(accessibleName(el), 80);
111 if (name) parts.push(JSON.stringify(name));
112 const tag = el.tagName;
113 if (tag === "INPUT" && (el.type === "checkbox" || el.type === "radio")) {
114 parts.push(el.checked ? "checked" : "unchecked");
115 } else if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") {
116 if (el.value) parts.push(`value=${JSON.stringify(clip(el.value, 80))}`);
117 }
118 if (tag === "A") parts.push(`-> ${clip(el.getAttribute("href"), 100)}`);
119 if (el.disabled) parts.push("disabled");
120 return parts.join(" ");
121 }
122
123 function snapshot({ max_elements }) {
124 const lines = [];
125 let total = 0;
126 for (const el of walk(document)) {
127 if (!el.matches(INTERACTIVE) && !el.matches(HEADINGS)) continue;
128 if (!visible(el)) continue;
129 total++;
130 if (lines.length < max_elements) lines.push(describe(el));
131 }
132 const header = [`title: ${document.title}`, `url: ${location.href}`];
133 if (total > lines.length) {
134 header.push(`(showing ${lines.length} of ${total} elements)`);
135 }
136 return [...header, "", ...lines].join("\n");
137 }
138
139 function text({ max_length }) {
140 const t = document.body ? document.body.innerText : "";
141 return t.length > max_length
142 ? t.slice(0, max_length) + `\n… (${t.length - max_length} more chars)`
143 : t;
144 }
145
146 function mouse(el, type, x, y) {
147 const Ctor = type.startsWith("pointer") ? PointerEvent : MouseEvent;
148 el.dispatchEvent(
149 new Ctor(type, {
150 bubbles: true,
151 cancelable: true,
152 composed: true,
153 clientX: x,
154 clientY: y,
155 button: 0,
156 buttons: type.endsWith("down") ? 1 : 0,
157 view: window,
158 }),
159 );
160 }
161
162 function click({ ref }) {
163 const el = lookup(ref);
164 el.scrollIntoView({ block: "center", inline: "center" });
165 const r = el.getBoundingClientRect();
166 const x = r.left + r.width / 2;
167 const y = r.top + r.height / 2;
168 mouse(el, "pointerdown", x, y);
169 mouse(el, "mousedown", x, y);
170 el.focus?.({ preventScroll: true });
171 mouse(el, "pointerup", x, y);
172 mouse(el, "mouseup", x, y);
173 el.click();
174 return `clicked ${describe(el)}`;
175 }
176
177 function fill({ ref, value }) {
178 const el = lookup(ref);
179 el.scrollIntoView({ block: "center" });
180 el.focus();
181 if (el.tagName === "SELECT") {
182 const option = [...el.options].find(
183 (o) => o.value === value || o.text.trim() === value,
184 );
185 if (!option) throw new Error(`no option ${JSON.stringify(value)}`);
186 el.value = option.value;
187 } else if (
188 el.tagName === "INPUT" &&
189 (el.type === "checkbox" || el.type === "radio")
190 ) {
191 const want = value === "true" || value === "on" || value === "checked";
192 if (el.checked !== want) el.click();
193 return `set ${describe(el)}`;
194 } else {
195 // insertText goes through the editor like real typing (trusted input
196 // events, works for frameworks and contenteditable editors).
197 if (el.select) el.select();
198 else document.getSelection().selectAllChildren(el);
199 const inserted =
200 value === ""
201 ? document.execCommand("delete")
202 : document.execCommand("insertText", false, value);
203 if (!inserted) {
204 if (el.isContentEditable) el.textContent = value;
205 else el.value = value;
206 el.dispatchEvent(new Event("input", { bubbles: true, composed: true }));
207 }
208 }
209 el.dispatchEvent(new Event("change", { bubbles: true }));
210 return `filled ${describe(el)}`;
211 }
212
213 const KEY_CODES = {
214 Enter: 13,
215 Escape: 27,
216 Tab: 9,
217 Backspace: 8,
218 Delete: 46,
219 ArrowUp: 38,
220 ArrowDown: 40,
221 ArrowLeft: 37,
222 ArrowRight: 39,
223 " ": 32,
224 };
225
226 function pressKey({ key, ref }) {
227 const el =
228 ref == null ? document.activeElement || document.body : lookup(ref);
229 const init = {
230 key,
231 code: key.length === 1 ? `Key${key.toUpperCase()}` : key,
232 keyCode:
233 KEY_CODES[key] ||
234 (key.length === 1 ? key.toUpperCase().charCodeAt(0) : 0),
235 bubbles: true,
236 cancelable: true,
237 composed: true,
238 };
239 const notCancelled = el.dispatchEvent(new KeyboardEvent("keydown", init));
240 if (key.length === 1 || key === "Enter") {
241 el.dispatchEvent(new KeyboardEvent("keypress", init));
242 }
243 el.dispatchEvent(new KeyboardEvent("keyup", init));
244 // Synthetic key events have no default action; do the one that matters.
245 if (notCancelled && key === "Enter" && el.form && el.tagName === "INPUT") {
246 el.form.requestSubmit();
247 return `pressed Enter on ${describe(el)}, submitted form`;
248 }
249 return `pressed ${key} on ${describe(el)}`;
250 }
251
252 return { snapshot, text, click, fill, pressKey };
253})();