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
package/src/traces/recorder.js
CHANGED
|
@@ -7,13 +7,12 @@
|
|
|
7
7
|
* request, so every caller does not have to.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import {
|
|
11
|
-
captureSnapshotInPage,
|
|
12
|
-
drainMutationsInPage,
|
|
13
|
-
installMutationRecorderInPage,
|
|
14
|
-
RECORDER_GLOBAL,
|
|
15
|
-
} from './page-capture.js';
|
|
10
|
+
import { captureSnapshotInPage } from './page-capture.js';
|
|
16
11
|
import { openTraceBundle } from './bundle.js';
|
|
12
|
+
import { openTraceLinks } from './links.js';
|
|
13
|
+
import { createTraceIdentity } from './identity.js';
|
|
14
|
+
import { withDeadline } from './deadline.js';
|
|
15
|
+
import { createMutationStream } from './mutation-stream.js';
|
|
17
16
|
import { attachTimelineObservers } from './observers.js';
|
|
18
17
|
import {
|
|
19
18
|
normalizePrivacyOptions,
|
|
@@ -24,17 +23,25 @@ import {
|
|
|
24
23
|
import {
|
|
25
24
|
createManifest,
|
|
26
25
|
DEFAULT_CAPTURE_TIMEOUT,
|
|
26
|
+
TRACE_CHECKPOINT_REASON,
|
|
27
27
|
TRACE_DROP_REASON,
|
|
28
28
|
TRACE_EVENT,
|
|
29
29
|
TRACE_EVENT_SOURCES,
|
|
30
30
|
TRACE_MODE,
|
|
31
31
|
TRACE_OUTCOME,
|
|
32
|
+
TRACE_SCHEMA_VERSION,
|
|
32
33
|
} from './schema.js';
|
|
33
34
|
|
|
34
35
|
/** DOM capture defaults: what a checkpoint holds unless the caller narrows it. */
|
|
35
36
|
const DEFAULT_DOM_OPTIONS = Object.freeze({
|
|
36
37
|
html: true,
|
|
37
38
|
liveControlState: true,
|
|
39
|
+
/**
|
|
40
|
+
* Record what typing, checking, selecting, focus and scroll did, as it
|
|
41
|
+
* happens (issue #93). Reading it only at checkpoints meant a replay could
|
|
42
|
+
* show an empty field a moment after the user finished filling it in.
|
|
43
|
+
*/
|
|
44
|
+
liveState: true,
|
|
38
45
|
mutations: false,
|
|
39
46
|
openShadowRoots: true,
|
|
40
47
|
});
|
|
@@ -68,32 +75,14 @@ function normalizeEvents(events) {
|
|
|
68
75
|
}
|
|
69
76
|
|
|
70
77
|
/**
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
* A page that stopped answering must cost the trace one record, not the run.
|
|
78
|
+
* Reject a malformed side export before the bundle opens any file handles.
|
|
74
79
|
*
|
|
75
|
-
* @param {
|
|
76
|
-
* @
|
|
77
|
-
* @param {string} what - Name used in the timeout message
|
|
78
|
-
* @returns {Promise<*>} The capture's result
|
|
80
|
+
* @param {Object|null} links - Optional Links Notation export options
|
|
81
|
+
* @returns {void}
|
|
79
82
|
*/
|
|
80
|
-
|
|
81
|
-
if (
|
|
82
|
-
|
|
83
|
-
}
|
|
84
|
-
let timer;
|
|
85
|
-
try {
|
|
86
|
-
return await Promise.race([
|
|
87
|
-
work,
|
|
88
|
-
new Promise((_resolve, reject) => {
|
|
89
|
-
timer = setTimeout(
|
|
90
|
-
() => reject(new Error(`${what} timed out after ${timeoutMs}ms`)),
|
|
91
|
-
timeoutMs
|
|
92
|
-
);
|
|
93
|
-
}),
|
|
94
|
-
]);
|
|
95
|
-
} finally {
|
|
96
|
-
clearTimeout(timer);
|
|
83
|
+
function validateLinksOptions(links) {
|
|
84
|
+
if (links && (typeof links.output !== 'string' || links.output === '')) {
|
|
85
|
+
throw new Error('trace links require an output path');
|
|
97
86
|
}
|
|
98
87
|
}
|
|
99
88
|
|
|
@@ -109,11 +98,13 @@ export async function startTrace(options = {}) {
|
|
|
109
98
|
page = commander?.page,
|
|
110
99
|
output,
|
|
111
100
|
mode = TRACE_MODE.CHECKPOINTS,
|
|
101
|
+
initialCheckpoint,
|
|
112
102
|
screenshots = 'checkpoints',
|
|
113
103
|
dom = {},
|
|
114
104
|
events,
|
|
115
105
|
privacy = {},
|
|
116
106
|
limits = {},
|
|
107
|
+
links = null,
|
|
117
108
|
strict = false,
|
|
118
109
|
captureTimeoutMs = DEFAULT_CAPTURE_TIMEOUT,
|
|
119
110
|
commanderVersion = null,
|
|
@@ -132,11 +123,50 @@ export async function startTrace(options = {}) {
|
|
|
132
123
|
}
|
|
133
124
|
const eventSources = normalizeEvents(events);
|
|
134
125
|
const privacyOptions = normalizePrivacyOptions(privacy);
|
|
126
|
+
// Validate the optional side export before opening the authoritative bundle.
|
|
127
|
+
// Otherwise a rejected start (for example `links: {}`) strands the bundle's
|
|
128
|
+
// events handle because no running trace is returned for the caller to stop.
|
|
129
|
+
validateLinksOptions(links);
|
|
135
130
|
const engine = commander?.engine ?? null;
|
|
136
|
-
|
|
137
|
-
|
|
131
|
+
const identity = createTraceIdentity({ page });
|
|
132
|
+
|
|
133
|
+
// A continuous trace records changes, and a change is only meaningful against
|
|
134
|
+
// something. Taking the base snapshot automatically is the safe default,
|
|
135
|
+
// because a trace that starts mid-air replays as a blank page (issue #93); a
|
|
136
|
+
// checkpoints-only trace is a list of moments the caller named, so nothing is
|
|
137
|
+
// added to it uninvited. Passing a string names the base checkpoint.
|
|
138
|
+
const baseCheckpoint =
|
|
139
|
+
initialCheckpoint ?? recorderMode === TRACE_MODE.CONTINUOUS;
|
|
140
|
+
const baseCheckpointName =
|
|
141
|
+
typeof baseCheckpoint === 'string' ? baseCheckpoint : 'initial';
|
|
142
|
+
|
|
143
|
+
// The Links Notation export is a second view of the same records, written
|
|
144
|
+
// as they are recorded (issue #94), so a run that is killed leaves a
|
|
145
|
+
// readable export of everything that had happened. The bundle stays
|
|
146
|
+
// authoritative: nothing is written here that is not written there first.
|
|
147
|
+
let linksSink = null;
|
|
148
|
+
const bundle = await openTraceBundle({
|
|
149
|
+
output,
|
|
150
|
+
limits,
|
|
151
|
+
strict,
|
|
152
|
+
now,
|
|
153
|
+
onEvent: (event) => linksSink?.event(event),
|
|
154
|
+
});
|
|
138
155
|
const startedAt = new Date(now()).toISOString();
|
|
139
156
|
|
|
157
|
+
if (links) {
|
|
158
|
+
linksSink = await openTraceLinks({
|
|
159
|
+
output: links.output,
|
|
160
|
+
include: links.include,
|
|
161
|
+
bundlePath: bundle.root,
|
|
162
|
+
schemaVersion: TRACE_SCHEMA_VERSION,
|
|
163
|
+
mode: recorderMode,
|
|
164
|
+
engine,
|
|
165
|
+
startedAt,
|
|
166
|
+
commanderVersion,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
140
170
|
let stopped = false;
|
|
141
171
|
let checkpointIndex = 0;
|
|
142
172
|
const checkpoints = [];
|
|
@@ -156,6 +186,9 @@ export async function startTrace(options = {}) {
|
|
|
156
186
|
}
|
|
157
187
|
return await bundle.appendEvent({
|
|
158
188
|
kind,
|
|
189
|
+
// Who this happened to comes first so a payload that knows better - a
|
|
190
|
+
// drain that names the frame it came from, say - can say so.
|
|
191
|
+
...identity.owner(),
|
|
159
192
|
...redactValue(payload, privacyOptions),
|
|
160
193
|
});
|
|
161
194
|
}
|
|
@@ -169,61 +202,18 @@ export async function startTrace(options = {}) {
|
|
|
169
202
|
return await page.evaluate(fn, argument);
|
|
170
203
|
}
|
|
171
204
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
await bundle.drop({
|
|
185
|
-
reason: TRACE_DROP_REASON.CAPTURE_FAILED,
|
|
186
|
-
member: 'mutation-recorder',
|
|
187
|
-
detail: error.message,
|
|
188
|
-
});
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
async function drainMutations(index) {
|
|
193
|
-
if (!domOptions.mutations) {
|
|
194
|
-
return null;
|
|
195
|
-
}
|
|
196
|
-
try {
|
|
197
|
-
const drained = await withDeadline(
|
|
198
|
-
evaluateInPage(drainMutationsInPage, RECORDER_GLOBAL),
|
|
199
|
-
captureTimeoutMs,
|
|
200
|
-
'trace mutation drain'
|
|
201
|
-
);
|
|
202
|
-
if (drained?.dropped) {
|
|
203
|
-
await bundle.drop({
|
|
204
|
-
reason: TRACE_DROP_REASON.SIZE_LIMIT,
|
|
205
|
-
member: 'mutations',
|
|
206
|
-
detail: `${drained.dropped} records over the in-page queue limit`,
|
|
207
|
-
});
|
|
208
|
-
}
|
|
209
|
-
const member = await bundle.writeMutations(index, drained?.batches ?? []);
|
|
210
|
-
if (member) {
|
|
211
|
-
await record(TRACE_EVENT.MUTATIONS, {
|
|
212
|
-
member,
|
|
213
|
-
batches: drained.batches.length,
|
|
214
|
-
checkpoint: index,
|
|
215
|
-
});
|
|
216
|
-
}
|
|
217
|
-
return member;
|
|
218
|
-
} catch (error) {
|
|
219
|
-
await bundle.drop({
|
|
220
|
-
reason: TRACE_DROP_REASON.CAPTURE_FAILED,
|
|
221
|
-
member: 'mutations',
|
|
222
|
-
detail: error.message,
|
|
223
|
-
});
|
|
224
|
-
return null;
|
|
225
|
-
}
|
|
226
|
-
}
|
|
205
|
+
const mutations = createMutationStream({
|
|
206
|
+
page,
|
|
207
|
+
bundle,
|
|
208
|
+
record,
|
|
209
|
+
identity,
|
|
210
|
+
note,
|
|
211
|
+
domOptions,
|
|
212
|
+
privacyOptions,
|
|
213
|
+
limits,
|
|
214
|
+
captureTimeoutMs,
|
|
215
|
+
evaluateInPage,
|
|
216
|
+
});
|
|
227
217
|
|
|
228
218
|
async function screenshot(reason) {
|
|
229
219
|
const wanted =
|
|
@@ -267,7 +257,7 @@ export async function startTrace(options = {}) {
|
|
|
267
257
|
|
|
268
258
|
// Mutations are drained first so the batches belong to the interval that
|
|
269
259
|
// ended here, not to the one that starts now.
|
|
270
|
-
await
|
|
260
|
+
await mutations.drain(index - 1 > 0 ? index - 1 : 0);
|
|
271
261
|
|
|
272
262
|
let captured = null;
|
|
273
263
|
try {
|
|
@@ -318,9 +308,10 @@ export async function startTrace(options = {}) {
|
|
|
318
308
|
checkpoints.push(entry);
|
|
319
309
|
await record(TRACE_EVENT.CHECKPOINT, entry);
|
|
320
310
|
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
|
|
311
|
+
// The init script covers every document created from here on; this covers
|
|
312
|
+
// a frame that was attached without one, which is cheap because installing
|
|
313
|
+
// over a recorder that is already observing does nothing.
|
|
314
|
+
await mutations.install();
|
|
324
315
|
|
|
325
316
|
return entry;
|
|
326
317
|
}
|
|
@@ -331,15 +322,31 @@ export async function startTrace(options = {}) {
|
|
|
331
322
|
eventSources,
|
|
332
323
|
record,
|
|
333
324
|
note,
|
|
325
|
+
identity,
|
|
334
326
|
});
|
|
335
327
|
|
|
328
|
+
// Registered before the first record, so a navigation that starts in the same
|
|
329
|
+
// tick as the trace does is still recorded from its first mutation.
|
|
330
|
+
const detachInitScript = await mutations.installPersistent();
|
|
331
|
+
if (detachInitScript) {
|
|
332
|
+
detachers.push(detachInitScript);
|
|
333
|
+
}
|
|
334
|
+
|
|
336
335
|
await record(TRACE_EVENT.TRACE_START, {
|
|
337
336
|
mode: recorderMode,
|
|
338
337
|
engine,
|
|
339
338
|
dom: domOptions,
|
|
340
339
|
events: eventSources,
|
|
340
|
+
initialCheckpoint: Boolean(baseCheckpoint),
|
|
341
341
|
});
|
|
342
|
-
await
|
|
342
|
+
await mutations.install();
|
|
343
|
+
|
|
344
|
+
if (baseCheckpoint) {
|
|
345
|
+
await checkpoint(baseCheckpointName, {
|
|
346
|
+
actor: 'recorder',
|
|
347
|
+
reason: TRACE_CHECKPOINT_REASON.INITIAL,
|
|
348
|
+
});
|
|
349
|
+
}
|
|
343
350
|
|
|
344
351
|
/**
|
|
345
352
|
* Stop recording and write the manifest.
|
|
@@ -353,7 +360,7 @@ export async function startTrace(options = {}) {
|
|
|
353
360
|
}
|
|
354
361
|
const { discard = false, error = null } = stopOptions;
|
|
355
362
|
|
|
356
|
-
await
|
|
363
|
+
await mutations.drain(checkpointIndex);
|
|
357
364
|
if (error) {
|
|
358
365
|
await record(TRACE_EVENT.PAGE_ERROR, {
|
|
359
366
|
message: error.message ?? String(error),
|
|
@@ -364,6 +371,10 @@ export async function startTrace(options = {}) {
|
|
|
364
371
|
await record(TRACE_EVENT.TRACE_STOP, { discarded: discard });
|
|
365
372
|
stopped = true;
|
|
366
373
|
|
|
374
|
+
// The documents that exist stop observing here; the engine's init-script
|
|
375
|
+
// registration is removed with the detachers below.
|
|
376
|
+
await mutations.stop();
|
|
377
|
+
|
|
367
378
|
for (const detach of detachers.reverse()) {
|
|
368
379
|
try {
|
|
369
380
|
await detach();
|
|
@@ -382,6 +393,14 @@ export async function startTrace(options = {}) {
|
|
|
382
393
|
engine,
|
|
383
394
|
dom: domOptions,
|
|
384
395
|
events: eventSources,
|
|
396
|
+
replay: {
|
|
397
|
+
checkpoints: true,
|
|
398
|
+
mutations: Boolean(domOptions.mutations),
|
|
399
|
+
childListPositions: Boolean(domOptions.mutations),
|
|
400
|
+
liveState:
|
|
401
|
+
Boolean(domOptions.mutations) && domOptions.liveState !== false,
|
|
402
|
+
identifiers: true,
|
|
403
|
+
},
|
|
385
404
|
privacy: {
|
|
386
405
|
redactSelectors: privacyOptions.redactSelectors,
|
|
387
406
|
redactAttributes: privacyOptions.redactAttributes,
|
|
@@ -392,17 +411,27 @@ export async function startTrace(options = {}) {
|
|
|
392
411
|
})
|
|
393
412
|
);
|
|
394
413
|
|
|
414
|
+
// Closed after the manifest, because the closing link reports the outcome
|
|
415
|
+
// the manifest settled on, and the control diffs are read back out of the
|
|
416
|
+
// finished bundle rather than kept in memory for the length of a run.
|
|
417
|
+
if (linksSink) {
|
|
418
|
+
await linksSink.close({ manifest, bundlePath: bundle.root });
|
|
419
|
+
}
|
|
420
|
+
|
|
395
421
|
const result = {
|
|
396
422
|
path: bundle.root,
|
|
397
423
|
manifest,
|
|
398
424
|
checkpoints: [...checkpoints],
|
|
399
|
-
problems: [...bundle.problems],
|
|
425
|
+
problems: [...bundle.problems, ...(linksSink?.problems ?? [])],
|
|
426
|
+
links: linksSink?.path ?? null,
|
|
400
427
|
};
|
|
401
428
|
stopped = result;
|
|
402
429
|
|
|
403
430
|
if (discard) {
|
|
404
431
|
const fs = await import('node:fs/promises');
|
|
405
432
|
await fs.rm(bundle.root, { recursive: true, force: true });
|
|
433
|
+
// An export of a bundle that no longer exists points at nothing.
|
|
434
|
+
await linksSink?.discard();
|
|
406
435
|
result.discarded = true;
|
|
407
436
|
}
|
|
408
437
|
|
|
@@ -411,6 +440,7 @@ export async function startTrace(options = {}) {
|
|
|
411
440
|
|
|
412
441
|
return {
|
|
413
442
|
path: bundle.root,
|
|
443
|
+
links: linksSink?.path ?? null,
|
|
414
444
|
mode: recorderMode,
|
|
415
445
|
checkpoint,
|
|
416
446
|
/**
|
package/src/traces/schema.js
CHANGED
|
@@ -11,8 +11,15 @@
|
|
|
11
11
|
*
|
|
12
12
|
* Readers refuse a major version they were not written for rather than
|
|
13
13
|
* guessing at the meaning of unknown records.
|
|
14
|
+
*
|
|
15
|
+
* Version 2 (issue #93) is what makes continuous replay complete: mutation
|
|
16
|
+
* batches name the frame they came from, child-list records carry the position
|
|
17
|
+
* a node was inserted at or removed from, and live control state - typing,
|
|
18
|
+
* checking, selecting, focus and scroll - is recorded as it happens instead of
|
|
19
|
+
* only at checkpoints. A version 1 reader given a version 2 bundle would
|
|
20
|
+
* quietly replay a different page, so it refuses instead.
|
|
14
21
|
*/
|
|
15
|
-
export const TRACE_SCHEMA_VERSION =
|
|
22
|
+
export const TRACE_SCHEMA_VERSION = 2;
|
|
16
23
|
|
|
17
24
|
/** Name every reader looks for inside a bundle. */
|
|
18
25
|
export const TRACE_FILES = Object.freeze({
|
|
@@ -49,6 +56,40 @@ export const TRACE_EVENT = Object.freeze({
|
|
|
49
56
|
DROPPED: 'dropped',
|
|
50
57
|
});
|
|
51
58
|
|
|
59
|
+
/** Record kinds inside a mutation batch. */
|
|
60
|
+
export const TRACE_MUTATION_KIND = Object.freeze({
|
|
61
|
+
ATTRIBUTES: 'attributes',
|
|
62
|
+
CHARACTER_DATA: 'characterData',
|
|
63
|
+
CHILD_LIST: 'childList',
|
|
64
|
+
/**
|
|
65
|
+
* A change to what an element holds rather than to the document.
|
|
66
|
+
*
|
|
67
|
+
* Typing into an input, checking a box, choosing an option, moving focus and
|
|
68
|
+
* scrolling all change what the user sees and none of them mutate the DOM,
|
|
69
|
+
* so no observer reports them and replay skipped them entirely (issue #93).
|
|
70
|
+
*/
|
|
71
|
+
LIVE_STATE: 'live-state',
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
/** What a `live-state` record changed. */
|
|
75
|
+
export const TRACE_LIVE_STATE = Object.freeze({
|
|
76
|
+
VALUE: 'value',
|
|
77
|
+
CHECKED: 'checked',
|
|
78
|
+
SELECTED: 'selected',
|
|
79
|
+
FOCUS: 'focus',
|
|
80
|
+
SCROLL: 'scroll',
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
/** Why a checkpoint was taken. */
|
|
84
|
+
export const TRACE_CHECKPOINT_REASON = Object.freeze({
|
|
85
|
+
/** The base snapshot every later record is a change against. */
|
|
86
|
+
INITIAL: 'initial',
|
|
87
|
+
/** A caller named this moment. */
|
|
88
|
+
CHECKPOINT: 'checkpoint',
|
|
89
|
+
/** The run failed here. */
|
|
90
|
+
FAILURE: 'failure',
|
|
91
|
+
});
|
|
92
|
+
|
|
52
93
|
/** Event families a caller can subscribe the recorder to. */
|
|
53
94
|
export const TRACE_EVENT_SOURCES = Object.freeze([
|
|
54
95
|
'navigation',
|
|
@@ -101,6 +142,27 @@ export function sequenceName(index) {
|
|
|
101
142
|
return String(index).padStart(4, '0');
|
|
102
143
|
}
|
|
103
144
|
|
|
145
|
+
/**
|
|
146
|
+
* What a bundle's records let a viewer reproduce.
|
|
147
|
+
*
|
|
148
|
+
* Issue #93 asked for the viewer to stop claiming more than it can do. Saying
|
|
149
|
+
* it in the manifest is better than saying it in the viewer's markup: a bundle
|
|
150
|
+
* recorded by an older version is honestly described by its own manifest, and
|
|
151
|
+
* any of the three readers can tell a caller what they are looking at.
|
|
152
|
+
*/
|
|
153
|
+
export const DEFAULT_REPLAY_SUPPORT = Object.freeze({
|
|
154
|
+
/** Checkpoints are snapshots, always replayable. */
|
|
155
|
+
checkpoints: true,
|
|
156
|
+
/** DOM mutations are recorded between checkpoints. */
|
|
157
|
+
mutations: false,
|
|
158
|
+
/** Child-list records say where a node went, so removals and moves replay. */
|
|
159
|
+
childListPositions: false,
|
|
160
|
+
/** Typing, checking, selecting, focus and scroll are recorded as they happen. */
|
|
161
|
+
liveState: false,
|
|
162
|
+
/** Every record names the context, page, navigation and frame it belongs to. */
|
|
163
|
+
identifiers: false,
|
|
164
|
+
});
|
|
165
|
+
|
|
104
166
|
/**
|
|
105
167
|
* Build the manifest of a trace.
|
|
106
168
|
*
|
|
@@ -124,6 +186,7 @@ export function createManifest(options = {}) {
|
|
|
124
186
|
dom = {},
|
|
125
187
|
events = [],
|
|
126
188
|
dropped = 0,
|
|
189
|
+
replay = {},
|
|
127
190
|
} = options;
|
|
128
191
|
|
|
129
192
|
return {
|
|
@@ -140,6 +203,7 @@ export function createManifest(options = {}) {
|
|
|
140
203
|
runtime,
|
|
141
204
|
events: [...events],
|
|
142
205
|
dom: { ...dom },
|
|
206
|
+
replay: { ...DEFAULT_REPLAY_SUPPORT, ...replay },
|
|
143
207
|
privacy: { ...privacy },
|
|
144
208
|
limits: { ...limits },
|
|
145
209
|
counts: { checkpoints: 0, events: 0, mutationBatches: 0, ...counts },
|
package/src/traces/viewer.js
CHANGED
|
@@ -43,6 +43,7 @@ const VIEWER_STYLE = `
|
|
|
43
43
|
header { grid-column: 1 / -1; padding: 0.5rem 1rem; border-bottom: 1px solid #8884; }
|
|
44
44
|
h1 { font-size: 1rem; margin: 0 0 0.25rem; }
|
|
45
45
|
.meta { font-size: 0.75rem; opacity: 0.75; }
|
|
46
|
+
.meta.replay { opacity: 0.9; font-style: italic; }
|
|
46
47
|
aside { overflow: auto; border-right: 1px solid #8884; }
|
|
47
48
|
main { display: grid; grid-template-rows: auto 1fr auto; overflow: hidden; }
|
|
48
49
|
ol { list-style: none; margin: 0; padding: 0; font-size: 0.8rem; }
|
|
@@ -87,19 +88,117 @@ function renderHtml(index, html) {
|
|
|
87
88
|
frame.srcdoc = html == null ? '<p style="font:1rem system-ui">no HTML captured for this checkpoint</p>' : html;
|
|
88
89
|
}
|
|
89
90
|
|
|
91
|
+
function nodeOf(parsed, described) {
|
|
92
|
+
if (!described) return null;
|
|
93
|
+
if (described.type === 'text') {
|
|
94
|
+
return parsed.createTextNode(described.text == null ? '' : described.text);
|
|
95
|
+
}
|
|
96
|
+
if (described.type === 'element' && described.html) {
|
|
97
|
+
const holder = parsed.createElement('template');
|
|
98
|
+
holder.innerHTML = described.html;
|
|
99
|
+
return holder.content.firstChild;
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function looksLike(node, described) {
|
|
105
|
+
if (!node || !described) return false;
|
|
106
|
+
if (described.type === 'text') return node.nodeType === 3;
|
|
107
|
+
if (described.type !== 'element') return false;
|
|
108
|
+
if (node.nodeType !== 1 || node.localName !== described.tag) return false;
|
|
109
|
+
return described.id ? node.id === described.id : true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function childLike(parent, described) {
|
|
113
|
+
if (!described) return null;
|
|
114
|
+
// An id is identity; an index is only a hint. A node that moved is reported
|
|
115
|
+
// at the position it moved *to*, so the index alone would find the wrong one.
|
|
116
|
+
if (described.id) {
|
|
117
|
+
for (const child of parent.childNodes) {
|
|
118
|
+
if (child.nodeType === 1 && child.id === described.id) return child;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const at = described.index == null ? null : parent.childNodes[described.index];
|
|
122
|
+
if (looksLike(at, described)) return at;
|
|
123
|
+
for (const child of parent.childNodes) {
|
|
124
|
+
if (looksLike(child, described)) return child;
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function insertAt(parent, node, index) {
|
|
130
|
+
if (!node) return false;
|
|
131
|
+
const reference = index == null ? null : parent.childNodes[index] || null;
|
|
132
|
+
parent.insertBefore(node, reference);
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function applyLiveState(parsed, target, record) {
|
|
137
|
+
const value = record.after;
|
|
138
|
+
const text = value == null ? '' : String(value);
|
|
139
|
+
if (record.property === 'scroll') {
|
|
140
|
+
// A static copy of a document cannot be scrolled, so where it was
|
|
141
|
+
// scrolled to is written onto the element rather than quietly lost.
|
|
142
|
+
const element = target || parsed.documentElement;
|
|
143
|
+
const at = value || {};
|
|
144
|
+
element.setAttribute('data-bc-scroll', at.top + ',' + at.left);
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
if (!target) return false;
|
|
148
|
+
if (record.property === 'value') {
|
|
149
|
+
// Serialized HTML carries attributes, not properties, so what was typed
|
|
150
|
+
// is written where a re-parse will still find it.
|
|
151
|
+
if (target.localName === 'textarea' || target.hasAttribute('contenteditable')) {
|
|
152
|
+
target.textContent = text;
|
|
153
|
+
} else {
|
|
154
|
+
target.setAttribute('value', text);
|
|
155
|
+
}
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
if (record.property === 'checked') {
|
|
159
|
+
if (value) target.setAttribute('checked', '');
|
|
160
|
+
else target.removeAttribute('checked');
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
if (record.property === 'selected') {
|
|
164
|
+
const chosen = (Array.isArray(value) ? value : []).map(String);
|
|
165
|
+
for (const option of target.querySelectorAll('option')) {
|
|
166
|
+
if (chosen.indexOf(option.value) !== -1) option.setAttribute('selected', '');
|
|
167
|
+
else option.removeAttribute('selected');
|
|
168
|
+
}
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
if (record.property === 'focus') {
|
|
172
|
+
for (const had of parsed.querySelectorAll('[data-bc-focus]')) {
|
|
173
|
+
had.removeAttribute('data-bc-focus');
|
|
174
|
+
}
|
|
175
|
+
if (value) target.setAttribute('data-bc-focus', '');
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
|
|
90
181
|
function applyMutations(index, upTo) {
|
|
91
182
|
const html = trace.html[index];
|
|
92
183
|
if (html == null) return null;
|
|
93
184
|
const parsed = new DOMParser().parseFromString(html, 'text/html');
|
|
94
185
|
const batches = trace.mutations[index] || [];
|
|
95
186
|
let applied = 0;
|
|
187
|
+
let skipped = 0;
|
|
96
188
|
for (const batch of batches.slice(0, upTo)) {
|
|
189
|
+
// The stage holds one document. A batch from an iframe belongs to a
|
|
190
|
+
// different one, so it is counted rather than applied to the wrong page.
|
|
191
|
+
if (batch.mainFrame === false) { skipped += (batch.records || []).length; continue; }
|
|
97
192
|
for (const record of batch.records) {
|
|
98
193
|
const target = record.target && record.target.path
|
|
99
194
|
? parsed.querySelector(record.target.path)
|
|
100
195
|
: null;
|
|
101
|
-
if (
|
|
102
|
-
|
|
196
|
+
if (record.kind === 'live-state') {
|
|
197
|
+
if (applyLiveState(parsed, target, record)) applied++; else skipped++;
|
|
198
|
+
} else if (!target) {
|
|
199
|
+
skipped++;
|
|
200
|
+
continue;
|
|
201
|
+
} else if (record.kind === 'attributes' && record.attribute) {
|
|
103
202
|
if (record.after == null) target.removeAttribute(record.attribute);
|
|
104
203
|
else target.setAttribute(record.attribute, record.after);
|
|
105
204
|
applied++;
|
|
@@ -107,17 +206,32 @@ function applyMutations(index, upTo) {
|
|
|
107
206
|
target.textContent = record.after == null ? '' : record.after;
|
|
108
207
|
applied++;
|
|
109
208
|
} else if (record.kind === 'childList') {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
209
|
+
// Where a node went is as much of the change as what it was: appending
|
|
210
|
+
// every addition to the end replays an insertion before a sibling in
|
|
211
|
+
// the wrong place, and drops removals entirely (issue #93).
|
|
212
|
+
for (const removed of record.removed || []) {
|
|
213
|
+
const node = childLike(target, removed);
|
|
214
|
+
if (node) { target.removeChild(node); applied++; } else { skipped++; }
|
|
115
215
|
}
|
|
216
|
+
const added = (record.added || []).filter(Boolean)
|
|
217
|
+
.slice().sort((a, b) => (a.index == null ? 1e9 : a.index) - (b.index == null ? 1e9 : b.index));
|
|
218
|
+
for (const entry of added) {
|
|
219
|
+
if (insertAt(target, nodeOf(parsed, entry), entry.index)) applied++;
|
|
220
|
+
else skipped++;
|
|
221
|
+
}
|
|
222
|
+
} else {
|
|
223
|
+
skipped++;
|
|
116
224
|
}
|
|
117
|
-
if (target.style) target.style.outline = '2px solid #f59e0b';
|
|
225
|
+
if (target && target.style) target.style.outline = '2px solid #f59e0b';
|
|
118
226
|
}
|
|
119
227
|
}
|
|
120
|
-
return { html: '<!DOCTYPE html>' + parsed.documentElement.outerHTML, applied };
|
|
228
|
+
return { html: '<!DOCTYPE html>' + parsed.documentElement.outerHTML, applied, skipped };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function stepText(step, total, result) {
|
|
232
|
+
const base = 'batch ' + step + ' of ' + total;
|
|
233
|
+
if (!result || !result.skipped) return base;
|
|
234
|
+
return base + ' · ' + result.skipped + ' not replayable here';
|
|
121
235
|
}
|
|
122
236
|
|
|
123
237
|
function showCheckpoint(index) {
|
|
@@ -171,7 +285,7 @@ document.getElementById('play').addEventListener('click', async () => {
|
|
|
171
285
|
for (let step = 1; step <= batches.length; step++) {
|
|
172
286
|
const result = applyMutations(selectedCheckpoint, step);
|
|
173
287
|
if (result) frame.srcdoc = result.html;
|
|
174
|
-
stepLabel.textContent =
|
|
288
|
+
stepLabel.textContent = stepText(step, batches.length, result);
|
|
175
289
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
176
290
|
}
|
|
177
291
|
});
|
|
@@ -182,7 +296,7 @@ document.getElementById('step-forward').addEventListener('click', () => {
|
|
|
182
296
|
mutationStep = Math.min(mutationStep + 1, batches.length);
|
|
183
297
|
const result = applyMutations(selectedCheckpoint, mutationStep);
|
|
184
298
|
if (result) frame.srcdoc = result.html;
|
|
185
|
-
stepLabel.textContent =
|
|
299
|
+
stepLabel.textContent = stepText(mutationStep, batches.length, result);
|
|
186
300
|
});
|
|
187
301
|
|
|
188
302
|
document.getElementById('reset').addEventListener('click', () => {
|
|
@@ -192,6 +306,37 @@ document.getElementById('reset').addEventListener('click', () => {
|
|
|
192
306
|
if (trace.checkpoints.length) showCheckpoint(trace.checkpoints[0].index);
|
|
193
307
|
`;
|
|
194
308
|
|
|
309
|
+
/**
|
|
310
|
+
* What this bundle can be replayed from, in the viewer's own words.
|
|
311
|
+
*
|
|
312
|
+
* Issue #93 asked for the viewer to say plainly that it reconstructs a run
|
|
313
|
+
* rather than replaying a recording of one, and to stop implying it can show
|
|
314
|
+
* things the bundle does not contain. A checkpoints-only trace has nothing
|
|
315
|
+
* between its checkpoints; a continuous one has as much as it recorded.
|
|
316
|
+
*
|
|
317
|
+
* @param {Object} manifest - The bundle's manifest
|
|
318
|
+
* @returns {string} A one-line description of what replay covers
|
|
319
|
+
*/
|
|
320
|
+
export function replaySummary(manifest) {
|
|
321
|
+
const replay = manifest?.replay ?? {};
|
|
322
|
+
const covered = [];
|
|
323
|
+
if (replay.mutations) {
|
|
324
|
+
covered.push('DOM mutations');
|
|
325
|
+
}
|
|
326
|
+
if (replay.childListPositions) {
|
|
327
|
+
covered.push('insertion positions and removals');
|
|
328
|
+
}
|
|
329
|
+
if (replay.liveState) {
|
|
330
|
+
covered.push('live control state');
|
|
331
|
+
}
|
|
332
|
+
if (replay.identifiers) {
|
|
333
|
+
covered.push('page and frame identities');
|
|
334
|
+
}
|
|
335
|
+
return covered.length === 0
|
|
336
|
+
? 'partial diagnostic replay: checkpoints only, nothing between them'
|
|
337
|
+
: `partial diagnostic replay: checkpoints, ${covered.join(', ')}`;
|
|
338
|
+
}
|
|
339
|
+
|
|
195
340
|
/**
|
|
196
341
|
* Collect everything the viewer embeds.
|
|
197
342
|
*
|
|
@@ -266,6 +411,7 @@ export async function renderTraceViewer(reader, options = {}) {
|
|
|
266
411
|
${manifest.counts?.checkpoints ?? 0} checkpoints · ${manifest.counts?.events ?? 0} events ·
|
|
267
412
|
${manifest.dropped ?? 0} dropped · started ${manifest.startedAt ?? 'unknown'}
|
|
268
413
|
</div>
|
|
414
|
+
<div class="meta replay">${replaySummary(manifest)}</div>
|
|
269
415
|
</header>
|
|
270
416
|
<aside><ol id="timeline"></ol></aside>
|
|
271
417
|
<main>
|