Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions libs/cua-driver/rust/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions libs/cua-driver/rust/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
22 changes: 22 additions & 0 deletions libs/cua-driver/rust/crates/cua-driver-testkit/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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",
] }
74 changes: 74 additions & 0 deletions libs/cua-driver/rust/crates/cua-driver-testkit/src/cli.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
//! CLI transport: a stateless `cua-driver call <tool>` 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::<Value>(&stdout).unwrap_or(Value::Null);
let is_error = !out.status.success();
ToolResponse::new(stdout, structured, is_error, Value::Null)
}
}
15 changes: 15 additions & 0 deletions libs/cua-driver/rust/crates/cua-driver-testkit/src/driver.rs
Original file line number Diff line number Diff line change
@@ -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;
}
46 changes: 46 additions & 0 deletions libs/cua-driver/rust/crates/cua-driver-testkit/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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 <tool> <json>`
//! 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);
114 changes: 114 additions & 0 deletions libs/cua-driver/rust/crates/cua-driver-testkit/src/mcp.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
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<Self> {
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::<String>();
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))
}
}
36 changes: 36 additions & 0 deletions libs/cua-driver/rust/crates/cua-driver-testkit/src/paths.rs
Original file line number Diff line number Diff line change
@@ -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/<dir>/<exe>` (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)
}
Loading
Loading