diff --git a/LICENSE b/LICENSE index d9a526e..5499dcd 100644 --- a/LICENSE +++ b/LICENSE @@ -15,7 +15,7 @@ Additional Use Grant: You may use the Licensed Work in production, (in original or modified form) as a commercial product or as part of a commercial offering. -Change Date: 2030-06-14 +Change Date: 2030-06-28 Change License: Apache License, Version 2.0 diff --git a/docs/superpowers/plans/2026-06-29-screenshot-bank-regression.md b/docs/superpowers/plans/2026-06-29-screenshot-bank-regression.md new file mode 100644 index 0000000..b2325e6 --- /dev/null +++ b/docs/superpowers/plans/2026-06-29-screenshot-bank-regression.md @@ -0,0 +1,1449 @@ +# Banque de screenshots & régression visuelle — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Maintenir une banque de screenshots de référence par device et signaler les régressions visuelles entre runs, avec revue utilisateur (garder la banque / remplacer). + +**Architecture:** Rust = moteur pur (crate `image`, diff YIQ pixelmatch, I/O banque) exposé via deux commandes Tauri. Le front orchestre : à la fin d'un run réussi (`runner:exit` code 0), il appelle `compare_screenshots` avec les seuils (Paramètres), le device et le workspace ; le rapport renvoyé alimente une modale de revue. Les `takeScreenshot: ` du flow YAML produisent `.png` à côté du flow (CWD du runner fixé sur le dossier du flow). + +**Tech Stack:** Rust (Tauri 2, tokio, serde, crate `image`), React + TypeScript + Zustand + Vitest. + +## Global Constraints + +- En-tête de licence en tête de **chaque nouveau fichier** (copier verbatim depuis un fichier voisin existant) : + `// Copyright (c) 2026 Ethan Morisset` puis `// SPDX-License-Identifier: BUSL-1.1`. +- Commits **sans** attribution Claude / Co-Authored-By. +- Clé device = `_x`, sanitizée (tout caractère non `[A-Za-z0-9]` → `_`). +- Emplacement banque : `/maestro/bank//.png` ; runs : `/maestro/.runs//`. +- Seuils par défaut : `tolerance = 0.1` (delta couleur par pixel, échelle pixelmatch), `threshold = 0.001` (ratio de pixels changés). +- Aucun traitement de banque si le run échoue (exit ≠ 0) : orchestré côté front (n'appelle `compare_screenshots` que si `code === 0`). +- Tests Rust : `cargo test` dans `src-tauri/`. Tests front : `pnpm test` (vitest, fichiers `src/**/*.test.ts(x)`). +- Imports front via alias `@/` (ex: `@/stores/...`). + +--- + +### Task 1: Module `bank` + clé device + dépendance `image` + +**Files:** +- Modify: `src-tauri/Cargo.toml` (section `[dependencies]`) +- Create: `src-tauri/src/bank/mod.rs` +- Modify: `src-tauri/src/lib.rs` (déclaration du module) + +**Interfaces:** +- Produces: `pub fn device_key(model: &str, width: u32, height: u32) -> String` + +- [ ] **Step 1: Écrire le test qui échoue** + +Ajouter à la fin de `src-tauri/src/bank/mod.rs` (créer le fichier avec l'en-tête licence) : + +```rust +// Copyright (c) 2026 Ethan Morisset +// SPDX-License-Identifier: BUSL-1.1 + +pub fn device_key(model: &str, width: u32, height: u32) -> String { + todo!() +} + +#[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"); + } +} +``` + +Déclarer le module dans `src-tauri/src/lib.rs` (à côté des autres `mod ...;`, ex. près de `mod runner;`) : + +```rust +mod bank; +``` + +- [ ] **Step 2: Lancer le test, vérifier l'échec** + +Run: `cd src-tauri && cargo test bank::tests::device_key_sanitizes -- --nocapture` +Expected: PANIC `not yet implemented` (todo!). + +- [ ] **Step 3: Implémenter** + +Remplacer le corps de `device_key` : + +```rust +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}") +} +``` + +- [ ] **Step 4: Ajouter la dépendance `image`** + +Dans `src-tauri/Cargo.toml`, sous `[dependencies]`, ajouter (PNG uniquement, pas de features superflues) : + +```toml +image = { version = "0.25", default-features = false, features = ["png"] } +``` + +- [ ] **Step 5: Lancer le test, vérifier le succès** + +Run: `cd src-tauri && cargo test bank::tests::device_key_sanitizes` +Expected: PASS (1 test). + +- [ ] **Step 6: Commit** + +```bash +git add src-tauri/Cargo.toml src-tauri/Cargo.lock src-tauri/src/bank/mod.rs src-tauri/src/lib.rs +git commit -m "feat(bank): module + device_key + image crate" +``` + +--- + +### Task 2: Extraction des noms `takeScreenshot` du flow YAML + +**Files:** +- Create: `src-tauri/src/bank/flow.rs` +- Modify: `src-tauri/src/bank/mod.rs` (ajouter `pub mod flow;`) + +**Interfaces:** +- Produces: `pub fn screenshot_names(flow_yaml: &str) -> Vec` + Extrait, dans l'ordre, les noms des commandes `takeScreenshot`. Supporte la forme courte + (`- takeScreenshot: login`) et la forme objet (`- takeScreenshot:\n path: login`). + +- [ ] **Step 1: Écrire le test qui échoue** + +Créer `src-tauri/src/bank/flow.rs` : + +```rust +// 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 { + todo!() +} + +#[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); + } +} +``` + +Ajouter dans `src-tauri/src/bank/mod.rs`, sous l'en-tête, avant `device_key` : + +```rust +pub mod flow; +``` + +- [ ] **Step 2: Lancer le test, vérifier l'échec** + +Run: `cd src-tauri && cargo test bank::flow` +Expected: PANIC `not yet implemented`. + +- [ ] **Step 3: Implémenter (parsing ligne à ligne, sans dépendance YAML)** + +Remplacer le corps de `screenshot_names` : + +```rust +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() +} +``` + +- [ ] **Step 4: Lancer les tests, vérifier le succès** + +Run: `cd src-tauri && cargo test bank::flow` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src-tauri/src/bank/flow.rs src-tauri/src/bank/mod.rs +git commit -m "feat(bank): extract takeScreenshot names from flow yaml" +``` + +--- + +### Task 3: Cœur du diff pixel (YIQ pixelmatch) + +**Files:** +- Create: `src-tauri/src/bank/diff.rs` +- Modify: `src-tauri/src/bank/mod.rs` (ajouter `pub mod diff;`) + +**Interfaces:** +- Produces: + ```rust + pub struct DiffOutcome { + pub changed_ratio: f32, + pub bbox: Option<[u32; 4]>, // x, y, w, h des pixels changés + pub diff_png: Vec, // PNG: copie de `new` avec pixels changés en rouge + } + pub fn diff_images(bank_png: &[u8], new_png: &[u8], tolerance: f64) -> Result + ``` + Renvoie une erreur si l'un des PNG ne décode pas. (Les dimensions différentes sont gérées + par l'appelant — voir Task 4 — donc ici on suppose dimensions égales ; sinon `panic` testé séparément n'est pas requis.) + +- [ ] **Step 1: Écrire le test qui échoue** + +Créer `src-tauri/src/bank/diff.rs` : + +```rust +// Copyright (c) 2026 Ethan Morisset +// SPDX-License-Identifier: BUSL-1.1 + +use image::{ImageEncoder, RgbaImage}; + +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 { + todo!() +} + +#[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])); + } +} +``` + +Ajouter dans `src-tauri/src/bank/mod.rs` : `pub mod diff;` + +- [ ] **Step 2: Lancer le test, vérifier l'échec** + +Run: `cd src-tauri && cargo test bank::diff` +Expected: PANIC `not yet implemented`. + +- [ ] **Step 3: Implémenter le diff YIQ** + +Remplacer le corps de `diff_images` et ajouter les helpers : + +```rust +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) +} +``` + +- [ ] **Step 4: Lancer les tests, vérifier le succès** + +Run: `cd src-tauri && cargo test bank::diff` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src-tauri/src/bank/diff.rs src-tauri/src/bank/mod.rs +git commit -m "feat(bank): YIQ pixelmatch diff core with bbox + diff png" +``` + +--- + +### Task 4: Orchestration de comparaison sur un flow (seed/match/changed/missing/dimension) + +**Files:** +- Create: `src-tauri/src/bank/compare.rs` +- Modify: `src-tauri/src/bank/mod.rs` (ajouter `pub mod compare;` + ré-exports) + +**Interfaces:** +- Consumes: `device_key`, `flow::screenshot_names`, `diff::diff_images`. +- Produces: + ```rust + #[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, + } + + pub fn compare_flow(input: CompareInput) -> std::io::Result<(String, Vec)> + // renvoie (device_key, comparisons) + ``` + Règles : pour chaque nom attendu (du flow), résoudre `/.png` (produit) et + `/maestro/bank//.png` (référence). + - produit absent → `Missing`. + - produit présent, référence absente → copier le produit dans la banque → `Seeded`. + - les deux présents, dimensions ≠ → `DimensionMismatch` (+ b64 bank/new). + - les deux présents, `changed_ratio > threshold` → `Changed` (+ b64 bank/new/diff, bbox). + - sinon → `Match`. + Le `device_key` directory est créé au besoin (`create_dir_all`). + +- [ ] **Step 1: Écrire le test qui échoue** + +Créer `src-tauri/src/bank/compare.rs` avec l'en-tête licence, la déclaration des types ci-dessus, +un `pub fn compare_flow(...) -> ... { todo!() }`, et les tests : + +```rust +#[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)); + } +} +``` + +Ajouter dans `mod.rs` : `pub mod compare;` + +- [ ] **Step 2: Lancer les tests, vérifier l'échec** + +Run: `cd src-tauri && cargo test bank::compare` +Expected: PANIC `not yet implemented`. + +- [ ] **Step 3: Implémenter** + +Corps de `compare.rs` (au-dessus des tests, après les types) : + +```rust +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; + +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)) +} +``` + +Ajouter la dépendance `base64` si absente de `src-tauri/Cargo.toml` (vérifier d'abord — déjà utilisée ailleurs dans le projet pour l'encodage PNG ; si présente, ne rien faire) : + +```toml +base64 = "0.22" +``` + +- [ ] **Step 4: Lancer les tests, vérifier le succès** + +Run: `cd src-tauri && cargo test bank::compare` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src-tauri/src/bank/compare.rs src-tauri/src/bank/mod.rs src-tauri/Cargo.toml src-tauri/Cargo.lock +git commit -m "feat(bank): compare_flow orchestration (seed/match/changed/missing/dimension)" +``` + +--- + +### Task 5: Commandes Tauri `compare_screenshots` + `resolve_comparison` + +**Files:** +- Create: `src-tauri/src/bank/ipc.rs` +- Modify: `src-tauri/src/bank/mod.rs` (`pub mod ipc;`) +- Modify: `src-tauri/src/lib.rs` (enregistrer les 2 commandes dans `generate_handler!`) + +**Interfaces:** +- Consumes: `compare::{compare_flow, CompareInput, Comparison}`. +- Produces (commandes Tauri) : + ```rust + #[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 + + #[tauri::command] + pub async fn resolve_comparison( + workspace: String, run_id: String, device_key: String, name: String, decision: String, + ) -> Result<(), String> + ``` + `RunReport { run_id, device_key, comparisons }` (le front lira `run_id`/`device_key`/`comparisons`). `compare_screenshots` copie d'abord chaque PNG produit (à côté du flow) dans `/maestro/.runs//`, lance `compare_flow`, puis écrit un `report.json` *slim* (sans b64) dans ce même dossier. + `resolve_comparison` : `decision == "replace"` → copie `.runs//.png` → `bank//.png` (écrase la référence). `decision == "keep"` → no-op (banque inchangée, régression déjà tracée). + +- [ ] **Step 1: Écrire le test qui échoue (résolution `replace`)** + +Créer `src-tauri/src/bank/ipc.rs` avec en-tête licence, types + commandes en `todo!()`, et un test +unitaire sur la fonction pure de remplacement (la logique testable hors Tauri) : + +```rust +// 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<()> { + todo!() +} + +#[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"); + } +} +``` + +Ajouter `pub mod ipc;` dans `mod.rs`. + +- [ ] **Step 2: Lancer le test, vérifier l'échec** + +Run: `cd src-tauri && cargo test bank::ipc` +Expected: PANIC `not yet implemented`. + +- [ ] **Step 3: Implémenter les commandes + helper** + +Dans `ipc.rs`, implémenter `replace_bank_image` et les deux commandes. `compare_screenshots` +copie chaque PNG produit (à côté du flow) dans le dossier de run **avant** de comparer, pour que +`resolve replace` ait une source stable ; il écrit aussi le `report.json` slim. + +```rust +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(()) +} +``` + +- [ ] **Step 4: Enregistrer les commandes** + +Dans `src-tauri/src/lib.rs`, ajouter dans `tauri::generate_handler![ ... ]` (après `stop_flow,`) : + +```rust + bank::ipc::compare_screenshots, + bank::ipc::resolve_comparison, +``` + +- [ ] **Step 5: Lancer test + build, vérifier le succès** + +Run: `cd src-tauri && cargo test bank::ipc && cargo build` +Expected: tests PASS, build OK (commandes enregistrées sans erreur de macro). + +- [ ] **Step 6: Commit** + +```bash +git add src-tauri/src/bank/ipc.rs src-tauri/src/bank/mod.rs src-tauri/src/lib.rs +git commit -m "feat(bank): compare_screenshots + resolve_comparison tauri commands" +``` + +--- + +### Task 6: CWD du runner = dossier du flow + +**Files:** +- Modify: `src-tauri/src/runner/mod.rs` (4 sites `Command::new(...)` : lignes ~126, ~212, ~291, ~426) + +**Interfaces:** +- Aucune nouvelle interface publique ; comportement : les `takeScreenshot` de Maestro écrivent + `.png` dans le dossier du fichier flow. + +- [ ] **Step 1: Insérer `.current_dir(...)` dans `spawn_runner`** + +Avant le `.spawn()` de la commande maestro (après le dernier `.arg(flow_path)` / `.args(&env_args)`, +juste avant `.stdout(Stdio::piped())`), insérer (calculer le dossier parent une fois en haut de la fn) : + +```rust +let flow_dir = std::path::Path::new(flow_path) + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| std::path::PathBuf::from(".")); +``` + +puis sur la chaîne `Command::new(&bin)` ajouter `.current_dir(&flow_dir)` : + +```rust +let mut child = Command::new(&bin) + .no_window() + .args(["--udid", serial, "test"]) + .args(&env_args) + .arg(flow_path) + .current_dir(&flow_dir) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() +``` + +- [ ] **Step 2: Répéter pour les 3 autres runners** + +Appliquer la même `flow_dir` + `.current_dir(&flow_dir)` dans `spawn_web_runner` (~212), +`spawn_ios_runner` (~291), `spawn_ios_device_runner` (~426). (Le binaire maestro reçoit `flow_path` +en chemin relatif/absolu inchangé — un chemin absolu reste valide quel que soit le CWD.) + +- [ ] **Step 3: Vérifier la compilation** + +Run: `cd src-tauri && cargo build` +Expected: build OK. + +- [ ] **Step 4: Commit** + +```bash +git add src-tauri/src/runner/mod.rs +git commit -m "feat(runner): set CWD to flow directory so takeScreenshot lands next to flow" +``` + +--- + +### Task 7: Store de réglages `visualRegressionStore` + +**Files:** +- Create: `src/stores/visualRegressionStore.ts` +- Create: `src/stores/visualRegressionStore.test.ts` + +**Interfaces:** +- Produces: + ```ts + export const DEFAULT_TOLERANCE = 0.1; + export const DEFAULT_THRESHOLD = 0.001; + export interface VisualRegressionState { + tolerance: number | null; // null → défaut + threshold: number | null; // null → défaut + setTolerance: (v: number | null) => void; + setThreshold: (v: number | null) => void; + reset: () => void; + } + export const useVisualRegressionStore = create()(...); + export function effectiveThresholds(): { tolerance: number; threshold: number }; + ``` + +- [ ] **Step 1: Écrire le test qui échoue** + +Créer `src/stores/visualRegressionStore.test.ts` : + +```ts +import { describe, it, expect, beforeEach } from "vitest"; +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 }); + }); +}); +``` + +- [ ] **Step 2: Lancer le test, vérifier l'échec** + +Run: `pnpm test src/stores/visualRegressionStore.test.ts` +Expected: FAIL (module introuvable). + +- [ ] **Step 3: Implémenter le store (calqué sur `billyPromptStore.ts`)** + +Créer `src/stores/visualRegressionStore.ts` : + +```ts +// 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, + }; +} +``` + +- [ ] **Step 4: Lancer le test, vérifier le succès** + +Run: `pnpm test src/stores/visualRegressionStore.test.ts` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/stores/visualRegressionStore.ts src/stores/visualRegressionStore.test.ts +git commit -m "feat(settings): visual regression thresholds store" +``` + +--- + +### Task 8: Bindings IPC front + types + +**Files:** +- Modify: `src/lib/ipc.ts` (ajouter `compareScreenshots`, `resolveComparison` dans `ipc`) +- Create: `src/types/visualRegression.ts` + +**Interfaces:** +- Consumes: commandes Rust `compare_screenshots`, `resolve_comparison`. +- Produces: + ```ts + // src/types/visualRegression.ts + 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[]; + } + + // ajoutés à l'objet `ipc` : + compareScreenshots(args: { + workspace: string; flowPath: string; model: string; width: number; height: number; + tolerance: number; threshold: number; runId: string; + }): Promise; + resolveComparison(args: { + workspace: string; runId: string; deviceKey: string; name: string; decision: "keep" | "replace"; + }): Promise; + ``` + +- [ ] **Step 1: Créer les types** + +Créer `src/types/visualRegression.ts` avec l'en-tête licence et les types ci-dessus +(`ComparisonStatus`, `Comparison`, `RunReport`). + +- [ ] **Step 2: Ajouter les bindings dans `src/lib/ipc.ts`** + +Importer les types en haut du fichier : + +```ts +import type { RunReport } from "@/types/visualRegression"; +``` + +Dans l'objet `ipc` (à côté de `runFlow`/`stopFlow`), ajouter — les clés d'argument correspondent +aux noms de paramètres Rust (Tauri convertit camelCase→snake_case automatiquement) : + +```ts + 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), +``` + +- [ ] **Step 3: Vérifier le typecheck** + +Run: `pnpm typecheck` +Expected: aucune erreur. + +- [ ] **Step 4: Commit** + +```bash +git add src/lib/ipc.ts src/types/visualRegression.ts +git commit -m "feat(ipc): bindings + types for screenshot comparison" +``` + +--- + +### Task 9: Section Paramètres « Régression visuelle » + +**Files:** +- Create: `src/components/settings/VisualRegressionSettings.tsx` +- Modify: `src/components/settings/sections.tsx` (ajouter l'entrée + import) + +**Interfaces:** +- Consumes: `useVisualRegressionStore`, `DEFAULT_TOLERANCE`, `DEFAULT_THRESHOLD`, `SettingsSection`. + +- [ ] **Step 1: Créer le composant (calqué sur `BillySettings.tsx`)** + +Créer `src/components/settings/VisualRegressionSettings.tsx` : + +```tsx +// 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 ( + +
+ + +
+ +
+
+
+ ); +} +``` + +- [ ] **Step 2: Enregistrer la section** + +Dans `src/components/settings/sections.tsx`, importer en haut : + +```tsx +import { VisualRegressionSettings } from "@/components/settings/VisualRegressionSettings"; +``` + +et ajouter dans `SETTINGS_SECTIONS` (avant `about`) : + +```tsx + { id: "visual-regression", label: "Régression visuelle", render: () => }, +``` + +- [ ] **Step 3: Vérifier typecheck + lint** + +Run: `pnpm typecheck && pnpm lint` +Expected: aucune erreur. + +- [ ] **Step 4: Commit** + +```bash +git add src/components/settings/VisualRegressionSettings.tsx src/components/settings/sections.tsx +git commit -m "feat(settings): visual regression thresholds section" +``` + +--- + +### Task 10: Store de revue + orchestration post-run + +**Files:** +- Create: `src/stores/reviewStore.ts` +- Modify: `src/App.tsx` (handler `onRunnerExit`, lignes ~131-141 ; et accès au flow courant) + +**Interfaces:** +- Consumes: `ipc.compareScreenshots`, `effectiveThresholds`, `RunReport`, stores workspace/device, chemin du flow courant. +- Produces: + ```ts + export interface ReviewState { + report: RunReport | null; + queue: string[]; // noms des comparaisons à revoir (status changed/dimension_mismatch) + open: boolean; + setReport: (r: RunReport | null) => void; + next: () => void; // retire la tête de queue ; ferme si vide + close: () => void; + } + export const useReviewStore = create()(...); + ``` + +- [ ] **Step 1: Créer le store de revue** + +Créer `src/stores/reviewStore.ts` : + +```ts +// 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: [] }), +})); +``` + +- [ ] **Step 2: Identifier le flow courant dans `App.tsx`** + +Repérer comment `onRun` connaît le fichier de flow lancé (probablement via `runStore`/`workspaceStore`, +ex. `useWorkspaceStore.getState().lastOpenFile`). Utiliser ce chemin comme `flowPath`. Si plusieurs +flows (Run All), le handler s'exécute pour le flow effectivement lancé — réutiliser la même source que +`ipc.runFlow(filePath, ...)`. + +- [ ] **Step 3: Brancher la comparaison sur `onRunnerExit`** + +Dans `src/App.tsx`, dans le handler `events.onRunnerExit(({ code }) => { ... })`, après `setStopped(code)`, +ajouter (n'agir que si succès et contexte disponible) : + +```ts + if (code === 0) { + const ws = useWorkspaceStore.getState().folderPath; + const device = useDeviceStore.getState().current; + const flowPath = useWorkspaceStore.getState().lastOpenFile; // même source que runFlow + if (ws && device && flowPath) { + const { tolerance, threshold } = effectiveThresholds(); + const runId = String(useRunStore.getState().pid ?? Date.now()); + ipc + .compareScreenshots({ + workspace: ws, + flowPath, + model: device.model, + width: device.screen_width, + height: device.screen_height, + tolerance, + threshold, + runId, + }) + .then((report) => useReviewStore.getState().setReport(report)) + .catch((err) => console.error("compare_screenshots failed", err)); + } + } +``` + +Ajouter les imports nécessaires en haut de `App.tsx` : + +```ts +import { useReviewStore } from "@/stores/reviewStore"; +import { effectiveThresholds } from "@/stores/visualRegressionStore"; +import { useWorkspaceStore } from "@/stores/workspaceStore"; +import { useDeviceStore } from "@/stores/deviceStore"; +// `ipc`, `useRunStore` sont déjà importés ; sinon les ajouter. +``` + +> `runId` = PID du run (déjà unique et présent dans `runStore`). À défaut, `Date.now()`. + +- [ ] **Step 4: Vérifier typecheck** + +Run: `pnpm typecheck` +Expected: aucune erreur. + +- [ ] **Step 5: Commit** + +```bash +git add src/stores/reviewStore.ts src/App.tsx +git commit -m "feat(review): trigger comparison on successful run, queue reviewable diffs" +``` + +--- + +### Task 11: Modale de revue `ScreenshotReview` + +**Files:** +- Create: `src/components/ScreenshotReview.tsx` +- Modify: `src/components/MainView.tsx` (monter `` à côté de `RunConsole`, ~ligne 296) + +**Interfaces:** +- Consumes: `useReviewStore`, `ipc.resolveComparison`, `useWorkspaceStore`, `Comparison`. +- Produces: composant sans props `export function ScreenshotReview()`. + +- [ ] **Step 1: Créer la modale** + +Créer `src/components/ScreenshotReview.tsx`. Affiche la comparaison en tête de `queue` : +gauche = banque (`bank_b64`), droite = nouvelle (`new_b64`) avec la bbox encadrée ; toggle overlay diff +(`diff_b64`). Deux actions appellent `resolveComparison` puis `next()`. + +```tsx +// Copyright (c) 2026 Ethan Morisset +// SPDX-License-Identifier: BUSL-1.1 + +import { useState } from "react"; + +import { Button } from "@/components/ui/Button"; +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 [showDiff, setShowDiff] = useState(true); // overlay diff visible par défaut + + 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(false); + next(); + } + }; + + const bbox = comp.bbox; + + return ( +
+
+
+

+ Régression visuelle — « {name} » ({queue.length} restant{queue.length > 1 ? "s" : ""}) +

+ +
+ +
+
+
Banque (référence)
+ banque +
+
+
+ Nouvelle capture {bbox ? "(zone changée en rouge via l'overlay)" : ""} +
+ nouvelle +
+
+ +
+ + +
+
+
+ ); +} +``` + +> Localisation du changement : en v1, l'**overlay diff** (pixels rouges, visible par défaut) est le +> moyen visuel principal de repérer la zone qui a varié — robuste quel que soit le scaling +> `object-contain`. La `bbox` est conservée dans le rapport (utile pour une v2 qui dessinerait un +> encadré à l'échelle), mais pas rendue comme rectangle superposé en v1. + +- [ ] **Step 2: Monter la modale** + +Dans `src/components/MainView.tsx`, près du `` (~ligne 296), ajouter en frère : + +```tsx + +``` + +et l'import en haut : + +```tsx +import { ScreenshotReview } from "@/components/ScreenshotReview"; +``` + +- [ ] **Step 3: Vérifier typecheck + lint + build** + +Run: `pnpm typecheck && pnpm lint && pnpm test` +Expected: aucune erreur ; tests existants + nouveaux PASS. + +- [ ] **Step 4: Commit** + +```bash +git add src/components/ScreenshotReview.tsx src/components/MainView.tsx +git commit -m "feat(review): screenshot comparison review modal (keep/replace)" +``` + +--- + +## Vérification finale (manuelle, après toutes les tâches) + +- [ ] `cd src-tauri && cargo test` → tous les tests `bank::*` PASS. +- [ ] `pnpm test && pnpm typecheck && pnpm lint` → OK. +- [ ] `pnpm tauri:dev` : ouvrir un workspace, connecter un device, lancer un flow contenant + `takeScreenshot: `. + - 1er run (banque vide) → aucune modale ; `/maestro/bank//.png` créé. + - Modifier l'app pour provoquer un changement visuel, relancer → modale de revue s'ouvre + (gauche banque / droite nouvelle + overlay diff). + - « Remplacer » → `bank//.png` mis à jour. « Garder » → banque inchangée. + - Run en échec (exit ≠ 0) → aucune modale, aucune écriture banque. +- [ ] Paramètres → section « Régression visuelle » : modifier les seuils, vérifier la persistance + (recharger l'app), « Réinitialiser » revient aux défauts. +``` diff --git a/docs/superpowers/specs/2026-06-29-screenshot-bank-regression-design.md b/docs/superpowers/specs/2026-06-29-screenshot-bank-regression-design.md new file mode 100644 index 0000000..414de74 --- /dev/null +++ b/docs/superpowers/specs/2026-06-29-screenshot-bank-regression-design.md @@ -0,0 +1,226 @@ +# Banque de screenshots & détection de régression visuelle — Design + +**Date :** 2026-06-29 +**Statut :** Design (premier jet, local uniquement) +**Portée :** Maestro Deck, fonctionnalité locale (pas de cloud/sync pour l'instant) + +## Objectif + +Éviter les régressions visuelles utilisateur entre deux exécutions d'un flow Maestro. +Le principe : maintenir une **banque de screenshots de référence** (la « vérité ») par +device, et à chaque run comparer pixel par pixel les nouveaux screenshots à cette +référence. Toute divergence significative est signalée comme **régression** et soumise à +la revue de l'utilisateur, qui décide de garder la référence ou de la remplacer. + +## Décisions clés (verrouillées) + +| Sujet | Décision | +|-------|----------| +| Source des screenshots | Commandes `takeScreenshot: ` du flow YAML ; `` = clé de matching | +| Algorithme de diff | Pixelmatch-like : seuil % de pixels changés + tolérance couleur par pixel | +| Seuils | **Configurables** dans une nouvelle section des Paramètres (tolérance + seuil %) | +| Masques / zones ignorées | **Hors scope v1** (prévu v2) | +| Sémantique du refus | Garder la banque = **régression détectée** (signal d'alerte) ; banque inchangée | +| Sémantique de l'acceptation | Remplacer = la nouvelle capture devient la vérité (écrase la banque) | +| Portée | **Tout** run d'un flow (single + Run All), **uniquement si le run réussit** (exit 0) | +| Run en échec | Exit ≠ 0 → **aucun** traitement de banque (ni comparaison ni seed) | +| Clé de banque | `_x` (modèle + résolution, sanitizé) | +| Emplacement | `/maestro/bank//` (créer `bank/` si `maestro/` existe déjà) | +| Récupération PNG | Non invasif : CWD du runner = **dossier du flow** ; collecte des `.png` sur place | +| Implémentation diff | **Rust** (crate `image`), le front ne reçoit que verdicts + chemins | +| Banque vide (1er run) | Seed : tous les screenshots deviennent la référence, sans revue | + +## Architecture + +``` +Run flow (single ou Run All) + │ + ▼ +[Rust] run_flow : CWD du runner = dossier du flow + │ + ▼ +Maestro exécute, écrit .png à côté du flow + │ + runner:exit + │ + ├─ exit ≠ 0 → STOP (aucun traitement de banque) + ▼ exit == 0 +[Rust] Comparateur de banque (seuils lus depuis les Paramètres) + ├─ parse YAML → liste des noms takeScreenshot attendus + ├─ résout clé device = _x + ├─ pour chaque .png : + │ ├─ pas de baseline → Seed (copie dans bank/) + │ ├─ dimensions ≠ → DimensionMismatch (flag) + │ ├─ diff > seuil → Changed (flag + diff.png + bbox) + │ ├─ diff ≤ seuil → Match + │ └─ attendu absent → Missing (flag) + └─ écrit maestro/.runs//report.json + │ + ▼ (event screenshot:report) +[Front] Panneau de revue (si ≥1 Changed/Missing/DimensionMismatch) + ├─ Gauche : banque (vérité) | Droite : nouvelle + bbox encadrée + ├─ Toggle overlay diff (rouge) + └─ Action : « Garder la banque » | « Remplacer par la nouvelle » + │ + ▼ +[Rust] resolve_comparison(name, decision) + ├─ Keep → enregistre régression dans report.json + └─ Replace → écrase bank//.png +``` + +## Composants + +### 1. Stockage de la banque + +``` +/maestro/ +├── bank/ +│ └── _x/ ex: iPhone15_1179x2556/ +│ ├── .bank.json { key, model, width, height, created_at } +│ ├── login.png +│ └── home.png +└── .runs/ + └── / + ├── report.json rapport de comparaison du run + └── diffs/.png images de diff générées (overlay rouge) +``` + +- Clé device = `sanitize(Device.model) + "_" + width + "x" + height`. Sanitize : + espaces, `/`, et caractères non-alphanumériques → `_`. +- `` provient de l'argument `takeScreenshot:` (string simple ou champ `path:`). +- Création paresseuse : `mkdir -p` de `maestro/bank//` au besoin (ne touche pas à + un `maestro/` existant au-delà d'y ajouter `bank/`). + +### 2. Pipeline de capture (Rust, runner) + +- `run_flow` fixe `Command.current_dir()` pour les variantes du + runner (android, ios-sim, ios-device, web). C'est la convention Maestro standard ; les + chemins relatifs des flows sont résolus correctement et les `takeScreenshot` atterrissent + à côté du flow de façon déterministe. +- Parse du YAML du flow pour extraire la **liste ordonnée des noms** `takeScreenshot` + (sert au matching et à détecter un `Missing`). +- Au `runner:exit` : **si exit ≠ 0, on s'arrête là** — pas de comparaison, pas de seed. + Un run en échec n'a aucune logique de traitement de banque (état non fiable). +- Si exit == 0, collecte des `.png` attendus dans le dossier du flow, puis comparaison. + +### 3. Moteur de comparaison (Rust, nouveau module `src-tauri/src/bank/`) + +- Décodage PNG via la crate `image`. +- Diff pixelmatch-like : + - Si dimensions ≠ → `DimensionMismatch` immédiat. + - Sinon, pour chaque pixel : distance couleur (YIQ ou euclidienne RGBA) au-delà d'une + **tolérance** → pixel compté comme changé. + - `changed_ratio = pixels_changés / total`. Si `changed_ratio > seuil` → `Changed`. + - Génère `diffs/.png` (overlay rouge sur les pixels changés) et le **bounding box** + englobant (x, y, w, h) pour l'encadré UI. +- **Seuils fournis par l'appel** (lus depuis les Paramètres côté front, passés à la commande + de comparaison) : + - `tolerance` : tolérance couleur par pixel (échelle pixelmatch, défaut 0.1) + - `threshold` : ratio de pixels changés au-delà duquel on flague (défaut 0.001 = 0.1 %) + - Si les Paramètres n'ont jamais été modifiés → valeurs par défaut embarquées. + +### 4. Modèle de résultat + +```rust +enum ComparisonStatus { Seeded, Match, Changed, Missing, DimensionMismatch } + +struct ScreenshotComparison { + name: String, + status: ComparisonStatus, + bank_path: Option, + new_path: Option, + diff_path: Option, + changed_ratio: f32, + bbox: Option<[u32; 4]>, // x, y, w, h +} + +struct RunReport { + run_id: String, + flow_path: String, + device_key: String, + comparisons: Vec, + regressions: Vec, // noms gardés en banque (régression confirmée) +} +``` + +- Émis vers le front via un event Tauri (`screenshot:report`) en fin de run. +- Persisté dans `maestro/.runs//report.json` pour la traçabilité. + +### 5. UI de revue (front React) + +- Nouveau composant `ScreenshotReview` ouvert en fin de run **si** ≥1 statut + `Changed | Missing | DimensionMismatch`. +- File d'attente si plusieurs : un screenshot à la fois. +- Layout : + - **Gauche** : image de la banque (la vérité). + - **Droite** : nouvelle capture, avec **bbox encadrée** sur la zone divergente. + - Toggle overlay diff (rouge) optionnel. +- Deux actions par screenshot : + - **Garder la banque** → `resolve_comparison(name, Keep)` : banque inchangée, régression + enregistrée dans le rapport. + - **Remplacer par la nouvelle** → `resolve_comparison(name, Replace)` : écrase + `bank//.png`. +- Les statuts `Seeded` ne déclenchent pas de revue : simple bandeau « N références créées ». +- `Match` : silencieux. + +### 6. Paramètres — section « Régression visuelle » (front) + +Suit le pattern existant (cf. prompt Billy : store Zustand dédié + `persist` localStorage, +section enregistrée dans `SETTINGS_SECTIONS`). + +- Nouveau store `src/stores/visualRegressionStore.ts`, clé localStorage + `maestro-deck.visual-regression` : + - `tolerance: number | null` (null → défaut 0.1) + - `threshold: number | null` (null → défaut 0.001) + - setters + `reset()` (remet à `null` = défauts embarqués, façon Billy). +- Nouveau composant `src/components/settings/VisualRegressionSettings.tsx` : + deux champs numériques (tolérance, seuil %) avec valeurs par défaut et bouton reset, + enveloppés dans ``. +- Enregistrement dans `src/components/settings/sections.tsx` : + `{ id: "visual-regression", label: "Régression visuelle", render: () => }`. +- Au lancement d'un run, le front lit `visualRegressionStore.getState()` et passe + `tolerance` + `threshold` (résolus avec leurs défauts) à la commande de comparaison. + +### 7. Commandes IPC (Rust ↔ front) + +- `run_flow` (existant) — étendu : fixe le CWD ; après l'exit, **si exit == 0**, déclenche la + comparaison avec les seuils reçus. +- `get_run_report(run_id) -> RunReport` — lire le rapport. +- `resolve_comparison(run_id, name, decision: Keep | Replace)` — applique la décision. + +## Gestion des erreurs / cas limites + +- **Run échoué** (exit ≠ 0) → **aucun** traitement de banque (ni comparaison, ni seed, ni + rapport). Un flow en erreur laisse un état device non fiable : pas de logique à appliquer. +- **PNG manquant** sur un run réussi (`takeScreenshot` dans le YAML mais pas de fichier + produit) → statut `Missing`. Signalé, pas bloquant. Cas rare puisque le flow a réussi. +- **YAML non parsable** → on ne bloque pas le run ; on collecte les PNG présents par leur + nom de fichier en best-effort, et on log un avertissement. +- **Nouvelle résolution / nouveau modèle** → nouvelle clé de banque → comportement seed + (1er run sur ce device-type). +- **Collisions de noms** (deux `takeScreenshot: login` dans le même flow) → hors scope v1 ; + on documente que les noms doivent être uniques par flow. + +## Tests + +- **Unit (Rust)** — moteur de diff : images identiques → `Match` ; 1 bloc modifié → + `Changed` + bbox correct ; dimensions ≠ → `DimensionMismatch` ; tolérance absorbe le + bruit anti-aliasing ; seed quand banque vide. +- **Unit (Rust)** — clé device : sanitize correct, mêmes model+résolution → même clé. +- **Unit (Rust)** — parse YAML : extraction des noms `takeScreenshot` (forme string et + forme `path:`). +- **Intégration** — `resolve_comparison(Replace)` écrase bien le fichier banque ; `Keep` + laisse la banque intacte et enregistre la régression. +- **Intégration** — run en échec (exit ≠ 0) → aucun rapport, aucune écriture dans `bank/`. +- **Unit** — seuils : `tolerance`/`threshold` reçus en paramètre modifient bien le verdict ; + store front résout `null` → défauts (0.1 / 0.001). +- **Front** — `ScreenshotReview` : file de plusieurs diffs, actions Keep/Replace, + rendu de la bbox et de l'overlay. + +## Hors scope (v2+) + +- Masques / zones à ignorer (horloge, barre de statut) par screenshot. +- Historique/diff entre runs au-delà de la dernière référence. +- Synchronisation cloud / partage d'équipe de la banque. +- Fallback de clé par UUID device. +``` diff --git a/package.json b/package.json index 8cb1b7f..d728f7e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "maestro-deck", "private": true, - "version": "0.5.5", + "version": "0.6.0", "type": "module", "description": "Source-available visual IDE for Maestro mobile tests", "license": "BUSL-1.1", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d2c6b59..92b607b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "maestro-deck" -version = "0.5.5" +version = "0.6.0" description = "Source-available visual IDE for Maestro mobile tests" authors = ["Ethan Morisset "] license = "BUSL-1.1" diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs index f1dd6b6..ecf3d98 100644 --- a/src-tauri/src/error.rs +++ b/src-tauri/src/error.rs @@ -45,9 +45,6 @@ pub enum AppError { #[error("Performance metrics collection failed: {0}")] MetricsFailed(String), - #[error("Performance metrics already running")] - MetricsAlreadyRunning, - #[error("iOS tooling missing: {0}")] IosToolMissing(String), diff --git a/src-tauri/src/ipc/commands.rs b/src-tauri/src/ipc/commands.rs index 881a2d9..720b711 100644 --- a/src-tauri/src/ipc/commands.rs +++ b/src-tauri/src/ipc/commands.rs @@ -953,6 +953,7 @@ pub async fn install_ios_device_bridge() -> AppResult { #[tauri::command] pub async fn run_flow( file_path: String, + app_id: Option, app: AppHandle, state: State<'_, AppState>, ) -> AppResult { @@ -962,12 +963,16 @@ pub async fn run_flow( .clone() .ok_or(AppError::NoDevice)?; + // Configured global APP_ID, forwarded to maestro as `-e APP_ID=…` so flows + // referencing `${APP_ID}` (the CI placeholder) run locally unchanged. + let app_id = app_id.as_deref(); + if device.platform == crate::device::Platform::Web { // Web flows run with no `--udid`; maestro targets the browser via the // flow's `url:` header. Pause the studio keeper so its browser doesn't // contend with the one `maestro test` launches. teardown_web(state.inner()).await; - return runner::spawn_web_runner(app, &file_path).await; + return runner::spawn_web_runner(app, &file_path, app_id).await; } if device.platform == crate::device::Platform::Ios { @@ -981,6 +986,7 @@ pub async fn run_flow( &device.serial, &file_path, crate::ios_session::PHYSICAL_BRIDGE_PORT, + app_id, ) .await; } @@ -1000,7 +1006,7 @@ pub async fn run_flow( state .ios_sim_run_active .store(true, std::sync::atomic::Ordering::SeqCst); - let spawned = runner::spawn_ios_runner(app, &device.serial, &file_path).await; + let spawned = runner::spawn_ios_runner(app, &device.serial, &file_path, app_id).await; if spawned.is_err() { state .ios_sim_run_active @@ -1027,7 +1033,7 @@ pub async fn run_flow( // With maestro 2.5.x, `maestro test` uses an adb-socket // (AdbSocketFactory) instead of a host TCP forward, so it cohabits // peacefully with our running studio. No cleanup needed. - runner::spawn_runner(app, &serial, &file_path, None).await + runner::spawn_runner(app, &serial, &file_path, app_id, None).await } #[tauri::command] @@ -1042,13 +1048,12 @@ pub fn list_workspace(path: String) -> AppResult { #[tauri::command] pub async fn start_metrics(app: AppHandle, state: State<'_, AppState>) -> AppResult<()> { - let serial = state + let device = state .connected_device .read() - .as_ref() - .map(|d| d.serial.clone()) + .clone() .ok_or(AppError::NoDevice)?; - metrics::start(app, serial).await + metrics::start(app, device).await } #[tauri::command] diff --git a/src-tauri/src/metrics/collector.rs b/src-tauri/src/metrics/collector.rs index 8d04d60..61a5faf 100644 --- a/src-tauri/src/metrics/collector.rs +++ b/src-tauri/src/metrics/collector.rs @@ -9,8 +9,8 @@ use std::time::Instant; use crate::device::adb; use crate::error::{AppError, AppResult}; use crate::metrics::parsers::{ - cpu_percent, parse_gfxinfo, parse_netstats_detail_for_uid, parse_proc_stat, parse_vm_rss_mb, - parse_xt_qtaguid_for_uid, NetBytes, ProcStat, + cpu_percent, parse_gfxinfo, parse_netstats_detail_for_uid, parse_proc_stat, + parse_thermal_status, parse_vm_rss_mb, parse_xt_qtaguid_for_uid, NetBytes, ProcStat, }; const ANDROID_USER_HZ: u32 = 100; @@ -33,6 +33,10 @@ pub struct NetSample { pub struct GfxSample { pub fps: f32, pub jank_pct: f32, + pub p50_ms: Option, + pub p90_ms: Option, + pub p95_ms: Option, + pub p99_ms: Option, } pub fn fetch_cpu_mem( @@ -158,7 +162,19 @@ pub fn fetch_gfx(serial: &str, package: &str) -> AppResult> { // Window is 5s — FPS is total/5. let fps = stats.total_frames as f32 / 5.0; let jank_pct = stats.janky_frames as f32 * 100.0 / stats.total_frames as f32; - Ok(Some(GfxSample { fps, jank_pct })) + Ok(Some(GfxSample { + fps, + jank_pct, + p50_ms: stats.p50_ms, + p90_ms: stats.p90_ms, + p95_ms: stats.p95_ms, + p99_ms: stats.p99_ms, + })) +} + +pub fn fetch_thermal(serial: &str) -> AppResult> { + let out = adb::exec_shell(serial, "dumpsys thermalservice")?; + Ok(parse_thermal_status(&out)) } #[cfg(test)] diff --git a/src-tauri/src/metrics/ios_sim.rs b/src-tauri/src/metrics/ios_sim.rs new file mode 100644 index 0000000..ce40d1b --- /dev/null +++ b/src-tauri/src/metrics/ios_sim.rs @@ -0,0 +1,130 @@ +// Copyright (c) 2026 Ethan Morisset +// SPDX-License-Identifier: BUSL-1.1 + +//! iOS simulator metrics. A simulated app runs as a host macOS process, so its +//! CPU% and resident memory are readable with `ps`. Collection commands live in +//! the collector loop (mod.rs); the pure `ps` parser lives here for testing. + +use std::process::Command; + +use crate::error::{AppError, AppResult}; + +/// Parse `ps -o %cpu=,rss= -p ` output → (cpu_pct, mem_mb). +/// macOS reports RSS in KiB. Returns None if the line can't be parsed (e.g. the +/// process has exited and ps printed nothing). +pub fn parse_ps_cpu_rss(s: &str) -> Option<(f32, f32)> { + let line = s.lines().map(str::trim).find(|l| !l.is_empty())?; + let mut cols = line.split_whitespace(); + let cpu: f32 = cols.next()?.parse().ok()?; + let rss_kib: f32 = cols.next()?.parse().ok()?; + Some((cpu, rss_kib / 1024.0)) +} + +/// Find the host PID + bundle id of the foreground app under test from +/// `simctl spawn launchctl list` output. Rows look like +/// `\t\tUIKitApplication:[hex][rb-legacy]`. +/// +/// The list mixes the app under test with Apple system apps and the Maestro +/// driver, so we skip any bundle starting with `com.apple.` or containing +/// `maestro-driver`, then take the LAST remaining row (most recently launched). +pub fn parse_launchctl_frontmost(launchctl_list: &str) -> Option<(u32, String)> { + const MARKER: &str = "UIKitApplication:"; + let mut found: Option<(u32, String)> = None; + for line in launchctl_list.lines() { + let Some(idx) = line.find(MARKER) else { + continue; + }; + let after = &line[idx + MARKER.len()..]; + let bundle = after.split('[').next().unwrap_or("").trim(); + if bundle.is_empty() + || bundle.starts_with("com.apple.") + || bundle.contains("maestro-driver") + { + continue; + } + let Some(pid) = line.split_whitespace().next().and_then(|t| t.parse().ok()) else { + continue; + }; + found = Some((pid, bundle.to_string())); + } + found +} + +/// Resolve the foreground simulated app's host PID + bundle id by shelling +/// `xcrun simctl spawn launchctl list` and parsing the result. +pub fn frontmost_pid_and_bundle(udid: &str) -> AppResult> { + let out = Command::new("xcrun") + .args(["simctl", "spawn", udid, "launchctl", "list"]) + .output() + .map_err(AppError::Io)?; + let text = String::from_utf8_lossy(&out.stdout); + Ok(parse_launchctl_frontmost(&text)) +} + +/// Sample CPU% and resident MB for a host PID via `ps`. +pub fn sample_cpu_mem(pid: u32) -> AppResult> { + let out = Command::new("ps") + .args(["-o", "%cpu=,rss=", "-p", &pid.to_string()]) + .output() + .map_err(AppError::Io)?; + let text = String::from_utf8_lossy(&out.stdout); + Ok(parse_ps_cpu_rss(&text)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_cpu_and_rss() { + let (cpu, mem) = parse_ps_cpu_rss(" 12.5 204800\n").expect("should parse"); + assert!((cpu - 12.5).abs() < 0.01, "cpu {cpu}"); + assert!((mem - 200.0).abs() < 0.01, "mem {mem}"); + } + + #[test] + fn none_on_empty() { + assert_eq!(parse_ps_cpu_rss("\n \n"), None); + } + + #[test] + fn none_on_garbage() { + assert_eq!(parse_ps_cpu_rss("not numbers here"), None); + } + + #[test] + fn frontmost_skips_system_and_driver_apps() { + let out = "\ +1729\t0\tUIKitApplication:com.apple.chrono.WidgetRenderer-Default[565c][rb-legacy] +2452\t0\tUIKitApplication:com.apple.mobilecal[ca73][rb-legacy] +2139\t0\tUIKitApplication:dev.mobile.maestro-driver-iosUITests.xctrunner[c7a5][rb-legacy] +1748\t0\tUIKitApplication:com.apple.Spotlight[b257][rb-legacy] +1900\t0\tUIKitApplication:com.example.myapp[abcd][rb-legacy] +"; + assert_eq!( + parse_launchctl_frontmost(out), + Some((1900, "com.example.myapp".to_string())) + ); + } + + #[test] + fn frontmost_none_when_only_system_apps() { + let out = "\ +1748\t0\tUIKitApplication:com.apple.Spotlight[b257][rb-legacy] +2139\t0\tUIKitApplication:dev.mobile.maestro-driver-iosUITests.xctrunner[c7a5][rb-legacy] +"; + assert_eq!(parse_launchctl_frontmost(out), None); + } + + #[test] + fn frontmost_picks_last_of_multiple_user_apps() { + let out = "\ +1900\t0\tUIKitApplication:com.example.first[aaaa][rb-legacy] +1950\t0\tUIKitApplication:com.example.second[bbbb][rb-legacy] +"; + assert_eq!( + parse_launchctl_frontmost(out), + Some((1950, "com.example.second".to_string())) + ); + } +} diff --git a/src-tauri/src/metrics/mod.rs b/src-tauri/src/metrics/mod.rs index d18b133..262dc99 100644 --- a/src-tauri/src/metrics/mod.rs +++ b/src-tauri/src/metrics/mod.rs @@ -10,6 +10,7 @@ pub mod collector; pub mod foreground; +pub mod ios_sim; pub mod parsers; use std::time::{Duration, Instant}; @@ -21,7 +22,7 @@ use tokio::sync::{oneshot, Mutex as AsyncMutex}; use tracing::{debug, info, warn}; use crate::error::{AppError, AppResult}; -use crate::metrics::collector::{fetch_cpu_mem, fetch_gfx, GfxSample}; +use crate::metrics::collector::{fetch_cpu_mem, fetch_gfx, fetch_thermal, GfxSample}; use crate::metrics::foreground::{resolve_target, Target}; use crate::metrics::parsers::ProcStat; @@ -45,6 +46,11 @@ pub struct MetricsSample { pub mem_mb: f32, pub fps: Option, pub jank_pct: Option, + pub frame_p50_ms: Option, + pub frame_p90_ms: Option, + pub frame_p95_ms: Option, + pub frame_p99_ms: Option, + pub thermal_status: Option, pub net_rx_kbps: f32, pub net_tx_kbps: f32, pub ts: u64, @@ -62,16 +68,46 @@ pub struct StoppedReason { pub message: Option, } -pub async fn start(app: AppHandle, serial: String) -> AppResult<()> { +pub async fn start(app: AppHandle, device: crate::device::Device) -> AppResult<()> { + use crate::device::Platform; let mut guard = RUNNING.lock().await; - if guard.is_some() { - return Err(AppError::MetricsAlreadyRunning); + // Replace any running collector. On a device switch the frontend can call + // start() before the previous stop()'s join has completed, so reject-if- + // running would spuriously fail. Cancel + join the predecessor while + // holding the lock so there is no overlap. Safe from deadlock: a cancelled + // loop exits via the cancel arm and does NOT self-clear RUNNING (see + // exit_by_cancel), so it never tries to lock RUNNING during this join. + if let Some((tx, join)) = guard.take() { + let _ = tx.send(()); + let _ = join.await; + } + match (device.platform, device.physical) { + (Platform::Android, _) => { + let (tx, rx) = oneshot::channel::<()>(); + let join = tokio::spawn(run_loop(app, device.serial, rx)); + *guard = Some((tx, join)); + info!("android metrics task started"); + Ok(()) + } + (Platform::Ios, false) => { + let (tx, rx) = oneshot::channel::<()>(); + let join = tokio::spawn(run_loop_ios_sim(app, device.serial, rx)); + *guard = Some((tx, join)); + info!("ios-sim metrics task started"); + Ok(()) + } + (Platform::Ios, true) => { + emit_unsupported( + &app, + "Per-app metrics aren't available on physical iPhones (requires Instruments).", + ); + Ok(()) + } + (Platform::Web, _) => { + emit_unsupported(&app, "The Web target has no per-app performance metrics."); + Ok(()) + } } - let (tx, rx) = oneshot::channel::<()>(); - let join = tokio::spawn(run_loop(app, serial, rx)); - *guard = Some((tx, join)); - info!("metrics task started"); - Ok(()) } pub async fn stop() -> AppResult<()> { @@ -91,15 +127,22 @@ async fn run_loop(app: AppHandle, serial: String, mut cancel: oneshot::Receiver< let mut target: Option = None; let mut prev_stat: Option<(ProcStat, Instant)> = None; let mut last_gfx: Option = None; + let mut last_thermal: Option = None; let mut last_fg_check = Instant::now() - FOREGROUND_CHECK_INTERVAL; let mut last_gfx_poll = Instant::now() - GFX_INTERVAL; let mut consecutive_errors: u32 = 0; + // Track whether exit was triggered by stop() so we can skip the self-clear. + // stop() already .take()s the RUNNING slot before signalling, so there is + // nothing left to clear for cancel exits; only self-exits (error bailout) + // need to null the slot. + let mut exit_by_cancel = false; let mut ticker = tokio::time::interval(TICK_INTERVAL); ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); loop { tokio::select! { _ = &mut cancel => { + exit_by_cancel = true; emit_stopped(&app, "user", None); break; } @@ -133,6 +176,7 @@ async fn run_loop(app: AppHandle, serial: String, mut cancel: oneshot::Receiver< ); prev_stat = None; last_gfx = None; + last_thermal = None; } target = Some(new_target); } @@ -200,6 +244,15 @@ async fn run_loop(app: AppHandle, serial: String, mut cancel: oneshot::Receiver< warn!(error = ?e, "gfx fetch failed"); } } + let serial_for_thermal = serial.clone(); + match tokio::task::spawn_blocking(move || fetch_thermal(&serial_for_thermal)) + .await + .unwrap_or_else(|e| { + Err(AppError::MetricsFailed(format!("thermal join failed: {e}"))) + }) { + Ok(t) => last_thermal = t, + Err(e) => warn!(error = ?e, "thermal fetch failed"), + } } if consecutive_errors >= MAX_CONSECUTIVE_ERRORS { @@ -215,6 +268,11 @@ async fn run_loop(app: AppHandle, serial: String, mut cancel: oneshot::Receiver< mem_mb: cpu_mem.mem_mb, fps: last_gfx.map(|g| g.fps), jank_pct: last_gfx.map(|g| g.jank_pct), + frame_p50_ms: last_gfx.and_then(|g| g.p50_ms), + frame_p90_ms: last_gfx.and_then(|g| g.p90_ms), + frame_p95_ms: last_gfx.and_then(|g| g.p95_ms), + frame_p99_ms: last_gfx.and_then(|g| g.p99_ms), + thermal_status: last_thermal, net_rx_kbps: 0.0, net_tx_kbps: 0.0, ts: ts_ms(), @@ -223,22 +281,117 @@ async fn run_loop(app: AppHandle, serial: String, mut cancel: oneshot::Receiver< } info!("metrics task exited"); - // Self-clear the RUNNING slot when the task exits without an explicit - // stop() — e.g. on error bailout. We spawn this because we cannot await - // the mutex from inside the task's own body without risking reentrancy - // if stop() is concurrently holding the lock. - tokio::spawn(async { - let mut guard = RUNNING.lock().await; - // If stop() already took the slot (normal shutdown), nothing to do. - // Otherwise clear so a new start() can proceed. - *guard = None; - }); + // Only self-clear on error/self-exit. When stop() was called it already + // .take()d the slot before signalling; self-clearing after a cancel exit + // would race with a subsequent start() that put a new task in the slot. + if !exit_by_cancel { + tokio::spawn(async { + *RUNNING.lock().await = None; + }); + } +} + +/// 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; + // See run_loop for rationale: skip self-clear when exiting via stop() cancel. + let mut exit_by_cancel = false; + let mut ticker = tokio::time::interval(TICK_INTERVAL); + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + + loop { + tokio::select! { + _ = &mut cancel => { + exit_by_cancel = true; + 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, + // Process exited between pid resolution and ps — benign, try again. + Ok(None) => continue, + Err(e) => { + warn!(error = ?e, "ios cpu_mem fetch failed"); + consecutive_errors += 1; + if consecutive_errors >= MAX_CONSECUTIVE_ERRORS { + emit_stopped(&app, "error", Some(e.to_string())); + break; + } + 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"); + if !exit_by_cancel { + 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 }); } +fn emit_unsupported(app: &AppHandle, message: &str) { + let _ = app.emit( + EVT_STOPPED, + StoppedReason { + reason: "unsupported", + message: Some(message.to_string()), + }, + ); +} + fn ts_ms() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) diff --git a/src-tauri/src/metrics/parsers.rs b/src-tauri/src/metrics/parsers.rs index f025d32..4b7ab93 100644 --- a/src-tauri/src/metrics/parsers.rs +++ b/src-tauri/src/metrics/parsers.rs @@ -485,6 +485,28 @@ Active UID stats: pub struct GfxStats { pub total_frames: u32, pub janky_frames: u32, + pub p50_ms: Option, + pub p90_ms: Option, + pub p95_ms: Option, + pub p99_ms: Option, +} + +/// Parse the `th percentile: ms` value for a given percentile label. +fn parse_percentile_ms(s: &str, label: &str) -> Option { + for line in s.lines() { + let t = line.trim(); + if let Some(rest) = t.strip_prefix(label) { + let digits: String = rest + .trim() + .chars() + .take_while(|c| c.is_ascii_digit() || *c == '.') + .collect(); + if let Ok(v) = digits.parse::() { + return Some(v); + } + } + } + None } pub fn parse_gfxinfo(s: &str) -> Option { @@ -501,6 +523,10 @@ pub fn parse_gfxinfo(s: &str) -> Option { Some(GfxStats { total_frames: total?, janky_frames: janky?, + p50_ms: parse_percentile_ms(s, "50th percentile:"), + p90_ms: parse_percentile_ms(s, "90th percentile:"), + p95_ms: parse_percentile_ms(s, "95th percentile:"), + p99_ms: parse_percentile_ms(s, "99th percentile:"), }) } @@ -536,4 +562,73 @@ Graphics info for pid 1234 [com.example.app] // Total present but janky missing — treat as None to avoid lying to UI assert!(parse_gfxinfo("Total frames rendered: 100\n").is_none()); } + + #[test] + fn parses_frame_time_percentiles() { + let input = "\ + Total frames rendered: 420 + Janky frames: 38 (9.05%) + 50th percentile: 8ms + 90th percentile: 14ms + 95th percentile: 17ms + 99th percentile: 23ms +"; + let s = parse_gfxinfo(input).expect("should parse"); + assert_eq!(s.p50_ms, Some(8.0)); + assert_eq!(s.p90_ms, Some(14.0)); + assert_eq!(s.p95_ms, Some(17.0)); + assert_eq!(s.p99_ms, Some(23.0)); + } + + #[test] + fn percentiles_none_when_absent() { + let input = " Total frames rendered: 10\n Janky frames: 1 (10.00%)\n"; + let s = parse_gfxinfo(input).expect("should parse"); + assert_eq!(s.p50_ms, None); + assert_eq!(s.p99_ms, None); + } +} + +/// Parse the device thermal status code from `dumpsys thermalservice`. +/// Handles both the "Thermal Status: N" line and the "mStatus=N" form. +pub fn parse_thermal_status(s: &str) -> Option { + for line in s.lines() { + let t = line.trim(); + for marker in ["Thermal Status:", "mStatus="] { + if let Some(idx) = t.find(marker) { + let rest = &t[idx + marker.len()..]; + let digits: String = rest + .trim_start() + .chars() + .take_while(|c| c.is_ascii_digit()) + .collect(); + if let Ok(v) = digits.parse::() { + return Some(v); + } + } + } + } + None +} + +#[cfg(test)] +mod tests_thermal { + use super::*; + + #[test] + fn parses_thermal_status_label_form() { + let dump = "Thermal Status: 2\nCached temperatures:\n ...\n"; + assert_eq!(parse_thermal_status(dump), Some(2)); + } + + #[test] + fn parses_mstatus_form() { + let dump = "IsStatusOverride: false\nmStatus=0\n"; + assert_eq!(parse_thermal_status(dump), Some(0)); + } + + #[test] + fn none_when_absent() { + assert_eq!(parse_thermal_status("no thermal data here"), None); + } } diff --git a/src-tauri/src/runner/mod.rs b/src-tauri/src/runner/mod.rs index a1dcefc..f8c30c2 100644 --- a/src-tauri/src/runner/mod.rs +++ b/src-tauri/src/runner/mod.rs @@ -51,6 +51,18 @@ fn maestro_bin() -> String { crate::tool_paths::maestro_bin() } +/// Build the `-e APP_ID=` args for a maestro `test` invocation, or an +/// empty vec when no app id is configured. This lets a single global APP_ID +/// (set in Settings) feed the `${APP_ID}` placeholder users keep in their CI +/// flow files, so those flows run locally without per-file edits. `-e` is a +/// `test`-subcommand option, so callers must insert these args *after* `test`. +fn app_id_env_args(app_id: Option<&str>) -> Vec { + match app_id.map(str::trim).filter(|s| !s.is_empty()) { + Some(value) => vec!["-e".to_string(), format!("APP_ID={value}")], + None => Vec::new(), + } +} + /// Spawn `maestro --udid test ` and stream stdout/stderr to /// the frontend via Tauri events. The PID is returned so the frontend can /// request a stop. @@ -63,10 +75,12 @@ pub async fn spawn_runner( app: AppHandle, serial: &str, flow_path: &str, + app_id: Option<&str>, on_exit: Option>, ) -> AppResult { let bin = maestro_bin(); info!(bin = %bin, serial, flow = %flow_path, "spawning maestro"); + let env_args = app_id_env_args(app_id); // Maestro 2.5.x's session manager calls `dadb.Dadb.list()` which // walks every adb-server transport before honoring `--udid` — any @@ -112,6 +126,7 @@ pub async fn spawn_runner( let mut child = Command::new(&bin) .no_window() .args(["--udid", serial, "test"]) + .args(&env_args) .arg(flow_path) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -185,14 +200,20 @@ pub async fn spawn_runner( /// Maestro targets the browser via the flow's `url:` header, and no adb /// emulator-ghost preamble (irrelevant to web). Streams stdout/stderr and /// emits `runner:exit` exactly like [`spawn_runner`]. -pub async fn spawn_web_runner(app: AppHandle, flow_path: &str) -> AppResult { +pub async fn spawn_web_runner( + app: AppHandle, + flow_path: &str, + app_id: Option<&str>, +) -> AppResult { let bin = maestro_bin(); info!(bin = %bin, flow = %flow_path, "spawning maestro (web)"); + let env_args = app_id_env_args(app_id); let mut child = Command::new(&bin) .no_window() // `-p web` is a global flag and must precede the `test` subcommand. .args(["-p", "web", "test"]) + .args(&env_args) .arg(flow_path) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -257,13 +278,20 @@ pub async fn spawn_web_runner(app: AppHandle, flow_path: &str) -> AppResult /// iOS). The caller stops the studio keeper first so `maestro test` can bring up /// its own XCTest driver on :22087 without contention. Streams stdout/stderr and /// emits `runner:exit` exactly like the other runners. -pub async fn spawn_ios_runner(app: AppHandle, udid: &str, flow_path: &str) -> AppResult { +pub async fn spawn_ios_runner( + app: AppHandle, + udid: &str, + flow_path: &str, + app_id: Option<&str>, +) -> AppResult { let bin = maestro_bin(); info!(bin = %bin, udid, flow = %flow_path, "spawning maestro (ios)"); + let env_args = app_id_env_args(app_id); let mut child = Command::new(&bin) .no_window() .args(["--udid", udid, "test"]) + .args(&env_args) .arg(flow_path) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -381,6 +409,7 @@ pub async fn spawn_ios_device_runner( udid: &str, flow_path: &str, port: u16, + app_id: Option<&str>, ) -> AppResult { let bin = maestro_bin(); if !maestro_supports_driver_host_port(&bin).await { @@ -392,10 +421,12 @@ 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 mut child = Command::new(&bin) .no_window() .args(["--driver-host-port", &port_str, "--device", udid, "test"]) + .args(&env_args) .arg(flow_path) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -478,4 +509,27 @@ mod tests { let res = kill_runner(987_654_321).await; assert!(matches!(res, Err(AppError::RunnerFailed(_)))); } + + #[test] + fn app_id_env_args_emits_e_flag_when_set() { + assert_eq!( + app_id_env_args(Some("com.example.app")), + vec!["-e".to_string(), "APP_ID=com.example.app".to_string()] + ); + } + + #[test] + fn app_id_env_args_trims_whitespace() { + assert_eq!( + app_id_env_args(Some(" com.example.app ")), + vec!["-e".to_string(), "APP_ID=com.example.app".to_string()] + ); + } + + #[test] + fn app_id_env_args_empty_when_none_or_blank() { + assert!(app_id_env_args(None).is_empty()); + assert!(app_id_env_args(Some("")).is_empty()); + assert!(app_id_env_args(Some(" ")).is_empty()); + } } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index d20956e..8f00856 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Maestro Deck", - "version": "0.5.5", + "version": "0.6.0", "identifier": "dev.blueshork.maestrodeck", "build": { "beforeDevCommand": "pnpm dev", diff --git a/src/App.tsx b/src/App.tsx index ebd102f..eaea44d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,6 +18,7 @@ import { applyTheme, watchSystemTheme } from "@/lib/theme"; import { useDeviceStore } from "@/stores/deviceStore"; import { useInspectorStore } from "@/stores/inspectorStore"; import { useMetricsStore } from "@/stores/metricsStore"; +import { usePanelsStore } from "@/stores/panelsStore"; import { useRunStore } from "@/stores/runStore"; import { useSettingsStore } from "@/stores/settingsStore"; import { useStreamStore } from "@/stores/streamStore"; @@ -146,6 +147,11 @@ export default function App() { memMb: p.mem_mb, fps: p.fps, jankPct: p.jank_pct, + frameP50: p.frame_p50_ms, + frameP90: p.frame_p90_ms, + frameP95: p.frame_p95_ms, + frameP99: p.frame_p99_ms, + thermalStatus: p.thermal_status, netRxKbps: p.net_rx_kbps, netTxKbps: p.net_tx_kbps, }), @@ -202,16 +208,23 @@ export default function App() { }; }, []); - const panelOpen = useMetricsStore((s) => s.panelOpen); - const perfEnabled = useSettingsStore((s) => s.perfMonitoringEnabled); - const deviceConnected = useDeviceStore((s) => Boolean(s.current)); - const currentPlatform = useDeviceStore((s) => s.current?.platform ?? null); + const metricsOpen = usePanelsStore((s) => s.visible.metrics); + // Use device identity (serial + platform + physical) rather than mere presence + // so that a direct A→B switch (where deviceConnected stays true) still causes + // the effect to re-run, stopping the old collector and starting a new one for + // the correct device. + const deviceKey = useDeviceStore((s) => + s.current ? `${s.current.serial}:${s.current.platform}:${String(s.current.physical)}` : null, + ); useEffect(() => { - if (!perfEnabled || !panelOpen || !deviceConnected || currentPlatform !== "android") { + if (!metricsOpen || !deviceKey) { void ipc.stopMetrics().catch(() => {}); return; } + // Clear any stale samples/package from a previous device so nothing is + // misattributed while we wait for the new collector's first sample. + useMetricsStore.getState().reset(); void ipc.startMetrics().catch((err) => { toast.error( "Performance monitoring failed to start", @@ -221,7 +234,7 @@ export default function App() { return () => { void ipc.stopMetrics().catch(() => {}); }; - }, [perfEnabled, panelOpen, deviceConnected, currentPlatform]); + }, [metricsOpen, deviceKey]); return ( <> diff --git a/src/components/MainView.tsx b/src/components/MainView.tsx index a8e5f67..7b7c984 100644 --- a/src/components/MainView.tsx +++ b/src/components/MainView.tsx @@ -23,7 +23,6 @@ import { buildPartialFlow } from "@/lib/partialFlow"; import { useShortcuts } from "@/lib/keyboard"; import { useChatStore } from "@/stores/chatStore"; import { useFlowStore } from "@/stores/flowStore"; -import { useMetricsStore } from "@/stores/metricsStore"; import { useInspectorStore } from "@/stores/inspectorStore"; import { usePanelsStore } from "@/stores/panelsStore"; import { useRunStore } from "@/stores/runStore"; @@ -52,8 +51,6 @@ export function MainView() { const runningPid = useRunStore((s) => s.pid); const streamEnabled = useSettingsStore((s) => s.streamEnabled); - const panelOpen = useMetricsStore((s) => s.panelOpen); - const perfEnabled = useSettingsStore((s) => s.perfMonitoringEnabled); const panels = usePanelsStore((s) => s.visible); const chatOpen = useChatStore((s) => s.isOpen); @@ -74,7 +71,7 @@ export function MainView() { // editor + device get the most room; the user can drag it taller and the // size persists. `BOTTOM_MIN` must match the `main-bottom` Panel's `minSize`. const BOTTOM_MIN = 10; - const bottomVisible = panels.console || (perfEnabled && panelOpen && panels.metrics); + const bottomVisible = panels.console || panels.metrics; const mainTopSize = bottomVisible ? 100 - BOTTOM_MIN : 100; const mainBottomSize = 100 - mainTopSize; @@ -94,7 +91,7 @@ export function MainView() { } resetSteps(); initSteps(parseFlow(content).steps); - const pid = await ipc.runFlow(path); + const pid = await ipc.runFlow(path, useSettingsStore.getState().appId); setRunning(pid); appendLog("system", `[runner started pid ${pid} · ${path}]`); } catch (err) { @@ -119,7 +116,7 @@ export function MainView() { const { content: c2 } = useFlowStore.getState(); resetSteps(); initSteps(parseFlow(c2).steps); - const pid = await ipc.runFlow(folder); + const pid = await ipc.runFlow(folder, useSettingsStore.getState().appId); setRunning(pid); appendLog("system", `[runner started pid ${pid} · all flows in ${folder}]`); } catch (err) { @@ -147,7 +144,7 @@ export function MainView() { })); resetSteps(); initSteps(remappedSteps); - const pid = await ipc.runFlow(tempPath); + const pid = await ipc.runFlow(tempPath, useSettingsStore.getState().appId); setRunning(pid); appendLog( "system", @@ -278,7 +275,7 @@ export function MainView() { - {panels.console || (perfEnabled && panelOpen && panels.metrics) ? ( + {panels.console || panels.metrics ? ( <> @@ -301,7 +298,7 @@ export function MainView() { ) : null} - {perfEnabled && panelOpen && panels.metrics ? ( + {panels.metrics ? ( <> {panels.console ? ( diff --git a/src/components/MetricsPanel.tsx b/src/components/MetricsPanel.tsx index f3659d2..e5dd9c2 100644 --- a/src/components/MetricsPanel.tsx +++ b/src/components/MetricsPanel.tsx @@ -6,17 +6,20 @@ import { X } from "lucide-react"; import { Button } from "@/components/ui/Button"; import { MetricsSparkline } from "@/components/MetricsSparkline"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/Tooltip"; +import { metricsForDevice, thermalLabel, type MetricCardId } from "@/lib/metricsCards"; import { useDeviceStore } from "@/stores/deviceStore"; import { useMetricsStore } from "@/stores/metricsStore"; +import { usePanelsStore } from "@/stores/panelsStore"; export function MetricsPanel() { - const setPanelOpen = useMetricsStore((s) => s.setPanelOpen); + const hidePanel = usePanelsStore((s) => s.hide); const pkg = useMetricsStore((s) => s.currentPackage); const samples = useMetricsStore((s) => s.samples); const stopped = useMetricsStore((s) => s.stoppedReason); const device = useDeviceStore((s) => s.current); const last = samples[samples.length - 1]; + const layout = device ? metricsForDevice(device.platform, device.physical) : null; return (
@@ -30,7 +33,7 @@ export function MetricsPanel() { - {perfEnabled && ( - - )} +