Cua Driver 接入核对与离线动作验收
先核对驱动、窗口和会话的接入条件,再用可复制的离线模拟器练习旧观察失效、输入投递与保存结果验收。真实桌面操作仍需另行验证。
- 确认持有系统权限的实际进程
- 坐标绑定窗口与返回图像
- 动作回执之后读取应用结果
- 取消、超时和正常结束都执行清理
1. 从一个能验收的桌面动作开始
接入 Cua Driver 前,可以先把两件事分开核对:真实驱动需要哪些进程、系统权限和会话条件;应用收到动作后,怎样确认结果。本文前半部分按官方接口说明整理接入检查,后半部分用自编 Python 模拟器练习“观察输入框、输入文字、保存、重新读回”的闭环。离线演练会故意使用一次旧观察,让你看到拒绝原因,再用新观察完成保存;它不操作真实窗口,也不生成截图。
本文讨论 trycua/cua 仓库中的 Cua Driver。它提供桌面观察和输入接口,可通过 CLI、MCP 或 SDK 接入;模型调用和任务循环由上层程序负责。旧文章的 macOS 场景在这里继续展开,其他平台只说明会影响迁移的边界。通用的观察闭环可接着读 Computer Use。
下文分成官方接口资料审查和离线契约演练。资料核验于 2026-10-05,锁定仓库提交 1fe6ce5;生成式 CLI 与工具参考标注版本 0.33.4。本文实际运行的是自编 Python 模拟器,未安装 Cua Driver,未接入真实 Mac,也没有调用模型。文中的七张图都是原创结构示意。
2. 连接之前,先找到持有权限的进程
以 macOS 为例,官方入门页要求 macOS 14 或更新版本,并且需要可访问的图形桌面。CuaDriver.app 持有辅助功能和屏幕录制授权。前者用于读取可访问性树与输入,后者用于截图。看得见应用列表,并不代表已经能拿到图像;设置里出现一个同名应用,也不能证明当前运行的就是它。
准备真实环境时,应先阅读官方安装入口,核对应用来源,再由操作者完成安装与系统授权。下面的命令假定已经装好驱动,且测试机允许启动它。应用启动后,在另一个终端检查状态。如果 permissions status 返回 unknown,先核对 app-owned daemon 是否存在。不要通过关闭系统保护来省掉这一步。
官方命令形状:本次未执行
# Only on an installed, authorized macOS test machine.
open -n -g -a CuaDriver --args serve
cua-driver status
cua-driver permissions status
cua-driver doctor
cua-driver call list_apps
daemon 的归属会改变接入方式。macOS 的常规 MCP 路径代理到 CuaDriver.app;Windows 与 Linux 的直接 MCP 进程通常拥有自己的运行时。SDK 内嵌又由宿主应用持有运行时。把这三种情况混用,很容易把权限授给终端,却让另一个进程去截图。平台差异应对照 MCP 与 CLI 参考检查。
测试账户里尽量只放这轮验收需要的数据,提前关闭不相关窗口。屏幕通知、密码管理器和聊天内容都有机会进入截图。若任务需要在用户日常桌面运行,应约定前台切换时间,并让使用者知道如何停止当前任务。本文没有替读者开启远程访问,也没有创建长期后台服务。
3. 把 Codex 接到确定的驱动实例
官方连接指南提供 mcp-config,用本机实际安装路径生成客户端配置。对 Codex 执行下面的只读命令,检查打印出的命令或配置,再决定是否注册。安装位置发生变化时重新生成,避免沿用另一台机器的绝对路径。注册会改变客户端配置,应保留已有服务器条目。
官方只读检查命令:本次未执行
# Print and review the configuration; this does not register it.
cua-driver mcp-config --client codex
cua-driver list-tools
cua-driver describe get_window_state
cua-driver describe click
重新开启 Codex 会话后,先确认 Cua Driver 的工具可见,再读取应用和窗口列表。窗口标题只适合帮助人辨认目标,后续调用要保留实际返回的 pid 与 window_id。弹出的保存面板可能由系统服务进程持有;若返回 window_owner_pid_mismatch,应重新确认面板归属,不要用原应用的进程号强行操作。
第一次接入可以只做观察。下面是一条独立的截图调用:先输入已经核实的进程号和窗口号,再把该窗口的图片写到本地。每次一次性 cua-driver call 都使用临时 session;即使连接同一 daemon、填写相同标签,也不能据此复用前一次调用的快照与元素令牌。这段命令不建立后文动作所需的持续会话。before.png 可能包含桌面内容,不应顺手上传到公开问题单。若没有 PNG,检查响应里的降级原因和权限状态,不能把空图当成黑色窗口继续定位。
官方截图调用:仅用于已核实目标,本次未执行
# Enter verified PID and window ID, separated by a space.
read -r APP_PID WINDOW_ID
printf '{"pid":%s,"window_id":%s,"session":"calc-smoke"}\n' \
"$APP_PID" "$WINDOW_ID" |
cua-driver call get_window_state --screenshot-out-file before.png
同一机器上有多个驱动进程时,先记录当前连接的实例。显式 socket 适合由部署方管理的共享 daemon,但它的路径和生命周期要一起交接。换了驱动实例就重新发现窗口,上一轮进程号可能已经被系统复用。复制旧日志里的目标继续执行,无法证明操作仍落在同一个应用。
4. 每个动作都要携带能追溯的观察
get_window_state默认把指定窗口的可访问性树与截图放在同一次返回里。当前参考的 tree_format 默认是 markdown;需要结构化 elements 时,显式选择 tree_format: "elements",或用 "both" 同时返回两种表示。elements 记录角色、标签、值和元素令牌,便于检查准备操作哪个控件。截图可以补足画布、自绘内容和布局信息。接口提供缩减树、跳过图像或只取图像的选项,使用前要想清楚下一步需要哪一种证据。
对有可访问性信息的按钮,优先读取当前存活会话内最新快照的 element_token。在该会话内,重新读取同一窗口的可访问性树会产生新 snapshot_id 并使旧令牌失效;但按当前参考,include_accessibility_tree: false 的只截图读取会保留当前元素行与令牌,不能把它视为刷新了可访问性树。只取树(include_screenshot: false)在窗口尺寸未变时保留上一张截图的像素坐标基准;只截图且 display_only: true 的预览不注册动作截图,不能用于定位输入。会话结束或空闲过期后,旧快照与令牌也不能继续使用。过期错误发生后重新观察并选取目标即可,不要将缓存里的数字索引当成永久控件编号。对截图定位,还要留住 capture_id、图像尺寸和窗口边界。
我会把观察、动作回执、后续观察放进同一条任务记录。日志保存目标标识、时间、结果状态和必要的错误码,正文或图像只在确有调试需要时保留。截图本身可能比日志字段更敏感,保存位置、保留时间和是否允许分享,需要在测试前说清楚。
图 03 示意的是显式请求结构化 elements 并重新读取可访问性树的情况;图中“下一次同窗口快照”指树刷新,不包括保留元素令牌的只截图读取。预览图像能否用于动作定位,仍须按上文的 display_only 规则判断。
树和图片出现矛盾时,暂停当前动作。控件标签能读到,却可能已经被弹窗遮住;列表里的行还在,但屏幕位置可能滚出了视口。把两份信息交叉核对,必要时缩小到单个窗口重新获取。截取范围一旦扩大到整个桌面,记录范围和隐私风险也随之改变,需要让任务范围覆盖这一步。
需要连续观察和点击时,应由同一持续的 MCP 连接或 SDK 运行时,在同一存活 session 内完成 get_window_state → 选择元素 → click → 重新观察并验收结果。使用命名 session 时,每个支持该字段的调用都传同一标签;不要把前文独立 CLI 调用返回的令牌带到新调用里。中途重连、会话结束或过期后,必须重新观察。点击会发出真实输入,执行前仍须核实目标与任务授权;本文未运行真实会话。
点击字段只作说明,不提供可直接执行的跨 CLI 管道:目标使用已核实的进程与窗口标识,element_token 从同一存活 session 的当前快照中对应按钮记录取得,session 使用该连接内的任务标签。具体请求形状以安装版本的 describe click 或 MCP 工具 schema 为准。
5. 窗口像素、桌面坐标和焦点分别处理
click 的 macOS 参考把窗口像素定义为 get_window_state 返回 PNG 内的坐标,左上角是原点。驱动会处理 Retina backing scale 和图像缩放。调用方若先按印象除以二,再交给驱动处理,位置就会偏移。窗口移动、图片裁剪、缩略图缩放都值得单独检查。
离线模型故意采用最简单的几何条件:窗口逻辑尺寸 800 × 600,图片倍率固定为二,桌面原点为 100、80。图片中的 80、80 对应桌面上的 140、120。这个算式只解释示例的坐标契约,实际接口应直接传所返回图片中的坐标,并按当前 schema 绑定窗口与 capture_id。
焦点也要进入验收条件。点击输入框之后重新观察,再输入文本;若期间切换了窗口,旧观察应失效。后台投递是否可用取决于应用和平台。遇到 background_unavailable 或经过核验的无效果结果,先检查官方建议,再由任务授权决定能否尝试前台动作。前台动作会影响使用者,不能在循环里悄悄扩大操作范围。
本文模拟器只接受整数像素,明确拒绝负数、超出图片边界的点,以及布尔值和 NaN。这个限制属于示例自己的契约,官方接口的坐标参数类型以 schema 为准。边界测试采用右侧和底部不包含的区间,因此宽度为 1600 的图像里,最大合法横坐标是 1599。
6. 从回执继续走到应用结果
官方运行机制把动作效果分为 confirmed、unverifiable、suspected_noop、partial 和 refused 等状态。verified 为真表示可访问性读回层面的确认。业务上的完成条件仍需自己定义,例如内容已经保存、计算器显示正确结果,或者导出的文件可以重新打开。
示例里点击保存时,回执立即返回 delivered,但 saved 字段要过两个虚拟时钟单位才更新。等待函数不断取得新观察,核对文本和 pending 状态,在截止点仍做最后一次检查。预期不一致会超时;取消会打断等待。这个安排把异步结果从一次函数返回中拆出来,测试可以稳定复现。
超时后的处理要看动作有没有可能生效。保存、发送或付款等操作不能因为没有收到确认就重复派发。先获取新状态,记录能确认到哪一步,再决定恢复方案。真实接入还需区分传输断开、驱动拒绝、应用忙碌和结果未知;本文的单线程模型没有实现进程故障恢复。
等待预算最好写进调用方,错误信息带上已经检查过的条件。真实程序可以使用单调时钟衡量超时,避免系统时间校正影响截止点。演练为了让测试可重复,使用手动推进的虚拟时钟,没有 sleep。用户取消和应用完成恰好相遇时,真实适配器还需重新获取末次状态,说明结果是否已经落地。
7. 一轮工作结束时,把 session 收好
当前 session 接口管理生命周期、光标和录制等附属状态。在同一持续的 MCP 连接或 SDK 运行时中,start_session 可选;支持 session 的后续调用重复使用同一短标签。会话的可见性与清理受已认证传输的作用域约束,另起一次 CLI 调用并填写同名标签不能接管前一次会话。它不把目标窗口固定在会话里;窗口或桌面目标仍由每次动作选择。旧 capture_scope 相关入口已标记为兼容字段,新接入不要围绕它设计。
任务正常结束、异常中断或用户取消,都应走到清理分支。真实客户端需要停止派发新动作,再通过持有该会话的连接调用 end_session,并检查清理结果。共享 daemon 可能还服务其他任务,结束自己的会话后不应顺便停止它。只有确认本次独占实例时,才按 daemon 参考执行 stop 并复查 status。
模拟器使用上下文管理器保证异常时执行 close,释放按住的键并丢弃待完成任务;重复 close 只清理一次。真实应用中取消已经派发的动作,未必能撤销应用产生的变化。取消记录应包含最后一次观察,以及需要人工核对的未决结果,不能把本地停止等同于远端回滚。
会话标签适合使用 calc-smoke 这类短名字,不放用户名、文件内容或密钥。标签不是认证凭据,也不提供桌面隔离:不同 session 仍共享屏幕、键盘和焦点。原生输入逐个动作准入,也不代表整轮任务独占桌面;需要协调其他任务和人的输入。结束 session 不等于退出目标应用或回滚已产生的变化,关闭应用或删除测试文件属于另外的操作。
清理字段说明:在持有本轮会话的持续连接中调用 end_session,session 指向该连接内本轮使用的标签。不要另起 cua-driver call end_session 来清理先前独立调用的同名会话。本文未验证真实 GUI、MCP 或 SDK 会话的清理结果。
8. 认证与动作权限分别验收
客户端能够连上驱动,只说明连接这一层成立。系统是否允许截图、当前任务是否允许输入到某个应用,还要分别检查。官方权限指南说明运行时在触碰桌面前做授权检查,由可信启动方选择模式;正在运行的 daemon 不能通过普通工具调用重新配置。
standard 是默认模式,允许的桌面输入范围较宽。无人值守场景应仔细研究 bounded 模式与能力清单,把工具和资源范围收紧;能力清单需要被审阅和批准。接入已登录浏览器配置、读取截图后向外发送、修改系统设置,都应按实际任务单独考虑。配置文件中的口令或令牌不应出现在文章、示例日志和问题报告里。
模拟器把 authenticated、observe_allowed 与 input_allowed 分开。测试会证明认证通过的会话仍可能被拒绝输入,权限撤销后已有观察也不能继续使用。这是接口层的教学断言。真正的认证、安全策略、并发竞争和跨进程隔离需要可信运行时提供,几个 Python 布尔值无法承担这些职责。
9. 运行离线 driver 契约演练
本页附上两份完整文件,分别保存为 driver_contract.py 和 test_driver_contract.py,放在同一个目录。使用 Python 3.10 或更新版本即可运行,无需安装第三方包。程序只改动进程内的对象;observe 返回 metadata_only 记录,不生成截图,也不伪装成 Cua Driver 的 SDK。
离线演练命令:本次已执行
python3 driver_contract.py
python3 -m unittest -v
演练会先点击模拟输入框,再故意拿旧观察输入,得到 stale_capture。重新观察后输入 driver contract,点击保存并等待读回相同内容,最后输出 closed 和一次清理记录。后面的测试覆盖错误窗口、尺寸变化、焦点切换、重复使用观察、边界像素、权限撤销、超时与取消。
本次实测 Python 3.12.14,40 项测试通过(普通与 -O 模式均通过)。测试证明的是这些自编断言在当前离线实现中成立。GUI 图像识别、真实输入法、多显示器、网络断线、平台后台能力以及 Codex 的真实 MCP 注册都未执行。接入真实环境时,仍应在一次性测试数据上重做观察与结果验收。
等待预算也包含 on_tick 回调通过 advance 消耗的虚拟时间。旧实现会在回调后无条件再推进一格:预算为 1、回调推进 1 格时,原本要在第 2 格完成的保存会被误报为按时成功。现在回调返回后先检查会话状态,到达截止点便直接重新观察;截止点上的完成可以通过,超过截止点的观察则返回 timeout。新增四项测试覆盖用尽预算、恰在截止点完成、超时后已完成和截止点取消。超时不代表保存已撤销,测试会继续读回结果,而不会再次点击保存。这个同步回调不能被等待函数抢占,也不会被回滚;若它本身推进过多或不返回,不能把此虚拟预算当作真实进程的硬超时。
实际输出字段摘要:完整 stdout 在复现包 qa/demo-output.json
{
"kind": "offline_metadata_simulation",
"stale_error": "stale_capture",
"receipt": {"delivered": true, "effect": "unverifiable"},
"result": {"saved": "driver contract", "tick": 2},
"closed": "closed",
"cleanup_count": 1
}
完整文件:driver_contract.py
driver_contract.py · Python 标准库
"""Offline teaching contract; no Cua imports, pixels, OS input, or network."""
from dataclasses import asdict, dataclass
import json
from itertools import count
SESSION_IDS = count(1)
class DriverError(RuntimeError):
pass
@dataclass(frozen=True)
class Observation:
session: str
capture: int
revision: int
window: int
origin: tuple
size: tuple
scale: int
focus: str
text: str
saved: str
pending: bool
tick: int
kind: str = "metadata_only"
class MockDriver:
"""Single-threaded deterministic fixture, not a vendor-compatible driver."""
def __init__(self, *, authenticated=True, observe=True, input_allowed=True):
self.authenticated = authenticated
self.observe_allowed = observe
self.input_allowed = input_allowed
self.state = "new"
self.session = f"mock-session-{next(SESSION_IDS)}"
self.window = 7
self.origin = (100, 80)
self.size = (800, 600)
self.scale = 2
self.focus = "none"
self.text = ""
self.saved = ""
self.pending = None
self.tick = 0
self.revision = 0
self.capture = 0
self.latest = None
self.held_keys = set()
self.events = []
self.cleanup_count = 0
def connect(self):
if self.state != "new":
raise DriverError("bad_lifecycle")
if not self.authenticated:
raise DriverError("unauthenticated")
self.state = "open"
self.events.append({"event": "connect"})
return self
def __enter__(self):
return self.connect()
def __exit__(self, *_):
self.close()
def _live(self):
if self.state != "open":
raise DriverError(self.state)
def _changed(self):
self.revision += 1
self.latest = None
def observe(self):
self._live()
if not self.observe_allowed:
raise DriverError("observation_denied")
self.capture += 1
self.latest = Observation(
self.session, self.capture, self.revision, self.window,
self.origin, self.size, self.scale, self.focus, self.text,
self.saved, self.pending is not None, self.tick,
)
return self.latest
def _admit(self, observation, window):
self._live()
if not self.input_allowed:
raise DriverError("input_denied")
if type(window) is not int or window != self.window:
raise DriverError("wrong_window")
if not isinstance(observation, Observation):
raise DriverError("invalid_observation")
if observation != self.latest or observation.revision != self.revision:
raise DriverError("stale_capture")
if self.tick - observation.tick > 2:
raise DriverError("expired_capture")
def _consume(self, event):
self._changed()
self.events.append(event)
return {"delivered": True, "effect": "unverifiable"}
def click(self, observation, x, y, *, window=7):
self._admit(observation, window)
if type(x) is not int or type(y) is not int:
raise DriverError("integer_pixels_required")
w, h = self.size
if not (0 <= x < w * self.scale and 0 <= y < h * self.scale):
raise DriverError("outside_image")
if self.pending is not None:
raise DriverError("busy")
lx, ly = x / self.scale, y / self.scale
target = "none"
if 20 <= lx < 300 and 20 <= ly < 70:
target = "input"
elif 320 <= lx < 420 and 20 <= ly < 70:
target = "save"
self.focus = target
if target == "save":
self.pending = (self.tick + 2, self.text)
return self._consume({
"event": "click", "target": target,
"screen_point": [self.origin[0] + lx, self.origin[1] + ly],
})
def type_text(self, observation, text, *, window=7):
self._admit(observation, window)
if self.focus != "input":
raise DriverError("wrong_focus")
if type(text) is not str or len(text) > 100:
raise DriverError("invalid_text")
self.text += text
return self._consume({"event": "type", "characters": len(text)})
def key_down(self, observation, key, *, window=7):
self._admit(observation, window)
if type(key) is not str or key not in {"Shift", "Control"}:
raise DriverError("unsupported_key")
self.held_keys.add(key)
return self._consume({"event": "key_down", "key": key})
def external_focus(self, name):
self._live()
self.focus = name
self._changed()
def resize(self, width, height, *, scale=2):
self._live()
if any(type(v) is not int or v <= 0 for v in (width, height, scale)):
raise DriverError("invalid_geometry")
self.size, self.scale = (width, height), scale
self._changed()
def advance(self, ticks=1):
self._live()
if type(ticks) is not int or ticks < 0:
raise DriverError("invalid_ticks")
self.tick += ticks
if self.pending is not None and self.tick >= self.pending[0]:
self.saved = self.pending[1]
self.pending = None
self._changed()
def wait_saved(self, expected, *, budget=4, on_tick=None):
self._live()
if type(budget) is not int or budget < 0:
raise DriverError("invalid_budget")
deadline = self.tick + budget
while True:
observed = self.observe()
if observed.tick > deadline:
raise DriverError("timeout")
if observed.saved == expected and not observed.pending:
return observed
if self.tick >= deadline:
raise DriverError("timeout")
if on_tick:
on_tick(self)
self._live()
if self.tick >= deadline:
continue
self.advance()
def cancel(self):
self._live()
self.state = "cancelled"
self.pending = None
self.latest = None
self.events.append({"event": "cancel"})
def close(self):
if self.state == "closed":
return
self.held_keys.clear()
self.pending = None
self.latest = None
self.state = "closed"
self.cleanup_count += 1
self.events.append({"event": "close"})
def demo():
driver = MockDriver()
with driver:
initial = driver.observe()
driver.click(initial, 80, 80)
stale_error = None
try:
driver.type_text(initial, "not admitted")
except DriverError as error:
stale_error = str(error)
driver.type_text(driver.observe(), "driver contract")
receipt = driver.click(driver.observe(), 700, 80)
result = driver.wait_saved("driver contract")
return {
"kind": "offline_metadata_simulation",
"stale_error": stale_error,
"receipt": receipt,
"result": asdict(result),
"closed": driver.state,
"cleanup_count": driver.cleanup_count,
"events": driver.events,
}
if __name__ == "__main__":
print(json.dumps(demo(), indent=2, ensure_ascii=False))
完整文件:test_driver_contract.py
test_driver_contract.py · Python 标准库
"""Run with: python3 -m unittest -v. Standard library only."""
from dataclasses import replace
import unittest
from driver_contract import DriverError, MockDriver, demo
class DriverContractTests(unittest.TestCase):
def setUp(self):
self.d = MockDriver().connect()
self.addCleanup(self.d.close)
def reject(self, code, fn):
with self.assertRaisesRegex(DriverError, "^" + code + "$"):
fn()
def focus_input(self):
self.d.click(self.d.observe(), 80, 80)
def start_save(self, text="note"):
self.focus_input()
self.d.type_text(self.d.observe(), text)
return self.d.click(self.d.observe(), 700, 80)
def test_demo(self):
result = demo()
self.assertEqual(result["result"]["saved"], "driver contract")
self.assertEqual(result["cleanup_count"], 1)
self.assertEqual(result["closed"], "closed")
def test_requires_authentication(self):
self.reject("unauthenticated", MockDriver(authenticated=False).connect)
def test_auth_does_not_grant_input(self):
with MockDriver(input_allowed=False) as d:
self.reject("input_denied", lambda: d.click(d.observe(), 80, 80))
def test_observation_permission(self):
self.d.observe_allowed = False
self.reject("observation_denied", self.d.observe)
def test_observation_metadata_only(self):
o = self.d.observe()
self.assertEqual(o.kind, "metadata_only")
self.assertFalse(hasattr(o, "png"))
self.assertEqual((o.size, o.scale), ((800, 600), 2))
def test_click_changes_focus_and_revision(self):
o = self.d.observe()
self.d.click(o, 80, 80)
n = self.d.observe()
self.assertEqual(n.focus, "input")
self.assertGreater(n.revision, o.revision)
def test_coordinate_conversion(self):
self.focus_input()
self.assertEqual(self.d.events[-1]["screen_point"], [140.0, 120.0])
def test_old_capture_after_new_observation(self):
old = self.d.observe()
self.d.observe()
self.reject("stale_capture", lambda: self.d.click(old, 80, 80))
def test_consumed_capture(self):
old = self.d.observe()
self.d.click(old, 80, 80)
self.reject("stale_capture", lambda: self.d.click(old, 80, 80))
def test_other_session_capture(self):
current = self.d.observe()
with MockDriver() as other:
foreign = other.observe()
self.assertNotEqual(current.session, foreign.session)
self.reject("stale_capture", lambda: self.d.click(foreign, 80, 80))
def test_tampered_metadata(self):
o = replace(self.d.observe(), scale=1)
self.reject("stale_capture", lambda: self.d.click(o, 80, 80))
def test_wrong_window(self):
o = self.d.observe()
self.reject("wrong_window", lambda: self.d.click(o, 80, 80, window=8))
def test_bool_is_not_a_window_id(self):
o = self.d.observe()
self.reject("wrong_window", lambda: self.d.click(o, 80, 80, window=True))
def test_outside_image(self):
for x, y in [(-1, 80), (1600, 80), (80, 1200)]:
o = self.d.observe()
self.reject("outside_image", lambda: self.d.click(o, x, y))
def test_reject_noninteger_pixels(self):
for x in [True, 80.5, float("nan"), "80"]:
o = self.d.observe()
self.reject("integer_pixels_required", lambda: self.d.click(o, x, 80))
def test_expired_capture(self):
o = self.d.observe()
self.d.advance(3)
self.reject("expired_capture", lambda: self.d.click(o, 80, 80))
def test_resize_invalidates(self):
o = self.d.observe()
self.d.resize(400, 300, scale=1)
self.reject("stale_capture", lambda: self.d.click(o, 80, 80))
def test_bad_geometry(self):
self.reject("invalid_geometry", lambda: self.d.resize(0, 300))
def test_focus_change_invalidates(self):
self.focus_input()
o = self.d.observe()
self.d.external_focus("other-window")
self.reject("stale_capture", lambda: self.d.type_text(o, "hello"))
self.assertEqual(self.d.text, "")
def test_fresh_observation_wrong_focus(self):
self.reject("wrong_focus", lambda: self.d.type_text(self.d.observe(), "x"))
def test_type_changes_visible_metadata(self):
self.focus_input()
self.d.type_text(self.d.observe(), "draft")
self.assertEqual(self.d.observe().text, "draft")
def test_invalid_text_no_mutation(self):
self.focus_input()
self.reject("invalid_text", lambda: self.d.type_text(self.d.observe(), 4))
self.assertEqual(self.d.text, "")
def test_receipt_does_not_mean_saved(self):
receipt = self.start_save()
self.assertTrue(receipt["delivered"])
self.assertEqual(receipt["effect"], "unverifiable")
self.assertEqual(self.d.observe().saved, "")
def test_wait_completes_at_deadline(self):
self.start_save()
o = self.d.wait_saved("note", budget=2)
self.assertEqual(o.saved, "note")
self.assertEqual(o.tick, 2)
def test_busy_save_not_replayed(self):
self.start_save()
self.reject("busy", lambda: self.d.click(self.d.observe(), 700, 80))
self.assertEqual(sum(e["event"] == "click" for e in self.d.events), 2)
def test_wait_timeout(self):
self.start_save()
self.reject("timeout", lambda: self.d.wait_saved("note", budget=1))
self.assertEqual(self.d.tick, 1)
def test_wrong_expected_result_times_out(self):
self.start_save()
self.reject("timeout", lambda: self.d.wait_saved("wrong", budget=3))
def test_zero_budget_observes_once(self):
self.reject("timeout", lambda: self.d.wait_saved("x", budget=0))
self.assertEqual(self.d.tick, 0)
def test_cancel_during_wait(self):
self.start_save()
self.reject("cancelled", lambda: self.d.wait_saved(
"note", on_tick=lambda d: d.cancel()))
self.assertIsNone(self.d.pending)
self.reject("cancelled", self.d.observe)
def test_cleanup_on_exception(self):
d = MockDriver()
with self.assertRaises(ValueError):
with d:
d.key_down(d.observe(), "Shift")
raise ValueError("test")
self.assertEqual(d.held_keys, set())
self.assertEqual(d.cleanup_count, 1)
def test_idempotent_close(self):
self.d.close()
self.d.close()
self.assertEqual(self.d.cleanup_count, 1)
self.reject("closed", self.d.observe)
def test_closed_cannot_reconnect(self):
self.d.close()
self.reject("bad_lifecycle", self.d.connect)
def test_permission_revoked_before_action(self):
o = self.d.observe()
self.d.input_allowed = False
self.reject("input_denied", lambda: self.d.click(o, 80, 80))
self.assertEqual(self.d.focus, "none")
def test_bad_budget_and_ticks(self):
self.reject("invalid_budget", lambda: self.d.wait_saved("", budget=True))
self.reject("invalid_ticks", lambda: self.d.advance(-1))
def test_unhashable_key_is_contract_error(self):
for key in [[], {}]:
with self.subTest(key=key):
self.reject("unsupported_key", lambda: self.d.key_down(
self.d.observe(), key))
def test_unsupported_key(self):
self.reject("unsupported_key", lambda: self.d.key_down(
self.d.observe(), "Escape"))
def test_callback_consumes_last_tick_without_extra_advance(self):
self.start_save()
with self.assertRaisesRegex(DriverError, '^timeout$'):
self.d.wait_saved('note', budget=1, on_tick=lambda d: d.advance(1))
self.assertEqual(self.d.tick, 1)
self.assertEqual(self.d.saved, '')
self.assertIsNotNone(self.d.pending)
self.d.advance(1)
self.assertEqual(self.d.wait_saved('note', budget=0).saved, 'note')
self.assertEqual(sum(e['event'] == 'click' for e in self.d.events), 2)
def test_callback_completion_at_deadline_is_accepted(self):
self.start_save()
result = self.d.wait_saved('note', budget=2, on_tick=lambda d: d.advance(2))
self.assertEqual(result.tick, 2)
self.assertEqual(result.saved, 'note')
def test_callback_completion_after_deadline_is_not_timely_success(self):
self.start_save()
with self.assertRaisesRegex(DriverError, '^timeout$'):
self.d.wait_saved('note', budget=1, on_tick=lambda d: d.advance(3))
self.assertEqual(self.d.tick, 3)
self.assertEqual(self.d.observe().saved, 'note')
self.assertIsNone(self.d.pending)
def test_callback_cancel_at_deadline_is_not_success(self):
self.start_save()
def cancel_after_completion(d):
d.advance(2)
d.cancel()
with self.assertRaisesRegex(DriverError, '^cancelled$'):
self.d.wait_saved('note', budget=2, on_tick=cancel_after_completion)
self.assertEqual(self.d.tick, 2)
self.assertEqual(self.d.saved, 'note')
if __name__ == "__main__":
unittest.main()
源码中的 Observation 是不可变数据记录。每次 observe 都产生新的 capture 序号,动作检查它是否仍是当前记录。点击、输入、尺寸变更和外部焦点切换都会推进 revision。这样可以用很少的代码检验旧观察被误用的路径。它采用更保守的每动作重新观察规则,没有试图复刻厂商全部缓存语义。
10. 排查时先缩小故障位置
应用列表为空,先检查驱动是否进入正确的图形桌面;拿到树却没有图片,检查屏幕录制授权;元素令牌失效,取新快照;动作有回执但内容没变,检查 effect 与新观察。官方故障排查页还提示 diagnose 可能含用户名、路径和进程号,分享前应清理敏感信息。
macOS 的不同 Space、最小化窗口和自绘应用会影响观察或后台输入。Linux 下还要区分 X11、XWayland 与具体 Wayland 合成器,不能把某个环境通过的结果当成整个 Linux 的保证。跨平台迁移之前,先核对 平台支持矩阵,再安排对应的实机验收。
我倾向于保留一份很短的接入记录:驱动版本与启动方式、应用版本、目标窗口、授权范围、最后一次可确认结果。这样升级后出现回归,可以回到同一条路径复查。文章里的资料日期、完整源码和测试输出也为这个目的保留;真实 GUI 结果只有在实际完成那轮操作后才能填写。
11. 公开资料与复现文件
本文依据 Cua 官方文档和 trycua/cua 公开仓库编写。仓库核验快照为 1fe6ce53688c;在线文档会继续更新,实际安装版本的 describe 输出应纳入接入检查。完整源码已经直接列在本页,复制两份文件即可复现,不依赖额外下载地址。
运行本文代码会操作电脑吗?
不会。代码只更新内存状态,不连接驱动、网络、模型或桌面。
这些测试证明 Cua Driver 兼容我的应用吗?
不能。它们验证自编契约模型;真实应用、平台和 GUI 输入还需要独立验收。
Cua Driver 需要自己选择模型吗?
模型和任务循环由接入的 agent harness 负责,驱动处理桌面观察与动作。
窗口截图里的坐标要先换算 Retina 倍率吗?
按当前 click schema 传返回图片中的像素坐标,由驱动处理对应变换,避免重复缩放。