1. 背景:到底在解决什么问题
假设你的项目已经有一套能跑的端到端测试(Playwright / Cypress / vitest 都行),playwright test 一把梭能过。听起来不错------但放进团队日常,你会发现它只解决了"测试能跑"这一件事,下面这些全是空白:
- 没人定时跑:谁在每天早上、每次部署后触发?手动跑迟早被忘掉。
- 失败没人定位:红了。是偶发抖动(flaky)还是真 bug?哪个 commit 引入的?只能靠人盯。
- 结果进不了系统:测试结果、缺陷散在终端日志里,进不了团队的缺陷 / 测试管理平台。
- 没有通知:回归挂了,得有人主动去看才知道。
- 用例的"正确答案"没有源头 :用例的期望值(oracle)从哪来?若是照着当前代码写的,那只是把现状(可能含 bug)固化成绿色测试------测了等于没测。
本文把这五个空白一次补齐,做成一套无人值守、自动运维的测试 Agent:
定时回归 → 失败自动分诊(判 flaky / 真 bug、定位嫌疑 commit)→ 真 bug 自动建到缺陷平台 → 结果推到 IM 群。
整套稳态成本极低,关键在于大模型不参与每一条用例的执行,只在少数真正需要"判断"的环节出现。
先认个脸:用到的组件
为了让没接触过的读者也跟得下来,先交代每个组件干嘛:
| 组件 | 是什么 | 在本方案里的角色 |
|---|---|---|
| Playwright | 浏览器自动化 / E2E 框架 | 执行器:真的去点页面、断言结果(本文以 1.60 为例) |
| Hermes Agent | 常驻 AI agent 框架(cron 定时 / kanban 看板 / IM 网关 / MCP 工具宿主 / 技能) | 控制面 :调度、编排、通知------但不亲自跑测试 |
| Claude Code | 命令行编码 agent,可 claude -p "..." 无头运行,内置 Playwright 官方测试生成/自愈子代理 |
推理层:读 PRD 写用例、对真实页面生成 spec、分析失败原因 |
| 云效(Aliyun DevOps) | 研发协作平台 | 测试管理 + 缺陷(换 Jira / 禅道同理) |
| 钉钉 | IM | 通知告警 / 日报(换飞书 / 企微同理) |
| 预览环境(preview) | 独立于生产的部署 | 所有测试只打它,绝不碰生产 |
读完本文你将得到:一条可定时触发的回归流水线、一套"失败→分诊→建缺陷→通知"的闭环、一份能照着搭起来的 Runbook。
2. 核心思路:把"确定性"和"推理"分开
整套系统只有一句心法:能用脚本确定性完成的,绝不喂给大模型;大模型只在真正需要判断的地方出现。
为什么?因为两者的成本和可靠性天差地别:
| 维度 | 确定性脚本 | 大模型推理 |
|---|---|---|
| 成本 | 0 token,只吃 CPU | 按 token 计费 |
| 可重放 | 100% 可重放 | 有随机性 |
| 适合做 | 跑测试、算通过率、拼 URL、读写 JSON | 读 PRD 写用例、看失败 trace 判 flaky、归因 commit |
于是系统分三层,各司其职:
shell-out / 调度
需要判断时唤起
确定性层 · 纯脚本(0 token)
质量门
Playwright 执行
幂等写缺陷库
状态感知通知
推理层 · Claude Code(按需,少量 token)
PRD→用例
生成 spec
失败分诊
控制面 · Hermes(常驻)
cron 定时
kanban 编排
IM 网关
MCP 宿主
大模型只在四处出现 :把 PRD 写成用例、把用例生成 spec、分诊失败、兜底总结。跑 1000 条用例 = 0 token。
三条贯穿全文的红线
- 期望值必须来自 PRD,不是代码。照代码反推期望,等于把当前 bug 固化成"绿色测试"。
- 大模型不进每条用例的热路径。否则成本和稳定性双杀。
- 失败先定性、再处置。flaky 不能当 bug 建(会让整个套件被无视);真 bug 才进缺陷库。
3. 工具如何协作
一次回归的完整数据流:
结果 JSON
0 通过 / 1 门失败
2 有失败
聚类 + 重跑3次
是
否 flaky
cron / kanban
Hermes 调度
run-pipeline.sh
gate→generate→run
退出码
组装日报
分诊
Claude 推理
真失败?
缺陷库
建 Bug
打标 · 不建 Bug
IM 群
状态感知卡片
要点:Playwright 不走 MCP 跑回归 (已有 playwright test 直接跑,MCP 当中介只会更慢、cron 不友好),MCP 只在"生成阶段实时验证选择器"时用;代码托管在哪就用哪家的 API 做 commit 比对(云效 Codeup / GitHub 都行)。
4. 主流水线:gate → generate → run
整条流水线的脊柱是一个 shell 脚本 run-pipeline.sh(全文见附录 A)。它只做"确定性 + 调起生成",不碰缺陷库/IM (那些要 MCP 宿主 + 网关,交给外层 Hermes)。它用退出码告诉外层要不要分诊:
不过
过
是
否
全过
有失败
开始
gate:用例质量门
exit 1 · 不写缺陷库
--generate ?
claude -p 生成 spec
对真实页面验证选择器
run:playwright test
RUNNER = local / docker
回归结果
exit 0
exit 2 · 外层触发分诊
三个阶段:
- gate(质量门) :一段纯 JS(0 token)校验用例集------每条有需求映射、断言不空洞、覆盖桶齐全;不过即停,绝不让脏用例进系统(规则见附录 D)。
- generate(可选) :
claude -p调 Playwright 官方生成子代理,读用例规格 + 对真实页面验证选择器后生成/更新 spec。 - run :
playwright test --reporter=json,结果落artifacts/<module>-last-run.json。用 JSON reporter 是为了让外层确定性解析通过/失败数,而不是让模型读人类日志。
用例的期望分两层:E2E 可观测 的(文案 / 跳转 / 状态码)交 Playwright;只有后端能验证的(审计表 / 限流计数 / 响应体 token)交一个独立的 API smoke 脚本。混在一起会让 E2E 去断言它根本看不到的东西。
5. 执行隔离与"官方镜像拉不动"的完整绕行 ★
这是本文最有实战价值的一节------容器隔离听起来简单,落地时常被网络环境卡住。
为什么要容器隔离
本机直接跑 Playwright(RUNNER=local)没问题,但容器隔离能给你一个干净、可复制、与本机解耦的运行环境(CI / 多机一致)。于是想用官方 Playwright 镜像。
问题:官方镜像在某些网络不可达
官方镜像在 mcr.microsoft.com。在部分网络里:
bash
$ docker pull mcr.microsoft.com/playwright:v1.60.0-noble
Error: ... Head "https://mcr.microsoft.com/v2/.../manifests/...": EOF # 拉不动
那就别依赖它。先探一圈哪些 registry 可达:
| 目标 | 结果 |
|---|---|
registry-1.docker.io(Docker Hub) |
✅ 401(正常握手,可达) |
mcr.microsoft.com |
❌ 超时 |
cdn.npmmirror.com/binaries/playwright(npmmirror 浏览器 CDN) |
✅ 可达 |
registry.npmmirror.com / deb.debian.org |
✅ 可达 |
结论 :Docker Hub 能拉、npmmirror 能拉。那就从 Docker Hub 的 node 基镜像自建,浏览器二进制走 npmmirror(绕开被封的默认 CDN)。
自建镜像(scripts/qa/Dockerfile.playwright)
dockerfile
FROM node:22-bookworm
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \
PLAYWRIGHT_DOWNLOAD_HOST=https://cdn.npmmirror.com/binaries/playwright \
npm_config_registry=https://registry.npmmirror.com
# 只装 chromium;版本须与项目 @playwright/test 完全一致
RUN npx -y playwright@1.60.0 install --with-deps chromium \
&& chmod -R a+rx /ms-playwright
WORKDIR /work
bash
# 空构建上下文(只发 Dockerfile,不发整个仓库),构建一次本地缓存复用
docker build -t kbox-playwright:1.60 - < scripts/qa/Dockerfile.playwright
三个设计点,每个都踩过坑:
- 浏览器装到
/ms-playwright并chmod a+rx:容器运行时以非 root--user $(id -u)启动;若浏览器在 root 的~/.cache里,非 root 用户既找不到路径也没权限。放系统级目录 + world-readable 才行(这正是官方镜像的布局)。 PLAYWRIGHT_DOWNLOAD_HOST指向 npmmirror :默认下载源常被封;npmmirror 镜像了同构的builds/chromium/<rev>/路径。- 版本三处必须一致 :项目
package.json的@playwright/test= Dockerfile 的playwright@x= 容器内挂载运行 的playwright-core。因为容器跑的是挂载进来的 node_modules(纯 JS),它按某个 chromium revision 去/ms-playwright找浏览器,版本不一致就找不到。
跑起来(run-pipeline.sh 的 docker 分支)
local
docker
否
是
自建一次后缓存
RUNNER = ?
本机 Playwright 直接跑(默认)
镜像本地存在?
✗ 快速失败并提示自建
(绝不去 pull 不可达的官方镜像)
docker run --rm --ipc=host
--user (id -u):(id -g) -e HOME=/work
-v REPO:/work · npx playwright test
关键参数:--ipc=host(防 Chromium 因 /dev/shm 过小崩溃)、--user $(id -u):$(id -g)(产物属主是宿主用户)、-e HOME=/work(非 root 用户要可写 HOME)、把仓库挂 /work(node_modules 里 Playwright 纯 JS 可直接用)。绝不把宿主的浏览器缓存/PLAYWRIGHT_BROWSERS_PATH 带进容器------让容器只用镜像自带的 Linux 浏览器。
一个容易踩的架构选择
很多 agent 框架自身也支持"把任务跑进 docker 容器"。不要 为了这个需求去切换框架的全局容器后端------那会把所有任务塞进一个没有浏览器 的通用镜像,反而跑不了测试。正确做法:框架保持本机执行,只让 run-pipeline.sh 内部 docker run 单独起一个容器跑 Playwright。这是单层 docker,不是 docker-in-docker,没有嵌套复杂度。
6. 失败分诊:flaky vs 真问题
回归一旦失败(退出码 2),外层唤起一个分诊环节(这里需要大模型推理)。算法是先聚类、再判稳定性、最后幂等回写:
3/3 失败
1-2/3 · selector/超时
trace 有 5xx
有
无
N 个失败
按错误指纹聚类
每簇只开 1 个 Bug
代表用例
同 commit 重跑 3 次
结果
真失败
flaky · 打标不建 Bug
真失败(后端)
缺陷库已有
未关闭同源 Bug?
追加评论 · 不重复建
建 Bug
带嫌疑 commit + 期望 vs 实际
三个要点:
- 聚类 :一次 20 个同根因失败,只建 1 个 Bug(按
错误类型 → 模块 → 疑似根因指纹归簇),而不是建 20 个。 - 判 flaky:同 commit 重跑 3 次。3/3 失败才是真 bug;时红时绿是 flaky,打标不建 Bug。
- 幂等:用例有稳定主键(见下节),建 Bug 前先按主键查重,命中就追加评论,不重复建。
分诊判定为真失败后,agent 在 Hermes 侧给出关联缺陷摘要,对应到缺陷平台里的一条 Bug:

