browser-debugger-cli 0.8.0 → 0.10.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 (304) hide show
  1. package/README.md +7 -1
  2. package/dist/cdp/schema.d.ts +4 -1
  3. package/dist/cdp/schema.js +48 -7
  4. package/dist/commands/cdp.js +3 -2
  5. package/dist/commands/cleanup.d.ts +11 -0
  6. package/dist/commands/cleanup.js +161 -57
  7. package/dist/commands/console.d.ts +20 -1
  8. package/dist/commands/console.js +57 -17
  9. package/dist/commands/details.js +3 -2
  10. package/dist/commands/dom/DomElementResolver.d.ts +10 -3
  11. package/dist/commands/dom/DomElementResolver.js +35 -17
  12. package/dist/commands/dom/a11y.d.ts +10 -0
  13. package/dist/commands/dom/a11y.js +29 -6
  14. package/dist/commands/dom/eval.d.ts +3 -1
  15. package/dist/commands/dom/eval.js +29 -4
  16. package/dist/commands/dom/form.js +16 -62
  17. package/dist/commands/dom/formInteraction.js +189 -119
  18. package/dist/commands/dom/formSummary.d.ts +49 -0
  19. package/dist/commands/dom/formSummary.js +180 -0
  20. package/dist/commands/dom/frames.d.ts +2 -1
  21. package/dist/commands/dom/frames.js +17 -2
  22. package/dist/commands/dom/get.d.ts +6 -5
  23. package/dist/commands/dom/get.js +92 -82
  24. package/dist/commands/dom/helpers/index.d.ts +1 -1
  25. package/dist/commands/dom/helpers/index.js +1 -1
  26. package/dist/commands/dom/helpers/keyAttributes.d.ts +20 -0
  27. package/dist/commands/dom/helpers/keyAttributes.js +54 -0
  28. package/dist/commands/dom/helpers/query.d.ts +44 -17
  29. package/dist/commands/dom/helpers/query.js +300 -106
  30. package/dist/commands/dom/helpers/runElementCommand.d.ts +10 -2
  31. package/dist/commands/dom/helpers/runElementCommand.js +98 -30
  32. package/dist/commands/dom/helpers/screenshot.d.ts +4 -1
  33. package/dist/commands/dom/helpers/screenshot.js +239 -51
  34. package/dist/commands/dom/index.d.ts +4 -1
  35. package/dist/commands/dom/index.js +22 -7
  36. package/dist/commands/dom/inspect.d.ts +15 -0
  37. package/dist/commands/dom/inspect.js +82 -0
  38. package/dist/commands/dom/layout.d.ts +14 -0
  39. package/dist/commands/dom/layout.js +54 -0
  40. package/dist/commands/dom/listeners.d.ts +5 -1
  41. package/dist/commands/dom/listeners.js +15 -5
  42. package/dist/commands/dom/query.js +2 -3
  43. package/dist/commands/dom/screenshot.d.ts +12 -2
  44. package/dist/commands/dom/screenshot.js +27 -3
  45. package/dist/commands/dom/semanticUtils.d.ts +16 -10
  46. package/dist/commands/dom/semanticUtils.js +53 -16
  47. package/dist/commands/dom/wait.d.ts +13 -0
  48. package/dist/commands/dom/wait.js +83 -0
  49. package/dist/commands/helpJson.js +2 -2
  50. package/dist/commands/network/list.js +17 -13
  51. package/dist/commands/optionBehaviors.js +154 -21
  52. package/dist/commands/page.d.ts +3 -2
  53. package/dist/commands/page.js +100 -5
  54. package/dist/commands/peek.js +4 -11
  55. package/dist/commands/sessions.d.ts +8 -0
  56. package/dist/commands/sessions.js +19 -0
  57. package/dist/commands/shared/CommandRunner.js +4 -4
  58. package/dist/commands/shared/commonOptions.d.ts +4 -0
  59. package/dist/commands/shared/commonOptions.js +9 -0
  60. package/dist/commands/shared/dataFetcher.js +2 -2
  61. package/dist/commands/shared/followMode.d.ts +21 -1
  62. package/dist/commands/shared/followMode.js +29 -2
  63. package/dist/commands/shared/handleValidationError.d.ts +2 -2
  64. package/dist/commands/shared/handleValidationError.js +12 -3
  65. package/dist/commands/shared/optionTypes.d.ts +61 -5
  66. package/dist/commands/shared/startHelpers.d.ts +66 -0
  67. package/dist/commands/shared/startHelpers.js +103 -13
  68. package/dist/commands/shared/validation.d.ts +14 -2
  69. package/dist/commands/shared/validation.js +20 -3
  70. package/dist/commands/start.d.ts +63 -0
  71. package/dist/commands/start.js +115 -15
  72. package/dist/commands/status.js +29 -7
  73. package/dist/commands/stop.js +7 -6
  74. package/dist/commands/tail.js +4 -11
  75. package/dist/commands/types.d.ts +2 -0
  76. package/dist/commands.js +2 -0
  77. package/dist/connection/chromeIdentity.d.ts +65 -0
  78. package/dist/connection/chromeIdentity.js +143 -0
  79. package/dist/connection/launcher/profilePreferences.d.ts +47 -0
  80. package/dist/connection/launcher/profilePreferences.js +151 -0
  81. package/dist/connection/launcher.d.ts +21 -2
  82. package/dist/connection/launcher.js +42 -16
  83. package/dist/connection/portReservation.d.ts +14 -4
  84. package/dist/connection/portReservation.js +21 -6
  85. package/dist/connection/startupExit.d.ts +8 -0
  86. package/dist/connection/startupExit.js +15 -6
  87. package/dist/constants.d.ts +6 -2
  88. package/dist/constants.js +9 -2
  89. package/dist/daemon/SessionController.js +23 -7
  90. package/dist/daemon/errors.d.ts +1 -1
  91. package/dist/daemon/errors.js +1 -1
  92. package/dist/daemon/launcher.d.ts +10 -2
  93. package/dist/daemon/launcher.js +8 -7
  94. package/dist/daemon/server/SocketServer.js +1 -2
  95. package/dist/daemon/session/Session.d.ts +20 -0
  96. package/dist/daemon/session/Session.js +80 -9
  97. package/dist/daemon/session/chromeConnection.d.ts +9 -0
  98. package/dist/daemon/session/chromeConnection.js +45 -8
  99. package/dist/daemon/session/commandRegistry.d.ts +14 -1
  100. package/dist/daemon/session/commandRegistry.js +113 -67
  101. package/dist/daemon/session/interactions.d.ts +48 -9
  102. package/dist/daemon/session/interactions.js +46 -9
  103. package/dist/daemon/session/triggeredRequests.d.ts +67 -0
  104. package/dist/daemon/session/triggeredRequests.js +157 -0
  105. package/dist/daemon/session/types.d.ts +5 -1
  106. package/dist/daemon.js +10630 -3601
  107. package/dist/errors/messages.d.ts +456 -24
  108. package/dist/errors/messages.js +862 -67
  109. package/dist/index.js +6915 -3401
  110. package/dist/ipc/client.d.ts +21 -1
  111. package/dist/ipc/client.js +35 -3
  112. package/dist/ipc/protocol/commands.d.ts +145 -5
  113. package/dist/ipc/protocol/commands.js +4 -0
  114. package/dist/ipc/protocol/domTypes.d.ts +291 -7
  115. package/dist/ipc/protocol/inspectTypes.d.ts +388 -0
  116. package/dist/ipc/protocol/inspectTypes.js +10 -0
  117. package/dist/ipc/session/lifecycle.d.ts +8 -1
  118. package/dist/ipc/session/queries.d.ts +5 -1
  119. package/dist/ipc/session/types.d.ts +5 -0
  120. package/dist/ipc/transport/index.d.ts +2 -1
  121. package/dist/ipc/transport/index.js +2 -2
  122. package/dist/runtime/dom/actionEffects.d.ts +185 -0
  123. package/dist/runtime/dom/actionEffects.js +402 -0
  124. package/dist/runtime/dom/actionEffectsScripts.d.ts +90 -0
  125. package/dist/runtime/dom/actionEffectsScripts.js +426 -0
  126. package/dist/runtime/dom/elementGeometry.d.ts +170 -0
  127. package/dist/runtime/dom/elementGeometry.js +553 -0
  128. package/dist/runtime/dom/elementInfo.d.ts +103 -0
  129. package/dist/runtime/dom/elementInfo.js +256 -0
  130. package/dist/runtime/dom/evalHelpers.d.ts +51 -6
  131. package/dist/runtime/dom/evalHelpers.js +136 -26
  132. package/dist/runtime/dom/eventListeners.d.ts +2 -1
  133. package/dist/runtime/dom/eventListeners.js +184 -47
  134. package/dist/runtime/dom/formDiscovery.d.ts +1 -1
  135. package/dist/runtime/dom/formDiscovery.js +116 -16
  136. package/dist/runtime/dom/formFillHelpers/fill.d.ts +9 -0
  137. package/dist/runtime/dom/formFillHelpers/fill.js +178 -14
  138. package/dist/runtime/dom/formFillHelpers/index.d.ts +2 -2
  139. package/dist/runtime/dom/formFillHelpers/index.js +2 -2
  140. package/dist/runtime/dom/formFillHelpers/pressKey.js +14 -3
  141. package/dist/runtime/dom/formFillHelpers/scroll.d.ts +3 -0
  142. package/dist/runtime/dom/formFillHelpers/scroll.js +60 -18
  143. package/dist/runtime/dom/formFillHelpers/shared.d.ts +17 -0
  144. package/dist/runtime/dom/formFillHelpers/shared.js +25 -1
  145. package/dist/runtime/dom/formFillHelpers/stability.d.ts +20 -6
  146. package/dist/runtime/dom/formFillHelpers/stability.js +50 -19
  147. package/dist/runtime/dom/formSubmitHelpers.d.ts +3 -0
  148. package/dist/runtime/dom/formSubmitHelpers.js +89 -15
  149. package/dist/runtime/dom/frameLayout.d.ts +60 -0
  150. package/dist/runtime/dom/frameLayout.js +140 -0
  151. package/dist/runtime/dom/frameOrigin.d.ts +50 -0
  152. package/dist/runtime/dom/frameOrigin.js +62 -0
  153. package/dist/runtime/dom/frameScopedConnection.d.ts +92 -0
  154. package/dist/runtime/dom/frameScopedConnection.js +252 -0
  155. package/dist/runtime/dom/frameSelection.d.ts +12 -1
  156. package/dist/runtime/dom/frameSelection.js +22 -3
  157. package/dist/runtime/dom/frames.d.ts +61 -5
  158. package/dist/runtime/dom/frames.js +329 -75
  159. package/dist/runtime/dom/inspect.d.ts +28 -0
  160. package/dist/runtime/dom/inspect.js +557 -0
  161. package/dist/runtime/dom/inspectAllStyles.d.ts +62 -0
  162. package/dist/runtime/dom/inspectAllStyles.js +385 -0
  163. package/dist/runtime/dom/inspectCascade.d.ts +94 -0
  164. package/dist/runtime/dom/inspectCascade.js +371 -0
  165. package/dist/runtime/dom/inspectCascadeModel.d.ts +39 -0
  166. package/dist/runtime/dom/inspectCascadeModel.js +232 -0
  167. package/dist/runtime/dom/inspectHints.d.ts +62 -0
  168. package/dist/runtime/dom/inspectHints.js +305 -0
  169. package/dist/runtime/dom/inspectLayoutModel.d.ts +87 -0
  170. package/dist/runtime/dom/inspectLayoutModel.js +346 -0
  171. package/dist/runtime/dom/inspectModel.d.ts +74 -0
  172. package/dist/runtime/dom/inspectModel.js +184 -0
  173. package/dist/runtime/dom/inspectPaintModel.d.ts +157 -0
  174. package/dist/runtime/dom/inspectPaintModel.js +461 -0
  175. package/dist/runtime/dom/inspectRules.d.ts +37 -0
  176. package/dist/runtime/dom/inspectRules.js +101 -0
  177. package/dist/runtime/dom/inspectScripts.d.ts +132 -0
  178. package/dist/runtime/dom/inspectScripts.js +263 -0
  179. package/dist/runtime/dom/inspectTree.d.ts +40 -0
  180. package/dist/runtime/dom/inspectTree.js +134 -0
  181. package/dist/runtime/dom/inspectVariables.d.ts +33 -0
  182. package/dist/runtime/dom/inspectVariables.js +94 -0
  183. package/dist/runtime/dom/inspectWhyModel.d.ts +20 -0
  184. package/dist/runtime/dom/inspectWhyModel.js +134 -0
  185. package/dist/runtime/dom/layout.d.ts +71 -0
  186. package/dist/runtime/dom/layout.js +340 -0
  187. package/dist/runtime/dom/listenerPageScripts.d.ts +72 -0
  188. package/dist/runtime/dom/listenerPageScripts.js +365 -0
  189. package/dist/runtime/dom/listenerSummary.d.ts +136 -11
  190. package/dist/runtime/dom/listenerSummary.js +361 -22
  191. package/dist/runtime/dom/pageActivity.d.ts +41 -0
  192. package/dist/runtime/dom/pageActivity.js +123 -0
  193. package/dist/runtime/dom/reactEventHelpers.d.ts +63 -2
  194. package/dist/runtime/dom/reactEventHelpers.js +220 -41
  195. package/dist/runtime/dom/targetNode.d.ts +80 -27
  196. package/dist/runtime/dom/targetNode.js +249 -33
  197. package/dist/runtime/dom/wait.d.ts +25 -0
  198. package/dist/runtime/dom/wait.js +199 -0
  199. package/dist/runtime/dom/waitCondition.d.ts +71 -0
  200. package/dist/runtime/dom/waitCondition.js +75 -0
  201. package/dist/runtime/page/emulation.d.ts +71 -0
  202. package/dist/runtime/page/emulation.js +117 -0
  203. package/dist/runtime/page/loadingState.d.ts +36 -0
  204. package/dist/runtime/page/loadingState.js +86 -0
  205. package/dist/runtime/page/navigation.d.ts +46 -2
  206. package/dist/runtime/page/navigation.js +69 -33
  207. package/dist/session/QueryCacheManager.d.ts +11 -1
  208. package/dist/session/QueryCacheManager.js +25 -3
  209. package/dist/session/chromeOwners.d.ts +34 -0
  210. package/dist/session/chromeOwners.js +51 -0
  211. package/dist/session/cleanup/staleSession.d.ts +11 -1
  212. package/dist/session/cleanup/staleSession.js +17 -6
  213. package/dist/session/cleanup/userCommands.js +2 -4
  214. package/dist/session/metadata.d.ts +5 -1
  215. package/dist/session/metadata.js +2 -1
  216. package/dist/session/paths.d.ts +77 -3
  217. package/dist/session/paths.js +111 -5
  218. package/dist/session/port.d.ts +31 -7
  219. package/dist/session/port.js +50 -43
  220. package/dist/session/portClaims.d.ts +66 -0
  221. package/dist/session/portClaims.js +284 -0
  222. package/dist/session/sessionList.d.ts +58 -0
  223. package/dist/session/sessionList.js +199 -0
  224. package/dist/session/sessionName.d.ts +46 -0
  225. package/dist/session/sessionName.js +97 -0
  226. package/dist/telemetry/a11y.d.ts +18 -3
  227. package/dist/telemetry/a11y.js +170 -29
  228. package/dist/telemetry/console.d.ts +1 -0
  229. package/dist/telemetry/console.js +100 -5
  230. package/dist/telemetry/network.js +3 -1
  231. package/dist/telemetry/requestKinds.d.ts +32 -0
  232. package/dist/telemetry/requestKinds.js +61 -0
  233. package/dist/telemetry/requestState.d.ts +31 -0
  234. package/dist/telemetry/requestState.js +38 -0
  235. package/dist/types.d.ts +112 -3
  236. package/dist/ui/formatters/a11y.js +3 -0
  237. package/dist/ui/formatters/console/chronological.d.ts +8 -0
  238. package/dist/ui/formatters/console/chronological.js +17 -4
  239. package/dist/ui/formatters/console/json.js +3 -4
  240. package/dist/ui/formatters/console/shared.d.ts +12 -0
  241. package/dist/ui/formatters/console.d.ts +2 -2
  242. package/dist/ui/formatters/console.js +1 -1
  243. package/dist/ui/formatters/details.d.ts +8 -0
  244. package/dist/ui/formatters/details.js +61 -4
  245. package/dist/ui/formatters/dom.d.ts +27 -14
  246. package/dist/ui/formatters/dom.js +88 -59
  247. package/dist/ui/formatters/form.js +29 -18
  248. package/dist/ui/formatters/inspect.d.ts +39 -0
  249. package/dist/ui/formatters/inspect.js +596 -0
  250. package/dist/ui/formatters/keyAttributes.d.ts +19 -0
  251. package/dist/ui/formatters/keyAttributes.js +84 -0
  252. package/dist/ui/formatters/layout.d.ts +31 -0
  253. package/dist/ui/formatters/layout.js +53 -0
  254. package/dist/ui/formatters/listeners.d.ts +3 -2
  255. package/dist/ui/formatters/listeners.js +73 -9
  256. package/dist/ui/formatters/networkHeaders.d.ts +13 -0
  257. package/dist/ui/formatters/networkHeaders.js +36 -3
  258. package/dist/ui/formatters/networkList.d.ts +29 -1
  259. package/dist/ui/formatters/networkList.js +86 -20
  260. package/dist/ui/formatters/preview.js +2 -1
  261. package/dist/ui/formatters/requestStatus.d.ts +1 -17
  262. package/dist/ui/formatters/requestStatus.js +2 -30
  263. package/dist/ui/formatters/sessions.d.ts +12 -0
  264. package/dist/ui/formatters/sessions.js +40 -0
  265. package/dist/ui/formatters/status.d.ts +21 -2
  266. package/dist/ui/formatters/status.js +47 -10
  267. package/dist/ui/formatters/triggeredRequests.d.ts +36 -0
  268. package/dist/ui/formatters/triggeredRequests.js +65 -0
  269. package/dist/ui/formatting.d.ts +19 -0
  270. package/dist/ui/formatting.js +31 -36
  271. package/dist/ui/messages/chrome.d.ts +9 -0
  272. package/dist/ui/messages/chrome.js +17 -5
  273. package/dist/ui/messages/commands.d.ts +504 -14
  274. package/dist/ui/messages/commands.js +835 -21
  275. package/dist/ui/messages/consoleMessages.d.ts +10 -0
  276. package/dist/ui/messages/consoleMessages.js +17 -0
  277. package/dist/ui/messages/hints.js +2 -1
  278. package/dist/ui/messages/networkMessages.d.ts +14 -0
  279. package/dist/ui/messages/networkMessages.js +18 -0
  280. package/dist/ui/messages/preview.js +5 -4
  281. package/dist/ui/messages/session.d.ts +30 -21
  282. package/dist/ui/messages/session.js +48 -26
  283. package/dist/ui/messages/sessionCommand.d.ts +43 -0
  284. package/dist/ui/messages/sessionCommand.js +52 -0
  285. package/dist/utils/async.d.ts +17 -0
  286. package/dist/utils/async.js +36 -0
  287. package/dist/utils/color.d.ts +84 -0
  288. package/dist/utils/color.js +376 -0
  289. package/dist/utils/cssValues.d.ts +109 -0
  290. package/dist/utils/cssValues.js +236 -0
  291. package/dist/utils/http.d.ts +22 -1
  292. package/dist/utils/http.js +28 -9
  293. package/dist/utils/selectorFilters.d.ts +48 -8
  294. package/dist/utils/selectorFilters.js +296 -53
  295. package/dist/utils/shellDetection.d.ts +8 -2
  296. package/dist/utils/shellDetection.js +120 -33
  297. package/dist/utils/suggestions.d.ts +26 -0
  298. package/dist/utils/suggestions.js +73 -0
  299. package/dist/utils/taskMappings.js +10 -0
  300. package/dist/utils/url.d.ts +12 -2
  301. package/dist/utils/url.js +69 -7
  302. package/package.json +1 -1
  303. package/dist/ui/formatters/sessionFormatters.d.ts +0 -58
  304. package/dist/ui/formatters/sessionFormatters.js +0 -121
