@monoes/monobrowse 1.0.11 → 1.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (380) hide show
  1. package/dist/src/__tests__/adapters.test.js +5 -2
  2. package/dist/src/__tests__/adapters.test.js.map +1 -1
  3. package/dist/src/__tests__/batch-eval-parsing.test.js +4 -2
  4. package/dist/src/__tests__/batch-eval-parsing.test.js.map +1 -1
  5. package/dist/src/__tests__/box-model.test.js +9 -5
  6. package/dist/src/__tests__/box-model.test.js.map +1 -1
  7. package/dist/src/__tests__/browser-launch.test.d.ts +10 -0
  8. package/dist/src/__tests__/browser-launch.test.d.ts.map +1 -1
  9. package/dist/src/__tests__/browser-launch.test.js +17 -10
  10. package/dist/src/__tests__/browser-launch.test.js.map +1 -1
  11. package/dist/src/__tests__/cdp-client.test.js +9 -3
  12. package/dist/src/__tests__/cdp-client.test.js.map +1 -1
  13. package/dist/src/__tests__/close-browser.test.d.ts +13 -0
  14. package/dist/src/__tests__/close-browser.test.d.ts.map +1 -1
  15. package/dist/src/__tests__/close-browser.test.js +5 -5
  16. package/dist/src/__tests__/close-browser.test.js.map +1 -1
  17. package/dist/src/__tests__/console-log.test.js +17 -6
  18. package/dist/src/__tests__/console-log.test.js.map +1 -1
  19. package/dist/src/__tests__/find.test.js +26 -6
  20. package/dist/src/__tests__/find.test.js.map +1 -1
  21. package/dist/src/__tests__/frame-command.test.js +9 -3
  22. package/dist/src/__tests__/frame-command.test.js.map +1 -1
  23. package/dist/src/__tests__/open-url-navigate-error.test.js +1 -3
  24. package/dist/src/__tests__/open-url-navigate-error.test.js.map +1 -1
  25. package/dist/src/__tests__/output.test.js +1 -1
  26. package/dist/src/__tests__/output.test.js.map +1 -1
  27. package/dist/src/__tests__/profiler-heap.test.d.ts +2 -0
  28. package/dist/src/__tests__/profiler-heap.test.d.ts.map +1 -0
  29. package/dist/src/__tests__/profiler-heap.test.js +154 -0
  30. package/dist/src/__tests__/profiler-heap.test.js.map +1 -0
  31. package/dist/src/__tests__/reap-idle-browser.test.d.ts +15 -0
  32. package/dist/src/__tests__/reap-idle-browser.test.d.ts.map +1 -0
  33. package/dist/src/__tests__/reap-idle-browser.test.js +238 -0
  34. package/dist/src/__tests__/reap-idle-browser.test.js.map +1 -0
  35. package/dist/src/__tests__/ref-cache.test.js +18 -10
  36. package/dist/src/__tests__/ref-cache.test.js.map +1 -1
  37. package/dist/src/__tests__/report-a11y.test.d.ts +2 -0
  38. package/dist/src/__tests__/report-a11y.test.d.ts.map +1 -0
  39. package/dist/src/__tests__/report-a11y.test.js +147 -0
  40. package/dist/src/__tests__/report-a11y.test.js.map +1 -0
  41. package/dist/src/__tests__/report-analyze.test.d.ts +2 -0
  42. package/dist/src/__tests__/report-analyze.test.d.ts.map +1 -0
  43. package/dist/src/__tests__/report-analyze.test.js +157 -0
  44. package/dist/src/__tests__/report-analyze.test.js.map +1 -0
  45. package/dist/src/__tests__/report-budget.test.d.ts +2 -0
  46. package/dist/src/__tests__/report-budget.test.d.ts.map +1 -0
  47. package/dist/src/__tests__/report-budget.test.js +80 -0
  48. package/dist/src/__tests__/report-budget.test.js.map +1 -0
  49. package/dist/src/__tests__/report-diff.test.d.ts +2 -0
  50. package/dist/src/__tests__/report-diff.test.d.ts.map +1 -0
  51. package/dist/src/__tests__/report-diff.test.js +231 -0
  52. package/dist/src/__tests__/report-diff.test.js.map +1 -0
  53. package/dist/src/__tests__/report-evidence.test.d.ts +2 -0
  54. package/dist/src/__tests__/report-evidence.test.d.ts.map +1 -0
  55. package/dist/src/__tests__/report-evidence.test.js +214 -0
  56. package/dist/src/__tests__/report-evidence.test.js.map +1 -0
  57. package/dist/src/__tests__/report-flake.test.d.ts +2 -0
  58. package/dist/src/__tests__/report-flake.test.d.ts.map +1 -0
  59. package/dist/src/__tests__/report-flake.test.js +182 -0
  60. package/dist/src/__tests__/report-flake.test.js.map +1 -0
  61. package/dist/src/__tests__/report-history.test.d.ts +2 -0
  62. package/dist/src/__tests__/report-history.test.d.ts.map +1 -0
  63. package/dist/src/__tests__/report-history.test.js +229 -0
  64. package/dist/src/__tests__/report-history.test.js.map +1 -0
  65. package/dist/src/__tests__/report-output.test.d.ts +2 -0
  66. package/dist/src/__tests__/report-output.test.d.ts.map +1 -0
  67. package/dist/src/__tests__/report-output.test.js +169 -0
  68. package/dist/src/__tests__/report-output.test.js.map +1 -0
  69. package/dist/src/__tests__/report-png.test.d.ts +2 -0
  70. package/dist/src/__tests__/report-png.test.d.ts.map +1 -0
  71. package/dist/src/__tests__/report-png.test.js +84 -0
  72. package/dist/src/__tests__/report-png.test.js.map +1 -0
  73. package/dist/src/__tests__/report-render-extras.test.d.ts +2 -0
  74. package/dist/src/__tests__/report-render-extras.test.d.ts.map +1 -0
  75. package/dist/src/__tests__/report-render-extras.test.js +285 -0
  76. package/dist/src/__tests__/report-render-extras.test.js.map +1 -0
  77. package/dist/src/__tests__/report-render.test.d.ts +2 -0
  78. package/dist/src/__tests__/report-render.test.d.ts.map +1 -0
  79. package/dist/src/__tests__/report-render.test.js +143 -0
  80. package/dist/src/__tests__/report-render.test.js.map +1 -0
  81. package/dist/src/__tests__/report-run.test.d.ts +2 -0
  82. package/dist/src/__tests__/report-run.test.d.ts.map +1 -0
  83. package/dist/src/__tests__/report-run.test.js +234 -0
  84. package/dist/src/__tests__/report-run.test.js.map +1 -0
  85. package/dist/src/__tests__/session-state.test.d.ts +8 -0
  86. package/dist/src/__tests__/session-state.test.d.ts.map +1 -1
  87. package/dist/src/__tests__/session-state.test.js +10 -10
  88. package/dist/src/__tests__/session-state.test.js.map +1 -1
  89. package/dist/src/__tests__/snapshot.test.js +44 -14
  90. package/dist/src/__tests__/snapshot.test.js.map +1 -1
  91. package/dist/src/__tests__/storage-eval.test.js +14 -6
  92. package/dist/src/__tests__/storage-eval.test.js.map +1 -1
  93. package/dist/src/__tests__/tabs.test.js +25 -9
  94. package/dist/src/__tests__/tabs.test.js.map +1 -1
  95. package/dist/src/browser/action-builder/analyzer.d.ts +1 -1
  96. package/dist/src/browser/action-builder/analyzer.d.ts.map +1 -1
  97. package/dist/src/browser/action-builder/analyzer.js +25 -9
  98. package/dist/src/browser/action-builder/analyzer.js.map +1 -1
  99. package/dist/src/browser/action-builder/types.d.ts.map +1 -1
  100. package/dist/src/browser/actions.d.ts +1 -1
  101. package/dist/src/browser/actions.d.ts.map +1 -1
  102. package/dist/src/browser/actions.js +16 -6
  103. package/dist/src/browser/actions.js.map +1 -1
  104. package/dist/src/browser/adapters/gemini.d.ts.map +1 -1
  105. package/dist/src/browser/adapters/gemini.js.map +1 -1
  106. package/dist/src/browser/adapters/google.d.ts.map +1 -1
  107. package/dist/src/browser/adapters/google.js.map +1 -1
  108. package/dist/src/browser/adapters/index.d.ts.map +1 -1
  109. package/dist/src/browser/adapters/index.js +3 -3
  110. package/dist/src/browser/adapters/index.js.map +1 -1
  111. package/dist/src/browser/adapters/instagram.d.ts.map +1 -1
  112. package/dist/src/browser/adapters/instagram.js.map +1 -1
  113. package/dist/src/browser/adapters/linkedin.d.ts.map +1 -1
  114. package/dist/src/browser/adapters/linkedin.js.map +1 -1
  115. package/dist/src/browser/adapters/microsoft.d.ts.map +1 -1
  116. package/dist/src/browser/adapters/microsoft.js.map +1 -1
  117. package/dist/src/browser/adapters/x.d.ts.map +1 -1
  118. package/dist/src/browser/adapters/x.js.map +1 -1
  119. package/dist/src/browser/browser.d.ts +30 -0
  120. package/dist/src/browser/browser.d.ts.map +1 -1
  121. package/dist/src/browser/browser.js +122 -15
  122. package/dist/src/browser/browser.js.map +1 -1
  123. package/dist/src/browser/cdp.d.ts.map +1 -1
  124. package/dist/src/browser/cdp.js +21 -8
  125. package/dist/src/browser/cdp.js.map +1 -1
  126. package/dist/src/browser/console-log.d.ts.map +1 -1
  127. package/dist/src/browser/console-log.js +2 -2
  128. package/dist/src/browser/console-log.js.map +1 -1
  129. package/dist/src/browser/dashboard/server.d.ts.map +1 -1
  130. package/dist/src/browser/dashboard/server.js +29 -19
  131. package/dist/src/browser/dashboard/server.js.map +1 -1
  132. package/dist/src/browser/dashboard/ui.html +153 -165
  133. package/dist/src/browser/dialog.d.ts.map +1 -1
  134. package/dist/src/browser/dialog.js +3 -1
  135. package/dist/src/browser/dialog.js.map +1 -1
  136. package/dist/src/browser/emulation.d.ts.map +1 -1
  137. package/dist/src/browser/emulation.js +21 -6
  138. package/dist/src/browser/emulation.js.map +1 -1
  139. package/dist/src/browser/find.d.ts +4 -4
  140. package/dist/src/browser/find.d.ts.map +1 -1
  141. package/dist/src/browser/find.js +14 -6
  142. package/dist/src/browser/find.js.map +1 -1
  143. package/dist/src/browser/har.d.ts.map +1 -1
  144. package/dist/src/browser/har.js +14 -7
  145. package/dist/src/browser/har.js.map +1 -1
  146. package/dist/src/browser/index.d.ts +17 -16
  147. package/dist/src/browser/index.d.ts.map +1 -1
  148. package/dist/src/browser/index.js +17 -16
  149. package/dist/src/browser/index.js.map +1 -1
  150. package/dist/src/browser/network.d.ts.map +1 -1
  151. package/dist/src/browser/network.js +14 -4
  152. package/dist/src/browser/network.js.map +1 -1
  153. package/dist/src/browser/pdf.d.ts.map +1 -1
  154. package/dist/src/browser/pdf.js +3 -3
  155. package/dist/src/browser/pdf.js.map +1 -1
  156. package/dist/src/browser/profiler.d.ts.map +1 -1
  157. package/dist/src/browser/profiler.js +22 -20
  158. package/dist/src/browser/profiler.js.map +1 -1
  159. package/dist/src/browser/record.d.ts +1 -1
  160. package/dist/src/browser/record.d.ts.map +1 -1
  161. package/dist/src/browser/record.js +18 -7
  162. package/dist/src/browser/record.js.map +1 -1
  163. package/dist/src/browser/ref-cache.d.ts.map +1 -1
  164. package/dist/src/browser/ref-cache.js +9 -4
  165. package/dist/src/browser/ref-cache.js.map +1 -1
  166. package/dist/src/browser/screenshot.d.ts.map +1 -1
  167. package/dist/src/browser/screenshot.js +11 -7
  168. package/dist/src/browser/screenshot.js.map +1 -1
  169. package/dist/src/browser/session.d.ts +1 -1
  170. package/dist/src/browser/session.d.ts.map +1 -1
  171. package/dist/src/browser/session.js +27 -8
  172. package/dist/src/browser/session.js.map +1 -1
  173. package/dist/src/browser/snapshot.d.ts +1 -1
  174. package/dist/src/browser/snapshot.d.ts.map +1 -1
  175. package/dist/src/browser/snapshot.js +8 -4
  176. package/dist/src/browser/snapshot.js.map +1 -1
  177. package/dist/src/browser/storage.d.ts.map +1 -1
  178. package/dist/src/browser/storage.js.map +1 -1
  179. package/dist/src/browser/tabs.d.ts.map +1 -1
  180. package/dist/src/browser/tabs.js +9 -3
  181. package/dist/src/browser/tabs.js.map +1 -1
  182. package/dist/src/browser/trace.d.ts.map +1 -1
  183. package/dist/src/browser/trace.js +3 -3
  184. package/dist/src/browser/trace.js.map +1 -1
  185. package/dist/src/browser/types.d.ts.map +1 -1
  186. package/dist/src/browser/types.js +23 -4
  187. package/dist/src/browser/types.js.map +1 -1
  188. package/dist/src/browser/vitals.d.ts.map +1 -1
  189. package/dist/src/browser/vitals.js +1 -1
  190. package/dist/src/browser/vitals.js.map +1 -1
  191. package/dist/src/browser/wait.d.ts.map +1 -1
  192. package/dist/src/browser/wait.js +11 -5
  193. package/dist/src/browser/wait.js.map +1 -1
  194. package/dist/src/cli/action.d.ts.map +1 -1
  195. package/dist/src/cli/action.js +14 -11
  196. package/dist/src/cli/action.js.map +1 -1
  197. package/dist/src/cli/commands.d.ts.map +1 -1
  198. package/dist/src/cli/commands.js +707 -159
  199. package/dist/src/cli/commands.js.map +1 -1
  200. package/dist/src/cli/output.js +5 -5
  201. package/dist/src/cli/output.js.map +1 -1
  202. package/dist/src/cli/platform.d.ts.map +1 -1
  203. package/dist/src/cli/platform.js +15 -11
  204. package/dist/src/cli/platform.js.map +1 -1
  205. package/dist/src/cli/types.d.ts +1 -1
  206. package/dist/src/cli/types.d.ts.map +1 -1
  207. package/dist/src/cli.js +21 -9
  208. package/dist/src/cli.js.map +1 -1
  209. package/dist/src/index.d.ts +2 -2
  210. package/dist/src/index.d.ts.map +1 -1
  211. package/dist/src/index.js +2 -2
  212. package/dist/src/index.js.map +1 -1
  213. package/dist/src/report/a11y.d.ts +52 -0
  214. package/dist/src/report/a11y.d.ts.map +1 -0
  215. package/dist/src/report/a11y.js +231 -0
  216. package/dist/src/report/a11y.js.map +1 -0
  217. package/dist/src/report/analyze.d.ts +20 -0
  218. package/dist/src/report/analyze.d.ts.map +1 -0
  219. package/dist/src/report/analyze.js +136 -0
  220. package/dist/src/report/analyze.js.map +1 -0
  221. package/dist/src/report/budget.d.ts +27 -0
  222. package/dist/src/report/budget.d.ts.map +1 -0
  223. package/dist/src/report/budget.js +143 -0
  224. package/dist/src/report/budget.js.map +1 -0
  225. package/dist/src/report/collect-a11y.d.ts +25 -0
  226. package/dist/src/report/collect-a11y.d.ts.map +1 -0
  227. package/dist/src/report/collect-a11y.js +154 -0
  228. package/dist/src/report/collect-a11y.js.map +1 -0
  229. package/dist/src/report/collect.d.ts +45 -0
  230. package/dist/src/report/collect.d.ts.map +1 -0
  231. package/dist/src/report/collect.js +275 -0
  232. package/dist/src/report/collect.js.map +1 -0
  233. package/dist/src/report/evidence.d.ts +80 -0
  234. package/dist/src/report/evidence.d.ts.map +1 -0
  235. package/dist/src/report/evidence.js +279 -0
  236. package/dist/src/report/evidence.js.map +1 -0
  237. package/dist/src/report/flake.d.ts +88 -0
  238. package/dist/src/report/flake.d.ts.map +1 -0
  239. package/dist/src/report/flake.js +232 -0
  240. package/dist/src/report/flake.js.map +1 -0
  241. package/dist/src/report/history.d.ts +85 -0
  242. package/dist/src/report/history.d.ts.map +1 -0
  243. package/dist/src/report/history.js +176 -0
  244. package/dist/src/report/history.js.map +1 -0
  245. package/dist/src/report/index.d.ts +102 -0
  246. package/dist/src/report/index.d.ts.map +1 -0
  247. package/dist/src/report/index.js +216 -0
  248. package/dist/src/report/index.js.map +1 -0
  249. package/dist/src/report/pixel-diff.d.ts +30 -0
  250. package/dist/src/report/pixel-diff.d.ts.map +1 -0
  251. package/dist/src/report/pixel-diff.js +175 -0
  252. package/dist/src/report/pixel-diff.js.map +1 -0
  253. package/dist/src/report/png.d.ts +28 -0
  254. package/dist/src/report/png.d.ts.map +1 -0
  255. package/dist/src/report/png.js +259 -0
  256. package/dist/src/report/png.js.map +1 -0
  257. package/dist/src/report/render-evidence.d.ts +16 -0
  258. package/dist/src/report/render-evidence.d.ts.map +1 -0
  259. package/dist/src/report/render-evidence.js +174 -0
  260. package/dist/src/report/render-evidence.js.map +1 -0
  261. package/dist/src/report/render-trend.d.ts +16 -0
  262. package/dist/src/report/render-trend.d.ts.map +1 -0
  263. package/dist/src/report/render-trend.js +194 -0
  264. package/dist/src/report/render-trend.js.map +1 -0
  265. package/dist/src/report/render.d.ts +21 -0
  266. package/dist/src/report/render.d.ts.map +1 -0
  267. package/dist/src/report/render.js +286 -0
  268. package/dist/src/report/render.js.map +1 -0
  269. package/dist/src/report/run-diff.d.ts +18 -0
  270. package/dist/src/report/run-diff.d.ts.map +1 -0
  271. package/dist/src/report/run-diff.js +51 -0
  272. package/dist/src/report/run-diff.js.map +1 -0
  273. package/dist/src/report/structure.d.ts +41 -0
  274. package/dist/src/report/structure.d.ts.map +1 -0
  275. package/dist/src/report/structure.js +210 -0
  276. package/dist/src/report/structure.js.map +1 -0
  277. package/dist/src/report/trend.d.ts +26 -0
  278. package/dist/src/report/trend.d.ts.map +1 -0
  279. package/dist/src/report/trend.js +164 -0
  280. package/dist/src/report/trend.js.map +1 -0
  281. package/dist/src/report/types.d.ts +320 -0
  282. package/dist/src/report/types.d.ts.map +1 -0
  283. package/dist/src/report/types.js +11 -0
  284. package/dist/src/report/types.js.map +1 -0
  285. package/dist/src/report/util.d.ts +17 -0
  286. package/dist/src/report/util.d.ts.map +1 -0
  287. package/dist/src/report/util.js +45 -0
  288. package/dist/src/report/util.js.map +1 -0
  289. package/dist/tsconfig.tsbuildinfo +1 -1
  290. package/package.json +1 -1
  291. package/src/__tests__/adapters.test.ts +15 -8
  292. package/src/__tests__/batch-eval-parsing.test.ts +4 -2
  293. package/src/__tests__/box-model.test.ts +9 -5
  294. package/src/__tests__/browser-launch.test.ts +26 -15
  295. package/src/__tests__/cdp-client.test.ts +25 -9
  296. package/src/__tests__/close-browser.test.ts +11 -6
  297. package/src/__tests__/console-log.test.ts +36 -13
  298. package/src/__tests__/find.test.ts +58 -20
  299. package/src/__tests__/frame-command.test.ts +12 -6
  300. package/src/__tests__/open-url-navigate-error.test.ts +2 -4
  301. package/src/__tests__/output.test.ts +1 -1
  302. package/src/__tests__/profiler-heap.test.ts +191 -0
  303. package/src/__tests__/reap-idle-browser.test.ts +276 -0
  304. package/src/__tests__/ref-cache.test.ts +25 -12
  305. package/src/__tests__/report-a11y.test.ts +180 -0
  306. package/src/__tests__/report-analyze.test.ts +202 -0
  307. package/src/__tests__/report-budget.test.ts +92 -0
  308. package/src/__tests__/report-diff.test.ts +272 -0
  309. package/src/__tests__/report-evidence.test.ts +276 -0
  310. package/src/__tests__/report-flake.test.ts +214 -0
  311. package/src/__tests__/report-history.test.ts +293 -0
  312. package/src/__tests__/report-output.test.ts +210 -0
  313. package/src/__tests__/report-png.test.ts +95 -0
  314. package/src/__tests__/report-render-extras.test.ts +315 -0
  315. package/src/__tests__/report-render.test.ts +175 -0
  316. package/src/__tests__/report-run.test.ts +264 -0
  317. package/src/__tests__/session-state.test.ts +40 -28
  318. package/src/__tests__/snapshot.test.ts +60 -21
  319. package/src/__tests__/storage-eval.test.ts +30 -16
  320. package/src/__tests__/tabs.test.ts +26 -10
  321. package/src/browser/action-builder/analyzer.ts +39 -15
  322. package/src/browser/action-builder/types.ts +45 -8
  323. package/src/browser/actions.ts +288 -123
  324. package/src/browser/adapters/gemini.ts +7 -3
  325. package/src/browser/adapters/google.ts +5 -3
  326. package/src/browser/adapters/index.ts +5 -4
  327. package/src/browser/adapters/instagram.ts +4 -2
  328. package/src/browser/adapters/linkedin.ts +4 -2
  329. package/src/browser/adapters/microsoft.ts +9 -5
  330. package/src/browser/adapters/x.ts +7 -3
  331. package/src/browser/browser.ts +188 -42
  332. package/src/browser/cdp.ts +43 -11
  333. package/src/browser/console-log.ts +24 -7
  334. package/src/browser/dashboard/server.ts +44 -25
  335. package/src/browser/dashboard/ui.html +153 -165
  336. package/src/browser/dialog.ts +25 -8
  337. package/src/browser/emulation.ts +86 -36
  338. package/src/browser/find.ts +110 -47
  339. package/src/browser/har.ts +58 -26
  340. package/src/browser/index.ts +17 -16
  341. package/src/browser/network.ts +147 -46
  342. package/src/browser/pdf.ts +4 -4
  343. package/src/browser/profiler.ts +39 -19
  344. package/src/browser/record.ts +38 -19
  345. package/src/browser/ref-cache.ts +38 -17
  346. package/src/browser/screenshot.ts +58 -32
  347. package/src/browser/session.ts +51 -18
  348. package/src/browser/snapshot.ts +34 -20
  349. package/src/browser/storage.ts +73 -17
  350. package/src/browser/tabs.ts +29 -17
  351. package/src/browser/trace.ts +14 -10
  352. package/src/browser/types.ts +23 -4
  353. package/src/browser/vitals.ts +33 -16
  354. package/src/browser/wait.ts +47 -21
  355. package/src/cli/action.ts +52 -21
  356. package/src/cli/commands.ts +1365 -385
  357. package/src/cli/output.ts +5 -5
  358. package/src/cli/platform.ts +44 -23
  359. package/src/cli/types.ts +1 -1
  360. package/src/cli.ts +34 -18
  361. package/src/index.ts +2 -2
  362. package/src/report/a11y.ts +242 -0
  363. package/src/report/analyze.ts +181 -0
  364. package/src/report/budget.ts +155 -0
  365. package/src/report/collect-a11y.ts +200 -0
  366. package/src/report/collect.ts +408 -0
  367. package/src/report/evidence.ts +379 -0
  368. package/src/report/flake.ts +303 -0
  369. package/src/report/history.ts +219 -0
  370. package/src/report/index.ts +307 -0
  371. package/src/report/pixel-diff.ts +197 -0
  372. package/src/report/png.ts +290 -0
  373. package/src/report/render-evidence.ts +194 -0
  374. package/src/report/render-trend.ts +225 -0
  375. package/src/report/render.ts +314 -0
  376. package/src/report/run-diff.ts +63 -0
  377. package/src/report/structure.ts +212 -0
  378. package/src/report/trend.ts +189 -0
  379. package/src/report/types.ts +356 -0
  380. package/src/report/util.ts +48 -0
