browser-commander 0.28.0 → 0.30.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 (57) hide show
  1. package/.jscpd-baseline.json +7 -20
  2. package/CHANGELOG.md +12 -0
  3. package/README.md +27 -10
  4. package/package.json +1 -1
  5. package/scripts/run-tests.mjs +3 -1
  6. package/src/browser/browser-profile-files.js +5 -2
  7. package/src/browser/connector.js +140 -65
  8. package/src/browser/launcher.js +19 -8
  9. package/src/browser/persistent-session.js +110 -38
  10. package/src/browser/session-adoption.js +96 -0
  11. package/src/browser/session-worker.js +12 -0
  12. package/src/cli/args.js +3 -0
  13. package/src/cli/commands.js +3 -0
  14. package/src/cli/high-level-methods.js +7 -0
  15. package/src/cli/sessions.js +3 -0
  16. package/src/core/commander-features.js +33 -25
  17. package/src/core/navigation-safety.js +1 -0
  18. package/src/core/page-trigger-manager.js +173 -134
  19. package/src/index.js +2 -0
  20. package/src/interactions/overlays.js +59 -14
  21. package/src/traces/bundle.js +41 -3
  22. package/src/traces/checkpoints.js +7 -3
  23. package/src/traces/commander-actions.js +146 -0
  24. package/src/traces/debug-session.js +119 -0
  25. package/src/traces/drain-timer.js +25 -0
  26. package/src/traces/links.js +44 -5
  27. package/src/traces/mutation-stream.js +12 -5
  28. package/src/traces/network.js +65 -13
  29. package/src/traces/observers.js +50 -46
  30. package/src/traces/page-capture.js +65 -21
  31. package/src/traces/recorder.js +79 -28
  32. package/src/traces/redaction.js +89 -6
  33. package/src/traces/rolling-bundle.js +240 -0
  34. package/src/traces/rolling.js +10 -113
  35. package/src/traces/stop.js +1 -0
  36. package/src/traces/user-actions.js +405 -0
  37. package/tests/e2e/issue-160.e2e.test.js +291 -0
  38. package/tests/e2e/issue-162-recording.e2e.test.js +175 -0
  39. package/tests/e2e/traces.e2e.test.js +4 -1
  40. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.html +1 -1
  41. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.html +1 -1
  42. package/tests/fixtures/traces/conformance/expected/bundle/events.ndjson +4 -4
  43. package/tests/fixtures/traces/conformance/expected/bundle/manifest.json +4 -0
  44. package/tests/fixtures/traces/conformance/expected/bundle/viewer.html +1 -1
  45. package/tests/fixtures/traces/conformance/expected/trace.lino +4 -4
  46. package/tests/fixtures/traces/conformance/scenario.json +1 -0
  47. package/tests/helpers/e2e-browser.js +2 -1
  48. package/tests/unit/browser/issue-160-connector.test.js +157 -0
  49. package/tests/unit/core/interrupted-navigation.test.js +32 -0
  50. package/tests/unit/core/trigger-stop-race.test.js +80 -0
  51. package/tests/unit/interactions/overlays.test.js +58 -0
  52. package/tests/unit/traces/issue-160-privacy.test.js +110 -0
  53. package/tests/unit/traces/issue-160-rotation.test.js +140 -0
  54. package/tests/unit/traces/issue-162-defaults.test.js +223 -0
  55. package/tests/unit/traces/issue-162-user-actions.test.js +181 -0
  56. package/tests/unit/traces/network.test.js +55 -3
  57. package/tests/unit/traces/recorder.test.js +4 -1
@@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url';
5
5
  import { connectBrowser, pickForegroundPage } from './connector.js';
6
6
  import { launchAndConnectRealBrowserWithDependencies } from './real-browser.js';
7
7
  import { assertDedicatedUserDataDir } from './system-browser.js';
8
+ import { findSessionProcess } from './session-adoption.js';
8
9
  import { startDetachedProcess } from '../utilities/subprocess.js';
9
10
 
10
11
  export const SESSION_METADATA = '.browser-commander-session.json';
