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.
@@ -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
- const bundle = await openTraceBundle({ output, limits, strict, now });
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
- 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
- }
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 drainMutations(index - 1 > 0 ? index - 1 : 0);
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
- // 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();
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 installMutationRecorder();
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 drainMutations(checkpointIndex);
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
  /**
@@ -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>