browser-commander 0.29.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.
@@ -58,6 +58,7 @@
58
58
  "326f1ff9016e1e37": 1,
59
59
  "33357ce94e07cc4b": 1,
60
60
  "35cf2769645f3234": 1,
61
+ "364e8930fe8fdf71": 1,
61
62
  "37bd4114d0089f49": 1,
62
63
  "3adca52a3f04837c": 1,
63
64
  "3c1017def7b3216c": 1,
@@ -167,6 +168,7 @@
167
168
  "a36f66312d5bfbf8": 1,
168
169
  "a4c8498d4f2d2191": 1,
169
170
  "a5298242b5439858": 1,
171
+ "a5861532b8efced8": 1,
170
172
  "a6ad7ab6c05656dc": 1,
171
173
  "a6ffab1aacd00af6": 1,
172
174
  "a71a4b720d466e32": 1,
@@ -210,7 +212,6 @@
210
212
  "c714beebc66381dd": 1,
211
213
  "c9cf3c28bed33a5b": 1,
212
214
  "ca7e08d1eec2ad8c": 1,
213
- "cab590a1d04b5a05": 1,
214
215
  "cbd444e9a6a9072e": 1,
215
216
  "cebff7d1cc87f646": 1,
216
217
  "cfc928aa393146b6": 1,
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.30.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 0abb9c9: Enable complete JavaScript session recording by default for commanders, launches, and explicit traces. Record trusted user actions with private target descriptions alongside commander actions, DOM changes, network metadata, and a self-contained trace.lino export. Use debug: false or BROWSER_COMMANDER_DEBUG=0 to opt out.
8
+
3
9
  ## 0.29.0
4
10
 
5
11
  ### Minor Changes
