cyborg-hunter 0.9.1 → 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.
Files changed (37) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/CITATION.cff +1 -1
  3. package/README.md +33 -32
  4. package/dist/ch.js +41 -0
  5. package/dist/cyborg-hunter-replay.js +3 -3
  6. package/dist/cyborg-hunter.esm.js +1 -1
  7. package/dist/cyborg-hunter.min.js +4 -3
  8. package/dist/extension-cyborg-hunter.js +1 -1
  9. package/dist/extension-guard-friction.js +5 -5
  10. package/dist/extension-guard-honeypot.js +1 -1
  11. package/package.json +11 -5
  12. package/src/cli/extract-core.js +70 -4
  13. package/src/cli/renderers/replay-client-source.js +2 -2
  14. package/src/cli/segment-reassembly.js +204 -0
  15. package/src/jspsych/extension-cyborg-hunter-replay.js +1 -1
  16. package/src/jspsych/extension-cyborg-hunter.js +1 -1
  17. package/src/jspsych/extension-guard-friction.js +21 -7
  18. package/src/jspsych/extension-guard-honeypot.js +13 -6
  19. package/src/oneliner/adapters/jspsych-extension.js +196 -0
  20. package/src/oneliner/adapters/jspsych.js +483 -0
  21. package/src/oneliner/adapters/vanilla.js +420 -0
  22. package/src/oneliner/api.js +151 -0
  23. package/src/oneliner/boot.js +270 -0
  24. package/src/oneliner/config.js +122 -0
  25. package/src/oneliner/debug.js +137 -0
  26. package/src/oneliner/entry.js +8 -0
  27. package/src/oneliner/errors.js +219 -0
  28. package/src/oneliner/guards.js +67 -0
  29. package/src/oneliner/participant-id.js +49 -0
  30. package/src/oneliner/replay-loader.js +296 -0
  31. package/src/oneliner/segment-diff.js +68 -0
  32. package/src/oneliner/segmenter.js +186 -0
  33. package/src/replay/dom-instantiate.js +10 -1
  34. package/src/replay/index.js +4 -2
  35. package/src/replay/recorder.js +5 -1
  36. package/src/shared/constants.js +1 -1
  37. package/src/shared/schema-v2-validator.js +14 -9
