browser-commander 0.20.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 (139) hide show
  1. package/CHANGELOG.md +145 -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 +19 -4
  10. package/src/browser/attach/differences.js +131 -0
  11. package/src/browser/attach/extension-relay.js +402 -0
  12. package/src/browser/attach/index.js +81 -0
  13. package/src/browser/attach/snapshot-launch.js +146 -0
  14. package/src/browser/attach/snapshot.js +374 -0
  15. package/src/browser/attach/websocket.js +615 -0
  16. package/src/browser/browser-cookie-cache.js +12 -17
  17. package/src/browser/browser-cookie-credentials.js +6 -9
  18. package/src/browser/browser-cookies.js +5 -2
  19. package/src/browser/cdp-session.js +94 -0
  20. package/src/browser/connector.js +42 -4
  21. package/src/browser/debugging-port.js +155 -0
  22. package/src/browser/launch-options.js +63 -52
  23. package/src/browser/launch-restrictions.json +147 -0
  24. package/src/browser/launcher.js +329 -133
  25. package/src/browser/media.js +5 -0
  26. package/src/browser/migration/bookmarks.js +73 -0
  27. package/src/browser/migration/chromium-crypto.js +89 -0
  28. package/src/browser/migration/cookies.js +155 -0
  29. package/src/browser/migration/extensions.js +187 -0
  30. package/src/browser/migration/firefox-bookmarks.js +144 -0
  31. package/src/browser/migration/firefox-der.js +84 -0
  32. package/src/browser/migration/firefox-nss.js +281 -0
  33. package/src/browser/migration/firefox.js +334 -0
  34. package/src/browser/migration/fs-utils.js +51 -0
  35. package/src/browser/migration/history.js +103 -0
  36. package/src/browser/migration/index.js +307 -0
  37. package/src/browser/migration/os-crypt-keys.js +106 -0
  38. package/src/browser/migration/passwords.js +171 -0
  39. package/src/browser/migration/preferences.js +132 -0
  40. package/src/browser/migration/sqlite-snapshot.js +138 -0
  41. package/src/browser/open-in-user-browser.js +112 -0
  42. package/src/browser/parity.js +439 -0
  43. package/src/browser/profile-directory.js +102 -0
  44. package/src/browser/real-browser.js +513 -306
  45. package/src/browser/restrictions.js +148 -0
  46. package/src/browser/system-browser.js +232 -0
  47. package/src/browser/webdriver.js +572 -0
  48. package/src/cli/args.js +253 -0
  49. package/src/cli/commands.js +332 -0
  50. package/src/cli/dispatcher.js +99 -0
  51. package/src/cli/handle-methods.js +136 -0
  52. package/src/cli/handles.js +273 -0
  53. package/src/cli/high-level-methods.js +310 -0
  54. package/src/cli/main.js +67 -0
  55. package/src/cli/modules.js +100 -0
  56. package/src/cli/rpc-error.js +77 -0
  57. package/src/cli/script.js +77 -0
  58. package/src/cli/serve.js +144 -0
  59. package/src/cli/sessions.js +195 -0
  60. package/src/core/constants.js +7 -1
  61. package/src/core/engine-adapter.js +107 -1
  62. package/src/core/engine-detection.js +18 -3
  63. package/src/core/navigation-safety.js +4 -0
  64. package/src/core/webdriver-conversions.js +347 -0
  65. package/src/core/webdriver-events.js +229 -0
  66. package/src/core/webdriver-page.js +716 -0
  67. package/src/downloads/attach.js +6 -0
  68. package/src/elements/selectors.js +3 -2
  69. package/src/elements/visibility.js +4 -0
  70. package/src/exports.js +61 -2
  71. package/src/factory.js +19 -4
  72. package/src/fingerprint/apply.js +6 -0
  73. package/src/fingerprint/automation-parity.js +7 -8
  74. package/src/fingerprint/limitations.js +1 -1
  75. package/src/fingerprint/limitations.json +45 -2
  76. package/src/parity/harness.js +303 -0
  77. package/src/parity/probe.js +838 -0
  78. package/src/parity/version-page.js +144 -0
  79. package/src/utilities/subprocess.js +199 -0
  80. package/tests/e2e/api-coverage.e2e.test.js +174 -0
  81. package/tests/e2e/cli.e2e.test.js +160 -0
  82. package/tests/e2e/fingerprint-parity.e2e.test.js +104 -46
  83. package/tests/e2e/google-signin-probe.e2e.test.js +112 -0
  84. package/tests/e2e/measure-parity.e2e.test.js +118 -0
  85. package/tests/e2e/playwright.e2e.test.js +25 -17
  86. package/tests/e2e/puppeteer.e2e.test.js +17 -23
  87. package/tests/e2e/webdriver.e2e.test.js +239 -0
  88. package/tests/fixtures/firefox-nss-fixtures.mjs +211 -0
  89. package/tests/helpers/cli-fakes.js +214 -0
  90. package/tests/helpers/declared-api.js +80 -0
  91. package/tests/helpers/e2e-browser.js +40 -14
  92. package/tests/helpers/fake-chrome.js +124 -0
  93. package/tests/helpers/migration-fixtures.js +242 -0
  94. package/tests/helpers/temp-directory.js +24 -0
  95. package/tests/helpers/webdriver-mocks.js +183 -0
  96. package/tests/unit/browser/attach/extension-relay.test.js +360 -0
  97. package/tests/unit/browser/attach/snapshot-launch.test.js +312 -0
  98. package/tests/unit/browser/attach/snapshot.test.js +390 -0
  99. package/tests/unit/browser/attach/websocket.test.js +240 -0
  100. package/tests/unit/browser/browser-cookies.test.js +2 -32
  101. package/tests/unit/browser/cdp-session.test.js +162 -0
  102. package/tests/unit/browser/connector.test.js +23 -0
  103. package/tests/unit/browser/launch-building-blocks.test.js +238 -0
  104. package/tests/unit/browser/launch-options.test.js +41 -22
  105. package/tests/unit/browser/launcher.test.js +281 -102
  106. package/tests/unit/browser/media.test.js +13 -1
  107. package/tests/unit/browser/migration/bookmarks.test.js +69 -0
  108. package/tests/unit/browser/migration/chromium-crypto.test.js +94 -0
  109. package/tests/unit/browser/migration/cookies.test.js +94 -0
  110. package/tests/unit/browser/migration/extensions.test.js +104 -0
  111. package/tests/unit/browser/migration/firefox-nss.test.js +113 -0
  112. package/tests/unit/browser/migration/firefox.test.js +139 -0
  113. package/tests/unit/browser/migration/history.test.js +48 -0
  114. package/tests/unit/browser/migration/index.test.js +117 -0
  115. package/tests/unit/browser/migration/os-crypt-keys.test.js +95 -0
  116. package/tests/unit/browser/migration/passwords.test.js +144 -0
  117. package/tests/unit/browser/migration/preferences.test.js +77 -0
  118. package/tests/unit/browser/migration/sqlite-snapshot.test.js +75 -0
  119. package/tests/unit/browser/open-in-user-browser.test.js +123 -0
  120. package/tests/unit/browser/parity.test.js +281 -0
  121. package/tests/unit/browser/real-browser.test.js +458 -33
  122. package/tests/unit/browser/webdriver.test.js +473 -0
  123. package/tests/unit/cli/bin.test.js +69 -0
  124. package/tests/unit/cli/declared-api.test.js +49 -0
  125. package/tests/unit/cli/dispatcher.test.js +429 -0
  126. package/tests/unit/cli/handles.test.js +170 -0
  127. package/tests/unit/cli/main.test.js +409 -0
  128. package/tests/unit/cli/serve.test.js +167 -0
  129. package/tests/unit/core/webdriver-conversions.test.js +235 -0
  130. package/tests/unit/core/webdriver-page.test.js +534 -0
  131. package/tests/unit/fingerprint/automation-parity.test.js +17 -4
  132. package/tests/unit/fingerprint/limitations.test.js +1 -1
  133. package/tests/unit/parity/harness.test.js +138 -0
  134. package/tests/unit/scripts/ci-timeout-budgets.test.js +4 -0
  135. package/tests/unit/scripts/feature-parity.test.js +221 -0
  136. package/tests/unit/traces/reader.test.js +1 -0
  137. package/tests/unit/traces/recorder.test.js +1 -0
  138. package/tests/unit/utilities/command-stream-only.test.js +28 -0
  139. package/tests/unit/utilities/subprocess.test.js +80 -0
