@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.
- package/dist/src/__tests__/adapters.test.js +5 -2
- package/dist/src/__tests__/adapters.test.js.map +1 -1
- package/dist/src/__tests__/batch-eval-parsing.test.js +4 -2
- package/dist/src/__tests__/batch-eval-parsing.test.js.map +1 -1
- package/dist/src/__tests__/box-model.test.js +9 -5
- package/dist/src/__tests__/box-model.test.js.map +1 -1
- package/dist/src/__tests__/browser-launch.test.d.ts +10 -0
- package/dist/src/__tests__/browser-launch.test.d.ts.map +1 -1
- package/dist/src/__tests__/browser-launch.test.js +17 -10
- package/dist/src/__tests__/browser-launch.test.js.map +1 -1
- package/dist/src/__tests__/cdp-client.test.js +9 -3
- package/dist/src/__tests__/cdp-client.test.js.map +1 -1
- package/dist/src/__tests__/close-browser.test.d.ts +13 -0
- package/dist/src/__tests__/close-browser.test.d.ts.map +1 -1
- package/dist/src/__tests__/close-browser.test.js +5 -5
- package/dist/src/__tests__/close-browser.test.js.map +1 -1
- package/dist/src/__tests__/console-log.test.js +17 -6
- package/dist/src/__tests__/console-log.test.js.map +1 -1
- package/dist/src/__tests__/find.test.js +26 -6
- package/dist/src/__tests__/find.test.js.map +1 -1
- package/dist/src/__tests__/frame-command.test.js +9 -3
- package/dist/src/__tests__/frame-command.test.js.map +1 -1
- package/dist/src/__tests__/open-url-navigate-error.test.js +1 -3
- package/dist/src/__tests__/open-url-navigate-error.test.js.map +1 -1
- package/dist/src/__tests__/output.test.js +1 -1
- package/dist/src/__tests__/output.test.js.map +1 -1
- package/dist/src/__tests__/profiler-heap.test.d.ts +2 -0
- package/dist/src/__tests__/profiler-heap.test.d.ts.map +1 -0
- package/dist/src/__tests__/profiler-heap.test.js +154 -0
- package/dist/src/__tests__/profiler-heap.test.js.map +1 -0
- package/dist/src/__tests__/reap-idle-browser.test.d.ts +15 -0
- package/dist/src/__tests__/reap-idle-browser.test.d.ts.map +1 -0
- package/dist/src/__tests__/reap-idle-browser.test.js +238 -0
- package/dist/src/__tests__/reap-idle-browser.test.js.map +1 -0
- package/dist/src/__tests__/ref-cache.test.js +18 -10
- package/dist/src/__tests__/ref-cache.test.js.map +1 -1
- package/dist/src/__tests__/report-a11y.test.d.ts +2 -0
- package/dist/src/__tests__/report-a11y.test.d.ts.map +1 -0
- package/dist/src/__tests__/report-a11y.test.js +147 -0
- package/dist/src/__tests__/report-a11y.test.js.map +1 -0
- package/dist/src/__tests__/report-analyze.test.d.ts +2 -0
- package/dist/src/__tests__/report-analyze.test.d.ts.map +1 -0
- package/dist/src/__tests__/report-analyze.test.js +157 -0
- package/dist/src/__tests__/report-analyze.test.js.map +1 -0
- package/dist/src/__tests__/report-budget.test.d.ts +2 -0
- package/dist/src/__tests__/report-budget.test.d.ts.map +1 -0
- package/dist/src/__tests__/report-budget.test.js +80 -0
- package/dist/src/__tests__/report-budget.test.js.map +1 -0
- package/dist/src/__tests__/report-diff.test.d.ts +2 -0
- package/dist/src/__tests__/report-diff.test.d.ts.map +1 -0
- package/dist/src/__tests__/report-diff.test.js +231 -0
- package/dist/src/__tests__/report-diff.test.js.map +1 -0
- package/dist/src/__tests__/report-evidence.test.d.ts +2 -0
- package/dist/src/__tests__/report-evidence.test.d.ts.map +1 -0
- package/dist/src/__tests__/report-evidence.test.js +214 -0
- package/dist/src/__tests__/report-evidence.test.js.map +1 -0
- package/dist/src/__tests__/report-flake.test.d.ts +2 -0
- package/dist/src/__tests__/report-flake.test.d.ts.map +1 -0
- package/dist/src/__tests__/report-flake.test.js +182 -0
- package/dist/src/__tests__/report-flake.test.js.map +1 -0
- package/dist/src/__tests__/report-history.test.d.ts +2 -0
- package/dist/src/__tests__/report-history.test.d.ts.map +1 -0
- package/dist/src/__tests__/report-history.test.js +229 -0
- package/dist/src/__tests__/report-history.test.js.map +1 -0
- package/dist/src/__tests__/report-output.test.d.ts +2 -0
- package/dist/src/__tests__/report-output.test.d.ts.map +1 -0
- package/dist/src/__tests__/report-output.test.js +169 -0
- package/dist/src/__tests__/report-output.test.js.map +1 -0
- package/dist/src/__tests__/report-png.test.d.ts +2 -0
- package/dist/src/__tests__/report-png.test.d.ts.map +1 -0
- package/dist/src/__tests__/report-png.test.js +84 -0
- package/dist/src/__tests__/report-png.test.js.map +1 -0
- package/dist/src/__tests__/report-render-extras.test.d.ts +2 -0
- package/dist/src/__tests__/report-render-extras.test.d.ts.map +1 -0
- package/dist/src/__tests__/report-render-extras.test.js +285 -0
- package/dist/src/__tests__/report-render-extras.test.js.map +1 -0
- package/dist/src/__tests__/report-render.test.d.ts +2 -0
- package/dist/src/__tests__/report-render.test.d.ts.map +1 -0
- package/dist/src/__tests__/report-render.test.js +143 -0
- package/dist/src/__tests__/report-render.test.js.map +1 -0
- package/dist/src/__tests__/report-run.test.d.ts +2 -0
- package/dist/src/__tests__/report-run.test.d.ts.map +1 -0
- package/dist/src/__tests__/report-run.test.js +234 -0
- package/dist/src/__tests__/report-run.test.js.map +1 -0
- package/dist/src/__tests__/session-state.test.d.ts +8 -0
- package/dist/src/__tests__/session-state.test.d.ts.map +1 -1
- package/dist/src/__tests__/session-state.test.js +10 -10
- package/dist/src/__tests__/session-state.test.js.map +1 -1
- package/dist/src/__tests__/snapshot.test.js +44 -14
- package/dist/src/__tests__/snapshot.test.js.map +1 -1
- package/dist/src/__tests__/storage-eval.test.js +14 -6
- package/dist/src/__tests__/storage-eval.test.js.map +1 -1
- package/dist/src/__tests__/tabs.test.js +25 -9
- package/dist/src/__tests__/tabs.test.js.map +1 -1
- package/dist/src/browser/action-builder/analyzer.d.ts +1 -1
- package/dist/src/browser/action-builder/analyzer.d.ts.map +1 -1
- package/dist/src/browser/action-builder/analyzer.js +25 -9
- package/dist/src/browser/action-builder/analyzer.js.map +1 -1
- package/dist/src/browser/action-builder/types.d.ts.map +1 -1
- package/dist/src/browser/actions.d.ts +1 -1
- package/dist/src/browser/actions.d.ts.map +1 -1
- package/dist/src/browser/actions.js +16 -6
- package/dist/src/browser/actions.js.map +1 -1
- package/dist/src/browser/adapters/gemini.d.ts.map +1 -1
- package/dist/src/browser/adapters/gemini.js.map +1 -1
- package/dist/src/browser/adapters/google.d.ts.map +1 -1
- package/dist/src/browser/adapters/google.js.map +1 -1
- package/dist/src/browser/adapters/index.d.ts.map +1 -1
- package/dist/src/browser/adapters/index.js +3 -3
- package/dist/src/browser/adapters/index.js.map +1 -1
- package/dist/src/browser/adapters/instagram.d.ts.map +1 -1
- package/dist/src/browser/adapters/instagram.js.map +1 -1
- package/dist/src/browser/adapters/linkedin.d.ts.map +1 -1
- package/dist/src/browser/adapters/linkedin.js.map +1 -1
- package/dist/src/browser/adapters/microsoft.d.ts.map +1 -1
- package/dist/src/browser/adapters/microsoft.js.map +1 -1
- package/dist/src/browser/adapters/x.d.ts.map +1 -1
- package/dist/src/browser/adapters/x.js.map +1 -1
- package/dist/src/browser/browser.d.ts +30 -0
- package/dist/src/browser/browser.d.ts.map +1 -1
- package/dist/src/browser/browser.js +122 -15
- package/dist/src/browser/browser.js.map +1 -1
- package/dist/src/browser/cdp.d.ts.map +1 -1
- package/dist/src/browser/cdp.js +21 -8
- package/dist/src/browser/cdp.js.map +1 -1
- package/dist/src/browser/console-log.d.ts.map +1 -1
- package/dist/src/browser/console-log.js +2 -2
- package/dist/src/browser/console-log.js.map +1 -1
- package/dist/src/browser/dashboard/server.d.ts.map +1 -1
- package/dist/src/browser/dashboard/server.js +29 -19
- package/dist/src/browser/dashboard/server.js.map +1 -1
- package/dist/src/browser/dashboard/ui.html +153 -165
- package/dist/src/browser/dialog.d.ts.map +1 -1
- package/dist/src/browser/dialog.js +3 -1
- package/dist/src/browser/dialog.js.map +1 -1
- package/dist/src/browser/emulation.d.ts.map +1 -1
- package/dist/src/browser/emulation.js +21 -6
- package/dist/src/browser/emulation.js.map +1 -1
- package/dist/src/browser/find.d.ts +4 -4
- package/dist/src/browser/find.d.ts.map +1 -1
- package/dist/src/browser/find.js +14 -6
- package/dist/src/browser/find.js.map +1 -1
- package/dist/src/browser/har.d.ts.map +1 -1
- package/dist/src/browser/har.js +14 -7
- package/dist/src/browser/har.js.map +1 -1
- package/dist/src/browser/index.d.ts +17 -16
- package/dist/src/browser/index.d.ts.map +1 -1
- package/dist/src/browser/index.js +17 -16
- package/dist/src/browser/index.js.map +1 -1
- package/dist/src/browser/network.d.ts.map +1 -1
- package/dist/src/browser/network.js +14 -4
- package/dist/src/browser/network.js.map +1 -1
- package/dist/src/browser/pdf.d.ts.map +1 -1
- package/dist/src/browser/pdf.js +3 -3
- package/dist/src/browser/pdf.js.map +1 -1
- package/dist/src/browser/profiler.d.ts.map +1 -1
- package/dist/src/browser/profiler.js +22 -20
- package/dist/src/browser/profiler.js.map +1 -1
- package/dist/src/browser/record.d.ts +1 -1
- package/dist/src/browser/record.d.ts.map +1 -1
- package/dist/src/browser/record.js +18 -7
- package/dist/src/browser/record.js.map +1 -1
- package/dist/src/browser/ref-cache.d.ts.map +1 -1
- package/dist/src/browser/ref-cache.js +9 -4
- package/dist/src/browser/ref-cache.js.map +1 -1
- package/dist/src/browser/screenshot.d.ts.map +1 -1
- package/dist/src/browser/screenshot.js +11 -7
- package/dist/src/browser/screenshot.js.map +1 -1
- package/dist/src/browser/session.d.ts +1 -1
- package/dist/src/browser/session.d.ts.map +1 -1
- package/dist/src/browser/session.js +27 -8
- package/dist/src/browser/session.js.map +1 -1
- package/dist/src/browser/snapshot.d.ts +1 -1
- package/dist/src/browser/snapshot.d.ts.map +1 -1
- package/dist/src/browser/snapshot.js +8 -4
- package/dist/src/browser/snapshot.js.map +1 -1
- package/dist/src/browser/storage.d.ts.map +1 -1
- package/dist/src/browser/storage.js.map +1 -1
- package/dist/src/browser/tabs.d.ts.map +1 -1
- package/dist/src/browser/tabs.js +9 -3
- package/dist/src/browser/tabs.js.map +1 -1
- package/dist/src/browser/trace.d.ts.map +1 -1
- package/dist/src/browser/trace.js +3 -3
- package/dist/src/browser/trace.js.map +1 -1
- package/dist/src/browser/types.d.ts.map +1 -1
- package/dist/src/browser/types.js +23 -4
- package/dist/src/browser/types.js.map +1 -1
- package/dist/src/browser/vitals.d.ts.map +1 -1
- package/dist/src/browser/vitals.js +1 -1
- package/dist/src/browser/vitals.js.map +1 -1
- package/dist/src/browser/wait.d.ts.map +1 -1
- package/dist/src/browser/wait.js +11 -5
- package/dist/src/browser/wait.js.map +1 -1
- package/dist/src/cli/action.d.ts.map +1 -1
- package/dist/src/cli/action.js +14 -11
- package/dist/src/cli/action.js.map +1 -1
- package/dist/src/cli/commands.d.ts.map +1 -1
- package/dist/src/cli/commands.js +707 -159
- package/dist/src/cli/commands.js.map +1 -1
- package/dist/src/cli/output.js +5 -5
- package/dist/src/cli/output.js.map +1 -1
- package/dist/src/cli/platform.d.ts.map +1 -1
- package/dist/src/cli/platform.js +15 -11
- package/dist/src/cli/platform.js.map +1 -1
- package/dist/src/cli/types.d.ts +1 -1
- package/dist/src/cli/types.d.ts.map +1 -1
- package/dist/src/cli.js +21 -9
- package/dist/src/cli.js.map +1 -1
- package/dist/src/index.d.ts +2 -2
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +2 -2
- package/dist/src/index.js.map +1 -1
- package/dist/src/report/a11y.d.ts +52 -0
- package/dist/src/report/a11y.d.ts.map +1 -0
- package/dist/src/report/a11y.js +231 -0
- package/dist/src/report/a11y.js.map +1 -0
- package/dist/src/report/analyze.d.ts +20 -0
- package/dist/src/report/analyze.d.ts.map +1 -0
- package/dist/src/report/analyze.js +136 -0
- package/dist/src/report/analyze.js.map +1 -0
- package/dist/src/report/budget.d.ts +27 -0
- package/dist/src/report/budget.d.ts.map +1 -0
- package/dist/src/report/budget.js +143 -0
- package/dist/src/report/budget.js.map +1 -0
- package/dist/src/report/collect-a11y.d.ts +25 -0
- package/dist/src/report/collect-a11y.d.ts.map +1 -0
- package/dist/src/report/collect-a11y.js +154 -0
- package/dist/src/report/collect-a11y.js.map +1 -0
- package/dist/src/report/collect.d.ts +45 -0
- package/dist/src/report/collect.d.ts.map +1 -0
- package/dist/src/report/collect.js +275 -0
- package/dist/src/report/collect.js.map +1 -0
- package/dist/src/report/evidence.d.ts +80 -0
- package/dist/src/report/evidence.d.ts.map +1 -0
- package/dist/src/report/evidence.js +279 -0
- package/dist/src/report/evidence.js.map +1 -0
- package/dist/src/report/flake.d.ts +88 -0
- package/dist/src/report/flake.d.ts.map +1 -0
- package/dist/src/report/flake.js +232 -0
- package/dist/src/report/flake.js.map +1 -0
- package/dist/src/report/history.d.ts +85 -0
- package/dist/src/report/history.d.ts.map +1 -0
- package/dist/src/report/history.js +176 -0
- package/dist/src/report/history.js.map +1 -0
- package/dist/src/report/index.d.ts +102 -0
- package/dist/src/report/index.d.ts.map +1 -0
- package/dist/src/report/index.js +216 -0
- package/dist/src/report/index.js.map +1 -0
- package/dist/src/report/pixel-diff.d.ts +30 -0
- package/dist/src/report/pixel-diff.d.ts.map +1 -0
- package/dist/src/report/pixel-diff.js +175 -0
- package/dist/src/report/pixel-diff.js.map +1 -0
- package/dist/src/report/png.d.ts +28 -0
- package/dist/src/report/png.d.ts.map +1 -0
- package/dist/src/report/png.js +259 -0
- package/dist/src/report/png.js.map +1 -0
- package/dist/src/report/render-evidence.d.ts +16 -0
- package/dist/src/report/render-evidence.d.ts.map +1 -0
- package/dist/src/report/render-evidence.js +174 -0
- package/dist/src/report/render-evidence.js.map +1 -0
- package/dist/src/report/render-trend.d.ts +16 -0
- package/dist/src/report/render-trend.d.ts.map +1 -0
- package/dist/src/report/render-trend.js +194 -0
- package/dist/src/report/render-trend.js.map +1 -0
- package/dist/src/report/render.d.ts +21 -0
- package/dist/src/report/render.d.ts.map +1 -0
- package/dist/src/report/render.js +286 -0
- package/dist/src/report/render.js.map +1 -0
- package/dist/src/report/run-diff.d.ts +18 -0
- package/dist/src/report/run-diff.d.ts.map +1 -0
- package/dist/src/report/run-diff.js +51 -0
- package/dist/src/report/run-diff.js.map +1 -0
- package/dist/src/report/structure.d.ts +41 -0
- package/dist/src/report/structure.d.ts.map +1 -0
- package/dist/src/report/structure.js +210 -0
- package/dist/src/report/structure.js.map +1 -0
- package/dist/src/report/trend.d.ts +26 -0
- package/dist/src/report/trend.d.ts.map +1 -0
- package/dist/src/report/trend.js +164 -0
- package/dist/src/report/trend.js.map +1 -0
- package/dist/src/report/types.d.ts +320 -0
- package/dist/src/report/types.d.ts.map +1 -0
- package/dist/src/report/types.js +11 -0
- package/dist/src/report/types.js.map +1 -0
- package/dist/src/report/util.d.ts +17 -0
- package/dist/src/report/util.d.ts.map +1 -0
- package/dist/src/report/util.js +45 -0
- package/dist/src/report/util.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/__tests__/adapters.test.ts +15 -8
- package/src/__tests__/batch-eval-parsing.test.ts +4 -2
- package/src/__tests__/box-model.test.ts +9 -5
- package/src/__tests__/browser-launch.test.ts +26 -15
- package/src/__tests__/cdp-client.test.ts +25 -9
- package/src/__tests__/close-browser.test.ts +11 -6
- package/src/__tests__/console-log.test.ts +36 -13
- package/src/__tests__/find.test.ts +58 -20
- package/src/__tests__/frame-command.test.ts +12 -6
- package/src/__tests__/open-url-navigate-error.test.ts +2 -4
- package/src/__tests__/output.test.ts +1 -1
- package/src/__tests__/profiler-heap.test.ts +191 -0
- package/src/__tests__/reap-idle-browser.test.ts +276 -0
- package/src/__tests__/ref-cache.test.ts +25 -12
- package/src/__tests__/report-a11y.test.ts +180 -0
- package/src/__tests__/report-analyze.test.ts +202 -0
- package/src/__tests__/report-budget.test.ts +92 -0
- package/src/__tests__/report-diff.test.ts +272 -0
- package/src/__tests__/report-evidence.test.ts +276 -0
- package/src/__tests__/report-flake.test.ts +214 -0
- package/src/__tests__/report-history.test.ts +293 -0
- package/src/__tests__/report-output.test.ts +210 -0
- package/src/__tests__/report-png.test.ts +95 -0
- package/src/__tests__/report-render-extras.test.ts +315 -0
- package/src/__tests__/report-render.test.ts +175 -0
- package/src/__tests__/report-run.test.ts +264 -0
- package/src/__tests__/session-state.test.ts +40 -28
- package/src/__tests__/snapshot.test.ts +60 -21
- package/src/__tests__/storage-eval.test.ts +30 -16
- package/src/__tests__/tabs.test.ts +26 -10
- package/src/browser/action-builder/analyzer.ts +39 -15
- package/src/browser/action-builder/types.ts +45 -8
- package/src/browser/actions.ts +288 -123
- package/src/browser/adapters/gemini.ts +7 -3
- package/src/browser/adapters/google.ts +5 -3
- package/src/browser/adapters/index.ts +5 -4
- package/src/browser/adapters/instagram.ts +4 -2
- package/src/browser/adapters/linkedin.ts +4 -2
- package/src/browser/adapters/microsoft.ts +9 -5
- package/src/browser/adapters/x.ts +7 -3
- package/src/browser/browser.ts +188 -42
- package/src/browser/cdp.ts +43 -11
- package/src/browser/console-log.ts +24 -7
- package/src/browser/dashboard/server.ts +44 -25
- package/src/browser/dashboard/ui.html +153 -165
- package/src/browser/dialog.ts +25 -8
- package/src/browser/emulation.ts +86 -36
- package/src/browser/find.ts +110 -47
- package/src/browser/har.ts +58 -26
- package/src/browser/index.ts +17 -16
- package/src/browser/network.ts +147 -46
- package/src/browser/pdf.ts +4 -4
- package/src/browser/profiler.ts +39 -19
- package/src/browser/record.ts +38 -19
- package/src/browser/ref-cache.ts +38 -17
- package/src/browser/screenshot.ts +58 -32
- package/src/browser/session.ts +51 -18
- package/src/browser/snapshot.ts +34 -20
- package/src/browser/storage.ts +73 -17
- package/src/browser/tabs.ts +29 -17
- package/src/browser/trace.ts +14 -10
- package/src/browser/types.ts +23 -4
- package/src/browser/vitals.ts +33 -16
- package/src/browser/wait.ts +47 -21
- package/src/cli/action.ts +52 -21
- package/src/cli/commands.ts +1365 -385
- package/src/cli/output.ts +5 -5
- package/src/cli/platform.ts +44 -23
- package/src/cli/types.ts +1 -1
- package/src/cli.ts +34 -18
- package/src/index.ts +2 -2
- package/src/report/a11y.ts +242 -0
- package/src/report/analyze.ts +181 -0
- package/src/report/budget.ts +155 -0
- package/src/report/collect-a11y.ts +200 -0
- package/src/report/collect.ts +408 -0
- package/src/report/evidence.ts +379 -0
- package/src/report/flake.ts +303 -0
- package/src/report/history.ts +219 -0
- package/src/report/index.ts +307 -0
- package/src/report/pixel-diff.ts +197 -0
- package/src/report/png.ts +290 -0
- package/src/report/render-evidence.ts +194 -0
- package/src/report/render-trend.ts +225 -0
- package/src/report/render.ts +314 -0
- package/src/report/run-diff.ts +63 -0
- package/src/report/structure.ts +212 -0
- package/src/report/trend.ts +189 -0
- package/src/report/types.ts +356 -0
- package/src/report/util.ts +48 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RIG-14 — flake detection across repeated runs.
|
|
3
|
+
*
|
|
4
|
+
* The load-bearing assertion in here is that a check which passed some runs
|
|
5
|
+
* and failed others never produces a `pass` verdict. That is the whole story:
|
|
6
|
+
* the failure mode is a red check being re-run until it is green.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, it } from 'vitest';
|
|
9
|
+
import { buildReport } from '../report/analyze.js';
|
|
10
|
+
import { DEFAULT_BUDGET, parseBudget } from '../report/budget.js';
|
|
11
|
+
import {
|
|
12
|
+
analyzeFlake,
|
|
13
|
+
ruledOutFailureRate,
|
|
14
|
+
signalSignature,
|
|
15
|
+
summarizeFlake,
|
|
16
|
+
} from '../report/flake.js';
|
|
17
|
+
import type { Budget, CaptureData, Report } from '../report/types.js';
|
|
18
|
+
|
|
19
|
+
function capture(partial: Partial<CaptureData> = {}): CaptureData {
|
|
20
|
+
return {
|
|
21
|
+
url: 'https://shop.test/checkout',
|
|
22
|
+
finalUrl: 'https://shop.test/checkout',
|
|
23
|
+
title: 'Checkout',
|
|
24
|
+
capturedAt: '2026-09-21T10:00:00.000Z',
|
|
25
|
+
durationMs: 1000,
|
|
26
|
+
console: [],
|
|
27
|
+
pageErrors: [],
|
|
28
|
+
requests: [],
|
|
29
|
+
vitals: { lcp: 2000, cls: 0.01, inp: 50 },
|
|
30
|
+
a11y: [],
|
|
31
|
+
screenshots: [],
|
|
32
|
+
notes: [],
|
|
33
|
+
...partial,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const run = (partial: Partial<CaptureData> = {}, budget: Budget = DEFAULT_BUDGET): Report =>
|
|
38
|
+
buildReport(capture(partial), budget);
|
|
39
|
+
|
|
40
|
+
const err = (text: string) => ({ type: 'error', text, timestamp: 0 });
|
|
41
|
+
|
|
42
|
+
describe('analyzeFlake verdict', () => {
|
|
43
|
+
it('passes when every check held across every run', () => {
|
|
44
|
+
const flake = analyzeFlake([run(), run(), run(), run(), run()]);
|
|
45
|
+
expect(flake.verdict).toBe('pass');
|
|
46
|
+
expect(flake.runs).toBe(5);
|
|
47
|
+
expect(flake.checks.every((c) => c.stable)).toBe(true);
|
|
48
|
+
expect(flake.headline).toContain('PASS');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('refuses to call a check that passed sometimes a pass', () => {
|
|
52
|
+
// Three clean runs, two with a console error — exactly the case that gets
|
|
53
|
+
// shipped as green after a re-run.
|
|
54
|
+
const runs = [
|
|
55
|
+
run(),
|
|
56
|
+
run({ console: [err('boom')] }),
|
|
57
|
+
run(),
|
|
58
|
+
run({ console: [err('boom')] }),
|
|
59
|
+
run(),
|
|
60
|
+
];
|
|
61
|
+
const flake = analyzeFlake(runs);
|
|
62
|
+
expect(flake.verdict).toBe('flaky');
|
|
63
|
+
const check = flake.checks.find((c) => c.key === 'maxConsoleErrors');
|
|
64
|
+
expect(check).toMatchObject({ runs: 5, passed: 3, failed: 2, stable: false });
|
|
65
|
+
expect(check?.flakeRate).toBeCloseTo(0.4, 5);
|
|
66
|
+
expect(flake.headline).toContain('FLAKY');
|
|
67
|
+
expect(flake.headline).toContain('failed 2 of 5');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('fails outright when a check failed in every run', () => {
|
|
71
|
+
const runs = [run({ console: [err('boom')] }), run({ console: [err('boom')] })];
|
|
72
|
+
const flake = analyzeFlake(runs);
|
|
73
|
+
expect(flake.verdict).toBe('fail');
|
|
74
|
+
expect(flake.headline).toBe('FAIL — 1 check(s) failed in all 2 runs.');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('prefers flaky over fail when one check is unstable and another always fails', () => {
|
|
78
|
+
const budget = parseBudget({ lcp: 1000 });
|
|
79
|
+
const runs = [
|
|
80
|
+
run({ console: [err('boom')] }, budget),
|
|
81
|
+
run({}, budget), // lcp still breaches; the console error does not recur
|
|
82
|
+
];
|
|
83
|
+
const flake = analyzeFlake(runs);
|
|
84
|
+
expect(flake.checks.find((c) => c.key === 'lcpMs')?.failed).toBe(2);
|
|
85
|
+
expect(flake.verdict).toBe('flaky');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('records the measured value for every run, not just the failing ones', () => {
|
|
89
|
+
const runs = [run({ vitals: { lcp: 1900 } }), run({ vitals: { lcp: 3400 } })];
|
|
90
|
+
const lcp = analyzeFlake(runs).checks.find((c) => c.key === 'lcpMs');
|
|
91
|
+
expect(lcp?.actuals).toEqual(['1900ms', '3400ms']);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('ignores budgets that are not enforced', () => {
|
|
95
|
+
const budget = parseBudget({ ttfb: null, fcp: null });
|
|
96
|
+
const keys = analyzeFlake([run({}, budget)]).checks.map((c) => c.key);
|
|
97
|
+
expect(keys).not.toContain('ttfbMs');
|
|
98
|
+
expect(keys).not.toContain('fcpMs');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('throws rather than inventing a verdict from no runs', () => {
|
|
102
|
+
expect(() => analyzeFlake([])).toThrow(/at least one run/);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('analyzeFlake signals', () => {
|
|
107
|
+
it('reports how many runs an intermittent console error appeared in', () => {
|
|
108
|
+
const runs = [run(), run({ console: [err('Failed to fetch /cart')] }), run(), run(), run()];
|
|
109
|
+
const signal = analyzeFlake(runs).signals.find((s) => s.kind === 'console');
|
|
110
|
+
expect(signal).toMatchObject({ runs: 1, total: 5, signature: 'Failed to fetch /cart' });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('does not call an error that happened every run a flake', () => {
|
|
114
|
+
const runs = [run({ console: [err('always')] }), run({ console: [err('always')] })];
|
|
115
|
+
expect(analyzeFlake(runs).signals).toEqual([]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('groups the same error carrying a different request id', () => {
|
|
119
|
+
const runs = [
|
|
120
|
+
run({ console: [err('Request 1043321 failed')] }),
|
|
121
|
+
run({ console: [err('Request 9910412 failed')] }),
|
|
122
|
+
run(),
|
|
123
|
+
];
|
|
124
|
+
const signals = analyzeFlake(runs).signals;
|
|
125
|
+
expect(signals.length).toBe(1);
|
|
126
|
+
expect(signals[0]).toMatchObject({ runs: 2, total: 3 });
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('reports an intermittently failing request', () => {
|
|
130
|
+
const failing = {
|
|
131
|
+
requests: [
|
|
132
|
+
{ url: 'https://api.test/cart', method: 'GET', status: 503, failed: true },
|
|
133
|
+
{ url: 'https://api.test/ok', method: 'GET', status: 200, failed: false },
|
|
134
|
+
],
|
|
135
|
+
};
|
|
136
|
+
const signals = analyzeFlake([run(), run(failing), run()]).signals;
|
|
137
|
+
expect(signals).toEqual([
|
|
138
|
+
{ kind: 'request', signature: '503 https://api.test/cart', runs: 1, total: 3 },
|
|
139
|
+
]);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe('signalSignature', () => {
|
|
144
|
+
it('normalises ids, hex and long numbers so one error is one signal', () => {
|
|
145
|
+
expect(signalSignature('Request 1043321 failed')).toBe('Request <n> failed');
|
|
146
|
+
expect(signalSignature('trace 3f2a1b4c-0000-4aaa-8bbb-1234567890ab lost')).toBe(
|
|
147
|
+
'trace <uuid> lost',
|
|
148
|
+
);
|
|
149
|
+
expect(signalSignature('at 0xDEADBEEF')).toBe('at <hex>');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('leaves short numbers alone — a 404 is part of the message', () => {
|
|
153
|
+
expect(signalSignature('GET returned 404')).toBe('GET returned 404');
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe('analyzeFlake metrics', () => {
|
|
158
|
+
it('reports the range a metric varied across, which is the readable form', () => {
|
|
159
|
+
const runs = [1900, 3400, 2100, 2000, 2050].map((lcp) => run({ vitals: { lcp } }));
|
|
160
|
+
const lcp = analyzeFlake(runs).metrics.find((m) => m.key === 'lcp');
|
|
161
|
+
expect(lcp).toMatchObject({ min: 1900, max: 3400, measured: 5, total: 5, unstable: true });
|
|
162
|
+
expect(lcp?.spread).toBe(1500);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('does not flag a metric that barely moved', () => {
|
|
166
|
+
const runs = [2000, 2050, 2010].map((lcp) => run({ vitals: { lcp } }));
|
|
167
|
+
expect(analyzeFlake(runs).metrics.find((m) => m.key === 'lcp')?.unstable).toBe(false);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('counts how many runs actually measured the metric', () => {
|
|
171
|
+
const runs = [
|
|
172
|
+
run({ vitals: { lcp: 2000 } }),
|
|
173
|
+
run({ vitals: {} }),
|
|
174
|
+
run({ vitals: { lcp: 2100 } }),
|
|
175
|
+
];
|
|
176
|
+
expect(analyzeFlake(runs).metrics.find((m) => m.key === 'lcp')?.measured).toBe(2);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe('confidence', () => {
|
|
181
|
+
it('states what a clean window actually rules out rather than implying certainty', () => {
|
|
182
|
+
const flake = analyzeFlake([run(), run(), run(), run(), run()]);
|
|
183
|
+
expect(flake.confidence).toBe('high');
|
|
184
|
+
expect(flake.confidenceNote).toContain('95% confidence');
|
|
185
|
+
expect(flake.confidenceNote).toContain('45%');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('is low after a single run, and says why', () => {
|
|
189
|
+
const flake = analyzeFlake([run()]);
|
|
190
|
+
expect(flake.confidence).toBe('low');
|
|
191
|
+
expect(flake.confidenceNote).toContain('1 run');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('is high for an observed flake, because both outcomes were seen', () => {
|
|
195
|
+
const flake = analyzeFlake([run(), run({ console: [err('boom')] })]);
|
|
196
|
+
expect(flake.confidence).toBe('high');
|
|
197
|
+
expect(flake.confidenceNote).toContain('observed, not inferred');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('shrinks the ruled-out rate as runs are added', () => {
|
|
201
|
+
expect(ruledOutFailureRate(1)).toBeCloseTo(0.95, 5);
|
|
202
|
+
expect(ruledOutFailureRate(5)).toBeCloseTo(0.4507, 3);
|
|
203
|
+
expect(ruledOutFailureRate(20)).toBeCloseTo(0.1391, 3);
|
|
204
|
+
expect(ruledOutFailureRate(5)).toBeGreaterThan(ruledOutFailureRate(20));
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe('summarizeFlake', () => {
|
|
209
|
+
it('is one line naming the verdict and the confidence', () => {
|
|
210
|
+
const line = summarizeFlake(analyzeFlake([run(), run({ console: [err('boom')] })]));
|
|
211
|
+
expect(line).toMatch(/^FLAKY — /);
|
|
212
|
+
expect(line).toContain('Confidence: high.');
|
|
213
|
+
});
|
|
214
|
+
});
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RIG-13 — run history on disk and the trends computed from it.
|
|
3
|
+
*
|
|
4
|
+
* The filesystem half uses a scratch directory under the monomind data dir
|
|
5
|
+
* (never the shared /tmp) and points MONOBROWSE_HISTORY_DIR at it, so no test
|
|
6
|
+
* can touch a real history.
|
|
7
|
+
*/
|
|
8
|
+
import { mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises';
|
|
9
|
+
import { homedir } from 'node:os';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
12
|
+
import { buildReport } from '../report/analyze.js';
|
|
13
|
+
import { DEFAULT_BUDGET } from '../report/budget.js';
|
|
14
|
+
import {
|
|
15
|
+
type HistoryRun,
|
|
16
|
+
historyKey,
|
|
17
|
+
listRuns,
|
|
18
|
+
loadRunScreenshot,
|
|
19
|
+
pruneHistory,
|
|
20
|
+
saveRun,
|
|
21
|
+
toHistoryRun,
|
|
22
|
+
} from '../report/history.js';
|
|
23
|
+
import { encodePng, pngToDataUrl } from '../report/png.js';
|
|
24
|
+
import { buildTrend, formatTrendDelta, formatTrendValue } from '../report/trend.js';
|
|
25
|
+
import type { Budget, CaptureData, Report, VitalsData } from '../report/types.js';
|
|
26
|
+
|
|
27
|
+
function capture(partial: Partial<CaptureData> = {}): CaptureData {
|
|
28
|
+
return {
|
|
29
|
+
url: 'https://shop.test/checkout',
|
|
30
|
+
finalUrl: 'https://shop.test/checkout',
|
|
31
|
+
title: 'Checkout',
|
|
32
|
+
capturedAt: '2026-09-21T10:00:00.000Z',
|
|
33
|
+
durationMs: 1200,
|
|
34
|
+
console: [],
|
|
35
|
+
pageErrors: [],
|
|
36
|
+
requests: [],
|
|
37
|
+
vitals: { lcp: 2000, cls: 0.01, inp: 50 },
|
|
38
|
+
a11y: [],
|
|
39
|
+
screenshots: [],
|
|
40
|
+
notes: [],
|
|
41
|
+
...partial,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const report = (partial: Partial<CaptureData> = {}, budget: Budget = DEFAULT_BUDGET): Report =>
|
|
46
|
+
buildReport(capture(partial), budget);
|
|
47
|
+
|
|
48
|
+
/** A prior run, shaped exactly as it comes back off disk. */
|
|
49
|
+
function historyRun(
|
|
50
|
+
capturedAt: string,
|
|
51
|
+
vitals: VitalsData,
|
|
52
|
+
counts: Partial<Report['counts']> = {},
|
|
53
|
+
) {
|
|
54
|
+
const run = toHistoryRun(report({ capturedAt, vitals }), `id-${capturedAt}`);
|
|
55
|
+
run.counts = { ...run.counts, ...counts };
|
|
56
|
+
run.verdict = (counts.consoleErrors ?? 0) > 0 ? 'fail' : 'pass';
|
|
57
|
+
return run;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function tinyPng(width = 4, height = 4, shade = 200) {
|
|
61
|
+
const data = new Uint8Array(width * height * 4);
|
|
62
|
+
for (let i = 0; i < width * height; i++) data.set([shade, shade, shade, 255], i * 4);
|
|
63
|
+
return encodePng({ width, height, data });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// Storage
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
describe('historyKey', () => {
|
|
71
|
+
it('is readable and includes a hash of the full URL', () => {
|
|
72
|
+
expect(historyKey('https://shop.test/checkout')).toMatch(/^shop\.test-checkout-[0-9a-f]{8}$/);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('does not collide across URLs that differ only in the query string', () => {
|
|
76
|
+
expect(historyKey('https://a.test/list?page=2')).not.toBe(
|
|
77
|
+
historyKey('https://a.test/list?page=3'),
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('survives a string that is not a URL at all', () => {
|
|
82
|
+
expect(historyKey('not a url')).toMatch(/^not-a-url-[0-9a-f]{8}$/);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('run history on disk', () => {
|
|
87
|
+
let root: string;
|
|
88
|
+
|
|
89
|
+
beforeEach(async () => {
|
|
90
|
+
await mkdir(join(homedir(), '.monomind'), { recursive: true });
|
|
91
|
+
root = await mkdtemp(join(homedir(), '.monomind', 'monobrowse-history-test-'));
|
|
92
|
+
process.env.MONOBROWSE_HISTORY_DIR = root;
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
afterEach(async () => {
|
|
96
|
+
delete process.env.MONOBROWSE_HISTORY_DIR;
|
|
97
|
+
await rm(root, { recursive: true, force: true });
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('writes one JSON record per run, keyed by URL', async () => {
|
|
101
|
+
const saved = await saveRun(report(), { root, id: '20260921T100000Z-aaaaaaaa' });
|
|
102
|
+
expect(saved.dir).toBe(join(root, historyKey('https://shop.test/checkout')));
|
|
103
|
+
const files = await readdir(saved.dir);
|
|
104
|
+
expect(files).toContain('20260921T100000Z-aaaaaaaa.json');
|
|
105
|
+
expect(files).toContain('url.txt');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('archives the main screenshot alongside so the next run can diff pixels', async () => {
|
|
109
|
+
const png = tinyPng();
|
|
110
|
+
const saved = await saveRun(
|
|
111
|
+
report({
|
|
112
|
+
screenshots: [{ label: 'page', width: 4, height: 4, dataUrl: pngToDataUrl(png) }],
|
|
113
|
+
}),
|
|
114
|
+
{ root, id: '20260921T100000Z-bbbbbbbb' },
|
|
115
|
+
);
|
|
116
|
+
expect(saved.run.screenshotFile).toBe('20260921T100000Z-bbbbbbbb.png');
|
|
117
|
+
const loaded = await loadRunScreenshot(saved.dir, saved.run);
|
|
118
|
+
expect(loaded && Buffer.from(loaded).equals(png)).toBe(true);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('returns null for a screenshot that was never archived', async () => {
|
|
122
|
+
const saved = await saveRun(report(), { root, id: '20260921T100000Z-cccccccc' });
|
|
123
|
+
expect(await loadRunScreenshot(saved.dir, saved.run)).toBeNull();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('lists runs oldest first', async () => {
|
|
127
|
+
for (const ts of ['2026-09-21T12:00:00.000Z', '2026-09-21T10:00:00.000Z']) {
|
|
128
|
+
await saveRun(report({ capturedAt: ts }), { root });
|
|
129
|
+
}
|
|
130
|
+
const dir = join(root, historyKey('https://shop.test/checkout'));
|
|
131
|
+
expect((await listRuns(dir)).map((r) => r.capturedAt)).toEqual([
|
|
132
|
+
'2026-09-21T10:00:00.000Z',
|
|
133
|
+
'2026-09-21T12:00:00.000Z',
|
|
134
|
+
]);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('prunes to the configured maximum, oldest first, taking the PNGs with them', async () => {
|
|
138
|
+
const dir = join(root, historyKey('https://shop.test/checkout'));
|
|
139
|
+
for (let i = 0; i < 5; i++) {
|
|
140
|
+
await saveRun(
|
|
141
|
+
report({
|
|
142
|
+
capturedAt: `2026-09-2${i + 1}T10:00:00.000Z`,
|
|
143
|
+
screenshots: [{ label: 'page', width: 4, height: 4, dataUrl: pngToDataUrl(tinyPng()) }],
|
|
144
|
+
}),
|
|
145
|
+
{ root, max: 3, id: `2026092${i + 1}T100000Z-0000000${i}` },
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
const runs = await listRuns(dir);
|
|
149
|
+
expect(runs.map((r) => r.capturedAt)).toEqual([
|
|
150
|
+
'2026-09-23T10:00:00.000Z',
|
|
151
|
+
'2026-09-24T10:00:00.000Z',
|
|
152
|
+
'2026-09-25T10:00:00.000Z',
|
|
153
|
+
]);
|
|
154
|
+
const files = await readdir(dir);
|
|
155
|
+
expect(files.filter((f) => f.endsWith('.png')).length).toBe(3);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('keeps everything when the history is shorter than the maximum', async () => {
|
|
159
|
+
const dir = join(root, historyKey('https://shop.test/checkout'));
|
|
160
|
+
await saveRun(report(), { root, max: 10 });
|
|
161
|
+
expect(await pruneHistory(dir, 10)).toEqual([]);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('skips a corrupt record instead of failing the whole read', async () => {
|
|
165
|
+
const dir = join(root, historyKey('https://shop.test/checkout'));
|
|
166
|
+
await saveRun(report(), { root, id: '20260921T100000Z-dddddddd' });
|
|
167
|
+
await writeFile(join(dir, '20260922T100000Z-eeeeeeee.json'), '{ truncated', 'utf8');
|
|
168
|
+
const runs = await listRuns(dir);
|
|
169
|
+
expect(runs.map((r) => r.id)).toEqual(['20260921T100000Z-dddddddd']);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('returns an empty history for a URL never reported on', async () => {
|
|
173
|
+
expect(await listRuns(join(root, 'never-seen-00000000'))).toEqual([]);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
// Trends
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
describe('buildTrend', () => {
|
|
182
|
+
it('reports no prior runs on a first run without inventing a direction', () => {
|
|
183
|
+
const trend = buildTrend(report(), []);
|
|
184
|
+
expect(trend.runs).toBe(0);
|
|
185
|
+
expect(trend.windowFrom).toBeNull();
|
|
186
|
+
expect(trend.series.every((s) => s.direction === 'new')).toBe(true);
|
|
187
|
+
expect(trend.headlines).toEqual([]);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('names the slow decay the story is about', () => {
|
|
191
|
+
// LCP walks 2000 -> 2400ms across nine prior runs, never once crossing
|
|
192
|
+
// the 2500ms budget, so no individual run ever failed.
|
|
193
|
+
const history: HistoryRun[] = [];
|
|
194
|
+
for (let i = 0; i < 9; i++) {
|
|
195
|
+
history.push(historyRun(`2026-09-${10 + i}T10:00:00.000Z`, { lcp: 2000 + i * 50 }));
|
|
196
|
+
}
|
|
197
|
+
const trend = buildTrend(
|
|
198
|
+
report({ capturedAt: '2026-09-21T10:00:00.000Z', vitals: { lcp: 2400 } }),
|
|
199
|
+
history,
|
|
200
|
+
);
|
|
201
|
+
const lcp = trend.series.find((s) => s.key === 'lcp');
|
|
202
|
+
expect(lcp?.deltaFromOldest).toBe(400);
|
|
203
|
+
expect(lcp?.creep).toBe('LCP has crept up 400ms over 10 runs (2.00s → 2.40s).');
|
|
204
|
+
expect(trend.headlines[0]).toContain('crept up 400ms');
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('does not call rasteriser-scale jitter a regression', () => {
|
|
208
|
+
const history = [historyRun('2026-09-20T10:00:00.000Z', { lcp: 2000 })];
|
|
209
|
+
const trend = buildTrend(report({ vitals: { lcp: 2010 } }), history);
|
|
210
|
+
expect(trend.series.find((s) => s.key === 'lcp')?.direction).toBe('flat');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('calls a real movement a regression, and the reverse an improvement', () => {
|
|
214
|
+
const history = [historyRun('2026-09-20T10:00:00.000Z', { lcp: 2000 })];
|
|
215
|
+
expect(
|
|
216
|
+
buildTrend(report({ vitals: { lcp: 2600 } }), history).series.find((s) => s.key === 'lcp')
|
|
217
|
+
?.direction,
|
|
218
|
+
).toBe('regressed');
|
|
219
|
+
expect(
|
|
220
|
+
buildTrend(report({ vitals: { lcp: 1400 } }), history).series.find((s) => s.key === 'lcp')
|
|
221
|
+
?.direction,
|
|
222
|
+
).toBe('improved');
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('treats a single new console error as a regression', () => {
|
|
226
|
+
const history = [historyRun('2026-09-20T10:00:00.000Z', { lcp: 2000 })];
|
|
227
|
+
const current = report({
|
|
228
|
+
console: [{ type: 'error', text: 'boom', timestamp: 0 }],
|
|
229
|
+
});
|
|
230
|
+
const errors = buildTrend(current, history).series.find((s) => s.key === 'consoleErrors');
|
|
231
|
+
expect(errors).toMatchObject({
|
|
232
|
+
current: 1,
|
|
233
|
+
previous: 0,
|
|
234
|
+
deltaFromPrevious: 1,
|
|
235
|
+
direction: 'regressed',
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('leads with a first failure after a clean window', () => {
|
|
240
|
+
const history = [1, 2, 3].map((i) => historyRun(`2026-09-1${i}T10:00:00.000Z`, { lcp: 2000 }));
|
|
241
|
+
const failing = report({ console: [{ type: 'error', text: 'boom', timestamp: 0 }] });
|
|
242
|
+
expect(failing.verdict).toBe('fail');
|
|
243
|
+
expect(buildTrend(failing, history).headlines[0]).toBe(
|
|
244
|
+
'First failure in this window — the previous 3 run(s) all passed.',
|
|
245
|
+
);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('includes this run as the last point of every series', () => {
|
|
249
|
+
const history = [historyRun('2026-09-20T10:00:00.000Z', { lcp: 2000 })];
|
|
250
|
+
const trend = buildTrend(
|
|
251
|
+
report({ capturedAt: '2026-09-21T10:00:00.000Z', vitals: { lcp: 2100 } }),
|
|
252
|
+
history,
|
|
253
|
+
);
|
|
254
|
+
const lcp = trend.series.find((s) => s.key === 'lcp');
|
|
255
|
+
expect(lcp?.points).toEqual([
|
|
256
|
+
{ capturedAt: '2026-09-20T10:00:00.000Z', value: 2000 },
|
|
257
|
+
{ capturedAt: '2026-09-21T10:00:00.000Z', value: 2100 },
|
|
258
|
+
]);
|
|
259
|
+
expect(trend.verdictHistory.at(-1)).toEqual({
|
|
260
|
+
capturedAt: '2026-09-21T10:00:00.000Z',
|
|
261
|
+
verdict: 'pass',
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('ignores runs that never measured the metric when picking a baseline', () => {
|
|
266
|
+
const history = [
|
|
267
|
+
historyRun('2026-09-18T10:00:00.000Z', { lcp: 2000 }),
|
|
268
|
+
historyRun('2026-09-19T10:00:00.000Z', {}),
|
|
269
|
+
];
|
|
270
|
+
const lcp = buildTrend(report({ vitals: { lcp: 2600 } }), history).series.find(
|
|
271
|
+
(s) => s.key === 'lcp',
|
|
272
|
+
);
|
|
273
|
+
expect(lcp?.previous).toBe(2000);
|
|
274
|
+
expect(lcp?.deltaFromPrevious).toBe(600);
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
describe('trend formatting', () => {
|
|
279
|
+
it('switches to seconds past a second so a reader can see the scale', () => {
|
|
280
|
+
expect(formatTrendValue('ms', 412)).toBe('412ms');
|
|
281
|
+
expect(formatTrendValue('ms', 2450)).toBe('2.45s');
|
|
282
|
+
expect(formatTrendValue('score', 0.1234)).toBe('0.123');
|
|
283
|
+
expect(formatTrendValue('count', 3)).toBe('3');
|
|
284
|
+
expect(formatTrendValue('ms', null)).toBe('—');
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('signs deltas, and says so plainly when there is no change', () => {
|
|
288
|
+
expect(formatTrendDelta('ms', 400)).toBe('+400ms');
|
|
289
|
+
expect(formatTrendDelta('ms', -400)).toBe('−400ms');
|
|
290
|
+
expect(formatTrendDelta('count', 0)).toBe('no change');
|
|
291
|
+
expect(formatTrendDelta('count', null)).toBe('no change');
|
|
292
|
+
});
|
|
293
|
+
});
|