browser-commander 0.17.2 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/.jscpd-baseline.json +12 -17
  2. package/CHANGELOG.md +68 -0
  3. package/README.md +132 -0
  4. package/eslint.config.js +5 -0
  5. package/package.json +5 -1
  6. package/src/bindings.js +13 -1
  7. package/src/browser/connector.js +15 -3
  8. package/src/browser/launcher.js +14 -2
  9. package/src/browser/navigation.js +63 -0
  10. package/src/core/dialog-manager.js +48 -0
  11. package/src/core/navigation-manager.js +83 -87
  12. package/src/core/navigation-readiness.js +133 -0
  13. package/src/core/readiness.js +478 -0
  14. package/src/downloads/attach.js +58 -0
  15. package/src/downloads/destination.js +125 -0
  16. package/src/downloads/manager.js +476 -0
  17. package/src/downloads/naming.js +246 -0
  18. package/src/downloads/sources.js +238 -0
  19. package/src/downloads/staging.js +146 -0
  20. package/src/downloads/store.js +256 -0
  21. package/src/exports.js +86 -0
  22. package/src/factory.js +55 -0
  23. package/src/interactions/click-activation.js +317 -0
  24. package/src/interactions/click-result.js +108 -0
  25. package/src/interactions/click.js +766 -352
  26. package/src/tests/index.js +75 -11
  27. package/src/tests/tracing.js +236 -0
  28. package/src/traces/bundle.js +338 -0
  29. package/src/traces/deadline.js +33 -0
  30. package/src/traces/identity.js +127 -0
  31. package/src/traces/init-script.js +95 -0
  32. package/src/traces/links.js +623 -0
  33. package/src/traces/mutation-stream.js +232 -0
  34. package/src/traces/observers.js +272 -0
  35. package/src/traces/page-capture.js +601 -0
  36. package/src/traces/reader.js +226 -0
  37. package/src/traces/recorder.js +454 -0
  38. package/src/traces/redaction.js +231 -0
  39. package/src/traces/schema.js +229 -0
  40. package/src/traces/viewer.js +448 -0
  41. package/tests/e2e/click-readiness.e2e.test.js +240 -0
  42. package/tests/e2e/downloads.e2e.test.js +235 -0
  43. package/tests/e2e/traces.e2e.test.js +742 -0
  44. package/tests/helpers/click-fixtures.js +62 -0
  45. package/tests/helpers/download-fixtures.js +125 -0
  46. package/tests/helpers/download-server.js +129 -0
  47. package/tests/helpers/e2e-browser.js +67 -0
  48. package/tests/helpers/file-modes.js +31 -0
  49. package/tests/helpers/fixture-server.js +56 -0
  50. package/tests/helpers/mocks.js +63 -66
  51. package/tests/helpers/readiness-server.js +96 -0
  52. package/tests/helpers/temp-directory.js +44 -0
  53. package/tests/helpers/trace-fixtures.js +274 -0
  54. package/tests/helpers/trace-server.js +173 -0
  55. package/tests/unit/core/dialog-manager.test.js +118 -10
  56. package/tests/unit/core/navigation-manager.test.js +162 -0
  57. package/tests/unit/core/readiness.test.js +517 -0
  58. package/tests/unit/downloads/attach.test.js +220 -0
  59. package/tests/unit/downloads/destination.test.js +114 -0
  60. package/tests/unit/downloads/manager.test.js +485 -0
  61. package/tests/unit/downloads/naming.test.js +174 -0
  62. package/tests/unit/downloads/staging.test.js +117 -0
  63. package/tests/unit/downloads/store.test.js +215 -0
  64. package/tests/unit/interactions/click-activation.test.js +283 -0
  65. package/tests/unit/interactions/click-result.test.js +90 -0
  66. package/tests/unit/interactions/click.test.js +409 -200
  67. package/tests/unit/tests/tracing.test.js +299 -0
  68. package/tests/unit/traces/bundle.test.js +260 -0
  69. package/tests/unit/traces/links.test.js +557 -0
  70. package/tests/unit/traces/reader.test.js +208 -0
  71. package/tests/unit/traces/recorder.test.js +730 -0
  72. package/tests/unit/traces/redaction.test.js +170 -0
  73. package/tests/unit/traces/schema.test.js +92 -0
  74. package/tests/unit/traces/viewer.test.js +294 -0
