browser-commander 0.18.0 → 0.20.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 (35) hide show
  1. package/.jscpd-baseline.json +8 -0
  2. package/CHANGELOG.md +45 -0
  3. package/README.md +50 -2
  4. package/package.json +8 -3
  5. package/scripts/run-tests.mjs +4 -2
  6. package/src/downloads/manager.js +40 -8
  7. package/src/downloads/sources.js +127 -6
  8. package/src/downloads/staging.js +158 -0
  9. package/src/exports.js +14 -0
  10. package/src/traces/bundle.js +26 -3
  11. package/src/traces/deadline.js +33 -0
  12. package/src/traces/identity.js +127 -0
  13. package/src/traces/init-script.js +95 -0
  14. package/src/traces/links.js +623 -0
  15. package/src/traces/mutation-stream.js +232 -0
  16. package/src/traces/observers.js +29 -3
  17. package/src/traces/page-capture.js +251 -15
  18. package/src/traces/recorder.js +124 -94
  19. package/src/traces/schema.js +65 -1
  20. package/src/traces/viewer.js +157 -11
  21. package/tests/e2e/downloads.e2e.test.js +126 -2
  22. package/tests/e2e/traces.e2e.test.js +296 -11
  23. package/tests/helpers/download-server.js +26 -6
  24. package/tests/helpers/trace-fixtures.js +97 -3
  25. package/tests/helpers/trace-server.js +57 -1
  26. package/tests/unit/downloads/manager.test.js +213 -9
  27. package/tests/unit/downloads/staging.test.js +139 -0
  28. package/tests/unit/scripts/ci-workflow-policy.test.js +8 -0
  29. package/tests/unit/scripts/npm-release-helpers.test.js +3 -2
  30. package/tests/unit/scripts/pre-commit-config.test.js +2 -1
  31. package/tests/unit/traces/bundle.test.js +24 -3
  32. package/tests/unit/traces/links.test.js +564 -0
  33. package/tests/unit/traces/reader.test.js +10 -4
  34. package/tests/unit/traces/recorder.test.js +172 -1
  35. package/tests/unit/traces/viewer.test.js +9 -0
@@ -16,10 +16,14 @@ import {
16
16
  TRACE_MODE,
17
17
  TRACE_OUTCOME,
18
18
  } from '../../../src/traces/schema.js';
19
- import { useTempTraceDirectory } from '../../helpers/trace-fixtures.js';
19
+ import {
20
+ useTempTraceDirectory,
21
+ useTraceBundleCleanup,
22
+ } from '../../helpers/trace-fixtures.js';
20
23
 
