@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,175 @@
1
+ /**
2
+ * The renderer's contract is that the HTML is SELF-CONTAINED: it must render
3
+ * identically on a machine with no network, because monomind's document
4
+ * pipeline ingests it as an ordinary document and because an archived report
5
+ * has to still work years later. These tests enforce that mechanically.
6
+ */
7
+ import { describe, expect, it } from 'vitest';
8
+ import { buildReport } from '../report/analyze.js';
9
+ import { DEFAULT_BUDGET, parseBudget } from '../report/budget.js';
10
+ import { escapeHtml, renderHtml } from '../report/render.js';
11
+ import type { CaptureData, Report } from '../report/types.js';
12
+
13
+ const PNG =
14
+ 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==';
15
+
16
+ function capture(partial: Partial<CaptureData> = {}): CaptureData {
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: {},
27
+ a11y: [],
28
+ screenshots: [],
29
+ notes: [],
30
+ ...partial,
31
+ };
32
+ }
33
+
34
+ function report(partial: Partial<CaptureData> = {}, budget = DEFAULT_BUDGET): Report {
35
+ return buildReport(capture(partial), budget);
36
+ }
37
+
38
+ /** Anything that would make the browser go to the network to render the page. */
39
+ function externalReferences(html: string): string[] {
40
+ const patterns = [
41
+ /<script[^>]+\ssrc\s*=/gi,
42
+ /<link\b/gi,
43
+ /@import/gi,
44
+ /url\(\s*['"]?https?:/gi,
45
+ /\s(?:src|href|poster|action|data)\s*=\s*["']https?:/gi,
46
+ /\s(?:src|href)\s*=\s*["']\/\//gi,
47
+ ];
48
+ return patterns.flatMap((p) => html.match(p) ?? []);
49
+ }
50
+
51
+ describe('renderHtml — self-containment', () => {
52
+ it('references no external resource of any kind', () => {
53
+ const html = renderHtml(
54
+ report({
55
+ screenshots: [{ label: 'page', width: 1280, height: 3000, dataUrl: PNG }],
56
+ requests: [{ url: 'https://cdn.test/app.js', method: 'GET', status: 404, failed: true }],
57
+ console: [{ type: 'error', text: 'boom', url: 'https://cdn.test/app.js', timestamp: 0 }],
58
+ }),
59
+ );
60
+ expect(externalReferences(html)).toEqual([]);
61
+ });
62
+
63
+ it('inlines its CSS in a style element and inlines screenshots as data URIs', () => {
64
+ const html = renderHtml(
65
+ report({ screenshots: [{ label: 'page', width: 800, height: 600, dataUrl: PNG }] }),
66
+ );
67
+ expect(html).toContain('<style>');
68
+ expect(html).toContain('src="data:image/png;base64,');
69
+ });
70
+
71
+ it('themes for both light and dark without JavaScript', () => {
72
+ const html = renderHtml(report());
73
+ expect(html).toContain('@media (prefers-color-scheme: dark)');
74
+ expect(html).not.toMatch(/<script(?![^>]*type="application\/json")/i);
75
+ });
76
+ });
77
+
78
+ describe('renderHtml — content', () => {
79
+ it('leads with a PASS verdict for a clean page', () => {
80
+ const html = renderHtml(report({ vitals: { lcp: 800, cls: 0.01, inp: 20 } }));
81
+ const verdictAt = html.indexOf('PASS');
82
+ expect(verdictAt).toBeGreaterThan(-1);
83
+ expect(verdictAt).toBeLessThan(html.indexOf('Failed requests'));
84
+ expect(html).toContain('All budgets met.');
85
+ });
86
+
87
+ it('orders the sections verdict, errors, failed requests, vitals, a11y, screenshots', () => {
88
+ const html = renderHtml(report());
89
+ const order = [
90
+ 'PASS',
91
+ 'Errors',
92
+ 'Failed requests',
93
+ 'Web vitals',
94
+ 'Accessibility',
95
+ 'Screenshots',
96
+ ];
97
+ const positions = order.map((s) => html.indexOf(s));
98
+ expect(positions.every((p) => p > -1)).toBe(true);
99
+ expect([...positions].sort((a, b) => a - b)).toEqual(positions);
100
+ });
101
+
102
+ it('shows each budget failure with its expected and actual values', () => {
103
+ const html = renderHtml(
104
+ report({ vitals: { lcp: 4100, cls: 0, inp: 0 } }, parseBudget({ lcp: 2500 })),
105
+ );
106
+ expect(html).toContain('FAIL');
107
+ expect(html).toContain('LCP (Largest Contentful Paint)');
108
+ expect(html).toContain('&lt;= 2500ms');
109
+ expect(html).toContain('4100ms');
110
+ });
111
+
112
+ it('lists failed requests and omits the successful ones', () => {
113
+ const html = renderHtml(
114
+ report({
115
+ requests: [
116
+ { url: 'https://shop.test/api/cart', method: 'POST', status: 500, failed: true },
117
+ { url: 'https://shop.test/ok', method: 'GET', status: 200, failed: false },
118
+ ],
119
+ }),
120
+ );
121
+ expect(html).toContain('https://shop.test/api/cart');
122
+ expect(html).not.toContain('https://shop.test/ok');
123
+ });
124
+
125
+ it('says a metric was not measured rather than showing a fake zero', () => {
126
+ const html = renderHtml(report({ vitals: { cls: 0.05 } }));
127
+ expect(html).toContain('not measured');
128
+ });
129
+
130
+ it('renders a11y findings with role, name state and locator', () => {
131
+ const html = renderHtml(
132
+ report({
133
+ a11y: [
134
+ {
135
+ rule: 'unlabelled-control',
136
+ impact: 'error',
137
+ role: 'button',
138
+ name: null,
139
+ locator: 'form > button:nth-of-type(2)',
140
+ detail: 'button has no accessible name',
141
+ },
142
+ ],
143
+ }),
144
+ );
145
+ expect(html).toContain('unlabelled-control');
146
+ expect(html).toContain('form &gt; button:nth-of-type(2)');
147
+ expect(html).toContain('(no accessible name)');
148
+ });
149
+
150
+ it('states plainly that contrast was not evaluated', () => {
151
+ expect(renderHtml(report())).toMatch(/[Cc]olour contrast is not evaluated/);
152
+ });
153
+
154
+ it('surfaces degraded-run notes', () => {
155
+ const html = renderHtml(report({ notes: ['Page never went network-idle within 20000ms'] }));
156
+ expect(html).toContain('Run notes');
157
+ expect(html).toContain('never went network-idle');
158
+ });
159
+ });
160
+
161
+ describe('escapeHtml', () => {
162
+ it('neutralizes markup from page-controlled strings', () => {
163
+ expect(escapeHtml('<img src=x onerror="alert(1)">')).toBe(
164
+ '&lt;img src=x onerror=&quot;alert(1)&quot;&gt;',
165
+ );
166
+ });
167
+
168
+ it('keeps an injected error message from breaking out of the document', () => {
169
+ const html = renderHtml(
170
+ report({ pageErrors: [{ text: '</table><script>alert(1)</script>', timestamp: 0 }] }),
171
+ );
172
+ expect(html).not.toContain('<script>alert(1)</script>');
173
+ expect(html).toContain('&lt;script&gt;');
174
+ });
175
+ });
@@ -0,0 +1,264 @@
1
+ /**
2
+ * The orchestration seam in report/index.ts: history is read before the run
3
+ * is saved, evidence is only built when it is warranted, and `--repeat`
4
+ * judges the set rather than the last run.
5
+ *
6
+ * `collect` is mocked, so nothing here launches Chrome. Output and history
7
+ * both go to a scratch directory under the monomind data dir — never the
8
+ * shared /tmp.
9
+ */
10
+ import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises';
11
+ import { homedir } from 'node:os';
12
+ import { join } from 'node:path';
13
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
14
+ import type { CdpClient } from '../browser/cdp.js';
15
+ import { historyKey, listRuns } from '../report/history.js';
16
+ import { encodePng, pngToDataUrl } from '../report/png.js';
17
+ import type { CaptureData } from '../report/types.js';
18
+
19
+ const collect = vi.hoisted(() => vi.fn());
20
+ vi.mock('../report/collect.js', () => ({ collect }));
21
+
22
+ const { runReport, runReportRepeated, readHistory } = await import('../report/index.js');
23
+
24
+ const URL_UNDER_TEST = 'https://shop.test/checkout';
25
+ const client = {} as CdpClient;
26
+
27
+ function shot(shade: number, width = 8, height = 8) {
28
+ const data = new Uint8Array(width * height * 4);
29
+ for (let i = 0; i < width * height; i++) data.set([shade, shade, shade, 255], i * 4);
30
+ return {
31
+ label: 'page',
32
+ width,
33
+ height,
34
+ dataUrl: pngToDataUrl(encodePng({ width, height, data })),
35
+ };
36
+ }
37
+
38
+ function capture(partial: Partial<CaptureData> = {}): CaptureData {
39
+ return {
40
+ url: URL_UNDER_TEST,
41
+ finalUrl: URL_UNDER_TEST,
42
+ title: 'Checkout',
43
+ capturedAt: '2026-09-21T10:00:00.000Z',
44
+ durationMs: 1000,
45
+ console: [],
46
+ pageErrors: [],
47
+ requests: [],
48
+ vitals: { lcp: 2000, cls: 0.01, inp: 50 },
49
+ a11y: [],
50
+ structure: [{ path: '/main[1]/button[1]', role: 'button', name: 'Pay now', depth: 1 }],
51
+ screenshots: [shot(200)],
52
+ notes: [],
53
+ ...partial,
54
+ };
55
+ }
56
+
57
+ let root: string;
58
+ let outDir: string;
59
+
60
+ beforeEach(async () => {
61
+ await mkdir(join(homedir(), '.monomind'), { recursive: true });
62
+ root = await mkdtemp(join(homedir(), '.monomind', 'monobrowse-run-test-'));
63
+ outDir = join(root, 'out');
64
+ await mkdir(outDir, { recursive: true });
65
+ collect.mockReset();
66
+ });
67
+
68
+ afterEach(async () => {
69
+ await rm(root, { recursive: true, force: true });
70
+ });
71
+
72
+ const run = (data: CaptureData, extra: Record<string, unknown> = {}) => {
73
+ collect.mockResolvedValueOnce(data);
74
+ return runReport(client, 'S1', {
75
+ url: URL_UNDER_TEST,
76
+ out: join(outDir, `${Math.random().toString(36).slice(2)}.html`),
77
+ historyDir: root,
78
+ cwd: outDir,
79
+ ...extra,
80
+ });
81
+ };
82
+
83
+ describe('runReport and history', () => {
84
+ it('does not let the first run trend or diff against itself', async () => {
85
+ const result = await run(capture());
86
+ expect(result.report.trend?.runs).toBe(0);
87
+ expect(result.report.diff).toBeUndefined();
88
+ // ...but it is on disk for the next run to compare against.
89
+ expect((await listRuns(join(root, historyKey(URL_UNDER_TEST)))).length).toBe(1);
90
+ });
91
+
92
+ it('trends and diffs the second run against the first', async () => {
93
+ await run(capture());
94
+ const second = await run(
95
+ capture({
96
+ capturedAt: '2026-09-21T11:00:00.000Z',
97
+ vitals: { lcp: 2600, cls: 0.01, inp: 50 },
98
+ structure: [{ path: '/main[1]/button[1]', role: 'button', name: 'Place order', depth: 1 }],
99
+ screenshots: [shot(40)],
100
+ }),
101
+ );
102
+
103
+ expect(second.report.trend?.runs).toBe(1);
104
+ const lcp = second.report.trend?.series.find((s) => s.key === 'lcp');
105
+ expect(lcp).toMatchObject({ previous: 2000, current: 2600, direction: 'regressed' });
106
+
107
+ expect(second.report.diff?.previousCapturedAt).toBe('2026-09-21T10:00:00.000Z');
108
+ expect(second.report.diff?.structure?.renamed).toEqual([
109
+ { path: '/main[1]/button[1]', role: 'button', from: 'Pay now', to: 'Place order' },
110
+ ]);
111
+ // Every pixel went from light grey to dark — a whole-screenshot change.
112
+ expect(second.report.diff?.pixels?.changedPercent).toBe(100);
113
+ });
114
+
115
+ it('reports the history directory it wrote to', async () => {
116
+ const result = await run(capture());
117
+ expect(result.historyDir).toBe(join(root, historyKey(URL_UNDER_TEST)));
118
+ });
119
+
120
+ it('prunes to the configured maximum across runs', async () => {
121
+ for (let i = 0; i < 4; i++) {
122
+ await run(capture({ capturedAt: `2026-09-2${i + 1}T10:00:00.000Z` }), { historyMax: 2 });
123
+ }
124
+ const runs = await listRuns(join(root, historyKey(URL_UNDER_TEST)));
125
+ expect(runs.map((r) => r.capturedAt)).toEqual([
126
+ '2026-09-23T10:00:00.000Z',
127
+ '2026-09-24T10:00:00.000Z',
128
+ ]);
129
+ });
130
+
131
+ it('writes nothing to the store when history is off, and says nothing about trends', async () => {
132
+ const result = await run(capture(), { history: false });
133
+ expect(result.report.trend).toBeUndefined();
134
+ expect(result.historyDir).toBeUndefined();
135
+ await expect(readdir(join(root, historyKey(URL_UNDER_TEST)))).rejects.toThrow();
136
+ });
137
+
138
+ it('keeps the report when the history store cannot be written', async () => {
139
+ // A regular file where the store's directory needs to be: mkdir fails
140
+ // with ENOTDIR, and the report still has to come out.
141
+ const blocked = join(root, 'blocked-store');
142
+ await writeFile(blocked, 'not a directory', 'utf8');
143
+ const result = await run(capture(), { historyDir: blocked });
144
+ expect(result.report.verdict).toBe('pass');
145
+ expect(result.report.notes.join(' ')).toMatch(/Run history unavailable/);
146
+ expect(result.historyDir).toBeUndefined();
147
+ });
148
+
149
+ it('exposes the stored runs through readHistory', async () => {
150
+ await run(capture());
151
+ const { runs } = await readHistory(URL_UNDER_TEST, { historyDir: root });
152
+ expect(runs.length).toBe(1);
153
+ expect(runs[0]).toMatchObject({ url: URL_UNDER_TEST, verdict: 'pass' });
154
+ });
155
+ });
156
+
157
+ describe('runReport and evidence', () => {
158
+ const recording = {
159
+ startedAtMs: 1_000_000,
160
+ frames: [
161
+ { offsetMs: 0, data: 'AAAA', bytes: 4 },
162
+ { offsetMs: 900, data: 'BBBB', bytes: 4 },
163
+ ],
164
+ droppedFrames: 0,
165
+ requested: false,
166
+ };
167
+
168
+ it('attaches evidence when the run breaches its budget', async () => {
169
+ const result = await run(
170
+ capture({
171
+ recording,
172
+ console: [{ type: 'error', text: 'boom', timestamp: 1_000_800 }],
173
+ }),
174
+ );
175
+ expect(result.report.verdict).toBe('fail');
176
+ expect(result.report.evidence?.reason).toBe('budget-failure');
177
+ expect(result.report.evidence?.focusOffsetMs).toBe(800);
178
+ expect(result.report.evidence?.frames.length).toBe(2);
179
+ });
180
+
181
+ it('does not attach evidence to a passing run that did not ask for it', async () => {
182
+ const result = await run(capture({ recording }));
183
+ expect(result.report.verdict).toBe('pass');
184
+ expect(result.report.evidence).toBeUndefined();
185
+ });
186
+
187
+ it('attaches evidence to a passing run when --record asked for it', async () => {
188
+ const result = await run(capture({ recording: { ...recording, requested: true } }));
189
+ expect(result.report.evidence?.reason).toBe('requested');
190
+ });
191
+
192
+ it('never leaves the raw recording on the report it returns', async () => {
193
+ const result = await run(capture({ recording: { ...recording, requested: true } }));
194
+ expect(result.report.recording).toBeUndefined();
195
+ });
196
+
197
+ it('keeps the frame images out of the JSON sibling', async () => {
198
+ const result = await run(capture({ recording: { ...recording, requested: true } }));
199
+ const json = JSON.parse(await readFile(result.jsonPath, 'utf8'));
200
+ expect(json.evidence.frames).toEqual([
201
+ { offsetMs: 0, bytes: 4 },
202
+ { offsetMs: 900, bytes: 4 },
203
+ ]);
204
+ });
205
+ });
206
+
207
+ describe('runReportRepeated', () => {
208
+ const failing = capture({ console: [{ type: 'error', text: 'boom', timestamp: 0 }] });
209
+
210
+ it('runs the URL N times and judges the set', async () => {
211
+ for (const data of [capture(), failing, capture()]) collect.mockResolvedValueOnce(data);
212
+ const result = await runReportRepeated(client, 'S1', {
213
+ url: URL_UNDER_TEST,
214
+ out: join(outDir, 'repeat.html'),
215
+ historyDir: root,
216
+ cwd: outDir,
217
+ repeat: 3,
218
+ });
219
+
220
+ expect(collect).toHaveBeenCalledTimes(3);
221
+ expect(result.reports.length).toBe(3);
222
+ expect(result.flake.verdict).toBe('flaky');
223
+ expect(result.summary).toMatch(/^FLAKY/);
224
+ });
225
+
226
+ it('refuses to call a sometimes-failing URL a pass, even when the last run is green', async () => {
227
+ for (const data of [failing, capture()]) collect.mockResolvedValueOnce(data);
228
+ const result = await runReportRepeated(client, 'S1', {
229
+ url: URL_UNDER_TEST,
230
+ out: join(outDir, 'repeat2.html'),
231
+ historyDir: root,
232
+ cwd: outDir,
233
+ repeat: 2,
234
+ });
235
+ expect(result.report.verdict).toBe('pass'); // the last run alone
236
+ expect(result.flake.verdict).toBe('flaky'); // what the command reports
237
+ });
238
+
239
+ it('writes one history entry for the whole command, not one per run', async () => {
240
+ for (const data of [capture(), capture(), capture()]) collect.mockResolvedValueOnce(data);
241
+ await runReportRepeated(client, 'S1', {
242
+ url: URL_UNDER_TEST,
243
+ out: join(outDir, 'repeat3.html'),
244
+ historyDir: root,
245
+ cwd: outDir,
246
+ repeat: 3,
247
+ });
248
+ expect((await listRuns(join(root, historyKey(URL_UNDER_TEST)))).length).toBe(1);
249
+ });
250
+
251
+ it('puts the flake analysis in the JSON sibling', async () => {
252
+ for (const data of [capture(), failing]) collect.mockResolvedValueOnce(data);
253
+ const result = await runReportRepeated(client, 'S1', {
254
+ url: URL_UNDER_TEST,
255
+ out: join(outDir, 'repeat4.html'),
256
+ historyDir: root,
257
+ cwd: outDir,
258
+ repeat: 2,
259
+ });
260
+ const json = JSON.parse(await readFile(result.jsonPath, 'utf8'));
261
+ expect(json.flake.verdict).toBe('flaky');
262
+ expect(json.flake.runs).toBe(2);
263
+ });
264
+ });
@@ -1,3 +1,4 @@
1
+ export * from '../report/index.js';
1
2
  export * from './actions.js';
2
3
  export * from './batch.js';
3
4
  export * from './browser.js';
@@ -8,6 +8,9 @@ export interface ProfilerOptions {
8
8
  samplingInterval?: number;
9
9
  }
10
10
 
11
+ /** Heap snapshots on a large page take far longer than the 30s CDP default. */
12
+ const HEAP_SNAPSHOT_TIMEOUT_MS = 120_000;
13
+
11
14
  const _sessions = new Set<string>();
12
15
  const _heapSessions = new Set<string>();
13
16
 
@@ -76,33 +79,37 @@ export async function startHeapSnapshot(
76
79
  });
77
80
 
78
81
  try {
79
- await new Promise<void>((resolve, reject) => {
80
- const timeoutHandle = setTimeout(() => {
81
- off2();
82
- reject(new Error('Timeout waiting for heap snapshot (120s)'));
83
- }, 120_000);
84
- const off2 = client.on('HeapProfiler.reportHeapSnapshotProgress', (params, sid) => {
85
- if (sid !== sessionId) return;
86
- if (params.finished) {
87
- clearTimeout(timeoutHandle);
88
- off2();
89
- resolve();
90
- }
91
- });
92
- client
93
- .send('HeapProfiler.takeHeapSnapshot', { reportProgress: true }, sessionId)
94
- .catch((err) => {
95
- clearTimeout(timeoutHandle);
96
- off2();
97
- reject(err);
98
- });
99
- });
82
+ // Resolve on takeHeapSnapshot's own RESPONSE, not on
83
+ // `HeapProfiler.reportHeapSnapshotProgress` with finished:true. Chrome
84
+ // emits that progress event once it has finished WALKING the heap, which
85
+ // is before it has streamed a single addHeapSnapshotChunk (verified
86
+ // against Chrome 153) — so resolving there detached the chunk listener in
87
+ // the `finally` below and wrote a 0-byte snapshot every single time. The
88
+ // command response is the only signal that every chunk has been sent.
89
+ //
90
+ // reportProgress is off for the same reason: nothing consumes those
91
+ // events any more, and they are pure noise on the wire.
92
+ await client.send(
93
+ 'HeapProfiler.takeHeapSnapshot',
94
+ { reportProgress: false },
95
+ sessionId,
96
+ HEAP_SNAPSHOT_TIMEOUT_MS,
97
+ );
100
98
  } finally {
101
99
  off();
102
100
  _heapSessions.delete(sessionId);
103
101
  await client.send('HeapProfiler.disable', {}, sessionId).catch(() => {});
104
102
  }
105
103
 
104
+ // A zero-chunk snapshot is not a snapshot. Writing the empty file anyway is
105
+ // how this bug stayed invisible — the caller got a path, and only noticed
106
+ // when DevTools refused to load it.
107
+ if (chunks.length === 0) {
108
+ throw new Error(
109
+ 'Heap snapshot produced no data — Chrome accepted HeapProfiler.takeHeapSnapshot but sent no chunks',
110
+ );
111
+ }
112
+
106
113
  const path = outputPath ?? join(tmpdir(), `monomind-heap-${Date.now()}.heapsnapshot`);
107
114
  await writeFile(path, chunks.join(''));
108
115
  return path;