单测能告诉你一个函数算得对不对,却没法告诉你 splash 到底切没切到主页、消息发出去后回执有没有回来、某个按钮点下去界面有没有反应。这类问题只有把 app 真的跑在设备上、真的去点一下才知道。过去这件事一直是人工的:连真机、复现路径、截图、翻日志,跑一遍像样的回归要占掉一个人小半天。
我们在工程里搭了一条链路,把这件事交给 agent:给它一份写好的剧本,它自己连上正在运行的 app,按步骤点、输入、滑动,跑完出一份报告。这条链路叫 app-operator。它背后依赖一个叫 Marionette 的插件,先从这个插件说起。
一、Marionette:让 AI 能"看见",也能"操作"一个 app
Marionette 做的事可以一句话概括:把"操控一个正在运行的 Flutter app"变成一组 AI 能直接调用的工具。仓库地址
它分两半:
一半在 app 里,是 marionette_flutter 这个包。它提供一个 MarionetteBinding,在 app 启动时替换掉默认的 WidgetsFlutterBinding,挂上之后,app 的 widget 树、日志、渲染画面就能通过 Dart 的 VM Service 这条调试通道被外部读取和操控。换句话说,它在 app 身上开了一个"可以被远程遥控"的口子。
另一半在 app 外面,是 marionette_mcp 这个 server。它把上面那个口子翻译成 MCP 协议的工具,接到 Claude / Codex 这类 agent 上。我们在工程根目录的 .mcp.json 里把它注册好了,用的是工程内的 fvm dart 来启动:
json
"marionette": {
"command": "zk_app/.fvm/flutter_sdk/bin/dart",
"args": ["pub", "global", "run", "marionette_mcp"]
}
接通之后,agent 拿到的能力分两类。一类是"看":抓 app 从启动到现在的全量日志、截当前屏、读出当前屏所有可交互 widget 的清单(每个元素的 key、文本、类型、坐标)。另一类是"操作":按文本点、按 key 点、按坐标点、长按、双击、往输入框打字、滑动、返回。
到这一步,事情的性质就变了:agent 不再只是"写测试代码",而是能像一个人那样,看着屏幕、照着步骤,把 app 点一遍。

