Skip to main content

ras_agent/application/
clickable_map.rs

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