browser-commander 0.17.1 → 0.18.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 (70) hide show
  1. package/.jscpd-baseline.json +7 -18
  2. package/CHANGELOG.md +48 -0
  3. package/README.md +84 -0
  4. package/eslint.config.js +5 -0
  5. package/package.json +4 -1
  6. package/scripts/version-and-commit.mjs +13 -2
  7. package/src/bindings.js +13 -1
  8. package/src/browser/connector.js +15 -3
  9. package/src/browser/launcher.js +14 -2
  10. package/src/browser/navigation.js +63 -0
  11. package/src/core/dialog-manager.js +48 -0
  12. package/src/core/navigation-manager.js +83 -87
  13. package/src/core/navigation-readiness.js +133 -0
  14. package/src/core/readiness.js +478 -0
  15. package/src/downloads/attach.js +58 -0
  16. package/src/downloads/destination.js +125 -0
  17. package/src/downloads/manager.js +453 -0
  18. package/src/downloads/naming.js +246 -0
  19. package/src/downloads/sources.js +188 -0
  20. package/src/downloads/store.js +256 -0
  21. package/src/exports.js +72 -0
  22. package/src/factory.js +55 -0
  23. package/src/interactions/click-activation.js +317 -0
  24. package/src/interactions/click-result.js +108 -0
  25. package/src/interactions/click.js +766 -352
  26. package/src/tests/index.js +75 -11
  27. package/src/tests/tracing.js +236 -0
  28. package/src/traces/bundle.js +323 -0
  29. package/src/traces/observers.js +246 -0
  30. package/src/traces/page-capture.js +365 -0
  31. package/src/traces/reader.js +226 -0
  32. package/src/traces/recorder.js +437 -0
  33. package/src/traces/redaction.js +231 -0
  34. package/src/traces/schema.js +165 -0
  35. package/src/traces/viewer.js +302 -0
  36. package/tests/e2e/click-readiness.e2e.test.js +240 -0
  37. package/tests/e2e/downloads.e2e.test.js +188 -0
  38. package/tests/e2e/traces.e2e.test.js +457 -0
  39. package/tests/helpers/click-fixtures.js +62 -0
  40. package/tests/helpers/download-fixtures.js +125 -0
  41. package/tests/helpers/download-server.js +109 -0
  42. package/tests/helpers/e2e-browser.js +67 -0
  43. package/tests/helpers/file-modes.js +31 -0
  44. package/tests/helpers/fixture-server.js +56 -0
  45. package/tests/helpers/mocks.js +63 -66
  46. package/tests/helpers/readiness-server.js +96 -0
  47. package/tests/helpers/temp-directory.js +44 -0
  48. package/tests/helpers/trace-fixtures.js +198 -0
  49. package/tests/helpers/trace-server.js +117 -0
  50. package/tests/unit/core/dialog-manager.test.js +118 -10
  51. package/tests/unit/core/navigation-manager.test.js +162 -0
  52. package/tests/unit/core/readiness.test.js +517 -0
  53. package/tests/unit/downloads/attach.test.js +220 -0
  54. package/tests/unit/downloads/destination.test.js +114 -0
  55. package/tests/unit/downloads/manager.test.js +354 -0
  56. package/tests/unit/downloads/naming.test.js +174 -0
  57. package/tests/unit/downloads/store.test.js +215 -0
  58. package/tests/unit/interactions/click-activation.test.js +283 -0
  59. package/tests/unit/interactions/click-result.test.js +90 -0
  60. package/tests/unit/interactions/click.test.js +409 -200
  61. package/tests/unit/scripts/check-release-format.test.js +4 -1
  62. package/tests/unit/scripts/push-failure-classifier.test.js +153 -0
  63. package/tests/unit/scripts/push-with-rebase-retry.test.js +302 -0
  64. package/tests/unit/tests/tracing.test.js +299 -0
  65. package/tests/unit/traces/bundle.test.js +260 -0
  66. package/tests/unit/traces/reader.test.js +208 -0
  67. package/tests/unit/traces/recorder.test.js +562 -0
  68. package/tests/unit/traces/redaction.test.js +170 -0
  69. package/tests/unit/traces/schema.test.js +92 -0
  70. package/tests/unit/traces/viewer.test.js +289 -0
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Reading a trace bundle (issue #87).
3
+ *
4
+ * The reader assumes nothing finished cleanly. A run that was killed leaves a
5
+ * bundle with no manifest and a half-written last line; that bundle still
6
+ * holds the evidence someone is looking for, so it is repaired on open rather
7
+ * than rejected.
8
+ */
9
+
10
+ import fs from 'node:fs/promises';
11
+ import path from 'node:path';
12
+ import {
13
+ assertReadableManifest,
14
+ createManifest,
15
+ sequenceName,
16
+ TRACE_FILES,
17
+ TRACE_MODE,
18
+ TRACE_OUTCOME,
19
+ } from './schema.js';
20
+
21
+ async function readIfPresent(file) {
22
+ try {
23
+ return await fs.readFile(file, 'utf8');
24
+ } catch (error) {
25
+ if (error.code === 'ENOENT') {
26
+ return null;
27
+ }
28
+ throw error;
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Parse an NDJSON body, keeping everything before the first unreadable line.
34
+ *
35
+ * @param {string|null} body - File contents
36
+ * @returns {Object} `{records, truncated}`
37
+ */
38
+ export function parseNdjson(body) {
39
+ const records = [];
40
+ let truncated = false;
41
+
42
+ for (const line of (body ?? '').split('\n')) {
43
+ if (line.trim() === '') {
44
+ continue;
45
+ }
46
+ try {
47
+ records.push(JSON.parse(line));
48
+ } catch {
49
+ // A process killed mid-write leaves one partial line. Everything before
50
+ // it is still true, so the trace is readable up to that point.
51
+ truncated = true;
52
+ break;
53
+ }
54
+ }
55
+
56
+ return { records, truncated };
57
+ }
58
+
59
+ /**
60
+ * Open a trace bundle.
61
+ *
62
+ * @param {string} bundlePath - Path to the bundle directory
63
+ * @returns {Promise<Object>} `{path, manifest, events, checkpoints, truncated}`
64
+ */
65
+ export async function readTrace(bundlePath) {
66
+ const root = path.resolve(bundlePath);
67
+ const manifestBody = await readIfPresent(
68
+ path.join(root, TRACE_FILES.MANIFEST)
69
+ );
70
+ const eventsBody = await readIfPresent(path.join(root, TRACE_FILES.EVENTS));
71
+
72
+ if (manifestBody === null && eventsBody === null) {
73
+ throw new Error(`no trace bundle at ${root}`);
74
+ }
75
+
76
+ const { records: events, truncated } = parseNdjson(eventsBody);
77
+
78
+ let manifest;
79
+ if (manifestBody === null) {
80
+ // No manifest means the run never reached `stop()`. The timeline is the
81
+ // record of what happened; the manifest is rebuilt around it.
82
+ const started = events.find((event) => event.kind === 'trace.start');
83
+ manifest = createManifest({
84
+ mode: started?.mode ?? TRACE_MODE.CHECKPOINTS,
85
+ startedAt: started?.at ?? null,
86
+ stoppedAt: events.at(-1)?.at ?? null,
87
+ outcome: TRACE_OUTCOME.TRUNCATED,
88
+ engine: started?.engine ?? null,
89
+ events: started?.events ?? [],
90
+ dom: started?.dom ?? {},
91
+ counts: {
92
+ events: events.length,
93
+ checkpoints: events.filter((event) => event.kind === 'checkpoint')
94
+ .length,
95
+ },
96
+ });
97
+ } else {
98
+ try {
99
+ manifest = JSON.parse(manifestBody);
100
+ } catch {
101
+ throw new Error(
102
+ `${TRACE_FILES.MANIFEST} in ${root} is not readable JSON`
103
+ );
104
+ }
105
+ assertReadableManifest(manifest);
106
+ }
107
+
108
+ const checkpoints = events
109
+ .filter((event) => event.kind === 'checkpoint')
110
+ .map((event) => ({
111
+ index: event.index,
112
+ name: event.name,
113
+ actor: event.actor,
114
+ reason: event.reason,
115
+ url: event.url,
116
+ at: event.at,
117
+ sequence: event.sequence,
118
+ members: event.members ?? {},
119
+ }));
120
+
121
+ return {
122
+ path: root,
123
+ manifest,
124
+ events,
125
+ checkpoints,
126
+ truncated: truncated || manifest.outcome !== TRACE_OUTCOME.COMPLETE,
127
+ /**
128
+ * Read one checkpoint's HTML.
129
+ *
130
+ * @param {number} index - Checkpoint number
131
+ * @returns {Promise<string|null>} The captured markup
132
+ */
133
+ html: (index) =>
134
+ readIfPresent(
135
+ path.join(
136
+ root,
137
+ TRACE_FILES.CHECKPOINTS_DIR,
138
+ `${sequenceName(index)}.html`
139
+ )
140
+ ),
141
+ /**
142
+ * Read one checkpoint's live control state.
143
+ *
144
+ * @param {number} index - Checkpoint number
145
+ * @returns {Promise<Object|null>} The captured state
146
+ */
147
+ state: async (index) => {
148
+ const body = await readIfPresent(
149
+ path.join(
150
+ root,
151
+ TRACE_FILES.CHECKPOINTS_DIR,
152
+ `${sequenceName(index)}.state.json`
153
+ )
154
+ );
155
+ return body === null ? null : JSON.parse(body);
156
+ },
157
+ /**
158
+ * Read the mutation batches recorded after one checkpoint.
159
+ *
160
+ * @param {number} index - Checkpoint number
161
+ * @returns {Promise<Object[]>} Ordered batches
162
+ */
163
+ mutations: async (index) => {
164
+ const body = await readIfPresent(
165
+ path.join(
166
+ root,
167
+ TRACE_FILES.MUTATIONS_DIR,
168
+ `${sequenceName(index)}.ndjson`
169
+ )
170
+ );
171
+ return parseNdjson(body).records;
172
+ },
173
+ };
174
+ }
175
+
176
+ /**
177
+ * Diff two checkpoints' control state.
178
+ *
179
+ * `{before, after, actor}` per control is the shape a reviewer needs to answer
180
+ * "what did this step change?" without reading two HTML files side by side.
181
+ *
182
+ * @param {Object} before - State of the earlier checkpoint
183
+ * @param {Object} after - State of the later checkpoint
184
+ * @returns {Object[]} One record per changed control
185
+ */
186
+ export function diffControlState(before, after) {
187
+ const index = new Map(
188
+ (before?.controls ?? []).map((control) => [control.path, control])
189
+ );
190
+ const changes = [];
191
+
192
+ for (const control of after?.controls ?? []) {
193
+ const previous = index.get(control.path);
194
+ index.delete(control.path);
195
+ if (!previous) {
196
+ changes.push({
197
+ path: control.path,
198
+ change: 'added',
199
+ after: control.value,
200
+ });
201
+ continue;
202
+ }
203
+ if (
204
+ previous.value !== control.value ||
205
+ previous.checked !== control.checked
206
+ ) {
207
+ changes.push({
208
+ path: control.path,
209
+ change: 'changed',
210
+ before:
211
+ previous.checked === undefined ? previous.value : previous.checked,
212
+ after: control.checked === undefined ? control.value : control.checked,
213
+ });
214
+ }
215
+ }
216
+
217
+ for (const control of index.values()) {
218
+ changes.push({
219
+ path: control.path,
220
+ change: 'removed',
221
+ before: control.value,
222
+ });
223
+ }
224
+
225
+ return changes;
226
+ }
@@ -0,0 +1,437 @@
1
+ /**
2
+ * The trace recorder (issue #87).
3
+ *
4
+ * A consumer starts a recorder, names checkpoints and stops it. Nothing in
5
+ * that path requires the raw Playwright or Puppeteer page: the recorder is
6
+ * the thing that knows how each engine reports a console message or a failed
7
+ * request, so every caller does not have to.
8
+ */
9
+
10
+ import {
11
+ captureSnapshotInPage,
12
+ drainMutationsInPage,
13
+ installMutationRecorderInPage,
14
+ RECORDER_GLOBAL,
15
+ } from './page-capture.js';
16
+ import { openTraceBundle } from './bundle.js';
17
+ import { attachTimelineObservers } from './observers.js';
18
+ import {
19
+ normalizePrivacyOptions,
20
+ redactUrl,
21
+ redactValue,
22
+ REDACTED,
23
+ } from './redaction.js';
24
+ import {
25
+ createManifest,
26
+ DEFAULT_CAPTURE_TIMEOUT,
27
+ TRACE_DROP_REASON,
28
+ TRACE_EVENT,
29
+ TRACE_EVENT_SOURCES,
30
+ TRACE_MODE,
31
+ TRACE_OUTCOME,
32
+ } from './schema.js';
33
+
34
+ /** DOM capture defaults: what a checkpoint holds unless the caller narrows it. */
35
+ const DEFAULT_DOM_OPTIONS = Object.freeze({
36
+ html: true,
37
+ liveControlState: true,
38
+ mutations: false,
39
+ openShadowRoots: true,
40
+ });
41
+
42
+ function normalizeMode(mode) {
43
+ const values = Object.values(TRACE_MODE);
44
+ if (!values.includes(mode)) {
45
+ throw new Error(`trace mode must be one of ${values.join(', ')}`);
46
+ }
47
+ return mode;
48
+ }
49
+
50
+ function normalizeEvents(events) {
51
+ if (events === false) {
52
+ return [];
53
+ }
54
+ if (events === undefined || events === true) {
55
+ return [...TRACE_EVENT_SOURCES];
56
+ }
57
+ if (!Array.isArray(events)) {
58
+ throw new Error('trace events must be an array of event names');
59
+ }
60
+ for (const name of events) {
61
+ if (!TRACE_EVENT_SOURCES.includes(name)) {
62
+ throw new Error(
63
+ `unknown trace event source "${name}"; expected one of ${TRACE_EVENT_SOURCES.join(', ')}`
64
+ );
65
+ }
66
+ }
67
+ return [...new Set(events)];
68
+ }
69
+
70
+ /**
71
+ * Run a capture with its own deadline.
72
+ *
73
+ * A page that stopped answering must cost the trace one record, not the run.
74
+ *
75
+ * @param {Promise} work - The capture
76
+ * @param {number} timeoutMs - Budget
77
+ * @param {string} what - Name used in the timeout message
78
+ * @returns {Promise<*>} The capture's result
79
+ */
80
+ async function withDeadline(work, timeoutMs, what) {
81
+ if (!timeoutMs) {
82
+ return work;
83
+ }
84
+ let timer;
85
+ try {
86
+ return await Promise.race([
87
+ work,
88
+ new Promise((_resolve, reject) => {
89
+ timer = setTimeout(
90
+ () => reject(new Error(`${what} timed out after ${timeoutMs}ms`)),
91
+ timeoutMs
92
+ );
93
+ }),
94
+ ]);
95
+ } finally {
96
+ clearTimeout(timer);
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Start recording a session.
102
+ *
103
+ * @param {Object} options - Recorder options, see `commander.startTrace()`
104
+ * @returns {Promise<Object>} The running trace
105
+ */
106
+ export async function startTrace(options = {}) {
107
+ const {
108
+ commander,
109
+ page = commander?.page,
110
+ output,
111
+ mode = TRACE_MODE.CHECKPOINTS,
112
+ screenshots = 'checkpoints',
113
+ dom = {},
114
+ events,
115
+ privacy = {},
116
+ limits = {},
117
+ strict = false,
118
+ captureTimeoutMs = DEFAULT_CAPTURE_TIMEOUT,
119
+ commanderVersion = null,
120
+ log = commander?.log,
121
+ now = () => Date.now(),
122
+ } = options;
123
+
124
+ if (!page) {
125
+ throw new Error('startTrace requires a page or a commander');
126
+ }
127
+
128
+ const recorderMode = normalizeMode(mode);
129
+ const domOptions = { ...DEFAULT_DOM_OPTIONS, ...dom };
130
+ if (recorderMode === TRACE_MODE.CONTINUOUS && dom.mutations !== false) {
131
+ domOptions.mutations = true;
132
+ }
133
+ const eventSources = normalizeEvents(events);
134
+ const privacyOptions = normalizePrivacyOptions(privacy);
135
+ const engine = commander?.engine ?? null;
136
+
137
+ const bundle = await openTraceBundle({ output, limits, strict, now });
138
+ const startedAt = new Date(now()).toISOString();
139
+
140
+ let stopped = false;
141
+ let checkpointIndex = 0;
142
+ const checkpoints = [];
143
+
144
+ const note = (message) => log?.debug?.(`[trace] ${message}`);
145
+
146
+ /**
147
+ * Record an event on the shared timeline.
148
+ *
149
+ * @param {string} kind - One of TRACE_EVENT
150
+ * @param {Object} [payload] - Event fields, redacted before writing
151
+ * @returns {Promise<Object|null>} The written event
152
+ */
153
+ async function record(kind, payload = {}) {
154
+ if (stopped && kind !== TRACE_EVENT.TRACE_STOP) {
155
+ return null;
156
+ }
157
+ return await bundle.appendEvent({
158
+ kind,
159
+ ...redactValue(payload, privacyOptions),
160
+ });
161
+ }
162
+
163
+ async function evaluateInPage(fn, argument) {
164
+ // Both engines take `(fn, arg)`; the commander's own evaluate already
165
+ // normalizes the difference, and is used when it is available.
166
+ if (commander?.evaluate) {
167
+ return await commander.evaluate(fn, argument);
168
+ }
169
+ return await page.evaluate(fn, argument);
170
+ }
171
+
172
+ async function installMutationRecorder() {
173
+ if (!domOptions.mutations) {
174
+ return;
175
+ }
176
+ try {
177
+ await evaluateInPage(installMutationRecorderInPage, {
178
+ globalName: RECORDER_GLOBAL,
179
+ redactSelectors: privacyOptions.redactSelectors,
180
+ redacted: REDACTED,
181
+ maxQueued: limits.maxQueuedMutations ?? 5000,
182
+ });
183
+ } catch (error) {
184
+ await bundle.drop({
185
+ reason: TRACE_DROP_REASON.CAPTURE_FAILED,
186
+ member: 'mutation-recorder',
187
+ detail: error.message,
188
+ });
189
+ }
190
+ }
191
+
192
+ async function drainMutations(index) {
193
+ if (!domOptions.mutations) {
194
+ return null;
195
+ }
196
+ try {
197
+ const drained = await withDeadline(
198
+ evaluateInPage(drainMutationsInPage, RECORDER_GLOBAL),
199
+ captureTimeoutMs,
200
+ 'trace mutation drain'
201
+ );
202
+ if (drained?.dropped) {
203
+ await bundle.drop({
204
+ reason: TRACE_DROP_REASON.SIZE_LIMIT,
205
+ member: 'mutations',
206
+ detail: `${drained.dropped} records over the in-page queue limit`,
207
+ });
208
+ }
209
+ const member = await bundle.writeMutations(index, drained?.batches ?? []);
210
+ if (member) {
211
+ await record(TRACE_EVENT.MUTATIONS, {
212
+ member,
213
+ batches: drained.batches.length,
214
+ checkpoint: index,
215
+ });
216
+ }
217
+ return member;
218
+ } catch (error) {
219
+ await bundle.drop({
220
+ reason: TRACE_DROP_REASON.CAPTURE_FAILED,
221
+ member: 'mutations',
222
+ detail: error.message,
223
+ });
224
+ return null;
225
+ }
226
+ }
227
+
228
+ async function screenshot(reason) {
229
+ const wanted =
230
+ screenshots === true ||
231
+ screenshots === 'checkpoints' ||
232
+ (screenshots === 'only-on-failure' && reason === 'failure');
233
+ if (!wanted || typeof page.screenshot !== 'function') {
234
+ return null;
235
+ }
236
+ try {
237
+ const shot = await withDeadline(
238
+ page.screenshot({ type: 'png' }),
239
+ captureTimeoutMs,
240
+ 'trace screenshot'
241
+ );
242
+ return Buffer.isBuffer(shot) ? shot : Buffer.from(shot);
243
+ } catch (error) {
244
+ await bundle.drop({
245
+ reason: TRACE_DROP_REASON.CAPTURE_FAILED,
246
+ member: 'screenshot',
247
+ detail: error.message,
248
+ });
249
+ return null;
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Capture a named checkpoint.
255
+ *
256
+ * @param {string} name - What this moment is
257
+ * @param {Object} [checkpointOptions] - `{actor, reason, screenshots}`
258
+ * @returns {Promise<Object|null>} `{index, name, members}`
259
+ */
260
+ async function checkpoint(name, checkpointOptions = {}) {
261
+ if (stopped) {
262
+ throw new Error('this trace has already been stopped');
263
+ }
264
+
265
+ const { actor = 'automation', reason = 'checkpoint' } = checkpointOptions;
266
+ const index = ++checkpointIndex;
267
+
268
+ // Mutations are drained first so the batches belong to the interval that
269
+ // ended here, not to the one that starts now.
270
+ await drainMutations(index - 1 > 0 ? index - 1 : 0);
271
+
272
+ let captured = null;
273
+ try {
274
+ captured = await withDeadline(
275
+ evaluateInPage(captureSnapshotInPage, {
276
+ redactSelectors: privacyOptions.redactSelectors,
277
+ redactAttributes: privacyOptions.redactAttributes,
278
+ redacted: REDACTED,
279
+ html: domOptions.html,
280
+ liveControlState: domOptions.liveControlState,
281
+ openShadowRoots: domOptions.openShadowRoots,
282
+ maxHtmlBytes: limits.maxHtmlBytes ?? 0,
283
+ }),
284
+ captureTimeoutMs,
285
+ 'trace checkpoint capture'
286
+ );
287
+ } catch (error) {
288
+ await bundle.drop({
289
+ reason: /closed/i.test(error.message)
290
+ ? TRACE_DROP_REASON.PAGE_CLOSED
291
+ : TRACE_DROP_REASON.CAPTURE_FAILED,
292
+ member: `checkpoints/${index}`,
293
+ detail: error.message,
294
+ });
295
+ }
296
+
297
+ const shot = await screenshot(reason);
298
+ const state = captured?.state
299
+ ? redactValue(captured.state, privacyOptions)
300
+ : null;
301
+
302
+ const members = await bundle.writeCheckpoint({
303
+ index,
304
+ html: captured?.html ?? undefined,
305
+ state: state ? { ...state, name, actor, reason } : undefined,
306
+ screenshot: shot ?? undefined,
307
+ });
308
+
309
+ const entry = {
310
+ index,
311
+ name,
312
+ actor,
313
+ reason,
314
+ url: state ? redactUrl(state.url, privacyOptions) : null,
315
+ truncated: Boolean(captured?.truncated),
316
+ members,
317
+ };
318
+ checkpoints.push(entry);
319
+ await record(TRACE_EVENT.CHECKPOINT, entry);
320
+
321
+ // A navigation replaces the document and with it the observer, so the
322
+ // recorder is reinstalled at every checkpoint rather than only at start.
323
+ await installMutationRecorder();
324
+
325
+ return entry;
326
+ }
327
+
328
+ const detachers = attachTimelineObservers({
329
+ commander,
330
+ page,
331
+ eventSources,
332
+ record,
333
+ note,
334
+ });
335
+
336
+ await record(TRACE_EVENT.TRACE_START, {
337
+ mode: recorderMode,
338
+ engine,
339
+ dom: domOptions,
340
+ events: eventSources,
341
+ });
342
+ await installMutationRecorder();
343
+
344
+ /**
345
+ * Stop recording and write the manifest.
346
+ *
347
+ * @param {Object} [stopOptions] - `{outcome, discard, error}`
348
+ * @returns {Promise<Object>} `{path, manifest, checkpoints}`
349
+ */
350
+ async function stop(stopOptions = {}) {
351
+ if (stopped) {
352
+ return stopped;
353
+ }
354
+ const { discard = false, error = null } = stopOptions;
355
+
356
+ await drainMutations(checkpointIndex);
357
+ if (error) {
358
+ await record(TRACE_EVENT.PAGE_ERROR, {
359
+ message: error.message ?? String(error),
360
+ stack: error.stack ?? null,
361
+ fatal: true,
362
+ });
363
+ }
364
+ await record(TRACE_EVENT.TRACE_STOP, { discarded: discard });
365
+ stopped = true;
366
+
367
+ for (const detach of detachers.reverse()) {
368
+ try {
369
+ await detach();
370
+ } catch (detachError) {
371
+ note(`could not detach a listener: ${detachError.message}`);
372
+ }
373
+ }
374
+
375
+ const manifest = await bundle.close(
376
+ createManifest({
377
+ mode: recorderMode,
378
+ startedAt,
379
+ stoppedAt: new Date(now()).toISOString(),
380
+ outcome: TRACE_OUTCOME.COMPLETE,
381
+ commanderVersion,
382
+ engine,
383
+ dom: domOptions,
384
+ events: eventSources,
385
+ privacy: {
386
+ redactSelectors: privacyOptions.redactSelectors,
387
+ redactAttributes: privacyOptions.redactAttributes,
388
+ redactQueryParams: privacyOptions.redactQueryParams,
389
+ hasCallback: Boolean(privacyOptions.redact),
390
+ },
391
+ limits,
392
+ })
393
+ );
394
+
395
+ const result = {
396
+ path: bundle.root,
397
+ manifest,
398
+ checkpoints: [...checkpoints],
399
+ problems: [...bundle.problems],
400
+ };
401
+ stopped = result;
402
+
403
+ if (discard) {
404
+ const fs = await import('node:fs/promises');
405
+ await fs.rm(bundle.root, { recursive: true, force: true });
406
+ result.discarded = true;
407
+ }
408
+
409
+ return result;
410
+ }
411
+
412
+ return {
413
+ path: bundle.root,
414
+ mode: recorderMode,
415
+ checkpoint,
416
+ /**
417
+ * Record an event a caller cares about on the same timeline.
418
+ *
419
+ * @param {string} name - What happened
420
+ * @param {Object} [data] - Details, redacted before writing
421
+ * @returns {Promise<Object|null>} The written event
422
+ */
423
+ event: (name, data = {}) =>
424
+ record(TRACE_EVENT.INTERACTION, {
425
+ action: name,
426
+ actor: 'caller',
427
+ ...data,
428
+ }),
429
+ stop,
430
+ get stopped() {
431
+ return Boolean(stopped);
432
+ },
433
+ get checkpoints() {
434
+ return [...checkpoints];
435
+ },
436
+ };
437
+ }