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.
Files changed (74) hide show
  1. package/.jscpd-baseline.json +12 -17
  2. package/CHANGELOG.md +68 -0
  3. package/README.md +132 -0
  4. package/eslint.config.js +5 -0
  5. package/package.json +5 -1
  6. package/src/bindings.js +13 -1
  7. package/src/browser/connector.js +15 -3
  8. package/src/browser/launcher.js +14 -2
  9. package/src/browser/navigation.js +63 -0
  10. package/src/core/dialog-manager.js +48 -0
  11. package/src/core/navigation-manager.js +83 -87
  12. package/src/core/navigation-readiness.js +133 -0
  13. package/src/core/readiness.js +478 -0
  14. package/src/downloads/attach.js +58 -0
  15. package/src/downloads/destination.js +125 -0
  16. package/src/downloads/manager.js +476 -0
  17. package/src/downloads/naming.js +246 -0
  18. package/src/downloads/sources.js +238 -0
  19. package/src/downloads/staging.js +146 -0
  20. package/src/downloads/store.js +256 -0
  21. package/src/exports.js +86 -0
  22. package/src/factory.js +55 -0
  23. package/src/interactions/click-activation.js +317 -0
  24. package/src/interactions/click-result.js +108 -0
  25. package/src/interactions/click.js +766 -352
  26. package/src/tests/index.js +75 -11
  27. package/src/tests/tracing.js +236 -0
  28. package/src/traces/bundle.js +338 -0
  29. package/src/traces/deadline.js +33 -0
  30. package/src/traces/identity.js +127 -0
  31. package/src/traces/init-script.js +95 -0
  32. package/src/traces/links.js +623 -0
  33. package/src/traces/mutation-stream.js +232 -0
  34. package/src/traces/observers.js +272 -0
  35. package/src/traces/page-capture.js +601 -0
  36. package/src/traces/reader.js +226 -0
  37. package/src/traces/recorder.js +454 -0
  38. package/src/traces/redaction.js +231 -0
  39. package/src/traces/schema.js +229 -0
  40. package/src/traces/viewer.js +448 -0
  41. package/tests/e2e/click-readiness.e2e.test.js +240 -0
  42. package/tests/e2e/downloads.e2e.test.js +235 -0
  43. package/tests/e2e/traces.e2e.test.js +742 -0
  44. package/tests/helpers/click-fixtures.js +62 -0
  45. package/tests/helpers/download-fixtures.js +125 -0
  46. package/tests/helpers/download-server.js +129 -0
  47. package/tests/helpers/e2e-browser.js +67 -0
  48. package/tests/helpers/file-modes.js +31 -0
  49. package/tests/helpers/fixture-server.js +56 -0
  50. package/tests/helpers/mocks.js +63 -66
  51. package/tests/helpers/readiness-server.js +96 -0
  52. package/tests/helpers/temp-directory.js +44 -0
  53. package/tests/helpers/trace-fixtures.js +274 -0
  54. package/tests/helpers/trace-server.js +173 -0
  55. package/tests/unit/core/dialog-manager.test.js +118 -10
  56. package/tests/unit/core/navigation-manager.test.js +162 -0
  57. package/tests/unit/core/readiness.test.js +517 -0
  58. package/tests/unit/downloads/attach.test.js +220 -0
  59. package/tests/unit/downloads/destination.test.js +114 -0
  60. package/tests/unit/downloads/manager.test.js +485 -0
  61. package/tests/unit/downloads/naming.test.js +174 -0
  62. package/tests/unit/downloads/staging.test.js +117 -0
  63. package/tests/unit/downloads/store.test.js +215 -0
  64. package/tests/unit/interactions/click-activation.test.js +283 -0
  65. package/tests/unit/interactions/click-result.test.js +90 -0
  66. package/tests/unit/interactions/click.test.js +409 -200
  67. package/tests/unit/tests/tracing.test.js +299 -0
  68. package/tests/unit/traces/bundle.test.js +260 -0
  69. package/tests/unit/traces/links.test.js +557 -0
  70. package/tests/unit/traces/reader.test.js +208 -0
  71. package/tests/unit/traces/recorder.test.js +730 -0
  72. package/tests/unit/traces/redaction.test.js +170 -0
  73. package/tests/unit/traces/schema.test.js +92 -0
  74. 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
+ }