团队发现自动化浏览器Agent填表时,react-select组件只写入了可见input而hidden value为空,Agent自读验证完全匹配但实际未提交,调试成本极高。
我们运行的浏览器 Agent 会在各公司的招聘系统上填写求职申请表。其中一个 Agent 报告表单已全部填写完成。十七个字段写入,十七个确认,提交按钮已点击。
这十七个值中,零个到达了雇主端。
Agent 没有说谎。它读取了错误的东西,而那个错误的东西每一次都和它达成了一致。
那个字段是一个 react-select 组合框。你知道它的形态:上方是一个文本输入框,下方是一个选项菜单,以及一个表单实际提交时用的隐藏值。
我们的 Agent 向输入框中键入了内容,回读输入框,看到了自己的文本就躺在那里,然后标记字段为已完成。
以下是我们在一份真实职位上用真实浏览器测量的结果,用三种方式操作同一个组件:

react-select 在一个可信的 mousedown 事件上打开菜单。合成事件不符合条件。所以按我们的 Agent 的路径,菜单从未打开过,没有选中任何东西,隐藏值一直保持为空。
与此同时,可见输入框完美地保存着我们的文本。我们的回读是匹配的。成功。
这是一个反馈缺陷,不是填写缺陷
这个区别我们花了太长时间才搞清楚,所以我要把它说清楚。
填写从来都不是出问题的那部分。评分才是。
await fill(selector, value);
const readback = await getValue(selector);
if (loosely(readback, value)) return { success: true }; // <- 读取的是我们自己
那个条件里的每一个术语都在我们自己的控制之下。我们写入了值,我们读取了我们写入的字段,我们把自己的字符串和我们的字符串做了比较。这个检查里没有任何状态是对方产生的。只要写入工作正常它就不会失败,而且它不会仅在提交工作时才成功——而这恰恰是你对一个检查真正需要的属性。
因为字段被评分为完成,Agent 从未升级到专门的提交路径——那条会正确点击选项的路径。那条路径是存在的。它是模型可选的,所以没有任何东西替你调用它。虚假的通过比红色失败更糟糕,因为红色至少会把你路由到备用方案。
生产环境中的实际形态:
一次申请:17 个组合框写入中,0 个实际提交
另一次申请,同样的表单,同样的运行:专用路径上 3 次写入全部落地,Agent 路径上 8 次写入中 6 次发出去是空的
再一次:8 个是空的,其中一个回读出了字面占位符文本"Select…"并把它当作一个值
然后表单在提交时拒绝了,而 Agent 看不到任何错误,因为从浏览器的角度看什么都没错。必填字段只是空着。
我们后来写下的规则
永远不要在自己输入的回读上给写入评分。
要根据对方产生的东西来评分。对于组合框,这意味着要问组件它实际提交了什么:
已渲染的选中标签,而不是过滤文本 表单验证所依赖的隐藏后备输入框 库自身的已提交状态指纹
然后是让上线变得安全的那部分:
三种状态,不是两种。已提交、未提交、未知。未知不改变任何东西。
第三种状态是承重的。一个普通的 ARIA 组合框,其中键入的文本本身就可以是值——不能被一个为 react-select 构建的检查器判为失败。如果我们无法识别组件、无法解析作用域、或者任何东西抛出异常,我们就返回未知,并保持之前的判定不变。一个在陌生组件上自信地出错的检查器,是穿着旧 bug 外衣的新 bug。
我们自己的补丁中的两个 bug,就是被专门为此编写的测试捕获的:
在第一个看起来像 react-select 的元素上,在读取实际重要的隐藏输入框之前就得出未提交的结论。
把抛出异常当作失败,会让每一个我们从未见过的组件都失败。
这个模式在其他地方也同样存在
一旦你掌握了这个形态,你会到处看到它:
上传被标记为完成,因为文件输入框的 files.length 是 1,这是你自己的 DOM 写入的属性,而不是任何字节到达了服务器
保存通过重新读取本地状态来确认,而不是通过响应来确认
队列发布通过没有抛出异常来确认
指标读数为零,是因为仪器没有上锁,这和事件没有发生不是一回事
我们现在对每一个检查应用的一般化原则:
这个检查在成功场景和失败场景下,读数会不同吗?如果不是,它就不是证据,它是一面镜子。
等更久也没有用。我们试过在回读之前增加延迟,基于菜单很慢的理论。它被回滚了。根本没有什么菜单值得等。
我构建了 AI Applyd。我们在公司的自有招聘系统上投递申请,只有当他们的系统确认了之后,我们才把申请计为已发送,原因正是上面说的:我们自己的点击不是任何东西的证据。