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.
@@ -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 });