@monoes/monobrowse 1.0.12 → 1.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. package/dist/src/__tests__/profiler-heap.test.d.ts +2 -0
  2. package/dist/src/__tests__/profiler-heap.test.d.ts.map +1 -0
  3. package/dist/src/__tests__/profiler-heap.test.js +154 -0
  4. package/dist/src/__tests__/profiler-heap.test.js.map +1 -0
  5. package/dist/src/__tests__/report-a11y.test.d.ts +2 -0
  6. package/dist/src/__tests__/report-a11y.test.d.ts.map +1 -0
  7. package/dist/src/__tests__/report-a11y.test.js +147 -0
  8. package/dist/src/__tests__/report-a11y.test.js.map +1 -0
  9. package/dist/src/__tests__/report-analyze.test.d.ts +2 -0
  10. package/dist/src/__tests__/report-analyze.test.d.ts.map +1 -0
  11. package/dist/src/__tests__/report-analyze.test.js +157 -0
  12. package/dist/src/__tests__/report-analyze.test.js.map +1 -0
  13. package/dist/src/__tests__/report-budget.test.d.ts +2 -0
  14. package/dist/src/__tests__/report-budget.test.d.ts.map +1 -0
  15. package/dist/src/__tests__/report-budget.test.js +80 -0
  16. package/dist/src/__tests__/report-budget.test.js.map +1 -0
  17. package/dist/src/__tests__/report-diff.test.d.ts +2 -0
  18. package/dist/src/__tests__/report-diff.test.d.ts.map +1 -0
  19. package/dist/src/__tests__/report-diff.test.js +231 -0
  20. package/dist/src/__tests__/report-diff.test.js.map +1 -0
  21. package/dist/src/__tests__/report-evidence.test.d.ts +2 -0
  22. package/dist/src/__tests__/report-evidence.test.d.ts.map +1 -0
  23. package/dist/src/__tests__/report-evidence.test.js +214 -0
  24. package/dist/src/__tests__/report-evidence.test.js.map +1 -0
  25. package/dist/src/__tests__/report-flake.test.d.ts +2 -0
  26. package/dist/src/__tests__/report-flake.test.d.ts.map +1 -0
  27. package/dist/src/__tests__/report-flake.test.js +182 -0
  28. package/dist/src/__tests__/report-flake.test.js.map +1 -0
  29. package/dist/src/__tests__/report-history.test.d.ts +2 -0
  30. package/dist/src/__tests__/report-history.test.d.ts.map +1 -0
  31. package/dist/src/__tests__/report-history.test.js +229 -0
  32. package/dist/src/__tests__/report-history.test.js.map +1 -0
  33. package/dist/src/__tests__/report-output.test.d.ts +2 -0
  34. package/dist/src/__tests__/report-output.test.d.ts.map +1 -0
  35. package/dist/src/__tests__/report-output.test.js +169 -0
  36. package/dist/src/__tests__/report-output.test.js.map +1 -0
  37. package/dist/src/__tests__/report-png.test.d.ts +2 -0
  38. package/dist/src/__tests__/report-png.test.d.ts.map +1 -0
  39. package/dist/src/__tests__/report-png.test.js +84 -0
  40. package/dist/src/__tests__/report-png.test.js.map +1 -0
  41. package/dist/src/__tests__/report-render-extras.test.d.ts +2 -0
  42. package/dist/src/__tests__/report-render-extras.test.d.ts.map +1 -0
  43. package/dist/src/__tests__/report-render-extras.test.js +285 -0
  44. package/dist/src/__tests__/report-render-extras.test.js.map +1 -0
  45. package/dist/src/__tests__/report-render.test.d.ts +2 -0
  46. package/dist/src/__tests__/report-render.test.d.ts.map +1 -0
  47. package/dist/src/__tests__/report-render.test.js +143 -0
  48. package/dist/src/__tests__/report-render.test.js.map +1 -0
  49. package/dist/src/__tests__/report-run.test.d.ts +2 -0
  50. package/dist/src/__tests__/report-run.test.d.ts.map +1 -0
  51. package/dist/src/__tests__/report-run.test.js +234 -0
  52. package/dist/src/__tests__/report-run.test.js.map +1 -0
  53. package/dist/src/browser/index.d.ts +1 -0
  54. package/dist/src/browser/index.d.ts.map +1 -1
  55. package/dist/src/browser/index.js +1 -0
  56. package/dist/src/browser/index.js.map +1 -1
  57. package/dist/src/browser/profiler.d.ts.map +1 -1
  58. package/dist/src/browser/profiler.js +19 -22
  59. package/dist/src/browser/profiler.js.map +1 -1
  60. package/dist/src/cli/commands.d.ts.map +1 -1
  61. package/dist/src/cli/commands.js +254 -0
  62. package/dist/src/cli/commands.js.map +1 -1
  63. package/dist/src/report/a11y.d.ts +52 -0
  64. package/dist/src/report/a11y.d.ts.map +1 -0
  65. package/dist/src/report/a11y.js +231 -0
  66. package/dist/src/report/a11y.js.map +1 -0
  67. package/dist/src/report/analyze.d.ts +20 -0
  68. package/dist/src/report/analyze.d.ts.map +1 -0
  69. package/dist/src/report/analyze.js +136 -0
  70. package/dist/src/report/analyze.js.map +1 -0
  71. package/dist/src/report/budget.d.ts +27 -0
  72. package/dist/src/report/budget.d.ts.map +1 -0
  73. package/dist/src/report/budget.js +143 -0
  74. package/dist/src/report/budget.js.map +1 -0
  75. package/dist/src/report/collect-a11y.d.ts +25 -0
  76. package/dist/src/report/collect-a11y.d.ts.map +1 -0
  77. package/dist/src/report/collect-a11y.js +154 -0
  78. package/dist/src/report/collect-a11y.js.map +1 -0
  79. package/dist/src/report/collect.d.ts +45 -0
  80. package/dist/src/report/collect.d.ts.map +1 -0
  81. package/dist/src/report/collect.js +275 -0
  82. package/dist/src/report/collect.js.map +1 -0
  83. package/dist/src/report/evidence.d.ts +80 -0
  84. package/dist/src/report/evidence.d.ts.map +1 -0
  85. package/dist/src/report/evidence.js +279 -0
  86. package/dist/src/report/evidence.js.map +1 -0
  87. package/dist/src/report/flake.d.ts +88 -0
  88. package/dist/src/report/flake.d.ts.map +1 -0
  89. package/dist/src/report/flake.js +232 -0
  90. package/dist/src/report/flake.js.map +1 -0
  91. package/dist/src/report/history.d.ts +85 -0
  92. package/dist/src/report/history.d.ts.map +1 -0
  93. package/dist/src/report/history.js +176 -0
  94. package/dist/src/report/history.js.map +1 -0
  95. package/dist/src/report/index.d.ts +102 -0
  96. package/dist/src/report/index.d.ts.map +1 -0
  97. package/dist/src/report/index.js +216 -0
  98. package/dist/src/report/index.js.map +1 -0
  99. package/dist/src/report/pixel-diff.d.ts +30 -0
  100. package/dist/src/report/pixel-diff.d.ts.map +1 -0
  101. package/dist/src/report/pixel-diff.js +175 -0
  102. package/dist/src/report/pixel-diff.js.map +1 -0
  103. package/dist/src/report/png.d.ts +28 -0
  104. package/dist/src/report/png.d.ts.map +1 -0
  105. package/dist/src/report/png.js +259 -0
  106. package/dist/src/report/png.js.map +1 -0
  107. package/dist/src/report/render-evidence.d.ts +16 -0
  108. package/dist/src/report/render-evidence.d.ts.map +1 -0
  109. package/dist/src/report/render-evidence.js +174 -0
  110. package/dist/src/report/render-evidence.js.map +1 -0
  111. package/dist/src/report/render-trend.d.ts +16 -0
  112. package/dist/src/report/render-trend.d.ts.map +1 -0
  113. package/dist/src/report/render-trend.js +194 -0
  114. package/dist/src/report/render-trend.js.map +1 -0
  115. package/dist/src/report/render.d.ts +21 -0
  116. package/dist/src/report/render.d.ts.map +1 -0
  117. package/dist/src/report/render.js +286 -0
  118. package/dist/src/report/render.js.map +1 -0
  119. package/dist/src/report/run-diff.d.ts +18 -0
  120. package/dist/src/report/run-diff.d.ts.map +1 -0
  121. package/dist/src/report/run-diff.js +51 -0
  122. package/dist/src/report/run-diff.js.map +1 -0
  123. package/dist/src/report/structure.d.ts +41 -0
  124. package/dist/src/report/structure.d.ts.map +1 -0
  125. package/dist/src/report/structure.js +210 -0
  126. package/dist/src/report/structure.js.map +1 -0
  127. package/dist/src/report/trend.d.ts +26 -0
  128. package/dist/src/report/trend.d.ts.map +1 -0
  129. package/dist/src/report/trend.js +164 -0
  130. package/dist/src/report/trend.js.map +1 -0
  131. package/dist/src/report/types.d.ts +320 -0
  132. package/dist/src/report/types.d.ts.map +1 -0
  133. package/dist/src/report/types.js +11 -0
  134. package/dist/src/report/types.js.map +1 -0
  135. package/dist/src/report/util.d.ts +17 -0
  136. package/dist/src/report/util.d.ts.map +1 -0
  137. package/dist/src/report/util.js +45 -0
  138. package/dist/src/report/util.js.map +1 -0
  139. package/dist/tsconfig.tsbuildinfo +1 -1
  140. package/package.json +1 -1
  141. package/src/__tests__/profiler-heap.test.ts +191 -0
  142. package/src/__tests__/report-a11y.test.ts +180 -0
  143. package/src/__tests__/report-analyze.test.ts +202 -0
  144. package/src/__tests__/report-budget.test.ts +92 -0
  145. package/src/__tests__/report-diff.test.ts +272 -0
  146. package/src/__tests__/report-evidence.test.ts +276 -0
  147. package/src/__tests__/report-flake.test.ts +214 -0
  148. package/src/__tests__/report-history.test.ts +293 -0
  149. package/src/__tests__/report-output.test.ts +210 -0
  150. package/src/__tests__/report-png.test.ts +95 -0
  151. package/src/__tests__/report-render-extras.test.ts +315 -0
  152. package/src/__tests__/report-render.test.ts +175 -0
  153. package/src/__tests__/report-run.test.ts +264 -0
  154. package/src/browser/index.ts +1 -0
  155. package/src/browser/profiler.ts +28 -21
  156. package/src/cli/commands.ts +272 -0
  157. package/src/report/a11y.ts +242 -0
  158. package/src/report/analyze.ts +181 -0
  159. package/src/report/budget.ts +155 -0
  160. package/src/report/collect-a11y.ts +200 -0
  161. package/src/report/collect.ts +408 -0
  162. package/src/report/evidence.ts +379 -0
  163. package/src/report/flake.ts +303 -0
  164. package/src/report/history.ts +219 -0
  165. package/src/report/index.ts +307 -0
  166. package/src/report/pixel-diff.ts +197 -0
  167. package/src/report/png.ts +290 -0
  168. package/src/report/render-evidence.ts +194 -0
  169. package/src/report/render-trend.ts +225 -0
  170. package/src/report/render.ts +314 -0
  171. package/src/report/run-diff.ts +63 -0
  172. package/src/report/structure.ts +212 -0
  173. package/src/report/trend.ts +189 -0
  174. package/src/report/types.ts +356 -0
  175. package/src/report/util.ts +48 -0
