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,623 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Links Notation view of a trace bundle (issue #94).
|
|
3
|
+
*
|
|
4
|
+
* The JSON bundle stays authoritative: this is an adapter over what the
|
|
5
|
+
* readers already produce, not a second recorder. Nothing here can change what
|
|
6
|
+
* a trace says, which is the point - a portable export that could disagree
|
|
7
|
+
* with the bundle would be worse than no export at all.
|
|
8
|
+
*
|
|
9
|
+
* The export is line oriented. One link per line means `grep`, `diff` and
|
|
10
|
+
* `head` work on a trace the way they work on a log, and a run that was killed
|
|
11
|
+
* still leaves every line it had finished writing.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import fs from 'node:fs/promises';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { formatLinks, Link } from 'links-notation';
|
|
17
|
+
import { diffControlState, readTrace } from './reader.js';
|
|
18
|
+
import { TRACE_EVENT } from './schema.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Version of this representation.
|
|
22
|
+
*
|
|
23
|
+
* The bundle's own `schemaVersion` says what was recorded; this says how it is
|
|
24
|
+
* written as links. They move independently: adding a field to an event does
|
|
25
|
+
* not change the shape of the export, and changing the shape of the export
|
|
26
|
+
* does not mean anything new was recorded.
|
|
27
|
+
*/
|
|
28
|
+
export const TRACE_LINKS_VERSION = 1;
|
|
29
|
+
|
|
30
|
+
/** Extension used when an output path names a directory or has none. */
|
|
31
|
+
export const TRACE_LINKS_FILE = 'trace.lino';
|
|
32
|
+
|
|
33
|
+
/** Sections a caller can include, in the order they are written. */
|
|
34
|
+
export const TRACE_LINKS_SECTIONS = Object.freeze([
|
|
35
|
+
/** The header and the closing result link. */
|
|
36
|
+
'trace',
|
|
37
|
+
/** One link per ordered timeline event. */
|
|
38
|
+
'timeline',
|
|
39
|
+
/** One link per checkpoint, referencing its members by path. */
|
|
40
|
+
'checkpoints',
|
|
41
|
+
/** One link per control whose value changed between two checkpoints. */
|
|
42
|
+
'control-diffs',
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
/** Ids of the links this module writes. */
|
|
46
|
+
export const TRACE_LINK_IDS = Object.freeze({
|
|
47
|
+
TRACE: 'trace',
|
|
48
|
+
TIMELINE: 'timeline',
|
|
49
|
+
CHECKPOINT: 'checkpoint',
|
|
50
|
+
CONTROL_DIFF: 'control-diff',
|
|
51
|
+
RESULT: 'result',
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
/** What an event's `outcome` field can say. */
|
|
55
|
+
export const TRACE_LINK_OUTCOME = Object.freeze({
|
|
56
|
+
/** Nothing went wrong and nothing is missing. */
|
|
57
|
+
RECORDED: 'recorded',
|
|
58
|
+
/** The action this event reports succeeded. */
|
|
59
|
+
OK: 'ok',
|
|
60
|
+
/** The action this event reports failed. */
|
|
61
|
+
FAILED: 'failed',
|
|
62
|
+
/** The record is here but incomplete, such as truncated markup. */
|
|
63
|
+
PARTIAL: 'partial',
|
|
64
|
+
/** Something could not be recorded at all. */
|
|
65
|
+
DROPPED: 'dropped',
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Who caused an event, when the event does not say so itself.
|
|
70
|
+
*
|
|
71
|
+
* Most records carry no actor because, in the bundle, the kind implies it. An
|
|
72
|
+
* export that leaves the field out would make a reader guess, so the implied
|
|
73
|
+
* answer is written down.
|
|
74
|
+
*/
|
|
75
|
+
const IMPLIED_ACTOR = Object.freeze({
|
|
76
|
+
[TRACE_EVENT.TRACE_START]: 'recorder',
|
|
77
|
+
[TRACE_EVENT.TRACE_STOP]: 'recorder',
|
|
78
|
+
[TRACE_EVENT.MUTATIONS]: 'recorder',
|
|
79
|
+
[TRACE_EVENT.DROPPED]: 'recorder',
|
|
80
|
+
[TRACE_EVENT.INTERACTION]: 'automation',
|
|
81
|
+
[TRACE_EVENT.CHECKPOINT]: 'automation',
|
|
82
|
+
[TRACE_EVENT.NAVIGATION]: 'browser',
|
|
83
|
+
[TRACE_EVENT.CONSOLE]: 'browser',
|
|
84
|
+
[TRACE_EVENT.PAGE_ERROR]: 'browser',
|
|
85
|
+
[TRACE_EVENT.DIALOG]: 'browser',
|
|
86
|
+
[TRACE_EVENT.REQUEST_FAILED]: 'browser',
|
|
87
|
+
[TRACE_EVENT.DOWNLOAD]: 'browser',
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
/** Fields the head of a timeline link already accounts for. */
|
|
91
|
+
const HEAD_FIELDS = new Set([
|
|
92
|
+
'sequence',
|
|
93
|
+
'at',
|
|
94
|
+
'monotonicMs',
|
|
95
|
+
'kind',
|
|
96
|
+
'traceId',
|
|
97
|
+
'browserContextId',
|
|
98
|
+
'pageId',
|
|
99
|
+
'navigationId',
|
|
100
|
+
'frameId',
|
|
101
|
+
'action',
|
|
102
|
+
'actor',
|
|
103
|
+
'target',
|
|
104
|
+
'outcome',
|
|
105
|
+
]);
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Make one value safe to write as a link.
|
|
109
|
+
*
|
|
110
|
+
* Links Notation 0.20 quotes a value with whichever quote character the value
|
|
111
|
+
* does not contain, and its parser does not read escapes, so a value holding
|
|
112
|
+
* both quote characters cannot be read back. Newlines are worse: they would
|
|
113
|
+
* split one record across lines. Both are encoded here, reversibly, rather
|
|
114
|
+
* than dropped, so the export loses nothing the bundle holds.
|
|
115
|
+
*
|
|
116
|
+
* @param {*} value - Any scalar from a trace record
|
|
117
|
+
* @returns {string} Text that survives a format and parse round trip
|
|
118
|
+
*/
|
|
119
|
+
export function encodeLinkText(value) {
|
|
120
|
+
const text = typeof value === 'string' ? value : JSON.stringify(value);
|
|
121
|
+
const escaped = text
|
|
122
|
+
.replaceAll('\\', '\\\\')
|
|
123
|
+
.replaceAll('\n', '\\n')
|
|
124
|
+
.replaceAll('\r', '\\r')
|
|
125
|
+
.replaceAll('\t', '\\t');
|
|
126
|
+
// Only a value that holds both quote characters is unquotable, so only that
|
|
127
|
+
// value pays for the ugliness of an escaped quote.
|
|
128
|
+
return escaped.includes("'") && escaped.includes('"')
|
|
129
|
+
? escaped.replaceAll('"', '\\u0022')
|
|
130
|
+
: escaped;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Read back what `encodeLinkText` wrote.
|
|
135
|
+
*
|
|
136
|
+
* @param {string} text - A value parsed out of an exported link
|
|
137
|
+
* @returns {string} The original text
|
|
138
|
+
*/
|
|
139
|
+
export function decodeLinkText(text) {
|
|
140
|
+
const decoded = { '\\': '\\', n: '\n', r: '\r', t: '\t', u0022: '"' };
|
|
141
|
+
return String(text).replace(
|
|
142
|
+
/\\(\\|n|r|t|u0022)/g,
|
|
143
|
+
(match, code) => decoded[code]
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const leaf = (value) => new Link(encodeLinkText(value));
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* One `(name: value)` pair, or nothing when there is no value to write.
|
|
151
|
+
*
|
|
152
|
+
* @param {string} name - Field name
|
|
153
|
+
* @param {*} value - Field value; `null` and `undefined` are left out
|
|
154
|
+
* @returns {Link|null} The pair
|
|
155
|
+
*/
|
|
156
|
+
function field(name, value) {
|
|
157
|
+
if (value === null || value === undefined) {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
return new Link(name, [leaf(value)]);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* What an event's outcome was, in the export's own words.
|
|
165
|
+
*
|
|
166
|
+
* @param {Object} event - A timeline event
|
|
167
|
+
* @returns {string} One of TRACE_LINK_OUTCOME
|
|
168
|
+
*/
|
|
169
|
+
function outcomeOf(event) {
|
|
170
|
+
if (event.kind === TRACE_EVENT.DROPPED) {
|
|
171
|
+
return TRACE_LINK_OUTCOME.DROPPED;
|
|
172
|
+
}
|
|
173
|
+
if (event.ok === false || event.error || event.failure) {
|
|
174
|
+
return TRACE_LINK_OUTCOME.FAILED;
|
|
175
|
+
}
|
|
176
|
+
if (event.truncated === true) {
|
|
177
|
+
return TRACE_LINK_OUTCOME.PARTIAL;
|
|
178
|
+
}
|
|
179
|
+
if (event.ok === true) {
|
|
180
|
+
return TRACE_LINK_OUTCOME.OK;
|
|
181
|
+
}
|
|
182
|
+
return TRACE_LINK_OUTCOME.RECORDED;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* What an event happened to, when it happened to something addressable.
|
|
187
|
+
*
|
|
188
|
+
* @param {Object} event - A timeline event
|
|
189
|
+
* @returns {string|null} A selector, a URL or a bundle member
|
|
190
|
+
*/
|
|
191
|
+
function targetOf(event) {
|
|
192
|
+
return event.target ?? event.url ?? event.member ?? null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Turn one timeline event into one link.
|
|
197
|
+
*
|
|
198
|
+
* @param {Object} event - An event as written to `events.ndjson`
|
|
199
|
+
* @returns {Link} The `(timeline: ...)` link
|
|
200
|
+
*/
|
|
201
|
+
export function timelineLink(event) {
|
|
202
|
+
const values = [
|
|
203
|
+
field('sequence', event.sequence),
|
|
204
|
+
field('at', event.at),
|
|
205
|
+
field('monotonicMs', event.monotonicMs),
|
|
206
|
+
field('kind', event.kind),
|
|
207
|
+
field('trace', event.traceId),
|
|
208
|
+
field('context', event.browserContextId),
|
|
209
|
+
field('page', event.pageId),
|
|
210
|
+
field('navigation', event.navigationId),
|
|
211
|
+
field('frame', event.frameId),
|
|
212
|
+
field('actor', event.actor ?? IMPLIED_ACTOR[event.kind] ?? null),
|
|
213
|
+
field('action', event.action ?? event.phase ?? null),
|
|
214
|
+
field('target', targetOf(event)),
|
|
215
|
+
field('outcome', outcomeOf(event)),
|
|
216
|
+
];
|
|
217
|
+
|
|
218
|
+
// Whatever else the event carried is kept under its own name. A kind this
|
|
219
|
+
// module has never heard of still exports everything it holds, which is what
|
|
220
|
+
// keeps the adapter from quietly becoming the reason a trace lost a field.
|
|
221
|
+
for (const [name, value] of Object.entries(event)) {
|
|
222
|
+
if (HEAD_FIELDS.has(name) || value === null || value === undefined) {
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
if (name === 'members') {
|
|
226
|
+
// The checkpoint link says where the members are; saying it twice would
|
|
227
|
+
// make one of the two the wrong place to fix.
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
values.push(field(name, value));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return new Link(TRACE_LINK_IDS.TIMELINE, values.filter(Boolean));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Turn one checkpoint into one link that points at its members.
|
|
238
|
+
*
|
|
239
|
+
* Members are named by their path inside the bundle. A screenshot is a file
|
|
240
|
+
* next to this one, so the export stays a text file of any size a text file
|
|
241
|
+
* should be, and there is exactly one copy of every byte.
|
|
242
|
+
*
|
|
243
|
+
* The interval of mutations that starts here is named by its own timeline
|
|
244
|
+
* link, which carries the checkpoint index it belongs to. It is not repeated
|
|
245
|
+
* here, because a checkpoint cannot know its own interval until the next
|
|
246
|
+
* checkpoint drains it, and an export written as the run happens has to say
|
|
247
|
+
* the same thing as an export of the finished bundle.
|
|
248
|
+
*
|
|
249
|
+
* @param {Object} event - The checkpoint event from the timeline
|
|
250
|
+
* @returns {Link} The `(checkpoint: ...)` link
|
|
251
|
+
*/
|
|
252
|
+
export function checkpointLink(event) {
|
|
253
|
+
const members = event.members ?? {};
|
|
254
|
+
return new Link(
|
|
255
|
+
TRACE_LINK_IDS.CHECKPOINT,
|
|
256
|
+
[
|
|
257
|
+
field('index', event.index),
|
|
258
|
+
field('sequence', event.sequence),
|
|
259
|
+
field('at', event.at),
|
|
260
|
+
field('name', event.name),
|
|
261
|
+
field('actor', event.actor),
|
|
262
|
+
field('reason', event.reason),
|
|
263
|
+
field('page', event.pageId),
|
|
264
|
+
field('navigation', event.navigationId),
|
|
265
|
+
field('url', event.url),
|
|
266
|
+
field('outcome', outcomeOf(event)),
|
|
267
|
+
field('html', members.html),
|
|
268
|
+
field('state', members.state),
|
|
269
|
+
field('screenshot', members.screenshot),
|
|
270
|
+
].filter(Boolean)
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Turn one control change into one link.
|
|
276
|
+
*
|
|
277
|
+
* @param {Object} change - A record from `diffControlState`
|
|
278
|
+
* @param {Object} context - `{checkpoint, previous, actor}`
|
|
279
|
+
* @returns {Link} The `(control-diff: ...)` link
|
|
280
|
+
*/
|
|
281
|
+
export function controlDiffLink(change, context) {
|
|
282
|
+
return new Link(
|
|
283
|
+
TRACE_LINK_IDS.CONTROL_DIFF,
|
|
284
|
+
[
|
|
285
|
+
field('checkpoint', context.checkpoint),
|
|
286
|
+
field('previous', context.previous),
|
|
287
|
+
field('path', change.path),
|
|
288
|
+
field('change', change.change),
|
|
289
|
+
field('before', change.before),
|
|
290
|
+
field('after', change.after),
|
|
291
|
+
field('actor', context.actor),
|
|
292
|
+
].filter(Boolean)
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* The link that opens an export.
|
|
298
|
+
*
|
|
299
|
+
* @param {Object} about - `{bundle, schemaVersion, mode, engine, startedAt, commanderVersion}`
|
|
300
|
+
* @returns {Link} The `(trace: ...)` link
|
|
301
|
+
*/
|
|
302
|
+
export function traceHeaderLink(about) {
|
|
303
|
+
return new Link(
|
|
304
|
+
TRACE_LINK_IDS.TRACE,
|
|
305
|
+
[
|
|
306
|
+
field('format', 'browser-commander-trace'),
|
|
307
|
+
field('links', TRACE_LINKS_VERSION),
|
|
308
|
+
field('schema', about.schemaVersion),
|
|
309
|
+
field('bundle', about.bundle),
|
|
310
|
+
field('mode', about.mode),
|
|
311
|
+
field('engine', about.engine),
|
|
312
|
+
field('started', about.startedAt),
|
|
313
|
+
field('commander', about.commanderVersion),
|
|
314
|
+
].filter(Boolean)
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* The link that closes an export.
|
|
320
|
+
*
|
|
321
|
+
* A reader that does not find this link is reading a run that never stopped,
|
|
322
|
+
* which is exactly what it should conclude.
|
|
323
|
+
*
|
|
324
|
+
* @param {Object} manifest - The bundle's manifest
|
|
325
|
+
* @param {Object} [options] - `{truncated}` as the reader judged it
|
|
326
|
+
* @returns {Link} The `(result: ...)` link
|
|
327
|
+
*/
|
|
328
|
+
export function traceResultLink(manifest, { truncated = false } = {}) {
|
|
329
|
+
const counts = manifest?.counts ?? {};
|
|
330
|
+
const replay = Object.entries(manifest?.replay ?? {})
|
|
331
|
+
.filter(([, supported]) => supported)
|
|
332
|
+
.map(([name]) => leaf(name));
|
|
333
|
+
|
|
334
|
+
return new Link(
|
|
335
|
+
TRACE_LINK_IDS.RESULT,
|
|
336
|
+
[
|
|
337
|
+
field('outcome', manifest?.outcome),
|
|
338
|
+
field('stopped', manifest?.stoppedAt),
|
|
339
|
+
field('events', counts.events),
|
|
340
|
+
field('checkpoints', counts.checkpoints),
|
|
341
|
+
field('mutationBatches', counts.mutationBatches),
|
|
342
|
+
field('dropped', manifest?.dropped),
|
|
343
|
+
field('truncated', truncated),
|
|
344
|
+
replay.length ? new Link('replay', replay) : null,
|
|
345
|
+
].filter(Boolean)
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Format links as the lines of an export.
|
|
351
|
+
*
|
|
352
|
+
* @param {Link[]} links - Links to write, in order
|
|
353
|
+
* @returns {string} One link per line, newline terminated
|
|
354
|
+
*/
|
|
355
|
+
export function formatTraceLinks(links) {
|
|
356
|
+
// Formatting one link at a time is what guarantees the line-per-record
|
|
357
|
+
// layout, and is the same call the streaming sink makes for each record.
|
|
358
|
+
return links.map((link) => `${formatLinks([link])}\n`).join('');
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function chosenSections(include) {
|
|
362
|
+
if (include === undefined) {
|
|
363
|
+
return new Set(TRACE_LINKS_SECTIONS);
|
|
364
|
+
}
|
|
365
|
+
if (!Array.isArray(include)) {
|
|
366
|
+
throw new Error('trace links include must be an array of section names');
|
|
367
|
+
}
|
|
368
|
+
for (const name of include) {
|
|
369
|
+
if (!TRACE_LINKS_SECTIONS.includes(name)) {
|
|
370
|
+
throw new Error(
|
|
371
|
+
`unknown trace links section "${name}"; expected one of ${TRACE_LINKS_SECTIONS.join(', ')}`
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return new Set(include);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* The links one event contributes to an export.
|
|
380
|
+
*
|
|
381
|
+
* Streaming and exporting a finished bundle both go through here, because two
|
|
382
|
+
* routes to one representation that could drift apart would make neither
|
|
383
|
+
* trustworthy.
|
|
384
|
+
*
|
|
385
|
+
* @param {Object} event - An event as written to the bundle
|
|
386
|
+
* @param {Set<string>} sections - Sections the caller asked for
|
|
387
|
+
* @returns {Link[]} The links for this event, in the order they are written
|
|
388
|
+
*/
|
|
389
|
+
function linksForEvent(event, sections) {
|
|
390
|
+
const links = [];
|
|
391
|
+
if (sections.has('timeline')) {
|
|
392
|
+
links.push(timelineLink(event));
|
|
393
|
+
}
|
|
394
|
+
if (event.kind === TRACE_EVENT.CHECKPOINT && sections.has('checkpoints')) {
|
|
395
|
+
links.push(checkpointLink(event));
|
|
396
|
+
}
|
|
397
|
+
return links;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Build the links of a whole trace, in the order they are written.
|
|
402
|
+
*
|
|
403
|
+
* @param {Object|string} trace - An open trace, or a path to a bundle
|
|
404
|
+
* @param {Object} [options] - `{include}`
|
|
405
|
+
* @returns {Promise<Link[]>} Every link of the export
|
|
406
|
+
*/
|
|
407
|
+
export async function traceLinks(trace, options = {}) {
|
|
408
|
+
const opened = typeof trace === 'string' ? await readTrace(trace) : trace;
|
|
409
|
+
const sections = chosenSections(options.include);
|
|
410
|
+
const links = [];
|
|
411
|
+
|
|
412
|
+
if (sections.has('trace')) {
|
|
413
|
+
links.push(
|
|
414
|
+
traceHeaderLink({
|
|
415
|
+
bundle: path.basename(opened.path),
|
|
416
|
+
schemaVersion: opened.manifest?.schemaVersion,
|
|
417
|
+
mode: opened.manifest?.mode,
|
|
418
|
+
engine: opened.manifest?.engine,
|
|
419
|
+
startedAt: opened.manifest?.startedAt,
|
|
420
|
+
commanderVersion: opened.manifest?.commanderVersion,
|
|
421
|
+
})
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
for (const event of opened.events) {
|
|
426
|
+
links.push(...linksForEvent(event, sections));
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (sections.has('control-diffs')) {
|
|
430
|
+
links.push(...(await controlDiffLinks(opened)));
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (sections.has('trace')) {
|
|
434
|
+
links.push(
|
|
435
|
+
traceResultLink(opened.manifest, { truncated: opened.truncated })
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
return links;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Every control change between consecutive checkpoints of a trace.
|
|
444
|
+
*
|
|
445
|
+
* @param {Object} opened - An open trace
|
|
446
|
+
* @returns {Promise<Link[]>} The `(control-diff: ...)` links
|
|
447
|
+
*/
|
|
448
|
+
async function controlDiffLinks(opened) {
|
|
449
|
+
const links = [];
|
|
450
|
+
let previous = null;
|
|
451
|
+
|
|
452
|
+
for (const checkpoint of opened.checkpoints) {
|
|
453
|
+
const state = await opened.state(checkpoint.index);
|
|
454
|
+
if (previous && state) {
|
|
455
|
+
for (const change of diffControlState(previous.state, state)) {
|
|
456
|
+
links.push(
|
|
457
|
+
controlDiffLink(change, {
|
|
458
|
+
checkpoint: checkpoint.index,
|
|
459
|
+
previous: previous.index,
|
|
460
|
+
actor: checkpoint.actor,
|
|
461
|
+
})
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
if (state) {
|
|
466
|
+
previous = { index: checkpoint.index, state };
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
return links;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Resolve where an export should be written.
|
|
475
|
+
*
|
|
476
|
+
* @param {string} output - A file path, or a directory to write inside
|
|
477
|
+
* @returns {Promise<string>} The file to write
|
|
478
|
+
*/
|
|
479
|
+
async function resolveOutput(output) {
|
|
480
|
+
if (typeof output !== 'string' || output === '') {
|
|
481
|
+
throw new Error('trace links output must be a path');
|
|
482
|
+
}
|
|
483
|
+
const resolved = path.resolve(output);
|
|
484
|
+
try {
|
|
485
|
+
const stats = await fs.stat(resolved);
|
|
486
|
+
if (stats.isDirectory()) {
|
|
487
|
+
return path.join(resolved, TRACE_LINKS_FILE);
|
|
488
|
+
}
|
|
489
|
+
} catch {
|
|
490
|
+
// Nothing there yet, which is the ordinary case.
|
|
491
|
+
}
|
|
492
|
+
return resolved;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Write a trace as Links Notation.
|
|
497
|
+
*
|
|
498
|
+
* @param {Object|string} trace - An open trace, or a path to a bundle
|
|
499
|
+
* @param {string} output - Where to write
|
|
500
|
+
* @param {Object} [options] - `{include}`
|
|
501
|
+
* @returns {Promise<string>} The file that was written
|
|
502
|
+
*/
|
|
503
|
+
export async function writeTraceLinks(trace, output, options = {}) {
|
|
504
|
+
const file = await resolveOutput(output);
|
|
505
|
+
const links = await traceLinks(trace, options);
|
|
506
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
507
|
+
await fs.writeFile(file, formatTraceLinks(links), { mode: 0o600 });
|
|
508
|
+
return file;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Open a sink that writes links as a run happens.
|
|
513
|
+
*
|
|
514
|
+
* The sink exists so a trace that is killed still leaves a readable export:
|
|
515
|
+
* every record that reached the bundle reached this file too. Control diffs
|
|
516
|
+
* are the one thing it cannot stream, because a diff is the distance between
|
|
517
|
+
* two checkpoints and the second one has not happened yet; they are appended
|
|
518
|
+
* when the trace stops, from the bundle itself.
|
|
519
|
+
*
|
|
520
|
+
* @param {Object} options - `{output, include, bundle, schemaVersion, mode, engine, startedAt, commanderVersion}`
|
|
521
|
+
* @returns {Promise<Object>} The sink
|
|
522
|
+
*/
|
|
523
|
+
export async function openTraceLinks(options = {}) {
|
|
524
|
+
const { output, include, bundlePath, ...about } = options;
|
|
525
|
+
const file = await resolveOutput(output);
|
|
526
|
+
const sections = chosenSections(include);
|
|
527
|
+
const problems = [];
|
|
528
|
+
|
|
529
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
530
|
+
const handle = await fs.open(file, 'w', 0o600);
|
|
531
|
+
|
|
532
|
+
// Writes are serialized against each other for the same reason the bundle's
|
|
533
|
+
// are: a timeline whose lines arrive out of order is not a timeline.
|
|
534
|
+
let queue = Promise.resolve();
|
|
535
|
+
let closed = false;
|
|
536
|
+
|
|
537
|
+
const append = (links) => {
|
|
538
|
+
if (closed || links.length === 0) {
|
|
539
|
+
return queue;
|
|
540
|
+
}
|
|
541
|
+
const pending = queue.then(() =>
|
|
542
|
+
handle.write(formatTraceLinks(links)).catch((error) => {
|
|
543
|
+
// A failed export must not take the run down with it; the bundle is
|
|
544
|
+
// the authoritative record and it is still being written.
|
|
545
|
+
problems.push({ member: file, detail: error.message });
|
|
546
|
+
})
|
|
547
|
+
);
|
|
548
|
+
queue = pending.then(
|
|
549
|
+
() => {},
|
|
550
|
+
() => {}
|
|
551
|
+
);
|
|
552
|
+
return pending;
|
|
553
|
+
};
|
|
554
|
+
|
|
555
|
+
if (sections.has('trace')) {
|
|
556
|
+
await append([
|
|
557
|
+
traceHeaderLink({ ...about, bundle: path.basename(bundlePath ?? '') }),
|
|
558
|
+
]);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
return {
|
|
562
|
+
path: file,
|
|
563
|
+
problems,
|
|
564
|
+
/**
|
|
565
|
+
* Write the links of one event as it is recorded.
|
|
566
|
+
*
|
|
567
|
+
* @param {Object} event - The event as written to the bundle
|
|
568
|
+
* @returns {Promise<void>}
|
|
569
|
+
*/
|
|
570
|
+
async event(event) {
|
|
571
|
+
if (!event) {
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
await append(linksForEvent(event, sections));
|
|
575
|
+
},
|
|
576
|
+
/**
|
|
577
|
+
* Finish the export.
|
|
578
|
+
*
|
|
579
|
+
* @param {Object} [closing] - `{manifest, bundlePath}`
|
|
580
|
+
* @returns {Promise<string>} The file that was written
|
|
581
|
+
*/
|
|
582
|
+
async close(closing = {}) {
|
|
583
|
+
if (closed) {
|
|
584
|
+
return file;
|
|
585
|
+
}
|
|
586
|
+
const links = [];
|
|
587
|
+
if (sections.has('control-diffs') && closing.bundlePath) {
|
|
588
|
+
try {
|
|
589
|
+
links.push(
|
|
590
|
+
...(await controlDiffLinks(await readTrace(closing.bundlePath)))
|
|
591
|
+
);
|
|
592
|
+
} catch (error) {
|
|
593
|
+
problems.push({ member: file, detail: error.message });
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
if (sections.has('trace')) {
|
|
597
|
+
links.push(
|
|
598
|
+
traceResultLink(closing.manifest, {
|
|
599
|
+
truncated: closing.manifest?.outcome !== 'complete',
|
|
600
|
+
})
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
await append(links);
|
|
604
|
+
await queue;
|
|
605
|
+
closed = true;
|
|
606
|
+
await handle.close();
|
|
607
|
+
return file;
|
|
608
|
+
},
|
|
609
|
+
/**
|
|
610
|
+
* Remove the export, for a run whose bundle was discarded.
|
|
611
|
+
*
|
|
612
|
+
* @returns {Promise<void>}
|
|
613
|
+
*/
|
|
614
|
+
async discard() {
|
|
615
|
+
if (!closed) {
|
|
616
|
+
await queue;
|
|
617
|
+
closed = true;
|
|
618
|
+
await handle.close();
|
|
619
|
+
}
|
|
620
|
+
await fs.rm(file, { force: true });
|
|
621
|
+
},
|
|
622
|
+
};
|
|
623
|
+
}
|