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
package/src/browser/launcher.js
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
import {
|
|
1
|
+
import { access } from 'node:fs/promises';
|
|
2
|
+
|
|
3
|
+
import { assertSupportedEngine } from './connector.js';
|
|
4
4
|
import { emulateMedia } from './media.js';
|
|
5
5
|
import {
|
|
6
6
|
buildPlaywrightLaunchOptions,
|
|
7
7
|
buildPuppeteerLaunchOptions,
|
|
8
8
|
resolveChromeArgs,
|
|
9
9
|
} from './launch-options.js';
|
|
10
|
+
import {
|
|
11
|
+
createTemporaryUserDataDir,
|
|
12
|
+
removeUserDataDir,
|
|
13
|
+
} from './profile-directory.js';
|
|
14
|
+
import { launchRealBrowser } from './real-browser.js';
|
|
15
|
+
import { resolveRestrictions } from './restrictions.js';
|
|
16
|
+
import { resolveSystemBrowserExecutable } from './system-browser.js';
|
|
10
17
|
import { applyFingerprint } from '../fingerprint/apply.js';
|
|
11
18
|
import { attachDownloads } from '../downloads/attach.js';
|
|
12
19
|
import {
|
|
@@ -15,171 +22,360 @@ import {
|
|
|
15
22
|
restorePuppeteerStorageState,
|
|
16
23
|
} from './storage-state.js';
|
|
17
24
|
|
|
25
|
+
/** How `launchBrowser` starts the browser (issue #103). */
|
|
26
|
+
export const LAUNCH_MODES = Object.freeze(['real', 'engine']);
|
|
27
|
+
|
|
28
|
+
const loadEngine = {
|
|
29
|
+
playwright: async () => (await import('playwright')).chromium,
|
|
30
|
+
puppeteer: async () => (await import('puppeteer')).default,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function validateLaunchMode({ engine, launch, attach }) {
|
|
34
|
+
assertSupportedEngine(engine);
|
|
35
|
+
if (!LAUNCH_MODES.includes(launch)) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
`Invalid launch mode: ${launch}. Expected 'real' or 'engine'`
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
if (attach !== undefined && launch !== 'real') {
|
|
41
|
+
throw new TypeError(
|
|
42
|
+
"attach needs launch: 'real'; an engine launch starts its own profile"
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function isExecutable(file) {
|
|
48
|
+
try {
|
|
49
|
+
await access(file);
|
|
50
|
+
return true;
|
|
51
|
+
} catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Pick the browser binary for a real launch.
|
|
58
|
+
*
|
|
59
|
+
* An explicit `executablePath` or `channel` is honoured as given. Without
|
|
60
|
+
* either, the installed Google Chrome is preferred - it is the browser a
|
|
61
|
+
* person would start - and the engine's own downloaded Chromium is the
|
|
62
|
+
* fallback, so a machine with only `npx playwright install` still works. The
|
|
63
|
+
* binary is spawned by Browser Commander either way, with the same clean
|
|
64
|
+
* command line.
|
|
65
|
+
*/
|
|
66
|
+
export async function resolveLaunchExecutable(
|
|
67
|
+
{ engine, channel, executablePath },
|
|
68
|
+
{ resolveSystem = resolveSystemBrowserExecutable, loadEngineModule } = {}
|
|
69
|
+
) {
|
|
70
|
+
if (executablePath !== undefined || channel !== undefined) {
|
|
71
|
+
return await resolveSystem({
|
|
72
|
+
channel: channel ?? 'chrome',
|
|
73
|
+
executablePath,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
return await resolveSystem({ channel: 'chrome' });
|
|
78
|
+
} catch (error) {
|
|
79
|
+
const bundled = await (loadEngineModule ?? loadEngine[engine])()
|
|
80
|
+
.then((module) => module.executablePath())
|
|
81
|
+
.catch(() => undefined);
|
|
82
|
+
if (bundled && (await isExecutable(bundled))) {
|
|
83
|
+
return bundled;
|
|
84
|
+
}
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function launchReal(options, dependencies) {
|
|
90
|
+
const {
|
|
91
|
+
engine,
|
|
92
|
+
channel,
|
|
93
|
+
executablePath,
|
|
94
|
+
slowMo,
|
|
95
|
+
verbose,
|
|
96
|
+
storageState,
|
|
97
|
+
...rest
|
|
98
|
+
} = options;
|
|
99
|
+
const launchRealImplementation =
|
|
100
|
+
dependencies.launchRealBrowser ?? launchRealBrowser;
|
|
101
|
+
const session = await launchRealImplementation({
|
|
102
|
+
...rest,
|
|
103
|
+
// The channel still names the browser whose profile an attach snapshot
|
|
104
|
+
// copies and a migration targets; the executable is resolved here.
|
|
105
|
+
...(channel ? { channel } : {}),
|
|
106
|
+
engine,
|
|
107
|
+
verbose,
|
|
108
|
+
storageState,
|
|
109
|
+
executablePath: await resolveLaunchExecutable(
|
|
110
|
+
{ engine, channel, executablePath },
|
|
111
|
+
dependencies
|
|
112
|
+
),
|
|
113
|
+
...(slowMo ? { slowMo } : {}),
|
|
114
|
+
});
|
|
115
|
+
const {
|
|
116
|
+
browser: connectedBrowser,
|
|
117
|
+
page,
|
|
118
|
+
close,
|
|
119
|
+
downloads: _none,
|
|
120
|
+
...meta
|
|
121
|
+
} = session;
|
|
122
|
+
// Playwright's persistent-context launch returned a BrowserContext, so the
|
|
123
|
+
// real launch returns the attached default context the same way; closing
|
|
124
|
+
// it closes the browser this call started.
|
|
125
|
+
const browser =
|
|
126
|
+
engine === 'playwright' ? connectedBrowser.contexts()[0] : connectedBrowser;
|
|
127
|
+
if (browser !== connectedBrowser) {
|
|
128
|
+
browser.close = close;
|
|
129
|
+
}
|
|
130
|
+
return { browser, page, close, connectedBrowser, ...meta };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function launchWithEngine(options, dependencies) {
|
|
134
|
+
const {
|
|
135
|
+
engine,
|
|
136
|
+
userDataDir: requestedUserDataDir,
|
|
137
|
+
headless,
|
|
138
|
+
slowMo,
|
|
139
|
+
args,
|
|
140
|
+
extraArgs,
|
|
141
|
+
ignoreDefaultArgs,
|
|
142
|
+
restrictions,
|
|
143
|
+
env,
|
|
144
|
+
colorScheme,
|
|
145
|
+
channel,
|
|
146
|
+
executablePath,
|
|
147
|
+
automationParity,
|
|
148
|
+
storageState,
|
|
149
|
+
} = options;
|
|
150
|
+
const chrome = resolveChromeArgs({
|
|
151
|
+
args,
|
|
152
|
+
extraArgs,
|
|
153
|
+
ignoreDefaultArgs,
|
|
154
|
+
restrictions,
|
|
155
|
+
});
|
|
156
|
+
const restrictionEnv = resolveRestrictions(restrictions).env;
|
|
157
|
+
const childEnv =
|
|
158
|
+
env || Object.keys(restrictionEnv).length > 0
|
|
159
|
+
? { ...process.env, ...restrictionEnv, ...env }
|
|
160
|
+
: undefined;
|
|
161
|
+
const temporaryProfile = !requestedUserDataDir;
|
|
162
|
+
const userDataDir =
|
|
163
|
+
requestedUserDataDir ?? (await createTemporaryUserDataDir());
|
|
164
|
+
const engineModule = await (
|
|
165
|
+
dependencies.loadEngineModule ?? loadEngine[engine]
|
|
166
|
+
)();
|
|
167
|
+
const selection = { channel, executablePath };
|
|
168
|
+
|
|
169
|
+
let browser;
|
|
170
|
+
let page;
|
|
171
|
+
try {
|
|
172
|
+
if (engine === 'playwright') {
|
|
173
|
+
browser = await engineModule.launchPersistentContext(userDataDir, {
|
|
174
|
+
...buildPlaywrightLaunchOptions({
|
|
175
|
+
headless,
|
|
176
|
+
slowMo,
|
|
177
|
+
chromeArgs: chrome.args,
|
|
178
|
+
colorScheme,
|
|
179
|
+
...selection,
|
|
180
|
+
ignoreDefaultArgs: chrome.ignoreDefaultArgs,
|
|
181
|
+
automationParity,
|
|
182
|
+
}),
|
|
183
|
+
...(childEnv ? { env: childEnv } : {}),
|
|
184
|
+
});
|
|
185
|
+
page = browser.pages()[0] ?? (await browser.newPage());
|
|
186
|
+
await restorePlaywrightStorageState({ context: browser, storageState });
|
|
187
|
+
} else {
|
|
188
|
+
browser = await engineModule.launch({
|
|
189
|
+
...buildPuppeteerLaunchOptions({
|
|
190
|
+
headless,
|
|
191
|
+
chromeArgs: chrome.args,
|
|
192
|
+
userDataDir,
|
|
193
|
+
...selection,
|
|
194
|
+
ignoreDefaultArgs: chrome.ignoreDefaultArgs,
|
|
195
|
+
automationParity,
|
|
196
|
+
}),
|
|
197
|
+
...(slowMo ? { slowMo } : {}),
|
|
198
|
+
...(childEnv ? { env: childEnv } : {}),
|
|
199
|
+
});
|
|
200
|
+
page = (await browser.pages())[0] ?? (await browser.newPage());
|
|
201
|
+
await restorePuppeteerStorageState({ page, storageState });
|
|
202
|
+
}
|
|
203
|
+
} catch (error) {
|
|
204
|
+
await browser?.close().catch(() => {});
|
|
205
|
+
if (temporaryProfile) {
|
|
206
|
+
await removeUserDataDir(userDataDir);
|
|
207
|
+
}
|
|
208
|
+
throw error;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const originalClose = browser.close.bind(browser);
|
|
212
|
+
let closing;
|
|
213
|
+
const close = () => {
|
|
214
|
+
closing ??= (async () => {
|
|
215
|
+
await originalClose().catch(() => {});
|
|
216
|
+
if (temporaryProfile) {
|
|
217
|
+
await removeUserDataDir(userDataDir);
|
|
218
|
+
}
|
|
219
|
+
})();
|
|
220
|
+
return closing;
|
|
221
|
+
};
|
|
222
|
+
browser.close = close;
|
|
223
|
+
return {
|
|
224
|
+
browser,
|
|
225
|
+
page,
|
|
226
|
+
close,
|
|
227
|
+
userDataDir,
|
|
228
|
+
temporaryProfile,
|
|
229
|
+
args: chrome.args,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function unfocusAddressBar(page, verbose, settleMs = 500) {
|
|
234
|
+
// Bringing the page to front moves focus from the address bar to the page.
|
|
235
|
+
try {
|
|
236
|
+
await new Promise((resolve) => setTimeout(resolve, settleMs));
|
|
237
|
+
await page.bringToFront();
|
|
238
|
+
if (verbose) {
|
|
239
|
+
console.log('✅ Address bar unfocused automatically');
|
|
240
|
+
}
|
|
241
|
+
} catch (error) {
|
|
242
|
+
// Ignore errors - this is just a UX improvement
|
|
243
|
+
if (verbose) {
|
|
244
|
+
console.log('⚠️ Could not unfocus address bar:', error.message);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
18
249
|
/**
|
|
19
|
-
* Launch browser
|
|
250
|
+
* Launch a browser.
|
|
251
|
+
*
|
|
252
|
+
* By default (`launch: 'real'`) Browser Commander starts the installed Chrome
|
|
253
|
+
* itself, exactly like a person who wants to attach a debugger would:
|
|
254
|
+
* `--user-data-dir=<fresh temporary profile> --remote-debugging-port=<reserved
|
|
255
|
+
* port>` and nothing else, then attaches the engine over CDP (issues #101 and
|
|
256
|
+
* #103). The profile is deleted on close. Every restriction the library used
|
|
257
|
+
* to add silently is an explicit opt-in through `restrictions`.
|
|
258
|
+
*
|
|
259
|
+
* `launch: 'engine'` keeps the Playwright/Puppeteer-launched browser for CI
|
|
260
|
+
* and headless use; the switches those engines add are listed in
|
|
261
|
+
* limitations.json (`engine-launch-switches`).
|
|
262
|
+
*
|
|
20
263
|
* @param {Object} options - Configuration options
|
|
21
|
-
* @param {string} options.engine - Browser automation engine: 'playwright' or 'puppeteer'
|
|
22
|
-
* @param {
|
|
23
|
-
* @param {
|
|
24
|
-
* @param {
|
|
25
|
-
* @param {
|
|
26
|
-
* @param {
|
|
27
|
-
* @param {string[]} [options.
|
|
28
|
-
* @param {
|
|
264
|
+
* @param {string} [options.engine='playwright'] - Browser automation engine: 'playwright' or 'puppeteer'
|
|
265
|
+
* @param {'real'|'engine'} [options.launch='real'] - Who starts the browser: Browser Commander (a hand-started command line) or the automation engine
|
|
266
|
+
* @param {string} [options.userDataDir] - Persistent profile directory. When omitted a fresh temporary profile is created and deleted on close.
|
|
267
|
+
* @param {boolean} [options.headless=false] - Run in headless mode
|
|
268
|
+
* @param {number} [options.slowMo=0] - Slow down operations by ms
|
|
269
|
+
* @param {boolean} [options.verbose=false] - Enable verbose logging
|
|
270
|
+
* @param {string[]} [options.restrictions] - Opt-in restrictions from launch-restrictions.json, such as 'no-extensions' or the 'legacy-defaults' preset
|
|
271
|
+
* @param {string[]} [options.args] - Additional Chrome arguments
|
|
272
|
+
* @param {string[]} [options.extraArgs] - Additional Chrome arguments appended after args
|
|
273
|
+
* @param {boolean|string[]} [options.ignoreDefaultArgs] - Engine default switches to omit, or true for all ('engine' launch only)
|
|
274
|
+
* @param {Object<string,string>} [options.env] - Extra environment for the browser process only
|
|
29
275
|
* @param {string|null} [options.colorScheme] - Emulate color scheme: 'light', 'dark', 'no-preference', or null to reset
|
|
30
|
-
* @param {string} [options.channel] - Installed browser channel, such as 'chrome', 'chrome-beta', or '
|
|
276
|
+
* @param {string} [options.channel] - Installed browser channel, such as 'chrome', 'chrome-beta', 'msedge', 'brave' or 'chromium'
|
|
31
277
|
* @param {string} [options.executablePath] - Explicit path to a Chrome or Chromium executable
|
|
278
|
+
* @param {number} [options.remoteDebuggingPort] - Fixed CDP port for the real launch; a free one is reserved when omitted
|
|
32
279
|
* @param {string|Object} [options.storageState] - Playwright-compatible storage state path or object
|
|
33
280
|
* @param {Object} [options.fingerprint] - Environment fields to present to pages, such as userAgent, timezone, locale, hardwareConcurrency or screen. Applied over CDP after launch; see src/fingerprint/profile.js for the full field list and presets.js for ready-made profiles.
|
|
34
|
-
* @param {boolean} [options.automationParity=true] - Keep
|
|
281
|
+
* @param {boolean} [options.automationParity=true] - Keep navigator.webdriver false where a launch switch would turn it on (headless or engine launches)
|
|
35
282
|
* @param {boolean|Object} [options.downloads] - Manage downloads: true for defaults, or {directory, persist, conflict}. The directory may be an absolute path, 'user-downloads' or 'temporary'.
|
|
36
|
-
* @returns {Promise<{browser: Object, page: Object, downloads: Object|null}>}
|
|
283
|
+
* @returns {Promise<{browser: Object, page: Object, downloads: (Object|null), close: function(): Promise<void>, launch: string, userDataDir: string, temporaryProfile: boolean, args: Array<string>}>} Browser (a BrowserContext for Playwright), page, download manager and launch metadata
|
|
37
284
|
*/
|
|
38
285
|
export async function launchBrowser(options = {}) {
|
|
286
|
+
return await launchBrowserWithDependencies(options);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Dependency-injected implementation used by {@link launchBrowser} and tests. */
|
|
290
|
+
export async function launchBrowserWithDependencies(
|
|
291
|
+
options = {},
|
|
292
|
+
dependencies = {}
|
|
293
|
+
) {
|
|
39
294
|
const {
|
|
40
295
|
engine = 'playwright',
|
|
41
|
-
|
|
296
|
+
launch = 'real',
|
|
42
297
|
headless = false,
|
|
43
|
-
slowMo =
|
|
298
|
+
slowMo = 0,
|
|
44
299
|
verbose = false,
|
|
45
300
|
args = [],
|
|
46
301
|
extraArgs = [],
|
|
47
302
|
ignoreDefaultArgs = [],
|
|
303
|
+
restrictions = [],
|
|
48
304
|
colorScheme,
|
|
49
|
-
channel,
|
|
50
|
-
executablePath,
|
|
51
305
|
storageState,
|
|
52
306
|
fingerprint,
|
|
53
307
|
automationParity = true,
|
|
54
308
|
downloads,
|
|
309
|
+
...rest
|
|
55
310
|
} = options;
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
extraArgs,
|
|
60
|
-
ignoreDefaultArgs,
|
|
61
|
-
});
|
|
62
|
-
const chromeArgs = resolvedChromeArgs.args;
|
|
63
|
-
|
|
64
|
-
if (!['playwright', 'puppeteer'].includes(engine)) {
|
|
65
|
-
throw new Error(
|
|
66
|
-
`Invalid engine: ${engine}. Expected 'playwright' or 'puppeteer'`
|
|
67
|
-
);
|
|
68
|
-
}
|
|
69
|
-
|
|
311
|
+
validateLaunchMode({ engine, launch, attach: rest.attach });
|
|
312
|
+
// Validate arguments before anything is started or written to disk.
|
|
313
|
+
resolveChromeArgs({ args, extraArgs, ignoreDefaultArgs, restrictions });
|
|
70
314
|
const resolvedStorageState = await loadStorageState(storageState);
|
|
71
315
|
|
|
72
|
-
// Set environment variables to suppress warnings
|
|
73
|
-
process.env.GOOGLE_API_KEY = 'no';
|
|
74
|
-
process.env.GOOGLE_DEFAULT_CLIENT_ID = 'no';
|
|
75
|
-
process.env.GOOGLE_DEFAULT_CLIENT_SECRET = 'no';
|
|
76
|
-
|
|
77
|
-
// Disable translate in Preferences
|
|
78
|
-
await disableTranslateInPreferences({ userDataDir });
|
|
79
|
-
|
|
80
316
|
if (verbose) {
|
|
81
|
-
console.log(`🚀 Launching browser with ${engine} engine...`);
|
|
317
|
+
console.log(`🚀 Launching browser with ${engine} engine (${launch})...`);
|
|
82
318
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
page = browser.pages()[0];
|
|
104
|
-
await restorePlaywrightStorageState({
|
|
105
|
-
context: browser,
|
|
106
|
-
storageState: resolvedStorageState,
|
|
107
|
-
});
|
|
108
|
-
} else {
|
|
109
|
-
const puppeteer = await import('puppeteer');
|
|
110
|
-
browser = await puppeteer.default.launch(
|
|
111
|
-
buildPuppeteerLaunchOptions({
|
|
112
|
-
headless,
|
|
113
|
-
chromeArgs,
|
|
114
|
-
userDataDir,
|
|
115
|
-
channel,
|
|
116
|
-
executablePath,
|
|
117
|
-
ignoreDefaultArgs: resolvedChromeArgs.ignoreDefaultArgs,
|
|
118
|
-
automationParity,
|
|
119
|
-
})
|
|
120
|
-
);
|
|
121
|
-
const pages = await browser.pages();
|
|
122
|
-
page = pages[0];
|
|
123
|
-
await restorePuppeteerStorageState({
|
|
124
|
-
page,
|
|
125
|
-
storageState: resolvedStorageState,
|
|
126
|
-
});
|
|
127
|
-
}
|
|
128
|
-
|
|
319
|
+
const common = {
|
|
320
|
+
...rest,
|
|
321
|
+
engine,
|
|
322
|
+
headless,
|
|
323
|
+
slowMo,
|
|
324
|
+
verbose,
|
|
325
|
+
args,
|
|
326
|
+
extraArgs,
|
|
327
|
+
restrictions,
|
|
328
|
+
automationParity,
|
|
329
|
+
storageState: resolvedStorageState,
|
|
330
|
+
};
|
|
331
|
+
const launched =
|
|
332
|
+
launch === 'engine'
|
|
333
|
+
? await launchWithEngine(
|
|
334
|
+
{ ...common, ignoreDefaultArgs, colorScheme },
|
|
335
|
+
dependencies
|
|
336
|
+
)
|
|
337
|
+
: await launchReal(common, dependencies);
|
|
338
|
+
const { browser, page } = launched;
|
|
129
339
|
if (verbose) {
|
|
130
340
|
console.log(`✅ Browser launched with ${engine} engine`);
|
|
131
341
|
}
|
|
132
342
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
343
|
+
try {
|
|
344
|
+
// A Playwright-launched context applies colorScheme itself; an attached
|
|
345
|
+
// browser and Puppeteer need page-level emulation.
|
|
346
|
+
const emulateColorScheme =
|
|
347
|
+
colorScheme !== undefined &&
|
|
348
|
+
(launch === 'real' || engine === 'puppeteer');
|
|
349
|
+
if (emulateColorScheme) {
|
|
350
|
+
await emulateMedia({ page, engine, colorScheme }).catch((error) => {
|
|
351
|
+
if (verbose) {
|
|
352
|
+
console.log(`⚠️ Could not set color scheme: ${error.message}`);
|
|
353
|
+
}
|
|
354
|
+
});
|
|
144
355
|
}
|
|
145
|
-
}
|
|
146
356
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
357
|
+
// The fingerprint is applied before the caller can navigate, so the first
|
|
358
|
+
// document a page loads already sees the configured environment.
|
|
359
|
+
if (fingerprint !== undefined) {
|
|
360
|
+
await applyFingerprint({ browser, page, engine, profile: fingerprint });
|
|
361
|
+
if (verbose) {
|
|
362
|
+
console.log('✅ Fingerprint profile applied');
|
|
363
|
+
}
|
|
153
364
|
}
|
|
154
|
-
}
|
|
155
365
|
|
|
156
|
-
|
|
157
|
-
// Using page.bringToFront() - confirmed working solution
|
|
158
|
-
try {
|
|
159
|
-
// Wait for the browser to fully initialize
|
|
160
|
-
await new Promise((r) => setTimeout(r, 500));
|
|
366
|
+
await unfocusAddressBar(page, verbose, dependencies.settleMs);
|
|
161
367
|
|
|
162
|
-
//
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
368
|
+
// Downloads are armed before the caller can navigate: a download triggered
|
|
369
|
+
// by the first page load still lands in the managed directory.
|
|
370
|
+
const downloadManager = await attachDownloads({
|
|
371
|
+
engine,
|
|
372
|
+
browser,
|
|
373
|
+
page,
|
|
374
|
+
downloads,
|
|
375
|
+
});
|
|
376
|
+
return { ...launched, launch, downloads: downloadManager };
|
|
168
377
|
} catch (error) {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
console.log('⚠️ Could not unfocus address bar:', error.message);
|
|
172
|
-
}
|
|
378
|
+
await launched.close();
|
|
379
|
+
throw error;
|
|
173
380
|
}
|
|
174
|
-
|
|
175
|
-
// Downloads are armed before the caller can navigate: a download triggered
|
|
176
|
-
// by the first page load still lands in the managed directory.
|
|
177
|
-
const downloadManager = await attachDownloads({
|
|
178
|
-
engine,
|
|
179
|
-
browser,
|
|
180
|
-
page,
|
|
181
|
-
downloads,
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
return { browser, page, downloads: downloadManager };
|
|
185
381
|
}
|
package/src/browser/media.js
CHANGED
|
@@ -49,6 +49,11 @@ export async function emulateMedia({ page, engine, colorScheme } = {}) {
|
|
|
49
49
|
{ name: 'prefers-color-scheme', value: colorScheme },
|
|
50
50
|
]);
|
|
51
51
|
}
|
|
52
|
+
} else if (engine === 'selenium') {
|
|
53
|
+
throw new Error(
|
|
54
|
+
'emulateMedia is not available with the selenium engine: WebDriver has ' +
|
|
55
|
+
'no media emulation command (see the webdriver-no-cdp-emulation limitation)'
|
|
56
|
+
);
|
|
52
57
|
} else {
|
|
53
58
|
throw new Error(
|
|
54
59
|
`Unsupported engine: ${engine}. Expected 'playwright' or 'puppeteer'`
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { copyFile, mkdir, readFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { pathExists } from './fs-utils.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Bookmarks migration.
|
|
7
|
+
*
|
|
8
|
+
* Chrome stores bookmarks as a single JSON file, `Bookmarks`, in the profile
|
|
9
|
+
* directory, with a `Bookmarks.bak` backup next to it. The file is
|
|
10
|
+
* self-contained (there is no companion database), so a migration copies it as
|
|
11
|
+
* is. Chrome recomputes the tamper-detection `checksum` in `Bookmarks` on the
|
|
12
|
+
* next launch, so the copy is accepted even though its checksum was written for
|
|
13
|
+
* the source profile.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Count the bookmark entries in a Bookmarks JSON tree, for the report.
|
|
18
|
+
*
|
|
19
|
+
* @param {Object} bookmarks
|
|
20
|
+
* @returns {number}
|
|
21
|
+
*/
|
|
22
|
+
export function countBookmarks(bookmarks) {
|
|
23
|
+
let count = 0;
|
|
24
|
+
const visit = (node) => {
|
|
25
|
+
if (!node || typeof node !== 'object') {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (node.type === 'url') {
|
|
29
|
+
count += 1;
|
|
30
|
+
}
|
|
31
|
+
for (const child of node.children ?? []) {
|
|
32
|
+
visit(child);
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
for (const root of Object.values(bookmarks?.roots ?? {})) {
|
|
36
|
+
visit(root);
|
|
37
|
+
}
|
|
38
|
+
return count;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Copy the Bookmarks JSON from a source profile into the target profile.
|
|
43
|
+
*
|
|
44
|
+
* @param {Object} options
|
|
45
|
+
* @param {string} options.sourceProfileDir
|
|
46
|
+
* @param {string} options.targetProfileDir
|
|
47
|
+
* @returns {Promise<{migrated: number, skipped: Array, warnings: Array}>}
|
|
48
|
+
*/
|
|
49
|
+
export async function migrateBookmarks({ sourceProfileDir, targetProfileDir }) {
|
|
50
|
+
const source = path.join(sourceProfileDir, 'Bookmarks');
|
|
51
|
+
if (!(await pathExists(source))) {
|
|
52
|
+
return {
|
|
53
|
+
migrated: 0,
|
|
54
|
+
skipped: [
|
|
55
|
+
{
|
|
56
|
+
type: 'bookmarks',
|
|
57
|
+
item: 'Bookmarks',
|
|
58
|
+
reason: 'source-has-no-bookmarks',
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
warnings: [],
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
let count = 0;
|
|
65
|
+
try {
|
|
66
|
+
count = countBookmarks(JSON.parse(await readFile(source, 'utf8')));
|
|
67
|
+
} catch {
|
|
68
|
+
// A count is best-effort; the copy is what matters.
|
|
69
|
+
}
|
|
70
|
+
await mkdir(targetProfileDir, { recursive: true });
|
|
71
|
+
await copyFile(source, path.join(targetProfileDir, 'Bookmarks'));
|
|
72
|
+
return { migrated: count, skipped: [], warnings: [] };
|
|
73
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { createCipheriv, randomBytes } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Re-encryption for Chromium os_crypt values (passwords, and any other value
|
|
5
|
+
* stored the same way as cookies).
|
|
6
|
+
*
|
|
7
|
+
* `browser-cookie-crypto.js` already decrypts these values; this module is the
|
|
8
|
+
* write side, so a value decrypted from the source profile can be encrypted for
|
|
9
|
+
* the dedicated target profile's key. It is dependency-injected end to end so
|
|
10
|
+
* the whole path is unit-testable on Linux with a fabricated key.
|
|
11
|
+
*
|
|
12
|
+
* The wire format matches what Chromium's `OSCrypt` writes:
|
|
13
|
+
* - macOS/Linux: a `v10`/`v11` prefix followed by AES-128-CBC with a fixed
|
|
14
|
+
* 16-byte IV of `0x20`. The key is PBKDF2-SHA1 of the Safe Storage password.
|
|
15
|
+
* - Windows: a `v10` prefix followed by a 12-byte nonce, AES-256-GCM
|
|
16
|
+
* ciphertext, and the 16-byte auth tag. The key is the random 256-bit key in
|
|
17
|
+
* `Local State` `os_crypt.encrypted_key`, itself DPAPI-protected.
|
|
18
|
+
*
|
|
19
|
+
* Unlike the Cookies database at schema version 24, `Login Data` values do not
|
|
20
|
+
* carry the SHA-256(host) domain-hash prefix, so nothing is prepended here.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const CBC_IV = Buffer.alloc(16, 0x20);
|
|
24
|
+
const GCM_NONCE_BYTES = 12;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Encrypt a value the way Chromium's OSCrypt does for the given platform.
|
|
28
|
+
*
|
|
29
|
+
* @param {Object} options
|
|
30
|
+
* @param {string|Buffer} options.plaintext
|
|
31
|
+
* @param {Buffer} options.key
|
|
32
|
+
* @param {string} options.platform - 'darwin' | 'linux' | 'win32'
|
|
33
|
+
* @param {string} [options.prefix] - Version prefix ('v10' or 'v11')
|
|
34
|
+
* @returns {Buffer} The encrypted_value blob
|
|
35
|
+
*/
|
|
36
|
+
export function encryptChromiumValue({ plaintext, key, platform, prefix }) {
|
|
37
|
+
if (!Buffer.isBuffer(key)) {
|
|
38
|
+
throw new TypeError('a target encryption key is required');
|
|
39
|
+
}
|
|
40
|
+
const data = Buffer.isBuffer(plaintext)
|
|
41
|
+
? plaintext
|
|
42
|
+
: Buffer.from(String(plaintext), 'utf8');
|
|
43
|
+
if (platform === 'win32') {
|
|
44
|
+
const nonce = randomBytes(GCM_NONCE_BYTES);
|
|
45
|
+
const cipher = createCipheriv('aes-256-gcm', key, nonce);
|
|
46
|
+
const ciphertext = Buffer.concat([cipher.update(data), cipher.final()]);
|
|
47
|
+
return Buffer.concat([
|
|
48
|
+
Buffer.from(prefix ?? 'v10'),
|
|
49
|
+
nonce,
|
|
50
|
+
ciphertext,
|
|
51
|
+
cipher.getAuthTag(),
|
|
52
|
+
]);
|
|
53
|
+
}
|
|
54
|
+
if (platform === 'darwin' || platform === 'linux') {
|
|
55
|
+
const cipher = createCipheriv('aes-128-cbc', key, CBC_IV);
|
|
56
|
+
const ciphertext = Buffer.concat([cipher.update(data), cipher.final()]);
|
|
57
|
+
return Buffer.concat([Buffer.from(prefix ?? 'v10'), ciphertext]);
|
|
58
|
+
}
|
|
59
|
+
throw new Error(`Chromium value encryption is unsupported on ${platform}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Build the `os_crypt.encrypted_key` a fresh Windows profile needs.
|
|
64
|
+
*
|
|
65
|
+
* Chromium generates a random 256-bit key, DPAPI-protects it in the current
|
|
66
|
+
* user's context, prepends the ASCII tag `DPAPI`, and base64-encodes the result
|
|
67
|
+
* into `Local State`. This mirrors that so a target profile can decrypt values
|
|
68
|
+
* we re-encrypt with the returned raw key. `encryptDpapi` is injected so the
|
|
69
|
+
* generation is testable off Windows.
|
|
70
|
+
*
|
|
71
|
+
* @param {Object} [options]
|
|
72
|
+
* @param {function(Buffer): (Promise<Buffer>|Buffer)} [options.encryptDpapi]
|
|
73
|
+
* @param {function(): Buffer} [options.generateKey]
|
|
74
|
+
* @returns {Promise<{key: Buffer, encryptedKeyBase64: string}>}
|
|
75
|
+
*/
|
|
76
|
+
export async function createWindowsProfileKey({
|
|
77
|
+
encryptDpapi,
|
|
78
|
+
generateKey = () => randomBytes(32),
|
|
79
|
+
} = {}) {
|
|
80
|
+
if (typeof encryptDpapi !== 'function') {
|
|
81
|
+
throw new TypeError(
|
|
82
|
+
'createWindowsProfileKey requires an encryptDpapi function'
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
const key = generateKey();
|
|
86
|
+
const protectedKey = Buffer.from(await encryptDpapi(key));
|
|
87
|
+
const tagged = Buffer.concat([Buffer.from('DPAPI'), protectedKey]);
|
|
88
|
+
return { key, encryptedKeyBase64: tagged.toString('base64') };
|
|
89
|
+
}
|