@@ -0,0 +1,182 @@
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 { analyzeFlake, ruledOutFailureRate, signalSignature, summarizeFlake, } from '../report/flake.js';
12
+ function capture(partial = {}) {
13
+ return {
14
+ url: 'https://shop.test/checkout',
15
+ finalUrl: 'https://shop.test/checkout',
16
+ title: 'Checkout',
17
+ capturedAt: '2026-09-21T10:00:00.000Z',
18
+ durationMs: 1000,
19
+ console: [],
20
+ pageErrors: [],
21
+ requests: [],
22
+ vitals: { lcp: 2000, cls: 0.01, inp: 50 },
23
+ a11y: [],
24
+ screenshots: [],
25
+ notes: [],
26
+ ...partial,
27
+ };
28
+ }
29
+ const run = (partial = {}, budget = DEFAULT_BUDGET) => buildReport(capture(partial), budget);
30
+ const err = (text) => ({ type: 'error', text, timestamp: 0 });
31
+ describe('analyzeFlake verdict', () => {
32
+ it('passes when every check held across every run', () => {
33
+ const flake = analyzeFlake([run(), run(), run(), run(), run()]);
34
+ expect(flake.verdict).toBe('pass');
35
+ expect(flake.runs).toBe(5);
36
+ expect(flake.checks.every((c) => c.stable)).toBe(true);
37
+ expect(flake.headline).toContain('PASS');
38
+ });
39
+ it('refuses to call a check that passed sometimes a pass', () => {
40
+ // Three clean runs, two with a console error — exactly the case that gets
41
+ // shipped as green after a re-run.
42
+ const runs = [
43
+ run(),
44
+ run({ console: [err('boom')] }),
45
+ run(),
46
+ run({ console: [err('boom')] }),
47
+ run(),
48
+ ];
49
+ const flake = analyzeFlake(runs);
50
+ expect(flake.verdict).toBe('flaky');
51
+ const check = flake.checks.find((c) => c.key === 'maxConsoleErrors');
52
+ expect(check).toMatchObject({ runs: 5, passed: 3, failed: 2, stable: false });
53
+ expect(check?.flakeRate).toBeCloseTo(0.4, 5);
54
+ expect(flake.headline).toContain('FLAKY');
55
+ expect(flake.headline).toContain('failed 2 of 5');
56
+ });
57
+ it('fails outright when a check failed in every run', () => {
58
+ const runs = [run({ console: [err('boom')] }), run({ console: [err('boom')] })];
59
+ const flake = analyzeFlake(runs);
60
+ expect(flake.verdict).toBe('fail');
61
+ expect(flake.headline).toBe('FAIL — 1 check(s) failed in all 2 runs.');
62
+ });
63
+ it('prefers flaky over fail when one check is unstable and another always fails', () => {
64
+ const budget = parseBudget({ lcp: 1000 });
65
+ const runs = [
66
+ run({ console: [err('boom')] }, budget),
67
+ run({}, budget), // lcp still breaches; the console error does not recur
68
+ ];
69
+ const flake = analyzeFlake(runs);
70
+ expect(flake.checks.find((c) => c.key === 'lcpMs')?.failed).toBe(2);
71
+ expect(flake.verdict).toBe('flaky');
72
+ });
73
+ it('records the measured value for every run, not just the failing ones', () => {
74
+ const runs = [run({ vitals: { lcp: 1900 } }), run({ vitals: { lcp: 3400 } })];
75
+ const lcp = analyzeFlake(runs).checks.find((c) => c.key === 'lcpMs');
76
+ expect(lcp?.actuals).toEqual(['1900ms', '3400ms']);
77
+ });
78
+ it('ignores budgets that are not enforced', () => {
79
+ const budget = parseBudget({ ttfb: null, fcp: null });
80
+ const keys = analyzeFlake([run({}, budget)]).checks.map((c) => c.key);
81
+ expect(keys).not.toContain('ttfbMs');
82
+ expect(keys).not.toContain('fcpMs');
83
+ });
84
+ it('throws rather than inventing a verdict from no runs', () => {
85
+ expect(() => analyzeFlake([])).toThrow(/at least one run/);
86
+ });
87
+ });
88
+ describe('analyzeFlake signals', () => {
89
+ it('reports how many runs an intermittent console error appeared in', () => {
90
+ const runs = [run(), run({ console: [err('Failed to fetch /cart')] }), run(), run(), run()];
91
+ const signal = analyzeFlake(runs).signals.find((s) => s.kind === 'console');
92
+ expect(signal).toMatchObject({ runs: 1, total: 5, signature: 'Failed to fetch /cart' });
93
+ });
94
+ it('does not call an error that happened every run a flake', () => {
95
+ const runs = [run({ console: [err('always')] }), run({ console: [err('always')] })];
96
+ expect(analyzeFlake(runs).signals).toEqual([]);
97
+ });
98
+ it('groups the same error carrying a different request id', () => {
99
+ const runs = [
100
+ run({ console: [err('Request 1043321 failed')] }),
101
+ run({ console: [err('Request 9910412 failed')] }),
102
+ run(),
103
+ ];
104
+ const signals = analyzeFlake(runs).signals;
105
+ expect(signals.length).toBe(1);
106
+ expect(signals[0]).toMatchObject({ runs: 2, total: 3 });
107
+ });
108
+ it('reports an intermittently failing request', () => {
109
+ const failing = {
110
+ requests: [
111
+ { url: 'https://api.test/cart', method: 'GET', status: 503, failed: true },
112
+ { url: 'https://api.test/ok', method: 'GET', status: 200, failed: false },
113
+ ],
114
+ };
115
+ const signals = analyzeFlake([run(), run(failing), run()]).signals;
116
+ expect(signals).toEqual([
117
+ { kind: 'request', signature: '503 https://api.test/cart', runs: 1, total: 3 },
118
+ ]);
119
+ });
120
+ });
121
+ describe('signalSignature', () => {
122
+ it('normalises ids, hex and long numbers so one error is one signal', () => {
123
+ expect(signalSignature('Request 1043321 failed')).toBe('Request <n> failed');
124
+ expect(signalSignature('trace 3f2a1b4c-0000-4aaa-8bbb-1234567890ab lost')).toBe('trace <uuid> lost');
125
+ expect(signalSignature('at 0xDEADBEEF')).toBe('at <hex>');
126
+ });
127
+ it('leaves short numbers alone — a 404 is part of the message', () => {
128
+ expect(signalSignature('GET returned 404')).toBe('GET returned 404');
129
+ });
130
+ });
131
+ describe('analyzeFlake metrics', () => {
132
+ it('reports the range a metric varied across, which is the readable form', () => {
133
+ const runs = [1900, 3400, 2100, 2000, 2050].map((lcp) => run({ vitals: { lcp } }));
134
+ const lcp = analyzeFlake(runs).metrics.find((m) => m.key === 'lcp');
135
+ expect(lcp).toMatchObject({ min: 1900, max: 3400, measured: 5, total: 5, unstable: true });
136
+ expect(lcp?.spread).toBe(1500);
137
+ });
138
+ it('does not flag a metric that barely moved', () => {
139
+ const runs = [2000, 2050, 2010].map((lcp) => run({ vitals: { lcp } }));
140
+ expect(analyzeFlake(runs).metrics.find((m) => m.key === 'lcp')?.unstable).toBe(false);
141
+ });
142
+ it('counts how many runs actually measured the metric', () => {
143
+ const runs = [
144
+ run({ vitals: { lcp: 2000 } }),
145
+ run({ vitals: {} }),
146
+ run({ vitals: { lcp: 2100 } }),
147
+ ];
148
+ expect(analyzeFlake(runs).metrics.find((m) => m.key === 'lcp')?.measured).toBe(2);
149
+ });
150
+ });
151
+ describe('confidence', () => {
152
+ it('states what a clean window actually rules out rather than implying certainty', () => {
153
+ const flake = analyzeFlake([run(), run(), run(), run(), run()]);
154
+ expect(flake.confidence).toBe('high');
155
+ expect(flake.confidenceNote).toContain('95% confidence');
156
+ expect(flake.confidenceNote).toContain('45%');
157
+ });
158
+ it('is low after a single run, and says why', () => {
159
+ const flake = analyzeFlake([run()]);
160
+ expect(flake.confidence).toBe('low');
161
+ expect(flake.confidenceNote).toContain('1 run');
162
+ });
163
+ it('is high for an observed flake, because both outcomes were seen', () => {
164
+ const flake = analyzeFlake([run(), run({ console: [err('boom')] })]);
165
+ expect(flake.confidence).toBe('high');
166
+ expect(flake.confidenceNote).toContain('observed, not inferred');
167
+ });
168
+ it('shrinks the ruled-out rate as runs are added', () => {
169
+ expect(ruledOutFailureRate(1)).toBeCloseTo(0.95, 5);
170
+ expect(ruledOutFailureRate(5)).toBeCloseTo(0.4507, 3);
171
+ expect(ruledOutFailureRate(20)).toBeCloseTo(0.1391, 3);
172
+ expect(ruledOutFailureRate(5)).toBeGreaterThan(ruledOutFailureRate(20));
173
+ });
174
+ });
175
+ describe('summarizeFlake', () => {
176
+ it('is one line naming the verdict and the confidence', () => {
177
+ const line = summarizeFlake(analyzeFlake([run(), run({ console: [err('boom')] })]));
178
+ expect(line).toMatch(/^FLAKY — /);
179
+ expect(line).toContain('Confidence: high.');
180
+ });
181
+ });
182
+ //# sourceMappingURL=report-flake.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"report-flake.test.js","sourceRoot":"","sources":["../../../src/__tests__/report-flake.test.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=report-history.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"report-history.test.d.ts","sourceRoot":"","sources":["../../../src/__tests__/report-history.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,229 @@
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 { historyKey, listRuns, loadRunScreenshot, pruneHistory, saveRun, toHistoryRun, } from '../report/history.js';
15
+ import { encodePng, pngToDataUrl } from '../report/png.js';
16
+ import { buildTrend, formatTrendDelta, formatTrendValue } from '../report/trend.js';
17
+ function capture(partial = {}) {
18
+ return {
19
+ url: 'https://shop.test/checkout',
20
+ finalUrl: 'https://shop.test/checkout',
21
+ title: 'Checkout',
22
+ capturedAt: '2026-09-21T10:00:00.000Z',
23
+ durationMs: 1200,
24
+ console: [],
25
+ pageErrors: [],
26
+ requests: [],
27
+ vitals: { lcp: 2000, cls: 0.01, inp: 50 },
28
+ a11y: [],
29
+ screenshots: [],
30
+ notes: [],
31
+ ...partial,
32
+ };
33
+ }
34
+ const report = (partial = {}, budget = DEFAULT_BUDGET) => buildReport(capture(partial), budget);
35
+ /** A prior run, shaped exactly as it comes back off disk. */
36
+ function historyRun(capturedAt, vitals, counts = {}) {
37
+ const run = toHistoryRun(report({ capturedAt, vitals }), `id-${capturedAt}`);
38
+ run.counts = { ...run.counts, ...counts };
39
+ run.verdict = (counts.consoleErrors ?? 0) > 0 ? 'fail' : 'pass';
40
+ return run;
41
+ }
42
+ function tinyPng(width = 4, height = 4, shade = 200) {
43
+ const data = new Uint8Array(width * height * 4);
44
+ for (let i = 0; i < width * height; i++)
45
+ data.set([shade, shade, shade, 255], i * 4);
46
+ return encodePng({ width, height, data });
47
+ }
48
+ // ---------------------------------------------------------------------------
49
+ // Storage
50
+ // ---------------------------------------------------------------------------
51
+ describe('historyKey', () => {
52
+ it('is readable and includes a hash of the full URL', () => {
53
+ expect(historyKey('https://shop.test/checkout')).toMatch(/^shop\.test-checkout-[0-9a-f]{8}$/);
54
+ });
55
+ it('does not collide across URLs that differ only in the query string', () => {
56
+ expect(historyKey('https://a.test/list?page=2')).not.toBe(historyKey('https://a.test/list?page=3'));
57
+ });
58
+ it('survives a string that is not a URL at all', () => {
59
+ expect(historyKey('not a url')).toMatch(/^not-a-url-[0-9a-f]{8}$/);
60
+ });
61
+ });
62
+ describe('run history on disk', () => {
63
+ let root;
64
+ beforeEach(async () => {
65
+ await mkdir(join(homedir(), '.monomind'), { recursive: true });
66
+ root = await mkdtemp(join(homedir(), '.monomind', 'monobrowse-history-test-'));
67
+ process.env.MONOBROWSE_HISTORY_DIR = root;
68
+ });
69
+ afterEach(async () => {
70
+ delete process.env.MONOBROWSE_HISTORY_DIR;
71
+ await rm(root, { recursive: true, force: true });
72
+ });
73
+ it('writes one JSON record per run, keyed by URL', async () => {
74
+ const saved = await saveRun(report(), { root, id: '20260921T100000Z-aaaaaaaa' });
75
+ expect(saved.dir).toBe(join(root, historyKey('https://shop.test/checkout')));
76
+ const files = await readdir(saved.dir);
77
+ expect(files).toContain('20260921T100000Z-aaaaaaaa.json');
78
+ expect(files).toContain('url.txt');
79
+ });
80
+ it('archives the main screenshot alongside so the next run can diff pixels', async () => {
81
+ const png = tinyPng();
82
+ const saved = await saveRun(report({
83
+ screenshots: [{ label: 'page', width: 4, height: 4, dataUrl: pngToDataUrl(png) }],
84
+ }), { root, id: '20260921T100000Z-bbbbbbbb' });
85
+ expect(saved.run.screenshotFile).toBe('20260921T100000Z-bbbbbbbb.png');
86
+ const loaded = await loadRunScreenshot(saved.dir, saved.run);
87
+ expect(loaded && Buffer.from(loaded).equals(png)).toBe(true);
88
+ });
89
+ it('returns null for a screenshot that was never archived', async () => {
90
+ const saved = await saveRun(report(), { root, id: '20260921T100000Z-cccccccc' });
91
+ expect(await loadRunScreenshot(saved.dir, saved.run)).toBeNull();
92
+ });
93
+ it('lists runs oldest first', async () => {
94
+ for (const ts of ['2026-09-21T12:00:00.000Z', '2026-09-21T10:00:00.000Z']) {
95
+ await saveRun(report({ capturedAt: ts }), { root });
96
+ }
97
+ const dir = join(root, historyKey('https://shop.test/checkout'));
98
+ expect((await listRuns(dir)).map((r) => r.capturedAt)).toEqual([
99
+ '2026-09-21T10:00:00.000Z',
100
+ '2026-09-21T12:00:00.000Z',
101
+ ]);
102
+ });
103
+ it('prunes to the configured maximum, oldest first, taking the PNGs with them', async () => {
104
+ const dir = join(root, historyKey('https://shop.test/checkout'));
105
+ for (let i = 0; i < 5; i++) {
106
+ await saveRun(report({
107
+ capturedAt: `2026-09-2${i + 1}T10:00:00.000Z`,
108
+ screenshots: [{ label: 'page', width: 4, height: 4, dataUrl: pngToDataUrl(tinyPng()) }],
109
+ }), { root, max: 3, id: `2026092${i + 1}T100000Z-0000000${i}` });
110
+ }
111
+ const runs = await listRuns(dir);
112
+ expect(runs.map((r) => r.capturedAt)).toEqual([
113
+ '2026-09-23T10:00:00.000Z',
114
+ '2026-09-24T10:00:00.000Z',
115
+ '2026-09-25T10:00:00.000Z',
116
+ ]);
117
+ const files = await readdir(dir);
118
+ expect(files.filter((f) => f.endsWith('.png')).length).toBe(3);
119
+ });
120
+ it('keeps everything when the history is shorter than the maximum', async () => {
121
+ const dir = join(root, historyKey('https://shop.test/checkout'));
122
+ await saveRun(report(), { root, max: 10 });
123
+ expect(await pruneHistory(dir, 10)).toEqual([]);
124
+ });
125
+ it('skips a corrupt record instead of failing the whole read', async () => {
126
+ const dir = join(root, historyKey('https://shop.test/checkout'));
127
+ await saveRun(report(), { root, id: '20260921T100000Z-dddddddd' });
128
+ await writeFile(join(dir, '20260922T100000Z-eeeeeeee.json'), '{ truncated', 'utf8');
129
+ const runs = await listRuns(dir);
130
+ expect(runs.map((r) => r.id)).toEqual(['20260921T100000Z-dddddddd']);
131
+ });
132
+ it('returns an empty history for a URL never reported on', async () => {
133
+ expect(await listRuns(join(root, 'never-seen-00000000'))).toEqual([]);
134
+ });
135
+ });
136
+ // ---------------------------------------------------------------------------
137
+ // Trends
138
+ // ---------------------------------------------------------------------------
139
+ describe('buildTrend', () => {
140
+ it('reports no prior runs on a first run without inventing a direction', () => {
141
+ const trend = buildTrend(report(), []);
142
+ expect(trend.runs).toBe(0);
143
+ expect(trend.windowFrom).toBeNull();
144
+ expect(trend.series.every((s) => s.direction === 'new')).toBe(true);
145
+ expect(trend.headlines).toEqual([]);
146
+ });
147
+ it('names the slow decay the story is about', () => {
148
+ // LCP walks 2000 -> 2400ms across nine prior runs, never once crossing
149
+ // the 2500ms budget, so no individual run ever failed.
150
+ const history = [];
151
+ for (let i = 0; i < 9; i++) {
152
+ history.push(historyRun(`2026-09-${10 + i}T10:00:00.000Z`, { lcp: 2000 + i * 50 }));
153
+ }
154
+ const trend = buildTrend(report({ capturedAt: '2026-09-21T10:00:00.000Z', vitals: { lcp: 2400 } }), history);
155
+ const lcp = trend.series.find((s) => s.key === 'lcp');
156
+ expect(lcp?.deltaFromOldest).toBe(400);
157
+ expect(lcp?.creep).toBe('LCP has crept up 400ms over 10 runs (2.00s → 2.40s).');
158
+ expect(trend.headlines[0]).toContain('crept up 400ms');
159
+ });
160
+ it('does not call rasteriser-scale jitter a regression', () => {
161
+ const history = [historyRun('2026-09-20T10:00:00.000Z', { lcp: 2000 })];
162
+ const trend = buildTrend(report({ vitals: { lcp: 2010 } }), history);
163
+ expect(trend.series.find((s) => s.key === 'lcp')?.direction).toBe('flat');
164
+ });
165
+ it('calls a real movement a regression, and the reverse an improvement', () => {
166
+ const history = [historyRun('2026-09-20T10:00:00.000Z', { lcp: 2000 })];
167
+ expect(buildTrend(report({ vitals: { lcp: 2600 } }), history).series.find((s) => s.key === 'lcp')
168
+ ?.direction).toBe('regressed');
169
+ expect(buildTrend(report({ vitals: { lcp: 1400 } }), history).series.find((s) => s.key === 'lcp')
170
+ ?.direction).toBe('improved');
171
+ });
172
+ it('treats a single new console error as a regression', () => {
173
+ const history = [historyRun('2026-09-20T10:00:00.000Z', { lcp: 2000 })];
174
+ const current = report({
175
+ console: [{ type: 'error', text: 'boom', timestamp: 0 }],
176
+ });
177
+ const errors = buildTrend(current, history).series.find((s) => s.key === 'consoleErrors');
178
+ expect(errors).toMatchObject({
179
+ current: 1,
180
+ previous: 0,
181
+ deltaFromPrevious: 1,
182
+ direction: 'regressed',
183
+ });
184
+ });
185
+ it('leads with a first failure after a clean window', () => {
186
+ const history = [1, 2, 3].map((i) => historyRun(`2026-09-1${i}T10:00:00.000Z`, { lcp: 2000 }));
187
+ const failing = report({ console: [{ type: 'error', text: 'boom', timestamp: 0 }] });
188
+ expect(failing.verdict).toBe('fail');
189
+ expect(buildTrend(failing, history).headlines[0]).toBe('First failure in this window — the previous 3 run(s) all passed.');
190
+ });
191
+ it('includes this run as the last point of every series', () => {
192
+ const history = [historyRun('2026-09-20T10:00:00.000Z', { lcp: 2000 })];
193
+ const trend = buildTrend(report({ capturedAt: '2026-09-21T10:00:00.000Z', vitals: { lcp: 2100 } }), history);
194
+ const lcp = trend.series.find((s) => s.key === 'lcp');
195
+ expect(lcp?.points).toEqual([
196
+ { capturedAt: '2026-09-20T10:00:00.000Z', value: 2000 },
197
+ { capturedAt: '2026-09-21T10:00:00.000Z', value: 2100 },
198
+ ]);
199
+ expect(trend.verdictHistory.at(-1)).toEqual({
200
+ capturedAt: '2026-09-21T10:00:00.000Z',
201
+ verdict: 'pass',
202
+ });
203
+ });
204
+ it('ignores runs that never measured the metric when picking a baseline', () => {
205
+ const history = [
206
+ historyRun('2026-09-18T10:00:00.000Z', { lcp: 2000 }),
207
+ historyRun('2026-09-19T10:00:00.000Z', {}),
208
+ ];
209
+ const lcp = buildTrend(report({ vitals: { lcp: 2600 } }), history).series.find((s) => s.key === 'lcp');
210
+ expect(lcp?.previous).toBe(2000);
211
+ expect(lcp?.deltaFromPrevious).toBe(600);
212
+ });
213
+ });
214
+ describe('trend formatting', () => {
215
+ it('switches to seconds past a second so a reader can see the scale', () => {
216
+ expect(formatTrendValue('ms', 412)).toBe('412ms');
217
+ expect(formatTrendValue('ms', 2450)).toBe('2.45s');
218
+ expect(formatTrendValue('score', 0.1234)).toBe('0.123');
219
+ expect(formatTrendValue('count', 3)).toBe('3');
220
+ expect(formatTrendValue('ms', null)).toBe('—');
221
+ });
222
+ it('signs deltas, and says so plainly when there is no change', () => {
223
+ expect(formatTrendDelta('ms', 400)).toBe('+400ms');
224
+ expect(formatTrendDelta('ms', -400)).toBe('−400ms');
225
+ expect(formatTrendDelta('count', 0)).toBe('no change');
226
+ expect(formatTrendDelta('count', null)).toBe('no change');
227
+ });
228
+ });
229
+ //# sourceMappingURL=report-history.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"report-history.test.js","sourceRoot":"","sources":["../../../src/__tests__/report-history.test.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=report-output.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"report-output.test.d.ts","sourceRoot":"","sources":["../../../src/__tests__/report-output.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Everything around the two files the command leaves behind: where they go,
3
+ * what the JSON carries, plus the two pure helpers from the collectors
4
+ * (request classification and locator upgrading) that a stub client can
5
+ * exercise without launching Chrome.
6
+ */
7
+ import { mkdtemp, readFile } from 'node:fs/promises';
8
+ import { tmpdir } from 'node:os';
9
+ import { join } from 'node:path';
10
+ import { describe, expect, it, vi } from 'vitest';
11
+ import { buildReport } from '../report/analyze.js';
12
+ import { DEFAULT_BUDGET } from '../report/budget.js';
13
+ import { toRequestEntries } from '../report/collect.js';
14
+ import { upgradeLocators } from '../report/collect-a11y.js';
15
+ import { resolveOutPath, toJsonReport, writeReportFiles } from '../report/index.js';
16
+ function capture(partial = {}) {
17
+ return {
18
+ url: 'https://shop.test/checkout',
19
+ finalUrl: 'https://shop.test/checkout',
20
+ title: 'Checkout',
21
+ capturedAt: '2026-09-21T10:00:00.000Z',
22
+ durationMs: 3200,
23
+ console: [],
24
+ pageErrors: [],
25
+ requests: [],
26
+ vitals: { lcp: 100, cls: 0, inp: 10 },
27
+ a11y: [],
28
+ screenshots: [],
29
+ notes: [],
30
+ ...partial,
31
+ };
32
+ }
33
+ const report = (partial = {}) => buildReport(capture(partial), DEFAULT_BUDGET);
34
+ describe('resolveOutPath', () => {
35
+ const when = '2026-09-21T10:00:00.000Z';
36
+ it('derives a dated, host-and-path filename when --out is omitted', async () => {
37
+ const path = await resolveOutPath(undefined, 'https://shop.test/checkout', when, '/work');
38
+ expect(path).toBe('/work/monobrowse-report-shop.test-checkout-20260921T100000Z.html');
39
+ });
40
+ it('uses an explicit .html path as given', async () => {
41
+ expect(await resolveOutPath('/tmp/out.html', 'https://a.test/', when, '/work')).toBe('/tmp/out.html');
42
+ });
43
+ it('resolves a relative path against the working directory', async () => {
44
+ expect(await resolveOutPath('reports/run.html', 'https://a.test/', when, '/work')).toBe('/work/reports/run.html');
45
+ });
46
+ it('adds .html to a path that has no extension and is not a directory', async () => {
47
+ expect(await resolveOutPath('/tmp/run', 'https://a.test/', when, '/work')).toBe('/tmp/run.html');
48
+ });
49
+ it('treats a trailing-separator path as a directory to drop the report into', async () => {
50
+ const path = await resolveOutPath('reports/', 'https://a.test/', when, '/work');
51
+ expect(path).toBe('/work/reports/monobrowse-report-a.test-20260921T100000Z.html');
52
+ });
53
+ it('treats an existing directory as a directory', async () => {
54
+ const dir = await mkdtemp(join(tmpdir(), 'monobrowse-out-'));
55
+ const path = await resolveOutPath(dir, 'https://a.test/', when, '/work');
56
+ expect(path).toBe(join(dir, 'monobrowse-report-a.test-20260921T100000Z.html'));
57
+ });
58
+ });
59
+ describe('writeReportFiles', () => {
60
+ it('writes the HTML and a sibling JSON carrying the verdict and failures', async () => {
61
+ const dir = await mkdtemp(join(tmpdir(), 'monobrowse-write-'));
62
+ const r = report({ pageErrors: [{ text: 'boom', timestamp: 0 }] });
63
+ const paths = await writeReportFiles(r, join(dir, 'run.html'));
64
+ expect(paths.jsonPath).toBe(join(dir, 'run.json'));
65
+ const html = await readFile(paths.htmlPath, 'utf8');
66
+ expect(html).toContain('<!doctype html>');
67
+ const json = JSON.parse(await readFile(paths.jsonPath, 'utf8'));
68
+ expect(json.verdict).toBe('fail');
69
+ expect(json.failures[0]).toMatchObject({ budget: 'maxPageErrors', actual: '1' });
70
+ });
71
+ it('creates the output directory if it does not exist yet', async () => {
72
+ const dir = await mkdtemp(join(tmpdir(), 'monobrowse-write-'));
73
+ const paths = await writeReportFiles(report(), join(dir, 'nested', 'deep', 'run.html'));
74
+ await expect(readFile(paths.htmlPath, 'utf8')).resolves.toContain('PASS');
75
+ });
76
+ });
77
+ describe('toJsonReport', () => {
78
+ it('drops base64 image payloads but keeps the screenshot metadata', () => {
79
+ const json = toJsonReport(report({
80
+ screenshots: [
81
+ {
82
+ label: 'page',
83
+ width: 1280,
84
+ height: 900,
85
+ dataUrl: 'data:image/png;base64,AAAA',
86
+ path: '/tmp/page.png',
87
+ },
88
+ ],
89
+ }));
90
+ const shots = json.screenshots;
91
+ expect(shots[0]).toEqual({ label: 'page', width: 1280, height: 900, path: '/tmp/page.png' });
92
+ expect(JSON.stringify(json)).not.toContain('base64');
93
+ });
94
+ });
95
+ describe('toRequestEntries', () => {
96
+ const base = { id: '1', url: 'https://a.test/x', method: 'GET', startTime: 100 };
97
+ it('marks a 4xx/5xx response as failed', () => {
98
+ const [entry] = toRequestEntries([{ ...base, status: 503, endTime: 400 }], new Map());
99
+ expect(entry.failed).toBe(true);
100
+ expect(entry.durationMs).toBe(300);
101
+ });
102
+ it('leaves a 3xx and a 2xx alone', () => {
103
+ const entries = toRequestEntries([
104
+ { ...base, status: 200 },
105
+ { ...base, id: '2', status: 302 },
106
+ ], new Map());
107
+ expect(entries.map((e) => e.failed)).toEqual([false, false]);
108
+ });
109
+ it('marks a transport failure as failed and carries its errorText', () => {
110
+ const failures = new Map([['1', { errorText: 'net::ERR_CONNECTION_REFUSED' }]]);
111
+ const [entry] = toRequestEntries([base], failures);
112
+ expect(entry).toMatchObject({ failed: true, errorText: 'net::ERR_CONNECTION_REFUSED' });
113
+ });
114
+ it('does not count a request the page itself cancelled', () => {
115
+ const failures = new Map([['1', { errorText: 'net::ERR_ABORTED', canceled: true }]]);
116
+ expect(toRequestEntries([base], failures)[0].failed).toBe(false);
117
+ });
118
+ it('still counts a blocked request even though CDP also marks it cancelled', () => {
119
+ const failures = new Map([['1', { canceled: true, blockedReason: 'csp' }]]);
120
+ const [entry] = toRequestEntries([base], failures);
121
+ expect(entry).toMatchObject({ failed: true, errorText: 'blocked: csp' });
122
+ });
123
+ it('does not treat a still-in-flight request as a failure', () => {
124
+ expect(toRequestEntries([base], new Map())[0].failed).toBe(false);
125
+ });
126
+ });
127
+ describe('upgradeLocators', () => {
128
+ const finding = (locator) => ({
129
+ rule: 'unlabelled-control',
130
+ impact: 'error',
131
+ role: 'button',
132
+ name: null,
133
+ locator,
134
+ detail: 'no name',
135
+ });
136
+ function stubClient(selector) {
137
+ return {
138
+ send: vi.fn(async (method) => {
139
+ if (method === 'DOM.resolveNode')
140
+ return { object: { objectId: 'obj-1' } };
141
+ if (method === 'Runtime.callFunctionOn')
142
+ return { result: { value: selector } };
143
+ return {};
144
+ }),
145
+ };
146
+ }
147
+ const nodes = [{ nodeId: '7', backendDOMNodeId: 42 }];
148
+ it('replaces the ax-node placeholder with a resolved CSS selector', async () => {
149
+ const out = await upgradeLocators(stubClient('#submit'), 'sid', [finding('ax-node:7')], nodes);
150
+ expect(out[0].locator).toBe('#submit');
151
+ });
152
+ it('keeps the placeholder when the DOM cannot resolve the node', async () => {
153
+ const out = await upgradeLocators(stubClient(null), 'sid', [finding('ax-node:7')], nodes);
154
+ expect(out[0].locator).toBe('ax-node:7');
155
+ });
156
+ it('leaves DOM-derived locators untouched and costs no round-trip', async () => {
157
+ const client = stubClient('#other');
158
+ const out = await upgradeLocators(client, 'sid', [finding('#already-known')], nodes);
159
+ expect(out[0].locator).toBe('#already-known');
160
+ expect(client.send).not.toHaveBeenCalled();
161
+ });
162
+ it('resolves each backend node once, however many findings point at it', async () => {
163
+ const client = stubClient('#submit');
164
+ await upgradeLocators(client, 'sid', [finding('ax-node:7'), finding('ax-node:7')], nodes);
165
+ const resolves = client.send.mock.calls.filter((c) => c[0] === 'DOM.resolveNode');
166
+ expect(resolves).toHaveLength(1);
167
+ });
168
+ });
169
+ //# sourceMappingURL=report-output.test.js.map