1use std::collections::HashMap;
2use std::fmt::Write;
3
4use crate::eoka::Page;
5use anyhow::Result;
6use serde::{Deserialize, Serialize};
7
8#[derive(Serialize)]
9#[serde(rename_all = "camelCase")]
10struct GetFullAXTreeParams {}
11
12#[derive(Deserialize)]
13#[serde(rename_all = "camelCase")]
14struct AXTreeResult {
15 nodes: Vec<AXNode>,
16}
17
18#[derive(Deserialize)]
19#[serde(rename_all = "camelCase")]
20struct AXNode {
21 node_id: String,
22 #[serde(default)]
23 ignored: bool,
24 role: Option<AXValue>,
25 name: Option<AXValue>,
26 value: Option<AXValue>,
27 #[serde(default)]
28 properties: Vec<AXProperty>,
29 parent_id: Option<String>,
30 #[serde(rename = "backendDOMNodeId")]
31 backend_dom_node_id: Option<i64>,
32}
33
34#[derive(Deserialize)]
35#[serde(rename_all = "camelCase")]
36struct AXValue {
37 value: Option<serde_json::Value>,
38}
39
40impl AXValue {
41 fn as_str(&self) -> &str {
42 self.value.as_ref().and_then(|v| v.as_str()).unwrap_or("")
43 }
44
45 fn is_truthy(&self) -> bool {
46 self.value
47 .as_ref()
48 .map(|v| v.as_bool().unwrap_or(false) || v.as_str() == Some("true"))
49 .unwrap_or(false)
50 }
51}
52
53#[derive(Deserialize)]
54#[serde(rename_all = "camelCase")]
55struct AXProperty {
56 name: String,
57 value: AXValue,
58}
59
60#[derive(Debug, Clone)]
61pub struct SnapshotRef {
62 pub ref_label: String,
63 pub backend_node_id: i64,
64 pub role: String,
65 pub name: String,
66}
67
68#[derive(Debug)]
69pub struct SnapshotResult {
70 pub tree_text: String,
71 pub refs: Vec<SnapshotRef>,
72}
73
74const NOISE_ROLES: &[&str] = &[
75 "generic",
76 "none",
77 "presentation",
78 "InlineTextBox",
79 "LineBreak",
80];
81
82fn should_skip(role: &str, name: &str, ignored: bool, include_all: bool) -> bool {
83 if ignored {
84 return true;
85 }
86 if include_all {
87 return false;
88 }
89 if NOISE_ROLES.contains(&role) {
90 return true;
91 }
92 role == "StaticText" && name.is_empty()
93}
94
95fn opt_str(v: &Option<AXValue>) -> &str {
96 v.as_ref().map(|av| av.as_str()).unwrap_or("")
97}
98
99fn format_props(node: &AXNode, out: &mut String) {
100 let val = opt_str(&node.value);
101 if !val.is_empty() {
102 let _ = write!(out, " value=\"{}\"", val);
103 }
104
105 for prop in &node.properties {
106 let token: Option<&str> = match prop.name.as_str() {
107 "disabled" if prop.value.is_truthy() => Some("disabled"),
108 "required" if prop.value.is_truthy() => Some("required"),
109 "selected" if prop.value.is_truthy() => Some("selected"),
110 "focused" if prop.value.is_truthy() => Some("focused"),
111 "checked" => match prop.value.as_str() {
112 "true" => Some("checked"),
113 "mixed" => Some("checked=mixed"),
114 _ => None,
115 },
116 "expanded" => Some(if prop.value.is_truthy() {
117 "expanded"
118 } else {
119 "collapsed"
120 }),
121 "invalid" => match prop.value.as_str() {
122 "true" | "spelling" | "grammar" => Some("invalid"),
123 _ => None,
124 },
125 _ => None,
126 };
127 if let Some(t) = token {
128 let _ = write!(out, " {}", t);
129 }
130 }
131}
132
133struct Walker<'a> {
134 nodes_by_id: HashMap<&'a str, &'a AXNode>,
135 children_of: HashMap<&'a str, Vec<&'a str>>,
136 include_all: bool,
137 ref_counter: usize,
138 refs: Vec<SnapshotRef>,
139 output: String,
140}
141
142impl<'a> Walker<'a> {
143 fn walk(&mut self, node_id: &str, depth: usize) {
144 let node = match self.nodes_by_id.get(node_id) {
145 Some(n) => *n,
146 None => return,
147 };
148
149 let role = opt_str(&node.role);
150 let name = opt_str(&node.name);
151 let skip = should_skip(role, name, node.ignored, self.include_all);
152
153 if !skip {
154 self.emit_node(node, role, name, depth);
155 }
156
157 let child_depth = if skip { depth } else { depth + 1 };
158 if let Some(children) = self.children_of.get(node_id) {
159 for child_id in children.clone() {
160 self.walk(child_id, child_depth);
161 }
162 }
163 }
164
165 fn emit_node(&mut self, node: &AXNode, role: &str, name: &str, depth: usize) {
166 let ref_label = match node.backend_dom_node_id {
167 Some(id) if id > 0 => {
168 self.ref_counter += 1;
169 let label = format!("@e{}", self.ref_counter);
170 self.refs.push(SnapshotRef {
171 ref_label: label.clone(),
172 backend_node_id: id,
173 role: role.to_string(),
174 name: name.to_string(),
175 });
176 Some(label)
177 }
178 _ => None,
179 };
180
181 for _ in 0..depth {
182 self.output.push_str(" ");
183 }
184 if let Some(ref label) = ref_label {
185 let _ = write!(self.output, "{} ", label);
186 }
187 self.output.push_str(role);
188 if !name.is_empty() {
189 let _ = write!(self.output, " \"{}\"", name);
190 }
191 format_props(node, &mut self.output);
192 self.output.push('\n');
193 }
194}
195
196pub async fn snapshot(page: &Page, include_all: bool) -> Result<SnapshotResult> {
197 let _ = page
198 .session()
199 .send::<_, serde_json::Value>("DOM.enable", &serde_json::json!({}))
200 .await;
201 let _ = page
202 .session()
203 .send::<_, serde_json::Value>(
204 "DOM.getDocument",
205 &serde_json::json!({"depth": -1, "pierce": true}),
206 )
207 .await;
208
209 let result: AXTreeResult = page
210 .session()
211 .send("Accessibility.getFullAXTree", &GetFullAXTreeParams {})
212 .await
213 .map_err(|e| anyhow::anyhow!("CDP Accessibility.getFullAXTree failed: {}", e))?;
214
215 if result.nodes.is_empty() {
216 return Ok(SnapshotResult {
217 tree_text: "No accessibility tree available.".into(),
218 refs: Vec::new(),
219 });
220 }
221
222 let (nodes_by_id, children_of, roots) = build_tree_index(&result.nodes);
223
224 let mut walker = Walker {
225 nodes_by_id,
226 children_of,
227 include_all,
228 ref_counter: 0,
229 refs: Vec::new(),
230 output: String::with_capacity(4096),
231 };
232
233 for root in roots {
234 walker.walk(root, 0);
235 }
236
237 Ok(SnapshotResult {
238 tree_text: walker.output,
239 refs: walker.refs,
240 })
241}
242
243type TreeIndex<'a> = (
244 HashMap<&'a str, &'a AXNode>,
245 HashMap<&'a str, Vec<&'a str>>,
246 Vec<&'a str>,
247);
248
249fn build_tree_index(nodes: &[AXNode]) -> TreeIndex<'_> {
250 let mut by_id: HashMap<&str, &AXNode> = HashMap::with_capacity(nodes.len());
251 let mut children: HashMap<&str, Vec<&str>> = HashMap::new();
252 let mut roots: Vec<&str> = Vec::new();
253
254 for node in nodes {
255 by_id.insert(&node.node_id, node);
256 }
257
258 for node in nodes {
259 match node.parent_id.as_deref() {
260 Some(pid) if by_id.contains_key(pid) => {
261 children.entry(pid).or_default().push(&node.node_id);
262 }
263 _ => roots.push(&node.node_id),
264 }
265 }
266
267 (by_id, children, roots)
268}
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273
274 #[test]
275 fn skip_ignored_nodes() {
276 assert!(should_skip("button", "Click", true, false));
277 assert!(should_skip("button", "Click", true, true));
278 }
279
280 #[test]
281 fn skip_noise_roles() {
282 for role in NOISE_ROLES {
283 assert!(should_skip(role, "", false, false), "should skip {}", role);
284 }
285 }
286
287 #[test]
288 fn skip_unnamed_static_text() {
289 assert!(should_skip("StaticText", "", false, false));
290 assert!(!should_skip("StaticText", "Hello", false, false));
291 }
292
293 #[test]
294 fn keep_normal_roles() {
295 for (role, name) in [
296 ("button", "Submit"),
297 ("link", "Home"),
298 ("textbox", "Email"),
299 ("heading", "Title"),
300 ] {
301 assert!(!should_skip(role, name, false, false));
302 }
303 }
304
305 #[test]
306 fn include_all_keeps_noise() {
307 assert!(!should_skip("generic", "", false, true));
308 assert!(!should_skip("none", "", false, true));
309 }
310
311 #[test]
312 fn ax_value_helpers() {
313 let truthy = AXValue {
314 value: Some(serde_json::json!(true)),
315 };
316 assert!(truthy.is_truthy());
317 assert_eq!(truthy.as_str(), "");
318
319 let str_true = AXValue {
320 value: Some(serde_json::json!("true")),
321 };
322 assert!(str_true.is_truthy());
323 assert_eq!(str_true.as_str(), "true");
324
325 let falsy = AXValue {
326 value: Some(serde_json::json!(false)),
327 };
328 assert!(!falsy.is_truthy());
329
330 let none = AXValue { value: None };
331 assert!(!none.is_truthy());
332 assert_eq!(none.as_str(), "");
333 }
334
335 #[test]
336 fn format_props_value_and_disabled() {
337 let node = AXNode {
338 node_id: "1".into(),
339 ignored: false,
340 role: None,
341 name: None,
342 value: Some(AXValue {
343 value: Some(serde_json::json!("hello")),
344 }),
345 properties: vec![AXProperty {
346 name: "disabled".into(),
347 value: AXValue {
348 value: Some(serde_json::json!(true)),
349 },
350 }],
351 parent_id: None,
352 backend_dom_node_id: None,
353 };
354 let mut out = String::new();
355 format_props(&node, &mut out);
356 assert!(out.contains("value=\"hello\""));
357 assert!(out.contains("disabled"));
358 }
359}