@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,198 @@
1
+ /**
2
+ * The transport seam under CdpClient.
3
+ *
4
+ * Two halves: WebSocketTransport must behave exactly as the inlined `ws`
5
+ * handling it replaced (that is what keeps the 474 existing tests honest),
6
+ * and CdpClient must drive *any* transport — the whole point of the seam,
7
+ * since the bridge-backed one is not a socket to a local Chrome at all.
8
+ */
9
+ import { describe, expect, it, vi } from 'vitest';
10
+
11
+ // --- fake `ws` -------------------------------------------------------------
12
+ let lastSocket: FakeWs | null = null;
13
+
14
+ class FakeWs {
15
+ handlers = new Map<string, Array<(...a: unknown[]) => void>>();
16
+ sent: string[] = [];
17
+ closed = false;
18
+ sendError: Error | null = null;
19
+
20
+ constructor(public url: string) {
21
+ lastSocket = this;
22
+ }
23
+
24
+ on(event: string, fn: (...a: unknown[]) => void): void {
25
+ if (!this.handlers.has(event)) this.handlers.set(event, []);
26
+ this.handlers.get(event)!.push(fn);
27
+ }
28
+
29
+ emit(event: string, ...args: unknown[]): void {
30
+ for (const fn of [...(this.handlers.get(event) ?? [])]) fn(...args);
31
+ }
32
+
33
+ send(data: string, cb?: (err?: Error) => void): void {
34
+ this.sent.push(data);
35
+ cb?.(this.sendError ?? undefined);
36
+ }
37
+
38
+ close(): void {
39
+ this.closed = true;
40
+ }
41
+ }
42
+
43
+ vi.mock('ws', () => ({ WebSocket: FakeWs }));
44
+
45
+ const { WebSocketTransport } = await import('../browser/transport.js');
46
+ const { CdpClient } = await import('../browser/cdp.js');
47
+
48
+ import type { CdpTransport, CdpTransportHandlers } from '../browser/transport.js';
49
+
50
+ /** Collects what a transport hands back up to its owner. */
51
+ function recorder(): CdpTransportHandlers & {
52
+ messages: string[];
53
+ closes: Array<Error | undefined>;
54
+ } {
55
+ const messages: string[] = [];
56
+ const closes: Array<Error | undefined> = [];
57
+ const errors: Error[] = [];
58
+ return {
59
+ messages,
60
+ closes,
61
+ message: (d: string) => void messages.push(d),
62
+ close: (e?: Error) => void closes.push(e),
63
+ error: (e: Error) => void errors.push(e),
64
+ };
65
+ }
66
+
67
+ describe('WebSocketTransport', () => {
68
+ it('opens on the socket "open" event and reports the url it dialed', async () => {
69
+ const t = new WebSocketTransport('ws://127.0.0.1:9222/devtools/page/ABC');
70
+ const p = t.open(recorder());
71
+ lastSocket!.emit('open');
72
+ await p;
73
+ expect(lastSocket!.url).toBe('ws://127.0.0.1:9222/devtools/page/ABC');
74
+ });
75
+
76
+ it('rejects open() when the socket errors before it ever opened', async () => {
77
+ const t = new WebSocketTransport('ws://127.0.0.1:1/devtools');
78
+ const p = t.open(recorder());
79
+ lastSocket!.emit('error', new Error('ECONNREFUSED'));
80
+ await expect(p).rejects.toThrow('ECONNREFUSED');
81
+ });
82
+
83
+ it('routes a post-open error to error(), not to a rejected open()', async () => {
84
+ const h = recorder();
85
+ const errors: Error[] = [];
86
+ h.error = (e: Error) => void errors.push(e);
87
+ const t = new WebSocketTransport('ws://x');
88
+ const p = t.open(h);
89
+ lastSocket!.emit('open');
90
+ await p;
91
+ lastSocket!.emit('error', new Error('EPIPE'));
92
+ expect(errors.map((e) => e.message)).toEqual(['EPIPE']);
93
+ });
94
+
95
+ it('delivers messages as strings and reports close', async () => {
96
+ const h = recorder();
97
+ const t = new WebSocketTransport('ws://x');
98
+ const p = t.open(h);
99
+ lastSocket!.emit('open');
100
+ await p;
101
+ lastSocket!.emit('message', Buffer.from('{"id":1}'));
102
+ expect(h.messages).toEqual(['{"id":1}']);
103
+ lastSocket!.emit('close');
104
+ expect(h.closes).toHaveLength(1);
105
+ });
106
+
107
+ it('rejects send() with the socket error and closes the socket on close()', async () => {
108
+ const t = new WebSocketTransport('ws://x');
109
+ const p = t.open(recorder());
110
+ lastSocket!.emit('open');
111
+ await p;
112
+ await expect(t.send('{"id":1}')).resolves.toBeUndefined();
113
+ expect(lastSocket!.sent).toEqual(['{"id":1}']);
114
+ lastSocket!.sendError = new Error('socket gone');
115
+ await expect(t.send('{"id":2}')).rejects.toThrow('socket gone');
116
+ t.close();
117
+ expect(lastSocket!.closed).toBe(true);
118
+ });
119
+ });
120
+
121
+ // --- CdpClient over an arbitrary transport ---------------------------------
122
+
123
+ /** A transport with no socket at all: the test *is* the other end. */
124
+ class ScriptedTransport implements CdpTransport {
125
+ sent: string[] = [];
126
+ closed = false;
127
+ private handlers: CdpTransportHandlers | null = null;
128
+
129
+ async open(handlers: CdpTransportHandlers): Promise<void> {
130
+ this.handlers = handlers;
131
+ }
132
+
133
+ async send(frame: string): Promise<void> {
134
+ this.sent.push(frame);
135
+ }
136
+
137
+ close(): void {
138
+ this.closed = true;
139
+ }
140
+
141
+ /** Push a frame up as if it had arrived from the other side. */
142
+ deliver(msg: unknown): void {
143
+ this.handlers!.message(JSON.stringify(msg));
144
+ }
145
+
146
+ drop(err?: Error): void {
147
+ this.handlers!.close(err);
148
+ }
149
+ }
150
+
151
+ describe('CdpClient.connectTransport', () => {
152
+ it('round-trips a command through a transport that is not a socket', async () => {
153
+ const t = new ScriptedTransport();
154
+ const client = new CdpClient();
155
+ await client.connectTransport(t);
156
+ expect(client.isConnected()).toBe(true);
157
+
158
+ const pending = client.send<{ frameId: string }>('Page.navigate', { url: 'https://x.test' });
159
+ const sent = JSON.parse(t.sent[0]);
160
+ expect(sent.method).toBe('Page.navigate');
161
+ expect(sent.params).toEqual({ url: 'https://x.test' });
162
+
163
+ t.deliver({ id: sent.id, result: { frameId: 'F1' } });
164
+ expect(await pending).toEqual({ frameId: 'F1' });
165
+ });
166
+
167
+ it('dispatches events with their sessionId to listeners', async () => {
168
+ const t = new ScriptedTransport();
169
+ const client = new CdpClient();
170
+ await client.connectTransport(t);
171
+
172
+ const seen: Array<[Record<string, unknown>, string | undefined]> = [];
173
+ client.on('Network.responseReceived', (params, sid) => void seen.push([params, sid]));
174
+ t.deliver({ method: 'Network.responseReceived', params: { requestId: 'R1' }, sessionId: 'S1' });
175
+
176
+ expect(seen).toEqual([[{ requestId: 'R1' }, 'S1']]);
177
+ });
178
+
179
+ it('flushes in-flight commands when the transport drops', async () => {
180
+ const t = new ScriptedTransport();
181
+ const client = new CdpClient();
182
+ await client.connectTransport(t);
183
+
184
+ const pending = client.send('Page.enable');
185
+ t.drop(new Error('bridge disconnected'));
186
+ await expect(pending).rejects.toThrow('bridge disconnected');
187
+ expect(client.isConnected()).toBe(false);
188
+ });
189
+
190
+ it('closes the transport when the client closes', async () => {
191
+ const t = new ScriptedTransport();
192
+ const client = new CdpClient();
193
+ await client.connectTransport(t);
194
+ client.close();
195
+ expect(t.closed).toBe(true);
196
+ await expect(client.send('Page.enable')).rejects.toThrow('CDP not connected');
197
+ });
198
+ });
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Regression cover for the 0-byte heap snapshot.
3
+ *
4
+ * Chrome emits `HeapProfiler.reportHeapSnapshotProgress` with finished:true
5
+ * BEFORE it streams a single `addHeapSnapshotChunk` (verified against Chrome
6
+ * 153). startHeapSnapshot used to resolve on that progress event, detach its
7
+ * chunk listener and write an empty file every time. The stub below replays
8
+ * that exact ordering, so a regression fails here rather than in a profiling
9
+ * session nobody notices until DevTools refuses to load the snapshot.
10
+ */
11
+ import { mkdtemp, readFile, rm } from 'node:fs/promises';
12
+ import { tmpdir } from 'node:os';
13
+ import { join } from 'node:path';
14
+ import { afterEach, describe, expect, it, vi } from 'vitest';
15
+ import type { CdpClient } from '../browser/cdp.js';
16
+ import { startHeapSnapshot } from '../browser/profiler.js';
17
+
18
+ type Listener = (params: Record<string, unknown>, sessionId?: string) => void;
19
+
20
+ interface StubOptions {
21
+ /** Chunks Chrome streams, in order. */
22
+ chunks?: string[];
23
+ /** sessionId Chrome tags the chunk events with (defaults to the caller's). */
24
+ chunkSessionId?: string;
25
+ /** Emit the misleading finished:true progress event before the chunks. */
26
+ emitProgressFirst?: boolean;
27
+ }
28
+
29
+ /**
30
+ * A CdpClient that replays Chrome's real takeHeapSnapshot sequence: progress
31
+ * events first, then the chunks, and only then the command response.
32
+ */
33
+ function heapClient(sessionId: string, options: StubOptions = {}) {
34
+ const { chunks = ['{"snapshot":', '{"node_count":2}', '}'], emitProgressFirst = true } = options;
35
+ const listeners = new Map<string, Set<Listener>>();
36
+ const calls: string[] = [];
37
+
38
+ const emit = (event: string, params: Record<string, unknown>, sid?: string) => {
39
+ for (const fn of listeners.get(event) ?? []) fn(params, sid);
40
+ };
41
+
42
+ // Chrome delivers each of these in its own WebSocket frame, so the stub
43
+ // yields between them. Emitting synchronously would paper over the very
44
+ // bug this file exists to catch: the buggy implementation resolved on the
45
+ // progress event, and only lost the chunks because they arrived a turn
46
+ // later, after its `finally` had detached the listener.
47
+ const tick = () => new Promise<void>((r) => setTimeout(r, 0));
48
+
49
+ const client = {
50
+ on: vi.fn((event: string, fn: Listener) => {
51
+ if (!listeners.has(event)) listeners.set(event, new Set());
52
+ listeners.get(event)!.add(fn);
53
+ return () => listeners.get(event)?.delete(fn);
54
+ }),
55
+ send: vi.fn(async (method: string, _params?: unknown, _sid?: string) => {
56
+ calls.push(method);
57
+ if (method === 'HeapProfiler.takeHeapSnapshot') {
58
+ if (emitProgressFirst) {
59
+ emit(
60
+ 'HeapProfiler.reportHeapSnapshotProgress',
61
+ { done: 1, total: 1, finished: true },
62
+ sessionId,
63
+ );
64
+ await tick();
65
+ }
66
+ for (const chunk of chunks) {
67
+ emit('HeapProfiler.addHeapSnapshotChunk', { chunk }, options.chunkSessionId ?? sessionId);
68
+ await tick();
69
+ }
70
+ }
71
+ return {};
72
+ }),
73
+ } as unknown as CdpClient;
74
+
75
+ return { client, calls, listeners };
76
+ }
77
+
78
+ const dirs: string[] = [];
79
+ async function outPath(name = 'heap.heapsnapshot'): Promise<string> {
80
+ const dir = await mkdtemp(join(tmpdir(), 'monobrowse-heap-'));
81
+ dirs.push(dir);
82
+ return join(dir, name);
83
+ }
84
+
85
+ afterEach(async () => {
86
+ await Promise.all(dirs.splice(0).map((d) => rm(d, { recursive: true, force: true })));
87
+ });
88
+
89
+ let seq = 0;
90
+ const nextSession = () => `sid-${++seq}`;
91
+
92
+ describe('startHeapSnapshot', () => {
93
+ it('writes every streamed chunk, not an empty file', async () => {
94
+ const path = await outPath();
95
+ const sid = nextSession();
96
+ const { client } = heapClient(sid);
97
+
98
+ const written = await startHeapSnapshot(client, sid, path);
99
+
100
+ expect(written).toBe(path);
101
+ const contents = await readFile(path, 'utf8');
102
+ expect(contents).toBe('{"snapshot":{"node_count":2}}');
103
+ expect(contents.length).toBeGreaterThan(0);
104
+ });
105
+
106
+ it('does not resolve on the finished progress event that precedes the chunks', async () => {
107
+ // The regression: with emitProgressFirst the old implementation resolved
108
+ // before any chunk arrived. Ordering is the whole point of this case.
109
+ const path = await outPath();
110
+ const sid = nextSession();
111
+ const { client } = heapClient(sid, {
112
+ emitProgressFirst: true,
113
+ chunks: ['a'.repeat(64), 'b'.repeat(64)],
114
+ });
115
+
116
+ await startHeapSnapshot(client, sid, path);
117
+
118
+ expect((await readFile(path, 'utf8')).length).toBe(128);
119
+ });
120
+
121
+ it('works when Chrome sends no progress events at all', async () => {
122
+ const path = await outPath();
123
+ const sid = nextSession();
124
+ const { client } = heapClient(sid, { emitProgressFirst: false, chunks: ['{}'] });
125
+ await expect(startHeapSnapshot(client, sid, path)).resolves.toBe(path);
126
+ await expect(readFile(path, 'utf8')).resolves.toBe('{}');
127
+ });
128
+
129
+ it('asks Chrome not to report progress, since nothing consumes it', async () => {
130
+ const path = await outPath();
131
+ const sid = nextSession();
132
+ const { client } = heapClient(sid);
133
+ await startHeapSnapshot(client, sid, path);
134
+ const take = (client.send as ReturnType<typeof vi.fn>).mock.calls.find(
135
+ (c) => c[0] === 'HeapProfiler.takeHeapSnapshot',
136
+ );
137
+ expect(take?.[1]).toEqual({ reportProgress: false });
138
+ });
139
+
140
+ it('enables and disables the heap profiler around the capture', async () => {
141
+ const path = await outPath();
142
+ const sid = nextSession();
143
+ const { client, calls } = heapClient(sid);
144
+ await startHeapSnapshot(client, sid, path);
145
+ expect(calls).toEqual([
146
+ 'HeapProfiler.enable',
147
+ 'HeapProfiler.takeHeapSnapshot',
148
+ 'HeapProfiler.disable',
149
+ ]);
150
+ });
151
+
152
+ it('ignores chunks belonging to another session', async () => {
153
+ const path = await outPath();
154
+ const sid = nextSession();
155
+ const { client } = heapClient(sid, { chunkSessionId: 'other-session' });
156
+ await expect(startHeapSnapshot(client, sid, path)).rejects.toThrow(/no data/i);
157
+ });
158
+
159
+ it('fails loudly instead of writing a 0-byte snapshot', async () => {
160
+ const path = await outPath();
161
+ const sid = nextSession();
162
+ const { client } = heapClient(sid, { chunks: [] });
163
+ await expect(startHeapSnapshot(client, sid, path)).rejects.toThrow(/produced no data/);
164
+ await expect(readFile(path, 'utf8')).rejects.toThrow();
165
+ });
166
+
167
+ it('detaches its chunk listener once the snapshot is done', async () => {
168
+ const path = await outPath();
169
+ const sid = nextSession();
170
+ const { client, listeners } = heapClient(sid);
171
+ await startHeapSnapshot(client, sid, path);
172
+ expect(listeners.get('HeapProfiler.addHeapSnapshotChunk')?.size ?? 0).toBe(0);
173
+ });
174
+
175
+ it('releases the in-progress guard after a failure, so a retry is possible', async () => {
176
+ const failing = {
177
+ on: vi.fn(() => () => {}),
178
+ send: vi.fn(async (method: string) => {
179
+ if (method === 'HeapProfiler.takeHeapSnapshot') throw new Error('target crashed');
180
+ return {};
181
+ }),
182
+ } as unknown as CdpClient;
183
+
184
+ const sid = nextSession();
185
+ await expect(startHeapSnapshot(failing, sid)).rejects.toThrow('target crashed');
186
+
187
+ const path = await outPath();
188
+ const { client } = heapClient(sid);
189
+ await expect(startHeapSnapshot(client, sid, path)).resolves.toBe(path);
190
+ });
191
+ });
@@ -0,0 +1,180 @@
1
+ /**
2
+ * The a11y rules are pure functions over a CDP accessibility tree, so the
3
+ * fixtures here are hand-written AX node arrays — no Chrome involved.
4
+ */
5
+ import { describe, expect, it } from 'vitest';
6
+ import {
7
+ countA11y,
8
+ findFocusOrderIssues,
9
+ findHeadingIssues,
10
+ findNamingIssues,
11
+ orderNodes,
12
+ runA11yRules,
13
+ } from '../report/a11y.js';
14
+ import type { AxNode, FocusCandidate } from '../report/types.js';
15
+
16
+ function node(partial: Partial<AxNode> & { nodeId: string | number }): AxNode {
17
+ return partial as AxNode;
18
+ }
19
+
20
+ function ax(nodeId: string | number, role: string, name = '', extra: Partial<AxNode> = {}): AxNode {
21
+ return node({ nodeId, role: { value: role }, name: { value: name }, ...extra });
22
+ }
23
+
24
+ function heading(nodeId: string | number, level: number, name = `h${level}`): AxNode {
25
+ return node({
26
+ nodeId,
27
+ role: { value: 'heading' },
28
+ name: { value: name },
29
+ properties: [{ name: 'level', value: { value: level } }],
30
+ });
31
+ }
32
+
33
+ describe('findNamingIssues', () => {
34
+ it('flags a button with no accessible name', () => {
35
+ const findings = findNamingIssues([ax(1, 'button', '')]);
36
+ expect(findings).toHaveLength(1);
37
+ expect(findings[0]).toMatchObject({
38
+ rule: 'unlabelled-control',
39
+ impact: 'error',
40
+ role: 'button',
41
+ name: null,
42
+ locator: 'ax-node:1',
43
+ });
44
+ });
45
+
46
+ it('leaves named controls alone, including whitespace-trimmed names', () => {
47
+ expect(findNamingIssues([ax(1, 'button', 'Save'), ax(2, 'link', 'Home')])).toEqual([]);
48
+ });
49
+
50
+ it('treats a whitespace-only name as no name', () => {
51
+ expect(findNamingIssues([ax(1, 'link', ' ')])).toHaveLength(1);
52
+ });
53
+
54
+ it('reports an unlabelled form field under its own rule, not unlabelled-control', () => {
55
+ const findings = findNamingIssues([ax(1, 'textbox', '')]);
56
+ expect(findings[0].rule).toBe('form-field-no-label');
57
+ expect(findings[0].impact).toBe('error');
58
+ });
59
+
60
+ it('flags an image with no alt text', () => {
61
+ const findings = findNamingIssues([ax(1, 'image', '')]);
62
+ expect(findings[0].rule).toBe('image-missing-alt');
63
+ });
64
+
65
+ it('does not flag a decorative image, which the AX tree marks ignored', () => {
66
+ expect(findNamingIssues([ax(1, 'image', '', { ignored: true })])).toEqual([]);
67
+ });
68
+
69
+ it('uses a resolved CSS selector as the locator when one is supplied', () => {
70
+ const locators = new Map([[42, '#submit']]);
71
+ const findings = findNamingIssues([ax(1, 'button', '', { backendDOMNodeId: 42 })], locators);
72
+ expect(findings[0].locator).toBe('#submit');
73
+ });
74
+
75
+ it('ignores roles that carry no naming requirement', () => {
76
+ expect(findNamingIssues([ax(1, 'paragraph', ''), ax(2, 'generic', '')])).toEqual([]);
77
+ });
78
+ });
79
+
80
+ describe('findHeadingIssues', () => {
81
+ it('flags a skipped heading level', () => {
82
+ const findings = findHeadingIssues([heading(1, 1), heading(2, 3)]);
83
+ expect(findings).toHaveLength(1);
84
+ expect(findings[0].rule).toBe('heading-order-jump');
85
+ expect(findings[0].impact).toBe('warning');
86
+ expect(findings[0].detail).toContain('h1 -> h3');
87
+ });
88
+
89
+ it('accepts a well-formed outline, including going back up a level', () => {
90
+ expect(findHeadingIssues([heading(1, 1), heading(2, 2), heading(3, 3), heading(4, 2)])).toEqual(
91
+ [],
92
+ );
93
+ });
94
+
95
+ it('flags a document whose first heading is not h1', () => {
96
+ const findings = findHeadingIssues([heading(1, 2)]);
97
+ expect(findings[0].detail).toContain('starts at h2');
98
+ });
99
+
100
+ it('reads levels in document order, not array order', () => {
101
+ // Array order says h1, h3, h2 (a jump); tree order says h1, h2, h3 (fine).
102
+ const nodes = [
103
+ node({ nodeId: 'root', role: { value: 'main' }, childIds: ['a', 'c', 'b'] }),
104
+ { ...heading('b', 3), parentId: 'root' },
105
+ { ...heading('a', 1), parentId: 'root' },
106
+ { ...heading('c', 2), parentId: 'root' },
107
+ ];
108
+ expect(findHeadingIssues(nodes)).toEqual([]);
109
+ });
110
+
111
+ it('skips headings with no usable level property', () => {
112
+ expect(findHeadingIssues([ax(1, 'heading', 'Untitled')])).toEqual([]);
113
+ });
114
+ });
115
+
116
+ describe('orderNodes', () => {
117
+ it('walks childIds depth-first and keeps unreachable nodes rather than dropping them', () => {
118
+ const nodes = [
119
+ node({ nodeId: 'r', childIds: ['x'] }),
120
+ node({ nodeId: 'x', parentId: 'r', childIds: ['y'] }),
121
+ node({ nodeId: 'y', parentId: 'x' }),
122
+ node({ nodeId: 'orphan', parentId: 'gone' }),
123
+ ];
124
+ expect(orderNodes(nodes).map((n) => n.nodeId)).toEqual(['r', 'x', 'y', 'orphan']);
125
+ });
126
+
127
+ it('does not loop forever on a cyclic tree', () => {
128
+ const nodes = [
129
+ node({ nodeId: 'a', childIds: ['b'] }),
130
+ node({ nodeId: 'b', parentId: 'a', childIds: ['a'] }),
131
+ ];
132
+ expect(orderNodes(nodes)).toHaveLength(2);
133
+ });
134
+ });
135
+
136
+ describe('findFocusOrderIssues', () => {
137
+ const candidate = (partial: Partial<FocusCandidate>): FocusCandidate => ({
138
+ tag: 'button',
139
+ role: '',
140
+ name: 'Next',
141
+ tabindex: -1,
142
+ locator: '#next',
143
+ ...partial,
144
+ });
145
+
146
+ it('flags a natively focusable element taken out of the tab order', () => {
147
+ const findings = findFocusOrderIssues([candidate({})]);
148
+ expect(findings).toHaveLength(1);
149
+ expect(findings[0]).toMatchObject({ rule: 'negative-tabindex', impact: 'warning' });
150
+ expect(findings[0].detail).toContain('tabindex="-1"');
151
+ });
152
+
153
+ it('ignores non-negative tabindex', () => {
154
+ expect(findFocusOrderIssues([candidate({ tabindex: 0 })])).toEqual([]);
155
+ });
156
+
157
+ it('ignores a plain container that is merely script-focusable', () => {
158
+ expect(findFocusOrderIssues([candidate({ tag: 'div', role: '' })])).toEqual([]);
159
+ });
160
+
161
+ it('flags a div that claims an interactive role', () => {
162
+ expect(findFocusOrderIssues([candidate({ tag: 'div', role: 'button' })])).toHaveLength(1);
163
+ });
164
+ });
165
+
166
+ describe('runA11yRules', () => {
167
+ it('combines every rule and counts errors separately from warnings', () => {
168
+ const findings = runA11yRules({
169
+ nodes: [ax(1, 'button', ''), ax(2, 'image', ''), heading(3, 1), heading(4, 4)],
170
+ focusCandidates: [{ tag: 'a', role: '', name: 'Skip', tabindex: -1, locator: '#skip' }],
171
+ });
172
+ expect(findings.map((f) => f.rule).sort()).toEqual([
173
+ 'heading-order-jump',
174
+ 'image-missing-alt',
175
+ 'negative-tabindex',
176
+ 'unlabelled-control',
177
+ ]);
178
+ expect(countA11y(findings)).toEqual({ errors: 2, warnings: 2 });
179
+ });
180
+ });