@geometra/proxy 1.64.0 → 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,5 +1,5 @@
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));
@@ -436,6 +436,97 @@ async function locatorIsEditable(locator) {
436
436
  return false;
437
437
  }
438
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
+ }
439
530
  async function locatorAnchorY(locator) {
440
531
  const bounds = await locator.boundingBox();
441
532
  return bounds ? bounds.y + bounds.height / 2 : undefined;
@@ -576,7 +667,15 @@ async function findLabeledControl(frame, fieldLabel, exact, opts) {
576
667
  const style = getComputedStyle(el);
577
668
  return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0';
578
669
  }
579
- 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) {
580
679
  if (!ids)
581
680
  return undefined;
582
681
  const seen = visited ?? new Set();
@@ -586,12 +685,12 @@ async function findLabeledControl(frame, fieldLabel, exact, opts) {
586
685
  if (seen.has(id))
587
686
  return '';
588
687
  seen.add(id);
589
- const target = document.getElementById(id);
688
+ const target = rootScopedElementById(owner, id);
590
689
  if (!target)
591
690
  return '';
592
691
  const chained = target.getAttribute('aria-labelledby');
593
692
  if (chained)
594
- return referencedText(chained, seen) ?? '';
693
+ return referencedText(target, chained, seen) ?? '';
595
694
  return target.textContent?.trim() ?? '';
596
695
  })
597
696
  .filter(Boolean)