@@ -6,6 +6,34 @@
6
6
  * to properly trigger React's event system.
7
7
  */
8
8
  import type { FillResult, ClickResult } from '../../ipc/protocol/domTypes.js';
9
+ /**
10
+ * Page-side read-back of a filled field: a mismatch when its value is not
11
+ * what was asked for (the page rejected, reformatted or moved the input),
12
+ * undefined when it is. Values are compared as the browser normalises them:
13
+ * colors case-insensitively, numbers and ranges as numbers, email trimmed,
14
+ * textarea line endings as `\n`, times and local date-times without zero
15
+ * seconds and with `T` (`2024-01-05 10:00:00` is `2024-01-05T10:00`).
16
+ * Checkboxes and radios compare as `checked`/`unchecked`, a multiple select
17
+ * as its selected values joined by ", ", contenteditable text with
18
+ * whitespace collapsed. A value cut to the field's maxlength sets
19
+ * `truncatedTo`; a password mismatch gives masked values and both lengths.
20
+ */
21
+ export declare const FILL_VALUE_MISMATCH_JS = "(field, expected) => {\n const type = (field.type || '').toLowerCase();\n const actual = field.isContentEditable\n ? (field.textContent || '')\n : type === 'checkbox' || type === 'radio'\n ? (field.checked ? 'checked' : 'unchecked')\n : field.localName === 'select' && field.multiple\n ? Array.from(field.selectedOptions).map((o) => o.value).join(', ')\n : String(field.value);\n const time = (text) => text.trim().replace(' ', 'T').replace(/(\\d\\d:\\d\\d):00(\\.0+)?$/, '$1');\n const normalize = (text) => {\n text = String(text);\n if (field.isContentEditable) return text.replace(/\\s+/g, ' ').trim();\n if (field.localName === 'textarea') return text.replace(/\\r\\n?/g, '\\n');\n if (type === 'color') return text.trim().toLowerCase();\n if (type === 'email') return text.trim();\n if (type === 'time' || type === 'datetime-local') return time(text);\n return text;\n };\n const numeric = (type === 'number' || type === 'range') && actual.trim() !== '' && String(expected).trim() !== '';\n const same = numeric ? Number(actual) === Number(expected) : normalize(actual) === normalize(expected);\n if (same) return undefined;\n if (type === 'password') {\n const mask = (text) => (text === '' ? '' : '********');\n return { expected: mask(expected), actual: mask(actual), expectedLength: expected.length, actualLength: actual.length };\n }\n const cut = field.maxLength > 0 && actual.length === field.maxLength && expected.length > actual.length &&\n expected.startsWith(actual);\n return cut ? { expected: expected, actual: actual, truncatedTo: actual.length } : { expected: expected, actual: actual };\n}";
22
+ /**
23
+ * Page-side list of the other text-like fields of a field's form (of the
24
+ * document when it has none), with their values: taken before a fill, so
25
+ * {@link MOVED_VALUE_JS} can tell which one the page changed.
26
+ */
27
+ export declare const FIELD_VALUES_JS = "(field) => {\n const fields = field.form ? Array.from(field.form.elements) : Array.from(field.ownerDocument.querySelectorAll('input, textarea'));\n return new Map(fields\n .filter((f) => f !== field && /^(input|textarea)$/.test(f.localName) &&\n !/^(checkbox|radio|password|hidden|submit|button|reset|file|image)$/i.test(f.type || ''))\n .map((f) => [f, String(f.value)]));\n}";
28
+ /**
29
+ * Page-side reason `dom fill` cannot fill an element, or null when it can:
30
+ * a disabled or read-only form control (naming the attribute or the disabled
31
+ * `<fieldset>`), or for any other element the switch that keeps it from being
32
+ * editable: `contenteditable="false"` (on it or the editor around it), an
33
+ * `inert` ancestor, `aria-readonly` or `aria-disabled`; otherwise it is not a
34
+ * fillable kind of element. Messages are {@link FILL_REFUSALS}.
35
+ */
36
+ export declare const FILL_REFUSAL_JS: string;
9
37
  /**
10
38
  * JavaScript function to fill an input element in a React-compatible way.
11
39
  *
@@ -14,10 +42,36 @@ import type { FillResult, ClickResult } from '../../ipc/protocol/domTypes.js';
14
42
  * 2. Dispatches input/change events that React listens for
15
43
  * 3. Properly handles focus/blur for form validation
16
44
  *
45
+ * A `<label>` is filled through its control ({@link LABEL_CONTROL_JS}),
46
+ * reported as e.g. `input (via label)`.
47
+ *
48
+ * The result is returned right away (a change handler may navigate). The
49
+ * field and the value to expect are left in `window.__bdgFillCheck` for
50
+ * {@link FILL_READ_BACK_SCRIPT}, which reads the value back a moment later.
51
+ *
17
52
  * @remarks
18
53
  * Works with React, Vue, Angular, and vanilla JS applications.
19
54
  */
