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,165 @@
|
|
|
1
|
+
// src/replay/redaction.js
|
|
2
|
+
// Redaction predicates, shared by every capture path.
|
|
3
|
+
//
|
|
4
|
+
// Spec §8 makes redaction a property of the FILE, not of one capture channel:
|
|
5
|
+
// a redacted subtree's content must not appear in `initial_dom`, in
|
|
6
|
+
// `initial_state.form`, in `dom.text`/`dom.attr`, in key identities, in
|
|
7
|
+
// clipboard payloads, or in anchor identity. A single predicate is what makes
|
|
8
|
+
// that claim checkable — v1 had two implementations (capture-trace's
|
|
9
|
+
// `inRedactedSubtree`, capture-dom's `isInRedactedSubtree`) with different
|
|
10
|
+
// signatures and quietly different password detection, so "is this node
|
|
11
|
+
// redacted" could be answered two ways in one recording.
|
|
12
|
+
//
|
|
13
|
+
// The unified definition takes the SELECTOR (the actual configuration datum),
|
|
14
|
+
// not an options bag, so both capture modules call it the same way:
|
|
15
|
+
// capture-trace: isInRedactedSubtree(el, config.redactSelector)
|
|
16
|
+
// snapshot/dom: isInRedactedSubtree(node, opts.redactSelector)
|
|
17
|
+
//
|
|
18
|
+
// Adoption: complete. Every capture path — the snapshot walk, the mutation
|
|
19
|
+
// mapper, the keyframe seed and event capture — asks this module, and the v1
|
|
20
|
+
// duplicates it replaced are gone. The password check is the union of what the
|
|
21
|
+
// two of them used to do separately (property OR attribute), which is why the
|
|
22
|
+
// predicate is broader than either was.
|
|
23
|
+
|
|
24
|
+
var ELEMENT_NODE = 1;
|
|
25
|
+
|
|
26
|
+
// Nodes whose content this recording has ever withheld. Redaction is a
|
|
27
|
+
// property of the FILE (spec §8), and the DOM only knows the present: a node
|
|
28
|
+
// that spent the trial inside a redacted container and is then moved OUT of
|
|
29
|
+
// it would serialize its accumulated content into the very next `dom.add`,
|
|
30
|
+
// putting in the file exactly the text the keyframe refused. Membership is
|
|
31
|
+
// therefore permanent for the life of the page, not scoped to a keyframe
|
|
32
|
+
// span: a whole-file leak scan reads every segment.
|
|
33
|
+
//
|
|
34
|
+
// A module-level WeakSet, so the protection is on by default and no wiring
|
|
35
|
+
// step can forget it. It holds no node alive, and it is keyed by node
|
|
36
|
+
// IDENTITY, so two recorders on one page (or two tests in one process) can
|
|
37
|
+
// only ever over-redact each other's nodes, never under-redact. Callers that
|
|
38
|
+
// want their own scope may pass one in.
|
|
39
|
+
var defaultTaint = new WeakSet();
|
|
40
|
+
|
|
41
|
+
// How many nodes the SHARED set has ever withheld. A WeakSet has no `size`, so
|
|
42
|
+
// a counter is the only way to answer the one question a later recording needs
|
|
43
|
+
// to ask: did this page withhold anything before I started? Its answer travels
|
|
44
|
+
// as `extensions["cyborg-hunter"].inherited_redaction_taint`, because an empty
|
|
45
|
+
// field in a second recording otherwise means two things the file cannot
|
|
46
|
+
// distinguish — the participant typed nothing, or an earlier recording on this
|
|
47
|
+
// page withheld it.
|
|
48
|
+
//
|
|
49
|
+
// Monotonic on purpose. GC can drop entries from the WeakSet, but a node that
|
|
50
|
+
// became unreachable can never be encountered again, so "has ever withheld" is
|
|
51
|
+
// the question with a stable answer.
|
|
52
|
+
var defaultTaintMarks = 0;
|
|
53
|
+
|
|
54
|
+
function taintSet(taint) {
|
|
55
|
+
return taint && typeof taint.has === 'function' ? taint : defaultTaint;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Record that this node's content was withheld, so every later channel keeps
|
|
60
|
+
* withholding it. Called wherever the withholding happens: the snapshot walk
|
|
61
|
+
* stripping a subtree, a suppressed `dom.text`, a withheld `value` patch.
|
|
62
|
+
*/
|
|
63
|
+
export function markRedacted(node, taint) {
|
|
64
|
+
if (node) {
|
|
65
|
+
var set = taintSet(taint);
|
|
66
|
+
if (set === defaultTaint) defaultTaintMarks++;
|
|
67
|
+
set.add(node);
|
|
68
|
+
}
|
|
69
|
+
return node;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* True when the page's shared taint set has withheld something already. Read
|
|
74
|
+
* once, at `startSession`, so a recording can state whether it inherited
|
|
75
|
+
* withholding from an earlier one on the same page (see the counter above).
|
|
76
|
+
*/
|
|
77
|
+
export function hasInheritedRedactionTaint() {
|
|
78
|
+
return defaultTaintMarks > 0;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** True when this node's content has ever been withheld (see markRedacted). */
|
|
82
|
+
export function isRedactionTainted(node, taint) {
|
|
83
|
+
return !!node && taintSet(taint).has(node);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* True when this node OR ANY ANCESTOR has ever been withheld.
|
|
88
|
+
*
|
|
89
|
+
* The subtree reading is the one every channel has to use, and it exists
|
|
90
|
+
* because the node-only reading disagreed with itself across channels (T3
|
|
91
|
+
* final review, F-1). The snapshot walk always inherited a tainted ancestor's
|
|
92
|
+
* verdict downward — it descends the tree, so it carries the answer with it —
|
|
93
|
+
* while the mutation mapper and the trace capture asked about the queried node
|
|
94
|
+
* alone. New content created inside a container that had been moved OUT of a
|
|
95
|
+
* redacted subtree therefore shipped in full through `dom.add`, `dom.text` and
|
|
96
|
+
* `input.value`, and was then withheld by the next keyframe describing the same
|
|
97
|
+
* nodes: one file, two verdicts, the weaker one first.
|
|
98
|
+
*
|
|
99
|
+
* A fresh child of a withheld container is withheld. That is the only reading
|
|
100
|
+
* under which the taint set's stated property — it can only ever over-redact —
|
|
101
|
+
* is true of the FILE rather than of one channel at a time.
|
|
102
|
+
*/
|
|
103
|
+
export function isInTaintedSubtree(node, taint) {
|
|
104
|
+
var set = taintSet(taint);
|
|
105
|
+
var cur = node;
|
|
106
|
+
while (cur) {
|
|
107
|
+
if (set.has(cur)) return true;
|
|
108
|
+
cur = cur.parentNode;
|
|
109
|
+
}
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function hasTypePasswordAttr(el) {
|
|
114
|
+
var attrs = el.attributes || [];
|
|
115
|
+
for (var i = 0; i < attrs.length; i++) {
|
|
116
|
+
if (attrs[i].name === 'type') return attrs[i].value === 'password';
|
|
117
|
+
}
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The spec §8 floor: `<input type="password">` values are never recorded, in
|
|
123
|
+
* any channel, and no configuration can turn that off.
|
|
124
|
+
*
|
|
125
|
+
* Property OR attribute: the `type` IDL property is the browser's answer, but
|
|
126
|
+
* it is absent on duck-typed fixture nodes and reads "text" for an unknown
|
|
127
|
+
* type — so a field the page author wrote as `type="password"` must be caught
|
|
128
|
+
* by the attribute too. Detection stays declarative (spec §8): a name or
|
|
129
|
+
* placeholder that merely looks password-ish is not a password field.
|
|
130
|
+
*/
|
|
131
|
+
export function isPasswordField(el) {
|
|
132
|
+
if (!el || el.tagName !== 'INPUT') return false;
|
|
133
|
+
return el.type === 'password' || hasTypePasswordAttr(el);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* True when this element is itself redacted: a password field (always) or a
|
|
138
|
+
* match for the researcher-configured redaction selector.
|
|
139
|
+
*
|
|
140
|
+
* A selector that throws (invalid syntax) redacts nothing rather than
|
|
141
|
+
* everything — the alternative is a typo silently emptying a whole recording.
|
|
142
|
+
*/
|
|
143
|
+
export function isRedacted(el, selector) {
|
|
144
|
+
if (isPasswordField(el)) return true;
|
|
145
|
+
if (!selector || !el || typeof el.matches !== 'function') return false;
|
|
146
|
+
try { return el.matches(selector); } catch (e) { return false; }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* True when the node is a redacted element or lives inside one.
|
|
151
|
+
*
|
|
152
|
+
* The ancestor walk is what makes redaction a subtree property: text nodes and
|
|
153
|
+
* comments have no `matches()` of their own, so typed content under a redacted
|
|
154
|
+
* container (a contenteditable, a wrapper div matched by the selector) is only
|
|
155
|
+
* caught by asking about its ancestors. Elements above the observed root count
|
|
156
|
+
* too — the walk follows `parentNode` as far as it goes.
|
|
157
|
+
*/
|
|
158
|
+
export function isInRedactedSubtree(node, selector) {
|
|
159
|
+
var cur = node;
|
|
160
|
+
while (cur) {
|
|
161
|
+
if (cur.nodeType === ELEMENT_NODE && isRedacted(cur, selector)) return true;
|
|
162
|
+
cur = cur.parentNode;
|
|
163
|
+
}
|
|
164
|
+
return false;
|
|
165
|
+
}
|
package/src/replay/serializer.js
CHANGED
|
@@ -1,22 +1,53 @@
|
|
|
1
1
|
// src/replay/serializer.js
|
|
2
|
-
// In-memory recorder state → SessionRecording
|
|
3
|
-
// format) with a ch_extensions namespace for CH-only data.
|
|
2
|
+
// In-memory recorder state → SessionRecording v2 (spec §2/§3).
|
|
4
3
|
//
|
|
5
|
-
// This is one of exactly two places in the system that convert time bases
|
|
6
|
-
//
|
|
7
|
-
//
|
|
4
|
+
// This is one of exactly two places in the system that convert time bases (the
|
|
5
|
+
// other is the CLI ingest): in-memory events carry absolute performance.now()
|
|
6
|
+
// values; the wire carries ms since the recording started (spec §7), which is
|
|
7
|
+
// why the file also states that origin as `recording_started_at_perf` — the two
|
|
8
|
+
// halves of one claim, and a reader can put an event back on the capture clock
|
|
9
|
+
// by adding them.
|
|
10
|
+
//
|
|
11
|
+
// What v2 moved, and where it went (spec §14's CH list):
|
|
12
|
+
// - `metadata` dissolves into the top level; `recorder` becomes a
|
|
13
|
+
// {name, version} pair rather than a string.
|
|
14
|
+
// - `trials` → `segments`, `trial_index` → positional `index` (+ `label`),
|
|
15
|
+
// `trial_data` → `host_data`, `view_state` → `initial_state`.
|
|
16
|
+
// - the initial DOM is a DomNode tree (snapshot.js), not an HTML string.
|
|
17
|
+
// - `stylesheets: {initial, events}` → one id-bearing array plus
|
|
18
|
+
// `stylesheet_events` (empty: CH captures no stylesheet mutations yet — a
|
|
19
|
+
// recorded gap, not a claim that none happened).
|
|
20
|
+
// - `ch_extensions` → `extensions["cyborg-hunter"]`, same content minus
|
|
21
|
+
// `marker_attr` (integer node ids retired the nonce markers), plus the
|
|
22
|
+
// `tier`/`keys` config echo that v1 kept in metadata.
|
|
23
|
+
// - `capture_stopped` gains a standard mirror: top-level `truncated` (§5.7).
|
|
24
|
+
//
|
|
25
|
+
// Everything CH-specific that the format has no field for goes into the vendor
|
|
26
|
+
// namespace rather than riding as an unknown top-level key: an unknown key is
|
|
27
|
+
// indistinguishable from a producer bug, and §9 exists precisely so a player
|
|
28
|
+
// can ignore it knowingly.
|
|
8
29
|
|
|
9
30
|
import { VERSION } from '../shared/constants.js';
|
|
10
31
|
|
|
32
|
+
/**
|
|
33
|
+
* The wire format this module emits. Exported because it is not only the
|
|
34
|
+
* serializer's business: the meta pointer that rides in the host's data
|
|
35
|
+
* (persistence.js, and index.js's no-session degradation) states the schema
|
|
36
|
+
* version of the recording it points at, and a hard-coded literal there is how
|
|
37
|
+
* a v1 pointer survived into a v2 recorder.
|
|
38
|
+
*/
|
|
39
|
+
export var SCHEMA_VERSION = 2;
|
|
40
|
+
|
|
11
41
|
// Convert an absolute performance.now() value to wire time (ms since
|
|
12
|
-
//
|
|
42
|
+
// recording start), rounded to 1 decimal to keep JSON compact (spec §7 permits
|
|
43
|
+
// producer rounding and names CH's 0.1 ms).
|
|
13
44
|
function wireT(t, sessionStart) {
|
|
14
45
|
if (t == null) return null;
|
|
15
46
|
return Math.round((t - sessionStart) * 10) / 10;
|
|
16
47
|
}
|
|
17
48
|
|
|
18
49
|
// Session-scoped CH arrays whose entries carry absolute `t` — converted to
|
|
19
|
-
// session-relative on the way into
|
|
50
|
+
// session-relative on the way into the vendor namespace.
|
|
20
51
|
function convertTimes(entries, sessionStart) {
|
|
21
52
|
return (entries || []).map(function (e) {
|
|
22
53
|
var out = Object.assign({}, e);
|
|
@@ -26,11 +57,38 @@ function convertTimes(entries, sessionStart) {
|
|
|
26
57
|
});
|
|
27
58
|
}
|
|
28
59
|
|
|
60
|
+
// Time-sorted copy. Stable by construction (Array.prototype.sort is stable in
|
|
61
|
+
// every engine CH supports), so events that share a `t` keep the order the
|
|
62
|
+
// buffer received them in — which spec §7 makes authoritative within an array.
|
|
63
|
+
function byTime(entries) {
|
|
64
|
+
return entries.slice().sort(function (a, b) { return a.t - b.t; });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Spec §2 types every ViewportState field as a number, and a recorder started
|
|
68
|
+
// without a window (node harness, a test) has no geometry to report. The
|
|
69
|
+
// format has no way to say "unknown", so zeros keep the file loadable where
|
|
70
|
+
// nulls would fail every consumer's type check.
|
|
71
|
+
var VIEWPORT_UNKNOWN = { w: 0, h: 0, dpr: 1, scale: 1, offset_x: 0, offset_y: 0 };
|
|
72
|
+
|
|
73
|
+
function viewportState(v) {
|
|
74
|
+
return v ? {
|
|
75
|
+
w: num(v.w), h: num(v.h), dpr: num(v.dpr, 1),
|
|
76
|
+
scale: num(v.scale, 1), offset_x: num(v.offset_x), offset_y: num(v.offset_y),
|
|
77
|
+
} : Object.assign({}, VIEWPORT_UNKNOWN);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function num(v, fallback) {
|
|
81
|
+
return typeof v === 'number' && isFinite(v) ? v : (fallback == null ? 0 : fallback);
|
|
82
|
+
}
|
|
83
|
+
|
|
29
84
|
/**
|
|
30
85
|
* @param {Object} state - recorder.getState() output
|
|
31
|
-
* @param {Object} opts
|
|
32
|
-
* caller (jsPsych adapter or
|
|
33
|
-
*
|
|
86
|
+
* @param {Object} opts
|
|
87
|
+
* `chSessionReport` — pull-model CH merge: the caller (jsPsych adapter or
|
|
88
|
+
* researcher) provides CH's session report, so the
|
|
89
|
+
* serializer never reaches into CH itself.
|
|
90
|
+
* `host` — the runtime the recorder was embedded in (spec §2),
|
|
91
|
+
* `{name, version}`; null/absent for standalone use.
|
|
34
92
|
*/
|
|
35
93
|
export function serialize(state, opts) {
|
|
36
94
|
opts = opts || {};
|
|
@@ -40,42 +98,57 @@ export function serialize(state, opts) {
|
|
|
40
98
|
}
|
|
41
99
|
var ch = opts.chSessionReport || null;
|
|
42
100
|
|
|
101
|
+
// The last moment the recording observed, on the capture clock. Kept
|
|
102
|
+
// absolute so it sits in the same frame as `recording_started_at_perf`;
|
|
103
|
+
// their difference is the recording's duration in wire time.
|
|
43
104
|
var lastT = s0;
|
|
44
|
-
var
|
|
45
|
-
var events = trial.events
|
|
105
|
+
var segments = state.trials.map(function (trial, i) {
|
|
106
|
+
var events = byTime(trial.events);
|
|
46
107
|
var lastEventT = events.length > 0 ? events[events.length - 1].t : trial.tLoad;
|
|
47
108
|
if (lastEventT > lastT) lastT = lastEventT;
|
|
109
|
+
if (trial.tEnd != null && trial.tEnd > lastT) lastT = trial.tEnd;
|
|
48
110
|
return {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
111
|
+
// Positional, not the recorder's counter: spec §7 makes the array
|
|
112
|
+
// position authoritative and strict validation rejects disagreement.
|
|
113
|
+
index: i,
|
|
114
|
+
label: trial.trialId != null ? trial.trialId : null,
|
|
115
|
+
plugin: trial.plugin != null ? trial.plugin : null,
|
|
116
|
+
// t_load is the segment time origin (spec §3). t_start/t_dom_ready are
|
|
117
|
+
// null when the host has no pre-render hook.
|
|
54
118
|
t_start: wireT(trial.tStart, s0),
|
|
55
119
|
t_dom_ready: wireT(trial.tDomReady, s0),
|
|
56
120
|
t_load: wireT(trial.tLoad, s0),
|
|
57
|
-
// A
|
|
121
|
+
// A segment still open at serialization time (tab closed, mid-session
|
|
58
122
|
// getRecording) ends at its last observed event.
|
|
59
123
|
t_end: wireT(trial.tEnd != null ? trial.tEnd : lastEventT, s0),
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
|
|
63
|
-
|
|
124
|
+
// Keyframe (a DomNode tree) vs continuation (null) — spec §3. Absent on
|
|
125
|
+
// trace tier, where no segment ever carries one.
|
|
126
|
+
initial_dom: trial.initialDom || null,
|
|
127
|
+
// The replay-state seed the tree cannot carry (initial-state.js); null
|
|
128
|
+
// when every field was at its default, which is the spec's omit rule.
|
|
129
|
+
initial_state: trial.initialState || null,
|
|
64
130
|
events: events.map(function (e) {
|
|
65
131
|
var out = Object.assign({}, e);
|
|
66
132
|
out.t = wireT(e.t, s0);
|
|
67
133
|
return out;
|
|
68
134
|
}),
|
|
69
|
-
|
|
135
|
+
host_data: trial.hostData != null ? trial.hostData : null,
|
|
136
|
+
// An implicitly opened segment (events arrived before any startTrial) is
|
|
137
|
+
// a CH-side fact about bracketing, not a standard field.
|
|
138
|
+
extensions: trial.implicit ? { 'cyborg-hunter': { implicit: true } } : null,
|
|
70
139
|
};
|
|
71
140
|
});
|
|
72
141
|
|
|
73
142
|
var chExtensions = {
|
|
74
143
|
replay_version: VERSION,
|
|
75
|
-
//
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
144
|
+
// v1 kept these in `metadata`; spec §14 routes the config echo here.
|
|
145
|
+
tier: state.tier,
|
|
146
|
+
keys: state.keys,
|
|
147
|
+
// documentElement's client box at session start. Spec §2's ViewportState
|
|
148
|
+
// has no room for it and the per-event camera block (§6) only carries it
|
|
149
|
+
// where an interaction happened, but it is the box the page was laid out
|
|
150
|
+
// against — what a viewer should size its reconstruction by.
|
|
151
|
+
viewport_client: state.viewportClient || null,
|
|
79
152
|
ch_version: ch ? (ch.libraryVersion || null) : null,
|
|
80
153
|
preset: ch && ch.config ? ch.config.preset : null,
|
|
81
154
|
scoring: ch ? {
|
|
@@ -94,26 +167,58 @@ export function serialize(state, opts) {
|
|
|
94
167
|
zoom_changes: convertTimes(ch.zoomChanges, s0),
|
|
95
168
|
extension_injections: convertTimes(ch.extensionInjections, s0)
|
|
96
169
|
} : null,
|
|
170
|
+
// Guard-friction violations are CH events with no standard type (§5.8
|
|
171
|
+
// forbids vendor events in the stream), so they live here with their
|
|
172
|
+
// times converted like every other session-scoped CH array.
|
|
97
173
|
guard_violations: convertTimes(state.guardViolations, s0),
|
|
98
174
|
capture_failures: convertTimes(state.captureFailures, s0),
|
|
99
|
-
capture_stopped: !!state.captureStopped
|
|
175
|
+
capture_stopped: !!state.captureStopped,
|
|
176
|
+
// Spec §8 redaction is a property of the file, but the mechanism enforcing
|
|
177
|
+
// it (redaction.js's taint set) has PAGE lifetime — so a recording that is
|
|
178
|
+
// not the page's first can inherit withholding it never configured. Without
|
|
179
|
+
// this flag an empty field means two things the file cannot distinguish:
|
|
180
|
+
// the participant typed nothing, or an earlier recording withheld it.
|
|
181
|
+
// Always present, like every other key here, so its absence means an old
|
|
182
|
+
// file rather than a clean page.
|
|
183
|
+
inherited_redaction_taint: !!state.inheritedRedactionTaint
|
|
100
184
|
};
|
|
101
185
|
|
|
102
186
|
return {
|
|
103
|
-
schema_version:
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
187
|
+
schema_version: SCHEMA_VERSION,
|
|
188
|
+
recorder: { name: 'cyborg-hunter-replay', version: VERSION },
|
|
189
|
+
host: opts.host || null,
|
|
190
|
+
participant_id: state.participantId != null ? state.participantId : null,
|
|
191
|
+
recording_started_at: new Date(state.sessionStartEpoch).toISOString(),
|
|
192
|
+
recording_started_at_perf: s0,
|
|
193
|
+
user_agent: state.userAgent || '',
|
|
194
|
+
viewport: viewportState(state.viewport),
|
|
195
|
+
observed_root: state.observedRoot || null,
|
|
196
|
+
stylesheets: state.stylesheets || [],
|
|
197
|
+
// Known gap: CH captures the initial sheets only. Stylesheet mutations
|
|
198
|
+
// (spec §2 StylesheetEvent) need a CSSOM observer CH does not have, so the
|
|
199
|
+
// array is empty rather than absent — the format's way of saying "capture
|
|
200
|
+
// was on and nothing is recorded here".
|
|
201
|
+
stylesheet_events: [],
|
|
202
|
+
// Time-sorted on the way out (spec §7): the capture side coalesces both
|
|
203
|
+
// viewport channels through RAF callbacks, and this is the last place that
|
|
204
|
+
// can guarantee the property for the file.
|
|
205
|
+
viewport_changes: byTime(state.viewportChanges || []).map(function (c) {
|
|
206
|
+
var out = Object.assign({}, c);
|
|
207
|
+
out.t = wireT(c.t, s0);
|
|
208
|
+
return out;
|
|
209
|
+
}),
|
|
210
|
+
// CH never patches Math.random and never re-executes experiment code, so
|
|
211
|
+
// RNG capture was off — which §7 spells as both fields null.
|
|
212
|
+
rng: null,
|
|
213
|
+
rng_calls: null,
|
|
214
|
+
ended_at_perf: lastT,
|
|
215
|
+
end_reason: state.endReason || 'aborted',
|
|
216
|
+
// Spec §5.7: the standard mirror of the capture-stopped signal, so a
|
|
217
|
+
// player can surface truncation without reading a vendor namespace.
|
|
218
|
+
truncated: !!state.captureStopped,
|
|
219
|
+
extensions: { 'cyborg-hunter': chExtensions },
|
|
220
|
+
// Last, matching the canonical fixture's reading order: the bulk of the
|
|
221
|
+
// file after everything a reader needs to interpret it.
|
|
222
|
+
segments: segments,
|
|
118
223
|
};
|
|
119
224
|
}
|