browser-debugger-cli 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/.claude/skills/bdg/SKILL.md +2 -1
  2. package/dist/cdp/methodTarget.d.ts +92 -0
  3. package/dist/cdp/methodTarget.js +159 -0
  4. package/dist/cdp/protocol.d.ts +16 -1
  5. package/dist/cdp/protocol.js +21 -0
  6. package/dist/cdp/schema.d.ts +55 -1
  7. package/dist/cdp/schema.js +134 -25
  8. package/dist/cdp/types.d.ts +3 -1
  9. package/dist/commands/cdp.d.ts +38 -1
  10. package/dist/commands/cdp.js +200 -133
  11. package/dist/commands/cleanup.js +18 -4
  12. package/dist/commands/dom/formInteraction.js +8 -4
  13. package/dist/commands/dom/helpers/index.d.ts +4 -4
  14. package/dist/commands/dom/helpers/index.js +3 -3
  15. package/dist/commands/dom/helpers/query.d.ts +2 -2
  16. package/dist/commands/dom/helpers/query.js +2 -2
  17. package/dist/commands/dom/helpers/screenshot.d.ts +21 -26
  18. package/dist/commands/dom/helpers/screenshot.js +50 -668
  19. package/dist/commands/dom/screenshot.js +56 -36
  20. package/dist/commands/optionBehaviors.js +18 -8
  21. package/dist/commands/shared/CommandRunner.d.ts +5 -0
  22. package/dist/commands/shared/CommandRunner.js +18 -3
  23. package/dist/commands/shared/interrupt.d.ts +40 -0
  24. package/dist/commands/shared/interrupt.js +73 -0
  25. package/dist/commands/shared/optionTypes.d.ts +2 -0
  26. package/dist/commands/shared/startHelpers.d.ts +26 -3
  27. package/dist/commands/shared/startHelpers.js +145 -23
  28. package/dist/commands/types.d.ts +5 -0
  29. package/dist/connection/cdp.js +1 -16
  30. package/dist/connection/chromeIdentity.d.ts +24 -5
  31. package/dist/connection/chromeIdentity.js +53 -22
  32. package/dist/connection/launcher.d.ts +34 -1
  33. package/dist/connection/launcher.js +98 -10
  34. package/dist/connection/typed-cdp.d.ts +3 -2
  35. package/dist/constants.d.ts +1 -1
  36. package/dist/constants.js +1 -1
  37. package/dist/daemon/SessionController.d.ts +10 -5
  38. package/dist/daemon/SessionController.js +15 -8
  39. package/dist/daemon/ipcServer.js +1 -1
  40. package/dist/daemon/launcher.d.ts +5 -0
  41. package/dist/daemon/launcher.js +8 -1
  42. package/dist/daemon/session/Session.d.ts +5 -1
  43. package/dist/daemon/session/Session.js +9 -8
  44. package/dist/daemon/session/TelemetryStore.d.ts +5 -0
  45. package/dist/daemon/session/TelemetryStore.js +4 -0
  46. package/dist/daemon/session/captureGate.d.ts +59 -0
  47. package/dist/daemon/session/captureGate.js +96 -0
  48. package/dist/daemon/session/chromeConnection.d.ts +16 -1
  49. package/dist/daemon/session/chromeConnection.js +34 -4
  50. package/dist/daemon/session/collectors.d.ts +15 -0
  51. package/dist/daemon/session/collectors.js +39 -2
  52. package/dist/daemon/session/commandRegistry.d.ts +14 -1
  53. package/dist/daemon/session/commandRegistry.js +46 -11
  54. package/dist/daemon/session/downloads.d.ts +32 -0
  55. package/dist/daemon/session/downloads.js +96 -0
  56. package/dist/daemon/session/interactions.d.ts +3 -2
  57. package/dist/daemon/session/interactions.js +7 -2
  58. package/dist/daemon/session/plugins.js +6 -0
  59. package/dist/daemon.js +12843 -11482
  60. package/dist/errors/CommandError.d.ts +2 -0
  61. package/dist/errors/issues.d.ts +1 -1
  62. package/dist/errors/messages.d.ts +58 -0
  63. package/dist/errors/messages.js +112 -0
  64. package/dist/index.js +999 -1020
  65. package/dist/ipc/client.d.ts +14 -1
  66. package/dist/ipc/client.js +21 -4
  67. package/dist/ipc/protocol/commands.d.ts +32 -2
  68. package/dist/ipc/protocol/commands.js +1 -0
  69. package/dist/ipc/protocol/domTypes.d.ts +24 -1
  70. package/dist/ipc/session/queries.d.ts +3 -0
  71. package/dist/ipc/session/types.d.ts +5 -0
  72. package/dist/ipc/transport/IPCError.d.ts +9 -0
  73. package/dist/ipc/transport/IPCError.js +12 -0
  74. package/dist/ipc/transport/errors.d.ts +2 -1
  75. package/dist/ipc/transport/errors.js +4 -1
  76. package/dist/ipc/transport/index.d.ts +4 -2
  77. package/dist/ipc/transport/index.js +13 -3
  78. package/dist/runtime/dom/actionEffects.d.ts +48 -9
  79. package/dist/runtime/dom/actionEffects.js +269 -34
  80. package/dist/runtime/dom/actionEffectsScripts.d.ts +45 -0
  81. package/dist/runtime/dom/actionEffectsScripts.js +101 -2
  82. package/dist/runtime/dom/captureArea.d.ts +35 -0
  83. package/dist/runtime/dom/captureArea.js +203 -0
  84. package/dist/runtime/dom/elementInfo.d.ts +10 -8
  85. package/dist/runtime/dom/elementInfo.js +8 -6
  86. package/dist/runtime/dom/formDiscovery.d.ts +1 -1
  87. package/dist/runtime/page/bdgWorld.d.ts +9 -0
  88. package/dist/runtime/page/bdgWorld.js +11 -0
  89. package/dist/runtime/page/captureEmulation.d.ts +119 -0
  90. package/dist/runtime/page/captureEmulation.js +189 -0
  91. package/dist/runtime/page/captureScroll.d.ts +24 -0
  92. package/dist/runtime/page/captureScroll.js +124 -0
  93. package/dist/runtime/page/screenshot.d.ts +41 -0
  94. package/dist/runtime/page/screenshot.js +394 -0
  95. package/dist/session/paths.d.ts +14 -0
  96. package/dist/session/paths.js +25 -0
  97. package/dist/telemetry/downloads.d.ts +127 -0
  98. package/dist/telemetry/downloads.js +265 -0
  99. package/dist/telemetry/har/builder.js +22 -7
  100. package/dist/telemetry/har/sanitize.d.ts +7 -3
  101. package/dist/telemetry/har/sanitize.js +52 -6
  102. package/dist/telemetry/har/sanitizeBody.d.ts +47 -7
  103. package/dist/telemetry/har/sanitizeBody.js +429 -56
  104. package/dist/telemetry/har/types.d.ts +2 -0
  105. package/dist/telemetry/network.d.ts +4 -4
  106. package/dist/telemetry/network.js +38 -4
  107. package/dist/telemetry/networkRetention.d.ts +35 -14
  108. package/dist/telemetry/networkRetention.js +62 -26
  109. package/dist/types.d.ts +9 -14
  110. package/dist/ui/OutputBuilder.d.ts +3 -2
  111. package/dist/ui/OutputBuilder.js +4 -3
  112. package/dist/ui/formatters/cdp.d.ts +32 -9
  113. package/dist/ui/formatters/cdp.js +77 -6
  114. package/dist/ui/formatters/details.js +7 -15
  115. package/dist/ui/formatters/preview.d.ts +2 -0
  116. package/dist/ui/formatters/preview.js +7 -1
  117. package/dist/ui/formatters/status.js +6 -1
  118. package/dist/ui/formatting.d.ts +7 -0
  119. package/dist/ui/formatting.js +13 -0
  120. package/dist/ui/logging/logger.d.ts +1 -1
  121. package/dist/ui/messages/chrome.d.ts +13 -0
  122. package/dist/ui/messages/chrome.js +26 -0
  123. package/dist/ui/messages/commands.d.ts +71 -3
  124. package/dist/ui/messages/commands.js +98 -3
  125. package/dist/ui/messages/networkMessages.d.ts +24 -5
  126. package/dist/ui/messages/networkMessages.js +31 -8
  127. package/dist/utils/async.d.ts +3 -2
  128. package/dist/utils/async.js +16 -3
  129. package/dist/utils/http.d.ts +11 -4
  130. package/dist/utils/http.js +5 -3
  131. package/package.json +18 -4
  132. /package/dist/{commands/dom → runtime/page}/screenshotResize.d.ts +0 -0
  133. /package/dist/{commands/dom → runtime/page}/screenshotResize.js +0 -0
