@monoes/monobrowse 1.0.6 → 1.0.7

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 (248) hide show
  1. package/README.md +68 -0
  2. package/dist/src/__tests__/adapters.test.d.ts +2 -0
  3. package/dist/src/__tests__/adapters.test.d.ts.map +1 -0
  4. package/dist/src/__tests__/adapters.test.js +104 -0
  5. package/dist/src/__tests__/adapters.test.js.map +1 -0
  6. package/dist/src/__tests__/batch-eval-parsing.test.d.ts +0 -0
  7. package/dist/src/__tests__/batch-eval-parsing.test.d.ts.map +0 -0
  8. package/dist/src/__tests__/batch-eval-parsing.test.js +0 -0
  9. package/dist/src/__tests__/batch-eval-parsing.test.js.map +0 -0
  10. package/dist/src/__tests__/box-model.test.d.ts +0 -0
  11. package/dist/src/__tests__/box-model.test.d.ts.map +0 -0
  12. package/dist/src/__tests__/box-model.test.js +0 -0
  13. package/dist/src/__tests__/box-model.test.js.map +0 -0
  14. package/dist/src/__tests__/browser-launch.test.d.ts +0 -0
  15. package/dist/src/__tests__/browser-launch.test.d.ts.map +0 -0
  16. package/dist/src/__tests__/browser-launch.test.js +0 -0
  17. package/dist/src/__tests__/browser-launch.test.js.map +0 -0
  18. package/dist/src/__tests__/cdp-client.test.d.ts +2 -0
  19. package/dist/src/__tests__/cdp-client.test.d.ts.map +1 -0
  20. package/dist/src/__tests__/cdp-client.test.js +326 -0
  21. package/dist/src/__tests__/cdp-client.test.js.map +1 -0
  22. package/dist/src/__tests__/close-browser.test.d.ts +2 -0
  23. package/dist/src/__tests__/close-browser.test.d.ts.map +1 -0
  24. package/dist/src/__tests__/close-browser.test.js +128 -0
  25. package/dist/src/__tests__/close-browser.test.js.map +1 -0
  26. package/dist/src/__tests__/console-log.test.d.ts +2 -0
  27. package/dist/src/__tests__/console-log.test.d.ts.map +1 -0
  28. package/dist/src/__tests__/console-log.test.js +195 -0
  29. package/dist/src/__tests__/console-log.test.js.map +1 -0
  30. package/dist/src/__tests__/find.test.d.ts +2 -0
  31. package/dist/src/__tests__/find.test.d.ts.map +1 -0
  32. package/dist/src/__tests__/find.test.js +299 -0
  33. package/dist/src/__tests__/find.test.js.map +1 -0
  34. package/dist/src/__tests__/frame-command.test.d.ts +2 -0
  35. package/dist/src/__tests__/frame-command.test.d.ts.map +1 -0
  36. package/dist/src/__tests__/frame-command.test.js +85 -0
  37. package/dist/src/__tests__/frame-command.test.js.map +1 -0
  38. package/dist/src/__tests__/output.test.d.ts +0 -0
  39. package/dist/src/__tests__/output.test.d.ts.map +0 -0
  40. package/dist/src/__tests__/output.test.js +0 -0
  41. package/dist/src/__tests__/output.test.js.map +0 -0
  42. package/dist/src/__tests__/ref-cache.test.d.ts +0 -0
  43. package/dist/src/__tests__/ref-cache.test.d.ts.map +0 -0
  44. package/dist/src/__tests__/ref-cache.test.js +43 -2
  45. package/dist/src/__tests__/ref-cache.test.js.map +1 -1
  46. package/dist/src/__tests__/session-state.test.d.ts +2 -0
  47. package/dist/src/__tests__/session-state.test.d.ts.map +1 -0
  48. package/dist/src/__tests__/session-state.test.js +203 -0
  49. package/dist/src/__tests__/session-state.test.js.map +1 -0
  50. package/dist/src/__tests__/snapshot.test.d.ts +2 -0
  51. package/dist/src/__tests__/snapshot.test.d.ts.map +1 -0
  52. package/dist/src/__tests__/snapshot.test.js +262 -0
  53. package/dist/src/__tests__/snapshot.test.js.map +1 -0
  54. package/dist/src/__tests__/storage-eval.test.d.ts +2 -0
  55. package/dist/src/__tests__/storage-eval.test.d.ts.map +1 -0
  56. package/dist/src/__tests__/storage-eval.test.js +133 -0
  57. package/dist/src/__tests__/storage-eval.test.js.map +1 -0
  58. package/dist/src/__tests__/tabs.test.d.ts +2 -0
  59. package/dist/src/__tests__/tabs.test.d.ts.map +1 -0
  60. package/dist/src/__tests__/tabs.test.js +161 -0
  61. package/dist/src/__tests__/tabs.test.js.map +1 -0
  62. package/dist/src/browser/action-builder/analyzer.d.ts +0 -0
  63. package/dist/src/browser/action-builder/analyzer.d.ts.map +0 -0
  64. package/dist/src/browser/action-builder/analyzer.js +0 -0
  65. package/dist/src/browser/action-builder/analyzer.js.map +0 -0
  66. package/dist/src/browser/action-builder/types.d.ts +0 -0
  67. package/dist/src/browser/action-builder/types.d.ts.map +0 -0
  68. package/dist/src/browser/action-builder/types.js +0 -0
  69. package/dist/src/browser/action-builder/types.js.map +0 -0
  70. package/dist/src/browser/actions.d.ts +0 -0
  71. package/dist/src/browser/actions.d.ts.map +0 -0
  72. package/dist/src/browser/actions.js +0 -0
  73. package/dist/src/browser/actions.js.map +0 -0
  74. package/dist/src/browser/adapters/gemini.d.ts +0 -0
  75. package/dist/src/browser/adapters/gemini.d.ts.map +0 -0
  76. package/dist/src/browser/adapters/gemini.js +0 -0
  77. package/dist/src/browser/adapters/gemini.js.map +0 -0
  78. package/dist/src/browser/adapters/google.d.ts +0 -0
  79. package/dist/src/browser/adapters/google.d.ts.map +0 -0
  80. package/dist/src/browser/adapters/google.js +0 -0
  81. package/dist/src/browser/adapters/google.js.map +0 -0
  82. package/dist/src/browser/adapters/index.d.ts +0 -0
  83. package/dist/src/browser/adapters/index.d.ts.map +0 -0
  84. package/dist/src/browser/adapters/index.js +0 -0
  85. package/dist/src/browser/adapters/index.js.map +0 -0
  86. package/dist/src/browser/adapters/instagram.d.ts +0 -0
  87. package/dist/src/browser/adapters/instagram.d.ts.map +0 -0
  88. package/dist/src/browser/adapters/instagram.js +0 -0
  89. package/dist/src/browser/adapters/instagram.js.map +0 -0
  90. package/dist/src/browser/adapters/linkedin.d.ts +0 -0
  91. package/dist/src/browser/adapters/linkedin.d.ts.map +0 -0
  92. package/dist/src/browser/adapters/linkedin.js +0 -0
  93. package/dist/src/browser/adapters/linkedin.js.map +0 -0
  94. package/dist/src/browser/adapters/microsoft.d.ts +0 -0
  95. package/dist/src/browser/adapters/microsoft.d.ts.map +0 -0
  96. package/dist/src/browser/adapters/microsoft.js +0 -0
  97. package/dist/src/browser/adapters/microsoft.js.map +0 -0
  98. package/dist/src/browser/adapters/x.d.ts +0 -0
  99. package/dist/src/browser/adapters/x.d.ts.map +0 -0
  100. package/dist/src/browser/adapters/x.js +0 -0
  101. package/dist/src/browser/adapters/x.js.map +0 -0
  102. package/dist/src/browser/batch.d.ts +0 -0
  103. package/dist/src/browser/batch.d.ts.map +0 -0
  104. package/dist/src/browser/batch.js +0 -0
  105. package/dist/src/browser/batch.js.map +0 -0
  106. package/dist/src/browser/browser.d.ts +4 -0
  107. package/dist/src/browser/browser.d.ts.map +1 -1
  108. package/dist/src/browser/browser.js +55 -7
  109. package/dist/src/browser/browser.js.map +1 -1
  110. package/dist/src/browser/cdp.d.ts +4 -1
  111. package/dist/src/browser/cdp.d.ts.map +1 -1
  112. package/dist/src/browser/cdp.js +45 -16
  113. package/dist/src/browser/cdp.js.map +1 -1
  114. package/dist/src/browser/console-log.d.ts +0 -0
  115. package/dist/src/browser/console-log.d.ts.map +0 -0
  116. package/dist/src/browser/console-log.js +0 -0
  117. package/dist/src/browser/console-log.js.map +0 -0
  118. package/dist/src/browser/dashboard/api-types.d.ts +0 -0
  119. package/dist/src/browser/dashboard/api-types.d.ts.map +0 -0
  120. package/dist/src/browser/dashboard/api-types.js +0 -0
  121. package/dist/src/browser/dashboard/api-types.js.map +0 -0
  122. package/dist/src/browser/dashboard/server.d.ts +0 -0
  123. package/dist/src/browser/dashboard/server.d.ts.map +0 -0
  124. package/dist/src/browser/dashboard/server.js +0 -0
  125. package/dist/src/browser/dashboard/server.js.map +0 -0
  126. package/dist/src/browser/dialog.d.ts +0 -0
  127. package/dist/src/browser/dialog.d.ts.map +0 -0
  128. package/dist/src/browser/dialog.js +0 -0
  129. package/dist/src/browser/dialog.js.map +0 -0
  130. package/dist/src/browser/emulation.d.ts +0 -0
  131. package/dist/src/browser/emulation.d.ts.map +0 -0
  132. package/dist/src/browser/emulation.js +0 -0
  133. package/dist/src/browser/emulation.js.map +0 -0
  134. package/dist/src/browser/find.d.ts +0 -0
  135. package/dist/src/browser/find.d.ts.map +0 -0
  136. package/dist/src/browser/find.js +0 -0
  137. package/dist/src/browser/find.js.map +0 -0
  138. package/dist/src/browser/har.d.ts +0 -0
  139. package/dist/src/browser/har.d.ts.map +0 -0
  140. package/dist/src/browser/har.js +0 -0
  141. package/dist/src/browser/har.js.map +0 -0
  142. package/dist/src/browser/index.d.ts +0 -0
  143. package/dist/src/browser/index.d.ts.map +0 -0
  144. package/dist/src/browser/index.js +0 -0
  145. package/dist/src/browser/index.js.map +0 -0
  146. package/dist/src/browser/network.d.ts +0 -0
  147. package/dist/src/browser/network.d.ts.map +0 -0
  148. package/dist/src/browser/network.js +0 -0
  149. package/dist/src/browser/network.js.map +0 -0
  150. package/dist/src/browser/pdf.d.ts +0 -0
  151. package/dist/src/browser/pdf.d.ts.map +0 -0
  152. package/dist/src/browser/pdf.js +0 -0
  153. package/dist/src/browser/pdf.js.map +0 -0
  154. package/dist/src/browser/profiler.d.ts +0 -0
  155. package/dist/src/browser/profiler.d.ts.map +0 -0
  156. package/dist/src/browser/profiler.js +0 -0
  157. package/dist/src/browser/profiler.js.map +0 -0
  158. package/dist/src/browser/record.d.ts +0 -0
  159. package/dist/src/browser/record.d.ts.map +0 -0
  160. package/dist/src/browser/record.js +0 -0
  161. package/dist/src/browser/record.js.map +0 -0
  162. package/dist/src/browser/ref-cache.d.ts +6 -1
  163. package/dist/src/browser/ref-cache.d.ts.map +1 -1
  164. package/dist/src/browser/ref-cache.js +21 -3
  165. package/dist/src/browser/ref-cache.js.map +1 -1
  166. package/dist/src/browser/screenshot.d.ts +0 -0
  167. package/dist/src/browser/screenshot.d.ts.map +0 -0
  168. package/dist/src/browser/screenshot.js +0 -0
  169. package/dist/src/browser/screenshot.js.map +0 -0
  170. package/dist/src/browser/session.d.ts +0 -0
  171. package/dist/src/browser/session.d.ts.map +0 -0
  172. package/dist/src/browser/session.js +0 -0
  173. package/dist/src/browser/session.js.map +0 -0
  174. package/dist/src/browser/snapshot.d.ts +0 -0
  175. package/dist/src/browser/snapshot.d.ts.map +0 -0
  176. package/dist/src/browser/snapshot.js +0 -0
  177. package/dist/src/browser/snapshot.js.map +0 -0
  178. package/dist/src/browser/storage.d.ts +0 -0
  179. package/dist/src/browser/storage.d.ts.map +0 -0
  180. package/dist/src/browser/storage.js +0 -0
  181. package/dist/src/browser/storage.js.map +0 -0
  182. package/dist/src/browser/tabs.d.ts +5 -1
  183. package/dist/src/browser/tabs.d.ts.map +1 -1
  184. package/dist/src/browser/tabs.js +4 -3
  185. package/dist/src/browser/tabs.js.map +1 -1
  186. package/dist/src/browser/trace.d.ts +0 -0
  187. package/dist/src/browser/trace.d.ts.map +0 -0
  188. package/dist/src/browser/trace.js +0 -0
  189. package/dist/src/browser/trace.js.map +0 -0
  190. package/dist/src/browser/types.d.ts +4 -0
  191. package/dist/src/browser/types.d.ts.map +1 -1
  192. package/dist/src/browser/types.js +0 -0
  193. package/dist/src/browser/types.js.map +1 -1
  194. package/dist/src/browser/vitals.d.ts +0 -0
  195. package/dist/src/browser/vitals.d.ts.map +0 -0
  196. package/dist/src/browser/vitals.js +0 -0
  197. package/dist/src/browser/vitals.js.map +0 -0
  198. package/dist/src/browser/wait.d.ts +0 -0
  199. package/dist/src/browser/wait.d.ts.map +0 -0
  200. package/dist/src/browser/wait.js +0 -0
  201. package/dist/src/browser/wait.js.map +0 -0
  202. package/dist/src/cli/action.d.ts +0 -0
  203. package/dist/src/cli/action.d.ts.map +0 -0
  204. package/dist/src/cli/action.js +0 -0
  205. package/dist/src/cli/action.js.map +0 -0
  206. package/dist/src/cli/commands.d.ts +0 -0
  207. package/dist/src/cli/commands.d.ts.map +1 -1
  208. package/dist/src/cli/commands.js +96 -4
  209. package/dist/src/cli/commands.js.map +1 -1
  210. package/dist/src/cli/output.d.ts +0 -0
  211. package/dist/src/cli/output.d.ts.map +0 -0
  212. package/dist/src/cli/output.js +0 -0
  213. package/dist/src/cli/output.js.map +0 -0
  214. package/dist/src/cli/platform.d.ts +0 -0
  215. package/dist/src/cli/platform.d.ts.map +0 -0
  216. package/dist/src/cli/platform.js +0 -0
  217. package/dist/src/cli/platform.js.map +0 -0
  218. package/dist/src/cli/types.d.ts +0 -0
  219. package/dist/src/cli/types.d.ts.map +0 -0
  220. package/dist/src/cli/types.js +0 -0
  221. package/dist/src/cli/types.js.map +0 -0
  222. package/dist/src/cli.d.ts +0 -0
  223. package/dist/src/cli.d.ts.map +0 -0
  224. package/dist/src/cli.js +0 -0
  225. package/dist/src/cli.js.map +0 -0
  226. package/dist/src/index.d.ts +0 -0
  227. package/dist/src/index.d.ts.map +0 -0
  228. package/dist/src/index.js +0 -0
  229. package/dist/src/index.js.map +0 -0
  230. package/dist/tsconfig.tsbuildinfo +1 -1
  231. package/package.json +9 -5
  232. package/src/__tests__/adapters.test.ts +125 -0
  233. package/src/__tests__/cdp-client.test.ts +375 -0
  234. package/src/__tests__/close-browser.test.ts +141 -0
  235. package/src/__tests__/console-log.test.ts +250 -0
  236. package/src/__tests__/find.test.ts +354 -0
  237. package/src/__tests__/frame-command.test.ts +98 -0
  238. package/src/__tests__/ref-cache.test.ts +47 -2
  239. package/src/__tests__/session-state.test.ts +251 -0
  240. package/src/__tests__/snapshot.test.ts +331 -0
  241. package/src/__tests__/storage-eval.test.ts +171 -0
  242. package/src/__tests__/tabs.test.ts +183 -0
  243. package/src/browser/browser.ts +59 -7
  244. package/src/browser/cdp.ts +52 -15
  245. package/src/browser/ref-cache.ts +29 -6
  246. package/src/browser/tabs.ts +12 -4
  247. package/src/browser/types.ts +4 -0
  248. package/src/cli/commands.ts +90 -4
