browser-commander 0.21.0 → 0.21.2

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 (53) hide show
  1. package/.jscpd.json +2 -1
  2. package/.prettierignore +1 -0
  3. package/CHANGELOG.md +20 -0
  4. package/package.json +6 -3
  5. package/src/browser/attach/snapshot.js +4 -4
  6. package/src/browser/browser-cookie-cache.js +14 -2
  7. package/src/browser/profile-directory.js +109 -6
  8. package/src/browser/real-browser.js +54 -16
  9. package/src/cli/args.js +47 -0
  10. package/src/cli/sessions.js +4 -0
  11. package/src/traces/bundle.js +3 -2
  12. package/src/traces/observers.js +14 -5
  13. package/src/traces/recorder.js +3 -0
  14. package/src/traces/viewer.js +16 -8
  15. package/tests/e2e/playwright.e2e.test.js +1 -0
  16. package/tests/e2e/puppeteer.e2e.test.js +1 -0
  17. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0001.html +2 -0
  18. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0001.png +1 -0
  19. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0001.state.json +54 -0
  20. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.html +2 -0
  21. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.png +1 -0
  22. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.state.json +60 -0
  23. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.html +2 -0
  24. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.png +1 -0
  25. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.state.json +60 -0
  26. package/tests/fixtures/traces/conformance/expected/bundle/events.ndjson +18 -0
  27. package/tests/fixtures/traces/conformance/expected/bundle/manifest.json +77 -0
  28. package/tests/fixtures/traces/conformance/expected/bundle/mutations/0001.ndjson +2 -0
  29. package/tests/fixtures/traces/conformance/expected/bundle/mutations/0002.ndjson +1 -0
  30. package/tests/fixtures/traces/conformance/expected/bundle/viewer.html +309 -0
  31. package/tests/fixtures/traces/conformance/expected/redaction.json +86 -0
  32. package/tests/fixtures/traces/conformance/expected/trace.lino +27 -0
  33. package/tests/fixtures/traces/conformance/scenario.json +249 -0
  34. package/tests/unit/browser/attach/extension-relay.test.js +1 -1
  35. package/tests/unit/browser/attach/snapshot-launch.test.js +10 -1
  36. package/tests/unit/browser/attach/snapshot.test.js +23 -4
  37. package/tests/unit/browser/browser-cookie-lock.test.js +102 -0
  38. package/tests/unit/browser/launch-building-blocks.test.js +127 -4
  39. package/tests/unit/browser/migration/index.test.js +1 -1
  40. package/tests/unit/browser/open-in-user-browser.test.js +1 -1
  41. package/tests/unit/browser/parity.test.js +1 -1
  42. package/tests/unit/browser/real-browser.test.js +1 -1
  43. package/tests/unit/browser/storage-state.test.js +2 -0
  44. package/tests/unit/cli/main.test.js +30 -2
  45. package/tests/unit/core/navigation-manager.test.js +11 -1
  46. package/tests/unit/playwright-protocol-coverage.test.js +231 -0
  47. package/tests/unit/puppeteer-bindings-coverage.test.js +214 -0
  48. package/tests/unit/scripts/ci-timeout-budgets.test.js +23 -0
  49. package/tests/unit/scripts/feature-parity.test.js +111 -5
  50. package/tests/unit/traces/conformance.test.js +92 -0
  51. package/tests/unit/traces/reader.test.js +1 -1
  52. package/tests/unit/traces/recorder.test.js +1 -1
  53. package/tests/unit/traces/viewer.test.js +78 -0
package/.jscpd.json CHANGED
@@ -11,7 +11,8 @@
11
11
  "**/.changeset/**",
12
12
  "**/package-lock.json",
13
13
  "**/pnpm-lock.yaml",
14
- "**/yarn.lock"
14
+ "**/yarn.lock",
15
+ "**/tests/fixtures/traces/conformance/**"
15
16
  ],
16
17
  "reporters": ["console", "html"],
17
18
  "output": "./reports/jscpd"
package/.prettierignore CHANGED
@@ -7,3 +7,4 @@ package-lock.json
7
7
  .eslintcache
8
8
  CLAUDE.md
9
9
  reports
