Featured image of post 定位器坏了但测试没红:运行时自愈的工程实现与三道审计闸门

定位器坏了但测试没红:运行时自愈的工程实现与三道审计闸门

前端重构改了一个类名、挪了一个按钮,应用照常工作,一半选择器却不再匹配。传统做法是让人逐个改定位器,改完再跑一遍。运行时自愈提供了另一条路:…

前端重构改了一个类名、挪了一个按钮,应用照常工作,一半选择器却不再匹配。传统做法是让人逐个改定位器,改完再跑一遍。运行时自愈提供了另一条路:选择器失效时,让模型从页面实际存在的元素里判断你指的是哪一个,验证通过后继续执行,并把修复缓存下来,下次运行不再调用模型。

这个思路听起来像"让测试不那么脆",但真正的工程难点不在"修好",而在修错了也会绿。如果结算按钮被删掉、模型却自信地点了另一个按钮,套件全绿,没人发现产品出了问题。因此值得拆解的不是模型提示词,而是整套验证、审计与成本控制的机制——它们决定了自愈是提效手段,还是一个新的静默失败源。

一次自愈走完的六步

以 Playwright 生态里一套可运行的实现为例,一次自愈的流程是固定的、可观测的:

  1. 原定位器先跑。 给它一个很短的超时(默认 1 秒),元素正常出现就直接执行动作,自愈逻辑完全不介入——匹配正常时,套件行为与未接入前一致。
  2. 查缓存。 之前在同一页面路径、同一动作、同一描述下修好过的定位器,先拿来试。命中就不调用模型。
  3. 收窄候选。 从页面收集最多 1,000 个可交互元素,按关键词匹配、元素类型、ARIA role 与 test id 打分排序,只把前 40 个送给模型。这一步是为了防止目标元素因 DOM 顺序靠后被截断。
  4. 模型给出候选策略。 最多返回三条 Playwright 定位策略——test id、role 与 name、label、placeholder、text、alt text、title 或 CSS,每条带置信度;输出必须符合固定 JSON schema,不是自由文本。
  5. 逐条验证。 一条建议要被采纳,必须同时满足:只匹配到唯一一个元素、该元素可见、置信度不低于 minConfidence。
  6. 执行并落盘。 生效的定位器写入缓存文件,本次自愈以一行 JSONL 记入事件日志,包含模型的理由、置信度与 token 消耗。

“每个元素只调用一次模型,而不是每次运行调用一次"是成本控制的核心。缓存按动作、页面路径与描述组织,提交进仓库后团队与 CI 共享同一份修复。

从短超时判定、缓存命中、候选收窄到唯一性校验与落盘的完整链路

验证比生成重要,但验证本身不够

第 4、5 步是这套设计里最有价值的部分:模型不是凭空编一个选择器,而是从页面上真实存在的候选元素里选,答案必须能落回一个具体的、可见的、唯一的元素。相比之下,“让模型直接写一个新 CSS 选择器"几乎不可控。

但唯一性与可见性都只保证"这个元素存在且唯一”,不保证"它是你要的那个”。原文把风险说得很直白:删除结算按钮后,模型如果自信地挑了另一个按钮,测试依然通过,缺陷被静默吞掉。对质量保障来说,一个不会误报的自愈机制,比一个会偶尔失败的更危险。

因此需要三道闸门,把"自愈发生过"这件事强制变成可见信号。

闸门一:置信度阈值

minConfidence 的默认值为 0,也就是接受任何分数的建议。生产环境里应显式设到 0.8 一类水平:模型不够确定时,宁可让动作失败,也不要猜。这个开关把"不确定"重新变成红灯。

闸门二:只报告不修复的模式

warn 模式会记录"原本会怎么修",但保留原始失败;缓存里的修复也只报告、不应用。后一点尤其关键:否则一份被提交进仓库的缓存文件,就能把审计运行悄悄刷成全绿,团队再也看不到哪些选择器其实早该更新。

闸门三:让构建为自愈负责

提供运行级汇总接口,统计本次运行发生了多少次自愈,在全局 teardown 里列出待修的选择器,并让构建失败:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// global-teardown.ts
export default function () {
  const { healed, entries } = getHealSummary();
  if (healed === 0) return;
  console.error(`${healed} 个定位器需要修复,请更新以下选择器:`);
  for (const e of entries.filter((e) => e.outcome === 'healed')) {
    console.error(`  ${e.action} "${e.contextName}" → ${JSON.stringify(e.strategy)}`);
  }
  process.exitCode = 1;
}

这里有个进程模型上的坑:测试跑在 worker 进程,teardown 跑在 runner 进程,内存里的数据不共享,所以每次结果都要额外写进一个 JSONL 汇总文件,由汇总接口从文件读取;并且只统计当前运行——如果 runner 在两次运行之间不退出(UI 模式、watch 模式),要在 globalSetup 里显式重置计数。

三道闸门合起来的语义很清楚:自愈是让构建不中断的临时手段,不是让选择器免于维护的许可。

成本、速度与隐私怎么控住

