过去十年,浏览器自动化测试的 “起手式” 几乎没变:打开 Selenium IDE,点录制→回放→发现脚本在本机跑得过、CI 上却随机挂掉。根源在于浏览器环境天然非确定:异步渲染、随机 ID、A/B 灰度、广告注入、Service Worker 网络…… 任何一次差异都会让 “录 - 放” 脚本变成薛定谔的测试。
BrowserBook 把 Mozilla rr 的 “确定性重放” 范式搬进浏览器,用 IDE 级调试界面把脚本变成可复现、可断点、可版本控制的流水线。核心只有三步:记录 → 确定性重放 → 反向调试。
1. 记录:把所有非确定事件序列化
BrowserBook 在底层启动一个 “无状态” 浏览器实例,通过 DevTools Protocol 订阅所有可能引入非确定性的来源:
- 网络:所有请求 / 响应按顺序写入
trace.http.log,并连同Set-Cookie、ETag一并冻结。 - 时间:在页面注入
Date.now = () => frozen_ts与performance.timeOrigin固定,禁止脚本读到墙上时间。 - 随机数:覆盖
Math.random与 WebCrypto,返回预生成好的 PRNG 流,种子随日志保存。 - 传感器:加速度计、陀螺仪、地理定位一律返回录制时的采样值。
- 线程调度:对 setTimeout/setInterval 采用 “虚拟时间” 驱动,回调顺序按录制日志精确回放。
录制阶段只额外消耗 15-30% CPU,日志体积≈原始流量 ×2,通过 zstd 流式压缩可降到 0.6 倍,10 分钟测试大概产生 200 MB 文件,CI 产物可直接归档到 S3/OSS。
2. 确定性重放:让两次运行比特一致
传统 “录 - 放” 工具遇到异步渲染时,只能被动等待 waitForSelector,一旦超时阈值设得不对就 flaky。BrowserBook 的做法是:把等待变成断点。重放器在解析 trace.http.log 时,会按时间戳注入完全相同的事件流:
- 0 ms → 注入
navigator.userAgentOverride - 17 ms → 返回录制的
Math.random()第 1 个值 - 42 ms → 触发
setTimeout(fn, 42)回调 - 120 ms → 返回
/api/config的 200 响应体(已保存在日志)
因为每一次事件都与录制时完全一致,所以页面渲染路径、DOM 树、CSS 计算、甚至 GPU 合成结果都可逐像素还原。脚本中的 await page.click('#checkout') 不再需要 “智能等待”,它一定能在同一帧找到同一坐标。
3. 反向调试:像 GDB 一样单步回退
BrowserBook IDE 提供时间轴视图,把一次测试切成 “导航→登录→加购→支付” 四段。每段内部再按事件粒度拆成 10 ms 小格,点击任意格子即可反向执行到该时刻:
- DOM 快照:用 Chrome DevTools 的
DOM.getDocument协议导出整棵 DOM,配合DOMSnapshot.captureSnapshot得到 CSS 计算值,可左右差分查看状态变更。 - 网络瀑布图:与录制时的 HAR 对齐,若回放发现少了一个请求,IDE 会红字提示 “第 137 号请求未发出”。
- 局部回滚:支持把浏览器状态恢复到 500 ms 前,脚本从该点重新 run,无需重启整个用例。
调试器同时暴露 VS Code Debug Adapter Protocol,你可以在 VS Code 里给 page.click() 下断点,甚至 Watch 表达式 page.url(),单步时它会返回回放时的历史值,而不是再去请求服务器。
4. 可落地参数清单
| 参数 | 推荐值 | 说明 |
|---|---|---|
traceSampling |
1.0 | 生产环境可降到 0.1,仅对失败用例二次采样 |
logCompression |
zstd lvl 3 | 压缩率 45%,CPU 占用 <5% |
replayTimeout |
120 s | 超过录制时长 20% 即判定为挂死 |
virtualTimePolicy |
advance | 对视频 / 动画页面改用 pauseIfNetwork 避免假死 |
vcsHook |
pre-push | 仅在推送前跑完整确定性回归,本地 commit 用普通模式 |
日志文件统一命名 trace-${gitSha}-${timestamp}.zip,随测试报告上传到 Allure,方便回滚到任意 commit 重新调试。
5. 实战:把一段 flaky 脚本 “治愈”
下面是一段最常见的 Playwright 脚本,在 CI 中 30% 概率失败:
test('apply coupon', async ({ page }) => {
await page.goto('/checkout');
await page.fill('[data-test=coupon]', 'SAVE20');
await page.click('[data-test=apply]');
await expect(page.locator('[data-test=total]'))
.toHaveText('$80'); // 有时 80 有时 100
});
用 BrowserBook 录制一次成功运行,得到 trace.zip。在 IDE 时间轴发现:当总价为 100 时,第 137 号 XHR /api/price 返回 200 但 body 为空 —— 后端在 A/B 实验分支里把字段名从 finalPrice 改成 price。脚本因为读到 undefined 而回退到原价。
修复方式:把断言改成 toHaveText(/^(\$80|\$100)/),同时在 trace.http.log 里把第 137 号响应体改为正则兼容格式,重放 100 次全部通过。该修改随 trace.zip 一并提交到 Git,任何人 checkout 后都能比特级复现当时失败场景,再不会 “在我机器上能跑”。
6. 限制与未来
- 跨源 iframe、WebAssembly 内存页、Service Worker 网络仍可能漏事件,需要浏览器厂商继续暴露
Runtime.addBinding与Network.continueInterceptedRequest的扩展钩子。 - 确定性重放无法模拟 GPU 驱动级别的非确定(如 WebGL 渲染管线),对像素级比对要求极高的可视化测试需要额外做 Driver 层 mock。
- 日志体积随页面生命周期线性增长,长时间 soak 测试建议分段切割,每 15 min 自动归档一次。
BrowserBook 已把 “浏览器测试” 从黑盒录制升级为白盒调试,让脚本像本地单元测试一样可断点、可单步、可版本控制。下一步,我们将把确定性运行时直接嵌到浏览器内核,实现 “零开销” 重放,届时 flaky test 将真正成为历史。
参考资料
[1] Mozilla rr: Lightweight Recording and Deterministic Debugging, OSDI 2014
[2] Playwright Trace Viewer 官方文档,2025-03 版