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.
Files changed (74) hide show
  1. package/.jscpd-baseline.json +12 -17
  2. package/CHANGELOG.md +68 -0
  3. package/README.md +132 -0
  4. package/eslint.config.js +5 -0
  5. package/package.json +5 -1
  6. package/src/bindings.js +13 -1
  7. package/src/browser/connector.js +15 -3
  8. package/src/browser/launcher.js +14 -2
  9. package/src/browser/navigation.js +63 -0
  10. package/src/core/dialog-manager.js +48 -0
  11. package/src/core/navigation-manager.js +83 -87
  12. package/src/core/navigation-readiness.js +133 -0
  13. package/src/core/readiness.js +478 -0
  14. package/src/downloads/attach.js +58 -0
  15. package/src/downloads/destination.js +125 -0
  16. package/src/downloads/manager.js +476 -0
  17. package/src/downloads/naming.js +246 -0
  18. package/src/downloads/sources.js +238 -0
  19. package/src/downloads/staging.js +146 -0
  20. package/src/downloads/store.js +256 -0
  21. package/src/exports.js +86 -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 +338 -0
  29. package/src/traces/deadline.js +33 -0
  30. package/src/traces/identity.js +127 -0
  31. package/src/traces/init-script.js +95 -0
  32. package/src/traces/links.js +623 -0
  33. package/src/traces/mutation-stream.js +232 -0
  34. package/src/traces/observers.js +272 -0
  35. package/src/traces/page-capture.js +601 -0
  36. package/src/traces/reader.js +226 -0
  37. package/src/traces/recorder.js +454 -0
  38. package/src/traces/redaction.js +231 -0
  39. package/src/traces/schema.js +229 -0
  40. package/src/traces/viewer.js +448 -0
  41. package/tests/e2e/click-readiness.e2e.test.js +240 -0
  42. package/tests/e2e/downloads.e2e.test.js +235 -0
  43. package/tests/e2e/traces.e2e.test.js +742 -0
  44. package/tests/helpers/click-fixtures.js +62 -0
  45. package/tests/helpers/download-fixtures.js +125 -0
  46. package/tests/helpers/download-server.js +129 -0
  47. package/tests/helpers/e2e-browser.js +67 -0
  48. package/tests/helpers/file-modes.js +31 -0
  49. package/tests/helpers/fixture-server.js +56 -0
  50. package/tests/helpers/mocks.js +63 -66
  51. package/tests/helpers/readiness-server.js +96 -0
  52. package/tests/helpers/temp-directory.js +44 -0
  53. package/tests/helpers/trace-fixtures.js +274 -0
  54. package/tests/helpers/trace-server.js +173 -0
  55. package/tests/unit/core/dialog-manager.test.js +118 -10
  56. package/tests/unit/core/navigation-manager.test.js +162 -0
  57. package/tests/unit/core/readiness.test.js +517 -0
  58. package/tests/unit/downloads/attach.test.js +220 -0
  59. package/tests/unit/downloads/destination.test.js +114 -0
  60. package/tests/unit/downloads/manager.test.js +485 -0
  61. package/tests/unit/downloads/naming.test.js +174 -0
  62. package/tests/unit/downloads/staging.test.js +117 -0
  63. package/tests/unit/downloads/store.test.js +215 -0
  64. package/tests/unit/interactions/click-activation.test.js +283 -0
  65. package/tests/unit/interactions/click-result.test.js +90 -0
  66. package/tests/unit/interactions/click.test.js +409 -200
  67. package/tests/unit/tests/tracing.test.js +299 -0
  68. package/tests/unit/traces/bundle.test.js +260 -0
  69. package/tests/unit/traces/links.test.js +557 -0
  70. package/tests/unit/traces/reader.test.js +208 -0
  71. package/tests/unit/traces/recorder.test.js +730 -0
  72. package/tests/unit/traces/redaction.test.js +170 -0
  73. package/tests/unit/traces/schema.test.js +92 -0
  74. 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 */