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.
- package/CHANGELOG.md +74 -0
- package/CITATION.cff +2 -2
- package/README.md +22 -7
- package/dist/cyborg-hunter-replay.js +3 -3
- package/dist/cyborg-hunter.esm.js +5 -2
- package/dist/cyborg-hunter.min.js +3 -3
- package/dist/extension-cyborg-hunter.js +1 -1
- package/package.json +10 -2
- package/src/cli/analyzers/score-weights.js +145 -0
- package/src/cli/analyzers/triage.js +55 -39
- package/src/cli/config.js +15 -0
- package/src/cli/ingest.js +311 -57
- package/src/cli/renderers/html-index-core.js +88 -22
- package/src/cli/renderers/html-index.js +7 -5
- package/src/cli/renderers/replay-assets.js +61 -7
- package/src/cli/renderers/replay-client-source.js +102 -0
- package/src/cli/renderers/replay-viewer.client.js +1750 -595
- package/src/cli/renderers/score-weights.js +16 -0
- package/src/cli/renderers/summary-csv.js +2 -0
- package/src/cli/renderers/trajectories-core.js +2 -1
- package/src/cli/renderers/triage-md.js +9 -2
- package/src/cli/report.js +16 -3
- package/src/core/monitor.js +12 -13
- package/src/jspsych/extension-cyborg-hunter-replay.js +64 -3
- package/src/jspsych/extension-cyborg-hunter.js +1 -1
- package/src/replay/capture-dom.js +470 -458
- package/src/replay/capture-trace.js +688 -271
- package/src/replay/delivery.js +82 -0
- package/src/replay/dom-instantiate.js +779 -0
- package/src/replay/index.js +88 -5
- package/src/replay/initial-state.js +295 -0
- package/src/replay/mutations.js +668 -0
- package/src/replay/node-registry.js +116 -0
- package/src/replay/persistence.js +19 -6
- package/src/replay/recorder.js +342 -73
- package/src/replay/redaction.js +165 -0
- package/src/replay/serializer.js +148 -43
- package/src/replay/snapshot.js +409 -0
- package/src/replay/span.js +55 -0
- package/src/replay/viewer-model.js +293 -102
- package/src/shared/constants.js +1 -1
- package/src/shared/inline-safe.js +80 -0
- package/src/shared/schema-v2-validator.js +595 -0
- package/src/shared/schema.js +1 -0
- package/src/shared/validation.js +1 -1
- package/tools/convert/jspsych-v1-to-v2.mjs +432 -0
package/src/replay/recorder.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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.
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
|
|
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]
|
|
436
|
+
throw new Error('[cyborg-hunter-replay] viewport change pushed to a destroyed recorder');
|
|
208
437
|
}
|
|
209
|
-
if (state === 'created' || state === 'stopped') return;
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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) {
|