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,432 @@
1
+ // tools/convert/jspsych-v1-to-v2.mjs
2
+ //
3
+ // jsPsych `schema_version: 1` SessionRecording → SessionRecording v2
4
+ // (docs/plans/2026-08-09-session-recording-v2-spec-draft.md, §14 migration).
5
+ // This tool IS the migration path: players stay v2-only, there is no dual-read,
6
+ // and there is no v2 → v1 direction.
7
+ //
8
+ // v1 source of truth: the fork's pre-flip `src/schema/types.ts`
9
+ // git -C <jspsych-replay-fork> show 06dfa08~1:src/schema/types.ts
10
+ // itself copied from jspsych/jsPsych packages/jspsych/src/modules/recording.ts.
11
+ // The key tables below are that interface, transcribed. They are the whole
12
+ // shape contract: a recording whose top-level or per-trial key set differs from
13
+ // them is REFUSED, in both directions (unknown keys and missing keys alike).
14
+ //
15
+ // Why refuse instead of coping. A converter that defaults a missing field is
16
+ // guessing about unrepeatable participant data, and a converter that renumbers
17
+ // a disagreeing `trial_index` silently rewrites the experiment's own record of
18
+ // what ran when. Both failures are invisible downstream — the output validates
19
+ // either way. So every deviation from the v1 shape stops the conversion and
20
+ // names itself, with a remedy attached. (Note the deliberate contrast with the
21
+ // v2 *loader*, which is tolerant by design, spec §11: tolerance protects an
22
+ // analyst opening a file; strictness protects a producer manufacturing one.
23
+ // This tool is BOTH — a producer when it cuts a fixture, and an archive's only
24
+ // door when a researcher points it at a 2025 recording, since §14 makes
25
+ // conversion the sole migration path. Hence the one concession below.)
26
+ //
27
+ // What the mapping does NOT touch: `initial_dom`, `events`, `trial_data`,
28
+ // stylesheets, viewport changes and RNG records are participant data and are
29
+ // copied through value-for-value. Their internal key order is theirs, not ours.
30
+ //
31
+ // Packaging: `convertRecording` has no dependency outside node: builtins and
32
+ // runs from a bare copy of this file. The CLI additionally validates its output
33
+ // against the in-repo schema-v2 validator, which it imports lazily, so a missing
34
+ // `tests/` directory costs the CLI its output gate and costs the pure function
35
+ // nothing. (`validator.js:2` says that file lifts into a shared package one day;
36
+ // when it moves, only the dynamic specifier below needs updating.)
37
+ //
38
+ // Usage:
39
+ // node tools/convert/jspsych-v1-to-v2.mjs <v1.json> --stdout
40
+ // node tools/convert/jspsych-v1-to-v2.mjs <v1.json> --out <v2.json>
41
+ // cat v1.json | node tools/convert/jspsych-v1-to-v2.mjs --stdout
42
+ import { createHash } from 'node:crypto';
43
+ import { readFileSync, writeFileSync, writeSync } from 'node:fs';
44
+ import { fileURLToPath } from 'node:url';
45
+
46
+ const VALIDATOR_SPECIFIER = '../../src/shared/schema-v2-validator.js';
47
+
48
+ // Stamped into every converted file. Bump it deliberately: the goldens carry
49
+ // this string, so a bump fails the golden tests until they are regenerated,
50
+ // which is exactly the review moment a mapping change deserves.
51
+ // 1.1.0: stylesheets backfill + `backfilled` report, provenance moved under the
52
+ // `cyborg-hunter` vendor slug, `label` null instead of String(trial_index).
53
+ export const CONVERTER_VERSION = '1.1.0';
54
+ const CONVERTER_TOOL = 'jspsych-v1-to-v2';
55
+ // Spec §9 types `extensions` as { "<vendor>": JsonValue } with lowercase-slug
56
+ // vendor keys. "converter" is a role, not a vendor, so the stamp nests inside
57
+ // CH's existing namespace — the shape travels to the fork with the jspsych-full
58
+ // fixture, where a bare "converter" key would read as a second vendor.
59
+ const CH_VENDOR = 'cyborg-hunter';
60
+
61
+ // Transcribed from v1 `interface SessionRecording` / `interface TrialRecording`.
62
+ const V1_TOP_KEYS = [
63
+ 'schema_version', 'jspsych_version', 'recording_started_at',
64
+ 'recording_started_at_perf', 'user_agent', 'viewport', 'rng',
65
+ 'display_element_id', 'stylesheets', 'stylesheet_events', 'trials',
66
+ 'viewport_changes', 'rng_calls', 'ended_at_perf', 'end_reason',
67
+ ];
68
+ const V1_TRIAL_KEYS = [
69
+ 'trial_index', 't_start', 't_dom_ready', 't_end', 'plugin',
70
+ 'initial_dom', 'events', 'trial_data',
71
+ ];
72
+
73
+ // The ONLY concession to v1 history, mirroring the v1 reference validator
74
+ // (fork 06dfa08~1:src/schema/types.ts:219-224, "stylesheet fields were added
75
+ // later. Default to empty arrays so older recordings still load") — and those
76
+ // are its only two backfills, so this is a bounded concession, not the top of a
77
+ // slope. These two fields postdate the rest of the shape, so ABSENCE means the
78
+ // recorder had no stylesheet feature: `[]` records that fact rather than
79
+ // inventing one, which is why `viewport` or `rng_calls` cannot join the list.
80
+ // Absence only. A present-but-wrong-type value means something went wrong, and
81
+ // the converter has nothing true to say about it: it passes through and the
82
+ // strict profile stops it at the CLI boundary.
83
+ const V1_BACKFILL_KEYS = ['stylesheets', 'stylesheet_events'];
84
+
85
+ // ── conversion ──────────────────────────────────────────────────────────────
86
+
87
+ /**
88
+ * Convert a jsPsych-v1 SessionRecording object to v2. Pure: the input is never
89
+ * mutated and the output shares no structure with it (one clone up front), so
90
+ * a caller can keep using either independently.
91
+ *
92
+ * Throws on ANY deviation from the v1 shape. The Error carries `.reasons`
93
+ * (string[]) with every problem found, not just the first.
94
+ */
95
+ export function convertRecording(input) {
96
+ const reasons = [];
97
+
98
+ if (typeof input !== 'object' || input === null || Array.isArray(input)) {
99
+ throw refusal([
100
+ `input must be a JSON object (got ${describe(input)}). ` +
101
+ `Pass one jsPsych SessionRecording, not a list of them or a bare value.`,
102
+ ]);
103
+ }
104
+
105
+ if (input.schema_version !== 1) {
106
+ reasons.push(
107
+ `schema_version must be the integer 1 (got ${describe(input.schema_version)}). ` +
108
+ `Point this tool at a jsPsych v1 recording: a v2 file needs no conversion, and a ` +
109
+ `Cyborg Hunter v1 file takes the separate CH migration path (spec §14).`
110
+ );
111
+ }
112
+
113
+ const backfilled = V1_BACKFILL_KEYS.filter(k => !Object.keys(input).includes(k));
114
+ const top = keySetDiff(input, V1_TOP_KEYS, backfilled);
115
+ if (top.unknown.length) {
116
+ reasons.push(
117
+ `unknown top-level key(s): ${top.unknown.join(', ')}. ` +
118
+ `Remove them from the recording, or extend V1_TOP_KEYS in this tool if jsPsych's ` +
119
+ `v1 shape really grew a field.`
120
+ );
121
+ }
122
+ if (top.missing.length) {
123
+ reasons.push(
124
+ `missing top-level key(s): ${top.missing.join(', ')}. ` +
125
+ `Re-export the recording from its source; only ${V1_BACKFILL_KEYS.join('/')} have a ` +
126
+ `safe default ([], applied automatically), so filling anything else in would invent data.`
127
+ );
128
+ }
129
+
130
+ if (typeof input.jspsych_version !== 'string') {
131
+ reasons.push(
132
+ `jspsych_version must be a string (got ${describe(input.jspsych_version)}). ` +
133
+ `Quote it ("8.2.1"): it becomes recorder.version and host.version.`
134
+ );
135
+ }
136
+ if (typeof input.display_element_id !== 'string' || input.display_element_id === '') {
137
+ reasons.push(
138
+ `display_element_id must be a non-empty string (got ${describe(input.display_element_id)}). ` +
139
+ `Name the element the session was recorded from; it becomes observed_root as "#"+id.`
140
+ );
141
+ }
142
+
143
+ // Trial checks only run when there is an array to walk; otherwise every
144
+ // per-trial message would be noise on top of the real problem.
145
+ if (!Array.isArray(input.trials)) {
146
+ reasons.push(
147
+ `trials must be an array (got ${describe(input.trials)}). ` +
148
+ `Pass the recording's own trials list, even when it is empty.`
149
+ );
150
+ } else {
151
+ input.trials.forEach((t, i) => checkTrial(t, i, reasons));
152
+ }
153
+
154
+ // Nothing below runs for a refused recording, so a bad file costs neither the
155
+ // clone nor the hash.
156
+ if (reasons.length) throw refusal(reasons);
157
+
158
+ const v1 = structuredClone(input);
159
+ // Hashed canonically (see canonicalize) and BEFORE the backfill, so the stamp
160
+ // identifies the source recording as it arrived; `backfilled` below says what
161
+ // the converter added on top.
162
+ const sourceHash = sha256(JSON.stringify(canonicalize(input)));
163
+ for (const k of backfilled) v1[k] = [];
164
+
165
+ return {
166
+ schema_version: 2,
167
+ // v1 states one version for the recorder and the runtime because in v1 they
168
+ // are the same program. v2 splits the roles, so both get the same identity
169
+ // here rather than one of them getting a guess.
170
+ recorder: { name: 'jspsych', version: v1.jspsych_version },
171
+ host: { name: 'jspsych', version: v1.jspsych_version },
172
+ participant_id: null, // v1 records none, so the converter invents none
173
+ recording_started_at: v1.recording_started_at,
174
+ recording_started_at_perf: v1.recording_started_at_perf,
175
+ user_agent: v1.user_agent,
176
+ viewport: v1.viewport,
177
+ // v2 wants a selector (§2). Not CSS-escaped: an id starting with a digit or
178
+ // holding a `.`/`:`/space yields a selector querySelector rejects. Left as
179
+ // is deliberately — CH's own recorder builds observed_root the same way
180
+ // (src/replay/capture-dom.js:251-253), so escaping is a repo-wide
181
+ // convention to change in both places or neither.
182
+ observed_root: '#' + v1.display_element_id,
183
+ stylesheets: v1.stylesheets,
184
+ stylesheet_events: v1.stylesheet_events,
185
+ viewport_changes: v1.viewport_changes,
186
+ rng: v1.rng,
187
+ rng_calls: v1.rng_calls,
188
+ ended_at_perf: v1.ended_at_perf,
189
+ end_reason: v1.end_reason,
190
+ truncated: false, // v1 has no early-stop channel to report
191
+ extensions: {
192
+ [CH_VENDOR]: {
193
+ converter: {
194
+ tool: CONVERTER_TOOL,
195
+ version: CONVERTER_VERSION,
196
+ source_sha256: sourceHash,
197
+ // Present only when something was filled in, so its presence alone is
198
+ // the signal that this file is not purely what the recorder wrote.
199
+ ...(backfilled.length ? { backfilled } : {}),
200
+ },
201
+ },
202
+ },
203
+ segments: v1.trials.map(convertTrial),
204
+ };
205
+ }
206
+
207
+ // jsPsych wipes the display between trials, so every v1 trial is a v2 keyframe:
208
+ // `initial_dom` is always a fresh snapshot and node numbering always restarts.
209
+ // That is why `initial_state` is null (spec §3 exempts wiping hosts) and why no
210
+ // continuation bookkeeping is needed here.
211
+ function convertTrial(t) {
212
+ return {
213
+ index: t.trial_index,
214
+ // null, not String(trial_index): spec §3 calls `label` host-assigned, and
215
+ // jsPsych assigns none. Stringifying the index would duplicate `index` while
216
+ // asserting a label the recording never carried.
217
+ label: null,
218
+ plugin: t.plugin,
219
+ t_start: t.t_start,
220
+ t_dom_ready: t.t_dom_ready,
221
+ t_load: null, // v1 never recorded a load milestone
222
+ t_end: t.t_end,
223
+ initial_dom: t.initial_dom, // v1's DomNode encoding IS v2's (§4)
224
+ initial_state: null,
225
+ events: t.events, // v1's dotted event vocabulary IS v2's (§5)
226
+ host_data: t.trial_data,
227
+ extensions: null,
228
+ };
229
+ }
230
+
231
+ // ── refusals ────────────────────────────────────────────────────────────────
232
+
233
+ function checkTrial(t, i, reasons) {
234
+ const at = `trials[${i}]`;
235
+ if (typeof t !== 'object' || t === null || Array.isArray(t)) {
236
+ reasons.push(
237
+ `${at} must be a JSON object (got ${describe(t)}). ` +
238
+ `Drop the entry or restore the trial record; the converter will not invent one.`
239
+ );
240
+ return;
241
+ }
242
+
243
+ const keys = keySetDiff(t, V1_TRIAL_KEYS);
244
+ if (keys.unknown.length) {
245
+ reasons.push(
246
+ `${at}: unknown trial-level key(s): ${keys.unknown.join(', ')}. ` +
247
+ `Remove them, or extend V1_TRIAL_KEYS in this tool if the v1 trial shape really grew a field.`
248
+ );
249
+ }
250
+ if (keys.missing.length) {
251
+ reasons.push(
252
+ `${at}: missing trial-level key(s): ${keys.missing.join(', ')}. ` +
253
+ `Re-export the recording from its source; no trial-level field has a safe default.`
254
+ );
255
+ }
256
+
257
+ if (!Number.isInteger(t.trial_index)) {
258
+ reasons.push(
259
+ `${at}.trial_index must be an integer (got ${describe(t.trial_index)}). ` +
260
+ `Fix it at the source: it becomes the segment index, which v2 §7 requires to equal ` +
261
+ `the array position.`
262
+ );
263
+ } else if (t.trial_index !== i) {
264
+ // Never renumbered. v2 §7 requires index === array position, and the only
265
+ // safe way to satisfy it is to make a human decide which one is wrong.
266
+ reasons.push(
267
+ `${at}.trial_index (${t.trial_index}) must equal its array position (${i}). ` +
268
+ `Reorder the trials to match their own indices, or fix the indices at the source; ` +
269
+ `this tool never renumbers, because that would rewrite the experiment's record of ` +
270
+ `what ran when.`
271
+ );
272
+ }
273
+ }
274
+
275
+ // `exempt` names keys whose absence is being handled elsewhere (the stylesheets
276
+ // backfill), so they are not also reported as missing.
277
+ function keySetDiff(obj, known, exempt = []) {
278
+ const present = Object.keys(obj);
279
+ return {
280
+ unknown: present.filter(k => !known.includes(k)).sort(),
281
+ missing: known.filter(k => !present.includes(k) && !exempt.includes(k)).sort(),
282
+ };
283
+ }
284
+
285
+ function refusal(reasons) {
286
+ const err = new Error(
287
+ `${CONVERTER_TOOL} refused this recording (${reasons.length} problem` +
288
+ `${reasons.length === 1 ? '' : 's'}):\n` +
289
+ reasons.map(r => ' - ' + r).join('\n')
290
+ );
291
+ err.reasons = reasons;
292
+ return err;
293
+ }
294
+
295
+ function describe(v) {
296
+ if (v === undefined) return 'undefined';
297
+ if (v === null) return 'null';
298
+ if (Array.isArray(v)) return 'an array';
299
+ if (typeof v === 'object') return 'an object';
300
+ return JSON.stringify(v);
301
+ }
302
+
303
+ // ── provenance hash ─────────────────────────────────────────────────────────
304
+
305
+ // Recursively key-sorted copy. Hashing THIS rather than the raw bytes makes the
306
+ // provenance stamp identify the recording's content, not its formatting: the
307
+ // same recording re-serialized with different key order or indentation gets the
308
+ // same hash, and any change to a value gets a different one.
309
+ function canonicalize(value) {
310
+ if (Array.isArray(value)) return value.map(canonicalize);
311
+ if (value && typeof value === 'object') {
312
+ const out = {};
313
+ for (const k of Object.keys(value).sort()) out[k] = canonicalize(value[k]);
314
+ return out;
315
+ }
316
+ return value;
317
+ }
318
+
319
+ function sha256(text) {
320
+ return createHash('sha256').update(text, 'utf8').digest('hex');
321
+ }
322
+
323
+ // ── CLI ─────────────────────────────────────────────────────────────────────
324
+
325
+ const USAGE = `Usage: node tools/convert/jspsych-v1-to-v2.mjs [<v1.json>] [--stdout | --out <v2.json>]
326
+
327
+ <v1.json> input path; "-" or omitted reads stdin
328
+ --stdout write the converted recording to stdout (the default)
329
+ --out, -o <path> write it to a file instead
330
+ --help, -h this message
331
+
332
+ Converts a jsPsych schema_version:1 SessionRecording to SessionRecording v2.
333
+ Refuses anything that is not exactly v1-shaped, and refuses to emit output that
334
+ fails schema-v2 strict validation. Absent stylesheets/stylesheet_events are the
335
+ one exception: they backfill to [] and say so in the provenance stamp.`;
336
+
337
+ // writeSync on fd 2 rather than process.stderr.write: on macOS a piped stderr
338
+ // is asynchronous, so an immediate process.exit() can truncate the very message
339
+ // that explains the refusal.
340
+ function die(message) {
341
+ writeSync(2, message + '\n');
342
+ process.exit(1);
343
+ }
344
+
345
+ async function main(argv) {
346
+ let inputPath = null;
347
+ let outPath = null;
348
+ let explicitStdout = false;
349
+
350
+ for (let i = 0; i < argv.length; i++) {
351
+ const arg = argv[i];
352
+ if (arg === '--help' || arg === '-h') {
353
+ process.stdout.write(USAGE + '\n');
354
+ return;
355
+ } else if (arg === '--stdout') {
356
+ explicitStdout = true;
357
+ } else if (arg === '--out' || arg === '-o') {
358
+ outPath = argv[++i];
359
+ if (outPath === undefined) die(`${CONVERTER_TOOL}: --out needs a path\n\n${USAGE}`);
360
+ } else if (arg.startsWith('-') && arg !== '-') {
361
+ die(`${CONVERTER_TOOL}: unknown option "${arg}"\n\n${USAGE}`);
362
+ } else if (inputPath === null) {
363
+ inputPath = arg;
364
+ } else {
365
+ die(`${CONVERTER_TOOL}: more than one input path given\n\n${USAGE}`);
366
+ }
367
+ }
368
+ if (explicitStdout && outPath !== null) {
369
+ die(`${CONVERTER_TOOL}: --stdout and --out are mutually exclusive`);
370
+ }
371
+
372
+ // fd 0 covers both the piped and the redirected case; "-" is the conventional
373
+ // spelling of "stdin" when a path would otherwise be expected.
374
+ const source = inputPath === null || inputPath === '-' ? 0 : inputPath;
375
+ let raw;
376
+ try {
377
+ raw = readFileSync(source, 'utf8');
378
+ } catch (e) {
379
+ die(`${CONVERTER_TOOL}: cannot read ${inputPath ?? 'stdin'}: ${e.message}`);
380
+ }
381
+
382
+ let v1;
383
+ try {
384
+ v1 = JSON.parse(raw);
385
+ } catch (e) {
386
+ die(`${CONVERTER_TOOL}: input is not valid JSON: ${e.message}`);
387
+ }
388
+
389
+ let v2;
390
+ try {
391
+ v2 = convertRecording(v1);
392
+ } catch (e) {
393
+ die(e.message);
394
+ }
395
+
396
+ // The design's validation duty: a converted file that does not strict-validate
397
+ // is not a v2 recording, so it never reaches disk or a pipe. Imported here
398
+ // rather than at module scope so `convertRecording` never loads it (M1).
399
+ let validateStrict;
400
+ try {
401
+ ({ validateStrict } = await import(VALIDATOR_SPECIFIER));
402
+ } catch (e) {
403
+ die(
404
+ `${CONVERTER_TOOL}: cannot load the schema-v2 validator (${VALIDATOR_SPECIFIER}): ` +
405
+ `${e.message}\nThe CLI strict-validates its ` +
406
+ `own output; the exported convertRecording() has no such dependency.`
407
+ );
408
+ }
409
+ const verdict = validateStrict(v2);
410
+ if (!verdict.ok) {
411
+ die(
412
+ `${CONVERTER_TOOL}: converted output failed schema-v2 strict validation ` +
413
+ `(${verdict.errors.length} error${verdict.errors.length === 1 ? '' : 's'}):\n` +
414
+ verdict.errors.map(e => ' - ' + e).join('\n')
415
+ );
416
+ }
417
+
418
+ const text = JSON.stringify(v2, null, 2) + '\n';
419
+ if (outPath !== null) {
420
+ writeFileSync(outPath, text);
421
+ // Progress chatter goes to stderr so stdout carries recordings and nothing
422
+ // else, whichever output mode is in use.
423
+ process.stderr.write(`Wrote ${outPath}\n`);
424
+ } else {
425
+ process.stdout.write(text);
426
+ }
427
+ }
428
+
429
+ // Runs only when this file is the entry point (not on import from the test).
430
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
431
+ main(process.argv.slice(2)).catch(e => die(`${CONVERTER_TOOL}: ${e.stack ?? e.message}`));
432
+ }