cyborg-hunter 0.9.1 → 0.10.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 (37) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/CITATION.cff +1 -1
  3. package/README.md +33 -32
  4. package/dist/ch.js +41 -0
  5. package/dist/cyborg-hunter-replay.js +3 -3
  6. package/dist/cyborg-hunter.esm.js +1 -1
  7. package/dist/cyborg-hunter.min.js +4 -3
  8. package/dist/extension-cyborg-hunter.js +1 -1
  9. package/dist/extension-guard-friction.js +5 -5
  10. package/dist/extension-guard-honeypot.js +1 -1
  11. package/package.json +11 -5
  12. package/src/cli/extract-core.js +70 -4
  13. package/src/cli/renderers/replay-client-source.js +2 -2
  14. package/src/cli/segment-reassembly.js +204 -0
  15. package/src/jspsych/extension-cyborg-hunter-replay.js +1 -1
  16. package/src/jspsych/extension-cyborg-hunter.js +1 -1
  17. package/src/jspsych/extension-guard-friction.js +21 -7
  18. package/src/jspsych/extension-guard-honeypot.js +13 -6
  19. package/src/oneliner/adapters/jspsych-extension.js +196 -0
  20. package/src/oneliner/adapters/jspsych.js +483 -0
  21. package/src/oneliner/adapters/vanilla.js +420 -0
  22. package/src/oneliner/api.js +151 -0
  23. package/src/oneliner/boot.js +270 -0
  24. package/src/oneliner/config.js +122 -0
  25. package/src/oneliner/debug.js +137 -0
  26. package/src/oneliner/entry.js +8 -0
  27. package/src/oneliner/errors.js +219 -0
  28. package/src/oneliner/guards.js +67 -0
  29. package/src/oneliner/participant-id.js +49 -0
  30. package/src/oneliner/replay-loader.js +296 -0
  31. package/src/oneliner/segment-diff.js +68 -0
  32. package/src/oneliner/segmenter.js +186 -0
  33. package/src/replay/dom-instantiate.js +10 -1
  34. package/src/replay/index.js +4 -2
  35. package/src/replay/recorder.js +5 -1
  36. package/src/shared/constants.js +1 -1
  37. package/src/shared/schema-v2-validator.js +14 -9
