cyborg-hunter 0.7.4 → 0.8.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 (39) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/CITATION.cff +2 -2
  3. package/README.md +21 -6
  4. package/dist/cyborg-hunter-replay.js +3 -3
  5. package/dist/cyborg-hunter.esm.js +3 -2
  6. package/dist/cyborg-hunter.min.js +3 -3
  7. package/dist/extension-cyborg-hunter.js +1 -1
  8. package/dist/extension-guard-friction.js +5 -5
  9. package/package.json +10 -2
  10. package/src/cli/ingest.js +311 -57
  11. package/src/cli/renderers/html-index-core.js +66 -12
  12. package/src/cli/renderers/html-index.js +7 -5
  13. package/src/cli/renderers/replay-assets.js +61 -7
  14. package/src/cli/renderers/replay-client-source.js +102 -0
  15. package/src/cli/renderers/replay-viewer.client.js +1750 -595
  16. package/src/cli/report.js +6 -0
  17. package/src/core/monitor.js +12 -13
  18. package/src/jspsych/extension-cyborg-hunter-replay.js +64 -3
  19. package/src/jspsych/extension-cyborg-hunter.js +1 -1
  20. package/src/jspsych/extension-guard-friction.js +59 -1
  21. package/src/replay/capture-dom.js +470 -458
  22. package/src/replay/capture-trace.js +688 -271
  23. package/src/replay/delivery.js +82 -0
  24. package/src/replay/dom-instantiate.js +779 -0
  25. package/src/replay/index.js +88 -5
  26. package/src/replay/initial-state.js +295 -0
  27. package/src/replay/mutations.js +668 -0
  28. package/src/replay/node-registry.js +116 -0
  29. package/src/replay/persistence.js +19 -6
  30. package/src/replay/recorder.js +342 -73
  31. package/src/replay/redaction.js +165 -0
  32. package/src/replay/serializer.js +148 -43
  33. package/src/replay/snapshot.js +409 -0
  34. package/src/replay/span.js +55 -0
  35. package/src/replay/viewer-model.js +293 -102
  36. package/src/shared/constants.js +1 -1
  37. package/src/shared/inline-safe.js +80 -0
  38. package/src/shared/schema-v2-validator.js +595 -0
  39. package/tools/convert/jspsych-v1-to-v2.mjs +432 -0
