1. 点击 下拉框 → PASSED
2. 点击 选项「上海」 → PASSED
3. 断言 表单值为「上海」 → FAILED
前两步日志干干净净,第三步告诉你没选上。原因不神秘:Ant Design / Element / Vant 的下拉不是原生 <select>,你点开的「选项」是个 <div>,点它只是让 DOM 变了个样,表单状态并没有提交。
这类问题在社区里讨论过很多次,本质是一句话:动作成功 ≠ 业务终态达成。这篇文章聊我们用什么办法把它堵住 —— 一个跟着被测页面跑的插件系统,以及里面几条被踩出来的约定。

先说边界:插件不管「怎么找到元素」(那是定位器的事),它管三件具体的事:
只做第 2 步,就是把裸 click 换成封装好的 click,假成功还在。真正拦下假成功的是第 3 步。
现在的插件覆盖四个组件库变体(Ant Design、Element 的 element-ui/element-plus、Vant、MUI),按能力域拆开:下拉选择、树形选择、日期选择、时间选择、滑块、级联选择、勾选、单选。原生 <select> 不走插件,由原生交互层兜底 selectOption。
插件就是一个求值为「插件定义对象」的表达式:
({
detect: (el) => ..., // 1. 归属判定
candidates: (el) => [...], // 2. 候选定位器
annotate: (el) => '...', // 3. 一句话语义标注
actions: { select: { ... } }, // 4. 语义动作
})
几个刻意的设计,都是为了少踩坑:
(() => {...; return {...}; })());const / 箭头函数 / 可选链直接用;.js 或 zip(manifest.json 只写 {"entry": "index.js"})。detect 要宽进,但注册了动作的插件必须精确。 每个元素都会被问「你归谁管」,命中谁,动作就派给谁。如果类名写得太泛,动作会被误派给不该处理的组件。
弹层内容必须锚定容器。 弹层常常 portal 到 body 下,而且是常驻实例(隐藏但不销毁)—— 不锚定作用域,就会命中那个隐藏实例,于是「明明可见的选项」定位失败。
后验不能只看回显。 这条最容易写错:可搜索下拉在筛选时会把输入文本回显到输入框里,所以「输入框里显示了上海」不等于上海被选中了。必须校验组件自己的选中状态标记,否则「筛选了但没点中」会被判成成功。
状态相关的多步操作必须在一个动作里闭环(打开 → 定位 → 选中 → 等回显),不允许返回「还需要点 N 次」让外壳拆步 —— 外壳拆出来的步骤既不稳定,语义也丢了。
动作要幂等。 已经是目标终态就直接返回成功:匹配链上排在前面插件可能已经做了一半。
每个动作只有三种结果:
| 返回 | 含义 |
|---|---|
| 返回字符串 | success(字符串作为结果消息) |
throw Error |
failed(错误信息给用户/模型看) |
返回 {status, message}
|
显式三态:success / failed / uncertain
|
uncertain 是特意留的口子:动作执行完了,但此刻读不到足够证据判断终态。这种交给平台的效果哈希兜底去核对,而不是让插件拍脑袋报「成功」。后验 verify 不过,视同 failed —— 这就是拦下「点了但没选上」的那个开关。
我们的观点是:宁可返回一个待定,也不要返回一个假的成功。 前者最多多花你几秒钟看证据,后者会让你在两周后才发现某个断言其实早就失效了。
页内脚本能力有限:拿不到真实键盘事件的时序、没有 auto-wait、等网络要自己写。所以动作的第三个参数是一个 Playwright 桥(在 Node 侧执行):
async fn(el, args, pw) {
const root = await pw(el); // 页内元素 → Node 句柄
await root.locator('input').fill('上海'); // auto-wait 真实 fill
await pw.page.keyboard.press('Escape'); // 真实键盘事件
await pw.page.waitForLoadState('networkidle'); // 等网络空闲
}
代价得说清楚:每次 pw 调用都是一次跨进程往返,适合用在关键几步,不适合放进高频轮询;等待类需求交给 Playwright 的 auto-wait。另外,脱离平台外壳运行时(比如在页面控制台里手搓)会直接报「桥未注入」—— 这是故意的,免得你以为在用真实 Playwright,其实调了个空壳。
插件不是上传就生效,这是有意的设计:
项目 → 关联的预设 → 预设成员(按优先级) → 注入浏览器
注入顺序还有一条不能动的规则:运行时框架脚本先行,成员插件随后。插件内部有安装守卫,顺序颠倒会静默跳过注册 —— 症状是「插件装了但动作不见了」,很难查。
覆盖内置是允许的:把自己的插件排在内置插件之前就能增强或覆盖。但有一种情况要小心:如果你的组件与某个已注册动作的内置插件共享根类(比如 antd 的 TimePicker 与 DatePicker 同为 .ant-picker),就得同步收窄既有插件的归属判定并加误派保护,否则动作会派错。

插件模板下载下来是个能跑的小项目:
npm install
npx playwright install chromium
npm run harness -- sample-widget index.js --probe
会弹浏览器扫示例页面,期望看到「候选 ✓唯一」标记和「✅ 验收通过」以及动作清单。验收只看两件事:候选唯一、动作清单齐全(harness 不执行动作);要自测动作就加 --hold,在控制台里直接调:
await window.__ttPluginRegistry__.invokeAction('demo-select', 'select', document.querySelector('.demo-select'), { value: '上海' })
// 期望 { status: 'success', message: '已选择:上海' },且页面下拉框已回显
上传之后还要点一次「试运行」:插件的动作清单要在页内注册后才拿得到,试运行会把「命中数、候选总数、动作列表」回写回去。它顺带解决了一个体验问题 —— 任意可打开的页面都能试运行,不需要页面里真有目标组件。更新插件也不用删了重传,操作列的「重新上传」替换源码即可。


有一个前提必须提醒:插件代码是在被测网站的浏览器环境里执行的,它能读页面、能操作页面。所以「只启用你理解过的插件」不是客套话 —— 界面上也写了这句提示。

组件适配这件事,各家走的路不太一样。我见过:给组件库写专门的 Page Object、要求开发加 data-testid 后只用 testid 定位、用视觉/坐标点击绕开 DOM、以及在 CI 里跑「组件冒烟」逐个验证下拉/日期能用。
你们团队怎么处理这类「点不上」的组件?有没有遇到过那种『日志全绿、业务没生效』的假成功? 欢迎在评论区聊聊。
本项目已收录进 TesterHome 社区开源项目库:TestDog。
帮助文档 · 插件开发:https://softwing.top/testdog-doc/guide/plugin-dev.html
快速开始:https://softwing.top/testdog-doc/guide/getting-started.html
利益相关:我是 TestDog 的作者,本文写的是自己的实现与踩坑,不构成第三方评测。