10
+ tests/fixtures/traces/conformance/expected
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.21.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 917d611: Keep the real-browser launcher under the ESLint complexity limit, fail `npm run lint` on any warning, and record that `node-pty`'s install script (pulled in by `command-stream`) stays blocked.
8
+
9
+ ## 0.21.1
10
+
11
+ ### Patch Changes
12
+
13
+ - 4caa385: Suppress Chromium's default-browser prompt through profile settings on fresh and snapshot launches, and expose configurable Preferences and Local State values.
14
+
15
+ Update the locked brace-expansion dependency to 5.0.12 to resolve its CPU and stack exhaustion advisories.
16
+
17
+ Retry transient Windows credential-lock EPERM within the existing bounded deadline, preserving the underlying error when contention persists.
18
+
19
+ Accept a `monotonic` clock in `startTrace` and use the injected `now` for interaction durations, so a trace recorded with a fixed clock is byte-for-byte reproducible; the Python and Rust recorders are checked against such a recording.
20
+
21
+ Escape recorded text in the offline trace viewer. A recorded page's URL, title, form control values and event fields were inserted into the viewer as HTML, so a page could run script in the viewer of whoever opened its trace.
22
+
3
23
  ## 0.21.0
4
24
 
5
25
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "browser-commander",
3
- "version": "0.21.0",
3
+ "version": "0.21.2",
4
4
  "description": "Universal browser automation library that supports both Playwright and Puppeteer with a unified API",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -27,7 +27,7 @@
27
27
  "test:e2e:api-coverage": "node scripts/run-tests.mjs --env RUN_E2E=true tests/e2e/api-coverage.e2e.test.js",
28
28
  "test:e2e:google-probe": "node scripts/run-tests.mjs --env RUN_E2E=true --env RUN_GOOGLE_PROBE=true tests/e2e/google-signin-probe.e2e.test.js",
29
29
  "test:all": "npm run test:unit && npm run test:e2e",
30
- "lint": "eslint .",
30
+ "lint": "eslint . --max-warnings 0",
31
31
  "lint:fix": "eslint . --fix",
32
32
  "format": "prettier --write .",
33
33
  "format:check": "prettier --check .",
@@ -97,10 +97,13 @@
97
97
  "playwright": "^1.62.1",
98
98
  "prettier": "^3.9.6",
99
99
  "puppeteer": "^25.10.0",
100
- "selenium-webdriver": "^4.49.0"
100
+ "selenium-webdriver": "^4.49.0",
101
+ "typescript": "^5.9.3",
102
+ "yaml": "^2.9.1"
101
103
  },
102
104
  "allowScripts": {
103
105
  "better-sqlite3@13.0.3": true,
106
+ "node-pty": false,
104
107
  "puppeteer": false
105
108
  }
106
109
  }
@@ -155,9 +155,9 @@ function isInside(child, parent) {
155
155
  );
156
156
  }
157
157
 
