@monoes/monobrowse 1.0.12 → 1.0.14
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__/bridge-instruments.test.d.ts +2 -0
- package/dist/src/__tests__/bridge-instruments.test.d.ts.map +1 -0
- package/dist/src/__tests__/bridge-instruments.test.js +132 -0
- package/dist/src/__tests__/bridge-instruments.test.js.map +1 -0
- package/dist/src/__tests__/bridge-transport.test.d.ts +2 -0
- package/dist/src/__tests__/bridge-transport.test.d.ts.map +1 -0
- package/dist/src/__tests__/bridge-transport.test.js +287 -0
- package/dist/src/__tests__/bridge-transport.test.js.map +1 -0
- package/dist/src/__tests__/cdp-transport.test.d.ts +2 -0
- package/dist/src/__tests__/cdp-transport.test.d.ts.map +1 -0
- package/dist/src/__tests__/cdp-transport.test.js +167 -0
- package/dist/src/__tests__/cdp-transport.test.js.map +1 -0
- 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__/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/browser/bridge.d.ts +119 -0
- package/dist/src/browser/bridge.d.ts.map +1 -0
- package/dist/src/browser/bridge.js +264 -0
- package/dist/src/browser/bridge.js.map +1 -0
- package/dist/src/browser/cdp.d.ts +11 -1
- package/dist/src/browser/cdp.d.ts.map +1 -1
- package/dist/src/browser/cdp.js +60 -56
- package/dist/src/browser/cdp.js.map +1 -1
- package/dist/src/browser/index.d.ts +3 -0
- package/dist/src/browser/index.d.ts.map +1 -1
- package/dist/src/browser/index.js +3 -0
- package/dist/src/browser/index.js.map +1 -1
- package/dist/src/browser/profiler.d.ts.map +1 -1
- package/dist/src/browser/profiler.js +19 -22
- package/dist/src/browser/profiler.js.map +1 -1
- package/dist/src/browser/transport.d.ts +62 -0
- package/dist/src/browser/transport.d.ts.map +1 -0
- package/dist/src/browser/transport.js +65 -0
- package/dist/src/browser/transport.js.map +1 -0
- package/dist/src/cli/commander-adapter.d.ts +16 -0
- package/dist/src/cli/commander-adapter.d.ts.map +1 -0
- package/dist/src/cli/commander-adapter.js +68 -0
- package/dist/src/cli/commander-adapter.js.map +1 -0
- package/dist/src/cli/commands-context.d.ts +14 -0
- package/dist/src/cli/commands-context.d.ts.map +1 -0
- package/dist/src/cli/commands-context.js +351 -0
- package/dist/src/cli/commands-context.js.map +1 -0
- package/dist/src/cli/commands-data.d.ts +12 -0
- package/dist/src/cli/commands-data.d.ts.map +1 -0
- package/dist/src/cli/commands-data.js +246 -0
- package/dist/src/cli/commands-data.js.map +1 -0
- package/dist/src/cli/commands-element.d.ts +15 -0
- package/dist/src/cli/commands-element.d.ts.map +1 -0
- package/dist/src/cli/commands-element.js +262 -0
- package/dist/src/cli/commands-element.js.map +1 -0
- package/dist/src/cli/commands-files.d.ts +9 -0
- package/dist/src/cli/commands-files.d.ts.map +1 -0
- package/dist/src/cli/commands-files.js +207 -0
- package/dist/src/cli/commands-files.js.map +1 -0
- package/dist/src/cli/commands-input.d.ts +26 -0
- package/dist/src/cli/commands-input.d.ts.map +1 -0
- package/dist/src/cli/commands-input.js +399 -0
- package/dist/src/cli/commands-input.js.map +1 -0
- package/dist/src/cli/commands-navigate.d.ts +12 -0
- package/dist/src/cli/commands-navigate.d.ts.map +1 -0
- package/dist/src/cli/commands-navigate.js +389 -0
- package/dist/src/cli/commands-navigate.js.map +1 -0
- package/dist/src/cli/commands-page.d.ts +25 -0
- package/dist/src/cli/commands-page.d.ts.map +1 -0
- package/dist/src/cli/commands-page.js +377 -0
- package/dist/src/cli/commands-page.js.map +1 -0
- package/dist/src/cli/commands-report.d.ts +8 -0
- package/dist/src/cli/commands-report.d.ts.map +1 -0
- package/dist/src/cli/commands-report.js +256 -0
- package/dist/src/cli/commands-report.js.map +1 -0
- package/dist/src/cli/commands-script.d.ts +43 -0
- package/dist/src/cli/commands-script.d.ts.map +1 -0
- package/dist/src/cli/commands-script.js +168 -0
- package/dist/src/cli/commands-script.js.map +1 -0
- package/dist/src/cli/commands-session.d.ts +15 -0
- package/dist/src/cli/commands-session.d.ts.map +1 -0
- package/dist/src/cli/commands-session.js +357 -0
- package/dist/src/cli/commands-session.js.map +1 -0
- package/dist/src/cli/commands-trace.d.ts +12 -0
- package/dist/src/cli/commands-trace.d.ts.map +1 -0
- package/dist/src/cli/commands-trace.js +347 -0
- package/dist/src/cli/commands-trace.js.map +1 -0
- package/dist/src/cli/commands.d.ts +22 -42
- package/dist/src/cli/commands.d.ts.map +1 -1
- package/dist/src/cli/commands.js +48 -3400
- package/dist/src/cli/commands.js.map +1 -1
- package/dist/src/cli/session.d.ts +47 -0
- package/dist/src/cli/session.d.ts.map +1 -0
- package/dist/src/cli/session.js +343 -0
- package/dist/src/cli/session.js.map +1 -0
- 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__/bridge-instruments.test.ts +180 -0
- package/src/__tests__/bridge-transport.test.ts +365 -0
- package/src/__tests__/cdp-transport.test.ts +198 -0
- package/src/__tests__/profiler-heap.test.ts +191 -0
- 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/browser/bridge.ts +354 -0
- package/src/browser/cdp.ts +57 -52
- package/src/browser/index.ts +3 -0
- package/src/browser/profiler.ts +28 -21
- package/src/browser/transport.ts +111 -0
- package/src/cli/commander-adapter.ts +65 -0
- package/src/cli/commands-context.ts +367 -0
- package/src/cli/commands-data.ts +248 -0
- package/src/cli/commands-element.ts +305 -0
- package/src/cli/commands-files.ts +248 -0
- package/src/cli/commands-input.ts +434 -0
- package/src/cli/commands-navigate.ts +442 -0
- package/src/cli/commands-page.ts +420 -0
- package/src/cli/commands-report.ts +275 -0
- package/src/cli/commands-script.ts +183 -0
- package/src/cli/commands-session.ts +376 -0
- package/src/cli/commands-trace.ts +349 -0
- package/src/cli/commands.ts +101 -3677
- package/src/cli/session.ts +412 -0
- 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,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RIG-11 — recorded evidence around a failure.
|
|
3
|
+
*
|
|
4
|
+
* Only the pure half is tested here (selection and timeline assembly); the
|
|
5
|
+
* recorder itself is exercised with a stub CDP client, so nothing launches
|
|
6
|
+
* Chrome. The bounds are the point: a report must never grow without limit,
|
|
7
|
+
* however long the run or however noisy the page.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
10
|
+
import type { CdpClient } from '../browser/cdp.js';
|
|
11
|
+
import {
|
|
12
|
+
type BuildEvidenceInput,
|
|
13
|
+
buildEvidence,
|
|
14
|
+
findFocusOffset,
|
|
15
|
+
type RawFrame,
|
|
16
|
+
selectFrames,
|
|
17
|
+
startEvidenceRecorder,
|
|
18
|
+
} from '../report/evidence.js';
|
|
19
|
+
|
|
20
|
+
const START = 1_000_000;
|
|
21
|
+
|
|
22
|
+
function frames(count: number, spacingMs = 100, bytes = 1000): RawFrame[] {
|
|
23
|
+
return Array.from({ length: count }, (_, i) => ({
|
|
24
|
+
offsetMs: i * spacingMs,
|
|
25
|
+
data: 'x'.repeat(bytes),
|
|
26
|
+
bytes,
|
|
27
|
+
}));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function input(partial: Partial<BuildEvidenceInput> = {}): BuildEvidenceInput {
|
|
31
|
+
return {
|
|
32
|
+
reason: 'budget-failure',
|
|
33
|
+
startedAtMs: START,
|
|
34
|
+
frames: frames(20),
|
|
35
|
+
console: [],
|
|
36
|
+
pageErrors: [],
|
|
37
|
+
requests: [],
|
|
38
|
+
failures: [],
|
|
39
|
+
...partial,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('findFocusOffset', () => {
|
|
44
|
+
it('picks the earliest hard error as the moment to explain', () => {
|
|
45
|
+
const offset = findFocusOffset(
|
|
46
|
+
input({
|
|
47
|
+
console: [
|
|
48
|
+
{ type: 'error', text: 'late', timestamp: START + 1800 },
|
|
49
|
+
{ type: 'error', text: 'early', timestamp: START + 600 },
|
|
50
|
+
],
|
|
51
|
+
pageErrors: [{ text: 'uncaught', timestamp: START + 900 }],
|
|
52
|
+
}),
|
|
53
|
+
);
|
|
54
|
+
expect(offset).toBe(600);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('ignores console warnings — a warning is not the failure', () => {
|
|
58
|
+
expect(
|
|
59
|
+
findFocusOffset(input({ console: [{ type: 'warn', text: 'meh', timestamp: START + 100 }] })),
|
|
60
|
+
).toBeNull();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('uses a failed request when it carries a clock reading', () => {
|
|
64
|
+
expect(
|
|
65
|
+
findFocusOffset(
|
|
66
|
+
input({
|
|
67
|
+
requests: [
|
|
68
|
+
{ url: 'https://a.test/x', method: 'GET', failed: true, startedAtMs: START + 450 },
|
|
69
|
+
],
|
|
70
|
+
}),
|
|
71
|
+
),
|
|
72
|
+
).toBe(450);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('skips a failed request with no clock reading rather than guessing', () => {
|
|
76
|
+
expect(
|
|
77
|
+
findFocusOffset(
|
|
78
|
+
input({ requests: [{ url: 'https://a.test/x', method: 'GET', failed: true }] }),
|
|
79
|
+
),
|
|
80
|
+
).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('has no focus when the breach is a load-wide metric', () => {
|
|
84
|
+
expect(
|
|
85
|
+
findFocusOffset(
|
|
86
|
+
input({ failures: [{ budget: 'lcpMs', expected: '<= 2500ms', actual: '4100ms' }] }),
|
|
87
|
+
),
|
|
88
|
+
).toBeNull();
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('selectFrames', () => {
|
|
93
|
+
it('keeps the frames nearest the failure', () => {
|
|
94
|
+
const picked = selectFrames(frames(40), 2000, { maxFrames: 5 });
|
|
95
|
+
expect(picked.frames.map((f) => f.offsetMs)).toEqual([1800, 1900, 2000, 2100, 2200]);
|
|
96
|
+
expect(picked.dropped).toBe(35);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('samples the whole run when there is no single moment to centre on', () => {
|
|
100
|
+
const picked = selectFrames(frames(21), null, { maxFrames: 5 });
|
|
101
|
+
expect(picked.frames.map((f) => f.offsetMs)).toEqual([0, 500, 1000, 1500, 2000]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('returns the frames in time order even though it ranks them by distance', () => {
|
|
105
|
+
const picked = selectFrames(frames(40), 2000, { maxFrames: 7 });
|
|
106
|
+
const offsets = picked.frames.map((f) => f.offsetMs);
|
|
107
|
+
expect([...offsets].sort((a, b) => a - b)).toEqual(offsets);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('falls back to the nearest frames when none fall inside the window', () => {
|
|
111
|
+
const picked = selectFrames(frames(10), 60_000, { maxFrames: 3, windowMs: 500 });
|
|
112
|
+
expect(picked.frames.map((f) => f.offsetMs)).toEqual([700, 800, 900]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('enforces the byte budget by evicting the least relevant frame first', () => {
|
|
116
|
+
const picked = selectFrames(frames(40, 100, 1000), 2000, { maxFrames: 10, maxBytes: 3000 });
|
|
117
|
+
expect(picked.frames.length).toBe(3);
|
|
118
|
+
expect(picked.frames.map((f) => f.offsetMs)).toEqual([1900, 2000, 2100]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('handles an empty recording without throwing', () => {
|
|
122
|
+
expect(selectFrames([], 100)).toEqual({ frames: [], dropped: 0 });
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('buildEvidence', () => {
|
|
127
|
+
it('attaches frames around the failure and lines the events up against them', () => {
|
|
128
|
+
const evidence = buildEvidence(
|
|
129
|
+
input({
|
|
130
|
+
frames: frames(40),
|
|
131
|
+
console: [{ type: 'error', text: 'Cannot read x of undefined', timestamp: START + 2000 }],
|
|
132
|
+
requests: [
|
|
133
|
+
{
|
|
134
|
+
url: 'https://api.test/cart',
|
|
135
|
+
method: 'GET',
|
|
136
|
+
status: 503,
|
|
137
|
+
failed: true,
|
|
138
|
+
startedAtMs: START + 2100,
|
|
139
|
+
},
|
|
140
|
+
],
|
|
141
|
+
failures: [{ budget: 'maxConsoleErrors', expected: '<= 0', actual: '1' }],
|
|
142
|
+
select: { maxFrames: 5 },
|
|
143
|
+
}),
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
expect(evidence.focusOffsetMs).toBe(2000);
|
|
147
|
+
expect(evidence.frames.length).toBe(5);
|
|
148
|
+
expect(evidence.frames[0].dataUrl.startsWith('data:image/jpeg;base64,')).toBe(true);
|
|
149
|
+
|
|
150
|
+
const kinds = evidence.timeline.map((e) => e.kind);
|
|
151
|
+
expect(kinds).toContain('console');
|
|
152
|
+
expect(kinds).toContain('request');
|
|
153
|
+
expect(kinds).toContain('marker');
|
|
154
|
+
// Everything is in time order, which is what makes it readable.
|
|
155
|
+
const offsets = evidence.timeline.map((e) => e.offsetMs);
|
|
156
|
+
expect([...offsets].sort((a, b) => a - b)).toEqual(offsets);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('indexes each frame row back to the attached frame it shows', () => {
|
|
160
|
+
const evidence = buildEvidence(input({ frames: frames(6), select: { maxFrames: 3 } }));
|
|
161
|
+
const frameRows = evidence.timeline.filter((e) => e.kind === 'frame');
|
|
162
|
+
expect(frameRows.map((e) => e.frameIndex)).toEqual([0, 1, 2]);
|
|
163
|
+
for (const row of frameRows) {
|
|
164
|
+
expect(evidence.frames[row.frameIndex as number].offsetMs).toBe(row.offsetMs);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('bounds the total attached bytes', () => {
|
|
169
|
+
const evidence = buildEvidence(
|
|
170
|
+
input({
|
|
171
|
+
frames: frames(200, 50, 100_000),
|
|
172
|
+
console: [{ type: 'error', text: 'boom', timestamp: START + 5000 }],
|
|
173
|
+
select: { maxFrames: 12, maxBytes: 500_000 },
|
|
174
|
+
}),
|
|
175
|
+
);
|
|
176
|
+
expect(evidence.totalBytes).toBeLessThanOrEqual(500_000);
|
|
177
|
+
expect(evidence.droppedFrames).toBeGreaterThan(0);
|
|
178
|
+
expect(evidence.notes.join(' ')).toMatch(/not attached/);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('trims a noisy timeline worst-first so the real error survives', () => {
|
|
182
|
+
const warnings = Array.from({ length: 200 }, (_, i) => ({
|
|
183
|
+
type: 'warn',
|
|
184
|
+
text: `deprecation ${i}`,
|
|
185
|
+
timestamp: START + i,
|
|
186
|
+
}));
|
|
187
|
+
const evidence = buildEvidence(
|
|
188
|
+
input({
|
|
189
|
+
frames: frames(4),
|
|
190
|
+
console: [...warnings, { type: 'error', text: 'the real one', timestamp: START + 9999 }],
|
|
191
|
+
maxEvents: 10,
|
|
192
|
+
select: { maxFrames: 4 },
|
|
193
|
+
}),
|
|
194
|
+
);
|
|
195
|
+
const details = evidence.timeline.map((e) => e.detail ?? '');
|
|
196
|
+
expect(details).toContain('the real one');
|
|
197
|
+
expect(evidence.timeline.filter((e) => e.kind !== 'frame').length).toBe(10);
|
|
198
|
+
expect(evidence.notes.join(' ')).toMatch(/Timeline trimmed/);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('explains itself when the breach has no single moment', () => {
|
|
202
|
+
const evidence = buildEvidence(
|
|
203
|
+
input({ failures: [{ budget: 'lcpMs', expected: '<= 2500ms', actual: '4100ms' }] }),
|
|
204
|
+
);
|
|
205
|
+
expect(evidence.focusOffsetMs).toBeNull();
|
|
206
|
+
expect(evidence.notes.join(' ')).toMatch(/load-wide metric/);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('says so plainly when nothing was recorded', () => {
|
|
210
|
+
const evidence = buildEvidence(input({ frames: [] }));
|
|
211
|
+
expect(evidence.frames).toEqual([]);
|
|
212
|
+
expect(evidence.notes.join(' ')).toMatch(/No screencast frames/);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('carries the dropped-frame count from the recorder into the total', () => {
|
|
216
|
+
const evidence = buildEvidence(
|
|
217
|
+
input({ frames: frames(4), bufferDropped: 30, select: { maxFrames: 4 } }),
|
|
218
|
+
);
|
|
219
|
+
expect(evidence.droppedFrames).toBe(30);
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
describe('startEvidenceRecorder', () => {
|
|
224
|
+
function stubClient() {
|
|
225
|
+
const handlers = new Map<string, (params: unknown, sid: string) => void>();
|
|
226
|
+
const send = vi.fn(async () => ({}));
|
|
227
|
+
const client = {
|
|
228
|
+
send,
|
|
229
|
+
on: (event: string, handler: (params: unknown, sid: string) => void) => {
|
|
230
|
+
handlers.set(event, handler);
|
|
231
|
+
return () => handlers.delete(event);
|
|
232
|
+
},
|
|
233
|
+
} as unknown as CdpClient;
|
|
234
|
+
return { client, handlers, send };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
it('starts a screencast and collects timestamped frames', async () => {
|
|
238
|
+
const { client, handlers, send } = stubClient();
|
|
239
|
+
const recorder = await startEvidenceRecorder(client, 'S1', Date.now());
|
|
240
|
+
expect(send).toHaveBeenCalledWith('Page.startScreencast', expect.anything(), 'S1');
|
|
241
|
+
|
|
242
|
+
handlers.get('Page.screencastFrame')?.({ data: 'abc', sessionId: 1 }, 'S1');
|
|
243
|
+
expect(recorder.frames.length).toBe(1);
|
|
244
|
+
expect(recorder.frames[0].bytes).toBe(3);
|
|
245
|
+
// Failing to ack stalls the screencast, so it must happen every frame.
|
|
246
|
+
expect(send).toHaveBeenCalledWith('Page.screencastFrameAck', { sessionId: 1 }, 'S1');
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('ignores frames from another session', async () => {
|
|
250
|
+
const { client, handlers } = stubClient();
|
|
251
|
+
const recorder = await startEvidenceRecorder(client, 'S1', Date.now());
|
|
252
|
+
handlers.get('Page.screencastFrame')?.({ data: 'abc', sessionId: 1 }, 'S2');
|
|
253
|
+
expect(recorder.frames.length).toBe(0);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('decimates rather than dropping the head, so early frames survive a long run', async () => {
|
|
257
|
+
const { client, handlers } = stubClient();
|
|
258
|
+
const recorder = await startEvidenceRecorder(client, 'S1', Date.now(), { maxFrames: 8 });
|
|
259
|
+
const frame = handlers.get('Page.screencastFrame');
|
|
260
|
+
for (let i = 0; i < 20; i++) frame?.({ data: `f${i}`, sessionId: i }, 'S1');
|
|
261
|
+
|
|
262
|
+
expect(recorder.frames.length).toBeLessThanOrEqual(8);
|
|
263
|
+
expect(recorder.droppedFrames).toBeGreaterThan(0);
|
|
264
|
+
// The very first frame is still there — a ring buffer would have lost it.
|
|
265
|
+
expect(recorder.frames[0].data).toBe('f0');
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('unsubscribes and stops the screencast on stop', async () => {
|
|
269
|
+
const { client, handlers, send } = stubClient();
|
|
270
|
+
const recorder = await startEvidenceRecorder(client, 'S1', Date.now());
|
|
271
|
+
await recorder.stop();
|
|
272
|
+
expect(send).toHaveBeenCalledWith('Page.stopScreencast', {}, 'S1');
|
|
273
|
+
handlers.get('Page.screencastFrame')?.({ data: 'abc', sessionId: 1 }, 'S1');
|
|
274
|
+
expect(recorder.frames.length).toBe(0);
|
|
275
|
+
});
|
|
276
|
+
});
|
|
@@ -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
|
+
});
|