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.
- package/CHANGELOG.md +98 -0
- package/CITATION.cff +1 -1
- package/README.md +33 -32
- package/dist/ch.js +41 -0
- package/dist/cyborg-hunter-replay.js +3 -3
- package/dist/cyborg-hunter.esm.js +1 -1
- package/dist/cyborg-hunter.min.js +4 -3
- package/dist/extension-cyborg-hunter.js +1 -1
- package/dist/extension-guard-friction.js +5 -5
- package/dist/extension-guard-honeypot.js +1 -1
- package/package.json +11 -5
- package/src/cli/extract-core.js +70 -4
- package/src/cli/renderers/replay-client-source.js +2 -2
- package/src/cli/segment-reassembly.js +204 -0
- package/src/jspsych/extension-cyborg-hunter-replay.js +1 -1
- package/src/jspsych/extension-cyborg-hunter.js +1 -1
- package/src/jspsych/extension-guard-friction.js +21 -7
- package/src/jspsych/extension-guard-honeypot.js +13 -6
- package/src/oneliner/adapters/jspsych-extension.js +196 -0
- package/src/oneliner/adapters/jspsych.js +483 -0
- package/src/oneliner/adapters/vanilla.js +420 -0
- package/src/oneliner/api.js +151 -0
- package/src/oneliner/boot.js +270 -0
- package/src/oneliner/config.js +122 -0
- package/src/oneliner/debug.js +137 -0
- package/src/oneliner/entry.js +8 -0
- package/src/oneliner/errors.js +219 -0
- package/src/oneliner/guards.js +67 -0
- package/src/oneliner/participant-id.js +49 -0
- package/src/oneliner/replay-loader.js +296 -0
- package/src/oneliner/segment-diff.js +68 -0
- package/src/oneliner/segmenter.js +186 -0
- package/src/replay/dom-instantiate.js +10 -1
- package/src/replay/index.js +4 -2
- package/src/replay/recorder.js +5 -1
- package/src/shared/constants.js +1 -1
- 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
|
+
}
|