earl_protocol_browser/
accessibility.rs1use std::collections::HashMap;
2
3#[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
12pub 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 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}