cyborg-hunter 0.4.0 → 0.7.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 (46) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/CITATION.cff +29 -0
  3. package/LICENSE +21 -0
  4. package/README.md +117 -79
  5. package/package.json +10 -3
  6. package/src/cli/analyzers/edge-exit.js +4 -1
  7. package/src/cli/analyzers/phase-scope.js +83 -0
  8. package/src/cli/analyzers/summary.js +161 -28
  9. package/src/cli/analyzers/triage.js +59 -27
  10. package/src/cli/config.js +26 -1
  11. package/src/cli/ingest.js +623 -41
  12. package/src/cli/init.js +1 -1
  13. package/src/cli/renderers/event-log.js +18 -19
  14. package/src/cli/renderers/extensions.js +12 -3
  15. package/src/cli/renderers/html-index.js +163 -24
  16. package/src/cli/renderers/replay-assets.js +177 -0
  17. package/src/cli/renderers/replay-viewer.client.js +1022 -0
  18. package/src/cli/renderers/session-timeline.js +917 -0
  19. package/src/cli/renderers/summary-csv.js +5 -0
  20. package/src/cli/renderers/trajectories.js +69 -8
  21. package/src/cli/renderers/triage-md.js +10 -4
  22. package/src/cli/renderers/typing-profile.js +7 -1
  23. package/src/cli/report.js +42 -8
  24. package/src/core/monitor.js +60 -7
  25. package/src/core/scoring.js +11 -2
  26. package/src/core/signals/browser.js +51 -18
  27. package/src/core/signals/clipboard.js +10 -2
  28. package/src/core/signals/dom-protection.js +9 -0
  29. package/src/core/signals/focus.js +16 -2
  30. package/src/jspsych/extension-cyborg-hunter-replay.js +135 -0
  31. package/src/jspsych/{extension.js → extension-cyborg-hunter.js} +9 -2
  32. package/src/jspsych/extension-guard-friction.js +1164 -0
  33. package/src/jspsych/extension-guard-honeypot.js +492 -0
  34. package/src/replay/capture-dom.js +575 -0
  35. package/src/replay/capture-trace.js +468 -0
  36. package/src/replay/index.js +104 -0
  37. package/src/replay/persistence.js +141 -0
  38. package/src/replay/recorder.js +315 -0
  39. package/src/replay/serializer.js +119 -0
  40. package/src/shared/constants.js +12 -6
  41. package/src/shared/schema.js +5 -0
  42. package/src/shared/validation.js +55 -0
  43. package/dist/cyborg-hunter.esm.js +0 -1527
  44. package/dist/cyborg-hunter.min.js +0 -6
  45. package/dist/jspsych-cyborg-hunter.js +0 -1
  46. package/src/cli/renderers/tab-timeline.js +0 -149
