browser-commander 0.20.0 → 0.21.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/CHANGELOG.md +145 -0
- package/README.md +216 -32
- package/bin/browser-commander.js +16 -0
- package/eslint.config.js +10 -0
- package/examples/react-test-app/src/App.jsx +24 -23
- package/extension/background.js +14 -0
- package/extension/manifest.json +12 -0
- package/extension/relay-handler.js +353 -0
- package/package.json +19 -4
- package/src/browser/attach/differences.js +131 -0
- package/src/browser/attach/extension-relay.js +402 -0
- package/src/browser/attach/index.js +81 -0
- package/src/browser/attach/snapshot-launch.js +146 -0
- package/src/browser/attach/snapshot.js +374 -0
- package/src/browser/attach/websocket.js +615 -0
- package/src/browser/browser-cookie-cache.js +12 -17
- package/src/browser/browser-cookie-credentials.js +6 -9
- package/src/browser/browser-cookies.js +5 -2
- package/src/browser/cdp-session.js +94 -0
- package/src/browser/connector.js +42 -4
- package/src/browser/debugging-port.js +155 -0
- package/src/browser/launch-options.js +63 -52
- package/src/browser/launch-restrictions.json +147 -0
- package/src/browser/launcher.js +329 -133
- package/src/browser/media.js +5 -0
- package/src/browser/migration/bookmarks.js +73 -0
- package/src/browser/migration/chromium-crypto.js +89 -0
- package/src/browser/migration/cookies.js +155 -0
- package/src/browser/migration/extensions.js +187 -0
- package/src/browser/migration/firefox-bookmarks.js +144 -0
- package/src/browser/migration/firefox-der.js +84 -0
- package/src/browser/migration/firefox-nss.js +281 -0
- package/src/browser/migration/firefox.js +334 -0
- package/src/browser/migration/fs-utils.js +51 -0
- package/src/browser/migration/history.js +103 -0
- package/src/browser/migration/index.js +307 -0
- package/src/browser/migration/os-crypt-keys.js +106 -0
- package/src/browser/migration/passwords.js +171 -0
- package/src/browser/migration/preferences.js +132 -0
- package/src/browser/migration/sqlite-snapshot.js +138 -0
- package/src/browser/open-in-user-browser.js +112 -0
- package/src/browser/parity.js +439 -0
- package/src/browser/profile-directory.js +102 -0
- package/src/browser/real-browser.js +513 -306
- package/src/browser/restrictions.js +148 -0
- package/src/browser/system-browser.js +232 -0
- package/src/browser/webdriver.js +572 -0
- package/src/cli/args.js +253 -0
- package/src/cli/commands.js +332 -0
- package/src/cli/dispatcher.js +99 -0
- package/src/cli/handle-methods.js +136 -0
- package/src/cli/handles.js +273 -0
- package/src/cli/high-level-methods.js +310 -0
- package/src/cli/main.js +67 -0
- package/src/cli/modules.js +100 -0
- package/src/cli/rpc-error.js +77 -0
- package/src/cli/script.js +77 -0
- package/src/cli/serve.js +144 -0
- package/src/cli/sessions.js +195 -0
- package/src/core/constants.js +7 -1
- package/src/core/engine-adapter.js +107 -1
- package/src/core/engine-detection.js +18 -3
- package/src/core/navigation-safety.js +4 -0
- package/src/core/webdriver-conversions.js +347 -0
- package/src/core/webdriver-events.js +229 -0
- package/src/core/webdriver-page.js +716 -0
- package/src/downloads/attach.js +6 -0
- package/src/elements/selectors.js +3 -2
- package/src/elements/visibility.js +4 -0
- package/src/exports.js +61 -2
- package/src/factory.js +19 -4
- package/src/fingerprint/apply.js +6 -0
- package/src/fingerprint/automation-parity.js +7 -8
- package/src/fingerprint/limitations.js +1 -1
- package/src/fingerprint/limitations.json +45 -2
- package/src/parity/harness.js +303 -0
- package/src/parity/probe.js +838 -0
- package/src/parity/version-page.js +144 -0
- package/src/utilities/subprocess.js +199 -0
- package/tests/e2e/api-coverage.e2e.test.js +174 -0
- package/tests/e2e/cli.e2e.test.js +160 -0
- package/tests/e2e/fingerprint-parity.e2e.test.js +104 -46
- package/tests/e2e/google-signin-probe.e2e.test.js +112 -0
- package/tests/e2e/measure-parity.e2e.test.js +118 -0
- package/tests/e2e/playwright.e2e.test.js +25 -17
- package/tests/e2e/puppeteer.e2e.test.js +17 -23
- package/tests/e2e/webdriver.e2e.test.js +239 -0
- package/tests/fixtures/firefox-nss-fixtures.mjs +211 -0
- package/tests/helpers/cli-fakes.js +214 -0
- package/tests/helpers/declared-api.js +80 -0
- package/tests/helpers/e2e-browser.js +40 -14
- package/tests/helpers/fake-chrome.js +124 -0
- package/tests/helpers/migration-fixtures.js +242 -0
- package/tests/helpers/temp-directory.js +24 -0
- package/tests/helpers/webdriver-mocks.js +183 -0
- package/tests/unit/browser/attach/extension-relay.test.js +360 -0
- package/tests/unit/browser/attach/snapshot-launch.test.js +312 -0
- package/tests/unit/browser/attach/snapshot.test.js +390 -0
- package/tests/unit/browser/attach/websocket.test.js +240 -0
- package/tests/unit/browser/browser-cookies.test.js +2 -32
- package/tests/unit/browser/cdp-session.test.js +162 -0
- package/tests/unit/browser/connector.test.js +23 -0
- package/tests/unit/browser/launch-building-blocks.test.js +238 -0
- package/tests/unit/browser/launch-options.test.js +41 -22
- package/tests/unit/browser/launcher.test.js +281 -102
- package/tests/unit/browser/media.test.js +13 -1
- package/tests/unit/browser/migration/bookmarks.test.js +69 -0
- package/tests/unit/browser/migration/chromium-crypto.test.js +94 -0
- package/tests/unit/browser/migration/cookies.test.js +94 -0
- package/tests/unit/browser/migration/extensions.test.js +104 -0
- package/tests/unit/browser/migration/firefox-nss.test.js +113 -0
- package/tests/unit/browser/migration/firefox.test.js +139 -0
- package/tests/unit/browser/migration/history.test.js +48 -0
- package/tests/unit/browser/migration/index.test.js +117 -0
- package/tests/unit/browser/migration/os-crypt-keys.test.js +95 -0
- package/tests/unit/browser/migration/passwords.test.js +144 -0
- package/tests/unit/browser/migration/preferences.test.js +77 -0
- package/tests/unit/browser/migration/sqlite-snapshot.test.js +75 -0
- package/tests/unit/browser/open-in-user-browser.test.js +123 -0
- package/tests/unit/browser/parity.test.js +281 -0
- package/tests/unit/browser/real-browser.test.js +458 -33
- package/tests/unit/browser/webdriver.test.js +473 -0
- package/tests/unit/cli/bin.test.js +69 -0
- package/tests/unit/cli/declared-api.test.js +49 -0
- package/tests/unit/cli/dispatcher.test.js +429 -0
- package/tests/unit/cli/handles.test.js +170 -0
- package/tests/unit/cli/main.test.js +409 -0
- package/tests/unit/cli/serve.test.js +167 -0
- package/tests/unit/core/webdriver-conversions.test.js +235 -0
- package/tests/unit/core/webdriver-page.test.js +534 -0
- package/tests/unit/fingerprint/automation-parity.test.js +17 -4
- package/tests/unit/fingerprint/limitations.test.js +1 -1
- package/tests/unit/parity/harness.test.js +138 -0
- package/tests/unit/scripts/ci-timeout-budgets.test.js +4 -0
- package/tests/unit/scripts/feature-parity.test.js +221 -0
- package/tests/unit/traces/reader.test.js +1 -0
- package/tests/unit/traces/recorder.test.js +1 -0
- package/tests/unit/utilities/command-stream-only.test.js +28 -0
- package/tests/unit/utilities/subprocess.test.js +80 -0
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Measure how far a browser Browser Commander drives is from the same browser
|
|
3
|
+
* started by hand (issue #103).
|
|
4
|
+
*
|
|
5
|
+
* Three things are compared, because each can give automation away on its
|
|
6
|
+
* own:
|
|
7
|
+
* - the command line, as the browser itself reports it on chrome://version,
|
|
8
|
+
* against the one a person would type (`--user-data-dir` and, headless,
|
|
9
|
+
* `--headless=new`);
|
|
10
|
+
* - the feature state the command line sets (`--enable-features`,
|
|
11
|
+
* `--disable-features`, `--disable-blink-features`, ...);
|
|
12
|
+
* - everything a page can read, through the environment probe that also backs
|
|
13
|
+
* the parity e2e suite.
|
|
14
|
+
*
|
|
15
|
+
* Every difference is either explained by an entry in the shared limitations
|
|
16
|
+
* catalogue, explained by an option the caller asked for, or unlisted. A
|
|
17
|
+
* report with unlisted differences is not `ok`; `browser-commander doctor`
|
|
18
|
+
* exits non-zero on it and CI fails.
|
|
19
|
+
*/
|
|
20
|
+
import { randomUUID } from 'node:crypto';
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
captureReferenceReport,
|
|
24
|
+
diffReports,
|
|
25
|
+
readProbeSource,
|
|
26
|
+
startProbeServer,
|
|
27
|
+
} from '../parity/harness.js';
|
|
28
|
+
import { readReferenceVersionPage } from '../parity/version-page.js';
|
|
29
|
+
import { findFingerprintLimitation } from '../fingerprint/limitations.js';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Switches whose value is different on every launch by construction. Their
|
|
33
|
+
* presence is compared; their value is not.
|
|
34
|
+
*/
|
|
35
|
+
const VOLATILE_SWITCHES = new Set(['--user-data-dir']);
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Markers Chrome itself writes around the switches it took from chrome://flags.
|
|
39
|
+
* A hand-started browser shows them too.
|
|
40
|
+
*/
|
|
41
|
+
const CHROME_MARKERS = new Set([
|
|
42
|
+
'--flag-switches-begin',
|
|
43
|
+
'--flag-switches-end',
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
/** Switches that carry a comma-separated feature list. */
|
|
47
|
+
const FEATURE_SWITCHES = new Set([
|
|
48
|
+
'--enable-features',
|
|
49
|
+
'--disable-features',
|
|
50
|
+
'--enable-blink-features',
|
|
51
|
+
'--disable-blink-features',
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The debugging port is how the library attaches. Issue #101 measured that a
|
|
56
|
+
* fixed non-zero port changes nothing a page can observe, so it is reported
|
|
57
|
+
* as the attachment rather than as a difference.
|
|
58
|
+
*/
|
|
59
|
+
const ATTACHMENT_SWITCHES = new Set(['--remote-debugging-port']);
|
|
60
|
+
|
|
61
|
+
const URL_WORD = /^[a-z][a-z0-9+.-]*:/iu;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Regroup whitespace-separated words into switches: a word starting with `--`
|
|
65
|
+
* starts a switch, any other word continues the previous one's value. The
|
|
66
|
+
* start URL, when there is one, is the last word and is dropped.
|
|
67
|
+
*/
|
|
68
|
+
function joinSwitchWords(words) {
|
|
69
|
+
const last = words.at(-1) ?? '';
|
|
70
|
+
if (words.length > 1 && !last.startsWith('--') && URL_WORD.test(last)) {
|
|
71
|
+
words.pop();
|
|
72
|
+
}
|
|
73
|
+
const tokens = [];
|
|
74
|
+
for (const word of words) {
|
|
75
|
+
if (word.startsWith('--') || tokens.length === 0) {
|
|
76
|
+
tokens.push(word);
|
|
77
|
+
} else {
|
|
78
|
+
tokens[tokens.length - 1] += ` ${word}`;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return tokens;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Split a command line into `--switch[=value]` entries.
|
|
86
|
+
*
|
|
87
|
+
* chrome://version prints the command line joined with spaces, so a value
|
|
88
|
+
* containing a space (a profile path, say) cannot be recovered exactly. Every
|
|
89
|
+
* `--` that follows whitespace starts a new switch; the executable and any
|
|
90
|
+
* trailing URL are not switches and are dropped.
|
|
91
|
+
*
|
|
92
|
+
* @param {string|string[]} commandLine
|
|
93
|
+
* @returns {Map<string, string|null>} switch name to value (`null` for a flag)
|
|
94
|
+
*/
|
|
95
|
+
export function parseSwitches(commandLine) {
|
|
96
|
+
const tokens = Array.isArray(commandLine)
|
|
97
|
+
? commandLine
|
|
98
|
+
: joinSwitchWords(String(commandLine).trim().split(/\s+/u));
|
|
99
|
+
const switches = new Map();
|
|
100
|
+
for (const token of tokens) {
|
|
101
|
+
if (!token.startsWith('--')) {
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const separator = token.indexOf('=');
|
|
105
|
+
if (separator === -1) {
|
|
106
|
+
switches.set(token, null);
|
|
107
|
+
} else {
|
|
108
|
+
switches.set(token.slice(0, separator), token.slice(separator + 1));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return switches;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function featureList(value) {
|
|
115
|
+
return String(value ?? '')
|
|
116
|
+
.split(',')
|
|
117
|
+
.map((entry) => entry.trim())
|
|
118
|
+
.filter(Boolean)
|
|
119
|
+
.sort();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Compare the command line the browser reports with the reference one.
|
|
124
|
+
*
|
|
125
|
+
* @param {string[]} reference - Arguments the hand-started browser received
|
|
126
|
+
* @param {string|string[]} candidate - chrome://version command line
|
|
127
|
+
* @returns {{extra: string[], missing: string[], changed: Object[], attachment: string[], features: Object}}
|
|
128
|
+
*/
|
|
129
|
+
export function compareCommandLines(reference, candidate) {
|
|
130
|
+
const left = parseSwitches(reference);
|
|
131
|
+
const right = parseSwitches(candidate);
|
|
132
|
+
const extra = [];
|
|
133
|
+
const missing = [];
|
|
134
|
+
const changed = [];
|
|
135
|
+
const attachment = [];
|
|
136
|
+
const features = {};
|
|
137
|
+
const names = new Set([...left.keys(), ...right.keys()]);
|
|
138
|
+
for (const name of [...names].sort()) {
|
|
139
|
+
if (CHROME_MARKERS.has(name)) {
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (ATTACHMENT_SWITCHES.has(name)) {
|
|
143
|
+
if (right.has(name)) {
|
|
144
|
+
attachment.push(`${name}=${right.get(name)}`);
|
|
145
|
+
}
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const format = (map) =>
|
|
149
|
+
map.get(name) === null ? name : `${name}=${map.get(name)}`;
|
|
150
|
+
if (!left.has(name)) {
|
|
151
|
+
extra.push(format(right));
|
|
152
|
+
} else if (!right.has(name)) {
|
|
153
|
+
missing.push(format(left));
|
|
154
|
+
} else if (
|
|
155
|
+
!VOLATILE_SWITCHES.has(name) &&
|
|
156
|
+
left.get(name) !== right.get(name)
|
|
157
|
+
) {
|
|
158
|
+
changed.push({
|
|
159
|
+
name,
|
|
160
|
+
reference: left.get(name),
|
|
161
|
+
candidate: right.get(name),
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
if (FEATURE_SWITCHES.has(name)) {
|
|
165
|
+
features[name] = {
|
|
166
|
+
reference: featureList(left.get(name)),
|
|
167
|
+
candidate: featureList(right.get(name)),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return { extra, missing, changed, attachment, features };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function commandLineDifferences(comparison) {
|
|
175
|
+
return [
|
|
176
|
+
...comparison.extra.map((entry) => ({
|
|
177
|
+
path: `commandLine.extra.${entry.split('=')[0]}`,
|
|
178
|
+
reference: null,
|
|
179
|
+
candidate: entry,
|
|
180
|
+
})),
|
|
181
|
+
...comparison.missing.map((entry) => ({
|
|
182
|
+
path: `commandLine.missing.${entry.split('=')[0]}`,
|
|
183
|
+
reference: entry,
|
|
184
|
+
candidate: null,
|
|
185
|
+
})),
|
|
186
|
+
...comparison.changed.map(({ name, reference, candidate }) => ({
|
|
187
|
+
path: `commandLine.changed.${name}`,
|
|
188
|
+
reference,
|
|
189
|
+
candidate,
|
|
190
|
+
})),
|
|
191
|
+
];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Switches that change which Blink features and field trials are active. A
|
|
195
|
+
// non-Chrome-branded build (Chromium) applies its testing field-trial config
|
|
196
|
+
// unless told otherwise, so Playwright's --disable-field-trial-config alone
|
|
197
|
+
// changes the API surface (the Protected Audience methods on navigator) and
|
|
198
|
+
// the language list on Chromium, while Chrome and Edge are unaffected.
|
|
199
|
+
const FEATURE_CONFIG_SWITCHES = new Set([
|
|
200
|
+
'--disable-field-trial-config',
|
|
201
|
+
'--disable-features',
|
|
202
|
+
'--enable-features',
|
|
203
|
+
]);
|
|
204
|
+
|
|
205
|
+
// Surfaces those switches are measured to move: which properties an object
|
|
206
|
+
// exposes, and the reduced or full Accept-Language list.
|
|
207
|
+
const FEATURE_CONFIG_SURFACES = /(?:^|\.)(?:keys|languages)$/u;
|
|
208
|
+
|
|
209
|
+
function changesFeatureConfig(extraSwitches = []) {
|
|
210
|
+
return extraSwitches.some((entry) =>
|
|
211
|
+
FEATURE_CONFIG_SWITCHES.has(entry.split('=')[0])
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Explain a difference: a limitation id, `requested` for a switch the caller
|
|
217
|
+
* asked for (or that follows from an option they set), or `null`.
|
|
218
|
+
*/
|
|
219
|
+
export function explainDifference(difference, context) {
|
|
220
|
+
const { path } = difference;
|
|
221
|
+
if (path.startsWith('commandLine.')) {
|
|
222
|
+
const value = String(difference.candidate ?? difference.reference ?? '');
|
|
223
|
+
if (context.requestedArgs.some((arg) => arg === value)) {
|
|
224
|
+
return { requested: true };
|
|
225
|
+
}
|
|
226
|
+
if (context.launch === 'engine') {
|
|
227
|
+
return { limitation: 'engine-launch-switches' };
|
|
228
|
+
}
|
|
229
|
+
return {};
|
|
230
|
+
}
|
|
231
|
+
if (
|
|
232
|
+
context.launch === 'engine' &&
|
|
233
|
+
FEATURE_CONFIG_SURFACES.test(path) &&
|
|
234
|
+
changesFeatureConfig(context.extraSwitches)
|
|
235
|
+
) {
|
|
236
|
+
return { limitation: 'engine-launch-switches' };
|
|
237
|
+
}
|
|
238
|
+
if (path.startsWith('navigator.userAgentData.brands')) {
|
|
239
|
+
return { limitation: 'grease-brand-not-reproduced' };
|
|
240
|
+
}
|
|
241
|
+
if (path === 'navigator.webdriver' && context.attached) {
|
|
242
|
+
return { limitation: 'automation-controlled-is-launch-only' };
|
|
243
|
+
}
|
|
244
|
+
return {};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Tag every difference with its explanation and split out the unlisted ones.
|
|
249
|
+
*
|
|
250
|
+
* @param {Object[]} differences - `{path, reference, candidate}` entries
|
|
251
|
+
* @param {Object} context
|
|
252
|
+
* @returns {{differences: Object[], unlisted: Object[]}}
|
|
253
|
+
*/
|
|
254
|
+
export function classifyDifferences(differences, context) {
|
|
255
|
+
const tagged = differences.map((difference) => {
|
|
256
|
+
const explanation = explainDifference(difference, context);
|
|
257
|
+
const limitation =
|
|
258
|
+
explanation.limitation &&
|
|
259
|
+
findFingerprintLimitation(explanation.limitation)
|
|
260
|
+
? explanation.limitation
|
|
261
|
+
: null;
|
|
262
|
+
return {
|
|
263
|
+
path: difference.path,
|
|
264
|
+
expected: difference.reference ?? null,
|
|
265
|
+
actual: difference.candidate ?? null,
|
|
266
|
+
limitation,
|
|
267
|
+
requested: explanation.requested === true,
|
|
268
|
+
};
|
|
269
|
+
});
|
|
270
|
+
return {
|
|
271
|
+
differences: tagged,
|
|
272
|
+
unlisted: tagged.filter((entry) => !entry.limitation && !entry.requested),
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Read the browser's own view of itself from chrome://version in a new tab,
|
|
278
|
+
* so the page under measurement is left where it was.
|
|
279
|
+
*
|
|
280
|
+
* @param {Object} page - A Playwright or Puppeteer page of the browser
|
|
281
|
+
* @returns {Promise<{commandLine: string, version: string, executablePath: string}>}
|
|
282
|
+
*/
|
|
283
|
+
export async function readBrowserVersionPage(page) {
|
|
284
|
+
const opener =
|
|
285
|
+
typeof page.context === 'function' ? page.context() : page.browser();
|
|
286
|
+
const versionPage = await opener.newPage();
|
|
287
|
+
try {
|
|
288
|
+
await versionPage.goto('chrome://version');
|
|
289
|
+
return await versionPage.evaluate(() => {
|
|
290
|
+
const text = (id) =>
|
|
291
|
+
(document.getElementById(id)?.textContent ?? '').trim();
|
|
292
|
+
return {
|
|
293
|
+
commandLine: text('command_line'),
|
|
294
|
+
version: text('version'),
|
|
295
|
+
executablePath: text('executable_path'),
|
|
296
|
+
};
|
|
297
|
+
});
|
|
298
|
+
} finally {
|
|
299
|
+
await versionPage.close().catch(() => {});
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function captureCandidate(session, server, readVersionPage) {
|
|
304
|
+
const token = randomUUID();
|
|
305
|
+
await session.page.goto(server.url(token), { waitUntil: 'load' });
|
|
306
|
+
const report = await server.waitForReport(token);
|
|
307
|
+
const versionPage = await readVersionPage(session.page);
|
|
308
|
+
return { report, versionPage };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function normalizeWhitespace(text) {
|
|
312
|
+
return String(text ?? '')
|
|
313
|
+
.replace(/\s+/gu, ' ')
|
|
314
|
+
.trim();
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function requestedArgsFor(session, options) {
|
|
318
|
+
const requested = [...(options.args ?? []), ...(options.extraArgs ?? [])];
|
|
319
|
+
const launchedArgs = session.args ?? [];
|
|
320
|
+
if ((options.restrictions ?? []).length > 0) {
|
|
321
|
+
requested.push(
|
|
322
|
+
...launchedArgs.filter(
|
|
323
|
+
(arg) =>
|
|
324
|
+
!arg.startsWith('--user-data-dir') &&
|
|
325
|
+
!arg.startsWith('--remote-debugging-port')
|
|
326
|
+
)
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
return requested;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Measure a launched (or about-to-be-launched) browser against the same
|
|
334
|
+
* binary started by hand.
|
|
335
|
+
*
|
|
336
|
+
* @param {Object} [options] - Launch options for {@link launchBrowser} (engine,
|
|
337
|
+
* channel, executablePath, headless, launch, restrictions, args, ...)
|
|
338
|
+
* @param {Object} [options.session] - Measure this already-launched session
|
|
339
|
+
* (the result of `launchBrowser`/`launchRealBrowser`) instead of launching
|
|
340
|
+
* @param {boolean} [options.attached=false] - The session was started by
|
|
341
|
+
* somebody else and only connected to
|
|
342
|
+
* @param {Object} [dependencies] - Test seams
|
|
343
|
+
* @returns {Promise<Object>} The parity report described in docs/cli-and-bridge.md
|
|
344
|
+
*/
|
|
345
|
+
export async function measureParity(options = {}, dependencies = {}) {
|
|
346
|
+
const {
|
|
347
|
+
session: providedSession,
|
|
348
|
+
attached = false,
|
|
349
|
+
headless = false,
|
|
350
|
+
...launchOptions
|
|
351
|
+
} = options;
|
|
352
|
+
const {
|
|
353
|
+
launchBrowser = (await import('./launcher.js')).launchBrowser,
|
|
354
|
+
resolveLaunchExecutable = (await import('./launcher.js'))
|
|
355
|
+
.resolveLaunchExecutable,
|
|
356
|
+
startServer = startProbeServer,
|
|
357
|
+
readProbe = readProbeSource,
|
|
358
|
+
captureReference = captureReferenceReport,
|
|
359
|
+
readVersionPage = readBrowserVersionPage,
|
|
360
|
+
readReferenceVersion = readReferenceVersionPage,
|
|
361
|
+
} = dependencies;
|
|
362
|
+
|
|
363
|
+
const engine = launchOptions.engine ?? 'playwright';
|
|
364
|
+
const executablePath =
|
|
365
|
+
providedSession?.executablePath ??
|
|
366
|
+
(await resolveLaunchExecutable({
|
|
367
|
+
engine,
|
|
368
|
+
channel: launchOptions.channel,
|
|
369
|
+
executablePath: launchOptions.executablePath,
|
|
370
|
+
}));
|
|
371
|
+
const server = await startServer(await readProbe());
|
|
372
|
+
try {
|
|
373
|
+
const reference = await captureReference({
|
|
374
|
+
executablePath,
|
|
375
|
+
server,
|
|
376
|
+
token: randomUUID(),
|
|
377
|
+
headless,
|
|
378
|
+
});
|
|
379
|
+
// The browser appends switches of its own (`--ozone-platform=...`, and
|
|
380
|
+
// several more headless), so the reference command line is what the same
|
|
381
|
+
// binary reports for the argv a person types, not that argv.
|
|
382
|
+
const referenceVersionPage = await readReferenceVersion({
|
|
383
|
+
executablePath,
|
|
384
|
+
headless,
|
|
385
|
+
});
|
|
386
|
+
const session =
|
|
387
|
+
providedSession ??
|
|
388
|
+
(await launchBrowser({
|
|
389
|
+
...launchOptions,
|
|
390
|
+
engine,
|
|
391
|
+
executablePath,
|
|
392
|
+
headless,
|
|
393
|
+
}));
|
|
394
|
+
let candidate;
|
|
395
|
+
try {
|
|
396
|
+
candidate = await captureCandidate(session, server, readVersionPage);
|
|
397
|
+
} finally {
|
|
398
|
+
if (!providedSession) {
|
|
399
|
+
await session.browser.close();
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
const launch = session.launch ?? launchOptions.launch ?? 'real';
|
|
403
|
+
const commandLine = compareCommandLines(
|
|
404
|
+
referenceVersionPage.commandLine,
|
|
405
|
+
candidate.versionPage.commandLine
|
|
406
|
+
);
|
|
407
|
+
const { differences, unlisted } = classifyDifferences(
|
|
408
|
+
[
|
|
409
|
+
...commandLineDifferences(commandLine),
|
|
410
|
+
...diffReports(reference, candidate.report),
|
|
411
|
+
],
|
|
412
|
+
{
|
|
413
|
+
launch,
|
|
414
|
+
attached,
|
|
415
|
+
extraSwitches: commandLine.extra,
|
|
416
|
+
requestedArgs: requestedArgsFor(session, { ...options, headless }),
|
|
417
|
+
}
|
|
418
|
+
);
|
|
419
|
+
return {
|
|
420
|
+
browser: {
|
|
421
|
+
executablePath,
|
|
422
|
+
version: normalizeWhitespace(candidate.versionPage.version),
|
|
423
|
+
engine,
|
|
424
|
+
launch,
|
|
425
|
+
headless,
|
|
426
|
+
},
|
|
427
|
+
commandLine: {
|
|
428
|
+
launched: candidate.versionPage.commandLine,
|
|
429
|
+
reference: referenceVersionPage.commandLine,
|
|
430
|
+
...commandLine,
|
|
431
|
+
},
|
|
432
|
+
differences,
|
|
433
|
+
unlisted,
|
|
434
|
+
ok: unlisted.length === 0,
|
|
435
|
+
};
|
|
436
|
+
} finally {
|
|
437
|
+
await server.close();
|
|
438
|
+
}
|
|
439
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Chrome skips its first-run experience when this file exists in the user
|
|
7
|
+
* data directory; it is what Chrome itself writes after the first run.
|
|
8
|
+
* Writing it keeps `--no-first-run` off the command line (issue #103), and it
|
|
9
|
+
* is required for a headful launch at all: without it the first-run dialog
|
|
10
|
+
* holds startup and the DevTools endpoint never appears (measured with Chrome
|
|
11
|
+
* 153, experiments/issue-105/headful-first-run.mjs).
|
|
12
|
+
*/
|
|
13
|
+
export const FIRST_RUN_SENTINEL = 'First Run';
|
|
14
|
+
|
|
15
|
+
/** Chrome's profile-wide settings file, next to the profile directories. */
|
|
16
|
+
export const LOCAL_STATE_FILE = 'Local State';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Local State written into a brand-new user data directory.
|
|
20
|
+
*
|
|
21
|
+
* With only the First Run sentinel, Chrome treats a new directory like a
|
|
22
|
+
* browser that was just updated and opens a "What's new" tab next to the New
|
|
23
|
+
* Tab page, a moment after startup (measured with Chrome 153,
|
|
24
|
+
* experiments/issue-105/whats-new-local-state.mjs). That second tab takes the
|
|
25
|
+
* foreground after the engine has attached, so the tab being driven would
|
|
26
|
+
* report `document.hidden === true`. Chrome records the milestone it last
|
|
27
|
+
* showed What's New for in `browser.last_whats_new_version` and skips the tab
|
|
28
|
+
* when it is not older than the running version; a milestone no release has
|
|
29
|
+
* reached keeps the tab closed without asking the binary for its version.
|
|
30
|
+
*
|
|
31
|
+
* Microsoft Edge ignores both and opens its own first-run tab,
|
|
32
|
+
* `edge://welcome-edge/`, which takes the foreground the same way; it is
|
|
33
|
+
* skipped once Edge has recorded `fre.has_user_seen_fre` (measured with Edge
|
|
34
|
+
* 153, experiments/issue-103/edge-first-run.sh). Chrome ignores the key.
|
|
35
|
+
*/
|
|
36
|
+
export const INITIAL_LOCAL_STATE = Object.freeze({
|
|
37
|
+
browser: Object.freeze({ last_whats_new_version: 9999 }),
|
|
38
|
+
fre: Object.freeze({ has_user_seen_fre: true }),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/** Prefix of the fresh profiles Browser Commander creates and deletes. */
|
|
42
|
+
export const TEMPORARY_PROFILE_PREFIX = 'browser-commander-profile-';
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Make sure a user data directory exists and has the First Run sentinel.
|
|
46
|
+
*
|
|
47
|
+
* An existing sentinel is left alone, so a profile Chrome already used keeps
|
|
48
|
+
* its timestamp, and Local State is only written when Chrome has not written
|
|
49
|
+
* one yet.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} userDataDir
|
|
52
|
+
* @returns {Promise<string>} The same directory
|
|
53
|
+
*/
|
|
54
|
+
export async function prepareUserDataDir(userDataDir) {
|
|
55
|
+
await mkdir(userDataDir, { recursive: true });
|
|
56
|
+
await writeFile(path.join(userDataDir, FIRST_RUN_SENTINEL), '', {
|
|
57
|
+
flag: 'a',
|
|
58
|
+
});
|
|
59
|
+
try {
|
|
60
|
+
await writeFile(
|
|
61
|
+
path.join(userDataDir, LOCAL_STATE_FILE),
|
|
62
|
+
JSON.stringify(INITIAL_LOCAL_STATE),
|
|
63
|
+
{ flag: 'wx' }
|
|
64
|
+
);
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (error.code !== 'EEXIST') {
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return userDataDir;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Create a fresh, empty profile for one launch.
|
|
75
|
+
*
|
|
76
|
+
* @param {Object} [options]
|
|
77
|
+
* @param {string} [options.parent=os.tmpdir()] - Directory to create it in
|
|
78
|
+
* @returns {Promise<string>} Path of the new user data directory
|
|
79
|
+
*/
|
|
80
|
+
export async function createTemporaryUserDataDir({
|
|
81
|
+
parent = os.tmpdir(),
|
|
82
|
+
} = {}) {
|
|
83
|
+
const userDataDir = await mkdtemp(
|
|
84
|
+
path.join(parent, TEMPORARY_PROFILE_PREFIX)
|
|
85
|
+
);
|
|
86
|
+
return await prepareUserDataDir(userDataDir);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Delete a temporary profile. Chrome can still be flushing files for a moment
|
|
91
|
+
* after its process exits, so removal is retried.
|
|
92
|
+
*
|
|
93
|
+
* @param {string} userDataDir
|
|
94
|
+
*/
|
|
95
|
+
export async function removeUserDataDir(userDataDir) {
|
|
96
|
+
await rm(userDataDir, {
|
|
97
|
+
recursive: true,
|
|
98
|
+
force: true,
|
|
99
|
+
maxRetries: 5,
|
|
100
|
+
retryDelay: 200,
|
|
101
|
+
});
|
|
102
|
+
}
|