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
@@ -1,259 +1,108 @@
1
- import { spawn } from 'node:child_process';
2
- import { constants } from 'node:fs';
3
- import { access, mkdir, readFile } from 'node:fs/promises';
4
- import os from 'node:os';
1
+ import { readFile } from 'node:fs/promises';
5
2
  import path from 'node:path';
6
3
 
4
+ import { startProcess } from '../utilities/subprocess.js';
5
+ import {
6
+ applyAutomationParityArgs,
7
+ detectAutomationControlledTriggers,
8
+ } from '../fingerprint/automation-parity.js';
9
+ import {
10
+ prepareSnapshotLaunch,
11
+ validateAttachOption,
12
+ } from './attach/snapshot-launch.js';
7
13
  import { connectBrowser } from './connector.js';
8
- import { resolveChromeArgs } from './launch-options.js';
14
+ import { migrateProfile } from './migration/index.js';
15
+ import {
16
+ assertFixedDebuggingPort,
17
+ classifyDevToolsOwnership,
18
+ LOOPBACK_HOST,
19
+ PortRaceError,
20
+ reserveLoopbackPort,
21
+ watchDevToolsOutput,
22
+ } from './debugging-port.js';
23
+ import {
24
+ createTemporaryUserDataDir,
25
+ prepareUserDataDir,
26
+ removeUserDataDir,
27
+ } from './profile-directory.js';
28
+ import {
29
+ assertStringArray,
30
+ mergeFeatureSwitches,
31
+ resolveRestrictions,
32
+ } from './restrictions.js';
33
+ import {
34
+ assertDedicatedUserDataDir,
35
+ defaultUserDataDir,
36
+ knownDefaultUserDataDirs,
37
+ resolveSystemBrowserExecutable,
38
+ } from './system-browser.js';
9
39
 
40
+ /**
41
+ * Switches the launcher owns. Letting a caller pass them would break the
42
+ * guarantees of this path: DevTools bound to loopback only, a dedicated
43
+ * profile, and no switch that turns AutomationControlled on (issue #101).
44
+ */
10
45
  const MANAGED_ARGUMENTS = [
11
46
  '--remote-debugging-address',
12
47
  '--remote-debugging-port',
48
+ '--remote-debugging-pipe',
13
49
  '--user-data-dir',
14
50
  ];
15
51
 
