@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.
@@ -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
- if (map.has(key))
65
- return map.get(key) ?? null;
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
- for (const key of lookupCacheKeys(kind, label, exact, fieldId)) {
74
- map.set(key, locator);
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 ? readCachedLocator(opts?.cache, 'control', fieldLabel, exact, opts?.fieldId) : undefined;
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
- for (const frame of page.frames()) {
2737
- const locator = await findLabeledFileInput(frame, fieldLabel, exact);
2738
- if (!locator)
2739
- continue;
2740
- writeCachedLocator(cache, 'file', fieldLabel, exact, fieldId, locator);
2741
- return locator;
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
- writeCachedLocator(cache, 'file', fieldLabel, exact, fieldId, null);
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, { fieldId: opts?.fieldId, fieldLabel, exact, cache: opts?.cache }))
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
- pending.push({
3321
- index,
3322
- kind: 'toggle',
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
- if (!expected)
4282
- return false;
4283
- const option = Array.from(el.options).find((candidate) => {
4284
- if (candidate.disabled)
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
- const label = normalize(candidate.textContent);
4287
- const rawValue = normalize(candidate.value);
4288
- if (payload.exact)
4289
- return label === expected || rawValue === expected;
4290
- return label.includes(expected) || rawValue.includes(expected);
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.value = option.value;
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, { cache: opts?.cache, fieldId: opts?.fieldId });
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, { cache: opts?.cache, fieldId: opts?.fieldId });
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, { fieldId: field.fieldId, fieldLabel: field.fieldLabel, exact: field.exact, cache });
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
- if (sel.options[i].disabled)
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.value = found.value;
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.value = optEl.value;
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
- // Force-commit for searchable/autocomplete comboboxes (React Select,
4909
- // Headless UI, Radix, Ant Design, etc.). Some library versions — most
4910
- // notably Greenhouse's Remix-wrapped react-select visually select the
4911
- // option on synthetic mouse click but never invoke `onChange`, leaving
4912
- // the controlled form state empty. Dispatching a keyboard `Enter` on
4913
- // the focused combobox input puts the selection through the keyboard
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
- * Set a checkbox/radio by accessible label instead of brittle coordinate clicks.
5065
- * Helps custom form UIs that keep the real control opacity-hidden but still interactive.
5066
- */
5067
- export async function setCheckedControl(page, label, opts) {
5068
- const exact = opts?.exact ?? false;
5069
- const desiredChecked = opts?.checked ?? true;
5070
- const controlType = opts?.controlType;
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 result = await frame.evaluate((payload) => {
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
- const rect = el.getBoundingClientRect();
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 target = document.getElementById(id);
5101
- if (!target)
5283
+ const node = document.getElementById(id);
5284
+ if (!node)
5102
5285
  return '';
5103
- const chained = target.getAttribute('aria-labelledby');
5104
- if (chained)
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 controlKind(el) {
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 controlName(el) {
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 labels = el.labels ? Array.from(el.labels) : [];
5137
- for (const labelEl of labels) {
5138
- const text = labelEl.textContent?.trim();
5139
- if (text)
5140
- return text;
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 instanceof HTMLElement ? el : null;
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 clearReactTracker(target, next) {
5167
- const tracker = target._valueTracker;
5168
- if (tracker && typeof tracker.setValue === 'function') {
5169
- try {
5170
- tracker.setValue(String(!next));
5171
- return true;
5172
- }
5173
- catch { /* ignore */ }
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 false;
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
- let after = readChecked(target);
5212
- if (target instanceof HTMLInputElement) {
5213
- setInputCheckedReactAware(target, payload.checked);
5214
- after = target.checked;
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
- matched: true,
5218
- success: after === payload.checked,
5219
- kind,
5220
- name,
5221
- before,
5222
- after,
5223
- };
5224
- }, { label, exact, checked: desiredChecked, controlType: controlType ?? null });
5225
- if (!result.matched)
5226
- continue;
5227
- if (result.success)
5228
- return;
5229
- if (result.reason === 'radio-uncheck') {
5230
- throw new Error(`setChecked: radio "${result.name}" cannot be unchecked directly; choose a different option instead`);
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
- throw new Error(`setChecked: matched ${result.kind} "${result.name}" but could not set it to ${String(desiredChecked)}`);
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
- const kindLabel = controlType ?? 'checkbox/radio';
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)) {