cyborg-hunter 0.7.4 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +63 -0
- package/CITATION.cff +2 -2
- package/README.md +21 -6
- package/dist/cyborg-hunter-replay.js +3 -3
- package/dist/cyborg-hunter.esm.js +3 -2
- package/dist/cyborg-hunter.min.js +3 -3
- package/dist/extension-cyborg-hunter.js +1 -1
- package/dist/extension-guard-friction.js +5 -5
- package/package.json +10 -2
- package/src/cli/ingest.js +311 -57
- package/src/cli/renderers/html-index-core.js +66 -12
- package/src/cli/renderers/html-index.js +7 -5
- package/src/cli/renderers/replay-assets.js +61 -7
- package/src/cli/renderers/replay-client-source.js +102 -0
- package/src/cli/renderers/replay-viewer.client.js +1750 -595
- package/src/cli/report.js +6 -0
- package/src/core/monitor.js +12 -13
- package/src/jspsych/extension-cyborg-hunter-replay.js +64 -3
- package/src/jspsych/extension-cyborg-hunter.js +1 -1
- package/src/jspsych/extension-guard-friction.js +59 -1
- package/src/replay/capture-dom.js +470 -458
- package/src/replay/capture-trace.js +688 -271
- package/src/replay/delivery.js +82 -0
- package/src/replay/dom-instantiate.js +779 -0
- package/src/replay/index.js +88 -5
- package/src/replay/initial-state.js +295 -0
- package/src/replay/mutations.js +668 -0
- package/src/replay/node-registry.js +116 -0
- package/src/replay/persistence.js +19 -6
- package/src/replay/recorder.js +342 -73
- package/src/replay/redaction.js +165 -0
- package/src/replay/serializer.js +148 -43
- package/src/replay/snapshot.js +409 -0
- package/src/replay/span.js +55 -0
- package/src/replay/viewer-model.js +293 -102
- package/src/shared/constants.js +1 -1
- package/src/shared/inline-safe.js +80 -0
- package/src/shared/schema-v2-validator.js +595 -0
- package/tools/convert/jspsych-v1-to-v2.mjs +432 -0
|
@@ -1,413 +1,231 @@
|
|
|
1
1
|
// src/replay/capture-dom.js
|
|
2
|
-
// Tier-2 ("dom") capture
|
|
3
|
-
//
|
|
2
|
+
// Tier-2 ("dom") capture, wired to the v2 node-tree pipeline: keyframe
|
|
3
|
+
// snapshots (snapshot.js), MutationObserver batches → `dom.*` patches
|
|
4
|
+
// (mutations.js), the `initial_state` seed (initial-state.js), initial
|
|
5
|
+
// stylesheet capture, and the guard-friction pre-scramble hook.
|
|
4
6
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
7
|
+
// This module owns no serialization logic of its own any more. It is the
|
|
8
|
+
// WIRING: what the observed root is, when a keyframe is taken, which span the
|
|
9
|
+
// ids come from, and how a batch reaches the recorder. Everything it used to
|
|
10
|
+
// do by hand is now a shared, separately tested module — which is the point:
|
|
11
|
+
// the keyframe and the patches that address it must agree about exclusion,
|
|
12
|
+
// redaction and node identity, and the only way to guarantee that is for both
|
|
13
|
+
// to run the same code.
|
|
8
14
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
|
|
15
|
+
// What retired here at the v2 switchover (spec §14's CH migration list):
|
|
16
|
+
// - the HTML-STRING walker (`serializeDom`) and its iframe span placeholder,
|
|
17
|
+
// void-tag table and escaping. A keyframe is a DomNode tree (spec §4);
|
|
18
|
+
// iframes are recorded as the element itself with no children (§13).
|
|
19
|
+
// - the NONCE MARKER registry (`data-chn-*`). Integer node ids scoped to a
|
|
20
|
+
// keyframe span replaced it, so nothing has to be stamped into serialized
|
|
21
|
+
// markup and harvested back out by the viewer.
|
|
22
|
+
// - CHILD-INDEX PATHS (`nodePath`) and the `mutation` patch shape. Patches
|
|
23
|
+
// address nodes by id (§5.1).
|
|
24
|
+
// - INTRA-BATCH DEDUP and the characterData→childList fold. Both existed
|
|
25
|
+
// because a v1 childList patch re-serialized the target's whole resulting
|
|
26
|
+
// children; a `dom.add` carries only what was inserted. mutations.js's
|
|
27
|
+
// header records the reasoning, and its batch pre-scan is why the observer
|
|
28
|
+
// callback below hands over the COMPLETE records array untouched.
|
|
29
|
+
// - the DUPLICATED redaction predicates. redaction.js is the one definition
|
|
30
|
+
// (spec §8 makes redaction a property of the file, which is a claim only a
|
|
31
|
+
// single predicate can make checkable).
|
|
32
|
+
|
|
33
|
+
import { serializeTree } from './snapshot.js';
|
|
34
|
+
import { mapMutations, MUTATION_OBSERVER_INIT } from './mutations.js';
|
|
35
|
+
import { buildInitialState } from './initial-state.js';
|
|
36
|
+
import { createSpan } from './span.js';
|
|
22
37
|
|
|
23
38
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
39
|
+
* Initial stylesheet capture (spec §2 `StylesheetSnapshot`).
|
|
40
|
+
*
|
|
41
|
+
* Ids are the array position plus one, assigned here because the format wants
|
|
42
|
+
* them: `stylesheet_events` addresses sheets by id, and even with that stream
|
|
43
|
+
* empty (CH captures no CSSOM mutations — spec §13's known limit) a consumer
|
|
44
|
+
* reads the two arrays as one addressable set.
|
|
45
|
+
*
|
|
46
|
+
* `kind` follows the sheet's origin rather than its readability: a <link>
|
|
47
|
+
* stays a link sheet even when its rules are readable same-origin, and its
|
|
48
|
+
* `css` is filled in when they are. A cross-origin sheet throws on `cssRules`,
|
|
49
|
+
* so only the (absolute) href survives — the viewer can link it, nothing can
|
|
50
|
+
* inline it.
|
|
34
51
|
*/
|
|
35
|
-
export function
|
|
36
|
-
var ids = new WeakMap();
|
|
37
|
-
var next = 1;
|
|
38
|
-
return {
|
|
39
|
-
attr: 'data-chn-' + nonce,
|
|
40
|
-
refFor: function (node) {
|
|
41
|
-
var n = ids.get(node);
|
|
42
|
-
if (n == null) { n = next++; ids.set(node, n); }
|
|
43
|
-
return n;
|
|
44
|
-
}
|
|
45
|
-
};
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// Void elements per the HTML spec — serialized without a closing tag.
|
|
49
|
-
var VOID_TAGS = {
|
|
50
|
-
AREA: true, BASE: true, BR: true, COL: true, EMBED: true, HR: true,
|
|
51
|
-
IMG: true, INPUT: true, LINK: true, META: true, SOURCE: true,
|
|
52
|
-
TRACK: true, WBR: true
|
|
53
|
-
};
|
|
54
|
-
|
|
55
|
-
// Valid attribute-name token; also refuses event-handler attributes
|
|
56
|
-
// (on*) as defense-in-depth — the viewer sandbox+CSP already block inline
|
|
57
|
-
// handlers, but the serialized artifact should not carry them at all.
|
|
58
|
-
var ATTR_NAME_RE = /^[a-zA-Z_:][-a-zA-Z0-9_:.]*$/;
|
|
59
|
-
function isSerializableAttr(name) {
|
|
60
|
-
return ATTR_NAME_RE.test(name) && !/^on/i.test(name);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function escapeHtml(s) {
|
|
64
|
-
return String(s).replace(/&/g, '&').replace(/</g, '<')
|
|
65
|
-
.replace(/>/g, '>').replace(/"/g, '"');
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function isBait(node, opts) {
|
|
69
|
-
if (opts && opts.keepBait) return false;
|
|
70
|
-
if (!node || node.nodeType !== ELEMENT_NODE) return false;
|
|
71
|
-
if (node.id === 'ch-decoy') return true;
|
|
72
|
-
var attrs = node.attributes || [];
|
|
73
|
-
for (var i = 0; i < attrs.length; i++) {
|
|
74
|
-
if (attrs[i].name === 'data-ch-role') return true;
|
|
75
|
-
if (attrs[i].name === 'data-ch-decoy') return true;
|
|
76
|
-
}
|
|
77
|
-
return false;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function isPassword(node) {
|
|
81
|
-
return node.tagName === 'INPUT' &&
|
|
82
|
-
(node.type === 'password' || hasAttr(node, 'type', 'password'));
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// True when the node should be redacted in the DOM capture: a password field
|
|
86
|
-
// (always) or anything matching the configured redactSelector. Without this the
|
|
87
|
-
// DOM path (initial snapshot, value attributes, characterData/childList patches)
|
|
88
|
-
// leaked the content of fields the researcher explicitly marked for redaction,
|
|
89
|
-
// silently defeating redactSelector for everything except the RAF-coalesced
|
|
90
|
-
// input-value events.
|
|
91
|
-
function isRedacted(node, opts) {
|
|
92
|
-
if (!node) return false;
|
|
93
|
-
if (isPassword(node)) return true;
|
|
94
|
-
var sel = opts && opts.redactSelector;
|
|
95
|
-
if (!sel || typeof node.matches !== 'function') return false;
|
|
96
|
-
try { return node.matches(sel); } catch (e) { return false; }
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// True when the node is inside (or is) a redacted element. Walks ancestors so
|
|
100
|
-
// that mutation targets which are TEXT NODES (characterData records, added text
|
|
101
|
-
// nodes) — which have no matches() of their own — are correctly redacted when
|
|
102
|
-
// their containing element is. Without the walk, characterData on a redacted
|
|
103
|
-
// contenteditable would leak the typed text through mutation patches.
|
|
104
|
-
function isInRedactedSubtree(node, opts) {
|
|
105
|
-
var cur = node;
|
|
106
|
-
while (cur) {
|
|
107
|
-
if (cur.nodeType === ELEMENT_NODE && isRedacted(cur, opts)) return true;
|
|
108
|
-
cur = cur.parentNode;
|
|
109
|
-
}
|
|
110
|
-
return false;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
function hasAttr(node, name, value) {
|
|
114
|
-
var attrs = node.attributes || [];
|
|
115
|
-
for (var i = 0; i < attrs.length; i++) {
|
|
116
|
-
if (attrs[i].name === name) return value == null || attrs[i].value === value;
|
|
117
|
-
}
|
|
118
|
-
return false;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Serializes a DOM subtree to an HTML string.
|
|
123
|
-
* - strips scripts/iframes and (by default) honeypot/decoy nodes
|
|
124
|
-
* - prefers the `src` PROPERTY for images (always absolute) over the
|
|
125
|
-
* attribute (may be relative and unresolvable inside the viewer's srcdoc)
|
|
126
|
-
* - redacts password input values (marker attribute, no content)
|
|
127
|
-
*/
|
|
128
|
-
export function serializeDom(root, opts) {
|
|
129
|
-
opts = opts || {};
|
|
52
|
+
export function captureStylesheets(doc) {
|
|
130
53
|
var out = [];
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
var w = null, h = null;
|
|
145
|
-
try {
|
|
146
|
-
if (typeof node.getBoundingClientRect === 'function') {
|
|
147
|
-
var r = node.getBoundingClientRect();
|
|
148
|
-
// A 0×0 rect is a VALID footprint (hidden iframe) — the placeholder
|
|
149
|
-
// must reproduce it, not invent a default-size box that shifts layout.
|
|
150
|
-
if (r) { w = Math.round(r.width || 0); h = Math.round(r.height || 0); }
|
|
151
|
-
}
|
|
152
|
-
} catch (e) { /* fall through to attributes */ }
|
|
153
|
-
if (w == null) {
|
|
154
|
-
w = parseInt(hasAttr(node, 'width') ? getAttrValue(node, 'width') : '', 10);
|
|
155
|
-
h = parseInt(hasAttr(node, 'height') ? getAttrValue(node, 'height') : '', 10);
|
|
156
|
-
if (!(w > 0)) w = 300; // spec default iframe size
|
|
157
|
-
if (!(h > 0)) h = 150;
|
|
158
|
-
}
|
|
159
|
-
return { w: w, h: h };
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
// Layout-affecting computed properties copied onto the placeholder so an
|
|
163
|
-
// absolutely-positioned / block / floated / margined iframe doesn't collapse
|
|
164
|
-
// into a plain in-flow inline box and shift everything around it.
|
|
165
|
-
var IFRAME_LAYOUT_PROPS = ['position', 'top', 'right', 'bottom', 'left',
|
|
166
|
-
'margin', 'float', 'vertical-align', 'z-index'];
|
|
167
|
-
|
|
168
|
-
function iframeFootprintStyle(node) {
|
|
169
|
-
var f = iframeFootprint(node);
|
|
170
|
-
var css = '';
|
|
171
|
-
var display = 'inline-block';
|
|
172
|
-
try {
|
|
173
|
-
var view = node.ownerDocument && node.ownerDocument.defaultView;
|
|
174
|
-
if (view && typeof view.getComputedStyle === 'function') {
|
|
175
|
-
var cs = view.getComputedStyle(node);
|
|
176
|
-
// display: keep the computed value except 'inline' — a span needs
|
|
177
|
-
// inline-block (or stronger) to honor explicit width/height.
|
|
178
|
-
if (cs.display && cs.display !== 'inline') display = cs.display;
|
|
179
|
-
for (var i = 0; i < IFRAME_LAYOUT_PROPS.length; i++) {
|
|
180
|
-
var prop = IFRAME_LAYOUT_PROPS[i];
|
|
181
|
-
var v = cs.getPropertyValue(prop);
|
|
182
|
-
if (v && v !== 'auto' && v !== 'none' && v !== 'normal' &&
|
|
183
|
-
v !== 'baseline' && v !== '0px') {
|
|
184
|
-
css += prop + ':' + v + ';';
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
} catch (e) { /* fall back to the plain inline-block footprint */ }
|
|
189
|
-
return 'display:' + display + ';' + css +
|
|
190
|
-
'width:' + f.w + 'px;height:' + f.h + 'px';
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
function serializeIframePlaceholder(node, opts, out) {
|
|
194
|
-
// <span>, not <div>: iframes are phrasing content, so the placeholder must
|
|
195
|
-
// be too — a div inside <p> would trigger parser reparenting (implicit </p>)
|
|
196
|
-
// and shift the reconstructed layout the placeholder exists to preserve.
|
|
197
|
-
out.push('<span data-ch-iframe=""');
|
|
198
|
-
if (opts.markers) {
|
|
199
|
-
out.push(' ' + opts.markers.attr + '="' + opts.markers.refFor(node) + '"');
|
|
200
|
-
}
|
|
201
|
-
out.push(' style="' + iframeFootprintStyle(node) + '"></span>');
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
function getAttrValue(node, name) {
|
|
205
|
-
var attrs = node.attributes || [];
|
|
206
|
-
for (var i = 0; i < attrs.length; i++) {
|
|
207
|
-
if (attrs[i].name === name) return attrs[i].value;
|
|
208
|
-
}
|
|
209
|
-
return '';
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
function serializeNode(node, opts, out) {
|
|
213
|
-
if (!node) return;
|
|
214
|
-
if (node.nodeType === TEXT_NODE) {
|
|
215
|
-
out.push(escapeHtml(node.textContent || ''));
|
|
216
|
-
return;
|
|
217
|
-
}
|
|
218
|
-
if (node.nodeType !== ELEMENT_NODE) return; // comments, PIs: irrelevant
|
|
219
|
-
var tag = node.tagName;
|
|
220
|
-
if (SKIP_TAGS[tag]) return;
|
|
221
|
-
if (isBait(node, opts)) return;
|
|
222
|
-
if (tag === 'IFRAME') { serializeIframePlaceholder(node, opts, out); return; }
|
|
223
|
-
|
|
224
|
-
var lower = tag.toLowerCase();
|
|
225
|
-
out.push('<' + lower);
|
|
226
|
-
if (opts.markers) {
|
|
227
|
-
out.push(' ' + opts.markers.attr + '="' + opts.markers.refFor(node) + '"');
|
|
228
|
-
}
|
|
229
|
-
// Shadow roots are not serializable from the outside: the host's box
|
|
230
|
-
// survives but its rendered content does not. Mark it so the viewer can
|
|
231
|
-
// refuse to "verify" interactions against a hollow reconstruction.
|
|
232
|
-
if (node.shadowRoot) out.push(' data-ch-shadow=""');
|
|
233
|
-
|
|
234
|
-
var redactValue = isRedacted(node, opts);
|
|
235
|
-
var srcOverride = (tag === 'IMG' && node.src) ? node.src : null;
|
|
236
|
-
var wroteSrc = false;
|
|
237
|
-
|
|
238
|
-
var attrs = node.attributes || [];
|
|
239
|
-
for (var i = 0; i < attrs.length; i++) {
|
|
240
|
-
var name = attrs[i].name;
|
|
241
|
-
var value = attrs[i].value;
|
|
242
|
-
if (!isSerializableAttr(name)) continue;
|
|
243
|
-
if (name === 'value' && redactValue) continue;
|
|
244
|
-
if (name === 'src' && srcOverride) { value = srcOverride; wroteSrc = true; }
|
|
245
|
-
out.push(' ' + name + '="' + escapeHtml(value) + '"');
|
|
246
|
-
}
|
|
247
|
-
if (srcOverride && !wroteSrc) out.push(' src="' + escapeHtml(srcOverride) + '"');
|
|
248
|
-
if (redactValue) out.push(' data-ch-redacted="true"');
|
|
249
|
-
out.push('>');
|
|
250
|
-
|
|
251
|
-
if (!VOID_TAGS[tag]) {
|
|
252
|
-
// A redacted element's text children (e.g. contenteditable content) are
|
|
253
|
-
// withheld too — otherwise typed text under a redactSelector match would
|
|
254
|
-
// leak straight into the snapshot despite the marker on the element.
|
|
255
|
-
if (!redactValue) {
|
|
256
|
-
var kids = node.childNodes || [];
|
|
257
|
-
for (var k = 0; k < kids.length; k++) serializeNode(kids[k], opts, out);
|
|
54
|
+
var sheets = (doc && doc.styleSheets) || [];
|
|
55
|
+
for (var i = 0; i < sheets.length; i++) {
|
|
56
|
+
var sheet = sheets[i];
|
|
57
|
+
var href = sheet.href || null;
|
|
58
|
+
var media = sheet.media && sheet.media.mediaText ? sheet.media.mediaText : null;
|
|
59
|
+
var css = null;
|
|
60
|
+
try {
|
|
61
|
+
var rules = sheet.cssRules;
|
|
62
|
+
var text = [];
|
|
63
|
+
for (var r = 0; r < rules.length; r++) text.push(rules[r].cssText);
|
|
64
|
+
css = text.join('\n');
|
|
65
|
+
} catch (e) {
|
|
66
|
+
css = null; // cross-origin: unreadable from here
|
|
258
67
|
}
|
|
259
|
-
out.push(
|
|
68
|
+
out.push(href
|
|
69
|
+
? { id: i + 1, kind: 'link', href: href, css: css, media: media }
|
|
70
|
+
: { id: i + 1, kind: 'inline', css: css == null ? '' : css, media: media });
|
|
260
71
|
}
|
|
72
|
+
return out;
|
|
261
73
|
}
|
|
262
74
|
|
|
263
75
|
/**
|
|
264
|
-
*
|
|
265
|
-
*
|
|
266
|
-
*
|
|
76
|
+
* Inline the text of href-only link sheets by fetching them (2026-09-03).
|
|
77
|
+
*
|
|
78
|
+
* `captureStylesheets` cannot read a cross-origin sheet's rules — the browser
|
|
79
|
+
* refuses `cssRules` under the same-origin policy — so such a sheet ships
|
|
80
|
+
* href-only and the viewer plays unstyled until an analyst opts into fetching
|
|
81
|
+
* it there. A fresh CORS `fetch()` of the same URL is a different operation
|
|
82
|
+
* the server may permit (CDNs such as jsdelivr do), so the text can be inlined
|
|
83
|
+
* HERE, where the page is, and the recording becomes self-contained; the
|
|
84
|
+
* viewer's no-network frame then needs nothing (spec §12 stays intact).
|
|
85
|
+
*
|
|
86
|
+
* Mutates the entries IN PLACE: the recorder holds these same objects
|
|
87
|
+
* (`setStylesheets`), so a fill that lands before finalize is what ships.
|
|
88
|
+
* Off the critical path — the caller does not await the returned promise; a
|
|
89
|
+
* finalize that beats the fetch ships href-only, which is today's behaviour.
|
|
90
|
+
* Any failure (no fetch, CORS refusal, non-2xx, network) leaves `css: null`.
|
|
91
|
+
* Only http(s) hrefs are tried: blob:/data: sheets cannot be re-fetched.
|
|
92
|
+
* No cookies are sent (`credentials: 'omit'`) — a stylesheet needs none, and
|
|
93
|
+
* a participant's session must never ride on a recorder's request.
|
|
267
94
|
*/
|
|
268
|
-
export function
|
|
269
|
-
|
|
270
|
-
var
|
|
271
|
-
|
|
272
|
-
var
|
|
273
|
-
if (!
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
path.unshift(idx);
|
|
277
|
-
cur = parent;
|
|
95
|
+
export function fillCrossOriginSheets(sheets, fetchImpl) {
|
|
96
|
+
if (typeof fetchImpl !== 'function' || !Array.isArray(sheets)) return Promise.resolve();
|
|
97
|
+
var pending = [];
|
|
98
|
+
for (var i = 0; i < sheets.length; i++) {
|
|
99
|
+
var sheet = sheets[i];
|
|
100
|
+
if (!sheet || sheet.kind !== 'link' || sheet.css != null) continue;
|
|
101
|
+
if (typeof sheet.href !== 'string' || !/^https?:/i.test(sheet.href)) continue;
|
|
102
|
+
pending.push(fillOne(sheet, fetchImpl));
|
|
278
103
|
}
|
|
279
|
-
return
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
function indexOfChild(parent, child) {
|
|
283
|
-
var kids = parent.childNodes || [];
|
|
284
|
-
for (var i = 0; i < kids.length; i++) if (kids[i] === child) return i;
|
|
285
|
-
return -1;
|
|
104
|
+
return Promise.all(pending).then(function () { /* settled */ });
|
|
286
105
|
}
|
|
287
106
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
}
|
|
300
|
-
// With a marker registry, characterData mutations are recorded as the
|
|
301
|
-
// PARENT's resulting-children snapshot: text nodes are not parser-stable
|
|
302
|
-
// references (adjacent text nodes merge on reparse; empty ones vanish), so
|
|
303
|
-
// a text-node address can resolve to the wrong node in the reconstruction.
|
|
304
|
-
// The parent element IS stable via its marker, and a children snapshot is
|
|
305
|
-
// idempotent like every other childList patch.
|
|
306
|
-
if (record.type === 'characterData' && opts.markers) {
|
|
307
|
-
var parent = target.parentNode;
|
|
308
|
-
if (!parent || nodePath(parent, root) === null) return null;
|
|
309
|
-
record = { type: 'childList', target: parent };
|
|
310
|
-
target = parent;
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
var path = nodePath(target, root);
|
|
314
|
-
if (path === null) return null;
|
|
315
|
-
|
|
316
|
-
// Marker reference: the parser-stable address the viewer resolves first;
|
|
317
|
-
// the child-index path stays as a diagnostic fallback. `tag` is the tag
|
|
318
|
-
// EXPECTED IN THE RECONSTRUCTION (that's what resolution validates) — for
|
|
319
|
-
// iframes that is the placeholder span, not the source tag.
|
|
320
|
-
var ref = opts.markers && target.nodeType === ELEMENT_NODE
|
|
321
|
-
? { n: opts.markers.refFor(target),
|
|
322
|
-
tag: target.tagName === 'IFRAME' ? 'span' : target.tagName.toLowerCase() }
|
|
323
|
-
: null;
|
|
324
|
-
|
|
325
|
-
if (record.type === 'childList') {
|
|
326
|
-
// State-snapshot patch: removed nodes are already detached (they have
|
|
327
|
-
// no address), so a faithful add/remove log is impossible. Serializing
|
|
328
|
-
// the target's RESULTING children makes application an idempotent
|
|
329
|
-
// innerHTML assignment — and backward seeks just rebuild from the
|
|
330
|
-
// initial DOM and re-apply patches in order.
|
|
331
|
-
//
|
|
332
|
-
// A childList mutation on (or inside) a redacted element emits empty
|
|
333
|
-
// children: serializeChildren walks the target's children directly, so it
|
|
334
|
-
// would otherwise leak text/inputs that serializeNode hides when it reaches
|
|
335
|
-
// the redacted element itself.
|
|
336
|
-
return Object.assign({
|
|
337
|
-
op: 'childList',
|
|
338
|
-
path: path,
|
|
339
|
-
html: isInRedactedSubtree(target, opts) ? '' : serializeChildren(target, opts)
|
|
340
|
-
}, ref || {});
|
|
341
|
-
}
|
|
342
|
-
if (record.type === 'attributes') {
|
|
343
|
-
// The reconstruction holds a placeholder SPAN where the iframe was, and
|
|
344
|
-
// width/height ATTRIBUTES don't size a span — so any attribute change on
|
|
345
|
-
// an iframe is translated into a fresh footprint style patch instead of
|
|
346
|
-
// forwarding an attribute the placeholder can't honor.
|
|
347
|
-
if (target.tagName === 'IFRAME') {
|
|
348
|
-
return Object.assign(
|
|
349
|
-
{ op: 'attributes', path: path, name: 'style', value: iframeFootprintStyle(target) },
|
|
350
|
-
ref || {});
|
|
351
|
-
}
|
|
352
|
-
var name = record.attributeName;
|
|
353
|
-
if (!isSerializableAttr(name)) return null;
|
|
354
|
-
var value = typeof target.getAttribute === 'function'
|
|
355
|
-
? target.getAttribute(name) : null;
|
|
356
|
-
// Never leak the value attribute of a redacted field (password or
|
|
357
|
-
// redactSelector match, including via a redacted ancestor).
|
|
358
|
-
if (name === 'value' && isInRedactedSubtree(target, opts)) value = null;
|
|
359
|
-
return Object.assign({ op: 'attributes', path: path, name: name, value: value }, ref || {});
|
|
360
|
-
}
|
|
361
|
-
if (record.type === 'characterData') {
|
|
362
|
-
// characterData targets are TEXT NODES, so redaction must consult the
|
|
363
|
-
// containing element(s): typing into a redacted contenteditable must not
|
|
364
|
-
// carry the typed text through the patch.
|
|
365
|
-
var text = isInRedactedSubtree(target, opts) ? '' : (target.textContent || '');
|
|
366
|
-
return { op: 'characterData', path: path, value: text };
|
|
367
|
-
}
|
|
368
|
-
return null;
|
|
107
|
+
function fillOne(sheet, fetchImpl) {
|
|
108
|
+
return Promise.resolve()
|
|
109
|
+
.then(function () { return fetchImpl(sheet.href, { mode: 'cors', credentials: 'omit' }); })
|
|
110
|
+
.then(function (res) {
|
|
111
|
+
if (!res || !res.ok) return null;
|
|
112
|
+
return res.text();
|
|
113
|
+
})
|
|
114
|
+
.then(function (text) {
|
|
115
|
+
if (typeof text === 'string' && sheet.css == null) sheet.css = text;
|
|
116
|
+
})
|
|
117
|
+
.catch(function () { /* stays href-only */ });
|
|
369
118
|
}
|
|
370
119
|
|
|
371
|
-
//
|
|
372
|
-
// the
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
120
|
+
// How many characters a keyframe payload costs, as the wire would carry it.
|
|
121
|
+
// Exact rather than estimated: the trial's size budget is a bound on the
|
|
122
|
+
// payload, and the payload is a JSON tree, so its JSON length is the thing
|
|
123
|
+
// being bounded. Measured ONCE per keyframe and used twice — to decide whether
|
|
124
|
+
// the snapshot itself is over budget, and to seed the recorder's per-trial
|
|
125
|
+
// character count, which cannot see anything stored on the trial.
|
|
126
|
+
function payloadChars(value) {
|
|
127
|
+
if (value == null) return 0;
|
|
128
|
+
try { return JSON.stringify(value).length; } catch (e) { return 0; }
|
|
378
129
|
}
|
|
379
130
|
|
|
380
131
|
/**
|
|
381
|
-
*
|
|
382
|
-
*
|
|
132
|
+
* Keyframe or continuation? (spec §3's cadence guidance.)
|
|
133
|
+
*
|
|
134
|
+
* Pure, so the boundaries a scripted DOM cannot hit on purpose are testable
|
|
135
|
+
* directly. `cadence` is the bookkeeping the attachment keeps per span:
|
|
136
|
+
*
|
|
137
|
+
* `hasKeyframe` has a keyframe of THIS span reached the file? False at
|
|
138
|
+
* the start of a recording and after a keyframe was
|
|
139
|
+
* dropped or threw — in both cases there is nothing for
|
|
140
|
+
* a continuation to continue from, and §3 requires the
|
|
141
|
+
* first DOM-bearing segment to be a keyframe.
|
|
142
|
+
* `segments` segments opened in this span, the keyframe included.
|
|
143
|
+
* `patchChars` the plan's `bytesSinceKeyframe`. See below.
|
|
144
|
+
* `lastSnapshotChars` what the span's keyframe cost the file: the exact JSON
|
|
145
|
+
* length of its tree plus its `initial_state` seed (the
|
|
146
|
+
* same number `noteSnapshotChars` gets). 0 when no
|
|
147
|
+
* keyframe has been measured.
|
|
148
|
+
*
|
|
149
|
+
* WHAT `patchChars` COUNTS, exactly: the sum over every observer batch flushed
|
|
150
|
+
* since the keyframe of `JSON.stringify(patches).length`, where `patches` is
|
|
151
|
+
* the array of `dom.*` events that batch mapped to.
|
|
152
|
+
*
|
|
153
|
+
* - CHARS, not bytes. The plan and design call it `bytesSinceKeyframe`; it is
|
|
154
|
+
* measured in the same unit as `maxCharsPerTrial` (UTF-16 code units, which
|
|
155
|
+
* UTF-8 byte size can exceed for non-ASCII), so it carries that config's
|
|
156
|
+
* name for that config's reason. Both sides of the comparison use it, so
|
|
157
|
+
* the ratio the trigger actually tests is unit-free.
|
|
158
|
+
* - `dom.*` ONLY, on FILE-SIZE grounds — not because trace events carry no
|
|
159
|
+
* state. Some of them plainly do: `input.value`, `scroll.*` and `media.*`
|
|
160
|
+
* are exactly the state `initial_state` re-states (spec §3 `form`,
|
|
161
|
+
* `scroll`, `element_scroll`, `media`), and their keyframe-side cost is
|
|
162
|
+
* priced into `lastSnapshotChars` through the seed. What makes them
|
|
163
|
+
* uncountable here is that a keyframe does not REMOVE them from the file —
|
|
164
|
+
* playback needs every one of them — so §3's "at parity the keyframe is
|
|
165
|
+
* free" argument, which is the whole basis of this comparison, does not
|
|
166
|
+
* apply. Counting them would keyframe on volume the keyframe cannot
|
|
167
|
+
* reclaim. The consequence is real and belongs on the record: on a
|
|
168
|
+
* form-heavy or scroll-heavy but DOM-STATIC segment this trigger can never
|
|
169
|
+
* fire, and `keyframeEvery` becomes the only bound on seek distance and on
|
|
170
|
+
* a corrupt event's blast radius — §3's failure mode from the other side.
|
|
171
|
+
* - AT EMISSION, before the recorder's per-trial caps can refuse a record, and
|
|
172
|
+
* off the in-memory event (absolute `t`) rather than the wire event
|
|
173
|
+
* (session-relative, usually shorter). Both make the count an over-estimate:
|
|
174
|
+
* MEASURED at 1.08x, 5.3 chars per patch over 12 `dom.attr` patches (864
|
|
175
|
+
* in-memory against 800 on the wire), all of it the timestamp. It biases
|
|
176
|
+
* toward keyframing sooner — the direction that shortens seek distance and
|
|
177
|
+
* shrinks the blast radius of a corrupt event.
|
|
178
|
+
*
|
|
179
|
+
* @param {{hasKeyframe: boolean, segments: number, patchChars: number,
|
|
180
|
+
* lastSnapshotChars: number}} cadence
|
|
181
|
+
* @param {number|null} keyframeEvery segment fallback (recorder config)
|
|
383
182
|
*/
|
|
384
|
-
export function
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
183
|
+
export function shouldKeyframe(cadence, keyframeEvery) {
|
|
184
|
+
if (!cadence.hasKeyframe) return true;
|
|
185
|
+
// The fallback: at most `keyframeEvery` segments per span, so segment N of a
|
|
186
|
+
// span (counting the keyframe as 1) is where the next keyframe lands.
|
|
187
|
+
//
|
|
188
|
+
// The `typeof` is deliberately strict, and diverges from the sibling caps —
|
|
189
|
+
// `maxViewportChanges` would coerce a string `"10"` and work. Here a wrong
|
|
190
|
+
// TYPE must not silently become a different CADENCE: `"10"` from a JSON
|
|
191
|
+
// config or a URL parameter would compare as a string and disable the
|
|
192
|
+
// fallback, changing the shape of the researcher's file with no other
|
|
193
|
+
// symptom. It is refused rather than coerced, and `attachDomCapture` warns
|
|
194
|
+
// once about it at attach, which is where a misconfiguration is still
|
|
195
|
+
// fixable.
|
|
196
|
+
if (typeof keyframeEvery === 'number' && keyframeEvery >= 1 &&
|
|
197
|
+
cadence.segments >= keyframeEvery) return true;
|
|
198
|
+
// The size trigger: spec §3's self-tuning rule — take a keyframe once the
|
|
199
|
+
// accumulated mutation volume rivals a fresh snapshot's size, because at that
|
|
200
|
+
// point the keyframe is free in file size. `>=` so parity keyframes.
|
|
201
|
+
// Guarded on a measured keyframe: 0 would make the comparison vacuously true.
|
|
202
|
+
return cadence.lastSnapshotChars > 0 &&
|
|
203
|
+
cadence.patchChars >= cadence.lastSnapshotChars;
|
|
401
204
|
}
|
|
402
205
|
|
|
403
206
|
/**
|
|
404
207
|
* Attaches tier-2 capture to a recorder:
|
|
405
|
-
* -
|
|
406
|
-
*
|
|
407
|
-
*
|
|
208
|
+
* - a KEYFRAME at the start of a segment the cadence asks for one at: the
|
|
209
|
+
* DomNode tree plus its `initial_state` seed, both taken on the shared
|
|
210
|
+
* capture span. Every other segment is a CONTINUATION of it (see
|
|
211
|
+
* `shouldKeyframe`)
|
|
212
|
+
* - MutationObserver batches → `dom.*` patch events
|
|
213
|
+
* - initial stylesheet capture at attach
|
|
408
214
|
* - guard-friction cooperation: onViolation('start') fires synchronously
|
|
409
215
|
* BEFORE obfuscateContent() (pinned by contract test), so the clean-DOM
|
|
410
216
|
* snapshot taken inside the callback is genuinely pre-scramble.
|
|
217
|
+
*
|
|
218
|
+
* @param {object} rec the recorder
|
|
219
|
+
* @param {object} env {doc, win, now, MutationObserver} for testing, plus the
|
|
220
|
+
* two capture-wide objects the assembly (index.js) threads to BOTH capture
|
|
221
|
+
* modules:
|
|
222
|
+
* `span` the capture span (span.js) — node ids and the file's own
|
|
223
|
+
* record of what it contains. The SAME object capture-trace
|
|
224
|
+
* gets, because an event's `target` and a patch's `node` are
|
|
225
|
+
* the same numbering or neither means anything.
|
|
226
|
+
* `scrolled` a function returning capture-trace's set of elements that
|
|
227
|
+
* have scrolled, which the keyframe seed enumerates (spec §3
|
|
228
|
+
* `element_scroll`). Only the scroll listener knows this.
|
|
411
229
|
*/
|
|
412
230
|
export function attachDomCapture(rec, env) {
|
|
413
231
|
env = env || {};
|
|
@@ -416,112 +234,307 @@ export function attachDomCapture(rec, env) {
|
|
|
416
234
|
var now = env.now || function () { return performance.now(); };
|
|
417
235
|
var MutationObserverImpl = env.MutationObserver ||
|
|
418
236
|
(typeof MutationObserver !== 'undefined' ? MutationObserver : null);
|
|
419
|
-
//
|
|
420
|
-
//
|
|
421
|
-
//
|
|
422
|
-
var
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
237
|
+
// A lone attachment (a test wiring this module by itself) gets its own span
|
|
238
|
+
// rather than an ambient shared one: a module-level default is what lets a
|
|
239
|
+
// stray serialization write into a live recording's model (span.js).
|
|
240
|
+
var span = env.span || createSpan();
|
|
241
|
+
var scrolled = typeof env.scrolled === 'function'
|
|
242
|
+
? env.scrolled : function () { return null; };
|
|
243
|
+
// One options bag for every consumer — the snapshot walk, the mutation
|
|
244
|
+
// mapper, the seed and the guard snapshot. Exclusion and redaction cannot be
|
|
245
|
+
// answered one way in the keyframe and another way in a patch if there is
|
|
246
|
+
// only one answer to hand around.
|
|
247
|
+
var opts = {
|
|
248
|
+
keepBait: rec.config.keepBait,
|
|
249
|
+
redactSelector: rec.config.redactSelector,
|
|
250
|
+
taint: env.taint,
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
// THE OBSERVED ROOT, resolved ONCE.
|
|
254
|
+
//
|
|
255
|
+
// v1 re-resolved it at every snapshot and every batch, which let the keyframe
|
|
256
|
+
// and the patches addressing it describe two different elements if the page
|
|
257
|
+
// replaced the container — and the observer, attached to whichever element
|
|
258
|
+
// existed first, would have been watching neither. The recording's root is by
|
|
259
|
+
// definition the element the observer watches, so it is resolved here and
|
|
260
|
+
// held. (Task 4's residual: the seed's "did anything get walked" guard
|
|
261
|
+
// catches a never-walked span, not a walk of a DIFFERENT root. One root
|
|
262
|
+
// identity is what makes that unreachable rather than merely unlikely.)
|
|
263
|
+
// Did the held root actually come from the configured selector? Resolving
|
|
264
|
+
// once turns a transient miss into a permanent one, so the answer has to
|
|
265
|
+
// reach the file rather than being assumed (see rootSelector below).
|
|
266
|
+
var rootFromSelector = false;
|
|
267
|
+
var root = resolveRoot();
|
|
430
268
|
|
|
431
269
|
function resolveRoot() {
|
|
432
270
|
var r = rec.config.root;
|
|
433
|
-
if (typeof r === 'string') {
|
|
434
|
-
|
|
271
|
+
if (typeof r === 'string' && r) {
|
|
272
|
+
var found = null;
|
|
273
|
+
try { found = doc.querySelector(r); } catch (e) { found = null; }
|
|
274
|
+
if (found) { rootFromSelector = true; return found; }
|
|
275
|
+
// A selector that matches nothing (or does not parse) is a study
|
|
276
|
+
// misconfiguration, and the fallback is silent everywhere else: the
|
|
277
|
+
// recording looks complete and simply describes a different subtree.
|
|
278
|
+
rec.captureFailure('observed_root', new Error(
|
|
279
|
+
'root selector "' + r + '" matched nothing at startSession(); ' +
|
|
280
|
+
'observing document.body instead'));
|
|
281
|
+
return doc.body;
|
|
435
282
|
}
|
|
436
283
|
return r || doc.body;
|
|
437
284
|
}
|
|
438
285
|
|
|
286
|
+
// Spec §2 types `observed_root` as a SELECTOR, and it must name what was
|
|
287
|
+
// OBSERVED, not what was asked for. Returning the configured selector after
|
|
288
|
+
// the fallback would put a body-rooted tree in a file labelled `#stage`, and
|
|
289
|
+
// a conforming player honouring the field would mount it there.
|
|
290
|
+
//
|
|
291
|
+
// So: the configured selector only when the held root came from it; else the
|
|
292
|
+
// root's own id; else null, which is §2's spelling for "the document body"
|
|
293
|
+
// and exactly what the fallback observed. `null` alone would read as "the
|
|
294
|
+
// researcher configured nothing", which is why the capture failure above is
|
|
295
|
+
// the other half of this — together they are diagnosable.
|
|
296
|
+
function rootSelector() {
|
|
297
|
+
if (rootFromSelector) return rec.config.root;
|
|
298
|
+
if (root && root.id) return '#' + root.id;
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
rec.setObservedRoot(rootSelector());
|
|
303
|
+
|
|
439
304
|
try {
|
|
440
|
-
|
|
305
|
+
var sheets = captureStylesheets(doc);
|
|
306
|
+
rec.setStylesheets(sheets);
|
|
307
|
+
// Cross-origin sheets are href-only here; try to inline their text so the
|
|
308
|
+
// recording is self-contained (see fillCrossOriginSheets). Not awaited.
|
|
309
|
+
var view = doc && doc.defaultView;
|
|
310
|
+
if (view && typeof view.fetch === 'function') {
|
|
311
|
+
fillCrossOriginSheets(sheets, view.fetch.bind(view));
|
|
312
|
+
}
|
|
441
313
|
} catch (e) { rec.captureFailure('stylesheets', e); }
|
|
442
314
|
|
|
443
|
-
//
|
|
444
|
-
// implicit) — this is the frame the viewer reconstructs and patches.
|
|
315
|
+
// ── Keyframe cadence (spec §3) ──
|
|
445
316
|
//
|
|
446
|
-
//
|
|
447
|
-
//
|
|
448
|
-
//
|
|
449
|
-
//
|
|
450
|
-
//
|
|
451
|
-
|
|
317
|
+
// A segment either opens a new keyframe span — a full snapshot, ids restarting
|
|
318
|
+
// at 1 — or CONTINUES the current one, carrying `initial_dom: null` and
|
|
319
|
+
// nothing else. `shouldKeyframe` above owns the decision and documents the
|
|
320
|
+
// trigger; this is the bookkeeping it reads, one object per attachment, which
|
|
321
|
+
// is one per recording.
|
|
322
|
+
var cadence = {
|
|
323
|
+
hasKeyframe: false, segments: 0, patchChars: 0, lastSnapshotChars: 0,
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
// Said once, at attach, rather than per segment: the fallback is refused for
|
|
327
|
+
// a non-number (see `shouldKeyframe`), and silently recording a differently
|
|
328
|
+
// shaped file is exactly what the strictness exists to prevent.
|
|
329
|
+
var kfEvery = rec.config.keyframeEvery;
|
|
330
|
+
if (kfEvery != null && typeof kfEvery !== 'number') {
|
|
331
|
+
console.warn('[cyborg-hunter-replay] keyframeEvery must be a number; got ' +
|
|
332
|
+
typeof kfEvery + ' (' + JSON.stringify(kfEvery) + '). The segment fallback ' +
|
|
333
|
+
'is DISABLED for this recording — keyframes will be taken on accumulated ' +
|
|
334
|
+
'patch size alone.');
|
|
335
|
+
}
|
|
336
|
+
|
|
452
337
|
rec.onTrialStart(function (trial) {
|
|
338
|
+
// An IMPLICIT segment is opened RE-ENTRANTLY from inside `pushRecord`
|
|
339
|
+
// (recorder.js's storeEvent), and the capture path doing that push has
|
|
340
|
+
// ALREADY resolved its node ids against the current span — mapMutations for
|
|
341
|
+
// a whole batch, targetFacts for one event — into records that are about to
|
|
342
|
+
// be stored. A keyframe here calls `span.reset()`, so those ids name a span
|
|
343
|
+
// the file no longer describes, and because a fresh pre-order walk reuses
|
|
344
|
+
// the same small integers they do not dangle harmlessly: the player
|
|
345
|
+
// resolves them against the new tree. Reproduced as a `dom.remove` that
|
|
346
|
+
// deletes a node the participant never lost, and a `mouse.click` whose
|
|
347
|
+
// `target` names a different element than its own `anchor.tag`. Strict
|
|
348
|
+
// validation passes it — `node` fields are only number-checked.
|
|
349
|
+
//
|
|
350
|
+
// So an implicit segment always CONTINUES. The single exception is a span
|
|
351
|
+
// with no keyframe at all, where §3 still forces one below — and in that
|
|
352
|
+
// state the span holds nothing, so no id in flight can be stale: a mapped
|
|
353
|
+
// batch produces no events, and a trace event's target already resolves to
|
|
354
|
+
// null. (One consequence of that exception is recorded at recorder.js's
|
|
355
|
+
// implicit-trial branch: the event that opens such a segment loses its
|
|
356
|
+
// `target` id.)
|
|
357
|
+
//
|
|
358
|
+
// This is the only re-entrant path into `span.reset()`: its three call
|
|
359
|
+
// sites are all in this hook, and `fireTrialStart` reaches it from
|
|
360
|
+
// `startTrial` (host-driven, never re-entrant) and from `storeEvent`
|
|
361
|
+
// (implicit only). Closing the implicit branch closes it for every capture
|
|
362
|
+
// module at once, which a check inside any one module could not do.
|
|
363
|
+
if (trial.implicit && cadence.hasKeyframe) {
|
|
364
|
+
cadence.segments++;
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
if (!shouldKeyframe(cadence, rec.config.keyframeEvery)) {
|
|
368
|
+
// A CONTINUATION. Nothing to do, and that is the point: the trial's
|
|
369
|
+
// `initialDom`/`initialState` are already null (recorder.js), the span is
|
|
370
|
+
// NOT reset, so every id the keyframe assigned stays valid and every node
|
|
371
|
+
// the player holds stays held. A seed here would be wrong rather than
|
|
372
|
+
// merely redundant — `initial_state` states what was true BEFORE a
|
|
373
|
+
// keyframe (spec §3), and a continuation has no keyframe to precede.
|
|
374
|
+
// The state it would re-state is already in the file as the patches and
|
|
375
|
+
// events of the segments since, which the player has replayed by the time
|
|
376
|
+
// it arrives here.
|
|
377
|
+
cadence.segments++;
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
// A KEYFRAME. The order is a contract, not a preference: reset, then walk,
|
|
381
|
+
// then seed. `span.reset()` restarts ids at 1 and empties the delivered
|
|
382
|
+
// picture in one call (span.js — never `span.registry.resetSpan()` alone,
|
|
383
|
+
// which silently loses nodes), the walk is the span's FIRST allocation so
|
|
384
|
+
// the tree numbers 1..N, and the seed reads the ids that walk assigned.
|
|
385
|
+
// Taken in any other order the seed names nodes the file does not contain.
|
|
453
386
|
try {
|
|
454
|
-
|
|
387
|
+
span.reset();
|
|
388
|
+
var tree = serializeTree(root, span, opts);
|
|
389
|
+
var seed = buildInitialState(root, span, {
|
|
390
|
+
win: win,
|
|
391
|
+
scrolled: scrolled(),
|
|
392
|
+
keepBait: opts.keepBait,
|
|
393
|
+
redactSelector: opts.redactSelector,
|
|
394
|
+
taint: opts.taint,
|
|
395
|
+
});
|
|
396
|
+
var chars = payloadChars(tree) + payloadChars(seed);
|
|
455
397
|
var cap = rec.config.maxCharsPerTrial;
|
|
456
|
-
if (cap != null &&
|
|
398
|
+
if (cap != null && chars > cap) {
|
|
399
|
+
// The keyframe is the single largest capture source and is stored on
|
|
400
|
+
// the trial rather than pushed as an event, so the recorder's cap
|
|
401
|
+
// cannot refuse it — it is refused HERE, or a giant DOM bypasses the
|
|
402
|
+
// payload limit entirely. The trial then replays as "no DOM captured".
|
|
457
403
|
rec.captureFailure('dom_snapshot', new Error(
|
|
458
|
-
'initial DOM
|
|
404
|
+
'initial DOM keyframe of ' + chars + ' chars exceeds maxCharsPerTrial ' +
|
|
459
405
|
cap + '; dropped to bound payload size'));
|
|
460
|
-
trial.initialDom =
|
|
406
|
+
trial.initialDom = null;
|
|
407
|
+
trial.initialState = null;
|
|
408
|
+
// The walk told the span the player holds this tree. It does not: the
|
|
409
|
+
// keyframe is not in the file. Resetting again empties that picture, so
|
|
410
|
+
// the patches that follow address nothing and are dropped rather than
|
|
411
|
+
// naming ids no player ever received.
|
|
412
|
+
span.reset();
|
|
413
|
+
keyframeFailed();
|
|
461
414
|
} else {
|
|
462
|
-
trial.initialDom =
|
|
415
|
+
trial.initialDom = tree;
|
|
416
|
+
trial.initialState = seed;
|
|
417
|
+
rec.noteSnapshotChars(trial, chars);
|
|
418
|
+
cadence.hasKeyframe = true;
|
|
419
|
+
cadence.segments = 1;
|
|
420
|
+
cadence.patchChars = 0;
|
|
421
|
+
cadence.lastSnapshotChars = chars;
|
|
463
422
|
}
|
|
464
423
|
} catch (e) {
|
|
465
424
|
rec.captureFailure('dom_snapshot', e);
|
|
466
|
-
trial.initialDom =
|
|
425
|
+
trial.initialDom = null;
|
|
426
|
+
trial.initialState = null;
|
|
427
|
+
span.reset();
|
|
428
|
+
keyframeFailed();
|
|
467
429
|
}
|
|
468
430
|
});
|
|
469
431
|
|
|
432
|
+
// A keyframe that was dropped or threw leaves the file with no tree for this
|
|
433
|
+
// span, so the next segment must take one rather than continue from nothing:
|
|
434
|
+
// a continuation carrying dom.* patches before any keyframe is a recording no
|
|
435
|
+
// player can reconstruct, and strict validation rejects it (spec §3).
|
|
436
|
+
//
|
|
437
|
+
// ONE of these four assignments has behaviour, and the reader is owed that
|
|
438
|
+
// plainly. `hasKeyframe = false` is what forces the retry; the other three are
|
|
439
|
+
// DEAD BY CONSTRUCTION and kept deliberately. Proof, not opinion: rule 1 of
|
|
440
|
+
// `shouldKeyframe` short-circuits before reading `segments`, `patchChars` or
|
|
441
|
+
// `lastSnapshotChars`, and the next keyframe that lands rewrites all four —
|
|
442
|
+
// verified by inversion, since reducing this function to its single live line
|
|
443
|
+
// passes the whole suite. They stay because they make the cadence state a true
|
|
444
|
+
// description of the file (this span has no keyframe, so it has no segments,
|
|
445
|
+
// no accumulated patches and no measured snapshot) and because they make the
|
|
446
|
+
// retry singly-caused: without them the retry would still happen, from the
|
|
447
|
+
// stale trigger values that asked for the failed keyframe, which is a
|
|
448
|
+
// coincidence rather than a rule.
|
|
449
|
+
//
|
|
450
|
+
// Fault-injection consequence, so nobody reads the four as jointly guarded:
|
|
451
|
+
// deleting this call altogether is NOT observable (the stale counters retry
|
|
452
|
+
// anyway); deleting the `hasKeyframe` line alone IS, and is pinned.
|
|
453
|
+
function keyframeFailed() {
|
|
454
|
+
cadence.hasKeyframe = false;
|
|
455
|
+
cadence.segments = 0;
|
|
456
|
+
cadence.patchChars = 0;
|
|
457
|
+
cadence.lastSnapshotChars = 0;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// ── Mutations (spec §5.1) ──
|
|
470
461
|
if (MutationObserverImpl) {
|
|
471
|
-
var
|
|
462
|
+
var handleBatch = function (records) {
|
|
472
463
|
try {
|
|
473
|
-
|
|
474
|
-
//
|
|
475
|
-
//
|
|
476
|
-
//
|
|
477
|
-
//
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
var seenChildListTargets = new Set();
|
|
491
|
-
var effectiveChildListTarget = function (record) {
|
|
492
|
-
if (record.type === 'childList') return record.target;
|
|
493
|
-
if (record.type === 'characterData' && opts.markers &&
|
|
494
|
-
record.target && record.target.parentNode) {
|
|
495
|
-
return record.target.parentNode;
|
|
496
|
-
}
|
|
497
|
-
return null;
|
|
498
|
-
};
|
|
499
|
-
for (var i = 0; i < records.length; i++) {
|
|
500
|
-
var key = effectiveChildListTarget(records[i]);
|
|
501
|
-
if (key) {
|
|
502
|
-
if (seenChildListTargets.has(key)) continue;
|
|
503
|
-
seenChildListTargets.add(key);
|
|
504
|
-
}
|
|
505
|
-
var patch = mutationToPatch(records[i], root, opts);
|
|
506
|
-
if (patch) rec.pushEvent('mutation', patch, now());
|
|
464
|
+
// The COMPLETE batch, in the observer's own order. mapMutations
|
|
465
|
+
// pre-scans it (which removals and insertions are still to come, what
|
|
466
|
+
// each attribute held before the batch) and that pre-scan is what makes
|
|
467
|
+
// the mapping batch-coherent — filtering or reordering records here
|
|
468
|
+
// would silently break it.
|
|
469
|
+
var events = mapMutations(records, {
|
|
470
|
+
root: root,
|
|
471
|
+
span: span,
|
|
472
|
+
// One callback = one `t` (spec §7): the batch is one task's worth of
|
|
473
|
+
// DOM change, and the observer reports it with no per-record times.
|
|
474
|
+
t: now(),
|
|
475
|
+
keepBait: opts.keepBait,
|
|
476
|
+
redactSelector: opts.redactSelector,
|
|
477
|
+
taint: opts.taint,
|
|
478
|
+
});
|
|
479
|
+
for (var i = 0; i < events.length; i++) {
|
|
480
|
+
rec.pushRecord(events[i], events[i].t);
|
|
507
481
|
}
|
|
482
|
+
// What the span has cost in deltas so far (see `shouldKeyframe`).
|
|
483
|
+
// Measured once per batch rather than once per patch: one stringify of
|
|
484
|
+
// the array is the cheaper call and the closer estimate of what the
|
|
485
|
+
// events cost as a group on the wire.
|
|
486
|
+
if (events.length) cadence.patchChars += payloadChars(events);
|
|
508
487
|
} catch (e) { rec.captureFailure('mutations', e); }
|
|
509
|
-
}
|
|
488
|
+
};
|
|
489
|
+
var observer = new MutationObserverImpl(handleBatch);
|
|
510
490
|
try {
|
|
511
|
-
observer.observe(
|
|
512
|
-
childList: true, attributes: true, characterData: true, subtree: true
|
|
513
|
-
});
|
|
491
|
+
observer.observe(root, MUTATION_OBSERVER_INIT);
|
|
514
492
|
// Registered through the listener registry with the observer marker so
|
|
515
493
|
// recorder.destroy() disconnects it (same convention as core monitor).
|
|
516
494
|
rec.addListener(
|
|
517
495
|
{ addEventListener: function () {}, removeEventListener: function () {} },
|
|
518
496
|
'_mutation_observer', observer, { _isObserver: true });
|
|
497
|
+
// The observer callback is a MICROTASK, so DOM changes made in the same
|
|
498
|
+
// task as stopSession() are still queued when the recording closes and
|
|
499
|
+
// disconnecting drops them silently. Draining the queue through the SAME
|
|
500
|
+
// handler is what makes the last thing a participant saw a patch rather
|
|
501
|
+
// than a gap (T3 final review, F-5).
|
|
502
|
+
rec.addPreCloseFlush(function () {
|
|
503
|
+
var pending = observer.takeRecords();
|
|
504
|
+
if (pending && pending.length) handleBatch(pending);
|
|
505
|
+
});
|
|
519
506
|
} catch (e) { rec.captureFailure('mutations', e); }
|
|
520
507
|
}
|
|
521
508
|
|
|
522
|
-
// Guard-friction cooperation
|
|
523
|
-
//
|
|
524
|
-
//
|
|
509
|
+
// ── Guard-friction cooperation ──
|
|
510
|
+
// Snapshot the clean DOM synchronously when a violation starts (before
|
|
511
|
+
// friction scrambles content), so the analyst can see exactly what the
|
|
512
|
+
// participant saw at the moment of violation.
|
|
513
|
+
//
|
|
514
|
+
// The snapshot is taken on a THROWAWAY SPAN. It is not part of the recording
|
|
515
|
+
// the player replays — it is a CH diagnostic in the vendor namespace — so
|
|
516
|
+
// numbering it into the live span would tell the live recording that the
|
|
517
|
+
// player holds nodes it was never sent, and suppress the next patch for each
|
|
518
|
+
// of them (span.js, D1). Its ids are internal to itself and start at 1; a
|
|
519
|
+
// reader compares it to the recording by structure, not by id.
|
|
520
|
+
//
|
|
521
|
+
// The violation itself is a session-level vendor entry, not an event: spec
|
|
522
|
+
// §5.8 admits no vendor event types in the stream, and there is no standard
|
|
523
|
+
// event for "the participant left fullscreen".
|
|
524
|
+
function guardSnapshot() {
|
|
525
|
+
var tree = serializeTree(root, createSpan(), opts);
|
|
526
|
+
var cap = rec.config.maxCharsPerTrial;
|
|
527
|
+
// Bounded by the same budget a keyframe is: this is a whole second copy of
|
|
528
|
+
// the DOM, and unlike a keyframe it rides a session-level array that no
|
|
529
|
+
// per-trial cap can see.
|
|
530
|
+
if (cap != null && payloadChars(tree) > cap) {
|
|
531
|
+
rec.captureFailure('guard_violation', new Error(
|
|
532
|
+
'pre-scramble DOM snapshot exceeds maxCharsPerTrial ' + cap + '; withheld'));
|
|
533
|
+
return null;
|
|
534
|
+
}
|
|
535
|
+
return tree;
|
|
536
|
+
}
|
|
537
|
+
|
|
525
538
|
if (win.GuardFriction && typeof win.GuardFriction.onViolation === 'function') {
|
|
526
539
|
try {
|
|
527
540
|
// Late-subscription hardening: if a violation is ALREADY in progress
|
|
@@ -532,14 +545,13 @@ export function attachDomCapture(rec, env) {
|
|
|
532
545
|
// misses an ongoing violation.
|
|
533
546
|
// Local invariant guard: assembly (index.js) only attaches captures
|
|
534
547
|
// after startSession(), but make that self-evident here — synthesize
|
|
535
|
-
// only when the recorder is actually recording
|
|
536
|
-
// can ever route this through a pre-session pushEvent.
|
|
548
|
+
// only when the recorder is actually recording.
|
|
537
549
|
var recState = rec.getState().state;
|
|
538
550
|
if ((recState === 'session' || recState === 'trial') &&
|
|
539
551
|
typeof win.GuardFriction.getCurrentState === 'function') {
|
|
540
552
|
var gs = win.GuardFriction.getCurrentState();
|
|
541
553
|
if (gs && gs.in_violation) {
|
|
542
|
-
rec.
|
|
554
|
+
rec.pushGuardViolation({
|
|
543
555
|
reason: gs.current_reason || 'unknown',
|
|
544
556
|
phase: 'start',
|
|
545
557
|
synthesized_at_subscribe: true,
|
|
@@ -548,20 +560,20 @@ export function attachDomCapture(rec, env) {
|
|
|
548
560
|
// whatever the DOM looks like right now, and its name must not
|
|
549
561
|
// overclaim (an analyst could otherwise read scrambled content
|
|
550
562
|
// as what the participant "really saw" pre-violation).
|
|
551
|
-
dom_at_subscribe:
|
|
563
|
+
dom_at_subscribe: guardSnapshot()
|
|
552
564
|
}, now());
|
|
553
565
|
}
|
|
554
566
|
}
|
|
555
567
|
win.GuardFriction.onViolation(function (violation) {
|
|
556
568
|
try {
|
|
557
569
|
if (violation && violation.phase === 'start') {
|
|
558
|
-
rec.
|
|
570
|
+
rec.pushGuardViolation({
|
|
559
571
|
reason: violation.reason,
|
|
560
572
|
phase: 'start',
|
|
561
|
-
pre_scramble_dom:
|
|
573
|
+
pre_scramble_dom: guardSnapshot()
|
|
562
574
|
}, now());
|
|
563
575
|
} else if (violation && violation.phase) {
|
|
564
|
-
rec.
|
|
576
|
+
rec.pushGuardViolation({
|
|
565
577
|
reason: violation.reason, phase: violation.phase,
|
|
566
578
|
duration_ms: violation.duration != null ? violation.duration : null
|
|
567
579
|
}, now());
|