158
- async function prepareTarget(to, sourceRoot) {
158
+ async function prepareTarget(to, sourceRoot, profile) {
159
159
  if (!to) {
160
- return await createTemporaryUserDataDir();
160
+ return await createTemporaryUserDataDir({ profileDirectory: profile });
161
161
  }
162
162
  if (isInside(to, sourceRoot)) {
163
163
  throw new Error(
@@ -328,7 +328,7 @@ export async function snapshotUserDataDir({
328
328
  );
329
329
  }
330
330
 
331
- const target = await prepareTarget(to, sourceRoot);
331
+ const target = await prepareTarget(to, sourceRoot, profile);
332
332
  try {
333
333
  return await copyProfile({
334
334
  sourceRoot,
@@ -361,7 +361,7 @@ async function copyProfile({ sourceRoot, profile, target, source }) {
361
361
  });
362
362
  }
363
363
  await copier.copyDirectory(profile);
364
- await prepareUserDataDir(target);
364
+ await prepareUserDataDir(target, { profileDirectory: profile });
365
365
  await markExitedCleanly(path.join(target, profile));
366
366
 
367
367
  return {
@@ -162,13 +162,21 @@ async function wait(milliseconds) {
162
162
 
163
163
  async function acquireCredentialLock(lockPath, cachedPath, options) {
164
164
  const startedAt = options.now();
165
+ let contention;
165
166
  while (options.now() - startedAt <= LOCK_WAIT_MILLISECONDS) {
166
167
  try {
167
168
  return await open(lockPath, 'wx', 0o600);
168
169
  } catch (error) {
169
- if (error.code !== 'EEXIST') {
170
+ // Windows can report EPERM while another process's unlinked lock is
171
+ // still pending deletion. Retry within the same bounded lock deadline.
172
+ // Preserve the original error if it proves to be permanent.
173
+ if (
174
+ error.code !== 'EEXIST' &&
175
+ !(options.platform === 'win32' && error.code === 'EPERM')
176
+ ) {
170
177
  throw error;
171
178
  }
179
+ contention = error;
172
180
  const cached = await readFreshJson(
173
181
  cachedPath,
174
182
  options.ttlSeconds,
@@ -184,7 +192,9 @@ async function acquireCredentialLock(lockPath, cachedPath, options) {
184
192
  await wait(50);
185
193
  }
186
194
  }
187
- throw new Error('timed out waiting for another cookie credential reader');
195
+ throw new Error('timed out waiting for another cookie credential reader', {
196
+ cause: contention,
197
+ });
188
198
  }
189
199
 
190
200
  async function loadOrCreateCredential({
@@ -194,6 +204,7 @@ async function loadOrCreateCredential({
194
204
  create,
195
205
  now,
196
206
  metadata,
207
+ platform = process.platform,
197
208
  }) {
198
209
  if (!cache.enabled) {
199
210
  return { key: Buffer.from(await create()), savedAt: now() / 1000 };
@@ -214,6 +225,7 @@ async function loadOrCreateCredential({
214
225
  now,
215
226
  refresh,
216
227
  ttlSeconds: cache.ttlSeconds,
228
+ platform,
217
229
  });
218
230
  if (acquired.cached) {
219
231
  return {
@@ -1,4 +1,4 @@
1
- import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
1
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
4
 
@@ -14,6 +14,7 @@ export const FIRST_RUN_SENTINEL = 'First Run';
14
14
 
15
15
  /** Chrome's profile-wide settings file, next to the profile directories. */
16
16
  export const LOCAL_STATE_FILE = 'Local State';
17
+ export const PREFERENCES_FILE = path.join('Default', 'Preferences');
17
18
 
18
19
  /**
19
20
  * Local State written into a brand-new user data directory.
@@ -51,11 +52,111 @@ export const TEMPORARY_PROFILE_PREFIX = 'browser-commander-profile-';
51
52
  * @param {string} userDataDir
52
53
  * @returns {Promise<string>} The same directory
53
54
  */
54
- export async function prepareUserDataDir(userDataDir) {
55
+ function assertObject(value, name) {
56
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
57
+ throw new TypeError(`${name} must be a JSON object`);
58
+ }
59
+ }
60
+
61
+ function deepMerge(target, source) {
62
+ for (const [key, value] of Object.entries(source)) {
63
+ if (['__proto__', 'prototype', 'constructor'].includes(key)) {
64
+ throw new TypeError(`profile setting key ${key} is not allowed`);
65
+ }
66
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
67
+ target[key] = deepMerge(
68
+ target[key] &&
69
+ typeof target[key] === 'object' &&
70
+ !Array.isArray(target[key])
71
+ ? target[key]
72
+ : {},
73
+ value
74
+ );
75
+ } else {
76
+ target[key] = value;
77
+ }
78
+ }
79
+ return target;
80
+ }
81
+
82
+ async function mergeJsonFile(file, defaults, overrides) {
83
+ let current = {};
84
+ try {
85
+ current = JSON.parse(await readFile(file, 'utf8'));
86
+ assertObject(current, file);
87
+ } catch (error) {
88
+ if (error.code !== 'ENOENT') {
89
+ throw error;
90
+ }
91
+ }
92
+ const merged = deepMerge(deepMerge(current, defaults), overrides);
93
+ await mkdir(path.dirname(file), { recursive: true });
94
+ await writeFile(file, JSON.stringify(merged));
95
+ }
96
+
97
+ /** Merge launch settings into a fresh, migrated, or snapshotted profile. */
98
+ export async function configureUserDataDir(
99
+ userDataDir,
100
+ {
101
+ defaultBrowserCheck,
102
+ preferences = {},
103
+ localState = {},
104
+ profileDirectory = 'Default',
105
+ } = {}
106
+ ) {
107
+ assertObject(preferences, 'preferences');
108
+ assertObject(localState, 'localState');
109
+ if (
110
+ defaultBrowserCheck !== undefined &&
111
+ typeof defaultBrowserCheck !== 'boolean'
112
+ ) {
113
+ throw new TypeError('defaultBrowserCheck must be a boolean');
114
+ }
115
+ if (
116
+ typeof profileDirectory !== 'string' ||
117
+ !profileDirectory ||
118
+ path.basename(profileDirectory) !== profileDirectory ||
119
+ profileDirectory === '.' ||
120
+ profileDirectory === '..'
121
+ ) {
122
+ throw new TypeError('profileDirectory must be one profile directory name');
123
+ }
124
+ const profilePreferences = deepMerge(
125
+ { browser: { check_default_browser: false } },
126
+ preferences
127
+ );
128
+ assertObject(profilePreferences.browser, 'preferences.browser');
129
+ if (defaultBrowserCheck !== undefined) {
130
+ profilePreferences.browser.check_default_browser = defaultBrowserCheck;
131
+ }
132
+ await mergeJsonFile(
133
+ path.join(userDataDir, profileDirectory, 'Preferences'),
134
+ {},
135
+ profilePreferences
136
+ );
137
+ const check = profilePreferences.browser?.check_default_browser === true;
138
+ await mergeJsonFile(
139
+ path.join(userDataDir, LOCAL_STATE_FILE),
140
+ {
141
+ browser: {
142
+ default_browser_infobar_declined_count: check ? 0 : 5,
143
+ default_browser_declined_count: check ? 0 : 5,
144
+ },
145
+ },
146
+ localState
147
+ );
148
+ }
149
+
150
+ export async function prepareUserDataDir(userDataDir, options = {}) {
151
+ if (options.firstRun !== undefined && typeof options.firstRun !== 'boolean') {
152
+ throw new TypeError('firstRun must be a boolean');
153
+ }
55
154
  await mkdir(userDataDir, { recursive: true });
56
- await writeFile(path.join(userDataDir, FIRST_RUN_SENTINEL), '', {
57
- flag: 'a',
58
- });
155
+ if (options.firstRun !== true) {
156
+ await writeFile(path.join(userDataDir, FIRST_RUN_SENTINEL), '', {
157
+ flag: 'a',
158
+ });
159
+ }
59
160
  try {
60
161
  await writeFile(
61
162
  path.join(userDataDir, LOCAL_STATE_FILE),
@@ -67,6 +168,7 @@ export async function prepareUserDataDir(userDataDir) {
67
168
  throw error;
68
169
  }
69
170
  }
171
+ await configureUserDataDir(userDataDir, options);
70
172
  return userDataDir;
71
173
  }
72
174
 
@@ -79,11 +181,12 @@ export async function prepareUserDataDir(userDataDir) {
79
181
  */
80
182
  export async function createTemporaryUserDataDir({
81
183
  parent = os.tmpdir(),
184
+ ...options
82
185
  } = {}) {
83
186
  const userDataDir = await mkdtemp(
84
187
  path.join(parent, TEMPORARY_PROFILE_PREFIX)
85
188
  );
86
- return await prepareUserDataDir(userDataDir);
189
+ return await prepareUserDataDir(userDataDir, options);
87
190
  }
88
191
 
89
192
  /**
@@ -21,6 +21,7 @@ import {
21
21
  watchDevToolsOutput,
22
22
  } from './debugging-port.js';
23
23
  import {
24
+ configureUserDataDir,
24
25
  createTemporaryUserDataDir,
25
26
  prepareUserDataDir,
26
27
  removeUserDataDir,
@@ -324,6 +325,9 @@ function createCloser({
324
325
  * @param {string[]} [options.restrictions] - Opt-in restrictions from launch-restrictions.json, such as 'no-extensions'
325
326
  * @param {string[]} [options.args] - Additional browser arguments
326
327
  * @param {string[]} [options.extraArgs] - Additional browser arguments appended after args
328
+ * @param {boolean} [options.defaultBrowserCheck=false] - Allow the disposable browser to ask to become the default
329
+ * @param {Object} [options.preferences] - Deep-merged into Default/Preferences before launch
330
+ * @param {Object} [options.localState] - Deep-merged into Local State before launch
327
331
  * @param {Object<string,string>} [options.env] - Extra environment for the browser process only
328
332
  * @param {boolean} [options.automationParity=true] - Add the AutomationControlled off switch when a custom argument turns the feature on
329
333
  * @param {number} [options.startupTimeout=30000] - CDP readiness timeout in milliseconds
@@ -455,6 +459,7 @@ async function prepareProfile({
455
459
  requestedUserDataDir,
456
460
  argOptions,
457
461
  snapshot,
462
+ firstRun,
458
463
  }) {
459
464
  if (attach) {
460
465
  const snapshotLaunch = await prepareSnapshotLaunch({
@@ -469,8 +474,8 @@ async function prepareProfile({
469
474
  }
470
475
  return {
471
476
  userDataDir: requestedUserDataDir
472
- ? await prepareUserDataDir(requestedUserDataDir)
473
- : await createTemporaryUserDataDir(),
477
+ ? await prepareUserDataDir(requestedUserDataDir, { firstRun })
478
+ : await createTemporaryUserDataDir({ firstRun }),
474
479
  };
475
480
  }
476
481
 
@@ -516,6 +521,28 @@ async function runPreLaunchMigration({
516
521
  }
517
522
  }
518
523
 
524
+ function mergeSeedCookies(seedCookies, migratedCookies) {
525
+ return migratedCookies.length > 0
526
+ ? [...(seedCookies ?? []), ...migratedCookies]
527
+ : seedCookies;
528
+ }
529
+
530
+ // Runs a launch step and, if it throws, removes the profile this launch
531
+ // created before rethrowing. A caller-supplied profile is never removed.
532
+ async function removingTemporaryProfileOnError(
533
+ { userDataDir, temporaryProfile },
534
+ step
535
+ ) {
536
+ try {
537
+ return await step();
538
+ } catch (error) {
539
+ if (temporaryProfile) {
540
+ await removeUserDataDir(userDataDir);
541
+ }
542
+ throw error;
543
+ }
544
+ }
545
+
519
546
  export async function launchAndConnectRealBrowserWithDependencies(
520
547
  options = {},
521
548
  dependencies = {}
@@ -541,6 +568,10 @@ export async function launchAndConnectRealBrowserWithDependencies(
541
568
  wsEndpoint,
542
569
  migrateFrom,
543
570
  attach,
571
+ defaultBrowserCheck,
572
+ preferences,
573
+ localState,
574
+ firstRun,
544
575
  ...connectionOptions
545
576
  } = options;
546
577
  const argOptions = {
@@ -573,6 +604,7 @@ export async function launchAndConnectRealBrowserWithDependencies(
573
604
  requestedUserDataDir,
574
605
  argOptions,
575
606
  snapshot: dependencies.snapshotUserDataDir,
607
+ firstRun,
576
608
  });
577
609
  const childEnv = browserEnvironment(restrictions, env);
578
610
 
@@ -584,9 +616,20 @@ export async function launchAndConnectRealBrowserWithDependencies(
584
616
  migrate: dependencies.migrateProfile ?? migrateProfile,
585
617
  });
586
618
 
587
- let launched;
588
- try {
589
- launched = await spawnOnFreePort({
619
+ // Migration and snapshot attach can copy a preference that enables the
620
+ // default-browser infobar, so apply launch settings after either copy.
621
+ const profile = { userDataDir, temporaryProfile };
622
+ await removingTemporaryProfileOnError(profile, () =>
623
+ configureUserDataDir(userDataDir, {
624
+ defaultBrowserCheck,
625
+ preferences,
626
+ localState,
627
+ profileDirectory: attach?.profile ?? 'Default',
628
+ })
629
+ );
630
+
631
+ const launched = await removingTemporaryProfileOnError(profile, () =>
632
+ spawnOnFreePort({
590
633
  requestedPort: remoteDebuggingPort,
591
634
  portAttempts,
592
635
  executablePath,
@@ -596,13 +639,8 @@ export async function launchAndConnectRealBrowserWithDependencies(
596
639
  userDataDir,
597
640
  startupTimeout,
598
641
  dependencies,
599
- });
600
- } catch (error) {
601
- if (temporaryProfile) {
602
- await removeUserDataDir(userDataDir);
603
- }
604
- throw error;
605
- }
642
+ })
643
+ );
606
644
  const { browserProcess, cdpEndpoint: resolvedCdpEndpoint } = launched;
607
645
  if (temporaryProfile) {
608
646
  // The profile goes away with the browser, also when the user closes the
@@ -614,10 +652,10 @@ export async function launchAndConnectRealBrowserWithDependencies(
614
652
 
615
653
  try {
616
654
  const connect = dependencies.connect ?? connectBrowser;
617
- const seedCookies =
618
- migratedCookies.length > 0
619
- ? [...(connectionOptions.seedCookies ?? []), ...migratedCookies]
620
- : connectionOptions.seedCookies;
655
+ const seedCookies = mergeSeedCookies(
656
+ connectionOptions.seedCookies,
657
+ migratedCookies
658
+ );
621
659
  const connection = await connect({
622
660
  engine,
623
661
  cdpEndpoint: resolvedCdpEndpoint,
package/src/cli/args.js CHANGED
@@ -27,6 +27,10 @@ const BROWSER_OPTIONS = Object.freeze({
27
27
  launch: STRING,
28
28
  restriction: STRINGS,
29
29
  arg: STRINGS,
30
+ pref: STRINGS,
31
+ 'local-state': STRINGS,
32
+ 'default-browser-check': FLAG,
33
+ 'first-run': FLAG,
30
34
  });
31
35
 
32
36
  /** Options accepted by every page command. */
@@ -182,6 +186,45 @@ export function requireOption(options, name, command) {
182
186
  * `--restriction` → `restrictions`), with unset options left out.
183
187
  */
184
188
  export function launchParams(options) {
189
+ const settings = (entries, flag) => {
190
+ if (entries === undefined) {
191
+ return undefined;
192
+ }
193
+ const result = {};
194
+ for (const entry of entries) {
195
+ const separator = entry.indexOf('=');
196
+ const path = entry.slice(0, separator).split('.');
197
+ if (
198
+ separator < 1 ||
199
+ path.some(
200
+ (part) =>
201
+ !part || ['__proto__', 'prototype', 'constructor'].includes(part)
202
+ )
203
+ ) {
204
+ throw new UsageError(`${flag} requires a dotted key=value`);
205
+ }
206
+ let value = entry.slice(separator + 1);
207
+ try {
208
+ value = JSON.parse(value);
209
+ } catch {
210
+ // Unquoted CLI text is a string.
211
+ }
212
+ let parent = result;
213
+ for (const part of path.slice(0, -1)) {
214
+ parent[part] ??= {};
215
+ if (
216
+ !parent[part] ||
217
+ typeof parent[part] !== 'object' ||
218
+ Array.isArray(parent[part])
219
+ ) {
220
+ throw new UsageError(`${flag} has a conflicting key: ${entry}`);
221
+ }
222
+ parent = parent[part];
223
+ }
224
+ parent[path.at(-1)] = value;
225
+ }
226
+ return result;
227
+ };
185
228
  const params = {
186
229
  engine: options.engine,
187
230
  browser: options.browser,
@@ -191,6 +234,10 @@ export function launchParams(options) {
191
234
  launch: options.launch,
192
235
  restrictions: options.restriction,
193
236
  args: options.arg,
237
+ preferences: settings(options.pref, '--pref'),
238
+ localState: settings(options.localState, '--local-state'),
239
+ defaultBrowserCheck: options.defaultBrowserCheck,
240
+ firstRun: options.firstRun,
194
241
  };
195
242
  return Object.fromEntries(
196
243
  Object.entries(params).filter(([, value]) => value !== undefined)
@@ -34,6 +34,10 @@ const LAUNCH_PASSTHROUGH = Object.freeze([
34
34
  'env',
35
35
  'startupTimeout',
36
36
  'attach',
37
+ 'preferences',
38
+ 'localState',
39
+ 'defaultBrowserCheck',
40
+ 'firstRun',
37
41
  ]);
38
42
 
39
43
  /** Validate an engine name, defaulting to Playwright. */
@@ -32,7 +32,7 @@ export const TRACE_DIRECTORY_MODE = 0o700;
32
32
  /**
33
33
  * Open a bundle for writing.
34
34
  *
35
- * @param {Object} options - `{output, limits, strict, now, onEvent}`
35
+ * @param {Object} options - `{output, limits, strict, now, monotonic, onEvent}`
36
36
  * @returns {Promise<Object>} The bundle writer
37
37
  */
38
38
  export async function openTraceBundle(options = {}) {
@@ -41,6 +41,7 @@ export async function openTraceBundle(options = {}) {
41
41
  strict = false,
42
42
  limits = {},
43
43
  now = () => Date.now(),
44
+ monotonic = () => performance.now(),
44
45
  onEvent = null,
45
46
  } = options;
46
47
 
@@ -119,7 +120,7 @@ export async function openTraceBundle(options = {}) {
119
120
  const record = {
120
121
  sequence: ++sequence,
121
122
  at: new Date(now()).toISOString(),
122
- monotonicMs: Math.round(performance.now()),
123
+ monotonicMs: Math.round(monotonic()),
123
124
  ...event,
124
125
  };
125
126
  const line = `${JSON.stringify(record)}\n`;
@@ -51,7 +51,7 @@ export function interactionTarget(argument) {
51
51
  /**
52
52
  * Subscribe to everything a running trace listens to.
53
53
  *
54
- * @param {Object} options - `{commander, page, eventSources, record, note, identity}`
54
+ * @param {Object} options - `{commander, page, eventSources, record, note, identity, now}`
55
55
  * @param {Object} [options.commander] - The commander being traced, when there
56
56
  * is one; interactions and managed downloads are reached through it
57
57
  * @param {Object} options.page - The page being traced
@@ -61,10 +61,19 @@ export function interactionTarget(argument) {
61
61
  * @param {Object} [options.identity] - Trace identity, see `identity.js`; the
62
62
  * navigation observer is what moves it on to the next navigation, and the
63
63
  * interaction observer is what names an action (issue #93)
64
+ * @param {Function} [options.now] - Wall clock in milliseconds, for durations
64
65
  * @returns {Function[]} One detach function per attached observer
65
66
  */
66
67
  export function attachTimelineObservers(options) {
67
- const { commander, page, eventSources, record, note, identity } = options;
68
+ const {
69
+ commander,
70
+ page,
71
+ eventSources,
72
+ record,
73
+ note,
74
+ identity,
75
+ now = () => Date.now(),
76
+ } = options;
68
77
  const detachers = [];
69
78
 
70
79
  function subscribe(source, attach) {
@@ -234,7 +243,7 @@ export function attachTimelineObservers(options) {
234
243
  }
235
244
  originals.set(name, original);
236
245
  commander[name] = async (...args) => {
237
- const startedMs = Date.now();
246
+ const startedMs = now();
238
247
  const target = interactionTarget(args[0]);
239
248
  // Named before the call, so everything the action causes can be traced
240
249
  // back to it even when the action itself ends in an exception.
@@ -245,7 +254,7 @@ export function attachTimelineObservers(options) {
245
254
  actionId,
246
255
  action: name,
247
256
  target,
248
- durationMs: Date.now() - startedMs,
257
+ durationMs: now() - startedMs,
249
258
  ok: true,
250
259
  });
251
260
  return result;
@@ -254,7 +263,7 @@ export function attachTimelineObservers(options) {
254
263
  actionId,
255
264
  action: name,
256
265
  target,
257
- durationMs: Date.now() - startedMs,
266
+ durationMs: now() - startedMs,
258
267
  ok: false,
259
268
  error: error.message,
260
269
  });
@@ -110,6 +110,7 @@ export async function startTrace(options = {}) {
110
110
  commanderVersion = null,
111
111
  log = commander?.log,
112
112
  now = () => Date.now(),
113
+ monotonic,
113
114
  } = options;
114
115
 
115
116
  if (!page) {
@@ -150,6 +151,7 @@ export async function startTrace(options = {}) {
150
151
  limits,
151
152
  strict,
152
153
  now,
154
+ monotonic,
153
155
  onEvent: (event) => linksSink?.event(event),
154
156
  });
155
157
  const startedAt = new Date(now()).toISOString();
@@ -323,6 +325,7 @@ export async function startTrace(options = {}) {
323
325
  record,
324
326
  note,
325
327
  identity,
328
+ now,
326
329
  });
327
330
 
328
331
  // Registered before the first record, so a navigation that starts in the same