截图加坐标点击在演示里好看,却经不起布局微调、动态节点和异步渲染:同一条轨迹很难复现,更难写成回归。本文用 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 做回放,截图留在失败分支。这样浏览器智能体才从“看起来会用电脑”变成一套能回归、能审计、能分类失败的工具。先把这一层写死,再让模型在它上面规划;否则评测看到的只是一串不可复现的像素。