browser-commander 0.17.2 → 0.18.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 +4 -17
- package/CHANGELOG.md +34 -0
- package/README.md +84 -0
- package/eslint.config.js +5 -0
- package/package.json +4 -1
- package/src/bindings.js +13 -1
- package/src/browser/connector.js +15 -3
- package/src/browser/launcher.js +14 -2
- package/src/browser/navigation.js +63 -0
- package/src/core/dialog-manager.js +48 -0
- package/src/core/navigation-manager.js +83 -87
- package/src/core/navigation-readiness.js +133 -0
- package/src/core/readiness.js +478 -0
- package/src/downloads/attach.js +58 -0
- package/src/downloads/destination.js +125 -0
- package/src/downloads/manager.js +453 -0
- package/src/downloads/naming.js +246 -0
- package/src/downloads/sources.js +188 -0
- package/src/downloads/store.js +256 -0
- package/src/exports.js +72 -0
- package/src/factory.js +55 -0
- package/src/interactions/click-activation.js +317 -0
- package/src/interactions/click-result.js +108 -0
- package/src/interactions/click.js +766 -352
- package/src/tests/index.js +75 -11
- package/src/tests/tracing.js +236 -0
- package/src/traces/bundle.js +323 -0
- package/src/traces/observers.js +246 -0
- package/src/traces/page-capture.js +365 -0
- package/src/traces/reader.js +226 -0
- package/src/traces/recorder.js +437 -0
- package/src/traces/redaction.js +231 -0
- package/src/traces/schema.js +165 -0
- package/src/traces/viewer.js +302 -0
- package/tests/e2e/click-readiness.e2e.test.js +240 -0
- package/tests/e2e/downloads.e2e.test.js +188 -0
- package/tests/e2e/traces.e2e.test.js +457 -0
- package/tests/helpers/click-fixtures.js +62 -0
- package/tests/helpers/download-fixtures.js +125 -0
- package/tests/helpers/download-server.js +109 -0
- package/tests/helpers/e2e-browser.js +67 -0
- package/tests/helpers/file-modes.js +31 -0
- package/tests/helpers/fixture-server.js +56 -0
- package/tests/helpers/mocks.js +63 -66
- package/tests/helpers/readiness-server.js +96 -0
- package/tests/helpers/temp-directory.js +44 -0
- package/tests/helpers/trace-fixtures.js +198 -0
- package/tests/helpers/trace-server.js +117 -0
- package/tests/unit/core/dialog-manager.test.js +118 -10
- package/tests/unit/core/navigation-manager.test.js +162 -0
- package/tests/unit/core/readiness.test.js +517 -0
- package/tests/unit/downloads/attach.test.js +220 -0
- package/tests/unit/downloads/destination.test.js +114 -0
- package/tests/unit/downloads/manager.test.js +354 -0
- package/tests/unit/downloads/naming.test.js +174 -0
- package/tests/unit/downloads/store.test.js +215 -0
- package/tests/unit/interactions/click-activation.test.js +283 -0
- package/tests/unit/interactions/click-result.test.js +90 -0
- package/tests/unit/interactions/click.test.js +409 -200
- package/tests/unit/tests/tracing.test.js +299 -0
- package/tests/unit/traces/bundle.test.js +260 -0
- package/tests/unit/traces/reader.test.js +208 -0
- package/tests/unit/traces/recorder.test.js +562 -0
- package/tests/unit/traces/redaction.test.js +170 -0
- package/tests/unit/traces/schema.test.js +92 -0
- package/tests/unit/traces/viewer.test.js +289 -0
|
@@ -0,0 +1,562 @@
|
|
|
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
|
+
|
|
6
|
+
import { startTrace } from '../../../src/traces/recorder.js';
|
|
7
|
+
import { readTrace } from '../../../src/traces/reader.js';
|
|
8
|
+
import { REDACTED } from '../../../src/traces/redaction.js';
|
|
9
|
+
import {
|
|
10
|
+
TRACE_DROP_REASON,
|
|
11
|
+
TRACE_EVENT,
|
|
12
|
+
TRACE_MODE,
|
|
13
|
+
TRACE_OUTCOME,
|
|
14
|
+
} from '../../../src/traces/schema.js';
|
|
15
|
+
import {
|
|
16
|
+
createFakeCommander,
|
|
17
|
+
createFakeDialogManager,
|
|
18
|
+
createFakeDownloads,
|
|
19
|
+
createFakePage,
|
|
20
|
+
makeSnapshot,
|
|
21
|
+
useTempTraceDirectory,
|
|
22
|
+
} from '../../helpers/trace-fixtures.js';
|
|
23
|
+
|
|
24
|
+
describe('trace recorder (issue #87)', () => {
|
|
25
|
+
const directory = useTempTraceDirectory();
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Start a trace over a fake page and commander.
|
|
29
|
+
*
|
|
30
|
+
* @param {Object} [options] - `{page, commander, ...startTrace options}`
|
|
31
|
+
* @returns {Promise<Object>} `{trace, page, commander}`
|
|
32
|
+
*/
|
|
33
|
+
const start = async (options = {}) => {
|
|
34
|
+
const {
|
|
35
|
+
page = createFakePage(),
|
|
36
|
+
commander = createFakeCommander(page, options.commanderExtras ?? {}),
|
|
37
|
+
commanderExtras: _extras,
|
|
38
|
+
...traceOptions
|
|
39
|
+
} = options;
|
|
40
|
+
|
|
41
|
+
const trace = await startTrace({
|
|
42
|
+
commander,
|
|
43
|
+
page,
|
|
44
|
+
output: path.join(directory.path, 'run'),
|
|
45
|
+
...traceOptions,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
return { trace, page, commander };
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Read the whole bundle a stopped trace left behind.
|
|
53
|
+
*
|
|
54
|
+
* @param {Object} stopped - What `stop()` returned
|
|
55
|
+
* @returns {Promise<Object>} The reader
|
|
56
|
+
*/
|
|
57
|
+
const read = (stopped) => readTrace(stopped.path);
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The timeline as raw text, for asserting what it does not contain.
|
|
61
|
+
*
|
|
62
|
+
* A secret is absent from the record only if it is absent from the bytes,
|
|
63
|
+
* so these assertions read the file rather than the parsed events.
|
|
64
|
+
*
|
|
65
|
+
* @param {Object} stopped - What `stop()` returned
|
|
66
|
+
* @returns {Promise<string>} The contents of events.ndjson
|
|
67
|
+
*/
|
|
68
|
+
const timelineText = (stopped) =>
|
|
69
|
+
fs.readFile(path.join(stopped.path, 'events.ndjson'), 'utf8');
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The first event of one kind a stopped trace holds.
|
|
73
|
+
*
|
|
74
|
+
* @param {Object} stopped - What `stop()` returned
|
|
75
|
+
* @param {string} kind - One of TRACE_EVENT
|
|
76
|
+
* @returns {Promise<Object|undefined>} The event
|
|
77
|
+
*/
|
|
78
|
+
const eventOfKind = async (stopped, kind) =>
|
|
79
|
+
(await read(stopped)).events.find((event) => event.kind === kind);
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The first gap a stopped trace recorded.
|
|
83
|
+
*
|
|
84
|
+
* @param {Object} stopped - What `stop()` returned
|
|
85
|
+
* @returns {Promise<Object|undefined>} The dropped event
|
|
86
|
+
*/
|
|
87
|
+
const firstDrop = (stopped) => eventOfKind(stopped, TRACE_EVENT.DROPPED);
|
|
88
|
+
|
|
89
|
+
describe('starting', () => {
|
|
90
|
+
it('should refuse to record without a page', async () => {
|
|
91
|
+
await assert.rejects(
|
|
92
|
+
startTrace({ output: path.join(directory.path, 'run') }),
|
|
93
|
+
/requires a page or a commander/
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('should refuse a mode it does not know', async () => {
|
|
98
|
+
await assert.rejects(
|
|
99
|
+
start({ mode: 'sometimes' }),
|
|
100
|
+
/trace mode must be one of/
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should refuse an event source it does not know', async () => {
|
|
105
|
+
await assert.rejects(
|
|
106
|
+
start({ events: ['telepathy'] }),
|
|
107
|
+
/unknown trace event source/
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('should open the timeline with what it is about to record', async () => {
|
|
112
|
+
const { trace } = await start({ mode: TRACE_MODE.CHECKPOINTS });
|
|
113
|
+
const stopped = await trace.stop();
|
|
114
|
+
|
|
115
|
+
const started = (await read(stopped)).events[0];
|
|
116
|
+
assert.strictEqual(started.kind, TRACE_EVENT.TRACE_START);
|
|
117
|
+
assert.strictEqual(started.mode, TRACE_MODE.CHECKPOINTS);
|
|
118
|
+
assert.strictEqual(started.engine, 'playwright');
|
|
119
|
+
assert.ok(started.events.includes('console'));
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('should record mutations without being asked in continuous mode', async () => {
|
|
123
|
+
const { trace, page } = await start({ mode: TRACE_MODE.CONTINUOUS });
|
|
124
|
+
await trace.stop();
|
|
125
|
+
|
|
126
|
+
assert.ok(
|
|
127
|
+
page.state.evaluated.some(
|
|
128
|
+
(call) => call.name === 'installMutationRecorderInPage'
|
|
129
|
+
)
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('should leave the page alone when mutations are not recorded', async () => {
|
|
134
|
+
const { trace, page } = await start({ mode: TRACE_MODE.CHECKPOINTS });
|
|
135
|
+
await trace.stop();
|
|
136
|
+
|
|
137
|
+
assert.ok(
|
|
138
|
+
!page.state.evaluated.some(
|
|
139
|
+
(call) => call.name === 'installMutationRecorderInPage'
|
|
140
|
+
)
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe('checkpoints', () => {
|
|
146
|
+
it('should capture HTML, live state and a screenshot together', async () => {
|
|
147
|
+
const { trace } = await start({ screenshots: true });
|
|
148
|
+
|
|
149
|
+
const entry = await trace.checkpoint('after login', { actor: 'user' });
|
|
150
|
+
const stopped = await trace.stop();
|
|
151
|
+
|
|
152
|
+
assert.strictEqual(entry.index, 1);
|
|
153
|
+
assert.deepStrictEqual(entry.members, {
|
|
154
|
+
html: 'checkpoints/0001.html',
|
|
155
|
+
state: 'checkpoints/0001.state.json',
|
|
156
|
+
screenshot: 'checkpoints/0001.png',
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const reader = await read(stopped);
|
|
160
|
+
assert.match(await reader.html(1), /captured/);
|
|
161
|
+
const state = await reader.state(1);
|
|
162
|
+
assert.strictEqual(state.name, 'after login');
|
|
163
|
+
assert.strictEqual(state.actor, 'user');
|
|
164
|
+
assert.strictEqual(state.controls[0].value, 'alice');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('should not take a screenshot the caller did not ask for', async () => {
|
|
168
|
+
const { trace, page } = await start({ screenshots: false });
|
|
169
|
+
|
|
170
|
+
await trace.checkpoint('start');
|
|
171
|
+
|
|
172
|
+
assert.strictEqual(page.state.screenshots, 0);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('should take a screenshot only on failure when asked', async () => {
|
|
176
|
+
const { trace, page } = await start({ screenshots: 'only-on-failure' });
|
|
177
|
+
|
|
178
|
+
await trace.checkpoint('start');
|
|
179
|
+
assert.strictEqual(page.state.screenshots, 0);
|
|
180
|
+
|
|
181
|
+
await trace.checkpoint('the end', { reason: 'failure' });
|
|
182
|
+
assert.strictEqual(page.state.screenshots, 1);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('should keep a gap rather than fail when the page has closed', async () => {
|
|
186
|
+
const { trace, page } = await start({ screenshots: false });
|
|
187
|
+
page.failEvaluate(
|
|
188
|
+
'captureSnapshotInPage',
|
|
189
|
+
'Target page, context or browser has been closed'
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
const entry = await trace.checkpoint('after the crash');
|
|
193
|
+
const stopped = await trace.stop();
|
|
194
|
+
|
|
195
|
+
assert.deepStrictEqual(entry.members, {});
|
|
196
|
+
const dropped = await firstDrop(stopped);
|
|
197
|
+
assert.strictEqual(dropped.reason, TRACE_DROP_REASON.PAGE_CLOSED);
|
|
198
|
+
assert.strictEqual(stopped.manifest.outcome, TRACE_OUTCOME.PARTIAL);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('should give a capture its own deadline', async () => {
|
|
202
|
+
const page = createFakePage();
|
|
203
|
+
page.evaluate = () => new Promise(() => {});
|
|
204
|
+
const { trace } = await start({
|
|
205
|
+
page,
|
|
206
|
+
commander: null,
|
|
207
|
+
captureTimeoutMs: 20,
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
await trace.checkpoint('hangs');
|
|
211
|
+
const stopped = await trace.stop();
|
|
212
|
+
|
|
213
|
+
const dropped = await firstDrop(stopped);
|
|
214
|
+
assert.match(dropped.detail, /timed out after 20ms/);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('should refuse a checkpoint after the trace has stopped', async () => {
|
|
218
|
+
const { trace } = await start();
|
|
219
|
+
await trace.stop();
|
|
220
|
+
|
|
221
|
+
await assert.rejects(
|
|
222
|
+
trace.checkpoint('too late'),
|
|
223
|
+
/already been stopped/
|
|
224
|
+
);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('should reinstall the mutation recorder a navigation threw away', async () => {
|
|
228
|
+
const { trace, page } = await start({ mode: TRACE_MODE.CONTINUOUS });
|
|
229
|
+
|
|
230
|
+
await trace.checkpoint('after navigation');
|
|
231
|
+
|
|
232
|
+
const installs = page.state.evaluated.filter(
|
|
233
|
+
(call) => call.name === 'installMutationRecorderInPage'
|
|
234
|
+
);
|
|
235
|
+
assert.strictEqual(installs.length, 2);
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
describe('mutations', () => {
|
|
240
|
+
it('should keep ordered batches between checkpoints', async () => {
|
|
241
|
+
const page = createFakePage({
|
|
242
|
+
mutations: [
|
|
243
|
+
{ sequence: 1, records: [{ type: 'childList' }] },
|
|
244
|
+
{ sequence: 2, records: [{ type: 'attributes' }] },
|
|
245
|
+
],
|
|
246
|
+
});
|
|
247
|
+
const { trace } = await start({ page, mode: TRACE_MODE.CONTINUOUS });
|
|
248
|
+
|
|
249
|
+
await trace.checkpoint('after the update');
|
|
250
|
+
const stopped = await trace.stop();
|
|
251
|
+
|
|
252
|
+
const batches = await (await read(stopped)).mutations(0);
|
|
253
|
+
assert.deepStrictEqual(
|
|
254
|
+
batches.map((batch) => batch.sequence),
|
|
255
|
+
[1, 2]
|
|
256
|
+
);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('should record that the page dropped mutations of its own', async () => {
|
|
260
|
+
const page = createFakePage({
|
|
261
|
+
mutations: [{ sequence: 1, records: [] }],
|
|
262
|
+
droppedMutations: 12,
|
|
263
|
+
});
|
|
264
|
+
const { trace } = await start({ page, mode: TRACE_MODE.CONTINUOUS });
|
|
265
|
+
|
|
266
|
+
await trace.checkpoint('after the storm');
|
|
267
|
+
const stopped = await trace.stop();
|
|
268
|
+
|
|
269
|
+
const dropped = await firstDrop(stopped);
|
|
270
|
+
assert.strictEqual(dropped.reason, TRACE_DROP_REASON.SIZE_LIMIT);
|
|
271
|
+
assert.match(dropped.detail, /12 records/);
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
describe('the shared timeline', () => {
|
|
276
|
+
/** A completed download, as the manager reports one. */
|
|
277
|
+
const COMPLETED_DOWNLOAD = Object.freeze({
|
|
278
|
+
id: 'download-1',
|
|
279
|
+
suggestedFilename: 'report.pdf',
|
|
280
|
+
path: '/tmp/downloads/report.pdf',
|
|
281
|
+
checksum: 'abc123',
|
|
282
|
+
bytes: 2048,
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('should order navigation, interactions and page events as they happened', async () => {
|
|
286
|
+
const downloads = createFakeDownloads();
|
|
287
|
+
const { trace, page, commander } = await start({
|
|
288
|
+
commanderExtras: { downloads },
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
await commander.goto('https://example.com/login');
|
|
292
|
+
page.emit('console', { type: () => 'warning', text: () => 'slow' });
|
|
293
|
+
page.emit('pageerror', new Error('boom'));
|
|
294
|
+
page.emit('requestfailed', {
|
|
295
|
+
url: () => 'https://example.com/missing',
|
|
296
|
+
method: () => 'GET',
|
|
297
|
+
failure: () => ({ errorText: 'net::ERR_FAILED' }),
|
|
298
|
+
});
|
|
299
|
+
page.emit('framenavigated', { url: () => 'https://example.com/home' });
|
|
300
|
+
downloads.emit('completed', COMPLETED_DOWNLOAD);
|
|
301
|
+
const stopped = await trace.stop();
|
|
302
|
+
|
|
303
|
+
const kinds = (await read(stopped)).events.map((event) => event.kind);
|
|
304
|
+
assert.deepStrictEqual(kinds, [
|
|
305
|
+
TRACE_EVENT.TRACE_START,
|
|
306
|
+
TRACE_EVENT.INTERACTION,
|
|
307
|
+
TRACE_EVENT.CONSOLE,
|
|
308
|
+
TRACE_EVENT.PAGE_ERROR,
|
|
309
|
+
TRACE_EVENT.REQUEST_FAILED,
|
|
310
|
+
TRACE_EVENT.NAVIGATION,
|
|
311
|
+
TRACE_EVENT.DOWNLOAD,
|
|
312
|
+
TRACE_EVENT.TRACE_STOP,
|
|
313
|
+
]);
|
|
314
|
+
const sequences = (await read(stopped)).events.map(
|
|
315
|
+
(event) => event.sequence
|
|
316
|
+
);
|
|
317
|
+
assert.deepStrictEqual(sequences, [1, 2, 3, 4, 5, 6, 7, 8]);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('should reference a download by path and checksum, not by its bytes', async () => {
|
|
321
|
+
const downloads = createFakeDownloads();
|
|
322
|
+
const { trace } = await start({ commanderExtras: { downloads } });
|
|
323
|
+
|
|
324
|
+
downloads.emit('completed', {
|
|
325
|
+
...COMPLETED_DOWNLOAD,
|
|
326
|
+
url: 'https://example.com/report.pdf?token=secret',
|
|
327
|
+
});
|
|
328
|
+
const stopped = await trace.stop();
|
|
329
|
+
|
|
330
|
+
const event = await eventOfKind(stopped, TRACE_EVENT.DOWNLOAD);
|
|
331
|
+
assert.strictEqual(event.path, '/tmp/downloads/report.pdf');
|
|
332
|
+
assert.strictEqual(event.checksum, 'abc123');
|
|
333
|
+
assert.strictEqual(event.bytes, 2048);
|
|
334
|
+
assert.strictEqual(
|
|
335
|
+
event.url,
|
|
336
|
+
`https://example.com/report.pdf?token=${REDACTED}`
|
|
337
|
+
);
|
|
338
|
+
assert.ok(!Object.hasOwn(event, 'contents'));
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('should record what an interaction did without recording what was typed', async () => {
|
|
342
|
+
const { trace, commander } = await start();
|
|
343
|
+
|
|
344
|
+
await commander.typeText('#password', 'hunter2');
|
|
345
|
+
const stopped = await trace.stop();
|
|
346
|
+
|
|
347
|
+
const body = await timelineText(stopped);
|
|
348
|
+
const event = await eventOfKind(stopped, TRACE_EVENT.INTERACTION);
|
|
349
|
+
assert.strictEqual(event.action, 'typeText');
|
|
350
|
+
assert.strictEqual(event.target, '#password');
|
|
351
|
+
assert.strictEqual(event.ok, true);
|
|
352
|
+
assert.ok(!body.includes('hunter2'));
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it('should read the target out of the options object a call was given', async () => {
|
|
356
|
+
const page = createFakePage();
|
|
357
|
+
const commander = createFakeCommander(page, {
|
|
358
|
+
click: async (options) => options,
|
|
359
|
+
});
|
|
360
|
+
const { trace } = await start({ page, commander });
|
|
361
|
+
|
|
362
|
+
await commander.click({ selector: '#buy', text: 'hunter2' });
|
|
363
|
+
await commander.goto({ url: 'https://example.com/cart' });
|
|
364
|
+
const stopped = await trace.stop();
|
|
365
|
+
|
|
366
|
+
const [clicked, navigated] = (await read(stopped)).events.filter(
|
|
367
|
+
(event) => event.kind === TRACE_EVENT.INTERACTION
|
|
368
|
+
);
|
|
369
|
+
assert.strictEqual(clicked.target, '#buy');
|
|
370
|
+
assert.strictEqual(navigated.target, 'https://example.com/cart');
|
|
371
|
+
const body = await timelineText(stopped);
|
|
372
|
+
assert.ok(!body.includes('hunter2'));
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it('should record an interaction that failed and rethrow it', async () => {
|
|
376
|
+
const page = createFakePage();
|
|
377
|
+
const commander = createFakeCommander(page, {
|
|
378
|
+
click: async () => {
|
|
379
|
+
throw new Error('element is covered');
|
|
380
|
+
},
|
|
381
|
+
});
|
|
382
|
+
const { trace } = await start({ page, commander });
|
|
383
|
+
|
|
384
|
+
await assert.rejects(commander.click('#buy'), /element is covered/);
|
|
385
|
+
const stopped = await trace.stop();
|
|
386
|
+
|
|
387
|
+
const event = await eventOfKind(stopped, TRACE_EVENT.INTERACTION);
|
|
388
|
+
assert.strictEqual(event.ok, false);
|
|
389
|
+
assert.strictEqual(event.error, 'element is covered');
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it('should let a caller put its own step on the same timeline', async () => {
|
|
393
|
+
const { trace } = await start();
|
|
394
|
+
|
|
395
|
+
await trace.event('paid the invoice', { invoice: 'INV-42' });
|
|
396
|
+
const stopped = await trace.stop();
|
|
397
|
+
|
|
398
|
+
const event = (await read(stopped)).events.find(
|
|
399
|
+
(record) => record.actor === 'caller'
|
|
400
|
+
);
|
|
401
|
+
assert.strictEqual(event.action, 'paid the invoice');
|
|
402
|
+
assert.strictEqual(event.invoice, 'INV-42');
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it('should record a dialog without taking responsibility for it', async () => {
|
|
406
|
+
const dialogManager = createFakeDialogManager();
|
|
407
|
+
const { trace } = await start({ commanderExtras: { dialogManager } });
|
|
408
|
+
|
|
409
|
+
const dialog = await dialogManager.raise({
|
|
410
|
+
type: 'alert',
|
|
411
|
+
message: 'are you sure?',
|
|
412
|
+
});
|
|
413
|
+
const stopped = await trace.stop();
|
|
414
|
+
|
|
415
|
+
const event = await eventOfKind(stopped, TRACE_EVENT.DIALOG);
|
|
416
|
+
assert.strictEqual(event.type, 'alert');
|
|
417
|
+
assert.strictEqual(event.message, 'are you sure?');
|
|
418
|
+
// Watching is not answering: the dialog is still auto-dismissed, so a
|
|
419
|
+
// traced page does not sit waiting for an answer an untraced one got.
|
|
420
|
+
assert.strictEqual(dialog.dismissed, true);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it('should stop watching dialogs when the trace stops', async () => {
|
|
424
|
+
const dialogManager = createFakeDialogManager();
|
|
425
|
+
const { trace } = await start({ commanderExtras: { dialogManager } });
|
|
426
|
+
|
|
427
|
+
await trace.stop();
|
|
428
|
+
await dialogManager.raise({ type: 'confirm', message: 'after' });
|
|
429
|
+
|
|
430
|
+
assert.strictEqual(dialogManager.observerCount(), 0);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it('should only observe the sources the caller asked for', async () => {
|
|
434
|
+
const { trace, page, commander } = await start({ events: ['console'] });
|
|
435
|
+
|
|
436
|
+
page.emit('console', { type: () => 'log', text: () => 'hello' });
|
|
437
|
+
await commander.goto('https://example.com');
|
|
438
|
+
const stopped = await trace.stop();
|
|
439
|
+
|
|
440
|
+
const kinds = (await read(stopped)).events.map((event) => event.kind);
|
|
441
|
+
assert.ok(kinds.includes(TRACE_EVENT.CONSOLE));
|
|
442
|
+
assert.ok(!kinds.includes(TRACE_EVENT.INTERACTION));
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it('should stop observing once the trace is stopped', async () => {
|
|
446
|
+
const { trace, page, commander } = await start();
|
|
447
|
+
const wrapped = commander.goto;
|
|
448
|
+
|
|
449
|
+
const stopped = await trace.stop();
|
|
450
|
+
page.emit('console', { type: () => 'log', text: () => 'after' });
|
|
451
|
+
|
|
452
|
+
assert.strictEqual(page.listenerCount('console'), 0);
|
|
453
|
+
assert.notStrictEqual(commander.goto, wrapped);
|
|
454
|
+
const body = await timelineText(stopped);
|
|
455
|
+
assert.ok(!body.includes('after'));
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
describe('privacy', () => {
|
|
460
|
+
it('should keep a secret out of every member of the bundle', async () => {
|
|
461
|
+
const page = createFakePage({
|
|
462
|
+
snapshot: makeSnapshot({
|
|
463
|
+
state: {
|
|
464
|
+
url: 'https://example.com/report?access_token=super-secret',
|
|
465
|
+
controls: [
|
|
466
|
+
{ path: 'input#token', tag: 'input', value: 'super-secret' },
|
|
467
|
+
],
|
|
468
|
+
},
|
|
469
|
+
}),
|
|
470
|
+
});
|
|
471
|
+
const { trace } = await start({
|
|
472
|
+
page,
|
|
473
|
+
screenshots: false,
|
|
474
|
+
privacy: { redactPatterns: ['super-secret'] },
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
await trace.checkpoint('after login');
|
|
478
|
+
await trace.event('used the token', { token: 'super-secret' });
|
|
479
|
+
const stopped = await trace.stop();
|
|
480
|
+
|
|
481
|
+
for (const member of await fs.readdir(stopped.path, {
|
|
482
|
+
recursive: true,
|
|
483
|
+
withFileTypes: true,
|
|
484
|
+
})) {
|
|
485
|
+
if (!member.isFile()) {
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
const body = await fs.readFile(
|
|
489
|
+
path.join(member.parentPath ?? member.path, member.name),
|
|
490
|
+
'utf8'
|
|
491
|
+
);
|
|
492
|
+
assert.ok(
|
|
493
|
+
!body.includes('super-secret'),
|
|
494
|
+
`${member.name} still holds the secret`
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it('should record the privacy settings it applied', async () => {
|
|
500
|
+
const { trace } = await start({
|
|
501
|
+
privacy: { redactSelectors: ['.card-number'], redact: () => 'x' },
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
const stopped = await trace.stop();
|
|
505
|
+
|
|
506
|
+
assert.ok(
|
|
507
|
+
stopped.manifest.privacy.redactSelectors.includes('.card-number')
|
|
508
|
+
);
|
|
509
|
+
assert.strictEqual(stopped.manifest.privacy.hasCallback, true);
|
|
510
|
+
});
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
describe('stopping', () => {
|
|
514
|
+
it('should write a readable manifest and report the checkpoints', async () => {
|
|
515
|
+
const { trace } = await start();
|
|
516
|
+
|
|
517
|
+
await trace.checkpoint('one');
|
|
518
|
+
await trace.checkpoint('two');
|
|
519
|
+
const stopped = await trace.stop();
|
|
520
|
+
|
|
521
|
+
assert.strictEqual(stopped.manifest.counts.checkpoints, 2);
|
|
522
|
+
assert.strictEqual(stopped.manifest.outcome, TRACE_OUTCOME.COMPLETE);
|
|
523
|
+
assert.ok(stopped.manifest.stoppedAt);
|
|
524
|
+
assert.deepStrictEqual(
|
|
525
|
+
stopped.checkpoints.map((entry) => entry.name),
|
|
526
|
+
['one', 'two']
|
|
527
|
+
);
|
|
528
|
+
assert.strictEqual(trace.stopped, true);
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
it('should record the error a run ended with', async () => {
|
|
532
|
+
const { trace } = await start();
|
|
533
|
+
|
|
534
|
+
const stopped = await trace.stop({
|
|
535
|
+
error: new Error('assertion failed'),
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
const fatal = (await read(stopped)).events.find(
|
|
539
|
+
(event) => event.kind === TRACE_EVENT.PAGE_ERROR && event.fatal
|
|
540
|
+
);
|
|
541
|
+
assert.strictEqual(fatal.message, 'assertion failed');
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
it('should remove a bundle it was told to discard', async () => {
|
|
545
|
+
const { trace } = await start();
|
|
546
|
+
|
|
547
|
+
const stopped = await trace.stop({ discard: true });
|
|
548
|
+
|
|
549
|
+
assert.strictEqual(stopped.discarded, true);
|
|
550
|
+
await assert.rejects(fs.stat(stopped.path), { code: 'ENOENT' });
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
it('should answer the same way when stopped twice', async () => {
|
|
554
|
+
const { trace } = await start();
|
|
555
|
+
|
|
556
|
+
const first = await trace.stop();
|
|
557
|
+
const second = await trace.stop();
|
|
558
|
+
|
|
559
|
+
assert.strictEqual(first, second);
|
|
560
|
+
});
|
|
561
|
+
});
|
|
562
|
+
});
|