给中后台系统写自动化测试、写视觉走查脚本,最容易死在找入口这一步。
拿开源的 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 的模板长这样。
<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(验收条件)。
// 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 层刚好分得开弹窗内、表格行内和页面背景。
// 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,并列熔断照样生效。
// 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 语义树,生成结构变化块。
## 操作后的结构变化
元素变化: 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 行,并且只允许输出这个结构。
{
"decision": "continue",
"actions": [{ "markId": 6, "operation": "click" }],
"expectedOverlay": { "type": "Table", "title": "行内编辑表格", "requiredTexts": [] },
"confidence": 0.95
}
模型返回的 JSON 得连过四道本地关卡,才轮得到浏览器点击。前三道防的是模型幻觉,第四道防的是页面自己在变。
- Zod Schema 结构清洗(
rawPlannerResponseSchema):校验顶层decision枚举(取值为continue/opened/wrong_overlay/blocked/needs_fixture/unsafe/uncertain),以及actions数组(仅当decision === "continue"时保留,长度上限为 1)内元素的markId正整数与operation: "click"字面量。多出的自由字段全部丢弃,模型没法夹带像素坐标或自定义选择器。 - 场景预期防篡改(
normalizePlannerResponse):故意要求模型回传expectedOverlay,再和本地 AST 上下文比对。模型若返回decision: "opened"宣称目标弹层已打开,但回传标题与本地预期不符,直接降级为uncertain。比对完,强制用本地 AST 的expectedOverlay覆盖输出,防止模型靠幻觉把验收标准调低。 - 动作租约核销与受约束重绑定(
executeActionToken):拿模型选出的markId去本地映射表换actionToken。模型推理的那几秒里页面若发生重渲染,原节点可能已经脱落。这时resolveActionLease会结合调用方上下文传入的白名单与行级策略(IVisualPlannerSceneContext)重新扫描,必须在文案一致、且满足场景行级约束的前提下,再找中心点几何距离最近(getRectDistance)的唯一元素来完成重绑定。距离并列或行约束失配,立即放弃点击,报页面状态已变化,无法唯一重绑定动作租约。 - 点击前二次验真(
clickCurrentMark):调用locator.click()前的最后一刻,再进 DOM 读一次该节点的实时文案、禁用状态和高风险词库。完整拦截词表在文末附录。
// 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 里做了本地人工接管,完整时序是这样。
自动唤醒失败 (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 挂起当前任务,等操作人决策。
// 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 从入参里摘掉,只留结构语义字段。这笔账我们算过,选了贵的那边。
// 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 列宽下检验列宽挤压、省略号截断和操作列按钮换行。
// 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,提醒研发同步查暗黑模式变量。只看单套主题,这类问题看不出来。
// 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。然后一条命令。
pnpm run demo:vue-admin-navigation
下面是本次实跑的终端输出节选,中间第 2~11 条表格路由的重复巡航日志省掉了。
[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 及空数组字段。
[
{
"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 弹窗"
}
]
边界在哪,怎么接进自己的项目
先说它做不到什么。这三处是设计上的停步点,不是待办。
- 缺少稳定业务标识列的同名行。表格既没有唯一 ID 列、也没有能区分行的文本时,画出 SoM 编号也没用,VLM 缺消歧依据。“同分熔断 → VLM”在这里会退化,只能人工指定行索引,或者补测试夹具。
- 非标准渲染容器。纯 Canvas 画布、跨域 iframe、
mode: 'closed'的 Shadow DOM,主文档选择器都拿不到内部节点(注:open模式的 Shadow Root 虽可通过 JS 访问,但当前默认querySelectorAll快照未递归穿透,需按需开 shadow 穿透采集)。 - 无文本纯图标与虚拟列表。既没
aria-label/title也没内部文本的纯图标按钮,白名单非空时不会被标markId;开了虚拟滚动的超长表格,没挂载进 DOM 的行不会进入快照。
要接进自己的中后台,最小路径三步。
- 实现目标应用适配器(
ITargetAppAdapter)。照着你的路由文件(Vue Router 或 React Router)写路由扫描器,并在IUiSelectorProfile里填当前 UI 组件库(Element UI / Ant Design / TDesign)的弹窗、抽屉、表格行和下拉菜单选择器。 - 配会话与安全词表。先用有头模式人工登录一次,通过 Playwright
context.storageState导出.auth/session.json给后续复用;然后逐条检查、补充业务特有的高危副作用按钮词表。这一步偷懒会出事。 - 跑通单模块,再上 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 在浏览器层还备了四项应对复杂线上控制台的能力。
- 非标准菜单识别与置灰项保留(
dom-extractor.ts):提前遍历同源document.styleSheets,缓存:hover { cursor: pointer }选择器(跨域样式表受浏览器 CORS 策略限制,只在允许跨域读取时生效),再用hasOwnCursorPointer检查“自身为 pointer 且父节点非 pointer”,防止外层容器设了手型光标、导致内部包装<div>全部继承。同时<li class="is-disabled">这类置灰菜单项走earlyDisabled分支,保留ref并标[disabled],禁止点击但允许hover触发 Tooltip 查禁用原因。 - 操作指纹循环检测与附图告警(
navigator-tool.ts):detectRepeatCycle记录非只读动作指纹,检测窗口长度1..4的单步重复或多步乒乓循环,连续重复 3 次即触发。命中后向模型返回跳出思路警告,并自动调captureScreenshot()附上实时画面,帮模型重新决策。// 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; } - 真人键鼠抢占监测与命令内容风控(
user-interaction-monitor.ts/command-risk-policy.ts):在document捕获阶段监听event.isTrusted === true且非程序触发的真实用户点击或回车,一旦触发立即挂起 Agent,让出控制权。同时在 Web 终端(xterm)或命令表单输入时拦截rm -rf、mkfs、dd of=、DROP TABLE、TRUNCATE等高危指令。 - 跨页跳转会话接力(
navigation-handover/orchestrator.ts):点击触发<a target="_blank">、window.open或跨域硬跳转导致页面卸载时,NavigationHandoverOrchestrator按下面这个时序把会话接过去。点击前拦截 (ClickTransaction) ├─► 静态已知 URL:由 queryCarrier 注入 ?__xw_handover=TICKET ├─► 动态 window.open('about:blank'):由 cookieCarrier 写入同域短期兜底 Cookie └─► 异步路由跳转:启动 30s AsyncPatchSession (每 1.5s 轮询 URL 变化补注票据) └─► 新页面加载后读取 TICKET,调用 fork 恢复上下文并继续执行观测