点了,但什么都没发生:浏览器自动化里三类静默失败
脚本跑完了,日志一行错误都没有,每个 click() 都返回成功,page.wait_for_timeout() 也老老实实等够了。然后打开后台一看:什么都没变。
这是我用 Playwright 做无人值守任务时踩得最多的一类坑——动作执行成功了,业务没发生。Playwright 的自动等待只保证元素"可以点",不保证"点了有用"。下面三类情况我都实际撞过,每一种的共同点都是:不报错、不超时、静默失败。
一、被一个看不见的遮罩挡住
最常见,也最容易被误判。现象是这样的:前面某个步骤打开了一个弹窗,你按 Escape 把它关了——肉眼看着确实关了,因为弹窗主体消失了。但遮罩层还在,只是变成了透明。
于是后面所有 page.click() 都超时,报的还是那句让人困惑的话:
element is not visible
subtree intercepts pointer events
真正的原因在第二句:有个你根本没注意的元素盖在上面,把点击事件吃掉了。Escape 只触发了组件自己的关闭回调,遮罩的销毁逻辑走的是另一条路径(比如等某个动画结束或者等消息回来),两边不同步。
诊断只需要一行:
page.evaluate("""(sel) => {
const el = document.querySelector(sel);
if (!el) return 'no element';
const r = el.getBoundingClientRect();
const top = document.elementFromPoint(r.x + r.width/2, r.y + r.height/2);
return top === el ? 'ok' : 'blocked by ' + top.className;
}""", ".submit-btn")
elementFromPoint 返回的不是目标元素,就说明有东西盖着。比起靠 click() 的报错去猜,这个查询直接把"是谁挡的"点出来。
还有一种更阴的情况:遮罩层名字里带 mask,但它 offsetParent 不为 null、尺寸铺满全屏、opacity: 0。用可见性判断(is_visible())会认为它可见,用截图看又是一片空白。这种时候别在遮罩底下硬点,直接 page.reload() 重来一次比研究怎么关它便宜得多。
二、点在了滚动之前的坐标上
第二类是坐标系问题。Playwright 的 mouse.click(x, y) 用的是视口坐标,而 bounding_box() 拿到的是当时那一刻的位置。这两件事之间只要发生了一次滚动,坐标就作废了。
典型的错误写法:
btn = page.query_selector(".publish-btn")
bb = btn.bounding_box() # 此时按钮在视口下方 800px
btn.scroll_into_view_if_needed() # 页面滚了 700px
page.mouse.click(bb["x"]+10, bb["y"]+10) # 点的还是旧坐标 → 点空了
点空不会报错,mouse.click() 永远"成功",事件只是落在了页面别的地方。所以要写成:
btn.scroll_into_view_if_needed()
page.wait_for_timeout(500) # 等滚动动画结束
bb = btn.bounding_box() # 滚动之后重新取
page.mouse.click(bb["x"]+bb["width"]/2, bb["y"]+bb["height"]/2)
中间那个 wait_for_timeout 不是凑数的:scroll_into_view_if_needed() 在很多页面里触发的是平滑滚动,返回时动画还没结束,立刻取 bounding_box() 拿到的仍是中间态的位置。
顺带一个经验:只要能用 locator.click() 就别用 mouse.click(坐标)。前者每次点击前会重新计算位置,后者是你自己在替它做这个计算,而且只算一次。
三、DOM 改了,组件状态没改
第三类最隐蔽:你以为自己在操作组件,其实只操作了它的影子。
一个下拉选择框,选项列表默认是隐藏的(容器上挂着 options-hidden 类)。如果强行用 JS 把类改成 options-visible 再给列表设 display:block,选项确实出现了,点一下,标签也确实被插进了 DOM。看起来一切正常。
然后点提交:零网络请求、零报错、URL 不变、连校验红字都不出现。
原因是这个组件把"已选中"记在自己的内部状态里,渲染 DOM 只是它的输出。你手动插进去的那个标签,只是绕过组件塞进来的一段 HTML,组件自己并不知道。提交时它读内部状态,发现是空的,于是……什么也没做。
同类问题还有提交按钮的 disabled。按钮灰着的时候点它不会报错,因为元素确实存在、也确实"可点"(很多框架的禁用状态是 CSS 的 pointer-events: none 或者一个空的事件处理器,而不是 HTML 的 disabled 属性)。真正可靠的判断是读属性:
disabled = page.evaluate("() => document.querySelector('.submit-btn').disabled")
如果它是靠"标题有值 且 正文通过某种特定方式写入"才解除的,那么只填了标题、正文用 fill() 写进去(没触发框架监听的 input 事件),按钮会一直灰着。
四、唯一可信的成功判据是副作用
上面三类坑,归根到底是一个方法问题:不要用"我点了"判断成功,要用"世界变了"判断成功。
按可信度排个序:
- 接口响应。挂
page.on("response"),看有没有那个 POST,状态码是多少,返回体里的 ID 是什么。这是最硬的证据。 - 服务端数据。提交成功后去列表接口拉一次,看新的那条在不在。注意有些接口需要在页面上下文里带着 csrf 头才能调,换个页面
fetch会返回一段纯文本错误。 - URL 变化。弱一些,因为很多单页应用提交完就是不跳转的。
- 页面文本变化。比如"提交成功,将在 2 个工作日内完成预审"。这个能用,但要读对区域,别读整个
body.innerText——导航栏里经常也有"提交"两个字。 - 元素外观变化(按钮变灰、文案变"已收藏")。最弱,而且经常是异步的。
第 1 条有个反直觉的细节:如果提交真的成功了,页面发生了导航,你在 evaluate 里读状态时会抛
Execution context was destroyed, most likely because of a navigation
这个报错反而是成功信号。把它当成失败去重试,就会提交两遍。我第一次遇到时正好在发文章,结果同一天发了两篇一模一样的博客,只能回头删掉一篇。
五、一个可复用的检查清单
写自动化脚本时,我现在固定做这几件事:
- 每次点击前确认目标没被遮挡:
elementFromPoint比对。 - 需要坐标点击时,滚动后重新取
bounding_box(),并且取中心点而不是左上角。 - 尺寸为 0 的元素直接跳过:
getBoundingClientRect()宽高都是 0 的,是装饰用的 svg 或占位符,点它永远没用。 - 页面上有多个同名按钮时,按"可见 + 文本精确匹配"过滤:
el.offsetParent !== null && el.innerText.trim() === "确定"。很多隐藏的弹窗模板在 DOM 里一直躺着,get_by_text(...).first很容易取到那个 0×0 的。 - 关键动作一定挂请求监听,脚本结束时打印"这次一共发了几个 POST"。一个都没有,就说明整段操作是空转。
- 失败重试前先想清楚"上次到底有没有成功"。异步同步的场景(比如积分、审核),本地看不出变化很正常,这时候重试就是重复提交。
最后一句总结:Playwright 帮你解决的是"元素在不在、能不能点",而"点了之后世界有没有变"这件事,只有业务层面的断言能回答。两者之间那道缝,就是所有静默失败藏身的地方。
- 点赞
- 收藏
- 关注作者
评论(0)