QA Agent — 前端质量保障自动化 (v1.1)
输入即执行: Bug 描述 → 自动复现 + 定位。测试步骤 → 自动执行 + 报告。代码变更 → 影响分析 + 发散验证。
基于 Browser Harness (CDP 协议) 驱动真实浏览器,LLM 将自然语言翻译为操作序列,截图 + 坐标点击穿透 Shadow DOM。
前置条件与强制约束
| 约束 | 说明 |
|---|---|
| ✅ 必须使用 Browser Harness | 所有浏览器操作通过 CDP 协议执行,禁止 Playwright/Puppeteer/Selenium |
| 🔧 未安装时询问用户 | browser-harness --doctor 失败 → 停止 → 提供安装文档 → 询问是否自动安装 |
| 🚫 禁止替用户操作 Chrome 设置 | daemon 连接失败时引导用户自己打开 chrome://inspect/#remote-debugging 并点击 Allow。Agent 端浏览器无法控制用户 Chrome |
| 📂 有源码时自动增强 | 检测到 src/ 或 package.json → 自动启用源码交叉分析 (同 page-spec-generator v3.3 +src 插件) |
首次使用验证
在正式开始前,先跑 5 行脚本确认环境正常:
browser-harness <<'PY'
new_tab("https://www.baidu.com")
print(page_info())
PY
| 验证项 | 通过标志 |
|---|---|
| daemon + CDP 连接 | page_info() 返回 dict 非报错 |
| 页面导航 | url 字段为目标 URL |
| 错误信息 | 原因 | 解决 |
|---|---|---|
fatal: CDP WS handshake failed |
Chrome 远程调试未授权 | chrome://inspect 重勾 Allow |
Connection refused |
Chrome 未启动 | 启动 Chrome 或 Way 2 启动: chrome.exe --remote-debugging-port=9222 --user-data-dir=C:\temp\chrome-dev 然后 export BU_CDP_URL=http://127.0.0.1:9222 |
TimeoutError |
网络不通 | 换 https://www.baidu.com (国内最快) |
Target ... not found |
标签页失效 | 调用 ensure_real_tab() 恢复 |
四个核心能力
用户输入
│
├── "复现这个 bug: {描述}"
│ → 🔍 流水线 §Q: Bug 复现 + 根因定位
│
├── "验证 {BUG-123} 修好了没"
│ → ✅ 流水线 §R: 回归验证
│
├── "分析这次修改影响了哪些功能"
│ → 🌐 流水线 §I: 影响发散
│
└── "执行这些测试步骤: {步骤列表}"
→ 🤖 流水线 §T: 用例自动执行
流水线 §Q: Bug 复现 + 根因定位
整体流程
Bug 描述 (自然语言)
│
Q0 ── LLM 结构化提取
│ 输入: 任何形式的 bug 描述 (Jira / Slack / GitHub Issue / 口头)
│ 输出: { url, steps[], expectedBehavior, actualBehavior, missingInfo[] }
│
Q1 ── 操作序列规划
│ 每个 step → LLM 翻译为 Browser Harness 原子操作链
│ 操作类型: navigate / click / input / select / scroll / hover / wait
│
Q2 ── 逐步执行 + 三层日志
│ for each action:
│ 截图(执行前) → 执行 → 截图(执行后)
│ 记录: DOM 变化 / 网络请求 / 控制台错误
│
Q3 ── 结果判断
│ 实际行为 vs 预期行为 → 复现成功 / 失败
│ 未复现 → 3 次变异重试 (换视口/换数据/节流/清缓存)
│
Q4 ── 根因定位
│ 控制台错误 → 前端代码定位
│ 网络错误 → 后端 API 定位
│ 视觉异常 → 渲染问题定位
│ 输出: 层级 (前端/后端/数据) + 置信度 + 可疑文件
│
Q5 ── 修改建议
输出: 可疑代码位置 + 修复方向 + 临时规避方案
Q0: LLM 结构化提取
def parse_bug_report(user_input):
"""从自然语言 bug 描述中提取结构化信息。"""
prompt = f"""
从以下 bug 描述中提取结构化信息。无法确定的字段填 null。
Bug 描述: {user_input}
返回 JSON:
{{
"url": "起始页面 URL",
"title": "一句话标题",
"severity": "critical / major / minor",
"steps": [
{{ "order": 1, "action": "navigate / click / input / select",
"target": "操作目标", "value": "输入值" }}
],
"expectedBehavior": "预期行为",
"actualBehavior": "实际行为",
"errorInfo": {{ "consoleError": "", "networkError": "", "visualGlitch": "" }},
"missingInfo": ["缺失的关键信息"]
}}
"""
return llm_json(prompt)
缺失信息处理:
| 缺失项 | 处理 |
|--------|------|
| URL | 尝试从文本提取链接;无则询问用户 |
| 登录态 | 先以未登录尝试;受阻则暂停提示用户登录 |
| 操作目标模糊 | 截图 + LLM 视觉识别找最可能元素 |
| 前置数据 | 尝试自动创建,复杂则标记需人工准备 |
Q1: 操作序列规划
每个步骤翻译为 Browser Harness 原子操作:
"打开 {url}" → new_tab(url) → wait_for_load() → wait_for_network_idle()
"点击 {目标}" → capture_screenshot() → LLM 视觉定位坐标 → click_at_xy(x, y)
"输入 {内容}" → capture_screenshot() → LLM 定位输入框 → click_at_xy(x, y) → fill value
"选择 {选项}" → capture_screenshot() → LLM 定位选项 → click_at_xy(x, y)
"验证 {内容}存在" → capture_screenshot() → LLM 检查文本
视觉定位 Prompt:
这张截图是一个网页。请找到 "{target}" 的像素坐标。
返回: {{ "x": 中心x, "y": 中心y, "confidence": 0-1, "reason": "依据" }}
找不到: {{ "x": null, "y": null, "confidence": 0, "reason": "原因" }}
Q2: 逐步执行
前置: 注入证据采集器(在执行任何操作之前):
def inject_evidence_collectors():
"""在页面中注入控制台错误和网络请求拦截器。"""
js("""
window.__qa_console_errors = [];
window.addEventListener('error', function(e) {
window.__qa_console_errors.push({
type: 'error', message: e.message,
filename: (e.filename||'').split('/').pop(),
line: e.lineno, timestamp: Date.now()
});
});
window.addEventListener('unhandledrejection', function(e) {
window.__qa_console_errors.push({
type: 'unhandledrejection',
message: String(e.reason).substring(0, 500),
timestamp: Date.now()
});
});
window.__qa_network_log = [];
var _origFetch = window.fetch;
window.fetch = async function(url, opts) {
var start = Date.now();
var reqUrl = typeof url === 'string' ? url : (url.url || '');
var reqMethod = (opts && opts.method) || 'GET';
var reqBody = '';
try { reqBody = (opts && opts.body) ? String(opts.body).substring(0, 1000) : ''; } catch(e) {}
try {
var resp = await _origFetch.apply(this, arguments);
var clone = resp.clone();
var respBody = await clone.text().catch(function(){ return ''; });
window.__qa_network_log.push({
url: reqUrl, method: reqMethod, status: resp.status,
duration: Date.now() - start, requestBody: reqBody,
responseBody: respBody.substring(0, 1000), timestamp: Date.now()
});
return resp;
} catch(e) {
window.__qa_network_log.push({
url: reqUrl, method: reqMethod, error: String(e).substring(0, 300),
duration: Date.now() - start, timestamp: Date.now()
});
throw e;
}
};
""")
def get_console_errors():
return json.loads(js("JSON.stringify(window.__qa_console_errors || [])"))
def get_network_errors():
all_logs = json.loads(js("JSON.stringify(window.__qa_network_log || [])"))
return [r for r in all_logs if r.get('status', 0) >= 400 or r.get('error')]
执行循环 (含异常保护):
def execute_action_plan(plan):
inject_evidence_collectors()
trace = []
for i, action in enumerate(plan):
step_entry = {"step": i, "action": action}
try:
# 执行前快照
step_entry["before"] = {
"url": js("window.location.href"),
"shot": f"bug_step{i}_before"
}
try:
capture_screenshot(step_entry["before"]["shot"])
except Exception:
step_entry["before"]["shot"] = "screenshot_failed"
# 执行操作
if action["action"] == "navigate":
new_tab(action["url"])
wait_for_load()
wait_for_network_idle(timeout=15, idle_ms=1500)
elif action["action"] == "click":
coord = llm_visual_locate(action["target"], step_entry["before"]["shot"])
if coord.get("x") is not None:
click_at_xy(coord["x"], coord["y"])
else:
step_entry["error"] = f"visual locate failed: {coord.get('reason','')}"
elif action["action"] == "input":
coord = llm_visual_locate(action["target"], step_entry["before"]["shot"])
if coord.get("x") is not None:
click_at_xy(coord["x"], coord["y"])
value = json.dumps(action['value'])
js(f"""
var el = document.activeElement;
var setter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype, 'value'
).set;
setter.call(el, {value});
el.dispatchEvent(new Event('input', {{ bubbles: true }}));
el.dispatchEvent(new Event('change', {{ bubbles: true }}));
""")
else:
step_entry["error"] = f"visual locate failed: {coord.get('reason','')}"
time.sleep(1.5)
# 执行后快照
step_entry["after"] = {
"url": js("window.location.href"),
"shot": f"bug_step{i}_after"
}
try:
capture_screenshot(step_entry["after"]["shot"])
except Exception:
step_entry["after"]["shot"] = "screenshot_failed"
step_entry["consoleErrors"] = get_console_errors()
step_entry["networkErrors"] = get_network_errors()
except Exception as e:
step_entry["error"] = f"{type(e).__name__}: {str(e)[:300]}"
step_entry["partial"] = True
trace.append(step_entry)
# 关键操作失败则停止后续步骤
if step_entry.get("error") and action["action"] in ("navigate", "click"):
break
return trace
Q3: 结果判断
def judge_reproduction(trace, expected, actual_described):
last = trace[-1]
# 判断 1: 控制台错误
has_error = len(last["consoleErrors"]) > 0
# 判断 2: 页面白屏 (body 文本极少)
is_white_screen = js("document.body.innerText.length") < 100
# 判断 3: LLM 视觉比对
# 将实际截图与 Bug 描述中的 actualBehavior 比对,而非 expected
llm_result = llm_compare(
actual_screenshot=last["after"]["shot"],
described_behavior=actual_described,
console=last["consoleErrors"]
)
# reproduced = 实际表现匹配 Bug 描述中的异常行为
if has_error or is_white_screen or llm_result.matches:
return {"reproduced": True, "confidence": "high", "evidence": trace}
# 未复现 → 变异重试 (最多 3 次)
for i, variant in enumerate(generate_variants()):
if i >= 3:
break
retry_trace = execute_with_variant(plan, variant)
if judge_reproduction(retry_trace, expected, actual_described)["reproduced"]:
return {"reproduced": True, "confidence": "medium",
"variant": variant, "evidence": retry_trace}
return {"reproduced": False, "confidence": "low"}
变异策略: 移动端视口 / 不同输入数据 / 加速操作 / 网络节流 / 清除缓存 / 不同登录态。最多 3 次。
Q4-Q5: 根因定位 + 修改建议
分层定位:
控制台错误
├─ "Cannot read property 'X' of undefined"
│ → API 响应缺少字段 → 前端未做空值保护
│ → +src:api 定位 API 调用代码
│ → 建议: 前端加 ?. 空值保护
│
├─ HTTP 4xx/5xx
│ → 从 Performance API 提取失败请求 URL
│ → +src:api 定位调用方
│ → 建议: 检查 API 参数/认证/可用性
│
└─ 无错误但页面异常
→ 截图对比 (正常 vs 异常)
→ LLM 视觉分析差异区域
→ 建议: 检查组件渲染条件 / CSS 布局
流水线 §R: 回归验证
R0 ── 读取原始 Bug 的复现步骤 (来自 Q2 的 trace)
│
R1 ── 在修复后的页面上重放完全相同的操作序列
│ 使用保存的坐标/输入值
│ 若 DOM 变化导致坐标失效 → LLM 重新视觉定位
│
R2 ── 对比修复前后
│ before.png vs after.png (截图对比)
│ 控制台对比 (JS 异常是否消失)
│ 网络对比 (错误 API 是否恢复正常)
│
R3 ── 判定
✅ 通过: 原异常消失 + 核心功能正常
❌ 失败: 原异常仍存在
⚠️ 部分: 原异常消失但出现新异常
流水线 §I: 影响发散
I0 ── 输入: 修改文件列表 (git diff --name-only) + 基础 URL
│
I1 ── 构建影响图 (需要源码)
│ 对每个修改文件:
│ 组件: 提取 Props → 找所有引用该组件的父组件
│ API: 提取函数名 → 找所有调用该 API 的页面
│ Store: 提取 state 字段 → 找所有读取该字段的组件
│ 路由: 修改的路由 → 提取关联导航入口
│
I2 ── 生成验证清单
│ 每组受影响场景 → 一个验证任务
│ 按风险排序: 直接引用 > 间接引用 > 同模块
│
I3 ── 自动探索验证
│ for each task:
│ LLM 翻译 → Browser Harness 执行 → 截图 → 比对
│ 异常 (报错/白屏/样式错乱) → 标记风险点
│
I4 ── 发散验证报告
影响范围: {N} 场景需验证
自动验证: {M} 通过 / {K} 需人工
风险点: 异常截图 + 说明
流水线 §T: 用例自动执行
T0 ── 解析测试步骤
│ 支持格式:
│ Gherkin: Given/When/Then
│ 编号列表: 1. 打开页面 2. 输入xxx 3. 验证xxx
│ 自然语言: "打开登录页,输入admin/admin,点击登录,应该跳转首页"
│ LLM 提取: 每步的操作 + 预期结果
│
T1 ── 逐步执行
│ for each step:
│ 截图(执行前)
│ LLM 翻译 → Browser Harness 操作 → 执行
│ 截图(执行后)
│ 若含 "验证/检查/确认/应该":
│ LLM 判断页面文本/URL/元素是否满足预期
│ 记录: ✅ 通过 / ❌ 失败 (附差异说明)
│
T2 ── 异常处理
│ 操作失败 → 重试 1 次 (等待更久)
│ 仍失败 → 标记 ❌ 阻塞,跳过后续步骤
│
T3 ── 生成报告
| 步骤 | 操作 | 预期 | 实际 | 截图 | 结果 |
|------|------|------|------|------|------|
| 1 | 打开 /login | 登录页 | 正常 | [图] | ✅ |
| 2 | 输入 admin/admin | 可提交 | 按钮可点击 | [图] | ✅ |
| 3 | 点击登录 | 跳转首页 | URL 变为 /home | [图] | ✅ |
通过率: 3/3 (100%)
LLM 翻译层设计
分层调用 (4 个 LLM 角色)
LLM-1 (结构化提取): Bug描述 → StructuredBug JSON
LLM-2 (操作规划): Step[] → ActionPlan (操作链)
LLM-3 (视觉定位): 截图 + target → {x, y, confidence}
LLM-4 (断言判断): 截图 + 预期 → {pass: bool, reason: str}
分层的好处: 每个 LLM 只做一件事,Prompt 更短更精确。
视觉定位为首选
| 方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 截图+坐标 | 穿透 Shadow DOM、不受 DOM 结构影响 | 依赖视觉模型、布局变化需重定位 | 默认首选 |
| CSS 选择器 | 精确、快速 | DOM 变化即失效 | 有 data-testid 时 |
| 文本匹配 | 直观稳定 | 同文本多元素需消歧 | 唯一文本时 |
操作失败自适应
LLM-3 返回 confidence < 0.7
→ 不执行,先消歧:
1. 滚动页面不同区域再截图
2. 尝试不同描述词
3. 仍低置信度 → 标记需人工介入
输出产物
| 能力 | 产物 | 内容 |
|---|---|---|
| Bug 复现 | bug-{id}-analysis.md |
复现证据 (截图+日志) + 根因定位 + 修改建议 |
| 回归验证 | bug-{id}-regression.md |
修复前后对比 + 通过/失败/部分 判定 |
| 影响发散 | impact-{commit}-report.md |
影响场景清单 + 自动验证结果 + 风险点 |
| 用例执行 | testrun-{name}-report.md |
步骤×结果矩阵 + 截图 + 通过率 |
| 共用 | screenshots/{run-id}/ |
每步操作前后截图 |
| 共用 | traces/{run-id}/ |
网络日志 + 控制台日志 + DOM 快照 |
与 page-spec-generator 的关系
| 维度 | page-spec-generator | QA Agent |
|---|---|---|
| 定位 | 探索未知站点 → 生成规格文档 | 针对已知问题/用例 → 定向验证 |
| 输入 | URL | Bug 描述 / 测试步骤 / Git Diff |
| 驱动 | 8 阶段结构化流水线 | LLM 翻译自然语言 → 操作序列 |
| 输出 | 14/8 章规格文档 | 步骤×结果矩阵 QA 报告 |
| 共享 | Browser Harness CDP | Browser Harness CDP |
| 共享 | +src 源码插件 v3.3 | +src 源码插件 v3.3 |
Browser Harness 操作注意事项
| 问题 | 对策 |
|---|---|
| 截图超时导致流程中断 | 截图包裹 try/except,失败降级为文本断言,不抛异常 |
js() 无法引用 Python 变量 |
js() 在 Chrome 上下文执行,需传参用 f-string: js(f"el.value = {json.dumps(val)}") |
| CDP 调用无自动重试 | capture_screenshot()/click_at_xy() 失败时原文报告,不自动重试超 2 次 |
| 标签页意外切换 | 用户切标签/刷新/chrome-error:// 后 session 失效 → ensure_real_tab() 恢复 |
| SPA 页面滚动不可靠 | CDP mouseWheel 是 Chromium 已知 Bug。用 js("window.scrollBy(0,300)") 替代 CDP scroll |
常见问题
Q: 视觉定位找不到目标元素怎么办?
- 滚动画面的不同区域再截图重试
- 尝试不同描述词 ("微信支付" → "WeChat Pay" → "微信")
- 尝试 CSS 选择器 / 文本匹配降级
- 三次消歧失败 → 标记需人工介入
Q: Bug 描述信息不全怎么办?
先执行结构化提取 (Q0),将 missingInfo 列出。如果缺失的信息是关键前置条件 (URL、登录账号),暂停并询问用户。如果是次要信息 (浏览器版本),用默认值继续。
Q: 操作会产生不可逆后果怎么办?
提交订单、删除数据、发送请求等不可逆操作 → 执行前确认: "此操作将 {后果},是否继续?" 用户确认后才执行。
Q: 和 AgentiTest / e2e-test-agent 的区别?
- 它们: 纯测试用例执行 (Given/When/Then → Playwright)
- QA Agent: Bug 复现 + 回归验证 + 影响发散 + 用例执行,四种能力合一
- QA Agent 独有: 源码交叉分析 (+src)、截图坐标点击 (穿透 Shadow DOM)、变异重试策略
版本
v1.1 — 基于 Browser Harness CDP + LLM 视觉定位 + page-spec-generator v3.4 源码插件。四个子流水线: Bug 复现 (Q)、回归验证 (R)、影响发散 (I)、用例执行 (T)。新增首次使用验证、操作注意事项、Way 2 支持、SPA 兼容性警告。