@geometra/proxy 1.19.20 → 1.19.23

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.
@@ -4,7 +4,26 @@ function delay(ms) {
4
4
  return new Promise(r => setTimeout(r, ms));
5
5
  }
6
6
  const LABELED_CONTROL_SELECTOR = 'input, select, textarea, button, [role="combobox"], [role="textbox"], [aria-haspopup="listbox"], [contenteditable="true"]';
7
- const OPTION_PICKER_SELECTOR = '[role="option"], [role="menuitem"], [role="treeitem"], button, li, [data-value], [aria-selected], [aria-checked]';
7
+ 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"]';
8
+ 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"]';
9
+ const OPTION_PICKER_SELECTOR = [
10
+ '[role="option"]',
11
+ '[role="menuitem"]',
12
+ '[role="treeitem"]',
13
+ 'button',
14
+ 'li',
15
+ '[data-value]',
16
+ '[aria-selected]',
17
+ '[aria-checked]',
18
+ '[role="listbox"] > *',
19
+ '[role="menu"] > *',
20
+ '[class*="option"]',
21
+ '[class*="menu-item"]',
22
+ '[class*="dropdown-item"]',
23
+ '[class*="listbox-option"]',
24
+ ].join(', ');
25
+ const MAX_VISIBLE_OPTION_HINTS = 12;
26
+ const LISTBOX_KEYBOARD_FALLBACK_STEPS = 40;
8
27
  export function createFillLookupCache() {
9
28
  return {
10
29
  control: new Map(),
@@ -52,7 +71,18 @@ function writeCachedLocator(cache, kind, label, exact, fieldId, locator) {
52
71
  }
53
72
  }
54
73
  function normalizedOptionLabel(value) {
55
- return value.replace(/\s+/g, ' ').trim().toLowerCase();
74
+ return value
75
+ .normalize('NFKD')
76
+ .replace(/[\u0300-\u036f]/g, '')
77
+ .replace(/[+﹢∔]/g, '+')
78
+ .replace(/[‐‑‒–—―]/g, '-')
79
+ .replace(/&/g, ' and ')
80
+ .replace(/\bplus\b/g, '+')
81
+ .replace(/[,/()]+/g, ' ')
82
+ .replace(/\s*\+\s*/g, '+')
83
+ .replace(/\s+/g, ' ')
84
+ .trim()
85
+ .toLowerCase();
56
86
  }
57
87
  function prefersGroupedChoiceValue(value) {
58
88
  const normalized = normalizedOptionLabel(value);
@@ -82,6 +112,30 @@ function semanticSelectionAliases(value) {
82
112
  aliases.add(alias);
83
113
  }
84
114
  }
115
+ if (normalized === 'atx' || normalized.includes('austin')) {
116
+ for (const alias of ['atx', 'austin', 'austin tx', 'austin texas'])
117
+ aliases.add(alias);
118
+ }
119
+ if (normalized === 'nyc' || normalized.includes('new york')) {
120
+ for (const alias of ['nyc', 'new york', 'new york ny'])
121
+ aliases.add(alias);
122
+ }
123
+ if (normalized === 'sf' || normalized.includes('san francisco')) {
124
+ for (const alias of ['sf', 'san francisco', 'san francisco ca'])
125
+ aliases.add(alias);
126
+ }
127
+ if (normalized === 'la' || normalized.includes('los angeles')) {
128
+ for (const alias of ['la', 'los angeles', 'los angeles ca'])
129
+ aliases.add(alias);
130
+ }
131
+ if (normalized === 'dc' || normalized.includes('washington dc')) {
132
+ for (const alias of ['dc', 'washington dc', 'washington d c'])
133
+ aliases.add(alias);
134
+ }
135
+ if (normalized === 'us' || normalized === 'usa' || normalized.includes('united states')) {
136
+ for (const alias of ['us', 'usa', 'united states'])
137
+ aliases.add(alias);
138
+ }
85
139
  return [...aliases];
86
140
  }
