cyborg-hunter 0.9.2 → 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 +52 -0
- package/CITATION.cff +1 -1
- package/README.md +29 -29
- package/dist/ch.js +3 -3
- 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 +3 -2
- package/src/cli/extract-core.js +70 -4
- 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/recorder.js +5 -1
- package/src/shared/constants.js +1 -1
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
// src/oneliner/boot.js
|
|
2
|
+
// Everything dist/ch.js does at load, in order:
|
|
3
|
+
// 0. window.jsPsychCyborgHunter = OneLinerExtension unless a class is
|
|
4
|
+
// already there, before anything that can stop or fail: the documented
|
|
5
|
+
// per-trial { type: jsPsychCyborgHunter, params } entry then works with
|
|
6
|
+
// ch.js alone, and never becomes a ReferenceError in the researcher's
|
|
7
|
+
// code (extension-cyborg-hunter.js loaded first keeps its own class, and
|
|
8
|
+
// loaded later replaces this one: manual mode either way when initJsPsych
|
|
9
|
+
// lists it). With no context wired the class is inert;
|
|
10
|
+
// 1. double-load sentinel: if cyborg-hunter.min.js (or another ch.js)
|
|
11
|
+
// already ran, say so and stop: no monitor, window.CyborgHunter untouched.
|
|
12
|
+
// After min.js, initJsPsych gets the inert wrapper (below); after another
|
|
13
|
+
// ch.js it is left to that one;
|
|
14
|
+
// 2. config (tag data-* attributes over window.CyborgHunterConfig);
|
|
15
|
+
// 3. participant id (warns when it has to fall back to a random id). The
|
|
16
|
+
// id is kept for the tab in sessionStorage, so a later page without an
|
|
17
|
+
// id of its own reuses the first page's random id (source 'session')
|
|
18
|
+
// and continues its session; a URL, attribute or config id still wins;
|
|
19
|
+
// 4. a monitor (its session starts at step 6);
|
|
20
|
+
// 5. host: 'jspsych' when initJsPsych is already defined, else 'vanilla'
|
|
21
|
+
// (the host adapters install their hooks into ctx.handlers). The vanilla
|
|
22
|
+
// adapter (adapters/vanilla.js) is installed before the first span
|
|
23
|
+
// opens: it restores a previous page's segment index, so the boot span
|
|
24
|
+
// is named after the continued index;
|
|
25
|
+
// 6. the monitor's session starts and the segmenter keeps it inside a
|
|
26
|
+
// trial from this moment on ('span-<index>'), so a paste before the
|
|
27
|
+
// first host trial or mark is still recorded. The session start needs
|
|
28
|
+
// document.body (core signals/browser.js observes it), so with ch.js in
|
|
29
|
+
// <head> this step alone waits for DOMContentLoaded (a paste before
|
|
30
|
+
// then is not recorded); a failure then is logged as bootFailed;
|
|
31
|
+
// 7. guards, vanilla host only (honeypot on by default, friction
|
|
32
|
+
// observe-only when enabled). On the jsPsych host the injected guard
|
|
33
|
+
// extensions own them: their initialize() runs GuardHoneypot.init and
|
|
34
|
+
// friction's setJsPsych / injectRefusalNotices, and the entry trial
|
|
35
|
+
// starts enforcement. Starting them here too would init the honeypot
|
|
36
|
+
// twice (the second init resets its violation log). On the jsPsych host
|
|
37
|
+
// initJsPsych is wrapped (adapters/jspsych.js); on both, a placement
|
|
38
|
+
// check reports a ch.js tag above jspsych.js or below the experiment
|
|
39
|
+
// code, and a jsPsych page that turns out not hookable falls back to the
|
|
40
|
+
// vanilla host (its guards and adapter start then);
|
|
41
|
+
// 8. replay, only with data-replay (replay-loader.js): on the jsPsych
|
|
42
|
+
// host a proxy extension the initJsPsych wrap lists, which loads
|
|
43
|
+
// cyborg-hunter-replay.js in jsPsych's run(); on the vanilla host (and
|
|
44
|
+
// on the not-hookable fallback) the standalone recorder, started after
|
|
45
|
+
// DOMContentLoaded. CyborgHunter.replay() is wired either way;
|
|
46
|
+
// 9. window.CyborgHunter = the one-liner namespace; then the sentinel;
|
|
47
|
+
// 10. data-debug only (debug.js): the badge and the console summary, shown
|
|
48
|
+
// once now and again when the jsPsych timeline is walked.
|
|
49
|
+
//
|
|
50
|
+
// boot({ script, win, monitorFactory?, participantParams? }) → ctx | null
|
|
51
|
+
// script: the ch.js <script> element (document.currentScript), or null
|
|
52
|
+
// win: the window (the core monitor itself uses the globals)
|
|
53
|
+
// monitorFactory: core init(); injectable for tests
|
|
54
|
+
// participantParams: URL parameter names for the participant id, in order
|
|
55
|
+
// ctx = { config, participantId, participantIdSource, monitor, differ,
|
|
56
|
+
// segmenter, host, scriptSrc, handlers, win, api, vanilla?,
|
|
57
|
+
// replaySrc?, replayProxy? (jsPsych), replay? (vanilla handle),
|
|
58
|
+
// debug? (data-debug) }
|
|
59
|
+
//
|
|
60
|
+
// boot never throws into the page: any failure is logged as bootFailed, a
|
|
61
|
+
// monitor created before the failure is destroyed, and boot returns null.
|
|
62
|
+
// The sentinel is set only after a successful boot, so a later
|
|
63
|
+
// cyborg-hunter.min.js still works if ch.js failed. The exception is a
|
|
64
|
+
// failure at the deferred session start (step 6, ch.js in <head>): boot has
|
|
65
|
+
// returned by then, so the namespace and the sentinel stay, and what can
|
|
66
|
+
// still be saved is marked (failDeferred below).
|
|
67
|
+
//
|
|
68
|
+
// Whenever ch.js does not monitor (a failure, either kind, or the stand-down
|
|
69
|
+
// after min.js) the host page must still run as it would without ch.js:
|
|
70
|
+
// initJsPsych is left with only the inert wrapper (adapters/jspsych.js
|
|
71
|
+
// installInertWrapper), which lists OneLinerExtension when nothing named
|
|
72
|
+
// 'cyborg-hunter' is listed, so the researcher's jsPsychCyborgHunter trials
|
|
73
|
+
// find a registered instance; and a boot failure with no window.CyborgHunter
|
|
74
|
+
// yet leaves the inert namespace (api.js buildInertApi), so documented calls
|
|
75
|
+
// do not throw.
|
|
76
|
+
|
|
77
|
+
import { init } from '../core/monitor.js';
|
|
78
|
+
import { createSegmentDiffer } from './segment-diff.js';
|
|
79
|
+
import { createSegmenter } from './segmenter.js';
|
|
80
|
+
import { readConfig } from './config.js';
|
|
81
|
+
import { resolveParticipantId, randomParticipantId, DEFAULT_PARAMS } from './participant-id.js';
|
|
82
|
+
import { startGuards } from './guards.js';
|
|
83
|
+
import { buildPublicApi, buildInertApi } from './api.js';
|
|
84
|
+
import { MESSAGES } from './errors.js';
|
|
85
|
+
import { installJsPsychAdapter, installInertWrapper, watchHostPlacement } from './adapters/jspsych.js';
|
|
86
|
+
import { OneLinerExtension } from './adapters/jspsych-extension.js';
|
|
87
|
+
import { installVanillaAdapter } from './adapters/vanilla.js';
|
|
88
|
+
import { installReplay } from './replay-loader.js';
|
|
89
|
+
import { createDebug } from './debug.js';
|
|
90
|
+
|
|
91
|
+
// Not under the session prefix (adapters/vanilla.js, cyborg-hunter:oneliner:
|
|
92
|
+
// session:<id>), so no participant id can collide with it.
|
|
93
|
+
var PID_KEY = 'cyborg-hunter:oneliner:pid';
|
|
94
|
+
|
|
95
|
+
function sessionGet(win, key) {
|
|
96
|
+
try { return win.sessionStorage.getItem(key); } catch (_) { return null; }
|
|
97
|
+
}
|
|
98
|
+
function sessionSet(win, key, value) {
|
|
99
|
+
try { win.sessionStorage.setItem(key, value); } catch (_) { /* blocked: the id is per page then */ }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function boot(opts) {
|
|
103
|
+
var win = opts.win;
|
|
104
|
+
var monitorFactory = opts.monitorFactory || init;
|
|
105
|
+
var monitor = null;
|
|
106
|
+
var ctx = null;
|
|
107
|
+
var adapter = null; // the jsPsych adapter, once installed (step 7)
|
|
108
|
+
try {
|
|
109
|
+
// detectManualMode (adapters/jspsych.js) treats this class as the one-liner.
|
|
110
|
+
if (win.jsPsychCyborgHunter === undefined) win.jsPsychCyborgHunter = OneLinerExtension;
|
|
111
|
+
} catch (_) { /* a locked global: the researcher's own script tag still works */ }
|
|
112
|
+
try {
|
|
113
|
+
if (win.__cyborgHunterLoaded) {
|
|
114
|
+
console.error(MESSAGES.doubleLoad(win.__cyborgHunterLoaded, 'ch.js'));
|
|
115
|
+
// Another ch.js already wraps initJsPsych; a second wrapper would list
|
|
116
|
+
// this bundle's own class, which that ch.js takes for a manual-mode
|
|
117
|
+
// extension (detectManualMode compares classes).
|
|
118
|
+
if (win.__cyborgHunterLoaded !== 'ch.js') installInertWrapper(win);
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
var script = opts.script || null;
|
|
123
|
+
var config = readConfig({ dataset: (script && script.dataset) || {}, globalConfig: win.CyborgHunterConfig });
|
|
124
|
+
|
|
125
|
+
var pid = resolveParticipantId({
|
|
126
|
+
search: (win.location && win.location.search) || '',
|
|
127
|
+
attr: config.participantIdAttr,
|
|
128
|
+
configId: config.monitor.participantId,
|
|
129
|
+
params: opts.participantParams || DEFAULT_PARAMS,
|
|
130
|
+
random: function () { return randomParticipantId(win.crypto || globalThis.crypto); }
|
|
131
|
+
});
|
|
132
|
+
if (pid.source === 'random') {
|
|
133
|
+
var kept = sessionGet(win, PID_KEY);
|
|
134
|
+
if (kept) pid = { id: kept, source: 'session' };
|
|
135
|
+
else console.warn(MESSAGES.randomId(pid.id));
|
|
136
|
+
}
|
|
137
|
+
sessionSet(win, PID_KEY, pid.id);
|
|
138
|
+
|
|
139
|
+
monitor = monitorFactory(Object.assign({}, config.monitor, { participantId: pid.id, preset: config.preset }));
|
|
140
|
+
var differ = createSegmentDiffer(monitor);
|
|
141
|
+
var segmenter = createSegmenter({ monitor: monitor, differ: differ });
|
|
142
|
+
var host = typeof win.initJsPsych === 'function' ? 'jspsych' : 'vanilla';
|
|
143
|
+
|
|
144
|
+
ctx = {
|
|
145
|
+
config: config,
|
|
146
|
+
participantId: pid.id,
|
|
147
|
+
participantIdSource: pid.source,
|
|
148
|
+
monitor: monitor,
|
|
149
|
+
differ: differ,
|
|
150
|
+
segmenter: segmenter,
|
|
151
|
+
host: host,
|
|
152
|
+
scriptSrc: (script && script.src) || null,
|
|
153
|
+
scriptNonce: (script && script.nonce) || null, // copied onto the lazily loaded replay <script>
|
|
154
|
+
handlers: {},
|
|
155
|
+
win: win,
|
|
156
|
+
api: null
|
|
157
|
+
};
|
|
158
|
+
ctx.api = buildPublicApi(ctx);
|
|
159
|
+
// data-debug only: the badge, the console summary and the perf counters.
|
|
160
|
+
if (config.debug) {
|
|
161
|
+
ctx.debug = createDebug({ doc: win.document, ctx: ctx });
|
|
162
|
+
win.__cyborgHunterDebug = { stats: ctx.debug.stats };
|
|
163
|
+
}
|
|
164
|
+
var replay = installReplay({ win: win, ctx: ctx });
|
|
165
|
+
if (host === 'vanilla') ctx.vanilla = installVanillaAdapter({ win: win, ctx: ctx });
|
|
166
|
+
|
|
167
|
+
// The session start observes document.body (core signals/browser.js), so
|
|
168
|
+
// with ch.js in <head> it waits for DOMContentLoaded; everything else,
|
|
169
|
+
// including the initJsPsych wrap the experiment code may call before
|
|
170
|
+
// DOMContentLoaded, is in place at once.
|
|
171
|
+
if (win.document.body) startMonitoring(ctx);
|
|
172
|
+
else {
|
|
173
|
+
win.document.addEventListener('DOMContentLoaded', function () {
|
|
174
|
+
try { startMonitoring(ctx); } catch (e) { failDeferred(ctx, adapter, e); }
|
|
175
|
+
}, { once: true });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (host === 'vanilla') {
|
|
179
|
+
startGuards({ win: win, doc: win.document, guards: config.guards, debug: config.debug });
|
|
180
|
+
replay.startVanilla();
|
|
181
|
+
} else adapter = installJsPsychAdapter({ win: win, ctx: ctx });
|
|
182
|
+
watchHostPlacement({
|
|
183
|
+
win: win, doc: win.document, ctx: ctx, adapter: adapter,
|
|
184
|
+
onVanilla: function () {
|
|
185
|
+
try {
|
|
186
|
+
startGuards({ win: win, doc: win.document, guards: config.guards, debug: config.debug });
|
|
187
|
+
if (!ctx.vanilla) ctx.vanilla = installVanillaAdapter({ win: win, ctx: ctx });
|
|
188
|
+
replay.startVanilla();
|
|
189
|
+
if (ctx.debug) ctx.debug.logWhenParsed();
|
|
190
|
+
} catch (e) {
|
|
191
|
+
console.error(MESSAGES.bootFailed(String((e && e.message) || e)));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
win.CyborgHunter = ctx.api;
|
|
196
|
+
win.__cyborgHunterLoaded = 'ch.js';
|
|
197
|
+
// One console summary per page: vanilla logs once the DOM is parsed;
|
|
198
|
+
// jsPsych logs from the wrapped run() (after the walk), so here it only
|
|
199
|
+
// shows the badge.
|
|
200
|
+
if (ctx.debug) {
|
|
201
|
+
if (host === 'jspsych') ctx.debug.refresh(); else ctx.debug.logWhenParsed();
|
|
202
|
+
}
|
|
203
|
+
return ctx;
|
|
204
|
+
} catch (e) {
|
|
205
|
+
fail(win, ctx || { monitor: monitor }, adapter, e);
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// The monitor's session and the first span ('span-<index>'). Skipped after a
|
|
211
|
+
// manual-mode hand-over, which destroyed the monitor already.
|
|
212
|
+
function startMonitoring(ctx) {
|
|
213
|
+
if (ctx.host === 'manual') return;
|
|
214
|
+
ctx.monitor.startSession();
|
|
215
|
+
var started = ctx.segmenter.start();
|
|
216
|
+
if (started && started.error) throw new Error('could not open the first trial: ' + started.error);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// The deferred session start failed. The segmenter is abandoned first (it
|
|
220
|
+
// closes a span the failure left open while the monitor is still alive, and
|
|
221
|
+
// latches, so a later cut() or finish() neither touches the destroyed
|
|
222
|
+
// monitor nor replaces the marker below). The monitor is destroyed and the
|
|
223
|
+
// failure logged once; ctx.bootError keeps the placement check from taking
|
|
224
|
+
// the page for one ch.js could not hook. Then:
|
|
225
|
+
// vanilla the adapter stays (cut() does nothing without an open span), so
|
|
226
|
+
// data(), the form's hidden input and the next page still carry
|
|
227
|
+
// the earlier pages, with a cyborgHunterError note;
|
|
228
|
+
// jsPsych the initJsPsych wrap is removed, so a later initJsPsych() runs
|
|
229
|
+
// jsPsych as if ch.js were absent. An instance created before
|
|
230
|
+
// DOMContentLoaded keeps the extensions already injected (they
|
|
231
|
+
// stand down on ctx.bootError) and gets cyborgHunterError on every
|
|
232
|
+
// row.
|
|
233
|
+
function failDeferred(ctx, adapter, e) {
|
|
234
|
+
var msg = String((e && e.message) || e);
|
|
235
|
+
ctx.bootError = msg;
|
|
236
|
+
try { ctx.segmenter.abandon(); } catch (_) { /* already failing */ }
|
|
237
|
+
try { ctx.monitor.destroy(); } catch (_) { /* already failing */ }
|
|
238
|
+
console.error(MESSAGES.bootFailed(msg));
|
|
239
|
+
try {
|
|
240
|
+
if (ctx.vanilla) {
|
|
241
|
+
var page = ctx.vanilla.blob().cyborgHunterOneLiner.pageCount;
|
|
242
|
+
ctx.vanilla.noteError('Cyborg Hunter did not start on page ' + page + ': ' + msg);
|
|
243
|
+
}
|
|
244
|
+
if (adapter) {
|
|
245
|
+
adapter.restore();
|
|
246
|
+
installInertWrapper(ctx.win);
|
|
247
|
+
if (ctx.jsPsych) ctx.jsPsych.data.addProperties({ cyborgHunterError: 'Cyborg Hunter did not start: ' + msg });
|
|
248
|
+
}
|
|
249
|
+
} catch (_) { /* the failure is logged above */ }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// A failure inside boot(). ctx.bootError keeps the placement check
|
|
253
|
+
// (watchHostPlacement, registered before some steps that can fail) from
|
|
254
|
+
// taking the page for one ch.js could not hook and starting the vanilla
|
|
255
|
+
// path. A jsPsych wrap already installed is removed (it would drive the
|
|
256
|
+
// destroyed monitor) and the inert wrapper takes its place.
|
|
257
|
+
function fail(win, ctx, adapter, e) {
|
|
258
|
+
var msg = String((e && e.message) || e);
|
|
259
|
+
ctx.bootError = msg;
|
|
260
|
+
if (ctx.vanilla) { try { ctx.vanilla.teardown(); } catch (_) { /* already failing */ } }
|
|
261
|
+
if (ctx.monitor) { try { ctx.monitor.destroy(); } catch (_) { /* already failing; the boot error is the one to show */ } }
|
|
262
|
+
console.error(MESSAGES.bootFailed(msg));
|
|
263
|
+
try {
|
|
264
|
+
if (adapter) adapter.restore();
|
|
265
|
+
installInertWrapper(win);
|
|
266
|
+
} catch (_) { /* the failure is logged above */ }
|
|
267
|
+
try {
|
|
268
|
+
if (win.CyborgHunter === undefined) win.CyborgHunter = buildInertApi();
|
|
269
|
+
} catch (_) { /* a locked global */ }
|
|
270
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// src/oneliner/config.js
|
|
2
|
+
// The one-liner's configuration, from two places: the ch.js tag's data-*
|
|
3
|
+
// attributes (`dataset`) and window.CyborgHunterConfig (`globalConfig`).
|
|
4
|
+
// A tag attribute wins over the same key in CyborgHunterConfig.
|
|
5
|
+
//
|
|
6
|
+
// readConfig({ dataset, globalConfig }) → {
|
|
7
|
+
// preset, permissive | standard | strict,
|
|
8
|
+
// case-insensitive; 'standard' when
|
|
9
|
+
// unset; an unknown value warns
|
|
10
|
+
// (unknownPreset) and is 'standard'
|
|
11
|
+
// participantIdAttr, data-participant-id, or null
|
|
12
|
+
// guards: { honeypot, friction }, data-guards: comma list of
|
|
13
|
+
// honeypot | friction | none;
|
|
14
|
+
// default honeypot on, friction off
|
|
15
|
+
// replay: null | { tier: 'trace'|'dom', data-replay: "" | trace → trace, dom → dom;
|
|
16
|
+
// autoSave? }, CyborgHunterConfig.replay may be
|
|
17
|
+
// { tier, autoSave } (autoSave kept
|
|
18
|
+
// even when data-replay sets the tier)
|
|
19
|
+
// replaySrc, data-replay-src, or null
|
|
20
|
+
// debug, data-debug present (and not "false")
|
|
21
|
+
// monitor every other CyborgHunterConfig key,
|
|
22
|
+
// passed to init() as is (init's own
|
|
23
|
+
// validateConfig warns on typos);
|
|
24
|
+
// CyborgHunterConfig.participantId
|
|
25
|
+
// rides here for the id resolver
|
|
26
|
+
// }
|
|
27
|
+
// autoMonitor and excludeTrialTypes are dropped with a warning: the one-liner
|
|
28
|
+
// monitors every trial.
|
|
29
|
+
|
|
30
|
+
import { MESSAGES } from './errors.js';
|
|
31
|
+
|
|
32
|
+
export const DATA_KEYS = ['preset', 'participantId', 'guards', 'replay', 'replaySrc', 'debug'];
|
|
33
|
+
|
|
34
|
+
var ONE_LINER_KEYS = ['preset', 'guards', 'replay', 'replaySrc', 'debug'];
|
|
35
|
+
var REMOVED_KEYS = ['autoMonitor', 'excludeTrialTypes'];
|
|
36
|
+
var GUARD_NAMES = ['honeypot', 'friction'];
|
|
37
|
+
var PRESETS = ['permissive', 'standard', 'strict'];
|
|
38
|
+
|
|
39
|
+
function has(obj, key) {
|
|
40
|
+
return !!obj && obj[key] !== undefined && obj[key] !== null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// A bare attribute (data-debug) is the empty string, which is "on".
|
|
44
|
+
function flag(v) {
|
|
45
|
+
if (typeof v === 'string') return v.trim().toLowerCase() !== 'false';
|
|
46
|
+
return !!v;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function parseGuards(v) {
|
|
50
|
+
var out = { honeypot: false, friction: false };
|
|
51
|
+
var names = Array.isArray(v) ? v : String(v).split(',');
|
|
52
|
+
names.forEach(function (raw) {
|
|
53
|
+
var name = String(raw).trim().toLowerCase();
|
|
54
|
+
if (name === '' || name === 'none') return;
|
|
55
|
+
if (GUARD_NAMES.indexOf(name) === -1) {
|
|
56
|
+
console.warn('[cyborg-hunter] data-guards: unknown guard "' + name + '" ignored (use honeypot, friction or none)');
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
out[name] = true;
|
|
60
|
+
});
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// init() would reject an unknown preset outright (and boot would fail), so a
|
|
65
|
+
// typo like "stric" or "Strict" must not reach it.
|
|
66
|
+
function parsePreset(v) {
|
|
67
|
+
if (v === undefined || v === '') return 'standard';
|
|
68
|
+
var name = String(v).trim().toLowerCase();
|
|
69
|
+
if (PRESETS.indexOf(name) !== -1) return name;
|
|
70
|
+
console.warn(MESSAGES.unknownPreset(v));
|
|
71
|
+
return 'standard';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function parseReplay(v) {
|
|
75
|
+
if (v === false) return null;
|
|
76
|
+
if (v === true) return { tier: 'trace' };
|
|
77
|
+
if (typeof v === 'object') return parseReplay(v.tier === undefined || v.tier === null ? '' : v.tier);
|
|
78
|
+
var tier = String(v).trim().toLowerCase();
|
|
79
|
+
if (tier === '' || tier === 'trace') return { tier: 'trace' };
|
|
80
|
+
if (tier === 'dom') return { tier: 'dom' };
|
|
81
|
+
console.warn('[cyborg-hunter] data-replay: unknown tier "' + v + '"; recording the trace tier (use trace or dom)');
|
|
82
|
+
return { tier: 'trace' };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function readConfig(opts) {
|
|
86
|
+
var dataset = (opts && opts.dataset) || {};
|
|
87
|
+
var globalConfig = (opts && opts.globalConfig) || {};
|
|
88
|
+
|
|
89
|
+
// The tag attribute when present, else the CyborgHunterConfig value.
|
|
90
|
+
function pick(key) {
|
|
91
|
+
if (has(dataset, key)) return dataset[key];
|
|
92
|
+
if (has(globalConfig, key)) return globalConfig[key];
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
var monitor = {};
|
|
97
|
+
Object.keys(globalConfig).forEach(function (key) {
|
|
98
|
+
if (ONE_LINER_KEYS.indexOf(key) !== -1) return;
|
|
99
|
+
if (REMOVED_KEYS.indexOf(key) !== -1) {
|
|
100
|
+
console.warn('[cyborg-hunter] CyborgHunterConfig.' + key + ' is ignored: the one-line setup monitors every trial');
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
monitor[key] = globalConfig[key];
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
var guards = pick('guards');
|
|
107
|
+
var replay = pick('replay');
|
|
108
|
+
replay = replay === undefined ? null : parseReplay(replay);
|
|
109
|
+
// The replay recorder's autoSave (DataPipe) only comes from the object form.
|
|
110
|
+
var g = globalConfig.replay;
|
|
111
|
+
if (replay && g && typeof g === 'object' && g.autoSave) replay.autoSave = g.autoSave;
|
|
112
|
+
var debug = pick('debug');
|
|
113
|
+
return {
|
|
114
|
+
preset: parsePreset(pick('preset')),
|
|
115
|
+
participantIdAttr: has(dataset, 'participantId') ? dataset.participantId : null,
|
|
116
|
+
guards: guards === undefined ? { honeypot: true, friction: false } : parseGuards(guards),
|
|
117
|
+
replay: replay,
|
|
118
|
+
replaySrc: pick('replaySrc') || null,
|
|
119
|
+
debug: debug === undefined ? false : flag(debug),
|
|
120
|
+
monitor: monitor
|
|
121
|
+
};
|
|
122
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// src/oneliner/debug.js
|
|
2
|
+
// data-debug: a small fixed badge on the page, one console line per update,
|
|
3
|
+
// and the segment-write timings a later perf budget reads. Off by default;
|
|
4
|
+
// the docs say to remove it before launch, because participants can see it.
|
|
5
|
+
// boot only creates this when config.debug is on, so a page without
|
|
6
|
+
// data-debug pays nothing (no badge, no counters, no timing calls).
|
|
7
|
+
//
|
|
8
|
+
// createDebug({ doc, ctx, log = console.info })
|
|
9
|
+
// → { update(), refresh(), logWhenParsed(), summary() → string, badgeText() → string,
|
|
10
|
+
// stats() → { segmentWriteMs }, remove() }
|
|
11
|
+
//
|
|
12
|
+
// summary():
|
|
13
|
+
// Cyborg Hunter active · <jsPsych detected | vanilla mode> ·
|
|
14
|
+
// <N trials instrumented | N mark elements> · ID from <source> ·
|
|
15
|
+
// honeypot <on|off> · friction <off|observe|enforce>
|
|
16
|
+
// N on jsPsych is the PLANNED count: unique trial objects the timeline walk
|
|
17
|
+
// instrumented (ctx.jspsych.instrumented). On vanilla it is the number of
|
|
18
|
+
// [data-ch-trial] elements in the DOM.
|
|
19
|
+
// One summary is logged per page: update() logs, refresh() never does. The
|
|
20
|
+
// badge on jsPsych shows live progress, "written/planned trials"
|
|
21
|
+
// (ctx.jspsych.segmentsWritten / instrumented), or "N trials (M planned)"
|
|
22
|
+
// once loops / timeline_variables write more rows than planned (so never
|
|
23
|
+
// "5/3"); refresh() updates it.
|
|
24
|
+
// <source> is the URL parameter name that resolved, data-participant-id,
|
|
25
|
+
// CyborgHunterConfig, or "random id (not linkable)".
|
|
26
|
+
// With data-replay (and no recorder autoSave) the summary, not the badge,
|
|
27
|
+
// ends with " · data-replay is on: save CyborgHunter.replay() in your save
|
|
28
|
+
// code", in place of boot's console.info reminder (replay-loader.js).
|
|
29
|
+
//
|
|
30
|
+
// The badge never takes focus or clicks (pointer-events: none) and nothing
|
|
31
|
+
// here throws into the host page. update() and refresh() re-attach it when
|
|
32
|
+
// the host has wiped it from the document (jsPsych's run() resets <body>).
|
|
33
|
+
|
|
34
|
+
import { REPLAY_SAVE_REMINDER } from './errors.js';
|
|
35
|
+
import { replaySaveReminderApplies } from './replay-loader.js';
|
|
36
|
+
|
|
37
|
+
var BADGE_ID = 'ch-debug-badge';
|
|
38
|
+
var BADGE_STYLE = 'position:fixed;left:8px;bottom:8px;z-index:2147483646;font:12px/1.4 system-ui;' +
|
|
39
|
+
'background:#111;color:#fff;padding:6px 8px;border-radius:4px;opacity:.9;pointer-events:none';
|
|
40
|
+
|
|
41
|
+
function idSource(source) {
|
|
42
|
+
if (typeof source === 'string' && source.indexOf('url:') === 0) return source.slice(4);
|
|
43
|
+
if (source === 'attribute') return 'data-participant-id';
|
|
44
|
+
if (source === 'config') return 'CyborgHunterConfig';
|
|
45
|
+
return 'random id (not linkable)'; // 'random', or 'session' (a random id kept from an earlier page)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function frictionMode(ctx) {
|
|
49
|
+
if (!ctx.config.guards.friction) return 'off';
|
|
50
|
+
// On jsPsych the entry trial turns enforcement on; otherwise observe-only.
|
|
51
|
+
return ctx.host === 'jspsych' && ctx.jspsych && ctx.jspsych.entryTrialFound ? 'enforce' : 'observe';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function createDebug(opts) {
|
|
55
|
+
var doc = opts.doc, ctx = opts.ctx;
|
|
56
|
+
var log = opts.log || function (m) { console.info(m); };
|
|
57
|
+
var stats = { segmentWriteMs: [] };
|
|
58
|
+
var badge = null;
|
|
59
|
+
var waiting = false;
|
|
60
|
+
|
|
61
|
+
function parts(live) {
|
|
62
|
+
var hostPart, countPart;
|
|
63
|
+
if (ctx.host === 'vanilla') {
|
|
64
|
+
var marks = doc.querySelectorAll ? doc.querySelectorAll('[data-ch-trial]').length : 0;
|
|
65
|
+
hostPart = 'vanilla mode';
|
|
66
|
+
countPart = marks + ' mark elements';
|
|
67
|
+
} else {
|
|
68
|
+
hostPart = 'jsPsych detected';
|
|
69
|
+
var js = ctx.jspsych || {};
|
|
70
|
+
var written = js.segmentsWritten || 0, planned = js.instrumented || 0;
|
|
71
|
+
if (!live) countPart = planned + ' trials instrumented';
|
|
72
|
+
else if (written > planned) countPart = written + ' trials (' + planned + ' planned)'; // loops repeat one trial object
|
|
73
|
+
else countPart = written + '/' + planned + ' trials';
|
|
74
|
+
}
|
|
75
|
+
var out = ['Cyborg Hunter active', hostPart, countPart,
|
|
76
|
+
'ID from ' + idSource(ctx.participantIdSource),
|
|
77
|
+
'honeypot ' + (ctx.config.guards.honeypot ? 'on' : 'off'),
|
|
78
|
+
'friction ' + frictionMode(ctx)];
|
|
79
|
+
if (!live && replaySaveReminderApplies(ctx)) out.push(REPLAY_SAVE_REMINDER);
|
|
80
|
+
return out.join(' · ');
|
|
81
|
+
}
|
|
82
|
+
function summary() { return parts(false); }
|
|
83
|
+
function badgeText() { return parts(true); }
|
|
84
|
+
|
|
85
|
+
function render(text) {
|
|
86
|
+
if (!badge) {
|
|
87
|
+
if (!doc.body) {
|
|
88
|
+
if (!waiting) {
|
|
89
|
+
waiting = true;
|
|
90
|
+
doc.addEventListener('DOMContentLoaded', function () {
|
|
91
|
+
waiting = false;
|
|
92
|
+
try { render(badgeText()); } catch (_) { /* the badge is optional */ }
|
|
93
|
+
}, { once: true });
|
|
94
|
+
}
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
badge = doc.getElementById(BADGE_ID) || doc.createElement('div');
|
|
98
|
+
badge.id = BADGE_ID;
|
|
99
|
+
badge.setAttribute('style', BADGE_STYLE);
|
|
100
|
+
}
|
|
101
|
+
// jsPsych 7 sets display_element.innerHTML in run(), and display_element
|
|
102
|
+
// defaults to <body>, so the badge appended at boot is gone by the first
|
|
103
|
+
// trial. The same node goes back (getElementById no longer finds it, so a
|
|
104
|
+
// lookup would make a second one); under <html> if a page has no <body>.
|
|
105
|
+
if (!badge.isConnected) (doc.body || doc.documentElement).appendChild(badge);
|
|
106
|
+
badge.textContent = text;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
summary: summary,
|
|
111
|
+
badgeText: badgeText,
|
|
112
|
+
stats: function () { return stats; },
|
|
113
|
+
update: function () {
|
|
114
|
+
try {
|
|
115
|
+
render(badgeText());
|
|
116
|
+
log(summary());
|
|
117
|
+
} catch (_) { /* a debug aid never breaks the page */ }
|
|
118
|
+
},
|
|
119
|
+
// Badge only, no console line (a row was written).
|
|
120
|
+
refresh: function () {
|
|
121
|
+
try { render(badgeText()); } catch (_) { /* a debug aid never breaks the page */ }
|
|
122
|
+
},
|
|
123
|
+
// update() once the DOM is parsed, so vanilla mark elements are all there.
|
|
124
|
+
logWhenParsed: function () {
|
|
125
|
+
try {
|
|
126
|
+
var self = this;
|
|
127
|
+
if (doc.readyState === 'loading' && doc.addEventListener) {
|
|
128
|
+
doc.addEventListener('DOMContentLoaded', function () { self.update(); }, { once: true });
|
|
129
|
+
} else this.update();
|
|
130
|
+
} catch (_) { /* a debug aid never breaks the page */ }
|
|
131
|
+
},
|
|
132
|
+
remove: function () {
|
|
133
|
+
try { if (badge && badge.parentNode) badge.parentNode.removeChild(badge); } catch (_) { /* ignore */ }
|
|
134
|
+
badge = null;
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// src/oneliner/entry.js — dist/ch.js entry. Keep this file tiny: everything
|
|
2
|
+
// testable lives in boot.js. document.currentScript is only meaningful while
|
|
3
|
+
// this script runs, so it is captured here, synchronously, before anything else.
|
|
4
|
+
import '../jspsych/extension-guard-friction.js'; // side effect: window.GuardFriction + window.jsPsychGuardFriction
|
|
5
|
+
import '../jspsych/extension-guard-honeypot.js'; // side effect: window.GuardHoneypot + window.jsPsychGuardHoneypot
|
|
6
|
+
import { boot } from './boot.js';
|
|
7
|
+
var script = typeof document !== 'undefined' ? document.currentScript : null;
|
|
8
|
+
boot({ script: script, win: window });
|