10 颗一模一样的「Edit」:中后台 Web Agent 怎么找到对的那一行

给中后台系统写自动化测试、写视觉走查脚本,最容易死在找入口这一步。

拿开源的 vue-element-admin(4.4.0)来说。行内编辑页(/table/inline-edit-table)一屏渲染 10 行数据,每行末尾挂一颗文案为 Edit 的按钮。点开某一行之后才会出现的确认 Ok 和取消 cancel,写在 v-if="row.edit" 里。没点开之前,DOM 中根本不存在这两个节点。

你可能想过最省事的办法:整页截图丢给视觉大模型(VLM),让它吐出 (x, y) 像素坐标去点。表格多一行、窗口缩放一点,坐标就偏。另一条路是像传统脚本那样把 CSS 选择器写死在用例里,业务模板一改结构,脚本全废。

我们继续用 Playwright 当底层执行器,但入口定位不能交给这两条路。

内部工程 xingwu-vision-agent(Node.js 20 + TypeScript;注:下文代码取自核心模块,为说明设计意图做了删减与签名简化)最后走的是三层约束。第一层扫源码,用 AST 解析路由、配对模板状态,产出站点地图(IWalkthroughPlaybook)。第二层看运行态,采集可见 DOM 语义快照、给候选打分,最高分并列就立刻停。第三层兜底,给并列候选打上受白名单约束的视觉编号(Set-of-Mark)交给 VLM 挑,还打不开就挂起,切到本地人工接管。

这篇讲的是这三层怎么接起来。更要紧的是,它在哪些地方必须停手。

靶场是本地 vue-element-admin 4.4.0 实跑,产出 15 个页面状态(13 条表格路由巡航 + 2 个交互状态),归档产物已上传至 vue-admin-navigation-demo,可查看 Vue Element Admin 自动化视觉走查报告 和 capture-manifest.json。

先把 6 个术语对齐

不然后面全是歧义。

统一术语 代码标识 / 属性 定义与生命周期
站点地图 IWalkthroughPlaybook 静态扫描产出的全站路由拓扑与待测组件场景清单
已验证剧本 IVerifiedScenePlaybook 某场景通过运行态校验后落盘的确定性入口路径与指纹
语义引用 (ref) data-xingwu-aria-ref DOM 采集器为单次快照中可见交互元素分配的临时正整数编号
视觉编号 (markId) data-xingwu-scene-som-id 视觉兜底阶段在页面白名单元素左上角绘制的 #1、#2 编号
动作租约 (actionToken) ISceneSomMark.actionToken 绑定 markId、元素文案(accessibleLabel)、几何矩形(rect)与页面版本(pageVersion)的一次性 UUID 执行凭证
页面版本 (pageVersion) __xingwuSceneSomVersion 由 MutationObserver 维护的 DOM 变更单调递增计数器

一次点击背后其实是四个问题:去哪个路由(看站点地图)、点哪个元素(看当前 DOM 的 ref)、点完有没有生效(看状态差分和断言)、哪些动作禁止触发(本地拦截高危词)。

这四个问题分给四层去管。架构上叫"三层约束":AST 站点地图、DOM 语义发现、兜底机制。兜底这一层在运行态又按自动化程度拆成 VLM 视觉选择 和 人工接管 两段,正好是下表四行。

层次划分 输入信息 产出结果 安全边界
AST 站点地图层 路由配置、Vue/React 组件源码 授信路由列表、入口动作链、目标弹层验收条件 不假定源码里的组件在运行态一定可见
DOM 语义发现层 当前可见 DOM、AST 步骤文本 带短生命周期 ref 的唯一安全候选 候选最高分出现并列时立即熔断,拒绝盲猜
VLM 视觉选择层 注入 markId 的截图、候选白名单、行级约束 一个已存在的 markId 编号或 uncertain 不输出自由像素坐标,不跨出本地白名单
人工接管层 阻塞原因、前置条件、目标窗口要求 人工复核决策与 evidenceStatus 证据状态 人工完成后仍需通过容器与标题断言才允许归档

第 1 步 扫源码,先把地图画出来

1.1 路由用 AST,模板用受限正则

Agent 出发前手里得先有张地图。packages/vision-core/src/ast/VueElementAdminTopology.ts 干的就是这件事:扫路由配置和 .vue 单文件组件(SFC),产出 IWalkthroughPlaybook。

这里的分工值得单独说一句。路由表和组件的 import 依赖图本身是标准 JS/TS,交给 ts-morph 解析 AST 最稳。但 Vue 2 SFC 的 <template> 不是合法 JS AST,硬塞进 AST 解析器不划算,我们退回受限正则,提取标签属性,再和 :visible.sync 做静态状态配对。轻,规则也完全透明。

