Playwright 的 click 做哪些检查:actionability 拆开看

2026-08-18

用 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 或横杠。这张表值得记住的不是每一格,而是它的形状:不是所有动作都跑全部五项hoverdragTo 不查 Enabled;screenshot 只查 Visible 和 Stable;fillclear 查 Visible、Enabled、Editable,但不查 Stable 与 Receives Events;selectText 只查 Visible;scrollIntoViewIfNeeded 只查 Stable。表格末尾还有一整排全是横杠的动作:blurdispatchEventfocuspresspressSequentiallysetInputFiles——这几个一项都不查。

这一排横杠是最容易被忽略又最容易咬人的地方。press 不做可操作性检查,意味着你对一个被浮层盖住的输入框按键,不会得到”被遮挡”的报错。同理,dispatchEvent 是直接派发事件,不走真实指针。

沿代码走一遍:检查发生在哪几个位置

服务端这条链路在 packages/playwright-core/src/server/dom.ts 里。指针类动作走 _retryPointerAction,它包在通用的 _retryAction 外面。_retryAction 的结构是一个 while 循环:每一轮先打一行 attempting <动作名> action,重试时改打 retrying <动作名> action 并等待一小段时间(间隔逐轮拉长,源码注释写明最长到 500ms;这是仓库当前代码里的值,随版本可能变动),然后调用真正的动作函数,拿回一个结果码。

结果码是一个联合类型 PerformActionResult,取值包括 error:notvisibleerror:notinviewporterror:notconnectederror:optionsnotfounderror:optionnotenabled,以及两个对象形态:{ missingState }{ hitTargetDescription }。前面那些报错日志就是在这里生成的——element is not visible 对应 error:notvisibleelement 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 里。顺序是这样的:

  1. 如果元素在子 frame 里,先尽力滚动一次,把 iframe 带进视口;
  2. 状态检查:只要没设 force,就把一组状态名交给页面里的注入脚本核对;
  3. 滚动元素进视口(scroll 选项设成 'none' 时跳过这一步);
  4. 计算点击点:有 position 就走 _offsetPoint,否则走 _clickablePoint
  5. 命中目标检查:没设 force 时,逐层核对 iframe 的点位,再装上命中拦截器。

注意第 2 步和第 5 步是分开的两处。Receives Events 这一项并不在状态检查里,它排在滚动和取点之后——因为要先有坐标才能问”这个坐标上到底是谁”。

状态检查内部:stable 被提到了最前面

第 2 步传进去的数组,在源码里写得很直白:

const elementStates: ElementState[] = waitForEnabled ? ['visible', 'enabled', 'stable'] : ['visible', 'stable'];

waitForEnabled 是各个动作调用 _retryPointerAction 时传的布尔量。仓库里 clickdblclicktaptruehoverdropfalse——这正好和文档表格里 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 判定

Visibledocs/src/actionability.md 的定义是:元素有非空的 bounding box,且计算样式里没有 visibility:hidden。文档特意列了三条推论:零尺寸元素算可见;display:none 算可见;opacity:0 可见。最后一条经常让人意外——你用透明度做的淡出遮罩,在 Playwright 眼里是可见元素。代码在 packages/injected/src/domUtils.tsisElementVisible,它调 computeBox,最终判据是 rect.width > 0 && rect.height > 0

Stable。文档定义是”连续两个动画帧内 bounding box 保持不变”。代码里的 _checkElementIsStablerequestAnimationFrame 轮询,每帧取 getBoundingClientRect(),和上一帧比 x、y、width、height 四个值,一旦不同立刻判定为不稳定,相同则累加计数器,累计到 _stableRafCount 才算通过。这个计数不是写死在注入脚本里的,它在 packages/playwright-core/src/server/dom.ts 构造注入脚本时,从 page delegate 的 rafCountForStablePosition() 取来。

翻开各个 delegate 的实现,这里藏着一条和操作系统有关的分支:crPage.tsffPage.tsbidiPage.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.tsgetAriaDisabled,写法是 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。文档的说法是:元素是动作点上指针事件的命中目标。代码里 expectHitTargethitPointelementsFromPoint,取最内层元素,然后沿 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.mdforce 的原文是:是否绕过可操作性检查,默认 false(这是仓库当前文档里的默认值,随版本可能变动)。docs/src/actionability.md 补了一句更精确的说法——它关掉的是”非必要”的检查,例如给 Locator.click 传真值 force,就不会再检查目标元素是否真的接收点击事件。

对上源码,force 的效果有两层,第二层更隐蔽:状态检查和命中目标检查都被跳过;同时在 _retryAction 里,error:notvisibleerror:notinviewport 这些结果码不再进入重试循环,而是直接抛出不可恢复错误。也就是说 force 不只是”少查几项”,它还把”等一等再试”这个行为一并关掉了。

trial 是反过来的那一半:文档写明它只跑可操作性检查、跳过动作本身,默认 false,适合用来等元素就绪但不真的操作。带修饰键的那个版本额外注明,modifiers 无论 trial 与否都会被按下,以便测试那些只在按住某键时才显示的元素。

还有一个和检查顺序相关的选项:scroll。文档说它控制动作前是否滚动元素进视口,默认 'auto',设成 'none' 时不滚动,元素若不在视口内动作直接失败——用来断言”用户不用额外滚动就能够到这个元素”。这个选项在 docs/src/api/class-locator.mdLocator.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 签名、配置项与默认值随版本变动,请以仓库最新内容为准。 本文不涉及浏览器版本矩阵与版本清单,相关信息请以官方发布说明为准。

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