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.
- package/.changeset/config.json +1 -1
- package/.changeset/merged-loud-river.md +7 -0
- package/.jscpd-baseline.json +257 -0
- package/.jscpd.json +1 -3
- package/.prettierignore +1 -0
- package/CHANGELOG.md +22 -0
- package/eslint.config.js +23 -1
- package/package.json +13 -24
- package/scripts/changeset-version.mjs +2 -6
- package/scripts/create-github-release.mjs +7 -8
- package/scripts/create-manual-changeset.mjs +7 -8
- package/scripts/format-github-release.mjs +6 -7
- package/scripts/format-release-notes.mjs +7 -8
- package/scripts/instant-version-bump.mjs +7 -8
- package/scripts/publish-to-npm.mjs +7 -8
- package/scripts/setup-npm.mjs +2 -6
- package/scripts/version-and-commit.mjs +7 -8
- package/src/browser/browser-cookie-credentials.js +3 -1
- package/src/browser/browser-cookies.js +6 -2
- package/src/browser/browser-profiles.js +1 -1
- package/src/browser/launch-options.js +31 -8
- package/src/browser/launcher.js +16 -0
- package/src/exports.js +34 -0
- package/src/fingerprint/apply.js +116 -0
- package/src/fingerprint/automation-parity.js +224 -0
- package/src/fingerprint/cdp-overrides.js +176 -0
- package/src/fingerprint/derive.js +104 -0
- package/src/fingerprint/init-payload.js +181 -0
- package/src/fingerprint/init-script.js +104 -0
- package/src/fingerprint/limitations.js +107 -0
- package/src/fingerprint/limitations.json +96 -0
- package/src/fingerprint/presets.js +244 -0
- package/src/fingerprint/profile.js +428 -0
- package/src/tests/index.js +2 -1
- package/tests/e2e/fingerprint-parity.e2e.test.js +212 -0
- package/tests/helpers/mocks.js +4 -2
- package/tests/helpers/repo.js +74 -0
- package/tests/unit/browser/launch-options.test.js +9 -1
- package/tests/unit/experiments/fingerprint-parity-harness.test.js +97 -0
- package/tests/unit/fingerprint/apply.test.js +271 -0
- package/tests/unit/fingerprint/automation-parity.test.js +212 -0
- package/tests/unit/fingerprint/cdp-overrides.test.js +227 -0
- package/tests/unit/fingerprint/derive.test.js +105 -0
- package/tests/unit/fingerprint/init-script.test.js +357 -0
- package/tests/unit/fingerprint/limitations.test.js +184 -0
- package/tests/unit/fingerprint/presets.test.js +135 -0
- package/tests/unit/fingerprint/profile.test.js +282 -0
- package/tests/unit/scripts/check-pipeline-status.test.js +133 -0
- package/tests/unit/scripts/check-version-modification.test.js +70 -0
- package/tests/unit/scripts/check-web-archive.test.js +191 -0
- package/tests/unit/scripts/ci-timeout-budgets.test.js +311 -0
- package/tests/unit/scripts/ci-workflow-policy.test.js +217 -28
- package/tests/unit/scripts/debug-print.test.js +67 -0
- package/tests/unit/scripts/jscpd-config.test.js +141 -0
- package/tests/unit/scripts/pre-commit-config.test.js +209 -0
- package/tests/unit/scripts/read-manifest.test.js +194 -0
- package/tests/unit/scripts/run-with-budget-warning.test.js +115 -0
- package/tests/unit/scripts/use-module-adoption.test.js +74 -0
- package/tests/unit/scripts/use-module-integration.test.js +64 -0
- package/tests/unit/scripts/use-module.test.js +182 -0
- package/.husky/pre-commit +0 -1
- package/.prettierrc +0 -10
|
@@ -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
|
+
});
|
package/src/tests/index.js
CHANGED
|
@@ -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
|
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E2E parity tests: a page must not be able to tell that Browser Commander is
|
|
3
|
+
* driving the browser.
|
|
4
|
+
*
|
|
5
|
+
* The reference capture never speaks CDP. Chrome is started as a plain child
|
|
6
|
+
* process pointed at a local page; the page runs the environment probe and
|
|
7
|
+
* POSTs the report back over HTTP. Every automated capture is delivered the
|
|
8
|
+
* same way, so a difference in the diff is a difference in the browser rather
|
|
9
|
+
* than a difference in how the probe was invoked.
|
|
10
|
+
*
|
|
11
|
+
* Parity is asserted twice, because "the same as a real browser" means a
|
|
12
|
+
* different thing in each mode: a headful capture is compared against a
|
|
13
|
+
* hand-started headful Chrome, and a headless capture against a hand-started
|
|
14
|
+
* headless Chrome. The two references genuinely differ -- see the
|
|
15
|
+
* `headless-is-distinguishable` entry in `src/fingerprint/limitations.js` --
|
|
16
|
+
* so neither comparison can stand in for the other.
|
|
17
|
+
*
|
|
18
|
+
* Prerequisites:
|
|
19
|
+
* RUN_E2E=true, a Chrome binary, and, for the headful half, a display:
|
|
20
|
+
*
|
|
21
|
+
* RUN_E2E=true xvfb-run -a --server-args="-screen 0 1920x1080x24" \
|
|
22
|
+
* npm run test:e2e:parity
|
|
23
|
+
*/
|
|
24
|
+
import { describe, it, before, after } from 'node:test';
|
|
25
|
+
import assert from 'node:assert';
|
|
26
|
+
import { existsSync } from 'node:fs';
|
|
27
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
28
|
+
import { tmpdir } from 'node:os';
|
|
29
|
+
import path from 'node:path';
|
|
30
|
+
import { randomUUID } from 'node:crypto';
|
|
31
|
+
|
|
32
|
+
import {
|
|
33
|
+
captureReferenceReport,
|
|
34
|
+
diffReports,
|
|
35
|
+
readProbeSource,
|
|
36
|
+
startProbeServer,
|
|
37
|
+
} from '../../../experiments/fingerprint-parity/harness.mjs';
|
|
38
|
+
import { launchBrowser } from '../../src/index.js';
|
|
39
|
+
import { createFingerprintPreset } from '../../src/fingerprint/presets.js';
|
|
40
|
+
|
|
41
|
+
const CHROME = process.env.CHROME_PATH || '/usr/bin/google-chrome';
|
|
42
|
+
const TEST_TIMEOUT = 180000;
|
|
43
|
+
|
|
44
|
+
/** Why the suite cannot run here, or `false` when it can. */
|
|
45
|
+
function skipReason({ headless }) {
|
|
46
|
+
if (!process.env.RUN_E2E) {
|
|
47
|
+
return 'set RUN_E2E=true to run the parity tests';
|
|
48
|
+
}
|
|
49
|
+
if (!existsSync(CHROME)) {
|
|
50
|
+
return `no Chrome binary at ${CHROME}; set CHROME_PATH`;
|
|
51
|
+
}
|
|
52
|
+
if (!headless && process.platform === 'linux' && !process.env.DISPLAY) {
|
|
53
|
+
return 'headful parity needs a display; run under xvfb-run';
|
|
54
|
+
}
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function defineParitySuite({ headless }) {
|
|
59
|
+
const mode = headless ? 'headless' : 'headful';
|
|
60
|
+
|
|
61
|
+
describe(
|
|
62
|
+
`E2E Tests - Fingerprint Parity (${mode})`,
|
|
63
|
+
{ skip: skipReason({ headless }) },
|
|
64
|
+
() => {
|
|
65
|
+
let server;
|
|
66
|
+
let reference;
|
|
67
|
+
const userDataDirs = [];
|
|
68
|
+
|
|
69
|
+
const nextUserDataDir = async () => {
|
|
70
|
+
const directory = await mkdtemp(path.join(tmpdir(), 'bc-parity-e2e-'));
|
|
71
|
+
userDataDirs.push(directory);
|
|
72
|
+
return directory;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** Drive one capture through the shipped launcher. */
|
|
76
|
+
const captureWithLibrary = async (options) => {
|
|
77
|
+
const token = randomUUID();
|
|
78
|
+
const { browser, page } = await launchBrowser({
|
|
79
|
+
headless,
|
|
80
|
+
slowMo: 0,
|
|
81
|
+
executablePath: CHROME,
|
|
82
|
+
userDataDir: await nextUserDataDir(),
|
|
83
|
+
...options,
|
|
84
|
+
});
|
|
85
|
+
try {
|
|
86
|
+
await page.goto(server.url(token), { waitUntil: 'load' });
|
|
87
|
+
return { report: await server.waitForReport(token), token };
|
|
88
|
+
} finally {
|
|
89
|
+
await browser.close();
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
before(async () => {
|
|
94
|
+
server = await startProbeServer(await readProbeSource());
|
|
95
|
+
reference = await captureReferenceReport({
|
|
96
|
+
executablePath: CHROME,
|
|
97
|
+
server,
|
|
98
|
+
token: randomUUID(),
|
|
99
|
+
headless,
|
|
100
|
+
});
|
|
101
|
+
assert.ok(
|
|
102
|
+
reference && !reference.fatal,
|
|
103
|
+
`reference capture failed: ${reference && reference.fatal}`
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
after(async () => {
|
|
108
|
+
if (server) {
|
|
109
|
+
await server.close();
|
|
110
|
+
}
|
|
111
|
+
await Promise.all(
|
|
112
|
+
userDataDirs.map((directory) =>
|
|
113
|
+
rm(directory, { recursive: true, force: true, maxRetries: 10 })
|
|
114
|
+
)
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
for (const engine of ['playwright', 'puppeteer']) {
|
|
119
|
+
it(
|
|
120
|
+
`${engine} is indistinguishable from a hand-started Chrome`,
|
|
121
|
+
{ timeout: TEST_TIMEOUT },
|
|
122
|
+
async () => {
|
|
123
|
+
const { report } = await captureWithLibrary({ engine });
|
|
124
|
+
const differences = diffReports(reference, report);
|
|
125
|
+
|
|
126
|
+
assert.deepEqual(
|
|
127
|
+
differences,
|
|
128
|
+
[],
|
|
129
|
+
`${engine} differs from a real ${mode} browser in: ${differences
|
|
130
|
+
.map((entry) => entry.path)
|
|
131
|
+
.join(', ')}`
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
it(
|
|
137
|
+
`${engine} leaks navigator.webdriver once parity is turned off`,
|
|
138
|
+
{ timeout: TEST_TIMEOUT },
|
|
139
|
+
async () => {
|
|
140
|
+
// Negative control: without this the parity assertion above could
|
|
141
|
+
// pass for the wrong reason, for example because the probe stopped
|
|
142
|
+
// reporting the field.
|
|
143
|
+
const { report } = await captureWithLibrary({
|
|
144
|
+
engine,
|
|
145
|
+
automationParity: false,
|
|
146
|
+
});
|
|
147
|
+
const paths = diffReports(reference, report).map(
|
|
148
|
+
(entry) => entry.path
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
assert.ok(
|
|
152
|
+
paths.includes('navigator.webdriver'),
|
|
153
|
+
`expected navigator.webdriver to leak, saw: ${paths.join(', ')}`
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
it(
|
|
159
|
+
`${engine} presents the fingerprint profile it was launched with`,
|
|
160
|
+
{ timeout: TEST_TIMEOUT },
|
|
161
|
+
async () => {
|
|
162
|
+
const profile = createFingerprintPreset('macos-chrome', {
|
|
163
|
+
overrides: {
|
|
164
|
+
timezoneId: 'Australia/Sydney',
|
|
165
|
+
languages: ['pt-BR', 'pt', 'en'],
|
|
166
|
+
hardwareConcurrency: 6,
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
const { report, token } = await captureWithLibrary({
|
|
170
|
+
engine,
|
|
171
|
+
fingerprint: profile,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
assert.equal(report.navigator.userAgent, profile.userAgent);
|
|
175
|
+
assert.equal(report.navigator.platform, profile.platform);
|
|
176
|
+
assert.equal(report.navigator.hardwareConcurrency, 6);
|
|
177
|
+
assert.equal(report.navigator.deviceMemory, profile.deviceMemory);
|
|
178
|
+
assert.deepEqual(report.navigator.languages, ['pt-BR', 'pt', 'en']);
|
|
179
|
+
assert.equal(report.intl.dateTimeTimeZone, 'Australia/Sydney');
|
|
180
|
+
assert.equal(report.screen.width, profile.screen.width);
|
|
181
|
+
assert.equal(report.screen.availHeight, profile.screen.availHeight);
|
|
182
|
+
assert.equal(report.screen.colorDepth, profile.screen.colorDepth);
|
|
183
|
+
// A machine with no GPU stack reports no WebGL context at all, and
|
|
184
|
+
// there is then nothing for the renderer patch to replace.
|
|
185
|
+
if (report.webgl.webgl1) {
|
|
186
|
+
assert.equal(
|
|
187
|
+
report.webgl.webgl1.unmaskedVendor,
|
|
188
|
+
profile.webgl.unmaskedVendor
|
|
189
|
+
);
|
|
190
|
+
assert.equal(report.webgl.webgl1.vendor, profile.webgl.vendor);
|
|
191
|
+
}
|
|
192
|
+
// The overrides Chrome enforces itself reach the network stack
|
|
193
|
+
// too, which a JavaScript property patch never would. Chrome
|
|
194
|
+
// builds the quality ladder from the plain list, exactly as it
|
|
195
|
+
// does for a real browser configured with these languages.
|
|
196
|
+
assert.equal(
|
|
197
|
+
server.headersFor(token)['accept-language'],
|
|
198
|
+
'pt-BR,pt;q=0.9,en;q=0.8'
|
|
199
|
+
);
|
|
200
|
+
assert.equal(
|
|
201
|
+
server.headersFor(token)['user-agent'],
|
|
202
|
+
profile.userAgent
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
defineParitySuite({ headless: false });
|
|
212
|
+
defineParitySuite({ headless: true });
|
package/tests/helpers/mocks.js
CHANGED
|
@@ -530,7 +530,8 @@ export const assert = {
|
|
|
530
530
|
} catch (error) {
|
|
531
531
|
if (expectedError && !error.message.includes(expectedError)) {
|
|
532
532
|
throw new Error(
|
|
533
|
-
`${message}: Expected error message to include "${expectedError}", got "${error.message}"
|
|
533
|
+
`${message}: Expected error message to include "${expectedError}", got "${error.message}"`,
|
|
534
|
+
{ cause: error }
|
|
534
535
|
);
|
|
535
536
|
}
|
|
536
537
|
}
|
|
@@ -540,7 +541,8 @@ export const assert = {
|
|
|
540
541
|
await fn();
|
|
541
542
|
} catch (error) {
|
|
542
543
|
throw new Error(
|
|
543
|
-
`${message}: Expected function not to throw, but got: ${error.message}
|
|
544
|
+
`${message}: Expected function not to throw, but got: ${error.message}`,
|
|
545
|
+
{ cause: error }
|
|
544
546
|
);
|
|
545
547
|
}
|
|
546
548
|
},
|