@@ -0,0 +1,203 @@
1
+ /**
2
+ * The area an element screenshot captures: its border box, grown to what
3
+ * the element paints beyond it (overflowing descendants, text, shadows,
4
+ * outline), plus any `--padding`.
5
+ */
6
+ import { TypedCDPConnection } from '../../connection/typed-cdp.js';
7
+ import { CommandError } from '../../errors/index.js';
8
+ import { elementNotVisibleError, elementZeroDimensionsError } from '../../errors/messages.js';
9
+ import { resolveNodeInBdgWorld } from '../page/bdgWorld.js';
10
+ import { createLogger } from '../../ui/logging/index.js';
11
+ import { getErrorMessage } from '../../utils/errors.js';
12
+ import { EXIT_CODES } from '../../utils/exitCodes.js';
13
+ const log = createLogger('dom');
14
+ /** Descendants {@link CONTENT_OVERFLOW_JS} looks at, so a huge element stays cheap */
15
+ const OVERFLOW_SCAN_LIMIT = 2000;
16
+ /**
17
+ * Page-side distances (CSS px, never negative) by which what an element
18
+ * paints reaches beyond its border box on each side: its rendered
19
+ * descendants (uncleared floats, absolutely positioned and transformed
20
+ * children), its text (descenders past a tight line height, read from its
21
+ * scroll size beyond its client size, in transformed px, to the left on an
22
+ * RTL element), and its own outer box shadows and outline (a focus ring).
23
+ * Descendants of an element that clips its overflow (`overflow` other than
24
+ * `visible`) are cut off by it and not counted, nor are fixed ones (they
25
+ * belong to the viewport) or what lies outside the document (skip links at
26
+ * -9999px). Only the shadows and outline count when the element clips its
27
+ * own overflow.
28
+ */
29
+ const CONTENT_OVERFLOW_JS = `function () {
30
+ const view = this.ownerDocument.defaultView;
31
+ const scroller = this.ownerDocument.scrollingElement || this.ownerDocument.documentElement;
32
+ const own = this.getBoundingClientRect();
33
+ const reach = { left: own.left, top: own.top, right: own.right, bottom: own.bottom };
34
+ const page = { left: -view.scrollX, top: -view.scrollY, right: scroller.scrollWidth - view.scrollX, bottom: scroller.scrollHeight - view.scrollY };
35
+ const clips = (style) => style.overflowX !== 'visible' || style.overflowY !== 'visible';
36
+ let budget = ${OVERFLOW_SCAN_LIMIT};
37
+ const walk = (el) => {
38
+ for (const child of el.children) {
39
+ if (--budget < 0) return;
40
+ const style = view.getComputedStyle(child);
41
+ if (style.display === 'none' || style.position === 'fixed') continue;
42
+ const r = child.getBoundingClientRect();
43
+ if (r.width > 0 && r.height > 0 && style.visibility === 'visible') {
44
+ reach.left = Math.min(reach.left, Math.max(r.left, page.left));
45
+ reach.top = Math.min(reach.top, Math.max(r.top, page.top));
46
+ reach.right = Math.max(reach.right, Math.min(r.right, page.right));
47
+ reach.bottom = Math.max(reach.bottom, Math.min(r.bottom, page.bottom));
48
+ }
49
+ if (!clips(style)) walk(child);
50
+ }
51
+ };
52
+ const ownStyle = view.getComputedStyle(this);
53
+ if (!clips(ownStyle)) {
54
+ walk(this);
55
+ const scaleX = this.offsetWidth ? own.width / this.offsetWidth : 1;
56
+ const scaleY = this.offsetHeight ? own.height / this.offsetHeight : 1;
57
+ const wider = Math.max(0, this.scrollWidth - this.clientWidth) * scaleX;
58
+ const taller = Math.max(0, this.scrollHeight - this.clientHeight) * scaleY;
59
+ if (ownStyle.direction === 'rtl') reach.left = Math.min(reach.left, own.left - wider);
60
+ else reach.right = Math.max(reach.right, own.right + wider);
61
+ reach.bottom = Math.max(reach.bottom, own.bottom + taller);
62
+ }
63
+ const ink = { left: 0, top: 0, right: 0, bottom: 0 };
64
+ const grow = (side, amount) => { ink[side] = Math.max(ink[side], amount); };
65
+ for (const layer of ownStyle.boxShadow === 'none' ? [] : ownStyle.boxShadow.split(/,(?![^(]*\\))/)) {
66
+ if (/\\binset\\b/.test(layer)) continue;
67
+ const [x = 0, y = 0, blur = 0, spread = 0] = (layer.replace(/(rgba?|hsla?|color|oklch|lab|lch)\\([^)]*\\)/g, '').match(/-?[\\d.]+px/g) || []).map(parseFloat);
68
+ grow('left', blur + spread - x);
69
+ grow('right', blur + spread + x);
70
+ grow('top', blur + spread - y);
71
+ grow('bottom', blur + spread + y);
72
+ }
73
+ if (ownStyle.outlineStyle !== 'none') {
74
+ const outline = parseFloat(ownStyle.outlineWidth) + parseFloat(ownStyle.outlineOffset);
75
+ ['left', 'top', 'right', 'bottom'].forEach((side) => grow(side, outline));
76
+ }
77
+ return {
78
+ left: Math.max(own.left - reach.left, ink.left),
79
+ top: Math.max(own.top - reach.top, ink.top),
80
+ right: Math.max(reach.right - own.right, ink.right),
81
+ bottom: Math.max(reach.bottom - own.bottom, ink.bottom)
82
+ };
83
+ }`;
84
+ /** Overflow (px) below which the capture keeps to the border box (subpixel rounding) */
85
+ const OVERFLOW_SLACK = 1;
86
+ /** Measurements so far, naming each one's object group (captures may run side by side) */
87
+ let measurements = 0;
88
+ /**
89
+ * Throw an element error with exit code 81.
90
+ *
91
+ * @param err - Message and suggestion
92
+ * @throws CommandError (81)
93
+ */
94
+ function throwInvalid(err) {
95
+ throw new CommandError(err.message, { suggestion: err.suggestion }, EXIT_CODES.INVALID_ARGUMENTS);
96
+ }
97
+ /**
98
+ * The border box of an element (padding and border included), relative to
99
+ * the viewport.
100
+ *
101
+ * @param cdp - Session connection
102
+ * @param ref - The element
103
+ * @returns Bounds in CSS px
104
+ * @throws CommandError (81) when it is not rendered or has no area
105
+ */
106
+ export async function getElementBounds(cdp, ref) {
107
+ const boxModel = await new TypedCDPConnection(cdp)
108
+ .send('DOM.getBoxModel', ref)
109
+ .catch((error) => {
110
+ log.debug(`No box model: ${getErrorMessage(error)}`);
111
+ return undefined;
112
+ });
113
+ const border = boxModel?.model.border;
114
+ if (!border)
115
+ throwInvalid(elementNotVisibleError());
116
+ const x = border[0] ?? 0;
117
+ const y = border[1] ?? 0;
118
+ const width = (border[2] ?? 0) - x;
119
+ const height = (border[5] ?? 0) - y;
120
+ if (width <= 0 || height <= 0)
121
+ throwInvalid(elementZeroDimensionsError());
122
+ return { x, y, width, height };
123
+ }
124
+ /**
125
+ * Area an element screenshot captures: the border box, grown to the content
126
+ * that overflows it ({@link CONTENT_OVERFLOW_JS}), so floated children are
127
+ * not cropped away.
128
+ *
129
+ * @param cdp - Session connection
130
+ * @param ref - The element
131
+ * @param bounds - Border box (DOM.getBoxModel coordinates)
132
+ * @param padding - Extra space around it (CSS px)
133
+ * @returns The area, or the border box (with the padding) when nothing
134
+ * overflows (or the page cannot be asked)
135
+ */
136
+ export async function captureArea(cdp, ref, bounds, padding) {
137
+ const area = await paintedArea(cdp, ref, bounds);
138
+ return padding > 0
139
+ ? {
140
+ x: area.x - padding,
141
+ y: area.y - padding,
142
+ width: area.width + 2 * padding,
143
+ height: area.height + 2 * padding,
144
+ }
145
+ : area;
146
+ }
147
+ /**
148
+ * The border box grown to what the element paints beyond it
149
+ * ({@link CONTENT_OVERFLOW_JS}).
150
+ *
151
+ * @param cdp - Session connection
152
+ * @param ref - The element
153
+ * @param bounds - Border box
154
+ * @returns The area
155
+ */
156
+ async function paintedArea(cdp, ref, bounds) {
157
+ try {
158
+ const overflow = await measureOverflow(cdp, ref);
159
+ if (!overflow)
160
+ return bounds;
161
+ const [left, top, right, bottom] = ['left', 'top', 'right', 'bottom'].map((side) => Math.max(0, overflow[side] ?? 0));
162
+ if (Math.max(left, top, right, bottom) <= OVERFLOW_SLACK)
163
+ return bounds;
164
+ return {
165
+ x: bounds.x - left,
166
+ y: bounds.y - top,
167
+ width: bounds.width + left + right,
168
+ height: bounds.height + top + bottom,
169
+ };
170
+ }
171
+ catch (error) {
172
+ log.debug(`Could not measure overflowing content: ${getErrorMessage(error)}`);
173
+ return bounds;
174
+ }
175
+ }
176
+ /**
177
+ * Run {@link CONTENT_OVERFLOW_JS} on the element in bdg's world.
178
+ *
179
+ * @param cdp - Session connection
180
+ * @param ref - The element
181
+ * @returns Overflow per side, or undefined when the page did not answer
182
+ */
183
+ async function measureOverflow(cdp, ref) {
184
+ const objectGroup = `bdg-screenshot-${++measurements}`;
185
+ try {
186
+ const resolved = await resolveNodeInBdgWorld(cdp, { ...ref, objectGroup });
187
+ const objectId = resolved.object.objectId;
188
+ if (!objectId)
189
+ return undefined;
190
+ const response = await new TypedCDPConnection(cdp).send('Runtime.callFunctionOn', {
191
+ objectId,
192
+ functionDeclaration: CONTENT_OVERFLOW_JS,
193
+ returnByValue: true,
194
+ });
195
+ return response.result.value;
196
+ }
197
+ finally {
198
+ await cdp
199
+ .send('Runtime.releaseObjectGroup', { objectGroup })
200
+ .catch((error) => log.debug(`Object group not released: ${getErrorMessage(error)}`));
201
+ }
202
+ }
203
+ //# sourceMappingURL=captureArea.js.map
@@ -72,21 +72,23 @@ export declare const ELEMENT_TEXT_JS = "(el, full) => {\n const withoutDecorati
72
72
  export declare const MASKED_VALUE = "\u2022\u2022\u2022\u2022";
