cyborg-hunter 0.9.1 → 0.10.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 +98 -0
- package/CITATION.cff +1 -1
- package/README.md +33 -32
- package/dist/ch.js +41 -0
- package/dist/cyborg-hunter-replay.js +3 -3
- package/dist/cyborg-hunter.esm.js +1 -1
- package/dist/cyborg-hunter.min.js +4 -3
- package/dist/extension-cyborg-hunter.js +1 -1
- package/dist/extension-guard-friction.js +5 -5
- package/dist/extension-guard-honeypot.js +1 -1
- package/package.json +11 -5
- package/src/cli/extract-core.js +70 -4
- package/src/cli/renderers/replay-client-source.js +2 -2
- package/src/cli/segment-reassembly.js +204 -0
- package/src/jspsych/extension-cyborg-hunter-replay.js +1 -1
- package/src/jspsych/extension-cyborg-hunter.js +1 -1
- package/src/jspsych/extension-guard-friction.js +21 -7
- package/src/jspsych/extension-guard-honeypot.js +13 -6
- package/src/oneliner/adapters/jspsych-extension.js +196 -0
- package/src/oneliner/adapters/jspsych.js +483 -0
- package/src/oneliner/adapters/vanilla.js +420 -0
- package/src/oneliner/api.js +151 -0
- package/src/oneliner/boot.js +270 -0
- package/src/oneliner/config.js +122 -0
- package/src/oneliner/debug.js +137 -0
- package/src/oneliner/entry.js +8 -0
- package/src/oneliner/errors.js +219 -0
- package/src/oneliner/guards.js +67 -0
- package/src/oneliner/participant-id.js +49 -0
- package/src/oneliner/replay-loader.js +296 -0
- package/src/oneliner/segment-diff.js +68 -0
- package/src/oneliner/segmenter.js +186 -0
- package/src/replay/dom-instantiate.js +10 -1
- package/src/replay/index.js +4 -2
- package/src/replay/recorder.js +5 -1
- package/src/shared/constants.js +1 -1
- package/src/shared/schema-v2-validator.js +14 -9
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// src/oneliner/segment-diff.js
|
|
2
|
+
// Turns the monitor's cumulative session report into per-segment deltas.
|
|
3
|
+
// The session arrays are append-only (every write in src/core is a .push), so
|
|
4
|
+
// "what happened since the last cut" is report[key].slice(lastSeen[key]).
|
|
5
|
+
// Keys are discovered at every cut, not fixed at build time, so a signal added
|
|
6
|
+
// to the core later flows through without touching this file.
|
|
7
|
+
//
|
|
8
|
+
// createSegmentDiffer(monitor) → { cut(meta) → segment, seen() → {[key]: number} }
|
|
9
|
+
// monitor: anything with getSessionReport() and getSessionScore()
|
|
10
|
+
// meta: { segmentIndex, source: 'manual'|'host'|'page'|'final', trialId,
|
|
11
|
+
// pageOrigin, trialReport?, gapReports? }
|
|
12
|
+
// segment: { segmentIndex, source, trialId, pageOrigin,
|
|
13
|
+
// deltas: { [arrayKey]: any[] }, // every array key except aliases
|
|
14
|
+
// counters: { pasteCount, copyCount, dropCount },
|
|
15
|
+
// score: getSessionScore() verbatim,
|
|
16
|
+
// gap?: [...], // non-empty gap reports only
|
|
17
|
+
// config?, libraryVersion? } // segmentIndex === 0 only
|
|
18
|
+
//
|
|
19
|
+
// The CLI side (src/cli/segment-reassembly.js) concatenates the deltas back
|
|
20
|
+
// into the session shape finalize() dumps.
|
|
21
|
+
|
|
22
|
+
// Keys that are the same array as another key inside the monitor (monitor.js
|
|
23
|
+
// keeps layoutShifts as a deprecated alias of viewportWidthShifts). Skipped on
|
|
24
|
+
// the way out so the entries are not shipped twice; restored on the way in
|
|
25
|
+
// (src/cli/segment-reassembly.js keeps a copy; a test pins the two equal).
|
|
26
|
+
export const ALIAS_KEYS = { layoutShifts: 'viewportWidthShifts' };
|
|
27
|
+
|
|
28
|
+
export function createSegmentDiffer(monitor) {
|
|
29
|
+
var lastSeen = {};
|
|
30
|
+
function arrayKeys(report) {
|
|
31
|
+
return Object.keys(report).filter(function (k) {
|
|
32
|
+
return Array.isArray(report[k]) && !(k in ALIAS_KEYS);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
// A gap report covers the time between two host trials. Most are empty;
|
|
36
|
+
// only those with paste/copy/drop/synthetic-insertion evidence are kept,
|
|
37
|
+
// and only those four arrays plus the duration (no mouse trace).
|
|
38
|
+
function nonEmptyGap(r) {
|
|
39
|
+
var keys = ['pasteEvents', 'copyEvents', 'dropEvents', 'syntheticInsertions'];
|
|
40
|
+
var any = keys.some(function (k) { return Array.isArray(r[k]) && r[k].length > 0; });
|
|
41
|
+
if (!any) return null;
|
|
42
|
+
var out = { duration_ms: r.duration_ms };
|
|
43
|
+
keys.forEach(function (k) { out[k] = r[k] || []; });
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
seen: function () { return Object.assign({}, lastSeen); },
|
|
48
|
+
cut: function (meta) {
|
|
49
|
+
var report = monitor.getSessionReport(); // one deep copy per cut (monitor.js getSessionReport); O(session size)
|
|
50
|
+
var deltas = {};
|
|
51
|
+
arrayKeys(report).forEach(function (k) {
|
|
52
|
+
var from = lastSeen[k] || 0;
|
|
53
|
+
deltas[k] = report[k].slice(from);
|
|
54
|
+
lastSeen[k] = report[k].length;
|
|
55
|
+
});
|
|
56
|
+
var seg = {
|
|
57
|
+
segmentIndex: meta.segmentIndex, source: meta.source, trialId: meta.trialId, pageOrigin: meta.pageOrigin,
|
|
58
|
+
deltas: deltas,
|
|
59
|
+
counters: { pasteCount: report.pasteCount, copyCount: report.copyCount, dropCount: report.dropCount },
|
|
60
|
+
score: monitor.getSessionScore()
|
|
61
|
+
};
|
|
62
|
+
var gaps = (meta.gapReports || []).map(nonEmptyGap).filter(Boolean);
|
|
63
|
+
if (gaps.length) seg.gap = gaps;
|
|
64
|
+
if (meta.segmentIndex === 0) { seg.config = report.config; seg.libraryVersion = report.libraryVersion; }
|
|
65
|
+
return seg;
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// src/oneliner/segmenter.js
|
|
2
|
+
// Keeps the monitor permanently inside a trial. The core attaches its paste,
|
|
3
|
+
// typing, mouse, idle-gap and element-trace listeners only between startTrial
|
|
4
|
+
// and endTrial (monitor.js startTrial), so a paste outside a trial is never
|
|
5
|
+
// recorded. The one-line setup therefore opens a span at boot and, at every
|
|
6
|
+
// boundary, closes the current trial and opens the next one in the same call.
|
|
7
|
+
//
|
|
8
|
+
// createSegmenter({ monitor, differ, clock, sourceDefault }) → {
|
|
9
|
+
// start({ trialId }) open the boot span; no-op if already open
|
|
10
|
+
// → null | { error }
|
|
11
|
+
// rotate(opts) close the span (a "gap"), open the host's trial;
|
|
12
|
+
// no segment; the gap report is buffered for the
|
|
13
|
+
// next cut() and also returned
|
|
14
|
+
// → gapReport | null (nothing was open) | { error }
|
|
15
|
+
// cut({ source, nextTrialId?, nextOpts? }) close + segment + open next
|
|
16
|
+
// → { segment, trialReport } success
|
|
17
|
+
// → { segment, trialReport, error } cut OK, reopen failed
|
|
18
|
+
// → { error } nothing was cut
|
|
19
|
+
// finish({ source }) close + segment, nothing reopened
|
|
20
|
+
// → { segment, trialReport } | null (closed) | { error }
|
|
21
|
+
// abandon() close without a segment (manual-mode hand-over)
|
|
22
|
+
// → null | { error }
|
|
23
|
+
// state() { open, segmentIndex, currentTrialId }
|
|
24
|
+
// setSegmentIndex(n) multi-page restore
|
|
25
|
+
// }
|
|
26
|
+
// differ: createSegmentDiffer(monitor) (src/oneliner/segment-diff.js)
|
|
27
|
+
// clock: () => number, the page origin (performance.timeOrigin); injectable
|
|
28
|
+
//
|
|
29
|
+
// Contract for the host adapters: a result with `segment` must be saved even
|
|
30
|
+
// when it also carries `error` (the data is complete; only the next span failed
|
|
31
|
+
// to open, and the error becomes the cyborgHunterError marker). A result with
|
|
32
|
+
// `error` alone means nothing was cut.
|
|
33
|
+
//
|
|
34
|
+
// None of these throws into the host: a monitor or differ failure is logged
|
|
35
|
+
// and returned as { error: message }. After finish() or abandon() the segmenter
|
|
36
|
+
// is latched: start/rotate/cut return { error: 'finished' | 'abandoned' }
|
|
37
|
+
// without touching the monitor, and finish() returns null.
|
|
38
|
+
//
|
|
39
|
+
// Keeping `open` true to the monitor. The monitor has no state getter, but
|
|
40
|
+
// transition() is the FIRST statement of both startTrial and endTrial
|
|
41
|
+
// (monitor.js), and its rejection message names the current state. So:
|
|
42
|
+
// - any endTrial throw leaves the monitor outside a trial (rejected, or
|
|
43
|
+
// already transitioned) → open = false;
|
|
44
|
+
// - a startTrial throw that is not a lifecycle rejection means the
|
|
45
|
+
// transition happened → the trial IS open (listeners maybe partly
|
|
46
|
+
// attached) → open = true, so cut()/finish() still segment it rather than
|
|
47
|
+
// drop it, and endTrial's removeTrialListeners cleans up whatever attached;
|
|
48
|
+
// - a startTrial rejected "from 'trial'" means a trial we thought closed is
|
|
49
|
+
// still open → close it, keep its report as a gap, and retry once.
|
|
50
|
+
|
|
51
|
+
// Mirrors the message thrown by monitor.js transition(); null when `e` is not
|
|
52
|
+
// a lifecycle rejection.
|
|
53
|
+
var LIFECYCLE_FROM = /invalid lifecycle call: cannot transition from '(\w+)'/;
|
|
54
|
+
function lifecycleFrom(e) {
|
|
55
|
+
var m = LIFECYCLE_FROM.exec(String((e && e.message) || e));
|
|
56
|
+
return m ? m[1] : null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function createSegmenter(opts) {
|
|
60
|
+
var monitor = opts.monitor;
|
|
61
|
+
var differ = opts.differ;
|
|
62
|
+
var clock = opts.clock || function () { return performance.timeOrigin; };
|
|
63
|
+
var sourceDefault = opts.sourceDefault || 'host';
|
|
64
|
+
|
|
65
|
+
var open = false;
|
|
66
|
+
var latched = null; // 'finished' | 'abandoned': the segmenter no longer drives the monitor
|
|
67
|
+
var segmentIndex = 0;
|
|
68
|
+
var currentTrialId = null;
|
|
69
|
+
var gapReports = []; // gap trial reports since the last cut
|
|
70
|
+
|
|
71
|
+
function fail(what, e) {
|
|
72
|
+
var message = String((e && e.message) || e);
|
|
73
|
+
console.error('[cyborg-hunter] ' + what + ' failed: ' + message);
|
|
74
|
+
return { error: message };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// A span the host did not name is called after the segment it will become.
|
|
78
|
+
function spanId() { return 'span-' + segmentIndex; }
|
|
79
|
+
|
|
80
|
+
function closeTrial() {
|
|
81
|
+
try { return monitor.endTrial(); }
|
|
82
|
+
finally { open = false; }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// The explicit trialId always wins: it is assigned last, so a trialId inside
|
|
86
|
+
// `extra` (nextOpts, rotate's opts) can never rename the span.
|
|
87
|
+
function openTrial(trialId, extra) {
|
|
88
|
+
var args = Object.assign({}, extra || {}, { trialId: trialId });
|
|
89
|
+
try {
|
|
90
|
+
monitor.startTrial(args);
|
|
91
|
+
} catch (e) {
|
|
92
|
+
var from = lifecycleFrom(e);
|
|
93
|
+
if (from === 'trial') {
|
|
94
|
+
gapReports.push(closeTrial());
|
|
95
|
+
monitor.startTrial(args);
|
|
96
|
+
} else {
|
|
97
|
+
if (from === null) { currentTrialId = trialId; open = true; }
|
|
98
|
+
throw e;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
currentTrialId = trialId;
|
|
102
|
+
open = true;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Close the open trial and turn everything since the last cut into a segment.
|
|
106
|
+
function closeAndSegment(source) {
|
|
107
|
+
var report = closeTrial();
|
|
108
|
+
var segment = differ.cut({
|
|
109
|
+
segmentIndex: segmentIndex, source: source, trialId: currentTrialId,
|
|
110
|
+
pageOrigin: clock(), trialReport: report, gapReports: gapReports
|
|
111
|
+
});
|
|
112
|
+
gapReports = [];
|
|
113
|
+
segmentIndex += 1;
|
|
114
|
+
return { segment: segment, trialReport: report };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
start: function (o) {
|
|
119
|
+
if (latched) return { error: latched };
|
|
120
|
+
if (open) return null;
|
|
121
|
+
try {
|
|
122
|
+
openTrial((o && o.trialId) || spanId());
|
|
123
|
+
return null;
|
|
124
|
+
} catch (e) { return fail('start', e); }
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
rotate: function (o) {
|
|
128
|
+
if (latched) return { error: latched };
|
|
129
|
+
o = o || {};
|
|
130
|
+
var gap = null;
|
|
131
|
+
try {
|
|
132
|
+
if (open) {
|
|
133
|
+
gap = closeTrial();
|
|
134
|
+
gapReports.push(gap);
|
|
135
|
+
}
|
|
136
|
+
} catch (e) { return fail('trial rotation', e); }
|
|
137
|
+
var extra = Object.assign({}, o);
|
|
138
|
+
delete extra.trialId;
|
|
139
|
+
try {
|
|
140
|
+
openTrial(o.trialId || spanId(), extra);
|
|
141
|
+
} catch (e) { return fail('trial rotation', e); } // the gap, if any, stays buffered
|
|
142
|
+
return gap;
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
cut: function (o) {
|
|
146
|
+
if (latched) return { error: latched };
|
|
147
|
+
o = o || {};
|
|
148
|
+
var out;
|
|
149
|
+
try {
|
|
150
|
+
out = closeAndSegment(o.source || sourceDefault);
|
|
151
|
+
} catch (e) { return fail('segment write', e); }
|
|
152
|
+
try {
|
|
153
|
+
openTrial(o.nextTrialId || spanId(), o.nextOpts);
|
|
154
|
+
} catch (e) { out.error = fail('trial reopen', e).error; }
|
|
155
|
+
return out;
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
finish: function (o) {
|
|
159
|
+
if (latched) return null;
|
|
160
|
+
latched = 'finished';
|
|
161
|
+
if (!open) return null;
|
|
162
|
+
try {
|
|
163
|
+
return closeAndSegment((o && o.source) || 'final');
|
|
164
|
+
} catch (e) { return fail('segment write', e); }
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
// Hands the monitor to the host (manual mode). Gap reports still buffered
|
|
168
|
+
// are intentionally discarded: no cut() follows, and the abandoned spans
|
|
169
|
+
// carry no host trial to attach them to.
|
|
170
|
+
abandon: function () {
|
|
171
|
+
if (latched) return null;
|
|
172
|
+
latched = 'abandoned';
|
|
173
|
+
if (!open) return null;
|
|
174
|
+
try {
|
|
175
|
+
closeTrial();
|
|
176
|
+
return null;
|
|
177
|
+
} catch (e) { return fail('abandon', e); }
|
|
178
|
+
},
|
|
179
|
+
|
|
180
|
+
state: function () {
|
|
181
|
+
return { open: open, segmentIndex: segmentIndex, currentTrialId: currentTrialId };
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
setSegmentIndex: function (n) { segmentIndex = n; }
|
|
185
|
+
};
|
|
186
|
+
}
|
|
@@ -616,7 +616,7 @@ function mountTree(domNode, body, doc) {
|
|
|
616
616
|
//
|
|
617
617
|
// TOLERANT, COUNTED, SURFACED (design §4). A patch naming an id the map does
|
|
618
618
|
// not hold is skipped and counted into `patchFailures`, never thrown on. This
|
|
619
|
-
// is a DELIBERATE divergence from `
|
|
619
|
+
// is a DELIBERATE divergence from `packages/sessionrecording-conformance/src/fuzz/dom-player.js`, which
|
|
620
620
|
// throws on the same input: the test player's job is to catch mapper bugs, the
|
|
621
621
|
// viewer's job is to show an analyst as much of an unrepeatable session as
|
|
622
622
|
// survives. The consequence is recorded and routed — a tolerant viewer cannot
|
|
@@ -715,6 +715,15 @@ function applyAttr(patch, mount) {
|
|
|
715
715
|
el.removeAttribute(name);
|
|
716
716
|
return;
|
|
717
717
|
}
|
|
718
|
+
// An iframe placeholder never receives the IFRAME_SKIP names after mount
|
|
719
|
+
// either, or a later patch could re-arm what instantiation disarmed. The
|
|
720
|
+
// marker is viewer-owned on both verbs, so a recording can neither forge nor
|
|
721
|
+
// strip it, and checking it here is checking what instantiation decided.
|
|
722
|
+
// Silent and uncounted, like the viewer-owned refusals. Defence in depth:
|
|
723
|
+
// the viewer's `frame-src 'none'` CSP already blocks the load. SET verb
|
|
724
|
+
// only — the placeholder carries no label a removal could strip.
|
|
725
|
+
if (el.getAttribute(PLACEHOLDER_ATTR) === 'iframe'
|
|
726
|
+
&& IFRAME_SKIP[name.toLowerCase()] === true) return;
|
|
718
727
|
// ONE §12 gate on the set path, and it is the gate instantiation uses, so a
|
|
719
728
|
// keyframe and a patch can never disagree about what an element may carry.
|
|
720
729
|
setFilteredAttr(el, name, patch.value, mount);
|
package/src/replay/index.js
CHANGED
|
@@ -181,7 +181,9 @@ export function attach(userConfig) {
|
|
|
181
181
|
export { replayFilename, buildReplayMeta, serialize };
|
|
182
182
|
|
|
183
183
|
// Browser global — same pattern as the guard extensions (explicit window
|
|
184
|
-
// assignment; no esbuild globalName).
|
|
184
|
+
// assignment; no esbuild globalName). replayFilename rides along so a
|
|
185
|
+
// researcher saving the recording to their own server names it exactly as
|
|
186
|
+
// the CLI expects (sanitized participant id), without an ES import.
|
|
185
187
|
if (typeof window !== 'undefined') {
|
|
186
|
-
window.CyborgHunterReplay = { attach: attach };
|
|
188
|
+
window.CyborgHunterReplay = { attach: attach, replayFilename: replayFilename };
|
|
187
189
|
}
|
package/src/replay/recorder.js
CHANGED
|
@@ -360,7 +360,11 @@ export function createRecorder(userConfig) {
|
|
|
360
360
|
? { w: de.clientWidth || 0, h: de.clientHeight || 0 } : null;
|
|
361
361
|
session.userAgent = typeof navigator !== 'undefined' && navigator.userAgent
|
|
362
362
|
? String(navigator.userAgent) : '';
|
|
363
|
-
|
|
363
|
+
// _ownerSavesRecording (internal): the embedding library takes the
|
|
364
|
+
// recording itself and tells its user how to save it (the one-line
|
|
365
|
+
// setup's CyborgHunter.replay(), src/oneliner/replay-loader.js), so
|
|
366
|
+
// this warning, which names getRecording(), would only mislead there.
|
|
367
|
+
if (config.autoSave.mode === 'none' && !config._ownerSavesRecording) {
|
|
364
368
|
console.warn('[cyborg-hunter-replay] autoSave.mode is "none" — the recording will be lost unless you call getRecording() yourself.');
|
|
365
369
|
}
|
|
366
370
|
},
|
package/src/shared/constants.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Default thresholds and preset configurations for Cyborg Hunter.
|
|
3
3
|
// Single source of truth — used by both the browser library and CLI report tool.
|
|
4
4
|
|
|
5
|
-
export const VERSION = "0.
|
|
5
|
+
export const VERSION = "0.10.0";
|
|
6
6
|
|
|
7
7
|
// Default detection thresholds shared across presets.
|
|
8
8
|
// Researchers can override any value at init() time.
|
|
@@ -1,6 +1,11 @@
|
|
|
1
|
+
// GENERATED from packages/sessionrecording-conformance/src/validator.js — do not edit; run npm run sync:conformance
|
|
2
|
+
//
|
|
3
|
+
// The conformance package is the source of truth. CH ships this copy so the
|
|
4
|
+
// published tarball can validate without resolving a workspace package.
|
|
5
|
+
|
|
1
6
|
// SessionRecording v2 validator — dual profiles per spec §11.
|
|
2
|
-
// Zero dependencies. Lifted from tests/replay/schema-v2/ into shipped code
|
|
3
|
-
//
|
|
7
|
+
// Zero dependencies. Lifted from tests/replay/schema-v2/ into shipped code:
|
|
8
|
+
// ingest strict-validates converted recordings in-process (spec §11 A2 —
|
|
4
9
|
// warn, never refuse), so the validator is a runtime dependency of the CLI.
|
|
5
10
|
|
|
6
11
|
// Gzip magic bytes (RFC 1952): 0x1f 0x8b.
|
|
@@ -50,8 +55,8 @@ export function validateTolerant(input) {
|
|
|
50
55
|
}
|
|
51
56
|
if (errors.length) return { ok: false, recording: null, errors, warnings };
|
|
52
57
|
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 (
|
|
54
|
-
//
|
|
58
|
+
// WARN ON MALFORMED KNOWN FIELDS — WARN, NEVER COERCE (settled here after
|
|
59
|
+
// being parked from two reviews). Before this, a present-but-malformed known
|
|
55
60
|
// field (`stylesheets: null`, `truncated: "yes"`) passed through in total
|
|
56
61
|
// silence: defaults fill ABSENT keys only, and the analyst opening the file
|
|
57
62
|
// got no signal at all. Coercing is the wrong repair — overwriting
|
|
@@ -253,8 +258,8 @@ export function validateStrict(input) {
|
|
|
253
258
|
errors.push('end_reason must be "finished" | "aborted" | "unload" | null');
|
|
254
259
|
}
|
|
255
260
|
if (typeof r.truncated !== 'boolean') errors.push('truncated must be a boolean');
|
|
256
|
-
// `host`
|
|
257
|
-
// `host: {name: 42}` was strict-valid
|
|
261
|
+
// `host` used to appear ONCE in this file — in TOP_DEFAULTS — so
|
|
262
|
+
// `host: {name: 42}` was strict-valid.
|
|
258
263
|
if (r.host !== null && !(isPlainObject(r.host)
|
|
259
264
|
&& typeof r.host.name === 'string' && typeof r.host.version === 'string')) {
|
|
260
265
|
errors.push('host must be {name, version} strings or null');
|
|
@@ -383,7 +388,7 @@ export const REDACTABLE_TYPES = new Set([
|
|
|
383
388
|
'clipboard.copy', 'clipboard.cut', 'clipboard.paste', 'clipboard.drop',
|
|
384
389
|
]);
|
|
385
390
|
|
|
386
|
-
// ── §5.3 clipboard: REQUIRED-BUT-NULLABLE, settled here
|
|
391
|
+
// ── §5.3 clipboard: REQUIRED-BUT-NULLABLE, settled here ────────────────────
|
|
387
392
|
// The parked question was whether §5.3's four fields are required-but-nullable
|
|
388
393
|
// or typed-only-if-present. Settled as REQUIRED, for one reason that is not
|
|
389
394
|
// about strictness for its own sake: §5.3 defines its TWO PRODUCER MODES by
|
|
@@ -484,8 +489,8 @@ function checkSegmentsStrict(r, errors) {
|
|
|
484
489
|
if (s.initial_dom != null && !isKeyframe) {
|
|
485
490
|
errors.push(`${at}.initial_dom must be a DomNode object or null`);
|
|
486
491
|
}
|
|
487
|
-
// The keyframe tree itself, recursively (spec §4).
|
|
488
|
-
//
|
|
492
|
+
// The keyframe tree itself, recursively (spec §4). The validator used to
|
|
493
|
+
// stop at "is an object": a keyframe whose children were strings, or
|
|
489
494
|
// whose ids were absent, was strict-valid and only failed inside a player.
|
|
490
495
|
if (isKeyframe) checkDomNode(s.initial_dom, `${at}.initial_dom`, errors, 1);
|
|
491
496
|
if (isKeyframe) sawKeyframe = true;
|