@geometra/proxy 1.64.0 → 1.65.1

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,364 @@ 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, setFiles = async (selectedPaths) => handle.setInputFiles(selectedPaths)) {
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})`);
2821
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 setFiles(paths);
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
+ try {
3430
+ await commitFilesToExactInput(input, paths, 'chooser input', async (selectedPaths) => {
3431
+ await chooser.setFiles(selectedPaths);
3432
+ });
3433
+ }
3434
+ finally {
3435
+ // Playwright transfers ownership of this ElementHandle to the file-chooser
3436
+ // listener. Release it on success and every preflight/error path.
3437
+ await input.dispose().catch(() => { });
3438
+ }
2842
3439
  }
2843
3440
  /**
2844
3441
  * 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.
3442
+ * does not silently drop our synthetic files. A null result means MIME cannot
3443
+ * be inferred reliably from the path and must not be used to reject a MIME
3444
+ * accept contract as though application/octet-stream were authoritative.
2847
3445
  */
2848
3446
  function mimeTypeForPath(path) {
2849
3447
  const ext = path.toLowerCase().split('.').pop() ?? '';
@@ -2851,7 +3449,20 @@ function mimeTypeForPath(path) {
2851
3449
  case 'pdf': return 'application/pdf';
2852
3450
  case 'doc': return 'application/msword';
2853
3451
  case 'docx': return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
3452
+ case 'xls': return 'application/vnd.ms-excel';
3453
+ case 'xlsx': return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
3454
+ case 'ppt': return 'application/vnd.ms-powerpoint';
3455
+ case 'pptx': return 'application/vnd.openxmlformats-officedocument.presentationml.presentation';
3456
+ case 'json': return 'application/json';
3457
+ case 'xml': return 'application/xml';
3458
+ case 'zip': return 'application/zip';
3459
+ case 'gz': return 'application/gzip';
3460
+ case 'tar': return 'application/x-tar';
3461
+ case '7z': return 'application/x-7z-compressed';
2854
3462
  case 'txt': return 'text/plain';
3463
+ case 'csv': return 'text/csv';
3464
+ case 'htm':
3465
+ case 'html': return 'text/html';
2855
3466
  case 'rtf': return 'application/rtf';
2856
3467
  case 'png': return 'image/png';
2857
3468
  case 'jpg':
@@ -2859,9 +3470,76 @@ function mimeTypeForPath(path) {
2859
3470
  case 'gif': return 'image/gif';
2860
3471
  case 'webp': return 'image/webp';
2861
3472
  case 'svg': return 'image/svg+xml';
2862
- default: return 'application/octet-stream';
3473
+ case 'avif': return 'image/avif';
3474
+ case 'bmp': return 'image/bmp';
3475
+ case 'tif':
3476
+ case 'tiff': return 'image/tiff';
3477
+ case 'mp3': return 'audio/mpeg';
3478
+ case 'm4a': return 'audio/mp4';
3479
+ case 'wav': return 'audio/wav';
3480
+ case 'ogg': return 'audio/ogg';
3481
+ case 'mp4': return 'video/mp4';
3482
+ case 'mov': return 'video/quicktime';
3483
+ case 'webm': return 'video/webm';
3484
+ default: return null;
2863
3485
  }
2864
3486
  }
3487
+ async function dropFileInputAtPoint(page, x, y) {
3488
+ const handle = await page.mainFrame().evaluateHandle(({ x: clientX, y: clientY }) => {
3489
+ const deepest = document.elementFromPoint(clientX, clientY);
3490
+ if (!deepest)
3491
+ return null;
3492
+ function composedParent(element) {
3493
+ if (element.parentElement)
3494
+ return element.parentElement;
3495
+ const root = element.getRootNode();
3496
+ return root instanceof ShadowRoot ? root.host : null;
3497
+ }
3498
+ function isExplicitLocalBoundary(element) {
3499
+ const tag = element.tagName.toLowerCase();
3500
+ const className = typeof element.className === 'string' ? element.className : '';
3501
+ const testId = element.getAttribute('data-testid') ?? '';
3502
+ const buttonText = `${element.getAttribute('aria-label') ?? ''} ${element.textContent ?? ''}`;
3503
+ return tag === 'label' ||
3504
+ element.hasAttribute('data-dropzone') ||
3505
+ element.getAttributeNames().some(attribute => attribute.startsWith('data-rfd')) ||
3506
+ /drop[-_]?zone|file[-_]?upload|attach|upload/i.test(className) ||
3507
+ /drop|file|upload|attach/i.test(testId) ||
3508
+ (element.getAttribute('role') === 'button' && /drop|file|upload|attach/i.test(buttonText));
3509
+ }
3510
+ function isBroadContainer(element) {
3511
+ return element.matches('form, [role="form"], fieldset, section');
3512
+ }
3513
+ let current = deepest;
3514
+ let associationClosed = false;
3515
+ for (let depth = 0; current && depth < 16; depth++, current = composedParent(current)) {
3516
+ if (current instanceof HTMLInputElement && current.type === 'file')
3517
+ return current;
3518
+ // Do not infer a relationship from an unrelated page-global input.
3519
+ if (current === document.body || current === document.documentElement)
3520
+ break;
3521
+ const inputs = current.querySelectorAll('input[type="file"]');
3522
+ if (inputs.length > 1) {
3523
+ throw new Error(`file: coordinate-only drop target is ambiguous; nearest candidate container has ${inputs.length} file inputs`);
3524
+ }
3525
+ const input = inputs[0];
3526
+ const explicitBoundary = isExplicitLocalBoundary(current);
3527
+ const broadContainer = isBroadContainer(current);
3528
+ if (input instanceof HTMLInputElement &&
3529
+ !associationClosed &&
3530
+ explicitBoundary)
3531
+ return input;
3532
+ if (explicitBoundary || broadContainer)
3533
+ associationClosed = true;
3534
+ }
3535
+ return null;
3536
+ }, { x, y });
3537
+ const element = handle.asElement();
3538
+ if (element)
3539
+ return element;
3540
+ await handle.dispose();
3541
+ return null;
3542
+ }
2865
3543
  /**
2866
3544
  * Synthetic drop at (x,y) using file bytes from the proxy host.
2867
3545
  *
@@ -2879,91 +3557,260 @@ function mimeTypeForPath(path) {
2879
3557
  * button layer and the wrapper that actually has the listeners.
2880
3558
  */
2881
3559
  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.
3560
+ const dropFileInput = await dropFileInputAtPoint(page, dropX, dropY);
3561
+ try {
3562
+ // A real scoped input establishes the drop contract. Validate before
3563
+ // mouse movement, drag events, input assignment, or application callbacks.
3564
+ // Pure dropzones without an input retain their framework-defined behavior.
3565
+ const observedAccept = dropFileInput ? await handleFileAccept(dropFileInput) : null;
3566
+ if (dropFileInput) {
3567
+ const inputBlockReason = await dropFileInput.evaluate(mutationBlockReasonInPage, 'file');
3568
+ if (inputBlockReason) {
3569
+ throw new Error(`file: associated drop input is not mutable (${inputBlockReason})`);
3570
+ }
3571
+ }
3572
+ assertPathsMatchFileAccept(paths, observedAccept);
3573
+ const fs = await import('node:fs/promises');
3574
+ const buffers = await Promise.all(paths.map(p => fs.readFile(p)));
3575
+ const names = paths.map(p => p.split(/[/\\\\]/).pop() ?? 'file');
3576
+ const mimes = paths.map(path => mimeTypeForPath(path) ?? 'application/octet-stream');
3577
+ await page.mouse.move(dropX, dropY);
3578
+ const result = await page.mainFrame().evaluate(async ({ bufs, ns, ms, x, y, expectedAccept, expectedFileInput }) => {
3579
+ function blockReason(target) {
3580
+ function parent(node) {
3581
+ if (node.parentElement)
3582
+ return node.parentElement;
3583
+ const root = node.getRootNode();
3584
+ return root instanceof ShadowRoot ? root.host : null;
3585
+ }
3586
+ function within(node, ancestor) {
3587
+ let current = node;
3588
+ while (current) {
3589
+ if (current === ancestor)
3590
+ return true;
3591
+ current = parent(current);
3592
+ }
3593
+ return false;
3594
+ }
2912
3595
  try {
2913
- Object.defineProperty(ev, 'dataTransfer', { value: dt, configurable: true });
3596
+ if (target.matches(':disabled'))
3597
+ return 'disabled';
2914
3598
  }
2915
3599
  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 });
3600
+ if ((target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) && target.readOnly)
3601
+ return 'readonly';
3602
+ let current = target;
3603
+ while (current) {
3604
+ if (current.hasAttribute('inert'))
3605
+ return 'inert';
3606
+ if (current.getAttribute('aria-disabled')?.trim().toLowerCase() === 'true')
3607
+ return 'aria-disabled';
3608
+ if (current.getAttribute('aria-readonly')?.trim().toLowerCase() === 'true')
3609
+ return 'aria-readonly';
3610
+ if (current instanceof HTMLFieldSetElement && current.disabled) {
3611
+ const legend = Array.from(current.children).find(child => child instanceof HTMLLegendElement);
3612
+ if (!(legend instanceof HTMLLegendElement) || !within(target, legend))
3613
+ return 'disabled fieldset';
3614
+ }
3615
+ current = parent(current);
3616
+ }
3617
+ return null;
2953
3618
  }
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, '');
3619
+ const makeDataTransfer = () => {
3620
+ const dt = new DataTransfer();
3621
+ for (let i = 0; i < bufs.length; i++) {
3622
+ const u8 = new Uint8Array(bufs[i]);
3623
+ const blob = new Blob([u8], { type: ms[i] });
3624
+ dt.items.add(new File([blob], ns[i], { type: ms[i] }));
3625
+ }
3626
+ return dt;
3627
+ };
3628
+ const dispatchSequence = (target) => {
3629
+ let handled = false;
3630
+ // Each event needs its own DataTransfer because some frameworks
3631
+ // consume the items list during dragenter/dragover inspection.
3632
+ for (const type of ['dragenter', 'dragover', 'drop']) {
3633
+ const dt = makeDataTransfer();
3634
+ const ev = new DragEvent(type, {
3635
+ bubbles: true,
3636
+ cancelable: true,
3637
+ composed: true,
3638
+ clientX: x,
3639
+ clientY: y,
3640
+ dataTransfer: dt,
3641
+ });
3642
+ try {
3643
+ Object.defineProperty(ev, 'dataTransfer', { value: dt, configurable: true });
3644
+ }
3645
+ catch { /* ignore */ }
3646
+ const dispatched = target.dispatchEvent(ev);
3647
+ if (!dispatched || ev.defaultPrevented)
3648
+ handled = true;
3649
+ }
3650
+ return handled;
3651
+ };
3652
+ const deepest = document.elementFromPoint(x, y);
3653
+ if (!deepest) {
3654
+ return { accepted: false, blockReason: null, acceptContractChanged: false, ambiguousTarget: false };
3655
+ }
3656
+ const blocked = blockReason(deepest);
3657
+ if (blocked) {
3658
+ return { accepted: false, blockReason: blocked, acceptContractChanged: false, ambiguousTarget: false };
3659
+ }
3660
+ function resolveFileInput(start) {
3661
+ function composedParent(element) {
3662
+ if (element.parentElement)
3663
+ return element.parentElement;
3664
+ const root = element.getRootNode();
3665
+ return root instanceof ShadowRoot ? root.host : null;
3666
+ }
3667
+ function isExplicitLocalBoundary(element) {
3668
+ const tag = element.tagName.toLowerCase();
3669
+ const className = typeof element.className === 'string' ? element.className : '';
3670
+ const testId = element.getAttribute('data-testid') ?? '';
3671
+ const buttonText = `${element.getAttribute('aria-label') ?? ''} ${element.textContent ?? ''}`;
3672
+ return tag === 'label' ||
3673
+ element.hasAttribute('data-dropzone') ||
3674
+ element.getAttributeNames().some(attribute => attribute.startsWith('data-rfd')) ||
3675
+ /drop[-_]?zone|file[-_]?upload|attach|upload/i.test(className) ||
3676
+ /drop|file|upload|attach/i.test(testId) ||
3677
+ (element.getAttribute('role') === 'button' && /drop|file|upload|attach/i.test(buttonText));
3678
+ }
3679
+ function isBroadContainer(element) {
3680
+ return element.matches('form, [role="form"], fieldset, section');
3681
+ }
3682
+ let current = start;
3683
+ let associationClosed = false;
3684
+ for (let depth = 0; current && depth < 16; depth++, current = composedParent(current)) {
3685
+ if (current instanceof HTMLInputElement && current.type === 'file') {
3686
+ return { input: current, host: current, ambiguous: false };
3687
+ }
3688
+ if (current === document.body || current === document.documentElement)
3689
+ break;
3690
+ const inputs = current.querySelectorAll('input[type="file"]');
3691
+ if (inputs.length > 1)
3692
+ return { input: null, host: current, ambiguous: true };
3693
+ const input = inputs[0];
3694
+ const explicitBoundary = isExplicitLocalBoundary(current);
3695
+ const broadContainer = isBroadContainer(current);
3696
+ if (input instanceof HTMLInputElement &&
3697
+ !associationClosed &&
3698
+ explicitBoundary)
3699
+ return { input, host: current, ambiguous: false };
3700
+ if (explicitBoundary || broadContainer)
3701
+ associationClosed = true;
3702
+ }
3703
+ return {
3704
+ input: null,
3705
+ host: start.closest('form, [role="form"], fieldset, section, div') ?? document.body,
3706
+ ambiguous: false,
3707
+ };
3708
+ }
3709
+ const targets = [deepest];
3710
+ let p = deepest.parentElement;
3711
+ for (let depth = 0; depth < 12 && p; depth++) {
3712
+ const tag = p.tagName.toLowerCase();
3713
+ const attrs = p.getAttributeNames();
3714
+ const looksLikeDropzone = attrs.some(a => a.startsWith('data-rfd') || a === 'data-dropzone' || a === 'data-testid') ||
3715
+ (p.className && typeof p.className === 'string' && /drop[-_]?zone|file[-_]?upload|attach|upload/i.test(p.className)) ||
3716
+ tag === 'label' ||
3717
+ p.getAttribute('role') === 'button';
3718
+ if (looksLikeDropzone && !targets.includes(p))
3719
+ targets.push(p);
3720
+ p = p.parentElement;
3721
+ }
3722
+ const resolvedTarget = resolveFileInput(deepest);
3723
+ if (resolvedTarget.ambiguous) {
3724
+ return { accepted: false, blockReason: null, acceptContractChanged: false, ambiguousTarget: true };
3725
+ }
3726
+ const host = resolvedTarget.host;
3727
+ const fileInput = resolvedTarget.input;
3728
+ if (fileInput !== expectedFileInput) {
3729
+ return { accepted: false, blockReason: null, acceptContractChanged: true, ambiguousTarget: false };
3730
+ }
3731
+ const currentAccept = fileInput?.getAttribute('accept') ?? null;
3732
+ if (currentAccept !== expectedAccept) {
3733
+ return { accepted: false, blockReason: null, acceptContractChanged: true, ambiguousTarget: false };
3734
+ }
3735
+ if (fileInput) {
3736
+ const inputBlockReason = blockReason(fileInput);
3737
+ if (inputBlockReason) {
3738
+ throw new Error(`file: associated drop input is not mutable (${inputBlockReason})`);
3739
+ }
2961
3740
  }
2962
- catch { /* ignore */ }
2963
- fileInput.dispatchEvent(new Event('input', { bubbles: true }));
2964
- fileInput.dispatchEvent(new Event('change', { bubbles: true }));
3741
+ const beforeFileNames = Array.from(fileInput?.files ?? []).map(file => file.name);
3742
+ const beforeRenderedText = [host, ...targets]
3743
+ .map(node => node.textContent ?? '')
3744
+ .join(' ')
3745
+ .toLowerCase();
3746
+ let handled = false;
3747
+ for (const target of targets) {
3748
+ if (!blockReason(target))
3749
+ handled = dispatchSequence(target) || handled;
3750
+ }
3751
+ // A scoped file input can provide strong acceptance evidence, but only
3752
+ // after application handlers have had a chance to reject/clear it.
3753
+ if (fileInput && !blockReason(fileInput)) {
3754
+ const dt = makeDataTransfer();
3755
+ try {
3756
+ Object.defineProperty(fileInput, 'files', { value: dt.files, configurable: true });
3757
+ }
3758
+ catch { /* ignore */ }
3759
+ try {
3760
+ const proto = Object.getPrototypeOf(fileInput);
3761
+ const descriptor = Object.getOwnPropertyDescriptor(proto, 'value');
3762
+ descriptor?.set?.call(fileInput, '');
3763
+ }
3764
+ catch { /* ignore */ }
3765
+ fileInput.dispatchEvent(new Event('input', { bubbles: true }));
3766
+ fileInput.dispatchEvent(new Event('change', { bubbles: true }));
3767
+ }
3768
+ // Framework drop handlers commonly commit on a microtask or animation
3769
+ // frame. Only report success after observable page state contains every
3770
+ // submitted filename; canceling dragover alone is not acceptance.
3771
+ await Promise.resolve();
3772
+ await new Promise(resolveTimer => setTimeout(resolveTimer, 32));
3773
+ const settledFileNames = Array.from(fileInput?.files ?? []).map(file => file.name);
3774
+ const fileInputAccepted = Boolean(fileInput && !blockReason(fileInput)) &&
3775
+ ns.every(name => settledFileNames.includes(name)) &&
3776
+ (settledFileNames.length !== beforeFileNames.length || settledFileNames.some((name, index) => name !== beforeFileNames[index]));
3777
+ const renderedText = [host, ...targets]
3778
+ .map(node => node.textContent ?? '')
3779
+ .join(' ')
3780
+ .toLowerCase();
3781
+ const renderedAcceptance = renderedText !== beforeRenderedText &&
3782
+ ns.every(name => renderedText.includes(name.toLowerCase()));
3783
+ return {
3784
+ accepted: fileInputAccepted || (handled && renderedAcceptance),
3785
+ blockReason: null,
3786
+ acceptContractChanged: false,
3787
+ ambiguousTarget: false,
3788
+ };
3789
+ }, {
3790
+ bufs: buffers.map(b => Array.from(b)),
3791
+ ns: names,
3792
+ ms: mimes,
3793
+ x: dropX,
3794
+ y: dropY,
3795
+ expectedAccept: observedAccept,
3796
+ expectedFileInput: dropFileInput,
3797
+ });
3798
+ if (result.ambiguousTarget) {
3799
+ throw new Error('file: coordinate-only drop target became ambiguous between multiple file inputs');
2965
3800
  }
2966
- }, { bufs: buffers.map(b => Array.from(b)), ns: names, ms: mimes, x: dropX, y: dropY });
3801
+ if (result.acceptContractChanged) {
3802
+ throw new Error('file: drop target accept contract changed before upload; retry with a fresh target');
3803
+ }
3804
+ if (result.blockReason) {
3805
+ throw new Error(`file: drop target is not mutable (${result.blockReason})`);
3806
+ }
3807
+ if (!result.accepted) {
3808
+ throw new Error('file: drop target did not accept the supplied files');
3809
+ }
3810
+ }
3811
+ finally {
3812
+ await dropFileInput?.dispose().catch(() => { });
3813
+ }
2967
3814
  }
2968
3815
  /**
2969
3816
  * `strategy`:
@@ -2977,7 +3824,8 @@ const ATTACH_BUTTON_PATTERN = /attach|upload|add.file|choose.file|browse/i;
2977
3824
  * Find an Attach/Upload button near a field label (e.g. Greenhouse's "Attach" button
2978
3825
  * inside a "Resume/CV" section). Returns the button Locator or null.
2979
3826
  */
2980
- async function findAttachButtonNearLabel(page, fieldLabel) {
3827
+ async function findAttachButtonNearLabel(page, fieldLabel, contextText, sectionText) {
3828
+ const matches = [];
2981
3829
  for (const frame of page.frames()) {
2982
3830
  // Find all buttons/links whose text matches attach-like patterns
2983
3831
  const buttons = frame.locator('button, a, [role="button"]');
@@ -3001,15 +3849,18 @@ async function findAttachButtonNearLabel(page, fieldLabel) {
3001
3849
  }
3002
3850
  return false;
3003
3851
  }, fieldLabel);
3004
- if (nearLabel)
3005
- return btn;
3852
+ if (nearLabel && await locatorMatchesFileScope(btn, contextText, sectionText))
3853
+ matches.push(btn);
3006
3854
  }
3007
3855
  catch {
3008
3856
  continue;
3009
3857
  }
3010
3858
  }
3011
3859
  }
3012
- return null;
3860
+ if (matches.length > 1) {
3861
+ throw new Error(`file: ambiguous Attach/Upload control for field "${fieldLabel}"; provide a more specific contextText or sectionText`);
3862
+ }
3863
+ return matches[0] ?? null;
3013
3864
  }
3014
3865
  export async function attachFiles(page, paths, opts) {
3015
3866
  const strategy = opts?.strategy ?? 'auto';
@@ -3019,6 +3870,51 @@ export async function attachFiles(page, paths, opts) {
3019
3870
  const exact = opts?.exact ?? false;
3020
3871
  const dropX = opts?.dropX;
3021
3872
  const dropY = opts?.dropY;
3873
+ const hasClickX = clickX !== undefined;
3874
+ const hasClickY = clickY !== undefined;
3875
+ const hasDropX = dropX !== undefined;
3876
+ const hasDropY = dropY !== undefined;
3877
+ const hasClick = hasClickX && hasClickY;
3878
+ const hasDrop = hasDropX && hasDropY;
3879
+ const hasFieldLabel = Boolean(fieldLabel?.trim());
3880
+ const hasFieldKey = Boolean(opts?.fieldKey);
3881
+ const hasSemanticTarget = hasFieldLabel || hasFieldKey;
3882
+ const hasSemanticExtras = Boolean(opts?.fieldId || opts?.fieldKey || fieldLabel || opts?.contextText || opts?.sectionText || opts?.exact !== undefined);
3883
+ if (hasClickX !== hasClickY)
3884
+ throw new Error('file: clickX and clickY must be provided together');
3885
+ if (hasDropX !== hasDropY)
3886
+ throw new Error('file: dropX and dropY must be provided together');
3887
+ if ((opts?.contextText || opts?.sectionText || opts?.exact !== undefined) && !hasFieldLabel) {
3888
+ throw new Error('file: contextText, sectionText, and exact require fieldLabel');
3889
+ }
3890
+ if (opts?.fieldId && !hasSemanticTarget) {
3891
+ throw new Error('file: fieldId requires fieldLabel or fieldKey');
3892
+ }
3893
+ if (strategy === 'chooser') {
3894
+ if (!hasClick)
3895
+ throw new Error('file: chooser strategy requires x,y click coordinates');
3896
+ if (hasDrop || hasSemanticExtras)
3897
+ throw new Error('file: chooser strategy accepts only a coordinate target');
3898
+ }
3899
+ else if (strategy === 'drop') {
3900
+ if (!hasDrop)
3901
+ throw new Error('file: drop strategy requires dropX, dropY');
3902
+ if (hasClick || hasSemanticExtras)
3903
+ throw new Error('file: drop strategy accepts only a drop coordinate target');
3904
+ }
3905
+ else if (strategy === 'hidden') {
3906
+ if (!hasSemanticTarget)
3907
+ throw new Error('file: hidden strategy requires fieldLabel or fieldKey');
3908
+ if (hasClick || hasDrop)
3909
+ throw new Error('file: hidden strategy does not accept coordinates');
3910
+ }
3911
+ else {
3912
+ if (hasDrop)
3913
+ throw new Error('file: dropX/dropY require strategy "drop"');
3914
+ if (hasClick === hasSemanticTarget) {
3915
+ throw new Error('file: auto strategy requires exactly one explicit target: click coordinates, fieldLabel, or fieldKey');
3916
+ }
3917
+ }
3022
3918
  if (strategy === 'chooser' || (strategy === 'auto' && clickX !== undefined && clickY !== undefined)) {
3023
3919
  if (clickX === undefined || clickY === undefined) {
3024
3920
  throw new Error('file: chooser strategy requires x,y click coordinates');
@@ -3032,6 +3928,8 @@ export async function attachFiles(page, paths, opts) {
3032
3928
  fieldKey: opts?.fieldKey,
3033
3929
  fieldLabel,
3034
3930
  exact,
3931
+ contextText: opts?.contextText,
3932
+ sectionText: opts?.sectionText,
3035
3933
  cache: opts?.cache,
3036
3934
  }))
3037
3935
  return;
@@ -3043,9 +3941,10 @@ export async function attachFiles(page, paths, opts) {
3043
3941
  }
3044
3942
  // Fallback: look for an Attach/Upload button near the field label and use the file chooser
3045
3943
  if (fieldLabel && strategy === 'auto') {
3046
- const attachBtn = await findAttachButtonNearLabel(page, fieldLabel);
3944
+ const attachBtn = await findAttachButtonNearLabel(page, fieldLabel, opts?.contextText, opts?.sectionText);
3047
3945
  if (attachBtn) {
3048
3946
  try {
3947
+ await assertLocatorMutable(attachBtn, 'file', 'file');
3049
3948
  await attachBtn.scrollIntoViewIfNeeded();
3050
3949
  const box = await attachBtn.boundingBox();
3051
3950
  if (box) {
@@ -3059,6 +3958,12 @@ export async function attachFiles(page, paths, opts) {
3059
3958
  if (fieldLabel) {
3060
3959
  throw new Error(`file: no input[type=file] matching field "${fieldLabel}"`);
3061
3960
  }
3961
+ if (opts?.fieldKey) {
3962
+ throw new Error(`file: no input[type=file] matching fieldKey "${opts.fieldKey}"`);
3963
+ }
3964
+ if (opts?.contextText || opts?.sectionText) {
3965
+ throw new Error('file: no input[type=file] matched the requested context/section');
3966
+ }
3062
3967
  }
3063
3968
  if (strategy === 'drop' || (strategy === 'auto' && dropX !== undefined && dropY !== undefined)) {
3064
3969
  if (dropX === undefined || dropY === undefined) {
@@ -3067,15 +3972,7 @@ export async function attachFiles(page, paths, opts) {
3067
3972
  await attachViaDropPlaywright(page, paths, dropX, dropY);
3068
3973
  return;
3069
3974
  }
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');
3975
+ throw new Error('file: explicit upload target did not accept the supplied files');
3079
3976
  }
3080
3977
  async function findLabeledEditableField(page, fieldLabel, exact, cache, fieldId, fieldKey) {
3081
3978
  const cached = await readCachedLocator(cache, 'editable', fieldLabel, exact, fieldId, fieldKey);
@@ -3197,20 +4094,75 @@ async function locatorCurrentValue(locator) {
3197
4094
  function normalizedFieldValue(value) {
3198
4095
  return value.replace(/\s+/g, ' ').trim().toLowerCase();
3199
4096
  }
3200
- function fieldValueMatches(actual, expected) {
4097
+ function fieldValueMatches(actual, expected, inputType) {
3201
4098
  if (!actual)
3202
4099
  return false;
3203
4100
  const normalizedActual = normalizedFieldValue(actual);
3204
4101
  const normalizedExpected = normalizedFieldValue(expected);
3205
4102
  if (!normalizedActual || !normalizedExpected)
3206
4103
  return false;
3207
- return normalizedActual === normalizedExpected || normalizedActual.includes(normalizedExpected);
4104
+ if (inputType?.toLowerCase() === 'tel') {
4105
+ const actualDigits = normalizedActual.replace(/\D/g, '');
4106
+ const expectedDigits = normalizedExpected.replace(/\D/g, '');
4107
+ return actualDigits.length > 0 && actualDigits === expectedDigits;
4108
+ }
4109
+ return normalizedActual === normalizedExpected;
3208
4110
  }
3209
4111
  async function setLocatorTextValue(locator, value, opts) {
4112
+ if (await locatorMutationBlockReason(locator, 'text'))
4113
+ return false;
3210
4114
  try {
3211
4115
  return await locator.evaluate((el, payload) => {
3212
4116
  const nextValue = payload.value;
3213
4117
  const imeFriendly = payload.imeFriendly;
4118
+ // Preflight and commit are separate Playwright round-trips. Reactive
4119
+ // applications can make a control readonly/disabled in between, so the
4120
+ // final guard must live in the same page task as the first mutation.
4121
+ function commitMutationBlockReason(target) {
4122
+ function composedParent(node) {
4123
+ if (node.parentElement)
4124
+ return node.parentElement;
4125
+ const root = node.getRootNode();
4126
+ return root instanceof ShadowRoot ? root.host : null;
4127
+ }
4128
+ function composedDescendantOf(node, ancestor) {
4129
+ let current = node;
4130
+ while (current) {
4131
+ if (current === ancestor)
4132
+ return true;
4133
+ current = composedParent(current);
4134
+ }
4135
+ return false;
4136
+ }
4137
+ try {
4138
+ if (target.matches(':disabled'))
4139
+ return 'disabled';
4140
+ }
4141
+ catch { /* non-HTML namespaces may reject pseudo classes */ }
4142
+ if ((target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) &&
4143
+ target.readOnly) {
4144
+ return 'readonly';
4145
+ }
4146
+ let current = target;
4147
+ while (current) {
4148
+ if (current.hasAttribute('inert'))
4149
+ return 'inert';
4150
+ if (current.getAttribute('aria-disabled')?.trim().toLowerCase() === 'true')
4151
+ return 'aria-disabled';
4152
+ if (current.getAttribute('aria-readonly')?.trim().toLowerCase() === 'true')
4153
+ return 'aria-readonly';
4154
+ if (current instanceof HTMLFieldSetElement && current.disabled) {
4155
+ const firstLegend = Array.from(current.children).find(child => child instanceof HTMLLegendElement);
4156
+ if (!(firstLegend instanceof HTMLLegendElement) || !composedDescendantOf(target, firstLegend)) {
4157
+ return 'disabled fieldset';
4158
+ }
4159
+ }
4160
+ current = composedParent(current);
4161
+ }
4162
+ return null;
4163
+ }
4164
+ if (commitMutationBlockReason(el))
4165
+ return false;
3214
4166
  // React Greenhouse/Workday/Lever fix: React stores the previous value on
3215
4167
  // a hidden `_valueTracker` property that React uses to short-circuit
3216
4168
  // onChange when the value "hasn't changed". If we set el.value through
@@ -3416,7 +4368,57 @@ async function attemptNativeBatchFill(page, fields) {
3416
4368
  const style = getComputedStyle(el);
3417
4369
  return style.display !== 'none' && style.visibility !== 'hidden';
3418
4370
  }
3419
- function referencedText(ids, visited) {
4371
+ function mutationBlockReason(target) {
4372
+ function composedParent(node) {
4373
+ if (node.parentElement)
4374
+ return node.parentElement;
4375
+ const root = node.getRootNode();
4376
+ return root instanceof ShadowRoot ? root.host : null;
4377
+ }
4378
+ function composedDescendantOf(node, ancestor) {
4379
+ let current = node;
4380
+ while (current) {
4381
+ if (current === ancestor)
4382
+ return true;
4383
+ current = composedParent(current);
4384
+ }
4385
+ return false;
4386
+ }
4387
+ try {
4388
+ if (target.matches(':disabled'))
4389
+ return 'disabled';
4390
+ }
4391
+ catch { /* ignore */ }
4392
+ if ((target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) &&
4393
+ target.readOnly)
4394
+ return 'readonly';
4395
+ let current = target;
4396
+ while (current) {
4397
+ if (current.hasAttribute('inert'))
4398
+ return 'inert';
4399
+ if (current.getAttribute('aria-disabled')?.trim().toLowerCase() === 'true')
4400
+ return 'aria-disabled';
4401
+ if (current.getAttribute('aria-readonly')?.trim().toLowerCase() === 'true')
4402
+ return 'aria-readonly';
4403
+ if (current instanceof HTMLFieldSetElement && current.disabled) {
4404
+ const firstLegend = Array.from(current.children).find(child => child instanceof HTMLLegendElement);
4405
+ if (!(firstLegend instanceof HTMLLegendElement) || !composedDescendantOf(target, firstLegend)) {
4406
+ return 'disabled fieldset';
4407
+ }
4408
+ }
4409
+ current = composedParent(current);
4410
+ }
4411
+ return null;
4412
+ }
4413
+ function rootScopedElementById(owner, id) {
4414
+ const root = owner.getRootNode();
4415
+ if (root instanceof ShadowRoot)
4416
+ return root.getElementById(id);
4417
+ if (root instanceof Document)
4418
+ return root.getElementById(id);
4419
+ return null;
4420
+ }
4421
+ function referencedText(owner, ids, visited) {
3420
4422
  if (!ids)
3421
4423
  return undefined;
3422
4424
  const seen = visited ?? new Set();
@@ -3426,12 +4428,12 @@ async function attemptNativeBatchFill(page, fields) {
3426
4428
  if (seen.has(id))
3427
4429
  return '';
3428
4430
  seen.add(id);
3429
- const target = document.getElementById(id);
4431
+ const target = rootScopedElementById(owner, id);
3430
4432
  if (!target)
3431
4433
  return '';
3432
4434
  const chained = target.getAttribute('aria-labelledby');
3433
4435
  if (chained)
3434
- return referencedText(chained, seen) ?? '';
4436
+ return referencedText(target, chained, seen) ?? '';
3435
4437
  return target.textContent?.trim() ?? '';
3436
4438
  })
3437
4439
  .filter(Boolean)
@@ -3443,7 +4445,7 @@ async function attemptNativeBatchFill(page, fields) {
3443
4445
  const aria = el.getAttribute('aria-label')?.trim();
3444
4446
  if (aria)
3445
4447
  return aria;
3446
- const labelledBy = referencedText(el.getAttribute('aria-labelledby'));
4448
+ const labelledBy = referencedText(el, el.getAttribute('aria-labelledby'));
3447
4449
  if (labelledBy)
3448
4450
  return labelledBy;
3449
4451
  if ((el instanceof HTMLInputElement || el instanceof HTMLSelectElement || el instanceof HTMLTextAreaElement) &&
@@ -3452,7 +4454,10 @@ async function attemptNativeBatchFill(page, fields) {
3452
4454
  return el.labels[0]?.textContent?.trim() || undefined;
3453
4455
  }
3454
4456
  if (el instanceof HTMLElement && el.id) {
3455
- const label = document.querySelector(`label[for="${CSS.escape(el.id)}"]`);
4457
+ const root = el.getRootNode();
4458
+ const label = (root instanceof Document || root instanceof ShadowRoot)
4459
+ ? root.querySelector(`label[for="${CSS.escape(el.id)}"]`)
4460
+ : null;
3456
4461
  const text = label?.textContent?.trim();
3457
4462
  if (text)
3458
4463
  return text;
@@ -3505,6 +4510,8 @@ async function attemptNativeBatchFill(page, fields) {
3505
4510
  target.value = next;
3506
4511
  }
3507
4512
  function setInputCheckedReactAware(target, next) {
4513
+ if (mutationBlockReason(target))
4514
+ return false;
3508
4515
  const hadReactTracker = clearReactTracker(target, next);
3509
4516
  if (!hadReactTracker && target.checked === next)
3510
4517
  return true;
@@ -3664,6 +4671,8 @@ async function attemptNativeBatchFill(page, fields) {
3664
4671
  }
3665
4672
  if (!matches(explicitLabelText(control), fieldLabel, exact))
3666
4673
  continue;
4674
+ if (mutationBlockReason(control))
4675
+ return false;
3667
4676
  if (control instanceof HTMLInputElement || control instanceof HTMLTextAreaElement) {
3668
4677
  const inputType = control instanceof HTMLInputElement ? control.type : 'textarea';
3669
4678
  const v = canonicalizeHtmlInputValueInPage(inputType, value);
@@ -3671,7 +4680,7 @@ async function attemptNativeBatchFill(page, fields) {
3671
4680
  clearReactTracker(control);
3672
4681
  setInputLikeValue(control, v);
3673
4682
  dispatch(control);
3674
- return matches(currentValue(control), v, false);
4683
+ return normalize(currentValue(control)) === normalize(v);
3675
4684
  }
3676
4685
  if (control instanceof HTMLElement && control.isContentEditable) {
3677
4686
  if (!isPlainContentEditableBatch(control))
@@ -3680,7 +4689,7 @@ async function attemptNativeBatchFill(page, fields) {
3680
4689
  clearReactTracker(control);
3681
4690
  control.textContent = value;
3682
4691
  dispatch(control);
3683
- return matches(currentValue(control), value, false);
4692
+ return normalize(currentValue(control)) === normalize(value);
3684
4693
  }
3685
4694
  }
3686
4695
  return false;
@@ -3692,8 +4701,12 @@ async function attemptNativeBatchFill(page, fields) {
3692
4701
  continue;
3693
4702
  if (!matches(explicitLabelText(control), fieldLabel, exact))
3694
4703
  continue;
4704
+ if (mutationBlockReason(control))
4705
+ return false;
3695
4706
  const expected = normalize(value);
3696
4707
  const option = Array.from(control.options).find((candidate) => {
4708
+ if (candidate.disabled || (candidate.parentElement instanceof HTMLOptGroupElement && candidate.parentElement.disabled))
4709
+ return false;
3697
4710
  const label = normalize(candidate.textContent);
3698
4711
  const rawValue = normalize(candidate.value);
3699
4712
  return exact ? label === expected || rawValue === expected : label.includes(expected) || rawValue.includes(expected);
@@ -3702,7 +4715,7 @@ async function attemptNativeBatchFill(page, fields) {
3702
4715
  return false;
3703
4716
  control.value = option.value;
3704
4717
  dispatch(control);
3705
- return matches(currentValue(control), value, false) || normalize(control.value) === expected;
4718
+ return control.selectedIndex === option.index;
3706
4719
  }
3707
4720
  return false;
3708
4721
  }
@@ -3762,13 +4775,17 @@ async function attemptNativeBatchFill(page, fields) {
3762
4775
  const control = option.control;
3763
4776
  if (control instanceof HTMLInputElement)
3764
4777
  return setInputCheckedReactAware(control, true);
4778
+ if (mutationBlockReason(option))
4779
+ return false;
3765
4780
  option.click();
3766
- return true;
4781
+ return false;
3767
4782
  }
3768
4783
  // role=radio / role=checkbox: click and verify aria-checked
3769
4784
  // / aria-selected. The native batch fill used to silently return
3770
4785
  // true here, which masked Pinecone's commit-on-rerender bug.
3771
4786
  if (option.getAttribute('role') === 'radio' || option.getAttribute('role') === 'checkbox') {
4787
+ if (mutationBlockReason(option))
4788
+ return false;
3772
4789
  option.click();
3773
4790
  return (option.getAttribute('aria-checked') === 'true' ||
3774
4791
  option.getAttribute('aria-selected') === 'true');
@@ -3785,6 +4802,8 @@ async function attemptNativeBatchFill(page, fields) {
3785
4802
  // JobForge round-2 marathon — Pinecone Sr SWE Database Team
3786
4803
  // #320 and LangChain SE Manager #325.
3787
4804
  const beforeSig = selectionSignature(options);
4805
+ if (mutationBlockReason(option))
4806
+ return false;
3788
4807
  option.click();
3789
4808
  const afterSig = selectionSignature(options);
3790
4809
  if (beforeSig === afterSig)
@@ -3803,6 +4822,8 @@ async function attemptNativeBatchFill(page, fields) {
3803
4822
  continue;
3804
4823
  if (!matches(explicitLabelText(input), label, exact))
3805
4824
  continue;
4825
+ if (mutationBlockReason(input))
4826
+ return false;
3806
4827
  if (!setInputCheckedReactAware(input, checked))
3807
4828
  return false;
3808
4829
  return input.checked === checked;
@@ -3818,6 +4839,8 @@ async function attemptNativeBatchFill(page, fields) {
3818
4839
  continue;
3819
4840
  if (controlType && control.type !== controlType)
3820
4841
  continue;
4842
+ if (mutationBlockReason(control))
4843
+ return false;
3821
4844
  if (!setInputCheckedReactAware(control, checked))
3822
4845
  return false;
3823
4846
  return control.checked === checked;
@@ -4105,6 +5128,9 @@ export async function fillOtp(page, value, opts) {
4105
5128
  if (liveCount !== group.cellCount) {
4106
5129
  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
5130
  }
5131
+ for (let index = 0; index < liveCount; index++) {
5132
+ await assertLocatorMutable(group.boxes.nth(index), 'text', `fillOtp: OTP cell ${index + 1}`);
5133
+ }
4108
5134
  // Click the leftmost box via its center point. We use a low-level
4109
5135
  // bounding-box click because a semantic "click the first input" path
4110
5136
  // resolves to whichever input paints topmost in the stacking order,
@@ -4310,6 +5336,7 @@ export async function setFieldText(page, fieldLabel, value, opts) {
4310
5336
  if (!locator) {
4311
5337
  throw new Error(`setFieldText: no visible editable field matching "${fieldLabel}"`);
4312
5338
  }
5339
+ await assertLocatorMutable(locator, 'text', 'setFieldText');
4313
5340
  await locator.scrollIntoViewIfNeeded();
4314
5341
  const inputType = await locator.evaluate(el => {
4315
5342
  if (el instanceof HTMLInputElement)
@@ -4321,6 +5348,7 @@ export async function setFieldText(page, fieldLabel, value, opts) {
4321
5348
  const normalized = canonicalizeHtmlInputValue(inputType, value);
4322
5349
  const applied = await setLocatorTextValue(locator, normalized, { imeFriendly: opts?.imeFriendly });
4323
5350
  if (!applied) {
5351
+ await assertLocatorMutable(locator, 'text', 'setFieldText');
4324
5352
  try {
4325
5353
  await locator.fill(normalized);
4326
5354
  }
@@ -4331,19 +5359,66 @@ export async function setFieldText(page, fieldLabel, value, opts) {
4331
5359
  });
4332
5360
  }
4333
5361
  }
5362
+ await delay(40);
4334
5363
  const current = await locatorCurrentValue(locator);
4335
- if (fieldValueMatches(current, normalized))
5364
+ if (fieldValueMatches(current, normalized, inputType))
4336
5365
  return;
4337
5366
  const displayed = await locatorDisplayedValues(locator);
4338
- if (displayed.some(candidate => fieldValueMatches(candidate, normalized)))
5367
+ if (displayed.some(candidate => fieldValueMatches(candidate, normalized, inputType)))
4339
5368
  return;
4340
5369
  throw new Error(`setFieldText: set "${fieldLabel}" but could not confirm value ${JSON.stringify(value)}`);
4341
5370
  }
4342
5371
  async function setNativeSelectByLabel(locator, value, exact, optionIndex) {
5372
+ if (await locatorMutationBlockReason(locator, 'select'))
5373
+ return false;
4343
5374
  try {
4344
- return await locator.evaluate((el, payload) => {
5375
+ return await locator.evaluate(async (el, payload) => {
4345
5376
  if (!(el instanceof HTMLSelectElement))
4346
5377
  return false;
5378
+ // Keep the final state check atomic with selectedIndex/event mutation.
5379
+ // The earlier host-side preflight can race a reactive disable/inert
5380
+ // update while Playwright crosses back into the page.
5381
+ function commitMutationBlockReason(target) {
5382
+ function composedParent(node) {
5383
+ if (node.parentElement)
5384
+ return node.parentElement;
5385
+ const root = node.getRootNode();
5386
+ return root instanceof ShadowRoot ? root.host : null;
5387
+ }
5388
+ function composedDescendantOf(node, ancestor) {
5389
+ let current = node;
5390
+ while (current) {
5391
+ if (current === ancestor)
5392
+ return true;
5393
+ current = composedParent(current);
5394
+ }
5395
+ return false;
5396
+ }
5397
+ try {
5398
+ if (target.matches(':disabled'))
5399
+ return 'disabled';
5400
+ }
5401
+ catch { /* non-HTML namespaces may reject pseudo classes */ }
5402
+ let current = target;
5403
+ while (current) {
5404
+ if (current.hasAttribute('inert'))
5405
+ return 'inert';
5406
+ if (current.getAttribute('aria-disabled')?.trim().toLowerCase() === 'true')
5407
+ return 'aria-disabled';
5408
+ if (current.getAttribute('aria-readonly')?.trim().toLowerCase() === 'true')
5409
+ return 'aria-readonly';
5410
+ if (current instanceof HTMLFieldSetElement && current.disabled) {
5411
+ const firstLegend = Array.from(current.children).find(child => child instanceof HTMLLegendElement);
5412
+ if (!(firstLegend instanceof HTMLLegendElement) || !composedDescendantOf(target, firstLegend)) {
5413
+ return 'disabled fieldset';
5414
+ }
5415
+ }
5416
+ current = composedParent(current);
5417
+ }
5418
+ return null;
5419
+ }
5420
+ if (commitMutationBlockReason(el))
5421
+ return false;
4347
5422
  const normalize = (input) => input?.replace(/\s+/g, ' ').trim().toLowerCase() ?? '';
4348
5423
  const expected = normalize(payload.value);
4349
5424
  const enabled = (candidate) => !candidate.disabled && !(candidate.parentElement instanceof HTMLOptGroupElement && candidate.parentElement.disabled);
@@ -4372,7 +5447,8 @@ async function setNativeSelectByLabel(locator, value, exact, optionIndex) {
4372
5447
  el.selectedIndex = option.index;
4373
5448
  el.dispatchEvent(new Event('input', { bubbles: true }));
4374
5449
  el.dispatchEvent(new Event('change', { bubbles: true }));
4375
- return true;
5450
+ await new Promise(resolveTimer => setTimeout(resolveTimer, 40));
5451
+ return el.selectedIndex === option.index && el.selectedOptions[0] === option;
4376
5452
  }, { value, exact, optionIndex });
4377
5453
  }
4378
5454
  catch {
@@ -4394,6 +5470,46 @@ async function chooseValueFromLabeledGroup(page, fieldLabel, value, exact) {
4394
5470
  const style = getComputedStyle(el);
4395
5471
  return style.display !== 'none' && style.visibility !== 'hidden';
4396
5472
  }
5473
+ function blocked(target) {
5474
+ function parent(node) {
5475
+ if (node.parentElement)
5476
+ return node.parentElement;
5477
+ const root = node.getRootNode();
5478
+ return root instanceof ShadowRoot ? root.host : null;
5479
+ }
5480
+ function within(node, ancestor) {
5481
+ let current = node;
5482
+ while (current) {
5483
+ if (current === ancestor)
5484
+ return true;
5485
+ current = parent(current);
5486
+ }
5487
+ return false;
5488
+ }
5489
+ try {
5490
+ if (target.matches(':disabled'))
5491
+ return true;
5492
+ }
5493
+ catch { /* ignore */ }
5494
+ if ((target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) && target.readOnly)
5495
+ return true;
5496
+ let current = target;
5497
+ while (current) {
5498
+ if (current.hasAttribute('inert'))
5499
+ return true;
5500
+ if (current.getAttribute('aria-disabled')?.trim().toLowerCase() === 'true')
5501
+ return true;
5502
+ if (current.getAttribute('aria-readonly')?.trim().toLowerCase() === 'true')
5503
+ return true;
5504
+ if (current instanceof HTMLFieldSetElement && current.disabled) {
5505
+ const legend = Array.from(current.children).find(child => child instanceof HTMLLegendElement);
5506
+ if (!(legend instanceof HTMLLegendElement) || !within(target, legend))
5507
+ return true;
5508
+ }
5509
+ current = parent(current);
5510
+ }
5511
+ return false;
5512
+ }
4397
5513
  function matches(candidate) {
4398
5514
  const normalizedCandidate = normalize(candidate);
4399
5515
  const normalizedExpected = normalize(payload.fieldLabel);
@@ -4408,7 +5524,15 @@ async function chooseValueFromLabeledGroup(page, fieldLabel, value, exact) {
4408
5524
  return false;
4409
5525
  return payload.exact ? normalizedCandidate === normalizedExpected : normalizedCandidate.includes(normalizedExpected);
4410
5526
  }
4411
- function referencedText(ids, visited) {
5527
+ function rootScopedElementById(owner, id) {
5528
+ const root = owner.getRootNode();
5529
+ if (root instanceof ShadowRoot)
5530
+ return root.getElementById(id);
5531
+ if (root instanceof Document)
5532
+ return root.getElementById(id);
5533
+ return null;
5534
+ }
5535
+ function referencedText(owner, ids, visited) {
4412
5536
  if (!ids)
4413
5537
  return undefined;
4414
5538
  const seen = visited ?? new Set();
@@ -4418,12 +5542,12 @@ async function chooseValueFromLabeledGroup(page, fieldLabel, value, exact) {
4418
5542
  if (seen.has(id))
4419
5543
  return '';
4420
5544
  seen.add(id);
4421
- const target = document.getElementById(id);
5545
+ const target = rootScopedElementById(owner, id);
4422
5546
  if (!target)
4423
5547
  return '';
4424
5548
  const chained = target.getAttribute('aria-labelledby');
4425
5549
  if (chained)
4426
- return referencedText(chained, seen) ?? '';
5550
+ return referencedText(target, chained, seen) ?? '';
4427
5551
  return target.textContent?.trim() ?? '';
4428
5552
  })
4429
5553
  .filter(Boolean)
@@ -4435,7 +5559,7 @@ async function chooseValueFromLabeledGroup(page, fieldLabel, value, exact) {
4435
5559
  const aria = el.getAttribute('aria-label')?.trim();
4436
5560
  if (aria)
4437
5561
  return aria;
4438
- const labelledBy = referencedText(el.getAttribute('aria-labelledby'));
5562
+ const labelledBy = referencedText(el, el.getAttribute('aria-labelledby'));
4439
5563
  if (labelledBy)
4440
5564
  return labelledBy;
4441
5565
  if (el instanceof HTMLInputElement && el.labels && el.labels.length > 0) {
@@ -4469,7 +5593,7 @@ async function chooseValueFromLabeledGroup(page, fieldLabel, value, exact) {
4469
5593
  const aria = el.getAttribute('aria-label')?.trim();
4470
5594
  if (aria)
4471
5595
  return aria;
4472
- const labelledBy = referencedText(el.getAttribute('aria-labelledby'));
5596
+ const labelledBy = referencedText(el, el.getAttribute('aria-labelledby'));
4473
5597
  if (labelledBy)
4474
5598
  return labelledBy;
4475
5599
  return el.textContent?.trim() || undefined;
@@ -4547,6 +5671,8 @@ async function chooseValueFromLabeledGroup(page, fieldLabel, value, exact) {
4547
5671
  return false;
4548
5672
  }
4549
5673
  function setInputCheckedReactAware(target, next) {
5674
+ if (blocked(target))
5675
+ return false;
4550
5676
  const hadReactTracker = clearReactTracker(target, next);
4551
5677
  if (!hadReactTracker && target.checked === next)
4552
5678
  return true;
@@ -4573,6 +5699,8 @@ async function chooseValueFromLabeledGroup(page, fieldLabel, value, exact) {
4573
5699
  const control = option.control;
4574
5700
  if (control instanceof HTMLInputElement)
4575
5701
  return setInputCheckedReactAware(control, true);
5702
+ if (blocked(option))
5703
+ return false;
4576
5704
  option.click();
4577
5705
  // No backing input — verify via group signature change so we don't
4578
5706
  // silently no-op on label wrappers whose target is unparented.
@@ -4580,6 +5708,8 @@ async function chooseValueFromLabeledGroup(page, fieldLabel, value, exact) {
4580
5708
  return labelBeforeSig !== labelAfterSig;
4581
5709
  }
4582
5710
  if (option.getAttribute('role') === 'radio' || option.getAttribute('role') === 'checkbox') {
5711
+ if (blocked(option))
5712
+ return false;
4583
5713
  option.click();
4584
5714
  return option.getAttribute('aria-checked') === 'true' || option.getAttribute('aria-selected') === 'true';
4585
5715
  }
@@ -4589,6 +5719,8 @@ async function chooseValueFromLabeledGroup(page, fieldLabel, value, exact) {
4589
5719
  // was a no-op — return false so the caller can try the next strategy
4590
5720
  // (pickListboxOption fallback, etc) instead of silently succeeding.
4591
5721
  const beforeSig = selectionSignature(options);
5722
+ if (blocked(option))
5723
+ return false;
4592
5724
  option.click();
4593
5725
  const afterSig = selectionSignature(options);
4594
5726
  if (beforeSig === afterSig) {
@@ -4710,6 +5842,7 @@ export async function setFieldChoice(page, fieldLabel, value, opts) {
4710
5842
  fieldKey: opts?.fieldKey,
4711
5843
  });
4712
5844
  if (locator) {
5845
+ await assertLocatorMutable(locator, 'select', 'setFieldChoice');
4713
5846
  await locator.scrollIntoViewIfNeeded();
4714
5847
  const isNativeSelect = await locator.evaluate(el => el instanceof HTMLSelectElement);
4715
5848
  if (await setNativeSelectByLabel(locator, value, exact, opts?.optionIndex)) {
@@ -4848,6 +5981,8 @@ export async function fillFields(page, fields, cache = createFillLookupCache())
4848
5981
  fieldId: field.fieldId,
4849
5982
  fieldKey: field.fieldKey,
4850
5983
  fieldLabel: field.fieldLabel,
5984
+ contextText: field.contextText,
5985
+ sectionText: field.sectionText,
4851
5986
  exact: field.exact,
4852
5987
  cache,
4853
5988
  });
@@ -4885,71 +6020,46 @@ export async function selectNativeOption(page, x, y, opt) {
4885
6020
  if (opt.value === undefined && opt.label === undefined && opt.index === undefined) {
4886
6021
  throw new Error('selectOption: provide at least one of value, label, or index');
4887
6022
  }
6023
+ if (opt.index !== undefined && (!Number.isFinite(opt.index) || !Number.isInteger(opt.index) || opt.index < 0)) {
6024
+ throw new Error('selectOption: index must be a non-negative integer');
6025
+ }
6026
+ await assertPointMutable(page, x, y, 'select', 'selectOption');
4888
6027
  await page.mouse.click(x, y);
4889
6028
  await delay(40);
4890
6029
  for (const frame of page.frames()) {
4891
- const applied = await frame.evaluate((payload) => {
6030
+ const applied = await frame.evaluate(async (payload) => {
4892
6031
  const normalize = (input) => input?.replace(/\s+/g, ' ').trim().toLowerCase() ?? '';
4893
- const optionMatchesExpected = (o, expected) => {
6032
+ const enabled = (o) => !o.disabled && !(o.parentElement instanceof HTMLOptGroupElement && o.parentElement.disabled);
6033
+ const optionMatchesPayload = (o) => {
4894
6034
  if (o.disabled || (o.parentElement instanceof HTMLOptGroupElement && o.parentElement.disabled))
4895
6035
  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);
6036
+ if (payload.value !== null && normalize(o.value) !== normalize(payload.value))
6037
+ return false;
6038
+ if (payload.label !== null && normalize(o.textContent) !== normalize(payload.label))
6039
+ return false;
6040
+ if (payload.index !== undefined && o.index !== payload.index)
6041
+ return false;
6042
+ return true;
4899
6043
  };
4900
6044
  const a = document.activeElement;
4901
6045
  if (!a || a.tagName !== 'SELECT')
4902
6046
  return false;
4903
6047
  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 {
6048
+ if (sel.matches(':disabled') || sel.getAttribute('aria-disabled') === 'true' || sel.getAttribute('aria-readonly') === 'true')
4948
6049
  return false;
4949
- }
6050
+ if (sel.closest('[inert], [aria-disabled="true"], [aria-readonly="true"]'))
6051
+ return false;
6052
+ const candidate = payload.index !== undefined
6053
+ ? sel.options.item(payload.index)
6054
+ : Array.from(sel.options).find(optionMatchesPayload) ?? null;
6055
+ if (!candidate || !enabled(candidate) || !optionMatchesPayload(candidate))
6056
+ return false;
6057
+ sel.selectedIndex = candidate.index;
4950
6058
  sel.dispatchEvent(new Event('input', { bubbles: true }));
4951
6059
  sel.dispatchEvent(new Event('change', { bubbles: true }));
4952
- return true;
6060
+ await new Promise(resolveTimer => setTimeout(resolveTimer, 40));
6061
+ const selected = sel.selectedOptions[0];
6062
+ return sel.selectedIndex === candidate.index && selected === candidate && optionMatchesPayload(selected);
4953
6063
  }, {
4954
6064
  value: opt.value ?? null,
4955
6065
  label: opt.label ?? null,
@@ -4966,6 +6076,23 @@ export async function selectNativeOption(page, x, y, opt) {
4966
6076
  export async function pickListboxOption(page, label, opts) {
4967
6077
  let anchor;
4968
6078
  const exact = opts?.exact ?? false;
6079
+ const hasOpenX = opts?.openX !== undefined;
6080
+ const hasOpenY = opts?.openY !== undefined;
6081
+ if (hasOpenX !== hasOpenY) {
6082
+ throw new Error('listboxPick: openX and openY must be provided together');
6083
+ }
6084
+ if ((hasOpenX || hasOpenY) && (opts?.fieldLabel || opts?.fieldId || opts?.fieldKey)) {
6085
+ throw new Error('listboxPick: coordinate opening cannot be combined with fieldLabel, fieldId, or fieldKey');
6086
+ }
6087
+ if (hasOpenX && opts?.query !== undefined) {
6088
+ throw new Error('listboxPick: query cannot be combined with coordinate opening');
6089
+ }
6090
+ if (opts?.fieldId && !opts.fieldLabel && !opts.fieldKey) {
6091
+ throw new Error('listboxPick: fieldId requires fieldLabel or fieldKey and cannot be used as an unscoped hint');
6092
+ }
6093
+ if (!opts?.fieldLabel && !opts?.fieldKey && !hasOpenX) {
6094
+ throw new Error('listboxPick: provide fieldLabel, fieldKey, or a complete openX/openY target before selecting an option');
6095
+ }
4969
6096
  let attemptedSelection = false;
4970
6097
  let selectedOptionText;
4971
6098
  let openedHandle;
@@ -4991,16 +6118,16 @@ export async function pickListboxOption(page, label, opts) {
4991
6118
  await releasePopupScope();
4992
6119
  popupScope = await resolveOwnedPopupHandle(openedHandle);
4993
6120
  };
4994
- if (opts?.fieldLabel) {
6121
+ if (opts?.fieldLabel || opts?.fieldKey) {
4995
6122
  let opened;
4996
6123
  try {
4997
- opened = await openDropdownControl(page, opts.fieldLabel, exact, opts.cache, opts.fieldId, opts.fieldKey);
6124
+ opened = await openDropdownControl(page, opts.fieldLabel ?? '', exact, opts.cache, opts.fieldId, opts.fieldKey);
4998
6125
  }
4999
6126
  catch {
5000
6127
  throw new Error(listboxErrorMessage({
5001
6128
  reason: 'field_not_found',
5002
6129
  requestedLabel: label,
5003
- fieldLabel: opts.fieldLabel,
6130
+ fieldLabel: opts.fieldLabel ?? opts.fieldKey,
5004
6131
  query: opts?.query,
5005
6132
  exact,
5006
6133
  }));
@@ -5020,6 +6147,10 @@ export async function pickListboxOption(page, label, opts) {
5020
6147
  await refreshPopupScope();
5021
6148
  }
5022
6149
  else if (opts?.openX !== undefined && opts?.openY !== undefined) {
6150
+ await assertPointMutable(page, opts.openX, opts.openY, 'select', 'listboxPick');
6151
+ openedHandle = await elementHandleAtPoint(page, opts.openX, opts.openY);
6152
+ if (!openedHandle)
6153
+ throw new Error('listboxPick: no control at openX/openY');
5023
6154
  await page.mouse.click(opts.openX, opts.openY);
5024
6155
  anchor = { x: opts.openX, y: opts.openY };
5025
6156
  await delay(120);
@@ -5036,19 +6167,20 @@ export async function pickListboxOption(page, label, opts) {
5036
6167
  // popup. If the pointer click did not commit, the keyboard fallback
5037
6168
  // below deliberately reopens this exact control and selects by Enter.
5038
6169
  await dispatchListboxCommitEvents(openedHandle);
5039
- if (!opts?.fieldLabel ||
5040
- await confirmListboxSelection(page, opts.fieldLabel, label, exact, anchor, openedHandle, selectedOptionText, {
6170
+ if (opts?.fieldLabel) {
6171
+ if (await confirmListboxSelection(page, opts.fieldLabel, label, exact, anchor, openedHandle, selectedOptionText, {
5041
6172
  editable: openedEditable,
5042
- })) {
5043
- return true;
6173
+ }))
6174
+ return true;
6175
+ return false;
5044
6176
  }
5045
- return false;
6177
+ return await confirmListboxSelectionWithoutLabel(page, label, exact, anchor, openedHandle, selectedOptionText);
5046
6178
  };
5047
6179
  const dismissAfterSelection = async () => {
5048
6180
  if (!opts?.fieldLabel) {
5049
6181
  await page.keyboard.press('Tab');
5050
6182
  await delay(50);
5051
- return true;
6183
+ return await confirmListboxSelectionWithoutLabel(page, label, exact, anchor, openedHandle, selectedOptionText);
5052
6184
  }
5053
6185
  if (await dismissAndReVerifySelection(page, label, exact, openedHandle, selectedOptionText)) {
5054
6186
  return true;
@@ -5145,10 +6277,12 @@ export async function pickListboxOption(page, label, opts) {
5145
6277
  selectedOptionText = keyboardSelection;
5146
6278
  attemptedSelection = true;
5147
6279
  await dispatchListboxCommitEvents(openedHandle);
5148
- if (!opts?.fieldLabel ||
5149
- await confirmListboxSelection(page, opts.fieldLabel, label, exact, anchor, openedHandle, selectedOptionText, {
6280
+ const keyboardConfirmed = opts?.fieldLabel
6281
+ ? await confirmListboxSelection(page, opts.fieldLabel, label, exact, anchor, openedHandle, selectedOptionText, {
5150
6282
  editable: openedEditable,
5151
- })) {
6283
+ })
6284
+ : await confirmListboxSelectionWithoutLabel(page, label, exact, anchor, openedHandle, selectedOptionText);
6285
+ if (keyboardConfirmed) {
5152
6286
  if (await dismissAfterSelection() && await postCommitVerify())
5153
6287
  return;
5154
6288
  }
@@ -5183,7 +6317,15 @@ export async function pickListboxOption(page, label, opts) {
5183
6317
  }
5184
6318
  async function applyToggleState(locator, desiredChecked) {
5185
6319
  return await locator.evaluate((target, checked) => {
5186
- function referencedText(ids, visited) {
6320
+ function rootScopedElementById(owner, id) {
6321
+ const root = owner.getRootNode();
6322
+ if (root instanceof ShadowRoot)
6323
+ return root.getElementById(id);
6324
+ if (root instanceof Document)
6325
+ return root.getElementById(id);
6326
+ return null;
6327
+ }
6328
+ function referencedText(owner, ids, visited) {
5187
6329
  if (!ids)
5188
6330
  return undefined;
5189
6331
  const seen = visited ?? new Set();
@@ -5191,10 +6333,10 @@ async function applyToggleState(locator, desiredChecked) {
5191
6333
  if (seen.has(id))
5192
6334
  return '';
5193
6335
  seen.add(id);
5194
- const node = document.getElementById(id);
6336
+ const node = rootScopedElementById(owner, id);
5195
6337
  if (!node)
5196
6338
  return '';
5197
- return referencedText(node.getAttribute('aria-labelledby'), seen) ?? node.textContent?.trim() ?? '';
6339
+ return referencedText(node, node.getAttribute('aria-labelledby'), seen) ?? node.textContent?.trim() ?? '';
5198
6340
  }).filter(Boolean).join(' ').trim();
5199
6341
  return text || undefined;
5200
6342
  }
@@ -5207,7 +6349,7 @@ async function applyToggleState(locator, desiredChecked) {
5207
6349
  const aria = el.getAttribute('aria-label')?.trim();
5208
6350
  if (aria)
5209
6351
  return aria;
5210
- const labelledBy = referencedText(el.getAttribute('aria-labelledby'));
6352
+ const labelledBy = referencedText(el, el.getAttribute('aria-labelledby'));
5211
6353
  if (labelledBy)
5212
6354
  return labelledBy;
5213
6355
  if (el instanceof HTMLInputElement) {
@@ -5272,7 +6414,15 @@ async function collectToggleMatches(page, label, exact, controlType, contextText
5272
6414
  const style = getComputedStyle(el);
5273
6415
  return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden';
5274
6416
  }
5275
- function referencedText(ids, visited) {
6417
+ function rootScopedElementById(owner, id) {
6418
+ const root = owner.getRootNode();
6419
+ if (root instanceof ShadowRoot)
6420
+ return root.getElementById(id);
6421
+ if (root instanceof Document)
6422
+ return root.getElementById(id);
6423
+ return null;
6424
+ }
6425
+ function referencedText(owner, ids, visited) {
5276
6426
  if (!ids)
5277
6427
  return undefined;
5278
6428
  const seen = visited ?? new Set();
@@ -5280,10 +6430,10 @@ async function collectToggleMatches(page, label, exact, controlType, contextText
5280
6430
  if (seen.has(id))
5281
6431
  return '';
5282
6432
  seen.add(id);
5283
- const node = document.getElementById(id);
6433
+ const node = rootScopedElementById(owner, id);
5284
6434
  if (!node)
5285
6435
  return '';
5286
- return referencedText(node.getAttribute('aria-labelledby'), seen) ?? node.textContent?.trim() ?? '';
6436
+ return referencedText(node, node.getAttribute('aria-labelledby'), seen) ?? node.textContent?.trim() ?? '';
5287
6437
  }).filter(Boolean).join(' ').trim();
5288
6438
  return text || undefined;
5289
6439
  }
@@ -5306,7 +6456,7 @@ async function collectToggleMatches(page, label, exact, controlType, contextText
5306
6456
  const aria = el.getAttribute('aria-label')?.trim();
5307
6457
  if (aria)
5308
6458
  return aria;
5309
- const labelledBy = referencedText(el.getAttribute('aria-labelledby'));
6459
+ const labelledBy = referencedText(el, el.getAttribute('aria-labelledby'));
5310
6460
  if (labelledBy)
5311
6461
  return labelledBy;
5312
6462
  if (el instanceof HTMLInputElement) {
@@ -5336,7 +6486,7 @@ async function collectToggleMatches(page, label, exact, controlType, contextText
5336
6486
  if (!(section instanceof HTMLElement))
5337
6487
  return '';
5338
6488
  const explicit = section.getAttribute('aria-label')?.trim() ||
5339
- referencedText(section.getAttribute('aria-labelledby')) ||
6489
+ referencedText(section, section.getAttribute('aria-labelledby')) ||
5340
6490
  section.querySelector('legend, h1, h2, h3, h4, h5, h6')?.textContent?.trim();
5341
6491
  return (explicit || section.innerText || section.textContent || '').replace(/\s+/g, ' ').trim();
5342
6492
  }
@@ -5419,9 +6569,14 @@ export async function setCheckedControl(page, label, opts) {
5419
6569
  }
5420
6570
  target = matches[0].locator;
5421
6571
  }
6572
+ await assertLocatorMutable(target, 'toggle', 'setChecked');
5422
6573
  const result = await applyToggleState(target, desiredChecked);
5423
- if (result.success)
5424
- return;
6574
+ if (result.success) {
6575
+ await delay(40);
6576
+ const persisted = await target.evaluate((el, desired) => (el instanceof HTMLInputElement ? el.checked : el.getAttribute('aria-checked') === 'true') === desired, desiredChecked).catch(() => false);
6577
+ if (persisted)
6578
+ return;
6579
+ }
5425
6580
  if (result.reason === 'radio-uncheck') {
5426
6581
  throw new Error(`setChecked: radio "${result.name}" cannot be unchecked directly; choose a different option instead`);
5427
6582
  }