cyborg-hunter 0.7.4 → 0.8.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 (39) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/CITATION.cff +2 -2
  3. package/README.md +21 -6
  4. package/dist/cyborg-hunter-replay.js +3 -3
  5. package/dist/cyborg-hunter.esm.js +3 -2
  6. package/dist/cyborg-hunter.min.js +3 -3
  7. package/dist/extension-cyborg-hunter.js +1 -1
  8. package/dist/extension-guard-friction.js +5 -5
  9. package/package.json +10 -2
  10. package/src/cli/ingest.js +311 -57
  11. package/src/cli/renderers/html-index-core.js +66 -12
  12. package/src/cli/renderers/html-index.js +7 -5
  13. package/src/cli/renderers/replay-assets.js +61 -7
  14. package/src/cli/renderers/replay-client-source.js +102 -0
  15. package/src/cli/renderers/replay-viewer.client.js +1750 -595
  16. package/src/cli/report.js +6 -0
  17. package/src/core/monitor.js +12 -13
  18. package/src/jspsych/extension-cyborg-hunter-replay.js +64 -3
  19. package/src/jspsych/extension-cyborg-hunter.js +1 -1
  20. package/src/jspsych/extension-guard-friction.js +59 -1
  21. package/src/replay/capture-dom.js +470 -458
  22. package/src/replay/capture-trace.js +688 -271
  23. package/src/replay/delivery.js +82 -0
  24. package/src/replay/dom-instantiate.js +779 -0
  25. package/src/replay/index.js +88 -5
  26. package/src/replay/initial-state.js +295 -0
  27. package/src/replay/mutations.js +668 -0
  28. package/src/replay/node-registry.js +116 -0
  29. package/src/replay/persistence.js +19 -6
  30. package/src/replay/recorder.js +342 -73
  31. package/src/replay/redaction.js +165 -0
  32. package/src/replay/serializer.js +148 -43
  33. package/src/replay/snapshot.js +409 -0
  34. package/src/replay/span.js +55 -0
  35. package/src/replay/viewer-model.js +293 -102
  36. package/src/shared/constants.js +1 -1
  37. package/src/shared/inline-safe.js +80 -0
  38. package/src/shared/schema-v2-validator.js +595 -0
  39. package/tools/convert/jspsych-v1-to-v2.mjs +432 -0
@@ -1,413 +1,231 @@
1
1
  // src/replay/capture-dom.js
2
- // Tier-2 ("dom") capture: initial-DOM serialization, MutationObserver →
3
- // patch log, initial stylesheet capture, guard-friction pre-scramble hook.
2
+ // Tier-2 ("dom") capture, wired to the v2 node-tree pipeline: keyframe
3
+ // snapshots (snapshot.js), MutationObserver batches → `dom.*` patches
4
+ // (mutations.js), the `initial_state` seed (initial-state.js), initial
5
+ // stylesheet capture, and the guard-friction pre-scramble hook.
4
6
  //
5
- // The serializer is a hand-rolled tree walker (clean-room; no dependency on
6
- // outerHTML) so it can (a) strip/redact during the walk and (b) run against
7
- // duck-typed fixture trees in node tests.
7
+ // This module owns no serialization logic of its own any more. It is the
8
+ // WIRING: what the observed root is, when a keyframe is taken, which span the
9
+ // ids come from, and how a batch reaches the recorder. Everything it used to
10
+ // do by hand is now a shared, separately tested module — which is the point:
11
+ // the keyframe and the patches that address it must agree about exclusion,
12
+ // redaction and node identity, and the only way to guarantee that is for both
13
+ // to run the same code.
8
14
  //
9
- // Node addressing: a path is the list of childNodes indices from the capture
10
- // root down to the node ([1,1] = root.childNodes[1].childNodes[1]). The
11
- // viewer resolves paths against its reconstructed tree; paths are relative
12
- // to the SNAPSHOT state at each point in the patch sequence, which holds as
13
- // long as patches are applied in order from the initial DOM.
14
-
15
- var ELEMENT_NODE = 1;
16
- var TEXT_NODE = 3;
17
-
18
- // Elements never serialized: executable or replay-irrelevant. IFRAME is NOT
19
- // here — frames serialize as inert size-preserving placeholders (see
20
- // serializeNode) so the surrounding layout doesn't collapse in the viewer.
21
- var SKIP_TAGS = { SCRIPT: true, NOSCRIPT: true };
15
+ // What retired here at the v2 switchover (spec §14's CH migration list):
16
+ // - the HTML-STRING walker (`serializeDom`) and its iframe span placeholder,
17
+ // void-tag table and escaping. A keyframe is a DomNode tree (spec §4);
18
+ // iframes are recorded as the element itself with no children (§13).
19
+ // - the NONCE MARKER registry (`data-chn-*`). Integer node ids scoped to a
20
+ // keyframe span replaced it, so nothing has to be stamped into serialized
21
+ // markup and harvested back out by the viewer.
22
+ // - CHILD-INDEX PATHS (`nodePath`) and the `mutation` patch shape. Patches
23
+ // address nodes by id (§5.1).
24
+ // - INTRA-BATCH DEDUP and the characterData→childList fold. Both existed
25
+ // because a v1 childList patch re-serialized the target's whole resulting
26
+ // children; a `dom.add` carries only what was inserted. mutations.js's
27
+ // header records the reasoning, and its batch pre-scan is why the observer
28
+ // callback below hands over the COMPLETE records array untouched.
29
+ // - the DUPLICATED redaction predicates. redaction.js is the one definition
30
+ // (spec §8 makes redaction a property of the file, which is a claim only a
31
+ // single predicate can make checkable).
32
+
33
+ import { serializeTree } from './snapshot.js';
34
+ import { mapMutations, MUTATION_OBSERVER_INIT } from './mutations.js';
35
+ import { buildInitialState } from './initial-state.js';
36
+ import { createSpan } from './span.js';
22
37
 
23
38
  /**
24
- * Serialization-stable node references. Child-index paths are not stable
25
- * across HTML reparsing (the parser drops comments, merges text nodes, and
26
- * inserts elements like <tbody>), so every serialized ELEMENT is stamped
27
- * with a marker attribute instead. The attribute name embeds a per-recording
28
- * nonce so page CSS written before the recording existed cannot target it
29
- * ([data-chn-*]{display:none} attacks) and pre-existing page attributes
30
- * cannot collide. Ids live in a WeakMap keyed by the LIVE node — the live
31
- * DOM is never mutated (stamping real attributes would echo through the
32
- * MutationObserver and interfere with the host page). The viewer harvests
33
- * markers into an out-of-band map and strips them before any measured layout.
39
+ * Initial stylesheet capture (spec §2 `StylesheetSnapshot`).
40
+ *
41
+ * Ids are the array position plus one, assigned here because the format wants
42
+ * them: `stylesheet_events` addresses sheets by id, and even with that stream
43
+ * empty (CH captures no CSSOM mutations — spec §13's known limit) a consumer
44
+ * reads the two arrays as one addressable set.
45
+ *
46
+ * `kind` follows the sheet's origin rather than its readability: a <link>
47
+ * stays a link sheet even when its rules are readable same-origin, and its
48
+ * `css` is filled in when they are. A cross-origin sheet throws on `cssRules`,
49
+ * so only the (absolute) href survives — the viewer can link it, nothing can
50
+ * inline it.
34
51
  */
