本文记录了 2026 年 Windows 环境下,官方
miniprogram-automator在实际使用中遇到的兼容性问题排查与重建过程。起因是想做一个 Claude Code Skill,让 AI 读懂微信小程序的 WXML/JS、自动生成自动化测试脚本,结果动手第一步就撞上官方 SDK 的三处硬伤:launch()在我当前的 Node v24.12.0 + Windows 环境下稳定触发spawn EINVAL,且把错因误报成 cliPath 不对;Page.*命令族整族不响应;排查中又被残留会话骗了两轮------DevTools 在 cli 子进程被 kill 后不关自动化端口,导致launch()秒连上上一轮的残留会话,几秒后被顶掉。最终把元素层能力整个重建在evaluate之上,做成适配包miniprogram-automator-next,在真实项目上跑通 27 项自检。本文区分了已实测与仍属推断的证据边界,供同样在微信生态里做自动化测试的同学避坑。
上回刚写了《微信云托管迁移后内容含违规信息全线排查》,把后端链路的坑趟平了。这次换了个方向------想给小程序前端本身做自动化测试。结果没想到,官方 SDK 先给我上了一课。
一、起因:想做测试 Skill,先修的是 SDK
为什么选择 miniprogram-automator
起因是看到 Qwen-UI-Agent(阿里通义的 GUI Agent 方向,让模型看屏幕操作手机)。我想的是更轻的路线 :不部署视觉模型实时看截图,而是让 AI 读小程序的代码静态结构,生成确定性的测试脚本。脚本为主、探索为辅。
调研到官方有 miniprogram-automator,月下载约 4.8 万------地基现成,不用自己造自动化层。
原本的技术路线
计划很简单:写个 Claude Code Skill,教 AI 读 WXML/JS → 生成 automator 脚本 → 在开发者工具里跑。
然后就没有然后了。接下来两天全花在让官方 SDK 先能跑起来上。
环境信息
| 项 | 值 |
|---|---|
| 操作系统 | Windows 11 |
| Node.js | v24.12.0 |
| 微信开发者工具 | 2.01.2510290 |
| 基础库 | 3.17.0 |
| 官方 SDK | miniprogram-automator(2023-11-07 后未更新) |
| 被测项目 | 真实小程序项目(WXML + TS) |
二、launch() 必炸:spawn EINVAL,而错误信息在骗你
1. 表面错误:cliPath 明明正确
照官方 README 写第一行代码:
javascript
const automator = require('miniprogram-automator')
await automator.launch({ cliPath, projectPath })
得到:
vbnet
Failed to launch wechat web devTools, please make sure cliPath is correctly specified
于是我开始查 cliPath,查了很久。结果路径完全没问题。
网上教程都写 C:\Program Files (x86)\Tencent\微信web开发者工具\cli.bat,但安装位置是用户可改的 ,我本机装在 D:\微信web开发者工具\。正确的探测方式(工具开着时最准):
powershell
Get-Process wechatdevtools | Select-Object Path
换成绝对正确的路径,还是同一句报错。这时候才意识到:错误信息本身是误导------照它去查 cliPath 是死路。
2. 从错误堆栈找到 spawn EINVAL
打开错误堆栈才看见真相:spawn EINVAL,errno -4071。
这个行为与 Node.js 在 Windows 上针对 .bat / .cmd 的 spawn 安全修复高度吻合,涉及 CVE-2024-27980(BatBadBut) 。在我的 Node v24.12.0 环境中,官方 Launcher 对 cli.bat 的直接 spawn 会稳定触发 EINVAL。SDK 最后更新是 2023-11-07,比这个 CVE 早。
更坑的是它把这次失败 catch 住,然后统一报成cliPath 不对 。这是我损失时间最多的地方。
3. 为什么是 cli.bat
cli.bat 的全部内容其实就一行:
bat
"%~dp0.\node.exe" "%~dp0.\cli.js" %*
它只是 node.exe + cli.js 的壳。也就是说,绕开这个 .bat 壳,直接 spawn 同目录的 node.exe + cli.js 就行------不需要 shell: true(不重新打开 CVE 修掉的那个注入面):
javascript
function resolveCliRunner(cliPath) {
const dir = path.dirname(cliPath)
const nodeExe = path.join(dir, process.platform === 'win32' ? 'node.exe' : 'node')
const cliJs = path.join(dir, 'cli.js')
// 同目录有 node.exe + cli.js 就直接用它们,绕开 .bat
if (fs.existsSync(nodeExe) && fs.existsSync(cliJs)) {
return { mode: 'node+cli.js', cmd: nodeExe, prefixArgs: [cliJs] }
}
return { mode: 'raw', cmd: cliPath, prefixArgs: [] }
}
4. 为什么不提 PR
想提 PR 修掉它?提不了。 官方 SDK 的 repository 字段指向腾讯内网域名 git.code.oa.com,没有公开仓库。既不能 fork 也不能 PR,物理上没有对象可提。这就是后来做成独立适配包的原因。
三、Page.* 整族死亡:协议死了,不是会话断了
1. 实际表现
launch 通了,能拿到 miniProgram。接着照文档写:
javascript
const page = await miniProgram.currentPage()
const btn = await page.$('.guest-login-btn')
await btn.tap()
全部超时。
2. 协议族对照
一个个试下来,边界非常干净:
| 协议族 | 状态 |
|---|---|
App.*(evaluate / pageStack / reLaunch / mockWxMethod / screenshot ...) |
全活 |
Tool.* |
活 |
Page.*(data / setData / callMethod / $ / $$ / xpath / waitFor) |
整族超时 |
Element.* |
无法正常使用(element handle 只能从 Page.getElement 拿,而它也超时) |
也就是说,官方文档首页那句 page.$('.btn').tap(),在我这个版本的开发者工具上跑不通。
3. 如何排除会话断开
这一步很关键,因为会话失效也会表现成全超时,只跑一遍就下结论极容易误判。
做法:把 Page.* 调用和 evaluate 调用交错着跑,并且互换先后顺序各跑一次。
| 时间 | 调用 | 结果 |
|---|---|---|
| 0s | Page.data() |
超时 |
| 46.9s | evaluate() |
13ms 返回 |
| 后续 | Page.* |
持续超时 |
| 后续 | App.* |
正常 |
逻辑很简单:如果整个 WS 会话已经断开,Page.* 和 evaluate() 应该一起失败。但实际是 Page.data() 在第 0 秒就超时,evaluate() 在第 46.9 秒仍然 13ms 返回------会话要是断了,后面的 evaluate() 不可能还活着。所以是协议死了,不是连接断了。
4. 利用 evaluate() 重建 Page 能力
evaluate 能在小程序运行时里执行任意代码,那元素层能力就都能重做:
javascript
// page.data() 的替代:直接从页面栈顶读
await mp.evaluate(() => {
const pages = getCurrentPages()
return pages[pages.length - 1].data
})
setData、query(走 wx.createSelectorQuery().selectAll(sel).fields({...}))、waitForSelector 都是同样的路子。在当前环境下,这些基于 evaluate() 的替代实现本身开销很低:data() 约 9ms、setData() 约 16ms、query() 约 34~55ms、tap() 约 5ms。至少从当前测试数据看,重建层没有带来明显的性能负担。
5. evaluate() 的两个硬约束
① 函数是序列化过去执行的,闭包不生效。 外部值必须当参数传:
javascript
const n = 42
await mp.evaluate(() => n + 1) // n is not defined
await mp.evaluate((x) => x + 1, n) // 43
② 小程序逻辑层禁用 eval / new Function。 所以在运行时侧还原一个函数这条路是堵死的。凡是需要函数的,得在 Node 侧构造好、把结果当数据传进去。
这条约束直接决定了 tap 的实现形状:event 对象在 Node 侧拼好,整个当参数传进 evaluate。反过来也带来一个意外好处------waitForData 的 predicate 在 Node 侧跑,所以它的闭包是正常可用的。
6. 为什么 tap() 必须结合 WXML 静态分析
Element.tap 协议不可达,所以点击实际是构造 event 对象直接调页面 handler。拆成三个要素看:
| 要素 | 运行时拿得到吗 |
|---|---|
| 元素存在性 / 位置 / 尺寸 | selectorQuery.fields({rect, size}) |
元素的 dataset / id |
fields({dataset: true, id: true})------包会自动读出来填进 event,所以读 e.currentTarget.dataset.xxx 的 handler 能正常工作 |
bindtap="xxx" 这个绑定关系 |
拿不到 。fields() 不给事件绑定,它只存在于 WXML 源码里 |
所以要点一个按钮,必须有人去静态读 WXML 把 handler 名找出来。
这一条把我原本的定位给坐实了:AI 读 WXML 在这个方案里不是包装卖点,是技术必要条件。 我本来担心 AI 读代码生成脚本听着像给一个普通 SDK 套壳,结果协议层的缺失反过来证明了这个环节不可省。
四、残留会话:0.4 秒的 launch 比超时更危险
1. 一个看似正常的 launch()
包写完,demo 跑起来------每次都死在 open('/pages/home/index') 上:
sql
Connection closed, check if wechat web devTools is still running
而工具明明开着。
2. 用时间线定位残留会话
先怀疑是首页 onLoad 里的逻辑把运行时搞崩了,换一个已知好的页面(explain)跑对照------也死。又怀疑工具真的挂了,写脚本在死掉后立刻重连------连上了,evaluate 正常。问题不在业务调用,在启动阶段。
于是写了个诊断脚本:verbose: true 打开 cli 全部输出、每秒探活一次、且完全不做任何导航(用来区分是空闲时自己掉线,还是被我的调用搞掉的)。
时间线一出来,真相就明显了:
scss
[+0.4s] launch 返回成功 ← 太快了
[+4.0s] cli 打印出 ✔ auto
[+4.0s] 探活失败:Connection closed ← 就在这一刻
launch 只花了 0.4 秒。 而干净启动实测要 6.5--12.3 秒。
3. 为什么端口还在但连接却会死
去查端口:
powershell
Get-NetTCPConnection -LocalPort 9420 # 由 wechatdevtools 进程 listen,而此时并没有 cli 子进程
根因:DevTools 在 cli 子进程被 kill 之后,不会立刻关掉自动化端口。 官方 launch() 一起来就轮询 connect,于是瞬间连上上一轮的残留会话 并宣布启动成功;几秒后新会话把它顶掉,你就在随后随便哪个调用上收到 Connection closed。
所以报错落在哪个 API 上纯属随机------它伪装成了 open() 这个方法有毒。在我的测试环境中,亚秒级 launch 成功是非常强的残留会话信号。正常冷启动实测为 6.5~12.3 秒,因此 0.x 秒级的成功反而值得警惕。
4. 最终启动策略
- 等 cli 输出里那行单独的
auto再开始 connect。 别硬匹配✔ auto那个对勾(不同终端编码下字节不稳),剥掉行首非字母数字字符再比=== 'auto'。 ✔ auto只是必要条件不是充分条件------实测它打出来之后端口还可能没起来(遇到过打完标记然后 120s 连接全超时),所以标记只用来拦住抢跑,端口就绪仍靠轮询判断。- 连上后
evaluate探活 → 等 1.5s → 再探一次。残留会话就是在第二次探活被筛掉的。
5. 一个反面案例:不要用裸 TCP 探 WebSocket 服务端
我中间写过一个 portInUse() 用裸 TCP 探端口占用------net.connect 捅一下再立刻 destroy()。在我的一次实测中,它确实导致 DevTools 后续无法正常建立自动化连接:✔ auto 打了,但端口始终不 listen,硬等 120s 超时。
对一个 WebSocket 服务端做裸 TCP 连接再立刻断开,本来就不是个礼貌的动作。而且探不探端口,处理办法完全一样(都是等待信号 + 复探),所以我把这个函数删掉了,无条件走同一条路。少一个主动干扰服务端的动作,也少一个风险点。
五、最终方案:miniprogram-automator-next
整体架构
把前面三处修完,适配包的整体形态是这样的:

