use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct AxNode {
pub role: String,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub value: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub description: Option<String>,
#[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
pub properties: BTreeMap<String, String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub children: Vec<AxNode>,
}
impl AxNode {
fn is_blank(&self) -> bool {
self.role.is_empty() && self.name.is_empty() && self.properties.is_empty()
}
pub fn count(&self) -> usize {
1 + self.children.iter().map(AxNode::count).sum::<usize>()
}
fn collect<'a>(&'a self, out: &mut Vec<&'a AxNode>) {
out.push(self);
for c in &self.children {
c.collect(out);
}
}
fn write_outline(&self, depth: usize, out: &mut String) {
if self.is_blank() {
for c in &self.children {
c.write_outline(depth, out);
}
return;
}
for _ in 0..depth {
out.push_str(" ");
}
if self.role.is_empty() {
out.push_str("text");
} else {
out.push_str(&self.role);
}
if !self.name.is_empty() {
out.push_str(" \"");
out.push_str(&self.name);
out.push('"');
}
if !self.properties.is_empty() {
let kv: Vec<String> = self
.properties
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect();
out.push_str(" [");
out.push_str(&kv.join(", "));
out.push(']');
}
out.push('\n');
for c in &self.children {
c.write_outline(depth + 1, out);
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AxTree {
pub root: AxNode,
}
impl AxTree {
pub fn new(root: AxNode) -> Self {
Self { root }
}
pub fn count(&self) -> usize {
self.root.count()
}
pub fn nodes(&self) -> Vec<&AxNode> {
let mut out = Vec::new();
self.root.collect(&mut out);
out
}
pub fn find_by_role(&self, role: &str) -> Vec<&AxNode> {
self.nodes()
.into_iter()
.filter(|n| n.role == role)
.collect()
}
pub fn find_by_name(&self, substr: &str) -> Vec<&AxNode> {
self.nodes()
.into_iter()
.filter(|n| n.name.contains(substr))
.collect()
}
pub fn to_outline(&self) -> String {
let mut s = String::new();
self.root.write_outline(0, &mut s);
s
}
pub fn to_json(&self) -> String {
serde_json::to_string_pretty(self).unwrap_or_else(|_| "{}".into())
}
}
pub fn build_from_cdp(result: &Value) -> AxTree {
let nodes = result["nodes"].as_array().cloned().unwrap_or_default();
let mut index: BTreeMap<String, usize> = BTreeMap::new();
let mut parsed: Vec<(bool, AxNode, Vec<String>)> = Vec::with_capacity(nodes.len());
let mut has_parent: BTreeMap<String, bool> = BTreeMap::new();
let mut order: Vec<String> = Vec::new();
for n in &nodes {
let Some(id) = n["nodeId"].as_str() else {
continue;
};
let ignored = n["ignored"].as_bool().unwrap_or(false);
let role = ax_field(&n["role"]);
let name = ax_field(&n["name"]);
let value = non_empty(ax_field(&n["value"]));
let description = non_empty(ax_field(&n["description"]));
let properties = parse_cdp_props(&n["properties"]);
let child_ids: Vec<String> = n["childIds"]
.as_array()
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
for c in &child_ids {
has_parent.insert(c.clone(), true);
}
index.insert(id.to_string(), parsed.len());
order.push(id.to_string());
parsed.push((
ignored,
AxNode {
role,
name,
value,
description,
properties,
children: Vec::new(),
},
child_ids,
));
}
let root_id = order
.iter()
.find(|id| !has_parent.get(*id).copied().unwrap_or(false))
.or_else(|| order.first())
.cloned();
let mut visited = std::collections::BTreeSet::new();
let roots = match root_id {
Some(id) => assemble_cdp(&id, &index, &parsed, &mut visited),
None => Vec::new(),
};
let root = match roots.len() {
1 => roots.into_iter().next().unwrap(),
_ => AxNode {
role: String::new(),
name: String::new(),
children: roots,
..Default::default()
},
};
AxTree::new(root)
}
fn assemble_cdp(
id: &str,
index: &BTreeMap<String, usize>,
parsed: &[(bool, AxNode, Vec<String>)],
visited: &mut std::collections::BTreeSet<String>,
) -> Vec<AxNode> {
if !visited.insert(id.to_string()) {
return Vec::new(); }
let Some(&i) = index.get(id) else {
return Vec::new();
};
let (ignored, node, child_ids) = &parsed[i];
let mut kids = Vec::new();
for c in child_ids {
kids.extend(assemble_cdp(c, index, parsed, visited));
}
if *ignored {
kids
} else {
let mut me = node.clone();
me.children = kids;
vec![me]
}
}
fn ax_field(field: &Value) -> String {
match &field["value"] {
Value::String(s) => s.clone(),
Value::Null => String::new(),
other => other.to_string(),
}
}
fn parse_cdp_props(props: &Value) -> BTreeMap<String, String> {
const KEEP: &[&str] = &[
"checked", "disabled", "expanded", "focused", "selected", "pressed", "required", "level",
"invalid",
];
let mut out = BTreeMap::new();
if let Some(arr) = props.as_array() {
for p in arr {
let Some(name) = p["name"].as_str() else {
continue;
};
if !KEEP.contains(&name) {
continue;
}
let v = match &p["value"]["value"] {
Value::String(s) => s.clone(),
Value::Bool(b) => b.to_string(),
Value::Number(n) => n.to_string(),
_ => continue,
};
if v == "false" {
continue;
}
out.insert(name.to_string(), v);
}
}
out
}
pub fn build_from_snapshot(value: &Value) -> AxTree {
AxTree::new(parse_snapshot_node(value))
}
fn parse_snapshot_node(v: &Value) -> AxNode {
let role = v["role"].as_str().unwrap_or("").to_string();
let name = v["name"].as_str().unwrap_or("").trim().to_string();
let value = non_empty(v["value"].as_str().unwrap_or("").to_string());
let mut properties = BTreeMap::new();
if let Some(obj) = v["props"].as_object() {
for (k, val) in obj {
let s = match val {
Value::String(s) => s.clone(),
Value::Bool(b) => b.to_string(),
Value::Number(n) => n.to_string(),
_ => continue,
};
properties.insert(k.clone(), s);
}
}
let children = v["children"]
.as_array()
.map(|a| a.iter().map(parse_snapshot_node).collect())
.unwrap_or_default();
AxNode {
role,
name,
value,
description: None,
properties,
children,
}
}
fn non_empty(s: String) -> Option<String> {
if s.is_empty() { None } else { Some(s) }
}
pub const AX_SNAPSHOT_JS: &str = r#"(()=>{
const TAG_ROLE={a:'link',button:'button',nav:'navigation',main:'main',header:'banner',
footer:'contentinfo',aside:'complementary',h1:'heading',h2:'heading',h3:'heading',
h4:'heading',h5:'heading',h6:'heading',img:'img',ul:'list',ol:'list',li:'listitem',
table:'table',thead:'rowgroup',tbody:'rowgroup',tr:'row',td:'cell',th:'columnheader',
form:'form',select:'combobox',textarea:'textbox',option:'option',section:'region',
article:'article',dialog:'dialog',fieldset:'group',legend:'',summary:'button'};
const INPUT_ROLE={checkbox:'checkbox',radio:'radio',button:'button',submit:'button',
reset:'button',range:'slider',search:'searchbox',email:'textbox',tel:'textbox',
url:'textbox',number:'spinbutton',password:'textbox',text:'textbox'};
const SKIP={script:1,style:1,noscript:1,template:1,head:1,meta:1,link:1,br:1,svg:1,path:1};
const roleOf=(el)=>{
const ex=el.getAttribute('role'); if(ex) return ex.trim().split(/\s+/)[0];
const tag=el.tagName.toLowerCase();
if(tag==='a') return el.hasAttribute('href')?'link':'';
if(tag==='input'){const t=(el.getAttribute('type')||'text').toLowerCase();return INPUT_ROLE[t]||'textbox';}
return TAG_ROLE[tag]||'';
};
const ownText=(el)=>{let s='';for(const n of el.childNodes){if(n.nodeType===3)s+=n.textContent;}return s.trim();};
const nameOf=(el)=>{
const al=el.getAttribute('aria-label'); if(al&&al.trim()) return al.trim();
const lb=el.getAttribute('aria-labelledby');
if(lb){const r=lb.split(/\s+/).map(id=>{const n=document.getElementById(id);return n?(n.innerText||''):'';}).join(' ').trim();if(r)return r;}
const tag=el.tagName.toLowerCase();
if(tag==='img') return (el.getAttribute('alt')||'').trim();
if(tag==='input'){const t=(el.getAttribute('type')||'text').toLowerCase();
if(t==='submit'||t==='button'||t==='reset') return (el.value||'').trim();
return (el.getAttribute('placeholder')||el.getAttribute('name')||'').trim();}
const o=ownText(el); if(o) return o.slice(0,120);
if(['button','a','h1','h2','h3','h4','h5','h6','label','option','summary','td','th','legend'].includes(tag))
return (el.innerText||'').trim().slice(0,120);
return '';
};
const visible=(el)=>{const s=window.getComputedStyle(el);
if(!s||s.display==='none'||s.visibility==='hidden'||s.visibility==='collapse')return false;
if(el.hasAttribute('hidden')||el.getAttribute('aria-hidden')==='true')return false;return true;};
const propsOf=(el)=>{const p={};
if(el.hasAttribute('disabled'))p.disabled='true';
const ac=el.getAttribute('aria-checked');
if(ac){if(ac!=='false')p.checked=ac;}
else if(el.tagName==='INPUT'&&(el.type==='checkbox'||el.type==='radio')){if(el.checked)p.checked='true';}
const ae=el.getAttribute('aria-expanded'); if(ae)p.expanded=ae;
const as=el.getAttribute('aria-selected'); if(as&&as!=='false')p.selected=as;
if(/^h[1-6]$/.test(el.tagName.toLowerCase()))p.level=el.tagName[1];
return p;};
const valueOf=(el)=>{const tag=el.tagName.toLowerCase();
if(tag==='input'){const t=(el.getAttribute('type')||'text').toLowerCase();
if(['text','email','tel','url','number','password','search'].includes(t))return el.value||'';}
if(tag==='textarea')return el.value||'';return '';};
let count=0;const MAX=4000;
function build(el){
if(count>MAX||!el||el.nodeType!==1)return null;
if(SKIP[el.tagName.toLowerCase()])return null;
if(!visible(el))return null;
count++;
const kids=[];for(const c of el.children){const k=build(c);if(k)kids.push(k);}
const role=roleOf(el),name=nameOf(el),pr=propsOf(el),val=valueOf(el);
if(!role&&!name&&!val&&Object.keys(pr).length===0){
if(kids.length===0)return null;
if(kids.length===1)return kids[0];
return {role:'',name:'',children:kids};
}
const node={role:role,name:name};
if(val)node.value=val;
if(Object.keys(pr).length)node.props=pr;
if(kids.length)node.children=kids;
return node;
}
return build(document.body)||{role:'',name:''};
})()"#;
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn n(role: &str, name: &str) -> AxNode {
AxNode {
role: role.into(),
name: name.into(),
..Default::default()
}
}
#[test]
fn cdp_flat_rebuilds_tree_and_skips_ignored() {
let r = json!({"nodes":[
{"nodeId":"1","role":{"value":"RootWebArea"},"name":{"value":"Doc"},"childIds":["2","3"]},
{"nodeId":"2","ignored":true,"role":{"value":"generic"},"name":{"value":""},
"parentId":"1","childIds":["4"]},
{"nodeId":"3","role":{"value":"button"},"name":{"value":"OK"},"parentId":"1","childIds":[]},
{"nodeId":"4","role":{"value":"StaticText"},"name":{"value":"hi"},"parentId":"2","childIds":[]}
]});
let t = build_from_cdp(&r);
assert_eq!(t.root.role, "RootWebArea");
assert_eq!(t.root.children.len(), 2); let roles: Vec<&str> = t.root.children.iter().map(|c| c.role.as_str()).collect();
assert!(roles.contains(&"StaticText") && roles.contains(&"button"));
assert_eq!(t.find_by_role("button").len(), 1);
assert_eq!(t.find_by_role("button")[0].name, "OK");
}
#[test]
fn cdp_props_keep_only_truthy_state() {
let r = json!({"nodes":[
{"nodeId":"1","role":{"value":"checkbox"},"name":{"value":"Agree"},"childIds":[],
"properties":[
{"name":"checked","value":{"value":"true"}},
{"name":"disabled","value":{"value":"false"}},
{"name":"live","value":{"value":"polite"}}
]}
]});
let t = build_from_cdp(&r);
assert_eq!(
t.root.properties.get("checked").map(String::as_str),
Some("true")
);
assert!(!t.root.properties.contains_key("disabled"));
assert!(!t.root.properties.contains_key("live"));
}
#[test]
fn outline_indents_and_collapses_blank() {
let mut root = n("", ""); let mut form = n("form", "Login");
form.children.push(n("textbox", "User"));
let mut btn = n("button", "Submit");
btn.properties.insert("disabled".into(), "true".into());
form.children.push(btn);
root.children.push(form);
let t = AxTree::new(root);
let o = t.to_outline();
assert!(o.starts_with("form \"Login\"\n"));
assert!(o.contains("\n textbox \"User\"\n"));
assert!(o.contains("\n button \"Submit\" [disabled=true]\n"));
}
#[test]
fn find_helpers_and_count() {
let mut root = n("RootWebArea", "");
root.children.push(n("link", "Home"));
root.children.push(n("link", "About us"));
root.children.push(n("button", "Buy"));
let t = AxTree::new(root);
assert_eq!(t.count(), 4);
assert_eq!(t.find_by_role("link").len(), 2);
assert_eq!(t.find_by_name("us").len(), 1);
assert_eq!(t.find_by_name("us")[0].role, "link");
}
#[test]
fn snapshot_json_parsed() {
let v = json!({"role":"form","name":"Login","children":[
{"role":"textbox","name":"User","value":"bob","props":{"disabled":"true"}},
{"role":"button","name":"Go"}
]});
let t = build_from_snapshot(&v);
assert_eq!(t.root.role, "form");
assert_eq!(t.root.children.len(), 2);
let tb = &t.root.children[0];
assert_eq!(tb.value.as_deref(), Some("bob"));
assert_eq!(
tb.properties.get("disabled").map(String::as_str),
Some("true")
);
}
#[test]
fn to_json_skips_empty_fields() {
let t = AxTree::new(n("button", "OK"));
let j = t.to_json();
assert!(j.contains("\"role\": \"button\""));
assert!(!j.contains("\"value\""));
assert!(!j.contains("\"children\""));
}
}