@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.
Files changed (271) hide show
  1. package/dist/src/__tests__/bridge-instruments.test.d.ts +2 -0
  2. package/dist/src/__tests__/bridge-instruments.test.d.ts.map +1 -0
  3. package/dist/src/__tests__/bridge-instruments.test.js +132 -0
  4. package/dist/src/__tests__/bridge-instruments.test.js.map +1 -0
  5. package/dist/src/__tests__/bridge-transport.test.d.ts +2 -0
  6. package/dist/src/__tests__/bridge-transport.test.d.ts.map +1 -0
  7. package/dist/src/__tests__/bridge-transport.test.js +287 -0
  8. package/dist/src/__tests__/bridge-transport.test.js.map +1 -0
  9. package/dist/src/__tests__/cdp-transport.test.d.ts +2 -0
  10. package/dist/src/__tests__/cdp-transport.test.d.ts.map +1 -0
  11. package/dist/src/__tests__/cdp-transport.test.js +167 -0
  12. package/dist/src/__tests__/cdp-transport.test.js.map +1 -0
  13. package/dist/src/__tests__/profiler-heap.test.d.ts +2 -0
  14. package/dist/src/__tests__/profiler-heap.test.d.ts.map +1 -0
  15. package/dist/src/__tests__/profiler-heap.test.js +154 -0
  16. package/dist/src/__tests__/profiler-heap.test.js.map +1 -0
  17. package/dist/src/__tests__/report-a11y.test.d.ts +2 -0
  18. package/dist/src/__tests__/report-a11y.test.d.ts.map +1 -0
  19. package/dist/src/__tests__/report-a11y.test.js +147 -0
  20. package/dist/src/__tests__/report-a11y.test.js.map +1 -0
  21. package/dist/src/__tests__/report-analyze.test.d.ts +2 -0
  22. package/dist/src/__tests__/report-analyze.test.d.ts.map +1 -0
  23. package/dist/src/__tests__/report-analyze.test.js +157 -0
  24. package/dist/src/__tests__/report-analyze.test.js.map +1 -0
  25. package/dist/src/__tests__/report-budget.test.d.ts +2 -0
  26. package/dist/src/__tests__/report-budget.test.d.ts.map +1 -0
  27. package/dist/src/__tests__/report-budget.test.js +80 -0
  28. package/dist/src/__tests__/report-budget.test.js.map +1 -0
  29. package/dist/src/__tests__/report-diff.test.d.ts +2 -0
  30. package/dist/src/__tests__/report-diff.test.d.ts.map +1 -0
  31. package/dist/src/__tests__/report-diff.test.js +231 -0
  32. package/dist/src/__tests__/report-diff.test.js.map +1 -0
  33. package/dist/src/__tests__/report-evidence.test.d.ts +2 -0
  34. package/dist/src/__tests__/report-evidence.test.d.ts.map +1 -0
  35. package/dist/src/__tests__/report-evidence.test.js +214 -0
  36. package/dist/src/__tests__/report-evidence.test.js.map +1 -0
  37. package/dist/src/__tests__/report-flake.test.d.ts +2 -0
  38. package/dist/src/__tests__/report-flake.test.d.ts.map +1 -0
  39. package/dist/src/__tests__/report-flake.test.js +182 -0
  40. package/dist/src/__tests__/report-flake.test.js.map +1 -0
  41. package/dist/src/__tests__/report-history.test.d.ts +2 -0
  42. package/dist/src/__tests__/report-history.test.d.ts.map +1 -0
  43. package/dist/src/__tests__/report-history.test.js +229 -0
  44. package/dist/src/__tests__/report-history.test.js.map +1 -0
  45. package/dist/src/__tests__/report-output.test.d.ts +2 -0
  46. package/dist/src/__tests__/report-output.test.d.ts.map +1 -0
  47. package/dist/src/__tests__/report-output.test.js +169 -0
  48. package/dist/src/__tests__/report-output.test.js.map +1 -0
  49. package/dist/src/__tests__/report-png.test.d.ts +2 -0
  50. package/dist/src/__tests__/report-png.test.d.ts.map +1 -0
  51. package/dist/src/__tests__/report-png.test.js +84 -0
  52. package/dist/src/__tests__/report-png.test.js.map +1 -0
  53. package/dist/src/__tests__/report-render-extras.test.d.ts +2 -0
  54. package/dist/src/__tests__/report-render-extras.test.d.ts.map +1 -0
  55. package/dist/src/__tests__/report-render-extras.test.js +285 -0
  56. package/dist/src/__tests__/report-render-extras.test.js.map +1 -0
  57. package/dist/src/__tests__/report-render.test.d.ts +2 -0
  58. package/dist/src/__tests__/report-render.test.d.ts.map +1 -0
  59. package/dist/src/__tests__/report-render.test.js +143 -0
  60. package/dist/src/__tests__/report-render.test.js.map +1 -0
  61. package/dist/src/__tests__/report-run.test.d.ts +2 -0
  62. package/dist/src/__tests__/report-run.test.d.ts.map +1 -0
  63. package/dist/src/__tests__/report-run.test.js +234 -0
  64. package/dist/src/__tests__/report-run.test.js.map +1 -0
  65. package/dist/src/browser/bridge.d.ts +119 -0
  66. package/dist/src/browser/bridge.d.ts.map +1 -0
  67. package/dist/src/browser/bridge.js +264 -0
  68. package/dist/src/browser/bridge.js.map +1 -0
  69. package/dist/src/browser/cdp.d.ts +11 -1
  70. package/dist/src/browser/cdp.d.ts.map +1 -1
  71. package/dist/src/browser/cdp.js +60 -56
  72. package/dist/src/browser/cdp.js.map +1 -1
  73. package/dist/src/browser/index.d.ts +3 -0
  74. package/dist/src/browser/index.d.ts.map +1 -1
  75. package/dist/src/browser/index.js +3 -0
  76. package/dist/src/browser/index.js.map +1 -1
  77. package/dist/src/browser/profiler.d.ts.map +1 -1
  78. package/dist/src/browser/profiler.js +19 -22
  79. package/dist/src/browser/profiler.js.map +1 -1
  80. package/dist/src/browser/transport.d.ts +62 -0
  81. package/dist/src/browser/transport.d.ts.map +1 -0
  82. package/dist/src/browser/transport.js +65 -0
  83. package/dist/src/browser/transport.js.map +1 -0
  84. package/dist/src/cli/commander-adapter.d.ts +16 -0
  85. package/dist/src/cli/commander-adapter.d.ts.map +1 -0
  86. package/dist/src/cli/commander-adapter.js +68 -0
  87. package/dist/src/cli/commander-adapter.js.map +1 -0
  88. package/dist/src/cli/commands-context.d.ts +14 -0
  89. package/dist/src/cli/commands-context.d.ts.map +1 -0
  90. package/dist/src/cli/commands-context.js +351 -0
  91. package/dist/src/cli/commands-context.js.map +1 -0
  92. package/dist/src/cli/commands-data.d.ts +12 -0
  93. package/dist/src/cli/commands-data.d.ts.map +1 -0
  94. package/dist/src/cli/commands-data.js +246 -0
  95. package/dist/src/cli/commands-data.js.map +1 -0
  96. package/dist/src/cli/commands-element.d.ts +15 -0
  97. package/dist/src/cli/commands-element.d.ts.map +1 -0
  98. package/dist/src/cli/commands-element.js +262 -0
  99. package/dist/src/cli/commands-element.js.map +1 -0
  100. package/dist/src/cli/commands-files.d.ts +9 -0
  101. package/dist/src/cli/commands-files.d.ts.map +1 -0
  102. package/dist/src/cli/commands-files.js +207 -0
  103. package/dist/src/cli/commands-files.js.map +1 -0
  104. package/dist/src/cli/commands-input.d.ts +26 -0
  105. package/dist/src/cli/commands-input.d.ts.map +1 -0
  106. package/dist/src/cli/commands-input.js +399 -0
  107. package/dist/src/cli/commands-input.js.map +1 -0
  108. package/dist/src/cli/commands-navigate.d.ts +12 -0
  109. package/dist/src/cli/commands-navigate.d.ts.map +1 -0
  110. package/dist/src/cli/commands-navigate.js +389 -0
  111. package/dist/src/cli/commands-navigate.js.map +1 -0
  112. package/dist/src/cli/commands-page.d.ts +25 -0
  113. package/dist/src/cli/commands-page.d.ts.map +1 -0
  114. package/dist/src/cli/commands-page.js +377 -0
  115. package/dist/src/cli/commands-page.js.map +1 -0
  116. package/dist/src/cli/commands-report.d.ts +8 -0
  117. package/dist/src/cli/commands-report.d.ts.map +1 -0
  118. package/dist/src/cli/commands-report.js +256 -0
  119. package/dist/src/cli/commands-report.js.map +1 -0
  120. package/dist/src/cli/commands-script.d.ts +43 -0
  121. package/dist/src/cli/commands-script.d.ts.map +1 -0
  122. package/dist/src/cli/commands-script.js +168 -0
  123. package/dist/src/cli/commands-script.js.map +1 -0
  124. package/dist/src/cli/commands-session.d.ts +15 -0
  125. package/dist/src/cli/commands-session.d.ts.map +1 -0
  126. package/dist/src/cli/commands-session.js +357 -0
  127. package/dist/src/cli/commands-session.js.map +1 -0
  128. package/dist/src/cli/commands-trace.d.ts +12 -0
  129. package/dist/src/cli/commands-trace.d.ts.map +1 -0
  130. package/dist/src/cli/commands-trace.js +347 -0
  131. package/dist/src/cli/commands-trace.js.map +1 -0
  132. package/dist/src/cli/commands.d.ts +22 -42
  133. package/dist/src/cli/commands.d.ts.map +1 -1
  134. package/dist/src/cli/commands.js +48 -3400
  135. package/dist/src/cli/commands.js.map +1 -1
  136. package/dist/src/cli/session.d.ts +47 -0
  137. package/dist/src/cli/session.d.ts.map +1 -0
  138. package/dist/src/cli/session.js +343 -0
  139. package/dist/src/cli/session.js.map +1 -0
  140. package/dist/src/report/a11y.d.ts +52 -0
  141. package/dist/src/report/a11y.d.ts.map +1 -0
  142. package/dist/src/report/a11y.js +231 -0
  143. package/dist/src/report/a11y.js.map +1 -0
  144. package/dist/src/report/analyze.d.ts +20 -0
  145. package/dist/src/report/analyze.d.ts.map +1 -0
  146. package/dist/src/report/analyze.js +136 -0
  147. package/dist/src/report/analyze.js.map +1 -0
  148. package/dist/src/report/budget.d.ts +27 -0
  149. package/dist/src/report/budget.d.ts.map +1 -0
  150. package/dist/src/report/budget.js +143 -0
  151. package/dist/src/report/budget.js.map +1 -0
  152. package/dist/src/report/collect-a11y.d.ts +25 -0
  153. package/dist/src/report/collect-a11y.d.ts.map +1 -0
  154. package/dist/src/report/collect-a11y.js +154 -0
  155. package/dist/src/report/collect-a11y.js.map +1 -0
  156. package/dist/src/report/collect.d.ts +45 -0
  157. package/dist/src/report/collect.d.ts.map +1 -0
  158. package/dist/src/report/collect.js +275 -0
  159. package/dist/src/report/collect.js.map +1 -0
  160. package/dist/src/report/evidence.d.ts +80 -0
  161. package/dist/src/report/evidence.d.ts.map +1 -0
  162. package/dist/src/report/evidence.js +279 -0
  163. package/dist/src/report/evidence.js.map +1 -0
  164. package/dist/src/report/flake.d.ts +88 -0
  165. package/dist/src/report/flake.d.ts.map +1 -0
  166. package/dist/src/report/flake.js +232 -0
  167. package/dist/src/report/flake.js.map +1 -0
  168. package/dist/src/report/history.d.ts +85 -0
  169. package/dist/src/report/history.d.ts.map +1 -0
  170. package/dist/src/report/history.js +176 -0
  171. package/dist/src/report/history.js.map +1 -0
  172. package/dist/src/report/index.d.ts +102 -0
  173. package/dist/src/report/index.d.ts.map +1 -0
  174. package/dist/src/report/index.js +216 -0
  175. package/dist/src/report/index.js.map +1 -0
  176. package/dist/src/report/pixel-diff.d.ts +30 -0
  177. package/dist/src/report/pixel-diff.d.ts.map +1 -0
  178. package/dist/src/report/pixel-diff.js +175 -0
  179. package/dist/src/report/pixel-diff.js.map +1 -0
  180. package/dist/src/report/png.d.ts +28 -0
  181. package/dist/src/report/png.d.ts.map +1 -0
  182. package/dist/src/report/png.js +259 -0
  183. package/dist/src/report/png.js.map +1 -0
  184. package/dist/src/report/render-evidence.d.ts +16 -0
  185. package/dist/src/report/render-evidence.d.ts.map +1 -0
  186. package/dist/src/report/render-evidence.js +174 -0
  187. package/dist/src/report/render-evidence.js.map +1 -0
  188. package/dist/src/report/render-trend.d.ts +16 -0
  189. package/dist/src/report/render-trend.d.ts.map +1 -0
  190. package/dist/src/report/render-trend.js +194 -0
  191. package/dist/src/report/render-trend.js.map +1 -0
  192. package/dist/src/report/render.d.ts +21 -0
  193. package/dist/src/report/render.d.ts.map +1 -0
  194. package/dist/src/report/render.js +286 -0
  195. package/dist/src/report/render.js.map +1 -0
  196. package/dist/src/report/run-diff.d.ts +18 -0
  197. package/dist/src/report/run-diff.d.ts.map +1 -0
  198. package/dist/src/report/run-diff.js +51 -0
  199. package/dist/src/report/run-diff.js.map +1 -0
  200. package/dist/src/report/structure.d.ts +41 -0
  201. package/dist/src/report/structure.d.ts.map +1 -0
  202. package/dist/src/report/structure.js +210 -0
  203. package/dist/src/report/structure.js.map +1 -0
  204. package/dist/src/report/trend.d.ts +26 -0
  205. package/dist/src/report/trend.d.ts.map +1 -0
  206. package/dist/src/report/trend.js +164 -0
  207. package/dist/src/report/trend.js.map +1 -0
  208. package/dist/src/report/types.d.ts +320 -0
  209. package/dist/src/report/types.d.ts.map +1 -0
  210. package/dist/src/report/types.js +11 -0
  211. package/dist/src/report/types.js.map +1 -0
  212. package/dist/src/report/util.d.ts +17 -0
  213. package/dist/src/report/util.d.ts.map +1 -0
  214. package/dist/src/report/util.js +45 -0
  215. package/dist/src/report/util.js.map +1 -0
  216. package/dist/tsconfig.tsbuildinfo +1 -1
  217. package/package.json +1 -1
  218. package/src/__tests__/bridge-instruments.test.ts +180 -0
  219. package/src/__tests__/bridge-transport.test.ts +365 -0
  220. package/src/__tests__/cdp-transport.test.ts +198 -0
  221. package/src/__tests__/profiler-heap.test.ts +191 -0
  222. package/src/__tests__/report-a11y.test.ts +180 -0
  223. package/src/__tests__/report-analyze.test.ts +202 -0
  224. package/src/__tests__/report-budget.test.ts +92 -0
  225. package/src/__tests__/report-diff.test.ts +272 -0
  226. package/src/__tests__/report-evidence.test.ts +276 -0
  227. package/src/__tests__/report-flake.test.ts +214 -0
  228. package/src/__tests__/report-history.test.ts +293 -0
  229. package/src/__tests__/report-output.test.ts +210 -0
  230. package/src/__tests__/report-png.test.ts +95 -0
  231. package/src/__tests__/report-render-extras.test.ts +315 -0
  232. package/src/__tests__/report-render.test.ts +175 -0
  233. package/src/__tests__/report-run.test.ts +264 -0
  234. package/src/browser/bridge.ts +354 -0
  235. package/src/browser/cdp.ts +57 -52
  236. package/src/browser/index.ts +3 -0
  237. package/src/browser/profiler.ts +28 -21
  238. package/src/browser/transport.ts +111 -0
  239. package/src/cli/commander-adapter.ts +65 -0
  240. package/src/cli/commands-context.ts +367 -0
  241. package/src/cli/commands-data.ts +248 -0
  242. package/src/cli/commands-element.ts +305 -0
  243. package/src/cli/commands-files.ts +248 -0
  244. package/src/cli/commands-input.ts +434 -0
  245. package/src/cli/commands-navigate.ts +442 -0
  246. package/src/cli/commands-page.ts +420 -0
  247. package/src/cli/commands-report.ts +275 -0
  248. package/src/cli/commands-script.ts +183 -0
  249. package/src/cli/commands-session.ts +376 -0
  250. package/src/cli/commands-trace.ts +349 -0
  251. package/src/cli/commands.ts +101 -3677
  252. package/src/cli/session.ts +412 -0
  253. package/src/report/a11y.ts +242 -0
  254. package/src/report/analyze.ts +181 -0
  255. package/src/report/budget.ts +155 -0
  256. package/src/report/collect-a11y.ts +200 -0
  257. package/src/report/collect.ts +408 -0
  258. package/src/report/evidence.ts +379 -0
  259. package/src/report/flake.ts +303 -0
  260. package/src/report/history.ts +219 -0
  261. package/src/report/index.ts +307 -0
  262. package/src/report/pixel-diff.ts +197 -0
  263. package/src/report/png.ts +290 -0
  264. package/src/report/render-evidence.ts +194 -0
  265. package/src/report/render-trend.ts +225 -0
  266. package/src/report/render.ts +314 -0
  267. package/src/report/run-diff.ts +63 -0
  268. package/src/report/structure.ts +212 -0
  269. package/src/report/trend.ts +189 -0
  270. package/src/report/types.ts +356 -0
  271. package/src/report/util.ts +48 -0