20
- export declare const REACT_FILL_SCRIPT = "\n(function(selector, parts, value, options) {\n const allMatches = (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 visit = (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 passes = (el, filter) => {\n if (filter.kind === 'visible') {\n const rect = el.getBoundingClientRect();\n return rect.width > 0 && rect.height > 0 && el.ownerDocument.defaultView.getComputedStyle(el).visibility === 'visible';\n }\n const text = (typeof el.innerText === 'string' ? el.innerText : el.textContent || '').replace(/\\s+/g, ' ').trim();\n return filter.kind === 'text-is' ? text === filter.text : text.toLowerCase().includes(filter.text);\n};\n return found.filter((el) => parts.some((part) => el.matches(part.css) && part.filters.every((filter) => passes(el, filter))));\n})(selector, parts);\n})(selector, parts);\n const warnings = [];\n // Why a user could not reach the field (the value is still set, so scripted\n // flows keep working, but the result may not be what a user would see)\n const unreachableReason = (field) => {\n if (field.closest('[inert]')) return 'inert';\n const modal = field.ownerDocument.querySelector('dialog:modal');\n if (modal && !modal.contains(field)) return 'behind an open modal dialog';\n if (field.checkVisibility && !field.checkVisibility({ visibilityProperty: true, opacityProperty: true })) return 'hidden';\n return null;\n };\n // Why the browser did not take the value as given (it sanitizes instead of\n // throwing: bad dates become \"\", colors #000000, ranges are clamped)\n const rejectedValue = (field, type, text) => {\n const formats = {\n number: 'a number',\n date: 'YYYY-MM-DD',\n time: 'HH:MM',\n 'datetime-local': 'YYYY-MM-DDTHH:MM',\n month: 'YYYY-MM',\n week: 'YYYY-Www'\n };\n const rejected = 'The browser rejected \"' + text + '\" for a ' + type + ' field (it keeps its previous value)';\n if (formats[type] && text.trim() !== '' && field.value === '') {\n return { error: rejected, suggestion: 'Expected ' + formats[type] };\n }\n if (type === 'color' && field.value.toLowerCase() !== text.trim().toLowerCase()) {\n return { error: rejected, suggestion: 'Expected a hex color like #1a2b3c' };\n }\n if (type === 'range' && Number(field.value) !== Number(text)) {\n return {\n error: 'The browser would set ' + field.value + ' instead of \"' + text + '\" (range ' + (field.min || 0) + ' to ' + (field.max || 100) + ', step ' + (field.step || 1) + ')',\n suggestion: 'Use a value within the range that matches the step'\n };\n }\n return null;\n };\n \n if (allMatches.length === 0) {\n return { \n success: false, \n error: 'Element not found: ' + selector,\n selector: selector\n };\n }\n \n let el;\n const index = options.index;\n \n // If index is provided, use it directly (0-based)\n if (typeof index === 'number' && index >= 0) {\n if (index >= allMatches.length) {\n return {\n success: false,\n error: 'Index out of range',\n selector: selector,\n matchCount: allMatches.length,\n requestedIndex: index,\n suggestion: 'Use --index between 0 and ' + (allMatches.length - 1)\n };\n }\n el = allMatches[index];\n } else {\n el = allMatches[0];\n }\n \n const tagName = el.tagName.toLowerCase();\n const inputType = el.type?.toLowerCase();\n \n const isFillable = (\n tagName === 'input' || \n tagName === 'textarea' || \n tagName === 'select' ||\n el.isContentEditable\n );\n \n if (!isFillable) {\n return {\n success: false,\n error: 'Element is not fillable',\n elementType: tagName,\n suggestion: 'Only input, textarea, select, and contenteditable elements can be filled'\n };\n }\n\n if (el.disabled || el.matches(':disabled')) {\n return {\n success: false,\n error: 'Element is disabled',\n elementType: tagName,\n suggestion: 'Enable the field first (it may depend on another input)'\n };\n }\n if (el.readOnly) {\n return {\n success: false,\n error: 'Element is read-only',\n elementType: tagName,\n suggestion: 'Read-only fields cannot be filled'\n };\n }\n\n const unreachable = unreachableReason(el);\n if (unreachable) {\n warnings.push('The field is ' + unreachable + '; a user could not fill it (the value was set anyway)');\n }\n\n el.focus();\n\n if (tagName === 'select' && el.multiple) {\n // Several options, separated by commas (an option whose value contains a\n // comma matches as a whole); \"\" selects none\n const options = Array.from(el.options);\n const whole = options.some((o) => o.value === value || o.text.trim() === value);\n const wanted = whole ? [value] : value.split(',').map((part) => part.trim()).filter(Boolean);\n const chosen = wanted.map((part) =>\n options.find((o) => o.value === part) || options.find((o) => o.text.trim() === part)\n );\n const missing = wanted.find((part, i) => !chosen[i]);\n if (missing !== undefined) {\n return {\n success: false,\n error: 'Option not found: ' + missing,\n exitCode: 81,\n elementType: tagName,\n suggestion: 'Available options: ' + options.slice(0, 10).map((o) => o.value || o.text.trim()).join(', ')\n };\n }\n options.forEach((o) => { o.selected = chosen.includes(o); });\n el.dispatchEvent(new Event('input', { bubbles: true }));\n el.dispatchEvent(new Event('change', { bubbles: true }));\n } else if (tagName === 'select') {\n // Match by option value first, then by visible label\n const options = Array.from(el.options);\n const option =\n options.find((o) => o.value === value) ||\n options.find((o) => o.text.trim() === value);\n if (!option) {\n return {\n success: false,\n error: 'Option not found: ' + value,\n exitCode: 81,\n elementType: tagName,\n suggestion: 'Available options: ' + options.slice(0, 10).map((o) => o.value || o.text.trim()).join(', ')\n };\n }\n el.value = option.value;\n el.dispatchEvent(new Event('input', { bubbles: true }));\n el.dispatchEvent(new Event('change', { bubbles: true }));\n } else if (inputType === 'checkbox' || inputType === 'radio') {\n // Toggle through a click, like a user: frameworks (React) track checkable\n // state via click events and revert a programmatic `checked` assignment\n const normalized = String(value).trim().toLowerCase();\n const truthy = ['true', '1', 'yes', 'on', 'checked'];\n const falsy = ['false', '0', 'no', 'off', 'unchecked'];\n if (!truthy.includes(normalized) && !falsy.includes(normalized)) {\n return {\n success: false,\n error: 'Expected true or false for a ' + inputType + ', got \"' + value + '\"',\n elementType: tagName,\n inputType: inputType,\n suggestion: 'Use true/false (also yes/no, on/off, 1/0)'\n };\n }\n const shouldCheck = truthy.includes(normalized);\n if (inputType === 'radio' && !shouldCheck) {\n return {\n success: false,\n error: 'A radio button cannot be unchecked',\n elementType: tagName,\n inputType: inputType,\n suggestion: 'Select another option in the same group instead'\n };\n }\n if (el.checked !== shouldCheck) {\n el.click();\n }\n } else if (inputType === 'file') {\n return {\n success: false,\n fileInput: true,\n elementType: tagName,\n inputType: inputType,\n error: 'File input'\n };\n } else if (el.isContentEditable) {\n el.textContent = value;\n el.dispatchEvent(new Event('input', { bubbles: true }));\n } else {\n const nativeInputValueSetter = Object.getOwnPropertyDescriptor(\n el.ownerDocument.defaultView.HTMLInputElement.prototype,\n 'value'\n )?.set;\n \n const nativeTextAreaValueSetter = Object.getOwnPropertyDescriptor(\n el.ownerDocument.defaultView.HTMLTextAreaElement.prototype,\n 'value'\n )?.set;\n \n const setter = tagName === 'textarea' \n ? nativeTextAreaValueSetter \n : nativeInputValueSetter;\n\n if (el.maxLength > 0 && value.length > el.maxLength) {\n return {\n success: false,\n error: 'Value is ' + value.length + ' characters; the field accepts at most ' + el.maxLength,\n elementType: tagName,\n inputType: inputType || null,\n suggestion: 'Shorten the value (a user could not type more than maxlength characters)'\n };\n }\n \n const setValue = (text) => (setter ? setter.call(el, text) : (el.value = text));\n const previous = el.value;\n setValue(value);\n const rejection = rejectedValue(el, inputType, value);\n if (rejection) {\n setValue(previous);\n if (options.blur !== false) el.blur();\n return {\n success: false,\n error: rejection.error,\n elementType: tagName,\n inputType: inputType,\n suggestion: rejection.suggestion\n };\n }\n\n el.dispatchEvent(new Event('input', { bubbles: true }));\n el.dispatchEvent(new Event('change', { bubbles: true }));\n if (el.validity && (el.validity.rangeOverflow || el.validity.rangeUnderflow)) {\n 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');\n }\n }\n \n if (options.blur !== false) {\n el.blur();\n }\n \n return {\n success: true,\n selector: selector,\n value: el.isContentEditable\n ? el.textContent\n : inputType === 'password'\n ? '********'\n : tagName === 'select' && el.multiple\n ? Array.from(el.selectedOptions).map((o) => o.value).join(', ')\n : el.value,\n elementType: tagName,\n inputType: inputType || null,\n checked: inputType === 'checkbox' || inputType === 'radio' ? el.checked : undefined,\n matchCount: allMatches.length,\n warning: warnings.concat(allMatches.length > 1 && typeof index !== 'number'\n ? [allMatches.length + ' elements match; filled the first (use --index or a more specific selector)']\n : []).join('; ') || undefined\n };\n})\n";
55
+ export declare const REACT_FILL_SCRIPT: string;
56
+ /**
57
+ * Page-side search for the field a moved value went to: one of the fields
58
+ * recorded before the fill ({@link FIELD_VALUES_JS}) whose value changed to
59
+ * the one given (trimmed, at least 2 characters). Evaluates to its id or
60
+ * name (`input#first-name`, `input[name="first"]`) or else its
61
+ * description, or undefined. Passwords are never searched for.
62
+ */
63
+ export declare const MOVED_VALUE_JS = "(field, expected, before) => {\n const wanted = String(expected).trim();\n if (!before || wanted.length < 2 || (field.type || '').toLowerCase() === 'password') return undefined;\n const other = Array.from(before.keys()).find((f) =>\n String(f.value).trim() === wanted && String(before.get(f)).trim() !== wanted);\n if (!other) return undefined;\n if (other.id) return other.localName + '#' + other.id;\n return other.name ? other.localName + '[name=\"' + other.name + '\"]' : ((node) => node.tagName.toLowerCase() +\n (node.id ? '#' + node.id : '') +\n (node.classList && node.classList.length ? '.' + Array.from(node.classList).slice(0, 2).join('.') : ''))(other);\n}";
64
+ /**
65
+ * Page script reading back the field the last fill left in
66
+ * `window.__bdgFillCheck`, after one macrotask (so frameworks that render
67
+ * asynchronously have updated it). The macrotask comes from a
68
+ * `MessageChannel`, which fake timers and page code rarely replace. Evaluates
69
+ * to the mismatch ({@link FILL_VALUE_MISMATCH_JS}), with `movedTo` when the
70
+ * value turned up in another field ({@link MOVED_VALUE_JS}), or null when the
71
+ * value matches, nothing was left (the page navigated) or the field left the
72
+ * page.
73
+ */
74
+ export declare const FILL_READ_BACK_SCRIPT = "(() => {\n const check = window.__bdgFillCheck;\n delete window.__bdgFillCheck;\n if (!check) return null;\n const readBack = () => {\n if (!check.el.isConnected) return null;\n const mismatch = ((field, expected) => {\n const type = (field.type || '').toLowerCase();\n const actual = field.isContentEditable\n ? (field.textContent || '')\n : type === 'checkbox' || type === 'radio'\n ? (field.checked ? 'checked' : 'unchecked')\n : field.localName === 'select' && field.multiple\n ? Array.from(field.selectedOptions).map((o) => o.value).join(', ')\n : String(field.value);\n const time = (text) => text.trim().replace(' ', 'T').replace(/(\\d\\d:\\d\\d):00(\\.0+)?$/, '$1');\n const normalize = (text) => {\n text = String(text);\n if (field.isContentEditable) return text.replace(/\\s+/g, ' ').trim();\n if (field.localName === 'textarea') return text.replace(/\\r\\n?/g, '\\n');\n if (type === 'color') return text.trim().toLowerCase();\n if (type === 'email') return text.trim();\n if (type === 'time' || type === 'datetime-local') return time(text);\n return text;\n };\n const numeric = (type === 'number' || type === 'range') && actual.trim() !== '' && String(expected).trim() !== '';\n const same = numeric ? Number(actual) === Number(expected) : normalize(actual) === normalize(expected);\n if (same) return undefined;\n if (type === 'password') {\n const mask = (text) => (text === '' ? '' : '********');\n return { expected: mask(expected), actual: mask(actual), expectedLength: expected.length, actualLength: actual.length };\n }\n const cut = field.maxLength > 0 && actual.length === field.maxLength && expected.length > actual.length &&\n expected.startsWith(actual);\n return cut ? { expected: expected, actual: actual, truncatedTo: actual.length } : { expected: expected, actual: actual };\n})(check.el, check.expected);\n if (!mismatch) return null;\n const movedTo = ((field, expected, before) => {\n const wanted = String(expected).trim();\n if (!before || wanted.length < 2 || (field.type || '').toLowerCase() === 'password') return undefined;\n const other = Array.from(before.keys()).find((f) =>\n String(f.value).trim() === wanted && String(before.get(f)).trim() !== wanted);\n if (!other) return undefined;\n if (other.id) return other.localName + '#' + other.id;\n return other.name ? other.localName + '[name=\"' + other.name + '\"]' : ((node) => node.tagName.toLowerCase() +\n (node.id ? '#' + node.id : '') +\n (node.classList && node.classList.length ? '.' + Array.from(node.classList).slice(0, 2).join('.') : ''))(other);\n})(check.el, check.expected, check.before);\n return movedTo ? Object.assign(mismatch, { movedTo: movedTo }) : mismatch;\n };\n return new Promise((resolve) => {\n const channel = new MessageChannel();\n channel.port1.onmessage = () => {\n channel.port1.close();\n resolve(readBack());\n };\n channel.port2.postMessage(null);\n });\n})()";
21
75
  /**
22
76
  * JavaScript function to locate an element for clicking.
23
77
  *
@@ -30,8 +84,15 @@ export declare const REACT_FILL_SCRIPT = "\n(function(selector, parts, value, op
30
84
  * Handles both direct selector matching and indexed selection.
31
85
  * When index is provided, selects the nth matching element (0-based).
32
86
  * When selector matches multiple elements without index, prioritizes visible ones.
87
+ * A `<label>` is clicked (and double-clicked) through its control when that
88
+ * is visible ({@link LABEL_CONTROL_JS}), reported as e.g. `input (via label)`.
89
+ * A hover first notes what is hidden around the element
90
+ * ({@link REVEAL_SNAPSHOT_JS}), so its result can say what it revealed. For
91
+ * a press, a probe (`window.__bdgPressProbe`) records whether the press
92
+ * reaches the element, and otherwise which element it landed on: a browser
93
+ * dialog or bubble can swallow input while the page looks normal.
33
94
  */
