browser-commander 0.17.2 → 0.19.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/.jscpd-baseline.json +12 -17
- package/CHANGELOG.md +68 -0
- package/README.md +132 -0
- package/eslint.config.js +5 -0
- package/package.json +5 -1
- package/src/bindings.js +13 -1
- package/src/browser/connector.js +15 -3
- package/src/browser/launcher.js +14 -2
- package/src/browser/navigation.js +63 -0
- package/src/core/dialog-manager.js +48 -0
- package/src/core/navigation-manager.js +83 -87
- package/src/core/navigation-readiness.js +133 -0
- package/src/core/readiness.js +478 -0
- package/src/downloads/attach.js +58 -0
- package/src/downloads/destination.js +125 -0
- package/src/downloads/manager.js +476 -0
- package/src/downloads/naming.js +246 -0
- package/src/downloads/sources.js +238 -0
- package/src/downloads/staging.js +146 -0
- package/src/downloads/store.js +256 -0
- package/src/exports.js +86 -0
- package/src/factory.js +55 -0
- package/src/interactions/click-activation.js +317 -0
- package/src/interactions/click-result.js +108 -0
- package/src/interactions/click.js +766 -352
- package/src/tests/index.js +75 -11
- package/src/tests/tracing.js +236 -0
- package/src/traces/bundle.js +338 -0
- package/src/traces/deadline.js +33 -0
- package/src/traces/identity.js +127 -0
- package/src/traces/init-script.js +95 -0
- package/src/traces/links.js +623 -0
- package/src/traces/mutation-stream.js +232 -0
- package/src/traces/observers.js +272 -0
- package/src/traces/page-capture.js +601 -0
- package/src/traces/reader.js +226 -0
- package/src/traces/recorder.js +454 -0
- package/src/traces/redaction.js +231 -0
- package/src/traces/schema.js +229 -0
- package/src/traces/viewer.js +448 -0
- package/tests/e2e/click-readiness.e2e.test.js +240 -0
- package/tests/e2e/downloads.e2e.test.js +235 -0
- package/tests/e2e/traces.e2e.test.js +742 -0
- package/tests/helpers/click-fixtures.js +62 -0
- package/tests/helpers/download-fixtures.js +125 -0
- package/tests/helpers/download-server.js +129 -0
- package/tests/helpers/e2e-browser.js +67 -0
- package/tests/helpers/file-modes.js +31 -0
- package/tests/helpers/fixture-server.js +56 -0
- package/tests/helpers/mocks.js +63 -66
- package/tests/helpers/readiness-server.js +96 -0
- package/tests/helpers/temp-directory.js +44 -0
- package/tests/helpers/trace-fixtures.js +274 -0
- package/tests/helpers/trace-server.js +173 -0
- package/tests/unit/core/dialog-manager.test.js +118 -10
- package/tests/unit/core/navigation-manager.test.js +162 -0
- package/tests/unit/core/readiness.test.js +517 -0
- package/tests/unit/downloads/attach.test.js +220 -0
- package/tests/unit/downloads/destination.test.js +114 -0
- package/tests/unit/downloads/manager.test.js +485 -0
- package/tests/unit/downloads/naming.test.js +174 -0
- package/tests/unit/downloads/staging.test.js +117 -0
- package/tests/unit/downloads/store.test.js +215 -0
- package/tests/unit/interactions/click-activation.test.js +283 -0
- package/tests/unit/interactions/click-result.test.js +90 -0
- package/tests/unit/interactions/click.test.js +409 -200
- package/tests/unit/tests/tracing.test.js +299 -0
- package/tests/unit/traces/bundle.test.js +260 -0
- package/tests/unit/traces/links.test.js +557 -0
- package/tests/unit/traces/reader.test.js +208 -0
- package/tests/unit/traces/recorder.test.js +730 -0
- package/tests/unit/traces/redaction.test.js +170 -0
- package/tests/unit/traces/schema.test.js +92 -0
- package/tests/unit/traces/viewer.test.js +294 -0
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The part of tracing that runs inside the page (issue #87).
|
|
3
|
+
*
|
|
4
|
+
* These functions are serialized and evaluated in the browser, so they may
|
|
5
|
+
* only use browser globals and their own arguments. They exist because
|
|
6
|
+
* `page.content()` is not a replay: `value`, `checked`, `selected`, focus,
|
|
7
|
+
* scroll offsets and open shadow roots are runtime state that serialized
|
|
8
|
+
* markup does not carry.
|
|
9
|
+
*
|
|
10
|
+
* Redaction happens here, before the bytes ever leave the page.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Global the mutation recorder keeps its queue on. */
|
|
14
|
+
export const RECORDER_GLOBAL = '__browserCommanderTrace__';
|
|
15
|
+
|
|
16
|
+
/* c8 ignore start -- these bodies run inside the browser, not under node */
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Capture full HTML plus live control state from one document.
|
|
20
|
+
*
|
|
21
|
+
* Runs in the page. Redaction is applied to a clone, so the live page a
|
|
22
|
+
* caller is still automating is never modified by being traced.
|
|
23
|
+
*
|
|
24
|
+
* @param {Object} options - `{redactSelectors, redactAttributes, redacted, html, liveControlState, openShadowRoots, maxHtmlBytes}`
|
|
25
|
+
* @returns {Object} `{html, state, url, title, frames, truncated}`
|
|
26
|
+
*/
|
|
27
|
+
export function captureSnapshotInPage(options) {
|
|
28
|
+
const {
|
|
29
|
+
redactSelectors = [],
|
|
30
|
+
redactAttributes = [],
|
|
31
|
+
redacted = '[redacted]',
|
|
32
|
+
html: wantHtml = true,
|
|
33
|
+
liveControlState = true,
|
|
34
|
+
openShadowRoots = true,
|
|
35
|
+
maxHtmlBytes = 0,
|
|
36
|
+
} = options || {};
|
|
37
|
+
|
|
38
|
+
const isSecret = (element) =>
|
|
39
|
+
redactSelectors.some((selector) => {
|
|
40
|
+
try {
|
|
41
|
+
return element.matches(selector);
|
|
42
|
+
} catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const cssPath = (element) => {
|
|
48
|
+
const parts = [];
|
|
49
|
+
let node = element;
|
|
50
|
+
while (node && node.nodeType === 1 && parts.length < 10) {
|
|
51
|
+
let part = node.localName;
|
|
52
|
+
if (node.id) {
|
|
53
|
+
parts.unshift(`#${node.id}`);
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
const parent = node.parentNode;
|
|
57
|
+
if (parent && parent.children) {
|
|
58
|
+
const siblings = [...parent.children].filter(
|
|
59
|
+
(child) => child.localName === node.localName
|
|
60
|
+
);
|
|
61
|
+
if (siblings.length > 1) {
|
|
62
|
+
part += `:nth-of-type(${siblings.indexOf(node) + 1})`;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
parts.unshift(part);
|
|
66
|
+
node =
|
|
67
|
+
node.parentNode && node.parentNode.host ? node.parentNode.host : parent;
|
|
68
|
+
}
|
|
69
|
+
return parts.join(' > ');
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const controls = [];
|
|
73
|
+
const collectControls = (root) => {
|
|
74
|
+
const found = root.querySelectorAll
|
|
75
|
+
? root.querySelectorAll('input, textarea, select, [contenteditable]')
|
|
76
|
+
: [];
|
|
77
|
+
for (const element of found) {
|
|
78
|
+
const secret = isSecret(element);
|
|
79
|
+
const entry = {
|
|
80
|
+
path: cssPath(element),
|
|
81
|
+
tag: element.localName,
|
|
82
|
+
type: element.type || null,
|
|
83
|
+
name: element.name || null,
|
|
84
|
+
id: element.id || null,
|
|
85
|
+
focused: element === document.activeElement,
|
|
86
|
+
};
|
|
87
|
+
if (element.localName === 'select') {
|
|
88
|
+
entry.value = secret ? redacted : element.value;
|
|
89
|
+
entry.selected = [...element.selectedOptions].map((option) =>
|
|
90
|
+
secret ? redacted : option.value
|
|
91
|
+
);
|
|
92
|
+
} else if (element.type === 'checkbox' || element.type === 'radio') {
|
|
93
|
+
entry.checked = element.checked;
|
|
94
|
+
entry.value = secret ? redacted : element.value;
|
|
95
|
+
} else if (element.isContentEditable) {
|
|
96
|
+
entry.value = secret ? redacted : element.textContent;
|
|
97
|
+
} else {
|
|
98
|
+
entry.value = secret ? redacted : element.value;
|
|
99
|
+
}
|
|
100
|
+
if (element.scrollTop || element.scrollLeft) {
|
|
101
|
+
entry.scroll = { top: element.scrollTop, left: element.scrollLeft };
|
|
102
|
+
}
|
|
103
|
+
entry.redacted = secret;
|
|
104
|
+
controls.push(entry);
|
|
105
|
+
}
|
|
106
|
+
if (openShadowRoots && root.querySelectorAll) {
|
|
107
|
+
for (const element of root.querySelectorAll('*')) {
|
|
108
|
+
if (element.shadowRoot) {
|
|
109
|
+
collectControls(element.shadowRoot);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
if (liveControlState) {
|
|
116
|
+
collectControls(document);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// The clone carries the *live* state into the markup: a serialized
|
|
120
|
+
// `<input>` otherwise shows the value the server sent, not the one the user
|
|
121
|
+
// typed.
|
|
122
|
+
const carryLiveState = (original, copy, secret) => {
|
|
123
|
+
for (const attribute of redactAttributes) {
|
|
124
|
+
if (copy.hasAttribute && copy.hasAttribute(attribute)) {
|
|
125
|
+
copy.setAttribute(attribute, redacted);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (original.localName === 'input' || original.localName === 'textarea') {
|
|
129
|
+
const value = secret ? redacted : original.value;
|
|
130
|
+
copy.setAttribute('value', value);
|
|
131
|
+
if (original.localName === 'textarea') {
|
|
132
|
+
copy.textContent = value;
|
|
133
|
+
}
|
|
134
|
+
if (original.type === 'checkbox' || original.type === 'radio') {
|
|
135
|
+
if (original.checked) {
|
|
136
|
+
copy.setAttribute('checked', '');
|
|
137
|
+
} else {
|
|
138
|
+
copy.removeAttribute('checked');
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
} else if (original.localName === 'option') {
|
|
142
|
+
if (original.selected) {
|
|
143
|
+
copy.setAttribute('selected', '');
|
|
144
|
+
} else {
|
|
145
|
+
copy.removeAttribute('selected');
|
|
146
|
+
}
|
|
147
|
+
} else if (secret) {
|
|
148
|
+
copy.textContent = redacted;
|
|
149
|
+
}
|
|
150
|
+
if (original.shadowRoot && openShadowRoots) {
|
|
151
|
+
// Shadow content is not serialized by `outerHTML`; a declarative
|
|
152
|
+
// template keeps it in the same document the viewer renders.
|
|
153
|
+
const template = document.createElement('template');
|
|
154
|
+
template.setAttribute('shadowrootmode', 'open');
|
|
155
|
+
template.innerHTML = original.shadowRoot.innerHTML;
|
|
156
|
+
copy.appendChild(template);
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
let html = null;
|
|
161
|
+
let truncated = false;
|
|
162
|
+
if (wantHtml) {
|
|
163
|
+
const clone = document.documentElement.cloneNode(true);
|
|
164
|
+
const originals = document.documentElement.querySelectorAll('*');
|
|
165
|
+
const copies = clone.querySelectorAll('*');
|
|
166
|
+
for (let index = 0; index < originals.length; index++) {
|
|
167
|
+
const copy = copies[index];
|
|
168
|
+
if (!copy) {
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
carryLiveState(originals[index], copy, isSecret(originals[index]));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
html = `<!DOCTYPE html>\n${clone.outerHTML}`;
|
|
175
|
+
if (maxHtmlBytes > 0 && html.length > maxHtmlBytes) {
|
|
176
|
+
html = html.slice(0, maxHtmlBytes);
|
|
177
|
+
truncated = true;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
html,
|
|
183
|
+
state: {
|
|
184
|
+
url: location.href,
|
|
185
|
+
title: document.title,
|
|
186
|
+
readyState: document.readyState,
|
|
187
|
+
scroll: { x: window.scrollX, y: window.scrollY },
|
|
188
|
+
viewport: { width: window.innerWidth, height: window.innerHeight },
|
|
189
|
+
activeElement: document.activeElement
|
|
190
|
+
? cssPath(document.activeElement)
|
|
191
|
+
: null,
|
|
192
|
+
controls,
|
|
193
|
+
frames: [...document.querySelectorAll('iframe, frame')].map((frame) => ({
|
|
194
|
+
path: cssPath(frame),
|
|
195
|
+
src: frame.getAttribute('src'),
|
|
196
|
+
name: frame.getAttribute('name'),
|
|
197
|
+
})),
|
|
198
|
+
},
|
|
199
|
+
truncated,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Install the in-page recorder.
|
|
205
|
+
*
|
|
206
|
+
* Reinstalling after a navigation is safe and expected: the queue lives on the
|
|
207
|
+
* document, which a navigation replaces. Issue #93 is why this also runs as an
|
|
208
|
+
* init script - installing it only from `checkpoint()` left the interval
|
|
209
|
+
* between a navigation and the next checkpoint unrecorded, so a page that
|
|
210
|
+
* built its first screen during `DOMContentLoaded` replayed as if it had
|
|
211
|
+
* always been there.
|
|
212
|
+
*
|
|
213
|
+
* Two kinds of change are recorded. DOM mutations come from a
|
|
214
|
+
* `MutationObserver`, with the position information a deterministic replay
|
|
215
|
+
* needs. Live control state - what was typed, checked, selected, focused and
|
|
216
|
+
* scrolled - produces no mutation records at all, so it is observed from the
|
|
217
|
+
* events that carry it and recorded on the same queue, in the same order.
|
|
218
|
+
*
|
|
219
|
+
* @param {Object} options - `{globalName, redactSelectors, redacted, maxQueued, liveState}`
|
|
220
|
+
* @returns {boolean} True when a recorder is now observing
|
|
221
|
+
*/
|
|
222
|
+
export function installMutationRecorderInPage(options) {
|
|
223
|
+
const {
|
|
224
|
+
globalName = '__browserCommanderTrace__',
|
|
225
|
+
redactSelectors = [],
|
|
226
|
+
redacted = '[redacted]',
|
|
227
|
+
maxQueued = 5000,
|
|
228
|
+
liveState = true,
|
|
229
|
+
} = options || {};
|
|
230
|
+
|
|
231
|
+
if (window[globalName] && window[globalName].observing) {
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const state = {
|
|
236
|
+
observing: true,
|
|
237
|
+
queue: [],
|
|
238
|
+
dropped: 0,
|
|
239
|
+
sequence: 0,
|
|
240
|
+
// Every document gets its own name, so a batch drained from an iframe is
|
|
241
|
+
// never mistaken for one drained from the page that holds it.
|
|
242
|
+
frameId: `frame-${Math.random().toString(36).slice(2, 10)}`,
|
|
243
|
+
frameName: window.name || null,
|
|
244
|
+
mainFrame: window.top === window,
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
const isSecret = (node) => {
|
|
248
|
+
const element =
|
|
249
|
+
node && node.nodeType === 1 ? node : node && node.parentElement;
|
|
250
|
+
if (!element || !element.matches) {
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
return redactSelectors.some((selector) => {
|
|
254
|
+
try {
|
|
255
|
+
return element.matches(selector) || element.closest(selector) !== null;
|
|
256
|
+
} catch {
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
// A path the viewer can look the node up by: replay needs to find the same
|
|
263
|
+
// element again in a parsed copy of the checkpoint.
|
|
264
|
+
const pathOf = (node) => {
|
|
265
|
+
const steps = [];
|
|
266
|
+
for (let at = node; at && at.nodeType === 1; at = at.parentElement) {
|
|
267
|
+
if (at.id) {
|
|
268
|
+
steps.unshift(`#${at.id}`);
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
const siblings = at.parentElement
|
|
272
|
+
? [...at.parentElement.children].filter(
|
|
273
|
+
(c) => c.localName === at.localName
|
|
274
|
+
)
|
|
275
|
+
: [];
|
|
276
|
+
const nth =
|
|
277
|
+
siblings.length > 1 ? `:nth-of-type(${siblings.indexOf(at) + 1})` : '';
|
|
278
|
+
steps.unshift(at.localName + nth);
|
|
279
|
+
}
|
|
280
|
+
return steps.join(' > ');
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
const childIndexOf = (parent, node) => {
|
|
284
|
+
if (!parent || !node || !parent.childNodes) {
|
|
285
|
+
return -1;
|
|
286
|
+
}
|
|
287
|
+
return Array.prototype.indexOf.call(parent.childNodes, node);
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
const describe = (node) => {
|
|
291
|
+
if (!node) {
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
if (node.nodeType === 3) {
|
|
295
|
+
return {
|
|
296
|
+
type: 'text',
|
|
297
|
+
path: pathOf(node.parentElement),
|
|
298
|
+
text: isSecret(node) ? redacted : String(node.nodeValue).slice(0, 2000),
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
if (node.nodeType !== 1) {
|
|
302
|
+
return { type: 'node', nodeType: node.nodeType };
|
|
303
|
+
}
|
|
304
|
+
return {
|
|
305
|
+
type: 'element',
|
|
306
|
+
tag: node.localName,
|
|
307
|
+
id: node.id || null,
|
|
308
|
+
path: pathOf(node),
|
|
309
|
+
html: isSecret(node) ? redacted : String(node.outerHTML).slice(0, 4000),
|
|
310
|
+
};
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Describe a node that is no longer where it was.
|
|
315
|
+
*
|
|
316
|
+
* A removed node has no `parentElement`, so `pathOf` can only name the node
|
|
317
|
+
* itself. Its position in the parent it left is what a replay needs, and
|
|
318
|
+
* that is read from the siblings the record still points at.
|
|
319
|
+
*
|
|
320
|
+
* @param {Node} node - The node that moved or was removed
|
|
321
|
+
* @param {MutationRecord} record - The record it came from
|
|
322
|
+
* @returns {Object|null} The description, with `index` when one is knowable
|
|
323
|
+
*/
|
|
324
|
+
const describeAt = (node, record) => {
|
|
325
|
+
const described = describe(node);
|
|
326
|
+
if (!described) {
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
const parent = record.target;
|
|
330
|
+
const own = childIndexOf(parent, node);
|
|
331
|
+
if (own >= 0) {
|
|
332
|
+
described.index = own;
|
|
333
|
+
return described;
|
|
334
|
+
}
|
|
335
|
+
const after = childIndexOf(parent, record.previousSibling);
|
|
336
|
+
if (after >= 0) {
|
|
337
|
+
described.index = after + 1;
|
|
338
|
+
return described;
|
|
339
|
+
}
|
|
340
|
+
const before = childIndexOf(parent, record.nextSibling);
|
|
341
|
+
described.index = before >= 0 ? before : null;
|
|
342
|
+
return described;
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
const push = (batch) => {
|
|
346
|
+
if (state.queue.length >= maxQueued) {
|
|
347
|
+
state.dropped += 1;
|
|
348
|
+
return false;
|
|
349
|
+
}
|
|
350
|
+
state.queue.push({
|
|
351
|
+
sequence: ++state.sequence,
|
|
352
|
+
at: Date.now(),
|
|
353
|
+
url: location.href,
|
|
354
|
+
frameId: state.frameId,
|
|
355
|
+
mainFrame: state.mainFrame,
|
|
356
|
+
...batch,
|
|
357
|
+
});
|
|
358
|
+
return true;
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
const observer = new MutationObserver((records) => {
|
|
362
|
+
if (state.queue.length >= maxQueued) {
|
|
363
|
+
state.dropped += records.length;
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
push({
|
|
367
|
+
records: records.map((record) => {
|
|
368
|
+
const entry = {
|
|
369
|
+
kind: record.type,
|
|
370
|
+
target: describe(record.target),
|
|
371
|
+
};
|
|
372
|
+
if (record.type === 'attributes') {
|
|
373
|
+
entry.attribute = record.attributeName;
|
|
374
|
+
const secret =
|
|
375
|
+
isSecret(record.target) ||
|
|
376
|
+
redactSelectors.includes(record.attributeName);
|
|
377
|
+
entry.before = secret ? redacted : record.oldValue;
|
|
378
|
+
entry.after =
|
|
379
|
+
secret || !record.target.getAttribute
|
|
380
|
+
? redacted
|
|
381
|
+
: record.target.getAttribute(record.attributeName);
|
|
382
|
+
} else if (record.type === 'characterData') {
|
|
383
|
+
entry.before = isSecret(record.target) ? redacted : record.oldValue;
|
|
384
|
+
entry.after = isSecret(record.target)
|
|
385
|
+
? redacted
|
|
386
|
+
: record.target.nodeValue;
|
|
387
|
+
} else {
|
|
388
|
+
// Where a node went is as much of the change as what it was:
|
|
389
|
+
// appending every addition to the end replays an insertion before a
|
|
390
|
+
// sibling in the wrong place, and drops moves and removals entirely.
|
|
391
|
+
entry.added = [...record.addedNodes].map((node) =>
|
|
392
|
+
describeAt(node, record)
|
|
393
|
+
);
|
|
394
|
+
entry.removed = [...record.removedNodes].map((node) =>
|
|
395
|
+
describeAt(node, record)
|
|
396
|
+
);
|
|
397
|
+
entry.previous = describe(record.previousSibling);
|
|
398
|
+
entry.next = describe(record.nextSibling);
|
|
399
|
+
}
|
|
400
|
+
return entry;
|
|
401
|
+
}),
|
|
402
|
+
});
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
observer.observe(document, {
|
|
406
|
+
subtree: true,
|
|
407
|
+
childList: true,
|
|
408
|
+
attributes: true,
|
|
409
|
+
attributeOldValue: true,
|
|
410
|
+
characterData: true,
|
|
411
|
+
characterDataOldValue: true,
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
const liveDetachers = [];
|
|
415
|
+
if (liveState) {
|
|
416
|
+
const lastValues = new WeakMap();
|
|
417
|
+
const lastLive = new WeakMap();
|
|
418
|
+
|
|
419
|
+
// One interaction fires more than one event for the same state: typing
|
|
420
|
+
// into a field and then leaving it fires `input` for each keystroke and
|
|
421
|
+
// `change` for the value it ended at, and a checkbox fires both for the
|
|
422
|
+
// one click. Replaying a state that is already on screen shows nothing, so
|
|
423
|
+
// a repeat of what this element last reported is not recorded.
|
|
424
|
+
const repeats = (element, property, after) => {
|
|
425
|
+
const seen = lastLive.get(element) || {};
|
|
426
|
+
const asText = JSON.stringify(after === undefined ? null : after);
|
|
427
|
+
if (seen[property] === asText) {
|
|
428
|
+
return true;
|
|
429
|
+
}
|
|
430
|
+
seen[property] = asText;
|
|
431
|
+
lastLive.set(element, seen);
|
|
432
|
+
return false;
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
const liveEntry = (target, property, after, before) => ({
|
|
436
|
+
kind: 'live-state',
|
|
437
|
+
property,
|
|
438
|
+
target: describe(target),
|
|
439
|
+
before: before === undefined ? null : before,
|
|
440
|
+
after,
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
const pushLive = (entry) => {
|
|
444
|
+
// Scrolling fires far more often than it changes anything worth
|
|
445
|
+
// replaying, so consecutive scrolls of one element collapse into the
|
|
446
|
+
// position it ended at. Every other property keeps each step, because
|
|
447
|
+
// "stepwise replay must show each state" (issue #93).
|
|
448
|
+
const last = state.queue[state.queue.length - 1];
|
|
449
|
+
const only = last && last.records.length === 1 ? last.records[0] : null;
|
|
450
|
+
if (
|
|
451
|
+
entry.property === 'scroll' &&
|
|
452
|
+
only &&
|
|
453
|
+
only.kind === 'live-state' &&
|
|
454
|
+
only.property === 'scroll' &&
|
|
455
|
+
only.target?.path === entry.target?.path
|
|
456
|
+
) {
|
|
457
|
+
only.after = entry.after;
|
|
458
|
+
last.at = Date.now();
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
push({ records: [entry] });
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
const valueOf = (element) =>
|
|
465
|
+
isSecret(element)
|
|
466
|
+
? redacted
|
|
467
|
+
: element.isContentEditable
|
|
468
|
+
? element.textContent
|
|
469
|
+
: element.value;
|
|
470
|
+
|
|
471
|
+
const onValue = (event) => {
|
|
472
|
+
const element = event.target;
|
|
473
|
+
if (!element || element.nodeType !== 1) {
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
if (element.type === 'checkbox' || element.type === 'radio') {
|
|
477
|
+
if (!repeats(element, 'checked', element.checked)) {
|
|
478
|
+
pushLive(liveEntry(element, 'checked', element.checked));
|
|
479
|
+
}
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
if (element.localName === 'select') {
|
|
483
|
+
const chosen = [...element.selectedOptions].map((option) =>
|
|
484
|
+
isSecret(element) ? redacted : option.value
|
|
485
|
+
);
|
|
486
|
+
if (!repeats(element, 'selected', chosen)) {
|
|
487
|
+
pushLive(liveEntry(element, 'selected', chosen));
|
|
488
|
+
}
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
if (
|
|
492
|
+
element.localName !== 'input' &&
|
|
493
|
+
element.localName !== 'textarea' &&
|
|
494
|
+
!element.isContentEditable
|
|
495
|
+
) {
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
const after = valueOf(element);
|
|
499
|
+
if (repeats(element, 'value', after)) {
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
const before = lastValues.get(element);
|
|
503
|
+
lastValues.set(element, after);
|
|
504
|
+
pushLive(liveEntry(element, 'value', after, before));
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
const onFocus = (event) => {
|
|
508
|
+
pushLive(liveEntry(event.target, 'focus', event.type === 'focusin'));
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
const onScroll = (event) => {
|
|
512
|
+
const target = event.target;
|
|
513
|
+
const scrolling =
|
|
514
|
+
target === document || target === document.documentElement || !target
|
|
515
|
+
? null
|
|
516
|
+
: target;
|
|
517
|
+
pushLive({
|
|
518
|
+
kind: 'live-state',
|
|
519
|
+
property: 'scroll',
|
|
520
|
+
target: scrolling ? describe(scrolling) : null,
|
|
521
|
+
before: null,
|
|
522
|
+
after: scrolling
|
|
523
|
+
? { top: scrolling.scrollTop, left: scrolling.scrollLeft }
|
|
524
|
+
: { top: window.scrollY, left: window.scrollX },
|
|
525
|
+
});
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
const listen = (type, handler) => {
|
|
529
|
+
document.addEventListener(type, handler, true);
|
|
530
|
+
liveDetachers.push(() =>
|
|
531
|
+
document.removeEventListener(type, handler, true)
|
|
532
|
+
);
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
listen('input', onValue);
|
|
536
|
+
listen('change', onValue);
|
|
537
|
+
listen('focusin', onFocus);
|
|
538
|
+
listen('focusout', onFocus);
|
|
539
|
+
listen('scroll', onScroll);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
state.stop = () => {
|
|
543
|
+
observer.disconnect();
|
|
544
|
+
for (const detach of liveDetachers) {
|
|
545
|
+
detach();
|
|
546
|
+
}
|
|
547
|
+
state.observing = false;
|
|
548
|
+
};
|
|
549
|
+
|
|
550
|
+
window[globalName] = state;
|
|
551
|
+
return true;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Take everything the recorder has queued and leave the queue empty.
|
|
556
|
+
*
|
|
557
|
+
* @param {string} globalName - Global the recorder lives on
|
|
558
|
+
* @returns {Object} `{batches, dropped, installed, frameId, mainFrame}`
|
|
559
|
+
*/
|
|
560
|
+
export function drainMutationsInPage(globalName) {
|
|
561
|
+
const state = window[globalName || '__browserCommanderTrace__'];
|
|
562
|
+
if (!state) {
|
|
563
|
+
return {
|
|
564
|
+
batches: [],
|
|
565
|
+
dropped: 0,
|
|
566
|
+
installed: false,
|
|
567
|
+
frameId: null,
|
|
568
|
+
mainFrame: window.top === window,
|
|
569
|
+
url: location.href,
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
const batches = state.queue;
|
|
573
|
+
const dropped = state.dropped;
|
|
574
|
+
state.queue = [];
|
|
575
|
+
state.dropped = 0;
|
|
576
|
+
return {
|
|
577
|
+
batches,
|
|
578
|
+
dropped,
|
|
579
|
+
installed: true,
|
|
580
|
+
frameId: state.frameId,
|
|
581
|
+
mainFrame: state.mainFrame,
|
|
582
|
+
url: location.href,
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Stop the in-page recorder without waiting for the document to go away.
|
|
588
|
+
*
|
|
589
|
+
* @param {string} globalName - Global the recorder lives on
|
|
590
|
+
* @returns {boolean} True when a recorder was stopped
|
|
591
|
+
*/
|
|
592
|
+
export function stopMutationRecorderInPage(globalName) {
|
|
593
|
+
const state = window[globalName || '__browserCommanderTrace__'];
|
|
594
|
+
if (!state || !state.observing) {
|
|
595
|
+
return false;
|
|
596
|
+
}
|
|
597
|
+
state.stop();
|
|
598
|
+
return true;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/* c8 ignore stop */
|