browser-debugger-cli 0.12.0 → 0.13.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 (180) hide show
  1. package/.claude/skills/bdg/SKILL.md +4 -4
  2. package/README.md +1 -0
  3. package/dist/commands/cdp.d.ts +22 -1
  4. package/dist/commands/cdp.js +100 -43
  5. package/dist/commands/console.d.ts +12 -0
  6. package/dist/commands/console.js +62 -12
  7. package/dist/commands/dom/DomElementResolver.d.ts +3 -1
  8. package/dist/commands/dom/DomElementResolver.js +10 -3
  9. package/dist/commands/dom/a11y.js +3 -2
  10. package/dist/commands/dom/eval.d.ts +3 -2
  11. package/dist/commands/dom/eval.js +11 -5
  12. package/dist/commands/dom/form.js +10 -9
  13. package/dist/commands/dom/formInteraction.js +8 -7
  14. package/dist/commands/dom/get.js +8 -8
  15. package/dist/commands/dom/helpers/index.d.ts +1 -1
  16. package/dist/commands/dom/helpers/index.js +1 -1
  17. package/dist/commands/dom/helpers/query.d.ts +27 -3
  18. package/dist/commands/dom/helpers/query.js +152 -64
  19. package/dist/commands/dom/helpers/screenshot.js +13 -13
  20. package/dist/commands/dom/index.js +4 -2
  21. package/dist/commands/dom/query.d.ts +19 -2
  22. package/dist/commands/dom/query.js +37 -6
  23. package/dist/commands/dom/screenshot.js +2 -1
  24. package/dist/commands/dom/semanticUtils.d.ts +3 -2
  25. package/dist/commands/dom/semanticUtils.js +40 -9
  26. package/dist/commands/helpJson.d.ts +82 -19
  27. package/dist/commands/helpJson.js +111 -40
  28. package/dist/commands/helpTopic.d.ts +16 -1
  29. package/dist/commands/helpTopic.js +59 -1
  30. package/dist/commands/installSkill.d.ts +15 -5
  31. package/dist/commands/installSkill.js +86 -16
  32. package/dist/commands/network/list.js +22 -12
  33. package/dist/commands/optionBehaviors.js +33 -11
  34. package/dist/commands/shared/daemonErrorHandler.d.ts +5 -2
  35. package/dist/commands/shared/daemonErrorHandler.js +20 -9
  36. package/dist/commands/shared/dataFetcher.d.ts +12 -4
  37. package/dist/commands/shared/dataFetcher.js +12 -4
  38. package/dist/commands/shared/followMode.d.ts +9 -1
  39. package/dist/commands/shared/followMode.js +22 -4
  40. package/dist/commands/shared/optionTypes.d.ts +4 -1
  41. package/dist/commands/shared/outputFile.js +6 -1
  42. package/dist/commands/start.d.ts +7 -5
  43. package/dist/commands/start.js +65 -21
  44. package/dist/commands/stop.d.ts +11 -0
  45. package/dist/commands/stop.js +24 -1
  46. package/dist/commands.js +1 -1
  47. package/dist/connection/cdp.d.ts +7 -0
  48. package/dist/connection/cdp.js +9 -0
  49. package/dist/connection/launcher.js +3 -2
  50. package/dist/daemon/SessionController.js +6 -1
  51. package/dist/daemon/launcher.d.ts +3 -2
  52. package/dist/daemon/launcher.js +47 -3
  53. package/dist/daemon/session/Session.d.ts +4 -1
  54. package/dist/daemon/session/Session.js +33 -2
  55. package/dist/daemon/session/TelemetryStore.d.ts +8 -1
  56. package/dist/daemon/session/TelemetryStore.js +13 -1
  57. package/dist/daemon/session/commandRegistry.js +29 -13
  58. package/dist/daemon/session/interactions.d.ts +2 -1
  59. package/dist/daemon/session/interactions.js +13 -1
  60. package/dist/daemon/session/plugins.js +16 -2
  61. package/dist/daemon/session/teardown.js +1 -1
  62. package/dist/daemon.js +1622 -748
  63. package/dist/errors/messages.d.ts +54 -11
  64. package/dist/errors/messages.js +109 -22
  65. package/dist/index.js +13733 -8796
  66. package/dist/ipc/client.d.ts +18 -2
  67. package/dist/ipc/client.js +26 -5
  68. package/dist/ipc/protocol/auditTypes.d.ts +8 -2
  69. package/dist/ipc/protocol/commands.d.ts +12 -0
  70. package/dist/ipc/protocol/domTypes.d.ts +12 -0
  71. package/dist/ipc/protocol/inspectTypes.d.ts +2 -0
  72. package/dist/ipc/session/types.d.ts +2 -0
  73. package/dist/runtime/dom/actionEffects.d.ts +5 -1
  74. package/dist/runtime/dom/actionEffects.js +26 -14
  75. package/dist/runtime/dom/audit.js +3 -2
  76. package/dist/runtime/dom/auditModel.js +6 -1
  77. package/dist/runtime/dom/auditScripts.d.ts +9 -3
  78. package/dist/runtime/dom/auditScripts.js +41 -5
  79. package/dist/runtime/dom/elementGeometry.d.ts +10 -3
  80. package/dist/runtime/dom/elementGeometry.js +27 -4
  81. package/dist/runtime/dom/elementInfo.d.ts +74 -18
  82. package/dist/runtime/dom/elementInfo.js +187 -40
  83. package/dist/runtime/dom/evalHelpers.d.ts +12 -2
  84. package/dist/runtime/dom/evalHelpers.js +67 -7
  85. package/dist/runtime/dom/formDiscovery.d.ts +6 -2
  86. package/dist/runtime/dom/formDiscovery.js +20 -3
  87. package/dist/runtime/dom/formFillHelpers/fill.js +7 -11
  88. package/dist/runtime/dom/formFillHelpers/pressKey.js +2 -2
  89. package/dist/runtime/dom/formFillHelpers/shared.d.ts +16 -10
  90. package/dist/runtime/dom/formFillHelpers/shared.js +19 -52
  91. package/dist/runtime/dom/formSubmitHelpers.js +4 -3
  92. package/dist/runtime/dom/frameLayout.js +1 -0
  93. package/dist/runtime/dom/inspect.js +5 -6
  94. package/dist/runtime/dom/inspectAllStyles.js +1 -0
  95. package/dist/runtime/dom/inspectHints.d.ts +1 -1
  96. package/dist/runtime/dom/inspectModel.d.ts +2 -1
  97. package/dist/runtime/dom/inspectModel.js +7 -3
  98. package/dist/runtime/dom/inspectPaintModel.d.ts +2 -0
  99. package/dist/runtime/dom/inspectPaintModel.js +3 -1
  100. package/dist/runtime/dom/inspectScripts.d.ts +29 -2
  101. package/dist/runtime/dom/inspectScripts.js +49 -10
  102. package/dist/runtime/dom/layout.js +9 -7
  103. package/dist/runtime/dom/reactEventHelpers.d.ts +14 -4
  104. package/dist/runtime/dom/reactEventHelpers.js +63 -27
  105. package/dist/runtime/dom/targetNode.d.ts +18 -5
  106. package/dist/runtime/dom/targetNode.js +268 -8
  107. package/dist/runtime/dom/wait.js +2 -1
  108. package/dist/runtime/page/bdgWorld.d.ts +57 -0
  109. package/dist/runtime/page/bdgWorld.js +180 -0
  110. package/dist/runtime/page/replacedBuiltins.d.ts +28 -0
  111. package/dist/runtime/page/replacedBuiltins.js +136 -0
  112. package/dist/session/QueryCacheManager.d.ts +4 -1
  113. package/dist/session/QueryCacheManager.js +5 -2
  114. package/dist/session/chrome.d.ts +4 -1
  115. package/dist/session/chrome.js +7 -1
  116. package/dist/session/cleanup/staleSession.d.ts +21 -4
  117. package/dist/session/cleanup/staleSession.js +79 -9
  118. package/dist/session/cleanup/userCommands.d.ts +4 -1
  119. package/dist/session/cleanup/userCommands.js +10 -5
  120. package/dist/session/daemonSocket.d.ts +10 -0
  121. package/dist/session/daemonSocket.js +22 -0
  122. package/dist/session/lastSession.d.ts +6 -3
  123. package/dist/session/lastSession.js +11 -5
  124. package/dist/session/paths.d.ts +3 -1
  125. package/dist/session/paths.js +5 -5
  126. package/dist/session/portClaims.js +4 -3
  127. package/dist/session/sessionList.d.ts +13 -5
  128. package/dist/session/sessionList.js +31 -7
  129. package/dist/telemetry/a11y.js +2 -2
  130. package/dist/telemetry/console.d.ts +2 -1
  131. package/dist/telemetry/console.js +30 -21
  132. package/dist/telemetry/pageCrash.d.ts +26 -0
  133. package/dist/telemetry/pageCrash.js +53 -0
  134. package/dist/types.d.ts +16 -0
  135. package/dist/ui/formatters/audit.js +14 -5
  136. package/dist/ui/formatters/cdp.d.ts +138 -0
  137. package/dist/ui/formatters/cdp.js +131 -0
  138. package/dist/ui/formatters/console/chronological.js +3 -1
  139. package/dist/ui/formatters/console/follow.d.ts +2 -1
  140. package/dist/ui/formatters/console/follow.js +2 -2
  141. package/dist/ui/formatters/console/json.d.ts +2 -2
  142. package/dist/ui/formatters/console/json.js +11 -5
  143. package/dist/ui/formatters/console/shared.d.ts +30 -0
  144. package/dist/ui/formatters/console/shared.js +16 -0
  145. package/dist/ui/formatters/console/summarize.d.ts +9 -2
  146. package/dist/ui/formatters/console/summarize.js +40 -9
  147. package/dist/ui/formatters/console.d.ts +2 -1
  148. package/dist/ui/formatters/console.js +7 -5
  149. package/dist/ui/formatters/details.js +3 -1
  150. package/dist/ui/formatters/dom.d.ts +1 -1
  151. package/dist/ui/formatters/dom.js +5 -6
  152. package/dist/ui/formatters/helpFormatters.js +1 -1
  153. package/dist/ui/formatters/inspect.js +9 -3
  154. package/dist/ui/formatters/installSkill.d.ts +9 -1
  155. package/dist/ui/formatters/installSkill.js +32 -6
  156. package/dist/ui/formatters/layout.js +2 -1
  157. package/dist/ui/formatters/networkList.d.ts +1 -1
  158. package/dist/ui/formatters/networkList.js +1 -2
  159. package/dist/ui/formatters/preview.d.ts +2 -0
  160. package/dist/ui/formatters/preview.js +17 -7
  161. package/dist/ui/formatters/sessions.d.ts +2 -2
  162. package/dist/ui/formatters/sessions.js +9 -2
  163. package/dist/ui/logging/logger.d.ts +1 -1
  164. package/dist/ui/messages/commands.d.ts +124 -4
  165. package/dist/ui/messages/commands.js +162 -7
  166. package/dist/ui/messages/consoleMessages.d.ts +24 -0
  167. package/dist/ui/messages/consoleMessages.js +32 -0
  168. package/dist/ui/messages/preview.d.ts +6 -0
  169. package/dist/ui/messages/preview.js +9 -1
  170. package/dist/ui/messages/session.d.ts +13 -2
  171. package/dist/ui/messages/session.js +22 -3
  172. package/dist/utils/directories.d.ts +34 -0
  173. package/dist/utils/directories.js +88 -0
  174. package/dist/utils/display.d.ts +16 -0
  175. package/dist/utils/display.js +42 -0
  176. package/dist/utils/exitCodes.d.ts +1 -0
  177. package/dist/utils/exitCodes.js +6 -0
  178. package/dist/utils/process.d.ts +12 -0
  179. package/dist/utils/process.js +25 -0
  180. package/package.json +1 -1
