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
@@ -182,6 +182,23 @@ export function createPageTriggerManager(options = {}) {
182
182
  let isActionRunning = false,
183
183
  isStopping = false;
184
184
  let startQueue = Promise.resolve();
185
+ let pendingStart = null;
186
+ let destroyed = false;
187
+
188
+ function drainPending() {
189
+ if (destroyed || isActionRunning || isStopping || !pendingStart) {
190
+ return;
191
+ }
192
+ const pending = pendingStart;
193
+ pendingStart = null;
194
+ if (
195
+ triggers.includes(pending.trigger) &&
196
+ (!pending.commander.page?.url ||
197
+ pending.commander.page.url() === pending.url)
198
+ ) {
199
+ void startAction(pending.url, pending.commander, pending.readyOn);
200
+ }
201
+ }
185
202
 
186
203
  /**
187
204
  * Register a page trigger
@@ -208,7 +225,7 @@ export function createPageTriggerManager(options = {}) {
208
225
  ) {
209
226
  throw new RangeError(`Unknown trigger readyOn: ${readyOn}`);
210
227
  }
211
- if (!['skip', 'restart'].includes(concurrency)) {
228
+ if (!['skip', 'restart', 'queue'].includes(concurrency)) {
212
229
  throw new RangeError(`Unknown trigger concurrency: ${concurrency}`);
213
230
  }
214
231
 
@@ -242,6 +259,9 @@ export function createPageTriggerManager(options = {}) {
242
259
  const index = triggers.indexOf(triggerConfig);
243
260
  if (index !== -1) {
244
261
  triggers.splice(index, 1);
262
+ if (pendingStart?.trigger === triggerConfig) {
263
+ pendingStart = null;
264
+ }
245
265
  log.debug(() => `📋 Unregistered page trigger: "${name}"`);
246
266
  }
247
267
  };
@@ -298,7 +318,7 @@ export function createPageTriggerManager(options = {}) {
298
318
  }
299
319
 
300
320
  // Wait for action to finish (with timeout)
301
- const timeoutMs = 10000; // 10 second max wait
321
+ const timeoutMs = options.stopGraceMs ?? 10000;
302
322
  let stopTimer;
303
323
  const timeoutPromise = new Promise((resolve) => {
304
324
  stopTimer = setTimeout(() => {
@@ -337,6 +357,7 @@ export function createPageTriggerManager(options = {}) {
337
357
  }
338
358
 
339
359
  log.debug(() => '✅ Action stopped');
360
+ drainPending();
340
361
  }
341
362
 
342
363
  /**
@@ -345,6 +366,9 @@ export function createPageTriggerManager(options = {}) {
345
366
  * @param {Object} commander - BrowserCommander instance
346
367
  */
347
368
  function startAction(url, commander, readyOn = 'networkidle') {
369
+ if (destroyed) {
370
+ return Promise.resolve();
371
+ }
348
372
  // Create context for condition checking
349
373
  const conditionCtx = {
350
374
  url,
@@ -359,14 +383,18 @@ export function createPageTriggerManager(options = {}) {
359
383
  return Promise.resolve();
360
384
  }
361
385
  if (isActionRunning) {
362
- if (matchingTrigger.concurrency === 'skip') {
386
+ if (
387
+ matchingTrigger.concurrency === 'skip' &&
388
+ !isStopping &&
389
+ !currentAbortController?.signal.aborted
390
+ ) {
363
391
  return Promise.resolve();
364
392
  }
365
- return stopCurrentAction().then(() => {
366
- if (!isActionRunning) {
367
- return startAction(url, commander, readyOn);
368
- }
369
- });
393
+ // One latest start bounds memory while preserving readiness during stop.
394
+ pendingStart = { url, commander, readyOn, trigger: matchingTrigger };
395
+ return matchingTrigger.concurrency === 'restart'
396
+ ? stopCurrentAction()
397
+ : Promise.resolve();
370
398
  }
371
399
 
372
400
  log.debug(() => `🚀 Starting action "${matchingTrigger.name}" for: ${url}`);
@@ -377,12 +405,15 @@ export function createPageTriggerManager(options = {}) {
377
405
  isActionRunning = true;
378
406
 
379
407
  // Create action context
380
- const context = createActionContext({
381
- url,
382
- abortSignal: currentAbortController.signal,
383
- commander,
384
- triggerName: matchingTrigger.name,
385
- });
408
+ const context = createActionContext(
409
+ {
410
+ url,
411
+ abortSignal: currentAbortController.signal,
412
+ commander,
413
+ triggerName: matchingTrigger.name,
414
+ },
415
+ log
416
+ );
386
417
 
387
418
  // Run action
388
419
  actionPromise = (async () => {
@@ -411,6 +442,7 @@ export function createPageTriggerManager(options = {}) {
411
442
  isActionRunning = false;
412
443
  currentTrigger = null;
413
444
  currentAbortController = null;
445
+ globalThis.queueMicrotask(drainPending);
414
446
  }
415
447
  }
416
448
  })();
@@ -423,130 +455,12 @@ export function createPageTriggerManager(options = {}) {
423
455
  * @param {Object} options
424
456
  * @returns {Object} - Action context
425
457
  */
426
- function createActionContext(options) {
427
- const { url, abortSignal, commander, triggerName } = options;
428
-
429
- /**
430
- * Check if stopped and throw if so
431
- */
432
- function checkStopped() {
433
- if (abortSignal.aborted) {
434
- throw new ActionStoppedError(`Action "${triggerName}" stopped`);
435
- }
436
- }
437
-
438
- /**
439
- * Wrap async function to check abort before and after
440
- */
441
- function wrapAsync(fn) {
442
- return async (...args) => {
443
- checkStopped();
444
- const result = await fn(...args);
445
- checkStopped();
446
- return result;
447
- };
448
- }
449
-
450
- /**
451
- * Create abort-aware loop helper
452
- * Use this instead of for/while loops for stoppability
453
- */
454
- async function forEach(items, callback) {
455
- for (let i = 0; i < items.length; i++) {
456
- checkStopped();
457
- await callback(items[i], i, items);
458
- }
459
- }
460
-
461
- /**
462
- * Wait with abort support
463
- */
464
- async function wait(ms) {
465
- checkStopped();
466
- await new Promise((resolve, reject) => {
467
- const timeout = setTimeout(resolve, ms);
468
- abortSignal.addEventListener(
469
- 'abort',
470
- () => {
471
- clearTimeout(timeout);
472
- reject(new ActionStoppedError());
473
- },
474
- { once: true }
475
- );
476
- });
477
- }
478
-
479
- /**
480
- * Register cleanup callback (called when action stops)
481
- */
482
- const cleanupCallbacks = [];
483
- function onCleanup(callback) {
484
- const unsubscribe = subscribeCallbacks(cleanupCallbacks, callback);
485
- const onAbort = async () => {
486
- try {
487
- await callback();
488
- } catch (e) {
489
- log.debug(() => `⚠️ Cleanup error: ${e.message}`);
490
- }
491
- };
492
- abortSignal.addEventListener('abort', onAbort, { once: true });
493
- return () => {
494
- abortSignal.removeEventListener('abort', onAbort);
495
- unsubscribe();
496
- };
497
- }
498
-
499
- // Wrap all commander methods to be abort-aware
500
- const wrappedCommander = {};
501
- for (const [key, value] of Object.entries(commander)) {
502
- if (
503
- typeof value === 'function' &&
504
- key !== 'destroy' &&
505
- key !== 'pageTrigger'
506
- ) {
507
- wrappedCommander[key] = wrapAsync(value);
508
- } else {
509
- wrappedCommander[key] = value;
510
- }
511
- }
512
-
513
- return {
514
- // URL this action is running for
515
- url,
516
-
517
- // Abort signal - use with fetch() or custom abort logic
518
- abortSignal,
519
-
520
- // Check if action should stop
521
- isStopped: () => abortSignal.aborted,
522
-
523
- // Throw if stopped - call this in loops
524
- checkStopped,
525
-
526
- // Abort-aware iteration helper
527
- forEach,
528
-
529
- // Abort-aware wait
530
- wait,
531
-
532
- // Register cleanup callback
533
- onCleanup,
534
-
535
- // Wrapped commander - all methods throw ActionStoppedError if stopped
536
- commander: wrappedCommander,
537
-
538
- // Original commander (use carefully)
539
- rawCommander: commander,
540
-
541
- // Trigger name for debugging
542
- triggerName,
543
- };
544
- }
545
458
 
546
459
  /**
547
460
  * Handle navigation start - stop current action first
548
461
  */
549
462
  async function onNavigationStart() {
463
+ pendingStart = null;
550
464
  await stopCurrentAction();
551
465
  }
552
466
 
@@ -586,13 +500,18 @@ export function createPageTriggerManager(options = {}) {
586
500
  /**
587
501
  * Cleanup
588
502
  */
589
- const destroy = createTriggerDestroy({
503
+ const cleanup = createTriggerDestroy({
590
504
  subscriptions,
591
505
  getQueue: () => startQueue,
592
506
  stopCurrentAction,
593
507
  triggers,
594
508
  log,
595
509
  });
510
+ const destroy = async () => {
511
+ destroyed = true;
512
+ pendingStart = null;
513
+ await cleanup();
514
+ };
596
515
 
597
516
  return {
598
517
  pageTrigger,
@@ -652,3 +571,123 @@ function createTriggerDestroy({
652
571
  log.debug(() => '📋 PageTriggerManager destroyed');
653
572
  };
654
573
  }
574
+
575
+ function createActionContext(options, log) {
576
+ const { url, abortSignal, commander, triggerName } = options;
577
+
578
+ /**
579
+ * Check if stopped and throw if so
580
+ */
581
+ function checkStopped() {
582
+ if (abortSignal.aborted) {
583
+ throw new ActionStoppedError(`Action "${triggerName}" stopped`);
584
+ }
585
+ }
586
+
587
+ /**
588
+ * Wrap async function to check abort before and after
589
+ */
590
+ function wrapAsync(fn) {
591
+ return async (...args) => {
592
+ checkStopped();
593
+ const result = await fn(...args);
594
+ checkStopped();
595
+ return result;
596
+ };
597
+ }
598
+
599
+ /**
600
+ * Create abort-aware loop helper
601
+ * Use this instead of for/while loops for stoppability
602
+ */
603
+ async function forEach(items, callback) {
604
+ for (let i = 0; i < items.length; i++) {
605
+ checkStopped();
606
+ await callback(items[i], i, items);
607
+ }
608
+ }
609
+
610
+ /**
611
+ * Wait with abort support
612
+ */
613
+ async function wait(ms) {
614
+ checkStopped();
615
+ await new Promise((resolve, reject) => {
616
+ const timeout = setTimeout(resolve, ms);
617
+ abortSignal.addEventListener(
618
+ 'abort',
619
+ () => {
620
+ clearTimeout(timeout);
621
+ reject(new ActionStoppedError());
622
+ },
623
+ { once: true }
624
+ );
625
+ });
626
+ }
627
+
628
+ /**
629
+ * Register cleanup callback (called when action stops)
630
+ */
631
+ const cleanupCallbacks = [];
632
+ function onCleanup(callback) {
633
+ const unsubscribe = subscribeCallbacks(cleanupCallbacks, callback);
634
+ const onAbort = async () => {
635
+ try {
636
+ await callback();
637
+ } catch (e) {
638
+ log.debug(() => `⚠️ Cleanup error: ${e.message}`);
639
+ }
640
+ };
641
+ abortSignal.addEventListener('abort', onAbort, { once: true });
642
+ return () => {
643
+ abortSignal.removeEventListener('abort', onAbort);
644
+ unsubscribe();
645
+ };
646
+ }
647
+
648
+ // Wrap all commander methods to be abort-aware
649
+ const wrappedCommander = {};
650
+ for (const [key, value] of Object.entries(commander)) {
651
+ if (
652
+ typeof value === 'function' &&
653
+ key !== 'destroy' &&
654
+ key !== 'pageTrigger'
655
+ ) {
656
+ wrappedCommander[key] = wrapAsync(value);
657
+ } else {
658
+ wrappedCommander[key] = value;
659
+ }
660
+ }
661
+
662
+ return {
663
+ // URL this action is running for
664
+ url,
665
+
666
+ // Abort signal - use with fetch() or custom abort logic
667
+ abortSignal,
668
+
669
+ // Check if action should stop
670
+ isStopped: () => abortSignal.aborted,
671
+
672
+ // Throw if stopped - call this in loops
673
+ checkStopped,
674
+
675
+ // Abort-aware iteration helper
676
+ forEach,
677
+
678
+ // Abort-aware wait
679
+ wait,
680
+
681
+ // Register cleanup callback
682
+ onCleanup,
683
+
684
+ // Wrapped commander - all methods throw ActionStoppedError if stopped
685
+ commander: wrappedCommander,
686
+
687
+ // Original commander (use carefully)
688
+ rawCommander: commander,
689
+
690
+ // Trigger name for debugging
691
+ triggerName,
692
+ };
693
+ }
package/src/index.js CHANGED
@@ -20,3 +20,5 @@ export {
20
20
  export * from './exports.js';
21
21
  export * from './capture/index.js';
22
22
  export { connectOrLaunch } from './browser/persistent-session.js';
23
+
24
+ export { renderTrace, summarizeTrace } from './traces/render.js';
@@ -1,28 +1,73 @@
1
+ import { withDeadline } from '../traces/deadline.js';
1
2
  import { isVisible } from '../elements/visibility.js';
2
3
 
3
- /** Dismiss only overlays explicitly named by the caller. No page markers. */
4
- export async function dismissOverlays({ page, engine, overlays = [] }) {
4
+ async function dismissVisible({ page, engine, selector, timeout = 1000 }) {
5
+ if (engine === 'playwright') {
6
+ const matches = page.locator(selector);
7
+ for (let index = 0; index < (await matches.count()); index++) {
8
+ const target = matches.nth(index);
9
+ if (await target.isVisible()) {
10
+ await target.click({ timeout });
11
+ return 'dismissed';
12
+ }
13
+ }
14
+ return 'absent';
15
+ }
16
+ const matches = await page.$$(selector);
17
+ try {
18
+ for (const target of matches) {
19
+ if (await isVisible({ page, engine, selector: target })) {
20
+ await target.click({ timeout });
21
+ return 'dismissed';
22
+ }
23
+ }
24
+ return 'absent';
25
+ } finally {
26
+ await Promise.allSettled(
27
+ matches.map(async (target) => await target.dispose?.())
28
+ );
29
+ }
30
+ }
31
+
32
+ /** Dismiss caller-named overlays; a failed dismissal never blocks an action. */
33
+ export async function dismissOverlays({
34
+ page,
35
+ engine,
36
+ overlays = [],
37
+ onDismiss,
38
+ }) {
5
39
  if (!Array.isArray(overlays)) {
6
40
  throw new TypeError('overlays must be an array');
7
41
  }
42
+ const reports = [];
8
43
  for (const item of overlays) {
9
44
  const options = typeof item === 'string' ? { selector: item } : item;
10
45
  if (!options?.selector) {
11
46
  throw new TypeError('overlay requires selector');
12
47
  }
13
- if (engine === 'playwright') {
14
- const target = page.locator(options.selector).first();
15
- if (await target.isVisible()) {
16
- await target.click({ timeout: options.timeout ?? 1000 });
17
- }
18
- } else {
19
- const target = await page.$(options.selector);
20
- if (
21
- target &&
22
- (await isVisible({ page, engine, selector: options.selector }))
23
- ) {
24
- await target.click();
48
+ const report = { selector: options.selector, status: 'absent' };
49
+ try {
50
+ report.status = await withDeadline(
51
+ dismissVisible({ page, engine, ...options }),
52
+ options.timeout || 1000,
53
+ `overlay ${options.selector}`
54
+ );
55
+ } catch (error) {
56
+ report.status = 'error';
57
+ report.error = error;
58
+ }
59
+ reports.push(report);
60
+ for (const callback of [options.onDismiss, onDismiss]) {
61
+ try {
62
+ await withDeadline(
63
+ Promise.resolve(callback?.(report)),
64
+ options.timeout || 1000,
65
+ 'overlay report'
66
+ );
67
+ } catch {
68
+ // Reporting must not interrupt the caller's requested interaction.
25
69
  }
26
70
  }
27
71
  }
72
+ return reports;
28
73
  }
@@ -43,6 +43,7 @@ export async function openTraceBundle(options = {}) {
43
43
  now = () => Date.now(),
44
44
  monotonic = () => performance.now(),
45
45
  onEvent = null,
46
+ nextSequence = null,
46
47
  } = options;
47
48
 
48
49
  if (typeof output !== 'string' || output === '') {
@@ -70,6 +71,9 @@ export async function openTraceBundle(options = {}) {
70
71
  let dropped = 0;
71
72
  const counts = { checkpoints: 0, events: 0, mutationBatches: 0 };
72
73
  const problems = [];
74
+ let mutationIndex = null;
75
+ let mutationBytes = 0;
76
+ let mutationTruncated = false;
73
77
 
74
78
  /** Close the timeline exactly once, after every queued append. */
75
79
  function closeEvents() {
@@ -117,11 +121,13 @@ export async function openTraceBundle(options = {}) {
117
121
  * @returns {Promise<Object|null>} The event as written
118
122
  */
119
123
  async function appendEvent(event, { retry = true } = {}) {
124
+ const number = nextSequence?.() ?? ++sequence;
120
125
  const record = {
121
- sequence: ++sequence,
126
+ sequence: number,
122
127
  at: new Date(now()).toISOString(),
123
128
  monotonicMs: Math.round(monotonic()),
124
129
  ...event,
130
+ ...(nextSequence ? { sequence: number } : {}),
125
131
  };
126
132
  const line = `${JSON.stringify(record)}\n`;
127
133
  const bytes = Buffer.byteLength(line);
@@ -275,10 +281,39 @@ export async function openTraceBundle(options = {}) {
275
281
  return null;
276
282
  }
277
283
  const member = `${TRACE_FILES.MUTATIONS_DIR}/${sequenceName(index)}.ndjson`;
278
- const body = `${batches.map((batch) => JSON.stringify(batch)).join('\n')}\n`;
284
+ if (mutationIndex !== index) {
285
+ mutationIndex = index;
286
+ mutationBytes = 0;
287
+ mutationTruncated = false;
288
+ }
289
+ if (mutationTruncated) {
290
+ return null;
291
+ }
292
+ const ceiling = Math.min(
293
+ limits.maxMutationBytes ?? 4 * 1024 * 1024,
294
+ maxResourceBytes
295
+ );
296
+ const lines = [];
297
+ for (const batch of batches) {
298
+ const line = `${JSON.stringify(batch)}\n`;
299
+ if (mutationBytes + Buffer.byteLength(line) + 256 > ceiling) {
300
+ mutationTruncated = true;
301
+ const marker = `${JSON.stringify({ records: [{ kind: 'truncated', reason: 'mutation interval size limit' }] })}\n`;
302
+ if (mutationBytes + Buffer.byteLength(marker) <= ceiling) {
303
+ lines.push(marker);
304
+ }
305
+ break;
306
+ }
307
+ lines.push(line);
308
+ mutationBytes += Buffer.byteLength(line);
309
+ }
310
+ if (!lines.length) {
311
+ return null;
312
+ }
313
+ const body = lines.join('');
279
314
  const result = await writeMember(member, body, { append: true });
280
315
  if (result) {
281
- counts.mutationBatches += batches.length;
316
+ counts.mutationBatches += lines.length;
282
317
  }
283
318
  return result ? result.member : null;
284
319
  }
@@ -319,6 +354,9 @@ export async function openTraceBundle(options = {}) {
319
354
  get bytesWritten() {
320
355
  return written;
321
356
  },
357
+ get mutationTruncated() {
358
+ return mutationTruncated;
359
+ },
322
360
  appendEvent,
323
361
  writeMember,
324
362
  writeCheckpoint,
@@ -1,6 +1,6 @@
1
1
  import { captureSnapshotInPage } from './page-capture.js';
2
2
  import { withDeadline } from './deadline.js';
3
- import { redactValue, redactUrl, REDACTED } from './redaction.js';
3
+ import { redactText, redactValue, redactUrl, REDACTED } from './redaction.js';
4
4
  import { TRACE_EVENT, TRACE_DROP_REASON } from './schema.js';
5
5
  export function createCheckpointRecorder({
6
6
  isStopped,
@@ -42,13 +42,14 @@ export function createCheckpointRecorder({
42
42
  evaluateInPage(captureSnapshotInPage, {
43
43
  redactSelectors: privacyOptions.redactSelectors,
44
44
  redactAttributes: privacyOptions.redactAttributes,
45
+ useDefaults: privacyOptions.useDefaults,
45
46
  redacted: REDACTED,
46
47
  html: domOptions.html,
47
48
  liveControlState: domOptions.liveControlState,
48
49
  openShadowRoots: domOptions.openShadowRoots,
49
50
  ignoreSelectors: domOptions.ignoreSelectors ?? [],
50
51
  captureText: links?.dom === 'text',
51
- maxHtmlBytes: limits.maxHtmlBytes ?? 0,
52
+ maxHtmlBytes: limits.maxHtmlBytes ?? 4 * 1024 * 1024,
52
53
  }),
53
54
  captureTimeoutMs,
54
55
  'trace checkpoint capture'
@@ -70,7 +71,10 @@ export function createCheckpointRecorder({
70
71
 
71
72
  const members = await bundle.writeCheckpoint({
72
73
  index,
73
- html: captured?.html ?? undefined,
74
+ html: redactText(captured?.html, privacyOptions, {
75
+ kind: 'dom',
76
+ name: 'html',
77
+ }),
74
78
  state: state ? { ...state, name, actor, reason } : undefined,
75
79
  screenshot: shot ?? undefined,
76
80
  });