@@ -0,0 +1,379 @@
1
+ /**
2
+ * Recorded evidence for a bug report (RIG-11).
3
+ *
4
+ * When a run breaches its budget, "LCP was 4.1s" is a number; the frames
5
+ * either side of the moment it went wrong, lined up against the console and
6
+ * network entries from the same instant, is a bug report. This module records
7
+ * the frames and assembles that timeline.
8
+ *
9
+ * Size is the hard constraint. A screencast is tens of megabytes and a report
10
+ * is a single self-contained HTML file, so there are three separate bounds:
11
+ * the recorder's ring buffer (what we hold in memory), the frame selection
12
+ * (what we attach), and a byte budget applied over the selection. Nothing
13
+ * here can produce a 200MB report.
14
+ *
15
+ * `startEvidenceRecorder` talks to Chrome; everything below it is pure and
16
+ * unit-tested against fixture frames.
17
+ */
18
+
19
+ import type { CdpClient } from '../browser/cdp.js';
20
+ import type {
21
+ BudgetFailure,
22
+ ConsoleEntry,
23
+ Evidence,
24
+ EvidenceFrame,
25
+ PageErrorEntry,
26
+ RawFrame,
27
+ RequestEntry,
28
+ TimelineEntry,
29
+ } from './types.js';
30
+
31
+ export type { RawFrame };
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Recording
35
+ // ---------------------------------------------------------------------------
36
+
37
+ export interface EvidenceRecorderOptions {
38
+ /** Frames held in memory before the buffer is decimated. */
39
+ maxFrames?: number;
40
+ /** Base64 bytes held in memory before the buffer is decimated. */
41
+ maxBytes?: number;
42
+ everyNthFrame?: number;
43
+ quality?: number;
44
+ maxWidth?: number;
45
+ maxHeight?: number;
46
+ }
47
+
48
+ export interface EvidenceRecorder {
49
+ frames: RawFrame[];
50
+ droppedFrames: number;
51
+ stop: () => Promise<void>;
52
+ }
53
+
54
+ const DEFAULT_MAX_BUFFER_FRAMES = 160;
55
+ const DEFAULT_MAX_BUFFER_BYTES = 32 * 1024 * 1024;
56
+
57
+ /**
58
+ * Halve the temporal resolution of the buffer instead of dropping its head.
59
+ *
60
+ * A plain ring buffer keeps the end of the run, which is the wrong half: a
61
+ * page that throws during load has its evidence at the start. Decimating
62
+ * keeps coverage of the whole run and merely coarsens it, which degrades the
63
+ * timeline gracefully however long the run turns out to be.
64
+ */
65
+ function decimate(frames: RawFrame[]): { kept: RawFrame[]; dropped: number } {
66
+ const kept = frames.filter((_, i) => i % 2 === 0);
67
+ return { kept, dropped: frames.length - kept.length };
68
+ }
69
+
70
+ export async function startEvidenceRecorder(
71
+ client: CdpClient,
72
+ sessionId: string,
73
+ startedAtMs: number,
74
+ options: EvidenceRecorderOptions = {},
75
+ ): Promise<EvidenceRecorder> {
76
+ const maxFrames = options.maxFrames ?? DEFAULT_MAX_BUFFER_FRAMES;
77
+ const maxBytes = options.maxBytes ?? DEFAULT_MAX_BUFFER_BYTES;
78
+
79
+ const recorder: EvidenceRecorder = {
80
+ frames: [],
81
+ droppedFrames: 0,
82
+ stop: async () => {},
83
+ };
84
+ let bytes = 0;
85
+
86
+ const off = client.on('Page.screencastFrame', (params, sid) => {
87
+ if (sid !== sessionId) return;
88
+ const p = params as { data?: string; sessionId?: number };
89
+ // Ack first and unconditionally: Chrome stops sending frames until the
90
+ // previous one is acknowledged, so a failure to ack silently ends the
91
+ // recording.
92
+ if (p.sessionId !== undefined) {
93
+ void client
94
+ .send('Page.screencastFrameAck', { sessionId: p.sessionId }, sessionId)
95
+ .catch(() => {});
96
+ }
97
+ if (!p.data) return;
98
+ recorder.frames.push({
99
+ offsetMs: Math.max(0, Date.now() - startedAtMs),
100
+ data: p.data,
101
+ bytes: p.data.length,
102
+ });
103
+ bytes += p.data.length;
104
+ if (recorder.frames.length > maxFrames || bytes > maxBytes) {
105
+ const { kept, dropped } = decimate(recorder.frames);
106
+ recorder.frames = kept;
107
+ recorder.droppedFrames += dropped;
108
+ bytes = kept.reduce((sum, f) => sum + f.bytes, 0);
109
+ }
110
+ });
111
+
112
+ // Idempotent: collect() stops the recorder once the interesting part of the
113
+ // run is over and again in its `finally`, and Chrome errors on stopping a
114
+ // screencast that is already stopped.
115
+ let stopped = false;
116
+ recorder.stop = async () => {
117
+ if (stopped) return;
118
+ stopped = true;
119
+ off();
120
+ await client.send('Page.stopScreencast', {}, sessionId).catch(() => {});
121
+ };
122
+
123
+ try {
124
+ await client.send(
125
+ 'Page.startScreencast',
126
+ {
127
+ format: 'jpeg',
128
+ quality: options.quality ?? 60,
129
+ everyNthFrame: options.everyNthFrame ?? 2,
130
+ maxWidth: options.maxWidth ?? 900,
131
+ maxHeight: options.maxHeight ?? 1600,
132
+ },
133
+ sessionId,
134
+ );
135
+ } catch (err) {
136
+ off();
137
+ throw err;
138
+ }
139
+ return recorder;
140
+ }
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // Frame selection
144
+ // ---------------------------------------------------------------------------
145
+
146
+ export interface SelectFramesOptions {
147
+ /** Frames to attach to the report. */
148
+ maxFrames?: number;
149
+ /** Total base64 bytes to attach. */
150
+ maxBytes?: number;
151
+ /** Half-width of the window around the focus, in ms. */
152
+ windowMs?: number;
153
+ }
154
+
155
+ const DEFAULT_ATTACH_FRAMES = 12;
156
+ const DEFAULT_ATTACH_BYTES = 6 * 1024 * 1024;
157
+ const DEFAULT_WINDOW_MS = 4000;
158
+
159
+ /**
160
+ * Pick the frames to attach.
161
+ *
162
+ * With a focus offset, frames nearest it win — that is the moment being
163
+ * explained. Without one (a vitals-only breach has no instant), the run is
164
+ * sampled evenly so the reader still sees it load. The byte budget is applied
165
+ * last, evicting the least relevant frame first, so the cap is never reached
166
+ * by truncating the interesting end.
167
+ */
168
+ export function selectFrames(
169
+ frames: RawFrame[],
170
+ focusOffsetMs: number | null,
171
+ options: SelectFramesOptions = {},
172
+ ): { frames: RawFrame[]; dropped: number } {
173
+ const maxFrames = Math.max(1, options.maxFrames ?? DEFAULT_ATTACH_FRAMES);
174
+ const maxBytes = options.maxBytes ?? DEFAULT_ATTACH_BYTES;
175
+ const windowMs = options.windowMs ?? DEFAULT_WINDOW_MS;
176
+ if (!frames.length) return { frames: [], dropped: 0 };
177
+
178
+ let ranked: RawFrame[];
179
+ if (focusOffsetMs === null) {
180
+ if (frames.length <= maxFrames) {
181
+ ranked = [...frames];
182
+ } else {
183
+ const step = (frames.length - 1) / (maxFrames - 1 || 1);
184
+ const picked = new Set<number>();
185
+ for (let i = 0; i < maxFrames; i++) picked.add(Math.round(i * step));
186
+ ranked = [...picked].sort((a, b) => a - b).map((i) => frames[i]);
187
+ }
188
+ } else {
189
+ const distance = (f: RawFrame) => Math.abs(f.offsetMs - focusOffsetMs);
190
+ const inWindow = frames.filter((f) => distance(f) <= windowMs);
191
+ // Fall back to the nearest frames overall rather than attaching none: a
192
+ // failure whose frames all fell outside the window still deserves context.
193
+ const pool = inWindow.length ? inWindow : [...frames];
194
+ ranked = [...pool].sort((a, b) => distance(a) - distance(b)).slice(0, maxFrames);
195
+ }
196
+
197
+ let dropped = frames.length - ranked.length;
198
+ let total = ranked.reduce((sum, f) => sum + f.bytes, 0);
199
+ if (total > maxBytes) {
200
+ const order =
201
+ focusOffsetMs === null
202
+ ? [...ranked]
203
+ : [...ranked].sort(
204
+ (a, b) => Math.abs(a.offsetMs - focusOffsetMs) - Math.abs(b.offsetMs - focusOffsetMs),
205
+ );
206
+ const keep = new Set<RawFrame>();
207
+ let used = 0;
208
+ for (const frame of order) {
209
+ if (used + frame.bytes > maxBytes) continue;
210
+ keep.add(frame);
211
+ used += frame.bytes;
212
+ }
213
+ dropped += ranked.length - keep.size;
214
+ ranked = ranked.filter((f) => keep.has(f));
215
+ total = used;
216
+ }
217
+
218
+ return { frames: ranked.sort((a, b) => a.offsetMs - b.offsetMs), dropped };
219
+ }
220
+
221
+ // ---------------------------------------------------------------------------
222
+ // Timeline
223
+ // ---------------------------------------------------------------------------
224
+
225
+ export interface BuildEvidenceInput {
226
+ reason: Evidence['reason'];
227
+ /** `Date.now()` at the start of the run — the zero of every offset. */
228
+ startedAtMs: number;
229
+ frames: RawFrame[];
230
+ /** Frames the recorder already dropped to stay inside its buffer. */
231
+ bufferDropped?: number;
232
+ console: ConsoleEntry[];
233
+ pageErrors: PageErrorEntry[];
234
+ requests: RequestEntry[];
235
+ failures: BudgetFailure[];
236
+ select?: SelectFramesOptions;
237
+ /** Cap on non-frame timeline rows. */
238
+ maxEvents?: number;
239
+ }
240
+
241
+ const DEFAULT_MAX_EVENTS = 60;
242
+
243
+ function truncate(text: string, max = 200): string {
244
+ const flat = String(text ?? '')
245
+ .replace(/\s+/g, ' ')
246
+ .trim();
247
+ return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
248
+ }
249
+
250
+ /**
251
+ * The instant the run went wrong: the earliest hard error we have a clock
252
+ * reading for. Returns null when the breach has no moment attached — a slow
253
+ * LCP is a property of the whole load, not an event.
254
+ */
255
+ export function findFocusOffset(input: BuildEvidenceInput): number | null {
256
+ const candidates: number[] = [];
257
+ for (const e of input.console) {
258
+ if (e.type === 'error' && Number.isFinite(e.timestamp)) {
259
+ candidates.push(e.timestamp - input.startedAtMs);
260
+ }
261
+ }
262
+ for (const e of input.pageErrors) {
263
+ if (Number.isFinite(e.timestamp)) candidates.push(e.timestamp - input.startedAtMs);
264
+ }
265
+ for (const r of input.requests) {
266
+ if (r.failed && r.startedAtMs !== undefined) candidates.push(r.startedAtMs - input.startedAtMs);
267
+ }
268
+ const usable = candidates.filter((v) => Number.isFinite(v) && v >= 0);
269
+ return usable.length ? Math.min(...usable) : null;
270
+ }
271
+
272
+ export function buildEvidence(input: BuildEvidenceInput): Evidence {
273
+ const notes: string[] = [];
274
+ const focusOffsetMs = findFocusOffset(input);
275
+ const picked = selectFrames(input.frames, focusOffsetMs, input.select);
276
+
277
+ const frames: EvidenceFrame[] = picked.frames.map((f) => ({
278
+ offsetMs: f.offsetMs,
279
+ dataUrl: `data:image/jpeg;base64,${f.data}`,
280
+ bytes: f.bytes,
281
+ }));
282
+
283
+ const events: TimelineEntry[] = [];
284
+ for (const [i, frame] of picked.frames.entries()) {
285
+ events.push({
286
+ offsetMs: frame.offsetMs,
287
+ kind: 'frame',
288
+ label: 'frame',
289
+ severity: 'info',
290
+ frameIndex: i,
291
+ });
292
+ }
293
+ for (const e of input.console) {
294
+ if (e.type !== 'error' && e.type !== 'warn' && e.type !== 'warning') continue;
295
+ events.push({
296
+ offsetMs: e.timestamp - input.startedAtMs,
297
+ kind: 'console',
298
+ label: `console.${e.type}`,
299
+ detail: truncate(e.text),
300
+ severity: e.type === 'error' ? 'error' : 'warning',
301
+ });
302
+ }
303
+ for (const e of input.pageErrors) {
304
+ events.push({
305
+ offsetMs: e.timestamp - input.startedAtMs,
306
+ kind: 'pageerror',
307
+ label: 'uncaught exception',
308
+ detail: truncate(e.text),
309
+ severity: 'error',
310
+ });
311
+ }
312
+ for (const r of input.requests) {
313
+ if (!r.failed) continue;
314
+ if (r.startedAtMs === undefined) continue;
315
+ events.push({
316
+ offsetMs: r.startedAtMs - input.startedAtMs,
317
+ kind: 'request',
318
+ label: `${r.status ?? r.errorText ?? 'no response'} ${r.method}`,
319
+ detail: truncate(r.url),
320
+ severity: 'error',
321
+ });
322
+ }
323
+ if (focusOffsetMs !== null) {
324
+ events.push({
325
+ offsetMs: focusOffsetMs,
326
+ kind: 'marker',
327
+ label: 'first failure',
328
+ detail: input.failures.map((f) => `${f.budget} ${f.actual}`).join(', ') || undefined,
329
+ severity: 'error',
330
+ });
331
+ }
332
+
333
+ // Trim the non-frame rows worst-first, so a page with 400 console warnings
334
+ // still shows its one uncaught exception.
335
+ const frameRows = events.filter((e) => e.kind === 'frame');
336
+ let otherRows = events.filter((e) => e.kind !== 'frame');
337
+ const maxEvents = input.maxEvents ?? DEFAULT_MAX_EVENTS;
338
+ if (otherRows.length > maxEvents) {
339
+ const weight = { error: 0, warning: 1, info: 2 } as const;
340
+ const kept = [...otherRows]
341
+ .sort(
342
+ (a, b) =>
343
+ weight[a.severity] - weight[b.severity] ||
344
+ Math.abs(a.offsetMs - (focusOffsetMs ?? 0)) - Math.abs(b.offsetMs - (focusOffsetMs ?? 0)),
345
+ )
346
+ .slice(0, maxEvents);
347
+ notes.push(`Timeline trimmed to the ${maxEvents} most relevant of ${otherRows.length} events.`);
348
+ otherRows = kept;
349
+ }
350
+
351
+ const timeline = [...frameRows, ...otherRows].sort(
352
+ (a, b) => a.offsetMs - b.offsetMs || a.kind.localeCompare(b.kind),
353
+ );
354
+
355
+ const droppedFrames = (input.bufferDropped ?? 0) + picked.dropped;
356
+ if (droppedFrames > 0) {
357
+ notes.push(
358
+ `${droppedFrames} recorded frame(s) not attached — the report keeps the ${frames.length} nearest the failure to stay inside its size budget.`,
359
+ );
360
+ }
361
+ if (!frames.length && input.frames.length === 0) {
362
+ notes.push('No screencast frames were captured for this run.');
363
+ }
364
+ if (focusOffsetMs === null && input.failures.length) {
365
+ notes.push(
366
+ 'No single moment of failure could be located — the breach is a load-wide metric, so the frames sample the whole run instead.',
367
+ );
368
+ }
369
+
370
+ return {
371
+ reason: input.reason,
372
+ focusOffsetMs,
373
+ frames,
374
+ timeline,
375
+ droppedFrames,
376
+ totalBytes: frames.reduce((sum, f) => sum + f.bytes, 0),
377
+ notes,
378
+ };
379
+ }
@@ -0,0 +1,303 @@
1
+ /**
2
+ * Flake detection across repeated runs of the same URL (RIG-14).
3
+ *
4
+ * The failure mode this exists to stop: a check that fails two runs in five
5
+ * gets re-run until it is green and shipped as "passing". So the aggregate
6
+ * verdict has a third value — `flaky` — and a check that both passed and
7
+ * failed within the window can only produce that verdict. It is never
8
+ * rounded down to `pass`.
9
+ *
10
+ * The other half is honesty about what N runs can prove. `confidenceNote`
11
+ * states the failure rate the window actually rules out, because "5 runs
12
+ * passed" and "this is reliable" are very different claims.
13
+ *
14
+ * Pure: the tests feed arrays of Reports.
15
+ */
16
+
17
+ import { budgetLabel } from './budget.js';
18
+ import type { Budget, Report } from './types.js';
19
+
20
+ export type FlakeVerdict = 'pass' | 'fail' | 'flaky';
21
+
22
+ export interface FlakeCheck {
23
+ key: keyof Budget;
24
+ label: string;
25
+ runs: number;
26
+ passed: number;
27
+ failed: number;
28
+ /** Fraction of runs that failed, 0..1. */
29
+ flakeRate: number;
30
+ /** True when the check did the same thing every run. */
31
+ stable: boolean;
32
+ /** The measured value for each run, in run order. */
33
+ actuals: string[];
34
+ }
35
+
36
+ export interface FlakeSignal {
37
+ kind: 'console' | 'pageError' | 'request' | 'a11y';
38
+ signature: string;
39
+ /** Runs this signal appeared in. */
40
+ runs: number;
41
+ total: number;
42
+ }
43
+
44
+ export interface FlakeMetric {
45
+ key: string;
46
+ label: string;
47
+ unit: 'ms' | 'score';
48
+ /** Runs that reported the metric at all. */
49
+ measured: number;
50
+ total: number;
51
+ min: number;
52
+ max: number;
53
+ median: number;
54
+ spread: number;
55
+ /** Spread as a fraction of the median. */
56
+ spreadRatio: number;
57
+ unstable: boolean;
58
+ }
59
+
60
+ export interface FlakeReport {
61
+ runs: number;
62
+ verdict: FlakeVerdict;
63
+ checks: FlakeCheck[];
64
+ /** Errors and failed requests that did not appear in every run. */
65
+ signals: FlakeSignal[];
66
+ metrics: FlakeMetric[];
67
+ confidence: 'low' | 'moderate' | 'high';
68
+ confidenceNote: string;
69
+ headline: string;
70
+ }
71
+
72
+ /** Spread beyond this fraction of the median is worth flagging as unstable. */
73
+ const UNSTABLE_SPREAD_RATIO = 0.3;
74
+
75
+ const COUNT_READERS: Partial<Record<keyof Budget, (r: Report) => number>> = {
76
+ maxConsoleErrors: (r) => r.counts.consoleErrors,
77
+ maxPageErrors: (r) => r.counts.pageErrors,
78
+ maxFailedRequests: (r) => r.counts.failedRequests,
79
+ maxA11yErrors: (r) => r.counts.a11yErrors,
80
+ };
81
+
82
+ const METRIC_READERS: Partial<Record<keyof Budget, (r: Report) => number | undefined>> = {
83
+ lcpMs: (r) => r.vitals.lcp,
84
+ clsScore: (r) => r.vitals.cls,
85
+ inpMs: (r) => r.vitals.inp,
86
+ fcpMs: (r) => r.vitals.fcp,
87
+ ttfbMs: (r) => r.vitals.ttfb,
88
+ };
89
+
90
+ function actualFor(key: keyof Budget, report: Report): string {
91
+ const count = COUNT_READERS[key];
92
+ if (count) return String(count(report));
93
+ const metric = METRIC_READERS[key];
94
+ const raw = metric?.(report);
95
+ if (raw === undefined || !Number.isFinite(raw)) return 'not measured';
96
+ return key === 'clsScore' ? raw.toFixed(3) : `${Math.round(raw)}ms`;
97
+ }
98
+
99
+ function median(values: number[]): number {
100
+ const sorted = [...values].sort((a, b) => a - b);
101
+ const mid = Math.floor(sorted.length / 2);
102
+ return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
103
+ }
104
+
105
+ function truncate(text: string, max = 140): string {
106
+ const flat = text.replace(/\s+/g, ' ').trim();
107
+ return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
108
+ }
109
+
110
+ /**
111
+ * Normalise a message so the same error reported with a different id, line or
112
+ * timestamp still counts as the same signal. Without this, a flaky error
113
+ * carrying a request id looks like N distinct one-off errors.
114
+ */
115
+ export function signalSignature(text: string): string {
116
+ return truncate(
117
+ text
118
+ .replace(/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, '<uuid>')
119
+ .replace(/\b0x[0-9a-f]+\b/gi, '<hex>')
120
+ // 5 digits and up, not 3: request ids, timestamps and byte offsets are
121
+ // long, while an HTTP status code is part of what makes two errors
122
+ // different and must survive normalisation.
123
+ .replace(/\b\d{5,}\b/g, '<n>'),
124
+ );
125
+ }
126
+
127
+ function collectSignals(reports: Report[]): FlakeSignal[] {
128
+ const total = reports.length;
129
+ const seen = new Map<
130
+ string,
131
+ { kind: FlakeSignal['kind']; signature: string; runs: Set<number> }
132
+ >();
133
+
134
+ const add = (kind: FlakeSignal['kind'], signature: string, runIndex: number) => {
135
+ const id = `${kind}\n${signature}`;
136
+ let entry = seen.get(id);
137
+ if (!entry) {
138
+ entry = { kind, signature, runs: new Set() };
139
+ seen.set(id, entry);
140
+ }
141
+ entry.runs.add(runIndex);
142
+ };
143
+
144
+ reports.forEach((report, i) => {
145
+ for (const e of report.console) {
146
+ if (e.type === 'error') add('console', signalSignature(e.text), i);
147
+ }
148
+ for (const e of report.pageErrors) add('pageError', signalSignature(e.text), i);
149
+ for (const r of report.requests) {
150
+ if (r.failed) add('request', `${r.status ?? r.errorText ?? 'no response'} ${r.url}`, i);
151
+ }
152
+ for (const f of report.a11y) {
153
+ if (f.impact === 'error') add('a11y', `${f.rule} at ${f.locator}`, i);
154
+ }
155
+ });
156
+
157
+ return [...seen.values()]
158
+ .filter((e) => e.runs.size < total) // present in every run is a bug, not a flake
159
+ .map((e) => ({ kind: e.kind, signature: e.signature, runs: e.runs.size, total }))
160
+ .sort((a, b) => b.runs - a.runs || a.signature.localeCompare(b.signature));
161
+ }
162
+
163
+ function collectMetrics(reports: Report[]): FlakeMetric[] {
164
+ const defs: Array<[string, string, 'ms' | 'score', (r: Report) => number | undefined]> = [
165
+ ['lcp', 'LCP', 'ms', (r) => r.vitals.lcp],
166
+ ['fcp', 'FCP', 'ms', (r) => r.vitals.fcp],
167
+ ['cls', 'CLS', 'score', (r) => r.vitals.cls],
168
+ ['inp', 'INP', 'ms', (r) => r.vitals.inp],
169
+ ['ttfb', 'TTFB', 'ms', (r) => r.vitals.ttfb],
170
+ ];
171
+ const out: FlakeMetric[] = [];
172
+ for (const [key, label, unit, read] of defs) {
173
+ const values = reports
174
+ .map(read)
175
+ .filter((v): v is number => typeof v === 'number' && Number.isFinite(v));
176
+ if (!values.length) continue;
177
+ const min = Math.min(...values);
178
+ const max = Math.max(...values);
179
+ const mid = median(values);
180
+ const spread = max - min;
181
+ const spreadRatio = mid > 0 ? spread / mid : spread > 0 ? Infinity : 0;
182
+ out.push({
183
+ key,
184
+ label,
185
+ unit,
186
+ measured: values.length,
187
+ total: reports.length,
188
+ min,
189
+ max,
190
+ median: mid,
191
+ spread,
192
+ spreadRatio,
193
+ unstable: values.length > 1 && spreadRatio > UNSTABLE_SPREAD_RATIO,
194
+ });
195
+ }
196
+ return out;
197
+ }
198
+
199
+ /**
200
+ * What a window of all-passing runs actually rules out.
201
+ *
202
+ * If a check truly fails with probability p, N independent passing runs have
203
+ * probability (1-p)^N. Solving (1-p)^N = 0.05 gives the largest failure rate
204
+ * consistent with what we saw at 95% confidence — which is a far less
205
+ * flattering number than "N runs passed" sounds.
206
+ */
207
+ export function ruledOutFailureRate(runs: number): number {
208
+ if (runs <= 0) return 1;
209
+ return 1 - 0.05 ** (1 / runs);
210
+ }
211
+
212
+ function confidenceFor(
213
+ runs: number,
214
+ verdict: FlakeVerdict,
215
+ ): { confidence: FlakeReport['confidence']; note: string } {
216
+ if (verdict === 'flaky') {
217
+ return {
218
+ confidence: 'high',
219
+ note: `Flakiness is observed, not inferred: both outcomes occurred within ${runs} runs.`,
220
+ };
221
+ }
222
+ const bound = Math.round(ruledOutFailureRate(runs) * 100);
223
+ const level = runs >= 5 ? 'high' : runs >= 3 ? 'moderate' : 'low';
224
+ const outcome = verdict === 'pass' ? 'passed' : 'failed';
225
+ return {
226
+ confidence: level,
227
+ note:
228
+ `${runs} run${runs === 1 ? '' : 's'}, all ${outcome} consistently. That only rules out ` +
229
+ `intermittent behaviour more frequent than about 1 run in ${Math.max(2, Math.round(100 / bound))} ` +
230
+ `(${bound}%) at 95% confidence — re-run with a larger --repeat to tighten it.`,
231
+ };
232
+ }
233
+
234
+ /**
235
+ * Aggregate N runs of the same URL.
236
+ *
237
+ * `budget` comes from the runs themselves (they all share one), and only
238
+ * enforced keys become checks: an unenforced budget cannot be flaky.
239
+ */
240
+ export function analyzeFlake(reports: Report[]): FlakeReport {
241
+ if (!reports.length) throw new Error('analyzeFlake: needs at least one run');
242
+ const runs = reports.length;
243
+ const budget = reports[0].budget;
244
+
245
+ const checks: FlakeCheck[] = [];
246
+ for (const key of Object.keys(budget) as Array<keyof Budget>) {
247
+ if (budget[key] === null) continue;
248
+ const failed = reports.filter((r) => r.failures.some((f) => f.budget === key)).length;
249
+ checks.push({
250
+ key,
251
+ label: budgetLabel(key),
252
+ runs,
253
+ passed: runs - failed,
254
+ failed,
255
+ flakeRate: failed / runs,
256
+ stable: failed === 0 || failed === runs,
257
+ actuals: reports.map((r) => actualFor(key, r)),
258
+ });
259
+ }
260
+
261
+ const unstableChecks = checks.filter((c) => !c.stable);
262
+ const alwaysFailing = checks.filter((c) => c.failed === runs);
263
+ const verdict: FlakeVerdict = unstableChecks.length
264
+ ? 'flaky'
265
+ : alwaysFailing.length
266
+ ? 'fail'
267
+ : 'pass';
268
+
269
+ const signals = collectSignals(reports);
270
+ const metrics = collectMetrics(reports);
271
+ const { confidence, note } = confidenceFor(runs, verdict);
272
+
273
+ let headline: string;
274
+ if (verdict === 'flaky') {
275
+ const worst = [...unstableChecks].sort((a, b) => b.failed - a.failed)[0];
276
+ headline =
277
+ `FLAKY — ${unstableChecks.length} check${unstableChecks.length === 1 ? '' : 's'} ` +
278
+ `changed outcome across ${runs} runs (e.g. ${worst.label} failed ${worst.failed} of ${runs}).`;
279
+ } else if (verdict === 'fail') {
280
+ headline = `FAIL — ${alwaysFailing.length} check(s) failed in all ${runs} runs.`;
281
+ } else {
282
+ const unstableMetrics = metrics.filter((m) => m.unstable).length;
283
+ headline =
284
+ `PASS — every check held across all ${runs} runs` +
285
+ (unstableMetrics ? `, though ${unstableMetrics} metric(s) varied widely.` : '.');
286
+ }
287
+
288
+ return {
289
+ runs,
290
+ verdict,
291
+ checks,
292
+ signals,
293
+ metrics,
294
+ confidence,
295
+ confidenceNote: note,
296
+ headline,
297
+ };
298
+ }
299
+
300
+ /** One line for a CI log / agent transcript. */
301
+ export function summarizeFlake(flake: FlakeReport): string {
302
+ return `${flake.headline} Confidence: ${flake.confidence}.`;
303
+ }