Skip to content

Commit 5887e64

Browse files
committed
feat: snapshot diff
1 parent 481f84d commit 5887e64

File tree

4 files changed

+214
-0
lines changed

4 files changed

+214
-0
lines changed

src/browser/daemon/protocol.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,21 @@ pub enum DaemonCommand {
282282
offline: bool,
283283
},
284284

285+
// ── Diff ──
286+
DiffSnapshot {
287+
baseline: Option<String>,
288+
selector: Option<String>,
289+
compact: bool,
290+
max_depth: Option<usize>,
291+
},
292+
DiffScreenshot {
293+
baseline: String,
294+
threshold: Option<f64>,
295+
selector: Option<String>,
296+
full_page: bool,
297+
output: Option<String>,
298+
},
299+
285300
Close,
286301
Ping,
287302
Shutdown,

src/browser/daemon/server.rs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -662,6 +662,35 @@ async fn dispatch_inner(
662662
Ok(json!({"offline": offline}))
663663
}
664664

665+
// ── Diff ──
666+
DaemonCommand::DiffSnapshot {
667+
baseline,
668+
selector,
669+
compact,
670+
max_depth,
671+
} => {
672+
let options = build_snapshot_options(false, selector, compact, max_depth, false);
673+
engine.diff_snapshot(baseline.as_deref(), options).await
674+
}
675+
DaemonCommand::DiffScreenshot {
676+
baseline,
677+
threshold,
678+
selector,
679+
full_page,
680+
output,
681+
} => {
682+
let options =
683+
build_screenshot_options(full_page, selector, None, None, false, None, None);
684+
engine
685+
.diff_screenshot(
686+
&baseline,
687+
threshold.unwrap_or(0.1),
688+
options,
689+
output.as_deref(),
690+
)
691+
.await
692+
}
693+
665694
DaemonCommand::Close | DaemonCommand::Shutdown => Ok(json!({"closed": true})),
666695
DaemonCommand::Ping => Ok(json!("pong")),
667696
DaemonCommand::GetSessionInfo => {

src/browser/engine.rs

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use anyhow::Result;
99

1010
use browser_engine::native::browser::{BrowserManager, WaitUntil};
1111
use browser_engine::native::cdp::client::CdpClient;
12+
use browser_engine::native::diff;
1213
use browser_engine::native::element::{self, RefMap};
1314
use browser_engine::native::interaction;
1415
use browser_engine::native::network;
@@ -900,6 +901,66 @@ impl BrowserEngine {
900901
.map_err(|e| anyhow::anyhow!(e))
901902
}
902903

904+
// ── Diff ─────────────────────────────────────────────────────────
905+
906+
/// Diff current snapshot against a baseline string or file path.
907+
pub async fn diff_snapshot(
908+
&mut self,
909+
baseline: Option<&str>,
910+
options: SnapshotOptions,
911+
) -> Result<serde_json::Value> {
912+
let current = self.snapshot(options).await?;
913+
let baseline_text = match baseline {
914+
Some(b) if std::path::Path::new(b).exists() => std::fs::read_to_string(b)
915+
.map_err(|e| anyhow::anyhow!("Failed to read baseline: {e}"))?,
916+
Some(b) => b.to_string(),
917+
None => String::new(),
918+
};
919+
let result = diff::diff_snapshots(&baseline_text, &current);
920+
Ok(serde_json::json!({
921+
"diff": result.diff,
922+
"additions": result.additions,
923+
"removals": result.removals,
924+
"unchanged": result.unchanged,
925+
"changed": result.changed,
926+
}))
927+
}
928+
929+
/// Diff current screenshot against a baseline image file.
930+
pub async fn diff_screenshot(
931+
&mut self,
932+
baseline_path: &str,
933+
threshold: f64,
934+
options: ScreenshotOptions,
935+
output_path: Option<&str>,
936+
) -> Result<serde_json::Value> {
937+
let result = self.take_screenshot(options).await?;
938+
939+
let current_bytes =
940+
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &result.base64)
941+
.map_err(|e| anyhow::anyhow!("Failed to decode screenshot: {e}"))?;
942+
943+
let baseline_bytes = std::fs::read(baseline_path)
944+
.map_err(|e| anyhow::anyhow!("Failed to read baseline: {e}"))?;
945+
946+
let diff_result = diff::diff_screenshot(&baseline_bytes, &current_bytes, threshold)
947+
.map_err(|e| anyhow::anyhow!(e))?;
948+
949+
if let (Some(out), Some(diff_data)) = (output_path, &diff_result.diff_image) {
950+
std::fs::write(out, diff_data)
951+
.map_err(|e| anyhow::anyhow!("Failed to write diff image: {e}"))?;
952+
}
953+
954+
Ok(serde_json::json!({
955+
"match": diff_result.matched,
956+
"mismatchPercentage": diff_result.mismatch_percentage,
957+
"totalPixels": diff_result.total_pixels,
958+
"differentPixels": diff_result.different_pixels,
959+
"diffPath": output_path,
960+
"dimensionMismatch": diff_result.dimension_mismatch,
961+
}))
962+
}
963+
903964
/// Set offline mode.
904965
pub async fn set_offline(&self, offline: bool) -> Result<()> {
905966
let (client, session_id) = self.active_client_and_session()?;

src/commands/browser/action.rs

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,13 @@ pub enum ActionCommand {
146146
#[command(name = "bringtofront")]
147147
BringToFront,
148148

149+
// --- Diff ---
150+
/// Compare snapshots or screenshots
151+
Diff {
152+
#[command(subcommand)]
153+
command: DiffCommand,
154+
},
155+
149156
// --- Session ---
150157
/// Close the browser session
151158
#[command(aliases = ["quit", "exit"])]
@@ -259,6 +266,51 @@ pub enum SetCommand {
259266
UserAgent(SetUserAgentArgs),
260267
}
261268

269+
// ── Diff subcommands ────────────────────────────────────────────────
270+
271+
#[derive(Subcommand)]
272+
pub enum DiffCommand {
273+
/// Compare current snapshot against a baseline
274+
Snapshot(DiffSnapshotArgs),
275+
/// Compare current screenshot against a baseline image
276+
Screenshot(DiffScreenshotArgs),
277+
}
278+
279+
#[derive(Parser)]
280+
pub struct DiffSnapshotArgs {
281+
/// Baseline snapshot text or file path
282+
#[arg(short, long)]
283+
pub baseline: Option<String>,
284+
/// Restrict snapshot to a subtree
285+
#[arg(short, long)]
286+
pub selector: Option<String>,
287+
/// Use compact output format
288+
#[arg(short, long)]
289+
pub compact: bool,
290+
/// Maximum nesting depth
291+
#[arg(short = 'd', long, alias = "depth")]
292+
pub max_depth: Option<usize>,
293+
}
294+
295+
#[derive(Parser)]
296+
pub struct DiffScreenshotArgs {
297+
/// Baseline image file path (required)
298+
#[arg(short, long)]
299+
pub baseline: String,
300+
/// Color difference threshold (0.0–1.0)
301+
#[arg(short, long)]
302+
pub threshold: Option<f64>,
303+
/// Save diff image to this path
304+
#[arg(short, long)]
305+
pub output: Option<String>,
306+
/// Restrict screenshot to an element
307+
#[arg(short, long)]
308+
pub selector: Option<String>,
309+
/// Capture the full scrollable page
310+
#[arg(long, alias = "full")]
311+
pub full_page: bool,
312+
}
313+
262314
// ── Arg structs ─────────────────────────────────────────────────────
263315

264316
#[derive(Parser)]
@@ -1123,6 +1175,63 @@ async fn dispatch_action(client: &mut DaemonClient, action: ActionCommand) -> Re
11231175
output::success(data, "");
11241176
}
11251177

1178+
// --- Diff ---
1179+
ActionCommand::Diff { command } => match command {
1180+
DiffCommand::Snapshot(args) => {
1181+
let data = client
1182+
.send(DaemonCommand::DiffSnapshot {
1183+
baseline: args.baseline,
1184+
selector: args.selector,
1185+
compact: args.compact,
1186+
max_depth: args.max_depth,
1187+
})
1188+
.await?;
1189+
if output::is_json() {
1190+
output::success_data(data);
1191+
} else {
1192+
let diff_text = data["diff"].as_str().unwrap_or("");
1193+
if diff_text.is_empty() {
1194+
println!("No changes.");
1195+
} else {
1196+
print!("{diff_text}");
1197+
}
1198+
}
1199+
}
1200+
DiffCommand::Screenshot(args) => {
1201+
let output_path = args
1202+
.output
1203+
.as_ref()
1204+
.map(|p| resolve_output_path(p))
1205+
.transpose()?;
1206+
let data = client
1207+
.send(DaemonCommand::DiffScreenshot {
1208+
baseline: resolve_output_path(&args.baseline)?,
1209+
threshold: args.threshold,
1210+
selector: args.selector,
1211+
full_page: args.full_page,
1212+
output: output_path,
1213+
})
1214+
.await?;
1215+
if output::is_json() {
1216+
output::success_data(data);
1217+
} else {
1218+
let matched = data["match"].as_bool().unwrap_or(false);
1219+
let pct = data["mismatchPercentage"]
1220+
.as_f64()
1221+
.map(|p| format!("{p:.2}%"))
1222+
.unwrap_or_default();
1223+
if matched {
1224+
println!("Screenshots match.");
1225+
} else {
1226+
println!("Mismatch: {pct}");
1227+
if let Some(p) = data["diffPath"].as_str() {
1228+
println!("Diff image: {p}");
1229+
}
1230+
}
1231+
}
1232+
}
1233+
},
1234+
11261235
// --- Session ---
11271236
ActionCommand::Close => {
11281237
let data = client.send(DaemonCommand::Close).await?;

0 commit comments

Comments
 (0)