34
- export declare const CLICK_ELEMENT_SCRIPT = "\n(function(selector, parts, index, action) {\n const allMatches = (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 visit = (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 passes = (el, filter) => {\n if (filter.kind === 'visible') {\n const rect = el.getBoundingClientRect();\n return rect.width > 0 && rect.height > 0 && el.ownerDocument.defaultView.getComputedStyle(el).visibility === 'visible';\n }\n const text = (typeof el.innerText === 'string' ? el.innerText : el.textContent || '').replace(/\\s+/g, ' ').trim();\n return filter.kind === 'text-is' ? text === filter.text : text.toLowerCase().includes(filter.text);\n};\n return found.filter((el) => parts.some((part) => el.matches(part.css) && part.filters.every((filter) => passes(el, filter))));\n})(selector, parts);\n})(selector, parts);\n \n if (allMatches.length === 0) {\n return {\n success: false,\n error: 'Element not found: ' + selector,\n selector: selector\n };\n }\n \n let el;\n \n // If index is provided, use it directly (0-based)\n if (typeof index === 'number' && index >= 0) {\n if (index >= allMatches.length) {\n return {\n success: false,\n error: 'Index out of range',\n selector: selector,\n matchCount: allMatches.length,\n requestedIndex: index,\n suggestion: 'Use --index between 0 and ' + (allMatches.length - 1)\n };\n }\n el = allMatches[index];\n } else if (allMatches.length === 1) {\n // Single match - use it directly\n el = allMatches[0];\n } else {\n // Multiple matches without index - find first visible one\n el = allMatches[0];\n for (const candidate of allMatches) {\n const style = candidate.ownerDocument.defaultView.getComputedStyle(candidate);\n const rect = candidate.getBoundingClientRect();\n \n const hasSize = rect.width > 0 && rect.height > 0;\n const isDisplayed = style.display !== 'none' && style.visibility !== 'hidden';\n const isOpaque = parseFloat(style.opacity) > 0;\n const isPositioned = candidate.offsetParent !== null || style.position === 'fixed';\n \n const isVisible = hasSize && isDisplayed && isOpaque && isPositioned;\n \n if (isVisible) {\n el = candidate;\n break;\n }\n }\n }\n \n const tagName = el.tagName.toLowerCase();\n if (tagName === 'option') {\n const quote = (text) => \"'\" + String(text).split(\"'\").join(\"'\\\\''\") + \"'\";\n const select = el.closest('select');\n const target = select && select.id ? '#' + select.id : select && select.name ? 'select[name=\"' + select.name + '\"]' : 'select';\n return {\n success: false,\n error: 'An <option> is chosen through its <select>, not clicked',\n selector: selector,\n elementType: tagName,\n exitCode: 81,\n suggestion: 'bdg dom fill ' + quote(target) + ' ' + quote(el.value || el.text.trim())\n };\n }\n // Disabled elements still get hover (tooltips often explain why)\n if (action !== 'hover' && (el.disabled || el.matches(':disabled'))) {\n return {\n success: false,\n error: 'Element is disabled',\n selector: selector,\n elementType: tagName,\n suggestion: 'A user cannot click a disabled element; enable it first (it may depend on other fields)'\n };\n }\n el.scrollIntoView({ behavior: 'auto', block: 'center' });\n\n // The caller clicks with real mouse events at (x, y) when the element is the\n // topmost thing there; otherwise it falls back to el.click() via this handle.\n // Hit-testing happens in the element's own root (shadow root or frame\n // document), then in each enclosing document at the frame's position, so an\n // overlay over the iframe counts as covering it. The mouse events need\n // top-page coordinates: frame offsets (border and padding) are added.\n window.__bdgClickTarget = el;\n const rect = el.getBoundingClientRect();\n const view = el.ownerDocument.defaultView;\n const hitTest = (node, px, py) => {\n const root = node.getRootNode();\n const hit = (typeof root.elementFromPoint === 'function' ? root : node.ownerDocument).elementFromPoint(px, py);\n return hit !== null && (hit === node || node.contains(hit));\n };\n const toTopPage = (px, py) => {\n let hittable = hitTest(el, px, py);\n for (let frameWindow = view; frameWindow && frameWindow.frameElement; frameWindow = frameWindow.parent) {\n const frame = frameWindow.frameElement;\n const frameRect = frame.getBoundingClientRect();\n const frameStyle = frame.ownerDocument.defaultView.getComputedStyle(frame);\n px += frameRect.left + frame.clientLeft + parseFloat(frameStyle.paddingLeft);\n py += frameRect.top + frame.clientTop + parseFloat(frameStyle.paddingTop);\n hittable = hittable && hitTest(frame, px, py);\n }\n return { x: px, y: py, hittable: hittable };\n };\n const left = Math.max(rect.left, 0);\n const right = Math.min(rect.right, view.innerWidth);\n const top = Math.max(rect.top, 0);\n const bottom = Math.min(rect.bottom, view.innerHeight);\n const area = right > left && bottom > top\n ? { left: left, top: top, width: right - left, height: bottom - top }\n : { left: rect.left, top: rect.top, width: rect.width, height: rect.height };\n 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]];\n const hasSize = rect.width > 0 && rect.height > 0;\n let point = null;\n for (const [fx, fy] of hasSize ? fractions : [[0.5, 0.5]]) {\n const candidate = toTopPage(area.left + area.width * fx, area.top + area.height * fy);\n point = point || candidate;\n if (hasSize && candidate.hittable) { point = candidate; break; }\n }\n const x = point.x;\n const y = point.y;\n const hittable = hasSize && point.hittable;\n const describe = (node) => node.tagName.toLowerCase() + (node.id ? '#' + node.id : '') +\n (node.classList && node.classList.length ? '.' + Array.from(node.classList).slice(0, 2).join('.') : '');\n const coveredBy = () => {\n const root = el.getRootNode();\n const hit = (typeof root.elementFromPoint === 'function' ? root : el.ownerDocument)\n .elementFromPoint(area.left + area.width / 2, area.top + area.height / 2);\n return hit && !el.contains(hit) ? ' (' + describe(hit) + ')' : '';\n };\n const style = view.getComputedStyle(el);\n let obstruction = null;\n if (style.display === 'none' || el.getClientRects().length === 0) obstruction = 'not rendered (display: none)';\n else if (style.visibility === 'hidden') obstruction = 'hidden (visibility: hidden)';\n else if (el.closest('[inert]')) obstruction = 'inert (the page made it non-interactive)';\n else if (style.pointerEvents === 'none') obstruction = 'not clickable (pointer-events: none)';\n else if (!hasSize) obstruction = 'zero-size';\n else if (!hittable) obstruction = 'covered by another element' + coveredBy();\n\n return {\n success: true,\n selector: selector,\n elementType: tagName,\n matchCount: allMatches.length,\n selectedIndex: typeof index === 'number' ? index : undefined,\n x: x,\n y: y,\n hittable: hittable,\n obstruction: obstruction\n };\n})\n";
95
+ export declare const CLICK_ELEMENT_SCRIPT: string;
35
96
  /**
36
97
  * Options for filling an element.
37
98
  */
