Skip to main content

oxicode_agent/tools/browse/
helpers.rs

1//! Shared helpers for browser tools.
2//!
3//! Centralizes JS snippet generation and result parsing so that
4//! `BrowseTool`, `BrowseExtractTool`, and `BrowseScriptTool` share
5//! a single source of truth for DOM interaction logic.
6
7use crate::tools::ToolError;
8use crate::tools::browse::engine::{BrowserTab, ObservedElement};
9use serde_json::Value;
10
11// ── Link extraction ───────────────────────────────────────────────
12
13/// JS that returns `[{ text, href }, …]` for every `<a href>` on the page.
14pub const JS_ALL_LINKS: &str = r#"(function() {
15    var links = document.querySelectorAll('a[href]');
16    return Array.from(links).map(function(a) {
17        return { text: a.textContent.trim(), href: a.href };
18    });
19})()"#;
20
21/// JS that returns links inside the element matching `selector`.
22pub fn js_links_within(selector: &str) -> String {
23    let sel = serde_json::to_string(selector).unwrap_or_default();
24    format!(
25        r#"(function() {{
26            var root = document.querySelector({sel});
27            if (!root) return [];
28            var links = root.querySelectorAll('a[href]');
29            return Array.from(links).map(function(a) {{
30                return {{ text: a.textContent.trim(), href: a.href }};
31            }});
32        }})()"#
33    )
34}
35
36/// Parse the JSON array returned by the link-extraction snippets.
37pub fn parse_link_values(value: Value) -> Vec<(String, String)> {
38    let Value::Array(arr) = value else {
39        return Vec::new();
40    };
41    arr.iter()
42        .filter_map(|item| {
43            let href = item.get("href")?.as_str()?.to_string();
44            if href.is_empty() {
45                return None;
46            }
47            let text = item
48                .get("text")
49                .and_then(|v| v.as_str())
50                .unwrap_or("")
51                .to_string();
52            Some((text, href))
53        })
54        .collect()
55}
56
57/// Extract all links from the already-loaded page (no navigation).
58pub async fn extract_links(tab: &dyn BrowserTab) -> Result<Vec<(String, String)>, ToolError> {
59    let value = tab
60        .evaluate(JS_ALL_LINKS)
61        .await
62        .map_err(|e| e.to_string())?;
63    Ok(parse_link_values(value))
64}
65
66/// Format link pairs as a numbered markdown list.
67pub fn format_links(links: &[(String, String)]) -> String {
68    links
69        .iter()
70        .enumerate()
71        .map(|(i, (text, href))| {
72            if text.is_empty() {
73                format!("{}. {}", i + 1, href)
74            } else {
75                format!("{}. [{}]({})", i + 1, text, href)
76            }
77        })
78        .collect::<Vec<_>>()
79        .join("\n")
80}
81
82// ── Element extraction ────────────────────────────────────────────
83
84/// JS that returns `[{ tag, text, attributes }]` for every element
85/// matching `selector`.
86pub fn js_query_elements(selector: &str) -> String {
87    let sel = serde_json::to_string(selector).unwrap_or_default();
88    format!(
89        r#"(function() {{
90            var els = document.querySelectorAll({sel});
91            return Array.from(els).map(function(el) {{
92                var attrs = {{}};
93                for (var i = 0; i < el.attributes.length; i++) {{
94                    attrs[el.attributes[i].name] = el.attributes[i].value;
95                }}
96                return {{ tag: el.tagName, text: el.textContent.trim(), attributes: attrs }};
97            }});
98        }})()"#
99    )
100}
101
102/// Parse the JSON array returned by `js_query_elements`.
103pub fn parse_element_values(
104    value: Value,
105) -> Vec<(String, String, std::collections::HashMap<String, String>)> {
106    let Value::Array(arr) = value else {
107        return Vec::new();
108    };
109    arr.iter()
110        .filter_map(|item| {
111            let tag = item.get("tag")?.as_str()?.to_string();
112            let text = item
113                .get("text")
114                .and_then(|v| v.as_str())
115                .unwrap_or("")
116                .to_string();
117            let attributes = item
118                .get("attributes")
119                .and_then(|v| v.as_object())
120                .map(|map| {
121                    map.iter()
122                        .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
123                        .collect()
124                })
125                .unwrap_or_default();
126            Some((tag, text, attributes))
127        })
128        .collect()
129}
130
131// ── DOM interaction JS builders ───────────────────────────────────
132
133/// JS to set a `<select>` value and fire the `change` event.
134pub fn js_set_select_value(selector: &str, value: &str) -> String {
135    let sel = serde_json::to_string(selector).unwrap_or_default();
136    let val = serde_json::to_string(value).unwrap_or_default();
137    format!(
138        r#"(function() {{
139            var sel = document.querySelector({sel});
140            if (!sel) throw new Error('Element not found: ' + {sel});
141            sel.value = {val};
142            sel.dispatchEvent(new Event('change', {{ bubbles: true }}));
143        }})()"#
144    )
145}
146
147/// JS to check a checkbox (only if not already checked).
148pub fn js_check(selector: &str) -> String {
149    let sel = serde_json::to_string(selector).unwrap_or_default();
150    format!(
151        r#"(function() {{
152            var el = document.querySelector({sel});
153            if (!el) throw new Error('Element not found: ' + {sel});
154            if (!el.checked) el.click();
155        }})()"#
156    )
157}
158
159/// JS to uncheck a checkbox (only if currently checked).
160pub fn js_uncheck(selector: &str) -> String {
161    let sel = serde_json::to_string(selector).unwrap_or_default();
162    format!(
163        r#"(function() {{
164            var el = document.querySelector({sel});
165            if (!el) throw new Error('Element not found: ' + {sel});
166            if (el.checked) el.click();
167        }})()"#
168    )
169}
170
171// ── Accessibility-surface observation (omp `observe()` parity) ──────────────
172
173/// JS that walks the page's interactive elements, stamps each with a stable
174/// `data-oxicode-ref="eN"` attribute, and returns `{ ref_id, role, name, tag,
175/// selector, visible, interactive }[]`.
176///
177/// Visibility/interactivity use pure-CSS checks (`getComputedStyle`) which
178/// are reliable in the boa runtime. **No coordinates** are returned — the
179/// boa layout engine only approximates geometry and rects would mislead.
180/// `setAttribute` persists to the live `DomSnapshot`, so the returned
181/// `[data-oxicode-ref="eN"]` selectors resolve on a follow-up `click`/`fill`.
182pub const JS_OBSERVE: &str = r#"(function() {
183    var SEL = 'a[href], button, input, textarea, select, summary, [role], [tabindex], [onclick]';
184    var els = document.querySelectorAll(SEL);
185    var out = [];
186    var n = 0;
187    for (var i = 0; i < els.length; i++) {
188        var el = els[i];
189        var cs = getComputedStyle(el);
190        if (cs.getPropertyValue('display') === 'none') continue;
191        if (cs.getPropertyValue('visibility') === 'hidden') continue;
192        if (cs.getPropertyValue('opacity') === '0') continue;
193        if (el.getAttribute('hidden') !== null) continue;
194        if (el.getAttribute('aria-hidden') === 'true') continue;
195        if (el.getAttribute('disabled') !== null) continue;
196        if (cs.getPropertyValue('pointer-events') === 'none') continue;
197        var tag = (el.tagName || '').toLowerCase();
198        var role = el.getAttribute('role');
199        if (!role) {
200            var type = (el.getAttribute('type') || '').toLowerCase();
201            if (tag === 'a') role = 'link';
202            else if (tag === 'button' || type === 'button' || type === 'submit' || type === 'reset' || tag === 'summary') role = 'button';
203            else if (tag === 'input' || tag === 'textarea') role = 'textbox';
204            else if (tag === 'select') role = 'combobox';
205            else if (type === 'checkbox') role = 'checkbox';
206            else if (type === 'radio') role = 'radio';
207            else if (tag === 'option') role = 'option';
208            else role = tag;
209        }
210        var name = el.getAttribute('aria-label') || el.getAttribute('title') || '';
211        name = name.trim();
212        if (!name) {
213            name = (el.textContent || '').trim().replace(/\s+/g, ' ');
214        }
215        name = name.slice(0, 80);
216        n++;
217        var ref = 'e' + n;
218        try { el.setAttribute('data-oxicode-ref', ref); } catch (e) {}
219        out.push({
220            ref_id: ref,
221            role: role,
222            name: name,
223            tag: tag,
224            selector: '[data-oxicode-ref="' + ref + '"]',
225            visible: true,
226            interactive: true
227        });
228    }
229    return out;
230})()"#;
231
232/// Parse the JSON array returned by [`JS_OBSERVE`] into [`ObservedElement`]s.
233///
234/// Only `ref_id` is required; the rest fall back to empty/`true` so a
235/// partial or forward-incompatible entry degrades instead of dropping.
236pub fn parse_observed_elements(value: Value) -> Vec<ObservedElement> {
237    let Some(arr) = value.as_array() else {
238        return Vec::new();
239    };
240    arr.iter()
241        .filter_map(|e| {
242            let ref_id = e.get("ref_id")?.as_str()?.to_string();
243            let s = |k: &str| e.get(k).and_then(|v| v.as_str()).unwrap_or("").to_string();
244            Some(ObservedElement {
245                ref_id,
246                role: s("role"),
247                name: s("name"),
248                tag: s("tag"),
249                selector: s("selector"),
250                visible: e.get("visible").and_then(|v| v.as_bool()).unwrap_or(true),
251                interactive: e
252                    .get("interactive")
253                    .and_then(|v| v.as_bool())
254                    .unwrap_or(true),
255            })
256        })
257        .collect()
258}
259
260// ── Tests ─────────────────────────────────────────────────────────
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265    #[test]
266    fn test_parse_observed_elements() {
267        // null / non-array → empty
268        assert!(parse_observed_elements(Value::Null).is_empty());
269
270        // well-formed array → mapped in order
271        let input = serde_json::json!([
272            { "ref_id": "e1", "role": "link", "name": "Sign in", "tag": "a",
273              "selector": "[data-oxicode-ref=\"e1\"]", "visible": true, "interactive": true },
274            { "ref_id": "e2", "role": "textbox", "name": "Email", "tag": "input",
275              "selector": "[data-oxicode-ref=\"e2\"]" }
276        ]);
277        let els = parse_observed_elements(input);
278        assert_eq!(els.len(), 2);
279        assert_eq!(els[0].ref_id, "e1");
280        assert_eq!(els[0].selector, "[data-oxicode-ref=\"e1\"]");
281        // missing optional fields degrade (defaults), not drop
282        assert_eq!(els[1].name, "Email");
283        assert!(els[1].visible);
284        assert!(els[1].interactive);
285
286        // entry without ref_id is dropped
287        let partial = serde_json::json!([{ "role": "button" }]);
288        assert!(parse_observed_elements(partial).is_empty());
289    }
290
291    #[test]
292    fn test_parse_link_values_empty() {
293        assert!(parse_link_values(Value::Null).is_empty());
294        assert!(parse_link_values(Value::Array(vec![])).is_empty());
295    }
296
297    #[test]
298    fn test_parse_link_values_filters_empty_href() {
299        let input = serde_json::json!([
300            { "text": "ok", "href": "https://x.com" },
301            { "text": "bad", "href": "" },
302            { "text": "also ok", "href": "https://y.com" }
303        ]);
304        let links = parse_link_values(input);
305        assert_eq!(links.len(), 2);
306        assert_eq!(links[0].1, "https://x.com");
307        assert_eq!(links[1].1, "https://y.com");
308    }
309
310    #[test]
311    fn test_format_links() {
312        let links = vec![
313            ("Hello".to_string(), "https://a.com".to_string()),
314            ("".to_string(), "https://b.com".to_string()),
315        ];
316        let out = format_links(&links);
317        assert!(out.contains("[Hello](https://a.com)"));
318        assert!(out.contains("2. https://b.com"));
319    }
320
321    #[test]
322    fn test_js_query_elements_contains_selector() {
323        let js = js_query_elements(".item");
324        assert!(js.contains(".item"));
325        assert!(js.contains("querySelectorAll"));
326        assert!(js.contains("textContent"));
327    }
328
329    #[test]
330    fn test_js_links_within_scopes_to_selector() {
331        let js = js_links_within("#nav");
332        assert!(js.contains("#nav"));
333        assert!(js.contains("querySelector"));
334        assert!(js.contains("querySelectorAll('a[href]')"));
335    }
336
337    #[test]
338    fn test_parse_element_values() {
339        let input = serde_json::json!([
340            { "tag": "DIV", "text": "hello", "attributes": { "class": "item" } }
341        ]);
342        let elems = parse_element_values(input);
343        assert_eq!(elems.len(), 1);
344        assert_eq!(elems[0].0, "DIV");
345        assert_eq!(elems[0].1, "hello");
346        assert_eq!(elems[0].2.get("class").unwrap(), "item");
347    }
348}