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,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
+ });
@@ -0,0 +1,282 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert';
3
+
4
+ import {
5
+ FINGERPRINT_FIELD_MECHANISMS,
6
+ resolveFingerprintProfile,
7
+ } from '../../../src/fingerprint/profile.js';
8
+
9
+ const CHROME_UA =
10
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' +
11
+ '(KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36';
12
+
13
+ describe('fingerprint profile', () => {
14
+ it('returns an empty frozen profile for an empty input', () => {
15
+ const profile = resolveFingerprintProfile();
16
+
17
+ assert.deepEqual(profile, {});
18
+ assert.ok(Object.isFrozen(profile));
19
+ });
20
+
21
+ it('drops fields that were not supplied instead of filling in defaults', () => {
22
+ const profile = resolveFingerprintProfile({ locale: 'de-DE' });
23
+
24
+ assert.deepEqual(Object.keys(profile), ['locale']);
25
+ });
26
+
27
+ it('rejects an unknown field rather than silently ignoring it', () => {
28
+ assert.throws(
29
+ () => resolveFingerprintProfile({ hardwareConcurency: 8 }),
30
+ /unknown fingerprint profile field "hardwareConcurency"/u
31
+ );
32
+ });
33
+
34
+ it('rejects a profile that is not a plain object', () => {
35
+ for (const input of [null, [], 'profile', 42]) {
36
+ assert.throws(
37
+ () => resolveFingerprintProfile(input),
38
+ /fingerprint profile must be an object/u
39
+ );
40
+ }
41
+ });
42
+
43
+ it('derives acceptLanguage from languages when only languages is given', () => {
44
+ const profile = resolveFingerprintProfile({
45
+ languages: ['de-DE', 'de', 'en'],
46
+ });
47
+
48
+ assert.equal(profile.acceptLanguage, 'de-DE,de,en');
49
+ assert.deepEqual(profile.languages, ['de-DE', 'de', 'en']);
50
+ });
51
+
52
+ it('keeps an explicit acceptLanguage over the derived one', () => {
53
+ const profile = resolveFingerprintProfile({
54
+ languages: ['de-DE', 'de'],
55
+ acceptLanguage: 'fr-FR,fr',
56
+ });
57
+
58
+ assert.equal(profile.acceptLanguage, 'fr-FR,fr');
59
+ });
60
+
61
+ // Chrome splits acceptLanguage on commas without stripping q-values, so a
62
+ // q-value ends up inside a language tag and doubled in the header.
63
+ it('rejects q-values in acceptLanguage, which Chrome would misparse', () => {
64
+ assert.throws(
65
+ () => resolveFingerprintProfile({ acceptLanguage: 'de-DE,de;q=0.9' }),
66
+ /must be a plain comma-separated language list without q-values/u
67
+ );
68
+ });
69
+
70
+ it('derives client hints from a Chrome user agent', () => {
71
+ const profile = resolveFingerprintProfile({ userAgent: CHROME_UA });
72
+
73
+ assert.equal(profile.userAgentData.platform, 'Windows');
74
+ assert.equal(profile.userAgentData.architecture, 'x86');
75
+ assert.equal(profile.userAgentData.bitness, '64');
76
+ assert.equal(profile.userAgentData.mobile, false);
77
+ assert.ok(
78
+ profile.userAgentData.brands.some(
79
+ (entry) => entry.brand === 'Google Chrome' && entry.version === '140'
80
+ )
81
+ );
82
+ });
83
+
84
+ it('fills uaFullVersion from the Chrome entry of fullVersionList', () => {
85
+ const profile = resolveFingerprintProfile({
86
+ userAgentData: {
87
+ fullVersionList: [
88
+ { brand: 'Not=A?Brand', version: '24.0.0.0' },
89
+ { brand: 'Google Chrome', version: '140.0.7000.1' },
90
+ ],
91
+ },
92
+ });
93
+
94
+ assert.equal(profile.userAgentData.fullVersion, '140.0.7000.1');
95
+ });
96
+
97
+ it('keeps an explicit fullVersion instead of deriving one', () => {
98
+ const profile = resolveFingerprintProfile({
99
+ userAgentData: {
100
+ fullVersion: '99.1.2.3',
101
+ fullVersionList: [{ brand: 'Google Chrome', version: '140.0.0.0' }],
102
+ },
103
+ });
104
+
105
+ assert.equal(profile.userAgentData.fullVersion, '99.1.2.3');
106
+ });
107
+
108
+ it('lets an explicit userAgentData win over the one derived from the user agent', () => {
109
+ const profile = resolveFingerprintProfile({
110
+ userAgent: CHROME_UA,
111
+ userAgentData: { platform: 'macOS' },
112
+ });
113
+
114
+ assert.equal(profile.userAgentData.platform, 'macOS');
115
+ });
116
+
117
+ it('accepts every configurable field at once', () => {
118
+ const profile = resolveFingerprintProfile({
119
+ userAgent: CHROME_UA,
120
+ languages: ['de-DE', 'de'],
121
+ locale: 'de-DE',
122
+ timezoneId: 'Europe/Berlin',
123
+ platform: 'Win32',
124
+ vendor: 'Google Inc.',
125
+ hardwareConcurrency: 24,
126
+ deviceMemory: 32,
127
+ maxTouchPoints: 5,
128
+ doNotTrack: '1',
129
+ screen: {
130
+ width: 3840,
131
+ height: 2160,
132
+ availWidth: 3840,
133
+ availHeight: 2100,
134
+ colorDepth: 30,
135
+ pixelDepth: 30,
136
+ },
137
+ viewport: {
138
+ width: 1600,
139
+ height: 900,
140
+ deviceScaleFactor: 2,
141
+ mobile: false,
142
+ },
143
+ webgl: { unmaskedVendor: 'NVIDIA', unmaskedRenderer: 'RTX 4090' },
144
+ geolocation: { latitude: 52.52, longitude: 13.405, accuracy: 12 },
145
+ colorScheme: 'dark',
146
+ reducedMotion: 'reduce',
147
+ forcedColors: 'active',
148
+ });
149
+
150
+ assert.equal(profile.hardwareConcurrency, 24);
151
+ assert.equal(profile.deviceMemory, 32);
152
+ assert.equal(profile.screen.colorDepth, 30);
153
+ assert.equal(profile.viewport.deviceScaleFactor, 2);
154
+ assert.equal(profile.webgl.unmaskedRenderer, 'RTX 4090');
155
+ assert.equal(profile.geolocation.accuracy, 12);
156
+ assert.equal(profile.forcedColors, 'active');
157
+ });
158
+
159
+ it('rejects a hardwareConcurrency that is not a positive integer', () => {
160
+ for (const value of [0, -4, 2.5, '8']) {
161
+ assert.throws(
162
+ () => resolveFingerprintProfile({ hardwareConcurrency: value }),
163
+ /hardwareConcurrency must be a positive integer/u
164
+ );
165
+ }
166
+ });
167
+
168
+ it('allows maxTouchPoints to be zero but not negative', () => {
169
+ assert.equal(
170
+ resolveFingerprintProfile({ maxTouchPoints: 0 }).maxTouchPoints,
171
+ 0
172
+ );
173
+ assert.throws(
174
+ () => resolveFingerprintProfile({ maxTouchPoints: -1 }),
175
+ /maxTouchPoints must be a non-negative integer/u
176
+ );
177
+ });
178
+
179
+ it('requires screen width and height to be given together', () => {
180
+ assert.throws(
181
+ () => resolveFingerprintProfile({ screen: { width: 1920 } }),
182
+ /screen.width and screen.height must be provided together/u
183
+ );
184
+ });
185
+
186
+ it('requires viewport width and height to be given together', () => {
187
+ assert.throws(
188
+ () => resolveFingerprintProfile({ viewport: { height: 900 } }),
189
+ /viewport.width and viewport.height must be provided together/u
190
+ );
191
+ });
192
+
193
+ it('rejects out-of-range coordinates', () => {
194
+ assert.throws(
195
+ () =>
196
+ resolveFingerprintProfile({
197
+ geolocation: { latitude: 91, longitude: 0 },
198
+ }),
199
+ /latitude must be between -90 and 90/u
200
+ );
201
+ assert.throws(
202
+ () =>
203
+ resolveFingerprintProfile({
204
+ geolocation: { latitude: 0, longitude: -181 },
205
+ }),
206
+ /longitude must be between -180 and 180/u
207
+ );
208
+ });
209
+
210
+ it('rejects an unsupported enum value', () => {
211
+ assert.throws(
212
+ () => resolveFingerprintProfile({ colorScheme: 'sepia' }),
213
+ /colorScheme must be one of light, dark, no-preference/u
214
+ );
215
+ });
216
+
217
+ it('rejects an empty languages array', () => {
218
+ assert.throws(
219
+ () => resolveFingerprintProfile({ languages: [] }),
220
+ /languages must not be empty/u
221
+ );
222
+ });
223
+
224
+ it('rejects a brand entry with a non-string version', () => {
225
+ assert.throws(
226
+ () =>
227
+ resolveFingerprintProfile({
228
+ userAgentData: { brands: [{ brand: 'Chromium', version: 140 }] },
229
+ }),
230
+ /userAgentData.brands\[0\].version must be a string/u
231
+ );
232
+ });
233
+
234
+ it('treats null the same as an omitted field', () => {
235
+ const profile = resolveFingerprintProfile({
236
+ locale: null,
237
+ screen: null,
238
+ webgl: null,
239
+ });
240
+
241
+ assert.deepEqual(profile, {});
242
+ });
243
+
244
+ it('copies arrays so a later mutation of the input cannot reach the profile', () => {
245
+ const languages = ['de-DE', 'de'];
246
+ const profile = resolveFingerprintProfile({ languages });
247
+ languages.push('en');
248
+
249
+ assert.deepEqual(profile.languages, ['de-DE', 'de']);
250
+ });
251
+
252
+ it('records a mechanism for every field the profile accepts', () => {
253
+ const everyField = resolveFingerprintProfile({
254
+ userAgent: CHROME_UA,
255
+ acceptLanguage: 'en-US,en',
256
+ languages: ['en-US', 'en'],
257
+ locale: 'en-US',
258
+ timezoneId: 'UTC',
259
+ platform: 'Win32',
260
+ vendor: 'Google Inc.',
261
+ hardwareConcurrency: 8,
262
+ deviceMemory: 8,
263
+ maxTouchPoints: 0,
264
+ doNotTrack: '1',
265
+ screen: { width: 1920, height: 1080 },
266
+ viewport: { width: 1280, height: 720 },
267
+ webgl: { unmaskedVendor: 'Intel' },
268
+ geolocation: { latitude: 0, longitude: 0 },
269
+ colorScheme: 'dark',
270
+ reducedMotion: 'reduce',
271
+ forcedColors: 'active',
272
+ });
273
+
274
+ for (const field of Object.keys(everyField)) {
275
+ assert.ok(
276
+ FINGERPRINT_FIELD_MECHANISMS[field] === 'browser' ||
277
+ FINGERPRINT_FIELD_MECHANISMS[field] === 'script',
278
+ `no mechanism recorded for "${field}"`
279
+ );
280
+ }
281
+ });
282
+ });