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.
@@ -16,10 +16,13 @@ import { openTraceLinks } from './links.js';
16
16
  import { createTraceIdentity } from './identity.js';
17
17
  import { withDeadline } from './deadline.js';
18
18
  import { createMutationStream } from './mutation-stream.js';
19
- import { attachTimelineObservers } from './observers.js';
19
+ import { createUserActionStream } from './user-actions.js';
20
+ import { startTraceDrain } from './drain-timer.js';
21
+ import { attachRecorderObservers } from './observers.js';
20
22
  import { attachNetwork, validateNetworkOptions } from './network.js';
21
23
  import { startRecording } from '../capture/recording.js';
22
24
  import { normalizePrivacyOptions, redactValue } from './redaction.js';
25
+ import path from 'node:path';
23
26
  import {
24
27
  DEFAULT_CAPTURE_TIMEOUT,
25
28
  TRACE_CHECKPOINT_REASON,
@@ -94,13 +97,19 @@ function normalizeTraceOptions(options) {
94
97
  return {
95
98
  ...options,
96
99
  page: options.page ?? options.commander?.page,
97
- mode: options.mode ?? TRACE_MODE.CHECKPOINTS,
100
+ mode: options.mode ?? TRACE_MODE.CONTINUOUS,
98
101
  screenshots: options.screenshots ?? 'checkpoints',
99
102
  dom: options.dom ?? {},
100
103
  privacy: options.privacy ?? {},
101
104
  limits: options.limits ?? {},
102
- links: options.links ?? null,
103
- network: options.network ?? false,
105
+ links:
106
+ options.links === false || options.links === null
107
+ ? null
108
+ : (options.links ?? {
109
+ output: path.join(options.output ?? '', 'trace.lino'),
110
+ dom: 'full',
111
+ }),
112
+ network: options.network ?? { har: true },
104
113
  strict: options.strict ?? false,
105
114
  captureTimeoutMs: options.captureTimeoutMs ?? DEFAULT_CAPTURE_TIMEOUT,
106
115
  commanderVersion: options.commanderVersion ?? null,
@@ -110,6 +119,7 @@ function normalizeTraceOptions(options) {
110
119
  }
111
120
 
112
121
  export async function startTrace(options = {}) {
122
+ options = normalizeTraceOptions(options);
113
123
  if (options.limits?.rotate) {
114
124
  const { startRollingTrace } = await import('./rolling.js');
115
125
  return startRollingTrace(options, startTrace);
@@ -151,8 +161,8 @@ export async function startTrace(options = {}) {
151
161
  // Otherwise a rejected start (for example `links: {}`) strands the bundle's
152
162
  // events handle because no running trace is returned for the caller to stop.
153
163
  validateLinksOptions(links);
154
- const engine = commander?.engine ?? null;
155
- const identity = createTraceIdentity({ page });
164
+ const engine = commander?.engine ?? null,
165
+ identity = createTraceIdentity({ page });
156
166
 
157
167
  // A continuous trace records changes, and a change is only meaningful against
158
168
  // something. Taking the base snapshot automatically is the safe default,
@@ -243,6 +253,18 @@ export async function startTrace(options = {}) {
243
253
  captureTimeoutMs,
244
254
  evaluateInPage,
245
255
  });
256
+ const userActions = createUserActionStream({
257
+ page,
258
+ record,
259
+ identity,
260
+ note,
261
+ privacyOptions,
262
+ limits,
263
+ enabled: eventSources.includes('interaction'),
264
+ ignoreSelectors: domOptions.ignoreSelectors,
265
+ evaluateInPage,
266
+ captureTimeoutMs,
267
+ });
246
268
 
247
269
  async function screenshot(reason) {
248
270
  const wanted =
@@ -289,7 +311,7 @@ export async function startTrace(options = {}) {
289
311
  detachNetwork = async () => {},
290
312
  video = null;
291
313
  try {
292
- detachers = attachTimelineObservers({
314
+ const observers = attachRecorderObservers({
293
315
  commander,
294
316
  page,
295
317
  eventSources,
@@ -298,6 +320,7 @@ export async function startTrace(options = {}) {
298
320
  identity,
299
321
  now,
300
322
  });
323
+ detachers = observers.detachers;
301
324
  detachNetwork = attachNetwork({
302
325
  page,
303
326
  network,
@@ -344,7 +367,11 @@ export async function startTrace(options = {}) {
344
367
  events: eventSources,
345
368
  initialCheckpoint: Boolean(baseCheckpoint),
346
369
  });
347
- await mutations.install();
370
+ await Promise.all([mutations.install(), userActions.install()]);
371
+ detachers.push(async () => {
372
+ await userActions.drain();
373
+ await userActions.stop();
374
+ });
348
375
 
349
376
  if (baseCheckpoint) {
350
377
  await checkpoint(
@@ -356,23 +383,10 @@ export async function startTrace(options = {}) {
356
383
  );
357
384
  }
358
385
 
359
- let drainTask;
360
- if (domOptions.mutations) {
361
- const timer = setInterval(() => {
362
- if (!drainTask) {
363
- drainTask = mutations
364
- .drain(captureState.index)
365
- .catch((error) => note(error.message))
366
- .finally(() => {
367
- drainTask = null;
368
- });
369
- }
370
- }, 500);
371
- timer.unref?.();
372
- detachers.push(async () => {
373
- clearInterval(timer);
374
- await drainTask;
375
- });
386
+ if (domOptions.mutations || eventSources.includes('interaction')) {
387
+ detachers.push(
388
+ startTraceDrain({ mutations, userActions, captureState, note })
389
+ );
376
390
  }
377
391
 
378
392
  /**
@@ -424,6 +438,7 @@ export async function startTrace(options = {}) {
424
438
  return bundle.bytesWritten;
425
439
  },
426
440
  checkpoint,
441
+ attachCommander: observers.attachCommander,
427
442
  /**
428
443
  * Record an event a caller cares about on the same timeline.
429
444
  *
@@ -460,6 +475,12 @@ export async function startTrace(options = {}) {
460
475
 
461
476
  function requireTraceSupport(options) {
462
477
  validateNetworkOptions(options.network);
478
+ const maxQueued = options.limits.maxQueuedUserActions ?? 5000;
479
+ if (!Number.isSafeInteger(maxQueued) || maxQueued < 1) {
480
+ throw new RangeError(
481
+ 'maxQueuedUserActions must be a positive safe integer'
482
+ );
483
+ }
463
484
  (options.page ?? options.commander?.page)?.requireFeature?.('tracing');
464
485
  }
465
486
 
@@ -0,0 +1,405 @@
1
+ import { withDeadline } from './deadline.js';
2
+ import { framesOf, installInitScript } from './init-script.js';
3
+ import { TRACE_EVENT, TRACE_DROP_REASON } from './schema.js';
4
+
5
+ const bindings = new WeakMap();
6
+ const USER_ACTION_BINDING = '__browserCommanderUserActionsEmit__';
7
+
8
+ async function attachBinding(page, route, emit) {
9
+ if (!page.exposeFunction) {
10
+ return null;
11
+ }
12
+ let binding = bindings.get(page);
13
+ if (!binding) {
14
+ const callbacks = new Map();
15
+ binding = {
16
+ callbacks,
17
+ ready: page.exposeFunction(USER_ACTION_BINDING, (id, payload) =>
18
+ callbacks.get(id)?.(payload)
19
+ ),
20
+ };
21
+ bindings.set(page, binding);
22
+ }
23
+ await binding.ready;
24
+ binding.callbacks.set(route, emit);
25
+ return () => binding.callbacks.delete(route);
26
+ }
27
+
28
+ /** Runs in each document. Event descriptions never read control values. */
29
+ export function installUserActionRecorderInPage(options) {
30
+ const {
31
+ globalName,
32
+ binding,
33
+ maxQueued,
34
+ redactSelectors,
35
+ ignoreSelectors,
36
+ redacted,
37
+ } = options;
38
+ if (window[globalName]?.observing) {
39
+ return;
40
+ }
41
+ const state = {
42
+ observing: true,
43
+ queue: [],
44
+ dropped: 0,
45
+ frameId: `frame-${Math.random().toString(36).slice(2, 10)}`,
46
+ };
47
+ const matches = (node, selectors) => {
48
+ for (let at = node; at; at = at.getRootNode?.()?.host) {
49
+ if (
50
+ selectors.some((selector) => {
51
+ try {
52
+ return Boolean(at.closest?.(selector));
53
+ } catch {
54
+ return false;
55
+ }
56
+ })
57
+ ) {
58
+ return true;
59
+ }
60
+ }
61
+ return false;
62
+ };
63
+ const privateNode = (node) => matches(node, redactSelectors);
64
+ const pathOf = (node) => {
65
+ const steps = [];
66
+ let separator = '';
67
+ for (let at = node; at?.nodeType === 1 && steps.length < 20;) {
68
+ const siblings = [...(at.parentElement?.children ?? [])].filter(
69
+ (child) => child.localName === at.localName
70
+ );
71
+ const step = at.id
72
+ ? `#${window.CSS.escape(at.id)}`
73
+ : `${at.localName}${siblings.length > 1 ? `:nth-of-type(${siblings.indexOf(at) + 1})` : ''}`;
74
+ steps.unshift(`${step}${separator}`);
75
+ if (at.id) {
76
+ break;
77
+ }
78
+ separator = at.parentElement ? ' > ' : ' >>> ';
79
+ at = at.parentElement ?? at.getRootNode()?.host;
80
+ }
81
+ return steps.join('');
82
+ };
83
+ const visibleText = (node, depth = 0, budget = { remaining: 500 }) => {
84
+ if (
85
+ !node ||
86
+ depth >= 20 ||
87
+ --budget.remaining < 0 ||
88
+ matches(node.nodeType === 1 ? node : node.parentElement, [
89
+ 'input',
90
+ 'textarea',
91
+ 'select',
92
+ '[contenteditable]',
93
+ ...ignoreSelectors,
94
+ ])
95
+ ) {
96
+ return '';
97
+ }
98
+ if (privateNode(node.nodeType === 1 ? node : node.parentElement)) {
99
+ return redacted;
100
+ }
101
+ if (node.nodeType === 3) {
102
+ return node.nodeValue ?? '';
103
+ }
104
+ if (
105
+ node.nodeType === 1 &&
106
+ (!node.getClientRects().length ||
107
+ window.getComputedStyle(node).visibility === 'hidden')
108
+ ) {
109
+ return '';
110
+ }
111
+ let text = '';
112
+ for (const child of node.childNodes ?? []) {
113
+ text += visibleText(child, depth + 1, budget);
114
+ if (text.length >= 500) {
115
+ break;
116
+ }
117
+ }
118
+ return text.slice(0, 500);
119
+ };
120
+ const describe = (target) => {
121
+ const secret = privateNode(target);
122
+ const text = secret ? redacted : visibleText(target).trim();
123
+ const labelled = (target.getAttribute('aria-labelledby') ?? '')
124
+ .split(/\s+/)
125
+ .filter(Boolean)
126
+ .map((id) => visibleText(target.getRootNode().getElementById?.(id)))
127
+ .join(' ')
128
+ .trim();
129
+ const label = [...(target.labels ?? [])]
130
+ .slice(0, 20)
131
+ .map((node) => visibleText(node))
132
+ .join(' ')
133
+ .trim();
134
+ const roles = {
135
+ button: 'button',
136
+ a: 'link',
137
+ textarea: 'textbox',
138
+ select: 'combobox',
139
+ form: 'form',
140
+ };
141
+ const inputRole =
142
+ target.type === 'checkbox'
143
+ ? 'checkbox'
144
+ : target.type === 'radio'
145
+ ? 'radio'
146
+ : ['submit', 'button', 'reset'].includes(target.type)
147
+ ? 'button'
148
+ : 'textbox';
149
+ return {
150
+ path: pathOf(target),
151
+ role:
152
+ target.getAttribute('role') ??
153
+ (target.localName === 'input'
154
+ ? inputRole
155
+ : (roles[target.localName] ?? null)),
156
+ name: secret
157
+ ? redacted
158
+ : (
159
+ labelled ||
160
+ target.getAttribute('aria-label') ||
161
+ label ||
162
+ target.getAttribute('alt') ||
163
+ target.getAttribute('title') ||
164
+ text
165
+ ).slice(0, 500),
166
+ text,
167
+ };
168
+ };
169
+ const nonTextKeys =
170
+ /^(?:Enter|Escape|Tab|Backspace|Delete|Insert|Home|End|PageUp|PageDown|Arrow(?:Up|Down|Left|Right)|F(?:[1-9]|1\d|2[0-4]))$/;
171
+ const listener = (event) => {
172
+ if (
173
+ !event.isTrusted ||
174
+ (event.type === 'keydown' &&
175
+ (!nonTextKeys.test(event.key) || event.isComposing))
176
+ ) {
177
+ return;
178
+ }
179
+ const nodes = event.composedPath?.() ?? [event.target];
180
+ let target = nodes.find((node) => node?.nodeType === 1);
181
+ if (!target || matches(target, ignoreSelectors)) {
182
+ return;
183
+ }
184
+ // Only suppress engine input aimed at the commander's current target.
185
+ // A person using another control while an automation call waits is still
186
+ // recorded. Trusted input outside commander methods is always user input.
187
+ const commanderInput = (window.__browserCommanderActions__ ?? []).some(
188
+ (action) => {
189
+ if (!action.types.includes(event.type)) {
190
+ return false;
191
+ }
192
+ if (action.element) {
193
+ return (
194
+ action.element === target ||
195
+ action.element.contains(target) ||
196
+ (event.type === 'click' &&
197
+ event.detail === 0 &&
198
+ action.types.includes('keydown') &&
199
+ action.element.form &&
200
+ action.element.form === target.form) ||
201
+ (event.type === 'submit' && target.contains(action.element))
202
+ );
203
+ }
204
+ if (!action.selector) {
205
+ return event.type === 'keydown';
206
+ }
207
+ try {
208
+ return (
209
+ Boolean(target.closest(action.selector)) ||
210
+ (event.type === 'submit' &&
211
+ Boolean(target.querySelector(action.selector)))
212
+ );
213
+ } catch {
214
+ return false;
215
+ }
216
+ }
217
+ );
218
+ if (commanderInput) {
219
+ return;
220
+ }
221
+ if (event.type !== 'keydown') {
222
+ target =
223
+ target.closest(
224
+ 'button, a, input, select, textarea, form, [role], [contenteditable]'
225
+ ) ?? target;
226
+ }
227
+ const payload = {
228
+ source: 'user',
229
+ actor: 'user',
230
+ action: event.type,
231
+ target: describe(target),
232
+ at: new Date().toISOString(),
233
+ url: location.href,
234
+ frameId: state.frameId,
235
+ mainFrame: window.top === window,
236
+ ...(event.type === 'keydown'
237
+ ? {
238
+ key: event.key,
239
+ altKey: event.altKey,
240
+ ctrlKey: event.ctrlKey,
241
+ metaKey: event.metaKey,
242
+ shiftKey: event.shiftKey,
243
+ }
244
+ : {}),
245
+ };
246
+ const enqueue = () => {
247
+ if (!state.observing) {
248
+ return;
249
+ }
250
+ if (state.queue.length < maxQueued) {
251
+ state.queue.push(payload);
252
+ } else {
253
+ state.dropped += 1;
254
+ }
255
+ };
256
+ if (binding && typeof window[binding] === 'function') {
257
+ // Bindings deliver the action before the app's request/response callbacks;
258
+ // engines without bindings use the bounded polling queue instead.
259
+ Promise.resolve(window[binding](globalName, payload)).catch(enqueue);
260
+ } else {
261
+ enqueue();
262
+ }
263
+ };
264
+ const types = ['click', 'pointerdown', 'keydown', 'submit', 'contextmenu'];
265
+ for (const type of types) {
266
+ document.addEventListener(type, listener, true);
267
+ }
268
+ state.stop = () => {
269
+ state.observing = false;
270
+ for (const type of types) {
271
+ document.removeEventListener(type, listener, true);
272
+ }
273
+ };
274
+ window[globalName] = state;
275
+ }
276
+
277
+ function drainUserActionsInPage(globalName) {
278
+ const state = window[globalName];
279
+ if (!state) {
280
+ return { actions: [], dropped: 0 };
281
+ }
282
+ const result = { actions: state.queue.splice(0), dropped: state.dropped };
283
+ state.dropped = 0;
284
+ return result;
285
+ }
286
+
287
+ function stopUserActionsInPage(globalName) {
288
+ window[globalName]?.stop();
289
+ }
290
+
291
+ /** Host half, shared by raw launched pages and commander traces. */
292
+ export function createUserActionStream(options) {
293
+ const {
294
+ page,
295
+ enabled,
296
+ record,
297
+ identity,
298
+ note,
299
+ privacyOptions,
300
+ limits,
301
+ evaluateInPage,
302
+ captureTimeoutMs,
303
+ } = options;
304
+ const globalName = `__browserCommanderUserActions_${identity.traceId}`;
305
+ let active = true,
306
+ detachInit,
307
+ detachBinding;
308
+ const pending = new Set();
309
+ const emit = (payload) => {
310
+ if (!active) {
311
+ return Promise.resolve();
312
+ }
313
+ if (pending.size >= (limits.maxQueuedUserActions ?? 5000)) {
314
+ return Promise.reject(new Error('user action writer busy'));
315
+ }
316
+ const promise = record(TRACE_EVENT.INTERACTION, {
317
+ ...payload,
318
+ actionId: identity.nextActionId(),
319
+ });
320
+ pending.add(promise);
321
+ promise.finally(() => pending.delete(promise)).catch(() => {});
322
+ return promise;
323
+ };
324
+ const evaluate = async (fn, arg) => {
325
+ const frames = framesOf(page);
326
+ return await withDeadline(
327
+ frames.length
328
+ ? Promise.all(
329
+ frames.map((frame) => frame.evaluate(fn, arg).catch(() => null))
330
+ )
331
+ : Promise.resolve(evaluateInPage(fn, arg)).then((result) => [result]),
332
+ captureTimeoutMs,
333
+ 'trace user actions'
334
+ );
335
+ };
336
+ async function install() {
337
+ if (!enabled) {
338
+ return;
339
+ }
340
+ try {
341
+ detachBinding = await attachBinding(page, globalName, emit).catch(
342
+ (error) => {
343
+ note(`using queued user actions: ${error.message}`);
344
+ return null;
345
+ }
346
+ );
347
+ const config = {
348
+ globalName,
349
+ binding: detachBinding ? USER_ACTION_BINDING : null,
350
+ maxQueued: limits.maxQueuedUserActions ?? 5000,
351
+ redactSelectors: privacyOptions.redactSelectors,
352
+ ignoreSelectors: options.ignoreSelectors ?? [],
353
+ redacted: '[redacted]',
354
+ };
355
+ detachInit = await installInitScript({
356
+ page,
357
+ fn: installUserActionRecorderInPage,
358
+ arg: config,
359
+ note,
360
+ });
361
+ await evaluate(installUserActionRecorderInPage, config);
362
+ } catch (error) {
363
+ note(`could not install user action recorder: ${error.message}`);
364
+ await record(TRACE_EVENT.DROPPED, {
365
+ reason: TRACE_DROP_REASON.CAPTURE_FAILED,
366
+ member: 'user-actions',
367
+ detail: error.message,
368
+ });
369
+ }
370
+ }
371
+ async function drain() {
372
+ if (!enabled) {
373
+ return;
374
+ }
375
+ try {
376
+ for (const result of await evaluate(drainUserActionsInPage, globalName)) {
377
+ for (const action of result?.actions ?? []) {
378
+ await emit(action);
379
+ }
380
+ if (result?.dropped) {
381
+ await record(TRACE_EVENT.DROPPED, {
382
+ reason: TRACE_DROP_REASON.SIZE_LIMIT,
383
+ member: 'user-actions',
384
+ detail: `${result.dropped} user actions over the queue limit`,
385
+ });
386
+ }
387
+ }
388
+ await Promise.all([...pending]);
389
+ } catch (error) {
390
+ note(`could not drain user actions: ${error.message}`);
391
+ }
392
+ }
393
+ async function stop() {
394
+ active = false;
395
+ await evaluate(stopUserActionsInPage, globalName).catch((error) =>
396
+ note(error.message)
397
+ );
398
+ try {
399
+ await detachInit?.();
400
+ } finally {
401
+ detachBinding?.();
402
+ }
403
+ }
404
+ return { install, drain, stop };
405
+ }