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.
- package/.jscpd-baseline.json +7 -20
- package/CHANGELOG.md +12 -0
- package/README.md +27 -10
- package/package.json +1 -1
- package/scripts/run-tests.mjs +3 -1
- package/src/browser/browser-profile-files.js +5 -2
- package/src/browser/connector.js +140 -65
- package/src/browser/launcher.js +19 -8
- package/src/browser/persistent-session.js +110 -38
- package/src/browser/session-adoption.js +96 -0
- package/src/browser/session-worker.js +12 -0
- package/src/cli/args.js +3 -0
- package/src/cli/commands.js +3 -0
- package/src/cli/high-level-methods.js +7 -0
- package/src/cli/sessions.js +3 -0
- package/src/core/commander-features.js +33 -25
- package/src/core/navigation-safety.js +1 -0
- package/src/core/page-trigger-manager.js +173 -134
- package/src/index.js +2 -0
- package/src/interactions/overlays.js +59 -14
- package/src/traces/bundle.js +41 -3
- package/src/traces/checkpoints.js +7 -3
- package/src/traces/commander-actions.js +146 -0
- package/src/traces/debug-session.js +119 -0
- package/src/traces/drain-timer.js +25 -0
- package/src/traces/links.js +44 -5
- package/src/traces/mutation-stream.js +12 -5
- package/src/traces/network.js +65 -13
- package/src/traces/observers.js +50 -46
- package/src/traces/page-capture.js +65 -21
- package/src/traces/recorder.js +79 -28
- package/src/traces/redaction.js +89 -6
- package/src/traces/rolling-bundle.js +240 -0
- package/src/traces/rolling.js +10 -113
- package/src/traces/stop.js +1 -0
- package/src/traces/user-actions.js +405 -0
- package/tests/e2e/issue-160.e2e.test.js +291 -0
- package/tests/e2e/issue-162-recording.e2e.test.js +175 -0
- package/tests/e2e/traces.e2e.test.js +4 -1
- 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 +4 -4
- 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 +4 -4
- package/tests/fixtures/traces/conformance/scenario.json +1 -0
- package/tests/helpers/e2e-browser.js +2 -1
- package/tests/unit/browser/issue-160-connector.test.js +157 -0
- package/tests/unit/core/interrupted-navigation.test.js +32 -0
- package/tests/unit/core/trigger-stop-race.test.js +80 -0
- package/tests/unit/interactions/overlays.test.js +58 -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/issue-162-defaults.test.js +223 -0
- package/tests/unit/traces/issue-162-user-actions.test.js +181 -0
- package/tests/unit/traces/network.test.js +55 -3
- package/tests/unit/traces/recorder.test.js +4 -1
|
@@ -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
|
+
}
|
package/src/traces/rolling.js
CHANGED
|
@@ -1,120 +1,15 @@
|
|
|
1
|
-
import fs from 'node:fs/promises';
|
|
2
1
|
import path from 'node:path';
|
|
3
2
|
|
|
4
|
-
/**
|
|
3
|
+
/** Keep the ordinary trace handle while routing every write through rotation. */
|
|
5
4
|
export async function startRollingTrace(options, start) {
|
|
5
|
+
const { openRollingBundle } = await import('./rolling-bundle.js');
|
|
6
6
|
const config = options.limits.rotate === true ? {} : options.limits.rotate;
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
!Number.isSafeInteger(maxSegments) ||
|
|
14
|
-
maxSegments < 1 ||
|
|
15
|
-
maxSegments > 100
|
|
16
|
-
) {
|
|
17
|
-
throw new RangeError('invalid trace rotation bounds');
|
|
18
|
-
}
|
|
19
|
-
const root = path.resolve(options.output);
|
|
20
|
-
await fs.mkdir(root, { recursive: true, mode: 0o700 });
|
|
21
|
-
let current,
|
|
22
|
-
sequence = 0,
|
|
23
|
-
stopping,
|
|
24
|
-
failure,
|
|
25
|
-
ticking = false;
|
|
26
|
-
const segments = [];
|
|
27
|
-
let queue = Promise.resolve();
|
|
28
|
-
const enqueue = (action) => {
|
|
29
|
-
const task = queue.then(action);
|
|
30
|
-
queue = task.catch((error) => {
|
|
31
|
-
failure ??= error;
|
|
32
|
-
});
|
|
33
|
-
return task;
|
|
34
|
-
};
|
|
35
|
-
async function open() {
|
|
36
|
-
const name = `segment-${String(++sequence).padStart(6, '0')}`;
|
|
37
|
-
const output = path.join(root, name);
|
|
38
|
-
current = await start({
|
|
39
|
-
...options,
|
|
40
|
-
output,
|
|
41
|
-
limits: { ...options.limits, rotate: false, maxBundleBytes: maxBytes },
|
|
42
|
-
links: options.links
|
|
43
|
-
? { ...options.links, output: path.join(output, 'trace.lino') }
|
|
44
|
-
: null,
|
|
45
|
-
});
|
|
46
|
-
segments.push(name);
|
|
47
|
-
while (segments.length > maxSegments) {
|
|
48
|
-
await fs.rm(path.join(root, segments.shift()), {
|
|
49
|
-
recursive: true,
|
|
50
|
-
force: true,
|
|
51
|
-
});
|
|
52
|
-
}
|
|
53
|
-
const temporary = path.join(root, 'segments.json.tmp');
|
|
54
|
-
await fs.writeFile(temporary, JSON.stringify({ segments }), {
|
|
55
|
-
mode: 0o600,
|
|
56
|
-
});
|
|
57
|
-
await fs.rename(temporary, path.join(root, 'segments.json'));
|
|
58
|
-
}
|
|
59
|
-
await open();
|
|
60
|
-
const timer = setInterval(() => {
|
|
61
|
-
if (!stopping && !ticking) {
|
|
62
|
-
ticking = true;
|
|
63
|
-
enqueue(async () => {
|
|
64
|
-
if (current.bytesWritten >= maxBytes * 0.8) {
|
|
65
|
-
await current.stop();
|
|
66
|
-
await open();
|
|
67
|
-
}
|
|
68
|
-
})
|
|
69
|
-
.catch(() => clearInterval(timer))
|
|
70
|
-
.finally(() => {
|
|
71
|
-
ticking = false;
|
|
72
|
-
});
|
|
73
|
-
}
|
|
74
|
-
}, 100);
|
|
75
|
-
timer.unref?.();
|
|
76
|
-
return {
|
|
77
|
-
path: root,
|
|
78
|
-
mode: options.mode,
|
|
79
|
-
checkpoint: (...args) =>
|
|
80
|
-
enqueue(() => {
|
|
81
|
-
if (stopping) {
|
|
82
|
-
throw new Error('trace has stopped');
|
|
83
|
-
}
|
|
84
|
-
return current.checkpoint(...args);
|
|
85
|
-
}),
|
|
86
|
-
event: (...args) =>
|
|
87
|
-
enqueue(async () => {
|
|
88
|
-
if (stopping) {
|
|
89
|
-
throw new Error('trace has stopped');
|
|
90
|
-
}
|
|
91
|
-
if (
|
|
92
|
-
current.bytesWritten +
|
|
93
|
-
Buffer.byteLength(JSON.stringify(args)) +
|
|
94
|
-
256 >=
|
|
95
|
-
maxBytes * 0.8
|
|
96
|
-
) {
|
|
97
|
-
await current.stop();
|
|
98
|
-
await open();
|
|
99
|
-
}
|
|
100
|
-
return current.event(...args);
|
|
101
|
-
}),
|
|
102
|
-
stop: (...args) => {
|
|
103
|
-
stopping ??= (async () => {
|
|
104
|
-
clearInterval(timer);
|
|
105
|
-
await queue;
|
|
106
|
-
const result = await current.stop(...args);
|
|
107
|
-
if (args[0]?.discard) {
|
|
108
|
-
await fs.rm(root, { recursive: true, force: true });
|
|
109
|
-
}
|
|
110
|
-
if (failure) {
|
|
111
|
-
throw failure;
|
|
112
|
-
}
|
|
113
|
-
return { ...result, path: root, segments: [...segments] };
|
|
114
|
-
})();
|
|
115
|
-
return stopping;
|
|
116
|
-
},
|
|
117
|
-
};
|
|
7
|
+
return start({
|
|
8
|
+
...options,
|
|
9
|
+
limits: { ...options.limits, rotate: false },
|
|
10
|
+
openBundle: (bundleOptions) =>
|
|
11
|
+
openRollingBundle(bundleOptions, config, options),
|
|
12
|
+
});
|
|
118
13
|
}
|
|
119
14
|
|
|
120
15
|
/** Combine segment timelines while keeping checkpoint member paths distinct. */
|
|
@@ -174,6 +69,8 @@ export async function readRollingTrace(root, index, read) {
|
|
|
174
69
|
locations.get(index)?.opened.html(locations.get(index).index),
|
|
175
70
|
state: (index) =>
|
|
176
71
|
locations.get(index)?.opened.state(locations.get(index).index),
|
|
72
|
+
screenshot: (index) =>
|
|
73
|
+
locations.get(index)?.opened.screenshot(locations.get(index).index),
|
|
177
74
|
mutations: (index) =>
|
|
178
75
|
locations.get(index)?.opened.mutations(locations.get(index).index) ?? [],
|
|
179
76
|
};
|
package/src/traces/stop.js
CHANGED
|
@@ -134,6 +134,7 @@ export function createTraceStop(context) {
|
|
|
134
134
|
checkpoints: [...checkpoints],
|
|
135
135
|
problems: [...bundle.problems, ...(linksSink?.problems ?? [])],
|
|
136
136
|
links: linksSink?.path ?? null,
|
|
137
|
+
...(bundle.segments?.length ? { segments: bundle.segments } : {}),
|
|
137
138
|
};
|
|
138
139
|
setStopped(result);
|
|
139
140
|
|