browser-commander 0.21.0 → 0.21.1

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 +14 -0
  4. package/package.json +4 -2
  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 +38 -6
  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,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.21.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 4caa385: Suppress Chromium's default-browser prompt through profile settings on fresh and snapshot launches, and expose configurable Preferences and Local State values.
8
+
9
+ Update the locked brace-expansion dependency to 5.0.12 to resolve its CPU and stack exhaustion advisories.
10
+
11
+ Retry transient Windows credential-lock EPERM within the existing bounded deadline, preserving the underlying error when contention persists.
12
+
13
+ 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.
14
+
15
+ 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.
16
+
3
17
  ## 0.21.0
4
18
 
5
19
  ### 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.1",
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": {
@@ -97,7 +97,9 @@
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,
@@ -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,12 @@ 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
+
519
530
  export async function launchAndConnectRealBrowserWithDependencies(
520
531
  options = {},
521
532
  dependencies = {}
@@ -541,6 +552,10 @@ export async function launchAndConnectRealBrowserWithDependencies(
541
552
  wsEndpoint,
542
553
  migrateFrom,
543
554
  attach,
555
+ defaultBrowserCheck,
556
+ preferences,
557
+ localState,
558
+ firstRun,
544
559
  ...connectionOptions
545
560
  } = options;
546
561
  const argOptions = {
@@ -573,6 +588,7 @@ export async function launchAndConnectRealBrowserWithDependencies(
573
588
  requestedUserDataDir,
574
589
  argOptions,
575
590
  snapshot: dependencies.snapshotUserDataDir,
591
+ firstRun,
576
592
  });
577
593
  const childEnv = browserEnvironment(restrictions, env);
578
594
 
@@ -584,6 +600,22 @@ export async function launchAndConnectRealBrowserWithDependencies(
584
600
  migrate: dependencies.migrateProfile ?? migrateProfile,
585
601
  });
586
602
 
603
+ // Migration and snapshot attach can copy a preference that enables the
604
+ // default-browser infobar, so apply launch settings after either copy.
605
+ try {
606
+ await configureUserDataDir(userDataDir, {
607
+ defaultBrowserCheck,
608
+ preferences,
609
+ localState,
610
+ profileDirectory: attach?.profile ?? 'Default',
611
+ });
612
+ } catch (error) {
613
+ if (temporaryProfile) {
614
+ await removeUserDataDir(userDataDir);
615
+ }
616
+ throw error;
617
+ }
618
+
587
619
  let launched;