@@ -603,7 +702,7 @@ async function findLabeledControl(frame, fieldLabel, exact, opts) {
603
702
  const aria = el.getAttribute('aria-label')?.trim();
604
703
  if (aria)
605
704
  return aria;
606
- const labelledBy = referencedText(el.getAttribute('aria-labelledby'));
705
+ const labelledBy = referencedText(el, el.getAttribute('aria-labelledby'));
607
706
  if (labelledBy)
608
707
  return labelledBy;
609
708
  if ((el instanceof HTMLInputElement || el instanceof HTMLSelectElement || el instanceof HTMLTextAreaElement) &&
@@ -612,7 +711,10 @@ async function findLabeledControl(frame, fieldLabel, exact, opts) {
612
711
  return el.labels[0]?.textContent?.trim() || undefined;
613
712
  }
614
713
  if (el instanceof HTMLElement && el.id) {
615
- 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;
616
718
  const text = label?.textContent?.trim();
617
719
  if (text)
618
720
  return text;
@@ -769,6 +871,14 @@ async function findMenuTriggerByContainerLabel(frame, fieldLabel, exact) {
769
871
  const style = getComputedStyle(el);
770
872
  return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0';
771
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
+ }
772
882
  function containerLabelText(el) {
773
883
  // Walk up a few parent levels looking for a container that holds BOTH
774
884
  // the trigger and some sibling label text. The label text is the
@@ -811,7 +921,7 @@ async function findMenuTriggerByContainerLabel(frame, fieldLabel, exact) {
811
921
  if (labelledBy) {
812
922
  const text = labelledBy
813
923
  .split(/\s+/)
814
- .map(id => document.getElementById(id)?.textContent?.trim() ?? '')
924
+ .map(id => rootScopedElementById(el, id)?.textContent?.trim() ?? '')
815
925
  .filter(Boolean)
816
926
  .join(' ')
817
927
  .trim();
@@ -835,17 +945,88 @@ async function findMenuTriggerByContainerLabel(frame, fieldLabel, exact) {
835
945
  }, { fieldLabel, exact });
836
946
  return bestIndex >= 0 ? triggers.nth(bestIndex) : null;
837
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
+ }
838
1013
  async function findLabeledControlInPage(page, fieldLabel, exact, opts) {
839
1014
  const cacheable = !opts?.preferredAnchor;
840
1015
  const cached = cacheable
841
1016
  ? await readCachedLocator(opts?.cache, 'control', fieldLabel, exact, opts?.fieldId, opts?.fieldKey)
842
1017
  : undefined;
843
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
+ }
844
1022
  return cached;
845
1023
  }
846
1024
  if (opts?.fieldKey) {
847
1025
  const authored = await findAuthoredFieldByKeyInPage(page, opts.fieldKey, 'control');
848
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
+ }
849
1030
  if (cacheable)
850
1031
  writeCachedLocator(opts?.cache, 'control', fieldLabel, exact, opts?.fieldId, opts.fieldKey, authored);
851
1032
  return authored;
@@ -915,6 +1096,7 @@ function displayedValueMatchesSelection(candidate, expected, exact, selectedOpti
915
1096
  async function openDropdownControl(page, fieldLabel, exact, cache, fieldId, fieldKey) {
916
1097
  const locator = await findLabeledControlInPage(page, fieldLabel, exact, { cache, fieldId, fieldKey });
917
1098
  if (locator) {
1099
+ await assertLocatorMutable(locator, 'select', 'listboxPick');
918
1100
  await locator.scrollIntoViewIfNeeded();
919
1101
  const handle = await locator.elementHandle();
920
1102
  const clickTarget = await resolveMeaningfulClickTarget(locator);
@@ -1015,11 +1197,19 @@ async function resolveOwnedPopupHandle(triggerHandle) {
1015
1197
  }
1016
1198
  return ids;
1017
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
+ }
1018
1208
  function lookupReferenced() {
1019
1209
  const attributes = ['aria-controls', 'aria-owns', 'aria-activedescendant'];
1020
1210
  for (const attribute of attributes) {
1021
1211
  for (const id of readIdRefs(el, attribute)) {
1022
- const referenced = document.getElementById(id);
1212
+ const referenced = rootScopedElementById(el, id);
1023
1213
  const popup = asPopupRoot(referenced);
1024
1214
  if (popup)
1025
1215
  return popup;
@@ -1655,6 +1845,8 @@ async function clickScopedOptionCandidate(popupScope, label, exact) {
1655
1845
  }, hit.selector)).asElement();
1656
1846
  if (!target)
1657
1847
  return null;
1848
+ if (await target.evaluate(mutationBlockReasonInPage, 'select'))
1849
+ return null;
1658
1850
  try {
1659
1851
  await target.scrollIntoViewIfNeeded({ timeout: 500 });
1660
1852
  }
@@ -1839,6 +2031,8 @@ async function clickVisibleOptionCandidate(page, label, exact, anchor, popupScop
1839
2031
  });
1840
2032
  if (bestIndex >= 0) {
1841
2033
  const candidate = candidates.nth(bestIndex);
2034
+ if (await locatorMutationBlockReason(candidate, 'select'))
2035
+ return null;
1842
2036
  const selectedText = (await candidate.evaluate(el => el.getAttribute('aria-label')?.trim() || el.textContent?.trim() || '').catch(() => '')) || null;
1843
2037
  const clickTarget = await resolveMeaningfulOptionClickTarget(candidate);
1844
2038
  // Only scrollIntoView if the option is NOT inside a popup/dropdown container.
@@ -2546,6 +2740,22 @@ async function confirmListboxSelection(page, fieldLabel, label, exact, anchor, c
2546
2740
  }
2547
2741
  return false;
2548
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
+ }
2549
2759
  const PLACEHOLDER_PATTERN = /^(select|choose|pick|--|—\s)/i;
2550
2760
  /**
2551
2761
  * Dismiss the dropdown (Tab) and re-verify the field value didn't revert to a placeholder.
@@ -2576,7 +2786,7 @@ async function dismissAndReVerifySelection(page, label, exact, currentHandle, se
2576
2786
  }
2577
2787
  await delay(50);
2578
2788
  if (!currentHandle)
2579
- return true;
2789
+ return false;
2580
2790
  // Re-verify using the original element handle (immune to DOM reordering)
2581
2791
  const deadline = Date.now() + 800;
2582
2792
  let sawAnyValue = false;
@@ -2611,8 +2821,9 @@ async function dismissAndReVerifySelection(page, label, exact, currentHandle, se
2611
2821
  }
2612
2822
  }
2613
2823
  catch {
2614
- // Handle detached trust the earlier confirmation
2615
- 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;
2616
2827
  }
2617
2828
  await delay(100);
2618
2829
  }
@@ -2648,143 +2859,224 @@ async function dismissAndReVerifySelection(page, label, exact, currentHandle, se
2648
2859
  /**
2649
2860
  * Resolve and validate paths on the machine running the proxy (not the agent host).
2650
2861
  */
2651
- export function resolveExistingFiles(rawPaths) {
2862
+ export function resolveExistingFiles(rawPaths, allowedRoots = []) {
2652
2863
  if (!Array.isArray(rawPaths) || rawPaths.length === 0) {
2653
2864
  throw new Error('file: paths must be a non-empty array of strings');
2654
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
+ });
2655
2879
  const paths = [];
2656
2880
  for (const p of rawPaths) {
2657
2881
  if (typeof p !== 'string' || p.trim() === '')
2658
2882
  continue;
2659
- 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);
2660
2896
  }
2661
2897
  if (paths.length === 0)
2662
2898
  throw new Error('file: paths must contain at least one non-empty string');
2663
- for (const p of paths) {
2664
- if (!existsSync(p))
2665
- throw new Error(`file: path does not exist: ${p}`);
2666
- }
2667
2899
  return paths;
2668
2900
  }
2669
- async function findLabeledFileInput(frame, fieldLabel, exact) {
2670
- // Try exact-match candidates first, even when the caller passed
2671
- // exact=false. Same Greenhouse-style failure mode as findLabeledControl:
2672
- // a substring match (e.g. file labeled "Resume" hijacked by another
2673
- // "Please attach your resume below" field) silently picks the wrong
2674
- // input. Trying exact first guarantees the literal label wins when
2675
- // present, and only falls through to substring matching otherwise.
2676
- const exactDirect = frame.getByLabel(fieldLabel, { exact: true });
2677
- const exactDirectCount = await exactDirect.count();
2678
- for (let i = 0; i < exactDirectCount; i++) {
2679
- const candidate = exactDirect.nth(i);
2680
- const isFileInput = await candidate.evaluate(el => el instanceof HTMLInputElement && el.type === 'file').catch(() => false);
2681
- if (isFileInput)
2682
- return candidate;
2683
- }
2684
- if (!exact) {
2685
- const direct = frame.getByLabel(fieldLabel, { exact: false });
2686
- const directCount = await direct.count();
2687
- for (let i = 0; i < directCount; i++) {
2688
- const candidate = direct.nth(i);
2689
- const isFileInput = await candidate.evaluate(el => el instanceof HTMLInputElement && el.type === 'file').catch(() => false);
2690
- if (isFileInput)
2691
- 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;
2692
2910
  }
2693
- }
2694
- const loc = frame.locator('input[type="file"]');
2695
- const count = await loc.count();
2696
- if (count === 0)
2697
- return null;
2698
- const bestIndex = await loc.evaluateAll((elements, payload) => {
2699
- function normalize(value) {
2700
- return value.replace(/\s+/g, ' ').trim().toLowerCase();
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();
2701
2920
  }
2702
- function matches(candidate) {
2703
- if (!candidate)
2704
- return false;
2705
- const normalizedCandidate = normalize(candidate);
2706
- const normalizedExpected = normalize(payload.fieldLabel);
2707
- if (!normalizedCandidate || !normalizedExpected)
2708
- return false;
2709
- return payload.exact ? normalizedCandidate === normalizedExpected : normalizedCandidate.includes(normalizedExpected);
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) => {
2936
+ function normalize(value) {
2937
+ return (value ?? '').replace(/\s+/g, ' ').trim().toLowerCase();
2938
+ }
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;
2710
2946
  }
2711
- function referencedText(ids, visited) {
2947
+ function referencedText(owner, ids) {
2712
2948
  if (!ids)
2713
- return undefined;
2714
- const seen = visited ?? new Set();
2715
- const text = ids
2949
+ return '';
2950
+ return ids
2716
2951
  .split(/\s+/)
2717
- .map(id => {
2718
- if (seen.has(id))
2719
- return '';
2720
- seen.add(id);
2721
- const target = document.getElementById(id);
2722
- if (!target)
2723
- return '';
2724
- const chained = target.getAttribute('aria-labelledby');
2725
- if (chained)
2726
- return referencedText(chained, seen) ?? '';
2727
- return target.textContent?.trim() ?? '';
2728
- })
2952
+ .map(id => rootScopedElementById(owner, id)?.textContent?.trim() ?? '')
2729
2953
  .filter(Boolean)
2730
2954
  .join(' ')
2731
2955
  .trim();
2732
- return text || undefined;
2733
2956
  }
2734
- function explicitLabelText(el) {
2735
- const aria = el.getAttribute('aria-label')?.trim();
2736
- if (aria)
2737
- return aria;
2738
- const labelledBy = referencedText(el.getAttribute('aria-labelledby'));
2739
- if (labelledBy)
2740
- return labelledBy;
2741
- if (el instanceof HTMLInputElement && el.labels && el.labels.length > 0) {
2742
- return el.labels[0]?.textContent?.trim() || undefined;
2743
- }
2744
- if (el instanceof HTMLElement && el.id) {
2745
- const label = document.querySelector(`label[for="${CSS.escape(el.id)}"]`);
2746
- const text = label?.textContent?.trim();
2747
- if (text)
2748
- return text;
2749
- }
2750
- if (el.parentElement?.tagName.toLowerCase() === 'label') {
2751
- const text = el.parentElement.textContent?.trim();
2752
- if (text)
2753
- 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;
2754
2986
  }
2755
- const title = el.getAttribute('title')?.trim();
2756
- if (title)
2757
- return title;
2758
- return undefined;
2987
+ if (!normalize(nearestContext).includes(contextNeedle))
2988
+ return false;
2759
2989
  }
2760
- for (let i = 0; i < elements.length; i++) {
2761
- const el = elements[i];
2762
- if (!(el instanceof HTMLInputElement) || el.type !== 'file')
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;
3000
+ }
3001
+ return true;
3002
+ }, { contextText: contextText ?? '', sectionText: sectionText ?? '' }).catch(() => false);
3003
+ }
3004
+ async function findScopedFileInputsInPage(page, fieldLabel, exact, contextText, sectionText) {
3005
+ const exactMatches = [];
3006
+ const fuzzyMatches = [];
3007
+ const expected = normalizedOptionLabel(fieldLabel);
3008
+ for (const frame of page.frames()) {
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)))
2763
3014
  continue;
2764
- if (matches(explicitLabelText(el)))
2765
- return i;
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
+ }
2766
3025
  }
2767
- return -1;
2768
- }, { fieldLabel, exact });
2769
- return bestIndex >= 0 ? loc.nth(bestIndex) : null;
3026
+ }
3027
+ return exactMatches.length > 0 ? exactMatches : fuzzyMatches;
2770
3028
  }
2771
- async function findLabeledFileInputInPage(page, fieldLabel, exact, cache, fieldId, fieldKey) {
2772
- const cached = await readCachedLocator(cache, 'file', fieldLabel, exact, fieldId, fieldKey);
2773
- if (cached !== undefined)
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
+ }
2774
3042
  return cached;
