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.
Files changed (70) hide show
  1. package/.jscpd-baseline.json +7 -18
  2. package/CHANGELOG.md +48 -0
  3. package/README.md +84 -0
  4. package/eslint.config.js +5 -0
  5. package/package.json +4 -1
  6. package/scripts/version-and-commit.mjs +13 -2
  7. package/src/bindings.js +13 -1
  8. package/src/browser/connector.js +15 -3
  9. package/src/browser/launcher.js +14 -2
  10. package/src/browser/navigation.js +63 -0
  11. package/src/core/dialog-manager.js +48 -0
  12. package/src/core/navigation-manager.js +83 -87
  13. package/src/core/navigation-readiness.js +133 -0
  14. package/src/core/readiness.js +478 -0
  15. package/src/downloads/attach.js +58 -0
  16. package/src/downloads/destination.js +125 -0
  17. package/src/downloads/manager.js +453 -0
  18. package/src/downloads/naming.js +246 -0
  19. package/src/downloads/sources.js +188 -0
  20. package/src/downloads/store.js +256 -0
  21. package/src/exports.js +72 -0
  22. package/src/factory.js +55 -0
  23. package/src/interactions/click-activation.js +317 -0
  24. package/src/interactions/click-result.js +108 -0
  25. package/src/interactions/click.js +766 -352
  26. package/src/tests/index.js +75 -11
  27. package/src/tests/tracing.js +236 -0
  28. package/src/traces/bundle.js +323 -0
  29. package/src/traces/observers.js +246 -0
  30. package/src/traces/page-capture.js +365 -0
  31. package/src/traces/reader.js +226 -0
  32. package/src/traces/recorder.js +437 -0
  33. package/src/traces/redaction.js +231 -0
  34. package/src/traces/schema.js +165 -0
  35. package/src/traces/viewer.js +302 -0
  36. package/tests/e2e/click-readiness.e2e.test.js +240 -0
  37. package/tests/e2e/downloads.e2e.test.js +188 -0
  38. package/tests/e2e/traces.e2e.test.js +457 -0
  39. package/tests/helpers/click-fixtures.js +62 -0
  40. package/tests/helpers/download-fixtures.js +125 -0
  41. package/tests/helpers/download-server.js +109 -0
  42. package/tests/helpers/e2e-browser.js +67 -0
  43. package/tests/helpers/file-modes.js +31 -0
  44. package/tests/helpers/fixture-server.js +56 -0
  45. package/tests/helpers/mocks.js +63 -66
  46. package/tests/helpers/readiness-server.js +96 -0
  47. package/tests/helpers/temp-directory.js +44 -0
  48. package/tests/helpers/trace-fixtures.js +198 -0
  49. package/tests/helpers/trace-server.js +117 -0
  50. package/tests/unit/core/dialog-manager.test.js +118 -10
  51. package/tests/unit/core/navigation-manager.test.js +162 -0
  52. package/tests/unit/core/readiness.test.js +517 -0
  53. package/tests/unit/downloads/attach.test.js +220 -0
  54. package/tests/unit/downloads/destination.test.js +114 -0
  55. package/tests/unit/downloads/manager.test.js +354 -0
  56. package/tests/unit/downloads/naming.test.js +174 -0
  57. package/tests/unit/downloads/store.test.js +215 -0
  58. package/tests/unit/interactions/click-activation.test.js +283 -0
  59. package/tests/unit/interactions/click-result.test.js +90 -0
  60. package/tests/unit/interactions/click.test.js +409 -200
  61. package/tests/unit/scripts/check-release-format.test.js +4 -1
  62. package/tests/unit/scripts/push-failure-classifier.test.js +153 -0
  63. package/tests/unit/scripts/push-with-rebase-retry.test.js +302 -0
  64. package/tests/unit/tests/tracing.test.js +299 -0
  65. package/tests/unit/traces/bundle.test.js +260 -0
  66. package/tests/unit/traces/reader.test.js +208 -0
  67. package/tests/unit/traces/recorder.test.js +562 -0
  68. package/tests/unit/traces/redaction.test.js +170 -0
  69. package/tests/unit/traces/schema.test.js +92 -0
  70. 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 */