我继承过的每一个录制式移动测试都以同样的方式失效:有人移动了一个按钮。

录制脚本写着「在 (340, 712) 处点击」。重构把按钮上移了一行,测试脚本仍然在原坐标点击——现在点到的是空白,或是后来落在那里的其他内容。它不会立刻失败。三次迭代后,它开始以莫名的方式失败,而那时已经没人再信任这套测试了。

解决方案不是改进录制器,而是录制不同的东西:不记录「你在哪里点」,而是记录「你点了什么」。这需要元素树,而我们一度没有。

tapflow 是一款开源、自托管工具,可将 iOS 模拟器和 Android 模拟器串流到浏览器,让整个团队无需安装任何东西即可测试构建版本。直到现在,它传输的只是像素和点击。本文介绍如何在没有窗口的情况下,从模拟器中提取元素树,并同时支持两个平台。我们用这棵树做什么——重放可在重构后继续工作的流程——将在本系列下一篇文章中介绍。

这所支持的自动化路线——流程运行器和 MCP 服务器——仍是实验性的。成熟的方案仍是手动浏览器 QA 路径。


限制:没有 WebDriverAgent,也没有模拟器窗口

tapflow 已经能在不使用 WebDriverAgent 的情况下向 iOS 模拟器注入触摸——它加载 CoreSimulator.framework,并通过 SimDeviceLegacyHIDClient 推送 HID 事件(这个故事见 第 1 集)。串流直接读取 framebuffer IOSurface。两条路径都不需要 Simulator.app 出现在屏幕上,这是有意为之:一台放在壁橱里的代理 Mac 运行四个模拟器时,不应同时照看四个窗口。

因此,我们用来获取树的东西也必须遵守同一规则。无需安装和同步 Xcode 的 WDA。模拟器窗口也不需要在屏幕上。

我们最初的尝试正好遇到了这个限制。macOS 暴露了无障碍 API(AXUIElement),Simulator.app 通过它发布内容。我们为此写了一个辅助程序,在开发者的笔记本上完美运行。但在无头模式下,它什么都不返回,因为 AX 桥接只在模拟器窗口正在渲染时存在。用 simctl boot 启动的模拟器在当前 Xcode 中根本不会打开窗口。

于是我们得到一个只在根本不需要它的场景下才有效的树读取器。


常驻 XCUITest 运行器,运行在模拟器内部

XCUITest 可通过 bundle id 读取任意应用的元素树——这就是它的设计初衷——而且它运行在模拟器内部,因此不需要窗口。问题是 xcodebuild test 的设计围绕「运行测试、打印结果、退出」,而我们需要的是能连续响应查询数小时的东西。

于是测试目标实际上不测试任何内容。它启动一个 HTTP 服务器并阻塞:

// TreeRunner — 永不结束的 UI 测试
func testServeTree() {
    let server = try! TreeServer(port: port)
    server.start()
    // 永远阻塞:进程常驻并持续提供服务
    RunLoop.current.run()
}

进入全屏模式 退出全屏模式

服务器大约 100 行 Network.framework 代码,提供两条路由:GET /health 用于就绪检查,GET /tree?bundleId=<id> 返回 XCUIApplication(bundleIdentifier:).debugDescription——以文本形式返回完整的元素子树,包括屏幕上所有元素的角色、标签、标识符和 frame。它不是文档化的 API 契约,我会在限制部分再讨论。

网络绑定是我最低估的细节。iOS 模拟器共享主机的网络栈,因此默认的 NWListener 绑定会监听所有接口。这会将受测应用的所有屏幕无认证地暴露给本地网络,而这正是自托管方案本应避免的。requiredInterfaceType 还不够。你必须固定本地端点:

params.requiredLocalEndpoint = NWEndpoint.hostPort(
    host: NWEndpoint.Host("127.0.0.1"), port: nwPort
)

进入全屏模式 退出全屏模式

绑定失败也会立即退出进程,因为一个静默处于 .failed 状态的监听器对父进程来说仍然是「存活」的,会把一次陈旧端口冲突变成 90 秒就绪轮询后的两分钟谜题。

在 Node 端,XCUITreeReader 先用 build-for-testing 构建运行器一次(已缓存,因此只有第一次查询会付出构建成本),再用 test-without-building 启动分离进程,轮询 /health,然后从常驻进程提供查询。它在第一次树查询时才惰性启动,因此手动 QA——主要路径,设计师只想检查构建——永远不会为它不使用的运行器支付启动成本。