73
73
  /**
74
74
  * Regular expression source (case-insensitive) matching names of fields that
75
- * hold a password, one-time code or card code: `password`, `passwd`, `pwd`,
76
- * `passcode`, `otp`, `cvv`, `cvc`. Shared by the page-side
77
- * {@link SENSITIVE_FIELD_JS} and HAR sanitization.
75
+ * hold a password, one-time code, PIN, social security number or card data:
76
+ * `password`, `passwd`, `pwd`, `passcode`, `passphrase`, `otp`, `pin`, `ssn`
77
+ * (these three as whole words), `cvv`, `cvc`, `card_number`, `cardNo` (whole
78
+ * words). Shared
79
+ * by the page-side {@link SENSITIVE_FIELD_JS} and HAR sanitization.
78
80
  */
79
- export declare const SENSITIVE_NAME_SOURCE = "passw|passwd|pwd|passcode|(^|[^a-z])otp([^a-z]|$)|cvv|cvc";
81
+ export declare const SENSITIVE_NAME_SOURCE = "passw|passwd|pwd|passcode|passphrase|(^|[^a-z])(otp|pin|ssn)([^a-z]|$)|cvv|cvc|card[-_]?(num(ber)?|no)([^a-z]|$)";
80
82
  /**
81
83
  * Page-side check whether a form control holds a secret whose value must
82
84
  * never leave the page: a password field (live type or type attribute), a
83
85
  * field shown masked by CSS (`-webkit-text-security` other than `none`),
84
86
  * one whose `autocomplete` names a password, a payment card (`cc-*`) or a
85
- * one-time code, or a field named like a password, one-time code or card code
86
- * (`password`, `passwd`, `pwd`, `passcode`, `otp`, `cvv`, `cvc`), which
87
+ * one-time code, or a field named like a password, one-time code, PIN, SSN
88
+ * or card data ({@link SENSITIVE_NAME_SOURCE}), which
87
89
  * covers a password field switched to text by a "show password" button.
88
90
  */
