diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 92b607b..3d28263 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -80,6 +80,9 @@ hex = "0.4" # YAML for Maestro flow generation serde_yaml = "0.9" +# PNG image codec (screenshot regression bank) +image = { version = "0.25", default-features = false, features = ["png"] } + # gRPC client for talking directly to the on-device Maestro driver # (bypasses the slow `maestro hierarchy` CLI invocation path when the # driver is kept alive by a background `maestro studio` process). diff --git a/src-tauri/src/bank/compare.rs b/src-tauri/src/bank/compare.rs new file mode 100644 index 0000000..13dd665 --- /dev/null +++ b/src-tauri/src/bank/compare.rs @@ -0,0 +1,269 @@ +// Copyright (c) 2026 Ethan Morisset +// SPDX-License-Identifier: BUSL-1.1 + +use std::fs; +use std::path::Path; + +use base64::Engine; + +use crate::bank::device_key; +use crate::bank::diff::diff_images; +use crate::bank::flow::screenshot_names; + +#[derive(serde::Serialize, Clone)] +#[serde(rename_all = "snake_case")] +pub enum Status { + Seeded, + Match, + Changed, + Missing, + DimensionMismatch, +} + +#[derive(serde::Serialize, Clone)] +pub struct Comparison { + pub name: String, + pub status: Status, + pub changed_ratio: f32, + pub bbox: Option<[u32; 4]>, + #[serde(skip_serializing_if = "Option::is_none")] + pub bank_b64: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub new_b64: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub diff_b64: Option, +} + +pub struct CompareInput<'a> { + pub workspace: &'a Path, + pub flow_path: &'a Path, + pub model: &'a str, + pub width: u32, + pub height: u32, + pub tolerance: f64, + pub threshold: f64, +} + +fn b64(bytes: &[u8]) -> String { + format!( + "data:image/png;base64,{}", + base64::engine::general_purpose::STANDARD.encode(bytes) + ) +} + +fn dims(png: &[u8]) -> Option<(u32, u32)> { + image::load_from_memory(png) + .ok() + .map(|i| (i.width(), i.height())) +} + +pub fn compare_flow(input: CompareInput) -> std::io::Result<(String, Vec)> { + let key = device_key(input.model, input.width, input.height); + let bank_dir = input.workspace.join("maestro").join("bank").join(&key); + fs::create_dir_all(&bank_dir)?; + + let flow_dir = input.flow_path.parent().unwrap_or(Path::new(".")); + let yaml = fs::read_to_string(input.flow_path).unwrap_or_default(); + let names = screenshot_names(&yaml); + + let mut comps = Vec::new(); + for name in names { + let produced = flow_dir.join(format!("{name}.png")); + let reference = bank_dir.join(format!("{name}.png")); + + if !produced.exists() { + comps.push(Comparison { + name, + status: Status::Missing, + changed_ratio: 0.0, + bbox: None, + bank_b64: None, + new_b64: None, + diff_b64: None, + }); + continue; + } + let new_bytes = fs::read(&produced)?; + + if !reference.exists() { + fs::copy(&produced, &reference)?; + comps.push(Comparison { + name, + status: Status::Seeded, + changed_ratio: 0.0, + bbox: None, + bank_b64: None, + new_b64: None, + diff_b64: None, + }); + continue; + } + let bank_bytes = fs::read(&reference)?; + + if dims(&bank_bytes) != dims(&new_bytes) { + comps.push(Comparison { + name, + status: Status::DimensionMismatch, + changed_ratio: 0.0, + bbox: None, + bank_b64: Some(b64(&bank_bytes)), + new_b64: Some(b64(&new_bytes)), + diff_b64: None, + }); + continue; + } + + match diff_images(&bank_bytes, &new_bytes, input.tolerance) { + Ok(out) if out.changed_ratio as f64 > input.threshold => comps.push(Comparison { + name, + status: Status::Changed, + changed_ratio: out.changed_ratio, + bbox: out.bbox, + bank_b64: Some(b64(&bank_bytes)), + new_b64: Some(b64(&new_bytes)), + diff_b64: Some(b64(&out.diff_png)), + }), + Ok(out) => comps.push(Comparison { + name, + status: Status::Match, + changed_ratio: out.changed_ratio, + bbox: None, + bank_b64: None, + new_b64: None, + diff_b64: None, + }), + Err(_) => comps.push(Comparison { + name, + status: Status::Missing, + changed_ratio: 0.0, + bbox: None, + bank_b64: None, + new_b64: None, + diff_b64: None, + }), + } + } + Ok((key, comps)) +} + +#[cfg(test)] +mod tests { + use super::*; + use image::{ImageEncoder, RgbaImage}; + use std::fs; + + fn write_png(path: &Path, img: &RgbaImage) { + let mut buf = Vec::new(); + image::codecs::png::PngEncoder::new(&mut buf) + .write_image( + img.as_raw(), + img.width(), + img.height(), + image::ExtendedColorType::Rgba8, + ) + .unwrap(); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, buf).unwrap(); + } + + fn temp_dir(tag: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("mdbank_{tag}")); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn seeds_when_bank_empty_then_matches_next_run() { + let ws = temp_dir("seed"); + let flow_dir = ws.join("flows"); + let flow_path = flow_dir.join("f.yaml"); + fs::create_dir_all(&flow_dir).unwrap(); + fs::write(&flow_path, "- takeScreenshot: home\n").unwrap(); + write_png( + &flow_dir.join("home.png"), + &RgbaImage::from_pixel(2, 2, image::Rgba([1, 2, 3, 255])), + ); + + let input = CompareInput { + workspace: &ws, + flow_path: &flow_path, + model: "Dev", + width: 2, + height: 2, + tolerance: 0.1, + threshold: 0.001, + }; + let (key, comps) = compare_flow(input).unwrap(); + assert_eq!(comps.len(), 1); + assert!(matches!(comps[0].status, Status::Seeded)); + // la référence existe maintenant + assert!(ws.join("maestro/bank").join(&key).join("home.png").exists()); + + // 2e run identique → Match + let input2 = CompareInput { + workspace: &ws, + flow_path: &flow_path, + model: "Dev", + width: 2, + height: 2, + tolerance: 0.1, + threshold: 0.001, + }; + let (_, comps2) = compare_flow(input2).unwrap(); + assert!(matches!(comps2[0].status, Status::Match)); + } + + #[test] + fn flags_changed_pixels() { + let ws = temp_dir("changed"); + let flow_dir = ws.join("flows"); + let flow_path = flow_dir.join("f.yaml"); + fs::create_dir_all(&flow_dir).unwrap(); + fs::write(&flow_path, "- takeScreenshot: home\n").unwrap(); + let key = device_key("Dev", 4, 4); + // référence noire + write_png( + &ws.join("maestro/bank").join(&key).join("home.png"), + &RgbaImage::from_pixel(4, 4, image::Rgba([0, 0, 0, 255])), + ); + // produit avec un coin blanc + let mut produced = RgbaImage::from_pixel(4, 4, image::Rgba([0, 0, 0, 255])); + produced.put_pixel(0, 0, image::Rgba([255, 255, 255, 255])); + write_png(&flow_dir.join("home.png"), &produced); + + let (_, comps) = compare_flow(CompareInput { + workspace: &ws, + flow_path: &flow_path, + model: "Dev", + width: 4, + height: 4, + tolerance: 0.1, + threshold: 0.001, + }) + .unwrap(); + assert!(matches!(comps[0].status, Status::Changed)); + assert!(comps[0].diff_b64.is_some()); + assert_eq!(comps[0].bbox, Some([0, 0, 1, 1])); + } + + #[test] + fn missing_when_no_produced_file() { + let ws = temp_dir("missing"); + let flow_dir = ws.join("flows"); + let flow_path = flow_dir.join("f.yaml"); + fs::create_dir_all(&flow_dir).unwrap(); + fs::write(&flow_path, "- takeScreenshot: home\n").unwrap(); + let (_, comps) = compare_flow(CompareInput { + workspace: &ws, + flow_path: &flow_path, + model: "Dev", + width: 2, + height: 2, + tolerance: 0.1, + threshold: 0.001, + }) + .unwrap(); + assert!(matches!(comps[0].status, Status::Missing)); + } +} diff --git a/src-tauri/src/bank/diff.rs b/src-tauri/src/bank/diff.rs new file mode 100644 index 0000000..76ae831 --- /dev/null +++ b/src-tauri/src/bank/diff.rs @@ -0,0 +1,119 @@ +// Copyright (c) 2026 Ethan Morisset +// SPDX-License-Identifier: BUSL-1.1 + +use image::ImageEncoder; + +pub struct DiffOutcome { + pub changed_ratio: f32, + pub bbox: Option<[u32; 4]>, + pub diff_png: Vec, +} + +pub fn diff_images( + bank_png: &[u8], + new_png: &[u8], + tolerance: f64, +) -> Result { + let bank = image::load_from_memory(bank_png)?.to_rgba8(); + let mut new = image::load_from_memory(new_png)?.to_rgba8(); + let (w, h) = (new.width(), new.height()); + + // Seuil pixelmatch : delta max possible (noir↔blanc) = 35215. + let max_delta = 35215.0 * tolerance * tolerance; + + let (mut min_x, mut min_y, mut max_x, mut max_y) = (u32::MAX, u32::MAX, 0u32, 0u32); + let mut changed = 0u64; + + for y in 0..h { + for x in 0..w { + let a = bank.get_pixel(x, y).0; + let b = new.get_pixel(x, y).0; + if color_delta(a, b) > max_delta { + changed += 1; + min_x = min_x.min(x); + min_y = min_y.min(y); + max_x = max_x.max(x); + max_y = max_y.max(y); + new.put_pixel(x, y, image::Rgba([255, 0, 0, 255])); + } + } + } + + let total = (w as u64) * (h as u64); + let changed_ratio = if total == 0 { + 0.0 + } else { + changed as f32 / total as f32 + }; + let bbox = if changed == 0 { + None + } else { + Some([min_x, min_y, max_x - min_x + 1, max_y - min_y + 1]) + }; + + let mut diff_png = Vec::new(); + image::codecs::png::PngEncoder::new(&mut diff_png).write_image( + new.as_raw(), + w, + h, + image::ExtendedColorType::Rgba8, + )?; + + Ok(DiffOutcome { + changed_ratio, + bbox, + diff_png, + }) +} + +fn color_delta(a: [u8; 4], b: [u8; 4]) -> f64 { + let (ay, ai, aq) = yiq(a); + let (by, bi, bq) = yiq(b); + let (dy, di, dq) = (ay - by, ai - bi, aq - bq); + 0.5053 * dy * dy + 0.299 * di * di + 0.1957 * dq * dq +} + +fn yiq(p: [u8; 4]) -> (f64, f64, f64) { + let (r, g, b) = (p[0] as f64, p[1] as f64, p[2] as f64); + let y = r * 0.29889531 + g * 0.58662247 + b * 0.11448223; + let i = r * 0.59597799 - g * 0.27417610 - b * 0.32180189; + let q = r * 0.21147017 - g * 0.52261711 + b * 0.31114694; + (y, i, q) +} + +#[cfg(test)] +mod tests { + use super::*; + use image::RgbaImage; + + fn png_bytes(img: &RgbaImage) -> Vec { + let mut buf = Vec::new(); + image::codecs::png::PngEncoder::new(&mut buf) + .write_image( + img.as_raw(), + img.width(), + img.height(), + image::ExtendedColorType::Rgba8, + ) + .unwrap(); + buf + } + + #[test] + fn identical_images_have_zero_ratio() { + let img = RgbaImage::from_pixel(4, 4, image::Rgba([10, 20, 30, 255])); + let out = diff_images(&png_bytes(&img), &png_bytes(&img), 0.1).unwrap(); + assert_eq!(out.changed_ratio, 0.0); + assert!(out.bbox.is_none()); + } + + #[test] + fn one_changed_pixel_is_detected_with_bbox() { + let bank = RgbaImage::from_pixel(4, 4, image::Rgba([0, 0, 0, 255])); + let mut new = bank.clone(); + new.put_pixel(2, 1, image::Rgba([255, 255, 255, 255])); // blanc vs noir + let out = diff_images(&png_bytes(&bank), &png_bytes(&new), 0.1).unwrap(); + assert!(out.changed_ratio > 0.0); + assert_eq!(out.bbox, Some([2, 1, 1, 1])); + } +} diff --git a/src-tauri/src/bank/flow.rs b/src-tauri/src/bank/flow.rs new file mode 100644 index 0000000..ed20c57 --- /dev/null +++ b/src-tauri/src/bank/flow.rs @@ -0,0 +1,56 @@ +// Copyright (c) 2026 Ethan Morisset +// SPDX-License-Identifier: BUSL-1.1 + +/// Extrait les noms des commandes `takeScreenshot` d'un flow Maestro, dans l'ordre. +pub fn screenshot_names(flow_yaml: &str) -> Vec { + let mut names = Vec::new(); + let mut lines = flow_yaml.lines().peekable(); + while let Some(raw) = lines.next() { + let line = raw.trim_start_matches('-').trim(); + let Some(rest) = line.strip_prefix("takeScreenshot:") else { + continue; + }; + let inline = rest.trim(); + if !inline.is_empty() { + // Forme courte: `takeScreenshot: name` + names.push(unquote(inline)); + } else if let Some(next) = lines.peek() { + // Forme objet: `path: name` sur la ligne suivante + if let Some(path) = next.trim().strip_prefix("path:") { + names.push(unquote(path.trim())); + } + } + } + names +} + +fn unquote(s: &str) -> String { + s.trim_matches(|c| c == '"' || c == '\'').to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extracts_short_and_object_forms() { + let yaml = r#" +appId: com.example +--- +- launchApp +- takeScreenshot: login +- tapOn: "Next" +- takeScreenshot: + path: home +"#; + assert_eq!( + screenshot_names(yaml), + vec!["login".to_string(), "home".to_string()] + ); + } + + #[test] + fn returns_empty_when_none() { + assert_eq!(screenshot_names("- launchApp\n").len(), 0); + } +} diff --git a/src-tauri/src/bank/ipc.rs b/src-tauri/src/bank/ipc.rs new file mode 100644 index 0000000..5ad3bc5 --- /dev/null +++ b/src-tauri/src/bank/ipc.rs @@ -0,0 +1,233 @@ +// Copyright (c) 2026 Ethan Morisset +// SPDX-License-Identifier: BUSL-1.1 + +use std::fs; +use std::path::Path; +use std::time::SystemTime; + +use serde::Serialize; + +use crate::bank::compare::{compare_flow, CompareInput, Comparison}; + +#[derive(Serialize, Clone)] +pub struct RunReport { + pub run_id: String, + pub device_key: String, + pub comparisons: Vec, +} + +/// Ensures `/.gitignore` exists and contains `.runs/`. +/// If the file does not exist it is created with `.runs/\n`. +/// If it already exists it is left untouched. +fn ensure_runs_gitignore(maestro_dir: &Path) -> std::io::Result<()> { + let gi = maestro_dir.join(".gitignore"); + if !gi.exists() { + fs::write(&gi, ".runs/\n")?; + } + Ok(()) +} + +/// Keeps only the most recent `keep` subdirectories of `runs_dir` by +/// last-modified time, removing older ones. Best-effort: errors on individual +/// entries are ignored. +fn prune_runs(runs_dir: &Path, keep: usize) -> std::io::Result<()> { + let mut entries: Vec<(SystemTime, std::path::PathBuf)> = fs::read_dir(runs_dir)? + .filter_map(|e| e.ok()) + .filter(|e| e.path().is_dir()) + .filter_map(|e| { + let mtime = e.metadata().ok()?.modified().ok()?; + Some((mtime, e.path())) + }) + .collect(); + + if entries.len() <= keep { + return Ok(()); + } + + // Sort ascending (oldest first) so we remove from the front. + entries.sort_by_key(|(t, _)| *t); + let to_remove = entries.len() - keep; + for (_, path) in entries.into_iter().take(to_remove) { + let _ = fs::remove_dir_all(&path); + } + Ok(()) +} + +/// Remplace l'image de banque `/maestro/bank//.png` +/// par la nouvelle capture stockée dans `/maestro/.runs//.png`. +/// (La nouvelle capture est copiée dans le dossier de run par `compare_screenshots`.) +pub fn replace_bank_image( + workspace: &Path, + run_id: &str, + device_key: &str, + name: &str, +) -> std::io::Result<()> { + let src = workspace + .join("maestro") + .join(".runs") + .join(run_id) + .join(format!("{name}.png")); + let dst = workspace + .join("maestro") + .join("bank") + .join(device_key) + .join(format!("{name}.png")); + if let Some(parent) = dst.parent() { + fs::create_dir_all(parent)?; + } + fs::copy(src, dst)?; + Ok(()) +} + +#[tauri::command] +pub async fn compare_screenshots( + workspace: String, + flow_path: String, + model: String, + width: u32, + height: u32, + tolerance: f64, + threshold: f64, + run_id: String, +) -> Result { + let ws = std::path::PathBuf::from(&workspace); + let flow = std::path::PathBuf::from(&flow_path); + let flow_dir = flow.parent().map(|p| p.to_path_buf()).unwrap_or_default(); + + // Copier les PNG produits dans le dossier de run (source stable pour `replace`). + let maestro_dir = ws.join("maestro"); + let _ = ensure_runs_gitignore(&maestro_dir); + let run_dir = maestro_dir.join(".runs").join(&run_id); + fs::create_dir_all(&run_dir).map_err(|e| e.to_string())?; + let _ = prune_runs(&maestro_dir.join(".runs"), 10); + let yaml = fs::read_to_string(&flow).unwrap_or_default(); + for name in crate::bank::flow::screenshot_names(&yaml) { + let produced = flow_dir.join(format!("{name}.png")); + if produced.exists() { + let _ = fs::copy(&produced, run_dir.join(format!("{name}.png"))); + } + } + + let (device_key, comparisons) = compare_flow(CompareInput { + workspace: &ws, + flow_path: &flow, + model: &model, + width, + height, + tolerance, + threshold, + }) + .map_err(|e| e.to_string())?; + + // report.json slim (statuts seulement, sans base64). + let slim: Vec<_> = comparisons + .iter() + .map(|c| serde_json::json!({ "name": c.name, "status": c.status, "changed_ratio": c.changed_ratio })) + .collect(); + let report = + serde_json::json!({ "run_id": run_id, "device_key": device_key, "comparisons": slim }); + let _ = fs::write( + run_dir.join("report.json"), + serde_json::to_vec_pretty(&report).unwrap_or_default(), + ); + + Ok(RunReport { + run_id, + device_key, + comparisons, + }) +} + +#[tauri::command] +pub async fn resolve_comparison( + workspace: String, + run_id: String, + device_key: String, + name: String, + decision: String, +) -> Result<(), String> { + if decision == "replace" { + // "replace": la nouvelle capture (copiée dans le dossier de run) devient la vérité. + replace_bank_image(Path::new(&workspace), &run_id, &device_key, &name) + .map_err(|e| e.to_string())?; + } + // "keep": régression confirmée, banque inchangée (déjà tracée dans report.json). + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ensure_runs_gitignore_creates_when_absent() { + let dir = std::env::temp_dir().join("mdbank_gi_test_absent"); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + ensure_runs_gitignore(&dir).unwrap(); + let contents = fs::read_to_string(dir.join(".gitignore")).unwrap(); + assert!(contents.contains(".runs/"), "should contain .runs/"); + } + + #[test] + fn ensure_runs_gitignore_leaves_existing_untouched() { + let dir = std::env::temp_dir().join("mdbank_gi_test_existing"); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join(".gitignore"), "custom content\n").unwrap(); + ensure_runs_gitignore(&dir).unwrap(); + let contents = fs::read_to_string(dir.join(".gitignore")).unwrap(); + assert_eq!( + contents, "custom content\n", + "existing file must not be modified" + ); + } + + #[test] + fn prune_runs_keeps_newest_dirs() { + let dir = std::env::temp_dir().join("mdbank_prune_test"); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + + // Create 15 dirs in sequence; last-created will have newest mtime. + let keep = 10_usize; + let total = 15_usize; + for i in 0..total { + let sub = dir.join(format!("run_{:02}", i)); + fs::create_dir_all(&sub).unwrap(); + // Touch a file inside so mtime differs between iterations + // (directory mtime is set when we create a child on most OSes). + fs::write(sub.join("marker"), format!("{i}")).unwrap(); + } + + prune_runs(&dir, keep).unwrap(); + + let remaining: Vec<_> = fs::read_dir(&dir) + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| e.path().is_dir()) + .collect(); + assert_eq!(remaining.len(), keep, "should keep exactly {keep} dirs"); + + // The last-created dir (run_14) must still be present. + assert!( + dir.join("run_14").exists(), + "newest dir run_14 must survive" + ); + } + + #[test] + fn replace_overwrites_bank_with_run_image() { + let ws = std::env::temp_dir().join("mdbank_replace"); + let _ = fs::remove_dir_all(&ws); + let bank = ws.join("maestro/bank/Dev_2x2"); + let run = ws.join("maestro/.runs/r1"); + fs::create_dir_all(&bank).unwrap(); + fs::create_dir_all(&run).unwrap(); + fs::write(bank.join("home.png"), b"OLD").unwrap(); + fs::write(run.join("home.png"), b"NEW").unwrap(); + + replace_bank_image(&ws, "r1", "Dev_2x2", "home").unwrap(); + assert_eq!(fs::read(bank.join("home.png")).unwrap(), b"NEW"); + } +} diff --git a/src-tauri/src/bank/mod.rs b/src-tauri/src/bank/mod.rs new file mode 100644 index 0000000..1e052ea --- /dev/null +++ b/src-tauri/src/bank/mod.rs @@ -0,0 +1,29 @@ +// Copyright (c) 2026 Ethan Morisset +// SPDX-License-Identifier: BUSL-1.1 + +pub mod compare; +pub mod diff; +pub mod flow; +pub mod ipc; + +pub fn device_key(model: &str, width: u32, height: u32) -> String { + let sanitized: String = model + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) + .collect(); + format!("{sanitized}_{width}x{height}") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn device_key_sanitizes_and_appends_resolution() { + assert_eq!( + device_key("iPhone 15 Pro", 1179, 2556), + "iPhone_15_Pro_1179x2556" + ); + assert_eq!(device_key("Pixel/6", 1080, 2400), "Pixel_6_1080x2400"); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 95907f1..1fe15c3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,6 +5,7 @@ #[cfg(target_os = "macos")] pub mod avf_capture; +pub mod bank; pub mod credentials; pub mod device; mod env_shim; @@ -87,6 +88,8 @@ pub fn run() { get_dark_mode, run_flow, stop_flow, + bank::ipc::compare_screenshots, + bank::ipc::resolve_comparison, list_workspace, start_metrics, stop_metrics, diff --git a/src-tauri/src/runner/mod.rs b/src-tauri/src/runner/mod.rs index f8c30c2..2008945 100644 --- a/src-tauri/src/runner/mod.rs +++ b/src-tauri/src/runner/mod.rs @@ -81,6 +81,10 @@ pub async fn spawn_runner( let bin = maestro_bin(); info!(bin = %bin, serial, flow = %flow_path, "spawning maestro"); let env_args = app_id_env_args(app_id); + let flow_dir = std::path::Path::new(flow_path) + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| std::path::PathBuf::from(".")); // Maestro 2.5.x's session manager calls `dadb.Dadb.list()` which // walks every adb-server transport before honoring `--udid` — any @@ -128,6 +132,7 @@ pub async fn spawn_runner( .args(["--udid", serial, "test"]) .args(&env_args) .arg(flow_path) + .current_dir(&flow_dir) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .kill_on_drop(true) @@ -208,6 +213,10 @@ pub async fn spawn_web_runner( let bin = maestro_bin(); info!(bin = %bin, flow = %flow_path, "spawning maestro (web)"); let env_args = app_id_env_args(app_id); + let flow_dir = std::path::Path::new(flow_path) + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| std::path::PathBuf::from(".")); let mut child = Command::new(&bin) .no_window() @@ -215,6 +224,7 @@ pub async fn spawn_web_runner( .args(["-p", "web", "test"]) .args(&env_args) .arg(flow_path) + .current_dir(&flow_dir) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .kill_on_drop(true) @@ -287,12 +297,17 @@ pub async fn spawn_ios_runner( let bin = maestro_bin(); info!(bin = %bin, udid, flow = %flow_path, "spawning maestro (ios)"); let env_args = app_id_env_args(app_id); + let flow_dir = std::path::Path::new(flow_path) + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| std::path::PathBuf::from(".")); let mut child = Command::new(&bin) .no_window() .args(["--udid", udid, "test"]) .args(&env_args) .arg(flow_path) + .current_dir(&flow_dir) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .kill_on_drop(true) @@ -422,12 +437,17 @@ pub async fn spawn_ios_device_runner( let port_str = port.to_string(); info!(bin = %bin, udid, port, flow = %flow_path, "spawning maestro (ios physical)"); let env_args = app_id_env_args(app_id); + let flow_dir = std::path::Path::new(flow_path) + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| std::path::PathBuf::from(".")); let mut child = Command::new(&bin) .no_window() .args(["--driver-host-port", &port_str, "--device", udid, "test"]) .args(&env_args) .arg(flow_path) + .current_dir(&flow_dir) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .kill_on_drop(true) diff --git a/src/App.tsx b/src/App.tsx index eaea44d..e827f52 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,6 +16,8 @@ import { setShortcutsSuppressed } from "@/lib/keyboard"; import { parseLine as parseRunLine } from "@/lib/runStepParser"; import { applyTheme, watchSystemTheme } from "@/lib/theme"; import { useDeviceStore } from "@/stores/deviceStore"; +import { useReviewStore } from "@/stores/reviewStore"; +import { effectiveThresholds, useVisualRegressionStore } from "@/stores/visualRegressionStore"; import { useInspectorStore } from "@/stores/inspectorStore"; import { useMetricsStore } from "@/stores/metricsStore"; import { usePanelsStore } from "@/stores/panelsStore"; @@ -130,6 +132,7 @@ export default function App() { events.onRunnerStderr((line) => appendLog("stderr", line)), events.onRunnerExit(({ code }) => { const wasStopped = useRunStore.getState().stopRequested; + const exitedPid = useRunStore.getState().pid; appendLog( "system", wasStopped ? "[runner stopped by user]" : `[runner exited with code ${code}]`, @@ -138,6 +141,41 @@ export default function App() { if (wasStopped) toast.success("Flow stopped"); else if (code === 0) toast.success("Flow completed"); else toast.error("Flow failed", `exit code ${code}`); + if (code === 0 && !wasStopped && useVisualRegressionStore.getState().enabled) { + const target = useRunStore.getState().runTarget; + const ws = useWorkspaceStore.getState().folderPath; + const device = useDeviceStore.getState().current; + if (target?.kind === "all") { + appendLog( + "system", + "[bank] comparaison de banque ignorée pour Run All (non supporté dans cette version)", + ); + } else if (target?.kind === "flow" && ws && device) { + const { tolerance, threshold } = effectiveThresholds(); + const runId = String(exitedPid ?? Date.now()); + void ipc + .compareScreenshots({ + workspace: ws, + flowPath: target.path, + model: device.model, + width: device.screen_width, + height: device.screen_height, + tolerance, + threshold, + runId, + }) + .then((report) => { + useReviewStore.getState().setReport(report); + const seeded = report.comparisons.filter((c) => c.status === "seeded").length; + const missing = report.comparisons.filter((c) => c.status === "missing").length; + if (seeded > 0) + appendLog("system", `[bank] ${seeded} reference screenshot(s) created`); + if (missing > 0) + appendLog("system", `[bank] ${missing} expected screenshot(s) missing`); + }) + .catch((err) => appendLog("system", `[bank] échec comparaison: ${String(err)}`)); + } + } }), events.onDeviceDisconnected(() => markDisconnected()), events.onMetricsSample((p) => diff --git a/src/components/MainView.tsx b/src/components/MainView.tsx index 7b7c984..ad9e155 100644 --- a/src/components/MainView.tsx +++ b/src/components/MainView.tsx @@ -14,6 +14,7 @@ const MetricsPanel = lazy(() => ); import { PanelShell } from "@/components/PanelShell"; import { RunConsole } from "@/components/RunConsole"; +import { ScreenshotReview } from "@/components/ScreenshotReview"; import { Toolbar } from "@/components/Toolbar"; import { WorkspaceTree } from "@/components/WorkspaceTree"; import { ChatPanel } from "@/components/chat/ChatPanel"; @@ -91,6 +92,7 @@ export function MainView() { } resetSteps(); initSteps(parseFlow(content).steps); + useRunStore.getState().setRunTarget({ path, kind: "flow" }); const pid = await ipc.runFlow(path, useSettingsStore.getState().appId); setRunning(pid); appendLog("system", `[runner started pid ${pid} · ${path}]`); @@ -116,6 +118,7 @@ export function MainView() { const { content: c2 } = useFlowStore.getState(); resetSteps(); initSteps(parseFlow(c2).steps); + useRunStore.getState().setRunTarget({ path: folder, kind: "all" }); const pid = await ipc.runFlow(folder, useSettingsStore.getState().appId); setRunning(pid); appendLog("system", `[runner started pid ${pid} · all flows in ${folder}]`); @@ -144,6 +147,7 @@ export function MainView() { })); resetSteps(); initSteps(remappedSteps); + useRunStore.getState().setRunTarget({ path: tempPath, kind: "flow" }); const pid = await ipc.runFlow(tempPath, useSettingsStore.getState().appId); setRunning(pid); appendLog( @@ -184,157 +188,160 @@ export function MainView() { useShortcuts(shortcuts); return ( -
- void onRun()} - onRunAll={() => void onRunAll()} - onStop={() => void onStop()} - /> -
- - {panels.workspace ? ( - <> - - - - - - - - ) : null} + <> +
+ void onRun()} + onRunAll={() => void onRunAll()} + onStop={() => void onStop()} + /> +
+ + {panels.workspace ? ( + <> + + + + + + + + ) : null} - {panels.inspector ? ( - <> - - - -
- -
-
-
- - - ) : null} + {panels.inspector ? ( + <> + + + +
+ +
+
+
+ + + ) : null} - - - - - {streamEnabled && panels.device ? ( - <> + + + + + {streamEnabled && panels.device ? ( + <> + + {panels.editor ? : null} + + ) : null} + + {panels.editor ? ( - {panels.editor ? : null} - - ) : null} + ) : null} + + - {panels.editor ? ( + {panels.console || panels.metrics ? ( + <> + - - - - - ) : null} - - - - {panels.console || panels.metrics ? ( - <> - - - - {panels.console ? ( - - - void onRun()} onStop={() => void onStop()} /> - - - ) : null} - - {panels.metrics ? ( - <> - {panels.console ? ( - - ) : null} + + {panels.console ? ( - - - - + + void onRun()} onStop={() => void onStop()} /> - - ) : null} - - - - ) : null} - - + ) : null} + + {panels.metrics ? ( + <> + {panels.console ? ( + + ) : null} + + + + + + + + + ) : null} + + + + ) : null} +
+ - {chatOpen ? ( - <> - - - - - - ) : null} - + {chatOpen ? ( + <> + + + + + + ) : null} + +
-
+ + ); } diff --git a/src/components/ScreenshotReview.tsx b/src/components/ScreenshotReview.tsx new file mode 100644 index 0000000..7e255f9 --- /dev/null +++ b/src/components/ScreenshotReview.tsx @@ -0,0 +1,230 @@ +// Copyright (c) 2026 Ethan Morisset +// SPDX-License-Identifier: BUSL-1.1 + +import { useState } from "react"; +import type { ReactNode } from "react"; + +import { Check, Eye, EyeOff, ImageOff, RotateCcw, TriangleAlert } from "lucide-react"; + +import { Button } from "@/components/ui/Button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/Dialog"; +import { ipc } from "@/lib/ipc"; +import { cn } from "@/lib/utils"; +import { useReviewStore } from "@/stores/reviewStore"; +import { toast } from "@/stores/toastStore"; +import { useWorkspaceStore } from "@/stores/workspaceStore"; + +/** Neutral checkerboard so transparent PNGs and white captures both read + * clearly against the dialog surface. */ +const CHECKERBOARD = + "[background-image:linear-gradient(45deg,hsl(var(--muted))_25%,transparent_25%),linear-gradient(-45deg,hsl(var(--muted))_25%,transparent_25%),linear-gradient(45deg,transparent_75%,hsl(var(--muted))_75%),linear-gradient(-45deg,transparent_75%,hsl(var(--muted))_75%)] [background-position:0_0,0_8px,8px_-8px,-8px_0] [background-size:16px_16px]"; + +function ImageFrame({ + src, + alt, + accent, +}: { + src?: string; + alt: string; + accent: "neutral" | "warning"; +}) { + return ( +
+ {src ? ( + {alt} + ) : ( +
+ + No reference +
+ )} +
+ ); +} + +function PanelLabel({ + dot, + title, + hint, + trailing, +}: { + dot: string; + title: string; + hint: string; + trailing?: ReactNode; +}) { + return ( +
+
+ + {title} + {hint} +
+ {trailing} +
+ ); +} + +export function ScreenshotReview() { + const open = useReviewStore((s) => s.open); + const report = useReviewStore((s) => s.report); + const queue = useReviewStore((s) => s.queue); + const next = useReviewStore((s) => s.next); + const close = useReviewStore((s) => s.close); + const [showDiff, setShowDiff] = useState(true); + const [pending, setPending] = useState(false); + + if (!open || !report || queue.length === 0) return null; + + const name = queue[0]; + const comp = report.comparisons.find((c) => c.name === name); + if (!comp) return null; + + const workspace = useWorkspaceStore.getState().folderPath ?? ""; + + const reviewable = report.comparisons.filter( + (c) => c.status === "changed" || c.status === "dimension_mismatch", + ).length; + const position = reviewable - queue.length + 1; + + const isDimMismatch = comp.status === "dimension_mismatch"; + const hasDiff = Boolean(comp.diff_b64); + const overlayOn = showDiff && hasDiff; + const changedPct = (comp.changed_ratio * 100).toFixed(2); + const bbox = comp.bbox; + + const decide = async (decision: "keep" | "replace") => { + if (pending) return; + setPending(true); + try { + await ipc.resolveComparison({ + workspace, + runId: report.run_id, + deviceKey: report.device_key, + name, + decision, + }); + setShowDiff(true); + next(); + } catch (err) { + toast.error("Could not update bank", err instanceof Error ? err.message : String(err)); + } finally { + setPending(false); + } + }; + + return ( + { + if (!v) close(); + }} + > + + +
+ + Visual regression + + {name} + + + + {position} of {reviewable} + +
+ + {isDimMismatch ? ( + + + Dimensions differ from the baseline + + ) : ( + + + {changedPct}% of pixels changed + + )} + {bbox && ( + + changed region {bbox[2]}×{bbox[3]} at ({bbox[0]}, {bbox[1]}) + + )} + +
+ +
+
+ + +
+
+ setShowDiff((v) => !v)} + className="inline-flex items-center gap-1.5 rounded border border-border px-2 py-0.5 text-[11px] text-muted-foreground transition-colors hover:bg-muted" + > + {showDiff ? ( + <> + Hide diff + + ) : ( + <> + Show diff + + )} + + ) : undefined + } + /> + +
+
+ +
+