路由扫描从 src/router/index.js 和 src/router/modules/*.js 开始,解析 constantRoutes 与 asyncRoutes 数组,递归拼接父子 path,再从 component: () => import('@/views/...') 提取视图文件路径。拿到视图文件后,discoverVueScenesInFile 沿着 import 语句递归扫同目录及子组件里的 .vue 文件。当前适配器只覆盖 Element UI 的 <el-form>、<el-dialog>、<el-drawer>、<el-table> 四类容器,接 Ant Design 或 Arco Design 得自己扩 extractor。

光知道页面里有个 <el-dialog> 还不够,得知道点哪颗按钮能唤醒它。src/views/components-demo/drag-dialog.vue 的模板长这样。

vue 复制代码
<el-button type="primary" @click="dialogTableVisible = true">
  open a Drag Dialog
</el-button>
<el-dialog
  v-el-drag-dialog
  :visible.sync="dialogTableVisible"
  title="Shipping address"
>
  <el-select placeholder="请选择" />
  <el-table :data="gridData" />
</el-dialog>

扫描器先从 <el-dialog> 里取出 :visible.sync="dialogTableVisible" 和 title="Shipping address",再回同文件的 <el-button> 上找 @click 表达式,看谁把 dialogTableVisible 赋成了 true。状态名配上之后,按钮文案 open a Drag Dialog 写进 entryPath(入口动作链),弹窗标题 Shipping address 写进 expectedOverlay(验收条件)。

ts 复制代码
// packages/vision-core/src/ast/VueElementAdminTopology.ts(节选)
function extractTemplateActions(template: string): ReadonlyArray<ITemplateAction> {
  const actions: ITemplateAction[] = [];
  // 只处理 Element UI 按钮,避免外层 div 的嵌套闭合标签吞掉内部交互节点。
  // 已知不覆盖:自闭合 <el-button />、属性值内含 '>' 字符、多层 <el-button-group> 复杂嵌套。
  const elementPattern = /<el-button\b([^>]*)>([\s\S]*?)<\/el-button>/gi;
  for (const match of template.matchAll(elementPattern)) {
    const attributes = match[1] ?? '';
    const innerContent = match[2] ?? '';
    const clickExpression = /@click(?:\.\w+)*\s*=\s*["']([^"']+)["']/i.exec(attributes)?.[1] ?? '';
    const visibleAssignment = /([\w$]+)\s*=\s*true\b/.exec(clickExpression);
    const action = innerContent.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
    if (!visibleAssignment?.[1] || !action) {
      continue;
    }
    actions.push({ action, statePropName: visibleAssignment[1] });
  }
  return uniqueBy(actions, (item) => `${item.statePropName}:${item.action}`);
}

1.2 入口藏在函数里(React 分支,只到设计)

另一套 React + TSX 适配器(packages/vision-core/src/ast/SceneExtractors.ts,注:该分支为跨框架设计实现,未包含在本次 vue-element-admin 实跑产物中)碰到的麻烦更具体:弹窗常通过 DialogPlugin 或状态更新函数打开,而函数绑在表格行末尾 Dropdown 的 options.push({ content: '重置密码', onClick: handleReset }) 上。

extractDialogPluginSceneMetadata 和 extractStateControlledEntryPath 的做法是从弹窗调用节点往上查出外层处理函数名,再去 ts-morph AST 调用图里定位绑定该函数的菜单项,自动拼出"先点表格行操作区的 更多、再点展开菜单里的 重置密码"这两步 entryPath,顺手把包裹菜单项的 if 语句提取成运行态前置条件(preconditions)。

这条分支没实跑过,只到设计。

第 2 步 运行态:快照、打分、并列就叫停

2.1 快照怎么采,什么时候停

有了地图,执行器也不会拿着文案去全页盲点。它先调 AriaRefNavigator(packages/vision-core/src/browser/AriaRefNavigator.ts),把当前页面压成一份语义快照。

采集器在浏览器上下文里遍历节点,只认几类:按钮、链接、带交互角色(button、menuitem、listitem)的控件,以及手型光标节点。display: none、visibility: hidden、宽或高小于 8 像素、中心点被 document.elementFromPoint 检测挡住的,一律在采集阶段滤掉。有一类容易误判:元素在可滚动容器里,只因为还没滚进视口所以拿不到顶层命中点。这种结合 isInScrollableContainer 标成 scrollable,不算遮挡。

留下来的每个元素分一个单次有效的 ref,写进 data-xingwu-aria-ref。同时往上回溯,包含自身最多 5 层,记一份语义作用域 scopePath;再往上最多 10 层,读业务挂的 data-risk 风险属性。

为什么是 5 层:Element UI / TDesign 的表格行从 button 到 td > tr 或下拉容器,通常在 4 到 6 层之间,5 层刚好分得开弹窗内、表格行内和页面背景。

ts 复制代码
// packages/vision-core/src/browser/AriaRefNavigator.ts(节选:作用域回溯与遮挡检测)
function getScopePath(element: Element): string {
  const scopes: string[] = [];
  for (let cur: Element | null = element, depth = 0; cur && depth < 5; depth++, cur = cur.parentElement) {
    const role = cur.getAttribute('role');
    if (role === 'dialog' || role === 'menu' || role === 'tabpanel' || role === 'row') scopes.unshift(role);
    if (cur.matches('.el-dropdown-menu, .el-select-dropdown, .el-table__body tr, .el-drawer')) {
      scopes.unshift(cur.tagName.toLowerCase());
    }
  }
  return scopes.join(' > ') || 'page';
}

function isOccluded(element: Element): boolean {
  const rect = element.getBoundingClientRect();
  if (rect.width === 0 || rect.height === 0) return true;
  const topEl = document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2);
  return Boolean(topEl && !element.contains(topEl) && !topEl.contains(element));
}

接着 DomEntryDiscovery.ts 给候选打分。规则很土,但每条都能解释:文案去空白后和 AST 目标步骤完全相等得 100 分,只是包含得 50 分;带 hasPopup 可展开属性加 10 分(优先试能展开子菜单的触发器);带 isSelected 已选中状态加 5 分,用来处理 Tab 栏或单选组里要二次点击已激活项的场景。

打完分交 resolveUniqueDomEntry 判断有没有唯一最高分。默认 minScore = 50,用意是让"文案带图标字符或括号后缀"这种唯一包含匹配也能过。但如果页面上有两颗按钮同时拿 50,并列熔断照样生效。

ts 复制代码
// packages/vision-core/src/browser/DomEntryDiscovery.ts(节选:排序与最低分阈值)
export function resolveUniqueDomEntry(
  candidates: ReadonlyArray<IDomEntryCandidate>,
  minScore: number = 50,
): IDomEntryCandidate | null {
  const sorted = [...candidates].sort((left, right) => right.score - left.score || left.ref - right.ref);
  const first = sorted[0];
  const second = sorted[1];
  if (!first || first.score < minScore || (second && second.score === first.score)) {
    return null;
  }
  return first;
}

/components/drag-dialog 页面上,文案为 open a Drag Dialog 的按钮全页只有 1 颗,得分 100,没有并列第二名,resolveUniqueDomEntry 直接返回该 ref。随后 ModalWakeupStrategy.ts 点它,等可见弹层出现,核对弹层文本包含 Shipping address。一步走完,唤醒和校验一起做了。

/table/inline-edit-table 就不一样了。10 行数据,每行一颗 Edit,文案都精确匹配,都不带下拉菜单,都未选中。10 个候选整整齐齐全部拿到 100 分。second.score === first.score 命中,resolveUniqueDomEntry 立刻返回 null。

接下来去哪,规则是定死的:当前场景已配置 VLM 规划器且候选白名单非空,执行流转入第 3 步做 SoM 视觉消歧;没配置 VLM 但开了 HumanAssistConsole,转第 4 步人工接管;两者都没启用,直接抛 DOM 入口发现无法唯一定位:Edit 熔断停步。

绝不偷偷点第一行充数。

2.2 快照 Diff:Portal 把弹层挂到哪去了

入口藏在表格行操作列的 更多 菜单、或者折叠侧栏里的时候,点开触发器会出问题:React Portal 或 Vue Teleport 常把浮层直接挂到 document.body 底部的 .el-dropdown-menu 容器,DOM 树上和刚才点的那行彻底脱节。

新弹出的菜单项得能被一眼认出来。navigator-tool.ts 在 formatStructuralChanges 里引了 diff 库的 diffLines(基于 Myers 差分算法,时间复杂度 O(ND)),对比动作前后两份 YAML 语义树,生成结构变化块。

diff 复制代码
## 操作后的结构变化
元素变化: 1 移除, 4 新增
-   - button "更多" [collapsed] [hasPopup] [ref=42]
+   - button "更多" [expanded] [hasPopup] [ref=42]
+ - list:
+   - listitem "修改安全组" [cursor=pointer] [ref=88]
+   - listitem "重置密码" [cursor=pointer] [ref=89]
+   - listitem "销毁/退还" [disabled] [ref=90]

不管 Portal 把菜单挂到 body 的哪个角落,前后快照里唯一新增(+)的就是刚弹出来的菜单项。

顺带做了两件省 Token 的事。观测输出拆成 result 和 snapshot:result 装动作状态、当前 URL、验证码告警,不可压缩,滑窗摘要时始终保留;snapshot 装完整 YAML 树和 Diff,历史轮次可以按需丢掉。另外 detectCaptchaPresence 一旦发现页面出现验证码遮挡,自动把 snapshot 置空,并在 result 里注入人工处理提示。几百行背景 DOM 噪音淹没验证码告警这种事,我们不想再遇到。

2.3 事件链和等待:别让组件库卡住

现代前端组件库对事件序列很挑剔。折叠侧边栏(/components/drag-dialog 的前置导航)或悬浮气泡,有的靠 mouseenter 展开,有的必须等上一个悬停节点收到 mouseleave 才会关掉旧浮层。不等,就卡死:旧气泡不消失,新菜单弹不出来。

action-executor.ts 没有直接调原生 element.click(),而是合成完整的物理交互序列,配齐页面就绪等待。

  • 自适应滚动与帧稳定检测 (waitForElementMeaningfullyVisible):操作前先看已绑定 ref 的目标 DOM 节点,中心点(或至少 40% 面积)在不在视口里。不在,距离小于 900px 走 smooth 平滑滚动,大于等于 900px 走 auto 瞬时滚动。然后按 16ms 间隔轮询,最多等 700ms 滚动平息,待节点中心点连续 2 帧坐标漂移小于等于 1.5px 后,直接在该 DOM 节点引用上派发合成事件。700ms 超时还没稳,就重新采集快照、用新 ref 重试一次,还不稳就熔断报错。不盲目派发。
  • 补齐上一节点的离开事件链 (triggerHoverSequence):模块内部记着上一次悬停的元素 lastHoveredElement。移向新目标前,先对旧节点依次派发 pointerout、pointerleave、mouseout、mouseleave,再对新目标派发 pointerover、pointerenter、mouseover、mouseenter 和 mousemove。
  • 双阶段页面就绪等待 (page-ready.ts):动作发出后不搞盲目 sleep(2000),按动作类型分配等待预算(click / input / select / goto 为 8000ms,snapshot / screenshot / scroll 为 4000ms,hover 为 1500ms)。第一阶段每 500ms 查一次 .el-loading-mask、.t-loading、.ant-spin-spinning 这些加载类名,再用 document.createTreeWalker 扫字符长度 1 到 50 之间的可见文本节点,看有没有命中 /加载中|请稍候|处理中/。Loading 消失后进第二阶段,每 350ms 把视口内可见交互元素的 tagName|text|disabled 和弹窗尺寸拼成结构签名 D[dialogs]|E[elements](忽略局部滚动容器内的瞬时 top 偏移),连续两次采样一致就提前结束等待。

第 3 步 VLM 挑编号,租约兜底

3.1 画框,然后发一张租约

回到第 2 步那个悬念:10 颗 Edit 同拿 100 分、resolveUniqueDomEntry 返回 null 之后,接力的是 SceneSomNavigator.ts 和 VisualPlanner.ts,做的是带动作租约(Action Lease)的 Set-of-Mark(SoM)视觉选择。

SceneSomNavigator.injectMarks 收当前场景允许点击的白名单文案,这里只有 ['Edit']。它只给页面上可见、未禁用、文案匹配白名单的元素挂 data-xingwu-scene-som-id,并在元素左上角渲染红底白字的 #1、#2 角标。角标容器设了 pointer-events: none,不挡点击。

每画一个角标,Node 进程生成一个 UUID 作为 actionToken,存进 actionLeases 映射表。

这里有个小别扭:归档截图和传给 VLM 的画面要干净,不能有角标之外的遮挡;但平时有头浏览器里跑,旁观的操作人又得看清实时进度。captureMarkedScreenshot 的处理是,截图前瞬间把页面右上角的蓝色自动化提示横幅设为 display: none,快门结束立刻恢复。

上图就是实跑时传给 VLM 的编号截图,10 颗 Edit 按钮依次标上了 #1 到 #10。

3.2 四道关卡,和一次真实的行内编辑

接着 VisualPlanner 发起多模态规划。多模型降级链按主备顺序依次调用,常规单步推理耗时 3~8s;为兼容大图上传和 Thinking 模式冷启动,单次进程硬超时设 120s,超时、网络异常或返回 JSON 不符合 Schema 就切下一个备选模型。

提示词要求模型在 ID 列严格等于 6 的那一行找到右侧 Edit 对应的 markId,明确排除其余 9 行,并且只允许输出这个结构。

json 复制代码
{
  "decision": "continue",
  "actions": [{ "markId": 6, "operation": "click" }],
  "expectedOverlay": { "type": "Table", "title": "行内编辑表格", "requiredTexts": [] },
  "confidence": 0.95
}

模型返回的 JSON 得连过四道本地关卡,才轮得到浏览器点击。前三道防的是模型幻觉,第四道防的是页面自己在变。

  1. Zod Schema 结构清洗 (rawPlannerResponseSchema):校验顶层 decision 枚举(取值为 continue / opened / wrong_overlay / blocked / needs_fixture / unsafe / uncertain),以及 actions 数组(仅当 decision === "continue" 时保留,长度上限为 1)内元素的 markId 正整数与 operation: "click" 字面量。多出的自由字段全部丢弃,模型没法夹带像素坐标或自定义选择器。
  2. 场景预期防篡改 (normalizePlannerResponse):故意要求模型回传 expectedOverlay,再和本地 AST 上下文比对。模型若返回 decision: "opened" 宣称目标弹层已打开,但回传标题与本地预期不符,直接降级为 uncertain。比对完,强制用本地 AST 的 expectedOverlay 覆盖输出,防止模型靠幻觉把验收标准调低。
  3. 动作租约核销与受约束重绑定 (executeActionToken):拿模型选出的 markId 去本地映射表换 actionToken。模型推理的那几秒里页面若发生重渲染,原节点可能已经脱落。这时 resolveActionLease 会结合调用方上下文传入的白名单与行级策略(IVisualPlannerSceneContext)重新扫描,必须在文案一致、且满足场景行级约束的前提下,再找中心点几何距离最近(getRectDistance)的唯一元素来完成重绑定。距离并列或行约束失配,立即放弃点击,报 页面状态已变化,无法唯一重绑定动作租约。
  4. 点击前二次验真 (clickCurrentMark):调用 locator.click() 前的最后一刻,再进 DOM 读一次该节点的实时文案、禁用状态和高风险词库。完整拦截词表在文末附录。
ts 复制代码
// packages/vision-core/src/browser/SceneSomNavigator.ts(节选:动作租约核销与重绑定)
public async executeActionToken(
  page: Page,
  actionToken: string,
  allowedActions: ReadonlyArray<string>,
  sceneContext?: IVisualPlannerSceneContext,
): Promise<IResult<ISceneSomMark>> {
  const lease = this.actionLeases.get(actionToken);
  if (!lease) return { data: null, error: '动作租约不存在或已过期' };

  // 优先按当前 markId 点击(内部二次校验 visible / disabled / 白名单 / 高危词)
  const direct = await this.clickCurrentMark(page, lease.markId, allowedActions);
  if (direct.data || direct.error !== ESceneSomErrorCode.MARK_STALE) return direct;

  // 页面重绘导致标记脱落时,结合 sceneContext 行级作用域约束 + 几何最近距离唯一重绑定
  const rebound = await this.resolveActionLease(page, lease, allowedActions, sceneContext);
  if (!rebound) return { data: null, error: '页面状态已变化,无法唯一重绑定动作租约' };
  return this.clickCurrentMark(page, rebound.markId, allowedActions);
}

行内编辑那次实跑,脚本先通过 .el-table__body-wrapper tbody tr 首列单元格文本等于 '6' 锁定目标行索引(rowIndex),再由 VLM 选出 #6 标记并点击打开编辑态。然后脚本在事先锁定的 rows.nth(rowIndex) 行内等 <input> 出现。这一步也是校验:VLM 若点错行,第 6 行不会出现输入框,5 秒后断言直接失败。填入 VLM edited title,读回 inputValue() 验真,留下第二张归档截图。

第 4 步 什么时候认输,认输了怎么存

4.1 接管的时序

再往下就是自动化迈不过去的坎。弹窗依赖特定状态的测试夹具、入口前有图形验证码、或者在弹窗唤醒流程里走完最多 6 步 SoM 视觉尝试(tryWakeupWithVisualSom 的最大探索步数上限)目标弹层还是没出现。6 步这个数的来由很朴素:后台打开一个弹窗,极少超过"展开菜单 → 切 Tab → 点二级项 → 点按钮"4 到 6 次点击,超过 6 步通常意味着前置条件缺失,不是再试一次就能过的。

再硬着头皮重试,只是烧 Token。

我们在 HumanAssistConsole.ts 和 PlaywrightEngine.ts 里做了本地人工接管,完整时序是这样。

text 复制代码
自动唤醒失败 (DOM 同分熔断 / 6 步 SoM 未打开 / 命中高危拦截)
  │
  ├─► 1. 启动本地 127.0.0.1 接管服务 (HumanAssistConsole.start)
  ├─► 2. 翻译业务指引,注入被测页右上角蓝色横幅 (updateAutomationIndicator)
  ├─► 3. 操作人点击「开始接管」,在受控浏览器内手动点开目标窗口
  ├─► 4. 操作人点击「结束接管并校验」(submitDecision('continue'))
  └─► 5. 执行 validateOpenedOverlay 二次验真 (核对 URL hash + 容器类名 + title)
        ├─► 通过:标记 CAPTURED_HUMAN_ASSISTED,计算 SHA-256 存盘已验证剧本
        └─► 失败:标记 PENDING_EVIDENCE,拒绝写入伪造的成功截图

只要浏览器以有头模式启动,PlaywrightEngine 就会在页面右上角注入 #xingwu-vision-automation-indicator 蓝色状态横幅,视口四周还画一圈内嵌提示框,脉冲动画在蓝绿两色间切,提醒旁观者:这个浏览器正被程序控制,随时可以接管。弹层自动唤醒失败时,capture-route.ts 挂起当前任务,等操作人决策。

ts 复制代码
// skills/xingwu-scene-capturer/scripts/capture-route.ts(节选)
// 前置上下文:wakeupRes 为自动唤醒结果,decisionSource 记录决策来源(ARIA_REF / SOM / HUMAN_OPERATOR)
if (!wakeupRes.data && options.humanAssistConsole) {
  const task = buildHumanAssistTask(activeOverlay);
  const humanAssistConsoleUrl = await options.humanAssistConsole.start();
  await engine.updateAutomationIndicator(
    page,
    buildHumanAssistIndicatorMessage(task),
    humanAssistConsoleUrl,
  );
  const operatorRecord = await options.humanAssistConsole.waitForOperator(task);
  humanAssistRecords.push(operatorRecord);
  if (operatorRecord.decision === EHumanAssistDecision.CONTINUE) {
    await engine.updateAutomationIndicator(page, '人工接管已结束,正在重新校验目标状态');
    const validation = await validateOpenedOverlay(page, activeOverlay);
    if (validation.data) {
      wakeupRes = validation;
      decisionSource = ESceneDecisionSource.HUMAN_OPERATOR;
      evidenceStatus = ESceneEvidenceStatus.CAPTURED_HUMAN_ASSISTED;
    } else {
      wakeupRes = validation;
      evidenceStatus = ESceneEvidenceStatus.PENDING_EVIDENCE;
    }
  } else {
    // SKIP / TERMINATE 分支:强制校验 operatorRecord.reason 非空,并归档为 SKIPPED_BY_OPERATOR
  }
}

4.2 三件真正费劲的事

链路本身不复杂,花时间的是三个细节。

第一个,给操作人看的提示不能是源码变量名。Vue 场景里,VueElementAdminTopology.ts 会把 drag-dialog.vue 的 dialogTableVisible 翻成"请在当前页面找到并打开'Shipping address'窗口"。React 场景里,SceneExtractors.ts 的 extractOperatorGuidance 把下拉菜单链路翻成"请依次点击'更多' → '重置密码',直到看到'重置密码'窗口",源码里那个 if 分支条件则列进"前置条件"栏。人是来救场的,不是来读代码的。

第二个,人工点完"结束接管并校验"不等于通过。validateOpenedOverlay 要查四件事:当前 URL hash 是否还在目标路由、有没有可见弹层、容器类名匹不匹配 Dialog 或 Drawer、弹层全文含不含 expectedOverlay.title。四项全过才记 CAPTURED_HUMAN_ASSISTED。人工也可能点错,这条线不能松。

第三个,验证通过的场景要落盘成指纹。VerifiedScenePlaybook.ts 序列化后算 SHA-256,写进已验证剧本(IVerifiedScenePlaybook)。当前实现把 sceneId、routePath、componentType、sourceFile、sourceLine、entryPath 和 expectedOverlay 一起纳入哈希。

把 sourceLine 放进去是故意的,取的是宁可多探、不可误用的保守策略:源文件一有增删改,行号偏移,指纹立即失效,重新触发 AST 与 DOM 校验。代价是缓存命中率会低。若团队更在意跨小改动的命中率,可以把 sourceLine 从入参里摘掉,只留结构语义字段。这笔账我们算过,选了贵的那边。

ts 复制代码
// packages/vision-core/src/ast/VerifiedScenePlaybook.ts(节选:场景剧本 SHA-256 指纹)
export function buildSceneFingerprint(scene: IWalkthroughSceneItem): string {
  const normalizedOverlay = scene.expectedOverlay
    ? {
        title: scene.expectedOverlay.title,
        requiredTexts: [...scene.expectedOverlay.requiredTexts].sort(),
      }
    : null;
  const payload = JSON.stringify({
    sceneId: scene.sceneId,
    routePath: scene.routePath,
    componentType: scene.componentType,
    sourceFile: scene.sourceFile,
    sourceLine: scene.sourceLine, // 保守策略:行号偏移即触发重探;追求高命中率可移除此项
    entryPath: scene.entryPath ?? [],
    expectedOverlay: normalizedOverlay,
  });
  return createHash('sha256').update(payload).digest('hex');
}

第 5 步 四类测试怎么串起来

前三步解决"怎么安全到达目标状态",第 4 步解决"卡住时怎么兜底"。一次流水线按 「自动登录 → 全站路由冒烟巡航 → 跨层级功能交互 → 表格多态回归注入 → 双轨视觉走查」 的顺序,共用同一个浏览器会话和同一张站点地图,依次跑完四类测试。

5.1 冒烟:全站巡航与容器可达性

发版前跑一遍 buildVueElementAdminPlaybook。Agent 自动登录,会话写进 .auth/vue-element-admin-session.json,然后按地图逐个访问路由,检测主表格(.el-table / .t-table)或主表单能不能在 4 秒超时内正常渲染;同时 installSecurityNavigationGuard 拦截一切非授信域名的跳转。

Apple M 系列芯片(macOS 15、Node.js 20、有头模式 slowMo: 500ms 加页间 800ms 观察停顿)上,登录完成后从第 1 条路由(03:09:46.653Z)到第 13 条路由(03:10:04.554Z),冒烟巡检实测 17.9 秒。含自动登录全程约 22 秒。

5.2 功能:浮层穿透与状态差分断言

跨层级交互靠两样东西推进:每一步动作前后的语义快照差分(diffSemanticSnapshots),和结果断言。

以从折叠侧栏进 /components/drag-dialog 并打开弹窗为例。脚本先对侧栏 Components 子菜单做 hover。折叠态下 Element UI 会把二级菜单挂到 body 底部,所以这里改在整页可见范围里用 Playwright 选择器 .el-menu-item:visible 定位 Drag Dialog(注::visible 是 Playwright 引擎提供的可见性扩展伪类,不是原生 CSS 伪类)。hover 没展开就自动切 click 互备。路由就绪后按地图的 entryPath 点 open a Drag Dialog,核对 Shipping address 弹窗标题和内部表格。

每次点击都会比对 6 个快照字段(pageUrl、overlayTitles、menuItems、selectedCount、safeRefs、pageVersion),连同完整的 Playwright Trace 归档包一起写进 traces/navigation-demo.zip。

5.3 回归:无损网络拦截与表格五态注入

测试环境的数据往往只有正常态。空数据、超长文本把表格撑破这类回归缺陷,靠真实数据很难撞上。TableStateMocker.ts 的 applyTableSceneState 用 Playwright 的 page.route 接口,在不碰后端数据库的前提下,给每张表格注入五种回归状态(注:page.route 只适用于标准 HTTP XHR/Fetch 的 JSON 列表接口;业务若走 WebSocket 推送或单端点 GraphQL 聚合查询,得在业务层配专用 Mock 夹具)。

  • NORMAL(T1 正常态):等网络空闲和 400ms Loading 遮罩消失后抓拍默认数据。
  • HOVER_AND_SELECT(T2 悬停勾选态):对首行触发真实 hover 激活 :hover 伪类,并勾选首行复选框。
  • PAGINATION(T3 分页态):把底部分页器滚进可视区域,检查边距与对齐。
  • EMPTY(T4 空数据态):拦截包含 /api/ 或 /v1/ 的 JSON 响应,把 list、items、records、rows 等数组置空,total 设为 0,重载页面验证空状态占位图。
  • LONG_TEXT_SQUEEZE(T5 超长文本挤压态):把列表首行的 name、title、dept、email 等字段动态替换成一段 71 字符(26 个汉字 + 45 个 ASCII 字符,见下方 EXTREME_LONG_TEXT_VALUE 常量)的超长测试文本,在常规 120px~240px 列宽下检验列宽挤压、省略号截断和操作列按钮换行。
ts 复制代码
// packages/vision-core/src/browser/TableStateMocker.ts(节选:无损接口拦截与多态注入)
const EXTREME_LONG_TEXT_VALUE =
  '这是一段用于视觉回归测试的超长企业组织部门与成员名称_Xingwu_Visual_Walkthrough_Long_Text_Test_001';

async function installTableApiInterceptor(page: Page, mode: 'empty' | 'long_text'): Promise<() => Promise<void>> {
  const handler = async (route: Route): Promise<void> => {
    const req = route.request();
    if ((req.resourceType() !== 'xhr' && req.resourceType() !== 'fetch') || !/\/api\/|\/v1\//.test(req.url())) {
      await route.continue();
      return;
    }
    const response = await route.fetch();
    if (!(response.headers()['content-type'] ?? '').includes('application/json')) {
      await route.fulfill({ response });
      return;
    }
    const body = (await response.json()) as Record<string, unknown>;
    const patched = mode === 'empty' ? buildEmptyListResponse(body) : buildLongTextListResponse(body);
    await route.fulfill({ response, json: patched });
  };
  await page.route('**/*', handler);
  await page.reload({ waitUntil: 'networkidle' });
  return async () => page.unroute('**/*', handler);
}

