browser-commander 0.17.2 → 0.19.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 +12 -17
- package/CHANGELOG.md +68 -0
- package/README.md +132 -0
- package/eslint.config.js +5 -0
- package/package.json +5 -1
- package/src/bindings.js +13 -1
- package/src/browser/connector.js +15 -3
- package/src/browser/launcher.js +14 -2
- package/src/browser/navigation.js +63 -0
- package/src/core/dialog-manager.js +48 -0
- package/src/core/navigation-manager.js +83 -87
- package/src/core/navigation-readiness.js +133 -0
- package/src/core/readiness.js +478 -0
- package/src/downloads/attach.js +58 -0
- package/src/downloads/destination.js +125 -0
- package/src/downloads/manager.js +476 -0
- package/src/downloads/naming.js +246 -0
- package/src/downloads/sources.js +238 -0
- package/src/downloads/staging.js +146 -0
- package/src/downloads/store.js +256 -0
- package/src/exports.js +86 -0
- package/src/factory.js +55 -0
- package/src/interactions/click-activation.js +317 -0
- package/src/interactions/click-result.js +108 -0
- package/src/interactions/click.js +766 -352
- package/src/tests/index.js +75 -11
- package/src/tests/tracing.js +236 -0
- package/src/traces/bundle.js +338 -0
- package/src/traces/deadline.js +33 -0
- package/src/traces/identity.js +127 -0
- package/src/traces/init-script.js +95 -0
- package/src/traces/links.js +623 -0
- package/src/traces/mutation-stream.js +232 -0
- package/src/traces/observers.js +272 -0
- package/src/traces/page-capture.js +601 -0
- package/src/traces/reader.js +226 -0
- package/src/traces/recorder.js +454 -0
- package/src/traces/redaction.js +231 -0
- package/src/traces/schema.js +229 -0
- package/src/traces/viewer.js +448 -0
- package/tests/e2e/click-readiness.e2e.test.js +240 -0
- package/tests/e2e/downloads.e2e.test.js +235 -0
- package/tests/e2e/traces.e2e.test.js +742 -0
- package/tests/helpers/click-fixtures.js +62 -0
- package/tests/helpers/download-fixtures.js +125 -0
- package/tests/helpers/download-server.js +129 -0
- package/tests/helpers/e2e-browser.js +67 -0
- package/tests/helpers/file-modes.js +31 -0
- package/tests/helpers/fixture-server.js +56 -0
- package/tests/helpers/mocks.js +63 -66
- package/tests/helpers/readiness-server.js +96 -0
- package/tests/helpers/temp-directory.js +44 -0
- package/tests/helpers/trace-fixtures.js +274 -0
- package/tests/helpers/trace-server.js +173 -0
- package/tests/unit/core/dialog-manager.test.js +118 -10
- package/tests/unit/core/navigation-manager.test.js +162 -0
- package/tests/unit/core/readiness.test.js +517 -0
- package/tests/unit/downloads/attach.test.js +220 -0
- package/tests/unit/downloads/destination.test.js +114 -0
- package/tests/unit/downloads/manager.test.js +485 -0
- package/tests/unit/downloads/naming.test.js +174 -0
- package/tests/unit/downloads/staging.test.js +117 -0
- package/tests/unit/downloads/store.test.js +215 -0
- package/tests/unit/interactions/click-activation.test.js +283 -0
- package/tests/unit/interactions/click-result.test.js +90 -0
- package/tests/unit/interactions/click.test.js +409 -200
- package/tests/unit/tests/tracing.test.js +299 -0
- package/tests/unit/traces/bundle.test.js +260 -0
- package/tests/unit/traces/links.test.js +557 -0
- package/tests/unit/traces/reader.test.js +208 -0
- package/tests/unit/traces/recorder.test.js +730 -0
- package/tests/unit/traces/redaction.test.js +170 -0
- package/tests/unit/traces/schema.test.js +92 -0
- package/tests/unit/traces/viewer.test.js +294 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading a trace bundle (issue #87).
|
|
3
|
+
*
|
|
4
|
+
* The reader assumes nothing finished cleanly. A run that was killed leaves a
|
|
5
|
+
* bundle with no manifest and a half-written last line; that bundle still
|
|
6
|
+
* holds the evidence someone is looking for, so it is repaired on open rather
|
|
7
|
+
* than rejected.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import fs from 'node:fs/promises';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import {
|
|
13
|
+
assertReadableManifest,
|
|
14
|
+
createManifest,
|
|
15
|
+
sequenceName,
|
|
16
|
+
TRACE_FILES,
|
|
17
|
+
TRACE_MODE,
|
|
18
|
+
TRACE_OUTCOME,
|
|
19
|
+
} from './schema.js';
|
|
20
|
+
|
|
21
|
+
async function readIfPresent(file) {
|
|
22
|
+
try {
|
|
23
|
+
return await fs.readFile(file, 'utf8');
|
|
24
|
+
} catch (error) {
|
|
25
|
+
if (error.code === 'ENOENT') {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Parse an NDJSON body, keeping everything before the first unreadable line.
|
|
34
|
+
*
|
|
35
|
+
* @param {string|null} body - File contents
|
|
36
|
+
* @returns {Object} `{records, truncated}`
|
|
37
|
+
*/
|
|
38
|
+
export function parseNdjson(body) {
|
|
39
|
+
const records = [];
|
|
40
|
+
let truncated = false;
|
|
41
|
+
|
|
42
|
+
for (const line of (body ?? '').split('\n')) {
|
|
43
|
+
if (line.trim() === '') {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
records.push(JSON.parse(line));
|
|
48
|
+
} catch {
|
|
49
|
+
// A process killed mid-write leaves one partial line. Everything before
|
|
50
|
+
// it is still true, so the trace is readable up to that point.
|
|
51
|
+
truncated = true;
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return { records, truncated };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Open a trace bundle.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} bundlePath - Path to the bundle directory
|
|
63
|
+
* @returns {Promise<Object>} `{path, manifest, events, checkpoints, truncated}`
|
|
64
|
+
*/
|
|
65
|
+
export async function readTrace(bundlePath) {
|
|
66
|
+
const root = path.resolve(bundlePath);
|
|
67
|
+
const manifestBody = await readIfPresent(
|
|
68
|
+
path.join(root, TRACE_FILES.MANIFEST)
|
|
69
|
+
);
|
|
70
|
+
const eventsBody = await readIfPresent(path.join(root, TRACE_FILES.EVENTS));
|
|
71
|
+
|
|
72
|
+
if (manifestBody === null && eventsBody === null) {
|
|
73
|
+
throw new Error(`no trace bundle at ${root}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const { records: events, truncated } = parseNdjson(eventsBody);
|
|
77
|
+
|
|
78
|
+
let manifest;
|
|
79
|
+
if (manifestBody === null) {
|
|
80
|
+
// No manifest means the run never reached `stop()`. The timeline is the
|
|
81
|
+
// record of what happened; the manifest is rebuilt around it.
|
|
82
|
+
const started = events.find((event) => event.kind === 'trace.start');
|
|
83
|
+
manifest = createManifest({
|
|
84
|
+
mode: started?.mode ?? TRACE_MODE.CHECKPOINTS,
|
|
85
|
+
startedAt: started?.at ?? null,
|
|
86
|
+
stoppedAt: events.at(-1)?.at ?? null,
|
|
87
|
+
outcome: TRACE_OUTCOME.TRUNCATED,
|
|
88
|
+
engine: started?.engine ?? null,
|
|
89
|
+
events: started?.events ?? [],
|
|
90
|
+
dom: started?.dom ?? {},
|
|
91
|
+
counts: {
|
|
92
|
+
events: events.length,
|
|
93
|
+
checkpoints: events.filter((event) => event.kind === 'checkpoint')
|
|
94
|
+
.length,
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
} else {
|
|
98
|
+
try {
|
|
99
|
+
manifest = JSON.parse(manifestBody);
|
|
100
|
+
} catch {
|
|
101
|
+
throw new Error(
|
|
102
|
+
`${TRACE_FILES.MANIFEST} in ${root} is not readable JSON`
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
assertReadableManifest(manifest);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const checkpoints = events
|
|
109
|
+
.filter((event) => event.kind === 'checkpoint')
|
|
110
|
+
.map((event) => ({
|
|
111
|
+
index: event.index,
|
|
112
|
+
name: event.name,
|
|
113
|
+
actor: event.actor,
|
|
114
|
+
reason: event.reason,
|
|
115
|
+
url: event.url,
|
|
116
|
+
at: event.at,
|
|
117
|
+
sequence: event.sequence,
|
|
118
|
+
members: event.members ?? {},
|
|
119
|
+
}));
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
path: root,
|
|
123
|
+
manifest,
|
|
124
|
+
events,
|
|
125
|
+
checkpoints,
|
|
126
|
+
truncated: truncated || manifest.outcome !== TRACE_OUTCOME.COMPLETE,
|
|
127
|
+
/**
|
|
128
|
+
* Read one checkpoint's HTML.
|
|
129
|
+
*
|
|
130
|
+
* @param {number} index - Checkpoint number
|
|
131
|
+
* @returns {Promise<string|null>} The captured markup
|
|
132
|
+
*/
|
|
133
|
+
html: (index) =>
|
|
134
|
+
readIfPresent(
|
|
135
|
+
path.join(
|
|
136
|
+
root,
|
|
137
|
+
TRACE_FILES.CHECKPOINTS_DIR,
|
|
138
|
+
`${sequenceName(index)}.html`
|
|
139
|
+
)
|
|
140
|
+
),
|
|
141
|
+
/**
|
|
142
|
+
* Read one checkpoint's live control state.
|
|
143
|
+
*
|
|
144
|
+
* @param {number} index - Checkpoint number
|
|
145
|
+
* @returns {Promise<Object|null>} The captured state
|
|
146
|
+
*/
|
|
147
|
+
state: async (index) => {
|
|
148
|
+
const body = await readIfPresent(
|
|
149
|
+
path.join(
|
|
150
|
+
root,
|
|
151
|
+
TRACE_FILES.CHECKPOINTS_DIR,
|
|
152
|
+
`${sequenceName(index)}.state.json`
|
|
153
|
+
)
|
|
154
|
+
);
|
|
155
|
+
return body === null ? null : JSON.parse(body);
|
|
156
|
+
},
|
|
157
|
+
/**
|
|
158
|
+
* Read the mutation batches recorded after one checkpoint.
|
|
159
|
+
*
|
|
160
|
+
* @param {number} index - Checkpoint number
|
|
161
|
+
* @returns {Promise<Object[]>} Ordered batches
|
|
162
|
+
*/
|
|
163
|
+
mutations: async (index) => {
|
|
164
|
+
const body = await readIfPresent(
|
|
165
|
+
path.join(
|
|
166
|
+
root,
|
|
167
|
+
TRACE_FILES.MUTATIONS_DIR,
|
|
168
|
+
`${sequenceName(index)}.ndjson`
|
|
169
|
+
)
|
|
170
|
+
);
|
|
171
|
+
return parseNdjson(body).records;
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Diff two checkpoints' control state.
|
|
178
|
+
*
|
|
179
|
+
* `{before, after, actor}` per control is the shape a reviewer needs to answer
|
|
180
|
+
* "what did this step change?" without reading two HTML files side by side.
|
|
181
|
+
*
|
|
182
|
+
* @param {Object} before - State of the earlier checkpoint
|
|
183
|
+
* @param {Object} after - State of the later checkpoint
|
|
184
|
+
* @returns {Object[]} One record per changed control
|
|
185
|
+
*/
|
|
186
|
+
export function diffControlState(before, after) {
|
|
187
|
+
const index = new Map(
|
|
188
|
+
(before?.controls ?? []).map((control) => [control.path, control])
|
|
189
|
+
);
|
|
190
|
+
const changes = [];
|
|
191
|
+
|
|
192
|
+
for (const control of after?.controls ?? []) {
|
|
193
|
+
const previous = index.get(control.path);
|
|
194
|
+
index.delete(control.path);
|
|
195
|
+
if (!previous) {
|
|
196
|
+
changes.push({
|
|
197
|
+
path: control.path,
|
|
198
|
+
change: 'added',
|
|
199
|
+
after: control.value,
|
|
200
|
+
});
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if (
|
|
204
|
+
previous.value !== control.value ||
|
|
205
|
+
previous.checked !== control.checked
|
|
206
|
+
) {
|
|
207
|
+
changes.push({
|
|
208
|
+
path: control.path,
|
|
209
|
+
change: 'changed',
|
|
210
|
+
before:
|
|
211
|
+
previous.checked === undefined ? previous.value : previous.checked,
|
|
212
|
+
after: control.checked === undefined ? control.value : control.checked,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
for (const control of index.values()) {
|
|
218
|
+
changes.push({
|
|
219
|
+
path: control.path,
|
|
220
|
+
change: 'removed',
|
|
221
|
+
before: control.value,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return changes;
|
|
226
|
+
}
|
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The trace recorder (issue #87).
|
|
3
|
+
*
|
|
4
|
+
* A consumer starts a recorder, names checkpoints and stops it. Nothing in
|
|
5
|
+
* that path requires the raw Playwright or Puppeteer page: the recorder is
|
|
6
|
+
* the thing that knows how each engine reports a console message or a failed
|
|
7
|
+
* request, so every caller does not have to.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { captureSnapshotInPage } from './page-capture.js';
|
|
11
|
+
import { openTraceBundle } from './bundle.js';
|
|
12
|
+
import { openTraceLinks } from './links.js';
|
|
13
|
+
import { createTraceIdentity } from './identity.js';
|
|
14
|
+
import { withDeadline } from './deadline.js';
|
|
15
|
+
import { createMutationStream } from './mutation-stream.js';
|
|
16
|
+
import { attachTimelineObservers } from './observers.js';
|
|
17
|
+
import {
|
|
18
|
+
normalizePrivacyOptions,
|
|
19
|
+
redactUrl,
|
|
20
|
+
redactValue,
|
|
21
|
+
REDACTED,
|
|
22
|
+
} from './redaction.js';
|
|
23
|
+
import {
|
|
24
|
+
createManifest,
|
|
25
|
+
DEFAULT_CAPTURE_TIMEOUT,
|
|
26
|
+
TRACE_CHECKPOINT_REASON,
|
|
27
|
+
TRACE_DROP_REASON,
|
|
28
|
+
TRACE_EVENT,
|
|
29
|
+
TRACE_EVENT_SOURCES,
|
|
30
|
+
TRACE_MODE,
|
|
31
|
+
TRACE_OUTCOME,
|
|
32
|
+
TRACE_SCHEMA_VERSION,
|
|
33
|
+
} from './schema.js';
|
|
34
|
+
|
|
35
|
+
/** DOM capture defaults: what a checkpoint holds unless the caller narrows it. */
|
|
36
|
+
const DEFAULT_DOM_OPTIONS = Object.freeze({
|
|
37
|
+
html: true,
|
|
38
|
+
liveControlState: true,
|
|
39
|
+
/**
|
|
40
|
+
* Record what typing, checking, selecting, focus and scroll did, as it
|
|
41
|
+
* happens (issue #93). Reading it only at checkpoints meant a replay could
|
|
42
|
+
* show an empty field a moment after the user finished filling it in.
|
|
43
|
+
*/
|
|
44
|
+
liveState: true,
|
|
45
|
+
mutations: false,
|
|
46
|
+
openShadowRoots: true,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
function normalizeMode(mode) {
|
|
50
|
+
const values = Object.values(TRACE_MODE);
|
|
51
|
+
if (!values.includes(mode)) {
|
|
52
|
+
throw new Error(`trace mode must be one of ${values.join(', ')}`);
|
|
53
|
+
}
|
|
54
|
+
return mode;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function normalizeEvents(events) {
|
|
58
|
+
if (events === false) {
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
if (events === undefined || events === true) {
|
|
62
|
+
return [...TRACE_EVENT_SOURCES];
|
|
63
|
+
}
|
|
64
|
+
if (!Array.isArray(events)) {
|
|
65
|
+
throw new Error('trace events must be an array of event names');
|
|
66
|
+
}
|
|
67
|
+
for (const name of events) {
|
|
68
|
+
if (!TRACE_EVENT_SOURCES.includes(name)) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`unknown trace event source "${name}"; expected one of ${TRACE_EVENT_SOURCES.join(', ')}`
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return [...new Set(events)];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Start recording a session.
|
|
79
|
+
*
|
|
80
|
+
* @param {Object} options - Recorder options, see `commander.startTrace()`
|
|
81
|
+
* @returns {Promise<Object>} The running trace
|
|
82
|
+
*/
|
|
83
|
+
export async function startTrace(options = {}) {
|
|
84
|
+
const {
|
|
85
|
+
commander,
|
|
86
|
+
page = commander?.page,
|
|
87
|
+
output,
|
|
88
|
+
mode = TRACE_MODE.CHECKPOINTS,
|
|
89
|
+
initialCheckpoint,
|
|
90
|
+
screenshots = 'checkpoints',
|
|
91
|
+
dom = {},
|
|
92
|
+
events,
|
|
93
|
+
privacy = {},
|
|
94
|
+
limits = {},
|
|
95
|
+
links = null,
|
|
96
|
+
strict = false,
|
|
97
|
+
captureTimeoutMs = DEFAULT_CAPTURE_TIMEOUT,
|
|
98
|
+
commanderVersion = null,
|
|
99
|
+
log = commander?.log,
|
|
100
|
+
now = () => Date.now(),
|
|
101
|
+
} = options;
|
|
102
|
+
|
|
103
|
+
if (!page) {
|
|
104
|
+
throw new Error('startTrace requires a page or a commander');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const recorderMode = normalizeMode(mode);
|
|
108
|
+
const domOptions = { ...DEFAULT_DOM_OPTIONS, ...dom };
|
|
109
|
+
if (recorderMode === TRACE_MODE.CONTINUOUS && dom.mutations !== false) {
|
|
110
|
+
domOptions.mutations = true;
|
|
111
|
+
}
|
|
112
|
+
const eventSources = normalizeEvents(events);
|
|
113
|
+
const privacyOptions = normalizePrivacyOptions(privacy);
|
|
114
|
+
const engine = commander?.engine ?? null;
|
|
115
|
+
const identity = createTraceIdentity({ page });
|
|
116
|
+
|
|
117
|
+
// A continuous trace records changes, and a change is only meaningful against
|
|
118
|
+
// something. Taking the base snapshot automatically is the safe default,
|
|
119
|
+
// because a trace that starts mid-air replays as a blank page (issue #93); a
|
|
120
|
+
// checkpoints-only trace is a list of moments the caller named, so nothing is
|
|
121
|
+
// added to it uninvited. Passing a string names the base checkpoint.
|
|
122
|
+
const baseCheckpoint =
|
|
123
|
+
initialCheckpoint ?? recorderMode === TRACE_MODE.CONTINUOUS;
|
|
124
|
+
const baseCheckpointName =
|
|
125
|
+
typeof baseCheckpoint === 'string' ? baseCheckpoint : 'initial';
|
|
126
|
+
|
|
127
|
+
// The Links Notation export is a second view of the same records, written
|
|
128
|
+
// as they are recorded (issue #94), so a run that is killed leaves a
|
|
129
|
+
// readable export of everything that had happened. The bundle stays
|
|
130
|
+
// authoritative: nothing is written here that is not written there first.
|
|
131
|
+
let linksSink = null;
|
|
132
|
+
const bundle = await openTraceBundle({
|
|
133
|
+
output,
|
|
134
|
+
limits,
|
|
135
|
+
strict,
|
|
136
|
+
now,
|
|
137
|
+
onEvent: (event) => linksSink?.event(event),
|
|
138
|
+
});
|
|
139
|
+
const startedAt = new Date(now()).toISOString();
|
|
140
|
+
|
|
141
|
+
if (links) {
|
|
142
|
+
if (typeof links.output !== 'string' || links.output === '') {
|
|
143
|
+
throw new Error('trace links require an output path');
|
|
144
|
+
}
|
|
145
|
+
linksSink = await openTraceLinks({
|
|
146
|
+
output: links.output,
|
|
147
|
+
include: links.include,
|
|
148
|
+
bundlePath: bundle.root,
|
|
149
|
+
schemaVersion: TRACE_SCHEMA_VERSION,
|
|
150
|
+
mode: recorderMode,
|
|
151
|
+
engine,
|
|
152
|
+
startedAt,
|
|
153
|
+
commanderVersion,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
let stopped = false;
|
|
158
|
+
let checkpointIndex = 0;
|
|
159
|
+
const checkpoints = [];
|
|
160
|
+
|
|
161
|
+
const note = (message) => log?.debug?.(`[trace] ${message}`);
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Record an event on the shared timeline.
|
|
165
|
+
*
|
|
166
|
+
* @param {string} kind - One of TRACE_EVENT
|
|
167
|
+
* @param {Object} [payload] - Event fields, redacted before writing
|
|
168
|
+
* @returns {Promise<Object|null>} The written event
|
|
169
|
+
*/
|
|
170
|
+
async function record(kind, payload = {}) {
|
|
171
|
+
if (stopped && kind !== TRACE_EVENT.TRACE_STOP) {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
return await bundle.appendEvent({
|
|
175
|
+
kind,
|
|
176
|
+
// Who this happened to comes first so a payload that knows better - a
|
|
177
|
+
// drain that names the frame it came from, say - can say so.
|
|
178
|
+
...identity.owner(),
|
|
179
|
+
...redactValue(payload, privacyOptions),
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function evaluateInPage(fn, argument) {
|
|
184
|
+
// Both engines take `(fn, arg)`; the commander's own evaluate already
|
|
185
|
+
// normalizes the difference, and is used when it is available.
|
|
186
|
+
if (commander?.evaluate) {
|
|
187
|
+
return await commander.evaluate(fn, argument);
|
|
188
|
+
}
|
|
189
|
+
return await page.evaluate(fn, argument);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const mutations = createMutationStream({
|
|
193
|
+
page,
|
|
194
|
+
bundle,
|
|
195
|
+
record,
|
|
196
|
+
identity,
|
|
197
|
+
note,
|
|
198
|
+
domOptions,
|
|
199
|
+
privacyOptions,
|
|
200
|
+
limits,
|
|
201
|
+
captureTimeoutMs,
|
|
202
|
+
evaluateInPage,
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
async function screenshot(reason) {
|
|
206
|
+
const wanted =
|
|
207
|
+
screenshots === true ||
|
|
208
|
+
screenshots === 'checkpoints' ||
|
|
209
|
+
(screenshots === 'only-on-failure' && reason === 'failure');
|
|
210
|
+
if (!wanted || typeof page.screenshot !== 'function') {
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
try {
|
|
214
|
+
const shot = await withDeadline(
|
|
215
|
+
page.screenshot({ type: 'png' }),
|
|
216
|
+
captureTimeoutMs,
|
|
217
|
+
'trace screenshot'
|
|
218
|
+
);
|
|
219
|
+
return Buffer.isBuffer(shot) ? shot : Buffer.from(shot);
|
|
220
|
+
} catch (error) {
|
|
221
|
+
await bundle.drop({
|
|
222
|
+
reason: TRACE_DROP_REASON.CAPTURE_FAILED,
|
|
223
|
+
member: 'screenshot',
|
|
224
|
+
detail: error.message,
|
|
225
|
+
});
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Capture a named checkpoint.
|
|
232
|
+
*
|
|
233
|
+
* @param {string} name - What this moment is
|
|
234
|
+
* @param {Object} [checkpointOptions] - `{actor, reason, screenshots}`
|
|
235
|
+
* @returns {Promise<Object|null>} `{index, name, members}`
|
|
236
|
+
*/
|
|
237
|
+
async function checkpoint(name, checkpointOptions = {}) {
|
|
238
|
+
if (stopped) {
|
|
239
|
+
throw new Error('this trace has already been stopped');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const { actor = 'automation', reason = 'checkpoint' } = checkpointOptions;
|
|
243
|
+
const index = ++checkpointIndex;
|
|
244
|
+
|
|
245
|
+
// Mutations are drained first so the batches belong to the interval that
|
|
246
|
+
// ended here, not to the one that starts now.
|
|
247
|
+
await mutations.drain(index - 1 > 0 ? index - 1 : 0);
|
|
248
|
+
|
|
249
|
+
let captured = null;
|
|
250
|
+
try {
|
|
251
|
+
captured = await withDeadline(
|
|
252
|
+
evaluateInPage(captureSnapshotInPage, {
|
|
253
|
+
redactSelectors: privacyOptions.redactSelectors,
|
|
254
|
+
redactAttributes: privacyOptions.redactAttributes,
|
|
255
|
+
redacted: REDACTED,
|
|
256
|
+
html: domOptions.html,
|
|
257
|
+
liveControlState: domOptions.liveControlState,
|
|
258
|
+
openShadowRoots: domOptions.openShadowRoots,
|
|
259
|
+
maxHtmlBytes: limits.maxHtmlBytes ?? 0,
|
|
260
|
+
}),
|
|
261
|
+
captureTimeoutMs,
|
|
262
|
+
'trace checkpoint capture'
|
|
263
|
+
);
|
|
264
|
+
} catch (error) {
|
|
265
|
+
await bundle.drop({
|
|
266
|
+
reason: /closed/i.test(error.message)
|
|
267
|
+
? TRACE_DROP_REASON.PAGE_CLOSED
|
|
268
|
+
: TRACE_DROP_REASON.CAPTURE_FAILED,
|
|
269
|
+
member: `checkpoints/${index}`,
|
|
270
|
+
detail: error.message,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const shot = await screenshot(reason);
|
|
275
|
+
const state = captured?.state
|
|
276
|
+
? redactValue(captured.state, privacyOptions)
|
|
277
|
+
: null;
|
|
278
|
+
|
|
279
|
+
const members = await bundle.writeCheckpoint({
|
|
280
|
+
index,
|
|
281
|
+
html: captured?.html ?? undefined,
|
|
282
|
+
state: state ? { ...state, name, actor, reason } : undefined,
|
|
283
|
+
screenshot: shot ?? undefined,
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
const entry = {
|
|
287
|
+
index,
|
|
288
|
+
name,
|
|
289
|
+
actor,
|
|
290
|
+
reason,
|
|
291
|
+
url: state ? redactUrl(state.url, privacyOptions) : null,
|
|
292
|
+
truncated: Boolean(captured?.truncated),
|
|
293
|
+
members,
|
|
294
|
+
};
|
|
295
|
+
checkpoints.push(entry);
|
|
296
|
+
await record(TRACE_EVENT.CHECKPOINT, entry);
|
|
297
|
+
|
|
298
|
+
// The init script covers every document created from here on; this covers
|
|
299
|
+
// a frame that was attached without one, which is cheap because installing
|
|
300
|
+
// over a recorder that is already observing does nothing.
|
|
301
|
+
await mutations.install();
|
|
302
|
+
|
|
303
|
+
return entry;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const detachers = attachTimelineObservers({
|
|
307
|
+
commander,
|
|
308
|
+
page,
|
|
309
|
+
eventSources,
|
|
310
|
+
record,
|
|
311
|
+
note,
|
|
312
|
+
identity,
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
// Registered before the first record, so a navigation that starts in the same
|
|
316
|
+
// tick as the trace does is still recorded from its first mutation.
|
|
317
|
+
const detachInitScript = await mutations.installPersistent();
|
|
318
|
+
if (detachInitScript) {
|
|
319
|
+
detachers.push(detachInitScript);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
await record(TRACE_EVENT.TRACE_START, {
|
|
323
|
+
mode: recorderMode,
|
|
324
|
+
engine,
|
|
325
|
+
dom: domOptions,
|
|
326
|
+
events: eventSources,
|
|
327
|
+
initialCheckpoint: Boolean(baseCheckpoint),
|
|
328
|
+
});
|
|
329
|
+
await mutations.install();
|
|
330
|
+
|
|
331
|
+
if (baseCheckpoint) {
|
|
332
|
+
await checkpoint(baseCheckpointName, {
|
|
333
|
+
actor: 'recorder',
|
|
334
|
+
reason: TRACE_CHECKPOINT_REASON.INITIAL,
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Stop recording and write the manifest.
|
|
340
|
+
*
|
|
341
|
+
* @param {Object} [stopOptions] - `{outcome, discard, error}`
|
|
342
|
+
* @returns {Promise<Object>} `{path, manifest, checkpoints}`
|
|
343
|
+
*/
|
|
344
|
+
async function stop(stopOptions = {}) {
|
|
345
|
+
if (stopped) {
|
|
346
|
+
return stopped;
|
|
347
|
+
}
|
|
348
|
+
const { discard = false, error = null } = stopOptions;
|
|
349
|
+
|
|
350
|
+
await mutations.drain(checkpointIndex);
|
|
351
|
+
if (error) {
|
|
352
|
+
await record(TRACE_EVENT.PAGE_ERROR, {
|
|
353
|
+
message: error.message ?? String(error),
|
|
354
|
+
stack: error.stack ?? null,
|
|
355
|
+
fatal: true,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
await record(TRACE_EVENT.TRACE_STOP, { discarded: discard });
|
|
359
|
+
stopped = true;
|
|
360
|
+
|
|
361
|
+
// The documents that exist stop observing here; the engine's init-script
|
|
362
|
+
// registration is removed with the detachers below.
|
|
363
|
+
await mutations.stop();
|
|
364
|
+
|
|
365
|
+
for (const detach of detachers.reverse()) {
|
|
366
|
+
try {
|
|
367
|
+
await detach();
|
|
368
|
+
} catch (detachError) {
|
|
369
|
+
note(`could not detach a listener: ${detachError.message}`);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const manifest = await bundle.close(
|
|
374
|
+
createManifest({
|
|
375
|
+
mode: recorderMode,
|
|
376
|
+
startedAt,
|
|
377
|
+
stoppedAt: new Date(now()).toISOString(),
|
|
378
|
+
outcome: TRACE_OUTCOME.COMPLETE,
|
|
379
|
+
commanderVersion,
|
|
380
|
+
engine,
|
|
381
|
+
dom: domOptions,
|
|
382
|
+
events: eventSources,
|
|
383
|
+
replay: {
|
|
384
|
+
checkpoints: true,
|
|
385
|
+
mutations: Boolean(domOptions.mutations),
|
|
386
|
+
childListPositions: Boolean(domOptions.mutations),
|
|
387
|
+
liveState:
|
|
388
|
+
Boolean(domOptions.mutations) && domOptions.liveState !== false,
|
|
389
|
+
identifiers: true,
|
|
390
|
+
},
|
|
391
|
+
privacy: {
|
|
392
|
+
redactSelectors: privacyOptions.redactSelectors,
|
|
393
|
+
redactAttributes: privacyOptions.redactAttributes,
|
|
394
|
+
redactQueryParams: privacyOptions.redactQueryParams,
|
|
395
|
+
hasCallback: Boolean(privacyOptions.redact),
|
|
396
|
+
},
|
|
397
|
+
limits,
|
|
398
|
+
})
|
|
399
|
+
);
|
|
400
|
+
|
|
401
|
+
// Closed after the manifest, because the closing link reports the outcome
|
|
402
|
+
// the manifest settled on, and the control diffs are read back out of the
|
|
403
|
+
// finished bundle rather than kept in memory for the length of a run.
|
|
404
|
+
if (linksSink) {
|
|
405
|
+
await linksSink.close({ manifest, bundlePath: bundle.root });
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const result = {
|
|
409
|
+
path: bundle.root,
|
|
410
|
+
manifest,
|
|
411
|
+
checkpoints: [...checkpoints],
|
|
412
|
+
problems: [...bundle.problems, ...(linksSink?.problems ?? [])],
|
|
413
|
+
links: linksSink?.path ?? null,
|
|
414
|
+
};
|
|
415
|
+
stopped = result;
|
|
416
|
+
|
|
417
|
+
if (discard) {
|
|
418
|
+
const fs = await import('node:fs/promises');
|
|
419
|
+
await fs.rm(bundle.root, { recursive: true, force: true });
|
|
420
|
+
// An export of a bundle that no longer exists points at nothing.
|
|
421
|
+
await linksSink?.discard();
|
|
422
|
+
result.discarded = true;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return result;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return {
|
|
429
|
+
path: bundle.root,
|
|
430
|
+
links: linksSink?.path ?? null,
|
|
431
|
+
mode: recorderMode,
|
|
432
|
+
checkpoint,
|
|
433
|
+
/**
|
|
434
|
+
* Record an event a caller cares about on the same timeline.
|
|
435
|
+
*
|
|
436
|
+
* @param {string} name - What happened
|
|
437
|
+
* @param {Object} [data] - Details, redacted before writing
|
|
438
|
+
* @returns {Promise<Object|null>} The written event
|
|
439
|
+
*/
|
|
440
|
+
event: (name, data = {}) =>
|
|
441
|
+
record(TRACE_EVENT.INTERACTION, {
|
|
442
|
+
action: name,
|
|
443
|
+
actor: 'caller',
|
|
444
|
+
...data,
|
|
445
|
+
}),
|
|
446
|
+
stop,
|
|
447
|
+
get stopped() {
|
|
448
|
+
return Boolean(stopped);
|
|
449
|
+
},
|
|
450
|
+
get checkpoints() {
|
|
451
|
+
return [...checkpoints];
|
|
452
|
+
},
|
|
453
|
+
};
|
|
454
|
+
}
|