Skip to main content

browser_control/dom/
scripts.rs

1//! JavaScript payloads injected into the browser by MCP tools.
2
3/// Serializes the DOM with shadow roots included.
4pub const GET_DOM_JS: &str = r#"
5(function(selector) {
6    const root = selector ? document.querySelector(selector) : document.documentElement;
7    if (!root) return null;
8    if (typeof root.getHTML === 'function') {
9        try { return root.getHTML({ serializableShadowRoots: true }); } catch (e) {}
10    }
11    return root.outerHTML;
12})
13"#;
14
15/// Resolves a selector to a clip rectangle in *document* coordinates,
16/// scrolling the element into view first. Returns `null` when the selector
17/// matches nothing or the element has zero area (hidden / detached).
18pub const GET_CLIP_RECT_JS: &str = r#"
19(function(selector) {
20    const el = document.querySelector(selector);
21    if (!el) return null;
22    el.scrollIntoView({ block: 'center', inline: 'center' });
23    const r = el.getBoundingClientRect();
24    if (r.width === 0 || r.height === 0) return null;
25    return {
26        x: r.left + window.scrollX,
27        y: r.top + window.scrollY,
28        width: r.width,
29        height: r.height,
30    };
31})
32"#;
33
34/// Interactive element picker. Resolves with the selector string for the picked element.
35pub const SELECT_ELEMENT_JS: &str = r#"
36(function() {
37    function cssPath(el) {
38        if (!(el instanceof Element)) return '';
39        const path = [];
40        while (el && el.nodeType === 1) {
41            let selector = el.nodeName.toLowerCase();
42            if (el.id) { selector += '#' + el.id; path.unshift(selector); break; }
43            else {
44                let sib = el, nth = 1;
45                while ((sib = sib.previousElementSibling)) { if (sib.nodeName === el.nodeName) nth++; }
46                selector += ':nth-of-type(' + nth + ')';
47            }
48            path.unshift(selector);
49            el = el.parentNode;
50        }
51        return path.join(' > ');
52    }
53    return new Promise((resolve) => {
54        const overlay = document.createElement('div');
55        overlay.style.cssText = 'position:fixed;inset:0;z-index:2147483647;cursor:crosshair;background:rgba(0,150,255,0.05);';
56        document.body.appendChild(overlay);
57        overlay.addEventListener('click', (e) => {
58            e.preventDefault(); e.stopPropagation();
59            overlay.remove();
60            const x = e.clientX, y = e.clientY;
61            const el = document.elementFromPoint(x, y);
62            resolve(cssPath(el));
63        }, { capture: true, once: true });
64    });
65})()
66"#;
67
68/// Readable-text extraction for `browser_get_page_text`. Called as
69/// `(fn)(maxChars, selectorOrNull)`; returns a JSON string
70/// `{title, url, source, text, truncated, total_chars}` or `{error}`.
71///
72/// Root selection: explicit selector → `main`/`article`/`[role=main]` →
73/// the largest text block with the lowest link density → `body`. Page
74/// chrome (nav/header/footer/aside and their ARIA equivalents), scripts,
75/// styles, hidden elements, and form controls are skipped; headings, list
76/// items, and table cells keep a little structure.
77pub const GET_PAGE_TEXT_JS: &str = r#"
78(function(maxChars, selector) {
79    const doc = document;
80    const SKIP = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'SVG', 'CANVAS', 'IFRAME', 'OBJECT', 'EMBED', 'INPUT', 'TEXTAREA', 'SELECT']);
81    const CHROME = new Set(['NAV', 'HEADER', 'FOOTER', 'ASIDE']);
82    const CHROME_ROLES = new Set(['navigation', 'banner', 'contentinfo', 'complementary']);
83    const BLOCK = new Set(['P', 'DIV', 'LI', 'TR', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'SECTION', 'ARTICLE', 'BLOCKQUOTE', 'PRE', 'DT', 'DD', 'BR', 'HR', 'UL', 'OL', 'TABLE', 'MAIN', 'FORM', 'FIGURE', 'FIGCAPTION', 'DETAILS', 'SUMMARY', 'LABEL', 'OPTION', 'TD', 'TH']);
84    function visible(el) {
85        if (el.hidden || el.getAttribute('aria-hidden') === 'true') return false;
86        try { if (typeof el.checkVisibility === 'function') return el.checkVisibility(); } catch (e) {}
87        const cs = getComputedStyle(el);
88        return cs.display !== 'none' && cs.visibility !== 'hidden';
89    }
90    let root = null, source = 'body';
91    if (selector) {
92        root = doc.querySelector(selector);
93        if (!root) return JSON.stringify({ error: 'selector matched no element: ' + selector });
94        source = 'selector';
95    } else {
96        root = doc.querySelector('main, [role="main"], article');
97        if (root) {
98            source = root.tagName === 'ARTICLE' ? 'article' : 'main';
99        } else {
100            let best = null, bestScore = 0;
101            for (const el of doc.querySelectorAll('article, section, div')) {
102                const len = (el.innerText || '').length;
103                if (len < 200) continue;
104                let linkLen = 0;
105                for (const a of el.querySelectorAll('a')) linkLen += (a.innerText || '').length;
106                const score = len * (1 - Math.min(1, linkLen / len));
107                if (score > bestScore) { bestScore = score; best = el; }
108            }
109            if (best) { root = best; source = 'heuristic'; }
110            else root = doc.body || doc.documentElement;
111        }
112    }
113    const parts = [];
114    function walk(node, isRoot) {
115        if (node.nodeType === 3) {
116            const t = node.nodeValue;
117            if (t && t.trim()) parts.push(t.replace(/\s+/g, ' '));
118            return;
119        }
120        if (node.nodeType !== 1) return;
121        const tag = node.tagName;
122        if (SKIP.has(tag)) return;
123        if (!isRoot && (CHROME.has(tag) || CHROME_ROLES.has(node.getAttribute('role')))) return;
124        if (!visible(node)) return;
125        const block = BLOCK.has(tag);
126        if (block) parts.push('\n');
127        if (/^H[1-6]$/.test(tag)) parts.push('#'.repeat(+tag[1]) + ' ');
128        else if (tag === 'LI') parts.push('- ');
129        const kids = node.shadowRoot ? node.shadowRoot.childNodes : node.childNodes;
130        for (const c of kids) walk(c, false);
131        if (tag === 'TD' || tag === 'TH') parts.push(' | ');
132        if (block) parts.push('\n');
133    }
134    walk(root, true);
135    let text = parts.join('')
136        .replace(/[ \t]+\n/g, '\n')
137        .replace(/\n[ \t]+/g, '\n')
138        .replace(/[ \t]{2,}/g, ' ')
139        .replace(/\n{3,}/g, '\n\n')
140        .trim();
141    const total = text.length;
142    let truncated = false;
143    if (text.length > maxChars) {
144        let cut = text.lastIndexOf('\n', maxChars);
145        if (cut < maxChars / 2) cut = maxChars;
146        text = text.slice(0, cut);
147        truncated = true;
148    }
149    return JSON.stringify({ title: doc.title, url: location.href, source, text, truncated, total_chars: total });
150})
151"#;
152
153/// Called via `Runtime.callFunctionOn` with `this` bound to the element
154/// about to receive typed text. Selects the element's current content so
155/// the following `Input.insertText` replaces it (like Playwright `fill`).
156/// With `clear=true` the content is emptied outright and `input`/`change`
157/// are dispatched, for the "type an empty string" case.
158pub const SELECT_ALL_JS: &str = r#"
159(function(clear) {
160    const el = this;
161    const tag = (el.tagName || '').toLowerCase();
162    if (tag === 'input' || tag === 'textarea') {
163        if (clear) {
164            el.value = '';
165            el.dispatchEvent(new Event('input', { bubbles: true }));
166            el.dispatchEvent(new Event('change', { bubbles: true }));
167        } else {
168            try { el.select(); } catch (e) {
169                try { el.setSelectionRange(0, el.value.length); } catch (e2) {}
170            }
171        }
172        return 'field';
173    }
174    if (el.isContentEditable) {
175        if (clear) {
176            el.textContent = '';
177            el.dispatchEvent(new InputEvent('input', { bubbles: true }));
178        } else {
179            const sel = window.getSelection();
180            sel.removeAllRanges();
181            const range = document.createRange();
182            range.selectNodeContents(el);
183            sel.addRange(range);
184        }
185        return 'contenteditable';
186    }
187    return 'other';
188})
189"#;
190
191// ---------------------------------------------------------------------------
192// WebDriver BiDi (Firefox) accessibility snapshot + ref helpers.
193//
194// BiDi has no accessibility-tree command, so `SNAPSHOT_TREE_JS` walks the
195// DOM and emits `Accessibility.getFullAXTree`-shaped JSON with element refs
196// held in a page-side registry (`window.__bcRefs`). The helpers below take
197// a registry id and return a JSON string: `{"gone":true}` when the element
198// is no longer in the document, `{"error":"…"}` for a real failure.
199// ---------------------------------------------------------------------------
200
201/// The walker itself; see the header comment in the file.
202pub const SNAPSHOT_TREE_JS: &str = include_str!("js/snapshot_tree.js");
203
204/// Expression (for `script.evaluate`) yielding the per-document token as a
205/// decimal string; creates it when the walker has not run yet.
206pub const DOC_TOKEN_JS: &str = "String(window.__bcDocToken ?? (window.__bcDocToken = 4294967296 + Math.floor(Math.random() * (9007199254740992 - 4294967296))))";
207
208/// Expression yielding the document's scroll size as JSON, for full-page
209/// screenshots on BiDi.
210pub const DOC_SIZE_JS: &str = "JSON.stringify({width: Math.max(document.documentElement.scrollWidth, document.body ? document.body.scrollWidth : 0), height: Math.max(document.documentElement.scrollHeight, document.body ? document.body.scrollHeight : 0)})";
211
212/// `(id)` → centre of the element in viewport CSS px after scrolling it
213/// into view, clipped to the viewport.
214pub const REF_CENTER_JS: &str = r#"
215/* bc:center */
216(function(id) {
217    const reg = window.__bcRefs;
218    const r = reg && reg.byId.get(Number(id));
219    const el = r && typeof r.deref === 'function' ? r.deref() : r;
220    if (!el || !el.isConnected) return JSON.stringify({ gone: true });
221    try { el.scrollIntoView({ block: 'center', inline: 'center' }); } catch (e) {}
222    const rect = el.getBoundingClientRect();
223    const vw = window.innerWidth, vh = window.innerHeight;
224    const x1 = Math.max(0, rect.left), y1 = Math.max(0, rect.top);
225    const x2 = Math.min(vw, rect.right), y2 = Math.min(vh, rect.bottom);
226    if (x2 - x1 <= 1 || y2 - y1 <= 1) {
227        return JSON.stringify({ error: 'element has no visible box (hidden, zero-size, or outside the viewport after scrolling)' });
228    }
229    return JSON.stringify({ x: (x1 + x2) / 2, y: (y1 + y2) / 2 });
230})
231"#;
232
233/// `(id)` → the element's border box in document coordinates (same
234/// contract as `GET_CLIP_RECT_JS`).
235pub const REF_CLIP_RECT_JS: &str = r#"
236/* bc:clip */
237(function(id) {
238    const reg = window.__bcRefs;
239    const r = reg && reg.byId.get(Number(id));
240    const el = r && typeof r.deref === 'function' ? r.deref() : r;
241    if (!el || !el.isConnected) return JSON.stringify({ gone: true });
242    try { el.scrollIntoView({ block: 'center', inline: 'center' }); } catch (e) {}
243    const rect = el.getBoundingClientRect();
244    if (rect.width === 0 || rect.height === 0) return JSON.stringify({ error: 'element has zero area' });
245    return JSON.stringify({
246        x: rect.left + window.scrollX,
247        y: rect.top + window.scrollY,
248        width: rect.width,
249        height: rect.height,
250    });
251})
252"#;
253
254/// `(id, text, mode)` with `mode` = `fill` | `select` | `clear`: focus the
255/// element, select its content (or clear it), and for `fill` insert `text`
256/// through `execCommand('insertText')` with a value-setter fallback that
257/// keeps framework value trackers in sync. Returns `{kind, method}`.
258pub const REF_TYPE_JS: &str = r#"
259/* bc:type */
260(function(id, text, mode) {
261    const reg = window.__bcRefs;
262    const r = reg && reg.byId.get(Number(id));
263    const el = r && typeof r.deref === 'function' ? r.deref() : r;
264    if (!el || !el.isConnected) return JSON.stringify({ gone: true });
265    try { el.focus(); } catch (e) {}
266    const tag = (el.tagName || '').toLowerCase();
267    const isField = tag === 'input' || tag === 'textarea';
268    const editable = !isField && el.isContentEditable;
269    const kind = isField ? 'field' : editable ? 'contenteditable' : 'other';
270    function setValue(v) {
271        const proto = tag === 'textarea' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
272        const d = Object.getOwnPropertyDescriptor(proto, 'value');
273        if (d && d.set) d.set.call(el, v); else el.value = v;
274        el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: v }));
275        el.dispatchEvent(new Event('change', { bubbles: true }));
276    }
277    if (mode === 'clear') {
278        if (isField) setValue('');
279        else if (editable) { el.textContent = ''; el.dispatchEvent(new InputEvent('input', { bubbles: true })); }
280        return JSON.stringify({ kind: kind, method: 'clear' });
281    }
282    if (isField) {
283        try { el.select(); } catch (e) { try { el.setSelectionRange(0, el.value.length); } catch (e2) {} }
284    } else if (editable) {
285        const sel = window.getSelection();
286        sel.removeAllRanges();
287        const range = document.createRange();
288        range.selectNodeContents(el);
289        sel.addRange(range);
290    }
291    if (mode === 'select') return JSON.stringify({ kind: kind, method: 'select' });
292    let ok = false;
293    try { ok = document.execCommand('insertText', false, text); } catch (e) { ok = false; }
294    if (isField && el.value !== text) ok = false;
295    let method = 'execCommand';
296    if (!ok) {
297        method = 'fallback';
298        if (isField) setValue(text);
299        else if (editable) {
300            el.textContent = text;
301            el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: text }));
302        }
303    }
304    return JSON.stringify({ kind: kind, method: method });
305})
306"#;
307
308/// Performs a fetch from the page context. Receives JSON-string args.
309pub const FETCH_JS: &str = r#"
310(async function(argsJson) {
311    const args = JSON.parse(argsJson);
312    const timeoutMs = Number(args.timeoutMs || 0);
313    const controller = timeoutMs > 0 && typeof AbortController !== 'undefined'
314        ? new AbortController()
315        : null;
316    const timer = controller
317        ? setTimeout(() => controller.abort(), timeoutMs)
318        : null;
319    try {
320        const r = await fetch(args.url, {
321            method: args.method || 'GET',
322            headers: args.headers || {},
323            body: args.body,
324            credentials: 'include',
325            signal: controller ? controller.signal : undefined,
326        });
327        const text = await r.text();
328        const headers = {};
329        r.headers.forEach((v,k) => { headers[k] = v; });
330        return JSON.stringify({ ok: true, status: r.status, statusText: r.statusText, headers, body: text });
331    } catch (e) {
332        const aborted = controller && controller.signal.aborted;
333        return JSON.stringify({
334            ok: false,
335            error: aborted ? `fetch timed out after ${timeoutMs}ms` : String(e),
336            errorName: e && e.name ? e.name : null,
337        });
338    } finally {
339        if (timer) clearTimeout(timer);
340    }
341})
342"#;