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,420 @@
1
+ // src/oneliner/adapters/vanilla.js
2
+ // The vanilla host: any page without jsPsych (and a jsPsych page ch.js could
3
+ // not hook, see watchHostPlacement in adapters/jspsych.js). Boundaries come
4
+ // from two places, manual marks first:
5
+ // marks a click on (or inside) an element with data-ch-trial="id",
6
+ // CyborgHunter.mark(id) / startTrial({ trialId }) / endTrial():
7
+ // the open span becomes a 'manual' segment, the next span is
8
+ // named id (or span-<index> without one);
9
+ // page loads a <form> submit (event or form.submit()) and pagehide: the
10
+ // open span becomes a 'page' segment.
11
+ // Each segment becomes one row of a Shape-1 blob, the shape the CLI already
12
+ // reads (extract-core.js, Shape 1 + the 5th session convention):
13
+ // { participantId, libraryVersion, cyborgHunterOneLiner: { version, host, pageCount },
14
+ // trials: [{ trialId, integrity, integritySegment, integrityPasteCount,
15
+ // integrityCopyCount, integrityDropCount, integritySoftScore,
16
+ // integrityAnyHardTriggered, cyborgHunterError? }],
17
+ // ...honeypot session summary (when the honeypot is on) }
18
+ // The blob reaches the researcher's data two ways: a hidden input named
19
+ // cyborgHunterData in the POST form being submitted (not cyborgHunter: raw
20
+ // .cyborgHunter is an older CLI convention expecting a session report), and
21
+ // CyborgHunter.data(), which closes the current segment first. A GET form
22
+ // gets no input: the blob, often megabytes, would go into the URL; its page
23
+ // still saves the session for the next page.
24
+ //
25
+ // Several pages. Every page load runs a new monitor whose performance.now()
26
+ // starts at 0 again. The accumulated blob is kept in sessionStorage (per tab;
27
+ // not the core's storage helpers, which are localStorage-first and would leak
28
+ // into the next participant on a shared browser) under
29
+ // cyborg-hunter:oneliner:session:<participantId> (boot.js keeps the id itself
30
+ // under cyborg-hunter:oneliner:pid), so the next page continues the
31
+ // segment index and the last page's form carries the whole session. Each
32
+ // segment records its page origin (performance.timeOrigin); the CLI re-bases
33
+ // later pages onto the first page's clock. The honeypot's violation log is
34
+ // per page too: each entry carried forward is tagged with its page origin for
35
+ // the same re-base.
36
+ //
37
+ // A form post fires submit and then pagehide. The submit already closed the
38
+ // span, so pagehide only persists; without that the post would write an empty
39
+ // extra segment. A submit the page cancels (validation) leaves the page
40
+ // alive, so pagehide cuts again. form.requestSubmit() fires submit as a click
41
+ // does; form.submit() fires no submit event, so HTMLFormElement.prototype
42
+ // .submit is wrapped to do the same work just before the browser's submit
43
+ // (see wrappedSubmit).
44
+ //
45
+ // A page shown again from the back/forward cache (pageshow with persisted)
46
+ // keeps its monitor and this adapter's memory from before it was left; it
47
+ // re-adopts the saved session, which later pages have added to, so the
48
+ // indices continue and its next pagehide cuts again.
49
+ //
50
+ // When sessionStorage refuses the session (quota), a slim record still keeps
51
+ // the segment index and the page count; the next page's blob then carries a
52
+ // cyborgHunterError saying the earlier pages are only in their own saves.
53
+ // Such notes travel on to every later page (record field `errors`).
54
+ //
55
+ // Listeners go on document and window (and the submit() wrap on the form
56
+ // prototype), which exist while ch.js runs in <head>; nothing here needs <body> before a click or a submit. The guards
57
+ // (honeypot bait, friction) wait for DOMContentLoaded in guards.js.
58
+ //
59
+ // installVanillaAdapter({ win, ctx, clock?, warnChars? }) → {
60
+ // blob(), cut(source, nextTrialId?), persist(), restore(), teardown(),
61
+ // noteError(text) adds a cyborgHunterError note to this and later blobs
62
+ // }
63
+ // ctx: boot's context; gains ctx.handlers.mark / data / startFriction;
64
+ // ctx.replay (data-replay, set later by replay-loader.js) follows
65
+ // every cut and is stopped at pagehide
66
+ // clock: () => page origin, the segmenter's clock (performance.timeOrigin)
67
+ // warnChars: persist() warns once above this many characters (4,000,000)
68
+ // install restores the saved state first, so the boot span opened after it is
69
+ // named after the continued index. Nothing here throws into the page.
70
+
71
+ import { VERSION } from '../../shared/constants.js';
72
+ import { MESSAGES } from '../errors.js';
73
+
74
+ var KEY_PREFIX = 'cyborg-hunter:oneliner:session:';
75
+ var WARN_CHARS = 4000000;
76
+ var HIDDEN_INPUT = 'cyborgHunterData';
77
+ var FULLSCREEN_SETTLE_MS = 100; // the friction entry trial's own delay before start()
78
+
79
+ function message(e) { return String((e && e.message) || e); }
80
+
81
+ function closestAttr(target, attr) {
82
+ return target && typeof target.closest === 'function' ? target.closest('[' + attr + ']') : null;
83
+ }
84
+
85
+ function parseViolations(summary) {
86
+ try {
87
+ var v = JSON.parse((summary && summary.guard_assistance_violations_session) || '[]');
88
+ return Array.isArray(v) ? v : [];
89
+ } catch (_) { return []; }
90
+ }
91
+
92
+ export function installVanillaAdapter(opts) {
93
+ var win = opts.win, ctx = opts.ctx;
94
+ var clock = opts.clock || function () { return performance.timeOrigin; };
95
+ var warnChars = opts.warnChars || WARN_CHARS;
96
+ var key = KEY_PREFIX + ctx.participantId;
97
+ var doc = win.document;
98
+
99
+ var trials = [];
100
+ var pageCount = 1;
101
+ var earlierHoneypot = null; // merged honeypot summary of earlier pages
102
+ var restored = false;
103
+ var submitted = false; // this page's span was closed by a form submit
104
+ var warnedSize = false;
105
+ var noticedGet = false; // the GET-form console.info, once per page
106
+ var notes = []; // cyborgHunterError notes, this page's and earlier pages'
107
+
108
+ function readState() {
109
+ try {
110
+ var raw = win.sessionStorage.getItem(key);
111
+ return raw ? JSON.parse(raw) : null;
112
+ } catch (_) { return null; } // blocked storage or a damaged value: start fresh
113
+ }
114
+
115
+ // On failure (usually the quota) a slim record keeps the indices counting.
116
+ function writeState(json) {
117
+ try {
118
+ win.sessionStorage.setItem(key, json);
119
+ return;
120
+ } catch (e) {
121
+ console.error(MESSAGES.storageFailed(message(e)));
122
+ }
123
+ try {
124
+ win.sessionStorage.setItem(key, JSON.stringify({
125
+ segmentIndex: ctx.segmenter.state().segmentIndex,
126
+ pageCount: pageCount,
127
+ trials: [],
128
+ storageError: true,
129
+ errors: notes
130
+ }));
131
+ } catch (_) { /* storage blocked altogether: logged above */ }
132
+ }
133
+
134
+ function usable(saved) {
135
+ return !!saved && typeof saved.segmentIndex === 'number' && Array.isArray(saved.trials);
136
+ }
137
+
138
+ // Takes over a saved record's counters and notes (not its trials).
139
+ function adopt(saved) {
140
+ pageCount = (typeof saved.pageCount === 'number' ? saved.pageCount : 1) + 1;
141
+ earlierHoneypot = saved.honeypot || null;
142
+ notes = Array.isArray(saved.errors) ? saved.errors.slice() : [];
143
+ if (saved.storageError) {
144
+ notes.push('session storage full on page ' + (pageCount - 1) + '; earlier pages only in their own saves');
145
+ }
146
+ ctx.segmenter.setSegmentIndex(saved.segmentIndex);
147
+ }
148
+
149
+ function restore() {
150
+ if (restored) return null;
151
+ restored = true;
152
+ var saved = readState();
153
+ if (!usable(saved)) return null;
154
+ trials = saved.trials.concat(trials);
155
+ adopt(saved);
156
+ return saved;
157
+ }
158
+
159
+ // Earlier pages' honeypot summary plus this page's, re-stringified as the
160
+ // honeypot writes it. null when the honeypot is off on this page and no
161
+ // earlier page had it.
162
+ function honeypotSummary() {
163
+ var hp = ctx.config.guards.honeypot ? win.GuardHoneypot : null;
164
+ var current = null;
165
+ if (hp) {
166
+ try { current = hp.getSessionSummary(); } catch (_) { current = null; }
167
+ }
168
+ if (!current) return earlierHoneypot;
169
+ var origin = clock();
170
+ // Earlier entries tagged with this page's origin are this page's own
171
+ // (re-adopted after the back/forward cache); `current` has them all.
172
+ var earlier = parseViolations(earlierHoneypot).filter(function (v) { return v.pageOrigin !== origin; });
173
+ var violations = earlier.concat(parseViolations(current).map(function (v) {
174
+ return Object.assign({}, v, { pageOrigin: origin });
175
+ }));
176
+ var reports = [earlierHoneypot && earlierHoneypot.ai_report_session, current.ai_report_session]
177
+ .filter(function (r) { return typeof r === 'string' && r.length > 0; });
178
+ return {
179
+ guard_assistance_violations_session: JSON.stringify(violations),
180
+ guard_assistance_violation_count_session: violations.length,
181
+ ai_use_session: !!(earlierHoneypot && earlierHoneypot.ai_use_session === true) || current.ai_use_session === true,
182
+ ai_report_session: reports.join('\n')
183
+ };
184
+ }
185
+
186
+ function blob() {
187
+ var b = {
188
+ participantId: ctx.participantId,
189
+ libraryVersion: VERSION,
190
+ cyborgHunterOneLiner: { version: VERSION, host: 'vanilla', pageCount: pageCount },
191
+ trials: trials.slice()
192
+ };
193
+ var hp = honeypotSummary();
194
+ if (hp) Object.assign(b, hp);
195
+ if (notes.length) b.cyborgHunterError = notes.join('; ');
196
+ return b;
197
+ }
198
+
199
+ // Segmenter contract: a result with `segment` is saved even when it also
200
+ // carries `error` (only the next span failed to open); `error` alone means
201
+ // nothing was cut.
202
+ function cut(source, nextTrialId) {
203
+ // Nothing to cut before the session has started (ch.js in <head>, before
204
+ // DOMContentLoaded) or after a span failed to reopen; the segmenter would
205
+ // log the monitor's lifecycle rejection.
206
+ if (!ctx.segmenter.state().open) return { error: 'no open span' };
207
+ var r;
208
+ try {
209
+ r = ctx.segmenter.cut({ source: source, nextTrialId: nextTrialId || undefined });
210
+ } catch (e) {
211
+ r = { error: message(e) }; // the segmenter does not throw; kept for the host's sake
212
+ }
213
+ if (r && r.segment) {
214
+ var seg = r.segment;
215
+ var row = {
216
+ trialId: seg.trialId,
217
+ integrity: r.trialReport || {},
218
+ integritySegment: seg,
219
+ integrityPasteCount: seg.counters.pasteCount,
220
+ integrityCopyCount: seg.counters.copyCount,
221
+ integrityDropCount: seg.counters.dropCount,
222
+ integritySoftScore: seg.score.softScore,
223
+ integrityAnyHardTriggered: seg.score.anyHardTriggered
224
+ };
225
+ if (r.error) row.cyborgHunterError = r.error;
226
+ trials.push(row);
227
+ submitted = false;
228
+ followReplay();
229
+ }
230
+ return r;
231
+ }
232
+
233
+ // data-replay: the recorder's trials follow the segments (replay-loader.js
234
+ // sets ctx.replay once the recorder has started; its calls never throw).
235
+ function followReplay() {
236
+ if (!ctx.replay) return;
237
+ ctx.replay.endTrial();
238
+ var state = ctx.segmenter.state();
239
+ if (state.open) ctx.replay.startTrial(state.currentTrialId);
240
+ }
241
+
242
+ function persist() {
243
+ var json;
244
+ try {
245
+ json = JSON.stringify({
246
+ segmentIndex: ctx.segmenter.state().segmentIndex,
247
+ pageCount: pageCount,
248
+ trials: trials,
249
+ honeypot: honeypotSummary(),
250
+ errors: notes
251
+ });
252
+ } catch (e) {
253
+ console.error(MESSAGES.storageFailed(message(e)));
254
+ return;
255
+ }
256
+ if (json.length > warnChars && !warnedSize) {
257
+ warnedSize = true;
258
+ console.warn(MESSAGES.storageNearlyFull());
259
+ }
260
+ writeState(json);
261
+ }
262
+
263
+ // Mirrors the friction entry trial: fullscreen is requested inside the
264
+ // click (the user gesture), enforcement starts once it has settled.
265
+ // observeOnly: false explicitly, since start() keeps an earlier
266
+ // observe-only setting when the option is absent.
267
+ function startFriction() {
268
+ var F = win.GuardFriction;
269
+ if (!F) return;
270
+ if (!ctx.config.guards.friction) console.warn(MESSAGES.frictionStartWithoutFriction());
271
+ try { F.requestFullscreen(); } catch (e) { console.error(MESSAGES.guardFailed('friction', message(e))); }
272
+ win.setTimeout(function () {
273
+ try {
274
+ var token = F.start({ jsPsych: null, observeOnly: false, debug: !!ctx.config.debug });
275
+ Object.defineProperty(win, '_guardFrictionToken', {
276
+ value: token, writable: false, enumerable: false, configurable: true
277
+ });
278
+ } catch (e) {
279
+ console.error(MESSAGES.guardFailed('friction', message(e)));
280
+ }
281
+ }, FULLSCREEN_SETTLE_MS);
282
+ }
283
+
284
+ function onClick(ev) {
285
+ try {
286
+ var mark = closestAttr(ev.target, 'data-ch-trial');
287
+ if (mark) cut('manual', mark.getAttribute('data-ch-trial'));
288
+ if (closestAttr(ev.target, 'data-ch-friction-start')) startFriction();
289
+ } catch (e) {
290
+ console.error(MESSAGES.vanillaEventFailed(message(e)));
291
+ }
292
+ }
293
+
294
+ function effectiveMethod(form, submitter) {
295
+ return String((submitter && submitter.formMethod) || form.method || 'get').toLowerCase();
296
+ }
297
+
298
+ // The work of a form submit: close the span, save the session, and put the
299
+ // blob into a POST form's hidden input. `cutSpan` false keeps the span as it
300
+ // is (form.submit() called right after a submit event closed it). A dialog
301
+ // submit (method or formmethod "dialog") only closes its <dialog>: the page
302
+ // stays, so it is not a page load and the next pagehide must still cut.
303
+ function carry(form, submitter, cutSpan) {
304
+ if (form && effectiveMethod(form, submitter) === 'dialog') return;
305
+ if (cutSpan) cut('page');
306
+ submitted = true;
307
+ persist();
308
+ if (!form || typeof form.querySelector !== 'function') return;
309
+ var input = form.querySelector('input[name="' + HIDDEN_INPUT + '"]');
310
+ if (effectiveMethod(form, submitter) !== 'post') {
311
+ if (input) input.parentNode.removeChild(input); // left by an earlier POST submit of this form
312
+ if (!noticedGet) {
313
+ noticedGet = true;
314
+ console.info('[cyborg-hunter] A GET form was submitted: its data does not get cyborgHunterData (it would go into the URL). The session is kept for the next page and CyborgHunter.data().');
315
+ }
316
+ return;
317
+ }
318
+ if (!input) {
319
+ input = doc.createElement('input');
320
+ input.type = 'hidden';
321
+ input.name = HIDDEN_INPUT;
322
+ form.appendChild(input);
323
+ }
324
+ input.value = JSON.stringify(blob());
325
+ }
326
+
327
+ // Capture phase on document: runs before the page's own submit handlers,
328
+ // so a FormData built there already holds the value.
329
+ function onSubmit(ev) {
330
+ try {
331
+ // A cancelled submit (validation) keeps the page, so its next pagehide
332
+ // must cut. Decided once the page's own handlers have run; a bubble
333
+ // listener would miss a page that stops the event's propagation.
334
+ win.setTimeout(function () { if (ev.defaultPrevented) submitted = false; }, 0);
335
+ carry(ev.target, ev.submitter, true);
336
+ } catch (e) {
337
+ console.error(MESSAGES.vanillaEventFailed(message(e)));
338
+ }
339
+ }
340
+
341
+ // form.submit() fires no submit event. The wrap does the submit's work for
342
+ // that form just before the browser's submit() builds the entry list, so
343
+ // the post carries the blob with this page's open span. When a submit event
344
+ // has just closed the span (a page handler that calls form.submit()), it
345
+ // is not cut again. this, the arguments and the return value pass through,
346
+ // and the browser's submit() always runs. Not covered: submit() on a form
347
+ // in another frame (its own HTMLFormElement.prototype), or a reference to
348
+ // the original submit() the page took before ch.js ran. requestSubmit()
349
+ // and submit buttons do not go through submit(); they fire the event.
350
+ var formProto = win.HTMLFormElement && win.HTMLFormElement.prototype;
351
+ var nativeSubmit = formProto && typeof formProto.submit === 'function' ? formProto.submit : null;
352
+ var active = true;
353
+ function wrappedSubmit() {
354
+ if (active) {
355
+ try {
356
+ carry(this, null, !submitted);
357
+ } catch (e) {
358
+ console.error(MESSAGES.vanillaEventFailed(message(e)));
359
+ }
360
+ }
361
+ return nativeSubmit.apply(this, arguments);
362
+ }
363
+
364
+ // Back/forward cache: see the header. Without a usable record (storage
365
+ // blocked) the page keeps what it had in memory; with a slim one, its
366
+ // trials (earlier pages' and its own) but the record's counters and notes.
367
+ function onPageShow(ev) {
368
+ if (!ev || !ev.persisted) return;
369
+ try {
370
+ submitted = false;
371
+ var saved = readState();
372
+ if (!usable(saved)) return;
373
+ // A slim record (storage full) has no trials: keep the ones in memory.
374
+ if (!saved.storageError) trials = saved.trials.slice();
375
+ adopt(saved);
376
+ } catch (e) {
377
+ console.error(MESSAGES.vanillaEventFailed(message(e)));
378
+ }
379
+ }
380
+
381
+ function onPageHide() {
382
+ try {
383
+ if (!submitted) cut('page');
384
+ persist();
385
+ if (ctx.replay) ctx.replay.stop();
386
+ } catch (e) {
387
+ console.error(MESSAGES.vanillaEventFailed(message(e)));
388
+ }
389
+ }
390
+
391
+ restore();
392
+ doc.addEventListener('click', onClick, true);
393
+ doc.addEventListener('submit', onSubmit, true);
394
+ if (nativeSubmit) formProto.submit = wrappedSubmit;
395
+ win.addEventListener('pagehide', onPageHide);
396
+ win.addEventListener('pageshow', onPageShow);
397
+
398
+ ctx.handlers.mark = function (trialId) { cut('manual', trialId); };
399
+ ctx.handlers.data = function () { cut('manual'); return blob(); };
400
+ ctx.handlers.startFriction = startFriction;
401
+
402
+ return {
403
+ blob: blob,
404
+ cut: cut,
405
+ persist: persist,
406
+ restore: restore,
407
+ noteError: function (text) { notes.push(String(text)); },
408
+ teardown: function () {
409
+ doc.removeEventListener('click', onClick, true);
410
+ doc.removeEventListener('submit', onSubmit, true);
411
+ active = false;
412
+ if (nativeSubmit && formProto.submit === wrappedSubmit) formProto.submit = nativeSubmit;
413
+ win.removeEventListener('pagehide', onPageHide);
414
+ win.removeEventListener('pageshow', onPageShow);
415
+ delete ctx.handlers.mark;
416
+ delete ctx.handlers.data;
417
+ delete ctx.handlers.startFriction;
418
+ }
419
+ };
420
+ }
@@ -0,0 +1,151 @@
1
+ // src/oneliner/api.js
2
+ // The window.CyborgHunter namespace under the one-line setup. It replaces the
3
+ // core's namespace (init + static helpers), so a researcher's leftover
4
+ // CyborgHunter.init() cannot destroy the one-liner's monitor (core init()
5
+ // destroys the previous instance): here init() only explains itself, except
6
+ // in manual mode (below).
7
+ //
8
+ // buildPublicApi(ctx) → frozen {
9
+ // VERSION,
10
+ // mark(trialId?) close the current segment, open the next
11
+ // startTrial(opts) ≡ mark(opts && opts.trialId)
12
+ // endTrial() ≡ mark()
13
+ // data(), startFriction()
14
+ // replay() stop the replay recorder (data-replay) and return
15
+ // its recording for the researcher's own save code;
16
+ // null, with a warning, when replay is off or not
17
+ // started yet (replay-loader.js)
18
+ // frictionEntryTrial(opts) GuardFriction.createEntryTrial(opts)
19
+ // init(cfg) logs manualInitOnOneLiner, returns inertMonitor(api)
20
+ // (below); in manual mode, the core init(cfg)
21
+ // preventTextSelection, addHoneypot, setAltText (core static helpers)
22
+ // }
23
+ // buildInertApi() → the same members, inert (ch.js failed; see below).
24
+ // mark/data/startFriction depend on the host (jsPsych or vanilla); the host
25
+ // adapter installs them in ctx.handlers, and boot installs replay
26
+ // (replay-loader.js). Until then they return undefined. mark() (with startTrial/endTrial) and data() are vanilla calls:
27
+ // on the jsPsych host, and in manual mode, they warn and do nothing (ctx.host
28
+ // is read at call time: a jsPsych page ch.js cannot hook becomes vanilla
29
+ // after boot).
30
+
31
+ import { VERSION } from '../shared/constants.js';
32
+ import { init as coreInit } from '../core/monitor.js';
33
+ import { preventTextSelection, addHoneypot, setAltText } from '../core/signals/dom-protection.js';
34
+ import { MESSAGES } from './errors.js';
35
+
36
+ var VANILLA_ONLY = '[cyborg-hunter] mark()/data() are vanilla-mode calls; jsPsych trials are segmented automatically';
37
+
38
+ // What init() returns when it must not start a second monitor: a copy of the
39
+ // namespace with the core monitor's documented methods as no-ops, so
40
+ // half-migrated standalone code (CyborgHunter.init(cfg).startSession(), …
41
+ // .endTrial().foo = 1, a manual jsPsych extension on a failed ch.js) runs on
42
+ // without throwing and without driving ch.js's monitor. startTrial/endTrial
43
+ // shadow the namespace's mark() aliases on purpose. A plain copy, not
44
+ // Object.create(api): the namespace is frozen, and assigning over an
45
+ // inherited read-only property (startTrial, endTrial) throws. endTrial()
46
+ // returns a fresh object each call (the manual extension writes to it).
47
+ function inertMonitor(api) {
48
+ return Object.assign({}, api, {
49
+ startSession: function () {},
50
+ startTrial: function () {},
51
+ endTrial: function () { return {}; },
52
+ getSessionReport: function () { return {}; },
53
+ getSessionScore: function () { return {}; },
54
+ shouldScreenout: function () { return false; },
55
+ destroy: function () {}
56
+ });
57
+ }
58
+
59
+ export function buildPublicApi(ctx) {
60
+ function handler(name) {
61
+ return function () {
62
+ var h = ctx.handlers && ctx.handlers[name];
63
+ return h ? h.apply(null, arguments) : undefined;
64
+ };
65
+ }
66
+ function vanillaOnly(name) {
67
+ var h = handler(name);
68
+ return function () {
69
+ if (ctx.host === 'jspsych' || ctx.host === 'manual') {
70
+ console.warn(VANILLA_ONLY);
71
+ return undefined;
72
+ }
73
+ return h.apply(null, arguments);
74
+ };
75
+ }
76
+ var mark = vanillaOnly('mark');
77
+ var api = {
78
+ VERSION: VERSION,
79
+ mark: mark,
80
+ startTrial: function (opts) { return mark(opts && opts.trialId); },
81
+ endTrial: function () { return mark(); },
82
+ data: vanillaOnly('data'),
83
+ replay: handler('replay'),
84
+ startFriction: handler('startFriction'),
85
+ frictionEntryTrial: function (opts) { return ctx.win.GuardFriction.createEntryTrial(opts); },
86
+ // Manual mode (adapters/jspsych.js handOver): the researcher's own
87
+ // jsPsych extension creates the monitor by calling window.CyborgHunter
88
+ // .init(). That is this namespace, with ch.js alone and with
89
+ // cyborg-hunter.min.js loaded after ch.js (min.js's footer restores it;
90
+ // build.js), so init() hands out a core monitor here (ch.js's own monitor
91
+ // is already destroyed by then). ctx.host is read at call time: the
92
+ // hand-over happens after boot.
93
+ init: function (cfg) {
94
+ if (ctx.host === 'manual') return coreInit(cfg);
95
+ console.error(MESSAGES.manualInitOnOneLiner());
96
+ return inertMonitor(api);
97
+ },
98
+ preventTextSelection: preventTextSelection,
99
+ addHoneypot: addHoneypot,
100
+ setAltText: setAltText
101
+ };
102
+ return Object.freeze(api);
103
+ }
104
+
105
+ // window.CyborgHunter when ch.js failed and nothing else defined it (boot.js
106
+ // fail()): the same members, each returning a harmless value, so documented
107
+ // calls in the experiment code do not throw. The first call logs notRunning
108
+ // once. Return values:
109
+ // mark / startTrial / endTrial / startFriction undefined
110
+ // data() { libraryVersion, trials: [], cyborgHunterError }, so a save
111
+ // of it still parses and says why it is empty
112
+ // replay() null (as when replay is off)
113
+ // init() inertMonitor(api) (as the one-liner's init() outside manual
114
+ // mode), so a manual extension or standalone code runs on
115
+ // frictionEntryTrial() a timeline node jsPsych skips (conditional_function
116
+ // false): no row, and friction is not started with nothing to
117
+ // stop it at the end
118
+ // The core's static helpers (preventTextSelection, addHoneypot, setAltText)
119
+ // do not depend on the monitor and stay real.
120
+ export function buildInertApi() {
121
+ var warned = false;
122
+ function noted(value) {
123
+ return function () {
124
+ if (!warned) { warned = true; console.warn(MESSAGES.notRunning()); }
125
+ return typeof value === 'function' ? value() : value;
126
+ };
127
+ }
128
+ // Never run (the node is skipped), but a valid trial in case a host walks it.
129
+ function Skipped(jsPsych) { this.jsPsych = jsPsych; }
130
+ Skipped.info = { name: 'cyborg-hunter-skipped', parameters: {} };
131
+ Skipped.prototype.trial = function () { this.jsPsych.finishTrial({}); };
132
+ var api = {
133
+ VERSION: VERSION,
134
+ mark: noted(undefined),
135
+ startTrial: noted(undefined),
136
+ endTrial: noted(undefined),
137
+ data: noted(function () {
138
+ return { libraryVersion: VERSION, trials: [], cyborgHunterError: 'Cyborg Hunter did not start on this page' };
139
+ }),
140
+ replay: noted(null),
141
+ startFriction: noted(undefined),
142
+ frictionEntryTrial: noted(function () {
143
+ return { timeline: [{ type: Skipped }], conditional_function: function () { return false; } };
144
+ }),
145
+ init: noted(function () { return inertMonitor(api); }),
146
+ preventTextSelection: preventTextSelection,
147
+ addHoneypot: addHoneypot,
148
+ setAltText: setAltText
149
+ };
150
+ return Object.freeze(api);
151
+ }