@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,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
|
+
}
|