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.
- package/CHANGELOG.md +63 -0
- package/CITATION.cff +2 -2
- package/README.md +21 -6
- package/dist/cyborg-hunter-replay.js +3 -3
- package/dist/cyborg-hunter.esm.js +3 -2
- package/dist/cyborg-hunter.min.js +3 -3
- package/dist/extension-cyborg-hunter.js +1 -1
- package/dist/extension-guard-friction.js +5 -5
- package/package.json +10 -2
- package/src/cli/ingest.js +311 -57
- package/src/cli/renderers/html-index-core.js +66 -12
- package/src/cli/renderers/html-index.js +7 -5
- package/src/cli/renderers/replay-assets.js +61 -7
- package/src/cli/renderers/replay-client-source.js +102 -0
- package/src/cli/renderers/replay-viewer.client.js +1750 -595
- package/src/cli/report.js +6 -0
- package/src/core/monitor.js +12 -13
- package/src/jspsych/extension-cyborg-hunter-replay.js +64 -3
- package/src/jspsych/extension-cyborg-hunter.js +1 -1
- package/src/jspsych/extension-guard-friction.js +59 -1
- package/src/replay/capture-dom.js +470 -458
- package/src/replay/capture-trace.js +688 -271
- package/src/replay/delivery.js +82 -0
- package/src/replay/dom-instantiate.js +779 -0
- package/src/replay/index.js +88 -5
- package/src/replay/initial-state.js +295 -0
- package/src/replay/mutations.js +668 -0
- package/src/replay/node-registry.js +116 -0
- package/src/replay/persistence.js +19 -6
- package/src/replay/recorder.js +342 -73
- package/src/replay/redaction.js +165 -0
- package/src/replay/serializer.js +148 -43
- package/src/replay/snapshot.js +409 -0
- package/src/replay/span.js +55 -0
- package/src/replay/viewer-model.js +293 -102
- package/src/shared/constants.js +1 -1
- package/src/shared/inline-safe.js +80 -0
- package/src/shared/schema-v2-validator.js +595 -0
- 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
|
+
}
|