diff (#510)
* diff * fixes * fixes * fixes * fixes * fixes * better docs
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 { .. }
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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 <name></code></td><td>Use isolated session</td></tr>
|
||||
<tr><td><code>--profile <path></code></td><td>Persistent browser profile directory</td></tr>
|
||||
<tr><td><code>-p <provider></code></td><td>Cloud browser provider (<code>browserbase</code>, <code>browseruse</code>, <code>kernel</code>)</td></tr>
|
||||
<tr><td><code>--headers <json></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 <args></code></td><td>Browser launch args (comma-separated)</td></tr>
|
||||
<tr><td><code>--user-agent <ua></code></td><td>Custom User-Agent string</td></tr>
|
||||
<tr><td><code>--proxy <url></code></td><td>Proxy server URL</td></tr>
|
||||
<tr><td><code>--proxy-bypass <hosts></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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 <file></code></td><td>Compare current snapshot to a saved file</td></tr>
|
||||
<tr><td><code>diff screenshot --baseline <file></code></td><td>Visual pixel diff against a baseline image</td></tr>
|
||||
<tr><td><code>diff url <url1> <url2></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 <file></code></td><td>Path to a saved snapshot file to compare against</td></tr>
|
||||
<tr><td><code>-s, --selector <sel></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 <n></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 <file></code></td><td>Baseline PNG/JPEG image to compare against (required)</td></tr>
|
||||
<tr><td><code>-o, --output <file></code></td><td>Path for the generated diff image (default: temp dir)</td></tr>
|
||||
<tr><td><code>-t, --threshold <0-1></code></td><td>Color distance threshold (default: 0.1). Higher = more tolerant</td></tr>
|
||||
<tr><td><code>-s, --selector <sel></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 <strategy></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 <sel></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 <n></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
@@ -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
|
||||
|
||||
|
||||
@@ -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 <list></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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 "test@example.com"
|
||||
</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">
|
||||
✗ 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>
|
||||
);
|
||||
}
|
||||
@@ -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" },
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
@@ -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(() => {});
|
||||
}
|
||||
}
|
||||
@@ -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' }));
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user