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
@@ -13,6 +13,7 @@ export const REDACTED = '[redacted]';
13
13
  /** Controls whose value is a secret unless the caller says otherwise. */
14
14
  export const DEFAULT_REDACT_SELECTORS = Object.freeze([
15
15
  'input[type=password]',
16
+ 'input[type=hidden]',
16
17
  '[data-private]',
17
18
  '[data-bc-redact]',
18
19
  ]);
@@ -42,8 +43,74 @@ export const DEFAULT_REDACT_QUERY_PARAMS = Object.freeze([
42
43
  'session',
43
44
  'signature',
44
45
  'token',
46
+ 'csrf',
47
+ 'xsrf',
48
+ '_xsrf',
45
49
  ]);
46
50
 
51
+ /** Match names rather than values, including framework-specific token names. */
52
+ export function sensitiveName(name, privacy) {
53
+ return (
54
+ Boolean(privacy.useDefaults) &&
55
+ /password|passwd|token|csrf|xsrf|otp|secret|api[_-]?key|authorization/i.test(
56
+ name
57
+ )
58
+ );
59
+ }
60
+
61
+ function redactFields(value, privacy) {
62
+ if (Array.isArray(value)) {
63
+ return value.map((entry) => redactFields(entry, privacy));
64
+ }
65
+ if (value && typeof value === 'object') {
66
+ return Object.fromEntries(
67
+ Object.entries(value).map(([key, entry]) => [
68
+ key,
69
+ sensitiveName(key, privacy) ? REDACTED : redactFields(entry, privacy),
70
+ ])
71
+ );
72
+ }
73
+ return value;
74
+ }
75
+
76
+ function redactStructuredText(value, privacy) {
77
+ if (!privacy.useDefaults) {
78
+ return value;
79
+ }
80
+ try {
81
+ const parsed = JSON.parse(value);
82
+ if (parsed && typeof parsed === 'object') {
83
+ return JSON.stringify(redactFields(parsed, privacy));
84
+ }
85
+ } catch {
86
+ /* HTML, forms and script assignments are handled below. */
87
+ }
88
+ return value
89
+ .replace(
90
+ /(\b(?:["']?[\w-]+["']?)\s*[:=]\s*)("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\d+(?:\.\d+)?)/g,
91
+ (match, prefix) =>
92
+ sensitiveName(prefix, privacy)
93
+ ? `${prefix}${JSON.stringify(REDACTED)}`
94
+ : match
95
+ )
96
+ .replace(/(^|[&?])([^=&\s]+)=([^&\s]*)/g, (match, start, key) => {
97
+ let name = key;
98
+ try {
99
+ name = decodeURIComponent(key.replaceAll('+', ' '));
100
+ } catch {
101
+ /* Keep malformed names. */
102
+ }
103
+ return sensitiveName(name, privacy)
104
+ ? `${start}${key}=${REDACTED}`
105
+ : match;
106
+ })
107
+ .replace(
108
+ /(Content-Disposition:[^\r\n]*?;\s*name="([^"]+)"[^\r\n]*\r?\n(?:[^\r\n]+\r?\n)*\r?\n)([\s\S]*?)(?=\r?\n--)/gi,
109
+ (match, header, name) =>
110
+ sensitiveName(name, privacy) ? `${header}${REDACTED}` : match
111
+ );
112
+ }
113
+
47
114
  function lowerSet(values) {
48
115
  return [...new Set(values.map((value) => String(value).toLowerCase()))];
49
116
  }
@@ -75,6 +142,7 @@ export function normalizePrivacyOptions(privacy = {}) {
75
142
  }
76
143
 
77
144
  return {
145
+ useDefaults,
78
146
  redactSelectors: lowerSet([
79
147
  ...(useDefaults ? DEFAULT_REDACT_SELECTORS : []),
80
148
  ...redactSelectors,
@@ -107,7 +175,8 @@ export function redactText(value, privacy, context = {}) {
107
175
  return value;
108
176
  }
109
177
 
110
- let text = value;
178
+ let text =
179
+ context.kind === 'url' ? value : redactStructuredText(value, privacy);
111
180
  for (const pattern of privacy.redactPatterns || []) {
112
181
  // A caller's regex may be stateful; resetting keeps one trace's output
113
182
  // from depending on the previous record's match position.
@@ -148,7 +217,10 @@ export function redactUrl(url, privacy) {
148
217
  parsed.password = parsed.password ? REDACTED : '';
149
218
  }
150
219
  for (const key of [...parsed.searchParams.keys()]) {
151
- if (privacy.redactQueryParams.includes(key.toLowerCase())) {
220
+ if (
221
+ privacy.redactQueryParams.includes(key.toLowerCase()) ||
222
+ sensitiveName(key, privacy)
223
+ ) {
152
224
  parsed.searchParams.set(key, REDACTED);
153
225
  }
154
226
  }
@@ -157,7 +229,10 @@ export function redactUrl(url, privacy) {
157
229
  const fragment = new URLSearchParams(parsed.hash.slice(1));
158
230
  let changed = false;
159
231
  for (const key of [...fragment.keys()]) {
160
- if (privacy.redactQueryParams.includes(key.toLowerCase())) {
232
+ if (
233
+ privacy.redactQueryParams.includes(key.toLowerCase()) ||
234
+ sensitiveName(key, privacy)
235
+ ) {
161
236
  fragment.set(key, REDACTED);
162
237
  changed = true;
163
238
  }
@@ -190,9 +265,11 @@ export function redactHeaders(headers, privacy) {
190
265
 
191
266
  const result = {};
192
267
  for (const [name, value] of Object.entries(headers)) {
193
- result[name] = privacy.redactAttributes.includes(name.toLowerCase())
194
- ? REDACTED
195
- : redactText(String(value), privacy, { kind: 'header', name });
268
+ result[name] =
269
+ privacy.redactAttributes.includes(name.toLowerCase()) ||
270
+ sensitiveName(name, privacy)
271
+ ? REDACTED
272
+ : redactText(String(value), privacy, { kind: 'header', name });
196
273
  }
197
274
  return result;
198
275
  }
@@ -209,6 +286,12 @@ export function redactHeaders(headers, privacy) {
209
286
  * @returns {*} The value with strings redacted
210
287
  */
211
288
  export function redactValue(value, privacy, key = '') {
289
+ if (
290
+ sensitiveName(key, privacy) ||
291
+ privacy.redactAttributes.includes(key.toLowerCase())
292
+ ) {
293
+ return REDACTED;
294
+ }
212
295
  if (typeof value === 'string') {
213
296
  if (privacy.redactAttributes.includes(key.toLowerCase())) {
214
297
  return REDACTED;
@@ -0,0 +1,168 @@
1
+ import { writeCapture } from '../capture/files.js';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { readTrace } from './reader.js';
5
+ import {
6
+ encodeAnimation,
7
+ decodePng,
8
+ encodePng,
9
+ resizeFrame,
10
+ } from '../capture/encoding.js';
11
+ import { encodeVideo } from '../capture/video.js';
12
+
13
+ export function withinTime(record, options = {}) {
14
+ const time = (value) =>
15
+ typeof value === 'number' || /^\d+(\.\d+)?$/.test(value)
16
+ ? { offset: Number(value) * 1000 }
17
+ : {
18
+ date: Date.parse(
19
+ /^\d{2}:\d{2}(?::\d{2})?$/.test(value)
20
+ ? `${options.startedAt?.slice(0, 10)}T${value.length === 5 ? `${value}:00` : value}Z`
21
+ : value
22
+ ),
23
+ };
24
+ for (const [name, direction] of [
25
+ ['from', -1],
26
+ ['to', 1],
27
+ ]) {
28
+ if (options[name] === undefined) {
29
+ continue;
30
+ }
31
+ const bound = time(options[name]);
32
+ if (bound.date !== undefined && !Number.isFinite(bound.date)) {
33
+ throw new RangeError(`invalid ${name} timestamp`);
34
+ }
35
+ const value =
36
+ bound.offset !== undefined
37
+ ? (record.monotonicMs ?? 0) - (options.monotonicOrigin ?? 0)
38
+ : Date.parse(record.at);
39
+ if (
40
+ direction === -1
41
+ ? value < (bound.offset ?? bound.date)
42
+ : value > (bound.offset ?? bound.date)
43
+ ) {
44
+ return false;
45
+ }
46
+ }
47
+ return true;
48
+ }
49
+ export async function summarizeTrace(root, options = {}) {
50
+ const opened = await readTrace(root);
51
+ options = timelineOptions(opened, options);
52
+ const records = [...opened.events];
53
+ const seenMutations = new Set();
54
+ for (const event of opened.events.filter(
55
+ (event) => event.kind === 'mutations'
56
+ )) {
57
+ const key = `${event.segment ?? ''}:${event.checkpoint}`;
58
+ if (seenMutations.has(key)) {
59
+ continue;
60
+ }
61
+ seenMutations.add(key);
62
+ for (const batch of await opened.mutations(event.checkpoint)) {
63
+ for (const mutation of batch.records ?? []) {
64
+ records.push({
65
+ ...mutation,
66
+ kind: `dom.${mutation.kind}`,
67
+ at: new Date(batch.at).toISOString(),
68
+ monotonicMs: event.monotonicMs,
69
+ });
70
+ }
71
+ }
72
+ }
73
+ return records
74
+ .filter(
75
+ (record) =>
76
+ withinTime(record, options) &&
77
+ (!options.grep || JSON.stringify(record).includes(options.grep))
78
+ )
79
+ .sort(
80
+ (a, b) =>
81
+ Date.parse(a.at) - Date.parse(b.at) ||
82
+ (a.sequence ?? 0) - (b.sequence ?? 0)
83
+ );
84
+ }
85
+ export async function renderTrace(root, options = {}) {
86
+ const opened = await readTrace(root);
87
+ options = timelineOptions(opened, options);
88
+ const frames = [];
89
+ let compressedBytes = 0;
90
+ for (const event of opened.events.filter(
91
+ (event) => event.kind === 'checkpoint' && withinTime(event, options)
92
+ )) {
93
+ const member = event.members?.screenshot;
94
+ if (member) {
95
+ if (frames.length >= 1000) {
96
+ throw new RangeError('trace render exceeds 1000 frames');
97
+ }
98
+ const file = path.join(opened.path, member);
99
+ compressedBytes += (await fs.stat(file)).size;
100
+ if (compressedBytes > 64 * 1024 * 1024) {
101
+ throw new RangeError('trace render frames exceed 64 MiB');
102
+ }
103
+ frames.push(await fs.readFile(file));
104
+ }
105
+ }
106
+ if (frames.length === 0) {
107
+ throw new Error('trace has no checkpoint screenshots in this interval');
108
+ }
109
+ if (frames.length > 1000) {
110
+ throw new RangeError('trace render exceeds 1000 frames');
111
+ }
112
+ const format =
113
+ options.format ?? (path.extname(options.path ?? '').slice(1) || 'gif');
114
+ if (format === 'sheet') {
115
+ let total = 0;
116
+ const images = frames.map((frame) => {
117
+ if (frame.length < 33) {
118
+ throw new RangeError('frame must be a PNG');
119
+ }
120
+ total +=
121
+ frame.readUInt32BE(16) *
122
+ frame.readUInt32BE(20) *
123
+ 4 *
124
+ Math.max(1, (options.scale ?? 1) ** 2);
125
+ if (total > 256 * 1024 * 1024) {
126
+ throw new RangeError('contact sheet frames exceed 256 MiB');
127
+ }
128
+ return resizeFrame(decodePng(frame), options.scale ?? 1);
129
+ });
130
+ const columns = options.columns ?? Math.min(4, images.length);
131
+ if (!Number.isInteger(columns) || columns < 1 || columns > 1000) {
132
+ throw new RangeError('columns must be 1–1000');
133
+ }
134
+ const width = Math.max(...images.map((image) => image.width)) * columns;
135
+ const rowHeight = Math.max(...images.map((image) => image.height));
136
+ const height = rowHeight * Math.ceil(images.length / columns);
137
+ if (width * height > 16_777_216) {
138
+ throw new RangeError('contact sheet exceeds 16 megapixels');
139
+ }
140
+ const data = new Uint8Array(width * height * 4);
141
+ images.forEach((image, index) => {
142
+ const x = (index % columns) * (width / columns),
143
+ y = Math.floor(index / columns) * rowHeight;
144
+ for (let row = 0; row < image.height; row++) {
145
+ data.set(
146
+ image.data.subarray(
147
+ row * image.width * 4,
148
+ (row + 1) * image.width * 4
149
+ ),
150
+ ((y + row) * width + x) * 4
151
+ );
152
+ }
153
+ });
154
+ const bytes = encodePng({ width, height, data });
155
+ return writeCapture(bytes, options.path);
156
+ }
157
+ return ['mp4', 'webm', 'mov'].includes(format)
158
+ ? encodeVideo(frames, { ...options, format })
159
+ : encodeAnimation(frames, { ...options, format });
160
+ }
161
+
162
+ function timelineOptions(opened, options) {
163
+ return {
164
+ ...options,
165
+ monotonicOrigin: opened.events[0]?.monotonicMs ?? 0,
166
+ startedAt: opened.events[0]?.at,
167
+ };
168
+ }
@@ -0,0 +1,240 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { openTraceBundle } from './bundle.js';
4
+ import { createManifest } from './schema.js';
5
+
6
+ /** Rotate complete bundles at the producer's write boundary, including background events. */
7
+ export async function openRollingBundle(options, config, traceOptions) {
8
+ const root = path.resolve(options.output);
9
+ const maxBytes =
10
+ config.maxBytes ?? options.limits.maxBundleBytes ?? 32 * 1024 * 1024;
11
+ const maxSegments = config.maxSegments ?? Infinity;
12
+ if (
13
+ !Number.isSafeInteger(maxBytes) ||
14
+ maxBytes < 1024 ||
15
+ (maxSegments !== Infinity &&
16
+ (!Number.isSafeInteger(maxSegments) ||
17
+ maxSegments < 1 ||
18
+ maxSegments > 100))
19
+ ) {
20
+ throw new RangeError('invalid trace rotation bounds');
21
+ }
22
+ let current,
23
+ sequence = 0,
24
+ pinned = false,
25
+ base = null,
26
+ baseEvent = null;
27
+ let eventSequence = 0;
28
+ const segments = [],
29
+ writers = [];
30
+ let queue = Promise.resolve();
31
+ const run = (fn) => {
32
+ const result = queue.then(fn);
33
+ queue = result.catch(() => {});
34
+ return result;
35
+ };
36
+ const template = () =>
37
+ createManifest({
38
+ mode: traceOptions.mode ?? 'checkpoints',
39
+ engine: traceOptions.commander?.engine,
40
+ startedAt: new Date().toISOString(),
41
+ stoppedAt: new Date().toISOString(),
42
+ dom: traceOptions.dom,
43
+ limits: traceOptions.limits,
44
+ });
45
+ async function open() {
46
+ const name = `segment-${String(++sequence).padStart(6, '0')}`;
47
+ current = await openTraceBundle({
48
+ ...options,
49
+ output: path.join(root, name),
50
+ nextSequence: () => ++eventSequence,
51
+ limits: { ...options.limits, maxBundleBytes: Infinity },
52
+ onEvent: (event) => options.onEvent?.(event, { root: current.root }),
53
+ });
54
+ writers.push(current);
55
+ segments.push(name);
56
+ if (segments.length > maxSegments) {
57
+ await fs.rm(path.join(root, segments.shift()), {
58
+ recursive: true,
59
+ force: true,
60
+ });
61
+ }
62
+ await fs.writeFile(
63
+ path.join(root, 'segments.json.tmp'),
64
+ JSON.stringify({ segments }),
65
+ { mode: 0o600 }
66
+ );
67
+ await fs.rename(
68
+ path.join(root, 'segments.json.tmp'),
69
+ path.join(root, 'segments.json')
70
+ );
71
+ }
72
+ async function prepare(bytes, carry = false) {
73
+ if (
74
+ !pinned &&
75
+ current.bytesWritten > 0 &&
76
+ current.bytesWritten + bytes > maxBytes
77
+ ) {
78
+ await current.close(template());
79
+ if (sequence === 0) {
80
+ const name = 'segment-000001';
81
+ const destination = path.join(root, name);
82
+ await fs.mkdir(destination, { mode: 0o700 });
83
+ for (const member of [
84
+ 'events.ndjson',
85
+ 'manifest.json',
86
+ 'checkpoints',
87
+ 'mutations',
88
+ 'artifacts',
89
+ 'viewer.html',
90
+ ]) {
91
+ await fs
92
+ .rename(path.join(root, member), path.join(destination, member))
93
+ .catch((error) => {
94
+ if (error.code !== 'ENOENT') {
95
+ throw error;
96
+ }
97
+ });
98
+ }
99
+ sequence = 1;
100
+ segments.push(name);
101
+ }
102
+ await open();
103
+ if (carry && base && baseEvent) {
104
+ const members = await current.writeCheckpoint(base);
105
+ await current.appendEvent({
106
+ ...baseEvent,
107
+ members,
108
+ reason: 'rotation-base',
109
+ });
110
+ }
111
+ }
112
+ }
113
+ await fs.mkdir(root, { recursive: true, mode: 0o700 });
114
+ if (config.lazy) {
115
+ current = await openTraceBundle({
116
+ ...options,
117
+ nextSequence: () => ++eventSequence,
118
+ limits: { ...options.limits, maxBundleBytes: Infinity },
119
+ });
120
+ writers.push(current);
121
+ } else {
122
+ await open();
123
+ }
124
+ const counts = () =>
125
+ writers.reduce(
126
+ (sum, b) => {
127
+ for (const k of Object.keys(sum)) {
128
+ sum[k] += b.counts[k];
129
+ }
130
+ return sum;
131
+ },
132
+ { checkpoints: 0, events: 0, mutationBatches: 0 }
133
+ );
134
+ return {
135
+ root,
136
+ get currentPath() {
137
+ return current.root;
138
+ },
139
+ get segments() {
140
+ return [...segments];
141
+ },
142
+ get counts() {
143
+ return counts();
144
+ },
145
+ get problems() {
146
+ return writers.flatMap((b) => b.problems);
147
+ },
148
+ get dropped() {
149
+ return writers.reduce((sum, b) => sum + b.dropped, 0);
150
+ },
151
+ get bytesWritten() {
152
+ return current.bytesWritten;
153
+ },
154
+ get mutationTruncated() {
155
+ return current.mutationTruncated;
156
+ },
157
+ appendEvent: (event) =>
158
+ run(async () => {
159
+ await prepare(Buffer.byteLength(JSON.stringify(event)) + 256, true);
160
+ const result = await current.appendEvent(event);
161
+ if (event.kind === 'checkpoint') {
162
+ baseEvent = result;
163
+ }
164
+ if (
165
+ event.kind === 'checkpoint' ||
166
+ (event.kind === 'mutations' && pinned === 'mutations')
167
+ ) {
168
+ pinned = false;
169
+ }
170
+ return result;
171
+ }),
172
+ writeCheckpoint: (checkpoint) =>
173
+ run(async () => {
174
+ const bytes =
175
+ Buffer.byteLength(checkpoint.html ?? '') +
176
+ Buffer.byteLength(JSON.stringify(checkpoint.state ?? {})) +
177
+ (checkpoint.screenshot?.length ?? 0) +
178
+ 1024;
179
+ await prepare(bytes);
180
+ pinned = 'checkpoint';
181
+ base = checkpoint;
182
+ return current.writeCheckpoint(checkpoint);
183
+ }),
184
+ writeMutations: (index, batches) =>
185
+ run(async () => {
186
+ await prepare(Buffer.byteLength(JSON.stringify(batches)) + 256, true);
187
+ const result = await current.writeMutations(index, batches);
188
+ if (pinned !== 'checkpoint') {
189
+ pinned = result ? 'mutations' : false;
190
+ }
191
+ return result;
192
+ }),
193
+ writeMember: (member, contents, writeOptions) =>
194
+ run(async () => {
195
+ await prepare(Buffer.byteLength(contents));
196
+ return current.writeMember(member, contents, writeOptions);
197
+ }),
198
+ writeArtifact: (contents, extension) =>
199
+ run(async () => {
200
+ await prepare(Buffer.byteLength(contents));
201
+ const result = await current.writeArtifact(contents, extension);
202
+ return result
203
+ ? {
204
+ ...result,
205
+ member: segments.length
206
+ ? `${segments.at(-1)}/${result.member}`
207
+ : result.member,
208
+ }
209
+ : null;
210
+ }),
211
+ drop: (record) => run(() => current.drop(record)),
212
+ abort: () => run(() => current.abort()),
213
+ close: (manifest) =>
214
+ run(async () => {
215
+ manifest = await current.close(manifest);
216
+ if (writers.some((writer) => writer.dropped)) {
217
+ manifest.outcome = 'partial';
218
+ }
219
+ // Finished segments get the final capabilities and outcome as well.
220
+ for (const name of segments) {
221
+ const file = path.join(root, name, 'manifest.json');
222
+ const previous = JSON.parse(await fs.readFile(file, 'utf8'));
223
+ await fs.writeFile(
224
+ file,
225
+ JSON.stringify({
226
+ ...manifest,
227
+ counts: previous.counts,
228
+ dropped: previous.dropped,
229
+ }),
230
+ { mode: 0o600 }
231
+ );
232
+ }
233
+ return {
234
+ ...manifest,
235
+ counts: counts(),
236
+ dropped: writers.reduce((sum, b) => sum + b.dropped, 0),
237
+ };
238
+ }),
239
+ };
240
+ }
@@ -0,0 +1,77 @@
1
+ import path from 'node:path';
2
+
3
+ /** Keep the ordinary trace handle while routing every write through rotation. */
4
+ export async function startRollingTrace(options, start) {
5
+ const { openRollingBundle } = await import('./rolling-bundle.js');
6
+ const config = options.limits.rotate === true ? {} : options.limits.rotate;
7
+ return start({
8
+ ...options,
9
+ limits: { ...options.limits, rotate: false },
10
+ openBundle: (bundleOptions) =>
11
+ openRollingBundle(bundleOptions, config, options),
12
+ });
13
+ }
14
+
15
+ /** Combine segment timelines while keeping checkpoint member paths distinct. */
16
+ export async function readRollingTrace(root, index, read) {
17
+ const events = [],
18
+ checkpoints = [],
19
+ locations = new Map();
20
+ let manifest,
21
+ truncated = false,
22
+ number = 0;
23
+ for (const segment of index.segments) {
24
+ if (!/^segment-\d+$/.test(segment)) {
25
+ throw new Error('invalid trace segment path');
26
+ }
27
+ const opened = await read(path.join(root, segment));
28
+ manifest = opened.manifest;
29
+ truncated ||= opened.truncated;
30
+ const ids = new Map();
31
+ for (const checkpoint of opened.checkpoints) {
32
+ const id = ++number;
33
+ ids.set(checkpoint.index, id);
34
+ locations.set(id, { opened, index: checkpoint.index });
35
+ checkpoints.push({
36
+ ...checkpoint,
37
+ index: id,
38
+ members: Object.fromEntries(
39
+ Object.entries(checkpoint.members).map(([key, value]) => [
40
+ key,
41
+ `${segment}/${value}`,
42
+ ])
43
+ ),
44
+ });
45
+ }
46
+ for (const event of opened.events) {
47
+ const mapped = { ...event, segment };
48
+ if (event.kind === 'checkpoint') {
49
+ Object.assign(
50
+ mapped,
51
+ checkpoints.find(
52
+ (checkpoint) => checkpoint.index === ids.get(event.index)
53
+ )
54
+ );
55
+ }
56
+ if (event.kind === 'mutations') {
57
+ mapped.checkpoint = ids.get(event.checkpoint) ?? 0;
58
+ }
59
+ events.push(mapped);
60
+ }
61
+ }
62
+ return {
63
+ path: root,
64
+ manifest,
65
+ events,
66
+ checkpoints,
67
+ truncated,
68
+ html: (index) =>
69
+ locations.get(index)?.opened.html(locations.get(index).index),
70
+ state: (index) =>
71
+ locations.get(index)?.opened.state(locations.get(index).index),
72
+ screenshot: (index) =>
73
+ locations.get(index)?.opened.screenshot(locations.get(index).index),
74
+ mutations: (index) =>
75
+ locations.get(index)?.opened.mutations(locations.get(index).index) ?? [],
76
+ };
77
+ }
@@ -0,0 +1,15 @@
1
+ /** Release resources when startup fails before returning a running trace. */
2
+ export async function abortTraceStart(resources) {
3
+ const { detachers, detachNetwork, video, mutations, linksSink, bundle } =
4
+ resources;
5
+ for (const detach of detachers.reverse()) {
6
+ await Promise.resolve()
7
+ .then(detach)
8
+ .catch(() => {});
9
+ }
10
+ await detachNetwork().catch(() => {});
11
+ await video?.stop().catch(() => {});
12
+ await mutations.stop().catch(() => {});
13
+ await linksSink?.discard().catch(() => {});
14
+ await bundle.abort();
15
+ }