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.
Files changed (35) hide show
  1. package/.jscpd-baseline.json +8 -0
  2. package/CHANGELOG.md +45 -0
  3. package/README.md +50 -2
  4. package/package.json +8 -3
  5. package/scripts/run-tests.mjs +4 -2
  6. package/src/downloads/manager.js +40 -8
  7. package/src/downloads/sources.js +127 -6
  8. package/src/downloads/staging.js +158 -0
  9. package/src/exports.js +14 -0
  10. package/src/traces/bundle.js +26 -3
  11. package/src/traces/deadline.js +33 -0
  12. package/src/traces/identity.js +127 -0
  13. package/src/traces/init-script.js +95 -0
  14. package/src/traces/links.js +623 -0
  15. package/src/traces/mutation-stream.js +232 -0
  16. package/src/traces/observers.js +29 -3
  17. package/src/traces/page-capture.js +251 -15
  18. package/src/traces/recorder.js +124 -94
  19. package/src/traces/schema.js +65 -1
  20. package/src/traces/viewer.js +157 -11
  21. package/tests/e2e/downloads.e2e.test.js +126 -2
  22. package/tests/e2e/traces.e2e.test.js +296 -11
  23. package/tests/helpers/download-server.js +26 -6
  24. package/tests/helpers/trace-fixtures.js +97 -3
  25. package/tests/helpers/trace-server.js +57 -1
  26. package/tests/unit/downloads/manager.test.js +213 -9
  27. package/tests/unit/downloads/staging.test.js +139 -0
  28. package/tests/unit/scripts/ci-workflow-policy.test.js +8 -0
  29. package/tests/unit/scripts/npm-release-helpers.test.js +3 -2
  30. package/tests/unit/scripts/pre-commit-config.test.js +2 -1
  31. package/tests/unit/traces/bundle.test.js +24 -3
  32. package/tests/unit/traces/links.test.js +564 -0
  33. package/tests/unit/traces/reader.test.js +10 -4
  34. package/tests/unit/traces/recorder.test.js +172 -1
  35. package/tests/unit/traces/viewer.test.js +9 -0
@@ -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
- * Run a capture with its own deadline.
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 {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
80
+ * @param {Object|null} links - Optional Links Notation export options
81
+ * @returns {void}
79
82
  */
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);
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
- const bundle = await openTraceBundle({ output, limits, strict, now });
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
- async function installMutationRecorder() {
173
- if (!domOptions.mutations) {
174
- return;
175
- }
176
- try {
177
- await evaluateInPage(installMutationRecorderInPage, {
178
- globalName: RECORDER_GLOBAL,
179
- redactSelectors: privacyOptions.redactSelectors,
180
- redacted: REDACTED,
181
- maxQueued: limits.maxQueuedMutations ?? 5000,
182
- });
183
- } catch (error) {
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 drainMutations(index - 1 > 0 ? index - 1 : 0);
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
- // A navigation replaces the document and with it the observer, so the
322
- // recorder is reinstalled at every checkpoint rather than only at start.
323
- await installMutationRecorder();
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 installMutationRecorder();
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 drainMutations(checkpointIndex);
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
  /**
@@ -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 = 1;
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 },
@@ -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 (!target) continue;
102
- if (record.kind === 'attributes' && record.attribute) {
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
- for (const added of record.added || []) {
111
- if (added && added.type === 'element' && added.html) {
112
- target.insertAdjacentHTML('beforeend', added.html);
113
- applied++;
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 = 'batch ' + step + ' of ' + batches.length;
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 = 'batch ' + mutationStep + ' of ' + batches.length;
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>