Playwright 点不到页面上的元素:actionability 卡在哪一项

2026-08-18

一个很常见的场面:截图里按钮好端端在页面上,肉眼能看见,click() 却抛 TimeoutError。这时候大多数人第一反应是去改定位器,或者加一句 waitForTimeout。但 Playwright 在做动作之前会跑一组可操作性检查(actionability),点击超时通常意味着这组检查里有一项一直没通过——先弄清楚是哪一项,比换定位器有用得多。

五项检查各自在判什么

docs/src/actionability.md 把检查拆成五项:Visible、Stable、Receives Events、Enabled、Editable。文档里那张表逐个动作列出了各自要过哪几项,几个关键差异值得记住:

  • Locator.clickcheckdblclicksetCheckedtapuncheck 要过 Visible、Stable、Receives Events、Enabled 四项,不查 Editable。
  • Locator.hoverdragTo 不查 Enabled——一个 disabled 的按钮是可以被 hover 的。
  • Locator.fillclear 走的是另一条线:查 Visible、Enabled、Editable,不查 Stable 也不查 Receives Events。所以「填不进去」和「点不到」大概率不是同一个原因。
  • Locator.pressfocusblurdispatchEventsetInputFiles 这几行全是 -,一项都不查。它们失败一定是别的原因。

再看每一项的判定口径,这是最容易想当然的地方。

Visible:文档的定义是「有非空的 bounding box,且 computed style 不是 visibility:hidden」。由此派生三条,文档里逐条写明了:零尺寸元素算可见;display:none 算可见;opacity:0 可见。最后一条经常反直觉——一个透明度为 0 的遮罩层在 Playwright 眼里是可见的,它可能同时就是挡住你按钮的那个东西。

源码侧对应 packages/injected/src/domUtils.tscomputeBox():先取 computed style,display:contents 时会往子节点找(有可见子元素或可见文本节点就算可见),再走 isElementStyleVisibilityVisible(),最后落到 rect.width > 0 && rect.height > 0。同一文件的 computeElementStyleVisibilityVisible() 里还有一段注释说明,Element.prototype.checkVisibility 在 WebKit 上因为一个已知 bug 没有启用,那条分支改走 details/summary 的手工兜底。

Stable:文档写的是「连续两个动画帧里 bounding box 保持不变」。源码在 packages/injected/src/injectedScript.ts_checkElementIsStable(),用 requestAnimationFrame 轮询,逐帧比较矩形的四个值,帧数阈值来自 _stableRafCount。这一项失败的典型现场是 CSS 过渡动画、轮播、以及那种「加载完再往下顶一截」的布局抖动。

Receives Events:文档的说法是元素要是动作点上指针事件的命中目标。比如在 (10;10) 点击时,Playwright 会检查是不是有别的元素(通常是 overlay)会在那个点上把点击接走。这项失败时,packages/playwright-core/src/server/dom.ts 会打出 <某某描述> intercepts pointer events,日志里会直接告诉你是谁挡的。

Enabled:定义是「不 disabled」。文档列了三种 disabled 的情形:<button><select><input><textarea><option><optgroup>[disabled] 属性;上述控件属于一个带 [disabled]<fieldset>;或者它是某个带 [aria-disabled=true] 元素的后代。源码里 getAriaDisabled() 就是 isNativelyDisabled()hasExplicitAriaDisabled() 的或,注释里点明 aria-disabled 会向下影响所有后代——所以祖先上挂了这个属性,你的按钮就整体不可用了。

Editable:enabled 且不是 readonly。readonly 有两种:<select>/<input>/<textarea>[readonly];或者带 [aria-readonly=true] 且角色支持这个属性。这里有个值得单独说的行为:源码 getReadonly() 对既不是这三种标签、又没有对应 role、也不是 contenteditable 的元素返回 'error'elementState() 拿到后会抛出「Element is not an <input>, <textarea>, <select> or [contenteditable] and does not have a role allowing [aria-readonly]」。也就是说,对一个普通 <div>fill(),你等到的不是超时,而是这条明确报错。

怎么确认卡在哪一项

不用猜,日志会说。_performPointerAction() 在非 force 路径上按固定顺序打这几行:先 waiting for element to be visible, enabled and stable(不需要 Enabled 的动作打的是 visible and stable),通过后打 element is visible, enabled and stable,然后 scrolling into view if neededdone scrolling,之后才去算动作点、装命中测试拦截器。外层 _retryAction() 首轮打 attempting <动作> action,之后每一轮打 retrying <动作> action,等待间隔非零时再补一行 waiting <n>ms;某一项没过就打对应的一行:element is not visibleelement is outside of the viewportelement is not <状态>,或者上面那条 intercepts pointer events

日志停在哪一行,就是卡在哪一项。 停在 waiting for element to be visible, enabled and stable 反复重试,说明连状态检查都没过;已经打了 done scrolling 才开始刷 intercepts pointer events,那就是有东西挡着,跟可见性无关。

看这段日志有几个入口:

  • Playwright Inspector。docs/src/debug.md 写明它能单步执行、实时编辑 locator、查看 actionability 日志。JS 侧用 --debug
npx playwright test example.spec.ts:10 --debug

文档说明用了 --debug 后浏览器以 headed 模式启动,默认超时被设为 0(不超时)。 Python / Java / C# 侧走的是 PWDEBUG 环境变量(debug.md 里这一节标的语言标签就是 csharp、java、python,别把 JS 的写法套过去)。Windows 上两种 shell 写法不同,文档里分别给了:

