browser-commander 0.26.3 → 0.28.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 +24 -23
- package/CHANGELOG.md +22 -0
- package/README.md +6 -0
- package/eslint.config.js +11 -0
- package/package.json +9 -4
- package/src/bindings.js +19 -0
- package/src/browser/attach/snapshot-launch.js +8 -6
- package/src/browser/attach/snapshot.js +39 -2
- package/src/browser/browser-cookie-database.js +71 -2
- package/src/browser/browser-cookie-session.js +183 -0
- package/src/browser/browser-cookies.js +29 -0
- package/src/browser/connector.js +117 -13
- package/src/browser/debugging-port.js +4 -2
- package/src/browser/default-browser.js +33 -9
- package/src/browser/launch-diagnostics.js +102 -0
- package/src/browser/launch-options.js +2 -1
- package/src/browser/launch-restrictions.json +33 -2
- package/src/browser/launcher.js +54 -9
- package/src/browser/migration/sqlite-snapshot.js +5 -27
- package/src/browser/navigation.js +173 -98
- package/src/browser/persistent-session.js +360 -0
- package/src/browser/profile-directory.js +20 -0
- package/src/browser/real-browser.js +142 -70
- package/src/browser/restrictions.js +13 -4
- package/src/browser/session-cookies.js +78 -0
- package/src/browser/session-persistence.js +72 -0
- package/src/browser/session-watchdog.js +71 -0
- package/src/browser/session-worker.js +70 -0
- package/src/browser/storage-state.js +32 -6
- 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 +66 -1
- package/src/cli/commands.js +113 -2
- package/src/cli/high-level-methods.js +40 -3
- package/src/cli/sessions.js +11 -3
- package/src/core/commander-features.js +105 -0
- package/src/core/constants.js +1 -0
- package/src/core/dialog-manager.js +7 -0
- package/src/core/managed-navigation.js +101 -0
- package/src/core/navigation-manager.js +66 -80
- package/src/core/navigation-operation.js +25 -0
- package/src/core/navigation-readiness.js +68 -16
- package/src/core/navigation-safety.js +51 -0
- package/src/core/network-tracker.js +39 -92
- package/src/core/page-session.js +3 -2
- package/src/core/page-trigger-manager.js +130 -42
- package/src/core/readiness.js +76 -14
- package/src/core/subscriptions.js +20 -0
- package/src/core/webdriver-page.js +38 -0
- package/src/elements/locators.js +61 -37
- package/src/elements/reusable.js +92 -0
- package/src/elements/selectors.js +37 -81
- package/src/elements/visibility.js +38 -21
- package/src/exports.js +14 -0
- package/src/factory.js +17 -16
- package/src/high-level/universal-logic.js +57 -21
- package/src/index.js +2 -0
- package/src/interactions/click.js +47 -0
- package/src/interactions/overlays.js +28 -0
- package/src/interactions/scroll.js +8 -2
- package/src/traces/bundle.js +14 -5
- package/src/traces/checkpoints.js +99 -0
- package/src/traces/links.js +117 -1
- package/src/traces/mutation-stream.js +1 -0
- package/src/traces/network.js +257 -0
- package/src/traces/page-capture.js +143 -52
- package/src/traces/reader.js +8 -10
- package/src/traces/recorder.js +221 -245
- package/src/traces/render.js +168 -0
- package/src/traces/rolling.js +180 -0
- package/src/traces/start-cleanup.js +15 -0
- package/src/traces/stop.js +159 -0
- package/src/traces/storage.js +43 -0
- package/src/utilities/subprocess.js +47 -4
- package/tests/e2e/cli.e2e.test.js +20 -1
- package/tests/e2e/click-readiness.e2e.test.js +1 -1
- package/tests/e2e/navigation-budget.e2e.test.js +132 -0
- package/tests/e2e/reusable-session.e2e.test.js +231 -0
- package/tests/unit/browser/attach/snapshot-launch.test.js +2 -2
- package/tests/unit/browser/attach/snapshot.test.js +33 -0
- package/tests/unit/browser/default-browser.test.js +18 -0
- package/tests/unit/browser/launch-building-blocks.test.js +5 -1
- package/tests/unit/browser/launch-diagnostics.test.js +52 -0
- 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/real-browser.test.js +1 -1
- package/tests/unit/browser/session-workflows.test.js +206 -0
- package/tests/unit/browser/storage-state.test.js +7 -2
- 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/navigation-budget.test.js +120 -0
- package/tests/unit/core/navigation-manager.test.js +23 -1
- package/tests/unit/core/readiness.test.js +21 -0
- package/tests/unit/core/trigger-readiness.test.js +39 -0
- package/tests/unit/core/webdriver-page.test.js +33 -0
- package/tests/unit/elements/reusable-helpers.test.js +83 -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/scripts/ci-timeout-budgets.test.js +1 -0
- package/tests/unit/traces/bundle.test.js +16 -0
- package/tests/unit/traces/links.test.js +2 -2
- package/tests/unit/traces/network.test.js +97 -0
|
@@ -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,180 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
/** Bounded complete bundles, each independently readable after a crash. */
|
|
5
|
+
export async function startRollingTrace(options, start) {
|
|
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
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Combine segment timelines while keeping checkpoint member paths distinct. */
|
|
121
|
+
export async function readRollingTrace(root, index, read) {
|
|
122
|
+
const events = [],
|
|
123
|
+
checkpoints = [],
|
|
124
|
+
locations = new Map();
|
|
125
|
+
let manifest,
|
|
126
|
+
truncated = false,
|
|
127
|
+
number = 0;
|
|
128
|
+
for (const segment of index.segments) {
|
|
129
|
+
if (!/^segment-\d+$/.test(segment)) {
|
|
130
|
+
throw new Error('invalid trace segment path');
|
|
131
|
+
}
|
|
132
|
+
const opened = await read(path.join(root, segment));
|
|
133
|
+
manifest = opened.manifest;
|
|
134
|
+
truncated ||= opened.truncated;
|
|
135
|
+
const ids = new Map();
|
|
136
|
+
for (const checkpoint of opened.checkpoints) {
|
|
137
|
+
const id = ++number;
|
|
138
|
+
ids.set(checkpoint.index, id);
|
|
139
|
+
locations.set(id, { opened, index: checkpoint.index });
|
|
140
|
+
checkpoints.push({
|
|
141
|
+
...checkpoint,
|
|
142
|
+
index: id,
|
|
143
|
+
members: Object.fromEntries(
|
|
144
|
+
Object.entries(checkpoint.members).map(([key, value]) => [
|
|
145
|
+
key,
|
|
146
|
+
`${segment}/${value}`,
|
|
147
|
+
])
|
|
148
|
+
),
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
for (const event of opened.events) {
|
|
152
|
+
const mapped = { ...event, segment };
|
|
153
|
+
if (event.kind === 'checkpoint') {
|
|
154
|
+
Object.assign(
|
|
155
|
+
mapped,
|
|
156
|
+
checkpoints.find(
|
|
157
|
+
(checkpoint) => checkpoint.index === ids.get(event.index)
|
|
158
|
+
)
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
if (event.kind === 'mutations') {
|
|
162
|
+
mapped.checkpoint = ids.get(event.checkpoint) ?? 0;
|
|
163
|
+
}
|
|
164
|
+
events.push(mapped);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
path: root,
|
|
169
|
+
manifest,
|
|
170
|
+
events,
|
|
171
|
+
checkpoints,
|
|
172
|
+
truncated,
|
|
173
|
+
html: (index) =>
|
|
174
|
+
locations.get(index)?.opened.html(locations.get(index).index),
|
|
175
|
+
state: (index) =>
|
|
176
|
+
locations.get(index)?.opened.state(locations.get(index).index),
|
|
177
|
+
mutations: (index) =>
|
|
178
|
+
locations.get(index)?.opened.mutations(locations.get(index).index) ?? [],
|
|
179
|
+
};
|
|
180
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { createManifest, TRACE_EVENT, TRACE_OUTCOME } from './schema.js';
|
|
2
|
+
import { readTrace } from './reader.js';
|
|
3
|
+
import { writeHar } from './network.js';
|
|
4
|
+
import { gzipTrace } from './storage.js';
|
|
5
|
+
import { abortTraceStart } from './start-cleanup.js';
|
|
6
|
+
export function createTraceStop(context) {
|
|
7
|
+
const {
|
|
8
|
+
getStopped,
|
|
9
|
+
setStopped,
|
|
10
|
+
options,
|
|
11
|
+
detachers,
|
|
12
|
+
navigationCapture,
|
|
13
|
+
video,
|
|
14
|
+
detachNetwork,
|
|
15
|
+
} = context;
|
|
16
|
+
const {
|
|
17
|
+
mutations,
|
|
18
|
+
captureState,
|
|
19
|
+
record,
|
|
20
|
+
note,
|
|
21
|
+
bundle,
|
|
22
|
+
checkpoints,
|
|
23
|
+
linksSink,
|
|
24
|
+
network,
|
|
25
|
+
} = context;
|
|
26
|
+
const {
|
|
27
|
+
recorderMode,
|
|
28
|
+
startedAt,
|
|
29
|
+
now,
|
|
30
|
+
commanderVersion,
|
|
31
|
+
engine,
|
|
32
|
+
domOptions,
|
|
33
|
+
eventSources,
|
|
34
|
+
privacyOptions,
|
|
35
|
+
limits,
|
|
36
|
+
} = context;
|
|
37
|
+
let stopping;
|
|
38
|
+
async function stop(stopOptions = {}) {
|
|
39
|
+
if (getStopped()) {
|
|
40
|
+
return getStopped();
|
|
41
|
+
}
|
|
42
|
+
const { discard = false, error = null } = stopOptions;
|
|
43
|
+
|
|
44
|
+
{
|
|
45
|
+
for (const detach of detachers.splice(0)) {
|
|
46
|
+
try {
|
|
47
|
+
await detach();
|
|
48
|
+
} catch (error) {
|
|
49
|
+
note(`could not detach a listener: ${error.message}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
await navigationCapture();
|
|
53
|
+
}
|
|
54
|
+
if (video) {
|
|
55
|
+
const recording = await video.stop();
|
|
56
|
+
if (recording.bytes) {
|
|
57
|
+
await bundle.writeMember(
|
|
58
|
+
`recording.${recording.format}`,
|
|
59
|
+
recording.bytes
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
await detachNetwork();
|
|
64
|
+
await mutations.drain(captureState.index);
|
|
65
|
+
if (error) {
|
|
66
|
+
await record(TRACE_EVENT.PAGE_ERROR, {
|
|
67
|
+
message: error.message ?? String(error),
|
|
68
|
+
stack: error.stack ?? null,
|
|
69
|
+
fatal: true,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
await record(TRACE_EVENT.TRACE_STOP, { discarded: discard });
|
|
73
|
+
setStopped(true);
|
|
74
|
+
|
|
75
|
+
// The documents that exist stop observing here; the engine's init-script
|
|
76
|
+
// registration is removed with the detachers below.
|
|
77
|
+
await mutations.stop();
|
|
78
|
+
|
|
79
|
+
for (const detach of detachers.reverse()) {
|
|
80
|
+
try {
|
|
81
|
+
await detach();
|
|
82
|
+
} catch (detachError) {
|
|
83
|
+
note(`could not detach a listener: ${detachError.message}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const manifest = await bundle.close(
|
|
88
|
+
createManifest({
|
|
89
|
+
mode: recorderMode,
|
|
90
|
+
startedAt,
|
|
91
|
+
stoppedAt: new Date(now()).toISOString(),
|
|
92
|
+
outcome: TRACE_OUTCOME.COMPLETE,
|
|
93
|
+
commanderVersion,
|
|
94
|
+
engine,
|
|
95
|
+
dom: domOptions,
|
|
96
|
+
events: eventSources,
|
|
97
|
+
replay: {
|
|
98
|
+
checkpoints: true,
|
|
99
|
+
mutations: Boolean(domOptions.mutations),
|
|
100
|
+
childListPositions: Boolean(domOptions.mutations),
|
|
101
|
+
liveState:
|
|
102
|
+
Boolean(domOptions.mutations) && domOptions.liveState !== false,
|
|
103
|
+
identifiers: true,
|
|
104
|
+
},
|
|
105
|
+
privacy: {
|
|
106
|
+
redactSelectors: privacyOptions.redactSelectors,
|
|
107
|
+
redactAttributes: privacyOptions.redactAttributes,
|
|
108
|
+
redactQueryParams: privacyOptions.redactQueryParams,
|
|
109
|
+
hasCallback: Boolean(privacyOptions.redact),
|
|
110
|
+
},
|
|
111
|
+
limits,
|
|
112
|
+
})
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
// Closed after the manifest, because the closing link reports the outcome
|
|
116
|
+
// the manifest settled on, and the control diffs are read back out of the
|
|
117
|
+
// finished bundle rather than kept in memory for the length of a run.
|
|
118
|
+
if (linksSink) {
|
|
119
|
+
await linksSink.close({ manifest, bundlePath: bundle.root });
|
|
120
|
+
}
|
|
121
|
+
if (network?.har) {
|
|
122
|
+
await writeHar(
|
|
123
|
+
await readTrace(bundle.root),
|
|
124
|
+
typeof network.har === 'string' ? network.har : undefined
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
if (options.gzip || limits.gzip) {
|
|
128
|
+
await gzipTrace(bundle.root);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const result = {
|
|
132
|
+
path: bundle.root,
|
|
133
|
+
manifest,
|
|
134
|
+
checkpoints: [...checkpoints],
|
|
135
|
+
problems: [...bundle.problems, ...(linksSink?.problems ?? [])],
|
|
136
|
+
links: linksSink?.path ?? null,
|
|
137
|
+
};
|
|
138
|
+
setStopped(result);
|
|
139
|
+
|
|
140
|
+
if (discard) {
|
|
141
|
+
const fs = await import('node:fs/promises');
|
|
142
|
+
await fs.rm(bundle.root, { recursive: true, force: true });
|
|
143
|
+
// An export of a bundle that no longer exists points at nothing.
|
|
144
|
+
await linksSink?.discard();
|
|
145
|
+
result.discarded = true;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return result;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return (options) => {
|
|
152
|
+
stopping ??= stop(options).catch(async (error) => {
|
|
153
|
+
setStopped(true);
|
|
154
|
+
await abortTraceStart({ ...context, video: null }).catch(() => {});
|
|
155
|
+
throw error;
|
|
156
|
+
});
|
|
157
|
+
return stopping;
|
|
158
|
+
};
|
|
159
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { gzip, gunzip } from 'node:zlib';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
const compress = promisify(gzip),
|
|
6
|
+
decompress = promisify(gunzip);
|
|
7
|
+
|
|
8
|
+
export async function readTraceText(file) {
|
|
9
|
+
try {
|
|
10
|
+
return await fs.readFile(file, 'utf8');
|
|
11
|
+
} catch (error) {
|
|
12
|
+
if (error.code !== 'ENOENT') {
|
|
13
|
+
throw error;
|
|
14
|
+
}
|
|
15
|
+
try {
|
|
16
|
+
return (await decompress(await fs.readFile(`${file}.gz`))).toString(
|
|
17
|
+
'utf8'
|
|
18
|
+
);
|
|
19
|
+
} catch (error) {
|
|
20
|
+
if (error.code === 'ENOENT') {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Compress closed NDJSON members only; active traces remain crash-readable. */
|
|
29
|
+
export async function gzipTrace(root) {
|
|
30
|
+
for (const item of await fs.readdir(root, { withFileTypes: true })) {
|
|
31
|
+
const file = path.join(root, item.name);
|
|
32
|
+
if (item.isDirectory()) {
|
|
33
|
+
await gzipTrace(file);
|
|
34
|
+
} else if (item.name.endsWith('.ndjson')) {
|
|
35
|
+
const temporary = `${file}.gz.tmp`;
|
|
36
|
+
await fs.writeFile(temporary, await compress(await fs.readFile(file)), {
|
|
37
|
+
mode: 0o600,
|
|
38
|
+
});
|
|
39
|
+
await fs.rename(temporary, `${file}.gz`);
|
|
40
|
+
await fs.rm(file);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
import { ProcessRunner } from 'command-stream/process-runner';
|
|
2
2
|
|
|
3
|
+
/** Start an explicitly persistent process, with no parent-owned pipes or signal forwarding. */
|
|
4
|
+
export async function startDetachedProcess(file, args = [], options = {}) {
|
|
5
|
+
const runner = runnerFor(file, args, { ...options, capture: false });
|
|
6
|
+
runner.start();
|
|
7
|
+
await new Promise((resolve, reject) => {
|
|
8
|
+
runner.child.once('spawn', resolve);
|
|
9
|
+
runner.child.once('error', reject);
|
|
10
|
+
});
|
|
11
|
+
const child = runner.child;
|
|
12
|
+
// command-stream 1.x starts noninteractive children in their own process
|
|
13
|
+
// group. Release its controller-owned cancellation registry while retaining
|
|
14
|
+
// the child's exit listeners. Keep this adapter isolated until it exposes a
|
|
15
|
+
// public detach operation; normal subprocesses retain managed cancellation.
|
|
16
|
+
runner._child = null;
|
|
17
|
+
runner._abortController = null;
|
|
18
|
+
runner.finished = true;
|
|
19
|
+
runner._cleanup();
|
|
20
|
+
Promise.resolve(runner).catch(() => {});
|
|
21
|
+
child.stderrTail = '';
|
|
22
|
+
child.stderr?.on('data', (chunk) => {
|
|
23
|
+
child.stderrTail = (child.stderrTail + String(chunk)).slice(-8192);
|
|
24
|
+
});
|
|
25
|
+
child.on('error', (error) => {
|
|
26
|
+
child.spawnError = error;
|
|
27
|
+
});
|
|
28
|
+
child.unref();
|
|
29
|
+
child.stdout?.unref?.();
|
|
30
|
+
child.stderr?.unref?.();
|
|
31
|
+
child.stdin?.unref?.();
|
|
32
|
+
return child;
|
|
33
|
+
}
|
|
34
|
+
|
|
3
35
|
/**
|
|
4
36
|
* Every subprocess Browser Commander starts - browsers, WebDriver servers, the
|
|
5
37
|
* engine bridge, credential tools (`security`, `secret-tool`, `kwallet-query`,
|
|
@@ -14,12 +46,16 @@ import { ProcessRunner } from 'command-stream/process-runner';
|
|
|
14
46
|
* optional PTY and terminal-rendering modules are never loaded.
|
|
15
47
|
*/
|
|
16
48
|
|
|
17
|
-
function runnerFor(
|
|
49
|
+
function runnerFor(
|
|
50
|
+
file,
|
|
51
|
+
args,
|
|
52
|
+
{ env, cwd, stdin = 'ignore', killGrace, capture = true }
|
|
53
|
+
) {
|
|
18
54
|
return new ProcessRunner(
|
|
19
55
|
{ mode: 'exec', file, args },
|
|
20
56
|
{
|
|
21
57
|
mirror: false,
|
|
22
|
-
capture
|
|
58
|
+
capture,
|
|
23
59
|
stdin,
|
|
24
60
|
...(env ? { env } : {}),
|
|
25
61
|
...(cwd ? { cwd } : {}),
|
|
@@ -102,7 +138,7 @@ export async function runCommand(file, args = [], options = {}) {
|
|
|
102
138
|
*/
|
|
103
139
|
export function startProcess(file, args = [], options = {}) {
|
|
104
140
|
const { env, cwd, forwardOutput = false, killGrace = 2000 } = options;
|
|
105
|
-
const runner = runnerFor(file, args, { env, cwd, killGrace });
|
|
141
|
+
const runner = runnerFor(file, args, { env, cwd, killGrace, capture: false });
|
|
106
142
|
return new ManagedProcess(runner, { forwardOutput });
|
|
107
143
|
}
|
|
108
144
|
|
|
@@ -136,6 +172,8 @@ export class ManagedProcess {
|
|
|
136
172
|
this.runner = runner;
|
|
137
173
|
this.exitCode = null;
|
|
138
174
|
this.signalCode = null;
|
|
175
|
+
this.stderrTail = '';
|
|
176
|
+
this.spawnError = null;
|
|
139
177
|
this.stdout = new OutputChannel();
|
|
140
178
|
this.stderr = new OutputChannel();
|
|
141
179
|
this.exitListeners = new Set();
|
|
@@ -146,6 +184,7 @@ export class ManagedProcess {
|
|
|
146
184
|
this.stdout.emit(chunk);
|
|
147
185
|
});
|
|
148
186
|
runner.on('stderr', (chunk) => {
|
|
187
|
+
this.stderrTail = (this.stderrTail + String(chunk)).slice(-8192);
|
|
149
188
|
if (forwardOutput) {
|
|
150
189
|
process.stderr.write(chunk);
|
|
151
190
|
}
|
|
@@ -153,9 +192,13 @@ export class ManagedProcess {
|
|
|
153
192
|
});
|
|
154
193
|
runner.start();
|
|
155
194
|
this.exited = Promise.resolve(runner)
|
|
156
|
-
.catch((error) =>
|
|
195
|
+
.catch((error) => {
|
|
196
|
+
this.spawnError = error;
|
|
197
|
+
return { code: error?.code ?? 1 };
|
|
198
|
+
})
|
|
157
199
|
.then((result) => {
|
|
158
200
|
this.exitCode = typeof result.code === 'number' ? result.code : 1;
|
|
201
|
+
this.signalCode = result.signal ?? null;
|
|
159
202
|
for (const listener of this.exitListeners) {
|
|
160
203
|
listener(this.exitCode);
|
|
161
204
|
}
|