图:Hermes 控制面------一次回归分诊后自动关联的缺陷。

图:云效缺陷详情------标题带 [TC:*] 主键,描述含复现路径、期望(PRD)vs 实际、同源用例,全程可追溯。
一个贯穿全链路的稳定主键
给每条用例一个稳定 ID(如 TC-LOGIN-003),让它出现在 spec 标题、缺陷库用例 subject、Bug 标题 三处。同一个 ID 串起"用例---spec---执行记录---缺陷",回写、查重、追溯全靠它。缺陷库没有原生幂等键?没关系------把 [TC:<id>] 作为标题前缀,先查后写就实现了 upsert。
这些用例以 [TC:*] 为主键幂等写入测试管理平台的用例库:

图:云效用例库------每条用例以 [TC:*] 为稳定外部键,先查后写实现幂等 upsert,不会重复建。
7. 通知:自定义机器人 + 状态感知卡片
无人值守发通知,有个必须搞清楚 的坑:很多 IM 的"应用机器人"是被动回复 式的(要先收到消息拿到临时 webhook 才能回),cron 无人值守时根本没有会话,发不出去。主动推送要用"自定义群机器人" webhook。
以钉钉为例,群机器人开"加签"后这样推(无密钥入库,secret 只在 .env):
js
const ts = Date.now();
const sign = encodeURIComponent(
crypto.createHmac("sha256", SECRET).update(`${ts}\n${SECRET}`).digest("base64")
);
url += `${WEBHOOK.includes("?") ? "&" : "?"}timestamp=${ts}&sign=${sign}`;
// 然后 POST { msgtype:"markdown", markdown:{ title, text } }
卡片内容也由脚本确定性 组装(不让模型手搓 markdown):算通过率、画进度条、状态用图标区分(✅ 全过 / ⚠️ 有失败 / ❌ 门未过),失败用例直接链到覆盖它的 Bug。注意 IM 通常有频率上限(钉钉自定义机器人 20 条/分钟),所以一次回归发一张卡,不要一个失败发一张。

