Skip to main content

earl_protocol_browser/
accessibility.rs

1use std::collections::HashMap;
2
3/// Simplified AX node populated from CDP Accessibility.getFullAXTree.
4#[derive(Debug, Clone)]
5pub struct AXNode {
6    pub backend_node_id: u64,
7    pub role: String,
8    pub name: String,
9    pub children: Vec<AXNode>,
10}
11
12/// Render AX tree to markdown with opaque refs.
13/// Returns (markdown_text, ref_id → backend_node_id map).
14/// ref_id format: "e{backend_node_id}"
15pub fn render_ax_tree(nodes: &[AXNode], max_nodes: usize) -> (String, HashMap<String, u64>) {
16    let mut buf = String::new();
17    let mut refs: HashMap<String, u64> = HashMap::new();
18    let mut count = 0usize;
19    render_nodes(nodes, 0, max_nodes, &mut count, &mut buf, &mut refs);
20    if count >= max_nodes {
21        buf.push_str(&format!(
22            "\n[accessibility tree truncated at {max_nodes} nodes — increase max_snapshot_nodes if needed]"
23        ));
24    }
25    (buf, refs)
26}
27
28fn render_nodes(
29    nodes: &[AXNode],
30    depth: usize,
31    max: usize,
32    count: &mut usize,
33    buf: &mut String,
34    refs: &mut HashMap<String, u64>,
35) {
36    let indent = "  ".repeat(depth);
37    for node in nodes {
38        if *count >= max {
39            return;
40        }
41        let ref_id = format!("e{}", node.backend_node_id);
42        refs.insert(ref_id.clone(), node.backend_node_id);
43        buf.push_str(&format!(
44            "{}- {} \"{}\" [ref={}]\n",
45            indent, node.role, node.name, ref_id
46        ));
47        *count += 1;
48        if !node.children.is_empty() {
49            render_nodes(&node.children, depth + 1, max, count, buf, refs);
50        }
51    }
52}
53
54#[cfg(test)]
55mod tests {
56    use super::*;
57
58    #[test]
59    fn ax_tree_to_markdown_with_refs() {
60        let nodes = vec![
61            AXNode {
62                backend_node_id: 1,
63                role: "button".into(),
64                name: "Login".into(),
65                children: vec![],
66            },
67            AXNode {
68                backend_node_id: 2,
69                role: "textbox".into(),
70                name: "Email".into(),
71                children: vec![],
72            },
73        ];
74        let (markdown, refs) = render_ax_tree(&nodes, 5000);
75        assert!(
76            markdown.contains("button \"Login\" [ref=e1]"),
77            "got: {markdown}"
78        );
79        assert!(
80            markdown.contains("textbox \"Email\" [ref=e2]"),
81            "got: {markdown}"
82        );
83        assert_eq!(refs.get("e1"), Some(&1u64));
84        assert_eq!(refs.get("e2"), Some(&2u64));
85    }
86
87    #[test]
88    fn ax_tree_truncates_at_max_nodes() {
89        let nodes: Vec<AXNode> = (1..=10)
90            .map(|i| AXNode {
91                backend_node_id: i,
92                role: "button".into(),
93                name: format!("btn{i}"),
94                children: vec![],
95            })
96            .collect();
97        let (markdown, refs) = render_ax_tree(&nodes, 5);
98        assert!(
99            markdown.contains("truncated"),
100            "expected truncation notice, got: {markdown}"
101        );
102        assert_eq!(refs.len(), 5, "expected 5 refs, got {}", refs.len());
103    }
104
105    #[test]
106    fn nested_children_rendered_with_indent() {
107        let nodes = vec![AXNode {
108            backend_node_id: 1,
109            role: "list".into(),
110            name: "nav".into(),
111            children: vec![AXNode {
112                backend_node_id: 2,
113                role: "listitem".into(),
114                name: "Home".into(),
115                children: vec![],
116            }],
117        }];
118        let (markdown, _) = render_ax_tree(&nodes, 5000);
119        // Parent at depth 0, child at depth 1 (2-space indent)
120        assert!(
121            markdown.contains("  - listitem"),
122            "expected indented child, got: {markdown}"
123        );
124    }
125
126    #[test]
127    fn empty_tree_returns_empty_string_and_empty_refs() {
128        let (markdown, refs) = render_ax_tree(&[], 5000);
129        assert!(
130            markdown.is_empty() || !markdown.contains("[ref="),
131            "got: {markdown}"
132        );
133        assert!(refs.is_empty());
134    }
135}