1use std::collections::{BTreeMap, BTreeSet};
15
16use super::distill::interactive_uids;
17
18#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct SnapshotSignature {
21 pub url: String,
23 pub title: Option<String>,
25 pub interactable_uids: BTreeSet<String>,
27 pub content_digest: BTreeMap<String, String>,
35 pub console_errors: usize,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum ActionEffect {
42 Changed(String),
44 NoOp,
46}
47
48impl ActionEffect {
49 pub fn is_progress(&self) -> bool {
51 matches!(self, ActionEffect::Changed(_))
52 }
53
54 pub fn hint(&self) -> Option<&'static str> {
57 match self {
58 ActionEffect::NoOp => Some(
59 "[browser] observed change: NO — the page did not change after this \
60 action. The element may be wrong or the action had no effect. \
61 Re-snapshot, re-check the target, and try a different approach.",
62 ),
63 ActionEffect::Changed(_) => None,
64 }
65 }
66}
67
68fn root_title(snapshot: &str) -> Option<String> {
70 snapshot.lines().find_map(|l| {
71 let t = l.trim_start();
72 if t.contains("RootWebArea") {
73 let start = t.find('"')?;
74 let rest = &t[start + 1..];
75 let end = rest.find('"')?;
76 Some(rest[..end].to_string())
77 } else {
78 None
79 }
80 })
81}
82
83fn uid_and_content(line: &str) -> Option<(String, String)> {
87 let body = line.trim_start();
88 let rest = body.strip_prefix("uid=")?;
89 let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
90 let uid = &rest[..end];
91 let content = rest[end..].trim();
92 Some((uid.to_string(), content.to_string()))
93}
94
95pub fn signature(snapshot: &str, url: &str, console: &[String]) -> SnapshotSignature {
98 SnapshotSignature {
99 url: url.to_string(),
100 title: root_title(snapshot),
101 interactable_uids: interactive_uids(snapshot).into_iter().collect(),
102 content_digest: snapshot.lines().filter_map(uid_and_content).collect(),
103 console_errors: console
104 .iter()
105 .filter(|l| l.to_lowercase().contains("error"))
106 .count(),
107 }
108}
109
110pub fn diff(before: &SnapshotSignature, after: &SnapshotSignature) -> ActionEffect {
114 let mut reasons = Vec::new();
115
116 if before.url != after.url {
117 reasons.push(format!("url {} -> {}", before.url, after.url));
118 }
119 if before.title != after.title {
120 reasons.push(format!("title {:?} -> {:?}", before.title, after.title));
121 }
122 if before.interactable_uids != after.interactable_uids {
123 let added = after
124 .interactable_uids
125 .difference(&before.interactable_uids)
126 .count();
127 let removed = before
128 .interactable_uids
129 .difference(&after.interactable_uids)
130 .count();
131 reasons.push(format!("elements +{added}/-{removed}"));
132 }
133 let changed = after
137 .content_digest
138 .iter()
139 .filter(|(uid, content)| {
140 before
141 .content_digest
142 .get(*uid)
143 .is_some_and(|prev| prev != *content)
144 })
145 .count();
146 if changed > 0 {
147 reasons.push(format!("content changed on {changed} element(s)"));
148 }
149 let content_added = after
155 .content_digest
156 .keys()
157 .filter(|uid| !before.content_digest.contains_key(uid.as_str()))
158 .count();
159 let content_removed = before
160 .content_digest
161 .keys()
162 .filter(|uid| !after.content_digest.contains_key(uid.as_str()))
163 .count();
164 if content_added > 0 || content_removed > 0 {
165 reasons.push(format!("content nodes +{content_added}/-{content_removed}"));
166 }
167 if after.console_errors > before.console_errors {
168 reasons.push(format!(
169 "console errors +{}",
170 after.console_errors - before.console_errors
171 ));
172 }
173
174 if reasons.is_empty() {
175 ActionEffect::NoOp
176 } else {
177 ActionEffect::Changed(reasons.join("; "))
178 }
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184
185 const SNAP_A: &str = r#"uid=1_0 RootWebArea "Login" url="https://app.test/login"
186 uid=1_1 textbox "Email"
187 uid=1_2 textbox "Password"
188 uid=1_3 button "Sign in""#;
189
190 const SNAP_B: &str = r#"uid=2_0 RootWebArea "Dashboard" url="https://app.test/home"
192 uid=2_1 link "Profile"
193 uid=2_2 button "Log out""#;
194
195 #[test]
196 fn signature_extracts_title_url_uids_and_errors() {
197 let sig = signature(SNAP_A, "https://app.test/login", &["console.log ok".into()]);
198 assert_eq!(sig.url, "https://app.test/login");
199 assert_eq!(sig.title.as_deref(), Some("Login"));
200 assert_eq!(
201 sig.interactable_uids,
202 ["1_1", "1_2", "1_3"]
203 .iter()
204 .map(|s| s.to_string())
205 .collect()
206 );
207 assert_eq!(sig.console_errors, 0);
208 }
209
210 #[test]
211 fn signature_counts_console_errors() {
212 let console = vec![
213 "Uncaught TypeError: x is not a function".into(),
214 "info: loaded".into(),
215 "ERROR: network failed".into(),
216 ];
217 let sig = signature(SNAP_A, "u", &console);
218 assert_eq!(sig.console_errors, 2);
219 }
220
221 #[test]
222 fn diff_identical_is_noop() {
223 let a = signature(SNAP_A, "https://app.test/login", &[]);
224 let b = signature(SNAP_A, "https://app.test/login", &[]);
225 assert_eq!(diff(&a, &b), ActionEffect::NoOp);
226 assert!(!diff(&a, &b).is_progress());
227 assert!(diff(&a, &b).hint().is_some());
228 }
229
230 #[test]
231 fn diff_navigation_is_change() {
232 let a = signature(SNAP_A, "https://app.test/login", &[]);
233 let b = signature(SNAP_B, "https://app.test/home", &[]);
234 let effect = diff(&a, &b);
235 assert!(effect.is_progress(), "navigation must register as a change");
236 if let ActionEffect::Changed(summary) = effect {
237 assert!(summary.contains("url"), "summary: {summary}");
238 assert!(summary.contains("title"), "summary: {summary}");
239 assert!(summary.contains("elements"), "summary: {summary}");
240 } else {
241 panic!("expected Changed");
242 }
243 assert!(diff(&a, &b).hint().is_none());
244 }
245
246 #[test]
247 fn diff_same_url_new_element_is_change() {
248 let before = signature(SNAP_A, "https://app.test/login", &[]);
250 let with_error = r#"uid=1_0 RootWebArea "Login" url="https://app.test/login"
251 uid=1_1 textbox "Email"
252 uid=1_2 textbox "Password"
253 uid=1_3 button "Sign in"
254 uid=1_9 textbox "2FA code""#;
255 let after = signature(with_error, "https://app.test/login", &[]);
256 let effect = diff(&before, &after);
257 assert!(effect.is_progress());
258 if let ActionEffect::Changed(s) = effect {
259 assert!(s.contains("elements +1"), "summary: {s}");
260 }
261 }
262
263 #[test]
264 fn diff_added_noninteractive_text_is_change() {
265 let before = signature(SNAP_A, "https://app.test/login", &[]);
270 let with_banner = r#"uid=1_0 RootWebArea "Login" url="https://app.test/login"
271 uid=1_1 textbox "Email"
272 uid=1_2 textbox "Password"
273 uid=1_3 button "Sign in"
274 uid=1_9 StaticText "Invalid password""#;
275 let after = signature(with_banner, "https://app.test/login", &[]);
276 let effect = diff(&before, &after);
277 assert!(
278 effect.is_progress(),
279 "an added non-interactive error banner must register as a change, got {effect:?}"
280 );
281 if let ActionEffect::Changed(s) = effect {
282 assert!(s.contains("content nodes +1"), "summary: {s}");
283 }
284 }
285
286 #[test]
287 fn diff_new_console_error_is_change() {
288 let a = signature(SNAP_A, "u", &[]);
289 let b = signature(SNAP_A, "u", &["ERROR: submit failed".into()]);
290 assert_eq!(
291 diff(&a, &b),
292 ActionEffect::Changed("console errors +1".to_string())
293 );
294 }
295
296 #[test]
304 fn diff_textbox_value_set_is_change() {
305 let empty = r#"uid=4_0 RootWebArea "Form" url="https://app.test/f"
307 uid=4_1 textbox "Email"
308 uid=4_2 button "Submit""#;
309 let filled = r#"uid=4_0 RootWebArea "Form" url="https://app.test/f"
310 uid=4_1 textbox "Email" value="me@x.com"
311 uid=4_2 button "Submit""#;
312 let before = signature(empty, "https://app.test/f", &[]);
313 let after = signature(filled, "https://app.test/f", &[]);
314 assert!(
315 diff(&before, &after).is_progress(),
316 "typing into a textbox (value set) must register as progress, not NoOp"
317 );
318 }
319
320 #[test]
321 fn diff_same_uid_relabel_is_change() {
322 let play = r#"uid=4_0 RootWebArea "Player" url="https://app.test/p"
324 uid=4_1 button "Play""#;
325 let pause = r#"uid=4_0 RootWebArea "Player" url="https://app.test/p"
326 uid=4_1 button "Pause""#;
327 let before = signature(play, "https://app.test/p", &[]);
328 let after = signature(pause, "https://app.test/p", &[]);
329 assert!(
330 diff(&before, &after).is_progress(),
331 "a same-uid label change (Play->Pause) must register as progress"
332 );
333 }
334
335 #[test]
336 fn diff_true_noop_still_noop_with_digest() {
337 let snap = r#"uid=4_0 RootWebArea "Form" url="https://app.test/f"
340 uid=4_1 textbox "Email" value="me@x.com"
341 uid=4_2 button "Submit""#;
342 let a = signature(snap, "https://app.test/f", &[]);
343 let b = signature(snap, "https://app.test/f", &[]);
344 assert_eq!(
345 diff(&a, &b),
346 ActionEffect::NoOp,
347 "an unchanged page (same values) must stay NoOp"
348 );
349 }
350
351 #[test]
352 fn noop_hint_guides_replan() {
353 assert!(
354 ActionEffect::NoOp
355 .hint()
356 .unwrap()
357 .contains("observed change: NO")
358 );
359 assert!(ActionEffect::Changed("x".into()).hint().is_none());
360 }
361}