@@ -5,7 +5,97 @@
5
5
  * This module provides JavaScript snippets that can be injected via Runtime.evaluate
6
6
  * to properly trigger React's event system.
7
7
  */
8
- import { FIND_ELEMENTS_JS } from './targetNode.js';
8
+ import { FILL_REFUSALS, LABEL_WITHOUT_CONTROL, NAME_QUERY_PLACEHOLDER, VIA_LABEL_SUFFIX, } from '../../errors/messages.js';
9
+ import { REVEAL_SNAPSHOT_JS } from './actionEffectsScripts.js';
10
+ import { ELEMENT_DESCRIPTION_JS, ELEMENT_IDENTITY_JS } from './elementInfo.js';
11
+ import { FIND_ELEMENTS_JS, LABEL_CONTROL_JS } from './targetNode.js';
12
+ /**
13
+ * Page-side read-back of a filled field: a mismatch when its value is not
14
+ * what was asked for (the page rejected, reformatted or moved the input),
15
+ * undefined when it is. Values are compared as the browser normalises them:
16
+ * colors case-insensitively, numbers and ranges as numbers, email trimmed,
17
+ * textarea line endings as `\n`, times and local date-times without zero
18
+ * seconds and with `T` (`2024-01-05 10:00:00` is `2024-01-05T10:00`).
19
+ * Checkboxes and radios compare as `checked`/`unchecked`, a multiple select
20
+ * as its selected values joined by ", ", contenteditable text with
21
+ * whitespace collapsed. A value cut to the field's maxlength sets
22
+ * `truncatedTo`; a password mismatch gives masked values and both lengths.
23
+ */
24
+ export const FILL_VALUE_MISMATCH_JS = `(field, expected) => {
25
+ const type = (field.type || '').toLowerCase();
26
+ const actual = field.isContentEditable
27
+ ? (field.textContent || '')
28
+ : type === 'checkbox' || type === 'radio'
29
+ ? (field.checked ? 'checked' : 'unchecked')
30
+ : field.localName === 'select' && field.multiple
31
+ ? Array.from(field.selectedOptions).map((o) => o.value).join(', ')
32
+ : String(field.value);
33
+ const time = (text) => text.trim().replace(' ', 'T').replace(/(\\d\\d:\\d\\d):00(\\.0+)?$/, '$1');
34
+ const normalize = (text) => {
35
+ text = String(text);
36
+ if (field.isContentEditable) return text.replace(/\\s+/g, ' ').trim();
37
+ if (field.localName === 'textarea') return text.replace(/\\r\\n?/g, '\\n');
38
+ if (type === 'color') return text.trim().toLowerCase();
39
+ if (type === 'email') return text.trim();
40
+ if (type === 'time' || type === 'datetime-local') return time(text);
41
+ return text;
42
+ };
43
+ const numeric = (type === 'number' || type === 'range') && actual.trim() !== '' && String(expected).trim() !== '';
44
+ const same = numeric ? Number(actual) === Number(expected) : normalize(actual) === normalize(expected);
45
+ if (same) return undefined;
46
+ if (type === 'password') {
47
+ const mask = (text) => (text === '' ? '' : '********');
48
+ return { expected: mask(expected), actual: mask(actual), expectedLength: expected.length, actualLength: actual.length };
49
+ }
50
+ const cut = field.maxLength > 0 && actual.length === field.maxLength && expected.length > actual.length &&
51
+ expected.startsWith(actual);
52
+ return cut ? { expected: expected, actual: actual, truncatedTo: actual.length } : { expected: expected, actual: actual };
53
+ }`;
54
+ /**
55
+ * Page-side list of the other text-like fields of a field's form (of the
56
+ * document when it has none), with their values: taken before a fill, so
57
+ * {@link MOVED_VALUE_JS} can tell which one the page changed.
58
+ */
59
+ export const FIELD_VALUES_JS = `(field) => {
60
+ const fields = field.form ? Array.from(field.form.elements) : Array.from(field.ownerDocument.querySelectorAll('input, textarea'));
61
+ return new Map(fields
62
+ .filter((f) => f !== field && /^(input|textarea)$/.test(f.localName) &&
63
+ !/^(checkbox|radio|password|hidden|submit|button|reset|file|image)$/i.test(f.type || ''))
64
+ .map((f) => [f, String(f.value)]));
65
+ }`;
66
+ /**
67
+ * Page-side reason `dom fill` cannot fill an element, or null when it can:
68
+ * a disabled or read-only form control (naming the attribute or the disabled
69
+ * `<fieldset>`), or for any other element the switch that keeps it from being
70
+ * editable: `contenteditable="false"` (on it or the editor around it), an
71
+ * `inert` ancestor, `aria-readonly` or `aria-disabled`; otherwise it is not a
72
+ * fillable kind of element. Messages are {@link FILL_REFUSALS}.
73
+ */
74
+ export const FILL_REFUSAL_JS = `(el) => {
75
+ const describe = ${ELEMENT_DESCRIPTION_JS};
76
+ const refusals = ${JSON.stringify(FILL_REFUSALS)};
77
+ const refuse = (kind, cause) => ({
78
+ kind: kind,
79
+ error: refusals[kind].message + (cause ? ' (' + cause + ')' : ''),
80
+ suggestion: refusals[kind].suggestion
81
+ });
82
+ const tag = el.localName;
83
+ 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
+ }
87
+ return el.readOnly ? refuse('readOnly', 'readonly attribute') : null;
88
+ }
89
+ if (el.isContentEditable) return null;
90
+ const host = el.closest('[contenteditable]');
91
+ if (host && String(host.getAttribute('contenteditable')).trim().toLowerCase() === 'false') {
92
+ return refuse('readOnly', 'contenteditable="false"' + (host === el ? '' : ' on ' + describe(host)));
93
+ }
94
+ if (el.closest('[inert]')) return refuse('inert', 'inside an inert element');
95
+ if (el.getAttribute('aria-readonly') === 'true') return refuse('readOnly', 'aria-readonly="true"');
96
+ if (el.getAttribute('aria-disabled') === 'true') return refuse('disabled', 'aria-disabled="true"');
97
+ return refuse('notFillable', '<' + tag + '> is not an input, textarea, select or contenteditable element');
98
+ }`;
9
99
  /**
10
100
  * JavaScript function to fill an input element in a React-compatible way.
11
101
  *
@@ -14,6 +104,13 @@ import { FIND_ELEMENTS_JS } from './targetNode.js';
14
104
  * 2. Dispatches input/change events that React listens for
15
105
  * 3. Properly handles focus/blur for form validation
16
106
  *
107
+ * A `<label>` is filled through its control ({@link LABEL_CONTROL_JS}),
108
+ * reported as e.g. `input (via label)`.
109
+ *
110
+ * The result is returned right away (a change handler may navigate). The
111
+ * field and the value to expect are left in `window.__bdgFillCheck` for
112
+ * {@link FILL_READ_BACK_SCRIPT}, which reads the value back a moment later.
113
+ *
17
114
  * @remarks
18
115
  * Works with React, Vue, Angular, and vanilla JS applications.
19
116
  */
