diff --git a/libs/cua-driver/rust/Cargo.lock b/libs/cua-driver/rust/Cargo.lock index 86ab7a17f7..c6d7002ded 100644 --- a/libs/cua-driver/rust/Cargo.lock +++ b/libs/cua-driver/rust/Cargo.lock @@ -608,6 +608,7 @@ dependencies = [ "async-trait", "base64", "cua-driver-core", + "cua-driver-testkit", "cursor-overlay", "embed-resource", "flate2", @@ -646,6 +647,14 @@ dependencies = [ "windows 0.58.0", ] +[[package]] +name = "cua-driver-testkit" +version = "0.6.8" +dependencies = [ + "serde_json", + "windows 0.61.3", +] + [[package]] name = "cua-driver-uia" version = "0.6.8" diff --git a/libs/cua-driver/rust/Cargo.toml b/libs/cua-driver/rust/Cargo.toml index 35fc965f06..3c21dbf6d2 100644 --- a/libs/cua-driver/rust/Cargo.toml +++ b/libs/cua-driver/rust/Cargo.toml @@ -2,6 +2,7 @@ resolver = "2" members = [ "crates/cua-driver", + "crates/cua-driver-testkit", "crates/cua-driver-uia", "crates/cua-driver-core", "crates/platform-macos", diff --git a/libs/cua-driver/rust/crates/cua-driver-testkit/Cargo.toml b/libs/cua-driver/rust/crates/cua-driver-testkit/Cargo.toml new file mode 100644 index 0000000000..b030305eb6 --- /dev/null +++ b/libs/cua-driver/rust/crates/cua-driver-testkit/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "cua-driver-testkit" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +description = "Shared harness for cua-driver integration tests: MCP/CLI transports, child reaping, path + response helpers. Dev-dependency only — never shipped." +publish = false + +[dependencies] +serde_json = { workspace = true } + +[target.'cfg(target_os = "windows")'.dependencies] +windows = { version = "0.61", features = [ + "Win32_Foundation", + # Job Object: assign every spawned test child to a KILL_ON_JOB_CLOSE job so + # the OS terminates the whole tree when the test process dies for ANY reason. + "Win32_System_JobObjects", + "Win32_Security", + "Win32_System_Threading", +] } diff --git a/libs/cua-driver/rust/crates/cua-driver-testkit/src/cli.rs b/libs/cua-driver/rust/crates/cua-driver-testkit/src/cli.rs new file mode 100644 index 0000000000..f608966565 --- /dev/null +++ b/libs/cua-driver/rust/crates/cua-driver-testkit/src/cli.rs @@ -0,0 +1,74 @@ +//! CLI transport: a stateless `cua-driver call ` process per action. +//! +//! Each call is its own process — no state carries between calls (the property +//! that makes `set_config` disk-persistence observable here but not over MCP). +//! Args are piped via **stdin** rather than a positional arg, which the CLI +//! accepts and which dodges PowerShell 5.1's quote-stripping on JSON (see #1637). + +use std::io::Write; +use std::process::{Command, Stdio}; + +use serde_json::Value; + +use crate::driver::Driver; +use crate::paths::driver_binary; +use crate::response::ToolResponse; + +/// Drives cua-driver over the stateless CLI surface. +pub struct CliDriver { + bin: std::path::PathBuf, +} + +impl CliDriver { + pub fn new() -> Self { + CliDriver { bin: driver_binary() } + } + + /// Whether the driver binary exists (caller should skip the test if not). + pub fn available(&self) -> bool { + self.bin.exists() + } +} + +impl Default for CliDriver { + fn default() -> Self { + Self::new() + } +} + +impl Driver for CliDriver { + fn call(&mut self, tool: &str, args: Value) -> ToolResponse { + let mut child = match Command::new(&self.bin) + .arg("call") + .arg(tool) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + { + Ok(c) => c, + Err(e) => { + let msg = format!("spawn failed: {e}"); + return ToolResponse::new(msg, Value::Null, true, Value::Null); + } + }; + + if let Some(mut stdin) = child.stdin.take() { + let _ = writeln!(stdin, "{}", serde_json::to_string(&args).unwrap()); + } + let out = match child.wait_with_output() { + Ok(o) => o, + Err(e) => { + let msg = format!("wait failed: {e}"); + return ToolResponse::new(msg, Value::Null, true, Value::Null); + } + }; + + let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string(); + // The CLI prints structuredContent (pretty JSON) or plain text — parse + // when it's JSON, else keep it as text. + let structured = serde_json::from_str::(&stdout).unwrap_or(Value::Null); + let is_error = !out.status.success(); + ToolResponse::new(stdout, structured, is_error, Value::Null) + } +} diff --git a/libs/cua-driver/rust/crates/cua-driver-testkit/src/driver.rs b/libs/cua-driver/rust/crates/cua-driver-testkit/src/driver.rs new file mode 100644 index 0000000000..758ae9bf4b --- /dev/null +++ b/libs/cua-driver/rust/crates/cua-driver-testkit/src/driver.rs @@ -0,0 +1,15 @@ +//! The transport abstraction every test targets. + +use crate::response::ToolResponse; +use serde_json::Value; + +/// A way to invoke cua-driver tools. Implemented by [`crate::McpDriver`] +/// (long-lived server) and [`crate::CliDriver`] (stateless per-call process). +/// +/// Write scenarios against `Driver` to run them over either transport — the one +/// behavior that only surfaces across both is config persistence (`set_config` +/// is session-scoped over MCP but persists to disk over the CLI). +pub trait Driver { + /// Invoke `tool` with `args`, returning the normalized response. + fn call(&mut self, tool: &str, args: Value) -> ToolResponse; +} diff --git a/libs/cua-driver/rust/crates/cua-driver-testkit/src/lib.rs b/libs/cua-driver/rust/crates/cua-driver-testkit/src/lib.rs new file mode 100644 index 0000000000..d1af044e78 --- /dev/null +++ b/libs/cua-driver/rust/crates/cua-driver-testkit/src/lib.rs @@ -0,0 +1,46 @@ +//! Shared test harness for cua-driver integration tests. +//! +//! Before this crate, every `tests/*.rs` re-implemented the same machinery: +//! `workspace_root()` / `driver_binary()` (×9), the JSON-RPC-over-stdio client +//! (`send`/`call`/`init`, ×10), the Windows kill-on-close Job Object reaper +//! (×2), and the `result_text`/`is_error` response accessors. This crate is the +//! single home for all of it. +//! +//! ## Two transports, one shape +//! cua-driver is driven two ways, and a test should be able to target either: +//! - **MCP** ([`McpDriver`]) — one long-lived `cua-driver` server over stdio +//! JSON-RPC. State (e.g. `set_config`) persists for the connection. +//! Returns the `{"result":{"content",…,"structuredContent"}}` envelope. +//! - **CLI** ([`CliDriver`]) — a stateless `cua-driver call ` +//! process per action. Prints `structuredContent` (or text) directly, NOT +//! the JSON-RPC envelope. +//! +//! Both implement [`Driver`] and normalize their differing payloads into one +//! [`ToolResponse`], so a scenario reads `resp.text()` / `resp.structured()` / +//! `resp.is_error()` regardless of transport. This is what makes the +//! transport axis (CLI vs MCP) testable instead of MCP-only. +//! +//! ## Child hygiene +//! [`ChildReaper`] kills every spawned child on drop. On Windows it also assigns +//! them to a `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` job, so the OS reaps the whole +//! tree even on panic / SIGKILL / Ctrl-C — no orphaned windows or held ports. + +mod driver; +mod mcp; +mod cli; +mod paths; +mod reaper; +mod response; + +pub use driver::Driver; +pub use mcp::McpDriver; +pub use cli::CliDriver; +pub use paths::{driver_binary, harness_app, workspace_root}; +pub use reaper::{spawn_in_job, ChildReaper}; +pub use response::ToolResponse; + +use std::time::Duration; + +/// Hard ceiling on any single tool call: a hung driver becomes a fast, localized +/// failure instead of an indefinite wall-clock hang. +pub const CALL_TIMEOUT: Duration = Duration::from_secs(25); diff --git a/libs/cua-driver/rust/crates/cua-driver-testkit/src/mcp.rs b/libs/cua-driver/rust/crates/cua-driver-testkit/src/mcp.rs new file mode 100644 index 0000000000..ed5205f142 --- /dev/null +++ b/libs/cua-driver/rust/crates/cua-driver-testkit/src/mcp.rs @@ -0,0 +1,114 @@ +//! MCP transport: one long-lived `cua-driver` server over stdio JSON-RPC. + +use std::io::{BufRead, BufReader, Write}; +use std::process::{ChildStdin, Command, Stdio}; +use std::sync::mpsc::{channel, Receiver}; + +use serde_json::Value; + +use crate::driver::Driver; +use crate::paths::driver_binary; +use crate::reaper::{spawn_in_job, ChildReaper}; +use crate::response::ToolResponse; +use crate::CALL_TIMEOUT; + +/// A spawned cua-driver MCP server. State (e.g. `set_config`) persists for the +/// lifetime of this one connection — which is why config-scope tests drive a +/// single `McpDriver`. The server (and any apps spawned through [`reaper`]) are +/// reaped when this value drops. +/// +/// [`reaper`]: McpDriver::reaper +pub struct McpDriver { + reaper: ChildReaper, + stdin: ChildStdin, + rx: Receiver, + next_id: u32, +} + +impl McpDriver { + /// Spawn the driver, start the stdout reader thread, and `initialize`. + /// Returns `None` (with a skip message) if the binary isn't built — the + /// caller's test should early-return so an un-built binary skips, not fails. + pub fn spawn() -> Option { + let bin = driver_binary(); + if !bin.exists() { + eprintln!("[testkit] driver binary not built at {bin:?} — skipping"); + return None; + } + + let mut reaper = ChildReaper::new(); + let mut driver = spawn_in_job( + Command::new(&bin) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()), + ) + .inspect_err(|e| eprintln!("[testkit] driver spawn failed: {e}")) + .ok()?; + let stdin = driver.stdin.take().unwrap(); + let stdout = driver.stdout.take().unwrap(); + reaper.push(driver); + + let (tx, rx) = channel::(); + std::thread::spawn(move || { + let mut reader = BufReader::new(stdout); + let mut line = String::new(); + loop { + line.clear(); + match reader.read_line(&mut line) { + Ok(0) | Err(_) => break, + Ok(_) => { + if tx.send(line.trim().to_string()).is_err() { + break; + } + } + } + } + }); + + let mut d = McpDriver { reaper, stdin, rx, next_id: 2 }; + d.initialize(); + Some(d) + } + + fn initialize(&mut self) { + self.send(serde_json::json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {} + })); + let _ = self.rx.recv_timeout(CALL_TIMEOUT); + } + + fn send(&mut self, req: Value) { + let _ = writeln!(self.stdin, "{}", serde_json::to_string(&req).unwrap()); + let _ = self.stdin.flush(); + } + + /// Mutable access to the child reaper, e.g. to launch a target app whose + /// lifetime should be tied to this driver. + pub fn reaper(&mut self) -> &mut ChildReaper { + &mut self.reaper + } + + /// Raw JSON-RPC response envelope, for the rare assertion needing it. + pub fn call_raw(&mut self, tool: &str, args: Value) -> Value { + let id = self.next_id; + self.next_id += 1; + self.send(serde_json::json!({ + "jsonrpc": "2.0", "id": id, "method": "tools/call", + "params": { "name": tool, "arguments": args } + })); + match self.rx.recv_timeout(CALL_TIMEOUT) { + Ok(line) => serde_json::from_str(&line) + .unwrap_or_else(|_| serde_json::json!({ "error": format!("bad json: {line}") })), + Err(_) => serde_json::json!({ + "error": format!("TIMEOUT (>{}s) on {tool}", CALL_TIMEOUT.as_secs()) + }), + } + } +} + +impl Driver for McpDriver { + fn call(&mut self, tool: &str, args: Value) -> ToolResponse { + ToolResponse::from_mcp(self.call_raw(tool, args)) + } +} diff --git a/libs/cua-driver/rust/crates/cua-driver-testkit/src/paths.rs b/libs/cua-driver/rust/crates/cua-driver-testkit/src/paths.rs new file mode 100644 index 0000000000..1f3818ad08 --- /dev/null +++ b/libs/cua-driver/rust/crates/cua-driver-testkit/src/paths.rs @@ -0,0 +1,36 @@ +//! Filesystem paths, resolved at test runtime from `CARGO_MANIFEST_DIR`. +//! +//! When an integration test runs, Cargo sets `CARGO_MANIFEST_DIR` to the crate +//! under test (`crates/cua-driver`), so `workspace_root()` resolves the same +//! whether called from the test or from here. + +use std::path::PathBuf; + +/// The Rust workspace root (`libs/cua-driver/rust`). +pub fn workspace_root() -> PathBuf { + let manifest = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR"); + PathBuf::from(manifest) + .parent() + .unwrap() // crates/ + .parent() + .unwrap() // workspace root + .to_owned() +} + +/// The built `cua-driver` binary (`.exe` on Windows). One impl replaces the +/// four divergent spellings that were copy-pasted across the test files. +pub fn driver_binary() -> PathBuf { + let name = if cfg!(target_os = "windows") { + "cua-driver.exe" + } else { + "cua-driver" + }; + workspace_root().join("target/debug").join(name) +} + +/// A built harness app under `test-apps//` (produced by +/// `test-harness/build/{windows.ps1,macos.sh}`). Example: +/// `harness_app("harness-wpf", "CuaTestHarness.Wpf.exe")`. +pub fn harness_app(dir: &str, exe: &str) -> PathBuf { + workspace_root().join("test-apps").join(dir).join(exe) +} diff --git a/libs/cua-driver/rust/crates/cua-driver-testkit/src/reaper.rs b/libs/cua-driver/rust/crates/cua-driver-testkit/src/reaper.rs new file mode 100644 index 0000000000..a184c118bb --- /dev/null +++ b/libs/cua-driver/rust/crates/cua-driver-testkit/src/reaper.rs @@ -0,0 +1,148 @@ +//! Cross-platform child reaping. Kills every spawned child on drop; on Windows +//! also assigns them to a kill-on-close Job Object so the OS reaps the whole +//! tree even on panic / SIGKILL / Ctrl-C. + +use std::process::{Child, Command}; +use std::time::Duration; + +/// Owns spawned children and externally-discovered pids (e.g. a packaged app's +/// broker-launched window process), killing them all on drop. Prompt cleanup +/// between tests; the Windows Job Object is the hard-kill backstop. +pub struct ChildReaper { + children: Vec, + pids: Vec, +} + +impl ChildReaper { + pub fn new() -> Self { + ChildReaper { children: Vec::new(), pids: Vec::new() } + } + + /// Spawn `cmd` into the kill-on-close job (Windows) and own the child. + pub fn spawn(&mut self, cmd: &mut Command) -> std::io::Result<()> { + let child = spawn_in_job(cmd)?; + self.children.push(child); + Ok(()) + } + + /// Take ownership of an already-spawned child (assigning it to the job on + /// Windows). Use when you spawned via [`spawn_in_job`] to grab its pipes + /// first, then hand the remainder here. + pub fn push(&mut self, child: Child) { + #[cfg(target_os = "windows")] + win::assign_child(&child); + self.children.push(child); + } + + /// Track an external pid (and its whole tree) for teardown — packaged / + /// broker-launched window processes that aren't our direct child. + pub fn track_pid(&mut self, pid: u32) { + #[cfg(target_os = "windows")] + win::assign_pid(pid); + self.pids.push(pid); + } +} + +impl Default for ChildReaper { + fn default() -> Self { + Self::new() + } +} + +impl Drop for ChildReaper { + fn drop(&mut self) { + for &pid in &self.pids { + tree_kill(pid); + } + for c in &mut self.children { + let _ = c.kill(); + let _ = c.wait(); + } + std::thread::sleep(Duration::from_millis(250)); + } +} + +/// Spawn a command, assigning it to the kill-on-close job on Windows so it can +/// never outlive the test process. On other platforms a plain spawn (the +/// [`ChildReaper`] still kills it on drop). +pub fn spawn_in_job(cmd: &mut Command) -> std::io::Result { + let child = cmd.spawn()?; + #[cfg(target_os = "windows")] + win::assign_child(&child); + Ok(child) +} + +#[cfg(target_os = "windows")] +fn tree_kill(pid: u32) { + use std::process::Stdio; + let _ = Command::new("taskkill") + .args(["/F", "/T", "/PID", &pid.to_string()]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); +} + +#[cfg(not(target_os = "windows"))] +fn tree_kill(pid: u32) { + use std::process::Stdio; + let _ = Command::new("kill") + .args(["-9", &pid.to_string()]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); +} + +#[cfg(target_os = "windows")] +mod win { + use core::ffi::c_void; + use std::os::windows::io::AsRawHandle; + use std::process::Child; + use std::sync::OnceLock; + use windows::Win32::Foundation::{CloseHandle, HANDLE}; + use windows::Win32::System::JobObjects::{ + AssignProcessToJobObject, CreateJobObjectW, SetInformationJobObject, + JobObjectExtendedLimitInformation, JOBOBJECT_EXTENDED_LIMIT_INFORMATION, + JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + }; + use windows::Win32::System::Threading::{OpenProcess, PROCESS_SET_QUOTA, PROCESS_TERMINATE}; + + /// Process-global job handle (stored as usize so it's Send/Sync in the + /// OnceLock), created lazily with KILL_ON_JOB_CLOSE. + static JOB: OnceLock = OnceLock::new(); + + fn job() -> HANDLE { + let raw = *JOB.get_or_init(|| unsafe { + let h = CreateJobObjectW(None, windows::core::PCWSTR::null()).expect("CreateJobObjectW"); + let mut info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); + info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + let _ = SetInformationJobObject( + h, + JobObjectExtendedLimitInformation, + &info as *const _ as *const c_void, + std::mem::size_of::() as u32, + ); + h.0 as usize + }); + HANDLE(raw as *mut c_void) + } + + /// Assign one of our spawned children to the kill-on-close job. + pub(super) fn assign_child(child: &Child) { + unsafe { + let h = HANDLE(child.as_raw_handle() as *mut c_void); + let _ = AssignProcessToJobObject(job(), h); + } + } + + /// Assign an already-running pid (broker-spawned window process) to the job. + pub(super) fn assign_pid(pid: u32) { + unsafe { + if let Ok(h) = OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, false, pid) { + if !h.is_invalid() { + let _ = AssignProcessToJobObject(job(), h); + let _ = CloseHandle(h); + } + } + } + } +} diff --git a/libs/cua-driver/rust/crates/cua-driver-testkit/src/response.rs b/libs/cua-driver/rust/crates/cua-driver-testkit/src/response.rs new file mode 100644 index 0000000000..c9b2cfa579 --- /dev/null +++ b/libs/cua-driver/rust/crates/cua-driver-testkit/src/response.rs @@ -0,0 +1,54 @@ +//! Normalized tool response — the common shape both transports return. + +use serde_json::Value; + +/// A tool-call result, normalized across the MCP and CLI transports. +/// +/// MCP returns `{"result":{"content":[{"text":…}],"structuredContent":{…}, +/// "isError":bool}}`; the CLI prints `structuredContent` (or the text) directly. +/// Each transport builds a `ToolResponse` with the same accessors below, so test +/// assertions never branch on transport. +pub struct ToolResponse { + /// Human-readable text (the MCP `content[0].text`, or CLI stdout). + text: String, + /// The structured payload (`structuredContent`), or `Null` if none. + structured: Value, + /// Whether the call reported an error. + is_error: bool, + /// The raw underlying value, for the rare assertion that needs it. + pub raw: Value, +} + +impl ToolResponse { + pub(crate) fn new(text: String, structured: Value, is_error: bool, raw: Value) -> Self { + Self { text, structured, is_error, raw } + } + + /// Build from an MCP JSON-RPC response envelope. + pub(crate) fn from_mcp(raw: Value) -> Self { + let text = raw["result"]["content"][0]["text"] + .as_str() + .unwrap_or("") + .to_string(); + let structured = raw["result"]["structuredContent"].clone(); + let is_error = raw["result"]["isError"].as_bool().unwrap_or(false) + || raw.get("error").is_some(); + Self::new(text, structured, is_error, raw) + } + + /// The result text. Empty string when absent. + pub fn text(&self) -> &str { + &self.text + } + + /// The structured payload. `Null` when the tool returned none — index into + /// it directly (`resp.structured()["screen_width"]`). + pub fn structured(&self) -> &Value { + &self.structured + } + + /// Whether the call errored (MCP `isError`/`error`, or CLI nonzero exit). + pub fn is_error(&self) -> bool { + self.is_error + } +} diff --git a/libs/cua-driver/rust/crates/cua-driver/Cargo.toml b/libs/cua-driver/rust/crates/cua-driver/Cargo.toml index f836994f19..ecbbb9ec1f 100644 --- a/libs/cua-driver/rust/crates/cua-driver/Cargo.toml +++ b/libs/cua-driver/rust/crates/cua-driver/Cargo.toml @@ -73,6 +73,9 @@ portal-libei = ["platform-linux/portal-libei"] embed-resource = "2" [dev-dependencies] +# Shared integration-test harness (MCP/CLI transports, child reaping, paths, +# response accessors). Dev-only — never linked into the shipped driver. +cua-driver-testkit = { path = "../cua-driver-testkit" } tokio = { workspace = true, features = ["full"] } image = { workspace = true } base64 = { workspace = true } diff --git a/libs/cua-driver/rust/crates/cua-driver/tests/focus_check_test.rs b/libs/cua-driver/rust/crates/cua-driver/tests/focus_check_test.rs index bd2d4ea5b9..0cbc491811 100644 --- a/libs/cua-driver/rust/crates/cua-driver/tests/focus_check_test.rs +++ b/libs/cua-driver/rust/crates/cua-driver/tests/focus_check_test.rs @@ -2,45 +2,21 @@ //! //! Test strategy: //! 1. Open a "focus check window" (Terminal) and confirm it is focused. -//! 2. Start a cua-driver-rs MCP server process. +//! 2. Start a cua-driver MCP server (via the shared `McpDriver` testkit). //! 3. Send automation actions (click, type_text) to Calculator (a different app). //! 4. After each action, verify the Terminal window is STILL the active window. //! -//! This test requires: -//! - macOS (or Linux/Windows equivalents) -//! - Calculator app installed (com.apple.calculator on macOS) -//! - Accessibility permission granted +//! Requires: macOS, Calculator (com.apple.calculator), Accessibility permission, +//! and a built `cua-driver` (`cargo build` first). //! -//! Run with: cargo test --test focus_check_test -- --nocapture -//! Requires: `cargo build` first to produce the binary. - -use std::io::{BufRead, BufReader, Write}; -use std::process::{Command, Stdio}; -use std::time::Duration; -use std::thread; +//! Run with: cargo test --test focus_check_test focus_not_stolen -- --ignored --nocapture #[cfg(target_os = "macos")] mod macos_focus_tests { - use super::*; - - fn binary_path() -> std::path::PathBuf { - let manifest = std::env::var("CARGO_MANIFEST_DIR") - .expect("CARGO_MANIFEST_DIR not set"); - std::path::PathBuf::from(manifest) - .parent().unwrap() // crates/ - .parent().unwrap() // workspace root - .join("target/debug/cua-driver") - } - - fn send(stdin: &mut impl Write, req: serde_json::Value) { - writeln!(stdin, "{}", serde_json::to_string(&req).unwrap()).unwrap(); - } - - fn recv(stdout: &mut impl BufRead) -> serde_json::Value { - let mut line = String::new(); - stdout.read_line(&mut line).unwrap(); - serde_json::from_str(line.trim()).unwrap() - } + use cua_driver_testkit::{Driver, McpDriver}; + use std::process::Command; + use std::thread; + use std::time::Duration; /// Get the bundle ID of the current frontmost app via osascript. fn frontmost_bundle_id() -> String { @@ -69,14 +45,10 @@ mod macos_focus_tests { thread::sleep(Duration::from_secs(1)); } - fn find_calculator_pid(stdout: &mut impl BufRead, stdin: &mut impl Write) -> Option { - send(stdin, serde_json::json!({ - "jsonrpc": "2.0", "id": 100, "method": "tools/call", - "params": { "name": "list_apps", "arguments": {} } - })); - let resp = recv(stdout); + fn find_calculator_pid(driver: &mut McpDriver) -> Option { + let resp = driver.call("list_apps", serde_json::json!({})); // Use structuredContent.apps array (preferred over text parsing). - if let Some(apps) = resp["result"]["structuredContent"]["apps"].as_array() { + if let Some(apps) = resp.structured()["apps"].as_array() { for app in apps { let bundle = app["bundle_id"].as_str().unwrap_or(""); let name = app["name"].as_str().unwrap_or(""); @@ -88,12 +60,9 @@ mod macos_focus_tests { } } // Fallback: parse text content for older binary versions. - let text = resp["result"]["content"][0]["text"].as_str()?; - for line in text.lines() { + for line in resp.text().lines() { if line.contains("com.apple.calculator") || line.contains("Calculator") { - if let Some(pid_str) = line.split("(pid ").nth(1) - .and_then(|s| s.split(')').next()) - { + if let Some(pid_str) = line.split("(pid ").nth(1).and_then(|s| s.split(')').next()) { return pid_str.trim().parse().ok(); } } @@ -104,76 +73,45 @@ mod macos_focus_tests { #[test] #[ignore] // Run explicitly: cargo test --test focus_check_test focus_not_stolen -- --ignored --nocapture fn focus_not_stolen_during_calculator_click() { - let binary = binary_path(); - if !binary.exists() { - eprintln!("Binary not built. Run `cargo build` first."); - return; - } - // Setup: open Calculator in background, bring Terminal to front. open_calculator_background(); focus_terminal(); let initial_focus = frontmost_bundle_id(); println!("Initial focus: {}", initial_focus); - // Terminal or our test runner should be focused. - - // Start the MCP driver. - let mut child = Command::new(&binary) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .spawn() - .expect("spawn cua-driver-rs"); - let stdin = child.stdin.as_mut().unwrap(); - let mut stdout = BufReader::new(child.stdout.as_mut().unwrap()); - - // Initialize. - send(stdin, serde_json::json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{}})); - recv(&mut stdout); + // Start the MCP driver (skips if the binary isn't built). + let Some(mut driver) = McpDriver::spawn() else { return }; // Find Calculator. - let calc_pid = find_calculator_pid(&mut stdout, stdin); - let calc_pid = match calc_pid { + let calc_pid = match find_calculator_pid(&mut driver) { Some(p) => p, None => { eprintln!("Calculator not found in running apps"); - child.kill().ok(); return; } }; println!("Calculator pid: {}", calc_pid); // Get Calculator's window. - send(stdin, serde_json::json!({ - "jsonrpc": "2.0", "id": 200, "method": "tools/call", - "params": { - "name": "list_windows", - "arguments": { "pid": calc_pid } - } - })); - let resp = recv(&mut stdout); - let windows = resp["result"]["structuredContent"]["windows"].as_array() - .expect("windows array"); + let resp = driver.call("list_windows", serde_json::json!({ "pid": calc_pid })); + let windows = resp.structured()["windows"].as_array().expect("windows array"); if windows.is_empty() { eprintln!("No windows for Calculator"); - child.kill().ok(); return; } let window_id = windows[0]["window_id"].as_u64().unwrap() as u32; println!("Calculator window_id: {}", window_id); // Walk AX tree. - send(stdin, serde_json::json!({ - "jsonrpc": "2.0", "id": 300, "method": "tools/call", - "params": { - "name": "get_window_state", - "arguments": { "pid": calc_pid, "window_id": window_id, "capture_mode": "tree" } - } - })); - let resp = recv(&mut stdout); - println!("get_window_state: {}", resp["result"]["content"][0]["text"].as_str().unwrap_or("(none)").chars().take(200).collect::()); + let resp = driver.call( + "get_window_state", + serde_json::json!({ "pid": calc_pid, "window_id": window_id, "capture_mode": "tree" }), + ); + println!( + "get_window_state: {}", + resp.text().chars().take(200).collect::() + ); // Verify focus hasn't been stolen yet. let focus_after_get = frontmost_bundle_id(); @@ -183,21 +121,13 @@ mod macos_focus_tests { "get_window_state STOLE FOCUS! Was: {}, now: {}", initial_focus, focus_after_get ); - // Find a button element to click (element_index 1 is the Delete/AC button - // in Calculator's AX tree and supports AXPress). - // We use element_index=1 because [0] is the AXWindow itself (only raises). - send(stdin, serde_json::json!({ - "jsonrpc": "2.0", "id": 400, "method": "tools/call", - "params": { - "name": "click", - "arguments": { "pid": calc_pid, "window_id": window_id, "element_index": 1 } - } - })); - let resp = recv(&mut stdout); - let click_text = resp["result"]["content"][0]["text"].as_str().unwrap_or("?"); - println!("click result: {}", click_text); - // Accept either a successful click or a supported AX error — what matters is no crash/focus steal. - + // Click element_index 1 (a Calculator button supporting AXPress; [0] is + // the AXWindow itself, which only raises). + let resp = driver.call( + "click", + serde_json::json!({ "pid": calc_pid, "window_id": window_id, "element_index": 1 }), + ); + println!("click result: {}", resp.text()); thread::sleep(Duration::from_millis(200)); // CRITICAL: verify focus was not stolen. @@ -209,15 +139,8 @@ mod macos_focus_tests { ); // type_text also must not steal focus. - send(stdin, serde_json::json!({ - "jsonrpc": "2.0", "id": 500, "method": "tools/call", - "params": { - "name": "type_text", - "arguments": { "pid": calc_pid, "text": "5" } - } - })); - let resp = recv(&mut stdout); - println!("type_text result: {}", resp["result"]["content"][0]["text"].as_str().unwrap_or("?")); + let resp = driver.call("type_text", serde_json::json!({ "pid": calc_pid, "text": "5" })); + println!("type_text result: {}", resp.text()); thread::sleep(Duration::from_millis(200)); let focus_after_type = frontmost_bundle_id(); println!("Focus after type_text: {}", focus_after_type); @@ -227,6 +150,5 @@ mod macos_focus_tests { ); println!("✅ Focus was NOT stolen by click or type_text. Background automation confirmed."); - child.kill().ok(); } } diff --git a/libs/cua-driver/rust/crates/cua-driver/tests/harness_desktop_scope_test.rs b/libs/cua-driver/rust/crates/cua-driver/tests/harness_desktop_scope_test.rs index 268f453bbb..ff5a35c399 100644 --- a/libs/cua-driver/rust/crates/cua-driver/tests/harness_desktop_scope_test.rs +++ b/libs/cua-driver/rust/crates/cua-driver/tests/harness_desktop_scope_test.rs @@ -22,176 +22,50 @@ #![cfg(target_os = "windows")] -use core::ffi::c_void; -use std::io::{BufRead, BufReader, Write}; -use std::os::windows::io::AsRawHandle; -use std::path::PathBuf; -use std::process::{Child, ChildStdin, Command, Stdio}; -use std::sync::mpsc::{channel, Receiver}; -use std::sync::OnceLock; +use std::process::{Command, Stdio}; use std::time::{Duration, Instant}; -use windows::Win32::Foundation::{CloseHandle, HANDLE}; -use windows::Win32::System::JobObjects::{ - AssignProcessToJobObject, CreateJobObjectW, SetInformationJobObject, - JobObjectExtendedLimitInformation, JOBOBJECT_EXTENDED_LIMIT_INFORMATION, - JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, -}; -use windows::Win32::System::Threading::{OpenProcess, PROCESS_SET_QUOTA, PROCESS_TERMINATE}; +use cua_driver_testkit::{harness_app, Driver, McpDriver}; -const CALL_TIMEOUT: Duration = Duration::from_secs(25); - -// ── kill-on-close job object (same pattern as e2e_windows_bg_input_test) ─────── -static JOB: OnceLock = OnceLock::new(); -fn job() -> HANDLE { - let raw = *JOB.get_or_init(|| unsafe { - let h = CreateJobObjectW(None, windows::core::PCWSTR::null()).expect("CreateJobObjectW"); - let mut info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); - info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; - let _ = SetInformationJobObject( - h, JobObjectExtendedLimitInformation, - &info as *const _ as *const c_void, - std::mem::size_of::() as u32, - ); - h.0 as usize - }); - HANDLE(raw as *mut c_void) -} -fn spawn_in_job(cmd: &mut Command) -> std::io::Result { - let child = cmd.spawn()?; - unsafe { - let h = HANDLE(child.as_raw_handle() as *mut c_void); - let _ = AssignProcessToJobObject(job(), h); - } - Ok(child) -} -fn assign_pid_to_job(pid: u32) { - unsafe { - if let Ok(h) = OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, false, pid) { - if !h.is_invalid() { - let _ = AssignProcessToJobObject(job(), h); - let _ = CloseHandle(h); - } - } - } -} -struct ChildBag { children: Vec, pids: Vec } -impl ChildBag { - fn new() -> Self { ChildBag { children: Vec::new(), pids: Vec::new() } } - fn push(&mut self, c: Child) { self.children.push(c); } - fn track_pid(&mut self, pid: u32) { self.pids.push(pid); } -} -impl Drop for ChildBag { - fn drop(&mut self) { - for pid in &self.pids { - let _ = Command::new("taskkill").args(["/F", "/T", "/PID", &pid.to_string()]) - .stdout(Stdio::null()).stderr(Stdio::null()).status(); - } - for c in &mut self.children { let _ = c.kill(); let _ = c.wait(); } - std::thread::sleep(Duration::from_millis(250)); - } -} - -// ── paths ───────────────────────────────────────────────────────────────────── -fn workspace_root() -> PathBuf { - let manifest = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR"); - PathBuf::from(manifest).parent().unwrap().parent().unwrap().to_owned() -} -fn driver_binary() -> PathBuf { workspace_root().join("target/debug/cua-driver.exe") } /// WPF harness app (built by `test-harness/build/windows.ps1`). Path mirrors /// `shared/scenarios.json`'s `wpf.exe_relative_path`. -fn harness_wpf_exe() -> PathBuf { - workspace_root().join("test-apps/harness-wpf/CuaTestHarness.Wpf.exe") +fn harness_wpf_exe() -> std::path::PathBuf { + harness_app("harness-wpf", "CuaTestHarness.Wpf.exe") } -// ── JSON-RPC over the driver's stdio ────────────────────────────────────────── -fn send(stdin: &mut ChildStdin, req: serde_json::Value) { - let _ = writeln!(stdin, "{}", serde_json::to_string(&req).unwrap()); - let _ = stdin.flush(); -} -fn call(stdin: &mut ChildStdin, rx: &Receiver, id: u32, name: &str, - args: serde_json::Value) -> serde_json::Value { - send(stdin, serde_json::json!({ - "jsonrpc":"2.0","id":id,"method":"tools/call","params":{"name":name,"arguments":args} - })); - match rx.recv_timeout(CALL_TIMEOUT) { - Ok(line) => serde_json::from_str(&line) - .unwrap_or_else(|_| serde_json::json!({"error": format!("bad json: {line}")})), - Err(_) => serde_json::json!({"error": format!("TIMEOUT (>{}s) on {name}", CALL_TIMEOUT.as_secs())}), - } -} -fn init(stdin: &mut ChildStdin, rx: &Receiver) { - send(stdin, serde_json::json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{}})); - let _ = rx.recv_timeout(CALL_TIMEOUT); -} -fn result_text(s: &serde_json::Value) -> String { - s["result"]["content"][0]["text"].as_str().unwrap_or("").to_string() -} -fn is_error(s: &serde_json::Value) -> bool { - s["result"]["isError"].as_bool().unwrap_or(false) || s.get("error").is_some() -} -fn structured<'a>(s: &'a serde_json::Value) -> &'a serde_json::Value { - &s["result"]["structuredContent"] -} - -// ── fixture: one long-lived driver MCP server (session-scoped config) ───────── -struct Fixture { _bag: ChildBag, stdin: ChildStdin, rx: Receiver } - -fn spawn_driver() -> Option { - let driver_bin = driver_binary(); - if !driver_bin.exists() { - eprintln!("[desktop-scope] cua-driver.exe not built — skipping"); return None; - } - let mut bag = ChildBag::new(); - let mut driver = spawn_in_job( - Command::new(&driver_bin).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()) - ).inspect_err(|e| eprintln!("[desktop-scope] driver spawn failed: {e}")).ok()?; - let mut stdin = driver.stdin.take().unwrap(); - let stdout = driver.stdout.take().unwrap(); - bag.push(driver); - let (tx, rx) = channel::(); - std::thread::spawn(move || { - let mut reader = BufReader::new(stdout); - let mut line = String::new(); - loop { - line.clear(); - match reader.read_line(&mut line) { - Ok(0) | Err(_) => break, - Ok(_) => { if tx.send(line.trim().to_string()).is_err() { break; } } - } - } - }); - init(&mut stdin, &rx); - Some(Fixture { _bag: bag, stdin, rx }) -} - -/// Launch the WPF harness app and return (pid, window bounds center in screen px). +/// Launch the WPF harness app and return (pid, window center in screen px). /// Skips (returns None) if the harness app isn't built. -fn launch_wpf_and_center(fx: &mut Fixture) -> Option<(u32, i32, i32)> { +fn launch_wpf_and_center(driver: &mut McpDriver) -> Option<(u32, i32, i32)> { let exe = harness_wpf_exe(); if !exe.exists() { eprintln!("[desktop-scope] WPF harness not built ({exe:?}) — skipping window-target tests"); return None; } - let app = spawn_in_job(Command::new(&exe).stdout(Stdio::null()).stderr(Stdio::null())).ok()?; - fx._bag.push(app); + driver + .reaper() + .spawn(Command::new(&exe).stdout(Stdio::null()).stderr(Stdio::null())) + .ok()?; + let deadline = Instant::now() + Duration::from_secs(15); while Instant::now() < deadline { - let r = call(&mut fx.stdin, &fx.rx, 50, "list_windows", serde_json::json!({})); - if let Some(arr) = structured(&r)["windows"].as_array() { + let r = driver.call("list_windows", serde_json::json!({})); + if let Some(arr) = r.structured()["windows"].as_array() { for w in arr { let title = w["title"].as_str().unwrap_or(""); - if !title.contains("CuaTestHarness") { continue; } + if !title.contains("CuaTestHarness") { + continue; + } let pid = w["pid"].as_u64().unwrap_or(0) as u32; // #2018: bounds is nested {x,y,width,height} on Windows. let b = &w["bounds"]; let (x, y, ww, h) = ( - b["x"].as_i64().unwrap_or(0) as i32, b["y"].as_i64().unwrap_or(0) as i32, - b["width"].as_i64().unwrap_or(0) as i32, b["height"].as_i64().unwrap_or(0) as i32, + b["x"].as_i64().unwrap_or(0) as i32, + b["y"].as_i64().unwrap_or(0) as i32, + b["width"].as_i64().unwrap_or(0) as i32, + b["height"].as_i64().unwrap_or(0) as i32, ); if pid != 0 && ww > 0 && h > 0 { - assign_pid_to_job(pid); - fx._bag.track_pid(pid); + driver.reaper().track_pid(pid); return Some((pid, x + ww / 2, y + h / 2)); } } @@ -202,12 +76,18 @@ fn launch_wpf_and_center(fx: &mut Fixture) -> Option<(u32, i32, i32)> { None } -fn set_scope(fx: &mut Fixture, scope: &str) { - let r = call(&mut fx.stdin, &fx.rx, 10, "set_config", - serde_json::json!({"key": "capture_scope", "value": scope})); - assert!(!is_error(&r), "set_config capture_scope={scope} failed: {r}"); - assert_eq!(structured(&r)["capture_scope"].as_str(), Some(scope), - "set_config did not report capture_scope={scope}: {r}"); +fn set_scope(driver: &mut McpDriver, scope: &str) { + let r = driver.call( + "set_config", + serde_json::json!({ "key": "capture_scope", "value": scope }), + ); + assert!(!r.is_error(), "set_config capture_scope={scope} failed: {}", r.text()); + assert_eq!( + r.structured()["capture_scope"].as_str(), + Some(scope), + "set_config did not report capture_scope={scope}: {}", + r.text() + ); } // ── tests ───────────────────────────────────────────────────────────────────── @@ -218,13 +98,13 @@ fn set_scope(fx: &mut Fixture, scope: &str) { #[test] #[ignore] fn desktop_scope_capture_returns_screen_dims() { - let Some(mut fx) = spawn_driver() else { return }; - set_scope(&mut fx, "desktop"); - let r = call(&mut fx.stdin, &fx.rx, 20, "get_desktop_state", serde_json::json!({})); - assert!(!is_error(&r), "get_desktop_state errored: {r}"); - let sw = structured(&r)["screen_width"].as_u64().unwrap_or(0); - let sh = structured(&r)["screen_height"].as_u64().unwrap_or(0); - assert!(sw > 0 && sh > 0, "get_desktop_state returned no/zero screen size: {r}"); + let Some(mut driver) = McpDriver::spawn() else { return }; + set_scope(&mut driver, "desktop"); + let r = driver.call("get_desktop_state", serde_json::json!({})); + assert!(!r.is_error(), "get_desktop_state errored: {}", r.text()); + let sw = r.structured()["screen_width"].as_u64().unwrap_or(0); + let sh = r.structured()["screen_height"].as_u64().unwrap_or(0); + assert!(sw > 0 && sh > 0, "get_desktop_state returned no/zero screen size: {}", r.text()); eprintln!("[desktop-scope] get_desktop_state OK — screen {sw}x{sh}"); } @@ -233,21 +113,26 @@ fn desktop_scope_capture_returns_screen_dims() { #[test] #[ignore] fn desktop_scope_windowless_click_and_scroll_land() { - let Some(mut fx) = spawn_driver() else { return }; - set_scope(&mut fx, "desktop"); - let Some((_pid, cx, cy)) = launch_wpf_and_center(&mut fx) else { return }; - - let clicked = call(&mut fx.stdin, &fx.rx, 21, "click", serde_json::json!({"x": cx, "y": cy})); - assert!(!is_error(&clicked), "desktop-scope click errored: {clicked}"); - let ct = result_text(&clicked).to_lowercase(); - assert!(ct.contains("desktop scope"), "click not reported as desktop-scope: {}", result_text(&clicked)); - assert!(ct.contains("hwnd"), "click did not resolve a window via WindowFromPoint: {}", result_text(&clicked)); - - let scrolled = call(&mut fx.stdin, &fx.rx, 22, "scroll", - serde_json::json!({"x": cx, "y": cy, "direction": "down"})); - assert!(!is_error(&scrolled), "desktop-scope scroll errored: {scrolled}"); - assert!(result_text(&scrolled).to_lowercase().contains("desktop scope"), - "scroll not reported as desktop-scope: {}", result_text(&scrolled)); + let Some(mut driver) = McpDriver::spawn() else { return }; + set_scope(&mut driver, "desktop"); + let Some((_pid, cx, cy)) = launch_wpf_and_center(&mut driver) else { return }; + + let clicked = driver.call("click", serde_json::json!({ "x": cx, "y": cy })); + assert!(!clicked.is_error(), "desktop-scope click errored: {}", clicked.text()); + let ct = clicked.text().to_lowercase(); + assert!(ct.contains("desktop scope"), "click not reported as desktop-scope: {}", clicked.text()); + assert!(ct.contains("hwnd"), "click did not resolve a window via WindowFromPoint: {}", clicked.text()); + + let scrolled = driver.call( + "scroll", + serde_json::json!({ "x": cx, "y": cy, "direction": "down" }), + ); + assert!(!scrolled.is_error(), "desktop-scope scroll errored: {}", scrolled.text()); + assert!( + scrolled.text().to_lowercase().contains("desktop scope"), + "scroll not reported as desktop-scope: {}", + scrolled.text() + ); } /// Negative gate: a window-less screen-absolute click under `capture_scope=window` @@ -255,12 +140,13 @@ fn desktop_scope_windowless_click_and_scroll_land() { #[test] #[ignore] fn window_scope_rejects_windowless_click() { - let Some(mut fx) = spawn_driver() else { return }; - set_scope(&mut fx, "window"); - let r = call(&mut fx.stdin, &fx.rx, 30, "click", serde_json::json!({"x": 100, "y": 100})); - let txt = result_text(&r).to_lowercase(); + let Some(mut driver) = McpDriver::spawn() else { return }; + set_scope(&mut driver, "window"); + let r = driver.call("click", serde_json::json!({ "x": 100, "y": 100 })); + let txt = r.text().to_lowercase(); assert!( - is_error(&r) || txt.contains("desktop scope") || txt.contains("desktop_scope_disabled"), - "window-scope window-less click was NOT rejected: {r}" + r.is_error() || txt.contains("desktop scope") || txt.contains("desktop_scope_disabled"), + "window-scope window-less click was NOT rejected: {}", + r.text() ); }