cyborg-hunter 0.7.4 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +63 -0
- package/CITATION.cff +2 -2
- package/README.md +21 -6
- package/dist/cyborg-hunter-replay.js +3 -3
- package/dist/cyborg-hunter.esm.js +3 -2
- 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/package.json +10 -2
- package/src/cli/ingest.js +311 -57
- package/src/cli/renderers/html-index-core.js +66 -12
- 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/report.js +6 -0
- 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/jspsych/extension-guard-friction.js +59 -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/tools/convert/jspsych-v1-to-v2.mjs +432 -0
package/src/cli/report.js
CHANGED
|
@@ -150,6 +150,12 @@ export async function run(args) {
|
|
|
150
150
|
if (replayAssets.count > 0) {
|
|
151
151
|
console.log(` replay/ — ${replayAssets.count} session replays (${(replayAssets.totalBytes / 1024 / 1024).toFixed(1)} MB)`);
|
|
152
152
|
}
|
|
153
|
+
// A skipped artifact is already visible in the report (the participant's
|
|
154
|
+
// replay section says why), but an analyst watching the CLI must not have
|
|
155
|
+
// to open the HTML to learn a recording did not make it.
|
|
156
|
+
for (const s of replayAssets.skipped) {
|
|
157
|
+
console.log(` replay/ — skipped ${s.participantId}: ${s.reason}`);
|
|
158
|
+
}
|
|
153
159
|
|
|
154
160
|
// HTML index page — references images/ folder (not base64-embedded)
|
|
155
161
|
const { renderHtmlIndex } = await import('./renderers/html-index.js');
|
package/src/core/monitor.js
CHANGED
|
@@ -64,7 +64,7 @@ export function init(userConfig) {
|
|
|
64
64
|
}, userConfig.domProtection || {}),
|
|
65
65
|
collectForPostHoc: Object.assign({
|
|
66
66
|
fullKeystrokeTimestamps: false,
|
|
67
|
-
rawMouseTrack:
|
|
67
|
+
rawMouseTrack: true, // see the privacy note at endTrial (flipped on 2026-09-02)
|
|
68
68
|
responseText: false,
|
|
69
69
|
windowPositionLog: true,
|
|
70
70
|
elementTrace: false,
|
|
@@ -378,18 +378,17 @@ export function init(userConfig) {
|
|
|
378
378
|
report.mouseMetrics = mouseMetrics;
|
|
379
379
|
}
|
|
380
380
|
|
|
381
|
-
//
|
|
382
|
-
//
|
|
383
|
-
//
|
|
384
|
-
//
|
|
385
|
-
//
|
|
386
|
-
//
|
|
387
|
-
//
|
|
388
|
-
//
|
|
389
|
-
//
|
|
390
|
-
//
|
|
391
|
-
//
|
|
392
|
-
// toggle.
|
|
381
|
+
// Raw per-sample mouse coordinates persist under `mouseTrack` (the
|
|
382
|
+
// name extract-core's field map expects: mouseTrack → mouseEvents, so a
|
|
383
|
+
// saved report round-trips through extractIntegrityData() without new
|
|
384
|
+
// glue) unless collectForPostHoc.rawMouseTrack is set false, in which
|
|
385
|
+
// case only the derived mouseMetrics above ship and the {x,y,t,type}
|
|
386
|
+
// samples never leave the browser. ON by default since 2026-09-02: a
|
|
387
|
+
// researcher who adds CH to an experiment has already adjudicated
|
|
388
|
+
// collecting behavioural traces, and an off-by-default gate made the
|
|
389
|
+
// report's trajectory panels read "no mouse data" for everyone who
|
|
390
|
+
// never found the toggle (found by CK on the bench harness). The
|
|
391
|
+
// toggle stays for protocols that need less.
|
|
393
392
|
if (config.collectForPostHoc.rawMouseTrack) {
|
|
394
393
|
report.mouseTrack = report.mouseEvents;
|
|
395
394
|
}
|
|
@@ -35,7 +35,7 @@ import { buildReplayMeta } from '../replay/persistence.js';
|
|
|
35
35
|
class CyborgHunterReplayExtension {
|
|
36
36
|
static info = {
|
|
37
37
|
name: 'cyborg-hunter-replay',
|
|
38
|
-
version: '0.
|
|
38
|
+
version: '0.8.0',
|
|
39
39
|
data: {} // per-trial return is {}; session meta goes via addProperties
|
|
40
40
|
};
|
|
41
41
|
|
|
@@ -47,9 +47,37 @@ class CyborgHunterReplayExtension {
|
|
|
47
47
|
this._monitoring = false;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
// Keyframe cadence on a jsPsych host: every TRIAL segment, always.
|
|
51
|
+
//
|
|
52
|
+
// The standalone recorder keyframes adaptively (spec §3's size trigger plus a
|
|
53
|
+
// segment fallback) because a persistent DOM makes a continuation cheap: the
|
|
54
|
+
// player carries the previous segment's tree forward and applies deltas. A
|
|
55
|
+
// jsPsych trial wipes the display and builds a new one, so there is nothing
|
|
56
|
+
// to carry forward — a continuation would make the player reconstruct each
|
|
57
|
+
// trial by replaying the wipe as a removal of everything followed by an
|
|
58
|
+
// insertion of everything, and would leave a seek into any trial dependent on
|
|
59
|
+
// the trials before it. §3 says as much: wiping hosts SHOULD keyframe every
|
|
60
|
+
// segment, which is also jsPsych-v1's own behaviour.
|
|
61
|
+
//
|
|
62
|
+
// Forced over researcher config rather than merged under it: the reason is a
|
|
63
|
+
// property of the host, not a tuning preference, so a value passed here is a
|
|
64
|
+
// misunderstanding rather than a choice. It is not discarded silently.
|
|
65
|
+
//
|
|
66
|
+
// "Every TRIAL segment" is exact, not loose. A jsPsych recording can also
|
|
67
|
+
// contain UNBRACKETED segments — the inter-trial interval, where the display
|
|
68
|
+
// wipe fires mutations between one trial's `on_finish` and the next trial's
|
|
69
|
+
// `on_load` — and those are recorded as CONTINUATIONS whatever this setting
|
|
70
|
+
// says (capture-dom's implicit-segment rule). That is correct: an ITI segment
|
|
71
|
+
// genuinely continues the outgoing trial's DOM as it is torn down, and its
|
|
72
|
+
// patches were numbered against that span before the segment existed.
|
|
50
73
|
initialize(params) {
|
|
51
74
|
this.params = params || {};
|
|
52
|
-
this.
|
|
75
|
+
if (this.params.keyframeEvery != null && this.params.keyframeEvery !== 1) {
|
|
76
|
+
console.warn('[cyborg-hunter-replay] ignoring keyframeEvery: ' +
|
|
77
|
+
this.params.keyframeEvery + ' — jsPsych wipes the display between ' +
|
|
78
|
+
'trials, so every trial segment is recorded as a keyframe.');
|
|
79
|
+
}
|
|
80
|
+
this.api = CHReplay.attach(Object.assign({}, this.params, { keyframeEvery: 1 }));
|
|
53
81
|
this.api.startSession();
|
|
54
82
|
this._stashChMonitor();
|
|
55
83
|
}
|
|
@@ -64,6 +92,36 @@ class CyborgHunterReplayExtension {
|
|
|
64
92
|
} catch (e) { /* standalone replay is fine */ }
|
|
65
93
|
}
|
|
66
94
|
|
|
95
|
+
// Spec §2's `host`: the runtime the recorder was embedded in, as opposed to
|
|
96
|
+
// `recorder`, which is this library. The recorder core is host-agnostic by
|
|
97
|
+
// construction and never sniffs for globals, so the adapter — the one piece
|
|
98
|
+
// that exists because jsPsych does — is where the answer comes from.
|
|
99
|
+
//
|
|
100
|
+
// §2 types host as `{name, version} | null` with version a required STRING,
|
|
101
|
+
// so a runtime that reports no version gets no host record rather than a
|
|
102
|
+
// half-record a conforming consumer would trip over.
|
|
103
|
+
//
|
|
104
|
+
// `version()` is the only shape checked for, because it is the only shape
|
|
105
|
+
// jsPsych has: 7.3.1 defines `version() { return version; }` on the JsPsych
|
|
106
|
+
// class and 8.x keeps the same core-API accessor. An earlier draft also
|
|
107
|
+
// accepted a plain string field; nothing produces one, so it was speculative
|
|
108
|
+
// dead code and went.
|
|
109
|
+
//
|
|
110
|
+
// Never throws: finalize()'s outer catch turns any throw into
|
|
111
|
+
// replayFinalizeError with NO autosave, and the least important field in
|
|
112
|
+
// the file must not be able to cost the recording.
|
|
113
|
+
_detectHost() {
|
|
114
|
+
try {
|
|
115
|
+
var jp = this.jsPsych;
|
|
116
|
+
if (!jp || typeof jp.version !== 'function') return null;
|
|
117
|
+
var v = jp.version();
|
|
118
|
+
if (typeof v !== 'string' || !v) return null;
|
|
119
|
+
return { name: 'jspsych', version: v };
|
|
120
|
+
} catch (e) {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
67
125
|
// jsPsych 7 calls on_start unconditionally for every trial that lists the
|
|
68
126
|
// extension — must exist even as a no-op (see extension-cyborg-hunter.js).
|
|
69
127
|
on_start(_params) {}
|
|
@@ -105,7 +163,10 @@ class CyborgHunterReplayExtension {
|
|
|
105
163
|
} catch (e) {
|
|
106
164
|
console.warn('[cyborg-hunter-replay] could not pull CH session report:', e);
|
|
107
165
|
}
|
|
108
|
-
var result = await this.api.autoSaveNow({
|
|
166
|
+
var result = await this.api.autoSaveNow({
|
|
167
|
+
chSessionReport: chReport,
|
|
168
|
+
host: this._detectHost()
|
|
169
|
+
});
|
|
109
170
|
this._lastRecording = result.recording;
|
|
110
171
|
this.jsPsych.data.addProperties({ integrityReplayMeta: result.meta });
|
|
111
172
|
} catch (e) {
|
|
@@ -10,7 +10,7 @@ class CyborgHunterExtension {
|
|
|
10
10
|
// and package.json). Hand-bumped on each release; if you forget, the
|
|
11
11
|
// jsPsych developer console shows a stale number — the actual version
|
|
12
12
|
// attached to data is read from window.CyborgHunter.VERSION at runtime.
|
|
13
|
-
version: '0.
|
|
13
|
+
version: '0.8.0',
|
|
14
14
|
data: { integrity: { type: 'object' } }
|
|
15
15
|
};
|
|
16
16
|
|
|
@@ -72,6 +72,23 @@ export function fullscreenElementOf(doc) {
|
|
|
72
72
|
|| null;
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
// Mirrors requestFullscreen()'s prefix fallback on the exit side: Safari
|
|
76
|
+
// <16.4 exposes only webkitExitFullscreen, older Firefox
|
|
77
|
+
// mozCancelFullScreen. Pure + exported for unit testing, like
|
|
78
|
+
// fullscreenElementOf above. Returns the METHOD, unbound — every caller
|
|
79
|
+
// must invoke it as fn.call(document) (see exitFullscreen below): a
|
|
80
|
+
// detached call (fn()) throws "Illegal invocation" in real Chrome, because
|
|
81
|
+
// document.exitFullscreen is a native method that requires document as its
|
|
82
|
+
// receiver.
|
|
83
|
+
export function exitFullscreenFnOf(doc) {
|
|
84
|
+
doc = doc || (typeof document !== 'undefined' ? document : null);
|
|
85
|
+
if (!doc) return null;
|
|
86
|
+
return doc.exitFullscreen
|
|
87
|
+
|| doc.webkitExitFullscreen
|
|
88
|
+
|| doc.mozCancelFullScreen
|
|
89
|
+
|| null;
|
|
90
|
+
}
|
|
91
|
+
|
|
75
92
|
(function (global) {
|
|
76
93
|
'use strict';
|
|
77
94
|
|
|
@@ -789,6 +806,42 @@ export function fullscreenElementOf(doc) {
|
|
|
789
806
|
}
|
|
790
807
|
}
|
|
791
808
|
|
|
809
|
+
// Counterpart to requestFullscreen() above, on the exit side. Two guards
|
|
810
|
+
// before ever touching the native method: a no-op when nothing is
|
|
811
|
+
// fullscreen (fullscreenElementOf(document) is null — nothing to exit),
|
|
812
|
+
// and a no-op while the guard is armed (state.active, the same flag
|
|
813
|
+
// start()/stop() maintain and getCurrentState() reports). The second
|
|
814
|
+
// guard matters because check() runs on every fullscreenchange: exiting
|
|
815
|
+
// while armed would immediately log a false 'not_fullscreen' violation
|
|
816
|
+
// against the participant — a footgun for any study calling this
|
|
817
|
+
// mid-timeline. Callers must stop() first. Never throws.
|
|
818
|
+
function exitFullscreen() {
|
|
819
|
+
if (!fullscreenElementOf(document)) {
|
|
820
|
+
logDebug('exit_fullscreen.not_fullscreen', { diagnostics: getDiagnostics() });
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
if (state.active) {
|
|
824
|
+
logDebug('exit_fullscreen.refused_guard_active', { diagnostics: getDiagnostics() });
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
const exit = exitFullscreenFnOf(document);
|
|
828
|
+
if (exit) {
|
|
829
|
+
try {
|
|
830
|
+
logDebug('exit_fullscreen.attempt', { diagnostics: getDiagnostics() });
|
|
831
|
+
// .call(document) is mandatory, not stylistic — see
|
|
832
|
+
// exitFullscreenFnOf's comment above.
|
|
833
|
+
exit.call(document);
|
|
834
|
+
} catch (e) {
|
|
835
|
+
logDebug('exit_fullscreen.error', {
|
|
836
|
+
message: e && e.message ? e.message : String(e),
|
|
837
|
+
diagnostics: getDiagnostics(),
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
} else {
|
|
841
|
+
logDebug('exit_fullscreen.unavailable_api', { diagnostics: getDiagnostics() });
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
|
|
792
845
|
function start(opts = {}) {
|
|
793
846
|
state.jsPsych = opts.jsPsych || state.jsPsych;
|
|
794
847
|
if (typeof opts.sidebarTolerance === 'number') {
|
|
@@ -1050,6 +1103,11 @@ export function fullscreenElementOf(doc) {
|
|
|
1050
1103
|
stop: function (token) { stop(token); },
|
|
1051
1104
|
setJsPsych: function (j) { state.jsPsych = j; },
|
|
1052
1105
|
requestFullscreen: function () { requestFullscreen(); },
|
|
1106
|
+
// Counterpart to requestFullscreen() above. Call this AFTER stop() —
|
|
1107
|
+
// it no-ops while the guard is still armed (see exitFullscreen's
|
|
1108
|
+
// guard above), so the ordering (not just the refusal) is what a
|
|
1109
|
+
// caller should rely on.
|
|
1110
|
+
exitFullscreen: function () { exitFullscreen(); },
|
|
1053
1111
|
injectRefusalNotices: function () { injectRefusalNotices(); },
|
|
1054
1112
|
createEntryTrial: function (opts) { return createEntryTrial(opts); },
|
|
1055
1113
|
onViolation: function (handler) { return onViolation(handler); },
|
|
@@ -1086,7 +1144,7 @@ class GuardFrictionExtension {
|
|
|
1086
1144
|
// Hand-bumped on each release to track the package version
|
|
1087
1145
|
// (package.json / src/shared/constants.js). Shown in the jsPsych developer
|
|
1088
1146
|
// console only; the runtime library version is independent.
|
|
1089
|
-
version: '0.
|
|
1147
|
+
version: '0.7.0',
|
|
1090
1148
|
data: {}
|
|
1091
1149
|
};
|
|
1092
1150
|
|