@@ -0,0 +1,165 @@
1
+ // src/replay/redaction.js
2
+ // Redaction predicates, shared by every capture path.
3
+ //
4
+ // Spec §8 makes redaction a property of the FILE, not of one capture channel:
5
+ // a redacted subtree's content must not appear in `initial_dom`, in
6
+ // `initial_state.form`, in `dom.text`/`dom.attr`, in key identities, in
7
+ // clipboard payloads, or in anchor identity. A single predicate is what makes
8
+ // that claim checkable — v1 had two implementations (capture-trace's
9
+ // `inRedactedSubtree`, capture-dom's `isInRedactedSubtree`) with different
10
+ // signatures and quietly different password detection, so "is this node
11
+ // redacted" could be answered two ways in one recording.
12
+ //
13
+ // The unified definition takes the SELECTOR (the actual configuration datum),
14
+ // not an options bag, so both capture modules call it the same way:
15
+ // capture-trace: isInRedactedSubtree(el, config.redactSelector)
16
+ // snapshot/dom: isInRedactedSubtree(node, opts.redactSelector)
17
+ //
18
+ // Adoption: complete. Every capture path — the snapshot walk, the mutation
19
+ // mapper, the keyframe seed and event capture — asks this module, and the v1
20
+ // duplicates it replaced are gone. The password check is the union of what the
21
+ // two of them used to do separately (property OR attribute), which is why the
22
+ // predicate is broader than either was.
23
+
24
+ var ELEMENT_NODE = 1;
25
+
26
+ // Nodes whose content this recording has ever withheld. Redaction is a
27
+ // property of the FILE (spec §8), and the DOM only knows the present: a node
28
+ // that spent the trial inside a redacted container and is then moved OUT of
29
+ // it would serialize its accumulated content into the very next `dom.add`,
30
+ // putting in the file exactly the text the keyframe refused. Membership is
31
+ // therefore permanent for the life of the page, not scoped to a keyframe
32
+ // span: a whole-file leak scan reads every segment.
33
+ //
34
+ // A module-level WeakSet, so the protection is on by default and no wiring
35
+ // step can forget it. It holds no node alive, and it is keyed by node
36
+ // IDENTITY, so two recorders on one page (or two tests in one process) can
37
+ // only ever over-redact each other's nodes, never under-redact. Callers that
38
+ // want their own scope may pass one in.
39
+ var defaultTaint = new WeakSet();
40
+
41
+ // How many nodes the SHARED set has ever withheld. A WeakSet has no `size`, so
42
+ // a counter is the only way to answer the one question a later recording needs
43
+ // to ask: did this page withhold anything before I started? Its answer travels
44
+ // as `extensions["cyborg-hunter"].inherited_redaction_taint`, because an empty
45
+ // field in a second recording otherwise means two things the file cannot
46
+ // distinguish — the participant typed nothing, or an earlier recording on this
47
+ // page withheld it.
48
+ //
49
+ // Monotonic on purpose. GC can drop entries from the WeakSet, but a node that
50
+ // became unreachable can never be encountered again, so "has ever withheld" is
51
+ // the question with a stable answer.
52
+ var defaultTaintMarks = 0;
53
+
54
+ function taintSet(taint) {
55
+ return taint && typeof taint.has === 'function' ? taint : defaultTaint;
56
+ }
57
+
58
+ /**
59
+ * Record that this node's content was withheld, so every later channel keeps
60
+ * withholding it. Called wherever the withholding happens: the snapshot walk
61
+ * stripping a subtree, a suppressed `dom.text`, a withheld `value` patch.
62
+ */
63
+ export function markRedacted(node, taint) {
64
+ if (node) {
65
+ var set = taintSet(taint);
66
+ if (set === defaultTaint) defaultTaintMarks++;
67
+ set.add(node);
68
+ }
69
+ return node;
70
+ }
71
+
72
+ /**
73
+ * True when the page's shared taint set has withheld something already. Read
74
+ * once, at `startSession`, so a recording can state whether it inherited
75
+ * withholding from an earlier one on the same page (see the counter above).
76
+ */
77
+ export function hasInheritedRedactionTaint() {
78
+ return defaultTaintMarks > 0;
79
+ }
80
+
81
+ /** True when this node's content has ever been withheld (see markRedacted). */
82
+ export function isRedactionTainted(node, taint) {
83
+ return !!node && taintSet(taint).has(node);
84
+ }
85
+
86
+ /**
87
+ * True when this node OR ANY ANCESTOR has ever been withheld.
88
+ *
89
+ * The subtree reading is the one every channel has to use, and it exists
90
+ * because the node-only reading disagreed with itself across channels (T3
91
+ * final review, F-1). The snapshot walk always inherited a tainted ancestor's
92
+ * verdict downward — it descends the tree, so it carries the answer with it —
93
+ * while the mutation mapper and the trace capture asked about the queried node
94
+ * alone. New content created inside a container that had been moved OUT of a
95
+ * redacted subtree therefore shipped in full through `dom.add`, `dom.text` and
96
+ * `input.value`, and was then withheld by the next keyframe describing the same
97
+ * nodes: one file, two verdicts, the weaker one first.
98
+ *
99
+ * A fresh child of a withheld container is withheld. That is the only reading
100
+ * under which the taint set's stated property — it can only ever over-redact —
101
+ * is true of the FILE rather than of one channel at a time.
102
+ */
103
+ export function isInTaintedSubtree(node, taint) {
104
+ var set = taintSet(taint);
105
+ var cur = node;
106
+ while (cur) {
107
+ if (set.has(cur)) return true;
108
+ cur = cur.parentNode;
109
+ }
110
+ return false;
111
+ }
112
+
113
+ function hasTypePasswordAttr(el) {
114
+ var attrs = el.attributes || [];
115
+ for (var i = 0; i < attrs.length; i++) {
116
+ if (attrs[i].name === 'type') return attrs[i].value === 'password';
117
+ }
118
+ return false;
119
+ }
120
+
121
+ /**
122
+ * The spec §8 floor: `<input type="password">` values are never recorded, in
123
+ * any channel, and no configuration can turn that off.
124
+ *
125
+ * Property OR attribute: the `type` IDL property is the browser's answer, but
126
+ * it is absent on duck-typed fixture nodes and reads "text" for an unknown
127
+ * type — so a field the page author wrote as `type="password"` must be caught
128
+ * by the attribute too. Detection stays declarative (spec §8): a name or
129
+ * placeholder that merely looks password-ish is not a password field.
130
+ */
131
+ export function isPasswordField(el) {
132
+ if (!el || el.tagName !== 'INPUT') return false;
133
+ return el.type === 'password' || hasTypePasswordAttr(el);
134
+ }
135
+
136
+ /**
137
+ * True when this element is itself redacted: a password field (always) or a
138
+ * match for the researcher-configured redaction selector.
139
+ *
140
+ * A selector that throws (invalid syntax) redacts nothing rather than
141
+ * everything — the alternative is a typo silently emptying a whole recording.
142
+ */
143
+ export function isRedacted(el, selector) {
144
+ if (isPasswordField(el)) return true;
145
+ if (!selector || !el || typeof el.matches !== 'function') return false;
146
+ try { return el.matches(selector); } catch (e) { return false; }
147
+ }
148
+
149
+ /**
150
+ * True when the node is a redacted element or lives inside one.
151
+ *
152
+ * The ancestor walk is what makes redaction a subtree property: text nodes and
153
+ * comments have no `matches()` of their own, so typed content under a redacted
154
+ * container (a contenteditable, a wrapper div matched by the selector) is only
155
+ * caught by asking about its ancestors. Elements above the observed root count
156
+ * too — the walk follows `parentNode` as far as it goes.
157
+ */
158
+ export function isInRedactedSubtree(node, selector) {
159
+ var cur = node;
160
+ while (cur) {
161
+ if (cur.nodeType === ELEMENT_NODE && isRedacted(cur, selector)) return true;
162
+ cur = cur.parentNode;
163
+ }
164
+ return false;
165
+ }
@@ -1,22 +1,53 @@
1
1
  // src/replay/serializer.js
