browser_use/tools/
snapshot.rs

1use crate::dom::{AriaChild, AriaNode, yaml_escape_key_if_needed, yaml_escape_value_if_needed};
2use crate::error::Result;
3use crate::tools::{Tool, ToolContext, ToolResult};
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6
7/// Parameters for the snapshot tool
8#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
9pub struct SnapshotParams {
10    /// Whether to include full snapshot or incremental
11    #[serde(default)]
12    pub incremental: bool,
13}
14
15/// Tool for getting an ARIA snapshot of the page in YAML format
16#[derive(Default)]
17pub struct SnapshotTool;
18
19impl Tool for SnapshotTool {
20    type Params = SnapshotParams;
21
22    fn name(&self) -> &str {
23        "snapshot"
24    }
25
26    fn execute_typed(
27        &self,
28        params: SnapshotParams,
29        context: &mut ToolContext,
30    ) -> Result<ToolResult> {
31        // Get or extract the DOM tree
32        let dom = context.get_dom()?;
33
34        // Generate YAML snapshot
35        let yaml_snapshot = render_aria_tree(&dom.root, RenderMode::Ai, None);
36
37        // Count interactive elements
38        let interactive_count = dom.count_interactive();
39
40        let result = if params.incremental {
41            // TODO: Implement incremental snapshots
42            serde_json::json!({
43                "full": yaml_snapshot,
44                "interactive_count": interactive_count,
45            })
46        } else {
47            serde_json::json!({
48                "snapshot": yaml_snapshot,
49                "interactive_count": interactive_count,
50            })
51        };
52
53        Ok(ToolResult::success_with(result))
54    }
55}
56
57/// Rendering mode for ARIA tree
58#[derive(Debug, Clone, Copy)]
59pub enum RenderMode {
60    /// AI consumption mode (includes refs, cursor, active markers)
61    Ai,
62    /// Expect mode (for testing)
63    Expect,
64}
65
66/// Render an ARIA tree to YAML format
67/// Based on Playwright's renderAriaTree function
68pub fn render_aria_tree(root: &AriaNode, mode: RenderMode, previous: Option<&AriaNode>) -> String {
69    let mut lines = Vec::new();
70
71    let render_cursor_pointer = matches!(mode, RenderMode::Ai);
72    let render_active = matches!(mode, RenderMode::Ai);
73
74    // Do not render the root fragment, just its children
75    let nodes_to_render = if root.role == "fragment" {
76        &root.children
77    } else {
78        // Single root node case - wrap it
79        return render_single_node(root, mode, previous);
80    };
81
82    for node in nodes_to_render {
83        match node {
84            AriaChild::Text(text) => {
85                visit_text(text, "", &mut lines);
86            }
87            AriaChild::Node(node) => {
88                visit(
89                    node,
90                    "",
91                    render_cursor_pointer,
92                    render_active,
93                    &mut lines,
94                    previous,
95                );
96            }
97        }
98    }
99
100    lines.join("\n")
101}
102
103fn render_single_node(root: &AriaNode, mode: RenderMode, previous: Option<&AriaNode>) -> String {
104    let mut lines = Vec::new();
105    let render_cursor_pointer = matches!(mode, RenderMode::Ai);
106    let render_active = matches!(mode, RenderMode::Ai);
107
108    visit(
109        root,
110        "",
111        render_cursor_pointer,
112        render_active,
113        &mut lines,
114        previous,
115    );
116
117    lines.join("\n")
118}
119
120fn visit_text(text: &str, indent: &str, lines: &mut Vec<String>) {
121    let escaped = yaml_escape_value_if_needed(text);
122    if !escaped.is_empty() {
123        lines.push(format!("{}- text: {}", indent, escaped));
124    }
125}
126
127fn visit(
128    aria_node: &AriaNode,
129    indent: &str,
130    render_cursor_pointer: bool,
131    render_active: bool,
132    lines: &mut Vec<String>,
133    _previous: Option<&AriaNode>,
134) {
135    // Create the key (role + name + attributes)
136    let key = create_key(aria_node, render_cursor_pointer, render_active);
137    let escaped_key = format!("{}- {}", indent, yaml_escape_key_if_needed(&key));
138
139    // Get single inlined text child if applicable
140    let single_text_child = get_single_inlined_text_child(aria_node);
141
142    if aria_node.children.is_empty() && aria_node.props.is_empty() {
143        // Leaf node without children or props
144        lines.push(escaped_key);
145    } else if let Some(text) = single_text_child {
146        // Leaf node with just text inside
147        lines.push(format!(
148            "{}: {}",
149            escaped_key,
150            yaml_escape_value_if_needed(&text)
151        ));
152    } else {
153        // Node with props and/or children
154        lines.push(format!("{}:", escaped_key));
155
156        // Render props
157        for (name, value) in &aria_node.props {
158            lines.push(format!(
159                "{}  - /{}: {}",
160                indent,
161                name,
162                yaml_escape_value_if_needed(value)
163            ));
164        }
165
166        // Render children
167        let child_indent = format!("{}  ", indent);
168        let in_cursor_pointer =
169            aria_node.index.is_some() && render_cursor_pointer && aria_node.has_pointer_cursor();
170
171        for child in &aria_node.children {
172            match child {
173                AriaChild::Text(text) => {
174                    visit_text(text, &child_indent, lines);
175                }
176                AriaChild::Node(child_node) => {
177                    visit(
178                        child_node,
179                        &child_indent,
180                        render_cursor_pointer && !in_cursor_pointer,
181                        render_active,
182                        lines,
183                        None,
184                    );
185                }
186            }
187        }
188    }
189}
190
191fn create_key(aria_node: &AriaNode, render_cursor_pointer: bool, render_active: bool) -> String {
192    let mut key = aria_node.role.clone();
193
194    // Add name if present and not too long
195    if !aria_node.name.is_empty() && aria_node.name.len() <= 900 {
196        // YAML has a limit of 1024 characters per key
197        let name = &aria_node.name;
198        // Simple stringification (no regex handling for now)
199        key.push(' ');
200        key.push_str(&format!("{:?}", name)); // JSON-style quoting
201    }
202
203    // Add ARIA state attributes
204    if let Some(checked) = &aria_node.checked {
205        match checked {
206            crate::dom::element::AriaChecked::Bool(true) => key.push_str(" [checked]"),
207            crate::dom::element::AriaChecked::Bool(false) => {}
208            crate::dom::element::AriaChecked::Mixed(_) => key.push_str(" [checked=mixed]"),
209        }
210    }
211
212    if aria_node.disabled == Some(true) {
213        key.push_str(" [disabled]");
214    }
215
216    if aria_node.expanded == Some(true) {
217        key.push_str(" [expanded]");
218    }
219
220    if render_active && aria_node.active == Some(true) {
221        key.push_str(" [active]");
222    }
223
224    if let Some(level) = aria_node.level {
225        key.push_str(&format!(" [level={}]", level));
226    }
227
228    if let Some(pressed) = &aria_node.pressed {
229        match pressed {
230            crate::dom::element::AriaPressed::Bool(true) => key.push_str(" [pressed]"),
231            crate::dom::element::AriaPressed::Bool(false) => {}
232            crate::dom::element::AriaPressed::Mixed(_) => key.push_str(" [pressed=mixed]"),
233        }
234    }
235
236    if aria_node.selected == Some(true) {
237        key.push_str(" [selected]");
238    }
239
240    // Add index attribute
241    if let Some(index) = aria_node.index {
242        key.push_str(&format!(" [index={}]", index));
243
244        if render_cursor_pointer && aria_node.has_pointer_cursor() {
245            key.push_str(" [cursor=pointer]");
246        }
247    }
248
249    key
250}
251
252fn get_single_inlined_text_child(aria_node: &AriaNode) -> Option<String> {
253    if aria_node.children.len() == 1 && aria_node.props.is_empty() {
254        if let AriaChild::Text(text) = &aria_node.children[0] {
255            return Some(text.clone());
256        }
257    }
258    None
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    #[test]
266    fn test_render_simple_tree() {
267        let mut root = AriaNode::fragment();
268        root.children.push(AriaChild::Node(Box::new(
269            AriaNode::new("button", "Click me")
270                .with_index(0)
271                .with_box(true, Some("pointer".to_string())),
272        )));
273
274        let yaml = render_aria_tree(&root, RenderMode::Ai, None);
275        assert!(yaml.contains("button"));
276        assert!(yaml.contains("Click me"));
277        assert!(yaml.contains("[index=0]"));
278        assert!(yaml.contains("[cursor=pointer]"));
279    }
280
281    #[test]
282    fn test_render_tree_with_text() {
283        let mut root = AriaNode::fragment();
284        root.children
285            .push(AriaChild::Text("Hello world".to_string()));
286
287        let yaml = render_aria_tree(&root, RenderMode::Ai, None);
288        eprintln!("YAML output:\n{}", yaml);
289        assert!(yaml.contains("text:"));
290        assert!(yaml.contains("Hello world"));
291    }
292
293    #[test]
294    fn test_render_nested_tree() {
295        let mut root = AriaNode::fragment();
296        let mut div = AriaNode::new("generic", "");
297        div.children
298            .push(AriaChild::Text("Parent text".to_string()));
299        div.children.push(AriaChild::Node(Box::new(
300            AriaNode::new("button", "Child button").with_index(0),
301        )));
302
303        root.children.push(AriaChild::Node(Box::new(div)));
304
305        let yaml = render_aria_tree(&root, RenderMode::Ai, None);
306        assert!(yaml.contains("generic"));
307        assert!(yaml.contains("Parent text"));
308        assert!(yaml.contains("button"));
309        assert!(yaml.contains("Child button"));
310    }
311
312    #[test]
313    fn test_render_with_props() {
314        let mut root = AriaNode::fragment();
315        root.children.push(AriaChild::Node(Box::new(
316            AriaNode::new("link", "Go to page")
317                .with_index(0)
318                .with_prop("url", "https://example.com"),
319        )));
320
321        let yaml = render_aria_tree(&root, RenderMode::Ai, None);
322        eprintln!("YAML output:\n{}", yaml);
323        assert!(yaml.contains("link"));
324        assert!(yaml.contains("[index=0]"));
325        assert!(yaml.contains("/url:"));
326        assert!(yaml.contains("https://example.com"));
327    }
328
329    #[test]
330    fn test_render_with_aria_states() {
331        let mut root = AriaNode::fragment();
332        root.children.push(AriaChild::Node(Box::new(
333            AriaNode::new("checkbox", "Accept terms")
334                .with_index(0)
335                .with_checked(true)
336                .with_disabled(false),
337        )));
338
339        let yaml = render_aria_tree(&root, RenderMode::Ai, None);
340        assert!(yaml.contains("checkbox"));
341        assert!(yaml.contains("[checked]"));
342        // disabled=false should not appear
343        assert!(!yaml.contains("[disabled]"));
344    }
345
346    #[test]
347    fn test_render_heading_with_level() {
348        let mut root = AriaNode::fragment();
349        root.children.push(AriaChild::Node(Box::new(
350            AriaNode::new("heading", "Page Title").with_level(1),
351        )));
352
353        let yaml = render_aria_tree(&root, RenderMode::Ai, None);
354        assert!(yaml.contains("heading"));
355        assert!(yaml.contains("Page Title"));
356        assert!(yaml.contains("[level=1]"));
357    }
358
359    #[test]
360    fn test_empty_snapshot() {
361        let root = AriaNode::fragment();
362        let yaml = render_aria_tree(&root, RenderMode::Ai, None);
363        assert_eq!(yaml.trim(), "");
364    }
365}