588
620
  try {
589
621
  launched = await spawnOnFreePort({
@@ -614,10 +646,10 @@ export async function launchAndConnectRealBrowserWithDependencies(
614
646
 
615
647
  try {
616
648
  const connect = dependencies.connect ?? connectBrowser;
617
- const seedCookies =
618
- migratedCookies.length > 0
619
- ? [...(connectionOptions.seedCookies ?? []), ...migratedCookies]
620
- : connectionOptions.seedCookies;
649
+ const seedCookies = mergeSeedCookies(
650
+ connectionOptions.seedCookies,
651
+ migratedCookies
652
+ );
621
653
  const connection = await connect({
622
654
  engine,
623
655
  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
@@ -36,7 +36,7 @@ function embed(data) {
36
36
  );
37
37
  }
38
38
 
39
- const VIEWER_STYLE = `
39
+ export const VIEWER_STYLE = `
40
40
  :root { color-scheme: light dark; font-family: system-ui, sans-serif; }
41
41
  body { margin: 0; display: grid; grid-template-columns: 22rem 1fr;
42
42
  grid-template-rows: auto 1fr; height: 100vh; }
@@ -62,7 +62,7 @@ const VIEWER_STYLE = `
62
62
  button { font: inherit; }
63
63
  `;
64
64
 
65
- const VIEWER_SCRIPT = String.raw`
65
+ export const VIEWER_SCRIPT = String.raw`
66
66
  const trace = JSON.parse(document.getElementById('trace-data').textContent);
67
67
  const timeline = document.getElementById('timeline');
68
68
  const frame = document.getElementById('stage');
@@ -72,6 +72,13 @@ const stepLabel = document.getElementById('step');
72
72
  let selectedCheckpoint = null;
73
73
  let mutationStep = 0;
74
74
 
75
+ // Everything in the trace is what the recorded page made it, and the policy
76
+ // allows inline handlers for this script, so recorded text is escaped before
77
+ // it is put into the viewer's markup.
78
+ function escapeHtml(value) {
79
+ return String(value).replace(/[&<>"']/g, (c) => '&#' + c.charCodeAt(0) + ';');
80
+ }
81
+
75
82
  function checkpointFor(event) {
76
83
  if (event.kind === 'checkpoint') return event.index;
77
84
  let latest = null;
@@ -241,15 +248,16 @@ function showCheckpoint(index) {
241
248
  const state = trace.state[index];
242
249
  const previous = trace.state[index - 1];
243
250
  details.innerHTML = state
244
- ? '<table><tr><th>url</th><td>' + state.url + '</td></tr>' +
245
- '<tr><th>title</th><td>' + (state.title || '') + '</td></tr>' +
251
+ ? '<table><tr><th>url</th><td>' + escapeHtml(state.url) + '</td></tr>' +
252
+ '<tr><th>title</th><td>' + escapeHtml(state.title || '') + '</td></tr>' +
246
253
  '<tr><th>controls</th><td>' + (state.controls || []).length + '</td></tr></table>'
247
254
  : '<p>no state captured</p>';
248
255
  const changes = trace.diffs[index] || [];
249
256
  diffPanel.innerHTML = previous && changes.length
250
257
  ? '<table><tr><th>control</th><th>before</th><th>after</th></tr>' +
251
- changes.map((c) => '<tr><td>' + c.path + '</td><td>' + (c.before ?? '') +
252
- '</td><td>' + (c.after ?? '') + '</td></tr>').join('') + '</table>'
258
+ changes.map((c) => '<tr><td>' + escapeHtml(c.path) + '</td><td>' +
259
+ escapeHtml(c.before ?? '') + '</td><td>' + escapeHtml(c.after ?? '') +
260
+ '</td></tr>').join('') + '</table>'
253
261
  : '<p>no control changes against the previous checkpoint</p>';
254
262
  stepLabel.textContent = (trace.mutations[index] || []).length + ' mutation batches';
255
263
  }
@@ -261,8 +269,8 @@ function select(event, element) {
261
269
  if (index != null && index !== selectedCheckpoint) showCheckpoint(index);
262
270
  if (event.kind !== 'checkpoint') {
263
271
  details.innerHTML = '<table>' + Object.entries(event)
264
- .map(([k, v]) => '<tr><th>' + k + '</th><td>' + String(
265
- typeof v === 'object' ? JSON.stringify(v) : v).slice(0, 400) + '</td></tr>')
272
+ .map(([k, v]) => '<tr><th>' + escapeHtml(k) + '</th><td>' + escapeHtml(String(
273
+ typeof v === 'object' ? JSON.stringify(v) : v).slice(0, 400)) + '</td></tr>')
266
274
  .join('') + '</table>';
267
275
  }
268
276
  }
@@ -1,3 +1,4 @@
1
+ // feature-parity: engine.playwright@native-typed
1
2
  /**
2
3
  * E2E tests for browser-commander using Playwright engine
3
4
  *
@@ -1,3 +1,4 @@
1
+ // feature-parity: engine.puppeteer@native-typed
1
2
  /**
2
3
  * E2E tests for browser-commander using Puppeteer engine
3
4
  *
@@ -0,0 +1,2 @@
1
+ <!DOCTYPE html>
2
+ <html><head><title>Start</title></head><body><form><input name="user" value="alice"><input type="checkbox" name="agree"><select name="plan"><option>a</option></select></form></body></html>