E2E 里有两类步骤。一类是「找到元素点一下」,简单直接;另一类是文件上传、滚动、作用域定位——它们有状态、有时序、有副作用,写错了往往不会当场红,而是重试到超时,或者安静地「假成功」。
这篇把我自己踩过、以及在一次真跑里观察到的坑整理一下。三个动作,三种不同的坑法。
上传写起来就两个参数:upload: { fileIds, mode },mode 决定交互方式:
| 模式 | 定位谁 | 干什么 |
|---|---|---|
input |
唯一的 input[type=file](可以是隐藏的,不能是禁用) |
直接 setInputFiles 塞文件 |
chooser |
上传按钮(人点的那颗) | 先监听文件选择事件,再点击,然后设置文件 |
第一个坑就在 chooser:这个动作已经包含点击了。如果前面又加一步「点击上传按钮」,页面会弹出两次文件选择窗口,第二步必然超时——而且报错信息通常很难看懂(它在等一个永远不会出现的 chooser)。
其他几条边界:
fileIds 是 1~5 个不同的文件,单文件最多 20MB,多文件要求控件支持 multiple;change 只是「文件被塞进去了」,后面必须断言文件列表、导入记录或需求要求的结果。这一点特别容易被「假成功」骗到——页面上文件名都显示出来了,服务端其实没收;
如果要跨项目复用(把用例连文件一起交给别人),含上传的 .testcase 要升到 version: 2,顶层带上 files,每份资源是 {id, name, mime, size, sha256, data}:data 是原始字节的标准 base64(不带 data: 前缀)、size 是原始字节数、sha256 是同一份字节的小写十六进制。这三项用代码算,别手写——导入时会真的校验大小、base64 和 SHA-256,然后在目标项目里重建文件 ID 并重写步骤引用。
滚动步骤是 scroll: { target, mode, axis?, distance? }:
| target | 要不要定位器 | 可用 mode |
|---|---|---|
page |
不要 |
by / toStart / toEnd
|
container |
要:唯一且可见的实际滚动容器 |
by / toStart / toEnd
|
element |
要:唯一、可见、已挂载(可在视口外) | 只有 intoView
|
axis 默认 y;by 必须给非零 distance,范围 -10000~10000 px,正数向下/右;其他模式不要写 distance,intoView 也不要写 axis。
四个容易走偏的地方:
scrollIntoViewIfNeeded,不等于鼠标滚轮(wheel)测试,两者别混;toEnd 只到当前内容边界,不会自动遍历无限列表;虚拟列表目标还没挂载时要逐段滚并查新状态,连续不动就停下来看加载状态或定位器,不要盲重试;scrollable=x/y/xy、容器位置尺寸),改了距离/目标/模式,之前那次「通过」就作废,得重跑。
作用域的写法是给主定位器挂一个父容器:
{
"strategy": "role", "value": "button", "role": "button", "name": "确定",
"scope": { "strategy": "role", "value": "dialog", "role": "dialog", "name": "导入客户" }
}
等价于 page.getByRole('dialog',{name:'导入客户'}).getByRole('button',{name:'确定'})。引擎里就是一句「换根」:
const root = loc.scope ? resolveQuery(page, loc.scope) : page;
理解这一点,下面几条就好记了:
.first() 去掩盖多匹配;body 上,视觉上「在弹窗里」,DOM 上不在——按视觉位置套弹窗作用域就会查不到;testid / role / label / placeholder / text / alt / title / css,不支持 xpath、response、websocket,也不支持递归 scope;scope.value / scope.name 同样支持 {{变量}},本地预校验也必须替换,只换主定位器是不够的;界面上的对应操作是:步骤表里可以添加、展开编辑、折叠、单独拾取、删除作用域。拾取作用域只更新父容器并保留主目标;删除作用域也保留主目标,只是恢复成页面级查询。

技能包里对这三类动作有专项参考,而且明确要求:简化 smoke 模板不支持 upload/scroll,必须实现等价语义,不能跳过之后记成通过。所以我写了个本地固件页面(一个文件输入 + 一个可滚动容器 + 一个带同名干扰按钮的弹窗),按参考的语义真跑了一遍:
上传文件: customers.csv | size = 69 | sha256 = 4d191591cea4ca26…
用例: 三大难动作冒烟 | 目标: hard-actions.html
[1] upload(chooser) -> PASSED (监听 filechooser → 点击 → setFiles,随后断言业务结果「上传成功:customers.csv」)
[2] scroll(container,toEnd) -> PASSED (scrollTop 0 → 1933,末条「客户记录 060」可见 = true)
[3] scope(role=dialog[name=导入客户] → button[确定]) -> PASSED (页面级同名按钮匹配 2 个;父容器唯一 1 个;容器内目标 1 个;点击后命中提示 = true)
[4] 反例:不用 scope、直接取页面级 .first() -> 页面级匹配 2 个;结果:<div id="mask" class="on">…</div> intercepts pointer events
结果: 三类动作语义验证通过,截图已保存到 shots/
第 4 条是我顺手加的反例,也是这次最有意思的发现:不用作用域时,页面级 getByRole('button',{name:'确定'}).first() 匹配到的是弹窗外面那颗按钮,而它正被遮罩盖着——点击会一直等「可点击」,重试到超时才失败。
定位歧义的表现不一定是「点错元素」,还可能是「元素明明看得见、却被浮层拦截,点击一路重试到超时」。
真实项目里这类现象很常见:想点的按钮就在眼前,日志里却是 intercepts pointer events、element is not stable 这一类话。

upload、scroll、assert 都被排除在外——自愈是给「元素挪了位置」准备的,而上传/滚动语义一改,验收证据就不成立;断言更不能靠换目标把失败改成通过。所以这三类只能一次写对,别指望兜底。

mode 选对了吗?chooser 前面是不是又加了一次点击?断言到业务结果了吗(还是只断言文件选中)?version: 2 了吗?size/sha256 是从真实字节算的吗?fileIds 与资源一一对应吗?target 是哪个?by 的 distance 非零且在范围里吗?element 是不是用的 intoView?你们的上传用例是怎么断言「真的传上去了」的?有没有被 portal 弹窗(下拉、气泡挂在 body 上)坑过?评论区聊聊,我也想看看别人家的处理方式。
参考: