browser-commander 0.27.0 → 0.29.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 (93) hide show
  1. package/.jscpd-baseline.json +5 -19
  2. package/CHANGELOG.md +22 -0
  3. package/README.md +23 -9
  4. package/eslint.config.js +11 -0
  5. package/package.json +5 -2
  6. package/src/browser/browser-profile-files.js +5 -2
  7. package/src/browser/connector.js +215 -36
  8. package/src/browser/launch-options.js +2 -1
  9. package/src/browser/launch-restrictions.json +33 -2
  10. package/src/browser/launcher.js +26 -1
  11. package/src/browser/persistent-session.js +432 -0
  12. package/src/browser/profile-directory.js +20 -0
  13. package/src/browser/real-browser.js +7 -2
  14. package/src/browser/restrictions.js +13 -4
  15. package/src/browser/session-adoption.js +96 -0
  16. package/src/browser/session-watchdog.js +71 -0
  17. package/src/browser/session-worker.js +82 -0
  18. package/src/browser/webdriver.js +4 -0
  19. package/src/capture/encoding.js +327 -0
  20. package/src/capture/errors.js +12 -0
  21. package/src/capture/files.js +9 -0
  22. package/src/capture/index.js +5 -0
  23. package/src/capture/recording.js +136 -0
  24. package/src/capture/screenshot.js +222 -0
  25. package/src/capture/video.js +186 -0
  26. package/src/capture/viewport.js +68 -0
  27. package/src/capture/worker.js +22 -0
  28. package/src/cli/args.js +69 -1
  29. package/src/cli/commands.js +116 -2
  30. package/src/cli/high-level-methods.js +47 -3
  31. package/src/cli/sessions.js +14 -3
  32. package/src/core/commander-features.js +110 -0
  33. package/src/core/constants.js +1 -0
  34. package/src/core/navigation-manager.js +4 -2
  35. package/src/core/navigation-readiness.js +30 -2
  36. package/src/core/navigation-safety.js +52 -0
  37. package/src/core/page-trigger-manager.js +280 -156
  38. package/src/core/readiness.js +10 -2
  39. package/src/elements/locators.js +26 -32
  40. package/src/elements/selectors.js +37 -81
  41. package/src/elements/visibility.js +32 -10
  42. package/src/factory.js +13 -12
  43. package/src/index.js +4 -0
  44. package/src/interactions/click.js +44 -0
  45. package/src/interactions/overlays.js +73 -0
  46. package/src/traces/bundle.js +55 -8
  47. package/src/traces/checkpoints.js +103 -0
  48. package/src/traces/links.js +157 -2
  49. package/src/traces/mutation-stream.js +9 -2
  50. package/src/traces/network.js +309 -0
  51. package/src/traces/page-capture.js +203 -68
  52. package/src/traces/reader.js +8 -10
  53. package/src/traces/recorder.js +253 -247
  54. package/src/traces/redaction.js +89 -6
  55. package/src/traces/render.js +168 -0
  56. package/src/traces/rolling-bundle.js +240 -0
  57. package/src/traces/rolling.js +77 -0
  58. package/src/traces/start-cleanup.js +15 -0
  59. package/src/traces/stop.js +160 -0
  60. package/src/traces/storage.js +43 -0
  61. package/src/utilities/subprocess.js +32 -0
  62. package/tests/e2e/cli.e2e.test.js +20 -1
  63. package/tests/e2e/click-readiness.e2e.test.js +1 -1
  64. package/tests/e2e/issue-160.e2e.test.js +291 -0
  65. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.html +1 -1
  66. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.html +1 -1
  67. package/tests/fixtures/traces/conformance/expected/bundle/events.ndjson +2 -2
  68. package/tests/fixtures/traces/conformance/expected/bundle/manifest.json +4 -0
  69. package/tests/fixtures/traces/conformance/expected/bundle/viewer.html +1 -1
  70. package/tests/fixtures/traces/conformance/expected/trace.lino +2 -2
  71. package/tests/unit/browser/issue-160-connector.test.js +157 -0
  72. package/tests/unit/browser/launch-building-blocks.test.js +5 -1
  73. package/tests/unit/browser/launcher.test.js +5 -1
  74. package/tests/unit/browser/persistent-session.test.js +59 -0
  75. package/tests/unit/browser/quiet-ui.test.js +16 -0
  76. package/tests/unit/browser/tab-selection.test.js +38 -0
  77. package/tests/unit/capture/capture.test.js +52 -0
  78. package/tests/unit/capture/cleanup.test.js +24 -0
  79. package/tests/unit/capture/viewport.test.js +168 -0
  80. package/tests/unit/cli/dispatcher.test.js +25 -0
  81. package/tests/unit/core/interrupted-navigation.test.js +32 -0
  82. package/tests/unit/core/readiness.test.js +21 -0
  83. package/tests/unit/core/trigger-readiness.test.js +39 -0
  84. package/tests/unit/core/trigger-stop-race.test.js +80 -0
  85. package/tests/unit/elements/visible-count.test.js +24 -0
  86. package/tests/unit/elements/wait-navigation.test.js +26 -0
  87. package/tests/unit/interactions/navigation-timeout.test.js +39 -0
  88. package/tests/unit/interactions/overlays.test.js +58 -0
  89. package/tests/unit/scripts/ci-timeout-budgets.test.js +1 -0
  90. package/tests/unit/traces/bundle.test.js +16 -0
  91. package/tests/unit/traces/issue-160-privacy.test.js +110 -0
  92. package/tests/unit/traces/issue-160-rotation.test.js +140 -0
  93. package/tests/unit/traces/network.test.js +149 -0