图:钉钉状态感知日报卡片------状态图标 + 通过率进度条 + 失败用例直链 + 已建缺陷链接,全部由脚本确定性组装。
8. 进阶(可选,先跑通基础版再上)
- Kanban 可恢复编排:把"gate→generate→run→triage"从单进程拆成看板上 5 张有依赖的卡,支持断点续跑、单阶段重试、可观测。代价是更复杂,建议基础版稳定后再上。
- 视觉 / 探索式测试 :DOM 断言抓不到"布局错位、文字溢出、对比度、i18n 串版"这类视觉问题。可以让一个带视觉能力的 agent 像"乱来的真人"探索操作 + 截图比对,产出问题清单交给分诊环节建 Bug。红线:只在 preview 跑、oracle 仍来自 PRD/设计稿、绝不用它绕过验证码。
下图是把流水线落成看板任务链后的样子(plan → gate → generate → run → triage):

图:Hermes Kanban 看板上的五段可恢复任务链,依赖驱动、可断点续跑。
9. 从零复现:Runbook
前置:一台常驻机器(macOS / Linux)、Node ≥ 20、Docker(要容器隔离才需要)、项目已 clone 并装好依赖、已有 Playwright preview 配置(
baseURL指向预览域名、带"URL 必须含 preview"的安全断言,从机制上禁止误打生产)。
Step 1 --- 凭据只进 .env(gitignore,永不入库)
bash
# .env
YUNXIAO_PAT=<缺陷库专用服务账号 PAT>
DINGTALK_WEBHOOK=<钉钉自定义群机器人 webhook>
DINGTALK_WEBHOOK_SECRET=<加签 secret>
Step 2 --- 校验缺陷库 token 的权限边界 (专用服务账号 + 最小授权 + 项目隔离;很多平台的 PAT 不能单项目限定,要靠"只属于该项目的服务账号"来限定,务必验证它看不到别的项目)。
Step 3 --- 准备用例集 + 过质量门
bash
node docs/qa/promptfoo/assert-testcases.js docs/qa/cases/<module>.json # 不过即停
Step 4 --- 自建 Docker 镜像(要容器隔离才需要)
bash
docker build -t kbox-playwright:1.60 - < scripts/qa/Dockerfile.playwright
Step 5 --- 先本机、后容器各跑一次,确认结果一致
bash
MODULE=<module> bash scripts/qa/run-pipeline.sh # local
MODULE=<module> RUNNER=docker bash scripts/qa/run-pipeline.sh # docker,应与 local 同结果
Step 6 --- 自测通知 :手动触发一次,确认 IM 群收到卡片,且通过/失败数与 artifacts/<module>-last-run.json 一致。
Step 7 --- 挂上定时任务 :用 Hermes(或任何 cron)每天定时跑 run-pipeline.sh,按退出码分支:2→唤起分诊建 Bug,0/1→出简报,最后推 IM。
Step 8 --- 验收:
- 故意推一个坏 commit(如登录 handler
throw),下次回归应变红 → IM 告警 → 缺陷库建好 Bug;回滚后告警消除。 - 缺陷库里每个用例主键
[TC:*]都有最新执行记录。 - 重启机器后调度器能自动恢复,不重复跑。
10. 踩坑速查
- 官方镜像拉不动 :别等
docker run自动 pull,自建镜像 + 镜像不存在时快速失败给指引。 - Playwright 版本三处一致 :
package.json= Dockerfile = 容器内挂载运行的playwright-core,差一个小版本就找不到浏览器。 - 容器非 root 读不到浏览器 :装到
/ms-playwright+chmod a+rx,别放 root 的~/.cache。 - IM 主动推送:用自定义群机器人 webhook(+ 加签),不是被动式应用机器人。
- 期望永远来自 PRD:命中 PRD 与代码冲突时,期望标"待裁决",不从代码反推。
- 失败先定性:同 commit 重跑 3 次再决定 flaky / 真 bug,绝不盲建 Bug。
结语
这套方案的内核不是某个炫技工具,而是一条原则:把确定性和推理分干净 。脚本扛热路径(0 token、可重放),大模型只在"读 PRD、判 flaky、归因"这种真正需要智能的地方出现,agent 框架在最外层把调度、编排、通知粘起来。再配上几条红线(oracle 来自 PRD、稳定主键幂等、失败先定性),就能得到一套长效、廉价、可信的自动化测试 Agent。
附录:关键脚本
路径 / ID / 域名 / 账号已模板化,替换成你自己的即可。
附录 A:run-pipeline.sh(主流水线,全文)
bash
#!/usr/bin/env bash
# gate(确定性) → [--generate: claude -p 生成 spec] → run(Playwright)
# 退出码: 0=通过;1=门失败(不写缺陷库);2=回归有失败(外层触发分诊)
set -uo pipefail
REPO="${REPO:-/path/to/your/repo}"
MODULE="${MODULE:-account-login}"
RUNNER="${RUNNER:-local}" # local=本机 | docker=容器隔离
PW_IMAGE="${PW_IMAGE:-kbox-playwright:1.60}" # 自建镜像(官方镜像不可达)
CASES="$REPO/docs/qa/cases/${MODULE}.json"
SPEC_MD="$REPO/tests/e2e/specs/${MODULE}.md"
cd "$REPO" || { echo "✗ 仓库不存在: $REPO"; exit 1; }
mkdir -p artifacts
ts() { date +%H:%M:%S; }
# Stage 1: 确定性质量门(写缺陷库/生成 spec 前必过)
echo "[$(ts)] ▶ gate"
if ! node docs/qa/promptfoo/assert-testcases.js "$CASES"; then
echo "[$(ts)] ✗ 门未过,终止"; exit 1
fi
# Stage 2(可选): claude -p → 官方生成子代理,对真实页面验证选择器
if [[ "${1:-}" == "--generate" ]]; then
claude -p "用 playwright-test-generator 子代理:读 ${SPEC_MD} 与 ${CASES},对活 DOM 验证选择器后生成/更新 tests/e2e/preview/preview-${MODULE}.spec.ts。只生成 spec。" \
--output-format stream-json --dangerously-skip-permissions 2>&1 | tail -8 \
|| echo "[$(ts)] ⚠ 生成异常,沿用现有 spec"
fi
# Stage 3: 跑 preview 回归(JSON reporter 便于外层确定性解析)
RUN_JSON="artifacts/${MODULE}-last-run.json"; RUN_LOG="artifacts/${MODULE}-last-run.log"
SPEC="tests/e2e/preview/preview-${MODULE}.spec.ts"
if [[ "$RUNNER" == "docker" ]]; then
if ! docker image inspect "$PW_IMAGE" >/dev/null 2>&1; then
echo "[$(ts)] ✗ 镜像 $PW_IMAGE 不在本地。自建:docker build -t ${PW_IMAGE} - < scripts/qa/Dockerfile.playwright"; exit 1
fi
PW_ENV=(); [[ -n "${PLAYWRIGHT_PREVIEW_BASE_URL:-}" ]] && PW_ENV+=(-e "PLAYWRIGHT_PREVIEW_BASE_URL=$PLAYWRIGHT_PREVIEW_BASE_URL")
docker run --rm --ipc=host --user "$(id -u):$(id -g)" -e HOME=/work \
${PW_ENV[@]+"${PW_ENV[@]}"} -v "$REPO:/work" -w /work "$PW_IMAGE" \
npx playwright test --config playwright.preview.config.ts --reporter=json "$SPEC" > "$RUN_JSON" 2> "$RUN_LOG"
RUN=$?
else
npx playwright test --config playwright.preview.config.ts --reporter=json "$SPEC" > "$RUN_JSON" 2> "$RUN_LOG"
RUN=$?
fi
node -e "try{const s=require('./$RUN_JSON').stats||{};console.log('[run] expected='+s.expected+' unexpected='+s.unexpected+' flaky='+s.flaky)}catch(e){console.log('解析失败',e.message)}" 2>/dev/null
[[ $RUN -ne 0 ]] && { echo "[$(ts)] ✗ 有失败 → exit 2"; exit 2; }
echo "[$(ts)] ✓ 全过"; exit 0
附录 B:Dockerfile.playwright(自建运行镜像,全文)
dockerfile
FROM node:22-bookworm
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \
PLAYWRIGHT_DOWNLOAD_HOST=https://cdn.npmmirror.com/binaries/playwright \
npm_config_registry=https://registry.npmmirror.com
RUN npx -y playwright@1.60.0 install --with-deps chromium \
&& chmod -R a+rx /ms-playwright
WORKDIR /work
附录 C:钉钉自定义机器人推送(notify-dingtalk.mjs,全文)
js
import crypto from "node:crypto";
/** 推送 markdown 到钉钉自定义机器人。返回 {ok, skipped?, status?, errcode?, errmsg?}。 */
export async function sendDingtalk(title, text) {
const WEBHOOK = process.env.DINGTALK_WEBHOOK || "";
const SECRET = process.env.DINGTALK_WEBHOOK_SECRET || "";
if (!WEBHOOK) return { ok: false, skipped: true, reason: "未配置 DINGTALK_WEBHOOK" };
let url = WEBHOOK;
if (SECRET) { // 群机器人安全设置选「加签」时
const ts = Date.now();
const sign = encodeURIComponent(
crypto.createHmac("sha256", SECRET).update(`${ts}\n${SECRET}`).digest("base64")
);
url += `${WEBHOOK.includes("?") ? "&" : "?"}timestamp=${ts}&sign=${sign}`;
}
const body = { msgtype: "markdown", markdown: { title, text } };
const res = await fetch(url, { method: "POST",
headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
const data = await res.json().catch(() => ({}));
return { ok: res.ok && data.errcode === 0, status: res.status, errcode: data.errcode, errmsg: data.errmsg };
}
附录 D:质量门规则 + 用例 schema
质量门(assert-testcases.js)的核心规则------任一命中即判不合格:
text
REQUIRED_BUCKETS = [functional, error] # 必须都有
ANY_OF_BUCKETS = [edge, empty-state, recovery,
input-fuzz, state-transition, authz] # 至少 1 个
不合格条件:用例数为 0 / 缺 requirement_id / expected 为空 /
断言空洞(无数字·引号·"401|403|跳转|拒绝|失败"等信号且长度<12) /
缺 functional 或 error 桶 / ANY_OF 一个都没有
退出码:合格 0,不合格 1,参数错 2 ← 0 token,写缺陷库前必过
每条用例的结构(期望来自 PRD,不是代码):
yaml
- id: TC-LOGIN-003 # 稳定可读唯一(贯穿 spec / 缺陷库 / Bug)
requirement_id: REQ-LOGIN-3 # 映射 PRD(必填)
flow: 账号登录
priority: P1 # P0/P1/P2
bucket: error # functional|edge|error|state-transition|empty-state|recovery|input-fuzz|authz
preconditions: [在登录页, 未登录]
steps: [输入正确账号, 输入错误密码, 点击登录]
expected: # E2E 可观测,来自 PRD
- 显示统一错误提示
- 停留在登录页
- 登录请求返回 401
expected_backend: # 仅后端可验证,交 API smoke
- 审计表写入 LOGIN_FAILED