@geometra/proxy 1.64.0 → 1.65.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +1466 -325
- 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,355 @@ async function findLabeledFileInputInPage(page, fieldLabel, exact, cache, fieldI
|
|
|
2792
3084
|
writeCachedLocator(cache, 'file', fieldLabel, exact, fieldId, undefined, null);
|
|
2793
3085
|
return null;
|
|
2794
3086
|
}
|
|
2795
|
-
|
|
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) {
|
|
3336
|
+
const { guard, initial } = await beginFileInputCommitGuard(handle);
|
|
3337
|
+
try {
|
|
3338
|
+
if (!initial.validFileInput) {
|
|
3339
|
+
await stopFileInputCommitGuard(guard);
|
|
3340
|
+
throw new Error(`file: ${targetName} detached or is no longer a file input`);
|
|
3341
|
+
}
|
|
3342
|
+
if (initial.blockReason) {
|
|
3343
|
+
await stopFileInputCommitGuard(guard);
|
|
3344
|
+
throw new Error(`file: ${targetName} is not mutable (${initial.blockReason})`);
|
|
3345
|
+
}
|
|
3346
|
+
try {
|
|
3347
|
+
assertPathsMatchFileAccept(paths, initial.accept);
|
|
3348
|
+
}
|
|
3349
|
+
catch (error) {
|
|
3350
|
+
await stopFileInputCommitGuard(guard);
|
|
3351
|
+
throw error;
|
|
3352
|
+
}
|
|
3353
|
+
let operationError;
|
|
3354
|
+
try {
|
|
3355
|
+
await handle.setInputFiles(paths);
|
|
2821
3356
|
}
|
|
3357
|
+
catch (error) {
|
|
3358
|
+
operationError = error;
|
|
3359
|
+
}
|
|
3360
|
+
// Give synchronous handlers, their microtasks, and one timer turn a chance
|
|
3361
|
+
// to reject/clear the selection before calling the outcome confirmed.
|
|
3362
|
+
await delay(40);
|
|
3363
|
+
const final = await finalizeFileInputCommitGuard(guard).catch(() => ({
|
|
3364
|
+
invalidReason: 'the exact target could not be inspected after commit',
|
|
3365
|
+
names: [],
|
|
3366
|
+
}));
|
|
3367
|
+
const retained = uploadedNamesMatch(final.names, expectedUploadNames(paths));
|
|
3368
|
+
if (!operationError && !final.invalidReason && retained)
|
|
3369
|
+
return;
|
|
3370
|
+
const cleanupOutcome = await clearAmbiguousRetainedFiles(handle, final.names, initial.names);
|
|
3371
|
+
const operationReason = operationError instanceof Error
|
|
3372
|
+
? `Playwright could not confirm the exact-target commit: ${operationError.message}`
|
|
3373
|
+
: operationError
|
|
3374
|
+
? 'Playwright could not confirm the exact-target commit'
|
|
3375
|
+
: null;
|
|
3376
|
+
const reason = final.invalidReason ?? operationReason ?? 'the exact target did not retain the supplied filenames';
|
|
3377
|
+
throw new FileUploadOutcomeAmbiguousError(reason, cleanupOutcome);
|
|
3378
|
+
}
|
|
3379
|
+
finally {
|
|
3380
|
+
await guard.dispose().catch(() => { });
|
|
3381
|
+
}
|
|
3382
|
+
}
|
|
3383
|
+
async function attachHiddenInAllFrames(page, paths, opts) {
|
|
3384
|
+
if (opts?.fieldLabel || opts?.fieldKey || opts?.contextText || opts?.sectionText) {
|
|
3385
|
+
const labeled = await findLabeledFileInputInPage(page, opts.fieldLabel ?? '', opts.exact ?? false, opts.cache, opts.fieldId, opts.fieldKey, opts.contextText, opts.sectionText);
|
|
3386
|
+
if (labeled) {
|
|
3387
|
+
const handle = await labeled.elementHandle();
|
|
3388
|
+
if (!handle)
|
|
3389
|
+
throw new Error('file: matched input detached before upload');
|
|
3390
|
+
await commitFilesToExactInput(handle, paths, 'matched input');
|
|
3391
|
+
return true;
|
|
3392
|
+
}
|
|
3393
|
+
return false;
|
|
3394
|
+
}
|
|
3395
|
+
let acceptError;
|
|
3396
|
+
for (const frame of page.frames()) {
|
|
2822
3397
|
const loc = frame.locator('input[type="file"]');
|
|
2823
3398
|
const n = await loc.count();
|
|
2824
3399
|
for (let i = 0; i < n; i++) {
|
|
3400
|
+
if (await locatorMutationBlockReason(loc.nth(i), 'file'))
|
|
3401
|
+
continue;
|
|
3402
|
+
const handle = await loc.nth(i).elementHandle();
|
|
3403
|
+
if (!handle)
|
|
3404
|
+
continue;
|
|
2825
3405
|
try {
|
|
2826
|
-
await
|
|
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
|
+
await commitFilesToExactInput(input, paths, 'chooser input');
|
|
2842
3430
|
}
|
|
2843
3431
|
/**
|
|
2844
3432
|
* Map common file extensions to MIME types so react-dropzone's `accept` filter
|
|
2845
|
-
* does not silently drop our synthetic files.
|
|
2846
|
-
*
|
|
3433
|
+
* does not silently drop our synthetic files. A null result means MIME cannot
|
|
3434
|
+
* be inferred reliably from the path and must not be used to reject a MIME
|
|
3435
|
+
* accept contract as though application/octet-stream were authoritative.
|
|
2847
3436
|
*/
|
|
2848
3437
|
function mimeTypeForPath(path) {
|
|
2849
3438
|
const ext = path.toLowerCase().split('.').pop() ?? '';
|
|
@@ -2851,7 +3440,20 @@ function mimeTypeForPath(path) {
|
|
|
2851
3440
|
case 'pdf': return 'application/pdf';
|
|
2852
3441
|
case 'doc': return 'application/msword';
|
|
2853
3442
|
case 'docx': return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
|
|
3443
|
+
case 'xls': return 'application/vnd.ms-excel';
|
|
3444
|
+
case 'xlsx': return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
|
3445
|
+
case 'ppt': return 'application/vnd.ms-powerpoint';
|
|
3446
|
+
case 'pptx': return 'application/vnd.openxmlformats-officedocument.presentationml.presentation';
|
|
3447
|
+
case 'json': return 'application/json';
|
|
3448
|
+
case 'xml': return 'application/xml';
|
|
3449
|
+
case 'zip': return 'application/zip';
|
|
3450
|
+
case 'gz': return 'application/gzip';
|
|
3451
|
+
case 'tar': return 'application/x-tar';
|
|
3452
|
+
case '7z': return 'application/x-7z-compressed';
|
|
2854
3453
|
case 'txt': return 'text/plain';
|
|
3454
|
+
case 'csv': return 'text/csv';
|
|
3455
|
+
case 'htm':
|
|
3456
|
+
case 'html': return 'text/html';
|
|
2855
3457
|
case 'rtf': return 'application/rtf';
|
|
2856
3458
|
case 'png': return 'image/png';
|
|
2857
3459
|
case 'jpg':
|
|
@@ -2859,9 +3461,76 @@ function mimeTypeForPath(path) {
|
|
|
2859
3461
|
case 'gif': return 'image/gif';
|
|
2860
3462
|
case 'webp': return 'image/webp';
|
|
2861
3463
|
case 'svg': return 'image/svg+xml';
|
|
2862
|
-
|
|
3464
|
+
case 'avif': return 'image/avif';
|
|
3465
|
+
case 'bmp': return 'image/bmp';
|
|
3466
|
+
case 'tif':
|
|
3467
|
+
case 'tiff': return 'image/tiff';
|
|
3468
|
+
case 'mp3': return 'audio/mpeg';
|
|
3469
|
+
case 'm4a': return 'audio/mp4';
|
|
3470
|
+
case 'wav': return 'audio/wav';
|
|
3471
|
+
case 'ogg': return 'audio/ogg';
|
|
3472
|
+
case 'mp4': return 'video/mp4';
|
|
3473
|
+
case 'mov': return 'video/quicktime';
|
|
3474
|
+
case 'webm': return 'video/webm';
|
|
3475
|
+
default: return null;
|
|
2863
3476
|
}
|
|
2864
3477
|
}
|
|
3478
|
+
async function dropFileInputAtPoint(page, x, y) {
|
|
3479
|
+
const handle = await page.mainFrame().evaluateHandle(({ x: clientX, y: clientY }) => {
|
|
3480
|
+
const deepest = document.elementFromPoint(clientX, clientY);
|
|
3481
|
+
if (!deepest)
|
|
3482
|
+
return null;
|
|
3483
|
+
function composedParent(element) {
|
|
3484
|
+
if (element.parentElement)
|
|
3485
|
+
return element.parentElement;
|
|
3486
|
+
const root = element.getRootNode();
|
|
3487
|
+
return root instanceof ShadowRoot ? root.host : null;
|
|
3488
|
+
}
|
|
3489
|
+
function isExplicitLocalBoundary(element) {
|
|
3490
|
+
const tag = element.tagName.toLowerCase();
|
|
3491
|
+
const className = typeof element.className === 'string' ? element.className : '';
|
|
3492
|
+
const testId = element.getAttribute('data-testid') ?? '';
|
|
3493
|
+
const buttonText = `${element.getAttribute('aria-label') ?? ''} ${element.textContent ?? ''}`;
|
|
3494
|
+
return tag === 'label' ||
|
|
3495
|
+
element.hasAttribute('data-dropzone') ||
|
|
3496
|
+
element.getAttributeNames().some(attribute => attribute.startsWith('data-rfd')) ||
|
|
3497
|
+
/drop[-_]?zone|file[-_]?upload|attach|upload/i.test(className) ||
|
|
3498
|
+
/drop|file|upload|attach/i.test(testId) ||
|
|
3499
|
+
(element.getAttribute('role') === 'button' && /drop|file|upload|attach/i.test(buttonText));
|
|
3500
|
+
}
|
|
3501
|
+
function isBroadContainer(element) {
|
|
3502
|
+
return element.matches('form, [role="form"], fieldset, section');
|
|
3503
|
+
}
|
|
3504
|
+
let current = deepest;
|
|
3505
|
+
let associationClosed = false;
|
|
3506
|
+
for (let depth = 0; current && depth < 16; depth++, current = composedParent(current)) {
|
|
3507
|
+
if (current instanceof HTMLInputElement && current.type === 'file')
|
|
3508
|
+
return current;
|
|
3509
|
+
// Do not infer a relationship from an unrelated page-global input.
|
|
3510
|
+
if (current === document.body || current === document.documentElement)
|
|
3511
|
+
break;
|
|
3512
|
+
const inputs = current.querySelectorAll('input[type="file"]');
|
|
3513
|
+
if (inputs.length > 1) {
|
|
3514
|
+
throw new Error(`file: coordinate-only drop target is ambiguous; nearest candidate container has ${inputs.length} file inputs`);
|
|
3515
|
+
}
|
|
3516
|
+
const input = inputs[0];
|
|
3517
|
+
const explicitBoundary = isExplicitLocalBoundary(current);
|
|
3518
|
+
const broadContainer = isBroadContainer(current);
|
|
3519
|
+
if (input instanceof HTMLInputElement &&
|
|
3520
|
+
!associationClosed &&
|
|
3521
|
+
explicitBoundary)
|
|
3522
|
+
return input;
|
|
3523
|
+
if (explicitBoundary || broadContainer)
|
|
3524
|
+
associationClosed = true;
|
|
3525
|
+
}
|
|
3526
|
+
return null;
|
|
3527
|
+
}, { x, y });
|
|
3528
|
+
const element = handle.asElement();
|
|
3529
|
+
if (element)
|
|
3530
|
+
return element;
|
|
3531
|
+
await handle.dispose();
|
|
3532
|
+
return null;
|
|
3533
|
+
}
|
|
2865
3534
|
/**
|
|
2866
3535
|
* Synthetic drop at (x,y) using file bytes from the proxy host.
|
|
2867
3536
|
*
|
|
@@ -2879,91 +3548,260 @@ function mimeTypeForPath(path) {
|
|
|
2879
3548
|
* button layer and the wrapper that actually has the listeners.
|
|
2880
3549
|
*/
|
|
2881
3550
|
async function attachViaDropPlaywright(page, paths, dropX, dropY) {
|
|
2882
|
-
const
|
|
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
|
-
|
|
3551
|
+
const dropFileInput = await dropFileInputAtPoint(page, dropX, dropY);
|
|
3552
|
+
try {
|
|
3553
|
+
// A real scoped input establishes the drop contract. Validate before
|
|
3554
|
+
// mouse movement, drag events, input assignment, or application callbacks.
|
|
3555
|
+
// Pure dropzones without an input retain their framework-defined behavior.
|
|
3556
|
+
const observedAccept = dropFileInput ? await handleFileAccept(dropFileInput) : null;
|
|
3557
|
+
if (dropFileInput) {
|
|
3558
|
+
const inputBlockReason = await dropFileInput.evaluate(mutationBlockReasonInPage, 'file');
|
|
3559
|
+
if (inputBlockReason) {
|
|
3560
|
+
throw new Error(`file: associated drop input is not mutable (${inputBlockReason})`);
|
|
3561
|
+
}
|
|
3562
|
+
}
|
|
3563
|
+
assertPathsMatchFileAccept(paths, observedAccept);
|
|
3564
|
+
const fs = await import('node:fs/promises');
|
|
3565
|
+
const buffers = await Promise.all(paths.map(p => fs.readFile(p)));
|
|
3566
|
+
const names = paths.map(p => p.split(/[/\\\\]/).pop() ?? 'file');
|
|
3567
|
+
const mimes = paths.map(path => mimeTypeForPath(path) ?? 'application/octet-stream');
|
|
3568
|
+
await page.mouse.move(dropX, dropY);
|
|
3569
|
+
const result = await page.mainFrame().evaluate(async ({ bufs, ns, ms, x, y, expectedAccept, expectedFileInput }) => {
|
|
3570
|
+
function blockReason(target) {
|
|
3571
|
+
function parent(node) {
|
|
3572
|
+
if (node.parentElement)
|
|
3573
|
+
return node.parentElement;
|
|
3574
|
+
const root = node.getRootNode();
|
|
3575
|
+
return root instanceof ShadowRoot ? root.host : null;
|
|
3576
|
+
}
|
|
3577
|
+
function within(node, ancestor) {
|
|
3578
|
+
let current = node;
|
|
3579
|
+
while (current) {
|
|
3580
|
+
if (current === ancestor)
|
|
3581
|
+
return true;
|
|
3582
|
+
current = parent(current);
|
|
3583
|
+
}
|
|
3584
|
+
return false;
|
|
3585
|
+
}
|
|
2912
3586
|
try {
|
|
2913
|
-
|
|
3587
|
+
if (target.matches(':disabled'))
|
|
3588
|
+
return 'disabled';
|
|
2914
3589
|
}
|
|
2915
3590
|
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 });
|
|
3591
|
+
if ((target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) && target.readOnly)
|
|
3592
|
+
return 'readonly';
|
|
3593
|
+
let current = target;
|
|
3594
|
+
while (current) {
|
|
3595
|
+
if (current.hasAttribute('inert'))
|
|
3596
|
+
return 'inert';
|
|
3597
|
+
if (current.getAttribute('aria-disabled')?.trim().toLowerCase() === 'true')
|
|
3598
|
+
return 'aria-disabled';
|
|
3599
|
+
if (current.getAttribute('aria-readonly')?.trim().toLowerCase() === 'true')
|
|
3600
|
+
return 'aria-readonly';
|
|
3601
|
+
if (current instanceof HTMLFieldSetElement && current.disabled) {
|
|
3602
|
+
const legend = Array.from(current.children).find(child => child instanceof HTMLLegendElement);
|
|
3603
|
+
if (!(legend instanceof HTMLLegendElement) || !within(target, legend))
|
|
3604
|
+
return 'disabled fieldset';
|
|
3605
|
+
}
|
|
3606
|
+
current = parent(current);
|
|
3607
|
+
}
|
|
3608
|
+
return null;
|
|
2953
3609
|
}
|
|
2954
|
-
|
|
2955
|
-
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
|
|
3610
|
+
const makeDataTransfer = () => {
|
|
3611
|
+
const dt = new DataTransfer();
|
|
3612
|
+
for (let i = 0; i < bufs.length; i++) {
|
|
3613
|
+
const u8 = new Uint8Array(bufs[i]);
|
|
3614
|
+
const blob = new Blob([u8], { type: ms[i] });
|
|
3615
|
+
dt.items.add(new File([blob], ns[i], { type: ms[i] }));
|
|
3616
|
+
}
|
|
3617
|
+
return dt;
|
|
3618
|
+
};
|
|
3619
|
+
const dispatchSequence = (target) => {
|
|
3620
|
+
let handled = false;
|
|
3621
|
+
// Each event needs its own DataTransfer because some frameworks
|
|
3622
|
+
// consume the items list during dragenter/dragover inspection.
|
|
3623
|
+
for (const type of ['dragenter', 'dragover', 'drop']) {
|
|
3624
|
+
const dt = makeDataTransfer();
|
|
3625
|
+
const ev = new DragEvent(type, {
|
|
3626
|
+
bubbles: true,
|
|
3627
|
+
cancelable: true,
|
|
3628
|
+
composed: true,
|
|
3629
|
+
clientX: x,
|
|
3630
|
+
clientY: y,
|
|
3631
|
+
dataTransfer: dt,
|
|
3632
|
+
});
|
|
3633
|
+
try {
|
|
3634
|
+
Object.defineProperty(ev, 'dataTransfer', { value: dt, configurable: true });
|
|
3635
|
+
}
|
|
3636
|
+
catch { /* ignore */ }
|
|
3637
|
+
const dispatched = target.dispatchEvent(ev);
|
|
3638
|
+
if (!dispatched || ev.defaultPrevented)
|
|
3639
|
+
handled = true;
|
|
3640
|
+
}
|
|
3641
|
+
return handled;
|
|
3642
|
+
};
|
|
3643
|
+
const deepest = document.elementFromPoint(x, y);
|
|
3644
|
+
if (!deepest) {
|
|
3645
|
+
return { accepted: false, blockReason: null, acceptContractChanged: false, ambiguousTarget: false };
|
|
3646
|
+
}
|
|
3647
|
+
const blocked = blockReason(deepest);
|
|
3648
|
+
if (blocked) {
|
|
3649
|
+
return { accepted: false, blockReason: blocked, acceptContractChanged: false, ambiguousTarget: false };
|
|
3650
|
+
}
|
|
3651
|
+
function resolveFileInput(start) {
|
|
3652
|
+
function composedParent(element) {
|
|
3653
|
+
if (element.parentElement)
|
|
3654
|
+
return element.parentElement;
|
|
3655
|
+
const root = element.getRootNode();
|
|
3656
|
+
return root instanceof ShadowRoot ? root.host : null;
|
|
3657
|
+
}
|
|
3658
|
+
function isExplicitLocalBoundary(element) {
|
|
3659
|
+
const tag = element.tagName.toLowerCase();
|
|
3660
|
+
const className = typeof element.className === 'string' ? element.className : '';
|
|
3661
|
+
const testId = element.getAttribute('data-testid') ?? '';
|
|
3662
|
+
const buttonText = `${element.getAttribute('aria-label') ?? ''} ${element.textContent ?? ''}`;
|
|
3663
|
+
return tag === 'label' ||
|
|
3664
|
+
element.hasAttribute('data-dropzone') ||
|
|
3665
|
+
element.getAttributeNames().some(attribute => attribute.startsWith('data-rfd')) ||
|
|
3666
|
+
/drop[-_]?zone|file[-_]?upload|attach|upload/i.test(className) ||
|
|
3667
|
+
/drop|file|upload|attach/i.test(testId) ||
|
|
3668
|
+
(element.getAttribute('role') === 'button' && /drop|file|upload|attach/i.test(buttonText));
|
|
3669
|
+
}
|
|
3670
|
+
function isBroadContainer(element) {
|
|
3671
|
+
return element.matches('form, [role="form"], fieldset, section');
|
|
3672
|
+
}
|
|
3673
|
+
let current = start;
|
|
3674
|
+
let associationClosed = false;
|
|
3675
|
+
for (let depth = 0; current && depth < 16; depth++, current = composedParent(current)) {
|
|
3676
|
+
if (current instanceof HTMLInputElement && current.type === 'file') {
|
|
3677
|
+
return { input: current, host: current, ambiguous: false };
|
|
3678
|
+
}
|
|
3679
|
+
if (current === document.body || current === document.documentElement)
|
|
3680
|
+
break;
|
|
3681
|
+
const inputs = current.querySelectorAll('input[type="file"]');
|
|
3682
|
+
if (inputs.length > 1)
|
|
3683
|
+
return { input: null, host: current, ambiguous: true };
|
|
3684
|
+
const input = inputs[0];
|
|
3685
|
+
const explicitBoundary = isExplicitLocalBoundary(current);
|
|
3686
|
+
const broadContainer = isBroadContainer(current);
|
|
3687
|
+
if (input instanceof HTMLInputElement &&
|
|
3688
|
+
!associationClosed &&
|
|
3689
|
+
explicitBoundary)
|
|
3690
|
+
return { input, host: current, ambiguous: false };
|
|
3691
|
+
if (explicitBoundary || broadContainer)
|
|
3692
|
+
associationClosed = true;
|
|
3693
|
+
}
|
|
3694
|
+
return {
|
|
3695
|
+
input: null,
|
|
3696
|
+
host: start.closest('form, [role="form"], fieldset, section, div') ?? document.body,
|
|
3697
|
+
ambiguous: false,
|
|
3698
|
+
};
|
|
3699
|
+
}
|
|
3700
|
+
const targets = [deepest];
|
|
3701
|
+
let p = deepest.parentElement;
|
|
3702
|
+
for (let depth = 0; depth < 12 && p; depth++) {
|
|
3703
|
+
const tag = p.tagName.toLowerCase();
|
|
3704
|
+
const attrs = p.getAttributeNames();
|
|
3705
|
+
const looksLikeDropzone = attrs.some(a => a.startsWith('data-rfd') || a === 'data-dropzone' || a === 'data-testid') ||
|
|
3706
|
+
(p.className && typeof p.className === 'string' && /drop[-_]?zone|file[-_]?upload|attach|upload/i.test(p.className)) ||
|
|
3707
|
+
tag === 'label' ||
|
|
3708
|
+
p.getAttribute('role') === 'button';
|
|
3709
|
+
if (looksLikeDropzone && !targets.includes(p))
|
|
3710
|
+
targets.push(p);
|
|
3711
|
+
p = p.parentElement;
|
|
3712
|
+
}
|
|
3713
|
+
const resolvedTarget = resolveFileInput(deepest);
|
|
3714
|
+
if (resolvedTarget.ambiguous) {
|
|
3715
|
+
return { accepted: false, blockReason: null, acceptContractChanged: false, ambiguousTarget: true };
|
|
3716
|
+
}
|
|
3717
|
+
const host = resolvedTarget.host;
|
|
3718
|
+
const fileInput = resolvedTarget.input;
|
|
3719
|
+
if (fileInput !== expectedFileInput) {
|
|
3720
|
+
return { accepted: false, blockReason: null, acceptContractChanged: true, ambiguousTarget: false };
|
|
3721
|
+
}
|
|
3722
|
+
const currentAccept = fileInput?.getAttribute('accept') ?? null;
|
|
3723
|
+
if (currentAccept !== expectedAccept) {
|
|
3724
|
+
return { accepted: false, blockReason: null, acceptContractChanged: true, ambiguousTarget: false };
|
|
3725
|
+
}
|
|
3726
|
+
if (fileInput) {
|
|
3727
|
+
const inputBlockReason = blockReason(fileInput);
|
|
3728
|
+
if (inputBlockReason) {
|
|
3729
|
+
throw new Error(`file: associated drop input is not mutable (${inputBlockReason})`);
|
|
3730
|
+
}
|
|
2961
3731
|
}
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
3732
|
+
const beforeFileNames = Array.from(fileInput?.files ?? []).map(file => file.name);
|
|
3733
|
+
const beforeRenderedText = [host, ...targets]
|
|
3734
|
+
.map(node => node.textContent ?? '')
|
|
3735
|
+
.join(' ')
|
|
3736
|
+
.toLowerCase();
|
|
3737
|
+
let handled = false;
|
|
3738
|
+
for (const target of targets) {
|
|
3739
|
+
if (!blockReason(target))
|
|
3740
|
+
handled = dispatchSequence(target) || handled;
|
|
3741
|
+
}
|
|
3742
|
+
// A scoped file input can provide strong acceptance evidence, but only
|
|
3743
|
+
// after application handlers have had a chance to reject/clear it.
|
|
3744
|
+
if (fileInput && !blockReason(fileInput)) {
|
|
3745
|
+
const dt = makeDataTransfer();
|
|
3746
|
+
try {
|
|
3747
|
+
Object.defineProperty(fileInput, 'files', { value: dt.files, configurable: true });
|
|
3748
|
+
}
|
|
3749
|
+
catch { /* ignore */ }
|
|
3750
|
+
try {
|
|
3751
|
+
const proto = Object.getPrototypeOf(fileInput);
|
|
3752
|
+
const descriptor = Object.getOwnPropertyDescriptor(proto, 'value');
|
|
3753
|
+
descriptor?.set?.call(fileInput, '');
|
|
3754
|
+
}
|
|
3755
|
+
catch { /* ignore */ }
|
|
3756
|
+
fileInput.dispatchEvent(new Event('input', { bubbles: true }));
|
|
3757
|
+
fileInput.dispatchEvent(new Event('change', { bubbles: true }));
|
|
3758
|
+
}
|
|
3759
|
+
// Framework drop handlers commonly commit on a microtask or animation
|
|
3760
|
+
// frame. Only report success after observable page state contains every
|
|
3761
|
+
// submitted filename; canceling dragover alone is not acceptance.
|
|
3762
|
+
await Promise.resolve();
|
|
3763
|
+
await new Promise(resolveTimer => setTimeout(resolveTimer, 32));
|
|
3764
|
+
const settledFileNames = Array.from(fileInput?.files ?? []).map(file => file.name);
|
|
3765
|
+
const fileInputAccepted = Boolean(fileInput && !blockReason(fileInput)) &&
|
|
3766
|
+
ns.every(name => settledFileNames.includes(name)) &&
|
|
3767
|
+
(settledFileNames.length !== beforeFileNames.length || settledFileNames.some((name, index) => name !== beforeFileNames[index]));
|
|
3768
|
+
const renderedText = [host, ...targets]
|
|
3769
|
+
.map(node => node.textContent ?? '')
|
|
3770
|
+
.join(' ')
|
|
3771
|
+
.toLowerCase();
|
|
3772
|
+
const renderedAcceptance = renderedText !== beforeRenderedText &&
|
|
3773
|
+
ns.every(name => renderedText.includes(name.toLowerCase()));
|
|
3774
|
+
return {
|
|
3775
|
+
accepted: fileInputAccepted || (handled && renderedAcceptance),
|
|
3776
|
+
blockReason: null,
|
|
3777
|
+
acceptContractChanged: false,
|
|
3778
|
+
ambiguousTarget: false,
|
|
3779
|
+
};
|
|
3780
|
+
}, {
|
|
3781
|
+
bufs: buffers.map(b => Array.from(b)),
|
|
3782
|
+
ns: names,
|
|
3783
|
+
ms: mimes,
|
|
3784
|
+
x: dropX,
|
|
3785
|
+
y: dropY,
|
|
3786
|
+
expectedAccept: observedAccept,
|
|
3787
|
+
expectedFileInput: dropFileInput,
|
|
3788
|
+
});
|
|
3789
|
+
if (result.ambiguousTarget) {
|
|
3790
|
+
throw new Error('file: coordinate-only drop target became ambiguous between multiple file inputs');
|
|
3791
|
+
}
|
|
3792
|
+
if (result.acceptContractChanged) {
|
|
3793
|
+
throw new Error('file: drop target accept contract changed before upload; retry with a fresh target');
|
|
2965
3794
|
}
|
|
2966
|
-
|
|
3795
|
+
if (result.blockReason) {
|
|
3796
|
+
throw new Error(`file: drop target is not mutable (${result.blockReason})`);
|
|
3797
|
+
}
|
|
3798
|
+
if (!result.accepted) {
|
|
3799
|
+
throw new Error('file: drop target did not accept the supplied files');
|
|
3800
|
+
}
|
|
3801
|
+
}
|
|
3802
|
+
finally {
|
|
3803
|
+
await dropFileInput?.dispose().catch(() => { });
|
|
3804
|
+
}
|
|
2967
3805
|
}
|
|
2968
3806
|
/**
|
|
2969
3807
|
* `strategy`:
|
|
@@ -2977,7 +3815,8 @@ const ATTACH_BUTTON_PATTERN = /attach|upload|add.file|choose.file|browse/i;
|
|
|
2977
3815
|
* Find an Attach/Upload button near a field label (e.g. Greenhouse's "Attach" button
|
|
2978
3816
|
* inside a "Resume/CV" section). Returns the button Locator or null.
|
|
2979
3817
|
*/
|
|
2980
|
-
async function findAttachButtonNearLabel(page, fieldLabel) {
|
|
3818
|
+
async function findAttachButtonNearLabel(page, fieldLabel, contextText, sectionText) {
|
|
3819
|
+
const matches = [];
|
|
2981
3820
|
for (const frame of page.frames()) {
|
|
2982
3821
|
// Find all buttons/links whose text matches attach-like patterns
|
|
2983
3822
|
const buttons = frame.locator('button, a, [role="button"]');
|
|
@@ -3001,15 +3840,18 @@ async function findAttachButtonNearLabel(page, fieldLabel) {
|
|
|
3001
3840
|
}
|
|
3002
3841
|
return false;
|
|
3003
3842
|
}, fieldLabel);
|
|
3004
|
-
if (nearLabel)
|
|
3005
|
-
|
|
3843
|
+
if (nearLabel && await locatorMatchesFileScope(btn, contextText, sectionText))
|
|
3844
|
+
matches.push(btn);
|
|
3006
3845
|
}
|
|
3007
3846
|
catch {
|
|
3008
3847
|
continue;
|
|
3009
3848
|
}
|
|
3010
3849
|
}
|
|
3011
3850
|
}
|
|
3012
|
-
|
|
3851
|
+
if (matches.length > 1) {
|
|
3852
|
+
throw new Error(`file: ambiguous Attach/Upload control for field "${fieldLabel}"; provide a more specific contextText or sectionText`);
|
|
3853
|
+
}
|
|
3854
|
+
return matches[0] ?? null;
|
|
3013
3855
|
}
|
|
3014
3856
|
export async function attachFiles(page, paths, opts) {
|
|
3015
3857
|
const strategy = opts?.strategy ?? 'auto';
|
|
@@ -3019,6 +3861,51 @@ export async function attachFiles(page, paths, opts) {
|
|
|
3019
3861
|
const exact = opts?.exact ?? false;
|
|
3020
3862
|
const dropX = opts?.dropX;
|
|
3021
3863
|
const dropY = opts?.dropY;
|
|
3864
|
+
const hasClickX = clickX !== undefined;
|
|
3865
|
+
const hasClickY = clickY !== undefined;
|
|
3866
|
+
const hasDropX = dropX !== undefined;
|
|
3867
|
+
const hasDropY = dropY !== undefined;
|
|
3868
|
+
const hasClick = hasClickX && hasClickY;
|
|
3869
|
+
const hasDrop = hasDropX && hasDropY;
|
|
3870
|
+
const hasFieldLabel = Boolean(fieldLabel?.trim());
|
|
3871
|
+
const hasFieldKey = Boolean(opts?.fieldKey);
|
|
3872
|
+
const hasSemanticTarget = hasFieldLabel || hasFieldKey;
|
|
3873
|
+
const hasSemanticExtras = Boolean(opts?.fieldId || opts?.fieldKey || fieldLabel || opts?.contextText || opts?.sectionText || opts?.exact !== undefined);
|
|
3874
|
+
if (hasClickX !== hasClickY)
|
|
3875
|
+
throw new Error('file: clickX and clickY must be provided together');
|
|
3876
|
+
if (hasDropX !== hasDropY)
|
|
3877
|
+
throw new Error('file: dropX and dropY must be provided together');
|
|
3878
|
+
if ((opts?.contextText || opts?.sectionText || opts?.exact !== undefined) && !hasFieldLabel) {
|
|
3879
|
+
throw new Error('file: contextText, sectionText, and exact require fieldLabel');
|
|
3880
|
+
}
|
|
3881
|
+
if (opts?.fieldId && !hasSemanticTarget) {
|
|
3882
|
+
throw new Error('file: fieldId requires fieldLabel or fieldKey');
|
|
3883
|
+
}
|
|
3884
|
+
if (strategy === 'chooser') {
|
|
3885
|
+
if (!hasClick)
|
|
3886
|
+
throw new Error('file: chooser strategy requires x,y click coordinates');
|
|
3887
|
+
if (hasDrop || hasSemanticExtras)
|
|
3888
|
+
throw new Error('file: chooser strategy accepts only a coordinate target');
|
|
3889
|
+
}
|
|
3890
|
+
else if (strategy === 'drop') {
|
|
3891
|
+
if (!hasDrop)
|
|
3892
|
+
throw new Error('file: drop strategy requires dropX, dropY');
|
|
3893
|
+
if (hasClick || hasSemanticExtras)
|
|
3894
|
+
throw new Error('file: drop strategy accepts only a drop coordinate target');
|
|
3895
|
+
}
|
|
3896
|
+
else if (strategy === 'hidden') {
|
|
3897
|
+
if (!hasSemanticTarget)
|
|
3898
|
+
throw new Error('file: hidden strategy requires fieldLabel or fieldKey');
|
|
3899
|
+
if (hasClick || hasDrop)
|
|
3900
|
+
throw new Error('file: hidden strategy does not accept coordinates');
|
|
3901
|
+
}
|
|
3902
|
+
else {
|
|
3903
|
+
if (hasDrop)
|
|
3904
|
+
throw new Error('file: dropX/dropY require strategy "drop"');
|
|
3905
|
+
if (hasClick === hasSemanticTarget) {
|
|
3906
|
+
throw new Error('file: auto strategy requires exactly one explicit target: click coordinates, fieldLabel, or fieldKey');
|
|
3907
|
+
}
|
|
3908
|
+
}
|
|
3022
3909
|
if (strategy === 'chooser' || (strategy === 'auto' && clickX !== undefined && clickY !== undefined)) {
|
|
3023
3910
|
if (clickX === undefined || clickY === undefined) {
|
|
3024
3911
|
throw new Error('file: chooser strategy requires x,y click coordinates');
|
|
@@ -3032,6 +3919,8 @@ export async function attachFiles(page, paths, opts) {
|
|
|
3032
3919
|
fieldKey: opts?.fieldKey,
|
|
3033
3920
|
fieldLabel,
|
|
3034
3921
|
exact,
|
|
3922
|
+
contextText: opts?.contextText,
|
|
3923
|
+
sectionText: opts?.sectionText,
|
|
3035
3924
|
cache: opts?.cache,
|
|
3036
3925
|
}))
|
|
3037
3926
|
return;
|
|
@@ -3043,9 +3932,10 @@ export async function attachFiles(page, paths, opts) {
|
|
|
3043
3932
|
}
|
|
3044
3933
|
// Fallback: look for an Attach/Upload button near the field label and use the file chooser
|
|
3045
3934
|
if (fieldLabel && strategy === 'auto') {
|
|
3046
|
-
const attachBtn = await findAttachButtonNearLabel(page, fieldLabel);
|
|
3935
|
+
const attachBtn = await findAttachButtonNearLabel(page, fieldLabel, opts?.contextText, opts?.sectionText);
|
|
3047
3936
|
if (attachBtn) {
|
|
3048
3937
|
try {
|
|
3938
|
+
await assertLocatorMutable(attachBtn, 'file', 'file');
|
|
3049
3939
|
await attachBtn.scrollIntoViewIfNeeded();
|
|
3050
3940
|
const box = await attachBtn.boundingBox();
|
|
3051
3941
|
if (box) {
|
|
@@ -3059,6 +3949,12 @@ export async function attachFiles(page, paths, opts) {
|
|
|
3059
3949
|
if (fieldLabel) {
|
|
3060
3950
|
throw new Error(`file: no input[type=file] matching field "${fieldLabel}"`);
|
|
3061
3951
|
}
|
|
3952
|
+
if (opts?.fieldKey) {
|
|
3953
|
+
throw new Error(`file: no input[type=file] matching fieldKey "${opts.fieldKey}"`);
|
|
3954
|
+
}
|
|
3955
|
+
if (opts?.contextText || opts?.sectionText) {
|
|
3956
|
+
throw new Error('file: no input[type=file] matched the requested context/section');
|
|
3957
|
+
}
|
|
3062
3958
|
}
|
|
3063
3959
|
if (strategy === 'drop' || (strategy === 'auto' && dropX !== undefined && dropY !== undefined)) {
|
|
3064
3960
|
if (dropX === undefined || dropY === undefined) {
|
|
@@ -3067,15 +3963,7 @@ export async function attachFiles(page, paths, opts) {
|
|
|
3067
3963
|
await attachViaDropPlaywright(page, paths, dropX, dropY);
|
|
3068
3964
|
return;
|
|
3069
3965
|
}
|
|
3070
|
-
|
|
3071
|
-
const loc = frame.locator('input[type="file"]');
|
|
3072
|
-
const n = await loc.count();
|
|
3073
|
-
if (n > 0) {
|
|
3074
|
-
await loc.first().setInputFiles(paths);
|
|
3075
|
-
return;
|
|
3076
|
-
}
|
|
3077
|
-
}
|
|
3078
|
-
throw new Error('file: no input[type=file] in any frame; pass x,y (chooser), dropX/dropY (drop), or strategy hidden');
|
|
3966
|
+
throw new Error('file: explicit upload target did not accept the supplied files');
|
|
3079
3967
|
}
|
|
3080
3968
|
async function findLabeledEditableField(page, fieldLabel, exact, cache, fieldId, fieldKey) {
|
|
3081
3969
|
const cached = await readCachedLocator(cache, 'editable', fieldLabel, exact, fieldId, fieldKey);
|
|
@@ -3204,13 +4092,63 @@ function fieldValueMatches(actual, expected) {
|
|
|
3204
4092
|
const normalizedExpected = normalizedFieldValue(expected);
|
|
3205
4093
|
if (!normalizedActual || !normalizedExpected)
|
|
3206
4094
|
return false;
|
|
3207
|
-
return normalizedActual === normalizedExpected
|
|
4095
|
+
return normalizedActual === normalizedExpected;
|
|
3208
4096
|
}
|
|
3209
4097
|
async function setLocatorTextValue(locator, value, opts) {
|
|
4098
|
+
if (await locatorMutationBlockReason(locator, 'text'))
|
|
4099
|
+
return false;
|
|
3210
4100
|
try {
|
|
3211
4101
|
return await locator.evaluate((el, payload) => {
|
|
3212
4102
|
const nextValue = payload.value;
|
|
3213
4103
|
const imeFriendly = payload.imeFriendly;
|
|
4104
|
+
// Preflight and commit are separate Playwright round-trips. Reactive
|
|
4105
|
+
// applications can make a control readonly/disabled in between, so the
|
|
4106
|
+
// final guard must live in the same page task as the first mutation.
|
|
4107
|
+
function commitMutationBlockReason(target) {
|
|
4108
|
+
function composedParent(node) {
|
|
4109
|
+
if (node.parentElement)
|
|
4110
|
+
return node.parentElement;
|
|
4111
|
+
const root = node.getRootNode();
|
|
4112
|
+
return root instanceof ShadowRoot ? root.host : null;
|
|
4113
|
+
}
|
|
4114
|
+
function composedDescendantOf(node, ancestor) {
|
|
4115
|
+
let current = node;
|
|
4116
|
+
while (current) {
|
|
4117
|
+
if (current === ancestor)
|
|
4118
|
+
return true;
|
|
4119
|
+
current = composedParent(current);
|
|
4120
|
+
}
|
|
4121
|
+
return false;
|
|
4122
|
+
}
|
|
4123
|
+
try {
|
|
4124
|
+
if (target.matches(':disabled'))
|
|
4125
|
+
return 'disabled';
|
|
4126
|
+
}
|
|
4127
|
+
catch { /* non-HTML namespaces may reject pseudo classes */ }
|
|
4128
|
+
if ((target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) &&
|
|
4129
|
+
target.readOnly) {
|
|
4130
|
+
return 'readonly';
|
|
4131
|
+
}
|
|
4132
|
+
let current = target;
|
|
4133
|
+
while (current) {
|
|
4134
|
+
if (current.hasAttribute('inert'))
|
|
4135
|
+
return 'inert';
|
|
4136
|
+
if (current.getAttribute('aria-disabled')?.trim().toLowerCase() === 'true')
|
|
4137
|
+
return 'aria-disabled';
|
|
4138
|
+
if (current.getAttribute('aria-readonly')?.trim().toLowerCase() === 'true')
|
|
4139
|
+
return 'aria-readonly';
|
|
4140
|
+
if (current instanceof HTMLFieldSetElement && current.disabled) {
|
|
4141
|
+
const firstLegend = Array.from(current.children).find(child => child instanceof HTMLLegendElement);
|
|
4142
|
+
if (!(firstLegend instanceof HTMLLegendElement) || !composedDescendantOf(target, firstLegend)) {
|
|
4143
|
+
return 'disabled fieldset';
|
|
4144
|
+
}
|
|
4145
|
+
}
|
|
4146
|
+
current = composedParent(current);
|
|
4147
|
+
}
|
|
4148
|
+
return null;
|
|
4149
|
+
}
|
|
4150
|
+
if (commitMutationBlockReason(el))
|
|
4151
|
+
return false;
|
|
3214
4152
|
// React Greenhouse/Workday/Lever fix: React stores the previous value on
|
|
3215
4153
|
// a hidden `_valueTracker` property that React uses to short-circuit
|
|
3216
4154
|
// onChange when the value "hasn't changed". If we set el.value through
|
|
@@ -3416,7 +4354,57 @@ async function attemptNativeBatchFill(page, fields) {
|
|
|
3416
4354
|
const style = getComputedStyle(el);
|
|
3417
4355
|
return style.display !== 'none' && style.visibility !== 'hidden';
|
|
3418
4356
|
}
|
|
3419
|
-
function
|
|
4357
|
+
function mutationBlockReason(target) {
|
|
4358
|
+
function composedParent(node) {
|
|
4359
|
+
if (node.parentElement)
|
|
4360
|
+
return node.parentElement;
|
|
4361
|
+
const root = node.getRootNode();
|
|
4362
|
+
return root instanceof ShadowRoot ? root.host : null;
|
|
4363
|
+
}
|
|
4364
|
+
function composedDescendantOf(node, ancestor) {
|
|
4365
|
+
let current = node;
|
|
4366
|
+
while (current) {
|
|
4367
|
+
if (current === ancestor)
|
|
4368
|
+
return true;
|
|
4369
|
+
current = composedParent(current);
|
|
4370
|
+
}
|
|
4371
|
+
return false;
|
|
4372
|
+
}
|
|
4373
|
+
try {
|
|
4374
|
+
if (target.matches(':disabled'))
|
|
4375
|
+
return 'disabled';
|
|
4376
|
+
}
|
|
4377
|
+
catch { /* ignore */ }
|
|
4378
|
+
if ((target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) &&
|
|
4379
|
+
target.readOnly)
|
|
4380
|
+
return 'readonly';
|
|
4381
|
+
let current = target;
|
|
4382
|
+
while (current) {
|
|
4383
|
+
if (current.hasAttribute('inert'))
|
|
4384
|
+
return 'inert';
|
|
4385
|
+
if (current.getAttribute('aria-disabled')?.trim().toLowerCase() === 'true')
|
|
4386
|
+
return 'aria-disabled';
|
|
4387
|
+
if (current.getAttribute('aria-readonly')?.trim().toLowerCase() === 'true')
|
|
4388
|
+
return 'aria-readonly';
|
|
4389
|
+
if (current instanceof HTMLFieldSetElement && current.disabled) {
|
|
4390
|
+
const firstLegend = Array.from(current.children).find(child => child instanceof HTMLLegendElement);
|
|
4391
|
+
if (!(firstLegend instanceof HTMLLegendElement) || !composedDescendantOf(target, firstLegend)) {
|
|
4392
|
+
return 'disabled fieldset';
|
|
4393
|
+
}
|
|
4394
|
+
}
|
|
4395
|
+
current = composedParent(current);
|
|
4396
|
+
}
|
|
4397
|
+
return null;
|
|
4398
|
+
}
|
|
4399
|
+
function rootScopedElementById(owner, id) {
|
|
4400
|
+
const root = owner.getRootNode();
|
|
4401
|
+
if (root instanceof ShadowRoot)
|
|
4402
|
+
return root.getElementById(id);
|
|
4403
|
+
if (root instanceof Document)
|
|
4404
|
+
return root.getElementById(id);
|
|
4405
|
+
return null;
|
|
4406
|
+
}
|
|
4407
|
+
function referencedText(owner, ids, visited) {
|
|
3420
4408
|
if (!ids)
|
|
3421
4409
|
return undefined;
|
|
3422
4410
|
const seen = visited ?? new Set();
|
|
@@ -3426,12 +4414,12 @@ async function attemptNativeBatchFill(page, fields) {
|
|
|
3426
4414
|
if (seen.has(id))
|
|
3427
4415
|
return '';
|
|
3428
4416
|
seen.add(id);
|
|
3429
|
-
const target =
|
|
4417
|
+
const target = rootScopedElementById(owner, id);
|
|
3430
4418
|
if (!target)
|
|
3431
4419
|
return '';
|
|
3432
4420
|
const chained = target.getAttribute('aria-labelledby');
|
|
3433
4421
|
if (chained)
|
|
3434
|
-
return referencedText(chained, seen) ?? '';
|
|
4422
|
+
return referencedText(target, chained, seen) ?? '';
|
|
3435
4423
|
return target.textContent?.trim() ?? '';
|
|
3436
4424
|
})
|
|
3437
4425
|
.filter(Boolean)
|
|
@@ -3443,7 +4431,7 @@ async function attemptNativeBatchFill(page, fields) {
|
|
|
3443
4431
|
const aria = el.getAttribute('aria-label')?.trim();
|
|
3444
4432
|
if (aria)
|
|
3445
4433
|
return aria;
|
|
3446
|
-
const labelledBy = referencedText(el.getAttribute('aria-labelledby'));
|
|
4434
|
+
const labelledBy = referencedText(el, el.getAttribute('aria-labelledby'));
|
|
3447
4435
|
if (labelledBy)
|
|
3448
4436
|
return labelledBy;
|
|
3449
4437
|
if ((el instanceof HTMLInputElement || el instanceof HTMLSelectElement || el instanceof HTMLTextAreaElement) &&
|
|
@@ -3452,7 +4440,10 @@ async function attemptNativeBatchFill(page, fields) {
|
|
|
3452
4440
|
return el.labels[0]?.textContent?.trim() || undefined;
|
|
3453
4441
|
}
|
|
3454
4442
|
if (el instanceof HTMLElement && el.id) {
|
|
3455
|
-
const
|
|
4443
|
+
const root = el.getRootNode();
|
|
4444
|
+
const label = (root instanceof Document || root instanceof ShadowRoot)
|
|
4445
|
+
? root.querySelector(`label[for="${CSS.escape(el.id)}"]`)
|
|
4446
|
+
: null;
|
|
3456
4447
|
const text = label?.textContent?.trim();
|
|
3457
4448
|
if (text)
|
|
3458
4449
|
return text;
|
|
@@ -3505,6 +4496,8 @@ async function attemptNativeBatchFill(page, fields) {
|
|
|
3505
4496
|
target.value = next;
|
|
3506
4497
|
}
|
|
3507
4498
|
function setInputCheckedReactAware(target, next) {
|
|
4499
|
+
if (mutationBlockReason(target))
|
|
4500
|
+
return false;
|
|
3508
4501
|
const hadReactTracker = clearReactTracker(target, next);
|
|
3509
4502
|
if (!hadReactTracker && target.checked === next)
|
|
3510
4503
|
return true;
|
|
@@ -3664,6 +4657,8 @@ async function attemptNativeBatchFill(page, fields) {
|
|
|
3664
4657
|
}
|
|
3665
4658
|
if (!matches(explicitLabelText(control), fieldLabel, exact))
|
|
3666
4659
|
continue;
|
|
4660
|
+
if (mutationBlockReason(control))
|
|
4661
|
+
return false;
|
|
3667
4662
|
if (control instanceof HTMLInputElement || control instanceof HTMLTextAreaElement) {
|
|
3668
4663
|
const inputType = control instanceof HTMLInputElement ? control.type : 'textarea';
|
|
3669
4664
|
const v = canonicalizeHtmlInputValueInPage(inputType, value);
|
|
@@ -3671,7 +4666,7 @@ async function attemptNativeBatchFill(page, fields) {
|
|
|
3671
4666
|
clearReactTracker(control);
|
|
3672
4667
|
setInputLikeValue(control, v);
|
|
3673
4668
|
dispatch(control);
|
|
3674
|
-
return
|
|
4669
|
+
return normalize(currentValue(control)) === normalize(v);
|
|
3675
4670
|
}
|
|
3676
4671
|
if (control instanceof HTMLElement && control.isContentEditable) {
|
|
3677
4672
|
if (!isPlainContentEditableBatch(control))
|
|
@@ -3680,7 +4675,7 @@ async function attemptNativeBatchFill(page, fields) {
|
|
|
3680
4675
|
clearReactTracker(control);
|
|
3681
4676
|
control.textContent = value;
|
|
3682
4677
|
dispatch(control);
|
|
3683
|
-
return
|
|
4678
|
+
return normalize(currentValue(control)) === normalize(value);
|
|
3684
4679
|
}
|
|
3685
4680
|
}
|
|
3686
4681
|
return false;
|
|
@@ -3692,8 +4687,12 @@ async function attemptNativeBatchFill(page, fields) {
|
|
|
3692
4687
|
continue;
|
|
3693
4688
|
if (!matches(explicitLabelText(control), fieldLabel, exact))
|
|
3694
4689
|
continue;
|
|
4690
|
+
if (mutationBlockReason(control))
|
|
4691
|
+
return false;
|
|
3695
4692
|
const expected = normalize(value);
|
|
3696
4693
|
const option = Array.from(control.options).find((candidate) => {
|
|
4694
|
+
if (candidate.disabled || (candidate.parentElement instanceof HTMLOptGroupElement && candidate.parentElement.disabled))
|
|
4695
|
+
return false;
|
|
3697
4696
|
const label = normalize(candidate.textContent);
|
|
3698
4697
|
const rawValue = normalize(candidate.value);
|
|
3699
4698
|
return exact ? label === expected || rawValue === expected : label.includes(expected) || rawValue.includes(expected);
|
|
@@ -3702,7 +4701,7 @@ async function attemptNativeBatchFill(page, fields) {
|
|
|
3702
4701
|
return false;
|
|
3703
4702
|
control.value = option.value;
|
|
3704
4703
|
dispatch(control);
|
|
3705
|
-
return
|
|
4704
|
+
return control.selectedIndex === option.index;
|
|
3706
4705
|
}
|
|
3707
4706
|
return false;
|
|
3708
4707
|
}
|
|
@@ -3762,13 +4761,17 @@ async function attemptNativeBatchFill(page, fields) {
|
|
|
3762
4761
|
const control = option.control;
|
|
3763
4762
|
if (control instanceof HTMLInputElement)
|
|
3764
4763
|
return setInputCheckedReactAware(control, true);
|
|
4764
|
+
if (mutationBlockReason(option))
|
|
4765
|
+
return false;
|
|
3765
4766
|
option.click();
|
|
3766
|
-
return
|
|
4767
|
+
return false;
|
|
3767
4768
|
}
|
|
3768
4769
|
// role=radio / role=checkbox: click and verify aria-checked
|
|
3769
4770
|
// / aria-selected. The native batch fill used to silently return
|
|
3770
4771
|
// true here, which masked Pinecone's commit-on-rerender bug.
|
|
3771
4772
|
if (option.getAttribute('role') === 'radio' || option.getAttribute('role') === 'checkbox') {
|
|
4773
|
+
if (mutationBlockReason(option))
|
|
4774
|
+
return false;
|
|
3772
4775
|
option.click();
|
|
3773
4776
|
return (option.getAttribute('aria-checked') === 'true' ||
|
|
3774
4777
|
option.getAttribute('aria-selected') === 'true');
|
|
@@ -3785,6 +4788,8 @@ async function attemptNativeBatchFill(page, fields) {
|
|
|
3785
4788
|
// JobForge round-2 marathon — Pinecone Sr SWE Database Team
|
|
3786
4789
|
// #320 and LangChain SE Manager #325.
|
|
3787
4790
|
const beforeSig = selectionSignature(options);
|
|
4791
|
+
if (mutationBlockReason(option))
|
|
4792
|
+
return false;
|
|
3788
4793
|
option.click();
|
|
3789
4794
|
const afterSig = selectionSignature(options);
|
|
3790
4795
|
if (beforeSig === afterSig)
|
|
@@ -3803,6 +4808,8 @@ async function attemptNativeBatchFill(page, fields) {
|
|
|
3803
4808
|
continue;
|
|
3804
4809
|
if (!matches(explicitLabelText(input), label, exact))
|
|
3805
4810
|
continue;
|
|
4811
|
+
if (mutationBlockReason(input))
|
|
4812
|
+
return false;
|
|
3806
4813
|
if (!setInputCheckedReactAware(input, checked))
|
|
3807
4814
|
return false;
|
|
3808
4815
|
return input.checked === checked;
|
|
@@ -3818,6 +4825,8 @@ async function attemptNativeBatchFill(page, fields) {
|
|
|
3818
4825
|
continue;
|
|
3819
4826
|
if (controlType && control.type !== controlType)
|
|
3820
4827
|
continue;
|
|
4828
|
+
if (mutationBlockReason(control))
|
|
4829
|
+
return false;
|
|
3821
4830
|
if (!setInputCheckedReactAware(control, checked))
|
|
3822
4831
|
return false;
|
|
3823
4832
|
return control.checked === checked;
|
|
@@ -4105,6 +5114,9 @@ export async function fillOtp(page, value, opts) {
|
|
|
4105
5114
|
if (liveCount !== group.cellCount) {
|
|
4106
5115
|
throw new Error(`fillOtp: OTP cell group went stale before fill (expected ${group.cellCount} cells, found ${liveCount}). The form likely re-rendered between detection and fill — retry the call.`);
|
|
4107
5116
|
}
|
|
5117
|
+
for (let index = 0; index < liveCount; index++) {
|
|
5118
|
+
await assertLocatorMutable(group.boxes.nth(index), 'text', `fillOtp: OTP cell ${index + 1}`);
|
|
5119
|
+
}
|
|
4108
5120
|
// Click the leftmost box via its center point. We use a low-level
|
|
4109
5121
|
// bounding-box click because a semantic "click the first input" path
|
|
4110
5122
|
// resolves to whichever input paints topmost in the stacking order,
|
|
@@ -4310,6 +5322,7 @@ export async function setFieldText(page, fieldLabel, value, opts) {
|
|
|
4310
5322
|
if (!locator) {
|
|
4311
5323
|
throw new Error(`setFieldText: no visible editable field matching "${fieldLabel}"`);
|
|
4312
5324
|
}
|
|
5325
|
+
await assertLocatorMutable(locator, 'text', 'setFieldText');
|
|
4313
5326
|
await locator.scrollIntoViewIfNeeded();
|
|
4314
5327
|
const inputType = await locator.evaluate(el => {
|
|
4315
5328
|
if (el instanceof HTMLInputElement)
|
|
@@ -4321,6 +5334,7 @@ export async function setFieldText(page, fieldLabel, value, opts) {
|
|
|
4321
5334
|
const normalized = canonicalizeHtmlInputValue(inputType, value);
|
|
4322
5335
|
const applied = await setLocatorTextValue(locator, normalized, { imeFriendly: opts?.imeFriendly });
|
|
4323
5336
|
if (!applied) {
|
|
5337
|
+
await assertLocatorMutable(locator, 'text', 'setFieldText');
|
|
4324
5338
|
try {
|
|
4325
5339
|
await locator.fill(normalized);
|
|
4326
5340
|
}
|
|
@@ -4331,6 +5345,7 @@ export async function setFieldText(page, fieldLabel, value, opts) {
|
|
|
4331
5345
|
});
|
|
4332
5346
|
}
|
|
4333
5347
|
}
|
|
5348
|
+
await delay(40);
|
|
4334
5349
|
const current = await locatorCurrentValue(locator);
|
|
4335
5350
|
if (fieldValueMatches(current, normalized))
|
|
4336
5351
|
return;
|
|
@@ -4340,10 +5355,56 @@ export async function setFieldText(page, fieldLabel, value, opts) {
|
|
|
4340
5355
|
throw new Error(`setFieldText: set "${fieldLabel}" but could not confirm value ${JSON.stringify(value)}`);
|
|
4341
5356
|
}
|
|
4342
5357
|
async function setNativeSelectByLabel(locator, value, exact, optionIndex) {
|
|
5358
|
+
if (await locatorMutationBlockReason(locator, 'select'))
|
|
5359
|
+
return false;
|
|
4343
5360
|
try {
|
|
4344
|
-
return await locator.evaluate((el, payload) => {
|
|
5361
|
+
return await locator.evaluate(async (el, payload) => {
|
|
4345
5362
|
if (!(el instanceof HTMLSelectElement))
|
|
4346
5363
|
return false;
|
|
5364
|
+
// Keep the final state check atomic with selectedIndex/event mutation.
|
|
5365
|
+
// The earlier host-side preflight can race a reactive disable/inert
|
|
5366
|
+
// update while Playwright crosses back into the page.
|
|
5367
|
+
function commitMutationBlockReason(target) {
|
|
5368
|
+
function composedParent(node) {
|
|
5369
|
+
if (node.parentElement)
|
|
5370
|
+
return node.parentElement;
|
|
5371
|
+
const root = node.getRootNode();
|
|
5372
|
+
return root instanceof ShadowRoot ? root.host : null;
|
|
5373
|
+
}
|
|
5374
|
+
function composedDescendantOf(node, ancestor) {
|
|
5375
|
+
let current = node;
|
|
5376
|
+
while (current) {
|
|
5377
|
+
if (current === ancestor)
|
|
5378
|
+
return true;
|
|
5379
|
+
current = composedParent(current);
|
|
5380
|
+
}
|
|
5381
|
+
return false;
|
|
5382
|
+
}
|
|
5383
|
+
try {
|
|
5384
|
+
if (target.matches(':disabled'))
|
|
5385
|
+
return 'disabled';
|
|
5386
|
+
}
|
|
5387
|
+
catch { /* non-HTML namespaces may reject pseudo classes */ }
|
|
5388
|
+
let current = target;
|
|
5389
|
+
while (current) {
|
|
5390
|
+
if (current.hasAttribute('inert'))
|
|
5391
|
+
return 'inert';
|
|
5392
|
+
if (current.getAttribute('aria-disabled')?.trim().toLowerCase() === 'true')
|
|
5393
|
+
return 'aria-disabled';
|
|
5394
|
+
if (current.getAttribute('aria-readonly')?.trim().toLowerCase() === 'true')
|
|
5395
|
+
return 'aria-readonly';
|
|
5396
|
+
if (current instanceof HTMLFieldSetElement && current.disabled) {
|
|
5397
|
+
const firstLegend = Array.from(current.children).find(child => child instanceof HTMLLegendElement);
|
|
5398
|
+
if (!(firstLegend instanceof HTMLLegendElement) || !composedDescendantOf(target, firstLegend)) {
|
|
5399
|
+
return 'disabled fieldset';
|
|
5400
|
+
}
|
|
5401
|
+
}
|
|
5402
|
+
current = composedParent(current);
|
|
5403
|
+
}
|
|
5404
|
+
return null;
|
|
5405
|
+
}
|
|
5406
|
+
if (commitMutationBlockReason(el))
|
|
5407
|
+
return false;
|
|
4347
5408
|
const normalize = (input) => input?.replace(/\s+/g, ' ').trim().toLowerCase() ?? '';
|
|
4348
5409
|
const expected = normalize(payload.value);
|
|
4349
5410
|
const enabled = (candidate) => !candidate.disabled && !(candidate.parentElement instanceof HTMLOptGroupElement && candidate.parentElement.disabled);
|
|
@@ -4372,7 +5433,8 @@ async function setNativeSelectByLabel(locator, value, exact, optionIndex) {
|
|
|
4372
5433
|
el.selectedIndex = option.index;
|
|
4373
5434
|
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
4374
5435
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
4375
|
-
|
|
5436
|
+
await new Promise(resolveTimer => setTimeout(resolveTimer, 40));
|
|
5437
|
+
return el.selectedIndex === option.index && el.selectedOptions[0] === option;
|
|
4376
5438
|
}, { value, exact, optionIndex });
|
|
4377
5439
|
}
|
|
4378
5440
|
catch {
|
|
@@ -4394,6 +5456,46 @@ async function chooseValueFromLabeledGroup(page, fieldLabel, value, exact) {
|
|
|
4394
5456
|
const style = getComputedStyle(el);
|
|
4395
5457
|
return style.display !== 'none' && style.visibility !== 'hidden';
|
|
4396
5458
|
}
|
|
5459
|
+
function blocked(target) {
|
|
5460
|
+
function parent(node) {
|
|
5461
|
+
if (node.parentElement)
|
|
5462
|
+
return node.parentElement;
|
|
5463
|
+
const root = node.getRootNode();
|
|
5464
|
+
return root instanceof ShadowRoot ? root.host : null;
|
|
5465
|
+
}
|
|
5466
|
+
function within(node, ancestor) {
|
|
5467
|
+
let current = node;
|
|
5468
|
+
while (current) {
|
|
5469
|
+
if (current === ancestor)
|
|
5470
|
+
return true;
|
|
5471
|
+
current = parent(current);
|
|
5472
|
+
}
|
|
5473
|
+
return false;
|
|
5474
|
+
}
|
|
5475
|
+
try {
|
|
5476
|
+
if (target.matches(':disabled'))
|
|
5477
|
+
return true;
|
|
5478
|
+
}
|
|
5479
|
+
catch { /* ignore */ }
|
|
5480
|
+
if ((target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) && target.readOnly)
|
|
5481
|
+
return true;
|
|
5482
|
+
let current = target;
|
|
5483
|
+
while (current) {
|
|
5484
|
+
if (current.hasAttribute('inert'))
|
|
5485
|
+
return true;
|
|
5486
|
+
if (current.getAttribute('aria-disabled')?.trim().toLowerCase() === 'true')
|
|
5487
|
+
return true;
|
|
5488
|
+
if (current.getAttribute('aria-readonly')?.trim().toLowerCase() === 'true')
|
|
5489
|
+
return true;
|
|
5490
|
+
if (current instanceof HTMLFieldSetElement && current.disabled) {
|
|
5491
|
+
const legend = Array.from(current.children).find(child => child instanceof HTMLLegendElement);
|
|
5492
|
+
if (!(legend instanceof HTMLLegendElement) || !within(target, legend))
|
|
5493
|
+
return true;
|
|
5494
|
+
}
|
|
5495
|
+
current = parent(current);
|
|
5496
|
+
}
|
|
5497
|
+
return false;
|
|
5498
|
+
}
|
|
4397
5499
|
function matches(candidate) {
|
|
4398
5500
|
const normalizedCandidate = normalize(candidate);
|
|
4399
5501
|
const normalizedExpected = normalize(payload.fieldLabel);
|
|
@@ -4408,7 +5510,15 @@ async function chooseValueFromLabeledGroup(page, fieldLabel, value, exact) {
|
|
|
4408
5510
|
return false;
|
|
4409
5511
|
return payload.exact ? normalizedCandidate === normalizedExpected : normalizedCandidate.includes(normalizedExpected);
|
|
4410
5512
|
}
|
|
4411
|
-
function
|
|
5513
|
+
function rootScopedElementById(owner, id) {
|
|
5514
|
+
const root = owner.getRootNode();
|
|
5515
|
+
if (root instanceof ShadowRoot)
|
|
5516
|
+
return root.getElementById(id);
|
|
5517
|
+
if (root instanceof Document)
|
|
5518
|
+
return root.getElementById(id);
|
|
5519
|
+
return null;
|
|
5520
|
+
}
|
|
5521
|
+
function referencedText(owner, ids, visited) {
|
|
4412
5522
|
if (!ids)
|
|
4413
5523
|
return undefined;
|
|
4414
5524
|
const seen = visited ?? new Set();
|
|
@@ -4418,12 +5528,12 @@ async function chooseValueFromLabeledGroup(page, fieldLabel, value, exact) {
|
|
|
4418
5528
|
if (seen.has(id))
|
|
4419
5529
|
return '';
|
|
4420
5530
|
seen.add(id);
|
|
4421
|
-
const target =
|
|
5531
|
+
const target = rootScopedElementById(owner, id);
|
|
4422
5532
|
if (!target)
|
|
4423
5533
|
return '';
|
|
4424
5534
|
const chained = target.getAttribute('aria-labelledby');
|
|
4425
5535
|
if (chained)
|
|
4426
|
-
return referencedText(chained, seen) ?? '';
|
|
5536
|
+
return referencedText(target, chained, seen) ?? '';
|
|
4427
5537
|
return target.textContent?.trim() ?? '';
|
|
4428
5538
|
})
|
|
4429
5539
|
.filter(Boolean)
|
|
@@ -4435,7 +5545,7 @@ async function chooseValueFromLabeledGroup(page, fieldLabel, value, exact) {
|
|
|
4435
5545
|
const aria = el.getAttribute('aria-label')?.trim();
|
|
4436
5546
|
if (aria)
|
|
4437
5547
|
return aria;
|
|
4438
|
-
const labelledBy = referencedText(el.getAttribute('aria-labelledby'));
|
|
5548
|
+
const labelledBy = referencedText(el, el.getAttribute('aria-labelledby'));
|
|
4439
5549
|
if (labelledBy)
|
|
4440
5550
|
return labelledBy;
|
|
4441
5551
|
if (el instanceof HTMLInputElement && el.labels && el.labels.length > 0) {
|
|
@@ -4469,7 +5579,7 @@ async function chooseValueFromLabeledGroup(page, fieldLabel, value, exact) {
|
|
|
4469
5579
|
const aria = el.getAttribute('aria-label')?.trim();
|
|
4470
5580
|
if (aria)
|
|
4471
5581
|
return aria;
|
|
4472
|
-
const labelledBy = referencedText(el.getAttribute('aria-labelledby'));
|
|
5582
|
+
const labelledBy = referencedText(el, el.getAttribute('aria-labelledby'));
|
|
4473
5583
|
if (labelledBy)
|
|
4474
5584
|
return labelledBy;
|
|
4475
5585
|
return el.textContent?.trim() || undefined;
|
|
@@ -4547,6 +5657,8 @@ async function chooseValueFromLabeledGroup(page, fieldLabel, value, exact) {
|
|
|
4547
5657
|
return false;
|
|
4548
5658
|
}
|
|
4549
5659
|
function setInputCheckedReactAware(target, next) {
|
|
5660
|
+
if (blocked(target))
|
|
5661
|
+
return false;
|
|
4550
5662
|
const hadReactTracker = clearReactTracker(target, next);
|
|
4551
5663
|
if (!hadReactTracker && target.checked === next)
|
|
4552
5664
|
return true;
|
|
@@ -4573,6 +5685,8 @@ async function chooseValueFromLabeledGroup(page, fieldLabel, value, exact) {
|
|
|
4573
5685
|
const control = option.control;
|
|
4574
5686
|
if (control instanceof HTMLInputElement)
|
|
4575
5687
|
return setInputCheckedReactAware(control, true);
|
|
5688
|
+
if (blocked(option))
|
|
5689
|
+
return false;
|
|
4576
5690
|
option.click();
|
|
4577
5691
|
// No backing input — verify via group signature change so we don't
|
|
4578
5692
|
// silently no-op on label wrappers whose target is unparented.
|
|
@@ -4580,6 +5694,8 @@ async function chooseValueFromLabeledGroup(page, fieldLabel, value, exact) {
|
|
|
4580
5694
|
return labelBeforeSig !== labelAfterSig;
|
|
4581
5695
|
}
|
|
4582
5696
|
if (option.getAttribute('role') === 'radio' || option.getAttribute('role') === 'checkbox') {
|
|
5697
|
+
if (blocked(option))
|
|
5698
|
+
return false;
|
|
4583
5699
|
option.click();
|
|
4584
5700
|
return option.getAttribute('aria-checked') === 'true' || option.getAttribute('aria-selected') === 'true';
|
|
4585
5701
|
}
|
|
@@ -4589,6 +5705,8 @@ async function chooseValueFromLabeledGroup(page, fieldLabel, value, exact) {
|
|
|
4589
5705
|
// was a no-op — return false so the caller can try the next strategy
|
|
4590
5706
|
// (pickListboxOption fallback, etc) instead of silently succeeding.
|
|
4591
5707
|
const beforeSig = selectionSignature(options);
|
|
5708
|
+
if (blocked(option))
|
|
5709
|
+
return false;
|
|
4592
5710
|
option.click();
|
|
4593
5711
|
const afterSig = selectionSignature(options);
|
|
4594
5712
|
if (beforeSig === afterSig) {
|
|
@@ -4710,6 +5828,7 @@ export async function setFieldChoice(page, fieldLabel, value, opts) {
|
|
|
4710
5828
|
fieldKey: opts?.fieldKey,
|
|
4711
5829
|
});
|
|
4712
5830
|
if (locator) {
|
|
5831
|
+
await assertLocatorMutable(locator, 'select', 'setFieldChoice');
|
|
4713
5832
|
await locator.scrollIntoViewIfNeeded();
|
|
4714
5833
|
const isNativeSelect = await locator.evaluate(el => el instanceof HTMLSelectElement);
|
|
4715
5834
|
if (await setNativeSelectByLabel(locator, value, exact, opts?.optionIndex)) {
|
|
@@ -4848,6 +5967,8 @@ export async function fillFields(page, fields, cache = createFillLookupCache())
|
|
|
4848
5967
|
fieldId: field.fieldId,
|
|
4849
5968
|
fieldKey: field.fieldKey,
|
|
4850
5969
|
fieldLabel: field.fieldLabel,
|
|
5970
|
+
contextText: field.contextText,
|
|
5971
|
+
sectionText: field.sectionText,
|
|
4851
5972
|
exact: field.exact,
|
|
4852
5973
|
cache,
|
|
4853
5974
|
});
|
|
@@ -4885,71 +6006,46 @@ export async function selectNativeOption(page, x, y, opt) {
|
|
|
4885
6006
|
if (opt.value === undefined && opt.label === undefined && opt.index === undefined) {
|
|
4886
6007
|
throw new Error('selectOption: provide at least one of value, label, or index');
|
|
4887
6008
|
}
|
|
6009
|
+
if (opt.index !== undefined && (!Number.isFinite(opt.index) || !Number.isInteger(opt.index) || opt.index < 0)) {
|
|
6010
|
+
throw new Error('selectOption: index must be a non-negative integer');
|
|
6011
|
+
}
|
|
6012
|
+
await assertPointMutable(page, x, y, 'select', 'selectOption');
|
|
4888
6013
|
await page.mouse.click(x, y);
|
|
4889
6014
|
await delay(40);
|
|
4890
6015
|
for (const frame of page.frames()) {
|
|
4891
|
-
const applied = await frame.evaluate((payload) => {
|
|
6016
|
+
const applied = await frame.evaluate(async (payload) => {
|
|
4892
6017
|
const normalize = (input) => input?.replace(/\s+/g, ' ').trim().toLowerCase() ?? '';
|
|
4893
|
-
const
|
|
6018
|
+
const enabled = (o) => !o.disabled && !(o.parentElement instanceof HTMLOptGroupElement && o.parentElement.disabled);
|
|
6019
|
+
const optionMatchesPayload = (o) => {
|
|
4894
6020
|
if (o.disabled || (o.parentElement instanceof HTMLOptGroupElement && o.parentElement.disabled))
|
|
4895
6021
|
return false;
|
|
4896
|
-
|
|
4897
|
-
|
|
4898
|
-
|
|
6022
|
+
if (payload.value !== null && normalize(o.value) !== normalize(payload.value))
|
|
6023
|
+
return false;
|
|
6024
|
+
if (payload.label !== null && normalize(o.textContent) !== normalize(payload.label))
|
|
6025
|
+
return false;
|
|
6026
|
+
if (payload.index !== undefined && o.index !== payload.index)
|
|
6027
|
+
return false;
|
|
6028
|
+
return true;
|
|
4899
6029
|
};
|
|
4900
6030
|
const a = document.activeElement;
|
|
4901
6031
|
if (!a || a.tagName !== 'SELECT')
|
|
4902
6032
|
return false;
|
|
4903
6033
|
const sel = a;
|
|
4904
|
-
if (
|
|
4905
|
-
const i = Math.trunc(payload.index);
|
|
4906
|
-
if (i < 0 || i >= sel.options.length)
|
|
4907
|
-
return false;
|
|
4908
|
-
const indexed = sel.options[i];
|
|
4909
|
-
if (indexed.disabled || (indexed.parentElement instanceof HTMLOptGroupElement && indexed.parentElement.disabled))
|
|
4910
|
-
return false;
|
|
4911
|
-
if (payload.value !== null && normalize(indexed.value) !== normalize(payload.value))
|
|
4912
|
-
return false;
|
|
4913
|
-
if (payload.label !== null && !normalize(indexed.textContent).includes(normalize(payload.label)))
|
|
4914
|
-
return false;
|
|
4915
|
-
sel.selectedIndex = i;
|
|
4916
|
-
}
|
|
4917
|
-
else if (payload.value !== null && payload.value !== undefined) {
|
|
4918
|
-
const raw = String(payload.value);
|
|
4919
|
-
sel.value = raw;
|
|
4920
|
-
const expected = normalize(raw);
|
|
4921
|
-
if (!expected)
|
|
4922
|
-
return false;
|
|
4923
|
-
const selected = sel.selectedOptions[0];
|
|
4924
|
-
const ok = selected &&
|
|
4925
|
-
!selected.disabled &&
|
|
4926
|
-
!(selected.parentElement instanceof HTMLOptGroupElement && selected.parentElement.disabled) &&
|
|
4927
|
-
(normalize(selected.value) === expected ||
|
|
4928
|
-
normalize(selected.textContent) === expected ||
|
|
4929
|
-
normalize(selected.value).includes(expected) ||
|
|
4930
|
-
normalize(selected.textContent).includes(expected));
|
|
4931
|
-
if (!ok) {
|
|
4932
|
-
const found = Array.from(sel.options).find(o => optionMatchesExpected(o, expected));
|
|
4933
|
-
if (!found)
|
|
4934
|
-
return false;
|
|
4935
|
-
sel.selectedIndex = found.index;
|
|
4936
|
-
}
|
|
4937
|
-
}
|
|
4938
|
-
else if (payload.label !== null && payload.label !== undefined) {
|
|
4939
|
-
const expected = normalize(payload.label);
|
|
4940
|
-
if (!expected)
|
|
4941
|
-
return false;
|
|
4942
|
-
const optEl = Array.from(sel.options).find(o => optionMatchesExpected(o, expected));
|
|
4943
|
-
if (!optEl)
|
|
4944
|
-
return false;
|
|
4945
|
-
sel.selectedIndex = optEl.index;
|
|
4946
|
-
}
|
|
4947
|
-
else {
|
|
6034
|
+
if (sel.matches(':disabled') || sel.getAttribute('aria-disabled') === 'true' || sel.getAttribute('aria-readonly') === 'true')
|
|
4948
6035
|
return false;
|
|
4949
|
-
|
|
6036
|
+
if (sel.closest('[inert], [aria-disabled="true"], [aria-readonly="true"]'))
|
|
6037
|
+
return false;
|
|
6038
|
+
const candidate = payload.index !== undefined
|
|
6039
|
+
? sel.options.item(payload.index)
|
|
6040
|
+
: Array.from(sel.options).find(optionMatchesPayload) ?? null;
|
|
6041
|
+
if (!candidate || !enabled(candidate) || !optionMatchesPayload(candidate))
|
|
6042
|
+
return false;
|
|
6043
|
+
sel.selectedIndex = candidate.index;
|
|
4950
6044
|
sel.dispatchEvent(new Event('input', { bubbles: true }));
|
|
4951
6045
|
sel.dispatchEvent(new Event('change', { bubbles: true }));
|
|
4952
|
-
|
|
6046
|
+
await new Promise(resolveTimer => setTimeout(resolveTimer, 40));
|
|
6047
|
+
const selected = sel.selectedOptions[0];
|
|
6048
|
+
return sel.selectedIndex === candidate.index && selected === candidate && optionMatchesPayload(selected);
|
|
4953
6049
|
}, {
|
|
4954
6050
|
value: opt.value ?? null,
|
|
4955
6051
|
label: opt.label ?? null,
|
|
@@ -4966,6 +6062,23 @@ export async function selectNativeOption(page, x, y, opt) {
|
|
|
4966
6062
|
export async function pickListboxOption(page, label, opts) {
|
|
4967
6063
|
let anchor;
|
|
4968
6064
|
const exact = opts?.exact ?? false;
|
|
6065
|
+
const hasOpenX = opts?.openX !== undefined;
|
|
6066
|
+
const hasOpenY = opts?.openY !== undefined;
|
|
6067
|
+
if (hasOpenX !== hasOpenY) {
|
|
6068
|
+
throw new Error('listboxPick: openX and openY must be provided together');
|
|
6069
|
+
}
|
|
6070
|
+
if ((hasOpenX || hasOpenY) && (opts?.fieldLabel || opts?.fieldId || opts?.fieldKey)) {
|
|
6071
|
+
throw new Error('listboxPick: coordinate opening cannot be combined with fieldLabel, fieldId, or fieldKey');
|
|
6072
|
+
}
|
|
6073
|
+
if (hasOpenX && opts?.query !== undefined) {
|
|
6074
|
+
throw new Error('listboxPick: query cannot be combined with coordinate opening');
|
|
6075
|
+
}
|
|
6076
|
+
if (opts?.fieldId && !opts.fieldLabel && !opts.fieldKey) {
|
|
6077
|
+
throw new Error('listboxPick: fieldId requires fieldLabel or fieldKey and cannot be used as an unscoped hint');
|
|
6078
|
+
}
|
|
6079
|
+
if (!opts?.fieldLabel && !opts?.fieldKey && !hasOpenX) {
|
|
6080
|
+
throw new Error('listboxPick: provide fieldLabel, fieldKey, or a complete openX/openY target before selecting an option');
|
|
6081
|
+
}
|
|
4969
6082
|
let attemptedSelection = false;
|
|
4970
6083
|
let selectedOptionText;
|
|
4971
6084
|
let openedHandle;
|
|
@@ -4991,16 +6104,16 @@ export async function pickListboxOption(page, label, opts) {
|
|
|
4991
6104
|
await releasePopupScope();
|
|
4992
6105
|
popupScope = await resolveOwnedPopupHandle(openedHandle);
|
|
4993
6106
|
};
|
|
4994
|
-
if (opts?.fieldLabel) {
|
|
6107
|
+
if (opts?.fieldLabel || opts?.fieldKey) {
|
|
4995
6108
|
let opened;
|
|
4996
6109
|
try {
|
|
4997
|
-
opened = await openDropdownControl(page, opts.fieldLabel, exact, opts.cache, opts.fieldId, opts.fieldKey);
|
|
6110
|
+
opened = await openDropdownControl(page, opts.fieldLabel ?? '', exact, opts.cache, opts.fieldId, opts.fieldKey);
|
|
4998
6111
|
}
|
|
4999
6112
|
catch {
|
|
5000
6113
|
throw new Error(listboxErrorMessage({
|
|
5001
6114
|
reason: 'field_not_found',
|
|
5002
6115
|
requestedLabel: label,
|
|
5003
|
-
fieldLabel: opts.fieldLabel,
|
|
6116
|
+
fieldLabel: opts.fieldLabel ?? opts.fieldKey,
|
|
5004
6117
|
query: opts?.query,
|
|
5005
6118
|
exact,
|
|
5006
6119
|
}));
|
|
@@ -5020,6 +6133,10 @@ export async function pickListboxOption(page, label, opts) {
|
|
|
5020
6133
|
await refreshPopupScope();
|
|
5021
6134
|
}
|
|
5022
6135
|
else if (opts?.openX !== undefined && opts?.openY !== undefined) {
|
|
6136
|
+
await assertPointMutable(page, opts.openX, opts.openY, 'select', 'listboxPick');
|
|
6137
|
+
openedHandle = await elementHandleAtPoint(page, opts.openX, opts.openY);
|
|
6138
|
+
if (!openedHandle)
|
|
6139
|
+
throw new Error('listboxPick: no control at openX/openY');
|
|
5023
6140
|
await page.mouse.click(opts.openX, opts.openY);
|
|
5024
6141
|
anchor = { x: opts.openX, y: opts.openY };
|
|
5025
6142
|
await delay(120);
|
|
@@ -5036,19 +6153,20 @@ export async function pickListboxOption(page, label, opts) {
|
|
|
5036
6153
|
// popup. If the pointer click did not commit, the keyboard fallback
|
|
5037
6154
|
// below deliberately reopens this exact control and selects by Enter.
|
|
5038
6155
|
await dispatchListboxCommitEvents(openedHandle);
|
|
5039
|
-
if (
|
|
5040
|
-
await confirmListboxSelection(page, opts.fieldLabel, label, exact, anchor, openedHandle, selectedOptionText, {
|
|
6156
|
+
if (opts?.fieldLabel) {
|
|
6157
|
+
if (await confirmListboxSelection(page, opts.fieldLabel, label, exact, anchor, openedHandle, selectedOptionText, {
|
|
5041
6158
|
editable: openedEditable,
|
|
5042
|
-
}))
|
|
5043
|
-
|
|
6159
|
+
}))
|
|
6160
|
+
return true;
|
|
6161
|
+
return false;
|
|
5044
6162
|
}
|
|
5045
|
-
return
|
|
6163
|
+
return await confirmListboxSelectionWithoutLabel(page, label, exact, anchor, openedHandle, selectedOptionText);
|
|
5046
6164
|
};
|
|
5047
6165
|
const dismissAfterSelection = async () => {
|
|
5048
6166
|
if (!opts?.fieldLabel) {
|
|
5049
6167
|
await page.keyboard.press('Tab');
|
|
5050
6168
|
await delay(50);
|
|
5051
|
-
return
|
|
6169
|
+
return await confirmListboxSelectionWithoutLabel(page, label, exact, anchor, openedHandle, selectedOptionText);
|
|
5052
6170
|
}
|
|
5053
6171
|
if (await dismissAndReVerifySelection(page, label, exact, openedHandle, selectedOptionText)) {
|
|
5054
6172
|
return true;
|
|
@@ -5145,10 +6263,12 @@ export async function pickListboxOption(page, label, opts) {
|
|
|
5145
6263
|
selectedOptionText = keyboardSelection;
|
|
5146
6264
|
attemptedSelection = true;
|
|
5147
6265
|
await dispatchListboxCommitEvents(openedHandle);
|
|
5148
|
-
|
|
5149
|
-
await confirmListboxSelection(page, opts.fieldLabel, label, exact, anchor, openedHandle, selectedOptionText, {
|
|
6266
|
+
const keyboardConfirmed = opts?.fieldLabel
|
|
6267
|
+
? await confirmListboxSelection(page, opts.fieldLabel, label, exact, anchor, openedHandle, selectedOptionText, {
|
|
5150
6268
|
editable: openedEditable,
|
|
5151
|
-
})
|
|
6269
|
+
})
|
|
6270
|
+
: await confirmListboxSelectionWithoutLabel(page, label, exact, anchor, openedHandle, selectedOptionText);
|
|
6271
|
+
if (keyboardConfirmed) {
|
|
5152
6272
|
if (await dismissAfterSelection() && await postCommitVerify())
|
|
5153
6273
|
return;
|
|
5154
6274
|
}
|
|
@@ -5183,7 +6303,15 @@ export async function pickListboxOption(page, label, opts) {
|
|
|
5183
6303
|
}
|
|
5184
6304
|
async function applyToggleState(locator, desiredChecked) {
|
|
5185
6305
|
return await locator.evaluate((target, checked) => {
|
|
5186
|
-
function
|
|
6306
|
+
function rootScopedElementById(owner, id) {
|
|
6307
|
+
const root = owner.getRootNode();
|
|
6308
|
+
if (root instanceof ShadowRoot)
|
|
6309
|
+
return root.getElementById(id);
|
|
6310
|
+
if (root instanceof Document)
|
|
6311
|
+
return root.getElementById(id);
|
|
6312
|
+
return null;
|
|
6313
|
+
}
|
|
6314
|
+
function referencedText(owner, ids, visited) {
|
|
5187
6315
|
if (!ids)
|
|
5188
6316
|
return undefined;
|
|
5189
6317
|
const seen = visited ?? new Set();
|
|
@@ -5191,10 +6319,10 @@ async function applyToggleState(locator, desiredChecked) {
|
|
|
5191
6319
|
if (seen.has(id))
|
|
5192
6320
|
return '';
|
|
5193
6321
|
seen.add(id);
|
|
5194
|
-
const node =
|
|
6322
|
+
const node = rootScopedElementById(owner, id);
|
|
5195
6323
|
if (!node)
|
|
5196
6324
|
return '';
|
|
5197
|
-
return referencedText(node.getAttribute('aria-labelledby'), seen) ?? node.textContent?.trim() ?? '';
|
|
6325
|
+
return referencedText(node, node.getAttribute('aria-labelledby'), seen) ?? node.textContent?.trim() ?? '';
|
|
5198
6326
|
}).filter(Boolean).join(' ').trim();
|
|
5199
6327
|
return text || undefined;
|
|
5200
6328
|
}
|
|
@@ -5207,7 +6335,7 @@ async function applyToggleState(locator, desiredChecked) {
|
|
|
5207
6335
|
const aria = el.getAttribute('aria-label')?.trim();
|
|
5208
6336
|
if (aria)
|
|
5209
6337
|
return aria;
|
|
5210
|
-
const labelledBy = referencedText(el.getAttribute('aria-labelledby'));
|
|
6338
|
+
const labelledBy = referencedText(el, el.getAttribute('aria-labelledby'));
|
|
5211
6339
|
if (labelledBy)
|
|
5212
6340
|
return labelledBy;
|
|
5213
6341
|
if (el instanceof HTMLInputElement) {
|
|
@@ -5272,7 +6400,15 @@ async function collectToggleMatches(page, label, exact, controlType, contextText
|
|
|
5272
6400
|
const style = getComputedStyle(el);
|
|
5273
6401
|
return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden';
|
|
5274
6402
|
}
|
|
5275
|
-
function
|
|
6403
|
+
function rootScopedElementById(owner, id) {
|
|
6404
|
+
const root = owner.getRootNode();
|
|
6405
|
+
if (root instanceof ShadowRoot)
|
|
6406
|
+
return root.getElementById(id);
|
|
6407
|
+
if (root instanceof Document)
|
|
6408
|
+
return root.getElementById(id);
|
|
6409
|
+
return null;
|
|
6410
|
+
}
|
|
6411
|
+
function referencedText(owner, ids, visited) {
|
|
5276
6412
|
if (!ids)
|
|
5277
6413
|
return undefined;
|
|
5278
6414
|
const seen = visited ?? new Set();
|
|
@@ -5280,10 +6416,10 @@ async function collectToggleMatches(page, label, exact, controlType, contextText
|
|
|
5280
6416
|
if (seen.has(id))
|
|
5281
6417
|
return '';
|
|
5282
6418
|
seen.add(id);
|
|
5283
|
-
const node =
|
|
6419
|
+
const node = rootScopedElementById(owner, id);
|
|
5284
6420
|
if (!node)
|
|
5285
6421
|
return '';
|
|
5286
|
-
return referencedText(node.getAttribute('aria-labelledby'), seen) ?? node.textContent?.trim() ?? '';
|
|
6422
|
+
return referencedText(node, node.getAttribute('aria-labelledby'), seen) ?? node.textContent?.trim() ?? '';
|
|
5287
6423
|
}).filter(Boolean).join(' ').trim();
|
|
5288
6424
|
return text || undefined;
|
|
5289
6425
|
}
|
|
@@ -5306,7 +6442,7 @@ async function collectToggleMatches(page, label, exact, controlType, contextText
|
|
|
5306
6442
|
const aria = el.getAttribute('aria-label')?.trim();
|
|
5307
6443
|
if (aria)
|
|
5308
6444
|
return aria;
|
|
5309
|
-
const labelledBy = referencedText(el.getAttribute('aria-labelledby'));
|
|
6445
|
+
const labelledBy = referencedText(el, el.getAttribute('aria-labelledby'));
|
|
5310
6446
|
if (labelledBy)
|
|
5311
6447
|
return labelledBy;
|
|
5312
6448
|
if (el instanceof HTMLInputElement) {
|
|
@@ -5336,7 +6472,7 @@ async function collectToggleMatches(page, label, exact, controlType, contextText
|
|
|
5336
6472
|
if (!(section instanceof HTMLElement))
|
|
5337
6473
|
return '';
|
|
5338
6474
|
const explicit = section.getAttribute('aria-label')?.trim() ||
|
|
5339
|
-
referencedText(section.getAttribute('aria-labelledby')) ||
|
|
6475
|
+
referencedText(section, section.getAttribute('aria-labelledby')) ||
|
|
5340
6476
|
section.querySelector('legend, h1, h2, h3, h4, h5, h6')?.textContent?.trim();
|
|
5341
6477
|
return (explicit || section.innerText || section.textContent || '').replace(/\s+/g, ' ').trim();
|
|
5342
6478
|
}
|
|
@@ -5419,9 +6555,14 @@ export async function setCheckedControl(page, label, opts) {
|
|
|
5419
6555
|
}
|
|
5420
6556
|
target = matches[0].locator;
|
|
5421
6557
|
}
|
|
6558
|
+
await assertLocatorMutable(target, 'toggle', 'setChecked');
|
|
5422
6559
|
const result = await applyToggleState(target, desiredChecked);
|
|
5423
|
-
if (result.success)
|
|
5424
|
-
|
|
6560
|
+
if (result.success) {
|
|
6561
|
+
await delay(40);
|
|
6562
|
+
const persisted = await target.evaluate((el, desired) => (el instanceof HTMLInputElement ? el.checked : el.getAttribute('aria-checked') === 'true') === desired, desiredChecked).catch(() => false);
|
|
6563
|
+
if (persisted)
|
|
6564
|
+
return;
|
|
6565
|
+
}
|
|
5425
6566
|
if (result.reason === 'radio-uncheck') {
|
|
5426
6567
|
throw new Error(`setChecked: radio "${result.name}" cannot be unchecked directly; choose a different option instead`);
|
|
5427
6568
|
}
|