Skip to main content

browser_control/a11y/
refs.rs

1//! Per-tab element reference table.
2//!
3//! A ref (`e1`, `e2`, …) is a short, agent-facing handle for a DOM node
4//! discovered through an accessibility snapshot or `browser_find`. Each ref
5//! maps to a CDP `backendDOMNodeId`, which is what `DOM.*` and `Input.*`
6//! accept directly, so a ref-based click never has to re-resolve a CSS
7//! selector.
8//!
9//! Refs are stable for the lifetime of a *document*: taking a second
10//! snapshot of the same page reuses the existing ref for a node that was
11//! already interned, and only allocates new numbers for nodes that appear
12//! for the first time. A navigation replaces the whole table (the document
13//! token — the Document node's `backendDOMNodeId` — changes), so refs from
14//! the previous page are reported as stale instead of silently hitting a
15//! recycled node id.
16
17use std::collections::HashMap;
18
19/// One interned element.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct RefEntry {
22    /// The agent-facing handle, e.g. `e12`.
23    pub r#ref: String,
24    /// CDP `backendDOMNodeId` of the element.
25    pub backend_node_id: u64,
26    /// Accessibility role at the time of interning (for messages only).
27    pub role: String,
28    /// Accessible name at the time of interning (for messages only).
29    pub name: String,
30}
31
32/// Ref table for one tab and one document.
33#[derive(Debug, Clone)]
34pub struct RefTable {
35    /// Identity of the document the refs were taken from (Document node's
36    /// `backendDOMNodeId`). Compared before every ref-based action.
37    pub doc_token: u64,
38    by_ref: HashMap<String, RefEntry>,
39    by_backend: HashMap<u64, String>,
40    next: u32,
41}
42
43impl RefTable {
44    pub fn new(doc_token: u64) -> Self {
45        Self {
46            doc_token,
47            by_ref: HashMap::new(),
48            by_backend: HashMap::new(),
49            next: 1,
50        }
51    }
52
53    /// Return the ref for `backend_node_id`, allocating a fresh `eN` when
54    /// the node has not been seen in this document yet. Role and name are
55    /// refreshed on every call so error messages describe the latest
56    /// snapshot.
57    pub fn intern(&mut self, backend_node_id: u64, role: &str, name: &str) -> String {
58        if let Some(r) = self.by_backend.get(&backend_node_id) {
59            if let Some(entry) = self.by_ref.get_mut(r) {
60                entry.role = role.to_string();
61                entry.name = name.to_string();
62            }
63            return r.clone();
64        }
65        let r = format!("e{}", self.next);
66        self.next += 1;
67        self.by_backend.insert(backend_node_id, r.clone());
68        self.by_ref.insert(
69            r.clone(),
70            RefEntry {
71                r#ref: r.clone(),
72                backend_node_id,
73                role: role.to_string(),
74                name: name.to_string(),
75            },
76        );
77        r
78    }
79
80    /// Look up an interned ref.
81    pub fn lookup(&self, r: &str) -> Option<&RefEntry> {
82        self.by_ref.get(r)
83    }
84
85    /// Number of interned refs (tests / diagnostics).
86    pub fn len(&self) -> usize {
87        self.by_ref.len()
88    }
89
90    pub fn is_empty(&self) -> bool {
91        self.by_ref.is_empty()
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98
99    #[test]
100    fn intern_is_stable_per_backend_node() {
101        let mut t = RefTable::new(1);
102        let a = t.intern(10, "button", "Save");
103        let b = t.intern(11, "link", "Docs");
104        let a2 = t.intern(10, "button", "Save changes");
105        assert_eq!(a, "e1");
106        assert_eq!(b, "e2");
107        assert_eq!(a2, "e1");
108        assert_eq!(t.lookup("e1").unwrap().name, "Save changes");
109        assert_eq!(t.lookup("e1").unwrap().backend_node_id, 10);
110        assert!(t.lookup("e3").is_none());
111        assert_eq!(t.len(), 2);
112    }
113}