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.
Files changed (155) hide show
  1. package/CHANGELOG.md +156 -0
  2. package/README.md +216 -32
  3. package/bin/browser-commander.js +16 -0
  4. package/eslint.config.js +10 -0
  5. package/examples/react-test-app/src/App.jsx +24 -23
  6. package/extension/background.js +14 -0
  7. package/extension/manifest.json +12 -0
  8. package/extension/relay-handler.js +353 -0
  9. package/package.json +25 -6
  10. package/scripts/run-tests.mjs +4 -2
  11. package/src/browser/attach/differences.js +131 -0
  12. package/src/browser/attach/extension-relay.js +402 -0
  13. package/src/browser/attach/index.js +81 -0
  14. package/src/browser/attach/snapshot-launch.js +146 -0
  15. package/src/browser/attach/snapshot.js +374 -0
  16. package/src/browser/attach/websocket.js +615 -0
  17. package/src/browser/browser-cookie-cache.js +12 -17
  18. package/src/browser/browser-cookie-credentials.js +6 -9
  19. package/src/browser/browser-cookies.js +5 -2
  20. package/src/browser/cdp-session.js +94 -0
  21. package/src/browser/connector.js +42 -4
  22. package/src/browser/debugging-port.js +155 -0
  23. package/src/browser/launch-options.js +63 -52
  24. package/src/browser/launch-restrictions.json +147 -0
  25. package/src/browser/launcher.js +329 -133
  26. package/src/browser/media.js +5 -0
  27. package/src/browser/migration/bookmarks.js +73 -0
  28. package/src/browser/migration/chromium-crypto.js +89 -0
  29. package/src/browser/migration/cookies.js +155 -0
  30. package/src/browser/migration/extensions.js +187 -0
  31. package/src/browser/migration/firefox-bookmarks.js +144 -0
  32. package/src/browser/migration/firefox-der.js +84 -0
  33. package/src/browser/migration/firefox-nss.js +281 -0
  34. package/src/browser/migration/firefox.js +334 -0
  35. package/src/browser/migration/fs-utils.js +51 -0
  36. package/src/browser/migration/history.js +103 -0
  37. package/src/browser/migration/index.js +307 -0
  38. package/src/browser/migration/os-crypt-keys.js +106 -0
  39. package/src/browser/migration/passwords.js +171 -0
  40. package/src/browser/migration/preferences.js +132 -0
  41. package/src/browser/migration/sqlite-snapshot.js +138 -0
  42. package/src/browser/open-in-user-browser.js +112 -0
  43. package/src/browser/parity.js +439 -0
  44. package/src/browser/profile-directory.js +102 -0
  45. package/src/browser/real-browser.js +513 -306
  46. package/src/browser/restrictions.js +148 -0
  47. package/src/browser/system-browser.js +232 -0
  48. package/src/browser/webdriver.js +572 -0
  49. package/src/cli/args.js +253 -0
  50. package/src/cli/commands.js +332 -0
  51. package/src/cli/dispatcher.js +99 -0
  52. package/src/cli/handle-methods.js +136 -0
  53. package/src/cli/handles.js +273 -0
  54. package/src/cli/high-level-methods.js +310 -0
  55. package/src/cli/main.js +67 -0
  56. package/src/cli/modules.js +100 -0
  57. package/src/cli/rpc-error.js +77 -0
  58. package/src/cli/script.js +77 -0
  59. package/src/cli/serve.js +144 -0
  60. package/src/cli/sessions.js +195 -0
  61. package/src/core/constants.js +7 -1
  62. package/src/core/engine-adapter.js +107 -1
  63. package/src/core/engine-detection.js +18 -3
  64. package/src/core/navigation-safety.js +4 -0
  65. package/src/core/webdriver-conversions.js +347 -0
  66. package/src/core/webdriver-events.js +229 -0
  67. package/src/core/webdriver-page.js +716 -0
  68. package/src/downloads/attach.js +6 -0
  69. package/src/downloads/manager.js +9 -0
  70. package/src/downloads/sources.js +71 -0
  71. package/src/downloads/staging.js +21 -9
  72. package/src/elements/selectors.js +3 -2
  73. package/src/elements/visibility.js +4 -0
  74. package/src/exports.js +61 -2
  75. package/src/factory.js +19 -4
  76. package/src/fingerprint/apply.js +6 -0
  77. package/src/fingerprint/automation-parity.js +7 -8
  78. package/src/fingerprint/limitations.js +1 -1
  79. package/src/fingerprint/limitations.json +45 -2
  80. package/src/parity/harness.js +303 -0
  81. package/src/parity/probe.js +838 -0
  82. package/src/parity/version-page.js +144 -0
  83. package/src/traces/bundle.js +10 -2
  84. package/src/traces/recorder.js +16 -3
  85. package/src/utilities/subprocess.js +199 -0
  86. package/tests/e2e/api-coverage.e2e.test.js +174 -0
  87. package/tests/e2e/cli.e2e.test.js +160 -0
  88. package/tests/e2e/downloads.e2e.test.js +79 -2
  89. package/tests/e2e/fingerprint-parity.e2e.test.js +104 -46
  90. package/tests/e2e/google-signin-probe.e2e.test.js +112 -0
  91. package/tests/e2e/measure-parity.e2e.test.js +118 -0
  92. package/tests/e2e/playwright.e2e.test.js +25 -17
  93. package/tests/e2e/puppeteer.e2e.test.js +17 -23
  94. package/tests/e2e/webdriver.e2e.test.js +239 -0
  95. package/tests/fixtures/firefox-nss-fixtures.mjs +211 -0
  96. package/tests/helpers/cli-fakes.js +214 -0
  97. package/tests/helpers/declared-api.js +80 -0
  98. package/tests/helpers/e2e-browser.js +40 -14
  99. package/tests/helpers/fake-chrome.js +124 -0
  100. package/tests/helpers/migration-fixtures.js +242 -0
  101. package/tests/helpers/temp-directory.js +24 -0
  102. package/tests/helpers/trace-fixtures.js +18 -0
  103. package/tests/helpers/webdriver-mocks.js +183 -0
  104. package/tests/unit/browser/attach/extension-relay.test.js +360 -0
  105. package/tests/unit/browser/attach/snapshot-launch.test.js +312 -0
  106. package/tests/unit/browser/attach/snapshot.test.js +390 -0
  107. package/tests/unit/browser/attach/websocket.test.js +240 -0
  108. package/tests/unit/browser/browser-cookies.test.js +2 -32
  109. package/tests/unit/browser/cdp-session.test.js +162 -0
  110. package/tests/unit/browser/connector.test.js +23 -0
  111. package/tests/unit/browser/launch-building-blocks.test.js +238 -0
  112. package/tests/unit/browser/launch-options.test.js +41 -22
  113. package/tests/unit/browser/launcher.test.js +281 -102
  114. package/tests/unit/browser/media.test.js +13 -1
  115. package/tests/unit/browser/migration/bookmarks.test.js +69 -0
  116. package/tests/unit/browser/migration/chromium-crypto.test.js +94 -0
  117. package/tests/unit/browser/migration/cookies.test.js +94 -0
  118. package/tests/unit/browser/migration/extensions.test.js +104 -0
  119. package/tests/unit/browser/migration/firefox-nss.test.js +113 -0
  120. package/tests/unit/browser/migration/firefox.test.js +139 -0
  121. package/tests/unit/browser/migration/history.test.js +48 -0
  122. package/tests/unit/browser/migration/index.test.js +117 -0
  123. package/tests/unit/browser/migration/os-crypt-keys.test.js +95 -0
  124. package/tests/unit/browser/migration/passwords.test.js +144 -0
  125. package/tests/unit/browser/migration/preferences.test.js +77 -0
  126. package/tests/unit/browser/migration/sqlite-snapshot.test.js +75 -0
  127. package/tests/unit/browser/open-in-user-browser.test.js +123 -0
  128. package/tests/unit/browser/parity.test.js +281 -0
  129. package/tests/unit/browser/real-browser.test.js +458 -33
  130. package/tests/unit/browser/webdriver.test.js +473 -0
  131. package/tests/unit/cli/bin.test.js +69 -0
  132. package/tests/unit/cli/declared-api.test.js +49 -0
  133. package/tests/unit/cli/dispatcher.test.js +429 -0
  134. package/tests/unit/cli/handles.test.js +170 -0
  135. package/tests/unit/cli/main.test.js +409 -0
  136. package/tests/unit/cli/serve.test.js +167 -0
  137. package/tests/unit/core/webdriver-conversions.test.js +235 -0
  138. package/tests/unit/core/webdriver-page.test.js +534 -0
  139. package/tests/unit/downloads/manager.test.js +82 -9
  140. package/tests/unit/downloads/staging.test.js +24 -2
  141. package/tests/unit/fingerprint/automation-parity.test.js +17 -4
  142. package/tests/unit/fingerprint/limitations.test.js +1 -1
  143. package/tests/unit/parity/harness.test.js +138 -0
  144. package/tests/unit/scripts/ci-timeout-budgets.test.js +4 -0
  145. package/tests/unit/scripts/ci-workflow-policy.test.js +8 -0
  146. package/tests/unit/scripts/feature-parity.test.js +221 -0
  147. package/tests/unit/scripts/npm-release-helpers.test.js +3 -2
  148. package/tests/unit/scripts/pre-commit-config.test.js +2 -1
  149. package/tests/unit/traces/bundle.test.js +24 -3
  150. package/tests/unit/traces/links.test.js +7 -0
  151. package/tests/unit/traces/reader.test.js +11 -4
  152. package/tests/unit/traces/recorder.test.js +4 -0
  153. package/tests/unit/traces/viewer.test.js +4 -0
  154. package/tests/unit/utilities/command-stream-only.test.js +28 -0
  155. package/tests/unit/utilities/subprocess.test.js +80 -0