@@ -21,11 +118,14 @@ export const REACT_FILL_SCRIPT = `
21
118
  (function(selector, parts, value, options) {
22
119
  const allMatches = (${FIND_ELEMENTS_JS})(selector, parts);
23
120
  const warnings = [];
121
+ let expected = value;
24
122
  // Why a user could not reach the field (the value is still set, so scripted
25
123
  // flows keep working, but the result may not be what a user would see)
26
124
  const unreachableReason = (field) => {
27
125
  if (field.closest('[inert]')) return 'inert';
28
- const modal = field.ownerDocument.querySelector('dialog:modal');
126
+ const shown = (node) => !node.checkVisibility || node.checkVisibility();
127
+ const modal = field.ownerDocument.querySelector('dialog:modal') ||
128
+ Array.from(field.ownerDocument.querySelectorAll('[aria-modal="true"]')).find(shown);
29
129
  if (modal && !modal.contains(field)) return 'behind an open modal dialog';
30
130
  if (field.checkVisibility && !field.checkVisibility({ visibilityProperty: true, opacityProperty: true })) return 'hidden';
31
131
  return null;
@@ -84,40 +184,33 @@ export const REACT_FILL_SCRIPT = `
84
184
  } else {
85
185
  el = allMatches[0];
86
186
  }
