Skip to main content

playwright_cdp/
accessibility.rs

1//! `Accessibility` — accessibility-tree snapshot via the CDP `Accessibility`
2//! domain.
3//!
4//! [`Page::accessibility`](crate::Page::accessibility) returns an
5//! [`Accessibility`] handle bound to the page-level CDP session.
6//! [`Accessibility::snapshot`] captures a point-in-time view of the page's
7//! accessibility tree and returns it as an [`AccessibilityNode`] tree.
8//!
9//! CDP exposes two relevant commands:
10//! - `Accessibility.getFullAXTree` — the entire page's accessibility tree
11//!   (used when no `root` is requested).
12//! - `Accessibility.getPartialAXTree` — the subtree rooted at a given
13//!   `backendNodeId` (used when `root` is supplied).
14//!
15//! Both return a flat `nodes` array. We rebuild the parent/child relationships
16//! from each node's `childIds` into the nested [`AccessibilityNode`] shape that
17//! mirrors Playwright's `Accessibility.snapshot`.
18
19use crate::cdp::session::CdpSession;
20use crate::error::{Error, Result};
21use serde_json::{json, Value};
22use std::collections::HashMap;
23use std::sync::Arc;
24
25/// An accessibility snapshot handle for a [`Page`](crate::Page).
26///
27/// Cheaply cloneable; all clones share the same underlying page-level session.
28#[derive(Clone)]
29pub struct Accessibility {
30    inner: Arc<AccessibilityInner>,
31}
32
33struct AccessibilityInner {
34    /// The page-level CDP session (Accessibility domain).
35    session: CdpSession,
36}
37
38impl Accessibility {
39    pub(crate) fn new(session: CdpSession) -> Self {
40        Self {
41            inner: Arc::new(AccessibilityInner { session }),
42        }
43    }
44
45    /// Capture an accessibility-tree snapshot.
46    ///
47    /// Without `options` (or with `interesting_only: true`), this fetches the
48    /// full accessibility tree and prunes uninteresting nodes (those without a
49    /// role/name/value/children), mirroring Playwright's default. When `root`
50    /// is supplied, the partial tree rooted at that node is fetched instead via
51    /// `Accessibility.getPartialAXTree`.
52    pub async fn snapshot(
53        &self,
54        options: Option<AccessibilitySnapshotOptions>,
55    ) -> Result<Option<AccessibilityNode>> {
56        let opts = options.unwrap_or_default();
57        let interesting_only = opts.interesting_only.unwrap_or(true);
58
59        // Best-effort enable; some Chrome builds require it before the get*
60        // commands return data.
61        let _ = self.inner.session.send("Accessibility.enable", json!({})).await;
62
63        let nodes = if let Some(root_node_id) = &opts.root {
64            self.partial_tree(root_node_id).await?
65        } else {
66            let resp: Value = self
67                .inner
68                .session
69                .send("Accessibility.getFullAXTree", json!({}))
70                .await?;
71            resp.get("nodes")
72                .and_then(|v| v.as_array())
73                .cloned()
74                .unwrap_or_default()
75        };
76
77        if nodes.is_empty() {
78            return Ok(None);
79        }
80
81        let root_id = match nodes.first().and_then(|n| n.get("nodeId")).and_then(|v| v.as_str()) {
82            Some(id) => id.to_string(),
83            None => return Ok(None),
84        };
85
86        let tree = build_tree(&nodes, &root_id, interesting_only)?;
87        Ok(tree)
88    }
89
90    /// Fetch the partial accessibility tree rooted at `backend_node_id` via
91    /// `Accessibility.getPartialAXTree`.
92    async fn partial_tree(&self, backend_node_id: &str) -> Result<Vec<Value>> {
93        // `backendNodeId` is an integer in CDP; accept a numeric string.
94        let backend: i64 = backend_node_id
95            .parse()
96            .map_err(|_| Error::InvalidArgument("root must be a numeric backendNodeId".into()))?;
97        let resp: Value = self
98            .inner
99            .session
100            .send(
101                "Accessibility.getPartialAXTree",
102                json!({ "backendNodeId": backend }),
103            )
104            .await?;
105        Ok(resp
106            .get("nodes")
107            .and_then(|v| v.as_array())
108            .cloned()
109            .unwrap_or_default())
110    }
111}
112
113/// Options for [`Accessibility::snapshot`].
114#[derive(Debug, Clone, Default)]
115#[non_exhaustive]
116pub struct AccessibilitySnapshotOptions {
117    /// When `true` (the default), uninteresting nodes (no role/name/value and
118    /// no interesting children) are pruned from the result. When `false`, the
119    /// raw tree is returned verbatim.
120    pub interesting_only: Option<bool>,
121    /// A backend node id to snapshot the partial subtree of, instead of the
122    /// whole page. Maps to `Accessibility.getPartialAXTree.backendNodeId`.
123    pub root: Option<String>,
124}
125
126impl AccessibilitySnapshotOptions {
127    pub fn interesting_only(mut self, v: bool) -> Self {
128        self.interesting_only = Some(v);
129        self
130    }
131    pub fn root(mut self, v: impl Into<String>) -> Self {
132        self.root = Some(v.into());
133        self
134    }
135}
136
137/// A single node in an accessibility-tree snapshot.
138///
139/// Mirrors the useful subset of Playwright's `AccessibilityNode` and CDP's
140/// `AXNode`. Fields are optional since CDP omits any that don't apply.
141#[derive(Debug, Clone, Default)]
142#[non_exhaustive]
143pub struct AccessibilityNode {
144    /// The ARIA/DOM role (e.g. `"button"`, `"textbox"`).
145    pub role: Option<String>,
146    /// The accessible name.
147    pub name: Option<String>,
148    /// The current value, when the node has one.
149    pub value: Option<Value>,
150    /// The accessible description.
151    pub description: Option<String>,
152    /// Checked state: `"checked"`, `"unchecked"`, `"mixed"`, or `None`.
153    pub checked: Option<String>,
154    /// Whether the node is disabled.
155    pub disabled: Option<bool>,
156    /// Expanded state: `"expanded"`, `"collapsed"`, or `None`.
157    pub expanded: Option<String>,
158    /// Whether the node is focused.
159    pub focused: Option<bool>,
160    /// Whether the node is modal.
161    pub modal: Option<bool>,
162    /// Multi-line state for text inputs.
163    pub multiline: Option<bool>,
164    /// Read-only state.
165    pub readonly: Option<bool>,
166    /// Required state.
167    pub required: Option<bool>,
168    /// Selected state.
169    pub selected: Option<bool>,
170    /// Whether the node is hidden from the accessibility tree.
171    pub hidden: Option<bool>,
172    /// Child nodes.
173    pub children: Vec<AccessibilityNode>,
174}
175
176impl AccessibilityNode {
177    pub fn role(&self) -> Option<&str> {
178        self.role.as_deref()
179    }
180    pub fn name(&self) -> Option<&str> {
181        self.name.as_deref()
182    }
183}
184
185/// Rebuild a nested [`AccessibilityNode`] tree from CDP's flat `nodes` array.
186///
187/// `interesting_only` controls pruning: a node is kept if it has any
188/// meaningful property (role/name/value/description or a non-empty children
189/// list after pruning), and dropped otherwise — matching Playwright's default.
190fn build_tree(
191    nodes: &[Value],
192    root_id: &str,
193    interesting_only: bool,
194) -> Result<Option<AccessibilityNode>> {
195    // Index nodes by their `nodeId` for O(1) lookup.
196    let mut by_id: HashMap<String, &Value> = HashMap::with_capacity(nodes.len());
197    for n in nodes {
198        if let Some(id) = n.get("nodeId").and_then(|v| v.as_str()) {
199            by_id.insert(id.to_string(), n);
200        }
201    }
202    let root = by_id.get(root_id).ok_or_else(|| {
203        Error::ProtocolError(format!(
204            "accessibility tree root node '{root_id}' not found in response"
205        ))
206    })?;
207    let mut node = convert_node(root, &by_id, interesting_only)?;
208    if interesting_only {
209        node = prune(node);
210    }
211    if node.role.is_none()
212        && node.name.is_none()
213        && node.value.is_none()
214        && node.description.is_none()
215        && node.children.is_empty()
216    {
217        return Ok(None);
218    }
219    Ok(Some(node))
220}
221
222/// Convert one CDP `AXNode` into an [`AccessibilityNode`], recursing into its
223/// `childIds`.
224fn convert_node(
225    raw: &Value,
226    by_id: &HashMap<String, &Value>,
227    interesting_only: bool,
228) -> Result<AccessibilityNode> {
229    let role = property_value(raw, "role").or_else(|| {
230        raw.get("role")
231            .and_then(|r| r.get("value"))
232            .and_then(|v| v.as_str())
233            .map(String::from)
234    });
235    // CDP carries `name` and `description` as top-level `AxValue` fields (same
236    // shape as `role`: `{ type, value }`), so fall back to the top-level value
237    // when they aren't present in the `properties` array.
238    let name = property_value(raw, "name").or_else(|| {
239        raw.get("name")
240            .and_then(|n| n.get("value"))
241            .and_then(|v| v.as_str())
242            .map(String::from)
243    });
244    let description = property_value(raw, "description").or_else(|| {
245        raw.get("description")
246            .and_then(|d| d.get("value"))
247            .and_then(|v| v.as_str())
248            .map(String::from)
249    });
250    let value = raw
251        .get("value")
252        .and_then(|v| v.get("value"))
253        .cloned()
254        .filter(|v| !v.is_null());
255
256    let checked = property_value(raw, "checked").filter(|s| s != "false");
257    let expanded = property_value(raw, "expanded").map(|s| match s.as_str() {
258        "true" => "expanded".to_string(),
259        "false" => "collapsed".to_string(),
260        other => other.to_string(),
261    });
262    let disabled = boolean_property(raw, "disabled");
263    let focused = boolean_property(raw, "focused");
264    let modal = boolean_property(raw, "modal");
265    let multiline = boolean_property(raw, "multiline");
266    let readonly = boolean_property(raw, "readonly");
267    let required = boolean_property(raw, "required");
268    let selected = boolean_property(raw, "selected");
269    let hidden = boolean_property(raw, "hidden")
270        .or_else(|| property_value(raw, "visibility").map(|s| s == "hidden"));
271
272    let mut children = Vec::new();
273    if let Some(child_ids) = raw.get("childIds").and_then(|v| v.as_array()) {
274        for cid in child_ids {
275            if let Some(id) = cid.as_str() {
276                if let Some(child_raw) = by_id.get(id) {
277                    let mut child = convert_node(child_raw, by_id, interesting_only)?;
278                    if interesting_only {
279                        child = prune(child);
280                    }
281                    // After pruning, drop empty children entirely so they don't
282                    // pad the tree.
283                    let empty = child.role.is_none()
284                        && child.name.is_none()
285                        && child.value.is_none()
286                        && child.description.is_none()
287                        && child.children.is_empty();
288                    if !empty {
289                        children.push(child);
290                    }
291                }
292            }
293        }
294    }
295
296    Ok(AccessibilityNode {
297        role,
298        name,
299        value,
300        description,
301        checked,
302        disabled,
303        expanded,
304        focused,
305        modal,
306        multiline,
307        readonly,
308        required,
309        selected,
310        hidden,
311        children,
312    })
313}
314
315/// Read a string-valued property from a node's `properties` array.
316///
317/// CDP shapes properties as `[{ name, value: { type, value } }, ...]`.
318fn property_value(node: &Value, name: &str) -> Option<String> {
319    let props = node.get("properties").and_then(|v| v.as_array())?;
320    for p in props {
321        let matches = p
322            .get("name")
323            .and_then(|v| v.as_str())
324            .map(|n| n == name)
325            .unwrap_or(false);
326        if matches {
327            return p
328                .get("value")
329                .and_then(|v| v.get("value"))
330                .and_then(|v| v.as_str())
331                .map(String::from);
332        }
333    }
334    None
335}
336
337/// Read a boolean-valued property from a node's `properties` array.
338fn boolean_property(node: &Value, name: &str) -> Option<bool> {
339    let props = node.get("properties").and_then(|v| v.as_array())?;
340    for p in props {
341        let matches = p
342            .get("name")
343            .and_then(|v| v.as_str())
344            .map(|n| n == name)
345            .unwrap_or(false);
346        if matches {
347            return p
348                .get("value")
349                .and_then(|v| v.get("value"))
350                .and_then(|v| v.as_bool());
351        }
352    }
353    None
354}
355
356/// Drop an "interesting_only"-filtered node that carries no meaningful
357/// payload, returning an empty node; the caller then discards empty nodes.
358fn prune(node: AccessibilityNode) -> AccessibilityNode {
359    // Keep nodes that have any user-facing property or surviving children.
360    let has_payload = node.role.is_some()
361        || node.name.is_some()
362        || node.value.is_some()
363        || node.description.is_some()
364        || node.checked.is_some()
365        || node.expanded.is_some()
366        || node.disabled == Some(true)
367        || node.focused == Some(true)
368        || node.selected == Some(true);
369    if has_payload || !node.children.is_empty() {
370        node
371    } else {
372        AccessibilityNode::default()
373    }
374}