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/downloads/attach.js
CHANGED
|
@@ -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,
|
|
@@ -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
|
|
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 {
|
|
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
|
|
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 (!
|
|
48
|
+
if (!providedPage) {
|
|
47
49
|
throw new Error('page is required in options');
|
|
48
50
|
}
|
|
49
51
|
|
|
50
|
-
const engine = detectEngine(
|
|
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
|
*
|
package/src/fingerprint/apply.js
CHANGED
|
@@ -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
|
|
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, --
|
|
8
|
-
"workaround": "Launch the browser through this library,
|
|
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
|
]
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parity harness: capture the environment probe from a browser that nothing is
|
|
3
|
+
* automating, then from a browser Browser Commander drives, and diff the two.
|
|
4
|
+
*
|
|
5
|
+
* The reference capture never speaks CDP. The browser is started as a plain
|
|
6
|
+
* child process pointed at a local page; the page runs the probe and POSTs the
|
|
7
|
+
* JSON report back. That is the only way to get a baseline that is genuinely
|
|
8
|
+
* "a real browser" rather than "a browser we are already driving". Automated
|
|
9
|
+
* captures are delivered the same way, so a difference in the diff is a
|
|
10
|
+
* difference in the browser rather than in how the probe was invoked.
|
|
11
|
+
*/
|
|
12
|
+
import { createServer } from 'node:http';
|
|
13
|
+
import { readFile } from 'node:fs/promises';
|
|
14
|
+
|
|
15
|
+
import { startProcess } from '../utilities/subprocess.js';
|
|
16
|
+
import {
|
|
17
|
+
createTemporaryUserDataDir,
|
|
18
|
+
removeUserDataDir,
|
|
19
|
+
} from '../browser/profile-directory.js';
|
|
20
|
+
|
|
21
|
+
/** Read the environment probe shipped with the package. */
|
|
22
|
+
export function readProbeSource() {
|
|
23
|
+
return readFile(new URL('./probe.js', import.meta.url), 'utf8');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function probeExpression(source) {
|
|
27
|
+
return `(${source})()`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function probePage(probeSource, token) {
|
|
31
|
+
return `<!doctype html>
|
|
32
|
+
<html lang="en"><head><meta charset="utf-8"><title>probe</title></head>
|
|
33
|
+
<body><p id="status">running</p>
|
|
34
|
+
<script>
|
|
35
|
+
${probeExpression(probeSource)}
|
|
36
|
+
.then((report) => fetch('/report/${token}', {
|
|
37
|
+
method: 'POST',
|
|
38
|
+
headers: { 'content-type': 'application/json' },
|
|
39
|
+
body: JSON.stringify(report),
|
|
40
|
+
}))
|
|
41
|
+
.then(() => { document.getElementById('status').textContent = 'done'; })
|
|
42
|
+
.catch((error) => fetch('/report/${token}', {
|
|
43
|
+
method: 'POST',
|
|
44
|
+
headers: { 'content-type': 'application/json' },
|
|
45
|
+
body: JSON.stringify({ fatal: String(error && error.stack || error) }),
|
|
46
|
+
}));
|
|
47
|
+
</script></body></html>`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Serve the probe page and collect reports POSTed back by page scripts. */
|
|
51
|
+
export async function startProbeServer(probeSource) {
|
|
52
|
+
const reports = new Map();
|
|
53
|
+
const waiters = new Map();
|
|
54
|
+
// The Accept-Language and Sec-CH-UA-* headers are part of the fingerprint and
|
|
55
|
+
// are only observable from the server side, so record them per token.
|
|
56
|
+
const requestHeaders = new Map();
|
|
57
|
+
|
|
58
|
+
const receiveReport = (token, request, response) => {
|
|
59
|
+
const chunks = [];
|
|
60
|
+
request.on('data', (chunk) => chunks.push(chunk));
|
|
61
|
+
request.on('end', () => {
|
|
62
|
+
const report = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
63
|
+
reports.set(token, report);
|
|
64
|
+
waiters.get(token)?.(report);
|
|
65
|
+
waiters.delete(token);
|
|
66
|
+
response.writeHead(204);
|
|
67
|
+
response.end();
|
|
68
|
+
});
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const server = createServer((request, response) => {
|
|
72
|
+
const url = new URL(request.url, 'http://127.0.0.1');
|
|
73
|
+
if (request.method === 'GET' && url.pathname.startsWith('/probe/')) {
|
|
74
|
+
const token = url.pathname.slice('/probe/'.length);
|
|
75
|
+
requestHeaders.set(token, { ...request.headers });
|
|
76
|
+
response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
|
77
|
+
response.end(probePage(probeSource, token));
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (request.method === 'POST' && url.pathname.startsWith('/report/')) {
|
|
81
|
+
receiveReport(url.pathname.slice('/report/'.length), request, response);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
response.writeHead(404);
|
|
85
|
+
response.end();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const port = await new Promise((resolve) =>
|
|
89
|
+
server.listen(0, '127.0.0.1', () => resolve(server.address().port))
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
port,
|
|
94
|
+
url: (token) => `http://127.0.0.1:${port}/probe/${token}`,
|
|
95
|
+
headersFor: (token) => requestHeaders.get(token) || null,
|
|
96
|
+
waitForReport(token, timeoutMs = 60000) {
|
|
97
|
+
if (reports.has(token)) {
|
|
98
|
+
return Promise.resolve(reports.get(token));
|
|
99
|
+
}
|
|
100
|
+
return new Promise((resolve, reject) => {
|
|
101
|
+
const timer = setTimeout(
|
|
102
|
+
() => reject(new Error(`timed out waiting for report ${token}`)),
|
|
103
|
+
timeoutMs
|
|
104
|
+
);
|
|
105
|
+
waiters.set(token, (report) => {
|
|
106
|
+
clearTimeout(timer);
|
|
107
|
+
resolve(report);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
},
|
|
111
|
+
async close() {
|
|
112
|
+
server.closeAllConnections?.();
|
|
113
|
+
await new Promise((resolve) => server.close(resolve));
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The command line of a hand-started reference browser: a dedicated profile,
|
|
120
|
+
* headless when asked, and the page to open. Nothing else, so the reference is
|
|
121
|
+
* exactly what a person types.
|
|
122
|
+
*
|
|
123
|
+
* @param {Object} options
|
|
124
|
+
* @param {string} options.userDataDir
|
|
125
|
+
* @param {string} options.url
|
|
126
|
+
* @param {boolean} [options.headless=false]
|
|
127
|
+
* @param {string[]} [options.extraArgs]
|
|
128
|
+
* @returns {string[]}
|
|
129
|
+
*/
|
|
130
|
+
export function buildReferenceArgs({
|
|
131
|
+
userDataDir,
|
|
132
|
+
url,
|
|
133
|
+
headless = false,
|
|
134
|
+
extraArgs = [],
|
|
135
|
+
}) {
|
|
136
|
+
return [
|
|
137
|
+
`--user-data-dir=${userDataDir}`,
|
|
138
|
+
...(headless ? ['--headless=new'] : []),
|
|
139
|
+
...extraArgs,
|
|
140
|
+
url,
|
|
141
|
+
];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const STDERR_TAIL_BYTES = 4000;
|
|
145
|
+
|
|
146
|
+
/** Keep the last few kilobytes a process wrote, for error messages. */
|
|
147
|
+
function collectTail(channel) {
|
|
148
|
+
let tail = '';
|
|
149
|
+
channel?.on?.('data', (chunk) => {
|
|
150
|
+
tail = (tail + String(chunk)).slice(-STDERR_TAIL_BYTES);
|
|
151
|
+
});
|
|
152
|
+
return () => tail.trim();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function withOutput(error, output) {
|
|
156
|
+
if (output) {
|
|
157
|
+
error.message += `\nbrowser stderr:\n${output}`;
|
|
158
|
+
}
|
|
159
|
+
return error;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Reject as soon as the reference browser exits without having reported, so a
|
|
164
|
+
* browser that cannot start (a missing sandbox, a bad switch) fails with its
|
|
165
|
+
* own exit code and stderr instead of a silent report timeout. A clean exit
|
|
166
|
+
* never settles: a launcher script may hand off to a browser that keeps
|
|
167
|
+
* running and still reports.
|
|
168
|
+
*/
|
|
169
|
+
async function exitedBeforeReport(child, executablePath, stderr) {
|
|
170
|
+
const code = await child.exited;
|
|
171
|
+
if (code === 0) {
|
|
172
|
+
return new Promise(() => {});
|
|
173
|
+
}
|
|
174
|
+
throw withOutput(
|
|
175
|
+
new Error(
|
|
176
|
+
`reference browser ${executablePath} exited with code ${code} before reporting`
|
|
177
|
+
),
|
|
178
|
+
stderr()
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Start the browser as an ordinary user would - no CDP, no automation
|
|
184
|
+
* switches - and collect the probe report its page POSTs back.
|
|
185
|
+
*
|
|
186
|
+
* The profile is prepared exactly like the launcher's temporary profile: the
|
|
187
|
+
* `First Run` sentinel and the "What's new" milestone in `Local State` keep
|
|
188
|
+
* the first-run UI and a second tab from opening.
|
|
189
|
+
*
|
|
190
|
+
* @returns {Promise<Object>} The probe report with `commandLine` attached
|
|
191
|
+
*/
|
|
192
|
+
async function captureReferenceAttempt({
|
|
193
|
+
executablePath = process.env.CHROME_PATH || 'google-chrome',
|
|
194
|
+
server,
|
|
195
|
+
token,
|
|
196
|
+
extraArgs = [],
|
|
197
|
+
headless = false,
|
|
198
|
+
timeoutMs = 60000,
|
|
199
|
+
start = startProcess,
|
|
200
|
+
}) {
|
|
201
|
+
const userDataDir = await createTemporaryUserDataDir();
|
|
202
|
+
const args = buildReferenceArgs({
|
|
203
|
+
userDataDir,
|
|
204
|
+
url: server.url(token),
|
|
205
|
+
headless,
|
|
206
|
+
extraArgs,
|
|
207
|
+
});
|
|
208
|
+
const child = start(executablePath, args, { killGrace: 3000 });
|
|
209
|
+
const stderr = collectTail(child.stderr);
|
|
210
|
+
try {
|
|
211
|
+
const report = await Promise.race([
|
|
212
|
+
server.waitForReport(token, timeoutMs).catch((error) => {
|
|
213
|
+
throw withOutput(error, stderr());
|
|
214
|
+
}),
|
|
215
|
+
exitedBeforeReport(child, executablePath, stderr),
|
|
216
|
+
]);
|
|
217
|
+
Object.defineProperty(report, 'commandLine', {
|
|
218
|
+
value: args,
|
|
219
|
+
enumerable: false,
|
|
220
|
+
});
|
|
221
|
+
return report;
|
|
222
|
+
} finally {
|
|
223
|
+
child.kill('SIGTERM');
|
|
224
|
+
await child.exited;
|
|
225
|
+
await removeUserDataDir(userDataDir);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Edge can keep running after its network service crashes during startup,
|
|
231
|
+
* leaving the probe page unable to POST. Retry only that observed transient
|
|
232
|
+
* startup failure; a separate profile and token keep late first-attempt
|
|
233
|
+
* reports from being mistaken for the retry's report.
|
|
234
|
+
*/
|
|
235
|
+
export async function captureReferenceReport(options) {
|
|
236
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
237
|
+
try {
|
|
238
|
+
return await captureReferenceAttempt({
|
|
239
|
+
...options,
|
|
240
|
+
token:
|
|
241
|
+
attempt === 0 ? options.token : `${options.token}-retry-${attempt}`,
|
|
242
|
+
});
|
|
243
|
+
} catch (error) {
|
|
244
|
+
if (
|
|
245
|
+
attempt === 0 &&
|
|
246
|
+
error.message.startsWith('timed out waiting for report ') &&
|
|
247
|
+
error.message.includes(
|
|
248
|
+
'Network service crashed or was terminated, restarting service.'
|
|
249
|
+
)
|
|
250
|
+
) {
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
throw error;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const IGNORED_PATHS = [
|
|
259
|
+
// Window geometry depends on the window manager and on how each engine sizes
|
|
260
|
+
// the first window; it is user-configurable, not an automation artifact.
|
|
261
|
+
/^window\.(innerWidth|innerHeight|outerWidth|outerHeight|screenX|screenY|screenLeft|screenTop)$/u,
|
|
262
|
+
/^viewportRelation\./u,
|
|
263
|
+
/^document\.(referrer|hasFocus|bodyClientHeightIsPositive)$/u,
|
|
264
|
+
/^probeErrors\./u,
|
|
265
|
+
// NetworkInformation.downlink is a rolling bandwidth estimate: two captures
|
|
266
|
+
// from the same real browser disagree, so it carries no automation signal.
|
|
267
|
+
/^connection\.(downlink|rtt)$/u,
|
|
268
|
+
];
|
|
269
|
+
|
|
270
|
+
function isIgnored(pathString, extraIgnores) {
|
|
271
|
+
return (
|
|
272
|
+
IGNORED_PATHS.some((pattern) => pattern.test(pathString)) ||
|
|
273
|
+
extraIgnores.some((pattern) => pattern.test(pathString))
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function isPlainRecord(value) {
|
|
278
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Deep diff producing dotted paths, so failures name the exact leaked field. */
|
|
282
|
+
export function diffReports(reference, candidate, { ignore = [] } = {}) {
|
|
283
|
+
const differences = [];
|
|
284
|
+
|
|
285
|
+
const walk = (left, right, trail) => {
|
|
286
|
+
if (isIgnored(trail, ignore)) {
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
if (isPlainRecord(left) && isPlainRecord(right)) {
|
|
290
|
+
const keys = new Set([...Object.keys(left), ...Object.keys(right)]);
|
|
291
|
+
for (const key of [...keys].sort()) {
|
|
292
|
+
walk(left[key], right[key], trail ? `${trail}.${key}` : key);
|
|
293
|
+
}
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
if (JSON.stringify(left) !== JSON.stringify(right)) {
|
|
297
|
+
differences.push({ path: trail, reference: left, candidate: right });
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
walk(reference, candidate, '');
|
|
302
|
+
return differences;
|
|
303
|
+
}
|