89
- export declare const SENSITIVE_FIELD_JS = "(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}";
91
+ export declare const SENSITIVE_FIELD_JS = "(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|passphrase|(^|[^a-z])(otp|pin|ssn)([^a-z]|$)|cvv|cvc|card[-_]?(num(ber)?|no)([^a-z]|$)/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}";
90
92
  /**
91
93
  * Page-side live state of a form control, which its attributes do not show:
92
94
  * the type and current value of an `<input>` (for checkboxes and radios
@@ -100,7 +102,7 @@ export declare const SENSITIVE_FIELD_JS = "(el) => {\n const autocomplete = el.
100
102
  * is replaced by {@link MASKED_VALUE}, whatever its length, with
101
103
  * `sensitive: true`.
102
104
  */
103
- export declare const ELEMENT_STATE_JS = "(el) => {\n const isSensitive = (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};\n const mask = (value) => (value ? '\u2022\u2022\u2022\u2022' : '');\n const guarded = (state) => {\n if (!isSensitive(el)) return state;\n const result = { ...state, sensitive: true };\n if ('value' in result) result.value = mask(result.value);\n if ('selected' in result) result.selected = mask(result.selected);\n return result;\n };\n switch (el.localName) {\n case 'input':\n if (el.type === 'hidden') return { type: 'hidden' };\n return guarded(\n /^(checkbox|radio)$/.test(el.type)\n ? { type: el.type, checked: el.checked, value: el.getAttribute('value') || '' }\n : { type: el.type, value: el.value }\n );\n case 'textarea':\n return guarded({ value: el.value });\n case 'select':\n return guarded({ selected: Array.from(el.selectedOptions || [], (option) => option.label).join(', ') });\n case 'button':\n return el.form ? { type: el.type } : {};\n default:\n return {};\n }\n}";
105
+ export declare const ELEMENT_STATE_JS = "(el) => {\n const isSensitive = (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|passphrase|(^|[^a-z])(otp|pin|ssn)([^a-z]|$)|cvv|cvc|card[-_]?(num(ber)?|no)([^a-z]|$)/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};\n const mask = (value) => (value ? '\u2022\u2022\u2022\u2022' : '');\n const guarded = (state) => {\n if (!isSensitive(el)) return state;\n const result = { ...state, sensitive: true };\n if ('value' in result) result.value = mask(result.value);\n if ('selected' in result) result.selected = mask(result.selected);\n return result;\n };\n switch (el.localName) {\n case 'input':\n if (el.type === 'hidden') return { type: 'hidden' };\n return guarded(\n /^(checkbox|radio)$/.test(el.type)\n ? { type: el.type, checked: el.checked, value: el.getAttribute('value') || '' }\n : { type: el.type, value: el.value }\n );\n case 'textarea':\n return guarded({ value: el.value });\n case 'select':\n return guarded({ selected: Array.from(el.selectedOptions || [], (option) => option.label).join(', ') });\n case 'button':\n return el.form ? { type: el.type } : {};\n default:\n return {};\n }\n}";
104
106
  /**
105
107
  * Page-side short description of an element: tag, id and up to two classes
106
108
  * ({@link LABEL_CLASSES_JS}), e.g. `button#save.primary.large`.
@@ -159,18 +159,20 @@ export const ELEMENT_TEXT_JS = `(el, full) => {
159
159
  export const MASKED_VALUE = '••••';
160
160
  /**
161
161
  * Regular expression source (case-insensitive) matching names of fields that
162
- * hold a password, one-time code or card code: `password`, `passwd`, `pwd`,
163
- * `passcode`, `otp`, `cvv`, `cvc`. Shared by the page-side
164
- * {@link SENSITIVE_FIELD_JS} and HAR sanitization.
162
+ * hold a password, one-time code, PIN, social security number or card data:
163
+ * `password`, `passwd`, `pwd`, `passcode`, `passphrase`, `otp`, `pin`, `ssn`
164
+ * (these three as whole words), `cvv`, `cvc`, `card_number`, `cardNo` (whole
165
+ * words). Shared
166
+ * by the page-side {@link SENSITIVE_FIELD_JS} and HAR sanitization.
165
167
  */
