browser-commander 0.18.0 → 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 +8 -0
- package/CHANGELOG.md +34 -0
- package/README.md +50 -2
- package/package.json +2 -1
- package/src/downloads/manager.js +31 -8
- package/src/downloads/sources.js +56 -6
- package/src/downloads/staging.js +146 -0
- package/src/exports.js +14 -0
- package/src/traces/bundle.js +16 -1
- 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 +29 -3
- package/src/traces/page-capture.js +251 -15
- package/src/traces/recorder.js +117 -100
- package/src/traces/schema.js +65 -1
- package/src/traces/viewer.js +157 -11
- package/tests/e2e/downloads.e2e.test.js +47 -0
- package/tests/e2e/traces.e2e.test.js +296 -11
- package/tests/helpers/download-server.js +26 -6
- package/tests/helpers/trace-fixtures.js +79 -3
- package/tests/helpers/trace-server.js +57 -1
- package/tests/unit/downloads/manager.test.js +131 -0
- package/tests/unit/downloads/staging.test.js +117 -0
- package/tests/unit/traces/links.test.js +557 -0
- package/tests/unit/traces/recorder.test.js +169 -1
- package/tests/unit/traces/viewer.test.js +5 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The continuous half of a trace (issue #93).
|
|
3
|
+
*
|
|
4
|
+
* A checkpoint is a photograph; this is the film between two of them. Keeping
|
|
5
|
+
* it here rather than inside the recorder is what lets it answer the three
|
|
6
|
+
* questions issue #93 said it could not: what happened while a new document was
|
|
7
|
+
* loading, what happened inside an iframe, and what happened to state the DOM
|
|
8
|
+
* never mentions.
|
|
9
|
+
*
|
|
10
|
+
* The in-page half lives in `page-capture.js` and is installed twice over: once
|
|
11
|
+
* into every document that already exists, and once as an init script so every
|
|
12
|
+
* document created from now on is observed before its own code runs.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { withDeadline } from './deadline.js';
|
|
16
|
+
import { framesOf, installInitScript } from './init-script.js';
|
|
17
|
+
import {
|
|
18
|
+
drainMutationsInPage,
|
|
19
|
+
installMutationRecorderInPage,
|
|
20
|
+
RECORDER_GLOBAL,
|
|
21
|
+
stopMutationRecorderInPage,
|
|
22
|
+
} from './page-capture.js';
|
|
23
|
+
import { REDACTED } from './redaction.js';
|
|
24
|
+
import { TRACE_DROP_REASON, TRACE_EVENT } from './schema.js';
|
|
25
|
+
|
|
26
|
+
/** In-page records kept before the recorder starts counting drops instead. */
|
|
27
|
+
export const DEFAULT_MAX_QUEUED_MUTATIONS = 5000;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Build the recorder's continuous stream.
|
|
31
|
+
*
|
|
32
|
+
* @param {Object} options - Collaborators the stream needs
|
|
33
|
+
* @param {Object} options.page - The page being traced
|
|
34
|
+
* @param {Object} options.bundle - Open trace bundle
|
|
35
|
+
* @param {Function} options.record - Writes one timeline record
|
|
36
|
+
* @param {Object} options.identity - Trace identity, see `identity.js`
|
|
37
|
+
* @param {Function} options.note - Reports something that could not be done
|
|
38
|
+
* @param {Object} options.domOptions - Resolved `dom` options
|
|
39
|
+
* @param {Object} options.privacyOptions - Resolved privacy options
|
|
40
|
+
* @param {Object} options.limits - Resolved limits
|
|
41
|
+
* @param {number} options.captureTimeoutMs - Budget for one drain
|
|
42
|
+
* @param {Function} options.evaluateInPage - Fallback for a page without frames
|
|
43
|
+
* @returns {Object} `{install, installPersistent, drain, stop}`
|
|
44
|
+
*/
|
|
45
|
+
export function createMutationStream(options) {
|
|
46
|
+
const {
|
|
47
|
+
page,
|
|
48
|
+
bundle,
|
|
49
|
+
record,
|
|
50
|
+
identity,
|
|
51
|
+
note,
|
|
52
|
+
domOptions,
|
|
53
|
+
privacyOptions,
|
|
54
|
+
limits,
|
|
55
|
+
captureTimeoutMs,
|
|
56
|
+
evaluateInPage,
|
|
57
|
+
} = options;
|
|
58
|
+
|
|
59
|
+
const enabled = Boolean(domOptions.mutations);
|
|
60
|
+
|
|
61
|
+
/** What the in-page recorder is built with, wherever it is installed. */
|
|
62
|
+
const recorderOptions = {
|
|
63
|
+
globalName: RECORDER_GLOBAL,
|
|
64
|
+
redactSelectors: privacyOptions.redactSelectors,
|
|
65
|
+
redacted: REDACTED,
|
|
66
|
+
maxQueued: limits.maxQueuedMutations ?? DEFAULT_MAX_QUEUED_MUTATIONS,
|
|
67
|
+
liveState: domOptions.liveState !== false,
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Run something in every frame of the page.
|
|
72
|
+
*
|
|
73
|
+
* An iframe is a separate document with a separate observer, so a trace that
|
|
74
|
+
* only ever spoke to the main frame recorded nothing that happened inside
|
|
75
|
+
* one. A frame that goes away mid-call is expected rather than a failure: a
|
|
76
|
+
* frame that has already gone has nothing left to report.
|
|
77
|
+
*
|
|
78
|
+
* @param {Function} fn - The function to evaluate
|
|
79
|
+
* @param {*} argument - Its argument
|
|
80
|
+
* @returns {Promise<Array>} One result per frame that answered
|
|
81
|
+
*/
|
|
82
|
+
async function evaluateInFrames(fn, argument) {
|
|
83
|
+
const frames = framesOf(page);
|
|
84
|
+
if (frames.length === 0) {
|
|
85
|
+
return [await evaluateInPage(fn, argument)];
|
|
86
|
+
}
|
|
87
|
+
const results = await Promise.all(
|
|
88
|
+
frames.map(async (frame) => {
|
|
89
|
+
try {
|
|
90
|
+
return await frame.evaluate(fn, argument);
|
|
91
|
+
} catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
);
|
|
96
|
+
return results.filter((result) => result !== null);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function dropped(
|
|
100
|
+
member,
|
|
101
|
+
error,
|
|
102
|
+
reason = TRACE_DROP_REASON.CAPTURE_FAILED
|
|
103
|
+
) {
|
|
104
|
+
await bundle.drop({
|
|
105
|
+
reason,
|
|
106
|
+
member,
|
|
107
|
+
detail: error.message ?? String(error),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Install the recorder into the documents that already exist.
|
|
113
|
+
*
|
|
114
|
+
* New documents are covered by the init script; this covers the one the trace
|
|
115
|
+
* started in, and any frame that was already open when it did.
|
|
116
|
+
*
|
|
117
|
+
* @returns {Promise<void>} Resolves once every frame has been asked
|
|
118
|
+
*/
|
|
119
|
+
async function install() {
|
|
120
|
+
if (!enabled) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
await evaluateInFrames(installMutationRecorderInPage, recorderOptions);
|
|
125
|
+
} catch (error) {
|
|
126
|
+
await dropped('mutation-recorder', error);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Register the recorder to run before the code of every future document.
|
|
132
|
+
*
|
|
133
|
+
* This is what closes the gap between a navigation and the next checkpoint:
|
|
134
|
+
* without it a new document's own scripts run unobserved, and everything they
|
|
135
|
+
* build looks, on replay, like it had been there all along.
|
|
136
|
+
*
|
|
137
|
+
* @returns {Promise<Function|null>} Detach, when the engine offers one
|
|
138
|
+
*/
|
|
139
|
+
async function installPersistent() {
|
|
140
|
+
if (!enabled) {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
return await installInitScript({
|
|
145
|
+
page,
|
|
146
|
+
fn: installMutationRecorderInPage,
|
|
147
|
+
arg: recorderOptions,
|
|
148
|
+
note,
|
|
149
|
+
});
|
|
150
|
+
} catch (error) {
|
|
151
|
+
await dropped('mutation-recorder-init', error);
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Take everything every frame has queued and write it as one interval.
|
|
158
|
+
*
|
|
159
|
+
* @param {number} index - Checkpoint the interval ends at
|
|
160
|
+
* @returns {Promise<string|null>} The member written, when there was anything
|
|
161
|
+
*/
|
|
162
|
+
async function drain(index) {
|
|
163
|
+
if (!enabled) {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
const drained = await withDeadline(
|
|
168
|
+
evaluateInFrames(drainMutationsInPage, RECORDER_GLOBAL),
|
|
169
|
+
captureTimeoutMs,
|
|
170
|
+
'trace mutation drain'
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
const batches = [];
|
|
174
|
+
let over = 0;
|
|
175
|
+
for (const frame of drained) {
|
|
176
|
+
over += frame?.dropped ?? 0;
|
|
177
|
+
for (const batch of frame?.batches ?? []) {
|
|
178
|
+
// The identity of the trace comes from here and the identity of the
|
|
179
|
+
// document comes from inside it: a frame handle is not something an
|
|
180
|
+
// NDJSON record can hold, so each document names itself.
|
|
181
|
+
batches.push({ ...identity.owner(), ...batch });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (over) {
|
|
186
|
+
await dropped(
|
|
187
|
+
'mutations',
|
|
188
|
+
new Error(`${over} records over the in-page queue limit`),
|
|
189
|
+
TRACE_DROP_REASON.SIZE_LIMIT
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Frames are drained in parallel, so the batches come back grouped by
|
|
194
|
+
// frame; one ordered file is what a replay reads.
|
|
195
|
+
batches.sort((left, right) => (left.at ?? 0) - (right.at ?? 0));
|
|
196
|
+
|
|
197
|
+
const member = await bundle.writeMutations(index, batches);
|
|
198
|
+
if (member) {
|
|
199
|
+
await record(TRACE_EVENT.MUTATIONS, {
|
|
200
|
+
member,
|
|
201
|
+
batches: batches.length,
|
|
202
|
+
checkpoint: index,
|
|
203
|
+
frames: drained.length,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
return member;
|
|
207
|
+
} catch (error) {
|
|
208
|
+
await dropped('mutations', error);
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Switch the recorder off in every document that has one.
|
|
215
|
+
*
|
|
216
|
+
* A recorder left observing would keep queueing into a queue nobody drains.
|
|
217
|
+
*
|
|
218
|
+
* @returns {Promise<void>} Resolves once every frame has been asked
|
|
219
|
+
*/
|
|
220
|
+
async function stop() {
|
|
221
|
+
if (!enabled) {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
try {
|
|
225
|
+
await evaluateInFrames(stopMutationRecorderInPage, RECORDER_GLOBAL);
|
|
226
|
+
} catch (error) {
|
|
227
|
+
note?.(`could not stop the in-page recorder: ${error.message}`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return { install, installPersistent, drain, stop };
|
|
232
|
+
}
|
package/src/traces/observers.js
CHANGED
|
@@ -51,17 +51,20 @@ export function interactionTarget(argument) {
|
|
|
51
51
|
/**
|
|
52
52
|
* Subscribe to everything a running trace listens to.
|
|
53
53
|
*
|
|
54
|
-
* @param {Object} options - `{commander, page, eventSources, record, note}`
|
|
54
|
+
* @param {Object} options - `{commander, page, eventSources, record, note, identity}`
|
|
55
55
|
* @param {Object} [options.commander] - The commander being traced, when there
|
|
56
56
|
* is one; interactions and managed downloads are reached through it
|
|
57
57
|
* @param {Object} options.page - The page being traced
|
|
58
58
|
* @param {string[]} options.eventSources - Sources the caller asked for
|
|
59
59
|
* @param {Function} options.record - Writes one timeline record
|
|
60
60
|
* @param {Function} options.note - Reports an observer that could not attach
|
|
61
|
+
* @param {Object} [options.identity] - Trace identity, see `identity.js`; the
|
|
62
|
+
* navigation observer is what moves it on to the next navigation, and the
|
|
63
|
+
* interaction observer is what names an action (issue #93)
|
|
61
64
|
* @returns {Function[]} One detach function per attached observer
|
|
62
65
|
*/
|
|
63
66
|
export function attachTimelineObservers(options) {
|
|
64
|
-
const { commander, page, eventSources, record, note } = options;
|
|
67
|
+
const { commander, page, eventSources, record, note, identity } = options;
|
|
65
68
|
const detachers = [];
|
|
66
69
|
|
|
67
70
|
function subscribe(source, attach) {
|
|
@@ -90,8 +93,19 @@ export function attachTimelineObservers(options) {
|
|
|
90
93
|
if (!manager) {
|
|
91
94
|
// Without the manager the engine's own event still gives ordering.
|
|
92
95
|
return onPage('framenavigated', (frame) => {
|
|
96
|
+
const isMain =
|
|
97
|
+
typeof frame?.parentFrame === 'function'
|
|
98
|
+
? frame.parentFrame() === null
|
|
99
|
+
: true;
|
|
100
|
+
// A record's navigation is the one it happened during, so the counter
|
|
101
|
+
// moves on before the record that reports the move is written.
|
|
102
|
+
const navigationId = isMain
|
|
103
|
+
? identity?.navigated()
|
|
104
|
+
: identity?.navigationId;
|
|
93
105
|
void record(TRACE_EVENT.NAVIGATION, {
|
|
94
106
|
phase: 'framenavigated',
|
|
107
|
+
navigationId,
|
|
108
|
+
mainFrame: isMain,
|
|
95
109
|
url: typeof frame?.url === 'function' ? frame.url() : String(frame),
|
|
96
110
|
});
|
|
97
111
|
});
|
|
@@ -99,7 +113,14 @@ export function attachTimelineObservers(options) {
|
|
|
99
113
|
const started = (info) =>
|
|
100
114
|
void record(TRACE_EVENT.NAVIGATION, { phase: 'start', ...info });
|
|
101
115
|
const completed = (info) =>
|
|
102
|
-
void record(TRACE_EVENT.NAVIGATION, {
|
|
116
|
+
void record(TRACE_EVENT.NAVIGATION, {
|
|
117
|
+
phase: 'complete',
|
|
118
|
+
// Everything recorded after this belongs to the document that just
|
|
119
|
+
// loaded, which is the distinction a replay needs to stop mixing two
|
|
120
|
+
// pages' changes together (issue #93).
|
|
121
|
+
navigationId: identity?.navigated(),
|
|
122
|
+
...info,
|
|
123
|
+
});
|
|
103
124
|
const changed = (info) =>
|
|
104
125
|
void record(TRACE_EVENT.NAVIGATION, { phase: 'urlchange', ...info });
|
|
105
126
|
manager.on('onNavigationStart', started);
|
|
@@ -215,9 +236,13 @@ export function attachTimelineObservers(options) {
|
|
|
215
236
|
commander[name] = async (...args) => {
|
|
216
237
|
const startedMs = Date.now();
|
|
217
238
|
const target = interactionTarget(args[0]);
|
|
239
|
+
// Named before the call, so everything the action causes can be traced
|
|
240
|
+
// back to it even when the action itself ends in an exception.
|
|
241
|
+
const actionId = identity?.nextActionId() ?? null;
|
|
218
242
|
try {
|
|
219
243
|
const result = await original(...args);
|
|
220
244
|
await record(TRACE_EVENT.INTERACTION, {
|
|
245
|
+
actionId,
|
|
221
246
|
action: name,
|
|
222
247
|
target,
|
|
223
248
|
durationMs: Date.now() - startedMs,
|
|
@@ -226,6 +251,7 @@ export function attachTimelineObservers(options) {
|
|
|
226
251
|
return result;
|
|
227
252
|
} catch (error) {
|
|
228
253
|
await record(TRACE_EVENT.INTERACTION, {
|
|
254
|
+
actionId,
|
|
229
255
|
action: name,
|
|
230
256
|
target,
|
|
231
257
|
durationMs: Date.now() - startedMs,
|
|
@@ -201,12 +201,22 @@ export function captureSnapshotInPage(options) {
|
|
|
201
201
|
}
|
|
202
202
|
|
|
203
203
|
/**
|
|
204
|
-
* Install the
|
|
204
|
+
* Install the in-page recorder.
|
|
205
205
|
*
|
|
206
|
-
* Reinstalling after a navigation is safe and expected: the queue lives on
|
|
207
|
-
*
|
|
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.
|
|
208
212
|
*
|
|
209
|
-
*
|
|
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}`
|
|
210
220
|
* @returns {boolean} True when a recorder is now observing
|
|
211
221
|
*/
|
|
212
222
|
export function installMutationRecorderInPage(options) {
|
|
@@ -215,6 +225,7 @@ export function installMutationRecorderInPage(options) {
|
|
|
215
225
|
redactSelectors = [],
|
|
216
226
|
redacted = '[redacted]',
|
|
217
227
|
maxQueued = 5000,
|
|
228
|
+
liveState = true,
|
|
218
229
|
} = options || {};
|
|
219
230
|
|
|
220
231
|
if (window[globalName] && window[globalName].observing) {
|
|
@@ -226,6 +237,11 @@ export function installMutationRecorderInPage(options) {
|
|
|
226
237
|
queue: [],
|
|
227
238
|
dropped: 0,
|
|
228
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,
|
|
229
245
|
};
|
|
230
246
|
|
|
231
247
|
const isSecret = (node) => {
|
|
@@ -264,6 +280,13 @@ export function installMutationRecorderInPage(options) {
|
|
|
264
280
|
return steps.join(' > ');
|
|
265
281
|
};
|
|
266
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
|
+
|
|
267
290
|
const describe = (node) => {
|
|
268
291
|
if (!node) {
|
|
269
292
|
return null;
|
|
@@ -287,15 +310,60 @@ export function installMutationRecorderInPage(options) {
|
|
|
287
310
|
};
|
|
288
311
|
};
|
|
289
312
|
|
|
290
|
-
|
|
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) => {
|
|
291
346
|
if (state.queue.length >= maxQueued) {
|
|
292
|
-
state.dropped +=
|
|
293
|
-
return;
|
|
347
|
+
state.dropped += 1;
|
|
348
|
+
return false;
|
|
294
349
|
}
|
|
295
|
-
|
|
350
|
+
state.queue.push({
|
|
296
351
|
sequence: ++state.sequence,
|
|
297
352
|
at: Date.now(),
|
|
298
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({
|
|
299
367
|
records: records.map((record) => {
|
|
300
368
|
const entry = {
|
|
301
369
|
kind: record.type,
|
|
@@ -317,13 +385,21 @@ export function installMutationRecorderInPage(options) {
|
|
|
317
385
|
? redacted
|
|
318
386
|
: record.target.nodeValue;
|
|
319
387
|
} else {
|
|
320
|
-
|
|
321
|
-
|
|
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);
|
|
322
399
|
}
|
|
323
400
|
return entry;
|
|
324
401
|
}),
|
|
325
|
-
};
|
|
326
|
-
state.queue.push(batch);
|
|
402
|
+
});
|
|
327
403
|
});
|
|
328
404
|
|
|
329
405
|
observer.observe(document, {
|
|
@@ -335,8 +411,139 @@ export function installMutationRecorderInPage(options) {
|
|
|
335
411
|
characterDataOldValue: true,
|
|
336
412
|
});
|
|
337
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
|
+
|
|
338
542
|
state.stop = () => {
|
|
339
543
|
observer.disconnect();
|
|
544
|
+
for (const detach of liveDetachers) {
|
|
545
|
+
detach();
|
|
546
|
+
}
|
|
340
547
|
state.observing = false;
|
|
341
548
|
};
|
|
342
549
|
|
|
@@ -348,18 +555,47 @@ export function installMutationRecorderInPage(options) {
|
|
|
348
555
|
* Take everything the recorder has queued and leave the queue empty.
|
|
349
556
|
*
|
|
350
557
|
* @param {string} globalName - Global the recorder lives on
|
|
351
|
-
* @returns {Object} `{batches, dropped}`
|
|
558
|
+
* @returns {Object} `{batches, dropped, installed, frameId, mainFrame}`
|
|
352
559
|
*/
|
|
353
560
|
export function drainMutationsInPage(globalName) {
|
|
354
561
|
const state = window[globalName || '__browserCommanderTrace__'];
|
|
355
562
|
if (!state) {
|
|
356
|
-
return {
|
|
563
|
+
return {
|
|
564
|
+
batches: [],
|
|
565
|
+
dropped: 0,
|
|
566
|
+
installed: false,
|
|
567
|
+
frameId: null,
|
|
568
|
+
mainFrame: window.top === window,
|
|
569
|
+
url: location.href,
|
|
570
|
+
};
|
|
357
571
|
}
|
|
358
572
|
const batches = state.queue;
|
|
359
573
|
const dropped = state.dropped;
|
|
360
574
|
state.queue = [];
|
|
361
575
|
state.dropped = 0;
|
|
362
|
-
return {
|
|
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;
|
|
363
599
|
}
|
|
364
600
|
|
|
365
601
|
/* c8 ignore stop */
|