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
|
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
|
### Debug
|
||||||
|
|
||||||
```bash
|
```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 {
|
_ => Err(ParseError::UnknownCommand {
|
||||||
command: cmd.to_string(),
|
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> {
|
fn parse_get(rest: &[&str], id: &str) -> Result<Value, ParseError> {
|
||||||
const VALID: &[&str] = &[
|
const VALID: &[&str] = &[
|
||||||
"text", "html", "value", "attr", "url", "title", "count", "box", "styles",
|
"text", "html", "value", "attr", "url", "title", "count", "box", "styles",
|
||||||
@@ -2712,4 +2984,420 @@ mod tests {
|
|||||||
assert_eq!(cmd["action"], "trace_stop");
|
assert_eq!(cmd["action"], "trace_stop");
|
||||||
assert!(cmd.get("path").is_none() || cmd["path"].is_null());
|
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);
|
println!("{}", url);
|
||||||
return;
|
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
|
// Snapshot
|
||||||
if let Some(snapshot) = data.get("snapshot").and_then(|v| v.as_str()) {
|
if let Some(snapshot) = data.get("snapshot").and_then(|v| v.as_str()) {
|
||||||
println!("{}", snapshot);
|
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,
|
_ => return false,
|
||||||
};
|
};
|
||||||
println!("{}", help.trim());
|
println!("{}", help.trim());
|
||||||
@@ -1922,6 +2010,11 @@ Storage:
|
|||||||
Tabs:
|
Tabs:
|
||||||
tab [new|list|close|<n>] Manage 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:
|
Debug:
|
||||||
trace start|stop [path] Record Playwright trace
|
trace start|stop [path] Record Playwright trace
|
||||||
profiler start|stop [path] Record Chrome DevTools profile
|
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() {
|
pub fn print_version() {
|
||||||
println!("agent-browser {}", env!("CARGO_PKG_VERSION"));
|
println!("agent-browser {}", env!("CARGO_PKG_VERSION"));
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "portless agent-browser next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
|
|||||||
@@ -70,25 +70,30 @@ This enables control of:
|
|||||||
|
|
||||||
## Global options
|
## Global options
|
||||||
|
|
||||||
| Option | Description |
|
<table>
|
||||||
| --- | --- |
|
<thead>
|
||||||
| `--session <name>` | Use isolated session |
|
<tr><th>Option</th><th>Description</th></tr>
|
||||||
| `--profile <path>` | Persistent browser profile directory |
|
</thead>
|
||||||
| `-p <provider>` | Cloud browser provider (`browserbase`, `browseruse`, `kernel`) |
|
<tbody>
|
||||||
| `--headers <json>` | HTTP headers scoped to origin |
|
<tr><td><code>--session <name></code></td><td>Use isolated session</td></tr>
|
||||||
| `--executable-path` | Custom browser executable |
|
<tr><td><code>--profile <path></code></td><td>Persistent browser profile directory</td></tr>
|
||||||
| `--args <args>` | Browser launch args (comma-separated) |
|
<tr><td><code>-p <provider></code></td><td>Cloud browser provider (<code>browserbase</code>, <code>browseruse</code>, <code>kernel</code>)</td></tr>
|
||||||
| `--user-agent <ua>` | Custom User-Agent string |
|
<tr><td><code>--headers <json></code></td><td>HTTP headers scoped to origin</td></tr>
|
||||||
| `--proxy <url>` | Proxy server URL |
|
<tr><td><code>--executable-path</code></td><td>Custom browser executable</td></tr>
|
||||||
| `--proxy-bypass <hosts>` | Hosts to bypass proxy |
|
<tr><td><code>--args <args></code></td><td>Browser launch args (comma-separated)</td></tr>
|
||||||
| `--json` | JSON output for scripts |
|
<tr><td><code>--user-agent <ua></code></td><td>Custom User-Agent string</td></tr>
|
||||||
| `--full, -f` | Full page screenshot |
|
<tr><td><code>--proxy <url></code></td><td>Proxy server URL</td></tr>
|
||||||
| `--name, -n` | Locator name filter |
|
<tr><td><code>--proxy-bypass <hosts></code></td><td>Hosts to bypass proxy</td></tr>
|
||||||
| `--exact` | Exact text match |
|
<tr><td><code>--json</code></td><td>JSON output for scripts</td></tr>
|
||||||
| `--headed` | Show browser window |
|
<tr><td><code>--full, -f</code></td><td>Full page screenshot</td></tr>
|
||||||
| `--cdp <port\|url>` | CDP connection (port or WebSocket URL) |
|
<tr><td><code>--name, -n</code></td><td>Locator name filter</td></tr>
|
||||||
| `--auto-connect` | Auto-discover and connect to running Chrome |
|
<tr><td><code>--exact</code></td><td>Exact text match</td></tr>
|
||||||
| `--debug` | Debug output |
|
<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
|
## 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:
|
agent-browser checks two locations, merged in priority order:
|
||||||
|
|
||||||
| Priority | Location | Scope |
|
<table>
|
||||||
|----------|----------|-------|
|
<thead>
|
||||||
| 1 (lowest) | `~/.agent-browser/config.json` | User-level defaults |
|
<tr><th>Priority</th><th>Location</th><th>Scope</th></tr>
|
||||||
| 2 | `./agent-browser.json` | Project-level overrides |
|
</thead>
|
||||||
| 3 | `AGENT_BROWSER_*` env vars | Override config values |
|
<tbody>
|
||||||
| 4 (highest) | CLI flags | Override everything |
|
<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.
|
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:
|
Every CLI flag can be set in the config file using its camelCase equivalent:
|
||||||
|
|
||||||
| Config Key | CLI Flag | Type |
|
<table>
|
||||||
|------------|----------|------|
|
<thead>
|
||||||
| `headed` | `--headed` | boolean |
|
<tr><th>Config Key</th><th>CLI Flag</th><th>Type</th></tr>
|
||||||
| `json` | `--json` | boolean |
|
</thead>
|
||||||
| `full` | `--full, -f` | boolean |
|
<tbody>
|
||||||
| `debug` | `--debug` | boolean |
|
<tr><td><code>headed</code></td><td><code>--headed</code></td><td>boolean</td></tr>
|
||||||
| `session` | `--session` | string |
|
<tr><td><code>json</code></td><td><code>--json</code></td><td>boolean</td></tr>
|
||||||
| `sessionName` | `--session-name` | string |
|
<tr><td><code>full</code></td><td><code>--full, -f</code></td><td>boolean</td></tr>
|
||||||
| `executablePath` | `--executable-path` | string |
|
<tr><td><code>debug</code></td><td><code>--debug</code></td><td>boolean</td></tr>
|
||||||
| `extensions` | `--extension` | string[] |
|
<tr><td><code>session</code></td><td><code>--session</code></td><td>string</td></tr>
|
||||||
| `profile` | `--profile` | string |
|
<tr><td><code>sessionName</code></td><td><code>--session-name</code></td><td>string</td></tr>
|
||||||
| `state` | `--state` | string |
|
<tr><td><code>executablePath</code></td><td><code>--executable-path</code></td><td>string</td></tr>
|
||||||
| `proxy` | `--proxy` | string |
|
<tr><td><code>extensions</code></td><td><code>--extension</code></td><td>string[]</td></tr>
|
||||||
| `proxyBypass` | `--proxy-bypass` | string |
|
<tr><td><code>profile</code></td><td><code>--profile</code></td><td>string</td></tr>
|
||||||
| `args` | `--args` | string |
|
<tr><td><code>state</code></td><td><code>--state</code></td><td>string</td></tr>
|
||||||
| `userAgent` | `--user-agent` | string |
|
<tr><td><code>proxy</code></td><td><code>--proxy</code></td><td>string</td></tr>
|
||||||
| `provider` | `-p, --provider` | string |
|
<tr><td><code>proxyBypass</code></td><td><code>--proxy-bypass</code></td><td>string</td></tr>
|
||||||
| `device` | `--device` | string |
|
<tr><td><code>args</code></td><td><code>--args</code></td><td>string</td></tr>
|
||||||
| `ignoreHttpsErrors` | `--ignore-https-errors` | boolean |
|
<tr><td><code>userAgent</code></td><td><code>--user-agent</code></td><td>string</td></tr>
|
||||||
| `allowFileAccess` | `--allow-file-access` | boolean |
|
<tr><td><code>provider</code></td><td><code>-p, --provider</code></td><td>string</td></tr>
|
||||||
| `cdp` | `--cdp` | string |
|
<tr><td><code>device</code></td><td><code>--device</code></td><td>string</td></tr>
|
||||||
| `autoConnect` | `--auto-connect` | boolean |
|
<tr><td><code>ignoreHttpsErrors</code></td><td><code>--ignore-https-errors</code></td><td>boolean</td></tr>
|
||||||
| `headers` | `--headers` | string (JSON) |
|
<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
|
## 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
|
agent-browser tap @e1
|
||||||
```
|
```
|
||||||
|
|
||||||
| Variable | Description |
|
<table>
|
||||||
| --- | --- |
|
<thead>
|
||||||
| `AGENT_BROWSER_PROVIDER` | Set to `ios` to enable iOS mode |
|
<tr><th>Variable</th><th>Description</th></tr>
|
||||||
| `AGENT_BROWSER_IOS_DEVICE` | Device name (e.g., "iPhone 16 Pro") |
|
</thead>
|
||||||
| `AGENT_BROWSER_IOS_UDID` | Device UDID (alternative to device name) |
|
<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
|
## Supported devices
|
||||||
|
|
||||||
@@ -168,13 +173,18 @@ agent-browser -p ios --device "John's iPhone" open https://example.com
|
|||||||
|
|
||||||
## Differences from desktop
|
## Differences from desktop
|
||||||
|
|
||||||
| Feature | Desktop | iOS |
|
<table>
|
||||||
| --- | --- | --- |
|
<thead>
|
||||||
| Browser | Chromium/Firefox/WebKit | Safari only |
|
<tr><th>Feature</th><th>Desktop</th><th>iOS</th></tr>
|
||||||
| Tabs | Supported | Single tab only |
|
</thead>
|
||||||
| PDF export | Supported | Not supported |
|
<tbody>
|
||||||
| Screencast | Supported | Not supported |
|
<tr><td>Browser</td><td>Chromium/Firefox/WebKit</td><td>Safari only</td></tr>
|
||||||
| Swipe gestures | Not native | Native support |
|
<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
|
## Troubleshooting
|
||||||
|
|
||||||
|
|||||||
@@ -25,11 +25,16 @@ tool that accepts Chrome Trace Event format.
|
|||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
| Command | Description |
|
<table>
|
||||||
|---------|-------------|
|
<thead>
|
||||||
| `profiler start` | Start recording a performance profile |
|
<tr><th>Command</th><th>Description</th></tr>
|
||||||
| `profiler start --categories <list>` | Start with custom trace categories |
|
</thead>
|
||||||
| `profiler stop [path]` | Stop profiling and save to file |
|
<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
|
## Trace categories
|
||||||
|
|
||||||
@@ -46,14 +51,19 @@ call stack analysis.
|
|||||||
|
|
||||||
### Common categories
|
### Common categories
|
||||||
|
|
||||||
| Category | What it captures |
|
<table>
|
||||||
|----------|-----------------|
|
<thead>
|
||||||
| `devtools.timeline` | Standard DevTools performance events |
|
<tr><th>Category</th><th>What it captures</th></tr>
|
||||||
| `v8.execute` | Time spent running JavaScript |
|
</thead>
|
||||||
| `blink` | Renderer events (layout, paint, style) |
|
<tbody>
|
||||||
| `blink.user_timing` | `performance.mark()` and `performance.measure()` calls |
|
<tr><td><code>devtools.timeline</code></td><td>Standard DevTools performance events</td></tr>
|
||||||
| `latencyInfo` | Input-to-display latency |
|
<tr><td><code>v8.execute</code></td><td>Time spent running JavaScript</td></tr>
|
||||||
| `disabled-by-default-v8.cpu_profiler` | Sampling-based JS CPU profiling |
|
<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
|
## Output format
|
||||||
|
|
||||||
|
|||||||
@@ -181,9 +181,14 @@ agent-browser set headers '{"X-Custom-Header": "value"}'
|
|||||||
|
|
||||||
## Environment variables
|
## Environment variables
|
||||||
|
|
||||||
| Variable | Description |
|
<table>
|
||||||
|----------|-------------|
|
<thead>
|
||||||
| `AGENT_BROWSER_SESSION` | Browser session ID (default: "default") |
|
<tr><th>Variable</th><th>Description</th></tr>
|
||||||
| `AGENT_BROWSER_SESSION_NAME` | Auto-save/load state persistence name |
|
</thead>
|
||||||
| `AGENT_BROWSER_ENCRYPTION_KEY` | 64-char hex key for AES-256-GCM encryption |
|
<tbody>
|
||||||
| `AGENT_BROWSER_STATE_EXPIRE_DAYS` | Auto-delete states older than N days (default: 30) |
|
<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
|
agent-browser snapshot -i -c -d 5 # Combine options
|
||||||
```
|
```
|
||||||
|
|
||||||
| Option | Description |
|
<table>
|
||||||
| --- | --- |
|
<thead>
|
||||||
| `-i, --interactive` | Only interactive elements (buttons, links, inputs) |
|
<tr><th>Option</th><th>Description</th></tr>
|
||||||
| `-C, --cursor` | Include cursor-interactive elements (cursor:pointer, onclick, tabindex) |
|
</thead>
|
||||||
| `-c, --compact` | Remove empty structural elements |
|
<tbody>
|
||||||
| `-d, --depth` | Limit tree depth |
|
<tr><td><code>-i, --interactive</code></td><td>Only interactive elements (buttons, links, inputs)</td></tr>
|
||||||
| `-s, --selector` | Scope to CSS selector |
|
<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
|
## 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",
|
title: "Features",
|
||||||
items: [
|
items: [
|
||||||
{ name: "Sessions", href: "/sessions" },
|
{ name: "Sessions", href: "/sessions" },
|
||||||
|
{ name: "Diffing", href: "/diffing" },
|
||||||
{ name: "CDP Mode", href: "/cdp-mode" },
|
{ name: "CDP Mode", href: "/cdp-mode" },
|
||||||
{ name: "Streaming", href: "/streaming" },
|
{ name: "Streaming", href: "/streaming" },
|
||||||
{ name: "Profiler", href: "/profiler" },
|
{ 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 --full # Full page screenshot
|
||||||
agent-browser screenshot --annotate # Annotated screenshot with numbered element labels
|
agent-browser screenshot --annotate # Annotated screenshot with numbered element labels
|
||||||
agent-browser pdf output.pdf # Save as PDF
|
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
|
## 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`.
|
**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
|
## 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:
|
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,
|
RecordingStartCommand,
|
||||||
RecordingStopCommand,
|
RecordingStopCommand,
|
||||||
RecordingRestartCommand,
|
RecordingRestartCommand,
|
||||||
|
DiffSnapshotCommand,
|
||||||
|
DiffScreenshotCommand,
|
||||||
|
DiffUrlCommand,
|
||||||
Annotation,
|
Annotation,
|
||||||
NavigateData,
|
NavigateData,
|
||||||
ScreenshotData,
|
ScreenshotData,
|
||||||
EvaluateData,
|
EvaluateData,
|
||||||
|
DiffSnapshotData,
|
||||||
|
DiffScreenshotData,
|
||||||
|
DiffUrlData,
|
||||||
ContentData,
|
ContentData,
|
||||||
TabListData,
|
TabListData,
|
||||||
TabNewData,
|
TabNewData,
|
||||||
@@ -141,6 +147,8 @@ import type {
|
|||||||
StylesData,
|
StylesData,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import { successResponse, errorResponse } from './protocol.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
|
// Callback for screencast frames - will be set by the daemon when streaming is active
|
||||||
let screencastFrameCallback: ((frame: ScreencastFrame) => void) | null = null;
|
let screencastFrameCallback: ((frame: ScreencastFrame) => void) | null = null;
|
||||||
@@ -486,6 +494,12 @@ export async function executeCommand(command: Command, browser: BrowserManager):
|
|||||||
return await handleRecordingStop(command, browser);
|
return await handleRecordingStop(command, browser);
|
||||||
case 'recording_restart':
|
case 'recording_restart':
|
||||||
return await handleRecordingRestart(command, browser);
|
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: {
|
default: {
|
||||||
// TypeScript narrows to never here, but we handle it for safety
|
// TypeScript narrows to never here, but we handle it for safety
|
||||||
const unknownCommand = command as { id: string; action: string };
|
const unknownCommand = command as { id: string; action: string };
|
||||||
@@ -2464,3 +2478,106 @@ async function handleRecordingRestart(
|
|||||||
stopped: result.stopped,
|
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;
|
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
|
* 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', () => {
|
describe('invalid commands', () => {
|
||||||
it('should reject unknown action', () => {
|
it('should reject unknown action', () => {
|
||||||
const result = parseCommand(cmd({ id: '1', action: 'unknown' }));
|
const result = parseCommand(cmd({ id: '1', action: 'unknown' }));
|
||||||
|
|||||||
@@ -740,6 +740,36 @@ const deviceListSchema = baseCommandSchema.extend({
|
|||||||
action: z.literal('device_list'),
|
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({
|
const pressSchema = baseCommandSchema.extend({
|
||||||
action: z.literal('press'),
|
action: z.literal('press'),
|
||||||
key: z.string().min(1),
|
key: z.string().min(1),
|
||||||
@@ -972,6 +1002,9 @@ const commandSchema = z.discriminatedUnion('action', [
|
|||||||
inputTouchSchema,
|
inputTouchSchema,
|
||||||
swipeSchema,
|
swipeSchema,
|
||||||
deviceListSchema,
|
deviceListSchema,
|
||||||
|
diffSnapshotSchema,
|
||||||
|
diffScreenshotSchema,
|
||||||
|
diffUrlSchema,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Parse result type
|
// Parse result type
|
||||||
|
|||||||
+57
-1
@@ -1014,7 +1014,40 @@ export type Command =
|
|||||||
| InputKeyboardCommand
|
| InputKeyboardCommand
|
||||||
| InputTouchCommand
|
| InputTouchCommand
|
||||||
| SwipeCommand
|
| 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
|
// Response types
|
||||||
export interface SuccessResponse<T = unknown> {
|
export interface SuccessResponse<T = unknown> {
|
||||||
@@ -1145,6 +1178,29 @@ export interface StylesData {
|
|||||||
elements: ElementStyleInfo[];
|
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
|
// Browser state
|
||||||
export interface BrowserState {
|
export interface BrowserState {
|
||||||
browser: Browser | null;
|
browser: Browser | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user