3043
+ }
2775
3044
  if (fieldKey) {
2776
3045
  const authored = await findAuthoredFieldByKeyInPage(page, fieldKey, 'file');
2777
3046
  if (authored) {
2778
- writeCachedLocator(cache, 'file', fieldLabel, exact, fieldId, fieldKey, 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
+ }
3056
+ }
3057
+ if (!(await locatorMatchesFileScope(authored, contextText, sectionText))) {
3058
+ throw new Error(`file: fieldKey "${fieldKey}" did not match the requested context/section`);
3059
+ }
3060
+ if (!scoped)
3061
+ writeCachedLocator(cache, 'file', fieldLabel, exact, fieldId, fieldKey, authored);
2779
3062
  return authored;
2780
3063
  }
2781
3064
  throw new Error(`fieldKey "${fieldKey}" did not resolve to a file input; refresh geometra_form_schema`);
2782
3065
  }
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
+ }
2783
3073
  if (fieldLabel) {
2784
- for (const frame of page.frames()) {
2785
- const locator = await findLabeledFileInput(frame, fieldLabel, exact);
2786
- if (!locator)
2787
- continue;
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) {
2788
3080
  writeCachedLocator(cache, 'file', fieldLabel, exact, fieldId, undefined, locator);
2789
3081
  return locator;
2790
3082
  }
@@ -2792,58 +3084,355 @@ async function findLabeledFileInputInPage(page, fieldLabel, exact, cache, fieldI
2792
3084
  writeCachedLocator(cache, 'file', fieldLabel, exact, fieldId, undefined, null);
2793
3085
  return null;
2794
3086
  }
2795
- async function attachHiddenInAllFrames(page, paths, opts) {
2796
- if (opts?.fieldLabel || opts?.fieldKey) {
2797
- const labeled = await findLabeledFileInputInPage(page, opts.fieldLabel ?? '', opts.exact ?? false, opts.cache, opts.fieldId, opts.fieldKey);
2798
- if (labeled) {
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) {
2799
3191
  try {
2800
- await labeled.setInputFiles(paths);
2801
- 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);
2802
3214
  }
2803
- catch {
2804
- /* fall through to uncached scan */
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;
2805
3247
  }
3248
+ return false;
2806
3249
  }
2807
- }
2808
- for (const frame of page.frames()) {
2809
- if (opts?.fieldLabel) {
2810
- const labeled = await findLabeledFileInput(frame, opts.fieldLabel, opts.exact ?? false);
2811
- if (!labeled)
2812
- continue;
2813
- try {
2814
- await labeled.setInputFiles(paths);
2815
- return true;
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})`);
2816
3267
  }
2817
- catch {
2818
- /* try next frame */
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})`);
2819
3296
  }
2820
- continue;
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);
2821
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()) {
2822
3397
  const loc = frame.locator('input[type="file"]');
2823
3398
  const n = await loc.count();
2824
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;
2825
3405
  try {
2826
- await loc.nth(i).setInputFiles(paths);
3406
+ await commitFilesToExactInput(handle, paths, 'matched input');
2827
3407
  return true;
2828
3408
  }
2829
- catch {
2830
- /* try next */
3409
+ catch (error) {
3410
+ if (error instanceof FileAcceptValidationError) {
3411
+ acceptError ??= error;
3412
+ continue;
3413
+ }
3414
+ throw error;
2831
3415
  }
2832
3416
  }
2833
3417
  }
3418
+ if (acceptError)
3419
+ throw acceptError;
2834
3420
  return false;
2835
3421
  }
2836
3422
  async function attachViaChooser(page, paths, clickX, clickY) {
3423
+ await assertPointMutable(page, clickX, clickY, 'file', 'file');
2837
3424
  const [chooser] = await Promise.all([
2838
3425
  page.waitForEvent('filechooser', { timeout: 12_000 }),
2839
3426
  page.mouse.click(clickX, clickY),
2840
3427
  ]);
2841
- await chooser.setFiles(paths);
3428
+ const input = chooser.element();
3429
+ await commitFilesToExactInput(input, paths, 'chooser input');
2842
3430
  }
2843
3431
  /**
2844
3432
  * Map common file extensions to MIME types so react-dropzone's `accept` filter
2845
- * does not silently drop our synthetic files. Defaults to application/octet-stream
2846
- * 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.
2847
3436
  */
2848
3437
  function mimeTypeForPath(path) {
2849
3438
  const ext = path.toLowerCase().split('.').pop() ?? '';
@@ -2851,7 +3440,20 @@ function mimeTypeForPath(path) {
2851
3440
  case 'pdf': return 'application/pdf';
2852
3441
  case 'doc': return 'application/msword';
2853
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';
2854
3453
  case 'txt': return 'text/plain';
3454
+ case 'csv': return 'text/csv';
3455
+ case 'htm':
3456
+ case 'html': return 'text/html';
2855
3457
  case 'rtf': return 'application/rtf';
2856
3458
  case 'png': return 'image/png';
2857
3459
  case 'jpg':
@@ -2859,9 +3461,76 @@ function mimeTypeForPath(path) {
2859
3461
  case 'gif': return 'image/gif';
2860
3462
  case 'webp': return 'image/webp';
2861
3463
  case 'svg': return 'image/svg+xml';
2862
- 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;
2863
3476
  }
2864
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
+ }
2865
3534
  /**
2866
3535
  * Synthetic drop at (x,y) using file bytes from the proxy host.
2867
3536
  *
@@ -2879,91 +3548,260 @@ function mimeTypeForPath(path) {
2879
3548
  * button layer and the wrapper that actually has the listeners.
2880
3549
  */
2881
3550
  async function attachViaDropPlaywright(page, paths, dropX, dropY) {
2882
- const fs = await import('node:fs/promises');
2883
- const buffers = await Promise.all(paths.map(p => fs.readFile(p)));
2884
- const names = paths.map(p => p.split(/[/\\\\]/).pop() ?? 'file');
2885
- const mimes = paths.map(mimeTypeForPath);
2886
- await page.mouse.move(dropX, dropY);
2887
- await page.mainFrame().evaluate(({ bufs, ns, ms, x, y }) => {
2888
- const makeDataTransfer = () => {
2889
- const dt = new DataTransfer();
2890
- for (let i = 0; i < bufs.length; i++) {
2891
- const u8 = new Uint8Array(bufs[i]);
2892
- const blob = new Blob([u8], { type: ms[i] });
2893
- dt.items.add(new File([blob], ns[i], { type: ms[i] }));
2894
- }
2895
- return dt;
2896
- };
2897
- const dispatchSequence = (target) => {
2898
- // Each event needs its own DataTransfer because some frameworks
2899
- // consume the items list during dragenter/dragover inspection.
2900
- for (const type of ['dragenter', 'dragover', 'drop']) {
2901
- const dt = makeDataTransfer();
2902
- const ev = new DragEvent(type, {
2903
- bubbles: true,
2904
- cancelable: true,
2905
- composed: true,
2906
- clientX: x,
2907
- clientY: y,
2908
- dataTransfer: dt,
2909
- });
2910
- // Some browsers/frameworks freeze the dataTransfer getter; re-define
2911
- // 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
+ }
2912
3586
  try {
2913
- Object.defineProperty(ev, 'dataTransfer', { value: dt, configurable: true });
3587
+ if (target.matches(':disabled'))
3588
+ return 'disabled';
2914
3589
  }
2915
3590
  catch { /* ignore */ }
2916
- target.dispatchEvent(ev);
2917
- }
2918
- };
2919
- const deepest = document.elementFromPoint(x, y);
2920
- const targets = [];
2921
- if (deepest)
2922
- targets.push(deepest);
2923
- // Walk up a few ancestors looking for anything that looks like a dropzone
2924
- // root (react-dropzone emits `data-rfd-*`; Greenhouse uses div.drop-zone;
2925
- // Ashby uses role="button" wrappers). We dispatch on the first dropzone-ish
2926
- // ancestor AND the deepest element, because we cannot tell statically
2927
- // which one has the real listener.
2928
- let p = deepest?.parentElement ?? null;
2929
- for (let depth = 0; depth < 12 && p; depth++) {
2930
- const tag = p.tagName.toLowerCase();
2931
- const attrs = p.getAttributeNames();
2932
- const looksLikeDropzone = attrs.some(a => a.startsWith('data-rfd') || a === 'data-dropzone' || a === 'data-testid') ||
2933
- (p.className && typeof p.className === 'string' && /drop[-_]?zone|file[-_]?upload|attach|upload/i.test(p.className)) ||
2934
- tag === 'label' ||
2935
- p.getAttribute('role') === 'button';
2936
- if (looksLikeDropzone && !targets.includes(p))
2937
- targets.push(p);
2938
- p = p.parentElement;
2939
- }
2940
- if (targets.length === 0)
2941
- targets.push(document.body);
2942
- for (const t of targets)
2943
- dispatchSequence(t);
2944
- // As a final fallback, if there is a hidden input[type=file] scoped to
2945
- // the nearest form/section, set its `files` via DataTransfer and dispatch
2946
- // the React-friendly native input value setter so react-hook-form notices.
2947
- const host = deepest?.closest('form, [role="form"], fieldset, section, div') ?? document.body;
2948
- const fileInput = host.querySelector('input[type="file"]');
2949
- if (fileInput) {
2950
- const dt = makeDataTransfer();
2951
- try {
2952
- 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;
2953
3609
  }
2954
- catch { /* ignore */ }
2955
- try {
2956
- // React overrides the input value setter; we need the native prototype
2957
- // setter so React sees the change.
2958
- const proto = Object.getPrototypeOf(fileInput);
2959
- const descriptor = Object.getOwnPropertyDescriptor(proto, 'value');
2960
- 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
+ }
2961
3731
  }
2962
- catch { /* ignore */ }
2963
- fileInput.dispatchEvent(new Event('input', { bubbles: true }));
2964
- 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');
2965
3794
  }
2966
- }, { bufs: buffers.map(b => Array.from(b)), ns: names, ms: mimes, x: dropX, y: dropY });
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');
3800
+ }
3801
+ }
3802
+ finally {
3803
+ await dropFileInput?.dispose().catch(() => { });
3804
+ }
2967
3805
  }
