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,244 @@
1
+ /**
2
+ * Ready-made fingerprint profiles for the desktop and mobile platforms Chrome
3
+ * ships on.
4
+ *
5
+ * A profile is only useful if it is internally consistent: the user agent
6
+ * string, the User-Agent Client Hints, `navigator.platform`, the WebGL
7
+ * renderer and the screen size all have to describe the same machine, because
8
+ * every serious fingerprinting script cross-checks them. Each preset below is
9
+ * therefore written as one machine rather than as a bag of independent fields.
10
+ *
11
+ * The Chrome version is a parameter instead of a constant. A profile claiming
12
+ * Chrome 131 while the binary is Chrome 149 is trivially detectable from
13
+ * feature sniffing, so the caller should pass the version of the browser they
14
+ * actually launch.
15
+ */
16
+ import { resolveFingerprintProfile } from './profile.js';
17
+
18
+ const DEFAULT_CHROME_VERSION = '140.0.0.0';
19
+
20
+ function majorVersion(version) {
21
+ return version.split('.')[0];
22
+ }
23
+
24
+ function brandsFor(version) {
25
+ const major = majorVersion(version);
26
+ return [
27
+ { brand: 'Google Chrome', version: major },
28
+ { brand: 'Chromium', version: major },
29
+ { brand: 'Not)A;Brand', version: '24' },
30
+ ];
31
+ }
32
+
33
+ function fullVersionListFor(version) {
34
+ return [
35
+ { brand: 'Google Chrome', version },
36
+ { brand: 'Chromium', version },
37
+ { brand: 'Not)A;Brand', version: '24.0.0.0' },
38
+ ];
39
+ }
40
+
41
+ function desktopUserAgent(platformToken, version) {
42
+ return `Mozilla/5.0 (${platformToken}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${majorVersion(version)}.0.0.0 Safari/537.36`;
43
+ }
44
+
45
+ const BUILDERS = {
46
+ 'windows-chrome': (version) => ({
47
+ userAgent: desktopUserAgent('Windows NT 10.0; Win64; x64', version),
48
+ userAgentData: {
49
+ brands: brandsFor(version),
50
+ fullVersionList: fullVersionListFor(version),
51
+ platform: 'Windows',
52
+ platformVersion: '15.0.0',
53
+ architecture: 'x86',
54
+ bitness: '64',
55
+ model: '',
56
+ mobile: false,
57
+ wow64: false,
58
+ formFactors: ['Desktop'],
59
+ },
60
+ platform: 'Win32',
61
+ vendor: 'Google Inc.',
62
+ languages: ['en-US', 'en'],
63
+ locale: 'en-US',
64
+ timezoneId: 'America/New_York',
65
+ hardwareConcurrency: 8,
66
+ deviceMemory: 8,
67
+ maxTouchPoints: 0,
68
+ screen: {
69
+ width: 1920,
70
+ height: 1080,
71
+ availWidth: 1920,
72
+ availHeight: 1032,
73
+ colorDepth: 24,
74
+ pixelDepth: 24,
75
+ },
76
+ viewport: { width: 1920, height: 947, deviceScaleFactor: 1, mobile: false },
77
+ webgl: {
78
+ vendor: 'WebKit',
79
+ renderer: 'WebKit WebGL',
80
+ unmaskedVendor: 'Google Inc. (NVIDIA)',
81
+ unmaskedRenderer:
82
+ 'ANGLE (NVIDIA, NVIDIA GeForce RTX 3060 (0x00002503) Direct3D11 vs_5_0 ps_5_0, D3D11)',
83
+ },
84
+ }),
85
+ 'macos-chrome': (version) => ({
86
+ userAgent: desktopUserAgent('Macintosh; Intel Mac OS X 10_15_7', version),
87
+ userAgentData: {
88
+ brands: brandsFor(version),
89
+ fullVersionList: fullVersionListFor(version),
90
+ platform: 'macOS',
91
+ platformVersion: '15.6.0',
92
+ architecture: 'arm',
93
+ bitness: '64',
94
+ model: '',
95
+ mobile: false,
96
+ wow64: false,
97
+ formFactors: ['Desktop'],
98
+ },
99
+ platform: 'MacIntel',
100
+ vendor: 'Google Inc.',
101
+ languages: ['en-US', 'en'],
102
+ locale: 'en-US',
103
+ timezoneId: 'America/Los_Angeles',
104
+ hardwareConcurrency: 10,
105
+ deviceMemory: 8,
106
+ maxTouchPoints: 0,
107
+ screen: {
108
+ width: 1728,
109
+ height: 1117,
110
+ availWidth: 1728,
111
+ availHeight: 1085,
112
+ colorDepth: 30,
113
+ pixelDepth: 30,
114
+ },
115
+ viewport: {
116
+ width: 1728,
117
+ height: 1005,
118
+ deviceScaleFactor: 2,
119
+ mobile: false,
120
+ },
121
+ webgl: {
122
+ vendor: 'WebKit',
123
+ renderer: 'WebKit WebGL',
124
+ unmaskedVendor: 'Google Inc. (Apple)',
125
+ unmaskedRenderer:
126
+ 'ANGLE (Apple, ANGLE Metal Renderer: Apple M3, Unspecified Version)',
127
+ },
128
+ }),
129
+ 'linux-chrome': (version) => ({
130
+ userAgent: desktopUserAgent('X11; Linux x86_64', version),
131
+ userAgentData: {
132
+ brands: brandsFor(version),
133
+ fullVersionList: fullVersionListFor(version),
134
+ platform: 'Linux',
135
+ platformVersion: '',
136
+ architecture: 'x86',
137
+ bitness: '64',
138
+ model: '',
139
+ mobile: false,
140
+ wow64: false,
141
+ formFactors: ['Desktop'],
142
+ },
143
+ platform: 'Linux x86_64',
144
+ vendor: 'Google Inc.',
145
+ languages: ['en-US', 'en'],
146
+ locale: 'en-US',
147
+ timezoneId: 'UTC',
148
+ hardwareConcurrency: 8,
149
+ deviceMemory: 8,
150
+ maxTouchPoints: 0,
151
+ screen: {
152
+ width: 1920,
153
+ height: 1080,
154
+ availWidth: 1920,
155
+ availHeight: 1053,
156
+ colorDepth: 24,
157
+ pixelDepth: 24,
158
+ },
159
+ viewport: { width: 1920, height: 955, deviceScaleFactor: 1, mobile: false },
160
+ webgl: {
161
+ vendor: 'WebKit',
162
+ renderer: 'WebKit WebGL',
163
+ unmaskedVendor: 'Google Inc. (Intel)',
164
+ unmaskedRenderer:
165
+ 'ANGLE (Intel, Mesa Intel(R) UHD Graphics 630 (CFL GT2), OpenGL 4.6)',
166
+ },
167
+ }),
168
+ 'android-chrome': (version) => ({
169
+ userAgent: `Mozilla/5.0 (Linux; Android 15; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${majorVersion(version)}.0.0.0 Mobile Safari/537.36`,
170
+ userAgentData: {
171
+ brands: brandsFor(version),
172
+ fullVersionList: fullVersionListFor(version),
173
+ platform: 'Android',
174
+ platformVersion: '15.0.0',
175
+ architecture: '',
176
+ bitness: '',
177
+ model: 'Pixel 8',
178
+ mobile: true,
179
+ wow64: false,
180
+ formFactors: ['Mobile'],
181
+ },
182
+ platform: 'Linux armv81',
183
+ vendor: 'Google Inc.',
184
+ languages: ['en-US', 'en'],
185
+ locale: 'en-US',
186
+ timezoneId: 'America/New_York',
187
+ hardwareConcurrency: 8,
188
+ deviceMemory: 8,
189
+ maxTouchPoints: 5,
190
+ screen: {
191
+ width: 412,
192
+ height: 915,
193
+ availWidth: 412,
194
+ availHeight: 915,
195
+ colorDepth: 24,
196
+ pixelDepth: 24,
197
+ },
198
+ viewport: {
199
+ width: 412,
200
+ height: 823,
201
+ deviceScaleFactor: 2.625,
202
+ mobile: true,
203
+ },
204
+ webgl: {
205
+ vendor: 'WebKit',
206
+ renderer: 'WebKit WebGL',
207
+ unmaskedVendor: 'Google Inc. (Qualcomm)',
208
+ unmaskedRenderer: 'ANGLE (Qualcomm, Adreno (TM) 750, OpenGL ES 3.2)',
209
+ },
210
+ }),
211
+ };
212
+
213
+ /** Names accepted by {@link createFingerprintPreset}. */
214
+ export const FINGERPRINT_PRESET_NAMES = Object.freeze(
215
+ Object.keys(BUILDERS).sort()
216
+ );
217
+
218
+ /**
219
+ * Build a complete, internally consistent fingerprint profile.
220
+ *
221
+ * @param {string} name One of {@link FINGERPRINT_PRESET_NAMES}.
222
+ * @param {object} [options] Preset options.
223
+ * @param {string} [options.chromeVersion] Full Chrome version, e.g. `'140.0.7339.80'`.
224
+ * @param {object} [options.overrides] Profile fields merged over the preset.
225
+ * @returns {object} Normalized fingerprint profile.
226
+ */
227
+ export function createFingerprintPreset(name, options = {}) {
228
+ const builder = BUILDERS[name];
229
+ if (!builder) {
230
+ throw new TypeError(
231
+ `unknown fingerprint preset "${name}"; known presets: ${FINGERPRINT_PRESET_NAMES.join(', ')}`
232
+ );
233
+ }
234
+ const { chromeVersion = DEFAULT_CHROME_VERSION, overrides = {} } = options;
235
+ if (
236
+ typeof chromeVersion !== 'string' ||
237
+ !/^\d+(\.\d+)*$/u.test(chromeVersion)
238
+ ) {
239
+ throw new TypeError(
240
+ 'chromeVersion must be a dotted numeric version string'
241
+ );
242
+ }
243
+ return resolveFingerprintProfile({ ...builder(chromeVersion), ...overrides });
244
+ }
@@ -0,0 +1,428 @@
1
+ /**
2
+ * A fingerprint profile is the complete description of the environment a page
3
+ * is allowed to see: who the browser claims to be, where it claims to run, and
4
+ * what hardware it claims to have.
5
+ *
6
+ * Every field here is applied through a documented mechanism - a Chrome
7
+ * switch, a CDP `Emulation` command, or a page init script - and the mechanism
8
+ * is recorded in `FINGERPRINT_FIELD_MECHANISMS` so callers can tell an
9
+ * override the browser enforces from an override that is only a JavaScript
10
+ * patch. See `limitations.js` for the surfaces that have no mechanism at all.
11
+ */
12
+
13
+ import { deriveUserAgentData } from './derive.js';
14
+
15
+ const BRAND_KEYS = ['brand', 'version'];
16
+
17
+ function fail(message) {
18
+ throw new TypeError(message);
19
+ }
20
+
21
+ function optionalString(value, name) {
22
+ if (value === undefined || value === null) {
23
+ return undefined;
24
+ }
25
+ if (typeof value !== 'string') {
26
+ fail(`${name} must be a string`);
27
+ }
28
+ return value;
29
+ }
30
+
31
+ function optionalBoolean(value, name) {
32
+ if (value === undefined || value === null) {
33
+ return undefined;
34
+ }
35
+ if (typeof value !== 'boolean') {
36
+ fail(`${name} must be a boolean`);
37
+ }
38
+ return value;
39
+ }
40
+
41
+ function optionalPositiveInteger(value, name) {
42
+ if (value === undefined || value === null) {
43
+ return undefined;
44
+ }
45
+ if (!Number.isInteger(value) || value <= 0) {
46
+ fail(`${name} must be a positive integer`);
47
+ }
48
+ return value;
49
+ }
50
+
51
+ function optionalNonNegativeInteger(value, name) {
52
+ if (value === undefined || value === null) {
53
+ return undefined;
54
+ }
55
+ if (!Number.isInteger(value) || value < 0) {
56
+ fail(`${name} must be a non-negative integer`);
57
+ }
58
+ return value;
59
+ }
60
+
61
+ function optionalPositiveNumber(value, name) {
62
+ if (value === undefined || value === null) {
63
+ return undefined;
64
+ }
65
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
66
+ fail(`${name} must be a positive number`);
67
+ }
68
+ return value;
69
+ }
70
+
71
+ function optionalStringArray(value, name) {
72
+ if (value === undefined || value === null) {
73
+ return undefined;
74
+ }
75
+ if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) {
76
+ fail(`${name} must be an array of strings`);
77
+ }
78
+ if (value.length === 0) {
79
+ fail(`${name} must not be empty`);
80
+ }
81
+ return [...value];
82
+ }
83
+
84
+ function optionalBrands(value, name) {
85
+ if (value === undefined || value === null) {
86
+ return undefined;
87
+ }
88
+ if (!Array.isArray(value)) {
89
+ fail(`${name} must be an array of { brand, version } entries`);
90
+ }
91
+ return value.map((entry, index) => {
92
+ if (!entry || typeof entry !== 'object') {
93
+ fail(`${name}[${index}] must be an object`);
94
+ }
95
+ for (const key of BRAND_KEYS) {
96
+ if (typeof entry[key] !== 'string') {
97
+ fail(`${name}[${index}].${key} must be a string`);
98
+ }
99
+ }
100
+ return { brand: entry.brand, version: entry.version };
101
+ });
102
+ }
103
+
104
+ function optionalEnum(value, name, allowed) {
105
+ if (value === undefined || value === null) {
106
+ return undefined;
107
+ }
108
+ if (!allowed.includes(value)) {
109
+ fail(`${name} must be one of ${allowed.join(', ')}`);
110
+ }
111
+ return value;
112
+ }
113
+
114
+ function compact(object) {
115
+ const result = {};
116
+ for (const [key, value] of Object.entries(object)) {
117
+ if (value !== undefined) {
118
+ result[key] = value;
119
+ }
120
+ }
121
+ return result;
122
+ }
123
+
124
+ function resolveScreen(screen) {
125
+ if (screen === undefined || screen === null) {
126
+ return undefined;
127
+ }
128
+ if (typeof screen !== 'object') {
129
+ fail('screen must be an object');
130
+ }
131
+ const resolved = compact({
132
+ width: optionalPositiveInteger(screen.width, 'screen.width'),
133
+ height: optionalPositiveInteger(screen.height, 'screen.height'),
134
+ availWidth: optionalPositiveInteger(screen.availWidth, 'screen.availWidth'),
135
+ availHeight: optionalPositiveInteger(
136
+ screen.availHeight,
137
+ 'screen.availHeight'
138
+ ),
139
+ colorDepth: optionalPositiveInteger(screen.colorDepth, 'screen.colorDepth'),
140
+ pixelDepth: optionalPositiveInteger(screen.pixelDepth, 'screen.pixelDepth'),
141
+ });
142
+ if ((resolved.width === undefined) !== (resolved.height === undefined)) {
143
+ fail('screen.width and screen.height must be provided together');
144
+ }
145
+ return Object.keys(resolved).length > 0 ? resolved : undefined;
146
+ }
147
+
148
+ function resolveViewport(viewport) {
149
+ if (viewport === undefined || viewport === null) {
150
+ return undefined;
151
+ }
152
+ if (typeof viewport !== 'object') {
153
+ fail('viewport must be an object');
154
+ }
155
+ const resolved = compact({
156
+ width: optionalPositiveInteger(viewport.width, 'viewport.width'),
157
+ height: optionalPositiveInteger(viewport.height, 'viewport.height'),
158
+ deviceScaleFactor: optionalPositiveNumber(
159
+ viewport.deviceScaleFactor,
160
+ 'viewport.deviceScaleFactor'
161
+ ),
162
+ mobile: optionalBoolean(viewport.mobile, 'viewport.mobile'),
163
+ });
164
+ if ((resolved.width === undefined) !== (resolved.height === undefined)) {
165
+ fail('viewport.width and viewport.height must be provided together');
166
+ }
167
+ return Object.keys(resolved).length > 0 ? resolved : undefined;
168
+ }
169
+
170
+ function resolveGeolocation(geolocation) {
171
+ if (geolocation === undefined || geolocation === null) {
172
+ return undefined;
173
+ }
174
+ if (typeof geolocation !== 'object') {
175
+ fail('geolocation must be an object');
176
+ }
177
+ const { latitude, longitude, accuracy } = geolocation;
178
+ for (const [value, name] of [
179
+ [latitude, 'geolocation.latitude'],
180
+ [longitude, 'geolocation.longitude'],
181
+ ]) {
182
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
183
+ fail(`${name} must be a finite number`);
184
+ }
185
+ }
186
+ if (latitude < -90 || latitude > 90) {
187
+ fail('geolocation.latitude must be between -90 and 90');
188
+ }
189
+ if (longitude < -180 || longitude > 180) {
190
+ fail('geolocation.longitude must be between -180 and 180');
191
+ }
192
+ return compact({
193
+ latitude,
194
+ longitude,
195
+ accuracy: optionalPositiveNumber(accuracy, 'geolocation.accuracy'),
196
+ });
197
+ }
198
+
199
+ function resolveWebgl(webgl) {
200
+ if (webgl === undefined || webgl === null) {
201
+ return undefined;
202
+ }
203
+ if (typeof webgl !== 'object') {
204
+ fail('webgl must be an object');
205
+ }
206
+ const resolved = compact({
207
+ vendor: optionalString(webgl.vendor, 'webgl.vendor'),
208
+ renderer: optionalString(webgl.renderer, 'webgl.renderer'),
209
+ unmaskedVendor: optionalString(
210
+ webgl.unmaskedVendor,
211
+ 'webgl.unmaskedVendor'
212
+ ),
213
+ unmaskedRenderer: optionalString(
214
+ webgl.unmaskedRenderer,
215
+ 'webgl.unmaskedRenderer'
216
+ ),
217
+ });
218
+ return Object.keys(resolved).length > 0 ? resolved : undefined;
219
+ }
220
+
221
+ /**
222
+ * Chrome derives both the Accept-Language header and `navigator.languages`
223
+ * from this one string, and it splits on commas without stripping q-values.
224
+ * Passing `de-DE,de;q=0.9` therefore yields the language tag `"de;q=0.9"` and
225
+ * the header `de-DE,de;q=0.9;q=0.9`; passing the plain list `de-DE,de,en`
226
+ * yields correct tags and the header `de-DE,de;q=0.9,en;q=0.8` that a real
227
+ * browser sends. Measured in
228
+ * `docs/case-studies/issue-79/analysis-artifacts/ua-hints-detail.json`.
229
+ */
230
+ function resolveAcceptLanguage(acceptLanguage) {
231
+ if (acceptLanguage === undefined) {
232
+ return undefined;
233
+ }
234
+ if (acceptLanguage.includes(';')) {
235
+ fail(
236
+ 'acceptLanguage must be a plain comma-separated language list without ' +
237
+ 'q-values; Chrome generates the quality values itself'
238
+ );
239
+ }
240
+ return acceptLanguage;
241
+ }
242
+
243
+ /** Keep `uaFullVersion` consistent with `fullVersionList`. */
244
+ function withFullVersion(userAgentData) {
245
+ if (!userAgentData || userAgentData.fullVersion !== undefined) {
246
+ return userAgentData;
247
+ }
248
+ const primary = userAgentData.fullVersionList?.find(
249
+ (entry) => entry.brand === 'Google Chrome' || entry.brand === 'Chromium'
250
+ );
251
+ return primary
252
+ ? { ...userAgentData, fullVersion: primary.version }
253
+ : userAgentData;
254
+ }
255
+
256
+ function resolveUserAgentData(userAgentData) {
257
+ if (userAgentData === undefined || userAgentData === null) {
258
+ return undefined;
259
+ }
260
+ if (typeof userAgentData !== 'object') {
261
+ fail('userAgentData must be an object');
262
+ }
263
+ const resolved = compact({
264
+ brands: optionalBrands(userAgentData.brands, 'userAgentData.brands'),
265
+ fullVersionList: optionalBrands(
266
+ userAgentData.fullVersionList,
267
+ 'userAgentData.fullVersionList'
268
+ ),
269
+ platform: optionalString(userAgentData.platform, 'userAgentData.platform'),
270
+ platformVersion: optionalString(
271
+ userAgentData.platformVersion,
272
+ 'userAgentData.platformVersion'
273
+ ),
274
+ architecture: optionalString(
275
+ userAgentData.architecture,
276
+ 'userAgentData.architecture'
277
+ ),
278
+ bitness: optionalString(userAgentData.bitness, 'userAgentData.bitness'),
279
+ // Deprecated in the protocol but still the only way to control the
280
+ // `uaFullVersion` high-entropy hint: with `fullVersionList` alone the page
281
+ // still reads the real Chrome build number.
282
+ fullVersion: optionalString(
283
+ userAgentData.fullVersion,
284
+ 'userAgentData.fullVersion'
285
+ ),
286
+ model: optionalString(userAgentData.model, 'userAgentData.model'),
287
+ mobile: optionalBoolean(userAgentData.mobile, 'userAgentData.mobile'),
288
+ wow64: optionalBoolean(userAgentData.wow64, 'userAgentData.wow64'),
289
+ formFactors: optionalStringArray(
290
+ userAgentData.formFactors,
291
+ 'userAgentData.formFactors'
292
+ ),
293
+ });
294
+ return Object.keys(resolved).length > 0 ? resolved : undefined;
295
+ }
296
+
297
+ /**
298
+ * Normalize and validate a fingerprint profile.
299
+ *
300
+ * Unknown keys are rejected rather than ignored: a typo in `hardwareConcurency`
301
+ * would otherwise silently leave the real core count exposed, which is exactly
302
+ * the failure this module exists to prevent.
303
+ *
304
+ * @param {object} [input] Raw profile.
305
+ * @returns {object} Frozen, normalized profile.
306
+ */
307
+ export function resolveFingerprintProfile(input = {}) {
308
+ if (input === null || typeof input !== 'object' || Array.isArray(input)) {
309
+ fail('fingerprint profile must be an object');
310
+ }
311
+
312
+ const known = new Set([
313
+ 'userAgent',
314
+ 'userAgentData',
315
+ 'acceptLanguage',
316
+ 'languages',
317
+ 'locale',
318
+ 'timezoneId',
319
+ 'platform',
320
+ 'vendor',
321
+ 'hardwareConcurrency',
322
+ 'deviceMemory',
323
+ 'maxTouchPoints',
324
+ 'doNotTrack',
325
+ 'screen',
326
+ 'viewport',
327
+ 'webgl',
328
+ 'geolocation',
329
+ 'colorScheme',
330
+ 'reducedMotion',
331
+ 'forcedColors',
332
+ ]);
333
+ for (const key of Object.keys(input)) {
334
+ if (!known.has(key)) {
335
+ fail(
336
+ `unknown fingerprint profile field "${key}"; known fields: ${[...known].sort().join(', ')}`
337
+ );
338
+ }
339
+ }
340
+
341
+ const languages = optionalStringArray(input.languages, 'languages');
342
+ const acceptLanguage = resolveAcceptLanguage(
343
+ optionalString(input.acceptLanguage, 'acceptLanguage') ??
344
+ (languages ? languages.join(',') : undefined)
345
+ );
346
+ const userAgent = optionalString(input.userAgent, 'userAgent');
347
+ const userAgentData =
348
+ resolveUserAgentData(input.userAgentData) ??
349
+ (userAgent === undefined ? undefined : deriveUserAgentData(userAgent));
350
+
351
+ return Object.freeze(
352
+ compact({
353
+ userAgent,
354
+ userAgentData: withFullVersion(userAgentData),
355
+ acceptLanguage,
356
+ languages,
357
+ locale: optionalString(input.locale, 'locale'),
358
+ timezoneId: optionalString(input.timezoneId, 'timezoneId'),
359
+ platform: optionalString(input.platform, 'platform'),
360
+ vendor: optionalString(input.vendor, 'vendor'),
361
+ hardwareConcurrency: optionalPositiveInteger(
362
+ input.hardwareConcurrency,
363
+ 'hardwareConcurrency'
364
+ ),
365
+ deviceMemory: optionalPositiveNumber(input.deviceMemory, 'deviceMemory'),
366
+ maxTouchPoints: optionalNonNegativeInteger(
367
+ input.maxTouchPoints,
368
+ 'maxTouchPoints'
369
+ ),
370
+ doNotTrack: optionalString(input.doNotTrack, 'doNotTrack'),
371
+ screen: resolveScreen(input.screen),
372
+ viewport: resolveViewport(input.viewport),
373
+ webgl: resolveWebgl(input.webgl),
374
+ geolocation: resolveGeolocation(input.geolocation),
375
+ colorScheme: optionalEnum(input.colorScheme, 'colorScheme', [
376
+ 'light',
377
+ 'dark',
378
+ 'no-preference',
379
+ ]),
380
+ reducedMotion: optionalEnum(input.reducedMotion, 'reducedMotion', [
381
+ 'reduce',
382
+ 'no-preference',
383
+ ]),
384
+ forcedColors: optionalEnum(input.forcedColors, 'forcedColors', [
385
+ 'active',
386
+ 'none',
387
+ ]),
388
+ })
389
+ );
390
+ }
391
+
392
+ /**
393
+ * How each profile field reaches the page.
394
+ *
395
+ * `browser` means Chrome itself produces the value, so it holds for the
396
+ * document, for HTTP headers and for any code that reads it, and a page cannot
397
+ * detect the override by comparing two ways of asking. `script` means the value
398
+ * is a JavaScript property patch installed before page scripts run, which is
399
+ * weaker: it holds for the main world but is not what the network stack or a
400
+ * fresh renderer would say.
401
+ *
402
+ * Neither kind reaches a worker in full. Measured in a dedicated worker,
403
+ * `userAgent`, `timezoneId` and `locale` follow the profile while `platform`,
404
+ * `languages` and `hardwareConcurrency` revert to the host values; see the
405
+ * `init-script-does-not-reach-workers` entry in `limitations.js` and
406
+ * `docs/case-studies/issue-79/analysis-artifacts/worker-visibility.json`.
407
+ */
408
+ export const FINGERPRINT_FIELD_MECHANISMS = Object.freeze({
409
+ userAgent: 'browser',
410
+ userAgentData: 'browser',
411
+ acceptLanguage: 'browser',
412
+ languages: 'browser',
413
+ locale: 'browser',
414
+ timezoneId: 'browser',
415
+ hardwareConcurrency: 'browser',
416
+ screen: 'browser',
417
+ viewport: 'browser',
418
+ maxTouchPoints: 'browser',
419
+ geolocation: 'browser',
420
+ colorScheme: 'browser',
421
+ reducedMotion: 'browser',
422
+ forcedColors: 'browser',
423
+ platform: 'browser',
424
+ vendor: 'script',
425
+ deviceMemory: 'script',
426
+ doNotTrack: 'script',
427
+ webgl: 'script',
428
+ });
@@ -223,7 +223,8 @@ export function loadTimingData(options = {}) {
223
223
  return createEmptyTimingData();
224
224
  }
225
225
  throw new Error(
226
- `Could not read browser test timing data: ${error.message}`
226
+ `Could not read browser test timing data: ${error.message}`,
227
+ { cause: error }
227
228
  );
228
229
  }
229
230
  }