文章 · 浏览器自动化
选择器坏了先别急着改:三类失败,三种修法
同步回复的脚本第一次坏掉时,我花了一晚上改选择器,第二天它又坏了。后来才明白:超时、多匹配、过期引用是三种不同的问题,只有其中一种该靠改选择器解决。
这篇回应的是 ego lite 官网的 《How to Handle Selectors Breaking in Browser Automation》。观点是我自己的,例子来自我搭的 KOL 建联系统。
Article · Browser automation
When a selector breaks, don't fix it yet: three failure classes, three repairs
The first time my reply-sync script broke, I spent an evening fixing selectors. It broke again the next day. Only later did I understand that timeouts, multiple matches and stale references are three different problems, and only one of them should ever be fixed by changing a selector.
This piece responds to the ego lite article “How to Handle Selectors Breaking in Browser Automation”. The opinions are mine; the examples come from the KOL outreach system I built.
- 先读报错的类型再碰代码。超时说明元素没找到,严格模式冲突说明找到了不止一个,过期引用说明你拿着的是一个已经被替换掉的节点。三种报错指向三个方向。
- 再把失败归到三层:标识符坏了、页面状态不对、工作流本身变了。只有第一层该改选择器,第二层改等待和作用域,第三层要人来改任务。
- 建联系统里我的做法是:用角色加名字定位,脚本坏在第三层时让 agent 看着当前页面重新规划,而不是硬改一个本来就不该存在的步骤。
一个坏掉的选择器在告诉你什么
大家嘴里的「选择器坏了」,报错其实只有三种,而且它们指向完全不同的方向。
等待元素超时,意思是查询什么都没找到。可能页面根本没渲染它,可能它在一个你没进去的 iframe 里,可能它还在加载态后面,也可能标识符真的变了。把这种情况当成命名问题是最常见的误诊,因为「元素不存在」和「元素改名」的症状一模一样。
严格模式冲突正好相反:查询现在匹配到了不止一个元素,工具拒绝替你猜。Playwright 的定位器默认就是严格模式,一旦多匹配立刻抛错,不会等。这对诊断很重要,因为多匹配和超时毫无共同点,哪怕它们都以「这一步失败了」的面目出现。
节点已分离或引用过期,意思是你拿着一个早些时候取到的元素,而页面后来把它换掉了。重新渲染时旧节点被丢弃,引用就没了,哪怕那个控件还在屏幕上、还是对的。
在动代码之前先把报错类型读清楚,这是第一步,也是大多数人跳过的一步。
失败的三层
知道了报错类型,下一个问题是:哪个假设失效了?把失败归到三层之一,正确的应对就从层级而不是症状里来。
| 失败类别 | 真正坏掉的东西 | 正确的应对 |
|---|---|---|
| 标识符不稳定 | 你匹配的属性是实现细节:自动生成的 class、位置索引、一长串 CSS 路径。 | 改定位器。换成角色加可访问名字、test id,或者一个稳定的容器作用域。这是唯一该改选择器的一类。 |
| 页面状态不对 | 查询本身合理,但它跑在页面到达预期状态之前,或者跑在了一个标记不同的响应式版本上。 | 改等待、改 iframe 作用域、改视口假设。改选择器只会把真正的原因留在原地。 |
| 工作流假设失效 | 这一步本身错了。控件挪进了别的流程,任务多了一个确认步骤,通往结果的路径变了。 | 人来改工作流。改任何定位器都不对,而「让这一步无论如何过去」的补丁是最危险的结果。 |
这张表的实际价值在于:三行里有两行应该阻止你去改选择器。公开的测试维护调查里,脆弱的选择器通常只占失败的四分之一到三分之一,时间和交互问题占的份额差不多甚至更大。如果你的失败几乎全被归到「选择器问题」,说明分类哪里错了。
改之前先留证据
在改代码之前先把失败现场留下来。截图回答「用户当时能看到什么」,DOM 或可访问性快照回答「自动化当时能定位到什么」,控制台和网络请求解释「预期状态为什么始终没出现」。如果你只留了一条超时信息,三种根因就会塌缩成同一张工单。
| 证据 | 它回答的问题 | 别据此下的结论 |
|---|---|---|
| 截图加当前 URL | 这次运行是否在预期的路由、账号状态、弹层和视口上? | 一个看得见的标签不等于元素唯一可操作 |
| DOM 或可访问性快照 | 元素存在吗?角色和名字是什么?有几个匹配? | 工作流的含义仍然相同 |
| 控制台和失败的请求 | 定位器运行之前,渲染或数据加载失败了吗? | 把超时调长就能修好应用状态 |
三层证据拿到之后,在干净的页面加载上复现一次。只有稳定标识符的失败才应该产出一次定位器修改;页面状态的失败改等待或前置步骤;工作流的失败改任务。
为什么定位器活得下来,而句柄活不下来
定位器不是一个缓存起来的元素引用,而是一段查询描述,用到的那一刻才解析,并且在每次操作前都对着当前页面重新解析一遍。句柄正好相反:它指向某个时刻捕获的某一个节点。这一个差别解释了大多数被归咎于「选择器质量」的存活与否。
// 脆弱:存下了早先渲染出来的节点,后来一次重渲染就让它失效
const button = await page.$("div.toolbar > button:nth-child(2)");
await button.click();
// 耐久:每次操作前重新解析,重渲染后照样能用
const button = page
.getByRole("navigation", { name: "Issue actions" })
.getByRole("button", { name: "Close issue" });
await button.click();
两个细节值得在重写之前知道。第一,角色定位器查的是可访问性树,所以它扛得住包装元素和改名的生成 class:它匹配的是这个控件「是什么、叫什么」,而不是「在哪里」。第二,严格模式只对定位器生效,对原始查询方法不生效,所以把定位器换成原始查询的「修复」可能看起来管用,实际上悄悄拆掉了一道护栏。
耐久的定位器仍然做不到一件事:判断这一步还是不是该走的一步。class 改名是定位器问题,控件挪进另一个流程是任务问题,大多数选择器调试走错的地方恰恰是这两者的分界。
五个可以自己复现的案例
每个案例都能在你自己控制的页面上复现。做一遍的意义在于,分清楚定位器修复真正能修好什么,哪些只有人能修。
- 生成的 class 名变了。 给按钮换个 class,所有按旧 class 匹配的查询都停止解析;带可访问名字的角色定位器完全不受影响,因为 class 从来不是控件身份的一部分。这是定位器修复能完全修好的一类,也是人们由此推断「稳定定位器解决 UI 变化」的那一类。
- 加了一层包装元素。 在控件外面包一个 div,所有后代和子代组合路径都断,所有位置选择器都偏移;角色定位器和 test id 照样解析。它和第一类是同一类,修法相同,这正是为什么前两个案例是对整个问题的误导性抽样。
- 组件重渲染替换了节点。 强制组件卸载再挂载,持有旧引用的代码路径以过期引用失败;在使用时刻重新解析定位器的代码路径继续工作。可操作性检查会等元素附加、可见、稳定、可用,而不是立刻失败,所以有些时序问题换成定位器后就消失了,尽管身份什么都没变。
- 可访问名字变了。 把按钮从「保存」改成「保存草稿」,或者加一个改变计算名字的图标。匹配旧名字的角色定位器失败了,但这个失败比 class 改名更有信息量,因为它通常反映了一次真实的产品变化。如果标签由内容或翻译控制、会一直变,诚实的选择是 test id,而不是再写一个文本匹配。
- 控件挪进了新的流程。 多一步确认,或者动作藏进了以前没有的菜单。所有定位技术都会以同一个原因失败:旧的那一步不再成立。你可以把一个正确的定位器指向一个正确的元素,但如果那一步本身不该存在,任务照样完不成。人得改工作流;而一个有人监督的 agent 往往能看着当前页面另找一条路把任务做完。
还有一种情况即便界面没变也表现得像第五类:页面是别人的第三方网站。维护者可以毫无预警地重构它,定位器写得再好也护不住你,因为你依赖的那份契约从来不属于你。
我在建联系统里的三条规则
同步回复的脚本要进邮箱读信、再进 KOL 后台改状态,两个页面我都不拥有,都会改版。被坑过几次之后,我给自己定了三条规则。
- 定位只用角色加名字,或者 test id。 脚本里不允许出现 nth-child 和生成的 class。这一条消灭了第一层的绝大多数失败。
- 每次失败先分层,再决定改什么。 脚本捕获到错误时自动存一张截图、一份快照和当前 URL,然后退出。我看完证据再决定是改等待、改定位,还是改任务。不允许「先把超时调成 60 秒再说」。
- 第三层交给 agent 重新规划,而不是硬改脚本。 当后台把「同步」按钮挪进了下拉菜单,脚本当然坏了。但这时我不去猜新路径,而是让 Claude Code 通过 ego-browser 看一眼当前页面,描述它看到的路径,确认之后再把新步骤写回脚本。确定性的脚本负责日常,有判断力的 agent 负责变化。
最后一条的边界很清楚:工作流稳定、步骤明确、要反复跑,就用脚本;页面在变、路径要重新找,就让 agent 看着页面做决定。元素变了,修选择器;流程变了,重新规划。
- Read the error class before touching code. A timeout means nothing was found, a strict-mode violation means more than one thing was found, a stale reference means you are holding a node that has since been replaced. Three errors, three directions.
- Then sort the failure into one of three layers: the identifier broke, the page state was wrong, or the workflow itself changed. Only the first layer calls for a selector fix; the second changes waits and scope; the third needs a person to change the task.
- In the outreach system I locate by role and name, and when a script breaks at the third layer I let the agent re-plan from the live page instead of forcing a step that should no longer exist.
What a broken selector is actually telling you
What people call a broken selector arrives as one of three error classes, and they point in completely different directions.
A timeout waiting for the element means the query resolved to nothing. The page did not render it, it rendered inside a frame your locator never entered, it is still behind a loading state, or the identifier genuinely changed. Treating this as a naming problem is the most common misdiagnosis, because a missing element and a renamed element look identical.
A strict-mode violation is the opposite: the query now matches more than one element and the tool refuses to guess. Playwright locators are strict by default and throw immediately on multiple matches, without waiting. That matters, because ambiguity and a timeout have nothing in common even though both show up as a failed step.
A detached or stale reference means you kept an element from earlier in the run and the page replaced it since. The node was discarded during a re-render, so the reference is gone even though the control is still on screen and still correct.
Reading the error class before changing the query is the first step, and the one most people skip.
Three layers of failure
Once you know the error class, the next question is which assumption failed. Sort the failure into one of three layers and the right response follows from the layer, not the symptom.
| Failure class | What actually broke | Correct response |
|---|---|---|
| Unstable identifier | The attribute you matched on was an implementation detail: a generated class, a positional index, a long CSS path. | Fix the locator. Move to role plus accessible name, a test id, or a stable container scope. This is the only class where a selector fix is right. |
| Wrong page state | The query is reasonable but ran before the page reached the state it assumes, or against a responsive variant with different markup. | Fix the wait, the frame scope or the viewport assumption. Changing the selector leaves the real cause in place. |
| Broken workflow assumption | The step itself is wrong. The control moved into another flow, the task gained a confirmation step, the route to the outcome changed. | A person updates the workflow. No locator change is correct here, and a patch that makes the step pass anyway is the most dangerous outcome. |
The practical value of the table is that two of its three rows should stop you from editing a selector. In public test-maintenance surveys, brittle selectors account for roughly a quarter to a third of failures; timing and interaction problems take a comparable or larger share. If almost every failure in your suite is filed as a selector problem, the classification is wrong somewhere.
Capture evidence before you change anything
Preserve the failure before editing code. A screenshot answers what the user could see; the DOM or accessibility snapshot answers what the automation could address; console and network evidence explains why the expected state never appeared. Keep only the timeout message and three root causes collapse into one ticket.
| Evidence | Question it answers | Do not conclude |
|---|---|---|
| Screenshot plus URL | Was the run on the expected route, account state, modal and viewport? | That a visible label proves the element is uniquely actionable |
| DOM or accessibility snapshot | Did the element exist, under which role and name, and were there several matches? | That the workflow still means the same thing |
| Console and failed requests | Did rendering or data loading fail before the locator ran? | That a longer timeout will repair the application state |
With those three layers captured, reproduce once on a clean page load. Only a stable-identifier failure should produce a locator edit; a page-state failure changes the wait or setup; a workflow failure changes the task.
Why a locator survives what a handle does not
A locator is not a cached element reference. It is a query description that stays unresolved until used, and it re-resolves against the live page before every action. A handle is the opposite: it points at one node captured at one moment. That single difference explains most of the survive-or-break behaviour people attribute to selector quality.
// Brittle: stores a node from an earlier render; a later re-render detaches it
const button = await page.$("div.toolbar > button:nth-child(2)");
await button.click();
// Durable: re-resolves before every action and survives a re-render
const button = page
.getByRole("navigation", { name: "Issue actions" })
.getByRole("button", { name: "Close issue" });
await button.click();
Two details are worth knowing before you rewrite anything. First, role locators query the accessibility tree, which is why they survive wrapper elements and renamed generated classes: they match what the control is and what it is called, not where it sits. Second, strict mode applies to locators but not to raw query methods, so a “fix” that swaps a locator for a raw query may look like it worked while quietly removing a guardrail.
What a durable locator still cannot do is decide that the step is no longer the right step. A renamed class is a locator problem. A control that moved into a different flow is a task problem, and the line between those two is exactly where most selector debugging goes wrong.
Five cases you can reproduce yourself
Each case is reproducible on a page you control. The point of working through them is to separate what a locator fix genuinely repairs from what only a person can repair.
- A generated class name changes. Rename a button's class and everything matching the old class stops resolving; a role locator with an accessible name is untouched, because the class was never part of the control's identity. This is the case a locator fix fully repairs, and the one people generalise from when they conclude stable locators solve UI change.
- A wrapper element is added. Wrap the control in a div: descendant and child paths break, positional selectors shift, role locators and test ids still resolve. Same class as case one, same fix, which is exactly why the first two cases are a misleading sample of the problem.
- The component re-renders and replaces the node. Force an unmount and remount: code holding an old reference fails with a detached reference; code that re-resolves its locator at the moment of use carries on. Actionability checks wait for attached, visible, stable and enabled rather than failing immediately, so some timing problems disappear when you move to locators even though identity never changed.
- The accessible name changes. Change Save to Save draft, or add an icon that alters the computed name. A role locator on the old name fails, and the failure is more informative than a class rename because it usually reflects a real product change. If the label is owned by content or translation and will keep moving, a test id is the honest choice, not another text match.
- The control moves into a new flow. Add a confirmation step, or hide the action behind a menu it was never behind. Every locating technique fails for the same reason: the old step is no longer valid. You can point a correct locator at a correct element inside a step that should not exist and the task still will not complete. A person has to change the workflow, and a supervised agent can often finish anyway by reading the current page and choosing another route.
One more situation behaves like the fifth even when nothing changed: the page is a third-party site you do not own. Its maintainers can restructure it without warning, and no locator quality protects you, because the contract you relied on was never yours.
My three rules in the outreach system
The reply-sync script reads mail in the mailbox and updates statuses in the KOL back office. I own neither page and both get redesigned. After a few burns I set three rules for myself.
- Locate by role plus name, or by test id, and nothing else. No nth-child, no generated classes anywhere in the scripts. This alone removes most layer-one failures.
- Classify every failure before deciding what to change. When a script catches an error it saves a screenshot, a snapshot and the current URL, then exits. I look at the evidence before choosing between a wait fix, a locator fix and a task change. “Bump the timeout to 60 seconds and see” is not allowed.
- Hand layer three to the agent for re-planning instead of hacking the script. When the back office moved the sync button into a dropdown, the script broke, as it should. Instead of guessing the new path I let Claude Code look at the live page through ego-browser, describe the route it sees, and only after I confirm do the new steps go back into the script. Deterministic scripts for the daily run; an agent with judgement for the changes.
The boundary of that last rule is clear: when the workflow is stable, the steps are known and the run repeats, use a script; when the page is changing and the path has to be found again, let the agent decide from what it sees. If the element changed, fix the selector; if the flow changed, re-plan.
常见问题
只有在页面状态那一层才算,而且前提是你已经确认元素确实会出现、只是慢。对标识符失败和工作流失败,调长超时只是让你多等几十秒看同一个错误。
控件的名字稳定就用角色加名字,它同时验证了可访问性。名字由内容或翻译控制、会一直变,就用 test id。两者都比 class 和位置可靠得多。
日常的稳定任务用确定性脚本更便宜、更可预测。agent 的价值在第三层:页面变了、路径要重新找的时候。两者搭配比任何一个单独用都好。
Only at the page-state layer, and only once you have confirmed the element really does appear, just slowly. For identifier and workflow failures a longer timeout just makes you wait longer to see the same error.
If the control's name is stable, use role plus name; it also verifies accessibility. If the name is owned by content or translation and keeps moving, use a test id. Both beat classes and positions by a wide margin.
Deterministic scripts are cheaper and more predictable for stable daily tasks. The agent earns its keep at layer three, when the page has changed and the path has to be found again. The combination beats either alone.
More about me
Wanna know more? 👇