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,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
|
+
});
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
applyAutomationParityArgs,
|
|
6
|
+
AUTOMATION_CONTROLLED_OFF_ARG,
|
|
7
|
+
AUTOMATION_CONTROLLED_TRIGGERS,
|
|
8
|
+
detectAutomationControlledTriggers,
|
|
9
|
+
disablesAutomationControlled,
|
|
10
|
+
ENGINE_PARITY_IGNORED_DEFAULT_ARGS,
|
|
11
|
+
parityIgnoredDefaultArgs,
|
|
12
|
+
PLAYWRIGHT_HEADLESS_POINTER_ARG,
|
|
13
|
+
PLAYWRIGHT_SOFTWARE_WEBGL_ARG,
|
|
14
|
+
} from '../../../src/fingerprint/automation-parity.js';
|
|
15
|
+
|
|
16
|
+
describe('AutomationControlled triggers', () => {
|
|
17
|
+
it('documents a reason for every trigger', () => {
|
|
18
|
+
assert.ok(AUTOMATION_CONTROLLED_TRIGGERS.length > 0);
|
|
19
|
+
for (const trigger of AUTOMATION_CONTROLLED_TRIGGERS) {
|
|
20
|
+
assert.equal(typeof trigger.switch, 'string');
|
|
21
|
+
assert.ok(trigger.reason.length > 0, `${trigger.switch} needs a reason`);
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('finds each switch Chromium maps onto the feature', () => {
|
|
26
|
+
const found = detectAutomationControlledTriggers([
|
|
27
|
+
'--enable-automation',
|
|
28
|
+
'--headless=new',
|
|
29
|
+
'--remote-debugging-pipe',
|
|
30
|
+
'--remote-debugging-port=0',
|
|
31
|
+
]).map((entry) => entry.switch);
|
|
32
|
+
|
|
33
|
+
assert.deepEqual(found, [
|
|
34
|
+
'--enable-automation',
|
|
35
|
+
'--headless',
|
|
36
|
+
'--remote-debugging-pipe',
|
|
37
|
+
'--remote-debugging-port=0',
|
|
38
|
+
]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('reports the argument as written next to the switch it matched', () => {
|
|
42
|
+
assert.deepEqual(
|
|
43
|
+
detectAutomationControlledTriggers(['--headless=new'])[0].argument,
|
|
44
|
+
'--headless=new'
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('leaves a fixed debugging port alone', () => {
|
|
49
|
+
// runtime_features.cc treats only an ephemeral port as automation, because
|
|
50
|
+
// a specific port is what a human attaching a debugger passes.
|
|
51
|
+
assert.deepEqual(
|
|
52
|
+
detectAutomationControlledTriggers(['--remote-debugging-port=9222']),
|
|
53
|
+
[]
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('finds nothing in an ordinary command line', () => {
|
|
58
|
+
assert.deepEqual(
|
|
59
|
+
detectAutomationControlledTriggers([
|
|
60
|
+
'--no-first-run',
|
|
61
|
+
'--window-size=1280,720',
|
|
62
|
+
]),
|
|
63
|
+
[]
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('rejects a non-array or a non-string argument', () => {
|
|
68
|
+
assert.throws(
|
|
69
|
+
() => detectAutomationControlledTriggers('--headless'),
|
|
70
|
+
/must be an array of strings/u
|
|
71
|
+
);
|
|
72
|
+
assert.throws(
|
|
73
|
+
() => detectAutomationControlledTriggers([1]),
|
|
74
|
+
/must be an array of strings/u
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('applying automation parity to a command line', () => {
|
|
80
|
+
it('appends the switch that turns the feature back off', () => {
|
|
81
|
+
assert.deepEqual(applyAutomationParityArgs(['--no-first-run']), [
|
|
82
|
+
'--no-first-run',
|
|
83
|
+
AUTOMATION_CONTROLLED_OFF_ARG,
|
|
84
|
+
]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('works on an empty command line and on no command line at all', () => {
|
|
88
|
+
assert.deepEqual(applyAutomationParityArgs([]), [
|
|
89
|
+
AUTOMATION_CONTROLLED_OFF_ARG,
|
|
90
|
+
]);
|
|
91
|
+
assert.deepEqual(applyAutomationParityArgs(), [
|
|
92
|
+
AUTOMATION_CONTROLLED_OFF_ARG,
|
|
93
|
+
]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('is idempotent', () => {
|
|
97
|
+
const once = applyAutomationParityArgs(['--no-first-run']);
|
|
98
|
+
assert.deepEqual(applyAutomationParityArgs(once), once);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('extends an existing feature list instead of duplicating the switch', () => {
|
|
102
|
+
// Chrome keeps only the last --disable-blink-features occurrence, so a
|
|
103
|
+
// second one would silently drop the caller's features.
|
|
104
|
+
assert.deepEqual(
|
|
105
|
+
applyAutomationParityArgs(['--disable-blink-features=AcceptCHFrame']),
|
|
106
|
+
['--disable-blink-features=AcceptCHFrame,AutomationControlled']
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('does not copy the input array back to the caller', () => {
|
|
111
|
+
const args = ['--no-first-run'];
|
|
112
|
+
assert.notEqual(applyAutomationParityArgs(args), args);
|
|
113
|
+
assert.deepEqual(args, ['--no-first-run']);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('rejects a non-array', () => {
|
|
117
|
+
assert.throws(
|
|
118
|
+
() => applyAutomationParityArgs('--no-first-run'),
|
|
119
|
+
/must be an array of strings/u
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('recognises the feature inside a longer list', () => {
|
|
124
|
+
assert.equal(
|
|
125
|
+
disablesAutomationControlled([
|
|
126
|
+
'--disable-blink-features=AcceptCHFrame, AutomationControlled',
|
|
127
|
+
]),
|
|
128
|
+
true
|
|
129
|
+
);
|
|
130
|
+
assert.equal(
|
|
131
|
+
disablesAutomationControlled(['--disable-blink-features=AcceptCHFrame']),
|
|
132
|
+
false
|
|
133
|
+
);
|
|
134
|
+
assert.equal(disablesAutomationControlled([]), false);
|
|
135
|
+
// Enabling is not disabling.
|
|
136
|
+
assert.equal(
|
|
137
|
+
disablesAutomationControlled([
|
|
138
|
+
'--enable-blink-features=AutomationControlled',
|
|
139
|
+
]),
|
|
140
|
+
false
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe('engine default switches to suppress', () => {
|
|
146
|
+
it('suppresses --enable-automation on both engines', () => {
|
|
147
|
+
for (const engine of ['playwright', 'puppeteer']) {
|
|
148
|
+
assert.ok(
|
|
149
|
+
parityIgnoredDefaultArgs(engine).includes('--enable-automation'),
|
|
150
|
+
`${engine} must suppress --enable-automation`
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('suppresses the Playwright headless pointer switch only when headless', () => {
|
|
156
|
+
// Playwright forces hover:hover and pointer:fine in headless Chrome, which
|
|
157
|
+
// a real headless browser never reports; see parity-headless.json.
|
|
158
|
+
assert.ok(
|
|
159
|
+
!parityIgnoredDefaultArgs('playwright').includes(
|
|
160
|
+
PLAYWRIGHT_HEADLESS_POINTER_ARG
|
|
161
|
+
)
|
|
162
|
+
);
|
|
163
|
+
assert.ok(
|
|
164
|
+
parityIgnoredDefaultArgs('playwright', { headless: true }).includes(
|
|
165
|
+
PLAYWRIGHT_HEADLESS_POINTER_ARG
|
|
166
|
+
)
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('suppresses the Playwright software-WebGL switch in both modes', () => {
|
|
171
|
+
// Playwright 1.62 pushes --enable-unsafe-swiftshader on every platform, so
|
|
172
|
+
// a machine with no usable GPU answers getContext('webgl') with a
|
|
173
|
+
// SwiftShader context where a hand-started Chrome answers null. Measured in
|
|
174
|
+
// analysis-artifacts/parity-webgl-swiftshader.json; the switch has to go in
|
|
175
|
+
// both modes, because headless Chrome turns SwiftShader on by itself.
|
|
176
|
+
for (const headless of [false, true]) {
|
|
177
|
+
assert.ok(
|
|
178
|
+
parityIgnoredDefaultArgs('playwright', { headless }).includes(
|
|
179
|
+
PLAYWRIGHT_SOFTWARE_WEBGL_ARG
|
|
180
|
+
),
|
|
181
|
+
`playwright must suppress ${PLAYWRIGHT_SOFTWARE_WEBGL_ARG} when headless=${headless}`
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('has nothing extra to suppress for headless Puppeteer', () => {
|
|
187
|
+
assert.deepEqual(
|
|
188
|
+
parityIgnoredDefaultArgs('puppeteer', { headless: true }),
|
|
189
|
+
['--enable-automation']
|
|
190
|
+
);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('rejects an unknown engine by name', () => {
|
|
194
|
+
assert.throws(
|
|
195
|
+
() => parityIgnoredDefaultArgs('selenium'),
|
|
196
|
+
/unknown engine "selenium"/u
|
|
197
|
+
);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('does not let a caller mutate the shared table', () => {
|
|
201
|
+
assert.ok(Object.isFrozen(ENGINE_PARITY_IGNORED_DEFAULT_ARGS));
|
|
202
|
+
assert.ok(
|
|
203
|
+
Object.isFrozen(ENGINE_PARITY_IGNORED_DEFAULT_ARGS.playwright.always)
|
|
204
|
+
);
|
|
205
|
+
const result = parityIgnoredDefaultArgs('playwright');
|
|
206
|
+
result.push('--mutated');
|
|
207
|
+
assert.deepEqual(parityIgnoredDefaultArgs('playwright'), [
|
|
208
|
+
'--enable-automation',
|
|
209
|
+
PLAYWRIGHT_SOFTWARE_WEBGL_ARG,
|
|
210
|
+
]);
|
|
211
|
+
});
|
|
212
|
+
});
|