@@ -7,8 +7,17 @@
7
7
  */
8
8
  import { FILL_REFUSALS, LABEL_WITHOUT_CONTROL, NAME_QUERY_PLACEHOLDER, VIA_LABEL_SUFFIX, } from '../../errors/messages.js';
9
9
  import { REVEAL_SNAPSHOT_JS } from './actionEffectsScripts.js';
10
- import { ELEMENT_DESCRIPTION_JS, ELEMENT_IDENTITY_JS } from './elementInfo.js';
10
+ import { DISABLED_CAUSE_JS, ELEMENT_DESCRIPTION_JS, ELEMENT_IDENTITY_JS, } from './elementInfo.js';
11
11
  import { FIND_ELEMENTS_JS, LABEL_CONTROL_JS } from './targetNode.js';
12
+ /**
13
+ * Page-side copy of a list (`select.options`, a NodeList) as an array,
14
+ * without `Array.from`, which pages replace.
15
+ */
16
+ const LIST_JS = `(list) => {
17
+ const items = [];
18
+ for (let i = 0; i < list.length; i++) items[items.length] = list[i];
19
+ return items;
20
+ }`;
12
21
  /**
13
22
  * Page-side read-back of a filled field: a mismatch when its value is not
14
23
  * what was asked for (the page rejected, reformatted or moved the input),
@@ -28,7 +37,7 @@ export const FILL_VALUE_MISMATCH_JS = `(field, expected) => {
28
37
  : type === 'checkbox' || type === 'radio'
29
38
  ? (field.checked ? 'checked' : 'unchecked')
30
39
  : field.localName === 'select' && field.multiple
31
- ? Array.from(field.selectedOptions).map((o) => o.value).join(', ')
40
+ ? (${LIST_JS})(field.selectedOptions).map((o) => o.value).join(', ')
32
41
  : String(field.value);
33
42
  const time = (text) => text.trim().replace(' ', 'T').replace(/(\\d\\d:\\d\\d):00(\\.0+)?$/, '$1');
34
43
  const normalize = (text) => {
@@ -81,9 +90,8 @@ export const FILL_REFUSAL_JS = `(el) => {
81
90
  });
82
91
  const tag = el.localName;
83
92
  if (tag === 'input' || tag === 'textarea' || tag === 'select') {
84
- if (el.disabled || el.matches(':disabled')) {
85
- return refuse('disabled', el.hasAttribute('disabled') ? 'disabled attribute' : el.closest('fieldset[disabled]') ? 'inside a disabled <fieldset>' : '');
86
- }
93
+ const disabled = (${DISABLED_CAUSE_JS})(el);
94
+ if (disabled !== null) return refuse('disabled', disabled);
87
95
  return el.readOnly ? refuse('readOnly', 'readonly attribute') : null;
88
96
  }
89
97
  if (el.isContentEditable) return null;
@@ -111,6 +119,17 @@ export const SHADOW_FIELD_JS = `(el) => {
111
119
  const fields = el.shadowRoot.querySelectorAll('input:not([type="hidden"]), textarea, select, [contenteditable]:not([contenteditable="false"])');
112
120
  return fields.length === 1 ? fields[0] : null;
113
121
  }`;
122
+ /**
123
+ * Page-side: fire a bubbling, non-cancelable event of a type on an element,
124
+ * like `new Event(type, { bubbles: true })`. The event comes from
125
+ * `document.createEvent`, which keeps working on pages that replace the
126
+ * `Event` constructor (MooTools 1.2 does).
127
+ */
128
+ export const FIRE_EVENT_JS = `(target, type) => {
129
+ const event = target.ownerDocument.createEvent('Event');
130
+ event.initEvent(type, true, false);
131
+ return target.dispatchEvent(event);
132
+ }`;
114
133
  /**
115
134
  * JavaScript function to fill an input element in a React-compatible way.
116
135
  *
@@ -132,6 +151,8 @@ export const SHADOW_FIELD_JS = `(el) => {
132
151
  export const REACT_FILL_SCRIPT = `
133
152
  (function(selector, parts, value, options) {
134
153
  const allMatches = (${FIND_ELEMENTS_JS})(selector, parts);
154
+ const fire = ${FIRE_EVENT_JS};
155
+ const listOf = ${LIST_JS};
135
156
  const warnings = [];
136
157
  let expected = value;
137
158
  // Why a user could not reach the field (the value is still set, so scripted
@@ -243,7 +264,7 @@ export const REACT_FILL_SCRIPT = `
243
264
  if (tagName === 'select' && el.multiple) {
244
265
  // Several options, separated by commas (an option whose value contains a
245
266
  // comma matches as a whole); "" selects none
246
- const options = Array.from(el.options);
267
+ const options = listOf(el.options);
247
268
  const whole = options.some((o) => o.value === value || o.text.trim() === value);
248
269
  const wanted = whole ? [value] : value.split(',').map((part) => part.trim()).filter(Boolean);
249
270
  const chosen = wanted.map((part) =>
@@ -261,11 +282,11 @@ export const REACT_FILL_SCRIPT = `
261
282
  }
262
283
  options.forEach((o) => { o.selected = chosen.includes(o); });
263
284
  expected = chosen.map((o) => o.value).join(', ');
264
- el.dispatchEvent(new Event('input', { bubbles: true }));
265
- el.dispatchEvent(new Event('change', { bubbles: true }));
285
+ fire(el, 'input');
286
+ fire(el, 'change');
266
287
  } else if (tagName === 'select') {
267
288
  // Match by option value first, then by visible label
268
- const options = Array.from(el.options);
289
+ const options = listOf(el.options);
269
290
  const option =
270
291
  options.find((o) => o.value === value) ||
271
292
  options.find((o) => o.text.trim() === value);
@@ -280,8 +301,8 @@ export const REACT_FILL_SCRIPT = `
280
301
  }
281
302
  el.value = option.value;
282
303
  expected = option.value;
283
- el.dispatchEvent(new Event('input', { bubbles: true }));
284
- el.dispatchEvent(new Event('change', { bubbles: true }));
304
+ fire(el, 'input');
305
+ fire(el, 'change');
285
306
  } else if (inputType === 'checkbox' || inputType === 'radio') {
286
307
  // Toggle through a click, like a user: frameworks (React) track checkable
287
308
  // state via click events and revert a programmatic \`checked\` assignment
@@ -321,7 +342,7 @@ export const REACT_FILL_SCRIPT = `
321
342
  };
322
343
  } else if (el.isContentEditable) {
323
344
  el.textContent = value;
324
- el.dispatchEvent(new Event('input', { bubbles: true }));
345
+ fire(el, 'input');
325
346
  } else {
326
347
  const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
327
348
  el.ownerDocument.defaultView.HTMLInputElement.prototype,
@@ -363,8 +384,8 @@ export const REACT_FILL_SCRIPT = `
363
384
  };
364
385
  }
365
386
 
366
- el.dispatchEvent(new Event('input', { bubbles: true }));
367
- el.dispatchEvent(new Event('change', { bubbles: true }));
387
+ fire(el, 'input');
388
+ fire(el, 'change');
368
389
  if (el.validity && (el.validity.rangeOverflow || el.validity.rangeUnderflow)) {
369
390
  warnings.push('The value is outside the allowed range (' + (el.min || 'no minimum') + ' to ' + (el.max || 'no maximum') + '); the form will not submit until it is fixed');
370
391
  }
@@ -383,7 +404,7 @@ export const REACT_FILL_SCRIPT = `
383
404
  : inputType === 'password'
384
405
  ? '********'
385
406
  : tagName === 'select' && el.multiple
386
- ? Array.from(el.selectedOptions).map((o) => o.value).join(', ')
407
+ ? listOf(el.selectedOptions).map((o) => o.value).join(', ')
387
408
  : el.value,
388
409
  element: (${ELEMENT_IDENTITY_JS})(el),
389
410
  elementType: tagName + viaLabel + viaShadow,
@@ -445,7 +466,7 @@ export const FILL_READ_BACK_SCRIPT = `(() => {
445
466
  /**
446
467
  * JavaScript function to locate an element for clicking.
447
468
  *
448
- * Scrolls the element into view and reports its center point and whether it
469
+ * Scrolls the element into view (reporting how far the page moved) and reports its center point and whether it
449
470
  * is the topmost element there (`hittable`). It does not click; see
450
471
  * `clickElement`, which dispatches real mouse events or falls back to
451
472
  * `el.click()`.
@@ -461,6 +482,9 @@ export const FILL_READ_BACK_SCRIPT = `(() => {
461
482
  * a press, a probe (`window.__bdgPressProbe`) records whether the press
462
483
  * reaches the element, and otherwise which element it landed on: a browser
463
484
  * dialog or bubble can swallow input while the page looks normal.
485
+ * Slotted text hit-tests as its shadow host, so an element in a shadow root
486
+ * that shows slotted content (a button labelled through a `<slot>`) is
487
+ * topmost where its host is hit.
464
488
  */
