use std::collections::{BTreeMap, BTreeSet};
use super::distill::interactive_uids;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SnapshotSignature {
pub url: String,
pub title: Option<String>,
pub interactable_uids: BTreeSet<String>,
pub content_digest: BTreeMap<String, String>,
pub console_errors: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ActionEffect {
Changed(String),
NoOp,
}
impl ActionEffect {
pub fn is_progress(&self) -> bool {
matches!(self, ActionEffect::Changed(_))
}
pub fn hint(&self) -> Option<&'static str> {
match self {
ActionEffect::NoOp => Some(
"[browser] observed change: NO — the page did not change after this \
action. The element may be wrong or the action had no effect. \
Re-snapshot, re-check the target, and try a different approach.",
),
ActionEffect::Changed(_) => None,
}
}
}
fn root_title(snapshot: &str) -> Option<String> {
snapshot.lines().find_map(|l| {
let t = l.trim_start();
if t.contains("RootWebArea") {
let start = t.find('"')?;
let rest = &t[start + 1..];
let end = rest.find('"')?;
Some(rest[..end].to_string())
} else {
None
}
})
}
fn uid_and_content(line: &str) -> Option<(String, String)> {
let body = line.trim_start();
let rest = body.strip_prefix("uid=")?;
let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
let uid = &rest[..end];
let content = rest[end..].trim();
Some((uid.to_string(), content.to_string()))
}
pub fn signature(snapshot: &str, url: &str, console: &[String]) -> SnapshotSignature {
SnapshotSignature {
url: url.to_string(),
title: root_title(snapshot),
interactable_uids: interactive_uids(snapshot).into_iter().collect(),
content_digest: snapshot.lines().filter_map(uid_and_content).collect(),
console_errors: console
.iter()
.filter(|l| l.to_lowercase().contains("error"))
.count(),
}
}
pub fn diff(before: &SnapshotSignature, after: &SnapshotSignature) -> ActionEffect {
let mut reasons = Vec::new();
if before.url != after.url {
reasons.push(format!("url {} -> {}", before.url, after.url));
}
if before.title != after.title {
reasons.push(format!("title {:?} -> {:?}", before.title, after.title));
}
if before.interactable_uids != after.interactable_uids {
let added = after
.interactable_uids
.difference(&before.interactable_uids)
.count();
let removed = before
.interactable_uids
.difference(&after.interactable_uids)
.count();
reasons.push(format!("elements +{added}/-{removed}"));
}
let changed = after
.content_digest
.iter()
.filter(|(uid, content)| {
before
.content_digest
.get(*uid)
.is_some_and(|prev| prev != *content)
})
.count();
if changed > 0 {
reasons.push(format!("content changed on {changed} element(s)"));
}
let content_added = after
.content_digest
.keys()
.filter(|uid| !before.content_digest.contains_key(uid.as_str()))
.count();
let content_removed = before
.content_digest
.keys()
.filter(|uid| !after.content_digest.contains_key(uid.as_str()))
.count();
if content_added > 0 || content_removed > 0 {
reasons.push(format!("content nodes +{content_added}/-{content_removed}"));
}
if after.console_errors > before.console_errors {
reasons.push(format!(
"console errors +{}",
after.console_errors - before.console_errors
));
}
if reasons.is_empty() {
ActionEffect::NoOp
} else {
ActionEffect::Changed(reasons.join("; "))
}
}
#[cfg(test)]
mod tests {
use super::*;
const SNAP_A: &str = r#"uid=1_0 RootWebArea "Login" url="https://app.test/login"
uid=1_1 textbox "Email"
uid=1_2 textbox "Password"
uid=1_3 button "Sign in""#;
const SNAP_B: &str = r#"uid=2_0 RootWebArea "Dashboard" url="https://app.test/home"
uid=2_1 link "Profile"
uid=2_2 button "Log out""#;
#[test]
fn signature_extracts_title_url_uids_and_errors() {
let sig = signature(SNAP_A, "https://app.test/login", &["console.log ok".into()]);
assert_eq!(sig.url, "https://app.test/login");
assert_eq!(sig.title.as_deref(), Some("Login"));
assert_eq!(
sig.interactable_uids,
["1_1", "1_2", "1_3"]
.iter()
.map(|s| s.to_string())
.collect()
);
assert_eq!(sig.console_errors, 0);
}
#[test]
fn signature_counts_console_errors() {
let console = vec![
"Uncaught TypeError: x is not a function".into(),
"info: loaded".into(),
"ERROR: network failed".into(),
];
let sig = signature(SNAP_A, "u", &console);
assert_eq!(sig.console_errors, 2);
}
#[test]
fn diff_identical_is_noop() {
let a = signature(SNAP_A, "https://app.test/login", &[]);
let b = signature(SNAP_A, "https://app.test/login", &[]);
assert_eq!(diff(&a, &b), ActionEffect::NoOp);
assert!(!diff(&a, &b).is_progress());
assert!(diff(&a, &b).hint().is_some());
}
#[test]
fn diff_navigation_is_change() {
let a = signature(SNAP_A, "https://app.test/login", &[]);
let b = signature(SNAP_B, "https://app.test/home", &[]);
let effect = diff(&a, &b);
assert!(effect.is_progress(), "navigation must register as a change");
if let ActionEffect::Changed(summary) = effect {
assert!(summary.contains("url"), "summary: {summary}");
assert!(summary.contains("title"), "summary: {summary}");
assert!(summary.contains("elements"), "summary: {summary}");
} else {
panic!("expected Changed");
}
assert!(diff(&a, &b).hint().is_none());
}
#[test]
fn diff_same_url_new_element_is_change() {
let before = signature(SNAP_A, "https://app.test/login", &[]);
let with_error = r#"uid=1_0 RootWebArea "Login" url="https://app.test/login"
uid=1_1 textbox "Email"
uid=1_2 textbox "Password"
uid=1_3 button "Sign in"
uid=1_9 textbox "2FA code""#;
let after = signature(with_error, "https://app.test/login", &[]);
let effect = diff(&before, &after);
assert!(effect.is_progress());
if let ActionEffect::Changed(s) = effect {
assert!(s.contains("elements +1"), "summary: {s}");
}
}
#[test]
fn diff_added_noninteractive_text_is_change() {
let before = signature(SNAP_A, "https://app.test/login", &[]);
let with_banner = r#"uid=1_0 RootWebArea "Login" url="https://app.test/login"
uid=1_1 textbox "Email"
uid=1_2 textbox "Password"
uid=1_3 button "Sign in"
uid=1_9 StaticText "Invalid password""#;
let after = signature(with_banner, "https://app.test/login", &[]);
let effect = diff(&before, &after);
assert!(
effect.is_progress(),
"an added non-interactive error banner must register as a change, got {effect:?}"
);
if let ActionEffect::Changed(s) = effect {
assert!(s.contains("content nodes +1"), "summary: {s}");
}
}
#[test]
fn diff_new_console_error_is_change() {
let a = signature(SNAP_A, "u", &[]);
let b = signature(SNAP_A, "u", &["ERROR: submit failed".into()]);
assert_eq!(
diff(&a, &b),
ActionEffect::Changed("console errors +1".to_string())
);
}
#[test]
fn diff_textbox_value_set_is_change() {
let empty = r#"uid=4_0 RootWebArea "Form" url="https://app.test/f"
uid=4_1 textbox "Email"
uid=4_2 button "Submit""#;
let filled = r#"uid=4_0 RootWebArea "Form" url="https://app.test/f"
uid=4_1 textbox "Email" value="me@x.com"
uid=4_2 button "Submit""#;
let before = signature(empty, "https://app.test/f", &[]);
let after = signature(filled, "https://app.test/f", &[]);
assert!(
diff(&before, &after).is_progress(),
"typing into a textbox (value set) must register as progress, not NoOp"
);
}
#[test]
fn diff_same_uid_relabel_is_change() {
let play = r#"uid=4_0 RootWebArea "Player" url="https://app.test/p"
uid=4_1 button "Play""#;
let pause = r#"uid=4_0 RootWebArea "Player" url="https://app.test/p"
uid=4_1 button "Pause""#;
let before = signature(play, "https://app.test/p", &[]);
let after = signature(pause, "https://app.test/p", &[]);
assert!(
diff(&before, &after).is_progress(),
"a same-uid label change (Play->Pause) must register as progress"
);
}
#[test]
fn diff_true_noop_still_noop_with_digest() {
let snap = r#"uid=4_0 RootWebArea "Form" url="https://app.test/f"
uid=4_1 textbox "Email" value="me@x.com"
uid=4_2 button "Submit""#;
let a = signature(snap, "https://app.test/f", &[]);
let b = signature(snap, "https://app.test/f", &[]);
assert_eq!(
diff(&a, &b),
ActionEffect::NoOp,
"an unchanged page (same values) must stay NoOp"
);
}
#[test]
fn noop_hint_guides_replan() {
assert!(
ActionEffect::NoOp
.hint()
.unwrap()
.contains("observed change: NO")
);
assert!(ActionEffect::Changed("x".into()).hint().is_none());
}
}