5.4 视觉走查:CDP 像素校验 + VLM 双轨审计

截图归档前,PlaywrightEngine 统一锁 1440×900 视口、deviceScaleFactor: 2 高清渲染,按需冻结系统时钟(installFixedClock,消掉相对时间戳跳字带来的噪声),并用 Playwright 原生 mask 参数给手机号、邮箱这些文本节点盖纯色遮罩。打码只画在叶节点文本框内部,不改外层容器的盒模型尺寸,也不碰 Flex/Grid 排版。

然后分两条轨道跑。

轨道 A 是零幻觉的确定性规则校验(TokenRuleChecker.ts)。CDPStyleExtractor 通过 CDP 协议拿到目标组件的真实 ComputedStyle 快照,runDeterministicTokenAudit 把字号、行高、内边距、圆角、背景色和设计规范 JSON(table-spec.json / modal-spec.json)逐项比对。

每条规则的容差 tolerancePx 可独立配置,取值范围 0~2px,默认 1px;1px 同时也是抗浏览器亚像素舍入误差的硬下限,配 0 也照样放行 1px。偏差判定在 evaluateRuleMismatch 里分三档:偏差 ≤ max(1, tolerancePx) 直接通过;落在 (max(1, tolerancePx), 4px) 之间记 MODERATE(中度尺寸偏差提示);≥ 4px 记 CRITICAL(严重 Token 档位错误),报出源码行号和建议替换的 Design Token 名称。默认 4px 算一个标准 Token 步进档位,不同规范可以按规则配 tierStepPx。

