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,219 @@
1
+ // src/oneliner/errors.js
2
+ // The one-line setup's loud errors. Every message names the problem, its
3
+ // cause, the fix and a doc link, in that order, so a researcher reading the
4
+ // console knows what to change without opening the source:
5
+ // [cyborg-hunter] <problem>: <cause>. Fix: <fix>. <link>
6
+ // build.js imports MESSAGES for the cyborg-hunter.min.js double-load footer,
7
+ // so the bundle and the catalogue share one string. The guard cores are plain
8
+ // IIFEs that cannot import this file; their double-load messages are written
9
+ // out in the same format (tests/oneliner/errors.test.js checks all of them).
10
+
11
+ export const DOCS = 'https://github.com/cyborg-hunter/cyborg-hunter/blob/main/docs/';
12
+
13
+ export function formatError(problem, cause, fix, link) {
14
+ return '[cyborg-hunter] ' + problem + ': ' + cause + '. Fix: ' + fix + '. ' + link;
15
+ }
16
+
17
+ // For one-off messages; the catalogue entries below are passed to
18
+ // console.error directly (they are already formatted).
19
+ export function loudError(problem, cause, fix, link) {
20
+ console.error(formatError(problem, cause, fix, link));
21
+ }
22
+
23
+ var REPORT_FIX = 'open an issue with this message and your <script> tag';
24
+
25
+ // The data-debug summary's part for MESSAGES.replaySaveReminder (debug.js).
26
+ export const REPLAY_SAVE_REMINDER = 'data-replay is on: save CyborgHunter.replay() in your save code';
27
+
28
+ export const MESSAGES = {
29
+ doubleLoad: function (first, second) {
30
+ return formatError('Not starting a second monitor', second + ' was loaded after ' + first,
31
+ 'load only one of ch.js and cyborg-hunter.min.js (the one-liner already contains the monitor)',
32
+ DOCS + 'advanced-integration.md#double-load');
33
+ },
34
+ // min.js's own sentinel found by a second copy of min.js: nothing to say
35
+ // about ch.js, and no load order to claim.
36
+ coreLoadedTwice: function () {
37
+ return formatError('cyborg-hunter.min.js is loaded twice',
38
+ 'two <script> tags on this page load the monitor bundle',
39
+ 'keep one <script> tag',
40
+ DOCS + 'advanced-integration.md#double-load');
41
+ },
42
+ notHookable: function () {
43
+ return formatError('Not monitoring jsPsych trials',
44
+ 'ch.js loaded after initJsPsych() ran, or the page calls jsPsychModule.initJsPsych / new JsPsych directly (a bundler or ES module build), which never goes through window.initJsPsych',
45
+ 'move the ch.js <script> above your experiment code (and below jspsych.js); for a bundled jsPsych, use manual mode (cyborg-hunter.min.js)',
46
+ DOCS + 'quickstart.md#placement');
47
+ },
48
+ loadedAboveJsPsych: function () {
49
+ return formatError('Not monitoring jsPsych trials',
50
+ 'ch.js was loaded before jspsych.js, so initJsPsych could not be wrapped',
51
+ 'move the ch.js <script> below jspsych.js and above your experiment code',
52
+ DOCS + 'quickstart.md#placement');
53
+ },
54
+ // The URL parameter names are not spelled out here: the caller supplies the
55
+ // list (participant-id.js), and the docs anchor documents it.
56
+ randomId: function (id) {
57
+ return formatError('Participant rows cannot be linked to your platform ID',
58
+ 'no PROLIFIC-style URL parameter, data-participant-id or CyborgHunterConfig.participantId was found; using ' + id,
59
+ 'add data-participant-id="..." to the ch.js tag or pass the ID in the URL',
60
+ DOCS + 'quickstart.md#participant-id');
61
+ },
62
+ manualInitOnOneLiner: function () {
63
+ return formatError('CyborgHunter.init() called while the one-liner is running',
64
+ 'ch.js already created the monitor at page load',
65
+ 'remove the init()/startTrial()/endTrial() code, or switch to cyborg-hunter.min.js for manual mode',
66
+ DOCS + 'advanced-integration.md#manual-mode');
67
+ },
68
+ // console.warn, not error: the monitor still runs, on the standard preset.
69
+ unknownPreset: function (value) {
70
+ return formatError('Unknown preset "' + value + '"',
71
+ 'data-preset / CyborgHunterConfig.preset must be permissive, standard or strict; using standard',
72
+ 'use permissive, standard or strict',
73
+ DOCS + 'quickstart.md#configuration');
74
+ },
75
+ bootFailed: function (msg) {
76
+ return formatError('Cyborg Hunter did not start', msg, REPORT_FIX, DOCS + 'known-issues.md#one-line-setup');
77
+ },
78
+ // The jsPsych host: wrapping initJsPsych, walking the timeline at run(),
79
+ // and the end-of-session hook. jsPsych keeps running after each of them.
80
+ hookFailed: function (msg) {
81
+ return formatError('Cyborg Hunter could not hook initJsPsych', msg, REPORT_FIX, DOCS + 'known-issues.md#one-line-setup');
82
+ },
83
+ instrumentFailed: function (msg) {
84
+ return formatError('Cyborg Hunter could not instrument the timeline', msg, REPORT_FIX, DOCS + 'known-issues.md#one-line-setup');
85
+ },
86
+ sessionEndFailed: function (msg) {
87
+ return formatError('Cyborg Hunter could not write the end-of-session data', msg, REPORT_FIX, DOCS + 'known-issues.md#one-line-setup');
88
+ },
89
+ guardFailed: function (guard, msg) {
90
+ return formatError('The ' + guard + ' guard is not running', msg, REPORT_FIX, DOCS + 'known-issues.md#one-line-setup');
91
+ },
92
+ // console.warn: the experiment runs; the entry trial still starts friction,
93
+ // but without the friction extension it has no jsPsych instance and no
94
+ // refusal notices.
95
+ frictionEntryWithoutFriction: function () {
96
+ return formatError('Friction is only partly set up',
97
+ 'the timeline has friction\'s entry trial but data-guards does not enable friction',
98
+ 'add friction to data-guards (for example data-guards="honeypot,friction"), or remove the entry trial',
99
+ DOCS + 'known-issues.md#one-line-setup');
100
+ },
101
+ // Vanilla host: a mark click, a form submit or pagehide. The page carries on;
102
+ // the segment may be missing from the blob.
103
+ vanillaEventFailed: function (msg) {
104
+ return formatError('Cyborg Hunter could not record a page boundary', msg, REPORT_FIX, DOCS + 'known-issues.md#one-line-setup');
105
+ },
106
+ // console.warn, vanilla host: CyborgHunter.startFriction() or a
107
+ // data-ch-friction-start click still starts enforcement (as the jsPsych
108
+ // entry trial does), but nothing injected the refusal notices.
109
+ frictionStartWithoutFriction: function () {
110
+ return formatError('Friction is only partly set up',
111
+ 'friction was started (data-ch-friction-start or CyborgHunter.startFriction()) but data-guards does not enable friction',
112
+ 'add friction to data-guards (for example data-guards="honeypot,friction"), or remove the friction start',
113
+ DOCS + 'known-issues.md#one-line-setup');
114
+ },
115
+ // console.warn, once per page, vanilla host: the whole session is kept in
116
+ // sessionStorage so the last page's form carries it; browsers cap it at
117
+ // about 5 MB per origin.
118
+ storageNearlyFull: function () {
119
+ return formatError('The saved session is approaching the sessionStorage limit',
120
+ 'the session kept for the next page is over 4 MB, mostly the raw mouse trace',
121
+ 'set CyborgHunterConfig.collectForPostHoc.rawMouseTrack = false',
122
+ DOCS + 'known-issues.md#one-line-setup');
123
+ },
124
+ // console.error, vanilla host: the session could not be kept for the next
125
+ // page (storage blocked or full). This page's form and data() still carry
126
+ // everything recorded so far.
127
+ storageFailed: function (msg) {
128
+ return formatError('The session could not be carried to the next page', msg,
129
+ 'allow site storage for the study page, or save CyborgHunter.data() on every page',
130
+ DOCS + 'known-issues.md#one-line-setup');
131
+ },
132
+ // Session replay (data-replay): cyborg-hunter-replay.js is loaded lazily
133
+ // from next to ch.js (or data-replay-src). console.error; the experiment
134
+ // runs on without replay.
135
+ replayUnavailable: function (msg) {
136
+ return formatError('Session replay is not recording', msg,
137
+ 'put cyborg-hunter-replay.js next to ch.js or point data-replay-src at it, and allow its URL in the page\'s Content-Security-Policy',
138
+ DOCS + 'known-issues.md#one-line-setup');
139
+ },
140
+ // console.warn, from CyborgHunter.replay(), which then returns null.
141
+ replayOff: function () {
142
+ return formatError('CyborgHunter.replay() has no recording',
143
+ 'session replay is off (the ch.js tag has no data-replay)',
144
+ 'add data-replay to the ch.js tag',
145
+ DOCS + 'advanced-integration.md#replay-with-the-one-liner');
146
+ },
147
+ replayNotReady: function () {
148
+ return formatError('CyborgHunter.replay() has no recording',
149
+ 'the replay recorder has not started yet, or cyborg-hunter-replay.js failed to load (see the error above)',
150
+ 'call CyborgHunter.replay() when the session ends (your on_finish or save code)',
151
+ DOCS + 'advanced-integration.md#replay-with-the-one-liner');
152
+ },
153
+ // console.warn, from CyborgHunter.replay(): the autoSave finalize() ran but
154
+ // left no recording (its save failed, or it timed out); replay() returns null.
155
+ replayFinalizeFailed: function () {
156
+ return formatError('CyborgHunter.replay() has no recording',
157
+ 'the recorder\'s autoSave finalize ended without a recording (its save failed; see the error above)',
158
+ 'check the console for the save error above, or set autoSave.mode to \'none\' and save the recording CyborgHunter.replay() returns yourself',
159
+ DOCS + 'advanced-integration.md#replay-with-the-one-liner');
160
+ },
161
+ // console.warn at boot, vanilla host: CyborgHunterConfig.replay.autoSave
162
+ // has no effect there (no session-end hook to run the recorder's save from).
163
+ replayAutoSaveVanilla: function () {
164
+ return formatError('CyborgHunterConfig.replay.autoSave is ignored',
165
+ 'autoSave is jsPsych-only under the one-liner, and this page has no jsPsych',
166
+ 'save the recording yourself: call CyborgHunter.replay() in your submit or save code and send what it returns (autoSave works only with jsPsych)',
167
+ DOCS + 'advanced-integration.md#replay-with-the-one-liner');
168
+ },
169
+ // console.warn, jsPsych host: the autoSave finalize() did not settle in time;
170
+ // the researcher's on_finish runs anyway.
171
+ replayFinalizeTimedOut: function () {
172
+ return formatError('The replay recorder\'s autoSave did not finish',
173
+ 'finalize() had not settled after 15 s, so the session ends without waiting for it',
174
+ 'check that the autoSave target (for example DataPipe) is reachable, or save the recording yourself with CyborgHunter.replay()',
175
+ DOCS + 'advanced-integration.md#replay-with-the-one-liner');
176
+ },
177
+ // console.warn: ch.js keeps one session per page. The session ends when the
178
+ // first instance finishes; rows recorded after that carry no integrity data.
179
+ secondJsPsychInstance: function () {
180
+ return formatError('A second jsPsych instance was created',
181
+ 'ch.js records one session per page and ends it when the first instance finishes, so trials run after that are not monitored',
182
+ 'create one jsPsych instance with initJsPsych() and run one timeline, or use cyborg-hunter.min.js and the jsPsych extension (manual mode) for several instances',
183
+ DOCS + 'known-issues.md#one-line-setup');
184
+ },
185
+ // console.info, once at boot, with data-replay and no data-debug (with
186
+ // data-debug the summary carries REPLAY_SAVE_REMINDER instead; debug.js).
187
+ // It replaces the recorder's own autoSave warning, which the one-liner
188
+ // silences (replay-loader.js recorderConfig).
189
+ replaySaveReminder: function () {
190
+ return formatError('data-replay is on',
191
+ 'ch.js records the session but does not save the recording',
192
+ 'save CyborgHunter.replay() in your save code',
193
+ DOCS + 'advanced-integration.md#replay-with-the-one-liner');
194
+ },
195
+ // console.warn, once, from the inert window.CyborgHunter that boot leaves
196
+ // when ch.js failed (api.js buildInertApi): the call did nothing.
197
+ notRunning: function () {
198
+ return formatError('Cyborg Hunter is not running on this page',
199
+ 'ch.js did not start (see the error above), so CyborgHunter calls do nothing',
200
+ 'fix the error logged above; until then the experiment runs without monitoring',
201
+ DOCS + 'known-issues.md#one-line-setup');
202
+ },
203
+ // console.warn, once: a half-migrated manual page still calls the manual
204
+ // extension's finalize() from its on_finish.
205
+ finalizeNotNeeded: function () {
206
+ return formatError('finalize() is not needed with ch.js',
207
+ 'ch.js ends the session itself when the jsPsych timeline finishes, so this call does nothing',
208
+ 'remove the finalize() call from your on_finish (keep your own save code)',
209
+ DOCS + 'advanced-integration.md#switching-to-the-one-liner');
210
+ },
211
+ // console.warn, once: the manual docs' initJsPsych entry still carries
212
+ // participantId / preset params, which the one-liner does not read.
213
+ extensionParamsIgnored: function () {
214
+ return formatError('participantId and preset in the cyborg-hunter extension params are ignored by ch.js',
215
+ 'ch.js reads the participant ID and the preset from its own <script> tag',
216
+ 'use data-participant-id / data-preset on the ch.js tag instead',
217
+ DOCS + 'advanced-integration.md#switching-to-the-one-liner');
218
+ }
219
+ };
@@ -0,0 +1,67 @@
1
+ // src/oneliner/guards.js
2
+ // Starts the guard cores that ch.js bundles (window.GuardHoneypot,
3
+ // window.GuardFriction) without jsPsych: the honeypot injects its bait DOM and
4
+ // subscribes to friction's violations; friction injects its AI refusal
5
+ // notices and runs observe-only (it logs violations, shows no curtain) until
6
+ // a host mark starts enforcement. Called once per page: by boot on the
7
+ // vanilla host, or when a jsPsych page falls back to vanilla.
8
+ //
9
+ // startGuards({ win, doc, guards: { honeypot, friction }, debug })
10
+ //
11
+ // Order matters, as in extension-guard-friction.js initialize(): friction's
12
+ // start() emits its first violation synchronously, so it is deferred to a
13
+ // microtask that runs after the honeypot has subscribed. The honeypot needs
14
+ // document.body for its bait elements; when ch.js runs in <head> both guards
15
+ // wait for DOMContentLoaded, keeping the same order.
16
+ //
17
+ // A missing core (only possible outside the ch.js bundle) is skipped. A core
18
+ // that throws is reported loudly and does not stop the other one.
19
+ //
20
+ // A guard already running is not started again. That happens on a jsPsych
21
+ // page ch.js could not hook whose researcher listed the guard extensions
22
+ // themselves: their initialize() ran before the fallback. A second
23
+ // GuardHoneypot.init would reset its violation log, a second
24
+ // injectRefusalNotices adds another refresh interval. The signs are the DOM
25
+ // each one leaves (the honeypot's #fg-honeypot bait, friction's
26
+ // #ai-research-notice) and friction's token.
27
+
28
+ import { MESSAGES } from './errors.js';
29
+
30
+ function attempt(name, fn) {
31
+ try { fn(); } catch (e) { console.error(MESSAGES.guardFailed(name, String((e && e.message) || e))); }
32
+ }
33
+
34
+ export function startGuards(opts) {
35
+ var win = opts.win, doc = opts.doc, guards = opts.guards;
36
+ var debug = !!opts.debug;
37
+
38
+ function present(id) {
39
+ return typeof doc.getElementById === 'function' && !!doc.getElementById(id);
40
+ }
41
+
42
+ function run() {
43
+ if (guards.honeypot && win.GuardHoneypot && !present('fg-honeypot')) {
44
+ attempt('honeypot', function () {
45
+ win.GuardHoneypot.init({ jsPsych: null, friction: win.GuardFriction, debug: debug });
46
+ });
47
+ }
48
+ if (guards.friction && win.GuardFriction && !win._guardFrictionToken && !present('ai-research-notice')) {
49
+ // Once per page, as the friction extension's initialize() does (the
50
+ // notices are not idempotent: each call adds another refresh interval).
51
+ attempt('friction', function () { win.GuardFriction.injectRefusalNotices(); });
52
+ Promise.resolve().then(function () {
53
+ attempt('friction', function () {
54
+ var token = win.GuardFriction.start({ jsPsych: null, observeOnly: true, debug: debug });
55
+ // Same non-enumerable slot the friction extension uses, so whoever
56
+ // finalizes the session can stop() with the right token.
57
+ Object.defineProperty(win, '_guardFrictionToken', {
58
+ value: token, writable: false, enumerable: false, configurable: true
59
+ });
60
+ });
61
+ });
62
+ }
63
+ }
64
+
65
+ if (doc.body) run();
66
+ else doc.addEventListener('DOMContentLoaded', run, { once: true });
67
+ }
@@ -0,0 +1,49 @@
1
+ // src/oneliner/participant-id.js
2
+ // Which participant the session belongs to, in order of precedence:
3
+ // 1. a URL parameter, the first name in `params` present in the query
4
+ // string (recruitment platforms append the worker's ID to the study URL);
5
+ // 2. the ch.js tag's data-participant-id attribute;
6
+ // 3. window.CyborgHunterConfig.participantId;
7
+ // 4. a random id, which boot.js warns about: those rows cannot be linked
8
+ // back to the platform's records.
9
+ // Values are trimmed; an empty or blank value counts as missing.
10
+ //
11
+ // resolveParticipantId({ search, attr, configId, params, random }) → { id, source }
12
+ // search: location.search ('?a=1&b=2')
13
+ // params: ordered URL parameter names; the caller supplies the list
14
+ // random: () => string, called only when nothing else is found
15
+ // source: 'url:<param>' | 'attribute' | 'config' | 'random'
16
+ // (boot.js adds 'session': a random id kept from an earlier page)
17
+
18
+ // The URL parameter names boot.js reads by default, in order of precedence:
19
+ // Prolific's and MTurk's documented study-URL parameters, then a generic name.
20
+ export const DEFAULT_PARAMS = ['PROLIFIC_PID', 'workerId', 'participant'];
21
+
22
+ function clean(v) {
23
+ if (v === null || v === undefined) return null;
24
+ var s = String(v).trim();
25
+ return s === '' ? null : s;
26
+ }
27
+
28
+ export function resolveParticipantId(opts) {
29
+ var query = new URLSearchParams(opts.search || '');
30
+ var params = opts.params || [];
31
+ for (var i = 0; i < params.length; i++) {
32
+ var fromUrl = clean(query.get(params[i]));
33
+ if (fromUrl) return { id: fromUrl, source: 'url:' + params[i] };
34
+ }
35
+ var attr = clean(opts.attr);
36
+ if (attr) return { id: attr, source: 'attribute' };
37
+ var configId = clean(opts.configId);
38
+ if (configId) return { id: configId, source: 'config' };
39
+ return { id: opts.random(), source: 'random' };
40
+ }
41
+
42
+ // 'ch-' + 12 lowercase hex characters (48 random bits) from the given
43
+ // Web Crypto object (window.crypto in the browser).
44
+ export function randomParticipantId(crypto) {
45
+ var bytes = crypto.getRandomValues(new Uint8Array(6));
46
+ var hex = '';
47
+ for (var i = 0; i < bytes.length; i++) hex += (bytes[i] < 16 ? '0' : '') + bytes[i].toString(16);
48
+ return 'ch-' + hex;
49
+ }
@@ -0,0 +1,296 @@
1
+ // src/oneliner/replay-loader.js
2
+ // Session replay under the one-line setup, opt-in with data-replay. ch.js does
3
+ // not bundle the recorder: cyborg-hunter-replay.js (self-contained, the same
4
+ // file manual mode loads) is fetched only when data-replay is set, from the
5
+ // directory ch.js itself was loaded from, or from data-replay-src.
6
+ //
7
+ // jsPsych initJsPsych runs synchronously in the experiment code, before
8
+ // the script can have loaded. ch.js therefore lists a proxy
9
+ // extension (adapters/jspsych.js) whose async initialize() loads
10
+ // the script and then hands over to the real replay extension:
11
+ // jsPsych's run() awaits loadExtensions, which awaits every
12
+ // initialize() (jspsych.js 7.3.1 :2695, :2946). The proxy keeps
13
+ // the name 'cyborg-hunter-replay', so the replay extension finds
14
+ // the monitor through jsPsych.extensions['cyborg-hunter'] as in
15
+ // manual mode.
16
+ // vanilla the standalone recorder (window.CyborgHunterReplay.attach)
17
+ // starts once the page has loaded (DOMContentLoaded) and follows
18
+ // the segmenter: adapters/vanilla.js ends its trial and starts the
19
+ // next span's at every cut, and stops it at pagehide. Recordings
20
+ // are per page.
21
+ //
22
+ // CyborgHunter.replay() stops the recorder, serializes it and returns the
23
+ // recording for the researcher's own save code (default autoSave mode
24
+ // 'none'; boot reminds the researcher to save it, replaySaveReminder, in
25
+ // place of the recorder's own warning). CyborgHunterConfig.replay = { tier, autoSave } keeps the
26
+ // recorder's own DataPipe save available (adapters/jspsych.js finalizes it at
27
+ // the end of the session).
28
+ //
29
+ // Nothing here throws into the page. A script that fails to load (404,
30
+ // network, Content-Security-Policy) or does not arrive within LOAD_TIMEOUT_MS
31
+ // is a catalogue error, and the experiment runs on without replay: jsPsych's
32
+ // run() is waiting on the proxy's initialize(), so a stalled CDN must not
33
+ // hold the experiment back indefinitely.
34
+
35
+ import { VERSION } from '../shared/constants.js';
36
+ import { MESSAGES } from './errors.js';
37
+
38
+ var REPLAY_FILE = 'cyborg-hunter-replay.js';
39
+ var REPLAY_NAME = 'cyborg-hunter-replay';
40
+ var LOAD_TIMEOUT_MS = 15000;
41
+
42
+ function message(e) { return String((e && e.message) || e); }
43
+
44
+ // The recorder's defaults under the one-liner; the researcher's
45
+ // CyborgHunterConfig.replay keys (tier, autoSave) override them.
46
+ // _ownerSavesRecording silences the recorder's "autoSave.mode is none"
47
+ // warning (src/replay/recorder.js), which names getRecording(): the
48
+ // one-liner's own reminder (replaySaveReminder below) names
49
+ // CyborgHunter.replay() instead.
50
+ function recorderConfig(ctx, params) {
51
+ return Object.assign({ participantId: ctx.participantId, autoSave: { mode: 'none' }, _ownerSavesRecording: true }, params);
52
+ }
53
+
54
+ // Whether the researcher must save CyborgHunter.replay() themselves: replay
55
+ // is on and has a script URL, and the recorder does not save itself
56
+ // (CyborgHunterConfig.replay.autoSave, which only the jsPsych host runs).
57
+ // Read by debug.js for the summary.
58
+ export function replaySaveReminderApplies(ctx) {
59
+ if (!ctx.config.replay || !ctx.replaySrc) return false;
60
+ var autoSave = ctx.config.replay.autoSave;
61
+ var selfSaving = !!(autoSave && autoSave.mode && autoSave.mode !== 'none');
62
+ return !(selfSaving && ctx.host !== 'vanilla');
63
+ }
64
+
65
+ // data-replay-src when given, else cyborg-hunter-replay.js next to ch.js.
66
+ // scriptSrc is document.currentScript.src (entry.js); it is null for an
67
+ // inline or bundled ch.js, which has no directory to look in.
68
+ export function replaySrcFor(scriptSrc, override) {
69
+ if (override) return override;
70
+ if (!scriptSrc) {
71
+ throw new Error('ch.js could not tell which URL it was loaded from (inline or bundled), so it cannot find ' +
72
+ REPLAY_FILE + '; set data-replay-src');
73
+ }
74
+ return new URL(REPLAY_FILE, scriptSrc).href;
75
+ }
76
+
77
+ // One <script> per src and document; every caller gets the same promise.
78
+ var loads = new WeakMap(); // doc → { src: promise }
79
+
80
+ export function loadScript(doc, src, timeoutMs, nonce) {
81
+ var perDoc = loads.get(doc);
82
+ if (!perDoc) { perDoc = Object.create(null); loads.set(doc, perDoc); }
83
+ if (perDoc[src]) return perDoc[src];
84
+ var p = new Promise(function (resolve, reject) {
85
+ var el = doc.createElement('script');
86
+ var timer = setTimeout(function () {
87
+ reject(new Error(src + ' timed out after ' + Math.round((timeoutMs || LOAD_TIMEOUT_MS) / 1000) + ' s'));
88
+ }, timeoutMs || LOAD_TIMEOUT_MS);
89
+ // Listeners before the append: a script can settle inside appendChild.
90
+ el.addEventListener('load', function () { clearTimeout(timer); resolve(); });
91
+ el.addEventListener('error', function () {
92
+ clearTimeout(timer);
93
+ reject(new Error('could not load ' + src + ' (missing file, network error or Content-Security-Policy)'));
94
+ });
95
+ // A page with a nonce-based Content-Security-Policy only runs scripts that carry its nonce.
96
+ if (nonce) el.nonce = nonce;
97
+ el.src = src;
98
+ (doc.head || doc.documentElement || doc.body).appendChild(el);
99
+ });
100
+ perDoc[src] = p;
101
+ return p;
102
+ }
103
+
104
+ // makeReplayProxy({ doc, src, ctx, timeoutMs? }) → class ReplayProxyExtension
105
+ export function makeReplayProxy(opts) {
106
+ var doc = opts.doc, src = opts.src, ctx = opts.ctx, timeoutMs = opts.timeoutMs;
107
+ var win = ctx.win;
108
+
109
+ class ReplayProxyExtension {
110
+ static info = { name: REPLAY_NAME, version: VERSION, data: {} };
111
+
112
+ constructor(jsPsych) {
113
+ this.jsPsych = jsPsych;
114
+ this.inner = null; // the real replay extension, once loaded and initialized
115
+ }
116
+
117
+ // Always resolves: a rejection here would stop jsPsych's run().
118
+ async initialize(params) {
119
+ var inner = null;
120
+ try {
121
+ // Already on the page (a <script> of the researcher's own): no load.
122
+ if (typeof win.jsPsychCyborgHunterReplay !== 'function') await loadScript(doc, src, timeoutMs, ctx.scriptNonce);
123
+ var Inner = win.jsPsychCyborgHunterReplay;
124
+ if (typeof Inner !== 'function') throw new Error(src + ' loaded but did not define jsPsychCyborgHunterReplay');
125
+ inner = new Inner(this.jsPsych);
126
+ inner.initialize(recorderConfig(ctx, params));
127
+ this.inner = inner;
128
+ } catch (e) {
129
+ // A recorder attached before the failure would keep its listeners.
130
+ if (inner && inner.api) { try { inner.api.destroy(); } catch (_) { /* already failing */ } }
131
+ console.error(MESSAGES.replayUnavailable(message(e)));
132
+ }
133
+ }
134
+
135
+ // jsPsych calls these on every trial that lists the extension: no-ops
136
+ // until the real extension is in place, and never a throw into jsPsych.
137
+ on_start(params) {
138
+ if (!this.inner) return;
139
+ try { this.inner.on_start(params); } catch (_) { /* replay only */ }
140
+ }
141
+
142
+ on_load(params) {
143
+ if (!this.inner) return;
144
+ try { this.inner.on_load(params); } catch (_) { /* replay only */ }
145
+ }
146
+
147
+ on_finish(params) {
148
+ if (!this.inner) return {};
149
+ try { return this.inner.on_finish(params) || {}; } catch (_) { return {}; }
150
+ }
151
+
152
+ finalize() {
153
+ return this.inner ? this.inner.finalize() : Promise.resolve();
154
+ }
155
+
156
+ getLastRecording() {
157
+ return this.inner ? this.inner.getLastRecording() : null;
158
+ }
159
+ }
160
+
161
+ return ReplayProxyExtension;
162
+ }
163
+
164
+ // createVanillaReplay({ win, doc, src, ctx, timeoutMs? })
165
+ // → Promise<{ api, startTrial(trialId), endTrial(), stop() }>
166
+ // The wrappers never throw (the recorder's lifecycle calls do, on a call out
167
+ // of order) and do nothing once stop() ran or replay() took the recording
168
+ // (handle.api is null then).
169
+ export function createVanillaReplay(opts) {
170
+ var win = opts.win, doc = opts.doc, src = opts.src, ctx = opts.ctx;
171
+ var ready = win.CyborgHunterReplay ? Promise.resolve() : loadScript(doc, src, opts.timeoutMs, ctx.scriptNonce);
172
+ return ready.then(function () {
173
+ var R = win.CyborgHunterReplay;
174
+ if (!R || typeof R.attach !== 'function') throw new Error(src + ' loaded but did not define CyborgHunterReplay');
175
+ var api = R.attach(recorderConfig(ctx, ctx.config.replay));
176
+ var handle = {
177
+ api: api,
178
+ stopped: false,
179
+ startTrial: function (trialId) {
180
+ if (!handle.api || handle.stopped) return;
181
+ try { handle.api.startTrial({ trialId: trialId }); } catch (_) { /* replay only */ }
182
+ },
183
+ endTrial: function () {
184
+ if (!handle.api || handle.stopped) return;
185
+ try { handle.api.endTrial(); } catch (_) { /* replay only */ }
186
+ },
187
+ stop: function () {
188
+ if (!handle.api || handle.stopped) return;
189
+ handle.stopped = true;
190
+ try { handle.api.stopSession('finished'); } catch (_) { /* replay only */ }
191
+ }
192
+ };
193
+ try {
194
+ api.startSession();
195
+ var state = ctx.segmenter.state();
196
+ if (state.open) api.startTrial({ trialId: state.currentTrialId });
197
+ } catch (e) {
198
+ try { api.destroy(); } catch (_) { /* already failing */ }
199
+ throw e;
200
+ }
201
+ return handle;
202
+ });
203
+ }
204
+
205
+ // Stop, serialize, destroy. The recorder refuses stopSession once stopped,
206
+ // which is fine: the recording is complete then.
207
+ function takeRecording(holder, opts) {
208
+ var api = holder.api;
209
+ try { api.stopSession('finished'); } catch (_) { /* already stopped */ }
210
+ try {
211
+ return api.getRecording(opts);
212
+ } finally {
213
+ try { api.destroy(); } catch (_) { /* teardown best-effort */ }
214
+ holder.api = null; // the jsPsych extension's later finalize() is a no-op
215
+ }
216
+ }
217
+
218
+ // The object holding the recorder handle (.api): the real jsPsych replay
219
+ // extension behind the proxy, or the vanilla handle.
220
+ function currentHolder(ctx) {
221
+ if (ctx.host === 'vanilla') return ctx.replay || null;
222
+ var ext = ctx.jsPsych && ctx.jsPsych.extensions && ctx.jsPsych.extensions[REPLAY_NAME];
223
+ if (!ext) return null;
224
+ return 'inner' in ext ? ext.inner : ext;
225
+ }
226
+
227
+ function replay(ctx) {
228
+ if (!ctx.config.replay) { console.warn(MESSAGES.replayOff()); return null; }
229
+ if (ctx.replayRecording) return ctx.replayRecording;
230
+ var holder = currentHolder(ctx);
231
+ if (holder && holder.api) {
232
+ var report = null;
233
+ try { report = ctx.monitor.getSessionReport(); } catch (_) { /* the recording stands on its own */ }
234
+ var host = null;
235
+ try { host = typeof holder._detectHost === 'function' ? holder._detectHost() : null; } catch (_) { /* optional */ }
236
+ ctx.replayRecording = takeRecording(holder, { chSessionReport: report, host: host });
237
+ return ctx.replayRecording;
238
+ }
239
+ // An autosaving finalize() (CyborgHunterConfig.replay.autoSave) took it.
240
+ var last = holder && typeof holder.getLastRecording === 'function' ? holder.getLastRecording() : null;
241
+ if (last) return last;
242
+ // The holder exists but its recorder is gone and saved nothing: an autosaving
243
+ // finalize() destroyed it and failed. Not "has not started yet".
244
+ console.warn(holder ? MESSAGES.replayFinalizeFailed() : MESSAGES.replayNotReady());
245
+ return null;
246
+ }
247
+
248
+ // installReplay({ win, ctx, doc?, timeoutMs? }) → { startVanilla() }
249
+ // Installs ctx.handlers.replay (CyborgHunter.replay()) whether or not replay
250
+ // is on. With data-replay: ctx.replaySrc, and on the jsPsych host
251
+ // ctx.replayProxy, which adapters/jspsych.js lists in initJsPsych.
252
+ // startVanilla() starts the vanilla recorder (vanilla host, or a jsPsych page
253
+ // ch.js could not hook), once, after DOMContentLoaded; it sets ctx.replay.
254
+ export function installReplay(opts) {
255
+ var win = opts.win, ctx = opts.ctx;
256
+ var doc = opts.doc || win.document;
257
+ ctx.handlers.replay = function () {
258
+ try { return replay(ctx); } catch (e) {
259
+ console.error(MESSAGES.replayUnavailable(message(e)));
260
+ return null;
261
+ }
262
+ };
263
+ var none = { startVanilla: function () {} };
264
+ if (!ctx.config.replay) return none;
265
+ try {
266
+ ctx.replaySrc = replaySrcFor(ctx.scriptSrc, ctx.config.replaySrc);
267
+ } catch (e) {
268
+ console.error(MESSAGES.replayUnavailable(message(e)));
269
+ return none;
270
+ }
271
+ var autoSave = ctx.config.replay.autoSave;
272
+ if (ctx.host === 'vanilla' && autoSave && autoSave.mode && autoSave.mode !== 'none') {
273
+ console.warn(MESSAGES.replayAutoSaveVanilla());
274
+ }
275
+ // With data-debug the summary says it (debug.js), so the page gets one line.
276
+ if (!ctx.debug && replaySaveReminderApplies(ctx)) console.info(MESSAGES.replaySaveReminder());
277
+ if (ctx.host === 'jspsych') {
278
+ ctx.replayProxy = makeReplayProxy({ doc: doc, src: ctx.replaySrc, ctx: ctx, timeoutMs: opts.timeoutMs });
279
+ }
280
+
281
+ var started = false;
282
+ function start() {
283
+ createVanillaReplay({ win: win, doc: doc, src: ctx.replaySrc, ctx: ctx, timeoutMs: opts.timeoutMs }).then(
284
+ function (handle) { ctx.replay = handle; },
285
+ function (e) { console.error(MESSAGES.replayUnavailable(message(e))); }
286
+ );
287
+ }
288
+ return {
289
+ startVanilla: function () {
290
+ if (started) return;
291
+ started = true;
292
+ if (doc.readyState === 'loading') doc.addEventListener('DOMContentLoaded', start, { once: true });
293
+ else start();
294
+ }
295
+ };
296
+ }