@@ -108,8 +109,22 @@ async function open(options, file, endpoint) {
108
109
  let metadata = await read(file);
109
110
  const version = await probeSession(endpoint);
110
111
  if (version) {
112
+ let adopted = false;
113
+ if (!metadata && options.adoptExisting) {
114
+ const pid = await findSessionProcess(options);
115
+ metadata = {
116
+ token: randomUUID(),
117
+ pid,
118
+ userDataDir: path.resolve(options.userDataDir),
119
+ remoteDebuggingPort: options.remoteDebuggingPort,
120
+ webSocketDebuggerUrl: version.webSocketDebuggerUrl,
121
+ };
122
+ adopted = true;
123
+ }
111
124
  if (
112
125
  !metadata ||
126
+ path.resolve(metadata.userDataDir ?? '') !==
127
+ path.resolve(options.userDataDir) ||
113
128
  metadata.remoteDebuggingPort !== options.remoteDebuggingPort ||
114
129
  metadata.webSocketDebuggerUrl !== version.webSocketDebuggerUrl
115
130
  ) {
@@ -119,9 +134,13 @@ async function open(options, file, endpoint) {
119
134
  ...options,
120
135
  cdpEndpoint: endpoint,
121
136
  targetId:
122
- options.targetId ?? (!options.url ? metadata.targetId : undefined),
137
+ options.targetId ??
138
+ (!options.url && !options.urlMatchers?.length
139
+ ? metadata.targetId
140
+ : undefined),
141
+ fallback: !options.targetId,
123
142
  });
124
- return { session, metadata, reused: true };
143
+ return { session, metadata, reused: true, adopted };
125
144
  }
126
145
  const session = await launchAndConnectRealBrowserWithDependencies(
127
146
  { ...options, keepOpen: false },
@@ -177,7 +196,7 @@ export async function closeOwnedSession(file, metadata) {
177
196
  await fs.rm(file, { force: true });
178
197
  }
179
198
  async function lifecycle(
180
- { session, metadata, reused },
199
+ { session, metadata, reused, adopted },
181
200
  options,
182
201
  file,
183
202
  endpoint
@@ -212,11 +231,48 @@ async function lifecycle(
212
231
  value.off?.('framenavigated', touch);
213
232
  };
214
233
  await remember(page, engine, file, metadata, idleTimeoutMs);
234
+ if (!reused || adopted) {
235
+ await startDetachedProcess(process.execPath, [
236
+ fileURLToPath(new URL('./session-watchdog.js', import.meta.url)),
237
+ file,
238
+ metadata.token,
239
+ ]);
240
+ }
215
241
  bind(page);
242
+ const heartbeat = setInterval(
243
+ touch,
244
+ Math.max(20, Math.min(30_000, idleTimeoutMs / 3))
245
+ );
246
+ heartbeat.unref?.();
247
+ const tabSource = engine === 'playwright' ? page.context() : session.browser;
248
+ const tabTasks = new Set();
249
+ let selecting = false;
250
+ const closeNewTab = (value) => {
251
+ const task = Promise.resolve()
252
+ .then(async () => {
253
+ const candidate =
254
+ engine === 'playwright' ? value : await value.page?.();
255
+ if (candidate && candidate !== page && !disconnected && !selecting) {
256
+ await candidate.close();
257
+ }
258
+ })
259
+ .catch((error) =>
260
+ options.log?.debug?.(() => `session tab close: ${error.message}`)
261
+ );
262
+ tabTasks.add(task);
263
+ void task.finally(() => tabTasks.delete(task));
264
+ };
265
+ const tabEvent = engine === 'playwright' ? 'page' : 'targetcreated';
266
+ if (options.closeNewTabs) {
267
+ tabSource.on?.(tabEvent, closeNewTab);
268
+ }
216
269
  const detach = async () => {
217
270
  if (disconnected) {
218
271
  return;
219
272
  }
273
+ clearInterval(heartbeat);
274
+ tabSource.off?.(tabEvent, closeNewTab);
275
+ await Promise.allSettled([...tabTasks]);
220
276
  touch();
221
277
  await activityQueue;
222
278
  unbind(page);
@@ -248,51 +304,65 @@ async function lifecycle(
248
304
  close,
249
305
  touch,
250
306
  reused,
307
+ adopted: Boolean(adopted),
251
308
  cdpEndpoint: endpoint,
252
309
  userDataDir: path.resolve(options.userDataDir),
253
310
  remoteDebuggingPort: options.remoteDebuggingPort,
254
311
  temporaryProfile: false,
255
312
  };
256
313
  result.reusePage = async (selection = {}) => {
257
- if (disconnected) {
258
- throw new Error('Persistent controller is detached');
259
- }
260
- const release = await acquireLease(`${file}.lock`);
314
+ selecting = true;
261
315
  try {
262
- const current = await read(file);
263
- if (current?.token !== metadata.token) {
264
- throw new Error('Persistent session ownership changed');
316
+ if (disconnected) {
317
+ throw new Error('Persistent controller is detached');
265
318
  }
266
- const pages =
267
- engine === 'playwright'
268
- ? page.context().pages()
269
- : await session.browser.pages();
270
- const selected = await pickForegroundPage(pages, {
271
- targetId:
272
- selection.targetId ??
273
- (!selection.url ? metadata.targetId : undefined),
274
- ...selection,
275
- });
276
- if (!selected) {
277
- throw new Error('No reusable browser page');
319
+ const release = await acquireLease(`${file}.lock`);
320
+ try {
321
+ const current = await read(file);
322
+ if (current?.token !== metadata.token) {
323
+ throw new Error('Persistent session ownership changed');
324
+ }
325
+ const pages =
326
+ engine === 'playwright'
327
+ ? page.context().pages()
328
+ : await session.browser.pages();
329
+ const selected =
330
+ (await pickForegroundPage(pages, {
331
+ targetId:
332
+ selection.targetId ??
333
+ (!selection.url && !selection.urlMatchers?.length
334
+ ? metadata.targetId
335
+ : undefined),
336
+ fallback: !selection.targetId,
337
+ ...selection,
338
+ })) ??
339
+ (engine === 'playwright'
340
+ ? await page.context().newPage()
341
+ : await session.browser.newPage());
342
+ unbind(page);
343
+ page = selected;
344
+ bind(page);
345
+ result.page = page;
346
+ await remember(page, engine, file, metadata, idleTimeoutMs);
347
+ } finally {
348
+ await release();
278
349
  }
279
- unbind(page);
280
- page = selected;
281
- bind(page);
282
- result.page = page;
283
- await remember(page, engine, file, metadata, idleTimeoutMs);
350
+ return page;
284
351
  } finally {
285
- await release();
352
+ selecting = false;
353
+ if (options.closeNewTabs && !disconnected) {
354
+ const pages =
355
+ engine === 'playwright'
356
+ ? page.context().pages()
357
+ : await session.browser.pages();
358
+ await Promise.allSettled(
359
+ pages
360
+ .filter((candidate) => candidate !== page)
361
+ .map((candidate) => candidate.close())
362
+ );
363
+ }
286
364
  }
287
- return page;
288
365
  };
289
- if (!reused) {
290
- await startDetachedProcess(process.execPath, [
291
- fileURLToPath(new URL('./session-watchdog.js', import.meta.url)),
292
- file,
293
- metadata.token,
294
- ]);
295
- }
296
366
  return result;
297
367
  }
298
368
  export async function closeRemoteBrowser(endpoint) {
@@ -350,8 +420,10 @@ export async function connectOrLaunch(settings = {}) {
350
420
  owned = await open(options, file, endpoint);
351
421
  return await lifecycle(owned, options, file, endpoint);
352
422
  } catch (error) {
353
- if (owned && !owned.reused) {
354
- await owned.session.close().catch(() => {});
423
+ if (owned) {
424
+ await (
425
+ owned.reused ? owned.session.detach() : owned.session.close()
426
+ ).catch(() => {});
355
427
  }
356
428
  throw error;
357
429
  } finally {
@@ -0,0 +1,96 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { runCommand } from '../utilities/subprocess.js';
4
+
5
+ /** Match both the dedicated profile and fixed port before adopting ownership. */
6
+ export function matchesSessionProcess(args, options) {
7
+ if (
8
+ args.filter((arg) => arg.startsWith('--user-data-dir=')).length !== 1 ||
9
+ args.filter((arg) => arg.startsWith('--remote-debugging-port=')).length !==
10
+ 1
11
+ ) {
12
+ return false;
13
+ }
14
+ const profile = args
15
+ .find((arg) => arg.startsWith('--user-data-dir='))
16
+ ?.slice(16);
17
+ const port = args
18
+ .find((arg) => arg.startsWith('--remote-debugging-port='))
19
+ ?.slice(24);
20
+ return (
21
+ /^(?:google[ -]chrome(?:[ -](?:for testing|stable|beta|unstable|canary|dev))?|chrome|chromium(?:-browser)?|brave(?: browser)?|microsoft edge|msedge)(?:\.exe)?$/i.test(
22
+ path.basename(args[0] ?? '')
23
+ ) &&
24
+ Boolean(profile) &&
25
+ path.resolve(profile) === path.resolve(options.userDataDir) &&
26
+ Number(port) === options.remoteDebuggingPort &&
27
+ !args.some((arg) => arg.startsWith('--type='))
28
+ );
29
+ }
30
+ export async function findSessionProcess(options) {
31
+ if (process.platform === 'linux') {
32
+ for (const name of await fs.readdir('/proc')) {
33
+ if (!/^\d+$/.test(name)) {
34
+ continue;
35
+ }
36
+ const args = await fs.readFile(`/proc/${name}/cmdline`, 'utf8').then(
37
+ (text) => text.split('\0'),
38
+ () => []
39
+ );
40
+ if (matchesSessionProcess(args, options)) {
41
+ return Number(name);
42
+ }
43
+ }
44
+ } else if (process.platform === 'darwin') {
45
+ const { stdout } = await runCommand('ps', ['-axo', 'pid=,command=']);
46
+ for (const line of stdout.split('\n')) {
47
+ const match = line.match(/^\s*(\d+)\s+(.+)$/);
48
+ if (!match) {
49
+ continue;
50
+ }
51
+ const args = processArguments(match[2]);
52
+ if (matchesSessionProcess(args, options)) {
53
+ return Number(match[1]);
54
+ }
55
+ }
56
+ } else if (process.platform === 'win32') {
57
+ const { stdout } = await runCommand('powershell.exe', [
58
+ '-NoProfile',
59
+ '-NonInteractive',
60
+ '-Command',
61
+ 'Get-CimInstance Win32_Process | Where-Object { $_.Name -match "^(chrome|chromium|brave|msedge)\\.exe$" } | Select-Object ProcessId,ExecutablePath,CommandLine | ConvertTo-Json -Compress',
62
+ ]);
63
+ const records = JSON.parse(stdout || '[]');
64
+ for (const record of Array.isArray(records) ? records : [records]) {
65
+ const args = processArguments(record.CommandLine ?? '');
66
+ args[0] = record.ExecutablePath ?? args[0];
67
+ if (matchesSessionProcess(args, options)) {
68
+ return record.ProcessId;
69
+ }
70
+ }
71
+ }
72
+ throw new Error(
73
+ 'Cannot verify the running browser profile and port for adoption'
74
+ );
75
+ }
76
+
77
+ /** Process listings omit argv boundaries; only accept unambiguous managed switches. */
78
+ export function processArguments(command) {
79
+ if (
80
+ command.match(/--user-data-dir=/g)?.length !== 1 ||
81
+ command.match(/--remote-debugging-port=/g)?.length !== 1
82
+ ) {
83
+ return [];
84
+ }
85
+ const executable = command.match(/^"([^"\n]+)"|^(.*?)(?=\s--)/);
86
+ const profile = command.match(
87
+ /--user-data-dir=(?:"([^"\n]+)"|'([^'\n]+)'|(.+?))(?=\s--|$)/
88
+ );
89
+ const port = command.match(/--remote-debugging-port=(\d+)(?=\s|$)/);
90
+ return [
91
+ executable?.[1] ?? executable?.[2] ?? '',
92
+ `--user-data-dir=${profile?.[1] ?? profile?.[2] ?? profile?.[3] ?? ''}`,
93
+ `--remote-debugging-port=${port?.[1] ?? ''}`,
94
+ ...(command.includes('--type=') ? ['--type=child'] : []),
95
+ ];
96
+ }
@@ -1,5 +1,6 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
+ import { connectBrowser } from './connector.js';
3
4
  import {
4
5
  connectOrLaunch,
5
6
  SESSION_METADATA,
@@ -45,6 +46,15 @@ if (options.operation) {
45
46
  metadata.targetId = options.targetId;
46
47
  }
47
48
  await writeSessionMetadata(file, metadata);
49
+ if (options.closeNewTabs) {
50
+ const connection = await connectBrowser({
51
+ cdpEndpoint: `http://127.0.0.1:${metadata.remoteDebuggingPort}`,
52
+ targetId: metadata.targetId,
53
+ singleTab: true,
54
+ noDefaults: true,
55
+ });
56
+ await connection.detach();
57
+ }
48
58
  } else {
49
59
  throw new TypeError('Unknown session operation');
50
60
  }
@@ -65,6 +75,8 @@ if (options.operation) {
65
75
  reused: session.reused,
66
76
  token: metadata.token,
67
77
  targetId: metadata.targetId,
78
+ idleTimeoutMs: metadata.idleTimeoutMs,
79
+ adopted: session.adopted,
68
80
  })
69
81
  );
70
82
  }
package/src/cli/args.js CHANGED
@@ -35,6 +35,9 @@ const BROWSER_OPTIONS = Object.freeze({
35
35
  bidi: FLAG,
36
36
  'remote-debugging-port': STRING,
37
37
  'idle-timeout-ms': STRING,
38
+ 'adopt-existing': FLAG,
39
+ 'close-new-tabs': FLAG,
40
+ 'no-defaults': FLAG,
38
41
  });
39
42
 
40
43
  /** Options accepted by every page command. */
@@ -186,6 +186,9 @@ async function withPageSession(parsed, io, work, { skipUrl = false } = {}) {
186
186
  engine: options.engine,
187
187
  targetId: options.targetId,
188
188
  singleTab: options.singleTab,
189
+ ...(options.noDefaults !== undefined
190
+ ? { noDefaults: options.noDefaults }
191
+ : {}),
189
192
  ...(options.serverUrl ? { serverUrl: options.serverUrl } : {}),
190
193
  ...(options.driverPath ? { driverPath: options.driverPath } : {}),
191
194
  ...(options.bidi !== undefined ? { bidi: options.bidi } : {}),
@@ -54,6 +54,13 @@ async function connectSession(state, params) {
54
54
  ...endpoint,
55
55
  ...(params.targetId ? { targetId: params.targetId } : {}),
56
56
  ...(params.urlMatcher ? { url: params.urlMatcher } : {}),
57
+ ...(params.noDefaults !== undefined
58
+ ? { noDefaults: params.noDefaults }
59
+ : {}),
60
+ ...(params.urlMatchers !== undefined
61
+ ? { urlMatchers: params.urlMatchers }
62
+ : {}),
63
+ ...(params.fallback !== undefined ? { fallback: params.fallback } : {}),
57
64
  ...(params.singleTab !== undefined ? { singleTab: params.singleTab } : {}),
58
65
  ...(params.driverPath ? { driverPath: params.driverPath } : {}),
59
66
  ...(params.bidi !== undefined ? { bidi: params.bidi } : {}),
@@ -47,6 +47,9 @@ const LAUNCH_PASSTHROUGH = Object.freeze([
47
47
  'disableFeatures',
48
48
  'keepOpen',
49
49
  'idleTimeoutMs',
50
+ 'adoptExisting',
51
+ 'closeNewTabs',
52
+ 'noDefaults',
50
53
  ]);
51
54
 
52
55
  /** Validate an engine name, defaulting to Playwright. */
@@ -1,4 +1,7 @@
1
1
  import { startTrace } from '../traces/recorder.js';
2
+ import { debugEnabled, ensureDebugSession } from '../traces/debug-session.js';
3
+ import { observeCommanderActions } from '../traces/commander-actions.js';
4
+ import { TRACED_INTERACTIONS, interactionTarget } from '../traces/observers.js';
2
5
  import { touchSessionPage } from '../browser/persistent-session.js';
3
6
  import { dismissOverlays } from '../interactions/overlays.js';
4
7
  import {
@@ -15,7 +18,7 @@ const INTERACTIONS = new Set([
15
18
  'pressKey',
16
19
  ]);
17
20
 
18
- /** Bind capture, persistence and opt-in debugging to the commander lifecycle. */
21
+ /** Bind capture, persistence and automatic debugging to the commander lifecycle. */
19
22
  export function attachCommanderFeatures(commander, options, recreate) {
20
23
  const { page, engine } = commander;
21
24
  const managed = new Set();
@@ -38,27 +41,18 @@ export function attachCommanderFeatures(commander, options, recreate) {
38
41
  commander.gif = (frames, animationOptions = {}) =>
39
42
  encodeAnimation(frames, { ...animationOptions, format: 'gif' });
40
43
  commander.dismissOverlays = (overlays = options.overlays) =>
41
- dismissOverlays({ page, engine, overlays });
44
+ dismissOverlays({
45
+ page,
46
+ engine,
47
+ overlays,
48
+ onDismiss: options.onOverlayDismiss,
49
+ });
42
50
  commander.startTrace = async (traceOptions = {}) =>
43
51
  track(await startTrace({ commander, page, ...traceOptions }));
44
- const output =
45
- options.debug?.output ??
46
- options.output ??
47
- process.env.BROWSER_COMMANDER_TRACE;
48
- let debug;
49
- const ensureDebug = async () => {
50
- if (!output || (!options.debug && !process.env.BROWSER_COMMANDER_TRACE)) {
51
- return;
52
- }
53
- debug ??= commander.startTrace({
54
- output,
55
- mode: 'continuous',
56
- network: { har: true },
57
- links: { output: `${output}.links`, dom: 'text' },
58
- checkpointOnNavigation: true,
59
- });
60
- await debug;
61
- };
52
+ let debugSession;
53
+ let detachActions;
54
+ commander.tracePath = null;
55
+ const ensureDebug = () => commander.ready;
62
56
  for (const name of [...INTERACTIONS, 'goto', 'evaluate', 'count']) {
63
57
  const method = commander[name];
64
58
  if (!method) {
@@ -76,10 +70,10 @@ export function attachCommanderFeatures(commander, options, recreate) {
76
70
  const destroy = commander.destroy;
77
71
  commander.destroy = async () => {
78
72
  try {
79
- if (debug) {
80
- await debug;
81
- }
73
+ await commander.ready;
74
+ await debugSession?.stop();
82
75
  await Promise.all([...managed].map((item) => item.stop()));
76
+ detachActions?.();
83
77
  } finally {
84
78
  await destroy();
85
79
  }
@@ -99,7 +93,21 @@ export function attachCommanderFeatures(commander, options, recreate) {
99
93
  touchSessionPage(selected);
100
94
  return selected;
101
95
  };
102
- if (options.debug || process.env.BROWSER_COMMANDER_TRACE) {
103
- commander.ready = ensureDebug();
96
+ if (debugEnabled({ ...options, page })) {
97
+ detachActions = observeCommanderActions({
98
+ commander,
99
+ page,
100
+ names: TRACED_INTERACTIONS,
101
+ targetOf: interactionTarget,
102
+ });
104
103
  }
104
+ commander.ready = ensureDebugSession({
105
+ ...options,
106
+ page,
107
+ engine,
108
+ commander,
109
+ }).then((session) => {
110
+ debugSession = session;
111
+ commander.tracePath = session?.path ?? null;
112
+ });
105
113
  }
@@ -65,6 +65,7 @@ export function isNavigationError(error) {
65
65
  }
66
66
 
67
67
  const navigationErrorPatterns = [
68
+ 'net::ERR_ABORTED',
68
69
  'Execution context was destroyed',
69
70
  'detached Frame',
70
71
  'Target closed',