Playwright 点不到页面上的元素:actionability 卡在哪一项
一个很常见的场面:截图里按钮好端端在页面上,肉眼能看见,click() 却抛 TimeoutError。这时候大多数人第一反应是去改定位器,或者加一句 waitForTimeout。但 Playwright 在做动作之前会跑一组可操作性检查(actionability),点击超时通常意味着这组检查里有一项一直没通过——先弄清楚是哪一项,比换定位器有用得多。
五项检查各自在判什么
docs/src/actionability.md 把检查拆成五项:Visible、Stable、Receives Events、Enabled、Editable。文档里那张表逐个动作列出了各自要过哪几项,几个关键差异值得记住:
Locator.click、check、dblclick、setChecked、tap、uncheck要过 Visible、Stable、Receives Events、Enabled 四项,不查 Editable。Locator.hover和dragTo不查 Enabled——一个 disabled 的按钮是可以被 hover 的。Locator.fill和clear走的是另一条线:查 Visible、Enabled、Editable,不查 Stable 也不查 Receives Events。所以「填不进去」和「点不到」大概率不是同一个原因。Locator.press、focus、blur、dispatchEvent、setInputFiles这几行全是-,一项都不查。它们失败一定是别的原因。
再看每一项的判定口径,这是最容易想当然的地方。
Visible:文档的定义是「有非空的 bounding box,且 computed style 不是 visibility:hidden」。由此派生三条,文档里逐条写明了:零尺寸元素不算可见;display:none 不算可见;opacity:0 算可见。最后一条经常反直觉——一个透明度为 0 的遮罩层在 Playwright 眼里是可见的,它可能同时就是挡住你按钮的那个东西。
源码侧对应 packages/injected/src/domUtils.ts 的 computeBox():先取 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 needed、done scrolling,之后才去算动作点、装命中测试拦截器。外层 _retryAction() 首轮打 attempting <动作> action,之后每一轮打 retrying <动作> action,等待间隔非零时再补一行 waiting <n>ms;某一项没过就打对应的一行:element is not visible、element is outside of the viewport、element 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.md与docs/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的断言表里有toBeVisible、toBeEnabled、toBeEditable、toBeInViewport,它们会重试到条件满足。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。
然后是 force。params.md 对它的定义只有一句:「Whether to bypass the actionability checks. Defaults to false.」actionability.md 补了一层:它关掉的是非必要检查,例如给 click 传真值 force 后,不会再检查目标元素是否真的收到点击事件。
代价在源码里看得更清楚,有两层:
_performPointerAction()里if (!force)才会调checkElementStates——一旦 force,visible、enabled、stable 三项一起跳过,不是只跳过某一项;接着 force 分支只打一句forcing action,命中测试拦截器那一段整块不执行。也就是说,遮挡问题不会消失,只是不再报错了,点击落到谁身上取决于页面。- 更容易被忽略的一层在
_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 stable → done scrolling,中间没有反复重试。断言层面则用 toBeVisible() / toBeEnabled() 这类会重试的断言替掉 isVisible() 之类的瞬时读取。
什么情况说明不是这个原因
- 报的是 strict mode violation,不是超时。
actionability.md列的第一条前提就是「locator 解析到恰好一个元素」,多匹配属于定位器问题,跟五项检查无关。 - 元素在动作过程中从 DOM 脱离。
docs/src/api/class-locator.md的click细节里写明,元素在动作期间任何时刻从 DOM 脱离,方法都会抛错;源码里对应error:notconnected,这条不走重试等待那套逻辑。 - 失败的动作是
press、focus、dispatchEvent、setInputFiles。 表里这几行五项全是-,压根不做可操作性检查。 - 日志已经走过全部检查、卡在动作之后。
click的步骤在文档里有四步,最后一步是等待由此触发的导航成功或失败(除非设了noWaitAfter)。日志停在这里说明问题在导航,不在元素状态。 - 对
<div>调fill()收到的是那条[aria-readonly]报错。 那是元素类型不对,不是「没等够」。
最后提醒一句:日志文案、内部重试节奏、状态名这些都属于实现细节,本文引的是仓库当前代码里的写法,随版本会变。真正稳定的是 actionability.md 里那张表和五项定义——排查时先回去对那张表,再去读日志。
本文依据 github.com/microsoft/playwright 仓库于 2026-08-18 的公开内容整理,
事实来自仓库内的文档与源码。我们没有对文中涉及的功能做过实测,
因此不涉及运行速度、稳定性与实际表现的任何描述。
该项目迭代频繁,文中涉及的 API 签名、配置项与默认值随版本变动,请以仓库最新内容为准。
本文不涉及浏览器版本矩阵与版本清单,相关信息请以官方发布说明为准。