oxicode_agent/tools/browse/
helpers.rs1use crate::tools::ToolError;
8use crate::tools::browse::engine::{BrowserTab, ObservedElement};
9use serde_json::Value;
10
11pub 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
21pub 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
36pub 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
57pub 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
66pub 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
82pub 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
102pub 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
131pub 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
147pub 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
159pub 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
171pub 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
232pub 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#[cfg(test)]
263mod tests {
264 use super::*;
265 #[test]
266 fn test_parse_observed_elements() {
267 assert!(parse_observed_elements(Value::Null).is_empty());
269
270 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 assert_eq!(els[1].name, "Email");
283 assert!(els[1].visible);
284 assert!(els[1].interactive);
285
286 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}