browser-debugger-cli 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -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 +29 -6
- 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 +189 -119
- 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/keyAttributes.d.ts +20 -0
- package/dist/commands/dom/helpers/keyAttributes.js +54 -0
- package/dist/commands/dom/helpers/query.d.ts +44 -17
- package/dist/commands/dom/helpers/query.js +300 -106
- package/dist/commands/dom/helpers/runElementCommand.d.ts +10 -2
- package/dist/commands/dom/helpers/runElementCommand.js +98 -30
- package/dist/commands/dom/helpers/screenshot.d.ts +4 -1
- package/dist/commands/dom/helpers/screenshot.js +239 -51
- package/dist/commands/dom/index.d.ts +4 -1
- package/dist/commands/dom/index.js +22 -7
- package/dist/commands/dom/inspect.d.ts +15 -0
- package/dist/commands/dom/inspect.js +82 -0
- 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 +15 -5
- 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 +16 -10
- package/dist/commands/dom/semanticUtils.js +53 -16
- 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 +17 -13
- package/dist/commands/optionBehaviors.js +154 -21
- package/dist/commands/page.d.ts +3 -2
- package/dist/commands/page.js +100 -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/commonOptions.d.ts +4 -0
- package/dist/commands/shared/commonOptions.js +9 -0
- 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 +61 -5
- package/dist/commands/shared/startHelpers.d.ts +66 -0
- package/dist/commands/shared/startHelpers.js +103 -13
- package/dist/commands/shared/validation.d.ts +14 -2
- package/dist/commands/shared/validation.js +20 -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 +10 -2
- package/dist/daemon/launcher.js +8 -7
- package/dist/daemon/server/SocketServer.js +1 -2
- package/dist/daemon/session/Session.d.ts +20 -0
- package/dist/daemon/session/Session.js +80 -9
- package/dist/daemon/session/chromeConnection.d.ts +9 -0
- package/dist/daemon/session/chromeConnection.js +45 -8
- package/dist/daemon/session/commandRegistry.d.ts +14 -1
- package/dist/daemon/session/commandRegistry.js +113 -67
- package/dist/daemon/session/interactions.d.ts +48 -9
- package/dist/daemon/session/interactions.js +46 -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 +10630 -3601
- package/dist/errors/messages.d.ts +456 -24
- package/dist/errors/messages.js +862 -67
- package/dist/index.js +6915 -3401
- package/dist/ipc/client.d.ts +21 -1
- package/dist/ipc/client.js +35 -3
- package/dist/ipc/protocol/commands.d.ts +145 -5
- package/dist/ipc/protocol/commands.js +4 -0
- package/dist/ipc/protocol/domTypes.d.ts +291 -7
- package/dist/ipc/protocol/inspectTypes.d.ts +388 -0
- package/dist/ipc/protocol/inspectTypes.js +10 -0
- 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 +185 -0
- package/dist/runtime/dom/actionEffects.js +402 -0
- package/dist/runtime/dom/actionEffectsScripts.d.ts +90 -0
- package/dist/runtime/dom/actionEffectsScripts.js +426 -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 +103 -0
- package/dist/runtime/dom/elementInfo.js +256 -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 +184 -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 +9 -0
- package/dist/runtime/dom/formFillHelpers/fill.js +178 -14
- 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 +12 -1
- package/dist/runtime/dom/frameSelection.js +22 -3
- package/dist/runtime/dom/frames.d.ts +61 -5
- package/dist/runtime/dom/frames.js +329 -75
- package/dist/runtime/dom/inspect.d.ts +28 -0
- package/dist/runtime/dom/inspect.js +557 -0
- package/dist/runtime/dom/inspectAllStyles.d.ts +62 -0
- package/dist/runtime/dom/inspectAllStyles.js +385 -0
- package/dist/runtime/dom/inspectCascade.d.ts +94 -0
- package/dist/runtime/dom/inspectCascade.js +371 -0
- package/dist/runtime/dom/inspectCascadeModel.d.ts +39 -0
- package/dist/runtime/dom/inspectCascadeModel.js +232 -0
- package/dist/runtime/dom/inspectHints.d.ts +62 -0
- package/dist/runtime/dom/inspectHints.js +305 -0
- package/dist/runtime/dom/inspectLayoutModel.d.ts +87 -0
- package/dist/runtime/dom/inspectLayoutModel.js +346 -0
- package/dist/runtime/dom/inspectModel.d.ts +74 -0
- package/dist/runtime/dom/inspectModel.js +184 -0
- package/dist/runtime/dom/inspectPaintModel.d.ts +157 -0
- package/dist/runtime/dom/inspectPaintModel.js +461 -0
- package/dist/runtime/dom/inspectRules.d.ts +37 -0
- package/dist/runtime/dom/inspectRules.js +101 -0
- package/dist/runtime/dom/inspectScripts.d.ts +132 -0
- package/dist/runtime/dom/inspectScripts.js +263 -0
- package/dist/runtime/dom/inspectTree.d.ts +40 -0
- package/dist/runtime/dom/inspectTree.js +134 -0
- package/dist/runtime/dom/inspectVariables.d.ts +33 -0
- package/dist/runtime/dom/inspectVariables.js +94 -0
- package/dist/runtime/dom/inspectWhyModel.d.ts +20 -0
- package/dist/runtime/dom/inspectWhyModel.js +134 -0
- package/dist/runtime/dom/layout.d.ts +71 -0
- package/dist/runtime/dom/layout.js +340 -0
- package/dist/runtime/dom/listenerPageScripts.d.ts +72 -0
- package/dist/runtime/dom/listenerPageScripts.js +365 -0
- package/dist/runtime/dom/listenerSummary.d.ts +136 -11
- package/dist/runtime/dom/listenerSummary.js +361 -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 +63 -2
- package/dist/runtime/dom/reactEventHelpers.js +220 -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 +71 -0
- package/dist/runtime/page/emulation.js +117 -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 +18 -3
- package/dist/telemetry/a11y.js +170 -29
- package/dist/telemetry/console.d.ts +1 -0
- package/dist/telemetry/console.js +100 -5
- package/dist/telemetry/network.js +3 -1
- 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 +112 -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.d.ts +8 -0
- package/dist/ui/formatters/details.js +61 -4
- package/dist/ui/formatters/dom.d.ts +27 -14
- package/dist/ui/formatters/dom.js +88 -59
- package/dist/ui/formatters/form.js +29 -18
- package/dist/ui/formatters/inspect.d.ts +39 -0
- package/dist/ui/formatters/inspect.js +596 -0
- package/dist/ui/formatters/keyAttributes.d.ts +19 -0
- package/dist/ui/formatters/keyAttributes.js +84 -0
- 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.d.ts +13 -0
- package/dist/ui/formatters/networkHeaders.js +36 -3
- package/dist/ui/formatters/networkList.d.ts +29 -1
- package/dist/ui/formatters/networkList.js +86 -20
- 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 +19 -0
- package/dist/ui/formatting.js +31 -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 +504 -14
- package/dist/ui/messages/commands.js +835 -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/networkMessages.d.ts +14 -0
- package/dist/ui/messages/networkMessages.js +18 -0
- package/dist/ui/messages/preview.js +5 -4
- package/dist/ui/messages/session.d.ts +30 -21
- package/dist/ui/messages/session.js +48 -26
- package/dist/ui/messages/sessionCommand.d.ts +43 -0
- package/dist/ui/messages/sessionCommand.js +52 -0
- package/dist/utils/async.d.ts +17 -0
- package/dist/utils/async.js +36 -0
- package/dist/utils/color.d.ts +84 -0
- package/dist/utils/color.js +376 -0
- package/dist/utils/cssValues.d.ts +109 -0
- package/dist/utils/cssValues.js +236 -0
- package/dist/utils/http.d.ts +22 -1
- package/dist/utils/http.js +28 -9
- package/dist/utils/selectorFilters.d.ts +48 -8
- package/dist/utils/selectorFilters.js +296 -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
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { CDPProtocolError } from '../../connection/errors.js';
|
|
1
2
|
import { CommandError } from '../../errors/index.js';
|
|
2
|
-
import { navigationPendingError, pageBusyError, promiseTimeoutError, scriptExecutionError, scriptTimeoutError, } from '../../errors/messages.js';
|
|
3
|
+
import { navigationPendingError, pageBusyError, pageClosedDuringEvalError, pageNavigatedDuringEvalError, promiseTimeoutError, scriptExecutionError, scriptTimeoutError, } from '../../errors/messages.js';
|
|
3
4
|
import { pendingNavigationUrl } from '../page/navigation.js';
|
|
4
5
|
import { senderFor } from '../../telemetry/objectExpander.js';
|
|
5
6
|
import { formatRemoteObject } from '../../telemetry/remoteObject.js';
|
|
@@ -302,9 +303,10 @@ async function withDeadline(work, ms, onTimeout) {
|
|
|
302
303
|
* answering because a navigation still waits for the server is left alone.
|
|
303
304
|
*
|
|
304
305
|
* @param cdp - CDP connection
|
|
306
|
+
* @param scope - Whether the target is the page or an iframe
|
|
305
307
|
* @returns The timeout error to report
|
|
306
308
|
*/
|
|
307
|
-
async function terminatePageScripts(cdp) {
|
|
309
|
+
async function terminatePageScripts(cdp, scope) {
|
|
308
310
|
const pendingUrl = await pendingNavigationUrl(cdp).catch((error) => {
|
|
309
311
|
log.debug(`Could not check for a pending navigation: ${getErrorMessage(error)}`);
|
|
310
312
|
return undefined;
|
|
@@ -313,14 +315,50 @@ async function terminatePageScripts(cdp) {
|
|
|
313
315
|
const err = navigationPendingError(pendingUrl);
|
|
314
316
|
return new CommandError(err.message, { suggestion: err.suggestion }, EXIT_CODES.CDP_TIMEOUT);
|
|
315
317
|
}
|
|
316
|
-
await cdp
|
|
317
|
-
.send('Runtime.terminateExecution')
|
|
318
|
-
.catch((error) => log.debug(`terminateExecution failed: ${getErrorMessage(error)}`));
|
|
319
|
-
const err = pageBusyError(EVAL_TIMEOUT_MS);
|
|
318
|
+
const err = pageBusyError(EVAL_TIMEOUT_MS, scope, await terminateExecution(cdp));
|
|
320
319
|
return new CommandError(err.message, { suggestion: err.suggestion }, EXIT_CODES.CDP_TIMEOUT);
|
|
321
320
|
}
|
|
321
|
+
/**
|
|
322
|
+
* Ask V8 to terminate the target's running script.
|
|
323
|
+
*
|
|
324
|
+
* @param cdp - CDP connection of the target
|
|
325
|
+
* @returns Whether the target acknowledged it in time
|
|
326
|
+
*/
|
|
327
|
+
async function terminateExecution(cdp) {
|
|
328
|
+
try {
|
|
329
|
+
const stopped = await settledWithin(cdp.send('Runtime.terminateExecution'), LIVENESS_CHECK_MS);
|
|
330
|
+
return stopped.settled;
|
|
331
|
+
}
|
|
332
|
+
catch (error) {
|
|
333
|
+
log.debug(`terminateExecution failed: ${getErrorMessage(error)}`);
|
|
334
|
+
return false;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
322
337
|
/** How long a page gets to answer a liveness check before it counts as busy */
|
|
323
338
|
const LIVENESS_CHECK_MS = 2_000;
|
|
339
|
+
/**
|
|
340
|
+
* Whether the target answers a trivial evaluation in time.
|
|
341
|
+
*
|
|
342
|
+
* @param cdp - CDP connection
|
|
343
|
+
* @param ms - Time limit
|
|
344
|
+
* @returns False when its scripts keep it busy
|
|
345
|
+
*/
|
|
346
|
+
async function isAnswering(cdp, ms) {
|
|
347
|
+
const probe = await settledWithin(cdp.send('Runtime.evaluate', { expression: '1', returnByValue: true }), ms);
|
|
348
|
+
return probe.settled;
|
|
349
|
+
}
|
|
350
|
+
/** Chrome's messages for a command whose execution context or target went away */
|
|
351
|
+
const CONTEXT_LOST_PATTERN = /Execution context was destroyed|Inspected target navigated or closed|Cannot find context with specified id|uniqueContextId not found|Session with given id not found/i;
|
|
352
|
+
/**
|
|
353
|
+
* Whether Chrome failed a command because the document it ran in went away
|
|
354
|
+
* (navigation, a removed iframe).
|
|
355
|
+
*
|
|
356
|
+
* @param error - Error thrown by a CDP command
|
|
357
|
+
* @returns True for a lost context
|
|
358
|
+
*/
|
|
359
|
+
export function isContextLostError(error) {
|
|
360
|
+
return error instanceof CDPProtocolError && CONTEXT_LOST_PATTERN.test(error.message);
|
|
361
|
+
}
|
|
324
362
|
/**
|
|
325
363
|
* Run a CDP command on the page, recovering a page kept busy by its own
|
|
326
364
|
* scripts. If the command has not answered when a script would have been
|
|
@@ -331,19 +369,19 @@ const LIVENESS_CHECK_MS = 2_000;
|
|
|
331
369
|
*
|
|
332
370
|
* @param cdp - CDP connection
|
|
333
371
|
* @param command - The pending command
|
|
334
|
-
* @param
|
|
372
|
+
* @param options - When to check the page, how long it gets to answer, what to call it
|
|
335
373
|
* @returns The command's result
|
|
336
374
|
* @throws CommandError (102) when the page was busy
|
|
337
375
|
*/
|
|
338
|
-
export async function withBusyPageRecovery(cdp, command,
|
|
376
|
+
export async function withBusyPageRecovery(cdp, command, options = {}) {
|
|
377
|
+
const { busyAfterMs = EVAL_TIMEOUT_MS + TERMINATION_GRACE_MS, livenessMs = LIVENESS_CHECK_MS, scope = 'page', } = options;
|
|
339
378
|
command.catch(() => undefined);
|
|
340
|
-
const early = await settledWithin(command,
|
|
379
|
+
const early = await settledWithin(command, busyAfterMs);
|
|
341
380
|
if (early.settled)
|
|
342
381
|
return early.value;
|
|
343
|
-
|
|
344
|
-
if (probe.settled)
|
|
382
|
+
if (await isAnswering(cdp, livenessMs))
|
|
345
383
|
return command;
|
|
346
|
-
throw await terminatePageScripts(cdp);
|
|
384
|
+
throw await terminatePageScripts(cdp, scope);
|
|
347
385
|
}
|
|
348
386
|
/**
|
|
349
387
|
* The work's value if it settles within `ms` (its rejection is passed on).
|
|
@@ -352,7 +390,7 @@ export async function withBusyPageRecovery(cdp, command, limits = { busyAfterMs:
|
|
|
352
390
|
* @param ms - Time limit
|
|
353
391
|
* @returns `{ settled: true, value }`, or `{ settled: false }` when time ran out
|
|
354
392
|
*/
|
|
355
|
-
async function settledWithin(work, ms) {
|
|
393
|
+
export async function settledWithin(work, ms) {
|
|
356
394
|
let timer;
|
|
357
395
|
const timeout = new Promise((resolve) => {
|
|
358
396
|
timer = setTimeout(() => resolve({ settled: false }), ms);
|
|
@@ -364,6 +402,80 @@ async function settledWithin(work, ms) {
|
|
|
364
402
|
clearTimeout(timer);
|
|
365
403
|
}
|
|
366
404
|
}
|
|
405
|
+
/**
|
|
406
|
+
* The error for an evaluation that did not answer in time: a target that
|
|
407
|
+
* still answers was only waiting for a promise that never settled
|
|
408
|
+
* (`await new Promise(() => {})`); one that does not answer is busy, and its
|
|
409
|
+
* scripts are terminated.
|
|
410
|
+
*
|
|
411
|
+
* @param cdp - CDP connection of the target
|
|
412
|
+
* @param scope - Whether the script ran in the page or an iframe
|
|
413
|
+
* @returns The error to report (102)
|
|
414
|
+
*/
|
|
415
|
+
async function evaluationTimeoutError(cdp, scope) {
|
|
416
|
+
if (await isAnswering(cdp, LIVENESS_CHECK_MS))
|
|
417
|
+
return promiseTimeout();
|
|
418
|
+
return terminatePageScripts(cdp, scope);
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* A promise the script awaited or returned did not settle in time.
|
|
422
|
+
*
|
|
423
|
+
* @returns Command error (102)
|
|
424
|
+
*/
|
|
425
|
+
function promiseTimeout() {
|
|
426
|
+
const err = promiseTimeoutError(EVAL_TIMEOUT_MS);
|
|
427
|
+
return new CommandError(err.message, { suggestion: err.suggestion }, EXIT_CODES.CDP_TIMEOUT);
|
|
428
|
+
}
|
|
429
|
+
/** Chrome's message for a command sent to a session (target) that is gone */
|
|
430
|
+
const SESSION_GONE_PATTERN = /Session with given id not found/i;
|
|
431
|
+
/**
|
|
432
|
+
* Whether the page still answers. A page that does not answer in time is
|
|
433
|
+
* taken as open: commands wait while a navigation is pending.
|
|
434
|
+
*
|
|
435
|
+
* @param cdp - CDP connection of the page
|
|
436
|
+
* @returns False when the page (tab) was closed
|
|
437
|
+
*/
|
|
438
|
+
export async function pageStillOpen(cdp) {
|
|
439
|
+
try {
|
|
440
|
+
await settledWithin(cdp.send('Runtime.evaluate', { expression: '1', returnByValue: true }), LIVENESS_CHECK_MS);
|
|
441
|
+
return true;
|
|
442
|
+
}
|
|
443
|
+
catch (error) {
|
|
444
|
+
log.debug(`Page did not answer: ${getErrorMessage(error)}`);
|
|
445
|
+
return false;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* The error for a page script whose context went away: the page navigated,
|
|
450
|
+
* or the tab was closed.
|
|
451
|
+
*
|
|
452
|
+
* @param error - Chrome's context-lost error
|
|
453
|
+
* @param cdp - CDP connection of the page
|
|
454
|
+
* @returns Command error (83)
|
|
455
|
+
*/
|
|
456
|
+
async function pageContextLostError(error, cdp) {
|
|
457
|
+
const sessionGone = SESSION_GONE_PATTERN.test(getErrorMessage(error));
|
|
458
|
+
const err = sessionGone || !(await pageStillOpen(cdp))
|
|
459
|
+
? pageClosedDuringEvalError()
|
|
460
|
+
: pageNavigatedDuringEvalError();
|
|
461
|
+
return new CommandError(err.message, { suggestion: err.suggestion }, EXIT_CODES.RESOURCE_NOT_FOUND);
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* `Runtime.evaluate` options of a `bdg dom eval` script.
|
|
465
|
+
*
|
|
466
|
+
* @param target - Execution context to run in
|
|
467
|
+
* @returns Options (REPL mode, previews, 20 s limit)
|
|
468
|
+
*/
|
|
469
|
+
function evaluateOptions(target) {
|
|
470
|
+
return {
|
|
471
|
+
returnByValue: false,
|
|
472
|
+
generatePreview: true,
|
|
473
|
+
replMode: true,
|
|
474
|
+
objectGroup: EVAL_OBJECT_GROUP,
|
|
475
|
+
timeout: EVAL_TIMEOUT_MS,
|
|
476
|
+
...(target.uniqueContextId && { uniqueContextId: target.uniqueContextId }),
|
|
477
|
+
};
|
|
478
|
+
}
|
|
367
479
|
/**
|
|
368
480
|
* Evaluate a `bdg dom eval` script.
|
|
369
481
|
*
|
|
@@ -378,25 +490,23 @@ async function settledWithin(work, ms) {
|
|
|
378
490
|
* @param script - JavaScript expression
|
|
379
491
|
* @param target - Session and execution context to run in (default: the page)
|
|
380
492
|
* @returns Value and type
|
|
381
|
-
* @throws CommandError (102) when the script was terminated
|
|
493
|
+
* @throws CommandError (102) when the script was terminated or a promise did
|
|
494
|
+
* not settle, (91) when it threw, (83) when the page navigated while it ran
|
|
495
|
+
* (in a frame, Chrome's context-lost error is passed on for the caller to explain)
|
|
382
496
|
*/
|
|
383
497
|
export async function evaluateScript(cdp, script, target = {}) {
|
|
384
498
|
const session = senderFor(cdp, target.sessionId);
|
|
499
|
+
const scope = target.uniqueContextId ? 'frame' : 'page';
|
|
385
500
|
try {
|
|
386
|
-
const response = await withDeadline(executeScript(session, script,
|
|
387
|
-
|
|
388
|
-
generatePreview: true,
|
|
389
|
-
replMode: true,
|
|
390
|
-
objectGroup: EVAL_OBJECT_GROUP,
|
|
391
|
-
timeout: EVAL_TIMEOUT_MS,
|
|
392
|
-
...(target.uniqueContextId && { uniqueContextId: target.uniqueContextId }),
|
|
393
|
-
}), EVAL_TIMEOUT_MS + TERMINATION_GRACE_MS, () => terminatePageScripts(session));
|
|
394
|
-
const settled = await withDeadline(settlePromise(session, response.result, script), EVAL_TIMEOUT_MS, () => {
|
|
395
|
-
const err = promiseTimeoutError(EVAL_TIMEOUT_MS);
|
|
396
|
-
return new CommandError(err.message, { suggestion: err.suggestion }, EXIT_CODES.CDP_TIMEOUT);
|
|
397
|
-
});
|
|
501
|
+
const response = await withDeadline(executeScript(session, script, evaluateOptions(target)), EVAL_TIMEOUT_MS + TERMINATION_GRACE_MS, () => evaluationTimeoutError(target.recovery ?? session, scope));
|
|
502
|
+
const settled = await withDeadline(settlePromise(session, response.result, script), EVAL_TIMEOUT_MS, promiseTimeout);
|
|
398
503
|
return await toEvalResult(session, settled);
|
|
399
504
|
}
|
|
505
|
+
catch (error) {
|
|
506
|
+
throw scope === 'page' && isContextLostError(error)
|
|
507
|
+
? await pageContextLostError(error, session)
|
|
508
|
+
: error;
|
|
509
|
+
}
|
|
400
510
|
finally {
|
|
401
511
|
void session
|
|
402
512
|
.send('Runtime.releaseObjectGroup', { objectGroup: EVAL_OBJECT_GROUP })
|
|
@@ -4,7 +4,8 @@
|
|
|
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 type { CDPConnection } from '../../connection/cdp.js';
|
|
@@ -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,179 @@ 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
|
+
{
|
|
253
|
+
value: listeners.map(({ position, listener }) => ({
|
|
254
|
+
position,
|
|
255
|
+
type: listener.type,
|
|
256
|
+
capture: listener.useCapture,
|
|
257
|
+
})),
|
|
258
|
+
},
|
|
259
|
+
{ value: types ?? null },
|
|
260
|
+
...chain.map((entry) => ({ objectId: entry.objectId })),
|
|
261
|
+
...handlers.map(objectArgument),
|
|
262
|
+
...targets.map(objectArgument),
|
|
263
|
+
],
|
|
264
|
+
objectGroup,
|
|
243
265
|
}));
|
|
244
|
-
const
|
|
245
|
-
|
|
266
|
+
const [infoObject, ...fns] = await arrayItems(cdp, response.result.objectId ?? '');
|
|
267
|
+
const info = await valueOf(cdp, infoObject?.objectId ?? '');
|
|
268
|
+
return toPageDetails(info, await Promise.all(fns.map((fn) => handlerSource(cdp, fn))));
|
|
246
269
|
}
|
|
247
270
|
catch (error) {
|
|
248
|
-
log.debug(`Handler
|
|
249
|
-
return [];
|
|
271
|
+
log.debug(`Handler details not read: ${getErrorMessage(error)}`);
|
|
272
|
+
return { details: [], roots: [], react: [] };
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* A `Runtime.callFunctionOn` argument for a remote object.
|
|
277
|
+
*
|
|
278
|
+
* @param object - The object, if any
|
|
279
|
+
* @returns Its handle, or null
|
|
280
|
+
*/
|
|
281
|
+
function objectArgument(object) {
|
|
282
|
+
return object?.objectId ? { objectId: object.objectId } : { value: null };
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* An internal property (`[[TargetFunction]]`, `[[FunctionLocation]]`) of a
|
|
286
|
+
* page object.
|
|
287
|
+
*
|
|
288
|
+
* @param cdp - CDP connection
|
|
289
|
+
* @param objectId - The object
|
|
290
|
+
* @param name - Property name
|
|
291
|
+
* @returns Its value, undefined when missing or unreadable
|
|
292
|
+
*/
|
|
293
|
+
async function internalProperty(cdp, objectId, name) {
|
|
294
|
+
try {
|
|
295
|
+
const { internalProperties = [] } = (await cdp.send('Runtime.getProperties', {
|
|
296
|
+
objectId,
|
|
297
|
+
ownProperties: true,
|
|
298
|
+
}));
|
|
299
|
+
return internalProperties.find((property) => property.name === name)?.value;
|
|
250
300
|
}
|
|
301
|
+
catch (error) {
|
|
302
|
+
log.debug(`${name} not read: ${getErrorMessage(error)}`);
|
|
303
|
+
return undefined;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* The function a bound handler calls (React binds one dispatcher per event
|
|
308
|
+
* type), so dispatchers are recognised by function identity.
|
|
309
|
+
*
|
|
310
|
+
* @param cdp - CDP connection
|
|
311
|
+
* @param handler - Listener handler
|
|
312
|
+
* @returns The target function, undefined for handlers that are not bound
|
|
313
|
+
*/
|
|
314
|
+
async function boundTarget(cdp, handler) {
|
|
315
|
+
if (!handler?.objectId || !handler.description?.includes('[native code]'))
|
|
316
|
+
return undefined;
|
|
317
|
+
return internalProperty(cdp, handler.objectId, '[[TargetFunction]]');
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Copy a page object by value.
|
|
321
|
+
*
|
|
322
|
+
* @param cdp - CDP connection
|
|
323
|
+
* @param objectId - The object
|
|
324
|
+
* @returns Its JSON value
|
|
325
|
+
*/
|
|
326
|
+
async function valueOf(cdp, objectId) {
|
|
327
|
+
const response = (await cdp.send('Runtime.callFunctionOn', {
|
|
328
|
+
objectId,
|
|
329
|
+
functionDeclaration: 'function () { return this; }',
|
|
330
|
+
returnByValue: true,
|
|
331
|
+
}));
|
|
332
|
+
return response.result.value;
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Source and location of a framework handler function (no location when
|
|
336
|
+
* it cannot be read).
|
|
337
|
+
*
|
|
338
|
+
* @param cdp - CDP connection
|
|
339
|
+
* @param fn - The function
|
|
340
|
+
* @returns Its source and location
|
|
341
|
+
*/
|
|
342
|
+
async function handlerSource(cdp, fn) {
|
|
343
|
+
const source = { source: fn?.description, scriptId: '0', lineNumber: 0, columnNumber: 0 };
|
|
344
|
+
if (!fn?.objectId)
|
|
345
|
+
return source;
|
|
346
|
+
const location = (await internalProperty(cdp, fn.objectId, '[[FunctionLocation]]'))?.value;
|
|
347
|
+
return { ...source, ...location };
|
|
348
|
+
}
|
|
349
|
+
/** Location of a handler whose source could not be read */
|
|
350
|
+
const UNKNOWN_SOURCE = { scriptId: '0', lineNumber: 0, columnNumber: 0 };
|
|
351
|
+
/**
|
|
352
|
+
* Combine the page's report with the framework handlers' sources.
|
|
353
|
+
*
|
|
354
|
+
* @param info - Page report
|
|
355
|
+
* @param sources - Source and location of each jQuery or Preact handler,
|
|
356
|
+
* then each React prop handler, in report order
|
|
357
|
+
* @returns Details per listener and the React prop handlers
|
|
358
|
+
*/
|
|
359
|
+
function toPageDetails(info, sources) {
|
|
360
|
+
let next = 0;
|
|
361
|
+
const details = info.listeners.map(({ name, identity, targetName, jquery, preact }) => ({
|
|
362
|
+
name: name ?? undefined,
|
|
363
|
+
identity: identity ?? undefined,
|
|
364
|
+
targetName: targetName ?? undefined,
|
|
365
|
+
jquery: jquery?.map((handler) => ({
|
|
366
|
+
type: handler.type,
|
|
367
|
+
selector: handler.selector ?? undefined,
|
|
368
|
+
name: handler.name,
|
|
369
|
+
...(sources[next++] ?? UNKNOWN_SOURCE),
|
|
370
|
+
})),
|
|
371
|
+
preact: preact && {
|
|
372
|
+
name: preact.name,
|
|
373
|
+
...(sources[next++] ?? UNKNOWN_SOURCE),
|
|
374
|
+
},
|
|
375
|
+
}));
|
|
376
|
+
const react = info.react.map((handler) => ({
|
|
377
|
+
...handler,
|
|
378
|
+
...(sources[next++] ?? UNKNOWN_SOURCE),
|
|
379
|
+
}));
|
|
380
|
+
return {
|
|
381
|
+
details,
|
|
382
|
+
roots: info.roots,
|
|
383
|
+
react,
|
|
384
|
+
...(info.frame && { frame: info.frame }),
|
|
385
|
+
...(info.jquerySkipped > 0 && { jquerySkipped: info.jquerySkipped }),
|
|
386
|
+
...(info.reactSkipped > 0 && { reactSkipped: info.reactSkipped }),
|
|
387
|
+
};
|
|
251
388
|
}
|
|
252
389
|
//# 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
|
*
|