ras_agent/application/
clickable_map.rs1use 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}