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