browser-commander 0.20.0 → 0.21.1

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 (169) hide show
  1. package/.jscpd.json +2 -1
  2. package/.prettierignore +1 -0
  3. package/CHANGELOG.md +159 -0
  4. package/README.md +216 -32
  5. package/bin/browser-commander.js +16 -0
  6. package/eslint.config.js +10 -0
  7. package/examples/react-test-app/src/App.jsx +24 -23
  8. package/extension/background.js +14 -0
  9. package/extension/manifest.json +12 -0
  10. package/extension/relay-handler.js +353 -0
  11. package/package.json +21 -4
  12. package/src/browser/attach/differences.js +131 -0
  13. package/src/browser/attach/extension-relay.js +402 -0
  14. package/src/browser/attach/index.js +81 -0
  15. package/src/browser/attach/snapshot-launch.js +146 -0
  16. package/src/browser/attach/snapshot.js +374 -0
  17. package/src/browser/attach/websocket.js +615 -0
  18. package/src/browser/browser-cookie-cache.js +26 -19
  19. package/src/browser/browser-cookie-credentials.js +6 -9
  20. package/src/browser/browser-cookies.js +5 -2
  21. package/src/browser/cdp-session.js +94 -0
  22. package/src/browser/connector.js +42 -4
  23. package/src/browser/debugging-port.js +155 -0
  24. package/src/browser/launch-options.js +63 -52
  25. package/src/browser/launch-restrictions.json +147 -0
  26. package/src/browser/launcher.js +329 -133
  27. package/src/browser/media.js +5 -0
  28. package/src/browser/migration/bookmarks.js +73 -0
  29. package/src/browser/migration/chromium-crypto.js +89 -0
  30. package/src/browser/migration/cookies.js +155 -0
  31. package/src/browser/migration/extensions.js +187 -0
  32. package/src/browser/migration/firefox-bookmarks.js +144 -0
  33. package/src/browser/migration/firefox-der.js +84 -0
  34. package/src/browser/migration/firefox-nss.js +281 -0
  35. package/src/browser/migration/firefox.js +334 -0
  36. package/src/browser/migration/fs-utils.js +51 -0
  37. package/src/browser/migration/history.js +103 -0
  38. package/src/browser/migration/index.js +307 -0
  39. package/src/browser/migration/os-crypt-keys.js +106 -0
  40. package/src/browser/migration/passwords.js +171 -0
  41. package/src/browser/migration/preferences.js +132 -0
  42. package/src/browser/migration/sqlite-snapshot.js +138 -0
  43. package/src/browser/open-in-user-browser.js +112 -0
  44. package/src/browser/parity.js +439 -0
  45. package/src/browser/profile-directory.js +205 -0
  46. package/src/browser/real-browser.js +545 -306
  47. package/src/browser/restrictions.js +148 -0
  48. package/src/browser/system-browser.js +232 -0
  49. package/src/browser/webdriver.js +572 -0
  50. package/src/cli/args.js +300 -0
  51. package/src/cli/commands.js +332 -0
  52. package/src/cli/dispatcher.js +99 -0
  53. package/src/cli/handle-methods.js +136 -0
  54. package/src/cli/handles.js +273 -0
  55. package/src/cli/high-level-methods.js +310 -0
  56. package/src/cli/main.js +67 -0
  57. package/src/cli/modules.js +100 -0
  58. package/src/cli/rpc-error.js +77 -0
  59. package/src/cli/script.js +77 -0
  60. package/src/cli/serve.js +144 -0
  61. package/src/cli/sessions.js +199 -0
  62. package/src/core/constants.js +7 -1
  63. package/src/core/engine-adapter.js +107 -1
  64. package/src/core/engine-detection.js +18 -3
  65. package/src/core/navigation-safety.js +4 -0
  66. package/src/core/webdriver-conversions.js +347 -0
  67. package/src/core/webdriver-events.js +229 -0
  68. package/src/core/webdriver-page.js +716 -0
  69. package/src/downloads/attach.js +6 -0
  70. package/src/elements/selectors.js +3 -2
  71. package/src/elements/visibility.js +4 -0
  72. package/src/exports.js +61 -2
  73. package/src/factory.js +19 -4
  74. package/src/fingerprint/apply.js +6 -0
  75. package/src/fingerprint/automation-parity.js +7 -8
  76. package/src/fingerprint/limitations.js +1 -1
  77. package/src/fingerprint/limitations.json +45 -2
  78. package/src/parity/harness.js +303 -0
  79. package/src/parity/probe.js +838 -0
  80. package/src/parity/version-page.js +144 -0
  81. package/src/traces/bundle.js +3 -2
  82. package/src/traces/observers.js +14 -5
  83. package/src/traces/recorder.js +3 -0
  84. package/src/traces/viewer.js +16 -8
  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/fingerprint-parity.e2e.test.js +104 -46
  89. package/tests/e2e/google-signin-probe.e2e.test.js +112 -0
  90. package/tests/e2e/measure-parity.e2e.test.js +118 -0
  91. package/tests/e2e/playwright.e2e.test.js +26 -17
  92. package/tests/e2e/puppeteer.e2e.test.js +18 -23
  93. package/tests/e2e/webdriver.e2e.test.js +239 -0
  94. package/tests/fixtures/firefox-nss-fixtures.mjs +211 -0
  95. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0001.html +2 -0
  96. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0001.png +1 -0
  97. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0001.state.json +54 -0
  98. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.html +2 -0
  99. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.png +1 -0
  100. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.state.json +60 -0
  101. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.html +2 -0
  102. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.png +1 -0
  103. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.state.json +60 -0
  104. package/tests/fixtures/traces/conformance/expected/bundle/events.ndjson +18 -0
  105. package/tests/fixtures/traces/conformance/expected/bundle/manifest.json +77 -0
  106. package/tests/fixtures/traces/conformance/expected/bundle/mutations/0001.ndjson +2 -0
  107. package/tests/fixtures/traces/conformance/expected/bundle/mutations/0002.ndjson +1 -0
  108. package/tests/fixtures/traces/conformance/expected/bundle/viewer.html +309 -0
  109. package/tests/fixtures/traces/conformance/expected/redaction.json +86 -0
  110. package/tests/fixtures/traces/conformance/expected/trace.lino +27 -0
  111. package/tests/fixtures/traces/conformance/scenario.json +249 -0
  112. package/tests/helpers/cli-fakes.js +214 -0
  113. package/tests/helpers/declared-api.js +80 -0
  114. package/tests/helpers/e2e-browser.js +40 -14
  115. package/tests/helpers/fake-chrome.js +124 -0
  116. package/tests/helpers/migration-fixtures.js +242 -0
  117. package/tests/helpers/temp-directory.js +24 -0
  118. package/tests/helpers/webdriver-mocks.js +183 -0
  119. package/tests/unit/browser/attach/extension-relay.test.js +360 -0
  120. package/tests/unit/browser/attach/snapshot-launch.test.js +321 -0
  121. package/tests/unit/browser/attach/snapshot.test.js +409 -0
  122. package/tests/unit/browser/attach/websocket.test.js +240 -0
  123. package/tests/unit/browser/browser-cookie-lock.test.js +102 -0
  124. package/tests/unit/browser/browser-cookies.test.js +2 -32
  125. package/tests/unit/browser/cdp-session.test.js +162 -0
  126. package/tests/unit/browser/connector.test.js +23 -0
  127. package/tests/unit/browser/launch-building-blocks.test.js +361 -0
  128. package/tests/unit/browser/launch-options.test.js +41 -22
  129. package/tests/unit/browser/launcher.test.js +281 -102
  130. package/tests/unit/browser/media.test.js +13 -1
  131. package/tests/unit/browser/migration/bookmarks.test.js +69 -0
  132. package/tests/unit/browser/migration/chromium-crypto.test.js +94 -0
  133. package/tests/unit/browser/migration/cookies.test.js +94 -0
  134. package/tests/unit/browser/migration/extensions.test.js +104 -0
  135. package/tests/unit/browser/migration/firefox-nss.test.js +113 -0
  136. package/tests/unit/browser/migration/firefox.test.js +139 -0
  137. package/tests/unit/browser/migration/history.test.js +48 -0
  138. package/tests/unit/browser/migration/index.test.js +117 -0
  139. package/tests/unit/browser/migration/os-crypt-keys.test.js +95 -0
  140. package/tests/unit/browser/migration/passwords.test.js +144 -0
  141. package/tests/unit/browser/migration/preferences.test.js +77 -0
  142. package/tests/unit/browser/migration/sqlite-snapshot.test.js +75 -0
  143. package/tests/unit/browser/open-in-user-browser.test.js +123 -0
  144. package/tests/unit/browser/parity.test.js +281 -0
  145. package/tests/unit/browser/real-browser.test.js +458 -33
  146. package/tests/unit/browser/storage-state.test.js +2 -0
  147. package/tests/unit/browser/webdriver.test.js +473 -0
  148. package/tests/unit/cli/bin.test.js +69 -0
  149. package/tests/unit/cli/declared-api.test.js +49 -0
  150. package/tests/unit/cli/dispatcher.test.js +429 -0
  151. package/tests/unit/cli/handles.test.js +170 -0
  152. package/tests/unit/cli/main.test.js +437 -0
  153. package/tests/unit/cli/serve.test.js +167 -0
  154. package/tests/unit/core/navigation-manager.test.js +11 -1
  155. package/tests/unit/core/webdriver-conversions.test.js +235 -0
  156. package/tests/unit/core/webdriver-page.test.js +534 -0
  157. package/tests/unit/fingerprint/automation-parity.test.js +17 -4
  158. package/tests/unit/fingerprint/limitations.test.js +1 -1
  159. package/tests/unit/parity/harness.test.js +138 -0
  160. package/tests/unit/playwright-protocol-coverage.test.js +231 -0
  161. package/tests/unit/puppeteer-bindings-coverage.test.js +214 -0
  162. package/tests/unit/scripts/ci-timeout-budgets.test.js +27 -0
  163. package/tests/unit/scripts/feature-parity.test.js +327 -0
  164. package/tests/unit/traces/conformance.test.js +92 -0
  165. package/tests/unit/traces/reader.test.js +1 -0
  166. package/tests/unit/traces/recorder.test.js +1 -0
  167. package/tests/unit/traces/viewer.test.js +78 -0
  168. package/tests/unit/utilities/command-stream-only.test.js +28 -0
  169. package/tests/unit/utilities/subprocess.test.js +80 -0
