Hotdry.

Article

BrowserBook:把浏览器自动化脚本变成可复现、可断点、可版本控制的确定性流水线

借助确定性重放与反向调试,让浏览器脚本像本地代码一样可断点、可单步、可 watch,彻底告别 flaky test。

2025-12-11application-security

过去十年,浏览器自动化测试的 “起手式” 几乎没变:打开 Selenium IDE,点录制→回放→发现脚本在本机跑得过、CI 上却随机挂掉。根源在于浏览器环境天然非确定:异步渲染、随机 ID、A/B 灰度、广告注入、Service Worker 网络…… 任何一次差异都会让 “录 - 放” 脚本变成薛定谔的测试。

BrowserBook 把 Mozilla rr 的 “确定性重放” 范式搬进浏览器,用 IDE 级调试界面把脚本变成可复现、可断点、可版本控制的流水线。核心只有三步:记录确定性重放反向调试

1. 记录:把所有非确定事件序列化

BrowserBook 在底层启动一个 “无状态” 浏览器实例,通过 DevTools Protocol 订阅所有可能引入非确定性的来源:

  • 网络:所有请求 / 响应按顺序写入 trace.http.log,并连同 Set-CookieETag 一并冻结。
  • 时间:在页面注入 Date.now = () => frozen_tsperformance.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 时,会按时间戳注入完全相同的事件流:

  1. 0 ms → 注入 navigator.userAgentOverride
  2. 17 ms → 返回录制的 Math.random() 第 1 个值
  3. 42 ms → 触发 setTimeout(fn, 42) 回调
  4. 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.addBindingNetwork.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 版

application-security