@@ -1,5 +1,9 @@
1
- import { isNavigationError } from '../core/navigation-safety.js';
2
- import { createPlaywrightLocator } from './locators.js';
1
+ import {
2
+ isNavigationError,
3
+ waitNavigationState,
4
+ recoverWaitError,
5
+ } from '../core/navigation-safety.js';
6
+ import { createPlaywrightLocator, requireSelector } from './locators.js';
3
7
 
4
8
  /**
5
9
  * Query single element
@@ -9,67 +13,38 @@ import { createPlaywrightLocator } from './locators.js';
9
13
  * @param {string} options.selector - CSS selector
10
14
  * @returns {Promise<Object|null>} - Element handle or null
11
15
  */
12
- export async function querySelector(options = {}) {
13
- const { page, engine, selector } = options;
14
-
15
- if (!selector) {
16
- throw new Error('selector is required in options');
17
- }
16
+ export function querySelector(options = {}) {
17
+ return queryElements(options, true);
18
+ }
18
19
 
19
- try {
20
- if (engine === 'playwright') {
21
- const locator = createPlaywrightLocator({ page, selector }).first();
22
- const count = await locator.count();
23
- return count > 0 ? locator : null;
24
- } else {
25
- return await page.$(selector);
26
- }
27
- } catch (error) {
28
- if (isNavigationError(error)) {
29
- console.log(
30
- '⚠️ Navigation detected during querySelector, returning null'
31
- );
32
- return null;
33
- }
34
- throw error;
35
- }
20
+ /** Return every matching element with the same navigation recovery as querySelector. */
21
+ export function querySelectorAll(options = {}) {
22
+ return queryElements(options, false);
36
23
  }
37
24
 
38
- /**
39
- * Query all elements
40
- * @param {Object} options - Configuration options
41
- * @param {Object} options.page - Browser page object
42
- * @param {string} options.engine - Engine type ('playwright' or 'puppeteer')
43
- * @param {string} options.selector - CSS selector
44
- * @returns {Promise<Array>} - Array of element handles
45
- */
46
- export async function querySelectorAll(options = {}) {
25
+ async function queryElements(options, single) {
26
+ requireSelector(options.selector);
47
27
  const { page, engine, selector } = options;
48
-
49
- if (!selector) {
50
- throw new Error('selector is required in options');
51
- }
52
-
53
28
  try {
54
- if (engine === 'playwright') {
55
- const locator = createPlaywrightLocator({ page, selector });
56
- const count = await locator.count();
57
- const elements = [];
58
- for (let i = 0; i < count; i++) {
59
- elements.push(locator.nth(i));
60
- }
61
- return elements;
62
- } else {
63
- return await page.$$(selector);
29
+ if (engine !== 'playwright') {
30
+ return single ? await page.$(selector) : await page.$$(selector);
64
31
  }
32
+ const matching = createPlaywrightLocator({ page, selector });
33
+ const locator = single ? matching.first() : matching;
34
+ const total = await locator.count();
35
+ return single
36
+ ? total > 0
37
+ ? locator
38
+ : null
39
+ : Array.from({ length: total }, (_, index) => locator.nth(index));
65
40
  } catch (error) {
66
- if (isNavigationError(error)) {
67
- console.log(
68
- '⚠️ Navigation detected during querySelectorAll, returning empty array'
69
- );
70
- return [];
41
+ if (!isNavigationError(error)) {
42
+ throw error;
71
43
  }
72
- throw error;
44
+ console.log(
45
+ `⚠️ Navigation detected during ${single ? 'querySelector' : 'querySelectorAll'}, recovering gracefully`
46
+ );
47
+ return single ? null : [];
73
48
  }
74
49
  }
75
50
 
@@ -175,9 +150,7 @@ function parsePlaywrightTextSelector(selector) {
175
150
  export async function normalizeSelector(options = {}) {
176
151
  const { page, engine, selector } = options;
177
152
 
178
- if (!selector) {
179
- throw new Error('selector is required in options');
180
- }
153
+ requireSelector(selector);
181
154
 
182
155
  // DEFENSIVE: Validate selector type - must be string or Puppeteer text selector object
183
156
  // Arrays and other invalid types should be rejected to prevent downstream querySelectorAll errors
@@ -360,19 +333,11 @@ export function withTextSelectorSupport(fn, engine, page) {
360
333
  * @returns {Promise<boolean>} - True if selector found, false on navigation
361
334
  */
362
335
  export async function waitForSelector(options = {}) {
363
- const {
364
- page,
365
- engine,
366
- selector,
367
- visible = true,
368
- timeout = 5000,
369
- throwOnNavigation = true,
370
- } = options;
371
-
372
- if (!selector) {
373
- throw new Error('selector is required in options');
374
- }
336
+ const { page, engine, selector, visible = true, timeout = 5000 } = options;
337
+
338
+ requireSelector(selector);
375
339
 
340
+ const initial = waitNavigationState(page, options.navigationManager);
376
341
  try {
377
342
  if (engine === 'playwright') {
378
343
  const locator = createPlaywrightLocator({ page, selector });
@@ -384,16 +349,7 @@ export async function waitForSelector(options = {}) {
384
349
  await page.waitForSelector(selector, { visible, timeout });
385
350
  }
386
351
  return true;
387
- } catch (error) {
388
- if (isNavigationError(error)) {
389
- console.log(
390
- '⚠️ Navigation detected during waitForSelector, recovering gracefully'
391
- );
392
- if (throwOnNavigation) {
393
- throw error;
394
- }
395
- return false;
396
- }
397
- throw error;
352
+ } catch (caught) {
353
+ return recoverWaitError(caught, options, initial, 'waitForSelector', false);
398
354
  }
399
355
  }
@@ -1,6 +1,6 @@
1
1
  import { TIMING } from '../core/constants.js';
2
2
  import { isNavigationError } from '../core/navigation-safety.js';
3
- import { getLocatorOrElement } from './locators.js';
3
+ import { getLocatorOrElement, requireSelector } from './locators.js';
4
4
 
5
5
  /**
6
6
  * Check if element is visible
@@ -13,9 +13,7 @@ import { getLocatorOrElement } from './locators.js';
13
13
  export async function isVisible(options = {}) {
14
14
  const { page, engine, selector } = options;
15
15
 
16
- if (!selector) {
17
- throw new Error('selector is required in options');
18
- }
16
+ requireSelector(selector);
19
17
 
20
18
  try {
21
19
  if (engine === 'playwright') {
@@ -62,9 +60,7 @@ export async function isVisible(options = {}) {
62
60
  export async function isEnabled(options = {}) {
63
61
  const { page, engine, selector, disabledClasses = ['disabled'] } = options;
64
62
 
65
- if (!selector) {
66
- throw new Error('selector is required in options');
67
- }
63
+ requireSelector(selector);
68
64
 
69
65
  try {
70
66
  if (engine === 'playwright') {
@@ -119,9 +115,7 @@ export async function isEnabled(options = {}) {
119
115
  export async function count(options = {}) {
120
116
  const { page, engine, selector } = options;
121
117
 
122
- if (!selector) {
123
- throw new Error('selector is required in options');
124
- }
118
+ requireSelector(selector);
125
119
 
126
120
  try {
127
121
  // Handle Puppeteer text selectors
@@ -150,9 +144,37 @@ export async function count(options = {}) {
150
144
  }
151
145
 
152
146
  if (engine === 'playwright') {
147
+ if (options.visible) {
148
+ return await page
149
+ .locator(selector)
150
+ .evaluateAll(
151
+ (elements) =>
152
+ elements.filter(
153
+ (element) =>
154
+ element.getClientRects().length > 0 &&
155
+ element.checkVisibility({ visibilityProperty: true })
156
+ ).length
157
+ );
158
+ }
153
159
  return await page.locator(selector).count();
154
160
  } else {
155
161
  const elements = await page.$$(selector);
162
+ if (options.visible) {
163
+ let visible = 0;
164
+ for (const element of elements) {
165
+ if (
166
+ await page.evaluate(
167
+ (element) =>
168
+ element.getClientRects().length > 0 &&
169
+ element.checkVisibility({ visibilityProperty: true }),
170
+ element
171
+ )
172
+ ) {
173
+ visible += 1;
174
+ }
175
+ }
176
+ return visible;
177
+ }
156
178
  return elements.length;
157
179
  }
158
180
  } catch (error) {
package/src/factory.js CHANGED
@@ -22,7 +22,7 @@ import {
22
22
  } from './core/page-trigger-manager.js';
23
23
  import { createBoundFunctions } from './bindings.js';
24
24
  import { attachDownloads } from './downloads/attach.js';
25
- import { startTrace } from './traces/recorder.js';
25
+ import { attachCommanderFeatures } from './core/commander-features.js';
26
26
  import { createCdpSession } from './browser/cdp-session.js';
27
27
 
28
28
  /**
@@ -66,7 +66,7 @@ export function makeBrowserCommander(options = {}) {
66
66
  page,
67
67
  engine,
68
68
  log,
69
- idleTimeout: 30000, // Wait 30 seconds without requests before considering network idle
69
+ idleTimeout: options.networkIdleForMs ?? 30000, // Wait 30 seconds without requests before considering network idle
70
70
  });
71
71
  networkTracker.startTracking();
72
72
  }
@@ -92,6 +92,7 @@ export function makeBrowserCommander(options = {}) {
92
92
  log,
93
93
  networkTracker,
94
94
  });
95
+ navigationManager.configure(navigationOptions(options));
95
96
  navigationManager.startListening();
96
97
 
97
98
  // Create PageSession factory
@@ -159,7 +160,6 @@ export function makeBrowserCommander(options = {}) {
159
160
 
160
161
  // All bound functions
161
162
  ...boundFunctions,
162
-
163
163
  // Managed downloads (issue #88). Present once a manager is attached,
164
164
  // either by passing the one launchBrowser() returned or by calling
165
165
  // configureDownloads() on an existing commander.
@@ -192,15 +192,6 @@ export function makeBrowserCommander(options = {}) {
192
192
  */
193
193
  createCdpSession: () => createCdpSession(page, { engine }),
194
194
 
195
- /**
196
- * Start recording a privacy-aware trace of this session (issue #87).
197
- *
198
- * @param {Object} [traceOptions] - {output, mode, screenshots, dom, events, privacy, limits}
199
- * @returns {Promise<Object>} The running trace: {checkpoint, event, stop}
200
- */
201
- startTrace: (traceOptions = {}) =>
202
- startTrace({ commander, page, log, ...traceOptions }),
203
-
204
195
  // Lifecycle
205
196
  destroy,
206
197
 
@@ -295,5 +286,15 @@ export function makeBrowserCommander(options = {}) {
295
286
  pageTriggerManager.initialize(commander);
296
287
  }
297
288
 
289
+ attachCommanderFeatures(commander, options, makeBrowserCommander);
298
290
  return commander;
299
291
  }
292
+
293
+ function navigationOptions(options) {
294
+ return {
295
+ ...(options.navigationManager ?? {}),
296
+ ...(options.networkIdleTimeout === undefined
297
+ ? {}
298
+ : { networkIdleTimeout: options.networkIdleTimeout }),
299
+ };
300
+ }
package/src/index.js CHANGED
@@ -18,3 +18,7 @@ export {
18
18
 
19
19
  // Re-export all public APIs from exports module
20
20
  export * from './exports.js';
21
+ export * from './capture/index.js';
22
+ export { connectOrLaunch } from './browser/persistent-session.js';
23
+
24
+ export { renderTrace, summarizeTrace } from './traces/render.js';
@@ -31,6 +31,25 @@ function isInterrupted(error) {
31
31
  return isNavigationError(error) || isActionStoppedError(error);
32
32
  }
33
33
 
34
+ function waitInterruptedByNavigation({
35
+ error,
36
+ page,
37
+ navigationManager,
38
+ startUrl,
39
+ startSessionId,
40
+ preparationFinished,
41
+ }) {
42
+ const timedOut =
43
+ error.name === 'TimeoutError' ||
44
+ error.name === 'NavigationInterruptedError' ||
45
+ /timeout|timed out/i.test(error.message);
46
+ const navigationChanged =
47
+ page.url() !== startUrl ||
48
+ (startSessionId !== undefined &&
49
+ navigationManager?.getSessionId?.() !== startSessionId);
50
+ return timedOut && navigationChanged && !preparationFinished;
51
+ }
52
+
34
53
  /**
35
54
  * Read the target element's observable state under the click's own budget.
36
55
  *
@@ -975,6 +994,7 @@ export async function clickButton(options = {}) {
975
994
 
976
995
  const startUrl = page.url();
977
996
  const startSessionId = navigationManager?.getSessionId?.();
997
+ let preparationFinished = false;
978
998
 
979
999
  try {
980
1000
  const prepareResult = await prepareElement({
@@ -1012,6 +1032,7 @@ export async function clickButton(options = {}) {
1012
1032
  });
1013
1033
  }
1014
1034
 
1035
+ preparationFinished = true;
1015
1036
  const clickResult = await clickElement({
1016
1037
  page,
1017
1038
  engine,
@@ -1055,6 +1076,29 @@ export async function clickButton(options = {}) {
1055
1076
  actionId,
1056
1077
  });
1057
1078
  } catch (error) {
1079
+ if (
1080
+ waitInterruptedByNavigation({
1081
+ error,
1082
+ page,
1083
+ navigationManager,
1084
+ startUrl,
1085
+ startSessionId,
1086
+ preparationFinished,
1087
+ })
1088
+ ) {
1089
+ return makeClickResult({
1090
+ status: CLICK_STATUS.INTERRUPTED,
1091
+ dispatched: false,
1092
+ effect: CLICK_EFFECT.NOT_OBSERVED,
1093
+ navigated: true,
1094
+ reason: 'navigation interrupted the element wait before the click',
1095
+ evidence: [
1096
+ evidence('interrupted', { message: error.message, phase: 'prepare' }),
1097
+ ],
1098
+ elapsedMs: elapsed(),
1099
+ actionId,
1100
+ });
1101
+ }
1058
1102
  if (isInterrupted(error)) {
1059
1103
  log.debug(
1060
1104
  () =>
@@ -0,0 +1,73 @@
1
+ import { withDeadline } from '../traces/deadline.js';
2
+ import { isVisible } from '../elements/visibility.js';
3
+
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
+ }) {
39
+ if (!Array.isArray(overlays)) {
40
+ throw new TypeError('overlays must be an array');
41
+ }
42
+ const reports = [];
43
+ for (const item of overlays) {
44
+ const options = typeof item === 'string' ? { selector: item } : item;
45
+ if (!options?.selector) {
46
+ throw new TypeError('overlay requires selector');
47
+ }
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.
69
+ }
70
+ }
71
+ }
72
+ return reports;
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);
@@ -181,10 +187,17 @@ export async function openTraceBundle(options = {}) {
181
187
  * @param {string|Buffer} contents - What to write
182
188
  * @returns {Promise<Object|null>} `{member, bytes}` or null when dropped
183
189
  */
184
- async function writeMember(member, contents) {
190
+ async function writeMember(member, contents, { append = false } = {}) {
185
191
  const data = Buffer.isBuffer(contents) ? contents : Buffer.from(contents);
192
+ const target = path.join(root, member);
193
+ const previous = append
194
+ ? await fs.stat(target).then(
195
+ (value) => value.size,
196
+ () => 0
197
+ )
198
+ : 0;
186
199
 
187
- if (!fits(data.length)) {
200
+ if (!fits(data.length) || previous + data.length > maxResourceBytes) {
188
201
  await drop({
189
202
  reason: TRACE_DROP_REASON.SIZE_LIMIT,
190
203
  member,
@@ -193,13 +206,15 @@ export async function openTraceBundle(options = {}) {
193
206
  return null;
194
207
  }
195
208
 
196
- const target = path.join(root, member);
197
209
  try {
198
210
  await fs.mkdir(path.dirname(target), {
199
211
  recursive: true,
200
212
  mode: TRACE_DIRECTORY_MODE,
201
213
  });
202
- await fs.writeFile(target, data, { mode: TRACE_FILE_MODE });
214
+ await fs.writeFile(target, data, {
215
+ mode: TRACE_FILE_MODE,
216
+ flag: append ? 'a' : 'w',
217
+ });
203
218
  written += data.length;
204
219
  return { member, bytes: data.length };
205
220
  } catch (error) {
@@ -266,10 +281,39 @@ export async function openTraceBundle(options = {}) {
266
281
  return null;
267
282
  }
268
283
  const member = `${TRACE_FILES.MUTATIONS_DIR}/${sequenceName(index)}.ndjson`;
269
- const body = `${batches.map((batch) => JSON.stringify(batch)).join('\n')}\n`;
270
- const result = await writeMember(member, body);
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('');
314
+ const result = await writeMember(member, body, { append: true });
271
315
  if (result) {
272
- counts.mutationBatches += batches.length;
316
+ counts.mutationBatches += lines.length;
273
317
  }
274
318
  return result ? result.member : null;
275
319
  }
@@ -310,6 +354,9 @@ export async function openTraceBundle(options = {}) {
310
354
  get bytesWritten() {
311
355
  return written;
312
356
  },
357
+ get mutationTruncated() {
358
+ return mutationTruncated;
359
+ },
313
360
  appendEvent,
314
361
  writeMember,
315
362
  writeCheckpoint,
@@ -0,0 +1,103 @@
1
+ import { captureSnapshotInPage } from './page-capture.js';
2
+ import { withDeadline } from './deadline.js';
3
+ import { redactText, redactValue, redactUrl, REDACTED } from './redaction.js';
4
+ import { TRACE_EVENT, TRACE_DROP_REASON } from './schema.js';
5
+ export function createCheckpointRecorder({
6
+ isStopped,
7
+ captureState,
8
+ mutations,
9
+ evaluateInPage,
10
+ privacyOptions,
11
+ domOptions,
12
+ links,
13
+ limits,
14
+ captureTimeoutMs,
15
+ bundle,
16
+ screenshot,
17
+ checkpoints,
18
+ record,
19
+ }) {
20
+ /**
21
+ * Capture a named checkpoint.
22
+ *
23
+ * @param {string} name - What this moment is
24
+ * @param {Object} [checkpointOptions] - `{actor, reason, screenshots}`
25
+ * @returns {Promise<Object|null>} `{index, name, members}`
26
+ */
27
+ async function checkpoint(name, checkpointOptions = {}) {
28
+ if (isStopped()) {
29
+ throw new Error('this trace has already been stopped');
30
+ }
31
+
32
+ const { actor = 'automation', reason = 'checkpoint' } = checkpointOptions;
33
+ const index = ++captureState.index;
34
+
35
+ // Mutations are drained first so the batches belong to the interval that
36
+ // ended here, not to the one that starts now.
37
+ await mutations.drain(index - 1 > 0 ? index - 1 : 0);
38
+
39
+ let captured = null;
40
+ try {
41
+ captured = await withDeadline(
42
+ evaluateInPage(captureSnapshotInPage, {
43
+ redactSelectors: privacyOptions.redactSelectors,
44
+ redactAttributes: privacyOptions.redactAttributes,
45
+ useDefaults: privacyOptions.useDefaults,
46
+ redacted: REDACTED,
47
+ html: domOptions.html,
48
+ liveControlState: domOptions.liveControlState,
49
+ openShadowRoots: domOptions.openShadowRoots,
50
+ ignoreSelectors: domOptions.ignoreSelectors ?? [],
51
+ captureText: links?.dom === 'text',
52
+ maxHtmlBytes: limits.maxHtmlBytes ?? 4 * 1024 * 1024,
53
+ }),
54
+ captureTimeoutMs,
55
+ 'trace checkpoint capture'
56
+ );
57
+ } catch (error) {
58
+ await bundle.drop({
59
+ reason: /closed/i.test(error.message)
60
+ ? TRACE_DROP_REASON.PAGE_CLOSED
61
+ : TRACE_DROP_REASON.CAPTURE_FAILED,
62
+ member: `checkpoints/${index}`,
63
+ detail: error.message,
64
+ });
65
+ }
66
+
67
+ const shot = await screenshot(reason);
68
+ const state = captured?.state
69
+ ? redactValue(captured.state, privacyOptions)
70
+ : null;
71
+
72
+ const members = await bundle.writeCheckpoint({
73
+ index,
74
+ html: redactText(captured?.html, privacyOptions, {
75
+ kind: 'dom',
76
+ name: 'html',
77
+ }),
78
+ state: state ? { ...state, name, actor, reason } : undefined,
79
+ screenshot: shot ?? undefined,
80
+ });
81
+
82
+ const entry = {
83
+ index,
84
+ name,
85
+ actor,
86
+ reason,
87
+ url: state ? redactUrl(state.url, privacyOptions) : null,
88
+ truncated: Boolean(captured?.truncated),
89
+ members,
90
+ };
91
+ checkpoints.push(entry);
92
+ await record(TRACE_EVENT.CHECKPOINT, entry);
93
+
94
+ // The init script covers every document created from here on; this covers
95
+ // a frame that was attached without one, which is cheap because installing
96
+ // over a recorder that is already observing does nothing.
97
+ await mutations.install();
98
+
99
+ return entry;
100
+ }
101
+
102
+ return checkpoint;
103
+ }