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,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
  }
@@ -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 });
@@ -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
  },