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.
- 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 +8 -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-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 +193 -157
- 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,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
|
},
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the repository is, and how to run one of its shell scripts.
|
|
3
|
+
*
|
|
4
|
+
* Several checks this repository ships are bash scripts that a workflow calls
|
|
5
|
+
* directly (`scripts/check-pipeline-status.sh`,
|
|
6
|
+
* `scripts/run-with-budget-warning.sh`). Testing one means resolving the
|
|
7
|
+
* repository root from the test file, spawning bash with a controlled
|
|
8
|
+
* environment, and reading stdout and stderr together - CI interleaves them,
|
|
9
|
+
* and a `::error::` annotation is only useful if the assertion sees it in the
|
|
10
|
+
* same stream the runner does.
|
|
11
|
+
*
|
|
12
|
+
* Every test that needs those three things imports them from here, so the
|
|
13
|
+
* duplication gate (`npm run check:duplication`) stays quiet and a change to
|
|
14
|
+
* the spawning contract happens in one place.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { spawnSync } from 'node:child_process';
|
|
18
|
+
import { readFileSync } from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
|
|
22
|
+
export const REPO_ROOT = path.resolve(
|
|
23
|
+
fileURLToPath(new URL('.', import.meta.url)),
|
|
24
|
+
'../../..'
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
/** Absolute path to a file, named the way the repository names it. */
|
|
28
|
+
export function repoPath(...segments) {
|
|
29
|
+
return path.join(REPO_ROOT, ...segments);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Newlines as the assertions expect them.
|
|
34
|
+
*
|
|
35
|
+
* A Windows runner checks the repository out with CRLF endings, so text read
|
|
36
|
+
* straight off disk there does not contain the `\n` a matcher written on Linux
|
|
37
|
+
* looks for. Normalizing at the read is the difference between a test that
|
|
38
|
+
* checks the repository and a test that checks the operating system.
|
|
39
|
+
*/
|
|
40
|
+
export function normalizeNewlines(text) {
|
|
41
|
+
return text.replaceAll('\r\n', '\n');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Read a repository file as text, with newlines normalized. */
|
|
45
|
+
export function readRepoText(...segments) {
|
|
46
|
+
return normalizeNewlines(readFileSync(repoPath(...segments), 'utf8'));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* False on a runner without bash (a plain Windows image), where a test of a
|
|
51
|
+
* bash script has nothing to say. Suites use it as `{ skip: !BASH_AVAILABLE }`
|
|
52
|
+
* rather than asserting on an absent interpreter.
|
|
53
|
+
*/
|
|
54
|
+
export const BASH_AVAILABLE =
|
|
55
|
+
spawnSync('bash', ['-c', 'exit 0'], { encoding: 'utf8' }).status === 0;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Run a bash script and return its exit status with stdout and stderr joined.
|
|
59
|
+
*
|
|
60
|
+
* `env` is merged over the caller's environment rather than replacing it: the
|
|
61
|
+
* scripts under test call `node` and `git`, which need PATH.
|
|
62
|
+
*/
|
|
63
|
+
export function runBashScript(scriptPath, args = [], options = {}) {
|
|
64
|
+
const result = spawnSync('bash', [scriptPath, ...args], {
|
|
65
|
+
cwd: options.cwd ?? REPO_ROOT,
|
|
66
|
+
encoding: 'utf8',
|
|
67
|
+
env: { ...process.env, ...options.env },
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
status: result.status,
|
|
72
|
+
output: `${result.stdout ?? ''}${result.stderr ?? ''}`,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -56,10 +56,18 @@ describe('browser launch options', () => {
|
|
|
56
56
|
});
|
|
57
57
|
|
|
58
58
|
assert.deepEqual(playwright.ignoreDefaultArgs, [
|
|
59
|
+
'--enable-automation',
|
|
60
|
+
// Playwright also forces software WebGL on, which a hand-started Chrome
|
|
61
|
+
// without a usable GPU refuses to do.
|
|
62
|
+
'--enable-unsafe-swiftshader',
|
|
63
|
+
'--no-first-run',
|
|
64
|
+
]);
|
|
65
|
+
// Puppeteer passes --enable-automation by default, so automation parity
|
|
66
|
+
// has to suppress it there too.
|
|
67
|
+
assert.deepEqual(puppeteer.ignoreDefaultArgs, [
|
|
59
68
|
'--enable-automation',
|
|
60
69
|
'--no-first-run',
|
|
61
70
|
]);
|
|
62
|
-
assert.deepEqual(puppeteer.ignoreDefaultArgs, ['--no-first-run']);
|
|
63
71
|
});
|
|
64
72
|
|
|
65
73
|
it('forwards channel and executablePath to Playwright', () => {
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The projection the parity runners compare on.
|
|
3
|
+
*
|
|
4
|
+
* Three runners used to read the probe report through long
|
|
5
|
+
* `report?.a?.b ?? null` chains - flat tables of twenty-odd fields that ESLint
|
|
6
|
+
* counted as functions with a cyclomatic complexity of 40, 50 and 53. The
|
|
7
|
+
* chains are now paths through readReportPath(), so these tests pin the two
|
|
8
|
+
* properties the runners depended on: a missing level yields the fallback
|
|
9
|
+
* rather than throwing, and a value that is present is returned unchanged even
|
|
10
|
+
* when it is falsy.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import assert from 'node:assert/strict';
|
|
14
|
+
import { describe, it } from 'node:test';
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
projectReport,
|
|
18
|
+
readReportPath,
|
|
19
|
+
} from '../../../../experiments/fingerprint-parity/harness.mjs';
|
|
20
|
+
|
|
21
|
+
describe('readReportPath', () => {
|
|
22
|
+
it('reads a nested value', () => {
|
|
23
|
+
assert.equal(
|
|
24
|
+
readReportPath({ webgl: { webgl1: { unmaskedVendor: 'Intel' } } }, [
|
|
25
|
+
'webgl',
|
|
26
|
+
'webgl1',
|
|
27
|
+
'unmaskedVendor',
|
|
28
|
+
]),
|
|
29
|
+
'Intel'
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('returns the fallback for a level the report never sent', () => {
|
|
34
|
+
// A browser that failed half the probe still POSTs what it collected, so
|
|
35
|
+
// every level of the path can be absent.
|
|
36
|
+
assert.equal(
|
|
37
|
+
readReportPath({}, ['webgl', 'webgl1', 'unmaskedVendor']),
|
|
38
|
+
undefined
|
|
39
|
+
);
|
|
40
|
+
assert.equal(
|
|
41
|
+
readReportPath(undefined, ['navigator', 'webdriver'], null),
|
|
42
|
+
null
|
|
43
|
+
);
|
|
44
|
+
assert.equal(
|
|
45
|
+
readReportPath({ intl: null }, ['intl', 'dateTimeLocale'], null),
|
|
46
|
+
null
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('keeps a falsy value that the report actually reported', () => {
|
|
51
|
+
// `navigator.webdriver: false` is the single most important reading in the
|
|
52
|
+
// whole suite; a fallback applied to it would report a pass as a miss.
|
|
53
|
+
assert.equal(
|
|
54
|
+
readReportPath(
|
|
55
|
+
{ navigator: { webdriver: false } },
|
|
56
|
+
['navigator', 'webdriver'],
|
|
57
|
+
null
|
|
58
|
+
),
|
|
59
|
+
false
|
|
60
|
+
);
|
|
61
|
+
assert.equal(
|
|
62
|
+
readReportPath({ plugins: [] }, ['plugins', 'length'], null),
|
|
63
|
+
0
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('reads a key that is not an identifier', () => {
|
|
68
|
+
assert.equal(
|
|
69
|
+
readReportPath({ mediaQueries: { '(pointer: coarse)': true } }, [
|
|
70
|
+
'mediaQueries',
|
|
71
|
+
'(pointer: coarse)',
|
|
72
|
+
]),
|
|
73
|
+
true
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe('projectReport', () => {
|
|
79
|
+
it('names every field it was asked for, present or not', () => {
|
|
80
|
+
const projected = projectReport(
|
|
81
|
+
{ navigator: { webdriver: false } },
|
|
82
|
+
{
|
|
83
|
+
webdriver: ['navigator', 'webdriver'],
|
|
84
|
+
userAgent: ['navigator', 'userAgent'],
|
|
85
|
+
},
|
|
86
|
+
null
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
assert.deepEqual(projected, { webdriver: false, userAgent: null });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('leaves an absent field undefined when no fallback is given', () => {
|
|
93
|
+
assert.deepEqual(projectReport({}, { screen: ['screen'] }), {
|
|
94
|
+
screen: undefined,
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
applyFingerprint,
|
|
6
|
+
createCdpSession,
|
|
7
|
+
} from '../../../src/fingerprint/apply.js';
|
|
8
|
+
import { createFingerprintPreset } from '../../../src/fingerprint/presets.js';
|
|
9
|
+
|
|
10
|
+
/** A CDP session that records what was sent instead of talking to Chrome. */
|
|
11
|
+
function createRecordingSession() {
|
|
12
|
+
const sent = [];
|
|
13
|
+
return {
|
|
14
|
+
sent,
|
|
15
|
+
methods: () => sent.map((entry) => entry.method),
|
|
16
|
+
send(method, params) {
|
|
17
|
+
sent.push({ method, params });
|
|
18
|
+
return Promise.resolve({});
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Minimal stand-ins for the two engines' object graphs. */
|
|
24
|
+
function createPlaywrightDouble() {
|
|
25
|
+
const session = createRecordingSession();
|
|
26
|
+
const listeners = new Map();
|
|
27
|
+
const page = {};
|
|
28
|
+
const browser = {
|
|
29
|
+
newCDPSession(target) {
|
|
30
|
+
browser.cdpTargets.push(target);
|
|
31
|
+
return Promise.resolve(session);
|
|
32
|
+
},
|
|
33
|
+
cdpTargets: [],
|
|
34
|
+
on(event, handler) {
|
|
35
|
+
listeners.set(event, handler);
|
|
36
|
+
},
|
|
37
|
+
emit: (event, argument) => listeners.get(event)?.(argument),
|
|
38
|
+
listeners,
|
|
39
|
+
};
|
|
40
|
+
return { browser, page, session };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function createPuppeteerDouble() {
|
|
44
|
+
const session = createRecordingSession();
|
|
45
|
+
const listeners = new Map();
|
|
46
|
+
const page = {
|
|
47
|
+
createCDPSession: () => Promise.resolve(session),
|
|
48
|
+
};
|
|
49
|
+
const browser = {
|
|
50
|
+
on(event, handler) {
|
|
51
|
+
listeners.set(event, handler);
|
|
52
|
+
},
|
|
53
|
+
emit: (event, argument) => listeners.get(event)?.(argument),
|
|
54
|
+
listeners,
|
|
55
|
+
};
|
|
56
|
+
return { browser, page, session };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe('opening a CDP session', () => {
|
|
60
|
+
it('uses the Playwright context that launchBrowser hands back', async () => {
|
|
61
|
+
const { browser, page, session } = createPlaywrightDouble();
|
|
62
|
+
|
|
63
|
+
assert.equal(
|
|
64
|
+
await createCdpSession({ browser, page, engine: 'playwright' }),
|
|
65
|
+
session
|
|
66
|
+
);
|
|
67
|
+
assert.deepEqual(browser.cdpTargets, [page]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('falls back to the page context when the browser has no newCDPSession', async () => {
|
|
71
|
+
const session = createRecordingSession();
|
|
72
|
+
const context = { newCDPSession: () => Promise.resolve(session) };
|
|
73
|
+
const page = { context: () => context };
|
|
74
|
+
|
|
75
|
+
assert.equal(
|
|
76
|
+
await createCdpSession({ browser: {}, page, engine: 'playwright' }),
|
|
77
|
+
session
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('uses page.createCDPSession on Puppeteer', async () => {
|
|
82
|
+
const { page, session } = createPuppeteerDouble();
|
|
83
|
+
|
|
84
|
+
assert.equal(
|
|
85
|
+
await createCdpSession({ browser: {}, page, engine: 'puppeteer' }),
|
|
86
|
+
session
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('falls back to the target on older Puppeteer pages', async () => {
|
|
91
|
+
const session = createRecordingSession();
|
|
92
|
+
const page = {
|
|
93
|
+
target: () => ({ createCDPSession: () => Promise.resolve(session) }),
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
assert.equal(
|
|
97
|
+
await createCdpSession({ browser: {}, page, engine: 'puppeteer' }),
|
|
98
|
+
session
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('applying a fingerprint', () => {
|
|
104
|
+
const profile = createFingerprintPreset('windows-chrome');
|
|
105
|
+
|
|
106
|
+
it('requires a page', async () => {
|
|
107
|
+
await assert.rejects(
|
|
108
|
+
() => applyFingerprint({ profile }),
|
|
109
|
+
/requires a page/u
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('sends the emulation commands before installing the init script', async () => {
|
|
114
|
+
const { browser, page, session } = createPlaywrightDouble();
|
|
115
|
+
|
|
116
|
+
await applyFingerprint({ browser, page, engine: 'playwright', profile });
|
|
117
|
+
|
|
118
|
+
const methods = session.methods();
|
|
119
|
+
const firstScriptIndex = methods.indexOf('Page.enable');
|
|
120
|
+
assert.ok(firstScriptIndex > 0);
|
|
121
|
+
assert.ok(
|
|
122
|
+
methods
|
|
123
|
+
.slice(0, firstScriptIndex)
|
|
124
|
+
.every((method) => method.startsWith('Emulation.'))
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('enables the Page domain before adding the script, then patches the open document', async () => {
|
|
129
|
+
const { browser, page, session } = createPlaywrightDouble();
|
|
130
|
+
|
|
131
|
+
await applyFingerprint({ browser, page, engine: 'playwright', profile });
|
|
132
|
+
|
|
133
|
+
// Measured: without Page.enable, Chrome accepts
|
|
134
|
+
// addScriptToEvaluateOnNewDocument and never runs the script.
|
|
135
|
+
assert.deepEqual(
|
|
136
|
+
session.methods().filter((method) => !method.startsWith('Emulation.')),
|
|
137
|
+
[
|
|
138
|
+
'Page.enable',
|
|
139
|
+
'Page.addScriptToEvaluateOnNewDocument',
|
|
140
|
+
'Runtime.evaluate',
|
|
141
|
+
]
|
|
142
|
+
);
|
|
143
|
+
const [, added, evaluated] = session.sent.slice(-3);
|
|
144
|
+
assert.equal(added.params.source, evaluated.params.expression);
|
|
145
|
+
assert.equal(evaluated.params.returnByValue, true);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('normalizes a raw profile and reports what it sent', async () => {
|
|
149
|
+
const { browser, page, session } = createPlaywrightDouble();
|
|
150
|
+
|
|
151
|
+
const result = await applyFingerprint({
|
|
152
|
+
browser,
|
|
153
|
+
page,
|
|
154
|
+
engine: 'playwright',
|
|
155
|
+
profile: { timezoneId: 'Europe/Berlin', deviceMemory: 8 },
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
assert.ok(Object.isFrozen(result.profile));
|
|
159
|
+
assert.deepEqual(result.commands, [
|
|
160
|
+
{
|
|
161
|
+
method: 'Emulation.setTimezoneOverride',
|
|
162
|
+
params: { timezoneId: 'Europe/Berlin' },
|
|
163
|
+
},
|
|
164
|
+
]);
|
|
165
|
+
assert.match(result.initScript, /deviceMemory/u);
|
|
166
|
+
assert.equal(session.methods()[0], 'Emulation.setTimezoneOverride');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('rejects a raw profile with an unknown field', async () => {
|
|
170
|
+
const { browser, page } = createPlaywrightDouble();
|
|
171
|
+
|
|
172
|
+
await assert.rejects(
|
|
173
|
+
() => applyFingerprint({ browser, page, profile: { cpuModel: 'M3' } }),
|
|
174
|
+
/cpuModel/u
|
|
175
|
+
);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('skips the script commands when the browser overrides cover everything', async () => {
|
|
179
|
+
const { browser, page, session } = createPlaywrightDouble();
|
|
180
|
+
|
|
181
|
+
const result = await applyFingerprint({
|
|
182
|
+
browser,
|
|
183
|
+
page,
|
|
184
|
+
engine: 'playwright',
|
|
185
|
+
profile: { timezoneId: 'UTC' },
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
assert.equal(result.initScript, null);
|
|
189
|
+
assert.deepEqual(session.methods(), ['Emulation.setTimezoneOverride']);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('applies the same profile to a page opened later on Playwright', async () => {
|
|
193
|
+
const { browser, page, session } = createPlaywrightDouble();
|
|
194
|
+
|
|
195
|
+
await applyFingerprint({ browser, page, engine: 'playwright', profile });
|
|
196
|
+
const before = session.sent.length;
|
|
197
|
+
const laterPage = {};
|
|
198
|
+
await browser.emit('page', laterPage);
|
|
199
|
+
|
|
200
|
+
assert.equal(session.sent.length, before * 2);
|
|
201
|
+
assert.deepEqual(browser.cdpTargets, [page, laterPage]);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('applies the same profile to a target opened later on Puppeteer', async () => {
|
|
205
|
+
const { browser, page, session } = createPuppeteerDouble();
|
|
206
|
+
|
|
207
|
+
await applyFingerprint({ browser, page, engine: 'puppeteer', profile });
|
|
208
|
+
const before = session.sent.length;
|
|
209
|
+
await browser.emit('targetcreated', {
|
|
210
|
+
page: () =>
|
|
211
|
+
Promise.resolve({ createCDPSession: () => Promise.resolve(session) }),
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
assert.equal(session.sent.length, before * 2);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('ignores a new target that is not a page', async () => {
|
|
218
|
+
const { browser, page, session } = createPuppeteerDouble();
|
|
219
|
+
|
|
220
|
+
await applyFingerprint({ browser, page, engine: 'puppeteer', profile });
|
|
221
|
+
const before = session.sent.length;
|
|
222
|
+
await browser.emit('targetcreated', { page: () => Promise.resolve(null) });
|
|
223
|
+
|
|
224
|
+
assert.equal(session.sent.length, before);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('survives a page that closes before its session is ready', async () => {
|
|
228
|
+
const { browser, page } = createPlaywrightDouble();
|
|
229
|
+
|
|
230
|
+
await applyFingerprint({ browser, page, engine: 'playwright', profile });
|
|
231
|
+
browser.newCDPSession = () => Promise.reject(new Error('Target closed'));
|
|
232
|
+
|
|
233
|
+
await assert.doesNotReject(() => browser.emit('page', {}));
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('does not hook new pages when asked not to', async () => {
|
|
237
|
+
const { browser, page } = createPlaywrightDouble();
|
|
238
|
+
|
|
239
|
+
await applyFingerprint({
|
|
240
|
+
browser,
|
|
241
|
+
page,
|
|
242
|
+
engine: 'playwright',
|
|
243
|
+
profile,
|
|
244
|
+
applyToNewPages: false,
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
assert.equal(browser.listeners.size, 0);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('adds the webdriver patch only when asked', async () => {
|
|
251
|
+
const { browser, page } = createPlaywrightDouble();
|
|
252
|
+
|
|
253
|
+
const plain = await applyFingerprint({
|
|
254
|
+
browser,
|
|
255
|
+
page,
|
|
256
|
+
engine: 'playwright',
|
|
257
|
+
profile: { timezoneId: 'UTC' },
|
|
258
|
+
});
|
|
259
|
+
assert.equal(plain.initScript, null);
|
|
260
|
+
|
|
261
|
+
const patched = await applyFingerprint({
|
|
262
|
+
browser,
|
|
263
|
+
page,
|
|
264
|
+
engine: 'playwright',
|
|
265
|
+
profile: { timezoneId: 'UTC' },
|
|
266
|
+
patchWebdriver: true,
|
|
267
|
+
applyToNewPages: false,
|
|
268
|
+
});
|
|
269
|
+
assert.match(patched.initScript, /"webdriver":false/u);
|
|
270
|
+
});
|
|
271
|
+
});
|