分工是:skill 负责读代码 → 写脚本,修复包负责让脚本真的能跑起来。两者可以单独用------修复包是普通 npm 包,不装 skill 也能手写脚本调用。
核心代码(最终测试的样子)
javascript
const assert = require('assert')
const { launch } = require('miniprogram-automator-next')
;(async () => {
let mp
try {
// 冷启动 6~13s 是正常的。秒启动反而是坏事------说明连上了残留会话
mp = await launch({ projectPath: 'D:\\workspace\\coach-miniapp', port: 9420 })
const page = await mp.open('/pages/home/index', { settle: 2000 })
assert.strictEqual((await page.route()).route, 'pages/home/index')
// 断言 1:empty 态下,登录按钮被 wx:if 藏着,不该渲染
await page.setData({ phase: 'empty' })
await page.wait(300)
assert.strictEqual(await page.count('.guest-login-btn'), 0)
// 断言 2:直接把页面摆到 guest 态------不必为了测一个按钮走完整前置流程
await page.setData({ phase: 'guest' })
await page.waitForSelector('.guest-login-btn', { timeout: 3000 })
const btn = await page.query('.guest-login-btn')
assert.ok(btn.width > 0 && btn.height > 0)
// 断言 3:'loginAndLoad' 来自 WXML 的 bindtap,运行时读不到,只能静态抄
const r = await page.tap('.guest-login-btn', 'loginAndLoad')
assert.strictEqual(r.handler, 'loginAndLoad')
console.log('✓ 全部通过')
} finally {
if (mp) await mp.teardown() // 必须收,否则 cli 子进程残留,下一轮又撞残留端口
}
})()
实测结果(27 项自检)
| 指标 | 实测值 |
|---|---|
| 自检通过 | 27 / 27 全通过 |
冷启动 launch 耗时 |
12.3s(正常范围 6.5--12.3s) |
page.data() 替代(evaluate) |
约 9ms |
page.setData() 替代 |
约 16ms |
page.query() 替代 |
约 34~55ms |
page.tap() 替代 |
约 5ms |
| 截图存盘 | 73864 bytes |
| 覆盖能力 | open / 自动读 dataset 的 tap / waitForData 闭包 / 截图 |
六、哪些结论已经确定,哪些还只是推断
写文档时我一度把适用范围写成 DevTools 2.01.2510290 / 基础库 3.17.0 / Node v24.12.0 / Windows 11,暗示这四个变量都是嫌疑人。用户看完提了一句:这个基础库好像基本没啥问题。 一句话点醒------回头看自己的数据:evaluate() 可以在当前小程序运行环境中稳定执行 JS,多次调用均能在毫秒级返回。这至少说明当前运行时以及 evaluate() 所依赖的通信链路是正常工作的 ,因此没有证据表明基础库运行时本身是主要故障点。死掉的是按协议域切分的 Page.*,而协议域更接近开发者工具那一侧的实现边界。所以这里必须把实测和推断分开写,不然读者会去换一个没用的变量重测。
已实测
- 当前环境下(Node v24.12.0 + Windows),官方
Launcher直接 spawncli.bat触发spawn EINVAL(errno-4071); - 当前环境下
App.*正常、Tool.*正常; - 当前环境下
Page.*持续超时;依赖Page.getElement获取 handle 的Element.*因而无法正常使用; evaluate()可以正常执行;Page.*与evaluate()交错执行后,可以排除整个会话已经断开;- 残留 DevTools 会话可能造成亚秒级 launch 假成功(实测 0.4s);
- 正常冷启动耗时约 6.5~12.3 秒;
- 当前适配方案可以通过
evaluate()重建部分 Page / Element 能力,27 项自检全通过。
当前推断
- 基础库 3.17.0 不是主要故障点 (高可信推断):
evaluate在基础库运行时里跑得好好的,死的是按协议域切分的Page.*,那是工具侧的边界。但这个结论是间接证据推出来的,不是对照实验; - 更值得优先怀疑的是 SDK 与开发者工具之间的兼容性(SDK 2023-11 后没更新,工具一直在走,形状更像版本错配),而不是基础库运行时本身;
- 但由于目前没有进行不同 DevTools 版本的 A/B 对照,因此这仍然属于推断。
尚未验证
- Page.* 是 DevTools 版本兼容问题------尚无版本 A/B 测试,待验证;
- 更换 DevTools 版本可以恢复 Page.*------未测试,未知;
- macOS / 其他 Node 版本上的表现------未测试。
一张表收拢(这也是全文最重要的证据边界):
| 结论 | 当前证据 | 状态 |
|---|---|---|
cli.bat 在当前环境触发 spawn EINVAL |
实际复现 | 已确认 |
Page.* 在当前 DevTools 环境不可用 |
多 API 交错测试 | 已确认 |
App.* / evaluate() 正常 |
多次测试 | 已确认 |
| 残留会话导致秒启动 | 启动时间线 + 二次探活 | 已确认 |
| 基础库 3.17.0 不是主要故障点 | 间接证据 | 高可信推断 |
Page.* 是 DevTools 版本兼容问题 |
尚无版本 A/B | 待验证 |
更换 DevTools 版本可以恢复 Page.* |
未测试 | 未知 |
如果后续要验证 Page.* 是否会恢复,优先应该进行 DevTools 版本 A/B 测试,而不是单纯更换基础库版本。当前证据更支持前者,但还不能把它当作已验证结论。
七、当前方案的边界
- 自定义组件是硬边界。 页面级
selectorQuery不跨组件边界,tap也只调页面方法。组件内部的元素查不到,定义在组件里的 handler 会提示页面对象上不存在。这是当前方案的已知边界,没解决。 - 只能本机跑。 强依赖本地开发者工具 CLI,不支持 Linux / CI / 云端。这是官方 SDK 的限制,不是适配包能绕开的。
- selector 不是完整 CSS。
createSelectorQuery只支持 id / class / 标签 /::before::after及其并集与后代组合。没有nth-child,没有属性选择器。 - 结论绑定版本。 上面所有实测数据都绑在 DevTools 2.01.2510290 / 基础库 3.17.0 / Node v24.12.0 / Windows 11 这一套环境上,换环境请重测,别直接外推。
- 修复包刚发首版(0.1.0)。 只在一个真实项目上验证过(27 项自检),欢迎报 issue。
八、和其他方案的区别
这个方向不是空白市场,我也没打算装作是。客观说下几条路线:
| 项目 | 思路 | 和本项目的关系 |
|---|---|---|
miniprogram-automator |
微信官方 SDK | 本项目的地基,不是竞品;修复包以它为 peerDependency |
@weapp-vite/miniprogram-automator |
走 headless 模拟器 | 绕过 了 EINVAL 而不是修它,路线不同 |
miniprogram-automator-mcp / @creatoria/miniapp-mcp / @purea/wechat-devtools-mcp 等 |
包成 MCP server 给 AI 用 | 形态不同(MCP tool vs Claude Code skill),本项目的差异在于针对当前环境下 Page.* 不可用的问题做了一层基于 evaluate() 的适配,而不是继续包装已失效的 API |
如果只是想让 AI 能操作小程序,那几个 MCP 可能更顺手。本项目更适合:想要确定性、可提交进仓库、可重复跑的测试脚本,并且恰好撞上了这两个坑的人。
九、一个意外的收获:直接 setData 驱动状态测试
排障之外,这套基于 evaluate() 的方案还带来一个意外的能力:直接 setData 把页面摆到想测的状态。
javascript
// 测登录按钮的 guest 态:一句话把页面摆到位,而不是先走一遍登录流程
await page.setData({ phase: 'guest' })
await page.waitForSelector('.guest-login-btn', { timeout: 3000 })
传统测试要测一个按钮,得先走完整前置流程:启动页面 → 等登录状态 → 请求接口 → 等数据 → 进入 guest → 点按钮。当前方案直接定位到某个 UI 状态:
javascript
// 断言 1:empty 态下,登录按钮被 wx:if 藏着,不该渲染
await page.setData({ phase: 'empty' })
await page.wait(300)
assert.strictEqual(await page.count('.guest-login-btn'), 0)
// 断言 2:直接把页面摆到 guest 态------不必为了测一个按钮走完整前置流程
await page.setData({ phase: 'guest' })
await page.waitForSelector('.guest-login-btn', { timeout: 3000 })
const btn = await page.query('.guest-login-btn')
assert.ok(btn.width > 0 && btn.height > 0)
这不是单纯为了绕过前置流程,而是让测试可以直接定位到某个 UI 状态进行验证 。empty 态验证按钮被 wx:if 藏住不渲染,guest 态验证按钮渲染且可点击------两个分支都没走真实登录流程。这是通过 evaluate 换来的额外好处,原生协议反而做不到这么随意。
十、总结
回答开头的三个问题:
1. miniprogram-automator 在 2026 年还能不能用?
能用,但带条件。在我当前的 Node v24.12.0 + Windows 环境下:launch() 会稳定触发 spawn EINVAL(且误报成 cliPath 不对),官方 Page.* / Element.* 元素层无法正常工作;活着的是 App.* / Tool.* 和 evaluate()。结论绑定环境,换环境请重测。
2. 为什么我要重建 Page / Element 层?
因为官方元素层协议在当前环境不可用,而测试恰恰最需要元素级能力(查元素、点按钮、断言状态)。evaluate() 是当前环境可用的通用通道,重建在它之上才能拿到这些能力------从当前测试数据看,重建层开销很低(data() 约 9ms、tap() 约 5ms)。
3. 为什么 WXML 静态分析最终变成了这个项目不可缺少的一部分?
因为事件绑定关系(bindtap="xxx")运行时读不到,只存在于 WXML 源码里;而 Element.tap 协议不可达,点击只能构造 event 直接调页面 handler,handler 名必须从 WXML 静态找出来。这不是包装卖点,是技术必要条件。
这次排查最后没有停留在官方 SDK 不能用了这个结论上。因为真正需要的是:
lua
AI 读取 WXML → 识别页面元素和事件 → 生成确定性测试步骤 → 通过 automator-next 执行 → 得到测试结果
因此我把这套适配层和 Claude Code Skill 一起开源成了 miniprogram-auto-test。
如果你使用的是其他版本的微信开发者工具,尤其是不同版本的 DevTools,欢迎测试 Page.* 是否仍然可用------最好按照本文第三节的交错验证法测,只跑一遍很容易把残留会话误判成协议死亡。
十一、项目地址
- 仓库:github.com/Zi-Yi-Ming/...
- npm 适配包:www.npmjs.com/package/min...
- License:MIT
十二、参考资料 / 延伸阅读
- CVE-2024-27980(BatBadBut):nvd.nist.gov/vuln/detail...
- 官方 SDK
miniprogram-automator:www.npmjs.com/package/min... - 微信开发者工具 CLI / 自动化接口官方文档:developers.weixin.qq.com/miniprogram...