package/README.md CHANGED
@@ -816,6 +816,13 @@ See [capture, debugging and reusable sessions](https://github.com/link-foundatio
816
816
  for screenshots, recordings, full/text DOM links, network/HAR, rotation,
817
817
  persistent browsers, early trigger readiness and engine capability limits.
818
818
 
819
+ JavaScript commanders and browser launches record complete sessions by default
820
+ under `./browser-commander-traces/`, including trusted user actions, DOM changes,
821
+ network metadata and `trace.lino`. Await `commander.ready` before using its raw
822
+ page, and call `commander.destroy()` or the launch's `close()` to finish the files.
823
+ Use `debug: false` or `BROWSER_COMMANDER_DEBUG=0` to opt out; `debug: { output }`
824
+ chooses the directory. `commander.tracePath` exposes its path.
825
+
819
826
  `startTrace()` records a session into a schema-versioned bundle any of the three
820
827
  languages can read - the manifest, an ordered NDJSON timeline, per-checkpoint
821
828
  DOM snapshots and the mutation batches between them:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "browser-commander",
3
- "version": "0.29.0",
3
+ "version": "0.30.0",
4
4
  "description": "Universal browser automation library that supports Playwright, Puppeteer, and Selenium with a unified API",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -177,7 +177,9 @@ function runNodeTests(options, testFiles) {
177
177
  process.execPath,
178
178
  buildNodeTestArgs(options, testFiles),
179
179
  {
180
- env: { ...process.env, ...options.env },
180
+ // Most fixtures exercise unrelated APIs with incomplete fake pages. The
181
+ // default-recording regressions explicitly unset this harness opt-out.
182
+ env: { BROWSER_COMMANDER_DEBUG: '0', ...process.env, ...options.env },
181
183
  stdio: 'inherit',
182
184
  }
183
185
  );
@@ -8,6 +8,7 @@ import {
8
8
  import { assertSupportedEngine } from './connector.js';
9
9
  import { emulateMedia } from './media.js';
10
10
  import { reserveLoopbackPort } from './debugging-port.js';
11
+ import { attachLaunchDebug } from '../traces/debug-session.js';
11
12
  import {
12
13
  buildPlaywrightLaunchOptions,
13
14
  buildPuppeteerLaunchOptions,
@@ -319,12 +320,16 @@ export async function launchBrowserWithDependencies(
319
320
  ) {
320
321
  if (options.keepOpen) {
321
322
  const { connectOrLaunch } = await import('./persistent-session.js');
322
- return connectOrLaunch({
323
- ...options,
324
- userDataDir: options.userDataDir ?? (await createTemporaryUserDataDir()),
325
- remoteDebuggingPort:
326
- options.remoteDebuggingPort ?? (await reserveLoopbackPort()),
327
- });
323
+ return attachLaunchDebug(
324
+ await connectOrLaunch({
325
+ ...options,
326
+ userDataDir:
327
+ options.userDataDir ?? (await createTemporaryUserDataDir()),
328
+ remoteDebuggingPort:
329
+ options.remoteDebuggingPort ?? (await reserveLoopbackPort()),
330
+ }),
331
+ options
332
+ );
328
333
  }
329
334
  sessionPersistencePath(options);
330
335
  if (isSafariChannel(options.channel ?? options.browser)) {
@@ -333,11 +338,17 @@ export async function launchBrowserWithDependencies(
333
338
  launch: options.launch ?? 'real',
334
339
  attach: options.attach,
335
340
  });
336
- return await launchSafari(options, dependencies);
341
+ return attachLaunchDebug(
342
+ await launchSafari(options, dependencies),
343
+ options
344
+ );
337
345
  }
338
346
  const result = await launchNonSafariBrowser(options, dependencies);
339
347
  try {
340
- return await installSessionPersistence(result, options);
348
+ return await attachLaunchDebug(
349
+ await installSessionPersistence(result, options),
350
+ options
351
+ );
341
352
  } catch (error) {
342
353
  await result.close();
343
354
  throw error;
@@ -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();
@@ -46,24 +49,10 @@ export function attachCommanderFeatures(commander, options, recreate) {
46
49
  });
47
50
  commander.startTrace = async (traceOptions = {}) =>
48
51
  track(await startTrace({ commander, page, ...traceOptions }));
49
- const output =
50
- options.debug?.output ??
51
- options.output ??
52
- process.env.BROWSER_COMMANDER_TRACE;
53
- let debug;
54
- const ensureDebug = async () => {
55
- if (!output || (!options.debug && !process.env.BROWSER_COMMANDER_TRACE)) {
56
- return;
57
- }
58
- debug ??= commander.startTrace({
59
- output,
60
- mode: 'continuous',
61
- network: { har: true },
62
- links: { output: `${output}.links`, dom: 'text' },
63
- checkpointOnNavigation: true,
64
- });
65
- await debug;
66
- };
52
+ let debugSession;
53
+ let detachActions;
54
+ commander.tracePath = null;
55
+ const ensureDebug = () => commander.ready;
67
56
  for (const name of [...INTERACTIONS, 'goto', 'evaluate', 'count']) {
68
57
  const method = commander[name];
69
58
  if (!method) {
@@ -81,10 +70,10 @@ export function attachCommanderFeatures(commander, options, recreate) {
81
70
  const destroy = commander.destroy;
82
71
  commander.destroy = async () => {
83
72
  try {
84
- if (debug) {
85
- await debug;
86
- }
73
+ await commander.ready;
74
+ await debugSession?.stop();
87
75
  await Promise.all([...managed].map((item) => item.stop()));
76
+ detachActions?.();
88
77
  } finally {
89
78
  await destroy();
90
79
  }
@@ -104,7 +93,21 @@ export function attachCommanderFeatures(commander, options, recreate) {
104
93
  touchSessionPage(selected);
105
94
  return selected;
106
95
  };
107
- if (options.debug || process.env.BROWSER_COMMANDER_TRACE) {
108
- commander.ready = ensureDebug();
96
+ if (debugEnabled({ ...options, page })) {
97
+ detachActions = observeCommanderActions({
98
+ commander,
99
+ page,
100
+ names: TRACED_INTERACTIONS,
101
+ targetOf: interactionTarget,
102
+ });
109
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
+ });
110
113
  }
@@ -0,0 +1,146 @@
1
+ /** One wrapper per commander, even when explicit and automatic traces overlap. */
2
+ import { withDeadline } from './deadline.js';
3
+ import { framesOf } from './init-script.js';
4
+
5
+ const commanders = new WeakMap();
6
+
7
+ let actionSequence = 0;
8
+
9
+ function labelActionInPage(argument, elementOptions) {
10
+ const { marker, remove } = elementOptions ?? argument;
11
+ const actions = (window.__browserCommanderActions__ ??= []);
12
+ if (remove) {
13
+ const index = actions.findIndex((action) => action.id === marker.id);
14
+ if (index >= 0) {
15
+ actions.splice(index, 1);
16
+ }
17
+ } else {
18
+ const focused =
19
+ !marker.selector && marker.types.includes('keydown')
20
+ ? document.activeElement
21
+ : null;
22
+ actions.push({
23
+ ...marker,
24
+ element: elementOptions
25
+ ? argument
26
+ : focused !== document.body && focused !== document.documentElement
27
+ ? focused
28
+ : null,
29
+ });
30
+ }
31
+ }
32
+
33
+ export function observeCommanderActions({
34
+ commander,
35
+ page,
36
+ names,
37
+ targetOf,
38
+ record,
39
+ identity,
40
+ now,
41
+ note,
42
+ }) {
43
+ if (!commander) {
44
+ return () => {};
45
+ }
46
+ let state = commanders.get(commander);
47
+ if (!state) {
48
+ state = { observers: new Set(), originals: new Map() };
49
+ commanders.set(commander, state);
50
+ for (const name of names) {
51
+ const original = commander[name];
52
+ if (typeof original !== 'function') {
53
+ continue;
54
+ }
55
+ const wrapper = async (...args) => {
56
+ await commander.ready;
57
+ const observing = [...state.observers].map((observer) => ({
58
+ observer,
59
+ started: observer.now(),
60
+ actionId: observer.identity?.nextActionId() ?? null,
61
+ }));
62
+ const marker = {
63
+ id: ++actionSequence,
64
+ selector: targetOf(args[0]),
65
+ types: /^(click|check)/.test(name)
66
+ ? ['pointerdown', 'click', 'submit', 'contextmenu']
67
+ : /^(fill|typeText|pressKey)/.test(name)
68
+ ? ['keydown', 'submit', 'click']
69
+ : [],
70
+ };
71
+ const element = args[0]?.locatorOrElement ?? args[0]?.selector;
72
+ const labelAction = async (remove) => {
73
+ try {
74
+ if (marker.types.length) {
75
+ const config = { marker, remove };
76
+ const frames =
77
+ remove || (!marker.selector && marker.types.includes('keydown'))
78
+ ? framesOf(page)
79
+ : [];
80
+ const target = element?.first?.() ?? element;
81
+ const work = frames.length
82
+ ? Promise.all(
83
+ frames.map((frame) =>
84
+ frame.evaluate(labelActionInPage, config)
85
+ )
86
+ )
87
+ : !remove && typeof target?.evaluate === 'function'
88
+ ? target.evaluate(labelActionInPage, config, {
89
+ timeout: 1000,
90
+ })
91
+ : page.evaluate?.(labelActionInPage, config);
92
+ await withDeadline(
93
+ Promise.resolve(work),
94
+ 1000,
95
+ 'commander input label'
96
+ );
97
+ }
98
+ } catch (error) {
99
+ note?.(`could not label commander input: ${error.message}`);
100
+ }
101
+ };
102
+ await labelAction(false);
103
+ let error;
104
+ try {
105
+ return await original.apply(commander, args);
106
+ } catch (failure) {
107
+ error = failure;
108
+ throw failure;
109
+ } finally {
110
+ await labelAction(true);
111
+ await Promise.all(
112
+ observing.map(({ observer, started, actionId }) =>
113
+ observer.record('interaction', {
114
+ actionId,
115
+ source: 'commander',
116
+ action: name,
117
+ target: targetOf(args[0]),
118
+ durationMs: observer.now() - started,
119
+ ok: !error,
120
+ ...(error ? { error: error.message } : {}),
121
+ })
122
+ )
123
+ );
124
+ }
125
+ };
126
+ state.originals.set(name, { original, wrapper });
127
+ commander[name] = wrapper;
128
+ }
129
+ }
130
+ const observer = { record, identity, now };
131
+ if (record) {
132
+ state.observers.add(observer);
133
+ }
134
+ return () => {
135
+ state.observers.delete(observer);
136
+ if (state.observers.size) {
137
+ return;
138
+ }
139
+ for (const [name, { original, wrapper }] of state.originals) {
140
+ if (commander[name] === wrapper) {
141
+ commander[name] = original;
142
+ }
143
+ }
144
+ commanders.delete(commander);
145
+ };
146
+ }
@@ -0,0 +1,119 @@
1
+ import path from 'node:path';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { startTrace } from './recorder.js';
4
+
5
+ const sessions = new WeakMap();
6
+ const disabledPages = new WeakSet();
7
+
8
+ export function debugEnabled(options = {}) {
9
+ return (
10
+ options.debug !== false &&
11
+ process.env.BROWSER_COMMANDER_DEBUG !== '0' &&
12
+ !(options.debug === undefined && disabledPages.has(options.page))
13
+ );
14
+ }
15
+
16
+ /** A launched page and its commander share one recording and one output path. */
17
+ export async function ensureDebugSession(options) {
18
+ const { page, commander } = options;
19
+ const existing = sessions.get(page);
20
+ if (!debugEnabled(options)) {
21
+ disabledPages.add(page);
22
+ await existing?.stop();
23
+ return null;
24
+ }
25
+ disabledPages.delete(page);
26
+ let session = existing;
27
+ if (!session) {
28
+ const config =
29
+ options.debug && typeof options.debug === 'object' ? options.debug : {};
30
+ const output = path.resolve(
31
+ config.output ??
32
+ options.output ??
33
+ process.env.BROWSER_COMMANDER_TRACE ??
34
+ path.join(
35
+ 'browser-commander-traces',
36
+ `${new Date().toISOString().replaceAll(':', '-')}-${process.pid}-${randomUUID().slice(0, 8)}`
37
+ )
38
+ );
39
+ session = { path: output };
40
+ sessions.set(page, session);
41
+ session.ready = startTrace({
42
+ ...config,
43
+ output,
44
+ page,
45
+ commander: commander ?? {
46
+ page,
47
+ engine: options.engine,
48
+ downloads: options.downloads,
49
+ },
50
+ checkpointOnNavigation: config.checkpointOnNavigation ?? true,
51
+ })
52
+ .then((trace) => {
53
+ console.error(`[browser-commander] Recording session to ${output}`);
54
+ return trace;
55
+ })
56
+ .catch((error) => {
57
+ sessions.delete(page);
58
+ console.warn(
59
+ `[browser-commander] Could not start recording: ${error.message}`
60
+ );
61
+ return null;
62
+ });
63
+ let stopping;
64
+ session.stop = () =>
65
+ (stopping ??= session.ready.then(async (trace) => {
66
+ try {
67
+ return await trace?.stop();
68
+ } finally {
69
+ if (sessions.get(page) === session) {
70
+ sessions.delete(page);
71
+ }
72
+ }
73
+ }));
74
+ }
75
+ const trace = await session.ready;
76
+ if (!trace) {
77
+ return null;
78
+ }
79
+ if (commander) {
80
+ trace.attachCommander(commander);
81
+ }
82
+ return session;
83
+ }
84
+
85
+ /** Finish trace files before closing/detaching the engine that supplies them. */
86
+ export async function attachLaunchDebug(result, options) {
87
+ const session = await ensureDebugSession({
88
+ ...options,
89
+ engine:
90
+ options.engine ??
91
+ (result.page.isWebDriverPage ? 'selenium' : 'playwright'),
92
+ page: result.page,
93
+ downloads: result.downloads,
94
+ });
95
+ result.tracePath = session?.path ?? null;
96
+ if (!session) {
97
+ return result;
98
+ }
99
+ const wrap = (owner, name) => {
100
+ const method = owner?.[name];
101
+ if (typeof method !== 'function') {
102
+ return;
103
+ }
104
+ owner[name] = async (...args) => {
105
+ let result;
106
+ try {
107
+ await session.stop();
108
+ } finally {
109
+ result = await method.apply(owner, args);
110
+ }
111
+ return result;
112
+ };
113
+ };
114
+ wrap(result, 'close');
115
+ wrap(result, 'detach');
116
+ wrap(result.browser, 'close');
117
+ wrap(result.browser, 'disconnect');
118
+ return result;
119
+ }
@@ -0,0 +1,25 @@
1
+ /** Serialize background drains and wait for the active drain during teardown. */
2
+ export function startTraceDrain({
3
+ mutations,
4
+ userActions,
5
+ captureState,
6
+ note,
7
+ }) {
8
+ let task;
9
+ const timer = setInterval(() => {
10
+ if (!task) {
11
+ task = Promise.resolve()
12
+ .then(() => userActions.drain())
13
+ .then(() => mutations.drain(captureState.index))
14
+ .catch((error) => note(error.message))
15
+ .finally(() => {
16
+ task = null;
17
+ });
18
+ }
19
+ }, 500);
20
+ timer.unref?.();
21
+ return async () => {
22
+ clearInterval(timer);
23
+ await task;
24
+ };
25
+ }
@@ -57,10 +57,11 @@ export function createMutationStream(options) {
57
57
  } = options;
58
58
 
59
59
  const enabled = Boolean(domOptions.mutations);
60
+ const globalName = `${RECORDER_GLOBAL}${identity.traceId}`;
60
61
 
61
62
  /** What the in-page recorder is built with, wherever it is installed. */
62
63
  const recorderOptions = {
63
- globalName: RECORDER_GLOBAL,
64
+ globalName,
64
65
  redactSelectors: privacyOptions.redactSelectors,
65
66
  redactAttributes: privacyOptions.redactAttributes,
66
67
  useDefaults: privacyOptions.useDefaults,
@@ -168,7 +169,7 @@ export function createMutationStream(options) {
168
169
  }
169
170
  try {
170
171
  const drained = await withDeadline(
171
- evaluateInFrames(drainMutationsInPage, RECORDER_GLOBAL),
172
+ evaluateInFrames(drainMutationsInPage, globalName),
172
173
  captureTimeoutMs,
173
174
  'trace mutation drain'
174
175
  );
@@ -229,7 +230,7 @@ export function createMutationStream(options) {
229
230
  return;
230
231
  }
231
232
  try {
232
- await evaluateInFrames(stopMutationRecorderInPage, RECORDER_GLOBAL);
233
+ await evaluateInFrames(stopMutationRecorderInPage, globalName);
233
234
  } catch (error) {
234
235
  note?.(`could not stop the in-page recorder: ${error.message}`);
235
236
  }
@@ -8,11 +8,49 @@
8
8
  */
9
9
 
10
10
  import { TRACE_EVENT } from './schema.js';
11
+ import { observeCommanderActions } from './commander-actions.js';
12
+
13
+ /** Keep page observations when a launched trace is adopted by a commander. */
14
+ export function attachRecorderObservers(options) {
15
+ const ownerSources = ['interaction', 'dialog', 'download', 'navigation'];
16
+ const detachers = attachTimelineObservers({
17
+ ...options,
18
+ commander: null,
19
+ eventSources: options.eventSources.filter(
20
+ (source) => !ownerSources.includes(source)
21
+ ),
22
+ });
23
+ let commanderDetachers = [],
24
+ attachedCommander,
25
+ hasCommander = false;
26
+ const attachCommander = (commander) => {
27
+ if (hasCommander && commander === attachedCommander) {
28
+ return;
29
+ }
30
+ hasCommander = true;
31
+ attachedCommander = commander;
32
+ for (const detach of commanderDetachers) {
33
+ detach();
34
+ }
35
+ commanderDetachers = attachTimelineObservers({
36
+ ...options,
37
+ commander,
38
+ eventSources: options.eventSources.filter((source) =>
39
+ ownerSources.includes(source)
40
+ ),
41
+ });
42
+ };
43
+ attachCommander(options.commander);
44
+ detachers.push(() => commanderDetachers.forEach((detach) => detach()));
45
+ return { detachers, attachCommander };
46
+ }
11
47
 
12
48
  /** Commander methods recorded as interactions while a trace is running. */
13
49
  export const TRACED_INTERACTIONS = Object.freeze([
14
50
  'click',
15
51
  'clickButton',
52
+ 'clickElement',
53
+ 'check',
16
54
  'fill',
17
55
  'fillTextArea',
18
56
  'goto',
@@ -231,51 +269,17 @@ export function attachTimelineObservers(options) {
231
269
  };
232
270
  });
233
271
 
234
- subscribe('interaction', () => {
235
- if (!commander) {
236
- return null;
237
- }
238
- const originals = new Map();
239
- for (const name of TRACED_INTERACTIONS) {
240
- const original = commander[name];
241
- if (typeof original !== 'function') {
242
- continue;
243
- }
244
- originals.set(name, original);
245
- commander[name] = async (...args) => {
246
- const startedMs = now();
247
- const target = interactionTarget(args[0]);
248
- // Named before the call, so everything the action causes can be traced
249
- // back to it even when the action itself ends in an exception.
250
- const actionId = identity?.nextActionId() ?? null;
251
- try {
252
- const result = await original(...args);
253
- await record(TRACE_EVENT.INTERACTION, {
254
- actionId,
255
- action: name,
256
- target,
257
- durationMs: now() - startedMs,
258
- ok: true,
259
- });
260
- return result;
261
- } catch (error) {
262
- await record(TRACE_EVENT.INTERACTION, {
263
- actionId,
264
- action: name,
265
- target,
266
- durationMs: now() - startedMs,
267
- ok: false,
268
- error: error.message,
269
- });
270
- throw error;
271
- }
272
- };
273
- }
274
- return () => {
275
- for (const [name, original] of originals) {
276
- commander[name] = original;
277
- }
278
- };
279
- });
272
+ subscribe('interaction', () =>
273
+ observeCommanderActions({
274
+ commander,
275
+ page,
276
+ names: TRACED_INTERACTIONS,
277
+ targetOf: interactionTarget,
278
+ record,
279
+ identity,
280
+ now,
281
+ note,
282
+ })
283
+ );
280
284
  return detachers;
281
285
  }