browser-commander 0.16.1 → 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.
Files changed (61) hide show
  1. package/.changeset/config.json +1 -1
  2. package/.changeset/merged-loud-river.md +7 -0
  3. package/.jscpd-baseline.json +257 -0
  4. package/.jscpd.json +1 -3
  5. package/.prettierignore +1 -0
  6. package/CHANGELOG.md +8 -0
  7. package/eslint.config.js +23 -1
  8. package/package.json +13 -24
  9. package/scripts/changeset-version.mjs +2 -6
  10. package/scripts/create-github-release.mjs +7 -8
  11. package/scripts/create-manual-changeset.mjs +7 -8
  12. package/scripts/format-github-release.mjs +6 -7
  13. package/scripts/format-release-notes.mjs +7 -8
  14. package/scripts/instant-version-bump.mjs +7 -8
  15. package/scripts/publish-to-npm.mjs +7 -8
  16. package/scripts/setup-npm.mjs +2 -6
  17. package/scripts/version-and-commit.mjs +7 -8
  18. package/src/browser/browser-cookie-credentials.js +3 -1
  19. package/src/browser/browser-cookies.js +6 -2
  20. package/src/browser/browser-profiles.js +1 -1
  21. package/src/browser/launch-options.js +31 -8
  22. package/src/browser/launcher.js +16 -0
  23. package/src/exports.js +34 -0
  24. package/src/fingerprint/apply.js +116 -0
  25. package/src/fingerprint/automation-parity.js +224 -0
  26. package/src/fingerprint/cdp-overrides.js +176 -0
  27. package/src/fingerprint/derive.js +104 -0
  28. package/src/fingerprint/init-payload.js +181 -0
  29. package/src/fingerprint/init-script.js +104 -0
  30. package/src/fingerprint/limitations.js +107 -0
  31. package/src/fingerprint/limitations.json +96 -0
  32. package/src/fingerprint/presets.js +244 -0
  33. package/src/fingerprint/profile.js +428 -0
  34. package/src/tests/index.js +2 -1
  35. package/tests/e2e/fingerprint-parity.e2e.test.js +212 -0
  36. package/tests/helpers/mocks.js +4 -2
  37. package/tests/helpers/repo.js +74 -0
  38. package/tests/unit/browser/launch-options.test.js +9 -1
  39. package/tests/unit/experiments/fingerprint-parity-harness.test.js +97 -0
  40. package/tests/unit/fingerprint/apply.test.js +271 -0
  41. package/tests/unit/fingerprint/automation-parity.test.js +212 -0
  42. package/tests/unit/fingerprint/cdp-overrides.test.js +227 -0
  43. package/tests/unit/fingerprint/derive.test.js +105 -0
  44. package/tests/unit/fingerprint/init-script.test.js +357 -0
  45. package/tests/unit/fingerprint/limitations.test.js +184 -0
  46. package/tests/unit/fingerprint/presets.test.js +135 -0
  47. package/tests/unit/fingerprint/profile.test.js +282 -0
  48. package/tests/unit/scripts/check-pipeline-status.test.js +133 -0
  49. package/tests/unit/scripts/check-web-archive.test.js +191 -0
  50. package/tests/unit/scripts/ci-timeout-budgets.test.js +311 -0
  51. package/tests/unit/scripts/ci-workflow-policy.test.js +193 -157
  52. package/tests/unit/scripts/debug-print.test.js +67 -0
  53. package/tests/unit/scripts/jscpd-config.test.js +141 -0
  54. package/tests/unit/scripts/pre-commit-config.test.js +209 -0
  55. package/tests/unit/scripts/read-manifest.test.js +194 -0
  56. package/tests/unit/scripts/run-with-budget-warning.test.js +115 -0
  57. package/tests/unit/scripts/use-module-adoption.test.js +74 -0
  58. package/tests/unit/scripts/use-module-integration.test.js +64 -0
  59. package/tests/unit/scripts/use-module.test.js +182 -0
  60. package/.husky/pre-commit +0 -1
  61. package/.prettierrc +0 -10
@@ -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
- : [...new Set(['--enable-automation', ...normalizedIgnoreDefaultArgs])];
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: ['--start-maximized', ...chromeArgs],
125
+ args: automationParity ? applyAutomationParityArgs(baseArgs) : baseArgs,
100
126
  userDataDir,
101
127
  };
102
- if (
103
- normalizedIgnoreDefaultArgs === true ||
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
  }
