browser-debugger-cli 0.12.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/bdg/SKILL.md +4 -4
- package/README.md +1 -0
- package/dist/commands/cdp.d.ts +22 -1
- package/dist/commands/cdp.js +100 -43
- package/dist/commands/console.d.ts +12 -0
- package/dist/commands/console.js +62 -12
- package/dist/commands/dom/DomElementResolver.d.ts +3 -1
- package/dist/commands/dom/DomElementResolver.js +10 -3
- package/dist/commands/dom/a11y.js +3 -2
- package/dist/commands/dom/eval.d.ts +3 -2
- package/dist/commands/dom/eval.js +11 -5
- package/dist/commands/dom/form.js +10 -9
- package/dist/commands/dom/formInteraction.js +8 -7
- package/dist/commands/dom/get.js +8 -8
- 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 +27 -3
- package/dist/commands/dom/helpers/query.js +152 -64
- package/dist/commands/dom/helpers/screenshot.js +13 -13
- package/dist/commands/dom/index.js +4 -2
- package/dist/commands/dom/query.d.ts +19 -2
- package/dist/commands/dom/query.js +37 -6
- package/dist/commands/dom/screenshot.js +2 -1
- package/dist/commands/dom/semanticUtils.d.ts +3 -2
- package/dist/commands/dom/semanticUtils.js +40 -9
- package/dist/commands/helpJson.d.ts +82 -19
- package/dist/commands/helpJson.js +111 -40
- package/dist/commands/helpTopic.d.ts +16 -1
- package/dist/commands/helpTopic.js +59 -1
- package/dist/commands/installSkill.d.ts +15 -5
- package/dist/commands/installSkill.js +86 -16
- package/dist/commands/network/list.js +22 -12
- package/dist/commands/optionBehaviors.js +33 -11
- package/dist/commands/shared/daemonErrorHandler.d.ts +5 -2
- package/dist/commands/shared/daemonErrorHandler.js +20 -9
- package/dist/commands/shared/dataFetcher.d.ts +12 -4
- package/dist/commands/shared/dataFetcher.js +12 -4
- package/dist/commands/shared/followMode.d.ts +9 -1
- package/dist/commands/shared/followMode.js +22 -4
- package/dist/commands/shared/optionTypes.d.ts +4 -1
- package/dist/commands/shared/outputFile.js +6 -1
- package/dist/commands/start.d.ts +7 -5
- package/dist/commands/start.js +65 -21
- package/dist/commands/stop.d.ts +11 -0
- package/dist/commands/stop.js +24 -1
- package/dist/commands.js +1 -1
- package/dist/connection/cdp.d.ts +7 -0
- package/dist/connection/cdp.js +9 -0
- package/dist/connection/launcher.js +3 -2
- package/dist/daemon/SessionController.js +6 -1
- package/dist/daemon/launcher.d.ts +3 -2
- package/dist/daemon/launcher.js +47 -3
- package/dist/daemon/session/Session.d.ts +4 -1
- package/dist/daemon/session/Session.js +33 -2
- package/dist/daemon/session/TelemetryStore.d.ts +8 -1
- package/dist/daemon/session/TelemetryStore.js +13 -1
- package/dist/daemon/session/commandRegistry.js +29 -13
- package/dist/daemon/session/interactions.d.ts +2 -1
- package/dist/daemon/session/interactions.js +13 -1
- package/dist/daemon/session/plugins.js +16 -2
- package/dist/daemon/session/teardown.js +1 -1
- package/dist/daemon.js +1622 -748
- package/dist/errors/messages.d.ts +54 -11
- package/dist/errors/messages.js +109 -22
- package/dist/index.js +13733 -8796
- package/dist/ipc/client.d.ts +18 -2
- package/dist/ipc/client.js +26 -5
- package/dist/ipc/protocol/auditTypes.d.ts +8 -2
- package/dist/ipc/protocol/commands.d.ts +12 -0
- package/dist/ipc/protocol/domTypes.d.ts +12 -0
- package/dist/ipc/protocol/inspectTypes.d.ts +2 -0
- package/dist/ipc/session/types.d.ts +2 -0
- package/dist/runtime/dom/actionEffects.d.ts +5 -1
- package/dist/runtime/dom/actionEffects.js +26 -14
- package/dist/runtime/dom/audit.js +3 -2
- package/dist/runtime/dom/auditModel.js +6 -1
- package/dist/runtime/dom/auditScripts.d.ts +9 -3
- package/dist/runtime/dom/auditScripts.js +41 -5
- package/dist/runtime/dom/elementGeometry.d.ts +10 -3
- package/dist/runtime/dom/elementGeometry.js +27 -4
- package/dist/runtime/dom/elementInfo.d.ts +74 -18
- package/dist/runtime/dom/elementInfo.js +187 -40
- package/dist/runtime/dom/evalHelpers.d.ts +12 -2
- package/dist/runtime/dom/evalHelpers.js +67 -7
- package/dist/runtime/dom/formDiscovery.d.ts +6 -2
- package/dist/runtime/dom/formDiscovery.js +20 -3
- package/dist/runtime/dom/formFillHelpers/fill.js +7 -11
- package/dist/runtime/dom/formFillHelpers/pressKey.js +2 -2
- package/dist/runtime/dom/formFillHelpers/shared.d.ts +16 -10
- package/dist/runtime/dom/formFillHelpers/shared.js +19 -52
- package/dist/runtime/dom/formSubmitHelpers.js +4 -3
- package/dist/runtime/dom/frameLayout.js +1 -0
- package/dist/runtime/dom/inspect.js +5 -6
- package/dist/runtime/dom/inspectAllStyles.js +1 -0
- package/dist/runtime/dom/inspectHints.d.ts +1 -1
- package/dist/runtime/dom/inspectModel.d.ts +2 -1
- package/dist/runtime/dom/inspectModel.js +7 -3
- package/dist/runtime/dom/inspectPaintModel.d.ts +2 -0
- package/dist/runtime/dom/inspectPaintModel.js +3 -1
- package/dist/runtime/dom/inspectScripts.d.ts +29 -2
- package/dist/runtime/dom/inspectScripts.js +49 -10
- package/dist/runtime/dom/layout.js +9 -7
- package/dist/runtime/dom/reactEventHelpers.d.ts +14 -4
- package/dist/runtime/dom/reactEventHelpers.js +63 -27
- package/dist/runtime/dom/targetNode.d.ts +18 -5
- package/dist/runtime/dom/targetNode.js +268 -8
- package/dist/runtime/dom/wait.js +2 -1
- package/dist/runtime/page/bdgWorld.d.ts +57 -0
- package/dist/runtime/page/bdgWorld.js +180 -0
- package/dist/runtime/page/replacedBuiltins.d.ts +28 -0
- package/dist/runtime/page/replacedBuiltins.js +136 -0
- package/dist/session/QueryCacheManager.d.ts +4 -1
- package/dist/session/QueryCacheManager.js +5 -2
- package/dist/session/chrome.d.ts +4 -1
- package/dist/session/chrome.js +7 -1
- package/dist/session/cleanup/staleSession.d.ts +21 -4
- package/dist/session/cleanup/staleSession.js +79 -9
- package/dist/session/cleanup/userCommands.d.ts +4 -1
- package/dist/session/cleanup/userCommands.js +10 -5
- package/dist/session/daemonSocket.d.ts +10 -0
- package/dist/session/daemonSocket.js +22 -0
- package/dist/session/lastSession.d.ts +6 -3
- package/dist/session/lastSession.js +11 -5
- package/dist/session/paths.d.ts +3 -1
- package/dist/session/paths.js +5 -5
- package/dist/session/portClaims.js +4 -3
- package/dist/session/sessionList.d.ts +13 -5
- package/dist/session/sessionList.js +31 -7
- package/dist/telemetry/a11y.js +2 -2
- package/dist/telemetry/console.d.ts +2 -1
- package/dist/telemetry/console.js +30 -21
- package/dist/telemetry/pageCrash.d.ts +26 -0
- package/dist/telemetry/pageCrash.js +53 -0
- package/dist/types.d.ts +16 -0
- package/dist/ui/formatters/audit.js +14 -5
- package/dist/ui/formatters/cdp.d.ts +138 -0
- package/dist/ui/formatters/cdp.js +131 -0
- package/dist/ui/formatters/console/chronological.js +3 -1
- package/dist/ui/formatters/console/follow.d.ts +2 -1
- package/dist/ui/formatters/console/follow.js +2 -2
- package/dist/ui/formatters/console/json.d.ts +2 -2
- package/dist/ui/formatters/console/json.js +11 -5
- package/dist/ui/formatters/console/shared.d.ts +30 -0
- package/dist/ui/formatters/console/shared.js +16 -0
- package/dist/ui/formatters/console/summarize.d.ts +9 -2
- package/dist/ui/formatters/console/summarize.js +40 -9
- package/dist/ui/formatters/console.d.ts +2 -1
- package/dist/ui/formatters/console.js +7 -5
- package/dist/ui/formatters/details.js +3 -1
- package/dist/ui/formatters/dom.d.ts +1 -1
- package/dist/ui/formatters/dom.js +5 -6
- package/dist/ui/formatters/helpFormatters.js +1 -1
- package/dist/ui/formatters/inspect.js +9 -3
- package/dist/ui/formatters/installSkill.d.ts +9 -1
- package/dist/ui/formatters/installSkill.js +32 -6
- package/dist/ui/formatters/layout.js +2 -1
- package/dist/ui/formatters/networkList.d.ts +1 -1
- package/dist/ui/formatters/networkList.js +1 -2
- package/dist/ui/formatters/preview.d.ts +2 -0
- package/dist/ui/formatters/preview.js +17 -7
- package/dist/ui/formatters/sessions.d.ts +2 -2
- package/dist/ui/formatters/sessions.js +9 -2
- package/dist/ui/logging/logger.d.ts +1 -1
- package/dist/ui/messages/commands.d.ts +124 -4
- package/dist/ui/messages/commands.js +162 -7
- package/dist/ui/messages/consoleMessages.d.ts +24 -0
- package/dist/ui/messages/consoleMessages.js +32 -0
- package/dist/ui/messages/preview.d.ts +6 -0
- package/dist/ui/messages/preview.js +9 -1
- package/dist/ui/messages/session.d.ts +13 -2
- package/dist/ui/messages/session.js +22 -3
- package/dist/utils/directories.d.ts +34 -0
- package/dist/utils/directories.js +88 -0
- package/dist/utils/display.d.ts +16 -0
- package/dist/utils/display.js +42 -0
- package/dist/utils/exitCodes.d.ts +1 -0
- package/dist/utils/exitCodes.js +6 -0
- package/dist/utils/process.d.ts +12 -0
- package/dist/utils/process.js +25 -0
- package/package.json +1 -1
|
@@ -2,8 +2,10 @@ import { CDPProtocolError } from '../../connection/errors.js';
|
|
|
2
2
|
import { CommandError } from '../../errors/index.js';
|
|
3
3
|
import { navigationPendingError, pageBusyError, pageClosedDuringEvalError, pageNavigatedDuringEvalError, promiseTimeoutError, scriptExecutionError, scriptTimeoutError, } from '../../errors/messages.js';
|
|
4
4
|
import { pendingNavigationUrl } from '../page/navigation.js';
|
|
5
|
+
import { findReplacedBuiltins } from '../page/replacedBuiltins.js';
|
|
5
6
|
import { senderFor } from '../../telemetry/objectExpander.js';
|
|
6
7
|
import { formatRemoteObject } from '../../telemetry/remoteObject.js';
|
|
8
|
+
import { evalCopiedByBrowserWarning, evalPreviewWarning } from '../../ui/messages/commands.js';
|
|
7
9
|
import { createLogger } from '../../ui/logging/index.js';
|
|
8
10
|
import { getErrorMessage } from '../../utils/errors.js';
|
|
9
11
|
import { EXIT_CODES } from '../../utils/exitCodes.js';
|
|
@@ -121,7 +123,7 @@ async function settlePromise(cdp, result, script) {
|
|
|
121
123
|
* @param details - Exception details
|
|
122
124
|
* @returns Readable message
|
|
123
125
|
*/
|
|
124
|
-
function describeException(details) {
|
|
126
|
+
export function describeException(details) {
|
|
125
127
|
const exception = details.exception;
|
|
126
128
|
if (exception?.type === 'object' && exception.subtype !== 'error' && exception.preview) {
|
|
127
129
|
return `Uncaught ${formatRemoteObject(exception)}`;
|
|
@@ -164,7 +166,8 @@ const BRIEF_SUBTYPES = new Set(['node', 'arraybuffer', 'dataview']);
|
|
|
164
166
|
* `12n`, functions `function name()`, and cycles `[Circular]` (an object
|
|
165
167
|
* shared by two properties is copied twice). Works for objects of iframes
|
|
166
168
|
* (other realms); lists and objects are cut after 1000 entries, and a
|
|
167
|
-
* throwing getter becomes `[Error: …]`.
|
|
169
|
+
* throwing getter becomes `[Error: …]`. It uses the page's built-ins
|
|
170
|
+
* ({@link COPY_BUILTINS}), so it only runs when the page left them alone.
|
|
168
171
|
*/
|
|
169
172
|
export const JSON_SAFE_COPY_FUNCTION = `function () {
|
|
170
173
|
const MAX_ITEMS = 1000;
|
|
@@ -178,6 +181,13 @@ export const JSON_SAFE_COPY_FUNCTION = `function () {
|
|
|
178
181
|
};
|
|
179
182
|
const items = (list, next) => {
|
|
180
183
|
const result = [];
|
|
184
|
+
if (typeof list.length === 'number') {
|
|
185
|
+
for (let i = 0; i < list.length; i++) {
|
|
186
|
+
if (i === MAX_ITEMS) { result.push('…'); break; }
|
|
187
|
+
result.push(next(list[i]));
|
|
188
|
+
}
|
|
189
|
+
return result;
|
|
190
|
+
}
|
|
181
191
|
let count = 0;
|
|
182
192
|
for (const item of list) {
|
|
183
193
|
if (count++ === MAX_ITEMS) { result.push('…'); break; }
|
|
@@ -226,6 +236,33 @@ export const JSON_SAFE_COPY_FUNCTION = `function () {
|
|
|
226
236
|
};
|
|
227
237
|
return copy(this, 0);
|
|
228
238
|
}`;
|
|
239
|
+
/** Built-ins {@link JSON_SAFE_COPY_FUNCTION} uses (it runs in the page's world, next to the result) */
|
|
240
|
+
const COPY_BUILTINS = [
|
|
241
|
+
'Object.keys',
|
|
242
|
+
'Object.is',
|
|
243
|
+
'Object.prototype.toString',
|
|
244
|
+
'Function.prototype.call',
|
|
245
|
+
'Array.from',
|
|
246
|
+
'Array.isArray',
|
|
247
|
+
'Array.prototype.push',
|
|
248
|
+
'Array.prototype.slice',
|
|
249
|
+
'Array.prototype.join',
|
|
250
|
+
'ArrayBuffer.isView',
|
|
251
|
+
'Number.isNaN',
|
|
252
|
+
'Number.isFinite',
|
|
253
|
+
'String',
|
|
254
|
+
'String.prototype.slice',
|
|
255
|
+
'String.prototype.toLowerCase',
|
|
256
|
+
'Set',
|
|
257
|
+
'Set.prototype.add',
|
|
258
|
+
'Set.prototype.has',
|
|
259
|
+
'Set.prototype.delete',
|
|
260
|
+
'Set.prototype.values',
|
|
261
|
+
'Map.prototype.entries',
|
|
262
|
+
'Date.prototype.toISOString',
|
|
263
|
+
];
|
|
264
|
+
/** Returns the object it is called on (for the browser to copy it by value) */
|
|
265
|
+
const SELF_FUNCTION = 'function () { return this; }';
|
|
229
266
|
/**
|
|
230
267
|
* Whether an exception is V8 terminating a script that ran too long.
|
|
231
268
|
*
|
|
@@ -240,7 +277,10 @@ function isTerminated(details) {
|
|
|
240
277
|
*
|
|
241
278
|
* Plain objects and arrays are copied by value; values JSON cannot represent
|
|
242
279
|
* (NaN, -0, BigInt, functions, symbols, DOM nodes, dates, maps, errors, the
|
|
243
|
-
* window, cyclic objects) are given as their readable description.
|
|
280
|
+
* window, cyclic objects) are given as their readable description. On a
|
|
281
|
+
* page that replaced built-ins that copy uses, the browser copies the
|
|
282
|
+
* object (JSON-like values exactly, the others as null or `{}`), or else
|
|
283
|
+
* its preview is given, with a warning naming them.
|
|
244
284
|
*
|
|
245
285
|
* @param cdp - CDP connection
|
|
246
286
|
* @param remote - Remote object returned by Runtime.evaluate
|
|
@@ -260,19 +300,39 @@ async function toEvalResult(cdp, remote) {
|
|
|
260
300
|
if (remote.type !== 'object' || DESCRIBED_SUBTYPES.has(remote.subtype ?? '')) {
|
|
261
301
|
return { value: formatRemoteObject(remote), ...kind };
|
|
262
302
|
}
|
|
303
|
+
const replaced = await findReplacedBuiltins(cdp, COPY_BUILTINS, remote.objectId);
|
|
304
|
+
const copy = await copyByValue(cdp, remote.objectId, replaced.length === 0 ? JSON_SAFE_COPY_FUNCTION : SELF_FUNCTION);
|
|
305
|
+
const value = copy ? copy.value : formatRemoteObject(remote);
|
|
306
|
+
if (replaced.length === 0)
|
|
307
|
+
return { value, ...kind };
|
|
308
|
+
const warning = copy ? evalCopiedByBrowserWarning(replaced) : evalPreviewWarning(replaced);
|
|
309
|
+
return { value, ...kind, warning };
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Copy a result by value: what a function called on it returns, which the
|
|
313
|
+
* browser serialises itself (without the page's built-ins).
|
|
314
|
+
*
|
|
315
|
+
* @param cdp - CDP connection
|
|
316
|
+
* @param objectId - The result
|
|
317
|
+
* @param functionDeclaration - {@link JSON_SAFE_COPY_FUNCTION}, or
|
|
318
|
+
* {@link SELF_FUNCTION} for the browser's plain copy (it fails on cycles
|
|
319
|
+
* and BigInts)
|
|
320
|
+
* @returns The copy, or undefined when it failed
|
|
321
|
+
*/
|
|
322
|
+
async function copyByValue(cdp, objectId, functionDeclaration) {
|
|
263
323
|
try {
|
|
264
324
|
const copy = (await cdp.send('Runtime.callFunctionOn', {
|
|
265
|
-
objectId
|
|
266
|
-
functionDeclaration
|
|
325
|
+
objectId,
|
|
326
|
+
functionDeclaration,
|
|
267
327
|
returnByValue: true,
|
|
268
328
|
}));
|
|
269
329
|
if (!copy.exceptionDetails)
|
|
270
|
-
return { value: copy.result?.value
|
|
330
|
+
return { value: copy.result?.value };
|
|
271
331
|
}
|
|
272
332
|
catch (error) {
|
|
273
333
|
log.debug(`Could not copy eval result by value: ${getErrorMessage(error)}`);
|
|
274
334
|
}
|
|
275
|
-
return
|
|
335
|
+
return undefined;
|
|
276
336
|
}
|
|
277
337
|
/**
|
|
278
338
|
* Wait for `work`, or fail when it takes longer than `ms`.
|
|
@@ -12,10 +12,14 @@ import type { RawFormData } from './formTypes.js';
|
|
|
12
12
|
* - Native form elements and inputs
|
|
13
13
|
* - Custom components with ARIA roles
|
|
14
14
|
* - Labels via priority chain (label[for], aria-label, placeholder, etc.)
|
|
15
|
-
* - Current values and validation state
|
|
15
|
+
* - Current values and validation state (never a secret: a hidden input's
|
|
16
|
+
* value is left out, a sensitive text field's or select's
|
|
17
|
+
* ({@link SENSITIVE_FIELD_JS}; checkboxes and radios named like one keep
|
|
18
|
+
* their checked state) is {@link MASKED_VALUE} when filled, and a
|
|
19
|
+
* sensitive select's options do not say which is chosen)
|
|
16
20
|
* - Form relevance scoring for multi-form pages
|
|
17
21
|
*/
|
|
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";
|
|
22
|
+
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 (type === 'hidden') return '';\n if (isSecret(element)) {\n return (element.isContentEditable ? element.textContent : element.value) ? '\u2022\u2022\u2022\u2022' : '';\n }\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 isSecret(element) {\n const type = element.type?.toLowerCase() || 'text';\n if (type === 'checkbox' || type === 'radio') return false;\n if (element.getAttribute('role') === 'checkbox' || element.getAttribute('role') === 'switch') return false;\n return ((el) => {\n const autocomplete = el.getAttribute('autocomplete') || '';\n if (/(^|\\s)(cc-[a-z-]+|one-time-code|current-password|new-password)(\\s|$)/i.test(autocomplete)) return true;\n if (el.type === 'password' || /^password$/i.test(el.getAttribute('type') || '')) return true;\n const names = [el.getAttribute('name'), el.id, autocomplete].join(' ');\n if (/passw|passwd|pwd|passcode|(^|[^a-z])otp([^a-z]|$)|cvv|cvc/i.test(names)) return true;\n try {\n const security = el.ownerDocument.defaultView.getComputedStyle(el).getPropertyValue('-webkit-text-security');\n return Boolean(security) && security !== 'none';\n } catch (e) {\n return false;\n }\n})(element);\n }\n\n function getSelectOptions(element) {\n if (element.tagName.toLowerCase() !== 'select') return undefined;\n const secret = isSecret(element);\n return Array.from(element.options).map(opt => ({\n value: secret ? '' : opt.value,\n label: opt.textContent.trim(),\n selected: secret ? false : 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
23
|
/**
|
|
20
24
|
* Type guard for raw form data.
|
|
21
25
|
*
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Discovers forms, extracts semantic labels, detects state and validation,
|
|
5
5
|
* and returns structured data for agent consumption.
|
|
6
6
|
*/
|
|
7
|
+
import { MASKED_VALUE, SENSITIVE_FIELD_JS } from './elementInfo.js';
|
|
7
8
|
/**
|
|
8
9
|
* Page-context script for form discovery.
|
|
9
10
|
*
|
|
@@ -11,7 +12,11 @@
|
|
|
11
12
|
* - Native form elements and inputs
|
|
12
13
|
* - Custom components with ARIA roles
|
|
13
14
|
* - Labels via priority chain (label[for], aria-label, placeholder, etc.)
|
|
14
|
-
* - Current values and validation state
|
|
15
|
+
* - Current values and validation state (never a secret: a hidden input's
|
|
16
|
+
* value is left out, a sensitive text field's or select's
|
|
17
|
+
* ({@link SENSITIVE_FIELD_JS}; checkboxes and radios named like one keep
|
|
18
|
+
* their checked state) is {@link MASKED_VALUE} when filled, and a
|
|
19
|
+
* sensitive select's options do not say which is chosen)
|
|
15
20
|
* - Form relevance scoring for multi-form pages
|
|
16
21
|
*/
|
|
17
22
|
export const FORM_DISCOVERY_SCRIPT = `
|
|
@@ -206,6 +211,10 @@ export const FORM_DISCOVERY_SCRIPT = `
|
|
|
206
211
|
function getFieldValue(element) {
|
|
207
212
|
const tag = element.tagName.toLowerCase();
|
|
208
213
|
const type = element.type?.toLowerCase() || 'text';
|
|
214
|
+
if (type === 'hidden') return '';
|
|
215
|
+
if (isSecret(element)) {
|
|
216
|
+
return (element.isContentEditable ? element.textContent : element.value) ? '${MASKED_VALUE}' : '';
|
|
217
|
+
}
|
|
209
218
|
if (tag === 'select') {
|
|
210
219
|
if (element.multiple) {
|
|
211
220
|
return Array.from(element.selectedOptions).map(o => o.value);
|
|
@@ -265,12 +274,20 @@ export const FORM_DISCOVERY_SCRIPT = `
|
|
|
265
274
|
return tag === 'input' || tag === 'textarea' || tag === 'select';
|
|
266
275
|
}
|
|
267
276
|
|
|
277
|
+
function isSecret(element) {
|
|
278
|
+
const type = element.type?.toLowerCase() || 'text';
|
|
279
|
+
if (type === 'checkbox' || type === 'radio') return false;
|
|
280
|
+
if (element.getAttribute('role') === 'checkbox' || element.getAttribute('role') === 'switch') return false;
|
|
281
|
+
return (${SENSITIVE_FIELD_JS})(element);
|
|
282
|
+
}
|
|
283
|
+
|
|
268
284
|
function getSelectOptions(element) {
|
|
269
285
|
if (element.tagName.toLowerCase() !== 'select') return undefined;
|
|
286
|
+
const secret = isSecret(element);
|
|
270
287
|
return Array.from(element.options).map(opt => ({
|
|
271
|
-
value: opt.value,
|
|
288
|
+
value: secret ? '' : opt.value,
|
|
272
289
|
label: opt.textContent.trim(),
|
|
273
|
-
selected: opt.selected
|
|
290
|
+
selected: secret ? false : opt.selected
|
|
274
291
|
}));
|
|
275
292
|
}
|
|
276
293
|
|
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
import * as fs from 'node:fs';
|
|
6
6
|
import * as path from 'node:path';
|
|
7
7
|
import { CommandError } from '../../../errors/index.js';
|
|
8
|
-
import { fileNotFoundError, uploadDirectoryError, singleFileInputError, fillableElementNotFoundError,
|
|
9
|
-
import { escapeValueForJS,
|
|
10
|
-
import { FILL_READ_BACK_SCRIPT, REACT_FILL_SCRIPT, CLICK_ELEMENT_SCRIPT, SHADOW_FIELD_JS, isFillResult, isClickResult, } from '../reactEventHelpers.js';
|
|
8
|
+
import { fileNotFoundError, uploadDirectoryError, singleFileInputError, fillableElementNotFoundError, clickTargetDetachedError, unexpectedResponseFormatError, operationFailedError, pressNotReceivedError, unreachableElementError, } from '../../../errors/messages.js';
|
|
9
|
+
import { ActionScriptError, escapeValueForJS, exceptionSummary, throwIfInvalidSelector, withMultipleMatchesWarning, withValueMismatchWarning, } from './shared.js';
|
|
10
|
+
import { FILL_READ_BACK_SCRIPT, FIRE_EVENT_JS, REACT_FILL_SCRIPT, CLICK_ELEMENT_SCRIPT, SHADOW_FIELD_JS, isFillResult, isClickResult, } from '../reactEventHelpers.js';
|
|
11
11
|
import { FIND_ELEMENTS_JS, LABEL_CONTROL_JS, selectorArgsJS } from '../targetNode.js';
|
|
12
12
|
import { createLogger } from '../../../ui/logging/index.js';
|
|
13
13
|
import { CLICK_NOT_RECEIVED_WARNING, POINTER_ACTION_DONE, POINTER_ACTION_NOUN, domClickFallbackWarning, } from '../../../ui/messages/commands.js';
|
|
@@ -32,9 +32,7 @@ export async function fillElement(cdp, selector, value, options = {}) {
|
|
|
32
32
|
const cdpResponse = response;
|
|
33
33
|
if (cdpResponse.exceptionDetails) {
|
|
34
34
|
throwIfInvalidSelector(cdpResponse.exceptionDetails, selector);
|
|
35
|
-
|
|
36
|
-
const err = fillableElementNotFoundError(selector);
|
|
37
|
-
throw new CommandError(errorMessage, { suggestion: err.suggestion }, EXIT_CODES.SOFTWARE_ERROR);
|
|
35
|
+
throw new ActionScriptError('fill', exceptionSummary(cdpResponse.exceptionDetails), selector);
|
|
38
36
|
}
|
|
39
37
|
if (cdpResponse.result?.value && isFillResult(cdpResponse.result.value)) {
|
|
40
38
|
const result = cdpResponse.result.value;
|
|
@@ -106,8 +104,8 @@ function isValueMismatch(value) {
|
|
|
106
104
|
/** Empties a file input as a user removing the selection would (with events). */
|
|
107
105
|
const CLEAR_FILE_INPUT_FUNCTION = `function () {
|
|
108
106
|
this.value = '';
|
|
109
|
-
this
|
|
110
|
-
this
|
|
107
|
+
(${FIRE_EVENT_JS})(this, 'input');
|
|
108
|
+
(${FIRE_EVENT_JS})(this, 'change');
|
|
111
109
|
}`;
|
|
112
110
|
/** Object group for remote objects created while selecting files. */
|
|
113
111
|
const UPLOAD_OBJECT_GROUP = 'bdg-upload';
|
|
@@ -441,9 +439,7 @@ export async function clickElement(cdp, selector, options = {}) {
|
|
|
441
439
|
const cdpResponse = response;
|
|
442
440
|
if (cdpResponse.exceptionDetails) {
|
|
443
441
|
throwIfInvalidSelector(cdpResponse.exceptionDetails, selector);
|
|
444
|
-
|
|
445
|
-
const err = clickableElementNotFoundError(selector);
|
|
446
|
-
throw new CommandError(errorMessage, { suggestion: err.suggestion }, EXIT_CODES.SOFTWARE_ERROR);
|
|
442
|
+
throw new ActionScriptError(POINTER_ACTION_NOUN[action], exceptionSummary(cdpResponse.exceptionDetails), selector);
|
|
447
443
|
}
|
|
448
444
|
if (cdpResponse.result?.value && isClickResult(cdpResponse.result.value)) {
|
|
449
445
|
return await performClick(cdp, cdpResponse.result.value, action, options.strict === true);
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { CommandError } from '../../../errors/index.js';
|
|
7
7
|
import { VIA_LABEL_SUFFIX, keyPressFailedError, operationFailedError, unknownKeyError, } from '../../../errors/messages.js';
|
|
8
|
-
import { ELEMENT_IDENTITY_JS } from '../elementInfo.js';
|
|
8
|
+
import { DISABLED_CAUSE_JS, ELEMENT_IDENTITY_JS } from '../elementInfo.js';
|
|
9
9
|
import { throwIfInvalidSelector, withMultipleMatchesWarning, } from './shared.js';
|
|
10
10
|
import { describeModifiers, getKeyDefinition, impliesShift, MODIFIER_FLAGS, parseModifiers, similarKeyNames, shortcutCommands, } from '../keyMapping.js';
|
|
11
11
|
import { FIND_ELEMENTS_JS, LABEL_CONTROL_JS, selectorArgsJS } from '../targetNode.js';
|
|
@@ -60,7 +60,7 @@ const FOCUS_ELEMENT_SCRIPT = `
|
|
|
60
60
|
return {
|
|
61
61
|
success: false,
|
|
62
62
|
reason: 'not-focusable',
|
|
63
|
-
error: 'Element <' + el.tagName.toLowerCase() + '> cannot receive keyboard focus' + (
|
|
63
|
+
error: 'Element <' + el.tagName.toLowerCase() + '> cannot receive keyboard focus' + ((${DISABLED_CAUSE_JS})(el) !== null ? ' (it is disabled)' : '')
|
|
64
64
|
};
|
|
65
65
|
}
|
|
66
66
|
}
|
|
@@ -1,13 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Shared internal helpers for form-fill operations: script
|
|
3
|
-
*
|
|
2
|
+
* Shared internal helpers for form-fill operations: script errors and JS
|
|
3
|
+
* string escaping.
|
|
4
4
|
*/
|
|
5
5
|
import type { Protocol } from '../../../connection/typed-cdp.js';
|
|
6
|
+
import { CommandError } from '../../../errors/index.js';
|
|
6
7
|
import type { FillResult } from '../../../ipc/protocol/domTypes.js';
|
|
7
|
-
/**
|
|
8
|
-
* Escape a CSS selector for embedding in a single-quoted JS string.
|
|
9
|
-
*/
|
|
10
|
-
export declare function escapeSelectorForJS(selector: string): string;
|
|
11
8
|
/**
|
|
12
9
|
* Add a warning when the selector matched several elements and no index was
|
|
13
10
|
* given, so the caller knows which one was used. Keeps an existing warning.
|
|
@@ -34,11 +31,20 @@ export declare function withValueMismatchWarning(result: FillResult): FillResult
|
|
|
34
31
|
*/
|
|
35
32
|
export declare function escapeValueForJS(value: string): string;
|
|
36
33
|
/**
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
34
|
+
* An action's page script threw. The action that ran it reports what was
|
|
35
|
+
* thrown; when the page replaced built-ins the script uses,
|
|
36
|
+
* `onScriptTarget` makes that the error instead.
|
|
40
37
|
*/
|
|
41
|
-
export declare
|
|
38
|
+
export declare class ActionScriptError extends CommandError {
|
|
39
|
+
readonly action: string;
|
|
40
|
+
readonly exception: string;
|
|
41
|
+
/**
|
|
42
|
+
* @param action - The action, e.g. `fill`
|
|
43
|
+
* @param exception - What the script threw, in one line
|
|
44
|
+
* @param selector - Selector the action was for
|
|
45
|
+
*/
|
|
46
|
+
constructor(action: string, exception: string, selector: string);
|
|
47
|
+
}
|
|
42
48
|
/**
|
|
43
49
|
* What a page script threw, in one line (`TypeError: Cannot read …` rather
|
|
44
50
|
* than CDP's bare "Uncaught").
|
|
@@ -1,18 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Shared internal helpers for form-fill operations: script
|
|
3
|
-
*
|
|
2
|
+
* Shared internal helpers for form-fill operations: script errors and JS
|
|
3
|
+
* string escaping.
|
|
4
4
|
*/
|
|
5
5
|
import { CommandError } from '../../../errors/index.js';
|
|
6
|
-
import { invalidSelectorError } from '../../../errors/messages.js';
|
|
6
|
+
import { actionScriptFailedError, invalidSelectorError } from '../../../errors/messages.js';
|
|
7
7
|
import { multipleMatchesWarning, valueMismatchWarning } from '../../../ui/messages/commands.js';
|
|
8
8
|
import { EXIT_CODES } from '../../../utils/exitCodes.js';
|
|
9
|
-
import { detectSelectorQuoteDamage } from '../../../utils/shellDetection.js';
|
|
10
|
-
/**
|
|
11
|
-
* Escape a CSS selector for embedding in a single-quoted JS string.
|
|
12
|
-
*/
|
|
13
|
-
export function escapeSelectorForJS(selector) {
|
|
14
|
-
return JSON.stringify(selector).slice(1, -1).replace(/'/g, "\\'");
|
|
15
|
-
}
|
|
16
9
|
/**
|
|
17
10
|
* Add a warning when the selector matched several elements and no index was
|
|
18
11
|
* given, so the caller knows which one was used. Keeps an existing warning.
|
|
@@ -49,50 +42,24 @@ export function escapeValueForJS(value) {
|
|
|
49
42
|
return JSON.stringify(value).slice(1, -1).replace(/'/g, "\\'");
|
|
50
43
|
}
|
|
51
44
|
/**
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
45
|
+
* An action's page script threw. The action that ran it reports what was
|
|
46
|
+
* thrown; when the page replaced built-ins the script uses,
|
|
47
|
+
* `onScriptTarget` makes that the error instead.
|
|
55
48
|
*/
|
|
56
|
-
export
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
lines.push('');
|
|
70
|
-
lines.push('Shell quote damage detected in selector:');
|
|
71
|
-
if (selectorCheck.details) {
|
|
72
|
-
lines.push(` ${selectorCheck.details}`);
|
|
73
|
-
}
|
|
74
|
-
lines.push('');
|
|
75
|
-
lines.push('Try using the two-step pattern:');
|
|
76
|
-
lines.push(` 1. bdg dom query '${selector}'`);
|
|
77
|
-
lines.push(` 2. bdg dom ${operationType} 0${operationType === 'fill' ? ' "value"' : ''}`);
|
|
78
|
-
return lines.join('\n');
|
|
79
|
-
}
|
|
49
|
+
export class ActionScriptError extends CommandError {
|
|
50
|
+
action;
|
|
51
|
+
exception;
|
|
52
|
+
/**
|
|
53
|
+
* @param action - The action, e.g. `fill`
|
|
54
|
+
* @param exception - What the script threw, in one line
|
|
55
|
+
* @param selector - Selector the action was for
|
|
56
|
+
*/
|
|
57
|
+
constructor(action, exception, selector) {
|
|
58
|
+
const err = actionScriptFailedError(action, exception, selector);
|
|
59
|
+
super(err.message, { suggestion: err.suggestion }, EXIT_CODES.SOFTWARE_ERROR);
|
|
60
|
+
this.action = action;
|
|
61
|
+
this.exception = exception;
|
|
80
62
|
}
|
|
81
|
-
const troubleshootingSteps = operationType === 'fill'
|
|
82
|
-
? [
|
|
83
|
-
`1. Verify element exists: bdg dom query "${selector}"`,
|
|
84
|
-
'2. Check element is visible and not disabled',
|
|
85
|
-
`3. Try direct eval: bdg dom eval "document.querySelector('${escapeSelectorForJS(selector)}').value = 'your-value'"`,
|
|
86
|
-
]
|
|
87
|
-
: [
|
|
88
|
-
`1. Verify element exists: bdg dom query "${selector}"`,
|
|
89
|
-
'2. Check element is visible and clickable',
|
|
90
|
-
`3. Try direct eval: bdg dom eval "document.querySelector('${escapeSelectorForJS(selector)}').click()"`,
|
|
91
|
-
];
|
|
92
|
-
lines.push('');
|
|
93
|
-
lines.push('Troubleshooting:');
|
|
94
|
-
lines.push(` ${troubleshootingSteps.join('\n ')}`);
|
|
95
|
-
return lines.join('\n');
|
|
96
63
|
}
|
|
97
64
|
/**
|
|
98
65
|
* What a page script threw, in one line (`TypeError: Cannot read …` rather
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { CDPTimeoutError } from '../../connection/errors.js';
|
|
5
5
|
import { trackInFlightRequests } from '../../connection/inFlightRequests.js';
|
|
6
6
|
import { submitNetworkBusyWarning, submitTimeoutError } from '../../errors/messages.js';
|
|
7
|
-
import { ELEMENT_IDENTITY_JS } from './elementInfo.js';
|
|
7
|
+
import { DISABLED_CAUSE_JS, ELEMENT_IDENTITY_JS } from './elementInfo.js';
|
|
8
8
|
import { throwIfInvalidSelector } from './formFillHelpers/shared.js';
|
|
9
9
|
import { FIND_ELEMENTS_JS, selectorArgsJS } from './targetNode.js';
|
|
10
10
|
import { summarizePendingRequests } from '../page/loadingState.js';
|
|
@@ -49,7 +49,8 @@ const PREPARE_SUBMIT_SCRIPT = `
|
|
|
49
49
|
f.matches('button:not([type]), button[type=submit], input[type=submit], input[type=image]')
|
|
50
50
|
)
|
|
51
51
|
: [];
|
|
52
|
-
|
|
52
|
+
const disabled = (f) => (${DISABLED_CAUSE_JS})(f) !== null;
|
|
53
|
+
if (submitters.length > 0 && submitters.every(disabled)) {
|
|
53
54
|
// A user could not submit: Enter does nothing while the default button is disabled
|
|
54
55
|
return { action: 'fail', reason: 'disabled', error: 'The form\\'s submit button is disabled' };
|
|
55
56
|
}
|
|
@@ -62,7 +63,7 @@ const PREPARE_SUBMIT_SCRIPT = `
|
|
|
62
63
|
if (isForm) {
|
|
63
64
|
// Like pressing Enter: the form's default button is the submitter, so its
|
|
64
65
|
// name=value is sent too
|
|
65
|
-
const submitter = submitters.find((f) => !f
|
|
66
|
+
const submitter = submitters.find((f) => !disabled(f));
|
|
66
67
|
const element = (${ELEMENT_IDENTITY_JS})(submitter || el);
|
|
67
68
|
if (submitter) el.requestSubmit(submitter);
|
|
68
69
|
else el.requestSubmit();
|
|
@@ -127,6 +127,7 @@ export function placeInOwnerFrame(raw, owner, mapping) {
|
|
|
127
127
|
clipper: inner.clipper ?? (inside(rect, frameView) ? outer.clipper : frame.element),
|
|
128
128
|
hidden: inner.hidden ?? (outer.hidden ? 'inside a hidden iframe' : null),
|
|
129
129
|
invisible: inner.invisible ?? outer.invisible,
|
|
130
|
+
masked: inner.invisible ? null : (inner.masked ?? outer.masked ?? null),
|
|
130
131
|
fixed: outer.fixed,
|
|
131
132
|
sticky: outer.sticky ?? false,
|
|
132
133
|
pageScroll: outer.pageScroll,
|
|
@@ -17,13 +17,14 @@ import { operationFailedError, unknownCssPropertyError, whyAllPropertyError, } f
|
|
|
17
17
|
import { throwIfInvalidSelector } from './formFillHelpers/shared.js';
|
|
18
18
|
import { selectedProps } from './inspectAllStyles.js';
|
|
19
19
|
import { buildCascadeFields } from './inspectCascadeModel.js';
|
|
20
|
+
import { explainUnsetVariables } from './inspectHints.js';
|
|
20
21
|
import { buildInspectResult } from './inspectModel.js';
|
|
21
22
|
import { matchedStyles, sourceLabel, trackStyleSheets } from './inspectRules.js';
|
|
22
|
-
import { explainUnsetVariables } from './inspectHints.js';
|
|
23
23
|
import { INSPECT_PAGE_JS, RELATED_NODE_JS, VARIABLE_SETTERS_JS, } from './inspectScripts.js';
|
|
24
24
|
import { DEFAULT_TREE_DEPTH, DEFAULT_TREE_LIMIT } from './inspectTree.js';
|
|
25
25
|
import { inspectLayout } from './layout.js';
|
|
26
26
|
import { DEEP_QUERY_JS, missingElementError, selectorArgsJS } from './targetNode.js';
|
|
27
|
+
import { evaluateInBdgWorld, resolveNodeInBdgWorld } from '../page/bdgWorld.js';
|
|
27
28
|
import { createLogger } from '../../ui/logging/index.js';
|
|
28
29
|
import { getErrorMessage } from '../../utils/errors.js';
|
|
29
30
|
import { EXIT_CODES } from '../../utils/exitCodes.js';
|
|
@@ -248,9 +249,7 @@ function pickedHow(params, count, index) {
|
|
|
248
249
|
* @returns Remote object id, or undefined when the node left the page
|
|
249
250
|
*/
|
|
250
251
|
async function resolveCachedNode(cdp, backendNodeId, objectGroup) {
|
|
251
|
-
const resolved = (await cdp
|
|
252
|
-
.send('DOM.resolveNode', { backendNodeId, objectGroup })
|
|
253
|
-
.catch((error) => {
|
|
252
|
+
const resolved = (await resolveNodeInBdgWorld(cdp, { backendNodeId, objectGroup }).catch((error) => {
|
|
254
253
|
log.debug(`Node ${backendNodeId} not resolved: ${getErrorMessage(error)}`);
|
|
255
254
|
return {};
|
|
256
255
|
}));
|
|
@@ -270,10 +269,10 @@ async function resolveCachedNode(cdp, backendNodeId, objectGroup) {
|
|
|
270
269
|
* @throws CommandError (81) invalid selector, (91) page script failure
|
|
271
270
|
*/
|
|
272
271
|
async function querySelector(cdp, selector, objectGroup) {
|
|
273
|
-
const response =
|
|
272
|
+
const response = await evaluateInBdgWorld(cdp, {
|
|
274
273
|
expression: `(${DEEP_QUERY_JS})(${selectorArgsJS(selector)})`,
|
|
275
274
|
objectGroup,
|
|
276
|
-
})
|
|
275
|
+
});
|
|
277
276
|
if (response.exceptionDetails || !response.result.objectId) {
|
|
278
277
|
if (response.exceptionDetails)
|
|
279
278
|
throwIfInvalidSelector(response.exceptionDetails, selector);
|
|
@@ -170,6 +170,7 @@ const PREFIXED_KEPT = new Set([
|
|
|
170
170
|
'-webkit-line-clamp',
|
|
171
171
|
'-webkit-text-stroke-width',
|
|
172
172
|
'-webkit-text-security',
|
|
173
|
+
'-webkit-text-fill-color',
|
|
173
174
|
]);
|
|
174
175
|
/** SVG paint and geometry properties (noise on HTML elements) */
|
|
175
176
|
const SVG_ONLY = /^(fill|fill-opacity|fill-rule|stroke|stroke-.*|stop-.*|flood-.*|lighting-color|marker-.*|clip-rule|color-interpolation.*|color-rendering|shape-rendering|text-anchor|dominant-baseline|alignment-baseline|baseline-shift|vector-effect|buffered-rendering|paint-order|mask-type)$/;
|