use crate::tools::ToolError;
use crate::tools::browse::engine::{BrowserTab, ObservedElement};
use serde_json::Value;
pub const JS_ALL_LINKS: &str = r#"(function() {
var links = document.querySelectorAll('a[href]');
return Array.from(links).map(function(a) {
return { text: a.textContent.trim(), href: a.href };
});
})()"#;
pub fn js_links_within(selector: &str) -> String {
let sel = serde_json::to_string(selector).unwrap_or_default();
format!(
r#"(function() {{
var root = document.querySelector({sel});
if (!root) return [];
var links = root.querySelectorAll('a[href]');
return Array.from(links).map(function(a) {{
return {{ text: a.textContent.trim(), href: a.href }};
}});
}})()"#
)
}
pub fn parse_link_values(value: Value) -> Vec<(String, String)> {
let Value::Array(arr) = value else {
return Vec::new();
};
arr.iter()
.filter_map(|item| {
let href = item.get("href")?.as_str()?.to_string();
if href.is_empty() {
return None;
}
let text = item
.get("text")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
Some((text, href))
})
.collect()
}
pub async fn extract_links(tab: &dyn BrowserTab) -> Result<Vec<(String, String)>, ToolError> {
let value = tab
.evaluate(JS_ALL_LINKS)
.await
.map_err(|e| e.to_string())?;
Ok(parse_link_values(value))
}
pub fn format_links(links: &[(String, String)]) -> String {
links
.iter()
.enumerate()
.map(|(i, (text, href))| {
if text.is_empty() {
format!("{}. {}", i + 1, href)
} else {
format!("{}. [{}]({})", i + 1, text, href)
}
})
.collect::<Vec<_>>()
.join("\n")
}
pub fn js_query_elements(selector: &str) -> String {
let sel = serde_json::to_string(selector).unwrap_or_default();
format!(
r#"(function() {{
var els = document.querySelectorAll({sel});
return Array.from(els).map(function(el) {{
var attrs = {{}};
for (var i = 0; i < el.attributes.length; i++) {{
attrs[el.attributes[i].name] = el.attributes[i].value;
}}
return {{ tag: el.tagName, text: el.textContent.trim(), attributes: attrs }};
}});
}})()"#
)
}
pub fn parse_element_values(
value: Value,
) -> Vec<(String, String, std::collections::HashMap<String, String>)> {
let Value::Array(arr) = value else {
return Vec::new();
};
arr.iter()
.filter_map(|item| {
let tag = item.get("tag")?.as_str()?.to_string();
let text = item
.get("text")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let attributes = item
.get("attributes")
.and_then(|v| v.as_object())
.map(|map| {
map.iter()
.filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
.collect()
})
.unwrap_or_default();
Some((tag, text, attributes))
})
.collect()
}
pub fn js_set_select_value(selector: &str, value: &str) -> String {
let sel = serde_json::to_string(selector).unwrap_or_default();
let val = serde_json::to_string(value).unwrap_or_default();
format!(
r#"(function() {{
var sel = document.querySelector({sel});
if (!sel) throw new Error('Element not found: ' + {sel});
sel.value = {val};
sel.dispatchEvent(new Event('change', {{ bubbles: true }}));
}})()"#
)
}
pub fn js_check(selector: &str) -> String {
let sel = serde_json::to_string(selector).unwrap_or_default();
format!(
r#"(function() {{
var el = document.querySelector({sel});
if (!el) throw new Error('Element not found: ' + {sel});
if (!el.checked) el.click();
}})()"#
)
}
pub fn js_uncheck(selector: &str) -> String {
let sel = serde_json::to_string(selector).unwrap_or_default();
format!(
r#"(function() {{
var el = document.querySelector({sel});
if (!el) throw new Error('Element not found: ' + {sel});
if (el.checked) el.click();
}})()"#
)
}
pub const JS_OBSERVE: &str = r#"(function() {
var SEL = 'a[href], button, input, textarea, select, summary, [role], [tabindex], [onclick]';
var els = document.querySelectorAll(SEL);
var out = [];
var n = 0;
for (var i = 0; i < els.length; i++) {
var el = els[i];
var cs = getComputedStyle(el);
if (cs.getPropertyValue('display') === 'none') continue;
if (cs.getPropertyValue('visibility') === 'hidden') continue;
if (cs.getPropertyValue('opacity') === '0') continue;
if (el.getAttribute('hidden') !== null) continue;
if (el.getAttribute('aria-hidden') === 'true') continue;
if (el.getAttribute('disabled') !== null) continue;
if (cs.getPropertyValue('pointer-events') === 'none') continue;
var tag = (el.tagName || '').toLowerCase();
var role = el.getAttribute('role');
if (!role) {
var type = (el.getAttribute('type') || '').toLowerCase();
if (tag === 'a') role = 'link';
else if (tag === 'button' || type === 'button' || type === 'submit' || type === 'reset' || tag === 'summary') role = 'button';
else if (tag === 'input' || tag === 'textarea') role = 'textbox';
else if (tag === 'select') role = 'combobox';
else if (type === 'checkbox') role = 'checkbox';
else if (type === 'radio') role = 'radio';
else if (tag === 'option') role = 'option';
else role = tag;
}
var name = el.getAttribute('aria-label') || el.getAttribute('title') || '';
name = name.trim();
if (!name) {
name = (el.textContent || '').trim().replace(/\s+/g, ' ');
}
name = name.slice(0, 80);
n++;
var ref = 'e' + n;
try { el.setAttribute('data-oxi-ref', ref); } catch (e) {}
out.push({
ref_id: ref,
role: role,
name: name,
tag: tag,
selector: '[data-oxi-ref="' + ref + '"]',
visible: true,
interactive: true
});
}
return out;
})()"#;
pub fn parse_observed_elements(value: Value) -> Vec<ObservedElement> {
let Some(arr) = value.as_array() else {
return Vec::new();
};
arr.iter()
.filter_map(|e| {
let ref_id = e.get("ref_id")?.as_str()?.to_string();
let s = |k: &str| e.get(k).and_then(|v| v.as_str()).unwrap_or("").to_string();
Some(ObservedElement {
ref_id,
role: s("role"),
name: s("name"),
tag: s("tag"),
selector: s("selector"),
visible: e.get("visible").and_then(|v| v.as_bool()).unwrap_or(true),
interactive: e
.get("interactive")
.and_then(|v| v.as_bool())
.unwrap_or(true),
})
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_observed_elements() {
assert!(parse_observed_elements(Value::Null).is_empty());
let input = serde_json::json!([
{ "ref_id": "e1", "role": "link", "name": "Sign in", "tag": "a",
"selector": "[data-oxi-ref=\"e1\"]", "visible": true, "interactive": true },
{ "ref_id": "e2", "role": "textbox", "name": "Email", "tag": "input",
"selector": "[data-oxi-ref=\"e2\"]" }
]);
let els = parse_observed_elements(input);
assert_eq!(els.len(), 2);
assert_eq!(els[0].ref_id, "e1");
assert_eq!(els[0].selector, "[data-oxi-ref=\"e1\"]");
assert_eq!(els[1].name, "Email");
assert!(els[1].visible);
assert!(els[1].interactive);
let partial = serde_json::json!([{ "role": "button" }]);
assert!(parse_observed_elements(partial).is_empty());
}
#[test]
fn test_parse_link_values_empty() {
assert!(parse_link_values(Value::Null).is_empty());
assert!(parse_link_values(Value::Array(vec![])).is_empty());
}
#[test]
fn test_parse_link_values_filters_empty_href() {
let input = serde_json::json!([
{ "text": "ok", "href": "https://x.com" },
{ "text": "bad", "href": "" },
{ "text": "also ok", "href": "https://y.com" }
]);
let links = parse_link_values(input);
assert_eq!(links.len(), 2);
assert_eq!(links[0].1, "https://x.com");
assert_eq!(links[1].1, "https://y.com");
}
#[test]
fn test_format_links() {
let links = vec![
("Hello".to_string(), "https://a.com".to_string()),
("".to_string(), "https://b.com".to_string()),
];
let out = format_links(&links);
assert!(out.contains("[Hello](https://a.com)"));
assert!(out.contains("2. https://b.com"));
}
#[test]
fn test_js_query_elements_contains_selector() {
let js = js_query_elements(".item");
assert!(js.contains(".item"));
assert!(js.contains("querySelectorAll"));
assert!(js.contains("textContent"));
}
#[test]
fn test_js_links_within_scopes_to_selector() {
let js = js_links_within("#nav");
assert!(js.contains("#nav"));
assert!(js.contains("querySelector"));
assert!(js.contains("querySelectorAll('a[href]')"));
}
#[test]
fn test_parse_element_values() {
let input = serde_json::json!([
{ "tag": "DIV", "text": "hello", "attributes": { "class": "item" } }
]);
let elems = parse_element_values(input);
assert_eq!(elems.len(), 1);
assert_eq!(elems[0].0, "DIV");
assert_eq!(elems[0].1, "hello");
assert_eq!(elems[0].2.get("class").unwrap(), "item");
}
}