@@ -0,0 +1,572 @@
1
+ /**
2
+ * Launching and connecting WebDriver sessions for the selenium engine
3
+ * (issue #104).
4
+ *
5
+ * `launchWebDriver()` starts a driver server (chromedriver or geckodriver)
6
+ * itself instead of letting selenium-webdriver do it, for the same reasons the
7
+ * real-browser launcher owns Chrome's command line (issues #101, #103):
8
+ *
9
+ * - The driver is found the way a person would find it: the `driverPath` they
10
+ * gave, then PATH, then Selenium Manager (`--browser <name> --output json`),
11
+ * which downloads a driver matching the installed browser.
12
+ * - The server runs on a reserved loopback port through command-stream like
13
+ * every other subprocess, and is ready when `GET /status` says so.
14
+ * - Chrome gets a clean command line. chromedriver adds twenty switches of
15
+ * its own, including `--enable-automation` and `--remote-debugging-port=0`,
16
+ * each of which turns `navigator.webdriver` on. They are all excluded and
17
+ * Chrome gets a fixed debugging port instead, which measured
18
+ * `navigator.webdriver === false` (limitation
19
+ * `webdriver-driver-launch-switches`, experiments/issue-104/).
20
+ *
21
+ * `connectWebDriver()` attaches to a server that is already running, such as
22
+ * a Selenium Grid.
23
+ */
24
+
25
+ import { access, constants } from 'node:fs';
26
+ import { promisify } from 'node:util';
27
+ import { createRequire } from 'node:module';
28
+ import path from 'node:path';
29
+
30
+ import { createWebDriverPage } from '../core/webdriver-page.js';
31
+ import {
32
+ runCommand as defaultRunCommand,
33
+ startProcess as defaultStartProcess,
34
+ } from '../utilities/subprocess.js';
35
+ import { LOOPBACK_HOST, reserveLoopbackPort } from './debugging-port.js';
36
+ import {
37
+ createTemporaryUserDataDir,
38
+ prepareUserDataDir,
39
+ removeUserDataDir,
40
+ } from './profile-directory.js';
41
+ import { buildRealBrowserArgs } from './real-browser.js';
42
+ import { resolveRestrictions } from './restrictions.js';
43
+
44
+ const require = createRequire(import.meta.url);
45
+ const accessAsync = promisify(access);
46
+
47
+ /**
48
+ * Switches chromedriver adds to Chrome's command line on its own, measured
49
+ * from `chrome://version` with Chrome and chromedriver 153. All of them are
50
+ * excluded; `--remote-debugging-port=0` is replaced by a fixed port instead
51
+ * (chromedriver needs a port to attach, and port 0 is an AutomationControlled
52
+ * trigger).
53
+ */
54
+ export const CHROMEDRIVER_DEFAULT_SWITCHES = Object.freeze([
55
+ 'allow-pre-commit-input',
56
+ 'disable-background-networking',
57
+ 'disable-background-timer-throttling',
58
+ 'disable-backgrounding-occluded-windows',
59
+ 'disable-client-side-phishing-detection',
60
+ 'disable-default-apps',
61
+ 'disable-features',
62
+ 'disable-hang-monitor',
63
+ 'disable-popup-blocking',
64
+ 'disable-prompt-on-repost',
65
+ 'disable-sync',
66
+ 'enable-automation',
67
+ 'enable-logging',
68
+ 'log-level',
69
+ 'no-first-run',
70
+ 'no-service-autorun',
71
+ 'password-store',
72
+ 'test-type',
73
+ 'use-mock-keychain',
74
+ ]);
75
+
76
+ const DRIVERS = Object.freeze({
77
+ chrome: { name: 'chromedriver', builder: 'chrome' },
78
+ firefox: { name: 'geckodriver', builder: 'firefox' },
79
+ });
80
+
81
+ /**
82
+ * Load selenium-webdriver, an optional peer dependency.
83
+ *
84
+ * @param {string} [subpath] - e.g. 'chrome' for selenium-webdriver/chrome
85
+ * @returns {Object}
86
+ */
87
+ export function loadSelenium(subpath) {
88
+ const id = subpath ? `selenium-webdriver/${subpath}` : 'selenium-webdriver';
89
+ try {
90
+ return require(id);
91
+ } catch (error) {
92
+ if (error?.code === 'MODULE_NOT_FOUND') {
93
+ throw new Error(
94
+ 'The selenium engine needs the selenium-webdriver package: npm install selenium-webdriver',
95
+ { cause: error }
96
+ );
97
+ }
98
+ throw error;
99
+ }
100
+ }
101
+
102
+ function assertBrowser(browser) {
103
+ if (!DRIVERS[browser]) {
104
+ throw new Error(
105
+ `Unsupported WebDriver browser: ${browser}. Expected 'chrome' or 'firefox'`
106
+ );
107
+ }
108
+ return DRIVERS[browser];
109
+ }
110
+
111
+ /**
112
+ * The Selenium Manager binary shipped with selenium-webdriver (or
113
+ * `SE_MANAGER_PATH`), mirroring selenium-webdriver's own lookup.
114
+ *
115
+ * @param {Object} [options]
116
+ * @returns {string}
117
+ */
118
+ export function seleniumManagerPath({
119
+ platform = process.platform,
120
+ arch = process.arch,
121
+ environment = process.env,
122
+ } = {}) {
123
+ if (environment.SE_MANAGER_PATH) {
124
+ return environment.SE_MANAGER_PATH;
125
+ }
126
+ const directory = {
127
+ darwin: 'macos',
128
+ win32: 'windows',
129
+ cygwin: 'windows',
130
+ linux: arch === 'arm64' ? 'linux-arm64' : 'linux-x86_64',
131
+ }[platform];
132
+ const file =
133
+ directory === 'windows' ? 'selenium-manager.exe' : 'selenium-manager';
134
+ const packageRoot = path.dirname(
135
+ require.resolve('selenium-webdriver/package.json')
136
+ );
137
+ return path.join(packageRoot, 'bin', directory ?? platform, file);
138
+ }
139
+
140
+ async function isExecutable(file, checkAccess) {
141
+ try {
142
+ await checkAccess(file, constants.X_OK);
143
+ return true;
144
+ } catch {
145
+ return false;
146
+ }
147
+ }
148
+
149
+ async function findOnPath(name, { environment, platform, checkAccess }) {
150
+ const paths = platform === 'win32' ? path.win32 : path.posix;
151
+ const file = platform === 'win32' ? `${name}.exe` : name;
152
+ for (const directory of (environment.PATH ?? '').split(paths.delimiter)) {
153
+ if (
154
+ directory &&
155
+ (await isExecutable(paths.join(directory, file), checkAccess))
156
+ ) {
157
+ return paths.join(directory, file);
158
+ }
159
+ }
160
+ return null;
161
+ }
162
+
163
+ /**
164
+ * Find the driver server for a browser: `driverPath`, then PATH, then
165
+ * Selenium Manager.
166
+ *
167
+ * @param {Object} options
168
+ * @param {'chrome'|'firefox'} [options.browser='chrome']
169
+ * @param {string} [options.driverPath] - Explicit driver executable
170
+ * @param {string} [options.executablePath] - Browser the driver must match
171
+ * @param {Function} [options.runCommand] - command-stream runner (tests)
172
+ * @returns {Promise<{driverPath: string, browserPath: (string|null), source: string}>}
173
+ */
174
+ export async function resolveWebDriverExecutable({
175
+ browser = 'chrome',
176
+ driverPath,
177
+ executablePath,
178
+ environment = process.env,
179
+ platform = process.platform,
180
+ runCommand = defaultRunCommand,
181
+ checkAccess = accessAsync,
182
+ managerPath,
183
+ } = {}) {
184
+ const { name } = assertBrowser(browser);
185
+ if (driverPath) {
186
+ if (!(await isExecutable(driverPath, checkAccess))) {
187
+ throw new Error(`WebDriver executable is not accessible: ${driverPath}`);
188
+ }
189
+ return {
190
+ driverPath,
191
+ browserPath: executablePath ?? null,
192
+ source: 'driverPath',
193
+ };
194
+ }
195
+
196
+ const onPath = await findOnPath(name, { environment, platform, checkAccess });
197
+ if (onPath) {
198
+ return {
199
+ driverPath: onPath,
200
+ browserPath: executablePath ?? null,
201
+ source: 'PATH',
202
+ };
203
+ }
204
+
205
+ const manager = managerPath ?? seleniumManagerPath({ platform, environment });
206
+ const args = ['--browser', browser, '--output', 'json'];
207
+ if (executablePath) {
208
+ args.push('--browser-path', executablePath);
209
+ }
210
+ const { stdout, code } = await runCommand(manager, args, { check: false });
211
+ let output;
212
+ try {
213
+ output = JSON.parse(stdout);
214
+ } catch {
215
+ throw new Error(
216
+ `Selenium Manager did not return JSON for ${name} (exit code ${code})`
217
+ );
218
+ }
219
+ const result = output?.result ?? {};
220
+ if (code !== 0 || !result.driver_path) {
221
+ throw new Error(
222
+ `Could not find ${name}: not on PATH, and Selenium Manager failed: ${result.message ?? `exit code ${code}`}. Pass driverPath.`
223
+ );
224
+ }
225
+ return {
226
+ driverPath: result.driver_path,
227
+ browserPath: executablePath ?? result.browser_path ?? null,
228
+ source: 'selenium-manager',
229
+ };
230
+ }
231
+
232
+ /**
233
+ * Command-line arguments for a driver server listening on `port`.
234
+ *
235
+ * @param {'chrome'|'firefox'} browser
236
+ * @param {number} port
237
+ * @returns {string[]}
238
+ */
239
+ export function buildDriverServerArgs(browser, port) {
240
+ if (assertBrowser(browser).name === 'geckodriver') {
241
+ return ['--port', String(port), '--host', LOOPBACK_HOST];
242
+ }
243
+ return [`--port=${port}`];
244
+ }
245
+
246
+ /**
247
+ * Poll `GET /status` until the driver reports `ready`.
248
+ *
249
+ * @param {string} serverUrl
250
+ * @param {Object} options
251
+ * @returns {Promise<void>}
252
+ */
253
+ export async function waitForDriverReady(
254
+ serverUrl,
255
+ { fetchImplementation = fetch, timeout = 30_000, driverProcess, output } = {}
256
+ ) {
257
+ const deadline = Date.now() + timeout;
258
+ for (;;) {
259
+ if (
260
+ driverProcess?.exitCode !== null &&
261
+ driverProcess?.exitCode !== undefined
262
+ ) {
263
+ throw new Error(
264
+ `WebDriver server exited with code ${driverProcess.exitCode} before it was ready${output?.() ? `: ${output().trim()}` : ''}`
265
+ );
266
+ }
267
+ try {
268
+ const response = await fetchImplementation(`${serverUrl}/status`);
269
+ if (response.ok) {
270
+ const status = await response.json();
271
+ if (status?.value?.ready) {
272
+ return;
273
+ }
274
+ }
275
+ } catch {
276
+ // Not listening yet.
277
+ }
278
+ if (Date.now() >= deadline) {
279
+ throw new Error(
280
+ `WebDriver server at ${serverUrl} was not ready within ${timeout}ms`
281
+ );
282
+ }
283
+ await new Promise((resolve) => setTimeout(resolve, 100));
284
+ }
285
+ }
286
+
287
+ /**
288
+ * Browser options for a launch: Chrome with the real-browser command line and
289
+ * chromedriver's own switches excluded, or Firefox with a dedicated profile.
290
+ *
291
+ * @param {Object} options
292
+ * @returns {{options: Object, args: string[]}} selenium Options and the browser arguments
293
+ */
294
+ export function buildBrowserOptions({
295
+ selenium,
296
+ browser,
297
+ browserPath,
298
+ userDataDir,
299
+ remoteDebuggingPort,
300
+ headless,
301
+ restrictions,
302
+ args,
303
+ automationParity,
304
+ bidi,
305
+ }) {
306
+ let options;
307
+ let browserArgs;
308
+ if (browser === 'chrome') {
309
+ options = new selenium.chrome.Options();
310
+ browserArgs = buildRealBrowserArgs({
311
+ userDataDir,
312
+ remoteDebuggingPort,
313
+ headless,
314
+ restrictions,
315
+ args,
316
+ automationParity,
317
+ });
318
+ options.addArguments(...browserArgs);
319
+ options.excludeSwitches(...CHROMEDRIVER_DEFAULT_SWITCHES);
320
+ if (browserPath) {
321
+ options.setChromeBinaryPath(browserPath);
322
+ }
323
+ } else {
324
+ options = new selenium.firefox.Options();
325
+ browserArgs = [
326
+ '-profile',
327
+ userDataDir,
328
+ ...(headless ? ['-headless'] : []),
329
+ ...args,
330
+ ];
331
+ options.addArguments(...browserArgs);
332
+ if (browserPath) {
333
+ options.setBinary(browserPath);
334
+ }
335
+ }
336
+ if (bidi) {
337
+ options.set('webSocketUrl', true);
338
+ // BiDi reports prompts as events and the page handles them (the dialog
339
+ // manager dismisses unhandled ones), so the driver must not close them
340
+ // first.
341
+ options.set('unhandledPromptBehavior', 'ignore');
342
+ }
343
+ return { options, args: browserArgs };
344
+ }
345
+
346
+ function seleniumModules(dependencies) {
347
+ return (
348
+ dependencies.selenium ?? {
349
+ webdriver: loadSelenium(),
350
+ chrome: loadSelenium('chrome'),
351
+ firefox: loadSelenium('firefox'),
352
+ }
353
+ );
354
+ }
355
+
356
+ function resolveProfile(browser, userDataDir) {
357
+ if (!userDataDir) {
358
+ return createTemporaryUserDataDir();
359
+ }
360
+ // Chrome's profile gets the same preparation as a real-browser launch
361
+ // (translate prompts off); Firefox uses the directory as given.
362
+ return browser === 'chrome'
363
+ ? prepareUserDataDir(userDataDir)
364
+ : Promise.resolve(userDataDir);
365
+ }
366
+
367
+ // The driver passes its environment on to the browser, so restriction
368
+ // variables (launch-restrictions.json) go to the driver process.
369
+ function driverEnvironment(restrictions, env) {
370
+ const restrictionEnv = resolveRestrictions(restrictions).env;
371
+ if (!env && Object.keys(restrictionEnv).length === 0) {
372
+ return undefined;
373
+ }
374
+ return { ...process.env, ...restrictionEnv, ...env };
375
+ }
376
+
377
+ function buildSession({ selenium, serverUrl, browser, options }) {
378
+ const builder = new selenium.webdriver.Builder()
379
+ .usingServer(serverUrl)
380
+ .forBrowser(DRIVERS[browser].builder);
381
+ if (browser === 'chrome') {
382
+ builder.setChromeOptions(options);
383
+ } else {
384
+ builder.setFirefoxOptions(options);
385
+ }
386
+ return builder.build();
387
+ }
388
+
389
+ /**
390
+ * Wrap a no-argument async function so every call shares the first result.
391
+ * @param {function(): Promise<void>} fn
392
+ * @returns {function(): Promise<void>}
393
+ */
394
+ function memoize(fn) {
395
+ let result;
396
+ return () => (result ??= fn());
397
+ }
398
+
399
+ /**
400
+ * Stop whatever part of a WebDriver session exists, ignoring failures of
401
+ * pieces that are already gone: the page's BiDi subscriptions, the session,
402
+ * the driver server and a temporary profile.
403
+ */
404
+ async function teardown({
405
+ page,
406
+ driver,
407
+ driverProcess,
408
+ temporaryProfile = false,
409
+ userDataDir,
410
+ }) {
411
+ await page?.dispose().catch(() => {});
412
+ await driver?.quit().catch(() => {});
413
+ driverProcess?.kill();
414
+ await driverProcess?.exited?.catch(() => {});
415
+ if (temporaryProfile) {
416
+ await removeUserDataDir(userDataDir);
417
+ }
418
+ }
419
+
420
+ /**
421
+ * Launch a browser under a WebDriver server this process starts and owns.
422
+ *
423
+ * @param {Object} [options]
424
+ * @param {'chrome'|'firefox'} [options.browser='chrome']
425
+ * @param {string} [options.executablePath] - Browser executable
426
+ * @param {string} [options.driverPath] - chromedriver/geckodriver executable
427
+ * @param {boolean} [options.headless=false]
428
+ * @param {string} [options.userDataDir] - Profile directory; a temporary one is created (and removed on close) when omitted
429
+ * @param {boolean} [options.bidi=false] - Enable WebDriver BiDi (events, preload scripts, full-page screenshots)
430
+ * @param {string[]} [options.args] - Extra browser arguments
431
+ * @param {string[]} [options.restrictions] - Chrome launch restrictions (launch-restrictions.json)
432
+ * @param {boolean} [options.automationParity=true] - Keep navigator.webdriver false in headless Chrome
433
+ * @param {Object<string,string>} [options.env] - Extra environment for the driver and browser
434
+ * @param {number} [options.startupTimeout=30000]
435
+ * @param {boolean} [options.verbose=false] - Mirror the driver server's output
436
+ * @param {Object} [dependencies] - Injected selenium modules, runCommand, startProcess, fetch, reservePort, environment, platform (tests)
437
+ * @returns {Promise<{driver: Object, page: Object, close: function(): Promise<void>, serverUrl: string, driverProcess: Object, driverPath: string, driverSource: string, browser: string, userDataDir: string, temporaryProfile: boolean, args: Array<string>, bidi: boolean}>}
438
+ */
439
+ export async function launchWebDriver(options = {}, dependencies = {}) {
440
+ const {
441
+ browser = 'chrome',
442
+ executablePath,
443
+ driverPath: requestedDriverPath,
444
+ headless = false,
445
+ userDataDir: requestedUserDataDir,
446
+ bidi = false,
447
+ args = [],
448
+ restrictions = [],
449
+ automationParity = true,
450
+ env,
451
+ startupTimeout = 30_000,
452
+ verbose = false,
453
+ } = options;
454
+ assertBrowser(browser);
455
+ if (browser === 'firefox' && restrictions.length > 0) {
456
+ throw new Error(
457
+ 'Launch restrictions are Chrome switches; not available for firefox'
458
+ );
459
+ }
460
+
461
+ const selenium = seleniumModules(dependencies);
462
+ const reservePort = dependencies.reservePort ?? reserveLoopbackPort;
463
+ const startProcess = dependencies.startProcess ?? defaultStartProcess;
464
+
465
+ const resolved = await resolveWebDriverExecutable({
466
+ browser,
467
+ driverPath: requestedDriverPath,
468
+ executablePath,
469
+ runCommand: dependencies.runCommand ?? defaultRunCommand,
470
+ ...(dependencies.checkAccess
471
+ ? { checkAccess: dependencies.checkAccess }
472
+ : {}),
473
+ ...(dependencies.environment
474
+ ? { environment: dependencies.environment }
475
+ : {}),
476
+ ...(dependencies.platform ? { platform: dependencies.platform } : {}),
477
+ });
478
+
479
+ const temporaryProfile = !requestedUserDataDir;
480
+ const userDataDir = await resolveProfile(browser, requestedUserDataDir);
481
+ const childEnv = driverEnvironment(restrictions, env);
482
+
483
+ let driverProcess;
484
+ let driver;
485
+ try {
486
+ const port = await reservePort();
487
+ const serverUrl = `http://${LOOPBACK_HOST}:${port}`;
488
+ driverProcess = startProcess(
489
+ resolved.driverPath,
490
+ buildDriverServerArgs(browser, port),
491
+ { env: childEnv, forwardOutput: verbose }
492
+ );
493
+ let output = '';
494
+ driverProcess.stderr?.on('data', (chunk) => {
495
+ output = `${output}${chunk}`.slice(-4000);
496
+ });
497
+ await waitForDriverReady(serverUrl, {
498
+ fetchImplementation: dependencies.fetch ?? fetch,
499
+ timeout: startupTimeout,
500
+ driverProcess,
501
+ output: () => output,
502
+ });
503
+
504
+ const built = buildBrowserOptions({
505
+ selenium,
506
+ browser,
507
+ browserPath: resolved.browserPath,
508
+ userDataDir,
509
+ remoteDebuggingPort:
510
+ browser === 'chrome' ? await reservePort() : undefined,
511
+ headless,
512
+ restrictions,
513
+ args,
514
+ automationParity,
515
+ bidi,
516
+ });
517
+ driver = await buildSession({
518
+ selenium,
519
+ serverUrl,
520
+ browser,
521
+ options: built.options,
522
+ });
523
+ const page = await createWebDriverPage(driver, dependencies.pageOptions);
524
+
525
+ const close = memoize(() =>
526
+ teardown({ page, driver, driverProcess, temporaryProfile, userDataDir })
527
+ );
528
+
529
+ return {
530
+ driver,
531
+ page,
532
+ close,
533
+ serverUrl,
534
+ driverProcess,
535
+ driverPath: resolved.driverPath,
536
+ driverSource: resolved.source,
537
+ browser,
538
+ userDataDir,
539
+ temporaryProfile,
540
+ args: built.args,
541
+ bidi,
542
+ };
543
+ } catch (error) {
544
+ await teardown({ driver, driverProcess, temporaryProfile, userDataDir });
545
+ throw error;
546
+ }
547
+ }
548
+
549
+ /**
550
+ * Open a session on a WebDriver server that is already running (a driver
551
+ * started elsewhere, a Selenium Grid, a cloud provider).
552
+ *
553
+ * @param {Object} options
554
+ * @param {string} options.serverUrl - e.g. 'http://127.0.0.1:4444'
555
+ * @param {Object} [options.capabilities] - W3C capabilities; `browserName` defaults to 'chrome'. Set `webSocketUrl: true` for BiDi.
556
+ * @param {Object} [dependencies] - Injected selenium modules (tests)
557
+ * @returns {Promise<{driver: Object, page: Object, close: function(): Promise<void>, serverUrl: string}>}
558
+ */
559
+ export async function connectWebDriver(options = {}, dependencies = {}) {
560
+ const { serverUrl, capabilities = {} } = options;
561
+ if (!serverUrl) {
562
+ throw new Error('serverUrl is required to connect to a WebDriver server');
563
+ }
564
+ const { webdriver } = dependencies.selenium ?? { webdriver: loadSelenium() };
565
+ const driver = await new webdriver.Builder()
566
+ .usingServer(serverUrl)
567
+ .withCapabilities({ browserName: 'chrome', ...capabilities })
568
+ .build();
569
+ const page = await createWebDriverPage(driver, dependencies.pageOptions);
570
+ const close = memoize(() => teardown({ page, driver }));
571
+ return { driver, page, close, serverUrl };
572
+ }