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.
- package/.jscpd-baseline.json +8 -0
- package/CHANGELOG.md +45 -0
- package/README.md +50 -2
- package/package.json +8 -3
- package/scripts/run-tests.mjs +4 -2
- package/src/downloads/manager.js +40 -8
- package/src/downloads/sources.js +127 -6
- package/src/downloads/staging.js +158 -0
- package/src/exports.js +14 -0
- package/src/traces/bundle.js +26 -3
- 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 +29 -3
- package/src/traces/page-capture.js +251 -15
- package/src/traces/recorder.js +124 -94
- package/src/traces/schema.js +65 -1
- package/src/traces/viewer.js +157 -11
- package/tests/e2e/downloads.e2e.test.js +126 -2
- package/tests/e2e/traces.e2e.test.js +296 -11
- package/tests/helpers/download-server.js +26 -6
- package/tests/helpers/trace-fixtures.js +97 -3
- package/tests/helpers/trace-server.js +57 -1
- package/tests/unit/downloads/manager.test.js +213 -9
- package/tests/unit/downloads/staging.test.js +139 -0
- package/tests/unit/scripts/ci-workflow-policy.test.js +8 -0
- package/tests/unit/scripts/npm-release-helpers.test.js +3 -2
- package/tests/unit/scripts/pre-commit-config.test.js +2 -1
- package/tests/unit/traces/bundle.test.js +24 -3
- package/tests/unit/traces/links.test.js +564 -0
- package/tests/unit/traces/reader.test.js +10 -4
- package/tests/unit/traces/recorder.test.js +172 -1
- 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 {
|
|
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
|
-
|
|
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 {
|
|
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 =
|
|
33
|
-
|
|
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,
|