16
- const CHANNEL_EXECUTABLE_NAMES = {
17
- brave: ['brave-browser', 'brave-browser-stable', 'brave'],
18
- chrome: ['google-chrome', 'google-chrome-stable', 'chrome'],
19
- 'chrome-beta': ['google-chrome-beta'],
20
- 'chrome-canary': ['google-chrome-canary'],
21
- 'chrome-dev': ['google-chrome-unstable'],
22
- chromium: ['chromium', 'chromium-browser'],
23
- msedge: ['microsoft-edge', 'microsoft-edge-stable', 'msedge'],
24
- 'msedge-beta': ['microsoft-edge-beta'],
25
- 'msedge-canary': ['microsoft-edge-canary'],
26
- 'msedge-dev': ['microsoft-edge-dev'],
27
- };
28
-
29
- function platformPath(platform) {
30
- return platform === 'win32' ? path.win32 : path;
31
- }
32
-
33
- function defaultUserDataDir(channel, homeDir = os.homedir()) {
34
- const directoryName = channel.replace(/[^a-z0-9_.-]/gi, '-');
35
- return path.join(
36
- homeDir,
37
- '.browser-commander',
38
- 'real-browser',
39
- directoryName
40
- );
41
- }
42
-
43
- function knownDefaultUserDataDirs({
44
- platform = process.platform,
45
- homeDir = os.homedir(),
46
- environment = process.env,
47
- } = {}) {
48
- const pathApi = platformPath(platform);
49
- if (platform === 'darwin') {
50
- const applicationSupport = pathApi.join(
51
- homeDir,
52
- 'Library',
53
- 'Application Support'
54
- );
55
- return [
56
- pathApi.join(applicationSupport, 'Google', 'Chrome'),
57
- pathApi.join(applicationSupport, 'Google', 'Chrome Beta'),
58
- pathApi.join(applicationSupport, 'Google', 'Chrome Canary'),
59
- pathApi.join(applicationSupport, 'Google', 'Chrome Dev'),
60
- pathApi.join(applicationSupport, 'Chromium'),
61
- pathApi.join(applicationSupport, 'BraveSoftware', 'Brave-Browser'),
62
- pathApi.join(applicationSupport, 'BraveSoftware', 'Brave-Browser-Beta'),
63
- pathApi.join(
64
- applicationSupport,
65
- 'BraveSoftware',
66
- 'Brave-Browser-Nightly'
67
- ),
68
- pathApi.join(applicationSupport, 'Microsoft Edge'),
69
- pathApi.join(applicationSupport, 'Microsoft Edge Beta'),
70
- pathApi.join(applicationSupport, 'Microsoft Edge Canary'),
71
- pathApi.join(applicationSupport, 'Microsoft Edge Dev'),
72
- ];
73
- }
74
- if (platform === 'win32') {
75
- const localAppData =
76
- environment.LOCALAPPDATA ?? pathApi.join(homeDir, 'AppData', 'Local');
77
- return [
78
- pathApi.join(localAppData, 'Google', 'Chrome', 'User Data'),
79
- pathApi.join(localAppData, 'Google', 'Chrome Beta', 'User Data'),
80
- pathApi.join(localAppData, 'Google', 'Chrome Dev', 'User Data'),
81
- pathApi.join(localAppData, 'Google', 'Chrome SxS', 'User Data'),
82
- pathApi.join(localAppData, 'Chromium', 'User Data'),
83
- pathApi.join(localAppData, 'BraveSoftware', 'Brave-Browser', 'User Data'),
84
- pathApi.join(
85
- localAppData,
86
- 'BraveSoftware',
87
- 'Brave-Browser-Beta',
88
- 'User Data'
89
- ),
90
- pathApi.join(
91
- localAppData,
92
- 'BraveSoftware',
93
- 'Brave-Browser-Nightly',
94
- 'User Data'
95
- ),
96
- pathApi.join(localAppData, 'Microsoft', 'Edge', 'User Data'),
97
- pathApi.join(localAppData, 'Microsoft', 'Edge Beta', 'User Data'),
98
- pathApi.join(localAppData, 'Microsoft', 'Edge Dev', 'User Data'),
99
- pathApi.join(localAppData, 'Microsoft', 'Edge SxS', 'User Data'),
100
- ];
101
- }
102
- return [
103
- pathApi.join(homeDir, '.config', 'google-chrome'),
104
- pathApi.join(homeDir, '.config', 'google-chrome-beta'),
105
- pathApi.join(homeDir, '.config', 'google-chrome-unstable'),
106
- pathApi.join(homeDir, '.config', 'chromium'),
107
- pathApi.join(homeDir, '.config', 'BraveSoftware', 'Brave-Browser'),
108
- pathApi.join(homeDir, '.config', 'BraveSoftware', 'Brave-Browser-Beta'),
109
- pathApi.join(homeDir, '.config', 'BraveSoftware', 'Brave-Browser-Nightly'),
110
- pathApi.join(homeDir, '.config', 'microsoft-edge'),
111
- pathApi.join(homeDir, '.config', 'microsoft-edge-beta'),
112
- pathApi.join(homeDir, '.config', 'microsoft-edge-dev'),
113
- ];
114
- }
115
-
116
- /** Ensure Chrome is not asked to expose the user's default profile over CDP. */
117
- export function assertDedicatedUserDataDir(userDataDir, platformOptions = {}) {
118
- if (!userDataDir) {
119
- throw new Error('launchAndConnectRealBrowser requires a userDataDir');
120
- }
121
- const platform = platformOptions.platform ?? process.platform;
122
- const pathApi = platformPath(platform);
123
- const normalize = (value) => {
124
- const resolved = pathApi.resolve(value).replace(/[\\/]+$/, '');
125
- return platform === 'win32' ? resolved.toLowerCase() : resolved;
126
- };
127
- const requested = normalize(userDataDir);
128
- const isDefault = knownDefaultUserDataDirs(platformOptions).some(
129
- (directory) => normalize(directory) === requested
130
- );
131
- if (isDefault) {
132
- throw new Error(
133
- 'launchAndConnectRealBrowser requires a dedicated userDataDir, not a browser default profile'
134
- );
135
- }
136
- }
137
-
138
- function browserInstallCandidates({
139
- channel,
140
- platform = process.platform,
141
- environment = process.env,
142
- }) {
143
- const candidates = [];
144
- const names = CHANNEL_EXECUTABLE_NAMES[channel];
145
- if (!names) {
146
- throw new Error(
147
- `Unknown browser channel: ${channel}. Expected one of ${Object.keys(CHANNEL_EXECUTABLE_NAMES).join(', ')}`
148
- );
149
- }
150
-
151
- if (platform === 'darwin') {
152
- const applications = {
153
- brave: 'Brave Browser.app/Contents/MacOS/Brave Browser',
154
- chrome: 'Google Chrome.app/Contents/MacOS/Google Chrome',
155
- 'chrome-beta': 'Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta',
156
- 'chrome-canary':
157
- 'Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
158
- 'chrome-dev': 'Google Chrome Dev.app/Contents/MacOS/Google Chrome Dev',
159
- chromium: 'Chromium.app/Contents/MacOS/Chromium',
160
- msedge: 'Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
161
- 'msedge-beta':
162
- 'Microsoft Edge Beta.app/Contents/MacOS/Microsoft Edge Beta',
163
- 'msedge-canary':
164
- 'Microsoft Edge Canary.app/Contents/MacOS/Microsoft Edge Canary',
165
- 'msedge-dev': 'Microsoft Edge Dev.app/Contents/MacOS/Microsoft Edge Dev',
166
- };
167
- candidates.push(path.join('/Applications', applications[channel]));
168
- } else if (platform === 'win32') {
169
- const roots = [
170
- environment.PROGRAMFILES,
171
- environment['PROGRAMFILES(X86)'],
172
- environment.LOCALAPPDATA,
173
- ].filter(Boolean);
174
- const relativePaths = {
175
- brave: ['BraveSoftware', 'Brave-Browser', 'Application', 'brave.exe'],
176
- chrome: ['Google', 'Chrome', 'Application', 'chrome.exe'],
177
- 'chrome-beta': ['Google', 'Chrome Beta', 'Application', 'chrome.exe'],
178
- 'chrome-canary': ['Google', 'Chrome SxS', 'Application', 'chrome.exe'],
179
- 'chrome-dev': ['Google', 'Chrome Dev', 'Application', 'chrome.exe'],
180
- chromium: ['Chromium', 'Application', 'chrome.exe'],
181
- msedge: ['Microsoft', 'Edge', 'Application', 'msedge.exe'],
182
- 'msedge-beta': ['Microsoft', 'Edge Beta', 'Application', 'msedge.exe'],
183
- 'msedge-canary': ['Microsoft', 'Edge SxS', 'Application', 'msedge.exe'],
184
- 'msedge-dev': ['Microsoft', 'Edge Dev', 'Application', 'msedge.exe'],
185
- };
186
- for (const root of roots) {
187
- candidates.push(path.win32.join(root, ...relativePaths[channel]));
188
- }
189
- } else {
190
- for (const name of names) {
191
- candidates.push(`/usr/bin/${name}`, `/usr/local/bin/${name}`);
192
- }
193
- if (channel === 'chrome') {
194
- candidates.push('/opt/google/chrome/google-chrome');
195
- }
196
- }
197
-
198
- const pathApi = platformPath(platform);
199
- const delimiter = platform === 'win32' ? ';' : path.delimiter;
200
- for (const directory of (environment.PATH ?? '').split(delimiter)) {
201
- if (!directory) {
202
- continue;
203
- }
204
- for (const name of names) {
205
- candidates.push(
206
- pathApi.join(directory, platform === 'win32' ? `${name}.exe` : name)
207
- );
208
- }
209
- }
210
- return [...new Set(candidates)];
211
- }
212
-
213
- /** Resolve a genuine installed Chrome-family browser executable. */
214
- export async function resolveSystemBrowserExecutable({
215
- channel = 'chrome',
216
- executablePath,
217
- platform = process.platform,
218
- environment = process.env,
219
- } = {}) {
220
- const candidates = executablePath
221
- ? [path.resolve(executablePath)]
222
- : browserInstallCandidates({ channel, platform, environment });
223
- for (const candidate of candidates) {
224
- try {
225
- await access(candidate, constants.X_OK);
226
- return candidate;
227
- } catch {
228
- // Continue through known locations and PATH entries.
229
- }
230
- }
231
- throw new Error(
232
- executablePath
233
- ? `Browser executable is not accessible: ${executablePath}`
234
- : `Could not find an installed ${channel} browser; provide executablePath`
235
- );
236
- }
52
+ /**
53
+ * The page a real launch opens when the caller passes no start URL.
54
+ *
55
+ * With no URL at all a browser opens its start pages instead: Chrome the New
56
+ * Tab page, Edge the MSN New Tab page and edge://welcome-new-profile, whose
57
+ * flow closes the window - and so ends the browser - a few seconds after the
58
+ * launch. Playwright and Puppeteer open about:blank for the same reason. The
59
+ * URL is not a switch, and a page cannot see how its tab was first opened.
60
+ */
61
+ const START_URL = 'about:blank';
237
62
 