每次自愈都要调用一次模型,如果放任不管,成本和延迟会随失败选择器数量线性上升。可复用的优化有四条:

  • 候选属性压缩:只发送有值的属性,并用短键(如 tid 表示 test id、txt 表示文本);
  • 隐藏元素标记而非丢弃:真实应用常把目标元素刻意隐藏(自定义样式的复选框、仅 hover 出现的按钮),因此这类元素带着 hid: true 一起送;
  • 先排序再截断:按关键词、元素类型、ARIA role、test id 打分后只取前 maxCandidates 个,默认 40;简单页面可降到 15,密集页面可升到 80;
  • 限制输出长度:模型最多返回三条策略,token 消耗在每次自愈时打印并记录到事件日志,便于按团队核算。

发送范围也需要明确:给模型的是结构化候选元素列表(标签名、role、ARIA 标签、文本、test id 等)与页面 URL,不发送截图,也不发送完整页面 HTML。若 DOM 可能含个人信息、财务数据或内部地址,可选的路径包括使用本地模型让数据不出机器、指向自建网关、限定只在非生产环境启用,以及定期复核事件日志里实际发出去的内容。

三道审计闸门与成本控制点在流程中的位置

本地模型的实测取舍

在完全离线的本地推理方案上,公开记录过一组对比数据,很适合作为选型参考:

模型体积测试结果特点
llama3.2:3b2.0 GB5/5 通过综合最优:快、准、体积小
mistral4.1 GB5/5 通过最准也最慢,约 1.4 分钟每次运行
gemma3:4b3.3 GB4/5 通过比 mistral 快(约 53 秒),准确率略低

1B 到 4B 的小模型在"一排长得几乎一样的复选框"这类场景里会吃力;准确率优先时应选更大的模型。本地方案的代价是每次自愈的模型延迟通常高于云端(云端约 1–2 秒),但换来零 API 密钥、零外发数据、零调用费。

还有一类不走"生成文本"的模型值得单独说:它不写选择器,而是回答带校准概率的判断题——在收集到的候选元素里,哪个是你描述的元素,以及这个元素到底在不在页面上。答案只能是页面上真实存在过的元素,其概率直接作为 minConfidence 检查的置信度,定位器再由代码根据该元素自身属性构造。在一套包含 76 个故意损坏定位器的评测集上(覆盖按钮、链接、输入框、复选框、ARIA 部件与隐藏控件),这种方式全部修复;单次成本约 0.0001 美元,40 个候选的往返不到四分之一秒。当描述与页面上任何元素都不匹配时,它会明确回答"没有",并把最佳猜测连同分数一起放进错误信息,因此描述写得不对也容易被发现。

配置项里最该调的四个

配置项默认值调整建议
quickTimeout1000 ms刻意设短以便坏选择器快速失败;若应用确实有慢渲染区域要调高,否则会把"慢"误判成"坏",白花一次模型调用
minConfidence0设为 0.8 左右,让不确定变成失败
maxCandidates40简单页降到 15,密集页升到 80,直接决定 token 与准确率
maxAiTries4放弃前最多验证多少条建议,配合三条策略的输出上限使用

产物文件要分清职责:缓存文件(修好的定位器,按动作/页面路径/描述组织)建议提交进仓库,让团队与 CI 共享;事件日志(每次尝试的理由、置信度、token 数)与汇总日志加入忽略列表;锁文件与临时文件同样忽略。也可以选择整目录不提交,让每台机器各自维护修复,代价是 CI 每次都要重新调用模型。

已知限制

  • 云端模型需要密钥与网络,本地模型两者都不需要,但延迟更高;
  • 只搜索主 frame,iframe 内的元素还不是候选,开放 shadow root 可以搜到;
  • force: true 的点击直接派发事件,不带指针坐标,无法覆盖依赖真实指针位置的交互;
  • 并行 worker 通过锁文件共享缓存,属于 best effort,不是数据库;
  • 自愈默认关闭,只有显式打开开关后才生效,因此接入初期套件行为不变,风险可控。

建议的接入顺序

  1. 保持自愈关闭,先以 warn 模式跑一到两周,收集"哪些选择器最常坏"的清单——这份清单本身就是自动化资产的债务表;
  2. 对高频失效的选择器先做人工修复,剩下的才交给自愈,避免把架构问题藏进缓存;
  3. 打开自愈时同步设置 minConfidence,并在全局 teardown 里启用"有自愈即失败"的规则;
  4. 缓存文件的每一次变更都走代码评审,把它当测试代码而不是构建产物;
  5. 只在非生产环境启用,涉及敏感 DOM 时改用本地模型或自建端点;
  6. 定期看事件日志里的 token 消耗与理由分布,判断是"重构变多了"还是"描述写得含糊"。

自愈机制真正解决的,是重构期间大量用例同时红掉、人来不及修的排队问题。它没有解决、也不应该试图解决的是"测试到底在验证什么"。把每一次自愈都变成一条需要被看见、被评审、最终被消除的记录,这个能力才不会退化成把真实缺陷一并抹平的绿灯。

使用 Hugo 构建
主题 Stack 由 Jimmy 设计