凡是属性名含 color 子串的规则(覆盖 color、backgroundColor、borderColor 等),自动标 affectsDarkMode: true,提醒研发同步查暗黑模式变量。只看单套主题,这类问题看不出来。

ts 复制代码
// packages/vision-core/src/auditor/TokenRuleChecker.ts(节选:像素偏差三档判定)
function evaluateRuleMismatch(actualVal: string, rule: IBaselineStyleRule): IRuleMismatchResult {
  const actualPx = parseFloat(actualVal);
  const expectedPx = parseFloat(rule.expectedValue);
  if (actualVal.endsWith('px') && rule.expectedValue.endsWith('px') && !Number.isNaN(actualPx) && !Number.isNaN(expectedPx)) {
    const delta = Math.abs(actualPx - expectedPx);
    if (delta <= Math.max(1, rule.tolerancePx)) {
      return { isMismatch: false, computedSeverity: EIssueSeverity.MINOR, category: EIssueCategory.SIZE_MISMATCH, diffText: '' };
    }
    const isCritical = delta >= (rule.tierStepPx ?? 4);
    return {
      isMismatch: true,
      computedSeverity: isCritical ? EIssueSeverity.CRITICAL : EIssueSeverity.MODERATE,
      category: isCritical ? EIssueCategory.TOKEN_TIER_ERROR : EIssueCategory.SIZE_MISMATCH,
      diffText: `实测 ${actualVal} 与设计稿 ${rule.expectedValue} 相差 ${delta}px(应使用 ${rule.expectedToken})`,
    };
  }
  // 颜色与枚举型属性比对分支 ...
}

