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
@@ -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
+ }
@@ -583,7 +583,9 @@ export async function openTraceLinks(options = {}) {
583
583
  const file = await resolveOutput(output);
584
584
  const sections = chosenSections(include);
585
585
  const problems = [];
586
- const seenDom = new Set();
586
+ // Only the current interval is remembered; completed intervals never recur.
587
+ let mutationMember = null;
588
+ let mutationOffset = 0;
587
589
 
588
590
  await fs.mkdir(path.dirname(file), { recursive: true });
589
591
  const handle = await fs.open(file, 'w', 0o600);
@@ -626,7 +628,7 @@ export async function openTraceLinks(options = {}) {
626
628
  * @param {Object} event - The event as written to the bundle
627
629
  * @returns {Promise<void>}
628
630
  */
629
- async event(event) {
631
+ async event(event, source = {}) {
630
632
  if (!event) {
631
633
  return;
632
634
  }
@@ -636,7 +638,45 @@ export async function openTraceLinks(options = {}) {
636
638
  bundlePath &&
637
639
  [TRACE_EVENT.CHECKPOINT, TRACE_EVENT.MUTATIONS].includes(event.kind)
638
640
  ) {
639
- const opened = await readTrace(bundlePath);
641
+ const root = source.root ?? bundlePath;
642
+ let batches = [];
643
+ if (event.kind === TRACE_EVENT.MUTATIONS) {
644
+ const member = path.join(root, event.member);
645
+ if (member !== mutationMember) {
646
+ mutationMember = member;
647
+ mutationOffset = 0;
648
+ }
649
+ const input = await fs.open(member, 'r');
650
+ try {
651
+ const size = (await input.stat()).size;
652
+ const bytes = Buffer.alloc(Math.max(0, size - mutationOffset));
653
+ await input.read(bytes, 0, bytes.length, mutationOffset);
654
+ mutationOffset = size;
655
+ batches = bytes
656
+ .toString('utf8')
657
+ .split('\n')
658
+ .filter(Boolean)
659
+ .map(JSON.parse);
660
+ } finally {
661
+ await input.close();
662
+ }
663
+ }
664
+ const opened = {
665
+ html: () =>
666
+ event.members?.html
667
+ ? fs.readFile(path.join(root, event.members.html), 'utf8')
668
+ : null,
669
+ state: async () =>
670
+ event.members?.state
671
+ ? JSON.parse(
672
+ await fs.readFile(
673
+ path.join(root, event.members.state),
674
+ 'utf8'
675
+ )
676
+ )
677
+ : null,
678
+ mutations: () => batches,
679
+ };
640
680
  await append(
641
681
  await domLinks(
642
682
  {
@@ -644,8 +684,7 @@ export async function openTraceLinks(options = {}) {
644
684
  checkpoints: event.kind === TRACE_EVENT.CHECKPOINT ? [event] : [],
645
685
  events: event.kind === TRACE_EVENT.MUTATIONS ? [event] : [],
646
686
  },
647
- dom,
648
- seenDom
687
+ dom
649
688
  )
650
689
  );
651
690
  }
@@ -20,7 +20,7 @@ import {
20
20
  RECORDER_GLOBAL,
21
21
  stopMutationRecorderInPage,
22
22
  } from './page-capture.js';
23
- import { REDACTED } from './redaction.js';
23
+ import { redactValue, REDACTED } from './redaction.js';
24
24
  import { TRACE_DROP_REASON, TRACE_EVENT } from './schema.js';
25
25
 
26
26
  /** In-page records kept before the recorder starts counting drops instead. */
@@ -57,11 +57,14 @@ 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,
66
+ redactAttributes: privacyOptions.redactAttributes,
67
+ useDefaults: privacyOptions.useDefaults,
65
68
  redacted: REDACTED,
66
69
  ignoreSelectors: domOptions.ignoreSelectors ?? [],
67
70
  maxQueued: limits.maxQueuedMutations ?? DEFAULT_MAX_QUEUED_MUTATIONS,
@@ -166,7 +169,7 @@ export function createMutationStream(options) {
166
169
  }
167
170
  try {
168
171
  const drained = await withDeadline(
169
- evaluateInFrames(drainMutationsInPage, RECORDER_GLOBAL),
172
+ evaluateInFrames(drainMutationsInPage, globalName),
170
173
  captureTimeoutMs,
171
174
  'trace mutation drain'
172
175
  );
@@ -195,13 +198,17 @@ export function createMutationStream(options) {
195
198
  // frame; one ordered file is what a replay reads.
196
199
  batches.sort((left, right) => (left.at ?? 0) - (right.at ?? 0));
197
200
 
198
- const member = await bundle.writeMutations(index, batches);
201
+ const member = await bundle.writeMutations(
202
+ index,
203
+ redactValue(batches, privacyOptions)
204
+ );
199
205
  if (member) {
200
206
  await record(TRACE_EVENT.MUTATIONS, {
201
207
  member,
202
208
  batches: batches.length,
203
209
  checkpoint: index,
204
210
  frames: drained.length,
211
+ truncated: bundle.mutationTruncated,
205
212
  });
206
213
  }
207
214
  return member;
@@ -223,7 +230,7 @@ export function createMutationStream(options) {
223
230
  return;
224
231
  }
225
232
  try {
226
- await evaluateInFrames(stopMutationRecorderInPage, RECORDER_GLOBAL);
233
+ await evaluateInFrames(stopMutationRecorderInPage, globalName);
227
234
  } catch (error) {
228
235
  note?.(`could not stop the in-page recorder: ${error.message}`);
229
236
  }
@@ -1,6 +1,11 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { withDeadline } from './deadline.js';
4
+ import {
5
+ normalizePrivacyOptions,
6
+ redactText,
7
+ redactHeaders as privateHeaders,
8
+ } from './redaction.js';
4
9
 
5
10
  const PRIVATE_HEADERS = new Set([
6
11
  'cookie',
@@ -9,11 +14,20 @@ const PRIVATE_HEADERS = new Set([
9
14
  'set-cookie',
10
15
  ]);
11
16
  export function redactHeaders(headers = {}) {
12
- return Object.fromEntries(
13
- Object.entries(headers).map(([key, value]) => [
14
- key.toLowerCase(),
15
- PRIVATE_HEADERS.has(key.toLowerCase()) ? '[redacted]' : value,
16
- ])
17
+ return privateHeaders(
18
+ Object.fromEntries(
19
+ Object.entries(headers).map(([key, value]) => [
20
+ key.toLowerCase(),
21
+ PRIVATE_HEADERS.has(key.toLowerCase()) ? '[redacted]' : value,
22
+ ])
23
+ ),
24
+ normalizePrivacyOptions()
25
+ );
26
+ }
27
+
28
+ export function isTextBody(contentType = '') {
29
+ return /^(?:text\/|application\/(?:[\w.+-]*json|[\w.+-]*xml|javascript|x-www-form-urlencoded))|multipart\/form-data/i.test(
30
+ contentType
17
31
  );
18
32
  }
19
33
 
@@ -81,10 +95,12 @@ export function networkHar(events) {
81
95
  content: {
82
96
  size: response.body?.size ?? 0,
83
97
  mimeType: response.contentType ?? '',
84
- ...(response.body
98
+ ...(response.body?.data !== undefined
85
99
  ? {
86
100
  text: response.body.data,
87
- encoding: 'base64',
101
+ ...(response.body.encoding === 'utf8'
102
+ ? {}
103
+ : { encoding: 'base64' }),
88
104
  _truncated: response.body.truncated,
89
105
  }
90
106
  : {}),
@@ -131,7 +147,13 @@ export function validateNetworkOptions(network) {
131
147
  );
132
148
  }
133
149
  }
134
- export function attachNetwork({ page, network, record, note }) {
150
+ export function attachNetwork({
151
+ page,
152
+ network,
153
+ record,
154
+ note,
155
+ privacy = normalizePrivacyOptions(),
156
+ }) {
135
157
  validateNetworkOptions(network);
136
158
  if (!network) {
137
159
  return async () => {};
@@ -172,7 +194,12 @@ export function attachNetwork({ page, network, record, note }) {
172
194
  ['document', 'xhr', 'fetch'].includes(request.resourceType?.()) &&
173
195
  request.postData?.()
174
196
  ? {
175
- postData: Buffer.from(request.postData())
197
+ postData: Buffer.from(
198
+ redactText(request.postData(), privacy, {
199
+ kind: 'network',
200
+ name: 'postData',
201
+ })
202
+ )
176
203
  .subarray(0, maxBodyBytes)
177
204
  .toString('utf8'),
178
205
  postDataTruncated:
@@ -210,9 +237,26 @@ export function attachNetwork({ page, network, record, note }) {
210
237
  options.bodies &&
211
238
  ['document', 'xhr', 'fetch'].includes(resourceType)
212
239
  ) {
213
- const length = Number(headers['content-length']);
214
- if (Number.isFinite(length) && length > maxBodyBytes) {
215
- payload.body = { size: length, truncated: true, data: '' };
240
+ let length = Number(headers['content-length']);
241
+ if (!Number.isFinite(length) && request.sizes) {
242
+ const sizes = await withDeadline(
243
+ request.sizes(),
244
+ 5000,
245
+ 'network sizes'
246
+ ).catch(() => ({}));
247
+ length = sizes.responseBodySize ?? NaN;
248
+ }
249
+ if (!isTextBody(payload.contentType ?? '')) {
250
+ payload.body = {
251
+ size: Number.isFinite(length) ? length : null,
252
+ omitted: 'binary',
253
+ };
254
+ } else if (Number.isFinite(length) && length > maxBodyBytes) {
255
+ payload.body = {
256
+ size: length,
257
+ truncated: true,
258
+ omitted: 'size limit',
259
+ };
216
260
  } else {
217
261
  try {
218
262
  const data = Buffer.from(
@@ -225,7 +269,15 @@ export function attachNetwork({ page, network, record, note }) {
225
269
  payload.body = {
226
270
  size: data.length,
227
271
  truncated: data.length > maxBodyBytes,
228
- data: data.subarray(0, maxBodyBytes).toString('base64'),
272
+ encoding: 'utf8',
273
+ data: Buffer.from(
274
+ redactText(data.toString('utf8'), privacy, {
275
+ kind: 'network',
276
+ name: 'responseBody',
277
+ })
278
+ )
279
+ .subarray(0, maxBodyBytes)
280
+ .toString('utf8'),
229
281
  };
230
282
  } catch (error) {
231
283
  payload.bodyError = error.message;
@@ -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
  }