21
24
  describe('trace bundle writer (issue #87)', () => {
22
25
  const directory = useTempTraceDirectory();
26
+ const cleanup = useTraceBundleCleanup();
23
27
 
24
28
  /**
25
29
  * Open a bundle inside the suite's temporary directory.
@@ -27,8 +31,13 @@ describe('trace bundle writer (issue #87)', () => {
27
31
  * @param {Object} [options] - Options for `openTraceBundle`
28
32
  * @returns {Promise<Object>} The bundle writer
29
33
  */
30
- const open = (options = {}) =>
31
- openTraceBundle({ output: path.join(directory.path, 'run'), ...options });
34
+ const open = async (options = {}) =>
35
+ cleanup(
36
+ await openTraceBundle({
37
+ output: path.join(directory.path, 'run'),
38
+ ...options,
39
+ })
40
+ );
32
41
 
33
42
  /**
34
43
  * List a bundle's members, relative to its root.
@@ -66,6 +75,18 @@ describe('trace bundle writer (issue #87)', () => {
66
75
  await assert.rejects(openTraceBundle({}), /trace output must be a path/);
67
76
  });
68
77
 
78
+ it('should abort an unfinished bundle without writing a manifest', async () => {
79
+ const bundle = await open();
80
+ await bundle.appendEvent({ kind: TRACE_EVENT.CHECKPOINT });
81
+
82
+ await bundle.abort();
83
+ await bundle.abort();
84
+
85
+ await assert.rejects(
86
+ fs.access(path.join(bundle.root, TRACE_FILES.MANIFEST))
87
+ );
88
+ });
89
+
69
90
  it('should write the layout the format documents', async () => {
70
91
  const bundle = await open();
71
92
 
@@ -0,0 +1,564 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import fs from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { Parser } from 'links-notation';
6
+
7
+ import { openTraceBundle } from '../../../src/traces/bundle.js';
8
+ import { readTrace } from '../../../src/traces/reader.js';
9
+ import { startTrace } from '../../../src/traces/recorder.js';
10
+ import {
11
+ decodeLinkText,
12
+ encodeLinkText,
13
+ formatTraceLinks,
14
+ timelineLink,
15
+ traceLinks,
16
+ TRACE_LINKS_SECTIONS,
17
+ TRACE_LINKS_VERSION,
18
+ writeTraceLinks,
19
+ } from '../../../src/traces/links.js';
20
+ import {
21
+ createManifest,
22
+ TRACE_EVENT,
23
+ TRACE_MODE,
24
+ } from '../../../src/traces/schema.js';
25
+ import {
26
+ createFakeCommander,
27
+ createFakePage,
28
+ makeSnapshot,
29
+ useTempTraceDirectory,
30
+ useTraceBundleCleanup,
31
+ } from '../../helpers/trace-fixtures.js';
32
+
33
+ /**
34
+ * The fields of one exported link, by name.
35
+ *
36
+ * @param {Object} link - A link parsed out of an export
37
+ * @returns {Object} `{name: value}` for every pair the link holds
38
+ */
39
+ function fieldsOf(link) {
40
+ const fields = {};
41
+ for (const value of link.values ?? []) {
42
+ fields[value.id] = decodeLinkText(
43
+ (value.values ?? []).map((inner) => inner.id).join(' ')
44
+ );
45
+ }
46
+ return fields;
47
+ }
48
+
49
+ /**
50
+ * Parse an export back into links, one per line.
51
+ *
52
+ * @param {string} text - The contents of an export
53
+ * @returns {Object[]} `{id, fields}` per link, in file order
54
+ */
55
+ function parseExport(text) {
56
+ const lines = text.split('\n').filter((line) => line !== '');
57
+ return lines.map((line) => {
58
+ const parsed = new Parser().parse(line);
59
+ assert.strictEqual(
60
+ parsed.length,
61
+ 1,
62
+ `a line held ${parsed.length} links: ${line}`
63
+ );
64
+ return { id: parsed[0].id, fields: fieldsOf(parsed[0]) };
65
+ });
66
+ }
67
+
68
+ describe('trace links export (issue #94)', () => {
69
+ const directory = useTempTraceDirectory();
70
+ const trackOpenBundle = useTraceBundleCleanup();
71
+
72
+ /**
73
+ * Write a bundle holding one of everything the export has to say.
74
+ *
75
+ * @param {Object} [options] - `{close}`
76
+ * @returns {Promise<string>} Path to the bundle
77
+ */
78
+ const writeBundle = async ({ close = true } = {}) => {
79
+ const root = path.join(directory.path, 'run.bc-trace');
80
+ const bundle = await openTraceBundle({ output: root });
81
+ trackOpenBundle(bundle);
82
+ const owner = {
83
+ traceId: 'trace-1',
84
+ browserContextId: 'context-1',
85
+ pageId: 'page-1',
86
+ navigationId: 'nav-1',
87
+ };
88
+
89
+ await bundle.appendEvent({
90
+ kind: TRACE_EVENT.TRACE_START,
91
+ ...owner,
92
+ mode: TRACE_MODE.CONTINUOUS,
93
+ engine: 'playwright',
94
+ });
95
+
96
+ const first = await bundle.writeCheckpoint({
97
+ index: 1,
98
+ html: '<html><body>one</body></html>',
99
+ state: {
100
+ url: 'https://example.com/one',
101
+ controls: [
102
+ { path: '#name', value: '' },
103
+ { path: '#terms', value: 'on', checked: false },
104
+ ],
105
+ },
106
+ screenshot: Buffer.from('fake-png-bytes'),
107
+ });
108
+ await bundle.appendEvent({
109
+ kind: TRACE_EVENT.CHECKPOINT,
110
+ ...owner,
111
+ index: 1,
112
+ name: 'loaded',
113
+ actor: 'recorder',
114
+ reason: 'initial',
115
+ url: 'https://example.com/one',
116
+ members: first,
117
+ });
118
+
119
+ await bundle.appendEvent({
120
+ kind: TRACE_EVENT.INTERACTION,
121
+ ...owner,
122
+ actionId: 'trace-1-action-1',
123
+ action: 'click',
124
+ target: "#name[data-label='it\\'s here']",
125
+ durationMs: 12,
126
+ ok: true,
127
+ });
128
+ await bundle.appendEvent({
129
+ kind: TRACE_EVENT.CONSOLE,
130
+ ...owner,
131
+ level: 'log',
132
+ text: 'first line\nsecond line',
133
+ });
134
+ await bundle.appendEvent({
135
+ kind: TRACE_EVENT.INTERACTION,
136
+ ...owner,
137
+ action: 'click',
138
+ target: '#missing',
139
+ ok: false,
140
+ error: 'no element matched',
141
+ });
142
+ const member = await bundle.writeMutations(1, [
143
+ { records: [{ kind: 'childList' }] },
144
+ ]);
145
+ await bundle.appendEvent({
146
+ kind: TRACE_EVENT.MUTATIONS,
147
+ ...owner,
148
+ member,
149
+ batches: 1,
150
+ checkpoint: 1,
151
+ frames: 1,
152
+ });
153
+ await bundle.drop({
154
+ reason: 'size-limit',
155
+ member: 'checkpoints/2',
156
+ detail: '99 bytes',
157
+ });
158
+
159
+ const second = await bundle.writeCheckpoint({
160
+ index: 2,
161
+ html: '<html><body>two</body></html>',
162
+ state: {
163
+ url: 'https://example.com/two',
164
+ controls: [
165
+ { path: '#name', value: 'ada' },
166
+ { path: '#terms', value: 'on', checked: true },
167
+ ],
168
+ },
169
+ });
170
+ await bundle.appendEvent({
171
+ kind: TRACE_EVENT.CHECKPOINT,
172
+ ...owner,
173
+ index: 2,
174
+ name: 'filled in',
175
+ actor: 'automation',
176
+ reason: 'checkpoint',
177
+ url: 'https://example.com/two',
178
+ members: second,
179
+ });
180
+
181
+ if (close) {
182
+ await bundle.close(
183
+ createManifest({
184
+ mode: TRACE_MODE.CONTINUOUS,
185
+ engine: 'playwright',
186
+ startedAt: '2026-01-01T00:00:00.000Z',
187
+ stoppedAt: '2026-01-01T00:00:10.000Z',
188
+ })
189
+ );
190
+ }
191
+ return root;
192
+ };
193
+
194
+ /**
195
+ * Export a bundle and read the result back.
196
+ *
197
+ * @param {string} root - Path to the bundle
198
+ * @param {Object} [options] - `{include}`
199
+ * @returns {Promise<Object>} `{file, text, links}`
200
+ */
201
+ const exported = async (root, options = {}) => {
202
+ const file = await writeTraceLinks(
203
+ await readTrace(root),
204
+ path.join(directory.path, 'run.lino'),
205
+ options
206
+ );
207
+ const text = await fs.readFile(file, 'utf8');
208
+ return { file, text, links: parseExport(text) };
209
+ };
210
+
211
+ it('should write one link per timeline event, in the order they happened', async () => {
212
+ const root = await writeBundle();
213
+ const { links } = await exported(root);
214
+ const { events } = await readTrace(root);
215
+
216
+ const timeline = links.filter((link) => link.id === 'timeline');
217
+ assert.strictEqual(timeline.length, events.length);
218
+ assert.deepStrictEqual(
219
+ timeline.map((link) => Number(link.fields.sequence)),
220
+ events.map((event) => event.sequence)
221
+ );
222
+
223
+ const click = timeline.find(
224
+ (link) => link.fields.action === 'click' && link.fields.outcome === 'ok'
225
+ );
226
+ // Everything issue #94 asks a timeline link to answer: when, what kind,
227
+ // whose, who did it, what it did, to what, and how it went.
228
+ assert.ok(Number(click.fields.sequence) > 0);
229
+ assert.match(click.fields.at, /^\d{4}-\d{2}-\d{2}T/);
230
+ assert.strictEqual(click.fields.kind, TRACE_EVENT.INTERACTION);
231
+ assert.strictEqual(click.fields.trace, 'trace-1');
232
+ assert.strictEqual(click.fields.context, 'context-1');
233
+ assert.strictEqual(click.fields.page, 'page-1');
234
+ assert.strictEqual(click.fields.navigation, 'nav-1');
235
+ assert.strictEqual(click.fields.actor, 'automation');
236
+ assert.strictEqual(click.fields.target, "#name[data-label='it\\'s here']");
237
+ assert.strictEqual(click.fields.actionId, 'trace-1-action-1');
238
+ });
239
+
240
+ it('should say which records failed, were dropped or are partial', async () => {
241
+ const root = await writeBundle();
242
+ const { links } = await exported(root);
243
+ const outcomes = links
244
+ .filter((link) => link.id === 'timeline')
245
+ .map((link) => link.fields.outcome);
246
+
247
+ assert.ok(outcomes.includes('ok'), 'no successful record is marked ok');
248
+ assert.ok(
249
+ outcomes.includes('failed'),
250
+ 'an interaction that threw is not marked failed'
251
+ );
252
+ const dropped = links.find(
253
+ (link) => link.id === 'timeline' && link.fields.outcome === 'dropped'
254
+ );
255
+ // A gap has to be visible in the export, not only in the bundle.
256
+ assert.strictEqual(dropped.fields.kind, TRACE_EVENT.DROPPED);
257
+ assert.strictEqual(dropped.fields.reason, 'size-limit');
258
+ assert.strictEqual(dropped.fields.target, 'checkpoints/2');
259
+
260
+ const result = links.find((link) => link.id === 'result');
261
+ assert.strictEqual(result.fields.outcome, 'partial');
262
+ assert.strictEqual(result.fields.dropped, '1');
263
+ });
264
+
265
+ it('should point at checkpoint members instead of copying them', async () => {
266
+ const root = await writeBundle();
267
+ const { text, links } = await exported(root);
268
+ const checkpoints = links.filter((link) => link.id === 'checkpoint');
269
+
270
+ assert.strictEqual(checkpoints.length, 2);
271
+ assert.strictEqual(checkpoints[0].fields.name, 'loaded');
272
+ assert.strictEqual(checkpoints[0].fields.html, 'checkpoints/0001.html');
273
+ assert.strictEqual(
274
+ checkpoints[0].fields.state,
275
+ 'checkpoints/0001.state.json'
276
+ );
277
+ assert.strictEqual(
278
+ checkpoints[0].fields.screenshot,
279
+ 'checkpoints/0001.png'
280
+ );
281
+ // The interval of mutations that starts at a checkpoint is named by its
282
+ // own timeline link, which says which checkpoint it belongs to.
283
+ const interval = links.find(
284
+ (link) => link.fields.kind === TRACE_EVENT.MUTATIONS
285
+ );
286
+ assert.strictEqual(interval.fields.member, 'mutations/0001.ndjson');
287
+ assert.strictEqual(interval.fields.checkpoint, '1');
288
+ // A checkpoint that has no screenshot says so by not naming one.
289
+ assert.strictEqual(checkpoints[1].fields.screenshot, undefined);
290
+
291
+ // Every member reference is a path inside the bundle, and none of the
292
+ // bytes behind those paths are in the export.
293
+ for (const member of Object.values(checkpoints[0].fields)) {
294
+ assert.ok(!path.isAbsolute(member), `${member} is not bundle-relative`);
295
+ }
296
+ assert.ok(
297
+ !text.includes('fake-png-bytes'),
298
+ 'the export copied a screenshot instead of pointing at it'
299
+ );
300
+ assert.ok(
301
+ !text.includes('<html>'),
302
+ 'the export copied captured markup instead of pointing at it'
303
+ );
304
+ });
305
+
306
+ it('should write one link per control the run changed', async () => {
307
+ const root = await writeBundle();
308
+ const { links } = await exported(root);
309
+ const diffs = links.filter((link) => link.id === 'control-diff');
310
+
311
+ assert.deepStrictEqual(
312
+ diffs.map((link) => link.fields.path),
313
+ ['#name', '#terms']
314
+ );
315
+ const [name, terms] = diffs;
316
+ assert.strictEqual(name.fields.before, '');
317
+ assert.strictEqual(name.fields.after, 'ada');
318
+ // Who made the change is part of the answer, and it is the actor of the
319
+ // checkpoint the change is measured to.
320
+ assert.strictEqual(name.fields.actor, 'automation');
321
+ assert.strictEqual(name.fields.checkpoint, '2');
322
+ assert.strictEqual(name.fields.previous, '1');
323
+ assert.strictEqual(terms.fields.before, 'false');
324
+ assert.strictEqual(terms.fields.after, 'true');
325
+ });
326
+
327
+ it('should write only the sections a caller asked for', async () => {
328
+ const root = await writeBundle();
329
+ const { links } = await exported(root, {
330
+ include: ['timeline', 'checkpoints', 'control-diffs'],
331
+ });
332
+ const ids = new Set(links.map((link) => link.id));
333
+
334
+ assert.deepStrictEqual(
335
+ [...ids].sort(),
336
+ ['checkpoint', 'control-diff', 'timeline'].sort()
337
+ );
338
+
339
+ const only = await exported(root, { include: ['checkpoints'] });
340
+ assert.deepStrictEqual(
341
+ [...new Set(only.links.map((link) => link.id))],
342
+ ['checkpoint']
343
+ );
344
+
345
+ await assert.rejects(
346
+ () => exported(root, { include: ['everything'] }),
347
+ /unknown trace links section "everything"/
348
+ );
349
+ });
350
+
351
+ it('should survive a round trip through a Links Notation parser', async () => {
352
+ const root = await writeBundle();
353
+ const { text, links } = await exported(root);
354
+
355
+ // One link per line is what makes the export greppable and what makes a
356
+ // half-written file readable up to its last full line.
357
+ for (const line of text.split('\n').filter(Boolean)) {
358
+ assert.ok(line.startsWith('('), `a line is not one link: ${line}`);
359
+ }
360
+
361
+ // A value holding a newline, a quote, or both, is the case the notation's
362
+ // own escaping cannot read back, so the export encodes it reversibly.
363
+ const console = links.find(
364
+ (link) => link.fields.kind === TRACE_EVENT.CONSOLE
365
+ );
366
+ assert.strictEqual(console.fields.text, 'first line\nsecond line');
367
+
368
+ for (const value of [
369
+ 'both\'and"',
370
+ 'line\nbreak',
371
+ 'tab\there',
372
+ 'back\\slash',
373
+ '',
374
+ '[redacted]',
375
+ 'plain',
376
+ ]) {
377
+ assert.strictEqual(decodeLinkText(encodeLinkText(value)), value);
378
+ }
379
+ });
380
+
381
+ it('should keep the same secrets the bundle keeps', async () => {
382
+ const page = createFakePage({
383
+ snapshot: makeSnapshot({
384
+ state: {
385
+ controls: [
386
+ {
387
+ path: '#password',
388
+ tag: 'input',
389
+ type: 'password',
390
+ value: 'hunter2',
391
+ },
392
+ ],
393
+ },
394
+ }),
395
+ });
396
+ const trace = await startTrace({
397
+ commander: createFakeCommander(page),
398
+ page,
399
+ output: path.join(directory.path, 'private.bc-trace'),
400
+ links: { output: path.join(directory.path, 'private.lino') },
401
+ });
402
+ await trace.checkpoint('signed in');
403
+ const stopped = await trace.stop();
404
+
405
+ const text = await fs.readFile(stopped.links, 'utf8');
406
+ const bundleText = await fs.readFile(
407
+ path.join(stopped.path, 'events.ndjson'),
408
+ 'utf8'
409
+ );
410
+ // The export cannot leak what the bundle redacted, because it only ever
411
+ // reads what the bundle already wrote.
412
+ assert.ok(!bundleText.includes('hunter2'));
413
+ assert.ok(!text.includes('hunter2'));
414
+ });
415
+
416
+ it('should stream the same export the finished bundle produces', async () => {
417
+ const page = createFakePage();
418
+ const trace = await startTrace({
419
+ commander: createFakeCommander(page),
420
+ page,
421
+ mode: TRACE_MODE.CONTINUOUS,
422
+ output: path.join(directory.path, 'streamed.bc-trace'),
423
+ links: { output: path.join(directory.path, 'streamed.lino') },
424
+ });
425
+ page.queueMutations([{ records: [{ kind: 'childList' }] }]);
426
+ await trace.checkpoint('after the update');
427
+ await trace.event('something the caller cared about');
428
+ const stopped = await trace.stop();
429
+
430
+ const streamed = await fs.readFile(stopped.links, 'utf8');
431
+ const afterwards = await writeTraceLinks(
432
+ await readTrace(stopped.path),
433
+ path.join(directory.path, 'afterwards.lino')
434
+ );
435
+
436
+ // Streaming as the run happens and exporting the finished bundle are two
437
+ // routes to one representation; if they could disagree, neither could be
438
+ // trusted.
439
+ assert.strictEqual(streamed, await fs.readFile(afterwards, 'utf8'));
440
+ assert.ok(streamed.startsWith('(trace: '));
441
+ assert.match(streamed, /\(result: /);
442
+ });
443
+
444
+ it('should leave a readable export when a run never stops', async () => {
445
+ const page = createFakePage();
446
+ const trace = await startTrace({
447
+ commander: createFakeCommander(page),
448
+ page,
449
+ output: path.join(directory.path, 'killed.bc-trace'),
450
+ links: { output: path.join(directory.path, 'killed.lino') },
451
+ });
452
+ await trace.checkpoint('as far as it got');
453
+
454
+ // Nothing calls stop(), the way a killed process does not.
455
+ const links = parseExport(await fs.readFile(trace.links, 'utf8'));
456
+ assert.strictEqual(links[0].id, 'trace');
457
+ assert.ok(
458
+ links.some((link) => link.id === 'checkpoint'),
459
+ 'a checkpoint that was recorded is missing from the export'
460
+ );
461
+ // No closing link, which is how a reader tells this run never finished.
462
+ assert.ok(!links.some((link) => link.id === 'result'));
463
+
464
+ // The assertions above model the process being killed. The test process
465
+ // itself survives, so release its open descriptor explicitly.
466
+ await trace.stop();
467
+ });
468
+
469
+ it('should take the export away with a discarded bundle', async () => {
470
+ const page = createFakePage();
471
+ const output = path.join(directory.path, 'discarded.lino');
472
+ const trace = await startTrace({
473
+ commander: createFakeCommander(page),
474
+ page,
475
+ output: path.join(directory.path, 'discarded.bc-trace'),
476
+ links: { output },
477
+ });
478
+ await trace.checkpoint('never kept');
479
+ await trace.stop({ discard: true });
480
+
481
+ await assert.rejects(() => fs.stat(output), { code: 'ENOENT' });
482
+ });
483
+
484
+ it('should refuse an export with nowhere to write', async () => {
485
+ const page = createFakePage();
486
+ await assert.rejects(
487
+ () =>
488
+ startTrace({
489
+ commander: createFakeCommander(page),
490
+ page,
491
+ output: path.join(directory.path, 'nowhere.bc-trace'),
492
+ links: {},
493
+ }),
494
+ /trace links require an output path/
495
+ );
496
+ });
497
+
498
+ it('should write the representation this version pins', async () => {
499
+ // A golden line, so a change to the shape of the export is a change to
500
+ // this test rather than a surprise for whatever is reading the files.
501
+ const text = formatTraceLinks([
502
+ timelineLink({
503
+ sequence: 7,
504
+ at: '2026-01-01T00:00:03.000Z',
505
+ monotonicMs: 1200,
506
+ kind: TRACE_EVENT.INTERACTION,
507
+ traceId: 'trace-1',
508
+ browserContextId: 'context-1',
509
+ pageId: 'page-1',
510
+ navigationId: 'nav-2',
511
+ actionId: 'trace-1-action-3',
512
+ action: 'click',
513
+ target: '#add',
514
+ durationMs: 12,
515
+ ok: true,
516
+ }),
517
+ ]);
518
+
519
+ assert.strictEqual(
520
+ text,
521
+ "(timeline: (sequence: 7) (at: '2026-01-01T00:00:03.000Z') " +
522
+ '(monotonicMs: 1200) (kind: interaction) (trace: trace-1) ' +
523
+ '(context: context-1) (page: page-1) (navigation: nav-2) ' +
524
+ "(actor: automation) (action: click) (target: '#add') (outcome: ok) " +
525
+ '(actionId: trace-1-action-3) (durationMs: 12) (ok: true))\n'
526
+ );
527
+ assert.strictEqual(TRACE_LINKS_VERSION, 1);
528
+ assert.deepStrictEqual(TRACE_LINKS_SECTIONS, [
529
+ 'trace',
530
+ 'timeline',
531
+ 'checkpoints',
532
+ 'control-diffs',
533
+ ]);
534
+ });
535
+
536
+ it('should pin the Links Notation version it was written against', async () => {
537
+ const read = async (file) =>
538
+ JSON.parse(await fs.readFile(new URL(file, import.meta.url), 'utf8'));
539
+ const manifest = await read('../../../package.json');
540
+ const installed = await read(
541
+ '../../../node_modules/links-notation/package.json'
542
+ );
543
+
544
+ // The notation's formatter and parser decide whether an export can be read
545
+ // back, so the range this package depends on and the version the tests
546
+ // proved are the same range.
547
+ assert.strictEqual(manifest.dependencies['links-notation'], '^0.20.0');
548
+ assert.match(installed.version, /^0\.20\./);
549
+ });
550
+
551
+ it('should export a bundle named by path as readily as an open one', async () => {
552
+ const root = await writeBundle();
553
+ const file = await writeTraceLinks(
554
+ root,
555
+ path.join(directory.path, 'by-path.lino')
556
+ );
557
+ const links = await traceLinks(root);
558
+
559
+ assert.strictEqual(
560
+ await fs.readFile(file, 'utf8'),
561
+ formatTraceLinks(links)
562
+ );
563
+ });
564
+ });
@@ -16,10 +16,14 @@ import {
16
16
  TRACE_MODE,
17
17
  TRACE_OUTCOME,
18
18
  } from '../../../src/traces/schema.js';
19
- import { useTempTraceDirectory } from '../../helpers/trace-fixtures.js';
19
+ import {
20
+ useTempTraceDirectory,
21
+ useTraceBundleCleanup,
22
+ } from '../../helpers/trace-fixtures.js';
20
23
 
21
24
  describe('trace reader (issue #87)', () => {
22
25
  const directory = useTempTraceDirectory();
26
+ const cleanup = useTraceBundleCleanup();
23
27
 
24
28
  /**
25
29
  * Write a small bundle the tests can read back.
@@ -29,9 +33,11 @@ describe('trace reader (issue #87)', () => {
29
33
  * @returns {Promise<Object>} The bundle writer
30
34
  */
31
35
  const writeBundle = async ({ close = true } = {}) => {
32
- const bundle = await openTraceBundle({
33
- output: path.join(directory.path, 'run'),
34
- });
36
+ const bundle = cleanup(
37
+ await openTraceBundle({
38
+ output: path.join(directory.path, 'run'),
39
+ })
40
+ );
35
41
  await bundle.appendEvent({
36
42
  kind: TRACE_EVENT.TRACE_START,
37
43
  mode: TRACE_MODE.CHECKPOINTS,