@@ -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
+ }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Translate a fingerprint profile into CDP `Emulation` commands.
3
+ *
4
+ * These are the overrides Chrome itself enforces. They apply to workers and to
5
+ * outgoing HTTP headers, not only to the main world, which is what makes them
6
+ * strictly better than patching JavaScript properties. Anything that has no
7
+ * command here needs a page init script instead; `limitations.js` records why.
8
+ */
9
+
10
+ function userAgentMetadata(profile) {
11
+ const data = profile.userAgentData;
12
+ if (!data) {
13
+ return undefined;
14
+ }
15
+ // platform, platformVersion, architecture, model and mobile are required by
16
+ // the protocol; Chrome rejects the command when any of them is missing.
17
+ const metadata = {
18
+ platform: data.platform ?? '',
19
+ platformVersion: data.platformVersion ?? '',
20
+ architecture: data.architecture ?? '',
21
+ model: data.model ?? '',
22
+ mobile: data.mobile ?? false,
23
+ };
24
+ if (data.brands) {
25
+ metadata.brands = data.brands.map((entry) => ({ ...entry }));
26
+ }
27
+ if (data.fullVersionList) {
28
+ metadata.fullVersionList = data.fullVersionList.map((entry) => ({
29
+ ...entry,
30
+ }));
31
+ }
32
+ if (data.bitness !== undefined) {
33
+ metadata.bitness = data.bitness;
34
+ }
35
+ if (data.fullVersion !== undefined) {
36
+ // Deprecated in the protocol, but `fullVersionList` does not cover the
37
+ // `uaFullVersion` hint: without this the page still reads the real Chrome
38
+ // build number. See analysis-artifacts/ua-hints-detail.json.
39
+ metadata.fullVersion = data.fullVersion;
40
+ }
41
+ if (data.wow64 !== undefined) {
42
+ metadata.wow64 = data.wow64;
43
+ }
44
+ if (data.formFactors !== undefined) {
45
+ metadata.formFactors = [...data.formFactors];
46
+ }
47
+ return metadata;
48
+ }
49
+
50
+ function emulatedMediaFeatures(profile) {
51
+ const features = [];
52
+ if (profile.reducedMotion !== undefined) {
53
+ features.push({
54
+ name: 'prefers-reduced-motion',
55
+ value: profile.reducedMotion,
56
+ });
57
+ }
58
+ if (profile.forcedColors !== undefined) {
59
+ features.push({ name: 'forced-colors', value: profile.forcedColors });
60
+ }
61
+ if (profile.colorScheme !== undefined) {
62
+ features.push({ name: 'prefers-color-scheme', value: profile.colorScheme });
63
+ }
64
+ return features;
65
+ }
66
+
67
+ function deviceMetrics(profile) {
68
+ const { viewport, screen } = profile;
69
+ if (!viewport && !screen) {
70
+ return undefined;
71
+ }
72
+ const params = {
73
+ // 0 means "no override" for the viewport, so a profile that only sets
74
+ // screen dimensions still leaves the real window size alone.
75
+ width: viewport?.width ?? 0,
76
+ height: viewport?.height ?? 0,
77
+ deviceScaleFactor: viewport?.deviceScaleFactor ?? 0,
78
+ mobile: viewport?.mobile ?? false,
79
+ };
80
+ if (screen?.width !== undefined) {
81
+ params.screenWidth = screen.width;
82
+ params.screenHeight = screen.height;
83
+ }
84
+ return params;
85
+ }
86
+
87
+ /**
88
+ * Build the ordered CDP command list for a profile.
89
+ *
90
+ * @param {object} profile Normalized fingerprint profile.
91
+ * @returns {Array<{method: string, params: object}>} Commands to send in order.
92
+ */
93
+ export function buildCdpEmulationCommands(profile) {
94
+ if (!profile || typeof profile !== 'object') {
95
+ throw new TypeError('profile must be a normalized fingerprint profile');
96
+ }
97
+ const commands = [];
98
+
99
+ if (profile.userAgent !== undefined || profile.acceptLanguage !== undefined) {
100
+ const params = {};
101
+ if (profile.userAgent !== undefined) {
102
+ params.userAgent = profile.userAgent;
103
+ }
104
+ if (profile.acceptLanguage !== undefined) {
105
+ params.acceptLanguage = profile.acceptLanguage;
106
+ }
107
+ if (profile.platform !== undefined) {
108
+ params.platform = profile.platform;
109
+ }
110
+ const metadata = userAgentMetadata(profile);
111
+ if (metadata) {
112
+ params.userAgentMetadata = metadata;
113
+ }
114
+ // userAgent is a required parameter even when only the language changes.
115
+ if (params.userAgent === undefined) {
116
+ params.userAgent = '';
117
+ }
118
+ commands.push({ method: 'Emulation.setUserAgentOverride', params });
119
+ }
120
+
121
+ if (profile.timezoneId !== undefined) {
122
+ commands.push({
123
+ method: 'Emulation.setTimezoneOverride',
124
+ params: { timezoneId: profile.timezoneId },
125
+ });
126
+ }
127
+
128
+ if (profile.locale !== undefined) {
129
+ commands.push({
130
+ method: 'Emulation.setLocaleOverride',
131
+ params: { locale: profile.locale },
132
+ });
133
+ }
134
+
135
+ if (profile.hardwareConcurrency !== undefined) {
136
+ commands.push({
137
+ method: 'Emulation.setHardwareConcurrencyOverride',
138
+ params: { hardwareConcurrency: profile.hardwareConcurrency },
139
+ });
140
+ }
141
+
142
+ const metrics = deviceMetrics(profile);
143
+ if (metrics) {
144
+ commands.push({
145
+ method: 'Emulation.setDeviceMetricsOverride',
146
+ params: metrics,
147
+ });
148
+ }
149
+
150
+ if (profile.maxTouchPoints !== undefined) {
151
+ commands.push({
152
+ method: 'Emulation.setTouchEmulationEnabled',
153
+ params: {
154
+ enabled: profile.maxTouchPoints > 0,
155
+ maxTouchPoints: Math.max(profile.maxTouchPoints, 1),
156
+ },
157
+ });
158
+ }
159
+
160
+ const features = emulatedMediaFeatures(profile);
161
+ if (features.length > 0) {
162
+ commands.push({
163
+ method: 'Emulation.setEmulatedMedia',
164
+ params: { features },
165
+ });
166
+ }
167
+
168
+ if (profile.geolocation !== undefined) {
169
+ commands.push({
170
+ method: 'Emulation.setGeolocationOverride',
171
+ params: { ...profile.geolocation },
172
+ });
173
+ }
174
+
175
+ return commands;
176
+ }