browser-commander 0.19.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.
Files changed (155) hide show
  1. package/CHANGELOG.md +156 -0
  2. package/README.md +216 -32
  3. package/bin/browser-commander.js +16 -0
  4. package/eslint.config.js +10 -0
  5. package/examples/react-test-app/src/App.jsx +24 -23
  6. package/extension/background.js +14 -0
  7. package/extension/manifest.json +12 -0
  8. package/extension/relay-handler.js +353 -0
  9. package/package.json +25 -6
  10. package/scripts/run-tests.mjs +4 -2
  11. package/src/browser/attach/differences.js +131 -0
  12. package/src/browser/attach/extension-relay.js +402 -0
  13. package/src/browser/attach/index.js +81 -0
  14. package/src/browser/attach/snapshot-launch.js +146 -0
  15. package/src/browser/attach/snapshot.js +374 -0
  16. package/src/browser/attach/websocket.js +615 -0
  17. package/src/browser/browser-cookie-cache.js +12 -17
  18. package/src/browser/browser-cookie-credentials.js +6 -9
  19. package/src/browser/browser-cookies.js +5 -2
  20. package/src/browser/cdp-session.js +94 -0
  21. package/src/browser/connector.js +42 -4
  22. package/src/browser/debugging-port.js +155 -0
  23. package/src/browser/launch-options.js +63 -52
  24. package/src/browser/launch-restrictions.json +147 -0
  25. package/src/browser/launcher.js +329 -133
  26. package/src/browser/media.js +5 -0
  27. package/src/browser/migration/bookmarks.js +73 -0
  28. package/src/browser/migration/chromium-crypto.js +89 -0
  29. package/src/browser/migration/cookies.js +155 -0
  30. package/src/browser/migration/extensions.js +187 -0
  31. package/src/browser/migration/firefox-bookmarks.js +144 -0
  32. package/src/browser/migration/firefox-der.js +84 -0
  33. package/src/browser/migration/firefox-nss.js +281 -0
  34. package/src/browser/migration/firefox.js +334 -0
  35. package/src/browser/migration/fs-utils.js +51 -0
  36. package/src/browser/migration/history.js +103 -0
  37. package/src/browser/migration/index.js +307 -0
  38. package/src/browser/migration/os-crypt-keys.js +106 -0
  39. package/src/browser/migration/passwords.js +171 -0
  40. package/src/browser/migration/preferences.js +132 -0
  41. package/src/browser/migration/sqlite-snapshot.js +138 -0
  42. package/src/browser/open-in-user-browser.js +112 -0
  43. package/src/browser/parity.js +439 -0
  44. package/src/browser/profile-directory.js +102 -0
  45. package/src/browser/real-browser.js +513 -306
  46. package/src/browser/restrictions.js +148 -0
  47. package/src/browser/system-browser.js +232 -0
  48. package/src/browser/webdriver.js +572 -0
  49. package/src/cli/args.js +253 -0
  50. package/src/cli/commands.js +332 -0
  51. package/src/cli/dispatcher.js +99 -0
  52. package/src/cli/handle-methods.js +136 -0
  53. package/src/cli/handles.js +273 -0
  54. package/src/cli/high-level-methods.js +310 -0
  55. package/src/cli/main.js +67 -0
  56. package/src/cli/modules.js +100 -0
  57. package/src/cli/rpc-error.js +77 -0
  58. package/src/cli/script.js +77 -0
  59. package/src/cli/serve.js +144 -0
  60. package/src/cli/sessions.js +195 -0
  61. package/src/core/constants.js +7 -1
  62. package/src/core/engine-adapter.js +107 -1
  63. package/src/core/engine-detection.js +18 -3
  64. package/src/core/navigation-safety.js +4 -0
  65. package/src/core/webdriver-conversions.js +347 -0
  66. package/src/core/webdriver-events.js +229 -0
  67. package/src/core/webdriver-page.js +716 -0
  68. package/src/downloads/attach.js +6 -0
  69. package/src/downloads/manager.js +9 -0
  70. package/src/downloads/sources.js +71 -0
  71. package/src/downloads/staging.js +21 -9
  72. package/src/elements/selectors.js +3 -2
  73. package/src/elements/visibility.js +4 -0
  74. package/src/exports.js +61 -2
  75. package/src/factory.js +19 -4
  76. package/src/fingerprint/apply.js +6 -0
  77. package/src/fingerprint/automation-parity.js +7 -8
  78. package/src/fingerprint/limitations.js +1 -1
  79. package/src/fingerprint/limitations.json +45 -2
  80. package/src/parity/harness.js +303 -0
  81. package/src/parity/probe.js +838 -0
  82. package/src/parity/version-page.js +144 -0
  83. package/src/traces/bundle.js +10 -2
  84. package/src/traces/recorder.js +16 -3
  85. package/src/utilities/subprocess.js +199 -0
  86. package/tests/e2e/api-coverage.e2e.test.js +174 -0
  87. package/tests/e2e/cli.e2e.test.js +160 -0
  88. package/tests/e2e/downloads.e2e.test.js +79 -2
  89. package/tests/e2e/fingerprint-parity.e2e.test.js +104 -46
  90. package/tests/e2e/google-signin-probe.e2e.test.js +112 -0
  91. package/tests/e2e/measure-parity.e2e.test.js +118 -0
  92. package/tests/e2e/playwright.e2e.test.js +25 -17
  93. package/tests/e2e/puppeteer.e2e.test.js +17 -23
  94. package/tests/e2e/webdriver.e2e.test.js +239 -0
  95. package/tests/fixtures/firefox-nss-fixtures.mjs +211 -0
  96. package/tests/helpers/cli-fakes.js +214 -0
  97. package/tests/helpers/declared-api.js +80 -0
  98. package/tests/helpers/e2e-browser.js +40 -14
  99. package/tests/helpers/fake-chrome.js +124 -0
  100. package/tests/helpers/migration-fixtures.js +242 -0
  101. package/tests/helpers/temp-directory.js +24 -0
  102. package/tests/helpers/trace-fixtures.js +18 -0
  103. package/tests/helpers/webdriver-mocks.js +183 -0
  104. package/tests/unit/browser/attach/extension-relay.test.js +360 -0
  105. package/tests/unit/browser/attach/snapshot-launch.test.js +312 -0
  106. package/tests/unit/browser/attach/snapshot.test.js +390 -0
  107. package/tests/unit/browser/attach/websocket.test.js +240 -0
  108. package/tests/unit/browser/browser-cookies.test.js +2 -32
  109. package/tests/unit/browser/cdp-session.test.js +162 -0
  110. package/tests/unit/browser/connector.test.js +23 -0
  111. package/tests/unit/browser/launch-building-blocks.test.js +238 -0
  112. package/tests/unit/browser/launch-options.test.js +41 -22
  113. package/tests/unit/browser/launcher.test.js +281 -102
  114. package/tests/unit/browser/media.test.js +13 -1
  115. package/tests/unit/browser/migration/bookmarks.test.js +69 -0
  116. package/tests/unit/browser/migration/chromium-crypto.test.js +94 -0
  117. package/tests/unit/browser/migration/cookies.test.js +94 -0
  118. package/tests/unit/browser/migration/extensions.test.js +104 -0
  119. package/tests/unit/browser/migration/firefox-nss.test.js +113 -0
  120. package/tests/unit/browser/migration/firefox.test.js +139 -0
  121. package/tests/unit/browser/migration/history.test.js +48 -0
  122. package/tests/unit/browser/migration/index.test.js +117 -0
  123. package/tests/unit/browser/migration/os-crypt-keys.test.js +95 -0
  124. package/tests/unit/browser/migration/passwords.test.js +144 -0
  125. package/tests/unit/browser/migration/preferences.test.js +77 -0
  126. package/tests/unit/browser/migration/sqlite-snapshot.test.js +75 -0
  127. package/tests/unit/browser/open-in-user-browser.test.js +123 -0
  128. package/tests/unit/browser/parity.test.js +281 -0
  129. package/tests/unit/browser/real-browser.test.js +458 -33
  130. package/tests/unit/browser/webdriver.test.js +473 -0
  131. package/tests/unit/cli/bin.test.js +69 -0
  132. package/tests/unit/cli/declared-api.test.js +49 -0
  133. package/tests/unit/cli/dispatcher.test.js +429 -0
  134. package/tests/unit/cli/handles.test.js +170 -0
  135. package/tests/unit/cli/main.test.js +409 -0
  136. package/tests/unit/cli/serve.test.js +167 -0
  137. package/tests/unit/core/webdriver-conversions.test.js +235 -0
  138. package/tests/unit/core/webdriver-page.test.js +534 -0
  139. package/tests/unit/downloads/manager.test.js +82 -9
  140. package/tests/unit/downloads/staging.test.js +24 -2
  141. package/tests/unit/fingerprint/automation-parity.test.js +17 -4
  142. package/tests/unit/fingerprint/limitations.test.js +1 -1
  143. package/tests/unit/parity/harness.test.js +138 -0
  144. package/tests/unit/scripts/ci-timeout-budgets.test.js +4 -0
  145. package/tests/unit/scripts/ci-workflow-policy.test.js +8 -0
  146. package/tests/unit/scripts/feature-parity.test.js +221 -0
  147. package/tests/unit/scripts/npm-release-helpers.test.js +3 -2
  148. package/tests/unit/scripts/pre-commit-config.test.js +2 -1
  149. package/tests/unit/traces/bundle.test.js +24 -3
  150. package/tests/unit/traces/links.test.js +7 -0
  151. package/tests/unit/traces/reader.test.js +11 -4
  152. package/tests/unit/traces/recorder.test.js +4 -0
  153. package/tests/unit/traces/viewer.test.js +4 -0
  154. package/tests/unit/utilities/command-stream-only.test.js +28 -0
  155. 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
+ }