+ Keep marks this as a regression and leaves the bank untouched. Replace makes this + capture the new baseline. +

+
+ + +
+
+
+
+ ); +} diff --git a/src/components/settings/SettingsPage.test.ts b/src/components/settings/SettingsPage.test.ts index 5417448..ec40f59 100644 --- a/src/components/settings/SettingsPage.test.ts +++ b/src/components/settings/SettingsPage.test.ts @@ -27,6 +27,7 @@ describe("resolveSection", () => { "tools", "ai", "billy", + "visual-regression", "about", ]); }); diff --git a/src/components/settings/VisualRegressionSettings.tsx b/src/components/settings/VisualRegressionSettings.tsx new file mode 100644 index 0000000..f4755e8 --- /dev/null +++ b/src/components/settings/VisualRegressionSettings.tsx @@ -0,0 +1,104 @@ +// Copyright (c) 2026 Ethan Morisset +// SPDX-License-Identifier: BUSL-1.1 + +import { Button } from "@/components/ui/Button"; +import { SettingsSection, ToggleRow } from "@/components/settings/SettingsPrimitives"; +import { + useVisualRegressionStore, + DEFAULT_TOLERANCE, + DEFAULT_THRESHOLD, +} from "@/stores/visualRegressionStore"; + +function ThresholdField({ + label, + hint, + step, + value, + fallback, + disabled, + onChange, +}: { + label: string; + hint: string; + step: string; + value: number | null; + fallback: number; + disabled?: boolean; + onChange: (v: number | null) => void; +}) { + const isDefault = value === null; + return ( + + ); +} + +export function VisualRegressionSettings() { + const enabled = useVisualRegressionStore((s) => s.enabled); + const tolerance = useVisualRegressionStore((s) => s.tolerance); + const threshold = useVisualRegressionStore((s) => s.threshold); + const setEnabled = useVisualRegressionStore((s) => s.setEnabled); + const setTolerance = useVisualRegressionStore((s) => s.setTolerance); + const setThreshold = useVisualRegressionStore((s) => s.setThreshold); + const reset = useVisualRegressionStore((s) => s.reset); + + const isCustomized = tolerance !== null || threshold !== null; + + return ( + +
+ + + +
+ +
+
+
+ ); +} diff --git a/src/components/settings/sections.tsx b/src/components/settings/sections.tsx index 00206ae..8a8b9ee 100644 --- a/src/components/settings/sections.tsx +++ b/src/components/settings/sections.tsx @@ -7,6 +7,7 @@ import { AiSettings } from "@/components/AiSettings"; import { ToolPathsSettings } from "@/components/ToolPathsSettings"; import { AboutSettings } from "@/components/settings/AboutSettings"; import { BillySettings } from "@/components/settings/BillySettings"; +import { VisualRegressionSettings } from "@/components/settings/VisualRegressionSettings"; import { DevicePerformanceSettings } from "@/components/settings/DevicePerformanceSettings"; import { GeneralSettings } from "@/components/settings/GeneralSettings"; @@ -23,6 +24,11 @@ export const SETTINGS_SECTIONS: SettingsSectionDef[] = [ { id: "tools", label: "Tools", render: () => }, { id: "ai", label: "AI", render: () => }, { id: "billy", label: "Billy AI", render: () => }, + { + id: "visual-regression", + label: "Visual Regression", + render: () => , + }, { id: "about", label: "About", render: () => }, ]; diff --git a/src/lib/ipc.ts b/src/lib/ipc.ts index da40793..23774e9 100644 --- a/src/lib/ipc.ts +++ b/src/lib/ipc.ts @@ -17,6 +17,7 @@ import type { UINode, WorkspaceNode, } from "@/types"; +import type { RunReport } from "@/types/visualRegression"; export class IpcError extends Error { constructor( @@ -86,6 +87,23 @@ export const ipc = { runFlow: (filePath: string, appId?: string) => call("run_flow", { filePath, appId: appId?.trim() || null }), stopFlow: (pid: number) => call("stop_flow", { pid }), + compareScreenshots: (args: { + workspace: string; + flowPath: string; + model: string; + width: number; + height: number; + tolerance: number; + threshold: number; + runId: string; + }) => call("compare_screenshots", args), + resolveComparison: (args: { + workspace: string; + runId: string; + deviceKey: string; + name: string; + decision: "keep" | "replace"; + }) => call("resolve_comparison", args), listWorkspace: (path: string) => call("list_workspace", { path }), startStream: () => call("start_stream"), stopStream: () => call("stop_stream"), diff --git a/src/stores/reviewStore.ts b/src/stores/reviewStore.ts new file mode 100644 index 0000000..64a4ce9 --- /dev/null +++ b/src/stores/reviewStore.ts @@ -0,0 +1,31 @@ +// Copyright (c) 2026 Ethan Morisset +// SPDX-License-Identifier: BUSL-1.1 + +import { create } from "zustand"; +import type { RunReport } from "@/types/visualRegression"; + +const REVIEWABLE = new Set(["changed", "dimension_mismatch"]); + +export interface ReviewState { + report: RunReport | null; + queue: string[]; + open: boolean; + setReport: (r: RunReport | null) => void; + next: () => void; + close: () => void; +} + +export const useReviewStore = create((set, get) => ({ + report: null, + queue: [], + open: false, + setReport: (r) => { + const queue = r ? r.comparisons.filter((c) => REVIEWABLE.has(c.status)).map((c) => c.name) : []; + set({ report: r, queue, open: queue.length > 0 }); + }, + next: () => { + const queue = get().queue.slice(1); + set({ queue, open: queue.length > 0 }); + }, + close: () => set({ open: false, queue: [] }), +})); diff --git a/src/stores/runStore.ts b/src/stores/runStore.ts index 8be1e69..ae16dc6 100644 --- a/src/stores/runStore.ts +++ b/src/stores/runStore.ts @@ -38,11 +38,13 @@ interface RunState { stopRequested: boolean; logs: LogLine[]; steps: StepRunState[]; + runTarget: { path: string; kind: "flow" | "all" } | null; setStarting: () => void; startFailed: () => void; setRunning: (pid: number) => void; requestStop: () => void; setStopped: (exitCode: number | null) => void; + setRunTarget: (target: { path: string; kind: "flow" | "all" }) => void; appendLog: (stream: LogStream, text: string) => void; /** Clears the visible console — both the technical `logs` and the Simple * view's `steps`, plus the run-result badge — so "Clear" empties whichever @@ -63,6 +65,7 @@ export const useRunStore = create((set) => ({ stopRequested: false, logs: [], steps: [], + runTarget: null, // Posted immediately on the Run click so the toolbar reacts instantly, // before the (potentially slow) backend round-trip returns the PID. Clears // logs here — the earliest point — so early runner stdout isn't dropped. @@ -72,6 +75,7 @@ export const useRunStore = create((set) => ({ set({ running: true, starting: false, pid, exitCode: null, stopRequested: false }), requestStop: () => set({ stopRequested: true }), setStopped: (exitCode) => set({ running: false, starting: false, pid: null, exitCode }), + setRunTarget: (runTarget) => set({ runTarget }), appendLog: (stream, text) => set((s) => { const entry = { id: nextId++, stream, text, timestamp: Date.now() }; diff --git a/src/stores/visualRegressionStore.test.ts b/src/stores/visualRegressionStore.test.ts new file mode 100644 index 0000000..7b419cf --- /dev/null +++ b/src/stores/visualRegressionStore.test.ts @@ -0,0 +1,50 @@ +// Copyright (c) 2026 Ethan Morisset +// SPDX-License-Identifier: BUSL-1.1 + +import { describe, it, expect, beforeEach, vi } from "vitest"; + +vi.hoisted(() => { + const storage = new Map(); + (globalThis as unknown as { localStorage: Storage }).localStorage = { + getItem: (k: string) => storage.get(k) ?? null, + setItem: (k: string, v: string) => { + storage.set(k, v); + }, + removeItem: (k: string) => { + storage.delete(k); + }, + clear: () => storage.clear(), + key: () => null, + length: 0, + } as Storage; +}); + +import { + useVisualRegressionStore, + effectiveThresholds, + DEFAULT_TOLERANCE, + DEFAULT_THRESHOLD, +} from "@/stores/visualRegressionStore"; + +describe("visualRegressionStore", () => { + beforeEach(() => useVisualRegressionStore.getState().reset()); + + it("returns defaults when unset", () => { + expect(effectiveThresholds()).toEqual({ + tolerance: DEFAULT_TOLERANCE, + threshold: DEFAULT_THRESHOLD, + }); + }); + + it("uses custom values when set", () => { + useVisualRegressionStore.getState().setTolerance(0.2); + useVisualRegressionStore.getState().setThreshold(0.05); + expect(effectiveThresholds()).toEqual({ tolerance: 0.2, threshold: 0.05 }); + }); + + it("is enabled by default and toggles", () => { + expect(useVisualRegressionStore.getState().enabled).toBe(true); + useVisualRegressionStore.getState().setEnabled(false); + expect(useVisualRegressionStore.getState().enabled).toBe(false); + }); +}); diff --git a/src/stores/visualRegressionStore.ts b/src/stores/visualRegressionStore.ts new file mode 100644 index 0000000..de6d9bb --- /dev/null +++ b/src/stores/visualRegressionStore.ts @@ -0,0 +1,45 @@ +// Copyright (c) 2026 Ethan Morisset +// SPDX-License-Identifier: BUSL-1.1 + +import { create } from "zustand"; +import { persist, createJSONStorage } from "zustand/middleware"; + +export const DEFAULT_TOLERANCE = 0.1; +export const DEFAULT_THRESHOLD = 0.001; + +export interface VisualRegressionState { + /** Master switch: when false, no screenshot comparison runs after a flow. */ + enabled: boolean; + tolerance: number | null; + threshold: number | null; + setEnabled: (v: boolean) => void; + setTolerance: (v: number | null) => void; + setThreshold: (v: number | null) => void; + reset: () => void; +} + +export const useVisualRegressionStore = create()( + persist( + (set) => ({ + enabled: true, + tolerance: null, + threshold: null, + setEnabled: (v) => set({ enabled: v }), + setTolerance: (v) => set({ tolerance: v }), + setThreshold: (v) => set({ threshold: v }), + reset: () => set({ tolerance: null, threshold: null }), + }), + { + name: "maestro-deck.visual-regression", + storage: createJSONStorage(() => localStorage), + }, + ), +); + +export function effectiveThresholds(): { tolerance: number; threshold: number } { + const { tolerance, threshold } = useVisualRegressionStore.getState(); + return { + tolerance: tolerance ?? DEFAULT_TOLERANCE, + threshold: threshold ?? DEFAULT_THRESHOLD, + }; +} diff --git a/src/types/visualRegression.ts b/src/types/visualRegression.ts new file mode 100644 index 0000000..77f987f --- /dev/null +++ b/src/types/visualRegression.ts @@ -0,0 +1,20 @@ +// Copyright (c) 2026 Ethan Morisset +// SPDX-License-Identifier: BUSL-1.1 + +export type ComparisonStatus = "seeded" | "match" | "changed" | "missing" | "dimension_mismatch"; + +export interface Comparison { + name: string; + status: ComparisonStatus; + changed_ratio: number; + bbox: [number, number, number, number] | null; + bank_b64?: string; + new_b64?: string; + diff_b64?: string; +} + +export interface RunReport { + run_id: string; + device_key: string; + comparisons: Comparison[]; +}