set PWDEBUG=1
pytest -s
$env:PWDEBUG=1
pytest -s
  • Trace Viewer 与 UI Mode。docs/src/trace-viewer.mddocs/src/test-ui-mode-js.md 都写明可以看到完整日志,包括滚动进视口、等待元素变为 visible/enabled/stable、以及执行动作本身。本地不开 UI Mode 时可以用 npx playwright test --trace on 强制开启 tracing。

  • 不改动作、只跑检查:动作方法带 trial 选项,docs/src/api/params.md 写明「只执行可操作性检查、跳过动作,默认 false」。click 用的是带修饰键说明的那一版,注意 modifiers 无论 trial 与否都会被按下。

  • 用自动重试断言把范围缩小。actionability.md 的断言表里有 toBeVisibletoBeEnabledtoBeEditabletoBeInViewport,它们会重试到条件满足。Locator.isVisible() 的文档里专门挂了一条 warning:要断言可见性时优先用 toBeVisible,以免引入 flakiness——isVisible() 是取一次瞬时值,不重试。

处置:先按项对症,再考虑 force

判定清楚之后,处置是分项的:卡 Stable 就等动画收敛(用断言等到目标态,而不是硬等一个时长);卡 Receives Events 就去处置日志点名的那个覆盖层;卡 Enabled 就先断言 toBeEnabled(),顺便查一下祖先上有没有 aria-disabled;报 element is outside of the viewport 且你显式传了 scroll: 'none',那是预期行为——params.md 写明该选项默认 'auto'(必要时滚动,包括滚动嵌套的可滚动容器),设为 'none' 时不滚动、元素不在视口内则动作失败,用途是断言「用户不用额外滚动就能够到这个元素」。这是仓库当前文档里的默认值,随版本可能变动;该选项在 click 上标的是 since: v1.62

然后是 forceparams.md 对它的定义只有一句:「Whether to bypass the actionability checks. Defaults to false.」actionability.md 补了一层:它关掉的是非必要检查,例如给 click 传真值 force 后,不会再检查目标元素是否真的收到点击事件。

代价在源码里看得更清楚,有两层:

  1. _performPointerAction()if (!force) 才会调 checkElementStates——一旦 force,visible、enabled、stable 三项一起跳过,不是只跳过某一项;接着 force 分支只打一句 forcing action,命中测试拦截器那一段整块不执行。也就是说,遮挡问题不会消失,只是不再报错了,点击落到谁身上取决于页面。
  2. 更容易被忽略的一层在 _retryAction():force(或内部的 noAutoWaiting)为真时,error:notvisible 会直接抛 NonRecoverableDOMError('Element is not visible') 而不再重试,error:notinviewport 同理。force 不只是「不检查」,它还把一部分「再等一帧就好了」的情况变成了立即失败。

有一点 force 管不着:滚动。doScrollIntoView 的分支只看 scroll === 'none',不看 force。

await page.getByRole('button', { name: 'Sign Up' }).click({
  force: true,
  timeout: 5000,
});

timeout: 5000 沿用仓库示例里出现过的写法,只是一个示例值,不是推荐值。以上为按仓库代码中的接口语义组合的示例,未经实测,以仓库最新代码为准。)

处置后怎么验证

别只看「不报错了」。可靠的验证是把同一个动作换成 trial 跑一遍:检查全过、动作不执行,说明元素确实进入了可操作状态,而不是被 force 掩盖过去。再把 Inspector 或 trace 里的日志翻一遍,确认那几行按顺序走完了 element is visible, enabled and stabledone scrolling,中间没有反复重试。断言层面则用 toBeVisible() / toBeEnabled() 这类会重试的断言替掉 isVisible() 之类的瞬时读取。

什么情况说明不是这个原因

  • 报的是 strict mode violation,不是超时。 actionability.md 列的第一条前提就是「locator 解析到恰好一个元素」,多匹配属于定位器问题,跟五项检查无关。
  • 元素在动作过程中从 DOM 脱离。 docs/src/api/class-locator.mdclick 细节里写明,元素在动作期间任何时刻从 DOM 脱离,方法都会抛错;源码里对应 error:notconnected,这条不走重试等待那套逻辑。
  • 失败的动作是 pressfocusdispatchEventsetInputFiles 表里这几行五项全是 -,压根不做可操作性检查。
  • 日志已经走过全部检查、卡在动作之后。 click 的步骤在文档里有四步,最后一步是等待由此触发的导航成功或失败(除非设了 noWaitAfter)。日志停在这里说明问题在导航,不在元素状态。
  • <div>fill() 收到的是那条 [aria-readonly] 报错。 那是元素类型不对,不是「没等够」。

最后提醒一句:日志文案、内部重试节奏、状态名这些都属于实现细节,本文引的是仓库当前代码里的写法,随版本会变。真正稳定的是 actionability.md 里那张表和五项定义——排查时先回去对那张表,再去读日志。


本文依据 github.com/microsoft/playwright 仓库于 2026-08-18 的公开内容整理, 事实来自仓库内的文档与源码。我们没有对文中涉及的功能做过实测, 因此不涉及运行速度、稳定性与实际表现的任何描述。 该项目迭代频繁,文中涉及的 API 签名、配置项与默认值随版本变动,请以仓库最新内容为准。 本文不涉及浏览器版本矩阵与版本清单,相关信息请以官方发布说明为准。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。