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,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
|
+
}
|