cyborg-hunter 0.7.5 → 0.9.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 +74 -0
  2. package/CITATION.cff +2 -2
  3. package/README.md +22 -7
  4. package/dist/cyborg-hunter-replay.js +3 -3
  5. package/dist/cyborg-hunter.esm.js +5 -2
  6. package/dist/cyborg-hunter.min.js +3 -3
  7. package/dist/extension-cyborg-hunter.js +1 -1
  8. package/package.json +10 -2
  9. package/src/cli/analyzers/score-weights.js +145 -0
  10. package/src/cli/analyzers/triage.js +55 -39
  11. package/src/cli/config.js +15 -0
  12. package/src/cli/ingest.js +311 -57
  13. package/src/cli/renderers/html-index-core.js +88 -22
  14. package/src/cli/renderers/html-index.js +7 -5
  15. package/src/cli/renderers/replay-assets.js +61 -7
  16. package/src/cli/renderers/replay-client-source.js +102 -0
  17. package/src/cli/renderers/replay-viewer.client.js +1750 -595
  18. package/src/cli/renderers/score-weights.js +16 -0
  19. package/src/cli/renderers/summary-csv.js +2 -0
  20. package/src/cli/renderers/trajectories-core.js +2 -1
  21. package/src/cli/renderers/triage-md.js +9 -2
  22. package/src/cli/report.js +16 -3
  23. package/src/core/monitor.js +12 -13
  24. package/src/jspsych/extension-cyborg-hunter-replay.js +64 -3
  25. package/src/jspsych/extension-cyborg-hunter.js +1 -1
  26. package/src/replay/capture-dom.js +470 -458
  27. package/src/replay/capture-trace.js +688 -271
  28. package/src/replay/delivery.js +82 -0
  29. package/src/replay/dom-instantiate.js +779 -0
  30. package/src/replay/index.js +88 -5
  31. package/src/replay/initial-state.js +295 -0
  32. package/src/replay/mutations.js +668 -0
  33. package/src/replay/node-registry.js +116 -0
  34. package/src/replay/persistence.js +19 -6
  35. package/src/replay/recorder.js +342 -73
  36. package/src/replay/redaction.js +165 -0
  37. package/src/replay/serializer.js +148 -43
  38. package/src/replay/snapshot.js +409 -0
  39. package/src/replay/span.js +55 -0
  40. package/src/replay/viewer-model.js +293 -102
  41. package/src/shared/constants.js +1 -1
  42. package/src/shared/inline-safe.js +80 -0
  43. package/src/shared/schema-v2-validator.js +595 -0
  44. package/src/shared/schema.js +1 -0
  45. package/src/shared/validation.js +1 -1
  46. package/tools/convert/jspsych-v1-to-v2.mjs +432 -0
@@ -12,6 +12,8 @@
12
12
  // the same clock CH core uses, so CH-derived data merges with no conversion;
13
13
  // serializer.js converts to ms-since-session-start on the wire.
14
14
 