@@ -0,0 +1,575 @@
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.
4
+ //
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.
8
+ //
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 };
22
+
23
+ /**
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.
34
+ */
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 || {};
130
+ 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);
258
+ }
259
+ out.push('</' + lower + '>');
260
+ }
261
+ }
262
+
263
+ /**
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).
267
+ */
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;
278
+ }
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;
286
+ }
287
+
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;
369
+ }
370
+
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('');
378
+ }
379
+
380
+ /**
381
+ * Initial stylesheet capture: css text where readable, href fallback for
382
+ * cross-origin sheets (viewer inlines css; hrefs render as absolute links).
383
+ */
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;
401
+ }
402
+
403
+ /**
404
+ * 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
408
+ * - guard-friction cooperation: onViolation('start') fires synchronously
409
+ * BEFORE obfuscateContent() (pinned by contract test), so the clean-DOM
410
+ * snapshot taken inside the callback is genuinely pre-scramble.
411
+ */
412
+ export function attachDomCapture(rec, env) {
413
+ env = env || {};
414
+ var doc = env.doc || document;
415
+ var win = env.win || window;
416
+ var now = env.now || function () { return performance.now(); };
417
+ var MutationObserverImpl = env.MutationObserver ||
418
+ (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 };
430
+
431
+ function resolveRoot() {
432
+ var r = rec.config.root;
433
+ if (typeof r === 'string') {
434
+ try { return doc.querySelector(r) || doc.body; } catch (e) { return doc.body; }
435
+ }
436
+ return r || doc.body;
437
+ }
438
+
439
+ try {
440
+ rec.setStylesheets(captureStylesheets(doc));
441
+ } catch (e) { rec.captureFailure('stylesheets', e); }
442
+
443
+ // Snapshot the initial DOM at the start of every trial (explicit or
444
+ // implicit) — this is the frame the viewer reconstructs and patches.
445
+ //
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.
452
+ rec.onTrialStart(function (trial) {
453
+ try {
454
+ var html = serializeDom(resolveRoot(), opts);
455
+ var cap = rec.config.maxCharsPerTrial;
456
+ if (cap != null && html.length > cap) {
457
+ rec.captureFailure('dom_snapshot', new Error(
458
+ 'initial DOM snapshot length ' + html.length + ' exceeds maxCharsPerTrial ' +
459
+ cap + '; dropped to bound payload size'));
460
+ trial.initialDom = '';
461
+ } else {
462
+ trial.initialDom = html;
463
+ }
464
+ } catch (e) {
465
+ rec.captureFailure('dom_snapshot', e);
466
+ trial.initialDom = '';
467
+ }
468
+ });
469
+
470
+ if (MutationObserverImpl) {
471
+ var observer = new MutationObserverImpl(function (records) {
472
+ 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());
507
+ }
508
+ } catch (e) { rec.captureFailure('mutations', e); }
509
+ });
510
+ try {
511
+ observer.observe(resolveRoot(), {
512
+ childList: true, attributes: true, characterData: true, subtree: true
513
+ });
514
+ // Registered through the listener registry with the observer marker so
515
+ // recorder.destroy() disconnects it (same convention as core monitor).
516
+ rec.addListener(
517
+ { addEventListener: function () {}, removeEventListener: function () {} },
518
+ '_mutation_observer', observer, { _isObserver: true });
519
+ } catch (e) { rec.captureFailure('mutations', e); }
520
+ }
521
+
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.
525
+ if (win.GuardFriction && typeof win.GuardFriction.onViolation === 'function') {
526
+ try {
527
+ // Late-subscription hardening: if a violation is ALREADY in progress
528
+ // when replay attaches (friction started first — e.g. standalone
529
+ // wiring order, or a participant who was out of fullscreen from the
530
+ // very beginning), its phase:'start' emission is long gone. Synthesize
531
+ // it from friction's current state so the recording never silently
532
+ // misses an ongoing violation.
533
+ // Local invariant guard: assembly (index.js) only attaches captures
534
+ // 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.
537
+ var recState = rec.getState().state;
538
+ if ((recState === 'session' || recState === 'trial') &&
539
+ typeof win.GuardFriction.getCurrentState === 'function') {
540
+ var gs = win.GuardFriction.getCurrentState();
541
+ if (gs && gs.in_violation) {
542
+ rec.pushEvent('ch:guard_violation', {
543
+ reason: gs.current_reason || 'unknown',
544
+ phase: 'start',
545
+ synthesized_at_subscribe: true,
546
+ // NOT pre_scramble_dom: in enforcement mode the page may already
547
+ // be scrambled by the time we subscribe — this snapshot is
548
+ // whatever the DOM looks like right now, and its name must not
549
+ // overclaim (an analyst could otherwise read scrambled content
550
+ // as what the participant "really saw" pre-violation).
551
+ dom_at_subscribe: serializeDom(resolveRoot(), opts)
552
+ }, now());
553
+ }
554
+ }
555
+ win.GuardFriction.onViolation(function (violation) {
556
+ try {
557
+ if (violation && violation.phase === 'start') {
558
+ rec.pushEvent('ch:guard_violation', {
559
+ reason: violation.reason,
560
+ phase: 'start',
561
+ pre_scramble_dom: serializeDom(resolveRoot(), opts)
562
+ }, now());
563
+ } else if (violation && violation.phase) {
564
+ rec.pushEvent('ch:guard_violation', {
565
+ reason: violation.reason, phase: violation.phase,
566
+ duration_ms: violation.duration != null ? violation.duration : null
567
+ }, now());
568
+ }
569
+ } catch (e) { rec.captureFailure('guard_violation', e); }
570
+ });
571
+ } catch (e) { rec.captureFailure('guard_violation', e); }
572
+ }
573
+
574
+ // (No return value: all wiring goes through recorder hooks.)
575
+ }