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}