cyborg-hunter 0.3.0 → 0.5.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.
@@ -0,0 +1,185 @@
1
+ // jsPsych extension adapter for cyborg-hunter.
2
+ // Loaded as a script tag AFTER cyborg-hunter.min.js — references
3
+ // window.CyborgHunter at runtime rather than importing the core, so the
4
+ // extension stays a thin (~2KB) adapter even though the core is ~28KB.
5
+
6
+ class CyborgHunterExtension {
7
+ static info = {
8
+ name: 'cyborg-hunter',
9
+ // Must match the cyborg-hunter library version (src/shared/constants.js
10
+ // and package.json). Hand-bumped on each release; if you forget, the
11
+ // jsPsych developer console shows a stale number — the actual version
12
+ // attached to data is read from window.CyborgHunter.VERSION at runtime.
13
+ version: '0.3.0',
14
+ data: { integrity: { type: 'object' } }
15
+ };
16
+
17
+ constructor(jsPsych) {
18
+ this.jsPsych = jsPsych;
19
+ this.monitor = null;
20
+ this.params = {};
21
+ this._monitoring = false;
22
+ this._trialStart_perfNow = null;
23
+ }
24
+
25
+ // jsPsych calls this once during initJsPsych setup.
26
+ // Splits extension-specific keys out and forwards the rest to the monitor.
27
+ initialize(params) {
28
+ this.params = params;
29
+ const { autoMonitor, excludeTrialTypes, ...monitorConfig } = params;
30
+
31
+ const CyborgHunter = window.CyborgHunter || window.IntegrityMonitor;
32
+ if (!CyborgHunter) {
33
+ throw new Error('CyborgHunter not found. Load cyborg-hunter.min.js before the jsPsych extension.');
34
+ }
35
+
36
+ this.monitor = CyborgHunter.init(monitorConfig);
37
+ this.monitor.startSession();
38
+ }
39
+
40
+ // jsPsych 7 unconditionally calls on_start for every trial that lists this
41
+ // extension, even though the actual setup happens in on_load (after DOM
42
+ // render). Without this no-op, the very next trial throws
43
+ // "this.extensions['cyborg-hunter'].on_start is not a function".
44
+ on_start(_params) {}
45
+
46
+ // Called per trial after DOM render. Per-trial extension params arrive here.
47
+ on_load(params) {
48
+ this._monitoring = false;
49
+
50
+ // Explicit opt-in mode: only monitor trials that pass a trialId.
51
+ if (this.params.autoMonitor === false && !(params && params.trialId)) {
52
+ return;
53
+ }
54
+
55
+ // Auto-monitor with exclusions: skip trial types named in the config
56
+ // (instructions, fixation, etc.). Trial type name comes from the plugin's
57
+ // static info.name property.
58
+ if (this.params.excludeTrialTypes) {
59
+ const typeName = this.jsPsych.getCurrentTrial().type?.info?.name || '';
60
+ if (this.params.excludeTrialTypes.includes(typeName)) return;
61
+ }
62
+
63
+ this._monitoring = true;
64
+ // Capture performance.now() at trial start so ingest can compute
65
+ // trial-relative tab-away timestamps by direct subtraction. tabAwayEvents
66
+ // store their `start` on the same monotonic clock, so the difference is
67
+ // exact. Without this anchor, the CLI falls back to a heuristic estimator
68
+ // on session-anchored data, which is less precise.
69
+ this._trialStart_perfNow = performance.now();
70
+ const trialIndex = this.jsPsych.getProgress().current_trial_global;
71
+ this.monitor.startTrial({
72
+ trialId: (params && params.trialId) || `trial-${trialIndex}`,
73
+ phase: params?.phase || null,
74
+ decoyAnswer: params?.decoyAnswer || null,
75
+ experimentContainer: params?.experimentContainer || null
76
+ });
77
+ }
78
+
79
+ // Called when a trial finishes. Returns the integrity report, which jsPsych
80
+ // merges into the trial's data under the key declared in static info.data
81
+ // (i.e., data.integrity).
82
+ on_finish(_params) {
83
+ if (!this._monitoring) return {};
84
+ this._monitoring = false;
85
+ const report = this.monitor.endTrial();
86
+ // Forward the on_load anchor (see on_load above) so ingest can do exact
87
+ // time arithmetic without re-deriving it.
88
+ report.trialStart_perfNow = this._trialStart_perfNow;
89
+ this._trialStart_perfNow = null;
90
+ return { integrity: report };
91
+ }
92
+
93
+ // Called explicitly by the researcher from initJsPsych({ on_finish }), BEFORE
94
+ // saving data. Example:
95
+ //
96
+ // const jsPsych = initJsPsych({
97
+ // extensions: [{ type: jsPsychCyborgHunter, params: { ... } }],
98
+ // on_finish: function () {
99
+ // jsPsych.extensions['cyborg-hunter'].finalize();
100
+ // jsPsych.data.get().localSave('csv', 'data.csv');
101
+ // }
102
+ // });
103
+ //
104
+ // Why this isn't automatic: jsPsych 7 has no on_finish_experiment extension
105
+ // hook (only initialize / on_start / on_load / on_finish exist). An earlier
106
+ // version of this wrapper defined on_finish_experiment and silently dropped
107
+ // session data because jsPsych never called it. We surface the boundary
108
+ // explicitly so it can't silently regress.
109
+ //
110
+ // Persistence shape (split attachment):
111
+ // - Scalars (counts, booleans, version) → addProperties, so they appear
112
+ // as columns on EVERY trial row. Cheap, queryable from R/pandas.
113
+ // - Arrays + nested objects (sidebarEvents, integrityScore, etc.) →
114
+ // addDataToLastTrial, so they're attached ONCE on the final row.
115
+ // Avoids duplicating kilobytes of identical JSON across every row.
116
+ //
117
+ // After persisting, tears down listeners and clears session state.
118
+ finalize() {
119
+ if (!this.monitor) return;
120
+
121
+ try {
122
+ // getSessionReport() is the source of truth: contains both the
123
+ // accumulators and the scoring summary in one shape.
124
+ const report = this.monitor.getSessionReport();
125
+
126
+ const {
127
+ // Arrays — attached once on the last trial only (no CSV bloat).
128
+ sidebarEvents = [], keyboardShortcuts = [], windowPositions = [],
129
+ layoutShifts = [], zoomChanges = [], idleGaps = [], extensionInjections = [],
130
+ tabAwaySums = [], charsPerSec = [], aiExtensionsFound = [],
131
+ // Scoring summary — full object on last trial; key scalars below
132
+ // also duplicated to every row via addProperties.
133
+ hardScore, softScore, anyHardTriggered, trialsCompleted, softScoreThreshold,
134
+ // Scalars.
135
+ pasteCount = 0, copyCount = 0, dropCount = 0,
136
+ libraryVersion, config,
137
+ // Future-proof: anything new from the monitor flows into integritySession
138
+ // automatically without polluting the per-row scalar columns.
139
+ ...extras
140
+ } = report;
141
+
142
+ this.jsPsych.data.addProperties({
143
+ integrityPasteCount: pasteCount,
144
+ integrityCopyCount: copyCount,
145
+ integrityDropCount: dropCount,
146
+ integrityAnyHardTriggered: !!anyHardTriggered,
147
+ integritySoftScore: softScore,
148
+ cyborgHunterVersion: libraryVersion,
149
+ });
150
+
151
+ this.jsPsych.data.addDataToLastTrial({
152
+ integritySession: {
153
+ pasteCount, copyCount, dropCount,
154
+ sidebarEvents, keyboardShortcuts, windowPositions,
155
+ layoutShifts, zoomChanges, idleGaps, extensionInjections,
156
+ tabAwaySums, charsPerSec, aiExtensionsFound,
157
+ ...extras
158
+ },
159
+ integrityScore: { hardScore, softScore, anyHardTriggered, trialsCompleted, softScoreThreshold },
160
+ });
161
+ } catch (e) {
162
+ // Don't crash the experiment's end-of-session path — the user's save
163
+ // call (localSave / dispatcher / etc.) MUST still run. But don't fail
164
+ // silently either — drop a marker into the data so analysts can see
165
+ // during ingest that finalize() failed for this participant, plus log
166
+ // to console for local debugging.
167
+ console.warn('[cyborg-hunter] Failed to save session report:', e);
168
+ try {
169
+ this.jsPsych.data.addProperties({
170
+ cyborgHunterFinalizeError: String(e && e.message ? e.message : e)
171
+ });
172
+ } catch (_) { /* if even addProperties fails, the warn above is all we have */ }
173
+ }
174
+
175
+ this.monitor.destroy();
176
+ }
177
+ }
178
+
179
+ // Module export AND IIFE-friendly window assignment. The build target is IIFE,
180
+ // so `window.jsPsychCyborgHunter` is what researchers reference in their
181
+ // extensions array. The named export exists for completeness.
182
+ export { CyborgHunterExtension };
183
+ if (typeof window !== 'undefined') {
184
+ window.jsPsychCyborgHunter = CyborgHunterExtension;
185
+ }