87
141
  function hasNegativeSelectionCue(value) {
@@ -290,6 +344,7 @@ async function resolveMeaningfulClickTarget(locator) {
290
344
  async function findLabeledControl(frame, fieldLabel, exact, opts) {
291
345
  const directCandidates = [
292
346
  frame.getByLabel(fieldLabel, { exact }),
347
+ frame.getByPlaceholder(fieldLabel, { exact }),
293
348
  frame.getByRole('combobox', { name: fieldLabel, exact }),
294
349
  frame.getByRole('textbox', { name: fieldLabel, exact }),
295
350
  frame.getByRole('button', { name: fieldLabel, exact }),
@@ -354,6 +409,25 @@ async function findLabeledControl(frame, fieldLabel, exact, opts) {
354
409
  if (text)
355
410
  return text;
356
411
  }
412
+ if (el.parentElement?.tagName.toLowerCase() === 'label') {
413
+ const text = el.parentElement.textContent?.trim();
414
+ if (text)
415
+ return text;
416
+ }
417
+ if ((el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) &&
418
+ !['checkbox', 'radio', 'file', 'button', 'submit', 'reset', 'hidden'].includes(el instanceof HTMLInputElement ? el.type : '')) {
419
+ const placeholder = el.getAttribute('aria-placeholder')?.trim() || el.getAttribute('placeholder')?.trim();
420
+ if (placeholder)
421
+ return placeholder;
422
+ }
423
+ if (el instanceof HTMLInputElement && ['button', 'submit', 'reset'].includes(el.type)) {
424
+ const value = el.value?.trim();
425
+ if (value)
426
+ return value;
427
+ }
428
+ const title = el.getAttribute('title')?.trim();
429
+ if (title)
430
+ return title;
357
431
  return undefined;
358
432
  }
359
433
  function controlPriority(el) {
@@ -512,6 +586,362 @@ async function typeIntoActiveEditableElement(page, text) {
512
586
  }
513
587
  return false;
514
588
  }
589
+ async function clearEditableLocator(locator) {
590
+ try {
591
+ await locator.fill('');
592
+ return true;
593
+ }
594
+ catch {
595
+ /* fall through */
596
+ }
597
+ try {
598
+ await locator.click();
599
+ await locator.press('ControlOrMeta+A');
600
+ await locator.press('Backspace');
601
+ return true;
602
+ }
603
+ catch {
604
+ return false;
605
+ }
606
+ }
607
+ async function clearActiveEditableElement(page) {
608
+ for (const frame of page.frames()) {
609
+ const cleared = await frame.evaluate(() => {
610
+ const active = document.activeElement;
611
+ if (active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement) {
612
+ active.value = '';
613
+ active.dispatchEvent(new Event('input', { bubbles: true }));
614
+ return true;
615
+ }
616
+ if (active instanceof HTMLElement && active.isContentEditable) {
617
+ active.textContent = '';
618
+ active.dispatchEvent(new Event('input', { bubbles: true }));
619
+ return true;
620
+ }
621
+ return false;
622
+ });
623
+ if (cleared)
624
+ return true;
625
+ }
626
+ return false;
627
+ }
628
+ async function resetTypedListboxQuery(page, locator) {
629
+ if (locator && await clearEditableLocator(locator))
630
+ return true;
631
+ return clearActiveEditableElement(page);
632
+ }
633
+ async function resolveMeaningfulOptionClickTarget(locator) {
634
+ const baseHandle = await locator.elementHandle();
635
+ if (!baseHandle)
636
+ return null;
637
+ const targetHandle = (await baseHandle.evaluateHandle((el, payload) => {
638
+ function visible(node) {
639
+ if (!(node instanceof HTMLElement))
640
+ return false;
641
+ const rect = node.getBoundingClientRect();
642
+ if (rect.width <= 0 || rect.height <= 0)
643
+ return false;
644
+ const style = getComputedStyle(node);
645
+ return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0';
646
+ }
647
+ function textFor(node) {
648
+ return node.getAttribute('aria-label')?.trim() || node.textContent?.trim() || '';
649
+ }
650
+ if (!(el instanceof HTMLElement))
651
+ return el;
652
+ const baseText = textFor(el);
653
+ const popup = el.closest(payload.popupSelector);
654
+ let best = el;
655
+ let bestScore = Number.POSITIVE_INFINITY;
656
+ let current = el;
657
+ let depth = 0;
658
+ while (current && depth < 6) {
659
+ if (visible(current)) {
660
+ const rect = current.getBoundingClientRect();
661
+ const className = typeof current.className === 'string' ? current.className.toLowerCase() : '';
662
+ const role = current.getAttribute('role');
663
+ const tag = current.tagName.toLowerCase();
664
+ const currentText = textFor(current);
665
+ const rowLike = role === 'option' ||
666
+ role === 'menuitem' ||
667
+ role === 'treeitem' ||
668
+ tag === 'button' ||
669
+ tag === 'li' ||
670
+ tag === 'label' ||
671
+ current.hasAttribute('data-value') ||
672
+ current.hasAttribute('aria-selected') ||
673
+ current.hasAttribute('aria-checked') ||
674
+ className.includes('option') ||
675
+ className.includes('item') ||
676
+ className.includes('row') ||
677
+ className.includes('menu');
678
+ const textAligned = !!baseText && !!currentText && (currentText === baseText || currentText.includes(baseText));
679
+ const insidePopup = popup ? popup.contains(current) : !!current.closest(payload.popupSelector);
680
+ if ((rowLike || textAligned) && insidePopup) {
681
+ const score = rect.width * rect.height +
682
+ depth * 600 -
683
+ (rowLike ? 20_000 : 0) -
684
+ (textAligned ? 8_000 : 0);
685
+ if (score < bestScore) {
686
+ best = current;
687
+ bestScore = score;
688
+ }
689
+ }
690
+ }
691
+ current = current.parentElement;
692
+ depth++;
693
+ }
694
+ return best;
695
+ }, { popupSelector: POPUP_CONTAINER_SELECTOR }));
696
+ return targetHandle;
697
+ }
698
+ async function collectVisibleOptionHints(page, anchor) {
699
+ const merged = new Map();
700
+ let hasPopup = false;
701
+ for (const frame of page.frames()) {
702
+ const snapshot = await frame.evaluate((payload) => {
703
+ function normalize(value) {
704
+ return value
705
+ .normalize('NFKD')
706
+ .replace(/[\u0300-\u036f]/g, '')
707
+ .replace(/[+﹢∔]/g, '+')
708
+ .replace(/[‐‑‒–—―]/g, '-')
709
+ .replace(/&/g, ' and ')
710
+ .replace(/\bplus\b/g, '+')
711
+ .replace(/[,/()]+/g, ' ')
712
+ .replace(/\s*\+\s*/g, '+')
713
+ .replace(/\s+/g, ' ')
714
+ .trim()
715
+ .toLowerCase();
716
+ }
717
+ function visible(el) {
718
+ if (!(el instanceof HTMLElement))
719
+ return false;
720
+ const rect = el.getBoundingClientRect();
721
+ if (rect.width <= 0 || rect.height <= 0)
722
+ return false;
723
+ const style = getComputedStyle(el);
724
+ return style.display !== 'none' && style.visibility !== 'hidden';
725
+ }
726
+ function labelFor(el) {
727
+ return el.getAttribute('aria-label')?.trim() || el.textContent?.trim() || '';
728
+ }
729
+ function selected(el) {
730
+ return (el.getAttribute('aria-selected') === 'true' ||
731
+ el.getAttribute('aria-checked') === 'true' ||
732
+ el.getAttribute('data-selected') === 'true' ||
733
+ el.getAttribute('data-state') === 'checked' ||
734
+ el.getAttribute('data-state') === 'on');
735
+ }
736
+ function highlighted(el) {
737
+ return (el === document.activeElement ||
738
+ el.getAttribute('data-highlighted') === 'true' ||
739
+ el.getAttribute('data-focus') === 'true' ||
740
+ el.getAttribute('data-focused') === 'true' ||
741
+ el.getAttribute('data-hovered') === 'true' ||
742
+ el.getAttribute('data-state') === 'active' ||
743
+ el.getAttribute('aria-current') === 'true');
744
+ }
745
+ const popupRoots = Array.from(document.querySelectorAll(payload.popupSelector)).filter((el) => visible(el));
746
+ const rows = [];
747
+ const seen = new Set();
748
+ for (const popup of popupRoots) {
749
+ const candidates = [popup, ...Array.from(popup.querySelectorAll(payload.optionSelector))];
750
+ for (const el of candidates) {
751
+ if (!(el instanceof Element) || !visible(el))
752
+ continue;
753
+ const className = typeof el.className === 'string' ? el.className.toLowerCase() : '';
754
+ const role = el.getAttribute('role');
755
+ const tag = el.tagName.toLowerCase();
756
+ const optionLike = role === 'option' ||
757
+ role === 'menuitem' ||
758
+ role === 'treeitem' ||
759
+ tag === 'button' ||
760
+ tag === 'li' ||
761
+ tag === 'label' ||
762
+ el.hasAttribute('data-value') ||
763
+ el.hasAttribute('aria-selected') ||
764
+ el.hasAttribute('aria-checked') ||
765
+ className.includes('option') ||
766
+ className.includes('item') ||
767
+ className.includes('row') ||
768
+ className.includes('menu');
769
+ const label = labelFor(el);
770
+ if (!label || label.length > 180)
771
+ continue;
772
+ if (!optionLike && !popup.contains(el.parentElement))
773
+ continue;
774
+ const key = normalize(label);
775
+ if (!key || seen.has(key))
776
+ continue;
777
+ const rect = el.getBoundingClientRect();
778
+ const centerX = rect.left + rect.width / 2;
779
+ const centerY = rect.top + rect.height / 2;
780
+ const distance = payload.anchorX === null && payload.anchorY === null
781
+ ? rect.top
782
+ : Math.abs(centerX - (payload.anchorX ?? centerX)) + Math.abs(centerY - (payload.anchorY ?? centerY));
783
+ rows.push({
784
+ label,
785
+ selected: selected(el),
786
+ highlighted: highlighted(el),
787
+ rank: (selected(el) ? -4_000 : 0) + (highlighted(el) ? -2_000 : 0) + distance,
788
+ });
789
+ seen.add(key);
790
+ if (rows.length >= payload.maxOptions * 3)
791
+ break;
792
+ }
793
+ }
794
+ rows.sort((a, b) => a.rank - b.rank || a.label.localeCompare(b.label));
795
+ return {
796
+ hasPopup: popupRoots.length > 0,
797
+ options: rows.slice(0, payload.maxOptions),
798
+ };
799
+ }, {
800
+ popupSelector: POPUP_ROOT_SELECTOR,
801
+ optionSelector: OPTION_PICKER_SELECTOR,
802
+ anchorX: anchor?.x ?? null,
803
+ anchorY: anchor?.y ?? null,
804
+ maxOptions: MAX_VISIBLE_OPTION_HINTS,
805
+ });
806
+ hasPopup ||= snapshot.hasPopup;
807
+ for (const option of snapshot.options) {
808
+ const key = normalizedOptionLabel(option.label);
809
+ const existing = merged.get(key);
810
+ const rank = (option.selected ? 0 : 10) + (option.highlighted ? 0 : 5) + merged.size;
811
+ if (!existing || rank < existing.rank) {
812
+ merged.set(key, { ...option, rank });
813
+ }
814
+ }
815
+ }
816
+ const options = [...merged.values()]
817
+ .sort((a, b) => a.rank - b.rank || a.label.localeCompare(b.label))
818
+ .slice(0, MAX_VISIBLE_OPTION_HINTS)
819
+ .map(({ label, selected, highlighted }) => ({ label, selected, highlighted }));
820
+ return { hasPopup, options };
821
+ }
822
+ async function activeListboxOptionLabel(page, anchor) {
823
+ for (const frame of page.frames()) {
824
+ const label = await frame.evaluate((payload) => {
825
+ function visible(el) {
826
+ if (!(el instanceof HTMLElement))
827
+ return false;
828
+ const rect = el.getBoundingClientRect();
829
+ if (rect.width <= 0 || rect.height <= 0)
830
+ return false;
831
+ const style = getComputedStyle(el);
832
+ return style.display !== 'none' && style.visibility !== 'hidden';
833
+ }
834
+ function labelFor(el) {
835
+ const text = el?.getAttribute('aria-label')?.trim() || el?.textContent?.trim() || '';
836
+ return text || null;
837
+ }
838
+ function highlighted(el) {
839
+ return (el === document.activeElement ||
840
+ el.getAttribute('data-highlighted') === 'true' ||
841
+ el.getAttribute('data-focus') === 'true' ||
842
+ el.getAttribute('data-focused') === 'true' ||
843
+ el.getAttribute('data-hovered') === 'true' ||
844
+ el.getAttribute('data-state') === 'active' ||
845
+ el.getAttribute('aria-current') === 'true' ||
846
+ el.getAttribute('aria-selected') === 'true');
847
+ }
848
+ const active = document.activeElement;
849
+ const activeDescendantId = active?.getAttribute('aria-activedescendant');
850
+ const referenced = activeDescendantId ? document.getElementById(activeDescendantId) : null;
851
+ if (referenced && visible(referenced))
852
+ return labelFor(referenced);
853
+ const candidates = Array.from(document.querySelectorAll(payload.optionSelector)).filter(el => visible(el) && highlighted(el));
854
+ let best = null;
855
+ for (const el of candidates) {
856
+ const label = labelFor(el);
857
+ if (!label)
858
+ continue;
859
+ const rect = el.getBoundingClientRect();
860
+ const centerX = rect.left + rect.width / 2;
861
+ const centerY = rect.top + rect.height / 2;
862
+ const distance = payload.anchorX === null && payload.anchorY === null
863
+ ? rect.top
864
+ : Math.abs(centerX - (payload.anchorX ?? centerX)) + Math.abs(centerY - (payload.anchorY ?? centerY));
865
+ if (!best || distance < best.score)
866
+ best = { label, score: distance };
867
+ }
868
+ return best?.label ?? null;
869
+ }, {
870
+ optionSelector: OPTION_PICKER_SELECTOR,
871
+ anchorX: anchor?.x ?? null,
872
+ anchorY: anchor?.y ?? null,
873
+ });
874
+ if (label)
875
+ return label;
876
+ }
877
+ return null;
878
+ }
879
+ async function tryKeyboardSelectVisibleOption(page, label, exact, anchor, focusLocator) {
880
+ if (focusLocator) {
881
+ try {
882
+ await focusLocator.click();
883
+ }
884
+ catch {
885
+ /* ignore */
886
+ }
887
+ try {
888
+ await focusLocator.focus();
889
+ }
890
+ catch {
891
+ /* ignore */
892
+ }
893
+ await delay(40);
894
+ }
895
+ const visible = await collectVisibleOptionHints(page, anchor);
896
+ if (!visible.options.some(option => selectionMatchScore(option.label, label, exact) !== null)) {
897
+ return null;
898
+ }
899
+ for (const key of ['ArrowDown', 'ArrowUp']) {
900
+ const seen = new Set();
901
+ for (let step = 0; step < LISTBOX_KEYBOARD_FALLBACK_STEPS; step++) {
902
+ await page.keyboard.press(key);
903
+ await delay(50);
904
+ const active = await activeListboxOptionLabel(page, anchor);
905
+ if (!active)
906
+ continue;
907
+ if (selectionMatchScore(active, label, exact) !== null) {
908
+ await page.keyboard.press('Enter');
909
+ return active;
910
+ }
911
+ const normalized = normalizedOptionLabel(active);
912
+ if (seen.has(normalized))
913
+ break;
914
+ seen.add(normalized);
915
+ }
916
+ }
917
+ return null;
918
+ }
919
+ function listboxErrorMessage(opts) {
920
+ const visibleOptions = (opts.visibleOptions ?? []).map(option => option.label).slice(0, MAX_VISIBLE_OPTION_HINTS);
921
+ const payload = {
922
+ error: 'listboxPick',
923
+ reason: opts.reason,
924
+ message: opts.reason === 'field_not_found'
925
+ ? `listboxPick: no visible combobox/dropdown matching field "${opts.fieldLabel ?? 'unknown'}"`
926
+ : opts.reason === 'selection_not_confirmed'
927
+ ? `listboxPick: selected "${opts.requestedLabel}" but could not confirm it on field "${opts.fieldLabel ?? 'unknown'}"`
928
+ : `listboxPick: no visible option matching "${opts.requestedLabel}"`,
929
+ requestedLabel: opts.requestedLabel,
930
+ ...(opts.fieldLabel ? { fieldLabel: opts.fieldLabel } : {}),
931
+ ...(opts.query ? { query: opts.query } : {}),
932
+ exact: opts.exact,
933
+ ...(opts.listEmpty !== undefined ? { listEmpty: opts.listEmpty } : {}),
934
+ ...(opts.queryReset ? { queryReset: true } : {}),
935
+ visibleOptionCount: visibleOptions.length,
936
+ visibleOptions,
937
+ suggestedAction: visibleOptions.length > 0
938
+ ? 'Retry with one of visibleOptions, or pass a shorter query/alias for searchable comboboxes.'
939
+ : opts.listEmpty
940
+ ? 'The list appears empty. Retry after clearing the search query or reopening the dropdown.'
941
+ : 'Open the dropdown first, or retry with fieldLabel so Geometra can anchor to the correct combobox.',
942
+ };
943
+ return JSON.stringify(payload, null, 2);
944
+ }
515
945
  async function clickVisibleOptionCandidate(page, label, exact, anchor) {
516
946
  for (const frame of page.frames()) {
517
947
  const candidates = frame.locator(OPTION_PICKER_SELECTOR);
@@ -520,7 +950,18 @@ async function clickVisibleOptionCandidate(page, label, exact, anchor) {
520
950
  continue;
521
951
  const bestIndex = await candidates.evaluateAll((elements, payload) => {
522
952
  function normalize(value) {
523
- return value.replace(/\s+/g, ' ').trim().toLowerCase();
953
+ return value
954
+ .normalize('NFKD')
955
+ .replace(/[\u0300-\u036f]/g, '')
956
+ .replace(/[+﹢∔]/g, '+')
957
+ .replace(/[‐‑‒–—―]/g, '-')
958
+ .replace(/&/g, ' and ')
959
+ .replace(/\bplus\b/g, '+')
960
+ .replace(/[,/()]+/g, ' ')
961
+ .replace(/\s*\+\s*/g, '+')
962
+ .replace(/\s+/g, ' ')
963
+ .trim()
964
+ .toLowerCase();
524
965
  }
525
966
  function aliases(value) {
526
967
  const out = new Set([value]);
@@ -539,6 +980,30 @@ async function clickVisibleOptionCandidate(page, label, exact, anchor) {
539
980
  out.add(alias);
540
981
  }
541
982
  }
983
+ if (value === 'atx' || value.includes('austin')) {
984
+ for (const alias of ['atx', 'austin', 'austin tx', 'austin texas'])
985
+ out.add(alias);
986
+ }
987
+ if (value === 'nyc' || value.includes('new york')) {
988
+ for (const alias of ['nyc', 'new york', 'new york ny'])
989
+ out.add(alias);
990
+ }
991
+ if (value === 'sf' || value.includes('san francisco')) {
992
+ for (const alias of ['sf', 'san francisco', 'san francisco ca'])
993
+ out.add(alias);
994
+ }
995
+ if (value === 'la' || value.includes('los angeles')) {
996
+ for (const alias of ['la', 'los angeles', 'los angeles ca'])
997
+ out.add(alias);
998
+ }
999
+ if (value === 'dc' || value.includes('washington dc')) {
1000
+ for (const alias of ['dc', 'washington dc', 'washington d c'])
1001
+ out.add(alias);
1002
+ }
1003
+ if (value === 'us' || value === 'usa' || value.includes('united states')) {
1004
+ for (const alias of ['us', 'usa', 'united states'])
1005
+ out.add(alias);
1006
+ }
542
1007
  return [...out];
543
1008
  }
544
1009
  function hasNegativeCue(value) {
@@ -590,7 +1055,7 @@ async function clickVisibleOptionCandidate(page, label, exact, anchor) {
590
1055
  return style.display !== 'none' && style.visibility !== 'hidden';
591
1056
  }
592
1057
  function popupWeight(el) {
593
- return el.closest('[role="listbox"], [role="menu"], [role="dialog"], [aria-modal="true"], [data-radix-popper-content-wrapper], [class*="menu"], [class*="option"], [class*="select"], [class*="dropdown"]')
1058
+ return el.closest(payload.popupSelector)
594
1059
  ? 0
595
1060
  : 220;
596
1061
  }
@@ -618,13 +1083,24 @@ async function clickVisibleOptionCandidate(page, label, exact, anchor) {
618
1083
  best = { index: i, score };
619
1084
  }
620
1085
  return best?.index ?? -1;
621
- }, { label, exact, anchorX: anchor?.x ?? null, anchorY: anchor?.y ?? null });
1086
+ }, {
1087
+ label,
1088
+ exact,
1089
+ anchorX: anchor?.x ?? null,
1090
+ anchorY: anchor?.y ?? null,
1091
+ popupSelector: POPUP_CONTAINER_SELECTOR,
1092
+ });
622
1093
  if (bestIndex >= 0) {
623
- const selectedText = (await candidates
624
- .nth(bestIndex)
625
- .evaluate(el => el.getAttribute('aria-label')?.trim() || el.textContent?.trim() || '')
626
- .catch(() => '')) || null;
627
- await candidates.nth(bestIndex).click();
1094
+ const candidate = candidates.nth(bestIndex);
1095
+ const selectedText = (await candidate.evaluate(el => el.getAttribute('aria-label')?.trim() || el.textContent?.trim() || '').catch(() => '')) || null;
1096
+ const clickTarget = await resolveMeaningfulOptionClickTarget(candidate);
1097
+ if (clickTarget) {
1098
+ await clickTarget.scrollIntoViewIfNeeded();
1099
+ await clickTarget.click();
1100
+ }
1101
+ else {
1102
+ await candidate.click();
1103
+ }
628
1104
  return selectedText;
629
1105
  }
630
1106
  }
@@ -654,7 +1130,18 @@ async function visibleOptionIsSelected(page, label, exact, anchor) {
654
1130
  continue;
655
1131
  const selected = await candidates.evaluateAll((elements, payload) => {
656
1132
  function normalize(value) {
657
- return value.replace(/\s+/g, ' ').trim().toLowerCase();
1133
+ return value
1134
+ .normalize('NFKD')
1135
+ .replace(/[\u0300-\u036f]/g, '')
1136
+ .replace(/[+﹢∔]/g, '+')
1137
+ .replace(/[‐‑‒–—―]/g, '-')
1138
+ .replace(/&/g, ' and ')
1139
+ .replace(/\bplus\b/g, '+')
1140
+ .replace(/[,/()]+/g, ' ')
1141
+ .replace(/\s*\+\s*/g, '+')
1142
+ .replace(/\s+/g, ' ')
1143
+ .trim()
1144
+ .toLowerCase();
658
1145
  }
659
1146
  function aliases(value) {
660
1147
  const out = new Set([value]);
@@ -673,6 +1160,30 @@ async function visibleOptionIsSelected(page, label, exact, anchor) {
673
1160
  out.add(alias);
674
1161
  }
675
1162
  }
1163
+ if (value === 'atx' || value.includes('austin')) {
1164
+ for (const alias of ['atx', 'austin', 'austin tx', 'austin texas'])
1165
+ out.add(alias);
1166
+ }
1167
+ if (value === 'nyc' || value.includes('new york')) {
1168
+ for (const alias of ['nyc', 'new york', 'new york ny'])
1169
+ out.add(alias);
1170
+ }
1171
+ if (value === 'sf' || value.includes('san francisco')) {
1172
+ for (const alias of ['sf', 'san francisco', 'san francisco ca'])
1173
+ out.add(alias);
1174
+ }
1175
+ if (value === 'la' || value.includes('los angeles')) {
1176
+ for (const alias of ['la', 'los angeles', 'los angeles ca'])
1177
+ out.add(alias);
1178
+ }
1179
+ if (value === 'dc' || value.includes('washington dc')) {
1180
+ for (const alias of ['dc', 'washington dc', 'washington d c'])
1181
+ out.add(alias);
1182
+ }
1183
+ if (value === 'us' || value === 'usa' || value.includes('united states')) {
1184
+ for (const alias of ['us', 'usa', 'united states'])
1185
+ out.add(alias);
1186
+ }
676
1187
  return [...out];
677
1188
  }
678
1189
  function hasNegativeCue(value) {
@@ -750,10 +1261,19 @@ async function visibleOptionIsSelected(page, label, exact, anchor) {
750
1261
  }
751
1262
  return false;
752
1263
  }
753
- async function confirmListboxSelection(page, fieldLabel, label, exact, anchor, currentHandle, selectedOptionText) {
1264
+ async function confirmListboxSelection(page, fieldLabel, label, exact, anchor, currentHandle, selectedOptionText, opts) {
1265
+ const canTrustEditableDisplayMatch = async () => {
1266
+ if (!opts?.editable)
1267
+ return true;
1268
+ if (await visibleOptionIsSelected(page, label, exact, anchor))
1269
+ return true;
1270
+ const popupState = await collectVisibleOptionHints(page, anchor);
1271
+ return !popupState.hasPopup;
1272
+ };
754
1273
  if (currentHandle) {
755
1274
  const immediateValues = await elementHandleDisplayedValues(currentHandle);
756
- if (immediateValues.some(value => displayedValueMatchesSelection(value, label, exact, selectedOptionText))) {
1275
+ if (immediateValues.some(value => displayedValueMatchesSelection(value, label, exact, selectedOptionText)) &&
1276
+ await canTrustEditableDisplayMatch()) {
757
1277
  return true;
758
1278
  }
759
1279
  }
@@ -764,8 +1284,10 @@ async function confirmListboxSelection(page, fieldLabel, label, exact, anchor, c
764
1284
  if (!locator)
765
1285
  continue;
766
1286
  const values = await locatorDisplayedValues(locator);
767
- if (values.some(value => displayedValueMatchesSelection(value, label, exact, selectedOptionText)))
1287
+ if (values.some(value => displayedValueMatchesSelection(value, label, exact, selectedOptionText)) &&
1288
+ await canTrustEditableDisplayMatch()) {
768
1289
  return true;
1290
+ }
769
1291
  }
770
1292
  if (await visibleOptionIsSelected(page, label, exact, anchor))
771
1293
  return true;
@@ -847,6 +1369,14 @@ async function findLabeledFileInput(frame, fieldLabel, exact) {
847
1369
  if (text)
848
1370
  return text;
849
1371
  }
1372
+ if (el.parentElement?.tagName.toLowerCase() === 'label') {
1373
+ const text = el.parentElement.textContent?.trim();
1374
+ if (text)
1375
+ return text;
1376
+ }
1377
+ const title = el.getAttribute('title')?.trim();
1378
+ if (title)
1379
+ return title;
850
1380
  return undefined;
851
1381
  }
852
1382
  for (let i = 0; i < elements.length; i++) {
@@ -998,6 +1528,7 @@ async function findLabeledEditableField(page, fieldLabel, exact, cache, fieldId)
998
1528
  for (const frame of page.frames()) {
999
1529
  const candidates = [
1000
1530
  frame.getByLabel(fieldLabel, { exact }),
1531
+ frame.getByPlaceholder(fieldLabel, { exact }),
1001
1532
  frame.getByRole('textbox', { name: fieldLabel, exact }),
1002
1533
  frame.getByRole('combobox', { name: fieldLabel, exact }),
1003
1534
  ];
@@ -1205,6 +1736,20 @@ async function attemptNativeBatchFill(page, fields) {
1205
1736
  if (el.parentElement?.tagName.toLowerCase() === 'label') {
1206
1737
  return el.parentElement.textContent?.trim() || undefined;
1207
1738
  }
1739
+ if ((el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) &&
1740
+ !['checkbox', 'radio', 'file', 'button', 'submit', 'reset', 'hidden'].includes(el instanceof HTMLInputElement ? el.type : '')) {
1741
+ const placeholder = el.getAttribute('aria-placeholder')?.trim() || el.getAttribute('placeholder')?.trim();
1742
+ if (placeholder)
1743
+ return placeholder;
1744
+ }
1745
+ if (el instanceof HTMLInputElement && ['button', 'submit', 'reset'].includes(el.type)) {
1746
+ const value = el.value?.trim();
1747
+ if (value)
1748
+ return value;
1749
+ }
1750
+ const title = el.getAttribute('title')?.trim();
1751
+ if (title)
1752
+ return title;
1208
1753
  return undefined;
1209
1754
  }
1210
1755
  function dispatch(target) {
@@ -1524,6 +2069,23 @@ async function chooseValueFromLabeledGroup(page, fieldLabel, value, exact) {
1524
2069
  if (el instanceof HTMLInputElement && el.labels && el.labels.length > 0) {
1525
2070
  return el.labels[0]?.textContent?.trim() || undefined;
1526
2071
  }
2072
+ if (el.parentElement?.tagName.toLowerCase() === 'label') {
2073
+ return el.parentElement.textContent?.trim() || undefined;
2074
+ }
2075
+ if ((el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) &&
2076
+ !['checkbox', 'radio', 'file', 'button', 'submit', 'reset', 'hidden'].includes(el instanceof HTMLInputElement ? el.type : '')) {
2077
+ const placeholder = el.getAttribute('aria-placeholder')?.trim() || el.getAttribute('placeholder')?.trim();
2078
+ if (placeholder)
2079
+ return placeholder;
2080
+ }
2081
+ if (el instanceof HTMLInputElement && ['button', 'submit', 'reset'].includes(el.type)) {
2082
+ const value = el.value?.trim();
2083
+ if (value)
2084
+ return value;
2085
+ }
2086
+ const title = el.getAttribute('title')?.trim();
2087
+ if (title)
2088
+ return title;
1527
2089
  return undefined;
1528
2090
  }
1529
2091
  function choiceLabel(el) {
@@ -1806,16 +2368,34 @@ export async function pickListboxOption(page, label, opts) {
1806
2368
  let attemptedSelection = false;
1807
2369
  let selectedOptionText;
1808
2370
  let openedHandle;
2371
+ let openedLocator;
2372
+ let openedEditable = false;
2373
+ let queryUsed;
2374
+ let queryReset = false;
1809
2375
  if (opts?.fieldLabel) {
1810
- const opened = await openDropdownControl(page, opts.fieldLabel, exact, opts.cache, opts.fieldId);
2376
+ let opened;
2377
+ try {
2378
+ opened = await openDropdownControl(page, opts.fieldLabel, exact, opts.cache, opts.fieldId);
2379
+ }
2380
+ catch {
2381
+ throw new Error(listboxErrorMessage({
2382
+ reason: 'field_not_found',
2383
+ requestedLabel: label,
2384
+ fieldLabel: opts.fieldLabel,
2385
+ query: opts?.query,
2386
+ exact,
2387
+ }));
2388
+ }
1811
2389
  anchor = { x: opened.anchorX, y: opened.anchorY };
1812
2390
  openedHandle = opened.handle;
1813
- const query = opts.query ?? label;
1814
- if (query && opened.editable) {
1815
- await typeIntoEditableLocator(page, opened.locator, query);
2391
+ openedLocator = opened.locator;
2392
+ openedEditable = opened.editable;
2393
+ queryUsed = opts.query ?? label;
2394
+ if (queryUsed && opened.editable) {
2395
+ await typeIntoEditableLocator(page, opened.locator, queryUsed);
1816
2396
  await delay(80);
1817
2397
  }
1818
- else if (query && await typeIntoActiveEditableElement(page, query)) {
2398
+ else if (queryUsed && await typeIntoActiveEditableElement(page, queryUsed)) {
1819
2399
  await delay(80);
1820
2400
  }
1821
2401
  }
@@ -1824,22 +2404,66 @@ export async function pickListboxOption(page, label, opts) {
1824
2404
  anchor = { x: opts.openX, y: opts.openY };
1825
2405
  await delay(120);
1826
2406
  }
1827
- const deadline = Date.now() + 3000;
1828
- while (Date.now() < deadline) {
2407
+ const attemptClickSelection = async () => {
1829
2408
  selectedOptionText = (await clickVisibleOptionCandidate(page, label, exact, anchor)) ?? undefined;
1830
- if (selectedOptionText) {
1831
- attemptedSelection = true;
1832
- if (!opts?.fieldLabel ||
1833
- await confirmListboxSelection(page, opts.fieldLabel, label, exact, anchor, openedHandle, selectedOptionText)) {
2409
+ if (!selectedOptionText)
2410
+ return false;
2411
+ attemptedSelection = true;
2412
+ if (!opts?.fieldLabel ||
2413
+ await confirmListboxSelection(page, opts.fieldLabel, label, exact, anchor, openedHandle, selectedOptionText, {
2414
+ editable: openedEditable,
2415
+ })) {
2416
+ return true;
2417
+ }
2418
+ return false;
2419
+ };
2420
+ if (await attemptClickSelection())
2421
+ return;
2422
+ let visibleHints = await collectVisibleOptionHints(page, anchor);
2423
+ const visibleMatchExists = visibleHints.options.some(option => selectionMatchScore(option.label, label, exact) !== null);
2424
+ if (queryUsed && !visibleMatchExists) {
2425
+ queryReset = await resetTypedListboxQuery(page, openedLocator);
2426
+ if (queryReset) {
2427
+ await delay(80);
2428
+ if (await attemptClickSelection())
1834
2429
  return;
1835
- }
2430
+ visibleHints = await collectVisibleOptionHints(page, anchor);
1836
2431
  }
1837
- await delay(120);
1838
2432
  }
1839
- if (opts?.fieldLabel && attemptedSelection) {
1840
- throw new Error(`listboxPick: selected "${label}" but could not confirm it on field "${opts.fieldLabel}"`);
2433
+ const keyboardSelection = await tryKeyboardSelectVisibleOption(page, label, exact, anchor, openedLocator);
2434
+ if (keyboardSelection) {
2435
+ selectedOptionText = keyboardSelection;
2436
+ attemptedSelection = true;
2437
+ if (!opts?.fieldLabel ||
2438
+ await confirmListboxSelection(page, opts.fieldLabel, label, exact, anchor, openedHandle, selectedOptionText, {
2439
+ editable: openedEditable,
2440
+ })) {
2441
+ return;
2442
+ }
1841
2443
  }
1842
- throw new Error(`listboxPick: no visible option matching "${label}"`);
2444
+ visibleHints = await collectVisibleOptionHints(page, anchor);
2445
+ if (opts?.fieldLabel && attemptedSelection) {
2446
+ throw new Error(listboxErrorMessage({
2447
+ reason: 'selection_not_confirmed',
2448
+ requestedLabel: label,
2449
+ fieldLabel: opts.fieldLabel,
2450
+ query: queryUsed,
2451
+ exact,
2452
+ visibleOptions: visibleHints.options,
2453
+ listEmpty: visibleHints.hasPopup && visibleHints.options.length === 0,
2454
+ queryReset,
2455
+ }));
2456
+ }
2457
+ throw new Error(listboxErrorMessage({
2458
+ reason: 'no_visible_option_match',
2459
+ requestedLabel: label,
2460
+ fieldLabel: opts?.fieldLabel,
2461
+ query: queryUsed,
2462
+ exact,
2463
+ visibleOptions: visibleHints.options,
2464
+ listEmpty: visibleHints.hasPopup && visibleHints.options.length === 0,
2465
+ queryReset,
2466
+ }));
1843
2467
  }
1844
2468
  /**
1845
2469
  * Set a checkbox/radio by accessible label instead of brittle coordinate clicks.