llm_browser_testkit/
diagnostics.rs1use std::fmt::Write as _;
10use std::path::Path;
11
12use headless_chrome::{protocol::cdp::Page::CaptureScreenshotFormatOption, Tab};
13
14use crate::truncate;
15
16const EXCERPT_LEN: usize = 160;
18const FULL_TEXT_LEN: usize = 1500;
20
21pub const DIAGNOSTIC_ALERTS_JS: &str = r#"
24(() => {
25 const sel = '[role="alert"], [aria-live="assertive"], .alert, .error, .error-message, .errorMessage, .error-text, [data-error], [class*="error"], [class*="Error"], mat-snack-bar-container, .mat-mdc-snack-bar-container, .toast, .notification';
26 const els = document.querySelectorAll(sel);
27 const out = [];
28 const seen = new Set();
29 els.forEach((el) => {
30 if (el.offsetParent === null && !el.closest('mat-snack-bar-container')) return;
31 const text = (el.innerText || el.textContent || '').trim();
32 if (!text || seen.has(text)) return;
33 seen.add(text);
34 out.push(text.substring(0, 200));
35 });
36 return JSON.stringify(out.slice(0, 6));
37})()
38"#;
39
40pub struct PageState {
42 pub url: String,
44 pub title: String,
46 pub visible_text: String,
48 pub alerts: Vec<String>,
50}
51
52#[must_use]
56pub fn capture(tab: &Tab) -> PageState {
57 let url = tab.get_url();
58 let title = tab
59 .evaluate("document.title", false)
60 .ok()
61 .and_then(|r| r.value)
62 .and_then(|v| v.as_str().map(String::from))
63 .unwrap_or_else(|| "unknown".to_owned());
64
65 let visible_text = tab
66 .evaluate(
67 "document.body ? document.body.innerText : document.documentElement.innerText",
68 false,
69 )
70 .ok()
71 .and_then(|r| r.value)
72 .and_then(|v| v.as_str().map(String::from))
73 .unwrap_or_default();
74
75 let alerts = tab
76 .evaluate(DIAGNOSTIC_ALERTS_JS, false)
77 .ok()
78 .and_then(|r| r.value)
79 .and_then(|v| v.as_str().map(String::from))
80 .and_then(|json| serde_json::from_str::<Vec<String>>(&json).ok())
81 .unwrap_or_default();
82
83 PageState {
84 url,
85 title,
86 visible_text: truncate(&visible_text, FULL_TEXT_LEN),
87 alerts,
88 }
89}
90
91#[must_use]
94pub fn inline_excerpt(state: &PageState) -> String {
95 let mut out = format!("page: {} ({})", state.url, state.title);
96 let trimmed = state.visible_text.trim();
97 if !trimmed.is_empty() {
98 let excerpt = truncate(trimmed, EXCERPT_LEN);
99 let one_line = excerpt.replace('\n', " ⏎ ");
100 let _ = write!(out, " — visible: \"{one_line}\"");
101 }
102 if !state.alerts.is_empty() {
103 let _ = write!(out, " — alerts: {}", state.alerts.join(" | "));
104 }
105 out
106}
107
108#[must_use]
114pub fn full_context(state: &PageState) -> String {
115 let mut lines = Vec::new();
116 lines.push(format!(" │ url: {}", state.url));
117 lines.push(format!(" │ title: {}", state.title));
118 if !state.alerts.is_empty() {
119 for alert in &state.alerts {
120 lines.push(format!(" │ alert: {alert}"));
121 }
122 }
123 let trimmed = state.visible_text.trim();
124 if !trimmed.is_empty() {
125 lines.push(format!(" │ content: {trimmed}"));
126 }
127 lines.join("\n")
128}
129
130#[must_use]
132pub fn slugify(name: &str, max_len: usize) -> String {
133 let mut slug: String = name
134 .chars()
135 .map(|c| {
136 if c.is_ascii_alphanumeric() {
137 c.to_ascii_lowercase()
138 } else {
139 '-'
140 }
141 })
142 .collect();
143 while slug.contains("--") {
144 slug = slug.replace("--", "-");
145 }
146 let slug = slug.trim_matches('-');
147 if slug.len() > max_len {
148 slug[..max_len].to_owned()
151 } else {
152 slug.to_owned()
153 }
154}
155
156#[must_use]
160pub fn save_screenshot(
161 tab: &Tab,
162 dir: &Path,
163 scenario: &str,
164 test: &str,
165 step_index: usize,
166 step_kind: &str,
167) -> Option<String> {
168 let data = tab
169 .capture_screenshot(CaptureScreenshotFormatOption::Png, None, None, true)
170 .ok()?;
171 let file_name = format!(
172 "{scenario}__{test}__{step_index:03}-{step_kind}.png",
173 scenario = slugify(scenario, 40),
174 test = slugify(test, 40),
175 );
176 let path = dir.join(file_name);
177 if let Some(parent) = path.parent() {
178 if std::fs::create_dir_all(parent).is_err() {
179 return None;
180 }
181 }
182 if std::fs::write(&path, &data).is_err() {
183 return None;
184 }
185 Some(path.to_string_lossy().into_owned())
186}
187
188#[cfg(test)]
189mod tests {
190 use super::{full_context, inline_excerpt, slugify};
191 use crate::diagnostics::PageState;
192
193 fn state() -> PageState {
194 PageState {
195 url: "http://127.0.0.1:8082/auth/login".into(),
196 title: "Immosai — Sign in".into(),
197 visible_text: "Email address\nPassword\nSign in\nInvalid credentials.".into(),
198 alerts: vec!["Invalid credentials.".into()],
199 }
200 }
201
202 #[test]
203 fn test_inline_excerpt_contains_url_and_text() {
204 let s = inline_excerpt(&state());
205 assert!(s.contains("auth/login"));
206 assert!(s.contains("visible:"));
207 assert!(s.contains("alerts:"));
208 }
209
210 #[test]
211 fn test_inline_excerpt_newlines_flattened() {
212 let s = inline_excerpt(&state());
213 assert!(!s.contains('\n'));
214 }
215
216 #[test]
217 fn test_full_context_lists_alerts() {
218 let s = full_context(&state());
219 assert!(s.contains("url:"));
220 assert!(s.contains("alert:"));
221 assert!(s.contains("content:"));
222 }
223
224 #[test]
225 fn test_slugify() {
226 assert_eq!(
227 slugify("Login and open account", 60),
228 "login-and-open-account"
229 );
230 assert_eq!(slugify("Äpfel & Birnen!", 60), "pfel-birnen");
231 assert_eq!(slugify("a".repeat(100).as_str(), 20), "a".repeat(20));
232 }
233}