87
-
88
- const tagName = el.tagName.toLowerCase();
89
- const inputType = el.type?.toLowerCase();
90
-
91
- const isFillable = (
92
- tagName === 'input' ||
93
- tagName === 'textarea' ||
94
- tagName === 'select' ||
95
- el.isContentEditable
96
- );
97
-
98
- if (!isFillable) {
99
- return {
100
- success: false,
101
- error: 'Element is not fillable',
102
- elementType: tagName,
103
- suggestion: 'Only input, textarea, select, and contenteditable elements can be filled'
104
- };
105
- }
106
187
 
107
- if (el.disabled || el.matches(':disabled')) {
188
+ const labelControl = (${LABEL_CONTROL_JS})(el);
189
+ if (el.localName === 'label' && !labelControl) {
190
+ const quote = (text) => "'" + String(text).split("'").join("'\\\\''") + "'";
191
+ const labelText = (el.textContent || '').replace(/\\s+/g, ' ').trim().slice(0, 60);
108
192
  return {
109
193
  success: false,
110
- error: 'Element is disabled',
111
- elementType: tagName,
112
- suggestion: 'Enable the field first (it may depend on another input)'
194
+ error: ${JSON.stringify(LABEL_WITHOUT_CONTROL.message)},
195
+ elementType: 'label',
196
+ unsuitableElement: true,
197
+ suggestion: ${JSON.stringify(LABEL_WITHOUT_CONTROL.suggestion)}.split(${JSON.stringify(NAME_QUERY_PLACEHOLDER)}).join(quote('name=' + labelText))
113
198
  };
114
199
  }
115
- if (el.readOnly) {
200
+ const viaLabel = labelControl ? ${JSON.stringify(VIA_LABEL_SUFFIX)} : '';
201
+ if (labelControl) el = labelControl;
202
+
203
+ const tagName = el.tagName.toLowerCase();
204
+ const inputType = el.type?.toLowerCase();
205
+
206
+ const refusal = (${FILL_REFUSAL_JS})(el);
207
+ if (refusal) {
116
208
  return {
117
209
  success: false,
118
- error: 'Element is read-only',
119
- elementType: tagName,
120
- suggestion: 'Read-only fields cannot be filled'
210
+ error: refusal.error,
211
+ elementType: tagName + viaLabel,
212
+ suggestion: refusal.suggestion,
213
+ unsuitableElement: refusal.kind === 'notFillable'
121
214
  };
122
215
  }
123
216
 
@@ -126,6 +219,7 @@ export const REACT_FILL_SCRIPT = `
126
219
  warnings.push('The field is ' + unreachable + '; a user could not fill it (the value was set anyway)');
127
220
  }
128
221
 
222
+ const fieldsBefore = (${FIELD_VALUES_JS})(el);
129
223
  el.focus();
130
224
 
131
225
  if (tagName === 'select' && el.multiple) {
@@ -143,11 +237,12 @@ export const REACT_FILL_SCRIPT = `
143
237
  success: false,
144
238
  error: 'Option not found: ' + missing,
145
239
  exitCode: 81,
146
- elementType: tagName,
240
+ elementType: tagName + viaLabel,
147
241
  suggestion: 'Available options: ' + options.slice(0, 10).map((o) => o.value || o.text.trim()).join(', ')
148
242
  };
149
243
  }
150
244
  options.forEach((o) => { o.selected = chosen.includes(o); });
245
+ expected = chosen.map((o) => o.value).join(', ');
151
246
  el.dispatchEvent(new Event('input', { bubbles: true }));
152
247
  el.dispatchEvent(new Event('change', { bubbles: true }));
