browser-debugger-cli 0.8.0 → 0.9.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 (251) hide show
  1. package/README.md +4 -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 +27 -5
  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 +152 -113
  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/query.d.ts +44 -17
  27. package/dist/commands/dom/helpers/query.js +244 -97
  28. package/dist/commands/dom/helpers/runElementCommand.d.ts +10 -2
  29. package/dist/commands/dom/helpers/runElementCommand.js +97 -30
  30. package/dist/commands/dom/helpers/screenshot.d.ts +4 -1
  31. package/dist/commands/dom/helpers/screenshot.js +164 -49
  32. package/dist/commands/dom/index.d.ts +3 -1
  33. package/dist/commands/dom/index.js +16 -6
  34. package/dist/commands/dom/layout.d.ts +14 -0
  35. package/dist/commands/dom/layout.js +54 -0
  36. package/dist/commands/dom/listeners.d.ts +5 -1
  37. package/dist/commands/dom/listeners.js +13 -3
  38. package/dist/commands/dom/query.js +2 -3
  39. package/dist/commands/dom/screenshot.d.ts +12 -2
  40. package/dist/commands/dom/screenshot.js +27 -3
  41. package/dist/commands/dom/semanticUtils.d.ts +6 -13
  42. package/dist/commands/dom/semanticUtils.js +15 -19
  43. package/dist/commands/dom/wait.d.ts +13 -0
  44. package/dist/commands/dom/wait.js +83 -0
  45. package/dist/commands/helpJson.js +2 -2
  46. package/dist/commands/network/list.js +4 -11
  47. package/dist/commands/optionBehaviors.js +112 -21
  48. package/dist/commands/page.d.ts +2 -1
  49. package/dist/commands/page.js +41 -5
  50. package/dist/commands/peek.js +4 -11
  51. package/dist/commands/sessions.d.ts +8 -0
  52. package/dist/commands/sessions.js +19 -0
  53. package/dist/commands/shared/CommandRunner.js +4 -4
  54. package/dist/commands/shared/dataFetcher.js +2 -2
  55. package/dist/commands/shared/followMode.d.ts +21 -1
  56. package/dist/commands/shared/followMode.js +29 -2
  57. package/dist/commands/shared/handleValidationError.d.ts +2 -2
  58. package/dist/commands/shared/handleValidationError.js +12 -3
  59. package/dist/commands/shared/optionTypes.d.ts +40 -5
  60. package/dist/commands/shared/startHelpers.js +12 -3
  61. package/dist/commands/shared/validation.d.ts +3 -2
  62. package/dist/commands/shared/validation.js +4 -3
  63. package/dist/commands/start.d.ts +63 -0
  64. package/dist/commands/start.js +115 -15
  65. package/dist/commands/status.js +29 -7
  66. package/dist/commands/stop.js +7 -6
  67. package/dist/commands/tail.js +4 -11
  68. package/dist/commands/types.d.ts +2 -0
  69. package/dist/commands.js +2 -0
  70. package/dist/connection/chromeIdentity.d.ts +65 -0
  71. package/dist/connection/chromeIdentity.js +143 -0
  72. package/dist/connection/launcher/profilePreferences.d.ts +47 -0
  73. package/dist/connection/launcher/profilePreferences.js +151 -0
  74. package/dist/connection/launcher.d.ts +21 -2
  75. package/dist/connection/launcher.js +42 -16
  76. package/dist/connection/portReservation.d.ts +14 -4
  77. package/dist/connection/portReservation.js +21 -6
  78. package/dist/connection/startupExit.d.ts +8 -0
  79. package/dist/connection/startupExit.js +15 -6
  80. package/dist/constants.d.ts +6 -2
  81. package/dist/constants.js +9 -2
  82. package/dist/daemon/SessionController.js +23 -7
  83. package/dist/daemon/errors.d.ts +1 -1
  84. package/dist/daemon/errors.js +1 -1
  85. package/dist/daemon/launcher.d.ts +2 -1
  86. package/dist/daemon/launcher.js +5 -6
  87. package/dist/daemon/server/SocketServer.js +1 -2
  88. package/dist/daemon/session/Session.d.ts +13 -0
  89. package/dist/daemon/session/Session.js +57 -8
  90. package/dist/daemon/session/chromeConnection.d.ts +9 -0
  91. package/dist/daemon/session/chromeConnection.js +45 -8
  92. package/dist/daemon/session/commandRegistry.js +52 -62
  93. package/dist/daemon/session/interactions.d.ts +35 -9
  94. package/dist/daemon/session/interactions.js +36 -9
  95. package/dist/daemon/session/triggeredRequests.d.ts +67 -0
  96. package/dist/daemon/session/triggeredRequests.js +157 -0
  97. package/dist/daemon/session/types.d.ts +5 -1
  98. package/dist/daemon.js +5393 -1600
  99. package/dist/errors/messages.d.ts +387 -24
  100. package/dist/errors/messages.js +761 -67
  101. package/dist/index.js +3976 -1558
  102. package/dist/ipc/client.d.ts +12 -1
  103. package/dist/ipc/client.js +22 -3
  104. package/dist/ipc/protocol/commands.d.ts +89 -4
  105. package/dist/ipc/protocol/commands.js +2 -0
  106. package/dist/ipc/protocol/domTypes.d.ts +258 -7
  107. package/dist/ipc/session/lifecycle.d.ts +8 -1
  108. package/dist/ipc/session/queries.d.ts +5 -1
  109. package/dist/ipc/session/types.d.ts +5 -0
  110. package/dist/ipc/transport/index.d.ts +2 -1
  111. package/dist/ipc/transport/index.js +2 -2
  112. package/dist/runtime/dom/actionEffects.d.ts +106 -0
  113. package/dist/runtime/dom/actionEffects.js +256 -0
  114. package/dist/runtime/dom/actionEffectsScripts.d.ts +52 -0
  115. package/dist/runtime/dom/actionEffectsScripts.js +234 -0
  116. package/dist/runtime/dom/elementGeometry.d.ts +170 -0
  117. package/dist/runtime/dom/elementGeometry.js +553 -0
  118. package/dist/runtime/dom/elementInfo.d.ts +77 -0
  119. package/dist/runtime/dom/elementInfo.js +191 -0
  120. package/dist/runtime/dom/evalHelpers.d.ts +51 -6
  121. package/dist/runtime/dom/evalHelpers.js +136 -26
  122. package/dist/runtime/dom/eventListeners.d.ts +2 -1
  123. package/dist/runtime/dom/eventListeners.js +174 -47
  124. package/dist/runtime/dom/formDiscovery.d.ts +1 -1
  125. package/dist/runtime/dom/formDiscovery.js +116 -16
  126. package/dist/runtime/dom/formFillHelpers/fill.d.ts +10 -0
  127. package/dist/runtime/dom/formFillHelpers/fill.js +125 -10
  128. package/dist/runtime/dom/formFillHelpers/index.d.ts +2 -2
  129. package/dist/runtime/dom/formFillHelpers/index.js +2 -2
  130. package/dist/runtime/dom/formFillHelpers/pressKey.js +14 -3
  131. package/dist/runtime/dom/formFillHelpers/scroll.d.ts +3 -0
  132. package/dist/runtime/dom/formFillHelpers/scroll.js +60 -18
  133. package/dist/runtime/dom/formFillHelpers/shared.d.ts +17 -0
  134. package/dist/runtime/dom/formFillHelpers/shared.js +25 -1
  135. package/dist/runtime/dom/formFillHelpers/stability.d.ts +20 -6
  136. package/dist/runtime/dom/formFillHelpers/stability.js +50 -19
  137. package/dist/runtime/dom/formSubmitHelpers.d.ts +3 -0
  138. package/dist/runtime/dom/formSubmitHelpers.js +89 -15
  139. package/dist/runtime/dom/frameLayout.d.ts +60 -0
  140. package/dist/runtime/dom/frameLayout.js +140 -0
  141. package/dist/runtime/dom/frameOrigin.d.ts +50 -0
  142. package/dist/runtime/dom/frameOrigin.js +62 -0
  143. package/dist/runtime/dom/frameScopedConnection.d.ts +92 -0
  144. package/dist/runtime/dom/frameScopedConnection.js +252 -0
  145. package/dist/runtime/dom/frameSelection.d.ts +1 -1
  146. package/dist/runtime/dom/frameSelection.js +2 -2
  147. package/dist/runtime/dom/frames.d.ts +25 -2
  148. package/dist/runtime/dom/frames.js +202 -63
  149. package/dist/runtime/dom/layout.d.ts +67 -0
  150. package/dist/runtime/dom/layout.js +333 -0
  151. package/dist/runtime/dom/listenerPageScripts.d.ts +66 -0
  152. package/dist/runtime/dom/listenerPageScripts.js +279 -0
  153. package/dist/runtime/dom/listenerSummary.d.ts +132 -11
  154. package/dist/runtime/dom/listenerSummary.js +344 -22
  155. package/dist/runtime/dom/pageActivity.d.ts +41 -0
  156. package/dist/runtime/dom/pageActivity.js +123 -0
  157. package/dist/runtime/dom/reactEventHelpers.d.ts +58 -2
  158. package/dist/runtime/dom/reactEventHelpers.js +212 -41
  159. package/dist/runtime/dom/targetNode.d.ts +80 -27
  160. package/dist/runtime/dom/targetNode.js +249 -33
  161. package/dist/runtime/dom/wait.d.ts +25 -0
  162. package/dist/runtime/dom/wait.js +199 -0
  163. package/dist/runtime/dom/waitCondition.d.ts +71 -0
  164. package/dist/runtime/dom/waitCondition.js +75 -0
  165. package/dist/runtime/page/emulation.d.ts +51 -0
  166. package/dist/runtime/page/emulation.js +80 -0
  167. package/dist/runtime/page/loadingState.d.ts +36 -0
  168. package/dist/runtime/page/loadingState.js +86 -0
  169. package/dist/runtime/page/navigation.d.ts +46 -2
  170. package/dist/runtime/page/navigation.js +69 -33
  171. package/dist/session/QueryCacheManager.d.ts +11 -1
  172. package/dist/session/QueryCacheManager.js +25 -3
  173. package/dist/session/chromeOwners.d.ts +34 -0
  174. package/dist/session/chromeOwners.js +51 -0
  175. package/dist/session/cleanup/staleSession.d.ts +11 -1
  176. package/dist/session/cleanup/staleSession.js +17 -6
  177. package/dist/session/cleanup/userCommands.js +2 -4
  178. package/dist/session/metadata.d.ts +5 -1
  179. package/dist/session/metadata.js +2 -1
  180. package/dist/session/paths.d.ts +77 -3
  181. package/dist/session/paths.js +111 -5
  182. package/dist/session/port.d.ts +31 -7
  183. package/dist/session/port.js +50 -43
  184. package/dist/session/portClaims.d.ts +66 -0
  185. package/dist/session/portClaims.js +284 -0
  186. package/dist/session/sessionList.d.ts +58 -0
  187. package/dist/session/sessionList.js +199 -0
  188. package/dist/session/sessionName.d.ts +46 -0
  189. package/dist/session/sessionName.js +97 -0
  190. package/dist/telemetry/a11y.d.ts +8 -3
  191. package/dist/telemetry/a11y.js +92 -28
  192. package/dist/telemetry/requestKinds.d.ts +32 -0
  193. package/dist/telemetry/requestKinds.js +61 -0
  194. package/dist/telemetry/requestState.d.ts +31 -0
  195. package/dist/telemetry/requestState.js +38 -0
  196. package/dist/types.d.ts +80 -3
  197. package/dist/ui/formatters/a11y.js +3 -0
  198. package/dist/ui/formatters/console/chronological.d.ts +8 -0
  199. package/dist/ui/formatters/console/chronological.js +17 -4
  200. package/dist/ui/formatters/console/json.js +3 -4
  201. package/dist/ui/formatters/console/shared.d.ts +12 -0
  202. package/dist/ui/formatters/console.d.ts +2 -2
  203. package/dist/ui/formatters/console.js +1 -1
  204. package/dist/ui/formatters/details.js +2 -1
  205. package/dist/ui/formatters/dom.d.ts +26 -14
  206. package/dist/ui/formatters/dom.js +65 -52
  207. package/dist/ui/formatters/form.js +29 -18
  208. package/dist/ui/formatters/layout.d.ts +31 -0
  209. package/dist/ui/formatters/layout.js +53 -0
  210. package/dist/ui/formatters/listeners.d.ts +3 -2
  211. package/dist/ui/formatters/listeners.js +73 -9
  212. package/dist/ui/formatters/networkHeaders.js +13 -0
  213. package/dist/ui/formatters/preview.js +2 -1
  214. package/dist/ui/formatters/requestStatus.d.ts +1 -17
  215. package/dist/ui/formatters/requestStatus.js +2 -30
  216. package/dist/ui/formatters/sessions.d.ts +12 -0
  217. package/dist/ui/formatters/sessions.js +40 -0
  218. package/dist/ui/formatters/status.d.ts +21 -2
  219. package/dist/ui/formatters/status.js +47 -10
  220. package/dist/ui/formatters/triggeredRequests.d.ts +36 -0
  221. package/dist/ui/formatters/triggeredRequests.js +65 -0
  222. package/dist/ui/formatting.d.ts +10 -0
  223. package/dist/ui/formatting.js +28 -36
  224. package/dist/ui/messages/chrome.d.ts +9 -0
  225. package/dist/ui/messages/chrome.js +17 -5
  226. package/dist/ui/messages/commands.d.ts +388 -14
  227. package/dist/ui/messages/commands.js +664 -21
  228. package/dist/ui/messages/consoleMessages.d.ts +10 -0
  229. package/dist/ui/messages/consoleMessages.js +17 -0
  230. package/dist/ui/messages/hints.js +2 -1
  231. package/dist/ui/messages/preview.js +5 -4
  232. package/dist/ui/messages/session.d.ts +16 -21
  233. package/dist/ui/messages/session.js +28 -26
  234. package/dist/ui/messages/sessionCommand.d.ts +43 -0
  235. package/dist/ui/messages/sessionCommand.js +52 -0
  236. package/dist/utils/async.d.ts +8 -0
  237. package/dist/utils/async.js +19 -0
  238. package/dist/utils/http.d.ts +22 -1
  239. package/dist/utils/http.js +28 -9
  240. package/dist/utils/selectorFilters.d.ts +36 -8
  241. package/dist/utils/selectorFilters.js +267 -53
  242. package/dist/utils/shellDetection.d.ts +8 -2
  243. package/dist/utils/shellDetection.js +120 -33
  244. package/dist/utils/suggestions.d.ts +26 -0
  245. package/dist/utils/suggestions.js +73 -0
  246. package/dist/utils/taskMappings.js +10 -0
  247. package/dist/utils/url.d.ts +12 -2
  248. package/dist/utils/url.js +69 -7
  249. package/package.json +1 -1
  250. package/dist/ui/formatters/sessionFormatters.d.ts +0 -58
  251. package/dist/ui/formatters/sessionFormatters.js +0 -121
