我是 jeff,这两个月在做 RecompDaily(一个 Flutter应用 + Go 的健身记录 app)。
这篇讲一个我自己挠头三个月、最后写代码砸出来解决的事:Flutter Web 应用怎么做 E2E 测试。
如果你也在用 Flutter Web / Figma 风格的 canvas-based 应用,被 Playwright 折磨过,这文章可能对你有用。最后我把工具开源了:github.com/jackwangfeng/qdesk(Apache 2.0)。
痛点:Flutter Web 是 canvas,Playwright 是瞎子
如果你做过 Flutter Web 你应该知道,Flutter 不像 React/Vue 把每个组件渲染成 DOM,它把整个 UI 画到一块 canvas 上(用 Skia 或 Impeller)。
意思是:
html
<flt-glass-pane>
<canvas><!-- 整个 app 的所有内容都在这里 --></canvas>
</flt-glass-pane>
DOM 里就一个 canvas 元素。
这对 Playwright 是灾难:
// 想点"保存"按钮?祝你好运
await page.locator('button:has-text("Save")').click();
// → Error: locator resolved to 0 elements
// 想等待页面加载完?
await page.waitForSelector('[data-testid="dashboard"]');
// → Timeout: there are no DOM nodes inside the canvas
// Playwright 唯一能做的就是按坐标点
await page.mouse.click(100, 200);
// 但坐标硬编码,UI 一改就废
我们的 RecompDaily 上,Playwright 测试基本只能覆盖:登录页那几个 native HTML 表单 + URL 路由变化。Flutter app 主体 90% 的功能完全测不了。
QA 同事每次发版前手工点半天。改一次 UI,他从 demo 第一遍开始重测。
---
三个月里我试过的烂方案
第一阶段:给 Flutter widget 都加 testid。 改了几百个 widget,最后发现 Flutter Web 里 testid 根本不会变成 DOM 属性 --- 它只是个内部 key。卒。
第二阶段:像素 diff(Percy/Applitools)。 跑起来了,但只能验证"长得一样",不能验证"功能对"。而且字体抗锯齿差几个像素就报告失败,假阳性满天飞。
第三阶段:让 QA 录视频对比。 真这么干了一个月。一次发版录 30 个流程,人均 2 小时。
第四阶段:开始研究Computer Use 这类 vision-LLM...
---
转念:这不就是 vision model 该干的事吗
2024 年底 Anthropic 发了 Computer Use,Google Gemini 2.5 也有视觉能力。它们能直接看截屏判断 UI 状态。
那为啥不让它看 Flutter Web 的截屏,直接点呢?
我画了个最简单的设想:
1. 跑个 Linux 容器,里面装 Chrome,打开你的 app
2. agent 截屏
3. 把截屏 + 自然语言任务("点击保存按钮") 发给 Gemini
4. Gemini 返回 JSON: {type:"click", x:300, y:450}
5. 容器里 xdotool 模拟点击
6. 再截屏,继续循环,直到任务完成
7. 用 Gemini 验证"成功的样子"
关键:测试不是脚本,是描述 + 期望。 UI 改了,描述不动,AI 自动适配。
我用周末搭了个 demo 跑通了。觉得有意思,接着花了几天做了个完整版。
---
几天搭出来的样子
整个东西叫 qdesk,3000 行 Go,4 个 binary,Apache 2.0:
┌─────────────────────────────────────────────┐
│ qdesk run tests/login.qdesk.yaml │
│ │ │
│ ▼ │
│ control plane (HTTP API,SQLite) │
│ │ │
│ ├─→ Docker:跑 Linux + Xvfb + Chrome │
│ │ └─ qdesk-agentd: /screenshot │
│ │ /actions {click/type/...} │
│ │ via scrot + xdotool │
│ │ │
│ └─→ runner: 循环 │
│ screenshot → Gemini → action │
│ → 直到 done → 验证 expect │
│ → 出 HTML 报告 + AI 诊断 │
└─────────────────────────────────────────────┘
写一个测试就这五行 YAML:
name: 登录跳转
url: http://host.docker.internal:8888
goal: 在欢迎页点 "Get started" 按钮
expect:
- 页面跳到 Sign in 屏,左上角显示 "Sign in"
跑:
$ qdesk run tests/login.qdesk.yaml
🤖 看到欢迎页有红色 "Get started" 按钮(bbox 中心 x=960, y=720)
👆 click(960, 720)
🤖 看到 Sign in 屏出现,左上角有返回箭头 + "Sign in" 文字
✅ DONE
✅ PASS (1 step, 41 秒, ~$0.005)
📄 报告: file:///qdesk-runs/login-2026-05-04/report.html
41 秒,5 毛钱。
---
真实跑 RecompDaily 时踩的几个坑
测我自己 app 时几个真实经验,留这里给后来人:
1. Gemini 2.5 Flash 在 canvas 上 Y 坐标系统性偏小
实测一个按钮真实 y=740,Gemini Flash 估的是 y=592(偏小 148 像素)。
Pro 好一些 y=685,但还是上偏。
我猜原因是训练数据里"canvas 像素 → 语义"对齐不够。
Mitigation: 在 prompt 里强制 LLM 先输出 bounding box,再算几何中心:
GROUNDING DISCIPLINE:
1. First identify target bounding box (x_min, y_min, x_max, y_max)
2. Compute center: x = (x_min+x_max)/2, y = (y_min+y_max)/2
3. If previous click didn't change page, retry 30-60px LOWER
A primary CTA centered horizontally on a Flutter welcome screen is
typically located between y=700 and y=780. Do NOT default to y < 650
for buttons that visually appear in the lower half of the page.
加了这段,Gemini Flash 在 4 步内能命中。
2. Flutter Web 路由切换需要 ~1.2 秒 settle
点击后 400ms 截屏,Flutter 路由还在 transition,截到的是旧屏。
runner 默认 settle 1200ms 才下一次截屏 → 才稳。
比 React/Vue 慢一个数量级,要小心。
3. dbus 报错可以无视
Chromium 在容器里没 dbus,启动时刷一堆 ERROR,但不影响渲染。
---
一个真实 bug,顺手测出来的
写 demo 的时候随手写了个测试:
goal: 点 "Get started"
expect:
- 跳到 Sign in 页
- 出现 Google 登录按钮
跑出 FAIL。AI 诊断:
▎ "页面卡在 'Getting ready' 状态。Google Sign-In 初始化超时。
▎ 建议:加 timeout 处理,网络问题或后端不通时给用户明确反馈。"
去看 --- 原来当后端没启动时,Google Sign-In script 会无声 hang 住,
用户体验极烂(看到 "Getting ready" 转圈永不结束)。
这个 bug 之前没被 Playwright 抓到 --- Playwright 只能等 selector 出现,
没出现就 timeout 报错,不会写"诊断"。
qdesk 的 AI 看一眼就说出根因。
后来加了 5 秒 timeout 和明确的错误提示,这事修了。
---
给团队用上之后
我把这套接到 Claude Code 里,通过 Model Context Protocol(MCP)注册成工具。
效果:我团队的工程师在 Claude Code 里写完代码,跟 Claude 说一句:
▎ "测一下我刚改的 Log 页"
Claude 自动调 qdesk_quick_test,30 秒后告诉他:
- ✅ PASS,4 个期望全过,这是报告链接
- 或 ❌ FAIL,这是失败截屏 + AI 诊断
工程师收到反馈,要么改代码要么 commit。没有人再写一行 Playwright selector。
QA 那边减负 70%。
---
谁会用这个 / 谁不会用
我不替自己说好话 --- qdesk 不是 Playwright 替代品,有明确 trade-off:
qdesk 适合:
- ✅ Flutter Web / canvas-heavy 应用(Figma、Excalidraw、Miro 类)
- ✅ AI 编程工具用户(Claude Code / Cursor 写完自验证)
- ✅ UI 经常变动的探索期项目(测试自愈)
- ✅ 非工程师(QA / PM)写测试
Playwright 适合:
- ✅ 大量 CI 测试(qdesk 慢 10x,贵)
- ✅ DOM 精确断言("class 应该是 X")
- ✅ 跨浏览器测试(qdesk 只 Chromium)
- ✅ 网络层 mocking
- ✅ 成熟稳定的产品
两者长期共存,不是你死我活。我团队现在 Playwright 测我们后端 + 登录这种 native HTML,qdesk 测整个 Flutter app 主体。
---
开源 + 开始用
仓库:github.com/jackwangfeng/qdesk
5 分钟接入 Claude Code(完整文档 docs/TEAM_QUICKSTART.md): 或者把这个链接给claude code, 它会自动安装, ai极度友好
# 1. 装
curl -fsSL https://raw.githubusercontent.com/jackwangfeng/qdesk/main/scripts/install.sh | bash
# 2. 设环境(GEMINI_API_KEY 去 https://aistudio.google.com 拿,免费)
export GEMINI_API_KEY=AIza...
export QDESK_DEV_KEY=$(openssl rand -hex 16)
# 3. 后台跑控制面
nohup qdesk-control --dev-key "$QDESK_DEV_KEY" \
--image qdesk/ubuntu-chrome:dev > ~/.qdesk-control.log 2>&1 &
# 4. 接到 Claude Code
claude mcp add --transport stdio qdesk -- qdesk-mcp \
--control http://127.0.0.1:8090 \
--api-key "$QDESK_DEV_KEY" --gemini-key "$GEMINI_API_KEY"
# 5. 重启 Claude Code session,/mcp 看到 qdesk
接下来 Claude Code 写完代码自动可以测 UI。
如果你也在做 Flutter Web / canvas 应用,觉得 Playwright 又烦又测不动,
来试试。Issue / Star / PR 都欢迎。
--- jeff