二、工程里的 app-operator 链路
插件只是给了能力。真正让它在团队里跑起来、能复用、能留痕的,是围绕它搭的这条链路。下面按"一次回归是怎么跑起来的"顺下来讲。
2.1 它负责什么,不负责什么
app-operator 是工程里的一个 agent(定义在 .claude/agents/app-operator.md,跑在 sonnet 上)。它的职责很专一:给它一个 spec 剧本,它按剧本驱动 app、跑完、产出报告。它不负责构建、不负责装包、不负责登录------这些前置环境由调用方准备好,它只管"按剧本操作"这一段。
工程里还有一个容易和它混淆的角色,是 marionette-debug skill。两者都用 Marionette,但分工是反的:skill 用来临时诊断,"app 现在卡哪了,瞅一眼日志和截图就走",尽量不改变 app 的现状;app-operator 用来跑回归,它会真的去点、去断言、改变 app 状态,并且每次都落一份报告。简单讲:想看现状用 skill,想按剧本回归用 agent。 这条边界看着琐碎,但很重要------诊断的前提是"别动现状",回归的前提是"按预期改状态",两件事混在一个角色里迟早出乱子。
2.2 让 agent 自己找到入口(这条链路最关键的一环)
这一段是整条链路能"无人值守"的真正原因,但它一点都不性感------就是一个日志重定向。
背景是这样:agent 要操控 app,得先连上 app 的 VM Service,而这个地址形如 http://127.0.0.1:60789/6i_RqcARFZ0=/,端口和 token 每次 flutter run 都变。如果每次都要人把这串地址复制出来贴给 agent,那这套东西就谈不上自动化------人还是被拴在中间。
我们的做法是给启动加一层固定约定。工程里用 make dev 起 app:
makefile
dev:
@cd zk_app/apps/zhike && fvm exec flutter run \
--flavor beta --dart-define=FLAVOR=beta \
$${DEVICE:+-d $$DEVICE} 2>&1 | tee /tmp/zhike-flutter-run.log
关键就在结尾的 tee /tmp/zhike-flutter-run.log:把 flutter run 的输出原样抄一份到一个固定路径。VM Service 的地址会被 Flutter 打印在这份输出里,于是 agent 不需要任何人帮忙,自己就能走完这套:
-
读
/tmp/zhike-flutter-run.log; -
用正则
VM Service on .* is available at: (https?://[^\s]+)抓地址,取最后一次匹配(app 可能重启过,最后一次才是当前活着的那个); -
把
http://...:60789/TOKEN=/转成 WebSocket 地址ws://...:60789/TOKEN=/ws(换协议、末尾补ws); -
connect 进去。
就这么点东西。没有服务发现、没有额外进程,一个 tee 加一段 grep,就把"人肉粘地址"这个唯一的人工卡点消掉了。这类不起眼的工程化细节,往往才是一条自动化链路能不能真正跑起来的分水岭------能力是插件给的,但"让能力自动接上"得自己缝。

顺带一提,这也是它的一个脆弱点:app 一旦 hot restart 或重启,地址就变了,得重新 grep 最新一次。所以 agent 每次连接前都重新读日志取最后一条,而不是缓存。
2.3 Spec:把"怎么测"写成可版本化的剧本
agent 不自由发挥,它严格照着 spec 走。spec 就是用 Markdown 写的测试剧本,放在 docs/app-operator/specs/ 下,五段式结构:Setup(起点状态)、Steps(一步一行的操作)、Assert(收尾验收)、Teardown(收拾回起点)、Notes(已知的不稳定点和假设)。
拿工程里一条真实的冒烟 spec send-text-message 看,它要验的是"在某个群发一条文本消息,发送成功、回执到位、输入框清空":
markdown
## Setup
- 当前在消息列表首页(底部 Tab "消息" 高亮),已登录
- 列表里有 "测试礼物" 这一条
## Steps
1. `tap: "测试礼物"`
2. `wait_for_text: "33 成员"`
3. `tap_coordinates: [150, 704]` # 聚焦输入框(TextField 没 ValueKey,暂用坐标)
4. `enter_text: "operator smoke test 一条消息"`
5. `tap_coordinates: [324, 419]` # 发送按钮(无名 IconButton)
6. `wait_for_key: receipt_status_sent`
## Assert
- `visible_text: "operator smoke test 一条消息"` # 气泡文本
- `visible_key: receipt_status_sent` # 已送达图标
## Teardown
- `press_back`
- `wait_for_text: "搜索全站动态、项目、圈子"` # 回到消息列表
每个动词对应一个 Marionette 工具:tap / tap_key / enter_text / scroll_to / wait_for_text 等等;断言这边有 visible_text / visible_key / absent_text / 按类型数数量这几种谓词。写 spec 有个原则:能用文本或 key 定位就别用坐标 ,坐标是最后选择,用了要在 Notes 里写清假设------上面那条 spec 之所以用 [324, 419] 点发送,就是因为发送按钮还没加 ValueKey,这事在 Notes 里专门记了一笔。
把测试写成 spec 而不是写成一段一次性的对话,好处是它能进 git、能 review、能被反复跑、能演进。它是一份资产,不是一次操作。
2.4 一次 run 的完整生命周期
agent 接到 spec 后,跑的是一条固定的流水线,每一步都有明确的失败处理。
连接:按 2.2 的办法拿到 ws 地址,connect。如果返回 "No isolate found with ext.flutter.marionette...",说明 app 没挂上 binding(多半是拿 release/profile 跑的),直接中止报告,不瞎试。
Setup 校验 :spec 的 Setup 段描述了"应该从哪个页面、看到哪些元素开始"。agent 先 get_interactive_elements 读一遍当前屏,跟 Setup 的关键词比对。对不上就中止,报告里写清"预期 X,实际 Y"------绝不在错误的起点上硬跑,那只会产出一份没意义的失败报告。
跑 Steps :按顺序执行,每一步记下动作、结果、耗时。这里有一条最重要的纪律------单步失败立即中止,不重试 :某一步 Marionette 报错,或者 wait_for_* 在超时(默认 5 秒)内没等到目标,agent 立刻停,截图、抓最近的日志写进报告,然后收尾。不重试是刻意的:重试会把"偶发"和"真坏"搅在一起,掩盖真正的问题。异步是常态(IM 消息、接口回包都不是同步的),所以剧本里靠 wait_for_* 轮询去等,而不是用 sleep 硬等一个拍脑袋的时长。
Assert :Steps 跑完,逐条验收 Assert,每条 get_interactive_elements 查一遍当前屏。
Teardown :无论前面成没成都要跑,尽力把 app 收拾回起点(通常是返回到消息列表),保证下一条用例从干净状态开始。收不回去就在报告里 warn 一句,也不反复重试。
整个过程里还压着几条硬规矩:不改 Flutter 代码、不 hot reload、不 kill app、不自己"探索"剧本之外的路径、遇到意料外的弹窗就截图报告并中止而不是擅自点掉。这些约束的共同目的是------让 agent 的行为可预测。一个会自由发挥的测试 agent,跑出来的结果你没法信。

2.5 报告与可追溯
每跑完一次,agent 在 docs/app-operator/runs/<时间戳>-<spec名>.md 落一份报告:VM 地址、spec 路径、每一步的结果表、Assert 的逐条勾选、失败详情(带日志片段)、Teardown 状态。然后它还会回头把这条记录写回 spec 文件末尾的 History 表,最新的在最上面:
js
\| 时间 | 结果 | 报告 |
\|------|------|------|
\| 2026-04-22 15:19 | ✅ PASS | `runs/2026-04-22-151930-send-text-message.md` |
\| 2026-04-22 15:21 | ❌ FAIL(step 5 超时) | `runs/2026-04-22-152100-recall-last-message.md` |
这样一来,"这条用例最近跑过没有、过没过、报告在哪",站在 spec 这一头就能看全;反过来从某份 run 报告也能查到它对应哪条 spec。spec 和 run 互相指,形成一条能追溯的链。
把它放回整张图里看:逻辑层有单测和 Widget 测试,各有专门的角色去补;而"真机上点一遍对不对"这一格,过去一直是空的,现在由 app-operator 填上了。一个 agent,一份剧本,一份能追溯的报告------它替我们把那件最费人、又最容易被省略的事,跑成了可以反复跑的事。