From 96003b9f0ab09cc677f989aa97e853b1b7009f3b Mon Sep 17 00:00:00 2001 From: Fauzaan Date: Tue, 21 Apr 2026 07:35:32 +0000 Subject: [PATCH 01/11] Add wukong test command group for iOS simulator driving MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements `wukong test --platform ios ` as a Rust CLI layer that delegates to the simClaw bash backend. The backend script is bundled via include_str! and extracted to ~/.config/wukong/scripts/sim.sh on each invocation so a Wukong upgrade always ships the matching script. Command surface (18 commands): setup, start, status, doctor, teardown, activate, layout-map, title, tap, tap-on, swipe, scroll {up|down|to}, type, wait [--stable], find-element, hit-test, describe, screenshot. Architecture: PlatformBackend async trait with an IosBackend impl. Android slots in via the same trait and --platform flag without touching the iOS path. Three run helpers (run_streaming, run_capture, run_json) cover the output-shape variants; stderr is inherited so the user sees script output in real time. Script exit codes are surfaced as TestError::ScriptFailed. The bundled sim.sh is a placeholder today — the real simClaw script replaces it before release. Out of scope: Android backend, skills installer (separate RFC), JSON output for `status` (pending a --json flag from the script team). Co-Authored-By: Claude Opus 4.7 (1M context) --- cli/src/commands/mod.rs | 6 +- cli/src/commands/test/ios/mod.rs | 305 ++++++++++++++++++++++++++++++ cli/src/commands/test/ios/sim.sh | 19 ++ cli/src/commands/test/mod.rs | 261 +++++++++++++++++++++++++ cli/src/commands/test/platform.rs | 79 ++++++++ cli/src/error.rs | 17 ++ 6 files changed, 686 insertions(+), 1 deletion(-) create mode 100644 cli/src/commands/test/ios/mod.rs create mode 100644 cli/src/commands/test/ios/sim.sh create mode 100644 cli/src/commands/test/mod.rs create mode 100644 cli/src/commands/test/platform.rs diff --git a/cli/src/commands/mod.rs b/cli/src/commands/mod.rs index 8b98a93b..417288d4 100644 --- a/cli/src/commands/mod.rs +++ b/cli/src/commands/mod.rs @@ -21,6 +21,7 @@ mod dev; mod google; mod init; mod login; +mod test; mod tui; #[derive(Debug, Default)] @@ -98,6 +99,8 @@ pub enum CommandGroup { }, /// Start TUI session Tui, + /// This command group contains the commands to drive device simulators/emulators for app testing. + Test(test::Test), } impl ClapApp { @@ -123,6 +126,7 @@ impl ClapApp { CommandGroup::Config(config) => config.handle_command(), CommandGroup::Dev(dev) => dev.handle_command(self).await, CommandGroup::Tui => handle_tui(channel).await, + CommandGroup::Test(test) => test.handle_command().await, }; // Check for CLI updates: @@ -178,7 +182,7 @@ fn get_context_without_application(clap_app: &ClapApp) -> Result, + source_timeout: Option, +} + +impl IosBackend { + pub fn new(device: Option, source_timeout: Option) -> Self { + Self { + device, + source_timeout, + } + } + + /// The bundled sim.sh is rewritten on every call so a Wukong upgrade + /// always ships the matching backend — stale copies aren't possible. + /// chmod runs only on first creation; overwrites preserve the inode mode. + fn script_path() -> Result { + let extract_err = + |e: std::io::Error| WKCliError::TestError(TestError::ScriptExtractionFailed(e.to_string())); + + let mut dir = dirs::home_dir().ok_or_else(|| { + WKCliError::TestError(TestError::ScriptExtractionFailed( + "could not resolve home directory".into(), + )) + })?; + dir.extend([".config", "wukong", "scripts"]); + std::fs::create_dir_all(&dir).map_err(extract_err)?; + + let path = dir.join(SCRIPT_FILENAME); + let newly_created = !path.exists(); + std::fs::write(&path, SIM_SCRIPT).map_err(extract_err)?; + + #[cfg(unix)] + if newly_created { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)) + .map_err(extract_err)?; + } + + Ok(path) + } + + 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?; + serde_json::from_str::(stdout.trim()).map_err(|e| { + WKCliError::TestError(TestError::InvalidScriptOutput { + subcommand: subcommand.to_string(), + reason: e.to_string(), + }) + }) + } +} + +fn opt(value: &Option) -> Option { + value.map(|v| v.to_string()) +} + +#[async_trait] +impl PlatformBackend for IosBackend { + async fn setup(&self, app: &str, port: u16) -> Result<(), WKCliError> { + self.run_streaming("setup", &[app.to_string(), "--port".into(), port.to_string()]) + .await + } + + async fn wda_start(&self, port: u16) -> Result<(), WKCliError> { + self.run_streaming("wda-start", &[port.to_string()]).await + } + + async fn status(&self) -> Result<(), WKCliError> { + self.run_streaming("status", &[]).await + } + + async fn layout_map(&self) -> Result { + self.run_json("layout-map", &[]).await + } + + async fn tap(&self, x: f64, y: f64) -> Result<(), WKCliError> { + self.run_streaming("tap", &[x.to_string(), y.to_string()]) + .await + } + + async fn tap_element(&self, label: &str) -> Result<(), WKCliError> { + self.run_streaming("tap-element", &[label.to_string()]).await + } + + async fn tap_and_wait( + &self, + label: &str, + expected: Option<&str>, + timeout: u32, + ) -> Result { + // nav.sh reads `expected` as `${2:-}`, so an empty string is the + // script's "skip expected" sentinel. Timeout must stay in $3. + let expected = expected.unwrap_or(""); + self.run_json( + "tap-and-wait", + &[label.to_string(), expected.to_string(), timeout.to_string()], + ) + .await + } + + async fn swipe( + &self, + x1: f64, + y1: f64, + x2: f64, + y2: f64, + steps: u32, + step_ms: u32, + ) -> Result<(), WKCliError> { + self.run_streaming( + "swipe", + &[ + x1.to_string(), + y1.to_string(), + x2.to_string(), + y2.to_string(), + steps.to_string(), + step_ms.to_string(), + ], + ) + .await + } + + async fn scroll_up( + &self, + x: Option, + from_y: Option, + to_y: Option, + ) -> Result<(), WKCliError> { + let args: Vec = [opt(&x), opt(&from_y), opt(&to_y)] + .into_iter() + .flatten() + .collect(); + self.run_streaming("scroll-up", &args).await + } + + async fn scroll_down( + &self, + x: Option, + from_y: Option, + to_y: Option, + ) -> Result<(), WKCliError> { + let args: Vec = [opt(&x), opt(&from_y), opt(&to_y)] + .into_iter() + .flatten() + .collect(); + self.run_streaming("scroll-down", &args).await + } + + async fn scroll_to_visible(&self, label: &str, max_swipes: u32) -> Result<(), WKCliError> { + self.run_streaming( + "scroll-to-visible", + &[label.to_string(), max_swipes.to_string()], + ) + .await + } + + async fn type_text(&self, text: &str) -> Result<(), WKCliError> { + self.run_streaming("type", &[text.to_string()]).await + } + + async fn wait_for(&self, label: &str, timeout: u32) -> Result<(), WKCliError> { + self.run_streaming("wait-for", &[label.to_string(), timeout.to_string()]) + .await + } + + async fn wait_for_stable(&self, timeout: u32) -> Result<(), WKCliError> { + self.run_streaming("wait-for-stable", &[timeout.to_string()]) + .await + } + + async fn screen_title(&self) -> Result { + let out = self.run_capture("screen-title", &[]).await?; + Ok(out.trim().to_string()) + } + + async fn find_element(&self, label: &str) -> Result { + self.run_json("find-element", &[label.to_string()]).await + } + + async fn describe_point(&self, x: f64, y: f64) -> Result { + self.run_json("describe-point", &[x.to_string(), y.to_string()]) + .await + } + + async fn describe( + &self, + depth: Option, + interactive: bool, + ) -> Result { + let mut args = Vec::new(); + if let Some(d) = depth { + args.push(d.to_string()); + } + if interactive { + args.push("--interactive".into()); + } + self.run_json("describe", &args).await + } + + async fn screenshot(&self, output: &str) -> Result<(), WKCliError> { + self.run_streaming("screenshot", &[output.to_string()]).await + } + + async fn ensure_foreground_app(&self, bundle_id: Option<&str>) -> Result<(), WKCliError> { + let args: Vec = bundle_id.map(|b| vec![b.to_string()]).unwrap_or_default(); + self.run_streaming("ensure-foreground-app", &args).await + } + + async fn cleanup(&self) -> Result<(), WKCliError> { + self.run_streaming("cleanup", &[]).await + } + + async fn health_check(&self) -> Result<(), WKCliError> { + self.run_streaming("health-check", &[]).await + } +} diff --git a/cli/src/commands/test/ios/sim.sh b/cli/src/commands/test/ios/sim.sh new file mode 100644 index 00000000..56d4bdf2 --- /dev/null +++ b/cli/src/commands/test/ios/sim.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Placeholder for the simClaw bash backend. +# +# The real script (~3000 lines) is owned and maintained by a separate team and +# will be dropped in here before the `wukong test` feature ships. The Rust CLI +# layer (cli/src/commands/test/ios/mod.rs) extracts this file to +# ~/.config/wukong/scripts/sim.sh on first invocation and delegates every +# subcommand to it. +# +# Contract (see RFC: "iOS backend: bash delegate"): +# sim [--device ] [args...] +# Exit codes are mapped to WKCliError variants by the Rust layer. + +set -euo pipefail + +echo "wukong test ios backend is not yet installed." >&2 +echo "This is a placeholder. Replace cli/src/commands/test/ios/sim.sh with" >&2 +echo "the real simClaw script before release." >&2 +exit 127 diff --git a/cli/src/commands/test/mod.rs b/cli/src/commands/test/mod.rs new file mode 100644 index 00000000..57c34101 --- /dev/null +++ b/cli/src/commands/test/mod.rs @@ -0,0 +1,261 @@ +mod ios; +mod platform; + +use clap::{Args, Subcommand, ValueEnum}; +use serde::Serialize; + +use crate::{ + error::{TestError, WKCliError}, + output::colored_println, +}; + +use platform::PlatformBackend; + +#[derive(Debug, Clone, Copy, ValueEnum)] +pub enum Platform { + Ios, +} + +#[derive(Debug, Args)] +pub struct Test { + /// Target platform. + #[arg(long, value_enum)] + pub platform: Platform, + + /// Target device by ID (UDID for iOS, serial for Android). + /// Falls back to the most recently booted device when omitted. + #[arg(long)] + pub device: Option, + + /// Override the WDA /source fetch timeout (seconds). Raise when working + /// with large accessibility trees where layout-map exceeds the default 8s. + #[arg(long)] + pub source_timeout: Option, + + #[command(subcommand)] + pub subcommand: TestSubcommand, +} + +#[derive(Debug, Subcommand)] +pub enum TestSubcommand { + /// One-shot: boot simulator, build WDA, install app, start WDA, launch app. + Setup { + /// Path to .app bundle or bundle identifier. + app: String, + /// Automation server HTTP port. + #[arg(long, default_value_t = 8100)] + port: u16, + }, + /// Start the automation server (WebDriverAgent on iOS). + Start { + #[arg(long, default_value_t = 8100)] + port: u16, + }, + /// Print booted device info and screen bounds. + Status, + /// Verify the device, automation server, and host toolchain are healthy. + Doctor, + /// Kill stale automation server processes and clear cached session state. + Teardown, + /// Re-pin and activate the target app so it is in the foreground. + Activate { + /// Bundle identifier. Defaults to the script's configured default. + bundle_id: Option, + }, + /// Full structured JSON snapshot of the current screen. + LayoutMap, + /// Get the current screen title. + Title, + /// Tap at logical coordinates. + Tap { x: f64, y: f64 }, + /// Tap an element by accessibility label. Waits for the UI to settle and + /// returns the resulting layout-map unless --no-wait is set. + TapOn { + label: String, + /// Wait until this label appears before returning. + #[arg(long)] + wait_for: Option, + /// Seconds to wait for the post-tap transition. + #[arg(long, default_value_t = 5)] + timeout: u32, + /// Fire-and-forget: skip the wait and don't return a layout-map. + #[arg(long)] + no_wait: bool, + }, + /// Swipe between two points. + Swipe { + x1: f64, + y1: f64, + x2: f64, + y2: f64, + #[arg(long, default_value_t = 10)] + steps: u32, + #[arg(long, default_value_t = 50)] + step_ms: u32, + }, + /// Scroll the screen. + Scroll { + #[command(subcommand)] + kind: ScrollKind, + }, + /// Type text into the focused field. + Type { text: String }, + /// Block until a label appears, or (with --stable) until the UI stops changing. + Wait { + /// Accessibility label to wait for. Required unless --stable is set. + label: Option, + /// Wait for UI stability instead of a specific label. + #[arg(long)] + stable: bool, + /// Max seconds to wait. + #[arg(long, default_value_t = 10)] + timeout: u32, + }, + /// Find an element by label. + FindElement { label: String }, + /// Hit-test the element at a coordinate. + HitTest { x: f64, y: f64 }, + /// Dump the accessibility tree as JSON. + Describe { + depth: Option, + #[arg(short, long)] + interactive: bool, + }, + /// Capture a PNG screenshot. + Screenshot { output: String }, +} + +#[derive(Debug, Subcommand)] +pub enum ScrollKind { + /// Scroll up (reveals content above). + Up { + #[arg(long)] + x: Option, + #[arg(long)] + from: Option, + #[arg(long)] + to: Option, + }, + /// Scroll down (reveals content below). + Down { + #[arg(long)] + x: Option, + #[arg(long)] + from: Option, + #[arg(long)] + to: Option, + }, + /// Scroll until the given label is visible. + To { + label: String, + #[arg(long, default_value_t = 10)] + max_swipes: u32, + }, +} + +impl Test { + pub async fn handle_command(&self) -> Result { + let backend = self.backend(); + dispatch(backend.as_ref(), &self.subcommand).await + } + + fn backend(&self) -> Box { + match self.platform { + Platform::Ios => Box::new(ios::IosBackend::new( + self.device.clone(), + self.source_timeout, + )), + } + } +} + +async fn dispatch( + backend: &dyn PlatformBackend, + subcommand: &TestSubcommand, +) -> Result { + match subcommand { + TestSubcommand::Setup { app, port } => backend.setup(app, *port).await?, + TestSubcommand::Start { port } => backend.wda_start(*port).await?, + TestSubcommand::Status => backend.status().await?, + TestSubcommand::Doctor => backend.health_check().await?, + TestSubcommand::Teardown => backend.cleanup().await?, + TestSubcommand::Activate { bundle_id } => { + backend.ensure_foreground_app(bundle_id.as_deref()).await? + } + TestSubcommand::LayoutMap => print_json(&backend.layout_map().await?)?, + TestSubcommand::Title => println!("{}", backend.screen_title().await?), + TestSubcommand::Tap { x, y } => backend.tap(*x, *y).await?, + TestSubcommand::TapOn { + label, + wait_for, + timeout, + no_wait, + } => { + if *no_wait { + backend.tap_element(label).await?; + } else { + let layout = backend + .tap_and_wait(label, wait_for.as_deref(), *timeout) + .await?; + print_json(&layout)?; + } + } + TestSubcommand::Swipe { + x1, + y1, + x2, + y2, + steps, + step_ms, + } => { + backend + .swipe(*x1, *y1, *x2, *y2, *steps, *step_ms) + .await? + } + TestSubcommand::Scroll { kind } => match kind { + ScrollKind::Up { x, from, to } => backend.scroll_up(*x, *from, *to).await?, + ScrollKind::Down { x, from, to } => backend.scroll_down(*x, *from, *to).await?, + ScrollKind::To { label, max_swipes } => { + backend.scroll_to_visible(label, *max_swipes).await? + } + }, + TestSubcommand::Type { text } => backend.type_text(text).await?, + TestSubcommand::Wait { + label, + stable, + timeout, + } => match (label, *stable) { + (Some(l), false) => backend.wait_for(l, *timeout).await?, + (None, true) => backend.wait_for_stable(*timeout).await?, + (Some(_), true) => { + return Err(WKCliError::TestError(TestError::InvalidInvocation( + "wait: --stable is mutually exclusive with a label argument".into(), + ))); + } + (None, false) => { + return Err(WKCliError::TestError(TestError::InvalidInvocation( + "wait: provide a