@@ -1,12 +1,19 @@
1
- import path from 'path';
2
- import os from 'os';
3
- import { disableTranslateInPreferences } from '../core/preferences.js';
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 with default configuration
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 {string} options.userDataDir - Path to user data directory
23
- * @param {boolean} options.headless - Run in headless mode (default: false)
24
- * @param {number} options.slowMo - Slow down operations by ms (default: 150 for Playwright, 0 for Puppeteer)
25
- * @param {boolean} options.verbose - Enable verbose logging (default: false)
26
- * @param {string[]} options.args - Custom Chrome arguments to append to the default CHROME_ARGS
27
- * @param {string[]} [options.extraArgs] - Additional Chrome arguments appended after legacy args
28
- * @param {boolean|string[]} [options.ignoreDefaultArgs] - Browser Commander defaults to omit, or true for all
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 'msedge'
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 the command line indistinguishable from a hand-started Chrome. Turning this off restores the pre-parity switches and makes navigator.webdriver true.
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}>} - Browser, page and the download manager when one was requested
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
- userDataDir = path.join(os.homedir(), '.hh-apply', `${engine}-data`),
296
+ launch = 'real',
42
297
  headless = false,
43
- slowMo = engine === 'playwright' ? 150 : 0,
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
- const resolvedChromeArgs = resolveChromeArgs({
58
- args,
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
- let browser;
85
- let page;
86
-
87
- if (engine === 'playwright') {
88
- const { chromium } = await import('playwright');
89
- const contextOptions = buildPlaywrightLaunchOptions({
90
- headless,
91
- slowMo,
92
- chromeArgs,
93
- colorScheme,
94
- channel,
95
- executablePath,
96
- ignoreDefaultArgs: resolvedChromeArgs.ignoreDefaultArgs,
97
- automationParity,
98
- });
99
- browser = await chromium.launchPersistentContext(
100
- userDataDir,
101
- contextOptions
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
- // Apply color scheme emulation if requested (Puppeteer needs page-level emulation)
134
- if (colorScheme !== undefined && engine === 'puppeteer') {
135
- try {
136
- await emulateMedia({ page, engine, colorScheme });
137
- if (verbose) {
138
- console.log(`✅ Color scheme set to "${colorScheme}"`);
139
- }
140
- } catch (error) {
141
- if (verbose) {
142
- console.log(`⚠️ Could not set color scheme: ${error.message}`);
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
- // The fingerprint is applied before the caller can navigate, so the first
148
- // document a page loads already sees the configured environment.
149
- if (fingerprint !== undefined) {
150
- await applyFingerprint({ browser, page, engine, profile: fingerprint });
151
- if (verbose) {
152
- console.log('✅ Fingerprint profile applied');
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
- // Unfocus address bar automatically after browser launch
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
- // Bring page to front - this removes focus from address bar
163
- await page.bringToFront();
164
-
165
- if (verbose) {
166
- console.log('✅ Address bar unfocused automatically');
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
- // Ignore errors - this is just a UX improvement
170
- if (verbose) {
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
  }
@@ -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
+ }