qwen-code/packages/cua-driver/rust/tests/integration/test_api_parity.py
顾盼 adc2bce414
feat(cua-driver): vendor qwen-cua-driver with opt-in 0–1000 relative coordinates (#5896)
* feat(cua-driver): vendor trycua/cua driver with 1000-normalized coordinate support

Vendor libs/cua-driver from trycua/cua into packages/cua-driver as the
basis for qwen-code's computer-use backend, adding an opt-in relative
(1000x1000 normalized) coordinate mode for Qwen-VL clients.

- coord_norm.rs: 0-1000 <-> pixel conversion, per-(pid,window_id) size
  cache, tools/list description rewrite (TDD, 27 tests)
- ToolRegistry: normalized field + invoke input/output hooks
- protocol.rs: system-instruction coordinate wording switched by mode
- serve.rs: daemon list path description rewrite (input_schema aware)
- main.rs: CUA_DRIVER_RS_COORDINATE_SPACE env seed

Default coordinate_space=pixels => zero behavior change for existing
pixel clients. Set CUA_DRIVER_RS_COORDINATE_SPACE=normalized_1000 to
enable. Excludes rust/target build output.

* feat(cua-driver): make normalized coordinate scale configurable

Add CUA_DRIVER_RS_COORDINATE_SCALE (default 1000) so the normalization
full-scale can absorb the Qwen 999-vs-1000 cookbook ambiguity without a
recompile. norm_to_px/px_to_norm now take an explicit scale; denormalize_args
reads the process-wide COORDINATE_SCALE seeded once at startup from env.

* ci(cua-driver): add cross-platform release workflow for vendored driver

Standalone GitHub Action that builds, signs, and releases the vendored
cua-driver under packages/cua-driver. Adapted from upstream trycua/cua
cd-rust-cua-driver.yml:

- macOS: universal binary (lipo arm64+x86_64), codesigned + notarized into
  CuaDriver.app using qwen-code's existing secrets (MAC_CSC_LINK cert +
  App Store Connect API key notarization); Developer ID identity is
  auto-discovered from the imported cert.
- Linux: x86_64 + arm64, built in debian:11 for a glibc 2.31 floor.
- Windows: x86_64 + arm64, unsigned (no EV cert, matches upstream).
- Release: softprops/action-gh-release on cua-driver-rs-v* tags or manual
  dispatch, prerelease.

Triggered by tag push (cua-driver-rs-v*) or workflow_dispatch.

* chore(cua-driver): rebrand vendored driver as qwen-cua-driver

Rename the vendored trycua/cua driver so the fork installs and runs
independently of any upstream trycua install:
- binary cua-driver -> qwen-cua-driver
- bundle CuaDriver.app -> QwenCuaDriver.app
- bundle id com.trycua.driver -> com.qwencode.cua-driver

Updates the cargo/uia manifests, Info.plist, bundle/proxy launch paths,
permission/health-report wording, the install/build scripts, and the
cross-platform release workflow.

* feat(cua-driver): finish relative-coordinate mode — toggle, scale, zoom/move_cursor

- CUA_DRIVER_RS_COORDINATE_SPACE is now a 1/0 toggle (via is_env_truthy);
  default off keeps pixel mode byte-identical to upstream.
- Thread CUA_DRIVER_RS_COORDINATE_SCALE through every coordinate surface
  (was hardcoded 1000): input denormalization already used it; now the
  rewritten screenshot dims, the tool/param descriptions, and the agent
  instructions track the configured scale too.
- Normalize zoom (window basis) and move_cursor (screen basis) inputs and
  rewrite their descriptions, alongside click/double_click/right_click/drag.
- Fix zoom on downscaled (Retina) windows: apply the get_window_state resize
  ratio so the crop lands on the region the agent saw. Normalized mode only;
  pixel-mode zoom unchanged.

All coordinate behavior stays gated on the normalized flag, so the default
(pixels) path is unchanged from upstream.

* chore(cua-driver): add upstream-sync script (git subtree unusable here)

`git subtree split --prefix=libs/cua-driver` hangs on a commit deep in
trycua/cua's history, so the subtree add/pull workflow isn't usable for
the vendored driver (and a pull would re-split + re-hang every time).

Add scripts/sync-from-upstream.sh instead: it git-diffs two upstream refs
(never walks the full history, so it dodges the hang), reprefixes the
libs/cua-driver delta to packages/cua-driver, and `git apply --reject`s it
on top of our local changes — conflicts land as *.rej for manual fixup.
Record the vendored version in .vendored-from and document the migration +
sync method in the design doc.

* chore(cua-driver): exclude vendored driver from qwen-code ESLint