2
- // In-memory recorder state → SessionRecording v1 (jsPsych PR #3661 wire
3
- // format) with a ch_extensions namespace for CH-only data.
2
+ // In-memory recorder state → SessionRecording v2 (spec §2/§3).
4
3
  //
5
- // This is one of exactly two places in the system that convert time bases
6
- // (the other is the CLI ingest): in-memory events carry absolute
7
- // performance.now() values; the wire carries ms-since-session-start.
4
+ // This is one of exactly two places in the system that convert time bases (the
5
+ // other is the CLI ingest): in-memory events carry absolute performance.now()
6
+ // values; the wire carries ms since the recording started (spec §7), which is
7
+ // why the file also states that origin as `recording_started_at_perf` — the two
8
+ // halves of one claim, and a reader can put an event back on the capture clock
9
+ // by adding them.
10
+ //
11
+ // What v2 moved, and where it went (spec §14's CH list):
12
+ // - `metadata` dissolves into the top level; `recorder` becomes a
13
+ // {name, version} pair rather than a string.
14
+ // - `trials` → `segments`, `trial_index` → positional `index` (+ `label`),
15
+ // `trial_data` → `host_data`, `view_state` → `initial_state`.
16
+ // - the initial DOM is a DomNode tree (snapshot.js), not an HTML string.
17
+ // - `stylesheets: {initial, events}` → one id-bearing array plus
18
+ // `stylesheet_events` (empty: CH captures no stylesheet mutations yet — a
19
+ // recorded gap, not a claim that none happened).
20
+ // - `ch_extensions` → `extensions["cyborg-hunter"]`, same content minus
21
+ // `marker_attr` (integer node ids retired the nonce markers), plus the
22
+ // `tier`/`keys` config echo that v1 kept in metadata.
23
+ // - `capture_stopped` gains a standard mirror: top-level `truncated` (§5.7).
24
+ //
25
+ // Everything CH-specific that the format has no field for goes into the vendor
26
+ // namespace rather than riding as an unknown top-level key: an unknown key is
27
+ // indistinguishable from a producer bug, and §9 exists precisely so a player
28
+ // can ignore it knowingly.
8
29
 