@@ -0,0 +1,409 @@
1
+ // feature-parity: cli.version cli.script cli.serve
2
+ import { describe, it } from 'node:test';
3
+ import assert from 'node:assert';
4
+ import { EventEmitter } from 'node:events';
5
+ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+ import { PassThrough } from 'node:stream';
9
+
10
+ import { parseCommandLine, UsageError } from '../../../src/cli/args.js';
11
+ import { TRACE_STOP_MARKER } from '../../../src/cli/commands.js';
12
+ import { EXIT_CODES, runCli } from '../../../src/cli/main.js';
13
+ import { runScript } from '../../../src/cli/script.js';
14
+ import { createFakeDependencies } from '../../helpers/cli-fakes.js';
15
+
16
+ /** Run the CLI against fakes and collect the printed documents. */
17
+ async function cli(argv, { fakes = createFakeDependencies(), ...io } = {}) {
18
+ const documents = [];
19
+ const exitCode = await runCli(argv, {
20
+ stdout: { write: (text) => documents.push(JSON.parse(text)) },
21
+ stdin: new PassThrough(),
22
+ signals: new EventEmitter(),
23
+ dependencies: fakes.dependencies,
24
+ ...io,
25
+ });
26
+ return { exitCode, documents, document: documents.at(-1), ...fakes };
27
+ }
28
+
29
+ async function assertUsageErrors(commands) {
30
+ for (const argv of commands) {
31
+ assert.equal((await cli(argv)).exitCode, EXIT_CODES.USAGE, argv.join(' '));
32
+ }
33
+ }
34
+
35
+ describe('parseCommandLine', () => {
36
+ it('parses positionals, kebab-case options and two-word commands', () => {
37
+ assert.deepEqual(
38
+ parseCommandLine([
39
+ 'fill',
40
+ '#q',
41
+ 'hello',
42
+ '--cdp-endpoint',
43
+ 'http://127.0.0.1:9222',
44
+ '--restriction',
45
+ 'no-extensions',
46
+ '--restriction',
47
+ 'no-sync',
48
+ ]),
49
+ {
50
+ command: 'fill',
51
+ args: { selector: '#q', value: 'hello' },
52
+ options: {
53
+ cdpEndpoint: 'http://127.0.0.1:9222',
54
+ restriction: ['no-extensions', 'no-sync'],
55
+ },
56
+ }
57
+ );
58
+ assert.equal(
59
+ parseCommandLine(['trace', 'view', 'dir']).command,
60
+ 'trace view'
61
+ );
62
+ });
63
+
64
+ it('throws usage errors for bad command lines', () => {
65
+ for (const argv of [
66
+ [],
67
+ ['--help'],
68
+ ['bogus'],
69
+ ['trace', 'pause'],
70
+ ['goto'],
71
+ ['goto', 'a', 'b'],
72
+ ['version', '--nope'],
73
+ ]) {
74
+ assert.throws(() => parseCommandLine(argv), UsageError, argv.join(' '));
75
+ }
76
+ });
77
+ });
78
+
79
+ describe('runCli', () => {
80
+ it('prints the version document', async () => {
81
+ const { exitCode, documents } = await cli(['version']);
82
+
83
+ assert.equal(exitCode, EXIT_CODES.OK);
84
+ assert.deepEqual(documents, [
85
+ { name: 'browser-commander', version: '0.0.0-test', language: 'js' },
86
+ ]);
87
+ });
88
+
89
+ it('exits 64 with a JSON usage error', async () => {
90
+ const { exitCode, document, launches } = await cli(['click']);
91
+
92
+ assert.equal(exitCode, EXIT_CODES.USAGE);
93
+ assert.equal(document.error.name, 'UsageError');
94
+ assert.equal(launches.length, 0);
95
+ assert.equal((await cli(['cookies', 'import'])).exitCode, 64);
96
+ assert.equal((await cli(['serve'])).exitCode, 64);
97
+ assert.equal((await cli(['goto', 'x', '--engine', 'nope'])).exitCode, 64);
98
+ });
99
+
100
+ it('exits 1 with a JSON error when the engine fails', async () => {
101
+ const fakes = createFakeDependencies();
102
+ fakes.dependencies.launchBrowser = async () => {
103
+ throw new Error('Chrome is not installed');
104
+ };
105
+
106
+ const { exitCode, document } = await cli(['goto', 'https://a.test'], {
107
+ fakes,
108
+ });
109
+
110
+ assert.equal(exitCode, EXIT_CODES.ERROR);
111
+ assert.deepEqual(document, {
112
+ error: { name: 'Error', message: 'Chrome is not installed' },
113
+ });
114
+ });
115
+
116
+ it('launches a temporary browser for a page command and closes it', async () => {
117
+ const { exitCode, document, launches, pages } = await cli([
118
+ 'eval',
119
+ 'document.title',
120
+ '--url',
121
+ 'https://a.test/',
122
+ '--browser',
123
+ 'chrome',
124
+ // A value starting with "--" needs the --arg=VALUE form.
125
+ '--arg=--lang=de',
126
+ '--headless',
127
+ ]);
128
+
129
+ assert.equal(exitCode, 0);
130
+ assert.deepEqual(document, { value: { expression: 'document.title' } });
131
+ assert.deepEqual(launches[0].options, {
132
+ engine: 'playwright',
133
+ headless: true,
134
+ channel: 'chrome',
135
+ args: ['--lang=de'],
136
+ });
137
+ assert.equal(launches[0].closed, true);
138
+ assert.deepEqual(pages[0].calls[0], ['goto', 'https://a.test/']);
139
+ });
140
+
141
+ it('attaches with --cdp-endpoint and leaves the browser running', async () => {
142
+ const { exitCode, document, connections, launches } = await cli([
143
+ 'goto',
144
+ 'https://b.test/',
145
+ '--cdp-endpoint',
146
+ 'http://127.0.0.1:9333',
147
+ '--engine',
148
+ 'puppeteer',
149
+ ]);
150
+
151
+ assert.equal(exitCode, 0);
152
+ assert.deepEqual(document, {
153
+ url: 'https://b.test/',
154
+ title: 'Title of https://b.test/',
155
+ });
156
+ assert.equal(launches.length, 0);
157
+ const [{ options, disconnected, closed }] = connections;
158
+ assert.deepEqual(
159
+ { cdpEndpoint: options.cdpEndpoint, disconnected, closed },
160
+ {
161
+ cdpEndpoint: 'http://127.0.0.1:9333',
162
+ disconnected: true,
163
+ closed: false,
164
+ }
165
+ );
166
+ });
167
+
168
+ it('keeps a launched browser open until a signal arrives', async () => {
169
+ const signals = new EventEmitter();
170
+ const documents = [];
171
+ const fakes = createFakeDependencies();
172
+ const running = runCli(['launch', '--keep-open'], {
173
+ stdout: { write: (text) => documents.push(JSON.parse(text)) },
174
+ stdin: new PassThrough(),
175
+ signals,
176
+ dependencies: fakes.dependencies,
177
+ });
178
+
179
+ await new Promise((resolve) => setTimeout(resolve, 10));
180
+ assert.equal(documents[0].cdpEndpoint, 'http://127.0.0.1:9222');
181
+ assert.deepEqual(documents[0].args, ['--user-data-dir=/tmp/fake-profile']);
182
+ assert.equal(fakes.launches[0].closed, false);
183
+ signals.emit('SIGTERM');
184
+
185
+ assert.equal(await running, 0);
186
+ assert.equal(documents.length, 1);
187
+ assert.equal(fakes.launches[0].closed, true);
188
+ });
189
+
190
+ it('closes a launch without --keep-open right after printing', async () => {
191
+ const { exitCode, document, launches } = await cli(['launch']);
192
+
193
+ assert.equal(exitCode, 0);
194
+ assert.equal(document.remoteDebuggingPort, 9222);
195
+ assert.equal(launches[0].closed, true);
196
+ });
197
+
198
+ it('exits 2 when doctor finds an unlisted difference', async () => {
199
+ const fakes = createFakeDependencies();
200
+ fakes.dependencies.measureParity = async () => ({
201
+ ok: false,
202
+ unlisted: [{ property: 'navigator.webdriver' }],
203
+ });
204
+
205
+ const { exitCode, document } = await cli(['doctor'], { fakes });
206
+
207
+ assert.equal(exitCode, EXIT_CODES.UNLISTED_DIFFERENCE);
208
+ assert.equal(document.unlisted[0].property, 'navigator.webdriver');
209
+ assert.equal((await cli(['doctor'])).exitCode, 0);
210
+ });
211
+
212
+ it('splits profile migrate --include on commas', async () => {
213
+ const { document } = await cli([
214
+ 'profile',
215
+ 'migrate',
216
+ '--from',
217
+ 'chrome',
218
+ '--include',
219
+ 'cookies, bookmarks',
220
+ '--domain',
221
+ 'a.test',
222
+ ]);
223
+
224
+ assert.deepEqual(document.options, {
225
+ from: 'chrome',
226
+ include: ['cookies', 'bookmarks'],
227
+ domains: ['a.test'],
228
+ });
229
+ });
230
+
231
+ it('prints a profile snapshot report', async () => {
232
+ const { exitCode, document } = await cli([
233
+ 'profile',
234
+ 'snapshot',
235
+ '--from',
236
+ 'edge',
237
+ '--profile',
238
+ 'Profile 2',
239
+ '--to',
240
+ 'copy',
241
+ ]);
242
+
243
+ assert.equal(exitCode, 0);
244
+ assert.equal(document.source.browser, 'edge');
245
+ assert.equal(document.source.profile, 'Profile 2');
246
+ assert.equal(document.target, path.resolve('copy'));
247
+ assert.equal(
248
+ (await cli(['profile', 'snapshot', '--to', 'copy'])).exitCode,
249
+ 64
250
+ );
251
+ });
252
+
253
+ it('launches a snapshot with --attach snapshot', async () => {
254
+ const fakes = createFakeDependencies();
255
+ const launchBrowser = fakes.dependencies.launchBrowser;
256
+ fakes.dependencies.launchBrowser = async (options) => ({
257
+ ...(await launchBrowser(options)),
258
+ attach: { mode: 'snapshot', snapshot: { target: '/tmp/s' } },
259
+ });
260
+
261
+ const { exitCode, document, launches } = await cli(
262
+ ['launch', '--attach', 'snapshot', '--from', 'chrome', '--profile', 'P'],
263
+ { fakes }
264
+ );
265
+
266
+ assert.equal(exitCode, 0);
267
+ assert.deepEqual(launches[0].options.attach, {
268
+ mode: 'snapshot',
269
+ browser: 'chrome',
270
+ profile: 'P',
271
+ });
272
+ assert.equal(document.attach.mode, 'snapshot');
273
+ await assertUsageErrors([
274
+ ['launch', '--attach', 'extension'],
275
+ ['launch', '--from', 'chrome'],
276
+ ['launch', '--attach', 'snapshot', '--user-data-dir', '/tmp/x'],
277
+ ['launch', '--attach', 'snapshot', '--launch', 'engine'],
278
+ ]);
279
+ });
280
+
281
+ it('attaches through the extension and closes the relay', async () => {
282
+ const { exitCode, document, relays } = await cli([
283
+ 'attach',
284
+ '--mode',
285
+ 'extension',
286
+ '--port',
287
+ '9444',
288
+ '--timeout',
289
+ '5000',
290
+ ]);
291
+
292
+ assert.equal(exitCode, 0);
293
+ assert.deepEqual(Object.keys(document), [
294
+ 'mode',
295
+ 'extension',
296
+ 'tabs',
297
+ 'differences',
298
+ ]);
299
+ assert.equal(document.mode, 'extension');
300
+ assert.equal(relays[0].options.port, 9444);
301
+ assert.equal(relays[0].options.timeoutMs, 5000);
302
+ assert.equal(relays[0].closed, true);
303
+ await assertUsageErrors([
304
+ ['attach'],
305
+ ['attach', '--mode', 'snapshot'],
306
+ ['attach', '--mode', 'extension', '--port', 'x'],
307
+ ]);
308
+ });
309
+
310
+ it('runs a script and exits 1 when a step failed', async () => {
311
+ const dir = await mkdtemp(path.join(os.tmpdir(), 'bc-cli-run-'));
312
+ const script = path.join(dir, 'script.json');
313
+ await writeFile(
314
+ script,
315
+ JSON.stringify({
316
+ steps: [
317
+ { method: 'session.launch' },
318
+ { method: 'page.goto', params: { session: '$session', url: 'x:' } },
319
+ { method: 'nope' },
320
+ { method: 'session.close', params: { session: '$session' } },
321
+ ],
322
+ })
323
+ );
324
+ try {
325
+ const { exitCode, document, launches } = await cli([
326
+ 'run',
327
+ script,
328
+ '--headless',
329
+ ]);
330
+
331
+ assert.equal(exitCode, 1);
332
+ assert.deepEqual(
333
+ document.results.map((entry) => entry.method),
334
+ ['session.launch', 'page.goto', 'nope', 'session.close']
335
+ );
336
+ assert.deepEqual(document.results[2].error, {
337
+ code: -32601,
338
+ message: 'Unknown method: nope',
339
+ });
340
+ assert.deepEqual(document.results[3].result, { closed: true });
341
+ assert.equal(launches[0].options.headless, true);
342
+
343
+ const missing = await cli(['run', path.join(dir, 'missing.json')]);
344
+ assert.equal(missing.exitCode, 1);
345
+ assert.match(missing.document.error.message, /Cannot read/u);
346
+ } finally {
347
+ await rm(dir, { recursive: true, force: true });
348
+ }
349
+ });
350
+
351
+ it('stops trace start when trace stop writes the marker', async () => {
352
+ const dir = await mkdtemp(path.join(os.tmpdir(), 'bc-cli-trace-'));
353
+ try {
354
+ const recording = cli(['trace', 'start', '--out', dir]);
355
+ await new Promise((resolve) => setTimeout(resolve, 50));
356
+ const stop = await cli(['trace', 'stop', '--out', dir]);
357
+ assert.deepEqual(stop.document, { trace: dir, stopRequested: true });
358
+ assert.equal(
359
+ await readFile(path.join(dir, TRACE_STOP_MARKER), 'utf8'),
360
+ ''
361
+ );
362
+
363
+ const { exitCode, document } = await recording;
364
+ assert.equal(exitCode, 0);
365
+ assert.deepEqual(document, { trace: dir, stopped: true });
366
+
367
+ const view = await cli(['trace', 'view', dir]);
368
+ assert.deepEqual(view.document, {
369
+ viewer: path.join(dir, 'viewer.html'),
370
+ });
371
+ } finally {
372
+ await rm(dir, { recursive: true, force: true });
373
+ }
374
+ });
375
+
376
+ it('serves JSON-RPC over the given stdin', async () => {
377
+ const stdin = new PassThrough();
378
+ const documents = [];
379
+ const serving = runCli(['serve', '--stdio'], {
380
+ stdout: { write: (text) => documents.push(JSON.parse(text)) },
381
+ stdin,
382
+ signals: new EventEmitter(),
383
+ dependencies: createFakeDependencies().dependencies,
384
+ });
385
+ stdin.end(
386
+ `${JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'version' })}\n`
387
+ );
388
+
389
+ assert.equal(await serving, 0);
390
+ assert.deepEqual(documents, [
391
+ {
392
+ jsonrpc: '2.0',
393
+ id: 1,
394
+ result: {
395
+ name: 'browser-commander',
396
+ version: '0.0.0-test',
397
+ language: 'js',
398
+ },
399
+ },
400
+ ]);
401
+ });
402
+ });
403
+
404
+ describe('runScript', () => {
405
+ it('rejects scripts without steps', async () => {
406
+ await assert.rejects(runScript({}), /\{"steps": \[\.\.\.\]\}/u);
407
+ await assert.rejects(runScript({ steps: [{}] }), /Step 1 has no/u);
408
+ });
409
+ });
@@ -0,0 +1,167 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import { PassThrough } from 'node:stream';
4
+
5
+ import { createDispatcher } from '../../../src/cli/dispatcher.js';
6
+ import {
7
+ createMessageHandler,
8
+ handleLine,
9
+ serveStdio,
10
+ } from '../../../src/cli/serve.js';
11
+ import { createFakeDependencies } from '../../helpers/cli-fakes.js';
12
+
13
+ function handler() {
14
+ const { dependencies } = createFakeDependencies();
15
+ return createMessageHandler(createDispatcher({ dependencies }));
16
+ }
17
+
18
+ describe('JSON-RPC message handling', () => {
19
+ it('answers a request with its id', async () => {
20
+ const response = await handler()({
21
+ jsonrpc: '2.0',
22
+ id: 7,
23
+ method: 'version',
24
+ });
25
+
26
+ assert.equal(response.id, 7);
27
+ assert.equal(response.result.name, 'browser-commander');
28
+ });
29
+
30
+ it('maps errors to the contract codes', async () => {
31
+ const handle = handler();
32
+
33
+ assert.deepEqual(await handle({ jsonrpc: '2.0', id: 1, method: 'nope' }), {
34
+ jsonrpc: '2.0',
35
+ id: 1,
36
+ error: { code: -32601, message: 'Unknown method: nope' },
37
+ });
38
+ const invalid = await handle({
39
+ jsonrpc: '2.0',
40
+ id: 2,
41
+ method: 'page.goto',
42
+ });
43
+ assert.equal(invalid.error.code, -32602);
44
+ assert.deepEqual(await handle({ id: 3, method: 'version' }), {
45
+ jsonrpc: '2.0',
46
+ id: 3,
47
+ error: { code: -32600, message: 'Invalid request' },
48
+ });
49
+ const engine = await handle({
50
+ jsonrpc: '2.0',
51
+ id: 4,
52
+ method: 'session.connect',
53
+ params: {},
54
+ });
55
+ assert.equal(engine.error.code, -32602);
56
+ });
57
+
58
+ it('reports engine failures as -32000 with the error name', async () => {
59
+ const { dependencies } = createFakeDependencies();
60
+ dependencies.launchBrowser = async () => {
61
+ throw new TypeError('no browser here');
62
+ };
63
+ const handle = createMessageHandler(createDispatcher({ dependencies }));
64
+
65
+ const response = await handle({
66
+ jsonrpc: '2.0',
67
+ id: 'a',
68
+ method: 'session.launch',
69
+ });
70
+
71
+ assert.equal(response.error.code, -32000);
72
+ assert.equal(response.error.message, 'no browser here');
73
+ assert.equal(response.error.data.name, 'TypeError');
74
+ });
75
+
76
+ it('does not answer notifications and supports batches', async () => {
77
+ const handle = handler();
78
+
79
+ assert.equal(await handle({ jsonrpc: '2.0', method: 'version' }), null);
80
+ const batch = await handle([
81
+ { jsonrpc: '2.0', id: 1, method: 'version' },
82
+ { jsonrpc: '2.0', method: 'version' },
83
+ { jsonrpc: '2.0', id: 2, method: 'nope' },
84
+ ]);
85
+ assert.deepEqual(
86
+ batch.map((response) => response.id),
87
+ [1, 2]
88
+ );
89
+ assert.equal((await handle([])).error.code, -32600);
90
+ });
91
+
92
+ it('answers unparsable lines with -32700 and skips blank ones', async () => {
93
+ const handle = handler();
94
+
95
+ const response = await handleLine('{not json', handle);
96
+
97
+ assert.equal(response.id, null);
98
+ assert.equal(response.error.code, -32700);
99
+ assert.equal(await handleLine(' ', handle), null);
100
+ });
101
+ });
102
+
103
+ /** Start a server on a PassThrough stdin and collect what it writes. */
104
+ function startServer(dependencies, options = {}) {
105
+ const input = new PassThrough();
106
+ const lines = [];
107
+ const served = serveStdio({
108
+ input,
109
+ write: (text) => lines.push(JSON.parse(text)),
110
+ dependencies,
111
+ ...options,
112
+ });
113
+ return { input, lines, served };
114
+ }
115
+
116
+ describe('serveStdio', () => {
117
+ it('serves line-delimited requests and closes sessions when stdin ends', async () => {
118
+ const { dependencies, launches, pages } = createFakeDependencies();
119
+ const { input, lines, served } = startServer(dependencies);
120
+
121
+ const request = (id, method, params) =>
122
+ input.write(
123
+ `${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`
124
+ );
125
+ request(1, 'session.launch', { headless: true });
126
+ await new Promise((resolve) => setTimeout(resolve, 10));
127
+ request(2, 'handle.root', { name: 'session:s1' });
128
+ await new Promise((resolve) => setTimeout(resolve, 10));
129
+ request(3, 'events.subscribe', { handle: 'h3', event: 'console' });
130
+ await new Promise((resolve) => setTimeout(resolve, 10));
131
+ pages[0].emit('console', 'hi');
132
+ request(4, 'bogus');
133
+ input.end();
134
+ await served;
135
+
136
+ const byId = Object.fromEntries(
137
+ lines.filter((line) => 'id' in line).map((line) => [line.id, line])
138
+ );
139
+ assert.equal(byId[1].result.session, 's1');
140
+ assert.equal(byId[2].result.page.$handle, 'h3');
141
+ assert.equal(byId[3].result.subscription, 'e1');
142
+ assert.equal(byId[4].error.code, -32601);
143
+ assert.deepEqual(
144
+ lines.find((line) => line.method === 'events.emit'),
145
+ {
146
+ jsonrpc: '2.0',
147
+ method: 'events.emit',
148
+ params: { subscription: 'e1', args: ['hi'] },
149
+ }
150
+ );
151
+ assert.equal(launches[0].closed, true);
152
+ });
153
+
154
+ it('stops waiting for a stuck request after the drain timeout', async () => {
155
+ const { dependencies } = createFakeDependencies();
156
+ dependencies.packageInfo = () => new Promise(() => {});
157
+ const { input, lines, served } = startServer(dependencies, {
158
+ drainTimeoutMs: 20,
159
+ });
160
+ input.end(
161
+ `${JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'version' })}\n`
162
+ );
163
+ await served;
164
+
165
+ assert.deepEqual(lines, []);
166
+ });
167
+ });