#![allow(missing_docs)]
use std::collections::HashMap;
use serde_json::Value;
use super::cdp::client::CdpClient;
use super::cdp::types::{
AXNode, AXProperty, AXValue, EvaluateParams, EvaluateResult, GetFullAXTreeResult,
};
use super::element::{resolve_ax_session, RefMap};
const INTERACTIVE_ROLES: &[&str] = &[
"button",
"link",
"textbox",
"checkbox",
"radio",
"combobox",
"listbox",
"menuitem",
"menuitemcheckbox",
"menuitemradio",
"option",
"searchbox",
"slider",
"spinbutton",
"switch",
"tab",
"treeitem",
"Iframe",
];
const CONTENT_ROLES: &[&str] = &[
"heading",
"cell",
"gridcell",
"columnheader",
"rowheader",
"listitem",
"article",
"region",
"main",
"navigation",
];
const INVISIBLE_CHARS: &[char] = &[
'\u{FEFF}', '\u{200B}', '\u{200C}', '\u{200D}', '\u{2060}', '\u{00A0}', ];
#[derive(Default)]
pub struct SnapshotOptions {
pub selector: Option<String>,
pub interactive: bool,
pub compact: bool,
pub depth: Option<usize>,
pub urls: bool,
}
struct TreeNode {
role: String,
name: String,
level: Option<i64>,
checked: Option<String>,
expanded: Option<bool>,
selected: Option<bool>,
disabled: Option<bool>,
required: Option<bool>,
value_text: Option<String>,
backend_node_id: Option<i64>,
children: Vec<usize>,
parent_idx: Option<usize>,
has_ref: bool,
ref_id: Option<String>,
depth: usize,
cursor_info: Option<CursorElementInfo>,
url: Option<String>,
}
impl TreeNode {
fn empty() -> Self {
Self {
role: String::new(),
name: String::new(),
level: None,
checked: None,
expanded: None,
selected: None,
disabled: None,
required: None,
value_text: None,
backend_node_id: None,
children: Vec::new(),
parent_idx: None,
has_ref: false,
ref_id: None,
depth: 0,
cursor_info: None,
url: None,
}
}
fn clear(&mut self) {
self.role = String::new();
self.name = String::new();
self.level = None;
self.checked = None;
self.expanded = None;
self.selected = None;
self.disabled = None;
self.required = None;
self.value_text = None;
self.backend_node_id = None;
self.children.clear();
self.parent_idx = None;
self.has_ref = false;
self.url = None;
self.ref_id = None;
self.depth = 0;
self.cursor_info = None;
}
}
#[derive(Clone, Copy)]
enum HiddenInputKind {
Radio,
Checkbox,
}
impl HiddenInputKind {
fn parse(s: &str) -> Option<Self> {
match s {
"radio" => Some(Self::Radio),
"checkbox" => Some(Self::Checkbox),
_ => None,
}
}
fn as_role(&self) -> &str {
match self {
Self::Radio => "radio",
Self::Checkbox => "checkbox",
}
}
}
#[derive(Clone)]
struct CursorElementInfo {
kind: String, hints: Vec<String>,
text: String, hidden_input_kind: Option<HiddenInputKind>,
hidden_input_checked: Option<String>, }
struct RoleNameTracker {
counts: HashMap<String, usize>,
entries: Vec<(usize, String)>,
}
impl RoleNameTracker {
fn new() -> Self {
Self {
counts: HashMap::new(),
entries: Vec::new(),
}
}
fn track(&mut self, role: &str, name: &str, node_idx: usize) -> usize {
let key = format!("{}:{}", role, name);
let count = self.counts.entry(key.clone()).or_insert(0);
let nth = *count;
*count += 1;
self.entries.push((node_idx, key));
nth
}
fn get_duplicates(&self) -> HashMap<String, usize> {
self.counts
.iter()
.filter(|(_, &count)| count > 1)
.map(|(key, &count)| (key.clone(), count))
.collect()
}
}
pub async fn take_snapshot(
client: &CdpClient,
session_id: &str,
options: &SnapshotOptions,
ref_map: &mut RefMap,
frame_id: Option<&str>,
iframe_sessions: &HashMap<String, String>,
) -> Result<String, String> {
client
.send_command_no_params("DOM.enable", Some(session_id))
.await?;
client
.send_command_no_params("Accessibility.enable", Some(session_id))
.await?;
let selector_backend_ids: Option<std::collections::HashSet<i64>> =
if let Some(ref selector) = options.selector {
let js = format!(
"document.querySelector({})",
serde_json::to_string(selector).unwrap_or_default()
);
let result: EvaluateResult = client
.send_command_typed(
"Runtime.evaluate",
&EvaluateParams {
expression: js,
return_by_value: Some(false),
await_promise: Some(false),
},
Some(session_id),
)
.await?;
if let Some(exception) = result.exception_details {
let detail = exception
.exception
.and_then(|e| e.description)
.unwrap_or(exception.text);
return Err(format!("Invalid selector '{}': {}", selector, detail));
}
if result.result.subtype.as_deref() != Some("node") {
return Err(format!("Selector '{}' did not match any element", selector));
}
let object_id = result
.result
.object_id
.ok_or_else(|| format!("Selector '{}' did not match any element", selector))?;
let describe: Value = client
.send_command(
"DOM.describeNode",
Some(serde_json::json!({ "objectId": object_id, "depth": -1 })),
Some(session_id),
)
.await?;
let root_node = describe
.get("node")
.ok_or_else(|| format!("Could not resolve DOM node for selector '{}'", selector))?;
let mut ids = std::collections::HashSet::new();
collect_backend_node_ids(root_node, &mut ids);
if ids.is_empty() {
return Err(format!(
"Could not resolve backendNodeId for selector '{}'",
selector
));
}
Some(ids)
} else {
None
};
let (ax_params, effective_session_id) =
resolve_ax_session(frame_id, session_id, iframe_sessions);
if effective_session_id != session_id {
let _ = client
.send_command_no_params("DOM.enable", Some(effective_session_id))
.await;
let _ = client
.send_command_no_params("Accessibility.enable", Some(effective_session_id))
.await;
}
let ax_tree: GetFullAXTreeResult = client
.send_command_typed(
"Accessibility.getFullAXTree",
&ax_params,
Some(effective_session_id),
)
.await?;
let (mut tree_nodes, root_indices) = build_tree(&ax_tree.nodes);
let effective_roots = if let Some(ref id_set) = selector_backend_ids {
let in_subtree: Vec<bool> = tree_nodes
.iter()
.map(|n| n.backend_node_id.is_some_and(|bid| id_set.contains(&bid)))
.collect();
let mut roots = Vec::new();
for (idx, node) in tree_nodes.iter().enumerate() {
if !in_subtree[idx] {
continue;
}
let parent_in_subtree = node.parent_idx.is_some_and(|pidx| in_subtree[pidx]);
if !parent_in_subtree {
roots.push(idx);
}
}
if roots.is_empty() {
return Err(format!(
"No accessibility node found for selector '{}'",
options.selector.as_deref().unwrap_or("")
));
}
roots
} else {
root_indices
};
let mut tracker = RoleNameTracker::new();
let mut next_ref: usize = ref_map.next_ref_num();
let mut nodes_with_refs: Vec<(usize, usize)> = Vec::new();
let cursor_elements: HashMap<i64, CursorElementInfo> =
find_cursor_interactive_elements(client, session_id)
.await
.unwrap_or_default();
promote_hidden_inputs(&mut tree_nodes, &cursor_elements);
for (idx, node) in tree_nodes.iter().enumerate() {
let role = node.role.as_str();
let mut should_ref = if INTERACTIVE_ROLES.contains(&role) {
true
} else if CONTENT_ROLES.contains(&role) {
!node.name.is_empty()
} else {
false
};
if node
.backend_node_id
.is_some_and(|bid| cursor_elements.contains_key(&bid))
{
should_ref = true;
}
if should_ref {
let nth = tracker.track(role, &node.name, idx);
nodes_with_refs.push((idx, nth));
}
}
let duplicates = tracker.get_duplicates();
for (idx, nth) in &nodes_with_refs {
let node = &tree_nodes[*idx];
let key = format!("{}:{}", node.role, node.name);
let actual_nth = if duplicates.contains_key(&key) {
Some(*nth)
} else {
None
};
let ref_id = format!("e{}", next_ref);
next_ref += 1;
ref_map.add_with_frame(
ref_id.clone(),
tree_nodes[*idx].backend_node_id,
&tree_nodes[*idx].role,
&tree_nodes[*idx].name,
actual_nth,
frame_id,
);
tree_nodes[*idx].has_ref = true;
tree_nodes[*idx].ref_id = Some(ref_id);
}
for (idx, _) in &nodes_with_refs {
if let Some(bid) = tree_nodes[*idx].backend_node_id {
if let Some(cursor_info) = cursor_elements.get(&bid) {
tree_nodes[*idx].cursor_info = Some((*cursor_info).clone());
}
}
}
ref_map.set_next_ref_num(next_ref);
if options.urls {
let link_nodes: Vec<(usize, i64)> = tree_nodes
.iter()
.enumerate()
.filter(|(_, n)| n.role == "link" && n.has_ref && n.backend_node_id.is_some())
.filter_map(|(i, n)| n.backend_node_id.map(|bid| (i, bid)))
.collect();
if !link_nodes.is_empty() {
let resolve_futs = link_nodes.iter().map(|&(idx, bid)| async move {
let resolved = client
.send_command(
"DOM.resolveNode",
Some(serde_json::json!({ "backendNodeId": bid })),
Some(session_id),
)
.await;
let obj_id = resolved.ok().and_then(|r| {
r.get("object")
.and_then(|o| o.get("objectId"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
});
(idx, obj_id)
});
let resolved: Vec<(usize, Option<String>)> =
futures_util::future::join_all(resolve_futs).await;
let href_futs: Vec<_> = resolved
.iter()
.filter_map(|(idx, obj_id)| {
let oid = obj_id.as_ref()?;
Some(async move {
let result = client
.send_command(
"Runtime.callFunctionOn",
Some(serde_json::json!({
"objectId": oid,
"functionDeclaration": "function() { return this.href || ''; }",
"returnByValue": true,
})),
Some(session_id),
)
.await;
let href = result.ok().and_then(|r| {
r.get("result")
.and_then(|r| r.get("value"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
});
(*idx, href)
})
})
.collect();
let hrefs: Vec<(usize, Option<String>)> =
futures_util::future::join_all(href_futs).await;
for (idx, href) in hrefs {
if let Some(url) = href {
tree_nodes[idx].url = Some(url);
}
}
}
}
let mut output = String::new();
for &root_idx in &effective_roots {
render_tree(&tree_nodes, root_idx, 0, &mut output, options);
}
if frame_id.is_none() {
let mut iframe_snapshots: Vec<(String, String)> = Vec::new(); for node in tree_nodes.iter() {
if node.role != "Iframe" || !node.has_ref {
continue;
}
let Some(bid) = node.backend_node_id else {
continue;
};
let ref_id = node.ref_id.as_deref().unwrap_or("");
if let Ok(child_fid) = resolve_iframe_frame_id(client, session_id, bid).await {
if let Ok(child_text) = Box::pin(take_snapshot(
client,
session_id,
options,
ref_map,
Some(&child_fid),
iframe_sessions,
))
.await
{
if !child_text.is_empty()
&& child_text != "(empty page)"
&& child_text != "(no interactive elements)"
{
iframe_snapshots.push((ref_id.to_string(), child_text));
}
}
}
}
for (ref_id, child_text) in iframe_snapshots {
let marker = format!("[ref={}]", ref_id);
if let Some(pos) = output.find(&marker) {
let line_end = output[pos..]
.find('\n')
.map(|i| pos + i)
.unwrap_or(output.len());
let line_start = output[..pos].rfind('\n').map(|i| i + 1).unwrap_or(0);
let iframe_line = &output[line_start..line_end];
let iframe_indent = iframe_line.len() - iframe_line.trim_start().len();
let child_indent = iframe_indent + 2; let prefix = " ".repeat(child_indent);
let indented_child: String = child_text
.lines()
.map(|line| format!("{}{}\n", prefix, line))
.collect();
if line_end == output.len() {
output.push('\n');
output.push_str(&indented_child);
} else {
output.insert_str(line_end + 1, &indented_child);
}
}
}
}
if options.compact {
output = compact_tree(&output, options.interactive);
}
let trimmed = output.trim().to_string();
if trimmed.is_empty() {
if options.interactive {
return Ok("(no interactive elements)".to_string());
}
return Ok("(empty page)".to_string());
}
Ok(trimmed)
}
async fn resolve_iframe_frame_id(
client: &CdpClient,
session_id: &str,
backend_node_id: i64,
) -> Result<String, String> {
let describe: Value = client
.send_command(
"DOM.describeNode",
Some(serde_json::json!({ "backendNodeId": backend_node_id, "depth": 1 })),
Some(session_id),
)
.await?;
if let Some(frame_id) = describe
.get("node")
.and_then(|n| n.get("contentDocument"))
.and_then(|cd| cd.get("frameId"))
.and_then(|v| v.as_str())
{
return Ok(frame_id.to_string());
}
describe
.get("node")
.and_then(|n| n.get("frameId"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.ok_or_else(|| "Could not resolve iframe frame ID".to_string())
}
async fn find_cursor_interactive_elements(
client: &CdpClient,
session_id: &str,
) -> Result<HashMap<i64, CursorElementInfo>, String> {
let js = r#"
(function() {
var results = [];
if (!document.body) return results;
var interactiveRoles = {
'button':1, 'link':1, 'textbox':1, 'checkbox':1, 'radio':1, 'combobox':1, 'listbox':1,
'menuitem':1, 'menuitemcheckbox':1, 'menuitemradio':1, 'option':1, 'searchbox':1,
'slider':1, 'spinbutton':1, 'switch':1, 'tab':1, 'treeitem':1
};
var interactiveTags = {
'a':1, 'button':1, 'input':1, 'select':1, 'textarea':1, 'details':1, 'summary':1
};
var allElements = document.body.querySelectorAll('*');
for (var i = 0; i < allElements.length; i++) {
var el = allElements[i];
if (el.closest && el.closest('[hidden], [aria-hidden="true"]')) continue;
var tagName = el.tagName.toLowerCase();
if (interactiveTags[tagName]) continue;
var role = el.getAttribute('role');
if (role && interactiveRoles[role.toLowerCase()]) continue;
var computedStyle = getComputedStyle(el);
var hasCursorPointer = computedStyle.cursor === 'pointer';
var hasOnClick = el.hasAttribute('onclick') || el.onclick !== null;
var tabIndex = el.getAttribute('tabindex');
var hasTabIndex = tabIndex !== null && tabIndex !== '-1';
var ce = el.getAttribute('contenteditable');
var isEditable = ce === '' || ce === 'true';
if (!hasCursorPointer && !hasOnClick && !hasTabIndex && !isEditable) continue;
// Skip elements that only inherit cursor:pointer from an ancestor
if (hasCursorPointer && !hasOnClick && !hasTabIndex && !isEditable) {
var parent = el.parentElement;
if (parent && getComputedStyle(parent).cursor === 'pointer') continue;
}
var text = (el.textContent || '').trim().slice(0, 100);
var rect = el.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) continue;
// Detect hidden radio/checkbox inputs inside this element (common pattern:
// <label> wrapping a display:none <input type="radio"> styled as a card).
// Note: we only check display/visibility/hidden, NOT opacity:0 or sr-only,
// because those inputs remain in Chrome's AX tree and already appear as
// role="radio" without promotion.
var hiddenInputType = null;
var hiddenInputChecked = null;
var hiddenInput = el.querySelector('input[type="radio"], input[type="checkbox"]');
if (hiddenInput) {
var hiddenInputStyle = getComputedStyle(hiddenInput);
var isInputHidden = hiddenInputStyle.display === 'none' || hiddenInputStyle.visibility === 'hidden' || hiddenInput.hidden;
if (isInputHidden) {
hiddenInputType = hiddenInput.type;
hiddenInputChecked = hiddenInput.indeterminate ? 'mixed' : String(hiddenInput.checked);
}
}
el.setAttribute('data-__ab-ci', String(results.length));
results.push({
text: text,
tagName: tagName,
hasOnClick: hasOnClick,
hasCursorPointer: hasCursorPointer,
hasTabIndex: hasTabIndex,
isEditable: isEditable,
hiddenInputType: hiddenInputType,
hiddenInputChecked: hiddenInputChecked
});
}
return results;
})()
"#;
let result: EvaluateResult = client
.send_command_typed(
"Runtime.evaluate",
&EvaluateParams {
expression: js.to_string(),
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
)
.await?;
let elements: Vec<Value> = result
.result
.value
.and_then(|v| serde_json::from_value::<Vec<Value>>(v).ok())
.unwrap_or_default();
if elements.is_empty() {
return Ok(HashMap::new());
}
let doc: Value = client
.send_command(
"DOM.getDocument",
Some(serde_json::json!({ "depth": 0 })),
Some(session_id),
)
.await?;
let root_node_id = doc
.get("root")
.and_then(|r| r.get("nodeId"))
.and_then(|v| v.as_i64())
.ok_or("DOM.getDocument did not return root nodeId")?;
let query_result: Value = client
.send_command(
"DOM.querySelectorAll",
Some(serde_json::json!({
"nodeId": root_node_id,
"selector": "[data-__ab-ci]"
})),
Some(session_id),
)
.await?;
let node_ids: Vec<i64> = query_result
.get("nodeIds")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_i64()).collect())
.unwrap_or_default();
let describe_futures: Vec<_> = node_ids
.iter()
.map(|&node_id| {
client.send_command(
"DOM.describeNode",
Some(serde_json::json!({ "nodeId": node_id })),
Some(session_id),
)
})
.collect();
let describe_results = futures_util::future::join_all(describe_futures).await;
let mut idx_to_backend: HashMap<usize, i64> = HashMap::new();
for desc in describe_results.into_iter().flatten() {
let backend_id = desc
.get("node")
.and_then(|n| n.get("backendNodeId"))
.and_then(|v| v.as_i64());
let ci_attr = desc
.get("node")
.and_then(|n| n.get("attributes"))
.and_then(|a| a.as_array())
.and_then(|attrs| {
attrs
.iter()
.enumerate()
.find(|(_, v)| v.as_str() == Some("data-__ab-ci"))
.and_then(|(i, _)| attrs.get(i + 1))
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<usize>().ok())
});
if let (Some(bid), Some(idx)) = (backend_id, ci_attr) {
idx_to_backend.insert(idx, bid);
}
}
let cleanup_js =
r#"(function(){ var els = document.querySelectorAll('[data-__ab-ci]'); for (var i = 0; i < els.length; i++) els[i].removeAttribute('data-__ab-ci'); return els.length; })()"#.to_string();
if let Err(e) = client
.send_command_typed::<EvaluateParams, EvaluateResult>(
"Runtime.evaluate",
&EvaluateParams {
expression: cleanup_js,
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
)
.await
{
eprintln!(
"[browser-automation-cli] Warning: failed to clean up data-__ab-ci attributes: {e}"
);
}
let mut map: HashMap<i64, CursorElementInfo> = HashMap::new();
for (i, elem) in elements.iter().enumerate() {
let backend_node_id = idx_to_backend.get(&i).copied();
let has_cursor_pointer = elem
.get("hasCursorPointer")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let has_on_click = elem
.get("hasOnClick")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let has_tab_index = elem
.get("hasTabIndex")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let is_editable = elem
.get("isEditable")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let kind = if has_cursor_pointer || has_on_click {
"clickable"
} else if is_editable {
"editable"
} else {
"focusable"
};
let mut hints: Vec<String> = Vec::new();
if has_cursor_pointer {
hints.push("cursor:pointer".to_string());
}
if has_on_click {
hints.push("onclick".to_string());
}
if has_tab_index {
hints.push("tabindex".to_string());
}
if is_editable {
hints.push("contenteditable".to_string());
}
let text = elem
.get("text")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim()
.to_string();
let hidden_input_kind = elem
.get("hiddenInputType")
.and_then(|v| v.as_str())
.and_then(HiddenInputKind::parse);
let hidden_input_checked = elem
.get("hiddenInputChecked")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
if let Some(bid) = backend_node_id {
map.insert(
bid,
CursorElementInfo {
kind: kind.to_string(),
hints,
text,
hidden_input_kind,
hidden_input_checked,
},
);
}
}
Ok(map)
}
fn promote_hidden_inputs(
tree_nodes: &mut [TreeNode],
cursor_elements: &HashMap<i64, CursorElementInfo>,
) {
for node in tree_nodes.iter_mut() {
if !matches!(node.role.as_str(), "LabelText" | "generic") {
continue;
}
let cursor_info = match node
.backend_node_id
.and_then(|bid| cursor_elements.get(&bid))
{
Some(info) => info,
None => continue,
};
if let Some(input_kind) = cursor_info.hidden_input_kind {
node.role = input_kind.as_role().to_string();
if node.name.is_empty() && !cursor_info.text.is_empty() {
node.name = cursor_info.text.clone();
}
if let Some(ref checked) = cursor_info.hidden_input_checked {
node.checked = Some(checked.clone());
}
}
}
}
fn build_tree(nodes: &[AXNode]) -> (Vec<TreeNode>, Vec<usize>) {
let mut tree_nodes: Vec<TreeNode> = Vec::with_capacity(nodes.len());
let mut id_to_idx: HashMap<String, usize> = HashMap::new();
for (i, node) in nodes.iter().enumerate() {
let role = extract_ax_string(&node.role);
let name = extract_ax_string(&node.name);
let value_text = extract_ax_string_opt(&node.value);
let (level, checked, expanded, selected, disabled, required) =
extract_properties(&node.properties);
if (node.ignored.unwrap_or(false) && role != "RootWebArea") || role == "InlineTextBox" {
tree_nodes.push(TreeNode::empty());
id_to_idx.insert(node.node_id.clone(), i);
continue;
}
tree_nodes.push(TreeNode {
role,
name,
level,
checked,
expanded,
selected,
disabled,
required,
value_text,
backend_node_id: node.backend_d_o_m_node_id,
children: Vec::new(),
parent_idx: None,
has_ref: false,
ref_id: None,
depth: 0,
cursor_info: None,
url: None,
});
id_to_idx.insert(node.node_id.clone(), i);
}
for (i, node) in nodes.iter().enumerate() {
if let Some(ref child_ids) = node.child_ids {
for cid in child_ids {
if let Some(&child_idx) = id_to_idx.get(cid) {
tree_nodes[i].children.push(child_idx);
tree_nodes[child_idx].parent_idx = Some(i);
}
}
}
}
for i in 0..tree_nodes.len() {
if tree_nodes[i].role.is_empty() || tree_nodes[i].children.is_empty() {
continue;
}
let children_indices: Vec<usize> = tree_nodes[i].children.clone();
let mut start = 0;
while start < children_indices.len() {
if tree_nodes[children_indices[start]].role != "StaticText" {
start += 1;
continue;
}
let mut end = start + 1;
while end < children_indices.len()
&& tree_nodes[children_indices[end]].role == "StaticText"
{
end += 1;
}
if end > start + 1 {
let aggregated_name: String = (start..end)
.map(|idx| tree_nodes[children_indices[idx]].name.clone())
.collect();
tree_nodes[children_indices[start]].name = aggregated_name;
for j in (start + 1)..end {
tree_nodes[children_indices[j]].clear();
}
}
start = end;
}
if children_indices.len() == 1
&& tree_nodes[children_indices[0]].role == "StaticText"
&& tree_nodes[i].name == tree_nodes[children_indices[0]].name
{
tree_nodes[children_indices[0]].clear();
}
}
let mut root_indices = Vec::new();
let children_exist: Vec<bool> = nodes.iter().map(|_| false).collect();
let mut is_child = children_exist;
for node in &tree_nodes {
for &child in &node.children {
is_child[child] = true;
}
}
for (i, &is_c) in is_child.iter().enumerate() {
if !is_c {
root_indices.push(i);
}
}
fn set_depth(nodes: &mut [TreeNode], idx: usize, depth: usize) {
nodes[idx].depth = depth;
let children: Vec<usize> = nodes[idx].children.clone();
for child_idx in children {
set_depth(nodes, child_idx, depth + 1);
}
}
for &root in &root_indices {
set_depth(&mut tree_nodes, root, 0);
}
(tree_nodes, root_indices)
}
fn render_tree(
nodes: &[TreeNode],
idx: usize,
indent: usize,
output: &mut String,
options: &SnapshotOptions,
) {
let node = &nodes[idx];
if node.role.is_empty()
|| (node.role == "generic" && !node.has_ref && node.children.len() <= 1)
|| (node.role == "StaticText" && node.name.replace(INVISIBLE_CHARS, "").is_empty())
{
for &child in &node.children {
render_tree(nodes, child, indent, output, options);
}
return;
}
if let Some(max_depth) = options.depth {
if indent > max_depth {
return;
}
}
let role = &node.role;
if role == "RootWebArea" || role == "WebArea" {
for &child in &node.children {
render_tree(nodes, child, indent, output, options);
}
return;
}
if options.interactive && !node.has_ref {
for &child in &node.children {
render_tree(nodes, child, indent, output, options);
}
return;
}
let prefix = " ".repeat(indent);
let mut line = format!("{}- {}", prefix, role);
let unescaped_display_name = if !node.name.is_empty() {
&node.name
} else if options.interactive {
if let Some(ref ci) = node.cursor_info {
&ci.text
} else {
&node.name
}
} else {
&node.name
};
if !unescaped_display_name.is_empty() {
if let Ok(display_name) = serde_json::to_string(&unescaped_display_name) {
line.push_str(&format!(" {}", display_name.replace(INVISIBLE_CHARS, "")));
}
}
let mut attrs = Vec::new();
if let Some(level) = node.level {
attrs.push(format!("level={}", level));
}
if let Some(ref checked) = node.checked {
attrs.push(format!("checked={}", checked));
}
if let Some(expanded) = node.expanded {
attrs.push(format!("expanded={}", expanded));
}
if let Some(selected) = node.selected {
if selected {
attrs.push("selected".to_string());
}
}
if let Some(disabled) = node.disabled {
if disabled {
attrs.push("disabled".to_string());
}
}
if let Some(required) = node.required {
if required {
attrs.push("required".to_string());
}
}
if let Some(ref ref_id) = node.ref_id {
attrs.push(format!("ref={}", ref_id));
}
if let Some(ref url) = node.url {
attrs.push(format!("url={}", url));
}
if !attrs.is_empty() {
line.push_str(&format!(" [{}]", attrs.join(", ")));
}
if let Some(ref cursor_info) = node.cursor_info {
line.push_str(&format!(
" {} [{}]",
cursor_info.kind,
cursor_info.hints.join(", ")
));
}
if let Some(ref val) = node.value_text {
if !val.is_empty() && val != &node.name {
line.push_str(&format!(": {}", val));
}
}
output.push_str(&line);
output.push('\n');
for &child in &node.children {
render_tree(nodes, child, indent + 1, output, options);
}
}
fn compact_tree(tree: &str, interactive: bool) -> String {
let lines: Vec<&str> = tree.lines().collect();
if lines.is_empty() {
return String::new();
}
let mut keep = vec![false; lines.len()];
for (i, line) in lines.iter().enumerate() {
if line.contains("ref=") || line.contains(": ") {
keep[i] = true;
let my_indent = count_indent(line);
for j in (0..i).rev() {
let ancestor_indent = count_indent(lines[j]);
if ancestor_indent < my_indent {
keep[j] = true;
if ancestor_indent == 0 {
break;
}
}
}
}
}
let result: Vec<&str> = lines
.iter()
.enumerate()
.filter(|(i, _)| keep[*i])
.map(|(_, line)| *line)
.collect();
let output = result.join("\n");
if output.trim().is_empty() && interactive {
return "(no interactive elements)".to_string();
}
output
}
fn count_indent(line: &str) -> usize {
let trimmed = line.trim_start();
(line.len() - trimmed.len()) / 2
}
fn extract_ax_string(value: &Option<AXValue>) -> String {
match value {
Some(v) => match &v.value {
Some(Value::String(s)) => s.clone(),
Some(Value::Number(n)) => n.to_string(),
Some(Value::Bool(b)) => b.to_string(),
_ => String::new(),
},
None => String::new(),
}
}
fn extract_ax_string_opt(value: &Option<AXValue>) -> Option<String> {
match value {
Some(v) => match &v.value {
Some(Value::String(s)) if !s.is_empty() => Some(s.clone()),
Some(Value::Number(n)) => Some(n.to_string()),
_ => None,
},
None => None,
}
}
type NodeProperties = (
Option<i64>, Option<String>, Option<bool>, Option<bool>, Option<bool>, Option<bool>, );
fn extract_properties(props: &Option<Vec<AXProperty>>) -> NodeProperties {
let mut level = None;
let mut checked = None;
let mut expanded = None;
let mut selected = None;
let mut disabled = None;
let mut required = None;
if let Some(properties) = props {
for prop in properties {
match prop.name.as_str() {
"level" => {
level = prop.value.value.as_ref().and_then(|v| v.as_i64());
}
"checked" => {
checked = prop.value.value.as_ref().map(|v| match v {
Value::String(s) => s.clone(),
Value::Bool(b) => b.to_string(),
_ => "false".to_string(),
});
}
"expanded" => {
expanded = prop.value.value.as_ref().and_then(|v| v.as_bool());
}
"selected" => {
selected = prop.value.value.as_ref().and_then(|v| v.as_bool());
}
"disabled" => {
disabled = prop.value.value.as_ref().and_then(|v| v.as_bool());
}
"required" => {
required = prop.value.value.as_ref().and_then(|v| v.as_bool());
}
_ => {}
}
}
}
(level, checked, expanded, selected, disabled, required)
}
#[cfg(test)]
fn build_dedup_set(ref_map: &RefMap) -> std::collections::HashSet<String> {
ref_map
.entries_sorted()
.into_iter()
.filter(|(_, entry)| !entry.name.is_empty())
.map(|(_, entry)| entry.name.to_lowercase())
.collect()
}
fn collect_backend_node_ids(node: &Value, ids: &mut std::collections::HashSet<i64>) {
if let Some(id) = node.get("backendNodeId").and_then(|v| v.as_i64()) {
ids.insert(id);
}
if let Some(children) = node.get("children").and_then(|v| v.as_array()) {
for child in children {
collect_backend_node_ids(child, ids);
}
}
if let Some(shadow) = node.get("shadowRoots").and_then(|v| v.as_array()) {
for child in shadow {
collect_backend_node_ids(child, ids);
}
}
if let Some(doc) = node.get("contentDocument") {
collect_backend_node_ids(doc, ids);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_interactive_roles() {
assert!(INTERACTIVE_ROLES.contains(&"button"));
assert!(INTERACTIVE_ROLES.contains(&"textbox"));
assert!(!INTERACTIVE_ROLES.contains(&"heading"));
}
#[test]
fn test_content_roles() {
assert!(CONTENT_ROLES.contains(&"heading"));
assert!(!CONTENT_ROLES.contains(&"button"));
}
#[test]
fn test_compact_tree_basic() {
let tree = "- navigation\n - link \"Home\" [ref=e1]\n - link \"About\" [ref=e2]\n- main\n - heading \"Title\"\n - paragraph\n - text: Hello\n";
let result = compact_tree(tree, false);
assert!(result.contains("[ref=e1]"));
assert!(result.contains("[ref=e2]"));
assert!(result.contains("Hello"));
}
#[test]
fn test_compact_tree_radio_checkbox() {
let tree = "- form\n - radio \"Single unit\" [checked=false, ref=e1]\n - checkbox \"I agree\" [checked=false, ref=e2]\n - button \"Submit\" [ref=e3]\n";
let result = compact_tree(tree, true);
assert!(
result.contains("radio \"Single unit\""),
"radio should be kept"
);
assert!(
result.contains("checkbox \"I agree\""),
"checkbox should be kept"
);
assert!(
result.contains("button \"Submit\""),
"button should be kept"
);
}
#[test]
fn test_compact_tree_empty_interactive() {
let result = compact_tree("- generic\n", true);
assert_eq!(result, "(no interactive elements)");
}
#[test]
fn test_count_indent() {
assert_eq!(count_indent("- heading"), 0);
assert_eq!(count_indent(" - link"), 1);
assert_eq!(count_indent(" - text"), 2);
}
#[test]
fn test_role_name_tracker() {
let mut tracker = RoleNameTracker::new();
assert_eq!(tracker.track("button", "Submit", 0), 0);
assert_eq!(tracker.track("button", "Submit", 1), 1);
assert_eq!(tracker.track("button", "Cancel", 2), 0);
let dups = tracker.get_duplicates();
assert!(dups.contains_key("button:Submit"));
assert!(!dups.contains_key("button:Cancel"));
}
#[test]
fn test_dedup_set_from_ref_map_names() {
let mut ref_map = RefMap::new();
ref_map.add("e1".to_string(), Some(1), "link", "Example Link", None);
ref_map.add("e2".to_string(), Some(2), "button", "Submit", None);
let set = build_dedup_set(&ref_map);
assert!(set.contains("example link"));
assert!(set.contains("submit"));
assert!(!set.contains("other text"));
}
#[test]
fn test_dedup_set_case_insensitive() {
let mut ref_map = RefMap::new();
ref_map.add("e1".to_string(), Some(1), "button", "Submit Form", None);
let set = build_dedup_set(&ref_map);
assert!(set.contains("submit form"));
assert!(!set.contains("Submit Form"));
}
#[test]
fn test_dedup_set_empty_inputs() {
let ref_map = RefMap::new();
let set = build_dedup_set(&ref_map);
assert!(set.is_empty());
}
#[test]
fn test_dedup_set_skips_empty_names() {
let mut ref_map = RefMap::new();
ref_map.add("e1".to_string(), Some(1), "generic", "", None);
ref_map.add("e2".to_string(), Some(2), "button", "OK", None);
let set = build_dedup_set(&ref_map);
assert_eq!(set.len(), 1);
assert!(set.contains("ok"));
}
#[test]
fn test_cross_origin_iframe_uses_dedicated_session() {
let parent_session = "parent-session";
let iframe_frame_id = "cross-origin-iframe-frame";
let iframe_session = "cross-origin-iframe-session";
let mut iframe_sessions = HashMap::new();
iframe_sessions.insert(iframe_frame_id.to_string(), iframe_session.to_string());
let (params, session) =
resolve_ax_session(Some(iframe_frame_id), parent_session, &iframe_sessions);
assert_eq!(session, iframe_session);
assert_eq!(params, serde_json::json!({}));
}
#[test]
fn test_same_origin_iframe_uses_parent_session_with_frame_id() {
let parent_session = "parent-session";
let iframe_frame_id = "same-origin-iframe-frame";
let iframe_sessions = HashMap::new();
let (params, session) =
resolve_ax_session(Some(iframe_frame_id), parent_session, &iframe_sessions);
assert_eq!(session, parent_session);
assert_eq!(params, serde_json::json!({ "frameId": iframe_frame_id }));
}
#[test]
fn test_main_frame_uses_parent_session() {
let parent_session = "parent-session";
let iframe_sessions = HashMap::new();
let (params, session) = resolve_ax_session(None, parent_session, &iframe_sessions);
assert_eq!(session, parent_session);
assert_eq!(params, serde_json::json!({}));
}
fn make_node(role: &str, name: &str, backend_node_id: Option<i64>) -> TreeNode {
let mut node = TreeNode::empty();
node.role = role.to_string();
node.name = name.to_string();
node.backend_node_id = backend_node_id;
node
}
fn make_cursor_info(
hidden_kind: Option<HiddenInputKind>,
hidden_checked: Option<&str>,
text: &str,
) -> CursorElementInfo {
CursorElementInfo {
kind: "clickable".to_string(),
hints: vec!["cursor:pointer".to_string()],
text: text.to_string(),
hidden_input_kind: hidden_kind,
hidden_input_checked: hidden_checked.map(|s| s.to_string()),
}
}
#[test]
fn test_promote_label_with_hidden_radio() {
let mut nodes = vec![
make_node("LabelText", "", Some(1)),
make_node("LabelText", "", Some(2)),
make_node("button", "Submit", Some(3)),
];
let mut cursor_elements = HashMap::new();
cursor_elements.insert(
1,
make_cursor_info(Some(HiddenInputKind::Radio), Some("false"), "Option A"),
);
cursor_elements.insert(
2,
make_cursor_info(Some(HiddenInputKind::Radio), Some("true"), "Option B"),
);
promote_hidden_inputs(&mut nodes, &cursor_elements);
assert_eq!(nodes[0].role, "radio");
assert_eq!(nodes[0].name, "Option A");
assert_eq!(nodes[0].checked, Some("false".to_string()));
assert_eq!(nodes[1].role, "radio");
assert_eq!(nodes[1].name, "Option B");
assert_eq!(nodes[1].checked, Some("true".to_string()));
assert_eq!(nodes[2].role, "button");
}
#[test]
fn test_promote_preserves_existing_name() {
let mut nodes = vec![make_node("LabelText", "AX Name", Some(1))];
let mut cursor_elements = HashMap::new();
cursor_elements.insert(
1,
make_cursor_info(Some(HiddenInputKind::Radio), Some("false"), "Text Content"),
);
promote_hidden_inputs(&mut nodes, &cursor_elements);
assert_eq!(nodes[0].role, "radio");
assert_eq!(nodes[0].name, "AX Name"); }
#[test]
fn test_promote_skips_without_hidden_input() {
let mut nodes = vec![make_node("LabelText", "", Some(1))];
let mut cursor_elements = HashMap::new();
cursor_elements.insert(1, make_cursor_info(None, None, "Click me"));
promote_hidden_inputs(&mut nodes, &cursor_elements);
assert_eq!(nodes[0].role, "LabelText"); }
}