9
30
  import { VERSION } from '../shared/constants.js';
10
31
 
32
+ /**
33
+ * The wire format this module emits. Exported because it is not only the
34
+ * serializer's business: the meta pointer that rides in the host's data
35
+ * (persistence.js, and index.js's no-session degradation) states the schema
36
+ * version of the recording it points at, and a hard-coded literal there is how
37
+ * a v1 pointer survived into a v2 recorder.
38
+ */
39
+ export var SCHEMA_VERSION = 2;
40
+
11
41
  // Convert an absolute performance.now() value to wire time (ms since
12
- // session start), rounded to 1 decimal to keep JSON compact.
42
+ // recording start), rounded to 1 decimal to keep JSON compact (spec §7 permits
43
+ // producer rounding and names CH's 0.1 ms).
13
44
  function wireT(t, sessionStart) {
14
45
  if (t == null) return null;
15
46
  return Math.round((t - sessionStart) * 10) / 10;
16
47
  }
17
48
 
18
49
  // Session-scoped CH arrays whose entries carry absolute `t` — converted to
19
- // session-relative on the way into ch_extensions.
50
+ // session-relative on the way into the vendor namespace.
20
51
  function convertTimes(entries, sessionStart) {
21
52
  return (entries || []).map(function (e) {
22
53
  var out = Object.assign({}, e);
@@ -26,11 +57,38 @@ function convertTimes(entries, sessionStart) {
26
57
  });
27
58
  }
28
59
 
60
+ // Time-sorted copy. Stable by construction (Array.prototype.sort is stable in
61
+ // every engine CH supports), so events that share a `t` keep the order the
62
+ // buffer received them in — which spec §7 makes authoritative within an array.
63
+ function byTime(entries) {
64
+ return entries.slice().sort(function (a, b) { return a.t - b.t; });
65
+ }
66
+
67
+ // Spec §2 types every ViewportState field as a number, and a recorder started
68
+ // without a window (node harness, a test) has no geometry to report. The
69
+ // format has no way to say "unknown", so zeros keep the file loadable where
70
+ // nulls would fail every consumer's type check.
71
+ var VIEWPORT_UNKNOWN = { w: 0, h: 0, dpr: 1, scale: 1, offset_x: 0, offset_y: 0 };
72
+
73
+ function viewportState(v) {
74
+ return v ? {
75
+ w: num(v.w), h: num(v.h), dpr: num(v.dpr, 1),
76
+ scale: num(v.scale, 1), offset_x: num(v.offset_x), offset_y: num(v.offset_y),
77
+ } : Object.assign({}, VIEWPORT_UNKNOWN);
78
+ }
79
+
80
+ function num(v, fallback) {
81
+ return typeof v === 'number' && isFinite(v) ? v : (fallback == null ? 0 : fallback);
82
+ }
83
+
29
84
  /**
30
85
  * @param {Object} state - recorder.getState() output
31
- * @param {Object} opts - { chSessionReport } — pull-model CH merge; the
32
- * caller (jsPsych adapter or researcher) provides CH's session report,
33
- * the serializer never reaches into CH itself.
86
+ * @param {Object} opts
87
+ * `chSessionReport` — pull-model CH merge: the caller (jsPsych adapter or
88
+ * researcher) provides CH's session report, so the
89
+ * serializer never reaches into CH itself.
90
+ * `host` — the runtime the recorder was embedded in (spec §2),
91
+ * `{name, version}`; null/absent for standalone use.
34
92
  */
35
93
  export function serialize(state, opts) {
36
94
  opts = opts || {};
@@ -40,42 +98,57 @@ export function serialize(state, opts) {
40
98
  }
41
99
  var ch = opts.chSessionReport || null;
42
100
 
101
+ // The last moment the recording observed, on the capture clock. Kept
102
+ // absolute so it sits in the same frame as `recording_started_at_perf`;
103
+ // their difference is the recording's duration in wire time.
43
104
  var lastT = s0;
44
- var trials = state.trials.map(function (trial) {
45
- var events = trial.events.slice().sort(function (a, b) { return a.t - b.t; });
105
+ var segments = state.trials.map(function (trial, i) {
106
+ var events = byTime(trial.events);
46
107
  var lastEventT = events.length > 0 ? events[events.length - 1].t : trial.tLoad;
47
108
  if (lastEventT > lastT) lastT = lastEventT;
109
+ if (trial.tEnd != null && trial.tEnd > lastT) lastT = trial.tEnd;
48
110
  return {
49
- trial_index: trial.trialIndex,
50
- trial_id: trial.trialId,
51
- plugin: trial.plugin,
52
- // t_load is the trial time origin (design §10). t_start/t_dom_ready are
53
- // #3661-parity fields, null when the host has no pre-render hook.
111
+ // Positional, not the recorder's counter: spec §7 makes the array
112
+ // position authoritative and strict validation rejects disagreement.
113
+ index: i,
114
+ label: trial.trialId != null ? trial.trialId : null,
115
+ plugin: trial.plugin != null ? trial.plugin : null,
116
+ // t_load is the segment time origin (spec §3). t_start/t_dom_ready are
117
+ // null when the host has no pre-render hook.
54
118
  t_start: wireT(trial.tStart, s0),
55
119
  t_dom_ready: wireT(trial.tDomReady, s0),
56
120
  t_load: wireT(trial.tLoad, s0),
57
- // A trial still open at serialization time (tab closed, mid-session
121
+ // A segment still open at serialization time (tab closed, mid-session
58
122
  // getRecording) ends at its last observed event.
59
123
  t_end: wireT(trial.tEnd != null ? trial.tEnd : lastEventT, s0),
60
- // Scroll/viewport state at trial start — the viewer's camera seed.
61
- // Times inside are absolute-free (plain state), so no conversion.
62
- view_state: trial.viewState || null,
63
- initial_dom: trial.initialDom || '',
124
+ // Keyframe (a DomNode tree) vs continuation (null) — spec §3. Absent on
125
+ // trace tier, where no segment ever carries one.
126
+ initial_dom: trial.initialDom || null,
127
+ // The replay-state seed the tree cannot carry (initial-state.js); null
128
+ // when every field was at its default, which is the spec's omit rule.
129
+ initial_state: trial.initialState || null,
64
130
  events: events.map(function (e) {
65
131
  var out = Object.assign({}, e);
66
132
  out.t = wireT(e.t, s0);
67
133
  return out;
68
134
  }),
69
- trial_data: {}
135
+ host_data: trial.hostData != null ? trial.hostData : null,
136
+ // An implicitly opened segment (events arrived before any startTrial) is
137
+ // a CH-side fact about bracketing, not a standard field.
138
+ extensions: trial.implicit ? { 'cyborg-hunter': { implicit: true } } : null,
70
139
  };
71
140
  });
72
141
 
73
142
  var chExtensions = {
74
143
  replay_version: VERSION,
75
- // Serialization-marker attribute name (per-recording nonce); the viewer
76
- // harvests these markers out of the reconstructed DOM for stable
77
- // mutation/anchor resolution. Null on trace-tier or legacy recordings.
78
- marker_attr: state.markerAttr || null,
144
+ // v1 kept these in `metadata`; spec §14 routes the config echo here.
145
+ tier: state.tier,
146
+ keys: state.keys,
147
+ // documentElement's client box at session start. Spec §2's ViewportState
148
+ // has no room for it and the per-event camera block (§6) only carries it
149
+ // where an interaction happened, but it is the box the page was laid out
150
+ // against — what a viewer should size its reconstruction by.
151
+ viewport_client: state.viewportClient || null,
79
152
  ch_version: ch ? (ch.libraryVersion || null) : null,
80
153
  preset: ch && ch.config ? ch.config.preset : null,
81
154
  scoring: ch ? {
@@ -94,26 +167,58 @@ export function serialize(state, opts) {
94
167
  zoom_changes: convertTimes(ch.zoomChanges, s0),
95
168
  extension_injections: convertTimes(ch.extensionInjections, s0)
96
169
  } : null,
170
+ // Guard-friction violations are CH events with no standard type (§5.8
171
+ // forbids vendor events in the stream), so they live here with their
172
+ // times converted like every other session-scoped CH array.
97
173
  guard_violations: convertTimes(state.guardViolations, s0),
98
174
  capture_failures: convertTimes(state.captureFailures, s0),
99
- capture_stopped: !!state.captureStopped
175
+ capture_stopped: !!state.captureStopped,
176
+ // Spec §8 redaction is a property of the file, but the mechanism enforcing
177
+ // it (redaction.js's taint set) has PAGE lifetime — so a recording that is
178
+ // not the page's first can inherit withholding it never configured. Without
179
+ // this flag an empty field means two things the file cannot distinguish:
180
+ // the participant typed nothing, or an earlier recording withheld it.
181
+ // Always present, like every other key here, so its absence means an old
182
+ // file rather than a clean page.
183
+ inherited_redaction_taint: !!state.inheritedRedactionTaint
100
184
  };
101
185
 
102
186
  return {
103
- schema_version: 1,
104
- metadata: {
105
- start_time: new Date(state.sessionStartEpoch).toISOString(),
106
- end_time: new Date(state.sessionStartEpoch + Math.max(0, Math.round(lastT - s0))).toISOString(),
107
- end_reason: state.endReason || 'aborted',
108
- participant_id: state.participantId,
109
- recorder: 'cyborg-hunter-replay@' + VERSION,
110
- tier: state.tier,
111
- keys: state.keys
112
- },
113
- viewport: state.viewport,
114
- stylesheets: { initial: state.stylesheets || [], events: [] },
115
- rng_calls: [], // shape parity with #3661; CH never re-executes code
116
- trials: trials,
117
- ch_extensions: chExtensions
187
+ schema_version: SCHEMA_VERSION,
188
+ recorder: { name: 'cyborg-hunter-replay', version: VERSION },
189
+ host: opts.host || null,
190
+ participant_id: state.participantId != null ? state.participantId : null,
191
+ recording_started_at: new Date(state.sessionStartEpoch).toISOString(),
192
+ recording_started_at_perf: s0,
193
+ user_agent: state.userAgent || '',
194
+ viewport: viewportState(state.viewport),
195
+ observed_root: state.observedRoot || null,
196
+ stylesheets: state.stylesheets || [],
197
+ // Known gap: CH captures the initial sheets only. Stylesheet mutations
198
+ // (spec §2 StylesheetEvent) need a CSSOM observer CH does not have, so the
199
+ // array is empty rather than absent — the format's way of saying "capture
200
+ // was on and nothing is recorded here".
201
+ stylesheet_events: [],
202
+ // Time-sorted on the way out (spec §7): the capture side coalesces both
203
+ // viewport channels through RAF callbacks, and this is the last place that
204
+ // can guarantee the property for the file.
205
+ viewport_changes: byTime(state.viewportChanges || []).map(function (c) {
206
+ var out = Object.assign({}, c);
207
+ out.t = wireT(c.t, s0);
208
+ return out;
209
+ }),
210
+ // CH never patches Math.random and never re-executes experiment code, so
211
+ // RNG capture was off — which §7 spells as both fields null.
212
+ rng: null,
213
+ rng_calls: null,
214
+ ended_at_perf: lastT,
215
+ end_reason: state.endReason || 'aborted',
216
+ // Spec §5.7: the standard mirror of the capture-stopped signal, so a
217
+ // player can surface truncation without reading a vendor namespace.
218
+ truncated: !!state.captureStopped,
219
+ extensions: { 'cyborg-hunter': chExtensions },
220
+ // Last, matching the canonical fixture's reading order: the bulk of the
221
+ // file after everything a reader needs to interpret it.
222
+ segments: segments,
118
223
  };
119
224
  }