cyborg-hunter 0.5.0 → 0.7.2
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 +156 -0
- package/CITATION.cff +29 -0
- package/LICENSE +21 -0
- package/README.md +80 -21
- package/bin/cyborg-hunter.js +6 -2
- package/dist/cyborg-hunter-replay.js +3 -0
- package/dist/cyborg-hunter.esm.js +114 -22
- package/dist/cyborg-hunter.min.js +3 -3
- package/dist/extension-cyborg-hunter.js +1 -1
- package/dist/extension-guard-friction.js +5 -5
- package/dist/extension-guard-honeypot.js +1 -1
- package/package.json +15 -3
- package/src/cli/analyzers/edge-exit.js +4 -1
- package/src/cli/analyzers/phase-scope.js +83 -0
- package/src/cli/analyzers/summary.js +161 -28
- package/src/cli/analyzers/triage.js +59 -27
- package/src/cli/config.js +26 -1
- package/src/cli/extract-core.js +552 -0
- package/src/cli/ingest.js +250 -193
- package/src/cli/init.js +1 -1
- package/src/cli/preview-entry.js +36 -0
- package/src/cli/renderers/event-log.js +18 -19
- package/src/cli/renderers/extensions.js +12 -3
- package/src/cli/renderers/html-index-core.js +1273 -0
- package/src/cli/renderers/html-index.js +13 -1047
- package/src/cli/renderers/replay-assets.js +67 -0
- package/src/cli/renderers/replay-viewer.client.js +1185 -0
- package/src/cli/renderers/session-timeline-core.js +907 -0
- package/src/cli/renderers/session-timeline.js +33 -0
- package/src/cli/renderers/summary-csv.js +5 -0
- package/src/cli/renderers/trajectories-core.js +717 -0
- package/src/cli/renderers/trajectories.js +31 -635
- package/src/cli/renderers/triage-md.js +10 -4
- package/src/cli/renderers/typing-profile-core.js +211 -0
- package/src/cli/renderers/typing-profile.js +16 -186
- package/src/cli/report.js +42 -8
- package/src/core/monitor.js +77 -7
- package/src/core/scoring.js +11 -2
- package/src/core/signals/browser.js +51 -18
- package/src/core/signals/clipboard.js +10 -2
- package/src/core/signals/dom-protection.js +9 -0
- package/src/core/signals/focus.js +16 -2
- package/src/jspsych/extension-cyborg-hunter-replay.js +135 -0
- package/src/jspsych/extension-cyborg-hunter.js +9 -2
- package/src/jspsych/extension-guard-friction.js +43 -14
- package/src/jspsych/extension-guard-honeypot.js +25 -1
- package/src/replay/capture-dom.js +575 -0
- package/src/replay/capture-trace.js +468 -0
- package/src/replay/index.js +104 -0
- package/src/replay/persistence.js +141 -0
- package/src/replay/recorder.js +315 -0
- package/src/replay/serializer.js +119 -0
- package/src/replay/viewer-model.js +125 -0
- package/src/shared/constants.js +12 -6
- package/src/shared/paths.js +20 -0
- package/src/shared/schema.js +5 -0
- package/src/shared/validation.js +55 -0
- package/src/cli/renderers/tab-timeline.js +0 -149
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// src/replay/persistence.js
|
|
2
|
+
// Artifact naming, autosave, and the integrityReplayMeta pointer.
|
|
3
|
+
//
|
|
4
|
+
// Delivery semantics are honest by design (design §10): the artifact has the
|
|
5
|
+
// same delivery guarantees as the jsPsych data itself. There is no fallback
|
|
6
|
+
// chain — the configured mode either works or reports saved_to:'failed', and
|
|
7
|
+
// the analyst-facing meta pointer always states what actually happened.
|
|
8
|
+
|
|
9
|
+
var DATAPIPE_URL = 'https://pipe.jspsych.org/api/data/';
|
|
10
|
+
|
|
11
|
+
import { sanitizeId } from '../shared/constants.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* <pid>-replay-<sessionStartEpochMs>.json — the epoch suffix makes reloads
|
|
15
|
+
* produce distinct artifacts instead of overwriting (CLI picks the latest
|
|
16
|
+
* and warns on multiples).
|
|
17
|
+
*/
|
|
18
|
+
export function replayFilename(recording) {
|
|
19
|
+
var pid = sanitizeId(recording.metadata.participant_id);
|
|
20
|
+
var epoch = Date.parse(recording.metadata.start_time);
|
|
21
|
+
return pid + '-replay-' + epoch + '.json';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The small pointer that rides along in the jsPsych data (one
|
|
26
|
+
* addProperties call) so the analyst can tell from the CSV alone whether a
|
|
27
|
+
* replay exists, how big it was, and where it went.
|
|
28
|
+
*/
|
|
29
|
+
export function buildReplayMeta(recording, savedTo) {
|
|
30
|
+
return {
|
|
31
|
+
schema_version: recording.schema_version,
|
|
32
|
+
tier: recording.metadata.tier,
|
|
33
|
+
saved_to: savedTo,
|
|
34
|
+
bytes_uncompressed: JSON.stringify(recording).length,
|
|
35
|
+
capture_failures: (recording.ch_extensions.capture_failures || [])
|
|
36
|
+
.map(function (f) { return f.channel; }),
|
|
37
|
+
capture_stopped: !!recording.ch_extensions.capture_stopped
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Saves the recording using the configured mode. Never throws — failures
|
|
43
|
+
* come back as { saved_to: 'failed', error } so the experiment's finish
|
|
44
|
+
* path is never interrupted.
|
|
45
|
+
*
|
|
46
|
+
* Modes:
|
|
47
|
+
* datapipe — window.jsPsychPipe.saveData if loaded, else a direct POST to
|
|
48
|
+
* the DataPipe API. Sends PLAIN JSON (DataPipe stores strings;
|
|
49
|
+
* base64-gzip would make the OSF file unreadable for analysts).
|
|
50
|
+
* download — synthetic-anchor download (participant's machine!). Useful
|
|
51
|
+
* for local piloting; the CLI warns if it sees this in meta.
|
|
52
|
+
* none — recording stays in memory; researcher handles it.
|
|
53
|
+
*/
|
|
54
|
+
export async function autoSave(recording, autoSaveConfig) {
|
|
55
|
+
var cfg = autoSaveConfig || { mode: 'none' };
|
|
56
|
+
var filename = replayFilename(recording);
|
|
57
|
+
var w = typeof window !== 'undefined' ? window : {};
|
|
58
|
+
|
|
59
|
+
if (cfg.mode === 'none') {
|
|
60
|
+
return { saved_to: 'none', filename: filename };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (cfg.mode === 'datapipe') {
|
|
64
|
+
if (!cfg.experimentId) {
|
|
65
|
+
return { saved_to: 'failed', filename: filename,
|
|
66
|
+
error: 'autoSave.mode "datapipe" requires autoSave.experimentId' };
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
// Inside the try: JSON.stringify can throw (circular ref, BigInt) and
|
|
70
|
+
// this function is contractually never-throw — a serialization failure
|
|
71
|
+
// must come back as saved_to:'failed', not break the experiment's finish.
|
|
72
|
+
var json = JSON.stringify(recording);
|
|
73
|
+
if (w.jsPsychPipe && typeof w.jsPsychPipe.saveData === 'function') {
|
|
74
|
+
await w.jsPsychPipe.saveData(cfg.experimentId, filename, json);
|
|
75
|
+
} else {
|
|
76
|
+
var fetchFn = w.fetch || (typeof fetch !== 'undefined' ? fetch : null);
|
|
77
|
+
if (!fetchFn) throw new Error('no fetch available for DataPipe POST');
|
|
78
|
+
var res = await fetchFn(DATAPIPE_URL, {
|
|
79
|
+
method: 'POST',
|
|
80
|
+
headers: { 'Content-Type': 'application/json', Accept: '*/*' },
|
|
81
|
+
body: JSON.stringify({
|
|
82
|
+
experimentID: cfg.experimentId,
|
|
83
|
+
filename: filename,
|
|
84
|
+
data: json
|
|
85
|
+
})
|
|
86
|
+
});
|
|
87
|
+
if (res && res.ok === false) throw new Error('DataPipe HTTP error');
|
|
88
|
+
}
|
|
89
|
+
return { saved_to: 'datapipe:' + cfg.experimentId + '/' + filename, filename: filename };
|
|
90
|
+
} catch (e) {
|
|
91
|
+
console.error('[cyborg-hunter-replay] DataPipe autosave failed:', e);
|
|
92
|
+
return { saved_to: 'failed', filename: filename,
|
|
93
|
+
error: e && e.message ? e.message : String(e) };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (cfg.mode === 'download') {
|
|
98
|
+
try {
|
|
99
|
+
var doc = typeof document !== 'undefined' ? document : null;
|
|
100
|
+
var BlobImpl = w.Blob || (typeof Blob !== 'undefined' ? Blob : null);
|
|
101
|
+
var urlApi = w.URL || (typeof URL !== 'undefined' ? URL : null);
|
|
102
|
+
if (!doc || !BlobImpl || !urlApi) throw new Error('download mode needs a browser');
|
|
103
|
+
var blob = new BlobImpl([JSON.stringify(recording)], { type: 'application/json' });
|
|
104
|
+
var a = doc.createElement('a');
|
|
105
|
+
a.href = urlApi.createObjectURL(blob);
|
|
106
|
+
a.download = filename;
|
|
107
|
+
doc.body.appendChild(a);
|
|
108
|
+
a.click();
|
|
109
|
+
doc.body.removeChild(a);
|
|
110
|
+
urlApi.revokeObjectURL(a.href);
|
|
111
|
+
return { saved_to: 'download', filename: filename };
|
|
112
|
+
} catch (e) {
|
|
113
|
+
console.error('[cyborg-hunter-replay] download autosave failed:', e);
|
|
114
|
+
return { saved_to: 'failed', filename: filename,
|
|
115
|
+
error: e && e.message ? e.message : String(e) };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return { saved_to: 'failed', filename: filename,
|
|
120
|
+
error: 'unknown autoSave.mode "' + cfg.mode + '"' };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Gzip the recording via CompressionStream when the browser has it;
|
|
125
|
+
* falls back to a plain-JSON Blob (callers check blob.type).
|
|
126
|
+
*/
|
|
127
|
+
export async function compressRecording(recording) {
|
|
128
|
+
var json = JSON.stringify(recording);
|
|
129
|
+
var w = typeof window !== 'undefined' ? window : {};
|
|
130
|
+
var CS = w.CompressionStream ||
|
|
131
|
+
(typeof CompressionStream !== 'undefined' ? CompressionStream : null);
|
|
132
|
+
var BlobImpl = w.Blob || (typeof Blob !== 'undefined' ? Blob : null);
|
|
133
|
+
if (!BlobImpl) return null;
|
|
134
|
+
if (!CS) {
|
|
135
|
+
return new BlobImpl([json], { type: 'application/json' });
|
|
136
|
+
}
|
|
137
|
+
var stream = new BlobImpl([json]).stream().pipeThrough(new CS('gzip'));
|
|
138
|
+
var ResponseImpl = w.Response || (typeof Response !== 'undefined' ? Response : null);
|
|
139
|
+
var buf = await new ResponseImpl(stream).arrayBuffer();
|
|
140
|
+
return new BlobImpl([buf], { type: 'application/gzip' });
|
|
141
|
+
}
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
// src/replay/recorder.js
|
|
2
|
+
// Recorder engine: lifecycle state, event buffer, caps, listener registry.
|
|
3
|
+
//
|
|
4
|
+
// Deliberately DOM-free: it never touches document/window except to read
|
|
5
|
+
// viewport geometry (guarded), so it unit-tests in plain node. Capture
|
|
6
|
+
// modules (capture-trace.js, capture-dom.js) own all DOM listeners and
|
|
7
|
+
// register them here via addListener/addInterval so destroy() can tear
|
|
8
|
+
// everything down in one place. Assembly (singleton + capture wiring +
|
|
9
|
+
// window global) lives in index.js.
|
|
10
|
+
//
|
|
11
|
+
// Time base: every event carries an ABSOLUTE performance.now() `t`. This is
|
|
12
|
+
// the same clock CH core uses, so CH-derived data merges with no conversion;
|
|
13
|
+
// serializer.js converts to ms-since-session-start on the wire.
|
|
14
|
+
|
|
15
|
+
export const REPLAY_DEFAULTS = {
|
|
16
|
+
participantId: 'unknown',
|
|
17
|
+
tier: 'trace', // 'trace' | 'dom' (canvas reserved for v0.8)
|
|
18
|
+
keys: 'full', // 'full' | 'off'
|
|
19
|
+
mouseHz: 30, // mousemove sampling ceiling
|
|
20
|
+
redactSelector: '[data-ch-redact]',
|
|
21
|
+
keepBait: false, // keep honeypot/decoy nodes in DOM snapshots
|
|
22
|
+
root: null, // capture root; resolved at startSession (default document.body)
|
|
23
|
+
autoSave: { mode: 'none' }, // 'datapipe' | 'download' | 'none'
|
|
24
|
+
maxEventsPerTrial: 50000,
|
|
25
|
+
// Size ceiling per trial, measured in CHARACTERS (JS string length / UTF-16
|
|
26
|
+
// code units) — an approximate, monotonic proxy for payload size, deliberately
|
|
27
|
+
// not called "bytes" since UTF-8 byte size can exceed it for non-ASCII content.
|
|
28
|
+
// The event-count cap alone can't bound a recording that stays under 50k events
|
|
29
|
+
// but stores multi-megabyte values (a giant textarea edited repeatedly, a large
|
|
30
|
+
// DOM subtree). The budget covers events AND the initial DOM snapshot. ~8M chars
|
|
31
|
+
// is far above any normal trial yet bounds a runaway before it breaks the tab or
|
|
32
|
+
// the DataPipe/localStorage upload. Set null to disable.
|
|
33
|
+
maxCharsPerTrial: 8000000
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// States: created → session ⇄ trial → stopped; destroyed is terminal.
|
|
37
|
+
const VALID = {
|
|
38
|
+
created: ['session', 'destroyed'],
|
|
39
|
+
session: ['trial', 'stopped', 'destroyed'],
|
|
40
|
+
trial: ['session', 'stopped', 'destroyed'],
|
|
41
|
+
stopped: ['destroyed'],
|
|
42
|
+
destroyed: []
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export function createRecorder(userConfig) {
|
|
46
|
+
var config = Object.assign({}, REPLAY_DEFAULTS, userConfig || {});
|
|
47
|
+
config.autoSave = Object.assign({}, REPLAY_DEFAULTS.autoSave, (userConfig || {}).autoSave || {});
|
|
48
|
+
|
|
49
|
+
var state = 'created';
|
|
50
|
+
var listeners = [];
|
|
51
|
+
var intervals = [];
|
|
52
|
+
var trialCounter = 0;
|
|
53
|
+
var trialStartHooks = []; // capture modules subscribe (e.g. DOM snapshot)
|
|
54
|
+
// Running byte estimate for the OPEN trial (reset per trial). Kept off the
|
|
55
|
+
// serialized trial object (a WeakMap) so it never pollutes the wire payload.
|
|
56
|
+
var trialChars = new WeakMap();
|
|
57
|
+
// Trials that hit a per-trial cap. Tracking this per-trial (not via the
|
|
58
|
+
// session-wide captureStopped flag) means one oversized trial stops only
|
|
59
|
+
// itself; a subsequent trial captures fresh. captureStopped remains as a
|
|
60
|
+
// session-level "something was truncated somewhere" signal for the meta.
|
|
61
|
+
var stoppedTrials = new WeakSet();
|
|
62
|
+
function estimateEventChars(o) {
|
|
63
|
+
try { return JSON.stringify(o).length; } catch (e) { return 0; }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// The in-memory session buffer. Serialized by serializer.js at the end.
|
|
67
|
+
var session = {
|
|
68
|
+
participantId: config.participantId,
|
|
69
|
+
tier: config.tier,
|
|
70
|
+
keys: config.keys,
|
|
71
|
+
sessionStart: null, // performance.now() at startSession
|
|
72
|
+
sessionStartEpoch: null, // Date.now() at startSession (wire metadata + filename)
|
|
73
|
+
viewport: null,
|
|
74
|
+
stylesheets: [], // filled by capture-dom at startSession (tier dom)
|
|
75
|
+
trials: [],
|
|
76
|
+
guardViolations: [], // filled via GuardFriction.onViolation subscription
|
|
77
|
+
captureFailures: [],
|
|
78
|
+
captureStopped: false,
|
|
79
|
+
endReason: null,
|
|
80
|
+
markerAttr: null // set by capture-dom (serialization markers)
|
|
81
|
+
};
|
|
82
|
+
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
|
+
|
|
87
|
+
function transition(to) {
|
|
88
|
+
if (VALID[state].indexOf(to) === -1) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
'[cyborg-hunter-replay] invalid lifecycle call: ' + state + ' → ' + to +
|
|
91
|
+
'. Expected order: attach() → startSession() → (startTrial → endTrial)* → stopSession() → destroy().' +
|
|
92
|
+
(state === 'created' ? ' Did you forget startSession()?' : '')
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
state = to;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function newTrial(opts, implicit) {
|
|
99
|
+
trialCounter++;
|
|
100
|
+
return {
|
|
101
|
+
trialIndex: trialCounter - 1,
|
|
102
|
+
trialId: (opts && opts.trialId) || (implicit ? '__session__' : 'trial-' + (trialCounter - 1)),
|
|
103
|
+
plugin: (opts && opts.plugin) || 'ch:standalone',
|
|
104
|
+
implicit: !!implicit,
|
|
105
|
+
// tLoad is THE trial time origin (see design §10). tStart/tDomReady are
|
|
106
|
+
// #3661-parity fields; null when the host has no pre-render hook.
|
|
107
|
+
tLoad: implicit ? session.sessionStart : performance.now(),
|
|
108
|
+
tStart: (opts && opts.tStart) != null ? opts.tStart : null,
|
|
109
|
+
tDomReady: (opts && opts.tDomReady) != null ? opts.tDomReady : null,
|
|
110
|
+
tEnd: null,
|
|
111
|
+
initialDom: '',
|
|
112
|
+
events: []
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function closeTrial() {
|
|
117
|
+
currentTrial.tEnd = performance.now();
|
|
118
|
+
session.trials.push(currentTrial);
|
|
119
|
+
currentTrial = null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Fire trial-start subscribers (used by capture-dom for the initial DOM
|
|
123
|
+
// snapshot). A throwing hook is contained like any capture failure.
|
|
124
|
+
function fireTrialStart(trial) {
|
|
125
|
+
for (var i = 0; i < trialStartHooks.length; i++) {
|
|
126
|
+
try { trialStartHooks[i](trial); } catch (e) {
|
|
127
|
+
session.captureFailures.push({
|
|
128
|
+
channel: 'trial_start_hook',
|
|
129
|
+
message: e && e.message ? String(e.message) : String(e),
|
|
130
|
+
t: performance.now()
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
var recorder = {
|
|
137
|
+
config: config,
|
|
138
|
+
|
|
139
|
+
startSession: function () {
|
|
140
|
+
transition('session');
|
|
141
|
+
session.sessionStart = performance.now();
|
|
142
|
+
session.sessionStartEpoch = Date.now();
|
|
143
|
+
// Viewport geometry, if a window exists (absent in node tests).
|
|
144
|
+
var w = typeof window !== 'undefined' ? window : null;
|
|
145
|
+
// documentElement.clientWidth/Height = the LAYOUT width the page was
|
|
146
|
+
// actually formatted against (innerWidth minus any classic scrollbar) —
|
|
147
|
+
// the viewer sizes its reconstruction by this, not innerWidth.
|
|
148
|
+
var de = typeof document !== 'undefined' && document.documentElement
|
|
149
|
+
? 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 };
|
|
162
|
+
if (config.autoSave.mode === 'none') {
|
|
163
|
+
console.warn('[cyborg-hunter-replay] autoSave.mode is "none" — the recording will be lost unless you call getRecording() yourself.');
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
startTrial: function (opts) {
|
|
168
|
+
if (state === 'trial') {
|
|
169
|
+
// 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' });
|
|
172
|
+
closeTrial();
|
|
173
|
+
state = 'session';
|
|
174
|
+
} else if (currentTrial) {
|
|
175
|
+
// An implicit trial is open (session-scoped events arrived before
|
|
176
|
+
// the first explicit startTrial — e.g. a guard violation during the
|
|
177
|
+
// instructions screen). Close it; overwriting currentTrial would
|
|
178
|
+
// orphan those events. This is the NORMAL path, not an error.
|
|
179
|
+
closeTrial();
|
|
180
|
+
}
|
|
181
|
+
transition('trial');
|
|
182
|
+
currentTrial = newTrial(opts, false);
|
|
183
|
+
fireTrialStart(currentTrial);
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
endTrial: function () {
|
|
187
|
+
transition('session');
|
|
188
|
+
if (currentTrial) closeTrial();
|
|
189
|
+
},
|
|
190
|
+
|
|
191
|
+
stopSession: function (reason) {
|
|
192
|
+
if (state === 'trial') {
|
|
193
|
+
state = 'session';
|
|
194
|
+
}
|
|
195
|
+
// Close whatever trial is open — explicit (state was 'trial') or
|
|
196
|
+
// implicit (state 'session' with a lazily-opened trial).
|
|
197
|
+
if (currentTrial) closeTrial();
|
|
198
|
+
transition('stopped');
|
|
199
|
+
session.endReason = reason || 'finished';
|
|
200
|
+
},
|
|
201
|
+
|
|
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) {
|
|
206
|
+
if (state === 'destroyed') {
|
|
207
|
+
throw new Error('[cyborg-hunter-replay] pushEvent called on a destroyed recorder');
|
|
208
|
+
}
|
|
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
|
+
});
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
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;
|
|
248
|
+
}
|
|
249
|
+
trialChars.set(currentTrial, soFar);
|
|
250
|
+
}
|
|
251
|
+
currentTrial.events.push(e);
|
|
252
|
+
},
|
|
253
|
+
|
|
254
|
+
// Capture-channel failure: record and keep going. Recording must never
|
|
255
|
+
// break an experiment (design §6).
|
|
256
|
+
captureFailure: function (channel, err) {
|
|
257
|
+
session.captureFailures.push({
|
|
258
|
+
channel: channel,
|
|
259
|
+
message: err && err.message ? String(err.message) : String(err),
|
|
260
|
+
t: performance.now()
|
|
261
|
+
});
|
|
262
|
+
},
|
|
263
|
+
|
|
264
|
+
onTrialStart: function (fn) {
|
|
265
|
+
trialStartHooks.push(fn);
|
|
266
|
+
},
|
|
267
|
+
|
|
268
|
+
setStylesheets: function (sheets) {
|
|
269
|
+
session.stylesheets = sheets || [];
|
|
270
|
+
},
|
|
271
|
+
|
|
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; },
|
|
276
|
+
|
|
277
|
+
// Listener/interval registry — single teardown point.
|
|
278
|
+
addListener: function (target, event, handler, options) {
|
|
279
|
+
target.addEventListener(event, handler, options || false);
|
|
280
|
+
listeners.push({ target: target, event: event, handler: handler, options: options || false });
|
|
281
|
+
},
|
|
282
|
+
|
|
283
|
+
addInterval: function (id) {
|
|
284
|
+
intervals.push(id);
|
|
285
|
+
},
|
|
286
|
+
|
|
287
|
+
// Read-only view for serializer + tests. Trials array includes the open
|
|
288
|
+
// trial so mid-session getRecording() sees everything so far.
|
|
289
|
+
// Deliberately readable AFTER destroy(): the buffer survives teardown
|
|
290
|
+
// (only capture stops), mirroring CH core's getSessionReport contract —
|
|
291
|
+
// destroy-then-serialize returns complete data instead of losing it.
|
|
292
|
+
getState: function () {
|
|
293
|
+
var trials = session.trials.slice();
|
|
294
|
+
if (currentTrial) trials.push(currentTrial);
|
|
295
|
+
return Object.assign({}, session, { trials: trials, state: state });
|
|
296
|
+
},
|
|
297
|
+
|
|
298
|
+
destroy: function () {
|
|
299
|
+
if (state === 'destroyed') return;
|
|
300
|
+
transition('destroyed');
|
|
301
|
+
listeners.forEach(function (l) {
|
|
302
|
+
if (l.options && l.options._isObserver) {
|
|
303
|
+
l.handler.disconnect();
|
|
304
|
+
} else {
|
|
305
|
+
l.target.removeEventListener(l.event, l.handler, l.options);
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
listeners = [];
|
|
309
|
+
intervals.forEach(function (id) { clearInterval(id); });
|
|
310
|
+
intervals = [];
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
return recorder;
|
|
315
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// src/replay/serializer.js
|
|
2
|
+
// In-memory recorder state → SessionRecording v1 (jsPsych PR #3661 wire
|
|
3
|
+
// format) with a ch_extensions namespace for CH-only data.
|
|
4
|
+
//
|
|
5
|
+
// This is one of exactly two places in the system that convert time bases
|
|
6
|
+
// (the other is the CLI ingest): in-memory events carry absolute
|
|
7
|
+
// performance.now() values; the wire carries ms-since-session-start.
|
|
8
|
+
|
|
9
|
+
import { VERSION } from '../shared/constants.js';
|
|
10
|
+
|
|
11
|
+
// Convert an absolute performance.now() value to wire time (ms since
|
|
12
|
+
// session start), rounded to 1 decimal to keep JSON compact.
|
|
13
|
+
function wireT(t, sessionStart) {
|
|
14
|
+
if (t == null) return null;
|
|
15
|
+
return Math.round((t - sessionStart) * 10) / 10;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Session-scoped CH arrays whose entries carry absolute `t` — converted to
|
|
19
|
+
// session-relative on the way into ch_extensions.
|
|
20
|
+
function convertTimes(entries, sessionStart) {
|
|
21
|
+
return (entries || []).map(function (e) {
|
|
22
|
+
var out = Object.assign({}, e);
|
|
23
|
+
if (typeof out.t === 'number') out.t = wireT(out.t, sessionStart);
|
|
24
|
+
if (typeof out.start === 'number') out.start = wireT(out.start, sessionStart);
|
|
25
|
+
return out;
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {Object} state - recorder.getState() output
|
|
31
|
+
* @param {Object} opts - { chSessionReport } — pull-model CH merge; the
|
|
32
|
+
* caller (jsPsych adapter or researcher) provides CH's session report,
|
|
33
|
+
* the serializer never reaches into CH itself.
|
|
34
|
+
*/
|
|
35
|
+
export function serialize(state, opts) {
|
|
36
|
+
opts = opts || {};
|
|
37
|
+
var s0 = state.sessionStart;
|
|
38
|
+
if (s0 == null) {
|
|
39
|
+
throw new Error('[cyborg-hunter-replay] cannot serialize before startSession() — call startSession() first, or the timestamps would be meaningless (Unix epoch).');
|
|
40
|
+
}
|
|
41
|
+
var ch = opts.chSessionReport || null;
|
|
42
|
+
|
|
43
|
+
var lastT = s0;
|
|
44
|
+
var trials = state.trials.map(function (trial) {
|
|
45
|
+
var events = trial.events.slice().sort(function (a, b) { return a.t - b.t; });
|
|
46
|
+
var lastEventT = events.length > 0 ? events[events.length - 1].t : trial.tLoad;
|
|
47
|
+
if (lastEventT > lastT) lastT = lastEventT;
|
|
48
|
+
return {
|
|
49
|
+
trial_index: trial.trialIndex,
|
|
50
|
+
trial_id: trial.trialId,
|
|
51
|
+
plugin: trial.plugin,
|
|
52
|
+
// t_load is the trial time origin (design §10). t_start/t_dom_ready are
|
|
53
|
+
// #3661-parity fields, null when the host has no pre-render hook.
|
|
54
|
+
t_start: wireT(trial.tStart, s0),
|
|
55
|
+
t_dom_ready: wireT(trial.tDomReady, s0),
|
|
56
|
+
t_load: wireT(trial.tLoad, s0),
|
|
57
|
+
// A trial still open at serialization time (tab closed, mid-session
|
|
58
|
+
// getRecording) ends at its last observed event.
|
|
59
|
+
t_end: wireT(trial.tEnd != null ? trial.tEnd : lastEventT, s0),
|
|
60
|
+
// Scroll/viewport state at trial start — the viewer's camera seed.
|
|
61
|
+
// Times inside are absolute-free (plain state), so no conversion.
|
|
62
|
+
view_state: trial.viewState || null,
|
|
63
|
+
initial_dom: trial.initialDom || '',
|
|
64
|
+
events: events.map(function (e) {
|
|
65
|
+
var out = Object.assign({}, e);
|
|
66
|
+
out.t = wireT(e.t, s0);
|
|
67
|
+
return out;
|
|
68
|
+
}),
|
|
69
|
+
trial_data: {}
|
|
70
|
+
};
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
var chExtensions = {
|
|
74
|
+
replay_version: VERSION,
|
|
75
|
+
// Serialization-marker attribute name (per-recording nonce); the viewer
|
|
76
|
+
// harvests these markers out of the reconstructed DOM for stable
|
|
77
|
+
// mutation/anchor resolution. Null on trace-tier or legacy recordings.
|
|
78
|
+
marker_attr: state.markerAttr || null,
|
|
79
|
+
ch_version: ch ? (ch.libraryVersion || null) : null,
|
|
80
|
+
preset: ch && ch.config ? ch.config.preset : null,
|
|
81
|
+
scoring: ch ? {
|
|
82
|
+
hard_score: ch.hardScore || {},
|
|
83
|
+
soft_score: ch.softScore != null ? ch.softScore : null,
|
|
84
|
+
soft_score_threshold: ch.softScoreThreshold != null ? ch.softScoreThreshold : null,
|
|
85
|
+
any_hard_triggered: !!ch.anyHardTriggered,
|
|
86
|
+
trials_completed: ch.trialsCompleted != null ? ch.trialsCompleted : null
|
|
87
|
+
} : null,
|
|
88
|
+
session_signals: ch ? {
|
|
89
|
+
sidebar_events: convertTimes(ch.sidebarEvents, s0),
|
|
90
|
+
ai_extensions_found: convertTimes(ch.aiExtensionsFound, s0),
|
|
91
|
+
devtools_events: convertTimes(ch.devToolsEvents, s0),
|
|
92
|
+
window_positions: convertTimes(ch.windowPositions, s0),
|
|
93
|
+
layout_shifts: convertTimes(ch.layoutShifts, s0),
|
|
94
|
+
zoom_changes: convertTimes(ch.zoomChanges, s0),
|
|
95
|
+
extension_injections: convertTimes(ch.extensionInjections, s0)
|
|
96
|
+
} : null,
|
|
97
|
+
guard_violations: convertTimes(state.guardViolations, s0),
|
|
98
|
+
capture_failures: convertTimes(state.captureFailures, s0),
|
|
99
|
+
capture_stopped: !!state.captureStopped
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
schema_version: 1,
|
|
104
|
+
metadata: {
|
|
105
|
+
start_time: new Date(state.sessionStartEpoch).toISOString(),
|
|
106
|
+
end_time: new Date(state.sessionStartEpoch + Math.max(0, Math.round(lastT - s0))).toISOString(),
|
|
107
|
+
end_reason: state.endReason || 'aborted',
|
|
108
|
+
participant_id: state.participantId,
|
|
109
|
+
recorder: 'cyborg-hunter-replay@' + VERSION,
|
|
110
|
+
tier: state.tier,
|
|
111
|
+
keys: state.keys
|
|
112
|
+
},
|
|
113
|
+
viewport: state.viewport,
|
|
114
|
+
stylesheets: { initial: state.stylesheets || [], events: [] },
|
|
115
|
+
rng_calls: [], // shape parity with #3661; CH never re-executes code
|
|
116
|
+
trials: trials,
|
|
117
|
+
ch_extensions: chExtensions
|
|
118
|
+
};
|
|
119
|
+
}
|