@@ -0,0 +1,250 @@
1
+ /**
2
+ * console-log.ts is pure in-process state: three CDP event listeners feeding
3
+ * two per-session Maps. Nothing here touches a browser — we hand it a fake
4
+ * client that just records handlers and lets us fire events by hand.
5
+ */
6
+ import { describe, it, expect, beforeEach } from 'vitest';
7
+ import type { CdpClient } from '../browser/cdp.js';
8
+ import {
9
+ setupConsoleCapture,
10
+ getConsoleMessages,
11
+ clearConsoleMessages,
12
+ getPageErrors,
13
+ clearPageErrors,
14
+ teardownConsoleCapture,
15
+ } from '../browser/console-log.js';
16
+
17
+ type Handler = (params: Record<string, unknown>, sessionId?: string) => void;
18
+
19
+ class FakeClient {
20
+ listeners = new Map<string, Set<Handler>>();
21
+ offCalls = 0;
22
+
23
+ on(event: string, fn: Handler): () => void {
24
+ if (!this.listeners.has(event)) this.listeners.set(event, new Set());
25
+ this.listeners.get(event)!.add(fn);
26
+ return () => {
27
+ this.offCalls++;
28
+ this.listeners.get(event)!.delete(fn);
29
+ };
30
+ }
31
+
32
+ emit(event: string, params: Record<string, unknown>, sessionId?: string): void {
33
+ for (const fn of [...(this.listeners.get(event) ?? [])]) fn(params, sessionId);
34
+ }
35
+
36
+ get handlerCount(): number {
37
+ return [...this.listeners.values()].reduce((n, s) => n + s.size, 0);
38
+ }
39
+
40
+ asCdp(): CdpClient {
41
+ return this as unknown as CdpClient;
42
+ }
43
+ }
44
+
45
+ let client: FakeClient;
46
+
47
+ beforeEach(() => {
48
+ client = new FakeClient();
49
+ // Wipe cross-test global state in the module's module-level Maps.
50
+ clearConsoleMessages();
51
+ clearPageErrors();
52
+ });
53
+
54
+ describe('setupConsoleCapture', () => {
55
+ it('registers exactly the three CDP listeners it needs', () => {
56
+ setupConsoleCapture(client.asCdp(), 'S1');
57
+ expect([...client.listeners.keys()].sort()).toEqual([
58
+ 'Log.entryAdded',
59
+ 'Runtime.consoleAPICalled',
60
+ 'Runtime.exceptionThrown',
61
+ ]);
62
+ expect(client.handlerCount).toBe(3);
63
+ });
64
+
65
+ it('detaches the previous generation of listeners when re-run for the same session', () => {
66
+ setupConsoleCapture(client.asCdp(), 'S1');
67
+ setupConsoleCapture(client.asCdp(), 'S1');
68
+ // Three old handlers unsubscribed, three new ones live — not six live.
69
+ expect(client.offCalls).toBe(3);
70
+ expect(client.handlerCount).toBe(3);
71
+
72
+ // And a single emitted event must be recorded once, not twice.
73
+ client.emit('Runtime.consoleAPICalled', { type: 'log', args: [{ value: 'hi' }] }, 'S1');
74
+ expect(getConsoleMessages('S1')).toHaveLength(1);
75
+ });
76
+
77
+ it('resets buffered messages for the session it re-attaches to', () => {
78
+ setupConsoleCapture(client.asCdp(), 'S1');
79
+ client.emit('Runtime.consoleAPICalled', { type: 'log', args: [{ value: 'old' }] }, 'S1');
80
+ expect(getConsoleMessages('S1')).toHaveLength(1);
81
+
82
+ setupConsoleCapture(client.asCdp(), 'S1');
83
+ expect(getConsoleMessages('S1')).toEqual([]);
84
+ });
85
+ });
86
+
87
+ describe('Runtime.consoleAPICalled capture', () => {
88
+ beforeEach(() => setupConsoleCapture(client.asCdp(), 'S1'));
89
+
90
+ it('joins multiple args with a space, preferring description over value', () => {
91
+ client.emit(
92
+ 'Runtime.consoleAPICalled',
93
+ { type: 'log', args: [{ value: 'count:' }, { value: 42 }, { description: 'Object {a: 1}' }] },
94
+ 'S1'
95
+ );
96
+ expect(getConsoleMessages('S1')[0]!.text).toBe('count: 42 Object {a: 1}');
97
+ });
98
+
99
+ it('normalizes CDP\'s "warning" to the "warn" type used by ConsoleMessage', () => {
100
+ client.emit('Runtime.consoleAPICalled', { type: 'warning', args: [{ value: 'careful' }] }, 'S1');
101
+ expect(getConsoleMessages('S1')[0]!.type).toBe('warn');
102
+ });
103
+
104
+ it('passes other CDP types through unchanged', () => {
105
+ client.emit('Runtime.consoleAPICalled', { type: 'error', args: [{ value: 'boom' }] }, 'S1');
106
+ expect(getConsoleMessages('S1')[0]!.type).toBe('error');
107
+ });
108
+
109
+ it('tolerates a missing args array', () => {
110
+ client.emit('Runtime.consoleAPICalled', { type: 'log' }, 'S1');
111
+ expect(getConsoleMessages('S1')[0]!.text).toBe('');
112
+ });
113
+
114
+ it('ignores events addressed to a different session', () => {
115
+ client.emit('Runtime.consoleAPICalled', { type: 'log', args: [{ value: 'other' }] }, 'S2');
116
+ expect(getConsoleMessages('S1')).toEqual([]);
117
+ });
118
+ });
119
+
120
+ describe('Log.entryAdded capture', () => {
121
+ beforeEach(() => setupConsoleCapture(client.asCdp(), 'S1'));
122
+
123
+ it('records text, url and lineNumber and maps "warning" to "warn"', () => {
124
+ client.emit(
125
+ 'Log.entryAdded',
126
+ { entry: { level: 'warning', text: 'deprecated API', url: 'https://x.test/a.js', lineNumber: 17 } },
127
+ 'S1'
128
+ );
129
+ const [msg] = getConsoleMessages('S1');
130
+ expect(msg).toMatchObject({
131
+ type: 'warn',
132
+ text: 'deprecated API',
133
+ url: 'https://x.test/a.js',
134
+ lineNumber: 17,
135
+ });
136
+ });
137
+
138
+ it('defaults text to empty string when the entry omits it', () => {
139
+ client.emit('Log.entryAdded', { entry: { level: 'info' } }, 'S1');
140
+ expect(getConsoleMessages('S1')[0]).toMatchObject({ type: 'info', text: '' });
141
+ });
142
+ });
143
+
144
+ describe('Runtime.exceptionThrown capture', () => {
145
+ beforeEach(() => setupConsoleCapture(client.asCdp(), 'S1'));
146
+
147
+ it('prefers exception.description over the generic text field', () => {
148
+ client.emit(
149
+ 'Runtime.exceptionThrown',
150
+ {
151
+ exceptionDetails: {
152
+ text: 'Uncaught',
153
+ exception: { description: 'TypeError: x is not a function' },
154
+ url: 'https://x.test/a.js',
155
+ lineNumber: 3,
156
+ columnNumber: 9,
157
+ },
158
+ },
159
+ 'S1'
160
+ );
161
+ expect(getPageErrors('S1')[0]).toMatchObject({
162
+ text: 'TypeError: x is not a function',
163
+ url: 'https://x.test/a.js',
164
+ lineNumber: 3,
165
+ columnNumber: 9,
166
+ });
167
+ });
168
+
169
+ it('falls back to text, then to "Unknown error"', () => {
170
+ client.emit('Runtime.exceptionThrown', { exceptionDetails: { text: 'Uncaught (in promise)' } }, 'S1');
171
+ client.emit('Runtime.exceptionThrown', { exceptionDetails: {} }, 'S1');
172
+ expect(getPageErrors('S1').map((e) => e.text)).toEqual([
173
+ 'Uncaught (in promise)',
174
+ 'Unknown error',
175
+ ]);
176
+ });
177
+
178
+ it('keeps page errors out of the console message buffer', () => {
179
+ client.emit('Runtime.exceptionThrown', { exceptionDetails: { text: 'nope' } }, 'S1');
180
+ expect(getConsoleMessages('S1')).toEqual([]);
181
+ });
182
+ });
183
+
184
+ describe('multi-session isolation and clearing', () => {
185
+ beforeEach(() => {
186
+ setupConsoleCapture(client.asCdp(), 'S1');
187
+ setupConsoleCapture(client.asCdp(), 'S2');
188
+ client.emit('Runtime.consoleAPICalled', { type: 'log', args: [{ value: 'one' }] }, 'S1');
189
+ client.emit('Runtime.consoleAPICalled', { type: 'log', args: [{ value: 'two' }] }, 'S2');
190
+ client.emit('Runtime.exceptionThrown', { exceptionDetails: { text: 'e1' } }, 'S1');
191
+ client.emit('Runtime.exceptionThrown', { exceptionDetails: { text: 'e2' } }, 'S2');
192
+ });
193
+
194
+ it('keeps each session\'s buffers separate', () => {
195
+ expect(getConsoleMessages('S1').map((m) => m.text)).toEqual(['one']);
196
+ expect(getConsoleMessages('S2').map((m) => m.text)).toEqual(['two']);
197
+ });
198
+
199
+ it('returns a flattened view across sessions when no session id is given', () => {
200
+ expect(getConsoleMessages().map((m) => m.text).sort()).toEqual(['one', 'two']);
201
+ expect(getPageErrors().map((e) => e.text).sort()).toEqual(['e1', 'e2']);
202
+ });
203
+
204
+ it('returns a copy, so mutating the result does not corrupt the buffer', () => {
205
+ const snapshot = getConsoleMessages('S1');
206
+ snapshot.push({ type: 'log', text: 'injected', timestamp: 0 });
207
+ expect(getConsoleMessages('S1')).toHaveLength(1);
208
+ });
209
+
210
+ it('clears only the named session', () => {
211
+ clearConsoleMessages('S1');
212
+ clearPageErrors('S1');
213
+ expect(getConsoleMessages('S1')).toEqual([]);
214
+ expect(getPageErrors('S1')).toEqual([]);
215
+ expect(getConsoleMessages('S2')).toHaveLength(1);
216
+ expect(getPageErrors('S2')).toHaveLength(1);
217
+ });
218
+
219
+ it('clears every session when called with no id', () => {
220
+ clearConsoleMessages();
221
+ clearPageErrors();
222
+ expect(getConsoleMessages()).toEqual([]);
223
+ expect(getPageErrors()).toEqual([]);
224
+ });
225
+
226
+ it('a cleared session keeps capturing new events', () => {
227
+ clearConsoleMessages('S1');
228
+ client.emit('Runtime.consoleAPICalled', { type: 'log', args: [{ value: 'again' }] }, 'S1');
229
+ expect(getConsoleMessages('S1').map((m) => m.text)).toEqual(['again']);
230
+ });
231
+ });
232
+
233
+ describe('teardownConsoleCapture', () => {
234
+ it('unsubscribes the listeners and drops both buffers for that session only', () => {
235
+ setupConsoleCapture(client.asCdp(), 'S1');
236
+ setupConsoleCapture(client.asCdp(), 'S2');
237
+ client.emit('Runtime.consoleAPICalled', { type: 'log', args: [{ value: 'two' }] }, 'S2');
238
+
239
+ teardownConsoleCapture('S1');
240
+
241
+ expect(client.offCalls).toBe(3);
242
+ client.emit('Runtime.consoleAPICalled', { type: 'log', args: [{ value: 'late' }] }, 'S1');
243
+ expect(getConsoleMessages('S1')).toEqual([]);
244
+ expect(getConsoleMessages('S2')).toHaveLength(1);
245
+ });
246
+
247
+ it('is a no-op for an unknown session', () => {
248
+ expect(() => teardownConsoleCapture('never-registered')).not.toThrow();
249
+ });
250
+ });
@@ -0,0 +1,354 @@
1
+ /**
2
+ * find.ts — locator semantics over the snapshot ref map.
3
+ *
4
+ * findByRole / findByText / findByLabel / findByPlaceholder never talk to the
5
+ * browser at all: they filter the in-memory ref Map. findBySelector and
6
+ * findByTestId do issue CDP commands, which a recording stub answers.
7
+ */
8
+ import { describe, it, expect, vi } from 'vitest';
9
+ import type { CdpClient } from '../browser/cdp.js';
10
+ import type { ElementRef } from '../browser/types.js';
11
+ import {
12
+ findBySelector,
13
+ findByRole,
14
+ findByText,
15
+ findByLabel,
16
+ findByPlaceholder,
17
+ findByTestId,
18
+ isEnabled,
19
+ isVisible,
20
+ isChecked,
21
+ scrollIntoView,
22
+ } from '../browser/find.js';
23
+
24
+ function ref(partial: Partial<ElementRef> & { ref: string }): ElementRef {
25
+ return { role: 'generic', name: '', nodeId: 1, ...partial };
26
+ }
27
+
28
+ /** Ref map mirroring a small page snapshot. */
29
+ function sampleRefs(): Map<string, ElementRef> {
30
+ const entries: ElementRef[] = [
31
+ ref({ ref: 'e1', role: 'button', name: 'Save changes', backendDOMNodeId: 11 }),
32
+ ref({ ref: 'e2', role: 'BUTTON', name: 'Save', backendDOMNodeId: 12 }),
33
+ ref({ ref: 'e3', role: 'link', name: 'Save as draft', backendDOMNodeId: 13 }),
34
+ ref({ ref: 'e4', role: 'textbox', name: 'Email', placeholder: 'you@example.com', backendDOMNodeId: 14 }),
35
+ ref({ ref: 'e5', role: 'textbox', name: 'Notes', placeholder: 'Optional notes', backendDOMNodeId: 15, disabled: true }),
36
+ ];
37
+ return new Map(entries.map((e) => [e.ref, e]));
38
+ }
39
+
40
+ /** Answers a per-method table and records every call. */
41
+ function stubClient(table: Record<string, unknown> = {}): {
42
+ client: CdpClient;
43
+ calls: Array<{ method: string; params: Record<string, unknown> }>;
44
+ } {
45
+ const calls: Array<{ method: string; params: Record<string, unknown> }> = [];
46
+ const client = {
47
+ send: vi.fn(async (method: string, params: Record<string, unknown>) => {
48
+ calls.push({ method, params });
49
+ const entry = table[method];
50
+ if (typeof entry === 'function') return (entry as (p: unknown) => unknown)(params);
51
+ if (entry instanceof Error) throw entry;
52
+ return entry ?? {};
53
+ }),
54
+ } as unknown as CdpClient;
55
+ return { client, calls };
56
+ }
57
+
58
+ describe('nth validation is shared by every locator', () => {
59
+ const { client } = stubClient();
60
+ const refs = sampleRefs();
61
+
62
+ it.each([
63
+ ['findBySelector', () => findBySelector(client, 'S1', refs, 'button', { nth: 0 })],
64
+ ['findByRole', () => findByRole(client, 'S1', refs, 'button', { nth: 0 })],
65
+ ['findByText', () => findByText(client, 'S1', refs, 'Save', { nth: 0 })],
66
+ ['findByPlaceholder', () => findByPlaceholder(client, 'S1', refs, 'you', { nth: -1 })],
67
+ ])('%s rejects nth below 1', async (_label, run) => {
68
+ await expect(run()).rejects.toThrow(/nth must be >= 1 \(received (0|-1)\)/);
69
+ });
70
+ });
71
+
72
+ describe('findByRole', () => {
73
+ it('matches the role case-insensitively', async () => {
74
+ const { client } = stubClient();
75
+ const found = await findByRole(client, 'S1', sampleRefs(), 'BUTTON');
76
+ expect(found?.ref).toBe('e1');
77
+ });
78
+
79
+ it('does substring name matching by default', async () => {
80
+ const { client } = stubClient();
81
+ const found = await findByRole(client, 'S1', sampleRefs(), 'button', { name: 'save cha' });
82
+ expect(found?.ref).toBe('e1');
83
+ });
84
+
85
+ it('requires a full name match under exact', async () => {
86
+ const { client } = stubClient();
87
+ expect((await findByRole(client, 'S1', sampleRefs(), 'button', { name: 'Save', exact: true }))?.ref).toBe('e2');
88
+ expect(await findByRole(client, 'S1', sampleRefs(), 'button', { name: 'Sav', exact: true })).toBeNull();
89
+ });
90
+
91
+ it('nth is 1-based over the filtered matches', async () => {
92
+ const { client } = stubClient();
93
+ const refs = sampleRefs();
94
+ expect((await findByRole(client, 'S1', refs, 'button', { nth: 1 }))?.ref).toBe('e1');
95
+ expect((await findByRole(client, 'S1', refs, 'button', { nth: 2 }))?.ref).toBe('e2');
96
+ expect(await findByRole(client, 'S1', refs, 'button', { nth: 3 })).toBeNull();
97
+ });
98
+
99
+ it('last returns the final match', async () => {
100
+ const { client } = stubClient();
101
+ expect((await findByRole(client, 'S1', sampleRefs(), 'button', { last: true }))?.ref).toBe('e2');
102
+ });
103
+
104
+ it('returns null when no ref carries that role', async () => {
105
+ const { client } = stubClient();
106
+ expect(await findByRole(client, 'S1', sampleRefs(), 'checkbox')).toBeNull();
107
+ });
108
+
109
+ it('issues no CDP command at all', async () => {
110
+ const { client, calls } = stubClient();
111
+ await findByRole(client, 'S1', sampleRefs(), 'button');
112
+ expect(calls).toEqual([]);
113
+ });
114
+ });
115
+
116
+ describe('findByText', () => {
117
+ it('matches names case-insensitively as a substring', async () => {
118
+ const { client } = stubClient();
119
+ expect((await findByText(client, 'S1', sampleRefs(), 'save as'))?.ref).toBe('e3');
120
+ });
121
+
122
+ it('spans roles — first match wins', async () => {
123
+ const { client } = stubClient();
124
+ expect((await findByText(client, 'S1', sampleRefs(), 'save'))?.ref).toBe('e1');
125
+ });
126
+
127
+ it('honours exact, nth and last', async () => {
128
+ const { client } = stubClient();
129
+ const refs = sampleRefs();
130
+ expect((await findByText(client, 'S1', refs, 'Save', { exact: true }))?.ref).toBe('e2');
131
+ expect((await findByText(client, 'S1', refs, 'save', { nth: 2 }))?.ref).toBe('e2');
132
+ expect((await findByText(client, 'S1', refs, 'save', { last: true }))?.ref).toBe('e3');
133
+ expect(await findByText(client, 'S1', refs, 'save', { nth: 9 })).toBeNull();
134
+ });
135
+
136
+ it('returns null on no match', async () => {
137
+ const { client } = stubClient();
138
+ expect(await findByText(client, 'S1', sampleRefs(), 'nonexistent')).toBeNull();
139
+ });
140
+
141
+ it('findByLabel delegates to the same matching', async () => {
142
+ const { client } = stubClient();
143
+ const refs = sampleRefs();
144
+ expect(await findByLabel(client, 'S1', refs, 'Email')).toBe(await findByText(client, 'S1', refs, 'Email'));
145
+ });
146
+ });
147
+
148
+ describe('findByPlaceholder', () => {
149
+ it('matches on the placeholder attribute, not the accessible name', async () => {
150
+ const { client } = stubClient();
151
+ expect((await findByPlaceholder(client, 'S1', sampleRefs(), 'example.com'))?.ref).toBe('e4');
152
+ });
153
+
154
+ it('treats a missing placeholder as empty rather than crashing', async () => {
155
+ const { client } = stubClient();
156
+ // e1..e3 have no placeholder; an empty needle must not match them under exact.
157
+ expect((await findByPlaceholder(client, 'S1', sampleRefs(), '', { exact: true }))?.ref).toBe('e1');
158
+ expect(await findByPlaceholder(client, 'S1', sampleRefs(), 'nope')).toBeNull();
159
+ });
160
+
161
+ it('honours exact, nth and last', async () => {
162
+ const { client } = stubClient();
163
+ const refs = sampleRefs();
164
+ expect((await findByPlaceholder(client, 'S1', refs, 'Optional notes', { exact: true }))?.ref).toBe('e5');
165
+ expect((await findByPlaceholder(client, 'S1', refs, 'o', { nth: 1 }))?.ref).toBe('e4');
166
+ expect((await findByPlaceholder(client, 'S1', refs, 'o', { last: true }))?.ref).toBe('e5');
167
+ });
168
+ });
169
+
170
+ describe('findBySelector', () => {
171
+ const doc = { root: { nodeId: 1 } };
172
+
173
+ it('reuses an existing snapshot ref when the backend node already maps to one', async () => {
174
+ const { client } = stubClient({
175
+ 'DOM.getDocument': doc,
176
+ 'DOM.querySelector': { nodeId: 55 },
177
+ 'DOM.describeNode': { node: { backendNodeId: 11 } },
178
+ });
179
+ const refs = sampleRefs();
180
+ const found = await findBySelector(client, 'S1', refs, 'button.save');
181
+ expect(found?.ref).toBe('e1');
182
+ expect(refs.size).toBe(5);
183
+ });
184
+
185
+ it('mints a synthetic ref for an element absent from the AX tree and registers it', async () => {
186
+ const { client } = stubClient({
187
+ 'DOM.getDocument': doc,
188
+ 'DOM.querySelector': { nodeId: 55 },
189
+ 'DOM.describeNode': { node: { backendNodeId: 999 } },
190
+ });
191
+ const refs = sampleRefs();
192
+ const found = await findBySelector(client, 'S1', refs, 'div.hidden');
193
+ expect(found).toMatchObject({ ref: 'sel-999', role: 'generic', name: 'div.hidden', nodeId: 55 });
194
+ expect(refs.get('sel-999')).toBe(found);
195
+ });
196
+
197
+ it('returns null when the selector matches nothing', async () => {
198
+ const { client } = stubClient({ 'DOM.getDocument': doc, 'DOM.querySelector': { nodeId: 0 } });
199
+ expect(await findBySelector(client, 'S1', sampleRefs(), '.missing')).toBeNull();
200
+ });
201
+
202
+ it('returns null when describeNode yields no backend id', async () => {
203
+ const { client } = stubClient({
204
+ 'DOM.getDocument': doc,
205
+ 'DOM.querySelector': { nodeId: 55 },
206
+ 'DOM.describeNode': { node: {} },
207
+ });
208
+ expect(await findBySelector(client, 'S1', sampleRefs(), 'div')).toBeNull();
209
+ });
210
+
211
+ it('switches to querySelectorAll for nth and picks the 1-based entry', async () => {
212
+ const { client, calls } = stubClient({
213
+ 'DOM.getDocument': doc,
214
+ 'DOM.querySelectorAll': { nodeIds: [10, 20, 30] },
215
+ 'DOM.describeNode': { node: { backendNodeId: 999 } },
216
+ });
217
+ await findBySelector(client, 'S1', sampleRefs(), 'li', { nth: 2 });
218
+ expect(calls.map((c) => c.method)).toContain('DOM.querySelectorAll');
219
+ expect(calls.find((c) => c.method === 'DOM.describeNode')!.params).toEqual({ nodeId: 20 });
220
+ });
221
+
222
+ it('last picks the final nodeId', async () => {
223
+ const { client, calls } = stubClient({
224
+ 'DOM.getDocument': doc,
225
+ 'DOM.querySelectorAll': { nodeIds: [10, 20, 30] },
226
+ 'DOM.describeNode': { node: { backendNodeId: 999 } },
227
+ });
228
+ await findBySelector(client, 'S1', sampleRefs(), 'li', { last: true });
229
+ expect(calls.find((c) => c.method === 'DOM.describeNode')!.params).toEqual({ nodeId: 30 });
230
+ });
231
+
232
+ it('returns null when querySelectorAll finds nothing', async () => {
233
+ const { client } = stubClient({
234
+ 'DOM.getDocument': doc,
235
+ 'DOM.querySelectorAll': { nodeIds: [] },
236
+ });
237
+ expect(await findBySelector(client, 'S1', sampleRefs(), 'li', { nth: 1 })).toBeNull();
238
+ });
239
+
240
+ it('rethrows a malformed-selector error instead of masking it as "not found"', async () => {
241
+ const { client } = stubClient({
242
+ 'DOM.getDocument': doc,
243
+ 'DOM.querySelector': new Error("SyntaxError: '((' is not a valid selector"),
244
+ });
245
+ await expect(findBySelector(client, 'S1', sampleRefs(), '((')).rejects.toThrow(/not a valid selector/);
246
+ });
247
+
248
+ it('swallows transient CDP errors and reports not-found', async () => {
249
+ const { client } = stubClient({
250
+ 'DOM.getDocument': doc,
251
+ 'DOM.querySelector': new Error('Node with given id does not belong to the document'),
252
+ });
253
+ expect(await findBySelector(client, 'S1', sampleRefs(), 'div')).toBeNull();
254
+ });
255
+ });
256
+
257
+ describe('findByTestId', () => {
258
+ it('tries data-testid, then data-test-id, then data-test', async () => {
259
+ const seen: string[] = [];
260
+ const client = {
261
+ send: vi.fn(async (_m: string, params: { expression: string }) => {
262
+ seen.push(params.expression);
263
+ // Only the third form exists on this page.
264
+ return { result: { value: seen.length === 3 } };
265
+ }),
266
+ } as unknown as CdpClient;
267
+ await expect(findByTestId(client, 'S1', 'submit')).resolves.toBe('[data-test="submit"]');
268
+ expect(seen).toHaveLength(3);
269
+ expect(seen.map((e) => JSON.parse(e.slice('!!document.querySelector('.length, -1)))).toEqual([
270
+ '[data-testid="submit"]',
271
+ '[data-test-id="submit"]',
272
+ '[data-test="submit"]',
273
+ ]);
274
+ });
275
+
276
+ it('returns the first attribute form that matches', async () => {
277
+ const client = {
278
+ send: vi.fn(async () => ({ result: { value: true } })),
279
+ } as unknown as CdpClient;
280
+ await expect(findByTestId(client, 'S1', 'submit')).resolves.toBe('[data-testid="submit"]');
281
+ expect(client.send).toHaveBeenCalledTimes(1);
282
+ });
283
+
284
+ it('returns null when no attribute form matches', async () => {
285
+ const client = { send: vi.fn(async () => ({ result: { value: false } })) } as unknown as CdpClient;
286
+ await expect(findByTestId(client, 'S1', 'nope')).resolves.toBeNull();
287
+ });
288
+
289
+ it('escapes quotes and backslashes so the testId cannot break out of the attribute selector', async () => {
290
+ const seen: string[] = [];
291
+ const client = {
292
+ send: vi.fn(async (_m: string, params: { expression: string }) => {
293
+ seen.push(params.expression);
294
+ return { result: { value: false } };
295
+ }),
296
+ } as unknown as CdpClient;
297
+ await findByTestId(client, 'S1', 'a"]:has(script)\\b');
298
+ // The selector is JSON-embedded, so parsing it back must recover an
299
+ // attribute selector whose value is the raw testId with escapes intact.
300
+ const literal = seen[0]!.slice('!!document.querySelector('.length, -1);
301
+ expect(JSON.parse(literal)).toBe('[data-testid="a\\"]:has(script)\\\\b"]');
302
+ });
303
+ });
304
+
305
+ describe('element state helpers', () => {
306
+ it('isEnabled reads the snapshot flag without a round trip', async () => {
307
+ const { client, calls } = stubClient();
308
+ await expect(isEnabled(client, 'S1', ref({ ref: 'e1' }))).resolves.toBe(true);
309
+ await expect(isEnabled(client, 'S1', ref({ ref: 'e5', disabled: true }))).resolves.toBe(false);
310
+ expect(calls).toEqual([]);
311
+ });
312
+
313
+ it('isVisible / isChecked report false for a ref with no backend node', async () => {
314
+ const { client, calls } = stubClient();
315
+ const stale = ref({ ref: 'gone' });
316
+ await expect(isVisible(client, 'S1', stale)).resolves.toBe(false);
317
+ await expect(isChecked(client, 'S1', stale)).resolves.toBe(false);
318
+ expect(calls).toEqual([]);
319
+ });
320
+
321
+ it('isVisible / isChecked report false when the node no longer resolves', async () => {
322
+ const { client } = stubClient({ 'DOM.resolveNode': { object: {} } });
323
+ const r = ref({ ref: 'e1', backendDOMNodeId: 11 });
324
+ await expect(isVisible(client, 'S1', r)).resolves.toBe(false);
325
+ await expect(isChecked(client, 'S1', r)).resolves.toBe(false);
326
+ });
327
+
328
+ it('isVisible returns the page-side boolean via callFunctionOn', async () => {
329
+ const { client, calls } = stubClient({
330
+ 'DOM.resolveNode': { object: { objectId: 'OBJ1' } },
331
+ 'Runtime.callFunctionOn': { result: { value: true } },
332
+ });
333
+ await expect(isVisible(client, 'S1', ref({ ref: 'e1', backendDOMNodeId: 11 }))).resolves.toBe(true);
334
+ expect(calls.find((c) => c.method === 'Runtime.callFunctionOn')!.params).toMatchObject({
335
+ objectId: 'OBJ1',
336
+ returnByValue: true,
337
+ });
338
+ });
339
+
340
+ it('isChecked coerces a missing value to false', async () => {
341
+ const { client } = stubClient({
342
+ 'DOM.resolveNode': { object: { objectId: 'OBJ1' } },
343
+ 'Runtime.callFunctionOn': { result: {} },
344
+ });
345
+ await expect(isChecked(client, 'S1', ref({ ref: 'e1', backendDOMNodeId: 11 }))).resolves.toBe(false);
346
+ });
347
+
348
+ it('scrollIntoView throws a ref-named error when the node is gone', async () => {
349
+ const { client } = stubClient({ 'DOM.resolveNode': { object: {} } });
350
+ await expect(
351
+ scrollIntoView(client, 'S1', ref({ ref: 'e1', backendDOMNodeId: 11 }))
352
+ ).rejects.toThrow('Cannot scroll: ref @e1 not found in DOM');
353
+ });
354
+ });