The vendored packages/cua-driver tree carries upstream JS (e.g. the
test-harness Electron app) that doesn't follow qwen-code's lint rules and
fails CI. It is not a workspace package (no package.json) and is not
qwen-code TypeScript, so add it to eslint.config.js global ignores —
alongside packages/desktop/** — the standard treatment for vendored code.

* fix(cua-driver): let start_session revive an idle-reaped session

Ports the fix from upstream trycua/cua#2035 into the vendored driver.

When a session is reaped for idleness, a subsequent start_session with the
same id failed instead of resuming it. Revive the ended session in place so
the agent can continue rather than getting a hard error.

* fix(cua-driver): retry daemon socket writes on EAGAIN

Ports the fix from upstream trycua/cua#2036 into the vendored driver.

A non-blocking daemon socket can return EAGAIN/EWOULDBLOCK mid-write when the
peer's receive buffer is momentarily full. The driver treated that as fatal
and dropped the connection. Add a bounded retry/poll loop (mirror of the
read-side socket_io helper) so transient back-pressure no longer kills the
session; only a real timeout or hard error fails the write.

* fix(cua-driver/linux): stop reporting bare "Clicked" for X11 synthetic clicks

Ports the fix from upstream trycua/cua#2025 into the vendored driver.

On X11, clicks are delivered via XSendEvent synthetic events, which many
toolkits (GTK/SDL/Allegro) ignore because send_event is set. The driver still
reported a flat success ("Clicked"), masking that nothing happened. Report
the synthetic-delivery caveat honestly so the agent can fall back instead of
assuming the click landed.

(platform-linux crate is not built on macOS; verified by clean upstream apply
and covered by upstream + release-workflow Linux CI.)

* fix(cua-driver/windows): list empty-/null-title top-level windows

Ports the fix from upstream trycua/cua#2021 into the vendored driver.

list_windows filtered out any top-level window whose title was empty or null,
so legitimate targets (splash screens, some Electron/game windows, tool
windows) were invisible to the agent and unclickable. Include empty-title
windows, using class name / process as a fallback label.

(platform-windows crate is not built on macOS; verified by clean upstream
apply and covered by upstream + release-workflow Windows CI.)

* chore(cua-driver): track cherry-picked upstream PRs; fix vendored-from

The vendored copy is actually at cua-driver-rs-v0.6.7 (workspace version and
all 0.6.7->0.6.8 delta files confirm it), but .vendored-from had drifted to
0.6.8 during an earlier sync-script trial whose code delta was not kept. Left
as-is it would make a future sync diff 0.6.8->newer and silently skip the real
0.6.7->0.6.8 fixes. Correct it back to 0.6.7.

Also record the four not-yet-merged upstream PRs we carry as cherry-picks
(trycua/cua#2021/#2025/#2035/#2036) in .vendored-patches.md, and have
sync-from-upstream.sh point at it so the next sync reconciles them.

* ci(cua-driver): satisfy repo yamllint on the release workflow

The vendored-driver release workflow tripped 114 quoted-strings violations
under the repo's .yamllint (quote-type: single, required). Single-quote all
string scalars to match every other workflow in .github/workflows.

While reformatting, the release-notes body also got its paragraph blank lines
collapsed and still referenced the old CUA_DRIVER_RS_COORDINATE_SPACE=
normalized_1000 value — restore the blank lines and update it to the current
0/1 toggle (default 0 = off; optional CUA_DRIVER_RS_COORDINATE_SCALE=1000).

* chore(cua-driver): sync vendored driver to cua-driver-rs-v0.6.8

First real run of scripts/sync-from-upstream.sh: it 3-way-applied the upstream
0.6.7->0.6.8 delta onto our local fork. 10/12 files applied cleanly; the 2
rejects (install.ps1, _install-rust.sh) were already-applied baked-version
bumps (0.6.6->0.6.7, our copies were already at 0.6.7), i.e. no real conflict.

0.6.8 brings: Wayland input path (platform-linux), linux health_report +
overlay tweaks, a platform-macos build.rs step, and dependency bumps. Version
moved to 0.6.8 across the workspace.

Verified our work survived the sync untouched: the relative-coordinate shim
(coord_norm/protocol) and all four cherry-picked PRs (socket_io/session +
linux/windows) are intact — in particular the 0.6.8 edit to platform-linux
tools/impl_.rs landed alongside our #2025 change with no collision. macOS
cargo check + 132 core tests green. (platform-linux/windows + the binary
integration test build only on their own runners; upstream CI covers those.)

* ci(cua-driver): add a dry_run gate to the release workflow

Mirror the desktop-release / release dry-run pattern: a workflow_dispatch
dry_run boolean input (default true). The cross-platform build + package jobs
always run and upload their artifacts; the GitHub Release job now publishes
only on a tag push or an explicit dry_run=false dispatch.

Lets us rehearse the whole build/package pipeline (dry_run=true, notarize=false)
and inspect the produced artifacts without cutting a release. A branch push
(no tag, not a dispatch) likewise builds without releasing.
2026-06-26 13:06:43 +00:00

1182 lines
51 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Binary-level API parity tests: Swift cua-driver vs Rust cua-driver-rs.
Covers every public API surface of both binaries:
• CLI subcommands (list-tools, describe, call, mcp-config, serve/stop/status,
recording, dump-docs, update, doctor, diagnose, config, --version, --help)
• Stdio MCP protocol (tools/list + every registered tool)
• Embedded daemon mode (serve socket lifecycle)
Both binaries are tested via the *same* mixin class so any divergence shows
up as a test failure attributed to the specific binary.
Environment variables:
CUA_SWIFT_BINARY path to the Swift binary
(default: ~/.local/bin/cua-driver or `which cua-driver`)
CUA_DRIVER_BINARY path to the Rust binary
(default: ../../target/debug/cua-driver)
Run all parity tests against both binaries::
python3 -m unittest test_api_parity -v
Run against Rust binary only::
CUA_DRIVER_BINARY=../../target/release/cua-driver \\
python3 -m unittest test_api_parity.RustParityTests -v
Run against Swift binary only::
python3 -m unittest test_api_parity.SwiftParityTests -v
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
import tempfile
import time
import unittest
_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, _HERE)
from driver_client import DriverClient, MCPCallError, default_binary_path # noqa: E402
# ── helpers ────────────────────────────────────────────────────────────────────
def _run(
args: list[str],
timeout: int = 20,
stdin: str | None = None,
) -> subprocess.CompletedProcess:
return subprocess.run(
args,
capture_output=True,
text=True,
timeout=timeout,
input=stdin,
)
def _swift_binary() -> str | None:
"""Return Swift cua-driver binary path, or None if not found."""
from_env = os.environ.get("CUA_SWIFT_BINARY", "")
if from_env and os.path.isfile(from_env):
return from_env
candidates = [
os.path.expanduser("~/.local/bin/cua-driver"),
]
found = shutil.which("cua-driver")
if found:
candidates.insert(0, found)
for p in candidates:
if os.path.isfile(p) and os.access(p, os.X_OK):
return p
return None
# ── tool presence lists ────────────────────────────────────────────────────────
#
# REQUIRED_TOOLS tools that BOTH binaries must expose (tests fail on either
# binary when a listed tool is absent).
# RUST_ONLY_TOOLS tools in Rust but not (yet) in Swift. Tests annotated
# with these are expected to fail on Swift.
# SWIFT_ONLY_TOOLS tools in Swift but not (yet) registered in Rust. Tests
# annotated with these are expected to fail on Rust.
#
# Update these lists as parity gaps close.
REQUIRED_TOOLS = [
# Core enumeration
"get_screen_size",
"screenshot",
"list_apps",
"list_windows",
"get_window_state",
# Interaction
"click",
"double_click",
"right_click",
"drag",
"type_text",
"press_key",
"hotkey",
"scroll",
"set_value",
"zoom",
# App lifecycle
"launch_app",
# Permissions
"check_permissions",
# Cursor overlay
"move_cursor",
"get_cursor_position",
"set_agent_cursor_style",
"get_agent_cursor_state",
"set_agent_cursor_enabled",
"set_agent_cursor_motion",
# Config
"get_config",
"set_config",
# Recording / replay
"set_recording",
"get_recording_state",
"replay_trajectory",
]
# In Rust, not in Swift yet. Tests for these will fail on the Swift binary.
RUST_ONLY_TOOLS = [
"type_text_chars", # per-character delay typing; Swift uses type_text
"get_accessibility_tree",# lightweight desktop AX snapshot (separate from get_window_state)
]
# In Swift, not registered in Rust yet. (`page` is now cross-platform in Rust.)
SWIFT_ONLY_TOOLS: list[str] = []
# ── parity mixin ───────────────────────────────────────────────────────────────
class _ParityMixin:
"""All API surface tests. Concrete subclasses set `cls.binary`."""
binary: str = "" # set by setUpClass
# ── CLI: version / help ───────────────────────────────────────────────────
def test_version_flag_exits_zero(self) -> None:
"""--version must exit 0 (even if the binary enters MCP mode silently)."""
r = _run([self.binary, "--version"])
self.assertEqual(r.returncode, 0, f"stderr: {r.stderr}")
def test_version_flag_outputs_version_number(self) -> None:
"""--version must print a semver string to stdout or stderr.
Parity gap: Rust binary currently exits 0 with no output because
'--version' is treated as an unknown bare flag and silently enters
MCP mode. This test documents the requirement; it will fail until
'--version' is handled explicitly in cli::parse_command.
"""
r = _run([self.binary, "--version"])
self.assertEqual(r.returncode, 0, f"stderr: {r.stderr}")
self.assertRegex(
r.stdout + r.stderr,
r"\d+\.\d+\.\d+",
"--version did not print a semver string (N.N.N)",
)
def test_help_flag_exits_zero(self) -> None:
r = _run([self.binary, "--help"])
self.assertEqual(r.returncode, 0, f"stderr: {r.stderr}")
self.assertIn("cua-driver", r.stdout + r.stderr)
# ── CLI: list-tools ───────────────────────────────────────────────────────
def test_list_tools_exits_zero(self) -> None:
r = _run([self.binary, "list-tools"])
self.assertEqual(r.returncode, 0, f"stderr: {r.stderr}")
def test_list_tools_contains_required_tool_names(self) -> None:
r = _run([self.binary, "list-tools"])
self.assertEqual(r.returncode, 0)
for name in [
"click", "screenshot", "list_windows", "press_key",
"get_window_state", "type_text", "hotkey", "scroll",
]:
self.assertIn(name, r.stdout, f"list-tools missing: {name!r}")
def test_list_tools_every_required_tool_present(self) -> None:
r = _run([self.binary, "list-tools"])
self.assertEqual(r.returncode, 0)
for name in REQUIRED_TOOLS:
self.assertIn(name, r.stdout, f"list-tools missing: {name!r}")
# ── CLI: describe ─────────────────────────────────────────────────────────
def test_describe_click_has_pid_and_schema(self) -> None:
r = _run([self.binary, "describe", "click"])
self.assertEqual(r.returncode, 0, f"stderr: {r.stderr}")
self.assertIn("click", r.stdout)
self.assertIn("pid", r.stdout)
self.assertIn("input_schema", r.stdout)
def test_describe_screenshot_has_schema(self) -> None:
r = _run([self.binary, "describe", "screenshot"])
self.assertEqual(r.returncode, 0)
self.assertIn("screenshot", r.stdout)
self.assertIn("input_schema", r.stdout)
def test_describe_each_required_tool_exits_zero(self) -> None:
for name in REQUIRED_TOOLS:
with self.subTest(tool=name):
r = _run([self.binary, "describe", name])
self.assertEqual(
r.returncode, 0,
f"describe {name!r} exited {r.returncode}: {r.stderr}",
)
self.assertIn(name, r.stdout)
self.assertIn("input_schema", r.stdout)
def test_describe_unknown_tool_exits_64(self) -> None:
r = _run([self.binary, "describe", "no_such_tool_xyzzy"])
self.assertEqual(r.returncode, 64)
self.assertIn("no_such_tool_xyzzy", r.stderr)
# ── CLI: call (stateless tools) ───────────────────────────────────────────
def test_call_get_screen_size_exits_zero(self) -> None:
r = _run([self.binary, "call", "get_screen_size"])
self.assertEqual(r.returncode, 0, f"stderr: {r.stderr}")
def test_call_get_screen_size_returns_json(self) -> None:
"""call get_screen_size must emit JSON with width and height.
Parity gap: Swift binary outputs human-readable text
(e.g. "✅ Main display: 1920x1080 points @ 1.0x") instead of JSON.
This test documents the requirement; it fails on Swift.
"""
r = _run([self.binary, "call", "get_screen_size"])
self.assertEqual(r.returncode, 0, f"stderr: {r.stderr}")
try:
data = json.loads(r.stdout)
except json.JSONDecodeError:
self.fail(
f"call get_screen_size did not output JSON.\n"
f"stdout: {r.stdout!r}\n"
f"(Swift binary outputs human-readable text instead of JSON — parity gap)"
)
self.assertIn("width", data)
self.assertIn("height", data)
self.assertGreater(data["width"], 0)
self.assertGreater(data["height"], 0)
def test_call_screenshot_returns_b64_png(self) -> None:
"""call screenshot (no args) must return JSON with screenshot_png_b64.
Parity gap: Swift binary requires `window_id` for screenshot and
returns a non-JSON error when called without args. The Rust binary
defaults to a full-display screenshot without requiring window_id.
This test fails on Swift on both counts; it documents the requirement.
"""
r = _run([self.binary, "call", "screenshot"], timeout=30)
self.assertEqual(r.returncode, 0, f"stderr: {r.stderr}\nstdout: {r.stdout!r}")
try:
data = json.loads(r.stdout)
except json.JSONDecodeError:
self.fail(
f"call screenshot did not output JSON.\n"
f"stdout: {r.stdout!r}\n"
f"(Swift requires window_id; Rust defaults to full display)"
)
self.assertIn("screenshot_png_b64", data)
self.assertGreater(len(data["screenshot_png_b64"]), 500)
def test_call_list_apps_returns_app_list(self) -> None:
r = _run([self.binary, "call", "list_apps"])
self.assertEqual(r.returncode, 0, f"stderr: {r.stderr}")
data = json.loads(r.stdout)
self.assertIn("apps", data)
self.assertIsInstance(data["apps"], list)
self.assertGreater(len(data["apps"]), 0)
def test_call_list_apps_entries_have_required_fields(self) -> None:
r = _run([self.binary, "call", "list_apps"])
self.assertEqual(r.returncode, 0)
data = json.loads(r.stdout)
for app in data["apps"][:5]: # spot-check first 5
with self.subTest(app=app.get("name") or app.get("bundle_id")):
self.assertIn("pid", app)
self.assertIn("name", app)
def test_call_list_windows_returns_window_list(self) -> None:
r = _run([self.binary, "call", "list_windows"])
self.assertEqual(r.returncode, 0, f"stderr: {r.stderr}")
data = json.loads(r.stdout)
self.assertIn("windows", data)
self.assertIsInstance(data["windows"], list)
def test_call_list_windows_entries_have_required_fields(self) -> None:
r = _run([self.binary, "call", "list_windows"])
self.assertEqual(r.returncode, 0)
data = json.loads(r.stdout)
for w in data["windows"][:5]:
with self.subTest(window_id=w.get("window_id")):
self.assertIn("window_id", w)
self.assertIn("pid", w)
self.assertIn("bounds", w)
b = w["bounds"]
for field in ("x", "y", "width", "height"):
self.assertIn(field, b)
def test_call_check_permissions_exits_zero(self) -> None:
r = _run([self.binary, "call", "check_permissions"])
self.assertEqual(r.returncode, 0, f"stderr: {r.stderr}")
def test_call_check_permissions_returns_accessibility_and_screen_recording(
self,
) -> None:
"""call check_permissions must emit JSON with accessibility/screen_recording.
Parity gap: Swift binary outputs human-readable text with emoji
(✅/❌) instead of JSON. This test documents the JSON requirement
and will fail on Swift until its CLI output is standardised.
"""
r = _run([self.binary, "call", "check_permissions"])
self.assertEqual(r.returncode, 0, f"stderr: {r.stderr}")
try:
data = json.loads(r.stdout)
except json.JSONDecodeError:
self.fail(
f"call check_permissions did not output JSON.\n"
f"stdout: {r.stdout!r}"
)
self.assertIn("accessibility", data)
self.assertIn("screen_recording", data)
def test_call_get_cursor_position_returns_xy(self) -> None:
r = _run([self.binary, "call", "get_cursor_position"])
self.assertEqual(r.returncode, 0, f"stderr: {r.stderr}")
data = json.loads(r.stdout)
self.assertIn("x", data)
self.assertIn("y", data)
def test_call_get_config_returns_config_object(self) -> None:
r = _run([self.binary, "call", "get_config"])
self.assertEqual(r.returncode, 0, f"stderr: {r.stderr}")
data = json.loads(r.stdout)
# Config must have at least one of the known top-level keys.
known_keys = {
"schema_version", "capture_mode", "max_image_dimension",
"agent_cursor", "telemetry_enabled",
}
self.assertTrue(
known_keys.intersection(data.keys()),
f"get_config returned no known keys: {list(data.keys())}",
)
def test_call_get_agent_cursor_state_returns_state(self) -> None:
r = _run([self.binary, "call", "get_agent_cursor_state"])
self.assertEqual(r.returncode, 0, f"stderr: {r.stderr}")
data = json.loads(r.stdout)
self.assertIn("enabled", data)
def test_call_get_recording_state_returns_state(self) -> None:
r = _run([self.binary, "call", "get_recording_state"])
self.assertEqual(r.returncode, 0, f"stderr: {r.stderr}")
data = json.loads(r.stdout)
self.assertIn("recording", data)
# ── CLI: call with a pid-bearing tool (launch_app + get_window_state) ─────
def test_call_launch_app_calculator_returns_pid(self) -> None:
subprocess.run(["pkill", "-x", "Calculator"], check=False)
time.sleep(0.3)
r = _run(
[self.binary, "call", "launch_app", '{"bundle_id": "com.apple.calculator"}'],
timeout=20,
)
self.assertEqual(r.returncode, 0, f"stderr: {r.stderr}")
data = json.loads(r.stdout)
self.assertIn("pid", data)
self.assertGreater(data["pid"], 0)
def test_call_get_window_state_for_calculator(self) -> None:
"""launch Calculator, get its window, then fetch get_window_state."""
# Launch
rl = _run(
[self.binary, "call", "launch_app", '{"bundle_id": "com.apple.calculator"}'],
timeout=20,
)
self.assertEqual(rl.returncode, 0, f"launch stderr: {rl.stderr}")
pid = json.loads(rl.stdout)["pid"]
time.sleep(1.0)
# List windows to get a window_id.
rw = _run(
[self.binary, "call", "list_windows", f'{{"pid": {pid}}}'],
)
self.assertEqual(rw.returncode, 0, f"list_windows stderr: {rw.stderr}")
windows = json.loads(rw.stdout)["windows"]
self.assertTrue(windows, "Calculator has no windows")
window_id = windows[0]["window_id"]
# get_window_state
rs = _run(
[self.binary, "call", "get_window_state",
f'{{"pid": {pid}, "window_id": {window_id}}}'],
timeout=30,
)
self.assertEqual(rs.returncode, 0, f"get_window_state stderr: {rs.stderr}")
data = json.loads(rs.stdout)
self.assertIn("tree_markdown", data)
self.assertIn("AX", data["tree_markdown"])
# ── CLI: call — error / missing args paths ────────────────────────────────
def test_call_unknown_tool_exits_64(self) -> None:
r = _run([self.binary, "call", "no_such_tool_xyzzy"])
self.assertEqual(r.returncode, 64)
def test_call_press_key_missing_pid_exits_1(self) -> None:
r = _run([self.binary, "call", "press_key", '{"key": "a"}'])
self.assertEqual(r.returncode, 1, f"stdout: {r.stdout}")
def test_call_click_missing_required_args_exits_1(self) -> None:
r = _run([self.binary, "call", "click", '{}'])
self.assertEqual(r.returncode, 1, f"stdout: {r.stdout}")
def test_call_type_text_missing_pid_exits_1(self) -> None:
r = _run([self.binary, "call", "type_text", '{"text": "hello"}'])
self.assertEqual(r.returncode, 1, f"stdout: {r.stdout}")
def test_call_hotkey_missing_pid_exits_1(self) -> None:
r = _run([self.binary, "call", "hotkey", '{"keys": ["cmd", "a"]}'])
self.assertEqual(r.returncode, 1, f"stdout: {r.stdout}")
def test_call_scroll_missing_required_args_exits_1(self) -> None:
r = _run([self.binary, "call", "scroll", '{}'])
self.assertEqual(r.returncode, 1, f"stdout: {r.stdout}")
def test_call_drag_missing_required_args_exits_1(self) -> None:
r = _run([self.binary, "call", "drag", '{}'])
self.assertEqual(r.returncode, 1, f"stdout: {r.stdout}")
def test_call_get_window_state_missing_pid_exits_1(self) -> None:
r = _run([self.binary, "call", "get_window_state", '{"window_id": 1}'])
self.assertEqual(r.returncode, 1, f"stdout: {r.stdout}")
def test_call_launch_app_no_args_exits_1(self) -> None:
r = _run([self.binary, "call", "launch_app", '{}'])
self.assertEqual(r.returncode, 1, f"stdout: {r.stdout}")
# ── CLI: implicit call (tool name as first positional) ────────────────────
def test_implicit_call_get_screen_size(self) -> None:
r = _run([self.binary, "get_screen_size"])
self.assertEqual(r.returncode, 0, f"stderr: {r.stderr}")
data = json.loads(r.stdout)
self.assertIn("width", data)
self.assertIn("height", data)
def test_implicit_call_check_permissions(self) -> None:
r = _run([self.binary, "check_permissions"])
self.assertEqual(r.returncode, 0, f"stderr: {r.stderr}")
data = json.loads(r.stdout)
self.assertIn("accessibility", data)
def test_implicit_call_list_apps(self) -> None:
r = _run([self.binary, "list_apps"])
self.assertEqual(r.returncode, 0, f"stderr: {r.stderr}")
data = json.loads(r.stdout)
self.assertIn("apps", data)
def test_implicit_call_screenshot_exits_zero(self) -> None:
"""Implicit-call screenshot must exit 0.
Note: Swift binary requires window_id so exits non-zero with an
error message — this is a parity gap in Swift.
"""
r = _run([self.binary, "screenshot"], timeout=30)
self.assertEqual(r.returncode, 0, f"stderr: {r.stderr}\nstdout: {r.stdout!r}")
def test_implicit_unknown_exits_64(self) -> None:
r = _run([self.binary, "no_such_tool_xyzzy"])
self.assertEqual(r.returncode, 64)
# ── CLI: mcp-config ───────────────────────────────────────────────────────
def test_mcp_config_default_outputs_json(self) -> None:
r = _run([self.binary, "mcp-config"])
self.assertEqual(r.returncode, 0, f"stderr: {r.stderr}")
out = r.stdout.strip()
# Should either be a JSON object or a shell snippet containing "cua-driver"
self.assertIn("cua-driver", out)
def test_mcp_config_client_claude_outputs_add_command(self) -> None:
r = _run([self.binary, "mcp-config", "--client", "claude"])
self.assertEqual(r.returncode, 0, f"stderr: {r.stderr}")
self.assertIn("cua-driver", r.stdout)
def test_mcp_config_client_cursor_exits_zero(self) -> None:
r = _run([self.binary, "mcp-config", "--client", "cursor"])
self.assertEqual(r.returncode, 0, f"stderr: {r.stderr}")
# ── CLI: dump-docs ────────────────────────────────────────────────────────
def test_dump_docs_exits_zero(self) -> None:
r = _run([self.binary, "dump-docs"])
self.assertEqual(r.returncode, 0, f"stderr: {r.stderr}")
self.assertTrue(len(r.stdout) > 200, "dump-docs output too short")
def test_dump_docs_contains_tool_names(self) -> None:
r = _run([self.binary, "dump-docs"])
self.assertEqual(r.returncode, 0)
for name in ["click", "screenshot", "get_window_state"]:
self.assertIn(name, r.stdout)
def test_dump_docs_pretty_is_valid_json(self) -> None:
r = _run([self.binary, "dump-docs", "--pretty"])
self.assertEqual(r.returncode, 0, f"stderr: {r.stderr}")
# --pretty outputs JSON
try:
data = json.loads(r.stdout)
except json.JSONDecodeError:
self.fail(f"dump-docs --pretty is not valid JSON:\n{r.stdout[:500]}")
self.assertIsInstance(data, (list, dict))
# ── CLI: diagnose ─────────────────────────────────────────────────────────
def test_diagnose_exits_zero(self) -> None:
r = _run([self.binary, "diagnose"], timeout=30)
self.assertEqual(r.returncode, 0, f"stderr: {r.stderr}")
self.assertTrue(len(r.stdout) > 50)
def test_diagnose_mentions_accessibility(self) -> None:
r = _run([self.binary, "diagnose"], timeout=30)
self.assertEqual(r.returncode, 0)
out = (r.stdout + r.stderr).lower()
self.assertIn("access", out)
# ── CLI: doctor ───────────────────────────────────────────────────────────
def test_doctor_exits_zero_or_one(self) -> None:
"""doctor returns 0 if all checks pass, 1 if any check fails."""
r = _run([self.binary, "doctor"], timeout=20)
self.assertIn(r.returncode, (0, 1), f"unexpected exit: {r.returncode}")
def test_doctor_produces_output(self) -> None:
r = _run([self.binary, "doctor"], timeout=20)
self.assertIn(r.returncode, (0, 1))
out = r.stdout + r.stderr
self.assertGreater(len(out.strip()), 0)
# ── CLI: config ───────────────────────────────────────────────────────────
def test_config_show_exits_zero(self) -> None:
r = _run([self.binary, "config", "show"])
self.assertEqual(r.returncode, 0, f"stderr: {r.stderr}")
self.assertGreater(len(r.stdout.strip()), 0)
def test_config_get_max_image_dimension(self) -> None:
r = _run([self.binary, "config", "get", "max_image_dimension"])
self.assertEqual(r.returncode, 0, f"stderr: {r.stderr}")
# Value should look like an integer
out = r.stdout.strip()
self.assertRegex(out, r"\d+", f"expected integer, got: {out!r}")
def test_config_get_agent_cursor_enabled(self) -> None:
r = _run([self.binary, "config", "get", "agent_cursor.enabled"])
self.assertEqual(r.returncode, 0, f"stderr: {r.stderr}")
val = r.stdout.strip().lower()
self.assertIn(val, ("true", "false"), f"unexpected value: {val!r}")
def test_config_set_and_restore_max_image_dimension(self) -> None:
# Read original value.
r0 = _run([self.binary, "config", "get", "max_image_dimension"])
self.assertEqual(r0.returncode, 0)
original = r0.stdout.strip()
# Set a new value.
r1 = _run([self.binary, "config", "set", "max_image_dimension", "1024"])
self.assertEqual(r1.returncode, 0, f"set stderr: {r1.stderr}")
# Confirm it was applied.
r2 = _run([self.binary, "config", "get", "max_image_dimension"])
self.assertEqual(r2.returncode, 0)
self.assertEqual(r2.stdout.strip(), "1024")
# Restore original value.
_run([self.binary, "config", "set", "max_image_dimension", original])
def test_config_reset_exits_zero(self) -> None:
r = _run([self.binary, "config", "reset"])
self.assertEqual(r.returncode, 0, f"stderr: {r.stderr}")
def test_config_get_unknown_key_exits_nonzero(self) -> None:
r = _run([self.binary, "config", "get", "no_such_key_xyzzy"])
self.assertNotEqual(r.returncode, 0)
# ── CLI: update ───────────────────────────────────────────────────────────
def test_update_dry_run_exits_zero(self) -> None:
"""update without --apply should exit 0 even if already up-to-date."""
r = _run([self.binary, "update"], timeout=30)
self.assertIn(r.returncode, (0, 1), f"unexpected exit: {r.returncode}")
out = r.stdout + r.stderr
# Should mention version or "up to date" or similar
self.assertGreater(len(out.strip()), 0)
# ── CLI: serve / stop / status daemon lifecycle ───────────────────────────
def _tmp_socket(self) -> str:
return tempfile.mktemp(suffix=".sock", prefix="cua-parity-test-")
def test_status_exits_1_when_no_daemon(self) -> None:
sock = self._tmp_socket()
r = _run([self.binary, "status", "--socket", sock])
self.assertEqual(r.returncode, 1)
out = (r.stdout + r.stderr).lower()
self.assertIn("not running", out)
def test_stop_exits_1_when_no_daemon(self) -> None:
sock = self._tmp_socket()
r = _run([self.binary, "stop", "--socket", sock])
self.assertEqual(r.returncode, 1)
def test_serve_status_stop_lifecycle(self) -> None:
sock = self._tmp_socket()
proc = subprocess.Popen(
[self.binary, "serve", "--socket", sock],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
try:
# Wait for daemon to bind (up to 5 s).
deadline = time.monotonic() + 5.0
running = False
while time.monotonic() < deadline:
r = _run([self.binary, "status", "--socket", sock])
if r.returncode == 0:
running = True
break
time.sleep(0.1)
self.assertTrue(running, "daemon did not become ready in 5 s")
# status reports "running" and mentions the socket path.
r = _run([self.binary, "status", "--socket", sock])
self.assertEqual(r.returncode, 0, f"status stderr: {r.stderr}")
out = (r.stdout + r.stderr).lower()
self.assertIn("running", out)
# stop the daemon.
r = _run([self.binary, "stop", "--socket", sock])
self.assertEqual(r.returncode, 0, f"stop stderr: {r.stderr}")
# status should report not running.
time.sleep(0.3)
r = _run([self.binary, "status", "--socket", sock])
self.assertEqual(r.returncode, 1)
finally:
proc.terminate()
proc.wait(timeout=3)
def test_serve_second_instance_exits_1(self) -> None:
sock = self._tmp_socket()
proc = subprocess.Popen(
[self.binary, "serve", "--socket", sock],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
try:
# Wait for first daemon.
deadline = time.monotonic() + 5.0
while time.monotonic() < deadline:
r = _run([self.binary, "status", "--socket", sock])
if r.returncode == 0:
break
time.sleep(0.1)
# Second serve should fail.
r = _run([self.binary, "serve", "--socket", sock])
self.assertEqual(r.returncode, 1, f"stdout: {r.stdout}\nstderr: {r.stderr}")
out = (r.stdout + r.stderr).lower()
self.assertIn("already running", out)
finally:
_run([self.binary, "stop", "--socket", sock])
proc.wait(timeout=3)
# ── stdio MCP: tools/list ─────────────────────────────────────────────────
def _mcp(self) -> DriverClient:
return DriverClient(self.binary)
def test_mcp_tools_list_returns_all_required_tools(self) -> None:
with self._mcp() as c:
tools = c.list_tools()
names = {t["name"] for t in tools}
missing = [n for n in REQUIRED_TOOLS if n not in names]
self.assertFalse(
missing,
f"tools/list missing: {missing}\n got: {sorted(names)}",
)
def test_mcp_tools_list_each_has_name_description_schema(self) -> None:
with self._mcp() as c:
tools = c.list_tools()
for t in tools:
with self.subTest(tool=t.get("name")):
self.assertIn("name", t)
self.assertIn("description", t)
self.assertIn("inputSchema", t)
schema = t["inputSchema"]
self.assertIn("type", schema)
self.assertEqual(schema["type"], "object")
# ── stdio MCP: stateless read tools ──────────────────────────────────────
def test_mcp_get_screen_size(self) -> None:
with self._mcp() as c:
result = c.call_tool("get_screen_size")
sc = result.get("structuredContent", result)
self.assertIn("width", sc)
self.assertIn("height", sc)
self.assertGreater(sc["width"], 0)
self.assertGreater(sc["height"], 0)
def test_mcp_screenshot_has_png_image(self) -> None:
with self._mcp() as c:
result = c.call_tool("screenshot")
# Should have image content.
images = [i for i in result.get("content", []) if i.get("type") == "image"]
sc = result.get("structuredContent", {})
# At least one of: image in content OR screenshot_png_b64 in structuredContent.
has_image = bool(images) or bool(sc.get("screenshot_png_b64"))
self.assertTrue(has_image, f"screenshot returned no image: {result}")
def test_mcp_screenshot_with_quality(self) -> None:
with self._mcp() as c:
result = c.call_tool("screenshot", {"quality": 50})
images = [i for i in result.get("content", []) if i.get("type") == "image"]
sc = result.get("structuredContent", {})
has_image = bool(images) or bool(sc.get("screenshot_png_b64"))
self.assertTrue(has_image, "screenshot with quality returned no image")
def test_mcp_list_apps(self) -> None:
with self._mcp() as c:
result = c.call_tool("list_apps")
sc = result["structuredContent"]
self.assertIn("apps", sc)
apps = sc["apps"]
self.assertIsInstance(apps, list)
self.assertGreater(len(apps), 0)
# Every app entry must have pid + name.
for app in apps[:5]:
self.assertIn("pid", app)
self.assertIn("name", app)
def test_mcp_list_apps_has_active_flag(self) -> None:
with self._mcp() as c:
result = c.call_tool("list_apps")
apps = result["structuredContent"]["apps"]
# At least one app should have an active field.
has_active = any("active" in app for app in apps)
self.assertTrue(has_active, "no app has 'active' field")
def test_mcp_list_windows_default(self) -> None:
with self._mcp() as c:
result = c.call_tool("list_windows")
sc = result["structuredContent"]
self.assertIn("windows", sc)
windows = sc["windows"]
self.assertIsInstance(windows, list)
def test_mcp_list_windows_fields(self) -> None:
with self._mcp() as c:
result = c.call_tool("list_windows")
windows = result["structuredContent"]["windows"]
for w in windows[:5]:
with self.subTest(window_id=w.get("window_id")):
self.assertIn("window_id", w)
self.assertIn("pid", w)
self.assertIn("bounds", w)
for f in ("x", "y", "width", "height"):
self.assertIn(f, w["bounds"])
def test_mcp_list_windows_pid_filter(self) -> None:
"""list_windows with pid filter should only return windows for that pid."""
with self._mcp() as c:
# Get any pid that has windows.
all_wins = c.call_tool("list_windows")["structuredContent"]["windows"]
if not all_wins:
self.skipTest("no windows available")
target_pid = all_wins[0]["pid"]
filtered = c.call_tool("list_windows", {"pid": target_pid})
windows = filtered["structuredContent"]["windows"]
for w in windows:
self.assertEqual(w["pid"], target_pid)
def test_mcp_list_windows_on_screen_only(self) -> None:
with self._mcp() as c:
result = c.call_tool("list_windows", {"on_screen_only": True})
windows = result["structuredContent"]["windows"]
for w in windows:
self.assertTrue(
w.get("is_on_screen", True),
f"on_screen_only=true returned off-screen window: {w}",
)
def test_mcp_check_permissions(self) -> None:
with self._mcp() as c:
result = c.call_tool("check_permissions")
sc = result.get("structuredContent", result)
self.assertIn("accessibility", sc)
self.assertIn("screen_recording", sc)
self.assertIsInstance(sc["accessibility"], bool)
self.assertIsInstance(sc["screen_recording"], bool)
def test_mcp_get_cursor_position(self) -> None:
with self._mcp() as c:
result = c.call_tool("get_cursor_position")
sc = result.get("structuredContent", result)
self.assertIn("x", sc)
self.assertIn("y", sc)
def test_mcp_get_agent_cursor_state(self) -> None:
with self._mcp() as c:
result = c.call_tool("get_agent_cursor_state")
sc = result.get("structuredContent", result)
self.assertIn("enabled", sc)
def test_mcp_set_agent_cursor_enabled_toggle(self) -> None:
with self._mcp() as c:
# Read current.
state = c.call_tool("get_agent_cursor_state")
enabled = state.get("structuredContent", state).get("enabled", True)
# Toggle off.
c.call_tool("set_agent_cursor_enabled", {"enabled": False})
state2 = c.call_tool("get_agent_cursor_state")
self.assertFalse(state2.get("structuredContent", state2).get("enabled", True))
# Restore.
c.call_tool("set_agent_cursor_enabled", {"enabled": enabled})
def test_mcp_set_agent_cursor_style_default(self) -> None:
with self._mcp() as c:
# Calling with no args or default args should succeed.
c.call_tool("set_agent_cursor_style", {})
def test_mcp_get_config_roundtrip(self) -> None:
with self._mcp() as c:
result = c.call_tool("get_config")
sc = result.get("structuredContent", result)
known = {"schema_version", "capture_mode", "max_image_dimension",
"agent_cursor", "telemetry_enabled"}
self.assertTrue(known.intersection(sc.keys()),
f"get_config has no known keys: {list(sc.keys())}")
def test_mcp_set_config_max_image_dimension(self) -> None:
with self._mcp() as c:
# Read original.
orig = c.call_tool("get_config")
orig_dim = orig.get("structuredContent", orig).get("max_image_dimension", 1920)
# Set new value.
c.call_tool("set_config", {"max_image_dimension": 800})
# Confirm.
updated = c.call_tool("get_config")
new_dim = updated.get("structuredContent", updated).get("max_image_dimension")
self.assertEqual(new_dim, 800)
# Restore.
c.call_tool("set_config", {"max_image_dimension": orig_dim})
def test_mcp_get_recording_state(self) -> None:
with self._mcp() as c:
result = c.call_tool("get_recording_state")
sc = result.get("structuredContent", result)
self.assertIn("recording", sc)
self.assertIsInstance(sc["recording"], bool)
def test_mcp_set_recording_enable_disable(self) -> None:
import tempfile
with tempfile.TemporaryDirectory() as tmpdir:
with self._mcp() as c:
# Enable recording — output_dir is required.
c.call_tool("set_recording", {"enabled": True, "output_dir": tmpdir})
state = c.call_tool("get_recording_state")
sc = state.get("structuredContent", state)
# Accept either "recording" (Swift) or "enabled" (Rust) key.
is_recording = sc.get("recording", sc.get("enabled", False))
self.assertTrue(is_recording, f"recording should be enabled: {sc}")
# Disable.
c.call_tool("set_recording", {"enabled": False})
state2 = c.call_tool("get_recording_state")
sc2 = state2.get("structuredContent", state2)
is_recording2 = sc2.get("recording", sc2.get("enabled", True))
self.assertFalse(is_recording2, f"recording should be disabled: {sc2}")
# ── stdio MCP: tool error contracts ──────────────────────────────────────
def _assert_tool_raises_mcp_error(
self, tool_name: str, args: dict, timeout: float = 10.0
) -> None:
"""Assert that calling `tool_name` with `args` signals an error.
Accepts either a JSON-RPC MCPCallError (Swift behaviour) or a result
with ``isError=true`` in the tool-content list (Rust behaviour).
"""
with self._mcp() as c:
try:
result = c.call_tool(tool_name, args)
# Rust returns isError:true in the content payload
self.assertTrue(
result.get("isError"),
f"{tool_name} did not raise an error (isError missing or false)",
)
except MCPCallError:
pass # Swift-style JSON-RPC error — acceptable
def test_mcp_click_missing_pid_raises_error(self) -> None:
self._assert_tool_raises_mcp_error("click", {"window_id": 1})
def test_mcp_double_click_missing_pid_raises_error(self) -> None:
self._assert_tool_raises_mcp_error("double_click", {"window_id": 1})
def test_mcp_right_click_missing_pid_raises_error(self) -> None:
self._assert_tool_raises_mcp_error("right_click", {"window_id": 1})
def test_mcp_press_key_missing_pid_raises_error(self) -> None:
self._assert_tool_raises_mcp_error("press_key", {"key": "a"})
def test_mcp_hotkey_missing_pid_raises_error(self) -> None:
self._assert_tool_raises_mcp_error("hotkey", {"keys": ["cmd", "a"]})
def test_mcp_type_text_missing_pid_raises_error(self) -> None:
self._assert_tool_raises_mcp_error("type_text", {"text": "hello"})
def test_mcp_type_text_chars_missing_pid_raises_error(self) -> None:
self._assert_tool_raises_mcp_error("type_text_chars", {"text": "hello"})
def test_mcp_scroll_missing_pid_raises_error(self) -> None:
self._assert_tool_raises_mcp_error("scroll", {"x": 100, "y": 100})
def test_mcp_drag_missing_pid_raises_error(self) -> None:
self._assert_tool_raises_mcp_error("drag", {})
def test_mcp_get_window_state_missing_pid_raises_error(self) -> None:
self._assert_tool_raises_mcp_error("get_window_state", {"window_id": 1})
def test_mcp_launch_app_missing_args_raises_error(self) -> None:
self._assert_tool_raises_mcp_error("launch_app", {})
def test_mcp_set_value_missing_pid_raises_error(self) -> None:
self._assert_tool_raises_mcp_error(
"set_value", {"element_index": 0, "value": "x"}
)
def test_mcp_replay_trajectory_bad_dir_raises_error(self) -> None:
self._assert_tool_raises_mcp_error(
"replay_trajectory", {"dir": "/tmp/no_such_trajectory_xyzzy"}
)
# ── stdio MCP: get_window_state live ─────────────────────────────────────
def test_mcp_get_window_state_calculator(self) -> None:
"""Launch Calculator, capture a window state, verify tree_markdown."""
subprocess.run(["pkill", "-x", "Calculator"], check=False)
time.sleep(0.3)
with self._mcp() as c:
# Launch
launch = c.call_tool("launch_app", {"bundle_id": "com.apple.calculator"})
pid = launch.get("structuredContent", launch).get("pid", 0)
self.assertGreater(pid, 0, f"launch_app returned bad pid: {launch}")
time.sleep(1.0)
# Find window
wins = c.call_tool("list_windows", {"pid": pid})["structuredContent"]["windows"]
self.assertTrue(wins, "Calculator has no windows")
window_id = wins[0]["window_id"]
# get_window_state
state = c.call_tool("get_window_state", {"pid": pid, "window_id": window_id})
sc = state.get("structuredContent", state)
tree = sc.get("tree_markdown", "")
self.assertIn("AX", tree, f"tree_markdown has no AX nodes:\n{tree}")
# Screenshot should be present.
images = [i for i in state.get("content", []) if i.get("type") == "image"]
self.assertTrue(images, "get_window_state returned no screenshot")
def test_mcp_get_window_state_has_required_sc_keys(self) -> None:
subprocess.run(["pkill", "-x", "Calculator"], check=False)
time.sleep(0.3)
with self._mcp() as c:
launch = c.call_tool("launch_app", {"bundle_id": "com.apple.calculator"})
pid = launch.get("structuredContent", launch).get("pid", 0)
time.sleep(1.0)
wins = c.call_tool("list_windows", {"pid": pid})["structuredContent"]["windows"]
if not wins:
self.skipTest("Calculator has no windows")
state = c.call_tool("get_window_state", {
"pid": pid, "window_id": wins[0]["window_id"]
})
sc = state.get("structuredContent", state)
for key in ("tree_markdown", "screenshot_width", "screenshot_height"):
self.assertIn(key, sc, f"get_window_state structuredContent missing: {key!r}")
# ── stdio MCP: launch_app ─────────────────────────────────────────────────
def test_mcp_launch_app_by_bundle_id(self) -> None:
subprocess.run(["pkill", "-x", "Calculator"], check=False)
time.sleep(0.3)
with self._mcp() as c:
result = c.call_tool("launch_app", {"bundle_id": "com.apple.calculator"})
sc = result.get("structuredContent", result)
self.assertIn("pid", sc)
self.assertGreater(sc["pid"], 0)
def test_mcp_launch_app_by_name(self) -> None:
subprocess.run(["pkill", "-x", "Calculator"], check=False)
time.sleep(0.3)
with self._mcp() as c:
result = c.call_tool("launch_app", {"name": "Calculator"})
sc = result.get("structuredContent", result)
self.assertIn("pid", sc)
self.assertGreater(sc["pid"], 0)
def test_mcp_launch_app_unknown_bundle_id_raises_error(self) -> None:
self._assert_tool_raises_mcp_error(
"launch_app", {"bundle_id": "com.example.no_such_app_xyzzy"}
)
# ── stdio MCP: set_agent_cursor_motion ────────────────────────────────────
def test_mcp_set_agent_cursor_motion_spring(self) -> None:
with self._mcp() as c:
c.call_tool("set_agent_cursor_motion", {"spring": True})
def test_mcp_set_agent_cursor_motion_arc(self) -> None:
with self._mcp() as c:
c.call_tool("set_agent_cursor_motion", {"arc_size": 0.5})
# ── stdio MCP: zoom ───────────────────────────────────────────────────────
def test_mcp_zoom_missing_pid_raises_error(self) -> None:
self._assert_tool_raises_mcp_error(
"zoom", {"x1": 0, "y1": 0, "x2": 100, "y2": 100}
)
# ── stdio MCP: page ───────────────────────────────────────────────────────
def test_mcp_page_is_registered(self) -> None:
"""'page' tool must appear in tools/list.
Parity gap: Rust binary has page.rs implemented but the tool is not
yet registered in platform_macos::tools::register_all. This test will
fail on the Rust binary until page::PageTool is added to register_all.
"""
with self._mcp() as c:
names = {t["name"] for t in c.list_tools()}
self.assertIn("page", names, "'page' tool is not registered")
def test_mcp_page_missing_pid_raises_error(self) -> None:
self._assert_tool_raises_mcp_error(
"page", {"action": "get_text"}
)
# ── stdio MCP: Rust-only tools (missing in Swift) ────────────────────────
def test_mcp_rust_only_tools_present(self) -> None:
"""Rust-only tools (type_text_chars, get_accessibility_tree).
These are in the Rust binary but not yet in Swift. Both binaries
should eventually have them. This test fails on Swift until ported.
"""
with self._mcp() as c:
names = {t["name"] for t in c.list_tools()}
missing = [n for n in RUST_ONLY_TOOLS if n not in names]
self.assertFalse(
missing,
f"Rust-only tools missing: {missing} (not yet in this binary)",
)
def test_mcp_type_text_chars_missing_pid_raises_error(self) -> None:
"""type_text_chars (Rust-only) must raise MCP error with missing pid.
Fails on Swift binary — tool not yet ported.
"""
self._assert_tool_raises_mcp_error("type_text_chars", {"text": "hello"})
def test_mcp_get_accessibility_tree_exits_without_pid(self) -> None:
"""get_accessibility_tree (Rust-only) returns desktop snapshot without pid.
Fails on Swift binary — tool not yet ported.
"""
with self._mcp() as c:
result = c.call_tool("get_accessibility_tree")
sc = result.get("structuredContent", result)
# Should return some content — desktop always has windows.
self.assertIsInstance(sc, dict)
# ── stdio MCP: Swift-only tools (missing/unregistered in Rust) ───────────
def test_mcp_swift_only_tools_present(self) -> None:
"""Swift-only tools list — currently empty after the page-tool
unification in Rust. Kept as a no-op so the matrix stays symmetric
with `test_mcp_rust_only_tools_present`.
"""
with self._mcp() as c:
names = {t["name"] for t in c.list_tools()}
missing = [n for n in SWIFT_ONLY_TOOLS if n not in names]
self.assertFalse(
missing,
f"Swift-only tools missing: {missing} (not yet registered in this binary)",
)
# ── stdio MCP: protocol-level contracts ──────────────────────────────────
def test_mcp_tools_call_unknown_tool_returns_error(self) -> None:
"""Calling an unknown tool must signal an error.
Accepts MCPCallError (Swift / JSON-RPC error) or isError=true (Rust).
"""
with self._mcp() as c:
try:
result = c.call_tool("no_such_tool_xyzzy")
self.assertTrue(
result.get("isError"),
"unknown tool should set isError=true",
)
except MCPCallError:
pass # JSON-RPC level error — acceptable
def test_mcp_handles_rapid_sequential_calls(self) -> None:
"""Send 5 sequential get_screen_size calls — all must succeed."""
with self._mcp() as c:
for i in range(5):
result = c.call_tool("get_screen_size")
sc = result.get("structuredContent", result)
self.assertIn("width", sc, f"call {i} failed: {result}")
def test_mcp_protocol_version_echo(self) -> None:
"""The initialize response should echo an MCP protocol version."""
# The DriverClient handshake already calls initialize; if it doesn't
# raise an exception, the server replied correctly.
with self._mcp() as c:
tools = c.list_tools()
self.assertGreater(len(tools), 0)
# ── concrete test classes (one per binary) ────────────────────────────────────
class SwiftParityTests(_ParityMixin, unittest.TestCase):
"""Run the full parity suite against the Swift cua-driver binary."""
@classmethod
def setUpClass(cls) -> None:
path = _swift_binary()
if not path:
raise unittest.SkipTest(
"Swift cua-driver not found. "
"Set CUA_SWIFT_BINARY or install cua-driver to ~/.local/bin/."
)
cls.binary = path
print(f"\n[SwiftParityTests] binary: {cls.binary}", flush=True)
class RustParityTests(_ParityMixin, unittest.TestCase):
"""Run the full parity suite against the Rust cua-driver-rs binary."""
@classmethod
def setUpClass(cls) -> None:
cls.binary = default_binary_path()
if not os.path.isfile(cls.binary):
raise unittest.SkipTest(
f"Rust binary not found at {cls.binary}. "
"Run `cargo build -p cua-driver` or set CUA_DRIVER_BINARY."
)
print(f"\n[RustParityTests] binary: {cls.binary}", flush=True)
if __name__ == "__main__":
unittest.main(verbosity=2)