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.
- package/README.md +4 -1
- package/dist/cdp/schema.d.ts +4 -1
- package/dist/cdp/schema.js +48 -7
- package/dist/commands/cdp.js +3 -2
- package/dist/commands/cleanup.d.ts +11 -0
- package/dist/commands/cleanup.js +161 -57
- package/dist/commands/console.d.ts +20 -1
- package/dist/commands/console.js +57 -17
- package/dist/commands/details.js +3 -2
- package/dist/commands/dom/DomElementResolver.d.ts +10 -3
- package/dist/commands/dom/DomElementResolver.js +35 -17
- package/dist/commands/dom/a11y.d.ts +10 -0
- package/dist/commands/dom/a11y.js +27 -5
- package/dist/commands/dom/eval.d.ts +3 -1
- package/dist/commands/dom/eval.js +29 -4
- package/dist/commands/dom/form.js +16 -62
- package/dist/commands/dom/formInteraction.js +152 -113
- package/dist/commands/dom/formSummary.d.ts +49 -0
- package/dist/commands/dom/formSummary.js +180 -0
- package/dist/commands/dom/frames.d.ts +2 -1
- package/dist/commands/dom/frames.js +17 -2
- package/dist/commands/dom/get.d.ts +6 -5
- package/dist/commands/dom/get.js +92 -82
- package/dist/commands/dom/helpers/index.d.ts +1 -1
- package/dist/commands/dom/helpers/index.js +1 -1
- package/dist/commands/dom/helpers/query.d.ts +44 -17
- package/dist/commands/dom/helpers/query.js +244 -97
- package/dist/commands/dom/helpers/runElementCommand.d.ts +10 -2
- package/dist/commands/dom/helpers/runElementCommand.js +97 -30
- package/dist/commands/dom/helpers/screenshot.d.ts +4 -1
- package/dist/commands/dom/helpers/screenshot.js +164 -49
- package/dist/commands/dom/index.d.ts +3 -1
- package/dist/commands/dom/index.js +16 -6
- package/dist/commands/dom/layout.d.ts +14 -0
- package/dist/commands/dom/layout.js +54 -0
- package/dist/commands/dom/listeners.d.ts +5 -1
- package/dist/commands/dom/listeners.js +13 -3
- package/dist/commands/dom/query.js +2 -3
- package/dist/commands/dom/screenshot.d.ts +12 -2
- package/dist/commands/dom/screenshot.js +27 -3
- package/dist/commands/dom/semanticUtils.d.ts +6 -13
- package/dist/commands/dom/semanticUtils.js +15 -19
- package/dist/commands/dom/wait.d.ts +13 -0
- package/dist/commands/dom/wait.js +83 -0
- package/dist/commands/helpJson.js +2 -2
- package/dist/commands/network/list.js +4 -11
- package/dist/commands/optionBehaviors.js +112 -21
- package/dist/commands/page.d.ts +2 -1
- package/dist/commands/page.js +41 -5
- package/dist/commands/peek.js +4 -11
- package/dist/commands/sessions.d.ts +8 -0
- package/dist/commands/sessions.js +19 -0
- package/dist/commands/shared/CommandRunner.js +4 -4
- package/dist/commands/shared/dataFetcher.js +2 -2
- package/dist/commands/shared/followMode.d.ts +21 -1
- package/dist/commands/shared/followMode.js +29 -2
- package/dist/commands/shared/handleValidationError.d.ts +2 -2
- package/dist/commands/shared/handleValidationError.js +12 -3
- package/dist/commands/shared/optionTypes.d.ts +40 -5
- package/dist/commands/shared/startHelpers.js +12 -3
- package/dist/commands/shared/validation.d.ts +3 -2
- package/dist/commands/shared/validation.js +4 -3
- package/dist/commands/start.d.ts +63 -0
- package/dist/commands/start.js +115 -15
- package/dist/commands/status.js +29 -7
- package/dist/commands/stop.js +7 -6
- package/dist/commands/tail.js +4 -11
- package/dist/commands/types.d.ts +2 -0
- package/dist/commands.js +2 -0
- package/dist/connection/chromeIdentity.d.ts +65 -0
- package/dist/connection/chromeIdentity.js +143 -0
- package/dist/connection/launcher/profilePreferences.d.ts +47 -0
- package/dist/connection/launcher/profilePreferences.js +151 -0
- package/dist/connection/launcher.d.ts +21 -2
- package/dist/connection/launcher.js +42 -16
- package/dist/connection/portReservation.d.ts +14 -4
- package/dist/connection/portReservation.js +21 -6
- package/dist/connection/startupExit.d.ts +8 -0
- package/dist/connection/startupExit.js +15 -6
- package/dist/constants.d.ts +6 -2
- package/dist/constants.js +9 -2
- package/dist/daemon/SessionController.js +23 -7
- package/dist/daemon/errors.d.ts +1 -1
- package/dist/daemon/errors.js +1 -1
- package/dist/daemon/launcher.d.ts +2 -1
- package/dist/daemon/launcher.js +5 -6
- package/dist/daemon/server/SocketServer.js +1 -2
- package/dist/daemon/session/Session.d.ts +13 -0
- package/dist/daemon/session/Session.js +57 -8
- package/dist/daemon/session/chromeConnection.d.ts +9 -0
- package/dist/daemon/session/chromeConnection.js +45 -8
- package/dist/daemon/session/commandRegistry.js +52 -62
- package/dist/daemon/session/interactions.d.ts +35 -9
- package/dist/daemon/session/interactions.js +36 -9
- package/dist/daemon/session/triggeredRequests.d.ts +67 -0
- package/dist/daemon/session/triggeredRequests.js +157 -0
- package/dist/daemon/session/types.d.ts +5 -1
- package/dist/daemon.js +5393 -1600
- package/dist/errors/messages.d.ts +387 -24
- package/dist/errors/messages.js +761 -67
- package/dist/index.js +3976 -1558
- package/dist/ipc/client.d.ts +12 -1
- package/dist/ipc/client.js +22 -3
- package/dist/ipc/protocol/commands.d.ts +89 -4
- package/dist/ipc/protocol/commands.js +2 -0
- package/dist/ipc/protocol/domTypes.d.ts +258 -7
- package/dist/ipc/session/lifecycle.d.ts +8 -1
- package/dist/ipc/session/queries.d.ts +5 -1
- package/dist/ipc/session/types.d.ts +5 -0
- package/dist/ipc/transport/index.d.ts +2 -1
- package/dist/ipc/transport/index.js +2 -2
- package/dist/runtime/dom/actionEffects.d.ts +106 -0
- package/dist/runtime/dom/actionEffects.js +256 -0
- package/dist/runtime/dom/actionEffectsScripts.d.ts +52 -0
- package/dist/runtime/dom/actionEffectsScripts.js +234 -0
- package/dist/runtime/dom/elementGeometry.d.ts +170 -0
- package/dist/runtime/dom/elementGeometry.js +553 -0
- package/dist/runtime/dom/elementInfo.d.ts +77 -0
- package/dist/runtime/dom/elementInfo.js +191 -0
- package/dist/runtime/dom/evalHelpers.d.ts +51 -6
- package/dist/runtime/dom/evalHelpers.js +136 -26
- package/dist/runtime/dom/eventListeners.d.ts +2 -1
- package/dist/runtime/dom/eventListeners.js +174 -47
- package/dist/runtime/dom/formDiscovery.d.ts +1 -1
- package/dist/runtime/dom/formDiscovery.js +116 -16
- package/dist/runtime/dom/formFillHelpers/fill.d.ts +10 -0
- package/dist/runtime/dom/formFillHelpers/fill.js +125 -10
- package/dist/runtime/dom/formFillHelpers/index.d.ts +2 -2
- package/dist/runtime/dom/formFillHelpers/index.js +2 -2
- package/dist/runtime/dom/formFillHelpers/pressKey.js +14 -3
- package/dist/runtime/dom/formFillHelpers/scroll.d.ts +3 -0
- package/dist/runtime/dom/formFillHelpers/scroll.js +60 -18
- package/dist/runtime/dom/formFillHelpers/shared.d.ts +17 -0
- package/dist/runtime/dom/formFillHelpers/shared.js +25 -1
- package/dist/runtime/dom/formFillHelpers/stability.d.ts +20 -6
- package/dist/runtime/dom/formFillHelpers/stability.js +50 -19
- package/dist/runtime/dom/formSubmitHelpers.d.ts +3 -0
- package/dist/runtime/dom/formSubmitHelpers.js +89 -15
- package/dist/runtime/dom/frameLayout.d.ts +60 -0
- package/dist/runtime/dom/frameLayout.js +140 -0
- package/dist/runtime/dom/frameOrigin.d.ts +50 -0
- package/dist/runtime/dom/frameOrigin.js +62 -0
- package/dist/runtime/dom/frameScopedConnection.d.ts +92 -0
- package/dist/runtime/dom/frameScopedConnection.js +252 -0
- package/dist/runtime/dom/frameSelection.d.ts +1 -1
- package/dist/runtime/dom/frameSelection.js +2 -2
- package/dist/runtime/dom/frames.d.ts +25 -2
- package/dist/runtime/dom/frames.js +202 -63
- package/dist/runtime/dom/layout.d.ts +67 -0
- package/dist/runtime/dom/layout.js +333 -0
- package/dist/runtime/dom/listenerPageScripts.d.ts +66 -0
- package/dist/runtime/dom/listenerPageScripts.js +279 -0
- package/dist/runtime/dom/listenerSummary.d.ts +132 -11
- package/dist/runtime/dom/listenerSummary.js +344 -22
- package/dist/runtime/dom/pageActivity.d.ts +41 -0
- package/dist/runtime/dom/pageActivity.js +123 -0
- package/dist/runtime/dom/reactEventHelpers.d.ts +58 -2
- package/dist/runtime/dom/reactEventHelpers.js +212 -41
- package/dist/runtime/dom/targetNode.d.ts +80 -27
- package/dist/runtime/dom/targetNode.js +249 -33
- package/dist/runtime/dom/wait.d.ts +25 -0
- package/dist/runtime/dom/wait.js +199 -0
- package/dist/runtime/dom/waitCondition.d.ts +71 -0
- package/dist/runtime/dom/waitCondition.js +75 -0
- package/dist/runtime/page/emulation.d.ts +51 -0
- package/dist/runtime/page/emulation.js +80 -0
- package/dist/runtime/page/loadingState.d.ts +36 -0
- package/dist/runtime/page/loadingState.js +86 -0
- package/dist/runtime/page/navigation.d.ts +46 -2
- package/dist/runtime/page/navigation.js +69 -33
- package/dist/session/QueryCacheManager.d.ts +11 -1
- package/dist/session/QueryCacheManager.js +25 -3
- package/dist/session/chromeOwners.d.ts +34 -0
- package/dist/session/chromeOwners.js +51 -0
- package/dist/session/cleanup/staleSession.d.ts +11 -1
- package/dist/session/cleanup/staleSession.js +17 -6
- package/dist/session/cleanup/userCommands.js +2 -4
- package/dist/session/metadata.d.ts +5 -1
- package/dist/session/metadata.js +2 -1
- package/dist/session/paths.d.ts +77 -3
- package/dist/session/paths.js +111 -5
- package/dist/session/port.d.ts +31 -7
- package/dist/session/port.js +50 -43
- package/dist/session/portClaims.d.ts +66 -0
- package/dist/session/portClaims.js +284 -0
- package/dist/session/sessionList.d.ts +58 -0
- package/dist/session/sessionList.js +199 -0
- package/dist/session/sessionName.d.ts +46 -0
- package/dist/session/sessionName.js +97 -0
- package/dist/telemetry/a11y.d.ts +8 -3
- package/dist/telemetry/a11y.js +92 -28
- package/dist/telemetry/requestKinds.d.ts +32 -0
- package/dist/telemetry/requestKinds.js +61 -0
- package/dist/telemetry/requestState.d.ts +31 -0
- package/dist/telemetry/requestState.js +38 -0
- package/dist/types.d.ts +80 -3
- package/dist/ui/formatters/a11y.js +3 -0
- package/dist/ui/formatters/console/chronological.d.ts +8 -0
- package/dist/ui/formatters/console/chronological.js +17 -4
- package/dist/ui/formatters/console/json.js +3 -4
- package/dist/ui/formatters/console/shared.d.ts +12 -0
- package/dist/ui/formatters/console.d.ts +2 -2
- package/dist/ui/formatters/console.js +1 -1
- package/dist/ui/formatters/details.js +2 -1
- package/dist/ui/formatters/dom.d.ts +26 -14
- package/dist/ui/formatters/dom.js +65 -52
- package/dist/ui/formatters/form.js +29 -18
- package/dist/ui/formatters/layout.d.ts +31 -0
- package/dist/ui/formatters/layout.js +53 -0
- package/dist/ui/formatters/listeners.d.ts +3 -2
- package/dist/ui/formatters/listeners.js +73 -9
- package/dist/ui/formatters/networkHeaders.js +13 -0
- package/dist/ui/formatters/preview.js +2 -1
- package/dist/ui/formatters/requestStatus.d.ts +1 -17
- package/dist/ui/formatters/requestStatus.js +2 -30
- package/dist/ui/formatters/sessions.d.ts +12 -0
- package/dist/ui/formatters/sessions.js +40 -0
- package/dist/ui/formatters/status.d.ts +21 -2
- package/dist/ui/formatters/status.js +47 -10
- package/dist/ui/formatters/triggeredRequests.d.ts +36 -0
- package/dist/ui/formatters/triggeredRequests.js +65 -0
- package/dist/ui/formatting.d.ts +10 -0
- package/dist/ui/formatting.js +28 -36
- package/dist/ui/messages/chrome.d.ts +9 -0
- package/dist/ui/messages/chrome.js +17 -5
- package/dist/ui/messages/commands.d.ts +388 -14
- package/dist/ui/messages/commands.js +664 -21
- package/dist/ui/messages/consoleMessages.d.ts +10 -0
- package/dist/ui/messages/consoleMessages.js +17 -0
- package/dist/ui/messages/hints.js +2 -1
- package/dist/ui/messages/preview.js +5 -4
- package/dist/ui/messages/session.d.ts +16 -21
- package/dist/ui/messages/session.js +28 -26
- package/dist/ui/messages/sessionCommand.d.ts +43 -0
- package/dist/ui/messages/sessionCommand.js +52 -0
- package/dist/utils/async.d.ts +8 -0
- package/dist/utils/async.js +19 -0
- package/dist/utils/http.d.ts +22 -1
- package/dist/utils/http.js +28 -9
- package/dist/utils/selectorFilters.d.ts +36 -8
- package/dist/utils/selectorFilters.js +267 -53
- package/dist/utils/shellDetection.d.ts +8 -2
- package/dist/utils/shellDetection.js +120 -33
- package/dist/utils/suggestions.d.ts +26 -0
- package/dist/utils/suggestions.js +73 -0
- package/dist/utils/taskMappings.js +10 -0
- package/dist/utils/url.d.ts +12 -2
- package/dist/utils/url.js +69 -7
- package/package.json +1 -1
- package/dist/ui/formatters/sessionFormatters.d.ts +0 -58
- 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
|
|
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,
|
|
13
|
+
import { eventListenersUnavailableError, operationFailedError } from '../../errors/messages.js';
|
|
13
14
|
import { throwIfInvalidSelector, withMultipleMatchesWarning, } from './formFillHelpers/shared.js';
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
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
|
|
64
|
-
const
|
|
65
|
-
const
|
|
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
|
-
|
|
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
|
-
*
|
|
227
|
-
*
|
|
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
|
|
231
|
-
* @param
|
|
232
|
-
* @
|
|
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
|
|
235
|
-
|
|
236
|
-
|
|
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:
|
|
240
|
-
functionDeclaration:
|
|
241
|
-
arguments:
|
|
242
|
-
|
|
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
|
|
245
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
|
-
|
|
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
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
*/
|