Skip to main content

eoka_server/
snapshot.rs

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}