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,68 @@
|
|
|
1
|
+
// src/oneliner/segment-diff.js
|
|
2
|
+
// Turns the monitor's cumulative session report into per-segment deltas.
|
|
3
|
+
// The session arrays are append-only (every write in src/core is a .push), so
|
|
4
|
+
// "what happened since the last cut" is report[key].slice(lastSeen[key]).
|
|
5
|
+
// Keys are discovered at every cut, not fixed at build time, so a signal added
|
|
6
|
+
// to the core later flows through without touching this file.
|
|
7
|
+
//
|
|
8
|
+
// createSegmentDiffer(monitor) → { cut(meta) → segment, seen() → {[key]: number} }
|
|
9
|
+
// monitor: anything with getSessionReport() and getSessionScore()
|
|
10
|
+
// meta: { segmentIndex, source: 'manual'|'host'|'page'|'final', trialId,
|
|
11
|
+
// pageOrigin, trialReport?, gapReports? }
|
|
12
|
+
// segment: { segmentIndex, source, trialId, pageOrigin,
|
|
13
|
+
// deltas: { [arrayKey]: any[] }, // every array key except aliases
|
|
14
|
+
// counters: { pasteCount, copyCount, dropCount },
|
|
15
|
+
// score: getSessionScore() verbatim,
|
|
16
|
+
// gap?: [...], // non-empty gap reports only
|
|
17
|
+
// config?, libraryVersion? } // segmentIndex === 0 only
|
|
18
|
+
//
|
|
19
|
+
// The CLI side (src/cli/segment-reassembly.js) concatenates the deltas back
|
|
20
|
+
// into the session shape finalize() dumps.
|
|
21
|
+
|
|
22
|
+
// Keys that are the same array as another key inside the monitor (monitor.js
|
|
23
|
+
// keeps layoutShifts as a deprecated alias of viewportWidthShifts). Skipped on
|
|
24
|
+
// the way out so the entries are not shipped twice; restored on the way in
|
|
25
|
+
// (src/cli/segment-reassembly.js keeps a copy; a test pins the two equal).
|
|
26
|
+
export const ALIAS_KEYS = { layoutShifts: 'viewportWidthShifts' };
|
|
27
|
+
|
|
28
|
+
export function createSegmentDiffer(monitor) {
|
|
29
|
+
var lastSeen = {};
|
|
30
|
+
function arrayKeys(report) {
|
|
31
|
+
return Object.keys(report).filter(function (k) {
|
|
32
|
+
return Array.isArray(report[k]) && !(k in ALIAS_KEYS);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
// A gap report covers the time between two host trials. Most are empty;
|
|
36
|
+
// only those with paste/copy/drop/synthetic-insertion evidence are kept,
|
|
37
|
+
// and only those four arrays plus the duration (no mouse trace).
|
|
38
|
+
function nonEmptyGap(r) {
|
|
39
|
+
var keys = ['pasteEvents', 'copyEvents', 'dropEvents', 'syntheticInsertions'];
|
|
40
|
+
var any = keys.some(function (k) { return Array.isArray(r[k]) && r[k].length > 0; });
|
|
41
|
+
if (!any) return null;
|
|
42
|
+
var out = { duration_ms: r.duration_ms };
|
|
43
|
+
keys.forEach(function (k) { out[k] = r[k] || []; });
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
seen: function () { return Object.assign({}, lastSeen); },
|
|
48
|
+
cut: function (meta) {
|
|
49
|
+
var report = monitor.getSessionReport(); // one deep copy per cut (monitor.js getSessionReport); O(session size)
|
|
50
|
+
var deltas = {};
|
|
51
|
+
arrayKeys(report).forEach(function (k) {
|
|
52
|
+
var from = lastSeen[k] || 0;
|
|
53
|
+
deltas[k] = report[k].slice(from);
|
|
54
|
+
lastSeen[k] = report[k].length;
|
|
55
|
+
});
|
|
56
|
+
var seg = {
|
|
57
|
+
segmentIndex: meta.segmentIndex, source: meta.source, trialId: meta.trialId, pageOrigin: meta.pageOrigin,
|
|
58
|
+
deltas: deltas,
|
|
59
|
+
counters: { pasteCount: report.pasteCount, copyCount: report.copyCount, dropCount: report.dropCount },
|
|
60
|
+
score: monitor.getSessionScore()
|
|
61
|
+
};
|
|
62
|
+
var gaps = (meta.gapReports || []).map(nonEmptyGap).filter(Boolean);
|
|
63
|
+
if (gaps.length) seg.gap = gaps;
|
|
64
|
+
if (meta.segmentIndex === 0) { seg.config = report.config; seg.libraryVersion = report.libraryVersion; }
|
|
65
|
+
return seg;
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// src/oneliner/segmenter.js
|
|
2
|
+
// Keeps the monitor permanently inside a trial. The core attaches its paste,
|
|
3
|
+
// typing, mouse, idle-gap and element-trace listeners only between startTrial
|
|
4
|
+
// and endTrial (monitor.js startTrial), so a paste outside a trial is never
|
|
5
|
+
// recorded. The one-line setup therefore opens a span at boot and, at every
|
|
6
|
+
// boundary, closes the current trial and opens the next one in the same call.
|
|
7
|
+
//
|
|
8
|
+
// createSegmenter({ monitor, differ, clock, sourceDefault }) → {
|
|
9
|
+
// start({ trialId }) open the boot span; no-op if already open
|
|
10
|
+
// → null | { error }
|
|
11
|
+
// rotate(opts) close the span (a "gap"), open the host's trial;
|
|
12
|
+
// no segment; the gap report is buffered for the
|
|
13
|
+
// next cut() and also returned
|
|
14
|
+
// → gapReport | null (nothing was open) | { error }
|
|
15
|
+
// cut({ source, nextTrialId?, nextOpts? }) close + segment + open next
|
|
16
|
+
// → { segment, trialReport } success
|
|
17
|
+
// → { segment, trialReport, error } cut OK, reopen failed
|
|
18
|
+
// → { error } nothing was cut
|
|
19
|
+
// finish({ source }) close + segment, nothing reopened
|
|
20
|
+
// → { segment, trialReport } | null (closed) | { error }
|
|
21
|
+
// abandon() close without a segment (manual-mode hand-over)
|
|
22
|
+
// → null | { error }
|
|
23
|
+
// state() { open, segmentIndex, currentTrialId }
|
|
24
|
+
// setSegmentIndex(n) multi-page restore
|
|
25
|
+
// }
|
|
26
|
+
// differ: createSegmentDiffer(monitor) (src/oneliner/segment-diff.js)
|
|
27
|
+
// clock: () => number, the page origin (performance.timeOrigin); injectable
|
|
28
|
+
//
|
|
29
|
+
// Contract for the host adapters: a result with `segment` must be saved even
|
|
30
|
+
// when it also carries `error` (the data is complete; only the next span failed
|
|
31
|
+
// to open, and the error becomes the cyborgHunterError marker). A result with
|
|
32
|
+
// `error` alone means nothing was cut.
|
|
33
|
+
//
|
|
34
|
+
// None of these throws into the host: a monitor or differ failure is logged
|
|
35
|
+
// and returned as { error: message }. After finish() or abandon() the segmenter
|
|
36
|
+
// is latched: start/rotate/cut return { error: 'finished' | 'abandoned' }
|
|
37
|
+
// without touching the monitor, and finish() returns null.
|
|
38
|
+
//
|
|
39
|
+
// Keeping `open` true to the monitor. The monitor has no state getter, but
|
|
40
|
+
// transition() is the FIRST statement of both startTrial and endTrial
|
|
41
|
+
// (monitor.js), and its rejection message names the current state. So:
|
|
42
|
+
// - any endTrial throw leaves the monitor outside a trial (rejected, or
|
|
43
|
+
// already transitioned) → open = false;
|
|
44
|
+
// - a startTrial throw that is not a lifecycle rejection means the
|
|
45
|
+
// transition happened → the trial IS open (listeners maybe partly
|
|
46
|
+
// attached) → open = true, so cut()/finish() still segment it rather than
|
|
47
|
+
// drop it, and endTrial's removeTrialListeners cleans up whatever attached;
|
|
48
|
+
// - a startTrial rejected "from 'trial'" means a trial we thought closed is
|
|
49
|
+
// still open → close it, keep its report as a gap, and retry once.
|
|
50
|
+
|
|
51
|
+
// Mirrors the message thrown by monitor.js transition(); null when `e` is not
|
|
52
|
+
// a lifecycle rejection.
|
|
53
|
+
var LIFECYCLE_FROM = /invalid lifecycle call: cannot transition from '(\w+)'/;
|
|
54
|
+
function lifecycleFrom(e) {
|
|
55
|
+
var m = LIFECYCLE_FROM.exec(String((e && e.message) || e));
|
|
56
|
+
return m ? m[1] : null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function createSegmenter(opts) {
|
|
60
|
+
var monitor = opts.monitor;
|
|
61
|
+
var differ = opts.differ;
|
|
62
|
+
var clock = opts.clock || function () { return performance.timeOrigin; };
|
|
63
|
+
var sourceDefault = opts.sourceDefault || 'host';
|
|
64
|
+
|
|
65
|
+
var open = false;
|
|
66
|
+
var latched = null; // 'finished' | 'abandoned': the segmenter no longer drives the monitor
|
|
67
|
+
var segmentIndex = 0;
|
|
68
|
+
var currentTrialId = null;
|
|
69
|
+
var gapReports = []; // gap trial reports since the last cut
|
|
70
|
+
|
|
71
|
+
function fail(what, e) {
|
|
72
|
+
var message = String((e && e.message) || e);
|
|
73
|
+
console.error('[cyborg-hunter] ' + what + ' failed: ' + message);
|
|
74
|
+
return { error: message };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// A span the host did not name is called after the segment it will become.
|
|
78
|
+
function spanId() { return 'span-' + segmentIndex; }
|
|
79
|
+
|
|
80
|
+
function closeTrial() {
|
|
81
|
+
try { return monitor.endTrial(); }
|
|
82
|
+
finally { open = false; }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// The explicit trialId always wins: it is assigned last, so a trialId inside
|
|
86
|
+
// `extra` (nextOpts, rotate's opts) can never rename the span.
|
|
87
|
+
function openTrial(trialId, extra) {
|
|
88
|
+
var args = Object.assign({}, extra || {}, { trialId: trialId });
|
|
89
|
+
try {
|
|
90
|
+
monitor.startTrial(args);
|
|
91
|
+
} catch (e) {
|
|
92
|
+
var from = lifecycleFrom(e);
|
|
93
|
+
if (from === 'trial') {
|
|
94
|
+
gapReports.push(closeTrial());
|
|
95
|
+
monitor.startTrial(args);
|
|
96
|
+
} else {
|
|
97
|
+
if (from === null) { currentTrialId = trialId; open = true; }
|
|
98
|
+
throw e;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
currentTrialId = trialId;
|
|
102
|
+
open = true;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Close the open trial and turn everything since the last cut into a segment.
|
|
106
|
+
function closeAndSegment(source) {
|
|
107
|
+
var report = closeTrial();
|
|
108
|
+
var segment = differ.cut({
|
|
109
|
+
segmentIndex: segmentIndex, source: source, trialId: currentTrialId,
|
|
110
|
+
pageOrigin: clock(), trialReport: report, gapReports: gapReports
|
|
111
|
+
});
|
|
112
|
+
gapReports = [];
|
|
113
|
+
segmentIndex += 1;
|
|
114
|
+
return { segment: segment, trialReport: report };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
start: function (o) {
|
|
119
|
+
if (latched) return { error: latched };
|
|
120
|
+
if (open) return null;
|
|
121
|
+
try {
|
|
122
|
+
openTrial((o && o.trialId) || spanId());
|
|
123
|
+
return null;
|
|
124
|
+
} catch (e) { return fail('start', e); }
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
rotate: function (o) {
|
|
128
|
+
if (latched) return { error: latched };
|
|
129
|
+
o = o || {};
|
|
130
|
+
var gap = null;
|
|
131
|
+
try {
|
|
132
|
+
if (open) {
|
|
133
|
+
gap = closeTrial();
|
|
134
|
+
gapReports.push(gap);
|
|
135
|
+
}
|
|
136
|
+
} catch (e) { return fail('trial rotation', e); }
|
|
137
|
+
var extra = Object.assign({}, o);
|
|
138
|
+
delete extra.trialId;
|
|
139
|
+
try {
|
|
140
|
+
openTrial(o.trialId || spanId(), extra);
|
|
141
|
+
} catch (e) { return fail('trial rotation', e); } // the gap, if any, stays buffered
|
|
142
|
+
return gap;
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
cut: function (o) {
|
|
146
|
+
if (latched) return { error: latched };
|
|
147
|
+
o = o || {};
|
|
148
|
+
var out;
|
|
149
|
+
try {
|
|
150
|
+
out = closeAndSegment(o.source || sourceDefault);
|
|
151
|
+
} catch (e) { return fail('segment write', e); }
|
|
152
|
+
try {
|
|
153
|
+
openTrial(o.nextTrialId || spanId(), o.nextOpts);
|
|
154
|
+
} catch (e) { out.error = fail('trial reopen', e).error; }
|
|
155
|
+
return out;
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
finish: function (o) {
|
|
159
|
+
if (latched) return null;
|
|
160
|
+
latched = 'finished';
|
|
161
|
+
if (!open) return null;
|
|
162
|
+
try {
|
|
163
|
+
return closeAndSegment((o && o.source) || 'final');
|
|
164
|
+
} catch (e) { return fail('segment write', e); }
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
// Hands the monitor to the host (manual mode). Gap reports still buffered
|
|
168
|
+
// are intentionally discarded: no cut() follows, and the abandoned spans
|
|
169
|
+
// carry no host trial to attach them to.
|
|
170
|
+
abandon: function () {
|
|
171
|
+
if (latched) return null;
|
|
172
|
+
latched = 'abandoned';
|
|
173
|
+
if (!open) return null;
|
|
174
|
+
try {
|
|
175
|
+
closeTrial();
|
|
176
|
+
return null;
|
|
177
|
+
} catch (e) { return fail('abandon', e); }
|
|
178
|
+
},
|
|
179
|
+
|
|
180
|
+
state: function () {
|
|
181
|
+
return { open: open, segmentIndex: segmentIndex, currentTrialId: currentTrialId };
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
setSegmentIndex: function (n) { segmentIndex = n; }
|
|
185
|
+
};
|
|
186
|
+
}
|
package/src/replay/recorder.js
CHANGED
|
@@ -360,7 +360,11 @@ export function createRecorder(userConfig) {
|
|
|
360
360
|
? { w: de.clientWidth || 0, h: de.clientHeight || 0 } : null;
|
|
361
361
|
session.userAgent = typeof navigator !== 'undefined' && navigator.userAgent
|
|
362
362
|
? String(navigator.userAgent) : '';
|
|
363
|
-
|
|
363
|
+
// _ownerSavesRecording (internal): the embedding library takes the
|
|
364
|
+
// recording itself and tells its user how to save it (the one-line
|
|
365
|
+
// setup's CyborgHunter.replay(), src/oneliner/replay-loader.js), so
|
|
366
|
+
// this warning, which names getRecording(), would only mislead there.
|
|
367
|
+
if (config.autoSave.mode === 'none' && !config._ownerSavesRecording) {
|
|
364
368
|
console.warn('[cyborg-hunter-replay] autoSave.mode is "none" — the recording will be lost unless you call getRecording() yourself.');
|
|
365
369
|
}
|
|
366
370
|
},
|
package/src/shared/constants.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Default thresholds and preset configurations for Cyborg Hunter.
|
|
3
3
|
// Single source of truth — used by both the browser library and CLI report tool.
|
|
4
4
|
|
|
5
|
-
export const VERSION = "0.
|
|
5
|
+
export const VERSION = "0.10.0";
|
|
6
6
|
|
|
7
7
|
// Default detection thresholds shared across presets.
|
|
8
8
|
// Researchers can override any value at init() time.
|