browser-debugger-cli 0.11.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 +143 -79
- 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/css.d.ts +13 -0
- package/dist/commands/css.js +53 -0
- 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/audit.d.ts +14 -0
- package/dist/commands/dom/audit.js +87 -0
- 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 +42 -11
- 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/keyAttributes.d.ts +3 -2
- package/dist/commands/dom/helpers/keyAttributes.js +6 -4
- 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.d.ts +1 -0
- package/dist/commands/dom/helpers/screenshot.js +169 -49
- package/dist/commands/dom/index.js +7 -2
- package/dist/commands/dom/query.d.ts +19 -2
- package/dist/commands/dom/query.js +37 -6
- package/dist/commands/dom/screenshot.js +12 -7
- package/dist/commands/dom/semanticUtils.d.ts +3 -2
- package/dist/commands/dom/semanticUtils.js +40 -9
- package/dist/commands/dom/wait.js +5 -3
- package/dist/commands/helpJson.d.ts +82 -19
- package/dist/commands/helpJson.js +112 -41
- 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 +53 -16
- package/dist/commands/page.js +7 -4
- package/dist/commands/peek.d.ts +7 -0
- package/dist/commands/peek.js +65 -23
- 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 +9 -2
- package/dist/commands/shared/outputFile.js +6 -1
- package/dist/commands/start.d.ts +20 -5
- package/dist/commands/start.js +84 -23
- package/dist/commands/stop.d.ts +11 -0
- package/dist/commands/stop.js +24 -1
- package/dist/commands/tail.d.ts +7 -1
- package/dist/commands/tail.js +13 -62
- package/dist/commands.js +2 -0
- 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 +36 -14
- package/dist/daemon/session/interactions.d.ts +2 -1
- package/dist/daemon/session/interactions.js +13 -1
- package/dist/daemon/session/plugins.js +19 -53
- package/dist/daemon/session/teardown.js +1 -1
- package/dist/daemon.js +9234 -7222
- package/dist/errors/messages.d.ts +88 -15
- package/dist/errors/messages.js +177 -27
- package/dist/index.js +19322 -13961
- package/dist/ipc/client.d.ts +22 -2
- package/dist/ipc/client.js +34 -5
- package/dist/ipc/protocol/auditTypes.d.ts +135 -0
- package/dist/ipc/protocol/auditTypes.js +6 -0
- package/dist/ipc/protocol/commands.d.ts +35 -0
- package/dist/ipc/protocol/commands.js +2 -0
- package/dist/ipc/protocol/domTypes.d.ts +16 -0
- package/dist/ipc/protocol/inspectTypes.d.ts +73 -8
- package/dist/ipc/session/types.d.ts +2 -0
- package/dist/runtime/css/search.d.ts +39 -0
- package/dist/runtime/css/search.js +122 -0
- package/dist/runtime/dom/actionEffects.d.ts +9 -2
- package/dist/runtime/dom/actionEffects.js +30 -14
- package/dist/runtime/dom/audit.d.ts +19 -0
- package/dist/runtime/dom/audit.js +37 -0
- package/dist/runtime/dom/auditModel.d.ts +45 -0
- package/dist/runtime/dom/auditModel.js +220 -0
- package/dist/runtime/dom/auditScripts.d.ts +113 -0
- package/dist/runtime/dom/auditScripts.js +148 -0
- package/dist/runtime/dom/elementGeometry.d.ts +16 -3
- package/dist/runtime/dom/elementGeometry.js +49 -10
- package/dist/runtime/dom/elementInfo.d.ts +74 -17
- package/dist/runtime/dom/elementInfo.js +187 -34
- 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 +8 -12
- 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.d.ts +7 -0
- package/dist/runtime/dom/inspect.js +92 -28
- package/dist/runtime/dom/inspectAllStyles.d.ts +16 -4
- package/dist/runtime/dom/inspectAllStyles.js +90 -7
- package/dist/runtime/dom/inspectCascade.d.ts +19 -2
- package/dist/runtime/dom/inspectCascade.js +214 -44
- package/dist/runtime/dom/inspectCascadeModel.d.ts +8 -0
- package/dist/runtime/dom/inspectCascadeModel.js +108 -34
- package/dist/runtime/dom/inspectHints.d.ts +26 -3
- package/dist/runtime/dom/inspectHints.js +125 -9
- package/dist/runtime/dom/inspectModel.d.ts +5 -1
- package/dist/runtime/dom/inspectModel.js +37 -10
- package/dist/runtime/dom/inspectPaintModel.d.ts +50 -22
- package/dist/runtime/dom/inspectPaintModel.js +182 -68
- package/dist/runtime/dom/inspectRules.d.ts +19 -0
- package/dist/runtime/dom/inspectRules.js +21 -5
- package/dist/runtime/dom/inspectScripts.d.ts +112 -12
- package/dist/runtime/dom/inspectScripts.js +357 -32
- package/dist/runtime/dom/inspectTree.js +10 -2
- package/dist/runtime/dom/inspectWhyModel.d.ts +2 -1
- package/dist/runtime/dom/inspectWhyModel.js +52 -10
- package/dist/runtime/dom/layout.js +40 -16
- package/dist/runtime/dom/reactEventHelpers.d.ts +21 -4
- package/dist/runtime/dom/reactEventHelpers.js +90 -36
- 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/emulation.d.ts +13 -4
- package/dist/runtime/page/emulation.js +69 -4
- package/dist/runtime/page/replacedBuiltins.d.ts +28 -0
- package/dist/runtime/page/replacedBuiltins.js +136 -0
- package/dist/runtime/page/userAgent.d.ts +17 -0
- package/dist/runtime/page/userAgent.js +57 -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 +20 -0
- package/dist/ui/formatters/audit.d.ts +19 -0
- package/dist/ui/formatters/audit.js +115 -0
- 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 +2 -2
- package/dist/ui/formatters/dom.js +10 -8
- package/dist/ui/formatters/helpFormatters.js +1 -1
- package/dist/ui/formatters/inspect.js +50 -17
- 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/formatters/status.js +1 -1
- package/dist/ui/logging/logger.d.ts +1 -1
- package/dist/ui/messages/commands.d.ts +168 -11
- package/dist/ui/messages/commands.js +245 -18
- package/dist/ui/messages/consoleMessages.d.ts +24 -0
- package/dist/ui/messages/consoleMessages.js +32 -0
- package/dist/ui/messages/preview.d.ts +12 -0
- package/dist/ui/messages/preview.js +18 -2
- package/dist/ui/messages/session.d.ts +13 -2
- package/dist/ui/messages/session.js +22 -3
- package/dist/utils/cssValues.js +36 -4
- package/dist/utils/decisionTrees.js +0 -5
- 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/dist/utils/suggestions.d.ts +4 -2
- package/dist/utils/suggestions.js +7 -5
- package/dist/utils/taskMappings.js +1 -1
- package/package.json +3 -2
|
@@ -3,6 +3,31 @@
|
|
|
3
3
|
* `dom layout`, DOM context of console messages): their text as a user sees
|
|
4
4
|
* it and where they live (iframes, shadow roots).
|
|
5
5
|
*/
|
|
6
|
+
/**
|
|
7
|
+
* A class that is a fragment of a declaration-like class attribute
|
|
8
|
+
* (`class="brush: html"` has the class `brush:`), left out of labels.
|
|
9
|
+
*/
|
|
10
|
+
const CLASS_FRAGMENT = /[:;,]$/;
|
|
11
|
+
/**
|
|
12
|
+
* Whether a class name is shown in element labels: not empty and not a
|
|
13
|
+
* fragment of a declaration-like class attribute ({@link CLASS_FRAGMENT}).
|
|
14
|
+
*
|
|
15
|
+
* @param name - Class name
|
|
16
|
+
* @returns True to show it
|
|
17
|
+
*/
|
|
18
|
+
export function isLabelClass(name) {
|
|
19
|
+
return name !== '' && !CLASS_FRAGMENT.test(name);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Page-side `(node) => string[]`: the element's classes shown in labels
|
|
23
|
+
* ({@link isLabelClass}), read without array helpers a page may replace
|
|
24
|
+
*/
|
|
25
|
+
export const LABEL_CLASSES_JS = `(node) => {
|
|
26
|
+
const shown = [];
|
|
27
|
+
const list = node.classList || [];
|
|
28
|
+
for (let i = 0; i < list.length; i++) if (!${CLASS_FRAGMENT}.test(list[i])) shown[shown.length] = list[i];
|
|
29
|
+
return shown;
|
|
30
|
+
}`;
|
|
6
31
|
/** Length of the text preview shown for elements. */
|
|
7
32
|
const PREVIEW_LENGTH = 80;
|
|
8
33
|
/** Length of the text `dom get` shows for an element. */
|
|
@@ -36,27 +61,88 @@ export const WITHOUT_DECORATIONS_JS = `(el, text) => {
|
|
|
36
61
|
}
|
|
37
62
|
return result;
|
|
38
63
|
}`;
|
|
64
|
+
/**
|
|
65
|
+
* Page-side check whether `innerText` misses part of what an element shows:
|
|
66
|
+
* it is a `<slot>` or hosts an open shadow root, or one of its descendants
|
|
67
|
+
* is or does. `innerText` follows neither shadow roots nor slots.
|
|
68
|
+
*/
|
|
69
|
+
export const COMPOSED_JS = `(node) => {
|
|
70
|
+
const composes = (n) => Boolean(n.shadowRoot) || n.localName === 'slot';
|
|
71
|
+
if (composes(node)) return true;
|
|
72
|
+
const walker = node.ownerDocument.createTreeWalker(node, NodeFilter.SHOW_ELEMENT);
|
|
73
|
+
while (walker.nextNode()) if (composes(walker.currentNode)) return true;
|
|
74
|
+
return false;
|
|
75
|
+
}`;
|
|
76
|
+
/**
|
|
77
|
+
* Page-side text of an element as the flat tree renders it, for elements
|
|
78
|
+
* `innerText` cannot read ({@link COMPOSED_JS}): a shadow host is read from
|
|
79
|
+
* its open shadow root (its own labels and fallback content; light children
|
|
80
|
+
* only where a slot shows them), and each slot is replaced by what it
|
|
81
|
+
* shows, its assigned nodes flattened through nested slots (or its fallback
|
|
82
|
+
* content when nothing is assigned). Parts without slots or shadow roots
|
|
83
|
+
* are read with `innerText`; elements that are not rendered and text under
|
|
84
|
+
* `visibility: hidden` are left out, elements that are not inline are set
|
|
85
|
+
* apart by line breaks, as is a `<br>`. Fields and editable regions inside
|
|
86
|
+
* it (inputs, textareas, selects, a contenteditable editor) are skipped, so
|
|
87
|
+
* what a user typed is never read; raw text keeps `innerText`'s collapsed
|
|
88
|
+
* whitespace. The text is cut at `limit` characters, and a
|
|
89
|
+
* part whose text alone passes the limit is read from its text nodes
|
|
90
|
+
* (`textContent`) instead of `innerText`, which would lay out all of it.
|
|
91
|
+
*/
|
|
92
|
+
export const FLAT_TEXT_JS = `(el, limit) => {
|
|
93
|
+
const composed = ${COMPOSED_JS};
|
|
94
|
+
const view = el.ownerDocument.defaultView;
|
|
95
|
+
let text = '';
|
|
96
|
+
const nodesOf = (node) =>
|
|
97
|
+
node.localName === 'slot' ? node.assignedNodes({ flatten: true }) : Array.from((node.shadowRoot || node).childNodes);
|
|
98
|
+
const read = (node, visible) => {
|
|
99
|
+
if (text.length >= limit) return;
|
|
100
|
+
if (node.nodeType === 3 && visible) text += node.data.replace(/\\s+/g, ' ');
|
|
101
|
+
if (node.nodeType !== 1) return;
|
|
102
|
+
if (/^(input|textarea|select)$/.test(node.localName) || node.isContentEditable) return;
|
|
103
|
+
const style = view.getComputedStyle(node);
|
|
104
|
+
if (node.checkVisibility && !node.checkVisibility() && style.display !== 'contents') return;
|
|
105
|
+
if (node.localName === 'br') text += '\\n';
|
|
106
|
+
const apart = style.display.startsWith('inline') || style.display === 'contents' ? '' : '\\n';
|
|
107
|
+
text += apart;
|
|
108
|
+
const own = node.textContent || '';
|
|
109
|
+
if (composed(node)) nodesOf(node).forEach((child) => read(child, style.visibility === 'visible'));
|
|
110
|
+
else if (own.length > limit - text.length) text += own.slice(0, limit - text.length);
|
|
111
|
+
else text += typeof node.innerText === 'string' ? node.innerText : own;
|
|
112
|
+
text += apart;
|
|
113
|
+
};
|
|
114
|
+
const visible = view.getComputedStyle(el).visibility === 'visible';
|
|
115
|
+
nodesOf(el).forEach((child) => read(child, visible));
|
|
116
|
+
return text.slice(0, limit);
|
|
117
|
+
}`;
|
|
39
118
|
/**
|
|
40
119
|
* Page-side text of an element as a user sees it: `innerText` for a rendered
|
|
41
120
|
* element (CSS-hidden parts left out, inline elements not split apart), none
|
|
42
121
|
* for an element that is not rendered, `textContent` for SVG and other
|
|
43
122
|
* elements without `innerText` and for `display: contents` wrappers (no box
|
|
44
123
|
* of their own, but their children are shown), and the label of an
|
|
45
|
-
* `<option>` (which its `<select>` renders).
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
124
|
+
* `<option>` (which its `<select>` renders). A web component, a slot, and
|
|
125
|
+
* an element holding either are read through the flat tree
|
|
126
|
+
* ({@link FLAT_TEXT_JS}): the text a component renders from its shadow
|
|
127
|
+
* root, slotted content in place of its slots, blocks set apart. For large
|
|
128
|
+
* containers (more than 2000 characters of text) only the start is read,
|
|
129
|
+
* from the text nodes whose parent is rendered, so a preview never lays out
|
|
130
|
+
* a whole page's text, unless `full` is set. Decorations are left out
|
|
131
|
+
* ({@link WITHOUT_DECORATIONS_JS}).
|
|
49
132
|
*/
|
|
50
133
|
export const ELEMENT_TEXT_JS = `(el, full) => {
|
|
51
134
|
const withoutDecorations = ${WITHOUT_DECORATIONS_JS};
|
|
135
|
+
const composed = ${COMPOSED_JS};
|
|
136
|
+
const flatText = ${FLAT_TEXT_JS};
|
|
52
137
|
const all = el.textContent || '';
|
|
53
138
|
if (el.tagName === 'OPTION') return el.label;
|
|
54
139
|
if (typeof el.innerText !== 'string') return full ? all : all.slice(0, 2000);
|
|
55
140
|
const rendered = (node) => !node.checkVisibility || node.checkVisibility();
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
141
|
+
const shown = rendered(el);
|
|
142
|
+
const boxless = !shown && el.ownerDocument.defaultView.getComputedStyle(el).display === 'contents';
|
|
143
|
+
if (!shown && !boxless) return '';
|
|
144
|
+
if (composed(el)) return withoutDecorations(el, flatText(el, full ? Infinity : 2000));
|
|
145
|
+
if (boxless) return withoutDecorations(el, full ? all : all.slice(0, 2000));
|
|
60
146
|
if (full || all.length <= 2000) return withoutDecorations(el, el.innerText);
|
|
61
147
|
const walker = el.ownerDocument.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
|
62
148
|
let start = '';
|
|
@@ -132,12 +218,38 @@ export const ELEMENT_STATE_JS = `(el) => {
|
|
|
132
218
|
}
|
|
133
219
|
}`;
|
|
134
220
|
/**
|
|
135
|
-
* Page-side short description of an element: tag, id and up to two classes
|
|
136
|
-
* e.g. `button#save.primary.large`.
|
|
221
|
+
* Page-side short description of an element: tag, id and up to two classes
|
|
222
|
+
* ({@link LABEL_CLASSES_JS}), e.g. `button#save.primary.large`.
|
|
137
223
|
*/
|
|
138
|
-
export const ELEMENT_DESCRIPTION_JS = `(node) =>
|
|
139
|
-
|
|
140
|
-
|
|
224
|
+
export const ELEMENT_DESCRIPTION_JS = `(node) => {
|
|
225
|
+
const classes = (${LABEL_CLASSES_JS})(node);
|
|
226
|
+
return node.tagName.toLowerCase() + (node.id ? '#' + node.id : '') + (classes[0] ? '.' + classes[0] : '') + (classes[1] ? '.' + classes[1] : '');
|
|
227
|
+
}`;
|
|
228
|
+
/**
|
|
229
|
+
* Page-side reason an element is disabled, or null when it is not. A native
|
|
230
|
+
* control (`button`, `input`, `select`, `textarea`, `fieldset`, `optgroup`,
|
|
231
|
+
* `option`) is disabled by its `disabled attribute` or by being `inside a
|
|
232
|
+
* disabled <fieldset>` (not in its first `<legend>`), an `<option>` by
|
|
233
|
+
* being `inside a disabled <optgroup>`; any other element
|
|
234
|
+
* (e.g. a custom element) when its `disabled` property is set (empty
|
|
235
|
+
* reason). Reads the element's own attributes rather than
|
|
236
|
+
* `matches(':disabled')`, which pages replace.
|
|
237
|
+
*/
|
|
238
|
+
export const DISABLED_CAUSE_JS = `(el) => {
|
|
239
|
+
if (!/^(button|input|select|textarea|fieldset|optgroup|option)$/.test(el.localName)) return el.disabled ? '' : null;
|
|
240
|
+
if (el.hasAttribute('disabled')) return 'disabled attribute';
|
|
241
|
+
if (el.localName === 'option') {
|
|
242
|
+
const group = el.parentElement;
|
|
243
|
+
return group && group.localName === 'optgroup' && group.hasAttribute('disabled') ? 'inside a disabled <optgroup>' : null;
|
|
244
|
+
}
|
|
245
|
+
for (let node = el.parentElement; node; node = node.parentElement) {
|
|
246
|
+
if (node.localName !== 'fieldset' || !node.hasAttribute('disabled')) continue;
|
|
247
|
+
let legend = node.firstElementChild;
|
|
248
|
+
while (legend && legend.localName !== 'legend') legend = legend.nextElementSibling;
|
|
249
|
+
if (!legend || !legend.contains(el)) return 'inside a disabled <fieldset>';
|
|
250
|
+
}
|
|
251
|
+
return null;
|
|
252
|
+
}`;
|
|
141
253
|
/**
|
|
142
254
|
* Page-side position of an element among its parent's children with the
|
|
143
255
|
* same short description ({@link ELEMENT_DESCRIPTION_JS}), e.g. `(2nd of 3)`;
|
|
@@ -159,12 +271,15 @@ export const SIBLING_POSITION_JS = `(el) => {
|
|
|
159
271
|
* Page-side identity of an element an action hit, so the output says which
|
|
160
272
|
* one it was, as one string: its short description
|
|
161
273
|
* ({@link ELEMENT_DESCRIPTION_JS}) and visible text (button value for button
|
|
162
|
-
* inputs, decorations left out
|
|
163
|
-
*
|
|
274
|
+
* inputs, decorations left out; read through the flat tree for web
|
|
275
|
+
* components and slots, {@link FLAT_TEXT_JS}), e.g.
|
|
276
|
+
* `button#add.btn "Add to cart"`. A `<select>` is named by its label
|
|
277
|
+
* (`aria-labelledby` or `<label>`), aria-label or name, else by its selected
|
|
164
278
|
* option, e.g. `select.sort "Sort products"`. An element without visible
|
|
165
279
|
* text is described by itself first: by its position among same-looking
|
|
166
280
|
* siblings ({@link SIBLING_POSITION_JS}), e.g. `div.figure (2nd of 3)`, with
|
|
167
|
-
* its
|
|
281
|
+
* its name when it has one: aria-label, label, placeholder, title, or the
|
|
282
|
+
* alt text of an image (in it). Only an element
|
|
168
283
|
* without an id that is the only one of its kind is named by the nearest of
|
|
169
284
|
* three ancestors that has text, e.g.
|
|
170
285
|
* `input.toggle in div.view "Write report"` (rows of a list share their
|
|
@@ -172,7 +287,7 @@ export const SIBLING_POSITION_JS = `(el) => {
|
|
|
172
287
|
* of `<select>`s in it (read like `innerText`: CSS-hidden text is left out of
|
|
173
288
|
* a rendered ancestor; at most 500 text nodes; selects in shadow roots are
|
|
174
289
|
* not looked into), and never past an editable ancestor, whose text may be
|
|
175
|
-
* typed input; otherwise its
|
|
290
|
+
* typed input; otherwise its name is used. Texts
|
|
176
291
|
* are cut at 40 characters. Contenteditable elements count as controls: what
|
|
177
292
|
* was typed into them is never echoed (nor their ancestors' text).
|
|
178
293
|
*/
|
|
@@ -180,19 +295,36 @@ export const ELEMENT_IDENTITY_JS = `(el) => {
|
|
|
180
295
|
const describe = ${ELEMENT_DESCRIPTION_JS};
|
|
181
296
|
const siblingPosition = ${SIBLING_POSITION_JS};
|
|
182
297
|
const withoutDecorations = ${WITHOUT_DECORATIONS_JS};
|
|
298
|
+
const composed = ${COMPOSED_JS};
|
|
299
|
+
const flatText = ${FLAT_TEXT_JS};
|
|
183
300
|
const clean = (text) => (text || '').replace(/\\s+/g, ' ').trim();
|
|
184
301
|
const cut = (text) => {
|
|
185
302
|
const characters = Array.from(text);
|
|
186
303
|
return characters.length > 40 ? characters.slice(0, 40).join('') + '…' : text;
|
|
187
304
|
};
|
|
188
305
|
const isControl = (node) => /^(input|select|textarea)$/.test(node.localName) || node.isContentEditable;
|
|
189
|
-
const
|
|
190
|
-
|
|
306
|
+
const textOf = (node) =>
|
|
307
|
+
composed(node) ? flatText(node, 200) : typeof node.innerText === 'string' ? node.innerText : node.textContent;
|
|
308
|
+
const shownText = (node) => (isControl(node) ? '' : clean(withoutDecorations(node, textOf(node))));
|
|
191
309
|
const buttonValue = (node) => (node.localName === 'input' && /^(submit|button|reset)$/i.test(node.type) ? clean(node.value) : '');
|
|
310
|
+
const labelText = (node) => {
|
|
311
|
+
const ids = (node.getAttribute('aria-labelledby') || '').split(/\\s+/).filter(Boolean);
|
|
312
|
+
const root = node.getRootNode();
|
|
313
|
+
const labels = ids.length ? ids.map((id) => root.getElementById(id)) : Array.from(node.labels || []);
|
|
314
|
+
return clean(labels.filter(Boolean).map(textOf).join(' '));
|
|
315
|
+
};
|
|
316
|
+
const imageAlt = (node) => {
|
|
317
|
+
const image = node.localName === 'img' ? node : node.querySelector('img[alt]');
|
|
318
|
+
return image ? clean(image.getAttribute('alt')) : '';
|
|
319
|
+
};
|
|
192
320
|
const attributeText = (node) =>
|
|
193
|
-
clean(node.getAttribute('aria-label')) ||
|
|
321
|
+
clean(node.getAttribute('aria-label')) ||
|
|
322
|
+
labelText(node) ||
|
|
323
|
+
clean(node.getAttribute('placeholder')) ||
|
|
324
|
+
clean(node.getAttribute('title')) ||
|
|
325
|
+
imageAlt(node);
|
|
194
326
|
const selectName = (node) =>
|
|
195
|
-
|
|
327
|
+
labelText(node) ||
|
|
196
328
|
clean(node.getAttribute('aria-label')) ||
|
|
197
329
|
clean(node.getAttribute('name')) ||
|
|
198
330
|
clean(node.selectedOptions && node.selectedOptions[0] && node.selectedOptions[0].label);
|
|
@@ -225,21 +357,42 @@ export const ELEMENT_IDENTITY_JS = `(el) => {
|
|
|
225
357
|
}`;
|
|
226
358
|
/**
|
|
227
359
|
* Page-side location of an element: the iframes it is in (outermost first)
|
|
228
|
-
* and the shadow root holding it,
|
|
229
|
-
*
|
|
360
|
+
* and the shadow root holding it, with the host's first class and the text
|
|
361
|
+
* a user sees of it ({@link ELEMENT_TEXT_JS}, cut at 30 characters with
|
|
362
|
+
* `…`), e.g. `iframe#pay > shadow root of <sl-button.primary "Save">`;
|
|
363
|
+
* empty for the main document. Each host's text is read once per script
|
|
364
|
+
* run, as many matches share a host.
|
|
230
365
|
*/
|
|
231
|
-
export const ELEMENT_CONTEXT_JS = `(
|
|
366
|
+
export const ELEMENT_CONTEXT_JS = `(() => {
|
|
367
|
+
const textOf = ${ELEMENT_TEXT_JS};
|
|
368
|
+
const hostTexts = new WeakMap();
|
|
369
|
+
const hostText = (host) => {
|
|
370
|
+
if (!hostTexts.has(host)) {
|
|
371
|
+
const characters = Array.from(String(textOf(host, false)).replace(/\\s+/g, ' ').trim());
|
|
372
|
+
hostTexts.set(host, characters.length > 30 ? characters.slice(0, 30).join('') + '…' : characters.join(''));
|
|
373
|
+
}
|
|
374
|
+
return hostTexts.get(host);
|
|
375
|
+
};
|
|
232
376
|
const describe = (node) => node.tagName.toLowerCase() + (node.id ? '#' + node.id : '');
|
|
233
|
-
const
|
|
234
|
-
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
377
|
+
const labelClasses = ${LABEL_CLASSES_JS};
|
|
378
|
+
return (el) => {
|
|
379
|
+
const parts = [];
|
|
380
|
+
for (let doc = el.ownerDocument; doc && doc.defaultView && doc.defaultView.frameElement; ) {
|
|
381
|
+
const frame = doc.defaultView.frameElement;
|
|
382
|
+
parts.unshift(describe(frame));
|
|
383
|
+
doc = frame.ownerDocument;
|
|
384
|
+
}
|
|
385
|
+
const root = el.getRootNode();
|
|
386
|
+
if (root.host) {
|
|
387
|
+
const host = root.host;
|
|
388
|
+
const firstClass = labelClasses(host)[0];
|
|
389
|
+
const label = describe(host) + (!host.id && firstClass ? '.' + firstClass : '');
|
|
390
|
+
const text = hostText(host);
|
|
391
|
+
parts.push('shadow root of <' + label + (text ? ' "' + text + '"' : '') + '>');
|
|
392
|
+
}
|
|
393
|
+
return parts.join(' > ');
|
|
394
|
+
};
|
|
395
|
+
})()`;
|
|
243
396
|
/**
|
|
244
397
|
* Short text preview of an element's text: whitespace collapsed, cut on a
|
|
245
398
|
* whole character (an emoji is never split, which would make JSON invalid).
|
|
@@ -11,6 +11,13 @@ import { type CDPSender } from '../../telemetry/objectExpander.js';
|
|
|
11
11
|
* @throws Error When script execution throws exception or returns invalid response
|
|
12
12
|
*/
|
|
13
13
|
export declare function executeScript(cdp: CDPSender, script: string, options?: Omit<Protocol.Runtime.EvaluateRequest, 'expression'>): Promise<Protocol.Runtime.EvaluateResponse>;
|
|
14
|
+
/**
|
|
15
|
+
* Message of an exception, including thrown non-Error values (`throw "x"`).
|
|
16
|
+
*
|
|
17
|
+
* @param details - Exception details
|
|
18
|
+
* @returns Readable message
|
|
19
|
+
*/
|
|
20
|
+
export declare function describeException(details: Protocol.Runtime.ExceptionDetails): string;
|
|
14
21
|
/** Result of `bdg dom eval`: a JSON-safe value plus its JavaScript type. */
|
|
15
22
|
export interface EvalResult {
|
|
16
23
|
/** JSON value, or a readable description for values JSON cannot represent */
|
|
@@ -19,6 +26,8 @@ export interface EvalResult {
|
|
|
19
26
|
type: string;
|
|
20
27
|
/** Object subtype (`node`, `date`, `map`, `array`, ...) */
|
|
21
28
|
subtype?: string;
|
|
29
|
+
/** Set when the page replaced built-ins bdg's copy of the result uses, so the browser copied it */
|
|
30
|
+
warning?: string;
|
|
22
31
|
}
|
|
23
32
|
/**
|
|
24
33
|
* Page-side copy of an object as JSON, keeping what JSON would lose inside
|
|
@@ -28,9 +37,10 @@ export interface EvalResult {
|
|
|
28
37
|
* `12n`, functions `function name()`, and cycles `[Circular]` (an object
|
|
29
38
|
* shared by two properties is copied twice). Works for objects of iframes
|
|
30
39
|
* (other realms); lists and objects are cut after 1000 entries, and a
|
|
31
|
-
* throwing getter becomes `[Error: …]`.
|
|
40
|
+
* throwing getter becomes `[Error: …]`. It uses the page's built-ins
|
|
41
|
+
* ({@link COPY_BUILTINS}), so it only runs when the page left them alone.
|
|
32
42
|
*/
|
|
33
|
-
export declare const JSON_SAFE_COPY_FUNCTION = "function () {\n const MAX_ITEMS = 1000;\n const ancestors = new Set();\n const kind = (value) => Object.prototype.toString.call(value).slice(8, -1);\n const isNode = (value) => typeof value.nodeType === 'number' && typeof value.nodeName === 'string';\n const describeNode = (node) => {\n if (node.nodeType !== 1) return node.nodeName.toLowerCase();\n return node.tagName.toLowerCase() + (node.id ? '#' + node.id : '') +\n (node.classList && node.classList.length ? '.' + Array.from(node.classList).join('.') : '');\n };\n const items = (list, next) => {\n const result = [];\n let count = 0;\n for (const item of list) {\n if (count++ === MAX_ITEMS) { result.push('\u2026'); break; }\n result.push(next(item));\n }\n return result;\n };\n const leaf = (value) => {\n if (value === undefined) return null;\n if (typeof value === 'number') {\n if (Number.isNaN(value) || !Number.isFinite(value)) return String(value);\n return Object.is(value, -0) ? '-0' : value;\n }\n if (typeof value === 'bigint') return value + 'n';\n if (typeof value === 'symbol') return value.toString();\n if (typeof value === 'function') return 'function ' + (value.name || '(anonymous)') + '()';\n return value;\n };\n const copy = (value, depth) => {\n if (value === null || typeof value !== 'object') return leaf(value);\n if (ancestors.has(value)) return '[Circular]';\n if (depth > 20) return '[\u2026]';\n const next = (item) => copy(item, depth + 1);\n const type = kind(value);\n if (isNode(value)) return describeNode(value);\n if (value.window === value) return 'Window';\n if (type === 'Date') return isNaN(value) ? 'Invalid Date' : value.toISOString();\n if (type === 'RegExp') return String(value);\n if (type === 'Error' || value instanceof Error) return value.name + ': ' + value.message;\n ancestors.add(value);\n try {\n if (type === 'Map') return items(value, ([k, v]) => [next(k), next(v)]);\n if (type === 'Set' || type === 'NodeList' || type === 'HTMLCollection') return items(value, next);\n if (ArrayBuffer.isView(value) && type !== 'DataView') return items(value, next);\n if (Array.isArray(value)) return items(value, next);\n const result = {};\n const keys = Object.keys(value);\n for (const key of keys.slice(0, MAX_ITEMS)) {\n try { result[key] = next(value[key]); } catch (e) { result[key] = '[Error: ' + (e && e.message) + ']'; }\n }\n if (keys.length > MAX_ITEMS) result['\u2026'] = (keys.length - MAX_ITEMS) + ' more keys';\n return result;\n } finally {\n ancestors.delete(value);\n }\n };\n return copy(this, 0);\n}";
|
|
43
|
+
export declare const JSON_SAFE_COPY_FUNCTION = "function () {\n const MAX_ITEMS = 1000;\n const ancestors = new Set();\n const kind = (value) => Object.prototype.toString.call(value).slice(8, -1);\n const isNode = (value) => typeof value.nodeType === 'number' && typeof value.nodeName === 'string';\n const describeNode = (node) => {\n if (node.nodeType !== 1) return node.nodeName.toLowerCase();\n return node.tagName.toLowerCase() + (node.id ? '#' + node.id : '') +\n (node.classList && node.classList.length ? '.' + Array.from(node.classList).join('.') : '');\n };\n const items = (list, next) => {\n const result = [];\n if (typeof list.length === 'number') {\n for (let i = 0; i < list.length; i++) {\n if (i === MAX_ITEMS) { result.push('\u2026'); break; }\n result.push(next(list[i]));\n }\n return result;\n }\n let count = 0;\n for (const item of list) {\n if (count++ === MAX_ITEMS) { result.push('\u2026'); break; }\n result.push(next(item));\n }\n return result;\n };\n const leaf = (value) => {\n if (value === undefined) return null;\n if (typeof value === 'number') {\n if (Number.isNaN(value) || !Number.isFinite(value)) return String(value);\n return Object.is(value, -0) ? '-0' : value;\n }\n if (typeof value === 'bigint') return value + 'n';\n if (typeof value === 'symbol') return value.toString();\n if (typeof value === 'function') return 'function ' + (value.name || '(anonymous)') + '()';\n return value;\n };\n const copy = (value, depth) => {\n if (value === null || typeof value !== 'object') return leaf(value);\n if (ancestors.has(value)) return '[Circular]';\n if (depth > 20) return '[\u2026]';\n const next = (item) => copy(item, depth + 1);\n const type = kind(value);\n if (isNode(value)) return describeNode(value);\n if (value.window === value) return 'Window';\n if (type === 'Date') return isNaN(value) ? 'Invalid Date' : value.toISOString();\n if (type === 'RegExp') return String(value);\n if (type === 'Error' || value instanceof Error) return value.name + ': ' + value.message;\n ancestors.add(value);\n try {\n if (type === 'Map') return items(value, ([k, v]) => [next(k), next(v)]);\n if (type === 'Set' || type === 'NodeList' || type === 'HTMLCollection') return items(value, next);\n if (ArrayBuffer.isView(value) && type !== 'DataView') return items(value, next);\n if (Array.isArray(value)) return items(value, next);\n const result = {};\n const keys = Object.keys(value);\n for (const key of keys.slice(0, MAX_ITEMS)) {\n try { result[key] = next(value[key]); } catch (e) { result[key] = '[Error: ' + (e && e.message) + ']'; }\n }\n if (keys.length > MAX_ITEMS) result['\u2026'] = (keys.length - MAX_ITEMS) + ' more keys';\n return result;\n } finally {\n ancestors.delete(value);\n }\n };\n return copy(this, 0);\n}";
|
|
34
44
|
/** What a busy target is called in messages: the page, or an iframe (`--frame`) */
|
|
35
45
|
export type BusyScope = 'page' | 'frame';
|
|
36
46
|
/** When {@link withBusyPageRecovery} checks the target, and what it calls it */
|
|
@@ -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
|
*
|