Skip to main content

ras_agent/application/
clickable_map.rs

1use ras_dom::BrowserStateSummary;
2
3const CLICKABLE_LIMIT: usize = 80;
4const NAME_BUDGET: usize = 80;
5
6pub(crate) fn render_clickable_map(summary: &BrowserStateSummary) -> String {
7    if summary.clickables.is_empty() {
8        return String::new();
9    }
10    let mut buf = String::from("clickable_elements:\n");
11    for c in summary.clickables.iter().take(CLICKABLE_LIMIT) {
12        buf.push_str(&format!("  [{}] {}", c.index, c.tag));
13        if let Some(name) = &c.ax_name {
14            buf.push_str(&format!(" \"{}\"", truncate(name, NAME_BUDGET)));
15        } else if let Some(label) = &c.label {
16            buf.push_str(&format!(" \"{}\"", truncate(label, NAME_BUDGET)));
17        }
18        buf.push('\n');
19    }
20    if summary.clickables.len() > CLICKABLE_LIMIT {
21        buf.push_str(&format!(
22            "  …and {} more (truncated)\n",
23            summary.clickables.len() - CLICKABLE_LIMIT
24        ));
25    }
26    buf
27}
28
29fn truncate(s: &str, max: usize) -> String {
30    if s.chars().count() <= max {
31        s.to_string()
32    } else {
33        let mut out: String = s.chars().take(max).collect();
34        out.push_str("…");
35        out
36    }
37}
38
39#[cfg(test)]
40mod tests {
41    use super::*;
42    use ras_dom::{BoundingBox, ClickableElement, PageStatistics};
43    use ras_types::{BackendNodeId, TargetId};
44
45    fn summary_with(clickables: Vec<ClickableElement>) -> BrowserStateSummary {
46        BrowserStateSummary {
47            target: TargetId("mock".into()),
48            url: "https://example.com/".parse().expect("url"),
49            title: "T".into(),
50            tree: None,
51            clickables,
52            screenshot_b64: None,
53            tabs: vec![],
54            page_stats: PageStatistics::default(),
55        }
56    }
57
58    fn click(idx: u32, tag: &str, ax_name: Option<&str>, label: Option<&str>) -> ClickableElement {
59        ClickableElement {
60            index: idx,
61            backend_node_id: BackendNodeId(idx as i64),
62            bbox: BoundingBox {
63                x: 0.0,
64                y: 0.0,
65                width: 1.0,
66                height: 1.0,
67            },
68            xpath: String::new(),
69            stable_hash: String::new(),
70            ax_name: ax_name.map(String::from),
71            tag: tag.into(),
72            label: label.map(String::from),
73        }
74    }
75
76    #[test]
77    fn empty_clickables_yields_empty_string() {
78        assert_eq!(render_clickable_map(&summary_with(vec![])), "");
79    }
80
81    #[test]
82    fn ax_name_takes_precedence_over_label() {
83        let s = summary_with(vec![click(0, "button", Some("Sign in"), Some("submit"))]);
84        let out = render_clickable_map(&s);
85        assert!(out.contains("[0] button \"Sign in\""));
86        assert!(!out.contains("submit"));
87    }
88
89    #[test]
90    fn label_used_when_no_ax_name() {
91        let s = summary_with(vec![click(0, "input", None, Some("user@example.com"))]);
92        let out = render_clickable_map(&s);
93        assert!(out.contains("[0] input \"user@example.com\""));
94    }
95
96    #[test]
97    fn no_quotes_when_neither_ax_nor_label() {
98        let s = summary_with(vec![click(0, "a", None, None)]);
99        let out = render_clickable_map(&s);
100        assert!(out.contains("[0] a\n"));
101        assert!(!out.contains('"'));
102    }
103
104    #[test]
105    fn truncates_beyond_limit_with_more_marker() {
106        let many: Vec<ClickableElement> = (0..(CLICKABLE_LIMIT as u32 + 5))
107            .map(|i| click(i, "div", None, None))
108            .collect();
109        let s = summary_with(many);
110        let out = render_clickable_map(&s);
111        assert!(out.contains(&format!("[{}]", CLICKABLE_LIMIT - 1)));
112        assert!(!out.contains(&format!("[{}]", CLICKABLE_LIMIT)));
113        assert!(out.contains("…and 5 more (truncated)"));
114    }
115}