ras_agent/application/
clickable_map.rs1use 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('…');
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}