browser-commander 0.28.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/.jscpd-baseline.json +7 -20
  2. package/CHANGELOG.md +12 -0
  3. package/README.md +27 -10
  4. package/package.json +1 -1
  5. package/scripts/run-tests.mjs +3 -1
  6. package/src/browser/browser-profile-files.js +5 -2
  7. package/src/browser/connector.js +140 -65
  8. package/src/browser/launcher.js +19 -8
  9. package/src/browser/persistent-session.js +110 -38
  10. package/src/browser/session-adoption.js +96 -0
  11. package/src/browser/session-worker.js +12 -0
  12. package/src/cli/args.js +3 -0
  13. package/src/cli/commands.js +3 -0
  14. package/src/cli/high-level-methods.js +7 -0
  15. package/src/cli/sessions.js +3 -0
  16. package/src/core/commander-features.js +33 -25
  17. package/src/core/navigation-safety.js +1 -0
  18. package/src/core/page-trigger-manager.js +173 -134
  19. package/src/index.js +2 -0
  20. package/src/interactions/overlays.js +59 -14
  21. package/src/traces/bundle.js +41 -3
  22. package/src/traces/checkpoints.js +7 -3
  23. package/src/traces/commander-actions.js +146 -0
  24. package/src/traces/debug-session.js +119 -0
  25. package/src/traces/drain-timer.js +25 -0
  26. package/src/traces/links.js +44 -5
  27. package/src/traces/mutation-stream.js +12 -5
  28. package/src/traces/network.js +65 -13
  29. package/src/traces/observers.js +50 -46
  30. package/src/traces/page-capture.js +65 -21
  31. package/src/traces/recorder.js +79 -28
  32. package/src/traces/redaction.js +89 -6
  33. package/src/traces/rolling-bundle.js +240 -0
  34. package/src/traces/rolling.js +10 -113
  35. package/src/traces/stop.js +1 -0
  36. package/src/traces/user-actions.js +405 -0
  37. package/tests/e2e/issue-160.e2e.test.js +291 -0
  38. package/tests/e2e/issue-162-recording.e2e.test.js +175 -0
  39. package/tests/e2e/traces.e2e.test.js +4 -1
  40. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.html +1 -1
  41. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.html +1 -1
  42. package/tests/fixtures/traces/conformance/expected/bundle/events.ndjson +4 -4
  43. package/tests/fixtures/traces/conformance/expected/bundle/manifest.json +4 -0
  44. package/tests/fixtures/traces/conformance/expected/bundle/viewer.html +1 -1
  45. package/tests/fixtures/traces/conformance/expected/trace.lino +4 -4
  46. package/tests/fixtures/traces/conformance/scenario.json +1 -0
  47. package/tests/helpers/e2e-browser.js +2 -1
  48. package/tests/unit/browser/issue-160-connector.test.js +157 -0
  49. package/tests/unit/core/interrupted-navigation.test.js +32 -0
  50. package/tests/unit/core/trigger-stop-race.test.js +80 -0
  51. package/tests/unit/interactions/overlays.test.js +58 -0
  52. package/tests/unit/traces/issue-160-privacy.test.js +110 -0
  53. package/tests/unit/traces/issue-160-rotation.test.js +140 -0
  54. package/tests/unit/traces/issue-162-defaults.test.js +223 -0
  55. package/tests/unit/traces/issue-162-user-actions.test.js +181 -0
  56. package/tests/unit/traces/network.test.js +55 -3
  57. package/tests/unit/traces/recorder.test.js +4 -1
@@ -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
+ }
@@ -1,120 +1,15 @@
1
- import fs from 'node:fs/promises';
2
1
  import path from 'node:path';
3
2
 
4
- /** Bounded complete bundles, each independently readable after a crash. */
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
- const maxBytes =
8
- config.maxBytes ?? options.limits.maxBundleBytes ?? 32 * 1024 * 1024;
9
- const maxSegments = config.maxSegments ?? 4;
10
- if (
11
- !Number.isSafeInteger(maxBytes) ||
12
- maxBytes < 1024 ||
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
  };
@@ -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