browser-commander 0.16.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.changeset/config.json +1 -1
- package/.changeset/merged-loud-river.md +7 -0
- package/.jscpd-baseline.json +257 -0
- package/.jscpd.json +1 -3
- package/.prettierignore +1 -0
- package/CHANGELOG.md +22 -0
- package/eslint.config.js +23 -1
- package/package.json +13 -24
- package/scripts/changeset-version.mjs +2 -6
- package/scripts/create-github-release.mjs +7 -8
- package/scripts/create-manual-changeset.mjs +7 -8
- package/scripts/format-github-release.mjs +6 -7
- package/scripts/format-release-notes.mjs +7 -8
- package/scripts/instant-version-bump.mjs +7 -8
- package/scripts/publish-to-npm.mjs +7 -8
- package/scripts/setup-npm.mjs +2 -6
- package/scripts/version-and-commit.mjs +7 -8
- package/src/browser/browser-cookie-credentials.js +3 -1
- package/src/browser/browser-cookies.js +6 -2
- package/src/browser/browser-profiles.js +1 -1
- package/src/browser/launch-options.js +31 -8
- package/src/browser/launcher.js +16 -0
- package/src/exports.js +34 -0
- package/src/fingerprint/apply.js +116 -0
- package/src/fingerprint/automation-parity.js +224 -0
- package/src/fingerprint/cdp-overrides.js +176 -0
- package/src/fingerprint/derive.js +104 -0
- package/src/fingerprint/init-payload.js +181 -0
- package/src/fingerprint/init-script.js +104 -0
- package/src/fingerprint/limitations.js +107 -0
- package/src/fingerprint/limitations.json +96 -0
- package/src/fingerprint/presets.js +244 -0
- package/src/fingerprint/profile.js +428 -0
- package/src/tests/index.js +2 -1
- package/tests/e2e/fingerprint-parity.e2e.test.js +212 -0
- package/tests/helpers/mocks.js +4 -2
- package/tests/helpers/repo.js +74 -0
- package/tests/unit/browser/launch-options.test.js +9 -1
- package/tests/unit/experiments/fingerprint-parity-harness.test.js +97 -0
- package/tests/unit/fingerprint/apply.test.js +271 -0
- package/tests/unit/fingerprint/automation-parity.test.js +212 -0
- package/tests/unit/fingerprint/cdp-overrides.test.js +227 -0
- package/tests/unit/fingerprint/derive.test.js +105 -0
- package/tests/unit/fingerprint/init-script.test.js +357 -0
- package/tests/unit/fingerprint/limitations.test.js +184 -0
- package/tests/unit/fingerprint/presets.test.js +135 -0
- package/tests/unit/fingerprint/profile.test.js +282 -0
- package/tests/unit/scripts/check-pipeline-status.test.js +133 -0
- package/tests/unit/scripts/check-version-modification.test.js +70 -0
- package/tests/unit/scripts/check-web-archive.test.js +191 -0
- package/tests/unit/scripts/ci-timeout-budgets.test.js +311 -0
- package/tests/unit/scripts/ci-workflow-policy.test.js +217 -28
- package/tests/unit/scripts/debug-print.test.js +67 -0
- package/tests/unit/scripts/jscpd-config.test.js +141 -0
- package/tests/unit/scripts/pre-commit-config.test.js +209 -0
- package/tests/unit/scripts/read-manifest.test.js +194 -0
- package/tests/unit/scripts/run-with-budget-warning.test.js +115 -0
- package/tests/unit/scripts/use-module-adoption.test.js +74 -0
- package/tests/unit/scripts/use-module-integration.test.js +64 -0
- package/tests/unit/scripts/use-module.test.js +182 -0
- package/.husky/pre-commit +0 -1
- package/.prettierrc +0 -10
|
@@ -231,7 +231,8 @@ async function mapChromiumRows(rows, databaseVersion, context) {
|
|
|
231
231
|
} catch (error) {
|
|
232
232
|
if (!context.ignoreDecryptionErrors) {
|
|
233
233
|
throw new Error(
|
|
234
|
-
`Could not decrypt cookie ${row.name} for ${row.host_key}: ${error.message}
|
|
234
|
+
`Could not decrypt cookie ${row.name} for ${row.host_key}: ${error.message}`,
|
|
235
|
+
{ cause: error }
|
|
235
236
|
);
|
|
236
237
|
}
|
|
237
238
|
}
|
|
@@ -246,7 +247,10 @@ async function openCookieDatabase(cookiePath) {
|
|
|
246
247
|
readOnly: true,
|
|
247
248
|
});
|
|
248
249
|
} catch (error) {
|
|
249
|
-
throw new Error(
|
|
250
|
+
throw new Error(
|
|
251
|
+
`Could not open browser cookie database: ${error.message}`,
|
|
252
|
+
{ cause: error }
|
|
253
|
+
);
|
|
250
254
|
}
|
|
251
255
|
}
|
|
252
256
|
|
|
@@ -181,7 +181,7 @@ async function listFirefoxProfiles(root, platform) {
|
|
|
181
181
|
return [];
|
|
182
182
|
}
|
|
183
183
|
const pathApi = platformPath(platform);
|
|
184
|
-
let sections
|
|
184
|
+
let sections;
|
|
185
185
|
try {
|
|
186
186
|
sections = parseIni(
|
|
187
187
|
await readFile(pathApi.join(root, 'profiles.ini'), 'utf8')
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { CHROME_ARGS } from '../core/constants.js';
|
|
2
|
+
import {
|
|
3
|
+
applyAutomationParityArgs,
|
|
4
|
+
parityIgnoredDefaultArgs,
|
|
5
|
+
} from '../fingerprint/automation-parity.js';
|
|
2
6
|
|
|
3
7
|
function stringArray(value, name) {
|
|
4
8
|
if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) {
|
|
@@ -60,19 +64,28 @@ export function buildPlaywrightLaunchOptions({
|
|
|
60
64
|
channel,
|
|
61
65
|
executablePath,
|
|
62
66
|
ignoreDefaultArgs = [],
|
|
67
|
+
automationParity = true,
|
|
63
68
|
}) {
|
|
64
69
|
const normalizedIgnoreDefaultArgs =
|
|
65
70
|
ignoreDefaultArgs === false ? [] : ignoreDefaultArgs;
|
|
66
71
|
const engineIgnoreDefaultArgs =
|
|
67
72
|
normalizedIgnoreDefaultArgs === true
|
|
68
73
|
? true
|
|
69
|
-
: [
|
|
74
|
+
: [
|
|
75
|
+
...new Set([
|
|
76
|
+
'--enable-automation',
|
|
77
|
+
...(automationParity
|
|
78
|
+
? parityIgnoredDefaultArgs('playwright', { headless })
|
|
79
|
+
: []),
|
|
80
|
+
...normalizedIgnoreDefaultArgs,
|
|
81
|
+
]),
|
|
82
|
+
];
|
|
70
83
|
const options = {
|
|
71
84
|
headless,
|
|
72
85
|
slowMo,
|
|
73
86
|
chromiumSandbox: true,
|
|
74
87
|
viewport: null,
|
|
75
|
-
args: chromeArgs,
|
|
88
|
+
args: automationParity ? applyAutomationParityArgs(chromeArgs) : chromeArgs,
|
|
76
89
|
ignoreDefaultArgs: engineIgnoreDefaultArgs,
|
|
77
90
|
};
|
|
78
91
|
|
|
@@ -90,20 +103,30 @@ export function buildPuppeteerLaunchOptions({
|
|
|
90
103
|
channel,
|
|
91
104
|
executablePath,
|
|
92
105
|
ignoreDefaultArgs = [],
|
|
106
|
+
automationParity = true,
|
|
93
107
|
}) {
|
|
94
108
|
const normalizedIgnoreDefaultArgs =
|
|
95
109
|
ignoreDefaultArgs === false ? [] : ignoreDefaultArgs;
|
|
110
|
+
const baseArgs = ['--start-maximized', ...chromeArgs];
|
|
111
|
+
const engineIgnoreDefaultArgs =
|
|
112
|
+
normalizedIgnoreDefaultArgs === true
|
|
113
|
+
? true
|
|
114
|
+
: [
|
|
115
|
+
...new Set([
|
|
116
|
+
...(automationParity
|
|
117
|
+
? parityIgnoredDefaultArgs('puppeteer', { headless })
|
|
118
|
+
: []),
|
|
119
|
+
...normalizedIgnoreDefaultArgs,
|
|
120
|
+
]),
|
|
121
|
+
];
|
|
96
122
|
const options = {
|
|
97
123
|
headless,
|
|
98
124
|
defaultViewport: null,
|
|
99
|
-
args:
|
|
125
|
+
args: automationParity ? applyAutomationParityArgs(baseArgs) : baseArgs,
|
|
100
126
|
userDataDir,
|
|
101
127
|
};
|
|
102
|
-
if (
|
|
103
|
-
|
|
104
|
-
normalizedIgnoreDefaultArgs.length > 0
|
|
105
|
-
) {
|
|
106
|
-
options.ignoreDefaultArgs = normalizedIgnoreDefaultArgs;
|
|
128
|
+
if (engineIgnoreDefaultArgs === true || engineIgnoreDefaultArgs.length > 0) {
|
|
129
|
+
options.ignoreDefaultArgs = engineIgnoreDefaultArgs;
|
|
107
130
|
}
|
|
108
131
|
return setBrowserSelectionOptions(options, { channel, executablePath });
|
|
109
132
|
}
|
package/src/browser/launcher.js
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
buildPuppeteerLaunchOptions,
|
|
8
8
|
resolveChromeArgs,
|
|
9
9
|
} from './launch-options.js';
|
|
10
|
+
import { applyFingerprint } from '../fingerprint/apply.js';
|
|
10
11
|
import {
|
|
11
12
|
loadStorageState,
|
|
12
13
|
restorePlaywrightStorageState,
|
|
@@ -28,6 +29,8 @@ import {
|
|
|
28
29
|
* @param {string} [options.channel] - Installed browser channel, such as 'chrome', 'chrome-beta', or 'msedge'
|
|
29
30
|
* @param {string} [options.executablePath] - Explicit path to a Chrome or Chromium executable
|
|
30
31
|
* @param {string|Object} [options.storageState] - Playwright-compatible storage state path or object
|
|
32
|
+
* @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.
|
|
33
|
+
* @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.
|
|
31
34
|
* @returns {Promise<Object>} - Object with browser and page
|
|
32
35
|
*/
|
|
33
36
|
export async function launchBrowser(options = {}) {
|
|
@@ -44,6 +47,8 @@ export async function launchBrowser(options = {}) {
|
|
|
44
47
|
channel,
|
|
45
48
|
executablePath,
|
|
46
49
|
storageState,
|
|
50
|
+
fingerprint,
|
|
51
|
+
automationParity = true,
|
|
47
52
|
} = options;
|
|
48
53
|
|
|
49
54
|
const resolvedChromeArgs = resolveChromeArgs({
|
|
@@ -86,6 +91,7 @@ export async function launchBrowser(options = {}) {
|
|
|
86
91
|
channel,
|
|
87
92
|
executablePath,
|
|
88
93
|
ignoreDefaultArgs: resolvedChromeArgs.ignoreDefaultArgs,
|
|
94
|
+
automationParity,
|
|
89
95
|
});
|
|
90
96
|
browser = await chromium.launchPersistentContext(
|
|
91
97
|
userDataDir,
|
|
@@ -106,6 +112,7 @@ export async function launchBrowser(options = {}) {
|
|
|
106
112
|
channel,
|
|
107
113
|
executablePath,
|
|
108
114
|
ignoreDefaultArgs: resolvedChromeArgs.ignoreDefaultArgs,
|
|
115
|
+
automationParity,
|
|
109
116
|
})
|
|
110
117
|
);
|
|
111
118
|
const pages = await browser.pages();
|
|
@@ -134,6 +141,15 @@ export async function launchBrowser(options = {}) {
|
|
|
134
141
|
}
|
|
135
142
|
}
|
|
136
143
|
|
|
144
|
+
// The fingerprint is applied before the caller can navigate, so the first
|
|
145
|
+
// document a page loads already sees the configured environment.
|
|
146
|
+
if (fingerprint !== undefined) {
|
|
147
|
+
await applyFingerprint({ browser, page, engine, profile: fingerprint });
|
|
148
|
+
if (verbose) {
|
|
149
|
+
console.log('✅ Fingerprint profile applied');
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
137
153
|
// Unfocus address bar automatically after browser launch
|
|
138
154
|
// Using page.bringToFront() - confirmed working solution
|
|
139
155
|
try {
|
package/src/exports.js
CHANGED
|
@@ -139,3 +139,37 @@ export {
|
|
|
139
139
|
checkAndClearFlag,
|
|
140
140
|
findToggleButton,
|
|
141
141
|
} from './high-level/universal-logic.js';
|
|
142
|
+
|
|
143
|
+
// Re-export the fingerprint subsystem: parity with a hand-started browser, and
|
|
144
|
+
// full control over what a page reads about the environment.
|
|
145
|
+
export {
|
|
146
|
+
AUTOMATION_CONTROLLED_OFF_ARG,
|
|
147
|
+
AUTOMATION_CONTROLLED_TRIGGERS,
|
|
148
|
+
ENGINE_PARITY_IGNORED_DEFAULT_ARGS,
|
|
149
|
+
PLAYWRIGHT_HEADLESS_POINTER_ARG,
|
|
150
|
+
PLAYWRIGHT_SOFTWARE_WEBGL_ARG,
|
|
151
|
+
applyAutomationParityArgs,
|
|
152
|
+
detectAutomationControlledTriggers,
|
|
153
|
+
disablesAutomationControlled,
|
|
154
|
+
parityIgnoredDefaultArgs,
|
|
155
|
+
} from './fingerprint/automation-parity.js';
|
|
156
|
+
export {
|
|
157
|
+
FINGERPRINT_FIELD_MECHANISMS,
|
|
158
|
+
resolveFingerprintProfile,
|
|
159
|
+
} from './fingerprint/profile.js';
|
|
160
|
+
export { deriveUserAgentData } from './fingerprint/derive.js';
|
|
161
|
+
export { buildCdpEmulationCommands } from './fingerprint/cdp-overrides.js';
|
|
162
|
+
export {
|
|
163
|
+
buildFingerprintInitScript,
|
|
164
|
+
buildInitScriptConfig,
|
|
165
|
+
} from './fingerprint/init-script.js';
|
|
166
|
+
export { applyFingerprint, createCdpSession } from './fingerprint/apply.js';
|
|
167
|
+
export {
|
|
168
|
+
FINGERPRINT_PRESET_NAMES,
|
|
169
|
+
createFingerprintPreset,
|
|
170
|
+
} from './fingerprint/presets.js';
|
|
171
|
+
export {
|
|
172
|
+
FINGERPRINT_LIMITATIONS,
|
|
173
|
+
findFingerprintLimitation,
|
|
174
|
+
relevantFingerprintLimitations,
|
|
175
|
+
} from './fingerprint/limitations.js';
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Apply a fingerprint profile to a live page.
|
|
3
|
+
*
|
|
4
|
+
* Everything goes through CDP, including the init script, so the same code
|
|
5
|
+
* path works for Playwright and Puppeteer and behaves identically in both.
|
|
6
|
+
* The alternative -- `context.addInitScript` on one engine and
|
|
7
|
+
* `page.evaluateOnNewDocument` on the other -- would give the two engines
|
|
8
|
+
* subtly different injection timing, which is exactly the kind of difference
|
|
9
|
+
* this module exists to remove.
|
|
10
|
+
*/
|
|
11
|
+
import { buildCdpEmulationCommands } from './cdp-overrides.js';
|
|
12
|
+
import { buildFingerprintInitScript } from './init-script.js';
|
|
13
|
+
import { resolveFingerprintProfile } from './profile.js';
|
|
14
|
+
|
|
15
|
+
/** Open a CDP session for a page, whichever engine owns it. */
|
|
16
|
+
export function createCdpSession({ browser, page, engine }) {
|
|
17
|
+
if (engine === 'puppeteer') {
|
|
18
|
+
if (typeof page.createCDPSession === 'function') {
|
|
19
|
+
return page.createCDPSession();
|
|
20
|
+
}
|
|
21
|
+
return page.target().createCDPSession();
|
|
22
|
+
}
|
|
23
|
+
// Playwright exposes newCDPSession on the browser context; a persistent
|
|
24
|
+
// context is what `launchBrowser` hands back as `browser`.
|
|
25
|
+
const context =
|
|
26
|
+
typeof browser?.newCDPSession === 'function' ? browser : page.context();
|
|
27
|
+
return context.newCDPSession(page);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function applyToPage({ browser, page, engine, commands, initScript }) {
|
|
31
|
+
const session = await createCdpSession({ browser, page, engine });
|
|
32
|
+
for (const { method, params } of commands) {
|
|
33
|
+
await session.send(method, params);
|
|
34
|
+
}
|
|
35
|
+
if (initScript) {
|
|
36
|
+
// Measured: without Page.enable on this session, Chrome accepts
|
|
37
|
+
// addScriptToEvaluateOnNewDocument and returns an identifier, but never
|
|
38
|
+
// runs the script on any subsequent document. Enabling the domain is what
|
|
39
|
+
// makes the instrumentation take effect.
|
|
40
|
+
await session.send('Page.enable');
|
|
41
|
+
await session.send('Page.addScriptToEvaluateOnNewDocument', {
|
|
42
|
+
source: initScript,
|
|
43
|
+
});
|
|
44
|
+
// A page that has already navigated will not replay the init script, so
|
|
45
|
+
// patch the current document too. The payload guards against running
|
|
46
|
+
// twice, which makes this safe on a brand new about:blank as well.
|
|
47
|
+
await session.send('Runtime.evaluate', {
|
|
48
|
+
expression: initScript,
|
|
49
|
+
returnByValue: true,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
return session;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Apply a fingerprint profile to a page and, optionally, to pages opened later.
|
|
57
|
+
*
|
|
58
|
+
* @param {object} options
|
|
59
|
+
* @param {object} options.browser Playwright context or Puppeteer browser.
|
|
60
|
+
* @param {object} options.page Page to apply the profile to.
|
|
61
|
+
* @param {string} [options.engine] `'playwright'` or `'puppeteer'`.
|
|
62
|
+
* @param {object} options.profile Raw or already-normalized profile.
|
|
63
|
+
* @param {boolean} [options.patchWebdriver] Force `navigator.webdriver` to
|
|
64
|
+
* `false` from JavaScript. Only needed when attaching to a browser that was
|
|
65
|
+
* launched with automation switches that can no longer be changed; a browser
|
|
66
|
+
* launched by this library does not need it, because
|
|
67
|
+
* `--disable-blink-features=AutomationControlled` already covers it.
|
|
68
|
+
* @param {boolean} [options.applyToNewPages] Also apply to pages opened later.
|
|
69
|
+
* @returns {Promise<object>} The resolved profile, the commands that were sent
|
|
70
|
+
* and the init script that was injected.
|
|
71
|
+
*/
|
|
72
|
+
export async function applyFingerprint({
|
|
73
|
+
browser,
|
|
74
|
+
page,
|
|
75
|
+
engine = 'playwright',
|
|
76
|
+
profile,
|
|
77
|
+
patchWebdriver = false,
|
|
78
|
+
applyToNewPages = true,
|
|
79
|
+
}) {
|
|
80
|
+
if (!page) {
|
|
81
|
+
throw new TypeError('applyFingerprint requires a page');
|
|
82
|
+
}
|
|
83
|
+
const resolved = Object.isFrozen(profile)
|
|
84
|
+
? profile
|
|
85
|
+
: resolveFingerprintProfile(profile);
|
|
86
|
+
const commands = buildCdpEmulationCommands(resolved);
|
|
87
|
+
const initScript = buildFingerprintInitScript(resolved, { patchWebdriver });
|
|
88
|
+
|
|
89
|
+
await applyToPage({ browser, page, engine, commands, initScript });
|
|
90
|
+
|
|
91
|
+
if (applyToNewPages && browser) {
|
|
92
|
+
const attach = (newPage) =>
|
|
93
|
+
applyToPage({
|
|
94
|
+
browser,
|
|
95
|
+
page: newPage,
|
|
96
|
+
engine,
|
|
97
|
+
commands,
|
|
98
|
+
initScript,
|
|
99
|
+
}).catch(() => {
|
|
100
|
+
// A page can close before the session is established; losing the
|
|
101
|
+
// overrides on a page that no longer exists is not an error.
|
|
102
|
+
});
|
|
103
|
+
if (engine === 'puppeteer' && typeof browser.on === 'function') {
|
|
104
|
+
browser.on('targetcreated', async (target) => {
|
|
105
|
+
const newPage = await target.page();
|
|
106
|
+
if (newPage) {
|
|
107
|
+
await attach(newPage);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
} else if (typeof browser.on === 'function') {
|
|
111
|
+
browser.on('page', attach);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { profile: resolved, commands, initScript };
|
|
116
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chrome tells a page that it is automated through exactly one Blink runtime
|
|
3
|
+
* feature: `AutomationControlled`. `navigator.webdriver` is that feature and
|
|
4
|
+
* nothing else, so closing the gap between a hand-started Chrome and a
|
|
5
|
+
* Browser Commander Chrome is a matter of knowing which switches turn it on.
|
|
6
|
+
*
|
|
7
|
+
* `content/child/runtime_features.cc` in Chromium maps switches onto the
|
|
8
|
+
* feature in `SetRuntimeFeaturesFromCommandLine`:
|
|
9
|
+
*
|
|
10
|
+
* {wrf::EnableAutomationControlled, switches::kEnableAutomation, true},
|
|
11
|
+
* {wrf::EnableAutomationControlled, switches::kHeadless, true},
|
|
12
|
+
* {wrf::EnableAutomationControlled, switches::kRemoteDebuggingPipe, true},
|
|
13
|
+
*
|
|
14
|
+
* plus a special case directly below it: `--remote-debugging-port=0` also
|
|
15
|
+
* enables the feature, because an ephemeral port is how ChromeDriver launches
|
|
16
|
+
* the browser. A specific port number is left alone on purpose, since that is
|
|
17
|
+
* what a human attaching a debugger passes.
|
|
18
|
+
*
|
|
19
|
+
* https://chromium.googlesource.com/chromium/src/+/refs/heads/main/content/child/runtime_features.cc
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** Switch that disables the Blink feature regardless of what turned it on. */
|
|
23
|
+
export const AUTOMATION_CONTROLLED_OFF_ARG =
|
|
24
|
+
'--disable-blink-features=AutomationControlled';
|
|
25
|
+
|
|
26
|
+
/** Chrome switches that enable the `AutomationControlled` Blink feature. */
|
|
27
|
+
export const AUTOMATION_CONTROLLED_TRIGGERS = Object.freeze([
|
|
28
|
+
Object.freeze({
|
|
29
|
+
switch: '--enable-automation',
|
|
30
|
+
reason:
|
|
31
|
+
'Mapped onto AutomationControlled in content/child/runtime_features.cc; also shows the "controlled by automated test software" infobar.',
|
|
32
|
+
}),
|
|
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
|
+
Object.freeze({
|
|
39
|
+
switch: '--remote-debugging-pipe',
|
|
40
|
+
reason:
|
|
41
|
+
'Mapped onto AutomationControlled in content/child/runtime_features.cc. Playwright always passes it, and Puppeteer passes it whenever pipe transport is selected.',
|
|
42
|
+
}),
|
|
43
|
+
Object.freeze({
|
|
44
|
+
switch: '--remote-debugging-port=0',
|
|
45
|
+
reason:
|
|
46
|
+
'An ephemeral debugging port is how ChromeDriver launches Chrome, so runtime_features.cc treats it as automation. A fixed non-zero port is deliberately not treated that way. Puppeteer defaults to port 0.',
|
|
47
|
+
}),
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
function switchName(argument) {
|
|
51
|
+
const separator = argument.indexOf('=');
|
|
52
|
+
return separator === -1 ? argument : argument.slice(0, separator);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isTrigger(argument, trigger) {
|
|
56
|
+
if (trigger.switch === '--remote-debugging-port=0') {
|
|
57
|
+
return (
|
|
58
|
+
switchName(argument) === '--remote-debugging-port' &&
|
|
59
|
+
Number(argument.slice('--remote-debugging-port='.length)) === 0
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
if (trigger.switch === '--headless') {
|
|
63
|
+
return switchName(argument) === '--headless';
|
|
64
|
+
}
|
|
65
|
+
return switchName(argument) === trigger.switch;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Report which of the supplied Chrome switches would make `navigator.webdriver`
|
|
70
|
+
* true, so callers can explain a parity failure instead of only observing it.
|
|
71
|
+
*
|
|
72
|
+
* @param {string[]} args Chrome command line switches.
|
|
73
|
+
* @returns {Array<{switch: string, argument: string, reason: string}>} Triggers found.
|
|
74
|
+
*/
|
|
75
|
+
export function detectAutomationControlledTriggers(args = []) {
|
|
76
|
+
if (!Array.isArray(args)) {
|
|
77
|
+
throw new TypeError('args must be an array of strings');
|
|
78
|
+
}
|
|
79
|
+
const found = [];
|
|
80
|
+
for (const argument of args) {
|
|
81
|
+
if (typeof argument !== 'string') {
|
|
82
|
+
throw new TypeError('args must be an array of strings');
|
|
83
|
+
}
|
|
84
|
+
for (const trigger of AUTOMATION_CONTROLLED_TRIGGERS) {
|
|
85
|
+
if (isTrigger(argument, trigger)) {
|
|
86
|
+
found.push({
|
|
87
|
+
switch: trigger.switch,
|
|
88
|
+
argument,
|
|
89
|
+
reason: trigger.reason,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return found;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** True when the switch list already disables the AutomationControlled feature. */
|
|
98
|
+
export function disablesAutomationControlled(args = []) {
|
|
99
|
+
return args.some((argument) => {
|
|
100
|
+
if (switchName(argument) !== '--disable-blink-features') {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
return argument
|
|
104
|
+
.slice('--disable-blink-features='.length)
|
|
105
|
+
.split(',')
|
|
106
|
+
.some((feature) => feature.trim() === 'AutomationControlled');
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Append the switch that keeps `navigator.webdriver` false.
|
|
112
|
+
*
|
|
113
|
+
* The feature is disabled rather than the triggering switches removed: an
|
|
114
|
+
* engine adds `--remote-debugging-pipe` or `--remote-debugging-port=0` after
|
|
115
|
+
* the caller's arguments and needs that transport to work at all, so the only
|
|
116
|
+
* reliable place to intervene is the feature itself.
|
|
117
|
+
*
|
|
118
|
+
* @param {string[]} args Chrome switches assembled so far.
|
|
119
|
+
* @returns {string[]} Switches with automation parity applied.
|
|
120
|
+
*/
|
|
121
|
+
export function applyAutomationParityArgs(args = []) {
|
|
122
|
+
if (!Array.isArray(args)) {
|
|
123
|
+
throw new TypeError('args must be an array of strings');
|
|
124
|
+
}
|
|
125
|
+
if (disablesAutomationControlled(args)) {
|
|
126
|
+
return [...args];
|
|
127
|
+
}
|
|
128
|
+
const existingIndex = args.findIndex(
|
|
129
|
+
(argument) => switchName(argument) === '--disable-blink-features'
|
|
130
|
+
);
|
|
131
|
+
if (existingIndex === -1) {
|
|
132
|
+
return [...args, AUTOMATION_CONTROLLED_OFF_ARG];
|
|
133
|
+
}
|
|
134
|
+
// Chrome keeps only the last --disable-blink-features occurrence, so the
|
|
135
|
+
// existing feature list has to be extended in place rather than duplicated.
|
|
136
|
+
const merged = [...args];
|
|
137
|
+
merged[existingIndex] = `${args[existingIndex]},AutomationControlled`;
|
|
138
|
+
return merged;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Playwright forces a mouse-like pointer in headless Chrome:
|
|
143
|
+
*
|
|
144
|
+
* if (options.headless) {
|
|
145
|
+
* chromeArguments.push("--headless");
|
|
146
|
+
* chromeArguments.push(
|
|
147
|
+
* "--hide-scrollbars",
|
|
148
|
+
* "--mute-audio",
|
|
149
|
+
* "--blink-settings=primaryHoverType=2,availableHoverTypes=2," +
|
|
150
|
+
* "primaryPointerType=4,availablePointerTypes=4");
|
|
151
|
+
* }
|
|
152
|
+
*
|
|
153
|
+
* -- packages/playwright-core/src/server/chromium/chromium.ts. Headless Chrome
|
|
154
|
+
* has no pointing device, so a real headless browser answers `hover: none` and
|
|
155
|
+
* `pointer: none`; with that switch the browser answers `hover: hover` and
|
|
156
|
+
* `pointer: fine`, which is a four-media-query giveaway that no page script can
|
|
157
|
+
* explain away. Measured in analysis-artifacts/parity-headless.json.
|
|
158
|
+
*/
|
|
159
|
+
export const PLAYWRIGHT_HEADLESS_POINTER_ARG =
|
|
160
|
+
'--blink-settings=primaryHoverType=2,availableHoverTypes=2,primaryPointerType=4,availablePointerTypes=4';
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Playwright turns on software WebGL for every launch:
|
|
164
|
+
*
|
|
165
|
+
* const chromeArguments = [...chromiumSwitches()];
|
|
166
|
+
* chromeArguments.push('--enable-unsafe-swiftshader');
|
|
167
|
+
*
|
|
168
|
+
* -- packages/playwright-core/src/server/chromium/chromium.ts. Until 1.62 the
|
|
169
|
+
* push was guarded by `os.platform() === 'darwin'`; it is now unconditional.
|
|
170
|
+
*
|
|
171
|
+
* The switch tells Chrome to fall back to the SwiftShader software renderer
|
|
172
|
+
* when no usable GPU is present, which a hand-started Chrome refuses to do. On
|
|
173
|
+
* a machine without a GPU -- a container, a VM, a CI runner -- the difference
|
|
174
|
+
* is total: `canvas.getContext('webgl')` answers `null` in a real browser and
|
|
175
|
+
* a full context under Playwright, complete with the ANGLE/SwiftShader vendor
|
|
176
|
+
* and renderer strings. Measured in
|
|
177
|
+
* analysis-artifacts/parity-webgl-swiftshader.json.
|
|
178
|
+
*
|
|
179
|
+
* It has to be suppressed in headless too. Headless Chrome enables SwiftShader
|
|
180
|
+
* on its own, so removing the switch changes nothing there -- but leaving it in
|
|
181
|
+
* the headless list only would mean a headful launch kept it.
|
|
182
|
+
*/
|
|
183
|
+
export const PLAYWRIGHT_SOFTWARE_WEBGL_ARG = '--enable-unsafe-swiftshader';
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Default switches an engine adds that a hand-started Chrome does not have.
|
|
187
|
+
*
|
|
188
|
+
* These cannot be countered after launch: they have to be kept out of the
|
|
189
|
+
* command line through the engine's own `ignoreDefaultArgs` option.
|
|
190
|
+
*/
|
|
191
|
+
export const ENGINE_PARITY_IGNORED_DEFAULT_ARGS = Object.freeze({
|
|
192
|
+
playwright: Object.freeze({
|
|
193
|
+
always: Object.freeze([
|
|
194
|
+
'--enable-automation',
|
|
195
|
+
PLAYWRIGHT_SOFTWARE_WEBGL_ARG,
|
|
196
|
+
]),
|
|
197
|
+
headless: Object.freeze([PLAYWRIGHT_HEADLESS_POINTER_ARG]),
|
|
198
|
+
}),
|
|
199
|
+
puppeteer: Object.freeze({
|
|
200
|
+
always: Object.freeze(['--enable-automation']),
|
|
201
|
+
headless: Object.freeze([]),
|
|
202
|
+
}),
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Default switches to suppress so the engine's command line matches a
|
|
207
|
+
* hand-started Chrome.
|
|
208
|
+
*
|
|
209
|
+
* @param {'playwright'|'puppeteer'} engine Automation engine in use.
|
|
210
|
+
* @param {object} [options]
|
|
211
|
+
* @param {boolean} [options.headless] Whether the browser runs headless.
|
|
212
|
+
* @returns {string[]} Switches to pass as `ignoreDefaultArgs`.
|
|
213
|
+
*/
|
|
214
|
+
export function parityIgnoredDefaultArgs(engine, { headless = false } = {}) {
|
|
215
|
+
const entry = ENGINE_PARITY_IGNORED_DEFAULT_ARGS[engine];
|
|
216
|
+
if (!entry) {
|
|
217
|
+
throw new TypeError(
|
|
218
|
+
`unknown engine "${engine}"; expected one of ${Object.keys(
|
|
219
|
+
ENGINE_PARITY_IGNORED_DEFAULT_ARGS
|
|
220
|
+
).join(', ')}`
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
return [...entry.always, ...(headless ? entry.headless : [])];
|
|
224
|
+
}
|