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
|
@@ -0,0 +1,779 @@
|
|
|
1
|
+
// src/replay/dom-instantiate.js
|
|
2
|
+
// SessionRecording v2 keyframe (a DomNode tree, spec §4) → real DOM inside the
|
|
3
|
+
// reconstruction frame, plus the `Map<number, Node>` every `dom.*` patch
|
|
4
|
+
// (§5.1), every event `target`, and every `anchor.node` (§6) resolves through —
|
|
5
|
+
// and, since T5 Task 3, the application of those four patches through that map.
|
|
6
|
+
//
|
|
7
|
+
// Instantiation and patching live together because they are ONE READING OF §4.
|
|
8
|
+
// `dom.add` instantiates a subtree into the same id map, reverse index and
|
|
9
|
+
// canvas map a keyframe fills, and its namespace, its §12 filters and its
|
|
10
|
+
// tolerance for malformed nodes are the keyframe walk's, not a second set that
|
|
11
|
+
// happens to agree today. Splitting them would create the drift this migration
|
|
12
|
+
// exists to remove. (The concatenation contract below is a second, weaker
|
|
13
|
+
// reason: a separate module would have to import this one, and while an
|
|
14
|
+
// assembler that strips one export line could strip two, the reading argument
|
|
15
|
+
// stands on its own.)
|
|
16
|
+
//
|
|
17
|
+
// THIS IS WHERE THE HTML-STRING ERA ENDS, and that is a security result rather
|
|
18
|
+
// than a refactor. v1 built the reconstruction by interpolating a captured HTML
|
|
19
|
+
// string into the frame's `srcdoc` and applied child-list patches with
|
|
20
|
+
// `innerHTML`. Nothing here parses markup: every node is created by name and
|
|
21
|
+
// every attribute set by `setAttribute`, so `</script>` breakouts,
|
|
22
|
+
// attribute-escaping bugs and parser-normalisation drift (nested forms, table
|
|
23
|
+
// fostering, duplicate ids — the reason v1's reference resolution needed a
|
|
24
|
+
// tag-validated path fallback) stop being defended against and start being
|
|
25
|
+
// impossible. The claim the dogfood suite used to make — "injected markup
|
|
26
|
+
// arrives defanged" — becomes the stronger "injected markup is never parsed".
|
|
27
|
+
//
|
|
28
|
+
// Recording content is UNTRUSTED (spec §12): attacker-controlled DOM, CSS and
|
|
29
|
+
// URLs. CH's own capture already withholds `on*` handlers and iframe `srcdoc`,
|
|
30
|
+
// but a conforming file from a foreign producer is not bound by CH's capture
|
|
31
|
+
// rules, so the filters below are a PLAYER duty and are applied to every file.
|
|
32
|
+
//
|
|
33
|
+
// PURE and BROWSER-SAFE: no Node APIs, no imports, and — see the export block
|
|
34
|
+
// at the bottom — exactly one line of ESM syntax. The report's viewer client
|
|
35
|
+
// (`src/cli/renderers/replay-viewer.client.js`) is a plain IIFE inlined
|
|
36
|
+
// verbatim into the report and cannot `import`, so the build CONCATENATES this
|
|
37
|
+
// module ahead of it with that one line stripped (T5 Task 2's recorded
|
|
38
|
+
// decision; `tests/replay/dom-instantiate.test.js` machine-checks that the
|
|
39
|
+
// source stays concatenable and strict-safe). The alternative — the client
|
|
40
|
+
// carrying its own copy — would put two readings of §4 in the repo, which is
|
|
41
|
+
// the failure this whole migration exists to remove. Style follows the client
|
|
42
|
+
// and `snapshot.js` (`var`, `function`) for the same reason.
|
|
43
|
+
|
|
44
|
+
// Spec §4's exclusion placeholder is `{id, kind, tag}` with the `attrs` and
|
|
45
|
+
// `children` keys ABSENT, so `attrs || {}` and `children || []` below ARE the
|
|
46
|
+
// placeholder handling: a bare, empty element of that tag, holding its id and
|
|
47
|
+
// its sibling position. It is written as a fallback rather than a branch
|
|
48
|
+
// because that is exactly what it is, and because the fork crashed on the
|
|
49
|
+
// shape when it read the keys as required.
|
|
50
|
+
|
|
51
|
+
// Executable content never enters the reconstruction as itself. A foreign
|
|
52
|
+
// producer may serialise `script`/`noscript` (CH's own capture skips them
|
|
53
|
+
// outright); they are instantiated as inert `<template>`s, which the UA never
|
|
54
|
+
// renders and never runs, so ids and sibling positions stay coherent instead
|
|
55
|
+
// of the tree developing a hole a later patch would miss. Their children are
|
|
56
|
+
// instantiated too — the source text keeps its id, so a later `dom.text` still
|
|
57
|
+
// lands. WHERE those children sit is realm-dependent and deliberately not
|
|
58
|
+
// relied on: happy-dom routes `appendChild` on a template into `content`,
|
|
59
|
+
// browsers keep it in `childNodes`, and neither is rendered or executed.
|
|
60
|
+
var INERT_TAGS = { script: true, noscript: true };
|
|
61
|
+
|
|
62
|
+
// Attributes whose value is a URL the browser will follow. Everything else is
|
|
63
|
+
// page content: a `title` that reads like a `javascript:` URL is text.
|
|
64
|
+
var URL_ATTRS = {
|
|
65
|
+
href: true, src: true, action: true, formaction: true, 'xlink:href': true,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// The XML `Name` production, ASCII subset — the same predicate capture uses in
|
|
69
|
+
// `isSerializableAttr` (`snapshot.js`), duplicated rather than imported because
|
|
70
|
+
// this file must survive concatenation as a plain script (header). It gates
|
|
71
|
+
// both attribute names and element tags.
|
|
72
|
+
//
|
|
73
|
+
// WHY IT IS HERE, corrected in fix round 1 after measurement. The first version
|
|
74
|
+
// of this comment said a real browser's `setAttribute` throws
|
|
75
|
+
// `InvalidCharacterError` on a name outside this production. It does not:
|
|
76
|
+
// chromium, firefox and webkit all accept `setAttribute('<', 'v')`, and all
|
|
77
|
+
// three mount `jspsych-full`'s malformed free-sort node unfiltered. happy-dom
|
|
78
|
+
// 20.9.0 is the realm that throws on it. Three reasons survive, and they are
|
|
79
|
+
// the reasons the filter stays:
|
|
80
|
+
// 1. REALM DETERMINISM. The reconstruction runs in happy-dom under `npm
|
|
81
|
+
// test`, in three browser engines under the Task-8 battery, and in the
|
|
82
|
+
// analyst's browser in production. A viewer whose tree depends on which
|
|
83
|
+
// one is executing cannot be a conformance player. Filtering by a fixed
|
|
84
|
+
// predicate gives one answer everywhere; deferring to the host gives
|
|
85
|
+
// three.
|
|
86
|
+
// 2. PARITY WITH CAPTURE. CH's own recorder refuses the same names, so a CH
|
|
87
|
+
// file can never carry one. Sharing the predicate is what makes the
|
|
88
|
+
// corpus pin (`['<']`, exactly one attribute) mean something.
|
|
89
|
+
// 3. SOME NAMES REALLY DO ABORT. Every engine measured, happy-dom included,
|
|
90
|
+
// throws on a whitespace-bearing name such as `a b`. A player with no
|
|
91
|
+
// name filter loses the whole mount on a hostile file. Same for tags:
|
|
92
|
+
// `createElement('')`, `('<div')`, `('a b')` and `('1abc')` all throw in
|
|
93
|
+
// browsers, and happy-dom throws on `''` alone — the divergence reason 1
|
|
94
|
+
// exists for.
|
|
95
|
+
// The cost is real and is recorded rather than hidden: the engines accept
|
|
96
|
+
// `@click`, `[ngModel]`, `(click)`, `*ngIf`, `1x` and `café`, and this drops
|
|
97
|
+
// them all. CH files never carry them; foreign producers might.
|
|
98
|
+
var NAME_TOKEN_RE = /^[a-zA-Z_:][-a-zA-Z0-9_:.]*$/;
|
|
99
|
+
|
|
100
|
+
// Tags only, on top of the Name production: the XML namespace prefixes are
|
|
101
|
+
// RESERVED, and `createElementNS` enforces that where `createElement` does not.
|
|
102
|
+
// Measured tri-engine (playwright-core 1.62.1): `createElementNS(<any ns>,
|
|
103
|
+
// 'xml:foo' | 'xmlns:foo' | 'xmlns')` throws `NamespaceError` on chromium,
|
|
104
|
+
// firefox and webkit, while happy-dom 20.9.0 accepts all three — so without
|
|
105
|
+
// this line a foreign `svg` subtree carrying one aborts the whole mount in the
|
|
106
|
+
// analyst's browser and passes every node test. `a:b` and a bare `xml` are
|
|
107
|
+
// legal everywhere and stay legal. Case is not part of the rule but is not a
|
|
108
|
+
// gap either: the walk lowercases a tag before creating it, so `XML:Foo`
|
|
109
|
+
// reaches `createElementNS` as `xml:foo` and is caught here.
|
|
110
|
+
//
|
|
111
|
+
// It gates TAGS ONLY, deliberately. `setAttribute` does no namespace
|
|
112
|
+
// validation, so `xmlns:xlink="…"` — which real SVG markup carries — sets
|
|
113
|
+
// fine and is kept.
|
|
114
|
+
var RESERVED_TAG_PREFIX_RE = /^(?:xml|xmlns):|^xmlns$/;
|
|
115
|
+
|
|
116
|
+
var ELEMENT_NODE = 1;
|
|
117
|
+
var TEXT_NODE = 3;
|
|
118
|
+
var COMMENT_NODE = 8;
|
|
119
|
+
|
|
120
|
+
// Foreign content (spec-r3 gap, recorded). Spec §4's DomNode carries no
|
|
121
|
+
// namespace, so a player has to infer one, and `createElement` alone cannot
|
|
122
|
+
// reconstruct SVG at all: an `svg > circle` built that way lands in the XHTML
|
|
123
|
+
// namespace and lays out at 0×0, where the same markup PARSED lands in the SVG
|
|
124
|
+
// namespace and lays out at its viewport. v1 built the reconstruction from an
|
|
125
|
+
// HTML string and got the parser's foreign-content rules for free; this module
|
|
126
|
+
// has to state them. The rule below is the parser's, reduced to what a keyframe
|
|
127
|
+
// can express: an `svg` or `math` element opens its namespace, descendants
|
|
128
|
+
// inherit it, and the SVG HTML-integration points hand their children back to
|
|
129
|
+
// XHTML.
|
|
130
|
+
//
|
|
131
|
+
// Known incompleteness, both of them format-level rather than player-level:
|
|
132
|
+
// capture lowercases every tag (`snapshot.js`), so camelCase SVG children
|
|
133
|
+
// (`linearGradient`, `clipPath`) and `foreignObject` itself arrive folded and
|
|
134
|
+
// cannot be re-cased from the file; and MathML's own integration points
|
|
135
|
+
// (`annotation-xml`, `mi`/`mo`/`mn`/`ms`/`mtext`) are not modelled. Both belong
|
|
136
|
+
// in the spec-r3 conversation, not in a workaround here.
|
|
137
|
+
var XHTML_NS = 'http://www.w3.org/1999/xhtml';
|
|
138
|
+
var SVG_NS = 'http://www.w3.org/2000/svg';
|
|
139
|
+
var MATHML_NS = 'http://www.w3.org/1998/Math/MathML';
|
|
140
|
+
var FOREIGN_ROOTS = { svg: SVG_NS, math: MATHML_NS };
|
|
141
|
+
var SVG_HTML_INTEGRATION = { foreignobject: true, desc: true, title: true };
|
|
142
|
+
|
|
143
|
+
// Stamped on elements the format cannot carry the content of (spec §12/§13),
|
|
144
|
+
// so the shell stylesheet can outline the region and the header chip can count
|
|
145
|
+
// it. Only iframes need it today: their content is a separate browsing context
|
|
146
|
+
// no recorder observes.
|
|
147
|
+
var PLACEHOLDER_ATTR = 'data-ch-placeholder';
|
|
148
|
+
|
|
149
|
+
// Capture's own flag for a shadow host (`snapshot.js`, spec §13): the light-DOM
|
|
150
|
+
// children are ordinary capturable nodes, the shadow ROOT's content is not.
|
|
151
|
+
var SHADOW_ATTR = 'data-ch-shadow';
|
|
152
|
+
|
|
153
|
+
// The canvas-presentation key (design §3.1, route 2). A script-less sandboxed
|
|
154
|
+
// frame holds canvas pixels it never paints, so `canvas.snapshot` composites in
|
|
155
|
+
// a parent-owned offscreen canvas and is PRESENTED as a background image. The
|
|
156
|
+
// viewer stamps this name on the canvas it composites into and matches it from
|
|
157
|
+
// a rule in the shell head; `CANVAS_RULE_ATTR` marks the rule element itself.
|
|
158
|
+
// Presenting through the head instead of the element's inline `style` is what
|
|
159
|
+
// makes the presentation survive a recorded `dom.attr` naming `style` — per
|
|
160
|
+
// CSSOM that write replaces the whole inline declaration block.
|
|
161
|
+
var CANVAS_ATTR = 'data-ch-canvas';
|
|
162
|
+
var CANVAS_RULE_ATTR = 'data-ch-canvas-rule';
|
|
163
|
+
|
|
164
|
+
// VIEWER-OWNED ATTRIBUTES. These names are not page content: they are what
|
|
165
|
+
// the reconstruction says about ITSELF, and the viewer's chips read them back
|
|
166
|
+
// out of the live DOM — the iframe/placeholder chip through
|
|
167
|
+
// `iframe, [data-ch-placeholder="iframe"]`, the shadow chip through
|
|
168
|
+
// `[data-ch-shadow]`. So a recording that could write them would be choosing
|
|
169
|
+
// what the viewer reports about the reconstruction, and the failure runs in
|
|
170
|
+
// BOTH directions:
|
|
171
|
+
//
|
|
172
|
+
// FORGE — stamping `data-ch-placeholder` on any element fakes a "content was
|
|
173
|
+
// here" outline and chip over a region that was fully captured. Both names
|
|
174
|
+
// pass the Name-production filter, so nothing else stops it.
|
|
175
|
+
// STRIP — `dom.attr` with `value: null` reaches `removeAttribute`, which
|
|
176
|
+
// validates nothing in any realm (that is why the removal path carries no
|
|
177
|
+
// name filter), so a recording can delete a flag the viewer itself applied.
|
|
178
|
+
// This is the worse half: hiding the shadow chip turns spec §13's "absence
|
|
179
|
+
// of evidence is not evidence of absence" into silence, and an analyst
|
|
180
|
+
// reads an empty region as an empty region.
|
|
181
|
+
//
|
|
182
|
+
// ONE predicate therefore gates BOTH verbs on BOTH paths — the keyframe walk
|
|
183
|
+
// and `dom.attr` — and the viewer re-stamps the flags itself from the DomNode
|
|
184
|
+
// data (below). A recording can still CLAIM a shadow host in a keyframe, which
|
|
185
|
+
// is exactly what capture's own flag is; what it cannot do is move, invent or
|
|
186
|
+
// erase the claim afterwards.
|
|
187
|
+
//
|
|
188
|
+
// Scoped to the names the viewer stamps, NOT to the whole `data-ch-*`
|
|
189
|
+
// namespace: `data-ch-redact` is the researcher's own marker on their own page
|
|
190
|
+
// and is serialised into keyframes, and the honeypot's `data-ch-role` /
|
|
191
|
+
// `data-ch-decoy` are page-authored too. Dropping those would delete
|
|
192
|
+
// attributes the page really had and silently break any CSS keyed on them.
|
|
193
|
+
//
|
|
194
|
+
// The canvas pair joined the set in T5.5 for a reason with more teeth than the
|
|
195
|
+
// chips': `data-ch-canvas` is the SELECTOR the presented composite is keyed on,
|
|
196
|
+
// so a recording that could strip it would blank a canvas the analyst is
|
|
197
|
+
// looking at, and one that could forge it would paint another element with a
|
|
198
|
+
// canvas's pixels.
|
|
199
|
+
var VIEWER_OWNED_ATTRS = {};
|
|
200
|
+
VIEWER_OWNED_ATTRS[PLACEHOLDER_ATTR] = true;
|
|
201
|
+
VIEWER_OWNED_ATTRS[SHADOW_ATTR] = true;
|
|
202
|
+
VIEWER_OWNED_ATTRS[CANVAS_ATTR] = true;
|
|
203
|
+
VIEWER_OWNED_ATTRS[CANVAS_RULE_ATTR] = true;
|
|
204
|
+
|
|
205
|
+
function isViewerOwnedAttr(name) {
|
|
206
|
+
return VIEWER_OWNED_ATTRS[String(name).toLowerCase()] === true;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function isNameToken(name) {
|
|
210
|
+
return NAME_TOKEN_RE.test(name);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// The tag guard, applied at the instantiation boundary for keyframes and for
|
|
214
|
+
// every `dom.add` subtree alike.
|
|
215
|
+
function isUsableTag(tag) {
|
|
216
|
+
return isNameToken(tag) && !RESERVED_TAG_PREFIX_RE.test(tag);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// The namespace an element is created in, given its parent's.
|
|
220
|
+
function namespaceFor(tag, parentNs) {
|
|
221
|
+
if (FOREIGN_ROOTS[tag]) return FOREIGN_ROOTS[tag];
|
|
222
|
+
return parentNs || XHTML_NS;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// The namespace this element's CHILDREN are created in. Inside SVG, the HTML
|
|
226
|
+
// integration points hold ordinary HTML.
|
|
227
|
+
function childNamespaceOf(tag, ns) {
|
|
228
|
+
if (ns === SVG_NS && SVG_HTML_INTEGRATION[tag] === true) return XHTML_NS;
|
|
229
|
+
return ns;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Would this attribute value navigate to script?
|
|
234
|
+
*
|
|
235
|
+
* ASCII whitespace and C0 controls are stripped from ANYWHERE in the value
|
|
236
|
+
* before the scheme is tested, because the HTML and URL parsers remove tabs
|
|
237
|
+
* and newlines from URLs and trim leading controls — so `java\tscript:alert(1)`
|
|
238
|
+
* navigates, and a scheme test that trims only the ends is bypassed by one
|
|
239
|
+
* tab. Stripping cannot create a false positive: it can only remove characters
|
|
240
|
+
* from a scheme that was already there.
|
|
241
|
+
*/
|
|
242
|
+
function isSafeUrl(value) {
|
|
243
|
+
var probe = String(value).replace(/[\x00-\x20]/g, '').toLowerCase();
|
|
244
|
+
return probe.indexOf('javascript:') !== 0 && probe.indexOf('vbscript:') !== 0;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Set one attribute through the §12 filters. Refused attributes are DROPPED,
|
|
249
|
+
* not neutralised: a `javascript:` href rewritten to `#` would claim the page
|
|
250
|
+
* had a link it did not have.
|
|
251
|
+
*
|
|
252
|
+
* TWO refusal classes, counted differently (fix round 1, review I-4 — the first
|
|
253
|
+
* version of this counted neither, on an argument that only holds for one of
|
|
254
|
+
* them):
|
|
255
|
+
*
|
|
256
|
+
* - **security** — an `on*` handler, or a `javascript:`/`vbscript:` value in
|
|
257
|
+
* a URL attribute. Dropping these is the point, and the reconstruction is
|
|
258
|
+
* visually right without them, so nothing is counted. Counting would light
|
|
259
|
+
* the analyst's "recorded change(s) could not be reapplied" chip every time
|
|
260
|
+
* a filter worked.
|
|
261
|
+
* - **name token** — a name outside the XML `Name` production. Those are page
|
|
262
|
+
* state that is LOST: chromium, firefox and webkit all accept `@click`,
|
|
263
|
+
* `[ngModel]`, `(click)`, `*ngIf`, `1x`, `café` and `<` (Task 2's
|
|
264
|
+
* tri-engine table), and this module drops them anyway, for the
|
|
265
|
+
* realm-determinism reasons in the header. That loss gets a surface —
|
|
266
|
+
* `skipped`, the counter for "the file said something no DOM here could
|
|
267
|
+
* hold" — rather than only a comment.
|
|
268
|
+
*/
|
|
269
|
+
function setFilteredAttr(el, name, value, ctx) {
|
|
270
|
+
if (!isNameToken(name)) {
|
|
271
|
+
if (ctx) ctx.skipped++;
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
if (/^on/i.test(name)) return;
|
|
275
|
+
// Viewer-owned (see VIEWER_OWNED_ATTRS): refused on the SET path so a
|
|
276
|
+
// recording cannot forge a placeholder outline or a shadow chip. Uncounted,
|
|
277
|
+
// like the other integrity refusals — the reconstruction is visually right
|
|
278
|
+
// without it, and counting would light the analyst's "could not be
|
|
279
|
+
// reapplied" chip every time a filter worked. The viewer re-stamps the flags
|
|
280
|
+
// it recognises itself, in `instantiateElement`.
|
|
281
|
+
if (isViewerOwnedAttr(name)) return;
|
|
282
|
+
var text = value == null ? '' : String(value);
|
|
283
|
+
if (URL_ATTRS[name.toLowerCase()] === true && !isSafeUrl(text)) return;
|
|
284
|
+
el.setAttribute(name, text);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function applyAttrs(el, attrs, skip, ctx) {
|
|
288
|
+
for (var name in attrs) {
|
|
289
|
+
if (!Object.prototype.hasOwnProperty.call(attrs, name)) continue;
|
|
290
|
+
if (skip && skip[name.toLowerCase()] === true) continue;
|
|
291
|
+
setFilteredAttr(el, name, attrs[name], ctx);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Frames are recorded as the element only (spec §13) and must stay that way in
|
|
296
|
+
// the reconstruction: no `src` to fetch, no `srcdoc` to parse. §12's network
|
|
297
|
+
// policy is then satisfied structurally — there is nothing to request — with
|
|
298
|
+
// the viewer's `frame-src 'none'` CSP as the belt to that brace.
|
|
299
|
+
var IFRAME_SKIP = { src: true, srcdoc: true };
|
|
300
|
+
|
|
301
|
+
// Design §7 renders media as a state badge and a lane marker, with no playback,
|
|
302
|
+
// and honours `media_src` only so the element has its shape. `autoplay` is the
|
|
303
|
+
// one recorded attribute that would start playback with nobody asking, and the
|
|
304
|
+
// shell CSP allows `media-src *`, so it is dropped where it lands.
|
|
305
|
+
var MEDIA_TAGS = { video: true, audio: true };
|
|
306
|
+
var MEDIA_SKIP = { autoplay: true };
|
|
307
|
+
|
|
308
|
+
// Which recorded attributes this tag never receives.
|
|
309
|
+
function skipSetFor(tag) {
|
|
310
|
+
if (tag === 'iframe') return IFRAME_SKIP;
|
|
311
|
+
if (MEDIA_TAGS[tag] === true) return MEDIA_SKIP;
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function instantiateNode(domNode, ctx, parentNs) {
|
|
316
|
+
// Tolerant posture (design §4): an unusable node is skipped and counted, not
|
|
317
|
+
// thrown on. `instantiateTree` has no try/catch by design — one guard at the
|
|
318
|
+
// boundary, one predicate, the same answer in every realm — so this is the
|
|
319
|
+
// line that stops a hand-edited or foreign file from aborting a whole
|
|
320
|
+
// keyframe mount. The §11 tolerant loader admits all of these shapes into
|
|
321
|
+
// the model, so the viewer is where they arrive.
|
|
322
|
+
if (!domNode || typeof domNode !== 'object') {
|
|
323
|
+
ctx.skipped++;
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
var doc = ctx.doc;
|
|
327
|
+
var node;
|
|
328
|
+
if (domNode.kind === 'text') {
|
|
329
|
+
node = doc.createTextNode(domNode.text == null ? '' : String(domNode.text));
|
|
330
|
+
} else if (domNode.kind === 'comment') {
|
|
331
|
+
node = doc.createComment(domNode.text == null ? '' : String(domNode.text));
|
|
332
|
+
} else {
|
|
333
|
+
var tag = String(domNode.tag == null ? '' : domNode.tag).toLowerCase();
|
|
334
|
+
if (!isUsableTag(tag)) {
|
|
335
|
+
ctx.skipped++;
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
node = instantiateElement(domNode, ctx, tag, parentNs);
|
|
339
|
+
}
|
|
340
|
+
if (!bind(ctx, domNode.id, node)) {
|
|
341
|
+
// The id belongs to the reconstruction root and `bind` refused to move it
|
|
342
|
+
// (see there). Refusing the binding means refusing the NODE: the walk
|
|
343
|
+
// already bound its children as it descended, and a node in the tree that
|
|
344
|
+
// no id resolves to breaks every reader that walks from the root —
|
|
345
|
+
// including `readTree`, the shared one — which is the same failure the
|
|
346
|
+
// refusal exists to prevent.
|
|
347
|
+
purgeSubtree(ctx, node);
|
|
348
|
+
ctx.patchFailures++;
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
return node;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function instantiateElement(domNode, ctx, tag, parentNs) {
|
|
355
|
+
var inert = INERT_TAGS[tag] === true;
|
|
356
|
+
var isFrame = tag === 'iframe';
|
|
357
|
+
var ns = namespaceFor(tag, parentNs);
|
|
358
|
+
// The FRAME's document, never the report's: a node created in the report's
|
|
359
|
+
// realm and adopted into the frame is a cross-realm object, and the whole
|
|
360
|
+
// point of the sandbox is that the reconstruction is made of the frame's own
|
|
361
|
+
// nodes. A `<template>` standing in for a script is an HTML element wherever
|
|
362
|
+
// the script sat, so the substitution lands in the XHTML namespace.
|
|
363
|
+
var created = inert ? 'template' : tag;
|
|
364
|
+
var createdNs = inert ? XHTML_NS : ns;
|
|
365
|
+
var el = createdNs === XHTML_NS
|
|
366
|
+
? ctx.doc.createElement(created)
|
|
367
|
+
: ctx.doc.createElementNS(createdNs, created);
|
|
368
|
+
|
|
369
|
+
var skip = skipSetFor(tag);
|
|
370
|
+
applyAttrs(el, domNode.attrs || {}, skip, ctx);
|
|
371
|
+
// §4's `media_src` is the RESOLVED url the element actually loaded
|
|
372
|
+
// (`currentSrc`), where the recorded `src` attribute is whatever the page
|
|
373
|
+
// author wrote — routinely a relative path, which resolves to nothing in a
|
|
374
|
+
// `srcdoc` frame with no base URL. The resolved one wins, which is the same
|
|
375
|
+
// rule capture applies to `<img>` (snapshot.js `emittedAttrValue`). Media is
|
|
376
|
+
// rendered as badges and lane markers rather than played (design §7); this
|
|
377
|
+
// is only so the element has its shape.
|
|
378
|
+
//
|
|
379
|
+
// It obeys the SAME skip set as the recorded attributes (fix round 1). Until
|
|
380
|
+
// it did, a `media_src` on an iframe node reached the live DOM as `src` and
|
|
381
|
+
// chromium issued the request whenever the shell CSP was absent — measured.
|
|
382
|
+
// §12's network policy is supposed to hold STRUCTURALLY here, with nothing to
|
|
383
|
+
// request; `frame-src 'none'` is the belt, it lives in a file this module
|
|
384
|
+
// does not ship with, and three consumers build their own frames.
|
|
385
|
+
if (!(skip && skip.src === true)
|
|
386
|
+
&& typeof domNode.media_src === 'string' && domNode.media_src) {
|
|
387
|
+
setFilteredAttr(el, 'src', domNode.media_src, ctx);
|
|
388
|
+
}
|
|
389
|
+
if (isFrame) el.setAttribute(PLACEHOLDER_ATTR, 'iframe');
|
|
390
|
+
// The shadow flag is re-stamped BY THE VIEWER from the recorded claim rather
|
|
391
|
+
// than passed through `applyAttrs`, which now refuses the name. Same DOM as
|
|
392
|
+
// before; the difference is that the flag in the reconstruction is one the
|
|
393
|
+
// viewer put there and no later patch can move or delete (see
|
|
394
|
+
// VIEWER_OWNED_ATTRS). The value is normalised to the empty string capture
|
|
395
|
+
// writes, so the chip's selector cannot be dodged by a value.
|
|
396
|
+
var recorded = domNode.attrs;
|
|
397
|
+
if (recorded && Object.prototype.hasOwnProperty.call(recorded, SHADOW_ATTR)) {
|
|
398
|
+
el.setAttribute(SHADOW_ATTR, '');
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// §4's `canvas_size` is the BITMAP size, which is not an attribute and must
|
|
402
|
+
// not become one — it is what sizes the parent-owned offscreen canvas the
|
|
403
|
+
// snapshots composite into (design §3.1) and what the used-size-0 sizing
|
|
404
|
+
// repair pins from (design §3.3). Both live in the viewer client; recording it
|
|
405
|
+
// is this walk's, because this walk is the only place the annotation is in
|
|
406
|
+
// hand.
|
|
407
|
+
//
|
|
408
|
+
// The delete is not redundant with the set. `dom.add` OVERWRITES an id
|
|
409
|
+
// binding, because a remove+add pair carrying one id is a MOVE (pin M5), and
|
|
410
|
+
// this map is keyed by the same ids — so an id re-bound to a node with NO
|
|
411
|
+
// `canvas_size` would keep the previous node's bitmap size, and the viewer
|
|
412
|
+
// would size an offscreen canvas for an element that is not a canvas.
|
|
413
|
+
ctx.canvases.delete(domNode.id);
|
|
414
|
+
var size = domNode.canvas_size;
|
|
415
|
+
if (size && typeof size === 'object'
|
|
416
|
+
&& typeof size.w === 'number' && typeof size.h === 'number') {
|
|
417
|
+
ctx.canvases.set(domNode.id, { w: size.w, h: size.h });
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
var childNs = childNamespaceOf(tag, createdNs);
|
|
421
|
+
var kids = domNode.children || [];
|
|
422
|
+
for (var i = 0; i < kids.length; i++) {
|
|
423
|
+
var child = instantiateNode(kids[i], ctx, childNs);
|
|
424
|
+
if (child) el.appendChild(child);
|
|
425
|
+
}
|
|
426
|
+
return el;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// The span-scoped state. One of these per keyframe span (design §4): the id map
|
|
430
|
+
// is rebuilt from scratch at every span restore, and everything a patch needs
|
|
431
|
+
// to resolve travels with it.
|
|
432
|
+
//
|
|
433
|
+
// `idOf` is the reverse index, and it ships rather than being rebuilt on demand
|
|
434
|
+
// because `dom.remove` has to purge the removed node AND its whole subtree from
|
|
435
|
+
// the map — matching `dom-player.js` and the fork's `removeFromMap`, so a later
|
|
436
|
+
// patch naming a purged descendant is caught rather than resolved against a
|
|
437
|
+
// detached node. Rebuilding it per removal is O(span) per patch; keeping it
|
|
438
|
+
// alongside costs one `Map` and is maintained in exactly one place (`bind`).
|
|
439
|
+
function newContext(doc) {
|
|
440
|
+
return {
|
|
441
|
+
doc: doc, root: null,
|
|
442
|
+
idMap: new Map(), idOf: new Map(), canvases: new Map(),
|
|
443
|
+
skipped: 0, patchFailures: 0,
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function result(ctx, root) {
|
|
448
|
+
ctx.root = root;
|
|
449
|
+
return ctx;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Bind an id to a node, overwriting whatever either side held.
|
|
454
|
+
*
|
|
455
|
+
* The overwrite IS the contract (design §4, T3 Task 3 pin M5): a `dom.remove` +
|
|
456
|
+
* `dom.add` pair carrying the same id is a MOVE, and treating the second
|
|
457
|
+
* binding as a duplicate loses the node. Both directions are cleared first, so
|
|
458
|
+
* the two maps stay exact inverses of each other — the property the subtree
|
|
459
|
+
* purge depends on and a test pins over both fixtures. Note the guarantee is
|
|
460
|
+
* about the MAP: a bare re-add with no preceding remove leaves the old subtree
|
|
461
|
+
* in the document, still resolvable through its own ids, because nothing asked
|
|
462
|
+
* for it to be detached and inventing a detach would be the viewer editing the
|
|
463
|
+
* reconstruction.
|
|
464
|
+
*
|
|
465
|
+
* ONE EXCEPTION, added in fix round 1 (review I-1): the id the mount bound to
|
|
466
|
+
* the reconstruction ROOT is not movable. A `dom.add` carrying it — at the
|
|
467
|
+
* subtree's own root or buried in its children — otherwise pointed the root id
|
|
468
|
+
* at an attacker-chosen element with nothing counted, which decides what Task
|
|
469
|
+
* 7's `exists`/`attr:<name>` read, what an `anchor.node` naming the root
|
|
470
|
+
* resolves to, and what the §8 camera chain measures; and it dropped
|
|
471
|
+
* `mount.root` out of `idOf`, so every reader that walks from the root threw
|
|
472
|
+
* one layer up. Unreachable from CH capture, reachable from any foreign file.
|
|
473
|
+
*
|
|
474
|
+
* @returns {boolean} whether the binding was made
|
|
475
|
+
*/
|
|
476
|
+
function bind(ctx, id, node) {
|
|
477
|
+
var prev = ctx.idMap.get(id);
|
|
478
|
+
if (prev !== undefined && prev !== node) {
|
|
479
|
+
if (prev === ctx.root) return false;
|
|
480
|
+
ctx.idOf.delete(prev);
|
|
481
|
+
}
|
|
482
|
+
var prevId = ctx.idOf.get(node);
|
|
483
|
+
if (prevId !== undefined && prevId !== id) ctx.idMap.delete(prevId);
|
|
484
|
+
ctx.idMap.set(id, node);
|
|
485
|
+
ctx.idOf.set(node, id);
|
|
486
|
+
return true;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Drop a node and every descendant from both maps.
|
|
490
|
+
//
|
|
491
|
+
// `template.content` is walked as well as `childNodes` for the realm reason
|
|
492
|
+
// `INERT_TAGS` records: happy-dom routes `appendChild` on a template into
|
|
493
|
+
// `content`, browsers keep it in `childNodes`. Walking both leaves the same map
|
|
494
|
+
// in every realm, which is the same determinism argument the name filters rest
|
|
495
|
+
// on. Deleting an id twice is a no-op, so the overlap costs nothing.
|
|
496
|
+
function purgeSubtree(ctx, node) {
|
|
497
|
+
var id = ctx.idOf.get(node);
|
|
498
|
+
if (id !== undefined) {
|
|
499
|
+
ctx.idOf.delete(node);
|
|
500
|
+
// Delete only the binding this node actually OWNS. Given the inversion
|
|
501
|
+
// `bind` maintains, `idMap.get(id) === node` always holds — so the test is
|
|
502
|
+
// dead weight on a healthy map and the whole point on a broken one: without
|
|
503
|
+
// it, a node whose reverse entry disagrees takes an innocent node's binding
|
|
504
|
+
// down with it, turning a loud inconsistency into a quiet one. Pinned by
|
|
505
|
+
// injection rather than left as a defensive line (review I-2, H3).
|
|
506
|
+
if (ctx.idMap.get(id) === node) {
|
|
507
|
+
ctx.idMap.delete(id);
|
|
508
|
+
ctx.canvases.delete(id);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
var kids = node.childNodes || [];
|
|
512
|
+
for (var i = 0; i < kids.length; i++) purgeSubtree(ctx, kids[i]);
|
|
513
|
+
var content = node.content;
|
|
514
|
+
if (content && content.childNodes) {
|
|
515
|
+
var inner = content.childNodes;
|
|
516
|
+
for (var j = 0; j < inner.length; j++) purgeSubtree(ctx, inner[j]);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Instantiate a DomNode tree as a DETACHED subtree of `doc`.
|
|
522
|
+
*
|
|
523
|
+
* @param {object} domNode a spec §4 DomNode tree
|
|
524
|
+
* @param {Document} doc the frame's document — every node is created in it
|
|
525
|
+
* @returns {{root: Node|null, doc: Document, idMap: Map<number, Node>,
|
|
526
|
+
* idOf: Map<Node, number>, canvases: Map<number, {w, h}>,
|
|
527
|
+
* skipped: number, patchFailures: number}}
|
|
528
|
+
* the span state `applyPatch` extends. `canvases` is the §4 `canvas_size`
|
|
529
|
+
* annotation, which no DOM read can recover, keyed by the same ids as
|
|
530
|
+
* `idMap`.
|
|
531
|
+
*
|
|
532
|
+
* The two counters answer different questions and both belong in the
|
|
533
|
+
* `counters.patchFailures` surface that already drives the "recorded
|
|
534
|
+
* change(s) could not be reapplied" chip: `skipped` is *the file said
|
|
535
|
+
* something no DOM here could hold* (a tag or attribute name outside the
|
|
536
|
+
* Name production, a null child), `patchFailures` is *a reference the span
|
|
537
|
+
* could not honour* (an id the map does not hold, a `before` that is not a
|
|
538
|
+
* child, a target of the wrong kind, a refused re-bind of the root).
|
|
539
|
+
*
|
|
540
|
+
* THE RETURNED OBJECT IS THE LIVE STATE, not a snapshot: `applyPatch` keeps
|
|
541
|
+
* incrementing these counters on the same object, so read them as properties
|
|
542
|
+
* (`mount.skipped`) at the moment you need them. Destructuring at mount time
|
|
543
|
+
* captures the numbers as they were before any patch applied.
|
|
544
|
+
*/
|
|
545
|
+
function instantiateTree(domNode, doc) {
|
|
546
|
+
var ctx = newContext(doc);
|
|
547
|
+
return result(ctx, instantiateNode(domNode, ctx, null));
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Mount a keyframe into the frame's body, with the body-root split.
|
|
552
|
+
*
|
|
553
|
+
* A root whose tag is `body` — CH's ordinary case, since the observed root
|
|
554
|
+
* defaults to `document.body`, and jsPsych's too — has its ATTRIBUTES applied
|
|
555
|
+
* to the frame's own `<body>` and its children instantiated inside it, with
|
|
556
|
+
* the root id bound to that body. Instantiating a second `<body>` inside the
|
|
557
|
+
* first would break the `height: 100%` chain a wiped display element depends
|
|
558
|
+
* on and would put the recorded body's styles on a node the page's CSS does
|
|
559
|
+
* not match. Any other root is instantiated as a child of the cleared body.
|
|
560
|
+
*
|
|
561
|
+
* This is also the rebuild: the body's previous children and attributes are
|
|
562
|
+
* removed first, so a span restore is `mountTree(...)` and nothing else. The
|
|
563
|
+
* frame itself survives — `srcdoc` is written once per mount (design §4), so
|
|
564
|
+
* no `onload` round trip stands between a backward seek and a readable DOM.
|
|
565
|
+
*
|
|
566
|
+
* CARRY FOR TASK 3: on the body-root path the root id is bound to the frame's
|
|
567
|
+
* OWN `<body>`. A `dom.remove` naming that id must skip and count rather than
|
|
568
|
+
* call `.remove()`, which would detach the element every later mount and patch
|
|
569
|
+
* depends on and leave the reconstruction nowhere to go.
|
|
570
|
+
*
|
|
571
|
+
* @param {object|null} domNode the span keyframe's `initial_dom`; null clears
|
|
572
|
+
* the body and mounts nothing, which is the trace-tier and the
|
|
573
|
+
* nothing-to-restore case
|
|
574
|
+
* @param {Element} body the frame's `<body>`
|
|
575
|
+
* @param {Document} doc the frame's document
|
|
576
|
+
* @returns {object} the span state, as `instantiateTree` documents it
|
|
577
|
+
*/
|
|
578
|
+
function mountTree(domNode, body, doc) {
|
|
579
|
+
while (body.firstChild) body.removeChild(body.firstChild);
|
|
580
|
+
var live = body.attributes;
|
|
581
|
+
for (var i = live.length - 1; i >= 0; i--) body.removeAttribute(live[i].name);
|
|
582
|
+
|
|
583
|
+
var ctx = newContext(doc);
|
|
584
|
+
// A null keyframe is the LEGITIMATE case — trace tier, or a restore with
|
|
585
|
+
// nothing to mount — so it clears and reports no skip. Anything else that is
|
|
586
|
+
// not an object is a malformed keyframe and is counted.
|
|
587
|
+
if (domNode == null) return result(ctx, null);
|
|
588
|
+
if (typeof domNode !== 'object') {
|
|
589
|
+
ctx.skipped++;
|
|
590
|
+
return result(ctx, null);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
var tag = domNode.kind === 'element' || domNode.kind == null
|
|
594
|
+
? String(domNode.tag == null ? '' : domNode.tag).toLowerCase() : null;
|
|
595
|
+
if (tag !== 'body') {
|
|
596
|
+
var root = instantiateNode(domNode, ctx, null);
|
|
597
|
+
if (root) body.appendChild(root);
|
|
598
|
+
return result(ctx, root);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
applyAttrs(body, domNode.attrs || {}, null, ctx);
|
|
602
|
+
bind(ctx, domNode.id, body);
|
|
603
|
+
// Recorded BEFORE the children walk, not just by `result()` at the end, so a
|
|
604
|
+
// keyframe whose descendant repeats the root's id cannot re-bind it away from
|
|
605
|
+
// the frame's own body mid-mount — the mount-time half of `bind`'s exception.
|
|
606
|
+
ctx.root = body;
|
|
607
|
+
var kids = domNode.children || [];
|
|
608
|
+
for (var j = 0; j < kids.length; j++) {
|
|
609
|
+
var child = instantiateNode(kids[j], ctx, null);
|
|
610
|
+
if (child) body.appendChild(child);
|
|
611
|
+
}
|
|
612
|
+
return result(ctx, body);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// ── spec §5.1 patch application ────────────────────────────────────────────
|
|
616
|
+
//
|
|
617
|
+
// TOLERANT, COUNTED, SURFACED (design §4). A patch naming an id the map does
|
|
618
|
+
// not hold is skipped and counted into `patchFailures`, never thrown on. This
|
|
619
|
+
// is a DELIBERATE divergence from `tests/replay/support/dom-player.js`, which
|
|
620
|
+
// throws on the same input: the test player's job is to catch mapper bugs, the
|
|
621
|
+
// viewer's job is to show an analyst as much of an unrepeatable session as
|
|
622
|
+
// survives. The consequence is recorded and routed — a tolerant viewer cannot
|
|
623
|
+
// detect a producer emitting dangling references, which is why T7 owes a
|
|
624
|
+
// dangling-reference negative fixture (design §14 risk 2).
|
|
625
|
+
//
|
|
626
|
+
// What is NOT counted: an attribute refused by the §12 filters. Instantiation
|
|
627
|
+
// drops the same names silently, so counting them here would light up the
|
|
628
|
+
// "recorded change(s) could not be reapplied" chip for a filter working exactly
|
|
629
|
+
// as designed. A dropped `on*` handler leaves the visual reconstruction right;
|
|
630
|
+
// an unresolvable id means it is wrong.
|
|
631
|
+
|
|
632
|
+
// Every caller tests the result for falsiness, so an id the map does not hold
|
|
633
|
+
// arrives as `undefined` and reads the same as a missing node.
|
|
634
|
+
function resolve(mount, id) {
|
|
635
|
+
return mount.idMap.get(id);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function tagNameOf(node) {
|
|
639
|
+
return node && node.tagName ? String(node.tagName).toLowerCase() : '';
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function applyAdd(patch, mount) {
|
|
643
|
+
var parent = resolve(mount, patch.parent);
|
|
644
|
+
if (!parent || parent.nodeType !== ELEMENT_NODE) {
|
|
645
|
+
mount.patchFailures++;
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
// The inserted subtree inherits the LIVE parent's namespace, so a `dom.add`
|
|
649
|
+
// into an SVG subtree does not silently produce XHTML children that lay out
|
|
650
|
+
// at 0×0 — and an add into an SVG HTML-integration point produces HTML
|
|
651
|
+
// again. `dom-player.js` carries the same rule; re-sync both when either
|
|
652
|
+
// moves.
|
|
653
|
+
var childNs = childNamespaceOf(tagNameOf(parent), parent.namespaceURI);
|
|
654
|
+
var node = instantiateNode(patch.node, mount, childNs);
|
|
655
|
+
if (!node) return; // already counted into `skipped`
|
|
656
|
+
|
|
657
|
+
var ref = null;
|
|
658
|
+
if (patch.before !== null && patch.before !== undefined) {
|
|
659
|
+
var anchor = mount.idMap.get(patch.before);
|
|
660
|
+
// A `before` the map does not hold, or one that is not a child of this
|
|
661
|
+
// parent, cannot be honoured: the node is appended instead. Counted,
|
|
662
|
+
// because the recorded POSITION was lost even though the node survived —
|
|
663
|
+
// the reconstruction is knowingly approximate at that point.
|
|
664
|
+
if (anchor !== undefined && anchor.parentNode === parent) ref = anchor;
|
|
665
|
+
else mount.patchFailures++;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
try {
|
|
669
|
+
parent.insertBefore(node, ref);
|
|
670
|
+
} catch (err) {
|
|
671
|
+
// A hierarchy the DOM refuses. The subtree is unbound again rather than
|
|
672
|
+
// left in the map, where a later patch would resolve it against a node
|
|
673
|
+
// nothing can see.
|
|
674
|
+
mount.patchFailures++;
|
|
675
|
+
purgeSubtree(mount, node);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function applyRemove(patch, mount) {
|
|
680
|
+
var node = resolve(mount, patch.node);
|
|
681
|
+
if (!node) {
|
|
682
|
+
mount.patchFailures++;
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
// The reconstruction root is not removable, under either root shape — the
|
|
686
|
+
// other half of the invariant `bind` protects. On the body-root path (design
|
|
687
|
+
// §4's split) the root IS the frame's own `<body>`, so a blind `.remove()`
|
|
688
|
+
// detaches the element every later mount and patch depends on; on any other
|
|
689
|
+
// path it is the node every reader walks from. One predicate for both, so
|
|
690
|
+
// there is no disjunct a test cannot reach.
|
|
691
|
+
if (node === mount.root) {
|
|
692
|
+
mount.patchFailures++;
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
purgeSubtree(mount, node);
|
|
696
|
+
if (node.parentNode) node.parentNode.removeChild(node);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function applyAttr(patch, mount) {
|
|
700
|
+
var el = resolve(mount, patch.node);
|
|
701
|
+
if (!el || el.nodeType !== ELEMENT_NODE) {
|
|
702
|
+
mount.patchFailures++;
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
var name = String(patch.name == null ? '' : patch.name);
|
|
706
|
+
if (patch.value === null || patch.value === undefined) {
|
|
707
|
+
// ONE name check on the removal path, and it is the same predicate the set
|
|
708
|
+
// path uses. `removeAttribute` validates nothing — browsers by spec,
|
|
709
|
+
// happy-dom measured — so removing a name this module would never have SET
|
|
710
|
+
// is a harmless no-op and needs no guard; what DOES need one is the
|
|
711
|
+
// viewer's own flags, which a recording can strip as well as forge. Task 3
|
|
712
|
+
// left this line to Task 4 because the detector that reads the flags back
|
|
713
|
+
// lands there.
|
|
714
|
+
if (isViewerOwnedAttr(name)) return;
|
|
715
|
+
el.removeAttribute(name);
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
// ONE §12 gate on the set path, and it is the gate instantiation uses, so a
|
|
719
|
+
// keyframe and a patch can never disagree about what an element may carry.
|
|
720
|
+
setFilteredAttr(el, name, patch.value, mount);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
function applyText(patch, mount) {
|
|
724
|
+
var node = resolve(mount, patch.node);
|
|
725
|
+
if (!node) {
|
|
726
|
+
mount.patchFailures++;
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
// §5.1's `dom.text` addresses a text or comment node. An element has no
|
|
730
|
+
// character data, and assigning to `nodeValue` on one is a silent no-op —
|
|
731
|
+
// counting is the honest answer, and it is where the two players differ
|
|
732
|
+
// hardest: the strict one writes `.data` on whatever it resolved.
|
|
733
|
+
if (node.nodeType !== TEXT_NODE && node.nodeType !== COMMENT_NODE) {
|
|
734
|
+
mount.patchFailures++;
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
node.nodeValue = patch.text == null ? '' : String(patch.text);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* Apply one spec §5.1 patch to a mounted span.
|
|
742
|
+
*
|
|
743
|
+
* @param {object} patch a `dom.add` / `dom.remove` / `dom.attr` / `dom.text`
|
|
744
|
+
* event; anything else is left alone
|
|
745
|
+
* @param {object} mount the state `mountTree`/`instantiateTree` returned
|
|
746
|
+
* @returns {boolean} whether this event was a DOM patch — so a caller's
|
|
747
|
+
* vocabulary dispatch (§5.2-§5.8, in the viewer client) can fall through on
|
|
748
|
+
* `false`
|
|
749
|
+
* without needing its own list of the four types
|
|
750
|
+
*/
|
|
751
|
+
function applyPatch(patch, mount) {
|
|
752
|
+
if (!patch) return false;
|
|
753
|
+
var type = patch.type;
|
|
754
|
+
if (type === 'dom.add') applyAdd(patch, mount);
|
|
755
|
+
else if (type === 'dom.remove') applyRemove(patch, mount);
|
|
756
|
+
else if (type === 'dom.attr') applyAttr(patch, mount);
|
|
757
|
+
else if (type === 'dom.text') applyText(patch, mount);
|
|
758
|
+
else return false;
|
|
759
|
+
return true;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* Apply a time-ordered slice of a segment's events, in order, skipping the
|
|
764
|
+
* ones that are not DOM patches.
|
|
765
|
+
*
|
|
766
|
+
* @param {object[]} patches
|
|
767
|
+
* @param {object} mount
|
|
768
|
+
* @returns {object} the same mount state, for chaining
|
|
769
|
+
*/
|
|
770
|
+
function applyPatches(patches, mount) {
|
|
771
|
+
var list = patches || [];
|
|
772
|
+
for (var i = 0; i < list.length; i++) applyPatch(list[i], mount);
|
|
773
|
+
return mount;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// ONE line of ESM syntax, last, so the build can strip it with a single
|
|
777
|
+
// replace and concatenate the rest into the report's viewer script. Keep it
|
|
778
|
+
// that way — the header explains why, and the test suite fails if it drifts.
|
|
779
|
+
export { instantiateTree, mountTree, applyPatch, applyPatches };
|