diff --git a/README.md b/README.md index f5b7613..7b0dfba 100644 --- a/README.md +++ b/README.md @@ -254,6 +254,21 @@ agent-browser dialog accept [text] # Accept (with optional prompt text) agent-browser dialog dismiss # Dismiss ``` +### Diff + +```bash +agent-browser diff snapshot # Compare current vs last snapshot +agent-browser diff snapshot --baseline before.txt # Compare current vs saved snapshot file +agent-browser diff snapshot --selector "#main" --compact # Scoped snapshot diff +agent-browser diff screenshot --baseline before.png # Visual pixel diff against baseline +agent-browser diff screenshot --baseline b.png -o d.png # Save diff image to custom path +agent-browser diff screenshot --baseline b.png -t 0.2 # Adjust color threshold (0-1) +agent-browser diff url https://v1.com https://v2.com # Compare two URLs (snapshot diff) +agent-browser diff url https://v1.com https://v2.com --screenshot # Also visual diff +agent-browser diff url https://v1.com https://v2.com --wait-until networkidle # Custom wait strategy +agent-browser diff url https://v1.com https://v2.com --selector "#main" # Scope to element +``` + ### Debug ```bash diff --git a/cli/src/commands.rs b/cli/src/commands.rs index 4ac56b4..1f6307c 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -1037,12 +1037,284 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result parse_diff(&rest, &id, flags), + _ => Err(ParseError::UnknownCommand { command: cmd.to_string(), }), } } +fn parse_diff(rest: &[&str], id: &str, flags: &Flags) -> Result { + const VALID: &[&str] = &["snapshot", "screenshot", "url"]; + + match rest.first().copied() { + Some("snapshot") => { + let mut cmd = json!({ "id": id, "action": "diff_snapshot" }); + let obj = cmd.as_object_mut().unwrap(); + let mut i = 1; + while i < rest.len() { + match rest[i] { + "-b" | "--baseline" => { + if let Some(path) = rest.get(i + 1) { + obj.insert("baseline".to_string(), json!(path)); + i += 1; + } else { + return Err(ParseError::MissingArguments { + context: "diff snapshot --baseline".to_string(), + usage: "diff snapshot --baseline ", + }); + } + } + "-s" | "--selector" => { + if let Some(s) = rest.get(i + 1) { + obj.insert("selector".to_string(), json!(s)); + i += 1; + } else { + return Err(ParseError::MissingArguments { + context: "diff snapshot --selector".to_string(), + usage: "diff snapshot --selector ", + }); + } + } + "-c" | "--compact" => { + obj.insert("compact".to_string(), json!(true)); + } + "-d" | "--depth" => { + if let Some(d) = rest.get(i + 1) { + match d.parse::() { + Ok(n) => { + obj.insert("maxDepth".to_string(), json!(n)); + i += 1; + } + Err(_) => { + return Err(ParseError::InvalidValue { + message: format!("Depth must be a non-negative integer, got: {}", d), + usage: "diff snapshot --depth ", + }); + } + } + } else { + return Err(ParseError::MissingArguments { + context: "diff snapshot --depth".to_string(), + usage: "diff snapshot --depth ", + }); + } + } + other if other.starts_with('-') => { + return Err(ParseError::InvalidValue { + message: format!("Unknown flag: {}", other), + usage: "diff snapshot [--baseline ] [--selector ] [--compact] [--depth ]", + }); + } + other => { + return Err(ParseError::InvalidValue { + message: format!("Unexpected argument: {}", other), + usage: "diff snapshot [--baseline ] [--selector ] [--compact] [--depth ]", + }); + } + } + i += 1; + } + Ok(cmd) + } + Some("screenshot") => { + let mut cmd = json!({ "id": id, "action": "diff_screenshot" }); + let obj = cmd.as_object_mut().unwrap(); + let mut i = 1; + while i < rest.len() { + match rest[i] { + "-b" | "--baseline" => { + if let Some(path) = rest.get(i + 1) { + obj.insert("baseline".to_string(), json!(path)); + i += 1; + } else { + return Err(ParseError::MissingArguments { + context: "diff screenshot --baseline".to_string(), + usage: "diff screenshot --baseline ", + }); + } + } + "-o" | "--output" => { + if let Some(path) = rest.get(i + 1) { + obj.insert("output".to_string(), json!(path)); + i += 1; + } else { + return Err(ParseError::MissingArguments { + context: "diff screenshot --output".to_string(), + usage: "diff screenshot --output ", + }); + } + } + "-t" | "--threshold" => { + if let Some(t) = rest.get(i + 1) { + match t.parse::() { + Ok(n) if (0.0..=1.0).contains(&n) => { + obj.insert("threshold".to_string(), json!(n)); + i += 1; + } + Ok(n) => { + return Err(ParseError::InvalidValue { + message: format!("Threshold must be between 0 and 1, got {}", n), + usage: "diff screenshot --threshold <0-1>", + }); + } + Err(_) => { + return Err(ParseError::InvalidValue { + message: format!("Invalid threshold value: {}", t), + usage: "diff screenshot --threshold <0-1>", + }); + } + } + } else { + return Err(ParseError::MissingArguments { + context: "diff screenshot --threshold".to_string(), + usage: "diff screenshot --threshold <0-1>", + }); + } + } + "-s" | "--selector" => { + if let Some(s) = rest.get(i + 1) { + obj.insert("selector".to_string(), json!(s)); + i += 1; + } else { + return Err(ParseError::MissingArguments { + context: "diff screenshot --selector".to_string(), + usage: "diff screenshot --selector ", + }); + } + } + "--full" => { + obj.insert("fullPage".to_string(), json!(true)); + } + other if other.starts_with('-') => { + return Err(ParseError::InvalidValue { + message: format!("Unknown flag: {}", other), + usage: "diff screenshot --baseline [--output ] [--threshold <0-1>] [--selector ] [--full]", + }); + } + other => { + return Err(ParseError::InvalidValue { + message: format!("Unexpected argument: {}", other), + usage: "diff screenshot --baseline [--output ] [--threshold <0-1>] [--selector ] [--full]", + }); + } + } + i += 1; + } + if flags.full { + obj.insert("fullPage".to_string(), json!(true)); + } + if !obj.contains_key("baseline") { + return Err(ParseError::MissingArguments { + context: "diff screenshot".to_string(), + usage: "diff screenshot --baseline ", + }); + } + Ok(cmd) + } + Some("url") => { + let url1 = rest.get(1).ok_or_else(|| ParseError::MissingArguments { + context: "diff url".to_string(), + usage: "diff url ", + })?; + let url2 = rest.get(2).ok_or_else(|| ParseError::MissingArguments { + context: "diff url".to_string(), + usage: "diff url ", + })?; + let mut cmd = json!({ + "id": id, + "action": "diff_url", + "url1": url1, + "url2": url2, + }); + let obj = cmd.as_object_mut().unwrap(); + let mut i = 3; + while i < rest.len() { + match rest[i] { + "--screenshot" => { + obj.insert("screenshot".to_string(), json!(true)); + } + "--full" => { + obj.insert("fullPage".to_string(), json!(true)); + } + "--wait-until" => { + if let Some(val) = rest.get(i + 1) { + obj.insert("waitUntil".to_string(), json!(val)); + i += 1; + } else { + return Err(ParseError::MissingArguments { + context: "diff url --wait-until".to_string(), + usage: "diff url --wait-until ", + }); + } + } + "-s" | "--selector" => { + if let Some(s) = rest.get(i + 1) { + obj.insert("selector".to_string(), json!(s)); + i += 1; + } else { + return Err(ParseError::MissingArguments { + context: "diff url --selector".to_string(), + usage: "diff url --selector ", + }); + } + } + "-c" | "--compact" => { + obj.insert("compact".to_string(), json!(true)); + } + "-d" | "--depth" => { + if let Some(d) = rest.get(i + 1) { + match d.parse::() { + Ok(n) => { + obj.insert("maxDepth".to_string(), json!(n)); + i += 1; + } + Err(_) => { + return Err(ParseError::InvalidValue { + message: format!("Depth must be a non-negative integer, got: {}", d), + usage: "diff url --depth ", + }); + } + } + } else { + return Err(ParseError::MissingArguments { + context: "diff url --depth".to_string(), + usage: "diff url --depth ", + }); + } + } + other if other.starts_with('-') => { + return Err(ParseError::InvalidValue { + message: format!("Unknown flag: {}", other), + usage: "diff url [--screenshot] [--full] [--wait-until ] [--selector ] [--compact] [--depth ]", + }); + } + other => { + return Err(ParseError::InvalidValue { + message: format!("Unexpected argument: {}", other), + usage: "diff url [--screenshot] [--full] [--wait-until ] [--selector ] [--compact] [--depth ]", + }); + } + } + i += 1; + } + if flags.full { + obj.insert("fullPage".to_string(), json!(true)); + } + Ok(cmd) + } + Some(sub) => Err(ParseError::UnknownSubcommand { + subcommand: sub.to_string(), + valid_options: VALID, + }), + None => Err(ParseError::MissingArguments { + context: "diff".to_string(), + usage: "diff ", + }), + } +} + fn parse_get(rest: &[&str], id: &str) -> Result { const VALID: &[&str] = &[ "text", "html", "value", "attr", "url", "title", "count", "box", "styles", @@ -2712,4 +2984,420 @@ mod tests { assert_eq!(cmd["action"], "trace_stop"); assert!(cmd.get("path").is_none() || cmd["path"].is_null()); } + + // === Diff Tests === + + #[test] + fn test_diff_snapshot_basic() { + let cmd = parse_command(&args("diff snapshot"), &default_flags()).unwrap(); + assert_eq!(cmd["action"], "diff_snapshot"); + } + + #[test] + fn test_diff_snapshot_baseline() { + let cmd = + parse_command(&args("diff snapshot --baseline before.txt"), &default_flags()).unwrap(); + assert_eq!(cmd["action"], "diff_snapshot"); + assert_eq!(cmd["baseline"], "before.txt"); + } + + #[test] + fn test_diff_snapshot_selector_compact_depth() { + let cmd = parse_command( + &args("diff snapshot --selector #main --compact --depth 3"), + &default_flags(), + ) + .unwrap(); + assert_eq!(cmd["action"], "diff_snapshot"); + assert_eq!(cmd["selector"], "#main"); + assert_eq!(cmd["compact"], true); + assert_eq!(cmd["maxDepth"], 3); + } + + #[test] + fn test_diff_snapshot_short_flags() { + let cmd = + parse_command(&args("diff snapshot -b snap.txt -s .content -c -d 2"), &default_flags()) + .unwrap(); + assert_eq!(cmd["action"], "diff_snapshot"); + assert_eq!(cmd["baseline"], "snap.txt"); + assert_eq!(cmd["selector"], ".content"); + assert_eq!(cmd["compact"], true); + assert_eq!(cmd["maxDepth"], 2); + } + + #[test] + fn test_diff_screenshot_baseline() { + let cmd = parse_command( + &args("diff screenshot --baseline before.png"), + &default_flags(), + ) + .unwrap(); + assert_eq!(cmd["action"], "diff_screenshot"); + assert_eq!(cmd["baseline"], "before.png"); + } + + #[test] + fn test_diff_screenshot_all_options() { + let cmd = parse_command( + &args("diff screenshot --baseline b.png --output d.png --threshold 0.2 --selector #hero --full"), + &default_flags(), + ) + .unwrap(); + assert_eq!(cmd["action"], "diff_screenshot"); + assert_eq!(cmd["baseline"], "b.png"); + assert_eq!(cmd["output"], "d.png"); + assert_eq!(cmd["threshold"], 0.2); + assert_eq!(cmd["selector"], "#hero"); + assert_eq!(cmd["fullPage"], true); + } + + #[test] + fn test_diff_screenshot_missing_baseline() { + let result = parse_command(&args("diff screenshot"), &default_flags()); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + ParseError::MissingArguments { .. } + )); + } + + #[test] + fn test_diff_screenshot_global_full_flag() { + let mut flags = default_flags(); + flags.full = true; + let cmd = + parse_command(&args("diff screenshot --baseline b.png"), &flags).unwrap(); + assert_eq!(cmd["action"], "diff_screenshot"); + assert_eq!(cmd["fullPage"], true); + } + + #[test] + fn test_diff_url_basic() { + let cmd = parse_command( + &args("diff url https://a.com https://b.com"), + &default_flags(), + ) + .unwrap(); + assert_eq!(cmd["action"], "diff_url"); + assert_eq!(cmd["url1"], "https://a.com"); + assert_eq!(cmd["url2"], "https://b.com"); + } + + #[test] + fn test_diff_url_with_screenshot_full() { + let cmd = parse_command( + &args("diff url https://a.com https://b.com --screenshot --full"), + &default_flags(), + ) + .unwrap(); + assert_eq!(cmd["action"], "diff_url"); + assert_eq!(cmd["screenshot"], true); + assert_eq!(cmd["fullPage"], true); + } + + #[test] + fn test_diff_url_with_wait_until() { + let cmd = parse_command( + &args("diff url https://a.com https://b.com --wait-until networkidle"), + &default_flags(), + ) + .unwrap(); + assert_eq!(cmd["action"], "diff_url"); + assert_eq!(cmd["waitUntil"], "networkidle"); + } + + #[test] + fn test_diff_url_global_full_flag() { + let mut flags = default_flags(); + flags.full = true; + let cmd = + parse_command(&args("diff url https://a.com https://b.com"), &flags).unwrap(); + assert_eq!(cmd["fullPage"], true); + } + + #[test] + fn test_diff_missing_subcommand() { + let result = parse_command(&args("diff"), &default_flags()); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + ParseError::MissingArguments { .. } + )); + } + + #[test] + fn test_diff_unknown_subcommand() { + let result = parse_command(&args("diff invalid"), &default_flags()); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + ParseError::UnknownSubcommand { .. } + )); + } + + #[test] + fn test_diff_snapshot_baseline_missing_value() { + let result = parse_command(&args("diff snapshot --baseline"), &default_flags()); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + ParseError::MissingArguments { .. } + )); + } + + #[test] + fn test_diff_snapshot_selector_missing_value() { + let result = parse_command(&args("diff snapshot --selector"), &default_flags()); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + ParseError::MissingArguments { .. } + )); + } + + #[test] + fn test_diff_snapshot_depth_missing_value() { + let result = parse_command(&args("diff snapshot --depth"), &default_flags()); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + ParseError::MissingArguments { .. } + )); + } + + #[test] + fn test_diff_screenshot_threshold_missing_value() { + let result = parse_command( + &args("diff screenshot --baseline b.png --threshold"), + &default_flags(), + ); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + ParseError::MissingArguments { .. } + )); + } + + #[test] + fn test_diff_screenshot_output_missing_value() { + let result = parse_command( + &args("diff screenshot --baseline b.png --output"), + &default_flags(), + ); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + ParseError::MissingArguments { .. } + )); + } + + #[test] + fn test_diff_url_wait_until_missing_value() { + let result = parse_command( + &args("diff url https://a.com https://b.com --wait-until"), + &default_flags(), + ); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + ParseError::MissingArguments { .. } + )); + } + + #[test] + fn test_diff_snapshot_unexpected_arg() { + let result = parse_command(&args("diff snapshot foo"), &default_flags()); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + ParseError::InvalidValue { .. } + )); + } + + #[test] + fn test_diff_screenshot_unexpected_arg() { + let result = parse_command( + &args("diff screenshot --baseline b.png unexpected"), + &default_flags(), + ); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + ParseError::InvalidValue { .. } + )); + } + + #[test] + fn test_diff_url_unexpected_arg() { + let result = parse_command( + &args("diff url https://a.com https://b.com extra"), + &default_flags(), + ); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + ParseError::InvalidValue { .. } + )); + } + + #[test] + fn test_diff_snapshot_unknown_flag() { + let result = parse_command(&args("diff snapshot --invalid"), &default_flags()); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + ParseError::InvalidValue { .. } + )); + } + + #[test] + fn test_diff_url_missing_urls() { + let result = parse_command(&args("diff url"), &default_flags()); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + ParseError::MissingArguments { .. } + )); + } + + #[test] + fn test_diff_url_missing_second_url() { + let result = parse_command(&args("diff url https://a.com"), &default_flags()); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + ParseError::MissingArguments { .. } + )); + } + + #[test] + fn test_diff_snapshot_depth_invalid_value() { + let result = parse_command(&args("diff snapshot --depth abc"), &default_flags()); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + ParseError::InvalidValue { .. } + )); + } + + #[test] + fn test_diff_screenshot_threshold_invalid_value() { + let result = parse_command( + &args("diff screenshot --baseline b.png --threshold abc"), + &default_flags(), + ); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + ParseError::InvalidValue { .. } + )); + } + + #[test] + fn test_diff_screenshot_threshold_out_of_range() { + let result = parse_command( + &args("diff screenshot --baseline b.png --threshold 1.5"), + &default_flags(), + ); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + ParseError::InvalidValue { .. } + )); + } + + #[test] + fn test_diff_screenshot_threshold_negative() { + let result = parse_command( + &args("diff screenshot --baseline b.png --threshold -0.5"), + &default_flags(), + ); + assert!(result.is_err()); + } + + #[test] + fn test_diff_url_with_selector() { + let cmd = parse_command( + &args("diff url https://a.com https://b.com --selector #main"), + &default_flags(), + ) + .unwrap(); + assert_eq!(cmd["action"], "diff_url"); + assert_eq!(cmd["selector"], "#main"); + } + + #[test] + fn test_diff_url_with_compact_depth() { + let cmd = parse_command( + &args("diff url https://a.com https://b.com --compact --depth 3"), + &default_flags(), + ) + .unwrap(); + assert_eq!(cmd["action"], "diff_url"); + assert_eq!(cmd["compact"], true); + assert_eq!(cmd["maxDepth"], 3); + } + + #[test] + fn test_diff_url_with_short_snapshot_flags() { + let cmd = parse_command( + &args("diff url https://a.com https://b.com -s .content -c -d 2"), + &default_flags(), + ) + .unwrap(); + assert_eq!(cmd["action"], "diff_url"); + assert_eq!(cmd["selector"], ".content"); + assert_eq!(cmd["compact"], true); + assert_eq!(cmd["maxDepth"], 2); + } + + #[test] + fn test_diff_url_depth_invalid_value() { + let result = parse_command( + &args("diff url https://a.com https://b.com --depth abc"), + &default_flags(), + ); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + ParseError::InvalidValue { .. } + )); + } + + #[test] + fn test_diff_snapshot_depth_negative_value() { + let result = parse_command(&args("diff snapshot --depth -1"), &default_flags()); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + ParseError::InvalidValue { .. } + )); + } + + #[test] + fn test_diff_url_depth_negative_value() { + let result = parse_command( + &args("diff url https://a.com https://b.com --depth -1"), + &default_flags(), + ); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + ParseError::InvalidValue { .. } + )); + } + + #[test] + fn test_diff_url_selector_missing_value() { + let result = parse_command( + &args("diff url https://a.com https://b.com --selector"), + &default_flags(), + ); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + ParseError::MissingArguments { .. } + )); + } } diff --git a/cli/src/output.rs b/cli/src/output.rs index d271559..82fced5 100644 --- a/cli/src/output.rs +++ b/cli/src/output.rs @@ -27,6 +27,35 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) { println!("{}", url); return; } + // Diff responses -- route by action to avoid fragile shape probing + if let Some(obj) = data.as_object() { + match action { + Some("diff_snapshot") => { + print_snapshot_diff(obj); + return; + } + Some("diff_screenshot") => { + print_screenshot_diff(obj); + return; + } + Some("diff_url") => { + if let Some(snap_data) = + obj.get("snapshot").and_then(|v| v.as_object()) + { + println!("{}", color::bold("Snapshot diff:")); + print_snapshot_diff(snap_data); + } + if let Some(ss_data) = + obj.get("screenshot").and_then(|v| v.as_object()) + { + println!("\n{}", color::bold("Screenshot diff:")); + print_screenshot_diff(ss_data); + } + return; + } + _ => {} + } + } // Snapshot if let Some(snapshot) = data.get("snapshot").and_then(|v| v.as_str()) { println!("{}", snapshot); @@ -1850,6 +1879,65 @@ Examples: "## } + "diff" => { + r##" +agent-browser diff - Compare page states + +Subcommands: + + diff snapshot Compare current snapshot to last snapshot in session + diff screenshot --baseline Visual pixel diff against a baseline image + diff url Compare two pages + +Snapshot Diff: + + Usage: agent-browser diff snapshot [options] + + Options: + -b, --baseline Compare against a saved snapshot file + -s, --selector Scope snapshot to a CSS selector or @ref + -c, --compact Use compact snapshot format + -d, --depth Limit snapshot tree depth + + Without --baseline, compares against the last snapshot taken in this session. + +Screenshot Diff: + + Usage: agent-browser diff screenshot --baseline [options] + + Options: + -b, --baseline Baseline image to compare against (required) + -o, --output Path for the diff image (default: temp dir) + -t, --threshold <0-1> Color distance threshold (default: 0.1) + -s, --selector Scope screenshot to element + --full Full page screenshot + +URL Diff: + + Usage: agent-browser diff url [options] + + Options: + --screenshot Also compare screenshots (default: snapshot only) + --full Full page screenshots + --wait-until Navigation wait strategy: load, domcontentloaded, networkidle (default: load) + -s, --selector Scope snapshots to a CSS selector or @ref + -c, --compact Use compact snapshot format + -d, --depth Limit snapshot tree depth + +Global Options: + --json Output as JSON + --session Use specific session + +Examples: + agent-browser diff snapshot + agent-browser diff snapshot --baseline before.txt + agent-browser diff screenshot --baseline before.png + agent-browser diff screenshot --baseline before.png --output diff.png --threshold 0.2 + agent-browser diff url https://staging.example.com https://prod.example.com + agent-browser diff url https://v1.example.com https://v2.example.com --screenshot +"## + } + _ => return false, }; println!("{}", help.trim()); @@ -1922,6 +2010,11 @@ Storage: Tabs: tab [new|list|close|] Manage tabs +Diff: + diff snapshot Compare current vs last snapshot + diff screenshot --baseline Compare current vs baseline image + diff url Compare two pages + Debug: trace start|stop [path] Record Playwright trace profiler start|stop [path] Record Chrome DevTools profile @@ -2053,6 +2146,79 @@ iOS Simulator (requires Xcode and Appium): ); } +fn print_snapshot_diff(data: &serde_json::Map) { + let changed = data + .get("changed") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + if !changed { + println!("{} No changes detected", color::success_indicator()); + return; + } + if let Some(diff) = data.get("diff").and_then(|v| v.as_str()) { + for line in diff.lines() { + if line.starts_with("+ ") { + println!("{}", color::green(line)); + } else if line.starts_with("- ") { + println!("{}", color::red(line)); + } else { + println!("{}", color::dim(line)); + } + } + let additions = data.get("additions").and_then(|v| v.as_i64()).unwrap_or(0); + let removals = data.get("removals").and_then(|v| v.as_i64()).unwrap_or(0); + let unchanged = data.get("unchanged").and_then(|v| v.as_i64()).unwrap_or(0); + println!( + "\n{} additions, {} removals, {} unchanged", + color::green(&additions.to_string()), + color::red(&removals.to_string()), + unchanged + ); + } +} + +fn print_screenshot_diff(data: &serde_json::Map) { + let mismatch = data + .get("mismatchPercentage") + .and_then(|v| v.as_f64()) + .unwrap_or(0.0); + let is_match = data + .get("match") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let dim_mismatch = data + .get("dimensionMismatch") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + if dim_mismatch { + println!( + "{} Images have different dimensions", + color::error_indicator() + ); + } else if is_match { + println!("{} Images match (0% difference)", color::success_indicator()); + } else { + println!( + "{} {:.2}% pixels differ", + color::error_indicator(), + mismatch + ); + } + if let Some(diff_path) = data.get("diffPath").and_then(|v| v.as_str()) { + println!(" Diff image: {}", color::green(diff_path)); + } + let total = data.get("totalPixels").and_then(|v| v.as_i64()).unwrap_or(0); + let different = data + .get("differentPixels") + .and_then(|v| v.as_i64()) + .unwrap_or(0); + println!( + " {} different / {} total pixels", + color::red(&different.to_string()), + total + ); +} + pub fn print_version() { println!("agent-browser {}", env!("CARGO_PKG_VERSION")); } diff --git a/docs/package.json b/docs/package.json index 077a183..e4b0d1d 100644 --- a/docs/package.json +++ b/docs/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev", + "dev": "portless agent-browser next dev", "build": "next build", "start": "next start", "lint": "eslint" diff --git a/docs/src/app/cdp-mode/page.mdx b/docs/src/app/cdp-mode/page.mdx index 98ca5de..9c5242a 100644 --- a/docs/src/app/cdp-mode/page.mdx +++ b/docs/src/app/cdp-mode/page.mdx @@ -70,25 +70,30 @@ This enables control of: ## Global options -| Option | Description | -| --- | --- | -| `--session ` | Use isolated session | -| `--profile ` | Persistent browser profile directory | -| `-p ` | Cloud browser provider (`browserbase`, `browseruse`, `kernel`) | -| `--headers ` | HTTP headers scoped to origin | -| `--executable-path` | Custom browser executable | -| `--args ` | Browser launch args (comma-separated) | -| `--user-agent ` | Custom User-Agent string | -| `--proxy ` | Proxy server URL | -| `--proxy-bypass ` | Hosts to bypass proxy | -| `--json` | JSON output for scripts | -| `--full, -f` | Full page screenshot | -| `--name, -n` | Locator name filter | -| `--exact` | Exact text match | -| `--headed` | Show browser window | -| `--cdp ` | CDP connection (port or WebSocket URL) | -| `--auto-connect` | Auto-discover and connect to running Chrome | -| `--debug` | Debug output | + + + + + + + + + + + + + + + + + + + + + + + +
OptionDescription
--session <name>Use isolated session
--profile <path>Persistent browser profile directory
-p <provider>Cloud browser provider (browserbase, browseruse, kernel)
--headers <json>HTTP headers scoped to origin
--executable-pathCustom browser executable
--args <args>Browser launch args (comma-separated)
--user-agent <ua>Custom User-Agent string
--proxy <url>Proxy server URL
--proxy-bypass <hosts>Hosts to bypass proxy
--jsonJSON output for scripts
--full, -fFull page screenshot
--name, -nLocator name filter
--exactExact text match
--headedShow browser window
{"--cdp "}CDP connection (port or WebSocket URL)
--auto-connectAuto-discover and connect to running Chrome
--debugDebug output
## Cloud providers diff --git a/docs/src/app/configuration/page.mdx b/docs/src/app/configuration/page.mdx index 660152b..a82c36b 100644 --- a/docs/src/app/configuration/page.mdx +++ b/docs/src/app/configuration/page.mdx @@ -8,12 +8,17 @@ Create an `agent-browser.json` file to set persistent defaults instead of repeat agent-browser checks two locations, merged in priority order: -| Priority | Location | Scope | -|----------|----------|-------| -| 1 (lowest) | `~/.agent-browser/config.json` | User-level defaults | -| 2 | `./agent-browser.json` | Project-level overrides | -| 3 | `AGENT_BROWSER_*` env vars | Override config values | -| 4 (highest) | CLI flags | Override everything | + + + + + + + + + + +
PriorityLocationScope
1 (lowest)~/.agent-browser/config.jsonUser-level defaults
2./agent-browser.jsonProject-level overrides
3AGENT_BROWSER_* env varsOverride config values
4 (highest)CLI flagsOverride everything
Project-level values override user-level values. Environment variables override both. CLI flags always win. @@ -40,29 +45,34 @@ AGENT_BROWSER_CONFIG=./ci-config.json agent-browser open example.com Every CLI flag can be set in the config file using its camelCase equivalent: -| Config Key | CLI Flag | Type | -|------------|----------|------| -| `headed` | `--headed` | boolean | -| `json` | `--json` | boolean | -| `full` | `--full, -f` | boolean | -| `debug` | `--debug` | boolean | -| `session` | `--session` | string | -| `sessionName` | `--session-name` | string | -| `executablePath` | `--executable-path` | string | -| `extensions` | `--extension` | string[] | -| `profile` | `--profile` | string | -| `state` | `--state` | string | -| `proxy` | `--proxy` | string | -| `proxyBypass` | `--proxy-bypass` | string | -| `args` | `--args` | string | -| `userAgent` | `--user-agent` | string | -| `provider` | `-p, --provider` | string | -| `device` | `--device` | string | -| `ignoreHttpsErrors` | `--ignore-https-errors` | boolean | -| `allowFileAccess` | `--allow-file-access` | boolean | -| `cdp` | `--cdp` | string | -| `autoConnect` | `--auto-connect` | boolean | -| `headers` | `--headers` | string (JSON) | + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Config KeyCLI FlagType
headed--headedboolean
json--jsonboolean
full--full, -fboolean
debug--debugboolean
session--sessionstring
sessionName--session-namestring
executablePath--executable-pathstring
extensions--extensionstring[]
profile--profilestring
state--statestring
proxy--proxystring
proxyBypass--proxy-bypassstring
args--argsstring
userAgent--user-agentstring
provider-p, --providerstring
device--devicestring
ignoreHttpsErrors--ignore-https-errorsboolean
allowFileAccess--allow-file-accessboolean
cdp--cdpstring
autoConnect--auto-connectboolean
headers--headersstring (JSON)
## Common Configurations diff --git a/docs/src/app/diffing/page.mdx b/docs/src/app/diffing/page.mdx new file mode 100644 index 0000000..f6fcda9 --- /dev/null +++ b/docs/src/app/diffing/page.mdx @@ -0,0 +1,177 @@ +export const metadata = { title: "Diffing" } + +import { DiffDemo } from "@/components/diff-demo" + +# Diffing + +Compare page states to detect changes -- structurally via accessibility tree snapshots, visually via pixel comparison, or across two different URLs. + + + +## Commands + + + + + + + + + + + +
CommandDescription
diff snapshotCompare current snapshot to last snapshot in session
diff snapshot --baseline <file>Compare current snapshot to a saved file
diff screenshot --baseline <file>Visual pixel diff against a baseline image
diff url <url1> <url2>Compare two pages (snapshot + optional screenshot)
+ +## Snapshot diff + +Compares the accessibility tree between two points in time using a line-level text diff. + +```bash +# Compare against the last snapshot taken in this session +agent-browser diff snapshot + +# Compare against a saved baseline file +agent-browser diff snapshot --baseline before.txt + +# Scope to a specific part of the page +agent-browser diff snapshot --selector "#main" --compact +``` + +Without `--baseline`, the command automatically compares against the most recent snapshot taken in the current session. This is the primary use case for agents verifying that an action had the intended effect. + +### Options + + + + + + + + + + + +
FlagDescription
-b, --baseline <file>Path to a saved snapshot file to compare against
-s, --selector <sel>Scope the current snapshot to a CSS selector or @ref
-c, --compactUse compact snapshot format
-d, --depth <n>Limit snapshot tree depth
+ +### Output + +The diff uses `+` for added lines and `-` for removed lines, similar to unified diff format. A summary line shows the count of additions, removals, and unchanged lines. + +``` +- button "Submit" [ref=e2] ++ button "Submit" [ref=e2] [disabled] + 3 additions, 2 removals, 41 unchanged +``` + +## Screenshot diff + +Compares the current page screenshot against a baseline image at the pixel level. Produces a diff image with changed pixels highlighted in red. + +```bash +# Basic visual diff +agent-browser diff screenshot --baseline before.png + +# Save diff image to a specific path +agent-browser diff screenshot --baseline before.png --output diff.png + +# Adjust threshold and scope to element +agent-browser diff screenshot --baseline before.png --threshold 0.2 --selector "#hero" +``` + +### Options + + + + + + + + + + + + +
FlagDescription
-b, --baseline <file>Baseline PNG/JPEG image to compare against (required)
-o, --output <file>Path for the generated diff image (default: temp dir)
-t, --threshold <0-1>Color distance threshold (default: 0.1). Higher = more tolerant
-s, --selector <sel>Scope the current screenshot to an element
--fullTake a full-page screenshot
+ +### Output + +Reports the diff image path, number of different pixels, and mismatch percentage. The diff image shows unchanged pixels dimmed with changed pixels in red. + +If the baseline and current images have different dimensions, the command reports a dimension mismatch instead of attempting pixel comparison. + +## URL diff + +Compares two pages by navigating to each in sequence and diffing the results. + +```bash +# Compare two URLs (snapshot diff) +agent-browser diff url https://staging.example.com https://prod.example.com + +# Include visual comparison +agent-browser diff url https://v1.example.com https://v2.example.com --screenshot + +# Full-page screenshot comparison +agent-browser diff url https://v1.example.com https://v2.example.com --screenshot --full +``` + +The command navigates to the first URL, captures state, then navigates to the second URL and captures again. Snapshot diff is always included. Screenshot diff requires the `--screenshot` flag. + +After completion, the browser remains on the second URL. + +### Options + + + + + + + + + + + + + +
FlagDescription
--screenshotAlso perform visual screenshot comparison
--fullUse full-page screenshots
--wait-until <strategy>Navigation wait strategy: load, domcontentloaded, networkidle (default: load)
-s, --selector <sel>Scope snapshots to a CSS selector or @ref
-c, --compactUse compact snapshot format
-d, --depth <n>Limit snapshot tree depth
+ +## Use cases + +### Verifying agent actions + +The most common use case: confirm that an action (click, fill, submit) changed the page as expected. + +```bash +agent-browser snapshot -i # Take interactive-only snapshot (baseline) +agent-browser fill @e3 "test@example.com" +agent-browser diff snapshot # Compare current snapshot to the baseline +``` + +### Monitoring for changes + +Periodically compare a page against a saved baseline to detect updates. + +```bash +# Save baseline +agent-browser open https://example.com && agent-browser snapshot > baseline.txt + +# Later, check for changes +agent-browser open https://example.com && agent-browser diff snapshot --baseline baseline.txt +``` + +### Visual regression testing + +Compare screenshots before and after a deploy to catch unintended visual changes. + +```bash +agent-browser open https://staging.example.com && agent-browser screenshot baseline.png +# ... deploy happens ... +agent-browser open https://staging.example.com && agent-browser diff screenshot --baseline baseline.png +``` + +### Comparing environments + +Diff staging against production to verify parity. + +```bash +agent-browser diff url https://staging.example.com https://prod.example.com --screenshot +``` diff --git a/docs/src/app/ios/page.mdx b/docs/src/app/ios/page.mdx index 9aaa889..82e6b1d 100644 --- a/docs/src/app/ios/page.mdx +++ b/docs/src/app/ios/page.mdx @@ -93,11 +93,16 @@ agent-browser snapshot -i agent-browser tap @e1 ``` -| Variable | Description | -| --- | --- | -| `AGENT_BROWSER_PROVIDER` | Set to `ios` to enable iOS mode | -| `AGENT_BROWSER_IOS_DEVICE` | Device name (e.g., "iPhone 16 Pro") | -| `AGENT_BROWSER_IOS_UDID` | Device UDID (alternative to device name) | + + + + + + + + + +
VariableDescription
AGENT_BROWSER_PROVIDERSet to ios to enable iOS mode
AGENT_BROWSER_IOS_DEVICEDevice name (e.g., "iPhone 16 Pro")
AGENT_BROWSER_IOS_UDIDDevice UDID (alternative to device name)
## Supported devices @@ -168,13 +173,18 @@ agent-browser -p ios --device "John's iPhone" open https://example.com ## Differences from desktop -| Feature | Desktop | iOS | -| --- | --- | --- | -| Browser | Chromium/Firefox/WebKit | Safari only | -| Tabs | Supported | Single tab only | -| PDF export | Supported | Not supported | -| Screencast | Supported | Not supported | -| Swipe gestures | Not native | Native support | + + + + + + + + + + + +
FeatureDesktopiOS
BrowserChromium/Firefox/WebKitSafari only
TabsSupportedSingle tab only
PDF exportSupportedNot supported
ScreencastSupportedNot supported
Swipe gesturesNot nativeNative support
## Troubleshooting diff --git a/docs/src/app/profiler/page.mdx b/docs/src/app/profiler/page.mdx index d0bd372..f964376 100644 --- a/docs/src/app/profiler/page.mdx +++ b/docs/src/app/profiler/page.mdx @@ -25,11 +25,16 @@ tool that accepts Chrome Trace Event format. ## Commands -| Command | Description | -|---------|-------------| -| `profiler start` | Start recording a performance profile | -| `profiler start --categories ` | Start with custom trace categories | -| `profiler stop [path]` | Stop profiling and save to file | + + + + + + + + + +
CommandDescription
profiler startStart recording a performance profile
profiler start --categories <list>Start with custom trace categories
profiler stop [path]Stop profiling and save to file
## Trace categories @@ -46,14 +51,19 @@ call stack analysis. ### Common categories -| Category | What it captures | -|----------|-----------------| -| `devtools.timeline` | Standard DevTools performance events | -| `v8.execute` | Time spent running JavaScript | -| `blink` | Renderer events (layout, paint, style) | -| `blink.user_timing` | `performance.mark()` and `performance.measure()` calls | -| `latencyInfo` | Input-to-display latency | -| `disabled-by-default-v8.cpu_profiler` | Sampling-based JS CPU profiling | + + + + + + + + + + + + +
CategoryWhat it captures
devtools.timelineStandard DevTools performance events
v8.executeTime spent running JavaScript
blinkRenderer events (layout, paint, style)
blink.user_timingperformance.mark() and performance.measure() calls
latencyInfoInput-to-display latency
disabled-by-default-v8.cpu_profilerSampling-based JS CPU profiling
## Output format diff --git a/docs/src/app/sessions/page.mdx b/docs/src/app/sessions/page.mdx index ec4c51b..b73182e 100644 --- a/docs/src/app/sessions/page.mdx +++ b/docs/src/app/sessions/page.mdx @@ -181,9 +181,14 @@ agent-browser set headers '{"X-Custom-Header": "value"}' ## Environment variables -| Variable | Description | -|----------|-------------| -| `AGENT_BROWSER_SESSION` | Browser session ID (default: "default") | -| `AGENT_BROWSER_SESSION_NAME` | Auto-save/load state persistence name | -| `AGENT_BROWSER_ENCRYPTION_KEY` | 64-char hex key for AES-256-GCM encryption | -| `AGENT_BROWSER_STATE_EXPIRE_DAYS` | Auto-delete states older than N days (default: 30) | + + + + + + + + + + +
VariableDescription
AGENT_BROWSER_SESSIONBrowser session ID (default: "default")
AGENT_BROWSER_SESSION_NAMEAuto-save/load state persistence name
AGENT_BROWSER_ENCRYPTION_KEY64-char hex key for AES-256-GCM encryption
AGENT_BROWSER_STATE_EXPIRE_DAYSAuto-delete states older than N days (default: 30)
diff --git a/docs/src/app/snapshots/page.mdx b/docs/src/app/snapshots/page.mdx index c26a5d2..43471ee 100644 --- a/docs/src/app/snapshots/page.mdx +++ b/docs/src/app/snapshots/page.mdx @@ -18,13 +18,18 @@ agent-browser snapshot -s "#main" # Scope to CSS selector agent-browser snapshot -i -c -d 5 # Combine options ``` -| Option | Description | -| --- | --- | -| `-i, --interactive` | Only interactive elements (buttons, links, inputs) | -| `-C, --cursor` | Include cursor-interactive elements (cursor:pointer, onclick, tabindex) | -| `-c, --compact` | Remove empty structural elements | -| `-d, --depth` | Limit tree depth | -| `-s, --selector` | Scope to CSS selector | + + + + + + + + + + + +
OptionDescription
-i, --interactiveOnly interactive elements (buttons, links, inputs)
-C, --cursorInclude cursor-interactive elements (cursor:pointer, onclick, tabindex)
-c, --compactRemove empty structural elements
-d, --depthLimit tree depth
-s, --selectorScope to CSS selector
## Cursor-interactive elements diff --git a/docs/src/components/diff-demo.tsx b/docs/src/components/diff-demo.tsx new file mode 100644 index 0000000..d94d86f --- /dev/null +++ b/docs/src/components/diff-demo.tsx @@ -0,0 +1,282 @@ +"use client"; + +function DiffLine({ line }: { line: string }) { + if (line.startsWith("+ ")) { + return
{line}
; + } + if (line.startsWith("- ")) { + return
{line}
; + } + return
{line}
; +} + +function CommandLine({ children }: { children: string }) { + return ( +
+ $ + {children} +
+ ); +} + +function Terminal({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function PageMockup({ + label, + buttonColor, + diffMode, +}: { + label: string; + buttonColor: string; + diffMode?: boolean; +}) { + const dimOpacity = diffMode ? 0.15 : 1; + return ( +
+
+ {label} +
+ + + + {/* Nav bar */} + + + + + + {/* Heading */} + + + {/* Subtext */} + + + {/* Input field */} + + + {/* Button -- this is what changes */} + {diffMode ? ( + <> + + + + ) : ( + + )} + + Submit + + + {/* Footer line */} + + +
+ ); +} + +const snapshotDiffLines = [ + " heading \"Sign Up\" [ref=e1]", + " text \"Create your account\" [ref=e2]", + "- textbox \"Email\" [ref=e3]", + "+ textbox \"Email\" [ref=e3]: \"test@example.com\"", + "- button \"Submit\" [ref=e4]", + "+ button \"Submit\" [ref=e4] [disabled]", + "+ status \"Sending...\" [ref=e7]", + " link \"Already have an account?\" [ref=e5]", +]; + +export function DiffDemo() { + return ( +
+ {/* Panel 1: Snapshot diff */} +
+
+ Verify an action changed the page +
+ +
+ agent-browser snapshot -i + + agent-browser fill @e3 "test@example.com" + + agent-browser click @e4 +
+
+ agent-browser diff snapshot +
+
+ {snapshotDiffLines.map((line, i) => ( + + ))} +
+ 3 additions,{" "} + 2 removals,{" "} + 3 unchanged +
+
+
+
+ + {/* Panel 2: Screenshot diff */} +
+
+ Catch a visual regression +
+ +
+ + agent-browser diff screenshot --baseline before-deploy.png + +
+
+
+ ✗ 2.37% pixels differ +
+
+ Diff image: ~/.agent-browser/tmp/diffs/diff-1708473621.png +
+
+ 1,137 different /{" "} + 48,000 total pixels +
+
+
+
+ + + +
+
+
+ ); +} diff --git a/docs/src/lib/docs-navigation.ts b/docs/src/lib/docs-navigation.ts index 24b3317..4772231 100644 --- a/docs/src/lib/docs-navigation.ts +++ b/docs/src/lib/docs-navigation.ts @@ -30,6 +30,7 @@ export const navigation: NavSection[] = [ title: "Features", items: [ { name: "Sessions", href: "/sessions" }, + { name: "Diffing", href: "/diffing" }, { name: "CDP Mode", href: "/cdp-mode" }, { name: "Streaming", href: "/streaming" }, { name: "Profiler", href: "/profiler" }, diff --git a/skills/agent-browser/SKILL.md b/skills/agent-browser/SKILL.md index ea452e2..78538df 100644 --- a/skills/agent-browser/SKILL.md +++ b/skills/agent-browser/SKILL.md @@ -82,6 +82,14 @@ agent-browser screenshot # Screenshot to temp dir agent-browser screenshot --full # Full page screenshot agent-browser screenshot --annotate # Annotated screenshot with numbered element labels agent-browser pdf output.pdf # Save as PDF + +# Diff (compare page states) +agent-browser diff snapshot # Compare current vs last snapshot +agent-browser diff snapshot --baseline before.txt # Compare current vs saved file +agent-browser diff screenshot --baseline before.png # Visual pixel diff +agent-browser diff url # Compare two pages +agent-browser diff url --wait-until networkidle # Custom wait strategy +agent-browser diff url --selector "#main" # Scope to element ``` ## Common Patterns @@ -219,6 +227,31 @@ agent-browser -p ios close **Real devices:** Works with physical iOS devices if pre-configured. Use `--device ""` where UDID is from `xcrun xctrace list devices`. +## Diffing (Verifying Changes) + +Use `diff snapshot` after performing an action to verify it had the intended effect. This compares the current accessibility tree against the last snapshot taken in the session. + +```bash +# Typical workflow: snapshot -> action -> diff +agent-browser snapshot -i # Take baseline snapshot +agent-browser click @e2 # Perform action +agent-browser diff snapshot # See what changed (auto-compares to last snapshot) +``` + +For visual regression testing or monitoring: + +```bash +# Save a baseline screenshot, then compare later +agent-browser screenshot baseline.png +# ... time passes or changes are made ... +agent-browser diff screenshot --baseline baseline.png + +# Compare staging vs production +agent-browser diff url https://staging.example.com https://prod.example.com --screenshot +``` + +`diff snapshot` output uses `+` for additions and `-` for removals, similar to git diff. `diff screenshot` produces a diff image with changed pixels highlighted in red, plus a mismatch percentage. + ## Timeouts and Slow Pages The default Playwright timeout is 60 seconds for local browsers. For slow websites or large pages, use explicit waits instead of relying on the default timeout: diff --git a/src/actions.ts b/src/actions.ts index 4889b9b..3af3d07 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -123,10 +123,16 @@ import type { RecordingStartCommand, RecordingStopCommand, RecordingRestartCommand, + DiffSnapshotCommand, + DiffScreenshotCommand, + DiffUrlCommand, Annotation, NavigateData, ScreenshotData, EvaluateData, + DiffSnapshotData, + DiffScreenshotData, + DiffUrlData, ContentData, TabListData, TabNewData, @@ -141,6 +147,8 @@ import type { StylesData, } from './types.js'; import { successResponse, errorResponse } from './protocol.js'; +import { diffSnapshots, diffScreenshots } from './diff.js'; +import { getEnhancedSnapshot } from './snapshot.js'; // Callback for screencast frames - will be set by the daemon when streaming is active let screencastFrameCallback: ((frame: ScreencastFrame) => void) | null = null; @@ -486,6 +494,12 @@ export async function executeCommand(command: Command, browser: BrowserManager): return await handleRecordingStop(command, browser); case 'recording_restart': return await handleRecordingRestart(command, browser); + case 'diff_snapshot': + return await handleDiffSnapshot(command, browser); + case 'diff_screenshot': + return await handleDiffScreenshot(command, browser); + case 'diff_url': + return await handleDiffUrl(command, browser); default: { // TypeScript narrows to never here, but we handle it for safety const unknownCommand = command as { id: string; action: string }; @@ -2464,3 +2478,106 @@ async function handleRecordingRestart( stopped: result.stopped, }); } + +// Diff handlers + +async function handleDiffSnapshot( + command: DiffSnapshotCommand, + browser: BrowserManager +): Promise { + let before: string; + + if (command.baseline) { + try { + before = fs.readFileSync(command.baseline, 'utf-8'); + } catch { + return errorResponse(command.id, `Cannot read baseline file: ${command.baseline}`); + } + } else { + before = browser.getLastSnapshot(); + if (!before) { + return errorResponse( + command.id, + 'No previous snapshot in this session. Take a snapshot first, or use --baseline .' + ); + } + } + + const page = browser.getPage(); + const { tree } = await getEnhancedSnapshot(page, { + selector: command.selector, + compact: command.compact, + maxDepth: command.maxDepth, + }); + + const after = tree || 'Empty page'; + const result = diffSnapshots(before, after); + browser.setLastSnapshot(after); + return successResponse(command.id, result); +} + +async function handleDiffScreenshot( + command: DiffScreenshotCommand, + browser: BrowserManager +): Promise { + if (!fs.existsSync(command.baseline)) { + return errorResponse(command.id, `Baseline file not found: ${command.baseline}`); + } + + const page = browser.getPage(); + let screenshotBuffer: Buffer; + if (command.selector) { + const locator = browser.getLocatorFromRef(command.selector) || page.locator(command.selector); + screenshotBuffer = await locator.screenshot({ type: 'png' }); + } else { + screenshotBuffer = await page.screenshot({ fullPage: command.fullPage, type: 'png' }); + } + + const baselineBuffer = fs.readFileSync(command.baseline); + const ext = path.extname(command.baseline).toLowerCase(); + const baselineMime = ext === '.jpg' || ext === '.jpeg' ? 'image/jpeg' : 'image/png'; + + const result = await diffScreenshots(page.context(), baselineBuffer, screenshotBuffer, { + threshold: command.threshold, + outputPath: command.output, + baselineMime, + }); + + return successResponse(command.id, result); +} + +async function handleDiffUrl(command: DiffUrlCommand, browser: BrowserManager): Promise { + const page = browser.getPage(); + + const waitUntil = command.waitUntil ?? 'load'; + const snapshotOpts = { + selector: command.selector, + compact: command.compact, + maxDepth: command.maxDepth, + }; + + // Capture state of url1 + await page.goto(command.url1, { waitUntil }); + const { tree: tree1 } = await getEnhancedSnapshot(page, snapshotOpts); + const snapshot1 = tree1 || 'Empty page'; + let screenshot1: Buffer | undefined; + if (command.screenshot) { + screenshot1 = await page.screenshot({ fullPage: command.fullPage, type: 'png' }); + } + + // Capture state of url2 + await page.goto(command.url2, { waitUntil }); + const { tree: tree2 } = await getEnhancedSnapshot(page, snapshotOpts); + const snapshot2 = tree2 || 'Empty page'; + + const snapshotDiff = diffSnapshots(snapshot1, snapshot2); + + const result: DiffUrlData = { snapshot: snapshotDiff }; + + if (command.screenshot && screenshot1) { + const screenshot2 = await page.screenshot({ fullPage: command.fullPage, type: 'png' }); + result.screenshot = await diffScreenshots(page.context(), screenshot1, screenshot2, {}); + } + + return successResponse(command.id, result); +} diff --git a/src/browser.ts b/src/browser.ts index 98135c3..f78e83d 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -154,6 +154,20 @@ export class BrowserManager { return snapshot; } + /** + * Get the last snapshot tree text (empty string if no snapshot has been taken) + */ + getLastSnapshot(): string { + return this.lastSnapshot; + } + + /** + * Update the stored snapshot (used by diff to keep the baseline current) + */ + setLastSnapshot(snapshot: string): void { + this.lastSnapshot = snapshot; + } + /** * Get the cached ref map from last snapshot */ diff --git a/src/diff.test.ts b/src/diff.test.ts new file mode 100644 index 0000000..1a96d70 --- /dev/null +++ b/src/diff.test.ts @@ -0,0 +1,189 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { diffSnapshots, diffScreenshots } from './diff.js'; +import { chromium, type Browser, type BrowserContext, type Page } from 'playwright-core'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; + +describe('diffSnapshots', () => { + it('should report no changes for identical inputs', () => { + const text = 'heading "Hello"\nbutton "Submit" [ref=e1]'; + const result = diffSnapshots(text, text); + expect(result.changed).toBe(false); + expect(result.additions).toBe(0); + expect(result.removals).toBe(0); + expect(result.unchanged).toBe(2); + }); + + it('should report no changes for empty inputs', () => { + const result = diffSnapshots('', ''); + expect(result.changed).toBe(false); + expect(result.additions).toBe(0); + expect(result.removals).toBe(0); + expect(result.unchanged).toBe(1); + }); + + it('should detect a single-line addition', () => { + const before = 'heading "Hello"'; + const after = 'heading "Hello"\nbutton "New"'; + const result = diffSnapshots(before, after); + expect(result.changed).toBe(true); + expect(result.additions).toBe(1); + expect(result.removals).toBe(0); + expect(result.unchanged).toBe(1); + expect(result.diff).toContain('+ button "New"'); + }); + + it('should detect a single-line removal', () => { + const before = 'heading "Hello"\nbutton "Gone"'; + const after = 'heading "Hello"'; + const result = diffSnapshots(before, after); + expect(result.changed).toBe(true); + expect(result.additions).toBe(0); + expect(result.removals).toBe(1); + expect(result.unchanged).toBe(1); + expect(result.diff).toContain('- button "Gone"'); + }); + + it('should detect completely different inputs', () => { + const before = 'line A\nline B'; + const after = 'line C\nline D'; + const result = diffSnapshots(before, after); + expect(result.changed).toBe(true); + expect(result.additions).toBe(2); + expect(result.removals).toBe(2); + expect(result.unchanged).toBe(0); + }); + + it('should handle mixed additions, removals, and unchanged lines', () => { + const before = [ + 'heading "Title"', + 'button "Submit" [ref=e2]', + 'text "old value"', + 'footer "Copyright"', + ].join('\n'); + const after = [ + 'heading "Title"', + 'button "Submit" [ref=e2] [disabled]', + 'text "new value"', + 'link "Help" [ref=e5]', + 'footer "Copyright"', + ].join('\n'); + const result = diffSnapshots(before, after); + expect(result.changed).toBe(true); + expect(result.additions).toBeGreaterThan(0); + expect(result.removals).toBeGreaterThan(0); + expect(result.unchanged).toBeGreaterThan(0); + expect(result.diff).toContain('+ '); + expect(result.diff).toContain('- '); + }); + + it('should use + prefix for insertions and - prefix for deletions', () => { + const before = 'alpha'; + const after = 'beta'; + const result = diffSnapshots(before, after); + const lines = result.diff.split('\n'); + const deletions = lines.filter((l) => l.startsWith('- ')); + const insertions = lines.filter((l) => l.startsWith('+ ')); + expect(deletions.length).toBe(1); + expect(insertions.length).toBe(1); + expect(deletions[0]).toBe('- alpha'); + expect(insertions[0]).toBe('+ beta'); + }); + + it('should use two-space prefix for unchanged lines', () => { + const text = 'unchanged line'; + const result = diffSnapshots(text, text); + expect(result.diff).toBe(' unchanged line'); + }); + + it('should handle multiline to empty', () => { + const before = 'line 1\nline 2\nline 3'; + const after = ''; + const result = diffSnapshots(before, after); + expect(result.changed).toBe(true); + expect(result.removals).toBeGreaterThanOrEqual(3); + }); + + it('should handle empty to multiline', () => { + const before = ''; + const after = 'line 1\nline 2\nline 3'; + const result = diffSnapshots(before, after); + expect(result.changed).toBe(true); + expect(result.additions).toBeGreaterThanOrEqual(3); + }); +}); + +const canLaunchBrowser = await (async () => { + try { + const b = await chromium.launch({ headless: true }); + await b.close(); + return true; + } catch { + return false; + } +})(); + +describe.skipIf(!canLaunchBrowser)('diffScreenshots', () => { + let browser: Browser; + let context: BrowserContext; + let page: Page; + + beforeAll(async () => { + browser = await chromium.launch({ headless: true }); + context = await browser.newContext({ viewport: { width: 200, height: 200 } }); + page = await context.newPage(); + }); + + afterAll(async () => { + await browser.close(); + }); + + async function screenshotOfColor(color: string): Promise { + await page.setContent(`
`); + return await page.screenshot({ type: 'png' }); + } + + it('should report match for identical images', async () => { + const img = await screenshotOfColor('red'); + const result = await diffScreenshots(context, img, img, {}); + expect(result.match).toBe(true); + expect(result.differentPixels).toBe(0); + expect(result.mismatchPercentage).toBe(0); + expect(result.dimensionMismatch).toBeUndefined(); + if (result.diffPath) fs.unlinkSync(result.diffPath); + }); + + it('should detect differences between distinct images', async () => { + const imgA = await screenshotOfColor('red'); + const imgB = await screenshotOfColor('blue'); + const result = await diffScreenshots(context, imgA, imgB, {}); + expect(result.match).toBe(false); + expect(result.differentPixels).toBeGreaterThan(0); + expect(result.mismatchPercentage).toBeGreaterThan(0); + if (result.diffPath) fs.unlinkSync(result.diffPath); + }); + + it('should detect dimension mismatch', async () => { + const imgA = await screenshotOfColor('white'); + await page.setViewportSize({ width: 100, height: 100 }); + const imgB = await screenshotOfColor('white'); + await page.setViewportSize({ width: 200, height: 200 }); + const result = await diffScreenshots(context, imgA, imgB, {}); + expect(result.dimensionMismatch).toBe(true); + expect(result.mismatchPercentage).toBe(100); + if (result.diffPath) fs.unlinkSync(result.diffPath); + }); + + it('should write diff image to custom outputPath', async () => { + const imgA = await screenshotOfColor('green'); + const imgB = await screenshotOfColor('yellow'); + const outputPath = path.join(os.tmpdir(), `diff-test-${Date.now()}.png`); + const result = await diffScreenshots(context, imgA, imgB, { outputPath }); + expect(result.diffPath).toBe(outputPath); + expect(fs.existsSync(outputPath)).toBe(true); + const stat = fs.statSync(outputPath); + expect(stat.size).toBeGreaterThan(0); + fs.unlinkSync(outputPath); + }); +}); diff --git a/src/diff.ts b/src/diff.ts new file mode 100644 index 0000000..af03cf5 --- /dev/null +++ b/src/diff.ts @@ -0,0 +1,339 @@ +import type { BrowserContext } from 'playwright-core'; +import type { DiffSnapshotData, DiffScreenshotData } from './types.js'; +import { writeFile, mkdir } from 'node:fs/promises'; +import path from 'node:path'; + +// --- Text diffing (Myers algorithm, line-level) --- + +interface DiffEdit { + type: 'equal' | 'insert' | 'delete'; + line: string; +} + +/** + * Myers diff algorithm operating on arrays of lines. + * Returns a minimal edit script. + */ +function myersDiff(a: string[], b: string[]): DiffEdit[] { + const n = a.length; + const m = b.length; + const max = n + m; + + if (max === 0) return []; + + // Optimize: if both are identical, skip diff + if (n === m) { + let identical = true; + for (let i = 0; i < n; i++) { + if (a[i] !== b[i]) { + identical = false; + break; + } + } + if (identical) return a.map((line) => ({ type: 'equal' as const, line })); + } + + const vSize = 2 * max + 1; + const v = new Int32Array(vSize); + v.fill(-1); + const trace: Int32Array[] = []; + + v[max + 1] = 0; + for (let d = 0; d <= max; d++) { + const snapshot = new Int32Array(v); + trace.push(snapshot); + + for (let k = -d; k <= d; k += 2) { + const idx = k + max; + let x: number; + if (k === -d || (k !== d && v[idx - 1] < v[idx + 1])) { + x = v[idx + 1]; + } else { + x = v[idx - 1] + 1; + } + let y = x - k; + + while (x < n && y < m && a[x] === b[y]) { + x++; + y++; + } + + v[idx] = x; + + if (x >= n && y >= m) { + return buildEditScript(trace, a, b, max); + } + } + } + + return buildEditScript(trace, a, b, max); +} + +function buildEditScript(trace: Int32Array[], a: string[], b: string[], max: number): DiffEdit[] { + const edits: DiffEdit[] = []; + let x = a.length; + let y = b.length; + + for (let d = trace.length - 1; d > 0; d--) { + const v = trace[d]; + const k = x - y; + const idx = k + max; + + let prevK: number; + if (k === -d || (k !== d && v[idx - 1] < v[idx + 1])) { + prevK = k + 1; + } else { + prevK = k - 1; + } + + const prevIdx = prevK + max; + let prevX = v[prevIdx]; + let prevY = prevX - prevK; + + // Diagonal (equal lines) + while (x > prevX && y > prevY) { + x--; + y--; + edits.push({ type: 'equal', line: a[x] }); + } + + if (x === prevX) { + y--; + edits.push({ type: 'insert', line: b[y] }); + } else { + x--; + edits.push({ type: 'delete', line: a[x] }); + } + } + + // Remaining diagonal at d=0 + while (x > 0 && y > 0) { + x--; + y--; + edits.push({ type: 'equal', line: a[x] }); + } + + edits.reverse(); + return edits; +} + +/** + * Produce a unified diff string and stats from two snapshot texts. + */ +export function diffSnapshots(before: string, after: string): DiffSnapshotData { + const linesA = before.split('\n'); + const linesB = after.split('\n'); + + const edits = myersDiff(linesA, linesB); + + let additions = 0; + let removals = 0; + let unchanged = 0; + const diffLines: string[] = []; + + for (const edit of edits) { + switch (edit.type) { + case 'equal': + unchanged++; + diffLines.push(` ${edit.line}`); + break; + case 'insert': + additions++; + diffLines.push(`+ ${edit.line}`); + break; + case 'delete': + removals++; + diffLines.push(`- ${edit.line}`); + break; + } + } + + return { + diff: diffLines.join('\n'), + additions, + removals, + unchanged, + changed: additions > 0 || removals > 0, + }; +} + +// --- Image diffing (via browser Canvas API) --- + +interface PixelDiffResult { + totalPixels: number; + differentPixels: number; + mismatchPercentage: number; + diffBase64: string; + dimensionMismatch: boolean; +} + +const DIFF_ROUTE_PREFIX = 'https://agent-browser-diff.localhost'; + +/** + * Compare two image buffers using the browser's Canvas API for pixel comparison. + * Uses an isolated blank page to avoid CSP interference or DOM side effects on the + * user's page. Images are served via intercepted routes to avoid large base64 payloads + * through page.evaluate (which can be slow or hit CDP message size limits). + */ +export async function diffScreenshots( + context: BrowserContext, + baselineBuffer: Buffer, + currentBuffer: Buffer, + opts: { threshold?: number; outputPath?: string; baselineMime?: string } +): Promise { + const baselineMime = opts.baselineMime ?? 'image/png'; + const threshold = opts.threshold ?? 0.1; + + const nonce = Math.random().toString(36).slice(2, 10); + const blankUrl = `${DIFF_ROUTE_PREFIX}/${nonce}/index.html`; + const baselineUrl = `${DIFF_ROUTE_PREFIX}/${nonce}/baseline.png`; + const currentUrl = `${DIFF_ROUTE_PREFIX}/${nonce}/current.png`; + + const diffPage = await context.newPage(); + + let blankRouted = false; + let baselineRouted = false; + let currentRouted = false; + try { + await diffPage.route(blankUrl, (route) => + route.fulfill({ body: '', contentType: 'text/html' }) + ); + blankRouted = true; + await diffPage.route(baselineUrl, (route) => + route.fulfill({ body: baselineBuffer, contentType: baselineMime }) + ); + baselineRouted = true; + await diffPage.route(currentUrl, (route) => + route.fulfill({ body: currentBuffer, contentType: 'image/png' }) + ); + currentRouted = true; + + await diffPage.goto(blankUrl); + + const pixelDiffFn = async (args: { + baselineUrl: string; + currentUrl: string; + threshold: number; + }) => { + const g = globalThis as any; + const doc = g.document; + const Img = g.Image as new () => any; + function loadImage(url: string) { + return new Promise((resolve, reject) => { + const img = new Img(); + img.onload = () => resolve(img); + img.onerror = () => reject(new Error('Failed to load image')); + img.src = url; + }); + } + const [imgA, imgB] = (await Promise.all([ + loadImage(args.baselineUrl), + loadImage(args.currentUrl), + ])) as any[]; + if (imgA.width !== imgB.width || imgA.height !== imgB.height) { + const c = doc.createElement('canvas'); + c.width = 1; + c.height = 1; + return { + totalPixels: Math.max(imgA.width * imgA.height, imgB.width * imgB.height), + differentPixels: Math.max(imgA.width * imgA.height, imgB.width * imgB.height), + mismatchPercentage: 100, + diffBase64: c.toDataURL('image/png').split(',')[1], + dimensionMismatch: true, + }; + } + const w = imgA.width; + const h = imgA.height; + const canvasA = doc.createElement('canvas'); + canvasA.width = w; + canvasA.height = h; + const ctxA = canvasA.getContext('2d')!; + ctxA.drawImage(imgA, 0, 0); + const dataA = ctxA.getImageData(0, 0, w, h).data; + const canvasB = doc.createElement('canvas'); + canvasB.width = w; + canvasB.height = h; + const ctxB = canvasB.getContext('2d')!; + ctxB.drawImage(imgB, 0, 0); + const dataB = ctxB.getImageData(0, 0, w, h).data; + const diffCanvas = doc.createElement('canvas'); + diffCanvas.width = w; + diffCanvas.height = h; + const ctxDiff = diffCanvas.getContext('2d')!; + const diffImageData = ctxDiff.createImageData(w, h); + const diffData = diffImageData.data; + const maxColorDistance = args.threshold * 255 * Math.sqrt(3); + let differentPixels = 0; + const totalPixels = w * h; + for (let i = 0; i < totalPixels; i++) { + const offset = i * 4; + const rA = dataA[offset], + gA = dataA[offset + 1], + bA = dataA[offset + 2]; + const rB = dataB[offset], + gB = dataB[offset + 1], + bB = dataB[offset + 2]; + const dr = rA - rB, + dg = gA - gB, + db = bA - bB; + const dist = Math.sqrt(dr * dr + dg * dg + db * db); + if (dist > maxColorDistance) { + differentPixels++; + diffData[offset] = 255; + diffData[offset + 1] = 0; + diffData[offset + 2] = 0; + diffData[offset + 3] = 255; + } else { + diffData[offset] = Math.round(rA * 0.3); + diffData[offset + 1] = Math.round(gA * 0.3); + diffData[offset + 2] = Math.round(bA * 0.3); + diffData[offset + 3] = 255; + } + } + ctxDiff.putImageData(diffImageData, 0, 0); + const diffBase64 = diffCanvas.toDataURL('image/png').split(',')[1]; + return { + totalPixels, + differentPixels, + mismatchPercentage: Math.round((differentPixels / totalPixels) * 10000) / 100, + diffBase64, + dimensionMismatch: false, + }; + }; + + const result = (await diffPage.evaluate(pixelDiffFn, { + baselineUrl, + currentUrl, + threshold, + })) as PixelDiffResult; + + let outputPath = opts.outputPath; + if (!outputPath) { + const tmpDir = path.join( + process.env.HOME || process.env.USERPROFILE || '/tmp', + '.agent-browser', + 'tmp', + 'diffs' + ); + await mkdir(tmpDir, { recursive: true }); + outputPath = path.join(tmpDir, `diff-${Date.now()}.png`); + } + + const diffBuffer = Buffer.from(result.diffBase64, 'base64'); + await writeFile(outputPath, diffBuffer); + + return { + diffPath: outputPath, + totalPixels: result.totalPixels, + differentPixels: result.differentPixels, + mismatchPercentage: result.mismatchPercentage, + match: result.differentPixels === 0, + ...(result.dimensionMismatch ? { dimensionMismatch: true } : {}), + }; + } finally { + if (blankRouted) await diffPage.unroute(blankUrl).catch(() => {}); + if (baselineRouted) await diffPage.unroute(baselineUrl).catch(() => {}); + if (currentRouted) await diffPage.unroute(currentUrl).catch(() => {}); + await diffPage.close().catch(() => {}); + } +} diff --git a/src/protocol.test.ts b/src/protocol.test.ts index 5c3aac0..06c2673 100644 --- a/src/protocol.test.ts +++ b/src/protocol.test.ts @@ -1209,6 +1209,209 @@ describe('parseCommand', () => { }); }); + describe('diff', () => { + it('should parse diff_snapshot with no options', () => { + const result = parseCommand(cmd({ id: '1', action: 'diff_snapshot' })); + expect(result.success).toBe(true); + }); + + it('should parse diff_snapshot with baseline', () => { + const result = parseCommand( + cmd({ id: '1', action: 'diff_snapshot', baseline: 'before.txt' }) + ); + expect(result.success).toBe(true); + if (result.success) { + expect(result.command.baseline).toBe('before.txt'); + } + }); + + it('should parse diff_snapshot with all options', () => { + const result = parseCommand( + cmd({ + id: '1', + action: 'diff_snapshot', + baseline: 'snap.txt', + selector: '#main', + compact: true, + maxDepth: 3, + }) + ); + expect(result.success).toBe(true); + if (result.success) { + expect(result.command.baseline).toBe('snap.txt'); + expect(result.command.selector).toBe('#main'); + expect(result.command.compact).toBe(true); + expect(result.command.maxDepth).toBe(3); + } + }); + + it('should reject diff_snapshot with negative maxDepth', () => { + const result = parseCommand(cmd({ id: '1', action: 'diff_snapshot', maxDepth: -1 })); + expect(result.success).toBe(false); + }); + + it('should parse diff_screenshot with baseline', () => { + const result = parseCommand( + cmd({ id: '1', action: 'diff_screenshot', baseline: 'before.png' }) + ); + expect(result.success).toBe(true); + if (result.success) { + expect(result.command.baseline).toBe('before.png'); + } + }); + + it('should parse diff_screenshot with all options', () => { + const result = parseCommand( + cmd({ + id: '1', + action: 'diff_screenshot', + baseline: 'before.png', + output: 'diff.png', + threshold: 0.2, + selector: '#hero', + fullPage: true, + }) + ); + expect(result.success).toBe(true); + if (result.success) { + expect(result.command.baseline).toBe('before.png'); + expect(result.command.output).toBe('diff.png'); + expect(result.command.threshold).toBe(0.2); + expect(result.command.selector).toBe('#hero'); + expect(result.command.fullPage).toBe(true); + } + }); + + it('should reject diff_screenshot without baseline', () => { + const result = parseCommand(cmd({ id: '1', action: 'diff_screenshot' })); + expect(result.success).toBe(false); + }); + + it('should reject diff_screenshot with threshold out of range', () => { + const result = parseCommand( + cmd({ id: '1', action: 'diff_screenshot', baseline: 'b.png', threshold: 1.5 }) + ); + expect(result.success).toBe(false); + }); + + it('should parse diff_url with two URLs', () => { + const result = parseCommand( + cmd({ id: '1', action: 'diff_url', url1: 'https://a.com', url2: 'https://b.com' }) + ); + expect(result.success).toBe(true); + if (result.success) { + expect(result.command.url1).toBe('https://a.com'); + expect(result.command.url2).toBe('https://b.com'); + } + }); + + it('should parse diff_url with screenshot and fullPage', () => { + const result = parseCommand( + cmd({ + id: '1', + action: 'diff_url', + url1: 'https://a.com', + url2: 'https://b.com', + screenshot: true, + fullPage: true, + }) + ); + expect(result.success).toBe(true); + if (result.success) { + expect(result.command.screenshot).toBe(true); + expect(result.command.fullPage).toBe(true); + } + }); + + it('should parse diff_url with waitUntil', () => { + const result = parseCommand( + cmd({ + id: '1', + action: 'diff_url', + url1: 'https://a.com', + url2: 'https://b.com', + waitUntil: 'networkidle', + }) + ); + expect(result.success).toBe(true); + if (result.success) { + expect(result.command.waitUntil).toBe('networkidle'); + } + }); + + it('should reject diff_url without url1', () => { + const result = parseCommand(cmd({ id: '1', action: 'diff_url', url2: 'https://b.com' })); + expect(result.success).toBe(false); + }); + + it('should reject diff_url without url2', () => { + const result = parseCommand(cmd({ id: '1', action: 'diff_url', url1: 'https://a.com' })); + expect(result.success).toBe(false); + }); + + it('should reject diff_url with invalid waitUntil', () => { + const result = parseCommand( + cmd({ + id: '1', + action: 'diff_url', + url1: 'https://a.com', + url2: 'https://b.com', + waitUntil: 'invalid', + }) + ); + expect(result.success).toBe(false); + }); + + it('should parse diff_url with selector', () => { + const result = parseCommand( + cmd({ + id: '1', + action: 'diff_url', + url1: 'https://a.com', + url2: 'https://b.com', + selector: '#main', + }) + ); + expect(result.success).toBe(true); + if (result.success) { + expect(result.command.selector).toBe('#main'); + } + }); + + it('should parse diff_url with all snapshot options', () => { + const result = parseCommand( + cmd({ + id: '1', + action: 'diff_url', + url1: 'https://a.com', + url2: 'https://b.com', + selector: '#content', + compact: true, + maxDepth: 5, + }) + ); + expect(result.success).toBe(true); + if (result.success) { + expect(result.command.selector).toBe('#content'); + expect(result.command.compact).toBe(true); + expect(result.command.maxDepth).toBe(5); + } + }); + + it('should reject diff_url with negative maxDepth', () => { + const result = parseCommand( + cmd({ + id: '1', + action: 'diff_url', + url1: 'https://a.com', + url2: 'https://b.com', + maxDepth: -1, + }) + ); + expect(result.success).toBe(false); + }); + }); + describe('invalid commands', () => { it('should reject unknown action', () => { const result = parseCommand(cmd({ id: '1', action: 'unknown' })); diff --git a/src/protocol.ts b/src/protocol.ts index e43bd2d..dfc9dc8 100644 --- a/src/protocol.ts +++ b/src/protocol.ts @@ -740,6 +740,36 @@ const deviceListSchema = baseCommandSchema.extend({ action: z.literal('device_list'), }); +// Diff schemas +const diffSnapshotSchema = baseCommandSchema.extend({ + action: z.literal('diff_snapshot'), + baseline: z.string().optional(), + selector: z.string().optional(), + compact: z.boolean().optional(), + maxDepth: z.number().nonnegative().optional(), +}); + +const diffScreenshotSchema = baseCommandSchema.extend({ + action: z.literal('diff_screenshot'), + baseline: z.string().min(1), + output: z.string().optional(), + threshold: z.number().min(0).max(1).optional(), + selector: z.string().min(1).optional(), + fullPage: z.boolean().optional(), +}); + +const diffUrlSchema = baseCommandSchema.extend({ + action: z.literal('diff_url'), + url1: z.string().min(1), + url2: z.string().min(1), + screenshot: z.boolean().optional(), + fullPage: z.boolean().optional(), + waitUntil: z.enum(['load', 'domcontentloaded', 'networkidle']).optional(), + selector: z.string().optional(), + compact: z.boolean().optional(), + maxDepth: z.number().nonnegative().optional(), +}); + const pressSchema = baseCommandSchema.extend({ action: z.literal('press'), key: z.string().min(1), @@ -972,6 +1002,9 @@ const commandSchema = z.discriminatedUnion('action', [ inputTouchSchema, swipeSchema, deviceListSchema, + diffSnapshotSchema, + diffScreenshotSchema, + diffUrlSchema, ]); // Parse result type diff --git a/src/types.ts b/src/types.ts index 0723dcc..4cd7992 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1014,7 +1014,40 @@ export type Command = | InputKeyboardCommand | InputTouchCommand | SwipeCommand - | DeviceListCommand; + | DeviceListCommand + | DiffSnapshotCommand + | DiffScreenshotCommand + | DiffUrlCommand; + +// Diff commands +export interface DiffSnapshotCommand extends BaseCommand { + action: 'diff_snapshot'; + baseline?: string; + selector?: string; + compact?: boolean; + maxDepth?: number; +} + +export interface DiffScreenshotCommand extends BaseCommand { + action: 'diff_screenshot'; + baseline: string; + output?: string; + threshold?: number; + selector?: string; + fullPage?: boolean; +} + +export interface DiffUrlCommand extends BaseCommand { + action: 'diff_url'; + url1: string; + url2: string; + screenshot?: boolean; + fullPage?: boolean; + waitUntil?: 'load' | 'domcontentloaded' | 'networkidle'; + selector?: string; + compact?: boolean; + maxDepth?: number; +} // Response types export interface SuccessResponse { @@ -1145,6 +1178,29 @@ export interface StylesData { elements: ElementStyleInfo[]; } +// Diff response data +export interface DiffSnapshotData { + diff: string; + additions: number; + removals: number; + unchanged: number; + changed: boolean; +} + +export interface DiffScreenshotData { + diffPath: string; + totalPixels: number; + differentPixels: number; + mismatchPercentage: number; + match: boolean; + dimensionMismatch?: boolean; +} + +export interface DiffUrlData { + snapshot: DiffSnapshotData; + screenshot?: DiffScreenshotData; +} + // Browser state export interface BrowserState { browser: Browser | null;