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
|
@@ -1,125 +1,316 @@
|
|
|
1
1
|
// src/replay/viewer-model.js
|
|
2
|
-
//
|
|
3
|
-
// demo can bundle it directly (0.7.2 extraction from
|
|
4
|
-
// cli/renderers/replay-assets.js, which re-exports this for existing
|
|
5
|
-
// callers).
|
|
2
|
+
// SessionRecording v2 (spec r2) → viewer model. Pure — no Node APIs — so a
|
|
3
|
+
// browser demo can bundle it directly (0.7.2 extraction from
|
|
4
|
+
// cli/renderers/replay-assets.js, which re-exports this for existing callers).
|
|
6
5
|
//
|
|
7
6
|
// This is one of exactly two allowed wire→viewer time-conversion points (the
|
|
8
|
-
// other lives in the CLI ingest path): SessionRecording carries ms since
|
|
9
|
-
//
|
|
7
|
+
// other lives in the CLI ingest path): a SessionRecording carries ms since
|
|
8
|
+
// `recording_started_at_perf`; the viewer speaks segment-relative ms.
|
|
9
|
+
//
|
|
10
|
+
// Input is a parsed v2 recording from ANY producer, not just CH's recorder:
|
|
11
|
+
// the shared format's whole point is that a conforming file plays in either
|
|
12
|
+
// project's player. Everything CH-specific is read from
|
|
13
|
+
// `extensions['cyborg-hunter']` and guarded on its own key.
|
|
14
|
+
//
|
|
15
|
+
// The model is a frozen wire format between two files that ship together
|
|
16
|
+
// (renderReplayAssets writes it as JSONP; the report inlines its own copy of
|
|
17
|
+
// the viewer client), so there is no back-compat problem here — and no
|
|
18
|
+
// back-compat either. There is no CH-v1 playback path: v1 recordings are
|
|
19
|
+
// rejected by the §11 tolerant profile below and are played with the v0.7.1
|
|
20
|
+
// tag (design §12).
|
|
21
|
+
|
|
22
|
+
// Spec §11, tolerant loader profile. Reject ONLY these four categories;
|
|
23
|
+
// everything else loads with a warning-free documented default. Rationale:
|
|
24
|
+
// recordings are unrepeatable participant data, so rejection at runtime is
|
|
25
|
+
// data loss — strictness lives in CI (src/shared/schema-v2-validator.js),
|
|
26
|
+
// where it hits a developer instead of an analyst. The reason strings mirror
|
|
27
|
+
// that validator's tolerant profile verbatim so the repo holds ONE reading of
|
|
28
|
+
// the profile rather than two that can drift.
|
|
29
|
+
function tolerantReasons(rec) {
|
|
30
|
+
const reasons = [];
|
|
31
|
+
if (typeof rec !== 'object' || rec === null || Array.isArray(rec)) {
|
|
32
|
+
return ['recording must be a JSON object'];
|
|
33
|
+
}
|
|
34
|
+
if (rec.schema_version !== 2) reasons.push('schema_version must be the integer 2');
|
|
35
|
+
if (!rec.recorder || typeof rec.recorder.name !== 'string' || typeof rec.recorder.version !== 'string') {
|
|
36
|
+
reasons.push('recorder {name, version} strings are required');
|
|
37
|
+
}
|
|
38
|
+
if (!Array.isArray(rec.segments)) {
|
|
39
|
+
reasons.push('segments array is required');
|
|
40
|
+
} else {
|
|
41
|
+
rec.segments.forEach((s, i) => {
|
|
42
|
+
if (!s || typeof s !== 'object' || !Array.isArray(s.events)) {
|
|
43
|
+
reasons.push('segments[' + i + '] must carry an events array');
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
return reasons;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const asArray = (v) => (Array.isArray(v) ? v : []);
|
|
51
|
+
const num = (v) => (typeof v === 'number' && isFinite(v) ? v : null);
|
|
52
|
+
// The wire rounds to 0.1 ms (spec §7); rebasing two rounded floats
|
|
53
|
+
// reintroduces binary noise (2100 − 2000 = 99.99999999999999), so every
|
|
54
|
+
// derived time is re-rounded to the same precision.
|
|
55
|
+
const round1 = (v) => Math.round(v * 10) / 10;
|
|
56
|
+
|
|
57
|
+
// A keyframe is a DomNode OBJECT. A primitive (a v1 HTML string, say) or an
|
|
58
|
+
// array carries none of a DomNode's fields, so treating it as a keyframe
|
|
59
|
+
// would hand `mountTree` something it cannot instantiate and would license
|
|
60
|
+
// later `dom.*` patches against a snapshot that does not exist. Same test as
|
|
61
|
+
// the strict profile's `isKeyframe` (validator.js:203-204).
|
|
62
|
+
const isKeyframe = (s) => s.initial_dom != null && typeof s.initial_dom === 'object'
|
|
63
|
+
&& !Array.isArray(s.initial_dom);
|
|
10
64
|
|
|
11
65
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
66
|
+
* The badge a recording earns: the stated tier wins, otherwise infer it
|
|
67
|
+
* structurally so a foreign file gets an honest badge instead of "trace"
|
|
68
|
+
* (design §10). Exported because the report's index renders the same badge
|
|
69
|
+
* from the RECORDING (html-index-core.js) while the viewer renders it from
|
|
70
|
+
* the MODEL — one reading of the rule, in one place, or the two drift.
|
|
71
|
+
*
|
|
72
|
+
* Safe on anything: the index calls it on an artifact that has not been
|
|
73
|
+
* through the §11 profile yet.
|
|
74
|
+
*
|
|
75
|
+
* @param {object} recording a parsed SessionRecording v2, any producer
|
|
76
|
+
* @returns {'dom'|'trace'|string} the stated tier, or the inferred one
|
|
77
|
+
*/
|
|
78
|
+
export function inferTier(recording) {
|
|
79
|
+
const ext = (recording && recording.extensions && recording.extensions['cyborg-hunter']) || {};
|
|
80
|
+
if (ext.tier) return ext.tier;
|
|
81
|
+
const segs = (recording && Array.isArray(recording.segments)) ? recording.segments : [];
|
|
82
|
+
return segs.some((s) => s && isKeyframe(s)) ? 'dom' : 'trace';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @param {object} recording a parsed SessionRecording v2, any producer
|
|
87
|
+
* @returns {object} the viewer model (design §9)
|
|
88
|
+
* @throws {Error} with `.reasons` when the §11 tolerant profile rejects it
|
|
15
89
|
*/
|
|
16
90
|
export function buildViewerModel(recording) {
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
} else if (e.kind === 'resize') {
|
|
48
|
-
state.w = e.w != null ? e.w : state.w;
|
|
49
|
-
state.h = e.h != null ? e.h : state.h;
|
|
50
|
-
state.cw = e.cw != null ? e.cw : (e.w != null ? e.w - sbW : state.cw);
|
|
51
|
-
state.ch = e.ch != null ? e.ch : (e.h != null ? e.h - sbH : state.ch);
|
|
52
|
-
if (e.dpr != null) state.dpr = e.dpr;
|
|
53
|
-
}
|
|
54
|
-
return state;
|
|
91
|
+
const reasons = tolerantReasons(recording);
|
|
92
|
+
if (reasons.length) {
|
|
93
|
+
const err = new Error('buildViewerModel: not a loadable SessionRecording v2 — ' + reasons.join('; '));
|
|
94
|
+
err.reasons = reasons;
|
|
95
|
+
throw err;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const rec = recording;
|
|
99
|
+
// `extensions` is nullable at every level (spec §2/§9), and a foreign
|
|
100
|
+
// producer may carry the namespace for its own reasons — the T4 converter
|
|
101
|
+
// stamps provenance into it. Read individual keys; never treat presence of
|
|
102
|
+
// the namespace as evidence about the producer.
|
|
103
|
+
const ext = (rec.extensions && rec.extensions['cyborg-hunter']) || {};
|
|
104
|
+
|
|
105
|
+
// ── session viewport and the client (layout) box ────────────────────────
|
|
106
|
+
// Spec §2's ViewportState has no client-box room, so the layout box the page
|
|
107
|
+
// was formatted against travels as CH vendor data. The iframe must be sized
|
|
108
|
+
// to the LAYOUT width or every reconstruction is a scrollbar-width off.
|
|
109
|
+
// Chain (design §8): per-event `camera.client_w` (the viewer's business, at
|
|
110
|
+
// the moment of an anchored event) → `viewport_client` at session start →
|
|
111
|
+
// folded w/h minus the session scrollbar delta. The delta is computed from
|
|
112
|
+
// `viewport_client` ITSELF, so an absent one is a delta of zero rather than
|
|
113
|
+
// an arithmetic accident — which is exactly the foreign-file case.
|
|
114
|
+
const viewport = rec.viewport && typeof rec.viewport === 'object' ? rec.viewport : null;
|
|
115
|
+
const viewportClient = ext.viewport_client || null;
|
|
116
|
+
const scrollbar = {
|
|
117
|
+
w: viewport && viewportClient && num(viewport.w) != null && num(viewportClient.w) != null
|
|
118
|
+
? viewport.w - viewportClient.w : 0,
|
|
119
|
+
h: viewport && viewportClient && num(viewport.h) != null && num(viewportClient.h) != null
|
|
120
|
+
? viewport.h - viewportClient.h : 0,
|
|
55
121
|
};
|
|
56
122
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
123
|
+
const viewportChanges = asArray(rec.viewport_changes)
|
|
124
|
+
.filter((c) => c && typeof c === 'object')
|
|
125
|
+
.slice()
|
|
126
|
+
.sort((a, b) => (num(a.t) || 0) - (num(b.t) || 0));
|
|
127
|
+
|
|
128
|
+
// ── segments ────────────────────────────────────────────────────────────
|
|
129
|
+
const rawSegments = rec.segments;
|
|
130
|
+
|
|
131
|
+
let spanStart = null; // index of the keyframe the current span opens at
|
|
132
|
+
const segments = rawSegments.map((s, i) => {
|
|
133
|
+
const keyframe = isKeyframe(s);
|
|
134
|
+
if (keyframe) spanStart = i;
|
|
135
|
+
|
|
136
|
+
const tStart = num(s.t_start);
|
|
137
|
+
const tDomReady = num(s.t_dom_ready);
|
|
138
|
+
const tLoad = num(s.t_load);
|
|
139
|
+
const tEnd = num(s.t_end);
|
|
140
|
+
|
|
141
|
+
// Events are sorted BEFORE the origin is taken, because the origin can
|
|
142
|
+
// fall back to the first event's time and the recorded array is not
|
|
143
|
+
// guaranteed ordered in practice: RAF-coalesced input flushes with an
|
|
144
|
+
// EARLIER timestamp than events pushed after it was enqueued. Spec §7
|
|
145
|
+
// requires producers to emit sorted arrays; the viewer scans forward and
|
|
146
|
+
// would mis-apply an out-of-order event on a seek, so a foreign producer's
|
|
147
|
+
// bug must not become CH's rendering bug. Stable sort keeps equal-t order,
|
|
148
|
+
// which §7 makes authoritative.
|
|
149
|
+
const events = asArray(s.events)
|
|
69
150
|
.filter((e) => e && typeof e === 'object')
|
|
70
151
|
.slice()
|
|
71
|
-
.sort((a, b) => (
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
152
|
+
.sort((a, b) => (num(a.t) || 0) - (num(b.t) || 0));
|
|
153
|
+
|
|
154
|
+
// Spec §3: "Segment time origin for per-segment clocks: first non-null of
|
|
155
|
+
// t_load, t_dom_ready, t_start; else the first event's t."
|
|
156
|
+
// Recorded because it was contested: the pre-design's carry paraphrased
|
|
157
|
+
// this as "t_dom_ready, then t_load, then t_start last", swapping the
|
|
158
|
+
// first two. The SPEC TEXT is the contract. The readings differ only for a
|
|
159
|
+
// segment stating both t_load and t_dom_ready with different values, where
|
|
160
|
+
// §3 says t_load wins — and that is also the no-change reading, since
|
|
161
|
+
// CH-v1's anchor was t_load. (The fork's PLAYER still rebases by
|
|
162
|
+
// `t_dom_ready ?? 0`; that divergence is a cross-repo rider, not T5 scope.)
|
|
163
|
+
const origin = tLoad != null ? tLoad
|
|
164
|
+
: tDomReady != null ? tDomReady
|
|
165
|
+
: tStart != null ? tStart
|
|
166
|
+
: (events.length > 0 && num(events[0].t) != null ? num(events[0].t) : 0);
|
|
167
|
+
|
|
168
|
+
const lastT = events.length > 0 ? (num(events[events.length - 1].t) || origin) : origin;
|
|
169
|
+
const end = tEnd != null ? tEnd : lastT;
|
|
170
|
+
|
|
87
171
|
return {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
172
|
+
// Spec §7: `index` MUST equal the array position, and tolerant loaders
|
|
173
|
+
// trust array order. Taking the position rather than the stated field
|
|
174
|
+
// means a producer's off-by-one cannot desync the model from the array
|
|
175
|
+
// every other part of the viewer indexes into.
|
|
176
|
+
index: i,
|
|
177
|
+
label: s.label != null ? s.label : null,
|
|
178
|
+
plugin: s.plugin != null ? s.plugin : null,
|
|
179
|
+
tStart, tDomReady, tLoad, tEnd,
|
|
180
|
+
origin,
|
|
181
|
+
durMs: Math.max(0, round1(end - origin)),
|
|
182
|
+
keyframe,
|
|
183
|
+
// The nearest earlier segment with a non-null initial_dom (itself if it
|
|
184
|
+
// is a keyframe): the segment a restore mounts before walking forward.
|
|
185
|
+
// Null until the first keyframe arrives.
|
|
186
|
+
spanStart,
|
|
187
|
+
// Spec §3's pre-keyframe rule, in the strict profile's reading
|
|
188
|
+
// (validator.js:209): a segment with no keyframe before it is illegal
|
|
189
|
+
// IFF it carries `dom.*` events, tracked with a RUNNING keyframe flag.
|
|
190
|
+
// Deliberately the same predicate as the machine check rather than a
|
|
191
|
+
// second reading of the same sentence — the reason the §11 rejection
|
|
192
|
+
// strings above are mirrored verbatim applies here too. Two shapes turn
|
|
193
|
+
// on it: a trace prologue before a later keyframe is LEGAL (it
|
|
194
|
+
// reconstructs nothing, so it loses nothing), and `dom.*` before any
|
|
195
|
+
// keyframe is a DEFECT even when no keyframe ever arrives (the viewer
|
|
196
|
+
// would otherwise show a clean grey trace stage while dropping patches
|
|
197
|
+
// §12 requires it to flag).
|
|
198
|
+
defect: spanStart === null && events.some(
|
|
199
|
+
(e) => typeof e.type === 'string' && e.type.indexOf('dom.') === 0)
|
|
200
|
+
? 'continuation-before-keyframe' : null,
|
|
201
|
+
// v2's keyframe is a DomNode TREE, never an HTML string: the
|
|
202
|
+
// reconstruction is instantiated node by node and is never parsed from
|
|
203
|
+
// markup (design §4).
|
|
204
|
+
initialDom: keyframe ? s.initial_dom : null,
|
|
205
|
+
initialState: s.initial_state || null,
|
|
206
|
+
camera: null, // filled below, once every span start is known
|
|
207
|
+
// Segment-relative times, as v1's were trial-relative. A pure
|
|
208
|
+
// subtraction, NOT clamped at zero: an event recorded before its
|
|
209
|
+
// segment's origin keeps a negative relative time rather than being
|
|
210
|
+
// reported as happening at the segment start.
|
|
211
|
+
//
|
|
212
|
+
// THE CONVERSION CONTRACT (read this before writing Task 7's checkpoint
|
|
213
|
+
// executor). Rebasing rounds, so the two directions are not equally
|
|
214
|
+
// exact and only one of them is a rule:
|
|
215
|
+
// FORWARD (session → segment) is `round1(t − origin)`, and any
|
|
216
|
+
// consumer that needs a segment-relative time MUST compute it the
|
|
217
|
+
// same way. Do that and the comparison is exact by construction.
|
|
218
|
+
// REVERSE (`origin + tRel === t`) is NOT exact: `round1` re-rounds the
|
|
219
|
+
// difference and adding it back to a decimal origin does not
|
|
220
|
+
// reproduce the wire float (2000.1 + 14621.4 → 16621.500000000002).
|
|
221
|
+
// Measured over the corpus: 735 of jspsych-full's 909 events miss
|
|
222
|
+
// it, worst error 1.9e-7 ms. The stated bound is 5e-7 ms, pinned
|
|
223
|
+
// fixture-wide in tests/replay/viewer-model.test.js. Never compare
|
|
224
|
+
// reconstructed absolute times with ===.
|
|
94
225
|
events: events.map((e) => {
|
|
95
226
|
const out = Object.assign({}, e);
|
|
96
|
-
out.t =
|
|
227
|
+
out.t = round1((num(e.t) || 0) - origin);
|
|
97
228
|
return out;
|
|
98
|
-
})
|
|
229
|
+
}),
|
|
99
230
|
};
|
|
100
231
|
});
|
|
101
232
|
|
|
233
|
+
// ── per-segment camera seed (design §8) ─────────────────────────────────
|
|
234
|
+
// v1 folded per-trial view-state seeds plus in-stream resize events. v2
|
|
235
|
+
// has neither: `resize` is not an event type and viewport history is
|
|
236
|
+
// session-level. So the seed is `viewport_changes` folded up to the
|
|
237
|
+
// segment's origin, over the top-level viewport, with window scroll taken
|
|
238
|
+
// from the SPAN KEYFRAME's `initial_state` — the state a restore actually
|
|
239
|
+
// opens with (design §5). Scroll is not folded across segments: the span
|
|
240
|
+
// walk replays `scroll.window` itself, and a segment with no initial_state
|
|
241
|
+
// opens at (0,0) because the frame survives segment changes.
|
|
242
|
+
for (const seg of segments) {
|
|
243
|
+
const folded = {
|
|
244
|
+
w: viewport ? num(viewport.w) : null,
|
|
245
|
+
h: viewport ? num(viewport.h) : null,
|
|
246
|
+
dpr: viewport && num(viewport.dpr) != null ? viewport.dpr : 1,
|
|
247
|
+
scale: viewport && num(viewport.scale) != null ? viewport.scale : 1,
|
|
248
|
+
offset_x: viewport && num(viewport.offset_x) != null ? viewport.offset_x : 0,
|
|
249
|
+
offset_y: viewport && num(viewport.offset_y) != null ? viewport.offset_y : 0,
|
|
250
|
+
};
|
|
251
|
+
for (const c of viewportChanges) {
|
|
252
|
+
if ((num(c.t) || 0) > seg.origin) break;
|
|
253
|
+
for (const k of Object.keys(folded)) if (num(c[k]) != null) folded[k] = c[k];
|
|
254
|
+
}
|
|
255
|
+
const keyframeSeg = seg.spanStart != null ? segments[seg.spanStart] : null;
|
|
256
|
+
const seedScroll = keyframeSeg && keyframeSeg.initialState && keyframeSeg.initialState.scroll;
|
|
257
|
+
seg.camera = Object.assign({}, folded, {
|
|
258
|
+
scroll_x: seedScroll && num(seedScroll.x) != null ? seedScroll.x : 0,
|
|
259
|
+
scroll_y: seedScroll && num(seedScroll.y) != null ? seedScroll.y : 0,
|
|
260
|
+
client_w: folded.w != null ? folded.w - scrollbar.w : null,
|
|
261
|
+
client_h: folded.h != null ? folded.h - scrollbar.h : null,
|
|
262
|
+
// Which of the two scroll sources produced this seed. Not spec — it is
|
|
263
|
+
// what makes a wrong seed legible in the viewer's own diagnostics.
|
|
264
|
+
source: seedScroll ? 'initial_state' : 'default',
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
102
268
|
return {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
|
|
118
|
-
|
|
269
|
+
schemaVersion: 2,
|
|
270
|
+
pid: rec.participant_id != null ? String(rec.participant_id) : null,
|
|
271
|
+
recorder: rec.recorder,
|
|
272
|
+
host: rec.host || null,
|
|
273
|
+
startTime: rec.recording_started_at || null,
|
|
274
|
+
startPerf: num(rec.recording_started_at_perf),
|
|
275
|
+
endReason: rec.end_reason || null,
|
|
276
|
+
truncated: !!rec.truncated,
|
|
277
|
+
userAgent: rec.user_agent || null,
|
|
278
|
+
|
|
279
|
+
// `foreign` keys on PRODUCER IDENTITY, never on the presence of the
|
|
280
|
+
// `extensions['cyborg-hunter']` namespace: the T4 converter stamps its
|
|
281
|
+
// provenance into that namespace, so a namespace test calls the
|
|
282
|
+
// designated foreign-producer fixture CH-produced. A converted CH-v1 file
|
|
283
|
+
// is the mirror case — CH-produced and converter-stamped. `foreign` says
|
|
284
|
+
// "the report should not pretend this file has CH panels"; it never
|
|
285
|
+
// decides whether a given panel is drawn. Every panel below is guarded on
|
|
286
|
+
// its OWN field's presence.
|
|
287
|
+
foreign: rec.recorder.name !== 'cyborg-hunter-replay',
|
|
288
|
+
|
|
289
|
+
// Stated tier wins; otherwise infer structurally so a foreign file gets an
|
|
290
|
+
// honest badge instead of "trace" (design §10). Shared with the report's
|
|
291
|
+
// index, which badges the same recording before any model exists.
|
|
292
|
+
tier: inferTier(rec),
|
|
293
|
+
keys: ext.keys || null,
|
|
294
|
+
chVersion: ext.ch_version || null,
|
|
295
|
+
preset: ext.preset || null,
|
|
296
|
+
inheritedTaint: !!ext.inherited_redaction_taint,
|
|
297
|
+
|
|
298
|
+
viewport,
|
|
299
|
+
viewportClient,
|
|
300
|
+
scrollbar,
|
|
301
|
+
// Session-level streams keep ABSOLUTE wire times and are rebased at use:
|
|
302
|
+
// one array serves every segment.
|
|
303
|
+
viewportChanges,
|
|
304
|
+
stylesheets: asArray(rec.stylesheets),
|
|
305
|
+
stylesheetEvents: asArray(rec.stylesheet_events),
|
|
306
|
+
|
|
119
307
|
scoring: ext.scoring || null,
|
|
120
|
-
guardViolations: ext.guard_violations
|
|
308
|
+
guardViolations: asArray(ext.guard_violations),
|
|
309
|
+
// Channel names only: the chip says which channel went dark, and the
|
|
310
|
+
// message/time detail has no surface in the report today.
|
|
311
|
+
captureFailures: asArray(ext.capture_failures).map((f) => f && f.channel).filter(Boolean),
|
|
121
312
|
captureStopped: !!ext.capture_stopped,
|
|
122
|
-
|
|
123
|
-
|
|
313
|
+
|
|
314
|
+
segments,
|
|
124
315
|
};
|
|
125
316
|
}
|
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.8.0";
|
|
6
6
|
|
|
7
7
|
// Default detection thresholds shared across presets.
|
|
8
8
|
// Researchers can override any value at init() time.
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// src/shared/inline-safe.js
|
|
2
|
+
// Putting text inside an HTML `<script>` element safely. TWO rules, because
|
|
3
|
+
// there are two kinds of text and one rule for both is wrong in each
|
|
4
|
+
// direction. Pure and dependency-free: the CLI renderers, the Playwright
|
|
5
|
+
// batteries and the investigation probes all import it.
|
|
6
|
+
//
|
|
7
|
+
// WHY THIS MODULE EXISTS. Before T5 Task 10 this duty was hand-rolled in nine
|
|
8
|
+
// places, and the copy that mattered — the shipped report's — had drifted to
|
|
9
|
+
// zero, so every report generated between Task 4 and Task 10 inlined a viewer
|
|
10
|
+
// the HTML parser truncated at the first `</script` inside a source COMMENT
|
|
11
|
+
// and booted with a SyntaxError and no player. Nine correct copies and one
|
|
12
|
+
// missing copy is the same failure the tier read had (fixed by exporting
|
|
13
|
+
// `inferTier`), so it gets the same answer.
|
|
14
|
+
//
|
|
15
|
+
// ── Rule 1, inlineSafeJson: for DATA (a model, a payload) ──────────────────
|
|
16
|
+
// Escape EVERY `<` to the six-character JS unicode escape `<`. Inside a
|
|
17
|
+
// JSON string literal that is the same character to the JS parser, so the
|
|
18
|
+
// value round-trips exactly, and no `<` survives for the HTML tokenizer to
|
|
19
|
+
// react to — which covers `</script`, `<!--` and `<script` in one move. This
|
|
20
|
+
// is the rule to use whenever the text is untrusted (participant-typed or
|
|
21
|
+
// visitor-pasted content reaches the viewer model as DomNode text).
|
|
22
|
+
//
|
|
23
|
+
// ── Rule 2, inlineSafeSrc: for JS SOURCE ───────────────────────────────────
|
|
24
|
+
// Rule 1 cannot be used here: source is not a string literal, so rewriting
|
|
25
|
+
// `a < b` to `a < b` is a syntax error. What is safe is neutralising the
|
|
26
|
+
// one sequence that can close a `<script>` element — `</script`,
|
|
27
|
+
// case-insensitively. `<\/script` is an identity escape inside a string, a
|
|
28
|
+
// `\/` inside a regex literal, and raw text inside a comment, so no code
|
|
29
|
+
// changes meaning. Nothing wider is safe: `<\script` alters a regex literal
|
|
30
|
+
// (`\s` is a character class) and `<\!--` is a SyntaxError under `/u`.
|
|
31
|
+
//
|
|
32
|
+
// ── The precondition Rule 2 carries, and why the guard below exists ────────
|
|
33
|
+
// Rule 2 is complete for the END-TAG class only. An unpaired `<!--` puts the
|
|
34
|
+
// tokenizer into script-data-escaped state, and a following `<script` takes it
|
|
35
|
+
// to script-data-DOUBLE-escaped state, where the page's own `</script>` no
|
|
36
|
+
// longer closes the element — the viewer never boots and, unlike the
|
|
37
|
+
// truncation above, NOTHING IS LOGGED. That failure needs no `</script` at
|
|
38
|
+
// all. So a consumer that inlines source must also assert the precondition,
|
|
39
|
+
// which is what `inlineSrcHazards` is for. Data has no such precondition:
|
|
40
|
+
// Rule 1 escapes those sequences too.
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Inline a value as JSON inside a `<script>`. Use for all DATA.
|
|
44
|
+
* @param {*} value anything JSON-serialisable
|
|
45
|
+
* @returns {string} the JSON text, with every `<` escaped to `<`
|
|
46
|
+
*/
|
|
47
|
+
export function inlineSafeJson(value) {
|
|
48
|
+
return JSON.stringify(value).replace(/</g, '\\u003c');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Inline JavaScript SOURCE inside a `<script>`. Use for source only; pair it
|
|
53
|
+
* with `inlineSrcHazards` (or a caller that has already asserted the
|
|
54
|
+
* precondition — see `readReplayClientSrc`).
|
|
55
|
+
* @param {string} src
|
|
56
|
+
* @returns {string} the source with every `</script` neutralised
|
|
57
|
+
*/
|
|
58
|
+
export function inlineSafeSrc(src) {
|
|
59
|
+
return String(src ?? '').replace(/<\/(script)/gi, '<\\/$1');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The sequences `inlineSafeSrc` cannot neutralise. Non-empty means the source
|
|
64
|
+
* must not be inlined as-is: it can silently swallow its own closing tag.
|
|
65
|
+
* @param {string} src
|
|
66
|
+
* @returns {string[]} human-readable reasons, empty when the source is safe
|
|
67
|
+
*/
|
|
68
|
+
export function inlineSrcHazards(src) {
|
|
69
|
+
const s = String(src ?? '');
|
|
70
|
+
const reasons = [];
|
|
71
|
+
// `<!--` opens script-data-escaped state. A matching `-->` would close it,
|
|
72
|
+
// but "matching" is a tokenizer state question, not a text question, so the
|
|
73
|
+
// safe rule is: no `<!--` in inlined source at all.
|
|
74
|
+
if (/<!--/.test(s)) reasons.push('contains `<!--`, which opens script-data-escaped state');
|
|
75
|
+
// `<script` + whitespace/`/`/`>` is the second half of the double-escape.
|
|
76
|
+
// Harmless on its own, refused anyway: the pair is what bites, and either
|
|
77
|
+
// half arriving alone is one edit away from the other.
|
|
78
|
+
if (/<script[\s/>]/i.test(s)) reasons.push('contains `<script`, the second half of the double-escape');
|
|
79
|
+
return reasons;
|
|
80
|
+
}
|