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