238
- /** Build the protected command line used for a real browser CDP process. */
63
+ /**
64
+ * Build the command line for a real browser.
65
+ *
66
+ * By default it is exactly what a person would type to allow a debugger:
67
+ * `--user-data-dir=<dir> --remote-debugging-port=<port> about:blank` (issue
68
+ * #103), and a start URL among the custom `args` replaces `about:blank`. The
69
+ * fixed port keeps `navigator.webdriver` false without any extra switch
70
+ * (issue #101). Everything else is opt-in: `headless`, named `restrictions`
71
+ * and custom `args`.
72
+ *
73
+ * Chrome's DevTools server binds to loopback by default, so no
74
+ * `--remote-debugging-address` is passed; the switch stays managed so custom
75
+ * arguments cannot expose DevTools on another interface.
76
+ *
77
+ * `--headless` is not an AutomationControlled trigger (a hand-started headless
78
+ * Chrome reports `navigator.webdriver === false`), so a headless launch gets
79
+ * no off switch either. Only when a custom argument is a trigger is the off
80
+ * switch added, unless `automationParity` is false.
81
+ *
82
+ * @param {Object} options
83
+ * @param {string} options.userDataDir - Dedicated profile directory
84
+ * @param {number} options.remoteDebuggingPort - Fixed loopback port, 1-65535
85
+ * @param {boolean} [options.headless=false]
86
+ * @param {string[]} [options.restrictions] - Names from launch-restrictions.json
87
+ * @param {string[]} [options.args] - Additional switches
88
+ * @param {string[]} [options.extraArgs] - Additional switches appended after args
89
+ * @param {boolean} [options.automationParity=true]
90
+ * @returns {string[]}
91
+ */
239
92
  export function buildRealBrowserArgs({
240
93
  userDataDir,
241
- remoteDebuggingPort = 0,
94
+ remoteDebuggingPort,
242
95
  headless = false,
96
+ restrictions = [],
243
97
  args = [],
244
98
  extraArgs = [],
245
- ignoreDefaultArgs = [],
99
+ automationParity = true,
246
100
  }) {
247
- if (
248
- !Number.isInteger(remoteDebuggingPort) ||
249
- remoteDebuggingPort < 0 ||
250
- remoteDebuggingPort > 65_535
251
- ) {
252
- throw new RangeError(
253
- 'remoteDebuggingPort must be an integer from 0 to 65535'
254
- );
255
- }
256
- const customArgs = [...args, ...extraArgs];
101
+ assertFixedDebuggingPort(remoteDebuggingPort);
102
+ const customArgs = [
103
+ ...assertStringArray(args, 'args'),
104
+ ...assertStringArray(extraArgs, 'extraArgs'),
105
+ ];
257
106
  const conflictingArgument = customArgs.find((argument) =>
258
107
  MANAGED_ARGUMENTS.some(
259
108
  (managed) => argument === managed || argument.startsWith(`${managed}=`)
@@ -265,16 +114,23 @@ export function buildRealBrowserArgs({
265
114
  );
266
115
  }
267
116
 
268
- const defaultArgs = resolveChromeArgs({ ignoreDefaultArgs }).args;
269
-
270
- return [
271
- '--remote-debugging-address=127.0.0.1',
272
- `--remote-debugging-port=${remoteDebuggingPort}`,
117
+ let browserArgs = mergeFeatureSwitches([
273
118
  `--user-data-dir=${userDataDir}`,
274
- ...defaultArgs,
119
+ `--remote-debugging-port=${remoteDebuggingPort}`,
275
120
  ...(headless ? ['--headless=new'] : []),
121
+ ...resolveRestrictions(restrictions).args,
276
122
  ...customArgs,
277
- ];
123
+ ]);
124
+ if (
125
+ automationParity &&
126
+ detectAutomationControlledTriggers(browserArgs).length > 0
127
+ ) {
128
+ browserArgs = applyAutomationParityArgs(browserArgs);
129
+ }
130
+ if (browserArgs.every((argument) => argument.startsWith('-'))) {
131
+ browserArgs.push(START_URL);
132
+ }
133
+ return browserArgs;
278
134
  }
279
135
 
280
136
  async function fetchCdpVersion(endpoint, fetchImplementation, timeout) {
@@ -285,85 +141,198 @@ async function fetchCdpVersion(endpoint, fetchImplementation, timeout) {
285
141
  signal: controller.signal,
286
142
  });
287
143
  if (!response.ok) {
288
- return false;
144
+ return null;
289
145
  }
290
146
  const version = await response.json();
291
- return Boolean(version.webSocketDebuggerUrl);
147
+ return version.webSocketDebuggerUrl ? version : null;
292
148
  } finally {
293
149
  clearTimeout(timer);
294
150
  }
295
151
  }
