browser-commander 0.19.0 → 0.21.0

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 (155) hide show
  1. package/CHANGELOG.md +156 -0
  2. package/README.md +216 -32
  3. package/bin/browser-commander.js +16 -0
  4. package/eslint.config.js +10 -0
  5. package/examples/react-test-app/src/App.jsx +24 -23
  6. package/extension/background.js +14 -0
  7. package/extension/manifest.json +12 -0
  8. package/extension/relay-handler.js +353 -0
  9. package/package.json +25 -6
  10. package/scripts/run-tests.mjs +4 -2
  11. package/src/browser/attach/differences.js +131 -0
  12. package/src/browser/attach/extension-relay.js +402 -0
  13. package/src/browser/attach/index.js +81 -0
  14. package/src/browser/attach/snapshot-launch.js +146 -0
  15. package/src/browser/attach/snapshot.js +374 -0
  16. package/src/browser/attach/websocket.js +615 -0
  17. package/src/browser/browser-cookie-cache.js +12 -17
  18. package/src/browser/browser-cookie-credentials.js +6 -9
  19. package/src/browser/browser-cookies.js +5 -2
  20. package/src/browser/cdp-session.js +94 -0
  21. package/src/browser/connector.js +42 -4
  22. package/src/browser/debugging-port.js +155 -0
  23. package/src/browser/launch-options.js +63 -52
  24. package/src/browser/launch-restrictions.json +147 -0
  25. package/src/browser/launcher.js +329 -133
  26. package/src/browser/media.js +5 -0
  27. package/src/browser/migration/bookmarks.js +73 -0
  28. package/src/browser/migration/chromium-crypto.js +89 -0
  29. package/src/browser/migration/cookies.js +155 -0
  30. package/src/browser/migration/extensions.js +187 -0
  31. package/src/browser/migration/firefox-bookmarks.js +144 -0
  32. package/src/browser/migration/firefox-der.js +84 -0
  33. package/src/browser/migration/firefox-nss.js +281 -0
  34. package/src/browser/migration/firefox.js +334 -0
  35. package/src/browser/migration/fs-utils.js +51 -0
  36. package/src/browser/migration/history.js +103 -0
  37. package/src/browser/migration/index.js +307 -0
  38. package/src/browser/migration/os-crypt-keys.js +106 -0
  39. package/src/browser/migration/passwords.js +171 -0
  40. package/src/browser/migration/preferences.js +132 -0
  41. package/src/browser/migration/sqlite-snapshot.js +138 -0
  42. package/src/browser/open-in-user-browser.js +112 -0
  43. package/src/browser/parity.js +439 -0
  44. package/src/browser/profile-directory.js +102 -0
  45. package/src/browser/real-browser.js +513 -306
  46. package/src/browser/restrictions.js +148 -0
  47. package/src/browser/system-browser.js +232 -0
  48. package/src/browser/webdriver.js +572 -0
  49. package/src/cli/args.js +253 -0
  50. package/src/cli/commands.js +332 -0
  51. package/src/cli/dispatcher.js +99 -0
  52. package/src/cli/handle-methods.js +136 -0
  53. package/src/cli/handles.js +273 -0
  54. package/src/cli/high-level-methods.js +310 -0
  55. package/src/cli/main.js +67 -0
  56. package/src/cli/modules.js +100 -0
  57. package/src/cli/rpc-error.js +77 -0
  58. package/src/cli/script.js +77 -0
  59. package/src/cli/serve.js +144 -0
  60. package/src/cli/sessions.js +195 -0
  61. package/src/core/constants.js +7 -1
  62. package/src/core/engine-adapter.js +107 -1
  63. package/src/core/engine-detection.js +18 -3
  64. package/src/core/navigation-safety.js +4 -0
  65. package/src/core/webdriver-conversions.js +347 -0
  66. package/src/core/webdriver-events.js +229 -0
  67. package/src/core/webdriver-page.js +716 -0
  68. package/src/downloads/attach.js +6 -0
  69. package/src/downloads/manager.js +9 -0
  70. package/src/downloads/sources.js +71 -0
  71. package/src/downloads/staging.js +21 -9
  72. package/src/elements/selectors.js +3 -2
  73. package/src/elements/visibility.js +4 -0
  74. package/src/exports.js +61 -2
  75. package/src/factory.js +19 -4
  76. package/src/fingerprint/apply.js +6 -0
  77. package/src/fingerprint/automation-parity.js +7 -8
  78. package/src/fingerprint/limitations.js +1 -1
  79. package/src/fingerprint/limitations.json +45 -2
  80. package/src/parity/harness.js +303 -0
  81. package/src/parity/probe.js +838 -0
  82. package/src/parity/version-page.js +144 -0
  83. package/src/traces/bundle.js +10 -2
  84. package/src/traces/recorder.js +16 -3
  85. package/src/utilities/subprocess.js +199 -0
  86. package/tests/e2e/api-coverage.e2e.test.js +174 -0
  87. package/tests/e2e/cli.e2e.test.js +160 -0
  88. package/tests/e2e/downloads.e2e.test.js +79 -2
  89. package/tests/e2e/fingerprint-parity.e2e.test.js +104 -46
  90. package/tests/e2e/google-signin-probe.e2e.test.js +112 -0
  91. package/tests/e2e/measure-parity.e2e.test.js +118 -0
  92. package/tests/e2e/playwright.e2e.test.js +25 -17
  93. package/tests/e2e/puppeteer.e2e.test.js +17 -23
  94. package/tests/e2e/webdriver.e2e.test.js +239 -0
  95. package/tests/fixtures/firefox-nss-fixtures.mjs +211 -0
  96. package/tests/helpers/cli-fakes.js +214 -0
  97. package/tests/helpers/declared-api.js +80 -0
  98. package/tests/helpers/e2e-browser.js +40 -14
  99. package/tests/helpers/fake-chrome.js +124 -0
  100. package/tests/helpers/migration-fixtures.js +242 -0
  101. package/tests/helpers/temp-directory.js +24 -0
  102. package/tests/helpers/trace-fixtures.js +18 -0
  103. package/tests/helpers/webdriver-mocks.js +183 -0
  104. package/tests/unit/browser/attach/extension-relay.test.js +360 -0
  105. package/tests/unit/browser/attach/snapshot-launch.test.js +312 -0
  106. package/tests/unit/browser/attach/snapshot.test.js +390 -0
  107. package/tests/unit/browser/attach/websocket.test.js +240 -0
  108. package/tests/unit/browser/browser-cookies.test.js +2 -32
  109. package/tests/unit/browser/cdp-session.test.js +162 -0
  110. package/tests/unit/browser/connector.test.js +23 -0
  111. package/tests/unit/browser/launch-building-blocks.test.js +238 -0
  112. package/tests/unit/browser/launch-options.test.js +41 -22
  113. package/tests/unit/browser/launcher.test.js +281 -102
  114. package/tests/unit/browser/media.test.js +13 -1
  115. package/tests/unit/browser/migration/bookmarks.test.js +69 -0
  116. package/tests/unit/browser/migration/chromium-crypto.test.js +94 -0
  117. package/tests/unit/browser/migration/cookies.test.js +94 -0
  118. package/tests/unit/browser/migration/extensions.test.js +104 -0
  119. package/tests/unit/browser/migration/firefox-nss.test.js +113 -0
  120. package/tests/unit/browser/migration/firefox.test.js +139 -0
  121. package/tests/unit/browser/migration/history.test.js +48 -0
  122. package/tests/unit/browser/migration/index.test.js +117 -0
  123. package/tests/unit/browser/migration/os-crypt-keys.test.js +95 -0
  124. package/tests/unit/browser/migration/passwords.test.js +144 -0
  125. package/tests/unit/browser/migration/preferences.test.js +77 -0
  126. package/tests/unit/browser/migration/sqlite-snapshot.test.js +75 -0
  127. package/tests/unit/browser/open-in-user-browser.test.js +123 -0
  128. package/tests/unit/browser/parity.test.js +281 -0
  129. package/tests/unit/browser/real-browser.test.js +458 -33
  130. package/tests/unit/browser/webdriver.test.js +473 -0
  131. package/tests/unit/cli/bin.test.js +69 -0
  132. package/tests/unit/cli/declared-api.test.js +49 -0
  133. package/tests/unit/cli/dispatcher.test.js +429 -0
  134. package/tests/unit/cli/handles.test.js +170 -0
  135. package/tests/unit/cli/main.test.js +409 -0
  136. package/tests/unit/cli/serve.test.js +167 -0
  137. package/tests/unit/core/webdriver-conversions.test.js +235 -0
  138. package/tests/unit/core/webdriver-page.test.js +534 -0
  139. package/tests/unit/downloads/manager.test.js +82 -9
  140. package/tests/unit/downloads/staging.test.js +24 -2
  141. package/tests/unit/fingerprint/automation-parity.test.js +17 -4
  142. package/tests/unit/fingerprint/limitations.test.js +1 -1
  143. package/tests/unit/parity/harness.test.js +138 -0
  144. package/tests/unit/scripts/ci-timeout-budgets.test.js +4 -0
  145. package/tests/unit/scripts/ci-workflow-policy.test.js +8 -0
  146. package/tests/unit/scripts/feature-parity.test.js +221 -0
  147. package/tests/unit/scripts/npm-release-helpers.test.js +3 -2
  148. package/tests/unit/scripts/pre-commit-config.test.js +2 -1
  149. package/tests/unit/traces/bundle.test.js +24 -3
  150. package/tests/unit/traces/links.test.js +7 -0
  151. package/tests/unit/traces/reader.test.js +11 -4
  152. package/tests/unit/traces/recorder.test.js +4 -0
  153. package/tests/unit/traces/viewer.test.js +4 -0
  154. package/tests/unit/utilities/command-stream-only.test.js +28 -0
  155. package/tests/unit/utilities/subprocess.test.js +80 -0
