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.
- package/.jscpd-baseline.json +5 -19
- package/CHANGELOG.md +22 -0
- package/README.md +23 -9
- package/eslint.config.js +11 -0
- package/package.json +5 -2
- package/src/browser/browser-profile-files.js +5 -2
- package/src/browser/connector.js +215 -36
- package/src/browser/launch-options.js +2 -1
- package/src/browser/launch-restrictions.json +33 -2
- package/src/browser/launcher.js +26 -1
- package/src/browser/persistent-session.js +432 -0
- package/src/browser/profile-directory.js +20 -0
- package/src/browser/real-browser.js +7 -2
- package/src/browser/restrictions.js +13 -4
- package/src/browser/session-adoption.js +96 -0
- package/src/browser/session-watchdog.js +71 -0
- package/src/browser/session-worker.js +82 -0
- package/src/browser/webdriver.js +4 -0
- package/src/capture/encoding.js +327 -0
- package/src/capture/errors.js +12 -0
- package/src/capture/files.js +9 -0
- package/src/capture/index.js +5 -0
- package/src/capture/recording.js +136 -0
- package/src/capture/screenshot.js +222 -0
- package/src/capture/video.js +186 -0
- package/src/capture/viewport.js +68 -0
- package/src/capture/worker.js +22 -0
- package/src/cli/args.js +69 -1
- package/src/cli/commands.js +116 -2
- package/src/cli/high-level-methods.js +47 -3
- package/src/cli/sessions.js +14 -3
- package/src/core/commander-features.js +110 -0
- package/src/core/constants.js +1 -0
- package/src/core/navigation-manager.js +4 -2
- package/src/core/navigation-readiness.js +30 -2
- package/src/core/navigation-safety.js +52 -0
- package/src/core/page-trigger-manager.js +280 -156
- package/src/core/readiness.js +10 -2
- package/src/elements/locators.js +26 -32
- package/src/elements/selectors.js +37 -81
- package/src/elements/visibility.js +32 -10
- package/src/factory.js +13 -12
- package/src/index.js +4 -0
- package/src/interactions/click.js +44 -0
- package/src/interactions/overlays.js +73 -0
- package/src/traces/bundle.js +55 -8
- package/src/traces/checkpoints.js +103 -0
- package/src/traces/links.js +157 -2
- package/src/traces/mutation-stream.js +9 -2
- package/src/traces/network.js +309 -0
- package/src/traces/page-capture.js +203 -68
- package/src/traces/reader.js +8 -10
- package/src/traces/recorder.js +253 -247
- package/src/traces/redaction.js +89 -6
- package/src/traces/render.js +168 -0
- package/src/traces/rolling-bundle.js +240 -0
- package/src/traces/rolling.js +77 -0
- package/src/traces/start-cleanup.js +15 -0
- package/src/traces/stop.js +160 -0
- package/src/traces/storage.js +43 -0
- package/src/utilities/subprocess.js +32 -0
- package/tests/e2e/cli.e2e.test.js +20 -1
- package/tests/e2e/click-readiness.e2e.test.js +1 -1
- package/tests/e2e/issue-160.e2e.test.js +291 -0
- package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.html +1 -1
- package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.html +1 -1
- package/tests/fixtures/traces/conformance/expected/bundle/events.ndjson +2 -2
- package/tests/fixtures/traces/conformance/expected/bundle/manifest.json +4 -0
- package/tests/fixtures/traces/conformance/expected/bundle/viewer.html +1 -1
- package/tests/fixtures/traces/conformance/expected/trace.lino +2 -2
- package/tests/unit/browser/issue-160-connector.test.js +157 -0
- package/tests/unit/browser/launch-building-blocks.test.js +5 -1
- package/tests/unit/browser/launcher.test.js +5 -1
- package/tests/unit/browser/persistent-session.test.js +59 -0
- package/tests/unit/browser/quiet-ui.test.js +16 -0
- package/tests/unit/browser/tab-selection.test.js +38 -0
- package/tests/unit/capture/capture.test.js +52 -0
- package/tests/unit/capture/cleanup.test.js +24 -0
- package/tests/unit/capture/viewport.test.js +168 -0
- package/tests/unit/cli/dispatcher.test.js +25 -0
- package/tests/unit/core/interrupted-navigation.test.js +32 -0
- package/tests/unit/core/readiness.test.js +21 -0
- package/tests/unit/core/trigger-readiness.test.js +39 -0
- package/tests/unit/core/trigger-stop-race.test.js +80 -0
- package/tests/unit/elements/visible-count.test.js +24 -0
- package/tests/unit/elements/wait-navigation.test.js +26 -0
- package/tests/unit/interactions/navigation-timeout.test.js +39 -0
- package/tests/unit/interactions/overlays.test.js +58 -0
- package/tests/unit/scripts/ci-timeout-budgets.test.js +1 -0
- package/tests/unit/traces/bundle.test.js +16 -0
- package/tests/unit/traces/issue-160-privacy.test.js +110 -0
- package/tests/unit/traces/issue-160-rotation.test.js +140 -0
- package/tests/unit/traces/network.test.js +149 -0
package/src/traces/redaction.js
CHANGED
|
@@ -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 =
|
|
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 (
|
|
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 (
|
|
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] =
|
|
194
|
-
|
|
195
|
-
|
|
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
|
+
}
|