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
@@ -47,6 +47,12 @@ export function attachDownloads({ engine, browser, page, downloads, log }) {
47
47
  if (!managerOptions) {
48
48
  return null;
49
49
  }
50
+ if (engine === 'selenium') {
51
+ throw new Error(
52
+ 'Managed downloads are not available with the selenium engine: they ' +
53
+ 'are driven by CDP download events (see the webdriver-no-cdp-emulation limitation)'
54
+ );
55
+ }
50
56
 
51
57
  return createDownloadManager({
52
58
  engine,
@@ -22,6 +22,7 @@ import {
22
22
  import {
23
23
  attachCdpSource,
24
24
  attachPlaywrightSource,
25
+ browserContextIdForPage,
25
26
  classifyFailure,
26
27
  DOWNLOAD_FAILURE,
27
28
  openBrowserCdpSession,
@@ -308,17 +309,25 @@ export async function createDownloadManager(options = {}) {
308
309
  try {
309
310
  cdpSession = await openBrowserCdpSession({ engine, browser, page });
310
311
  if (cdpSession) {
312
+ const browserContextId = await browserContextIdForPage({
313
+ engine,
314
+ browser,
315
+ page,
316
+ browserSession: cdpSession,
317
+ });
311
318
  attached.push(
312
319
  await attachCdpSource({
313
320
  session: cdpSession,
314
321
  root,
315
322
  sink,
323
+ browserContextId,
316
324
  stagingTimeout,
317
325
  stagingPollInterval,
318
326
  })
319
327
  );
320
328
  }
321
329
  } catch (error) {
330
+ await cdpSession?.detach?.().catch?.(() => {});
322
331
  cdpSession = undefined;
323
332
  log?.warn?.(`manual downloads are not observable: ${error.message}`);
324
333
  }
@@ -92,6 +92,7 @@ export function attachPlaywrightSource({ context, sink }) {
92
92
  * @param {Object} options.session - CDP session with Browser domain access
93
93
  * @param {string} options.root - Managed download directory
94
94
  * @param {Object} options.sink - `{started, finished, failed, track}` callbacks
95
+ * @param {string} [options.browserContextId] - Chromium context to configure
95
96
  * @param {number} [options.stagingTimeout] - Budget for the bytes to land on disk
96
97
  * @param {number} [options.stagingPollInterval] - Milliseconds between readings
97
98
  * @returns {Promise<Object>} Handle with `detach()` and `stagingDirectory`
@@ -100,6 +101,7 @@ export async function attachCdpSource({
100
101
  session,
101
102
  root,
102
103
  sink,
104
+ browserContextId,
103
105
  stagingTimeout,
104
106
  stagingPollInterval,
105
107
  }) {
@@ -116,6 +118,7 @@ export async function attachCdpSource({
116
118
  behavior: 'allowAndName',
117
119
  downloadPath: stagingDirectory,
118
120
  eventsEnabled: true,
121
+ ...(browserContextId ? { browserContextId } : {}),
119
122
  });
120
123
 
121
124
  const byGuid = new Map();
@@ -200,6 +203,74 @@ export async function attachCdpSource({
200
203
  };
201
204
  }
202
205
 
206
+ /**
207
+ * Read the Chromium browser-context id that owns a page.
208
+ *
209
+ * `Browser.setDownloadBehavior` is sent over a browser-wide session so its
210
+ * events cover the whole browser, but omitting `browserContextId` configures
211
+ * only Chromium's default context. Playwright's `browser.newPage()` convenience
212
+ * API creates a non-default context, whose id is exposed by its target.
213
+ *
214
+ * @param {Object} options - Browser handles
215
+ * @param {string} options.engine - Engine name
216
+ * @param {Object} [options.browser] - Browser or persistent context
217
+ * @param {Object} [options.page] - Page whose context downloads belong to
218
+ * @param {Object} [options.browserSession] - Browser-wide CDP session
219
+ * @returns {Promise<string|undefined>} Chromium context id, when non-default
220
+ */
221
+ export async function browserContextIdForPage({
222
+ engine,
223
+ browser,
224
+ page,
225
+ browserSession,
226
+ }) {
227
+ if (engine !== 'playwright' || !page) {
228
+ return undefined;
229
+ }
230
+
231
+ const context = page.context?.() ?? browser;
232
+ // Playwright exposes a persistent context as the browser's default context:
233
+ // unlike browser.newContext(), its `browser()` is null. Keeping the id
234
+ // absent preserves default-context behavior, including downloads from tabs
235
+ // opened outside the automation.
236
+ if (typeof context?.browser === 'function' && !context.browser()) {
237
+ return undefined;
238
+ }
239
+ const pageSession = await context?.newCDPSession?.(page);
240
+ if (!pageSession) {
241
+ return undefined;
242
+ }
243
+
244
+ let browserContextId;
245
+ try {
246
+ const { targetInfo } = await pageSession.send('Target.getTargetInfo');
247
+ browserContextId = targetInfo?.browserContextId || undefined;
248
+ } finally {
249
+ // This session exists only for target metadata. A failure to detach it
250
+ // must not discard the id that lets the long-lived source work correctly.
251
+ await pageSession.detach?.().catch?.(() => {});
252
+ }
253
+ if (!browserContextId || !browserSession) {
254
+ return browserContextId;
255
+ }
256
+
257
+ try {
258
+ // TargetInfo also reports an opaque id for a persistent/default context in
259
+ // current Chromium. Only ids returned here are true non-default contexts
260
+ // accepted by Browser.setDownloadBehavior's browserContextId parameter.
261
+ const { browserContextIds = [] } = await browserSession.send(
262
+ 'Target.getBrowserContexts'
263
+ );
264
+ return browserContextIds.includes(browserContextId)
265
+ ? browserContextId
266
+ : undefined;
267
+ } catch {
268
+ // Older/page-scoped CDP bridges may not expose getBrowserContexts. The
269
+ // persistent-context guard above handles their known default-context case.
270
+ return browserContextId;
271
+ }
272
+ }
273
+
203
274
  /**
204
275
  * Open a CDP session that can drive the Browser domain.
205
276
  *
@@ -26,6 +26,9 @@ export const DEFAULT_STAGING_TIMEOUT = 10000;
26
26
  /** How often the staged file is looked at while waiting. */
27
27
  export const DEFAULT_STAGING_POLL_INTERVAL = 25;
28
28
 
29
+ /** How long an unchanged size must remain observable before it is complete. */
30
+ export const DEFAULT_STAGING_STABILITY = 100;
31
+
29
32
  /** Suffixes Chromium uses for a file it is still writing. */
30
33
  export const STAGING_IN_PROGRESS_SUFFIXES = Object.freeze([
31
34
  '.crdownload',
@@ -77,14 +80,15 @@ async function observeStagedFile(filePath, suffixes) {
77
80
  /**
78
81
  * Wait until a staged download is readable and has stopped growing.
79
82
  *
80
- * Stability is decided by two consecutive readings of the same size, the same
81
- * evidence the Selenium directory watcher uses: a file that merely exists is
82
- * not evidence that its last byte was written.
83
+ * Stability is decided by an unchanged-size quiet period. Two equal readings
84
+ * only prove that no write landed between those particular polls; a stalled
85
+ * writer can pause for longer than one interval and resume afterwards.
83
86
  *
84
87
  * @param {Object} options - Wait options
85
88
  * @param {string} options.path - Path the engine staged the download under
86
89
  * @param {number} [options.timeout] - Total budget in milliseconds
87
90
  * @param {number} [options.interval] - Milliseconds between readings
91
+ * @param {number} [options.stability] - Required unchanged-size quiet period
88
92
  * @param {string[]} [options.inProgressSuffixes] - Suffixes of a partial file
89
93
  * @returns {Promise<{ready: boolean, bytes: number|null, reason: string|null, waitedMs: number}>} Outcome
90
94
  */
@@ -93,11 +97,13 @@ export async function waitForStagedFile(options = {}) {
93
97
  path: filePath,
94
98
  timeout = DEFAULT_STAGING_TIMEOUT,
95
99
  interval = DEFAULT_STAGING_POLL_INTERVAL,
100
+ stability = DEFAULT_STAGING_STABILITY,
96
101
  inProgressSuffixes = STAGING_IN_PROGRESS_SUFFIXES,
97
102
  } = options;
98
103
 
99
104
  const deadline = createDeadline({ timeout });
100
105
  let previousSize;
106
+ let stableSince;
101
107
  // Always set by the reading below before the deadline can be checked.
102
108
  let reason;
103
109
 
@@ -107,15 +113,21 @@ export async function waitForStagedFile(options = {}) {
107
113
  reason = seen.reason;
108
114
  // A file that vanished has to prove itself stable again from scratch.
109
115
  previousSize = undefined;
116
+ stableSince = undefined;
110
117
  } else if (seen.size === previousSize) {
111
- return {
112
- ready: true,
113
- bytes: seen.size,
114
- reason: null,
115
- waitedMs: deadline.elapsedMs(),
116
- };
118
+ const stableMs = deadline.elapsedMs() - stableSince;
119
+ if (stableMs >= stability) {
120
+ return {
121
+ ready: true,
122
+ bytes: seen.size,
123
+ reason: null,
124
+ waitedMs: deadline.elapsedMs(),
125
+ };
126
+ }
127
+ reason = `the file had been unchanged for only ${stableMs}ms`;
117
128
  } else {
118
129
  previousSize = seen.size;
130
+ stableSince = deadline.elapsedMs();
119
131
  reason = `the file was still growing at ${seen.size} bytes`;
120
132
  }
121
133
 
@@ -302,9 +302,10 @@ export function withTextSelectorSupport(fn, engine, page) {
302
302
  return async (options = {}) => {
303
303
  let { selector } = options;
304
304
 
305
- // Normalize Puppeteer text selectors (object format)
305
+ // Normalize Puppeteer-style text selectors (object format; Puppeteer and
306
+ // selenium)
306
307
  if (
307
- engine === 'puppeteer' &&
308
+ engine !== 'playwright' &&
308
309
  typeof selector === 'object' &&
309
310
  selector._isPuppeteerTextSelector
310
311
  ) {
@@ -149,6 +149,10 @@ export async function count(options = {}) {
149
149
  );
150
150
  return result;
151
151
  }
152
+ if (engine === 'selenium' && selector?._isPuppeteerTextSelector) {
153
+ // The WebDriverPage facade evaluates like a Puppeteer page (issue #104).
154
+ return await count({ ...options, engine: 'puppeteer' });
155
+ }
152
156
 
153
157
  if (engine === 'playwright') {
154
158
  return await page.locator(selector).count();
package/src/exports.js CHANGED
@@ -27,9 +27,24 @@ export {
27
27
  EngineAdapter,
28
28
  PlaywrightAdapter,
29
29
  PuppeteerAdapter,
30
+ SeleniumAdapter,
30
31
  createEngineAdapter,
31
32
  } from './core/engine-adapter.js';
32
33
 
34
+ // Selenium / WebDriver engine (issue #104)
35
+ export {
36
+ WebDriverPage,
37
+ WebDriverTimeoutError,
38
+ createWebDriverPage,
39
+ isWebDriver,
40
+ } from './core/webdriver-page.js';
41
+ export {
42
+ launchWebDriver,
43
+ connectWebDriver,
44
+ resolveWebDriverExecutable,
45
+ CHROMEDRIVER_DEFAULT_SWITCHES,
46
+ } from './browser/webdriver.js';
47
+
33
48
  // Page trigger system
34
49
  export {
35
50
  createPageTriggerManager,
@@ -51,7 +66,49 @@ export {
51
66
  launchAndConnectRealBrowser,
52
67
  launchRealBrowser,
53
68
  } from './browser/real-browser.js';
54
- export { launchBrowser } from './browser/launcher.js';
69
+ export {
70
+ classifyDifferences,
71
+ compareCommandLines,
72
+ measureParity,
73
+ parseSwitches,
74
+ readBrowserVersionPage,
75
+ } from './browser/parity.js';
76
+ export {
77
+ openInUserBrowser,
78
+ buildOpenCommand,
79
+ validateOpenUrl,
80
+ } from './browser/open-in-user-browser.js';
81
+ export { migrateProfile, ALL_DATA_CLASSES } from './browser/migration/index.js';
82
+ export {
83
+ attachUserBrowser,
84
+ attachViaExtension,
85
+ ATTACH_MODES,
86
+ DEFAULT_RELAY_PORT,
87
+ describeAttachDifferences,
88
+ EXTENSION_DIRECTORY,
89
+ RELAY_PATH,
90
+ snapshotUserDataDir,
91
+ } from './browser/attach/index.js';
92
+ export {
93
+ launchBrowser,
94
+ LAUNCH_MODES,
95
+ resolveLaunchExecutable,
96
+ } from './browser/launcher.js';
97
+ export {
98
+ LAUNCH_RESTRICTIONS,
99
+ LAUNCH_RESTRICTION_PRESETS,
100
+ resolveRestrictions,
101
+ mergeFeatureSwitches,
102
+ } from './browser/restrictions.js';
103
+ export {
104
+ createTemporaryUserDataDir,
105
+ prepareUserDataDir,
106
+ removeUserDataDir,
107
+ } from './browser/profile-directory.js';
108
+ export {
109
+ PortRaceError,
110
+ reserveLoopbackPort,
111
+ } from './browser/debugging-port.js';
55
112
  export { saveStorageState } from './browser/storage-state.js';
56
113
  export { emulateMedia } from './browser/media.js';
57
114
  export {
@@ -187,7 +244,9 @@ export {
187
244
  buildFingerprintInitScript,
188
245
  buildInitScriptConfig,
189
246
  } from './fingerprint/init-script.js';
190
- export { applyFingerprint, createCdpSession } from './fingerprint/apply.js';
247
+ export { applyFingerprint } from './fingerprint/apply.js';
248
+ // Raw CDP with one surface for both engines (issue #104).
249
+ export { createCdpSession, wrapCdpSession } from './browser/cdp-session.js';
191
250
  export {
192
251
  FINGERPRINT_PRESET_NAMES,
193
252
  createFingerprintPreset,
package/src/factory.js CHANGED
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { createLogger } from './core/logger.js';
8
8
  import { detectEngine } from './core/engine-detection.js';
9
+ import { WebDriverPage } from './core/webdriver-page.js';
9
10
  import { createNetworkTracker } from './core/network-tracker.js';
10
11
  import { createNavigationManager } from './core/navigation-manager.js';
11
12
  import { createPageSessionFactory } from './core/page-session.js';
@@ -22,11 +23,12 @@ import {
22
23
  import { createBoundFunctions } from './bindings.js';
23
24
  import { attachDownloads } from './downloads/attach.js';
24
25
  import { startTrace } from './traces/recorder.js';
26
+ import { createCdpSession } from './browser/cdp-session.js';
25
27
 
26
28
  /**
27
29
  * Create a browser commander instance for a specific page
28
30
  * @param {Object} options - Configuration options
29
- * @param {Object} options.page - Playwright or Puppeteer page object
31
+ * @param {Object} options.page - Playwright or Puppeteer page object, or a selenium-webdriver WebDriver (or the WebDriverPage launchWebDriver() returns)
30
32
  * @param {boolean} options.verbose - Enable verbose logging
31
33
  * @param {boolean} options.enableNetworkTracking - Enable network request tracking (default: true)
32
34
  * @param {boolean} options.enableNavigationManager - Enable navigation manager (default: true)
@@ -35,7 +37,7 @@ import { startTrace } from './traces/recorder.js';
35
37
  */
36
38
  export function makeBrowserCommander(options = {}) {
37
39
  const {
38
- page,
40
+ page: providedPage,
39
41
  verbose = false,
40
42
  enableNetworkTracking = true,
41
43
  enableNavigationManager = true,
@@ -43,11 +45,17 @@ export function makeBrowserCommander(options = {}) {
43
45
  downloads = null,
44
46
  } = options;
45
47
 
46
- if (!page) {
48
+ if (!providedPage) {
47
49
  throw new Error('page is required in options');
48
50
  }
49
51
 
50
- const engine = detectEngine(page);
52
+ const engine = detectEngine(providedPage);
53
+ // A bare selenium-webdriver WebDriver is wrapped in the page facade the
54
+ // helpers speak to (issue #104); launchWebDriver() already returns one.
55
+ const page =
56
+ engine === 'selenium' && !providedPage.isWebDriverPage
57
+ ? new WebDriverPage(providedPage)
58
+ : providedPage;
51
59
  const log = createLogger({ verbose });
52
60
 
53
61
  // Create NetworkTracker if enabled
@@ -177,6 +185,13 @@ export function makeBrowserCommander(options = {}) {
177
185
  return commander.downloads;
178
186
  },
179
187
 
188
+ /**
189
+ * Open a raw CDP session for this commander's page (issue #104).
190
+ *
191
+ * @returns {Promise<Object>} {send, on, once, off, detach, session, engine}
192
+ */
193
+ createCdpSession: () => createCdpSession(page, { engine }),
194
+
180
195
  /**
181
196
  * Start recording a privacy-aware trace of this session (issue #87).
182
197
  *
@@ -14,6 +14,12 @@ import { resolveFingerprintProfile } from './profile.js';
14
14
 
15
15
  /** Open a CDP session for a page, whichever engine owns it. */
16
16
  export function createCdpSession({ browser, page, engine }) {
17
+ if (engine === 'selenium') {
18
+ throw new Error(
19
+ 'Fingerprint profiles need a CDP session, which the selenium engine does ' +
20
+ 'not have (see the webdriver-no-cdp-emulation limitation)'
21
+ );
22
+ }
17
23
  if (engine === 'puppeteer') {
18
24
  if (typeof page.createCDPSession === 'function') {
19
25
  return page.createCDPSession();
@@ -16,6 +16,13 @@
16
16
  * the browser. A specific port number is left alone on purpose, since that is
17
17
  * what a human attaching a debugger passes.
18
18
  *
19
+ * `kHeadless` is in that table but is not a trigger in practice: the renderer
20
+ * never sees the switch in today's headless mode. A hand-started Chrome 153
21
+ * with `--headless`, `--headless=new` or `--headless=old` and nothing else
22
+ * reports `navigator.webdriver === false`
23
+ * (experiments/issue-103/headless-webdriver.mjs), so a headless launch needs
24
+ * no off switch to match it, and adding one would be a difference of its own.
25
+ *
19
26
  * https://chromium.googlesource.com/chromium/src/+/refs/heads/main/content/child/runtime_features.cc
20
27
  */
21
28
 
@@ -30,11 +37,6 @@ export const AUTOMATION_CONTROLLED_TRIGGERS = Object.freeze([
30
37
  reason:
31
38
  'Mapped onto AutomationControlled in content/child/runtime_features.cc; also shows the "controlled by automated test software" infobar.',
32
39
  }),
33
- Object.freeze({
34
- switch: '--headless',
35
- reason:
36
- 'Mapped onto AutomationControlled in content/child/runtime_features.cc; covers --headless and --headless=new alike.',
37
- }),
38
40
  Object.freeze({
39
41
  switch: '--remote-debugging-pipe',
40
42
  reason:
@@ -59,9 +61,6 @@ function isTrigger(argument, trigger) {
59
61
  Number(argument.slice('--remote-debugging-port='.length)) === 0
60
62
  );
61
63
  }
62
- if (trigger.switch === '--headless') {
63
- return switchName(argument) === '--headless';
64
- }
65
64
  return switchName(argument) === trigger.switch;
66
65
  }
67
66
 
@@ -8,7 +8,7 @@
8
8
  * `docs/case-studies/issue-79/analysis-artifacts/`.
9
9
  *
10
10
  * The catalogue itself lives in `limitations.json`, because Python and Rust
11
- * publish the same eleven entries and a hand-copied paragraph of prose drifts
11
+ * publish the same sixteen entries and a hand-copied paragraph of prose drifts
12
12
  * as easily as a hand-copied patch. This module owns the JSON; the copies are
13
13
  * checked byte for byte by `scripts/check-shared-fingerprint-assets.sh`.
14
14
  *
@@ -4,10 +4,19 @@
4
4
  "surface": "navigator.webdriver",
5
5
  "severity": "high",
6
6
  "evidence": "measured",
7
- "detail": "navigator.webdriver is the AutomationControlled Blink runtime feature. Chrome enables it for --enable-automation, --headless, --remote-debugging-pipe and an ephemeral --remote-debugging-port=0, and the only way to turn it back off is the launch switch --disable-blink-features=AutomationControlled. Emulation.setAutomationOverride({enabled:false}) is accepted over CDP but leaves navigator.webdriver true, in the same document and after navigation, so a browser somebody else already launched cannot be corrected from the protocol side.",
8
- "workaround": "Launch the browser through this library, which passes the switch. When attaching to a browser somebody else started, apply the profile with the webdriver patch option, which installs a JavaScript getter instead: that is weaker, because it does not reach workers or a renderer that starts before the patch is installed.",
7
+ "detail": "navigator.webdriver is the AutomationControlled Blink runtime feature. Chrome enables it for --enable-automation, --remote-debugging-pipe and an ephemeral --remote-debugging-port=0, and the only way to turn it back off is the launch switch --disable-blink-features=AutomationControlled. A fixed non-zero --remote-debugging-port does not enable it, and neither does --headless in any of its forms (measured with Chrome 153, experiments/issue-103/headless-webdriver.mjs). Emulation.setAutomationOverride({enabled:false}) is accepted over CDP but leaves navigator.webdriver true, in the same document and after navigation, so a browser somebody else already launched cannot be corrected from the protocol side.",
8
+ "workaround": "Launch the browser through this library: the default real launch starts it on a fixed port, so the feature is never enabled, and launch: 'engine' passes the off switch. When attaching to a browser somebody else started, apply the profile with the webdriver patch option, which installs a JavaScript getter instead: that is weaker, because it does not reach workers or a renderer that starts before the patch is installed.",
9
9
  "reference": "content/child/runtime_features.cc; analysis-artifacts/remote-debugging-isolation.json"
10
10
  },
11
+ {
12
+ "id": "engine-launch-switches",
13
+ "surface": "Browser UI and chrome://version command line",
14
+ "severity": "medium",
15
+ "evidence": "measured",
16
+ "detail": "With launch: 'engine' the browser is started by Playwright or Puppeteer, which add their own switches (--enable-automation, --remote-debugging-pipe or port 0, --disable-extensions, --disable-sync and more; see chrome://version). Browser Commander removes --enable-automation and adds --disable-blink-features=AutomationControlled so navigator.webdriver stays false, but Chrome answers that switch with the \"You are using an unsupported command-line flag\" infobar, and the remaining engine switches change which browser features exist.",
17
+ "workaround": "Use the default launch: 'real', which starts the browser with only --user-data-dir and a fixed --remote-debugging-port, and opt into individual restrictions by name when they are wanted.",
18
+ "reference": "js/tests/e2e/fingerprint-parity.e2e.test.js; experiments/issue-105/"
19
+ },
11
20
  {
12
21
  "id": "no-cdp-device-memory-override",
13
22
  "surface": "navigator.deviceMemory",
@@ -92,5 +101,39 @@
92
101
  "detail": "A worker has its own Navigator, and overrides set on the page session only partly reach it. Measured in a dedicated worker: userAgent, timezone and locale follow the profile, but platform, language, languages, hardwareConcurrency and deviceMemory all report the real host values, and no JavaScript patch is present because Page.addScriptToEvaluateOnNewDocument runs in documents only. Full worker parity is reachable over raw CDP -- replaying the Emulation commands on the worker session fixes languages and hardwareConcurrency, and evaluating the init script during the waitForDebuggerOnStart pause fixes the rest -- but that needs a sessionId, which neither engine accepts: Playwright newCDPSession takes only Page or Frame, and Puppeteer worker sessions are internal to its TargetManager.",
93
102
  "workaround": "Avoid workloads that read the fingerprint from a worker, or drive Chrome over a raw CDP connection and replay the commands the CDP override builder returns on each attached worker session.",
94
103
  "reference": "analysis-artifacts/worker-visibility.json"
104
+ },
105
+ {
106
+ "id": "webdriver-driver-launch-switches",
107
+ "surface": "navigator.webdriver and the browser command line under chromedriver",
108
+ "severity": "medium",
109
+ "evidence": "measured",
110
+ "detail": "chromedriver starts Chrome with twenty switches of its own. Two of them turn navigator.webdriver on by themselves: --enable-automation, and --remote-debugging-port=0 (any debugging port chosen by the browser). With chromedriver's defaults navigator.webdriver is true; excluding --enable-automation alone still leaves it true; excluding every default switch and giving Chrome a fixed debugging port makes it false, headful and headless, with and without BiDi. launchWebDriver() does exactly that, so its command line is the same as a real-browser launch. A driver started by someone else -- a Selenium Grid node, a cloud provider, connectWebDriver() -- keeps whatever switches it was given, and --disable-blink-features=AutomationControlled, the usual workaround, shows an unsupported-flag infobar.",
111
+ "workaround": "Launch the driver with launchWebDriver(), or pass the same excludeSwitches and a fixed --remote-debugging-port in the capabilities given to connectWebDriver().",
112
+ "reference": "experiments/issue-104/webdriver-automation-flags.mjs"
113
+ },
114
+ {
115
+ "id": "webdriver-no-cdp-emulation",
116
+ "surface": "Fingerprint profiles, media emulation and managed downloads on the selenium engine",
117
+ "severity": "medium",
118
+ "evidence": "documented",
119
+ "detail": "Fingerprint profiles, emulateMedia and managed downloads are built on Chrome DevTools Protocol commands and events (Emulation.*, Page.addScriptToEvaluateOnNewDocument with a CDP session, Browser.setDownloadBehavior). A WebDriver session exposes no CDP session through the W3C protocol, and WebDriver BiDi has no equivalent of most Emulation commands yet, so on the selenium engine these operations throw instead of silently doing nothing.",
120
+ "workaround": "Use the playwright or puppeteer engine for fingerprint profiles, media emulation and managed downloads, or set the equivalent browser preferences in the capabilities."
121
+ },
122
+ {
123
+ "id": "webdriver-classic-has-no-events",
124
+ "surface": "Console, page error, dialog, request and navigation events on the selenium engine",
125
+ "severity": "low",
126
+ "evidence": "documented",
127
+ "detail": "Classic WebDriver is request/response only: a session cannot be told about console messages, uncaught errors, dialogs, network requests or navigations it did not start. Those events, preload scripts and full-page or clipped screenshots come from WebDriver BiDi, which a session has only when it was created with the webSocketUrl capability. Without it the operations that need events throw an error naming the missing capability.",
128
+ "workaround": "Launch with bidi: true, or add webSocketUrl: true to the capabilities given to connectWebDriver()."
129
+ },
130
+ {
131
+ "id": "webdriver-print-options",
132
+ "surface": "pdf() options on the selenium engine",
133
+ "severity": "low",
134
+ "evidence": "documented",
135
+ "detail": "WebDriver Print Page takes orientation, scale, background, page size, margins, shrink-to-fit and page ranges only. Header and footer templates, preferCSSPageSize, tagged and outlined PDFs and omitBackground have no WebDriver equivalent; pdf() rejects them rather than producing a different document than the one asked for. Page and margin sizes are converted to centimetres.",
136
+ "workaround": "Leave the unsupported options out, or produce the PDF with the playwright or puppeteer engine.",
137
+ "reference": "https://w3c.github.io/webdriver/#print-page"
95
138
  }
96
139
  ]