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.
Files changed (62) 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 +22 -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-version-modification.test.js +70 -0
  50. package/tests/unit/scripts/check-web-archive.test.js +191 -0
  51. package/tests/unit/scripts/ci-timeout-budgets.test.js +311 -0
  52. package/tests/unit/scripts/ci-workflow-policy.test.js +217 -28
  53. package/tests/unit/scripts/debug-print.test.js +67 -0
  54. package/tests/unit/scripts/jscpd-config.test.js +141 -0
  55. package/tests/unit/scripts/pre-commit-config.test.js +209 -0
  56. package/tests/unit/scripts/read-manifest.test.js +194 -0
  57. package/tests/unit/scripts/run-with-budget-warning.test.js +115 -0
  58. package/tests/unit/scripts/use-module-adoption.test.js +74 -0
  59. package/tests/unit/scripts/use-module-integration.test.js +64 -0
  60. package/tests/unit/scripts/use-module.test.js +182 -0
  61. package/.husky/pre-commit +0 -1
  62. package/.prettierrc +0 -10
@@ -0,0 +1,227 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert';
3
+
4
+ import { buildCdpEmulationCommands } from '../../../src/fingerprint/cdp-overrides.js';
5
+ import { resolveFingerprintProfile } from '../../../src/fingerprint/profile.js';
6
+
7
+ function methodsOf(profile) {
8
+ return buildCdpEmulationCommands(resolveFingerprintProfile(profile)).map(
9
+ (command) => command.method
10
+ );
11
+ }
12
+
13
+ function paramsOf(profile, method) {
14
+ const command = buildCdpEmulationCommands(
15
+ resolveFingerprintProfile(profile)
16
+ ).find((entry) => entry.method === method);
17
+ return command?.params;
18
+ }
19
+
20
+ describe('CDP emulation commands', () => {
21
+ it('rejects anything that is not a profile object', () => {
22
+ assert.throws(
23
+ () => buildCdpEmulationCommands(null),
24
+ /must be a normalized fingerprint profile/u
25
+ );
26
+ assert.throws(
27
+ () => buildCdpEmulationCommands('windows'),
28
+ /must be a normalized fingerprint profile/u
29
+ );
30
+ });
31
+
32
+ it('emits nothing for an empty profile', () => {
33
+ assert.deepEqual(
34
+ buildCdpEmulationCommands(resolveFingerprintProfile({})),
35
+ []
36
+ );
37
+ });
38
+
39
+ it('sends only the commands the profile asks for', () => {
40
+ assert.deepEqual(methodsOf({ timezoneId: 'Europe/Berlin' }), [
41
+ 'Emulation.setTimezoneOverride',
42
+ ]);
43
+ assert.deepEqual(methodsOf({ hardwareConcurrency: 4 }), [
44
+ 'Emulation.setHardwareConcurrencyOverride',
45
+ ]);
46
+ });
47
+
48
+ it('keeps a stable command order for a full profile', () => {
49
+ assert.deepEqual(
50
+ methodsOf({
51
+ userAgent:
52
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' +
53
+ '(KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36',
54
+ timezoneId: 'Europe/Berlin',
55
+ locale: 'de-DE',
56
+ hardwareConcurrency: 12,
57
+ screen: { width: 2560, height: 1440 },
58
+ viewport: { width: 1280, height: 720 },
59
+ maxTouchPoints: 0,
60
+ colorScheme: 'dark',
61
+ geolocation: { latitude: 52.52, longitude: 13.405, accuracy: 20 },
62
+ }),
63
+ [
64
+ 'Emulation.setUserAgentOverride',
65
+ 'Emulation.setTimezoneOverride',
66
+ 'Emulation.setLocaleOverride',
67
+ 'Emulation.setHardwareConcurrencyOverride',
68
+ 'Emulation.setDeviceMetricsOverride',
69
+ 'Emulation.setTouchEmulationEnabled',
70
+ 'Emulation.setEmulatedMedia',
71
+ 'Emulation.setGeolocationOverride',
72
+ ]
73
+ );
74
+ });
75
+
76
+ it('supplies the required empty user agent when only the language changes', () => {
77
+ const params = paramsOf(
78
+ { languages: ['fr-FR', 'fr'] },
79
+ 'Emulation.setUserAgentOverride'
80
+ );
81
+ // userAgent is a required protocol parameter; an empty string means
82
+ // "leave it alone" while acceptLanguage still takes effect.
83
+ assert.equal(params.userAgent, '');
84
+ assert.equal(params.acceptLanguage, 'fr-FR,fr');
85
+ });
86
+
87
+ it('carries the client hints, including the deprecated full version', () => {
88
+ const params = paramsOf(
89
+ {
90
+ userAgent:
91
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' +
92
+ '(KHTML, like Gecko) Chrome/140.0.7000.55 Safari/537.36',
93
+ platform: 'Win32',
94
+ },
95
+ 'Emulation.setUserAgentOverride'
96
+ );
97
+
98
+ assert.equal(params.platform, 'Win32');
99
+ assert.equal(params.userAgentMetadata.platform, 'Windows');
100
+ // fullVersionList does not cover the uaFullVersion hint, so the deprecated
101
+ // fullVersion field has to travel with it.
102
+ assert.equal(params.userAgentMetadata.fullVersion, '140.0.7000.55');
103
+ assert.equal(params.userAgentMetadata.bitness, '64');
104
+ assert.equal(params.userAgentMetadata.wow64, false);
105
+ assert.deepEqual(params.userAgentMetadata.formFactors, ['Desktop']);
106
+ });
107
+
108
+ it('always fills the protocol-required metadata fields', () => {
109
+ const params = paramsOf(
110
+ {
111
+ userAgent: 'custom agent',
112
+ userAgentData: { brands: [{ brand: 'Custom', version: '1' }] },
113
+ },
114
+ 'Emulation.setUserAgentOverride'
115
+ );
116
+
117
+ // Chrome rejects setUserAgentOverride when any of these is missing.
118
+ for (const field of [
119
+ 'platform',
120
+ 'platformVersion',
121
+ 'architecture',
122
+ 'model',
123
+ 'mobile',
124
+ ]) {
125
+ assert.ok(field in params.userAgentMetadata, `${field} must be present`);
126
+ }
127
+ assert.equal(params.userAgentMetadata.mobile, false);
128
+ assert.ok(!('bitness' in params.userAgentMetadata));
129
+ });
130
+
131
+ it('copies the brand entries instead of sharing them with the profile', () => {
132
+ const profile = resolveFingerprintProfile({
133
+ userAgent:
134
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' +
135
+ '(KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36',
136
+ });
137
+ const [{ params }] = buildCdpEmulationCommands(profile);
138
+
139
+ assert.notEqual(
140
+ params.userAgentMetadata.brands,
141
+ profile.userAgentData.brands
142
+ );
143
+ assert.notEqual(
144
+ params.userAgentMetadata.brands[0],
145
+ profile.userAgentData.brands[0]
146
+ );
147
+ });
148
+
149
+ it('leaves the window size alone when only the screen is described', () => {
150
+ const params = paramsOf(
151
+ { screen: { width: 2560, height: 1440 } },
152
+ 'Emulation.setDeviceMetricsOverride'
153
+ );
154
+
155
+ // Zeroes mean "no override" for the viewport, so a screen-only profile
156
+ // does not resize the window it was applied to.
157
+ assert.deepEqual(params, {
158
+ width: 0,
159
+ height: 0,
160
+ deviceScaleFactor: 0,
161
+ mobile: false,
162
+ screenWidth: 2560,
163
+ screenHeight: 1440,
164
+ });
165
+ });
166
+
167
+ it('sends the viewport without screen dimensions when no screen is set', () => {
168
+ assert.deepEqual(
169
+ paramsOf(
170
+ {
171
+ viewport: {
172
+ width: 1280,
173
+ height: 720,
174
+ deviceScaleFactor: 2,
175
+ mobile: true,
176
+ },
177
+ },
178
+ 'Emulation.setDeviceMetricsOverride'
179
+ ),
180
+ { width: 1280, height: 720, deviceScaleFactor: 2, mobile: true }
181
+ );
182
+ });
183
+
184
+ it('disables touch emulation for a desktop profile that names zero touch points', () => {
185
+ // maxTouchPoints must stay at least 1 because the protocol rejects 0, so
186
+ // "no touch" is expressed through enabled instead.
187
+ assert.deepEqual(
188
+ paramsOf({ maxTouchPoints: 0 }, 'Emulation.setTouchEmulationEnabled'),
189
+ { enabled: false, maxTouchPoints: 1 }
190
+ );
191
+ assert.deepEqual(
192
+ paramsOf({ maxTouchPoints: 5 }, 'Emulation.setTouchEmulationEnabled'),
193
+ { enabled: true, maxTouchPoints: 5 }
194
+ );
195
+ });
196
+
197
+ it('collects every media preference into a single command', () => {
198
+ assert.deepEqual(
199
+ paramsOf(
200
+ {
201
+ colorScheme: 'dark',
202
+ reducedMotion: 'reduce',
203
+ forcedColors: 'active',
204
+ },
205
+ 'Emulation.setEmulatedMedia'
206
+ ),
207
+ {
208
+ features: [
209
+ { name: 'prefers-reduced-motion', value: 'reduce' },
210
+ { name: 'forced-colors', value: 'active' },
211
+ { name: 'prefers-color-scheme', value: 'dark' },
212
+ ],
213
+ }
214
+ );
215
+ });
216
+
217
+ it('passes geolocation through as its own copy', () => {
218
+ const geolocation = { latitude: 48.85, longitude: 2.35, accuracy: 10 };
219
+ const params = paramsOf(
220
+ { geolocation },
221
+ 'Emulation.setGeolocationOverride'
222
+ );
223
+
224
+ assert.deepEqual(params, geolocation);
225
+ assert.notEqual(params, geolocation);
226
+ });
227
+ });
@@ -0,0 +1,105 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert';
3
+
4
+ import { deriveUserAgentData } from '../../../src/fingerprint/derive.js';
5
+
6
+ describe('client hints derived from a user agent', () => {
7
+ it('returns null when the string names no Chrome version', () => {
8
+ assert.equal(
9
+ deriveUserAgentData(
10
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ' +
11
+ 'AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15'
12
+ ),
13
+ null
14
+ );
15
+ });
16
+
17
+ it('rejects a non-string user agent', () => {
18
+ assert.throws(() => deriveUserAgentData(undefined), /must be a string/u);
19
+ });
20
+
21
+ it('maps a Windows user agent onto the Windows platform hints', () => {
22
+ const data = deriveUserAgentData(
23
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' +
24
+ '(KHTML, like Gecko) Chrome/140.0.7000.55 Safari/537.36'
25
+ );
26
+
27
+ assert.equal(data.platform, 'Windows');
28
+ assert.equal(data.architecture, 'x86');
29
+ assert.equal(data.bitness, '64');
30
+ assert.equal(data.mobile, false);
31
+ assert.deepEqual(data.formFactors, ['Desktop']);
32
+ // Chrome froze the user agent at "Windows NT 10.0" and reports the real
33
+ // version only through platformVersion, where 13 and up mean Windows 11.
34
+ assert.equal(data.platformVersion, '15.0.0');
35
+ assert.equal(data.fullVersion, '140.0.7000.55');
36
+ });
37
+
38
+ it('pads a major-only Chrome version into a four part full version', () => {
39
+ const data = deriveUserAgentData(
40
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' +
41
+ '(KHTML, like Gecko) Chrome/141 Safari/537.36'
42
+ );
43
+
44
+ assert.equal(data.fullVersion, '141.0.0.0');
45
+ assert.deepEqual(
46
+ data.brands.map((entry) => entry.version),
47
+ ['141', '141', '24']
48
+ );
49
+ });
50
+
51
+ it('parses the macOS version out of the user agent', () => {
52
+ const data = deriveUserAgentData(
53
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ' +
54
+ '(KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36'
55
+ );
56
+
57
+ assert.equal(data.platform, 'macOS');
58
+ assert.equal(data.platformVersion, '10.15.7');
59
+ });
60
+
61
+ it('reports an Android phone as mobile and recovers the model', () => {
62
+ const data = deriveUserAgentData(
63
+ 'Mozilla/5.0 (Linux; Android 14; Pixel 8 Build/UQ1A.240105.004) ' +
64
+ 'AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Mobile Safari/537.36'
65
+ );
66
+
67
+ assert.equal(data.platform, 'Android');
68
+ assert.equal(data.platformVersion, '14');
69
+ assert.equal(data.mobile, true);
70
+ assert.equal(data.model, 'Pixel 8');
71
+ assert.deepEqual(data.formFactors, ['Mobile']);
72
+ });
73
+
74
+ it('recognises Linux and Chrome OS', () => {
75
+ assert.equal(
76
+ deriveUserAgentData(
77
+ 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 ' +
78
+ '(KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36'
79
+ ).platform,
80
+ 'Linux'
81
+ );
82
+ assert.equal(
83
+ deriveUserAgentData(
84
+ 'Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 ' +
85
+ '(KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36'
86
+ ).platform,
87
+ 'Chrome OS'
88
+ );
89
+ });
90
+
91
+ it('includes the GREASE brand so the brand list has a real browser shape', () => {
92
+ const data = deriveUserAgentData(
93
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' +
94
+ '(KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36'
95
+ );
96
+
97
+ assert.equal(data.brands.length, 3);
98
+ assert.ok(data.brands.some((entry) => entry.brand === 'Not=A?Brand'));
99
+ assert.ok(
100
+ data.fullVersionList.every(
101
+ (entry) => entry.version.split('.').length === 4
102
+ )
103
+ );
104
+ });
105
+ });
@@ -0,0 +1,357 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import vm from 'node:vm';
4
+
5
+ import {
6
+ buildFingerprintInitScript,
7
+ buildInitScriptConfig,
8
+ FINGERPRINT_PAYLOAD_SOURCE,
9
+ } from '../../../src/fingerprint/init-script.js';
10
+ import { resolveFingerprintProfile } from '../../../src/fingerprint/profile.js';
11
+
12
+ /**
13
+ * Run a generated init script in a throwaway realm that looks enough like a
14
+ * page to exercise every patch: the payload only ever touches prototypes it
15
+ * finds on the global object, so a handful of stand-in constructors is all it
16
+ * needs. Running it for real is what proves the descriptor shapes, the
17
+ * `[native code]` masking and the double-application guard, none of which a
18
+ * string comparison would catch.
19
+ */
20
+ function runInFakeRealm(script, { hostValues = {} } = {}) {
21
+ const context = vm.createContext({});
22
+ vm.runInContext(
23
+ `
24
+ function Navigator() {}
25
+ function WorkerNavigator() {}
26
+ function Screen() {}
27
+ function WebGLRenderingContext() {}
28
+ function WebGL2RenderingContext() {}
29
+ const host = ${JSON.stringify({
30
+ deviceMemory: 64,
31
+ vendor: 'Host Vendor',
32
+ platform: 'HostPlatform',
33
+ language: 'xx-XX',
34
+ languages: ['xx-XX'],
35
+ doNotTrack: '1',
36
+ hardwareConcurrency: 128,
37
+ maxTouchPoints: 0,
38
+ ...hostValues,
39
+ })};
40
+ for (const property of Object.keys(host)) {
41
+ const value = host[property];
42
+ Object.defineProperty(Navigator.prototype, property, {
43
+ get() { return value; },
44
+ enumerable: true,
45
+ configurable: true,
46
+ });
47
+ Object.defineProperty(WorkerNavigator.prototype, property, {
48
+ get() { return value; },
49
+ enumerable: true,
50
+ configurable: true,
51
+ });
52
+ }
53
+ for (const property of ['width', 'height', 'availWidth', 'availHeight', 'colorDepth', 'pixelDepth']) {
54
+ Object.defineProperty(Screen.prototype, property, {
55
+ get() { return 1; },
56
+ enumerable: true,
57
+ configurable: true,
58
+ });
59
+ }
60
+ for (const name of ['WebGLRenderingContext', 'WebGL2RenderingContext']) {
61
+ this[name].prototype.getParameter = function getParameter(parameter) {
62
+ return 'real-' + parameter;
63
+ };
64
+ }
65
+ globalThis.navigator = new Navigator();
66
+ globalThis.screen = new Screen();
67
+ globalThis.gl = new WebGLRenderingContext();
68
+ `,
69
+ context
70
+ );
71
+ vm.runInContext(script, context);
72
+ return context;
73
+ }
74
+
75
+ describe('init script config', () => {
76
+ it('rejects anything that is not a profile object', () => {
77
+ assert.throws(
78
+ () => buildFingerprintInitScript(undefined),
79
+ /must be a normalized fingerprint profile/u
80
+ );
81
+ });
82
+
83
+ it('returns null when the browser-side overrides already cover everything', () => {
84
+ const profile = resolveFingerprintProfile({
85
+ userAgent: 'Mozilla/5.0 (X11; Linux x86_64) Chrome/140.0.0.0',
86
+ timezoneId: 'UTC',
87
+ hardwareConcurrency: 8,
88
+ maxTouchPoints: 0,
89
+ });
90
+
91
+ assert.equal(buildInitScriptConfig(profile), null);
92
+ assert.equal(buildFingerprintInitScript(profile), null);
93
+ });
94
+
95
+ it('patches only the fields the Emulation domain has no command for', () => {
96
+ const profile = resolveFingerprintProfile({
97
+ deviceMemory: 8,
98
+ vendor: 'Google Inc.',
99
+ doNotTrack: '1',
100
+ hardwareConcurrency: 8,
101
+ timezoneId: 'UTC',
102
+ });
103
+
104
+ // hardwareConcurrency and timezoneId are browser-enforced, so they must
105
+ // not appear in the weaker JavaScript patch.
106
+ assert.deepEqual(Object.keys(buildInitScriptConfig(profile)).sort(), [
107
+ 'deviceMemory',
108
+ 'doNotTrack',
109
+ 'vendor',
110
+ ]);
111
+ });
112
+
113
+ it('adds webdriver only when the caller asks for it', () => {
114
+ const profile = resolveFingerprintProfile({ vendor: 'Google Inc.' });
115
+
116
+ assert.equal(buildInitScriptConfig(profile).webdriver, undefined);
117
+ assert.equal(
118
+ buildInitScriptConfig(profile, { patchWebdriver: true }).webdriver,
119
+ false
120
+ );
121
+ });
122
+
123
+ it('patches webdriver even for an otherwise empty profile', () => {
124
+ const script = buildFingerprintInitScript(resolveFingerprintProfile({}), {
125
+ patchWebdriver: true,
126
+ });
127
+
128
+ assert.ok(script);
129
+ const context = runInFakeRealm(script);
130
+ assert.equal(vm.runInContext('navigator.webdriver', context), false);
131
+ });
132
+
133
+ it('leaves languages to the browser unless explicitly asked', () => {
134
+ const profile = resolveFingerprintProfile({ languages: ['fr-FR', 'fr'] });
135
+
136
+ assert.equal(buildInitScriptConfig(profile), null);
137
+ assert.deepEqual(
138
+ buildInitScriptConfig(profile, { patchLanguages: true }).languages,
139
+ ['fr-FR', 'fr']
140
+ );
141
+ });
142
+
143
+ it('drops the screen dimensions setDeviceMetricsOverride already enforces', () => {
144
+ const config = buildInitScriptConfig(
145
+ resolveFingerprintProfile({
146
+ screen: {
147
+ width: 1920,
148
+ height: 1080,
149
+ availWidth: 1920,
150
+ availHeight: 1032,
151
+ colorDepth: 24,
152
+ pixelDepth: 24,
153
+ },
154
+ })
155
+ );
156
+
157
+ assert.deepEqual(Object.keys(config.screen).sort(), [
158
+ 'availHeight',
159
+ 'availWidth',
160
+ 'colorDepth',
161
+ 'pixelDepth',
162
+ ]);
163
+ });
164
+
165
+ it('skips the screen patch when only width and height are given', () => {
166
+ assert.equal(
167
+ buildInitScriptConfig(
168
+ resolveFingerprintProfile({ screen: { width: 1920, height: 1080 } })
169
+ ),
170
+ null
171
+ );
172
+ });
173
+ });
174
+
175
+ describe('init script behaviour in a page-like realm', () => {
176
+ const profile = resolveFingerprintProfile({
177
+ deviceMemory: 8,
178
+ vendor: 'Google Inc.',
179
+ doNotTrack: null,
180
+ platform: 'Win32',
181
+ languages: ['de-DE', 'de'],
182
+ screen: {
183
+ width: 1920,
184
+ height: 1080,
185
+ availWidth: 1920,
186
+ availHeight: 1032,
187
+ colorDepth: 24,
188
+ pixelDepth: 24,
189
+ },
190
+ webgl: {
191
+ vendor: 'WebKit',
192
+ renderer: 'WebKit WebGL',
193
+ unmaskedVendor: 'Google Inc. (NVIDIA)',
194
+ unmaskedRenderer: 'ANGLE (NVIDIA, NVIDIA GeForce RTX 3060, D3D11)',
195
+ },
196
+ });
197
+ const script = buildFingerprintInitScript(profile, {
198
+ patchWebdriver: true,
199
+ patchLanguages: true,
200
+ });
201
+
202
+ it('replaces the values a page reads from navigator', () => {
203
+ const context = runInFakeRealm(script);
204
+
205
+ assert.equal(vm.runInContext('navigator.deviceMemory', context), 8);
206
+ assert.equal(vm.runInContext('navigator.vendor', context), 'Google Inc.');
207
+ assert.equal(vm.runInContext('navigator.webdriver', context), false);
208
+ assert.equal(vm.runInContext('navigator.language', context), 'de-DE');
209
+ assert.deepEqual(
210
+ vm.runInContext('Array.from(navigator.languages)', context),
211
+ ['de-DE', 'de']
212
+ );
213
+ });
214
+
215
+ it('hands out a frozen copy of languages so page code cannot mutate ours', () => {
216
+ const context = runInFakeRealm(script);
217
+
218
+ assert.equal(
219
+ vm.runInContext('Object.isFrozen(navigator.languages)', context),
220
+ true
221
+ );
222
+ assert.equal(
223
+ vm.runInContext('navigator.languages !== navigator.languages', context),
224
+ true
225
+ );
226
+ });
227
+
228
+ it('patches only the screen fields the profile named', () => {
229
+ const context = runInFakeRealm(script);
230
+
231
+ assert.equal(vm.runInContext('screen.availHeight', context), 1032);
232
+ assert.equal(vm.runInContext('screen.colorDepth', context), 24);
233
+ // width and height stay with the browser-side override.
234
+ assert.equal(vm.runInContext('screen.width', context), 1);
235
+ });
236
+
237
+ it('answers the WebGL renderer queries and forwards everything else', () => {
238
+ const context = runInFakeRealm(script);
239
+
240
+ // 0x9245/0x9246 are UNMASKED_VENDOR_WEBGL/UNMASKED_RENDERER_WEBGL.
241
+ assert.equal(
242
+ vm.runInContext('gl.getParameter(0x9245)', context),
243
+ 'Google Inc. (NVIDIA)'
244
+ );
245
+ assert.equal(vm.runInContext('gl.getParameter(0x1f00)', context), 'WebKit');
246
+ assert.equal(
247
+ vm.runInContext('gl.getParameter(0x1234)', context),
248
+ 'real-4660'
249
+ );
250
+ });
251
+
252
+ it('keeps the descriptor shape of a real accessor', () => {
253
+ const context = runInFakeRealm(script);
254
+ const descriptor = vm.runInContext(
255
+ `(() => {
256
+ const d = Object.getOwnPropertyDescriptor(Navigator.prototype, 'deviceMemory');
257
+ return {
258
+ enumerable: d.enumerable,
259
+ configurable: d.configurable,
260
+ hasGetter: typeof d.get === 'function',
261
+ setter: d.set,
262
+ name: d.get.name,
263
+ };
264
+ })()`,
265
+ context
266
+ );
267
+
268
+ assert.deepEqual(descriptor, {
269
+ enumerable: true,
270
+ configurable: true,
271
+ hasGetter: true,
272
+ setter: undefined,
273
+ name: 'get deviceMemory',
274
+ });
275
+ });
276
+
277
+ it('makes the replacements report native source', () => {
278
+ const context = runInFakeRealm(script);
279
+
280
+ assert.equal(
281
+ vm.runInContext(
282
+ "Object.getOwnPropertyDescriptor(Navigator.prototype, 'deviceMemory').get.toString()",
283
+ context
284
+ ),
285
+ 'function get deviceMemory() { [native code] }'
286
+ );
287
+ assert.equal(
288
+ vm.runInContext(
289
+ 'WebGLRenderingContext.prototype.getParameter.toString()',
290
+ context
291
+ ),
292
+ 'function getParameter() { [native code] }'
293
+ );
294
+ // The masking of toString itself has to be masked too, or the patch is
295
+ // detectable by asking the one function that does the lying.
296
+ assert.equal(
297
+ vm.runInContext('Function.prototype.toString.toString()', context),
298
+ 'function toString() { [native code] }'
299
+ );
300
+ // Untouched functions still report their real source.
301
+ assert.match(
302
+ vm.runInContext('(function example() { return 1; }).toString()', context),
303
+ /return 1/u
304
+ );
305
+ });
306
+
307
+ it('does not stack getters when the script runs twice', () => {
308
+ const context = runInFakeRealm(script);
309
+ vm.runInContext(script, context);
310
+
311
+ assert.equal(vm.runInContext('navigator.deviceMemory', context), 8);
312
+ assert.equal(
313
+ vm.runInContext(
314
+ 'WebGLRenderingContext.prototype.getParameter.toString()',
315
+ context
316
+ ),
317
+ 'function getParameter() { [native code] }'
318
+ );
319
+ assert.equal(
320
+ vm.runInContext('gl.getParameter(0x1234)', context),
321
+ 'real-4660'
322
+ );
323
+ });
324
+
325
+ it('carries the shared payload asset and nothing else', () => {
326
+ // The payload is one file that Python and Rust send byte for byte, so the
327
+ // script has to be that file rather than a copy generated from a function
328
+ // in this package. scripts/check-shared-fingerprint-assets.sh guards the copies.
329
+ assert.ok(script.includes(FINGERPRINT_PAYLOAD_SOURCE));
330
+ assert.match(
331
+ FINGERPRINT_PAYLOAD_SOURCE,
332
+ /^function fingerprintPayload\(/mu
333
+ );
334
+ assert.doesNotMatch(FINGERPRINT_PAYLOAD_SOURCE, /^(import|export)\s/mu);
335
+ });
336
+
337
+ it('leaves no helper of its own on the page global', () => {
338
+ const context = runInFakeRealm(script);
339
+
340
+ assert.equal(
341
+ vm.runInContext('typeof fingerprintPayload', context),
342
+ 'undefined'
343
+ );
344
+ });
345
+
346
+ it('hides its own marker from enumeration', () => {
347
+ const context = runInFakeRealm(script);
348
+
349
+ assert.equal(
350
+ vm.runInContext(
351
+ "Object.keys(globalThis).includes('__browserCommanderFingerprint')",
352
+ context
353
+ ),
354
+ false
355
+ );
356
+ });
357
+ });