15
+ import { hasInheritedRedactionTaint } from './redaction.js';
16
+
15
17
  export const REPLAY_DEFAULTS = {
16
18
  participantId: 'unknown',
17
19
  tier: 'trace', // 'trace' | 'dom' (canvas reserved for v0.8)
@@ -19,7 +21,19 @@ export const REPLAY_DEFAULTS = {
19
21
  mouseHz: 30, // mousemove sampling ceiling
20
22
  redactSelector: '[data-ch-redact]',
21
23
  keepBait: false, // keep honeypot/decoy nodes in DOM snapshots
24
+ // Clipboard capture mode (spec §5.3). CH's default is LENGTH-ONLY on privacy
25
+ // grounds: pasted text routinely carries identifying material from outside
26
+ // the page. Set true for content mode (jsPsych-recorder behaviour).
27
+ clipboardContent: false,
22
28
  root: null, // capture root; resolved at startSession (default document.body)
29
+ // Keyframe cadence (spec §3), read by capture-dom at every segment start.
30
+ // The size-aware trigger takes a keyframe as soon as the patches since the
31
+ // last one rival its size; this is the FALLBACK that bounds a span whose
32
+ // DOM barely changes — at most this many segments per keyframe, so at most
33
+ // this many segments a player must replay forward from a checkpoint. 1 =
34
+ // keyframe every segment, which is what a display-wiping host wants (the
35
+ // jsPsych adapter forces it). null disables the fallback, leaving only size.
36
+ keyframeEvery: 10,
23
37
  autoSave: { mode: 'none' }, // 'datapipe' | 'download' | 'none'
24
38
  maxEventsPerTrial: 50000,
25
39
  // Size ceiling per trial, measured in CHARACTERS (JS string length / UTF-16
@@ -30,7 +44,49 @@ export const REPLAY_DEFAULTS = {
30
44
  // DOM subtree). The budget covers events AND the initial DOM snapshot. ~8M chars
31
45
  // is far above any normal trial yet bounds a runaway before it breaks the tab or
32
46
  // the DataPipe/localStorage upload. Set null to disable.
33
- maxCharsPerTrial: 8000000
47
+ maxCharsPerTrial: 8000000,
48
+ // Ceiling for the SESSION-level viewport stream (spec §2 `viewport_changes`).
49
+ // Both caps above are per-trial, so the one stream that outlives trials is the
50
+ // one they never see: a desktop drag-resize, or a mobile scroll with URL-bar
51
+ // chrome, yields up to two entries per frame per channel for as long as it
52
+ // lasts (measured: 5000 coalesced resizes → 365 KB while the trial buffer held
53
+ // one event).
54
+ //
55
+ // The two storm shapes are bounded by different guards, and it is worth being
56
+ // precise about which does what. A window sitting IDLE between frames repeats
57
+ // the same geometry, and consecutive-identical dedup absorbs that entirely
58
+ // (measured: 5000 → 1 entry). A window being actively DRAGGED reports a
59
+ // distinct geometry every frame, which dedup cannot touch — that is what this
60
+ // cap is for, and 2000 distinct states is about 33 s of continuous dragging at
61
+ // 60 Hz, accumulated over the whole session. Past it the stream stops for good
62
+ // and says so once through captureFailure, which means a participant who
63
+ // fiddles with the window early keeps the early geometry and loses later
64
+ // changes. That inversion is the known cost of a forward-only bound; the
65
+ // alternative (dropping oldest) would leave the early segments unreplayable,
66
+ // which is worse, since a viewport stream is state and not deltas. 2000 kept
67
+ // deliberately at the v2 switchover: dedup now absorbs the cheap case, so
68
+ // these are 2000 REAL geometries (~150 KB), already far past any real session.
69
+ maxViewportChanges: 2000,
70
+ // Ceiling for the SESSION-level guard-violation array (spec §9 vendor data).
71
+ // The third stream the per-trial caps cannot see, and the most expensive per
72
+ // entry: every `phase:'start'` violation carries a whole pre-scramble DOM
73
+ // tree. `maxCharsPerTrial` bounds each tree, nothing bounded how many.
74
+ //
75
+ // 40 entries = 20 start/end episodes, so at most ~20 trees. A v2 keyframe
76
+ // measures ~3.4x its v1 HTML (Task 2's measurement: 951 -> ~3.2 KB on the
77
+ // demo page, and a mid-sized experiment DOM lands nearer 50 KB), which puts
78
+ // the realistic ceiling around 1 MB of vendor payload — the same order the
79
+ // viewport cap allows, and reached only by a session that left fullscreen
80
+ // twenty times. CH's hard scoring has flagged such a participant long before
81
+ // the eleventh episode; the trees after that are evidence nobody reads.
82
+ //
83
+ // Cost of a COUNT cap rather than a trees-only one: past the ceiling a
84
+ // `phase:'end'` entry can be dropped while its `start` was kept, so the last
85
+ // episode may read as unclosed. Accepted, because the array is unbounded in
86
+ // both dimensions — a friction bug looping cheap violations grows it just as
87
+ // surely as the trees do — and the ceiling sits far past any honest count.
88
+ // Set null to disable.
89
+ maxGuardViolations: 40
34
90
  };
35
91
 
36
92
  // States: created → session ⇄ trial → stopped; destroyed is terminal.
@@ -49,6 +105,17 @@ export function createRecorder(userConfig) {
49
105
  var state = 'created';
50
106
  var listeners = [];
51
107
  var intervals = [];
108
+ // Channels with undelivered state to hand over before the recording closes
109
+ // (see addPreCloseFlush). Drained once, then emptied, so stop-then-destroy
110
+ // cannot run one twice.
111
+ var preCloseFlushes = [];
112
+ function runPreCloseFlushes() {
113
+ var pending = preCloseFlushes;
114
+ preCloseFlushes = [];
115
+ for (var i = 0; i < pending.length; i++) {
116
+ try { pending[i](); } catch (e) { recorder.captureFailure('pre_close_flush', e); }
117
+ }
118
+ }
52
119
  var trialCounter = 0;
53
120
  var trialStartHooks = []; // capture modules subscribe (e.g. DOM snapshot)
54
121
  // Running byte estimate for the OPEN trial (reset per trial). Kept off the
@@ -69,20 +136,33 @@ export function createRecorder(userConfig) {
69
136
  tier: config.tier,
70
137
  keys: config.keys,
71
138
  sessionStart: null, // performance.now() at startSession
72
- sessionStartEpoch: null, // Date.now() at startSession (wire metadata + filename)
139
+ sessionStartEpoch: null, // Date.now() at startSession (wire time base + filename)
140
+ userAgent: '', // spec §2; read once at startSession
141
+ // Spec §2's ViewportState — the SAME shape every `viewport_changes` entry
142
+ // carries, because they describe the same thing at different times.
73
143
  viewport: null,
144
+ // documentElement's client box, which §2's ViewportState has no room for
145
+ // and CH's viewer sizes its reconstruction by. Vendor data (spec §9).
146
+ viewportClient: null,
147
+ observedRoot: null, // selector of the observed subtree (spec §2); set by capture-dom
148
+ // Did the page's shared redaction taint already hold nodes when this
149
+ // recording started (redaction.js)? Read once at startSession, reported in
150
+ // the vendor namespace, so an empty field in a second recording on one page
151
+ // is diagnosable rather than ambiguous.
152
+ inheritedRedactionTaint: false,
74
153
  stylesheets: [], // filled by capture-dom at startSession (tier dom)
75
154
  trials: [],
76
155
  guardViolations: [], // filled via GuardFriction.onViolation subscription
156
+ // Session-level viewport stream (spec §2 `viewport_changes`). Resize and
157
+ // visualViewport changes are NOT segment events in v2 — the format keeps
158
+ // them in one session-wide array, merged with the event streams by `t`
159
+ // (spec §7) — so capture-trace pushes them here rather than into a trial.
160
+ viewportChanges: [],
77
161
  captureFailures: [],
78
162
  captureStopped: false,
79
- endReason: null,
80
- markerAttr: null // set by capture-dom (serialization markers)
163
+ endReason: null
81
164
  };
82
165
  var currentTrial = null;
83
- // Opaque marker registry (capture-dom owns it; capture-trace reads it for
84
- // interaction anchors). The recorder never inspects it — staying DOM-free.
85
- var markers = null;
86
166
 
87
167
  function transition(to) {
88
168
  if (VALID[state].indexOf(to) === -1) {
@@ -104,11 +184,30 @@ export function createRecorder(userConfig) {
104
184
  implicit: !!implicit,
105
185
  // tLoad is THE trial time origin (see design §10). tStart/tDomReady are
106
186
  // #3661-parity fields; null when the host has no pre-render hook.
107
- tLoad: implicit ? session.sessionStart : performance.now(),
187
+ //
188
+ // An implicit segment backdates its origin to the session start ONLY when
189
+ // it is the first — spec §3's "unbracketed recordings are one whole-session
190
+ // segment", where one segment genuinely owns the whole timeline. A
191
+ // TRAILING implicit segment (events arriving after a bracketed trial
192
+ // closed, e.g. the teardown flush, or a click between trials) opens when
193
+ // it opens, and backdating it to 0 made the recording state two segments
194
+ // that both own the same instant. That breaks §3's non-overlap rule
195
+ // literally, and it breaks the viewer materially: the §3 origin is what
196
+ // every player rebases segment-relative times by, so this segment's
197
+ // playhead ran on the session clock while every other segment's ran on
198
+ // its own. Found by T7's segment non-overlap check, which is the first
199
+ // thing in the repo that compared one segment's end against the next
200
+ // one's stated origin.
201
+ tLoad: (implicit && trialCounter === 1) ? session.sessionStart : performance.now(),
108
202
  tStart: (opts && opts.tStart) != null ? opts.tStart : null,
109
203
  tDomReady: (opts && opts.tDomReady) != null ? opts.tDomReady : null,
110
204
  tEnd: null,
111
- initialDom: '',
205
+ // Spec §3: a keyframe is a DomNode tree, a continuation is null. Null
206
+ // until the DOM capture's trial-start hook fills it, and on trace tier
207
+ // it stays null for the whole recording, which is the honest statement
208
+ // that no DOM was ever observed.
209
+ initialDom: null,
210
+ initialState: null,
112
211
  events: []
113
212
  };
114
213
  }
@@ -133,6 +232,103 @@ export function createRecorder(userConfig) {
133
232
  }
134
233
  }
135
234
 
235
+ // Set once, when the viewport stream hits its ceiling, so the note about it
236
+ // is recorded once rather than per dropped entry.
237
+ var viewportCapped = false;
238
+ // The same, for the guard-violation array.
239
+ var guardViolationsCapped = false;
240
+
241
+ // Two viewport entries describe the same geometry when every field but `t`
242
+ // agrees. Written generically rather than against the six §2 field names: the
243
+ // recorder is the sink for whatever shape the spec's ViewportState grows into.
244
+ function sameViewportState(a, b) {
245
+ var seen = 0;
246
+ for (var k in b) {
247
+ if (k === 't' || !Object.prototype.hasOwnProperty.call(b, k)) continue;
248
+ if (a[k] !== b[k]) return false;
249
+ seen++;
250
+ }
251
+ for (var j in a) {
252
+ if (j === 't' || !Object.prototype.hasOwnProperty.call(a, j)) continue;
253
+ seen--;
254
+ }
255
+ return seen === 0;
256
+ }
257
+
258
+ // Spec §5.7's total-stop signal, replacing v1's `ch:capture_stopped`. The
259
+ // configured limit that was actually crossed is CH's own diagnostic, not part
260
+ // of the standard event, so it rides in the vendor namespace (spec §9) rather
261
+ // than as an unknown top-level field.
262
+ //
263
+ // ONCE PER RECORDING (§5.7: "emitted once, into the segment open at stop
264
+ // time"), which is also what makes top-level `truncated` a faithful mirror of
265
+ // it — one flag, one signal. v1 fired it per trial, so a recording that hit a
266
+ // cap in three trials claimed to have stopped capturing three times. The
267
+ // per-trial RECOVERY is unchanged and is a different fact: one oversized trial
268
+ // stops only itself, and the next captures fresh. What the recording says once
269
+ // is that something, somewhere, was dropped.
270
+ var captureStopSignalled = false;
271
+ function signalCaptureStopped(trial, t, detail) {
272
+ stoppedTrials.add(trial);
273
+ session.captureStopped = true;
274
+ if (captureStopSignalled) return;
275
+ captureStopSignalled = true;
276
+ trial.events.push({
277
+ type: 'recording.capture_stopped',
278
+ t: t,
279
+ reason: 'buffer_limit',
280
+ extensions: { 'cyborg-hunter': detail },
281
+ });
282
+ }
283
+
284
+ // The single path into the buffer, behind `pushRecord`. Lifecycle gate,
285
+ // implicit trial opening and both per-trial caps live here.
286
+ function storeEvent(e) {
287
+ if (state === 'destroyed') {
288
+ throw new Error('[cyborg-hunter-replay] event pushed to a destroyed recorder');
289
+ }
290
+ if (state === 'created' || state === 'stopped') return; // not recording
291
+ if (!currentTrial) {
292
+ // RE-ENTRANT: the hooks below run while the caller is halfway through
293
+ // pushing, and one of them is capture-dom's keyframe hook. That is why
294
+ // capture-dom refuses to keyframe an implicit segment whose span already
295
+ // has one (see its onTrialStart comment) — the ids in the record being
296
+ // pushed were resolved against the span a keyframe here would reset.
297
+ //
298
+ // KNOWN LOSS, unchanged and deliberate: when this is the FIRST segment of
299
+ // the recording, the span has no keyframe yet, so capture-dom must take
300
+ // one — and the event that opened the segment already resolved its
301
+ // `target` against the empty span, so it carries null. One event's target
302
+ // id, on an unbracketed recording, at the only moment where the
303
+ // alternative is a recording with no keyframe at all.
304
+ currentTrial = newTrial(null, true);
305
+ fireTrialStart(currentTrial);
306
+ }
307
+ // Per-trial stop (not the session-wide flag): a trial that already hit a
308
+ // cap drops further events, but a fresh trial is unaffected.
309
+ if (stoppedTrials.has(currentTrial)) return;
310
+ if (currentTrial.events.length >= config.maxEventsPerTrial) {
311
+ signalCaptureStopped(currentTrial, e.t, { limit_events: config.maxEventsPerTrial });
312
+ return;
313
+ }
314
+ // Size cap: stop capturing once the trial's estimated serialized length
315
+ // exceeds maxCharsPerTrial, so a few huge values can't blow up the payload
316
+ // while staying under the event-count cap. The budget is SEEDED with the
317
+ // keyframe (stored on the trial, not pushed as an event) so a
318
+ // multi-megabyte snapshot counts too rather than bypassing the cap — see
319
+ // `noteSnapshotChars`, which is where that seed now comes from.
320
+ if (config.maxCharsPerTrial != null) {
321
+ var soFar = trialChars.get(currentTrial) || 0;
322
+ soFar += estimateEventChars(e);
323
+ if (soFar > config.maxCharsPerTrial) {
324
+ signalCaptureStopped(currentTrial, e.t, { limit_chars: config.maxCharsPerTrial });
325
+ return;
326
+ }
327
+ trialChars.set(currentTrial, soFar);
328
+ }
329
+ currentTrial.events.push(e);
330
+ }
331
+
136
332
  var recorder = {
137
333
  config: config,
138
334
 
@@ -140,25 +336,30 @@ export function createRecorder(userConfig) {
140
336
  transition('session');
141
337
  session.sessionStart = performance.now();
142
338
  session.sessionStartEpoch = Date.now();
143
- // Viewport geometry, if a window exists (absent in node tests).
339
+ // Before any capture of this recording can add to it (see the field).
340
+ session.inheritedRedactionTaint = hasInheritedRedactionTaint();
341
+ // Viewport geometry, if a window exists (absent in node tests). Spec §2's
342
+ // ViewportState, identical in shape to every `viewport_changes` entry.
144
343
  var w = typeof window !== 'undefined' ? window : null;
344
+ var vv = w && w.visualViewport ? w.visualViewport : null;
345
+ session.viewport = w ? {
346
+ w: w.innerWidth || 0,
347
+ h: w.innerHeight || 0,
348
+ dpr: w.devicePixelRatio || 1,
349
+ scale: vv && typeof vv.scale === 'number' ? vv.scale : 1,
350
+ offset_x: vv ? (vv.offsetLeft || 0) : 0,
351
+ offset_y: vv ? (vv.offsetTop || 0) : 0
352
+ } : null;
145
353
  // documentElement.clientWidth/Height = the LAYOUT width the page was
146
354
  // actually formatted against (innerWidth minus any classic scrollbar) —
147
- // the viewer sizes its reconstruction by this, not innerWidth.
355
+ // the viewer sizes its reconstruction by this, not innerWidth. Spec §2
356
+ // has no field for it, so it travels as vendor data (spec §9).
148
357
  var de = typeof document !== 'undefined' && document.documentElement
149
358
  ? document.documentElement : null;
150
- session.viewport = w ? {
151
- width: w.innerWidth || null,
152
- height: w.innerHeight || null,
153
- client_width: de ? de.clientWidth || null : null,
154
- client_height: de ? de.clientHeight || null : null,
155
- dpr: w.devicePixelRatio || 1,
156
- visual_viewport: w.visualViewport ? {
157
- width: w.visualViewport.width, height: w.visualViewport.height,
158
- scale: w.visualViewport.scale
159
- } : null
160
- } : { width: null, height: null, client_width: null, client_height: null,
161
- dpr: null, visual_viewport: null };
359
+ session.viewportClient = de
360
+ ? { w: de.clientWidth || 0, h: de.clientHeight || 0 } : null;
361
+ session.userAgent = typeof navigator !== 'undefined' && navigator.userAgent
362
+ ? String(navigator.userAgent) : '';
162
363
  if (config.autoSave.mode === 'none') {
163
364
  console.warn('[cyborg-hunter-replay] autoSave.mode is "none" — the recording will be lost unless you call getRecording() yourself.');
164
365
  }
@@ -167,8 +368,12 @@ export function createRecorder(userConfig) {
167
368
  startTrial: function (opts) {
168
369
  if (state === 'trial') {
169
370
  // Standalone users may forget endTrial(); auto-close so events never
170
- // bleed across trials, and leave an auditable marker.
171
- this.pushEvent('ch:lifecycle_error', { reason: 'startTrial_without_endTrial' });
371
+ // bleed across trials, and leave an auditable marker. The marker is a
372
+ // capture failure, not an event: spec §5.8 admits no vendor event types
373
+ // in the stream, and this channel already carries CH's capture-side
374
+ // anomalies to the analyst through the vendor extension.
375
+ this.captureFailure('lifecycle',
376
+ new Error('startTrial_without_endTrial: the open trial was auto-closed'));
172
377
  closeTrial();
173
378
  state = 'session';
174
379
  } else if (currentTrial) {
@@ -189,6 +394,9 @@ export function createRecorder(userConfig) {
189
394
  },
190
395
 
191
396
  stopSession: function (reason) {
397
+ // BEFORE anything closes: a channel holding undelivered state gets to
398
+ // deliver it into the still-open trial (F-5).
399
+ runPreCloseFlushes();
192
400
  if (state === 'trial') {
193
401
  state = 'session';
194
402
  }
@@ -199,56 +407,93 @@ export function createRecorder(userConfig) {
199
407
  session.endReason = reason || 'finished';
200
408
  },
201
409
 
202
- // Central event sink used by all capture modules.
203
- // Events land in the current trial; unbracketed events lazily open a
204
- // single implicit trial that spans the session (design §5).
205
- pushEvent: function (kind, payload, tOverride) {
410
+ // The event sink (spec §5): the caller hands over a complete RecordedEvent minus
411
+ // its `t`. v2 payloads are per-type shapes with nested blocks (`camera`,
412
+ // `anchor`, `mods`, `extensions`) rather than v1's flat bag merged onto a
413
+ // `kind`. `type` leads the wire, `t` follows it, matching how the fixtures
414
+ // read — and `t` is written AFTER the merge, so the sink owns the timestamp
415
+ // even if a caller ever puts one in the record.
416
+ pushRecord: function (record, tOverride) {
417
+ var e = Object.assign({ type: record.type, t: null }, record);
418
+ e.t = tOverride != null ? tOverride : performance.now();
419
+ storeEvent(e);
420
+ },
421
+
422
+ // Session-level viewport stream (spec §2). Not a segment event: the format
423
+ // keeps viewport geometry in one session-wide array, so a resize that
424
+ // happens between trials still lands somewhere.
425
+ //
426
+ // Two guards the per-trial caps cannot give this stream. A state identical
427
+ // to the last one says nothing, and a drag-resize settles into repeated
428
+ // identical states between frames, which is the storm case. Past the
429
+ // ceiling the stream stops growing and says so ONCE, through the same
430
+ // capture-failure channel every other capture-side anomaly uses (spec §9's
431
+ // vendor namespace on the wire). It deliberately does NOT set
432
+ // `captureStopped`: spec §5.7's truncation means event capture stopped, and
433
+ // a bounded metadata stream is not that.
434
+ pushViewportChange: function (entry, tOverride) {
206
435
  if (state === 'destroyed') {
207
- throw new Error('[cyborg-hunter-replay] pushEvent called on a destroyed recorder');
436
+ throw new Error('[cyborg-hunter-replay] viewport change pushed to a destroyed recorder');
208
437
  }
209
- if (state === 'created' || state === 'stopped') return; // not recording
210
- if (!currentTrial) {
211
- currentTrial = newTrial(null, true);
212
- fireTrialStart(currentTrial);
213
- }
214
- // Per-trial stop (not the session-wide flag): a trial that already hit a
215
- // cap drops further events, but a fresh trial is unaffected.
216
- if (stoppedTrials.has(currentTrial)) return;
217
- if (currentTrial.events.length >= config.maxEventsPerTrial) {
218
- stoppedTrials.add(currentTrial);
219
- session.captureStopped = true;
220
- currentTrial.events.push({
221
- t: tOverride != null ? tOverride : performance.now(),
222
- kind: 'ch:capture_stopped',
223
- limit: config.maxEventsPerTrial
224
- });
438
+ if (state === 'created' || state === 'stopped') return;
439
+ var changes = session.viewportChanges;
440
+ if (changes.length && sameViewportState(changes[changes.length - 1], entry)) return;
441
+ var cap = config.maxViewportChanges;
442
+ if (cap != null && changes.length >= cap) {
443
+ if (!viewportCapped) {
444
+ viewportCapped = true;
445
+ recorder.captureFailure('viewport_changes', new Error(
446
+ 'viewport_changes cap reached (' + cap + '); later viewport geometry is not recorded'));
447
+ }
225
448
  return;
226
449
  }
227
- var e = Object.assign(
228
- { t: tOverride != null ? tOverride : performance.now(), kind: kind },
229
- payload || {});
230
- // Size cap: stop capturing once the trial's estimated serialized length
231
- // exceeds maxCharsPerTrial, so a few huge values can't blow up the payload
232
- // while staying under the event-count cap. The budget is SEEDED with the
233
- // initial DOM snapshot (stored on the trial, not pushed as an event) so a
234
- // multi-megabyte snapshot counts too rather than bypassing the cap.
235
- if (config.maxCharsPerTrial != null) {
236
- var soFar = trialChars.get(currentTrial);
237
- if (soFar == null) {
238
- soFar = currentTrial.initialDom ? currentTrial.initialDom.length : 0;
239
- }
240
- soFar += estimateEventChars(e);
241
- if (soFar > config.maxCharsPerTrial) {
242
- stoppedTrials.add(currentTrial);
243
- session.captureStopped = true;
244
- currentTrial.events.push({
245
- t: e.t, kind: 'ch:capture_stopped', limit_chars: config.maxCharsPerTrial
246
- });
247
- return;
450
+ changes.push(Object.assign({}, entry, {
451
+ t: tOverride != null ? tOverride : performance.now()
452
+ }));
453
+ },
454
+
455
+ // Guard-friction violations (spec §9's vendor namespace). NOT an event:
456
+ // §5.8 forbids vendor types in the stream, and there is no standard event
457
+ // for "the participant left fullscreen". The entry keeps its `t`, so a
458
+ // viewer can still place it on the timeline beside the events.
459
+ //
460
+ // Capped like the viewport stream and for the same reason (a session-level
461
+ // array no per-trial cap can see), with the same forward-only bound and the
462
+ // same single note through captureFailure. Deliberately NOT captureStopped:
463
+ // §5.7's truncation means EVENT capture stopped, and this is vendor data.
464
+ pushGuardViolation: function (entry, tOverride) {
465
+ if (state === 'destroyed') {
466
+ throw new Error('[cyborg-hunter-replay] guard violation pushed to a destroyed recorder');
467
+ }
468
+ if (state === 'created' || state === 'stopped') return;
469
+ var gcap = config.maxGuardViolations;
470
+ if (gcap != null && session.guardViolations.length >= gcap) {
471
+ if (!guardViolationsCapped) {
472
+ guardViolationsCapped = true;
473
+ recorder.captureFailure('guard_violations', new Error(
474
+ 'guard_violations cap reached (' + gcap + '); later guard violations ' +
475
+ 'are not recorded'));
248
476
  }
249
- trialChars.set(currentTrial, soFar);
477
+ return;
478
+ }
479
+ session.guardViolations.push(Object.assign({}, entry, {
480
+ t: tOverride != null ? tOverride : performance.now()
481
+ }));
482
+ },
483
+
484
+ // How many characters the keyframe payload of this trial takes.
485
+ //
486
+ // The keyframe lives ON the trial rather than in the event stream, so the
487
+ // per-trial size cap cannot see it unless the capture module says. v1 read
488
+ // `initialDom.length` off an HTML STRING; a v2 keyframe is a DomNode tree,
489
+ // whose `.length` is undefined — and `undefined + n` is NaN, which compares
490
+ // false against any cap, so the whole size budget would have failed silently
491
+ // open. capture-dom measures the payload exactly (it has to anyway, to
492
+ // decide whether the snapshot itself is over budget) and reports it here.
493
+ noteSnapshotChars: function (trial, chars) {
494
+ if (trial && typeof chars === 'number' && isFinite(chars)) {
495
+ trialChars.set(trial, chars);
250
496
  }
251
- currentTrial.events.push(e);
252
497
  },
253
498
 
254
499
  // Capture-channel failure: record and keep going. Recording must never
@@ -269,10 +514,11 @@ export function createRecorder(userConfig) {
269
514
  session.stylesheets = sheets || [];
270
515
  },
271
516
 
272
- // Marker registry passthrough (opaque — see comment on `markers` above).
273
- setMarkers: function (reg) { markers = reg; },
274
- getMarkers: function () { return markers; },
275
- setMarkerAttr: function (attr) { session.markerAttr = attr || null; },
517
+ // Selector of the observed subtree (spec §2 `observed_root`); null means
518
+ // "the document body", which is also what a trace-tier recording says.
519
+ setObservedRoot: function (selector) {
520
+ session.observedRoot = typeof selector === 'string' && selector ? selector : null;
521
+ },
276
522
 
277
523
  // Listener/interval registry — single teardown point.
278
524
  addListener: function (target, event, handler, options) {
@@ -284,6 +530,25 @@ export function createRecorder(userConfig) {
284
530
  intervals.push(id);
285
531
  },
286
532
 
533
+ // Work a capture channel must do before the recording closes, because it
534
+ // holds state the browser has not delivered yet.
535
+ //
536
+ // The MutationObserver is the case that needs it: its callback is a
537
+ // microtask, so DOM changes made in the same task as `stopSession()` — the
538
+ // last thing a trial does before ending — sit in the observer's queue when
539
+ // the recording closes, and disconnecting drops them with no trace. A
540
+ // `takeRecords()` through the mapper turns that silent loss into the
541
+ // patches the participant actually caused (T3 final review, F-5).
542
+ //
543
+ // Runs before the state transition in `stopSession` (so the events are
544
+ // still accepted) and at the top of `destroy` (for a caller that tears down
545
+ // without stopping — the buffer survives teardown either way). Each hook
546
+ // runs at most once, and a throwing hook is contained like any other
547
+ // capture channel.
548
+ addPreCloseFlush: function (fn) {
549
+ if (typeof fn === 'function') preCloseFlushes.push(fn);
550
+ },
551
+
287
552
  // Read-only view for serializer + tests. Trials array includes the open
288
553
  // trial so mid-session getRecording() sees everything so far.
289
554
  // Deliberately readable AFTER destroy(): the buffer survives teardown
@@ -297,6 +562,10 @@ export function createRecorder(userConfig) {
297
562
 
298
563
  destroy: function () {
299
564
  if (state === 'destroyed') return;
565
+ // A caller that tears down without stopping still gets its pending
566
+ // batch: the buffer survives destroy() by contract, so the patches are
567
+ // readable afterwards (F-5). A no-op after stopSession, which drained it.
568
+ runPreCloseFlushes();
300
569
  transition('destroyed');
301
570
  listeners.forEach(function (l) {
302
571
  if (l.options && l.options._isObserver) {