@@ -4,15 +4,17 @@
4
4
  * Collects the listeners of the element, every ancestor (through open shadow
5
5
  * roots), its document and its window with `DOMDebugger.getEventListeners`,
6
6
  * so handlers that frameworks attach by delegation (React on its root
7
- * container, jQuery on `document`) are found too. The Debugger domain is not
7
+ * container, jQuery on `document`) are found too, along with the handlers
8
+ * behind them (jQuery's handlers, React's `on…` props). The Debugger domain is not
8
9
  * enabled: that would make `debugger;` statements pause the page.
9
10
  */
10
11
  import { CDPProtocolError } from '../../connection/errors.js';
11
12
  import { CommandError } from '../../errors/index.js';
12
- import { eventListenersUnavailableError, indexOutOfRangeError, noNodesFoundError, operationFailedError, staleNodeError, } from '../../errors/messages.js';
13
+ import { eventListenersUnavailableError, operationFailedError } from '../../errors/messages.js';
13
14
  import { throwIfInvalidSelector, withMultipleMatchesWarning, } from './formFillHelpers/shared.js';
14
- import { buildListenerReport, describeChainEntry, } from './listenerSummary.js';
15
- import { DEEP_QUERY_JS, selectorArgsJS } from './targetNode.js';
15
+ import { ELEMENT_INFO_JS } from './listenerPageScripts.js';
16
+ import { buildListenerReport, describeChainEntry, suggestEventTypes, } from './listenerSummary.js';
17
+ import { DEEP_QUERY_JS, missingElementError, selectorArgsJS } from './targetNode.js';
16
18
  import { createLogger } from '../../ui/logging/index.js';
17
19
  import { getErrorMessage } from '../../utils/errors.js';
18
20
  import { EXIT_CODES } from '../../utils/exitCodes.js';
@@ -41,10 +43,6 @@ const CHAIN_JS = `function () {
41
43
  if (view) chain.push(view);
42
44
  return chain;
43
45
  }`;
44
- /** Page function: names of the given functions (null for non-functions) */
45
- const NAMES_JS = `function (...fns) {
46
- return fns.map((fn) => (typeof fn === 'function' ? fn.name : null));
47
- }`;
48
46
  /** Distinguishes the object groups of concurrent calls */
49
47
  let groupCounter = 0;
50
48
  /**
@@ -60,15 +58,24 @@ export async function inspectEventListeners(cdp, params) {
60
58
  const objectGroup = `bdg-listeners-${++groupCounter}`;
61
59
  try {
62
60
  const { matchCount, chain } = await findEventTargetChain(cdp, params, objectGroup);
63
- const found = await collectListeners(cdp, chain);
64
- const handlers = found.flatMap((entry) => entry.listeners.map((l) => l.handler));
65
- const names = await handlerNames(cdp, chain[0]?.objectId ?? '', handlers);
61
+ const collected = await collectListeners(cdp, chain);
62
+ const page = await pageDetails(cdp, chain, collected, objectGroup, params.types);
63
+ const found = collected.map((item) => {
64
+ const framework = page.roots[item.position];
65
+ return framework ? { ...item, entry: { ...item.entry, framework } } : item;
66
+ });
67
+ const report = buildListenerReport(found, page.details, params, page.react);
66
68
  const result = {
67
69
  success: true,
68
70
  selector: params.selector,
69
71
  ...(params.index !== undefined && { index: params.index }),
70
72
  element: describeChainEntry(chain[0] ?? {}),
71
- listeners: buildListenerReport(found, names, params.types),
73
+ ...(page.frame && { frame: page.frame }),
74
+ listeners: report.listeners,
75
+ ...(report.collapsed.length > 0 && { collapsed: report.collapsed }),
76
+ ...typeSuggestions(found, report, params.types),
77
+ ...(page.jquerySkipped && { jqueryHandlersSkipped: page.jquerySkipped }),
78
+ ...(page.reactSkipped && { reactHandlersSkipped: page.reactSkipped }),
72
79
  ...(params.backendNodeId === undefined && { matchCount }),
73
80
  };
74
81
  return withMultipleMatchesWarning(result, params.index, 'listing the first');
@@ -161,25 +168,6 @@ async function nodeChain(cdp, backendNodeId, objectGroup) {
161
168
  ? [{ objectId: item.objectId, className: item.className, description: item.description }]
162
169
  : []);
163
170
  }
164
- /**
165
- * Error for an element that could not be found.
166
- *
167
- * @param params - Request
168
- * @param matchCount - Elements the selector matched
169
- * @returns Stale (87), out of range (81) or not found (83) error
170
- */
171
- function missingElementError(params, matchCount) {
172
- if (params.backendNodeId !== undefined) {
173
- const err = staleNodeError();
174
- return new CommandError(err.message, { suggestion: err.suggestion }, EXIT_CODES.STALE_CACHE);
175
- }
176
- if (matchCount > 0) {
177
- const err = indexOutOfRangeError(params.index ?? 0, matchCount - 1);
178
- return new CommandError(err.message, { suggestion: err.suggestion }, EXIT_CODES.INVALID_ARGUMENTS);
179
- }
180
- const err = noNodesFoundError(params.selector);
181
- return new CommandError(err.message, { suggestion: err.suggestion }, EXIT_CODES.RESOURCE_NOT_FOUND);
182
- }
183
171
  /**
184
172
  * Items of an array in the page, in order.
185
173
  *
@@ -223,30 +211,169 @@ async function collectListeners(cdp, chain) {
223
211
  }
224
212
  }
225
213
  /**
226
- * Ask the page for the handlers' names (`Function.name` also knows names of
227
- * arrow functions assigned to variables, and `bound f` for bound functions).
214
+ * `--type` values the user probably meant, when none matched.
215
+ *
216
+ * @param found - All listeners found
217
+ * @param report - The filtered report
218
+ * @param types - Requested types
219
+ * @returns `{ typeSuggestions }` when there are any
220
+ */
221
+ function typeSuggestions(found, report, types) {
222
+ if (!types?.length || report.listeners.length > 0 || report.collapsed.length > 0)
223
+ return {};
224
+ const available = [...new Set(found.flatMap((entry) => entry.listeners.map((l) => l.type)))];
225
+ const suggestions = suggestEventTypes(types, available);
226
+ return suggestions.length > 0 ? { typeSuggestions: suggestions } : {};
227
+ }
228
+ /**
229
+ * Ask the page about the element and its listeners' handlers: their names
230
+ * (`Function.name` also knows names of arrow functions assigned to
231
+ * variables, and `bound f` for bound functions), which function object each
232
+ * calls, the jQuery handlers behind jQuery's dispatcher, the React `on…`
233
+ * props that run for the element's events, React root containers and the
234
+ * element's iframe.
228
235
  *
229
236
  * @param cdp - CDP connection
230
- * @param elementId - Object id of the element (the page context to run in)
231
- * @param handlers - Handler objects, in report order
232
- * @returns Names in the same order; empty when the page could not tell
237
+ * @param chain - Event targets, element first
238
+ * @param found - Listeners per chain entry
239
+ * @param objectGroup - Object group for the handles
240
+ * @param types - Requested event types (React props of others are not read)
241
+ * @returns Details; empty when the page could not tell
233
242
  */
