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
package/src/replay/index.js
CHANGED
|
@@ -15,13 +15,54 @@
|
|
|
15
15
|
import { createRecorder } from './recorder.js';
|
|
16
16
|
import { attachTraceCapture } from './capture-trace.js';
|
|
17
17
|
import { attachDomCapture } from './capture-dom.js';
|
|
18
|
-
import {
|
|
18
|
+
import { createSpan } from './span.js';
|
|
19
|
+
import { serialize, SCHEMA_VERSION } from './serializer.js';
|
|
19
20
|
import {
|
|
20
21
|
replayFilename, buildReplayMeta, autoSave, compressRecording,
|
|
21
22
|
} from './persistence.js';
|
|
22
23
|
|
|
23
24
|
var _active = null;
|
|
24
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Create (and replace) the singleton recorder.
|
|
28
|
+
*
|
|
29
|
+
* REDACTION CARRIES ACROSS RECORDINGS ON ONE PAGE. Spec §8 makes redaction a
|
|
30
|
+
* property of the FILE, and the mechanism that enforces it — redaction.js's
|
|
31
|
+
* taint set, which remembers every node whose content was ever withheld — has
|
|
32
|
+
* PAGE lifetime, not recording lifetime. So a second `attach()` in the same
|
|
33
|
+
* page load inherits the first's withholding: a field the first recording
|
|
34
|
+
* redacted stays empty in the second even if the second configures no
|
|
35
|
+
* redaction at all.
|
|
36
|
+
*
|
|
37
|
+
* This is deliberate. NOT for the reason this comment used to give: it said a
|
|
38
|
+
* reset here would re-open the §8 move-out hole (a node withheld at a keyframe,
|
|
39
|
+
* moved out of its container, re-serialized). That argument does not hold, and
|
|
40
|
+
* an external review was right to say so. §8 scopes redaction PER FILE, and by
|
|
41
|
+
* the time `attach()` runs the previous recording is serialized and closed —
|
|
42
|
+
* nothing a second recorder does can put content into it, so a per-recording
|
|
43
|
+
* reset could not re-expose anything there. The move-out hole lives WITHIN one
|
|
44
|
+
* recording, where the set already closes it.
|
|
45
|
+
*
|
|
46
|
+
* The two reasons it does stand:
|
|
47
|
+
*
|
|
48
|
+
* 1. FAIL-CLOSED against FAIL-OPEN. A page-lifetime ratchet can only ever
|
|
49
|
+
* over-redact. A per-recording set can under-redact relative to today, and
|
|
50
|
+
* this program's invariant is that redaction leaks fail the build.
|
|
51
|
+
* Over-redaction is a diagnosable annoyance; under-redaction is a
|
|
52
|
+
* participant-privacy failure.
|
|
53
|
+
* 2. It would re-open the THREADING SEAM this design closed. "One set per
|
|
54
|
+
* recorder" means threading an explicit object to four consumers (snapshot,
|
|
55
|
+
* mutations, initial-state, capture-trace), and partial threading fails
|
|
56
|
+
* OPEN — see the note in `startSession` below. Trading a known
|
|
57
|
+
* over-redaction for an unknown under-redaction runs the wrong way.
|
|
58
|
+
*
|
|
59
|
+
* The residual cost is real and narrow: a researcher who deliberately turns
|
|
60
|
+
* redaction OFF for a later block of a same-page-load SPA gets a field that
|
|
61
|
+
* stays empty with no explanation. The answer to that is to REPORT it, not to
|
|
62
|
+
* remove the ratchet — `extensions["cyborg-hunter"].inherited_redaction_taint`
|
|
63
|
+
* is true whenever this recording started on a page that had already withheld
|
|
64
|
+
* something. Both the inheritance and the flag are pinned in capture-e2e.test.js.
|
|
65
|
+
*/
|
|
25
66
|
export function attach(userConfig) {
|
|
26
67
|
if (_active) {
|
|
27
68
|
console.warn('[cyborg-hunter-replay] attach() called while a recorder is active — replacing the previous instance (its unsaved recording is discarded).');
|
|
@@ -39,13 +80,55 @@ export function attach(userConfig) {
|
|
|
39
80
|
|
|
40
81
|
startSession: function () {
|
|
41
82
|
rec.startSession();
|
|
83
|
+
// ONE capture span per recording (span.js): the node ids the keyframe
|
|
84
|
+
// assigns and the record of what the file contains. Both capture modules
|
|
85
|
+
// get the SAME object, because an event's `target` and a patch's `node`
|
|
86
|
+
// are the same numbering or neither of them means anything. Created here,
|
|
87
|
+
// reset at each keyframe by the DOM capture.
|
|
88
|
+
var span = createSpan();
|
|
89
|
+
// The §8 redaction taint set is deliberately NOT threaded alongside it.
|
|
90
|
+
// Both capture modules accept one, and threading an explicit object to
|
|
91
|
+
// some consumers and not others is the failure this seam invites: the
|
|
92
|
+
// halves stop sharing, and a field moved out of a redacted container is
|
|
93
|
+
// re-exposed by whichever half was forgotten. Passing NONE puts every
|
|
94
|
+
// consumer — snapshot, mutations, initial-state, capture-trace — on
|
|
95
|
+
// redaction.js's single module-level set, which is the symmetric answer
|
|
96
|
+
// and the fail-closed one: a future consumer that forgets to thread
|
|
97
|
+
// still lands in the same place. Its cost is that two recorders on one
|
|
98
|
+
// page can over-redact each other's nodes, which is the safe direction,
|
|
99
|
+
// and is what redaction.js documents.
|
|
100
|
+
//
|
|
42
101
|
// Capture modules attach after the session transition so nothing
|
|
43
|
-
// touches the DOM until the researcher opts in.
|
|
44
|
-
|
|
102
|
+
// touches the DOM until the researcher opts in. Trace capture goes
|
|
103
|
+
// FIRST for one mundane reason: `attachDomCapture` needs
|
|
104
|
+
// `trace.getScrolledElements` as a VALUE, and that handle only exists
|
|
105
|
+
// once trace capture has attached.
|
|
106
|
+
//
|
|
107
|
+
// Not because of hook ordering. `getScrolledElements()` prunes detached
|
|
108
|
+
// elements on read (capture-trace.js), so the trial-start prune hook is
|
|
109
|
+
// redundant for the seed and reversing the two attachments changes
|
|
110
|
+
// nothing observable. That hook stays for the other reason it was
|
|
111
|
+
// written: the tracker is a STRONG Set, so pruning once per trial bounds
|
|
112
|
+
// its growth even across trials where nothing reads it.
|
|
113
|
+
//
|
|
114
|
+
// The handle is passed as a live function rather than as a snapshot of
|
|
115
|
+
// the Set, so that elements which first scroll after this line are still
|
|
116
|
+
// seeded — see capture-dom.test.js ("enumerates capture-trace's scrolled
|
|
117
|
+
// elements", which adds to the set after attach) and end to end in
|
|
118
|
+
// capture-e2e.test.js ("the second keyframe seeds the scroll it
|
|
119
|
+
// inherited"). Neither test DISCRIMINATES, though: `getScrolledElements`
|
|
120
|
+
// returns the live Set by reference, so caching its result here would
|
|
121
|
+
// pass both. The function form buys independence from that — a tracker
|
|
122
|
+
// that returned a copy, or rebuilt its set, would still work — which is
|
|
123
|
+
// a property of the seam, not one today's tests can see.
|
|
124
|
+
var trace = attachTraceCapture(rec, { span: span });
|
|
45
125
|
if (rec.config.tier === 'dom' || rec.config.tier === 'canvas') {
|
|
46
126
|
// 'canvas' is accepted for forward compat but captures at dom tier
|
|
47
127
|
// in v0.7 (canvas snapshots arrive with the v0.8 diff codec).
|
|
48
|
-
attachDomCapture(rec
|
|
128
|
+
attachDomCapture(rec, {
|
|
129
|
+
span: span,
|
|
130
|
+
scrolled: trace.getScrolledElements,
|
|
131
|
+
});
|
|
49
132
|
}
|
|
50
133
|
return api;
|
|
51
134
|
},
|
|
@@ -74,7 +157,7 @@ export function attach(userConfig) {
|
|
|
74
157
|
return {
|
|
75
158
|
recording: null,
|
|
76
159
|
saveResult: { saved_to: 'no_session' },
|
|
77
|
-
meta: { schema_version:
|
|
160
|
+
meta: { schema_version: SCHEMA_VERSION, saved_to: 'no_session',
|
|
78
161
|
note: 'recorder was never started; nothing to save' }
|
|
79
162
|
};
|
|
80
163
|
}
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
// src/replay/initial-state.js
|
|
2
|
+
// Keyframe replay-state seeds (spec §3 `initial_state`).
|
|
3
|
+
//
|
|
4
|
+
// A DomNode tree is not a replay checkpoint on its own. It carries the page's
|
|
5
|
+
// STRUCTURE, and structure is where the participant's state mostly is not:
|
|
6
|
+
// how far a scroll container was scrolled, where a video sits, what is typed
|
|
7
|
+
// into a field the page author never wrote a `value` attribute for. All of
|
|
8
|
+
// that was established BEFORE the keyframe, so no mutation in the segment will
|
|
9
|
+
// ever restore it — a player seeking to the keyframe would show a page the
|
|
10
|
+
// participant never saw. `initial_state` is the missing half.
|
|
11
|
+
//
|
|
12
|
+
// Two properties shape everything here.
|
|
13
|
+
//
|
|
14
|
+
// **Only divergence is worth seeding.** The keyframe tree already carries the
|
|
15
|
+
// `value`, `checked` and `selected` ATTRIBUTES, so a control still at its
|
|
16
|
+
// default is restored for free by the reconstruction. What the tree cannot
|
|
17
|
+
// carry is the IDL state that diverged from those attributes, which is exactly
|
|
18
|
+
// the participant's contribution. The same reasoning gives the spec's
|
|
19
|
+
// omit-when-all-default rule its meaning (§3: keyframes MUST carry the seed
|
|
20
|
+
// "whenever any of that state is non-default"): default is the state a player
|
|
21
|
+
// reaches by rebuilding the tree and touching nothing.
|
|
22
|
+
//
|
|
23
|
+
// **A seed may only name nodes the file contains, and never a placeholder.**
|
|
24
|
+
// Every entry is a node id, and an id the player never received is worse than a
|
|
25
|
+
// missing seed: it either does nothing or lands on the wrong node. The span's
|
|
26
|
+
// delivery model (delivery.js) is the file's own record of what went into it,
|
|
27
|
+
// so it answers this directly — no re-derivation from a live DOM that may have
|
|
28
|
+
// moved since the keyframe walk. Re-deriving was the first version of this
|
|
29
|
+
// rule ("is the nearest emitted ancestor this node"), and it broke twice: it
|
|
30
|
+
// admitted a node whose exclusion attribute came off after the walk but before
|
|
31
|
+
// the observer flush, and it admitted the EXCLUSION PLACEHOLDER ITSELF, whose
|
|
32
|
+
// state is precisely what spec §4 withholds. Hence two conditions, delivered
|
|
33
|
+
// and not excluded, and `registry.peekId` only for the id.
|
|
34
|
+
//
|
|
35
|
+
// Redaction (spec §8) subtracts from all four fields rather than emitting
|
|
36
|
+
// redacted variants: §3's interface has no room for a redacted entry, and the
|
|
37
|
+
// entries are pure identity plus state. A skipped control is also marked in
|
|
38
|
+
// the taint set, so a field that spent this keyframe inside a redacted
|
|
39
|
+
// container stays withheld after it is moved out.
|
|
40
|
+
|
|
41
|
+
import {
|
|
42
|
+
isInRedactedSubtree, isRedactionTainted, markRedacted,
|
|
43
|
+
} from './redaction.js';
|
|
44
|
+
import { isExcluded } from './snapshot.js';
|
|
45
|
+
|
|
46
|
+
var MEDIA_SELECTOR = 'video, audio';
|
|
47
|
+
var FORM_SELECTOR = 'input, textarea, select';
|
|
48
|
+
|
|
49
|
+
// Never seeded, whatever their state: password values are the spec §8 floor
|
|
50
|
+
// (already caught by the redaction predicate — this is where the READER looks
|
|
51
|
+
// for it), and file inputs are spec §13's "never recorded", where browsers
|
|
52
|
+
// hand out a fake path rather than an empty string.
|
|
53
|
+
var SKIP_INPUT_TYPES = { password: true, file: true };
|
|
54
|
+
|
|
55
|
+
function round(v) { return Math.round(v); }
|
|
56
|
+
// Media positions are seconds; three decimals is millisecond resolution, and
|
|
57
|
+
// keeps a 17-digit float out of the wire.
|
|
58
|
+
function r3(v) { return Math.round(v * 1000) / 1000; }
|
|
59
|
+
|
|
60
|
+
function attrOf(el, name) {
|
|
61
|
+
if (typeof el.getAttribute === 'function') return el.getAttribute(name);
|
|
62
|
+
var attrs = el.attributes || [];
|
|
63
|
+
for (var i = 0; i < attrs.length; i++) if (attrs[i].name === name) return attrs[i].value;
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function hasAttr(el, name) { return attrOf(el, name) !== null; }
|
|
68
|
+
|
|
69
|
+
// Elements of interest under the observed root, in document order. The root
|
|
70
|
+
// itself counts: a recording whose observed root IS the scrollable pane or the
|
|
71
|
+
// media element is legal.
|
|
72
|
+
function collect(root, selector) {
|
|
73
|
+
var out = [];
|
|
74
|
+
if (typeof root.matches === 'function') {
|
|
75
|
+
try { if (root.matches(selector)) out.push(root); } catch (e) { /* not a real element */ }
|
|
76
|
+
}
|
|
77
|
+
if (typeof root.querySelectorAll !== 'function') return out;
|
|
78
|
+
var found = root.querySelectorAll(selector);
|
|
79
|
+
for (var i = 0; i < found.length; i++) out.push(found[i]);
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// The id to seed this node under, or null when the seed may not name it.
|
|
84
|
+
//
|
|
85
|
+
// `holds` is what the file contains, from the file's own record: it rejects
|
|
86
|
+
// nodes outside the observed root, scripts, iframe and shadow content,
|
|
87
|
+
// everything behind a placeholder, and anything numbered but never emitted —
|
|
88
|
+
// all in one lookup, and without asking the live DOM, which can have moved
|
|
89
|
+
// since the walk that produced the file.
|
|
90
|
+
//
|
|
91
|
+
// `isExcluded` is the second condition rather than a consequence of the first,
|
|
92
|
+
// because a placeholder IS delivered: spec §4 puts its `id`, `kind` and `tag`
|
|
93
|
+
// in the tree and withholds everything else. Its scroll offsets, media
|
|
94
|
+
// position and IDL value are that everything else. CH ships this exact shape
|
|
95
|
+
// (extension-guard-honeypot.js stamps the marker on the bait INPUT), and
|
|
96
|
+
// `keepBait` still turns the legacy markers off here, as everywhere.
|
|
97
|
+
function seedableId(node, span, opts) {
|
|
98
|
+
if (!span.delivery.holds(node)) return null;
|
|
99
|
+
if (isExcluded(node, opts)) return null;
|
|
100
|
+
return span.registry.peekId(node);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Redacted for seeding purposes: inside a redacted subtree right now, or
|
|
104
|
+
// content this recording has already withheld (the taint set). Marking on the
|
|
105
|
+
// way out is what keeps the second case true later.
|
|
106
|
+
function isWithheld(el, opts) {
|
|
107
|
+
if (isInRedactedSubtree(el, opts.redactSelector) || isRedactionTainted(el, opts.taint)) {
|
|
108
|
+
markRedacted(el, opts.taint);
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── per-field builders ─────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
// Tracked scrollers, in the order they first scrolled. Entries at the origin
|
|
117
|
+
// are skipped for the same reason the whole object is omitted when everything
|
|
118
|
+
// is default: a freshly rebuilt DOM is already at 0/0.
|
|
119
|
+
function elementScroll(root, span, opts) {
|
|
120
|
+
var out = [];
|
|
121
|
+
var scrolled = opts.scrolled;
|
|
122
|
+
if (!scrolled || typeof scrolled.forEach !== 'function') return out;
|
|
123
|
+
scrolled.forEach(function (el) {
|
|
124
|
+
// No connectedness check: what belongs in the seed is what the FILE holds,
|
|
125
|
+
// which `seedableId` reads off the span's own record. A node the keyframe
|
|
126
|
+
// emitted stays addressable in the player after the page drops it, and the
|
|
127
|
+
// removal reaches the player as the `dom.remove` the observer is about to
|
|
128
|
+
// emit. Keeping the Set from growing is the tracker's job
|
|
129
|
+
// (capture-trace.js), and its prune of detached elements is why one rarely
|
|
130
|
+
// reaches this loop at all.
|
|
131
|
+
if (!el) return;
|
|
132
|
+
if (isWithheld(el, opts)) return;
|
|
133
|
+
var id = seedableId(el, span, opts);
|
|
134
|
+
if (id === null) return;
|
|
135
|
+
var x = round(el.scrollLeft || 0);
|
|
136
|
+
var y = round(el.scrollTop || 0);
|
|
137
|
+
if (x === 0 && y === 0) return;
|
|
138
|
+
out.push({ node: id, x: x, y: y });
|
|
139
|
+
});
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function media(root, span, opts) {
|
|
144
|
+
var out = [];
|
|
145
|
+
var els = collect(root, MEDIA_SELECTOR);
|
|
146
|
+
for (var i = 0; i < els.length; i++) {
|
|
147
|
+
var el = els[i];
|
|
148
|
+
if (isWithheld(el, opts)) continue;
|
|
149
|
+
var id = seedableId(el, span, opts);
|
|
150
|
+
if (id === null) continue;
|
|
151
|
+
var time = typeof el.currentTime === 'number' && isFinite(el.currentTime)
|
|
152
|
+
? r3(el.currentTime) : 0;
|
|
153
|
+
// Absent `paused` reads as paused: that is the default for a media element
|
|
154
|
+
// nothing has played, and the direction that seeds nothing.
|
|
155
|
+
var paused = el.paused !== false;
|
|
156
|
+
if (time === 0 && paused) continue;
|
|
157
|
+
out.push({ node: id, current_time: time, paused: paused });
|
|
158
|
+
}
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// The value a rebuilt DOM would show for this control. A missing IDL default
|
|
163
|
+
// (duck-typed node) reads as empty, so anything the element actually holds
|
|
164
|
+
// counts as divergence rather than being silently dropped.
|
|
165
|
+
function defaultValueOf(el) {
|
|
166
|
+
return typeof el.defaultValue === 'string' ? el.defaultValue : '';
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function optionValue(option) {
|
|
170
|
+
if (typeof option.value === 'string') return option.value;
|
|
171
|
+
return String(option.textContent || '');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Display size, which decides whether the browser auto-selects anything: HTML's
|
|
175
|
+
// "ask for a reset" algorithm picks the first non-disabled option only for a
|
|
176
|
+
// single-select showing one row. Browsers return the attribute value from
|
|
177
|
+
// `el.size`, or 0 when the attribute is absent; happy-dom leaves it undefined
|
|
178
|
+
// even with the attribute present, hence the attribute fallback.
|
|
179
|
+
function displaySize(el) {
|
|
180
|
+
if (typeof el.size === 'number' && el.size > 0) return el.size;
|
|
181
|
+
var parsed = parseInt(attrOf(el, 'size'), 10);
|
|
182
|
+
return parsed > 0 ? parsed : 1;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Selected option values, and the selection the reconstruction would show on
|
|
186
|
+
// its own: options carrying the `selected` ATTRIBUTE, or, for a one-row
|
|
187
|
+
// single-select where none does, the first non-disabled option. A list box
|
|
188
|
+
// (`size` above 1) and a `multiple` select both start with nothing selected, so
|
|
189
|
+
// inventing a default for them would report divergence on an untouched page —
|
|
190
|
+
// and one junk entry defeats the whole-object omission.
|
|
191
|
+
function selectDivergence(el) {
|
|
192
|
+
var options = el.options ? Array.prototype.slice.call(el.options) : [];
|
|
193
|
+
var current = [];
|
|
194
|
+
var dflt = [];
|
|
195
|
+
var firstEnabled = null;
|
|
196
|
+
for (var i = 0; i < options.length; i++) {
|
|
197
|
+
var option = options[i];
|
|
198
|
+
if (option.selected) current.push(optionValue(option));
|
|
199
|
+
if (hasAttr(option, 'selected')) dflt.push(optionValue(option));
|
|
200
|
+
if (firstEnabled === null && !option.disabled) firstEnabled = option;
|
|
201
|
+
}
|
|
202
|
+
if (!el.multiple && displaySize(el) === 1 && dflt.length === 0 && firstEnabled) {
|
|
203
|
+
dflt.push(optionValue(firstEnabled));
|
|
204
|
+
}
|
|
205
|
+
if (current.length === dflt.length && current.every(function (v, i2) { return v === dflt[i2]; })) {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
return { selected: current };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// The state of one control, or null when it matches what the tree restores.
|
|
212
|
+
function formDivergence(el) {
|
|
213
|
+
var tag = el.tagName;
|
|
214
|
+
if (tag === 'SELECT') return selectDivergence(el);
|
|
215
|
+
if (tag === 'INPUT') {
|
|
216
|
+
var type = String(el.type || 'text').toLowerCase();
|
|
217
|
+
if (SKIP_INPUT_TYPES[type]) return null;
|
|
218
|
+
if (type === 'checkbox' || type === 'radio') {
|
|
219
|
+
// `checked` is the whole state of a box; its `value` is the author's
|
|
220
|
+
// submit token, which the tree already carries as an attribute.
|
|
221
|
+
return !!el.checked === !!el.defaultChecked ? null : { checked: !!el.checked };
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
var value = typeof el.value === 'string' ? el.value : String(el.value == null ? '' : el.value);
|
|
225
|
+
return value === defaultValueOf(el) ? null : { value: value };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function form(root, span, opts) {
|
|
229
|
+
var out = [];
|
|
230
|
+
var els = collect(root, FORM_SELECTOR);
|
|
231
|
+
for (var i = 0; i < els.length; i++) {
|
|
232
|
+
var el = els[i];
|
|
233
|
+
// The spec §8 floor lives in redaction.js's isPasswordField, which this
|
|
234
|
+
// walk reaches through isInRedactedSubtree(el) — the element itself is the
|
|
235
|
+
// first link of that ancestor chain.
|
|
236
|
+
if (isWithheld(el, opts)) continue;
|
|
237
|
+
var id = seedableId(el, span, opts);
|
|
238
|
+
if (id === null) continue;
|
|
239
|
+
var state = formDivergence(el);
|
|
240
|
+
if (!state) continue;
|
|
241
|
+
// `node` first on the wire, matching the fixture's reading order.
|
|
242
|
+
out.push(Object.assign({ node: id }, state));
|
|
243
|
+
}
|
|
244
|
+
return out;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Build the `initial_state` seed for a keyframe segment (spec §3).
|
|
249
|
+
*
|
|
250
|
+
* CALL ORDER: after the keyframe's `serializeTree` on the SAME span. The seed
|
|
251
|
+
* names nodes by the ids that walk assigned, and asks the span what the file
|
|
252
|
+
* contains. Taken before the walk it returns null, enforced below rather than
|
|
253
|
+
* left to the docblock: without the guard the window scroll still reads, so a
|
|
254
|
+
* wiring slip produces a plausible-looking seed carrying no state at all.
|
|
255
|
+
*
|
|
256
|
+
* @param {object} root the observed root, as serialized
|
|
257
|
+
* @param {object} span the capture span (span.js) the keyframe was taken on
|
|
258
|
+
* @param {object} [opts]
|
|
259
|
+
* `win` window-ish {scrollX, scrollY}; defaults to the real
|
|
260
|
+
* window, and a missing one reads as the scroll origin
|
|
261
|
+
* `scrolled` the tracker's Set of elements that have scrolled
|
|
262
|
+
* (capture-trace.js `getScrolledElements()`); iterated in
|
|
263
|
+
* first-scroll order, never mutated here
|
|
264
|
+
* `keepBait` exclusion override, as everywhere else
|
|
265
|
+
* `redactSelector` §8 redaction selector
|
|
266
|
+
* `taint` §8 taint set (redaction.js); shared with the snapshot
|
|
267
|
+
* @returns {object|null} the InitialState, or null when every field is at its
|
|
268
|
+
* default — the spec's omit rule, and the jsPsych-adapter case in general
|
|
269
|
+
* (a wiped display starts every segment at defaults, so that path simply
|
|
270
|
+
* never calls this).
|
|
271
|
+
*/
|
|
272
|
+
export function buildInitialState(root, span, opts) {
|
|
273
|
+
opts = opts || {};
|
|
274
|
+
if (!root || !span) return null;
|
|
275
|
+
// Numbered nothing means walked nothing: there are no ids to name, so there
|
|
276
|
+
// is no seed to give. (A walked span always numbers at least the root.)
|
|
277
|
+
if (span.registry.count === 0) return null;
|
|
278
|
+
var win = opts.win !== undefined
|
|
279
|
+
? opts.win : (typeof window !== 'undefined' ? window : null);
|
|
280
|
+
var scroll = {
|
|
281
|
+
x: win ? round(win.scrollX || 0) : 0,
|
|
282
|
+
y: win ? round(win.scrollY || 0) : 0,
|
|
283
|
+
};
|
|
284
|
+
var state = {
|
|
285
|
+
scroll: scroll,
|
|
286
|
+
element_scroll: elementScroll(root, span, opts),
|
|
287
|
+
media: media(root, span, opts),
|
|
288
|
+
form: form(root, span, opts),
|
|
289
|
+
};
|
|
290
|
+
if (scroll.x === 0 && scroll.y === 0 && !state.element_scroll.length &&
|
|
291
|
+
!state.media.length && !state.form.length) {
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
return state;
|
|
295
|
+
}
|