browser_use/dom/
element.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3
4/// Represents an ARIA node in the accessibility tree
5/// Based on Playwright's AriaNode structure
6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
7pub struct AriaNode {
8    /// ARIA role (e.g., "button", "link", "textbox", "generic", "iframe", "fragment")
9    pub role: String,
10
11    /// Accessible name of the element
12    pub name: String,
13
14    /// Index of the element in the interactive elements array
15    #[serde(skip_serializing_if = "Option::is_none")]
16    pub index: Option<usize>,
17
18    /// Child nodes (can be AriaNode or text strings)
19    #[serde(default)]
20    pub children: Vec<AriaChild>,
21
22    /// ARIA properties specific to this element (e.g., url, placeholder)
23    #[serde(default)]
24    pub props: HashMap<String, String>,
25
26    /// Box information (visibility, cursor)
27    #[serde(default)]
28    pub box_info: BoxInfo,
29
30    // ARIA states
31    /// Whether element is checked (for checkboxes, radios, etc.)
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub checked: Option<AriaChecked>,
34
35    /// Whether element is disabled
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub disabled: Option<bool>,
38
39    /// Whether element is expanded (for expandable elements)
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub expanded: Option<bool>,
42
43    /// Heading/list level
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub level: Option<u32>,
46
47    /// Whether button is pressed
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub pressed: Option<AriaPressed>,
50
51    /// Whether element is selected
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub selected: Option<bool>,
54
55    /// Whether element is currently active/focused
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub active: Option<bool>,
58}
59
60/// Child of an AriaNode - either another AriaNode or a text string
61#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
62#[serde(untagged)]
63pub enum AriaChild {
64    Text(String),
65    Node(Box<AriaNode>),
66}
67
68/// ARIA checked state (true, false, or mixed)
69#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
70#[serde(untagged)]
71pub enum AriaChecked {
72    Bool(bool),
73    Mixed(String), // "mixed"
74}
75
76/// ARIA pressed state (true, false, or mixed)
77#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
78#[serde(untagged)]
79pub enum AriaPressed {
80    Bool(bool),
81    Mixed(String), // "mixed"
82}
83
84/// Box/visibility information for an element
85#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
86pub struct BoxInfo {
87    /// Whether the element is visible (non-zero bounding box)
88    #[serde(default)]
89    pub visible: bool,
90
91    /// CSS cursor value (e.g., "pointer", "default")
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub cursor: Option<String>,
94}
95
96impl Default for BoxInfo {
97    fn default() -> Self {
98        Self {
99            visible: false,
100            cursor: None,
101        }
102    }
103}
104
105impl AriaNode {
106    /// Create a new AriaNode with minimal fields
107    pub fn new(role: impl Into<String>, name: impl Into<String>) -> Self {
108        Self {
109            role: role.into(),
110            name: name.into(),
111            index: None,
112            children: Vec::new(),
113            props: HashMap::new(),
114            box_info: BoxInfo::default(),
115            checked: None,
116            disabled: None,
117            expanded: None,
118            level: None,
119            pressed: None,
120            selected: None,
121            active: None,
122        }
123    }
124
125    /// Create a fragment node (root container)
126    pub fn fragment() -> Self {
127        Self::new("fragment", "")
128    }
129
130    /// Builder: set index
131    pub fn with_index(mut self, index: usize) -> Self {
132        self.index = Some(index);
133        self
134    }
135
136    /// Builder: add a child node
137    pub fn with_child(mut self, child: AriaChild) -> Self {
138        self.children.push(child);
139        self
140    }
141
142    /// Builder: add multiple children
143    pub fn with_children(mut self, children: Vec<AriaChild>) -> Self {
144        self.children = children;
145        self
146    }
147
148    /// Builder: add a property
149    pub fn with_prop(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
150        self.props.insert(key.into(), value.into());
151        self
152    }
153
154    /// Builder: set box info
155    pub fn with_box(mut self, visible: bool, cursor: Option<String>) -> Self {
156        self.box_info = BoxInfo { visible, cursor };
157        self
158    }
159
160    /// Builder: set checked state
161    pub fn with_checked(mut self, checked: bool) -> Self {
162        self.checked = Some(AriaChecked::Bool(checked));
163        self
164    }
165
166    /// Builder: set disabled state
167    pub fn with_disabled(mut self, disabled: bool) -> Self {
168        self.disabled = Some(disabled);
169        self
170    }
171
172    /// Builder: set expanded state
173    pub fn with_expanded(mut self, expanded: bool) -> Self {
174        self.expanded = Some(expanded);
175        self
176    }
177
178    /// Builder: set level
179    pub fn with_level(mut self, level: u32) -> Self {
180        self.level = Some(level);
181        self
182    }
183
184    /// Check if this node is interactive (has an index and is visible)
185    pub fn is_interactive(&self) -> bool {
186        self.index.is_some() && self.box_info.visible
187    }
188
189    /// Check if this node has pointer cursor
190    pub fn has_pointer_cursor(&self) -> bool {
191        self.box_info
192            .cursor
193            .as_ref()
194            .map_or(false, |c| c == "pointer")
195    }
196
197    /// Check if this is a fragment or iframe
198    pub fn is_container(&self) -> bool {
199        self.role == "fragment" || self.role == "iframe"
200    }
201
202    /// Get all text content (concatenate all text children recursively)
203    pub fn get_text_content(&self) -> String {
204        let mut result = String::new();
205        self.collect_text(&mut result);
206        result.trim().to_string()
207    }
208
209    fn collect_text(&self, buffer: &mut String) {
210        for child in &self.children {
211            match child {
212                AriaChild::Text(text) => {
213                    buffer.push_str(text);
214                    buffer.push(' ');
215                }
216                AriaChild::Node(node) => {
217                    node.collect_text(buffer);
218                }
219            }
220        }
221    }
222
223    /// Count total nodes in subtree
224    pub fn count_nodes(&self) -> usize {
225        1 + self
226            .children
227            .iter()
228            .map(|c| match c {
229                AriaChild::Text(_) => 0,
230                AriaChild::Node(n) => n.count_nodes(),
231            })
232            .sum::<usize>()
233    }
234
235    /// Find node by index (depth-first search)
236    pub fn find_by_index(&self, index: usize) -> Option<&AriaNode> {
237        if self.index == Some(index) {
238            return Some(self);
239        }
240
241        for child in &self.children {
242            if let AriaChild::Node(node) = child {
243                if let Some(found) = node.find_by_index(index) {
244                    return Some(found);
245                }
246            }
247        }
248
249        None
250    }
251
252    /// Find node by index (mutable)
253    pub fn find_by_index_mut(&mut self, index: usize) -> Option<&mut AriaNode> {
254        if self.index == Some(index) {
255            return Some(self);
256        }
257
258        for child in &mut self.children {
259            if let AriaChild::Node(node) = child {
260                if let Some(found) = node.find_by_index_mut(index) {
261                    return Some(found);
262                }
263            }
264        }
265
266        None
267    }
268
269    /// Count interactive elements in subtree (elements with indices)
270    pub fn count_interactive(&self) -> usize {
271        let mut count = 0;
272        self.count_interactive_recursive(&mut count);
273        count
274    }
275
276    fn count_interactive_recursive(&self, count: &mut usize) {
277        if self.index.is_some() {
278            *count += 1;
279        }
280
281        for child in &self.children {
282            if let AriaChild::Node(node) = child {
283                node.count_interactive_recursive(count);
284            }
285        }
286    }
287
288    /// Check if two nodes are equal (for diffing)
289    /// Based on Playwright's ariaNodesEqual
290    pub fn aria_equals(&self, other: &AriaNode) -> bool {
291        if self.role != other.role || self.name != other.name {
292            return false;
293        }
294
295        if self.checked != other.checked
296            || self.disabled != other.disabled
297            || self.expanded != other.expanded
298            || self.level != other.level
299            || self.pressed != other.pressed
300            || self.selected != other.selected
301        {
302            return false;
303        }
304
305        if self.has_pointer_cursor() != other.has_pointer_cursor() {
306            return false;
307        }
308
309        if self.props.len() != other.props.len() {
310            return false;
311        }
312
313        for (k, v) in &self.props {
314            if other.props.get(k) != Some(v) {
315                return false;
316            }
317        }
318
319        true
320    }
321}
322
323// Legacy compatibility: ElementNode type alias for old code
324// This allows gradual migration from ElementNode to AriaNode
325pub type ElementNode = AriaNode;
326
327// Legacy: BoundingBox (now BoxInfo)
328#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
329pub struct BoundingBox {
330    pub x: f64,
331    pub y: f64,
332    pub width: f64,
333    pub height: f64,
334}
335
336impl BoundingBox {
337    pub fn new(x: f64, y: f64, width: f64, height: f64) -> Self {
338        Self {
339            x,
340            y,
341            width,
342            height,
343        }
344    }
345
346    pub fn is_visible(&self) -> bool {
347        self.width > 0.0 && self.height > 0.0
348    }
349
350    pub fn area(&self) -> f64 {
351        self.width * self.height
352    }
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358
359    #[test]
360    fn test_is_interactive() {
361        let interactive = AriaNode::new("button", "Click")
362            .with_index(0)
363            .with_box(true, None);
364        assert!(interactive.is_interactive());
365
366        let not_interactive = AriaNode::new("button", "Click").with_box(false, None);
367        assert!(!not_interactive.is_interactive());
368
369        let no_index = AriaNode::new("button", "Click").with_box(true, None);
370        assert!(!no_index.is_interactive());
371    }
372
373    #[test]
374    fn test_has_pointer_cursor() {
375        let with_pointer = AriaNode::new("button", "").with_box(true, Some("pointer".to_string()));
376        assert!(with_pointer.has_pointer_cursor());
377
378        let without_pointer =
379            AriaNode::new("button", "").with_box(true, Some("default".to_string()));
380        assert!(!without_pointer.has_pointer_cursor());
381    }
382
383    #[test]
384    fn test_get_text_content() {
385        let mut node = AriaNode::new("div", "");
386        node.children.push(AriaChild::Text("Hello ".to_string()));
387        node.children.push(AriaChild::Node(Box::new(
388            AriaNode::new("span", "").with_child(AriaChild::Text("World".to_string())),
389        )));
390
391        assert_eq!(node.get_text_content(), "Hello  World");
392    }
393
394    #[test]
395    fn test_find_by_index() {
396        let mut root = AriaNode::new("fragment", "");
397        root.children.push(AriaChild::Node(Box::new(
398            AriaNode::new("button", "First").with_index(0),
399        )));
400        root.children.push(AriaChild::Node(Box::new(
401            AriaNode::new("button", "Second").with_index(1),
402        )));
403
404        let found = root.find_by_index(1);
405        assert!(found.is_some());
406        assert_eq!(found.unwrap().name, "Second");
407
408        let not_found = root.find_by_index(999);
409        assert!(not_found.is_none());
410    }
411
412    #[test]
413    fn test_count_interactive() {
414        let mut root = AriaNode::fragment().with_index(0);
415        root.children.push(AriaChild::Node(Box::new(
416            AriaNode::new("button", "").with_index(1),
417        )));
418        root.children.push(AriaChild::Node(Box::new(
419            AriaNode::new("link", "").with_index(2),
420        )));
421
422        let count = root.count_interactive();
423        assert_eq!(count, 3); // root + button + link
424    }
425
426    #[test]
427    fn test_aria_equals() {
428        let node1 = AriaNode::new("button", "Click")
429            .with_disabled(false)
430            .with_box(true, Some("pointer".to_string()));
431
432        let node2 = AriaNode::new("button", "Click")
433            .with_disabled(false)
434            .with_box(true, Some("pointer".to_string()));
435
436        assert!(node1.aria_equals(&node2));
437
438        let node3 = AriaNode::new("button", "Click")
439            .with_disabled(true)
440            .with_box(true, Some("pointer".to_string()));
441
442        assert!(!node1.aria_equals(&node3));
443    }
444
445    #[test]
446    fn test_count_nodes() {
447        let mut root = AriaNode::fragment();
448        root.children.push(AriaChild::Text("text".to_string()));
449        root.children
450            .push(AriaChild::Node(Box::new(AriaNode::new("button", ""))));
451        root.children.push(AriaChild::Node(Box::new(
452            AriaNode::new("div", "")
453                .with_child(AriaChild::Node(Box::new(AriaNode::new("span", "")))),
454        )));
455
456        // root + button + div + span = 4
457        assert_eq!(root.count_nodes(), 4);
458    }
459}