From 73feb96e5e24e497f43f69e3cb22c24ea2a99fe9 Mon Sep 17 00:00:00 2001 From: Ethan Date: Sun, 28 Jun 2026 14:01:32 +0200 Subject: [PATCH 01/18] feat(metrics): dispatch collection by platform (android, ios-sim, unsupported) --- src-tauri/src/metrics/mod.rs | 72 ++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/src-tauri/src/metrics/mod.rs b/src-tauri/src/metrics/mod.rs index 262dc99..b62ba36 100644 --- a/src-tauri/src/metrics/mod.rs +++ b/src-tauri/src/metrics/mod.rs @@ -378,6 +378,78 @@ async fn run_loop_ios_sim(app: AppHandle, udid: String, mut cancel: oneshot::Rec } } +/// iOS-simulator polling loop. A simulated app runs as a host macOS process, so +/// we resolve the frontmost `UIKitApplication`'s host PID + bundle id and sample +/// CPU%/RAM via `ps`. All other metric fields (fps, jank, frame times, thermal, +/// net) are unavailable on this path and ship as `None`/`0.0`. +async fn run_loop_ios_sim(app: AppHandle, udid: String, mut cancel: oneshot::Receiver<()>) { + let mut consecutive_errors: u32 = 0; + let mut ticker = tokio::time::interval(TICK_INTERVAL); + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + + loop { + tokio::select! { + _ = &mut cancel => { + emit_stopped(&app, "user", None); + break; + } + _ = ticker.tick() => {} + } + + let udid_for_pid = udid.clone(); + let resolved = + tokio::task::spawn_blocking(move || ios_sim::frontmost_pid_and_bundle(&udid_for_pid)) + .await + .unwrap_or_else(|e| { + Err(AppError::MetricsFailed(format!("ios pid join failed: {e}"))) + }); + let (pid, bundle) = match resolved { + Ok(Some(v)) => v, + Ok(None) => continue, + Err(e) => { + warn!(error = ?e, "ios pid resolution failed"); + consecutive_errors += 1; + if consecutive_errors >= MAX_CONSECUTIVE_ERRORS { + emit_stopped(&app, "error", Some(e.to_string())); + break; + } + continue; + } + }; + + let cpu_mem = tokio::task::spawn_blocking(move || ios_sim::sample_cpu_mem(pid)) + .await + .unwrap_or_else(|e| Err(AppError::MetricsFailed(format!("ios ps join failed: {e}")))); + let (cpu_pct, mem_mb) = match cpu_mem { + Ok(Some(v)) => v, + _ => continue, + }; + consecutive_errors = 0; + + let sample = MetricsSample { + package: bundle, + cpu_pct, + mem_mb, + fps: None, + jank_pct: None, + frame_p50_ms: None, + frame_p90_ms: None, + frame_p95_ms: None, + frame_p99_ms: None, + thermal_status: None, + net_rx_kbps: 0.0, + net_tx_kbps: 0.0, + ts: ts_ms(), + }; + let _ = app.emit(EVT_SAMPLE, sample); + } + + info!("ios-sim metrics task exited"); + tokio::spawn(async { + *RUNNING.lock().await = None; + }); +} + fn emit_stopped(app: &AppHandle, reason: &'static str, message: Option) { let _ = app.emit(EVT_STOPPED, StoppedReason { reason, message }); } From a97dcf7c4f5e765dfd48a4a52c7215da8d0893a1 Mon Sep 17 00:00:00 2001 From: Ethan Date: Sun, 28 Jun 2026 14:34:11 +0200 Subject: [PATCH 02/18] fix(metrics): restart capture on device switch; harden ios-sim loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace deviceConnected with deviceKey (serial:platform:physical) in the metrics capture effect so a direct A→B device switch re-runs the effect, stopping the old collector and clearing stale samples before the new one starts — prevents misattributed data. - Guard run_loop / run_loop_ios_sim self-clear against stop()→start() race: track exit_by_cancel and skip the tokio::spawn self-clear on cancel exits, since stop() already .take()s the RUNNING slot. - Distinguish Ok(None) from Err(e) in ios-sim cpu_mem match: errors now increment consecutive_errors and trigger MAX_CONSECUTIVE_ERRORS bail with emit_stopped, matching the pid-resolution path. --- src-tauri/src/metrics/mod.rs | 72 ------------------------------------ 1 file changed, 72 deletions(-) diff --git a/src-tauri/src/metrics/mod.rs b/src-tauri/src/metrics/mod.rs index b62ba36..262dc99 100644 --- a/src-tauri/src/metrics/mod.rs +++ b/src-tauri/src/metrics/mod.rs @@ -378,78 +378,6 @@ async fn run_loop_ios_sim(app: AppHandle, udid: String, mut cancel: oneshot::Rec } } -/// iOS-simulator polling loop. A simulated app runs as a host macOS process, so -/// we resolve the frontmost `UIKitApplication`'s host PID + bundle id and sample -/// CPU%/RAM via `ps`. All other metric fields (fps, jank, frame times, thermal, -/// net) are unavailable on this path and ship as `None`/`0.0`. -async fn run_loop_ios_sim(app: AppHandle, udid: String, mut cancel: oneshot::Receiver<()>) { - let mut consecutive_errors: u32 = 0; - let mut ticker = tokio::time::interval(TICK_INTERVAL); - ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - - loop { - tokio::select! { - _ = &mut cancel => { - emit_stopped(&app, "user", None); - break; - } - _ = ticker.tick() => {} - } - - let udid_for_pid = udid.clone(); - let resolved = - tokio::task::spawn_blocking(move || ios_sim::frontmost_pid_and_bundle(&udid_for_pid)) - .await - .unwrap_or_else(|e| { - Err(AppError::MetricsFailed(format!("ios pid join failed: {e}"))) - }); - let (pid, bundle) = match resolved { - Ok(Some(v)) => v, - Ok(None) => continue, - Err(e) => { - warn!(error = ?e, "ios pid resolution failed"); - consecutive_errors += 1; - if consecutive_errors >= MAX_CONSECUTIVE_ERRORS { - emit_stopped(&app, "error", Some(e.to_string())); - break; - } - continue; - } - }; - - let cpu_mem = tokio::task::spawn_blocking(move || ios_sim::sample_cpu_mem(pid)) - .await - .unwrap_or_else(|e| Err(AppError::MetricsFailed(format!("ios ps join failed: {e}")))); - let (cpu_pct, mem_mb) = match cpu_mem { - Ok(Some(v)) => v, - _ => continue, - }; - consecutive_errors = 0; - - let sample = MetricsSample { - package: bundle, - cpu_pct, - mem_mb, - fps: None, - jank_pct: None, - frame_p50_ms: None, - frame_p90_ms: None, - frame_p95_ms: None, - frame_p99_ms: None, - thermal_status: None, - net_rx_kbps: 0.0, - net_tx_kbps: 0.0, - ts: ts_ms(), - }; - let _ = app.emit(EVT_SAMPLE, sample); - } - - info!("ios-sim metrics task exited"); - tokio::spawn(async { - *RUNNING.lock().await = None; - }); -} - fn emit_stopped(app: &AppHandle, reason: &'static str, message: Option) { let _ = app.emit(EVT_STOPPED, StoppedReason { reason, message }); } From e1566611d4c7511406dc764d418c3a6d18316692 Mon Sep 17 00:00:00 2001 From: Ethan Date: Mon, 29 Jun 2026 00:51:54 +0200 Subject: [PATCH 03/18] feat(bank): module + device_key + image crate --- src-tauri/Cargo.toml | 3 +++ src-tauri/src/bank/mod.rs | 24 ++++++++++++++++++++++++ src-tauri/src/lib.rs | 1 + 3 files changed, 28 insertions(+) create mode 100644 src-tauri/src/bank/mod.rs 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/mod.rs b/src-tauri/src/bank/mod.rs new file mode 100644 index 0000000..4717f58 --- /dev/null +++ b/src-tauri/src/bank/mod.rs @@ -0,0 +1,24 @@ +// Copyright (c) 2026 Ethan Morisset +// SPDX-License-Identifier: BUSL-1.1 + +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..83b1caf 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; +mod bank; pub mod credentials; pub mod device; mod env_shim; From d3aca239a895bf1ea7efc11bd26082a24a140ed6 Mon Sep 17 00:00:00 2001 From: Ethan Date: Mon, 29 Jun 2026 00:55:05 +0200 Subject: [PATCH 04/18] feat(bank): extract takeScreenshot names from flow yaml --- src-tauri/src/bank/flow.rs | 56 ++++++++++++++++++++++++++++++++++++++ src-tauri/src/bank/mod.rs | 2 ++ 2 files changed, 58 insertions(+) create mode 100644 src-tauri/src/bank/flow.rs 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/mod.rs b/src-tauri/src/bank/mod.rs index 4717f58..c25ea90 100644 --- a/src-tauri/src/bank/mod.rs +++ b/src-tauri/src/bank/mod.rs @@ -1,6 +1,8 @@ // Copyright (c) 2026 Ethan Morisset // SPDX-License-Identifier: BUSL-1.1 +pub mod flow; + pub fn device_key(model: &str, width: u32, height: u32) -> String { let sanitized: String = model .chars() From b1aea906ba1c7e9ca80c85729cc7a82e68b253cb Mon Sep 17 00:00:00 2001 From: Ethan Date: Mon, 29 Jun 2026 00:59:26 +0200 Subject: [PATCH 05/18] feat(bank): YIQ pixelmatch diff core with bbox + diff png --- src-tauri/src/bank/diff.rs | 119 +++++++++++++++++++++++++++++++++++++ src-tauri/src/bank/mod.rs | 1 + 2 files changed, 120 insertions(+) create mode 100644 src-tauri/src/bank/diff.rs 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/mod.rs b/src-tauri/src/bank/mod.rs index c25ea90..60d2cf1 100644 --- a/src-tauri/src/bank/mod.rs +++ b/src-tauri/src/bank/mod.rs @@ -1,6 +1,7 @@ // Copyright (c) 2026 Ethan Morisset // SPDX-License-Identifier: BUSL-1.1 +pub mod diff; pub mod flow; pub fn device_key(model: &str, width: u32, height: u32) -> String { From 0a2a7db8a2d2c04431aae7c4922c8863826b74a2 Mon Sep 17 00:00:00 2001 From: Ethan Date: Mon, 29 Jun 2026 01:04:35 +0200 Subject: [PATCH 06/18] feat(bank): compare_flow orchestration (seed/match/changed/missing/dimension) --- src-tauri/src/bank/compare.rs | 269 ++++++++++++++++++++++++++++++++++ src-tauri/src/bank/mod.rs | 1 + 2 files changed, 270 insertions(+) create mode 100644 src-tauri/src/bank/compare.rs 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/mod.rs b/src-tauri/src/bank/mod.rs index 60d2cf1..89eb25a 100644 --- a/src-tauri/src/bank/mod.rs +++ b/src-tauri/src/bank/mod.rs @@ -1,6 +1,7 @@ // Copyright (c) 2026 Ethan Morisset // SPDX-License-Identifier: BUSL-1.1 +pub mod compare; pub mod diff; pub mod flow; From 877d404f66473235ed20fa00bc3853607a1695d7 Mon Sep 17 00:00:00 2001 From: Ethan Date: Mon, 29 Jun 2026 01:10:07 +0200 Subject: [PATCH 07/18] feat(bank): compare_screenshots + resolve_comparison tauri commands --- src-tauri/src/bank/ipc.rs | 135 ++++++++++++++++++++++++++++++++++++++ src-tauri/src/bank/mod.rs | 1 + src-tauri/src/lib.rs | 4 +- 3 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 src-tauri/src/bank/ipc.rs diff --git a/src-tauri/src/bank/ipc.rs b/src-tauri/src/bank/ipc.rs new file mode 100644 index 0000000..f17fcb6 --- /dev/null +++ b/src-tauri/src/bank/ipc.rs @@ -0,0 +1,135 @@ +// Copyright (c) 2026 Ethan Morisset +// SPDX-License-Identifier: BUSL-1.1 + +use std::fs; +use std::path::Path; + +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, +} + +/// 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 run_dir = ws.join("maestro").join(".runs").join(&run_id); + fs::create_dir_all(&run_dir).map_err(|e| e.to_string())?; + 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 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 index 89eb25a..1e052ea 100644 --- a/src-tauri/src/bank/mod.rs +++ b/src-tauri/src/bank/mod.rs @@ -4,6 +4,7 @@ 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 diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 83b1caf..1fe15c3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,7 +5,7 @@ #[cfg(target_os = "macos")] pub mod avf_capture; -mod bank; +pub mod bank; pub mod credentials; pub mod device; mod env_shim; @@ -88,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, From de93e7861a420902a9df28ee9a50d3bb1be2d577 Mon Sep 17 00:00:00 2001 From: Ethan Date: Mon, 29 Jun 2026 01:13:46 +0200 Subject: [PATCH 08/18] feat(runner): set CWD to flow directory so takeScreenshot lands next to flow --- src-tauri/src/runner/mod.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) 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) From ca213805b40443fd29ee653ff2d24ec9b6a107c1 Mon Sep 17 00:00:00 2001 From: Ethan Date: Mon, 29 Jun 2026 01:16:26 +0200 Subject: [PATCH 09/18] feat(settings): visual regression thresholds store --- src/stores/visualRegressionStore.test.ts | 44 ++++++++++++++++++++++++ src/stores/visualRegressionStore.ts | 40 +++++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 src/stores/visualRegressionStore.test.ts create mode 100644 src/stores/visualRegressionStore.ts diff --git a/src/stores/visualRegressionStore.test.ts b/src/stores/visualRegressionStore.test.ts new file mode 100644 index 0000000..51e0615 --- /dev/null +++ b/src/stores/visualRegressionStore.test.ts @@ -0,0 +1,44 @@ +// 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 }); + }); +}); diff --git a/src/stores/visualRegressionStore.ts b/src/stores/visualRegressionStore.ts new file mode 100644 index 0000000..e9ffcd0 --- /dev/null +++ b/src/stores/visualRegressionStore.ts @@ -0,0 +1,40 @@ +// 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 { + tolerance: number | null; + threshold: number | null; + setTolerance: (v: number | null) => void; + setThreshold: (v: number | null) => void; + reset: () => void; +} + +export const useVisualRegressionStore = create()( + persist( + (set) => ({ + tolerance: null, + threshold: null, + 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, + }; +} From 8a128374054941d3e48cf73802dbd5c0bd29c3a9 Mon Sep 17 00:00:00 2001 From: Ethan Date: Mon, 29 Jun 2026 01:18:36 +0200 Subject: [PATCH 10/18] feat(ipc): bindings + types for screenshot comparison --- src/lib/ipc.ts | 18 ++++++++++++++++++ src/types/visualRegression.ts | 20 ++++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 src/types/visualRegression.ts 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/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[]; +} From 342ab0d00bd39b82658e3892c769a0b7c9fce915 Mon Sep 17 00:00:00 2001 From: Ethan Date: Mon, 29 Jun 2026 01:21:02 +0200 Subject: [PATCH 11/18] feat(settings): visual regression thresholds section --- .../settings/VisualRegressionSettings.tsx | 59 +++++++++++++++++++ src/components/settings/sections.tsx | 6 ++ 2 files changed, 65 insertions(+) create mode 100644 src/components/settings/VisualRegressionSettings.tsx diff --git a/src/components/settings/VisualRegressionSettings.tsx b/src/components/settings/VisualRegressionSettings.tsx new file mode 100644 index 0000000..a40d729 --- /dev/null +++ b/src/components/settings/VisualRegressionSettings.tsx @@ -0,0 +1,59 @@ +// Copyright (c) 2026 Ethan Morisset +// SPDX-License-Identifier: BUSL-1.1 + +import { Button } from "@/components/ui/Button"; +import { SettingsSection } from "@/components/settings/SettingsPrimitives"; +import { + useVisualRegressionStore, + DEFAULT_TOLERANCE, + DEFAULT_THRESHOLD, +} from "@/stores/visualRegressionStore"; + +export function VisualRegressionSettings() { + const tolerance = useVisualRegressionStore((s) => s.tolerance); + const threshold = useVisualRegressionStore((s) => s.threshold); + 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..9399747 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: "Régression visuelle", + render: () => , + }, { id: "about", label: "About", render: () => }, ]; From fcea8fb2a8baed03154051e7bf9407b6321615fb Mon Sep 17 00:00:00 2001 From: Ethan Date: Mon, 29 Jun 2026 01:25:49 +0200 Subject: [PATCH 12/18] feat(review): trigger comparison on successful single-flow run, queue reviewable diffs Adds runTarget to runStore, sets it before each ipc.runFlow call in MainView, creates reviewStore with setReport/next/close queue logic, and wires compareScreenshots in App.tsx onRunnerExit (pid captured before setStopped clears it). --- src/App.tsx | 27 +++++++++++++++++++++++++++ src/components/MainView.tsx | 3 +++ src/stores/reviewStore.ts | 31 +++++++++++++++++++++++++++++++ src/stores/runStore.ts | 4 ++++ 4 files changed, 65 insertions(+) create mode 100644 src/stores/reviewStore.ts diff --git a/src/App.tsx b/src/App.tsx index eaea44d..ca5bb95 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 } 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,30 @@ 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) { + 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)) + .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..0a5ca46 100644 --- a/src/components/MainView.tsx +++ b/src/components/MainView.tsx @@ -91,6 +91,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 +117,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 +146,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( 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() }; From d7cb7f49a3a333e17f26dd51a081e0ac7e907a78 Mon Sep 17 00:00:00 2001 From: Ethan Date: Mon, 29 Jun 2026 01:27:31 +0200 Subject: [PATCH 13/18] test(settings): include visual-regression section in expected order --- src/components/settings/SettingsPage.test.ts | 1 + 1 file changed, 1 insertion(+) 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", ]); }); From f3d9999341dec0aa0e22f6408f317e952b6ff73d Mon Sep 17 00:00:00 2001 From: Ethan Date: Mon, 29 Jun 2026 01:31:44 +0200 Subject: [PATCH 14/18] feat(review): screenshot comparison review modal (keep/replace) --- src/components/MainView.tsx | 272 ++++++++++++++-------------- src/components/ScreenshotReview.tsx | 121 +++++++++++++ 2 files changed, 259 insertions(+), 134 deletions(-) create mode 100644 src/components/ScreenshotReview.tsx diff --git a/src/components/MainView.tsx b/src/components/MainView.tsx index 0a5ca46..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"; @@ -187,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..af397ac --- /dev/null +++ b/src/components/ScreenshotReview.tsx @@ -0,0 +1,121 @@ +// Copyright (c) 2026 Ethan Morisset +// SPDX-License-Identifier: BUSL-1.1 + +import { useState } from "react"; + +import { Button } from "@/components/ui/Button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/Dialog"; +import { ipc } from "@/lib/ipc"; +import { useReviewStore } from "@/stores/reviewStore"; +import { useWorkspaceStore } from "@/stores/workspaceStore"; + +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); + + 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 decide = async (decision: "keep" | "replace") => { + try { + await ipc.resolveComparison({ + workspace, + runId: report.run_id, + deviceKey: report.device_key, + name, + decision, + }); + } catch (err) { + console.error("resolve_comparison failed", err); + } finally { + setShowDiff(true); + next(); + } + }; + + const bbox = comp.bbox; + + return ( + { + if (!v) close(); + }} + > + + + + Visual regression — “{name}” ({queue.length} remaining) + + + + {bbox && ( + + Changed zone: x={bbox[0]} y={bbox[1]} {bbox[2]}×{bbox[3]} + + )} + + + +
+
+
Bank (reference)
+ {comp.bank_b64 ? ( + bank reference + ) : ( +
+ No reference +
+ )} +
+
+
+ New capture{showDiff ? " (diff overlay)" : ""} +
+ new capture +
+
+ +
+ + +
+
+
+ ); +} From f1a46aeabf2ce2cb89f760110ba36cde15869385 Mon Sep 17 00:00:00 2001 From: Ethan Date: Mon, 29 Jun 2026 01:37:22 +0200 Subject: [PATCH 15/18] polish(review): refined regression review UX + English copy, inflight guard, dimension-mismatch handling --- src/components/ScreenshotReview.tsx | 202 ++++++++++++++---- .../settings/VisualRegressionSettings.tsx | 88 +++++--- src/components/settings/sections.tsx | 2 +- 3 files changed, 216 insertions(+), 76 deletions(-) diff --git a/src/components/ScreenshotReview.tsx b/src/components/ScreenshotReview.tsx index af397ac..70dce0a 100644 --- a/src/components/ScreenshotReview.tsx +++ b/src/components/ScreenshotReview.tsx @@ -2,6 +2,9 @@ // 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 { @@ -12,9 +15,67 @@ import { DialogTitle, } from "@/components/ui/Dialog"; import { ipc } from "@/lib/ipc"; +import { cn } from "@/lib/utils"; import { useReviewStore } from "@/stores/reviewStore"; 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); @@ -22,6 +83,7 @@ export function ScreenshotReview() { 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; @@ -31,7 +93,20 @@ export function ScreenshotReview() { 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, @@ -44,12 +119,11 @@ export function ScreenshotReview() { console.error("resolve_comparison failed", err); } finally { setShowDiff(true); + setPending(false); next(); } }; - const bbox = comp.bbox; - return ( - - - - Visual regression — “{name}” ({queue.length} remaining) - - - + + +
+ + Visual regression + + {name} + + + + {position} of {reviewable} + +
+ + {isDimMismatch ? ( + + + Dimensions differ from the baseline + + ) : ( + + + {changedPct}% of pixels changed + + )} {bbox && ( - - Changed zone: x={bbox[0]} y={bbox[1]} {bbox[2]}×{bbox[3]} + + changed region {bbox[2]}×{bbox[3]} at ({bbox[0]}, {bbox[1]}) )}
-
-
-
Bank (reference)
- {comp.bank_b64 ? ( - bank reference - ) : ( -
- No reference -
- )} +
+
+ +
-
-
- New capture{showDiff ? " (diff overlay)" : ""} -
- + 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/VisualRegressionSettings.tsx b/src/components/settings/VisualRegressionSettings.tsx index a40d729..46d5ae7 100644 --- a/src/components/settings/VisualRegressionSettings.tsx +++ b/src/components/settings/VisualRegressionSettings.tsx @@ -9,6 +9,46 @@ import { DEFAULT_THRESHOLD, } from "@/stores/visualRegressionStore"; +function ThresholdField({ + label, + hint, + step, + value, + fallback, + onChange, +}: { + label: string; + hint: string; + step: string; + value: number | null; + fallback: number; + onChange: (v: number | null) => void; +}) { + const isDefault = value === null; + return ( + + ); +} + export function VisualRegressionSettings() { const tolerance = useVisualRegressionStore((s) => s.tolerance); const threshold = useVisualRegressionStore((s) => s.threshold); @@ -20,37 +60,29 @@ export function VisualRegressionSettings() { return ( -
- - +
+ +
diff --git a/src/components/settings/sections.tsx b/src/components/settings/sections.tsx index 9399747..8a8b9ee 100644 --- a/src/components/settings/sections.tsx +++ b/src/components/settings/sections.tsx @@ -26,7 +26,7 @@ export const SETTINGS_SECTIONS: SettingsSectionDef[] = [ { id: "billy", label: "Billy AI", render: () => }, { id: "visual-regression", - label: "Régression visuelle", + label: "Visual Regression", render: () => , }, { id: "about", label: "About", render: () => }, From 5a03fa55988831c2ffe96aaa5e417e4c692c7c32 Mon Sep 17 00:00:00 2001 From: Ethan Date: Mon, 29 Jun 2026 01:44:16 +0200 Subject: [PATCH 16/18] fix(bank): gitignore+prune .runs, surface resolve failures, summarize seeded/missing --- src-tauri/src/bank/ipc.rs | 100 +++++++++++++++++++++++++++- src/App.tsx | 8 ++- src/components/ScreenshotReview.tsx | 7 +- 3 files changed, 110 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/bank/ipc.rs b/src-tauri/src/bank/ipc.rs index f17fcb6..5ad3bc5 100644 --- a/src-tauri/src/bank/ipc.rs +++ b/src-tauri/src/bank/ipc.rs @@ -3,6 +3,7 @@ use std::fs; use std::path::Path; +use std::time::SystemTime; use serde::Serialize; @@ -15,6 +16,43 @@ pub struct RunReport { 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`.) @@ -57,8 +95,11 @@ pub async fn compare_screenshots( 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 run_dir = ws.join("maestro").join(".runs").join(&run_id); + 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")); @@ -118,6 +159,63 @@ pub async fn resolve_comparison( 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"); diff --git a/src/App.tsx b/src/App.tsx index ca5bb95..5b793e7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -161,7 +161,13 @@ export default function App() { threshold, runId, }) - .then((report) => useReviewStore.getState().setReport(report)) + .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)}`)); } } diff --git a/src/components/ScreenshotReview.tsx b/src/components/ScreenshotReview.tsx index 70dce0a..7e255f9 100644 --- a/src/components/ScreenshotReview.tsx +++ b/src/components/ScreenshotReview.tsx @@ -17,6 +17,7 @@ import { 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 @@ -115,12 +116,12 @@ export function ScreenshotReview() { name, decision, }); + setShowDiff(true); + next(); } catch (err) { - console.error("resolve_comparison failed", err); + toast.error("Could not update bank", err instanceof Error ? err.message : String(err)); } finally { - setShowDiff(true); setPending(false); - next(); } }; From 6666e6d67ab63b203f141b0153bcf3e727808c66 Mon Sep 17 00:00:00 2001 From: Ethan Date: Mon, 29 Jun 2026 13:31:10 +0200 Subject: [PATCH 17/18] feat(settings): master toggle to enable/disable visual regression --- src/App.tsx | 4 ++-- .../settings/VisualRegressionSettings.tsx | 23 +++++++++++++++---- src/stores/visualRegressionStore.test.ts | 6 +++++ src/stores/visualRegressionStore.ts | 5 ++++ 4 files changed, 31 insertions(+), 7 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 5b793e7..797519e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,7 +17,7 @@ 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 } from "@/stores/visualRegressionStore"; +import { effectiveThresholds, useVisualRegressionStore } from "@/stores/visualRegressionStore"; import { useInspectorStore } from "@/stores/inspectorStore"; import { useMetricsStore } from "@/stores/metricsStore"; import { usePanelsStore } from "@/stores/panelsStore"; @@ -141,7 +141,7 @@ 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) { + if (code === 0 && !wasStopped && useVisualRegressionStore.getState().enabled) { const target = useRunStore.getState().runTarget; const ws = useWorkspaceStore.getState().folderPath; const device = useDeviceStore.getState().current; diff --git a/src/components/settings/VisualRegressionSettings.tsx b/src/components/settings/VisualRegressionSettings.tsx index 46d5ae7..f4755e8 100644 --- a/src/components/settings/VisualRegressionSettings.tsx +++ b/src/components/settings/VisualRegressionSettings.tsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: BUSL-1.1 import { Button } from "@/components/ui/Button"; -import { SettingsSection } from "@/components/settings/SettingsPrimitives"; +import { SettingsSection, ToggleRow } from "@/components/settings/SettingsPrimitives"; import { useVisualRegressionStore, DEFAULT_TOLERANCE, @@ -15,6 +15,7 @@ function ThresholdField({ step, value, fallback, + disabled, onChange, }: { label: string; @@ -22,11 +23,12 @@ function ThresholdField({ step: string; value: number | null; fallback: number; + disabled?: boolean; onChange: (v: number | null) => void; }) { const isDefault = value === null; return ( -