browser-commander 0.18.0 → 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.
- package/.jscpd-baseline.json +8 -0
- package/CHANGELOG.md +34 -0
- package/README.md +50 -2
- package/package.json +2 -1
- package/src/downloads/manager.js +31 -8
- package/src/downloads/sources.js +56 -6
- package/src/downloads/staging.js +146 -0
- package/src/exports.js +14 -0
- package/src/traces/bundle.js +16 -1
- 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 +117 -100
- package/src/traces/schema.js +65 -1
- package/src/traces/viewer.js +157 -11
- package/tests/e2e/downloads.e2e.test.js +47 -0
- package/tests/e2e/traces.e2e.test.js +296 -11
- package/tests/helpers/download-server.js +26 -6
- package/tests/helpers/trace-fixtures.js +79 -3
- package/tests/helpers/trace-server.js +57 -1
- package/tests/unit/downloads/manager.test.js +131 -0
- package/tests/unit/downloads/staging.test.js +117 -0
- package/tests/unit/traces/links.test.js +557 -0
- package/tests/unit/traces/recorder.test.js +169 -1
- package/tests/unit/traces/viewer.test.js +5 -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
|
});
|
|
@@ -67,36 +74,6 @@ function normalizeEvents(events) {
|
|
|
67
74
|
return [...new Set(events)];
|
|
68
75
|
}
|
|
69
76
|
|
|
70
|
-
/**
|
|
71
|
-
* Run a capture with its own deadline.
|
|
72
|
-
*
|
|
73
|
-
* A page that stopped answering must cost the trace one record, not the run.
|
|
74
|
-
*
|
|
75
|
-
* @param {Promise} work - The capture
|
|
76
|
-
* @param {number} timeoutMs - Budget
|
|
77
|
-
* @param {string} what - Name used in the timeout message
|
|
78
|
-
* @returns {Promise<*>} The capture's result
|
|
79
|
-
*/
|
|
80
|
-
async function withDeadline(work, timeoutMs, what) {
|
|
81
|
-
if (!timeoutMs) {
|
|
82
|
-
return work;
|
|
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);
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
77
|
/**
|
|
101
78
|
* Start recording a session.
|
|
102
79
|
*
|
|
@@ -109,11 +86,13 @@ export async function startTrace(options = {}) {
|
|
|
109
86
|
page = commander?.page,
|
|
110
87
|
output,
|
|
111
88
|
mode = TRACE_MODE.CHECKPOINTS,
|
|
89
|
+
initialCheckpoint,
|
|
112
90
|
screenshots = 'checkpoints',
|
|
113
91
|
dom = {},
|
|
114
92
|
events,
|
|
115
93
|
privacy = {},
|
|
116
94
|
limits = {},
|
|
95
|
+
links = null,
|
|
117
96
|
strict = false,
|
|
118
97
|
captureTimeoutMs = DEFAULT_CAPTURE_TIMEOUT,
|
|
119
98
|
commanderVersion = null,
|
|
@@ -133,10 +112,48 @@ export async function startTrace(options = {}) {
|
|
|
133
112
|
const eventSources = normalizeEvents(events);
|
|
134
113
|
const privacyOptions = normalizePrivacyOptions(privacy);
|
|
135
114
|
const engine = commander?.engine ?? null;
|
|
136
|
-
|
|
137
|
-
|
|
115
|
+
const identity = createTraceIdentity({ page });
|
|
116
|
+
|
|
117
|
+
// A continuous trace records changes, and a change is only meaningful against
|
|
118
|
+
// something. Taking the base snapshot automatically is the safe default,
|
|
119
|
+
// because a trace that starts mid-air replays as a blank page (issue #93); a
|
|
120
|
+
// checkpoints-only trace is a list of moments the caller named, so nothing is
|
|
121
|
+
// added to it uninvited. Passing a string names the base checkpoint.
|
|
122
|
+
const baseCheckpoint =
|
|
123
|
+
initialCheckpoint ?? recorderMode === TRACE_MODE.CONTINUOUS;
|
|
124
|
+
const baseCheckpointName =
|
|
125
|
+
typeof baseCheckpoint === 'string' ? baseCheckpoint : 'initial';
|
|
126
|
+
|
|
127
|
+
// The Links Notation export is a second view of the same records, written
|
|
128
|
+
// as they are recorded (issue #94), so a run that is killed leaves a
|
|
129
|
+
// readable export of everything that had happened. The bundle stays
|
|
130
|
+
// authoritative: nothing is written here that is not written there first.
|
|
131
|
+
let linksSink = null;
|
|
132
|
+
const bundle = await openTraceBundle({
|
|
133
|
+
output,
|
|
134
|
+
limits,
|
|
135
|
+
strict,
|
|
136
|
+
now,
|
|
137
|
+
onEvent: (event) => linksSink?.event(event),
|
|
138
|
+
});
|
|
138
139
|
const startedAt = new Date(now()).toISOString();
|
|
139
140
|
|
|
141
|
+
if (links) {
|
|
142
|
+
if (typeof links.output !== 'string' || links.output === '') {
|
|
143
|
+
throw new Error('trace links require an output path');
|
|
144
|
+
}
|
|
145
|
+
linksSink = await openTraceLinks({
|
|
146
|
+
output: links.output,
|
|
147
|
+
include: links.include,
|
|
148
|
+
bundlePath: bundle.root,
|
|
149
|
+
schemaVersion: TRACE_SCHEMA_VERSION,
|
|
150
|
+
mode: recorderMode,
|
|
151
|
+
engine,
|
|
152
|
+
startedAt,
|
|
153
|
+
commanderVersion,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
140
157
|
let stopped = false;
|
|
141
158
|
let checkpointIndex = 0;
|
|
142
159
|
const checkpoints = [];
|
|
@@ -156,6 +173,9 @@ export async function startTrace(options = {}) {
|
|
|
156
173
|
}
|
|
157
174
|
return await bundle.appendEvent({
|
|
158
175
|
kind,
|
|
176
|
+
// Who this happened to comes first so a payload that knows better - a
|
|
177
|
+
// drain that names the frame it came from, say - can say so.
|
|
178
|
+
...identity.owner(),
|
|
159
179
|
...redactValue(payload, privacyOptions),
|
|
160
180
|
});
|
|
161
181
|
}
|
|
@@ -169,61 +189,18 @@ export async function startTrace(options = {}) {
|
|
|
169
189
|
return await page.evaluate(fn, argument);
|
|
170
190
|
}
|
|
171
191
|
|
|
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
|
-
}
|
|
192
|
+
const mutations = createMutationStream({
|
|
193
|
+
page,
|
|
194
|
+
bundle,
|
|
195
|
+
record,
|
|
196
|
+
identity,
|
|
197
|
+
note,
|
|
198
|
+
domOptions,
|
|
199
|
+
privacyOptions,
|
|
200
|
+
limits,
|
|
201
|
+
captureTimeoutMs,
|
|
202
|
+
evaluateInPage,
|
|
203
|
+
});
|
|
227
204
|
|
|
228
205
|
async function screenshot(reason) {
|
|
229
206
|
const wanted =
|
|
@@ -267,7 +244,7 @@ export async function startTrace(options = {}) {
|
|
|
267
244
|
|
|
268
245
|
// Mutations are drained first so the batches belong to the interval that
|
|
269
246
|
// ended here, not to the one that starts now.
|
|
270
|
-
await
|
|
247
|
+
await mutations.drain(index - 1 > 0 ? index - 1 : 0);
|
|
271
248
|
|
|
272
249
|
let captured = null;
|
|
273
250
|
try {
|
|
@@ -318,9 +295,10 @@ export async function startTrace(options = {}) {
|
|
|
318
295
|
checkpoints.push(entry);
|
|
319
296
|
await record(TRACE_EVENT.CHECKPOINT, entry);
|
|
320
297
|
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
|
|
298
|
+
// The init script covers every document created from here on; this covers
|
|
299
|
+
// a frame that was attached without one, which is cheap because installing
|
|
300
|
+
// over a recorder that is already observing does nothing.
|
|
301
|
+
await mutations.install();
|
|
324
302
|
|
|
325
303
|
return entry;
|
|
326
304
|
}
|
|
@@ -331,15 +309,31 @@ export async function startTrace(options = {}) {
|
|
|
331
309
|
eventSources,
|
|
332
310
|
record,
|
|
333
311
|
note,
|
|
312
|
+
identity,
|
|
334
313
|
});
|
|
335
314
|
|
|
315
|
+
// Registered before the first record, so a navigation that starts in the same
|
|
316
|
+
// tick as the trace does is still recorded from its first mutation.
|
|
317
|
+
const detachInitScript = await mutations.installPersistent();
|
|
318
|
+
if (detachInitScript) {
|
|
319
|
+
detachers.push(detachInitScript);
|
|
320
|
+
}
|
|
321
|
+
|
|
336
322
|
await record(TRACE_EVENT.TRACE_START, {
|
|
337
323
|
mode: recorderMode,
|
|
338
324
|
engine,
|
|
339
325
|
dom: domOptions,
|
|
340
326
|
events: eventSources,
|
|
327
|
+
initialCheckpoint: Boolean(baseCheckpoint),
|
|
341
328
|
});
|
|
342
|
-
await
|
|
329
|
+
await mutations.install();
|
|
330
|
+
|
|
331
|
+
if (baseCheckpoint) {
|
|
332
|
+
await checkpoint(baseCheckpointName, {
|
|
333
|
+
actor: 'recorder',
|
|
334
|
+
reason: TRACE_CHECKPOINT_REASON.INITIAL,
|
|
335
|
+
});
|
|
336
|
+
}
|
|
343
337
|
|
|
344
338
|
/**
|
|
345
339
|
* Stop recording and write the manifest.
|
|
@@ -353,7 +347,7 @@ export async function startTrace(options = {}) {
|
|
|
353
347
|
}
|
|
354
348
|
const { discard = false, error = null } = stopOptions;
|
|
355
349
|
|
|
356
|
-
await
|
|
350
|
+
await mutations.drain(checkpointIndex);
|
|
357
351
|
if (error) {
|
|
358
352
|
await record(TRACE_EVENT.PAGE_ERROR, {
|
|
359
353
|
message: error.message ?? String(error),
|
|
@@ -364,6 +358,10 @@ export async function startTrace(options = {}) {
|
|
|
364
358
|
await record(TRACE_EVENT.TRACE_STOP, { discarded: discard });
|
|
365
359
|
stopped = true;
|
|
366
360
|
|
|
361
|
+
// The documents that exist stop observing here; the engine's init-script
|
|
362
|
+
// registration is removed with the detachers below.
|
|
363
|
+
await mutations.stop();
|
|
364
|
+
|
|
367
365
|
for (const detach of detachers.reverse()) {
|
|
368
366
|
try {
|
|
369
367
|
await detach();
|
|
@@ -382,6 +380,14 @@ export async function startTrace(options = {}) {
|
|
|
382
380
|
engine,
|
|
383
381
|
dom: domOptions,
|
|
384
382
|
events: eventSources,
|
|
383
|
+
replay: {
|
|
384
|
+
checkpoints: true,
|
|
385
|
+
mutations: Boolean(domOptions.mutations),
|
|
386
|
+
childListPositions: Boolean(domOptions.mutations),
|
|
387
|
+
liveState:
|
|
388
|
+
Boolean(domOptions.mutations) && domOptions.liveState !== false,
|
|
389
|
+
identifiers: true,
|
|
390
|
+
},
|
|
385
391
|
privacy: {
|
|
386
392
|
redactSelectors: privacyOptions.redactSelectors,
|
|
387
393
|
redactAttributes: privacyOptions.redactAttributes,
|
|
@@ -392,17 +398,27 @@ export async function startTrace(options = {}) {
|
|
|
392
398
|
})
|
|
393
399
|
);
|
|
394
400
|
|
|
401
|
+
// Closed after the manifest, because the closing link reports the outcome
|
|
402
|
+
// the manifest settled on, and the control diffs are read back out of the
|
|
403
|
+
// finished bundle rather than kept in memory for the length of a run.
|
|
404
|
+
if (linksSink) {
|
|
405
|
+
await linksSink.close({ manifest, bundlePath: bundle.root });
|
|
406
|
+
}
|
|
407
|
+
|
|
395
408
|
const result = {
|
|
396
409
|
path: bundle.root,
|
|
397
410
|
manifest,
|
|
398
411
|
checkpoints: [...checkpoints],
|
|
399
|
-
problems: [...bundle.problems],
|
|
412
|
+
problems: [...bundle.problems, ...(linksSink?.problems ?? [])],
|
|
413
|
+
links: linksSink?.path ?? null,
|
|
400
414
|
};
|
|
401
415
|
stopped = result;
|
|
402
416
|
|
|
403
417
|
if (discard) {
|
|
404
418
|
const fs = await import('node:fs/promises');
|
|
405
419
|
await fs.rm(bundle.root, { recursive: true, force: true });
|
|
420
|
+
// An export of a bundle that no longer exists points at nothing.
|
|
421
|
+
await linksSink?.discard();
|
|
406
422
|
result.discarded = true;
|
|
407
423
|
}
|
|
408
424
|
|
|
@@ -411,6 +427,7 @@ export async function startTrace(options = {}) {
|
|
|
411
427
|
|
|
412
428
|
return {
|
|
413
429
|
path: bundle.root,
|
|
430
|
+
links: linksSink?.path ?? null,
|
|
414
431
|
mode: recorderMode,
|
|
415
432
|
checkpoint,
|
|
416
433
|
/**
|
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>
|