qwen-code/packages/cua-driver/rust/tests/integration/test_cli.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

242 lines
9.5 KiB
Python

"""Integration test: cua-driver-rs CLI subcommands.
Covers:
1. `list-tools` prints tool names and summaries.
2. `describe <tool>` prints the tool schema.
3. `call <tool> [json]` invokes a tool and prints ✅ result.
4. Implicit call (first positional as tool name) works.
5. Unknown tool exits 64.
6. Error tool result exits 1.
7. `screenshot` call returns JSON with a screenshot_png_b64 key.
Run:
CUA_DRIVER_BINARY=../../target/debug/cua-driver python3 -m unittest test_cli -v
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from driver_client import default_binary_path
BINARY = None # set in setUpClass
def _run(args: list[str], timeout: int = 15) -> subprocess.CompletedProcess:
return subprocess.run(
args,
capture_output=True,
text=True,
timeout=timeout,
)
class CLISubcommandTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.binary = default_binary_path()
def test_list_tools_prints_tool_names(self) -> None:
r = _run([self.binary, "list-tools"])
self.assertEqual(r.returncode, 0, f"stderr: {r.stderr}")
self.assertIn("click", r.stdout)
self.assertIn("screenshot", r.stdout)
self.assertIn("list_windows", r.stdout)
self.assertIn("press_key", r.stdout)
def test_list_tools_has_name_colon_summary_format(self) -> None:
r = _run([self.binary, "list-tools"])
self.assertEqual(r.returncode, 0)
for line in r.stdout.strip().splitlines():
self.assertRegex(
line,
r"^\w+",
f"list-tools line should start with tool name: {line!r}",
)
def test_describe_prints_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_unknown_tool_exits_64(self) -> None:
r = _run([self.binary, "describe", "no_such_tool_xyz"])
self.assertEqual(r.returncode, 64)
self.assertIn("no_such_tool_xyz", r.stderr)
def test_call_get_screen_size(self) -> None:
r = _run([self.binary, "call", "get_screen_size"])
self.assertEqual(r.returncode, 0, f"stdout: {r.stdout}\nstderr: {r.stderr}")
data = json.loads(r.stdout)
self.assertIn("width", data)
self.assertIn("height", data)
self.assertGreater(data["width"], 0)
self.assertGreater(data["height"], 0)
def test_implicit_call_get_screen_size(self) -> None:
"""First positional arg is treated as tool name (no 'call' prefix)."""
r = _run([self.binary, "get_screen_size"])
self.assertEqual(r.returncode, 0, f"stdout: {r.stdout}\nstderr: {r.stderr}")
data = json.loads(r.stdout)
self.assertIn("width", data)
self.assertIn("height", data)
def test_call_check_permissions_exits_zero(self) -> None:
"""check_permissions is read-only and always exits 0."""
r = _run([self.binary, "call", "check_permissions"])
self.assertEqual(r.returncode, 0, f"stdout: {r.stdout}\nstderr: {r.stderr}")
def test_call_check_permissions_output_has_green_checkmark(self) -> None:
r = _run([self.binary, "call", "check_permissions"])
self.assertEqual(r.returncode, 0)
# The structured JSON output should have accessibility/screen_recording fields.
data = json.loads(r.stdout)
self.assertIn("accessibility", data)
self.assertIn("screen_recording", data)
def test_call_unknown_tool_exits_64(self) -> None:
r = _run([self.binary, "call", "no_such_tool_xyz"])
self.assertEqual(r.returncode, 64)
def test_call_press_key_with_missing_pid_exits_1(self) -> None:
"""Calling a tool with missing required args should return an MCP error → exit 1."""
r = _run([self.binary, "call", "press_key", '{"key": "a"}'])
self.assertEqual(r.returncode, 1, f"stdout: {r.stdout}\nstderr: {r.stderr}")
def test_call_list_apps_json_output(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_screenshot_returns_b64_image(self) -> None:
r = _run([self.binary, "call", "screenshot"], timeout=30)
self.assertEqual(r.returncode, 0, f"stderr: {r.stderr}")
data = json.loads(r.stdout)
self.assertIn("screenshot_png_b64", data, "screenshot output missing image key")
b64 = data["screenshot_png_b64"]
self.assertGreater(len(b64), 100, "screenshot base64 data seems too short")
class ServeDaemonTests(unittest.TestCase):
"""Tests for `cua-driver serve` / `stop` / `status`."""
@classmethod
def setUpClass(cls) -> None:
cls.binary = default_binary_path()
import tempfile, os
cls._sock_file = tempfile.mktemp(suffix=".sock", prefix="cua-driver-test-")
def _start_daemon(self):
import subprocess, time
proc = subprocess.Popen(
[self.binary, "serve", "--socket", self._sock_file],
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
)
# Wait for daemon to bind.
for _ in range(30):
time.sleep(0.1)
r = _run([self.binary, "status", "--socket", self._sock_file])
if r.returncode == 0:
break
return proc
def test_status_exits_1_when_no_daemon(self) -> None:
import tempfile
sock = tempfile.mktemp(suffix=".sock", prefix="cua-noexist-")
r = _run([self.binary, "status", "--socket", sock])
self.assertEqual(r.returncode, 1)
self.assertIn("not running", r.stderr)
def test_stop_exits_1_when_no_daemon(self) -> None:
import tempfile
sock = tempfile.mktemp(suffix=".sock", prefix="cua-noexist2-")
r = _run([self.binary, "stop", "--socket", sock])
self.assertEqual(r.returncode, 1)
def test_serve_status_stop_lifecycle(self) -> None:
"""Start daemon → status is running → stop → status is not running."""
proc = self._start_daemon()
try:
# Status should show running.
r = _run([self.binary, "status", "--socket", self._sock_file])
self.assertEqual(r.returncode, 0, f"status stderr: {r.stderr}")
self.assertIn("running", r.stdout)
self.assertIn("socket:", r.stdout)
# Stop the daemon.
r = _run([self.binary, "stop", "--socket", self._sock_file])
self.assertEqual(r.returncode, 0, f"stop stderr: {r.stderr}")
# Status should now show not running.
import time; time.sleep(0.2)
r = _run([self.binary, "status", "--socket", self._sock_file])
self.assertEqual(r.returncode, 1)
finally:
proc.terminate()
proc.wait(timeout=3)
def test_serve_double_start_exits_1(self) -> None:
"""Second `serve` on same socket should fail with exit 1."""
proc = self._start_daemon()
try:
r = _run([self.binary, "serve", "--socket", self._sock_file])
self.assertEqual(r.returncode, 1)
self.assertIn("already running", r.stderr)
finally:
_run([self.binary, "stop", "--socket", self._sock_file])
proc.wait(timeout=3)
@unittest.skipIf(
sys.platform == "win32",
"Windows: daemon spawned by subprocess.Popen runs in same session as "
"the test runner (typically Session 0 / non-interactive in CI); the "
"GDI BitBlt path fails with `The handle is invalid (0x80070006)` "
"because Session 0 has no graphics. The regression-guard behaviour "
"still verifies on macOS/Linux runners.",
)
def test_call_screenshot_via_daemon_emits_b64(self) -> None:
"""`cua-driver call screenshot` over the daemon socket must emit
`screenshot_png_b64` — same shape as the in-process path. Regression
guard for the 2026-05-23 fix where the daemon-forwarding path in
`run_call` was silently dropping the image bytes (only printing
structuredContent metadata: format / width / height).
"""
proc = self._start_daemon()
try:
r = _run(
[self.binary, "call", "--socket", self._sock_file, "screenshot"],
timeout=30,
)
self.assertEqual(r.returncode, 0, f"stderr: {r.stderr}")
data = json.loads(r.stdout)
self.assertIn(
"screenshot_png_b64", data,
"daemon-forwarded screenshot dropped the image — "
"merge into structuredContent regressed",
)
b64 = data["screenshot_png_b64"]
self.assertGreater(
len(b64), 100,
"screenshot base64 data seems too short — likely empty payload",
)
self.assertIn(
"screenshot_mime_type", data,
"merge into structuredContent missed the mime-type key",
)
finally:
_run([self.binary, "stop", "--socket", self._sock_file])
proc.wait(timeout=3)
if __name__ == "__main__":
unittest.main(verbosity=2)