@geometra/proxy 1.63.2 → 1.65.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.
@@ -1,10 +1,12 @@
1
- import { existsSync } from 'node:fs';
2
- import { resolve } from 'node:path';
1
+ import { existsSync, realpathSync, statSync } from 'node:fs';
2
+ import { isAbsolute, relative, resolve } from 'node:path';
3
3
  import { canonicalizeHtmlInputValue } from './input-canonical.js';
4
4
  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
@@ -298,6 +436,97 @@ async function locatorIsEditable(locator) {
298
436
  return false;
299
437
  }
300
438
  }
439
+ /**
440
+ * Return the effective browser state that makes a control unsafe to mutate.
441
+ * This deliberately walks the composed tree: framework controls often put
442
+ * the clickable/editable node inside a shadow root while disabled/inert ARIA
443
+ * state lives on the host.
444
+ */
445
+ function mutationBlockReasonInPage(target, intent) {
446
+ function composedParent(node) {
447
+ if (node.parentElement)
448
+ return node.parentElement;
449
+ const root = node.getRootNode();
450
+ return root instanceof ShadowRoot ? root.host : null;
451
+ }
452
+ function composedDescendantOf(node, ancestor) {
453
+ let current = node;
454
+ while (current) {
455
+ if (current === ancestor)
456
+ return true;
457
+ current = composedParent(current);
458
+ }
459
+ return false;
460
+ }
461
+ // Native :disabled includes the disabled-fieldset/first-legend rules for
462
+ // form controls in the light DOM. The explicit ancestor walk below extends
463
+ // the same fail-closed behavior to custom and shadow-root controls.
464
+ try {
465
+ if (target.matches(':disabled'))
466
+ return 'disabled';
467
+ }
468
+ catch { /* non-HTML namespaces may reject pseudo classes */ }
469
+ if ((target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) &&
470
+ target.readOnly) {
471
+ return 'readonly';
472
+ }
473
+ let current = target;
474
+ while (current) {
475
+ if (current.hasAttribute('inert'))
476
+ return 'inert';
477
+ if (current.getAttribute('aria-disabled')?.trim().toLowerCase() === 'true')
478
+ return 'aria-disabled';
479
+ if (current.getAttribute('aria-readonly')?.trim().toLowerCase() === 'true')
480
+ return 'aria-readonly';
481
+ if (current instanceof HTMLFieldSetElement && current.disabled) {
482
+ const firstLegend = Array.from(current.children).find(child => child instanceof HTMLLegendElement);
483
+ if (!(firstLegend instanceof HTMLLegendElement) || !composedDescendantOf(target, firstLegend)) {
484
+ return 'disabled fieldset';
485
+ }
486
+ }
487
+ current = composedParent(current);
488
+ }
489
+ // Keep the argument explicit for call-site readability and future
490
+ // intent-specific platform states even though every current intent shares
491
+ // the effective ARIA/inert/fieldset policy.
492
+ void intent;
493
+ return null;
494
+ }
495
+ async function locatorMutationBlockReason(locator, intent) {
496
+ try {
497
+ return await locator.evaluate(mutationBlockReasonInPage, intent);
498
+ }
499
+ catch {
500
+ return 'detached or unavailable';
501
+ }
502
+ }
503
+ async function assertLocatorMutable(locator, intent, action) {
504
+ const reason = await locatorMutationBlockReason(locator, intent);
505
+ if (reason)
506
+ throw new Error(`${action}: matched control is not mutable (${reason})`);
507
+ }
508
+ async function elementHandleAtPoint(page, x, y) {
509
+ const handle = await page.evaluateHandle(({ pointX, pointY }) => document.elementFromPoint(pointX, pointY), { pointX: x, pointY: y });
510
+ const element = handle.asElement();
511
+ if (element)
512
+ return element;
513
+ await handle.dispose();
514
+ return null;
515
+ }
516
+ async function assertPointMutable(page, x, y, intent, action) {
517
+ const element = await elementHandleAtPoint(page, x, y);
518
+ if (!element) {
519
+ throw new Error(`${action}: no element at the supplied coordinates`);
520
+ }
521
+ try {
522
+ const reason = await element.evaluate(mutationBlockReasonInPage, intent);
523
+ if (reason)
524
+ throw new Error(`${action}: matched control is not mutable (${reason})`);
525
+ }
526
+ finally {
527
+ await element.dispose();
528
+ }
529
+ }
301
530
  async function locatorAnchorY(locator) {
302
531
  const bounds = await locator.boundingBox();
303
532
  return bounds ? bounds.y + bounds.height / 2 : undefined;
@@ -438,7 +667,15 @@ async function findLabeledControl(frame, fieldLabel, exact, opts) {
438
667
  const style = getComputedStyle(el);
439
668
  return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0';
440
669
  }
441
- function referencedText(ids, visited) {
670
+ function rootScopedElementById(owner, id) {
671
+ const root = owner.getRootNode();
672
+ if (root instanceof ShadowRoot)
673
+ return root.getElementById(id);
674
+ if (root instanceof Document)
675
+ return root.getElementById(id);
676
+ return null;
677
+ }
678
+ function referencedText(owner, ids, visited) {
442
679
  if (!ids)
443
680
  return undefined;
444
681
  const seen = visited ?? new Set();
@@ -448,12 +685,12 @@ async function findLabeledControl(frame, fieldLabel, exact, opts) {
448
685
  if (seen.has(id))
449
686
  return '';
450
687
  seen.add(id);
451
- const target = document.getElementById(id);
688
+ const target = rootScopedElementById(owner, id);
452
689
  if (!target)
453
690
  return '';
454
691
  const chained = target.getAttribute('aria-labelledby');
455
692
  if (chained)
456
- return referencedText(chained, seen) ?? '';
693
+ return referencedText(target, chained, seen) ?? '';
457
694
  return target.textContent?.trim() ?? '';
458
695
  })
459
696
  .filter(Boolean)
@@ -465,7 +702,7 @@ async function findLabeledControl(frame, fieldLabel, exact, opts) {
465
702
  const aria = el.getAttribute('aria-label')?.trim();
466
703
  if (aria)
467
704
  return aria;
468
- const labelledBy = referencedText(el.getAttribute('aria-labelledby'));
705
+ const labelledBy = referencedText(el, el.getAttribute('aria-labelledby'));
469
706
  if (labelledBy)
470
707
  return labelledBy;
471
708
  if ((el instanceof HTMLInputElement || el instanceof HTMLSelectElement || el instanceof HTMLTextAreaElement) &&
@@ -474,7 +711,10 @@ async function findLabeledControl(frame, fieldLabel, exact, opts) {
474
711
  return el.labels[0]?.textContent?.trim() || undefined;
475
712
  }
476
713
  if (el instanceof HTMLElement && el.id) {
477
- const label = document.querySelector(`label[for="${CSS.escape(el.id)}"]`);
714
+ const root = el.getRootNode();
715
+ const label = (root instanceof Document || root instanceof ShadowRoot)
716
+ ? root.querySelector(`label[for="${CSS.escape(el.id)}"]`)
717
+ : null;
478
718
  const text = label?.textContent?.trim();
479
719
  if (text)
480
720
  return text;
@@ -631,6 +871,14 @@ async function findMenuTriggerByContainerLabel(frame, fieldLabel, exact) {
631
871
  const style = getComputedStyle(el);
632
872
  return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0';
633
873
  }
874
+ function rootScopedElementById(owner, id) {
875
+ const root = owner.getRootNode();
876
+ if (root instanceof ShadowRoot)
877
+ return root.getElementById(id);
878
+ if (root instanceof Document)
879
+ return root.getElementById(id);
880
+ return null;
881
+ }
634
882
  function containerLabelText(el) {
635
883
  // Walk up a few parent levels looking for a container that holds BOTH
636
884
  // the trigger and some sibling label text. The label text is the
@@ -673,7 +921,7 @@ async function findMenuTriggerByContainerLabel(frame, fieldLabel, exact) {
673
921
  if (labelledBy) {
674
922
  const text = labelledBy
675
923
  .split(/\s+/)
676
- .map(id => document.getElementById(id)?.textContent?.trim() ?? '')
924
+ .map(id => rootScopedElementById(el, id)?.textContent?.trim() ?? '')
677
925
  .filter(Boolean)
678
926
  .join(' ')
679
927
  .trim();
@@ -697,12 +945,94 @@ async function findMenuTriggerByContainerLabel(frame, fieldLabel, exact) {
697
945
  }, { fieldLabel, exact });
698
946
  return bestIndex >= 0 ? triggers.nth(bestIndex) : null;
699
947
  }
948
+ async function locatorMatchesControlLabel(locator, fieldLabel, exact) {
949
+ const accessibleName = await locator.evaluate((el) => {
950
+ function rootScopedElementById(owner, id) {
951
+ const root = owner.getRootNode();
952
+ if (root instanceof ShadowRoot)
953
+ return root.getElementById(id);
954
+ if (root instanceof Document)
955
+ return root.getElementById(id);
956
+ return null;
957
+ }
958
+ function textWithoutNestedControls(node) {
959
+ if (node.nodeType === Node.TEXT_NODE)
960
+ return node.textContent ?? '';
961
+ if (!(node instanceof Element))
962
+ return '';
963
+ const tag = node.tagName.toLowerCase();
964
+ if (tag === 'input' || tag === 'select' || tag === 'textarea' || tag === 'button')
965
+ return '';
966
+ return Array.from(node.childNodes).map(textWithoutNestedControls).join('');
967
+ }
968
+ function referencedText(owner, ids, visited) {
969
+ if (!ids)
970
+ return '';
971
+ const seen = visited ?? new Set();
972
+ return ids
973
+ .split(/\s+/)
974
+ .map(id => {
975
+ if (seen.has(id))
976
+ return '';
977
+ seen.add(id);
978
+ const target = rootScopedElementById(owner, id);
979
+ if (!target)
980
+ return '';
981
+ const chained = target.getAttribute('aria-labelledby');
982
+ if (chained)
983
+ return referencedText(target, chained, seen);
984
+ return textWithoutNestedControls(target).replace(/\s+/g, ' ').trim();
985
+ })
986
+ .filter(Boolean)
987
+ .join(' ');
988
+ }
989
+ const explicit = el.getAttribute('aria-label')?.trim() || referencedText(el, el.getAttribute('aria-labelledby'));
990
+ if (explicit)
991
+ return explicit;
992
+ if (el instanceof HTMLInputElement || el instanceof HTMLSelectElement || el instanceof HTMLTextAreaElement) {
993
+ const associated = Array.from(el.labels ?? [])
994
+ .map(label => textWithoutNestedControls(label).replace(/\s+/g, ' ').trim())
995
+ .find(Boolean);
996
+ if (associated)
997
+ return associated;
998
+ const placeholder = el.getAttribute('placeholder')?.trim();
999
+ if (placeholder)
1000
+ return placeholder;
1001
+ }
1002
+ if (el.parentElement instanceof HTMLLabelElement) {
1003
+ return textWithoutNestedControls(el.parentElement).replace(/\s+/g, ' ').trim();
1004
+ }
1005
+ return el.getAttribute('title')?.trim() || el.textContent?.trim() || '';
1006
+ }).catch(() => '');
1007
+ const candidate = normalizedOptionLabel(accessibleName);
1008
+ const expected = normalizedOptionLabel(fieldLabel);
1009
+ if (!candidate || !expected)
1010
+ return false;
1011
+ return exact ? candidate === expected : candidate === expected || candidate.includes(expected);
1012
+ }
700
1013
  async function findLabeledControlInPage(page, fieldLabel, exact, opts) {
701
1014
  const cacheable = !opts?.preferredAnchor;
702
- const cached = cacheable ? readCachedLocator(opts?.cache, 'control', fieldLabel, exact, opts?.fieldId) : undefined;
1015
+ const cached = cacheable
1016
+ ? await readCachedLocator(opts?.cache, 'control', fieldLabel, exact, opts?.fieldId, opts?.fieldKey)
1017
+ : undefined;
703
1018
  if (cached !== undefined) {
1019
+ if (cached && opts?.fieldKey && fieldLabel && !(await locatorMatchesControlLabel(cached, fieldLabel, exact))) {
1020
+ throw new Error(`fieldKey "${opts.fieldKey}" did not match field label "${fieldLabel}"`);
1021
+ }
704
1022
  return cached;
705
1023
  }
1024
+ if (opts?.fieldKey) {
1025
+ const authored = await findAuthoredFieldByKeyInPage(page, opts.fieldKey, 'control');
1026
+ if (authored) {
1027
+ if (fieldLabel && !(await locatorMatchesControlLabel(authored, fieldLabel, exact))) {
1028
+ throw new Error(`fieldKey "${opts.fieldKey}" did not match field label "${fieldLabel}"`);
1029
+ }
1030
+ if (cacheable)
1031
+ writeCachedLocator(opts?.cache, 'control', fieldLabel, exact, opts?.fieldId, opts.fieldKey, authored);
1032
+ return authored;
1033
+ }
1034
+ throw new Error(`fieldKey "${opts.fieldKey}" did not resolve to a visible control; refresh geometra_form_schema`);
1035
+ }
706
1036
  // Try the label as-is first. If the caller passed a fully-qualified DOM
707
1037
  // label, that's the most accurate match.
708
1038
  for (const frame of page.frames()) {
@@ -710,7 +1040,7 @@ async function findLabeledControlInPage(page, fieldLabel, exact, opts) {
710
1040
  if (!locator)
711
1041
  continue;
712
1042
  if (cacheable)
713
- writeCachedLocator(opts?.cache, 'control', fieldLabel, exact, opts?.fieldId, locator);
1043
+ writeCachedLocator(opts?.cache, 'control', fieldLabel, exact, opts?.fieldId, undefined, locator);
714
1044
  return locator;
715
1045
  }
716
1046
  // Fallback: if the label looks truncated (ends in U+2026 or "..."), strip
@@ -726,7 +1056,7 @@ async function findLabeledControlInPage(page, fieldLabel, exact, opts) {
726
1056
  if (!locator)
727
1057
  continue;
728
1058
  if (cacheable)
729
- writeCachedLocator(opts?.cache, 'control', fieldLabel, exact, opts?.fieldId, locator);
1059
+ writeCachedLocator(opts?.cache, 'control', fieldLabel, exact, opts?.fieldId, undefined, locator);
730
1060
  return locator;
731
1061
  }
732
1062
  }
@@ -741,12 +1071,12 @@ async function findLabeledControlInPage(page, fieldLabel, exact, opts) {
741
1071
  if (!locator)
742
1072
  continue;
743
1073
  if (cacheable)
744
- writeCachedLocator(opts?.cache, 'control', fieldLabel, exact, opts?.fieldId, locator);
1074
+ writeCachedLocator(opts?.cache, 'control', fieldLabel, exact, opts?.fieldId, undefined, locator);
745
1075
  return locator;
746
1076
  }
747
1077
  }
748
1078
  if (cacheable)
749
- writeCachedLocator(opts?.cache, 'control', fieldLabel, exact, opts?.fieldId, null);
1079
+ writeCachedLocator(opts?.cache, 'control', fieldLabel, exact, opts?.fieldId, undefined, null);
750
1080
  return null;
751
1081
  }
752
1082
  function textMatches(candidate, expected, exact) {
@@ -763,9 +1093,10 @@ function displayedValueMatchesSelection(candidate, expected, exact, selectedOpti
763
1093
  return false;
764
1094
  return (normalizedSelectedOption.includes(normalizedCandidate) || normalizedCandidate.includes(normalizedSelectedOption));
765
1095
  }
766
- async function openDropdownControl(page, fieldLabel, exact, cache, fieldId) {
767
- const locator = await findLabeledControlInPage(page, fieldLabel, exact, { cache, fieldId });
1096
+ async function openDropdownControl(page, fieldLabel, exact, cache, fieldId, fieldKey) {
1097
+ const locator = await findLabeledControlInPage(page, fieldLabel, exact, { cache, fieldId, fieldKey });
768
1098
  if (locator) {
1099
+ await assertLocatorMutable(locator, 'select', 'listboxPick');
769
1100
  await locator.scrollIntoViewIfNeeded();
770
1101
  const handle = await locator.elementHandle();
771
1102
  const clickTarget = await resolveMeaningfulClickTarget(locator);
@@ -866,11 +1197,19 @@ async function resolveOwnedPopupHandle(triggerHandle) {
866
1197
  }
867
1198
  return ids;
868
1199
  }
1200
+ function rootScopedElementById(owner, id) {
1201
+ const root = owner.getRootNode();
1202
+ if (root instanceof ShadowRoot)
1203
+ return root.getElementById(id);
1204
+ if (root instanceof Document)
1205
+ return root.getElementById(id);
1206
+ return null;
1207
+ }
869
1208
  function lookupReferenced() {
870
1209
  const attributes = ['aria-controls', 'aria-owns', 'aria-activedescendant'];
871
1210
  for (const attribute of attributes) {
872
1211
  for (const id of readIdRefs(el, attribute)) {
873
- const referenced = document.getElementById(id);
1212
+ const referenced = rootScopedElementById(el, id);
874
1213
  const popup = asPopupRoot(referenced);
875
1214
  if (popup)
876
1215
  return popup;
@@ -1506,6 +1845,8 @@ async function clickScopedOptionCandidate(popupScope, label, exact) {
1506
1845
  }, hit.selector)).asElement();
1507
1846
  if (!target)
1508
1847
  return null;
1848
+ if (await target.evaluate(mutationBlockReasonInPage, 'select'))
1849
+ return null;
1509
1850
  try {
1510
1851
  await target.scrollIntoViewIfNeeded({ timeout: 500 });
1511
1852
  }
@@ -1690,6 +2031,8 @@ async function clickVisibleOptionCandidate(page, label, exact, anchor, popupScop
1690
2031
  });
1691
2032
  if (bestIndex >= 0) {
1692
2033
  const candidate = candidates.nth(bestIndex);
2034
+ if (await locatorMutationBlockReason(candidate, 'select'))
2035
+ return null;
1693
2036
  const selectedText = (await candidate.evaluate(el => el.getAttribute('aria-label')?.trim() || el.textContent?.trim() || '').catch(() => '')) || null;
1694
2037
  const clickTarget = await resolveMeaningfulOptionClickTarget(candidate);
1695
2038
  // Only scrollIntoView if the option is NOT inside a popup/dropdown container.
@@ -1876,116 +2219,6 @@ async function readTriggerShowsPlaceholder(handle) {
1876
2219
  return false;
1877
2220
  }
1878
2221
  }
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
2222
  /**
1990
2223
  * After a custom listbox option click, make the committed value visible to
1991
2224
  * framework state managers that listen on the backing input/select instead of
@@ -2507,6 +2740,22 @@ async function confirmListboxSelection(page, fieldLabel, label, exact, anchor, c
2507
2740
  }
2508
2741
  return false;
2509
2742
  }
2743
+ async function confirmListboxSelectionWithoutLabel(page, label, exact, anchor, currentHandle, selectedOptionText) {
2744
+ const deadline = Date.now() + 800;
2745
+ while (Date.now() < deadline) {
2746
+ if (currentHandle) {
2747
+ const values = await elementHandleDisplayedValues(currentHandle);
2748
+ const matches = values.some(value => displayedValueMatchesSelection(value, label, exact, selectedOptionText));
2749
+ if (matches && !(await readAriaInvalid(currentHandle)) && !(await readTriggerShowsPlaceholder(currentHandle))) {
2750
+ return true;
2751
+ }
2752
+ }
2753
+ if (await visibleOptionIsSelected(page, label, exact, anchor))
2754
+ return true;
2755
+ await delay(80);
2756
+ }
2757
+ return false;
2758
+ }
2510
2759
  const PLACEHOLDER_PATTERN = /^(select|choose|pick|--|—\s)/i;
2511
2760
  /**
2512
2761
  * Dismiss the dropdown (Tab) and re-verify the field value didn't revert to a placeholder.
@@ -2537,7 +2786,7 @@ async function dismissAndReVerifySelection(page, label, exact, currentHandle, se
2537
2786
  }
2538
2787
  await delay(50);
2539
2788
  if (!currentHandle)
2540
- return true;
2789
+ return false;
2541
2790
  // Re-verify using the original element handle (immune to DOM reordering)
2542
2791
  const deadline = Date.now() + 800;
2543
2792
  let sawAnyValue = false;
@@ -2572,8 +2821,9 @@ async function dismissAndReVerifySelection(page, label, exact, currentHandle, se
2572
2821
  }
2573
2822
  }
2574
2823
  catch {
2575
- // Handle detached trust the earlier confirmation
2576
- return true;
2824
+ // A detached trigger has no observable committed state. Fail closed;
2825
+ // callers can re-resolve the live field and retry.
2826
+ return false;
2577
2827
  }
2578
2828
  await delay(100);
2579
2829
  }
@@ -2609,192 +2859,580 @@ async function dismissAndReVerifySelection(page, label, exact, currentHandle, se
2609
2859
  /**
2610
2860
  * Resolve and validate paths on the machine running the proxy (not the agent host).
2611
2861
  */
2612
- export function resolveExistingFiles(rawPaths) {
2862
+ export function resolveExistingFiles(rawPaths, allowedRoots = []) {
2613
2863
  if (!Array.isArray(rawPaths) || rawPaths.length === 0) {
2614
2864
  throw new Error('file: paths must be a non-empty array of strings');
2615
2865
  }
2866
+ if (allowedRoots.length === 0) {
2867
+ throw new Error('file: uploads are disabled; configure one or more canonical roots with GEOMETRA_PROXY_FILE_ROOTS');
2868
+ }
2869
+ const canonicalRoots = allowedRoots.map(root => {
2870
+ const absoluteRoot = resolve(root);
2871
+ if (!existsSync(absoluteRoot))
2872
+ throw new Error(`file: allowed root does not exist: ${absoluteRoot}`);
2873
+ const canonicalRoot = realpathSync(absoluteRoot);
2874
+ if (!statSync(canonicalRoot).isDirectory()) {
2875
+ throw new Error(`file: allowed root is not a directory: ${absoluteRoot}`);
2876
+ }
2877
+ return canonicalRoot;
2878
+ });
2616
2879
  const paths = [];
2617
2880
  for (const p of rawPaths) {
2618
2881
  if (typeof p !== 'string' || p.trim() === '')
2619
2882
  continue;
2620
- paths.push(resolve(p));
2883
+ const absolutePath = resolve(p);
2884
+ if (!existsSync(absolutePath))
2885
+ throw new Error(`file: path does not exist: ${absolutePath}`);
2886
+ const canonicalPath = realpathSync(absolutePath);
2887
+ if (!statSync(canonicalPath).isFile())
2888
+ throw new Error(`file: path is not a regular file: ${absolutePath}`);
2889
+ const allowed = canonicalRoots.some(root => {
2890
+ const rel = relative(root, canonicalPath);
2891
+ return rel !== '' && rel !== '..' && !rel.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`) && !isAbsolute(rel);
2892
+ });
2893
+ if (!allowed)
2894
+ throw new Error(`file: path is outside configured upload roots: ${absolutePath}`);
2895
+ paths.push(canonicalPath);
2621
2896
  }
2622
2897
  if (paths.length === 0)
2623
2898
  throw new Error('file: paths must contain at least one non-empty string');
2624
- for (const p of paths) {
2625
- if (!existsSync(p))
2626
- throw new Error(`file: path does not exist: ${p}`);
2627
- }
2628
2899
  return paths;
2629
2900
  }
2630
- async function findLabeledFileInput(frame, fieldLabel, exact) {
2631
- // Try exact-match candidates first, even when the caller passed
2632
- // exact=false. Same Greenhouse-style failure mode as findLabeledControl:
2633
- // a substring match (e.g. file labeled "Resume" hijacked by another
2634
- // "Please attach your resume below" field) silently picks the wrong
2635
- // input. Trying exact first guarantees the literal label wins when
2636
- // present, and only falls through to substring matching otherwise.
2637
- const exactDirect = frame.getByLabel(fieldLabel, { exact: true });
2638
- const exactDirectCount = await exactDirect.count();
2639
- for (let i = 0; i < exactDirectCount; i++) {
2640
- const candidate = exactDirect.nth(i);
2641
- const isFileInput = await candidate.evaluate(el => el instanceof HTMLInputElement && el.type === 'file').catch(() => false);
2642
- if (isFileInput)
2643
- return candidate;
2644
- }
2645
- if (!exact) {
2646
- const direct = frame.getByLabel(fieldLabel, { exact: false });
2647
- const directCount = await direct.count();
2648
- for (let i = 0; i < directCount; i++) {
2649
- const candidate = direct.nth(i);
2650
- const isFileInput = await candidate.evaluate(el => el instanceof HTMLInputElement && el.type === 'file').catch(() => false);
2651
- if (isFileInput)
2652
- return candidate;
2901
+ async function fileInputLabel(locator) {
2902
+ return await locator.evaluate((el) => {
2903
+ function rootScopedElementById(owner, id) {
2904
+ const root = owner.getRootNode();
2905
+ if (root instanceof ShadowRoot)
2906
+ return root.getElementById(id);
2907
+ if (root instanceof Document)
2908
+ return root.getElementById(id);
2909
+ return null;
2653
2910
  }
2654
- }
2655
- const loc = frame.locator('input[type="file"]');
2656
- const count = await loc.count();
2657
- if (count === 0)
2658
- return null;
2659
- const bestIndex = await loc.evaluateAll((elements, payload) => {
2911
+ function referencedText(owner, ids) {
2912
+ if (!ids)
2913
+ return '';
2914
+ return ids
2915
+ .split(/\s+/)
2916
+ .map(id => rootScopedElementById(owner, id)?.textContent?.trim() ?? '')
2917
+ .filter(Boolean)
2918
+ .join(' ')
2919
+ .trim();
2920
+ }
2921
+ if (!(el instanceof HTMLInputElement) || el.type !== 'file')
2922
+ return '';
2923
+ return el.getAttribute('aria-label')?.trim() ||
2924
+ referencedText(el, el.getAttribute('aria-labelledby')) ||
2925
+ Array.from(el.labels ?? []).map(label => label.textContent?.trim() ?? '').find(Boolean) ||
2926
+ el.getAttribute('title')?.trim() ||
2927
+ el.getAttribute('name')?.trim() ||
2928
+ el.getAttribute('id')?.trim() ||
2929
+ '';
2930
+ }).catch(() => '');
2931
+ }
2932
+ async function locatorMatchesFileScope(locator, contextText, sectionText) {
2933
+ if (!contextText && !sectionText)
2934
+ return true;
2935
+ return await locator.evaluate((el, payload) => {
2660
2936
  function normalize(value) {
2661
- return value.replace(/\s+/g, ' ').trim().toLowerCase();
2937
+ return (value ?? '').replace(/\s+/g, ' ').trim().toLowerCase();
2662
2938
  }
2663
- function matches(candidate) {
2664
- if (!candidate)
2665
- return false;
2666
- const normalizedCandidate = normalize(candidate);
2667
- const normalizedExpected = normalize(payload.fieldLabel);
2668
- if (!normalizedCandidate || !normalizedExpected)
2669
- return false;
2670
- return payload.exact ? normalizedCandidate === normalizedExpected : normalizedCandidate.includes(normalizedExpected);
2939
+ function rootScopedElementById(owner, id) {
2940
+ const root = owner.getRootNode();
2941
+ if (root instanceof ShadowRoot)
2942
+ return root.getElementById(id);
2943
+ if (root instanceof Document)
2944
+ return root.getElementById(id);
2945
+ return null;
2671
2946
  }
2672
- function referencedText(ids, visited) {
2947
+ function referencedText(owner, ids) {
2673
2948
  if (!ids)
2674
- return undefined;
2675
- const seen = visited ?? new Set();
2676
- const text = ids
2949
+ return '';
2950
+ return ids
2677
2951
  .split(/\s+/)
2678
- .map(id => {
2679
- if (seen.has(id))
2680
- return '';
2681
- seen.add(id);
2682
- const target = document.getElementById(id);
2683
- if (!target)
2684
- return '';
2685
- const chained = target.getAttribute('aria-labelledby');
2686
- if (chained)
2687
- return referencedText(chained, seen) ?? '';
2688
- return target.textContent?.trim() ?? '';
2689
- })
2952
+ .map(id => rootScopedElementById(owner, id)?.textContent?.trim() ?? '')
2690
2953
  .filter(Boolean)
2691
2954
  .join(' ')
2692
2955
  .trim();
2693
- return text || undefined;
2694
2956
  }
2695
- function explicitLabelText(el) {
2696
- const aria = el.getAttribute('aria-label')?.trim();
2697
- if (aria)
2698
- return aria;
2699
- const labelledBy = referencedText(el.getAttribute('aria-labelledby'));
2700
- if (labelledBy)
2701
- return labelledBy;
2702
- if (el instanceof HTMLInputElement && el.labels && el.labels.length > 0) {
2703
- return el.labels[0]?.textContent?.trim() || undefined;
2704
- }
2705
- if (el instanceof HTMLElement && el.id) {
2706
- const label = document.querySelector(`label[for="${CSS.escape(el.id)}"]`);
2707
- const text = label?.textContent?.trim();
2708
- if (text)
2709
- return text;
2710
- }
2711
- if (el.parentElement?.tagName.toLowerCase() === 'label') {
2712
- const text = el.parentElement.textContent?.trim();
2713
- if (text)
2714
- return text;
2957
+ function labelText(input) {
2958
+ if (!(input instanceof HTMLInputElement))
2959
+ return '';
2960
+ return input.getAttribute('aria-label')?.trim() ||
2961
+ referencedText(input, input.getAttribute('aria-labelledby')) ||
2962
+ Array.from(input.labels ?? []).map(label => label.textContent?.trim() ?? '').find(Boolean) ||
2963
+ input.getAttribute('title')?.trim() ||
2964
+ input.getAttribute('name')?.trim() ||
2965
+ input.getAttribute('id')?.trim() ||
2966
+ '';
2967
+ }
2968
+ const contextNeedle = normalize(payload.contextText);
2969
+ const sectionNeedle = normalize(payload.sectionText);
2970
+ if (contextNeedle) {
2971
+ const normalizedLabel = normalize(labelText(el));
2972
+ let nearestContext = '';
2973
+ let current = el.parentElement;
2974
+ for (let depth = 0; current && depth < 7; depth++, current = current.parentElement) {
2975
+ if (current === document.body || current === document.documentElement)
2976
+ break;
2977
+ if (current instanceof HTMLFormElement)
2978
+ break;
2979
+ const text = current.innerText.replace(/\s+/g, ' ').trim();
2980
+ if (text && normalize(text) !== normalizedLabel) {
2981
+ nearestContext = text;
2982
+ break;
2983
+ }
2984
+ if (current.matches('fieldset, section, [role="group"], [role="region"]'))
2985
+ break;
2715
2986
  }
2716
- const title = el.getAttribute('title')?.trim();
2717
- if (title)
2718
- return title;
2719
- return undefined;
2987
+ if (!normalize(nearestContext).includes(contextNeedle))
2988
+ return false;
2720
2989
  }
2721
- for (let i = 0; i < elements.length; i++) {
2722
- const el = elements[i];
2723
- if (!(el instanceof HTMLInputElement) || el.type !== 'file')
2724
- continue;
2725
- if (matches(explicitLabelText(el)))
2726
- return i;
2990
+ if (sectionNeedle) {
2991
+ const section = el.closest('fieldset, section, form, dialog, [role="group"], [role="region"], [role="dialog"]');
2992
+ if (!section)
2993
+ return false;
2994
+ const explicit = section.getAttribute('aria-label')?.trim() ||
2995
+ referencedText(section, section.getAttribute('aria-labelledby')) ||
2996
+ section.querySelector('legend, h1, h2, h3, h4, h5, h6')?.textContent?.trim() ||
2997
+ (section instanceof HTMLElement ? section.innerText : section.textContent || '');
2998
+ if (!normalize(explicit).includes(sectionNeedle))
2999
+ return false;
2727
3000
  }
2728
- return -1;
2729
- }, { fieldLabel, exact });
2730
- return bestIndex >= 0 ? loc.nth(bestIndex) : null;
3001
+ return true;
3002
+ }, { contextText: contextText ?? '', sectionText: sectionText ?? '' }).catch(() => false);
2731
3003
  }
2732
- async function findLabeledFileInputInPage(page, fieldLabel, exact, cache, fieldId) {
2733
- const cached = readCachedLocator(cache, 'file', fieldLabel, exact, fieldId);
2734
- if (cached !== undefined)
2735
- return cached;
3004
+ async function findScopedFileInputsInPage(page, fieldLabel, exact, contextText, sectionText) {
3005
+ const exactMatches = [];
3006
+ const fuzzyMatches = [];
3007
+ const expected = normalizedOptionLabel(fieldLabel);
2736
3008
  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;
2742
- }
2743
- writeCachedLocator(cache, 'file', fieldLabel, exact, fieldId, null);
2744
- return null;
3009
+ const inputs = frame.locator('input[type="file"]');
3010
+ const count = await inputs.count();
3011
+ for (let index = 0; index < count; index++) {
3012
+ const candidate = inputs.nth(index);
3013
+ if (!(await locatorMatchesFileScope(candidate, contextText, sectionText)))
3014
+ continue;
3015
+ const label = normalizedOptionLabel(await fileInputLabel(candidate));
3016
+ if (expected) {
3017
+ if (label === expected)
3018
+ exactMatches.push(candidate);
3019
+ else if (!exact && label.includes(expected))
3020
+ fuzzyMatches.push(candidate);
3021
+ }
3022
+ else {
3023
+ fuzzyMatches.push(candidate);
3024
+ }
3025
+ }
3026
+ }
3027
+ return exactMatches.length > 0 ? exactMatches : fuzzyMatches;
2745
3028
  }
2746
- 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);
2749
- if (labeled) {
2750
- try {
2751
- await labeled.setInputFiles(paths);
2752
- return true;
3029
+ async function findLabeledFileInputInPage(page, fieldLabel, exact, cache, fieldId, fieldKey, contextText, sectionText) {
3030
+ const scoped = Boolean(contextText || sectionText);
3031
+ const cached = scoped ? undefined : await readCachedLocator(cache, 'file', fieldLabel, exact, fieldId, fieldKey);
3032
+ if (cached !== undefined) {
3033
+ if (cached && fieldKey && fieldLabel) {
3034
+ const resolvedLabel = normalizedOptionLabel(await fileInputLabel(cached));
3035
+ const expectedLabel = normalizedOptionLabel(fieldLabel);
3036
+ const matches = exact
3037
+ ? resolvedLabel === expectedLabel
3038
+ : resolvedLabel === expectedLabel || resolvedLabel.includes(expectedLabel);
3039
+ if (!matches)
3040
+ throw new Error(`file: fieldKey "${fieldKey}" did not match field label "${fieldLabel}"`);
3041
+ }
3042
+ return cached;
3043
+ }
3044
+ if (fieldKey) {
3045
+ const authored = await findAuthoredFieldByKeyInPage(page, fieldKey, 'file');
3046
+ if (authored) {
3047
+ if (fieldLabel) {
3048
+ const resolvedLabel = normalizedOptionLabel(await fileInputLabel(authored));
3049
+ const expectedLabel = normalizedOptionLabel(fieldLabel);
3050
+ const labelMatches = exact
3051
+ ? resolvedLabel === expectedLabel
3052
+ : resolvedLabel === expectedLabel || resolvedLabel.includes(expectedLabel);
3053
+ if (!labelMatches) {
3054
+ throw new Error(`file: fieldKey "${fieldKey}" did not match field label "${fieldLabel}"`);
3055
+ }
2753
3056
  }
2754
- catch {
2755
- /* fall through to uncached scan */
3057
+ if (!(await locatorMatchesFileScope(authored, contextText, sectionText))) {
3058
+ throw new Error(`file: fieldKey "${fieldKey}" did not match the requested context/section`);
2756
3059
  }
3060
+ if (!scoped)
3061
+ writeCachedLocator(cache, 'file', fieldLabel, exact, fieldId, fieldKey, authored);
3062
+ return authored;
2757
3063
  }
3064
+ throw new Error(`fieldKey "${fieldKey}" did not resolve to a file input; refresh geometra_form_schema`);
2758
3065
  }
2759
- for (const frame of page.frames()) {
2760
- if (opts?.fieldLabel) {
2761
- const labeled = await findLabeledFileInput(frame, opts.fieldLabel, opts.exact ?? false);
2762
- if (!labeled)
2763
- continue;
3066
+ if (scoped) {
3067
+ const matches = await findScopedFileInputsInPage(page, fieldLabel, exact, contextText, sectionText);
3068
+ if (matches.length > 1) {
3069
+ throw new Error(`file: ambiguous scoped match for field "${fieldLabel || '<unlabeled>'}"; matched ${matches.length} file inputs`);
3070
+ }
3071
+ return matches[0] ?? null;
3072
+ }
3073
+ if (fieldLabel) {
3074
+ const matches = await findScopedFileInputsInPage(page, fieldLabel, exact);
3075
+ if (matches.length > 1) {
3076
+ throw new Error(`file: ambiguous label "${fieldLabel}" matched ${matches.length} file inputs; provide fieldKey, contextText, or sectionText`);
3077
+ }
3078
+ const locator = matches[0];
3079
+ if (locator) {
3080
+ writeCachedLocator(cache, 'file', fieldLabel, exact, fieldId, undefined, locator);
3081
+ return locator;
3082
+ }
3083
+ }
3084
+ writeCachedLocator(cache, 'file', fieldLabel, exact, fieldId, undefined, null);
3085
+ return null;
3086
+ }
3087
+ function expectedUploadNames(paths) {
3088
+ return paths.map(path => path.split(/[/\\\\]/).pop() ?? path);
3089
+ }
3090
+ function uploadedNamesMatch(actual, expected) {
3091
+ return actual.length === expected.length && actual.every((name, index) => name === expected[index]);
3092
+ }
3093
+ class FileAcceptValidationError extends Error {
3094
+ }
3095
+ class FileUploadOutcomeAmbiguousError extends Error {
3096
+ constructor(reason, cleanupOutcome) {
3097
+ super(`file: upload outcome is ambiguous (${reason}); ${cleanupOutcome}. ` +
3098
+ 'Do not retry this upload automatically; inspect the current field first.');
3099
+ this.name = 'FileUploadOutcomeAmbiguousError';
3100
+ }
3101
+ }
3102
+ /** Parse only definite HTML file accept contracts; malformed tokens are inert. */
3103
+ function parseFileAcceptSpecifiers(rawAccept) {
3104
+ if (!rawAccept)
3105
+ return [];
3106
+ const specifiers = [];
3107
+ for (const rawToken of rawAccept.split(',')) {
3108
+ const token = rawToken.trim().toLowerCase();
3109
+ if (!token)
3110
+ continue;
3111
+ if (/^\.[a-z0-9][a-z0-9._+-]*$/i.test(token)) {
3112
+ specifiers.push({ kind: 'extension', value: token });
3113
+ continue;
3114
+ }
3115
+ const mimeParts = token.split('/');
3116
+ const mimeToken = /^[a-z0-9!#$%&'*+.^_`|~-]+$/i;
3117
+ if (mimeParts.length !== 2 ||
3118
+ !mimeToken.test(mimeParts[0] ?? '') ||
3119
+ (mimeParts[1] !== '*' && (!mimeToken.test(mimeParts[1] ?? '') || mimeParts[1].includes('*'))))
3120
+ continue;
3121
+ if (mimeParts[1] === '*') {
3122
+ specifiers.push({ kind: 'mime-wildcard', value: `${mimeParts[0]}/` });
3123
+ }
3124
+ else {
3125
+ specifiers.push({ kind: 'mime', value: token });
3126
+ }
3127
+ }
3128
+ return specifiers;
3129
+ }
3130
+ function pathMatchesFileAccept(path, specifiers) {
3131
+ const fileName = path.split(/[/\\]/).pop()?.toLowerCase() ?? path.toLowerCase();
3132
+ if (specifiers.some(specifier => specifier.kind === 'extension' && fileName.endsWith(specifier.value)))
3133
+ return true;
3134
+ const mimeSpecifiers = specifiers.filter(specifier => specifier.kind !== 'extension');
3135
+ const mime = mimeTypeForPath(path);
3136
+ if (!mime)
3137
+ return mimeSpecifiers.length > 0 ? 'unknown-mime' : false;
3138
+ return mimeSpecifiers.some(specifier => specifier.kind === 'mime-wildcard'
3139
+ ? mime.startsWith(specifier.value)
3140
+ : mime === specifier.value);
3141
+ }
3142
+ function assertPathsMatchFileAccept(paths, rawAccept) {
3143
+ const specifiers = parseFileAcceptSpecifiers(rawAccept);
3144
+ // Invalid-only accept strings do not establish a definite file contract.
3145
+ if (specifiers.length === 0)
3146
+ return;
3147
+ const unknownMime = paths.filter(path => pathMatchesFileAccept(path, specifiers) === 'unknown-mime');
3148
+ if (unknownMime.length > 0) {
3149
+ const names = unknownMime.map(path => path.split(/[/\\]/).pop() ?? path);
3150
+ throw new FileAcceptValidationError(`file: cannot safely infer MIME type for ${names.map(name => `"${name}"`).join(', ')} ` +
3151
+ `to validate input accept="${rawAccept ?? ''}"; no files were attached`);
3152
+ }
3153
+ const mismatches = paths.filter(path => pathMatchesFileAccept(path, specifiers) === false);
3154
+ if (mismatches.length === 0)
3155
+ return;
3156
+ const names = mismatches.map(path => path.split(/[/\\]/).pop() ?? path);
3157
+ throw new FileAcceptValidationError(`file: supplied file${names.length === 1 ? '' : 's'} ${names.map(name => `"${name}"`).join(', ')} ` +
3158
+ `did not match input accept="${rawAccept ?? ''}"; no files were attached`);
3159
+ }
3160
+ async function handleFileAccept(handle) {
3161
+ return await handle.evaluate(el => el instanceof HTMLInputElement && el.type === 'file'
3162
+ ? el.getAttribute('accept')
3163
+ : null);
3164
+ }
3165
+ /**
3166
+ * Playwright owns local-path transfer, so there is no practical atomic
3167
+ * in-page check+commit that works for arbitrarily large files without first
3168
+ * buffering and serializing their full contents. Keep the exact ElementHandle
3169
+ * instead and guard the short protocol-call window. Any relevant mutation in
3170
+ * that window makes the result explicitly ambiguous, even if the page toggles
3171
+ * the attribute back before our post-commit read.
3172
+ */
3173
+ async function beginFileInputCommitGuard(handle) {
3174
+ const guard = await handle.evaluateHandle((element) => {
3175
+ function composedParent(node) {
3176
+ if (node.parentElement)
3177
+ return node.parentElement;
3178
+ const root = node.getRootNode();
3179
+ return root instanceof ShadowRoot ? root.host : null;
3180
+ }
3181
+ function composedContains(ancestor, descendant) {
3182
+ let current = descendant;
3183
+ while (current) {
3184
+ if (current === ancestor)
3185
+ return true;
3186
+ current = composedParent(current);
3187
+ }
3188
+ return false;
3189
+ }
3190
+ function mutationBlockReason(target) {
2764
3191
  try {
2765
- await labeled.setInputFiles(paths);
2766
- return true;
3192
+ if (target.matches(':disabled'))
3193
+ return 'disabled';
3194
+ }
3195
+ catch { /* non-HTML namespaces may reject pseudo classes */ }
3196
+ if ((target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) &&
3197
+ target.readOnly)
3198
+ return 'readonly';
3199
+ let current = target;
3200
+ while (current) {
3201
+ if (current.hasAttribute('inert'))
3202
+ return 'inert';
3203
+ if (current.getAttribute('aria-disabled')?.trim().toLowerCase() === 'true')
3204
+ return 'aria-disabled';
3205
+ if (current.getAttribute('aria-readonly')?.trim().toLowerCase() === 'true')
3206
+ return 'aria-readonly';
3207
+ if (current instanceof HTMLFieldSetElement && current.disabled) {
3208
+ const firstLegend = Array.from(current.children).find(child => child instanceof HTMLLegendElement);
3209
+ if (!(firstLegend instanceof HTMLLegendElement) || !composedContains(firstLegend, target)) {
3210
+ return 'disabled fieldset';
3211
+ }
3212
+ }
3213
+ current = composedParent(current);
2767
3214
  }
2768
- catch {
2769
- /* try next frame */
3215
+ return null;
3216
+ }
3217
+ const input = element instanceof HTMLInputElement && element.type === 'file' ? element : null;
3218
+ const guardedAncestors = new Set();
3219
+ let current = input;
3220
+ while (current) {
3221
+ guardedAncestors.add(current);
3222
+ current = composedParent(current);
3223
+ }
3224
+ const initial = {
3225
+ validFileInput: Boolean(input?.isConnected),
3226
+ blockReason: input ? mutationBlockReason(input) : 'detached or no longer a file input',
3227
+ accept: input?.getAttribute('accept') ?? null,
3228
+ names: input ? Array.from(input.files ?? []).map(file => file.name) : [],
3229
+ };
3230
+ const state = {
3231
+ input,
3232
+ initial,
3233
+ invalidReason: null,
3234
+ observer: null,
3235
+ record: () => { },
3236
+ finalize: () => ({ invalidReason: 'commit guard was not initialized', names: [] }),
3237
+ };
3238
+ function markInvalid(reason) {
3239
+ state.invalidReason ??= reason;
3240
+ }
3241
+ function removedGuardedTarget(node) {
3242
+ if (!(node instanceof Element))
3243
+ return false;
3244
+ for (const ancestor of guardedAncestors) {
3245
+ if (node === ancestor || node.contains(ancestor))
3246
+ return true;
2770
3247
  }
2771
- continue;
3248
+ return false;
3249
+ }
3250
+ state.record = (records) => {
3251
+ for (const record of records) {
3252
+ if (record.type === 'childList' && Array.from(record.removedNodes).some(removedGuardedTarget)) {
3253
+ markInvalid('the exact target identity changed during commit');
3254
+ continue;
3255
+ }
3256
+ if (record.type !== 'attributes' || !(record.target instanceof Element))
3257
+ continue;
3258
+ if (!guardedAncestors.has(record.target))
3259
+ continue;
3260
+ const attribute = record.attributeName ?? 'state';
3261
+ if (attribute === 'accept')
3262
+ markInvalid('the target accept contract changed during commit');
3263
+ else if (attribute === 'type')
3264
+ markInvalid('the exact target stopped being a file input during commit');
3265
+ else
3266
+ markInvalid(`the target mutability changed during commit (${attribute})`);
3267
+ }
3268
+ };
3269
+ const observer = new MutationObserver(records => state.record(records));
3270
+ state.observer = observer;
3271
+ const roots = new Set();
3272
+ for (const ancestor of guardedAncestors)
3273
+ roots.add(ancestor.getRootNode());
3274
+ for (const root of roots) {
3275
+ observer.observe(root, {
3276
+ subtree: true,
3277
+ childList: true,
3278
+ attributes: true,
3279
+ attributeOldValue: true,
3280
+ attributeFilter: ['accept', 'type', 'disabled', 'readonly', 'inert', 'aria-disabled', 'aria-readonly'],
3281
+ });
3282
+ }
3283
+ state.finalize = () => {
3284
+ state.record(observer.takeRecords());
3285
+ observer.disconnect();
3286
+ if (!input?.isConnected)
3287
+ markInvalid('the exact target detached during commit');
3288
+ else if (input.type !== 'file')
3289
+ markInvalid('the exact target is no longer a file input');
3290
+ else if (input.getAttribute('accept') !== initial.accept)
3291
+ markInvalid('the target accept contract changed during commit');
3292
+ else {
3293
+ const blocked = mutationBlockReason(input);
3294
+ if (blocked)
3295
+ markInvalid(`the target is not mutable after commit (${blocked})`);
3296
+ }
3297
+ return {
3298
+ invalidReason: state.invalidReason,
3299
+ names: input ? Array.from(input.files ?? []).map(file => file.name) : [],
3300
+ };
3301
+ };
3302
+ return state;
3303
+ });
3304
+ const initial = await guard.evaluate(value => value.initial);
3305
+ return { guard, initial };
3306
+ }
3307
+ async function finalizeFileInputCommitGuard(guard) {
3308
+ return await guard.evaluate(value => value.finalize());
3309
+ }
3310
+ async function stopFileInputCommitGuard(guard) {
3311
+ await guard.evaluate(value => {
3312
+ value.observer.disconnect();
3313
+ }).catch(() => { });
3314
+ }
3315
+ async function clearAmbiguousRetainedFiles(handle, actualNames, initialNames) {
3316
+ const selectionChanged = !uploadedNamesMatch(actualNames, initialNames);
3317
+ if (!selectionChanged) {
3318
+ return actualNames.length === 0
3319
+ ? 'no retained file selection remained to clear'
3320
+ : 'the preexisting selection was preserved because a same-named replacement could not be proven';
3321
+ }
3322
+ if (actualNames.length === 0)
3323
+ return 'no newly retained files remained on the target';
3324
+ try {
3325
+ await handle.setInputFiles([]);
3326
+ }
3327
+ catch {
3328
+ await handle.evaluate((el) => {
3329
+ if (el instanceof HTMLInputElement && el.type === 'file')
3330
+ el.value = '';
3331
+ }).catch(() => { });
3332
+ }
3333
+ return 'the changed selection was cleared best-effort without trying another candidate';
3334
+ }
3335
+ async function commitFilesToExactInput(handle, paths, targetName) {
3336
+ const { guard, initial } = await beginFileInputCommitGuard(handle);
3337
+ try {
3338
+ if (!initial.validFileInput) {
3339
+ await stopFileInputCommitGuard(guard);
3340
+ throw new Error(`file: ${targetName} detached or is no longer a file input`);
3341
+ }
3342
+ if (initial.blockReason) {
3343
+ await stopFileInputCommitGuard(guard);
3344
+ throw new Error(`file: ${targetName} is not mutable (${initial.blockReason})`);
3345
+ }
3346
+ try {
3347
+ assertPathsMatchFileAccept(paths, initial.accept);
3348
+ }
3349
+ catch (error) {
3350
+ await stopFileInputCommitGuard(guard);
3351
+ throw error;
3352
+ }
3353
+ let operationError;
3354
+ try {
3355
+ await handle.setInputFiles(paths);
2772
3356
  }
3357
+ catch (error) {
3358
+ operationError = error;
3359
+ }
3360
+ // Give synchronous handlers, their microtasks, and one timer turn a chance
3361
+ // to reject/clear the selection before calling the outcome confirmed.
3362
+ await delay(40);
3363
+ const final = await finalizeFileInputCommitGuard(guard).catch(() => ({
3364
+ invalidReason: 'the exact target could not be inspected after commit',
3365
+ names: [],
3366
+ }));
3367
+ const retained = uploadedNamesMatch(final.names, expectedUploadNames(paths));
3368
+ if (!operationError && !final.invalidReason && retained)
3369
+ return;
3370
+ const cleanupOutcome = await clearAmbiguousRetainedFiles(handle, final.names, initial.names);
3371
+ const operationReason = operationError instanceof Error
3372
+ ? `Playwright could not confirm the exact-target commit: ${operationError.message}`
3373
+ : operationError
3374
+ ? 'Playwright could not confirm the exact-target commit'
3375
+ : null;
3376
+ const reason = final.invalidReason ?? operationReason ?? 'the exact target did not retain the supplied filenames';
3377
+ throw new FileUploadOutcomeAmbiguousError(reason, cleanupOutcome);
3378
+ }
3379
+ finally {
3380
+ await guard.dispose().catch(() => { });
3381
+ }
3382
+ }
3383
+ async function attachHiddenInAllFrames(page, paths, opts) {
3384
+ if (opts?.fieldLabel || opts?.fieldKey || opts?.contextText || opts?.sectionText) {
3385
+ const labeled = await findLabeledFileInputInPage(page, opts.fieldLabel ?? '', opts.exact ?? false, opts.cache, opts.fieldId, opts.fieldKey, opts.contextText, opts.sectionText);
3386
+ if (labeled) {
3387
+ const handle = await labeled.elementHandle();
3388
+ if (!handle)
3389
+ throw new Error('file: matched input detached before upload');
3390
+ await commitFilesToExactInput(handle, paths, 'matched input');
3391
+ return true;
3392
+ }
3393
+ return false;
3394
+ }
3395
+ let acceptError;
3396
+ for (const frame of page.frames()) {
2773
3397
  const loc = frame.locator('input[type="file"]');
2774
3398
  const n = await loc.count();
2775
3399
  for (let i = 0; i < n; i++) {
3400
+ if (await locatorMutationBlockReason(loc.nth(i), 'file'))
3401
+ continue;
3402
+ const handle = await loc.nth(i).elementHandle();
3403
+ if (!handle)
3404
+ continue;
2776
3405
  try {
2777
- await loc.nth(i).setInputFiles(paths);
3406
+ await commitFilesToExactInput(handle, paths, 'matched input');
2778
3407
  return true;
2779
3408
  }
2780
- catch {
2781
- /* try next */
3409
+ catch (error) {
3410
+ if (error instanceof FileAcceptValidationError) {
3411
+ acceptError ??= error;
3412
+ continue;
3413
+ }
3414
+ throw error;
2782
3415
  }
2783
3416
  }
2784
3417
  }
3418
+ if (acceptError)
3419
+ throw acceptError;
2785
3420
  return false;
2786
3421
  }
2787
3422
  async function attachViaChooser(page, paths, clickX, clickY) {
3423
+ await assertPointMutable(page, clickX, clickY, 'file', 'file');
2788
3424
  const [chooser] = await Promise.all([
2789
3425
  page.waitForEvent('filechooser', { timeout: 12_000 }),
2790
3426
  page.mouse.click(clickX, clickY),
2791
3427
  ]);
2792
- await chooser.setFiles(paths);
3428
+ const input = chooser.element();
3429
+ await commitFilesToExactInput(input, paths, 'chooser input');
2793
3430
  }
2794
3431
  /**
2795
3432
  * Map common file extensions to MIME types so react-dropzone's `accept` filter
2796
- * does not silently drop our synthetic files. Defaults to application/octet-stream
2797
- * for unknowns, which is rejected by most dropzones configured with accept.
3433
+ * does not silently drop our synthetic files. A null result means MIME cannot
3434
+ * be inferred reliably from the path and must not be used to reject a MIME
3435
+ * accept contract as though application/octet-stream were authoritative.
2798
3436
  */
2799
3437
  function mimeTypeForPath(path) {
2800
3438
  const ext = path.toLowerCase().split('.').pop() ?? '';
@@ -2802,7 +3440,20 @@ function mimeTypeForPath(path) {
2802
3440
  case 'pdf': return 'application/pdf';
2803
3441
  case 'doc': return 'application/msword';
2804
3442
  case 'docx': return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
3443
+ case 'xls': return 'application/vnd.ms-excel';
3444
+ case 'xlsx': return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
3445
+ case 'ppt': return 'application/vnd.ms-powerpoint';
3446
+ case 'pptx': return 'application/vnd.openxmlformats-officedocument.presentationml.presentation';
3447
+ case 'json': return 'application/json';
3448
+ case 'xml': return 'application/xml';
3449
+ case 'zip': return 'application/zip';
3450
+ case 'gz': return 'application/gzip';
3451
+ case 'tar': return 'application/x-tar';
3452
+ case '7z': return 'application/x-7z-compressed';
2805
3453
  case 'txt': return 'text/plain';
3454
+ case 'csv': return 'text/csv';
3455
+ case 'htm':
3456
+ case 'html': return 'text/html';
2806
3457
  case 'rtf': return 'application/rtf';
2807
3458
  case 'png': return 'image/png';
2808
3459
  case 'jpg':
@@ -2810,9 +3461,76 @@ function mimeTypeForPath(path) {
2810
3461
  case 'gif': return 'image/gif';
2811
3462
  case 'webp': return 'image/webp';
2812
3463
  case 'svg': return 'image/svg+xml';
2813
- default: return 'application/octet-stream';
3464
+ case 'avif': return 'image/avif';
3465
+ case 'bmp': return 'image/bmp';
3466
+ case 'tif':
3467
+ case 'tiff': return 'image/tiff';
3468
+ case 'mp3': return 'audio/mpeg';
3469
+ case 'm4a': return 'audio/mp4';
3470
+ case 'wav': return 'audio/wav';
3471
+ case 'ogg': return 'audio/ogg';
3472
+ case 'mp4': return 'video/mp4';
3473
+ case 'mov': return 'video/quicktime';
3474
+ case 'webm': return 'video/webm';
3475
+ default: return null;
2814
3476
  }
2815
3477
  }
3478
+ async function dropFileInputAtPoint(page, x, y) {
3479
+ const handle = await page.mainFrame().evaluateHandle(({ x: clientX, y: clientY }) => {
3480
+ const deepest = document.elementFromPoint(clientX, clientY);
3481
+ if (!deepest)
3482
+ return null;
3483
+ function composedParent(element) {
3484
+ if (element.parentElement)
3485
+ return element.parentElement;
3486
+ const root = element.getRootNode();
3487
+ return root instanceof ShadowRoot ? root.host : null;
3488
+ }
3489
+ function isExplicitLocalBoundary(element) {
3490
+ const tag = element.tagName.toLowerCase();
3491
+ const className = typeof element.className === 'string' ? element.className : '';
3492
+ const testId = element.getAttribute('data-testid') ?? '';
3493
+ const buttonText = `${element.getAttribute('aria-label') ?? ''} ${element.textContent ?? ''}`;
3494
+ return tag === 'label' ||
3495
+ element.hasAttribute('data-dropzone') ||
3496
+ element.getAttributeNames().some(attribute => attribute.startsWith('data-rfd')) ||
3497
+ /drop[-_]?zone|file[-_]?upload|attach|upload/i.test(className) ||
3498
+ /drop|file|upload|attach/i.test(testId) ||
3499
+ (element.getAttribute('role') === 'button' && /drop|file|upload|attach/i.test(buttonText));
3500
+ }
3501
+ function isBroadContainer(element) {
3502
+ return element.matches('form, [role="form"], fieldset, section');
3503
+ }
3504
+ let current = deepest;
3505
+ let associationClosed = false;
3506
+ for (let depth = 0; current && depth < 16; depth++, current = composedParent(current)) {
3507
+ if (current instanceof HTMLInputElement && current.type === 'file')
3508
+ return current;
3509
+ // Do not infer a relationship from an unrelated page-global input.
3510
+ if (current === document.body || current === document.documentElement)
3511
+ break;
3512
+ const inputs = current.querySelectorAll('input[type="file"]');
3513
+ if (inputs.length > 1) {
3514
+ throw new Error(`file: coordinate-only drop target is ambiguous; nearest candidate container has ${inputs.length} file inputs`);
3515
+ }
3516
+ const input = inputs[0];
3517
+ const explicitBoundary = isExplicitLocalBoundary(current);
3518
+ const broadContainer = isBroadContainer(current);
3519
+ if (input instanceof HTMLInputElement &&
3520
+ !associationClosed &&
3521
+ explicitBoundary)
3522
+ return input;
3523
+ if (explicitBoundary || broadContainer)
3524
+ associationClosed = true;
3525
+ }
3526
+ return null;
3527
+ }, { x, y });
3528
+ const element = handle.asElement();
3529
+ if (element)
3530
+ return element;
3531
+ await handle.dispose();
3532
+ return null;
3533
+ }
2816
3534
  /**
2817
3535
  * Synthetic drop at (x,y) using file bytes from the proxy host.
2818
3536
  *
@@ -2830,91 +3548,260 @@ function mimeTypeForPath(path) {
2830
3548
  * button layer and the wrapper that actually has the listeners.
2831
3549
  */
2832
3550
  async function attachViaDropPlaywright(page, paths, dropX, dropY) {
2833
- const fs = await import('node:fs/promises');
2834
- const buffers = await Promise.all(paths.map(p => fs.readFile(p)));
2835
- const names = paths.map(p => p.split(/[/\\\\]/).pop() ?? 'file');
2836
- const mimes = paths.map(mimeTypeForPath);
2837
- await page.mouse.move(dropX, dropY);
2838
- await page.mainFrame().evaluate(({ bufs, ns, ms, x, y }) => {
2839
- const makeDataTransfer = () => {
2840
- const dt = new DataTransfer();
2841
- for (let i = 0; i < bufs.length; i++) {
2842
- const u8 = new Uint8Array(bufs[i]);
2843
- const blob = new Blob([u8], { type: ms[i] });
2844
- dt.items.add(new File([blob], ns[i], { type: ms[i] }));
2845
- }
2846
- return dt;
2847
- };
2848
- const dispatchSequence = (target) => {
2849
- // Each event needs its own DataTransfer because some frameworks
2850
- // consume the items list during dragenter/dragover inspection.
2851
- for (const type of ['dragenter', 'dragover', 'drop']) {
2852
- const dt = makeDataTransfer();
2853
- const ev = new DragEvent(type, {
2854
- bubbles: true,
2855
- cancelable: true,
2856
- composed: true,
2857
- clientX: x,
2858
- clientY: y,
2859
- dataTransfer: dt,
2860
- });
2861
- // Some browsers/frameworks freeze the dataTransfer getter; re-define
2862
- // it defensively so listeners read the files.
3551
+ const dropFileInput = await dropFileInputAtPoint(page, dropX, dropY);
3552
+ try {
3553
+ // A real scoped input establishes the drop contract. Validate before
3554
+ // mouse movement, drag events, input assignment, or application callbacks.
3555
+ // Pure dropzones without an input retain their framework-defined behavior.
3556
+ const observedAccept = dropFileInput ? await handleFileAccept(dropFileInput) : null;
3557
+ if (dropFileInput) {
3558
+ const inputBlockReason = await dropFileInput.evaluate(mutationBlockReasonInPage, 'file');
3559
+ if (inputBlockReason) {
3560
+ throw new Error(`file: associated drop input is not mutable (${inputBlockReason})`);
3561
+ }
3562
+ }
3563
+ assertPathsMatchFileAccept(paths, observedAccept);
3564
+ const fs = await import('node:fs/promises');
3565
+ const buffers = await Promise.all(paths.map(p => fs.readFile(p)));
3566
+ const names = paths.map(p => p.split(/[/\\\\]/).pop() ?? 'file');
3567
+ const mimes = paths.map(path => mimeTypeForPath(path) ?? 'application/octet-stream');
3568
+ await page.mouse.move(dropX, dropY);
3569
+ const result = await page.mainFrame().evaluate(async ({ bufs, ns, ms, x, y, expectedAccept, expectedFileInput }) => {
3570
+ function blockReason(target) {
3571
+ function parent(node) {
3572
+ if (node.parentElement)
3573
+ return node.parentElement;
3574
+ const root = node.getRootNode();
3575
+ return root instanceof ShadowRoot ? root.host : null;
3576
+ }
3577
+ function within(node, ancestor) {
3578
+ let current = node;
3579
+ while (current) {
3580
+ if (current === ancestor)
3581
+ return true;
3582
+ current = parent(current);
3583
+ }
3584
+ return false;
3585
+ }
2863
3586
  try {
2864
- Object.defineProperty(ev, 'dataTransfer', { value: dt, configurable: true });
3587
+ if (target.matches(':disabled'))
3588
+ return 'disabled';
2865
3589
  }
2866
3590
  catch { /* ignore */ }
2867
- target.dispatchEvent(ev);
2868
- }
2869
- };
2870
- const deepest = document.elementFromPoint(x, y);
2871
- const targets = [];
2872
- if (deepest)
2873
- targets.push(deepest);
2874
- // Walk up a few ancestors looking for anything that looks like a dropzone
2875
- // root (react-dropzone emits `data-rfd-*`; Greenhouse uses div.drop-zone;
2876
- // Ashby uses role="button" wrappers). We dispatch on the first dropzone-ish
2877
- // ancestor AND the deepest element, because we cannot tell statically
2878
- // which one has the real listener.
2879
- let p = deepest?.parentElement ?? null;
2880
- for (let depth = 0; depth < 12 && p; depth++) {
2881
- const tag = p.tagName.toLowerCase();
2882
- const attrs = p.getAttributeNames();
2883
- const looksLikeDropzone = attrs.some(a => a.startsWith('data-rfd') || a === 'data-dropzone' || a === 'data-testid') ||
2884
- (p.className && typeof p.className === 'string' && /drop[-_]?zone|file[-_]?upload|attach|upload/i.test(p.className)) ||
2885
- tag === 'label' ||
2886
- p.getAttribute('role') === 'button';
2887
- if (looksLikeDropzone && !targets.includes(p))
2888
- targets.push(p);
2889
- p = p.parentElement;
2890
- }
2891
- if (targets.length === 0)
2892
- targets.push(document.body);
2893
- for (const t of targets)
2894
- dispatchSequence(t);
2895
- // As a final fallback, if there is a hidden input[type=file] scoped to
2896
- // the nearest form/section, set its `files` via DataTransfer and dispatch
2897
- // the React-friendly native input value setter so react-hook-form notices.
2898
- const host = deepest?.closest('form, [role="form"], fieldset, section, div') ?? document.body;
2899
- const fileInput = host.querySelector('input[type="file"]');
2900
- if (fileInput) {
2901
- const dt = makeDataTransfer();
2902
- try {
2903
- Object.defineProperty(fileInput, 'files', { value: dt.files, configurable: true });
3591
+ if ((target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) && target.readOnly)
3592
+ return 'readonly';
3593
+ let current = target;
3594
+ while (current) {
3595
+ if (current.hasAttribute('inert'))
3596
+ return 'inert';
3597
+ if (current.getAttribute('aria-disabled')?.trim().toLowerCase() === 'true')
3598
+ return 'aria-disabled';
3599
+ if (current.getAttribute('aria-readonly')?.trim().toLowerCase() === 'true')
3600
+ return 'aria-readonly';
3601
+ if (current instanceof HTMLFieldSetElement && current.disabled) {
3602
+ const legend = Array.from(current.children).find(child => child instanceof HTMLLegendElement);
3603
+ if (!(legend instanceof HTMLLegendElement) || !within(target, legend))
3604
+ return 'disabled fieldset';
3605
+ }
3606
+ current = parent(current);
3607
+ }
3608
+ return null;
2904
3609
  }
2905
- catch { /* ignore */ }
2906
- try {
2907
- // React overrides the input value setter; we need the native prototype
2908
- // setter so React sees the change.
2909
- const proto = Object.getPrototypeOf(fileInput);
2910
- const descriptor = Object.getOwnPropertyDescriptor(proto, 'value');
2911
- descriptor?.set?.call(fileInput, '');
3610
+ const makeDataTransfer = () => {
3611
+ const dt = new DataTransfer();
3612
+ for (let i = 0; i < bufs.length; i++) {
3613
+ const u8 = new Uint8Array(bufs[i]);
3614
+ const blob = new Blob([u8], { type: ms[i] });
3615
+ dt.items.add(new File([blob], ns[i], { type: ms[i] }));
3616
+ }
3617
+ return dt;
3618
+ };
3619
+ const dispatchSequence = (target) => {
3620
+ let handled = false;
3621
+ // Each event needs its own DataTransfer because some frameworks
3622
+ // consume the items list during dragenter/dragover inspection.
3623
+ for (const type of ['dragenter', 'dragover', 'drop']) {
3624
+ const dt = makeDataTransfer();
3625
+ const ev = new DragEvent(type, {
3626
+ bubbles: true,
3627
+ cancelable: true,
3628
+ composed: true,
3629
+ clientX: x,
3630
+ clientY: y,
3631
+ dataTransfer: dt,
3632
+ });
3633
+ try {
3634
+ Object.defineProperty(ev, 'dataTransfer', { value: dt, configurable: true });
3635
+ }
3636
+ catch { /* ignore */ }
3637
+ const dispatched = target.dispatchEvent(ev);
3638
+ if (!dispatched || ev.defaultPrevented)
3639
+ handled = true;
3640
+ }
3641
+ return handled;
3642
+ };
3643
+ const deepest = document.elementFromPoint(x, y);
3644
+ if (!deepest) {
3645
+ return { accepted: false, blockReason: null, acceptContractChanged: false, ambiguousTarget: false };
3646
+ }
3647
+ const blocked = blockReason(deepest);
3648
+ if (blocked) {
3649
+ return { accepted: false, blockReason: blocked, acceptContractChanged: false, ambiguousTarget: false };
3650
+ }
3651
+ function resolveFileInput(start) {
3652
+ function composedParent(element) {
3653
+ if (element.parentElement)
3654
+ return element.parentElement;
3655
+ const root = element.getRootNode();
3656
+ return root instanceof ShadowRoot ? root.host : null;
3657
+ }
3658
+ function isExplicitLocalBoundary(element) {
3659
+ const tag = element.tagName.toLowerCase();
3660
+ const className = typeof element.className === 'string' ? element.className : '';
3661
+ const testId = element.getAttribute('data-testid') ?? '';
3662
+ const buttonText = `${element.getAttribute('aria-label') ?? ''} ${element.textContent ?? ''}`;
3663
+ return tag === 'label' ||
3664
+ element.hasAttribute('data-dropzone') ||
3665
+ element.getAttributeNames().some(attribute => attribute.startsWith('data-rfd')) ||
3666
+ /drop[-_]?zone|file[-_]?upload|attach|upload/i.test(className) ||
3667
+ /drop|file|upload|attach/i.test(testId) ||
3668
+ (element.getAttribute('role') === 'button' && /drop|file|upload|attach/i.test(buttonText));
3669
+ }
3670
+ function isBroadContainer(element) {
3671
+ return element.matches('form, [role="form"], fieldset, section');
3672
+ }
3673
+ let current = start;
3674
+ let associationClosed = false;
3675
+ for (let depth = 0; current && depth < 16; depth++, current = composedParent(current)) {
3676
+ if (current instanceof HTMLInputElement && current.type === 'file') {
3677
+ return { input: current, host: current, ambiguous: false };
3678
+ }
3679
+ if (current === document.body || current === document.documentElement)
3680
+ break;
3681
+ const inputs = current.querySelectorAll('input[type="file"]');
3682
+ if (inputs.length > 1)
3683
+ return { input: null, host: current, ambiguous: true };
3684
+ const input = inputs[0];
3685
+ const explicitBoundary = isExplicitLocalBoundary(current);
3686
+ const broadContainer = isBroadContainer(current);
3687
+ if (input instanceof HTMLInputElement &&
3688
+ !associationClosed &&
3689
+ explicitBoundary)
3690
+ return { input, host: current, ambiguous: false };
3691
+ if (explicitBoundary || broadContainer)
3692
+ associationClosed = true;
3693
+ }
3694
+ return {
3695
+ input: null,
3696
+ host: start.closest('form, [role="form"], fieldset, section, div') ?? document.body,
3697
+ ambiguous: false,
3698
+ };
3699
+ }
3700
+ const targets = [deepest];
3701
+ let p = deepest.parentElement;
3702
+ for (let depth = 0; depth < 12 && p; depth++) {
3703
+ const tag = p.tagName.toLowerCase();
3704
+ const attrs = p.getAttributeNames();
3705
+ const looksLikeDropzone = attrs.some(a => a.startsWith('data-rfd') || a === 'data-dropzone' || a === 'data-testid') ||
3706
+ (p.className && typeof p.className === 'string' && /drop[-_]?zone|file[-_]?upload|attach|upload/i.test(p.className)) ||
3707
+ tag === 'label' ||
3708
+ p.getAttribute('role') === 'button';
3709
+ if (looksLikeDropzone && !targets.includes(p))
3710
+ targets.push(p);
3711
+ p = p.parentElement;
3712
+ }
3713
+ const resolvedTarget = resolveFileInput(deepest);
3714
+ if (resolvedTarget.ambiguous) {
3715
+ return { accepted: false, blockReason: null, acceptContractChanged: false, ambiguousTarget: true };
3716
+ }
3717
+ const host = resolvedTarget.host;
3718
+ const fileInput = resolvedTarget.input;
3719
+ if (fileInput !== expectedFileInput) {
3720
+ return { accepted: false, blockReason: null, acceptContractChanged: true, ambiguousTarget: false };
3721
+ }
3722
+ const currentAccept = fileInput?.getAttribute('accept') ?? null;
3723
+ if (currentAccept !== expectedAccept) {
3724
+ return { accepted: false, blockReason: null, acceptContractChanged: true, ambiguousTarget: false };
3725
+ }
3726
+ if (fileInput) {
3727
+ const inputBlockReason = blockReason(fileInput);
3728
+ if (inputBlockReason) {
3729
+ throw new Error(`file: associated drop input is not mutable (${inputBlockReason})`);
3730
+ }
2912
3731
  }
2913
- catch { /* ignore */ }
2914
- fileInput.dispatchEvent(new Event('input', { bubbles: true }));
2915
- fileInput.dispatchEvent(new Event('change', { bubbles: true }));
3732
+ const beforeFileNames = Array.from(fileInput?.files ?? []).map(file => file.name);
3733
+ const beforeRenderedText = [host, ...targets]
3734
+ .map(node => node.textContent ?? '')
3735
+ .join(' ')
3736
+ .toLowerCase();
3737
+ let handled = false;
3738
+ for (const target of targets) {
3739
+ if (!blockReason(target))
3740
+ handled = dispatchSequence(target) || handled;
3741
+ }
3742
+ // A scoped file input can provide strong acceptance evidence, but only
3743
+ // after application handlers have had a chance to reject/clear it.
3744
+ if (fileInput && !blockReason(fileInput)) {
3745
+ const dt = makeDataTransfer();
3746
+ try {
3747
+ Object.defineProperty(fileInput, 'files', { value: dt.files, configurable: true });
3748
+ }
3749
+ catch { /* ignore */ }
3750
+ try {
3751
+ const proto = Object.getPrototypeOf(fileInput);
3752
+ const descriptor = Object.getOwnPropertyDescriptor(proto, 'value');
3753
+ descriptor?.set?.call(fileInput, '');
3754
+ }
3755
+ catch { /* ignore */ }
3756
+ fileInput.dispatchEvent(new Event('input', { bubbles: true }));
3757
+ fileInput.dispatchEvent(new Event('change', { bubbles: true }));
3758
+ }
3759
+ // Framework drop handlers commonly commit on a microtask or animation
3760
+ // frame. Only report success after observable page state contains every
3761
+ // submitted filename; canceling dragover alone is not acceptance.
3762
+ await Promise.resolve();
3763
+ await new Promise(resolveTimer => setTimeout(resolveTimer, 32));
3764
+ const settledFileNames = Array.from(fileInput?.files ?? []).map(file => file.name);
3765
+ const fileInputAccepted = Boolean(fileInput && !blockReason(fileInput)) &&
3766
+ ns.every(name => settledFileNames.includes(name)) &&
3767
+ (settledFileNames.length !== beforeFileNames.length || settledFileNames.some((name, index) => name !== beforeFileNames[index]));
3768
+ const renderedText = [host, ...targets]
3769
+ .map(node => node.textContent ?? '')
3770
+ .join(' ')
3771
+ .toLowerCase();
3772
+ const renderedAcceptance = renderedText !== beforeRenderedText &&
3773
+ ns.every(name => renderedText.includes(name.toLowerCase()));
3774
+ return {
3775
+ accepted: fileInputAccepted || (handled && renderedAcceptance),
3776
+ blockReason: null,
3777
+ acceptContractChanged: false,
3778
+ ambiguousTarget: false,
3779
+ };
3780
+ }, {
3781
+ bufs: buffers.map(b => Array.from(b)),
3782
+ ns: names,
3783
+ ms: mimes,
3784
+ x: dropX,
3785
+ y: dropY,
3786
+ expectedAccept: observedAccept,
3787
+ expectedFileInput: dropFileInput,
3788
+ });
3789
+ if (result.ambiguousTarget) {
3790
+ throw new Error('file: coordinate-only drop target became ambiguous between multiple file inputs');
3791
+ }
3792
+ if (result.acceptContractChanged) {
3793
+ throw new Error('file: drop target accept contract changed before upload; retry with a fresh target');
3794
+ }
3795
+ if (result.blockReason) {
3796
+ throw new Error(`file: drop target is not mutable (${result.blockReason})`);
3797
+ }
3798
+ if (!result.accepted) {
3799
+ throw new Error('file: drop target did not accept the supplied files');
2916
3800
  }
2917
- }, { bufs: buffers.map(b => Array.from(b)), ns: names, ms: mimes, x: dropX, y: dropY });
3801
+ }
3802
+ finally {
3803
+ await dropFileInput?.dispose().catch(() => { });
3804
+ }
2918
3805
  }
2919
3806
  /**
2920
3807
  * `strategy`:
@@ -2928,7 +3815,8 @@ const ATTACH_BUTTON_PATTERN = /attach|upload|add.file|choose.file|browse/i;
2928
3815
  * Find an Attach/Upload button near a field label (e.g. Greenhouse's "Attach" button
2929
3816
  * inside a "Resume/CV" section). Returns the button Locator or null.
2930
3817
  */
2931
- async function findAttachButtonNearLabel(page, fieldLabel) {
3818
+ async function findAttachButtonNearLabel(page, fieldLabel, contextText, sectionText) {
3819
+ const matches = [];
2932
3820
  for (const frame of page.frames()) {
2933
3821
  // Find all buttons/links whose text matches attach-like patterns
2934
3822
  const buttons = frame.locator('button, a, [role="button"]');
@@ -2952,15 +3840,18 @@ async function findAttachButtonNearLabel(page, fieldLabel) {
2952
3840
  }
2953
3841
  return false;
2954
3842
  }, fieldLabel);
2955
- if (nearLabel)
2956
- return btn;
3843
+ if (nearLabel && await locatorMatchesFileScope(btn, contextText, sectionText))
3844
+ matches.push(btn);
2957
3845
  }
2958
3846
  catch {
2959
3847
  continue;
2960
3848
  }
2961
3849
  }
2962
3850
  }
2963
- return null;
3851
+ if (matches.length > 1) {
3852
+ throw new Error(`file: ambiguous Attach/Upload control for field "${fieldLabel}"; provide a more specific contextText or sectionText`);
3853
+ }
3854
+ return matches[0] ?? null;
2964
3855
  }
2965
3856
  export async function attachFiles(page, paths, opts) {
2966
3857
  const strategy = opts?.strategy ?? 'auto';
@@ -2970,6 +3861,51 @@ export async function attachFiles(page, paths, opts) {
2970
3861
  const exact = opts?.exact ?? false;
2971
3862
  const dropX = opts?.dropX;
2972
3863
  const dropY = opts?.dropY;
3864
+ const hasClickX = clickX !== undefined;
3865
+ const hasClickY = clickY !== undefined;
3866
+ const hasDropX = dropX !== undefined;
3867
+ const hasDropY = dropY !== undefined;
3868
+ const hasClick = hasClickX && hasClickY;
3869
+ const hasDrop = hasDropX && hasDropY;
3870
+ const hasFieldLabel = Boolean(fieldLabel?.trim());
3871
+ const hasFieldKey = Boolean(opts?.fieldKey);
3872
+ const hasSemanticTarget = hasFieldLabel || hasFieldKey;
3873
+ const hasSemanticExtras = Boolean(opts?.fieldId || opts?.fieldKey || fieldLabel || opts?.contextText || opts?.sectionText || opts?.exact !== undefined);
3874
+ if (hasClickX !== hasClickY)
3875
+ throw new Error('file: clickX and clickY must be provided together');
3876
+ if (hasDropX !== hasDropY)
3877
+ throw new Error('file: dropX and dropY must be provided together');
3878
+ if ((opts?.contextText || opts?.sectionText || opts?.exact !== undefined) && !hasFieldLabel) {
3879
+ throw new Error('file: contextText, sectionText, and exact require fieldLabel');
3880
+ }
3881
+ if (opts?.fieldId && !hasSemanticTarget) {
3882
+ throw new Error('file: fieldId requires fieldLabel or fieldKey');
3883
+ }
3884
+ if (strategy === 'chooser') {
3885
+ if (!hasClick)
3886
+ throw new Error('file: chooser strategy requires x,y click coordinates');
3887
+ if (hasDrop || hasSemanticExtras)
3888
+ throw new Error('file: chooser strategy accepts only a coordinate target');
3889
+ }
3890
+ else if (strategy === 'drop') {
3891
+ if (!hasDrop)
3892
+ throw new Error('file: drop strategy requires dropX, dropY');
3893
+ if (hasClick || hasSemanticExtras)
3894
+ throw new Error('file: drop strategy accepts only a drop coordinate target');
3895
+ }
3896
+ else if (strategy === 'hidden') {
3897
+ if (!hasSemanticTarget)
3898
+ throw new Error('file: hidden strategy requires fieldLabel or fieldKey');
3899
+ if (hasClick || hasDrop)
3900
+ throw new Error('file: hidden strategy does not accept coordinates');
3901
+ }
3902
+ else {
3903
+ if (hasDrop)
3904
+ throw new Error('file: dropX/dropY require strategy "drop"');
3905
+ if (hasClick === hasSemanticTarget) {
3906
+ throw new Error('file: auto strategy requires exactly one explicit target: click coordinates, fieldLabel, or fieldKey');
3907
+ }
3908
+ }
2973
3909
  if (strategy === 'chooser' || (strategy === 'auto' && clickX !== undefined && clickY !== undefined)) {
2974
3910
  if (clickX === undefined || clickY === undefined) {
2975
3911
  throw new Error('file: chooser strategy requires x,y click coordinates');
@@ -2978,7 +3914,15 @@ export async function attachFiles(page, paths, opts) {
2978
3914
  return;
2979
3915
  }
2980
3916
  if (strategy === 'hidden' || strategy === 'auto') {
2981
- if (await attachHiddenInAllFrames(page, paths, { fieldId: opts?.fieldId, fieldLabel, exact, cache: opts?.cache }))
3917
+ if (await attachHiddenInAllFrames(page, paths, {
3918
+ fieldId: opts?.fieldId,
3919
+ fieldKey: opts?.fieldKey,
3920
+ fieldLabel,
3921
+ exact,
3922
+ contextText: opts?.contextText,
3923
+ sectionText: opts?.sectionText,
3924
+ cache: opts?.cache,
3925
+ }))
2982
3926
  return;
2983
3927
  if (strategy === 'hidden') {
2984
3928
  if (fieldLabel) {
@@ -2988,9 +3932,10 @@ export async function attachFiles(page, paths, opts) {
2988
3932
  }
2989
3933
  // Fallback: look for an Attach/Upload button near the field label and use the file chooser
2990
3934
  if (fieldLabel && strategy === 'auto') {
2991
- const attachBtn = await findAttachButtonNearLabel(page, fieldLabel);
3935
+ const attachBtn = await findAttachButtonNearLabel(page, fieldLabel, opts?.contextText, opts?.sectionText);
2992
3936
  if (attachBtn) {
2993
3937
  try {
3938
+ await assertLocatorMutable(attachBtn, 'file', 'file');
2994
3939
  await attachBtn.scrollIntoViewIfNeeded();
2995
3940
  const box = await attachBtn.boundingBox();
2996
3941
  if (box) {
@@ -3004,6 +3949,12 @@ export async function attachFiles(page, paths, opts) {
3004
3949
  if (fieldLabel) {
3005
3950
  throw new Error(`file: no input[type=file] matching field "${fieldLabel}"`);
3006
3951
  }
3952
+ if (opts?.fieldKey) {
3953
+ throw new Error(`file: no input[type=file] matching fieldKey "${opts.fieldKey}"`);
3954
+ }
3955
+ if (opts?.contextText || opts?.sectionText) {
3956
+ throw new Error('file: no input[type=file] matched the requested context/section');
3957
+ }
3007
3958
  }
3008
3959
  if (strategy === 'drop' || (strategy === 'auto' && dropX !== undefined && dropY !== undefined)) {
3009
3960
  if (dropX === undefined || dropY === undefined) {
@@ -3012,20 +3963,20 @@ export async function attachFiles(page, paths, opts) {
3012
3963
  await attachViaDropPlaywright(page, paths, dropX, dropY);
3013
3964
  return;
3014
3965
  }
3015
- for (const frame of page.frames()) {
3016
- const loc = frame.locator('input[type="file"]');
3017
- const n = await loc.count();
3018
- if (n > 0) {
3019
- await loc.first().setInputFiles(paths);
3020
- return;
3021
- }
3022
- }
3023
- throw new Error('file: no input[type=file] in any frame; pass x,y (chooser), dropX/dropY (drop), or strategy hidden');
3966
+ throw new Error('file: explicit upload target did not accept the supplied files');
3024
3967
  }
3025
- async function findLabeledEditableField(page, fieldLabel, exact, cache, fieldId) {
3026
- const cached = readCachedLocator(cache, 'editable', fieldLabel, exact, fieldId);
3968
+ async function findLabeledEditableField(page, fieldLabel, exact, cache, fieldId, fieldKey) {
3969
+ const cached = await readCachedLocator(cache, 'editable', fieldLabel, exact, fieldId, fieldKey);
3027
3970
  if (cached !== undefined)
3028
3971
  return cached;
3972
+ if (fieldKey) {
3973
+ const authored = await findAuthoredFieldByKeyInPage(page, fieldKey, 'editable');
3974
+ if (authored) {
3975
+ writeCachedLocator(cache, 'editable', fieldLabel, exact, fieldId, fieldKey, authored);
3976
+ return authored;
3977
+ }
3978
+ throw new Error(`fieldKey "${fieldKey}" did not resolve to a visible editable control; refresh geometra_form_schema`);
3979
+ }
3029
3980
  for (const frame of page.frames()) {
3030
3981
  // Always try exact-match candidates first, even when the caller passed
3031
3982
  // exact=false. Same Greenhouse-style failure as findLabeledControl: a
@@ -3045,7 +3996,7 @@ async function findLabeledEditableField(page, fieldLabel, exact, cache, fieldId)
3045
3996
  if (!visible)
3046
3997
  continue;
3047
3998
  if (await locatorIsEditable(visible)) {
3048
- writeCachedLocator(cache, 'editable', fieldLabel, exact, fieldId, visible);
3999
+ writeCachedLocator(cache, 'editable', fieldLabel, exact, fieldId, undefined, visible);
3049
4000
  return visible;
3050
4001
  }
3051
4002
  }
@@ -3061,14 +4012,14 @@ async function findLabeledEditableField(page, fieldLabel, exact, cache, fieldId)
3061
4012
  if (!visible)
3062
4013
  continue;
3063
4014
  if (await locatorIsEditable(visible)) {
3064
- writeCachedLocator(cache, 'editable', fieldLabel, exact, fieldId, visible);
4015
+ writeCachedLocator(cache, 'editable', fieldLabel, exact, fieldId, undefined, visible);
3065
4016
  return visible;
3066
4017
  }
3067
4018
  }
3068
4019
  }
3069
4020
  const fallback = await findLabeledControl(frame, fieldLabel, exact);
3070
4021
  if (fallback && await locatorIsEditable(fallback)) {
3071
- writeCachedLocator(cache, 'editable', fieldLabel, exact, fieldId, fallback);
4022
+ writeCachedLocator(cache, 'editable', fieldLabel, exact, fieldId, undefined, fallback);
3072
4023
  return fallback;
3073
4024
  }
3074
4025
  }
@@ -3091,18 +4042,18 @@ async function findLabeledEditableField(page, fieldLabel, exact, cache, fieldId)
3091
4042
  if (!visible)
3092
4043
  continue;
3093
4044
  if (await locatorIsEditable(visible)) {
3094
- writeCachedLocator(cache, 'editable', fieldLabel, exact, fieldId, visible);
4045
+ writeCachedLocator(cache, 'editable', fieldLabel, exact, fieldId, undefined, visible);
3095
4046
  return visible;
3096
4047
  }
3097
4048
  }
3098
4049
  const fallback = await findLabeledControl(frame, stripped, exact);
3099
4050
  if (fallback && await locatorIsEditable(fallback)) {
3100
- writeCachedLocator(cache, 'editable', fieldLabel, exact, fieldId, fallback);
4051
+ writeCachedLocator(cache, 'editable', fieldLabel, exact, fieldId, undefined, fallback);
3101
4052
  return fallback;
3102
4053
  }
3103
4054
  }
3104
4055
  }
3105
- writeCachedLocator(cache, 'editable', fieldLabel, exact, fieldId, null);
4056
+ writeCachedLocator(cache, 'editable', fieldLabel, exact, fieldId, undefined, null);
3106
4057
  return null;
3107
4058
  }
3108
4059
  async function locatorCurrentValue(locator) {
@@ -3141,13 +4092,63 @@ function fieldValueMatches(actual, expected) {
3141
4092
  const normalizedExpected = normalizedFieldValue(expected);
3142
4093
  if (!normalizedActual || !normalizedExpected)
3143
4094
  return false;
3144
- return normalizedActual === normalizedExpected || normalizedActual.includes(normalizedExpected);
4095
+ return normalizedActual === normalizedExpected;
3145
4096
  }
3146
4097
  async function setLocatorTextValue(locator, value, opts) {
4098
+ if (await locatorMutationBlockReason(locator, 'text'))
4099
+ return false;
3147
4100
  try {
3148
4101
  return await locator.evaluate((el, payload) => {
3149
4102
  const nextValue = payload.value;
3150
4103
  const imeFriendly = payload.imeFriendly;
4104
+ // Preflight and commit are separate Playwright round-trips. Reactive
4105
+ // applications can make a control readonly/disabled in between, so the
4106
+ // final guard must live in the same page task as the first mutation.
4107
+ function commitMutationBlockReason(target) {
4108
+ function composedParent(node) {
4109
+ if (node.parentElement)
4110
+ return node.parentElement;
4111
+ const root = node.getRootNode();
4112
+ return root instanceof ShadowRoot ? root.host : null;
4113
+ }
4114
+ function composedDescendantOf(node, ancestor) {
4115
+ let current = node;
4116
+ while (current) {
4117
+ if (current === ancestor)
4118
+ return true;
4119
+ current = composedParent(current);
4120
+ }
4121
+ return false;
4122
+ }
4123
+ try {
4124
+ if (target.matches(':disabled'))
4125
+ return 'disabled';
4126
+ }
4127
+ catch { /* non-HTML namespaces may reject pseudo classes */ }
4128
+ if ((target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) &&
4129
+ target.readOnly) {
4130
+ return 'readonly';
4131
+ }
4132
+ let current = target;
4133
+ while (current) {
4134
+ if (current.hasAttribute('inert'))
4135
+ return 'inert';
4136
+ if (current.getAttribute('aria-disabled')?.trim().toLowerCase() === 'true')
4137
+ return 'aria-disabled';
4138
+ if (current.getAttribute('aria-readonly')?.trim().toLowerCase() === 'true')
4139
+ return 'aria-readonly';
4140
+ if (current instanceof HTMLFieldSetElement && current.disabled) {
4141
+ const firstLegend = Array.from(current.children).find(child => child instanceof HTMLLegendElement);
4142
+ if (!(firstLegend instanceof HTMLLegendElement) || !composedDescendantOf(target, firstLegend)) {
4143
+ return 'disabled fieldset';
4144
+ }
4145
+ }
4146
+ current = composedParent(current);
4147
+ }
4148
+ return null;
4149
+ }
4150
+ if (commitMutationBlockReason(el))
4151
+ return false;
3151
4152
  // React Greenhouse/Workday/Lever fix: React stores the previous value on
3152
4153
  // a hidden `_valueTracker` property that React uses to short-circuit
3153
4154
  // onChange when the value "hasn't changed". If we set el.value through
@@ -3267,6 +4268,11 @@ async function attemptNativeBatchFill(page, fields) {
3267
4268
  if (results[index])
3268
4269
  continue;
3269
4270
  const field = fields[index];
4271
+ // Authored field keys are global, exact identities. The per-frame
4272
+ // native batch path only knows labels, so let the normal resolvers scan
4273
+ // all frames before committing a keyed action.
4274
+ if (field.fieldKey)
4275
+ continue;
3270
4276
  if (field.kind === 'file')
3271
4277
  continue;
3272
4278
  if (field.kind === 'auto') {
@@ -3307,6 +4313,11 @@ async function attemptNativeBatchFill(page, fields) {
3307
4313
  continue;
3308
4314
  }
3309
4315
  if (field.kind === 'choice') {
4316
+ // An option index is an exact schema identity. The label-only native
4317
+ // batch path cannot preserve or validate it, so route this field
4318
+ // through setFieldChoice below.
4319
+ if (field.optionIndex !== undefined)
4320
+ continue;
3310
4321
  pending.push({
3311
4322
  index,
3312
4323
  kind: 'choice',
@@ -3317,14 +4328,9 @@ async function attemptNativeBatchFill(page, fields) {
3317
4328
  });
3318
4329
  continue;
3319
4330
  }
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
- });
4331
+ // Toggle resolution must collect every candidate before committing so
4332
+ // duplicate labels are rejected instead of silently selecting the first.
4333
+ continue;
3328
4334
  }
3329
4335
  if (pending.length === 0)
3330
4336
  break;
@@ -3348,7 +4354,57 @@ async function attemptNativeBatchFill(page, fields) {
3348
4354
  const style = getComputedStyle(el);
3349
4355
  return style.display !== 'none' && style.visibility !== 'hidden';
3350
4356
  }
3351
- function referencedText(ids, visited) {
4357
+ function mutationBlockReason(target) {
4358
+ function composedParent(node) {
4359
+ if (node.parentElement)
4360
+ return node.parentElement;
4361
+ const root = node.getRootNode();
4362
+ return root instanceof ShadowRoot ? root.host : null;
4363
+ }
4364
+ function composedDescendantOf(node, ancestor) {
4365
+ let current = node;
4366
+ while (current) {
4367
+ if (current === ancestor)
4368
+ return true;
4369
+ current = composedParent(current);
4370
+ }
4371
+ return false;
4372
+ }
4373
+ try {
4374
+ if (target.matches(':disabled'))
4375
+ return 'disabled';
4376
+ }
4377
+ catch { /* ignore */ }
4378
+ if ((target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) &&
4379
+ target.readOnly)
4380
+ return 'readonly';
4381
+ let current = target;
4382
+ while (current) {
4383
+ if (current.hasAttribute('inert'))
4384
+ return 'inert';
4385
+ if (current.getAttribute('aria-disabled')?.trim().toLowerCase() === 'true')
4386
+ return 'aria-disabled';
4387
+ if (current.getAttribute('aria-readonly')?.trim().toLowerCase() === 'true')
4388
+ return 'aria-readonly';
4389
+ if (current instanceof HTMLFieldSetElement && current.disabled) {
4390
+ const firstLegend = Array.from(current.children).find(child => child instanceof HTMLLegendElement);
4391
+ if (!(firstLegend instanceof HTMLLegendElement) || !composedDescendantOf(target, firstLegend)) {
4392
+ return 'disabled fieldset';
4393
+ }
4394
+ }
4395
+ current = composedParent(current);
4396
+ }
4397
+ return null;
4398
+ }
4399
+ function rootScopedElementById(owner, id) {
4400
+ const root = owner.getRootNode();
4401
+ if (root instanceof ShadowRoot)
4402
+ return root.getElementById(id);
4403
+ if (root instanceof Document)
4404
+ return root.getElementById(id);
4405
+ return null;
4406
+ }
4407
+ function referencedText(owner, ids, visited) {
3352
4408
  if (!ids)
3353
4409
  return undefined;
3354
4410
  const seen = visited ?? new Set();
@@ -3358,12 +4414,12 @@ async function attemptNativeBatchFill(page, fields) {
3358
4414
  if (seen.has(id))
3359
4415
  return '';
3360
4416
  seen.add(id);
3361
- const target = document.getElementById(id);
4417
+ const target = rootScopedElementById(owner, id);
3362
4418
  if (!target)
3363
4419
  return '';
3364
4420
  const chained = target.getAttribute('aria-labelledby');
3365
4421
  if (chained)
3366
- return referencedText(chained, seen) ?? '';
4422
+ return referencedText(target, chained, seen) ?? '';
3367
4423
  return target.textContent?.trim() ?? '';
3368
4424
  })
3369
4425
  .filter(Boolean)
@@ -3375,7 +4431,7 @@ async function attemptNativeBatchFill(page, fields) {
3375
4431
  const aria = el.getAttribute('aria-label')?.trim();
3376
4432
  if (aria)
3377
4433
  return aria;
3378
- const labelledBy = referencedText(el.getAttribute('aria-labelledby'));
4434
+ const labelledBy = referencedText(el, el.getAttribute('aria-labelledby'));
3379
4435
  if (labelledBy)
3380
4436
  return labelledBy;
3381
4437
  if ((el instanceof HTMLInputElement || el instanceof HTMLSelectElement || el instanceof HTMLTextAreaElement) &&
@@ -3384,7 +4440,10 @@ async function attemptNativeBatchFill(page, fields) {
3384
4440
  return el.labels[0]?.textContent?.trim() || undefined;
3385
4441
  }
3386
4442
  if (el instanceof HTMLElement && el.id) {
3387
- const label = document.querySelector(`label[for="${CSS.escape(el.id)}"]`);
4443
+ const root = el.getRootNode();
4444
+ const label = (root instanceof Document || root instanceof ShadowRoot)
4445
+ ? root.querySelector(`label[for="${CSS.escape(el.id)}"]`)
4446
+ : null;
3388
4447
  const text = label?.textContent?.trim();
3389
4448
  if (text)
3390
4449
  return text;
@@ -3437,6 +4496,8 @@ async function attemptNativeBatchFill(page, fields) {
3437
4496
  target.value = next;
3438
4497
  }
3439
4498
  function setInputCheckedReactAware(target, next) {
4499
+ if (mutationBlockReason(target))
4500
+ return false;
3440
4501
  const hadReactTracker = clearReactTracker(target, next);
3441
4502
  if (!hadReactTracker && target.checked === next)
3442
4503
  return true;
@@ -3596,6 +4657,8 @@ async function attemptNativeBatchFill(page, fields) {
3596
4657
  }
3597
4658
  if (!matches(explicitLabelText(control), fieldLabel, exact))
3598
4659
  continue;
4660
+ if (mutationBlockReason(control))
4661
+ return false;
3599
4662
  if (control instanceof HTMLInputElement || control instanceof HTMLTextAreaElement) {
3600
4663
  const inputType = control instanceof HTMLInputElement ? control.type : 'textarea';
3601
4664
  const v = canonicalizeHtmlInputValueInPage(inputType, value);
@@ -3603,7 +4666,7 @@ async function attemptNativeBatchFill(page, fields) {
3603
4666
  clearReactTracker(control);
3604
4667
  setInputLikeValue(control, v);
3605
4668
  dispatch(control);
3606
- return matches(currentValue(control), v, false);
4669
+ return normalize(currentValue(control)) === normalize(v);
3607
4670
  }
3608
4671
  if (control instanceof HTMLElement && control.isContentEditable) {
3609
4672
  if (!isPlainContentEditableBatch(control))
@@ -3612,7 +4675,7 @@ async function attemptNativeBatchFill(page, fields) {
3612
4675
  clearReactTracker(control);
3613
4676
  control.textContent = value;
3614
4677
  dispatch(control);
3615
- return matches(currentValue(control), value, false);
4678
+ return normalize(currentValue(control)) === normalize(value);
3616
4679
  }
3617
4680
  }
3618
4681
  return false;
@@ -3624,8 +4687,12 @@ async function attemptNativeBatchFill(page, fields) {
3624
4687
  continue;
3625
4688
  if (!matches(explicitLabelText(control), fieldLabel, exact))
3626
4689
  continue;
4690
+ if (mutationBlockReason(control))
4691
+ return false;
3627
4692
  const expected = normalize(value);
3628
4693
  const option = Array.from(control.options).find((candidate) => {
4694
+ if (candidate.disabled || (candidate.parentElement instanceof HTMLOptGroupElement && candidate.parentElement.disabled))
4695
+ return false;
3629
4696
  const label = normalize(candidate.textContent);
3630
4697
  const rawValue = normalize(candidate.value);
3631
4698
  return exact ? label === expected || rawValue === expected : label.includes(expected) || rawValue.includes(expected);
@@ -3634,7 +4701,7 @@ async function attemptNativeBatchFill(page, fields) {
3634
4701
  return false;
3635
4702
  control.value = option.value;
3636
4703
  dispatch(control);
3637
- return matches(currentValue(control), value, false) || normalize(control.value) === expected;
4704
+ return control.selectedIndex === option.index;
3638
4705
  }
3639
4706
  return false;
3640
4707
  }
@@ -3694,13 +4761,17 @@ async function attemptNativeBatchFill(page, fields) {
3694
4761
  const control = option.control;
3695
4762
  if (control instanceof HTMLInputElement)
3696
4763
  return setInputCheckedReactAware(control, true);
4764
+ if (mutationBlockReason(option))
4765
+ return false;
3697
4766
  option.click();
3698
- return true;
4767
+ return false;
3699
4768
  }
3700
4769
  // role=radio / role=checkbox: click and verify aria-checked
3701
4770
  // / aria-selected. The native batch fill used to silently return
3702
4771
  // true here, which masked Pinecone's commit-on-rerender bug.
3703
4772
  if (option.getAttribute('role') === 'radio' || option.getAttribute('role') === 'checkbox') {
4773
+ if (mutationBlockReason(option))
4774
+ return false;
3704
4775
  option.click();
3705
4776
  return (option.getAttribute('aria-checked') === 'true' ||
3706
4777
  option.getAttribute('aria-selected') === 'true');
@@ -3717,6 +4788,8 @@ async function attemptNativeBatchFill(page, fields) {
3717
4788
  // JobForge round-2 marathon — Pinecone Sr SWE Database Team
3718
4789
  // #320 and LangChain SE Manager #325.
3719
4790
  const beforeSig = selectionSignature(options);
4791
+ if (mutationBlockReason(option))
4792
+ return false;
3720
4793
  option.click();
3721
4794
  const afterSig = selectionSignature(options);
3722
4795
  if (beforeSig === afterSig)
@@ -3735,6 +4808,8 @@ async function attemptNativeBatchFill(page, fields) {
3735
4808
  continue;
3736
4809
  if (!matches(explicitLabelText(input), label, exact))
3737
4810
  continue;
4811
+ if (mutationBlockReason(input))
4812
+ return false;
3738
4813
  if (!setInputCheckedReactAware(input, checked))
3739
4814
  return false;
3740
4815
  return input.checked === checked;
@@ -3750,6 +4825,8 @@ async function attemptNativeBatchFill(page, fields) {
3750
4825
  continue;
3751
4826
  if (controlType && control.type !== controlType)
3752
4827
  continue;
4828
+ if (mutationBlockReason(control))
4829
+ return false;
3753
4830
  if (!setInputCheckedReactAware(control, checked))
3754
4831
  return false;
3755
4832
  return control.checked === checked;
@@ -4037,6 +5114,9 @@ export async function fillOtp(page, value, opts) {
4037
5114
  if (liveCount !== group.cellCount) {
4038
5115
  throw new Error(`fillOtp: OTP cell group went stale before fill (expected ${group.cellCount} cells, found ${liveCount}). The form likely re-rendered between detection and fill — retry the call.`);
4039
5116
  }
5117
+ for (let index = 0; index < liveCount; index++) {
5118
+ await assertLocatorMutable(group.boxes.nth(index), 'text', `fillOtp: OTP cell ${index + 1}`);
5119
+ }
4040
5120
  // Click the leftmost box via its center point. We use a low-level
4041
5121
  // bounding-box click because a semantic "click the first input" path
4042
5122
  // resolves to whichever input paints topmost in the stacking order,
@@ -4230,7 +5310,7 @@ export async function setFieldText(page, fieldLabel, value, opts) {
4230
5310
  // from fillOtp propagates out of setFieldText unchanged: silent fallback
4231
5311
  // to the plain text-fill path would write the whole string into box 0
4232
5312
  // and pretend success, which is the bug we're fixing.
4233
- if (labelLooksLikeOtp(fieldLabel) && /^\S{4,}$/.test(value)) {
5313
+ if (!opts?.fieldKey && labelLooksLikeOtp(fieldLabel) && /^\S{4,}$/.test(value)) {
4234
5314
  const group = await findOtpBoxGroup(page, fieldLabel, value.length);
4235
5315
  if (group) {
4236
5316
  await fillOtp(page, value, { fieldLabel });
@@ -4238,10 +5318,11 @@ export async function setFieldText(page, fieldLabel, value, opts) {
4238
5318
  }
4239
5319
  }
4240
5320
  const exact = opts?.exact ?? false;
4241
- const locator = await findLabeledEditableField(page, fieldLabel, exact, opts?.cache, opts?.fieldId);
5321
+ const locator = await findLabeledEditableField(page, fieldLabel, exact, opts?.cache, opts?.fieldId, opts?.fieldKey);
4242
5322
  if (!locator) {
4243
5323
  throw new Error(`setFieldText: no visible editable field matching "${fieldLabel}"`);
4244
5324
  }
5325
+ await assertLocatorMutable(locator, 'text', 'setFieldText');
4245
5326
  await locator.scrollIntoViewIfNeeded();
4246
5327
  const inputType = await locator.evaluate(el => {
4247
5328
  if (el instanceof HTMLInputElement)
@@ -4253,6 +5334,7 @@ export async function setFieldText(page, fieldLabel, value, opts) {
4253
5334
  const normalized = canonicalizeHtmlInputValue(inputType, value);
4254
5335
  const applied = await setLocatorTextValue(locator, normalized, { imeFriendly: opts?.imeFriendly });
4255
5336
  if (!applied) {
5337
+ await assertLocatorMutable(locator, 'text', 'setFieldText');
4256
5338
  try {
4257
5339
  await locator.fill(normalized);
4258
5340
  }
@@ -4263,6 +5345,7 @@ export async function setFieldText(page, fieldLabel, value, opts) {
4263
5345
  });
4264
5346
  }
4265
5347
  }
5348
+ await delay(40);
4266
5349
  const current = await locatorCurrentValue(locator);
4267
5350
  if (fieldValueMatches(current, normalized))
4268
5351
  return;
@@ -4271,31 +5354,88 @@ export async function setFieldText(page, fieldLabel, value, opts) {
4271
5354
  return;
4272
5355
  throw new Error(`setFieldText: set "${fieldLabel}" but could not confirm value ${JSON.stringify(value)}`);
4273
5356
  }
4274
- async function setNativeSelectByLabel(locator, value, exact) {
5357
+ async function setNativeSelectByLabel(locator, value, exact, optionIndex) {
5358
+ if (await locatorMutationBlockReason(locator, 'select'))
5359
+ return false;
4275
5360
  try {
4276
- return await locator.evaluate((el, payload) => {
5361
+ return await locator.evaluate(async (el, payload) => {
4277
5362
  if (!(el instanceof HTMLSelectElement))
4278
5363
  return false;
5364
+ // Keep the final state check atomic with selectedIndex/event mutation.
5365
+ // The earlier host-side preflight can race a reactive disable/inert
5366
+ // update while Playwright crosses back into the page.
5367
+ function commitMutationBlockReason(target) {
5368
+ function composedParent(node) {
5369
+ if (node.parentElement)
5370
+ return node.parentElement;
5371
+ const root = node.getRootNode();
5372
+ return root instanceof ShadowRoot ? root.host : null;
5373
+ }
5374
+ function composedDescendantOf(node, ancestor) {
5375
+ let current = node;
5376
+ while (current) {
5377
+ if (current === ancestor)
5378
+ return true;
5379
+ current = composedParent(current);
5380
+ }
5381
+ return false;
5382
+ }
5383
+ try {
5384
+ if (target.matches(':disabled'))
5385
+ return 'disabled';
5386
+ }
5387
+ catch { /* non-HTML namespaces may reject pseudo classes */ }
5388
+ let current = target;
5389
+ while (current) {
5390
+ if (current.hasAttribute('inert'))
5391
+ return 'inert';
5392
+ if (current.getAttribute('aria-disabled')?.trim().toLowerCase() === 'true')
5393
+ return 'aria-disabled';
5394
+ if (current.getAttribute('aria-readonly')?.trim().toLowerCase() === 'true')
5395
+ return 'aria-readonly';
5396
+ if (current instanceof HTMLFieldSetElement && current.disabled) {
5397
+ const firstLegend = Array.from(current.children).find(child => child instanceof HTMLLegendElement);
5398
+ if (!(firstLegend instanceof HTMLLegendElement) || !composedDescendantOf(target, firstLegend)) {
5399
+ return 'disabled fieldset';
5400
+ }
5401
+ }
5402
+ current = composedParent(current);
5403
+ }
5404
+ return null;
5405
+ }
5406
+ if (commitMutationBlockReason(el))
5407
+ return false;
4279
5408
  const normalize = (input) => input?.replace(/\s+/g, ' ').trim().toLowerCase() ?? '';
4280
5409
  const expected = normalize(payload.value);
4281
- if (!expected)
4282
- return false;
4283
- const option = Array.from(el.options).find((candidate) => {
4284
- if (candidate.disabled)
5410
+ const enabled = (candidate) => !candidate.disabled && !(candidate.parentElement instanceof HTMLOptGroupElement && candidate.parentElement.disabled);
5411
+ let option;
5412
+ if (payload.optionIndex !== undefined) {
5413
+ const indexed = el.options.item(payload.optionIndex) ?? undefined;
5414
+ if (!indexed || !enabled(indexed) || indexed.value !== payload.value)
4285
5415
  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
- });
5416
+ option = indexed;
5417
+ }
5418
+ else {
5419
+ const options = Array.from(el.options).filter(enabled);
5420
+ // Values are the submitted identity and take precedence over labels.
5421
+ // This prevents an earlier option whose label happens to equal a
5422
+ // different option's value from being selected silently.
5423
+ option = options.find(candidate => normalize(candidate.value) === expected);
5424
+ if (!option)
5425
+ option = options.find(candidate => normalize(candidate.textContent) === expected);
5426
+ if (!option && !payload.exact && expected) {
5427
+ option = options.find(candidate => normalize(candidate.value).includes(expected))
5428
+ ?? options.find(candidate => normalize(candidate.textContent).includes(expected));
5429
+ }
5430
+ }
4292
5431
  if (!option)
4293
5432
  return false;
4294
- el.value = option.value;
5433
+ el.selectedIndex = option.index;
4295
5434
  el.dispatchEvent(new Event('input', { bubbles: true }));
4296
5435
  el.dispatchEvent(new Event('change', { bubbles: true }));
4297
- return true;
4298
- }, { value, exact });
5436
+ await new Promise(resolveTimer => setTimeout(resolveTimer, 40));
5437
+ return el.selectedIndex === option.index && el.selectedOptions[0] === option;
5438
+ }, { value, exact, optionIndex });
4299
5439
  }
4300
5440
  catch {
4301
5441
  return false;
@@ -4316,6 +5456,46 @@ async function chooseValueFromLabeledGroup(page, fieldLabel, value, exact) {
4316
5456
  const style = getComputedStyle(el);
4317
5457
  return style.display !== 'none' && style.visibility !== 'hidden';
4318
5458
  }
5459
+ function blocked(target) {
5460
+ function parent(node) {
5461
+ if (node.parentElement)
5462
+ return node.parentElement;
5463
+ const root = node.getRootNode();
5464
+ return root instanceof ShadowRoot ? root.host : null;
5465
+ }
5466
+ function within(node, ancestor) {
5467
+ let current = node;
5468
+ while (current) {
5469
+ if (current === ancestor)
5470
+ return true;
5471
+ current = parent(current);
5472
+ }
5473
+ return false;
5474
+ }
5475
+ try {
5476
+ if (target.matches(':disabled'))
5477
+ return true;
5478
+ }
5479
+ catch { /* ignore */ }
5480
+ if ((target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) && target.readOnly)
5481
+ return true;
5482
+ let current = target;
5483
+ while (current) {
5484
+ if (current.hasAttribute('inert'))
5485
+ return true;
5486
+ if (current.getAttribute('aria-disabled')?.trim().toLowerCase() === 'true')
5487
+ return true;
5488
+ if (current.getAttribute('aria-readonly')?.trim().toLowerCase() === 'true')
5489
+ return true;
5490
+ if (current instanceof HTMLFieldSetElement && current.disabled) {
5491
+ const legend = Array.from(current.children).find(child => child instanceof HTMLLegendElement);
5492
+ if (!(legend instanceof HTMLLegendElement) || !within(target, legend))
5493
+ return true;
5494
+ }
5495
+ current = parent(current);
5496
+ }
5497
+ return false;
5498
+ }
4319
5499
  function matches(candidate) {
4320
5500
  const normalizedCandidate = normalize(candidate);
4321
5501
  const normalizedExpected = normalize(payload.fieldLabel);
@@ -4330,7 +5510,15 @@ async function chooseValueFromLabeledGroup(page, fieldLabel, value, exact) {
4330
5510
  return false;
4331
5511
  return payload.exact ? normalizedCandidate === normalizedExpected : normalizedCandidate.includes(normalizedExpected);
4332
5512
  }
4333
- function referencedText(ids, visited) {
5513
+ function rootScopedElementById(owner, id) {
5514
+ const root = owner.getRootNode();
5515
+ if (root instanceof ShadowRoot)
5516
+ return root.getElementById(id);
5517
+ if (root instanceof Document)
5518
+ return root.getElementById(id);
5519
+ return null;
5520
+ }
5521
+ function referencedText(owner, ids, visited) {
4334
5522
  if (!ids)
4335
5523
  return undefined;
4336
5524
  const seen = visited ?? new Set();
@@ -4340,12 +5528,12 @@ async function chooseValueFromLabeledGroup(page, fieldLabel, value, exact) {
4340
5528
  if (seen.has(id))
4341
5529
  return '';
4342
5530
  seen.add(id);
4343
- const target = document.getElementById(id);
5531
+ const target = rootScopedElementById(owner, id);
4344
5532
  if (!target)
4345
5533
  return '';
4346
5534
  const chained = target.getAttribute('aria-labelledby');
4347
5535
  if (chained)
4348
- return referencedText(chained, seen) ?? '';
5536
+ return referencedText(target, chained, seen) ?? '';
4349
5537
  return target.textContent?.trim() ?? '';
4350
5538
  })
4351
5539
  .filter(Boolean)
@@ -4357,7 +5545,7 @@ async function chooseValueFromLabeledGroup(page, fieldLabel, value, exact) {
4357
5545
  const aria = el.getAttribute('aria-label')?.trim();
4358
5546
  if (aria)
4359
5547
  return aria;
4360
- const labelledBy = referencedText(el.getAttribute('aria-labelledby'));
5548
+ const labelledBy = referencedText(el, el.getAttribute('aria-labelledby'));
4361
5549
  if (labelledBy)
4362
5550
  return labelledBy;
4363
5551
  if (el instanceof HTMLInputElement && el.labels && el.labels.length > 0) {
@@ -4391,7 +5579,7 @@ async function chooseValueFromLabeledGroup(page, fieldLabel, value, exact) {
4391
5579
  const aria = el.getAttribute('aria-label')?.trim();
4392
5580
  if (aria)
4393
5581
  return aria;
4394
- const labelledBy = referencedText(el.getAttribute('aria-labelledby'));
5582
+ const labelledBy = referencedText(el, el.getAttribute('aria-labelledby'));
4395
5583
  if (labelledBy)
4396
5584
  return labelledBy;
4397
5585
  return el.textContent?.trim() || undefined;
@@ -4469,6 +5657,8 @@ async function chooseValueFromLabeledGroup(page, fieldLabel, value, exact) {
4469
5657
  return false;
4470
5658
  }
4471
5659
  function setInputCheckedReactAware(target, next) {
5660
+ if (blocked(target))
5661
+ return false;
4472
5662
  const hadReactTracker = clearReactTracker(target, next);
4473
5663
  if (!hadReactTracker && target.checked === next)
4474
5664
  return true;
@@ -4495,6 +5685,8 @@ async function chooseValueFromLabeledGroup(page, fieldLabel, value, exact) {
4495
5685
  const control = option.control;
4496
5686
  if (control instanceof HTMLInputElement)
4497
5687
  return setInputCheckedReactAware(control, true);
5688
+ if (blocked(option))
5689
+ return false;
4498
5690
  option.click();
4499
5691
  // No backing input — verify via group signature change so we don't
4500
5692
  // silently no-op on label wrappers whose target is unparented.
@@ -4502,6 +5694,8 @@ async function chooseValueFromLabeledGroup(page, fieldLabel, value, exact) {
4502
5694
  return labelBeforeSig !== labelAfterSig;
4503
5695
  }
4504
5696
  if (option.getAttribute('role') === 'radio' || option.getAttribute('role') === 'checkbox') {
5697
+ if (blocked(option))
5698
+ return false;
4505
5699
  option.click();
4506
5700
  return option.getAttribute('aria-checked') === 'true' || option.getAttribute('aria-selected') === 'true';
4507
5701
  }
@@ -4511,6 +5705,8 @@ async function chooseValueFromLabeledGroup(page, fieldLabel, value, exact) {
4511
5705
  // was a no-op — return false so the caller can try the next strategy
4512
5706
  // (pickListboxOption fallback, etc) instead of silently succeeding.
4513
5707
  const beforeSig = selectionSignature(options);
5708
+ if (blocked(option))
5709
+ return false;
4514
5710
  option.click();
4515
5711
  const afterSig = selectionSignature(options);
4516
5712
  if (beforeSig === afterSig) {
@@ -4553,20 +5749,29 @@ async function autoFieldLocatorKind(locator) {
4553
5749
  export async function setAutoFieldValue(page, fieldLabel, value, opts) {
4554
5750
  const exact = opts?.exact ?? false;
4555
5751
  if (typeof value === 'boolean') {
5752
+ if (opts?.fieldKey) {
5753
+ await setCheckedControl(page, fieldLabel, { fieldKey: opts.fieldKey, checked: value, exact });
5754
+ return;
5755
+ }
4556
5756
  if (await chooseValueFromLabeledGroup(page, fieldLabel, value ? 'Yes' : 'No', exact))
4557
5757
  return;
4558
- await setCheckedControl(page, fieldLabel, { checked: value, exact });
5758
+ await setCheckedControl(page, fieldLabel, { fieldKey: opts?.fieldKey, checked: value, exact });
4559
5759
  return;
4560
5760
  }
4561
- if (prefersGroupedChoiceValue(value) && await chooseValueFromLabeledGroup(page, fieldLabel, value, exact)) {
5761
+ if (!opts?.fieldKey && prefersGroupedChoiceValue(value) && await chooseValueFromLabeledGroup(page, fieldLabel, value, exact)) {
4562
5762
  return;
4563
5763
  }
4564
- const locator = await findLabeledControlInPage(page, fieldLabel, exact, { cache: opts?.cache, fieldId: opts?.fieldId });
5764
+ const locator = await findLabeledControlInPage(page, fieldLabel, exact, {
5765
+ cache: opts?.cache,
5766
+ fieldId: opts?.fieldId,
5767
+ fieldKey: opts?.fieldKey,
5768
+ });
4565
5769
  if (locator) {
4566
5770
  const kind = await autoFieldLocatorKind(locator);
4567
5771
  if (kind === 'select') {
4568
5772
  await setFieldChoice(page, fieldLabel, value, {
4569
5773
  fieldId: opts?.fieldId,
5774
+ fieldKey: opts?.fieldKey,
4570
5775
  exact,
4571
5776
  choiceType: 'select',
4572
5777
  cache: opts?.cache,
@@ -4576,6 +5781,7 @@ export async function setAutoFieldValue(page, fieldLabel, value, opts) {
4576
5781
  if (kind === 'text') {
4577
5782
  await setFieldText(page, fieldLabel, value, {
4578
5783
  fieldId: opts?.fieldId,
5784
+ fieldKey: opts?.fieldKey,
4579
5785
  exact,
4580
5786
  cache: opts?.cache,
4581
5787
  });
@@ -4584,17 +5790,22 @@ export async function setAutoFieldValue(page, fieldLabel, value, opts) {
4584
5790
  if (kind === 'choice') {
4585
5791
  await setFieldChoice(page, fieldLabel, value, {
4586
5792
  fieldId: opts?.fieldId,
5793
+ fieldKey: opts?.fieldKey,
4587
5794
  exact,
4588
5795
  cache: opts?.cache,
4589
5796
  });
4590
5797
  return;
4591
5798
  }
4592
5799
  }
5800
+ if (opts?.fieldKey) {
5801
+ throw new Error(`fieldKey "${opts.fieldKey}" resolved to an unsupported control for field "${fieldLabel}"`);
5802
+ }
4593
5803
  if (await chooseValueFromLabeledGroup(page, fieldLabel, value, exact))
4594
5804
  return;
4595
5805
  try {
4596
5806
  await setFieldText(page, fieldLabel, value, {
4597
5807
  fieldId: opts?.fieldId,
5808
+ fieldKey: opts?.fieldKey,
4598
5809
  exact,
4599
5810
  cache: opts?.cache,
4600
5811
  });
@@ -4603,6 +5814,7 @@ export async function setAutoFieldValue(page, fieldLabel, value, opts) {
4603
5814
  catch {
4604
5815
  await setFieldChoice(page, fieldLabel, value, {
4605
5816
  fieldId: opts?.fieldId,
5817
+ fieldKey: opts?.fieldKey,
4606
5818
  exact,
4607
5819
  cache: opts?.cache,
4608
5820
  });
@@ -4610,11 +5822,22 @@ export async function setAutoFieldValue(page, fieldLabel, value, opts) {
4610
5822
  }
4611
5823
  export async function setFieldChoice(page, fieldLabel, value, opts) {
4612
5824
  const exact = opts?.exact ?? false;
4613
- const locator = await findLabeledControlInPage(page, fieldLabel, exact, { cache: opts?.cache, fieldId: opts?.fieldId });
5825
+ const locator = await findLabeledControlInPage(page, fieldLabel, exact, {
5826
+ cache: opts?.cache,
5827
+ fieldId: opts?.fieldId,
5828
+ fieldKey: opts?.fieldKey,
5829
+ });
4614
5830
  if (locator) {
5831
+ await assertLocatorMutable(locator, 'select', 'setFieldChoice');
4615
5832
  await locator.scrollIntoViewIfNeeded();
4616
5833
  const isNativeSelect = await locator.evaluate(el => el instanceof HTMLSelectElement);
4617
- if (await setNativeSelectByLabel(locator, value, exact)) {
5834
+ if (await setNativeSelectByLabel(locator, value, exact, opts?.optionIndex)) {
5835
+ if (opts?.optionIndex !== undefined) {
5836
+ const selectedIndex = await locator.evaluate(el => el instanceof HTMLSelectElement ? el.selectedIndex : -1);
5837
+ if (selectedIndex === opts.optionIndex)
5838
+ return;
5839
+ throw new Error(`setFieldChoice: selected native option index ${opts.optionIndex} for field "${fieldLabel}" but could not confirm it`);
5840
+ }
4618
5841
  const displayed = await locatorDisplayedValues(locator);
4619
5842
  if (displayed.some(candidate => displayedValueMatchesSelection(candidate, value, exact)))
4620
5843
  return;
@@ -4633,6 +5856,7 @@ export async function setFieldChoice(page, fieldLabel, value, opts) {
4633
5856
  try {
4634
5857
  await pickListboxOption(page, value, {
4635
5858
  fieldId: opts?.fieldId,
5859
+ fieldKey: opts?.fieldKey,
4636
5860
  fieldLabel,
4637
5861
  exact,
4638
5862
  query: opts?.query,
@@ -4678,6 +5902,7 @@ export async function fillFields(page, fields, cache = createFillLookupCache())
4678
5902
  if (textFills.length > 0) {
4679
5903
  const results = await Promise.allSettled(textFills.map(({ field }) => setFieldText(page, field.fieldLabel, field.value, {
4680
5904
  fieldId: field.fieldId,
5905
+ fieldKey: field.fieldKey,
4681
5906
  exact: field.exact,
4682
5907
  cache,
4683
5908
  typingDelayMs: field.typingDelayMs,
@@ -4708,6 +5933,7 @@ export async function fillFields(page, fields, cache = createFillLookupCache())
4708
5933
  if (field.kind === 'auto') {
4709
5934
  await setAutoFieldValue(page, field.fieldLabel, field.value, {
4710
5935
  fieldId: field.fieldId,
5936
+ fieldKey: field.fieldKey,
4711
5937
  exact: field.exact,
4712
5938
  cache,
4713
5939
  });
@@ -4716,7 +5942,9 @@ export async function fillFields(page, fields, cache = createFillLookupCache())
4716
5942
  if (field.kind === 'choice') {
4717
5943
  await setFieldChoice(page, field.fieldLabel, field.value, {
4718
5944
  fieldId: field.fieldId,
5945
+ fieldKey: field.fieldKey,
4719
5946
  exact: field.exact,
5947
+ optionIndex: field.optionIndex,
4720
5948
  query: field.query,
4721
5949
  choiceType: field.choiceType,
4722
5950
  cache,
@@ -4725,14 +5953,25 @@ export async function fillFields(page, fields, cache = createFillLookupCache())
4725
5953
  }
4726
5954
  if (field.kind === 'toggle') {
4727
5955
  await setCheckedControl(page, field.label, {
5956
+ fieldKey: field.fieldKey,
4728
5957
  checked: field.checked,
4729
5958
  exact: field.exact,
4730
5959
  controlType: field.controlType,
5960
+ contextText: field.contextText,
5961
+ sectionText: field.sectionText,
4731
5962
  });
4732
5963
  continue;
4733
5964
  }
4734
5965
  if (field.kind === 'file') {
4735
- await attachFiles(page, field.paths, { fieldId: field.fieldId, fieldLabel: field.fieldLabel, exact: field.exact, cache });
5966
+ await attachFiles(page, field.paths, {
5967
+ fieldId: field.fieldId,
5968
+ fieldKey: field.fieldKey,
5969
+ fieldLabel: field.fieldLabel,
5970
+ contextText: field.contextText,
5971
+ sectionText: field.sectionText,
5972
+ exact: field.exact,
5973
+ cache,
5974
+ });
4736
5975
  }
4737
5976
  }
4738
5977
  catch (e) {
@@ -4767,65 +6006,46 @@ export async function selectNativeOption(page, x, y, opt) {
4767
6006
  if (opt.value === undefined && opt.label === undefined && opt.index === undefined) {
4768
6007
  throw new Error('selectOption: provide at least one of value, label, or index');
4769
6008
  }
6009
+ if (opt.index !== undefined && (!Number.isFinite(opt.index) || !Number.isInteger(opt.index) || opt.index < 0)) {
6010
+ throw new Error('selectOption: index must be a non-negative integer');
6011
+ }
6012
+ await assertPointMutable(page, x, y, 'select', 'selectOption');
4770
6013
  await page.mouse.click(x, y);
4771
6014
  await delay(40);
4772
6015
  for (const frame of page.frames()) {
4773
- const applied = await frame.evaluate((payload) => {
6016
+ const applied = await frame.evaluate(async (payload) => {
4774
6017
  const normalize = (input) => input?.replace(/\s+/g, ' ').trim().toLowerCase() ?? '';
4775
- const optionMatchesExpected = (o, expected) => {
4776
- if (o.disabled)
6018
+ const enabled = (o) => !o.disabled && !(o.parentElement instanceof HTMLOptGroupElement && o.parentElement.disabled);
6019
+ const optionMatchesPayload = (o) => {
6020
+ if (o.disabled || (o.parentElement instanceof HTMLOptGroupElement && o.parentElement.disabled))
4777
6021
  return false;
4778
- const label = normalize(o.textContent);
4779
- const rawValue = normalize(o.value);
4780
- return label === expected || rawValue === expected || label.includes(expected) || rawValue.includes(expected);
6022
+ if (payload.value !== null && normalize(o.value) !== normalize(payload.value))
6023
+ return false;
6024
+ if (payload.label !== null && normalize(o.textContent) !== normalize(payload.label))
6025
+ return false;
6026
+ if (payload.index !== undefined && o.index !== payload.index)
6027
+ return false;
6028
+ return true;
4781
6029
  };
4782
6030
  const a = document.activeElement;
4783
6031
  if (!a || a.tagName !== 'SELECT')
4784
6032
  return false;
4785
6033
  const sel = a;
4786
- if (typeof payload.index === 'number' && Number.isFinite(payload.index)) {
4787
- const i = Math.trunc(payload.index);
4788
- if (i < 0 || i >= sel.options.length)
4789
- return false;
4790
- if (sel.options[i].disabled)
4791
- return false;
4792
- sel.selectedIndex = i;
4793
- }
4794
- else if (payload.value !== null && payload.value !== undefined) {
4795
- const raw = String(payload.value);
4796
- sel.value = raw;
4797
- const expected = normalize(raw);
4798
- if (!expected)
4799
- return false;
4800
- const selected = sel.selectedOptions[0];
4801
- const ok = selected &&
4802
- !selected.disabled &&
4803
- (normalize(selected.value) === expected ||
4804
- normalize(selected.textContent) === expected ||
4805
- normalize(selected.value).includes(expected) ||
4806
- normalize(selected.textContent).includes(expected));
4807
- if (!ok) {
4808
- const found = Array.from(sel.options).find(o => optionMatchesExpected(o, expected));
4809
- if (!found)
4810
- return false;
4811
- sel.value = found.value;
4812
- }
4813
- }
4814
- else if (payload.label !== null && payload.label !== undefined) {
4815
- const expected = normalize(payload.label);
4816
- if (!expected)
4817
- return false;
4818
- const optEl = Array.from(sel.options).find(o => optionMatchesExpected(o, expected));
4819
- if (!optEl)
4820
- return false;
4821
- sel.value = optEl.value;
4822
- }
4823
- else {
6034
+ if (sel.matches(':disabled') || sel.getAttribute('aria-disabled') === 'true' || sel.getAttribute('aria-readonly') === 'true')
4824
6035
  return false;
4825
- }
6036
+ if (sel.closest('[inert], [aria-disabled="true"], [aria-readonly="true"]'))
6037
+ return false;
6038
+ const candidate = payload.index !== undefined
6039
+ ? sel.options.item(payload.index)
6040
+ : Array.from(sel.options).find(optionMatchesPayload) ?? null;
6041
+ if (!candidate || !enabled(candidate) || !optionMatchesPayload(candidate))
6042
+ return false;
6043
+ sel.selectedIndex = candidate.index;
4826
6044
  sel.dispatchEvent(new Event('input', { bubbles: true }));
4827
6045
  sel.dispatchEvent(new Event('change', { bubbles: true }));
4828
- return true;
6046
+ await new Promise(resolveTimer => setTimeout(resolveTimer, 40));
6047
+ const selected = sel.selectedOptions[0];
6048
+ return sel.selectedIndex === candidate.index && selected === candidate && optionMatchesPayload(selected);
4829
6049
  }, {
4830
6050
  value: opt.value ?? null,
4831
6051
  label: opt.label ?? null,
@@ -4842,6 +6062,23 @@ export async function selectNativeOption(page, x, y, opt) {
4842
6062
  export async function pickListboxOption(page, label, opts) {
4843
6063
  let anchor;
4844
6064
  const exact = opts?.exact ?? false;
6065
+ const hasOpenX = opts?.openX !== undefined;
6066
+ const hasOpenY = opts?.openY !== undefined;
6067
+ if (hasOpenX !== hasOpenY) {
6068
+ throw new Error('listboxPick: openX and openY must be provided together');
6069
+ }
6070
+ if ((hasOpenX || hasOpenY) && (opts?.fieldLabel || opts?.fieldId || opts?.fieldKey)) {
6071
+ throw new Error('listboxPick: coordinate opening cannot be combined with fieldLabel, fieldId, or fieldKey');
6072
+ }
6073
+ if (hasOpenX && opts?.query !== undefined) {
6074
+ throw new Error('listboxPick: query cannot be combined with coordinate opening');
6075
+ }
6076
+ if (opts?.fieldId && !opts.fieldLabel && !opts.fieldKey) {
6077
+ throw new Error('listboxPick: fieldId requires fieldLabel or fieldKey and cannot be used as an unscoped hint');
6078
+ }
6079
+ if (!opts?.fieldLabel && !opts?.fieldKey && !hasOpenX) {
6080
+ throw new Error('listboxPick: provide fieldLabel, fieldKey, or a complete openX/openY target before selecting an option');
6081
+ }
4845
6082
  let attemptedSelection = false;
4846
6083
  let selectedOptionText;
4847
6084
  let openedHandle;
@@ -4867,16 +6104,16 @@ export async function pickListboxOption(page, label, opts) {
4867
6104
  await releasePopupScope();
4868
6105
  popupScope = await resolveOwnedPopupHandle(openedHandle);
4869
6106
  };
4870
- if (opts?.fieldLabel) {
6107
+ if (opts?.fieldLabel || opts?.fieldKey) {
4871
6108
  let opened;
4872
6109
  try {
4873
- opened = await openDropdownControl(page, opts.fieldLabel, exact, opts.cache, opts.fieldId);
6110
+ opened = await openDropdownControl(page, opts.fieldLabel ?? '', exact, opts.cache, opts.fieldId, opts.fieldKey);
4874
6111
  }
4875
6112
  catch {
4876
6113
  throw new Error(listboxErrorMessage({
4877
6114
  reason: 'field_not_found',
4878
6115
  requestedLabel: label,
4879
- fieldLabel: opts.fieldLabel,
6116
+ fieldLabel: opts.fieldLabel ?? opts.fieldKey,
4880
6117
  query: opts?.query,
4881
6118
  exact,
4882
6119
  }));
@@ -4896,6 +6133,10 @@ export async function pickListboxOption(page, label, opts) {
4896
6133
  await refreshPopupScope();
4897
6134
  }
4898
6135
  else if (opts?.openX !== undefined && opts?.openY !== undefined) {
6136
+ await assertPointMutable(page, opts.openX, opts.openY, 'select', 'listboxPick');
6137
+ openedHandle = await elementHandleAtPoint(page, opts.openX, opts.openY);
6138
+ if (!openedHandle)
6139
+ throw new Error('listboxPick: no control at openX/openY');
4899
6140
  await page.mouse.click(opts.openX, opts.openY);
4900
6141
  anchor = { x: opts.openX, y: opts.openY };
4901
6142
  await delay(120);
@@ -4905,29 +6146,27 @@ export async function pickListboxOption(page, label, opts) {
4905
6146
  if (!selectedOptionText)
4906
6147
  return false;
4907
6148
  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);
6149
+ // Do not press Enter after a successful pointer selection. Both
6150
+ // react-select and non-editable Radix triggers can interpret Enter on
6151
+ // the now-closed control as "open again", leaving several adjacent
6152
+ // dropdowns expanded and making later shared-label picks hit the wrong
6153
+ // popup. If the pointer click did not commit, the keyboard fallback
6154
+ // below deliberately reopens this exact control and selects by Enter.
4917
6155
  await dispatchListboxCommitEvents(openedHandle);
4918
- if (!opts?.fieldLabel ||
4919
- await confirmListboxSelection(page, opts.fieldLabel, label, exact, anchor, openedHandle, selectedOptionText, {
6156
+ if (opts?.fieldLabel) {
6157
+ if (await confirmListboxSelection(page, opts.fieldLabel, label, exact, anchor, openedHandle, selectedOptionText, {
4920
6158
  editable: openedEditable,
4921
- })) {
4922
- return true;
6159
+ }))
6160
+ return true;
6161
+ return false;
4923
6162
  }
4924
- return false;
6163
+ return await confirmListboxSelectionWithoutLabel(page, label, exact, anchor, openedHandle, selectedOptionText);
4925
6164
  };
4926
6165
  const dismissAfterSelection = async () => {
4927
6166
  if (!opts?.fieldLabel) {
4928
6167
  await page.keyboard.press('Tab');
4929
6168
  await delay(50);
4930
- return true;
6169
+ return await confirmListboxSelectionWithoutLabel(page, label, exact, anchor, openedHandle, selectedOptionText);
4931
6170
  }
4932
6171
  if (await dismissAndReVerifySelection(page, label, exact, openedHandle, selectedOptionText)) {
4933
6172
  return true;
@@ -5024,10 +6263,12 @@ export async function pickListboxOption(page, label, opts) {
5024
6263
  selectedOptionText = keyboardSelection;
5025
6264
  attemptedSelection = true;
5026
6265
  await dispatchListboxCommitEvents(openedHandle);
5027
- if (!opts?.fieldLabel ||
5028
- await confirmListboxSelection(page, opts.fieldLabel, label, exact, anchor, openedHandle, selectedOptionText, {
6266
+ const keyboardConfirmed = opts?.fieldLabel
6267
+ ? await confirmListboxSelection(page, opts.fieldLabel, label, exact, anchor, openedHandle, selectedOptionText, {
5029
6268
  editable: openedEditable,
5030
- })) {
6269
+ })
6270
+ : await confirmListboxSelectionWithoutLabel(page, label, exact, anchor, openedHandle, selectedOptionText);
6271
+ if (keyboardConfirmed) {
5031
6272
  if (await dismissAfterSelection() && await postCommitVerify())
5032
6273
  return;
5033
6274
  }
@@ -5060,57 +6301,129 @@ export async function pickListboxOption(page, label, opts) {
5060
6301
  await releasePopupScope();
5061
6302
  }
5062
6303
  }
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;
6304
+ async function applyToggleState(locator, desiredChecked) {
6305
+ return await locator.evaluate((target, checked) => {
6306
+ function rootScopedElementById(owner, id) {
6307
+ const root = owner.getRootNode();
6308
+ if (root instanceof ShadowRoot)
6309
+ return root.getElementById(id);
6310
+ if (root instanceof Document)
6311
+ return root.getElementById(id);
6312
+ return null;
6313
+ }
6314
+ function referencedText(owner, ids, visited) {
6315
+ if (!ids)
6316
+ return undefined;
6317
+ const seen = visited ?? new Set();
6318
+ const text = ids.split(/\s+/).map(id => {
6319
+ if (seen.has(id))
6320
+ return '';
6321
+ seen.add(id);
6322
+ const node = rootScopedElementById(owner, id);
6323
+ if (!node)
6324
+ return '';
6325
+ return referencedText(node, node.getAttribute('aria-labelledby'), seen) ?? node.textContent?.trim() ?? '';
6326
+ }).filter(Boolean).join(' ').trim();
6327
+ return text || undefined;
6328
+ }
6329
+ function kindOf(el) {
6330
+ if (el instanceof HTMLInputElement && el.type === 'radio')
6331
+ return 'radio';
6332
+ return el.getAttribute('role') === 'radio' ? 'radio' : 'checkbox';
6333
+ }
6334
+ function nameOf(el) {
6335
+ const aria = el.getAttribute('aria-label')?.trim();
6336
+ if (aria)
6337
+ return aria;
6338
+ const labelledBy = referencedText(el, el.getAttribute('aria-labelledby'));
6339
+ if (labelledBy)
6340
+ return labelledBy;
6341
+ if (el instanceof HTMLInputElement) {
6342
+ const labelText = Array.from(el.labels ?? []).map(label => label.textContent?.trim()).find(Boolean);
6343
+ if (labelText)
6344
+ return labelText;
6345
+ }
6346
+ return el.textContent?.trim() || '<unnamed>';
6347
+ }
6348
+ function readChecked(el) {
6349
+ return el instanceof HTMLInputElement ? el.checked : el.getAttribute('aria-checked') === 'true';
6350
+ }
6351
+ function dispatch(el) {
6352
+ el.dispatchEvent(new Event('input', { bubbles: true }));
6353
+ el.dispatchEvent(new Event('change', { bubbles: true }));
6354
+ }
6355
+ function setInputCheckedReactAware(input, next) {
6356
+ const tracker = input._valueTracker;
6357
+ if (tracker && typeof tracker.setValue === 'function') {
6358
+ try {
6359
+ tracker.setValue(String(!next));
6360
+ }
6361
+ catch { /* ignore */ }
6362
+ }
6363
+ const descriptor = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'checked');
6364
+ if (descriptor?.set)
6365
+ descriptor.set.call(input, next);
6366
+ else
6367
+ input.checked = next;
6368
+ dispatch(input);
6369
+ }
6370
+ const kind = kindOf(target);
6371
+ const name = nameOf(target);
6372
+ const before = readChecked(target);
6373
+ if (kind === 'radio' && checked === false) {
6374
+ return { success: before === false, reason: 'radio-uncheck', kind, name, before, after: before };
6375
+ }
6376
+ if (before !== checked) {
6377
+ if (target instanceof HTMLInputElement) {
6378
+ setInputCheckedReactAware(target, checked);
6379
+ }
6380
+ else if (target instanceof HTMLElement) {
6381
+ target.click();
6382
+ }
6383
+ }
6384
+ const after = readChecked(target);
6385
+ return { success: after === checked, kind, name, before, after };
6386
+ }, desiredChecked);
6387
+ }
6388
+ async function collectToggleMatches(page, label, exact, controlType, contextText, sectionText) {
6389
+ const matches = [];
5071
6390
  for (const frame of page.frames()) {
5072
- const result = await frame.evaluate((payload) => {
6391
+ const candidates = frame.locator(TOGGLE_CONTROL_SELECTOR);
6392
+ const metadata = await candidates.evaluateAll((elements, payload) => {
5073
6393
  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);
6394
+ return (value ?? '').replace(/\s+/g, ' ').trim().toLowerCase();
5082
6395
  }
5083
6396
  function visible(el) {
5084
- const rect = el.getBoundingClientRect();
5085
- if (rect.width <= 0 || rect.height <= 0)
6397
+ if (!(el instanceof HTMLElement))
5086
6398
  return false;
6399
+ const rect = el.getBoundingClientRect();
5087
6400
  const style = getComputedStyle(el);
5088
- return style.display !== 'none' && style.visibility !== 'hidden';
6401
+ return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden';
6402
+ }
6403
+ function rootScopedElementById(owner, id) {
6404
+ const root = owner.getRootNode();
6405
+ if (root instanceof ShadowRoot)
6406
+ return root.getElementById(id);
6407
+ if (root instanceof Document)
6408
+ return root.getElementById(id);
6409
+ return null;
5089
6410
  }
5090
- function referencedText(ids, visited) {
6411
+ function referencedText(owner, ids, visited) {
5091
6412
  if (!ids)
5092
6413
  return undefined;
5093
6414
  const seen = visited ?? new Set();
5094
- const text = ids
5095
- .split(/\s+/)
5096
- .map(id => {
6415
+ const text = ids.split(/\s+/).map(id => {
5097
6416
  if (seen.has(id))
5098
6417
  return '';
5099
6418
  seen.add(id);
5100
- const target = document.getElementById(id);
5101
- if (!target)
6419
+ const node = rootScopedElementById(owner, id);
6420
+ if (!node)
5102
6421
  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();
6422
+ return referencedText(node, node.getAttribute('aria-labelledby'), seen) ?? node.textContent?.trim() ?? '';
6423
+ }).filter(Boolean).join(' ').trim();
5111
6424
  return text || undefined;
5112
6425
  }
5113
- function controlKind(el) {
6426
+ function kindOf(el) {
5114
6427
  if (el instanceof HTMLInputElement) {
5115
6428
  if (el.type === 'checkbox')
5116
6429
  return 'checkbox';
@@ -5119,120 +6432,141 @@ export async function setCheckedControl(page, label, opts) {
5119
6432
  return undefined;
5120
6433
  }
5121
6434
  const role = el.getAttribute('role');
5122
- if (role === 'switch' || role === 'checkbox')
5123
- return 'checkbox';
5124
6435
  if (role === 'radio')
5125
6436
  return 'radio';
6437
+ if (role === 'checkbox' || role === 'switch')
6438
+ return 'checkbox';
5126
6439
  return undefined;
5127
6440
  }
5128
- function controlName(el) {
6441
+ function nameOf(el) {
5129
6442
  const aria = el.getAttribute('aria-label')?.trim();
5130
6443
  if (aria)
5131
6444
  return aria;
5132
- const labelledBy = referencedText(el.getAttribute('aria-labelledby'));
6445
+ const labelledBy = referencedText(el, el.getAttribute('aria-labelledby'));
5133
6446
  if (labelledBy)
5134
6447
  return labelledBy;
5135
6448
  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;
6449
+ const labelText = Array.from(el.labels ?? []).map(label => label.textContent?.trim()).find(Boolean);
6450
+ if (labelText)
6451
+ return labelText;
6452
+ const name = el.getAttribute('name')?.trim();
6453
+ if (name && /[A-Za-z]/.test(name) && /[\s,./()_-]/.test(name))
6454
+ return name;
5145
6455
  }
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;
5159
- }
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 }));
6456
+ return el.textContent?.trim() || undefined;
5165
6457
  }
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 */ }
6458
+ function nearestContext(el, name) {
6459
+ const normalizedName = normalize(name);
6460
+ let current = el.parentElement;
6461
+ for (let depth = 0; current && depth < 6; depth++, current = current.parentElement) {
6462
+ if (current === document.body || current === document.documentElement)
6463
+ break;
6464
+ const text = (current.innerText || current.textContent || '').replace(/\s+/g, ' ').trim();
6465
+ if (text && normalize(text) !== normalizedName)
6466
+ return text;
5174
6467
  }
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();
6468
+ return '';
5210
6469
  }
5211
- let after = readChecked(target);
5212
- if (target instanceof HTMLInputElement) {
5213
- setInputCheckedReactAware(target, payload.checked);
5214
- after = target.checked;
6470
+ function nearestSection(el) {
6471
+ const section = el.closest('fieldset, section, form, dialog, [role="group"], [role="radiogroup"], [role="region"], [role="dialog"]');
6472
+ if (!(section instanceof HTMLElement))
6473
+ return '';
6474
+ const explicit = section.getAttribute('aria-label')?.trim() ||
6475
+ referencedText(section, section.getAttribute('aria-labelledby')) ||
6476
+ section.querySelector('legend, h1, h2, h3, h4, h5, h6')?.textContent?.trim();
6477
+ return (explicit || section.innerText || section.textContent || '').replace(/\s+/g, ' ').trim();
6478
+ }
6479
+ const needle = normalize(payload.label);
6480
+ const contextNeedle = normalize(payload.contextText);
6481
+ const sectionNeedle = normalize(payload.sectionText);
6482
+ const result = [];
6483
+ for (let index = 0; index < elements.length; index++) {
6484
+ const element = elements[index];
6485
+ if (!element)
6486
+ continue;
6487
+ if (!visible(element))
6488
+ continue;
6489
+ const kind = kindOf(element);
6490
+ if (!kind || (payload.controlType && kind !== payload.controlType))
6491
+ continue;
6492
+ const name = nameOf(element);
6493
+ if (!name)
6494
+ continue;
6495
+ const normalizedName = normalize(name);
6496
+ const exactLabel = normalizedName === needle;
6497
+ if (payload.exact ? !exactLabel : !normalizedName.includes(needle))
6498
+ continue;
6499
+ const context = nearestContext(element, name);
6500
+ const section = nearestSection(element);
6501
+ if (contextNeedle && !normalize(context).includes(contextNeedle))
6502
+ continue;
6503
+ if (sectionNeedle && !normalize(section).includes(sectionNeedle))
6504
+ continue;
6505
+ result.push({ index, kind, name, exactLabel, contextText: context, sectionText: section });
5215
6506
  }
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`);
6507
+ return result;
6508
+ }, { label, exact, controlType: controlType ?? null, contextText: contextText ?? '', sectionText: sectionText ?? '' });
6509
+ for (const candidate of metadata) {
6510
+ matches.push({ ...candidate, locator: candidates.nth(candidate.index), frameUrl: frame.url() });
5231
6511
  }
5232
- throw new Error(`setChecked: matched ${result.kind} "${result.name}" but could not set it to ${String(desiredChecked)}`);
5233
6512
  }
5234
- const kindLabel = controlType ?? 'checkbox/radio';
5235
- throw new Error(`setChecked: no visible ${kindLabel} matching "${label}"`);
6513
+ if (!exact) {
6514
+ const exactMatches = matches.filter(match => match.exactLabel);
6515
+ if (exactMatches.length > 0)
6516
+ return exactMatches;
6517
+ }
6518
+ return matches;
6519
+ }
6520
+ /**
6521
+ * Set a checkbox/radio by accessible label instead of brittle coordinate clicks.
6522
+ * Helps custom form UIs that keep the real control opacity-hidden but still interactive.
6523
+ */
6524
+ export async function setCheckedControl(page, label, opts) {
6525
+ if (!label || label !== label.trim()) {
6526
+ throw new Error('setChecked: label must be a trimmed, non-empty string');
6527
+ }
6528
+ if (opts?.contextText !== undefined && (!opts.contextText || opts.contextText !== opts.contextText.trim())) {
6529
+ throw new Error('setChecked: contextText must be a trimmed, non-empty string when provided');
6530
+ }
6531
+ if (opts?.sectionText !== undefined && (!opts.sectionText || opts.sectionText !== opts.sectionText.trim())) {
6532
+ throw new Error('setChecked: sectionText must be a trimmed, non-empty string when provided');
6533
+ }
6534
+ const exact = opts?.exact ?? false;
6535
+ const desiredChecked = opts?.checked ?? true;
6536
+ const controlType = opts?.controlType;
6537
+ let target = null;
6538
+ if (opts?.fieldKey) {
6539
+ target = await findAuthoredFieldByKeyInPage(page, opts.fieldKey, 'toggle');
6540
+ if (!target) {
6541
+ throw new Error(`setChecked: fieldKey "${opts.fieldKey}" did not resolve to a visible checkbox/radio; refresh geometra_form_schema`);
6542
+ }
6543
+ }
6544
+ if (!target) {
6545
+ const matches = await collectToggleMatches(page, label, exact, controlType, opts?.contextText, opts?.sectionText);
6546
+ if (matches.length === 0) {
6547
+ const kindLabel = controlType ?? 'checkbox/radio';
6548
+ const context = [opts?.contextText && `context "${opts.contextText}"`, opts?.sectionText && `section "${opts.sectionText}"`]
6549
+ .filter(Boolean).join(' and ');
6550
+ throw new Error(`setChecked: no visible ${kindLabel} matching "${label}"${context ? ` in ${context}` : ''}`);
6551
+ }
6552
+ if (matches.length > 1) {
6553
+ const details = matches.slice(0, 5).map(match => `"${match.name}"${match.frameUrl ? ` in ${match.frameUrl}` : ''}`).join(', ');
6554
+ throw new Error(`setChecked: ambiguous label "${label}" matched ${matches.length} controls (${details}); provide fieldKey, contextText, or sectionText`);
6555
+ }
6556
+ target = matches[0].locator;
6557
+ }
6558
+ await assertLocatorMutable(target, 'toggle', 'setChecked');
6559
+ const result = await applyToggleState(target, desiredChecked);
6560
+ if (result.success) {
6561
+ await delay(40);
6562
+ const persisted = await target.evaluate((el, desired) => (el instanceof HTMLInputElement ? el.checked : el.getAttribute('aria-checked') === 'true') === desired, desiredChecked).catch(() => false);
6563
+ if (persisted)
6564
+ return;
6565
+ }
6566
+ if (result.reason === 'radio-uncheck') {
6567
+ throw new Error(`setChecked: radio "${result.name}" cannot be unchecked directly; choose a different option instead`);
6568
+ }
6569
+ throw new Error(`setChecked: matched ${result.kind} "${result.name}" but could not set it to ${String(desiredChecked)}`);
5236
6570
  }
5237
6571
  export async function wheelAt(page, deltaX, deltaY, x, y) {
5238
6572
  if (x !== undefined && y !== undefined && Number.isFinite(x) && Number.isFinite(y)) {