diff --git a/cli/src/commands/mod.rs b/cli/src/commands/mod.rs index 4c98a5b8..c3f6e15d 100644 --- a/cli/src/commands/mod.rs +++ b/cli/src/commands/mod.rs @@ -22,6 +22,7 @@ mod google; mod init; mod login; mod skills; +mod test; mod tui; #[derive(Debug, Default)] @@ -101,6 +102,8 @@ pub enum CommandGroup { Skills(skills::Skills), /// Start TUI session Tui, + /// This command group contains the commands to drive device simulators/emulators for app testing. + Test(test::Test), } impl ClapApp { @@ -131,6 +134,7 @@ impl ClapApp { .await } CommandGroup::Tui => handle_tui(channel).await, + CommandGroup::Test(test) => test.handle_command().await, }; // Check for CLI updates: @@ -186,7 +190,7 @@ fn get_context_without_application(clap_app: &ClapApp) -> Result { + &[$(($rel, include_str!(concat!("simclaw/", $rel)))),+] + }; +} + +const SIMCLAW_FILES: &[(&str, &str)] = simclaw_files!( + "bin/sim", + "lib/simclaw/bootstrap.sh", + "lib/simclaw/coords.sh", + "lib/simclaw/core.sh", + "lib/simclaw/device.sh", + "lib/simclaw/inspect.sh", + "lib/simclaw/layout_map.sh", + "lib/simclaw/login.sh", + "lib/simclaw/misc.sh", + "lib/simclaw/nav.sh", + "lib/simclaw/setup.sh", + "lib/simclaw/touch.sh", + "lib/simclaw/type.sh", + "lib/simclaw/wait.sh", + "lib/simclaw/wda.sh", + "lib/simclaw/swift/pickwindow.swift", +); + +const EXTRACT_SUBDIR: &str = "simclaw"; +const ENTRY_SCRIPT: &str = "bin/sim"; +const VERSION_MARKER: &str = ".wukong-version"; + +/// Upstream simClaw applies `snapshotMaxDepth=15, snapshotMaxChildren=25` when +/// creating the WDA session (see `lib/simclaw/wda.sh`). Those caps are too +/// tight for anything larger than a hello-world screen — Settings.app's root +/// alone has >25 children and the tree gets truncated before reaching the +/// cells, so `layout-map` / `find-element` / `tap-on` return empty. Post +/// permissive settings after the session is live. Remove this workaround +/// when the upstream script raises (or exposes) the caps. +const PERMISSIVE_SNAPSHOT_BODY: &str = + r#"{"settings":{"snapshotMaxDepth":60,"snapshotMaxChildren":200}}"#; + +pub struct IosBackend { + device: Option, + source_timeout: Option, +} + +impl IosBackend { + pub fn new(device: Option, source_timeout: Option) -> Self { + Self { + device, + source_timeout, + } + } + + /// Extract the bundled simClaw tree to `~/.config/wukong/scripts/simclaw/` + /// and return the path to its `bin/sim` entry script. A `.wukong-version` + /// marker lets us skip the 15 writes when the on-disk tree already matches + /// this build — upgrades still force a re-extract because the marker + /// disagrees. Only `bin/sim` needs the executable bit; the `.sh` helpers + /// are sourced, not invoked. + fn script_path() -> Result { + let extract_err = |e: std::io::Error| { + WKCliError::TestError(TestError::ScriptExtractionFailed(e.to_string())) + }; + + let mut root = dirs::home_dir().ok_or_else(|| { + WKCliError::TestError(TestError::ScriptExtractionFailed( + "could not resolve home directory".into(), + )) + })?; + root.extend([".config", "wukong", "scripts", EXTRACT_SUBDIR]); + let entry = root.join(ENTRY_SCRIPT); + let marker = root.join(VERSION_MARKER); + let expected_version = env!("CARGO_PKG_VERSION"); + + if std::fs::read_to_string(&marker).ok().as_deref() == Some(expected_version) + && entry.exists() + { + return Ok(entry); + } + + for (rel, contents) in SIMCLAW_FILES { + let dest = root.join(rel); + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent).map_err(extract_err)?; + } + std::fs::write(&dest, contents).map_err(extract_err)?; + } + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&entry, std::fs::Permissions::from_mode(0o755)) + .map_err(extract_err)?; + } + + std::fs::write(&marker, expected_version).map_err(extract_err)?; + + Ok(entry) + } + + fn build_command(&self, subcommand: &str, args: &[String]) -> Result { + let script = Self::script_path()?; + let mut cmd = Command::new("bash"); + cmd.arg(&script); + if let Some(device) = &self.device { + cmd.arg("--device").arg(device); + } + if let Some(t) = self.source_timeout { + cmd.arg("--source-timeout").arg(t.to_string()); + } + cmd.arg(subcommand); + for a in args { + cmd.arg(a); + } + Ok(cmd) + } + + /// `run_capture` is for commands whose stdout the Rust layer parses; + /// `run_streaming` is for commands whose output goes straight to the + /// user. Both forward stderr directly to the terminal. + async fn run_capture(&self, subcommand: &str, args: &[String]) -> Result { + let mut cmd = self.build_command(subcommand, args)?; + cmd.stdout(Stdio::piped()); + cmd.stderr(Stdio::inherit()); + + let mut child = cmd.spawn().map_err(WKCliError::Io)?; + let mut stdout = child + .stdout + .take() + .expect("stdout piped when building command"); + let mut collected = String::new(); + stdout + .read_to_string(&mut collected) + .await + .map_err(WKCliError::Io)?; + + let status = child.wait().await.map_err(WKCliError::Io)?; + if !status.success() { + return Err(WKCliError::TestError(TestError::ScriptFailed { + subcommand: subcommand.to_string(), + exit_code: status.code(), + })); + } + Ok(collected) + } + + async fn run_streaming(&self, subcommand: &str, args: &[String]) -> Result<(), WKCliError> { + let mut cmd = self.build_command(subcommand, args)?; + cmd.stdout(Stdio::inherit()); + cmd.stderr(Stdio::inherit()); + let status = cmd.status().await.map_err(WKCliError::Io)?; + if !status.success() { + return Err(WKCliError::TestError(TestError::ScriptFailed { + subcommand: subcommand.to_string(), + exit_code: status.code(), + })); + } + Ok(()) + } + + async fn run_json( + &self, + subcommand: &str, + args: &[String], + ) -> Result { + let stdout = self.run_capture(subcommand, args).await?; + // Some compound script commands (e.g. tap-and-wait) print progress + // lines like "READY: