browser-commander 0.17.1 → 0.18.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 +7 -18
- package/CHANGELOG.md +48 -0
- package/README.md +84 -0
- package/eslint.config.js +5 -0
- package/package.json +4 -1
- package/scripts/version-and-commit.mjs +13 -2
- 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 +453 -0
- package/src/downloads/naming.js +246 -0
- package/src/downloads/sources.js +188 -0
- package/src/downloads/store.js +256 -0
- package/src/exports.js +72 -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 +323 -0
- package/src/traces/observers.js +246 -0
- package/src/traces/page-capture.js +365 -0
- package/src/traces/reader.js +226 -0
- package/src/traces/recorder.js +437 -0
- package/src/traces/redaction.js +231 -0
- package/src/traces/schema.js +165 -0
- package/src/traces/viewer.js +302 -0
- package/tests/e2e/click-readiness.e2e.test.js +240 -0
- package/tests/e2e/downloads.e2e.test.js +188 -0
- package/tests/e2e/traces.e2e.test.js +457 -0
- package/tests/helpers/click-fixtures.js +62 -0
- package/tests/helpers/download-fixtures.js +125 -0
- package/tests/helpers/download-server.js +109 -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 +198 -0
- package/tests/helpers/trace-server.js +117 -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 +354 -0
- package/tests/unit/downloads/naming.test.js +174 -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/scripts/check-release-format.test.js +4 -1
- package/tests/unit/scripts/push-failure-classifier.test.js +153 -0
- package/tests/unit/scripts/push-with-rebase-retry.test.js +302 -0
- package/tests/unit/tests/tracing.test.js +299 -0
- package/tests/unit/traces/bundle.test.js +260 -0
- package/tests/unit/traces/reader.test.js +208 -0
- package/tests/unit/traces/recorder.test.js +562 -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 +289 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The observers behind a trace's shared timeline (issue #87).
|
|
3
|
+
*
|
|
4
|
+
* Navigation, Browser Commander interactions, console output, page errors,
|
|
5
|
+
* dialogs, failed requests and downloads are reported by different objects in
|
|
6
|
+
* different engines. They all end up as records on one ordered timeline, and
|
|
7
|
+
* this module is the only place that knows how each of them is reached.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { TRACE_EVENT } from './schema.js';
|
|
11
|
+
|
|
12
|
+
/** Commander methods recorded as interactions while a trace is running. */
|
|
13
|
+
export const TRACED_INTERACTIONS = Object.freeze([
|
|
14
|
+
'click',
|
|
15
|
+
'clickButton',
|
|
16
|
+
'fill',
|
|
17
|
+
'fillTextArea',
|
|
18
|
+
'goto',
|
|
19
|
+
'pressKey',
|
|
20
|
+
'typeText',
|
|
21
|
+
'setContent',
|
|
22
|
+
'scrollIntoView',
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* What an interaction was aimed at.
|
|
27
|
+
*
|
|
28
|
+
* A selector and a URL are the useful part of an interaction; typed text is
|
|
29
|
+
* never taken, because that is where passwords are. Most commander methods
|
|
30
|
+
* take an options object, and a few legacy ones take the selector directly,
|
|
31
|
+
* so both shapes are read here.
|
|
32
|
+
*
|
|
33
|
+
* @param {*} argument - The interaction's first argument
|
|
34
|
+
* @returns {string|null} The selector or URL it acted on
|
|
35
|
+
*/
|
|
36
|
+
export function interactionTarget(argument) {
|
|
37
|
+
if (typeof argument === 'string') {
|
|
38
|
+
return argument;
|
|
39
|
+
}
|
|
40
|
+
if (!argument || typeof argument !== 'object') {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
for (const key of ['selector', 'url', 'locatorOrElement']) {
|
|
44
|
+
if (typeof argument[key] === 'string') {
|
|
45
|
+
return argument[key];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Subscribe to everything a running trace listens to.
|
|
53
|
+
*
|
|
54
|
+
* @param {Object} options - `{commander, page, eventSources, record, note}`
|
|
55
|
+
* @param {Object} [options.commander] - The commander being traced, when there
|
|
56
|
+
* is one; interactions and managed downloads are reached through it
|
|
57
|
+
* @param {Object} options.page - The page being traced
|
|
58
|
+
* @param {string[]} options.eventSources - Sources the caller asked for
|
|
59
|
+
* @param {Function} options.record - Writes one timeline record
|
|
60
|
+
* @param {Function} options.note - Reports an observer that could not attach
|
|
61
|
+
* @returns {Function[]} One detach function per attached observer
|
|
62
|
+
*/
|
|
63
|
+
export function attachTimelineObservers(options) {
|
|
64
|
+
const { commander, page, eventSources, record, note } = options;
|
|
65
|
+
const detachers = [];
|
|
66
|
+
|
|
67
|
+
function subscribe(source, attach) {
|
|
68
|
+
if (!eventSources.includes(source)) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
const detach = attach();
|
|
73
|
+
if (typeof detach === 'function') {
|
|
74
|
+
detachers.push(detach);
|
|
75
|
+
}
|
|
76
|
+
} catch (error) {
|
|
77
|
+
note(`could not observe ${source}: ${error.message}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function onPage(eventName, listener) {
|
|
82
|
+
page.on?.(eventName, listener);
|
|
83
|
+
return () =>
|
|
84
|
+
page.off?.(eventName, listener) ??
|
|
85
|
+
page.removeListener?.(eventName, listener);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
subscribe('navigation', () => {
|
|
89
|
+
const manager = commander?.navigationManager;
|
|
90
|
+
if (!manager) {
|
|
91
|
+
// Without the manager the engine's own event still gives ordering.
|
|
92
|
+
return onPage('framenavigated', (frame) => {
|
|
93
|
+
void record(TRACE_EVENT.NAVIGATION, {
|
|
94
|
+
phase: 'framenavigated',
|
|
95
|
+
url: typeof frame?.url === 'function' ? frame.url() : String(frame),
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
const started = (info) =>
|
|
100
|
+
void record(TRACE_EVENT.NAVIGATION, { phase: 'start', ...info });
|
|
101
|
+
const completed = (info) =>
|
|
102
|
+
void record(TRACE_EVENT.NAVIGATION, { phase: 'complete', ...info });
|
|
103
|
+
const changed = (info) =>
|
|
104
|
+
void record(TRACE_EVENT.NAVIGATION, { phase: 'urlchange', ...info });
|
|
105
|
+
manager.on('onNavigationStart', started);
|
|
106
|
+
manager.on('onNavigationComplete', completed);
|
|
107
|
+
manager.on('onUrlChange', changed);
|
|
108
|
+
return () => {
|
|
109
|
+
manager.off?.('onNavigationStart', started);
|
|
110
|
+
manager.off?.('onNavigationComplete', completed);
|
|
111
|
+
manager.off?.('onUrlChange', changed);
|
|
112
|
+
};
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
subscribe('console', () =>
|
|
116
|
+
onPage('console', (message) => {
|
|
117
|
+
void record(TRACE_EVENT.CONSOLE, {
|
|
118
|
+
level: typeof message?.type === 'function' ? message.type() : null,
|
|
119
|
+
text:
|
|
120
|
+
typeof message?.text === 'function'
|
|
121
|
+
? message.text()
|
|
122
|
+
: String(message),
|
|
123
|
+
});
|
|
124
|
+
})
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
subscribe('pageerror', () =>
|
|
128
|
+
onPage('pageerror', (error) => {
|
|
129
|
+
void record(TRACE_EVENT.PAGE_ERROR, {
|
|
130
|
+
message: error?.message ?? String(error),
|
|
131
|
+
stack: error?.stack ?? null,
|
|
132
|
+
});
|
|
133
|
+
})
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
subscribe('requestfailed', () =>
|
|
137
|
+
onPage('requestfailed', (request) => {
|
|
138
|
+
void record(TRACE_EVENT.REQUEST_FAILED, {
|
|
139
|
+
url: typeof request?.url === 'function' ? request.url() : null,
|
|
140
|
+
method: typeof request?.method === 'function' ? request.method() : null,
|
|
141
|
+
failure:
|
|
142
|
+
typeof request?.failure === 'function'
|
|
143
|
+
? (request.failure()?.errorText ?? null)
|
|
144
|
+
: null,
|
|
145
|
+
});
|
|
146
|
+
})
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
subscribe('dialog', () => {
|
|
150
|
+
const manager = commander?.dialogManager;
|
|
151
|
+
if (!manager?.observeDialogs && !manager?.onDialog) {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
const listener = (dialog) => {
|
|
155
|
+
void record(TRACE_EVENT.DIALOG, {
|
|
156
|
+
type: typeof dialog?.type === 'function' ? dialog.type() : null,
|
|
157
|
+
message:
|
|
158
|
+
typeof dialog?.message === 'function' ? dialog.message() : null,
|
|
159
|
+
});
|
|
160
|
+
};
|
|
161
|
+
// Recording a dialog must not decide what happens to it: a passive
|
|
162
|
+
// observer leaves the manager's auto-dismissal in place, so a traced run
|
|
163
|
+
// answers dialogs exactly the way the same untraced run would.
|
|
164
|
+
if (manager.observeDialogs) {
|
|
165
|
+
manager.observeDialogs(listener);
|
|
166
|
+
return () => manager.unobserveDialogs?.(listener);
|
|
167
|
+
}
|
|
168
|
+
manager.onDialog(listener);
|
|
169
|
+
return () => manager.offDialog?.(listener);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
subscribe('download', () => {
|
|
173
|
+
const downloads = commander?.downloads;
|
|
174
|
+
if (!downloads?.on) {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
const listeners = ['started', 'completed', 'failed', 'cancelled'].map(
|
|
178
|
+
(phase) => {
|
|
179
|
+
const listener = (artifact) => {
|
|
180
|
+
void record(TRACE_EVENT.DOWNLOAD, {
|
|
181
|
+
phase,
|
|
182
|
+
id: artifact?.id ?? null,
|
|
183
|
+
// The path and checksum are the reference; the bytes stay where
|
|
184
|
+
// the download manager put them.
|
|
185
|
+
suggestedFilename: artifact?.suggestedFilename ?? null,
|
|
186
|
+
path: artifact?.path ?? null,
|
|
187
|
+
checksum: artifact?.checksum ?? null,
|
|
188
|
+
bytes: artifact?.bytes ?? null,
|
|
189
|
+
url: artifact?.url ?? null,
|
|
190
|
+
failure: artifact?.failure ?? null,
|
|
191
|
+
});
|
|
192
|
+
};
|
|
193
|
+
downloads.on(phase, listener);
|
|
194
|
+
return [phase, listener];
|
|
195
|
+
}
|
|
196
|
+
);
|
|
197
|
+
return () => {
|
|
198
|
+
for (const [phase, listener] of listeners) {
|
|
199
|
+
downloads.off?.(phase, listener);
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
subscribe('interaction', () => {
|
|
205
|
+
if (!commander) {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
const originals = new Map();
|
|
209
|
+
for (const name of TRACED_INTERACTIONS) {
|
|
210
|
+
const original = commander[name];
|
|
211
|
+
if (typeof original !== 'function') {
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
originals.set(name, original);
|
|
215
|
+
commander[name] = async (...args) => {
|
|
216
|
+
const startedMs = Date.now();
|
|
217
|
+
const target = interactionTarget(args[0]);
|
|
218
|
+
try {
|
|
219
|
+
const result = await original(...args);
|
|
220
|
+
await record(TRACE_EVENT.INTERACTION, {
|
|
221
|
+
action: name,
|
|
222
|
+
target,
|
|
223
|
+
durationMs: Date.now() - startedMs,
|
|
224
|
+
ok: true,
|
|
225
|
+
});
|
|
226
|
+
return result;
|
|
227
|
+
} catch (error) {
|
|
228
|
+
await record(TRACE_EVENT.INTERACTION, {
|
|
229
|
+
action: name,
|
|
230
|
+
target,
|
|
231
|
+
durationMs: Date.now() - startedMs,
|
|
232
|
+
ok: false,
|
|
233
|
+
error: error.message,
|
|
234
|
+
});
|
|
235
|
+
throw error;
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
return () => {
|
|
240
|
+
for (const [name, original] of originals) {
|
|
241
|
+
commander[name] = original;
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
});
|
|
245
|
+
return detachers;
|
|
246
|
+
}
|
|
@@ -0,0 +1,365 @@
|
|
|
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 mutation recorder in the page.
|
|
205
|
+
*
|
|
206
|
+
* Reinstalling after a navigation is safe and expected: the queue lives on
|
|
207
|
+
* the document, which a navigation replaces.
|
|
208
|
+
*
|
|
209
|
+
* @param {Object} options - `{globalName, redactSelectors, redacted, maxQueued}`
|
|
210
|
+
* @returns {boolean} True when a recorder is now observing
|
|
211
|
+
*/
|
|
212
|
+
export function installMutationRecorderInPage(options) {
|
|
213
|
+
const {
|
|
214
|
+
globalName = '__browserCommanderTrace__',
|
|
215
|
+
redactSelectors = [],
|
|
216
|
+
redacted = '[redacted]',
|
|
217
|
+
maxQueued = 5000,
|
|
218
|
+
} = options || {};
|
|
219
|
+
|
|
220
|
+
if (window[globalName] && window[globalName].observing) {
|
|
221
|
+
return true;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const state = {
|
|
225
|
+
observing: true,
|
|
226
|
+
queue: [],
|
|
227
|
+
dropped: 0,
|
|
228
|
+
sequence: 0,
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const isSecret = (node) => {
|
|
232
|
+
const element =
|
|
233
|
+
node && node.nodeType === 1 ? node : node && node.parentElement;
|
|
234
|
+
if (!element || !element.matches) {
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
return redactSelectors.some((selector) => {
|
|
238
|
+
try {
|
|
239
|
+
return element.matches(selector) || element.closest(selector) !== null;
|
|
240
|
+
} catch {
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
// A path the viewer can look the node up by: replay needs to find the same
|
|
247
|
+
// element again in a parsed copy of the checkpoint.
|
|
248
|
+
const pathOf = (node) => {
|
|
249
|
+
const steps = [];
|
|
250
|
+
for (let at = node; at && at.nodeType === 1; at = at.parentElement) {
|
|
251
|
+
if (at.id) {
|
|
252
|
+
steps.unshift(`#${at.id}`);
|
|
253
|
+
break;
|
|
254
|
+
}
|
|
255
|
+
const siblings = at.parentElement
|
|
256
|
+
? [...at.parentElement.children].filter(
|
|
257
|
+
(c) => c.localName === at.localName
|
|
258
|
+
)
|
|
259
|
+
: [];
|
|
260
|
+
const nth =
|
|
261
|
+
siblings.length > 1 ? `:nth-of-type(${siblings.indexOf(at) + 1})` : '';
|
|
262
|
+
steps.unshift(at.localName + nth);
|
|
263
|
+
}
|
|
264
|
+
return steps.join(' > ');
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const describe = (node) => {
|
|
268
|
+
if (!node) {
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
if (node.nodeType === 3) {
|
|
272
|
+
return {
|
|
273
|
+
type: 'text',
|
|
274
|
+
path: pathOf(node.parentElement),
|
|
275
|
+
text: isSecret(node) ? redacted : String(node.nodeValue).slice(0, 2000),
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
if (node.nodeType !== 1) {
|
|
279
|
+
return { type: 'node', nodeType: node.nodeType };
|
|
280
|
+
}
|
|
281
|
+
return {
|
|
282
|
+
type: 'element',
|
|
283
|
+
tag: node.localName,
|
|
284
|
+
id: node.id || null,
|
|
285
|
+
path: pathOf(node),
|
|
286
|
+
html: isSecret(node) ? redacted : String(node.outerHTML).slice(0, 4000),
|
|
287
|
+
};
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
const observer = new MutationObserver((records) => {
|
|
291
|
+
if (state.queue.length >= maxQueued) {
|
|
292
|
+
state.dropped += records.length;
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
const batch = {
|
|
296
|
+
sequence: ++state.sequence,
|
|
297
|
+
at: Date.now(),
|
|
298
|
+
url: location.href,
|
|
299
|
+
records: records.map((record) => {
|
|
300
|
+
const entry = {
|
|
301
|
+
kind: record.type,
|
|
302
|
+
target: describe(record.target),
|
|
303
|
+
};
|
|
304
|
+
if (record.type === 'attributes') {
|
|
305
|
+
entry.attribute = record.attributeName;
|
|
306
|
+
const secret =
|
|
307
|
+
isSecret(record.target) ||
|
|
308
|
+
redactSelectors.includes(record.attributeName);
|
|
309
|
+
entry.before = secret ? redacted : record.oldValue;
|
|
310
|
+
entry.after =
|
|
311
|
+
secret || !record.target.getAttribute
|
|
312
|
+
? redacted
|
|
313
|
+
: record.target.getAttribute(record.attributeName);
|
|
314
|
+
} else if (record.type === 'characterData') {
|
|
315
|
+
entry.before = isSecret(record.target) ? redacted : record.oldValue;
|
|
316
|
+
entry.after = isSecret(record.target)
|
|
317
|
+
? redacted
|
|
318
|
+
: record.target.nodeValue;
|
|
319
|
+
} else {
|
|
320
|
+
entry.added = [...record.addedNodes].map(describe);
|
|
321
|
+
entry.removed = [...record.removedNodes].map(describe);
|
|
322
|
+
}
|
|
323
|
+
return entry;
|
|
324
|
+
}),
|
|
325
|
+
};
|
|
326
|
+
state.queue.push(batch);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
observer.observe(document, {
|
|
330
|
+
subtree: true,
|
|
331
|
+
childList: true,
|
|
332
|
+
attributes: true,
|
|
333
|
+
attributeOldValue: true,
|
|
334
|
+
characterData: true,
|
|
335
|
+
characterDataOldValue: true,
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
state.stop = () => {
|
|
339
|
+
observer.disconnect();
|
|
340
|
+
state.observing = false;
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
window[globalName] = state;
|
|
344
|
+
return true;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Take everything the recorder has queued and leave the queue empty.
|
|
349
|
+
*
|
|
350
|
+
* @param {string} globalName - Global the recorder lives on
|
|
351
|
+
* @returns {Object} `{batches, dropped}`
|
|
352
|
+
*/
|
|
353
|
+
export function drainMutationsInPage(globalName) {
|
|
354
|
+
const state = window[globalName || '__browserCommanderTrace__'];
|
|
355
|
+
if (!state) {
|
|
356
|
+
return { batches: [], dropped: 0, installed: false };
|
|
357
|
+
}
|
|
358
|
+
const batches = state.queue;
|
|
359
|
+
const dropped = state.dropped;
|
|
360
|
+
state.queue = [];
|
|
361
|
+
state.dropped = 0;
|
|
362
|
+
return { batches, dropped, installed: true };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/* c8 ignore stop */
|