35
- export function createMarkerRegistry(nonce) {
36
- var ids = new WeakMap();
37
- var next = 1;
38
- return {
39
- attr: 'data-chn-' + nonce,
40
- refFor: function (node) {
41
- var n = ids.get(node);
42
- if (n == null) { n = next++; ids.set(node, n); }
43
- return n;
44
- }
45
- };
46
- }
47
-
48
- // Void elements per the HTML spec — serialized without a closing tag.
49
- var VOID_TAGS = {
50
- AREA: true, BASE: true, BR: true, COL: true, EMBED: true, HR: true,
51
- IMG: true, INPUT: true, LINK: true, META: true, SOURCE: true,
52
- TRACK: true, WBR: true
53
- };
54
-
55
- // Valid attribute-name token; also refuses event-handler attributes
56
- // (on*) as defense-in-depth — the viewer sandbox+CSP already block inline
57
- // handlers, but the serialized artifact should not carry them at all.
58
- var ATTR_NAME_RE = /^[a-zA-Z_:][-a-zA-Z0-9_:.]*$/;
59
- function isSerializableAttr(name) {
60
- return ATTR_NAME_RE.test(name) && !/^on/i.test(name);
61
- }
62
-
63
- function escapeHtml(s) {
64
- return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;')
65
- .replace(/>/g, '&gt;').replace(/"/g, '&quot;');
66
- }
67
-
68
- function isBait(node, opts) {
69
- if (opts && opts.keepBait) return false;
70
- if (!node || node.nodeType !== ELEMENT_NODE) return false;
71
- if (node.id === 'ch-decoy') return true;
72
- var attrs = node.attributes || [];
73
- for (var i = 0; i < attrs.length; i++) {
74
- if (attrs[i].name === 'data-ch-role') return true;
75
- if (attrs[i].name === 'data-ch-decoy') return true;
76
- }
77
- return false;
78
- }
79
-
80
- function isPassword(node) {
81
- return node.tagName === 'INPUT' &&
82
- (node.type === 'password' || hasAttr(node, 'type', 'password'));
83
- }
84
-
85
- // True when the node should be redacted in the DOM capture: a password field
86
- // (always) or anything matching the configured redactSelector. Without this the
87
- // DOM path (initial snapshot, value attributes, characterData/childList patches)
88
- // leaked the content of fields the researcher explicitly marked for redaction,
89
- // silently defeating redactSelector for everything except the RAF-coalesced
90
- // input-value events.
91
- function isRedacted(node, opts) {
92
- if (!node) return false;
93
- if (isPassword(node)) return true;
94
- var sel = opts && opts.redactSelector;
95
- if (!sel || typeof node.matches !== 'function') return false;
96
- try { return node.matches(sel); } catch (e) { return false; }
97
- }
98
-
99
- // True when the node is inside (or is) a redacted element. Walks ancestors so
100
- // that mutation targets which are TEXT NODES (characterData records, added text
101
- // nodes) — which have no matches() of their own — are correctly redacted when
102
- // their containing element is. Without the walk, characterData on a redacted
103
- // contenteditable would leak the typed text through mutation patches.
104
- function isInRedactedSubtree(node, opts) {
105
- var cur = node;
106
- while (cur) {
107
- if (cur.nodeType === ELEMENT_NODE && isRedacted(cur, opts)) return true;
108
- cur = cur.parentNode;
109
- }
110
- return false;
111
- }
112
-
113
- function hasAttr(node, name, value) {
114
- var attrs = node.attributes || [];
115
- for (var i = 0; i < attrs.length; i++) {
116
- if (attrs[i].name === name) return value == null || attrs[i].value === value;
117
- }
118
- return false;
119
- }
120
-
121
- /**
122
- * Serializes a DOM subtree to an HTML string.
123
- * - strips scripts/iframes and (by default) honeypot/decoy nodes
124
- * - prefers the `src` PROPERTY for images (always absolute) over the
125
- * attribute (may be relative and unresolvable inside the viewer's srcdoc)
126
- * - redacts password input values (marker attribute, no content)
127
- */
128
- export function serializeDom(root, opts) {
129
- opts = opts || {};
52
+ export function captureStylesheets(doc) {
130
53
  var out = [];
131
- serializeNode(root, opts, out);
132
- return out.join('');
133
- }
134
-
135
- // Iframe → inert placeholder. Child documents are never captured (their
136
- // events don't bubble to this document and the srcdoc CSP blocks frames
137
- // anyway), but dropping the element entirely would collapse the surrounding
138
- // layout and silently shift every element below it. The placeholder keeps
139
- // the recorded footprint; the viewer shows a per-trial "iframe content not
140
- // captured" warning when one is present.
141
- // Current footprint of an iframe, for the placeholder and for footprint
142
- // re-sync patches when the iframe's attributes later mutate.
143
- function iframeFootprint(node) {
144
- var w = null, h = null;
145
- try {
146
- if (typeof node.getBoundingClientRect === 'function') {
147
- var r = node.getBoundingClientRect();
148
- // A 0×0 rect is a VALID footprint (hidden iframe) — the placeholder
149
- // must reproduce it, not invent a default-size box that shifts layout.
150
- if (r) { w = Math.round(r.width || 0); h = Math.round(r.height || 0); }
151
- }
152
- } catch (e) { /* fall through to attributes */ }
153
- if (w == null) {
154
- w = parseInt(hasAttr(node, 'width') ? getAttrValue(node, 'width') : '', 10);
155
- h = parseInt(hasAttr(node, 'height') ? getAttrValue(node, 'height') : '', 10);
156
- if (!(w > 0)) w = 300; // spec default iframe size
157
- if (!(h > 0)) h = 150;
158
- }
159
- return { w: w, h: h };
160
- }
161
-
162
- // Layout-affecting computed properties copied onto the placeholder so an
163
- // absolutely-positioned / block / floated / margined iframe doesn't collapse
164
- // into a plain in-flow inline box and shift everything around it.
165
- var IFRAME_LAYOUT_PROPS = ['position', 'top', 'right', 'bottom', 'left',
166
- 'margin', 'float', 'vertical-align', 'z-index'];
167
-
168
- function iframeFootprintStyle(node) {
169
- var f = iframeFootprint(node);
170
- var css = '';
171
- var display = 'inline-block';
172
- try {
173
- var view = node.ownerDocument && node.ownerDocument.defaultView;
174
- if (view && typeof view.getComputedStyle === 'function') {
175
- var cs = view.getComputedStyle(node);
176
- // display: keep the computed value except 'inline' — a span needs
177
- // inline-block (or stronger) to honor explicit width/height.
178
- if (cs.display && cs.display !== 'inline') display = cs.display;
179
- for (var i = 0; i < IFRAME_LAYOUT_PROPS.length; i++) {
180
- var prop = IFRAME_LAYOUT_PROPS[i];
181
- var v = cs.getPropertyValue(prop);
182
- if (v && v !== 'auto' && v !== 'none' && v !== 'normal' &&
183
- v !== 'baseline' && v !== '0px') {
184
- css += prop + ':' + v + ';';
185
- }
186
- }
187
- }
188
- } catch (e) { /* fall back to the plain inline-block footprint */ }
189
- return 'display:' + display + ';' + css +
190
- 'width:' + f.w + 'px;height:' + f.h + 'px';
191
- }
192
-
193
- function serializeIframePlaceholder(node, opts, out) {
194
- // <span>, not <div>: iframes are phrasing content, so the placeholder must
195
- // be too — a div inside <p> would trigger parser reparenting (implicit </p>)
196
- // and shift the reconstructed layout the placeholder exists to preserve.
197
- out.push('<span data-ch-iframe=""');
198
- if (opts.markers) {
199
- out.push(' ' + opts.markers.attr + '="' + opts.markers.refFor(node) + '"');
200
- }
201
- out.push(' style="' + iframeFootprintStyle(node) + '"></span>');
202
- }
203
-
204
- function getAttrValue(node, name) {
205
- var attrs = node.attributes || [];
206
- for (var i = 0; i < attrs.length; i++) {
207
- if (attrs[i].name === name) return attrs[i].value;
208
- }
209
- return '';
210
- }
211
-
212
- function serializeNode(node, opts, out) {
213
- if (!node) return;
214
- if (node.nodeType === TEXT_NODE) {
215
- out.push(escapeHtml(node.textContent || ''));
216
- return;
217
- }
218
- if (node.nodeType !== ELEMENT_NODE) return; // comments, PIs: irrelevant
219
- var tag = node.tagName;
220
- if (SKIP_TAGS[tag]) return;
221
- if (isBait(node, opts)) return;
222
- if (tag === 'IFRAME') { serializeIframePlaceholder(node, opts, out); return; }
223
-
224
- var lower = tag.toLowerCase();
225
- out.push('<' + lower);
226
- if (opts.markers) {
227
- out.push(' ' + opts.markers.attr + '="' + opts.markers.refFor(node) + '"');
228
- }
229
- // Shadow roots are not serializable from the outside: the host's box
230
- // survives but its rendered content does not. Mark it so the viewer can
231
- // refuse to "verify" interactions against a hollow reconstruction.
232
- if (node.shadowRoot) out.push(' data-ch-shadow=""');
233
-
234
- var redactValue = isRedacted(node, opts);
235
- var srcOverride = (tag === 'IMG' && node.src) ? node.src : null;
236
- var wroteSrc = false;
237
-
238
- var attrs = node.attributes || [];
239
- for (var i = 0; i < attrs.length; i++) {
240
- var name = attrs[i].name;
241
- var value = attrs[i].value;
242
- if (!isSerializableAttr(name)) continue;
243
- if (name === 'value' && redactValue) continue;
244
- if (name === 'src' && srcOverride) { value = srcOverride; wroteSrc = true; }
245
- out.push(' ' + name + '="' + escapeHtml(value) + '"');
246
- }
247
- if (srcOverride && !wroteSrc) out.push(' src="' + escapeHtml(srcOverride) + '"');
248
- if (redactValue) out.push(' data-ch-redacted="true"');
249
- out.push('>');
250
-
251
- if (!VOID_TAGS[tag]) {
252
- // A redacted element's text children (e.g. contenteditable content) are
253
- // withheld too — otherwise typed text under a redactSelector match would
254
- // leak straight into the snapshot despite the marker on the element.
255
- if (!redactValue) {
256
- var kids = node.childNodes || [];
257
- for (var k = 0; k < kids.length; k++) serializeNode(kids[k], opts, out);
54
+ var sheets = (doc && doc.styleSheets) || [];
55
+ for (var i = 0; i < sheets.length; i++) {
56
+ var sheet = sheets[i];
57
+ var href = sheet.href || null;
58
+ var media = sheet.media && sheet.media.mediaText ? sheet.media.mediaText : null;
59
+ var css = null;
60
+ try {
61
+ var rules = sheet.cssRules;
62
+ var text = [];
63
+ for (var r = 0; r < rules.length; r++) text.push(rules[r].cssText);
64
+ css = text.join('\n');
65
+ } catch (e) {
66
+ css = null; // cross-origin: unreadable from here
258
67
  }
259
- out.push('</' + lower + '>');
68
+ out.push(href
69
+ ? { id: i + 1, kind: 'link', href: href, css: css, media: media }
70
+ : { id: i + 1, kind: 'inline', css: css == null ? '' : css, media: media });
260
71
  }
72
+ return out;
261
73
  }
262
74
 
263
75
  /**
264
- * Child-index path from root to node; [] for the root itself; null if the
265
- * node is not under the root (e.g. extension-injected DOM outside the
266
- * experiment container).
76
+ * Inline the text of href-only link sheets by fetching them (2026-09-03).
77
+ *
78
+ * `captureStylesheets` cannot read a cross-origin sheet's rules — the browser
79
+ * refuses `cssRules` under the same-origin policy — so such a sheet ships
80
+ * href-only and the viewer plays unstyled until an analyst opts into fetching
81
+ * it there. A fresh CORS `fetch()` of the same URL is a different operation
82
+ * the server may permit (CDNs such as jsdelivr do), so the text can be inlined
83
+ * HERE, where the page is, and the recording becomes self-contained; the
84
+ * viewer's no-network frame then needs nothing (spec §12 stays intact).
85
+ *
86
+ * Mutates the entries IN PLACE: the recorder holds these same objects
87
+ * (`setStylesheets`), so a fill that lands before finalize is what ships.
88
+ * Off the critical path — the caller does not await the returned promise; a
89
+ * finalize that beats the fetch ships href-only, which is today's behaviour.
90
+ * Any failure (no fetch, CORS refusal, non-2xx, network) leaves `css: null`.
91
+ * Only http(s) hrefs are tried: blob:/data: sheets cannot be re-fetched.
92
+ * No cookies are sent (`credentials: 'omit'`) — a stylesheet needs none, and
93
+ * a participant's session must never ride on a recorder's request.
267
94
  */
268
- export function nodePath(node, root) {
269
- var path = [];
270
- var cur = node;
271
- while (cur && cur !== root) {
272
- var parent = cur.parentNode;
273
- if (!parent) return null;
274
- var idx = indexOfChild(parent, cur);
275
- if (idx === -1) return null;
276
- path.unshift(idx);
277
- cur = parent;
95
+ export function fillCrossOriginSheets(sheets, fetchImpl) {
96
+ if (typeof fetchImpl !== 'function' || !Array.isArray(sheets)) return Promise.resolve();
97
+ var pending = [];
98
+ for (var i = 0; i < sheets.length; i++) {
99
+ var sheet = sheets[i];
100
+ if (!sheet || sheet.kind !== 'link' || sheet.css != null) continue;
101
+ if (typeof sheet.href !== 'string' || !/^https?:/i.test(sheet.href)) continue;
102
+ pending.push(fillOne(sheet, fetchImpl));
278
103
  }
279
- return cur === root ? path : null;
280
- }
281
-
282
- function indexOfChild(parent, child) {
283
- var kids = parent.childNodes || [];
284
- for (var i = 0; i < kids.length; i++) if (kids[i] === child) return i;
285
- return -1;
104
+ return Promise.all(pending).then(function () { /* settled */ });
286
105
  }
287
106
 
288
- /**
289
- * Translates one MutationRecord into a JSON patch entry, or null when the
290
- * mutation should not be recorded (bait subtree, node outside root).
291
- */
292
- export function mutationToPatch(record, root, opts) {
293
- var target = record.target;
294
- // Walk up: mutations inside bait subtrees are never recorded.
295
- var cur = target;
296
- while (cur) {
297
- if (isBait(cur, opts)) return null;
298
- cur = cur.parentNode;
299
- }
300
- // With a marker registry, characterData mutations are recorded as the
301
- // PARENT's resulting-children snapshot: text nodes are not parser-stable
302
- // references (adjacent text nodes merge on reparse; empty ones vanish), so
303
- // a text-node address can resolve to the wrong node in the reconstruction.
304
- // The parent element IS stable via its marker, and a children snapshot is
305
- // idempotent like every other childList patch.
306
- if (record.type === 'characterData' && opts.markers) {
307
- var parent = target.parentNode;
308
- if (!parent || nodePath(parent, root) === null) return null;
309
- record = { type: 'childList', target: parent };
310
- target = parent;
311
- }
312
-
313
- var path = nodePath(target, root);
314
- if (path === null) return null;
315
-
316
- // Marker reference: the parser-stable address the viewer resolves first;
317
- // the child-index path stays as a diagnostic fallback. `tag` is the tag
318
- // EXPECTED IN THE RECONSTRUCTION (that's what resolution validates) — for
319
- // iframes that is the placeholder span, not the source tag.
320
- var ref = opts.markers && target.nodeType === ELEMENT_NODE
321
- ? { n: opts.markers.refFor(target),
322
- tag: target.tagName === 'IFRAME' ? 'span' : target.tagName.toLowerCase() }
323
- : null;
324
-
325
- if (record.type === 'childList') {
326
- // State-snapshot patch: removed nodes are already detached (they have
327
- // no address), so a faithful add/remove log is impossible. Serializing
328
- // the target's RESULTING children makes application an idempotent
329
- // innerHTML assignment — and backward seeks just rebuild from the
330
- // initial DOM and re-apply patches in order.
331
- //
332
- // A childList mutation on (or inside) a redacted element emits empty
333
- // children: serializeChildren walks the target's children directly, so it
334
- // would otherwise leak text/inputs that serializeNode hides when it reaches
335
- // the redacted element itself.
336
- return Object.assign({
337
- op: 'childList',
338
- path: path,
339
- html: isInRedactedSubtree(target, opts) ? '' : serializeChildren(target, opts)
340
- }, ref || {});
341
- }
342
- if (record.type === 'attributes') {
343
- // The reconstruction holds a placeholder SPAN where the iframe was, and
344
- // width/height ATTRIBUTES don't size a span — so any attribute change on
345
- // an iframe is translated into a fresh footprint style patch instead of
346
- // forwarding an attribute the placeholder can't honor.
347
- if (target.tagName === 'IFRAME') {
348
- return Object.assign(
349
- { op: 'attributes', path: path, name: 'style', value: iframeFootprintStyle(target) },
350
- ref || {});
351
- }
352
- var name = record.attributeName;
353
- if (!isSerializableAttr(name)) return null;
354
- var value = typeof target.getAttribute === 'function'
355
- ? target.getAttribute(name) : null;
356
- // Never leak the value attribute of a redacted field (password or
357
- // redactSelector match, including via a redacted ancestor).
358
- if (name === 'value' && isInRedactedSubtree(target, opts)) value = null;
359
- return Object.assign({ op: 'attributes', path: path, name: name, value: value }, ref || {});
360
- }
361
- if (record.type === 'characterData') {
362
- // characterData targets are TEXT NODES, so redaction must consult the
363
- // containing element(s): typing into a redacted contenteditable must not
364
- // carry the typed text through the patch.
365
- var text = isInRedactedSubtree(target, opts) ? '' : (target.textContent || '');
366
- return { op: 'characterData', path: path, value: text };
367
- }
368
- return null;
107
+ function fillOne(sheet, fetchImpl) {
108
+ return Promise.resolve()
109
+ .then(function () { return fetchImpl(sheet.href, { mode: 'cors', credentials: 'omit' }); })
110
+ .then(function (res) {
111
+ if (!res || !res.ok) return null;
112
+ return res.text();
113
+ })
114
+ .then(function (text) {
115
+ if (typeof text === 'string' && sheet.css == null) sheet.css = text;
116
+ })
117
+ .catch(function () { /* stays href-only */ });
369
118
  }
370
119
 
371
- // Serializes only the CHILDREN of a node (innerHTML semantics) — used by
372
- // the childList state-snapshot patch.
373
- function serializeChildren(node, opts) {
374
- var out = [];
375
- var kids = node.childNodes || [];
376
- for (var i = 0; i < kids.length; i++) serializeNode(kids[i], opts, out);
377
- return out.join('');
120
+ // How many characters a keyframe payload costs, as the wire would carry it.
121
+ // Exact rather than estimated: the trial's size budget is a bound on the
122
+ // payload, and the payload is a JSON tree, so its JSON length is the thing
123
+ // being bounded. Measured ONCE per keyframe and used twice — to decide whether
124
+ // the snapshot itself is over budget, and to seed the recorder's per-trial
125
+ // character count, which cannot see anything stored on the trial.
126
+ function payloadChars(value) {
127
+ if (value == null) return 0;
128
+ try { return JSON.stringify(value).length; } catch (e) { return 0; }
378
129
  }
379
130
 
380
131
  /**
381
- * Initial stylesheet capture: css text where readable, href fallback for
382
- * cross-origin sheets (viewer inlines css; hrefs render as absolute links).
132
+ * Keyframe or continuation? (spec §3's cadence guidance.)
133
+ *
134
+ * Pure, so the boundaries a scripted DOM cannot hit on purpose are testable
135
+ * directly. `cadence` is the bookkeeping the attachment keeps per span:
136
+ *
137
+ * `hasKeyframe` has a keyframe of THIS span reached the file? False at
138
+ * the start of a recording and after a keyframe was
139
+ * dropped or threw — in both cases there is nothing for
140
+ * a continuation to continue from, and §3 requires the
141
+ * first DOM-bearing segment to be a keyframe.
142
+ * `segments` segments opened in this span, the keyframe included.
143
+ * `patchChars` the plan's `bytesSinceKeyframe`. See below.
144
+ * `lastSnapshotChars` what the span's keyframe cost the file: the exact JSON
145
+ * length of its tree plus its `initial_state` seed (the
146
+ * same number `noteSnapshotChars` gets). 0 when no
147
+ * keyframe has been measured.
148
+ *
149
+ * WHAT `patchChars` COUNTS, exactly: the sum over every observer batch flushed
150
+ * since the keyframe of `JSON.stringify(patches).length`, where `patches` is
151
+ * the array of `dom.*` events that batch mapped to.
152
+ *
153
+ * - CHARS, not bytes. The plan and design call it `bytesSinceKeyframe`; it is
154
+ * measured in the same unit as `maxCharsPerTrial` (UTF-16 code units, which
155
+ * UTF-8 byte size can exceed for non-ASCII), so it carries that config's
156
+ * name for that config's reason. Both sides of the comparison use it, so
157
+ * the ratio the trigger actually tests is unit-free.
158
+ * - `dom.*` ONLY, on FILE-SIZE grounds — not because trace events carry no
159
+ * state. Some of them plainly do: `input.value`, `scroll.*` and `media.*`
160
+ * are exactly the state `initial_state` re-states (spec §3 `form`,
161
+ * `scroll`, `element_scroll`, `media`), and their keyframe-side cost is
162
+ * priced into `lastSnapshotChars` through the seed. What makes them
163
+ * uncountable here is that a keyframe does not REMOVE them from the file —
164
+ * playback needs every one of them — so §3's "at parity the keyframe is
165
+ * free" argument, which is the whole basis of this comparison, does not
166
+ * apply. Counting them would keyframe on volume the keyframe cannot
167
+ * reclaim. The consequence is real and belongs on the record: on a
168
+ * form-heavy or scroll-heavy but DOM-STATIC segment this trigger can never
169
+ * fire, and `keyframeEvery` becomes the only bound on seek distance and on
170
+ * a corrupt event's blast radius — §3's failure mode from the other side.
171
+ * - AT EMISSION, before the recorder's per-trial caps can refuse a record, and
172
+ * off the in-memory event (absolute `t`) rather than the wire event
173
+ * (session-relative, usually shorter). Both make the count an over-estimate:
174
+ * MEASURED at 1.08x, 5.3 chars per patch over 12 `dom.attr` patches (864
175
+ * in-memory against 800 on the wire), all of it the timestamp. It biases
176
+ * toward keyframing sooner — the direction that shortens seek distance and
177
+ * shrinks the blast radius of a corrupt event.
178
+ *
179
+ * @param {{hasKeyframe: boolean, segments: number, patchChars: number,
180
+ * lastSnapshotChars: number}} cadence
181
+ * @param {number|null} keyframeEvery segment fallback (recorder config)
383
182
  */
384
- export function captureStylesheets(doc) {
385
- var out = [];
386
- var sheets = (doc && doc.styleSheets) || [];
387
- for (var i = 0; i < sheets.length; i++) {
388
- var sheet = sheets[i];
389
- try {
390
- var rules = sheet.cssRules;
391
- var css = [];
392
- for (var r = 0; r < rules.length; r++) css.push(rules[r].cssText);
393
- out.push({ css: css.join('\n') });
394
- } catch (e) {
395
- // Cross-origin sheet: record the (absolute) href so the viewer can
396
- // at least link it; content is unreadable from this origin.
397
- out.push({ href: sheet.href || null });
398
- }
399
- }
400
- return out;
183
+ export function shouldKeyframe(cadence, keyframeEvery) {
184
+ if (!cadence.hasKeyframe) return true;
185
+ // The fallback: at most `keyframeEvery` segments per span, so segment N of a
186
+ // span (counting the keyframe as 1) is where the next keyframe lands.
187
+ //
188
+ // The `typeof` is deliberately strict, and diverges from the sibling caps —
189
+ // `maxViewportChanges` would coerce a string `"10"` and work. Here a wrong
190
+ // TYPE must not silently become a different CADENCE: `"10"` from a JSON
191
+ // config or a URL parameter would compare as a string and disable the
192
+ // fallback, changing the shape of the researcher's file with no other
193
+ // symptom. It is refused rather than coerced, and `attachDomCapture` warns
194
+ // once about it at attach, which is where a misconfiguration is still
195
+ // fixable.
196
+ if (typeof keyframeEvery === 'number' && keyframeEvery >= 1 &&
197
+ cadence.segments >= keyframeEvery) return true;
198
+ // The size trigger: spec §3's self-tuning rule — take a keyframe once the
199
+ // accumulated mutation volume rivals a fresh snapshot's size, because at that
200
+ // point the keyframe is free in file size. `>=` so parity keyframes.
201
+ // Guarded on a measured keyframe: 0 would make the comparison vacuously true.
202
+ return cadence.lastSnapshotChars > 0 &&
203
+ cadence.patchChars >= cadence.lastSnapshotChars;
401
204
  }
402
205
 
403
206
  /**
404
207
  * Attaches tier-2 capture to a recorder:
405
- * - snapshots initial DOM + stylesheets at session start and at each
406
- * startTrial (recorder calls snapshotTrial via the returned hooks)
407
- * - MutationObserver → 'mutation' events
208
+ * - a KEYFRAME at the start of a segment the cadence asks for one at: the
209
+ * DomNode tree plus its `initial_state` seed, both taken on the shared
210
+ * capture span. Every other segment is a CONTINUATION of it (see
211
+ * `shouldKeyframe`)
212
+ * - MutationObserver batches → `dom.*` patch events
213
+ * - initial stylesheet capture at attach
408
214
  * - guard-friction cooperation: onViolation('start') fires synchronously
409
215
  * BEFORE obfuscateContent() (pinned by contract test), so the clean-DOM
410
216
  * snapshot taken inside the callback is genuinely pre-scramble.
217
+ *
218
+ * @param {object} rec the recorder
219
+ * @param {object} env {doc, win, now, MutationObserver} for testing, plus the
220
+ * two capture-wide objects the assembly (index.js) threads to BOTH capture
221
+ * modules:
222
+ * `span` the capture span (span.js) — node ids and the file's own
223
+ * record of what it contains. The SAME object capture-trace
224
+ * gets, because an event's `target` and a patch's `node` are
225
+ * the same numbering or neither means anything.
226
+ * `scrolled` a function returning capture-trace's set of elements that
227
+ * have scrolled, which the keyframe seed enumerates (spec §3
228
+ * `element_scroll`). Only the scroll listener knows this.
411
229
  */
412
230
  export function attachDomCapture(rec, env) {
413
231
  env = env || {};
@@ -416,112 +234,307 @@ export function attachDomCapture(rec, env) {
416
234
  var now = env.now || function () { return performance.now(); };
417
235
  var MutationObserverImpl = env.MutationObserver ||
418
236
  (typeof MutationObserver !== 'undefined' ? MutationObserver : null);
419
- // Per-recording marker nonce (see createMarkerRegistry). Registered on the
420
- // recorder so capture-trace can reference the same registry for interaction
421
- // anchors and the serializer can emit the attribute name on the wire.
422
- var markers = createMarkerRegistry(
423
- (Math.random().toString(16).slice(2, 10) || 'fallback0'));
424
- if (typeof rec.setMarkers === 'function') {
425
- rec.setMarkers(markers);
426
- rec.setMarkerAttr(markers.attr);
427
- }
428
- var opts = { keepBait: rec.config.keepBait, redactSelector: rec.config.redactSelector,
429
- markers: markers };
237
+ // A lone attachment (a test wiring this module by itself) gets its own span
238
+ // rather than an ambient shared one: a module-level default is what lets a
239
+ // stray serialization write into a live recording's model (span.js).
240
+ var span = env.span || createSpan();
241
+ var scrolled = typeof env.scrolled === 'function'
242
+ ? env.scrolled : function () { return null; };
243
+ // One options bag for every consumer — the snapshot walk, the mutation
244
+ // mapper, the seed and the guard snapshot. Exclusion and redaction cannot be
245
+ // answered one way in the keyframe and another way in a patch if there is
246
+ // only one answer to hand around.
247
+ var opts = {
248
+ keepBait: rec.config.keepBait,
249
+ redactSelector: rec.config.redactSelector,
250
+ taint: env.taint,
251
+ };
252
+
253
+ // THE OBSERVED ROOT, resolved ONCE.
254
+ //
255
+ // v1 re-resolved it at every snapshot and every batch, which let the keyframe
256
+ // and the patches addressing it describe two different elements if the page
257
+ // replaced the container — and the observer, attached to whichever element
258
+ // existed first, would have been watching neither. The recording's root is by
259
+ // definition the element the observer watches, so it is resolved here and
260
+ // held. (Task 4's residual: the seed's "did anything get walked" guard
261
+ // catches a never-walked span, not a walk of a DIFFERENT root. One root
262
+ // identity is what makes that unreachable rather than merely unlikely.)
263
+ // Did the held root actually come from the configured selector? Resolving
264
+ // once turns a transient miss into a permanent one, so the answer has to
265
+ // reach the file rather than being assumed (see rootSelector below).
266
+ var rootFromSelector = false;
267
+ var root = resolveRoot();
430
268
 
431
269
  function resolveRoot() {
432
270
  var r = rec.config.root;
433
- if (typeof r === 'string') {
434
- try { return doc.querySelector(r) || doc.body; } catch (e) { return doc.body; }
271
+ if (typeof r === 'string' && r) {
272
+ var found = null;
273
+ try { found = doc.querySelector(r); } catch (e) { found = null; }
274
+ if (found) { rootFromSelector = true; return found; }
275
+ // A selector that matches nothing (or does not parse) is a study
276
+ // misconfiguration, and the fallback is silent everywhere else: the
277
+ // recording looks complete and simply describes a different subtree.
278
+ rec.captureFailure('observed_root', new Error(
279
+ 'root selector "' + r + '" matched nothing at startSession(); ' +
280
+ 'observing document.body instead'));
281
+ return doc.body;
435
282
  }
436
283
  return r || doc.body;
437
284
  }
438
285
 
286
+ // Spec §2 types `observed_root` as a SELECTOR, and it must name what was
287
+ // OBSERVED, not what was asked for. Returning the configured selector after
288
+ // the fallback would put a body-rooted tree in a file labelled `#stage`, and
289
+ // a conforming player honouring the field would mount it there.
290
+ //
291
+ // So: the configured selector only when the held root came from it; else the
292
+ // root's own id; else null, which is §2's spelling for "the document body"
293
+ // and exactly what the fallback observed. `null` alone would read as "the
294
+ // researcher configured nothing", which is why the capture failure above is
295
+ // the other half of this — together they are diagnosable.
296
+ function rootSelector() {
297
+ if (rootFromSelector) return rec.config.root;
298
+ if (root && root.id) return '#' + root.id;
299
+ return null;
300
+ }
301
+
302
+ rec.setObservedRoot(rootSelector());
303
+
439
304
  try {
440
- rec.setStylesheets(captureStylesheets(doc));
305
+ var sheets = captureStylesheets(doc);
306
+ rec.setStylesheets(sheets);
307
+ // Cross-origin sheets are href-only here; try to inline their text so the
308
+ // recording is self-contained (see fillCrossOriginSheets). Not awaited.
309
+ var view = doc && doc.defaultView;
310
+ if (view && typeof view.fetch === 'function') {
311
+ fillCrossOriginSheets(sheets, view.fetch.bind(view));
312
+ }
441
313
  } catch (e) { rec.captureFailure('stylesheets', e); }
442
314
 
443
- // Snapshot the initial DOM at the start of every trial (explicit or
444
- // implicit) — this is the frame the viewer reconstructs and patches.
315
+ // ── Keyframe cadence (spec §3) ──
445
316
  //
446
- // The initial snapshot is the single largest capture source and is stored on
447
- // the trial (not pushed as an event), so the recorder's per-event size cap
448
- // can't see it. Enforce the cap HERE at assignment: a snapshot longer than
449
- // maxCharsPerTrial is dropped (with a captureFailure) rather than retained,
450
- // so a giant DOM can't bypass the resource/payload limit. A dropped snapshot
451
- // makes that trial's replay show "no DOM captured" rather than blowing up.
317
+ // A segment either opens a new keyframe span — a full snapshot, ids restarting
318
+ // at 1 — or CONTINUES the current one, carrying `initial_dom: null` and
319
+ // nothing else. `shouldKeyframe` above owns the decision and documents the
320
+ // trigger; this is the bookkeeping it reads, one object per attachment, which
321
+ // is one per recording.
322
+ var cadence = {
323
+ hasKeyframe: false, segments: 0, patchChars: 0, lastSnapshotChars: 0,
324
+ };
325
+
326
+ // Said once, at attach, rather than per segment: the fallback is refused for
327
+ // a non-number (see `shouldKeyframe`), and silently recording a differently
328
+ // shaped file is exactly what the strictness exists to prevent.
329
+ var kfEvery = rec.config.keyframeEvery;
330
+ if (kfEvery != null && typeof kfEvery !== 'number') {
331
+ console.warn('[cyborg-hunter-replay] keyframeEvery must be a number; got ' +
332
+ typeof kfEvery + ' (' + JSON.stringify(kfEvery) + '). The segment fallback ' +
333
+ 'is DISABLED for this recording — keyframes will be taken on accumulated ' +
334
+ 'patch size alone.');
335
+ }
336
+
452
337
  rec.onTrialStart(function (trial) {
338
+ // An IMPLICIT segment is opened RE-ENTRANTLY from inside `pushRecord`
339
+ // (recorder.js's storeEvent), and the capture path doing that push has
340
+ // ALREADY resolved its node ids against the current span — mapMutations for
341
+ // a whole batch, targetFacts for one event — into records that are about to
342
+ // be stored. A keyframe here calls `span.reset()`, so those ids name a span
343
+ // the file no longer describes, and because a fresh pre-order walk reuses
344
+ // the same small integers they do not dangle harmlessly: the player
345
+ // resolves them against the new tree. Reproduced as a `dom.remove` that
346
+ // deletes a node the participant never lost, and a `mouse.click` whose
347
+ // `target` names a different element than its own `anchor.tag`. Strict
348
+ // validation passes it — `node` fields are only number-checked.
349
+ //
350
+ // So an implicit segment always CONTINUES. The single exception is a span
351
+ // with no keyframe at all, where §3 still forces one below — and in that
352
+ // state the span holds nothing, so no id in flight can be stale: a mapped
353
+ // batch produces no events, and a trace event's target already resolves to
354
+ // null. (One consequence of that exception is recorded at recorder.js's
355
+ // implicit-trial branch: the event that opens such a segment loses its
356
+ // `target` id.)
357
+ //
358
+ // This is the only re-entrant path into `span.reset()`: its three call
359
+ // sites are all in this hook, and `fireTrialStart` reaches it from
360
+ // `startTrial` (host-driven, never re-entrant) and from `storeEvent`
361
+ // (implicit only). Closing the implicit branch closes it for every capture
362
+ // module at once, which a check inside any one module could not do.
363
+ if (trial.implicit && cadence.hasKeyframe) {
364
+ cadence.segments++;
365
+ return;
366
+ }
367
+ if (!shouldKeyframe(cadence, rec.config.keyframeEvery)) {
368
+ // A CONTINUATION. Nothing to do, and that is the point: the trial's
369
+ // `initialDom`/`initialState` are already null (recorder.js), the span is
370
+ // NOT reset, so every id the keyframe assigned stays valid and every node
371
+ // the player holds stays held. A seed here would be wrong rather than
372
+ // merely redundant — `initial_state` states what was true BEFORE a
373
+ // keyframe (spec §3), and a continuation has no keyframe to precede.
374
+ // The state it would re-state is already in the file as the patches and
375
+ // events of the segments since, which the player has replayed by the time
376
+ // it arrives here.
377
+ cadence.segments++;
378
+ return;
379
+ }
380
+ // A KEYFRAME. The order is a contract, not a preference: reset, then walk,
381
+ // then seed. `span.reset()` restarts ids at 1 and empties the delivered
382
+ // picture in one call (span.js — never `span.registry.resetSpan()` alone,
383
+ // which silently loses nodes), the walk is the span's FIRST allocation so
384
+ // the tree numbers 1..N, and the seed reads the ids that walk assigned.
385
+ // Taken in any other order the seed names nodes the file does not contain.
453
386
  try {
454
- var html = serializeDom(resolveRoot(), opts);
387
+ span.reset();
388
+ var tree = serializeTree(root, span, opts);
389
+ var seed = buildInitialState(root, span, {
390
+ win: win,
391
+ scrolled: scrolled(),
392
+ keepBait: opts.keepBait,
393
+ redactSelector: opts.redactSelector,
394
+ taint: opts.taint,
395
+ });
396
+ var chars = payloadChars(tree) + payloadChars(seed);
455
397
  var cap = rec.config.maxCharsPerTrial;
456
- if (cap != null && html.length > cap) {
398
+ if (cap != null && chars > cap) {
399
+ // The keyframe is the single largest capture source and is stored on
400
+ // the trial rather than pushed as an event, so the recorder's cap
401
+ // cannot refuse it — it is refused HERE, or a giant DOM bypasses the
402
+ // payload limit entirely. The trial then replays as "no DOM captured".
457
403
  rec.captureFailure('dom_snapshot', new Error(
458
- 'initial DOM snapshot length ' + html.length + ' exceeds maxCharsPerTrial ' +
404
+ 'initial DOM keyframe of ' + chars + ' chars exceeds maxCharsPerTrial ' +
459
405
  cap + '; dropped to bound payload size'));
460
- trial.initialDom = '';
406
+ trial.initialDom = null;
407
+ trial.initialState = null;
408
+ // The walk told the span the player holds this tree. It does not: the
409
+ // keyframe is not in the file. Resetting again empties that picture, so
410
+ // the patches that follow address nothing and are dropped rather than
411
+ // naming ids no player ever received.
412
+ span.reset();
413
+ keyframeFailed();
461
414
  } else {
462
- trial.initialDom = html;
415
+ trial.initialDom = tree;
416
+ trial.initialState = seed;
417
+ rec.noteSnapshotChars(trial, chars);
418
+ cadence.hasKeyframe = true;
419
+ cadence.segments = 1;
420
+ cadence.patchChars = 0;
421
+ cadence.lastSnapshotChars = chars;
463
422
  }
464
423
  } catch (e) {
465
424
  rec.captureFailure('dom_snapshot', e);
466
- trial.initialDom = '';
425
+ trial.initialDom = null;
426
+ trial.initialState = null;
427
+ span.reset();
428
+ keyframeFailed();
467
429
  }
468
430
  });
469
431
 
432
+ // A keyframe that was dropped or threw leaves the file with no tree for this
433
+ // span, so the next segment must take one rather than continue from nothing:
434
+ // a continuation carrying dom.* patches before any keyframe is a recording no
435
+ // player can reconstruct, and strict validation rejects it (spec §3).
436
+ //
437
+ // ONE of these four assignments has behaviour, and the reader is owed that
438
+ // plainly. `hasKeyframe = false` is what forces the retry; the other three are
439
+ // DEAD BY CONSTRUCTION and kept deliberately. Proof, not opinion: rule 1 of
440
+ // `shouldKeyframe` short-circuits before reading `segments`, `patchChars` or
441
+ // `lastSnapshotChars`, and the next keyframe that lands rewrites all four —
442
+ // verified by inversion, since reducing this function to its single live line
443
+ // passes the whole suite. They stay because they make the cadence state a true
444
+ // description of the file (this span has no keyframe, so it has no segments,
445
+ // no accumulated patches and no measured snapshot) and because they make the
446
+ // retry singly-caused: without them the retry would still happen, from the
447
+ // stale trigger values that asked for the failed keyframe, which is a
448
+ // coincidence rather than a rule.
449
+ //
450
+ // Fault-injection consequence, so nobody reads the four as jointly guarded:
451
+ // deleting this call altogether is NOT observable (the stale counters retry
452
+ // anyway); deleting the `hasKeyframe` line alone IS, and is pinned.
453
+ function keyframeFailed() {
454
+ cadence.hasKeyframe = false;
455
+ cadence.segments = 0;
456
+ cadence.patchChars = 0;
457
+ cadence.lastSnapshotChars = 0;
458
+ }
459
+
460
+ // ── Mutations (spec §5.1) ──
470
461
  if (MutationObserverImpl) {
471
- var observer = new MutationObserverImpl(function (records) {
462
+ var handleBatch = function (records) {
472
463
  try {
473
- var root = resolveRoot();
474
- // Intra-batch dedup: ONE childList patch per TARGET NODE, emitted at
475
- // the target's FIRST record position. A single task appending N
476
- // children to one container delivers N childList records, and each
477
- // patch serializes the target's FULL resulting children — O(N²)
478
- // work on the participant's machine without dedup. Serialization
479
- // happens at callback time (final state), so every record for a
480
- // target would carry identical html — only ORDER matters, and
481
- // first-occurrence order guarantees a node created in-batch has its
482
- // ancestor's creating patch emitted BEFORE any patch targeting the
483
- // new node (observer records are chronological). Keyed by node
484
- // IDENTITY, not path (path-keyed dedup was refuted: same-batch
485
- // index aliasing). attributes/characterData records never skipped.
486
- // Dedup key = the EFFECTIVE childList target: in marker mode,
487
- // characterData records become parent-level childList snapshots
488
- // inside mutationToPatch, so N text rewrites on one node are the
489
- // same O(N²) storm as N appends and must dedup on the PARENT.
490
- var seenChildListTargets = new Set();
491
- var effectiveChildListTarget = function (record) {
492
- if (record.type === 'childList') return record.target;
493
- if (record.type === 'characterData' && opts.markers &&
494
- record.target && record.target.parentNode) {
495
- return record.target.parentNode;
496
- }
497
- return null;
498
- };
499
- for (var i = 0; i < records.length; i++) {
500
- var key = effectiveChildListTarget(records[i]);
501
- if (key) {
502
- if (seenChildListTargets.has(key)) continue;
503
- seenChildListTargets.add(key);
504
- }
505
- var patch = mutationToPatch(records[i], root, opts);
506
- if (patch) rec.pushEvent('mutation', patch, now());
464
+ // The COMPLETE batch, in the observer's own order. mapMutations
465
+ // pre-scans it (which removals and insertions are still to come, what
466
+ // each attribute held before the batch) and that pre-scan is what makes
467
+ // the mapping batch-coherent — filtering or reordering records here
468
+ // would silently break it.
469
+ var events = mapMutations(records, {
470
+ root: root,
471
+ span: span,
472
+ // One callback = one `t` (spec §7): the batch is one task's worth of
473
+ // DOM change, and the observer reports it with no per-record times.
474
+ t: now(),
475
+ keepBait: opts.keepBait,
476
+ redactSelector: opts.redactSelector,
477
+ taint: opts.taint,
478
+ });
479
+ for (var i = 0; i < events.length; i++) {
480
+ rec.pushRecord(events[i], events[i].t);
507
481
  }
482
+ // What the span has cost in deltas so far (see `shouldKeyframe`).
483
+ // Measured once per batch rather than once per patch: one stringify of
484
+ // the array is the cheaper call and the closer estimate of what the
485
+ // events cost as a group on the wire.
486
+ if (events.length) cadence.patchChars += payloadChars(events);
508
487
  } catch (e) { rec.captureFailure('mutations', e); }
509
- });
488
+ };
489
+ var observer = new MutationObserverImpl(handleBatch);
510
490
  try {
511
- observer.observe(resolveRoot(), {
512
- childList: true, attributes: true, characterData: true, subtree: true
513
- });
491
+ observer.observe(root, MUTATION_OBSERVER_INIT);
514
492
  // Registered through the listener registry with the observer marker so
515
493
  // recorder.destroy() disconnects it (same convention as core monitor).
516
494
  rec.addListener(
517
495
  { addEventListener: function () {}, removeEventListener: function () {} },
518
496
  '_mutation_observer', observer, { _isObserver: true });
497
+ // The observer callback is a MICROTASK, so DOM changes made in the same
498
+ // task as stopSession() are still queued when the recording closes and
499
+ // disconnecting drops them silently. Draining the queue through the SAME
500
+ // handler is what makes the last thing a participant saw a patch rather
501
+ // than a gap (T3 final review, F-5).
502
+ rec.addPreCloseFlush(function () {
503
+ var pending = observer.takeRecords();
504
+ if (pending && pending.length) handleBatch(pending);
505
+ });
519
506
  } catch (e) { rec.captureFailure('mutations', e); }
520
507
  }
521
508
 
522
- // Guard-friction cooperation: snapshot the clean DOM synchronously when a
523
- // violation starts (before friction scrambles content), so the analyst can
524
- // see exactly what the participant saw at the moment of violation.
509
+ // ── Guard-friction cooperation ──
510
+ // Snapshot the clean DOM synchronously when a violation starts (before
511
+ // friction scrambles content), so the analyst can see exactly what the
512
+ // participant saw at the moment of violation.
513
+ //
514
+ // The snapshot is taken on a THROWAWAY SPAN. It is not part of the recording
515
+ // the player replays — it is a CH diagnostic in the vendor namespace — so
516
+ // numbering it into the live span would tell the live recording that the
517
+ // player holds nodes it was never sent, and suppress the next patch for each
518
+ // of them (span.js, D1). Its ids are internal to itself and start at 1; a
519
+ // reader compares it to the recording by structure, not by id.
520
+ //
521
+ // The violation itself is a session-level vendor entry, not an event: spec
522
+ // §5.8 admits no vendor event types in the stream, and there is no standard
523
+ // event for "the participant left fullscreen".
524
+ function guardSnapshot() {
525
+ var tree = serializeTree(root, createSpan(), opts);
526
+ var cap = rec.config.maxCharsPerTrial;
527
+ // Bounded by the same budget a keyframe is: this is a whole second copy of
528
+ // the DOM, and unlike a keyframe it rides a session-level array that no
529
+ // per-trial cap can see.
530
+ if (cap != null && payloadChars(tree) > cap) {
531
+ rec.captureFailure('guard_violation', new Error(
532
+ 'pre-scramble DOM snapshot exceeds maxCharsPerTrial ' + cap + '; withheld'));
533
+ return null;
534
+ }
535
+ return tree;
536
+ }
537
+
525
538
  if (win.GuardFriction && typeof win.GuardFriction.onViolation === 'function') {
526
539
  try {
527
540
  // Late-subscription hardening: if a violation is ALREADY in progress
@@ -532,14 +545,13 @@ export function attachDomCapture(rec, env) {
532
545
  // misses an ongoing violation.
533
546
  // Local invariant guard: assembly (index.js) only attaches captures
534
547
  // after startSession(), but make that self-evident here — synthesize
535
- // only when the recorder is actually recording, so no wiring change
536
- // can ever route this through a pre-session pushEvent.
548
+ // only when the recorder is actually recording.
537
549
  var recState = rec.getState().state;
538
550
  if ((recState === 'session' || recState === 'trial') &&
539
551
  typeof win.GuardFriction.getCurrentState === 'function') {
540
552
  var gs = win.GuardFriction.getCurrentState();
541
553
  if (gs && gs.in_violation) {
542
- rec.pushEvent('ch:guard_violation', {
554
+ rec.pushGuardViolation({
543
555
  reason: gs.current_reason || 'unknown',
544
556
  phase: 'start',
545
557
  synthesized_at_subscribe: true,
@@ -548,20 +560,20 @@ export function attachDomCapture(rec, env) {
548
560
  // whatever the DOM looks like right now, and its name must not
549
561
  // overclaim (an analyst could otherwise read scrambled content
550
562
  // as what the participant "really saw" pre-violation).
551
- dom_at_subscribe: serializeDom(resolveRoot(), opts)
563
+ dom_at_subscribe: guardSnapshot()
552
564
  }, now());
553
565
  }
554
566
  }
555
567
  win.GuardFriction.onViolation(function (violation) {
556
568
  try {
557
569
  if (violation && violation.phase === 'start') {
558
- rec.pushEvent('ch:guard_violation', {
570
+ rec.pushGuardViolation({
559
571
  reason: violation.reason,
560
572
  phase: 'start',
561
- pre_scramble_dom: serializeDom(resolveRoot(), opts)
573
+ pre_scramble_dom: guardSnapshot()
562
574
  }, now());
563
575
  } else if (violation && violation.phase) {
564
- rec.pushEvent('ch:guard_violation', {
576
+ rec.pushGuardViolation({
565
577
  reason: violation.reason, phase: violation.phase,
566
578
  duration_ms: violation.duration != null ? violation.duration : null
567
579
  }, now());