@geometra/proxy 1.63.2 → 1.64.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/dist/dom-actions.d.ts +9 -0
- package/dist/dom-actions.d.ts.map +1 -1
- package/dist/dom-actions.js +510 -317
- package/dist/dom-actions.js.map +1 -1
- package/dist/extractor.d.ts.map +1 -1
- package/dist/extractor.js +63 -0
- package/dist/extractor.js.map +1 -1
- package/dist/geometry-ws.d.ts +3 -0
- package/dist/geometry-ws.d.ts.map +1 -1
- package/dist/geometry-ws.js +62 -29
- package/dist/geometry-ws.js.map +1 -1
- package/dist/types.d.ts +31 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +289 -31
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
package/dist/dom-actions.js
CHANGED
|
@@ -5,6 +5,8 @@ export function delay(ms) {
|
|
|
5
5
|
return new Promise(r => setTimeout(r, ms));
|
|
6
6
|
}
|
|
7
7
|
const LABELED_CONTROL_SELECTOR = 'input, select, textarea, button, [role="combobox"], [role="textbox"], [aria-haspopup="listbox"], [aria-haspopup="menu"], [aria-haspopup="true"], [contenteditable="true"]';
|
|
8
|
+
const EDITABLE_FIELD_SELECTOR = 'input:not([type="checkbox"]):not([type="radio"]):not([type="file"]):not([type="button"]):not([type="submit"]):not([type="reset"]):not([type="hidden"]), textarea, [role="textbox"], [role="combobox"], [contenteditable="true"]';
|
|
9
|
+
const TOGGLE_CONTROL_SELECTOR = 'input[type="checkbox"], input[type="radio"], [role="checkbox"], [role="radio"], [role="switch"]';
|
|
8
10
|
const MENU_TRIGGER_SELECTOR = 'button[aria-haspopup="menu"], button[aria-haspopup="true"], [role="button"][aria-haspopup="menu"], [role="button"][aria-haspopup="true"]';
|
|
9
11
|
const POPUP_CONTAINER_SELECTOR = '[role="listbox"], [role="menu"], [role="dialog"], [aria-modal="true"], [data-radix-popper-content-wrapper], [class*="menu"], [class*="option"], [class*="select"], [class*="dropdown"]';
|
|
10
12
|
const POPUP_ROOT_SELECTOR = '[role="listbox"], [role="menu"], [role="dialog"], [aria-modal="true"], [data-radix-popper-content-wrapper], [class*="menu"], [class*="dropdown"], [class*="popover"], [class*="listbox"], [class*="options"]';
|
|
@@ -43,10 +45,12 @@ export function clearFillLookupCache(cache) {
|
|
|
43
45
|
function lookupCacheKey(kind, label, exact) {
|
|
44
46
|
return `${kind}:${exact ? 'exact' : 'fuzzy'}:${normalizedOptionLabel(label)}`;
|
|
45
47
|
}
|
|
46
|
-
function lookupCacheKeys(kind, label, exact, fieldId) {
|
|
48
|
+
function lookupCacheKeys(kind, label, exact, fieldId, fieldKey) {
|
|
47
49
|
const keys = [lookupCacheKey(kind, label, exact)];
|
|
48
50
|
if (fieldId)
|
|
49
51
|
keys.unshift(`${kind}:id:${fieldId}`);
|
|
52
|
+
if (fieldKey)
|
|
53
|
+
keys.unshift(`${kind}:key:${fieldKey}`);
|
|
50
54
|
return keys;
|
|
51
55
|
}
|
|
52
56
|
function cacheMapForKind(cache, kind) {
|
|
@@ -56,23 +60,157 @@ function cacheMapForKind(cache, kind) {
|
|
|
56
60
|
return cache.editable;
|
|
57
61
|
return cache.fileInput;
|
|
58
62
|
}
|
|
59
|
-
function readCachedLocator(cache, kind, label, exact, fieldId) {
|
|
63
|
+
async function readCachedLocator(cache, kind, label, exact, fieldId, fieldKey) {
|
|
60
64
|
if (!cache)
|
|
61
65
|
return undefined;
|
|
62
66
|
const map = cacheMapForKind(cache, kind);
|
|
67
|
+
const readLive = async (key) => {
|
|
68
|
+
if (!map.has(key))
|
|
69
|
+
return undefined;
|
|
70
|
+
const locator = map.get(key);
|
|
71
|
+
// Never retain negative lookups across reactive re-renders.
|
|
72
|
+
if (!locator) {
|
|
73
|
+
map.delete(key);
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
const attached = await locator.count() > 0;
|
|
78
|
+
const usable = attached && (kind === 'file' || await locator.isVisible());
|
|
79
|
+
const identityMatches = usable && fieldKey && key === `${kind}:key:${fieldKey}`
|
|
80
|
+
? await locatorMatchesAuthoredFieldKey(locator, fieldKey)
|
|
81
|
+
: usable;
|
|
82
|
+
if (identityMatches)
|
|
83
|
+
return locator;
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// Detached locators are normal after controlled-form re-renders.
|
|
87
|
+
}
|
|
88
|
+
map.delete(key);
|
|
89
|
+
return undefined;
|
|
90
|
+
};
|
|
91
|
+
// A newly supplied authored fieldKey must get a chance to resolve before a
|
|
92
|
+
// locator cached under the looser schema id or label is reused.
|
|
93
|
+
if (fieldKey) {
|
|
94
|
+
const key = `${kind}:key:${fieldKey}`;
|
|
95
|
+
return await readLive(key);
|
|
96
|
+
}
|
|
63
97
|
for (const key of lookupCacheKeys(kind, label, exact, fieldId)) {
|
|
64
|
-
|
|
65
|
-
|
|
98
|
+
const locator = await readLive(key);
|
|
99
|
+
if (locator)
|
|
100
|
+
return locator;
|
|
66
101
|
}
|
|
67
102
|
return undefined;
|
|
68
103
|
}
|
|
69
|
-
function writeCachedLocator(cache, kind, label, exact, fieldId, locator) {
|
|
104
|
+
function writeCachedLocator(cache, kind, label, exact, fieldId, fieldKey, locator) {
|
|
70
105
|
if (!cache)
|
|
71
106
|
return;
|
|
72
107
|
const map = cacheMapForKind(cache, kind);
|
|
73
|
-
|
|
74
|
-
|
|
108
|
+
const keys = fieldKey
|
|
109
|
+
? [`${kind}:key:${fieldKey}`, ...(fieldId ? [`${kind}:id:${fieldId}`] : [])]
|
|
110
|
+
: lookupCacheKeys(kind, label, exact, fieldId);
|
|
111
|
+
for (const key of keys) {
|
|
112
|
+
if (locator)
|
|
113
|
+
map.set(key, locator);
|
|
114
|
+
else
|
|
115
|
+
map.delete(key);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function parseAuthoredFieldKey(fieldKey) {
|
|
119
|
+
const normalized = fieldKey.trim();
|
|
120
|
+
if (!normalized || normalized !== fieldKey) {
|
|
121
|
+
throw new Error('fieldKey must be a trimmed, non-empty string');
|
|
122
|
+
}
|
|
123
|
+
if (normalized.startsWith('id:')) {
|
|
124
|
+
try {
|
|
125
|
+
const id = decodeURIComponent(normalized.slice(3));
|
|
126
|
+
if (id)
|
|
127
|
+
return { kind: 'id', id };
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
throw new Error('fieldKey contains an invalid encoded id');
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (normalized.startsWith('name:')) {
|
|
134
|
+
const parts = normalized.split(':');
|
|
135
|
+
const tag = parts[1]?.trim().toLowerCase();
|
|
136
|
+
const type = parts[2]?.trim().toLowerCase();
|
|
137
|
+
try {
|
|
138
|
+
const name = decodeURIComponent(parts.slice(3).join(':'));
|
|
139
|
+
if (tag && type && name)
|
|
140
|
+
return { kind: 'name', tag, type, name };
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
throw new Error('fieldKey contains an invalid encoded name');
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
throw new Error('fieldKey must use id:<id> or name:<tag>:<type-or-default>:<name>');
|
|
147
|
+
}
|
|
148
|
+
async function locatorMatchesAuthoredFieldKey(locator, fieldKey) {
|
|
149
|
+
const parsed = parseAuthoredFieldKey(fieldKey);
|
|
150
|
+
try {
|
|
151
|
+
return await locator.evaluate((element, key) => {
|
|
152
|
+
if (!(element instanceof Element))
|
|
153
|
+
return false;
|
|
154
|
+
if (key.kind === 'id')
|
|
155
|
+
return element.getAttribute('id') === key.id;
|
|
156
|
+
const authoredType = element.getAttribute('type')?.trim().toLowerCase() || 'default';
|
|
157
|
+
return element.tagName.toLowerCase() === key.tag &&
|
|
158
|
+
authoredType === key.type &&
|
|
159
|
+
element.getAttribute('name') === key.name;
|
|
160
|
+
}, parsed);
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
function selectorForAuthoredFieldKind(kind) {
|
|
167
|
+
if (kind === 'editable')
|
|
168
|
+
return EDITABLE_FIELD_SELECTOR;
|
|
169
|
+
if (kind === 'file')
|
|
170
|
+
return 'input[type="file"]';
|
|
171
|
+
if (kind === 'toggle')
|
|
172
|
+
return TOGGLE_CONTROL_SELECTOR;
|
|
173
|
+
return LABELED_CONTROL_SELECTOR;
|
|
174
|
+
}
|
|
175
|
+
async function findAuthoredFieldByKeyInPage(page, fieldKey, kind) {
|
|
176
|
+
const parsed = parseAuthoredFieldKey(fieldKey);
|
|
177
|
+
const matches = [];
|
|
178
|
+
for (const frame of page.frames()) {
|
|
179
|
+
const candidates = frame.locator(selectorForAuthoredFieldKind(kind));
|
|
180
|
+
const indices = await candidates.evaluateAll((elements, key) => {
|
|
181
|
+
const result = [];
|
|
182
|
+
for (let index = 0; index < elements.length; index++) {
|
|
183
|
+
const element = elements[index];
|
|
184
|
+
if (!(element instanceof Element))
|
|
185
|
+
continue;
|
|
186
|
+
if (key.kind === 'id') {
|
|
187
|
+
if (element.getAttribute('id') === key.id)
|
|
188
|
+
result.push(index);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
const authoredType = element.getAttribute('type')?.trim().toLowerCase() || 'default';
|
|
192
|
+
if (element.tagName.toLowerCase() === key.tag &&
|
|
193
|
+
authoredType === key.type &&
|
|
194
|
+
element.getAttribute('name') === key.name) {
|
|
195
|
+
result.push(index);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return result;
|
|
199
|
+
}, parsed);
|
|
200
|
+
for (const index of indices) {
|
|
201
|
+
const locator = candidates.nth(index);
|
|
202
|
+
if (kind === 'editable' && !(await locatorIsEditable(locator)))
|
|
203
|
+
continue;
|
|
204
|
+
if (kind !== 'file' && !(await locator.isVisible()))
|
|
205
|
+
continue;
|
|
206
|
+
matches.push({ locator, frameUrl: frame.url() });
|
|
207
|
+
}
|
|
75
208
|
}
|
|
209
|
+
if (matches.length > 1) {
|
|
210
|
+
const frames = [...new Set(matches.map(match => match.frameUrl || '<top-frame>'))];
|
|
211
|
+
throw new Error(`fieldKey "${fieldKey}" is ambiguous: matched ${matches.length} controls across ${frames.join(', ')}`);
|
|
212
|
+
}
|
|
213
|
+
return matches[0]?.locator ?? null;
|
|
76
214
|
}
|
|
77
215
|
function normalizedOptionLabel(value) {
|
|
78
216
|
return value
|
|
@@ -699,10 +837,21 @@ async function findMenuTriggerByContainerLabel(frame, fieldLabel, exact) {
|
|
|
699
837
|
}
|
|
700
838
|
async function findLabeledControlInPage(page, fieldLabel, exact, opts) {
|
|
701
839
|
const cacheable = !opts?.preferredAnchor;
|
|
702
|
-
const cached = cacheable
|
|
840
|
+
const cached = cacheable
|
|
841
|
+
? await readCachedLocator(opts?.cache, 'control', fieldLabel, exact, opts?.fieldId, opts?.fieldKey)
|
|
842
|
+
: undefined;
|
|
703
843
|
if (cached !== undefined) {
|
|
704
844
|
return cached;
|
|
705
845
|
}
|
|
846
|
+
if (opts?.fieldKey) {
|
|
847
|
+
const authored = await findAuthoredFieldByKeyInPage(page, opts.fieldKey, 'control');
|
|
848
|
+
if (authored) {
|
|
849
|
+
if (cacheable)
|
|
850
|
+
writeCachedLocator(opts?.cache, 'control', fieldLabel, exact, opts?.fieldId, opts.fieldKey, authored);
|
|
851
|
+
return authored;
|
|
852
|
+
}
|
|
853
|
+
throw new Error(`fieldKey "${opts.fieldKey}" did not resolve to a visible control; refresh geometra_form_schema`);
|
|
854
|
+
}
|
|
706
855
|
// Try the label as-is first. If the caller passed a fully-qualified DOM
|
|
707
856
|
// label, that's the most accurate match.
|
|
708
857
|
for (const frame of page.frames()) {
|
|
@@ -710,7 +859,7 @@ async function findLabeledControlInPage(page, fieldLabel, exact, opts) {
|
|
|
710
859
|
if (!locator)
|
|
711
860
|
continue;
|
|
712
861
|
if (cacheable)
|
|
713
|
-
writeCachedLocator(opts?.cache, 'control', fieldLabel, exact, opts?.fieldId, locator);
|
|
862
|
+
writeCachedLocator(opts?.cache, 'control', fieldLabel, exact, opts?.fieldId, undefined, locator);
|
|
714
863
|
return locator;
|
|
715
864
|
}
|
|
716
865
|
// Fallback: if the label looks truncated (ends in U+2026 or "..."), strip
|
|
@@ -726,7 +875,7 @@ async function findLabeledControlInPage(page, fieldLabel, exact, opts) {
|
|
|
726
875
|
if (!locator)
|
|
727
876
|
continue;
|
|
728
877
|
if (cacheable)
|
|
729
|
-
writeCachedLocator(opts?.cache, 'control', fieldLabel, exact, opts?.fieldId, locator);
|
|
878
|
+
writeCachedLocator(opts?.cache, 'control', fieldLabel, exact, opts?.fieldId, undefined, locator);
|
|
730
879
|
return locator;
|
|
731
880
|
}
|
|
732
881
|
}
|
|
@@ -741,12 +890,12 @@ async function findLabeledControlInPage(page, fieldLabel, exact, opts) {
|
|
|
741
890
|
if (!locator)
|
|
742
891
|
continue;
|
|
743
892
|
if (cacheable)
|
|
744
|
-
writeCachedLocator(opts?.cache, 'control', fieldLabel, exact, opts?.fieldId, locator);
|
|
893
|
+
writeCachedLocator(opts?.cache, 'control', fieldLabel, exact, opts?.fieldId, undefined, locator);
|
|
745
894
|
return locator;
|
|
746
895
|
}
|
|
747
896
|
}
|
|
748
897
|
if (cacheable)
|
|
749
|
-
writeCachedLocator(opts?.cache, 'control', fieldLabel, exact, opts?.fieldId, null);
|
|
898
|
+
writeCachedLocator(opts?.cache, 'control', fieldLabel, exact, opts?.fieldId, undefined, null);
|
|
750
899
|
return null;
|
|
751
900
|
}
|
|
752
901
|
function textMatches(candidate, expected, exact) {
|
|
@@ -763,8 +912,8 @@ function displayedValueMatchesSelection(candidate, expected, exact, selectedOpti
|
|
|
763
912
|
return false;
|
|
764
913
|
return (normalizedSelectedOption.includes(normalizedCandidate) || normalizedCandidate.includes(normalizedSelectedOption));
|
|
765
914
|
}
|
|
766
|
-
async function openDropdownControl(page, fieldLabel, exact, cache, fieldId) {
|
|
767
|
-
const locator = await findLabeledControlInPage(page, fieldLabel, exact, { cache, fieldId });
|
|
915
|
+
async function openDropdownControl(page, fieldLabel, exact, cache, fieldId, fieldKey) {
|
|
916
|
+
const locator = await findLabeledControlInPage(page, fieldLabel, exact, { cache, fieldId, fieldKey });
|
|
768
917
|
if (locator) {
|
|
769
918
|
await locator.scrollIntoViewIfNeeded();
|
|
770
919
|
const handle = await locator.elementHandle();
|
|
@@ -1876,116 +2025,6 @@ async function readTriggerShowsPlaceholder(handle) {
|
|
|
1876
2025
|
return false;
|
|
1877
2026
|
}
|
|
1878
2027
|
}
|
|
1879
|
-
/**
|
|
1880
|
-
* Detect whether a combobox trigger is a "searchable/autocomplete style"
|
|
1881
|
-
* combobox (React Select, Headless UI, Radix, Ant Design Select, etc.).
|
|
1882
|
-
*
|
|
1883
|
-
* These libraries commit their controlled `onChange` state on **keyboard
|
|
1884
|
-
* Enter**, not on a synthetic mouse click dispatched via Playwright's
|
|
1885
|
-
* `.click()`. For regular mouse interactions they use a bubbling
|
|
1886
|
-
* `mousedown`/`pointerdown` path that does not round-trip through React
|
|
1887
|
-
* Select's internal `selectOption` handler in some Remix-wrapped builds
|
|
1888
|
-
* (notably Greenhouse's ATS embed), so the option click fires visually but
|
|
1889
|
-
* the form state stays empty. Pressing Enter on the focused combobox input
|
|
1890
|
-
* after the click puts the selection through the keyboard code path which
|
|
1891
|
-
* ALWAYS commits.
|
|
1892
|
-
*
|
|
1893
|
-
* Detection is fully generic — no hostname or site-specific branching.
|
|
1894
|
-
* The signals we look for are:
|
|
1895
|
-
*
|
|
1896
|
-
* 1. `aria-autocomplete` on the element or its nearest ancestor input is
|
|
1897
|
-
* `"list"` or `"both"`. This is the standards-aligned signal for any
|
|
1898
|
-
* combobox that filters options as the user types.
|
|
1899
|
-
* 2. A class pattern indicative of React Select (`select__control`,
|
|
1900
|
-
* `select__`), React Suite picker (`rs-picker`), or Ant Design Select
|
|
1901
|
-
* (`ant-select`) on the element or any ancestor up to 5 levels.
|
|
1902
|
-
* 3. `role="combobox"` present together with `aria-expanded` on the
|
|
1903
|
-
* element or an ancestor — the ARIA 1.2 combobox pattern.
|
|
1904
|
-
*
|
|
1905
|
-
* Native `<select>` elements do NOT match any of these signals, so the
|
|
1906
|
-
* caller can safely gate its `Enter` dispatch on this check without
|
|
1907
|
-
* breaking non-searchable listboxes.
|
|
1908
|
-
*/
|
|
1909
|
-
async function isAutocompleteCombobox(handle) {
|
|
1910
|
-
if (!handle)
|
|
1911
|
-
return false;
|
|
1912
|
-
try {
|
|
1913
|
-
return await handle.evaluate((el) => {
|
|
1914
|
-
if (!(el instanceof Element))
|
|
1915
|
-
return false;
|
|
1916
|
-
let cur = el;
|
|
1917
|
-
let depth = 0;
|
|
1918
|
-
while (cur && depth < 5) {
|
|
1919
|
-
// aria-autocomplete on the element or an ancestor input/combobox.
|
|
1920
|
-
const ac = cur.getAttribute('aria-autocomplete');
|
|
1921
|
-
if (ac === 'list' || ac === 'both')
|
|
1922
|
-
return true;
|
|
1923
|
-
// Class-based fingerprint for known autocomplete libraries.
|
|
1924
|
-
const className = (cur.getAttribute('class') ?? '').toLowerCase();
|
|
1925
|
-
if (className) {
|
|
1926
|
-
if (className.includes('select__control') ||
|
|
1927
|
-
className.includes('select__') ||
|
|
1928
|
-
className.includes('rs-picker') ||
|
|
1929
|
-
className.includes('ant-select') ||
|
|
1930
|
-
className.includes('headlessui-combobox') ||
|
|
1931
|
-
className.includes('cmdk-')) {
|
|
1932
|
-
return true;
|
|
1933
|
-
}
|
|
1934
|
-
}
|
|
1935
|
-
// ARIA 1.2 combobox pattern: role="combobox" + aria-expanded is a
|
|
1936
|
-
// strong signal that the control commits via keyboard semantics.
|
|
1937
|
-
if (cur.getAttribute('role') === 'combobox' && cur.hasAttribute('aria-expanded')) {
|
|
1938
|
-
return true;
|
|
1939
|
-
}
|
|
1940
|
-
cur = cur.parentElement;
|
|
1941
|
-
depth++;
|
|
1942
|
-
}
|
|
1943
|
-
// Also look downward for a descendant input that declares
|
|
1944
|
-
// aria-autocomplete — some Headless UI wrappers expose the control via
|
|
1945
|
-
// a sibling input inside the trigger container.
|
|
1946
|
-
const descendant = el.querySelector?.('[aria-autocomplete="list"], [aria-autocomplete="both"]');
|
|
1947
|
-
return !!descendant;
|
|
1948
|
-
});
|
|
1949
|
-
}
|
|
1950
|
-
catch {
|
|
1951
|
-
return false;
|
|
1952
|
-
}
|
|
1953
|
-
}
|
|
1954
|
-
/**
|
|
1955
|
-
* Dispatch a keyboard `Enter` to commit an autocomplete-style combobox's
|
|
1956
|
-
* selection after the option has been clicked. Only fires if the trigger
|
|
1957
|
-
* looks like a searchable combobox per {@link isAutocompleteCombobox}.
|
|
1958
|
-
*
|
|
1959
|
-
* Why this exists: Playwright's `.click()` on a React Select option DOM
|
|
1960
|
-
* element dispatches a synthetic `pointerdown`/`mouseup`/`click` sequence
|
|
1961
|
-
* that a subset of React Select forks (and every Remix-wrapped Greenhouse
|
|
1962
|
-
* ATS build we've seen) will NOT commit through `selectOption`. The
|
|
1963
|
-
* library's `keydown Enter` handler, however, unconditionally commits via
|
|
1964
|
-
* the same internal `setValue` the visual selection uses. Pressing Enter
|
|
1965
|
-
* after the click is therefore a universal "force commit" primitive for
|
|
1966
|
-
* searchable comboboxes that benefit every library without touching the
|
|
1967
|
-
* native-<select> happy path.
|
|
1968
|
-
*
|
|
1969
|
-
* Intentionally swallows errors — this is a best-effort commit helper and
|
|
1970
|
-
* a missing page/focus should not break the surrounding selection flow.
|
|
1971
|
-
*/
|
|
1972
|
-
async function pressEnterToCommitListbox(page, handle) {
|
|
1973
|
-
if (!handle)
|
|
1974
|
-
return false;
|
|
1975
|
-
if (!(await isAutocompleteCombobox(handle)))
|
|
1976
|
-
return false;
|
|
1977
|
-
try {
|
|
1978
|
-
await page.keyboard.press('Enter');
|
|
1979
|
-
// Short settle window for the library to run its onChange -> form
|
|
1980
|
-
// state update. 80ms is the same budget used elsewhere in this file
|
|
1981
|
-
// for React Select commit propagation.
|
|
1982
|
-
await delay(80);
|
|
1983
|
-
return true;
|
|
1984
|
-
}
|
|
1985
|
-
catch {
|
|
1986
|
-
return false;
|
|
1987
|
-
}
|
|
1988
|
-
}
|
|
1989
2028
|
/**
|
|
1990
2029
|
* After a custom listbox option click, make the committed value visible to
|
|
1991
2030
|
* framework state managers that listen on the backing input/select instead of
|
|
@@ -2729,23 +2768,33 @@ async function findLabeledFileInput(frame, fieldLabel, exact) {
|
|
|
2729
2768
|
}, { fieldLabel, exact });
|
|
2730
2769
|
return bestIndex >= 0 ? loc.nth(bestIndex) : null;
|
|
2731
2770
|
}
|
|
2732
|
-
async function findLabeledFileInputInPage(page, fieldLabel, exact, cache, fieldId) {
|
|
2733
|
-
const cached = readCachedLocator(cache, 'file', fieldLabel, exact, fieldId);
|
|
2771
|
+
async function findLabeledFileInputInPage(page, fieldLabel, exact, cache, fieldId, fieldKey) {
|
|
2772
|
+
const cached = await readCachedLocator(cache, 'file', fieldLabel, exact, fieldId, fieldKey);
|
|
2734
2773
|
if (cached !== undefined)
|
|
2735
2774
|
return cached;
|
|
2736
|
-
|
|
2737
|
-
const
|
|
2738
|
-
if (
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2775
|
+
if (fieldKey) {
|
|
2776
|
+
const authored = await findAuthoredFieldByKeyInPage(page, fieldKey, 'file');
|
|
2777
|
+
if (authored) {
|
|
2778
|
+
writeCachedLocator(cache, 'file', fieldLabel, exact, fieldId, fieldKey, authored);
|
|
2779
|
+
return authored;
|
|
2780
|
+
}
|
|
2781
|
+
throw new Error(`fieldKey "${fieldKey}" did not resolve to a file input; refresh geometra_form_schema`);
|
|
2742
2782
|
}
|
|
2743
|
-
|
|
2783
|
+
if (fieldLabel) {
|
|
2784
|
+
for (const frame of page.frames()) {
|
|
2785
|
+
const locator = await findLabeledFileInput(frame, fieldLabel, exact);
|
|
2786
|
+
if (!locator)
|
|
2787
|
+
continue;
|
|
2788
|
+
writeCachedLocator(cache, 'file', fieldLabel, exact, fieldId, undefined, locator);
|
|
2789
|
+
return locator;
|
|
2790
|
+
}
|
|
2791
|
+
}
|
|
2792
|
+
writeCachedLocator(cache, 'file', fieldLabel, exact, fieldId, undefined, null);
|
|
2744
2793
|
return null;
|
|
2745
2794
|
}
|
|
2746
2795
|
async function attachHiddenInAllFrames(page, paths, opts) {
|
|
2747
|
-
if (opts?.fieldLabel) {
|
|
2748
|
-
const labeled = await findLabeledFileInputInPage(page, opts.fieldLabel, opts.exact ?? false, opts.cache, opts.fieldId);
|
|
2796
|
+
if (opts?.fieldLabel || opts?.fieldKey) {
|
|
2797
|
+
const labeled = await findLabeledFileInputInPage(page, opts.fieldLabel ?? '', opts.exact ?? false, opts.cache, opts.fieldId, opts.fieldKey);
|
|
2749
2798
|
if (labeled) {
|
|
2750
2799
|
try {
|
|
2751
2800
|
await labeled.setInputFiles(paths);
|
|
@@ -2978,7 +3027,13 @@ export async function attachFiles(page, paths, opts) {
|
|
|
2978
3027
|
return;
|
|
2979
3028
|
}
|
|
2980
3029
|
if (strategy === 'hidden' || strategy === 'auto') {
|
|
2981
|
-
if (await attachHiddenInAllFrames(page, paths, {
|
|
3030
|
+
if (await attachHiddenInAllFrames(page, paths, {
|
|
3031
|
+
fieldId: opts?.fieldId,
|
|
3032
|
+
fieldKey: opts?.fieldKey,
|
|
3033
|
+
fieldLabel,
|
|
3034
|
+
exact,
|
|
3035
|
+
cache: opts?.cache,
|
|
3036
|
+
}))
|
|
2982
3037
|
return;
|
|
2983
3038
|
if (strategy === 'hidden') {
|
|
2984
3039
|
if (fieldLabel) {
|
|
@@ -3022,10 +3077,18 @@ export async function attachFiles(page, paths, opts) {
|
|
|
3022
3077
|
}
|
|
3023
3078
|
throw new Error('file: no input[type=file] in any frame; pass x,y (chooser), dropX/dropY (drop), or strategy hidden');
|
|
3024
3079
|
}
|
|
3025
|
-
async function findLabeledEditableField(page, fieldLabel, exact, cache, fieldId) {
|
|
3026
|
-
const cached = readCachedLocator(cache, 'editable', fieldLabel, exact, fieldId);
|
|
3080
|
+
async function findLabeledEditableField(page, fieldLabel, exact, cache, fieldId, fieldKey) {
|
|
3081
|
+
const cached = await readCachedLocator(cache, 'editable', fieldLabel, exact, fieldId, fieldKey);
|
|
3027
3082
|
if (cached !== undefined)
|
|
3028
3083
|
return cached;
|
|
3084
|
+
if (fieldKey) {
|
|
3085
|
+
const authored = await findAuthoredFieldByKeyInPage(page, fieldKey, 'editable');
|
|
3086
|
+
if (authored) {
|
|
3087
|
+
writeCachedLocator(cache, 'editable', fieldLabel, exact, fieldId, fieldKey, authored);
|
|
3088
|
+
return authored;
|
|
3089
|
+
}
|
|
3090
|
+
throw new Error(`fieldKey "${fieldKey}" did not resolve to a visible editable control; refresh geometra_form_schema`);
|
|
3091
|
+
}
|
|
3029
3092
|
for (const frame of page.frames()) {
|
|
3030
3093
|
// Always try exact-match candidates first, even when the caller passed
|
|
3031
3094
|
// exact=false. Same Greenhouse-style failure as findLabeledControl: a
|
|
@@ -3045,7 +3108,7 @@ async function findLabeledEditableField(page, fieldLabel, exact, cache, fieldId)
|
|
|
3045
3108
|
if (!visible)
|
|
3046
3109
|
continue;
|
|
3047
3110
|
if (await locatorIsEditable(visible)) {
|
|
3048
|
-
writeCachedLocator(cache, 'editable', fieldLabel, exact, fieldId, visible);
|
|
3111
|
+
writeCachedLocator(cache, 'editable', fieldLabel, exact, fieldId, undefined, visible);
|
|
3049
3112
|
return visible;
|
|
3050
3113
|
}
|
|
3051
3114
|
}
|
|
@@ -3061,14 +3124,14 @@ async function findLabeledEditableField(page, fieldLabel, exact, cache, fieldId)
|
|
|
3061
3124
|
if (!visible)
|
|
3062
3125
|
continue;
|
|
3063
3126
|
if (await locatorIsEditable(visible)) {
|
|
3064
|
-
writeCachedLocator(cache, 'editable', fieldLabel, exact, fieldId, visible);
|
|
3127
|
+
writeCachedLocator(cache, 'editable', fieldLabel, exact, fieldId, undefined, visible);
|
|
3065
3128
|
return visible;
|
|
3066
3129
|
}
|
|
3067
3130
|
}
|
|
3068
3131
|
}
|
|
3069
3132
|
const fallback = await findLabeledControl(frame, fieldLabel, exact);
|
|
3070
3133
|
if (fallback && await locatorIsEditable(fallback)) {
|
|
3071
|
-
writeCachedLocator(cache, 'editable', fieldLabel, exact, fieldId, fallback);
|
|
3134
|
+
writeCachedLocator(cache, 'editable', fieldLabel, exact, fieldId, undefined, fallback);
|
|
3072
3135
|
return fallback;
|
|
3073
3136
|
}
|
|
3074
3137
|
}
|
|
@@ -3091,18 +3154,18 @@ async function findLabeledEditableField(page, fieldLabel, exact, cache, fieldId)
|
|
|
3091
3154
|
if (!visible)
|
|
3092
3155
|
continue;
|
|
3093
3156
|
if (await locatorIsEditable(visible)) {
|
|
3094
|
-
writeCachedLocator(cache, 'editable', fieldLabel, exact, fieldId, visible);
|
|
3157
|
+
writeCachedLocator(cache, 'editable', fieldLabel, exact, fieldId, undefined, visible);
|
|
3095
3158
|
return visible;
|
|
3096
3159
|
}
|
|
3097
3160
|
}
|
|
3098
3161
|
const fallback = await findLabeledControl(frame, stripped, exact);
|
|
3099
3162
|
if (fallback && await locatorIsEditable(fallback)) {
|
|
3100
|
-
writeCachedLocator(cache, 'editable', fieldLabel, exact, fieldId, fallback);
|
|
3163
|
+
writeCachedLocator(cache, 'editable', fieldLabel, exact, fieldId, undefined, fallback);
|
|
3101
3164
|
return fallback;
|
|
3102
3165
|
}
|
|
3103
3166
|
}
|
|
3104
3167
|
}
|
|
3105
|
-
writeCachedLocator(cache, 'editable', fieldLabel, exact, fieldId, null);
|
|
3168
|
+
writeCachedLocator(cache, 'editable', fieldLabel, exact, fieldId, undefined, null);
|
|
3106
3169
|
return null;
|
|
3107
3170
|
}
|
|
3108
3171
|
async function locatorCurrentValue(locator) {
|
|
@@ -3267,6 +3330,11 @@ async function attemptNativeBatchFill(page, fields) {
|
|
|
3267
3330
|
if (results[index])
|
|
3268
3331
|
continue;
|
|
3269
3332
|
const field = fields[index];
|
|
3333
|
+
// Authored field keys are global, exact identities. The per-frame
|
|
3334
|
+
// native batch path only knows labels, so let the normal resolvers scan
|
|
3335
|
+
// all frames before committing a keyed action.
|
|
3336
|
+
if (field.fieldKey)
|
|
3337
|
+
continue;
|
|
3270
3338
|
if (field.kind === 'file')
|
|
3271
3339
|
continue;
|
|
3272
3340
|
if (field.kind === 'auto') {
|
|
@@ -3307,6 +3375,11 @@ async function attemptNativeBatchFill(page, fields) {
|
|
|
3307
3375
|
continue;
|
|
3308
3376
|
}
|
|
3309
3377
|
if (field.kind === 'choice') {
|
|
3378
|
+
// An option index is an exact schema identity. The label-only native
|
|
3379
|
+
// batch path cannot preserve or validate it, so route this field
|
|
3380
|
+
// through setFieldChoice below.
|
|
3381
|
+
if (field.optionIndex !== undefined)
|
|
3382
|
+
continue;
|
|
3310
3383
|
pending.push({
|
|
3311
3384
|
index,
|
|
3312
3385
|
kind: 'choice',
|
|
@@ -3317,14 +3390,9 @@ async function attemptNativeBatchFill(page, fields) {
|
|
|
3317
3390
|
});
|
|
3318
3391
|
continue;
|
|
3319
3392
|
}
|
|
3320
|
-
|
|
3321
|
-
|
|
3322
|
-
|
|
3323
|
-
label: field.label,
|
|
3324
|
-
checked: field.checked ?? true,
|
|
3325
|
-
exact: field.exact ?? false,
|
|
3326
|
-
controlType: field.controlType ?? null,
|
|
3327
|
-
});
|
|
3393
|
+
// Toggle resolution must collect every candidate before committing so
|
|
3394
|
+
// duplicate labels are rejected instead of silently selecting the first.
|
|
3395
|
+
continue;
|
|
3328
3396
|
}
|
|
3329
3397
|
if (pending.length === 0)
|
|
3330
3398
|
break;
|
|
@@ -4230,7 +4298,7 @@ export async function setFieldText(page, fieldLabel, value, opts) {
|
|
|
4230
4298
|
// from fillOtp propagates out of setFieldText unchanged: silent fallback
|
|
4231
4299
|
// to the plain text-fill path would write the whole string into box 0
|
|
4232
4300
|
// and pretend success, which is the bug we're fixing.
|
|
4233
|
-
if (labelLooksLikeOtp(fieldLabel) && /^\S{4,}$/.test(value)) {
|
|
4301
|
+
if (!opts?.fieldKey && labelLooksLikeOtp(fieldLabel) && /^\S{4,}$/.test(value)) {
|
|
4234
4302
|
const group = await findOtpBoxGroup(page, fieldLabel, value.length);
|
|
4235
4303
|
if (group) {
|
|
4236
4304
|
await fillOtp(page, value, { fieldLabel });
|
|
@@ -4238,7 +4306,7 @@ export async function setFieldText(page, fieldLabel, value, opts) {
|
|
|
4238
4306
|
}
|
|
4239
4307
|
}
|
|
4240
4308
|
const exact = opts?.exact ?? false;
|
|
4241
|
-
const locator = await findLabeledEditableField(page, fieldLabel, exact, opts?.cache, opts?.fieldId);
|
|
4309
|
+
const locator = await findLabeledEditableField(page, fieldLabel, exact, opts?.cache, opts?.fieldId, opts?.fieldKey);
|
|
4242
4310
|
if (!locator) {
|
|
4243
4311
|
throw new Error(`setFieldText: no visible editable field matching "${fieldLabel}"`);
|
|
4244
4312
|
}
|
|
@@ -4271,31 +4339,41 @@ export async function setFieldText(page, fieldLabel, value, opts) {
|
|
|
4271
4339
|
return;
|
|
4272
4340
|
throw new Error(`setFieldText: set "${fieldLabel}" but could not confirm value ${JSON.stringify(value)}`);
|
|
4273
4341
|
}
|
|
4274
|
-
async function setNativeSelectByLabel(locator, value, exact) {
|
|
4342
|
+
async function setNativeSelectByLabel(locator, value, exact, optionIndex) {
|
|
4275
4343
|
try {
|
|
4276
4344
|
return await locator.evaluate((el, payload) => {
|
|
4277
4345
|
if (!(el instanceof HTMLSelectElement))
|
|
4278
4346
|
return false;
|
|
4279
4347
|
const normalize = (input) => input?.replace(/\s+/g, ' ').trim().toLowerCase() ?? '';
|
|
4280
4348
|
const expected = normalize(payload.value);
|
|
4281
|
-
|
|
4282
|
-
|
|
4283
|
-
|
|
4284
|
-
|
|
4349
|
+
const enabled = (candidate) => !candidate.disabled && !(candidate.parentElement instanceof HTMLOptGroupElement && candidate.parentElement.disabled);
|
|
4350
|
+
let option;
|
|
4351
|
+
if (payload.optionIndex !== undefined) {
|
|
4352
|
+
const indexed = el.options.item(payload.optionIndex) ?? undefined;
|
|
4353
|
+
if (!indexed || !enabled(indexed) || indexed.value !== payload.value)
|
|
4285
4354
|
return false;
|
|
4286
|
-
|
|
4287
|
-
|
|
4288
|
-
|
|
4289
|
-
|
|
4290
|
-
|
|
4291
|
-
|
|
4355
|
+
option = indexed;
|
|
4356
|
+
}
|
|
4357
|
+
else {
|
|
4358
|
+
const options = Array.from(el.options).filter(enabled);
|
|
4359
|
+
// Values are the submitted identity and take precedence over labels.
|
|
4360
|
+
// This prevents an earlier option whose label happens to equal a
|
|
4361
|
+
// different option's value from being selected silently.
|
|
4362
|
+
option = options.find(candidate => normalize(candidate.value) === expected);
|
|
4363
|
+
if (!option)
|
|
4364
|
+
option = options.find(candidate => normalize(candidate.textContent) === expected);
|
|
4365
|
+
if (!option && !payload.exact && expected) {
|
|
4366
|
+
option = options.find(candidate => normalize(candidate.value).includes(expected))
|
|
4367
|
+
?? options.find(candidate => normalize(candidate.textContent).includes(expected));
|
|
4368
|
+
}
|
|
4369
|
+
}
|
|
4292
4370
|
if (!option)
|
|
4293
4371
|
return false;
|
|
4294
|
-
el.
|
|
4372
|
+
el.selectedIndex = option.index;
|
|
4295
4373
|
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
4296
4374
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
4297
4375
|
return true;
|
|
4298
|
-
}, { value, exact });
|
|
4376
|
+
}, { value, exact, optionIndex });
|
|
4299
4377
|
}
|
|
4300
4378
|
catch {
|
|
4301
4379
|
return false;
|
|
@@ -4553,20 +4631,29 @@ async function autoFieldLocatorKind(locator) {
|
|
|
4553
4631
|
export async function setAutoFieldValue(page, fieldLabel, value, opts) {
|
|
4554
4632
|
const exact = opts?.exact ?? false;
|
|
4555
4633
|
if (typeof value === 'boolean') {
|
|
4634
|
+
if (opts?.fieldKey) {
|
|
4635
|
+
await setCheckedControl(page, fieldLabel, { fieldKey: opts.fieldKey, checked: value, exact });
|
|
4636
|
+
return;
|
|
4637
|
+
}
|
|
4556
4638
|
if (await chooseValueFromLabeledGroup(page, fieldLabel, value ? 'Yes' : 'No', exact))
|
|
4557
4639
|
return;
|
|
4558
|
-
await setCheckedControl(page, fieldLabel, { checked: value, exact });
|
|
4640
|
+
await setCheckedControl(page, fieldLabel, { fieldKey: opts?.fieldKey, checked: value, exact });
|
|
4559
4641
|
return;
|
|
4560
4642
|
}
|
|
4561
|
-
if (prefersGroupedChoiceValue(value) && await chooseValueFromLabeledGroup(page, fieldLabel, value, exact)) {
|
|
4643
|
+
if (!opts?.fieldKey && prefersGroupedChoiceValue(value) && await chooseValueFromLabeledGroup(page, fieldLabel, value, exact)) {
|
|
4562
4644
|
return;
|
|
4563
4645
|
}
|
|
4564
|
-
const locator = await findLabeledControlInPage(page, fieldLabel, exact, {
|
|
4646
|
+
const locator = await findLabeledControlInPage(page, fieldLabel, exact, {
|
|
4647
|
+
cache: opts?.cache,
|
|
4648
|
+
fieldId: opts?.fieldId,
|
|
4649
|
+
fieldKey: opts?.fieldKey,
|
|
4650
|
+
});
|
|
4565
4651
|
if (locator) {
|
|
4566
4652
|
const kind = await autoFieldLocatorKind(locator);
|
|
4567
4653
|
if (kind === 'select') {
|
|
4568
4654
|
await setFieldChoice(page, fieldLabel, value, {
|
|
4569
4655
|
fieldId: opts?.fieldId,
|
|
4656
|
+
fieldKey: opts?.fieldKey,
|
|
4570
4657
|
exact,
|
|
4571
4658
|
choiceType: 'select',
|
|
4572
4659
|
cache: opts?.cache,
|
|
@@ -4576,6 +4663,7 @@ export async function setAutoFieldValue(page, fieldLabel, value, opts) {
|
|
|
4576
4663
|
if (kind === 'text') {
|
|
4577
4664
|
await setFieldText(page, fieldLabel, value, {
|
|
4578
4665
|
fieldId: opts?.fieldId,
|
|
4666
|
+
fieldKey: opts?.fieldKey,
|
|
4579
4667
|
exact,
|
|
4580
4668
|
cache: opts?.cache,
|
|
4581
4669
|
});
|
|
@@ -4584,17 +4672,22 @@ export async function setAutoFieldValue(page, fieldLabel, value, opts) {
|
|
|
4584
4672
|
if (kind === 'choice') {
|
|
4585
4673
|
await setFieldChoice(page, fieldLabel, value, {
|
|
4586
4674
|
fieldId: opts?.fieldId,
|
|
4675
|
+
fieldKey: opts?.fieldKey,
|
|
4587
4676
|
exact,
|
|
4588
4677
|
cache: opts?.cache,
|
|
4589
4678
|
});
|
|
4590
4679
|
return;
|
|
4591
4680
|
}
|
|
4592
4681
|
}
|
|
4682
|
+
if (opts?.fieldKey) {
|
|
4683
|
+
throw new Error(`fieldKey "${opts.fieldKey}" resolved to an unsupported control for field "${fieldLabel}"`);
|
|
4684
|
+
}
|
|
4593
4685
|
if (await chooseValueFromLabeledGroup(page, fieldLabel, value, exact))
|
|
4594
4686
|
return;
|
|
4595
4687
|
try {
|
|
4596
4688
|
await setFieldText(page, fieldLabel, value, {
|
|
4597
4689
|
fieldId: opts?.fieldId,
|
|
4690
|
+
fieldKey: opts?.fieldKey,
|
|
4598
4691
|
exact,
|
|
4599
4692
|
cache: opts?.cache,
|
|
4600
4693
|
});
|
|
@@ -4603,6 +4696,7 @@ export async function setAutoFieldValue(page, fieldLabel, value, opts) {
|
|
|
4603
4696
|
catch {
|
|
4604
4697
|
await setFieldChoice(page, fieldLabel, value, {
|
|
4605
4698
|
fieldId: opts?.fieldId,
|
|
4699
|
+
fieldKey: opts?.fieldKey,
|
|
4606
4700
|
exact,
|
|
4607
4701
|
cache: opts?.cache,
|
|
4608
4702
|
});
|
|
@@ -4610,11 +4704,21 @@ export async function setAutoFieldValue(page, fieldLabel, value, opts) {
|
|
|
4610
4704
|
}
|
|
4611
4705
|
export async function setFieldChoice(page, fieldLabel, value, opts) {
|
|
4612
4706
|
const exact = opts?.exact ?? false;
|
|
4613
|
-
const locator = await findLabeledControlInPage(page, fieldLabel, exact, {
|
|
4707
|
+
const locator = await findLabeledControlInPage(page, fieldLabel, exact, {
|
|
4708
|
+
cache: opts?.cache,
|
|
4709
|
+
fieldId: opts?.fieldId,
|
|
4710
|
+
fieldKey: opts?.fieldKey,
|
|
4711
|
+
});
|
|
4614
4712
|
if (locator) {
|
|
4615
4713
|
await locator.scrollIntoViewIfNeeded();
|
|
4616
4714
|
const isNativeSelect = await locator.evaluate(el => el instanceof HTMLSelectElement);
|
|
4617
|
-
if (await setNativeSelectByLabel(locator, value, exact)) {
|
|
4715
|
+
if (await setNativeSelectByLabel(locator, value, exact, opts?.optionIndex)) {
|
|
4716
|
+
if (opts?.optionIndex !== undefined) {
|
|
4717
|
+
const selectedIndex = await locator.evaluate(el => el instanceof HTMLSelectElement ? el.selectedIndex : -1);
|
|
4718
|
+
if (selectedIndex === opts.optionIndex)
|
|
4719
|
+
return;
|
|
4720
|
+
throw new Error(`setFieldChoice: selected native option index ${opts.optionIndex} for field "${fieldLabel}" but could not confirm it`);
|
|
4721
|
+
}
|
|
4618
4722
|
const displayed = await locatorDisplayedValues(locator);
|
|
4619
4723
|
if (displayed.some(candidate => displayedValueMatchesSelection(candidate, value, exact)))
|
|
4620
4724
|
return;
|
|
@@ -4633,6 +4737,7 @@ export async function setFieldChoice(page, fieldLabel, value, opts) {
|
|
|
4633
4737
|
try {
|
|
4634
4738
|
await pickListboxOption(page, value, {
|
|
4635
4739
|
fieldId: opts?.fieldId,
|
|
4740
|
+
fieldKey: opts?.fieldKey,
|
|
4636
4741
|
fieldLabel,
|
|
4637
4742
|
exact,
|
|
4638
4743
|
query: opts?.query,
|
|
@@ -4678,6 +4783,7 @@ export async function fillFields(page, fields, cache = createFillLookupCache())
|
|
|
4678
4783
|
if (textFills.length > 0) {
|
|
4679
4784
|
const results = await Promise.allSettled(textFills.map(({ field }) => setFieldText(page, field.fieldLabel, field.value, {
|
|
4680
4785
|
fieldId: field.fieldId,
|
|
4786
|
+
fieldKey: field.fieldKey,
|
|
4681
4787
|
exact: field.exact,
|
|
4682
4788
|
cache,
|
|
4683
4789
|
typingDelayMs: field.typingDelayMs,
|
|
@@ -4708,6 +4814,7 @@ export async function fillFields(page, fields, cache = createFillLookupCache())
|
|
|
4708
4814
|
if (field.kind === 'auto') {
|
|
4709
4815
|
await setAutoFieldValue(page, field.fieldLabel, field.value, {
|
|
4710
4816
|
fieldId: field.fieldId,
|
|
4817
|
+
fieldKey: field.fieldKey,
|
|
4711
4818
|
exact: field.exact,
|
|
4712
4819
|
cache,
|
|
4713
4820
|
});
|
|
@@ -4716,7 +4823,9 @@ export async function fillFields(page, fields, cache = createFillLookupCache())
|
|
|
4716
4823
|
if (field.kind === 'choice') {
|
|
4717
4824
|
await setFieldChoice(page, field.fieldLabel, field.value, {
|
|
4718
4825
|
fieldId: field.fieldId,
|
|
4826
|
+
fieldKey: field.fieldKey,
|
|
4719
4827
|
exact: field.exact,
|
|
4828
|
+
optionIndex: field.optionIndex,
|
|
4720
4829
|
query: field.query,
|
|
4721
4830
|
choiceType: field.choiceType,
|
|
4722
4831
|
cache,
|
|
@@ -4725,14 +4834,23 @@ export async function fillFields(page, fields, cache = createFillLookupCache())
|
|
|
4725
4834
|
}
|
|
4726
4835
|
if (field.kind === 'toggle') {
|
|
4727
4836
|
await setCheckedControl(page, field.label, {
|
|
4837
|
+
fieldKey: field.fieldKey,
|
|
4728
4838
|
checked: field.checked,
|
|
4729
4839
|
exact: field.exact,
|
|
4730
4840
|
controlType: field.controlType,
|
|
4841
|
+
contextText: field.contextText,
|
|
4842
|
+
sectionText: field.sectionText,
|
|
4731
4843
|
});
|
|
4732
4844
|
continue;
|
|
4733
4845
|
}
|
|
4734
4846
|
if (field.kind === 'file') {
|
|
4735
|
-
await attachFiles(page, field.paths, {
|
|
4847
|
+
await attachFiles(page, field.paths, {
|
|
4848
|
+
fieldId: field.fieldId,
|
|
4849
|
+
fieldKey: field.fieldKey,
|
|
4850
|
+
fieldLabel: field.fieldLabel,
|
|
4851
|
+
exact: field.exact,
|
|
4852
|
+
cache,
|
|
4853
|
+
});
|
|
4736
4854
|
}
|
|
4737
4855
|
}
|
|
4738
4856
|
catch (e) {
|
|
@@ -4773,7 +4891,7 @@ export async function selectNativeOption(page, x, y, opt) {
|
|
|
4773
4891
|
const applied = await frame.evaluate((payload) => {
|
|
4774
4892
|
const normalize = (input) => input?.replace(/\s+/g, ' ').trim().toLowerCase() ?? '';
|
|
4775
4893
|
const optionMatchesExpected = (o, expected) => {
|
|
4776
|
-
if (o.disabled)
|
|
4894
|
+
if (o.disabled || (o.parentElement instanceof HTMLOptGroupElement && o.parentElement.disabled))
|
|
4777
4895
|
return false;
|
|
4778
4896
|
const label = normalize(o.textContent);
|
|
4779
4897
|
const rawValue = normalize(o.value);
|
|
@@ -4787,7 +4905,12 @@ export async function selectNativeOption(page, x, y, opt) {
|
|
|
4787
4905
|
const i = Math.trunc(payload.index);
|
|
4788
4906
|
if (i < 0 || i >= sel.options.length)
|
|
4789
4907
|
return false;
|
|
4790
|
-
|
|
4908
|
+
const indexed = sel.options[i];
|
|
4909
|
+
if (indexed.disabled || (indexed.parentElement instanceof HTMLOptGroupElement && indexed.parentElement.disabled))
|
|
4910
|
+
return false;
|
|
4911
|
+
if (payload.value !== null && normalize(indexed.value) !== normalize(payload.value))
|
|
4912
|
+
return false;
|
|
4913
|
+
if (payload.label !== null && !normalize(indexed.textContent).includes(normalize(payload.label)))
|
|
4791
4914
|
return false;
|
|
4792
4915
|
sel.selectedIndex = i;
|
|
4793
4916
|
}
|
|
@@ -4800,6 +4923,7 @@ export async function selectNativeOption(page, x, y, opt) {
|
|
|
4800
4923
|
const selected = sel.selectedOptions[0];
|
|
4801
4924
|
const ok = selected &&
|
|
4802
4925
|
!selected.disabled &&
|
|
4926
|
+
!(selected.parentElement instanceof HTMLOptGroupElement && selected.parentElement.disabled) &&
|
|
4803
4927
|
(normalize(selected.value) === expected ||
|
|
4804
4928
|
normalize(selected.textContent) === expected ||
|
|
4805
4929
|
normalize(selected.value).includes(expected) ||
|
|
@@ -4808,7 +4932,7 @@ export async function selectNativeOption(page, x, y, opt) {
|
|
|
4808
4932
|
const found = Array.from(sel.options).find(o => optionMatchesExpected(o, expected));
|
|
4809
4933
|
if (!found)
|
|
4810
4934
|
return false;
|
|
4811
|
-
sel.
|
|
4935
|
+
sel.selectedIndex = found.index;
|
|
4812
4936
|
}
|
|
4813
4937
|
}
|
|
4814
4938
|
else if (payload.label !== null && payload.label !== undefined) {
|
|
@@ -4818,7 +4942,7 @@ export async function selectNativeOption(page, x, y, opt) {
|
|
|
4818
4942
|
const optEl = Array.from(sel.options).find(o => optionMatchesExpected(o, expected));
|
|
4819
4943
|
if (!optEl)
|
|
4820
4944
|
return false;
|
|
4821
|
-
sel.
|
|
4945
|
+
sel.selectedIndex = optEl.index;
|
|
4822
4946
|
}
|
|
4823
4947
|
else {
|
|
4824
4948
|
return false;
|
|
@@ -4870,7 +4994,7 @@ export async function pickListboxOption(page, label, opts) {
|
|
|
4870
4994
|
if (opts?.fieldLabel) {
|
|
4871
4995
|
let opened;
|
|
4872
4996
|
try {
|
|
4873
|
-
opened = await openDropdownControl(page, opts.fieldLabel, exact, opts.cache, opts.fieldId);
|
|
4997
|
+
opened = await openDropdownControl(page, opts.fieldLabel, exact, opts.cache, opts.fieldId, opts.fieldKey);
|
|
4874
4998
|
}
|
|
4875
4999
|
catch {
|
|
4876
5000
|
throw new Error(listboxErrorMessage({
|
|
@@ -4905,15 +5029,12 @@ export async function pickListboxOption(page, label, opts) {
|
|
|
4905
5029
|
if (!selectedOptionText)
|
|
4906
5030
|
return false;
|
|
4907
5031
|
attemptedSelection = true;
|
|
4908
|
-
//
|
|
4909
|
-
//
|
|
4910
|
-
//
|
|
4911
|
-
//
|
|
4912
|
-
// the
|
|
4913
|
-
//
|
|
4914
|
-
// commit path, which ALL tested libraries honor. No-op for native
|
|
4915
|
-
// <select> / plain ARIA listboxes — see isAutocompleteCombobox.
|
|
4916
|
-
await pressEnterToCommitListbox(page, openedHandle);
|
|
5032
|
+
// Do not press Enter after a successful pointer selection. Both
|
|
5033
|
+
// react-select and non-editable Radix triggers can interpret Enter on
|
|
5034
|
+
// the now-closed control as "open again", leaving several adjacent
|
|
5035
|
+
// dropdowns expanded and making later shared-label picks hit the wrong
|
|
5036
|
+
// popup. If the pointer click did not commit, the keyboard fallback
|
|
5037
|
+
// below deliberately reopens this exact control and selects by Enter.
|
|
4917
5038
|
await dispatchListboxCommitEvents(openedHandle);
|
|
4918
5039
|
if (!opts?.fieldLabel ||
|
|
4919
5040
|
await confirmListboxSelection(page, opts.fieldLabel, label, exact, anchor, openedHandle, selectedOptionText, {
|
|
@@ -5060,57 +5181,113 @@ export async function pickListboxOption(page, label, opts) {
|
|
|
5060
5181
|
await releasePopupScope();
|
|
5061
5182
|
}
|
|
5062
5183
|
}
|
|
5063
|
-
|
|
5064
|
-
|
|
5065
|
-
|
|
5066
|
-
|
|
5067
|
-
|
|
5068
|
-
|
|
5069
|
-
|
|
5070
|
-
|
|
5184
|
+
async function applyToggleState(locator, desiredChecked) {
|
|
5185
|
+
return await locator.evaluate((target, checked) => {
|
|
5186
|
+
function referencedText(ids, visited) {
|
|
5187
|
+
if (!ids)
|
|
5188
|
+
return undefined;
|
|
5189
|
+
const seen = visited ?? new Set();
|
|
5190
|
+
const text = ids.split(/\s+/).map(id => {
|
|
5191
|
+
if (seen.has(id))
|
|
5192
|
+
return '';
|
|
5193
|
+
seen.add(id);
|
|
5194
|
+
const node = document.getElementById(id);
|
|
5195
|
+
if (!node)
|
|
5196
|
+
return '';
|
|
5197
|
+
return referencedText(node.getAttribute('aria-labelledby'), seen) ?? node.textContent?.trim() ?? '';
|
|
5198
|
+
}).filter(Boolean).join(' ').trim();
|
|
5199
|
+
return text || undefined;
|
|
5200
|
+
}
|
|
5201
|
+
function kindOf(el) {
|
|
5202
|
+
if (el instanceof HTMLInputElement && el.type === 'radio')
|
|
5203
|
+
return 'radio';
|
|
5204
|
+
return el.getAttribute('role') === 'radio' ? 'radio' : 'checkbox';
|
|
5205
|
+
}
|
|
5206
|
+
function nameOf(el) {
|
|
5207
|
+
const aria = el.getAttribute('aria-label')?.trim();
|
|
5208
|
+
if (aria)
|
|
5209
|
+
return aria;
|
|
5210
|
+
const labelledBy = referencedText(el.getAttribute('aria-labelledby'));
|
|
5211
|
+
if (labelledBy)
|
|
5212
|
+
return labelledBy;
|
|
5213
|
+
if (el instanceof HTMLInputElement) {
|
|
5214
|
+
const labelText = Array.from(el.labels ?? []).map(label => label.textContent?.trim()).find(Boolean);
|
|
5215
|
+
if (labelText)
|
|
5216
|
+
return labelText;
|
|
5217
|
+
}
|
|
5218
|
+
return el.textContent?.trim() || '<unnamed>';
|
|
5219
|
+
}
|
|
5220
|
+
function readChecked(el) {
|
|
5221
|
+
return el instanceof HTMLInputElement ? el.checked : el.getAttribute('aria-checked') === 'true';
|
|
5222
|
+
}
|
|
5223
|
+
function dispatch(el) {
|
|
5224
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
5225
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
5226
|
+
}
|
|
5227
|
+
function setInputCheckedReactAware(input, next) {
|
|
5228
|
+
const tracker = input._valueTracker;
|
|
5229
|
+
if (tracker && typeof tracker.setValue === 'function') {
|
|
5230
|
+
try {
|
|
5231
|
+
tracker.setValue(String(!next));
|
|
5232
|
+
}
|
|
5233
|
+
catch { /* ignore */ }
|
|
5234
|
+
}
|
|
5235
|
+
const descriptor = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'checked');
|
|
5236
|
+
if (descriptor?.set)
|
|
5237
|
+
descriptor.set.call(input, next);
|
|
5238
|
+
else
|
|
5239
|
+
input.checked = next;
|
|
5240
|
+
dispatch(input);
|
|
5241
|
+
}
|
|
5242
|
+
const kind = kindOf(target);
|
|
5243
|
+
const name = nameOf(target);
|
|
5244
|
+
const before = readChecked(target);
|
|
5245
|
+
if (kind === 'radio' && checked === false) {
|
|
5246
|
+
return { success: before === false, reason: 'radio-uncheck', kind, name, before, after: before };
|
|
5247
|
+
}
|
|
5248
|
+
if (before !== checked) {
|
|
5249
|
+
if (target instanceof HTMLInputElement) {
|
|
5250
|
+
setInputCheckedReactAware(target, checked);
|
|
5251
|
+
}
|
|
5252
|
+
else if (target instanceof HTMLElement) {
|
|
5253
|
+
target.click();
|
|
5254
|
+
}
|
|
5255
|
+
}
|
|
5256
|
+
const after = readChecked(target);
|
|
5257
|
+
return { success: after === checked, kind, name, before, after };
|
|
5258
|
+
}, desiredChecked);
|
|
5259
|
+
}
|
|
5260
|
+
async function collectToggleMatches(page, label, exact, controlType, contextText, sectionText) {
|
|
5261
|
+
const matches = [];
|
|
5071
5262
|
for (const frame of page.frames()) {
|
|
5072
|
-
const
|
|
5263
|
+
const candidates = frame.locator(TOGGLE_CONTROL_SELECTOR);
|
|
5264
|
+
const metadata = await candidates.evaluateAll((elements, payload) => {
|
|
5073
5265
|
function normalize(value) {
|
|
5074
|
-
return value.replace(/\s+/g, ' ').trim().toLowerCase();
|
|
5075
|
-
}
|
|
5076
|
-
function matchesLabel(candidate) {
|
|
5077
|
-
if (!candidate)
|
|
5078
|
-
return false;
|
|
5079
|
-
const normalizedCandidate = normalize(candidate);
|
|
5080
|
-
const normalizedNeedle = normalize(payload.label);
|
|
5081
|
-
return payload.exact ? normalizedCandidate === normalizedNeedle : normalizedCandidate.includes(normalizedNeedle);
|
|
5266
|
+
return (value ?? '').replace(/\s+/g, ' ').trim().toLowerCase();
|
|
5082
5267
|
}
|
|
5083
5268
|
function visible(el) {
|
|
5084
|
-
|
|
5085
|
-
if (rect.width <= 0 || rect.height <= 0)
|
|
5269
|
+
if (!(el instanceof HTMLElement))
|
|
5086
5270
|
return false;
|
|
5271
|
+
const rect = el.getBoundingClientRect();
|
|
5087
5272
|
const style = getComputedStyle(el);
|
|
5088
|
-
return style.display !== 'none' && style.visibility !== 'hidden';
|
|
5273
|
+
return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden';
|
|
5089
5274
|
}
|
|
5090
5275
|
function referencedText(ids, visited) {
|
|
5091
5276
|
if (!ids)
|
|
5092
5277
|
return undefined;
|
|
5093
5278
|
const seen = visited ?? new Set();
|
|
5094
|
-
const text = ids
|
|
5095
|
-
.split(/\s+/)
|
|
5096
|
-
.map(id => {
|
|
5279
|
+
const text = ids.split(/\s+/).map(id => {
|
|
5097
5280
|
if (seen.has(id))
|
|
5098
5281
|
return '';
|
|
5099
5282
|
seen.add(id);
|
|
5100
|
-
const
|
|
5101
|
-
if (!
|
|
5283
|
+
const node = document.getElementById(id);
|
|
5284
|
+
if (!node)
|
|
5102
5285
|
return '';
|
|
5103
|
-
|
|
5104
|
-
|
|
5105
|
-
return referencedText(chained, seen) ?? '';
|
|
5106
|
-
return target.textContent?.trim() ?? '';
|
|
5107
|
-
})
|
|
5108
|
-
.filter(Boolean)
|
|
5109
|
-
.join(' ')
|
|
5110
|
-
.trim();
|
|
5286
|
+
return referencedText(node.getAttribute('aria-labelledby'), seen) ?? node.textContent?.trim() ?? '';
|
|
5287
|
+
}).filter(Boolean).join(' ').trim();
|
|
5111
5288
|
return text || undefined;
|
|
5112
5289
|
}
|
|
5113
|
-
function
|
|
5290
|
+
function kindOf(el) {
|
|
5114
5291
|
if (el instanceof HTMLInputElement) {
|
|
5115
5292
|
if (el.type === 'checkbox')
|
|
5116
5293
|
return 'checkbox';
|
|
@@ -5119,13 +5296,13 @@ export async function setCheckedControl(page, label, opts) {
|
|
|
5119
5296
|
return undefined;
|
|
5120
5297
|
}
|
|
5121
5298
|
const role = el.getAttribute('role');
|
|
5122
|
-
if (role === 'switch' || role === 'checkbox')
|
|
5123
|
-
return 'checkbox';
|
|
5124
5299
|
if (role === 'radio')
|
|
5125
5300
|
return 'radio';
|
|
5301
|
+
if (role === 'checkbox' || role === 'switch')
|
|
5302
|
+
return 'checkbox';
|
|
5126
5303
|
return undefined;
|
|
5127
5304
|
}
|
|
5128
|
-
function
|
|
5305
|
+
function nameOf(el) {
|
|
5129
5306
|
const aria = el.getAttribute('aria-label')?.trim();
|
|
5130
5307
|
if (aria)
|
|
5131
5308
|
return aria;
|
|
@@ -5133,106 +5310,122 @@ export async function setCheckedControl(page, label, opts) {
|
|
|
5133
5310
|
if (labelledBy)
|
|
5134
5311
|
return labelledBy;
|
|
5135
5312
|
if (el instanceof HTMLInputElement) {
|
|
5136
|
-
const
|
|
5137
|
-
|
|
5138
|
-
|
|
5139
|
-
|
|
5140
|
-
|
|
5141
|
-
|
|
5142
|
-
const nameAttr = el.getAttribute('name')?.trim();
|
|
5143
|
-
if (nameAttr && /[A-Za-z]/.test(nameAttr) && /[\s,./()_-]/.test(nameAttr))
|
|
5144
|
-
return nameAttr;
|
|
5145
|
-
}
|
|
5146
|
-
const text = el.textContent?.trim();
|
|
5147
|
-
return text || undefined;
|
|
5148
|
-
}
|
|
5149
|
-
function readChecked(el) {
|
|
5150
|
-
if (el instanceof HTMLInputElement)
|
|
5151
|
-
return !!el.checked;
|
|
5152
|
-
return el.getAttribute('aria-checked') === 'true';
|
|
5153
|
-
}
|
|
5154
|
-
function clickTarget(el) {
|
|
5155
|
-
if (el instanceof HTMLInputElement) {
|
|
5156
|
-
const labelEl = el.labels?.[0];
|
|
5157
|
-
if (labelEl instanceof HTMLElement)
|
|
5158
|
-
return labelEl;
|
|
5313
|
+
const labelText = Array.from(el.labels ?? []).map(label => label.textContent?.trim()).find(Boolean);
|
|
5314
|
+
if (labelText)
|
|
5315
|
+
return labelText;
|
|
5316
|
+
const name = el.getAttribute('name')?.trim();
|
|
5317
|
+
if (name && /[A-Za-z]/.test(name) && /[\s,./()_-]/.test(name))
|
|
5318
|
+
return name;
|
|
5159
5319
|
}
|
|
5160
|
-
return el
|
|
5161
|
-
}
|
|
5162
|
-
function dispatch(target) {
|
|
5163
|
-
target.dispatchEvent(new Event('input', { bubbles: true }));
|
|
5164
|
-
target.dispatchEvent(new Event('change', { bubbles: true }));
|
|
5320
|
+
return el.textContent?.trim() || undefined;
|
|
5165
5321
|
}
|
|
5166
|
-
function
|
|
5167
|
-
const
|
|
5168
|
-
|
|
5169
|
-
|
|
5170
|
-
|
|
5171
|
-
|
|
5172
|
-
|
|
5173
|
-
|
|
5322
|
+
function nearestContext(el, name) {
|
|
5323
|
+
const normalizedName = normalize(name);
|
|
5324
|
+
let current = el.parentElement;
|
|
5325
|
+
for (let depth = 0; current && depth < 6; depth++, current = current.parentElement) {
|
|
5326
|
+
if (current === document.body || current === document.documentElement)
|
|
5327
|
+
break;
|
|
5328
|
+
const text = (current.innerText || current.textContent || '').replace(/\s+/g, ' ').trim();
|
|
5329
|
+
if (text && normalize(text) !== normalizedName)
|
|
5330
|
+
return text;
|
|
5174
5331
|
}
|
|
5175
|
-
return
|
|
5176
|
-
}
|
|
5177
|
-
function setInputCheckedReactAware(target, next) {
|
|
5178
|
-
const hadReactTracker = clearReactTracker(target, next);
|
|
5179
|
-
if (!hadReactTracker && target.checked === next)
|
|
5180
|
-
return true;
|
|
5181
|
-
const descriptor = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'checked');
|
|
5182
|
-
if (descriptor?.set)
|
|
5183
|
-
descriptor.set.call(target, next);
|
|
5184
|
-
else
|
|
5185
|
-
target.checked = next;
|
|
5186
|
-
dispatch(target);
|
|
5187
|
-
return target.checked === next;
|
|
5188
|
-
}
|
|
5189
|
-
const selector = 'input[type="checkbox"], input[type="radio"], [role="checkbox"], [role="radio"], [role="switch"]';
|
|
5190
|
-
const candidates = Array.from(document.querySelectorAll(selector)).filter((el) => el instanceof HTMLElement && visible(el));
|
|
5191
|
-
const target = candidates.find((el) => {
|
|
5192
|
-
const kind = controlKind(el);
|
|
5193
|
-
if (!kind)
|
|
5194
|
-
return false;
|
|
5195
|
-
if (payload.controlType && kind !== payload.controlType)
|
|
5196
|
-
return false;
|
|
5197
|
-
return matchesLabel(controlName(el));
|
|
5198
|
-
});
|
|
5199
|
-
if (!target) {
|
|
5200
|
-
return { matched: false };
|
|
5201
|
-
}
|
|
5202
|
-
const kind = controlKind(target);
|
|
5203
|
-
const name = controlName(target) ?? payload.label;
|
|
5204
|
-
const before = readChecked(target);
|
|
5205
|
-
if (kind === 'radio' && payload.checked === false) {
|
|
5206
|
-
return { matched: true, success: before === false, reason: 'radio-uncheck', kind, name };
|
|
5207
|
-
}
|
|
5208
|
-
if (before !== payload.checked && !(target instanceof HTMLInputElement)) {
|
|
5209
|
-
clickTarget(target)?.click();
|
|
5332
|
+
return '';
|
|
5210
5333
|
}
|
|
5211
|
-
|
|
5212
|
-
|
|
5213
|
-
|
|
5214
|
-
|
|
5334
|
+
function nearestSection(el) {
|
|
5335
|
+
const section = el.closest('fieldset, section, form, dialog, [role="group"], [role="radiogroup"], [role="region"], [role="dialog"]');
|
|
5336
|
+
if (!(section instanceof HTMLElement))
|
|
5337
|
+
return '';
|
|
5338
|
+
const explicit = section.getAttribute('aria-label')?.trim() ||
|
|
5339
|
+
referencedText(section.getAttribute('aria-labelledby')) ||
|
|
5340
|
+
section.querySelector('legend, h1, h2, h3, h4, h5, h6')?.textContent?.trim();
|
|
5341
|
+
return (explicit || section.innerText || section.textContent || '').replace(/\s+/g, ' ').trim();
|
|
5342
|
+
}
|
|
5343
|
+
const needle = normalize(payload.label);
|
|
5344
|
+
const contextNeedle = normalize(payload.contextText);
|
|
5345
|
+
const sectionNeedle = normalize(payload.sectionText);
|
|
5346
|
+
const result = [];
|
|
5347
|
+
for (let index = 0; index < elements.length; index++) {
|
|
5348
|
+
const element = elements[index];
|
|
5349
|
+
if (!element)
|
|
5350
|
+
continue;
|
|
5351
|
+
if (!visible(element))
|
|
5352
|
+
continue;
|
|
5353
|
+
const kind = kindOf(element);
|
|
5354
|
+
if (!kind || (payload.controlType && kind !== payload.controlType))
|
|
5355
|
+
continue;
|
|
5356
|
+
const name = nameOf(element);
|
|
5357
|
+
if (!name)
|
|
5358
|
+
continue;
|
|
5359
|
+
const normalizedName = normalize(name);
|
|
5360
|
+
const exactLabel = normalizedName === needle;
|
|
5361
|
+
if (payload.exact ? !exactLabel : !normalizedName.includes(needle))
|
|
5362
|
+
continue;
|
|
5363
|
+
const context = nearestContext(element, name);
|
|
5364
|
+
const section = nearestSection(element);
|
|
5365
|
+
if (contextNeedle && !normalize(context).includes(contextNeedle))
|
|
5366
|
+
continue;
|
|
5367
|
+
if (sectionNeedle && !normalize(section).includes(sectionNeedle))
|
|
5368
|
+
continue;
|
|
5369
|
+
result.push({ index, kind, name, exactLabel, contextText: context, sectionText: section });
|
|
5215
5370
|
}
|
|
5216
|
-
return
|
|
5217
|
-
|
|
5218
|
-
|
|
5219
|
-
|
|
5220
|
-
|
|
5221
|
-
|
|
5222
|
-
|
|
5223
|
-
|
|
5224
|
-
|
|
5225
|
-
|
|
5226
|
-
|
|
5227
|
-
|
|
5228
|
-
|
|
5229
|
-
|
|
5230
|
-
|
|
5371
|
+
return result;
|
|
5372
|
+
}, { label, exact, controlType: controlType ?? null, contextText: contextText ?? '', sectionText: sectionText ?? '' });
|
|
5373
|
+
for (const candidate of metadata) {
|
|
5374
|
+
matches.push({ ...candidate, locator: candidates.nth(candidate.index), frameUrl: frame.url() });
|
|
5375
|
+
}
|
|
5376
|
+
}
|
|
5377
|
+
if (!exact) {
|
|
5378
|
+
const exactMatches = matches.filter(match => match.exactLabel);
|
|
5379
|
+
if (exactMatches.length > 0)
|
|
5380
|
+
return exactMatches;
|
|
5381
|
+
}
|
|
5382
|
+
return matches;
|
|
5383
|
+
}
|
|
5384
|
+
/**
|
|
5385
|
+
* Set a checkbox/radio by accessible label instead of brittle coordinate clicks.
|
|
5386
|
+
* Helps custom form UIs that keep the real control opacity-hidden but still interactive.
|
|
5387
|
+
*/
|
|
5388
|
+
export async function setCheckedControl(page, label, opts) {
|
|
5389
|
+
if (!label || label !== label.trim()) {
|
|
5390
|
+
throw new Error('setChecked: label must be a trimmed, non-empty string');
|
|
5391
|
+
}
|
|
5392
|
+
if (opts?.contextText !== undefined && (!opts.contextText || opts.contextText !== opts.contextText.trim())) {
|
|
5393
|
+
throw new Error('setChecked: contextText must be a trimmed, non-empty string when provided');
|
|
5394
|
+
}
|
|
5395
|
+
if (opts?.sectionText !== undefined && (!opts.sectionText || opts.sectionText !== opts.sectionText.trim())) {
|
|
5396
|
+
throw new Error('setChecked: sectionText must be a trimmed, non-empty string when provided');
|
|
5397
|
+
}
|
|
5398
|
+
const exact = opts?.exact ?? false;
|
|
5399
|
+
const desiredChecked = opts?.checked ?? true;
|
|
5400
|
+
const controlType = opts?.controlType;
|
|
5401
|
+
let target = null;
|
|
5402
|
+
if (opts?.fieldKey) {
|
|
5403
|
+
target = await findAuthoredFieldByKeyInPage(page, opts.fieldKey, 'toggle');
|
|
5404
|
+
if (!target) {
|
|
5405
|
+
throw new Error(`setChecked: fieldKey "${opts.fieldKey}" did not resolve to a visible checkbox/radio; refresh geometra_form_schema`);
|
|
5406
|
+
}
|
|
5407
|
+
}
|
|
5408
|
+
if (!target) {
|
|
5409
|
+
const matches = await collectToggleMatches(page, label, exact, controlType, opts?.contextText, opts?.sectionText);
|
|
5410
|
+
if (matches.length === 0) {
|
|
5411
|
+
const kindLabel = controlType ?? 'checkbox/radio';
|
|
5412
|
+
const context = [opts?.contextText && `context "${opts.contextText}"`, opts?.sectionText && `section "${opts.sectionText}"`]
|
|
5413
|
+
.filter(Boolean).join(' and ');
|
|
5414
|
+
throw new Error(`setChecked: no visible ${kindLabel} matching "${label}"${context ? ` in ${context}` : ''}`);
|
|
5415
|
+
}
|
|
5416
|
+
if (matches.length > 1) {
|
|
5417
|
+
const details = matches.slice(0, 5).map(match => `"${match.name}"${match.frameUrl ? ` in ${match.frameUrl}` : ''}`).join(', ');
|
|
5418
|
+
throw new Error(`setChecked: ambiguous label "${label}" matched ${matches.length} controls (${details}); provide fieldKey, contextText, or sectionText`);
|
|
5231
5419
|
}
|
|
5232
|
-
|
|
5420
|
+
target = matches[0].locator;
|
|
5421
|
+
}
|
|
5422
|
+
const result = await applyToggleState(target, desiredChecked);
|
|
5423
|
+
if (result.success)
|
|
5424
|
+
return;
|
|
5425
|
+
if (result.reason === 'radio-uncheck') {
|
|
5426
|
+
throw new Error(`setChecked: radio "${result.name}" cannot be unchecked directly; choose a different option instead`);
|
|
5233
5427
|
}
|
|
5234
|
-
|
|
5235
|
-
throw new Error(`setChecked: no visible ${kindLabel} matching "${label}"`);
|
|
5428
|
+
throw new Error(`setChecked: matched ${result.kind} "${result.name}" but could not set it to ${String(desiredChecked)}`);
|
|
5236
5429
|
}
|
|
5237
5430
|
export async function wheelAt(page, deltaX, deltaY, x, y) {
|
|
5238
5431
|
if (x !== undefined && y !== undefined && Number.isFinite(x) && Number.isFinite(y)) {
|