234
- async function handlerNames(cdp, elementId, handlers) {
235
- if (handlers.length === 0)
236
- return [];
243
+ async function pageDetails(cdp, chain, found, objectGroup, types) {
244
+ const listeners = found.flatMap((entry) => entry.listeners.map((listener) => ({ position: entry.position, listener })));
245
+ const handlers = listeners.map(({ listener }) => listener.handler);
237
246
  try {
247
+ const targets = await Promise.all(handlers.map((handler) => boundTarget(cdp, handler)));
238
248
  const response = (await cdp.send('Runtime.callFunctionOn', {
239
- objectId: elementId,
240
- functionDeclaration: NAMES_JS,
241
- arguments: handlers.map((handler) => handler?.objectId ? { objectId: handler.objectId } : { value: null }),
242
- returnByValue: true,
249
+ objectId: chain[0]?.objectId,
250
+ functionDeclaration: ELEMENT_INFO_JS,
251
+ arguments: [
252
+ { value: listeners.map(({ position, listener }) => ({ position, type: listener.type })) },
253
+ { value: types ?? null },
254
+ ...chain.map((entry) => ({ objectId: entry.objectId })),
255
+ ...handlers.map(objectArgument),
256
+ ...targets.map(objectArgument),
257
+ ],
258
+ objectGroup,
243
259
  }));
244
- const names = response.result.value;
245
- return (names ?? []).map((name) => name ?? undefined);
260
+ const [infoObject, ...fns] = await arrayItems(cdp, response.result.objectId ?? '');
261
+ const info = await valueOf(cdp, infoObject?.objectId ?? '');
262
+ return toPageDetails(info, await Promise.all(fns.map((fn) => handlerSource(cdp, fn))));
246
263
  }
247
264
  catch (error) {
248
- log.debug(`Handler names not read: ${getErrorMessage(error)}`);
249
- return [];
265
+ log.debug(`Handler details not read: ${getErrorMessage(error)}`);
266
+ return { details: [], roots: [], react: [] };
267
+ }
268
+ }
269
+ /**
270
+ * A `Runtime.callFunctionOn` argument for a remote object.
271
+ *
272
+ * @param object - The object, if any
273
+ * @returns Its handle, or null
274
+ */
275
+ function objectArgument(object) {
276
+ return object?.objectId ? { objectId: object.objectId } : { value: null };
277
+ }
278
+ /**
279
+ * An internal property (`[[TargetFunction]]`, `[[FunctionLocation]]`) of a
280
+ * page object.
281
+ *
282
+ * @param cdp - CDP connection
283
+ * @param objectId - The object
284
+ * @param name - Property name
285
+ * @returns Its value, undefined when missing or unreadable
286
+ */
287
+ async function internalProperty(cdp, objectId, name) {
288
+ try {
289
+ const { internalProperties = [] } = (await cdp.send('Runtime.getProperties', {
290
+ objectId,
291
+ ownProperties: true,
292
+ }));
293
+ return internalProperties.find((property) => property.name === name)?.value;
250
294
  }
295
+ catch (error) {
296
+ log.debug(`${name} not read: ${getErrorMessage(error)}`);
297
+ return undefined;
298
+ }
299
+ }
300
+ /**
301
+ * The function a bound handler calls (React binds one dispatcher per event
302
+ * type), so dispatchers are recognised by function identity.
303
+ *
304
+ * @param cdp - CDP connection
305
+ * @param handler - Listener handler
306
+ * @returns The target function, undefined for handlers that are not bound
307
+ */
308
+ async function boundTarget(cdp, handler) {
309
+ if (!handler?.objectId || !handler.description?.includes('[native code]'))
310
+ return undefined;
311
+ return internalProperty(cdp, handler.objectId, '[[TargetFunction]]');
312
+ }
313
+ /**
314
+ * Copy a page object by value.
315
+ *
316
+ * @param cdp - CDP connection
317
+ * @param objectId - The object
318
+ * @returns Its JSON value
319
+ */
320
+ async function valueOf(cdp, objectId) {
321
+ const response = (await cdp.send('Runtime.callFunctionOn', {
322
+ objectId,
323
+ functionDeclaration: 'function () { return this; }',
324
+ returnByValue: true,
325
+ }));
326
+ return response.result.value;
327
+ }
328
+ /**
329
+ * Source and location of a framework handler function (no location when
330
+ * it cannot be read).
331
+ *
332
+ * @param cdp - CDP connection
333
+ * @param fn - The function
334
+ * @returns Its source and location
335
+ */
336
+ async function handlerSource(cdp, fn) {
337
+ const source = { source: fn?.description, scriptId: '0', lineNumber: 0, columnNumber: 0 };
338
+ if (!fn?.objectId)
339
+ return source;
340
+ const location = (await internalProperty(cdp, fn.objectId, '[[FunctionLocation]]'))?.value;
341
+ return { ...source, ...location };
342
+ }
343
+ /** Location of a handler whose source could not be read */
344
+ const UNKNOWN_SOURCE = { scriptId: '0', lineNumber: 0, columnNumber: 0 };
345
+ /**
346
+ * Combine the page's report with the framework handlers' sources.
347
+ *
348
+ * @param info - Page report
349
+ * @param sources - Source and location of each jQuery handler, then each
350
+ * React prop handler, in report order
351
+ * @returns Details per listener and the React prop handlers
352
+ */
353
+ function toPageDetails(info, sources) {
354
+ let next = 0;
355
+ const details = info.listeners.map(({ name, identity, targetName, jquery }) => ({
356
+ name: name ?? undefined,
357
+ identity: identity ?? undefined,
358
+ targetName: targetName ?? undefined,
359
+ jquery: jquery?.map((handler) => ({
360
+ type: handler.type,
361
+ selector: handler.selector ?? undefined,
362
+ name: handler.name,
363
+ ...(sources[next++] ?? UNKNOWN_SOURCE),
364
+ })),
365
+ }));
366
+ const react = info.react.map((handler) => ({
367
+ ...handler,
368
+ ...(sources[next++] ?? UNKNOWN_SOURCE),
369
+ }));
370
+ return {
371
+ details,
372
+ roots: info.roots,
373
+ react,
374
+ ...(info.frame && { frame: info.frame }),
375
+ ...(info.jquerySkipped > 0 && { jquerySkipped: info.jquerySkipped }),
376
+ ...(info.reactSkipped > 0 && { reactSkipped: info.reactSkipped }),
377
+ };
251
378
  }
252
379
  //# sourceMappingURL=eventListeners.js.map
@@ -15,7 +15,7 @@ import type { RawFormData } from './formTypes.js';
15
15
  * - Current values and validation state
16
16
  * - Form relevance scoring for multi-form pages
17
17
  */
18
- export declare const FORM_DISCOVERY_SCRIPT = "\n(function() {\n const result = { forms: [] };\n\n function isUnique(selector) {\n try {\n return document.querySelectorAll(selector).length === 1;\n } catch (e) {\n return false;\n }\n }\n\n function generateSelector(element) {\n if (element.id && isUnique('#' + CSS.escape(element.id))) {\n return '#' + CSS.escape(element.id);\n }\n if (element.name) {\n const byName = element.tagName.toLowerCase() + '[name=\"' + CSS.escape(element.name) + '\"]';\n if (isUnique(byName)) return byName;\n const byValue = byName + '[value=\"' + CSS.escape(element.getAttribute('value') || '') + '\"]';\n if (element.hasAttribute('value') && isUnique(byValue)) return byValue;\n }\n const tag = element.tagName.toLowerCase();\n const parent = element.parentElement;\n if (!parent) return tag;\n const siblings = Array.from(parent.children).filter(c => c.tagName === element.tagName);\n if (siblings.length === 1) {\n const parentSelector = generateSelector(parent);\n return parentSelector + ' > ' + tag;\n }\n const index = siblings.indexOf(element);\n const parentSelector = generateSelector(parent);\n return parentSelector + ' > ' + tag + ':nth-of-type(' + (index + 1) + ')';\n }\n\n function cleanLabelText(text) {\n if (!text) return text;\n const patterns = [\n /Previous\\s*arrow/gi,\n /Next\\s*arrow/gi,\n /\\barrow\\b/gi,\n /\\bchevron\\b/gi,\n /\u2190|\u2192|\u2191|\u2193|\u25B2|\u25BC|\u25C0|\u25B6/g,\n /\\u25C0|\\u25B6|\\u25B2|\\u25BC/g,\n /^\\s*[<>]\\s*/,\n /\\s*[<>]\\s*$/,\n ];\n let cleaned = text;\n for (const pattern of patterns) {\n cleaned = cleaned.replace(pattern, '');\n }\n cleaned = cleaned.replace(/\\s{2,}/g, ' ').trim();\n return cleaned || text.trim();\n }\n\n function extractLabel(element) {\n if (element.id) {\n const labelFor = document.querySelector('label[for=\"' + CSS.escape(element.id) + '\"]');\n if (labelFor) return cleanLabelText(labelFor.textContent);\n }\n const ariaLabel = element.getAttribute('aria-label');\n if (ariaLabel) return cleanLabelText(ariaLabel);\n const ariaLabelledBy = element.getAttribute('aria-labelledby');\n if (ariaLabelledBy) {\n const labelEl = document.getElementById(ariaLabelledBy);\n if (labelEl) return cleanLabelText(labelEl.textContent);\n }\n const wrappingLabel = element.closest('label');\n if (wrappingLabel) {\n const clone = wrappingLabel.cloneNode(true);\n const inputs = clone.querySelectorAll('input, select, textarea, button');\n inputs.forEach(i => i.remove());\n const text = clone.textContent.trim();\n if (text) return cleanLabelText(text);\n }\n if (element.placeholder) return cleanLabelText(element.placeholder);\n const title = element.getAttribute('title');\n if (title) return cleanLabelText(title);\n if (element.name) {\n return element.name\n .replace(/[_-]/g, ' ')\n .replace(/([a-z])([A-Z])/g, '$1 $2')\n .replace(/^./, s => s.toUpperCase());\n }\n return 'Unlabeled field';\n }\n\n function extractButtonLabel(element) {\n const ariaLabel = element.getAttribute('aria-label');\n if (ariaLabel) return ariaLabel.trim();\n if (element.value && element.type !== 'submit') return element.value;\n const text = element.textContent.trim();\n if (text) return text;\n if (element.value) return element.value;\n const title = element.getAttribute('title');\n if (title) return title;\n return 'Button';\n }\n\n function getFieldValue(element) {\n const tag = element.tagName.toLowerCase();\n const type = element.type?.toLowerCase() || 'text';\n if (tag === 'select') {\n if (element.multiple) {\n return Array.from(element.selectedOptions).map(o => o.value);\n }\n return element.value;\n }\n if (type === 'checkbox' || type === 'radio') {\n return element.checked;\n }\n if (element.getAttribute('role') === 'checkbox' || element.getAttribute('role') === 'switch') {\n return element.getAttribute('aria-checked') === 'true';\n }\n if (element.isContentEditable) {\n return element.textContent || '';\n }\n return element.value || '';\n }\n\n function getFieldState(element, value) {\n const type = element.type?.toLowerCase() || 'text';\n if (type === 'checkbox' || element.getAttribute('role') === 'checkbox' || element.getAttribute('role') === 'switch') {\n return value ? 'checked' : 'unchecked';\n }\n if (type === 'radio') {\n return value ? 'checked' : 'unchecked';\n }\n if (Array.isArray(value)) {\n return value.length > 0 ? 'filled' : 'empty';\n }\n if (typeof value === 'string') {\n return value.length > 0 ? 'filled' : 'empty';\n }\n return 'empty';\n }\n\n function getFieldType(element) {\n const tag = element.tagName.toLowerCase();\n const role = element.getAttribute('role');\n if (role === 'textbox') return 'textbox';\n if (role === 'checkbox') return 'checkbox';\n if (role === 'radio') return 'radio';\n if (role === 'combobox') return 'combobox';\n if (role === 'listbox') return 'listbox';\n if (role === 'switch') return 'switch';\n if (element.isContentEditable) return 'contenteditable';\n if (tag === 'textarea') return 'textarea';\n if (tag === 'select') return 'select';\n if (tag === 'input') {\n const type = element.type?.toLowerCase() || 'text';\n return type;\n }\n return 'unknown';\n }\n\n function isNativeInput(element) {\n const tag = element.tagName.toLowerCase();\n return tag === 'input' || tag === 'textarea' || tag === 'select';\n }\n\n function getSelectOptions(element) {\n if (element.tagName.toLowerCase() !== 'select') return undefined;\n return Array.from(element.options).map(opt => ({\n value: opt.value,\n label: opt.textContent.trim(),\n selected: opt.selected\n }));\n }\n\n function findSiblingError(element) {\n const next = element.nextElementSibling;\n if (next) {\n const isError = next.classList.contains('error') ||\n next.classList.contains('invalid') ||\n next.classList.contains('field-error') ||\n next.classList.contains('error-message') ||\n next.getAttribute('role') === 'alert';\n if (isError) return next.textContent.trim();\n }\n // aria-errormessage always names the error; aria-describedby does when the\n // field is marked invalid or the description looks like an error\n const invalid = element.getAttribute('aria-invalid') === 'true';\n const errorIds = (element.getAttribute('aria-errormessage') || '').split(/\\s+/).filter(Boolean);\n const describedIds = (element.getAttribute('aria-describedby') || '').split(/\\s+/).filter(Boolean);\n for (const id of [...errorIds, ...describedIds]) {\n const target = document.getElementById(id);\n const looksLikeError = errorIds.includes(id) || invalid ||\n /error|invalid|alert/i.test(target ? target.className + ' ' + (target.getAttribute('role') || '') : '');\n if (target && looksLikeError && target.textContent.trim()) {\n return target.textContent.trim();\n }\n }\n const parent = element.parentElement;\n const controls = parent ? parent.querySelectorAll('input, select, textarea, [contenteditable=\"true\"]') : [];\n if (parent && controls.length === 1) {\n const errorEl = parent.querySelector('.error-message, .field-error, [role=\"alert\"]');\n if (errorEl && errorEl !== element) return errorEl.textContent.trim();\n }\n return undefined;\n }\n\n function calculateRelevance(formEl, fields, buttons) {\n let score = 0;\n const rect = formEl.getBoundingClientRect();\n const viewportHeight = window.innerHeight;\n const viewportWidth = window.innerWidth;\n const centerX = rect.left + rect.width / 2;\n const centerY = rect.top + rect.height / 2;\n if (centerX > viewportWidth * 0.2 && centerX < viewportWidth * 0.8) score += 10;\n if (centerY > 0 && centerY < viewportHeight) score += 5;\n const isInMain = formEl.closest('main, [role=\"main\"], article, .main-content, #main');\n if (isInMain) score += 15;\n const isInHeader = formEl.closest('header, [role=\"banner\"], nav, [role=\"navigation\"]');\n if (isInHeader) score -= 10;\n const isInAside = formEl.closest('aside, [role=\"complementary\"], footer, [role=\"contentinfo\"]');\n if (isInAside) score -= 5;\n const distinctFields = new Set(\n fields.map((f) => (f.type === 'radio' || f.type === 'checkbox') && f.name ? f.type + ':' + f.name : f.index)\n ).size;\n score += Math.min(distinctFields * 3, 30);\n const textTypes = ['text', 'search', 'email', 'password', 'textarea', 'tel', 'url', 'number', 'textbox'];\n if (fields.some((f) => textTypes.includes(f.type))) score += 10;\n const hasSubmit = buttons.some(b => b.type === 'submit' || b.isPrimary);\n if (hasSubmit) score += 10;\n const style = window.getComputedStyle(formEl);\n if (style.display === 'none' || style.visibility === 'hidden') score -= 100;\n return score;\n }\n\n function detectFormStep(formEl) {\n const stepIndicators = formEl.querySelectorAll('[aria-current=\"step\"], .step.active, .wizard-step.current');\n if (stepIndicators.length === 0) {\n const pageSteps = document.querySelectorAll('.step, .wizard-step, [role=\"tablist\"] [role=\"tab\"]');\n if (pageSteps.length > 1) {\n const activeIndex = Array.from(pageSteps).findIndex(s =>\n s.classList.contains('active') ||\n s.classList.contains('current') ||\n s.getAttribute('aria-selected') === 'true'\n );\n if (activeIndex >= 0) {\n return { current: activeIndex + 1, total: pageSteps.length };\n }\n }\n }\n const ariaStep = formEl.querySelector('[aria-current=\"step\"]');\n if (ariaStep) {\n const allSteps = formEl.querySelectorAll('[role=\"listitem\"], .step');\n const currentIdx = Array.from(allSteps).indexOf(ariaStep);\n if (currentIdx >= 0) {\n return { current: currentIdx + 1, total: allSteps.length };\n }\n }\n return null;\n }\n\n function findNearbyHeading(formEl) {\n let sibling = formEl.previousElementSibling;\n let distance = 0;\n while (sibling && distance < 3) {\n if (sibling.matches('h1, h2, h3, h4, h5, h6, [role=\"heading\"]')) {\n return sibling.textContent.trim();\n }\n const heading = sibling.querySelector('h1, h2, h3, h4, h5, h6, [role=\"heading\"]');\n if (heading) return heading.textContent.trim();\n sibling = sibling.previousElementSibling;\n distance++;\n }\n const parent = formEl.parentElement;\n if (parent) {\n const heading = parent.querySelector(':scope > h1, :scope > h2, :scope > h3');\n if (heading && heading.compareDocumentPosition(formEl) & Node.DOCUMENT_POSITION_FOLLOWING) {\n return heading.textContent.trim();\n }\n }\n return null;\n }\n\n function matchesWord(text, word) {\n const pattern = new RegExp('\\\\b' + word + '\\\\b', 'i');\n return pattern.test(text);\n }\n\n function inferFormType(formEl) {\n const inputs = formEl.querySelectorAll('input, textarea, select');\n const types = Array.from(inputs).map(i => i.type?.toLowerCase() || i.tagName.toLowerCase());\n const names = Array.from(inputs).map(i => (i.name || '').toLowerCase());\n const allText = (Array.from(inputs).map(i => i.name + ' ' + i.placeholder + ' ' + i.id).join(' ')).toLowerCase();\n if (types.includes('password')) {\n if (types.filter(t => t === 'password').length >= 2) return 'Change Password';\n if (matchesWord(allText, 'register') || matchesWord(allText, 'signup') || matchesWord(allText, 'create')) return 'Registration';\n return 'Login';\n }\n const hasSearchRole = formEl.closest('[role=\"search\"]') || formEl.getAttribute('role') === 'search';\n const hasSearchInput = formEl.querySelector('[type=\"search\"], [aria-label*=\"search\" i]');\n if (hasSearchRole || hasSearchInput) return 'Search';\n const interactiveTypes = types.filter(t => !['hidden', 'submit', 'button', 'reset', 'image'].includes(t));\n if (interactiveTypes.length <= 2 && matchesWord(allText, 'search')) return 'Search';\n if (matchesWord(allText, 'address') || matchesWord(allText, 'street') || matchesWord(allText, 'postcode') ||\n matchesWord(allText, 'zipcode') || matchesWord(allText, 'city') || matchesWord(allText, 'county')) {\n return 'Address';\n }\n if (matchesWord(allText, 'email') && (matchesWord(allText, 'message') || matchesWord(allText, 'subject'))) {\n return 'Contact';\n }\n if (matchesWord(allText, 'card') || matchesWord(allText, 'cvv') || matchesWord(allText, 'expiry')) {\n return 'Payment';\n }\n if (names.some(n => matchesWord(n, 'subscribe') || matchesWord(n, 'newsletter'))) {\n return 'Newsletter';\n }\n return null;\n }\n\n function extractFormName(formEl) {\n const ariaLabel = formEl.getAttribute('aria-label');\n if (ariaLabel) return ariaLabel;\n const ariaLabelledBy = formEl.getAttribute('aria-labelledby');\n if (ariaLabelledBy) {\n const labelEl = document.getElementById(ariaLabelledBy);\n if (labelEl) return labelEl.textContent.trim();\n }\n const headings = formEl.querySelectorAll('h1, h2, h3, h4, h5, h6, [role=\"heading\"]');\n for (const h of headings) {\n if (!h.closest('[role=\"dialog\"], [role=\"alertdialog\"], [aria-modal=\"true\"]')) {\n return h.textContent.trim();\n }\n }\n const title = formEl.getAttribute('title');\n if (title) return title;\n const name = formEl.getAttribute('name');\n if (name) {\n return name\n .replace(/[_-]/g, ' ')\n .replace(/([a-z])([A-Z])/g, '$1 $2')\n .replace(/^./, s => s.toUpperCase());\n }\n const nearbyHeading = findNearbyHeading(formEl);\n if (nearbyHeading) return nearbyHeading;\n const inferredType = inferFormType(formEl);\n if (inferredType) return inferredType;\n return null;\n }\n\n function discoverFields(container, formIndex, startIndex) {\n const fields = [];\n const nativeInputs = container.querySelectorAll(\n 'input:not([type=\"hidden\"]):not([type=\"submit\"]):not([type=\"button\"]):not([type=\"reset\"]):not([type=\"image\"]), ' +\n 'textarea, ' +\n 'select'\n );\n const customInputs = container.querySelectorAll(\n '[role=\"textbox\"], ' +\n '[role=\"checkbox\"]:not(input), ' +\n '[role=\"radio\"]:not(input), ' +\n '[role=\"combobox\"], ' +\n '[role=\"listbox\"], ' +\n '[role=\"switch\"], ' +\n '[contenteditable=\"true\"]'\n );\n const allInputs = new Set([...nativeInputs, ...customInputs]);\n let idx = startIndex;\n for (const el of allInputs) {\n const style = window.getComputedStyle(el);\n const isHidden = style.display === 'none' || style.visibility === 'hidden' || el.type === 'hidden';\n const value = getFieldValue(el);\n fields.push({\n index: idx,\n formIndex: formIndex,\n selector: generateSelector(el),\n type: getFieldType(el),\n inputType: el.type?.toLowerCase(),\n label: extractLabel(el),\n name: el.name || null,\n placeholder: el.placeholder || undefined,\n required: el.required || el.getAttribute('aria-required') === 'true',\n disabled: el.disabled || el.getAttribute('aria-disabled') === 'true',\n readOnly: el.readOnly || false,\n hidden: isHidden,\n native: isNativeInput(el),\n value: value,\n checked: el.checked,\n validationMessage: el.validationMessage || undefined,\n isValid: el.validity ? el.validity.valid : true,\n valueMissing: el.validity ? el.validity.valueMissing : false,\n ariaInvalid: el.getAttribute('aria-invalid') === 'true',\n hasErrorClass: el.classList.contains('error') || el.classList.contains('invalid'),\n siblingErrorText: findSiblingError(el),\n options: getSelectOptions(el)\n });\n idx++;\n }\n return fields;\n }\n\n function discoverButtons(container, startIndex) {\n const buttons = [];\n const buttonEls = container.querySelectorAll(\n 'button, ' +\n 'input[type=\"submit\"], ' +\n 'input[type=\"button\"], ' +\n 'input[type=\"reset\"], ' +\n '[role=\"button\"]'\n );\n let idx = startIndex;\n for (const el of buttonEls) {\n const style = window.getComputedStyle(el);\n if (style.display === 'none' || style.visibility === 'hidden') continue;\n const type = el.type?.toLowerCase() || 'button';\n const btnType = type === 'submit' ? 'submit' : type === 'reset' ? 'reset' : 'button';\n const isPrimary = btnType === 'submit' ||\n el.classList.contains('primary') ||\n el.classList.contains('btn-primary') ||\n el.classList.contains('submit');\n buttons.push({\n index: idx,\n selector: generateSelector(el),\n label: extractButtonLabel(el),\n type: btnType,\n disabled: el.disabled || el.getAttribute('aria-disabled') === 'true',\n isPrimary: isPrimary\n });\n idx++;\n }\n return buttons;\n }\n\n const forms = document.querySelectorAll('form');\n let globalIndex = 0;\n\n if (forms.length === 0) {\n const bodyFields = discoverFields(document.body, 0, 0);\n const bodyButtons = discoverButtons(document.body, bodyFields.length);\n if (bodyFields.length > 0) {\n result.forms.push({\n index: 0,\n name: document.title || 'Page',\n action: null,\n method: 'GET',\n step: null,\n relevanceScore: bodyFields.length * 3,\n inIframe: false,\n fields: bodyFields,\n buttons: bodyButtons\n });\n }\n } else {\n for (let i = 0; i < forms.length; i++) {\n const formEl = forms[i];\n const fields = discoverFields(formEl, i, globalIndex);\n globalIndex += fields.length;\n const buttons = discoverButtons(formEl, globalIndex);\n globalIndex += buttons.length;\n const inIframe = formEl.ownerDocument !== document;\n result.forms.push({\n index: i,\n name: extractFormName(formEl),\n action: formEl.action || null,\n method: (formEl.method || 'GET').toUpperCase(),\n step: detectFormStep(formEl),\n relevanceScore: calculateRelevance(formEl, fields, buttons),\n inIframe: inIframe,\n fields: fields,\n buttons: buttons\n });\n }\n }\n\n result.forms.sort((a, b) => b.relevanceScore - a.relevanceScore);\n\n // Forms the main document does not contain may be in its same-origin iframes\n if (result.forms.length === 0) {\n result.frameForms = [];\n for (const frame of document.querySelectorAll('iframe, frame')) {\n let frameDocument = null;\n try { frameDocument = frame.contentDocument; } catch (e) { frameDocument = null; }\n if (frameDocument && frameDocument.querySelector('form, input:not([type=hidden]), select, textarea')) {\n result.frameForms.push({ url: frame.src || 'about:blank' });\n }\n }\n }\n\n return result;\n})()\n";
18
+ export declare const FORM_DISCOVERY_SCRIPT = "\n(function() {\n const result = { forms: [] };\n\n // Rendered and not visibility-hidden, ancestors included (opacity is left\n // out: styled checkboxes and radios are often transparent)\n function isShown(element) {\n if (typeof element.checkVisibility === 'function') {\n return element.checkVisibility({ visibilityProperty: true });\n }\n const style = window.getComputedStyle(element);\n return style.display !== 'none' && style.visibility !== 'hidden';\n }\n\n // Inside an open dialog: <dialog open>, aria-modal or a dialog role, or a\n // modal without them (DocSearch): an overlay ancestor below <body>\n // (position fixed or absolute) that is raised (z-index > 0) or has a\n // backdrop and whose class names a modal or dialog, or a fixed overlay over\n // half the viewport or more that is raised or lies over other page content\n // at the viewport centre. Static wrappers (Drupal's\n // dialog-off-canvas-main-canvas) and fixed app shells holding the whole\n // page are not dialogs.\n function inDialog(element) {\n if (element.closest('dialog[open], [aria-modal=\"true\"], [role=\"dialog\"], [role=\"alertdialog\"]')) return true;\n const viewportArea = window.innerWidth * window.innerHeight;\n for (let node = element.parentElement; node && node !== document.body; node = node.parentElement) {\n const style = window.getComputedStyle(node);\n if (style.position !== 'fixed' && style.position !== 'absolute') continue;\n const raised = parseInt(style.zIndex, 10) > 0;\n const className = typeof node.className === 'string' ? node.className : '';\n if (/(^|[\\s_-])(modal|dialog)($|[\\s_-])/i.test(className) && (raised || hasBackdrop(node))) return true;\n if (style.position !== 'fixed') continue;\n const box = node.getBoundingClientRect();\n if (box.width * box.height >= viewportArea / 2 && (raised || coversContent(node))) return true;\n }\n return false;\n }\n\n // A backdrop element next to or inside an overlay\n function hasBackdrop(node) {\n const backdrop = /backdrop|overlay/i;\n const near = [node.previousElementSibling, node.nextElementSibling, ...Array.from(node.children)];\n return near.some((n) => n && typeof n.className === 'string' && backdrop.test(n.className));\n }\n\n // The overlay is on top at the viewport centre with other page content\n // (not its ancestors) below it\n function coversContent(node) {\n const stack = document.elementsFromPoint(window.innerWidth / 2, window.innerHeight / 2);\n if (!stack.length || !node.contains(stack[0])) return false;\n return stack.some((n) => !n.contains(node) && !node.contains(n));\n }\n\n function isUnique(selector) {\n try {\n return document.querySelectorAll(selector).length === 1;\n } catch (e) {\n return false;\n }\n }\n\n function generateSelector(element) {\n if (element.id && isUnique('#' + CSS.escape(element.id))) {\n return '#' + CSS.escape(element.id);\n }\n if (element.name) {\n const byName = element.tagName.toLowerCase() + '[name=\"' + CSS.escape(element.name) + '\"]';\n if (isUnique(byName)) return byName;\n const byValue = byName + '[value=\"' + CSS.escape(element.getAttribute('value') || '') + '\"]';\n if (element.hasAttribute('value') && isUnique(byValue)) return byValue;\n }\n const tag = element.tagName.toLowerCase();\n const parent = element.parentElement;\n if (!parent) return tag;\n const siblings = Array.from(parent.children).filter(c => c.tagName === element.tagName);\n if (siblings.length === 1) {\n const parentSelector = generateSelector(parent);\n return parentSelector + ' > ' + tag;\n }\n const index = siblings.indexOf(element);\n const parentSelector = generateSelector(parent);\n return parentSelector + ' > ' + tag + ':nth-of-type(' + (index + 1) + ')';\n }\n\n function cleanLabelText(text) {\n if (!text) return text;\n const patterns = [\n /Previous\\s*arrow/gi,\n /Next\\s*arrow/gi,\n /\\barrow\\b/gi,\n /\\bchevron\\b/gi,\n /\u2190|\u2192|\u2191|\u2193|\u25B2|\u25BC|\u25C0|\u25B6/g,\n /\\u25C0|\\u25B6|\\u25B2|\\u25BC/g,\n /^\\s*[<>]\\s*/,\n /\\s*[<>]\\s*$/,\n ];\n let cleaned = text;\n for (const pattern of patterns) {\n cleaned = cleaned.replace(pattern, '');\n }\n cleaned = cleaned.replace(/\\s{2,}/g, ' ').trim();\n cleaned = cleaned.replace(/^\\*\\s+/, '').replace(/\\s+\\*(\\s*:?)$/, '$1');\n return cleaned || text.trim();\n }\n\n // The element labelling a field: label[for], aria-labelledby, wrapping label\n function labelElement(element) {\n const labelFor = element.id ? document.querySelector('label[for=\"' + CSS.escape(element.id) + '\"]') : null;\n const labelledBy = element.getAttribute('aria-labelledby');\n return labelFor || (labelledBy && document.getElementById(labelledBy)) || element.closest('label');\n }\n\n // A standalone required star in label text: \"* Name\", \"Name *\", \"Name *:\"\n // (not a footnote mark such as \"Terms*\")\n function labelMarksRequired(text) {\n return /^\\*\\s|\\s\\*\\s*:?$/.test(String(text).trim());\n }\n\n // Required by attribute, ARIA, or a label marked with a standalone star,\n // or with an element of its own holding just \"*\" (<span aria-hidden>*</span>)\n function isRequired(element) {\n if (element.required || element.getAttribute('aria-required') === 'true') return true;\n const label = labelElement(element);\n if (!label) return false;\n const clone = label.cloneNode(true);\n clone.querySelectorAll('input, select, textarea, button').forEach(i => i.remove());\n return labelMarksRequired(clone.textContent || '') ||\n Array.from(clone.querySelectorAll('*')).some(n => n.textContent.trim() === '*');\n }\n\n // Name of the radio or checkbox group a field belongs to: its radiogroup's\n // label, its fieldset's legend, or its name\n function groupLabel(element) {\n const type = element.type?.toLowerCase();\n if ((type !== 'radio' && type !== 'checkbox') || !element.name) return undefined;\n const group = element.closest('[role=\"radiogroup\"], fieldset');\n const named = group && (group.getAttribute('aria-label') ||\n (group.querySelector(':scope > legend') || {}).textContent);\n if (named && named.trim()) return cleanLabelText(named);\n return element.name\n .replace(/[_-]/g, ' ')\n .replace(/([a-z])([A-Z])/g, '$1 $2')\n .replace(/^./, s => s.toUpperCase());\n }\n\n function extractLabel(element) {\n if (element.id) {\n const labelFor = document.querySelector('label[for=\"' + CSS.escape(element.id) + '\"]');\n if (labelFor) return cleanLabelText(labelFor.textContent);\n }\n const ariaLabel = element.getAttribute('aria-label');\n if (ariaLabel) return cleanLabelText(ariaLabel);\n const ariaLabelledBy = element.getAttribute('aria-labelledby');\n if (ariaLabelledBy) {\n const labelEl = document.getElementById(ariaLabelledBy);\n if (labelEl) return cleanLabelText(labelEl.textContent);\n }\n const wrappingLabel = element.closest('label');\n if (wrappingLabel) {\n const clone = wrappingLabel.cloneNode(true);\n const inputs = clone.querySelectorAll('input, select, textarea, button');\n inputs.forEach(i => i.remove());\n const text = clone.textContent.trim();\n if (text) return cleanLabelText(text);\n }\n if (element.placeholder) return cleanLabelText(element.placeholder);\n const title = element.getAttribute('title');\n if (title) return cleanLabelText(title);\n if (element.name) {\n return element.name\n .replace(/[_-]/g, ' ')\n .replace(/([a-z])([A-Z])/g, '$1 $2')\n .replace(/^./, s => s.toUpperCase());\n }\n return 'Unlabeled field';\n }\n\n function extractButtonLabel(element) {\n const ariaLabel = element.getAttribute('aria-label');\n if (ariaLabel) return ariaLabel.trim();\n if (element.value && element.type !== 'submit') return element.value;\n const text = element.textContent.trim();\n if (text) return text;\n if (element.value) return element.value;\n const title = element.getAttribute('title');\n if (title) return title;\n return 'Button';\n }\n\n function getFieldValue(element) {\n const tag = element.tagName.toLowerCase();\n const type = element.type?.toLowerCase() || 'text';\n if (tag === 'select') {\n if (element.multiple) {\n return Array.from(element.selectedOptions).map(o => o.value);\n }\n return element.value;\n }\n if (type === 'checkbox' || type === 'radio') {\n return element.checked;\n }\n if (element.getAttribute('role') === 'checkbox' || element.getAttribute('role') === 'switch') {\n return element.getAttribute('aria-checked') === 'true';\n }\n if (element.isContentEditable) {\n return element.textContent || '';\n }\n return element.value || '';\n }\n\n function getFieldState(element, value) {\n const type = element.type?.toLowerCase() || 'text';\n if (type === 'checkbox' || element.getAttribute('role') === 'checkbox' || element.getAttribute('role') === 'switch') {\n return value ? 'checked' : 'unchecked';\n }\n if (type === 'radio') {\n return value ? 'checked' : 'unchecked';\n }\n if (Array.isArray(value)) {\n return value.length > 0 ? 'filled' : 'empty';\n }\n if (typeof value === 'string') {\n return value.length > 0 ? 'filled' : 'empty';\n }\n return 'empty';\n }\n\n function getFieldType(element) {\n const tag = element.tagName.toLowerCase();\n const role = element.getAttribute('role');\n if (role === 'textbox') return 'textbox';\n if (role === 'checkbox') return 'checkbox';\n if (role === 'radio') return 'radio';\n if (role === 'combobox') return 'combobox';\n if (role === 'listbox') return 'listbox';\n if (role === 'switch') return 'switch';\n if (element.isContentEditable) return 'contenteditable';\n if (tag === 'textarea') return 'textarea';\n if (tag === 'select') return 'select';\n if (tag === 'input') {\n const type = element.type?.toLowerCase() || 'text';\n return type;\n }\n return 'unknown';\n }\n\n function isNativeInput(element) {\n const tag = element.tagName.toLowerCase();\n return tag === 'input' || tag === 'textarea' || tag === 'select';\n }\n\n function getSelectOptions(element) {\n if (element.tagName.toLowerCase() !== 'select') return undefined;\n return Array.from(element.options).map(opt => ({\n value: opt.value,\n label: opt.textContent.trim(),\n selected: opt.selected\n }));\n }\n\n function findSiblingError(element) {\n const next = element.nextElementSibling;\n if (next) {\n const isError = next.classList.contains('error') ||\n next.classList.contains('invalid') ||\n next.classList.contains('field-error') ||\n next.classList.contains('error-message') ||\n next.getAttribute('role') === 'alert';\n if (isError) return next.textContent.trim();\n }\n // aria-errormessage always names the error; aria-describedby does when the\n // field is marked invalid or the description looks like an error\n const invalid = element.getAttribute('aria-invalid') === 'true';\n const errorIds = (element.getAttribute('aria-errormessage') || '').split(/\\s+/).filter(Boolean);\n const describedIds = (element.getAttribute('aria-describedby') || '').split(/\\s+/).filter(Boolean);\n for (const id of [...errorIds, ...describedIds]) {\n const target = document.getElementById(id);\n const looksLikeError = errorIds.includes(id) || invalid ||\n /error|invalid|alert/i.test(target ? target.className + ' ' + (target.getAttribute('role') || '') : '');\n if (target && looksLikeError && target.textContent.trim()) {\n return target.textContent.trim();\n }\n }\n const parent = element.parentElement;\n const controls = parent ? parent.querySelectorAll('input, select, textarea, [contenteditable=\"true\"]') : [];\n if (parent && controls.length === 1) {\n const errorEl = parent.querySelector('.error-message, .field-error, [role=\"alert\"]');\n if (errorEl && errorEl !== element) return errorEl.textContent.trim();\n }\n return undefined;\n }\n\n function calculateRelevance(formEl, fields, buttons) {\n let score = 0;\n const rect = formEl.getBoundingClientRect();\n const viewportHeight = window.innerHeight;\n const viewportWidth = window.innerWidth;\n const centerX = rect.left + rect.width / 2;\n const centerY = rect.top + rect.height / 2;\n if (centerX > viewportWidth * 0.2 && centerX < viewportWidth * 0.8) score += 10;\n if (centerY > 0 && centerY < viewportHeight) score += 5;\n const isInMain = formEl.closest('main, [role=\"main\"], article, .main-content, #main');\n if (isInMain) score += 15;\n const isInHeader = formEl.closest('header, [role=\"banner\"], nav, [role=\"navigation\"]');\n if (isInHeader) score -= 10;\n const isInAside = formEl.closest('aside, [role=\"complementary\"], footer, [role=\"contentinfo\"]');\n if (isInAside) score -= 5;\n const distinctFields = new Set(\n fields.map((f) => (f.type === 'radio' || f.type === 'checkbox') && f.name ? f.type + ':' + f.name : f.index)\n ).size;\n score += Math.min(distinctFields * 3, 30);\n const textTypes = ['text', 'search', 'email', 'password', 'textarea', 'tel', 'url', 'number', 'textbox'];\n if (fields.some((f) => textTypes.includes(f.type))) score += 10;\n const hasSubmit = buttons.some(b => b.type === 'submit' || b.primaryClass);\n if (hasSubmit) score += 10;\n const style = window.getComputedStyle(formEl);\n if (style.display === 'none' || style.visibility === 'hidden') score -= 100;\n return score;\n }\n\n function detectFormStep(formEl) {\n const stepIndicators = formEl.querySelectorAll('[aria-current=\"step\"], .step.active, .wizard-step.current');\n if (stepIndicators.length === 0) {\n const pageSteps = document.querySelectorAll('.step, .wizard-step, [role=\"tablist\"] [role=\"tab\"]');\n if (pageSteps.length > 1) {\n const activeIndex = Array.from(pageSteps).findIndex(s =>\n s.classList.contains('active') ||\n s.classList.contains('current') ||\n s.getAttribute('aria-selected') === 'true'\n );\n if (activeIndex >= 0) {\n return { current: activeIndex + 1, total: pageSteps.length };\n }\n }\n }\n const ariaStep = formEl.querySelector('[aria-current=\"step\"]');\n if (ariaStep) {\n const allSteps = formEl.querySelectorAll('[role=\"listitem\"], .step');\n const currentIdx = Array.from(allSteps).indexOf(ariaStep);\n if (currentIdx >= 0) {\n return { current: currentIdx + 1, total: allSteps.length };\n }\n }\n return null;\n }\n\n function findNearbyHeading(formEl) {\n let sibling = formEl.previousElementSibling;\n let distance = 0;\n while (sibling && distance < 3) {\n if (sibling.matches('h1, h2, h3, h4, h5, h6, [role=\"heading\"]')) {\n return sibling.textContent.trim();\n }\n const heading = sibling.querySelector('h1, h2, h3, h4, h5, h6, [role=\"heading\"]');\n if (heading) return heading.textContent.trim();\n sibling = sibling.previousElementSibling;\n distance++;\n }\n const parent = formEl.parentElement;\n if (parent) {\n const heading = parent.querySelector(':scope > h1, :scope > h2, :scope > h3');\n if (heading && heading.compareDocumentPosition(formEl) & Node.DOCUMENT_POSITION_FOLLOWING) {\n return heading.textContent.trim();\n }\n }\n return null;\n }\n\n function matchesWord(text, word) {\n const pattern = new RegExp('\\\\b' + word + '\\\\b', 'i');\n return pattern.test(text);\n }\n\n function inferFormType(formEl) {\n const inputs = formEl.querySelectorAll('input, textarea, select');\n const types = Array.from(inputs).map(i => i.type?.toLowerCase() || i.tagName.toLowerCase());\n const names = Array.from(inputs).map(i => (i.name || '').toLowerCase());\n const allText = (Array.from(inputs).map(i => i.name + ' ' + i.placeholder + ' ' + i.id).join(' ')).toLowerCase();\n if (types.includes('password')) {\n if (types.filter(t => t === 'password').length >= 2) return 'Change Password';\n if (matchesWord(allText, 'register') || matchesWord(allText, 'signup') || matchesWord(allText, 'create')) return 'Registration';\n return 'Login';\n }\n const hasSearchRole = formEl.closest('[role=\"search\"]') || formEl.getAttribute('role') === 'search';\n const hasSearchInput = formEl.querySelector('[type=\"search\"], [aria-label*=\"search\" i]');\n if (hasSearchRole || hasSearchInput) return 'Search';\n const interactiveTypes = types.filter(t => !['hidden', 'submit', 'button', 'reset', 'image'].includes(t));\n if (interactiveTypes.length <= 2 && matchesWord(allText, 'search')) return 'Search';\n if (matchesWord(allText, 'address') || matchesWord(allText, 'street') || matchesWord(allText, 'postcode') ||\n matchesWord(allText, 'zipcode') || matchesWord(allText, 'city') || matchesWord(allText, 'county')) {\n return 'Address';\n }\n if (matchesWord(allText, 'email') && (matchesWord(allText, 'message') || matchesWord(allText, 'subject'))) {\n return 'Contact';\n }\n if (matchesWord(allText, 'card') || matchesWord(allText, 'cvv') || matchesWord(allText, 'expiry')) {\n return 'Payment';\n }\n if (names.some(n => matchesWord(n, 'subscribe') || matchesWord(n, 'newsletter'))) {\n return 'Newsletter';\n }\n return null;\n }\n\n function extractFormName(formEl) {\n const ariaLabel = formEl.getAttribute('aria-label');\n if (ariaLabel) return ariaLabel;\n const ariaLabelledBy = formEl.getAttribute('aria-labelledby');\n if (ariaLabelledBy) {\n const labelEl = document.getElementById(ariaLabelledBy);\n if (labelEl) return labelEl.textContent.trim();\n }\n const headings = formEl.querySelectorAll('h1, h2, h3, h4, h5, h6, [role=\"heading\"]');\n for (const h of headings) {\n if (!h.closest('[role=\"dialog\"], [role=\"alertdialog\"], [aria-modal=\"true\"]')) {\n return h.textContent.trim();\n }\n }\n const title = formEl.getAttribute('title');\n if (title) return title;\n const name = formEl.getAttribute('name');\n if (name) {\n return name\n .replace(/[_-]/g, ' ')\n .replace(/([a-z])([A-Z])/g, '$1 $2')\n .replace(/^./, s => s.toUpperCase());\n }\n const nearbyHeading = findNearbyHeading(formEl);\n if (nearbyHeading) return nearbyHeading;\n const inferredType = inferFormType(formEl);\n if (inferredType) return inferredType;\n return null;\n }\n\n function discoverFields(container, formIndex, startIndex) {\n const fields = [];\n const nativeInputs = container.querySelectorAll(\n 'input:not([type=\"hidden\"]):not([type=\"submit\"]):not([type=\"button\"]):not([type=\"reset\"]):not([type=\"image\"]), ' +\n 'textarea, ' +\n 'select'\n );\n const customInputs = container.querySelectorAll(\n '[role=\"textbox\"], ' +\n '[role=\"checkbox\"]:not(input), ' +\n '[role=\"radio\"]:not(input), ' +\n '[role=\"combobox\"], ' +\n '[role=\"listbox\"], ' +\n '[role=\"switch\"], ' +\n '[contenteditable=\"true\"]'\n );\n const allInputs = new Set([...nativeInputs, ...customInputs]);\n let idx = startIndex;\n for (const el of allInputs) {\n const isHidden = el.type === 'hidden' || !isShown(el);\n const value = getFieldValue(el);\n fields.push({\n index: idx,\n formIndex: formIndex,\n selector: generateSelector(el),\n type: getFieldType(el),\n inputType: el.type?.toLowerCase(),\n label: extractLabel(el),\n name: el.name || null,\n placeholder: el.placeholder || undefined,\n required: isRequired(el),\n groupLabel: groupLabel(el),\n disabled: el.disabled || el.getAttribute('aria-disabled') === 'true',\n readOnly: el.readOnly || false,\n hidden: isHidden,\n native: isNativeInput(el),\n value: value,\n checked: el.checked,\n validationMessage: el.validationMessage || undefined,\n isValid: el.validity ? el.validity.valid : true,\n valueMissing: el.validity ? el.validity.valueMissing : false,\n ariaInvalid: el.getAttribute('aria-invalid') === 'true',\n hasErrorClass: el.classList.contains('error') || el.classList.contains('invalid'),\n siblingErrorText: findSiblingError(el),\n options: getSelectOptions(el)\n });\n idx++;\n }\n return fields;\n }\n\n function discoverButtons(container, startIndex) {\n const buttons = [];\n const buttonEls = container.querySelectorAll(\n 'button, ' +\n 'input[type=\"submit\"], ' +\n 'input[type=\"button\"], ' +\n 'input[type=\"reset\"], ' +\n '[role=\"button\"]'\n );\n let idx = startIndex;\n for (const el of buttonEls) {\n if (!isShown(el)) continue;\n const type = el.type?.toLowerCase() || 'button';\n const btnType = type === 'submit' ? 'submit' : type === 'reset' ? 'reset' : 'button';\n const explicitSubmit = btnType === 'submit' &&\n (el.tagName === 'INPUT' || (el.getAttribute('type') || '').toLowerCase() === 'submit');\n const formDefault = btnType === 'submit' && !explicitSubmit && Boolean(el.form);\n const primaryClass = Array.from(el.classList).some(c => /^(btn[-_])?(primary|submit)$/i.test(c));\n buttons.push({\n index: idx,\n selector: generateSelector(el),\n label: extractButtonLabel(el),\n type: btnType,\n disabled: el.disabled || el.getAttribute('aria-disabled') === 'true',\n explicitSubmit: explicitSubmit,\n formDefault: formDefault,\n primaryClass: primaryClass\n });\n idx++;\n }\n return buttons;\n }\n\n const forms = document.querySelectorAll('form');\n let globalIndex = 0;\n\n // A page still loading may not have a body yet\n const pageRoot = document.body || document.documentElement;\n if (forms.length === 0 && pageRoot) {\n const bodyFields = discoverFields(pageRoot, 0, 0);\n const bodyButtons = discoverButtons(pageRoot, bodyFields.length);\n if (bodyFields.length > 0) {\n result.forms.push({\n index: 0,\n name: document.title || 'Page',\n action: null,\n method: 'GET',\n step: null,\n relevanceScore: bodyFields.length * 3,\n hidden: false,\n inDialog: false,\n inIframe: false,\n fields: bodyFields,\n buttons: bodyButtons\n });\n }\n } else {\n for (let i = 0; i < forms.length; i++) {\n const formEl = forms[i];\n const fields = discoverFields(formEl, i, globalIndex);\n globalIndex += fields.length;\n const buttons = discoverButtons(formEl, globalIndex);\n globalIndex += buttons.length;\n const inIframe = formEl.ownerDocument !== document;\n // Shown when a field or a button is (buttons list shown ones only), or\n // it has neither (a form with only type=hidden inputs and no button)\n const shown = isShown(formEl) &&\n (fields.some((f) => !f.hidden) || buttons.length > 0 || fields.length === 0);\n result.forms.push({\n index: i,\n name: extractFormName(formEl),\n action: formEl.action || null,\n method: (formEl.method || 'GET').toUpperCase(),\n step: detectFormStep(formEl),\n relevanceScore: calculateRelevance(formEl, fields, buttons),\n hidden: !shown,\n inDialog: shown && inDialog(formEl),\n inIframe: inIframe,\n fields: fields,\n buttons: buttons\n });\n }\n }\n\n // Forms the main document does not contain may be in its same-origin iframes\n if (result.forms.length === 0) {\n result.frameForms = [];\n for (const frame of document.querySelectorAll('iframe, frame')) {\n let frameDocument = null;\n try { frameDocument = frame.contentDocument; } catch (e) { frameDocument = null; }\n if (frameDocument && frameDocument.querySelector('form, input:not([type=hidden]), select, textarea')) {\n result.frameForms.push({ url: frame.src || 'about:blank' });\n }\n }\n }\n\n result.readyState = document.readyState;\n return result;\n})()\n";
19
19
  /**
20
20
  * Type guard for raw form data.
21
21
  *
@@ -18,6 +18,55 @@ export const FORM_DISCOVERY_SCRIPT = `
18
18
  (function() {
19
19
  const result = { forms: [] };
20
20
 
21
+ // Rendered and not visibility-hidden, ancestors included (opacity is left
22
+ // out: styled checkboxes and radios are often transparent)
23
+ function isShown(element) {
24
+ if (typeof element.checkVisibility === 'function') {
25
+ return element.checkVisibility({ visibilityProperty: true });
26
+ }
27
+ const style = window.getComputedStyle(element);
28
+ return style.display !== 'none' && style.visibility !== 'hidden';
29
+ }
30
+
31
+ // Inside an open dialog: <dialog open>, aria-modal or a dialog role, or a
32
+ // modal without them (DocSearch): an overlay ancestor below <body>
33
+ // (position fixed or absolute) that is raised (z-index > 0) or has a
34
+ // backdrop and whose class names a modal or dialog, or a fixed overlay over
35
+ // half the viewport or more that is raised or lies over other page content
36
+ // at the viewport centre. Static wrappers (Drupal's
37
+ // dialog-off-canvas-main-canvas) and fixed app shells holding the whole
38
+ // page are not dialogs.
39
+ function inDialog(element) {
40
+ if (element.closest('dialog[open], [aria-modal="true"], [role="dialog"], [role="alertdialog"]')) return true;
41
+ const viewportArea = window.innerWidth * window.innerHeight;
42
+ for (let node = element.parentElement; node && node !== document.body; node = node.parentElement) {
43
+ const style = window.getComputedStyle(node);
44
+ if (style.position !== 'fixed' && style.position !== 'absolute') continue;
45
+ const raised = parseInt(style.zIndex, 10) > 0;
46
+ const className = typeof node.className === 'string' ? node.className : '';
47
+ if (/(^|[\\s_-])(modal|dialog)($|[\\s_-])/i.test(className) && (raised || hasBackdrop(node))) return true;
48
+ if (style.position !== 'fixed') continue;
49
+ const box = node.getBoundingClientRect();
50
+ if (box.width * box.height >= viewportArea / 2 && (raised || coversContent(node))) return true;
51
+ }
52
+ return false;
53
+ }
54
+
55
+ // A backdrop element next to or inside an overlay
56
+ function hasBackdrop(node) {
57
+ const backdrop = /backdrop|overlay/i;
58
+ const near = [node.previousElementSibling, node.nextElementSibling, ...Array.from(node.children)];
59
+ return near.some((n) => n && typeof n.className === 'string' && backdrop.test(n.className));
60
+ }
61
+
62
+ // The overlay is on top at the viewport centre with other page content
63
+ // (not its ancestors) below it
64
+ function coversContent(node) {
65
+ const stack = document.elementsFromPoint(window.innerWidth / 2, window.innerHeight / 2);
66
+ if (!stack.length || !node.contains(stack[0])) return false;
67
+ return stack.some((n) => !n.contains(node) && !node.contains(n));
68
+ }
69
+
21
70
  function isUnique(selector) {
22
71
  try {
23
72
  return document.querySelectorAll(selector).length === 1;
@@ -66,9 +115,50 @@ export const FORM_DISCOVERY_SCRIPT = `
66
115
  cleaned = cleaned.replace(pattern, '');
67
116
  }
68
117
  cleaned = cleaned.replace(/\\s{2,}/g, ' ').trim();
118
+ cleaned = cleaned.replace(/^\\*\\s+/, '').replace(/\\s+\\*(\\s*:?)$/, '$1');
69
119
  return cleaned || text.trim();
70
120
  }
71
121
 
122
+ // The element labelling a field: label[for], aria-labelledby, wrapping label
123
+ function labelElement(element) {
124
+ const labelFor = element.id ? document.querySelector('label[for="' + CSS.escape(element.id) + '"]') : null;
125
+ const labelledBy = element.getAttribute('aria-labelledby');
126
+ return labelFor || (labelledBy && document.getElementById(labelledBy)) || element.closest('label');
127
+ }
128
+
129
+ // A standalone required star in label text: "* Name", "Name *", "Name *:"
130
+ // (not a footnote mark such as "Terms*")
131
+ function labelMarksRequired(text) {
132
+ return /^\\*\\s|\\s\\*\\s*:?$/.test(String(text).trim());
133
+ }
134
+
135
+ // Required by attribute, ARIA, or a label marked with a standalone star,
136
+ // or with an element of its own holding just "*" (<span aria-hidden>*</span>)
137
+ function isRequired(element) {
138
+ if (element.required || element.getAttribute('aria-required') === 'true') return true;
139
+ const label = labelElement(element);
140
+ if (!label) return false;
141
+ const clone = label.cloneNode(true);
142
+ clone.querySelectorAll('input, select, textarea, button').forEach(i => i.remove());
143
+ return labelMarksRequired(clone.textContent || '') ||
144
+ Array.from(clone.querySelectorAll('*')).some(n => n.textContent.trim() === '*');
145
+ }
146
+
147
+ // Name of the radio or checkbox group a field belongs to: its radiogroup's
148
+ // label, its fieldset's legend, or its name
149
+ function groupLabel(element) {
150
+ const type = element.type?.toLowerCase();
151
+ if ((type !== 'radio' && type !== 'checkbox') || !element.name) return undefined;
152
+ const group = element.closest('[role="radiogroup"], fieldset');
153
+ const named = group && (group.getAttribute('aria-label') ||
154
+ (group.querySelector(':scope > legend') || {}).textContent);
155
+ if (named && named.trim()) return cleanLabelText(named);
156
+ return element.name
157
+ .replace(/[_-]/g, ' ')
158
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
159
+ .replace(/^./, s => s.toUpperCase());
160
+ }
161
+
72
162
  function extractLabel(element) {
73
163
  if (element.id) {
74
164
  const labelFor = document.querySelector('label[for="' + CSS.escape(element.id) + '"]');
@@ -237,7 +327,7 @@ export const FORM_DISCOVERY_SCRIPT = `
237
327
  score += Math.min(distinctFields * 3, 30);
238
328
  const textTypes = ['text', 'search', 'email', 'password', 'textarea', 'tel', 'url', 'number', 'textbox'];
239
329
  if (fields.some((f) => textTypes.includes(f.type))) score += 10;
240
- const hasSubmit = buttons.some(b => b.type === 'submit' || b.isPrimary);
330
+ const hasSubmit = buttons.some(b => b.type === 'submit' || b.primaryClass);
241
331
  if (hasSubmit) score += 10;
242
332
  const style = window.getComputedStyle(formEl);
243
333
  if (style.display === 'none' || style.visibility === 'hidden') score -= 100;
@@ -377,8 +467,7 @@ export const FORM_DISCOVERY_SCRIPT = `
377
467
  const allInputs = new Set([...nativeInputs, ...customInputs]);
378
468
  let idx = startIndex;
379
469
  for (const el of allInputs) {
380
- const style = window.getComputedStyle(el);
381
- const isHidden = style.display === 'none' || style.visibility === 'hidden' || el.type === 'hidden';
470
+ const isHidden = el.type === 'hidden' || !isShown(el);
382
471
  const value = getFieldValue(el);
383
472
  fields.push({
384
473
  index: idx,
@@ -389,7 +478,8 @@ export const FORM_DISCOVERY_SCRIPT = `
389
478
  label: extractLabel(el),
390
479
  name: el.name || null,
391
480
  placeholder: el.placeholder || undefined,
392
- required: el.required || el.getAttribute('aria-required') === 'true',
481
+ required: isRequired(el),
482
+ groupLabel: groupLabel(el),
393
483
  disabled: el.disabled || el.getAttribute('aria-disabled') === 'true',
394
484
  readOnly: el.readOnly || false,
395
485
  hidden: isHidden,
@@ -420,21 +510,22 @@ export const FORM_DISCOVERY_SCRIPT = `
420
510
  );
421
511
  let idx = startIndex;
422
512
  for (const el of buttonEls) {
423
- const style = window.getComputedStyle(el);
424
- if (style.display === 'none' || style.visibility === 'hidden') continue;
513
+ if (!isShown(el)) continue;
425
514
  const type = el.type?.toLowerCase() || 'button';
426
515
  const btnType = type === 'submit' ? 'submit' : type === 'reset' ? 'reset' : 'button';
427
- const isPrimary = btnType === 'submit' ||
428
- el.classList.contains('primary') ||
429
- el.classList.contains('btn-primary') ||
430
- el.classList.contains('submit');
516
+ const explicitSubmit = btnType === 'submit' &&
517
+ (el.tagName === 'INPUT' || (el.getAttribute('type') || '').toLowerCase() === 'submit');
518
+ const formDefault = btnType === 'submit' && !explicitSubmit && Boolean(el.form);
519
+ const primaryClass = Array.from(el.classList).some(c => /^(btn[-_])?(primary|submit)$/i.test(c));
431
520
  buttons.push({
432
521
  index: idx,
433
522
  selector: generateSelector(el),
434
523
  label: extractButtonLabel(el),
435
524
  type: btnType,
436
525
  disabled: el.disabled || el.getAttribute('aria-disabled') === 'true',
437
- isPrimary: isPrimary
526
+ explicitSubmit: explicitSubmit,
527
+ formDefault: formDefault,
528
+ primaryClass: primaryClass
438
529
  });
439
530
  idx++;
440
531
  }
@@ -444,9 +535,11 @@ export const FORM_DISCOVERY_SCRIPT = `
444
535
  const forms = document.querySelectorAll('form');
445
536
  let globalIndex = 0;
446
537
 
447
- if (forms.length === 0) {
448
- const bodyFields = discoverFields(document.body, 0, 0);
449
- const bodyButtons = discoverButtons(document.body, bodyFields.length);
538
+ // A page still loading may not have a body yet
539
+ const pageRoot = document.body || document.documentElement;
540
+ if (forms.length === 0 && pageRoot) {
541
+ const bodyFields = discoverFields(pageRoot, 0, 0);
542
+ const bodyButtons = discoverButtons(pageRoot, bodyFields.length);
450
543
  if (bodyFields.length > 0) {
451
544
  result.forms.push({
452
545
  index: 0,
@@ -455,6 +548,8 @@ export const FORM_DISCOVERY_SCRIPT = `
455
548
  method: 'GET',
456
549
  step: null,
457
550
  relevanceScore: bodyFields.length * 3,
551
+ hidden: false,
552
+ inDialog: false,
458
553
  inIframe: false,
459
554
  fields: bodyFields,
460
555
  buttons: bodyButtons
@@ -468,6 +563,10 @@ export const FORM_DISCOVERY_SCRIPT = `
468
563
  const buttons = discoverButtons(formEl, globalIndex);
469
564
  globalIndex += buttons.length;
470
565
  const inIframe = formEl.ownerDocument !== document;
566
+ // Shown when a field or a button is (buttons list shown ones only), or
567
+ // it has neither (a form with only type=hidden inputs and no button)
568
+ const shown = isShown(formEl) &&
569
+ (fields.some((f) => !f.hidden) || buttons.length > 0 || fields.length === 0);
471
570
  result.forms.push({
472
571
  index: i,
473
572
  name: extractFormName(formEl),
@@ -475,6 +574,8 @@ export const FORM_DISCOVERY_SCRIPT = `
475
574
  method: (formEl.method || 'GET').toUpperCase(),
476
575
  step: detectFormStep(formEl),
477
576
  relevanceScore: calculateRelevance(formEl, fields, buttons),
577
+ hidden: !shown,
578
+ inDialog: shown && inDialog(formEl),
478
579
  inIframe: inIframe,
479
580
  fields: fields,
480
581
  buttons: buttons
@@ -482,8 +583,6 @@ export const FORM_DISCOVERY_SCRIPT = `
482
583
  }
483
584
  }
484
585
 
485
- result.forms.sort((a, b) => b.relevanceScore - a.relevanceScore);
486
-
487
586
  // Forms the main document does not contain may be in its same-origin iframes
488
587
  if (result.forms.length === 0) {
489
588
  result.frameForms = [];
@@ -496,6 +595,7 @@ export const FORM_DISCOVERY_SCRIPT = `
496
595
  }
497
596
  }
498
597
 
598
+ result.readyState = document.readyState;
499
599
  return result;
500
600
  })()
501
601
  `;
@@ -19,6 +19,16 @@ export type PointerAction = 'click' | 'double' | 'right' | 'hover';
19
19
  * @returns `Input.dispatchMouseEvent` parameters, in order
20
20
  */
21
21
  export declare function mouseEvents(action: PointerAction, x: number, y: number): Array<Record<string, unknown>>;
22
+ /**
23
+ * Whether the mouse press just dispatched reached the target element.
24
+ *
25
+ * Any doubt (no probe, evaluation failure) counts as reached, so only a
26
+ * press that the page provably never saw is reported.
27
+ *
28
+ * @param cdp - CDP connection
29
+ * @returns False only when the target received no pointerdown/mousedown
30
+ */
31
+ export declare function pressReachedTarget(cdp: CDPConnection): Promise<boolean>;
22
32
  /**
23
33
  * Click an element.
24
34
  */