Playwright 的 click 做哪些检查:actionability 拆开看
用 Playwright 写测试,迟早会撞上一条超时报错,里面夹着几行看起来很像人话的日志:
attempting click action
waiting for element to be visible, enabled and stable
element is not stable
retrying click action
waiting 100ms
或者是另一种:
<div class="overlay">…</div> intercepts pointer events
这些句子不是随手写的提示文案,它们逐字对应着源码里的分支。把这条路径走一遍,以后看到报错就知道卡在第几步了。
文档层面的口径:五项检查,按动作发放
仓库里 docs/src/actionability.md 是这套机制的总说明。它先给了一个例子:对 Locator.click,Playwright 会确保 locator 恰好解析到一个元素、元素是 Visible、是 Stable(不在动画中或动画已结束)、Receives Events(没有被其它元素遮住)、是 Enabled。
然后是一张表,横向是五项检查——Visible、Stable、Receives Events、Enabled、Editable,纵向是各个动作方法,格子里写 Yes 或横杠。这张表值得记住的不是每一格,而是它的形状:不是所有动作都跑全部五项。hover 和 dragTo 不查 Enabled;screenshot 只查 Visible 和 Stable;fill 和 clear 查 Visible、Enabled、Editable,但不查 Stable 与 Receives Events;selectText 只查 Visible;scrollIntoViewIfNeeded 只查 Stable。表格末尾还有一整排全是横杠的动作:blur、dispatchEvent、focus、press、pressSequentially、setInputFiles——这几个一项都不查。
这一排横杠是最容易被忽略又最容易咬人的地方。press 不做可操作性检查,意味着你对一个被浮层盖住的输入框按键,不会得到”被遮挡”的报错。同理,dispatchEvent 是直接派发事件,不走真实指针。
沿代码走一遍:检查发生在哪几个位置
服务端这条链路在 packages/playwright-core/src/server/dom.ts 里。指针类动作走 _retryPointerAction,它包在通用的 _retryAction 外面。_retryAction 的结构是一个 while 循环:每一轮先打一行 attempting <动作名> action,重试时改打 retrying <动作名> action 并等待一小段时间(间隔逐轮拉长,源码注释写明最长到 500ms;这是仓库当前代码里的值,随版本可能变动),然后调用真正的动作函数,拿回一个结果码。
结果码是一个联合类型 PerformActionResult,取值包括 error:notvisible、error:notinviewport、error:notconnected、error:optionsnotfound、error:optionnotenabled,以及两个对象形态:{ missingState } 和 { hitTargetDescription }。前面那些报错日志就是在这里生成的——element is not visible 对应 error:notvisible,element is not ${result.missingState} 对应 { missingState },${result.hitTargetDescription} intercepts pointer events 对应 { hitTargetDescription }。拿到这几种结果都不抛错,而是 continue 进下一轮,直到 timeout 耗尽。
每一轮开头(除了被标记 skipActionPreChecks 的情况,以及设了 force 的时候)还会调一次 performActionPreChecks。这个方法在 packages/playwright-core/src/server/page.ts 里,做三件事:等待可能进行中的导航结束、跑一遍 locator handler 检查点、再等一次导航(源码注释说明第二次是防止 handler 自身触发了导航)。也就是说,“等页面跳转完”这一步严格来说不属于那五项检查,它排在五项之前。
真正的元素检查在 _performPointerAction 里。顺序是这样的:
- 如果元素在子 frame 里,先尽力滚动一次,把 iframe 带进视口;
- 状态检查:只要没设
force,就把一组状态名交给页面里的注入脚本核对; - 滚动元素进视口(
scroll选项设成'none'时跳过这一步); - 计算点击点:有
position就走_offsetPoint,否则走_clickablePoint; - 命中目标检查:没设
force时,逐层核对 iframe 的点位,再装上命中拦截器。
注意第 2 步和第 5 步是分开的两处。Receives Events 这一项并不在状态检查里,它排在滚动和取点之后——因为要先有坐标才能问”这个坐标上到底是谁”。
状态检查内部:stable 被提到了最前面
第 2 步传进去的数组,在源码里写得很直白:
const elementStates: ElementState[] = waitForEnabled ? ['visible', 'enabled', 'stable'] : ['visible', 'stable'];
waitForEnabled 是各个动作调用 _retryPointerAction 时传的布尔量。仓库里 click、dblclick、tap 传 true,hover 和 drop 传 false——这正好和文档表格里 hover 那一行 Enabled 是横杠对得上。文档和代码在这一点上是一致的,可以互相当校验。
但数组顺序不等于执行顺序。packages/injected/src/injectedScript.ts 里的 checkElementStates 做了一件事:先单独判断 stable,再按数组顺序遍历剩下的状态。所以哪怕你在数组里把 'stable' 写在最后,它也是第一个被检查的。这个细节的实际后果是:一个正在做入场动画的按钮,你拿到的报错会是 element is not stable,而不是 element is not enabled,即使它此刻确实还是 disabled。
其余状态走同一个入口 elementState(node, state)。这个方法开头有个容易忽略的分支:判断 visible / hidden 时用 retarget(node, 'none'),判断其它状态时用 'follow-label'——也就是说,对着一个 <label> 查 Enabled,实际查的是它关联的表单控件。
五项检查各自的 DOM 判定
Visible。docs/src/actionability.md 的定义是:元素有非空的 bounding box,且计算样式里没有 visibility:hidden。文档特意列了三条推论:零尺寸元素不算可见;display:none 不算可见;opacity:0 算可见。最后一条经常让人意外——你用透明度做的淡出遮罩,在 Playwright 眼里是可见元素。代码在 packages/injected/src/domUtils.ts 的 isElementVisible,它调 computeBox,最终判据是 rect.width > 0 && rect.height > 0。
Stable。文档定义是”连续两个动画帧内 bounding box 保持不变”。代码里的 _checkElementIsStable 用 requestAnimationFrame 轮询,每帧取 getBoundingClientRect(),和上一帧比 x、y、width、height 四个值,一旦不同立刻判定为不稳定,相同则累加计数器,累计到 _stableRafCount 才算通过。这个计数不是写死在注入脚本里的,它在 packages/playwright-core/src/server/dom.ts 构造注入脚本时,从 page delegate 的 rafCountForStablePosition() 取来。
翻开各个 delegate 的实现,这里藏着一条和操作系统有关的分支:crPage.ts、ffPage.ts、bidiPage.ts 里的 rafCountForStablePosition() 都返回 1,而 wkPage.ts 里写的是 process.platform === 'win32' ? 5 : 1(这些是仓库当前代码里的值,随版本可能变动)。计数为 1 时,第一帧记下矩形、第二帧比对相同就通过,正好对应文档里说的”两帧”;把文档那句话和这段代码放在一起看,文档描述的是计数为 1 的那种情形。
紧挨着的一行注释是同一条线上的:// Drop frames that are shorter than 16ms - WebKit Win bug.——当 _stableRafCount > 1 且距上一帧不足 15ms 时,这一帧被丢弃不计。注意这个条件只有在上面那个分支给出大于 1 的计数时才成立。所以”同一段动画,稳定判定要求的帧数在不同浏览器上不一样”这件事,是仓库代码里白纸黑字写着的;至于它在你的用例上具体会带来什么差别,我们没有跑过,不做判断。
Enabled。文档的定义是”不是 disabled”,并列了三种 disabled 情形:带 [disabled] 属性的 <button>、<select>、<input>、<textarea>、<option>、<optgroup>;上述元素属于一个带 [disabled] 的 <fieldset>;或者它是某个带 [aria-disabled=true] 元素的后代。代码在 packages/injected/src/roleUtils.ts 的 getAriaDisabled,写法是 isNativelyDisabled(element) || hasExplicitAriaDisabled(element),注释里点明 aria-disabled 会作用于所有后代,所以要沿祖先链往上查。
Editable。定义是 enabled 且非 readonly。getReadonly 的逻辑分三档:INPUT / TEXTAREA / SELECT 看有没有 readonly 属性;如果元素的 aria role 属于支持 aria-readonly 的那一类,看 aria-readonly 是不是 'true';contenteditable 元素直接算非只读。都不匹配时返回 'error',上层会抛出一条明确的错误,说这个元素不是 <input>、<textarea>、<select> 或 [contenteditable],也没有允许 [aria-readonly] 的 role。所以对一个 <div> 调 fill,你拿到的不是超时,是这条即时报错。
Receives Events。文档的说法是:元素是动作点上指针事件的命中目标。代码里 expectHitTarget 拿 hitPoint 调 elementsFromPoint,取最内层元素,然后沿 assignedSlot 或 shadow host 一路往上爬,看能不能爬到目标元素;爬到了返回 'done',爬不到就把挡路的那个元素做成 hitTargetDescription 返回。这个实现专门处理了 shadow DOM——它会先把从目标元素到文档的所有 shadow root 收集起来,从最外层往内逐层核对。嵌套 iframe 的情形则由 _checkFrameIsHitTarget 逐层换算坐标。
顺带说一处非常规的失败码:_clickablePoint 会把元素的 quads 和视口求交,过滤掉面积接近零的部分,如果一个都不剩,返回 error:notinviewport,日志是 element is outside of the viewport。这条和”不可见”是两回事,别混。
force 和 trial 各自关掉了什么
docs/src/api/params.md 里 force 的原文是:是否绕过可操作性检查,默认 false(这是仓库当前文档里的默认值,随版本可能变动)。docs/src/actionability.md 补了一句更精确的说法——它关掉的是”非必要”的检查,例如给 Locator.click 传真值 force,就不会再检查目标元素是否真的接收点击事件。
对上源码,force 的效果有两层,第二层更隐蔽:状态检查和命中目标检查都被跳过;同时在 _retryAction 里,error:notvisible、error:notinviewport 这些结果码不再进入重试循环,而是直接抛出不可恢复错误。也就是说 force 不只是”少查几项”,它还把”等一等再试”这个行为一并关掉了。
trial 是反过来的那一半:文档写明它只跑可操作性检查、跳过动作本身,默认 false,适合用来等元素就绪但不真的操作。带修饰键的那个版本额外注明,modifiers 无论 trial 与否都会被按下,以便测试那些只在按住某键时才显示的元素。
还有一个和检查顺序相关的选项:scroll。文档说它控制动作前是否滚动元素进视口,默认 'auto',设成 'none' 时不滚动,元素若不在视口内动作直接失败——用来断言”用户不用额外滚动就能够到这个元素”。这个选项在 docs/src/api/class-locator.md 里 Locator.click.scroll 条目下标着 since: v1.62。
写个组合示例:
// 只做检查、不真的点,且不允许自动滚动
await page.getByRole('button', { name: 'Sign Up' }).click({ trial: true, scroll: 'none' });
以上为按仓库代码中的接口语义组合的示例,未经实测,以仓库最新代码为准。
回头看那条报错
再看开头那几行日志,现在能读出位置了:waiting for element to be visible, enabled and stable 说明这是一个 waitForEnabled 为真的指针动作,卡在第 2 步;element is not stable 说明 stable 被提前判定失败,此刻 Enabled 还没轮到;而 intercepts pointer events 属于第 5 步,说明元素已经通过了状态检查、坐标也算出来了,是被别的东西挡住了。这两类报错的修法完全不同:前者要等动画或关掉动画,后者要去找那个 hitTargetDescription 里被打印出来的元素。
顺带一提,这一切的前提是 locator 先解析到恰好一个元素。docs/src/locators.md 的 strictness 一节写明 locator 是严格的,匹配到多个元素时操作会抛出 strict mode 违规错误——那是发生在可操作性检查之前的一道门,和上面五项不是一回事。
本文依据 github.com/microsoft/playwright 仓库于 2026-08-18 的公开内容整理,
事实来自仓库内的文档与源码。我们没有对文中涉及的功能做过实测,
因此不涉及运行速度、稳定性与实际表现的任何描述。
该项目迭代频繁,文中涉及的 API 签名、配置项与默认值随版本变动,请以仓库最新内容为准。
本文不涉及浏览器版本矩阵与版本清单,相关信息请以官方发布说明为准。