截图加坐标点击在演示里好看,却经不起布局微调、动态节点和异步渲染:同一条轨迹很难复现,更难写成回归。本文用 Node.js 与 Playwright 把智能体的浏览器操作收成一层可测接口——定位优先走角色、名称和测试 id,截图只用于失败诊断;并为跳转、点击、输入加上域名白名单、步骤预算和可回放轨迹。目标不是再做一个会“看屏幕”的模型,而是把计算机使用从人工盯屏变成可审计、可评测的能力。
截图点击为什么写不成回归
视觉智能体常见的工具形态是:截一张图,模型给出坐标,再 mouse.click(x, y)。这条路径对人工演示很友好,对测试不友好。按钮差几个像素、列表多插一行、字体或滚动位置变化,坐标就失效。动态节点和延迟渲染更麻烦:截图瞬间看到的节点,执行时可能已经卸载或被遮挡。于是同一任务今天能跑通,明天变成一次无法解释的误点。
坐标轨迹还有一个更实质的问题:它几乎不能当断言用。回归测试需要稳定的操作对象(“名为登录的按钮”),而不是一帧图像上的像素。截图适合回答“当时屏幕长什么样”,不适合回答“智能体到底点了什么”。若把截图点击当作主路径,失败时你只能得到一张图和一组坐标,很难区分是定位错、页面没就绪,还是模型幻觉。
可测的操作层应把“看见”和“动手”分开。看见可以辅助诊断,动手必须落在页面语义结构上。Playwright 已经提供了这条路:无障碍角色与名称、label、以及前端可控的 data-testid。智能体调用的不应是原始鼠标,而是带策略约束的 navigate / click / type。
把智能体的手收成可测操作层
接到现有工具执行层时,不要把 Playwright Page 对象直接交给模型。对外只暴露少量动作,并对每次调用写下同一组审计字段。一套够用的最小接口是:
snapshot():返回精简后的无障碍节点(角色、名称、深度),供规划下一步,而不是丢整页 HTML。navigate(url):唯一允许改变站点的入口,必须过域名白名单。click(target)/type(target, value):target只接受稳定选择器,不接受坐标。- 失败时的
screenshot:作为诊断附件,不作为下一步决策输入。
target 建议做成显式结构,避免模型自由发挥 CSS:{ testId }、{ role, name } 或 { label }。审计行至少包含时间、动作名、当前 URL、已用步数、剩余预算、是否成功、失败码、可选截图路径。这些字段既可写入 JSONL 做回放,也可在评测时按失败码聚合,而不必回放视频。
测试夹具负责打开起始页(本地 setContent 或受控环境);智能体若要离开当前页,只能走 navigate。这样“页面从哪来”和“模型被允许去哪”不会混在一起。
无障碍树与稳定选择器
定位顺序应当固定,且写进工具层,而不是靠提示词约定:data-testid 优先,其次是角色加可访问名称,再次是 label。CSS 路径和坐标放在拒绝名单里。getByRole 走的是计算后的无障碍信息,更接近真实用户和辅助技术看到的控件,也更接近 UI 测试的写法。
下面是一份可直接运行的工具层。先安装依赖:npm i playwright,再执行 npx playwright install chromium,然后 node agent-browser.js。
const { chromium } = require("playwright");
const fs = require("fs");
const path = require("path");
const ALLOWED_HOSTS = new Set(["example.com", "www.example.com"]);
const MAX_ACTIONS = 12;
class AgentError extends Error {
constructor(code, message, extra = {}) {
super(message);
this.name = "AgentError";
this.code = code;
Object.assign(this, extra);
}
}
function hostOf(url) {
try {
return new URL(url).hostname;
} catch {
return "";
}
}
function flattenA11y(node, rows = [], depth = 0) {
if (!node) return rows;
rows.push({ depth, role: node.role || "", name: node.name || "" });
for (const child of node.children || []) flattenA11y(child, rows, depth + 1);
return rows;
}
class BrowserToolLayer {
constructor({ page, auditPath, screenshotDir }) {
this.page = page;
this.auditPath = auditPath;
this.screenshotDir = screenshotDir;
this.used = 0;
this.trace = [];
}
remaining() {
return MAX_ACTIONS - this.used;
}
async record(entry) {
const row = {
ts: new Date().toISOString(),
used: this.used,
remaining: this.remaining(),
url: this.page.url(),
...entry,
};
this.trace.push(row);
fs.appendFileSync(this.auditPath, JSON.stringify(row) + "\n");
return row;
}
async charge(action) {
if (this.used >= MAX_ACTIONS) {
throw new AgentError("BUDGET_EXCEEDED", `动作预算已用尽(${MAX_ACTIONS})`, { action });
}
this.used += 1;
}
assertAllowed(url) {
const host = hostOf(url);
if (!host || !ALLOWED_HOSTS.has(host)) {
throw new AgentError("DOMAIN_BLOCKED", `域名不在白名单:${host || url}`, { url });
}
}
resolve(target) {
const { page } = this;
if (target.testId) return page.getByTestId(target.testId);
if (target.role && target.name) {
return page.getByRole(target.role, { name: target.name, exact: true });
}
if (target.label) return page.getByLabel(target.label, { exact: true });
throw new AgentError("BAD_TARGET", "目标必须提供 testId、role+name 或 label", { target });
}
async fail(code, err, extra) {
const shot = path.join(this.screenshotDir, `${Date.now()}-${code}.png`);
try {
await this.page.screenshot({ path: shot, fullPage: true });
} catch {
// 页面不可用时允许没有截图
}
const row = await this.record({
ok: false,
code,
error: err.message,
screenshot: shot,
...extra,
});
return { ok: false, code, error: err.message, screenshot: shot, audit: row };
}
async run(action, extra, fn) {
try {
await this.charge(action);
const result = await fn();
await this.record({ action, ok: true, ...extra });
return result;
} catch (err) {
return this.fail(err.code || "ACTION_FAILED", err, { action, ...extra });
}
}
async snapshot() {
return this.run("snapshot", {}, async () => {
const tree =
(await this.page.accessibility.snapshot({ interestingOnly: true })) ||
{ role: "WebArea", name: "", children: [] };
const nodes = flattenA11y(tree).filter((n) => n.role || n.name);
return { ok: true, nodes: nodes.slice(0, 80) };
});
}
async navigate(url) {
return this.run("navigate", { href: url }, async () => {
this.assertAllowed(url);
await this.page.goto(url, { waitUntil: "domcontentloaded", timeout: 15000 });
return { ok: true, url: this.page.url() };
});
}
async click(target) {
return this.run("click", { target }, async () => {
const locator = this.resolve(target);
const n = await locator.count();
if (n === 0) throw new AgentError("LOCATOR_NOT_FOUND", "没有匹配的控件", { target });
if (n > 1) throw new AgentError("LOCATOR_AMBIGUOUS", `匹配到 ${n} 个控件`, { target });
await locator.click({ timeout: 5000 });
return { ok: true, url: this.page.url() };
});
}
async type(target, value) {
return this.run("type", { target, valueLength: value.length }, async () => {
const locator = this.resolve(target);
const n = await locator.count();
if (n === 0) throw new AgentError("LOCATOR_NOT_FOUND", "没有匹配的控件", { target });
if (n > 1) throw new AgentError("LOCATOR_AMBIGUOUS", `匹配到 ${n} 个控件`, { target });
await locator.fill(value, { timeout: 5000 });
return { ok: true };
});
}
}
async function main() {
fs.mkdirSync("artifacts", { recursive: true });
const auditPath = path.join("artifacts", "audit.jsonl");
if (fs.existsSync(auditPath)) fs.unlinkSync(auditPath);
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext();
await context.tracing.start({ screenshots: true, snapshots: true });
const page = await context.newPage();
const tools = new BrowserToolLayer({
page,
auditPath,
screenshotDir: "artifacts",
});
await page.setContent(`
<main>
<h1>演示登录</h1>
<form>
<label>邮箱 <input data-testid="email" /></label>
<label>密码 <input data-testid="password" type="password" /></label>
<button type="button" data-testid="login">登录</button>
</form>
<p data-testid="status" role="status"></p>
</main>
<script>
document.querySelector('[data-testid="login"]').addEventListener("click", () => {
const email = document.querySelector('[data-testid="email"]').value;
document.querySelector('[data-testid="status"]').textContent =
email ? "已提交 " + email : "请输入邮箱";
});
</script>
`);
const snap = await tools.snapshot();
console.log("a11y", snap.nodes.slice(0, 8));
await tools.type({ testId: "email" }, "qa@example.com");
await tools.type({ label: "密码" }, "demo-only");
await tools.click({ role: "button", name: "登录" });
console.log("status", await page.getByRole("status").textContent());
const denied = await tools.navigate("https://blocked.example/");
console.log("policy", denied.code, denied.error);
await context.tracing.stop({ path: path.join("artifacts", "trace.zip") });
fs.writeFileSync(
path.join("artifacts", "replay.json"),
JSON.stringify(tools.trace, null, 2)
);
await browser.close();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
无障碍快照不是万能的:interestingOnly 会丢掉部分节点,iframe、canvas 和未正确暴露语义的自定义组件也可能缺席。文案一变,角色名称就会变,所以关键路径仍应让前端给测试 id。工具层要做的是把这些限制变成明确失败,而不是静默退回截图点击。
白名单、动作预算和回放轨迹
浏览器智能体的风险不在“会点按钮”,而在动作是开放世界的:任意 URL、任意次数、任意输入。操作层必须先当策略引擎。域名白名单卡在 goto 之前,避免模型用一次导航离开评测沙箱。步骤预算按动作计数,snapshot 也计入——否则模型可以靠无限观察拖垮任务。单步超时(示例里点击、输入 5 秒,导航 15 秒)用来切断等不到的异步。
预算不要和测试超时混为一谈。测试超时保护的是整个用例;动作预算保护的是智能体在一页上的 viscious 循环:重复点击、反复打开同一菜单、把观察当行动。预算用尽应返回 BUDGET_EXCEEDED,让评测把它记成策略失败,而不是“模型不够聪明”。
回放分两层,都不要靠截图序列。JSONL / replay.json 记录语义动作,便于对账和做回归夹具:给定同一 target,在同一页面上重放 click/type。Playwright Tracing 则保留 DOM 快照和时间线,失败后用 npx playwright show-trace artifacts/trace.zip 查看。截图只在 fail() 里出现,作为附件而不是轨迹本身。
失败分类怎样接到现有工具层
把异常收成稳定错误码,评测和编排才写得下去。上面示例已经能区分:DOMAIN_BLOCKED、BUDGET_EXCEEDED、BAD_TARGET、LOCATOR_NOT_FOUND、LOCATOR_AMBIGUOUS,以及未归类的 ACTION_FAILED。实际接入时还可以把 Playwright 超时映射为 ACTION_TIMEOUT,把跳转到非白名单主机映射为 UNEXPECTED_NAVIGATION(需要额外监听 page 的导航事件时再加,不必一上来做全)。
接到工具执行层时,每个工具返回同一形状:{ ok, code?, error?, screenshot?, ... }。规划器只根据 ok 和 code 决定重试、结束或换策略;截图留给人看或留给离线分析,不要再喂回“点哪里”的循环。回归用例可以脱离模型:用同一 target 调 click/type,断言状态节点文本,等于把智能体轨迹降级成普通 UI 测试。模型参与的评测则统计错误码分布:定位失败、策略拒绝、预算耗尽应分开,否则分数无法解释。
这层接口不要求你重写 Agent 框架。现有执行器只要把浏览器调用换成这些方法,并持久化审计行。演示仍可以附一张结束截图;日常运行应以轨迹和错误码为准。
总结
截图点击把页面当成图片,操作层应把页面当成有角色和名称的控件树。用 Playwright 的 getByTestId、getByRole、getByLabel 和 accessibility.snapshot 做主路径,用白名单与动作预算限制开放世界,用 JSONL 加 Tracing 做回放,截图留在失败分支。这样浏览器智能体才从“看起来会用电脑”变成一套能回归、能审计、能分类失败的工具。先把这一层写死,再让模型在它上面规划;否则评测看到的只是一串不可复现的像素。
评论