cdp_core/
accessibility.rs

1use crate::error::{CdpError, Result};
2use crate::page::{Page, element::ElementHandle};
3use cdp_protocol::accessibility as ax;
4use cdp_protocol::accessibility::{
5    Disable as AccessibilityDisable, DisableReturnObject as AccessibilityDisableReturnObject,
6    Enable as AccessibilityEnable, EnableReturnObject as AccessibilityEnableReturnObject,
7    GetFullAXTree, GetFullAXTreeReturnObject, GetPartialAXTree, GetPartialAXTreeReturnObject,
8};
9use std::collections::{HashMap, HashSet};
10use std::sync::Arc;
11
12/// High level controller for interacting with the Chrome Accessibility domain.
13pub struct AccessibilityController {
14    page: Arc<Page>,
15}
16
17impl AccessibilityController {
18    pub(crate) fn new(page: Arc<Page>) -> Self {
19        Self { page }
20    }
21
22    /// Captures an accessibility tree snapshot for the current page or a specific element.
23    ///
24    /// # Examples
25    /// ```no_run
26    /// # use cdp_core::{AccessibilitySnapshotOptions, Page};
27    /// # use std::sync::Arc;
28    /// # async fn example(page: Arc<Page>) -> anyhow::Result<()> {
29    /// let snapshot = page.accessibility().snapshot(None).await?;
30    /// assert!(!snapshot.is_empty());
31    /// # Ok(())
32    /// # }
33    /// ```
34    pub async fn snapshot(
35        &self,
36        options: Option<AccessibilitySnapshotOptions>,
37    ) -> Result<AccessibilitySnapshot> {
38        let opts = options.unwrap_or_default();
39        self.enable().await?;
40
41        let (nodes, root_backend_id) = if let Some(element) = opts.root.as_ref() {
42            self.ensure_same_page(element)?;
43            let method = GetPartialAXTree {
44                node_id: element.node_id,
45                backend_node_id: Some(element.backend_node_id),
46                object_id: None,
47                fetch_relatives: Some(true),
48            };
49            let response: GetPartialAXTreeReturnObject =
50                self.page.session.send_command(method, None).await?;
51            (response.nodes, Some(element.backend_node_id))
52        } else {
53            let method = GetFullAXTree {
54                depth: opts.max_depth,
55                frame_id: None,
56            };
57            let response: GetFullAXTreeReturnObject =
58                self.page.session.send_command(method, None).await?;
59            (response.nodes, None)
60        };
61
62        Ok(AccessibilitySnapshot::from_ax_nodes(
63            nodes,
64            root_backend_id,
65            opts.interesting_only,
66        ))
67    }
68
69    /// Enables the accessibility domain. Safe to call multiple times.
70    pub async fn enable(&self) -> Result<()> {
71        let method = AccessibilityEnable(None);
72        let _: AccessibilityEnableReturnObject =
73            self.page.session.send_command(method, None).await?;
74        Ok(())
75    }
76
77    /// Disables the accessibility domain.
78    pub async fn disable(&self) -> Result<()> {
79        let method = AccessibilityDisable(None);
80        let _: AccessibilityDisableReturnObject =
81            self.page.session.send_command(method, None).await?;
82        Ok(())
83    }
84
85    fn ensure_same_page(&self, element: &ElementHandle) -> Result<()> {
86        if !Arc::ptr_eq(&self.page, &element.page) {
87            return Err(CdpError::page(
88                "Accessibility snapshot root element must belong to the same page".to_string(),
89            ));
90        }
91        Ok(())
92    }
93}
94
95/// Options used when generating accessibility snapshots.
96#[derive(Clone)]
97pub struct AccessibilitySnapshotOptions {
98    pub root: Option<ElementHandle>,
99    pub interesting_only: bool,
100    pub max_depth: Option<u32>,
101}
102
103impl Default for AccessibilitySnapshotOptions {
104    fn default() -> Self {
105        Self {
106            root: None,
107            interesting_only: true,
108            max_depth: None,
109        }
110    }
111}
112
113impl AccessibilitySnapshotOptions {
114    pub fn with_root(mut self, element: ElementHandle) -> Self {
115        self.root = Some(element);
116        self
117    }
118
119    pub fn with_interesting_only(mut self, interesting_only: bool) -> Self {
120        self.interesting_only = interesting_only;
121        self
122    }
123
124    pub fn with_max_depth(mut self, depth: u32) -> Self {
125        self.max_depth = Some(depth);
126        self
127    }
128}
129
130/// Structured accessibility tree snapshot.
131#[derive(Clone, Debug, Default)]
132pub struct AccessibilitySnapshot {
133    pub roots: Vec<AccessibilityNode>,
134}
135
136impl AccessibilitySnapshot {
137    fn from_ax_nodes(
138        nodes: Vec<ax::AxNode>,
139        root_backend_id: Option<u32>,
140        interesting_only: bool,
141    ) -> Self {
142        let mut map: HashMap<String, ax::AxNode> = HashMap::new();
143        for node in nodes {
144            map.insert(node.node_id.clone(), node);
145        }
146
147        let mut referenced_children: HashSet<String> = HashSet::new();
148        for node in map.values() {
149            if let Some(children) = &node.child_ids {
150                for child in children {
151                    referenced_children.insert(child.clone());
152                }
153            }
154        }
155
156        let mut candidate_roots: Vec<String> = Vec::new();
157        if let Some(backend_id) = root_backend_id
158            && let Some(root) = map
159                .values()
160                .find(|node| node.backend_dom_node_id == Some(backend_id))
161        {
162            candidate_roots.push(root.node_id.clone());
163        }
164
165        if candidate_roots.is_empty() {
166            for node in map.values() {
167                let parent_known = node.parent_id.as_ref().and_then(|parent| map.get(parent));
168                if parent_known.is_none() && !referenced_children.contains(&node.node_id) {
169                    candidate_roots.push(node.node_id.clone());
170                }
171            }
172        }
173
174        if candidate_roots.is_empty()
175            && let Some(first) = map.keys().next()
176        {
177            candidate_roots.push(first.clone());
178        }
179
180        let mut visited: HashSet<String> = HashSet::new();
181        let mut roots: Vec<AccessibilityNode> = Vec::new();
182        for root_id in candidate_roots {
183            roots.extend(build_subtree(
184                &root_id,
185                &map,
186                interesting_only,
187                &mut visited,
188            ));
189        }
190
191        Self { roots }
192    }
193
194    pub fn is_empty(&self) -> bool {
195        self.roots.is_empty()
196    }
197}
198
199fn build_subtree(
200    node_id: &str,
201    nodes: &HashMap<String, ax::AxNode>,
202    interesting_only: bool,
203    visited: &mut HashSet<String>,
204) -> Vec<AccessibilityNode> {
205    if !visited.insert(node_id.to_string()) {
206        return Vec::new();
207    }
208
209    let node = match nodes.get(node_id) {
210        Some(node) => node.clone(),
211        None => return Vec::new(),
212    };
213
214    let child_ids = node.child_ids.clone().unwrap_or_default();
215    let mut children: Vec<AccessibilityNode> = Vec::new();
216    for child_id in child_ids {
217        children.extend(build_subtree(&child_id, nodes, interesting_only, visited));
218    }
219
220    let interesting = !interesting_only || !node.ignored;
221    if interesting {
222        vec![AccessibilityNode::from_ax_node(node, children)]
223    } else {
224        children
225    }
226}
227
228/// Represents a single accessibility node in the snapshot.
229#[derive(Clone, Debug)]
230pub struct AccessibilityNode {
231    pub node_id: String,
232    pub ignored: bool,
233    pub ignored_reasons: Vec<AccessibilityProperty>,
234    pub role: Option<ax::AxValue>,
235    pub chrome_role: Option<ax::AxValue>,
236    pub name: Option<ax::AxValue>,
237    pub description: Option<ax::AxValue>,
238    pub value: Option<ax::AxValue>,
239    pub properties: Vec<AccessibilityProperty>,
240    pub backend_dom_node_id: Option<u32>,
241    pub frame_id: Option<String>,
242    pub children: Vec<AccessibilityNode>,
243}
244
245impl AccessibilityNode {
246    fn from_ax_node(node: ax::AxNode, children: Vec<AccessibilityNode>) -> Self {
247        Self {
248            node_id: node.node_id,
249            ignored: node.ignored,
250            ignored_reasons: convert_properties(node.ignored_reasons),
251            role: node.role,
252            chrome_role: node.chrome_role,
253            name: node.name,
254            description: node.description,
255            value: node.value,
256            properties: convert_properties(node.properties),
257            backend_dom_node_id: node.backend_dom_node_id,
258            frame_id: node.frame_id,
259            children,
260        }
261    }
262}
263
264/// Name/value pair extracted from an `AxProperty`.
265#[derive(Clone, Debug)]
266pub struct AccessibilityProperty {
267    pub name: String,
268    pub value: ax::AxValue,
269}
270
271fn convert_properties(input: Option<Vec<ax::AxProperty>>) -> Vec<AccessibilityProperty> {
272    input
273        .unwrap_or_default()
274        .into_iter()
275        .map(|prop| AccessibilityProperty {
276            name: serialize_property_name(&prop.name),
277            value: prop.value,
278        })
279        .collect()
280}
281
282fn serialize_property_name(name: &ax::AxPropertyName) -> String {
283    serde_json::to_value(name)
284        .ok()
285        .and_then(|val| val.as_str().map(|s| s.to_string()))
286        .unwrap_or_else(|| format!("{:?}", name))
287}