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
@@ -1,7 +1,9 @@
1
+ // feature-parity: migration.launch
1
2
  import assert from 'node:assert';
2
- import { mkdtemp, rm } from 'node:fs/promises';
3
+ import { access, mkdtemp, rm } from 'node:fs/promises';
3
4
  import os from 'node:os';
4
5
  import path from 'node:path';
6
+ import { PassThrough } from 'node:stream';
5
7
  import { afterEach, describe, it } from 'node:test';
6
8
 
7
9
  import {
@@ -10,7 +12,13 @@ import {
10
12
  launchAndConnectRealBrowser,
11
13
  launchAndConnectRealBrowserWithDependencies,
12
14
  launchRealBrowser,
15
+ waitForCdpEndpoint,
13
16
  } from '../../../src/browser/real-browser.js';
17
+ import { PortRaceError } from '../../../src/browser/debugging-port.js';
18
+ import {
19
+ detectAutomationControlledTriggers,
20
+ disablesAutomationControlled,
21
+ } from '../../../src/fingerprint/automation-parity.js';
14
22
  import {
15
23
  launchAndConnectRealBrowser as publicHelper,
16
24
  launchRealBrowser as publicShortHelper,
@@ -32,55 +40,145 @@ describe('launchAndConnectRealBrowser', () => {
32
40
  assert.equal(publicShortHelper, launchRealBrowser);
33
41
  });
34
42
 
35
- it('builds a loopback-only CDP command with a dedicated profile', () => {
43
+ it('builds exactly the command line a person would type', () => {
44
+ // Issue #103: nothing but the dedicated profile and a fixed port.
45
+ assert.deepEqual(
46
+ buildRealBrowserArgs({
47
+ userDataDir: '/tmp/browser-commander-dedicated',
48
+ remoteDebuggingPort: 9333,
49
+ }),
50
+ [
51
+ '--user-data-dir=/tmp/browser-commander-dedicated',
52
+ '--remote-debugging-port=9333',
53
+ 'about:blank',
54
+ ]
55
+ );
56
+ });
57
+
58
+ it('opens a blank tab unless the caller passes a start URL', () => {
59
+ // With no URL, Edge opens edge://welcome-new-profile, which closes the
60
+ // window a few seconds later. Both engines open about:blank the same way.
61
+ assert.deepEqual(
62
+ buildRealBrowserArgs({
63
+ userDataDir: '/tmp/browser-commander-dedicated',
64
+ remoteDebuggingPort: 9333,
65
+ args: ['--lang=en-US', 'https://example.com/'],
66
+ }),
67
+ [
68
+ '--user-data-dir=/tmp/browser-commander-dedicated',
69
+ '--remote-debugging-port=9333',
70
+ '--lang=en-US',
71
+ 'https://example.com/',
72
+ ]
73
+ );
74
+ });
75
+
76
+ it('never turns AutomationControlled on in a headful launch (#101)', () => {
36
77
  const args = buildRealBrowserArgs({
37
78
  userDataDir: '/tmp/browser-commander-dedicated',
38
79
  remoteDebuggingPort: 9333,
39
- headless: true,
80
+ restrictions: ['legacy-defaults', 'no-extensions', 'no-translate'],
40
81
  args: ['--lang=en-US'],
41
82
  });
83
+ assert.deepEqual(detectAutomationControlledTriggers(args), []);
84
+ // ...so the switch that shows the unsupported-flag infobar is not needed.
85
+ assert.equal(disablesAutomationControlled(args), false);
86
+ });
87
+
88
+ it('refuses port 0, which makes navigator.webdriver true', () => {
89
+ assert.throws(
90
+ () =>
91
+ buildRealBrowserArgs({
92
+ userDataDir: '/tmp/browser-commander-dedicated',
93
+ remoteDebuggingPort: 0,
94
+ }),
95
+ /AutomationControlled/
96
+ );
97
+ });
42
98
 
99
+ it('launches headless exactly as a person would, with no off switch', () => {
100
+ // A hand-started headless Chrome reports navigator.webdriver false, so the
101
+ // off switch would only be a command-line difference of its own.
102
+ assert.deepEqual(
103
+ buildRealBrowserArgs({
104
+ userDataDir: '/tmp/browser-commander-dedicated',
105
+ remoteDebuggingPort: 9333,
106
+ headless: true,
107
+ }),
108
+ [
109
+ '--user-data-dir=/tmp/browser-commander-dedicated',
110
+ '--remote-debugging-port=9333',
111
+ '--headless=new',
112
+ 'about:blank',
113
+ ]
114
+ );
115
+ });
116
+
117
+ it('adds the off switch when a custom argument is a trigger', () => {
118
+ const args = buildRealBrowserArgs({
119
+ userDataDir: '/tmp/browser-commander-dedicated',
120
+ remoteDebuggingPort: 9333,
121
+ args: ['--disable-blink-features=Foo', '--enable-automation'],
122
+ });
43
123
  assert.deepEqual(args, [
44
- '--remote-debugging-address=127.0.0.1',
45
- '--remote-debugging-port=9333',
46
124
  '--user-data-dir=/tmp/browser-commander-dedicated',
47
- '--disable-session-crashed-bubble',
48
- '--hide-crash-restore-bubble',
49
- '--disable-infobars',
50
- '--password-store=basic',
51
- '--no-first-run',
52
- '--no-default-browser-check',
53
- '--disable-crash-restore',
54
- '--headless=new',
55
- '--lang=en-US',
125
+ '--remote-debugging-port=9333',
126
+ '--disable-blink-features=Foo,AutomationControlled',
127
+ '--enable-automation',
128
+ 'about:blank',
56
129
  ]);
130
+ assert.equal(
131
+ buildRealBrowserArgs({
132
+ userDataDir: '/tmp/browser-commander-dedicated',
133
+ remoteDebuggingPort: 9333,
134
+ args: ['--enable-automation'],
135
+ automationParity: false,
136
+ }).includes('--disable-blink-features=AutomationControlled'),
137
+ false
138
+ );
57
139
  });
58
140
 
59
- it('supports additive arguments and per-default opt-out', () => {
141
+ it('applies opt-in restrictions and merges feature lists', () => {
60
142
  const args = buildRealBrowserArgs({
61
143
  userDataDir: '/tmp/browser-commander-dedicated',
62
- args: ['--legacy-arg'],
144
+ remoteDebuggingPort: 9333,
145
+ restrictions: ['no-sync', 'no-translate'],
146
+ args: ['--legacy-arg', '--disable-features=Foo'],
63
147
  extraArgs: ['--lang=en-US'],
64
- ignoreDefaultArgs: ['--no-first-run'],
65
148
  });
66
149
 
67
- assert.ok(args.includes('--password-store=basic'));
68
- assert.equal(args.includes('--no-first-run'), false);
69
- assert.ok(args.includes('--no-default-browser-check'));
70
- assert.deepEqual(args.slice(-2), ['--legacy-arg', '--lang=en-US']);
150
+ assert.deepEqual(args, [
151
+ '--user-data-dir=/tmp/browser-commander-dedicated',
152
+ '--remote-debugging-port=9333',
153
+ '--disable-sync',
154
+ '--disable-features=Translate,Foo',
155
+ '--legacy-arg',
156
+ '--lang=en-US',
157
+ 'about:blank',
158
+ ]);
159
+ assert.throws(
160
+ () =>
161
+ buildRealBrowserArgs({
162
+ userDataDir: '/tmp/x',
163
+ remoteDebuggingPort: 9333,
164
+ restrictions: ['no-such-thing'],
165
+ }),
166
+ /Unknown launch restriction/
167
+ );
71
168
  });
72
169
 
73
170
  it('rejects custom arguments that could bypass protected CDP settings', () => {
74
171
  for (const argument of [
75
172
  '--remote-debugging-address=0.0.0.0',
76
173
  '--remote-debugging-port=9222',
174
+ '--remote-debugging-pipe',
77
175
  '--user-data-dir=/tmp/other',
78
176
  ]) {
79
177
  assert.throws(
80
178
  () =>
81
179
  buildRealBrowserArgs({
82
180
  userDataDir: '/tmp/browser-commander-dedicated',
83
- remoteDebuggingPort: 0,
181
+ remoteDebuggingPort: 9333,
84
182
  args: [argument],
85
183
  }),
86
184
  /managed by launchAndConnectRealBrowser/
@@ -106,11 +204,9 @@ describe('launchAndConnectRealBrowser', () => {
106
204
  });
107
205
 
108
206
  it('spawns, waits, connects, and returns process metadata', async () => {
109
- temporaryDirectory = await mkdtemp(
110
- path.join(os.tmpdir(), 'browser-commander-real-browser-test-')
111
- );
207
+ temporaryDirectory = await makeDedicatedProfile();
112
208
  const calls = [];
113
- const browserProcess = { exitCode: null, kill: () => calls.push(['kill']) };
209
+ const browserProcess = fakeProcess(calls);
114
210
  const browser = { id: 'browser' };
115
211
  const page = { id: 'page' };
116
212
 
@@ -119,13 +215,13 @@ describe('launchAndConnectRealBrowser', () => {
119
215
  engine: 'puppeteer',
120
216
  channel: 'chrome',
121
217
  userDataDir: temporaryDirectory,
122
- remoteDebuggingPort: 0,
123
218
  seedCookies: [{ name: 'SID', value: 'saved' }],
124
219
  },
125
220
  {
126
221
  resolveExecutable: async () => '/opt/google/chrome',
127
- spawnBrowser: (executablePath, args) => {
128
- calls.push(['spawn', executablePath, args]);
222
+ reservePort: async () => 9444,
223
+ spawnBrowser: (executablePath, args, options) => {
224
+ calls.push(['spawn', executablePath, args, options]);
129
225
  return browserProcess;
130
226
  },
131
227
  waitForEndpoint: async (options) => {
@@ -143,9 +239,21 @@ describe('launchAndConnectRealBrowser', () => {
143
239
  assert.equal(result.page, page);
144
240
  assert.equal(result.browserProcess, browserProcess);
145
241
  assert.equal(result.cdpEndpoint, 'http://127.0.0.1:9444');
242
+ assert.equal(result.remoteDebuggingPort, 9444);
146
243
  assert.equal(result.executablePath, '/opt/google/chrome');
147
244
  assert.equal(result.userDataDir, temporaryDirectory);
148
- assert.deepEqual(calls[1], ['wait', 0]);
245
+ assert.equal(result.temporaryProfile, false);
246
+ assert.deepEqual(calls[0], [
247
+ 'spawn',
248
+ '/opt/google/chrome',
249
+ [
250
+ `--user-data-dir=${temporaryDirectory}`,
251
+ '--remote-debugging-port=9444',
252
+ 'about:blank',
253
+ ],
254
+ { env: undefined, verbose: false },
255
+ ]);
256
+ assert.deepEqual(calls[1], ['wait', 9444]);
149
257
  assert.deepEqual(calls[2], [
150
258
  'connect',
151
259
  {
@@ -155,12 +263,213 @@ describe('launchAndConnectRealBrowser', () => {
155
263
  verbose: false,
156
264
  },
157
265
  ]);
266
+ // First-run UI is suppressed with Chrome's own sentinel, not a switch.
267
+ await access(path.join(temporaryDirectory, 'First Run'));
158
268
  });
159
269
 
160
- it('terminates the spawned browser when connection fails', async () => {
161
- temporaryDirectory = await mkdtemp(
162
- path.join(os.tmpdir(), 'browser-commander-real-browser-test-')
270
+ it('migrates a profile before launch and seeds the migrated cookies', async () => {
271
+ temporaryDirectory = await makeDedicatedProfile();
272
+ const calls = [];
273
+ const browserProcess = fakeProcess(calls);
274
+ let connectOptions;
275
+ let migrateOptions;
276
+
277
+ const result = await launchAndConnectRealBrowserWithDependencies(
278
+ {
279
+ engine: 'puppeteer',
280
+ channel: 'chrome',
281
+ userDataDir: temporaryDirectory,
282
+ seedCookies: [{ name: 'existing', value: 'keep' }],
283
+ migrateFrom: {
284
+ browser: 'chrome',
285
+ profile: 'Default',
286
+ include: ['cookies', 'bookmarks'],
287
+ domains: ['google.com'],
288
+ },
289
+ },
290
+ {
291
+ resolveExecutable: async () => '/opt/google/chrome',
292
+ reservePort: async () => 9445,
293
+ spawnBrowser: () => browserProcess,
294
+ waitForEndpoint: async () => 'http://127.0.0.1:9445',
295
+ connect: async (options) => {
296
+ connectOptions = options;
297
+ return { browser: {}, page: {} };
298
+ },
299
+ migrateProfile: async (options) => {
300
+ migrateOptions = options;
301
+ return {
302
+ source: {
303
+ browser: 'chrome',
304
+ profile: 'Default',
305
+ userDataDir: null,
306
+ },
307
+ target: options.to,
308
+ migrated: {
309
+ cookies: 1,
310
+ bookmarks: 2,
311
+ history: 0,
312
+ passwords: 0,
313
+ preferences: 0,
314
+ extensions: 0,
315
+ },
316
+ skipped: [],
317
+ warnings: [],
318
+ cookies: [
319
+ { name: 'SID', value: 'migrated', domain: '.google.com' },
320
+ ],
321
+ };
322
+ },
323
+ }
324
+ );
325
+
326
+ // Migration runs before launch, targeting the Default profile directory,
327
+ // and receives the launching channel as the target browser for key
328
+ // derivation.
329
+ assert.equal(migrateOptions.to, path.join(temporaryDirectory, 'Default'));
330
+ assert.equal(migrateOptions.targetBrowser, 'chrome');
331
+ assert.deepEqual(migrateOptions.include, ['cookies', 'bookmarks']);
332
+ assert.deepEqual(migrateOptions.domains, ['google.com']);
333
+ assert.deepEqual(migrateOptions.from, {
334
+ browser: 'chrome',
335
+ profile: 'Default',
336
+ });
337
+
338
+ // Migrated cookies are appended to any explicit seedCookies.
339
+ assert.deepEqual(connectOptions.seedCookies, [
340
+ { name: 'existing', value: 'keep' },
341
+ { name: 'SID', value: 'migrated', domain: '.google.com' },
342
+ ]);
343
+
344
+ // The session exposes the report without the bulky raw cookie array.
345
+ assert.equal(result.migration.migrated.bookmarks, 2);
346
+ assert.equal(result.migration.migrated.cookies, 1);
347
+ assert.equal(result.migration.cookies, undefined);
348
+ });
349
+
350
+ it('retries with a new reserved port after a port race', async () => {
351
+ const calls = [];
352
+ const ports = [40001, 40002];
353
+ const result = await launchAndConnectRealBrowserWithDependencies(
354
+ { closeTimeout: 50 },
355
+ {
356
+ resolveExecutable: async () => '/opt/google/chrome',
357
+ reservePort: async () => ports.shift(),
358
+ spawnBrowser: () => fakeProcess(calls),
359
+ waitForEndpoint: async ({ remoteDebuggingPort }) => {
360
+ calls.push(['wait', remoteDebuggingPort]);
361
+ if (remoteDebuggingPort === 40001) {
362
+ throw new PortRaceError(40001, 'taken');
363
+ }
364
+ return `http://127.0.0.1:${remoteDebuggingPort}`;
365
+ },
366
+ connect: async () => ({ browser: null, page: null }),
367
+ }
368
+ );
369
+ assert.deepEqual(calls, [['wait', 40001], ['kill'], ['wait', 40002]]);
370
+ assert.equal(result.remoteDebuggingPort, 40002);
371
+ await result.close();
372
+ });
373
+
374
+ it('does not retry a port the caller chose', async () => {
375
+ let attempts = 0;
376
+ await assert.rejects(
377
+ () =>
378
+ launchAndConnectRealBrowserWithDependencies(
379
+ { remoteDebuggingPort: 9555 },
380
+ {
381
+ resolveExecutable: async () => '/opt/google/chrome',
382
+ spawnBrowser: () => fakeProcess([]),
383
+ waitForEndpoint: async () => {
384
+ attempts++;
385
+ throw new PortRaceError(9555, 'taken');
386
+ },
387
+ }
388
+ ),
389
+ PortRaceError
390
+ );
391
+ assert.equal(attempts, 1);
392
+ });
393
+
394
+ it('uses a fresh temporary profile by default and deletes it on close', async () => {
395
+ const calls = [];
396
+ const browserProcess = fakeProcess(calls);
397
+ const result = await launchAndConnectRealBrowserWithDependencies(
398
+ { closeTimeout: 50 },
399
+ {
400
+ resolveExecutable: async () => '/opt/google/chrome',
401
+ reservePort: async () => 40003,
402
+ spawnBrowser: () => browserProcess,
403
+ waitForEndpoint: async () => 'http://127.0.0.1:40003',
404
+ connect: async () => ({ browser: null, page: null }),
405
+ }
406
+ );
407
+ assert.equal(result.temporaryProfile, true);
408
+ assert.match(
409
+ path.basename(result.userDataDir),
410
+ /^browser-commander-profile-/
411
+ );
412
+ await access(path.join(result.userDataDir, 'First Run'));
413
+
414
+ await result.close();
415
+ assert.deepEqual(calls, [['kill']]);
416
+ await assert.rejects(() => access(result.userDataDir), /ENOENT/);
417
+ });
418
+
419
+ it('closes the browser gracefully through the engine before killing it', async () => {
420
+ const calls = [];
421
+ const browserProcess = fakeProcess(calls);
422
+ const browser = {
423
+ close: async () => {
424
+ calls.push(['browser.close']);
425
+ browserProcess.exit(0);
426
+ },
427
+ };
428
+ const result = await launchAndConnectRealBrowserWithDependencies(
429
+ { engine: 'puppeteer' },
430
+ {
431
+ resolveExecutable: async () => '/opt/google/chrome',
432
+ reservePort: async () => 40004,
433
+ spawnBrowser: () => browserProcess,
434
+ waitForEndpoint: async () => 'http://127.0.0.1:40004',
435
+ connect: async () => ({ browser, page: null }),
436
+ }
437
+ );
438
+ // browser.close() is the session's close(), so it cleans up too.
439
+ await browser.close();
440
+ assert.deepEqual(calls, [['browser.close']]);
441
+ await assert.rejects(() => access(result.userDataDir), /ENOENT/);
442
+ });
443
+
444
+ it('passes restriction environment to the browser only', async () => {
445
+ const before = process.env.GOOGLE_API_KEY;
446
+ let spawnOptions;
447
+ const result = await launchAndConnectRealBrowserWithDependencies(
448
+ {
449
+ restrictions: ['no-google-services'],
450
+ env: { EXTRA: '1' },
451
+ closeTimeout: 50,
452
+ },
453
+ {
454
+ resolveExecutable: async () => '/opt/google/chrome',
455
+ reservePort: async () => 40005,
456
+ spawnBrowser: (executablePath, args, options) => {
457
+ spawnOptions = options;
458
+ return fakeProcess([]);
459
+ },
460
+ waitForEndpoint: async () => 'http://127.0.0.1:40005',
461
+ connect: async () => ({ browser: null, page: null }),
462
+ }
163
463
  );
464
+ assert.equal(spawnOptions.env.GOOGLE_API_KEY, 'no');
465
+ assert.equal(spawnOptions.env.GOOGLE_DEFAULT_CLIENT_ID, 'no');
466
+ assert.equal(spawnOptions.env.EXTRA, '1');
467
+ assert.equal(process.env.GOOGLE_API_KEY, before);
468
+ await result.close();
469
+ });
470
+
471
+ it('terminates the spawned browser when connection fails', async () => {
472
+ temporaryDirectory = await makeDedicatedProfile();
164
473
  let killed = false;
165
474
  const browserProcess = {
166
475
  exitCode: null,
@@ -175,6 +484,7 @@ describe('launchAndConnectRealBrowser', () => {
175
484
  { userDataDir: temporaryDirectory },
176
485
  {
177
486
  resolveExecutable: async () => '/opt/google/chrome',
487
+ reservePort: async () => 9222,
178
488
  spawnBrowser: () => browserProcess,
179
489
  waitForEndpoint: async () => 'http://127.0.0.1:9222',
180
490
  connect: async () => {
@@ -188,3 +498,118 @@ describe('launchAndConnectRealBrowser', () => {
188
498
  assert.equal(killed, true);
189
499
  });
190
500
  });
501
+
502
+ describe('waitForCdpEndpoint', () => {
503
+ const listening = (port, id = 'abc') =>
504
+ `DevTools listening on ws://127.0.0.1:${port}/devtools/browser/${id}\n`;
505
+
506
+ function stderrWith(text) {
507
+ const stream = new PassThrough();
508
+ stream.write(text);
509
+ return stream;
510
+ }
511
+
512
+ it('confirms ownership from the stderr line and the served browser id', async () => {
513
+ const endpoint = await waitForCdpEndpoint({
514
+ remoteDebuggingPort: 9777,
515
+ userDataDir: '/nonexistent',
516
+ browserProcess: { exitCode: null, stderr: stderrWith(listening(9777)) },
517
+ timeout: 2000,
518
+ fetchImplementation: async (url) => {
519
+ assert.equal(url, 'http://127.0.0.1:9777/json/version');
520
+ return {
521
+ ok: true,
522
+ json: async () => ({
523
+ webSocketDebuggerUrl: 'ws://127.0.0.1:9777/devtools/browser/abc',
524
+ }),
525
+ };
526
+ },
527
+ });
528
+ assert.equal(endpoint, 'http://127.0.0.1:9777');
529
+ });
530
+
531
+ const waitWith = (stderr, fetchImplementation, timeout = 2000) =>
532
+ waitForCdpEndpoint({
533
+ remoteDebuggingPort: 9777,
534
+ userDataDir: '/nonexistent',
535
+ browserProcess: { exitCode: null, stderr },
536
+ timeout,
537
+ fetchImplementation,
538
+ });
539
+
540
+ it('reports a race when another browser answers on the port', async () => {
541
+ await assert.rejects(
542
+ () =>
543
+ waitWith(stderrWith(listening(9777, 'ours')), async () => ({
544
+ ok: true,
545
+ json: async () => ({
546
+ webSocketDebuggerUrl:
547
+ 'ws://127.0.0.1:9777/devtools/browser/somebody-else',
548
+ }),
549
+ })),
550
+ PortRaceError
551
+ );
552
+ });
553
+
554
+ it('reports a race when Chrome fell back to another address', async () => {
555
+ const stderr = stderrWith(
556
+ 'bind() failed: Address already in use (98)\nDevTools listening on ws://[::1]:9777/devtools/browser/x\n'
557
+ );
558
+ await assert.rejects(
559
+ () =>
560
+ waitWith(stderr, async () => {
561
+ throw new Error('must not probe a port we do not own');
562
+ }),
563
+ PortRaceError
564
+ );
565
+ });
566
+
567
+ it('never probes the port before the browser claims it', async () => {
568
+ let probes = 0;
569
+ await assert.rejects(
570
+ () =>
571
+ waitWith(
572
+ new PassThrough(),
573
+ async () => {
574
+ probes++;
575
+ return { ok: false };
576
+ },
577
+ 300
578
+ ),
579
+ /Timed out/
580
+ );
581
+ assert.equal(probes, 0);
582
+ });
583
+ });
584
+
585
+ // A dedicated user-data directory; the suite's afterEach removes it.
586
+ function makeDedicatedProfile() {
587
+ return mkdtemp(
588
+ path.join(os.tmpdir(), 'browser-commander-real-browser-test-')
589
+ );
590
+ }
591
+
592
+ function fakeProcess(calls) {
593
+ const listeners = [];
594
+ const process = {
595
+ exitCode: null,
596
+ kill: () => {
597
+ calls.push(['kill']);
598
+ process.exit(143);
599
+ },
600
+ once: (event, listener) => {
601
+ if (process.exitCode === null) {
602
+ listeners.push(listener);
603
+ } else {
604
+ listener(process.exitCode);
605
+ }
606
+ },
607
+ exit: (code) => {
608
+ process.exitCode = code;
609
+ for (const listener of listeners.splice(0)) {
610
+ listener(code);
611
+ }
612
+ },
613
+ };
614
+ return process;
615
+ }