465
489
  export const CLICK_ELEMENT_SCRIPT = `
466
490
  (function(selector, parts, index, action) {
@@ -540,7 +564,7 @@ export const CLICK_ELEMENT_SCRIPT = `
540
564
  };
541
565
  }
542
566
  // Disabled elements still get hover (tooltips often explain why)
543
- if (action !== 'hover' && (el.disabled || el.matches(':disabled'))) {
567
+ if (action !== 'hover' && (${DISABLED_CAUSE_JS})(el) !== null) {
544
568
  return {
545
569
  success: false,
546
570
  error: 'Element is disabled',
@@ -549,7 +573,11 @@ export const CLICK_ELEMENT_SCRIPT = `
549
573
  suggestion: 'A user cannot click a disabled element; enable it first (it may depend on other fields)'
550
574
  };
551
575
  }
552
- el.scrollIntoView({ behavior: 'auto', block: 'center' });
576
+ let topView = el.ownerDocument.defaultView;
577
+ while (topView.frameElement) topView = topView.parent;
578
+ const scrollBefore = { x: topView.scrollX, y: topView.scrollY };
579
+ el.scrollIntoView({ behavior: 'instant', block: 'center' });
580
+ const scrolledBy = { x: Math.round(topView.scrollX - scrollBefore.x), y: Math.round(topView.scrollY - scrollBefore.y) };
553
581
 
554
582
  // The caller clicks with real mouse events at (x, y) when the element is the
555
583
  // topmost thing there; otherwise it falls back to el.click() via this handle.
@@ -560,10 +588,11 @@ export const CLICK_ELEMENT_SCRIPT = `
560
588
  window.__bdgClickTarget = el;
561
589
  const rect = el.getBoundingClientRect();
562
590
  const view = el.ownerDocument.defaultView;
591
+ const showsSlotted = (node) => Array.from(node.querySelectorAll('slot')).some((slot) => slot.assignedNodes().length > 0);
563
592
  const hitTest = (node, px, py) => {
564
593
  const root = node.getRootNode();
565
594
  const hit = (typeof root.elementFromPoint === 'function' ? root : node.ownerDocument).elementFromPoint(px, py);
566
- return hit !== null && (hit === node || node.contains(hit));
595
+ return hit !== null && (hit === node || node.contains(hit) || (hit === root.host && showsSlotted(node)));
567
596
  };
568
597
  const toTopPage = (px, py) => {
569
598
  let hittable = hitTest(el, px, py);
@@ -587,16 +616,16 @@ export const CLICK_ELEMENT_SCRIPT = `
587
616
  const fractions = [[0.5, 0.5], [0.5, 0.25], [0.5, 0.75], [0.25, 0.5], [0.75, 0.5], [0.25, 0.25], [0.75, 0.25], [0.25, 0.75], [0.75, 0.75]];
588
617
  const hasSize = rect.width > 0 && rect.height > 0;
589
618
  let point = null;
590
- for (const [fx, fy] of hasSize ? fractions : [[0.5, 0.5]]) {
591
- const candidate = toTopPage(area.left + area.width * fx, area.top + area.height * fy);
619
+ const tried = hasSize ? fractions : [[0.5, 0.5]];
620
+ for (let i = 0; i < tried.length; i++) {
621
+ const candidate = toTopPage(area.left + area.width * tried[i][0], area.top + area.height * tried[i][1]);
592
622
  point = point || candidate;
593
623
  if (hasSize && candidate.hittable) { point = candidate; break; }
594
624
  }
595
625
  const x = point.x;
596
626
  const y = point.y;
597
627
  const hittable = hasSize && point.hittable;
598
- const describe = (node) => node.tagName.toLowerCase() + (node.id ? '#' + node.id : '') +
599
- (node.classList && node.classList.length ? '.' + Array.from(node.classList).slice(0, 2).join('.') : '');
628
+ const describe = ${ELEMENT_DESCRIPTION_JS};
600
629
  const coveredBy = () => {
601
630
  const root = el.getRootNode();
602
631
  const hit = (typeof root.elementFromPoint === 'function' ? root : el.ownerDocument)
@@ -618,11 +647,17 @@ export const CLICK_ELEMENT_SCRIPT = `
618
647
  const probe = { reached: false, landedOn: null };
619
648
  const markReached = (event) => {
620
649
  const path = event.composedPath();
621
- if (path.includes(el)) probe.reached = true;
650
+ let reached = false;
651
+ for (let i = 0; i < path.length; i++) reached = reached || path[i] === el;
652
+ if (reached) probe.reached = true;
622
653
  else if (!probe.landedOn && path[0] && path[0].nodeType === 1) probe.landedOn = describe(path[0]);
623
654
  };
624
- ['pointerdown', 'mousedown'].forEach((type) => view.addEventListener(type, markReached, true));
625
- probe.stop = () => ['pointerdown', 'mousedown'].forEach((type) => view.removeEventListener(type, markReached, true));
655
+ view.addEventListener('pointerdown', markReached, true);
656
+ view.addEventListener('mousedown', markReached, true);
657
+ probe.stop = () => {
658
+ view.removeEventListener('pointerdown', markReached, true);
659
+ view.removeEventListener('mousedown', markReached, true);
660
+ };
626
661
  window.__bdgPressProbe = probe;
627
662
  }
628
663
 
@@ -636,7 +671,8 @@ export const CLICK_ELEMENT_SCRIPT = `
636
671
  x: x,
637
672
  y: y,
638
673
  hittable: hittable,
639
- obstruction: obstruction
674
+ obstruction: obstruction,
675
+ scrolledBy: scrolledBy.x !== 0 || scrolledBy.y !== 0 ? scrolledBy : undefined
640
676
  };
641
677
  })
642
678
  `;
@@ -12,8 +12,8 @@ import type { CDPConnection } from '../../connection/cdp.js';
12
12
  import { CommandError } from '../../errors/index.js';
13
13
  /** Selector placeholder that makes page scripts use the bound node. */
14
14
  export declare const BOUND_TARGET_SELECTOR = "__bdg_bound_target__";
15
- /** Removes the node bound for index-based commands from the window it was stored on */
16
- export declare const UNBIND_TARGET_SCRIPT = "delete window.__bdgTarget";
15
+ /** Removes the node bound for index-based commands, and matches bound for a selector, from the window they were stored on */
16
+ export declare const UNBIND_TARGET_SCRIPT = "delete window.__bdgTarget; delete window.__bdgMatches";
17
17
  /**
18
18
  * Page-side matching of filters ({@link SelectorFilter}) and scoped steps
19
19
  * ({@link ScopedStep}): called with the open shadow roots of the page, returns
@@ -54,9 +54,11 @@ export declare const DEEP_QUERY_JS = "function (selector, parts) {\n const css
54
54
  * Page-side element lookup shared by the interaction scripts.
55
55
  *
56
56
  * Returns the bound node (if still in the page) for the placeholder selector,
57
- * otherwise all matches of the selector ({@link DEEP_QUERY_JS}).
57
+ * the matches bound for the selector when the page replaced the search
58
+ * built-ins ({@link bindMatches}), otherwise all matches of the selector
59
+ * ({@link DEEP_QUERY_JS}).
58
60
  */
59
- export declare const FIND_ELEMENTS_JS = "function (selector, parts) {\n if (selector === '__bdg_bound_target__') {\n const el = window.__bdgTarget;\n return el && el.isConnected ? [el] : [];\n }\n return (function (selector, parts) {\n const css = parts ? parts.map((part) => part.css).join(', ') : selector;\n const found = [];\n const roots = [];\n const visit = (root) => {\n roots.push(root);\n for (const match of root.querySelectorAll(css)) found.push(match);\n for (const el of root.querySelectorAll('*')) {\n if (el.shadowRoot) visit(el.shadowRoot);\n if (el.tagName === 'IFRAME' || el.tagName === 'FRAME') {\n let frameDocument = null;\n try { frameDocument = el.contentDocument; } catch (e) { frameDocument = null; }\n if (frameDocument) visit(frameDocument);\n }\n }\n };\n visit(document);\n if (!parts) return found;\n const shadowRoots = roots.filter((root) => root.host);\n const { passesAll, descend } = ((shadowRoots) => {\n const skipped = /^(script|style|noscript|template)$/;\n const hiddenText = (el) => {\n const walker = el.ownerDocument.createTreeWalker(el, 5, {\n acceptNode: (node) => (node.nodeType === 1 && skipped.test(node.localName) ? 2 : 1)\n });\n let text = '';\n for (let node = walker.nextNode(); node; node = walker.nextNode()) {\n if (node.nodeType === 3) text += node.data;\n }\n return text;\n };\n const textOf = (el) => {\n if (el.localName === 'input' && /^(submit|button|reset)$/i.test(el.type)) return el.value;\n const rendered = typeof el.innerText === 'string' &&\n (typeof el.checkVisibility !== 'function' || el.checkVisibility({ visibilityProperty: true }));\n return (rendered ? el.innerText : hiddenText(el)).replace(/\\s+/g, ' ').trim();\n };\n const passes = (el, filter) => {\n if (filter.kind === 'visible') {\n if (typeof el.checkVisibility === 'function' && !el.checkVisibility()) return false;\n const rect = el.getBoundingClientRect();\n return rect.width > 0 && rect.height > 0 && el.ownerDocument.defaultView.getComputedStyle(el).visibility === 'visible';\n }\n if (filter.kind === 'has') return filter.selectors.some((chain) => descend(el, chain).length > 0);\n const text = textOf(el);\n return filter.kind === 'text-is' ? text === filter.text : text.toLowerCase().includes(filter.text);\n };\n const passesAll = (el, filters) =>\n filters.every((filter) => filter.kind !== 'visible' || passes(el, filter)) &&\n filters.every((filter) => filter.kind === 'visible' || passes(el, filter));\n const isUnder = (scope, node) => {\n for (let current = node; current; current = current.getRootNode().host) {\n if (scope === current || scope.contains(current)) return true;\n }\n return false;\n };\n const stepMatches = (scope, step) => {\n const matches = [...scope.querySelectorAll(':scope ' + step.combinator + ' ' + step.css)];\n if (step.combinator !== ' ') return matches;\n for (const root of shadowRoots) {\n if (isUnder(scope, root.host)) matches.push(...root.querySelectorAll(step.css));\n }\n return matches;\n };\n const descend = (scope, steps) => {\n let current = [scope];\n for (const step of steps) {\n const next = new Set();\n for (const el of current) {\n for (const match of stepMatches(el, step)) {\n if (passesAll(match, step.filters)) next.add(match);\n }\n }\n current = [...next];\n }\n return current;\n };\n return { passesAll: passesAll, descend: descend };\n})(shadowRoots);\n const result = new Set();\n for (const el of found) {\n for (const part of parts) {\n if (!el.matches(part.css) || !passesAll(el, part.filters)) continue;\n for (const match of descend(el, part.steps || [])) result.add(match);\n }\n }\n const rootOrder = new Map(roots.map((root, i) => [root, i]));\n const rootIndex = (el) => rootOrder.get(el.getRootNode()) ?? roots.length;\n return [...result].sort((a, b) =>\n rootIndex(a) - rootIndex(b) || (a.compareDocumentPosition(b) & 4 ? -1 : a === b ? 0 : 1)\n );\n})(selector, parts);\n}";
61
+ export declare const FIND_ELEMENTS_JS = "function (selector, parts) {\n if (selector === '__bdg_bound_target__') {\n const el = window.__bdgTarget;\n return el && el.isConnected ? [el] : [];\n }\n const bound = window.__bdgMatches;\n if (bound && bound.selector === selector) {\n const connected = [];\n for (let i = 0; i < bound.nodes.length; i++) {\n if (bound.nodes[i] && bound.nodes[i].isConnected) connected[connected.length] = bound.nodes[i];\n }\n return connected;\n }\n return (function (selector, parts) {\n const css = parts ? parts.map((part) => part.css).join(', ') : selector;\n const found = [];\n const roots = [];\n const visit = (root) => {\n roots.push(root);\n for (const match of root.querySelectorAll(css)) found.push(match);\n for (const el of root.querySelectorAll('*')) {\n if (el.shadowRoot) visit(el.shadowRoot);\n if (el.tagName === 'IFRAME' || el.tagName === 'FRAME') {\n let frameDocument = null;\n try { frameDocument = el.contentDocument; } catch (e) { frameDocument = null; }\n if (frameDocument) visit(frameDocument);\n }\n }\n };\n visit(document);\n if (!parts) return found;\n const shadowRoots = roots.filter((root) => root.host);\n const { passesAll, descend } = ((shadowRoots) => {\n const skipped = /^(script|style|noscript|template)$/;\n const hiddenText = (el) => {\n const walker = el.ownerDocument.createTreeWalker(el, 5, {\n acceptNode: (node) => (node.nodeType === 1 && skipped.test(node.localName) ? 2 : 1)\n });\n let text = '';\n for (let node = walker.nextNode(); node; node = walker.nextNode()) {\n if (node.nodeType === 3) text += node.data;\n }\n return text;\n };\n const textOf = (el) => {\n if (el.localName === 'input' && /^(submit|button|reset)$/i.test(el.type)) return el.value;\n const rendered = typeof el.innerText === 'string' &&\n (typeof el.checkVisibility !== 'function' || el.checkVisibility({ visibilityProperty: true }));\n return (rendered ? el.innerText : hiddenText(el)).replace(/\\s+/g, ' ').trim();\n };\n const passes = (el, filter) => {\n if (filter.kind === 'visible') {\n if (typeof el.checkVisibility === 'function' && !el.checkVisibility()) return false;\n const rect = el.getBoundingClientRect();\n return rect.width > 0 && rect.height > 0 && el.ownerDocument.defaultView.getComputedStyle(el).visibility === 'visible';\n }\n if (filter.kind === 'has') return filter.selectors.some((chain) => descend(el, chain).length > 0);\n const text = textOf(el);\n return filter.kind === 'text-is' ? text === filter.text : text.toLowerCase().includes(filter.text);\n };\n const passesAll = (el, filters) =>\n filters.every((filter) => filter.kind !== 'visible' || passes(el, filter)) &&\n filters.every((filter) => filter.kind === 'visible' || passes(el, filter));\n const isUnder = (scope, node) => {\n for (let current = node; current; current = current.getRootNode().host) {\n if (scope === current || scope.contains(current)) return true;\n }\n return false;\n };\n const stepMatches = (scope, step) => {\n const matches = [...scope.querySelectorAll(':scope ' + step.combinator + ' ' + step.css)];\n if (step.combinator !== ' ') return matches;\n for (const root of shadowRoots) {\n if (isUnder(scope, root.host)) matches.push(...root.querySelectorAll(step.css));\n }\n return matches;\n };\n const descend = (scope, steps) => {\n let current = [scope];\n for (const step of steps) {\n const next = new Set();\n for (const el of current) {\n for (const match of stepMatches(el, step)) {\n if (passesAll(match, step.filters)) next.add(match);\n }\n }\n current = [...next];\n }\n return current;\n };\n return { passesAll: passesAll, descend: descend };\n})(shadowRoots);\n const result = new Set();\n for (const el of found) {\n for (const part of parts) {\n if (!el.matches(part.css) || !passesAll(el, part.filters)) continue;\n for (const match of descend(el, part.steps || [])) result.add(match);\n }\n }\n const rootOrder = new Map(roots.map((root, i) => [root, i]));\n const rootIndex = (el) => rootOrder.get(el.getRootNode()) ?? roots.length;\n return [...result].sort((a, b) =>\n rootIndex(a) - rootIndex(b) || (a.compareDocumentPosition(b) & 4 ? -1 : a === b ? 0 : 1)\n );\n})(selector, parts);\n}";
60
62
  /**
61
63
  * Page-side check of what selectors cannot search in the top document:
62
64
  * iframes whose document cannot be read (cross-origin) and `<object>`/`<embed>`
@@ -94,23 +96,34 @@ export interface ScriptTarget {
94
96
  index?: number;
95
97
  /** Connection for the page scripts (scoped to the element's frame when the top page cannot reach it) */
96
98
  cdp: CDPConnection;
99
+ /** Built-ins the page replaced that the page scripts use (they run in the page's world) */
100
+ replacedBuiltins?: string[];
101
+ /** The page replaced the selector search, so the matches were found in bdg's world */
102
+ boundInBdgWorld?: boolean;
97
103
  }
98
104
  /**
99
105
  * Run an interaction on the element a request targets. Results and errors
100
106
  * never show the internal placeholder: a bound node the page scripts could
101
107
  * not find is reported as stale (87), and results carry the user's selector.
108
+ * On a page that replaced the selector search, a result warns that the
109
+ * element was found in bdg's world; on a page that replaced built-ins the
110
+ * scripts use, a failure adds that they may be the cause, and a script that
111
+ * threw is reported as broken by the page (90), naming them.
102
112
  *
103
113
  * @param cdp - CDP connection
104
114
  * @param params - Request with a selector (and optional index) or a backend node id
105
115
  * @param work - The interaction, given the script target
106
116
  * @returns The interaction's result
107
- * @throws CommandError (87) when the bound node left the page during the action
117
+ * @throws CommandError (87) when the bound node left the page during the
118
+ * action, (90) when its script threw on a page that replaced built-ins
108
119
  */
109
120
  export declare function onScriptTarget<T extends {
110
121
  selector?: string;
111
122
  error?: string;
112
123
  suggestion?: string;
113
124
  exitCode?: number;
125
+ warning?: string;
126
+ replacedBuiltins?: string[];
114
127
  }>(cdp: CDPConnection, params: {
115
128
  selector?: string;
116
129
  index?: number;
@@ -9,16 +9,86 @@
9
9
  * {@link BOUND_TARGET_SELECTOR}.
10
10
  */
11
11
  import { CommandError } from '../../errors/index.js';
12
- import { emptySelectorError, indexOutOfRangeError, noNodesFoundError, staleNodeError, } from '../../errors/messages.js';
12
+ import { actionBrokenByPageError, actionScriptFailedError, emptySelectorError, indexOutOfRangeError, noNodesFoundError, staleNodeError, } from '../../errors/messages.js';
13
+ import { ActionScriptError, throwIfInvalidSelector } from './formFillHelpers/shared.js';
13
14
  import { frameScopedConnection } from './frameScopedConnection.js';
15
+ import { evaluateInBdgWorld } from '../page/bdgWorld.js';
16
+ import { findReplacedBuiltins } from '../page/replacedBuiltins.js';
14
17
  import { createLogger } from '../../ui/logging/index.js';
18
+ import { brokenByReplacedBuiltinsSuggestion, replacedBuiltinsWarning, } from '../../ui/messages/commands.js';
15
19
  import { getErrorMessage } from '../../utils/errors.js';
16
20
  import { EXIT_CODES } from '../../utils/exitCodes.js';
17
21
  import { parseSelectorFilters, } from '../../utils/selectorFilters.js';
18
22
  /** Selector placeholder that makes page scripts use the bound node. */
19
23
  export const BOUND_TARGET_SELECTOR = '__bdg_bound_target__';
20
- /** Removes the node bound for index-based commands from the window it was stored on */
21
- export const UNBIND_TARGET_SCRIPT = 'delete window.__bdgTarget';
24
+ /** Removes the node bound for index-based commands, and matches bound for a selector, from the window they were stored on */
25
+ export const UNBIND_TARGET_SCRIPT = 'delete window.__bdgTarget; delete window.__bdgMatches';
26
+ /**
27
+ * Built-ins the selector search ({@link DEEP_QUERY_JS}) cannot do without:
28
+ * when the page replaced one, the search runs in bdg's world instead.
29
+ * Helpers that libraries replace with working versions (Prototype.js's
30
+ * `Array.prototype.map`) are left to {@link SCRIPT_BUILTINS}.
31
+ */
32
+ const SELECTION_BUILTINS = [
33
+ 'Element.prototype.querySelectorAll',
34
+ 'Element.prototype.querySelector',
35
+ 'Document.prototype.querySelectorAll',
36
+ 'Document.prototype.querySelector',
37
+ 'DocumentFragment.prototype.querySelectorAll',
38
+ 'Element.prototype.matches',
39
+ 'Element.prototype.closest',
40
+ 'Node.prototype.getRootNode',
41
+ 'Node.prototype.compareDocumentPosition',
42
+ 'Node.prototype.contains',
43
+ 'NodeList.prototype[Symbol.iterator]',
44
+ 'Array.prototype[Symbol.iterator]',
45
+ 'Array.prototype.push',
46
+ 'Set',
47
+ 'Map',
48
+ ];
49
+ /** Other built-ins the interaction scripts use */
50
+ const SCRIPT_BUILTINS = [
51
+ 'Array.from',
52
+ 'Array.prototype.map',
53
+ 'Array.prototype.filter',
54
+ 'Array.prototype.forEach',
55
+ 'Array.prototype.some',
56
+ 'Array.prototype.find',
57
+ 'Array.prototype.includes',
58
+ 'Object.keys',
59
+ 'Object.assign',
60
+ 'Object.getOwnPropertyDescriptor',
61
+ 'Function.prototype.call',
62
+ 'JSON.stringify',
63
+ 'JSON.parse',
64
+ ];
65
+ /** DOM built-ins the interaction scripts act through (anti-bot scripts often make them throw) */
66
+ const DOM_ACTION_BUILTINS = [
67
+ 'Element.prototype.getBoundingClientRect',
68
+ 'Element.prototype.getClientRects',
69
+ 'Element.prototype.scrollIntoView',
70
+ 'window.getComputedStyle',
71
+ 'Document.prototype.elementFromPoint',
72
+ 'Document.prototype.createEvent',
73
+ 'EventTarget.prototype.dispatchEvent',
74
+ 'HTMLElement.prototype.focus',
75
+ 'HTMLElement.prototype.blur',
76
+ 'HTMLElement.prototype.click',
77
+ 'Event',
78
+ 'MouseEvent',
79
+ 'HTMLInputElement.prototype.value',
80
+ 'HTMLTextAreaElement.prototype.value',
81
+ 'HTMLSelectElement.prototype.value',
82
+ ];
83
+ /** Matches bound for a selector when the page replaced the selector search built-ins (enough for --index) */
84
+ const MATCH_BIND_LIMIT = 100;
85
+ /** Page-side: stores a node as match `i` of the selector being bound on the top window (runs on the node, in its frame; a cross-origin frame cannot reach the top window and fails) */
86
+ const BIND_MATCH_FUNCTION = `function (selector, i) {
87
+ const w = window.top;
88
+ if (!w.__bdgMatches || w.__bdgMatches.selector !== selector) return false;
89
+ w.__bdgMatches.nodes[i] = this;
90
+ return true;
91
+ }`;
22
92
  const log = createLogger('dom');
23
93
  /**
24
94
  * Page-side matching of filters ({@link SelectorFilter}) and scoped steps
@@ -151,13 +221,23 @@ export const DEEP_QUERY_JS = `function (selector, parts) {
151
221
  * Page-side element lookup shared by the interaction scripts.
152
222
  *
153
223
  * Returns the bound node (if still in the page) for the placeholder selector,
154
- * otherwise all matches of the selector ({@link DEEP_QUERY_JS}).
224
+ * the matches bound for the selector when the page replaced the search
225
+ * built-ins ({@link bindMatches}), otherwise all matches of the selector
226
+ * ({@link DEEP_QUERY_JS}).
155
227
  */
156
228
  export const FIND_ELEMENTS_JS = `function (selector, parts) {
157
229
  if (selector === '${BOUND_TARGET_SELECTOR}') {
158
230
  const el = window.__bdgTarget;
159
231
  return el && el.isConnected ? [el] : [];
160
232
  }
233
+ const bound = window.__bdgMatches;
234
+ if (bound && bound.selector === selector) {
235
+ const connected = [];
236
+ for (let i = 0; i < bound.nodes.length; i++) {
237
+ if (bound.nodes[i] && bound.nodes[i].isConnected) connected[connected.length] = bound.nodes[i];
238
+ }
239
+ return connected;
240
+ }
161
241
  return (${DEEP_QUERY_JS})(selector, parts);
162
242
  }`;
163
243
  /**
@@ -269,13 +349,124 @@ async function bindTargetNode(cdp, backendNodeId) {
269
349
  */
270
350
  async function resolveScriptTarget(cdp, params) {
271
351
  if (params.backendNodeId === undefined) {
352
+ const selector = params.selector ?? '';
353
+ const replaced = await replacedBuiltins(cdp);
354
+ const searchReplaced = replaced.some((name) => SELECTION_BUILTINS.includes(name));
355
+ const bound = searchReplaced && (await bindMatches(cdp, selector, params.index ?? 0));
272
356
  return {
273
- selector: params.selector ?? '',
357
+ selector,
274
358
  ...(params.index !== undefined && { index: params.index }),
275
359
  cdp,
360
+ ...(replaced.length > 0 && { replacedBuiltins: replaced }),
361
+ ...(bound && { boundInBdgWorld: true }),
276
362
  };
277
363
  }
278
- return { selector: BOUND_TARGET_SELECTOR, cdp: await bindTargetNode(cdp, params.backendNodeId) };
364
+ const scriptCdp = await bindTargetNode(cdp, params.backendNodeId);
365
+ const replaced = await replacedBuiltins(scriptCdp);
366
+ return {
367
+ selector: BOUND_TARGET_SELECTOR,
368
+ cdp: scriptCdp,
369
+ ...(replaced.length > 0 && { replacedBuiltins: replaced }),
370
+ };
371
+ }
372
+ /**
373
+ * The built-ins the interaction scripts use that the page replaced.
374
+ *
375
+ * @param cdp - CDP connection
376
+ * @returns Their dotted names (empty when all are the browser's, or the check failed)
377
+ */
378
+ function replacedBuiltins(cdp) {
379
+ return findReplacedBuiltins(cdp, [
380
+ ...SELECTION_BUILTINS,
381
+ ...SCRIPT_BUILTINS,
382
+ ...DOM_ACTION_BUILTINS,
383
+ ]);
384
+ }
385
+ /**
386
+ * Search the selector in bdg's own world, where the page's replacements do
387
+ * not apply, and hand the matches (the first {@link MATCH_BIND_LIMIT}, or up
388
+ * to the index asked for) to the page scripts, which run in the page's world.
389
+ * When a match cannot be handed over (or the search fails other than on an
390
+ * invalid selector), nothing is bound and the scripts search themselves.
391
+ *
392
+ * @param cdp - CDP connection
393
+ * @param selector - Selector as the user gave it
394
+ * @param index - Match the action is for
395
+ * @returns Whether the matches were bound
396
+ * @throws CommandError (81) for an invalid selector
397
+ */
398
+ async function bindMatches(cdp, selector, index) {
399
+ const objectGroup = `bdg-bind-${Date.now()}`;
400
+ const limit = Math.max(MATCH_BIND_LIMIT, index + 1);
401
+ try {
402
+ const found = await evaluateInBdgWorld(cdp, {
403
+ expression: `(${DEEP_QUERY_JS})(${selectorArgsJS(selector)}).slice(0, ${limit})`,
404
+ objectGroup,
405
+ });
406
+ if (found.exceptionDetails)
407
+ throwIfInvalidSelector(found.exceptionDetails, selector);
408
+ const arrayId = found.result.objectId;
409
+ if (found.exceptionDetails || !arrayId)
410
+ return false;
411
+ const { result } = (await cdp.send('Runtime.getProperties', {
412
+ objectId: arrayId,
413
+ ownProperties: true,
414
+ }));
415
+ const matches = result
416
+ .filter((property) => /^\d+$/.test(property.name))
417
+ .map((property) => ({ i: Number(property.name), objectId: property.value?.objectId }));
418
+ await cdp.send('Runtime.evaluate', {
419
+ expression: `window.__bdgMatches = { selector: ${JSON.stringify(selector)}, nodes: [] }`,
420
+ });
421
+ const bound = await Promise.all(matches.map(({ i, objectId }) => objectId ? bindMatch(cdp, selector, i, objectId) : Promise.resolve(false)));
422
+ if (bound.every(Boolean))
423
+ return true;
424
+ await cdp.send('Runtime.evaluate', { expression: 'delete window.__bdgMatches' });
425
+ return false;
426
+ }
427
+ catch (error) {
428
+ if (error instanceof CommandError)
429
+ throw error;
430
+ log.debug(`Matches not bound: ${getErrorMessage(error)}`);
431
+ return false;
432
+ }
433
+ finally {
434
+ await cdp
435
+ .send('Runtime.releaseObjectGroup', { objectGroup })
436
+ .catch((error) => log.debug(`Matches not released: ${getErrorMessage(error)}`));
437
+ }
438
+ }
439
+ /**
440
+ * Hand one match from bdg's world to the page's world as match `i`.
441
+ *
442
+ * @param cdp - CDP connection
443
+ * @param selector - Selector the matches are for
444
+ * @param i - Its position among the matches
445
+ * @param objectId - The match in bdg's world
446
+ * @returns Whether it was handed over
447
+ */
448
+ async function bindMatch(cdp, selector, i, objectId) {
449
+ try {
450
+ const { node } = (await cdp.send('DOM.describeNode', {
451
+ objectId,
452
+ }));
453
+ const resolved = (await cdp.send('DOM.resolveNode', {
454
+ backendNodeId: node.backendNodeId,
455
+ }));
456
+ if (!resolved.object.objectId)
457
+ return false;
458
+ const stored = (await cdp.send('Runtime.callFunctionOn', {
459
+ objectId: resolved.object.objectId,
460
+ functionDeclaration: BIND_MATCH_FUNCTION,
461
+ arguments: [{ value: selector }, { value: i }],
462
+ returnByValue: true,
463
+ }));
464
+ return stored.result.value === true;
465
+ }
466
+ catch (error) {
467
+ log.debug(`Match ${i} not bound: ${getErrorMessage(error)}`);
468
+ return false;
469
+ }
279
470
  }
280
471
  /**
281
472
  * Whether an error is about the bound node: the placeholder only appears in
@@ -291,19 +482,28 @@ function boundNodeMissing(text) {
291
482
  * Run an interaction on the element a request targets. Results and errors
292
483
  * never show the internal placeholder: a bound node the page scripts could
293
484
  * not find is reported as stale (87), and results carry the user's selector.
485
+ * On a page that replaced the selector search, a result warns that the
486
+ * element was found in bdg's world; on a page that replaced built-ins the
487
+ * scripts use, a failure adds that they may be the cause, and a script that
488
+ * threw is reported as broken by the page (90), naming them.
294
489
  *
295
490
  * @param cdp - CDP connection
296
491
  * @param params - Request with a selector (and optional index) or a backend node id
297
492
  * @param work - The interaction, given the script target
298
493
  * @returns The interaction's result
299
- * @throws CommandError (87) when the bound node left the page during the action
494
+ * @throws CommandError (87) when the bound node left the page during the
495
+ * action, (90) when its script threw on a page that replaced built-ins
300
496
  */
301
497
  export async function onScriptTarget(cdp, params, work) {
302
498
  const err = staleNodeError();
303
499
  let target;
304
500
  try {
305
501
  target = await resolveScriptTarget(cdp, params);
306
- const result = withUserSelector(await work(target), params.selector);
502
+ const replaced = target.replacedBuiltins ?? [];
503
+ const result = withReplacedBuiltins(withUserSelector(await work(target), params.selector), target.boundInBdgWorld ? replaced : []);
504
+ if (result.error && replaced.length > 0) {
505
+ return { ...result, suggestion: withBrokenHint(result.suggestion, replaced) };
506
+ }
307
507
  if (!boundNodeMissing(result.error))
308
508
  return result;
309
509
  return {
@@ -314,6 +514,16 @@ export async function onScriptTarget(cdp, params, work) {
314
514
  };
315
515
  }
316
516
  catch (error) {
517
+ const replaced = target?.replacedBuiltins ?? [];
518
+ if (error instanceof ActionScriptError) {
519
+ throw actionScriptFailure(error, replaced, params.selector ?? '');
520
+ }
521
+ if (error instanceof CommandError && replaced.length > 0) {
522
+ const suggestion = error.metadata['suggestion'];
523
+ throw new CommandError(error.message, {
524
+ suggestion: withBrokenHint(typeof suggestion === 'string' ? suggestion : undefined, replaced),
525
+ }, error.exitCode);
526
+ }
317
527
  if (!(error instanceof CommandError) || !boundNodeMissing(error.message))
318
528
  throw error;
319
529
  throw new CommandError(err.message, { suggestion: err.suggestion }, EXIT_CODES.STALE_CACHE);
@@ -323,6 +533,24 @@ export async function onScriptTarget(cdp, params, work) {
323
533
  unbindInFrame(target.cdp);
324
534
  }
325
535
  }
536
+ /**
537
+ * The error for an action script that threw: on a page that replaced
538
+ * built-ins the scripts use, that the page broke it (90, naming them);
539
+ * otherwise what it threw (110), with the user's selector.
540
+ *
541
+ * @param error - What the action reported
542
+ * @param replaced - Built-ins the page replaced
543
+ * @param selector - Selector the user gave (or the cached query's selector)
544
+ * @returns Error to throw
545
+ */
546
+ function actionScriptFailure(error, replaced, selector) {
547
+ if (replaced.length > 0) {
548
+ const err = actionBrokenByPageError(error.action, error.exception, replaced);
549
+ return new CommandError(err.message, { suggestion: err.suggestion }, EXIT_CODES.RESOURCE_CONFLICT);
550
+ }
551
+ const err = actionScriptFailedError(error.action, error.exception, selector);
552
+ return new CommandError(err.message, { suggestion: err.suggestion }, EXIT_CODES.SOFTWARE_ERROR);
553
+ }
326
554
  /**
327
555
  * Remove a node bound in a cross-origin frame from that frame's window
328
556
  * (through the scoped connection, so the script runs where the bind ran; the
@@ -336,6 +564,38 @@ function unbindInFrame(frameConnection) {
336
564
  .send('Runtime.evaluate', { expression: UNBIND_TARGET_SCRIPT })
337
565
  .catch((error) => log.debug(`Frame target not unbound: ${getErrorMessage(error)}`));
338
566
  }
567
+ /**
568
+ * A failed action's suggestion with the hint that the page's replaced
569
+ * built-ins may have broken it.
570
+ *
571
+ * @param suggestion - The action's own suggestion
572
+ * @param replaced - Built-ins the page replaced
573
+ * @returns Both, the action's first
574
+ */
575
+ function withBrokenHint(suggestion, replaced) {
576
+ const hint = brokenByReplacedBuiltinsSuggestion(replaced);
577
+ return suggestion ? `${suggestion}. ${hint}` : hint;
578
+ }
579
+ /**
580
+ * Warn that the page replaced built-ins the page scripts use: they run in
581
+ * the page's world, so the action may misbehave (the element itself was
582
+ * found in bdg's world). The warning names the first few; `replacedBuiltins`
583
+ * lists them all.
584
+ *
585
+ * @param result - Script result
586
+ * @param replaced - Built-ins the page replaced
587
+ * @returns Result with the warning added to any it has
588
+ */
589
+ function withReplacedBuiltins(result, replaced) {
590
+ if (!replaced || replaced.length === 0)
591
+ return result;
592
+ const warning = replacedBuiltinsWarning(replaced);
593
+ return {
594
+ ...result,
595
+ warning: result.warning ? `${result.warning}; ${warning}` : warning,
596
+ replacedBuiltins: replaced,
597
+ };
598
+ }
339
599
  /**
340
600
  * Report the user's selector instead of the internal placeholder.
341
601
  *