@@ -0,0 +1,429 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert';
3
+
4
+ import {
5
+ METHOD_NAMES,
6
+ createDispatcher,
7
+ substituteSession,
8
+ } from '../../../src/cli/dispatcher.js';
9
+ import { importOptional } from '../../../src/cli/modules.js';
10
+ import { createFakeDependencies } from '../../helpers/cli-fakes.js';
11
+
12
+ function setup(overrides = {}) {
13
+ const fakes = createFakeDependencies();
14
+ const notifications = [];
15
+ const dispatcher = createDispatcher({
16
+ dependencies: { ...fakes.dependencies, ...overrides },
17
+ notify: (method, params) => notifications.push({ method, params }),
18
+ });
19
+ return { ...fakes, dispatcher, notifications };
20
+ }
21
+
22
+ describe('dispatcher: high-level methods', () => {
23
+ it('answers every method of the contract', () => {
24
+ for (const method of [
25
+ 'session.launch',
26
+ 'session.connect',
27
+ 'session.close',
28
+ 'page.goto',
29
+ 'page.click',
30
+ 'page.fill',
31
+ 'page.eval',
32
+ 'page.screenshot',
33
+ 'page.pdf',
34
+ 'trace.start',
35
+ 'trace.stop',
36
+ 'cookies.import',
37
+ 'profile.migrate',
38
+ 'profile.snapshot',
39
+ 'attach',
40
+ 'attach.tabs',
41
+ 'attach.send',
42
+ 'attach.close',
43
+ 'open',
44
+ 'doctor',
45
+ 'version',
46
+ 'handle.root',
47
+ 'handle.call',
48
+ 'handle.get',
49
+ 'handle.dispose',
50
+ 'handle.describe',
51
+ 'events.subscribe',
52
+ 'events.unsubscribe',
53
+ ]) {
54
+ assert.ok(METHOD_NAMES.includes(method), method);
55
+ }
56
+ });
57
+
58
+ it('launches, drives and closes a session', async () => {
59
+ const { dispatcher, launches, pages } = setup();
60
+ const { dispatch } = dispatcher;
61
+
62
+ const launched = await dispatch('session.launch', {
63
+ headless: true,
64
+ browser: 'edge',
65
+ restrictions: ['no-extensions'],
66
+ });
67
+ assert.deepEqual(launched, {
68
+ session: 's1',
69
+ cdpEndpoint: 'http://127.0.0.1:9222',
70
+ remoteDebuggingPort: 9222,
71
+ userDataDir: '/tmp/fake-profile',
72
+ temporaryProfile: true,
73
+ });
74
+ assert.deepEqual(launches[0].options, {
75
+ engine: 'playwright',
76
+ headless: true,
77
+ channel: 'msedge',
78
+ restrictions: ['no-extensions'],
79
+ });
80
+
81
+ assert.deepEqual(
82
+ await dispatch('page.goto', { session: 's1', url: 'https://a.test/' }),
83
+ { url: 'https://a.test/', title: 'Title of https://a.test/' }
84
+ );
85
+ assert.deepEqual(
86
+ await dispatch('page.fill', { session: 's1', selector: '#q', value: 7 }),
87
+ { filled: '#q', value: '7' }
88
+ );
89
+ assert.deepEqual(
90
+ await dispatch('page.click', { session: 's1', selector: '#go' }),
91
+ { clicked: '#go' }
92
+ );
93
+ assert.deepEqual(
94
+ await dispatch('page.eval', { session: 's1', expression: '1+1' }),
95
+ { value: { expression: '1+1' } }
96
+ );
97
+ assert.deepEqual(await dispatch('page.screenshot', { session: 's1' }), {
98
+ data: { $binary: Buffer.from('png-bytes').toString('base64') },
99
+ });
100
+ const pdf = await dispatch('page.pdf', { session: 's1', path: 'out.pdf' });
101
+ assert.equal(pdf.bytes, 4);
102
+ assert.ok(pdf.path.endsWith('out.pdf'));
103
+ assert.deepEqual(pages[0].calls.at(-1), ['pdf', { path: pdf.path }]);
104
+
105
+ assert.deepEqual(await dispatch('session.close', { session: 's1' }), {
106
+ closed: true,
107
+ });
108
+ assert.equal(launches[0].closed, true);
109
+ await assert.rejects(
110
+ dispatch('page.goto', { session: 's1', url: 'x' }),
111
+ /Unknown session: s1/u
112
+ );
113
+ });
114
+
115
+ it('fills through a locator for Puppeteer', async () => {
116
+ const { dispatcher, pages } = setup();
117
+ await dispatcher.dispatch('session.launch', { engine: 'puppeteer' });
118
+
119
+ await dispatcher.dispatch('page.fill', { selector: '#q', value: 'v' });
120
+
121
+ assert.deepEqual(pages[0].calls, [['fill', '#q', 'v']]);
122
+ });
123
+
124
+ it('connects without closing the browser', async () => {
125
+ const { dispatcher, connections } = setup();
126
+
127
+ const connected = await dispatcher.dispatch('session.connect', {
128
+ cdpEndpoint: 'http://127.0.0.1:9333',
129
+ engine: 'puppeteer',
130
+ });
131
+ await dispatcher.dispatch('session.close', { session: connected.session });
132
+
133
+ assert.deepEqual(connected, {
134
+ session: 's1',
135
+ cdpEndpoint: 'http://127.0.0.1:9333',
136
+ });
137
+ assert.equal(connections[0].disconnected, true);
138
+ assert.equal(connections[0].closed, false);
139
+ });
140
+
141
+ it('records and stops a trace', async () => {
142
+ const { dispatcher } = setup();
143
+ await dispatcher.dispatch('session.launch', {});
144
+
145
+ const started = await dispatcher.dispatch('trace.start', {
146
+ out: '/tmp/bc-trace',
147
+ });
148
+ await assert.rejects(
149
+ dispatcher.dispatch('trace.start', { out: '/tmp/bc-trace' }),
150
+ /already recording/u
151
+ );
152
+ assert.deepEqual(await dispatcher.dispatch('trace.stop', {}), started);
153
+ await assert.rejects(dispatcher.dispatch('trace.stop', {}), {
154
+ code: -32602,
155
+ });
156
+ });
157
+
158
+ it('imports cookies and reports the ones the engine rejects', async () => {
159
+ const { dispatcher } = setup();
160
+ await dispatcher.dispatch('session.launch', {});
161
+
162
+ const result = await dispatcher.dispatch('cookies.import', {
163
+ from: 'chrome',
164
+ domains: ['a.test', 'b.test'],
165
+ });
166
+
167
+ assert.equal(result.imported, 2);
168
+ assert.deepEqual(
169
+ result.skipped.map((entry) => entry.item),
170
+ ['.a.test bad', '.b.test bad']
171
+ );
172
+ });
173
+
174
+ it('forwards open, profile.migrate, doctor and version', async () => {
175
+ const { dispatcher } = setup();
176
+ const { dispatch } = dispatcher;
177
+
178
+ assert.deepEqual(await dispatch('open', { url: 'https://a.test' }), {
179
+ opened: 'https://a.test',
180
+ command: ['open', 'https://a.test'],
181
+ });
182
+ const migrated = await dispatch('profile.migrate', {
183
+ from: 'chrome',
184
+ include: ['cookies'],
185
+ });
186
+ assert.deepEqual(migrated.options.include, ['cookies']);
187
+ assert.deepEqual(await dispatch('doctor', {}), { ok: true, unlisted: [] });
188
+ assert.equal((await dispatch('version')).language, 'js');
189
+ });
190
+
191
+ it('forwards profile.snapshot and launches with attach', async () => {
192
+ const { dispatcher, launches } = setup();
193
+ const { dispatch } = dispatcher;
194
+
195
+ const report = await dispatch('profile.snapshot', {
196
+ from: 'chrome',
197
+ profile: 'Profile 1',
198
+ to: '/tmp/copy',
199
+ });
200
+ assert.equal(report.target, '/tmp/copy');
201
+ assert.equal(report.source.profile, 'Profile 1');
202
+ await assert.rejects(dispatch('profile.snapshot', {}), { code: -32602 });
203
+
204
+ await dispatch('session.launch', {
205
+ attach: { mode: 'snapshot', browser: 'chrome' },
206
+ });
207
+ assert.deepEqual(launches[0].options.attach, {
208
+ mode: 'snapshot',
209
+ browser: 'chrome',
210
+ });
211
+ });
212
+
213
+ it('keeps an extension relay open until attach.close', async () => {
214
+ const { dispatcher, relays } = setup();
215
+ const { dispatch } = dispatcher;
216
+
217
+ await assert.rejects(dispatch('attach', { mode: 'snapshot' }), {
218
+ code: -32602,
219
+ });
220
+ await assert.rejects(dispatch('attach.tabs'), { code: -32602 });
221
+ const attached = await dispatch('attach', { mode: 'extension', port: 0 });
222
+ assert.equal(attached.mode, 'extension');
223
+ assert.equal(attached.extension.id, 'abc');
224
+ assert.equal(attached.tabs[0].tabId, 1);
225
+ assert.equal(attached.differences[0].aspect, 'debugger-infobar');
226
+ assert.equal(relays[0].options.port, 0);
227
+ await assert.rejects(dispatch('attach', { mode: 'extension' }), {
228
+ code: -32602,
229
+ });
230
+
231
+ assert.deepEqual(await dispatch('attach.tabs'), attached.tabs);
232
+ assert.deepEqual(
233
+ await dispatch('attach.send', {
234
+ tabId: 1,
235
+ method: 'Runtime.evaluate',
236
+ params: { expression: '1' },
237
+ }),
238
+ { ok: true }
239
+ );
240
+ assert.deepEqual(relays[0].sent, [
241
+ { tabId: 1, method: 'Runtime.evaluate', params: { expression: '1' } },
242
+ ]);
243
+ await assert.rejects(dispatch('attach.send', { tabId: 'x' }), {
244
+ code: -32602,
245
+ });
246
+
247
+ assert.equal(relays[0].closed, false);
248
+ assert.deepEqual(await dispatch('attach.close'), { closed: true });
249
+ assert.equal(relays[0].closed, true);
250
+ assert.deepEqual(await dispatch('attach.close'), { closed: false });
251
+
252
+ await dispatch('attach', { mode: 'extension' });
253
+ await dispatcher.close();
254
+ assert.equal(relays[1].closed, true);
255
+ });
256
+
257
+ it('reports a missing optional module as an engine error', async () => {
258
+ const { dispatcher } = setup({
259
+ openInUserBrowser: () =>
260
+ importOptional('./does-not-exist.js', 'open (openInUserBrowser)'),
261
+ });
262
+
263
+ await assert.rejects(
264
+ dispatcher.dispatch('open', { url: 'https://a.test' }),
265
+ /open \(openInUserBrowser\) is not available/u
266
+ );
267
+ });
268
+
269
+ it('rejects unknown methods, bad params and bad engines', async () => {
270
+ const { dispatcher } = setup();
271
+
272
+ await assert.rejects(dispatcher.dispatch('nope'), { code: -32601 });
273
+ await assert.rejects(dispatcher.dispatch('version', []), { code: -32602 });
274
+ await assert.rejects(
275
+ dispatcher.dispatch('session.launch', { engine: 'selenium' }),
276
+ { code: -32602 }
277
+ );
278
+ await assert.rejects(
279
+ dispatcher.dispatch('session.launch', { browser: 'safari' }),
280
+ { code: -32602 }
281
+ );
282
+ await assert.rejects(dispatcher.dispatch('page.goto', { url: 'x' }), {
283
+ code: -32602,
284
+ message: 'No session; call session.launch first',
285
+ });
286
+ });
287
+ });
288
+
289
+ describe('dispatcher: generic handle methods', () => {
290
+ it('exposes the session objects and calls their methods', async () => {
291
+ const { dispatcher, pages } = setup();
292
+ const { dispatch } = dispatcher;
293
+ await dispatch('session.launch', {});
294
+
295
+ const roots = await dispatch('handle.root', { name: 'session:s1' });
296
+ assert.deepEqual(roots, {
297
+ browser: { $handle: 'h1', type: 'Object' },
298
+ context: { $handle: 'h2', type: 'FakeContext' },
299
+ page: { $handle: 'h3', type: 'FakePage' },
300
+ });
301
+
302
+ await dispatch('handle.call', {
303
+ handle: roots.page,
304
+ method: 'goto',
305
+ args: ['https://b.test/'],
306
+ });
307
+ assert.equal(pages[0].url(), 'https://b.test/');
308
+
309
+ const evaluated = await dispatch('handle.call', {
310
+ handle: 'h3',
311
+ method: 'evaluate',
312
+ args: [{ $function: '(a, b) => a * b' }, 6, 7],
313
+ });
314
+ assert.equal(evaluated, 42);
315
+
316
+ assert.deepEqual(
317
+ await dispatch('handle.get', { handle: 'h3', property: 'currentUrl' }),
318
+ 'https://b.test/'
319
+ );
320
+ assert.deepEqual(
321
+ await dispatch('handle.call', { handle: 'h2', method: 'pages' }),
322
+ [{ $handle: 'h3', type: 'FakePage' }]
323
+ );
324
+
325
+ const description = await dispatch('handle.describe', { handle: 'h3' });
326
+ assert.equal(description.type, 'FakePage');
327
+ for (const method of ['goto', 'fill', 'screenshot', 'on', 'emit']) {
328
+ assert.ok(description.methods.includes(method), method);
329
+ }
330
+
331
+ assert.deepEqual(await dispatch('handle.dispose', { handle: 'h3' }), {
332
+ disposed: true,
333
+ });
334
+ await assert.rejects(dispatch('handle.describe', { handle: 'h3' }), {
335
+ code: -32602,
336
+ });
337
+ });
338
+
339
+ it('resolves engine roots through the loader', async () => {
340
+ const { dispatcher } = setup();
341
+
342
+ const root = await dispatcher.dispatch('handle.root', {
343
+ name: 'puppeteer',
344
+ });
345
+ assert.deepEqual(root, { $handle: 'h1', type: 'FakeEngine' });
346
+ assert.equal(
347
+ await dispatcher.dispatch('handle.call', {
348
+ handle: root,
349
+ method: 'launch',
350
+ }),
351
+ 'launched with puppeteer'
352
+ );
353
+ await assert.rejects(
354
+ dispatcher.dispatch('handle.root', { name: 'selenium' }),
355
+ { code: -32602 }
356
+ );
357
+ });
358
+
359
+ it('rejects calls to members that are not methods', async () => {
360
+ const { dispatcher } = setup();
361
+ await dispatcher.dispatch('session.launch', {});
362
+ await dispatcher.dispatch('handle.root', { name: 'session:s1' });
363
+
364
+ await assert.rejects(
365
+ dispatcher.dispatch('handle.call', { handle: 'h3', method: 'values' }),
366
+ { code: -32602 }
367
+ );
368
+ });
369
+
370
+ it('streams subscribed events as events.emit notifications', async () => {
371
+ const { dispatcher, notifications, pages } = setup();
372
+ const { dispatch } = dispatcher;
373
+ await dispatch('session.launch', {});
374
+ await dispatch('handle.root', { name: 'session:s1' });
375
+
376
+ const { subscription } = await dispatch('events.subscribe', {
377
+ handle: 'h3',
378
+ event: 'console',
379
+ });
380
+ pages[0].emit('console', 'hello', pages[0]);
381
+ assert.deepEqual(await dispatch('events.unsubscribe', { subscription }), {
382
+ unsubscribed: true,
383
+ });
384
+ pages[0].emit('console', 'ignored');
385
+
386
+ assert.equal(subscription, 'e1');
387
+ assert.deepEqual(notifications, [
388
+ {
389
+ method: 'events.emit',
390
+ params: {
391
+ subscription: 'e1',
392
+ args: ['hello', { $handle: 'h3', type: 'FakePage' }],
393
+ },
394
+ },
395
+ ]);
396
+ await assert.rejects(dispatch('events.unsubscribe', { subscription }), {
397
+ code: -32602,
398
+ });
399
+ });
400
+
401
+ it('closes sessions and subscriptions on close()', async () => {
402
+ const { dispatcher, launches, pages } = setup();
403
+ await dispatcher.dispatch('session.launch', {});
404
+ await dispatcher.dispatch('session.launch', {});
405
+ await dispatcher.dispatch('handle.root', { name: 'session:s2' });
406
+ await dispatcher.dispatch('events.subscribe', {
407
+ handle: 'h3',
408
+ event: 'load',
409
+ });
410
+
411
+ await dispatcher.close();
412
+
413
+ assert.deepEqual(
414
+ launches.map((launch) => launch.closed),
415
+ [true, true]
416
+ );
417
+ assert.equal(pages[1].listenerCount('load'), 0);
418
+ });
419
+ });
420
+
421
+ describe('substituteSession', () => {
422
+ it('replaces $session deeply', () => {
423
+ assert.deepEqual(
424
+ substituteSession({ session: '$session', list: ['$session', 1] }, 's2'),
425
+ { session: 's2', list: ['s2', 1] }
426
+ );
427
+ assert.equal(substituteSession('$session', null), '$session');
428
+ });
429
+ });
@@ -0,0 +1,170 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert';
3
+
4
+ import {
5
+ HandleTable,
6
+ compileFunction,
7
+ decodeValue,
8
+ describeObject,
9
+ encodeValue,
10
+ isPlainObject,
11
+ typeName,
12
+ } from '../../../src/cli/handles.js';
13
+
14
+ class Page {
15
+ goto() {}
16
+ get mainFrame() {
17
+ throw new Error('getters must not run while describing');
18
+ }
19
+ _private() {}
20
+ }
21
+ class Page2 extends Page {}
22
+
23
+ describe('value encoding round-trip (no browser)', () => {
24
+ const cases = [
25
+ ['null', null],
26
+ ['booleans', [true, false]],
27
+ ['strings and numbers', { text: 'a', count: 3.5 }],
28
+ ['undefined', undefined],
29
+ ['nested plain objects', { a: { b: [1, { c: 'd' }] } }],
30
+ ];
31
+ for (const [name, value] of cases) {
32
+ it(`keeps ${name}`, () => {
33
+ const handles = new HandleTable();
34
+ assert.deepEqual(
35
+ decodeValue(
36
+ JSON.parse(JSON.stringify(encodeValue(value, handles))),
37
+ handles
38
+ ),
39
+ value
40
+ );
41
+ });
42
+ }
43
+
44
+ it('tags binary, dates, regexps, bigints and errors', () => {
45
+ const handles = new HandleTable();
46
+ const value = {
47
+ bytes: Buffer.from('hi'),
48
+ when: new Date('2026-01-02T03:04:05.000Z'),
49
+ pattern: /a+b/giu,
50
+ big: 12345678901234567890n,
51
+ failure: new TypeError('boom'),
52
+ missing: undefined,
53
+ };
54
+ const wire = JSON.parse(JSON.stringify(encodeValue(value, handles)));
55
+
56
+ assert.deepEqual(wire, {
57
+ bytes: { $binary: 'aGk=' },
58
+ when: { $date: '2026-01-02T03:04:05.000Z' },
59
+ pattern: { $regexp: { source: 'a+b', flags: 'giu' } },
60
+ big: { $bigint: '12345678901234567890' },
61
+ failure: { $error: { name: 'TypeError', message: 'boom' } },
62
+ missing: { $undefined: true },
63
+ });
64
+
65
+ const decoded = decodeValue(wire, handles);
66
+ assert.ok(Buffer.isBuffer(decoded.bytes));
67
+ assert.equal(decoded.bytes.toString(), 'hi');
68
+ assert.equal(decoded.when.getTime(), value.when.getTime());
69
+ assert.equal(decoded.pattern.toString(), '/a+b/giu');
70
+ assert.equal(decoded.big, value.big);
71
+ assert.equal(decoded.failure.name, 'TypeError');
72
+ assert.equal(decoded.failure.message, 'boom');
73
+ assert.ok('missing' in decoded && decoded.missing === undefined);
74
+ });
75
+
76
+ it('turns class instances and functions into handles and back', () => {
77
+ const handles = new HandleTable();
78
+ const page = new Page();
79
+ const callback = () => 1;
80
+
81
+ const wire = encodeValue({ page, callback, again: page }, handles);
82
+
83
+ assert.deepEqual(wire, {
84
+ page: { $handle: 'h1', type: 'Page' },
85
+ callback: { $handle: 'h2', type: 'Function' },
86
+ again: { $handle: 'h1', type: 'Page' },
87
+ });
88
+ const decoded = decodeValue(wire, handles);
89
+ assert.equal(decoded.page, page);
90
+ assert.equal(decoded.callback, callback);
91
+ });
92
+
93
+ it('compiles $function sources and keeps their source text', () => {
94
+ const handles = new HandleTable();
95
+ const fn = decodeValue({ $function: '(a, b) => a + b' }, handles);
96
+
97
+ assert.equal(fn(2, 3), 5);
98
+ assert.equal(fn.toString(), '(a, b) => a + b');
99
+ assert.throws(() => compileFunction('1 +'), /Invalid \$function/u);
100
+ assert.throws(() => compileFunction('42'), /did not evaluate/u);
101
+ });
102
+
103
+ it('encodes cycles as handles instead of recursing forever', () => {
104
+ const handles = new HandleTable();
105
+ const node = { name: 'root' };
106
+ node.self = node;
107
+
108
+ const wire = encodeValue(node, handles);
109
+
110
+ assert.equal(wire.name, 'root');
111
+ assert.deepEqual(wire.self, { $handle: 'h1', type: 'Object' });
112
+ });
113
+
114
+ it('maps non-finite numbers to null and symbols to strings', () => {
115
+ const handles = new HandleTable();
116
+ assert.deepEqual(encodeValue([Number.NaN, Infinity], handles), [
117
+ null,
118
+ null,
119
+ ]);
120
+ assert.equal(encodeValue(Symbol('s'), handles), 'Symbol(s)');
121
+ });
122
+ });
123
+
124
+ describe('HandleTable', () => {
125
+ it('allocates deterministic ids and disposes them', () => {
126
+ const handles = new HandleTable();
127
+ const first = {};
128
+ Object.setPrototypeOf(first, Page.prototype);
129
+
130
+ assert.equal(handles.register(first).$handle, 'h1');
131
+ assert.equal(handles.register(new Page()).$handle, 'h2');
132
+ assert.equal(handles.dispose('h1'), true);
133
+ assert.equal(handles.dispose('h1'), false);
134
+ assert.throws(() => handles.get('h1'), { code: -32602 });
135
+ assert.equal(handles.register(first).$handle, 'h3');
136
+ });
137
+ });
138
+
139
+ describe('describeObject', () => {
140
+ it('lists public methods across the prototype chain without calling getters', () => {
141
+ const description = describeObject(new Page2());
142
+
143
+ assert.equal(description.type, 'Page');
144
+ assert.ok(description.methods.includes('goto'));
145
+ assert.ok(description.methods.includes('hasOwnProperty') === false);
146
+ assert.ok(!description.methods.includes('_private'));
147
+ assert.ok(description.properties.includes('mainFrame'));
148
+ });
149
+
150
+ it('describes primitives as empty', () => {
151
+ assert.deepEqual(describeObject(null), {
152
+ type: 'Object',
153
+ methods: [],
154
+ properties: [],
155
+ });
156
+ });
157
+ });
158
+
159
+ describe('type names', () => {
160
+ it('drops bundler suffixes and recognizes plain objects', () => {
161
+ assert.equal(typeName(new Page2()), 'Page');
162
+ assert.equal(
163
+ typeName(() => {}),
164
+ 'Function'
165
+ );
166
+ assert.equal(isPlainObject({}), true);
167
+ assert.equal(isPlainObject(Object.create(null)), true);
168
+ assert.equal(isPlainObject(new Page()), false);
169
+ });
170
+ });