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,483 @@
1
+ // src/oneliner/adapters/jspsych.js
2
+ // The jsPsych 7 host: ch.js sits below jspsych.js and above the experiment
3
+ // code, so window.initJsPsych exists at boot and can be wrapped before the
4
+ // researcher calls it. The wrapper
5
+ // 1. adds ch.js's extensions to initJsPsych's list: OneLinerExtension
6
+ // ('cyborg-hunter'), the honeypot and, when enabled, friction (deduped by
7
+ // info.name: jsPsych keeps one instance per name but calls initialize()
8
+ // once per listed entry, jspsych.js 7.3.1 :2667-2669 and :2941-2946);
9
+ // 2. adds participantId and cyborgHunterVersion to every row (addProperties,
10
+ // once: the only static values);
11
+ // 3. wraps jsPsych.run so the timeline is walked and every trial object gets
12
+ // the per-trial entries BEFORE run() builds its TimelineNode tree
13
+ // (:2691). run is an own bound property (autoBind, :2648), so simulate()
14
+ // goes through the wrapper too;
15
+ // 4. chains initJsPsych's on_finish: the last segment, the *Final totals,
16
+ // friction stop, honeypot session summary and monitor teardown run
17
+ // first, then the researcher's own on_finish (its return value, possibly
18
+ // a promise, is passed back to jsPsych, :2968-2980). With data-replay
19
+ // and a CyborgHunterConfig.replay.autoSave mode other than 'none', the
20
+ // replay recorder's finalize() (serialize + save) is awaited in between.
21
+ // Per-trial segments and running totals are returned by OneLinerExtension's
22
+ // on_finish, merged into each row by jsPsych.
23
+ //
24
+ // Guards on this host. boot does not start them here; the injected guard
25
+ // extensions do, from their initialize(), which jsPsych calls inside run()
26
+ // after the window load event (prepareDom, :2882-2889). Between boot and that
27
+ // moment the monitor already records (the boot span), but the honeypot bait
28
+ // and friction are not active yet.
29
+ //
30
+ // One jsPsych instance per page is assumed: one boot monitor, one segmenter,
31
+ // one session. A second wrapped initJsPsych gets a catalogue warning; each
32
+ // instance's final hook writes to that instance's own data, and the first
33
+ // one to finish ends the session (later rows are left unmarked, see
34
+ // jspsych-extension.js).
35
+ //
36
+ // Nothing here throws into the page: a failure is logged from the catalogue
37
+ // (errors.js) and jsPsych runs as if ch.js were absent from that step on.
38
+
39
+ import { VERSION } from '../../shared/constants.js';
40
+ import { MESSAGES } from '../errors.js';
41
+ import { OneLinerExtension } from './jspsych-extension.js';
42
+
43
+ var CH_NAME = 'cyborg-hunter';
44
+ var HONEYPOT_NAME = 'guard-honeypot';
45
+ var FRICTION_NAME = 'guard-friction';
46
+ var REPLAY_NAME = 'cyborg-hunter-replay';
47
+ var ENTRY_TRIAL_LABEL = 'guard_friction_entry'; // GuardFriction.createEntryTrial's data label
48
+
49
+ function message(e) { return String((e && e.message) || e); }
50
+ function own(obj, key) { return Object.prototype.hasOwnProperty.call(obj, key); }
51
+
52
+ function nameOf(entry) {
53
+ return entry && entry.type && entry.type.info ? entry.type.info.name : undefined;
54
+ }
55
+
56
+ export function isChType(type) {
57
+ return !!(type && type.info && type.info.name === CH_NAME);
58
+ }
59
+
60
+ // Keeps the first entry per type.info.name. Entries without a name are kept
61
+ // as they are (jsPsych itself will report them).
62
+ export function dedupeExtensions(list) {
63
+ var seen = Object.create(null);
64
+ var out = [];
65
+ (list || []).forEach(function (entry) {
66
+ var name = nameOf(entry);
67
+ if (name === undefined) { out.push(entry); return; }
68
+ if (seen[name]) return;
69
+ seen[name] = true;
70
+ out.push(entry);
71
+ });
72
+ return out;
73
+ }
74
+
75
+ // Adds each of `entries` to every trial object of `timeline` that does not
76
+ // already list an extension of that name.
77
+ //
78
+ // jsPsych (:2160-2199) treats an object with `timeline` as a node and passes
79
+ // its other keys (type, extensions, data, ...) down to each child with a
80
+ // shallow Object.assign, so a child's own `extensions` replaces the parent's.
81
+ // The walk therefore carries the inherited type and extensions down, touches
82
+ // only trial objects (no `timeline`), and never wraps anything (a wrapper node
83
+ // would shift internal_node_id). A trial that inherits its parent's list gets
84
+ // its own copy of it plus ours.
85
+ //
86
+ // Rules: (a) a trial already listing a cyborg-hunter entry keeps it and gets
87
+ // no second one for that name; (b) an object reached twice (the same trial in
88
+ // two places) is handled once; a non-array `extensions` is left alone with a
89
+ // warning. A cyborg-hunter entry with null/undefined params gets `params: {}`.
90
+ //
91
+ // Each trial gets its own shallow copy of an entry and of its params:
92
+ // OneLinerExtension ties an on_load to its trial by the params object (see
93
+ // jspsych-extension.js). jsPsych 7.3.1 already deep-copies a trial before
94
+ // running it (TimelineNode.trial(), :2218-2222); the copy here does not rely
95
+ // on that. The copies are remembered across calls, so a timeline run twice
96
+ // does not count them as a researcher's own entry.
97
+ var injectedCopies = new WeakSet();
98
+
99
+ export function injectExtensions(timeline, entries, seen) {
100
+ seen = seen || new WeakSet();
101
+ var result = { trials: 0, skippedOwnEntry: 0, sharedObjects: 0, entryTrialFound: false };
102
+ var warnedFrozen = false;
103
+
104
+ function copyOf(entry) {
105
+ var c = Object.assign({}, entry);
106
+ if (entry.params && typeof entry.params === 'object') c.params = Object.assign({}, entry.params);
107
+ injectedCopies.add(c);
108
+ return c;
109
+ }
110
+
111
+ function walkList(list, inherited) {
112
+ if (!Array.isArray(list)) return;
113
+ for (var i = 0; i < list.length; i++) walkNode(list[i], inherited);
114
+ }
115
+
116
+ function walkNode(node, inherited) {
117
+ if (!node || typeof node !== 'object') return;
118
+ if (seen.has(node)) { result.sharedObjects += 1; return; }
119
+ seen.add(node);
120
+ var hasOwnList = own(node, 'extensions');
121
+ var type = own(node, 'type') ? node.type : inherited.type;
122
+ var list = hasOwnList ? node.extensions : inherited.extensions;
123
+ if (node.timeline !== undefined) {
124
+ walkList(node.timeline, { type: type, extensions: list });
125
+ return;
126
+ }
127
+ if (type === undefined) return; // not a trial; jsPsych reports a missing type itself
128
+ if (list !== undefined && !Array.isArray(list)) {
129
+ console.warn('[cyborg-hunter] a trial\'s extensions is not an array, so ch.js left that trial unmonitored');
130
+ return;
131
+ }
132
+ result.trials += 1;
133
+ if (node.data && node.data.trial_type_label === ENTRY_TRIAL_LABEL) result.entryTrialFound = true;
134
+
135
+ list = list || [];
136
+ // A researcher's own cyborg-hunter entry written without params (the
137
+ // manual docs' per-trial loop) would reach on_start and on_load as
138
+ // `undefined` on every trial, so a late load callback could pass for this
139
+ // trial's own. Give it a params object; params a researcher wrote are
140
+ // never touched. A frozen entry stays as it is (the write would throw and
141
+ // leave the rest of the timeline unmonitored).
142
+ list.forEach(function (e) {
143
+ if (!e || !isChType(e.type) || e.params != null) return;
144
+ try { e.params = {}; } catch (err) { /* frozen or sealed: left params-less */ }
145
+ });
146
+ var present = list.map(nameOf);
147
+ // Counted only for a researcher's own entry, not one of ours already
148
+ // pushed into an extensions array that several trials share.
149
+ if (list.some(function (e) { return e && isChType(e.type) && !injectedCopies.has(e); })) {
150
+ result.skippedOwnEntry += 1;
151
+ }
152
+ var missing = entries.filter(function (e) { return present.indexOf(nameOf(e)) === -1; });
153
+ if (missing.length === 0) return;
154
+ missing = missing.map(copyOf);
155
+ // A frozen or sealed trial object (or extensions array) throws in strict
156
+ // mode; that trial stays unmonitored and the walk goes on. The shipped
157
+ // bundle is not strict, where the same write fails silently, so check
158
+ // that it took.
159
+ try {
160
+ if (hasOwnList) Array.prototype.push.apply(node.extensions, missing);
161
+ else node.extensions = list.concat(missing);
162
+ if (!Array.isArray(node.extensions) || node.extensions.indexOf(missing[0]) === -1) {
163
+ throw new Error('trial object not writable');
164
+ }
165
+ } catch (err) {
166
+ result.trials -= 1;
167
+ if (!warnedFrozen) {
168
+ warnedFrozen = true;
169
+ console.warn('[cyborg-hunter] a trial object could not be changed (frozen or sealed), so ch.js left it unmonitored');
170
+ }
171
+ }
172
+ }
173
+
174
+ walkList(timeline, { type: undefined, extensions: undefined });
175
+ return result;
176
+ }
177
+
178
+ // Adds { type: OneLinerExtension, params: {} } to initJsPsych options whose
179
+ // extensions list (an array, or absent) has no 'cyborg-hunter' entry. jsPsych
180
+ // 7.3.1 calls this.extensions[name].on_start / on_load for every entry a
181
+ // trial lists (:3027-3030, :3050-3053), so a researcher trial typed
182
+ // jsPsychCyborgHunter needs an instance of that name registered, or it
183
+ // throws. A non-array list is left for jsPsych to report. Never throws.
184
+ function ensureChEntry(options) {
185
+ try {
186
+ var list = options.extensions;
187
+ if (list === undefined || list === null) list = [];
188
+ if (!Array.isArray(list)) return;
189
+ if (list.some(function (e) { return nameOf(e) === CH_NAME; })) return;
190
+ options.extensions = list.concat([{ type: OneLinerExtension, params: {} }]);
191
+ } catch (_) { /* jsPsych runs as it would without ch.js */ }
192
+ }
193
+
194
+ // What ch.js leaves on initJsPsych when it does not monitor: boot failed
195
+ // (boot.js fail()), the deferred session start failed (failDeferred, after
196
+ // restore()), or ch.js stood down after cyborg-hunter.min.js. Only
197
+ // ensureChEntry: OneLinerExtension.ctx is never set from here, so the entry
198
+ // is inert (no rotate, on_finish returns {}). Installed once, and only over a
199
+ // function (a page without jsPsych has nothing to wrap).
200
+ export function installInertWrapper(win) {
201
+ var orig = win.initJsPsych;
202
+ if (typeof orig !== 'function' || orig.__cyborgHunterInert) return;
203
+ var wrapped = function (options) {
204
+ options = options || {};
205
+ ensureChEntry(options);
206
+ return orig.apply(this, [options].concat(Array.prototype.slice.call(arguments, 1)));
207
+ };
208
+ wrapped.__cyborgHunterInert = true;
209
+ win.initJsPsych = wrapped;
210
+ }
211
+
212
+ // Manual mode (the researcher wires the cyborg-hunter extension themselves):
213
+ // an initJsPsych entry named 'cyborg-hunter' whose class is not ours. Listing
214
+ // OneLinerExtension itself is still the one-liner.
215
+ export function detectManualMode(options) {
216
+ var list = options && options.extensions;
217
+ if (!Array.isArray(list)) return false;
218
+ return list.some(function (e) { return !!e && isChType(e.type) && e.type !== OneLinerExtension; });
219
+ }
220
+
221
+ // Manual mode: the researcher's extension creates its own monitor, so ch.js
222
+ // hands over: its boot span is closed without a segment and its monitor is
223
+ // destroyed (two monitors would double-count every event).
224
+ //
225
+ // The researcher's extension calls window.CyborgHunter.init(), which is
226
+ // ch.js's namespace whether or not cyborg-hunter.min.js was loaded after
227
+ // ch.js (min.js's footer puts ch.js's namespace back; build.js). Its init()
228
+ // returns a core monitor once ctx.host is 'manual' (api.js), so the manual
229
+ // wiring works either way.
230
+ function handOver(ctx) {
231
+ ctx.host = 'manual';
232
+ try { ctx.segmenter.abandon(); } catch (_) { /* the hand-over continues */ }
233
+ try { ctx.monitor.destroy(); } catch (_) { /* already destroyed */ }
234
+ console.info('[cyborg-hunter] manual mode: initJsPsych lists a cyborg-hunter extension, so ch.js injects nothing; finalize() is still required.');
235
+ // run() is never wrapped in manual mode, so this is the page's one summary.
236
+ try { if (ctx.debug && ctx.debug.update) ctx.debug.update(); } catch (_) { /* a debug aid */ }
237
+ }
238
+
239
+ // The end of the session, from the chained on_finish of `jsPsych` (the
240
+ // instance that finished, not necessarily the latest one wrapped). Runs once;
241
+ // never throws. Friction is stopped before the honeypot writes its session
242
+ // summary, so a violation still open at the end is closed first
243
+ // (extension-guard-honeypot.js).
244
+ function runFinalHook(ctx, win, has, jsPsych) {
245
+ if (ctx.jspsych.finalized) return;
246
+ ctx.jspsych.finalized = true;
247
+ var problems = [];
248
+ var marker = null;
249
+ function step(fn) {
250
+ try { fn(); } catch (e) { problems.push(message(e)); }
251
+ }
252
+ step(function () {
253
+ var r = ctx.segmenter.finish({ source: 'final' });
254
+ if (r && r.segment) {
255
+ var seg = r.segment;
256
+ jsPsych.data.addDataToLastTrial({ integritySegmentFinal: seg });
257
+ jsPsych.data.addProperties({
258
+ integrityPasteCountFinal: seg.counters.pasteCount,
259
+ integrityCopyCountFinal: seg.counters.copyCount,
260
+ integrityDropCountFinal: seg.counters.dropCount,
261
+ integritySoftScoreFinal: seg.score.softScore,
262
+ integrityAnyHardTriggeredFinal: seg.score.anyHardTriggered
263
+ });
264
+ }
265
+ // The segmenter has already logged its own failure.
266
+ if (r && r.error) marker = r.error;
267
+ });
268
+ // Whenever friction holds a token, not only when ch.js injected friction:
269
+ // the entry trial starts friction from its own on_finish timer even when
270
+ // data-guards does not enable it, and its intervals and curtain would
271
+ // outlive the experiment.
272
+ step(function () {
273
+ var token = win._guardFrictionToken;
274
+ if (win.GuardFriction && token) win.GuardFriction.stop(token);
275
+ });
276
+ if (has(HONEYPOT_NAME)) {
277
+ step(function () {
278
+ if (win.GuardHoneypot) win.GuardHoneypot.attachToJsPsychData();
279
+ });
280
+ }
281
+ step(function () { if (!ctx.bootError) ctx.monitor.destroy(); }); // failDeferred destroyed it
282
+
283
+ if (problems.length) {
284
+ console.error(MESSAGES.sessionEndFailed(problems.join('; ')));
285
+ marker = marker ? marker + '; ' + problems.join('; ') : problems.join('; ');
286
+ }
287
+ if (marker) {
288
+ try { jsPsych.data.addProperties({ cyborgHunterError: marker }); } catch (_) { /* logged above */ }
289
+ }
290
+ return finalizeReplay(ctx, jsPsych);
291
+ }
292
+
293
+ var FINALIZE_TIMEOUT_MS = 15000;
294
+
295
+ // The replay recorder's own save (DataPipe, CyborgHunterConfig.replay
296
+ // .autoSave), before the researcher's on_finish saves the data, so the rows
297
+ // carry its integrityReplayMeta (the order manual mode documents). With the
298
+ // default mode 'none' nothing happens here: the researcher's save code calls
299
+ // CyborgHunter.replay(). Only the proxy ch.js listed is finalized; a
300
+ // researcher's own replay entry is theirs to finalize. → a promise or null.
301
+ function finalizeReplay(ctx, jsPsych) {
302
+ var r = ctx.config.replay;
303
+ if (!r || !r.autoSave || !r.autoSave.mode || r.autoSave.mode === 'none' || !ctx.replayProxy) return null;
304
+ try {
305
+ var ext = jsPsych.extensions && jsPsych.extensions[REPLAY_NAME];
306
+ if (!(ext instanceof ctx.replayProxy)) return null;
307
+ // finalize() never throws (extension-cyborg-hunter-replay.js); the catch is for the host's sake.
308
+ // Bounded: a save that never settles must not hold back the researcher's
309
+ // own save and redirect.
310
+ var timer = null;
311
+ var timeout = new Promise(function (resolve) {
312
+ timer = setTimeout(function () {
313
+ console.warn(MESSAGES.replayFinalizeTimedOut());
314
+ resolve();
315
+ }, ctx.replayFinalizeTimeoutMs || FINALIZE_TIMEOUT_MS);
316
+ });
317
+ var done = Promise.resolve(ext.finalize()).catch(function (e) {
318
+ console.error(MESSAGES.sessionEndFailed('replay: ' + message(e)));
319
+ });
320
+ return Promise.race([done, timeout]).then(function () { clearTimeout(timer); });
321
+ } catch (e) {
322
+ console.error(MESSAGES.sessionEndFailed('replay: ' + message(e)));
323
+ return null;
324
+ }
325
+ }
326
+
327
+ // installJsPsychAdapter({ win, ctx }) → { restore() }
328
+ // ctx: boot's context; gains ctx.jspsych = { invoked, instrumented,
329
+ // entryTrialFound, segmentsWritten, finalized }, ctx.jsPsych (the instance)
330
+ // and, in manual mode, ctx.host = 'manual'.
331
+ export function installJsPsychAdapter(opts) {
332
+ var win = opts.win, ctx = opts.ctx;
333
+ var orig = win.initJsPsych;
334
+ ctx.jspsych = { invoked: false, instrumented: 0, entryTrialFound: false, segmentsWritten: 0, finalized: false };
335
+
336
+ win.initJsPsych = function (options) {
337
+ if (ctx.jspsych.invoked) console.warn(MESSAGES.secondJsPsychInstance());
338
+ ctx.jspsych.invoked = true;
339
+ options = options || {};
340
+ var manual = false;
341
+ var ours = null;
342
+ var frictionEntry = null;
343
+ try {
344
+ if (detectManualMode(options)) {
345
+ manual = true;
346
+ handOver(ctx);
347
+ } else {
348
+ ours = [{ type: OneLinerExtension, params: {} }];
349
+ if (ctx.config.guards.honeypot && win.jsPsychGuardHoneypot) {
350
+ ours.push({ type: win.jsPsychGuardHoneypot, params: {} });
351
+ }
352
+ if (ctx.config.guards.friction && win.jsPsychGuardFriction) {
353
+ // Observe-only unless the timeline has the entry trial (decided in
354
+ // run() below, before jsPsych reads the params in loadExtensions).
355
+ frictionEntry = { type: win.jsPsychGuardFriction, params: { observeOnly: true } };
356
+ ours.push(frictionEntry);
357
+ }
358
+ if (ctx.config.replay && ctx.replayProxy) ours.push({ type: ctx.replayProxy, params: ctx.config.replay });
359
+ options.extensions = dedupeExtensions((options.extensions || []).concat(ours));
360
+ }
361
+ } catch (e) {
362
+ console.error(MESSAGES.hookFailed(message(e)));
363
+ // The inert entry the researcher's jsPsychCyborgHunter trials need
364
+ // (OneLinerExtension.ctx is not wired on this path).
365
+ ensureChEntry(options);
366
+ return orig(options);
367
+ }
368
+ if (manual) return orig(options);
369
+
370
+ // The entries jsPsych will instantiate, one per name (a researcher's own
371
+ // copy of a guard extension wins over ours, first entry per name).
372
+ var listed = options.extensions;
373
+ var ourNames = ours.map(nameOf);
374
+ var injected = listed.filter(function (e) { return ourNames.indexOf(nameOf(e)) !== -1; });
375
+ function has(name) { return injected.some(function (e) { return nameOf(e) === name; }); }
376
+ // Friction listed at all, ours or the researcher's own entry: either one
377
+ // sets friction up for the entry trial.
378
+ var frictionListed = listed.some(function (e) { return nameOf(e) === FRICTION_NAME; });
379
+
380
+ // jsPsych calls on_finish as this.opts.on_finish(...) (:2969), so `this`
381
+ // is the options object: the instance is captured below, per call.
382
+ var jsPsych = null;
383
+ var userFinish = options.on_finish;
384
+ options.on_finish = function () {
385
+ var self = this, args = arguments;
386
+ var pending = runFinalHook(ctx, win, has, jsPsych);
387
+ function researcherFinish() {
388
+ return typeof userFinish === 'function' ? userFinish.apply(self, args) : undefined;
389
+ }
390
+ return pending ? pending.then(researcherFinish) : researcherFinish();
391
+ };
392
+
393
+ jsPsych = orig(options);
394
+ try {
395
+ OneLinerExtension.ctx = ctx;
396
+ ctx.jsPsych = jsPsych;
397
+ jsPsych.data.addProperties({ participantId: ctx.participantId, cyborgHunterVersion: VERSION });
398
+ var origRun = jsPsych.run;
399
+ jsPsych.run = function (timeline) {
400
+ try {
401
+ var r = injectExtensions(timeline, injected);
402
+ ctx.jspsych.instrumented = r.trials;
403
+ ctx.jspsych.entryTrialFound = r.entryTrialFound;
404
+ // Shared with the initJsPsych list, which loadExtensions reads
405
+ // after this.
406
+ if (frictionEntry) frictionEntry.params.observeOnly = !r.entryTrialFound;
407
+ if (r.entryTrialFound && !frictionListed) console.warn(MESSAGES.frictionEntryWithoutFriction());
408
+ } catch (e) {
409
+ console.error(MESSAGES.instrumentFailed(message(e)));
410
+ }
411
+ // The page's one summary, even when the walk failed (counts what it got).
412
+ try { if (ctx.debug && ctx.debug.update) ctx.debug.update(); } catch (_) { /* a debug aid */ }
413
+ return origRun.apply(this, arguments);
414
+ };
415
+ } catch (e) {
416
+ console.error(MESSAGES.hookFailed(message(e)));
417
+ }
418
+ return jsPsych;
419
+ };
420
+
421
+ return { restore: function () { win.initJsPsych = orig; } };
422
+ }
423
+
424
+ // Placement diagnosis, registered by boot on every host.
425
+ // jsPsych host: if jsPsych starts running without initJsPsych having gone
426
+ // through the wrapper, ch.js was loaded after the experiment code called
427
+ // it. jsPsych marks <html jspsych="present"> only once run() is past the
428
+ // window load event and extension loading (:2693-2696), so the check
429
+ // watches for that attribute rather than DOMContentLoaded. Then the
430
+ // wrapper is removed, ctx.host becomes 'vanilla' and onVanilla() (from
431
+ // boot) starts the guards and the vanilla adapter.
432
+ // vanilla host: if initJsPsych appears by DOMContentLoaded, ch.js was
433
+ // loaded above jspsych.js. Otherwise, if jsPsych starts running anyway
434
+ // (the same attribute), the page built jsPsych from a bundler or ES
435
+ // module, which never defines window.initJsPsych: notHookable, once.
436
+ // ctx.host stays 'vanilla' either way.
437
+ export function watchHostPlacement(opts) {
438
+ var win = opts.win, doc = opts.doc, ctx = opts.ctx, adapter = opts.adapter;
439
+ var onVanilla = opts.onVanilla;
440
+ if (!doc || !doc.documentElement) return;
441
+ if (ctx.host === 'jspsych') {
442
+ var root = doc.documentElement;
443
+ var notHookable = function () {
444
+ // bootError: the deferred session start failed and removed the wrap.
445
+ if (ctx.bootError || (ctx.jspsych && ctx.jspsych.invoked)) return;
446
+ console.error(MESSAGES.notHookable());
447
+ ctx.host = 'vanilla';
448
+ if (adapter) adapter.restore();
449
+ if (onVanilla) onVanilla();
450
+ };
451
+ if (root.hasAttribute('jspsych')) { notHookable(); return; }
452
+ if (typeof win.MutationObserver !== 'function') return;
453
+ var mo = new win.MutationObserver(function () {
454
+ if (!root.hasAttribute('jspsych')) return;
455
+ mo.disconnect();
456
+ notHookable();
457
+ });
458
+ mo.observe(root, { attributes: true, attributeFilter: ['jspsych'] });
459
+ } else {
460
+ var reported = false;
461
+ if (doc.readyState === 'loading') {
462
+ doc.addEventListener('DOMContentLoaded', function () {
463
+ if (reported || typeof win.initJsPsych !== 'function') return;
464
+ reported = true;
465
+ console.error(MESSAGES.loadedAboveJsPsych());
466
+ }, { once: true });
467
+ }
468
+ var vroot = doc.documentElement;
469
+ var bundled = function () {
470
+ if (reported || ctx.bootError || ctx.host === 'manual') return;
471
+ reported = true;
472
+ console.error(MESSAGES.notHookable());
473
+ };
474
+ if (vroot.hasAttribute('jspsych')) { bundled(); return; }
475
+ if (typeof win.MutationObserver !== 'function') return;
476
+ var vmo = new win.MutationObserver(function () {
477
+ if (!vroot.hasAttribute('jspsych')) return;
478
+ vmo.disconnect();
479
+ bundled();
480
+ });
481
+ vmo.observe(vroot, { attributes: true, attributeFilter: ['jspsych'] });
482
+ }
483
+ }