2968
3806
  /**
2969
3807
  * `strategy`:
@@ -2977,7 +3815,8 @@ const ATTACH_BUTTON_PATTERN = /attach|upload|add.file|choose.file|browse/i;
2977
3815
  * Find an Attach/Upload button near a field label (e.g. Greenhouse's "Attach" button
2978
3816
  * inside a "Resume/CV" section). Returns the button Locator or null.
2979
3817
  */
2980
- async function findAttachButtonNearLabel(page, fieldLabel) {
3818
+ async function findAttachButtonNearLabel(page, fieldLabel, contextText, sectionText) {
3819
+ const matches = [];
2981
3820
  for (const frame of page.frames()) {
2982
3821
  // Find all buttons/links whose text matches attach-like patterns
2983
3822
  const buttons = frame.locator('button, a, [role="button"]');
@@ -3001,15 +3840,18 @@ async function findAttachButtonNearLabel(page, fieldLabel) {
3001
3840
  }
3002
3841
  return false;
3003
3842
  }, fieldLabel);
3004
- if (nearLabel)
3005
- return btn;
3843
+ if (nearLabel && await locatorMatchesFileScope(btn, contextText, sectionText))
3844
+ matches.push(btn);
3006
3845
  }
3007
3846
  catch {
3008
3847
  continue;
3009
3848
  }
3010
3849
  }
3011
3850
  }
3012
- 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;
3013
3855
  }