轨道 B 是多模态视觉排版审计(MultimodalVlmChecker.ts)。纯 CSS 属性看不出来的排版缺陷,比如相邻按钮重叠、文字异常折行、弹窗遮罩没铺满全屏,交给多模态模型看。审计器把组件裁剪图和设计稿参考图一并送进去,配一个基于图片与模型名的内容寻址缓存(computeAuditCacheKey)省重复调用,输出用 JSON Schema 强校验。重试后还是解析不出来,就安全降级成 isUncertainManualReview: true,转人工复核。

第 6 步 实跑一遍,把证据留下

6.1 前置条件与终端日志

本地复现要满足几个条件:Node.js ≥ 20、pnpm ≥ 9,本地克隆并启动 vue-element-admin(监听默认地址 http://127.0.0.1:9527),.env 里配好 VUE_ADMIN_BASE_URL 和 VUE_ADMIN_SOURCE_DIR。然后一条命令。

bash 复制代码
pnpm run demo:vue-admin-navigation

下面是本次实跑的终端输出节选,中间第 2~11 条表格路由的重复巡航日志省掉了。

text 复制代码
[vue-admin-demo] 正在自动登录。
[vue-admin-demo] 打开表格页 1/13:/permission/role
...(中略:依次打开第 2 至 11 条表格路由)
[vue-admin-demo] 打开表格页 12/13:/table/inline-edit-table
[vue-admin-demo] 打开表格页 13/13:/table/complex-table
[vue-admin-demo] VLM 已选择 ID=6 行的 Edit,并完成 Title 输入校验。
[vue-admin-demo] 通过侧栏进入 Components / Drag Dialog。
[vue-admin-demo] AST 入口路径:open a Drag Dialog。
[vue-admin-demo] 已验证"Shipping address"弹窗,并保存截图。

vue-element-admin 本地靶场自带完整 Mock 数据,本次又选了无副作用场景,所以 15 个状态全走了自动路径(evidenceStatus: "captured_auto"),实跑中并没有触发熔断或人工接管。

这话得说清楚,因为它意味着第 4 步那一整章没有本次实跑的样本。我们没打算补一个。

另外两个分支:没配视觉模型环境变量时跑演示脚本,runVlmInlineEditDemo 会按预期跳过,输出 未配置视觉规划模型,跳过 ID=6 行内编辑演示;而在完整走查入口 capture-route.ts 里撞上同名入口无法唯一定位,状态会记 PENDING_EVIDENCE,挂起等 HumanAssistConsole 接管。

6.2 归档清单

跑完,audit-archives/vue-admin-navigation-demo 下有三份核心产物。

面向设计师和测试的 可视化走查报告 index.html,含 13 条表格路由巡航截图和 2 个交互验证截图,共 15 个页面状态。

面向排障回放的 Playwright Trace 归档包 traces/navigation-demo.zip,单次 15 个状态约 4~8 MB,因为带着完整 DOM 快照和网络响应。它已在 .gitignore 里排除。这个别往公开仓提交。

还有面向 CI 的 capture-manifest.json。下面是它末尾两条交互验证记录的合法 JSON 节选,已省略与全屏图相同的 elementCropScreenshotPath 及空数组字段。

json 复制代码
[
  {
    "sceneId": "vue-element-admin.table.inline-edit.id-6",
    "app": "vue-element-admin",
    "moduleName": "table",
    "runMode": "standalone",
    "pageUrl": "http://127.0.0.1:9527/#/table/inline-edit-table",
    "componentType": "Table",
    "variantLabel": "normal",
    "evidenceStatus": "captured_auto",
    "sourceLocation": "src/views/table/inline-edit-table.vue",
    "fullpageScreenshotPath": "./vue-admin-navigation-demo/snapshots/inline-edit-id-6-filled.png",
    "capturedAt": "2026-11-06T03:10:17.124Z",
    "captureNote": "VLM 根据 SoM 截图选择 ID=6 行的 Edit,并完成 Title 输入校验"
  },
  {
    "sceneId": "vue-element-admin.components.components.drag.dialog.Dialog.1",
    "app": "vue-element-admin",
    "moduleName": "components",
    "runMode": "standalone",
    "pageUrl": "http://127.0.0.1:9527/#/components/drag-dialog",
    "componentType": "Dialog",
    "variantLabel": "level1_dom_click",
    "evidenceStatus": "captured_auto",
    "sourceLocation": "src/views/components-demo/drag-dialog.vue:6",
    "fullpageScreenshotPath": "./vue-admin-navigation-demo/snapshots/drag-dialog-opened.png",
    "capturedAt": "2026-11-06T03:10:19.919Z",
    "captureNote": "AST 入口路径已打开并校验 Shipping address 弹窗"
  }
]

边界在哪,怎么接进自己的项目

先说它做不到什么。这三处是设计上的停步点,不是待办。

  1. 缺少稳定业务标识列的同名行。表格既没有唯一 ID 列、也没有能区分行的文本时,画出 SoM 编号也没用,VLM 缺消歧依据。"同分熔断 → VLM"在这里会退化,只能人工指定行索引,或者补测试夹具。
  2. 非标准渲染容器 。纯 Canvas 画布、跨域 iframe、mode: 'closed' 的 Shadow DOM,主文档选择器都拿不到内部节点(注:open 模式的 Shadow Root 虽可通过 JS 访问,但当前默认 querySelectorAll 快照未递归穿透,需按需开 shadow 穿透采集)。
  3. 无文本纯图标与虚拟列表 。既没 aria-label/title 也没内部文本的纯图标按钮,白名单非空时不会被标 markId;开了虚拟滚动的超长表格,没挂载进 DOM 的行不会进入快照。

要接进自己的中后台,最小路径三步。

  1. 实现目标应用适配器(ITargetAppAdapter) 。照着你的路由文件(Vue Router 或 React Router)写路由扫描器,并在 IUiSelectorProfile 里填当前 UI 组件库(Element UI / Ant Design / TDesign)的弹窗、抽屉、表格行和下拉菜单选择器。
  2. 配会话与安全词表 。先用有头模式人工登录一次,通过 Playwright context.storageState 导出 .auth/session.json 给后续复用;然后逐条检查、补充业务特有的高危副作用按钮词表。这一步偷懒会出事。
  3. 跑通单模块,再上 CI 。先用 --modules 过滤跑单个业务模块的表格五态抓拍和弹窗唤醒,确认能生成 capture-manifest.json 和 HTML 走查报告,再挂进流水线。

结语

回头看,这套东西里没有哪一层是聪明的。AST 只回答"去哪个路由",DOM 快照只回答"点哪个元素",VLM 只回答"长得一样的这两颗是哪一颗",剩下的交给停下来问人。

真正费劲的是第四件事:每层都得清楚自己能证明什么,证不出来就交出去。这条我们写进了三处代码里------最高分并列返回 null、租约对不上直接报错、人工点完还得再过一次断言。

也得说清楚它现在站在哪。能拿出的证据,是本地靶场那 15 个状态,全自动,一次接管都没触发。这既是好消息,也是这篇最薄的地方:人工接管那一整条链,这次没有真实样本。等它接进真实业务后台,被验证码、权限弹窗、脏数据一起招呼的时候,这部分大概率还要再改一遍。

我们还在改。


附录:高危拦截词表与扩展浏览器机制

A.1 本地强制拦截的高危与非入口动作词表

执行器在两处硬编码排除了下面两类词:DOM 候选发现(ModalWakeupStrategy.ts)和 SoM 视觉编号注入(SceneSomNavigator.ts)。命中任一词条,即禁止作为打开弹层的入口候选自动点击。

  • 高危业务变更与文件副作用词表(FINAL_MUTATION_ACTIONS / DANGEROUS_ACTION_PATTERNS) :确认删除、确认停用、确认重置、发送、保存、提交、导入、上传、授权、支付、发布、退出登录、Delete、Remove、Save、Submit、Create、Update、Upload、Import、Export(注:批量导出常触发后台异步任务创建或本地文件下载弹窗,故在无人值守巡检中一并纳入拦截)、Publish、Logout。
  • 弹层关闭与二次确认按钮词表(NON_ENTRY_ACTIONS) :取消、关闭、返回、放弃、确认、确定、Cancel、Close、Back、Confirm(此类按钮用于关闭当前窗口或提交二次确认,不属于打开新场景的触发入口)。

A.2 面向复杂后台交互的四个底层扩展机制

正文主线之外,packages/vision-core/src/navigator 在浏览器层还备了四项应对复杂线上控制台的能力。

  1. 非标准菜单识别与置灰项保留 (dom-extractor.ts):提前遍历同源 document.styleSheets,缓存 :hover { cursor: pointer } 选择器(跨域样式表受浏览器 CORS 策略限制,只在允许跨域读取时生效),再用 hasOwnCursorPointer 检查"自身为 pointer 且父节点非 pointer",防止外层容器设了手型光标、导致内部包装 <div> 全部继承。同时 <li class="is-disabled"> 这类置灰菜单项走 earlyDisabled 分支,保留 ref 并标 [disabled],禁止点击但允许 hover 触发 Tooltip 查禁用原因。

  2. 操作指纹循环检测与附图告警 (navigator-tool.ts):detectRepeatCycle 记录非只读动作指纹,检测窗口长度 1..4 的单步重复或多步乒乓循环,连续重复 3 次即触发。命中后向模型返回跳出思路警告,并自动调 captureScreenshot() 附上实时画面,帮模型重新决策。

    ts 复制代码
    // packages/vision-core/src/navigator/navigator-tool.ts(节选:滑动窗口循环节检测)
    export function detectRepeatCycle(
      history: string[],
      minRepetitions: number = 3,
      maxCycleLen: number = 4,
    ): { cycleLen: number; repetitions: number } | null {
      for (let cycleLen = 1; cycleLen <= maxCycleLen; cycleLen++) {
        if (history.length < cycleLen * minRepetitions) continue;
        const pattern = history.slice(-cycleLen);
        let repetitions = 1;
        for (let rep = 2; rep <= Math.floor(history.length / cycleLen); rep++) {
          const start = history.length - rep * cycleLen;
          if (pattern.every((fp, j) => history[start + j] === fp)) repetitions++;
          else break;
        }
        if (repetitions >= minRepetitions) return { cycleLen, repetitions };
      }
      return null;
    }
  3. 真人键鼠抢占监测与命令内容风控 (user-interaction-monitor.ts / command-risk-policy.ts):在 document 捕获阶段监听 event.isTrusted === true 且非程序触发的真实用户点击或回车,一旦触发立即挂起 Agent,让出控制权。同时在 Web 终端(xterm)或命令表单输入时拦截 rm -rf、mkfs、dd of=、DROP TABLE、TRUNCATE 等高危指令。

  4. 跨页跳转会话接力 (navigation-handover/orchestrator.ts):点击触发 <a target="_blank">、window.open 或跨域硬跳转导致页面卸载时,NavigationHandoverOrchestrator 按下面这个时序把会话接过去。

    text 复制代码
    点击前拦截 (ClickTransaction)
      ├─► 静态已知 URL:由 queryCarrier 注入 ?__xw_handover=TICKET
      ├─► 动态 window.open('about:blank'):由 cookieCarrier 写入同域短期兜底 Cookie
      └─► 异步路由跳转:启动 30s AsyncPatchSession (每 1.5s 轮询 URL 变化补注票据)
            └─► 新页面加载后读取 TICKET,调用 fork 恢复上下文并继续执行观测
相关推荐
阿里云大数据AI技术22 分钟前
EMR Serverless Ray:让 AI 推理融入数据处理
人工智能·spark
TDengine (老段)31 分钟前
TDengine IDMP 十大实用功能(9)
大数据·数据库·人工智能·物联网·时序数据库·tdengine·idmp
MobotStone34 分钟前
毛选从哪篇开始读?
人工智能
QZ1665609515934 分钟前
政务泛监测体系能力排行:AI 重塑监测边界,治理的颗粒度革命
网络·人工智能·安全·网络安全·政务
量化分析码农41 分钟前
【Python量化多因子实战 04】因子暴露不是 alpha:市值与行业中性化实战
前端·后端
模域ModelSphere1 小时前
长回复吐到一半断了,流式推理还能接着写吗?
人工智能
不可能片场1 小时前
Electron 发布 正文写入的三条通道与读回口径
前端·electron
用户1917291270831 小时前
Agent 运行时还是预览版,企业怎么上生产?准入 4 查与隔离回滚实战
人工智能
海螺_AIHCC1 小时前
开源模型挤着发了一圈,先别比榜单,算算显存
人工智能