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,595 @@
1
+ // 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 —
4
+ // warn, never refuse), so the validator is a runtime dependency of the CLI.
5
+
6
+ // Gzip magic bytes (RFC 1952): 0x1f 0x8b.
7
+ export function detectGzip(bytes) {
8
+ return bytes != null && bytes.length >= 2 && bytes[0] === 0x1f && bytes[1] === 0x8b;
9
+ }
10
+
11
+ const TOP_DEFAULTS = {
12
+ host: null, participant_id: null, observed_root: null,
13
+ stylesheets: [], stylesheet_events: [], viewport_changes: [],
14
+ rng: null, rng_calls: null, ended_at_perf: null, end_reason: null,
15
+ truncated: false, extensions: null,
16
+ };
17
+ const ADVISORY_TOP = ['recording_started_at', 'recording_started_at_perf', 'user_agent', 'viewport'];
18
+
19
+ // Tolerant loader profile (spec §11): recordings are unrepeatable participant
20
+ // data, so runtime rejection is data loss. Only four defects are fatal;
21
+ // everything else loads with warnings and documented defaults.
22
+ // Defaults fill *absent* keys only — a present-but-malformed known field
23
+ // (e.g. `stylesheets: null`) passes through untouched by design: the strict
24
+ // profile type-checks the tolerant output, so coercing here would hide the
25
+ // defect from CI, and overwriting participant data at load time is exactly
26
+ // the data loss this profile exists to avoid.
27
+ export function validateTolerant(input) {
28
+ const warnings = [];
29
+ let obj = input;
30
+ if (typeof obj === 'string') {
31
+ try { obj = JSON.parse(obj); }
32
+ catch (e) { return { ok: false, recording: null, errors: ['invalid JSON: ' + e.message], warnings }; }
33
+ }
34
+ const errors = [];
35
+ if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) {
36
+ return { ok: false, recording: null, errors: ['recording must be a JSON object'], warnings };
37
+ }
38
+ if (obj.schema_version !== 2) errors.push('schema_version must be the integer 2');
39
+ if (!obj.recorder || typeof obj.recorder.name !== 'string' || typeof obj.recorder.version !== 'string') {
40
+ errors.push('recorder {name, version} strings are required');
41
+ }
42
+ if (!Array.isArray(obj.segments)) {
43
+ errors.push('segments array is required');
44
+ } else {
45
+ obj.segments.forEach((s, i) => {
46
+ if (!s || typeof s !== 'object' || !Array.isArray(s.events)) {
47
+ errors.push(`segments[${i}] must carry an events array`);
48
+ }
49
+ });
50
+ }
51
+ if (errors.length) return { ok: false, recording: null, errors, warnings };
52
+ 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
55
+ // field (`stylesheets: null`, `truncated: "yes"`) passed through in total
56
+ // silence: defaults fill ABSENT keys only, and the analyst opening the file
57
+ // got no signal at all. Coercing is the wrong repair — overwriting
58
+ // participant data at load time is exactly the loss this profile exists to
59
+ // avoid, and it would hide the defect from the strict profile that
60
+ // type-checks tolerant's output. So the value survives untouched and the
61
+ // loader says what it saw. Presence is read off the INPUT, not the defaulted
62
+ // output, or every default would report itself as malformed.
63
+ for (const [k, ok] of Object.entries(TOP_SHAPES)) {
64
+ if (k in obj && !ok(obj[k])) warnings.push(`malformed known field (kept as-is, not coerced): ${k}`);
65
+ }
66
+ // Clone: the array defaults must be fresh per recording, or two recordings
67
+ // defaulted in the same process would share one mutable array.
68
+ return { ok: true, recording: { ...structuredClone(TOP_DEFAULTS), ...obj }, errors, warnings };
69
+ }
70
+
71
+ // Shape probes for the tolerant profile's warn-on-malformed pass. Deliberately
72
+ // SHALLOW — an array must be an array, an object must be an object — because
73
+ // the strict profile is where depth lives and a tolerant loader duplicating it
74
+ // would be a second implementation of the schema, drifting.
75
+ const TOP_SHAPES = {
76
+ host: v => v === null || (isPlainObject(v) && typeof v.name === 'string' && typeof v.version === 'string'),
77
+ participant_id: v => strOrNull(v),
78
+ observed_root: v => strOrNull(v),
79
+ stylesheets: Array.isArray,
80
+ stylesheet_events: Array.isArray,
81
+ viewport_changes: Array.isArray,
82
+ rng: v => v === null || isPlainObject(v),
83
+ rng_calls: v => v === null || Array.isArray(v),
84
+ ended_at_perf: v => numOrNull(v),
85
+ end_reason: v => v === null || END_REASONS.has(v),
86
+ truncated: v => typeof v === 'boolean',
87
+ extensions: v => v === null || isPlainObject(v),
88
+ recording_started_at: v => typeof v === 'string',
89
+ recording_started_at_perf: v => isNum(v),
90
+ user_agent: v => typeof v === 'string',
91
+ viewport: v => isPlainObject(v) && VIEWPORT_KEYS.every(k => isNum(v[k])),
92
+ };
93
+
94
+ const END_REASONS = new Set(['finished', 'aborted', 'unload']);
95
+ const VIEWPORT_KEYS = ['w', 'h', 'dpr', 'scale', 'offset_x', 'offset_y'];
96
+ const CAMERA_KEYS = ['scroll_x', 'scroll_y', 'viewport_w', 'viewport_h',
97
+ 'client_w', 'client_h', 'dpr', 'vv_scale', 'vv_offset_x', 'vv_offset_y'];
98
+ const RECT_KEYS = ['x', 'y', 'w', 'h'];
99
+
100
+ // Recursion ceiling for the DomNode walk. A recording is untrusted input (spec
101
+ // §12) and this validator is the first thing that touches it, so the walk must
102
+ // not be the place a hostile file gets a stack overflow — an unbounded
103
+ // recursive descent turns a 100k-deep tree into a RangeError that reads like a
104
+ // validator crash rather than a rejected file. The bound is far above anything
105
+ // a browser produces (jspsych-full's deepest keyframe is 9 levels; Chromium
106
+ // itself stops parsing nested markup around 512) and below Node's default
107
+ // stack, so a tree that trips it is a defect either way.
108
+ const DOM_DEPTH_LIMIT = 256;
109
+
110
+ function isNum(v) { return typeof v === 'number' && Number.isFinite(v); }
111
+ function numOrNull(v) { return v === null || isNum(v); }
112
+ function strOrNull(v) { return v === null || typeof v === 'string'; }
113
+ function isPlainObject(v) { return v !== null && typeof v === 'object' && !Array.isArray(v); }
114
+ function isStrArray(v) { return Array.isArray(v) && v.every(s => typeof s === 'string'); }
115
+ function hasNumKeys(o, keys) { return isPlainObject(o) && keys.every(k => isNum(o[k])); }
116
+
117
+ // Spec §9: vendor keys are lowercase slugs. Enforced because the namespace only
118
+ // works as an ignore-list if the names are predictable — `extensions` keyed by
119
+ // a display string is a vendor bag no other player can route.
120
+ const VENDOR_SLUG = /^[a-z0-9]+(-[a-z0-9]+)*$/;
121
+
122
+ function checkExtensions(v, at, errors) {
123
+ if (v === undefined || v === null) return;
124
+ if (!isPlainObject(v)) { errors.push(`${at} must be an object keyed by vendor slug or null (spec §9)`); return; }
125
+ for (const k of Object.keys(v)) {
126
+ if (!VENDOR_SLUG.test(k)) errors.push(`${at} vendor key "${k}" is not a lowercase slug (spec §9)`);
127
+ }
128
+ }
129
+
130
+ // ── §4 DomNode ─────────────────────────────────────────────────────────────
131
+ // The exclusion placeholder is the reason `attrs`/`children` are checked as a
132
+ // PAIR rather than as two required fields. Spec §4 declares both on
133
+ // ElementNode, and then §4's exclusion rule says an excluded element appears
134
+ // with "its id, kind, and tag" and nothing else — CH's snapshot emits exactly
135
+ // `{id, kind, tag}`, no empty `attrs: {}` and no empty `children: []`. Both
136
+ // present is a normal element; both absent is a placeholder; ONE of the two is
137
+ // a producer that half-built one or half-stripped the other, and that is the
138
+ // shape a player's instantiateDom crashes on.
139
+ function checkDomNode(n, at, errors, depth) {
140
+ if (depth > DOM_DEPTH_LIMIT) {
141
+ errors.push(`${at}: DomNode nesting exceeds the ${DOM_DEPTH_LIMIT}-level depth bound`);
142
+ return;
143
+ }
144
+ if (!isPlainObject(n)) { errors.push(`${at} must be a DomNode object`); return; }
145
+ if (!isNum(n.id) || !Number.isInteger(n.id)) errors.push(`${at}.id must be an integer node id`);
146
+ if (n.kind === 'text' || n.kind === 'comment') {
147
+ if (typeof n.text !== 'string') errors.push(`${at}.text must be a string on a ${n.kind} node`);
148
+ return;
149
+ }
150
+ if (n.kind !== 'element') {
151
+ errors.push(`${at}.kind must be "element" | "text" | "comment" (spec §4)`);
152
+ return;
153
+ }
154
+ if (typeof n.tag !== 'string') errors.push(`${at}.tag must be a string`);
155
+ const hasAttrs = 'attrs' in n, hasChildren = 'children' in n;
156
+ if (hasAttrs !== hasChildren) {
157
+ errors.push(`${at}: an element node carries attrs AND children, or neither ` +
158
+ `(neither = the §4 exclusion placeholder); it carries only ${hasAttrs ? 'attrs' : 'children'}`);
159
+ }
160
+ if (hasAttrs) {
161
+ if (!isPlainObject(n.attrs)) errors.push(`${at}.attrs must be an object of string attribute values`);
162
+ else for (const [k, v] of Object.entries(n.attrs)) {
163
+ if (typeof v !== 'string') errors.push(`${at}.attrs["${k}"] must be a string`);
164
+ }
165
+ }
166
+ if (n.canvas_size !== undefined && !hasNumKeys(n.canvas_size, ['w', 'h'])) {
167
+ errors.push(`${at}.canvas_size must carry numeric w/h`);
168
+ }
169
+ if (n.media_src !== undefined && typeof n.media_src !== 'string') {
170
+ errors.push(`${at}.media_src must be a string`);
171
+ }
172
+ if (hasChildren) {
173
+ if (!Array.isArray(n.children)) { errors.push(`${at}.children must be an array of DomNodes`); return; }
174
+ n.children.forEach((c, i) => checkDomNode(c, `${at}.children[${i}]`, errors, depth + 1));
175
+ }
176
+ }
177
+
178
+ // ── §3 InitialState ────────────────────────────────────────────────────────
179
+ function checkInitialState(s, at, errors) {
180
+ if (s === undefined || s === null) return;
181
+ if (!isPlainObject(s)) { errors.push(`${at} must be an InitialState object or null`); return; }
182
+ if (!hasNumKeys(s.scroll, ['x', 'y'])) errors.push(`${at}.scroll must carry numeric x/y`);
183
+ const arr = (k) => {
184
+ if (!Array.isArray(s[k])) { errors.push(`${at}.${k} must be an array`); return null; }
185
+ return s[k];
186
+ };
187
+ (arr('element_scroll') ?? []).forEach((e, i) => {
188
+ if (!hasNumKeys(e, ['node', 'x', 'y'])) errors.push(`${at}.element_scroll[${i}] must carry numeric node/x/y`);
189
+ });
190
+ (arr('media') ?? []).forEach((e, i) => {
191
+ if (!hasNumKeys(e, ['node', 'current_time']) || typeof e.paused !== 'boolean') {
192
+ errors.push(`${at}.media[${i}] must carry numeric node/current_time and boolean paused`);
193
+ }
194
+ });
195
+ (arr('form') ?? []).forEach((e, i) => {
196
+ const eAt = `${at}.form[${i}]`;
197
+ if (!isPlainObject(e) || !isNum(e.node)) { errors.push(`${eAt} must carry a numeric node`); return; }
198
+ if (e.value !== undefined && typeof e.value !== 'string') errors.push(`${eAt}.value must be a string`);
199
+ if (e.checked !== undefined && typeof e.checked !== 'boolean') errors.push(`${eAt}.checked must be a boolean`);
200
+ if (e.selected !== undefined && !isStrArray(e.selected)) errors.push(`${eAt}.selected must be an array of strings`);
201
+ });
202
+ }
203
+
204
+ // ── §2 session-level arrays ────────────────────────────────────────────────
205
+ function checkStylesheet(s, at, errors) {
206
+ if (!isPlainObject(s) || !isNum(s.id)) { errors.push(`${at} must carry a numeric id`); return; }
207
+ if (!strOrNull(s.media ?? null)) errors.push(`${at}.media must be a string or null`);
208
+ if (s.kind === 'inline') {
209
+ if (typeof s.css !== 'string') errors.push(`${at}.css must be a string on an inline sheet`);
210
+ } else if (s.kind === 'link') {
211
+ if (typeof s.href !== 'string') errors.push(`${at}.href must be a string on a link sheet`);
212
+ if (!strOrNull(s.css ?? null)) errors.push(`${at}.css must be a string or null on a link sheet`);
213
+ } else {
214
+ errors.push(`${at}.kind must be "inline" | "link" (spec §2)`);
215
+ }
216
+ }
217
+
218
+ // `label` is the full message prefix (callers phrase it as "<what> must be
219
+ // time-sorted") so one implementation serves both top-level and per-segment
220
+ // call sites without composing garbled text.
221
+ function checkSorted(arr, label, errors) {
222
+ for (let i = 1; i < arr.length; i++) {
223
+ if (isNum(arr[i - 1]?.t) && isNum(arr[i]?.t) && arr[i].t < arr[i - 1].t) {
224
+ errors.push(`${label} (index ${i})`);
225
+ return;
226
+ }
227
+ }
228
+ }
229
+
230
+ // Strict conformance profile (spec §11): full checks, exhaustive error list.
231
+ // Producers prove themselves here (CI); runtime loading stays tolerant.
232
+ // Two layers: the top-level fields here, segment/keyframe/event rules in
233
+ // checkSegmentsStrict below.
234
+ // Presence-checking of defaulted top-level fields is deliberately out of scope
235
+ // here (strict sees the normalized recording); producer-side presence
236
+ // strictness is decided with the negative-fixture corpus.
237
+ export function validateStrict(input) {
238
+ const t = validateTolerant(input);
239
+ if (!t.ok) return { ok: false, errors: t.errors, warnings: t.warnings };
240
+ const r = t.recording;
241
+ const errors = [];
242
+
243
+ if (typeof r.recording_started_at !== 'string') errors.push('recording_started_at must be an ISO 8601 string');
244
+ if (!isNum(r.recording_started_at_perf)) errors.push('recording_started_at_perf must be a number');
245
+ if (typeof r.user_agent !== 'string') errors.push('user_agent must be a string');
246
+ if (!r.viewport || !VIEWPORT_KEYS.every(k => isNum(r.viewport[k]))) {
247
+ errors.push(`viewport must carry numeric ${VIEWPORT_KEYS.join('/')}`);
248
+ }
249
+ if (!strOrNull(r.observed_root)) errors.push('observed_root must be a string or null');
250
+ if (!strOrNull(r.participant_id)) errors.push('participant_id must be a string or null');
251
+ if (!numOrNull(r.ended_at_perf)) errors.push('ended_at_perf must be a number or null');
252
+ if (r.end_reason !== null && !END_REASONS.has(r.end_reason)) {
253
+ errors.push('end_reason must be "finished" | "aborted" | "unload" | null');
254
+ }
255
+ 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).
258
+ if (r.host !== null && !(isPlainObject(r.host)
259
+ && typeof r.host.name === 'string' && typeof r.host.version === 'string')) {
260
+ errors.push('host must be {name, version} strings or null');
261
+ }
262
+ if ((r.rng === null) !== (r.rng_calls === null)) {
263
+ errors.push('rng_calls must be non-null iff rng is non-null (spec §7)');
264
+ }
265
+ if (r.rng !== null && !(isPlainObject(r.rng) && strOrNull(r.rng.seed ?? null)
266
+ && typeof r.rng.math_random_patched === 'boolean')) {
267
+ errors.push('rng must be {seed: string|null, math_random_patched: boolean} or null');
268
+ }
269
+ if (r.rng_calls !== null) {
270
+ if (!Array.isArray(r.rng_calls)) errors.push('rng_calls must be an array or null');
271
+ else {
272
+ r.rng_calls.forEach((c, i) => {
273
+ // `args`/`result` are JsonValue by §2 — anything JSON can hold — so
274
+ // only the two identifying fields are typed.
275
+ if (!isPlainObject(c) || !isNum(c.t) || typeof c.fn !== 'string') {
276
+ errors.push(`rng_calls[${i}] must carry a numeric t and a string fn`);
277
+ }
278
+ });
279
+ checkSorted(r.rng_calls, 'rng_calls must be time-sorted', errors);
280
+ }
281
+ }
282
+ checkExtensions(r.extensions, 'extensions', errors);
283
+ if (!Array.isArray(r.stylesheets)) errors.push('stylesheets must be an array');
284
+ else r.stylesheets.forEach((s, i) => checkStylesheet(s, `stylesheets[${i}]`, errors));
285
+ if (!Array.isArray(r.stylesheet_events)) errors.push('stylesheet_events must be an array');
286
+ else {
287
+ checkSorted(r.stylesheet_events, 'stylesheet_events must be time-sorted', errors);
288
+ r.stylesheet_events.forEach((e, i) => {
289
+ const at = `stylesheet_events[${i}]`;
290
+ if (!isPlainObject(e) || !isNum(e.t)) { errors.push(`${at} must carry a numeric t`); return; }
291
+ if (e.type === 'stylesheet.add') checkStylesheet(e.sheet, `${at}.sheet`, errors);
292
+ else if (e.type === 'stylesheet.remove') { if (!isNum(e.id)) errors.push(`${at}.id must be a number`); }
293
+ else if (e.type === 'stylesheet.update') {
294
+ if (!isNum(e.id)) errors.push(`${at}.id must be a number`);
295
+ if (typeof e.css !== 'string') errors.push(`${at}.css must be a string`);
296
+ } else errors.push(`${at}.type must be stylesheet.add | stylesheet.remove | stylesheet.update`);
297
+ });
298
+ }
299
+ if (!Array.isArray(r.viewport_changes)) errors.push('viewport_changes must be an array');
300
+ else {
301
+ checkSorted(r.viewport_changes, 'viewport_changes must be time-sorted', errors);
302
+ r.viewport_changes.forEach((c, i) => {
303
+ if (!isNum(c?.t) || !hasNumKeys(c, VIEWPORT_KEYS)) {
304
+ errors.push(`viewport_changes[${i}] must carry a numeric t plus ${VIEWPORT_KEYS.join('/')}`);
305
+ }
306
+ });
307
+ }
308
+
309
+ checkSegmentsStrict(r, errors);
310
+ return { ok: errors.length === 0, errors, warnings: t.warnings };
311
+ }
312
+
313
+ // Event union checks (spec §5), covering every spec-defined top-level type.
314
+ // The table must stay complete: an omitted type is reported as unknown, so a
315
+ // gap here rejects conformant recordings. Unknown types are producer errors
316
+ // and vendor events belong in `extensions` (§5.8).
317
+ // `target` is DECLARED on every mouse/key record in §5.2 as `number | null`,
318
+ // and its three readings are distinguishable only if it is actually there
319
+ // (§7: null = no applicable target; a placeholder id = excluded; a live id
320
+ // with an anchor missing `id` = redacted). An absent key is a fourth state the
321
+ // spec does not define, so presence is required rather than defaulted.
322
+ const targetPresent = e => 'target' in e && numOrNull(e.target);
323
+ const touchesCheck = e => Array.isArray(e.touches)
324
+ && e.touches.every(t => hasNumKeys(t, ['id', 'x', 'y']));
325
+
326
+ const CORE_EVENT_CHECKS = {
327
+ 'dom.add': e => isNum(e.parent) && (e.before === null || isNum(e.before)) && e.node && typeof e.node === 'object',
328
+ 'dom.remove': e => isNum(e.node),
329
+ 'dom.attr': e => isNum(e.node) && typeof e.name === 'string' && strOrNull(e.value),
330
+ 'dom.text': e => isNum(e.node) && typeof e.text === 'string',
331
+ 'mouse.move': e => isNum(e.x) && isNum(e.y),
332
+ 'mouse.down': e => isNum(e.x) && isNum(e.y) && isNum(e.button) && targetPresent(e),
333
+ 'mouse.up': e => isNum(e.x) && isNum(e.y) && isNum(e.button) && targetPresent(e),
334
+ 'mouse.click': e => isNum(e.x) && isNum(e.y) && isNum(e.button) && targetPresent(e),
335
+ 'touch.start': touchesCheck,
336
+ 'touch.move': touchesCheck,
337
+ 'touch.end': touchesCheck,
338
+ 'key.down': keyCheck, 'key.up': keyCheck,
339
+ // The redacted variant carries a length and no content (spec §5.2). A
340
+ // surviving `value` is a redaction leak, so reject it rather than ignore it,
341
+ // matching keyCheck and clipboardCheck — and say WHY, because "missing/invalid
342
+ // required fields (e.g. value or value_len)" told a producer to add something
343
+ // when the defect was that it had added too much.
344
+ 'input.value': e => {
345
+ if (e.redacted !== true) return isNum(e.node) && typeof e.value === 'string';
346
+ if (e.value !== undefined) {
347
+ return 'redacted input.value must not carry `value` — it is the plaintext the redaction ' +
348
+ 'removed (spec §5.2/§8); the variant is {t, node, redacted, value_len}';
349
+ }
350
+ return isNum(e.node) && isNum(e.value_len);
351
+ },
352
+ 'input.checked': e => isNum(e.node) && typeof e.checked === 'boolean',
353
+ // `values` is `string[]` in §5.2. An untyped array let a `<select multiple>`
354
+ // ship option objects, which no player can set back onto the control.
355
+ 'input.select': e => isNum(e.node) && isStrArray(e.values),
356
+ 'scroll.window': e => isNum(e.x) && isNum(e.y),
357
+ 'scroll.element': e => isNum(e.node) && isNum(e.x) && isNum(e.y),
358
+ 'focus': () => true, 'blur': () => true,
359
+ 'fullscreen.enter': () => true, 'fullscreen.exit': () => true,
360
+ 'visibility.hidden': () => true, 'visibility.visible': () => true,
361
+ 'clipboard.copy': clipboardCheck, 'clipboard.cut': clipboardCheck,
362
+ 'clipboard.paste': clipboardCheck, 'clipboard.drop': clipboardCheck,
363
+ 'media.play': mediaCheck, 'media.pause': mediaCheck, 'media.ended': mediaCheck,
364
+ 'media.seeked': mediaCheck, 'media.time': mediaCheck,
365
+ 'canvas.snapshot': e => isNum(e.node) && typeof e.data_url === 'string'
366
+ && (e.region === undefined
367
+ || (e.region && isNum(e.region.x) && isNum(e.region.y) && isNum(e.region.w) && isNum(e.region.h))),
368
+ 'recording.capture_stopped': e => e.reason === 'buffer_limit' || e.reason === 'error',
369
+ };
370
+
371
+ // The event types spec §5.2 gives a redacted variant. These are exactly the
372
+ // types whose check above has a `redacted === true` branch that strips
373
+ // content, so the set and the branches must be extended together: a type
374
+ // listed here without such a branch would accept the marker and the payload.
375
+ // Exported so the conformance runner can hold its own §8 allowlist
376
+ // (REDACTED_SHAPES) against it. The two tables encode the same spec sentence
377
+ // from opposite directions — this one says which types may CLAIM redaction,
378
+ // that one says what a claimed event may CARRY — and the failure mode of a
379
+ // dual encoding is that one grows and the other does not. The drift guard in
380
+ // corpus-invariants.test.js is what makes that a red test rather than a hole.
381
+ export const REDACTABLE_TYPES = new Set([
382
+ 'key.down', 'key.up', 'input.value',
383
+ 'clipboard.copy', 'clipboard.cut', 'clipboard.paste', 'clipboard.drop',
384
+ ]);
385
+
386
+ // ── §5.3 clipboard: REQUIRED-BUT-NULLABLE, settled here (T7) ───────────────
387
+ // The parked question was whether §5.3's four fields are required-but-nullable
388
+ // or typed-only-if-present. Settled as REQUIRED, for one reason that is not
389
+ // about strictness for its own sake: §5.3 defines its TWO PRODUCER MODES by
390
+ // which fields are null. Content mode sets text/html and nulls len; length-only
391
+ // sets len and nulls text/html. If a field may simply be absent, "this producer
392
+ // withheld the content" and "this producer forgot the key" become the same
393
+ // file, and a player cannot tell which mode it is rendering. An explicit null
394
+ // is a claim; an absent key is silence. §5.3 is written as an interface, not a
395
+ // union, and every field carries an explicit `| null` — this reads that
396
+ // literally.
397
+ //
398
+ // THE RISK, STATED: the corpus has exactly one clipboard producer
399
+ // (length-only-clipboard.json, a real CH capture, which emits all four on every
400
+ // event including copy/cut). jsPsych's content-mode recorder is unexercised
401
+ // here because its demo timeline has no clipboard trial. If a conforming
402
+ // jsPsych recording turns out to omit `len`, THIS is the check that flips to
403
+ // fields-if-present, and the decision belongs in r3 rather than to whoever
404
+ // hits the failure. Strict is CI-only, so the cost of being wrong is a
405
+ // producer conversation, not an analyst losing data.
406
+ const CLIPBOARD_FIELDS = ['target', 'text', 'html', 'len'];
407
+ function clipboardCheck(e) {
408
+ for (const k of CLIPBOARD_FIELDS) {
409
+ if (!(k in e)) {
410
+ return `clipboard events state all of ${CLIPBOARD_FIELDS.join('/')} explicitly, nulling what ` +
411
+ `the mode withholds (spec §5.3); "${k}" is absent, which is silence rather than a claim`;
412
+ }
413
+ }
414
+ const typed = numOrNull(e.target) && strOrNull(e.text) && strOrNull(e.html) && numOrNull(e.len);
415
+ if (!typed) return false;
416
+ if (e.redacted === true && (e.text !== null || e.html !== null)) {
417
+ return 'redacted clipboard events must null both `text` and `html` — a surviving payload is ' +
418
+ 'the content the redaction removed (spec §5.3/§8); `len` may stay';
419
+ }
420
+ return true;
421
+ }
422
+
423
+ function mediaCheck(e) { return isNum(e.node) && isNum(e.current_time); }
424
+
425
+ const KEY_IDENTITY_FIELDS = ['key', 'code', 'mods', 'repeat', 'target'];
426
+ function keyCheck(e) {
427
+ if (e.redacted === true) {
428
+ // Redacted variant: NO identity fields allowed (spec §5.2/§8). Named, not
429
+ // counted: "missing/invalid required fields" sent a producer looking for
430
+ // something to add when the defect is a field to remove.
431
+ const present = KEY_IDENTITY_FIELDS.filter(k => e[k] !== undefined);
432
+ if (present.length === 0) return true;
433
+ return `redacted key events carry no identity — the variant is {t, type, redacted} and ` +
434
+ `nothing else (spec §5.2/§8); this one keeps ${present.map(k => `\`${k}\``).join(', ')}`;
435
+ }
436
+ return typeof e.key === 'string' && typeof e.code === 'string'
437
+ && e.mods && typeof e.mods === 'object' && typeof e.repeat === 'boolean'
438
+ && 'target' in e && numOrNull(e.target);
439
+ }
440
+
441
+ // Spec §3's segment time origin: first non-null of t_load, t_dom_ready,
442
+ // t_start; else the first event's t. Exported nowhere — the viewer has its own
443
+ // copy and a shared one would make this file part of the player.
444
+ function segmentOrigin(s) {
445
+ for (const k of ['t_load', 't_dom_ready', 't_start']) {
446
+ if (isNum(s?.[k])) return s[k];
447
+ }
448
+ const first = s?.events?.[0];
449
+ return isNum(first?.t) ? first.t : null;
450
+ }
451
+
452
+ function checkSegmentsStrict(r, errors) {
453
+ let sawKeyframe = false;
454
+ r.segments.forEach((s, i) => {
455
+ const at = `segments[${i}]`;
456
+ if (s.index !== i) errors.push(`${at}.index (${s.index}) must equal array position ${i}`);
457
+ for (const k of ['t_start', 't_dom_ready', 't_load', 't_end']) {
458
+ if (k in s && !numOrNull(s[k])) errors.push(`${at}.${k} must be a number or null`);
459
+ }
460
+ if (!strOrNull(s.label ?? null)) errors.push(`${at}.label must be a string or null`);
461
+ if (!strOrNull(s.plugin ?? null)) errors.push(`${at}.plugin must be a string or null`);
462
+ checkInitialState(s.initial_state, `${at}.initial_state`, errors);
463
+ checkExtensions(s.extensions, `${at}.extensions`, errors);
464
+ // Spec §3: "segments are ordered and non-overlapping (t_end[n] ≤ next
465
+ // segment's origin)". Unchecked, a recording can state two segments that
466
+ // both own the same instant, and every player resolves the tie its own way.
467
+ // Skipped where either side is unstated: a null t_end is an open segment,
468
+ // and an originless segment has no instant to overlap with.
469
+ const next = r.segments[i + 1];
470
+ if (next) {
471
+ const nextOrigin = segmentOrigin(next);
472
+ if (isNum(s.t_end) && isNum(nextOrigin) && s.t_end > nextOrigin) {
473
+ errors.push(`${at}.t_end (${s.t_end}) overlaps segments[${i + 1}], whose origin is ${nextOrigin} (spec §3)`);
474
+ }
475
+ }
476
+ const hasDomEvents = s.events.some(e => typeof e?.type === 'string' && e.type.startsWith('dom.'));
477
+ // A keyframe is a DomNode object. A primitive here is a producer bug, and
478
+ // so is an array — `typeof [] === 'object'`, but an array carries none of
479
+ // a DomNode's fields. Either way, treating it as a keyframe would license
480
+ // later dom.* patches against a snapshot the player cannot reconstruct,
481
+ // so the keyframe status and the shape error read off one test.
482
+ const isKeyframe = s.initial_dom != null && typeof s.initial_dom === 'object'
483
+ && !Array.isArray(s.initial_dom);
484
+ if (s.initial_dom != null && !isKeyframe) {
485
+ errors.push(`${at}.initial_dom must be a DomNode object or null`);
486
+ }
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
489
+ // whose ids were absent, was strict-valid and only failed inside a player.
490
+ if (isKeyframe) checkDomNode(s.initial_dom, `${at}.initial_dom`, errors, 1);
491
+ if (isKeyframe) sawKeyframe = true;
492
+ else if (hasDomEvents && !sawKeyframe) {
493
+ errors.push(`${at}: continuation carries dom.* events before any keyframe (spec §3)`);
494
+ }
495
+ checkSorted(s.events, `${at}.events must be time-sorted`, errors);
496
+ s.events.forEach((e, j) => {
497
+ const eAt = `${at}.events[${j}]`;
498
+ if (!e || typeof e.type !== 'string' || !isNum(e.t)) { errors.push(`${eAt}: events need string type and numeric t`); return; }
499
+ // `redacted` is a variant marker, not a toggle. Every per-type check
500
+ // selects the redacted shape on `=== true`, so a truthy non-boolean
501
+ // (`"true"`, `1`) would fall through to the plaintext branch and license
502
+ // exactly the content the redaction removed. `false` is rejected too:
503
+ // absence is how a non-redacted event says so, and a false marker only
504
+ // invites producers to emit the field on events that never redact.
505
+ // On a type outside REDACTABLE_TYPES the marker is a claim nothing
506
+ // enforces: that type's check has no redacted branch, so the event
507
+ // asserts redaction and keeps its payload (a canvas.snapshot with both
508
+ // `redacted: true` and its data_url). Strict refuses the marker there.
509
+ if ('redacted' in e) {
510
+ if (e.redacted !== true) {
511
+ errors.push(`${eAt}: redacted must be the boolean true when present (it marks the redacted variant; omit it on non-redacted events)`);
512
+ } else if (!REDACTABLE_TYPES.has(e.type)) {
513
+ errors.push(`${eAt}: ${e.type} has no redacted variant (spec §5.2/§8); the marker must not appear on it`);
514
+ }
515
+ }
516
+ checkExtensions(e.extensions, `${eAt}.extensions`, errors);
517
+ checkAlignment(e, eAt, errors);
518
+ const check = CORE_EVENT_CHECKS[e.type];
519
+ if (!check) {
520
+ errors.push(`${eAt}: unknown top-level event type "${e.type}" (vendor events belong in extensions, spec §5.8)`);
521
+ } else {
522
+ // A check returns `true`, `false`, or a SPECIFIC MESSAGE. The third
523
+ // case exists for the redaction branches: telling a producer its
524
+ // redacted event has "missing/invalid required fields (e.g. value or
525
+ // value_len)" points at the wrong half of the defect — the field is
526
+ // present and must not be — and that message cost a review round.
527
+ const verdict = check(e);
528
+ if (typeof verdict === 'string') errors.push(`${eAt}: ${verdict}`);
529
+ else if (!verdict) errors.push(`${eAt}: ${e.type} missing/invalid required fields (e.g. ${hintFor(e.type)})`);
530
+ }
531
+ // The keyframe walk's mid-span twin: a dom.add carries a whole subtree,
532
+ // and CORE_EVENT_CHECKS only asks whether it is an object.
533
+ if (e.type === 'dom.add' && isPlainObject(e.node)) checkDomNode(e.node, `${eAt}.node`, errors, 1);
534
+ });
535
+ });
536
+ }
537
+
538
+ // ── §6 alignment fields ────────────────────────────────────────────────────
539
+ // Optional, but each anchored event "carries complete blocks or none (no delta
540
+ // encoding)" — so a half-filled camera is a producer bug, not a cheaper event.
541
+ // `anchor.id` is the one field that may be ABSENT rather than null: §8 has
542
+ // anchors on redacted targets omit identity, and an explicit `id: null` is the
543
+ // different claim "the element had no id".
544
+ function checkAlignment(e, at, errors) {
545
+ if (e.camera !== undefined) {
546
+ if (!hasNumKeys(e.camera, CAMERA_KEYS)) {
547
+ errors.push(`${at}.camera must carry numeric ${CAMERA_KEYS.join('/')} (spec §6)`);
548
+ }
549
+ if (e.type === 'mouse.move') errors.push(`${at}: alignment fields never ride mouse.move (spec §6)`);
550
+ }
551
+ if (e.anchor !== undefined) {
552
+ const a = e.anchor;
553
+ if (!isPlainObject(a)) errors.push(`${at}.anchor must be an object (spec §6)`);
554
+ else {
555
+ if (typeof a.tag !== 'string') errors.push(`${at}.anchor.tag must be a string`);
556
+ if ('id' in a && !strOrNull(a.id)) errors.push(`${at}.anchor.id must be a string or null when present`);
557
+ // `rect` is typed WHEN PRESENT rather than required, and that is a
558
+ // deliberate concession to an existing producer contract rather than
559
+ // laxity. §6 declares it without a `?`, but CH's capture omits it when
560
+ // `getBoundingClientRect` is unavailable or throws — "rect stays absent —
561
+ // viewer treats as unverifiable" (capture-trace.js) — and the viewer's
562
+ // alignment check has a real branch for that state. Requiring it here
563
+ // would make CH's own capture non-conformant in every context without
564
+ // layout, which is a producer decision and not a validator's to force.
565
+ // Routed to r3 with the other two strictness questions: either §6 gains
566
+ // "rect MAY be omitted when capture-time geometry was unreadable", or CH
567
+ // emits a null rect and this becomes required-but-nullable like §5.3's
568
+ // clipboard fields.
569
+ if ('rect' in a && !hasNumKeys(a.rect, RECT_KEYS)) {
570
+ errors.push(`${at}.anchor.rect must carry numeric ${RECT_KEYS.join('/')} when present`);
571
+ }
572
+ if (!numOrNull(a.node ?? null)) errors.push(`${at}.anchor.node must be a number or null`);
573
+ }
574
+ if (e.type === 'mouse.move') errors.push(`${at}: alignment fields never ride mouse.move (spec §6)`);
575
+ }
576
+ }
577
+
578
+ function hintFor(type) {
579
+ return {
580
+ 'mouse.down': 'x/y/button/target', 'mouse.up': 'x/y/button/target', 'mouse.click': 'x/y/button/target',
581
+ 'key.down': 'key/code/mods/repeat/target', 'key.up': 'key/code/mods/repeat/target',
582
+ 'input.value': 'value or value_len', 'input.select': 'values as an array of strings',
583
+ 'touch.start': 'touches[] of {id,x,y}', 'touch.move': 'touches[] of {id,x,y}',
584
+ 'touch.end': 'touches[] of {id,x,y}',
585
+ 'media.play': 'node/current_time', 'media.pause': 'node/current_time',
586
+ 'media.ended': 'node/current_time', 'media.seeked': 'node/current_time',
587
+ 'media.time': 'node/current_time',
588
+ 'canvas.snapshot': 'node/data_url, optional numeric region x/y/w/h',
589
+ 'clipboard.copy': 'target/text/html/len, content withheld under redacted',
590
+ 'clipboard.cut': 'target/text/html/len, content withheld under redacted',
591
+ 'clipboard.paste': 'target/text/html/len, content withheld under redacted',
592
+ 'clipboard.drop': 'target/text/html/len, content withheld under redacted',
593
+ 'recording.capture_stopped': 'reason "buffer_limit" | "error"',
594
+ }[type] ?? 'see spec §5';
595
+ }