3014
3856
  export async function attachFiles(page, paths, opts) {
3015
3857
  const strategy = opts?.strategy ?? 'auto';
@@ -3019,6 +3861,51 @@ export async function attachFiles(page, paths, opts) {
3019
3861
  const exact = opts?.exact ?? false;
3020
3862
  const dropX = opts?.dropX;
3021
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
+ }
3022
3909
  if (strategy === 'chooser' || (strategy === 'auto' && clickX !== undefined && clickY !== undefined)) {
3023
3910
  if (clickX === undefined || clickY === undefined) {
3024
3911
  throw new Error('file: chooser strategy requires x,y click coordinates');
@@ -3032,6 +3919,8 @@ export async function attachFiles(page, paths, opts) {
3032
3919
  fieldKey: opts?.fieldKey,
3033
3920
  fieldLabel,
3034
3921
  exact,
3922
+ contextText: opts?.contextText,
3923
+ sectionText: opts?.sectionText,
3035
3924
  cache: opts?.cache,
3036
3925
  }))
3037
3926
  return;
@@ -3043,9 +3932,10 @@ export async function attachFiles(page, paths, opts) {
3043
3932
  }
3044
3933
  // Fallback: look for an Attach/Upload button near the field label and use the file chooser
3045
3934
  if (fieldLabel && strategy === 'auto') {
3046
- const attachBtn = await findAttachButtonNearLabel(page, fieldLabel);
3935
+ const attachBtn = await findAttachButtonNearLabel(page, fieldLabel, opts?.contextText, opts?.sectionText);
3047
3936
  if (attachBtn) {
3048
3937
  try {
3938
+ await assertLocatorMutable(attachBtn, 'file', 'file');
3049
3939
  await attachBtn.scrollIntoViewIfNeeded();
3050
3940
  const box = await attachBtn.boundingBox();
3051
3941
  if (box) {
@@ -3059,6 +3949,12 @@ export async function attachFiles(page, paths, opts) {
3059
3949
  if (fieldLabel) {
3060
3950
  throw new Error(`file: no input[type=file] matching field "${fieldLabel}"`);
3061
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
+ }
3062
3958
  }
3063
3959
  if (strategy === 'drop' || (strategy === 'auto' && dropX !== undefined && dropY !== undefined)) {
3064
3960
  if (dropX === undefined || dropY === undefined) {
@@ -3067,15 +3963,7 @@ export async function attachFiles(page, paths, opts) {
3067
3963
  await attachViaDropPlaywright(page, paths, dropX, dropY);
3068
3964
  return;
3069
3965
  }
3070
- for (const frame of page.frames()) {
3071
- const loc = frame.locator('input[type="file"]');
3072
- const n = await loc.count();
3073
- if (n > 0) {
3074
- await loc.first().setInputFiles(paths);
3075
- return;
3076
- }
3077
- }
3078
- 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');
3079
3967
  }
3080
3968
  async function findLabeledEditableField(page, fieldLabel, exact, cache, fieldId, fieldKey) {
3081
3969
  const cached = await readCachedLocator(cache, 'editable', fieldLabel, exact, fieldId, fieldKey);
@@ -3204,13 +4092,63 @@ function fieldValueMatches(actual, expected) {
3204
4092
  const normalizedExpected = normalizedFieldValue(expected);
3205
4093
  if (!normalizedActual || !normalizedExpected)
3206
4094
  return false;
3207
- return normalizedActual === normalizedExpected || normalizedActual.includes(normalizedExpected);
4095
+ return normalizedActual === normalizedExpected;
3208
4096
  }
3209
4097
  async function setLocatorTextValue(locator, value, opts) {
4098
+ if (await locatorMutationBlockReason(locator, 'text'))
4099
+ return false;
3210
4100
  try {
3211
4101
  return await locator.evaluate((el, payload) => {
3212
4102
  const nextValue = payload.value;
3213
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;
3214
4152
  // React Greenhouse/Workday/Lever fix: React stores the previous value on
3215
4153
  // a hidden `_valueTracker` property that React uses to short-circuit
3216
4154
  // onChange when the value "hasn't changed". If we set el.value through
@@ -3416,7 +4354,57 @@ async function attemptNativeBatchFill(page, fields) {
3416
4354
  const style = getComputedStyle(el);
3417
4355
  return style.display !== 'none' && style.visibility !== 'hidden';
3418
4356
  }
3419
- 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) {
3420
4408
  if (!ids)
3421
4409
  return undefined;
3422
4410
  const seen = visited ?? new Set();
@@ -3426,12 +4414,12 @@ async function attemptNativeBatchFill(page, fields) {
3426
4414
  if (seen.has(id))
3427
4415
  return '';
3428
4416
  seen.add(id);
3429
- const target = document.getElementById(id);
4417
+ const target = rootScopedElementById(owner, id);
3430
4418
  if (!target)
3431
4419
  return '';
3432
4420
  const chained = target.getAttribute('aria-labelledby');
3433
4421
  if (chained)
3434
- return referencedText(chained, seen) ?? '';
4422
+ return referencedText(target, chained, seen) ?? '';
3435
4423
  return target.textContent?.trim() ?? '';
3436
4424
  })
3437
4425
  .filter(Boolean)
@@ -3443,7 +4431,7 @@ async function attemptNativeBatchFill(page, fields) {
3443
4431
  const aria = el.getAttribute('aria-label')?.trim();
3444
4432
  if (aria)
3445
4433
  return aria;
3446
- const labelledBy = referencedText(el.getAttribute('aria-labelledby'));
4434
+ const labelledBy = referencedText(el, el.getAttribute('aria-labelledby'));
3447
4435
  if (labelledBy)
3448
4436
  return labelledBy;
3449
4437
  if ((el instanceof HTMLInputElement || el instanceof HTMLSelectElement || el instanceof HTMLTextAreaElement) &&
@@ -3452,7 +4440,10 @@ async function attemptNativeBatchFill(page, fields) {
3452
4440
  return el.labels[0]?.textContent?.trim() || undefined;
3453
4441
  }
3454
4442
  if (el instanceof HTMLElement && el.id) {
3455
- 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;
3456
4447
  const text = label?.textContent?.trim();
3457
4448
  if (text)
3458
4449
  return text;
@@ -3505,6 +4496,8 @@ async function attemptNativeBatchFill(page, fields) {
3505
4496
  target.value = next;
3506
4497
  }
3507
4498
  function setInputCheckedReactAware(target, next) {
4499
+ if (mutationBlockReason(target))
4500
+ return false;
3508
4501
  const hadReactTracker = clearReactTracker(target, next);
3509
4502
  if (!hadReactTracker && target.checked === next)
3510
4503
  return true;
@@ -3664,6 +4657,8 @@ async function attemptNativeBatchFill(page, fields) {
3664
4657
  }
3665
4658
  if (!matches(explicitLabelText(control), fieldLabel, exact))
3666
4659
  continue;
4660
+ if (mutationBlockReason(control))
4661
+ return false;
3667
4662
  if (control instanceof HTMLInputElement || control instanceof HTMLTextAreaElement) {
3668
4663
  const inputType = control instanceof HTMLInputElement ? control.type : 'textarea';
3669
4664
  const v = canonicalizeHtmlInputValueInPage(inputType, value);
@@ -3671,7 +4666,7 @@ async function attemptNativeBatchFill(page, fields) {
3671
4666
  clearReactTracker(control);
3672
4667
  setInputLikeValue(control, v);
3673
4668
  dispatch(control);
3674
- return matches(currentValue(control), v, false);
4669
+ return normalize(currentValue(control)) === normalize(v);
3675
4670
  }
3676
4671
  if (control instanceof HTMLElement && control.isContentEditable) {
3677
4672
  if (!isPlainContentEditableBatch(control))
@@ -3680,7 +4675,7 @@ async function attemptNativeBatchFill(page, fields) {
3680
4675
  clearReactTracker(control);
3681
4676
  control.textContent = value;
3682
4677
  dispatch(control);
3683
- return matches(currentValue(control), value, false);
4678
+ return normalize(currentValue(control)) === normalize(value);
3684
4679
  }
3685
4680
  }
3686
4681
  return false;
@@ -3692,8 +4687,12 @@ async function attemptNativeBatchFill(page, fields) {
3692
4687
  continue;
3693
4688
  if (!matches(explicitLabelText(control), fieldLabel, exact))
3694
4689
  continue;
4690
+ if (mutationBlockReason(control))
4691
+ return false;
3695
4692
  const expected = normalize(value);
3696
4693
  const option = Array.from(control.options).find((candidate) => {
4694
+ if (candidate.disabled || (candidate.parentElement instanceof HTMLOptGroupElement && candidate.parentElement.disabled))
4695
+ return false;
3697
4696
  const label = normalize(candidate.textContent);
3698
4697
  const rawValue = normalize(candidate.value);
3699
4698
  return exact ? label === expected || rawValue === expected : label.includes(expected) || rawValue.includes(expected);
@@ -3702,7 +4701,7 @@ async function attemptNativeBatchFill(page, fields) {
3702
4701
  return false;
3703
4702
  control.value = option.value;
3704
4703
  dispatch(control);
3705
- return matches(currentValue(control), value, false) || normalize(control.value) === expected;
4704
+ return control.selectedIndex === option.index;
3706
4705
  }
3707
4706
  return false;
3708
4707
  }
@@ -3762,13 +4761,17 @@ async function attemptNativeBatchFill(page, fields) {
3762
4761
  const control = option.control;
3763
4762
  if (control instanceof HTMLInputElement)
3764
4763
  return setInputCheckedReactAware(control, true);
4764
+ if (mutationBlockReason(option))
4765
+ return false;
3765
4766
  option.click();
3766
- return true;
4767
+ return false;
3767
4768
  }
3768
4769
  // role=radio / role=checkbox: click and verify aria-checked
3769
4770
  // / aria-selected. The native batch fill used to silently return
3770
4771
  // true here, which masked Pinecone's commit-on-rerender bug.
3771
4772
  if (option.getAttribute('role') === 'radio' || option.getAttribute('role') === 'checkbox') {
4773
+ if (mutationBlockReason(option))
4774
+ return false;
3772
4775
  option.click();
3773
4776
  return (option.getAttribute('aria-checked') === 'true' ||
3774
4777
  option.getAttribute('aria-selected') === 'true');
@@ -3785,6 +4788,8 @@ async function attemptNativeBatchFill(page, fields) {
3785
4788
  // JobForge round-2 marathon — Pinecone Sr SWE Database Team
3786
4789
  // #320 and LangChain SE Manager #325.
3787
4790
  const beforeSig = selectionSignature(options);
4791
+ if (mutationBlockReason(option))
4792
+ return false;
3788
4793
  option.click();
3789
4794
  const afterSig = selectionSignature(options);
3790
4795
  if (beforeSig === afterSig)
@@ -3803,6 +4808,8 @@ async function attemptNativeBatchFill(page, fields) {
3803
4808
  continue;
3804
4809
  if (!matches(explicitLabelText(input), label, exact))
3805
4810
  continue;
4811
+ if (mutationBlockReason(input))
4812
+ return false;
3806
4813
  if (!setInputCheckedReactAware(input, checked))
3807
4814
  return false;
3808
4815
  return input.checked === checked;
@@ -3818,6 +4825,8 @@ async function attemptNativeBatchFill(page, fields) {
3818
4825
  continue;
3819
4826
  if (controlType && control.type !== controlType)
3820
4827
  continue;
4828
+ if (mutationBlockReason(control))
4829
+ return false;
3821
4830
  if (!setInputCheckedReactAware(control, checked))
3822
4831
  return false;
3823
4832
  return control.checked === checked;
@@ -4105,6 +5114,9 @@ export async function fillOtp(page, value, opts) {
4105
5114
  if (liveCount !== group.cellCount) {
4106
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.`);
4107
5116
  }
5117
+ for (let index = 0; index < liveCount; index++) {
5118
+ await assertLocatorMutable(group.boxes.nth(index), 'text', `fillOtp: OTP cell ${index + 1}`);
5119
+ }
4108
5120
  // Click the leftmost box via its center point. We use a low-level
4109
5121
  // bounding-box click because a semantic "click the first input" path
4110
5122
  // resolves to whichever input paints topmost in the stacking order,
@@ -4310,6 +5322,7 @@ export async function setFieldText(page, fieldLabel, value, opts) {
4310
5322
  if (!locator) {
4311
5323
  throw new Error(`setFieldText: no visible editable field matching "${fieldLabel}"`);
4312
5324
  }
5325
+ await assertLocatorMutable(locator, 'text', 'setFieldText');
4313
5326
  await locator.scrollIntoViewIfNeeded();
4314
5327
  const inputType = await locator.evaluate(el => {
4315
5328
  if (el instanceof HTMLInputElement)
@@ -4321,6 +5334,7 @@ export async function setFieldText(page, fieldLabel, value, opts) {
4321
5334
  const normalized = canonicalizeHtmlInputValue(inputType, value);
4322
5335
  const applied = await setLocatorTextValue(locator, normalized, { imeFriendly: opts?.imeFriendly });
4323
5336
  if (!applied) {
5337
+ await assertLocatorMutable(locator, 'text', 'setFieldText');
4324
5338
  try {
4325
5339
  await locator.fill(normalized);
4326
5340
  }
@@ -4331,6 +5345,7 @@ export async function setFieldText(page, fieldLabel, value, opts) {
4331
5345
  });
4332
5346
  }
4333
5347
  }
5348
+ await delay(40);
4334
5349
  const current = await locatorCurrentValue(locator);
4335
5350
  if (fieldValueMatches(current, normalized))
4336
5351
  return;
@@ -4340,10 +5355,56 @@ export async function setFieldText(page, fieldLabel, value, opts) {
4340
5355
  throw new Error(`setFieldText: set "${fieldLabel}" but could not confirm value ${JSON.stringify(value)}`);
4341
5356
  }
4342
5357
  async function setNativeSelectByLabel(locator, value, exact, optionIndex) {
5358
+ if (await locatorMutationBlockReason(locator, 'select'))
5359
+ return false;
4343
5360
  try {
4344
- return await locator.evaluate((el, payload) => {
5361
+ return await locator.evaluate(async (el, payload) => {
4345
5362
  if (!(el instanceof HTMLSelectElement))
4346
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;
4347
5408
  const normalize = (input) => input?.replace(/\s+/g, ' ').trim().toLowerCase() ?? '';
4348
5409
  const expected = normalize(payload.value);
4349
5410
  const enabled = (candidate) => !candidate.disabled && !(candidate.parentElement instanceof HTMLOptGroupElement && candidate.parentElement.disabled);
@@ -4372,7 +5433,8 @@ async function setNativeSelectByLabel(locator, value, exact, optionIndex) {
4372
5433
  el.selectedIndex = option.index;
4373
5434
  el.dispatchEvent(new Event('input', { bubbles: true }));
4374
5435
  el.dispatchEvent(new Event('change', { bubbles: true }));
4375
- return true;
5436
+ await new Promise(resolveTimer => setTimeout(resolveTimer, 40));
5437
+ return el.selectedIndex === option.index && el.selectedOptions[0] === option;
4376
5438
  }, { value, exact, optionIndex });
4377
5439
  }
4378
5440
  catch {
@@ -4394,6 +5456,46 @@ async function chooseValueFromLabeledGroup(page, fieldLabel, value, exact) {
4394
5456
  const style = getComputedStyle(el);
4395
5457
  return style.display !== 'none' && style.visibility !== 'hidden';
4396
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
+ }
4397
5499
  function matches(candidate) {
4398
5500
  const normalizedCandidate = normalize(candidate);
4399
5501
  const normalizedExpected = normalize(payload.fieldLabel);
@@ -4408,7 +5510,15 @@ async function chooseValueFromLabeledGroup(page, fieldLabel, value, exact) {
4408
5510
  return false;
4409
5511
  return payload.exact ? normalizedCandidate === normalizedExpected : normalizedCandidate.includes(normalizedExpected);
4410
5512
  }
4411
- 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) {
4412
5522
  if (!ids)
4413
5523
  return undefined;
4414
5524
  const seen = visited ?? new Set();
@@ -4418,12 +5528,12 @@ async function chooseValueFromLabeledGroup(page, fieldLabel, value, exact) {
4418
5528
  if (seen.has(id))
4419
5529
  return '';
4420
5530
  seen.add(id);
4421
- const target = document.getElementById(id);
5531
+ const target = rootScopedElementById(owner, id);
4422
5532
  if (!target)
4423
5533
  return '';
4424
5534
  const chained = target.getAttribute('aria-labelledby');
4425
5535
  if (chained)
4426
- return referencedText(chained, seen) ?? '';
5536
+ return referencedText(target, chained, seen) ?? '';
4427
5537
  return target.textContent?.trim() ?? '';
4428
5538
  })
4429
5539
  .filter(Boolean)
@@ -4435,7 +5545,7 @@ async function chooseValueFromLabeledGroup(page, fieldLabel, value, exact) {
4435
5545
  const aria = el.getAttribute('aria-label')?.trim();
4436
5546
  if (aria)
4437
5547
  return aria;
4438
- const labelledBy = referencedText(el.getAttribute('aria-labelledby'));
5548
+ const labelledBy = referencedText(el, el.getAttribute('aria-labelledby'));
4439
5549
  if (labelledBy)
4440
5550
  return labelledBy;
4441
5551
  if (el instanceof HTMLInputElement && el.labels && el.labels.length > 0) {
@@ -4469,7 +5579,7 @@ async function chooseValueFromLabeledGroup(page, fieldLabel, value, exact) {
4469
5579
  const aria = el.getAttribute('aria-label')?.trim();
4470
5580
  if (aria)
4471
5581
  return aria;
4472
- const labelledBy = referencedText(el.getAttribute('aria-labelledby'));
5582
+ const labelledBy = referencedText(el, el.getAttribute('aria-labelledby'));
4473
5583
  if (labelledBy)
4474
5584
  return labelledBy;
4475
5585
  return el.textContent?.trim() || undefined;
@@ -4547,6 +5657,8 @@ async function chooseValueFromLabeledGroup(page, fieldLabel, value, exact) {
4547
5657
  return false;
4548
5658
  }
4549
5659
  function setInputCheckedReactAware(target, next) {
5660
+ if (blocked(target))
5661
+ return false;
4550
5662
  const hadReactTracker = clearReactTracker(target, next);
4551
5663
  if (!hadReactTracker && target.checked === next)
4552
5664
  return true;
@@ -4573,6 +5685,8 @@ async function chooseValueFromLabeledGroup(page, fieldLabel, value, exact) {
4573
5685
  const control = option.control;
4574
5686
  if (control instanceof HTMLInputElement)
4575
5687
  return setInputCheckedReactAware(control, true);
5688
+ if (blocked(option))
5689
+ return false;
4576
5690
  option.click();
4577
5691
  // No backing input — verify via group signature change so we don't
4578
5692
  // silently no-op on label wrappers whose target is unparented.
@@ -4580,6 +5694,8 @@ async function chooseValueFromLabeledGroup(page, fieldLabel, value, exact) {
4580
5694
  return labelBeforeSig !== labelAfterSig;
4581
5695
  }
4582
5696
  if (option.getAttribute('role') === 'radio' || option.getAttribute('role') === 'checkbox') {
5697
+ if (blocked(option))
5698
+ return false;
4583
5699
  option.click();
4584
5700
  return option.getAttribute('aria-checked') === 'true' || option.getAttribute('aria-selected') === 'true';
4585
5701
  }
@@ -4589,6 +5705,8 @@ async function chooseValueFromLabeledGroup(page, fieldLabel, value, exact) {
4589
5705
  // was a no-op — return false so the caller can try the next strategy
4590
5706
  // (pickListboxOption fallback, etc) instead of silently succeeding.
4591
5707
  const beforeSig = selectionSignature(options);
5708
+ if (blocked(option))
5709
+ return false;
4592
5710
  option.click();
4593
5711
  const afterSig = selectionSignature(options);
4594
5712
  if (beforeSig === afterSig) {
@@ -4710,6 +5828,7 @@ export async function setFieldChoice(page, fieldLabel, value, opts) {
4710
5828
  fieldKey: opts?.fieldKey,
4711
5829
  });
4712
5830
  if (locator) {
5831
+ await assertLocatorMutable(locator, 'select', 'setFieldChoice');
4713
5832
  await locator.scrollIntoViewIfNeeded();
4714
5833
  const isNativeSelect = await locator.evaluate(el => el instanceof HTMLSelectElement);
4715
5834
  if (await setNativeSelectByLabel(locator, value, exact, opts?.optionIndex)) {
@@ -4848,6 +5967,8 @@ export async function fillFields(page, fields, cache = createFillLookupCache())
4848
5967
  fieldId: field.fieldId,
4849
5968
  fieldKey: field.fieldKey,
4850
5969
  fieldLabel: field.fieldLabel,
5970
+ contextText: field.contextText,
5971
+ sectionText: field.sectionText,
4851
5972
  exact: field.exact,
4852
5973
  cache,
4853
5974
  });
@@ -4885,71 +6006,46 @@ export async function selectNativeOption(page, x, y, opt) {
4885
6006
  if (opt.value === undefined && opt.label === undefined && opt.index === undefined) {
4886
6007
  throw new Error('selectOption: provide at least one of value, label, or index');
4887
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');
4888
6013
  await page.mouse.click(x, y);
4889
6014
  await delay(40);
4890
6015
  for (const frame of page.frames()) {
4891
- const applied = await frame.evaluate((payload) => {
6016
+ const applied = await frame.evaluate(async (payload) => {
4892
6017
  const normalize = (input) => input?.replace(/\s+/g, ' ').trim().toLowerCase() ?? '';
4893
- const optionMatchesExpected = (o, expected) => {
6018
+ const enabled = (o) => !o.disabled && !(o.parentElement instanceof HTMLOptGroupElement && o.parentElement.disabled);
6019
+ const optionMatchesPayload = (o) => {
4894
6020
  if (o.disabled || (o.parentElement instanceof HTMLOptGroupElement && o.parentElement.disabled))
4895
6021
  return false;
4896
- const label = normalize(o.textContent);
4897
- const rawValue = normalize(o.value);
4898
- 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;
4899
6029
  };
4900
6030
  const a = document.activeElement;
4901
6031
  if (!a || a.tagName !== 'SELECT')
4902
6032
  return false;
4903
6033
  const sel = a;
4904
- if (typeof payload.index === 'number' && Number.isFinite(payload.index)) {
4905
- const i = Math.trunc(payload.index);
4906
- if (i < 0 || i >= sel.options.length)
4907
- return false;
4908
- const indexed = sel.options[i];
4909
- if (indexed.disabled || (indexed.parentElement instanceof HTMLOptGroupElement && indexed.parentElement.disabled))
4910
- return false;
4911
- if (payload.value !== null && normalize(indexed.value) !== normalize(payload.value))
4912
- return false;
4913
- if (payload.label !== null && !normalize(indexed.textContent).includes(normalize(payload.label)))
4914
- return false;
4915
- sel.selectedIndex = i;
4916
- }
4917
- else if (payload.value !== null && payload.value !== undefined) {
4918
- const raw = String(payload.value);
4919
- sel.value = raw;
4920
- const expected = normalize(raw);
4921
- if (!expected)
4922
- return false;
4923
- const selected = sel.selectedOptions[0];
4924
- const ok = selected &&
4925
- !selected.disabled &&
4926
- !(selected.parentElement instanceof HTMLOptGroupElement && selected.parentElement.disabled) &&
4927
- (normalize(selected.value) === expected ||
4928
- normalize(selected.textContent) === expected ||
4929
- normalize(selected.value).includes(expected) ||
4930
- normalize(selected.textContent).includes(expected));
4931
- if (!ok) {
4932
- const found = Array.from(sel.options).find(o => optionMatchesExpected(o, expected));
4933
- if (!found)
4934
- return false;
4935
- sel.selectedIndex = found.index;
4936
- }
4937
- }
4938
- else if (payload.label !== null && payload.label !== undefined) {
4939
- const expected = normalize(payload.label);
4940
- if (!expected)
4941
- return false;
4942
- const optEl = Array.from(sel.options).find(o => optionMatchesExpected(o, expected));
4943
- if (!optEl)
4944
- return false;
4945
- sel.selectedIndex = optEl.index;
4946
- }
4947
- else {
6034
+ if (sel.matches(':disabled') || sel.getAttribute('aria-disabled') === 'true' || sel.getAttribute('aria-readonly') === 'true')
4948
6035
  return false;
4949
- }
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;
4950
6044
  sel.dispatchEvent(new Event('input', { bubbles: true }));
4951
6045
  sel.dispatchEvent(new Event('change', { bubbles: true }));
4952
- 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);
4953
6049
  }, {
4954
6050
  value: opt.value ?? null,
4955
6051
  label: opt.label ?? null,
@@ -4966,6 +6062,23 @@ export async function selectNativeOption(page, x, y, opt) {
4966
6062
  export async function pickListboxOption(page, label, opts) {
4967
6063
  let anchor;
4968
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
+ }
4969
6082
  let attemptedSelection = false;
4970
6083
  let selectedOptionText;
4971
6084
  let openedHandle;
@@ -4991,16 +6104,16 @@ export async function pickListboxOption(page, label, opts) {
4991
6104
  await releasePopupScope();
4992
6105
  popupScope = await resolveOwnedPopupHandle(openedHandle);
4993
6106
  };
4994
- if (opts?.fieldLabel) {
6107
+ if (opts?.fieldLabel || opts?.fieldKey) {
4995
6108
  let opened;
4996
6109
  try {
4997
- opened = await openDropdownControl(page, opts.fieldLabel, exact, opts.cache, opts.fieldId, opts.fieldKey);
6110
+ opened = await openDropdownControl(page, opts.fieldLabel ?? '', exact, opts.cache, opts.fieldId, opts.fieldKey);
4998
6111
  }
4999
6112
  catch {
5000
6113
  throw new Error(listboxErrorMessage({
5001
6114
  reason: 'field_not_found',
5002
6115
  requestedLabel: label,
5003
- fieldLabel: opts.fieldLabel,
6116
+ fieldLabel: opts.fieldLabel ?? opts.fieldKey,
5004
6117
  query: opts?.query,
5005
6118
  exact,
5006
6119
  }));
@@ -5020,6 +6133,10 @@ export async function pickListboxOption(page, label, opts) {
5020
6133
  await refreshPopupScope();
5021
6134
  }
5022
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');
5023
6140
  await page.mouse.click(opts.openX, opts.openY);
5024
6141
  anchor = { x: opts.openX, y: opts.openY };
5025
6142
  await delay(120);
@@ -5036,19 +6153,20 @@ export async function pickListboxOption(page, label, opts) {
5036
6153
  // popup. If the pointer click did not commit, the keyboard fallback
5037
6154
  // below deliberately reopens this exact control and selects by Enter.
5038
6155
  await dispatchListboxCommitEvents(openedHandle);
5039
- if (!opts?.fieldLabel ||
5040
- 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, {
5041
6158
  editable: openedEditable,
5042
- })) {
5043
- return true;
6159
+ }))
6160
+ return true;
6161
+ return false;
5044
6162
  }
5045
- return false;
6163
+ return await confirmListboxSelectionWithoutLabel(page, label, exact, anchor, openedHandle, selectedOptionText);
5046
6164
  };
5047
6165
  const dismissAfterSelection = async () => {
5048
6166
  if (!opts?.fieldLabel) {
5049
6167
  await page.keyboard.press('Tab');
5050
6168
  await delay(50);
5051
- return true;
6169
+ return await confirmListboxSelectionWithoutLabel(page, label, exact, anchor, openedHandle, selectedOptionText);
5052
6170
  }
5053
6171
  if (await dismissAndReVerifySelection(page, label, exact, openedHandle, selectedOptionText)) {
5054
6172
  return true;
@@ -5145,10 +6263,12 @@ export async function pickListboxOption(page, label, opts) {
5145
6263
  selectedOptionText = keyboardSelection;
5146
6264
  attemptedSelection = true;
5147
6265
  await dispatchListboxCommitEvents(openedHandle);
5148
- if (!opts?.fieldLabel ||
5149
- 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, {
5150
6268
  editable: openedEditable,
5151
- })) {
6269
+ })
6270
+ : await confirmListboxSelectionWithoutLabel(page, label, exact, anchor, openedHandle, selectedOptionText);
6271
+ if (keyboardConfirmed) {
5152
6272
  if (await dismissAfterSelection() && await postCommitVerify())
5153
6273
  return;
5154
6274
  }
@@ -5183,7 +6303,15 @@ export async function pickListboxOption(page, label, opts) {
5183
6303
  }
5184
6304
  async function applyToggleState(locator, desiredChecked) {
5185
6305
  return await locator.evaluate((target, checked) => {
5186
- function referencedText(ids, visited) {
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) {
5187
6315
  if (!ids)
5188
6316
  return undefined;
5189
6317
  const seen = visited ?? new Set();
@@ -5191,10 +6319,10 @@ async function applyToggleState(locator, desiredChecked) {
5191
6319
  if (seen.has(id))
5192
6320
  return '';
5193
6321
  seen.add(id);
5194
- const node = document.getElementById(id);
6322
+ const node = rootScopedElementById(owner, id);
5195
6323
  if (!node)
5196
6324
  return '';
5197
- return referencedText(node.getAttribute('aria-labelledby'), seen) ?? node.textContent?.trim() ?? '';
6325
+ return referencedText(node, node.getAttribute('aria-labelledby'), seen) ?? node.textContent?.trim() ?? '';
5198
6326
  }).filter(Boolean).join(' ').trim();
5199
6327
  return text || undefined;
5200
6328
  }
@@ -5207,7 +6335,7 @@ async function applyToggleState(locator, desiredChecked) {
5207
6335
  const aria = el.getAttribute('aria-label')?.trim();
5208
6336
  if (aria)
5209
6337
  return aria;
5210
- const labelledBy = referencedText(el.getAttribute('aria-labelledby'));
6338
+ const labelledBy = referencedText(el, el.getAttribute('aria-labelledby'));
5211
6339
  if (labelledBy)
5212
6340
  return labelledBy;
5213
6341
  if (el instanceof HTMLInputElement) {
@@ -5272,7 +6400,15 @@ async function collectToggleMatches(page, label, exact, controlType, contextText
5272
6400
  const style = getComputedStyle(el);
5273
6401
  return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden';
5274
6402
  }
5275
- function referencedText(ids, visited) {
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;
6410
+ }
6411
+ function referencedText(owner, ids, visited) {
5276
6412
  if (!ids)
5277
6413
  return undefined;
5278
6414
  const seen = visited ?? new Set();
@@ -5280,10 +6416,10 @@ async function collectToggleMatches(page, label, exact, controlType, contextText
5280
6416
  if (seen.has(id))
5281
6417
  return '';
5282
6418
  seen.add(id);
5283
- const node = document.getElementById(id);
6419
+ const node = rootScopedElementById(owner, id);
5284
6420
  if (!node)
5285
6421
  return '';
5286
- return referencedText(node.getAttribute('aria-labelledby'), seen) ?? node.textContent?.trim() ?? '';
6422
+ return referencedText(node, node.getAttribute('aria-labelledby'), seen) ?? node.textContent?.trim() ?? '';
5287
6423
  }).filter(Boolean).join(' ').trim();
5288
6424
  return text || undefined;
5289
6425
  }
@@ -5306,7 +6442,7 @@ async function collectToggleMatches(page, label, exact, controlType, contextText
5306
6442
  const aria = el.getAttribute('aria-label')?.trim();
5307
6443
  if (aria)
5308
6444
  return aria;
5309
- const labelledBy = referencedText(el.getAttribute('aria-labelledby'));
6445
+ const labelledBy = referencedText(el, el.getAttribute('aria-labelledby'));
5310
6446
  if (labelledBy)
5311
6447
  return labelledBy;
5312
6448
  if (el instanceof HTMLInputElement) {
@@ -5336,7 +6472,7 @@ async function collectToggleMatches(page, label, exact, controlType, contextText
5336
6472
  if (!(section instanceof HTMLElement))
5337
6473
  return '';
5338
6474
  const explicit = section.getAttribute('aria-label')?.trim() ||
5339
- referencedText(section.getAttribute('aria-labelledby')) ||
6475
+ referencedText(section, section.getAttribute('aria-labelledby')) ||
5340
6476
  section.querySelector('legend, h1, h2, h3, h4, h5, h6')?.textContent?.trim();
5341
6477
  return (explicit || section.innerText || section.textContent || '').replace(/\s+/g, ' ').trim();
5342
6478
  }
@@ -5419,9 +6555,14 @@ export async function setCheckedControl(page, label, opts) {
5419
6555
  }
5420
6556
  target = matches[0].locator;
5421
6557
  }
6558
+ await assertLocatorMutable(target, 'toggle', 'setChecked');
5422
6559
  const result = await applyToggleState(target, desiredChecked);
5423
- if (result.success)
5424
- return;
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
+ }
5425
6566
  if (result.reason === 'radio-uncheck') {
5426
6567
  throw new Error(`setChecked: radio "${result.name}" cannot be unchecked directly; choose a different option instead`);
5427
6568
  }