@@ -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
+ }
@@ -616,7 +616,7 @@ function mountTree(domNode, body, doc) {
616
616
  //
617
617
  // TOLERANT, COUNTED, SURFACED (design §4). A patch naming an id the map does
618
618
  // not hold is skipped and counted into `patchFailures`, never thrown on. This
619
- // is a DELIBERATE divergence from `tests/replay/support/dom-player.js`, which
619
+ // is a DELIBERATE divergence from `packages/sessionrecording-conformance/src/fuzz/dom-player.js`, which
620
620
  // throws on the same input: the test player's job is to catch mapper bugs, the
621
621
  // viewer's job is to show an analyst as much of an unrepeatable session as
622
622
  // survives. The consequence is recorded and routed — a tolerant viewer cannot
@@ -715,6 +715,15 @@ function applyAttr(patch, mount) {
715
715
  el.removeAttribute(name);
716
716
  return;
717
717
  }
718
+ // An iframe placeholder never receives the IFRAME_SKIP names after mount
719
+ // either, or a later patch could re-arm what instantiation disarmed. The
720
+ // marker is viewer-owned on both verbs, so a recording can neither forge nor
721
+ // strip it, and checking it here is checking what instantiation decided.
722
+ // Silent and uncounted, like the viewer-owned refusals. Defence in depth:
723
+ // the viewer's `frame-src 'none'` CSP already blocks the load. SET verb
724
+ // only — the placeholder carries no label a removal could strip.
725
+ if (el.getAttribute(PLACEHOLDER_ATTR) === 'iframe'
726
+ && IFRAME_SKIP[name.toLowerCase()] === true) return;
718
727
  // ONE §12 gate on the set path, and it is the gate instantiation uses, so a
719
728
  // keyframe and a patch can never disagree about what an element may carry.
720
729
  setFilteredAttr(el, name, patch.value, mount);
@@ -181,7 +181,9 @@ export function attach(userConfig) {
181
181
  export { replayFilename, buildReplayMeta, serialize };
182
182
 
183
183
  // Browser global — same pattern as the guard extensions (explicit window
184
- // assignment; no esbuild globalName).
184
+ // assignment; no esbuild globalName). replayFilename rides along so a
185
+ // researcher saving the recording to their own server names it exactly as
186
+ // the CLI expects (sanitized participant id), without an ES import.
185
187
  if (typeof window !== 'undefined') {
186
- window.CyborgHunterReplay = { attach: attach };
188
+ window.CyborgHunterReplay = { attach: attach, replayFilename: replayFilename };
187
189
  }
@@ -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
- if (config.autoSave.mode === 'none') {
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
  },
@@ -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.9.1";
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.
@@ -1,6 +1,11 @@
1
+ // GENERATED from packages/sessionrecording-conformance/src/validator.js — do not edit; run npm run sync:conformance
2
+ //
3
+ // The conformance package is the source of truth. CH ships this copy so the
4
+ // published tarball can validate without resolving a workspace package.
5
+
1
6
  // SessionRecording v2 validator — dual profiles per spec §11.
2
- // Zero dependencies. Lifted from tests/replay/schema-v2/ into shipped code (A3 fix
3
- // round): ingest strict-validates converted recordings in-process (spec §11 A2 —
7
+ // Zero dependencies. Lifted from tests/replay/schema-v2/ into shipped code:
8
+ // ingest strict-validates converted recordings in-process (spec §11 A2 —
4
9
  // warn, never refuse), so the validator is a runtime dependency of the CLI.
5
10
 
6
11
  // Gzip magic bytes (RFC 1952): 0x1f 0x8b.
@@ -50,8 +55,8 @@ export function validateTolerant(input) {
50
55
  }
51
56
  if (errors.length) return { ok: false, recording: null, errors, warnings };
52
57
  for (const k of ADVISORY_TOP) if (!(k in obj)) warnings.push(`missing advisory field: ${k}`);
53
- // WARN ON MALFORMED KNOWN FIELDS — WARN, NEVER COERCE (T7 settles this; it
54
- // was parked from two reviews). Before this, a present-but-malformed known
58
+ // WARN ON MALFORMED KNOWN FIELDS — WARN, NEVER COERCE (settled here after
59
+ // being parked from two reviews). Before this, a present-but-malformed known
55
60
  // field (`stylesheets: null`, `truncated: "yes"`) passed through in total
56
61
  // silence: defaults fill ABSENT keys only, and the analyst opening the file
57
62
  // got no signal at all. Coercing is the wrong repair — overwriting
@@ -253,8 +258,8 @@ export function validateStrict(input) {
253
258
  errors.push('end_reason must be "finished" | "aborted" | "unload" | null');
254
259
  }
255
260
  if (typeof r.truncated !== 'boolean') errors.push('truncated must be a boolean');
256
- // `host` appeared ONCE in this file before T7 — in TOP_DEFAULTS — so
257
- // `host: {name: 42}` was strict-valid (T3 Task-7).
261
+ // `host` used to appear ONCE in this file — in TOP_DEFAULTS — so
262
+ // `host: {name: 42}` was strict-valid.
258
263
  if (r.host !== null && !(isPlainObject(r.host)
259
264
  && typeof r.host.name === 'string' && typeof r.host.version === 'string')) {
260
265
  errors.push('host must be {name, version} strings or null');
@@ -383,7 +388,7 @@ export const REDACTABLE_TYPES = new Set([
383
388
  'clipboard.copy', 'clipboard.cut', 'clipboard.paste', 'clipboard.drop',
384
389
  ]);
385
390
 
386
- // ── §5.3 clipboard: REQUIRED-BUT-NULLABLE, settled here (T7) ───────────────
391
+ // ── §5.3 clipboard: REQUIRED-BUT-NULLABLE, settled here ────────────────────
387
392
  // The parked question was whether §5.3's four fields are required-but-nullable
388
393
  // or typed-only-if-present. Settled as REQUIRED, for one reason that is not
389
394
  // about strictness for its own sake: §5.3 defines its TWO PRODUCER MODES by
@@ -484,8 +489,8 @@ function checkSegmentsStrict(r, errors) {
484
489
  if (s.initial_dom != null && !isKeyframe) {
485
490
  errors.push(`${at}.initial_dom must be a DomNode object or null`);
486
491
  }
487
- // The keyframe tree itself, recursively (spec §4). Until T7 the validator
488
- // stopped at "is an object": a keyframe whose children were strings, or
492
+ // The keyframe tree itself, recursively (spec §4). The validator used to
493
+ // stop at "is an object": a keyframe whose children were strings, or
489
494
  // whose ids were absent, was strict-valid and only failed inside a player.
490
495
  if (isKeyframe) checkDomNode(s.initial_dom, `${at}.initial_dom`, errors, 1);
491
496
  if (isKeyframe) sawKeyframe = true;