用 Promise.all 修好 Playwright 里 flaky 的下载、弹窗和上传测试
一个下载测试连过十次,第十一次却变红,报 Timeout waiting for event "download"。应用没人动过,是这个测试在跟自己抢时间。
这种情况出现在三条常见流程上:一个会下载文件的按钮,一个 "Sign in with Google" 弹窗,一个上传控件背后的文件选择器。它们变 flaky 的原因都一样,修法也是同一行结构。
监听器为什么会输掉这场比赛
照着脑子里读到的顺序去写一个下载测试,你会得到这样的代码:
await page.getByRole('button', { name: 'Export CSV' }).click();
const download = await page.waitForEvent('download');
先点击,再等待。问题出在时序上。click() 触发下载,而一个够快的下载,可能在下一行注册监听器之前就已经完成了。一旦这样,waitForEvent('download') 就在等一个早已触发过的事件,于是你只能干等到 30 秒超时。在慢一点的 CI 机器上,这个顺序翻转得够频繁,失败看起来就像是随机的。
弹窗和文件选择器的套路一模一样。click() 打开弹窗,popup 事件触发,而你的 waitForEvent('popup') 慢了半拍才到。
把监听器和动作配成一对
在点击之前先注册监听器,然后两者一起 await:
const [download] = await Promise.all([
page.waitForEvent('download'),
page.getByRole('button', { name: 'Export CSV' }).click(),
]);
Promise.all 先起监听器,再触发点击。事件溜不掉了,因为它发生时监听器已经在跑。弹窗和上传用的是同一套配对:
const [popup] = await Promise.all([
page.waitForEvent('popup'),
page.getByRole('button', { name: 'Continue with Google' }).click(),
]);
const [chooser] = await Promise.all([
page.waitForEvent('filechooser'),
page.getByText('Upload avatar').click(),
]);
await chooser.setFiles('avatar.png');
让 Hover 替你写好这个配对
规则你现在懂了,可每测一次下载、弹窗和上传,你还是得记着它。这一步 Hover 帮你省掉了。
当你 crystallize 一次会话时,Hover 那个确定性翻译器已经把弹窗和新标签页流程直接输出成 Promise.all 配对了。碰到下载或文件上传,它会在对应位置留一个 // hover:optimizable 标记,而可选的优化 pass 会把它补齐,用的正是 Hover 内置的已验证示例种子里那个精确配对。你读一遍 diff,留下即可。最终发布的 spec 是纯粹的 Playwright,竞态在设计层面就已经排除掉了。
在你自己的应用上试试 Hover。
把 Hover 的 MCP 加到你已经在用的编码 agent。它探索你的应用,结晶出你自己拥有的纯 Playwright spec。
npm i -g @hover-dev/mcp && claude mcp add hover -- hover-mcp阅读快速开始 →