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