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
@@ -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
+ });
@@ -0,0 +1,184 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import { readFileSync } from 'node:fs';
4
+
5
+ import {
6
+ FINGERPRINT_LIMITATIONS,
7
+ findFingerprintLimitation,
8
+ relevantFingerprintLimitations,
9
+ } from '../../../src/fingerprint/limitations.js';
10
+
11
+ const ids = (limitations) => limitations.map((limitation) => limitation.id);
12
+
13
+ describe('the documented limitations', () => {
14
+ it('gives every entry a unique id and the fields a reader needs', () => {
15
+ const seen = new Set();
16
+ for (const limitation of FINGERPRINT_LIMITATIONS) {
17
+ assert.ok(!seen.has(limitation.id), `duplicate id ${limitation.id}`);
18
+ seen.add(limitation.id);
19
+ assert.ok(limitation.surface.length > 0, limitation.id);
20
+ assert.ok(limitation.detail.length > 0, limitation.id);
21
+ assert.ok(
22
+ ['high', 'medium', 'low'].includes(limitation.severity),
23
+ limitation.id
24
+ );
25
+ assert.ok(
26
+ ['measured', 'documented'].includes(limitation.evidence),
27
+ limitation.id
28
+ );
29
+ }
30
+ });
31
+
32
+ it('points every measured entry at the artifact that proves it', () => {
33
+ for (const limitation of FINGERPRINT_LIMITATIONS) {
34
+ if (limitation.evidence === 'measured') {
35
+ assert.ok(limitation.reference, `${limitation.id} needs a reference`);
36
+ }
37
+ }
38
+ });
39
+
40
+ it('cannot be edited by a caller', () => {
41
+ assert.ok(Object.isFrozen(FINGERPRINT_LIMITATIONS));
42
+ assert.ok(FINGERPRINT_LIMITATIONS.every((entry) => Object.isFrozen(entry)));
43
+ });
44
+
45
+ it('is the shared JSON asset Python and Rust also publish', () => {
46
+ // The catalogue is data, not code, so the three packages ship one file
47
+ // rather than three translations of the same eleven paragraphs.
48
+ const asset = JSON.parse(
49
+ readFileSync(
50
+ new URL('../../../src/fingerprint/limitations.json', import.meta.url),
51
+ 'utf8'
52
+ )
53
+ );
54
+
55
+ assert.deepEqual(asset, FINGERPRINT_LIMITATIONS);
56
+ });
57
+
58
+ it('looks an entry up by id', () => {
59
+ assert.equal(
60
+ findFingerprintLimitation('webgl-strings-only').surface,
61
+ 'WebGL renderer strings and driver limits'
62
+ );
63
+ assert.equal(findFingerprintLimitation('no-such-limitation'), undefined);
64
+ });
65
+ });
66
+
67
+ describe('limitations relevant to a profile', () => {
68
+ it('always reports the two nothing can be done about', () => {
69
+ assert.deepEqual(ids(relevantFingerprintLimitations()), [
70
+ 'canvas-audio-font-follow-the-host',
71
+ 'network-layer-not-covered',
72
+ ]);
73
+ });
74
+
75
+ it("mentions the launch-only switch only when attaching to someone else's browser", () => {
76
+ assert.ok(
77
+ !ids(relevantFingerprintLimitations({}, {})).includes(
78
+ 'automation-controlled-is-launch-only'
79
+ )
80
+ );
81
+ assert.ok(
82
+ ids(relevantFingerprintLimitations({}, { attached: true })).includes(
83
+ 'automation-controlled-is-launch-only'
84
+ )
85
+ );
86
+ });
87
+
88
+ it('mentions headless only for a headless browser', () => {
89
+ assert.ok(
90
+ ids(relevantFingerprintLimitations({}, { headless: true })).includes(
91
+ 'headless-is-distinguishable'
92
+ )
93
+ );
94
+ });
95
+
96
+ it('mentions the JavaScript-only fields when the profile sets them', () => {
97
+ assert.ok(
98
+ ids(relevantFingerprintLimitations({ deviceMemory: 8 })).includes(
99
+ 'no-cdp-device-memory-override'
100
+ )
101
+ );
102
+ assert.ok(
103
+ ids(relevantFingerprintLimitations({ doNotTrack: '1' })).includes(
104
+ 'no-cdp-vendor-or-dnt-override'
105
+ )
106
+ );
107
+ assert.ok(
108
+ ids(
109
+ relevantFingerprintLimitations({ webgl: { vendor: 'WebKit' } })
110
+ ).includes('webgl-strings-only')
111
+ );
112
+ });
113
+
114
+ it('mentions the screen entry only for the fields CDP does not emulate', () => {
115
+ assert.ok(
116
+ !ids(
117
+ relevantFingerprintLimitations({
118
+ screen: { width: 1920, height: 1080 },
119
+ })
120
+ ).includes('screen-depth-and-avail-not-emulated')
121
+ );
122
+ assert.ok(
123
+ ids(
124
+ relevantFingerprintLimitations({
125
+ screen: { width: 1920, height: 1080, availHeight: 1032 },
126
+ })
127
+ ).includes('screen-depth-and-avail-not-emulated')
128
+ );
129
+ });
130
+
131
+ it('mentions the pointer side effect only when touch is actually enabled', () => {
132
+ assert.ok(
133
+ !ids(relevantFingerprintLimitations({ maxTouchPoints: 0 })).includes(
134
+ 'touch-emulation-changes-pointer-media'
135
+ )
136
+ );
137
+ assert.ok(
138
+ ids(relevantFingerprintLimitations({ maxTouchPoints: 5 })).includes(
139
+ 'touch-emulation-changes-pointer-media'
140
+ )
141
+ );
142
+ });
143
+
144
+ it('mentions workers for every field a worker reads differently', () => {
145
+ // Measured in worker-visibility.json: platform, languages and
146
+ // hardwareConcurrency revert to the host values inside a worker even
147
+ // though the page session overrides them.
148
+ for (const profile of [
149
+ { platform: 'Win32' },
150
+ { languages: ['de-DE'] },
151
+ { hardwareConcurrency: 8 },
152
+ { deviceMemory: 8 },
153
+ ]) {
154
+ assert.ok(
155
+ ids(relevantFingerprintLimitations(profile)).includes(
156
+ 'init-script-does-not-reach-workers'
157
+ ),
158
+ JSON.stringify(profile)
159
+ );
160
+ }
161
+ assert.ok(
162
+ !ids(relevantFingerprintLimitations({ timezoneId: 'UTC' })).includes(
163
+ 'init-script-does-not-reach-workers'
164
+ )
165
+ );
166
+ });
167
+
168
+ it('keeps the declaration order and returns a frozen list', () => {
169
+ const relevant = relevantFingerprintLimitations(
170
+ { deviceMemory: 8, webgl: { vendor: 'WebKit' } },
171
+ { headless: true, attached: true }
172
+ );
173
+
174
+ assert.ok(Object.isFrozen(relevant));
175
+ assert.deepEqual(
176
+ ids(relevant),
177
+ ids(
178
+ FINGERPRINT_LIMITATIONS.filter((entry) =>
179
+ ids(relevant).includes(entry.id)
180
+ )
181
+ )
182
+ );
183
+ });
184
+ });
@@ -0,0 +1,135 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert';
3
+
4
+ import {
5
+ createFingerprintPreset,
6
+ FINGERPRINT_PRESET_NAMES,
7
+ } from '../../../src/fingerprint/presets.js';
8
+ import { buildCdpEmulationCommands } from '../../../src/fingerprint/cdp-overrides.js';
9
+
10
+ describe('fingerprint presets', () => {
11
+ it('names the platforms Chrome ships on', () => {
12
+ assert.deepEqual(
13
+ [...FINGERPRINT_PRESET_NAMES],
14
+ ['android-chrome', 'linux-chrome', 'macos-chrome', 'windows-chrome']
15
+ );
16
+ });
17
+
18
+ it('rejects an unknown preset and lists the known ones', () => {
19
+ assert.throws(
20
+ () => createFingerprintPreset('windows-edge'),
21
+ /unknown fingerprint preset "windows-edge".*windows-chrome/su
22
+ );
23
+ });
24
+
25
+ it('rejects a Chrome version that is not a dotted number', () => {
26
+ for (const chromeVersion of ['latest', '', 140, '140.x']) {
27
+ assert.throws(
28
+ () => createFingerprintPreset('linux-chrome', { chromeVersion }),
29
+ /dotted numeric version string/u
30
+ );
31
+ }
32
+ });
33
+
34
+ it('describes one machine consistently', () => {
35
+ // Every serious fingerprinting script cross-checks these against each
36
+ // other, so a preset is only useful when they agree.
37
+ const expectations = {
38
+ 'windows-chrome': {
39
+ platform: 'Win32',
40
+ uaToken: 'Windows NT 10.0',
41
+ hint: 'Windows',
42
+ mobile: false,
43
+ },
44
+ 'macos-chrome': {
45
+ platform: 'MacIntel',
46
+ uaToken: 'Mac OS X',
47
+ hint: 'macOS',
48
+ mobile: false,
49
+ },
50
+ 'linux-chrome': {
51
+ platform: 'Linux x86_64',
52
+ uaToken: 'X11; Linux x86_64',
53
+ hint: 'Linux',
54
+ mobile: false,
55
+ },
56
+ 'android-chrome': {
57
+ platform: 'Linux armv81',
58
+ uaToken: 'Android 15',
59
+ hint: 'Android',
60
+ mobile: true,
61
+ },
62
+ };
63
+
64
+ for (const [name, expected] of Object.entries(expectations)) {
65
+ const profile = createFingerprintPreset(name);
66
+ assert.equal(profile.platform, expected.platform, name);
67
+ assert.ok(profile.userAgent.includes(expected.uaToken), name);
68
+ assert.equal(profile.userAgentData.platform, expected.hint, name);
69
+ assert.equal(profile.userAgentData.mobile, expected.mobile, name);
70
+ assert.equal(profile.viewport.mobile, expected.mobile, name);
71
+ assert.equal(
72
+ profile.userAgent.includes('Mobile Safari'),
73
+ expected.mobile,
74
+ name
75
+ );
76
+ assert.deepEqual(
77
+ profile.userAgentData.formFactors,
78
+ [expected.mobile ? 'Mobile' : 'Desktop'],
79
+ name
80
+ );
81
+ }
82
+ });
83
+
84
+ it('keeps the viewport inside the screen it claims', () => {
85
+ for (const name of FINGERPRINT_PRESET_NAMES) {
86
+ const { screen, viewport } = createFingerprintPreset(name);
87
+ assert.ok(viewport.width <= screen.width, name);
88
+ assert.ok(viewport.height <= screen.availHeight, name);
89
+ assert.ok(screen.availWidth <= screen.width, name);
90
+ assert.ok(screen.availHeight <= screen.height, name);
91
+ }
92
+ });
93
+
94
+ it('gives touch points only to the mobile preset', () => {
95
+ assert.equal(createFingerprintPreset('android-chrome').maxTouchPoints, 5);
96
+ for (const name of ['windows-chrome', 'macos-chrome', 'linux-chrome']) {
97
+ assert.equal(createFingerprintPreset(name).maxTouchPoints, 0, name);
98
+ }
99
+ });
100
+
101
+ it('puts the requested Chrome version everywhere it appears', () => {
102
+ const profile = createFingerprintPreset('windows-chrome', {
103
+ chromeVersion: '141.0.7390.55',
104
+ });
105
+
106
+ // The user agent carries only the major version, as Chrome freezes it.
107
+ assert.ok(profile.userAgent.includes('Chrome/141.0.0.0'));
108
+ for (const entry of profile.userAgentData.brands) {
109
+ assert.ok(['141', '24'].includes(entry.version), entry.brand);
110
+ }
111
+ assert.ok(
112
+ profile.userAgentData.fullVersionList.some(
113
+ (entry) => entry.version === '141.0.7390.55'
114
+ )
115
+ );
116
+ });
117
+
118
+ it('merges caller overrides over the preset', () => {
119
+ const profile = createFingerprintPreset('linux-chrome', {
120
+ overrides: { timezoneId: 'Europe/Lisbon', hardwareConcurrency: 32 },
121
+ });
122
+
123
+ assert.equal(profile.timezoneId, 'Europe/Lisbon');
124
+ assert.equal(profile.hardwareConcurrency, 32);
125
+ assert.equal(profile.platform, 'Linux x86_64');
126
+ });
127
+
128
+ it('returns a normalized, frozen profile every preset can be applied from', () => {
129
+ for (const name of FINGERPRINT_PRESET_NAMES) {
130
+ const profile = createFingerprintPreset(name);
131
+ assert.ok(Object.isFrozen(profile), name);
132
+ assert.ok(buildCdpEmulationCommands(profile).length > 0, name);
133
+ }
134
+ });
135
+ });