153
248
  } else if (tagName === 'select') {
@@ -161,11 +256,12 @@ export const REACT_FILL_SCRIPT = `
161
256
  success: false,
162
257
  error: 'Option not found: ' + value,
163
258
  exitCode: 81,
164
- elementType: tagName,
259
+ elementType: tagName + viaLabel,
165
260
  suggestion: 'Available options: ' + options.slice(0, 10).map((o) => o.value || o.text.trim()).join(', ')
166
261
  };
167
262
  }
168
263
  el.value = option.value;
264
+ expected = option.value;
169
265
  el.dispatchEvent(new Event('input', { bubbles: true }));
170
266
  el.dispatchEvent(new Event('change', { bubbles: true }));
171
267
  } else if (inputType === 'checkbox' || inputType === 'radio') {
@@ -178,7 +274,7 @@ export const REACT_FILL_SCRIPT = `
178
274
  return {
179
275
  success: false,
180
276
  error: 'Expected true or false for a ' + inputType + ', got "' + value + '"',
181
- elementType: tagName,
277
+ elementType: tagName + viaLabel,
182
278
  inputType: inputType,
183
279
  suggestion: 'Use true/false (also yes/no, on/off, 1/0)'
184
280
  };
@@ -188,11 +284,12 @@ export const REACT_FILL_SCRIPT = `
188
284
  return {
189
285
  success: false,
190
286
  error: 'A radio button cannot be unchecked',
191
- elementType: tagName,
287
+ elementType: tagName + viaLabel,
192
288
  inputType: inputType,
193
289
  suggestion: 'Select another option in the same group instead'
194
290
  };
195
291
  }
292
+ expected = shouldCheck ? 'checked' : 'unchecked';
196
293
  if (el.checked !== shouldCheck) {
197
294
  el.click();
198
295
  }
@@ -200,7 +297,7 @@ export const REACT_FILL_SCRIPT = `
200
297
  return {
201
298
  success: false,
202
299
  fileInput: true,
203
- elementType: tagName,
300
+ elementType: tagName + viaLabel,
204
301
  inputType: inputType,
205
302
  error: 'File input'
206
303
  };
@@ -226,7 +323,7 @@ export const REACT_FILL_SCRIPT = `
226
323
  return {
227
324
  success: false,
228
325
  error: 'Value is ' + value.length + ' characters; the field accepts at most ' + el.maxLength,
229
- elementType: tagName,
326
+ elementType: tagName + viaLabel,
230
327
  inputType: inputType || null,
231
328
  suggestion: 'Shorten the value (a user could not type more than maxlength characters)'
232
329
  };
@@ -242,7 +339,7 @@ export const REACT_FILL_SCRIPT = `
242
339
  return {
243
340
  success: false,
244
341
  error: rejection.error,
245
- elementType: tagName,
342
+ elementType: tagName + viaLabel,
246
343
  inputType: inputType,
247
344
  suggestion: rejection.suggestion
248
345
  };
@@ -258,7 +355,8 @@ export const REACT_FILL_SCRIPT = `
258
355
  if (options.blur !== false) {
259
356
  el.blur();
260
357
  }
261
-
358
+ window.__bdgFillCheck = { el: el, expected: expected, before: fieldsBefore };
359
+
262
360
  return {
263
361
  success: true,
264
362
  selector: selector,
@@ -269,7 +367,8 @@ export const REACT_FILL_SCRIPT = `
269
367
  : tagName === 'select' && el.multiple
270
368
  ? Array.from(el.selectedOptions).map((o) => o.value).join(', ')
271
369
  : el.value,
272
- elementType: tagName,
370
+ element: (${ELEMENT_IDENTITY_JS})(el),
371
+ elementType: tagName + viaLabel,
273
372
  inputType: inputType || null,
274
373
  checked: inputType === 'checkbox' || inputType === 'radio' ? el.checked : undefined,
275
374
  matchCount: allMatches.length,
@@ -279,6 +378,52 @@ export const REACT_FILL_SCRIPT = `
279
378
  };
280
379
  })
281
380
  `;
381
+ /**
382
+ * Page-side search for the field a moved value went to: one of the fields
383
+ * recorded before the fill ({@link FIELD_VALUES_JS}) whose value changed to
384
+ * the one given (trimmed, at least 2 characters). Evaluates to its id or
385
+ * name (`input#first-name`, `input[name="first"]`) or else its
386
+ * description, or undefined. Passwords are never searched for.
387
+ */
388
+ export const MOVED_VALUE_JS = `(field, expected, before) => {
389
+ const wanted = String(expected).trim();
390
+ if (!before || wanted.length < 2 || (field.type || '').toLowerCase() === 'password') return undefined;
391
+ const other = Array.from(before.keys()).find((f) =>
392
+ String(f.value).trim() === wanted && String(before.get(f)).trim() !== wanted);
393
+ if (!other) return undefined;
394
+ if (other.id) return other.localName + '#' + other.id;
395
+ return other.name ? other.localName + '[name="' + other.name + '"]' : (${ELEMENT_DESCRIPTION_JS})(other);
396
+ }`;
397
+ /**
398
+ * Page script reading back the field the last fill left in
399
+ * `window.__bdgFillCheck`, after one macrotask (so frameworks that render
400
+ * asynchronously have updated it). The macrotask comes from a
401
+ * `MessageChannel`, which fake timers and page code rarely replace. Evaluates
402
+ * to the mismatch ({@link FILL_VALUE_MISMATCH_JS}), with `movedTo` when the
403
+ * value turned up in another field ({@link MOVED_VALUE_JS}), or null when the
404
+ * value matches, nothing was left (the page navigated) or the field left the
405
+ * page.
406
+ */
407
+ export const FILL_READ_BACK_SCRIPT = `(() => {
408
+ const check = window.__bdgFillCheck;
409
+ delete window.__bdgFillCheck;
410
+ if (!check) return null;
411
+ const readBack = () => {
412
+ if (!check.el.isConnected) return null;
413
+ const mismatch = (${FILL_VALUE_MISMATCH_JS})(check.el, check.expected);
414
+ if (!mismatch) return null;
415
+ const movedTo = (${MOVED_VALUE_JS})(check.el, check.expected, check.before);
416
+ return movedTo ? Object.assign(mismatch, { movedTo: movedTo }) : mismatch;
417
+ };
418
+ return new Promise((resolve) => {
419
+ const channel = new MessageChannel();
420
+ channel.port1.onmessage = () => {
421
+ channel.port1.close();
422
+ resolve(readBack());
423
+ };
424
+ channel.port2.postMessage(null);
425
+ });
426
+ })()`;
282
427
  /**
283
428
  * JavaScript function to locate an element for clicking.
284
429
  *
@@ -291,9 +436,20 @@ export const REACT_FILL_SCRIPT = `
291
436
  * Handles both direct selector matching and indexed selection.
292
437
  * When index is provided, selects the nth matching element (0-based).
293
438
  * When selector matches multiple elements without index, prioritizes visible ones.
439
+ * A `<label>` is clicked (and double-clicked) through its control when that
440
+ * is visible ({@link LABEL_CONTROL_JS}), reported as e.g. `input (via label)`.
441
+ * A hover first notes what is hidden around the element
442
+ * ({@link REVEAL_SNAPSHOT_JS}), so its result can say what it revealed. For
443
+ * a press, a probe (`window.__bdgPressProbe`) records whether the press
444
+ * reaches the element, and otherwise which element it landed on: a browser
445
+ * dialog or bubble can swallow input while the page looks normal.
294
446
  */
295
447
  export const CLICK_ELEMENT_SCRIPT = `
296
448
  (function(selector, parts, index, action) {
449
+ if (window.__bdgPressProbe) {
450
+ window.__bdgPressProbe.stop();
451
+ delete window.__bdgPressProbe;
452
+ }
297
453
  const allMatches = (${FIND_ELEMENTS_JS})(selector, parts);
298
454
 
299
455
  if (allMatches.length === 0) {
@@ -343,6 +499,14 @@ export const CLICK_ELEMENT_SCRIPT = `
343
499
  }
344
500
  }
345
501
 
502
+ const labelControl = action === 'click' || action === 'double' ? (${LABEL_CONTROL_JS})(el) : null;
503
+ const viaLabel = labelControl &&
504
+ (typeof labelControl.checkVisibility !== 'function' || labelControl.checkVisibility({ visibilityProperty: true, opacityProperty: true })) &&
505
+ labelControl.getClientRects().length > 0
506
+ ? ${JSON.stringify(VIA_LABEL_SUFFIX)}
507
+ : '';
508
+ if (viaLabel) el = labelControl;
509
+
346
510
  const tagName = el.tagName.toLowerCase();
347
511
  if (tagName === 'option') {
348
512
  const quote = (text) => "'" + String(text).split("'").join("'\\\\''") + "'";
@@ -352,7 +516,7 @@ export const CLICK_ELEMENT_SCRIPT = `
352
516
  success: false,
353
517
  error: 'An <option> is chosen through its <select>, not clicked',
354
518
  selector: selector,
355
- elementType: tagName,
519
+ elementType: tagName + viaLabel,
356
520
  exitCode: 81,
357
521
  suggestion: 'bdg dom fill ' + quote(target) + ' ' + quote(el.value || el.text.trim())
358
522
  };
@@ -363,7 +527,7 @@ export const CLICK_ELEMENT_SCRIPT = `
363
527
  success: false,
364
528
  error: 'Element is disabled',
365
529
  selector: selector,
366
- elementType: tagName,
530
+ elementType: tagName + viaLabel,
367
531
  suggestion: 'A user cannot click a disabled element; enable it first (it may depend on other fields)'
368
532
  };
369
533
  }
@@ -430,10 +594,25 @@ export const CLICK_ELEMENT_SCRIPT = `
430
594
  else if (!hasSize) obstruction = 'zero-size';
431
595
  else if (!hittable) obstruction = 'covered by another element' + coveredBy();
432
596
 
597
+ if (action === 'hover') (${REVEAL_SNAPSHOT_JS})(el);
598
+
599
+ if (hittable && action !== 'hover') {
600
+ const probe = { reached: false, landedOn: null };
601
+ const markReached = (event) => {
602
+ const path = event.composedPath();
603
+ if (path.includes(el)) probe.reached = true;
604
+ else if (!probe.landedOn && path[0] && path[0].nodeType === 1) probe.landedOn = describe(path[0]);
605
+ };
606
+ ['pointerdown', 'mousedown'].forEach((type) => view.addEventListener(type, markReached, true));
607
+ probe.stop = () => ['pointerdown', 'mousedown'].forEach((type) => view.removeEventListener(type, markReached, true));
608
+ window.__bdgPressProbe = probe;
609
+ }
610
+
433
611
  return {
434
612
  success: true,
435
613
  selector: selector,
436
- elementType: tagName,
614
+ element: (${ELEMENT_IDENTITY_JS})(el),
615
+ elementType: tagName + viaLabel,
437
616
  matchCount: allMatches.length,
438
617
  selectedIndex: typeof index === 'number' ? index : undefined,
439
618
  x: x,