@@ -0,0 +1,196 @@
1
+ // src/oneliner/adapters/jspsych-extension.js
2
+ // The jsPsych extension ch.js injects into every trial (adapters/jspsych.js
3
+ // adds it to initJsPsych's list and to each trial object). Unlike the manual
4
+ // extension (src/jspsych/extension-cyborg-hunter.js) it creates no monitor:
5
+ // boot already has one, kept inside a trial by the segmenter. So:
6
+ // on_load rotate: close the span before this trial (a "gap"), open the
7
+ // host trial;
8
+ // on_finish cut: close the host trial, turn everything since the last cut
9
+ // into a segment, open the next gap span. The returned object is
10
+ // merged into this trial's own row by jsPsych (jspsych.js 7.3.1
11
+ // :2772, :2814-2823), before the trial's own on_finish, so
12
+ // whatever save the researcher already does carries the segment
13
+ // and the running totals up to this row.
14
+ //
15
+ // The class keeps info.name 'cyborg-hunter' (jsPsych keys its extension
16
+ // instances by name) and exposes `.monitor`, which the replay extension reads
17
+ // through jsPsych.extensions['cyborg-hunter'].monitor.
18
+ //
19
+ // A trial listing the manual class by name (a researcher-named entry with
20
+ // params.trialId) reaches this instance too, with its params, so its trialId
21
+ // names the segment.
22
+ //
23
+ // A late on_load is dropped. A synchronous plugin (call-function) finishes
24
+ // inside its own trial() call, and jsPsych runs that trial's load callback
25
+ // only afterwards (jspsych.js 7.3.1 :3046-3056, :3101-3103), in one of two
26
+ // orders:
27
+ // - nextTrial runs synchronously from finishTrial: the next trial's
28
+ // on_start and on_load come first, then the stale on_load, which would
29
+ // close the next trial's span as a gap and reopen it unnamed;
30
+ // - post_trial_gap / default_iti > 0 defer nextTrial: the stale on_load
31
+ // comes right after its own on_finish and would open a span for a trial
32
+ // that already ended.
33
+ // A third order: when the next trial's trial() returns a Promise (jsPsych 7
34
+ // audio plugins, custom plugins), jsPsych leaves its load callback to the
35
+ // plugin (:3099-3103), so the stale on_load arrives after the next trial's
36
+ // on_start but before that trial's own on_load.
37
+ // An index check (current_trial_global) catches only the first order: in the
38
+ // second the index has not moved yet. So on_start arms the load and records
39
+ // the params object jsPsych passed; on_load counts only while armed and only
40
+ // with that same object; on_finish disarms. jsPsych hands one trial's
41
+ // on_start and on_load the same object (extension.params of the same trial,
42
+ // :3027-3030, :3046-3054), and the adapter gives every trial its own copy of
43
+ // the injected entry, so the stale call is rejected in all three orders.
44
+ // (jsPsych calls the extension's on_start on every trial that lists it, so
45
+ // every real on_load is armed.)
46
+ //
47
+ // After the session has ended (the final hook ran, ctx.jspsych.finalized),
48
+ // both hooks leave the segmenter alone: rows of a second jsPsych instance
49
+ // that runs afterwards get no cyborgHunterError ('finished') marker; the
50
+ // adapter warned about the second instance when it was created. They also
51
+ // stand down when the deferred session start failed (ctx.bootError, boot.js
52
+ // failDeferred), which marked every row already.
53
+ //
54
+ // ctx (set by installJsPsychAdapter): { monitor, segmenter, jspsych, debug? }.
55
+ // None of the hooks throws into jsPsych: a failure becomes cyborgHunterError
56
+ // on the row. With no ctx (ch.js failed or stood down, boot.js; the class is
57
+ // still registered so researcher trials typed jsPsychCyborgHunter run) every
58
+ // hook does nothing.
59
+ //
60
+ // Leftovers of manual wiring on a half-migrated page, where
61
+ // jsPsychCyborgHunter is this class: participantId / preset in the
62
+ // initJsPsych entry's params (initialize) and the on_finish finalize() call
63
+ // each warn once from the catalogue and are otherwise ignored; finalize()
64
+ // existing at all keeps the researcher's save code after it running.
65
+
66
+ import { MESSAGES } from '../errors.js';
67
+
68
+ export class OneLinerExtension {
69
+ static info = {
70
+ name: 'cyborg-hunter',
71
+ // Hand-bumped with the package version (tests/cli/version-invariant.test.js
72
+ // pins it to package.json by reading this literal).
73
+ version: '0.10.0',
74
+ data: {
75
+ integrity: { type: 'object' },
76
+ integritySegment: { type: 'object' }
77
+ }
78
+ };
79
+
80
+ static ctx = null;
81
+
82
+ constructor(jsPsych) {
83
+ this.jsPsych = jsPsych;
84
+ this._trialStart_perfNow = null;
85
+ this._loadError = null;
86
+ this._loadArmed = false;
87
+ this._armedParams = undefined;
88
+ this._paramsWarned = false;
89
+ this._finalizeWarned = false;
90
+ }
91
+
92
+ get monitor() {
93
+ return OneLinerExtension.ctx ? OneLinerExtension.ctx.monitor : null;
94
+ }
95
+
96
+ // The monitor already exists (boot); nothing to set up. jsPsych passes the
97
+ // initJsPsych entry's params (a researcher's entry wins the dedupe over
98
+ // ours, adapters/jspsych.js).
99
+ initialize(params) {
100
+ if (this._paramsWarned || !params) return;
101
+ if (params.participantId !== undefined || params.preset !== undefined) {
102
+ this._paramsWarned = true;
103
+ console.warn(MESSAGES.extensionParamsIgnored());
104
+ }
105
+ }
106
+
107
+ // The manual extension's end-of-session call. ch.js ends the session from
108
+ // initJsPsych's on_finish (adapters/jspsych.js), before the researcher's.
109
+ finalize() {
110
+ if (this._finalizeWarned) return;
111
+ this._finalizeWarned = true;
112
+ console.warn(MESSAGES.finalizeNotNeeded());
113
+ }
114
+
115
+ // jsPsych 7 calls on_start on every trial that lists the extension, before
116
+ // the plugin's trial(): arm this trial's on_load, for these params only.
117
+ on_start(params) {
118
+ this._loadArmed = true;
119
+ this._armedParams = params;
120
+ }
121
+
122
+ on_load(params) {
123
+ var ctx = OneLinerExtension.ctx;
124
+ // A late on_load (see the header) is dropped before anything is reset:
125
+ // the trial now open keeps its anchor and any rotate error.
126
+ if (!this._loadArmed || params !== this._armedParams) return;
127
+ this._loadArmed = false;
128
+ this._armedParams = undefined;
129
+ this._loadError = null;
130
+ if (!ctx || ctx.bootError || (ctx.jspsych && ctx.jspsych.finalized)) return;
131
+ try {
132
+ // Same anchor as the manual extension: ingest subtracts it from the
133
+ // tab-away `start` times (same performance.now() clock).
134
+ this._trialStart_perfNow = performance.now();
135
+ var r = ctx.segmenter.rotate({
136
+ trialId: (params && params.trialId) || 'trial-' + this.jsPsych.getProgress().current_trial_global,
137
+ phase: (params && params.phase) || null,
138
+ // ?? not ||: an explicit decoyAnswer:false (skip the decoy) must reach
139
+ // the core as false.
140
+ decoyAnswer: params && params.decoyAnswer !== undefined && params.decoyAnswer !== null ? params.decoyAnswer : null,
141
+ experimentContainer: (params && params.experimentContainer) || null
142
+ });
143
+ if (r && r.error) this._loadError = r.error;
144
+ } catch (e) {
145
+ this._loadError = String((e && e.message) || e);
146
+ }
147
+ // jsPsych's prepareDom wiped <body>, badge included, before the first
148
+ // trial; this puts it back while that trial is on screen.
149
+ try { if (ctx.debug && ctx.debug.refresh) ctx.debug.refresh(); } catch (_) { /* a debug aid */ }
150
+ }
151
+
152
+ on_finish(_params) {
153
+ var ctx = OneLinerExtension.ctx;
154
+ this._loadArmed = false;
155
+ this._armedParams = undefined;
156
+ if (!ctx || ctx.bootError || (ctx.jspsych && ctx.jspsych.finalized)) return {};
157
+ // Timed only under data-debug: no clock reads otherwise.
158
+ var t0 = ctx.debug ? performance.now() : 0;
159
+ var out;
160
+ try {
161
+ var idx = this.jsPsych.getProgress().current_trial_global;
162
+ var r = ctx.segmenter.cut({ source: 'host', nextTrialId: 'gap-' + idx });
163
+ if (r && r.segment) {
164
+ var report = r.trialReport || {};
165
+ report.trialStart_perfNow = this._trialStart_perfNow;
166
+ out = {
167
+ integrity: report,
168
+ integritySegment: r.segment,
169
+ integrityPasteCount: r.segment.counters.pasteCount,
170
+ integrityCopyCount: r.segment.counters.copyCount,
171
+ integrityDropCount: r.segment.counters.dropCount,
172
+ integritySoftScore: r.segment.score.softScore,
173
+ integrityAnyHardTriggered: r.segment.score.anyHardTriggered
174
+ };
175
+ // A segment that comes with an error is complete; only the next span
176
+ // failed to open. Save it, and mark the row.
177
+ var err = r.error || this._loadError;
178
+ if (err) out.cyborgHunterError = err;
179
+ if (ctx.jspsych) ctx.jspsych.segmentsWritten = (ctx.jspsych.segmentsWritten || 0) + 1;
180
+ } else {
181
+ out = { cyborgHunterError: (r && r.error) || this._loadError || 'no segment' };
182
+ }
183
+ } catch (e) {
184
+ out = { cyborgHunterError: String((e && e.message) || e) };
185
+ }
186
+ this._trialStart_perfNow = null;
187
+ this._loadError = null;
188
+ try {
189
+ // The timing covers ch.js's cut plus output assembly, not jsPsych's own
190
+ // merge of this output into the data row.
191
+ if (ctx.debug && ctx.debug.stats) ctx.debug.stats().segmentWriteMs.push(performance.now() - t0);
192
+ if (ctx.debug && ctx.debug.refresh) ctx.debug.refresh(); // after the timing push
193
+ } catch (_) { /* debug counters are optional */ }
194
+ return out;
195
+ }
196
+ }
@@ -0,0 +1,483 @@
1
+ // src/oneliner/adapters/jspsych.js
2
+ // The jsPsych 7 host: ch.js sits below jspsych.js and above the experiment
3
+ // code, so window.initJsPsych exists at boot and can be wrapped before the
4
+ // researcher calls it. The wrapper
5
+ // 1. adds ch.js's extensions to initJsPsych's list: OneLinerExtension
6
+ // ('cyborg-hunter'), the honeypot and, when enabled, friction (deduped by
7
+ // info.name: jsPsych keeps one instance per name but calls initialize()
8
+ // once per listed entry, jspsych.js 7.3.1 :2667-2669 and :2941-2946);
9
+ // 2. adds participantId and cyborgHunterVersion to every row (addProperties,
10
+ // once: the only static values);
11
+ // 3. wraps jsPsych.run so the timeline is walked and every trial object gets
12
+ // the per-trial entries BEFORE run() builds its TimelineNode tree
13
+ // (:2691). run is an own bound property (autoBind, :2648), so simulate()
14
+ // goes through the wrapper too;
15
+ // 4. chains initJsPsych's on_finish: the last segment, the *Final totals,
16
+ // friction stop, honeypot session summary and monitor teardown run
17
+ // first, then the researcher's own on_finish (its return value, possibly
18
+ // a promise, is passed back to jsPsych, :2968-2980). With data-replay
19
+ // and a CyborgHunterConfig.replay.autoSave mode other than 'none', the
20
+ // replay recorder's finalize() (serialize + save) is awaited in between.
21
+ // Per-trial segments and running totals are returned by OneLinerExtension's
22
+ // on_finish, merged into each row by jsPsych.
23
+ //
24
+ // Guards on this host. boot does not start them here; the injected guard
25
+ // extensions do, from their initialize(), which jsPsych calls inside run()
26
+ // after the window load event (prepareDom, :2882-2889). Between boot and that
27
+ // moment the monitor already records (the boot span), but the honeypot bait
28
+ // and friction are not active yet.
29
+ //
30
+ // One jsPsych instance per page is assumed: one boot monitor, one segmenter,
31
+ // one session. A second wrapped initJsPsych gets a catalogue warning; each
32
+ // instance's final hook writes to that instance's own data, and the first
33
+ // one to finish ends the session (later rows are left unmarked, see
34
+ // jspsych-extension.js).
35
+ //
36
+ // Nothing here throws into the page: a failure is logged from the catalogue
37
+ // (errors.js) and jsPsych runs as if ch.js were absent from that step on.
38
+
39
+ import { VERSION } from '../../shared/constants.js';
40
+ import { MESSAGES } from '../errors.js';
41
+ import { OneLinerExtension } from './jspsych-extension.js';
42
+
43
+ var CH_NAME = 'cyborg-hunter';
44
+ var HONEYPOT_NAME = 'guard-honeypot';
45
+ var FRICTION_NAME = 'guard-friction';
46
+ var REPLAY_NAME = 'cyborg-hunter-replay';
47
+ var ENTRY_TRIAL_LABEL = 'guard_friction_entry'; // GuardFriction.createEntryTrial's data label
48
+
49
+ function message(e) { return String((e && e.message) || e); }
50
+ function own(obj, key) { return Object.prototype.hasOwnProperty.call(obj, key); }
51
+
52
+ function nameOf(entry) {
53
+ return entry && entry.type && entry.type.info ? entry.type.info.name : undefined;
54
+ }
55
+
56
+ export function isChType(type) {
57
+ return !!(type && type.info && type.info.name === CH_NAME);
58
+ }
59
+
60
+ // Keeps the first entry per type.info.name. Entries without a name are kept
61
+ // as they are (jsPsych itself will report them).
62
+ export function dedupeExtensions(list) {
63
+ var seen = Object.create(null);
64
+ var out = [];
65
+ (list || []).forEach(function (entry) {
66
+ var name = nameOf(entry);
67
+ if (name === undefined) { out.push(entry); return; }
68
+ if (seen[name]) return;
69
+ seen[name] = true;
70
+ out.push(entry);
71
+ });
72
+ return out;
73
+ }
74
+
75
+ // Adds each of `entries` to every trial object of `timeline` that does not
76
+ // already list an extension of that name.
77
+ //
78
+ // jsPsych (:2160-2199) treats an object with `timeline` as a node and passes
79
+ // its other keys (type, extensions, data, ...) down to each child with a
80
+ // shallow Object.assign, so a child's own `extensions` replaces the parent's.
81
+ // The walk therefore carries the inherited type and extensions down, touches
82
+ // only trial objects (no `timeline`), and never wraps anything (a wrapper node
83
+ // would shift internal_node_id). A trial that inherits its parent's list gets
84
+ // its own copy of it plus ours.
85
+ //
86
+ // Rules: (a) a trial already listing a cyborg-hunter entry keeps it and gets
87
+ // no second one for that name; (b) an object reached twice (the same trial in
88
+ // two places) is handled once; a non-array `extensions` is left alone with a
89
+ // warning. A cyborg-hunter entry with null/undefined params gets `params: {}`.
90
+ //
91
+ // Each trial gets its own shallow copy of an entry and of its params:
92
+ // OneLinerExtension ties an on_load to its trial by the params object (see
93
+ // jspsych-extension.js). jsPsych 7.3.1 already deep-copies a trial before
94
+ // running it (TimelineNode.trial(), :2218-2222); the copy here does not rely
95
+ // on that. The copies are remembered across calls, so a timeline run twice
96
+ // does not count them as a researcher's own entry.
97
+ var injectedCopies = new WeakSet();
98
+
99
+ export function injectExtensions(timeline, entries, seen) {
100
+ seen = seen || new WeakSet();
101
+ var result = { trials: 0, skippedOwnEntry: 0, sharedObjects: 0, entryTrialFound: false };
102
+ var warnedFrozen = false;
103
+
104
+ function copyOf(entry) {
105
+ var c = Object.assign({}, entry);
106
+ if (entry.params && typeof entry.params === 'object') c.params = Object.assign({}, entry.params);
107
+ injectedCopies.add(c);
108
+ return c;
109
+ }
110
+
111
+ function walkList(list, inherited) {
112
+ if (!Array.isArray(list)) return;
113
+ for (var i = 0; i < list.length; i++) walkNode(list[i], inherited);
114
+ }
115
+
116
+ function walkNode(node, inherited) {
117
+ if (!node || typeof node !== 'object') return;
118
+ if (seen.has(node)) { result.sharedObjects += 1; return; }
119
+ seen.add(node);
120
+ var hasOwnList = own(node, 'extensions');
121
+ var type = own(node, 'type') ? node.type : inherited.type;
122
+ var list = hasOwnList ? node.extensions : inherited.extensions;
123
+ if (node.timeline !== undefined) {
124
+ walkList(node.timeline, { type: type, extensions: list });
125
+ return;
126
+ }
127
+ if (type === undefined) return; // not a trial; jsPsych reports a missing type itself
128
+ if (list !== undefined && !Array.isArray(list)) {
129
+ console.warn('[cyborg-hunter] a trial\'s extensions is not an array, so ch.js left that trial unmonitored');
130
+ return;
131
+ }
132
+ result.trials += 1;
133
+ if (node.data && node.data.trial_type_label === ENTRY_TRIAL_LABEL) result.entryTrialFound = true;
134
+
135
+ list = list || [];
136
+ // A researcher's own cyborg-hunter entry written without params (the
137
+ // manual docs' per-trial loop) would reach on_start and on_load as
138
+ // `undefined` on every trial, so a late load callback could pass for this
139
+ // trial's own. Give it a params object; params a researcher wrote are
140
+ // never touched. A frozen entry stays as it is (the write would throw and
141
+ // leave the rest of the timeline unmonitored).
142
+ list.forEach(function (e) {
143
+ if (!e || !isChType(e.type) || e.params != null) return;
144
+ try { e.params = {}; } catch (err) { /* frozen or sealed: left params-less */ }
145
+ });
146
+ var present = list.map(nameOf);
147
+ // Counted only for a researcher's own entry, not one of ours already
148
+ // pushed into an extensions array that several trials share.
149
+ if (list.some(function (e) { return e && isChType(e.type) && !injectedCopies.has(e); })) {
150
+ result.skippedOwnEntry += 1;
151
+ }
152
+ var missing = entries.filter(function (e) { return present.indexOf(nameOf(e)) === -1; });
153
+ if (missing.length === 0) return;
154
+ missing = missing.map(copyOf);
155
+ // A frozen or sealed trial object (or extensions array) throws in strict
156
+ // mode; that trial stays unmonitored and the walk goes on. The shipped
157
+ // bundle is not strict, where the same write fails silently, so check
158
+ // that it took.
159
+ try {
160
+ if (hasOwnList) Array.prototype.push.apply(node.extensions, missing);
161
+ else node.extensions = list.concat(missing);
162
+ if (!Array.isArray(node.extensions) || node.extensions.indexOf(missing[0]) === -1) {
163
+ throw new Error('trial object not writable');
164
+ }
165
+ } catch (err) {
166
+ result.trials -= 1;
167
+ if (!warnedFrozen) {
168
+ warnedFrozen = true;
169
+ console.warn('[cyborg-hunter] a trial object could not be changed (frozen or sealed), so ch.js left it unmonitored');
170
+ }
171
+ }
172
+ }
173
+
174
+ walkList(timeline, { type: undefined, extensions: undefined });
175
+ return result;
176
+ }
177
+
178
+ // Adds { type: OneLinerExtension, params: {} } to initJsPsych options whose
179
+ // extensions list (an array, or absent) has no 'cyborg-hunter' entry. jsPsych
180
+ // 7.3.1 calls this.extensions[name].on_start / on_load for every entry a
181
+ // trial lists (:3027-3030, :3050-3053), so a researcher trial typed
182
+ // jsPsychCyborgHunter needs an instance of that name registered, or it
183
+ // throws. A non-array list is left for jsPsych to report. Never throws.
184
+ function ensureChEntry(options) {
185
+ try {
186
+ var list = options.extensions;
187
+ if (list === undefined || list === null) list = [];
188
+ if (!Array.isArray(list)) return;
189
+ if (list.some(function (e) { return nameOf(e) === CH_NAME; })) return;
190
+ options.extensions = list.concat([{ type: OneLinerExtension, params: {} }]);
191
+ } catch (_) { /* jsPsych runs as it would without ch.js */ }
192
+ }
193
+
194
+ // What ch.js leaves on initJsPsych when it does not monitor: boot failed
195
+ // (boot.js fail()), the deferred session start failed (failDeferred, after
196
+ // restore()), or ch.js stood down after cyborg-hunter.min.js. Only
197
+ // ensureChEntry: OneLinerExtension.ctx is never set from here, so the entry
198
+ // is inert (no rotate, on_finish returns {}). Installed once, and only over a
199
+ // function (a page without jsPsych has nothing to wrap).
200
+ export function installInertWrapper(win) {
201
+ var orig = win.initJsPsych;
202
+ if (typeof orig !== 'function' || orig.__cyborgHunterInert) return;
203
+ var wrapped = function (options) {
204
+ options = options || {};
205
+ ensureChEntry(options);
206
+ return orig.apply(this, [options].concat(Array.prototype.slice.call(arguments, 1)));
207
+ };
208
+ wrapped.__cyborgHunterInert = true;
209
+ win.initJsPsych = wrapped;
210
+ }
211
+
212
+ // Manual mode (the researcher wires the cyborg-hunter extension themselves):
213
+ // an initJsPsych entry named 'cyborg-hunter' whose class is not ours. Listing
214
+ // OneLinerExtension itself is still the one-liner.
215
+ export function detectManualMode(options) {
216
+ var list = options && options.extensions;
217
+ if (!Array.isArray(list)) return false;
218
+ return list.some(function (e) { return !!e && isChType(e.type) && e.type !== OneLinerExtension; });
219
+ }
220
+
221
+ // Manual mode: the researcher's extension creates its own monitor, so ch.js
222
+ // hands over: its boot span is closed without a segment and its monitor is
223
+ // destroyed (two monitors would double-count every event).
224
+ //
225
+ // The researcher's extension calls window.CyborgHunter.init(), which is
226
+ // ch.js's namespace whether or not cyborg-hunter.min.js was loaded after
227
+ // ch.js (min.js's footer puts ch.js's namespace back; build.js). Its init()
228
+ // returns a core monitor once ctx.host is 'manual' (api.js), so the manual
229
+ // wiring works either way.
230
+ function handOver(ctx) {
231
+ ctx.host = 'manual';
232
+ try { ctx.segmenter.abandon(); } catch (_) { /* the hand-over continues */ }
233
+ try { ctx.monitor.destroy(); } catch (_) { /* already destroyed */ }
234
+ console.info('[cyborg-hunter] manual mode: initJsPsych lists a cyborg-hunter extension, so ch.js injects nothing; finalize() is still required.');
235
+ // run() is never wrapped in manual mode, so this is the page's one summary.
236
+ try { if (ctx.debug && ctx.debug.update) ctx.debug.update(); } catch (_) { /* a debug aid */ }
237
+ }
238
+
239
+ // The end of the session, from the chained on_finish of `jsPsych` (the
240
+ // instance that finished, not necessarily the latest one wrapped). Runs once;
241
+ // never throws. Friction is stopped before the honeypot writes its session
242
+ // summary, so a violation still open at the end is closed first
243
+ // (extension-guard-honeypot.js).
244
+ function runFinalHook(ctx, win, has, jsPsych) {
245
+ if (ctx.jspsych.finalized) return;
246
+ ctx.jspsych.finalized = true;
247
+ var problems = [];
248
+ var marker = null;
249
+ function step(fn) {
250
+ try { fn(); } catch (e) { problems.push(message(e)); }
251
+ }
252
+ step(function () {
253
+ var r = ctx.segmenter.finish({ source: 'final' });
254
+ if (r && r.segment) {
255
+ var seg = r.segment;
256
+ jsPsych.data.addDataToLastTrial({ integritySegmentFinal: seg });
257
+ jsPsych.data.addProperties({
258
+ integrityPasteCountFinal: seg.counters.pasteCount,
259
+ integrityCopyCountFinal: seg.counters.copyCount,
260
+ integrityDropCountFinal: seg.counters.dropCount,
261
+ integritySoftScoreFinal: seg.score.softScore,
262
+ integrityAnyHardTriggeredFinal: seg.score.anyHardTriggered
263
+ });
264
+ }
265
+ // The segmenter has already logged its own failure.
266
+ if (r && r.error) marker = r.error;
267
+ });
268
+ // Whenever friction holds a token, not only when ch.js injected friction:
269
+ // the entry trial starts friction from its own on_finish timer even when
270
+ // data-guards does not enable it, and its intervals and curtain would
271
+ // outlive the experiment.
272
+ step(function () {
273
+ var token = win._guardFrictionToken;
274
+ if (win.GuardFriction && token) win.GuardFriction.stop(token);
275
+ });
276
+ if (has(HONEYPOT_NAME)) {
277
+ step(function () {
278
+ if (win.GuardHoneypot) win.GuardHoneypot.attachToJsPsychData();
279
+ });
280
+ }
281
+ step(function () { if (!ctx.bootError) ctx.monitor.destroy(); }); // failDeferred destroyed it
282
+
283
+ if (problems.length) {
284
+ console.error(MESSAGES.sessionEndFailed(problems.join('; ')));
285
+ marker = marker ? marker + '; ' + problems.join('; ') : problems.join('; ');
286
+ }
287
+ if (marker) {
288
+ try { jsPsych.data.addProperties({ cyborgHunterError: marker }); } catch (_) { /* logged above */ }
289
+ }
290
+ return finalizeReplay(ctx, jsPsych);
291
+ }
292
+
293
+ var FINALIZE_TIMEOUT_MS = 15000;
294
+
295
+ // The replay recorder's own save (DataPipe, CyborgHunterConfig.replay
296
+ // .autoSave), before the researcher's on_finish saves the data, so the rows
297
+ // carry its integrityReplayMeta (the order manual mode documents). With the
298
+ // default mode 'none' nothing happens here: the researcher's save code calls
299
+ // CyborgHunter.replay(). Only the proxy ch.js listed is finalized; a
300
+ // researcher's own replay entry is theirs to finalize. → a promise or null.
301
+ function finalizeReplay(ctx, jsPsych) {
302
+ var r = ctx.config.replay;
303
+ if (!r || !r.autoSave || !r.autoSave.mode || r.autoSave.mode === 'none' || !ctx.replayProxy) return null;
304
+ try {
305
+ var ext = jsPsych.extensions && jsPsych.extensions[REPLAY_NAME];
306
+ if (!(ext instanceof ctx.replayProxy)) return null;
307
+ // finalize() never throws (extension-cyborg-hunter-replay.js); the catch is for the host's sake.
308
+ // Bounded: a save that never settles must not hold back the researcher's
309
+ // own save and redirect.
310
+ var timer = null;
311
+ var timeout = new Promise(function (resolve) {
312
+ timer = setTimeout(function () {
313
+ console.warn(MESSAGES.replayFinalizeTimedOut());
314
+ resolve();
315
+ }, ctx.replayFinalizeTimeoutMs || FINALIZE_TIMEOUT_MS);
316
+ });
317
+ var done = Promise.resolve(ext.finalize()).catch(function (e) {
318
+ console.error(MESSAGES.sessionEndFailed('replay: ' + message(e)));
319
+ });
320
+ return Promise.race([done, timeout]).then(function () { clearTimeout(timer); });
321
+ } catch (e) {
322
+ console.error(MESSAGES.sessionEndFailed('replay: ' + message(e)));
323
+ return null;
324
+ }
325
+ }
326
+
327
+ // installJsPsychAdapter({ win, ctx }) → { restore() }
328
+ // ctx: boot's context; gains ctx.jspsych = { invoked, instrumented,
329
+ // entryTrialFound, segmentsWritten, finalized }, ctx.jsPsych (the instance)
330
+ // and, in manual mode, ctx.host = 'manual'.
331
+ export function installJsPsychAdapter(opts) {
332
+ var win = opts.win, ctx = opts.ctx;
333
+ var orig = win.initJsPsych;
334
+ ctx.jspsych = { invoked: false, instrumented: 0, entryTrialFound: false, segmentsWritten: 0, finalized: false };
335
+
336
+ win.initJsPsych = function (options) {
337
+ if (ctx.jspsych.invoked) console.warn(MESSAGES.secondJsPsychInstance());
338
+ ctx.jspsych.invoked = true;
339
+ options = options || {};
340
+ var manual = false;
341
+ var ours = null;
342
+ var frictionEntry = null;
343
+ try {
344
+ if (detectManualMode(options)) {
345
+ manual = true;
346
+ handOver(ctx);
347
+ } else {
348
+ ours = [{ type: OneLinerExtension, params: {} }];
349
+ if (ctx.config.guards.honeypot && win.jsPsychGuardHoneypot) {
350
+ ours.push({ type: win.jsPsychGuardHoneypot, params: {} });
351
+ }
352
+ if (ctx.config.guards.friction && win.jsPsychGuardFriction) {
353
+ // Observe-only unless the timeline has the entry trial (decided in
354
+ // run() below, before jsPsych reads the params in loadExtensions).
355
+ frictionEntry = { type: win.jsPsychGuardFriction, params: { observeOnly: true } };
356
+ ours.push(frictionEntry);
357
+ }
358
+ if (ctx.config.replay && ctx.replayProxy) ours.push({ type: ctx.replayProxy, params: ctx.config.replay });
359
+ options.extensions = dedupeExtensions((options.extensions || []).concat(ours));
360
+ }
361
+ } catch (e) {
362
+ console.error(MESSAGES.hookFailed(message(e)));
363
+ // The inert entry the researcher's jsPsychCyborgHunter trials need
364
+ // (OneLinerExtension.ctx is not wired on this path).
365
+ ensureChEntry(options);
366
+ return orig(options);
367
+ }
368
+ if (manual) return orig(options);
369
+
370
+ // The entries jsPsych will instantiate, one per name (a researcher's own
371
+ // copy of a guard extension wins over ours, first entry per name).
372
+ var listed = options.extensions;
373
+ var ourNames = ours.map(nameOf);
374
+ var injected = listed.filter(function (e) { return ourNames.indexOf(nameOf(e)) !== -1; });
375
+ function has(name) { return injected.some(function (e) { return nameOf(e) === name; }); }
376
+ // Friction listed at all, ours or the researcher's own entry: either one
377
+ // sets friction up for the entry trial.
378
+ var frictionListed = listed.some(function (e) { return nameOf(e) === FRICTION_NAME; });
379
+
380
+ // jsPsych calls on_finish as this.opts.on_finish(...) (:2969), so `this`
381
+ // is the options object: the instance is captured below, per call.
382
+ var jsPsych = null;
383
+ var userFinish = options.on_finish;
384
+ options.on_finish = function () {
385
+ var self = this, args = arguments;
386
+ var pending = runFinalHook(ctx, win, has, jsPsych);
387
+ function researcherFinish() {
388
+ return typeof userFinish === 'function' ? userFinish.apply(self, args) : undefined;
389
+ }
390
+ return pending ? pending.then(researcherFinish) : researcherFinish();
391
+ };
392
+
393
+ jsPsych = orig(options);
394
+ try {
395
+ OneLinerExtension.ctx = ctx;
396
+ ctx.jsPsych = jsPsych;
397
+ jsPsych.data.addProperties({ participantId: ctx.participantId, cyborgHunterVersion: VERSION });
398
+ var origRun = jsPsych.run;
399
+ jsPsych.run = function (timeline) {
400
+ try {
401
+ var r = injectExtensions(timeline, injected);
402
+ ctx.jspsych.instrumented = r.trials;
403
+ ctx.jspsych.entryTrialFound = r.entryTrialFound;
404
+ // Shared with the initJsPsych list, which loadExtensions reads
405
+ // after this.
406
+ if (frictionEntry) frictionEntry.params.observeOnly = !r.entryTrialFound;
407
+ if (r.entryTrialFound && !frictionListed) console.warn(MESSAGES.frictionEntryWithoutFriction());
408
+ } catch (e) {
409
+ console.error(MESSAGES.instrumentFailed(message(e)));
410
+ }
411
+ // The page's one summary, even when the walk failed (counts what it got).
412
+ try { if (ctx.debug && ctx.debug.update) ctx.debug.update(); } catch (_) { /* a debug aid */ }
413
+ return origRun.apply(this, arguments);
414
+ };
415
+ } catch (e) {
416
+ console.error(MESSAGES.hookFailed(message(e)));
417
+ }
418
+ return jsPsych;
419
+ };
420
+
421
+ return { restore: function () { win.initJsPsych = orig; } };
422
+ }
423
+
424
+ // Placement diagnosis, registered by boot on every host.
425
+ // jsPsych host: if jsPsych starts running without initJsPsych having gone
426
+ // through the wrapper, ch.js was loaded after the experiment code called
427
+ // it. jsPsych marks <html jspsych="present"> only once run() is past the
428
+ // window load event and extension loading (:2693-2696), so the check
429
+ // watches for that attribute rather than DOMContentLoaded. Then the
430
+ // wrapper is removed, ctx.host becomes 'vanilla' and onVanilla() (from
431
+ // boot) starts the guards and the vanilla adapter.
432
+ // vanilla host: if initJsPsych appears by DOMContentLoaded, ch.js was
433
+ // loaded above jspsych.js. Otherwise, if jsPsych starts running anyway
434
+ // (the same attribute), the page built jsPsych from a bundler or ES
435
+ // module, which never defines window.initJsPsych: notHookable, once.
436
+ // ctx.host stays 'vanilla' either way.
437
+ export function watchHostPlacement(opts) {
438
+ var win = opts.win, doc = opts.doc, ctx = opts.ctx, adapter = opts.adapter;
439
+ var onVanilla = opts.onVanilla;
440
+ if (!doc || !doc.documentElement) return;
441
+ if (ctx.host === 'jspsych') {
442
+ var root = doc.documentElement;
443
+ var notHookable = function () {
444
+ // bootError: the deferred session start failed and removed the wrap.
445
+ if (ctx.bootError || (ctx.jspsych && ctx.jspsych.invoked)) return;
446
+ console.error(MESSAGES.notHookable());
447
+ ctx.host = 'vanilla';
448
+ if (adapter) adapter.restore();
449
+ if (onVanilla) onVanilla();
450
+ };
451
+ if (root.hasAttribute('jspsych')) { notHookable(); return; }
452
+ if (typeof win.MutationObserver !== 'function') return;
453
+ var mo = new win.MutationObserver(function () {
454
+ if (!root.hasAttribute('jspsych')) return;
455
+ mo.disconnect();
456
+ notHookable();
457
+ });
458
+ mo.observe(root, { attributes: true, attributeFilter: ['jspsych'] });
459
+ } else {
460
+ var reported = false;
461
+ if (doc.readyState === 'loading') {
462
+ doc.addEventListener('DOMContentLoaded', function () {
463
+ if (reported || typeof win.initJsPsych !== 'function') return;
464
+ reported = true;
465
+ console.error(MESSAGES.loadedAboveJsPsych());
466
+ }, { once: true });
467
+ }
468
+ var vroot = doc.documentElement;
469
+ var bundled = function () {
470
+ if (reported || ctx.bootError || ctx.host === 'manual') return;
471
+ reported = true;
472
+ console.error(MESSAGES.notHookable());
473
+ };
474
+ if (vroot.hasAttribute('jspsych')) { bundled(); return; }
475
+ if (typeof win.MutationObserver !== 'function') return;
476
+ var vmo = new win.MutationObserver(function () {
477
+ if (!vroot.hasAttribute('jspsych')) return;
478
+ vmo.disconnect();
479
+ bundled();
480
+ });
481
+ vmo.observe(vroot, { attributes: true, attributeFilter: ['jspsych'] });
482
+ }
483
+ }