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.
- package/CHANGELOG.md +145 -0
- package/README.md +216 -32
- package/bin/browser-commander.js +16 -0
- package/eslint.config.js +10 -0
- package/examples/react-test-app/src/App.jsx +24 -23
- package/extension/background.js +14 -0
- package/extension/manifest.json +12 -0
- package/extension/relay-handler.js +353 -0
- package/package.json +19 -4
- package/src/browser/attach/differences.js +131 -0
- package/src/browser/attach/extension-relay.js +402 -0
- package/src/browser/attach/index.js +81 -0
- package/src/browser/attach/snapshot-launch.js +146 -0
- package/src/browser/attach/snapshot.js +374 -0
- package/src/browser/attach/websocket.js +615 -0
- package/src/browser/browser-cookie-cache.js +12 -17
- package/src/browser/browser-cookie-credentials.js +6 -9
- package/src/browser/browser-cookies.js +5 -2
- package/src/browser/cdp-session.js +94 -0
- package/src/browser/connector.js +42 -4
- package/src/browser/debugging-port.js +155 -0
- package/src/browser/launch-options.js +63 -52
- package/src/browser/launch-restrictions.json +147 -0
- package/src/browser/launcher.js +329 -133
- package/src/browser/media.js +5 -0
- package/src/browser/migration/bookmarks.js +73 -0
- package/src/browser/migration/chromium-crypto.js +89 -0
- package/src/browser/migration/cookies.js +155 -0
- package/src/browser/migration/extensions.js +187 -0
- package/src/browser/migration/firefox-bookmarks.js +144 -0
- package/src/browser/migration/firefox-der.js +84 -0
- package/src/browser/migration/firefox-nss.js +281 -0
- package/src/browser/migration/firefox.js +334 -0
- package/src/browser/migration/fs-utils.js +51 -0
- package/src/browser/migration/history.js +103 -0
- package/src/browser/migration/index.js +307 -0
- package/src/browser/migration/os-crypt-keys.js +106 -0
- package/src/browser/migration/passwords.js +171 -0
- package/src/browser/migration/preferences.js +132 -0
- package/src/browser/migration/sqlite-snapshot.js +138 -0
- package/src/browser/open-in-user-browser.js +112 -0
- package/src/browser/parity.js +439 -0
- package/src/browser/profile-directory.js +102 -0
- package/src/browser/real-browser.js +513 -306
- package/src/browser/restrictions.js +148 -0
- package/src/browser/system-browser.js +232 -0
- package/src/browser/webdriver.js +572 -0
- package/src/cli/args.js +253 -0
- package/src/cli/commands.js +332 -0
- package/src/cli/dispatcher.js +99 -0
- package/src/cli/handle-methods.js +136 -0
- package/src/cli/handles.js +273 -0
- package/src/cli/high-level-methods.js +310 -0
- package/src/cli/main.js +67 -0
- package/src/cli/modules.js +100 -0
- package/src/cli/rpc-error.js +77 -0
- package/src/cli/script.js +77 -0
- package/src/cli/serve.js +144 -0
- package/src/cli/sessions.js +195 -0
- package/src/core/constants.js +7 -1
- package/src/core/engine-adapter.js +107 -1
- package/src/core/engine-detection.js +18 -3
- package/src/core/navigation-safety.js +4 -0
- package/src/core/webdriver-conversions.js +347 -0
- package/src/core/webdriver-events.js +229 -0
- package/src/core/webdriver-page.js +716 -0
- package/src/downloads/attach.js +6 -0
- package/src/elements/selectors.js +3 -2
- package/src/elements/visibility.js +4 -0
- package/src/exports.js +61 -2
- package/src/factory.js +19 -4
- package/src/fingerprint/apply.js +6 -0
- package/src/fingerprint/automation-parity.js +7 -8
- package/src/fingerprint/limitations.js +1 -1
- package/src/fingerprint/limitations.json +45 -2
- package/src/parity/harness.js +303 -0
- package/src/parity/probe.js +838 -0
- package/src/parity/version-page.js +144 -0
- package/src/utilities/subprocess.js +199 -0
- package/tests/e2e/api-coverage.e2e.test.js +174 -0
- package/tests/e2e/cli.e2e.test.js +160 -0
- package/tests/e2e/fingerprint-parity.e2e.test.js +104 -46
- package/tests/e2e/google-signin-probe.e2e.test.js +112 -0
- package/tests/e2e/measure-parity.e2e.test.js +118 -0
- package/tests/e2e/playwright.e2e.test.js +25 -17
- package/tests/e2e/puppeteer.e2e.test.js +17 -23
- package/tests/e2e/webdriver.e2e.test.js +239 -0
- package/tests/fixtures/firefox-nss-fixtures.mjs +211 -0
- package/tests/helpers/cli-fakes.js +214 -0
- package/tests/helpers/declared-api.js +80 -0
- package/tests/helpers/e2e-browser.js +40 -14
- package/tests/helpers/fake-chrome.js +124 -0
- package/tests/helpers/migration-fixtures.js +242 -0
- package/tests/helpers/temp-directory.js +24 -0
- package/tests/helpers/webdriver-mocks.js +183 -0
- package/tests/unit/browser/attach/extension-relay.test.js +360 -0
- package/tests/unit/browser/attach/snapshot-launch.test.js +312 -0
- package/tests/unit/browser/attach/snapshot.test.js +390 -0
- package/tests/unit/browser/attach/websocket.test.js +240 -0
- package/tests/unit/browser/browser-cookies.test.js +2 -32
- package/tests/unit/browser/cdp-session.test.js +162 -0
- package/tests/unit/browser/connector.test.js +23 -0
- package/tests/unit/browser/launch-building-blocks.test.js +238 -0
- package/tests/unit/browser/launch-options.test.js +41 -22
- package/tests/unit/browser/launcher.test.js +281 -102
- package/tests/unit/browser/media.test.js +13 -1
- package/tests/unit/browser/migration/bookmarks.test.js +69 -0
- package/tests/unit/browser/migration/chromium-crypto.test.js +94 -0
- package/tests/unit/browser/migration/cookies.test.js +94 -0
- package/tests/unit/browser/migration/extensions.test.js +104 -0
- package/tests/unit/browser/migration/firefox-nss.test.js +113 -0
- package/tests/unit/browser/migration/firefox.test.js +139 -0
- package/tests/unit/browser/migration/history.test.js +48 -0
- package/tests/unit/browser/migration/index.test.js +117 -0
- package/tests/unit/browser/migration/os-crypt-keys.test.js +95 -0
- package/tests/unit/browser/migration/passwords.test.js +144 -0
- package/tests/unit/browser/migration/preferences.test.js +77 -0
- package/tests/unit/browser/migration/sqlite-snapshot.test.js +75 -0
- package/tests/unit/browser/open-in-user-browser.test.js +123 -0
- package/tests/unit/browser/parity.test.js +281 -0
- package/tests/unit/browser/real-browser.test.js +458 -33
- package/tests/unit/browser/webdriver.test.js +473 -0
- package/tests/unit/cli/bin.test.js +69 -0
- package/tests/unit/cli/declared-api.test.js +49 -0
- package/tests/unit/cli/dispatcher.test.js +429 -0
- package/tests/unit/cli/handles.test.js +170 -0
- package/tests/unit/cli/main.test.js +409 -0
- package/tests/unit/cli/serve.test.js +167 -0
- package/tests/unit/core/webdriver-conversions.test.js +235 -0
- package/tests/unit/core/webdriver-page.test.js +534 -0
- package/tests/unit/fingerprint/automation-parity.test.js +17 -4
- package/tests/unit/fingerprint/limitations.test.js +1 -1
- package/tests/unit/parity/harness.test.js +138 -0
- package/tests/unit/scripts/ci-timeout-budgets.test.js +4 -0
- package/tests/unit/scripts/feature-parity.test.js +221 -0
- package/tests/unit/traces/reader.test.js +1 -0
- package/tests/unit/traces/recorder.test.js +1 -0
- package/tests/unit/utilities/command-stream-only.test.js +28 -0
- 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
|
+
}
|