cyborg-hunter 0.7.5 → 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 +51 -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/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/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,22 +1,78 @@
|
|
|
1
1
|
// src/replay/capture-trace.js
|
|
2
2
|
// Tier-1 ("trace") capture: pointer, keys, clipboard, input values, scroll,
|
|
3
|
-
// touch, focus/visibility, viewport
|
|
4
|
-
//
|
|
3
|
+
// touch, focus/visibility, viewport — in the SessionRecording v2 event
|
|
4
|
+
// vocabulary (spec §5). Everything attaches through the recorder's listener
|
|
5
|
+
// registry so destroy() tears it all down.
|
|
5
6
|
//
|
|
6
|
-
//
|
|
7
|
-
// carries BOTH page coords (x/y — document space, survives any later scroll
|
|
8
|
-
// math) and client coords (cx/cy — viewport space, what the viewer actually
|
|
9
|
-
// draws). Discrete interactions additionally carry a target anchor and are
|
|
10
|
-
// preceded by a synchronous flush of pending camera state (scroll/resize),
|
|
11
|
-
// so the event order on the wire can never invert the state the interaction
|
|
12
|
-
// happened under.
|
|
7
|
+
// What v2 changed here, and why it is one change rather than a rename pass:
|
|
13
8
|
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
9
|
+
// - **Dotted `type` names** (spec §5) replace v1's flat `kind`, and the
|
|
10
|
+
// payload under each name is that type's own shape, not a bag of fields
|
|
11
|
+
// merged onto an event. Hence `rec.pushRecord(record, t)`, which took over
|
|
12
|
+
// from v1's flat `pushEvent(kind, payload, t)` merge.
|
|
13
|
+
// - **Client-frame coordinates only** (spec §7 makes the client frame
|
|
14
|
+
// normative). v1 carried both frames — page in `x`/`y`, client in
|
|
15
|
+
// `cx`/`cy` — because its viewer did the projection itself; page positions
|
|
16
|
+
// now derive from the scroll state that `scroll.window`, `initial_state`
|
|
17
|
+
// and the camera block already carry.
|
|
18
|
+
// - **Integer node ids** (spec §4/§7) replace the nonce-marker refs. An
|
|
19
|
+
// element is addressable only if the FILE contains it, which is a question
|
|
20
|
+
// about the recording and not about the live DOM — so it is answered by the
|
|
21
|
+
// capture span's delivery model (delivery.js), the same source the mutation
|
|
22
|
+
// mapper and the keyframe seed use.
|
|
23
|
+
// - **Alignment blocks** (spec §6): the camera state and the target anchor
|
|
24
|
+
// move from flat `sx/sy/vw/vh/cw/ch` + `{tag,id,rect,n}` into complete
|
|
25
|
+
// `camera` and `anchor` objects, with the `dpr`/`vv_*` fields v1 never had.
|
|
26
|
+
// - **Viewport changes leave the event stream**: spec §2 keeps resize and
|
|
27
|
+
// pinch state in the session-level `viewport_changes` array, so those two
|
|
28
|
+
// channels push to the recorder's session sink instead of a trial.
|
|
29
|
+
//
|
|
30
|
+
// Three floors decide what a payload may say about an element. Every channel
|
|
31
|
+
// asks all three, which is the point of stating them once:
|
|
32
|
+
//
|
|
33
|
+
// 1. REDACTION (spec §8) is a property of the FILE, not of one channel: a
|
|
34
|
+
// redacted subtree's content appears nowhere, so key identity, input
|
|
35
|
+
// values, clipboard payloads and anchor ids collapse to the spec's
|
|
36
|
+
// redacted variants. Where §5 defines no redacted variant for a type
|
|
37
|
+
// (input.checked, input.select, scroll.element), the event is dropped
|
|
38
|
+
// instead — the same subtraction initial-state.js makes for the same
|
|
39
|
+
// reason.
|
|
40
|
+
// 2. EXCLUSION (spec §4) is stronger and was MISSING in v1: the file holds a
|
|
41
|
+
// placeholder with no attributes, children or content, so no channel may
|
|
42
|
+
// report the element's STATE either — not a typed value, not its length,
|
|
43
|
+
// not a scroll offset. v1's input handler checked redaction and never
|
|
44
|
+
// exclusion, which shipped the typed contents of CH's own guard bait as
|
|
45
|
+
// plaintext `input` events (the honeypot stamps its marker on the bait
|
|
46
|
+
// INPUT itself, so the whole subtree is one element).
|
|
47
|
+
// 3. ADDRESSABILITY: `input.*` and `scroll.element` are typed with a REQUIRED
|
|
48
|
+
// `node` (spec §5.2/§5.6). An event naming a node the player never
|
|
49
|
+
// received is worse than a missing event — it either does nothing or
|
|
50
|
+
// lands on the wrong element — so those types are emitted only when the
|
|
51
|
+
// target resolves. On trace tier (no DOM capture at all) nothing resolves,
|
|
52
|
+
// and the recording is honestly node-free.
|
|
53
|
+
//
|
|
54
|
+
// Both floors are asked about the RETARGETED target and the composed one, so a
|
|
55
|
+
// field inside an open shadow root cannot escape them (see `composedTarget`),
|
|
56
|
+
// and both verdicts are computed once per event (see `targetFacts`) because
|
|
57
|
+
// each one costs an ancestor walk and CH's studies are typing-heavy.
|
|
58
|
+
//
|
|
59
|
+
// Testability: the environment (doc/win/now/raf) is injectable, and so is the
|
|
60
|
+
// capture span. In the browser, callers omit `env` and the real globals are
|
|
61
|
+
// used.
|
|
16
62
|
//
|
|
17
63
|
// Failure containment: every handler body runs inside guard() — a throwing
|
|
18
64
|
// capture channel logs one captureFailure and the experiment continues.
|
|
19
65
|
|
|
66
|
+
import {
|
|
67
|
+
isRedacted, isRedactionTainted, markRedacted,
|
|
68
|
+
} from './redaction.js';
|
|
69
|
+
import { isExcluded } from './snapshot.js';
|
|
70
|
+
|
|
71
|
+
// The per-node predicates, not the per-subtree ones: this module walks the
|
|
72
|
+
// parent chain ONCE for all three floors (see `floorsFor`), so it asks each
|
|
73
|
+
// predicate the question it can answer about a single node.
|
|
74
|
+
var ELEMENT_NODE = 1;
|
|
75
|
+
|
|
20
76
|
// Wrap a handler so an exception can never propagate into the host page.
|
|
21
77
|
function guard(rec, channel, fn) {
|
|
22
78
|
return function (e) {
|
|
@@ -28,40 +84,27 @@ function guard(rec, channel, fn) {
|
|
|
28
84
|
};
|
|
29
85
|
}
|
|
30
86
|
|
|
31
|
-
// Compact element descriptor for event payloads: "input#answer", "textarea".
|
|
32
|
-
function describeEl(el) {
|
|
33
|
-
if (!el || !el.tagName) return null;
|
|
34
|
-
var d = el.tagName.toLowerCase();
|
|
35
|
-
if (el.id) d += '#' + el.id;
|
|
36
|
-
return d;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function isPassword(el) {
|
|
40
|
-
return !!(el && el.tagName === 'INPUT' && el.type === 'password');
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function matchesRedact(el, selector) {
|
|
44
|
-
if (isPassword(el)) return true; // unconditional, not overridable
|
|
45
|
-
if (!selector || !el || typeof el.matches !== 'function') return false;
|
|
46
|
-
try { return el.matches(selector); } catch (e) { return false; }
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// Subtree semantics, matching capture-dom's isInRedactedSubtree: a click on
|
|
50
|
-
// a descendant of a redacted container must not leak the descendant's
|
|
51
|
-
// identity or geometry through an anchor.
|
|
52
|
-
function inRedactedSubtree(el, selector) {
|
|
53
|
-
var cur = el;
|
|
54
|
-
while (cur) {
|
|
55
|
-
if (matchesRedact(cur, selector)) return true;
|
|
56
|
-
cur = cur.parentNode;
|
|
57
|
-
}
|
|
58
|
-
return false;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
87
|
// Round to 0.1px — anchor rects feed pixel-tolerance checks; full float
|
|
62
88
|
// precision would just bloat the JSON.
|
|
63
89
|
function r10(v) { return Math.round(v * 10) / 10; }
|
|
64
90
|
|
|
91
|
+
// Spec §5/§6 type every coordinate and camera field as a number. Real events
|
|
92
|
+
// and real windows always supply them; a duck-typed fixture may not, and a
|
|
93
|
+
// NaN or null on the wire fails strict validation instead of degrading.
|
|
94
|
+
function intOr0(v) { return typeof v === 'number' && isFinite(v) ? Math.round(v) : 0; }
|
|
95
|
+
// Same reason for the string fields spec §5.2 requires: an absent `key` is
|
|
96
|
+
// better said as "" than as the word "undefined".
|
|
97
|
+
function strOrEmpty(v) { return v == null ? '' : String(v); }
|
|
98
|
+
|
|
99
|
+
// Vendor namespace for event-level extensions (spec §9): CH-specific facts
|
|
100
|
+
// about a standard event, which may not ride as unknown top-level fields.
|
|
101
|
+
var CH_VENDOR = 'cyborg-hunter';
|
|
102
|
+
function withVendorExtension(record, data) {
|
|
103
|
+
var ext = record.extensions || (record.extensions = {});
|
|
104
|
+
ext[CH_VENDOR] = Object.assign({}, ext[CH_VENDOR], data);
|
|
105
|
+
return record;
|
|
106
|
+
}
|
|
107
|
+
|
|
65
108
|
export function attachTraceCapture(rec, env) {
|
|
66
109
|
env = env || {};
|
|
67
110
|
var doc = env.doc || document;
|
|
@@ -72,70 +115,250 @@ export function attachTraceCapture(rec, env) {
|
|
|
72
115
|
: function (fn) { setTimeout(fn, 16); });
|
|
73
116
|
var config = rec.config;
|
|
74
117
|
|
|
118
|
+
// The capture span (span.js): node ids plus the file's own record of what it
|
|
119
|
+
// contains. THE SEAM — capture wiring (index.js) creates one span per
|
|
120
|
+
// recording and hands the same object to this module and to the DOM capture,
|
|
121
|
+
// so both speak about the file in the same ids. Absent (trace tier, or any
|
|
122
|
+
// caller that never took a keyframe) means nothing is addressable, which is
|
|
123
|
+
// exactly true of a recording with no DOM in it.
|
|
124
|
+
var span = env.span || null;
|
|
125
|
+
|
|
126
|
+
// The predicates' configuration, read once: config is fixed for the life of
|
|
127
|
+
// an attachment, and re-reading it per event bought nothing.
|
|
128
|
+
var opts = {
|
|
129
|
+
keepBait: config.keepBait,
|
|
130
|
+
redactSelector: config.redactSelector,
|
|
131
|
+
taint: env.taint,
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// ── The three floors ──
|
|
135
|
+
//
|
|
136
|
+
// 1. REDACTION (spec §8) withholds CONTENT: a password field always, plus
|
|
137
|
+
// anything matching `redactSelector`, plus anything the taint set already
|
|
138
|
+
// marked (which is how content stays withheld after the page moves the
|
|
139
|
+
// element out of the redacted container — redaction.js).
|
|
140
|
+
// 2. EXCLUSION (spec §4) withholds an element's whole STATE, not just its
|
|
141
|
+
// content: not a typed value, not its length, not a scroll offset.
|
|
142
|
+
// `keepBait` still turns the LEGACY markers off, as everywhere.
|
|
143
|
+
// 3. ADDRESSABILITY: the nearest ancestor-or-self the FILE holds, with its
|
|
144
|
+
// node id. Spec §7's three cases fall out of one walk — a normal target
|
|
145
|
+
// resolves to itself; a target inside an excluded subtree resolves to the
|
|
146
|
+
// PLACEHOLDER, which is the node the file actually has; a target outside
|
|
147
|
+
// the observed root resolves to null. Membership comes from the span's
|
|
148
|
+
// delivery model rather than from re-deriving it off the live DOM, for the
|
|
149
|
+
// reason initial-state.js gives: the DOM can have moved since the walk
|
|
150
|
+
// that produced the file, and the file is what the player holds.
|
|
151
|
+
//
|
|
152
|
+
// All three are answered by `floorsFor` below, in ONE traversal.
|
|
153
|
+
|
|
154
|
+
// The REAL target of an event that crossed a shadow boundary, or null.
|
|
155
|
+
//
|
|
156
|
+
// At a document-level listener the browser retargets `e.target` to the shadow
|
|
157
|
+
// HOST, and both floor predicates walk `parentNode`, which stops at the
|
|
158
|
+
// ShadowRoot. So a password field inside an open shadow root answered "not
|
|
159
|
+
// redacted" and shipped its keystrokes, and spec §8's floor admits no
|
|
160
|
+
// exception: password values are never recorded, in any channel. §13's
|
|
161
|
+
// shadow-DOM limit governs what can be RECONSTRUCTED and licenses nothing out
|
|
162
|
+
// of a redacted field. `composedPath()[0]` is the node the interaction
|
|
163
|
+
// actually reached; CLOSED roots hide it, which is the same limit §13 already
|
|
164
|
+
// records.
|
|
165
|
+
function composedTarget(e) {
|
|
166
|
+
try {
|
|
167
|
+
if (!e || typeof e.composedPath !== 'function') return null;
|
|
168
|
+
var path0 = e.composedPath()[0];
|
|
169
|
+
return path0 && path0 !== e.target ? path0 : null;
|
|
170
|
+
} catch (err) { return null; } // composedPath unavailable
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function nodeIdFor(el) { return floorsFor(el, true).node; }
|
|
174
|
+
|
|
175
|
+
// ── One walk, three verdicts ──
|
|
176
|
+
//
|
|
177
|
+
// Redaction (including its taint history), exclusion and addressability ask
|
|
178
|
+
// three different questions about THE SAME parent chain, and each used to
|
|
179
|
+
// walk it itself, through its own subtree helper: five traversals for an
|
|
180
|
+
// aligned key.down, and five again for every input flush. This fuses the
|
|
181
|
+
// traversal — one `while (cur = cur.parentNode)` carrying three accumulators.
|
|
182
|
+
// The exclusion helper it replaced (`isInExcludedSubtree`) had no callers
|
|
183
|
+
// left afterwards and is gone; the per-node predicates it wrapped are what
|
|
184
|
+
// this walk calls.
|
|
185
|
+
//
|
|
186
|
+
// It is FUSION, NOT CACHING, and the distinction is the whole point. Every
|
|
187
|
+
// verdict is still computed fresh from the live chain for every event, so
|
|
188
|
+
// there is no window in which a stale answer can survive an ancestor gaining
|
|
189
|
+
// the exclusion attribute, a class change flipping an arbitrary
|
|
190
|
+
// `redactSelector`, or the page moving the element into a withheld
|
|
191
|
+
// container. A cross-event `WeakMap<target, verdict>` would be materially
|
|
192
|
+
// cheaper and was rejected: it fails OPEN on both privacy floors, and a
|
|
193
|
+
// cached node id is silent misplacement. The per-ancestor work (`matches()`,
|
|
194
|
+
// the attribute reads, `delivery.holds`) is unchanged; only the repeated
|
|
195
|
+
// pointer-chasing is gone.
|
|
196
|
+
//
|
|
197
|
+
// The loop stops as soon as all three questions are answered, which is why
|
|
198
|
+
// the held lookup cannot simply return early on its own any more: the floors
|
|
199
|
+
// may still be undecided above it.
|
|
200
|
+
function floorsFor(node, wantHeld) {
|
|
201
|
+
var out = { redacted: false, excluded: false, node: null, heldEl: null };
|
|
202
|
+
if (!node) return out;
|
|
203
|
+
var redacted = false;
|
|
204
|
+
var excluded = false;
|
|
205
|
+
var haveHeld = !wantHeld || !span;
|
|
206
|
+
var cur = node;
|
|
207
|
+
while (cur && !(redacted && excluded && haveHeld)) {
|
|
208
|
+
// TAINT IS ASKED OF EVERY ANCESTOR, not just of the node (T3 final
|
|
209
|
+
// review, F-1): a field created inside a container whose content this
|
|
210
|
+
// recording already withheld is withheld too, which is the reading the
|
|
211
|
+
// snapshot walk has always used and the one that makes the taint set's
|
|
212
|
+
// "can only over-redact" property true of the file rather than of one
|
|
213
|
+
// channel. Unguarded by nodeType, unlike the selector match: text nodes
|
|
214
|
+
// carry taint as readily as elements.
|
|
215
|
+
if (!redacted && isRedactionTainted(cur, opts.taint)) redacted = true;
|
|
216
|
+
if (!redacted && cur.nodeType === ELEMENT_NODE
|
|
217
|
+
&& isRedacted(cur, opts.redactSelector)) redacted = true;
|
|
218
|
+
if (!excluded && isExcluded(cur, opts)) excluded = true;
|
|
219
|
+
if (!haveHeld && span.delivery.holds(cur)) {
|
|
220
|
+
var id = span.registry.peekId(cur);
|
|
221
|
+
out.node = id == null ? null : id;
|
|
222
|
+
out.heldEl = cur;
|
|
223
|
+
haveHeld = true;
|
|
224
|
+
}
|
|
225
|
+
cur = cur.parentNode;
|
|
226
|
+
}
|
|
227
|
+
// Marking on the way out is what keeps redacted content withheld after the
|
|
228
|
+
// element leaves the redacted container (redaction.js); it belongs to the
|
|
229
|
+
// node asked about, exactly as the per-predicate version did.
|
|
230
|
+
if (redacted) markRedacted(node, opts.taint);
|
|
231
|
+
out.redacted = redacted;
|
|
232
|
+
out.excluded = excluded;
|
|
233
|
+
return out;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Everything a discrete handler needs to know about its target, asked ONCE.
|
|
237
|
+
//
|
|
238
|
+
// Both floors are evaluated against the retargeted target AND the composed
|
|
239
|
+
// one, fail-closed, and both sides are evaluated (no short-circuit) so each
|
|
240
|
+
// marks its own redaction taint. Only the retargeted chain resolves the held
|
|
241
|
+
// node: it is the one the document tree, and therefore the file, contains.
|
|
242
|
+
function targetFacts(e) {
|
|
243
|
+
var el = e && e.target;
|
|
244
|
+
var composed = composedTarget(e);
|
|
245
|
+
var facts = floorsFor(el, true);
|
|
246
|
+
var redacted = facts.redacted;
|
|
247
|
+
var excluded = facts.excluded;
|
|
248
|
+
if (composed) {
|
|
249
|
+
var c = floorsFor(composed, false);
|
|
250
|
+
if (c.redacted) redacted = true;
|
|
251
|
+
if (c.excluded) excluded = true;
|
|
252
|
+
}
|
|
253
|
+
return {
|
|
254
|
+
el: el, composed: composed, redacted: redacted, excluded: excluded,
|
|
255
|
+
node: facts.node, heldEl: facts.heldEl
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
75
259
|
function clientDims() {
|
|
76
260
|
var de = doc.documentElement;
|
|
77
261
|
return {
|
|
78
|
-
cw: de && de.clientWidth ? de.clientWidth :
|
|
79
|
-
ch: de && de.clientHeight ? de.clientHeight :
|
|
262
|
+
cw: de && de.clientWidth ? de.clientWidth : win.innerWidth,
|
|
263
|
+
ch: de && de.clientHeight ? de.clientHeight : win.innerHeight
|
|
80
264
|
};
|
|
81
265
|
}
|
|
82
266
|
|
|
83
|
-
// Client
|
|
84
|
-
|
|
85
|
-
function
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
267
|
+
// Client-frame coordinates of a mouse event or a Touch point (spec §7).
|
|
268
|
+
function clientX(p) { return intOr0(p && p.clientX); }
|
|
269
|
+
function clientY(p) { return intOr0(p && p.clientY); }
|
|
270
|
+
|
|
271
|
+
// ── Alignment: camera (spec §6) ──
|
|
272
|
+
// Flushing pending camera state is not enough on its own: browsers dispatch
|
|
273
|
+
// scroll/resize NOTIFICATIONS asynchronously after programmatic scrolls
|
|
274
|
+
// (scrollIntoView, scrollTo, anchor jumps), so an interaction can be recorded
|
|
275
|
+
// before the scroll event that describes the state it happened under — with
|
|
276
|
+
// nothing pending to flush. The camera block reads the live state
|
|
277
|
+
// synchronously in the interaction's own handler.
|
|
278
|
+
//
|
|
279
|
+
// `dpr` and the `vv_*` fields complete the block per spec §6: zoom and pinch
|
|
280
|
+
// states become classifiable per event, independent of the coalesced
|
|
281
|
+
// viewport_changes stream.
|
|
282
|
+
function cameraBlock() {
|
|
283
|
+
var dims = clientDims();
|
|
284
|
+
var vv = win.visualViewport;
|
|
285
|
+
return {
|
|
286
|
+
scroll_x: intOr0(win.scrollX),
|
|
287
|
+
scroll_y: intOr0(win.scrollY),
|
|
288
|
+
viewport_w: intOr0(win.innerWidth),
|
|
289
|
+
viewport_h: intOr0(win.innerHeight),
|
|
290
|
+
client_w: intOr0(dims.cw),
|
|
291
|
+
client_h: intOr0(dims.ch),
|
|
292
|
+
dpr: typeof win.devicePixelRatio === 'number' ? win.devicePixelRatio : 1,
|
|
293
|
+
vv_scale: vv && typeof vv.scale === 'number' ? vv.scale : 1,
|
|
294
|
+
vv_offset_x: vv ? r10(vv.offsetLeft || 0) : 0,
|
|
295
|
+
vv_offset_y: vv ? r10(vv.offsetTop || 0) : 0
|
|
296
|
+
};
|
|
89
297
|
}
|
|
90
298
|
|
|
91
|
-
// ──
|
|
92
|
-
// Identity + geometry of what a discrete interaction hit, captured at
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
|
|
100
|
-
|
|
299
|
+
// ── Alignment: anchor (spec §6) ──
|
|
300
|
+
// Identity + geometry of what a discrete interaction hit, captured at CAPTURE
|
|
301
|
+
// PHASE (before page handlers can remove the target or stop the recording —
|
|
302
|
+
// the smoke fixture's Finish click was lost to exactly that).
|
|
303
|
+
//
|
|
304
|
+
// `tag` and `rect` describe the EVENT TARGET, `node` names the nearest node
|
|
305
|
+
// the file holds. They can differ, and the target's own geometry is still the
|
|
306
|
+
// truthful answer to "what was under the pointer" — the player's cross-check
|
|
307
|
+
// is free to read the disagreement as uncertainty, which is what it is for.
|
|
308
|
+
// Describing the resolved node in every case would misreport a click on an
|
|
309
|
+
// element inserted and clicked within one task, before the observer flushed.
|
|
310
|
+
//
|
|
311
|
+
// ONE case is clamped to the resolved node: a target strictly INSIDE an
|
|
312
|
+
// excluded subtree. There, `tag` and `rect` would describe the interior of a
|
|
313
|
+
// subtree spec §4 keeps out of the file, and the file holds nothing to
|
|
314
|
+
// compare them against anyway. A click ON the excluded element needs no
|
|
315
|
+
// clamp, since §4 puts that element's own tag in the tree.
|
|
316
|
+
//
|
|
317
|
+
// And when there is NOTHING to clamp to — an excluded target with no held
|
|
318
|
+
// ancestor at all, which is trace tier (no DOM captured) or an excluded
|
|
319
|
+
// element outside the observed root — the whole anchor is dropped. The clamp
|
|
320
|
+
// has no node to describe, the file has nothing to cross-check against, and
|
|
321
|
+
// the alternative is shipping the tag and rectangle of exactly the subtree
|
|
322
|
+
// §4 withholds. Alignment fields are optional (§6), so saying nothing is a
|
|
323
|
+
// conforming answer; saying it about excluded content is the only honest one.
|
|
324
|
+
//
|
|
325
|
+
// `id` is OMITTED (not nulled) when identity is withheld — spec §6 says
|
|
326
|
+
// omitted for redacted targets, §4 says events inside an excluded subtree
|
|
327
|
+
// carry no anchor identity — so "no id attribute" (null) stays
|
|
328
|
+
// distinguishable from "withheld" (absent).
|
|
329
|
+
function anchorFor(facts) {
|
|
330
|
+
var el = facts.el;
|
|
101
331
|
if (!el || !el.tagName) return null;
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
if (
|
|
107
|
-
return { redacted: true, tag: tag };
|
|
108
|
-
}
|
|
109
|
-
var a = { tag: tag };
|
|
110
|
-
// Shadow retargeting, detected at EVENT time: for OPEN shadow roots the
|
|
111
|
-
// composed path's first entry is the real (shadow-internal) target while
|
|
112
|
-
// e.target is the retargeted host — regardless of when attachShadow()
|
|
113
|
-
// ran (a post-snapshot attach leaves no mutation record). The viewer
|
|
114
|
-
// refuses to "verify" such interactions against the hollow host.
|
|
115
|
-
// CLOSED roots are undetectable from outside by design — documented
|
|
116
|
-
// limitation, same class as CSSOM-only content changes.
|
|
117
|
-
try {
|
|
118
|
-
if (typeof e.composedPath === 'function') {
|
|
119
|
-
var path0 = e.composedPath()[0];
|
|
120
|
-
if (path0 && path0 !== el) a.shadow = true;
|
|
121
|
-
}
|
|
122
|
-
} catch (err) { /* composedPath unavailable — snapshot marking still applies */ }
|
|
123
|
-
if (el.id) a.id = el.id;
|
|
124
|
-
var markers = typeof rec.getMarkers === 'function' ? rec.getMarkers() : null;
|
|
125
|
-
if (markers) a.n = markers.refFor(el);
|
|
332
|
+
if (facts.excluded && !facts.heldEl) return null;
|
|
333
|
+
var described = (facts.excluded && facts.heldEl && facts.heldEl !== el)
|
|
334
|
+
? facts.heldEl : el;
|
|
335
|
+
var a = { tag: (described.tagName || '').toLowerCase() };
|
|
336
|
+
if (!facts.redacted && !facts.excluded) a.id = described.id || null;
|
|
126
337
|
try {
|
|
127
|
-
if (typeof
|
|
128
|
-
var r =
|
|
129
|
-
a.rect =
|
|
338
|
+
if (typeof described.getBoundingClientRect === 'function') {
|
|
339
|
+
var r = described.getBoundingClientRect();
|
|
340
|
+
a.rect = { x: r10(r.left), y: r10(r.top), w: r10(r.width), h: r10(r.height) };
|
|
130
341
|
}
|
|
131
|
-
} catch (
|
|
342
|
+
} catch (err) { /* rect stays absent — viewer treats as unverifiable */ }
|
|
343
|
+
a.node = facts.node;
|
|
132
344
|
return a;
|
|
133
345
|
}
|
|
134
346
|
|
|
347
|
+
// Shadow retargeting, noted for the player: the interaction reached a node no
|
|
348
|
+
// recording can contain (spec §13), so the anchor describes the host instead
|
|
349
|
+
// of what was really hit. That is CH's own note about a standard event, so it
|
|
350
|
+
// rides in the vendor namespace (spec §9) rather than as an extra anchor
|
|
351
|
+
// field. Open roots only; closed roots are undetectable from outside by
|
|
352
|
+
// design, which is the same limit §13 records.
|
|
353
|
+
function noteShadowRetarget(record, facts) {
|
|
354
|
+
if (facts.composed) withVendorExtension(record, { shadow_retarget: true });
|
|
355
|
+
return record;
|
|
356
|
+
}
|
|
357
|
+
|
|
135
358
|
// ── Camera state: window/element scroll + viewport, RAF-coalesced ──
|
|
136
|
-
// Two invariants the
|
|
137
|
-
// 1. ORIGINAL timestamps: a coalesced event is stamped with the time of
|
|
138
|
-
//
|
|
359
|
+
// Two invariants the player's projection depends on:
|
|
360
|
+
// 1. ORIGINAL timestamps: a coalesced event is stamped with the time of the
|
|
361
|
+
// last underlying DOM event, not the RAF flush — otherwise a click
|
|
139
362
|
// recorded between the scroll and its flush sorts BEFORE the scroll and
|
|
140
363
|
// plays back under the stale camera (full-scroll-jump error).
|
|
141
364
|
// 2. Synchronous flush before discrete events: pending camera state is
|
|
@@ -151,224 +374,393 @@ export function attachTraceCapture(rec, env) {
|
|
|
151
374
|
function flushScrolls() {
|
|
152
375
|
scrollFlushQueued = false;
|
|
153
376
|
if (pendingWindowScroll) {
|
|
154
|
-
rec.
|
|
155
|
-
|
|
156
|
-
|
|
377
|
+
rec.pushRecord({
|
|
378
|
+
type: 'scroll.window',
|
|
379
|
+
x: intOr0(win.scrollX), y: intOr0(win.scrollY)
|
|
380
|
+
}, pendingWindowScroll.t);
|
|
157
381
|
pendingWindowScroll = null;
|
|
158
382
|
}
|
|
159
383
|
if (pendingElementScrolls.size > 0) {
|
|
160
384
|
pendingElementScrolls.forEach(function (state, target) {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
385
|
+
// An excluded element's scroll offset is part of the state spec §4
|
|
386
|
+
// withholds — the same call initial-state.js makes for the same
|
|
387
|
+
// element. A REDACTED one keeps its offsets: §8 withholds content, and
|
|
388
|
+
// an offset is not content; §5.6 defines no redacted variant to put it
|
|
389
|
+
// in; and the node reference is one spec §7 explicitly keeps.
|
|
390
|
+
//
|
|
391
|
+
// No composed-target check here, and not for lack of symmetry with the
|
|
392
|
+
// other channels: a `scroll` event is fired with neither `bubbles` nor
|
|
393
|
+
// `composed`, so a scroller inside a shadow root never reaches this
|
|
394
|
+
// window-level listener AT ALL. There is no retargeted scroll to
|
|
395
|
+
// handle — the case is a §13 capture limit (the offsets are simply not
|
|
396
|
+
// recorded), not a floor bypass. When the shadow HOST is itself the
|
|
397
|
+
// scroller, or a slotted light-DOM element is, the event fires on a
|
|
398
|
+
// document-tree node and the floors apply normally. The same fact is
|
|
399
|
+
// why `scrolledElements` can never hold a shadow-internal scroller, so
|
|
400
|
+
// the keyframe seed is consistent with this by construction.
|
|
401
|
+
var f = floorsFor(target, true);
|
|
402
|
+
if (f.excluded) return;
|
|
403
|
+
var node = f.node;
|
|
404
|
+
if (node == null) return; // spec §5.6 requires a node id
|
|
405
|
+
rec.pushRecord({
|
|
406
|
+
type: 'scroll.element', node: node,
|
|
407
|
+
x: intOr0(target.scrollLeft), y: intOr0(target.scrollTop)
|
|
408
|
+
}, state.t);
|
|
183
409
|
});
|
|
184
410
|
pendingElementScrolls.clear();
|
|
185
411
|
}
|
|
186
412
|
}
|
|
187
|
-
// Window-scroll payloads carry no element fields; tiny helper keeps the
|
|
188
|
-
// shape explicit rather than relying on undefined-stripping.
|
|
189
|
-
function withClientlessScroll(p) { return p; }
|
|
190
413
|
|
|
191
|
-
|
|
192
|
-
|
|
414
|
+
// Spec §2's ViewportState, complete: one shape for both channels, because
|
|
415
|
+
// `viewport_changes` entries are full states, not deltas, and a resize
|
|
416
|
+
// changes what pinch offsets mean as surely as a pinch does.
|
|
417
|
+
function viewportState() {
|
|
418
|
+
var vv = win.visualViewport;
|
|
419
|
+
return {
|
|
420
|
+
w: intOr0(win.innerWidth),
|
|
421
|
+
h: intOr0(win.innerHeight),
|
|
422
|
+
dpr: typeof win.devicePixelRatio === 'number' ? win.devicePixelRatio : 1,
|
|
423
|
+
scale: vv && typeof vv.scale === 'number' ? vv.scale : 1,
|
|
424
|
+
offset_x: vv ? r10(vv.offsetLeft || 0) : 0,
|
|
425
|
+
offset_y: vv ? r10(vv.offsetTop || 0) : 0
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function emitResize() {
|
|
193
430
|
if (!pendingResize) return;
|
|
194
|
-
|
|
195
|
-
rec.pushEvent('resize', {
|
|
196
|
-
w: win.innerWidth, h: win.innerHeight,
|
|
197
|
-
cw: dims.cw, ch: dims.ch,
|
|
198
|
-
dpr: win.devicePixelRatio || 1
|
|
199
|
-
}, pendingResize.t);
|
|
431
|
+
rec.pushViewportChange(viewportState(), pendingResize.t);
|
|
200
432
|
pendingResize = null;
|
|
201
433
|
}
|
|
202
434
|
|
|
203
|
-
function
|
|
204
|
-
vvFlushQueued = false;
|
|
435
|
+
function emitVv() {
|
|
205
436
|
if (!pendingVv) return;
|
|
206
|
-
|
|
207
|
-
if (vv) {
|
|
208
|
-
rec.pushEvent('vv', {
|
|
209
|
-
w: r10(vv.width), h: r10(vv.height), scale: vv.scale,
|
|
210
|
-
px: r10(vv.pageLeft || 0), py: r10(vv.pageTop || 0)
|
|
211
|
-
}, pendingVv.t);
|
|
212
|
-
}
|
|
437
|
+
rec.pushViewportChange(viewportState(), pendingVv.t);
|
|
213
438
|
pendingVv = null;
|
|
214
439
|
}
|
|
215
440
|
|
|
441
|
+
// Both viewport channels write into ONE array (spec §2) whose entries keep
|
|
442
|
+
// their original timestamps, and `viewport_changes` must be time-sorted
|
|
443
|
+
// (spec §7). RAF coalescing makes the two channels' flush callbacks arrive in
|
|
444
|
+
// registration order rather than timestamp order, so BOTH callbacks drain
|
|
445
|
+
// through here.
|
|
446
|
+
//
|
|
447
|
+
// When both are pending they describe ONE state: each emit reads the live
|
|
448
|
+
// geometry, so the pair is field-identical and only its timestamp is in
|
|
449
|
+
// question. It gets the LATER of the two. The composite geometry became
|
|
450
|
+
// observable when the second notification arrived; stamping it with the first
|
|
451
|
+
// asserts that the new width existed before it did, and for a player
|
|
452
|
+
// classifying zoom or pinch, backdating a state misattributes every event in
|
|
453
|
+
// between. (Mobile keyboard-open and URL-bar transitions fire the two
|
|
454
|
+
// together, so this is an ordinary path, not a corner.) The error either way
|
|
455
|
+
// is bounded by one frame — the reason this is a one-liner and not a redesign.
|
|
456
|
+
function flushViewport() {
|
|
457
|
+
resizeFlushQueued = false;
|
|
458
|
+
vvFlushQueued = false;
|
|
459
|
+
if (pendingResize && pendingVv) {
|
|
460
|
+
var t = Math.max(pendingResize.t, pendingVv.t);
|
|
461
|
+
pendingResize = null;
|
|
462
|
+
pendingVv = null;
|
|
463
|
+
rec.pushViewportChange(viewportState(), t);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
emitResize();
|
|
467
|
+
emitVv();
|
|
468
|
+
}
|
|
469
|
+
|
|
216
470
|
// Synchronous camera flush — called at the top of every discrete-event
|
|
217
471
|
// handler so pending state is IN THE BUFFER before the interaction lands.
|
|
218
472
|
// Emission order within this function (and Map iteration order) is
|
|
219
|
-
// deliberately irrelevant: every event carries its ORIGINAL timestamp and
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
// RAF-coalesced input events pre-date this and flush late the same way.
|
|
473
|
+
// deliberately irrelevant: every event carries its ORIGINAL timestamp and the
|
|
474
|
+
// wire order is established by the serializer's stable sort on t. The buffer
|
|
475
|
+
// has never been chronologically ordered — RAF-coalesced input events pre-date
|
|
476
|
+
// this and flush late the same way.
|
|
224
477
|
function flushCameraNow() {
|
|
225
478
|
flushScrolls();
|
|
226
|
-
|
|
227
|
-
flushVv();
|
|
479
|
+
flushViewport();
|
|
228
480
|
}
|
|
229
481
|
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
function withCameraSnapshot(payload) {
|
|
238
|
-
var dims = clientDims();
|
|
239
|
-
payload.sx = Math.round(win.scrollX || 0);
|
|
240
|
-
payload.sy = Math.round(win.scrollY || 0);
|
|
241
|
-
payload.vw = win.innerWidth || null;
|
|
242
|
-
payload.vh = win.innerHeight || null;
|
|
243
|
-
payload.cw = dims.cw;
|
|
244
|
-
payload.ch = dims.ch;
|
|
245
|
-
return payload;
|
|
482
|
+
// Every discrete interaction carries both alignment blocks or neither (spec
|
|
483
|
+
// §6: complete blocks, no delta encoding).
|
|
484
|
+
function withAlignment(record, facts) {
|
|
485
|
+
record.camera = cameraBlock();
|
|
486
|
+
var anchor = anchorFor(facts);
|
|
487
|
+
if (anchor) record.anchor = anchor;
|
|
488
|
+
return record;
|
|
246
489
|
}
|
|
247
490
|
|
|
248
491
|
// ── Mouse ──
|
|
249
|
-
//
|
|
250
|
-
// carry adjudication weight, so they always record — with
|
|
492
|
+
// mouse.move is throttled to mouseHz; down/up/click are low-frequency and
|
|
493
|
+
// carry adjudication weight, so they always record — with alignment blocks.
|
|
251
494
|
// All pointer listeners are CAPTURE-PHASE: page handlers may remove the
|
|
252
|
-
// target, stopPropagation, or stop the recording before bubble reaches
|
|
253
|
-
//
|
|
495
|
+
// target, stopPropagation, or stop the recording before bubble reaches the
|
|
496
|
+
// document.
|
|
254
497
|
var minMoveGap = 1000 / (config.mouseHz || 30);
|
|
255
498
|
var lastMove = -Infinity;
|
|
256
499
|
rec.addListener(doc, 'mousemove', guard(rec, 'mouse', function (e) {
|
|
257
500
|
var t = now();
|
|
258
501
|
if (t - lastMove < minMoveGap) return;
|
|
259
502
|
lastMove = t;
|
|
260
|
-
rec.
|
|
261
|
-
x: Math.round(e.pageX), y: Math.round(e.pageY)
|
|
262
|
-
}, e), t);
|
|
503
|
+
rec.pushRecord({ type: 'mouse.move', x: clientX(e), y: clientY(e) }, t);
|
|
263
504
|
}), { passive: true, capture: true });
|
|
264
|
-
['mousedown', 'mouseup', '
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
505
|
+
[['mousedown', 'mouse.down'], ['mouseup', 'mouse.up'], ['click', 'mouse.click']]
|
|
506
|
+
.forEach(function (pair) {
|
|
507
|
+
rec.addListener(doc, pair[0], guard(rec, 'mouse', function (e) {
|
|
508
|
+
flushCameraNow();
|
|
509
|
+
var facts = targetFacts(e);
|
|
510
|
+
var record = withAlignment({
|
|
511
|
+
type: pair[1],
|
|
512
|
+
x: clientX(e), y: clientY(e),
|
|
513
|
+
button: typeof e.button === 'number' ? e.button : 0,
|
|
514
|
+
target: facts.node
|
|
515
|
+
}, facts);
|
|
516
|
+
rec.pushRecord(noteShadowRetarget(record, facts), now());
|
|
517
|
+
}), { passive: true, capture: true });
|
|
518
|
+
});
|
|
275
519
|
|
|
276
520
|
// ── Keys ──
|
|
277
|
-
// keys:'off' attaches nothing.
|
|
278
|
-
//
|
|
279
|
-
//
|
|
280
|
-
// would defeat the same
|
|
521
|
+
// keys:'off' attaches nothing. A redacted OR excluded target records the
|
|
522
|
+
// spec §5.2 redacted variant — type and time, nothing else — because a
|
|
523
|
+
// field's text is reconstructable keystroke-by-keystroke otherwise, which
|
|
524
|
+
// would defeat the same withholding the input-value channel honours.
|
|
525
|
+
//
|
|
526
|
+
// Alignment fields ride key.down only, and not on auto-repeats: spec §6's
|
|
527
|
+
// cost rule, taken up deliberately because CH's studies are typing-heavy and
|
|
528
|
+
// each block costs a layout read.
|
|
281
529
|
if (config.keys !== 'off') {
|
|
282
|
-
['keydown', 'keyup'].forEach(function (
|
|
283
|
-
rec.addListener(doc,
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
rec.pushEvent(kind, { redacted: true }, now());
|
|
530
|
+
[['keydown', 'key.down'], ['keyup', 'key.up']].forEach(function (pair) {
|
|
531
|
+
rec.addListener(doc, pair[0], guard(rec, 'keys', function (e) {
|
|
532
|
+
var facts = targetFacts(e);
|
|
533
|
+
if (facts.redacted || facts.excluded) {
|
|
534
|
+
rec.pushRecord(
|
|
535
|
+
noteShadowRetarget({ type: pair[1], redacted: true }, facts), now());
|
|
289
536
|
return;
|
|
290
537
|
}
|
|
291
|
-
|
|
538
|
+
var aligned = pair[1] === 'key.down' && !e.repeat;
|
|
539
|
+
if (aligned) flushCameraNow();
|
|
540
|
+
var record = {
|
|
541
|
+
type: pair[1],
|
|
542
|
+
key: strOrEmpty(e.key), code: strOrEmpty(e.code),
|
|
543
|
+
mods: {
|
|
544
|
+
ctrl: !!e.ctrlKey, shift: !!e.shiftKey,
|
|
545
|
+
alt: !!e.altKey, meta: !!e.metaKey
|
|
546
|
+
},
|
|
547
|
+
repeat: !!e.repeat,
|
|
548
|
+
target: facts.node
|
|
549
|
+
};
|
|
550
|
+
if (aligned) withAlignment(record, facts);
|
|
551
|
+
rec.pushRecord(noteShadowRetarget(record, facts), now());
|
|
292
552
|
}), true); // capture phase — before frameworks can stopPropagation
|
|
293
553
|
});
|
|
294
554
|
}
|
|
295
555
|
|
|
296
|
-
// ── Clipboard ──
|
|
297
|
-
//
|
|
298
|
-
//
|
|
556
|
+
// ── Clipboard (spec §5.3) ──
|
|
557
|
+
// CH's default is LENGTH-ONLY: pasted text routinely contains identifying
|
|
558
|
+
// material from outside the page, so the content stays out of the replay
|
|
559
|
+
// stream by design (CH's own pasteDropContent flag governs content capture in
|
|
560
|
+
// the integrity report, separately). `clipboardContent: true` selects the
|
|
561
|
+
// content mode the spec also defines.
|
|
562
|
+
//
|
|
563
|
+
// copy/cut carry no length: the clipboard is not populated yet when they
|
|
564
|
+
// fire, so any number read there would be a fiction about the selection.
|
|
565
|
+
function readClipboard(getData) {
|
|
566
|
+
var text = null, html = null;
|
|
567
|
+
try { text = getData('text'); } catch (err) { /* stays null */ }
|
|
568
|
+
if (config.clipboardContent) {
|
|
569
|
+
try { html = getData('text/html') || null; } catch (err) { /* stays null */ }
|
|
570
|
+
}
|
|
571
|
+
return { text: typeof text === 'string' ? text : null, html: html };
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function clipboardRecord(type, facts, read) {
|
|
575
|
+
var record = {
|
|
576
|
+
type: type,
|
|
577
|
+
target: facts.node,
|
|
578
|
+
text: null, html: null,
|
|
579
|
+
len: read && read.text != null ? read.text.length : null
|
|
580
|
+
};
|
|
581
|
+
// Redacted target: content withheld, length allowed (spec §5.3, which
|
|
582
|
+
// permits `len` under the marker). An EXCLUDED target loses the length too:
|
|
583
|
+
// this module's floor #2 withholds an excluded element's state whole, and
|
|
584
|
+
// `input.value` already refuses even `value_len` for that same element, so
|
|
585
|
+
// a surviving clipboard length would be the one number that escaped.
|
|
586
|
+
if (facts.redacted) record.redacted = true;
|
|
587
|
+
if (facts.excluded) record.len = null;
|
|
588
|
+
if (config.clipboardContent && !facts.redacted && !facts.excluded && read) {
|
|
589
|
+
record.text = read.text;
|
|
590
|
+
record.html = read.html;
|
|
591
|
+
record.len = null;
|
|
592
|
+
}
|
|
593
|
+
return noteShadowRetarget(record, facts);
|
|
594
|
+
}
|
|
595
|
+
|
|
299
596
|
rec.addListener(doc, 'paste', guard(rec, 'clipboard', function (e) {
|
|
300
597
|
flushCameraNow();
|
|
301
|
-
var
|
|
302
|
-
|
|
303
|
-
|
|
598
|
+
var facts = targetFacts(e);
|
|
599
|
+
var data = e.clipboardData;
|
|
600
|
+
var read = readClipboard(function (fmt) { return data.getData(fmt); });
|
|
601
|
+
rec.pushRecord(clipboardRecord('clipboard.paste', facts, read), now());
|
|
304
602
|
}), true);
|
|
305
|
-
rec.addListener(doc, 'copy', guard(rec, 'clipboard', function () {
|
|
603
|
+
rec.addListener(doc, 'copy', guard(rec, 'clipboard', function (e) {
|
|
306
604
|
flushCameraNow();
|
|
307
|
-
rec.
|
|
605
|
+
rec.pushRecord(clipboardRecord('clipboard.copy', targetFacts(e), null), now());
|
|
308
606
|
}), true);
|
|
309
|
-
rec.addListener(doc, 'cut', guard(rec, 'clipboard', function () {
|
|
607
|
+
rec.addListener(doc, 'cut', guard(rec, 'clipboard', function (e) {
|
|
310
608
|
flushCameraNow();
|
|
311
|
-
rec.
|
|
609
|
+
rec.pushRecord(clipboardRecord('clipboard.cut', targetFacts(e), null), now());
|
|
312
610
|
}), true);
|
|
313
611
|
rec.addListener(doc, 'drop', guard(rec, 'clipboard', function (e) {
|
|
314
612
|
flushCameraNow();
|
|
315
|
-
var
|
|
316
|
-
|
|
317
|
-
|
|
613
|
+
var facts = targetFacts(e);
|
|
614
|
+
var data = e.dataTransfer;
|
|
615
|
+
var read = readClipboard(function (fmt) { return data.getData(fmt); });
|
|
616
|
+
rec.pushRecord(clipboardRecord('clipboard.drop', facts, read), now());
|
|
318
617
|
}), true);
|
|
319
618
|
|
|
320
619
|
// ── Input values (RAF-coalesced per target) ──
|
|
321
|
-
// Rapid typing produces one event per frame per field, holding the LAST
|
|
322
|
-
//
|
|
323
|
-
//
|
|
620
|
+
// Rapid typing produces one event per frame per field, holding the LAST value
|
|
621
|
+
// — enough to reconstruct field state on scrub without recording every
|
|
622
|
+
// intermediate keystroke twice (key.down already carries timing).
|
|
623
|
+
//
|
|
624
|
+
// v2 splits v1's single `input` event into the three types spec §5.2 defines,
|
|
625
|
+
// by what the control's state IS: a select's selection, a box's checkedness,
|
|
626
|
+
// everything else's value. The same three shapes initial-state.js seeds.
|
|
324
627
|
var pendingInputs = new Map(); // target → latest event time
|
|
325
628
|
var inputFlushQueued = false;
|
|
629
|
+
|
|
630
|
+
function readValue(target) {
|
|
631
|
+
if (target.value != null) return String(target.value);
|
|
632
|
+
if (target.isContentEditable && target.textContent != null) return String(target.textContent);
|
|
633
|
+
return '';
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function selectedValues(target) {
|
|
637
|
+
var out = [];
|
|
638
|
+
var options = target.options || [];
|
|
639
|
+
for (var i = 0; i < options.length; i++) {
|
|
640
|
+
if (options[i].selected) {
|
|
641
|
+
out.push(typeof options[i].value === 'string'
|
|
642
|
+
? options[i].value : String(options[i].textContent || ''));
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
return out;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// `<input type="file">`, read the way the password floor reads its own type
|
|
649
|
+
// (redaction.js): the IDL property first, the attribute as the fallback that
|
|
650
|
+
// catches duck-typed nodes and unknown-type reads.
|
|
651
|
+
function isFileInput(el) {
|
|
652
|
+
if (!el || el.tagName !== 'INPUT') return false;
|
|
653
|
+
if (String(el.type || '').toLowerCase() === 'file') return true;
|
|
654
|
+
var attr = typeof el.getAttribute === 'function' ? el.getAttribute('type') : null;
|
|
655
|
+
return typeof attr === 'string' && attr.toLowerCase() === 'file';
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// `composed` is the shadow-internal target captured at DISPATCH time, since
|
|
659
|
+
// composedPath() is only meaningful during dispatch and this channel reads
|
|
660
|
+
// its value a frame later.
|
|
661
|
+
function inputRecordFor(target, composed) {
|
|
662
|
+
// Spec §4: nothing about an excluded element's state, not even a length.
|
|
663
|
+
var f = floorsFor(target, true);
|
|
664
|
+
if (f.excluded) return null;
|
|
665
|
+
var c = composed ? floorsFor(composed, false) : null;
|
|
666
|
+
if (c && c.excluded) return null;
|
|
667
|
+
var node = f.node;
|
|
668
|
+
if (node == null) return null; // spec §5.2 requires a node id
|
|
669
|
+
var redacted = f.redacted || !!(c && c.redacted);
|
|
670
|
+
// FILE INPUTS SAY NOTHING (spec §13 puts file selection outside the format,
|
|
671
|
+
// and initial-state.js's SKIP_INPUT_TYPES already skips them at the seed).
|
|
672
|
+
// A file input's `value` is not participant-typed text, it is
|
|
673
|
+
// "C:\fakepath\<the participant's own filename>" — real PII from outside
|
|
674
|
+
// the page — so neither the value NOR its length may travel. Dropping the
|
|
675
|
+
// event, rather than emitting the redacted variant, is what keeps this
|
|
676
|
+
// channel and the keyframe seed saying the same thing about one control.
|
|
677
|
+
// happy-dom never synthesizes the fakepath value, which is why the leak
|
|
678
|
+
// survived unit coverage until the Chromium battery
|
|
679
|
+
// (tests/browser/replay/capture-chromium.battery.mjs, case 14) drove a real
|
|
680
|
+
// setInputFiles.
|
|
681
|
+
//
|
|
682
|
+
// Asked of the COMPOSED target too, like the two floors above it: at a
|
|
683
|
+
// document-level listener a file input inside an open shadow root arrives
|
|
684
|
+
// retargeted to its host, and a host that proxies `.value` would otherwise
|
|
685
|
+
// take the leaky path. Property OR attribute, like the password floor
|
|
686
|
+
// (redaction.js): the IDL `type` is the browser's answer but is absent on
|
|
687
|
+
// duck-typed nodes and reads "text" for an unknown type.
|
|
688
|
+
if (isFileInput(target) || (composed && isFileInput(composed))) return null;
|
|
689
|
+
var type = String(target.type || '').toLowerCase();
|
|
690
|
+
if (target.tagName === 'SELECT') {
|
|
691
|
+
// No redacted variant exists for input.select or input.checked (spec
|
|
692
|
+
// §5.2 defines one for input.value only), so a withheld control emits
|
|
693
|
+
// nothing at all rather than a shape the format does not have.
|
|
694
|
+
return redacted ? null
|
|
695
|
+
: { type: 'input.select', node: node, values: selectedValues(target) };
|
|
696
|
+
}
|
|
697
|
+
if (target.tagName === 'INPUT' && (type === 'checkbox' || type === 'radio')) {
|
|
698
|
+
// checked is an element PROPERTY (never an attribute mutation), so the
|
|
699
|
+
// DOM replay can only restore box state from here — but a redacted
|
|
700
|
+
// field's checked state is still its value: withhold it.
|
|
701
|
+
return redacted ? null
|
|
702
|
+
: { type: 'input.checked', node: node, checked: !!target.checked };
|
|
703
|
+
}
|
|
704
|
+
var value = readValue(target);
|
|
705
|
+
if (redacted) {
|
|
706
|
+
return { type: 'input.value', node: node, redacted: true, value_len: value.length };
|
|
707
|
+
}
|
|
708
|
+
return { type: 'input.value', node: node, value: value };
|
|
709
|
+
}
|
|
710
|
+
|
|
326
711
|
function flushInputs() {
|
|
327
712
|
inputFlushQueued = false;
|
|
328
|
-
pendingInputs.forEach(function (
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
: (target.isContentEditable && target.textContent != null
|
|
334
|
-
? String(target.textContent) : '');
|
|
335
|
-
// SUBTREE semantics: a field inside a redacted container leaks no
|
|
336
|
-
// value. The id/el descriptor stays (the DOM snapshot already carries
|
|
337
|
-
// redacted elements' ids; the viewer needs a target for the bullets).
|
|
338
|
-
if (inRedactedSubtree(target, config.redactSelector)) {
|
|
339
|
-
payload.redacted = true;
|
|
340
|
-
payload.value_len = value.length;
|
|
341
|
-
} else {
|
|
342
|
-
payload.value = value;
|
|
343
|
-
// Marker ref for duplicate-id-safe restore (non-redacted only:
|
|
344
|
-
// redacted events gain no NEW identity fields).
|
|
345
|
-
var inputMarkers = typeof rec.getMarkers === 'function' ? rec.getMarkers() : null;
|
|
346
|
-
if (inputMarkers) payload.n = inputMarkers.refFor(target);
|
|
713
|
+
pendingInputs.forEach(function (pending, target) {
|
|
714
|
+
var record = inputRecordFor(target, pending.composed);
|
|
715
|
+
if (record) {
|
|
716
|
+
if (pending.composed) withVendorExtension(record, { shadow_retarget: true });
|
|
717
|
+
rec.pushRecord(record, pending.t);
|
|
347
718
|
}
|
|
348
|
-
// checked is an element PROPERTY (never an attribute mutation), so
|
|
349
|
-
// the DOM replay can only restore checkbox/radio state from here —
|
|
350
|
-
// but a redacted field's checked state is still its VALUE: withhold it.
|
|
351
|
-
if (!payload.redacted && (target.type === 'checkbox' || target.type === 'radio')) {
|
|
352
|
-
payload.checked = !!target.checked;
|
|
353
|
-
}
|
|
354
|
-
rec.pushEvent('input', payload, t);
|
|
355
719
|
});
|
|
356
720
|
pendingInputs.clear();
|
|
357
721
|
}
|
|
358
722
|
rec.addListener(doc, 'input', guard(rec, 'input', function (e) {
|
|
359
723
|
if (!e.target) return;
|
|
360
|
-
pendingInputs.set(e.target, now());
|
|
724
|
+
pendingInputs.set(e.target, { t: now(), composed: composedTarget(e) });
|
|
361
725
|
if (!inputFlushQueued) {
|
|
362
726
|
inputFlushQueued = true;
|
|
363
727
|
raf(guard(rec, 'input', flushInputs));
|
|
364
728
|
}
|
|
365
729
|
}), true);
|
|
366
730
|
|
|
731
|
+
// ── Scrolled-element tracker (spec §3 initial_state.element_scroll) ──
|
|
732
|
+
// Which inner scrollers a keyframe has to seed is not answerable from the
|
|
733
|
+
// DOM: `overflow` says an element COULD scroll, not that it has, and the
|
|
734
|
+
// participant's offsets are the state a fresh snapshot cannot carry. The one
|
|
735
|
+
// place that knows is the scroll listener below, so it keeps the set.
|
|
736
|
+
//
|
|
737
|
+
// Lifetime is the RECORDING, not the trial: an element scrolled during trial
|
|
738
|
+
// 2 still needs seeding at the keyframe of trial 7, so the Set lives in this
|
|
739
|
+
// attachment (created once, at startSession) rather than on a trial.
|
|
740
|
+
//
|
|
741
|
+
// A strong Set, because the seed has to ENUMERATE it — which makes it a
|
|
742
|
+
// retention leak by construction, one detached subtree per scrolled element
|
|
743
|
+
// per trial over a long session. Pruning on read and once per trial bounds it
|
|
744
|
+
// without a reaper: `isConnected === false` is the browser's own answer, and
|
|
745
|
+
// reading it explicitly (rather than falsy) keeps duck-typed test nodes,
|
|
746
|
+
// which claim nothing, in the Set.
|
|
747
|
+
//
|
|
748
|
+
// Deliberately dumb: every element that scrolls goes in, redacted or excluded
|
|
749
|
+
// or outside the observed root. What may be SEEDED is one question with one
|
|
750
|
+
// answer, and it lives in initial-state.js.
|
|
751
|
+
var scrolledElements = new Set();
|
|
752
|
+
function pruneScrolledElements() {
|
|
753
|
+
scrolledElements.forEach(function (el) {
|
|
754
|
+
if (el && el.isConnected === false) scrolledElements.delete(el);
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
|
|
367
758
|
// ── Scroll (RAF-coalesced, per target, original timestamps) ──
|
|
368
759
|
rec.addListener(win, 'scroll', guard(rec, 'scroll', function (e) {
|
|
369
760
|
var target = e && e.target && e.target.tagName ? e.target : null;
|
|
370
761
|
if (target) {
|
|
371
762
|
pendingElementScrolls.set(target, { t: now() });
|
|
763
|
+
scrolledElements.add(target);
|
|
372
764
|
} else {
|
|
373
765
|
pendingWindowScroll = { t: now() };
|
|
374
766
|
}
|
|
@@ -379,53 +771,68 @@ export function attachTraceCapture(rec, env) {
|
|
|
379
771
|
}), { passive: true, capture: true });
|
|
380
772
|
|
|
381
773
|
// ── Touch ──
|
|
774
|
+
// Spec §5.2 records the whole touch list, not one point: multi-touch is what
|
|
775
|
+
// distinguishes a pinch from a drag. touch.end reports `changedTouches` (the
|
|
776
|
+
// points that ended) — `touches` at that moment holds the fingers still down,
|
|
777
|
+
// which says nothing about the gesture that just finished.
|
|
778
|
+
function touchList(list) {
|
|
779
|
+
var out = [];
|
|
780
|
+
for (var i = 0; i < (list ? list.length : 0); i++) {
|
|
781
|
+
var p = list[i];
|
|
782
|
+
out.push({
|
|
783
|
+
id: typeof p.identifier === 'number' ? p.identifier : i,
|
|
784
|
+
x: clientX(p), y: clientY(p)
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
return out;
|
|
788
|
+
}
|
|
789
|
+
|
|
382
790
|
rec.addListener(doc, 'touchstart', guard(rec, 'touch', function (e) {
|
|
383
791
|
flushCameraNow();
|
|
384
|
-
var
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
var anchor = anchorFor(e);
|
|
389
|
-
if (anchor) payload.target = anchor;
|
|
390
|
-
rec.pushEvent('touchstart', payload, now());
|
|
792
|
+
var facts = targetFacts(e);
|
|
793
|
+
var record = withAlignment(
|
|
794
|
+
{ type: 'touch.start', touches: touchList(e.touches) }, facts);
|
|
795
|
+
rec.pushRecord(noteShadowRetarget(record, facts), now());
|
|
391
796
|
}), { passive: true, capture: true });
|
|
392
797
|
var touchPending = false;
|
|
393
|
-
var
|
|
798
|
+
var lastTouches = null;
|
|
394
799
|
var lastTouchT = 0;
|
|
395
800
|
rec.addListener(doc, 'touchmove', guard(rec, 'touch', function (e) {
|
|
396
|
-
|
|
801
|
+
// Converted eagerly: a Touch is only meaningful for its own event, so the
|
|
802
|
+
// coalescing window must hold the numbers, not the objects.
|
|
803
|
+
lastTouches = touchList(e.touches);
|
|
397
804
|
lastTouchT = now();
|
|
398
805
|
if (touchPending) return;
|
|
399
806
|
touchPending = true;
|
|
400
807
|
raf(guard(rec, 'touch', function () {
|
|
401
808
|
touchPending = false;
|
|
402
|
-
if (
|
|
403
|
-
rec.
|
|
404
|
-
x: Math.round(lastTouch.pageX), y: Math.round(lastTouch.pageY)
|
|
405
|
-
}, lastTouch), lastTouchT);
|
|
809
|
+
if (lastTouches) {
|
|
810
|
+
rec.pushRecord({ type: 'touch.move', touches: lastTouches }, lastTouchT);
|
|
406
811
|
}
|
|
407
812
|
}));
|
|
408
813
|
}), { passive: true, capture: true });
|
|
409
814
|
rec.addListener(doc, 'touchend', guard(rec, 'touch', function (e) {
|
|
410
815
|
flushCameraNow();
|
|
411
|
-
var
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
var anchor = anchorFor(e);
|
|
416
|
-
if (anchor) payload.target = anchor;
|
|
417
|
-
rec.pushEvent('touchend', payload, now());
|
|
816
|
+
var facts = targetFacts(e);
|
|
817
|
+
var record = withAlignment(
|
|
818
|
+
{ type: 'touch.end', touches: touchList(e.changedTouches) }, facts);
|
|
819
|
+
rec.pushRecord(noteShadowRetarget(record, facts), now());
|
|
418
820
|
}), { passive: true, capture: true });
|
|
419
821
|
|
|
420
822
|
// ── Focus / visibility ──
|
|
823
|
+
// visibility.* records Page Visibility transitions (spec §5.5) — tab-away,
|
|
824
|
+
// distinct from window blur, and now distinct on the wire too: v1 folded both
|
|
825
|
+
// directions into one `visibility` event with a boolean.
|
|
421
826
|
rec.addListener(win, 'blur', guard(rec, 'focus', function () {
|
|
422
|
-
rec.
|
|
827
|
+
rec.pushRecord({ type: 'blur' }, now());
|
|
423
828
|
}));
|
|
424
829
|
rec.addListener(win, 'focus', guard(rec, 'focus', function () {
|
|
425
|
-
rec.
|
|
830
|
+
rec.pushRecord({ type: 'focus' }, now());
|
|
426
831
|
}));
|
|
427
832
|
rec.addListener(doc, 'visibilitychange', guard(rec, 'focus', function () {
|
|
428
|
-
rec.
|
|
833
|
+
rec.pushRecord({
|
|
834
|
+
type: doc.hidden ? 'visibility.hidden' : 'visibility.visible'
|
|
835
|
+
}, now());
|
|
429
836
|
}));
|
|
430
837
|
|
|
431
838
|
// ── Viewport (RAF-coalesced resize; event-driven, no polling) ──
|
|
@@ -433,36 +840,46 @@ export function attachTraceCapture(rec, env) {
|
|
|
433
840
|
pendingResize = { t: now() };
|
|
434
841
|
if (!resizeFlushQueued) {
|
|
435
842
|
resizeFlushQueued = true;
|
|
436
|
-
raf(guard(rec, 'viewport',
|
|
843
|
+
raf(guard(rec, 'viewport', flushViewport));
|
|
437
844
|
}
|
|
438
845
|
}));
|
|
439
846
|
// visualViewport: size/scale AND pan (scroll). Pinch-pan does not move the
|
|
440
|
-
// pointer relative to the DOM (
|
|
441
|
-
//
|
|
442
|
-
//
|
|
847
|
+
// pointer relative to the DOM (client coords are layout-viewport CSS px), so
|
|
848
|
+
// vv is visible-region METADATA, not part of the cursor projection — recorded
|
|
849
|
+
// so the viewer can later show what the participant could see.
|
|
443
850
|
if (win.visualViewport && typeof win.visualViewport.addEventListener === 'function') {
|
|
444
851
|
['resize', 'scroll'].forEach(function (kind) {
|
|
445
852
|
rec.addListener(win.visualViewport, kind, guard(rec, 'viewport', function () {
|
|
446
853
|
pendingVv = { t: now() };
|
|
447
854
|
if (!vvFlushQueued) {
|
|
448
855
|
vvFlushQueued = true;
|
|
449
|
-
raf(guard(rec, 'viewport',
|
|
856
|
+
raf(guard(rec, 'viewport', flushViewport));
|
|
450
857
|
}
|
|
451
858
|
}));
|
|
452
859
|
});
|
|
453
860
|
}
|
|
454
861
|
|
|
455
|
-
// ── Per-trial
|
|
456
|
-
//
|
|
457
|
-
//
|
|
458
|
-
//
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
cw: dims.cw, ch: dims.ch,
|
|
465
|
-
dpr: win.devicePixelRatio || 1
|
|
466
|
-
};
|
|
862
|
+
// ── Per-trial upkeep ──
|
|
863
|
+
// v1 stamped `trial.viewState` here — a window-scroll + viewport seed, added
|
|
864
|
+
// for a real mid-scroll alignment bug. Spec §14 names `initial_state` its v2
|
|
865
|
+
// successor, and that seed is taken at the KEYFRAME (initial-state.js), after
|
|
866
|
+
// the snapshot walk that gives it node ids to name. So this hook keeps only
|
|
867
|
+
// the tracker upkeep the seed depends on; the call site is the capture
|
|
868
|
+
// wiring's (Task 6).
|
|
869
|
+
rec.onTrialStart(function () {
|
|
870
|
+
pruneScrolledElements();
|
|
467
871
|
});
|
|
872
|
+
|
|
873
|
+
// The capture handle. v1 callers ignore the return value (index.js calls this
|
|
874
|
+
// for its side effects), so it stays invisible to them; the keyframe path
|
|
875
|
+
// reads the scrolled-element set through it. The span is NOT echoed back: it
|
|
876
|
+
// comes IN through `env`, so its owner already has it.
|
|
877
|
+
return {
|
|
878
|
+
// The LIVE Set, pruned of detached elements first. Read-only by contract:
|
|
879
|
+
// buildInitialState iterates it and the tracker owns its contents.
|
|
880
|
+
getScrolledElements: function () {
|
|
881
|
+
pruneScrolledElements();
|
|
882
|
+
return scrolledElements;
|
|
883
|
+
},
|
|
884
|
+
};
|
|
468
885
|
}
|