@@ -0,0 +1,742 @@
1
+ /**
2
+ * Real-browser tests for issue #87.
3
+ *
4
+ * A trace claims to hold the state a run was actually in: the value a user
5
+ * typed rather than the one the server sent, the DOM changes an app made
6
+ * between two moments, and every navigation, interaction, console message,
7
+ * error, failed request, dialog and download in one order. None of that can
8
+ * be proven against a fake page, so these drive both engines and finish by
9
+ * opening the offline viewer in the same browser.
10
+ *
11
+ * Run with: npm run test:e2e:traces
12
+ */
13
+
14
+ import { describe, it, before, beforeEach, after } from 'node:test';
15
+ import assert from 'node:assert';
16
+ import fs from 'node:fs/promises';
17
+ import os from 'node:os';
18
+ import path from 'node:path';
19
+
20
+ import { makeBrowserCommander } from '../../src/factory.js';
21
+ import { readTrace } from '../../src/traces/reader.js';
22
+ import { startTrace } from '../../src/traces/recorder.js';
23
+ import {
24
+ TRACE_DROP_REASON,
25
+ TRACE_EVENT,
26
+ TRACE_FILES,
27
+ TRACE_MODE,
28
+ TRACE_OUTCOME,
29
+ } from '../../src/traces/schema.js';
30
+ import { writeTraceViewer } from '../../src/traces/viewer.js';
31
+ import { launchE2EBrowser } from '../helpers/e2e-browser.js';
32
+ import {
33
+ startTraceServer,
34
+ TRACE_DOWNLOAD_BODY,
35
+ TRACE_SECRET,
36
+ } from '../helpers/trace-server.js';
37
+
38
+ const ENGINES = ['playwright', 'puppeteer'];
39
+
40
+ /**
41
+ * Run the trace expectations against one engine.
42
+ *
43
+ * @param {string} engine - 'playwright' or 'puppeteer'
44
+ * @returns {void}
45
+ */
46
+ function describeTraces(engine) {
47
+ describe(`E2E - portable traces with ${engine} (issue #87)`, () => {
48
+ let server;
49
+ let browser;
50
+ let page;
51
+ let downloads;
52
+ let commander;
53
+ let artifacts;
54
+ let downloadDirectory;
55
+ let cleanup;
56
+ let runs = 0;
57
+
58
+ before(async () => {
59
+ server = await startTraceServer();
60
+ artifacts = await fs.mkdtemp(path.join(os.tmpdir(), 'bc-e2e-traces-'));
61
+ downloadDirectory = await fs.mkdtemp(
62
+ path.join(os.tmpdir(), 'bc-e2e-files-')
63
+ );
64
+ ({ browser, page, downloads, cleanup } = await launchE2EBrowser({
65
+ engine,
66
+ downloadDirectory,
67
+ }));
68
+ commander = makeBrowserCommander({ page, downloads });
69
+ });
70
+
71
+ beforeEach(async () => {
72
+ await page.goto(`${server.baseUrl}/`);
73
+ });
74
+
75
+ after(async () => {
76
+ await commander?.destroy();
77
+ await cleanup?.();
78
+ await server?.close();
79
+ for (const directory of [artifacts, downloadDirectory]) {
80
+ await fs.rm(directory, { recursive: true, force: true });
81
+ }
82
+ });
83
+
84
+ /**
85
+ * Start a trace into this run's own bundle directory.
86
+ *
87
+ * @param {Object} [options] - Options for `commander.startTrace()`
88
+ * @returns {Promise<Object>} The running trace
89
+ */
90
+ const record = (options = {}) =>
91
+ commander.startTrace({
92
+ output: path.join(artifacts, `run-${++runs}`),
93
+ screenshots: false,
94
+ ...options,
95
+ });
96
+
97
+ /**
98
+ * Wait until the page is somewhere.
99
+ *
100
+ * @param {RegExp} pattern - What the URL must match
101
+ * @returns {Promise<void>} Resolves once it does
102
+ */
103
+ const waitForUrl = async (pattern) => {
104
+ const deadline = Date.now() + 20000;
105
+ while (!pattern.test(page.url())) {
106
+ if (Date.now() > deadline) {
107
+ throw new Error(`still at ${page.url()}, expected ${pattern}`);
108
+ }
109
+ await new Promise((resolve) => setTimeout(resolve, 50));
110
+ }
111
+ };
112
+
113
+ /**
114
+ * Drive the fixture through one app update, between two checkpoints.
115
+ *
116
+ * Two items are added and an attribute changes, so the batches recorded
117
+ * between the checkpoints hold both kinds of DOM change the format
118
+ * promises to preserve.
119
+ *
120
+ * @param {Object} trace - A running trace in continuous mode
121
+ * @returns {Promise<void>} Resolves once the second checkpoint is written
122
+ */
123
+ const recordAnAppUpdate = async (trace) => {
124
+ await trace.checkpoint('loaded');
125
+ await page.click('#add');
126
+ await page.click('#add');
127
+ await page.click('#touch');
128
+ await trace.checkpoint('the app updated itself');
129
+ };
130
+
131
+ /**
132
+ * Wait until the timeline holds every kind of event named.
133
+ *
134
+ * The observers write as things happen, so asking right after a click is
135
+ * asking too early; this waits for the record rather than for a guess.
136
+ *
137
+ * @param {Object} trace - A running trace
138
+ * @param {string[]} kinds - Event kinds that must be present
139
+ * @returns {Promise<Object[]>} The timeline
140
+ */
141
+ const waitForEvents = async (trace, kinds) => {
142
+ const deadline = Date.now() + 20000;
143
+ let seen = new Set();
144
+ for (;;) {
145
+ const { events } = await readTrace(trace.path);
146
+ seen = new Set(events.map((event) => event.kind));
147
+ if (kinds.every((kind) => seen.has(kind))) {
148
+ return events;
149
+ }
150
+ if (Date.now() > deadline) {
151
+ throw new Error(
152
+ `timed out waiting for ${kinds.join(', ')}; saw ${[...seen].join(', ')}`
153
+ );
154
+ }
155
+ await new Promise((resolve) => setTimeout(resolve, 100));
156
+ }
157
+ };
158
+
159
+ /**
160
+ * Choose an option, the same way in either engine.
161
+ *
162
+ * @param {string} selector - The select element
163
+ * @param {string} value - Option value to choose
164
+ * @returns {Promise<void>} Resolves once chosen
165
+ */
166
+ const choose = (selector, value) =>
167
+ commander.evaluate(
168
+ (options) => {
169
+ const element = document.querySelector(options.selector);
170
+ element.value = options.value;
171
+ element.dispatchEvent(new Event('change', { bubbles: true }));
172
+ },
173
+ { selector, value }
174
+ );
175
+
176
+ /**
177
+ * The control state one checkpoint captured, by CSS path.
178
+ *
179
+ * @param {Object} state - A checkpoint's state
180
+ * @returns {Map<string, Object>} Controls, keyed by path
181
+ */
182
+ const controlsOf = (state) =>
183
+ new Map(state.controls.map((control) => [control.path, control]));
184
+
185
+ /**
186
+ * Every record in a checkpoint's mutation batches.
187
+ *
188
+ * @param {Object[]} batches - Batches from `reader.mutations()`
189
+ * @returns {Object[]} The records, in order
190
+ */
191
+ const changesIn = (batches) => batches.flatMap((batch) => batch.records);
192
+
193
+ /**
194
+ * Every mutation record a stopped trace holds, whichever interval it fell in.
195
+ *
196
+ * @param {Object} reader - An open trace
197
+ * @returns {Promise<Object[]>} The records, in order
198
+ */
199
+ const allChanges = async (reader) => {
200
+ const records = [];
201
+ for (let index = 0; index <= reader.checkpoints.length; index++) {
202
+ records.push(...changesIn(await reader.mutations(index)));
203
+ }
204
+ return records;
205
+ };
206
+
207
+ it('should capture the state a user made, not the state the server sent', async () => {
208
+ const trace = await record({ screenshots: 'checkpoints' });
209
+
210
+ await page.type('#name', 'ada');
211
+ await page.click('#terms');
212
+ await choose('#plan', 'pro');
213
+ await trace.checkpoint('the form is filled in');
214
+ const stopped = await trace.stop();
215
+
216
+ const reader = await readTrace(stopped.path);
217
+ const controls = controlsOf(await reader.state(1));
218
+ assert.strictEqual(controls.get('#name').value, 'ada');
219
+ assert.strictEqual(controls.get('#terms').checked, true);
220
+ assert.deepStrictEqual(controls.get('#plan').selected, ['pro']);
221
+ const state = await reader.state(1);
222
+ assert.strictEqual(state.title, 'Traced app');
223
+ assert.strictEqual(state.url, `${server.baseUrl}/`);
224
+ // The frame is part of the moment too, and so is a picture of it.
225
+ assert.deepStrictEqual(
226
+ state.frames.map((frame) => frame.name),
227
+ ['inner']
228
+ );
229
+ // The serialized markup carries the typed value, which `outerHTML`
230
+ // alone would not: a replay of this checkpoint shows a filled-in form.
231
+ assert.match(await reader.html(1), /id="name"[^>]*value="ada"/);
232
+ const shot = await fs.readFile(
233
+ path.join(stopped.path, TRACE_FILES.CHECKPOINTS_DIR, '0001.png')
234
+ );
235
+ assert.strictEqual(shot.subarray(1, 4).toString(), 'PNG');
236
+ });
237
+
238
+ it('should keep ordered mutations across an app update and a navigation', async () => {
239
+ const trace = await record({ mode: TRACE_MODE.CONTINUOUS });
240
+
241
+ await recordAnAppUpdate(trace);
242
+ await page.click('#next');
243
+ await waitForUrl(/\/next$/);
244
+ await trace.checkpoint('the next document');
245
+ await page.click('#grow');
246
+ const stopped = await trace.stop();
247
+
248
+ const reader = await readTrace(stopped.path);
249
+ // A continuous trace takes its own base snapshot first (issue #93), so
250
+ // the interval a moment belongs to is looked up by that moment's name
251
+ // rather than counted from the caller's first checkpoint.
252
+ const indexOf = (name) =>
253
+ stopped.checkpoints.find((entry) => entry.name === name).index;
254
+ const batches = await reader.mutations(indexOf('loaded'));
255
+ const added = changesIn(batches)
256
+ .flatMap((change) => change.added ?? [])
257
+ .filter((node) => node?.id);
258
+ assert.deepStrictEqual(
259
+ added.map((node) => node.id),
260
+ ['item-1', 'item-2']
261
+ );
262
+ const attribute = changesIn(batches).find(
263
+ (change) => change.kind === 'attributes'
264
+ );
265
+ assert.strictEqual(attribute.attribute, 'data-state');
266
+ assert.strictEqual(attribute.before, 'idle');
267
+ assert.strictEqual(attribute.after, 'touched');
268
+ assert.deepStrictEqual(
269
+ batches.map((batch) => batch.sequence),
270
+ batches.map((_batch, index) => index + 1)
271
+ );
272
+
273
+ // The navigation threw the in-page recorder away with the document it
274
+ // lived in; a batch after it proves a new one took its place.
275
+ const afterNavigating = await reader.mutations(
276
+ indexOf('the next document')
277
+ );
278
+ assert.ok(
279
+ changesIn(afterNavigating)
280
+ .flatMap((change) => change.added ?? [])
281
+ .some((node) => node?.id === 'grown'),
282
+ `expected the grown item, got ${JSON.stringify(afterNavigating)}`
283
+ );
284
+ assert.match(
285
+ (await reader.state(indexOf('the next document'))).url,
286
+ /\/next$/
287
+ );
288
+ });
289
+
290
+ it('should put everything that happened on one ordered timeline', async () => {
291
+ const trace = await record();
292
+
293
+ // One interaction goes through the commander, because an interaction
294
+ // event is a record of what Browser Commander was asked to do. The
295
+ // rest drive the page directly: they are about what the page did.
296
+ //
297
+ // That one interaction costs about 30 seconds, and the cost is the
298
+ // factory's own configuration rather than anything this trace does:
299
+ // the `page.goto` above put the navigation manager into its external
300
+ // navigation wait, and a commander action joins that wait, which asks
301
+ // the network tracker for idle with the factory's `idleTimeout` of
302
+ // 30000ms - the window the network must stay quiet for. Changing that
303
+ // number would change what every caller's readiness means, so it is
304
+ // left alone and paid for here, once.
305
+ await commander.click({
306
+ selector: '#log',
307
+ waitForNavigation: false,
308
+ waitAfterClick: 0,
309
+ });
310
+ await page.click('#boom');
311
+ await page.click('#unreachable');
312
+ await page.click('#ask');
313
+ const artifact = await downloads.capture({
314
+ action: () => page.click('#download'),
315
+ timeout: 20000,
316
+ });
317
+ await page.goto(`${server.baseUrl}/next`);
318
+ const events = await waitForEvents(trace, [
319
+ TRACE_EVENT.INTERACTION,
320
+ TRACE_EVENT.CONSOLE,
321
+ TRACE_EVENT.PAGE_ERROR,
322
+ TRACE_EVENT.REQUEST_FAILED,
323
+ TRACE_EVENT.DIALOG,
324
+ TRACE_EVENT.DOWNLOAD,
325
+ TRACE_EVENT.NAVIGATION,
326
+ ]);
327
+ await trace.stop();
328
+
329
+ assert.deepStrictEqual(
330
+ events.map((event) => event.sequence),
331
+ events.map((_event, index) => index + 1)
332
+ );
333
+ const byKind = (kind) => events.filter((event) => event.kind === kind);
334
+ assert.strictEqual(byKind(TRACE_EVENT.INTERACTION)[0].target, '#log');
335
+ assert.match(byKind(TRACE_EVENT.CONSOLE)[0].text, /something happened/);
336
+ assert.match(
337
+ byKind(TRACE_EVENT.PAGE_ERROR)[0].message,
338
+ /page error on purpose/
339
+ );
340
+ assert.match(
341
+ byKind(TRACE_EVENT.REQUEST_FAILED)[0].url,
342
+ /127\.0\.0\.1:1\/nope/
343
+ );
344
+ assert.strictEqual(
345
+ byKind(TRACE_EVENT.DIALOG)[0].message,
346
+ 'are you sure?'
347
+ );
348
+ // The download is on the timeline by reference: the bytes stay where
349
+ // the download manager put them, and the trace says where that is.
350
+ const completed = byKind(TRACE_EVENT.DOWNLOAD).find(
351
+ (event) => event.phase === 'completed'
352
+ );
353
+ assert.strictEqual(completed.path, artifact.path);
354
+ assert.strictEqual(completed.checksum, artifact.checksum);
355
+ assert.ok(!Object.hasOwn(completed, 'contents'));
356
+ assert.strictEqual(
357
+ await fs.readFile(completed.path, 'utf8'),
358
+ TRACE_DOWNLOAD_BODY
359
+ );
360
+ });
361
+
362
+ it('should keep a secret the page held out of every member', async () => {
363
+ // No privacy options: a password is a secret by default, because a
364
+ // trace nobody configured is the one most likely to be shared.
365
+ const trace = await record({ screenshots: 'checkpoints' });
366
+
367
+ await commander.fill({ selector: '#password', text: TRACE_SECRET });
368
+ await trace.checkpoint('after typing the password');
369
+ const stopped = await trace.stop();
370
+ await writeTraceViewer(stopped.path);
371
+
372
+ let checked = 0;
373
+ for (const entry of await fs.readdir(stopped.path, {
374
+ recursive: true,
375
+ withFileTypes: true,
376
+ })) {
377
+ if (!entry.isFile()) {
378
+ continue;
379
+ }
380
+ const body = await fs.readFile(
381
+ path.join(entry.parentPath ?? entry.path, entry.name),
382
+ 'latin1'
383
+ );
384
+ assert.ok(
385
+ !body.includes(TRACE_SECRET),
386
+ `${entry.name} carries the secret`
387
+ );
388
+ checked += 1;
389
+ }
390
+ assert.ok(checked >= 4, `expected a full bundle, checked ${checked}`);
391
+ const controls = controlsOf(
392
+ await (await readTrace(stopped.path)).state(1)
393
+ );
394
+ assert.strictEqual(controls.get('#password').redacted, true);
395
+ assert.strictEqual(controls.get('#password').value, '[redacted]');
396
+ });
397
+
398
+ it('should leave a readable trace when the page closes mid-run', async () => {
399
+ const second = await browser.newPage();
400
+ await second.goto(`${server.baseUrl}/`);
401
+ // No commander: a consumer can trace any page, and this one is going to
402
+ // be taken away while the recorder is still running.
403
+ const trace = await startTrace({
404
+ page: second,
405
+ output: path.join(artifacts, `closed-${++runs}`),
406
+ screenshots: false,
407
+ });
408
+
409
+ await trace.checkpoint('still open');
410
+ await second.close();
411
+ await trace.checkpoint('after the page went away');
412
+ const stopped = await trace.stop();
413
+
414
+ const reader = await readTrace(stopped.path);
415
+ assert.strictEqual(reader.manifest.outcome, TRACE_OUTCOME.PARTIAL);
416
+ // The first checkpoint survives the second one failing: a partial trace
417
+ // is readable, not a corrupted run.
418
+ assert.match(await reader.html(1), /Traced app/);
419
+ const dropped = reader.events.find(
420
+ (event) => event.kind === TRACE_EVENT.DROPPED
421
+ );
422
+ assert.ok(
423
+ [
424
+ TRACE_DROP_REASON.PAGE_CLOSED,
425
+ TRACE_DROP_REASON.CAPTURE_FAILED,
426
+ ].includes(dropped.reason),
427
+ `unexpected reason ${dropped.reason}`
428
+ );
429
+ });
430
+
431
+ it('should record what a new document did while it was still loading', async () => {
432
+ const trace = await record({ mode: TRACE_MODE.CONTINUOUS });
433
+
434
+ // No checkpoint between the navigation and the change: the recorder has
435
+ // to already be in the new document when the document's own script runs,
436
+ // which is the interval issue #93 says used to be lost entirely.
437
+ await page.goto(`${server.baseUrl}/init`);
438
+ await trace.checkpoint('the new document initialized itself');
439
+ const stopped = await trace.stop();
440
+
441
+ const reader = await readTrace(stopped.path);
442
+ const records = await allChanges(reader);
443
+ const built = records.find((change) =>
444
+ (change.added ?? []).some(
445
+ (node) =>
446
+ node?.id === 'made-while-loading' ||
447
+ /made-while-loading/.test(node?.html ?? '')
448
+ )
449
+ );
450
+ assert.ok(
451
+ built,
452
+ `no initialization mutation among ${records.length} records`
453
+ );
454
+ const marked = records.find(
455
+ (change) =>
456
+ change.kind === 'attributes' &&
457
+ change.attribute === 'data-initialized'
458
+ );
459
+ assert.strictEqual(marked?.after, 'yes');
460
+ });
461
+
462
+ it('should record live control state as it changes, not only at checkpoints', async () => {
463
+ const trace = await record({ mode: TRACE_MODE.CONTINUOUS });
464
+
465
+ await page.click('#name');
466
+ await page.type('#name', 'ada');
467
+ await page.click('#terms');
468
+ await choose('#plan', 'pro');
469
+ await commander.evaluate(() => window.scrollTo(0, 400));
470
+ // A scroll event is dispatched after the scroll, not during it, so the
471
+ // checkpoint that drains the queue waits for a frame to be painted.
472
+ await commander.evaluate(
473
+ () =>
474
+ new Promise((resolve) => {
475
+ window.requestAnimationFrame(() =>
476
+ window.requestAnimationFrame(() => resolve(true))
477
+ );
478
+ })
479
+ );
480
+ await trace.checkpoint('the form was filled in');
481
+ const stopped = await trace.stop();
482
+
483
+ const reader = await readTrace(stopped.path);
484
+ const live = (await allChanges(reader)).filter(
485
+ (change) => change.kind === 'live-state'
486
+ );
487
+ const properties = new Set(live.map((change) => change.property));
488
+ for (const property of [
489
+ 'value',
490
+ 'checked',
491
+ 'selected',
492
+ 'focus',
493
+ 'scroll',
494
+ ]) {
495
+ assert.ok(
496
+ properties.has(property),
497
+ `no ${property} record among ${[...properties].join(', ')}`
498
+ );
499
+ }
500
+
501
+ // Each keystroke is its own state: a stepwise replay of typing has to be
502
+ // able to show "a", then "ad", then "ada".
503
+ const typed = live.filter(
504
+ (change) =>
505
+ change.property === 'value' && change.target?.path === '#name'
506
+ );
507
+ assert.deepStrictEqual(
508
+ typed.map((change) => change.after),
509
+ ['a', 'ad', 'ada']
510
+ );
511
+ const checked = live.find((change) => change.property === 'checked');
512
+ assert.strictEqual(checked.after, true);
513
+ const selected = live.find((change) => change.property === 'selected');
514
+ assert.deepStrictEqual(selected.after, ['pro']);
515
+ const scrolled = live
516
+ .filter((change) => change.property === 'scroll')
517
+ .pop();
518
+ assert.strictEqual(scrolled.after.top, 400);
519
+ });
520
+
521
+ it('should replay an insertion, a move, a removal and a replacement exactly', async () => {
522
+ const trace = await record({ mode: TRACE_MODE.CONTINUOUS });
523
+
524
+ await page.click('#insert-first');
525
+ await page.click('#move-last');
526
+ await page.click('#remove-one');
527
+ await page.click('#replace-subtree');
528
+ const settled = await trace.checkpoint('the list settled');
529
+ const stopped = await trace.stop();
530
+ const viewer = await writeTraceViewer(stopped.path);
531
+
532
+ await page.goto(`file://${viewer}`);
533
+ const batches = await page.evaluate(
534
+ () =>
535
+ JSON.parse(document.getElementById('trace-data').textContent)
536
+ .mutations[1].length
537
+ );
538
+ assert.ok(batches > 0, 'the interval recorded nothing to replay');
539
+ for (let step = 0; step < batches; step++) {
540
+ await page.click('#step-forward');
541
+ }
542
+
543
+ // Both documents reach the comparison as strings this test holds: the
544
+ // replayed one read out of the frame, the recorded one read from the
545
+ // bundle on disk. Taking text straight back out of the page and parsing
546
+ // it as HTML in the same breath is what CodeQL reports as
547
+ // `js/xss-through-dom` (alert 20 on this pull request), and reading the
548
+ // recorded side from the bundle is the stronger check anyway: it holds
549
+ // the viewer to the file it was built from rather than to the copy it
550
+ // embedded in itself.
551
+ const replayedHtml = await page.evaluate(() =>
552
+ document.getElementById('stage').getAttribute('srcdoc')
553
+ );
554
+ const capturedHtml = await (
555
+ await readTrace(stopped.path)
556
+ ).html(settled.index);
557
+ assert.ok(capturedHtml, 'the bundle kept no HTML for the checkpoint');
558
+
559
+ const compared = await page.evaluate(
560
+ ([replayedSource, capturedSource]) => {
561
+ // The viewer highlights what it touched and annotates what a static
562
+ // document cannot show; neither is part of the recorded DOM.
563
+ const clean = (source) => {
564
+ const parsed = new DOMParser().parseFromString(source, 'text/html');
565
+ for (const element of parsed.querySelectorAll('*')) {
566
+ element.removeAttribute('style');
567
+ for (const attribute of [...element.attributes]) {
568
+ if (attribute.name.startsWith('data-bc-')) {
569
+ element.removeAttribute(attribute.name);
570
+ }
571
+ }
572
+ }
573
+ return parsed;
574
+ };
575
+ const replayed = clean(replayedSource);
576
+ const captured = clean(capturedSource);
577
+ const sub = (parsed, selector) =>
578
+ parsed.querySelector(selector)?.innerHTML.trim() ?? null;
579
+ return {
580
+ items: [sub(replayed, '#items'), sub(captured, '#items')],
581
+ subtree: [sub(replayed, '#subtree'), sub(captured, '#subtree')],
582
+ };
583
+ },
584
+ [replayedHtml, capturedHtml]
585
+ );
586
+
587
+ assert.strictEqual(compared.items[0], compared.items[1]);
588
+ assert.strictEqual(compared.subtree[0], compared.subtree[1]);
589
+ assert.match(compared.items[1], /id="item-b"[\s\S]*id="inserted"/);
590
+ assert.doesNotMatch(compared.items[1], /id="item-a"/);
591
+ assert.match(compared.subtree[1], /id="new-child"/);
592
+ });
593
+
594
+ it('should resolve every record to one unambiguous page and frame', async () => {
595
+ const second = await browser.newPage();
596
+ await second.goto(`${server.baseUrl}/`);
597
+ const here = await record({ mode: TRACE_MODE.CONTINUOUS });
598
+ const there = await startTrace({
599
+ page: second,
600
+ output: path.join(artifacts, `owners-${++runs}`),
601
+ screenshots: false,
602
+ mode: TRACE_MODE.CONTINUOUS,
603
+ });
604
+
605
+ // Opening a second tab pushes the first into the background, where
606
+ // requestAnimationFrame stops firing and Puppeteer's click, which waits
607
+ // for the element to settle, never returns. Whichever page is being
608
+ // driven is the one in front.
609
+ await page.bringToFront();
610
+ await page.click('#add');
611
+ await second.bringToFront();
612
+ await second.click('#add');
613
+ await page.bringToFront();
614
+ // A change inside the iframe, so this run holds two documents of one page
615
+ // as well as two pages of one browser.
616
+ await commander.evaluate(() => {
617
+ const framed = document.getElementById('inner').contentDocument;
618
+ framed.body.appendChild(framed.createElement('span'));
619
+ });
620
+ await here.checkpoint('both pages moved');
621
+ await there.checkpoint('both pages moved');
622
+ const stoppedHere = await here.stop();
623
+ const stoppedThere = await there.stop();
624
+ await second.close();
625
+
626
+ const readHere = await readTrace(stoppedHere.path);
627
+ const readThere = await readTrace(stoppedThere.path);
628
+ const pagesOf = (events) => new Set(events.map((event) => event.pageId));
629
+ assert.strictEqual(pagesOf(readHere.events).size, 1);
630
+ assert.strictEqual(pagesOf(readThere.events).size, 1);
631
+ assert.notStrictEqual(
632
+ [...pagesOf(readHere.events)][0],
633
+ [...pagesOf(readThere.events)][0]
634
+ );
635
+ for (const event of [...readHere.events, ...readThere.events]) {
636
+ assert.ok(event.traceId, `an event with no trace: ${event.kind}`);
637
+ assert.ok(
638
+ event.navigationId,
639
+ `an event with no document: ${event.kind}`
640
+ );
641
+ }
642
+
643
+ const batches = [];
644
+ for (let index = 0; index <= readHere.checkpoints.length; index++) {
645
+ batches.push(...(await readHere.mutations(index)));
646
+ }
647
+ assert.ok(
648
+ batches.every(
649
+ (batch) => batch.frameId && batch.pageId && batch.traceId
650
+ ),
651
+ 'a batch arrived without an owner'
652
+ );
653
+ assert.ok(
654
+ batches.some((batch) => batch.mainFrame === false),
655
+ 'nothing was recorded inside the iframe'
656
+ );
657
+ assert.ok(
658
+ new Set(batches.map((batch) => batch.frameId)).size >= 2,
659
+ 'the page and its iframe reported the same frame'
660
+ );
661
+ });
662
+
663
+ it('should leave a viewer that opens with no network and replays the run', async () => {
664
+ const trace = await record({ mode: TRACE_MODE.CONTINUOUS });
665
+ await recordAnAppUpdate(trace);
666
+ const stopped = await trace.stop();
667
+
668
+ const viewer = await writeTraceViewer(stopped.path);
669
+ assert.strictEqual(viewer, path.join(stopped.path, TRACE_FILES.VIEWER));
670
+ const requested = [];
671
+ const onRequest = (request) => requested.push(request.url());
672
+ page.on('request', onRequest);
673
+ await page.goto(`file://${viewer}`);
674
+
675
+ // The viewer opens on the run's base snapshot, and nothing happened
676
+ // before it by definition (issue #93), so the interval with the app's
677
+ // changes in it is the one that starts at the checkpoint named here.
678
+ const selected = await page.evaluate(() => {
679
+ const entry = [...document.querySelectorAll('#timeline li')].find(
680
+ (li) => li.querySelector('.what').textContent === 'loaded'
681
+ );
682
+ if (!entry) {
683
+ return false;
684
+ }
685
+ entry.click();
686
+ return true;
687
+ });
688
+ assert.ok(selected, 'the timeline has no checkpoint named "loaded"');
689
+
690
+ const shown = await page.evaluate(() => ({
691
+ events: document.querySelectorAll('#timeline li').length,
692
+ step: document.getElementById('step').textContent,
693
+ details: document.getElementById('details').textContent,
694
+ replay: document.querySelector('.meta.replay').textContent,
695
+ framed: document.getElementById('stage').getAttribute('srcdoc'),
696
+ }));
697
+ assert.ok(shown.events > 0, 'the timeline is empty');
698
+ assert.match(shown.details, /Traced app/);
699
+ assert.match(shown.step, /[1-9]\d* mutation batches/);
700
+ assert.match(shown.framed, /id="items"/);
701
+ // The viewer says what it can and cannot reproduce rather than implying
702
+ // the recording is the whole run (issue #93).
703
+ assert.match(shown.replay, /partial diagnostic replay/);
704
+ assert.match(shown.replay, /live control state/);
705
+
706
+ // Stepping replays the recorded mutations into the captured document.
707
+ // One step is one batch, and a click reports the focus and the scroll it
708
+ // caused as well as what it changed in the DOM (issue #93), so the run's
709
+ // changes are reached by stepping to the end of the interval rather than
710
+ // by assuming which batch they landed in.
711
+ const batches = Number(shown.step.match(/^(\d+) mutation batches$/)[1]);
712
+ assert.ok(batches > 0, `nothing to step through: ${shown.step}`);
713
+ let replayed = null;
714
+ for (let step = 0; step < batches; step++) {
715
+ await page.click('#step-forward');
716
+ replayed = await page.evaluate(() => ({
717
+ step: document.getElementById('step').textContent,
718
+ framed: document.getElementById('stage').getAttribute('srcdoc'),
719
+ }));
720
+ assert.match(replayed.step, /batch \d+ of \d+/);
721
+ }
722
+ assert.match(replayed.framed, /id="item-1"/);
723
+ assert.match(replayed.framed, /data-state="touched"/);
724
+ page.off('request', onRequest);
725
+ // Opening a colleague's trace must not call anyone's server.
726
+ assert.deepStrictEqual(
727
+ requested.filter((url) => /^https?:/.test(url)),
728
+ []
729
+ );
730
+ });
731
+ });
732
+ }
733
+
734
+ describe(
735
+ 'E2E - privacy-aware portable traces (issue #87)',
736
+ { skip: !process.env.RUN_E2E, timeout: 600000 },
737
+ () => {
738
+ for (const engine of ENGINES) {
739
+ describeTraces(engine);
740
+ }
741
+ }
742
+ );