@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.
- package/README.md +10 -1
- package/dist/a11y-enrich.d.ts +4 -2
- package/dist/a11y-enrich.d.ts.map +1 -1
- package/dist/a11y-enrich.js +204 -35
- package/dist/a11y-enrich.js.map +1 -1
- package/dist/dom-actions.d.ts +3 -1
- package/dist/dom-actions.d.ts.map +1 -1
- package/dist/dom-actions.js +1483 -328
- package/dist/dom-actions.js.map +1 -1
- package/dist/extractor.d.ts +9 -0
- package/dist/extractor.d.ts.map +1 -1
- package/dist/extractor.js +877 -63
- package/dist/extractor.js.map +1 -1
- package/dist/geometry-ws.d.ts +34 -4
- package/dist/geometry-ws.d.ts.map +1 -1
- package/dist/geometry-ws.js +884 -91
- package/dist/geometry-ws.js.map +1 -1
- package/dist/index.js +10 -2
- package/dist/index.js.map +1 -1
- package/dist/runtime.d.ts +10 -0
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +9 -1
- package/dist/runtime.js.map +1 -1
- package/dist/types.d.ts +47 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +120 -37
- package/dist/types.js.map +1 -1
- package/package.json +3 -3
package/dist/dom-actions.js
CHANGED
|
@@ -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
|
|
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 =
|
|
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
|
|
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 =>
|
|
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 =
|
|
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
|
|
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
|
-
//
|
|
2615
|
-
|
|
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
|
-
|
|
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
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
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
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
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
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
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(
|
|
2947
|
+
function referencedText(owner, ids) {
|
|
2712
2948
|
if (!ids)
|
|
2713
|
-
return
|
|
2714
|
-
|
|
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
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
if (
|
|
2753
|
-
|
|
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
|
-
|
|
2756
|
-
|
|
2757
|
-
return title;
|
|
2758
|
-
return undefined;
|
|
2987
|
+
if (!normalize(nearestContext).includes(contextNeedle))
|
|
2988
|
+
return false;
|
|
2759
2989
|
}
|
|
2760
|
-
|
|
2761
|
-
const el =
|
|
2762
|
-
if (!
|
|
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
|
-
|
|
2765
|
-
|
|
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
|
-
|
|
2768
|
-
|
|
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
|
|
2773
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
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
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
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
|
-
|
|
2801
|
-
|
|
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
|
-
|
|
2804
|
-
|
|
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
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
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
|
-
|
|
2818
|
-
|
|
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
|
-
|
|
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
|
|
3406
|
+
await commitFilesToExactInput(handle, paths, 'matched input');
|
|
2827
3407
|
return true;
|
|
2828
3408
|
}
|
|
2829
|
-
catch {
|
|
2830
|
-
|
|
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
|
-
|
|
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.
|
|
2846
|
-
*
|
|
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
|
-
|
|
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
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
const
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
const
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
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
|
-
|
|
3596
|
+
if (target.matches(':disabled'))
|
|
3597
|
+
return 'disabled';
|
|
2914
3598
|
}
|
|
2915
3599
|
catch { /* ignore */ }
|
|
2916
|
-
target.
|
|
2917
|
-
|
|
2918
|
-
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
|
-
|
|
2922
|
-
|
|
2923
|
-
|
|
2924
|
-
|
|
2925
|
-
|
|
2926
|
-
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
|
|
2931
|
-
|
|
2932
|
-
|
|
2933
|
-
|
|
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
|
-
|
|
2955
|
-
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
|
|
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
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
|
|
4897
|
-
|
|
4898
|
-
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
6173
|
+
}))
|
|
6174
|
+
return true;
|
|
6175
|
+
return false;
|
|
5044
6176
|
}
|
|
5045
|
-
return
|
|
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
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
}
|