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
|
@@ -17,9 +17,14 @@ import fs from 'node:fs/promises';
|
|
|
17
17
|
import os from 'node:os';
|
|
18
18
|
import path from 'node:path';
|
|
19
19
|
|
|
20
|
-
import { openBrowserCdpSession } from '../../src/downloads/sources.js';
|
|
21
|
-
import { launchE2EBrowser } from '../helpers/e2e-browser.js';
|
|
22
20
|
import {
|
|
21
|
+
browserContextIdForPage,
|
|
22
|
+
openBrowserCdpSession,
|
|
23
|
+
} from '../../src/downloads/sources.js';
|
|
24
|
+
import { makeBrowserCommander } from '../../src/factory.js';
|
|
25
|
+
import { launchE2EBrowser, SANDBOX_ARGS } from '../helpers/e2e-browser.js';
|
|
26
|
+
import {
|
|
27
|
+
BLOB_PDF_BODY,
|
|
23
28
|
PDF_BODY,
|
|
24
29
|
REPORT_BODY,
|
|
25
30
|
startDownloadServer,
|
|
@@ -98,6 +103,52 @@ function describeDownloads(engine) {
|
|
|
98
103
|
assert.strictEqual(await fs.readFile(artifact.path, 'utf8'), REPORT_BODY);
|
|
99
104
|
});
|
|
100
105
|
|
|
106
|
+
it('should not publish a completed download before its bytes are readable', async () => {
|
|
107
|
+
// Issue #92: Chromium reports `Browser.downloadProgress` with
|
|
108
|
+
// `state: "completed"` before the GUID-named staging file is readable,
|
|
109
|
+
// and the manager then failed with
|
|
110
|
+
// `ENOENT ... .browser-commander-staging/<guid>`. A PDF the page builds
|
|
111
|
+
// itself is the shape the issue reported.
|
|
112
|
+
const completed = [];
|
|
113
|
+
const failures = [];
|
|
114
|
+
downloads.on('completed', (artifact) => completed.push(artifact));
|
|
115
|
+
downloads.on('failed', (artifact) => failures.push(artifact));
|
|
116
|
+
|
|
117
|
+
const artifact = await captureClickOn('#blob-pdf');
|
|
118
|
+
|
|
119
|
+
assert.deepStrictEqual(failures, []);
|
|
120
|
+
assert.strictEqual(artifact.state, 'completed');
|
|
121
|
+
assert.strictEqual(path.basename(artifact.path), 'generated.pdf');
|
|
122
|
+
assert.strictEqual(
|
|
123
|
+
await fs.readFile(artifact.path, 'utf8'),
|
|
124
|
+
BLOB_PDF_BODY
|
|
125
|
+
);
|
|
126
|
+
assert.strictEqual(artifact.bytes, Buffer.byteLength(BLOB_PDF_BODY));
|
|
127
|
+
|
|
128
|
+
const published = completed.filter(
|
|
129
|
+
(entry) => entry.path === artifact.path
|
|
130
|
+
);
|
|
131
|
+
assert.strictEqual(
|
|
132
|
+
published.length,
|
|
133
|
+
1,
|
|
134
|
+
`exactly one completed artifact should be published: ${published.length}`
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
const staged = await fs
|
|
138
|
+
.readdir(path.join(directory, '.browser-commander-staging'))
|
|
139
|
+
.catch(() => []);
|
|
140
|
+
assert.deepStrictEqual(
|
|
141
|
+
staged,
|
|
142
|
+
[],
|
|
143
|
+
`no staged file should survive a saved download: ${staged}`
|
|
144
|
+
);
|
|
145
|
+
const entries = await fs.readdir(directory);
|
|
146
|
+
assert.ok(
|
|
147
|
+
!entries.some((entry) => entry.endsWith('.partial')),
|
|
148
|
+
`no partial file should be left behind: ${entries}`
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
|
|
101
152
|
it('should save a navigation that turned into a download', async () => {
|
|
102
153
|
const artifact = await captureClickOn('#navigating');
|
|
103
154
|
|
|
@@ -155,9 +206,16 @@ function describeDownloads(engine) {
|
|
|
155
206
|
// than through the engine's download API. Issue #88 asks for "one
|
|
156
207
|
// lifecycle for automated *and* manual downloads".
|
|
157
208
|
const session = await openBrowserCdpSession({ engine, browser, page });
|
|
209
|
+
const browserContextId = await browserContextIdForPage({
|
|
210
|
+
engine,
|
|
211
|
+
browser,
|
|
212
|
+
page,
|
|
213
|
+
browserSession: session,
|
|
214
|
+
});
|
|
158
215
|
const captured = downloads.capture({ timeout: 20000 });
|
|
159
216
|
await session.send('Target.createTarget', {
|
|
160
217
|
url: `${server.baseUrl}/file/manual.pdf`,
|
|
218
|
+
...(browserContextId ? { browserContextId } : {}),
|
|
161
219
|
});
|
|
162
220
|
|
|
163
221
|
const artifact = await captured;
|
|
@@ -184,5 +242,71 @@ describe(
|
|
|
184
242
|
for (const engine of ENGINES) {
|
|
185
243
|
describeDownloads(engine);
|
|
186
244
|
}
|
|
245
|
+
|
|
246
|
+
it('should persist a Blob from browser.newPage() (issue #97)', async () => {
|
|
247
|
+
const { chromium } = await import('playwright');
|
|
248
|
+
const directory = await fs.mkdtemp(
|
|
249
|
+
path.join(os.tmpdir(), 'bc-e2e-context-downloads-')
|
|
250
|
+
);
|
|
251
|
+
const browser = await chromium.launch({
|
|
252
|
+
headless: true,
|
|
253
|
+
args: SANDBOX_ARGS,
|
|
254
|
+
});
|
|
255
|
+
const page = await browser.newPage();
|
|
256
|
+
const commander = makeBrowserCommander({
|
|
257
|
+
page,
|
|
258
|
+
enableNetworkTracking: false,
|
|
259
|
+
enableNavigationManager: false,
|
|
260
|
+
enableDialogManager: false,
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
try {
|
|
264
|
+
const downloads = await commander.configureDownloads({
|
|
265
|
+
directory,
|
|
266
|
+
persist: true,
|
|
267
|
+
});
|
|
268
|
+
const completed = [];
|
|
269
|
+
downloads.on('completed', (artifact) => completed.push(artifact));
|
|
270
|
+
await page.setContent(`
|
|
271
|
+
<button id="download">Download</button>
|
|
272
|
+
<script>
|
|
273
|
+
document.querySelector('#download').addEventListener('click', () => {
|
|
274
|
+
const blob = new Blob([${JSON.stringify(BLOB_PDF_BODY)}], {
|
|
275
|
+
type: 'application/pdf',
|
|
276
|
+
});
|
|
277
|
+
const anchor = document.createElement('a');
|
|
278
|
+
anchor.href = URL.createObjectURL(blob);
|
|
279
|
+
anchor.download = 'declaration.pdf';
|
|
280
|
+
anchor.click();
|
|
281
|
+
});
|
|
282
|
+
</script>
|
|
283
|
+
`);
|
|
284
|
+
|
|
285
|
+
const artifact = await downloads.capture({
|
|
286
|
+
action: page.click.bind(page, '#download'),
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
assert.strictEqual(artifact.state, 'completed');
|
|
290
|
+
assert.strictEqual(path.basename(artifact.path), 'declaration.pdf');
|
|
291
|
+
assert.strictEqual(
|
|
292
|
+
await fs.readFile(artifact.path, 'utf8'),
|
|
293
|
+
BLOB_PDF_BODY
|
|
294
|
+
);
|
|
295
|
+
assert.strictEqual(completed.length, 1);
|
|
296
|
+
const staged = await fs.readdir(
|
|
297
|
+
path.join(directory, '.browser-commander-staging')
|
|
298
|
+
);
|
|
299
|
+
assert.deepStrictEqual(staged, []);
|
|
300
|
+
assert.ok(
|
|
301
|
+
!(await fs.readdir(directory)).some((entry) =>
|
|
302
|
+
entry.endsWith('.partial')
|
|
303
|
+
)
|
|
304
|
+
);
|
|
305
|
+
} finally {
|
|
306
|
+
await commander.destroy();
|
|
307
|
+
await browser.close();
|
|
308
|
+
await fs.rm(directory, { recursive: true, force: true });
|
|
309
|
+
}
|
|
310
|
+
});
|
|
187
311
|
}
|
|
188
312
|
);
|
|
@@ -190,6 +190,20 @@ function describeTraces(engine) {
|
|
|
190
190
|
*/
|
|
191
191
|
const changesIn = (batches) => batches.flatMap((batch) => batch.records);
|
|
192
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
|
+
|
|
193
207
|
it('should capture the state a user made, not the state the server sent', async () => {
|
|
194
208
|
const trace = await record({ screenshots: 'checkpoints' });
|
|
195
209
|
|
|
@@ -232,7 +246,12 @@ function describeTraces(engine) {
|
|
|
232
246
|
const stopped = await trace.stop();
|
|
233
247
|
|
|
234
248
|
const reader = await readTrace(stopped.path);
|
|
235
|
-
|
|
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'));
|
|
236
255
|
const added = changesIn(batches)
|
|
237
256
|
.flatMap((change) => change.added ?? [])
|
|
238
257
|
.filter((node) => node?.id);
|
|
@@ -253,14 +272,19 @@ function describeTraces(engine) {
|
|
|
253
272
|
|
|
254
273
|
// The navigation threw the in-page recorder away with the document it
|
|
255
274
|
// lived in; a batch after it proves a new one took its place.
|
|
256
|
-
const afterNavigating = await reader.mutations(
|
|
275
|
+
const afterNavigating = await reader.mutations(
|
|
276
|
+
indexOf('the next document')
|
|
277
|
+
);
|
|
257
278
|
assert.ok(
|
|
258
279
|
changesIn(afterNavigating)
|
|
259
280
|
.flatMap((change) => change.added ?? [])
|
|
260
281
|
.some((node) => node?.id === 'grown'),
|
|
261
282
|
`expected the grown item, got ${JSON.stringify(afterNavigating)}`
|
|
262
283
|
);
|
|
263
|
-
assert.match(
|
|
284
|
+
assert.match(
|
|
285
|
+
(await reader.state(indexOf('the next document'))).url,
|
|
286
|
+
/\/next$/
|
|
287
|
+
);
|
|
264
288
|
});
|
|
265
289
|
|
|
266
290
|
it('should put everything that happened on one ordered timeline', async () => {
|
|
@@ -404,6 +428,238 @@ function describeTraces(engine) {
|
|
|
404
428
|
);
|
|
405
429
|
});
|
|
406
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
|
+
|
|
407
663
|
it('should leave a viewer that opens with no network and replays the run', async () => {
|
|
408
664
|
const trace = await record({ mode: TRACE_MODE.CONTINUOUS });
|
|
409
665
|
await recordAnAppUpdate(trace);
|
|
@@ -416,26 +672,55 @@ function describeTraces(engine) {
|
|
|
416
672
|
page.on('request', onRequest);
|
|
417
673
|
await page.goto(`file://${viewer}`);
|
|
418
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
|
+
|
|
419
690
|
const shown = await page.evaluate(() => ({
|
|
420
691
|
events: document.querySelectorAll('#timeline li').length,
|
|
421
692
|
step: document.getElementById('step').textContent,
|
|
422
693
|
details: document.getElementById('details').textContent,
|
|
694
|
+
replay: document.querySelector('.meta.replay').textContent,
|
|
423
695
|
framed: document.getElementById('stage').getAttribute('srcdoc'),
|
|
424
696
|
}));
|
|
425
697
|
assert.ok(shown.events > 0, 'the timeline is empty');
|
|
426
698
|
assert.match(shown.details, /Traced app/);
|
|
427
699
|
assert.match(shown.step, /[1-9]\d* mutation batches/);
|
|
428
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/);
|
|
429
705
|
|
|
430
706
|
// Stepping replays the recorded mutations into the captured document.
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
})
|
|
437
|
-
|
|
438
|
-
|
|
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"/);
|
|
439
724
|
page.off('request', onRequest);
|
|
440
725
|
// Opening a colleague's trace must not call anyone's server.
|
|
441
726
|
assert.deepStrictEqual(
|
|
@@ -15,6 +15,15 @@ export const REPORT_BODY = 'quarterly report body';
|
|
|
15
15
|
/** A body only its leading bytes identify as a PDF. */
|
|
16
16
|
export const PDF_BODY = `%PDF-1.7\n${REPORT_BODY}`;
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* A PDF large enough that Chromium writes it in more than one chunk.
|
|
20
|
+
*
|
|
21
|
+
* Issue #92 is a race between `Browser.downloadProgress` reporting
|
|
22
|
+
* `completed` and the staged bytes being readable, so a body the browser can
|
|
23
|
+
* write in a single instant is the one body that would not exercise it.
|
|
24
|
+
*/
|
|
25
|
+
export const BLOB_PDF_BODY = `%PDF-1.7\n${'blob pdf payload line\n'.repeat(40000)}%%EOF\n`;
|
|
26
|
+
|
|
18
27
|
/** A page offering the download shapes the issue lists. */
|
|
19
28
|
export const DOWNLOADS_PAGE = `<!doctype html>
|
|
20
29
|
<html><body style="margin:0">
|
|
@@ -23,17 +32,28 @@ export const DOWNLOADS_PAGE = `<!doctype html>
|
|
|
23
32
|
<a id="unnamed" href="/unnamed" download>Download without a name</a>
|
|
24
33
|
<a id="broken" href="/aborted" download>Download a broken file</a>
|
|
25
34
|
<button id="blob">Build and download</button>
|
|
35
|
+
<button id="blob-pdf">Build and download a PDF</button>
|
|
26
36
|
<script>
|
|
27
|
-
|
|
28
|
-
const
|
|
29
|
-
type: 'text/plain',
|
|
30
|
-
});
|
|
31
|
-
const url = URL.createObjectURL(blob);
|
|
37
|
+
const downloadBlob = (parts, type, name) => {
|
|
38
|
+
const url = URL.createObjectURL(new Blob(parts, { type }));
|
|
32
39
|
const anchor = document.createElement('a');
|
|
33
40
|
anchor.href = url;
|
|
34
|
-
anchor.download =
|
|
41
|
+
anchor.download = name;
|
|
35
42
|
document.body.appendChild(anchor);
|
|
36
43
|
anchor.click();
|
|
44
|
+
};
|
|
45
|
+
document.getElementById('blob').addEventListener('click', () => {
|
|
46
|
+
downloadBlob([${JSON.stringify(REPORT_BODY)}], 'text/plain', 'generated.txt');
|
|
47
|
+
});
|
|
48
|
+
document.getElementById('blob-pdf').addEventListener('click', () => {
|
|
49
|
+
// Built from a repeated unit rather than shipped as one literal: the
|
|
50
|
+
// page has to produce the bytes, not receive them over the wire.
|
|
51
|
+
const line = ${JSON.stringify('blob pdf payload line\n')};
|
|
52
|
+
downloadBlob(
|
|
53
|
+
['%PDF-1.7\\n', line.repeat(40000), '%%EOF\\n'],
|
|
54
|
+
'application/pdf',
|
|
55
|
+
'generated.pdf'
|
|
56
|
+
);
|
|
37
57
|
});
|
|
38
58
|
</script>
|
|
39
59
|
</body></html>`;
|