@@ -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
+ });
@@ -0,0 +1,354 @@
1
+ import { enableSessionDomains } from './browser.js';
2
+ import { CdpClient } from './cdp.js';
3
+ import type { CdpTransport, CdpTransportHandlers } from './transport.js';
4
+ import { WebSocketTransport } from './transport.js';
5
+
6
+ /**
7
+ * CDP over the MonoAgent extension bridge.
8
+ *
9
+ * The extension already holds Chrome's `debugger` permission and attaches to
10
+ * tabs with `chrome.debugger.attach`; mono-agent's Go server already owns the
11
+ * WebSocket to it. This transport puts CDP frames on that path — out through
12
+ * the relay's `/monoagent/cdp` socket, into `chrome.debugger.sendCommand`,
13
+ * and back — so a CdpClient can drive the browser the user is actually
14
+ * signed into rather than a headless Chrome we launched. Every instrument
15
+ * built on CdpClient (console, network, HAR, vitals, trace, CPU profiler,
16
+ * snapshot, screenshot, emulation) comes along unchanged.
17
+ *
18
+ * Two mismatches have to be reconciled here, and they are the whole job:
19
+ *
20
+ * **Sessions.** Against a raw socket, monobrowse attaches to a target and
21
+ * carries the resulting `sessionId` on every command and every event.
22
+ * `chrome.debugger` has no such id for the tab: the debuggee *is* the page
23
+ * session, and a `sessionId` on the wire means a *child* session (an
24
+ * out-of-process iframe). So this transport mints a stable synthetic id for
25
+ * the tab, strips it on the way out, stamps it onto relayed events on the
26
+ * way in, and passes any other id through untouched for real child sessions.
27
+ *
28
+ * **Attachment.** A socket is opened; a debuggee is attached. open() does the
29
+ * attach and fails if it cannot, rather than handing back a transport whose
30
+ * first command would fail. close() detaches, which is what takes Chrome's
31
+ * "…started debugging this browser" banner off the user's tab.
32
+ *
33
+ * Known gaps, which are Chrome's and not this file's: `chrome.debugger`
34
+ * exposes a fixed set of domains, and `HeapProfiler` and `Browser` are not
35
+ * among them, so heap snapshots and `Browser.close` fail over the bridge.
36
+ */
37
+
38
+ /** Header the Go relay authenticates with; mirrors internal/extension/token.go. */
39
+ export const BRIDGE_TOKEN_HEADER = 'X-Monoagent-Extension-Token';
40
+
41
+ /** Default relay endpoint; mirrors the extension server's default port. */
42
+ export const DEFAULT_BRIDGE_URL = 'ws://127.0.0.1:9222/monoagent/cdp';
43
+
44
+ /** How long the attach handshake may take before open() gives up. */
45
+ const DEFAULT_ATTACH_TIMEOUT_MS = 15_000;
46
+
47
+ /** The synthetic target id standing in for a Chrome tab. */
48
+ export function bridgeTargetId(tabId: number): string {
49
+ return `bridge-tab-${tabId}`;
50
+ }
51
+
52
+ /** The synthetic session id standing in for a tab's page session. */
53
+ export function bridgeSessionId(tabId: number): string {
54
+ return `bridge-session-${tabId}`;
55
+ }
56
+
57
+ /** True for ids this transport minted, as opposed to real CDP child sessions. */
58
+ export function isBridgeSessionId(sessionId: string | undefined): boolean {
59
+ return typeof sessionId === 'string' && sessionId.startsWith('bridge-session-');
60
+ }
61
+
62
+ export interface BridgeTransportOptions {
63
+ /**
64
+ * The Chrome tab to drive. Omit it to drive whichever tab the user is
65
+ * looking at: the extension resolves the active tab and reports the id
66
+ * back on attach, which is the normal way in — the caller has no tab ids
67
+ * to hand, only a browser with the user's session in it.
68
+ */
69
+ tabId?: number;
70
+ /** Relay endpoint. Defaults to DEFAULT_BRIDGE_URL. */
71
+ url?: string;
72
+ /** Relay token, from ~/.monoagent (the Go side writes it). */
73
+ token?: string;
74
+ /** Attach-handshake budget in ms. */
75
+ attachTimeoutMs?: number;
76
+ /**
77
+ * The byte pipe underneath. Defaults to a WebSocket to `url`; tests inject
78
+ * a scripted one so they can be the relay.
79
+ */
80
+ socket?: CdpTransport;
81
+ }
82
+
83
+ /** One bridge-level envelope, as the Go relay and the extension speak it. */
84
+ interface BridgeEnvelope {
85
+ id: string;
86
+ type: 'cdp' | 'cdp_attach' | 'cdp_detach';
87
+ /** Omitted when the extension should resolve the active tab itself. */
88
+ tabId?: number;
89
+ params?: Record<string, unknown>;
90
+ }
91
+
92
+ interface BridgeReply {
93
+ id?: string;
94
+ type?: string;
95
+ success?: boolean;
96
+ data?: {
97
+ result?: unknown;
98
+ tabId?: number;
99
+ method?: string;
100
+ params?: unknown;
101
+ sessionId?: string;
102
+ };
103
+ error?: string;
104
+ }
105
+
106
+ /** A CDP frame on its way out of CdpClient. */
107
+ interface OutboundCdp {
108
+ id: number;
109
+ method: string;
110
+ params?: Record<string, unknown>;
111
+ sessionId?: string;
112
+ }
113
+
114
+ export class BridgeTransport implements CdpTransport {
115
+ /** 0 until the extension resolves the active tab (see open()). */
116
+ private tabId: number;
117
+ private readonly socket: CdpTransport;
118
+ private readonly attachTimeoutMs: number;
119
+ private handlers: CdpTransportHandlers | null = null;
120
+ private nextEnvelopeId = 1;
121
+ private readonly pending = new Map<string, (reply: BridgeReply) => void>();
122
+ private closed = false;
123
+
124
+ constructor(opts: BridgeTransportOptions) {
125
+ this.tabId = opts.tabId ?? 0;
126
+ this.attachTimeoutMs = opts.attachTimeoutMs ?? DEFAULT_ATTACH_TIMEOUT_MS;
127
+ this.socket =
128
+ opts.socket ??
129
+ new WebSocketTransport(
130
+ opts.url ?? DEFAULT_BRIDGE_URL,
131
+ opts.token ? { [BRIDGE_TOKEN_HEADER]: opts.token } : undefined,
132
+ );
133
+ }
134
+
135
+ async open(handlers: CdpTransportHandlers): Promise<void> {
136
+ this.handlers = handlers;
137
+ await this.socket.open({
138
+ message: (data) => this.onBridgeMessage(data),
139
+ close: (err) => this.onGone(err ?? new Error('extension bridge closed')),
140
+ error: (err) => this.onGone(err),
141
+ });
142
+ const reply = await this.command('cdp_attach', undefined, this.attachTimeoutMs);
143
+ if (reply.success === false) {
144
+ this.socket.close();
145
+ const where = this.tabId ? `tab ${this.tabId}` : 'the active tab';
146
+ throw new Error(`bridge attach to ${where} failed: ${reply.error ?? 'unknown'}`);
147
+ }
148
+ // The extension answers with the tab it actually attached to, which is
149
+ // the only way to learn the id when we asked for "whatever is active".
150
+ if (typeof reply.data?.tabId === 'number') this.tabId = reply.data.tabId;
151
+ if (!this.tabId) {
152
+ this.socket.close();
153
+ throw new Error('bridge attach did not report a tab id');
154
+ }
155
+ }
156
+
157
+ /** The tab this transport is driving; valid once open() has resolved. */
158
+ get attachedTabId(): number {
159
+ return this.tabId;
160
+ }
161
+
162
+ /** The session id every instrument should use against this transport. */
163
+ get sessionId(): string {
164
+ return bridgeSessionId(this.tabId);
165
+ }
166
+
167
+ async send(frame: string): Promise<void> {
168
+ const cdp = JSON.parse(frame) as OutboundCdp;
169
+ if (this.answerLocally(cdp)) return;
170
+
171
+ const params: Record<string, unknown> = {
172
+ method: cdp.method,
173
+ params: cdp.params ?? {},
174
+ };
175
+ // A synthetic id means "the tab itself", which chrome.debugger addresses
176
+ // by its debuggee alone. Anything else is a real child session id and
177
+ // has to travel, or the command lands in the wrong frame.
178
+ if (cdp.sessionId !== undefined && !isBridgeSessionId(cdp.sessionId)) {
179
+ params.sessionId = cdp.sessionId;
180
+ }
181
+
182
+ const reply = await this.command('cdp', params);
183
+ if (reply.success === false) {
184
+ this.deliver({
185
+ id: cdp.id,
186
+ // -32000 is CDP's own "server error" code; the bridge has no richer
187
+ // one to offer, and the message is what a caller actually reads.
188
+ error: { code: -32000, message: reply.error ?? 'bridge command failed' },
189
+ });
190
+ return;
191
+ }
192
+ this.deliver({ id: cdp.id, result: reply.data?.result ?? {} });
193
+ }
194
+
195
+ close(): void {
196
+ if (this.closed) return;
197
+ this.closed = true;
198
+ // Best effort, and deliberately not awaited: leaving the debugger
199
+ // attached leaves Chrome's debugging banner on the user's tab. The
200
+ // extension's idle sweep would eventually detach anyway.
201
+ void this.command('cdp_detach').catch(() => {});
202
+ this.socket.close();
203
+ this.failPending(new Error('extension bridge closed'));
204
+ }
205
+
206
+ // --- internals -----------------------------------------------------------
207
+
208
+ /**
209
+ * Commands this transport answers itself. `Target.attachToTarget` against
210
+ * the tab is the one that matters: monobrowse's connect path asks for a
211
+ * session, and over the bridge there is nothing to ask — the debuggee is
212
+ * already the session. Any *other* target id is a genuine child target and
213
+ * goes to Chrome.
214
+ */
215
+ private answerLocally(cdp: OutboundCdp): boolean {
216
+ const targetId = cdp.params?.targetId;
217
+ if (cdp.method === 'Target.attachToTarget' && targetId === bridgeTargetId(this.tabId)) {
218
+ this.deliver({ id: cdp.id, result: { sessionId: bridgeSessionId(this.tabId) } });
219
+ return true;
220
+ }
221
+ if (
222
+ cdp.method === 'Target.detachFromTarget' &&
223
+ isBridgeSessionId(cdp.params?.sessionId as string | undefined)
224
+ ) {
225
+ this.deliver({ id: cdp.id, result: {} });
226
+ return true;
227
+ }
228
+ return false;
229
+ }
230
+
231
+ private command(
232
+ type: BridgeEnvelope['type'],
233
+ params?: Record<string, unknown>,
234
+ timeoutMs = 0,
235
+ ): Promise<BridgeReply> {
236
+ const id = `cdp-${this.nextEnvelopeId++}`;
237
+ const envelope: BridgeEnvelope = { id, type, params };
238
+ if (this.tabId) envelope.tabId = this.tabId;
239
+ return new Promise<BridgeReply>((resolve, reject) => {
240
+ let timer: ReturnType<typeof setTimeout> | undefined;
241
+ if (timeoutMs > 0) {
242
+ timer = setTimeout(() => {
243
+ this.pending.delete(id);
244
+ reject(new Error(`bridge ${type} timed out after ${timeoutMs}ms`));
245
+ }, timeoutMs);
246
+ timer.unref?.();
247
+ }
248
+ this.pending.set(id, (reply) => {
249
+ if (timer) clearTimeout(timer);
250
+ resolve(reply);
251
+ });
252
+ this.socket.send(JSON.stringify(envelope)).catch((err) => {
253
+ if (timer) clearTimeout(timer);
254
+ this.pending.delete(id);
255
+ reject(err);
256
+ });
257
+ });
258
+ }
259
+
260
+ private onBridgeMessage(data: string): void {
261
+ let msg: BridgeReply;
262
+ try {
263
+ msg = JSON.parse(data) as BridgeReply;
264
+ } catch {
265
+ return; // malformed frames are ignored, as on a raw CDP socket
266
+ }
267
+
268
+ if (msg.type === 'cdp_event') {
269
+ this.relayEvent(msg);
270
+ return;
271
+ }
272
+ if (msg.id !== undefined) {
273
+ const settle = this.pending.get(msg.id);
274
+ if (settle) {
275
+ this.pending.delete(msg.id);
276
+ settle(msg);
277
+ }
278
+ }
279
+ }
280
+
281
+ private relayEvent(msg: BridgeReply): void {
282
+ const ev = msg.data;
283
+ if (!ev?.method) return;
284
+ // One relay socket can carry several tabs; take only our own.
285
+ if (ev.tabId !== undefined && ev.tabId !== this.tabId) return;
286
+
287
+ // The debugger went away — the user hit Cancel on the banner, DevTools
288
+ // opened on the tab, or the tab closed. Nothing else will arrive, and a
289
+ // caller waiting on a command should hear that now rather than time out.
290
+ if (ev.method === 'Inspector.detached') {
291
+ const reason = (ev.params as { reason?: string } | undefined)?.reason ?? 'unknown';
292
+ this.onGone(new Error(`extension bridge detached from tab ${this.tabId}: ${reason}`));
293
+ return;
294
+ }
295
+
296
+ this.deliver({
297
+ method: ev.method,
298
+ params: (ev.params as Record<string, unknown>) ?? {},
299
+ // A real child session id wins; otherwise this is the tab's own
300
+ // session, which every instrument filters its events by.
301
+ sessionId: ev.sessionId ?? bridgeSessionId(this.tabId),
302
+ });
303
+ }
304
+
305
+ private deliver(frame: Record<string, unknown>): void {
306
+ this.handlers?.message(JSON.stringify(frame));
307
+ }
308
+
309
+ private onGone(err: Error): void {
310
+ this.failPending(err);
311
+ this.handlers?.close(err);
312
+ }
313
+
314
+ private failPending(err: Error): void {
315
+ for (const settle of this.pending.values()) {
316
+ settle({ success: false, error: err.message });
317
+ }
318
+ this.pending.clear();
319
+ }
320
+ }
321
+
322
+ /** What a bridge connection hands back: the same shape connectToTarget gives. */
323
+ export interface BridgeConnection {
324
+ client: CdpClient;
325
+ sessionId: string;
326
+ tabId: number;
327
+ transport: BridgeTransport;
328
+ }
329
+
330
+ /**
331
+ * Connect a CdpClient to the user's real Chrome through the extension
332
+ * bridge, with the same domains enabled that a local session gets. This is
333
+ * the whole of the "point the instruments at the logged-in browser" story
334
+ * at the call site:
335
+ *
336
+ * const { client, sessionId } = await connectBridge({ token });
337
+ * const vitals = await collectVitals(client, sessionId);
338
+ *
339
+ * `enableDomains` is on by default for parity with connectToTarget. Turn it
340
+ * off for a caller that wants to choose its own domains (a bridge attach is
341
+ * visible to the user, so a lighter footprint is sometimes the point).
342
+ */
343
+ export async function connectBridge(
344
+ opts: BridgeTransportOptions & { enableDomains?: boolean } = {},
345
+ ): Promise<BridgeConnection> {
346
+ const transport = new BridgeTransport(opts);
347
+ const client = new CdpClient();
348
+ await client.connectTransport(transport);
349
+ const sessionId = transport.sessionId;
350
+ if (opts.enableDomains !== false) {
351
+ await enableSessionDomains(client, sessionId);
352
+ }
353
+ return { client, sessionId, tabId: transport.attachedTabId, transport };
354
+ }