296
152
 
297
- /** Wait until Chrome publishes a usable DevTools endpoint. */
153
+ async function readDevToolsActivePort(userDataDir) {
154
+ try {
155
+ const content = await readFile(
156
+ path.join(userDataDir, 'DevToolsActivePort'),
157
+ 'utf8'
158
+ );
159
+ const [port, browserPath] = content.split(/\r?\n/);
160
+ return { port: Number.parseInt(port, 10), browserPath };
161
+ } catch {
162
+ return null;
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Wait until the browser we spawned publishes its DevTools endpoint on the
168
+ * reserved port, and prove that the endpoint belongs to it.
169
+ *
170
+ * Proof comes from the browser's own stderr (`DevTools listening on
171
+ * ws://127.0.0.1:<port>/devtools/browser/<id>`), whose browser id must match
172
+ * the `webSocketDebuggerUrl` served on the port. Chrome writes
173
+ * `DevToolsActivePort` only for port 0, so the file is accepted as proof when
174
+ * present (for callers that bring their own spawn) but never required.
175
+ *
176
+ * @throws {PortRaceError} When another process holds the port
177
+ */
298
178
  export async function waitForCdpEndpoint({
299
179
  remoteDebuggingPort,
300
180
  userDataDir,
301
181
  browserProcess,
182
+ devToolsOutput = watchDevToolsOutput(browserProcess?.stderr),
302
183
  timeout = 30_000,
303
184
  fetchImplementation = globalThis.fetch,
304
185
  }) {
186
+ const port = assertFixedDebuggingPort(remoteDebuggingPort);
187
+ const endpoint = `http://${LOOPBACK_HOST}:${port}`;
305
188
  const deadline = Date.now() + timeout;
306
189
  while (Date.now() < deadline) {
190
+ const output = devToolsOutput.state();
191
+ let ownership = devToolsOutput.available
192
+ ? classifyDevToolsOwnership(output, port)
193
+ : 'unknown';
194
+ if (ownership === 'race') {
195
+ throw new PortRaceError(port, output.listening?.url ?? 'bind failed');
196
+ }
307
197
  if (browserProcess.exitCode !== null) {
308
198
  throw new Error(
309
199
  `Browser exited before its DevTools endpoint was ready (exit ${browserProcess.exitCode})`
310
200
  );
311
201
  }
202
+ if (ownership !== 'owned') {
203
+ const activePort = await readDevToolsActivePort(userDataDir);
204
+ if (activePort?.port === port) {
205
+ ownership = 'owned';
206
+ }
207
+ }
312
208
 
313
- let port = remoteDebuggingPort;
314
- if (port === 0) {
209
+ if (ownership === 'owned') {
210
+ let version = null;
315
211
  try {
316
- const activePort = await readFile(
317
- path.join(userDataDir, 'DevToolsActivePort'),
318
- 'utf8'
212
+ version = await fetchCdpVersion(
213
+ endpoint,
214
+ fetchImplementation,
215
+ Math.max(1, deadline - Date.now())
319
216
  );
320
- port = Number.parseInt(activePort.split(/\r?\n/, 1)[0], 10);
321
217
  } catch {
322
- port = 0;
218
+ // The HTTP handler can lag the listening line by a moment.
323
219
  }
324
- }
325
-
326
- if (port > 0) {
327
- const endpoint = `http://127.0.0.1:${port}`;
328
- try {
329
- if (
330
- await fetchCdpVersion(
331
- endpoint,
332
- fetchImplementation,
333
- Math.max(1, deadline - Date.now())
334
- )
335
- ) {
336
- return endpoint;
220
+ if (version) {
221
+ const expected = output.listening?.url;
222
+ if (expected && version.webSocketDebuggerUrl !== expected) {
223
+ throw new PortRaceError(
224
+ port,
225
+ `port serves ${version.webSocketDebuggerUrl}, browser announced ${expected}`
226
+ );
337
227
  }
338
- } catch {
339
- // Chrome may have allocated the port before /json/version is ready.
228
+ return endpoint;
340
229
  }
341
230
  }
342
231
  await new Promise((resolve) => setTimeout(resolve, 100));
343
232
  }
344
233
  throw new Error(
345
- `Timed out after ${timeout}ms waiting for the DevTools endpoint`
234
+ `Timed out after ${timeout}ms waiting for the DevTools endpoint on port ${port}`
346
235
  );
347
236
  }
348
237
 
238
+ function spawnWithStartProcess(executablePath, args, { env, verbose }) {
239
+ return startProcess(executablePath, args, { env, forwardOutput: verbose });
240
+ }
241
+
242
+ async function waitForExit(browserProcess, timeout) {
243
+ if (browserProcess.exitCode !== null) {
244
+ return true;
245
+ }
246
+ if (typeof browserProcess.once !== 'function') {
247
+ return false;
248
+ }
249
+ let timer;
250
+ const exited = await Promise.race([
251
+ new Promise((resolve) => browserProcess.once('exit', () => resolve(true))),
252
+ new Promise((resolve) => {
253
+ timer = setTimeout(() => resolve(false), timeout);
254
+ }),
255
+ ]);
256
+ clearTimeout(timer);
257
+ return exited;
258
+ }
259
+
260
+ async function requestBrowserClose({ engine, browser, originalClose }) {
261
+ if (engine === 'puppeteer') {
262
+ // A connected Puppeteer Browser sends Browser.close and waits for it.
263
+ await originalClose.call(browser);
264
+ return;
265
+ }
266
+ // Playwright's close() only disconnects from a connectOverCDP browser, so
267
+ // ask Chrome itself to shut down, then drop the connection.
268
+ const session = await browser.newBrowserCDPSession();
269
+ await session.send('Browser.close').catch(() => {});
270
+ await originalClose.call(browser).catch(() => {});
271
+ }
272
+
273
+ function createCloser({
274
+ engine,
275
+ browser,
276
+ browserProcess,
277
+ userDataDir,
278
+ temporaryProfile,
279
+ closeTimeout,
280
+ }) {
281
+ const originalClose = browser?.close;
282
+ let closing;
283
+ const close = () => {
284
+ closing ??= (async () => {
285
+ if (browserProcess.exitCode === null && originalClose) {
286
+ await requestBrowserClose({ engine, browser, originalClose }).catch(
287
+ () => {}
288
+ );
289
+ }
290
+ if (!(await waitForExit(browserProcess, closeTimeout))) {
291
+ browserProcess.kill();
292
+ await waitForExit(browserProcess, closeTimeout);
293
+ }
294
+ if (temporaryProfile) {
295
+ await removeUserDataDir(userDataDir);
296
+ }
297
+ })();
298
+ return closing;
299
+ };
300
+ if (browser && originalClose) {
301
+ // `browser.close()` means "close the browser" for every caller, so it
302
+ // shuts the spawned process down and deletes a temporary profile too.
303
+ browser.close = close;
304
+ }
305
+ return close;
306
+ }
307
+
349
308
  /**
350
- * Spawn a genuine installed Chrome-family browser with an isolated profile,
309
+ * Spawn a genuine installed Chrome-family browser with a dedicated profile,
351
310
  * wait for its loopback CDP endpoint, and attach through {@link connectBrowser}.
352
311
  *
312
+ * The command line is `--user-data-dir=<dir> --remote-debugging-port=<port>`
313
+ * and nothing else unless asked for, so the session is as close to a
314
+ * hand-started browser as CDP allows and `navigator.webdriver` is false.
315
+ *
353
316
  * @param {Object} options - Launch and connection options
354
317
  * @param {'playwright'|'puppeteer'} [options.engine='playwright'] - Automation engine
355
318
  * @param {string} [options.channel='chrome'] - Installed Chrome-family channel
356
319
  * @param {string} [options.executablePath] - Explicit installed browser executable
357
- * @param {string} [options.userDataDir] - Dedicated non-default browser profile
358
- * @param {number} [options.remoteDebuggingPort=0] - Loopback CDP port; zero lets Chrome choose
320
+ * @param {string} [options.userDataDir] - Persistent dedicated profile. When omitted a fresh temporary profile is created and deleted on close.
321
+ * @param {number} [options.remoteDebuggingPort] - Fixed loopback CDP port. When omitted a free port is reserved (and re-reserved on a port race). Zero is refused because it sets navigator.webdriver.
322
+ * @param {number} [options.portAttempts=3] - Launch attempts when a reserved port is lost to a race
359
323
  * @param {boolean} [options.headless=false] - Run the installed browser headlessly
324
+ * @param {string[]} [options.restrictions] - Opt-in restrictions from launch-restrictions.json, such as 'no-extensions'
360
325
  * @param {string[]} [options.args] - Additional browser arguments
361
- * @param {string[]} [options.extraArgs] - Additional browser arguments appended after legacy args
362
- * @param {boolean|string[]} [options.ignoreDefaultArgs] - Browser Commander defaults to omit, or true for all
326
+ * @param {string[]} [options.extraArgs] - Additional browser arguments appended after args
327
+ * @param {Object<string,string>} [options.env] - Extra environment for the browser process only
328
+ * @param {boolean} [options.automationParity=true] - Add the AutomationControlled off switch when a custom argument turns the feature on
363
329
  * @param {number} [options.startupTimeout=30000] - CDP readiness timeout in milliseconds
330
+ * @param {number} [options.closeTimeout=5000] - How long close() waits before killing the process
364
331
  * @param {Object[]} [options.seedCookies] - Cookies to seed after connecting
332
+ * @param {{browser: string, profile: (string|undefined), userDataDir: (string|undefined), include: (Array<string>|undefined), domains: (Array<string>|undefined)}} [options.migrateFrom] - Migrate a real browser profile into the dedicated profile before launch; on-disk data is written before the browser starts and cookies are seeded over CDP after connecting
333
+ * @param {{mode: 'snapshot', browser: (string|undefined), profile: (string|undefined), userDataDir: (string|undefined)}} [options.attach] - Launch on a read-only snapshot of a real profile (issue #102, addendum): `snapshotUserDataDir()` copies it into a temporary user data directory, which is deleted on close. `browser` defaults to the channel's browser and `profile` to Default. Mutually exclusive with `migrateFrom` and `userDataDir`.
365
334
  * @param {boolean} [options.verbose=false] - Show browser and connection logs
366
- * @returns {Promise<{browser: Object, page: Object, browserProcess: Object, cdpEndpoint: string, executablePath: string, userDataDir: string}>} Connected handles and process metadata
335
+ * @returns {Promise<{browser: Object, page: Object, downloads: (Object|null), close: function(): Promise<void>, browserProcess: Object, cdpEndpoint: string, remoteDebuggingPort: number, executablePath: string, userDataDir: string, temporaryProfile: boolean, args: Array<string>, migration: (Object|undefined), attach: ({mode: 'snapshot', snapshot: Object, differences: Array<{aspect: string, description: string}>}|undefined)}>} Connected handles and process metadata (with a `migration` report when `migrateFrom` was given and an `attach` entry when `attach` was given)
367
336
  */
368
337
  export async function launchAndConnectRealBrowser(options = {}) {
369
338
  return await launchAndConnectRealBrowserWithDependencies(options);
@@ -378,7 +347,175 @@ export async function launchAndConnectRealBrowser(options = {}) {
378
347
  */
379
348
  export const launchRealBrowser = launchAndConnectRealBrowser;
380
349
 
350
+ async function spawnOnFreePort({
351
+ requestedPort,
352
+ portAttempts,
353
+ executablePath,
354
+ argOptions,
355
+ env,
356
+ verbose,
357
+ userDataDir,
358
+ startupTimeout,
359
+ dependencies,
360
+ }) {
361
+ const reservePort = dependencies.reservePort ?? reserveLoopbackPort;
362
+ const spawnBrowser = dependencies.spawnBrowser ?? spawnWithStartProcess;
363
+ const waitForEndpoint = dependencies.waitForEndpoint ?? waitForCdpEndpoint;
364
+ const attempts = requestedPort === undefined ? portAttempts : 1;
365
+ for (let attempt = 1; ; attempt++) {
366
+ const port = requestedPort ?? (await reservePort());
367
+ const browserArgs = buildRealBrowserArgs({
368
+ ...argOptions,
369
+ userDataDir,
370
+ remoteDebuggingPort: port,
371
+ });
372
+ const browserProcess = spawnBrowser(executablePath, browserArgs, {
373
+ env,
374
+ verbose,
375
+ });
376
+ try {
377
+ const cdpEndpoint = await waitForEndpoint({
378
+ remoteDebuggingPort: port,
379
+ userDataDir,
380
+ browserProcess,
381
+ timeout: startupTimeout,
382
+ });
383
+ return { browserProcess, cdpEndpoint, port, browserArgs };
384
+ } catch (error) {
385
+ if (browserProcess.exitCode === null) {
386
+ browserProcess.kill();
387
+ }
388
+ if (!(error instanceof PortRaceError) || attempt >= attempts) {
389
+ throw error;
390
+ }
391
+ if (verbose) {
392
+ console.log(`${error.message}; retrying with a new port`);
393
+ }
394
+ await waitForExit(browserProcess, 5_000);
395
+ }
396
+ }
397
+ }
398
+
399
+ /** Reject a launch request before anything touches the disk. */
400
+ function validateLaunchRequest({
401
+ argOptions,
402
+ userDataDir,
403
+ remoteDebuggingPort,
404
+ endpoint,
405
+ attach,
406
+ migrateFrom,
407
+ }) {
408
+ if (endpoint) {
409
+ throw new Error(
410
+ 'launchAndConnectRealBrowser creates its own endpoint; use connectBrowser to attach to an existing endpoint'
411
+ );
412
+ }
413
+ validateAttachOption({
414
+ attach,
415
+ migrateFrom,
416
+ userDataDir,
417
+ customArgs: [argOptions.args, argOptions.extraArgs].flatMap((list) =>
418
+ Array.isArray(list) ? list : []
419
+ ),
420
+ });
421
+ if (remoteDebuggingPort !== undefined) {
422
+ assertFixedDebuggingPort(remoteDebuggingPort);
423
+ }
424
+ buildRealBrowserArgs({
425
+ ...argOptions,
426
+ userDataDir: userDataDir ?? 'validation',
427
+ remoteDebuggingPort: remoteDebuggingPort ?? 1,
428
+ });
429
+ if (userDataDir) {
430
+ assertDedicatedUserDataDir(userDataDir);
431
+ }
432
+ }
433
+
434
+ /**
435
+ * Environment for the browser process. Restriction environment (for example
436
+ * no-google-services) goes to the browser only; this process's environment is
437
+ * never modified.
438
+ */
439
+ function browserEnvironment(restrictions, env) {
440
+ const restrictionEnv = resolveRestrictions(restrictions).env;
441
+ return env || Object.keys(restrictionEnv).length > 0
442
+ ? { ...process.env, ...restrictionEnv, ...env }
443
+ : undefined;
444
+ }
445
+
446
+ /**
447
+ * Create the profile the browser starts with: a snapshot of a real profile
448
+ * for `attach`, a fresh temporary one, or the caller's dedicated one. A
449
+ * snapshot is a temporary profile too and is deleted on close. The snapshot's
450
+ * `--profile-directory` switch is put before the custom `args`.
451
+ */
452
+ async function prepareProfile({
453
+ attach,
454
+ channel,
455
+ requestedUserDataDir,
456
+ argOptions,
457
+ snapshot,
458
+ }) {
459
+ if (attach) {
460
+ const snapshotLaunch = await prepareSnapshotLaunch({
461
+ attach,
462
+ channel,
463
+ restrictions: argOptions.restrictions,
464
+ customArgs: [...argOptions.args, ...argOptions.extraArgs],
465
+ snapshot,
466
+ });
467
+ argOptions.args = [...snapshotLaunch.args, ...argOptions.args];
468
+ return { userDataDir: snapshotLaunch.userDataDir, snapshotLaunch };
469
+ }
470
+ return {
471
+ userDataDir: requestedUserDataDir
472
+ ? await prepareUserDataDir(requestedUserDataDir)
473
+ : await createTemporaryUserDataDir(),
474
+ };
475
+ }
476
+
381
477
  /** Dependency-injected implementation used by the public helper and tests. */
478
+ /**
479
+ * Migrate a real browser profile into the dedicated profile before launch.
480
+ *
481
+ * On-disk data classes (bookmarks, history, passwords, preferences,
482
+ * extensions) must be in place before the browser starts, so this runs before
483
+ * the process is spawned. Cookies are returned instead of written because a
484
+ * running Chromium re-derives its own encryption; the caller seeds them over
485
+ * CDP after connecting. On failure a freshly created temporary profile is
486
+ * removed before the error propagates.
487
+ *
488
+ * @returns {Promise<{migration: (Object|undefined), migratedCookies: Array<Object>}>}
489
+ */
490
+ async function runPreLaunchMigration({
491
+ migrateFrom,
492
+ userDataDir,
493
+ channel,
494
+ temporaryProfile,
495
+ migrate,
496
+ }) {
497
+ if (!migrateFrom) {
498
+ return { migration: undefined, migratedCookies: [] };
499
+ }
500
+ const { include, domains, ...from } = migrateFrom;
501
+ try {
502
+ const report = await migrate({
503
+ from,
504
+ to: path.join(userDataDir, 'Default'),
505
+ include,
506
+ domains,
507
+ targetBrowser: channel,
508
+ });
509
+ const { cookies, ...migration } = report;
510
+ return { migration, migratedCookies: cookies ?? [] };
511
+ } catch (error) {
512
+ if (temporaryProfile) {
513
+ await removeUserDataDir(userDataDir);
514
+ }
515
+ throw error;
516
+ }
517
+ }
518
+
382
519
  export async function launchAndConnectRealBrowserWithDependencies(
383
520
  options = {},
384
521
  dependencies = {}
@@ -387,73 +524,143 @@ export async function launchAndConnectRealBrowserWithDependencies(
387
524
  engine = 'playwright',
388
525
  channel = 'chrome',
389
526
  executablePath: requestedExecutablePath,
390
- userDataDir = defaultUserDataDir(channel),
391
- remoteDebuggingPort = 0,
527
+ userDataDir: requestedUserDataDir,
528
+ remoteDebuggingPort,
529
+ portAttempts = 3,
392
530
  headless = false,
531
+ restrictions = [],
393
532
  args = [],
394
533
  extraArgs = [],
395
- ignoreDefaultArgs = [],
534
+ ignoreDefaultArgs: _ignoredLegacyDefaults,
535
+ env,
536
+ automationParity = true,
396
537
  startupTimeout = 30_000,
538
+ closeTimeout = 5_000,
397
539
  verbose = false,
398
540
  cdpEndpoint,
399
541
  wsEndpoint,
542
+ migrateFrom,
543
+ attach,
400
544
  ...connectionOptions
401
545
  } = options;
402
- if (cdpEndpoint || wsEndpoint) {
403
- throw new Error(
404
- 'launchAndConnectRealBrowser creates its own endpoint; use connectBrowser to attach to an existing endpoint'
405
- );
406
- }
546
+ const argOptions = {
547
+ headless,
548
+ restrictions,
549
+ args,
550
+ extraArgs,
551
+ automationParity,
552
+ };
553
+ validateLaunchRequest({
554
+ argOptions,
555
+ userDataDir: requestedUserDataDir,
556
+ remoteDebuggingPort,
557
+ endpoint: cdpEndpoint || wsEndpoint,
558
+ attach,
559
+ migrateFrom,
560
+ });
407
561
 
408
- assertDedicatedUserDataDir(userDataDir);
409
- await mkdir(userDataDir, { recursive: true });
410
562
  const resolveExecutable =
411
563
  dependencies.resolveExecutable ?? resolveSystemBrowserExecutable;
412
- const resolvedExecutablePath = await resolveExecutable({
564
+ const executablePath = await resolveExecutable({
413
565
  channel,
414
566
  executablePath: requestedExecutablePath,
415
567
  });
416
- const browserArgs = buildRealBrowserArgs({
417
- userDataDir,
418
- remoteDebuggingPort,
419
- headless,
420
- args,
421
- extraArgs,
422
- ignoreDefaultArgs,
568
+
569
+ const temporaryProfile = !requestedUserDataDir;
570
+ const { userDataDir, snapshotLaunch } = await prepareProfile({
571
+ attach,
572
+ channel,
573
+ requestedUserDataDir,
574
+ argOptions,
575
+ snapshot: dependencies.snapshotUserDataDir,
423
576
  });
424
- const spawnBrowser = dependencies.spawnBrowser ?? spawn;
425
- const browserProcess = spawnBrowser(resolvedExecutablePath, browserArgs, {
426
- stdio: verbose ? 'inherit' : 'ignore',
577
+ const childEnv = browserEnvironment(restrictions, env);
578
+
579
+ const { migration, migratedCookies } = await runPreLaunchMigration({
580
+ migrateFrom,
581
+ userDataDir,
582
+ channel,
583
+ temporaryProfile,
584
+ migrate: dependencies.migrateProfile ?? migrateProfile,
427
585
  });
428
586
 
587
+ let launched;
429
588
  try {
430
- const waitForEndpoint = dependencies.waitForEndpoint ?? waitForCdpEndpoint;
431
- const resolvedCdpEndpoint = await waitForEndpoint({
432
- remoteDebuggingPort,
589
+ launched = await spawnOnFreePort({
590
+ requestedPort: remoteDebuggingPort,
591
+ portAttempts,
592
+ executablePath,
593
+ argOptions,
594
+ env: childEnv,
595
+ verbose,
433
596
  userDataDir,
434
- browserProcess,
435
- timeout: startupTimeout,
597
+ startupTimeout,
598
+ dependencies,
436
599
  });
600
+ } catch (error) {
601
+ if (temporaryProfile) {
602
+ await removeUserDataDir(userDataDir);
603
+ }
604
+ throw error;
605
+ }
606
+ const { browserProcess, cdpEndpoint: resolvedCdpEndpoint } = launched;
607
+ if (temporaryProfile) {
608
+ // The profile goes away with the browser, also when the user closes the
609
+ // window instead of the caller calling close().
610
+ browserProcess.once?.('exit', () => {
611
+ removeUserDataDir(userDataDir).catch(() => {});
612
+ });
613
+ }
614
+
615
+ try {
437
616
  const connect = dependencies.connect ?? connectBrowser;
617
+ const seedCookies =
618
+ migratedCookies.length > 0
619
+ ? [...(connectionOptions.seedCookies ?? []), ...migratedCookies]
620
+ : connectionOptions.seedCookies;
438
621
  const connection = await connect({
439
622
  engine,
440
623
  cdpEndpoint: resolvedCdpEndpoint,
441
624
  ...connectionOptions,
625
+ seedCookies,
442
626
  verbose,
443
627
  });
628
+ const close = createCloser({
629
+ engine,
630
+ browser: connection.browser,
631
+ browserProcess,
632
+ userDataDir,
633
+ temporaryProfile,
634
+ closeTimeout,
635
+ });
444
636
  return {
445
637
  ...connection,
638
+ close,
446
639
  browserProcess,
447
640
  cdpEndpoint: resolvedCdpEndpoint,
448
- executablePath: resolvedExecutablePath,
641
+ remoteDebuggingPort: launched.port,
642
+ executablePath,
449
643
  userDataDir,
644
+ temporaryProfile,
645
+ args: launched.browserArgs,
646
+ ...(migration ? { migration } : {}),
647
+ ...(snapshotLaunch ? { attach: snapshotLaunch.attach } : {}),
450
648
  };
451
649
  } catch (error) {
452
650
  if (browserProcess.exitCode === null) {
453
651
  browserProcess.kill();
454
652
  }
653
+ await waitForExit(browserProcess, closeTimeout);
654
+ if (temporaryProfile) {
655
+ await removeUserDataDir(userDataDir);
656
+ }
455
657
  throw error;
456
658
  }
457
659
  }
458
660
 
459
- export { defaultUserDataDir, knownDefaultUserDataDirs };
661
+ export {
662
+ assertDedicatedUserDataDir,
663
+ defaultUserDataDir,
664
+ knownDefaultUserDataDirs,
665
+ resolveSystemBrowserExecutable,
666
+ };