* diff

* fixes

* fixes

* fixes

* fixes

* fixes

* better docs
This commit is contained in:
Chris Tate
2026-02-19 23:51:09 -06:00
committed by GitHub
parent 9732031087
commit d5a667ea2d
21 changed files with 2446 additions and 88 deletions
+15
View File
@@ -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
+688
View File
@@ -1037,12 +1037,284 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
}
}
"diff" => parse_diff(&rest, &id, flags),
_ => Err(ParseError::UnknownCommand {
command: cmd.to_string(),
}),
}
}
fn parse_diff(rest: &[&str], id: &str, flags: &Flags) -> Result<Value, ParseError> {
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 <file>",
});
}
}
"-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 <sel>",
});
}
}
"-c" | "--compact" => {
obj.insert("compact".to_string(), json!(true));
}
"-d" | "--depth" => {
if let Some(d) = rest.get(i + 1) {
match d.parse::<u32>() {
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 <n>",
});
}
}
} else {
return Err(ParseError::MissingArguments {
context: "diff snapshot --depth".to_string(),
usage: "diff snapshot --depth <n>",
});
}
}
other if other.starts_with('-') => {
return Err(ParseError::InvalidValue {
message: format!("Unknown flag: {}", other),
usage: "diff snapshot [--baseline <file>] [--selector <sel>] [--compact] [--depth <n>]",
});
}
other => {
return Err(ParseError::InvalidValue {
message: format!("Unexpected argument: {}", other),
usage: "diff snapshot [--baseline <file>] [--selector <sel>] [--compact] [--depth <n>]",
});
}
}
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 <file>",
});
}
}
"-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 <file>",
});
}
}
"-t" | "--threshold" => {
if let Some(t) = rest.get(i + 1) {
match t.parse::<f64>() {
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 <sel>",
});
}
}
"--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 <file> [--output <file>] [--threshold <0-1>] [--selector <sel>] [--full]",
});
}
other => {
return Err(ParseError::InvalidValue {
message: format!("Unexpected argument: {}", other),
usage: "diff screenshot --baseline <file> [--output <file>] [--threshold <0-1>] [--selector <sel>] [--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 <file>",
});
}
Ok(cmd)
}
Some("url") => {
let url1 = rest.get(1).ok_or_else(|| ParseError::MissingArguments {
context: "diff url".to_string(),
usage: "diff url <url1> <url2>",
})?;
let url2 = rest.get(2).ok_or_else(|| ParseError::MissingArguments {
context: "diff url".to_string(),
usage: "diff url <url1> <url2>",
})?;
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 <url1> <url2> --wait-until <load|domcontentloaded|networkidle>",
});
}
}
"-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 <url1> <url2> --selector <sel>",
});
}
}
"-c" | "--compact" => {
obj.insert("compact".to_string(), json!(true));
}
"-d" | "--depth" => {
if let Some(d) = rest.get(i + 1) {
match d.parse::<u32>() {
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 <url1> <url2> --depth <n>",
});
}
}
} else {
return Err(ParseError::MissingArguments {
context: "diff url --depth".to_string(),
usage: "diff url <url1> <url2> --depth <n>",
});
}
}
other if other.starts_with('-') => {
return Err(ParseError::InvalidValue {
message: format!("Unknown flag: {}", other),
usage: "diff url <url1> <url2> [--screenshot] [--full] [--wait-until <strategy>] [--selector <sel>] [--compact] [--depth <n>]",
});
}
other => {
return Err(ParseError::InvalidValue {
message: format!("Unexpected argument: {}", other),
usage: "diff url <url1> <url2> [--screenshot] [--full] [--wait-until <strategy>] [--selector <sel>] [--compact] [--depth <n>]",
});
}
}
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 <snapshot|screenshot|url>",
}),
}
}
fn parse_get(rest: &[&str], id: &str) -> Result<Value, ParseError> {
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 { .. }
));
}
}
+166
View File
@@ -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 <f> Visual pixel diff against a baseline image
diff url <url1> <url2> Compare two pages
Snapshot Diff:
Usage: agent-browser diff snapshot [options]
Options:
-b, --baseline <file> Compare against a saved snapshot file
-s, --selector <sel> Scope snapshot to a CSS selector or @ref
-c, --compact Use compact snapshot format
-d, --depth <n> Limit snapshot tree depth
Without --baseline, compares against the last snapshot taken in this session.
Screenshot Diff:
Usage: agent-browser diff screenshot --baseline <file> [options]
Options:
-b, --baseline <file> Baseline image to compare against (required)
-o, --output <file> Path for the diff image (default: temp dir)
-t, --threshold <0-1> Color distance threshold (default: 0.1)
-s, --selector <sel> Scope screenshot to element
--full Full page screenshot
URL Diff:
Usage: agent-browser diff url <url1> <url2> [options]
Options:
--screenshot Also compare screenshots (default: snapshot only)
--full 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, --compact Use compact snapshot format
-d, --depth <n> Limit snapshot tree depth
Global Options:
--json Output as JSON
--session <name> 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|<n>] Manage tabs
Diff:
diff snapshot Compare current vs last snapshot
diff screenshot --baseline Compare current vs baseline image
diff url <u1> <u2> 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<String, serde_json::Value>) {
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<String, serde_json::Value>) {
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"));
}
+1 -1
View File
@@ -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"
+24 -19
View File
@@ -70,25 +70,30 @@ This enables control of:
## Global options
| Option | Description |
| --- | --- |
| `--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-path` | Custom 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 |
| `--json` | JSON output for scripts |
| `--full, -f` | Full page screenshot |
| `--name, -n` | Locator name filter |
| `--exact` | Exact text match |
| `--headed` | Show browser window |
| `--cdp <port\|url>` | CDP connection (port or WebSocket URL) |
| `--auto-connect` | Auto-discover and connect to running Chrome |
| `--debug` | Debug output |
<table>
<thead>
<tr><th>Option</th><th>Description</th></tr>
</thead>
<tbody>
<tr><td><code>--session &lt;name&gt;</code></td><td>Use isolated session</td></tr>
<tr><td><code>--profile &lt;path&gt;</code></td><td>Persistent browser profile directory</td></tr>
<tr><td><code>-p &lt;provider&gt;</code></td><td>Cloud browser provider (<code>browserbase</code>, <code>browseruse</code>, <code>kernel</code>)</td></tr>
<tr><td><code>--headers &lt;json&gt;</code></td><td>HTTP headers scoped to origin</td></tr>
<tr><td><code>--executable-path</code></td><td>Custom browser executable</td></tr>
<tr><td><code>--args &lt;args&gt;</code></td><td>Browser launch args (comma-separated)</td></tr>
<tr><td><code>--user-agent &lt;ua&gt;</code></td><td>Custom User-Agent string</td></tr>
<tr><td><code>--proxy &lt;url&gt;</code></td><td>Proxy server URL</td></tr>
<tr><td><code>--proxy-bypass &lt;hosts&gt;</code></td><td>Hosts to bypass proxy</td></tr>
<tr><td><code>--json</code></td><td>JSON output for scripts</td></tr>
<tr><td><code>--full, -f</code></td><td>Full page screenshot</td></tr>
<tr><td><code>--name, -n</code></td><td>Locator name filter</td></tr>
<tr><td><code>--exact</code></td><td>Exact text match</td></tr>
<tr><td><code>--headed</code></td><td>Show browser window</td></tr>
<tr><td><code>{"--cdp <port|url>"}</code></td><td>CDP connection (port or WebSocket URL)</td></tr>
<tr><td><code>--auto-connect</code></td><td>Auto-discover and connect to running Chrome</td></tr>
<tr><td><code>--debug</code></td><td>Debug output</td></tr>
</tbody>
</table>
## Cloud providers
+39 -29
View File
@@ -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 |
<table>
<thead>
<tr><th>Priority</th><th>Location</th><th>Scope</th></tr>
</thead>
<tbody>
<tr><td>1 (lowest)</td><td><code>~/.agent-browser/config.json</code></td><td>User-level defaults</td></tr>
<tr><td>2</td><td><code>./agent-browser.json</code></td><td>Project-level overrides</td></tr>
<tr><td>3</td><td><code>AGENT_BROWSER_*</code> env vars</td><td>Override config values</td></tr>
<tr><td>4 (highest)</td><td>CLI flags</td><td>Override everything</td></tr>
</tbody>
</table>
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) |
<table>
<thead>
<tr><th>Config Key</th><th>CLI Flag</th><th>Type</th></tr>
</thead>
<tbody>
<tr><td><code>headed</code></td><td><code>--headed</code></td><td>boolean</td></tr>
<tr><td><code>json</code></td><td><code>--json</code></td><td>boolean</td></tr>
<tr><td><code>full</code></td><td><code>--full, -f</code></td><td>boolean</td></tr>
<tr><td><code>debug</code></td><td><code>--debug</code></td><td>boolean</td></tr>
<tr><td><code>session</code></td><td><code>--session</code></td><td>string</td></tr>
<tr><td><code>sessionName</code></td><td><code>--session-name</code></td><td>string</td></tr>
<tr><td><code>executablePath</code></td><td><code>--executable-path</code></td><td>string</td></tr>
<tr><td><code>extensions</code></td><td><code>--extension</code></td><td>string[]</td></tr>
<tr><td><code>profile</code></td><td><code>--profile</code></td><td>string</td></tr>
<tr><td><code>state</code></td><td><code>--state</code></td><td>string</td></tr>
<tr><td><code>proxy</code></td><td><code>--proxy</code></td><td>string</td></tr>
<tr><td><code>proxyBypass</code></td><td><code>--proxy-bypass</code></td><td>string</td></tr>
<tr><td><code>args</code></td><td><code>--args</code></td><td>string</td></tr>
<tr><td><code>userAgent</code></td><td><code>--user-agent</code></td><td>string</td></tr>
<tr><td><code>provider</code></td><td><code>-p, --provider</code></td><td>string</td></tr>
<tr><td><code>device</code></td><td><code>--device</code></td><td>string</td></tr>
<tr><td><code>ignoreHttpsErrors</code></td><td><code>--ignore-https-errors</code></td><td>boolean</td></tr>
<tr><td><code>allowFileAccess</code></td><td><code>--allow-file-access</code></td><td>boolean</td></tr>
<tr><td><code>cdp</code></td><td><code>--cdp</code></td><td>string</td></tr>
<tr><td><code>autoConnect</code></td><td><code>--auto-connect</code></td><td>boolean</td></tr>
<tr><td><code>headers</code></td><td><code>--headers</code></td><td>string (JSON)</td></tr>
</tbody>
</table>
## Common Configurations
+177
View File
@@ -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.
<DiffDemo />
## Commands
<table>
<thead>
<tr><th>Command</th><th>Description</th></tr>
</thead>
<tbody>
<tr><td><code>diff snapshot</code></td><td>Compare current snapshot to last snapshot in session</td></tr>
<tr><td><code>diff snapshot --baseline &lt;file&gt;</code></td><td>Compare current snapshot to a saved file</td></tr>
<tr><td><code>diff screenshot --baseline &lt;file&gt;</code></td><td>Visual pixel diff against a baseline image</td></tr>
<tr><td><code>diff url &lt;url1&gt; &lt;url2&gt;</code></td><td>Compare two pages (snapshot + optional screenshot)</td></tr>
</tbody>
</table>
## 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
<table>
<thead>
<tr><th>Flag</th><th>Description</th></tr>
</thead>
<tbody>
<tr><td><code>-b, --baseline &lt;file&gt;</code></td><td>Path to a saved snapshot file to compare against</td></tr>
<tr><td><code>-s, --selector &lt;sel&gt;</code></td><td>Scope the current snapshot to a CSS selector or @ref</td></tr>
<tr><td><code>-c, --compact</code></td><td>Use compact snapshot format</td></tr>
<tr><td><code>-d, --depth &lt;n&gt;</code></td><td>Limit snapshot tree depth</td></tr>
</tbody>
</table>
### 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
<table>
<thead>
<tr><th>Flag</th><th>Description</th></tr>
</thead>
<tbody>
<tr><td><code>-b, --baseline &lt;file&gt;</code></td><td>Baseline PNG/JPEG image to compare against (required)</td></tr>
<tr><td><code>-o, --output &lt;file&gt;</code></td><td>Path for the generated diff image (default: temp dir)</td></tr>
<tr><td><code>-t, --threshold &lt;0-1&gt;</code></td><td>Color distance threshold (default: 0.1). Higher = more tolerant</td></tr>
<tr><td><code>-s, --selector &lt;sel&gt;</code></td><td>Scope the current screenshot to an element</td></tr>
<tr><td><code>--full</code></td><td>Take a full-page screenshot</td></tr>
</tbody>
</table>
### 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
<table>
<thead>
<tr><th>Flag</th><th>Description</th></tr>
</thead>
<tbody>
<tr><td><code>--screenshot</code></td><td>Also perform visual screenshot comparison</td></tr>
<tr><td><code>--full</code></td><td>Use full-page screenshots</td></tr>
<tr><td><code>--wait-until &lt;strategy&gt;</code></td><td>Navigation wait strategy: <code>load</code>, <code>domcontentloaded</code>, <code>networkidle</code> (default: <code>load</code>)</td></tr>
<tr><td><code>-s, --selector &lt;sel&gt;</code></td><td>Scope snapshots to a CSS selector or @ref</td></tr>
<tr><td><code>-c, --compact</code></td><td>Use compact snapshot format</td></tr>
<tr><td><code>-d, --depth &lt;n&gt;</code></td><td>Limit snapshot tree depth</td></tr>
</tbody>
</table>
## 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
```
+22 -12
View File
@@ -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) |
<table>
<thead>
<tr><th>Variable</th><th>Description</th></tr>
</thead>
<tbody>
<tr><td><code>AGENT_BROWSER_PROVIDER</code></td><td>Set to <code>ios</code> to enable iOS mode</td></tr>
<tr><td><code>AGENT_BROWSER_IOS_DEVICE</code></td><td>Device name (e.g., "iPhone 16 Pro")</td></tr>
<tr><td><code>AGENT_BROWSER_IOS_UDID</code></td><td>Device UDID (alternative to device name)</td></tr>
</tbody>
</table>
## 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 |
<table>
<thead>
<tr><th>Feature</th><th>Desktop</th><th>iOS</th></tr>
</thead>
<tbody>
<tr><td>Browser</td><td>Chromium/Firefox/WebKit</td><td>Safari only</td></tr>
<tr><td>Tabs</td><td>Supported</td><td>Single tab only</td></tr>
<tr><td>PDF export</td><td>Supported</td><td>Not supported</td></tr>
<tr><td>Screencast</td><td>Supported</td><td>Not supported</td></tr>
<tr><td>Swipe gestures</td><td>Not native</td><td>Native support</td></tr>
</tbody>
</table>
## Troubleshooting
+23 -13
View File
@@ -25,11 +25,16 @@ tool that accepts Chrome Trace Event format.
## Commands
| Command | Description |
|---------|-------------|
| `profiler start` | Start recording a performance profile |
| `profiler start --categories <list>` | Start with custom trace categories |
| `profiler stop [path]` | Stop profiling and save to file |
<table>
<thead>
<tr><th>Command</th><th>Description</th></tr>
</thead>
<tbody>
<tr><td><code>profiler start</code></td><td>Start recording a performance profile</td></tr>
<tr><td><code>profiler start --categories &lt;list&gt;</code></td><td>Start with custom trace categories</td></tr>
<tr><td><code>profiler stop [path]</code></td><td>Stop profiling and save to file</td></tr>
</tbody>
</table>
## 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 |
<table>
<thead>
<tr><th>Category</th><th>What it captures</th></tr>
</thead>
<tbody>
<tr><td><code>devtools.timeline</code></td><td>Standard DevTools performance events</td></tr>
<tr><td><code>v8.execute</code></td><td>Time spent running JavaScript</td></tr>
<tr><td><code>blink</code></td><td>Renderer events (layout, paint, style)</td></tr>
<tr><td><code>blink.user_timing</code></td><td><code>performance.mark()</code> and <code>performance.measure()</code> calls</td></tr>
<tr><td><code>latencyInfo</code></td><td>Input-to-display latency</td></tr>
<tr><td><code>disabled-by-default-v8.cpu_profiler</code></td><td>Sampling-based JS CPU profiling</td></tr>
</tbody>
</table>
## Output format
+11 -6
View File
@@ -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) |
<table>
<thead>
<tr><th>Variable</th><th>Description</th></tr>
</thead>
<tbody>
<tr><td><code>AGENT_BROWSER_SESSION</code></td><td>Browser session ID (default: "default")</td></tr>
<tr><td><code>AGENT_BROWSER_SESSION_NAME</code></td><td>Auto-save/load state persistence name</td></tr>
<tr><td><code>AGENT_BROWSER_ENCRYPTION_KEY</code></td><td>64-char hex key for AES-256-GCM encryption</td></tr>
<tr><td><code>AGENT_BROWSER_STATE_EXPIRE_DAYS</code></td><td>Auto-delete states older than N days (default: 30)</td></tr>
</tbody>
</table>
+12 -7
View File
@@ -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 |
<table>
<thead>
<tr><th>Option</th><th>Description</th></tr>
</thead>
<tbody>
<tr><td><code>-i, --interactive</code></td><td>Only interactive elements (buttons, links, inputs)</td></tr>
<tr><td><code>-C, --cursor</code></td><td>Include cursor-interactive elements (cursor:pointer, onclick, tabindex)</td></tr>
<tr><td><code>-c, --compact</code></td><td>Remove empty structural elements</td></tr>
<tr><td><code>-d, --depth</code></td><td>Limit tree depth</td></tr>
<tr><td><code>-s, --selector</code></td><td>Scope to CSS selector</td></tr>
</tbody>
</table>
## Cursor-interactive elements
+282
View File
@@ -0,0 +1,282 @@
"use client";
function DiffLine({ line }: { line: string }) {
if (line.startsWith("+ ")) {
return <div className="text-green-400">{line}</div>;
}
if (line.startsWith("- ")) {
return <div className="text-red-400">{line}</div>;
}
return <div className="opacity-50">{line}</div>;
}
function CommandLine({ children }: { children: string }) {
return (
<div>
<span className="opacity-40">$ </span>
{children}
</div>
);
}
function Terminal({ children }: { children: React.ReactNode }) {
return (
<div
className="rounded border font-mono text-[0.8125rem] leading-[1.7] overflow-x-auto"
style={{
background: "var(--card)",
borderColor: "var(--border)",
padding: "0.875rem",
}}
>
{children}
</div>
);
}
function PageMockup({
label,
buttonColor,
diffMode,
}: {
label: string;
buttonColor: string;
diffMode?: boolean;
}) {
const dimOpacity = diffMode ? 0.15 : 1;
return (
<div className="flex-1 min-w-0">
<div
className="text-[0.6875rem] font-medium mb-1.5 text-center"
style={{ color: "var(--muted-foreground)" }}
>
{label}
</div>
<svg
viewBox="0 0 160 120"
className="w-full rounded border"
style={{ borderColor: "var(--border)" }}
>
<rect width="160" height="120" fill={diffMode ? "#1a1a1a" : "#111"} />
{/* Nav bar */}
<rect
x="0"
y="0"
width="160"
height="16"
fill="#222"
opacity={dimOpacity}
/>
<rect
x="8"
y="5"
width="24"
height="6"
rx="1"
fill="#555"
opacity={dimOpacity}
/>
<rect
x="120"
y="5"
width="12"
height="6"
rx="1"
fill="#444"
opacity={dimOpacity}
/>
<rect
x="136"
y="5"
width="12"
height="6"
rx="1"
fill="#444"
opacity={dimOpacity}
/>
{/* Heading */}
<rect
x="20"
y="26"
width="80"
height="6"
rx="1"
fill="#666"
opacity={dimOpacity}
/>
{/* Subtext */}
<rect
x="30"
y="38"
width="60"
height="4"
rx="1"
fill="#444"
opacity={dimOpacity}
/>
{/* Input field */}
<rect
x="30"
y="52"
width="100"
height="14"
rx="2"
fill="#1a1a1a"
stroke="#333"
strokeWidth="0.5"
opacity={dimOpacity}
/>
{/* Button -- this is what changes */}
{diffMode ? (
<>
<rect
x="55"
y="76"
width="50"
height="14"
rx="2"
fill="#ef4444"
opacity="0.85"
/>
<rect
x="55"
y="76"
width="50"
height="14"
rx="2"
fill="none"
stroke="#ef4444"
strokeWidth="1.5"
strokeDasharray="3 2"
/>
</>
) : (
<rect
x="55"
y="76"
width="50"
height="14"
rx="2"
fill={buttonColor}
/>
)}
<text
x="80"
y="85.5"
textAnchor="middle"
fill="white"
fontSize="6"
fontFamily="system-ui, sans-serif"
opacity={diffMode ? 0.9 : 1}
>
Submit
</text>
{/* Footer line */}
<rect
x="40"
y="102"
width="80"
height="3"
rx="1"
fill="#333"
opacity={dimOpacity}
/>
</svg>
</div>
);
}
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 (
<div className="grid gap-8 my-8">
{/* Panel 1: Snapshot diff */}
<div>
<div
className="text-xs font-medium uppercase tracking-wider mb-3"
style={{ color: "var(--muted-foreground)" }}
>
Verify an action changed the page
</div>
<Terminal>
<div className="opacity-60 mb-2">
<CommandLine>agent-browser snapshot -i</CommandLine>
<CommandLine>
agent-browser fill @e3 &quot;test@example.com&quot;
</CommandLine>
<CommandLine>agent-browser click @e4</CommandLine>
</div>
<div className="mb-3">
<CommandLine>agent-browser diff snapshot</CommandLine>
</div>
<div
className="border-t pt-3"
style={{ borderColor: "var(--border)" }}
>
{snapshotDiffLines.map((line, i) => (
<DiffLine key={i} line={line} />
))}
<div className="mt-2 opacity-60">
<span className="text-green-400">3</span> additions,{" "}
<span className="text-red-400">2</span> removals,{" "}
<span>3</span> unchanged
</div>
</div>
</Terminal>
</div>
{/* Panel 2: Screenshot diff */}
<div>
<div
className="text-xs font-medium uppercase tracking-wider mb-3"
style={{ color: "var(--muted-foreground)" }}
>
Catch a visual regression
</div>
<Terminal>
<div className="mb-3">
<CommandLine>
agent-browser diff screenshot --baseline before-deploy.png
</CommandLine>
</div>
<div
className="border-t pt-3"
style={{ borderColor: "var(--border)" }}
>
<div className="text-red-400">
&#x2717; 2.37% pixels differ
</div>
<div className="opacity-50">
Diff image: ~/.agent-browser/tmp/diffs/diff-1708473621.png
</div>
<div className="opacity-50">
<span className="text-red-400">1,137</span> different /{" "}
48,000 total pixels
</div>
</div>
</Terminal>
<div className="flex gap-2 mt-3">
<PageMockup label="Baseline" buttonColor="#3b82f6" />
<PageMockup label="Current" buttonColor="#22c55e" />
<PageMockup label="Diff" buttonColor="#ef4444" diffMode />
</div>
</div>
</div>
);
}
+1
View File
@@ -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" },
+33
View File
@@ -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 <url1> <url2> # Compare two pages
agent-browser diff url <url1> <url2> --wait-until networkidle # Custom wait strategy
agent-browser diff url <url1> <url2> --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 "<UDID>"` 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:
+117
View File
@@ -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<Response> {
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 <file>.'
);
}
}
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<Response> {
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<Response> {
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);
}
+14
View File
@@ -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
*/
+189
View File
@@ -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<Buffer> {
await page.setContent(`<div style="width:200px;height:200px;background:${color}"></div>`);
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);
});
});
+339
View File
@@ -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<DiffScreenshotData> {
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: '<html><body></body></html>', 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(() => {});
}
}
+203
View File
@@ -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' }));
+33
View File
@@ -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
+57 -1
View File
@@ -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<T = unknown> {
@@ -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;