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
@@ -15,13 +15,54 @@
15
15
  import { createRecorder } from './recorder.js';
16
16
  import { attachTraceCapture } from './capture-trace.js';
17
17
  import { attachDomCapture } from './capture-dom.js';
18
- import { serialize } from './serializer.js';
18
+ import { createSpan } from './span.js';
19
+ import { serialize, SCHEMA_VERSION } from './serializer.js';
19
20
  import {
20
21
  replayFilename, buildReplayMeta, autoSave, compressRecording,
21
22
  } from './persistence.js';
22
23
 
23
24
  var _active = null;
24
25
 
26
+ /**
27
+ * Create (and replace) the singleton recorder.
28
+ *
29
+ * REDACTION CARRIES ACROSS RECORDINGS ON ONE PAGE. Spec §8 makes redaction a
30
+ * property of the FILE, and the mechanism that enforces it — redaction.js's
31
+ * taint set, which remembers every node whose content was ever withheld — has
32
+ * PAGE lifetime, not recording lifetime. So a second `attach()` in the same
33
+ * page load inherits the first's withholding: a field the first recording
34
+ * redacted stays empty in the second even if the second configures no
35
+ * redaction at all.
36
+ *
37
+ * This is deliberate. NOT for the reason this comment used to give: it said a
38
+ * reset here would re-open the §8 move-out hole (a node withheld at a keyframe,
39
+ * moved out of its container, re-serialized). That argument does not hold, and
40
+ * an external review was right to say so. §8 scopes redaction PER FILE, and by
41
+ * the time `attach()` runs the previous recording is serialized and closed —
42
+ * nothing a second recorder does can put content into it, so a per-recording
43
+ * reset could not re-expose anything there. The move-out hole lives WITHIN one
44
+ * recording, where the set already closes it.
45
+ *
46
+ * The two reasons it does stand:
47
+ *
48
+ * 1. FAIL-CLOSED against FAIL-OPEN. A page-lifetime ratchet can only ever
49
+ * over-redact. A per-recording set can under-redact relative to today, and
50
+ * this program's invariant is that redaction leaks fail the build.
51
+ * Over-redaction is a diagnosable annoyance; under-redaction is a
52
+ * participant-privacy failure.
53
+ * 2. It would re-open the THREADING SEAM this design closed. "One set per
54
+ * recorder" means threading an explicit object to four consumers (snapshot,
55
+ * mutations, initial-state, capture-trace), and partial threading fails
56
+ * OPEN — see the note in `startSession` below. Trading a known
57
+ * over-redaction for an unknown under-redaction runs the wrong way.
58
+ *
59
+ * The residual cost is real and narrow: a researcher who deliberately turns
60
+ * redaction OFF for a later block of a same-page-load SPA gets a field that
61
+ * stays empty with no explanation. The answer to that is to REPORT it, not to
62
+ * remove the ratchet — `extensions["cyborg-hunter"].inherited_redaction_taint`
63
+ * is true whenever this recording started on a page that had already withheld
64
+ * something. Both the inheritance and the flag are pinned in capture-e2e.test.js.
65
+ */
25
66
  export function attach(userConfig) {
26
67
  if (_active) {
27
68
  console.warn('[cyborg-hunter-replay] attach() called while a recorder is active — replacing the previous instance (its unsaved recording is discarded).');
@@ -39,13 +80,55 @@ export function attach(userConfig) {
39
80
 
40
81
  startSession: function () {
41
82
  rec.startSession();
83
+ // ONE capture span per recording (span.js): the node ids the keyframe
84
+ // assigns and the record of what the file contains. Both capture modules
85
+ // get the SAME object, because an event's `target` and a patch's `node`
86
+ // are the same numbering or neither of them means anything. Created here,
87
+ // reset at each keyframe by the DOM capture.
88
+ var span = createSpan();
89
+ // The §8 redaction taint set is deliberately NOT threaded alongside it.
90
+ // Both capture modules accept one, and threading an explicit object to
91
+ // some consumers and not others is the failure this seam invites: the
92
+ // halves stop sharing, and a field moved out of a redacted container is
93
+ // re-exposed by whichever half was forgotten. Passing NONE puts every
94
+ // consumer — snapshot, mutations, initial-state, capture-trace — on
95
+ // redaction.js's single module-level set, which is the symmetric answer
96
+ // and the fail-closed one: a future consumer that forgets to thread
97
+ // still lands in the same place. Its cost is that two recorders on one
98
+ // page can over-redact each other's nodes, which is the safe direction,
99
+ // and is what redaction.js documents.
100
+ //
42
101
  // Capture modules attach after the session transition so nothing
43
- // touches the DOM until the researcher opts in.
44
- attachTraceCapture(rec);
102
+ // touches the DOM until the researcher opts in. Trace capture goes
103
+ // FIRST for one mundane reason: `attachDomCapture` needs
104
+ // `trace.getScrolledElements` as a VALUE, and that handle only exists
105
+ // once trace capture has attached.
106
+ //
107
+ // Not because of hook ordering. `getScrolledElements()` prunes detached
108
+ // elements on read (capture-trace.js), so the trial-start prune hook is
109
+ // redundant for the seed and reversing the two attachments changes
110
+ // nothing observable. That hook stays for the other reason it was
111
+ // written: the tracker is a STRONG Set, so pruning once per trial bounds
112
+ // its growth even across trials where nothing reads it.
113
+ //
114
+ // The handle is passed as a live function rather than as a snapshot of
115
+ // the Set, so that elements which first scroll after this line are still
116
+ // seeded — see capture-dom.test.js ("enumerates capture-trace's scrolled
117
+ // elements", which adds to the set after attach) and end to end in
118
+ // capture-e2e.test.js ("the second keyframe seeds the scroll it
119
+ // inherited"). Neither test DISCRIMINATES, though: `getScrolledElements`
120
+ // returns the live Set by reference, so caching its result here would
121
+ // pass both. The function form buys independence from that — a tracker
122
+ // that returned a copy, or rebuilt its set, would still work — which is
123
+ // a property of the seam, not one today's tests can see.
124
+ var trace = attachTraceCapture(rec, { span: span });
45
125
  if (rec.config.tier === 'dom' || rec.config.tier === 'canvas') {
46
126
  // 'canvas' is accepted for forward compat but captures at dom tier
47
127
  // in v0.7 (canvas snapshots arrive with the v0.8 diff codec).
48
- attachDomCapture(rec);
128
+ attachDomCapture(rec, {
129
+ span: span,
130
+ scrolled: trace.getScrolledElements,
131
+ });
49
132
  }
50
133
  return api;
51
134
  },
@@ -74,7 +157,7 @@ export function attach(userConfig) {
74
157
  return {
75
158
  recording: null,
76
159
  saveResult: { saved_to: 'no_session' },
77
- meta: { schema_version: 1, saved_to: 'no_session',
160
+ meta: { schema_version: SCHEMA_VERSION, saved_to: 'no_session',
78
161
  note: 'recorder was never started; nothing to save' }
79
162
  };
80
163
  }
@@ -0,0 +1,295 @@
1
+ // src/replay/initial-state.js
2
+ // Keyframe replay-state seeds (spec §3 `initial_state`).
3
+ //
4
+ // A DomNode tree is not a replay checkpoint on its own. It carries the page's
5
+ // STRUCTURE, and structure is where the participant's state mostly is not:
6
+ // how far a scroll container was scrolled, where a video sits, what is typed
7
+ // into a field the page author never wrote a `value` attribute for. All of
8
+ // that was established BEFORE the keyframe, so no mutation in the segment will
9
+ // ever restore it — a player seeking to the keyframe would show a page the
10
+ // participant never saw. `initial_state` is the missing half.
11
+ //
12
+ // Two properties shape everything here.
13
+ //
14
+ // **Only divergence is worth seeding.** The keyframe tree already carries the
15
+ // `value`, `checked` and `selected` ATTRIBUTES, so a control still at its
16
+ // default is restored for free by the reconstruction. What the tree cannot
17
+ // carry is the IDL state that diverged from those attributes, which is exactly
18
+ // the participant's contribution. The same reasoning gives the spec's
19
+ // omit-when-all-default rule its meaning (§3: keyframes MUST carry the seed
20
+ // "whenever any of that state is non-default"): default is the state a player
21
+ // reaches by rebuilding the tree and touching nothing.
22
+ //
23
+ // **A seed may only name nodes the file contains, and never a placeholder.**
24
+ // Every entry is a node id, and an id the player never received is worse than a
25
+ // missing seed: it either does nothing or lands on the wrong node. The span's
26
+ // delivery model (delivery.js) is the file's own record of what went into it,
27
+ // so it answers this directly — no re-derivation from a live DOM that may have
28
+ // moved since the keyframe walk. Re-deriving was the first version of this
29
+ // rule ("is the nearest emitted ancestor this node"), and it broke twice: it
30
+ // admitted a node whose exclusion attribute came off after the walk but before
31
+ // the observer flush, and it admitted the EXCLUSION PLACEHOLDER ITSELF, whose
32
+ // state is precisely what spec §4 withholds. Hence two conditions, delivered
33
+ // and not excluded, and `registry.peekId` only for the id.
34
+ //
35
+ // Redaction (spec §8) subtracts from all four fields rather than emitting
36
+ // redacted variants: §3's interface has no room for a redacted entry, and the
37
+ // entries are pure identity plus state. A skipped control is also marked in
38
+ // the taint set, so a field that spent this keyframe inside a redacted
39
+ // container stays withheld after it is moved out.
40
+
41
+ import {
42
+ isInRedactedSubtree, isRedactionTainted, markRedacted,
43
+ } from './redaction.js';
44
+ import { isExcluded } from './snapshot.js';
45
+
46
+ var MEDIA_SELECTOR = 'video, audio';
47
+ var FORM_SELECTOR = 'input, textarea, select';
48
+
49
+ // Never seeded, whatever their state: password values are the spec §8 floor
50
+ // (already caught by the redaction predicate — this is where the READER looks
51
+ // for it), and file inputs are spec §13's "never recorded", where browsers
52
+ // hand out a fake path rather than an empty string.
53
+ var SKIP_INPUT_TYPES = { password: true, file: true };
54
+
55
+ function round(v) { return Math.round(v); }
56
+ // Media positions are seconds; three decimals is millisecond resolution, and
57
+ // keeps a 17-digit float out of the wire.
58
+ function r3(v) { return Math.round(v * 1000) / 1000; }
59
+
60
+ function attrOf(el, name) {
61
+ if (typeof el.getAttribute === 'function') return el.getAttribute(name);
62
+ var attrs = el.attributes || [];
63
+ for (var i = 0; i < attrs.length; i++) if (attrs[i].name === name) return attrs[i].value;
64
+ return null;
65
+ }
66
+
67
+ function hasAttr(el, name) { return attrOf(el, name) !== null; }
68
+
69
+ // Elements of interest under the observed root, in document order. The root
70
+ // itself counts: a recording whose observed root IS the scrollable pane or the
71
+ // media element is legal.
72
+ function collect(root, selector) {
73
+ var out = [];
74
+ if (typeof root.matches === 'function') {
75
+ try { if (root.matches(selector)) out.push(root); } catch (e) { /* not a real element */ }
76
+ }
77
+ if (typeof root.querySelectorAll !== 'function') return out;
78
+ var found = root.querySelectorAll(selector);
79
+ for (var i = 0; i < found.length; i++) out.push(found[i]);
80
+ return out;
81
+ }
82
+
83
+ // The id to seed this node under, or null when the seed may not name it.
84
+ //
85
+ // `holds` is what the file contains, from the file's own record: it rejects
86
+ // nodes outside the observed root, scripts, iframe and shadow content,
87
+ // everything behind a placeholder, and anything numbered but never emitted —
88
+ // all in one lookup, and without asking the live DOM, which can have moved
89
+ // since the walk that produced the file.
90
+ //
91
+ // `isExcluded` is the second condition rather than a consequence of the first,
92
+ // because a placeholder IS delivered: spec §4 puts its `id`, `kind` and `tag`
93
+ // in the tree and withholds everything else. Its scroll offsets, media
94
+ // position and IDL value are that everything else. CH ships this exact shape
95
+ // (extension-guard-honeypot.js stamps the marker on the bait INPUT), and
96
+ // `keepBait` still turns the legacy markers off here, as everywhere.
97
+ function seedableId(node, span, opts) {
98
+ if (!span.delivery.holds(node)) return null;
99
+ if (isExcluded(node, opts)) return null;
100
+ return span.registry.peekId(node);
101
+ }
102
+
103
+ // Redacted for seeding purposes: inside a redacted subtree right now, or
104
+ // content this recording has already withheld (the taint set). Marking on the
105
+ // way out is what keeps the second case true later.
106
+ function isWithheld(el, opts) {
107
+ if (isInRedactedSubtree(el, opts.redactSelector) || isRedactionTainted(el, opts.taint)) {
108
+ markRedacted(el, opts.taint);
109
+ return true;
110
+ }
111
+ return false;
112
+ }
113
+
114
+ // ── per-field builders ─────────────────────────────────────────────────────
115
+
116
+ // Tracked scrollers, in the order they first scrolled. Entries at the origin
117
+ // are skipped for the same reason the whole object is omitted when everything
118
+ // is default: a freshly rebuilt DOM is already at 0/0.
119
+ function elementScroll(root, span, opts) {
120
+ var out = [];
121
+ var scrolled = opts.scrolled;
122
+ if (!scrolled || typeof scrolled.forEach !== 'function') return out;
123
+ scrolled.forEach(function (el) {
124
+ // No connectedness check: what belongs in the seed is what the FILE holds,
125
+ // which `seedableId` reads off the span's own record. A node the keyframe
126
+ // emitted stays addressable in the player after the page drops it, and the
127
+ // removal reaches the player as the `dom.remove` the observer is about to
128
+ // emit. Keeping the Set from growing is the tracker's job
129
+ // (capture-trace.js), and its prune of detached elements is why one rarely
130
+ // reaches this loop at all.
131
+ if (!el) return;
132
+ if (isWithheld(el, opts)) return;
133
+ var id = seedableId(el, span, opts);
134
+ if (id === null) return;
135
+ var x = round(el.scrollLeft || 0);
136
+ var y = round(el.scrollTop || 0);
137
+ if (x === 0 && y === 0) return;
138
+ out.push({ node: id, x: x, y: y });
139
+ });
140
+ return out;
141
+ }
142
+
143
+ function media(root, span, opts) {
144
+ var out = [];
145
+ var els = collect(root, MEDIA_SELECTOR);
146
+ for (var i = 0; i < els.length; i++) {
147
+ var el = els[i];
148
+ if (isWithheld(el, opts)) continue;
149
+ var id = seedableId(el, span, opts);
150
+ if (id === null) continue;
151
+ var time = typeof el.currentTime === 'number' && isFinite(el.currentTime)
152
+ ? r3(el.currentTime) : 0;
153
+ // Absent `paused` reads as paused: that is the default for a media element
154
+ // nothing has played, and the direction that seeds nothing.
155
+ var paused = el.paused !== false;
156
+ if (time === 0 && paused) continue;
157
+ out.push({ node: id, current_time: time, paused: paused });
158
+ }
159
+ return out;
160
+ }
161
+
162
+ // The value a rebuilt DOM would show for this control. A missing IDL default
163
+ // (duck-typed node) reads as empty, so anything the element actually holds
164
+ // counts as divergence rather than being silently dropped.
165
+ function defaultValueOf(el) {
166
+ return typeof el.defaultValue === 'string' ? el.defaultValue : '';
167
+ }
168
+
169
+ function optionValue(option) {
170
+ if (typeof option.value === 'string') return option.value;
171
+ return String(option.textContent || '');
172
+ }
173
+
174
+ // Display size, which decides whether the browser auto-selects anything: HTML's
175
+ // "ask for a reset" algorithm picks the first non-disabled option only for a
176
+ // single-select showing one row. Browsers return the attribute value from
177
+ // `el.size`, or 0 when the attribute is absent; happy-dom leaves it undefined
178
+ // even with the attribute present, hence the attribute fallback.
179
+ function displaySize(el) {
180
+ if (typeof el.size === 'number' && el.size > 0) return el.size;
181
+ var parsed = parseInt(attrOf(el, 'size'), 10);
182
+ return parsed > 0 ? parsed : 1;
183
+ }
184
+
185
+ // Selected option values, and the selection the reconstruction would show on
186
+ // its own: options carrying the `selected` ATTRIBUTE, or, for a one-row
187
+ // single-select where none does, the first non-disabled option. A list box
188
+ // (`size` above 1) and a `multiple` select both start with nothing selected, so
189
+ // inventing a default for them would report divergence on an untouched page —
190
+ // and one junk entry defeats the whole-object omission.
191
+ function selectDivergence(el) {
192
+ var options = el.options ? Array.prototype.slice.call(el.options) : [];
193
+ var current = [];
194
+ var dflt = [];
195
+ var firstEnabled = null;
196
+ for (var i = 0; i < options.length; i++) {
197
+ var option = options[i];
198
+ if (option.selected) current.push(optionValue(option));
199
+ if (hasAttr(option, 'selected')) dflt.push(optionValue(option));
200
+ if (firstEnabled === null && !option.disabled) firstEnabled = option;
201
+ }
202
+ if (!el.multiple && displaySize(el) === 1 && dflt.length === 0 && firstEnabled) {
203
+ dflt.push(optionValue(firstEnabled));
204
+ }
205
+ if (current.length === dflt.length && current.every(function (v, i2) { return v === dflt[i2]; })) {
206
+ return null;
207
+ }
208
+ return { selected: current };
209
+ }
210
+
211
+ // The state of one control, or null when it matches what the tree restores.
212
+ function formDivergence(el) {
213
+ var tag = el.tagName;
214
+ if (tag === 'SELECT') return selectDivergence(el);
215
+ if (tag === 'INPUT') {
216
+ var type = String(el.type || 'text').toLowerCase();
217
+ if (SKIP_INPUT_TYPES[type]) return null;
218
+ if (type === 'checkbox' || type === 'radio') {
219
+ // `checked` is the whole state of a box; its `value` is the author's
220
+ // submit token, which the tree already carries as an attribute.
221
+ return !!el.checked === !!el.defaultChecked ? null : { checked: !!el.checked };
222
+ }
223
+ }
224
+ var value = typeof el.value === 'string' ? el.value : String(el.value == null ? '' : el.value);
225
+ return value === defaultValueOf(el) ? null : { value: value };
226
+ }
227
+
228
+ function form(root, span, opts) {
229
+ var out = [];
230
+ var els = collect(root, FORM_SELECTOR);
231
+ for (var i = 0; i < els.length; i++) {
232
+ var el = els[i];
233
+ // The spec §8 floor lives in redaction.js's isPasswordField, which this
234
+ // walk reaches through isInRedactedSubtree(el) — the element itself is the
235
+ // first link of that ancestor chain.
236
+ if (isWithheld(el, opts)) continue;
237
+ var id = seedableId(el, span, opts);
238
+ if (id === null) continue;
239
+ var state = formDivergence(el);
240
+ if (!state) continue;
241
+ // `node` first on the wire, matching the fixture's reading order.
242
+ out.push(Object.assign({ node: id }, state));
243
+ }
244
+ return out;
245
+ }
246
+
247
+ /**
248
+ * Build the `initial_state` seed for a keyframe segment (spec §3).
249
+ *
250
+ * CALL ORDER: after the keyframe's `serializeTree` on the SAME span. The seed
251
+ * names nodes by the ids that walk assigned, and asks the span what the file
252
+ * contains. Taken before the walk it returns null, enforced below rather than
253
+ * left to the docblock: without the guard the window scroll still reads, so a
254
+ * wiring slip produces a plausible-looking seed carrying no state at all.
255
+ *
256
+ * @param {object} root the observed root, as serialized
257
+ * @param {object} span the capture span (span.js) the keyframe was taken on
258
+ * @param {object} [opts]
259
+ * `win` window-ish {scrollX, scrollY}; defaults to the real
260
+ * window, and a missing one reads as the scroll origin
261
+ * `scrolled` the tracker's Set of elements that have scrolled
262
+ * (capture-trace.js `getScrolledElements()`); iterated in
263
+ * first-scroll order, never mutated here
264
+ * `keepBait` exclusion override, as everywhere else
265
+ * `redactSelector` §8 redaction selector
266
+ * `taint` §8 taint set (redaction.js); shared with the snapshot
267
+ * @returns {object|null} the InitialState, or null when every field is at its
268
+ * default — the spec's omit rule, and the jsPsych-adapter case in general
269
+ * (a wiped display starts every segment at defaults, so that path simply
270
+ * never calls this).
271
+ */
272
+ export function buildInitialState(root, span, opts) {
273
+ opts = opts || {};
274
+ if (!root || !span) return null;
275
+ // Numbered nothing means walked nothing: there are no ids to name, so there
276
+ // is no seed to give. (A walked span always numbers at least the root.)
277
+ if (span.registry.count === 0) return null;
278
+ var win = opts.win !== undefined
279
+ ? opts.win : (typeof window !== 'undefined' ? window : null);
280
+ var scroll = {
281
+ x: win ? round(win.scrollX || 0) : 0,
282
+ y: win ? round(win.scrollY || 0) : 0,
283
+ };
284
+ var state = {
285
+ scroll: scroll,
286
+ element_scroll: elementScroll(root, span, opts),
287
+ media: media(root, span, opts),
288
+ form: form(root, span, opts),
289
+ };
290
+ if (scroll.x === 0 && scroll.y === 0 && !state.element_scroll.length &&
291
+ !state.media.length && !state.form.length) {
292
+ return null;
293
+ }
294
+ return state;
295
+ }