关闭时会杀死分离的进程组,然后对模拟器内的宿主应用也执行 simctl terminate,因为该进程不属于进程组,否则会继续占用端口:

// 分离式启动 → 子进程拥有自己的进程组;杀死整个组
if (pid) process.kill(-pid, 'SIGTERM')
execFile('xcrun', ['simctl', 'terminate', udid, RUNNER_HOST_BUNDLE], () => {})

进入全屏模式 退出全屏模式

有一条规则比其他一切都重要:树读取永远不会返回空数组来表示「出错了」。垃圾响应体、错误的 bundle id,或应用不再处于前台,都会产生明确的错误。「屏幕没有元素」应该只表示这个意思。如果它同时可能意味着基础设施失败,那么基于它构建的每一个测试都会变得模棱两可。


Android 是简单的那个

uiautomator dump 已经能给出 XML 层级结构,解析也很直观。唯一需要注意的点是 uiautomator 在转储前会等待窗口空闲,因此带有持续动画的应用——旋转指示器或循环启动画面——永远不会达到空闲状态,转储会挂起。

超时必须在设备上运行,而不是在主机上:

adb exec-out timeout 10 uiautomator dump /dev/tty

进入全屏模式 退出全屏模式

这个 timeout 来自 Android 的 toybox。同样的规则适用于 iOS:基础设施故障绝不能看起来像有效的 UI 输出。


统一模式,以及为什么 frame 归一化很重要

两个后端都解析成相同的元素结构:

interface UIElement {
  role: 'button' | 'text' | 'input' | 'cell' | 'image' | ...
  label: string
  identifier: string
  frame: { x: number; y: number; width: number; height: number } // 0–1
  enabled: boolean
}

进入全屏模式 退出全屏模式

role 将两种词汇表归一化:iOS 的 XCUIElementType 和 Android 的类名(AppCompatButtonAutoCompleteTextViewRecyclerView),排序规则是复合名称优先于通用子串——ToggleButton 必须先匹配 switch,再匹配 Button

最大的简化来自将所有 frame 归一化到与触摸路径相同的 0–1 坐标空间。iOS 的 debugDescription frame 以点为单位,需除以窗口 frame。Android 的 bounds 除以根节点(已覆盖整个显示区域),因此横屏模式无需额外的 wm size 查询即可工作。

之后,整个流水线变为:

tree query → element.frame center → tap(x, y) → 与人工点击相同的 HID 路径

进入全屏模式 退出全屏模式

无需第二次连接设备,无需单独的驱动程序,也无需额外的坐标转换。


这带来了什么

过去,测试描述点击位置只有一种方式:

(340, 712)

进入全屏模式 退出全屏模式

现在它可以直接说:

tapOn: "Sign in"

进入全屏模式 退出全屏模式

这会通过树进行解析——优先精确 identifier,其次精确 label,最后部分 label——匹配元素的 frame 中心会走上面展示的同一套点击路径。

这棵树也不仅用于流程运行器:

GET /api/v1/sessions/:sessionId/ui-tree     # REST
query_ui_tree                               # MCP tool

进入全屏模式 退出全屏模式

驱动会话的 LLM 代理读取的模式与流程完全相同。第 4 集通过截图让代理拥有了「眼睛」。现在它还拥有了所见内容的名称。


值得注意的限制

  • iOS 的树来自 debugDescription 它是一种文本格式,而非稳定 API。解析器是一个带夹具测试的纯函数,因此 Xcode 格式变化会在生产前先让单元测试失败——但格式仍有可能改变。
  • iOS 上的 enabled 是近似的。 debugDescription 不暴露该属性,因此元素默认 enabled: true
  • 同一时刻只有一个常驻运行器。 它监听固定端口(xcodebuild 不会将主机环境变量传播到模拟器内的运行器,因此无法按设备使用不同端口)。多设备并发树查询会被延迟。

试用

npm install -g tapflow
tapflow start          # → http://localhost:4000

进入全屏模式 退出全屏模式

本系列下一篇文章:当测试能够命名它所点击的对象后,你可以构建什么,以及我们在运行器中拒绝猜测的四个地方。

如果你用其他方式解决了无头树的问题,尤其是比 debugDescription 更稳定且不需要 WebDriverAgent 的方案,我很想听听。