166
- export const SENSITIVE_NAME_SOURCE = 'passw|passwd|pwd|passcode|(^|[^a-z])otp([^a-z]|$)|cvv|cvc';
168
+ export const SENSITIVE_NAME_SOURCE = 'passw|passwd|pwd|passcode|passphrase|(^|[^a-z])(otp|pin|ssn)([^a-z]|$)|cvv|cvc|card[-_]?(num(ber)?|no)([^a-z]|$)';
167
169
  /**
168
170
  * Page-side check whether a form control holds a secret whose value must
169
171
  * never leave the page: a password field (live type or type attribute), a
170
172
  * field shown masked by CSS (`-webkit-text-security` other than `none`),
171
173
  * one whose `autocomplete` names a password, a payment card (`cc-*`) or a
172
- * one-time code, or a field named like a password, one-time code or card code
173
- * (`password`, `passwd`, `pwd`, `passcode`, `otp`, `cvv`, `cvc`), which
174
+ * one-time code, or a field named like a password, one-time code, PIN, SSN
175
+ * or card data ({@link SENSITIVE_NAME_SOURCE}), which
174
176
  * covers a password field switched to text by a "show password" button.
175
177
  */
176
178
  export const SENSITIVE_FIELD_JS = `(el) => {
@@ -19,7 +19,7 @@ import type { RawFormData } from './formTypes.js';
19
19
  * sensitive select's options do not say which is chosen)
20
20
  * - Form relevance scoring for multi-form pages
21
21
  */
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";
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|passphrase|(^|[^a-z])(otp|pin|ssn)([^a-z]|$)|cvv|cvc|card[-_]?(num(ber)?|no)([^a-z]|$)/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";
23
23
  /**
24
24
  * Type guard for raw form data.
25
25
  *
@@ -23,6 +23,15 @@ import type { Protocol } from '../../connection/typed-cdp.js';
23
23
  type PageConnection = Pick<CDPConnection, 'send'> & Partial<Pick<CDPConnection, 'on'>>;
24
24
  /** Name of bdg's isolated world (shown in DevTools' context selector) */
25
25
  export declare const BDG_WORLD_NAME = "bdg";
26
+ /**
27
+ * Whether bdg's world is known for a connection: created or being created,
28
+ * and not forgotten since (the top frame navigated, or its contexts were
29
+ * cleared). Scripts left in a forgotten world went with its document.
30
+ *
31
+ * @param cdp - Connection to the page
32
+ * @returns True while the world is known
33
+ */
34
+ export declare function hasBdgWorld(cdp: PageConnection): boolean;
26
35
  /**
27
36
  * `Runtime.evaluate` in bdg's world of the top frame. A call that names its
28
37
  * own context is sent as it is.
@@ -37,6 +37,17 @@ function worldContext(cdp) {
37
37
  worlds.set(cdp, created);
38
38
  return created;
39
39
  }
40
+ /**
41
+ * Whether bdg's world is known for a connection: created or being created,
42
+ * and not forgotten since (the top frame navigated, or its contexts were
43
+ * cleared). Scripts left in a forgotten world went with its document.
44
+ *
45
+ * @param cdp - Connection to the page
46
+ * @returns True while the world is known
47
+ */
48
+ export function hasBdgWorld(cdp) {
49
+ return worlds.has(cdp);
50
+ }
40
51
  /**
41
52
  * Create the world in the top frame and forget it when the page changes.
42
53
  *
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Page emulation a screenshot changes for its capture, and puts back.
3
+ *
4
+ * A capture at a pixel ratio of 1 on a high-DPI page, and one beyond the
5
+ * viewport, override the device metrics (and hide the scrollbars). Each
6
+ * change is recorded before it is sent, so {@link CaptureEmulation.restore}
7
+ * (called once the capture ended, however it ended) undoes exactly what may
8
+ * have been changed: the session's emulation is put back from the daemon's
9
+ * own record of it as it is then (a `page emulate` during the capture
10
+ * counts), not from a file.
11
+ */
12
+ import type { CDPConnection } from '../../connection/cdp.js';
13
+ import type { ViewportSize } from '../../types.js';
14
+ /** Width and height in CSS px */
15
+ export interface Size {
16
+ width: number;
17
+ height: number;
18
+ }
19
+ /** A page position in CSS px */
20
+ export interface ScrollPosition {
21
+ x: number;
22
+ y: number;
23
+ }
24
+ /**
25
+ * Evaluate one of bdg's page scripts and return its value.
26
+ *
27
+ * @param cdp - Session connection
28
+ * @param expression - Script
29
+ * @returns Its value, or undefined when it threw
30
+ */
31
+ export declare function evaluateValue(cdp: CDPConnection, expression: string): Promise<unknown>;
32
+ /**
33
+ * Two numbers a page script returned as an array.
34
+ *
35
+ * @param cdp - Session connection
36
+ * @param expression - Script returning `[a, b]`
37
+ * @returns The numbers, undefined where the page did not answer
38
+ */
39
+ export declare function evaluatePair(cdp: CDPConnection, expression: string): Promise<[number | undefined, number | undefined]>;
40
+ /**
41
+ * The page's scroll position.
42
+ *
43
+ * @param cdp - Session connection
44
+ * @returns Scroll offsets in CSS px
45
+ */
46
+ export declare function scrollPosition(cdp: CDPConnection): Promise<ScrollPosition>;
47
+ /**
48
+ * The emulation changes of one capture, undone by {@link restore}.
49
+ */
50
+ export declare class CaptureEmulation {
51
+ private readonly connection;
52
+ private readonly sessionViewport;
53
+ private readonly cdp;
54
+ private metricsChanged;
55
+ private scrollbarsHidden;
56
+ private scrolledFrom;
57
+ /**
58
+ * @param connection - Session connection
59
+ * @param sessionViewport - Reads the session's emulated viewport
60
+ * (`--viewport`, `--mobile`, `page emulate`) as it is now; put back
61
+ * afterwards, none clears the override
62
+ */
63
+ constructor(connection: CDPConnection, sessionViewport: () => ViewportSize | undefined);
64
+ /**
65
+ * Capture at a pixel ratio of 1 (CSS px = image px) on a high-DPI page:
66
+ * the viewport is overridden at the session's viewport, else the window's
67
+ * size with its scrollbars (so the layout does not change).
68
+ *
69
+ * @param devicePixelRatio - Page's pixel ratio
70
+ * @param viewport - Visible viewport size, used when the page does not answer
71
+ */
72
+ useUnitPixelRatio(devicePixelRatio: number, viewport: Size): Promise<void>;
73
+ /**
74
+ * Lay the page out at its current width without scrollbars: a capture
75
+ * beyond the viewport hides them, and without this the page would widen by
76
+ * the scrollbar and centered content move after it was measured. The
77
+ * viewport is overridden at the visible size (CSS px, pixel ratio 1; still
78
+ * a phone in a phone session).
79
+ *
80
+ * @param view - Visible viewport size
81
+ */
82
+ keepLayoutWithoutScrollbars(view: Size): Promise<void>;
83
+ /**
84
+ * Record where the page was scrolled before the capture moved it, to
85
+ * scroll it back afterwards (the first position recorded wins).
86
+ *
87
+ * @param position - Scroll position before the capture
88
+ */
89
+ scrolledAwayFrom(position: ScrollPosition): void;
90
+ /**
91
+ * Put back what the capture changed: scrollbars shown, the session's
92
+ * device metrics (its viewport and a phone's touch input, which a capture
93
+ * beyond the viewport turns off), else none, and the scroll position. Each
94
+ * step runs even when an earlier one failed.
95
+ *
96
+ * @throws The first step's error, once every step ran
97
+ */
98
+ restore(): Promise<void>;
99
+ /**
100
+ * The steps {@link restore} runs, for what the capture changed.
101
+ *
102
+ * @returns Named steps, in order
103
+ */
104
+ private restoreSteps;
105
+ /**
106
+ * Put back the session's device metrics, or clear the override.
107
+ */
108
+ private restoreSessionMetrics;
109
+ /**
110
+ * The window's size with its scrollbars (`innerWidth`/`innerHeight`): an
111
+ * override at this size keeps the page's layout, where the visible size
112
+ * (without scrollbars) would narrow it and move centered content.
113
+ *
114
+ * @param viewport - Visible viewport size, used when the page does not answer
115
+ * @returns Width and height in CSS px
116
+ */
117
+ private windowSize;
118
+ }
119
+ //# sourceMappingURL=captureEmulation.d.ts.map