@geometra/proxy 1.63.2 → 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 +12 -1
- package/dist/dom-actions.d.ts.map +1 -1
- package/dist/dom-actions.js +1949 -615
- 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 +939 -62
- package/dist/extractor.js.map +1 -1
- package/dist/geometry-ws.d.ts +36 -3
- package/dist/geometry-ws.d.ts.map +1 -1
- package/dist/geometry-ws.js +940 -114
- 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 +78 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +372 -31
- package/dist/types.js.map +1 -1
- package/package.json +3 -3
package/dist/dom-actions.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
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));
|
|
6
6
|
}
|
|
7
7
|
const LABELED_CONTROL_SELECTOR = 'input, select, textarea, button, [role="combobox"], [role="textbox"], [aria-haspopup="listbox"], [aria-haspopup="menu"], [aria-haspopup="true"], [contenteditable="true"]';
|
|
8
|
+
const EDITABLE_FIELD_SELECTOR = 'input:not([type="checkbox"]):not([type="radio"]):not([type="file"]):not([type="button"]):not([type="submit"]):not([type="reset"]):not([type="hidden"]), textarea, [role="textbox"], [role="combobox"], [contenteditable="true"]';
|
|
9
|
+
const TOGGLE_CONTROL_SELECTOR = 'input[type="checkbox"], input[type="radio"], [role="checkbox"], [role="radio"], [role="switch"]';
|
|
8
10
|
const MENU_TRIGGER_SELECTOR = 'button[aria-haspopup="menu"], button[aria-haspopup="true"], [role="button"][aria-haspopup="menu"], [role="button"][aria-haspopup="true"]';
|
|
9
11
|
const POPUP_CONTAINER_SELECTOR = '[role="listbox"], [role="menu"], [role="dialog"], [aria-modal="true"], [data-radix-popper-content-wrapper], [class*="menu"], [class*="option"], [class*="select"], [class*="dropdown"]';
|
|
10
12
|
const POPUP_ROOT_SELECTOR = '[role="listbox"], [role="menu"], [role="dialog"], [aria-modal="true"], [data-radix-popper-content-wrapper], [class*="menu"], [class*="dropdown"], [class*="popover"], [class*="listbox"], [class*="options"]';
|
|
@@ -43,10 +45,12 @@ export function clearFillLookupCache(cache) {
|
|
|
43
45
|
function lookupCacheKey(kind, label, exact) {
|
|
44
46
|
return `${kind}:${exact ? 'exact' : 'fuzzy'}:${normalizedOptionLabel(label)}`;
|
|
45
47
|
}
|
|
46
|
-
function lookupCacheKeys(kind, label, exact, fieldId) {
|
|
48
|
+
function lookupCacheKeys(kind, label, exact, fieldId, fieldKey) {
|
|
47
49
|
const keys = [lookupCacheKey(kind, label, exact)];
|
|
48
50
|
if (fieldId)
|
|
49
51
|
keys.unshift(`${kind}:id:${fieldId}`);
|
|
52
|
+
if (fieldKey)
|
|
53
|
+
keys.unshift(`${kind}:key:${fieldKey}`);
|
|
50
54
|
return keys;
|
|
51
55
|
}
|
|
52
56
|
function cacheMapForKind(cache, kind) {
|
|
@@ -56,23 +60,157 @@ function cacheMapForKind(cache, kind) {
|
|
|
56
60
|
return cache.editable;
|
|
57
61
|
return cache.fileInput;
|
|
58
62
|
}
|
|
59
|
-
function readCachedLocator(cache, kind, label, exact, fieldId) {
|
|
63
|
+
async function readCachedLocator(cache, kind, label, exact, fieldId, fieldKey) {
|
|
60
64
|
if (!cache)
|
|
61
65
|
return undefined;
|
|
62
66
|
const map = cacheMapForKind(cache, kind);
|
|
67
|
+
const readLive = async (key) => {
|
|
68
|
+
if (!map.has(key))
|
|
69
|
+
return undefined;
|
|
70
|
+
const locator = map.get(key);
|
|
71
|
+
// Never retain negative lookups across reactive re-renders.
|
|
72
|
+
if (!locator) {
|
|
73
|
+
map.delete(key);
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
const attached = await locator.count() > 0;
|
|
78
|
+
const usable = attached && (kind === 'file' || await locator.isVisible());
|
|
79
|
+
const identityMatches = usable && fieldKey && key === `${kind}:key:${fieldKey}`
|
|
80
|
+
? await locatorMatchesAuthoredFieldKey(locator, fieldKey)
|
|
81
|
+
: usable;
|
|
82
|
+
if (identityMatches)
|
|
83
|
+
return locator;
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// Detached locators are normal after controlled-form re-renders.
|
|
87
|
+
}
|
|
88
|
+
map.delete(key);
|
|
89
|
+
return undefined;
|
|
90
|
+
};
|
|
91
|
+
// A newly supplied authored fieldKey must get a chance to resolve before a
|
|
92
|
+
// locator cached under the looser schema id or label is reused.
|
|
93
|
+
if (fieldKey) {
|
|
94
|
+
const key = `${kind}:key:${fieldKey}`;
|
|
95
|
+
return await readLive(key);
|
|
96
|
+
}
|
|
63
97
|
for (const key of lookupCacheKeys(kind, label, exact, fieldId)) {
|
|
64
|
-
|
|
65
|
-
|
|
98
|
+
const locator = await readLive(key);
|
|
99
|
+
if (locator)
|
|
100
|
+
return locator;
|
|
66
101
|
}
|
|
67
102
|
return undefined;
|
|
68
103
|
}
|
|
69
|
-
function writeCachedLocator(cache, kind, label, exact, fieldId, locator) {
|
|
104
|
+
function writeCachedLocator(cache, kind, label, exact, fieldId, fieldKey, locator) {
|
|
70
105
|
if (!cache)
|
|
71
106
|
return;
|
|
72
107
|
const map = cacheMapForKind(cache, kind);
|
|
73
|
-
|
|
74
|
-
|
|
108
|
+
const keys = fieldKey
|
|
109
|
+
? [`${kind}:key:${fieldKey}`, ...(fieldId ? [`${kind}:id:${fieldId}`] : [])]
|
|
110
|
+
: lookupCacheKeys(kind, label, exact, fieldId);
|
|
111
|
+
for (const key of keys) {
|
|
112
|
+
if (locator)
|
|
113
|
+
map.set(key, locator);
|
|
114
|
+
else
|
|
115
|
+
map.delete(key);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function parseAuthoredFieldKey(fieldKey) {
|
|
119
|
+
const normalized = fieldKey.trim();
|
|
120
|
+
if (!normalized || normalized !== fieldKey) {
|
|
121
|
+
throw new Error('fieldKey must be a trimmed, non-empty string');
|
|
122
|
+
}
|
|
123
|
+
if (normalized.startsWith('id:')) {
|
|
124
|
+
try {
|
|
125
|
+
const id = decodeURIComponent(normalized.slice(3));
|
|
126
|
+
if (id)
|
|
127
|
+
return { kind: 'id', id };
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
throw new Error('fieldKey contains an invalid encoded id');
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (normalized.startsWith('name:')) {
|
|
134
|
+
const parts = normalized.split(':');
|
|
135
|
+
const tag = parts[1]?.trim().toLowerCase();
|
|
136
|
+
const type = parts[2]?.trim().toLowerCase();
|
|
137
|
+
try {
|
|
138
|
+
const name = decodeURIComponent(parts.slice(3).join(':'));
|
|
139
|
+
if (tag && type && name)
|
|
140
|
+
return { kind: 'name', tag, type, name };
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
throw new Error('fieldKey contains an invalid encoded name');
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
throw new Error('fieldKey must use id:<id> or name:<tag>:<type-or-default>:<name>');
|
|
147
|
+
}
|
|
148
|
+
async function locatorMatchesAuthoredFieldKey(locator, fieldKey) {
|
|
149
|
+
const parsed = parseAuthoredFieldKey(fieldKey);
|
|
150
|
+
try {
|
|
151
|
+
return await locator.evaluate((element, key) => {
|
|
152
|
+
if (!(element instanceof Element))
|
|
153
|
+
return false;
|
|
154
|
+
if (key.kind === 'id')
|
|
155
|
+
return element.getAttribute('id') === key.id;
|
|
156
|
+
const authoredType = element.getAttribute('type')?.trim().toLowerCase() || 'default';
|
|
157
|
+
return element.tagName.toLowerCase() === key.tag &&
|
|
158
|
+
authoredType === key.type &&
|
|
159
|
+
element.getAttribute('name') === key.name;
|
|
160
|
+
}, parsed);
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
function selectorForAuthoredFieldKind(kind) {
|
|
167
|
+
if (kind === 'editable')
|
|
168
|
+
return EDITABLE_FIELD_SELECTOR;
|
|
169
|
+
if (kind === 'file')
|
|
170
|
+
return 'input[type="file"]';
|
|
171
|
+
if (kind === 'toggle')
|
|
172
|
+
return TOGGLE_CONTROL_SELECTOR;
|
|
173
|
+
return LABELED_CONTROL_SELECTOR;
|
|
174
|
+
}
|
|
175
|
+
async function findAuthoredFieldByKeyInPage(page, fieldKey, kind) {
|
|
176
|
+
const parsed = parseAuthoredFieldKey(fieldKey);
|
|
177
|
+
const matches = [];
|
|
178
|
+
for (const frame of page.frames()) {
|
|
179
|
+
const candidates = frame.locator(selectorForAuthoredFieldKind(kind));
|
|
180
|
+
const indices = await candidates.evaluateAll((elements, key) => {
|
|
181
|
+
const result = [];
|
|
182
|
+
for (let index = 0; index < elements.length; index++) {
|
|
183
|
+
const element = elements[index];
|
|
184
|
+
if (!(element instanceof Element))
|
|
185
|
+
continue;
|
|
186
|
+
if (key.kind === 'id') {
|
|
187
|
+
if (element.getAttribute('id') === key.id)
|
|
188
|
+
result.push(index);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
const authoredType = element.getAttribute('type')?.trim().toLowerCase() || 'default';
|
|
192
|
+
if (element.tagName.toLowerCase() === key.tag &&
|
|
193
|
+
authoredType === key.type &&
|
|
194
|
+
element.getAttribute('name') === key.name) {
|
|
195
|
+
result.push(index);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return result;
|
|
199
|
+
}, parsed);
|
|
200
|
+
for (const index of indices) {
|
|
201
|
+
const locator = candidates.nth(index);
|
|
202
|
+
if (kind === 'editable' && !(await locatorIsEditable(locator)))
|
|
203
|
+
continue;
|
|
204
|
+
if (kind !== 'file' && !(await locator.isVisible()))
|
|
205
|
+
continue;
|
|
206
|
+
matches.push({ locator, frameUrl: frame.url() });
|
|
207
|
+
}
|
|
75
208
|
}
|
|
209
|
+
if (matches.length > 1) {
|
|
210
|
+
const frames = [...new Set(matches.map(match => match.frameUrl || '<top-frame>'))];
|
|
211
|
+
throw new Error(`fieldKey "${fieldKey}" is ambiguous: matched ${matches.length} controls across ${frames.join(', ')}`);
|
|
212
|
+
}
|
|
213
|
+
return matches[0]?.locator ?? null;
|
|
76
214
|
}
|
|
77
215
|
function normalizedOptionLabel(value) {
|
|
78
216
|
return value
|
|
@@ -298,6 +436,97 @@ async function locatorIsEditable(locator) {
|
|
|
298
436
|
return false;
|
|
299
437
|
}
|
|
300
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
|
+
}
|
|
301
530
|
async function locatorAnchorY(locator) {
|
|
302
531
|
const bounds = await locator.boundingBox();
|
|
303
532
|
return bounds ? bounds.y + bounds.height / 2 : undefined;
|
|
@@ -438,7 +667,15 @@ async function findLabeledControl(frame, fieldLabel, exact, opts) {
|
|
|
438
667
|
const style = getComputedStyle(el);
|
|
439
668
|
return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0';
|
|
440
669
|
}
|
|
441
|
-
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) {
|
|
442
679
|
if (!ids)
|
|
443
680
|
return undefined;
|
|
444
681
|
const seen = visited ?? new Set();
|
|
@@ -448,12 +685,12 @@ async function findLabeledControl(frame, fieldLabel, exact, opts) {
|
|
|
448
685
|
if (seen.has(id))
|
|
449
686
|
return '';
|
|
450
687
|
seen.add(id);
|
|
451
|
-
const target =
|
|
688
|
+
const target = rootScopedElementById(owner, id);
|
|
452
689
|
if (!target)
|
|
453
690
|
return '';
|
|
454
691
|
const chained = target.getAttribute('aria-labelledby');
|
|
455
692
|
if (chained)
|
|
456
|
-
return referencedText(chained, seen) ?? '';
|
|
693
|
+
return referencedText(target, chained, seen) ?? '';
|
|
457
694
|
return target.textContent?.trim() ?? '';
|
|
458
695
|
})
|
|
459
696
|
.filter(Boolean)
|
|
@@ -465,7 +702,7 @@ async function findLabeledControl(frame, fieldLabel, exact, opts) {
|
|
|
465
702
|
const aria = el.getAttribute('aria-label')?.trim();
|
|
466
703
|
if (aria)
|
|
467
704
|
return aria;
|
|
468
|
-
const labelledBy = referencedText(el.getAttribute('aria-labelledby'));
|
|
705
|
+
const labelledBy = referencedText(el, el.getAttribute('aria-labelledby'));
|
|
469
706
|
if (labelledBy)
|
|
470
707
|
return labelledBy;
|
|
471
708
|
if ((el instanceof HTMLInputElement || el instanceof HTMLSelectElement || el instanceof HTMLTextAreaElement) &&
|
|
@@ -474,7 +711,10 @@ async function findLabeledControl(frame, fieldLabel, exact, opts) {
|
|
|
474
711
|
return el.labels[0]?.textContent?.trim() || undefined;
|
|
475
712
|
}
|
|
476
713
|
if (el instanceof HTMLElement && el.id) {
|
|
477
|
-
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;
|
|
478
718
|
const text = label?.textContent?.trim();
|
|
479
719
|
if (text)
|
|
480
720
|
return text;
|
|
@@ -631,6 +871,14 @@ async function findMenuTriggerByContainerLabel(frame, fieldLabel, exact) {
|
|
|
631
871
|
const style = getComputedStyle(el);
|
|
632
872
|
return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0';
|
|
633
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
|
+
}
|
|
634
882
|
function containerLabelText(el) {
|
|
635
883
|
// Walk up a few parent levels looking for a container that holds BOTH
|
|
636
884
|
// the trigger and some sibling label text. The label text is the
|
|
@@ -673,7 +921,7 @@ async function findMenuTriggerByContainerLabel(frame, fieldLabel, exact) {
|
|
|
673
921
|
if (labelledBy) {
|
|
674
922
|
const text = labelledBy
|
|
675
923
|
.split(/\s+/)
|
|
676
|
-
.map(id =>
|
|
924
|
+
.map(id => rootScopedElementById(el, id)?.textContent?.trim() ?? '')
|
|
677
925
|
.filter(Boolean)
|
|
678
926
|
.join(' ')
|
|
679
927
|
.trim();
|
|
@@ -697,12 +945,94 @@ async function findMenuTriggerByContainerLabel(frame, fieldLabel, exact) {
|
|
|
697
945
|
}, { fieldLabel, exact });
|
|
698
946
|
return bestIndex >= 0 ? triggers.nth(bestIndex) : null;
|
|
699
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
|
+
}
|
|
700
1013
|
async function findLabeledControlInPage(page, fieldLabel, exact, opts) {
|
|
701
1014
|
const cacheable = !opts?.preferredAnchor;
|
|
702
|
-
const cached = cacheable
|
|
1015
|
+
const cached = cacheable
|
|
1016
|
+
? await readCachedLocator(opts?.cache, 'control', fieldLabel, exact, opts?.fieldId, opts?.fieldKey)
|
|
1017
|
+
: undefined;
|
|
703
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
|
+
}
|
|
704
1022
|
return cached;
|
|
705
1023
|
}
|
|
1024
|
+
if (opts?.fieldKey) {
|
|
1025
|
+
const authored = await findAuthoredFieldByKeyInPage(page, opts.fieldKey, 'control');
|
|
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
|
+
}
|
|
1030
|
+
if (cacheable)
|
|
1031
|
+
writeCachedLocator(opts?.cache, 'control', fieldLabel, exact, opts?.fieldId, opts.fieldKey, authored);
|
|
1032
|
+
return authored;
|
|
1033
|
+
}
|
|
1034
|
+
throw new Error(`fieldKey "${opts.fieldKey}" did not resolve to a visible control; refresh geometra_form_schema`);
|
|
1035
|
+
}
|
|
706
1036
|
// Try the label as-is first. If the caller passed a fully-qualified DOM
|
|
707
1037
|
// label, that's the most accurate match.
|
|
708
1038
|
for (const frame of page.frames()) {
|
|
@@ -710,7 +1040,7 @@ async function findLabeledControlInPage(page, fieldLabel, exact, opts) {
|
|
|
710
1040
|
if (!locator)
|
|
711
1041
|
continue;
|
|
712
1042
|
if (cacheable)
|
|
713
|
-
writeCachedLocator(opts?.cache, 'control', fieldLabel, exact, opts?.fieldId, locator);
|
|
1043
|
+
writeCachedLocator(opts?.cache, 'control', fieldLabel, exact, opts?.fieldId, undefined, locator);
|
|
714
1044
|
return locator;
|
|
715
1045
|
}
|
|
716
1046
|
// Fallback: if the label looks truncated (ends in U+2026 or "..."), strip
|
|
@@ -726,7 +1056,7 @@ async function findLabeledControlInPage(page, fieldLabel, exact, opts) {
|
|
|
726
1056
|
if (!locator)
|
|
727
1057
|
continue;
|
|
728
1058
|
if (cacheable)
|
|
729
|
-
writeCachedLocator(opts?.cache, 'control', fieldLabel, exact, opts?.fieldId, locator);
|
|
1059
|
+
writeCachedLocator(opts?.cache, 'control', fieldLabel, exact, opts?.fieldId, undefined, locator);
|
|
730
1060
|
return locator;
|
|
731
1061
|
}
|
|
732
1062
|
}
|
|
@@ -741,12 +1071,12 @@ async function findLabeledControlInPage(page, fieldLabel, exact, opts) {
|
|
|
741
1071
|
if (!locator)
|
|
742
1072
|
continue;
|
|
743
1073
|
if (cacheable)
|
|
744
|
-
writeCachedLocator(opts?.cache, 'control', fieldLabel, exact, opts?.fieldId, locator);
|
|
1074
|
+
writeCachedLocator(opts?.cache, 'control', fieldLabel, exact, opts?.fieldId, undefined, locator);
|
|
745
1075
|
return locator;
|
|
746
1076
|
}
|
|
747
1077
|
}
|
|
748
1078
|
if (cacheable)
|
|
749
|
-
writeCachedLocator(opts?.cache, 'control', fieldLabel, exact, opts?.fieldId, null);
|
|
1079
|
+
writeCachedLocator(opts?.cache, 'control', fieldLabel, exact, opts?.fieldId, undefined, null);
|
|
750
1080
|
return null;
|
|
751
1081
|
}
|
|
752
1082
|
function textMatches(candidate, expected, exact) {
|
|
@@ -763,9 +1093,10 @@ function displayedValueMatchesSelection(candidate, expected, exact, selectedOpti
|
|
|
763
1093
|
return false;
|
|
764
1094
|
return (normalizedSelectedOption.includes(normalizedCandidate) || normalizedCandidate.includes(normalizedSelectedOption));
|
|
765
1095
|
}
|
|
766
|
-
async function openDropdownControl(page, fieldLabel, exact, cache, fieldId) {
|
|
767
|
-
const locator = await findLabeledControlInPage(page, fieldLabel, exact, { cache, fieldId });
|
|
1096
|
+
async function openDropdownControl(page, fieldLabel, exact, cache, fieldId, fieldKey) {
|
|
1097
|
+
const locator = await findLabeledControlInPage(page, fieldLabel, exact, { cache, fieldId, fieldKey });
|
|
768
1098
|
if (locator) {
|
|
1099
|
+
await assertLocatorMutable(locator, 'select', 'listboxPick');
|
|
769
1100
|
await locator.scrollIntoViewIfNeeded();
|
|
770
1101
|
const handle = await locator.elementHandle();
|
|
771
1102
|
const clickTarget = await resolveMeaningfulClickTarget(locator);
|
|
@@ -866,11 +1197,19 @@ async function resolveOwnedPopupHandle(triggerHandle) {
|
|
|
866
1197
|
}
|
|
867
1198
|
return ids;
|
|
868
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
|
+
}
|
|
869
1208
|
function lookupReferenced() {
|
|
870
1209
|
const attributes = ['aria-controls', 'aria-owns', 'aria-activedescendant'];
|
|
871
1210
|
for (const attribute of attributes) {
|
|
872
1211
|
for (const id of readIdRefs(el, attribute)) {
|
|
873
|
-
const referenced =
|
|
1212
|
+
const referenced = rootScopedElementById(el, id);
|
|
874
1213
|
const popup = asPopupRoot(referenced);
|
|
875
1214
|
if (popup)
|
|
876
1215
|
return popup;
|
|
@@ -1506,6 +1845,8 @@ async function clickScopedOptionCandidate(popupScope, label, exact) {
|
|
|
1506
1845
|
}, hit.selector)).asElement();
|
|
1507
1846
|
if (!target)
|
|
1508
1847
|
return null;
|
|
1848
|
+
if (await target.evaluate(mutationBlockReasonInPage, 'select'))
|
|
1849
|
+
return null;
|
|
1509
1850
|
try {
|
|
1510
1851
|
await target.scrollIntoViewIfNeeded({ timeout: 500 });
|
|
1511
1852
|
}
|
|
@@ -1690,6 +2031,8 @@ async function clickVisibleOptionCandidate(page, label, exact, anchor, popupScop
|
|
|
1690
2031
|
});
|
|
1691
2032
|
if (bestIndex >= 0) {
|
|
1692
2033
|
const candidate = candidates.nth(bestIndex);
|
|
2034
|
+
if (await locatorMutationBlockReason(candidate, 'select'))
|
|
2035
|
+
return null;
|
|
1693
2036
|
const selectedText = (await candidate.evaluate(el => el.getAttribute('aria-label')?.trim() || el.textContent?.trim() || '').catch(() => '')) || null;
|
|
1694
2037
|
const clickTarget = await resolveMeaningfulOptionClickTarget(candidate);
|
|
1695
2038
|
// Only scrollIntoView if the option is NOT inside a popup/dropdown container.
|
|
@@ -1876,116 +2219,6 @@ async function readTriggerShowsPlaceholder(handle) {
|
|
|
1876
2219
|
return false;
|
|
1877
2220
|
}
|
|
1878
2221
|
}
|
|
1879
|
-
/**
|
|
1880
|
-
* Detect whether a combobox trigger is a "searchable/autocomplete style"
|
|
1881
|
-
* combobox (React Select, Headless UI, Radix, Ant Design Select, etc.).
|
|
1882
|
-
*
|
|
1883
|
-
* These libraries commit their controlled `onChange` state on **keyboard
|
|
1884
|
-
* Enter**, not on a synthetic mouse click dispatched via Playwright's
|
|
1885
|
-
* `.click()`. For regular mouse interactions they use a bubbling
|
|
1886
|
-
* `mousedown`/`pointerdown` path that does not round-trip through React
|
|
1887
|
-
* Select's internal `selectOption` handler in some Remix-wrapped builds
|
|
1888
|
-
* (notably Greenhouse's ATS embed), so the option click fires visually but
|
|
1889
|
-
* the form state stays empty. Pressing Enter on the focused combobox input
|
|
1890
|
-
* after the click puts the selection through the keyboard code path which
|
|
1891
|
-
* ALWAYS commits.
|
|
1892
|
-
*
|
|
1893
|
-
* Detection is fully generic — no hostname or site-specific branching.
|
|
1894
|
-
* The signals we look for are:
|
|
1895
|
-
*
|
|
1896
|
-
* 1. `aria-autocomplete` on the element or its nearest ancestor input is
|
|
1897
|
-
* `"list"` or `"both"`. This is the standards-aligned signal for any
|
|
1898
|
-
* combobox that filters options as the user types.
|
|
1899
|
-
* 2. A class pattern indicative of React Select (`select__control`,
|
|
1900
|
-
* `select__`), React Suite picker (`rs-picker`), or Ant Design Select
|
|
1901
|
-
* (`ant-select`) on the element or any ancestor up to 5 levels.
|
|
1902
|
-
* 3. `role="combobox"` present together with `aria-expanded` on the
|
|
1903
|
-
* element or an ancestor — the ARIA 1.2 combobox pattern.
|
|
1904
|
-
*
|
|
1905
|
-
* Native `<select>` elements do NOT match any of these signals, so the
|
|
1906
|
-
* caller can safely gate its `Enter` dispatch on this check without
|
|
1907
|
-
* breaking non-searchable listboxes.
|
|
1908
|
-
*/
|
|
1909
|
-
async function isAutocompleteCombobox(handle) {
|
|
1910
|
-
if (!handle)
|
|
1911
|
-
return false;
|
|
1912
|
-
try {
|
|
1913
|
-
return await handle.evaluate((el) => {
|
|
1914
|
-
if (!(el instanceof Element))
|
|
1915
|
-
return false;
|
|
1916
|
-
let cur = el;
|
|
1917
|
-
let depth = 0;
|
|
1918
|
-
while (cur && depth < 5) {
|
|
1919
|
-
// aria-autocomplete on the element or an ancestor input/combobox.
|
|
1920
|
-
const ac = cur.getAttribute('aria-autocomplete');
|
|
1921
|
-
if (ac === 'list' || ac === 'both')
|
|
1922
|
-
return true;
|
|
1923
|
-
// Class-based fingerprint for known autocomplete libraries.
|
|
1924
|
-
const className = (cur.getAttribute('class') ?? '').toLowerCase();
|
|
1925
|
-
if (className) {
|
|
1926
|
-
if (className.includes('select__control') ||
|
|
1927
|
-
className.includes('select__') ||
|
|
1928
|
-
className.includes('rs-picker') ||
|
|
1929
|
-
className.includes('ant-select') ||
|
|
1930
|
-
className.includes('headlessui-combobox') ||
|
|
1931
|
-
className.includes('cmdk-')) {
|
|
1932
|
-
return true;
|
|
1933
|
-
}
|
|
1934
|
-
}
|
|
1935
|
-
// ARIA 1.2 combobox pattern: role="combobox" + aria-expanded is a
|
|
1936
|
-
// strong signal that the control commits via keyboard semantics.
|
|
1937
|
-
if (cur.getAttribute('role') === 'combobox' && cur.hasAttribute('aria-expanded')) {
|
|
1938
|
-
return true;
|
|
1939
|
-
}
|
|
1940
|
-
cur = cur.parentElement;
|
|
1941
|
-
depth++;
|
|
1942
|
-
}
|
|
1943
|
-
// Also look downward for a descendant input that declares
|
|
1944
|
-
// aria-autocomplete — some Headless UI wrappers expose the control via
|
|
1945
|
-
// a sibling input inside the trigger container.
|
|
1946
|
-
const descendant = el.querySelector?.('[aria-autocomplete="list"], [aria-autocomplete="both"]');
|
|
1947
|
-
return !!descendant;
|
|
1948
|
-
});
|
|
1949
|
-
}
|
|
1950
|
-
catch {
|
|
1951
|
-
return false;
|
|
1952
|
-
}
|
|
1953
|
-
}
|
|
1954
|
-
/**
|
|
1955
|
-
* Dispatch a keyboard `Enter` to commit an autocomplete-style combobox's
|
|
1956
|
-
* selection after the option has been clicked. Only fires if the trigger
|
|
1957
|
-
* looks like a searchable combobox per {@link isAutocompleteCombobox}.
|
|
1958
|
-
*
|
|
1959
|
-
* Why this exists: Playwright's `.click()` on a React Select option DOM
|
|
1960
|
-
* element dispatches a synthetic `pointerdown`/`mouseup`/`click` sequence
|
|
1961
|
-
* that a subset of React Select forks (and every Remix-wrapped Greenhouse
|
|
1962
|
-
* ATS build we've seen) will NOT commit through `selectOption`. The
|
|
1963
|
-
* library's `keydown Enter` handler, however, unconditionally commits via
|
|
1964
|
-
* the same internal `setValue` the visual selection uses. Pressing Enter
|
|
1965
|
-
* after the click is therefore a universal "force commit" primitive for
|
|
1966
|
-
* searchable comboboxes that benefit every library without touching the
|
|
1967
|
-
* native-<select> happy path.
|
|
1968
|
-
*
|
|
1969
|
-
* Intentionally swallows errors — this is a best-effort commit helper and
|
|
1970
|
-
* a missing page/focus should not break the surrounding selection flow.
|
|
1971
|
-
*/
|
|
1972
|
-
async function pressEnterToCommitListbox(page, handle) {
|
|
1973
|
-
if (!handle)
|
|
1974
|
-
return false;
|
|
1975
|
-
if (!(await isAutocompleteCombobox(handle)))
|
|
1976
|
-
return false;
|
|
1977
|
-
try {
|
|
1978
|
-
await page.keyboard.press('Enter');
|
|
1979
|
-
// Short settle window for the library to run its onChange -> form
|
|
1980
|
-
// state update. 80ms is the same budget used elsewhere in this file
|
|
1981
|
-
// for React Select commit propagation.
|
|
1982
|
-
await delay(80);
|
|
1983
|
-
return true;
|
|
1984
|
-
}
|
|
1985
|
-
catch {
|
|
1986
|
-
return false;
|
|
1987
|
-
}
|
|
1988
|
-
}
|
|
1989
2222
|
/**
|
|
1990
2223
|
* After a custom listbox option click, make the committed value visible to
|
|
1991
2224
|
* framework state managers that listen on the backing input/select instead of
|
|
@@ -2507,6 +2740,22 @@ async function confirmListboxSelection(page, fieldLabel, label, exact, anchor, c
|
|
|
2507
2740
|
}
|
|
2508
2741
|
return false;
|
|
2509
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
|
+
}
|
|
2510
2759
|
const PLACEHOLDER_PATTERN = /^(select|choose|pick|--|—\s)/i;
|
|
2511
2760
|
/**
|
|
2512
2761
|
* Dismiss the dropdown (Tab) and re-verify the field value didn't revert to a placeholder.
|
|
@@ -2537,7 +2786,7 @@ async function dismissAndReVerifySelection(page, label, exact, currentHandle, se
|
|
|
2537
2786
|
}
|
|
2538
2787
|
await delay(50);
|
|
2539
2788
|
if (!currentHandle)
|
|
2540
|
-
return
|
|
2789
|
+
return false;
|
|
2541
2790
|
// Re-verify using the original element handle (immune to DOM reordering)
|
|
2542
2791
|
const deadline = Date.now() + 800;
|
|
2543
2792
|
let sawAnyValue = false;
|
|
@@ -2572,8 +2821,9 @@ async function dismissAndReVerifySelection(page, label, exact, currentHandle, se
|
|
|
2572
2821
|
}
|
|
2573
2822
|
}
|
|
2574
2823
|
catch {
|
|
2575
|
-
//
|
|
2576
|
-
|
|
2824
|
+
// A detached trigger has no observable committed state. Fail closed;
|
|
2825
|
+
// callers can re-resolve the live field and retry.
|
|
2826
|
+
return false;
|
|
2577
2827
|
}
|
|
2578
2828
|
await delay(100);
|
|
2579
2829
|
}
|
|
@@ -2609,192 +2859,580 @@ async function dismissAndReVerifySelection(page, label, exact, currentHandle, se
|
|
|
2609
2859
|
/**
|
|
2610
2860
|
* Resolve and validate paths on the machine running the proxy (not the agent host).
|
|
2611
2861
|
*/
|
|
2612
|
-
export function resolveExistingFiles(rawPaths) {
|
|
2862
|
+
export function resolveExistingFiles(rawPaths, allowedRoots = []) {
|
|
2613
2863
|
if (!Array.isArray(rawPaths) || rawPaths.length === 0) {
|
|
2614
2864
|
throw new Error('file: paths must be a non-empty array of strings');
|
|
2615
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
|
+
});
|
|
2616
2879
|
const paths = [];
|
|
2617
2880
|
for (const p of rawPaths) {
|
|
2618
2881
|
if (typeof p !== 'string' || p.trim() === '')
|
|
2619
2882
|
continue;
|
|
2620
|
-
|
|
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);
|
|
2621
2896
|
}
|
|
2622
2897
|
if (paths.length === 0)
|
|
2623
2898
|
throw new Error('file: paths must contain at least one non-empty string');
|
|
2624
|
-
for (const p of paths) {
|
|
2625
|
-
if (!existsSync(p))
|
|
2626
|
-
throw new Error(`file: path does not exist: ${p}`);
|
|
2627
|
-
}
|
|
2628
2899
|
return paths;
|
|
2629
2900
|
}
|
|
2630
|
-
async function
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
for (let i = 0; i < exactDirectCount; i++) {
|
|
2640
|
-
const candidate = exactDirect.nth(i);
|
|
2641
|
-
const isFileInput = await candidate.evaluate(el => el instanceof HTMLInputElement && el.type === 'file').catch(() => false);
|
|
2642
|
-
if (isFileInput)
|
|
2643
|
-
return candidate;
|
|
2644
|
-
}
|
|
2645
|
-
if (!exact) {
|
|
2646
|
-
const direct = frame.getByLabel(fieldLabel, { exact: false });
|
|
2647
|
-
const directCount = await direct.count();
|
|
2648
|
-
for (let i = 0; i < directCount; i++) {
|
|
2649
|
-
const candidate = direct.nth(i);
|
|
2650
|
-
const isFileInput = await candidate.evaluate(el => el instanceof HTMLInputElement && el.type === 'file').catch(() => false);
|
|
2651
|
-
if (isFileInput)
|
|
2652
|
-
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;
|
|
2653
2910
|
}
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
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();
|
|
2920
|
+
}
|
|
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) => {
|
|
2660
2936
|
function normalize(value) {
|
|
2661
|
-
return value.replace(/\s+/g, ' ').trim().toLowerCase();
|
|
2937
|
+
return (value ?? '').replace(/\s+/g, ' ').trim().toLowerCase();
|
|
2662
2938
|
}
|
|
2663
|
-
function
|
|
2664
|
-
|
|
2665
|
-
|
|
2666
|
-
|
|
2667
|
-
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
return payload.exact ? normalizedCandidate === normalizedExpected : normalizedCandidate.includes(normalizedExpected);
|
|
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;
|
|
2671
2946
|
}
|
|
2672
|
-
function referencedText(
|
|
2947
|
+
function referencedText(owner, ids) {
|
|
2673
2948
|
if (!ids)
|
|
2674
|
-
return
|
|
2675
|
-
|
|
2676
|
-
const text = ids
|
|
2949
|
+
return '';
|
|
2950
|
+
return ids
|
|
2677
2951
|
.split(/\s+/)
|
|
2678
|
-
.map(id =>
|
|
2679
|
-
if (seen.has(id))
|
|
2680
|
-
return '';
|
|
2681
|
-
seen.add(id);
|
|
2682
|
-
const target = document.getElementById(id);
|
|
2683
|
-
if (!target)
|
|
2684
|
-
return '';
|
|
2685
|
-
const chained = target.getAttribute('aria-labelledby');
|
|
2686
|
-
if (chained)
|
|
2687
|
-
return referencedText(chained, seen) ?? '';
|
|
2688
|
-
return target.textContent?.trim() ?? '';
|
|
2689
|
-
})
|
|
2952
|
+
.map(id => rootScopedElementById(owner, id)?.textContent?.trim() ?? '')
|
|
2690
2953
|
.filter(Boolean)
|
|
2691
2954
|
.join(' ')
|
|
2692
2955
|
.trim();
|
|
2693
|
-
return text || undefined;
|
|
2694
2956
|
}
|
|
2695
|
-
function
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
if (
|
|
2714
|
-
|
|
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;
|
|
2715
2986
|
}
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
return title;
|
|
2719
|
-
return undefined;
|
|
2987
|
+
if (!normalize(nearestContext).includes(contextNeedle))
|
|
2988
|
+
return false;
|
|
2720
2989
|
}
|
|
2721
|
-
|
|
2722
|
-
const el =
|
|
2723
|
-
if (!
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
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;
|
|
2727
3000
|
}
|
|
2728
|
-
return
|
|
2729
|
-
}, {
|
|
2730
|
-
return bestIndex >= 0 ? loc.nth(bestIndex) : null;
|
|
3001
|
+
return true;
|
|
3002
|
+
}, { contextText: contextText ?? '', sectionText: sectionText ?? '' }).catch(() => false);
|
|
2731
3003
|
}
|
|
2732
|
-
async function
|
|
2733
|
-
const
|
|
2734
|
-
|
|
2735
|
-
|
|
3004
|
+
async function findScopedFileInputsInPage(page, fieldLabel, exact, contextText, sectionText) {
|
|
3005
|
+
const exactMatches = [];
|
|
3006
|
+
const fuzzyMatches = [];
|
|
3007
|
+
const expected = normalizedOptionLabel(fieldLabel);
|
|
2736
3008
|
for (const frame of page.frames()) {
|
|
2737
|
-
const
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
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)))
|
|
3014
|
+
continue;
|
|
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
|
+
}
|
|
3025
|
+
}
|
|
3026
|
+
}
|
|
3027
|
+
return exactMatches.length > 0 ? exactMatches : fuzzyMatches;
|
|
2745
3028
|
}
|
|
2746
|
-
async function
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
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
|
+
}
|
|
3042
|
+
return cached;
|
|
3043
|
+
}
|
|
3044
|
+
if (fieldKey) {
|
|
3045
|
+
const authored = await findAuthoredFieldByKeyInPage(page, fieldKey, 'file');
|
|
3046
|
+
if (authored) {
|
|
3047
|
+
if (fieldLabel) {
|
|
3048
|
+
const resolvedLabel = normalizedOptionLabel(await fileInputLabel(authored));
|
|
3049
|
+
const expectedLabel = normalizedOptionLabel(fieldLabel);
|
|
3050
|
+
const labelMatches = exact
|
|
3051
|
+
? resolvedLabel === expectedLabel
|
|
3052
|
+
: resolvedLabel === expectedLabel || resolvedLabel.includes(expectedLabel);
|
|
3053
|
+
if (!labelMatches) {
|
|
3054
|
+
throw new Error(`file: fieldKey "${fieldKey}" did not match field label "${fieldLabel}"`);
|
|
3055
|
+
}
|
|
2753
3056
|
}
|
|
2754
|
-
|
|
2755
|
-
|
|
3057
|
+
if (!(await locatorMatchesFileScope(authored, contextText, sectionText))) {
|
|
3058
|
+
throw new Error(`file: fieldKey "${fieldKey}" did not match the requested context/section`);
|
|
2756
3059
|
}
|
|
3060
|
+
if (!scoped)
|
|
3061
|
+
writeCachedLocator(cache, 'file', fieldLabel, exact, fieldId, fieldKey, authored);
|
|
3062
|
+
return authored;
|
|
2757
3063
|
}
|
|
3064
|
+
throw new Error(`fieldKey "${fieldKey}" did not resolve to a file input; refresh geometra_form_schema`);
|
|
2758
3065
|
}
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
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
|
+
}
|
|
3073
|
+
if (fieldLabel) {
|
|
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) {
|
|
3080
|
+
writeCachedLocator(cache, 'file', fieldLabel, exact, fieldId, undefined, locator);
|
|
3081
|
+
return locator;
|
|
3082
|
+
}
|
|
3083
|
+
}
|
|
3084
|
+
writeCachedLocator(cache, 'file', fieldLabel, exact, fieldId, undefined, null);
|
|
3085
|
+
return null;
|
|
3086
|
+
}
|
|
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) {
|
|
2764
3191
|
try {
|
|
2765
|
-
|
|
2766
|
-
|
|
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);
|
|
2767
3214
|
}
|
|
2768
|
-
|
|
2769
|
-
|
|
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;
|
|
2770
3247
|
}
|
|
2771
|
-
|
|
3248
|
+
return false;
|
|
3249
|
+
}
|
|
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})`);
|
|
3267
|
+
}
|
|
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})`);
|
|
3296
|
+
}
|
|
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);
|
|
2772
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()) {
|
|
2773
3397
|
const loc = frame.locator('input[type="file"]');
|
|
2774
3398
|
const n = await loc.count();
|
|
2775
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;
|
|
2776
3405
|
try {
|
|
2777
|
-
await
|
|
3406
|
+
await commitFilesToExactInput(handle, paths, 'matched input');
|
|
2778
3407
|
return true;
|
|
2779
3408
|
}
|
|
2780
|
-
catch {
|
|
2781
|
-
|
|
3409
|
+
catch (error) {
|
|
3410
|
+
if (error instanceof FileAcceptValidationError) {
|
|
3411
|
+
acceptError ??= error;
|
|
3412
|
+
continue;
|
|
3413
|
+
}
|
|
3414
|
+
throw error;
|
|
2782
3415
|
}
|
|
2783
3416
|
}
|
|
2784
3417
|
}
|
|
3418
|
+
if (acceptError)
|
|
3419
|
+
throw acceptError;
|
|
2785
3420
|
return false;
|
|
2786
3421
|
}
|
|
2787
3422
|
async function attachViaChooser(page, paths, clickX, clickY) {
|
|
3423
|
+
await assertPointMutable(page, clickX, clickY, 'file', 'file');
|
|
2788
3424
|
const [chooser] = await Promise.all([
|
|
2789
3425
|
page.waitForEvent('filechooser', { timeout: 12_000 }),
|
|
2790
3426
|
page.mouse.click(clickX, clickY),
|
|
2791
3427
|
]);
|
|
2792
|
-
|
|
3428
|
+
const input = chooser.element();
|
|
3429
|
+
await commitFilesToExactInput(input, paths, 'chooser input');
|
|
2793
3430
|
}
|
|
2794
3431
|
/**
|
|
2795
3432
|
* Map common file extensions to MIME types so react-dropzone's `accept` filter
|
|
2796
|
-
* does not silently drop our synthetic files.
|
|
2797
|
-
*
|
|
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.
|
|
2798
3436
|
*/
|
|
2799
3437
|
function mimeTypeForPath(path) {
|
|
2800
3438
|
const ext = path.toLowerCase().split('.').pop() ?? '';
|
|
@@ -2802,7 +3440,20 @@ function mimeTypeForPath(path) {
|
|
|
2802
3440
|
case 'pdf': return 'application/pdf';
|
|
2803
3441
|
case 'doc': return 'application/msword';
|
|
2804
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';
|
|
2805
3453
|
case 'txt': return 'text/plain';
|
|
3454
|
+
case 'csv': return 'text/csv';
|
|
3455
|
+
case 'htm':
|
|
3456
|
+
case 'html': return 'text/html';
|
|
2806
3457
|
case 'rtf': return 'application/rtf';
|
|
2807
3458
|
case 'png': return 'image/png';
|
|
2808
3459
|
case 'jpg':
|
|
@@ -2810,9 +3461,76 @@ function mimeTypeForPath(path) {
|
|
|
2810
3461
|
case 'gif': return 'image/gif';
|
|
2811
3462
|
case 'webp': return 'image/webp';
|
|
2812
3463
|
case 'svg': return 'image/svg+xml';
|
|
2813
|
-
|
|
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;
|
|
2814
3476
|
}
|
|
2815
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
|
+
}
|
|
2816
3534
|
/**
|
|
2817
3535
|
* Synthetic drop at (x,y) using file bytes from the proxy host.
|
|
2818
3536
|
*
|
|
@@ -2830,91 +3548,260 @@ function mimeTypeForPath(path) {
|
|
|
2830
3548
|
* button layer and the wrapper that actually has the listeners.
|
|
2831
3549
|
*/
|
|
2832
3550
|
async function attachViaDropPlaywright(page, paths, dropX, dropY) {
|
|
2833
|
-
const
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
const
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
const
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
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
|
+
}
|
|
2863
3586
|
try {
|
|
2864
|
-
|
|
3587
|
+
if (target.matches(':disabled'))
|
|
3588
|
+
return 'disabled';
|
|
2865
3589
|
}
|
|
2866
3590
|
catch { /* ignore */ }
|
|
2867
|
-
target.
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
tag === 'label' ||
|
|
2886
|
-
p.getAttribute('role') === 'button';
|
|
2887
|
-
if (looksLikeDropzone && !targets.includes(p))
|
|
2888
|
-
targets.push(p);
|
|
2889
|
-
p = p.parentElement;
|
|
2890
|
-
}
|
|
2891
|
-
if (targets.length === 0)
|
|
2892
|
-
targets.push(document.body);
|
|
2893
|
-
for (const t of targets)
|
|
2894
|
-
dispatchSequence(t);
|
|
2895
|
-
// As a final fallback, if there is a hidden input[type=file] scoped to
|
|
2896
|
-
// the nearest form/section, set its `files` via DataTransfer and dispatch
|
|
2897
|
-
// the React-friendly native input value setter so react-hook-form notices.
|
|
2898
|
-
const host = deepest?.closest('form, [role="form"], fieldset, section, div') ?? document.body;
|
|
2899
|
-
const fileInput = host.querySelector('input[type="file"]');
|
|
2900
|
-
if (fileInput) {
|
|
2901
|
-
const dt = makeDataTransfer();
|
|
2902
|
-
try {
|
|
2903
|
-
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;
|
|
2904
3609
|
}
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
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
|
+
}
|
|
2912
3731
|
}
|
|
2913
|
-
|
|
2914
|
-
|
|
2915
|
-
|
|
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');
|
|
3794
|
+
}
|
|
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');
|
|
2916
3800
|
}
|
|
2917
|
-
}
|
|
3801
|
+
}
|
|
3802
|
+
finally {
|
|
3803
|
+
await dropFileInput?.dispose().catch(() => { });
|
|
3804
|
+
}
|
|
2918
3805
|
}
|
|
2919
3806
|
/**
|
|
2920
3807
|
* `strategy`:
|
|
@@ -2928,7 +3815,8 @@ const ATTACH_BUTTON_PATTERN = /attach|upload|add.file|choose.file|browse/i;
|
|
|
2928
3815
|
* Find an Attach/Upload button near a field label (e.g. Greenhouse's "Attach" button
|
|
2929
3816
|
* inside a "Resume/CV" section). Returns the button Locator or null.
|
|
2930
3817
|
*/
|
|
2931
|
-
async function findAttachButtonNearLabel(page, fieldLabel) {
|
|
3818
|
+
async function findAttachButtonNearLabel(page, fieldLabel, contextText, sectionText) {
|
|
3819
|
+
const matches = [];
|
|
2932
3820
|
for (const frame of page.frames()) {
|
|
2933
3821
|
// Find all buttons/links whose text matches attach-like patterns
|
|
2934
3822
|
const buttons = frame.locator('button, a, [role="button"]');
|
|
@@ -2952,15 +3840,18 @@ async function findAttachButtonNearLabel(page, fieldLabel) {
|
|
|
2952
3840
|
}
|
|
2953
3841
|
return false;
|
|
2954
3842
|
}, fieldLabel);
|
|
2955
|
-
if (nearLabel)
|
|
2956
|
-
|
|
3843
|
+
if (nearLabel && await locatorMatchesFileScope(btn, contextText, sectionText))
|
|
3844
|
+
matches.push(btn);
|
|
2957
3845
|
}
|
|
2958
3846
|
catch {
|
|
2959
3847
|
continue;
|
|
2960
3848
|
}
|
|
2961
3849
|
}
|
|
2962
3850
|
}
|
|
2963
|
-
|
|
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;
|
|
2964
3855
|
}
|
|
2965
3856
|
export async function attachFiles(page, paths, opts) {
|
|
2966
3857
|
const strategy = opts?.strategy ?? 'auto';
|
|
@@ -2970,6 +3861,51 @@ export async function attachFiles(page, paths, opts) {
|
|
|
2970
3861
|
const exact = opts?.exact ?? false;
|
|
2971
3862
|
const dropX = opts?.dropX;
|
|
2972
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
|
+
}
|
|
2973
3909
|
if (strategy === 'chooser' || (strategy === 'auto' && clickX !== undefined && clickY !== undefined)) {
|
|
2974
3910
|
if (clickX === undefined || clickY === undefined) {
|
|
2975
3911
|
throw new Error('file: chooser strategy requires x,y click coordinates');
|
|
@@ -2978,7 +3914,15 @@ export async function attachFiles(page, paths, opts) {
|
|
|
2978
3914
|
return;
|
|
2979
3915
|
}
|
|
2980
3916
|
if (strategy === 'hidden' || strategy === 'auto') {
|
|
2981
|
-
if (await attachHiddenInAllFrames(page, paths, {
|
|
3917
|
+
if (await attachHiddenInAllFrames(page, paths, {
|
|
3918
|
+
fieldId: opts?.fieldId,
|
|
3919
|
+
fieldKey: opts?.fieldKey,
|
|
3920
|
+
fieldLabel,
|
|
3921
|
+
exact,
|
|
3922
|
+
contextText: opts?.contextText,
|
|
3923
|
+
sectionText: opts?.sectionText,
|
|
3924
|
+
cache: opts?.cache,
|
|
3925
|
+
}))
|
|
2982
3926
|
return;
|
|
2983
3927
|
if (strategy === 'hidden') {
|
|
2984
3928
|
if (fieldLabel) {
|
|
@@ -2988,9 +3932,10 @@ export async function attachFiles(page, paths, opts) {
|
|
|
2988
3932
|
}
|
|
2989
3933
|
// Fallback: look for an Attach/Upload button near the field label and use the file chooser
|
|
2990
3934
|
if (fieldLabel && strategy === 'auto') {
|
|
2991
|
-
const attachBtn = await findAttachButtonNearLabel(page, fieldLabel);
|
|
3935
|
+
const attachBtn = await findAttachButtonNearLabel(page, fieldLabel, opts?.contextText, opts?.sectionText);
|
|
2992
3936
|
if (attachBtn) {
|
|
2993
3937
|
try {
|
|
3938
|
+
await assertLocatorMutable(attachBtn, 'file', 'file');
|
|
2994
3939
|
await attachBtn.scrollIntoViewIfNeeded();
|
|
2995
3940
|
const box = await attachBtn.boundingBox();
|
|
2996
3941
|
if (box) {
|
|
@@ -3004,6 +3949,12 @@ export async function attachFiles(page, paths, opts) {
|
|
|
3004
3949
|
if (fieldLabel) {
|
|
3005
3950
|
throw new Error(`file: no input[type=file] matching field "${fieldLabel}"`);
|
|
3006
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
|
+
}
|
|
3007
3958
|
}
|
|
3008
3959
|
if (strategy === 'drop' || (strategy === 'auto' && dropX !== undefined && dropY !== undefined)) {
|
|
3009
3960
|
if (dropX === undefined || dropY === undefined) {
|
|
@@ -3012,20 +3963,20 @@ export async function attachFiles(page, paths, opts) {
|
|
|
3012
3963
|
await attachViaDropPlaywright(page, paths, dropX, dropY);
|
|
3013
3964
|
return;
|
|
3014
3965
|
}
|
|
3015
|
-
|
|
3016
|
-
const loc = frame.locator('input[type="file"]');
|
|
3017
|
-
const n = await loc.count();
|
|
3018
|
-
if (n > 0) {
|
|
3019
|
-
await loc.first().setInputFiles(paths);
|
|
3020
|
-
return;
|
|
3021
|
-
}
|
|
3022
|
-
}
|
|
3023
|
-
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');
|
|
3024
3967
|
}
|
|
3025
|
-
async function findLabeledEditableField(page, fieldLabel, exact, cache, fieldId) {
|
|
3026
|
-
const cached = readCachedLocator(cache, 'editable', fieldLabel, exact, fieldId);
|
|
3968
|
+
async function findLabeledEditableField(page, fieldLabel, exact, cache, fieldId, fieldKey) {
|
|
3969
|
+
const cached = await readCachedLocator(cache, 'editable', fieldLabel, exact, fieldId, fieldKey);
|
|
3027
3970
|
if (cached !== undefined)
|
|
3028
3971
|
return cached;
|
|
3972
|
+
if (fieldKey) {
|
|
3973
|
+
const authored = await findAuthoredFieldByKeyInPage(page, fieldKey, 'editable');
|
|
3974
|
+
if (authored) {
|
|
3975
|
+
writeCachedLocator(cache, 'editable', fieldLabel, exact, fieldId, fieldKey, authored);
|
|
3976
|
+
return authored;
|
|
3977
|
+
}
|
|
3978
|
+
throw new Error(`fieldKey "${fieldKey}" did not resolve to a visible editable control; refresh geometra_form_schema`);
|
|
3979
|
+
}
|
|
3029
3980
|
for (const frame of page.frames()) {
|
|
3030
3981
|
// Always try exact-match candidates first, even when the caller passed
|
|
3031
3982
|
// exact=false. Same Greenhouse-style failure as findLabeledControl: a
|
|
@@ -3045,7 +3996,7 @@ async function findLabeledEditableField(page, fieldLabel, exact, cache, fieldId)
|
|
|
3045
3996
|
if (!visible)
|
|
3046
3997
|
continue;
|
|
3047
3998
|
if (await locatorIsEditable(visible)) {
|
|
3048
|
-
writeCachedLocator(cache, 'editable', fieldLabel, exact, fieldId, visible);
|
|
3999
|
+
writeCachedLocator(cache, 'editable', fieldLabel, exact, fieldId, undefined, visible);
|
|
3049
4000
|
return visible;
|
|
3050
4001
|
}
|
|
3051
4002
|
}
|
|
@@ -3061,14 +4012,14 @@ async function findLabeledEditableField(page, fieldLabel, exact, cache, fieldId)
|
|
|
3061
4012
|
if (!visible)
|
|
3062
4013
|
continue;
|
|
3063
4014
|
if (await locatorIsEditable(visible)) {
|
|
3064
|
-
writeCachedLocator(cache, 'editable', fieldLabel, exact, fieldId, visible);
|
|
4015
|
+
writeCachedLocator(cache, 'editable', fieldLabel, exact, fieldId, undefined, visible);
|
|
3065
4016
|
return visible;
|
|
3066
4017
|
}
|
|
3067
4018
|
}
|
|
3068
4019
|
}
|
|
3069
4020
|
const fallback = await findLabeledControl(frame, fieldLabel, exact);
|
|
3070
4021
|
if (fallback && await locatorIsEditable(fallback)) {
|
|
3071
|
-
writeCachedLocator(cache, 'editable', fieldLabel, exact, fieldId, fallback);
|
|
4022
|
+
writeCachedLocator(cache, 'editable', fieldLabel, exact, fieldId, undefined, fallback);
|
|
3072
4023
|
return fallback;
|
|
3073
4024
|
}
|
|
3074
4025
|
}
|
|
@@ -3091,18 +4042,18 @@ async function findLabeledEditableField(page, fieldLabel, exact, cache, fieldId)
|
|
|
3091
4042
|
if (!visible)
|
|
3092
4043
|
continue;
|
|
3093
4044
|
if (await locatorIsEditable(visible)) {
|
|
3094
|
-
writeCachedLocator(cache, 'editable', fieldLabel, exact, fieldId, visible);
|
|
4045
|
+
writeCachedLocator(cache, 'editable', fieldLabel, exact, fieldId, undefined, visible);
|
|
3095
4046
|
return visible;
|
|
3096
4047
|
}
|
|
3097
4048
|
}
|
|
3098
4049
|
const fallback = await findLabeledControl(frame, stripped, exact);
|
|
3099
4050
|
if (fallback && await locatorIsEditable(fallback)) {
|
|
3100
|
-
writeCachedLocator(cache, 'editable', fieldLabel, exact, fieldId, fallback);
|
|
4051
|
+
writeCachedLocator(cache, 'editable', fieldLabel, exact, fieldId, undefined, fallback);
|
|
3101
4052
|
return fallback;
|
|
3102
4053
|
}
|
|
3103
4054
|
}
|
|
3104
4055
|
}
|
|
3105
|
-
writeCachedLocator(cache, 'editable', fieldLabel, exact, fieldId, null);
|
|
4056
|
+
writeCachedLocator(cache, 'editable', fieldLabel, exact, fieldId, undefined, null);
|
|
3106
4057
|
return null;
|
|
3107
4058
|
}
|
|
3108
4059
|
async function locatorCurrentValue(locator) {
|
|
@@ -3141,13 +4092,63 @@ function fieldValueMatches(actual, expected) {
|
|
|
3141
4092
|
const normalizedExpected = normalizedFieldValue(expected);
|
|
3142
4093
|
if (!normalizedActual || !normalizedExpected)
|
|
3143
4094
|
return false;
|
|
3144
|
-
return normalizedActual === normalizedExpected
|
|
4095
|
+
return normalizedActual === normalizedExpected;
|
|
3145
4096
|
}
|
|
3146
4097
|
async function setLocatorTextValue(locator, value, opts) {
|
|
4098
|
+
if (await locatorMutationBlockReason(locator, 'text'))
|
|
4099
|
+
return false;
|
|
3147
4100
|
try {
|
|
3148
4101
|
return await locator.evaluate((el, payload) => {
|
|
3149
4102
|
const nextValue = payload.value;
|
|
3150
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;
|
|
3151
4152
|
// React Greenhouse/Workday/Lever fix: React stores the previous value on
|
|
3152
4153
|
// a hidden `_valueTracker` property that React uses to short-circuit
|
|
3153
4154
|
// onChange when the value "hasn't changed". If we set el.value through
|
|
@@ -3267,6 +4268,11 @@ async function attemptNativeBatchFill(page, fields) {
|
|
|
3267
4268
|
if (results[index])
|
|
3268
4269
|
continue;
|
|
3269
4270
|
const field = fields[index];
|
|
4271
|
+
// Authored field keys are global, exact identities. The per-frame
|
|
4272
|
+
// native batch path only knows labels, so let the normal resolvers scan
|
|
4273
|
+
// all frames before committing a keyed action.
|
|
4274
|
+
if (field.fieldKey)
|
|
4275
|
+
continue;
|
|
3270
4276
|
if (field.kind === 'file')
|
|
3271
4277
|
continue;
|
|
3272
4278
|
if (field.kind === 'auto') {
|
|
@@ -3307,6 +4313,11 @@ async function attemptNativeBatchFill(page, fields) {
|
|
|
3307
4313
|
continue;
|
|
3308
4314
|
}
|
|
3309
4315
|
if (field.kind === 'choice') {
|
|
4316
|
+
// An option index is an exact schema identity. The label-only native
|
|
4317
|
+
// batch path cannot preserve or validate it, so route this field
|
|
4318
|
+
// through setFieldChoice below.
|
|
4319
|
+
if (field.optionIndex !== undefined)
|
|
4320
|
+
continue;
|
|
3310
4321
|
pending.push({
|
|
3311
4322
|
index,
|
|
3312
4323
|
kind: 'choice',
|
|
@@ -3317,14 +4328,9 @@ async function attemptNativeBatchFill(page, fields) {
|
|
|
3317
4328
|
});
|
|
3318
4329
|
continue;
|
|
3319
4330
|
}
|
|
3320
|
-
|
|
3321
|
-
|
|
3322
|
-
|
|
3323
|
-
label: field.label,
|
|
3324
|
-
checked: field.checked ?? true,
|
|
3325
|
-
exact: field.exact ?? false,
|
|
3326
|
-
controlType: field.controlType ?? null,
|
|
3327
|
-
});
|
|
4331
|
+
// Toggle resolution must collect every candidate before committing so
|
|
4332
|
+
// duplicate labels are rejected instead of silently selecting the first.
|
|
4333
|
+
continue;
|
|
3328
4334
|
}
|
|
3329
4335
|
if (pending.length === 0)
|
|
3330
4336
|
break;
|
|
@@ -3348,7 +4354,57 @@ async function attemptNativeBatchFill(page, fields) {
|
|
|
3348
4354
|
const style = getComputedStyle(el);
|
|
3349
4355
|
return style.display !== 'none' && style.visibility !== 'hidden';
|
|
3350
4356
|
}
|
|
3351
|
-
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) {
|
|
3352
4408
|
if (!ids)
|
|
3353
4409
|
return undefined;
|
|
3354
4410
|
const seen = visited ?? new Set();
|
|
@@ -3358,12 +4414,12 @@ async function attemptNativeBatchFill(page, fields) {
|
|
|
3358
4414
|
if (seen.has(id))
|
|
3359
4415
|
return '';
|
|
3360
4416
|
seen.add(id);
|
|
3361
|
-
const target =
|
|
4417
|
+
const target = rootScopedElementById(owner, id);
|
|
3362
4418
|
if (!target)
|
|
3363
4419
|
return '';
|
|
3364
4420
|
const chained = target.getAttribute('aria-labelledby');
|
|
3365
4421
|
if (chained)
|
|
3366
|
-
return referencedText(chained, seen) ?? '';
|
|
4422
|
+
return referencedText(target, chained, seen) ?? '';
|
|
3367
4423
|
return target.textContent?.trim() ?? '';
|
|
3368
4424
|
})
|
|
3369
4425
|
.filter(Boolean)
|
|
@@ -3375,7 +4431,7 @@ async function attemptNativeBatchFill(page, fields) {
|
|
|
3375
4431
|
const aria = el.getAttribute('aria-label')?.trim();
|
|
3376
4432
|
if (aria)
|
|
3377
4433
|
return aria;
|
|
3378
|
-
const labelledBy = referencedText(el.getAttribute('aria-labelledby'));
|
|
4434
|
+
const labelledBy = referencedText(el, el.getAttribute('aria-labelledby'));
|
|
3379
4435
|
if (labelledBy)
|
|
3380
4436
|
return labelledBy;
|
|
3381
4437
|
if ((el instanceof HTMLInputElement || el instanceof HTMLSelectElement || el instanceof HTMLTextAreaElement) &&
|
|
@@ -3384,7 +4440,10 @@ async function attemptNativeBatchFill(page, fields) {
|
|
|
3384
4440
|
return el.labels[0]?.textContent?.trim() || undefined;
|
|
3385
4441
|
}
|
|
3386
4442
|
if (el instanceof HTMLElement && el.id) {
|
|
3387
|
-
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;
|
|
3388
4447
|
const text = label?.textContent?.trim();
|
|
3389
4448
|
if (text)
|
|
3390
4449
|
return text;
|
|
@@ -3437,6 +4496,8 @@ async function attemptNativeBatchFill(page, fields) {
|
|
|
3437
4496
|
target.value = next;
|
|
3438
4497
|
}
|
|
3439
4498
|
function setInputCheckedReactAware(target, next) {
|
|
4499
|
+
if (mutationBlockReason(target))
|
|
4500
|
+
return false;
|
|
3440
4501
|
const hadReactTracker = clearReactTracker(target, next);
|
|
3441
4502
|
if (!hadReactTracker && target.checked === next)
|
|
3442
4503
|
return true;
|
|
@@ -3596,6 +4657,8 @@ async function attemptNativeBatchFill(page, fields) {
|
|
|
3596
4657
|
}
|
|
3597
4658
|
if (!matches(explicitLabelText(control), fieldLabel, exact))
|
|
3598
4659
|
continue;
|
|
4660
|
+
if (mutationBlockReason(control))
|
|
4661
|
+
return false;
|
|
3599
4662
|
if (control instanceof HTMLInputElement || control instanceof HTMLTextAreaElement) {
|
|
3600
4663
|
const inputType = control instanceof HTMLInputElement ? control.type : 'textarea';
|
|
3601
4664
|
const v = canonicalizeHtmlInputValueInPage(inputType, value);
|
|
@@ -3603,7 +4666,7 @@ async function attemptNativeBatchFill(page, fields) {
|
|
|
3603
4666
|
clearReactTracker(control);
|
|
3604
4667
|
setInputLikeValue(control, v);
|
|
3605
4668
|
dispatch(control);
|
|
3606
|
-
return
|
|
4669
|
+
return normalize(currentValue(control)) === normalize(v);
|
|
3607
4670
|
}
|
|
3608
4671
|
if (control instanceof HTMLElement && control.isContentEditable) {
|
|
3609
4672
|
if (!isPlainContentEditableBatch(control))
|
|
@@ -3612,7 +4675,7 @@ async function attemptNativeBatchFill(page, fields) {
|
|
|
3612
4675
|
clearReactTracker(control);
|
|
3613
4676
|
control.textContent = value;
|
|
3614
4677
|
dispatch(control);
|
|
3615
|
-
return
|
|
4678
|
+
return normalize(currentValue(control)) === normalize(value);
|
|
3616
4679
|
}
|
|
3617
4680
|
}
|
|
3618
4681
|
return false;
|
|
@@ -3624,8 +4687,12 @@ async function attemptNativeBatchFill(page, fields) {
|
|
|
3624
4687
|
continue;
|
|
3625
4688
|
if (!matches(explicitLabelText(control), fieldLabel, exact))
|
|
3626
4689
|
continue;
|
|
4690
|
+
if (mutationBlockReason(control))
|
|
4691
|
+
return false;
|
|
3627
4692
|
const expected = normalize(value);
|
|
3628
4693
|
const option = Array.from(control.options).find((candidate) => {
|
|
4694
|
+
if (candidate.disabled || (candidate.parentElement instanceof HTMLOptGroupElement && candidate.parentElement.disabled))
|
|
4695
|
+
return false;
|
|
3629
4696
|
const label = normalize(candidate.textContent);
|
|
3630
4697
|
const rawValue = normalize(candidate.value);
|
|
3631
4698
|
return exact ? label === expected || rawValue === expected : label.includes(expected) || rawValue.includes(expected);
|
|
@@ -3634,7 +4701,7 @@ async function attemptNativeBatchFill(page, fields) {
|
|
|
3634
4701
|
return false;
|
|
3635
4702
|
control.value = option.value;
|
|
3636
4703
|
dispatch(control);
|
|
3637
|
-
return
|
|
4704
|
+
return control.selectedIndex === option.index;
|
|
3638
4705
|
}
|
|
3639
4706
|
return false;
|
|
3640
4707
|
}
|
|
@@ -3694,13 +4761,17 @@ async function attemptNativeBatchFill(page, fields) {
|
|
|
3694
4761
|
const control = option.control;
|
|
3695
4762
|
if (control instanceof HTMLInputElement)
|
|
3696
4763
|
return setInputCheckedReactAware(control, true);
|
|
4764
|
+
if (mutationBlockReason(option))
|
|
4765
|
+
return false;
|
|
3697
4766
|
option.click();
|
|
3698
|
-
return
|
|
4767
|
+
return false;
|
|
3699
4768
|
}
|
|
3700
4769
|
// role=radio / role=checkbox: click and verify aria-checked
|
|
3701
4770
|
// / aria-selected. The native batch fill used to silently return
|
|
3702
4771
|
// true here, which masked Pinecone's commit-on-rerender bug.
|
|
3703
4772
|
if (option.getAttribute('role') === 'radio' || option.getAttribute('role') === 'checkbox') {
|
|
4773
|
+
if (mutationBlockReason(option))
|
|
4774
|
+
return false;
|
|
3704
4775
|
option.click();
|
|
3705
4776
|
return (option.getAttribute('aria-checked') === 'true' ||
|
|
3706
4777
|
option.getAttribute('aria-selected') === 'true');
|
|
@@ -3717,6 +4788,8 @@ async function attemptNativeBatchFill(page, fields) {
|
|
|
3717
4788
|
// JobForge round-2 marathon — Pinecone Sr SWE Database Team
|
|
3718
4789
|
// #320 and LangChain SE Manager #325.
|
|
3719
4790
|
const beforeSig = selectionSignature(options);
|
|
4791
|
+
if (mutationBlockReason(option))
|
|
4792
|
+
return false;
|
|
3720
4793
|
option.click();
|
|
3721
4794
|
const afterSig = selectionSignature(options);
|
|
3722
4795
|
if (beforeSig === afterSig)
|
|
@@ -3735,6 +4808,8 @@ async function attemptNativeBatchFill(page, fields) {
|
|
|
3735
4808
|
continue;
|
|
3736
4809
|
if (!matches(explicitLabelText(input), label, exact))
|
|
3737
4810
|
continue;
|
|
4811
|
+
if (mutationBlockReason(input))
|
|
4812
|
+
return false;
|
|
3738
4813
|
if (!setInputCheckedReactAware(input, checked))
|
|
3739
4814
|
return false;
|
|
3740
4815
|
return input.checked === checked;
|
|
@@ -3750,6 +4825,8 @@ async function attemptNativeBatchFill(page, fields) {
|
|
|
3750
4825
|
continue;
|
|
3751
4826
|
if (controlType && control.type !== controlType)
|
|
3752
4827
|
continue;
|
|
4828
|
+
if (mutationBlockReason(control))
|
|
4829
|
+
return false;
|
|
3753
4830
|
if (!setInputCheckedReactAware(control, checked))
|
|
3754
4831
|
return false;
|
|
3755
4832
|
return control.checked === checked;
|
|
@@ -4037,6 +5114,9 @@ export async function fillOtp(page, value, opts) {
|
|
|
4037
5114
|
if (liveCount !== group.cellCount) {
|
|
4038
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.`);
|
|
4039
5116
|
}
|
|
5117
|
+
for (let index = 0; index < liveCount; index++) {
|
|
5118
|
+
await assertLocatorMutable(group.boxes.nth(index), 'text', `fillOtp: OTP cell ${index + 1}`);
|
|
5119
|
+
}
|
|
4040
5120
|
// Click the leftmost box via its center point. We use a low-level
|
|
4041
5121
|
// bounding-box click because a semantic "click the first input" path
|
|
4042
5122
|
// resolves to whichever input paints topmost in the stacking order,
|
|
@@ -4230,7 +5310,7 @@ export async function setFieldText(page, fieldLabel, value, opts) {
|
|
|
4230
5310
|
// from fillOtp propagates out of setFieldText unchanged: silent fallback
|
|
4231
5311
|
// to the plain text-fill path would write the whole string into box 0
|
|
4232
5312
|
// and pretend success, which is the bug we're fixing.
|
|
4233
|
-
if (labelLooksLikeOtp(fieldLabel) && /^\S{4,}$/.test(value)) {
|
|
5313
|
+
if (!opts?.fieldKey && labelLooksLikeOtp(fieldLabel) && /^\S{4,}$/.test(value)) {
|
|
4234
5314
|
const group = await findOtpBoxGroup(page, fieldLabel, value.length);
|
|
4235
5315
|
if (group) {
|
|
4236
5316
|
await fillOtp(page, value, { fieldLabel });
|
|
@@ -4238,10 +5318,11 @@ export async function setFieldText(page, fieldLabel, value, opts) {
|
|
|
4238
5318
|
}
|
|
4239
5319
|
}
|
|
4240
5320
|
const exact = opts?.exact ?? false;
|
|
4241
|
-
const locator = await findLabeledEditableField(page, fieldLabel, exact, opts?.cache, opts?.fieldId);
|
|
5321
|
+
const locator = await findLabeledEditableField(page, fieldLabel, exact, opts?.cache, opts?.fieldId, opts?.fieldKey);
|
|
4242
5322
|
if (!locator) {
|
|
4243
5323
|
throw new Error(`setFieldText: no visible editable field matching "${fieldLabel}"`);
|
|
4244
5324
|
}
|
|
5325
|
+
await assertLocatorMutable(locator, 'text', 'setFieldText');
|
|
4245
5326
|
await locator.scrollIntoViewIfNeeded();
|
|
4246
5327
|
const inputType = await locator.evaluate(el => {
|
|
4247
5328
|
if (el instanceof HTMLInputElement)
|
|
@@ -4253,6 +5334,7 @@ export async function setFieldText(page, fieldLabel, value, opts) {
|
|
|
4253
5334
|
const normalized = canonicalizeHtmlInputValue(inputType, value);
|
|
4254
5335
|
const applied = await setLocatorTextValue(locator, normalized, { imeFriendly: opts?.imeFriendly });
|
|
4255
5336
|
if (!applied) {
|
|
5337
|
+
await assertLocatorMutable(locator, 'text', 'setFieldText');
|
|
4256
5338
|
try {
|
|
4257
5339
|
await locator.fill(normalized);
|
|
4258
5340
|
}
|
|
@@ -4263,6 +5345,7 @@ export async function setFieldText(page, fieldLabel, value, opts) {
|
|
|
4263
5345
|
});
|
|
4264
5346
|
}
|
|
4265
5347
|
}
|
|
5348
|
+
await delay(40);
|
|
4266
5349
|
const current = await locatorCurrentValue(locator);
|
|
4267
5350
|
if (fieldValueMatches(current, normalized))
|
|
4268
5351
|
return;
|
|
@@ -4271,31 +5354,88 @@ export async function setFieldText(page, fieldLabel, value, opts) {
|
|
|
4271
5354
|
return;
|
|
4272
5355
|
throw new Error(`setFieldText: set "${fieldLabel}" but could not confirm value ${JSON.stringify(value)}`);
|
|
4273
5356
|
}
|
|
4274
|
-
async function setNativeSelectByLabel(locator, value, exact) {
|
|
5357
|
+
async function setNativeSelectByLabel(locator, value, exact, optionIndex) {
|
|
5358
|
+
if (await locatorMutationBlockReason(locator, 'select'))
|
|
5359
|
+
return false;
|
|
4275
5360
|
try {
|
|
4276
|
-
return await locator.evaluate((el, payload) => {
|
|
5361
|
+
return await locator.evaluate(async (el, payload) => {
|
|
4277
5362
|
if (!(el instanceof HTMLSelectElement))
|
|
4278
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;
|
|
4279
5408
|
const normalize = (input) => input?.replace(/\s+/g, ' ').trim().toLowerCase() ?? '';
|
|
4280
5409
|
const expected = normalize(payload.value);
|
|
4281
|
-
|
|
4282
|
-
|
|
4283
|
-
|
|
4284
|
-
|
|
5410
|
+
const enabled = (candidate) => !candidate.disabled && !(candidate.parentElement instanceof HTMLOptGroupElement && candidate.parentElement.disabled);
|
|
5411
|
+
let option;
|
|
5412
|
+
if (payload.optionIndex !== undefined) {
|
|
5413
|
+
const indexed = el.options.item(payload.optionIndex) ?? undefined;
|
|
5414
|
+
if (!indexed || !enabled(indexed) || indexed.value !== payload.value)
|
|
4285
5415
|
return false;
|
|
4286
|
-
|
|
4287
|
-
|
|
4288
|
-
|
|
4289
|
-
|
|
4290
|
-
|
|
4291
|
-
|
|
5416
|
+
option = indexed;
|
|
5417
|
+
}
|
|
5418
|
+
else {
|
|
5419
|
+
const options = Array.from(el.options).filter(enabled);
|
|
5420
|
+
// Values are the submitted identity and take precedence over labels.
|
|
5421
|
+
// This prevents an earlier option whose label happens to equal a
|
|
5422
|
+
// different option's value from being selected silently.
|
|
5423
|
+
option = options.find(candidate => normalize(candidate.value) === expected);
|
|
5424
|
+
if (!option)
|
|
5425
|
+
option = options.find(candidate => normalize(candidate.textContent) === expected);
|
|
5426
|
+
if (!option && !payload.exact && expected) {
|
|
5427
|
+
option = options.find(candidate => normalize(candidate.value).includes(expected))
|
|
5428
|
+
?? options.find(candidate => normalize(candidate.textContent).includes(expected));
|
|
5429
|
+
}
|
|
5430
|
+
}
|
|
4292
5431
|
if (!option)
|
|
4293
5432
|
return false;
|
|
4294
|
-
el.
|
|
5433
|
+
el.selectedIndex = option.index;
|
|
4295
5434
|
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
4296
5435
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
4297
|
-
|
|
4298
|
-
|
|
5436
|
+
await new Promise(resolveTimer => setTimeout(resolveTimer, 40));
|
|
5437
|
+
return el.selectedIndex === option.index && el.selectedOptions[0] === option;
|
|
5438
|
+
}, { value, exact, optionIndex });
|
|
4299
5439
|
}
|
|
4300
5440
|
catch {
|
|
4301
5441
|
return false;
|
|
@@ -4316,6 +5456,46 @@ async function chooseValueFromLabeledGroup(page, fieldLabel, value, exact) {
|
|
|
4316
5456
|
const style = getComputedStyle(el);
|
|
4317
5457
|
return style.display !== 'none' && style.visibility !== 'hidden';
|
|
4318
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
|
+
}
|
|
4319
5499
|
function matches(candidate) {
|
|
4320
5500
|
const normalizedCandidate = normalize(candidate);
|
|
4321
5501
|
const normalizedExpected = normalize(payload.fieldLabel);
|
|
@@ -4330,7 +5510,15 @@ async function chooseValueFromLabeledGroup(page, fieldLabel, value, exact) {
|
|
|
4330
5510
|
return false;
|
|
4331
5511
|
return payload.exact ? normalizedCandidate === normalizedExpected : normalizedCandidate.includes(normalizedExpected);
|
|
4332
5512
|
}
|
|
4333
|
-
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) {
|
|
4334
5522
|
if (!ids)
|
|
4335
5523
|
return undefined;
|
|
4336
5524
|
const seen = visited ?? new Set();
|
|
@@ -4340,12 +5528,12 @@ async function chooseValueFromLabeledGroup(page, fieldLabel, value, exact) {
|
|
|
4340
5528
|
if (seen.has(id))
|
|
4341
5529
|
return '';
|
|
4342
5530
|
seen.add(id);
|
|
4343
|
-
const target =
|
|
5531
|
+
const target = rootScopedElementById(owner, id);
|
|
4344
5532
|
if (!target)
|
|
4345
5533
|
return '';
|
|
4346
5534
|
const chained = target.getAttribute('aria-labelledby');
|
|
4347
5535
|
if (chained)
|
|
4348
|
-
return referencedText(chained, seen) ?? '';
|
|
5536
|
+
return referencedText(target, chained, seen) ?? '';
|
|
4349
5537
|
return target.textContent?.trim() ?? '';
|
|
4350
5538
|
})
|
|
4351
5539
|
.filter(Boolean)
|
|
@@ -4357,7 +5545,7 @@ async function chooseValueFromLabeledGroup(page, fieldLabel, value, exact) {
|
|
|
4357
5545
|
const aria = el.getAttribute('aria-label')?.trim();
|
|
4358
5546
|
if (aria)
|
|
4359
5547
|
return aria;
|
|
4360
|
-
const labelledBy = referencedText(el.getAttribute('aria-labelledby'));
|
|
5548
|
+
const labelledBy = referencedText(el, el.getAttribute('aria-labelledby'));
|
|
4361
5549
|
if (labelledBy)
|
|
4362
5550
|
return labelledBy;
|
|
4363
5551
|
if (el instanceof HTMLInputElement && el.labels && el.labels.length > 0) {
|
|
@@ -4391,7 +5579,7 @@ async function chooseValueFromLabeledGroup(page, fieldLabel, value, exact) {
|
|
|
4391
5579
|
const aria = el.getAttribute('aria-label')?.trim();
|
|
4392
5580
|
if (aria)
|
|
4393
5581
|
return aria;
|
|
4394
|
-
const labelledBy = referencedText(el.getAttribute('aria-labelledby'));
|
|
5582
|
+
const labelledBy = referencedText(el, el.getAttribute('aria-labelledby'));
|
|
4395
5583
|
if (labelledBy)
|
|
4396
5584
|
return labelledBy;
|
|
4397
5585
|
return el.textContent?.trim() || undefined;
|
|
@@ -4469,6 +5657,8 @@ async function chooseValueFromLabeledGroup(page, fieldLabel, value, exact) {
|
|
|
4469
5657
|
return false;
|
|
4470
5658
|
}
|
|
4471
5659
|
function setInputCheckedReactAware(target, next) {
|
|
5660
|
+
if (blocked(target))
|
|
5661
|
+
return false;
|
|
4472
5662
|
const hadReactTracker = clearReactTracker(target, next);
|
|
4473
5663
|
if (!hadReactTracker && target.checked === next)
|
|
4474
5664
|
return true;
|
|
@@ -4495,6 +5685,8 @@ async function chooseValueFromLabeledGroup(page, fieldLabel, value, exact) {
|
|
|
4495
5685
|
const control = option.control;
|
|
4496
5686
|
if (control instanceof HTMLInputElement)
|
|
4497
5687
|
return setInputCheckedReactAware(control, true);
|
|
5688
|
+
if (blocked(option))
|
|
5689
|
+
return false;
|
|
4498
5690
|
option.click();
|
|
4499
5691
|
// No backing input — verify via group signature change so we don't
|
|
4500
5692
|
// silently no-op on label wrappers whose target is unparented.
|
|
@@ -4502,6 +5694,8 @@ async function chooseValueFromLabeledGroup(page, fieldLabel, value, exact) {
|
|
|
4502
5694
|
return labelBeforeSig !== labelAfterSig;
|
|
4503
5695
|
}
|
|
4504
5696
|
if (option.getAttribute('role') === 'radio' || option.getAttribute('role') === 'checkbox') {
|
|
5697
|
+
if (blocked(option))
|
|
5698
|
+
return false;
|
|
4505
5699
|
option.click();
|
|
4506
5700
|
return option.getAttribute('aria-checked') === 'true' || option.getAttribute('aria-selected') === 'true';
|
|
4507
5701
|
}
|
|
@@ -4511,6 +5705,8 @@ async function chooseValueFromLabeledGroup(page, fieldLabel, value, exact) {
|
|
|
4511
5705
|
// was a no-op — return false so the caller can try the next strategy
|
|
4512
5706
|
// (pickListboxOption fallback, etc) instead of silently succeeding.
|
|
4513
5707
|
const beforeSig = selectionSignature(options);
|
|
5708
|
+
if (blocked(option))
|
|
5709
|
+
return false;
|
|
4514
5710
|
option.click();
|
|
4515
5711
|
const afterSig = selectionSignature(options);
|
|
4516
5712
|
if (beforeSig === afterSig) {
|
|
@@ -4553,20 +5749,29 @@ async function autoFieldLocatorKind(locator) {
|
|
|
4553
5749
|
export async function setAutoFieldValue(page, fieldLabel, value, opts) {
|
|
4554
5750
|
const exact = opts?.exact ?? false;
|
|
4555
5751
|
if (typeof value === 'boolean') {
|
|
5752
|
+
if (opts?.fieldKey) {
|
|
5753
|
+
await setCheckedControl(page, fieldLabel, { fieldKey: opts.fieldKey, checked: value, exact });
|
|
5754
|
+
return;
|
|
5755
|
+
}
|
|
4556
5756
|
if (await chooseValueFromLabeledGroup(page, fieldLabel, value ? 'Yes' : 'No', exact))
|
|
4557
5757
|
return;
|
|
4558
|
-
await setCheckedControl(page, fieldLabel, { checked: value, exact });
|
|
5758
|
+
await setCheckedControl(page, fieldLabel, { fieldKey: opts?.fieldKey, checked: value, exact });
|
|
4559
5759
|
return;
|
|
4560
5760
|
}
|
|
4561
|
-
if (prefersGroupedChoiceValue(value) && await chooseValueFromLabeledGroup(page, fieldLabel, value, exact)) {
|
|
5761
|
+
if (!opts?.fieldKey && prefersGroupedChoiceValue(value) && await chooseValueFromLabeledGroup(page, fieldLabel, value, exact)) {
|
|
4562
5762
|
return;
|
|
4563
5763
|
}
|
|
4564
|
-
const locator = await findLabeledControlInPage(page, fieldLabel, exact, {
|
|
5764
|
+
const locator = await findLabeledControlInPage(page, fieldLabel, exact, {
|
|
5765
|
+
cache: opts?.cache,
|
|
5766
|
+
fieldId: opts?.fieldId,
|
|
5767
|
+
fieldKey: opts?.fieldKey,
|
|
5768
|
+
});
|
|
4565
5769
|
if (locator) {
|
|
4566
5770
|
const kind = await autoFieldLocatorKind(locator);
|
|
4567
5771
|
if (kind === 'select') {
|
|
4568
5772
|
await setFieldChoice(page, fieldLabel, value, {
|
|
4569
5773
|
fieldId: opts?.fieldId,
|
|
5774
|
+
fieldKey: opts?.fieldKey,
|
|
4570
5775
|
exact,
|
|
4571
5776
|
choiceType: 'select',
|
|
4572
5777
|
cache: opts?.cache,
|
|
@@ -4576,6 +5781,7 @@ export async function setAutoFieldValue(page, fieldLabel, value, opts) {
|
|
|
4576
5781
|
if (kind === 'text') {
|
|
4577
5782
|
await setFieldText(page, fieldLabel, value, {
|
|
4578
5783
|
fieldId: opts?.fieldId,
|
|
5784
|
+
fieldKey: opts?.fieldKey,
|
|
4579
5785
|
exact,
|
|
4580
5786
|
cache: opts?.cache,
|
|
4581
5787
|
});
|
|
@@ -4584,17 +5790,22 @@ export async function setAutoFieldValue(page, fieldLabel, value, opts) {
|
|
|
4584
5790
|
if (kind === 'choice') {
|
|
4585
5791
|
await setFieldChoice(page, fieldLabel, value, {
|
|
4586
5792
|
fieldId: opts?.fieldId,
|
|
5793
|
+
fieldKey: opts?.fieldKey,
|
|
4587
5794
|
exact,
|
|
4588
5795
|
cache: opts?.cache,
|
|
4589
5796
|
});
|
|
4590
5797
|
return;
|
|
4591
5798
|
}
|
|
4592
5799
|
}
|
|
5800
|
+
if (opts?.fieldKey) {
|
|
5801
|
+
throw new Error(`fieldKey "${opts.fieldKey}" resolved to an unsupported control for field "${fieldLabel}"`);
|
|
5802
|
+
}
|
|
4593
5803
|
if (await chooseValueFromLabeledGroup(page, fieldLabel, value, exact))
|
|
4594
5804
|
return;
|
|
4595
5805
|
try {
|
|
4596
5806
|
await setFieldText(page, fieldLabel, value, {
|
|
4597
5807
|
fieldId: opts?.fieldId,
|
|
5808
|
+
fieldKey: opts?.fieldKey,
|
|
4598
5809
|
exact,
|
|
4599
5810
|
cache: opts?.cache,
|
|
4600
5811
|
});
|
|
@@ -4603,6 +5814,7 @@ export async function setAutoFieldValue(page, fieldLabel, value, opts) {
|
|
|
4603
5814
|
catch {
|
|
4604
5815
|
await setFieldChoice(page, fieldLabel, value, {
|
|
4605
5816
|
fieldId: opts?.fieldId,
|
|
5817
|
+
fieldKey: opts?.fieldKey,
|
|
4606
5818
|
exact,
|
|
4607
5819
|
cache: opts?.cache,
|
|
4608
5820
|
});
|
|
@@ -4610,11 +5822,22 @@ export async function setAutoFieldValue(page, fieldLabel, value, opts) {
|
|
|
4610
5822
|
}
|
|
4611
5823
|
export async function setFieldChoice(page, fieldLabel, value, opts) {
|
|
4612
5824
|
const exact = opts?.exact ?? false;
|
|
4613
|
-
const locator = await findLabeledControlInPage(page, fieldLabel, exact, {
|
|
5825
|
+
const locator = await findLabeledControlInPage(page, fieldLabel, exact, {
|
|
5826
|
+
cache: opts?.cache,
|
|
5827
|
+
fieldId: opts?.fieldId,
|
|
5828
|
+
fieldKey: opts?.fieldKey,
|
|
5829
|
+
});
|
|
4614
5830
|
if (locator) {
|
|
5831
|
+
await assertLocatorMutable(locator, 'select', 'setFieldChoice');
|
|
4615
5832
|
await locator.scrollIntoViewIfNeeded();
|
|
4616
5833
|
const isNativeSelect = await locator.evaluate(el => el instanceof HTMLSelectElement);
|
|
4617
|
-
if (await setNativeSelectByLabel(locator, value, exact)) {
|
|
5834
|
+
if (await setNativeSelectByLabel(locator, value, exact, opts?.optionIndex)) {
|
|
5835
|
+
if (opts?.optionIndex !== undefined) {
|
|
5836
|
+
const selectedIndex = await locator.evaluate(el => el instanceof HTMLSelectElement ? el.selectedIndex : -1);
|
|
5837
|
+
if (selectedIndex === opts.optionIndex)
|
|
5838
|
+
return;
|
|
5839
|
+
throw new Error(`setFieldChoice: selected native option index ${opts.optionIndex} for field "${fieldLabel}" but could not confirm it`);
|
|
5840
|
+
}
|
|
4618
5841
|
const displayed = await locatorDisplayedValues(locator);
|
|
4619
5842
|
if (displayed.some(candidate => displayedValueMatchesSelection(candidate, value, exact)))
|
|
4620
5843
|
return;
|
|
@@ -4633,6 +5856,7 @@ export async function setFieldChoice(page, fieldLabel, value, opts) {
|
|
|
4633
5856
|
try {
|
|
4634
5857
|
await pickListboxOption(page, value, {
|
|
4635
5858
|
fieldId: opts?.fieldId,
|
|
5859
|
+
fieldKey: opts?.fieldKey,
|
|
4636
5860
|
fieldLabel,
|
|
4637
5861
|
exact,
|
|
4638
5862
|
query: opts?.query,
|
|
@@ -4678,6 +5902,7 @@ export async function fillFields(page, fields, cache = createFillLookupCache())
|
|
|
4678
5902
|
if (textFills.length > 0) {
|
|
4679
5903
|
const results = await Promise.allSettled(textFills.map(({ field }) => setFieldText(page, field.fieldLabel, field.value, {
|
|
4680
5904
|
fieldId: field.fieldId,
|
|
5905
|
+
fieldKey: field.fieldKey,
|
|
4681
5906
|
exact: field.exact,
|
|
4682
5907
|
cache,
|
|
4683
5908
|
typingDelayMs: field.typingDelayMs,
|
|
@@ -4708,6 +5933,7 @@ export async function fillFields(page, fields, cache = createFillLookupCache())
|
|
|
4708
5933
|
if (field.kind === 'auto') {
|
|
4709
5934
|
await setAutoFieldValue(page, field.fieldLabel, field.value, {
|
|
4710
5935
|
fieldId: field.fieldId,
|
|
5936
|
+
fieldKey: field.fieldKey,
|
|
4711
5937
|
exact: field.exact,
|
|
4712
5938
|
cache,
|
|
4713
5939
|
});
|
|
@@ -4716,7 +5942,9 @@ export async function fillFields(page, fields, cache = createFillLookupCache())
|
|
|
4716
5942
|
if (field.kind === 'choice') {
|
|
4717
5943
|
await setFieldChoice(page, field.fieldLabel, field.value, {
|
|
4718
5944
|
fieldId: field.fieldId,
|
|
5945
|
+
fieldKey: field.fieldKey,
|
|
4719
5946
|
exact: field.exact,
|
|
5947
|
+
optionIndex: field.optionIndex,
|
|
4720
5948
|
query: field.query,
|
|
4721
5949
|
choiceType: field.choiceType,
|
|
4722
5950
|
cache,
|
|
@@ -4725,14 +5953,25 @@ export async function fillFields(page, fields, cache = createFillLookupCache())
|
|
|
4725
5953
|
}
|
|
4726
5954
|
if (field.kind === 'toggle') {
|
|
4727
5955
|
await setCheckedControl(page, field.label, {
|
|
5956
|
+
fieldKey: field.fieldKey,
|
|
4728
5957
|
checked: field.checked,
|
|
4729
5958
|
exact: field.exact,
|
|
4730
5959
|
controlType: field.controlType,
|
|
5960
|
+
contextText: field.contextText,
|
|
5961
|
+
sectionText: field.sectionText,
|
|
4731
5962
|
});
|
|
4732
5963
|
continue;
|
|
4733
5964
|
}
|
|
4734
5965
|
if (field.kind === 'file') {
|
|
4735
|
-
await attachFiles(page, field.paths, {
|
|
5966
|
+
await attachFiles(page, field.paths, {
|
|
5967
|
+
fieldId: field.fieldId,
|
|
5968
|
+
fieldKey: field.fieldKey,
|
|
5969
|
+
fieldLabel: field.fieldLabel,
|
|
5970
|
+
contextText: field.contextText,
|
|
5971
|
+
sectionText: field.sectionText,
|
|
5972
|
+
exact: field.exact,
|
|
5973
|
+
cache,
|
|
5974
|
+
});
|
|
4736
5975
|
}
|
|
4737
5976
|
}
|
|
4738
5977
|
catch (e) {
|
|
@@ -4767,65 +6006,46 @@ export async function selectNativeOption(page, x, y, opt) {
|
|
|
4767
6006
|
if (opt.value === undefined && opt.label === undefined && opt.index === undefined) {
|
|
4768
6007
|
throw new Error('selectOption: provide at least one of value, label, or index');
|
|
4769
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');
|
|
4770
6013
|
await page.mouse.click(x, y);
|
|
4771
6014
|
await delay(40);
|
|
4772
6015
|
for (const frame of page.frames()) {
|
|
4773
|
-
const applied = await frame.evaluate((payload) => {
|
|
6016
|
+
const applied = await frame.evaluate(async (payload) => {
|
|
4774
6017
|
const normalize = (input) => input?.replace(/\s+/g, ' ').trim().toLowerCase() ?? '';
|
|
4775
|
-
const
|
|
4776
|
-
|
|
6018
|
+
const enabled = (o) => !o.disabled && !(o.parentElement instanceof HTMLOptGroupElement && o.parentElement.disabled);
|
|
6019
|
+
const optionMatchesPayload = (o) => {
|
|
6020
|
+
if (o.disabled || (o.parentElement instanceof HTMLOptGroupElement && o.parentElement.disabled))
|
|
4777
6021
|
return false;
|
|
4778
|
-
|
|
4779
|
-
|
|
4780
|
-
|
|
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;
|
|
4781
6029
|
};
|
|
4782
6030
|
const a = document.activeElement;
|
|
4783
6031
|
if (!a || a.tagName !== 'SELECT')
|
|
4784
6032
|
return false;
|
|
4785
6033
|
const sel = a;
|
|
4786
|
-
if (
|
|
4787
|
-
const i = Math.trunc(payload.index);
|
|
4788
|
-
if (i < 0 || i >= sel.options.length)
|
|
4789
|
-
return false;
|
|
4790
|
-
if (sel.options[i].disabled)
|
|
4791
|
-
return false;
|
|
4792
|
-
sel.selectedIndex = i;
|
|
4793
|
-
}
|
|
4794
|
-
else if (payload.value !== null && payload.value !== undefined) {
|
|
4795
|
-
const raw = String(payload.value);
|
|
4796
|
-
sel.value = raw;
|
|
4797
|
-
const expected = normalize(raw);
|
|
4798
|
-
if (!expected)
|
|
4799
|
-
return false;
|
|
4800
|
-
const selected = sel.selectedOptions[0];
|
|
4801
|
-
const ok = selected &&
|
|
4802
|
-
!selected.disabled &&
|
|
4803
|
-
(normalize(selected.value) === expected ||
|
|
4804
|
-
normalize(selected.textContent) === expected ||
|
|
4805
|
-
normalize(selected.value).includes(expected) ||
|
|
4806
|
-
normalize(selected.textContent).includes(expected));
|
|
4807
|
-
if (!ok) {
|
|
4808
|
-
const found = Array.from(sel.options).find(o => optionMatchesExpected(o, expected));
|
|
4809
|
-
if (!found)
|
|
4810
|
-
return false;
|
|
4811
|
-
sel.value = found.value;
|
|
4812
|
-
}
|
|
4813
|
-
}
|
|
4814
|
-
else if (payload.label !== null && payload.label !== undefined) {
|
|
4815
|
-
const expected = normalize(payload.label);
|
|
4816
|
-
if (!expected)
|
|
4817
|
-
return false;
|
|
4818
|
-
const optEl = Array.from(sel.options).find(o => optionMatchesExpected(o, expected));
|
|
4819
|
-
if (!optEl)
|
|
4820
|
-
return false;
|
|
4821
|
-
sel.value = optEl.value;
|
|
4822
|
-
}
|
|
4823
|
-
else {
|
|
6034
|
+
if (sel.matches(':disabled') || sel.getAttribute('aria-disabled') === 'true' || sel.getAttribute('aria-readonly') === 'true')
|
|
4824
6035
|
return false;
|
|
4825
|
-
|
|
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;
|
|
4826
6044
|
sel.dispatchEvent(new Event('input', { bubbles: true }));
|
|
4827
6045
|
sel.dispatchEvent(new Event('change', { bubbles: true }));
|
|
4828
|
-
|
|
6046
|
+
await new Promise(resolveTimer => setTimeout(resolveTimer, 40));
|
|
6047
|
+
const selected = sel.selectedOptions[0];
|
|
6048
|
+
return sel.selectedIndex === candidate.index && selected === candidate && optionMatchesPayload(selected);
|
|
4829
6049
|
}, {
|
|
4830
6050
|
value: opt.value ?? null,
|
|
4831
6051
|
label: opt.label ?? null,
|
|
@@ -4842,6 +6062,23 @@ export async function selectNativeOption(page, x, y, opt) {
|
|
|
4842
6062
|
export async function pickListboxOption(page, label, opts) {
|
|
4843
6063
|
let anchor;
|
|
4844
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
|
+
}
|
|
4845
6082
|
let attemptedSelection = false;
|
|
4846
6083
|
let selectedOptionText;
|
|
4847
6084
|
let openedHandle;
|
|
@@ -4867,16 +6104,16 @@ export async function pickListboxOption(page, label, opts) {
|
|
|
4867
6104
|
await releasePopupScope();
|
|
4868
6105
|
popupScope = await resolveOwnedPopupHandle(openedHandle);
|
|
4869
6106
|
};
|
|
4870
|
-
if (opts?.fieldLabel) {
|
|
6107
|
+
if (opts?.fieldLabel || opts?.fieldKey) {
|
|
4871
6108
|
let opened;
|
|
4872
6109
|
try {
|
|
4873
|
-
opened = await openDropdownControl(page, opts.fieldLabel, exact, opts.cache, opts.fieldId);
|
|
6110
|
+
opened = await openDropdownControl(page, opts.fieldLabel ?? '', exact, opts.cache, opts.fieldId, opts.fieldKey);
|
|
4874
6111
|
}
|
|
4875
6112
|
catch {
|
|
4876
6113
|
throw new Error(listboxErrorMessage({
|
|
4877
6114
|
reason: 'field_not_found',
|
|
4878
6115
|
requestedLabel: label,
|
|
4879
|
-
fieldLabel: opts.fieldLabel,
|
|
6116
|
+
fieldLabel: opts.fieldLabel ?? opts.fieldKey,
|
|
4880
6117
|
query: opts?.query,
|
|
4881
6118
|
exact,
|
|
4882
6119
|
}));
|
|
@@ -4896,6 +6133,10 @@ export async function pickListboxOption(page, label, opts) {
|
|
|
4896
6133
|
await refreshPopupScope();
|
|
4897
6134
|
}
|
|
4898
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');
|
|
4899
6140
|
await page.mouse.click(opts.openX, opts.openY);
|
|
4900
6141
|
anchor = { x: opts.openX, y: opts.openY };
|
|
4901
6142
|
await delay(120);
|
|
@@ -4905,29 +6146,27 @@ export async function pickListboxOption(page, label, opts) {
|
|
|
4905
6146
|
if (!selectedOptionText)
|
|
4906
6147
|
return false;
|
|
4907
6148
|
attemptedSelection = true;
|
|
4908
|
-
//
|
|
4909
|
-
//
|
|
4910
|
-
//
|
|
4911
|
-
//
|
|
4912
|
-
// the
|
|
4913
|
-
//
|
|
4914
|
-
// commit path, which ALL tested libraries honor. No-op for native
|
|
4915
|
-
// <select> / plain ARIA listboxes — see isAutocompleteCombobox.
|
|
4916
|
-
await pressEnterToCommitListbox(page, openedHandle);
|
|
6149
|
+
// Do not press Enter after a successful pointer selection. Both
|
|
6150
|
+
// react-select and non-editable Radix triggers can interpret Enter on
|
|
6151
|
+
// the now-closed control as "open again", leaving several adjacent
|
|
6152
|
+
// dropdowns expanded and making later shared-label picks hit the wrong
|
|
6153
|
+
// popup. If the pointer click did not commit, the keyboard fallback
|
|
6154
|
+
// below deliberately reopens this exact control and selects by Enter.
|
|
4917
6155
|
await dispatchListboxCommitEvents(openedHandle);
|
|
4918
|
-
if (
|
|
4919
|
-
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, {
|
|
4920
6158
|
editable: openedEditable,
|
|
4921
|
-
}))
|
|
4922
|
-
|
|
6159
|
+
}))
|
|
6160
|
+
return true;
|
|
6161
|
+
return false;
|
|
4923
6162
|
}
|
|
4924
|
-
return
|
|
6163
|
+
return await confirmListboxSelectionWithoutLabel(page, label, exact, anchor, openedHandle, selectedOptionText);
|
|
4925
6164
|
};
|
|
4926
6165
|
const dismissAfterSelection = async () => {
|
|
4927
6166
|
if (!opts?.fieldLabel) {
|
|
4928
6167
|
await page.keyboard.press('Tab');
|
|
4929
6168
|
await delay(50);
|
|
4930
|
-
return
|
|
6169
|
+
return await confirmListboxSelectionWithoutLabel(page, label, exact, anchor, openedHandle, selectedOptionText);
|
|
4931
6170
|
}
|
|
4932
6171
|
if (await dismissAndReVerifySelection(page, label, exact, openedHandle, selectedOptionText)) {
|
|
4933
6172
|
return true;
|
|
@@ -5024,10 +6263,12 @@ export async function pickListboxOption(page, label, opts) {
|
|
|
5024
6263
|
selectedOptionText = keyboardSelection;
|
|
5025
6264
|
attemptedSelection = true;
|
|
5026
6265
|
await dispatchListboxCommitEvents(openedHandle);
|
|
5027
|
-
|
|
5028
|
-
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, {
|
|
5029
6268
|
editable: openedEditable,
|
|
5030
|
-
})
|
|
6269
|
+
})
|
|
6270
|
+
: await confirmListboxSelectionWithoutLabel(page, label, exact, anchor, openedHandle, selectedOptionText);
|
|
6271
|
+
if (keyboardConfirmed) {
|
|
5031
6272
|
if (await dismissAfterSelection() && await postCommitVerify())
|
|
5032
6273
|
return;
|
|
5033
6274
|
}
|
|
@@ -5060,57 +6301,129 @@ export async function pickListboxOption(page, label, opts) {
|
|
|
5060
6301
|
await releasePopupScope();
|
|
5061
6302
|
}
|
|
5062
6303
|
}
|
|
5063
|
-
|
|
5064
|
-
|
|
5065
|
-
|
|
5066
|
-
|
|
5067
|
-
|
|
5068
|
-
|
|
5069
|
-
|
|
5070
|
-
|
|
6304
|
+
async function applyToggleState(locator, desiredChecked) {
|
|
6305
|
+
return await locator.evaluate((target, checked) => {
|
|
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) {
|
|
6315
|
+
if (!ids)
|
|
6316
|
+
return undefined;
|
|
6317
|
+
const seen = visited ?? new Set();
|
|
6318
|
+
const text = ids.split(/\s+/).map(id => {
|
|
6319
|
+
if (seen.has(id))
|
|
6320
|
+
return '';
|
|
6321
|
+
seen.add(id);
|
|
6322
|
+
const node = rootScopedElementById(owner, id);
|
|
6323
|
+
if (!node)
|
|
6324
|
+
return '';
|
|
6325
|
+
return referencedText(node, node.getAttribute('aria-labelledby'), seen) ?? node.textContent?.trim() ?? '';
|
|
6326
|
+
}).filter(Boolean).join(' ').trim();
|
|
6327
|
+
return text || undefined;
|
|
6328
|
+
}
|
|
6329
|
+
function kindOf(el) {
|
|
6330
|
+
if (el instanceof HTMLInputElement && el.type === 'radio')
|
|
6331
|
+
return 'radio';
|
|
6332
|
+
return el.getAttribute('role') === 'radio' ? 'radio' : 'checkbox';
|
|
6333
|
+
}
|
|
6334
|
+
function nameOf(el) {
|
|
6335
|
+
const aria = el.getAttribute('aria-label')?.trim();
|
|
6336
|
+
if (aria)
|
|
6337
|
+
return aria;
|
|
6338
|
+
const labelledBy = referencedText(el, el.getAttribute('aria-labelledby'));
|
|
6339
|
+
if (labelledBy)
|
|
6340
|
+
return labelledBy;
|
|
6341
|
+
if (el instanceof HTMLInputElement) {
|
|
6342
|
+
const labelText = Array.from(el.labels ?? []).map(label => label.textContent?.trim()).find(Boolean);
|
|
6343
|
+
if (labelText)
|
|
6344
|
+
return labelText;
|
|
6345
|
+
}
|
|
6346
|
+
return el.textContent?.trim() || '<unnamed>';
|
|
6347
|
+
}
|
|
6348
|
+
function readChecked(el) {
|
|
6349
|
+
return el instanceof HTMLInputElement ? el.checked : el.getAttribute('aria-checked') === 'true';
|
|
6350
|
+
}
|
|
6351
|
+
function dispatch(el) {
|
|
6352
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
6353
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
6354
|
+
}
|
|
6355
|
+
function setInputCheckedReactAware(input, next) {
|
|
6356
|
+
const tracker = input._valueTracker;
|
|
6357
|
+
if (tracker && typeof tracker.setValue === 'function') {
|
|
6358
|
+
try {
|
|
6359
|
+
tracker.setValue(String(!next));
|
|
6360
|
+
}
|
|
6361
|
+
catch { /* ignore */ }
|
|
6362
|
+
}
|
|
6363
|
+
const descriptor = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'checked');
|
|
6364
|
+
if (descriptor?.set)
|
|
6365
|
+
descriptor.set.call(input, next);
|
|
6366
|
+
else
|
|
6367
|
+
input.checked = next;
|
|
6368
|
+
dispatch(input);
|
|
6369
|
+
}
|
|
6370
|
+
const kind = kindOf(target);
|
|
6371
|
+
const name = nameOf(target);
|
|
6372
|
+
const before = readChecked(target);
|
|
6373
|
+
if (kind === 'radio' && checked === false) {
|
|
6374
|
+
return { success: before === false, reason: 'radio-uncheck', kind, name, before, after: before };
|
|
6375
|
+
}
|
|
6376
|
+
if (before !== checked) {
|
|
6377
|
+
if (target instanceof HTMLInputElement) {
|
|
6378
|
+
setInputCheckedReactAware(target, checked);
|
|
6379
|
+
}
|
|
6380
|
+
else if (target instanceof HTMLElement) {
|
|
6381
|
+
target.click();
|
|
6382
|
+
}
|
|
6383
|
+
}
|
|
6384
|
+
const after = readChecked(target);
|
|
6385
|
+
return { success: after === checked, kind, name, before, after };
|
|
6386
|
+
}, desiredChecked);
|
|
6387
|
+
}
|
|
6388
|
+
async function collectToggleMatches(page, label, exact, controlType, contextText, sectionText) {
|
|
6389
|
+
const matches = [];
|
|
5071
6390
|
for (const frame of page.frames()) {
|
|
5072
|
-
const
|
|
6391
|
+
const candidates = frame.locator(TOGGLE_CONTROL_SELECTOR);
|
|
6392
|
+
const metadata = await candidates.evaluateAll((elements, payload) => {
|
|
5073
6393
|
function normalize(value) {
|
|
5074
|
-
return value.replace(/\s+/g, ' ').trim().toLowerCase();
|
|
5075
|
-
}
|
|
5076
|
-
function matchesLabel(candidate) {
|
|
5077
|
-
if (!candidate)
|
|
5078
|
-
return false;
|
|
5079
|
-
const normalizedCandidate = normalize(candidate);
|
|
5080
|
-
const normalizedNeedle = normalize(payload.label);
|
|
5081
|
-
return payload.exact ? normalizedCandidate === normalizedNeedle : normalizedCandidate.includes(normalizedNeedle);
|
|
6394
|
+
return (value ?? '').replace(/\s+/g, ' ').trim().toLowerCase();
|
|
5082
6395
|
}
|
|
5083
6396
|
function visible(el) {
|
|
5084
|
-
|
|
5085
|
-
if (rect.width <= 0 || rect.height <= 0)
|
|
6397
|
+
if (!(el instanceof HTMLElement))
|
|
5086
6398
|
return false;
|
|
6399
|
+
const rect = el.getBoundingClientRect();
|
|
5087
6400
|
const style = getComputedStyle(el);
|
|
5088
|
-
return style.display !== 'none' && style.visibility !== 'hidden';
|
|
6401
|
+
return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden';
|
|
6402
|
+
}
|
|
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;
|
|
5089
6410
|
}
|
|
5090
|
-
function referencedText(ids, visited) {
|
|
6411
|
+
function referencedText(owner, ids, visited) {
|
|
5091
6412
|
if (!ids)
|
|
5092
6413
|
return undefined;
|
|
5093
6414
|
const seen = visited ?? new Set();
|
|
5094
|
-
const text = ids
|
|
5095
|
-
.split(/\s+/)
|
|
5096
|
-
.map(id => {
|
|
6415
|
+
const text = ids.split(/\s+/).map(id => {
|
|
5097
6416
|
if (seen.has(id))
|
|
5098
6417
|
return '';
|
|
5099
6418
|
seen.add(id);
|
|
5100
|
-
const
|
|
5101
|
-
if (!
|
|
6419
|
+
const node = rootScopedElementById(owner, id);
|
|
6420
|
+
if (!node)
|
|
5102
6421
|
return '';
|
|
5103
|
-
|
|
5104
|
-
|
|
5105
|
-
return referencedText(chained, seen) ?? '';
|
|
5106
|
-
return target.textContent?.trim() ?? '';
|
|
5107
|
-
})
|
|
5108
|
-
.filter(Boolean)
|
|
5109
|
-
.join(' ')
|
|
5110
|
-
.trim();
|
|
6422
|
+
return referencedText(node, node.getAttribute('aria-labelledby'), seen) ?? node.textContent?.trim() ?? '';
|
|
6423
|
+
}).filter(Boolean).join(' ').trim();
|
|
5111
6424
|
return text || undefined;
|
|
5112
6425
|
}
|
|
5113
|
-
function
|
|
6426
|
+
function kindOf(el) {
|
|
5114
6427
|
if (el instanceof HTMLInputElement) {
|
|
5115
6428
|
if (el.type === 'checkbox')
|
|
5116
6429
|
return 'checkbox';
|
|
@@ -5119,120 +6432,141 @@ export async function setCheckedControl(page, label, opts) {
|
|
|
5119
6432
|
return undefined;
|
|
5120
6433
|
}
|
|
5121
6434
|
const role = el.getAttribute('role');
|
|
5122
|
-
if (role === 'switch' || role === 'checkbox')
|
|
5123
|
-
return 'checkbox';
|
|
5124
6435
|
if (role === 'radio')
|
|
5125
6436
|
return 'radio';
|
|
6437
|
+
if (role === 'checkbox' || role === 'switch')
|
|
6438
|
+
return 'checkbox';
|
|
5126
6439
|
return undefined;
|
|
5127
6440
|
}
|
|
5128
|
-
function
|
|
6441
|
+
function nameOf(el) {
|
|
5129
6442
|
const aria = el.getAttribute('aria-label')?.trim();
|
|
5130
6443
|
if (aria)
|
|
5131
6444
|
return aria;
|
|
5132
|
-
const labelledBy = referencedText(el.getAttribute('aria-labelledby'));
|
|
6445
|
+
const labelledBy = referencedText(el, el.getAttribute('aria-labelledby'));
|
|
5133
6446
|
if (labelledBy)
|
|
5134
6447
|
return labelledBy;
|
|
5135
6448
|
if (el instanceof HTMLInputElement) {
|
|
5136
|
-
const
|
|
5137
|
-
|
|
5138
|
-
|
|
5139
|
-
|
|
5140
|
-
|
|
5141
|
-
|
|
5142
|
-
const nameAttr = el.getAttribute('name')?.trim();
|
|
5143
|
-
if (nameAttr && /[A-Za-z]/.test(nameAttr) && /[\s,./()_-]/.test(nameAttr))
|
|
5144
|
-
return nameAttr;
|
|
6449
|
+
const labelText = Array.from(el.labels ?? []).map(label => label.textContent?.trim()).find(Boolean);
|
|
6450
|
+
if (labelText)
|
|
6451
|
+
return labelText;
|
|
6452
|
+
const name = el.getAttribute('name')?.trim();
|
|
6453
|
+
if (name && /[A-Za-z]/.test(name) && /[\s,./()_-]/.test(name))
|
|
6454
|
+
return name;
|
|
5145
6455
|
}
|
|
5146
|
-
|
|
5147
|
-
return text || undefined;
|
|
5148
|
-
}
|
|
5149
|
-
function readChecked(el) {
|
|
5150
|
-
if (el instanceof HTMLInputElement)
|
|
5151
|
-
return !!el.checked;
|
|
5152
|
-
return el.getAttribute('aria-checked') === 'true';
|
|
5153
|
-
}
|
|
5154
|
-
function clickTarget(el) {
|
|
5155
|
-
if (el instanceof HTMLInputElement) {
|
|
5156
|
-
const labelEl = el.labels?.[0];
|
|
5157
|
-
if (labelEl instanceof HTMLElement)
|
|
5158
|
-
return labelEl;
|
|
5159
|
-
}
|
|
5160
|
-
return el instanceof HTMLElement ? el : null;
|
|
5161
|
-
}
|
|
5162
|
-
function dispatch(target) {
|
|
5163
|
-
target.dispatchEvent(new Event('input', { bubbles: true }));
|
|
5164
|
-
target.dispatchEvent(new Event('change', { bubbles: true }));
|
|
6456
|
+
return el.textContent?.trim() || undefined;
|
|
5165
6457
|
}
|
|
5166
|
-
function
|
|
5167
|
-
const
|
|
5168
|
-
|
|
5169
|
-
|
|
5170
|
-
|
|
5171
|
-
|
|
5172
|
-
|
|
5173
|
-
|
|
6458
|
+
function nearestContext(el, name) {
|
|
6459
|
+
const normalizedName = normalize(name);
|
|
6460
|
+
let current = el.parentElement;
|
|
6461
|
+
for (let depth = 0; current && depth < 6; depth++, current = current.parentElement) {
|
|
6462
|
+
if (current === document.body || current === document.documentElement)
|
|
6463
|
+
break;
|
|
6464
|
+
const text = (current.innerText || current.textContent || '').replace(/\s+/g, ' ').trim();
|
|
6465
|
+
if (text && normalize(text) !== normalizedName)
|
|
6466
|
+
return text;
|
|
5174
6467
|
}
|
|
5175
|
-
return
|
|
5176
|
-
}
|
|
5177
|
-
function setInputCheckedReactAware(target, next) {
|
|
5178
|
-
const hadReactTracker = clearReactTracker(target, next);
|
|
5179
|
-
if (!hadReactTracker && target.checked === next)
|
|
5180
|
-
return true;
|
|
5181
|
-
const descriptor = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'checked');
|
|
5182
|
-
if (descriptor?.set)
|
|
5183
|
-
descriptor.set.call(target, next);
|
|
5184
|
-
else
|
|
5185
|
-
target.checked = next;
|
|
5186
|
-
dispatch(target);
|
|
5187
|
-
return target.checked === next;
|
|
5188
|
-
}
|
|
5189
|
-
const selector = 'input[type="checkbox"], input[type="radio"], [role="checkbox"], [role="radio"], [role="switch"]';
|
|
5190
|
-
const candidates = Array.from(document.querySelectorAll(selector)).filter((el) => el instanceof HTMLElement && visible(el));
|
|
5191
|
-
const target = candidates.find((el) => {
|
|
5192
|
-
const kind = controlKind(el);
|
|
5193
|
-
if (!kind)
|
|
5194
|
-
return false;
|
|
5195
|
-
if (payload.controlType && kind !== payload.controlType)
|
|
5196
|
-
return false;
|
|
5197
|
-
return matchesLabel(controlName(el));
|
|
5198
|
-
});
|
|
5199
|
-
if (!target) {
|
|
5200
|
-
return { matched: false };
|
|
5201
|
-
}
|
|
5202
|
-
const kind = controlKind(target);
|
|
5203
|
-
const name = controlName(target) ?? payload.label;
|
|
5204
|
-
const before = readChecked(target);
|
|
5205
|
-
if (kind === 'radio' && payload.checked === false) {
|
|
5206
|
-
return { matched: true, success: before === false, reason: 'radio-uncheck', kind, name };
|
|
5207
|
-
}
|
|
5208
|
-
if (before !== payload.checked && !(target instanceof HTMLInputElement)) {
|
|
5209
|
-
clickTarget(target)?.click();
|
|
6468
|
+
return '';
|
|
5210
6469
|
}
|
|
5211
|
-
|
|
5212
|
-
|
|
5213
|
-
|
|
5214
|
-
|
|
6470
|
+
function nearestSection(el) {
|
|
6471
|
+
const section = el.closest('fieldset, section, form, dialog, [role="group"], [role="radiogroup"], [role="region"], [role="dialog"]');
|
|
6472
|
+
if (!(section instanceof HTMLElement))
|
|
6473
|
+
return '';
|
|
6474
|
+
const explicit = section.getAttribute('aria-label')?.trim() ||
|
|
6475
|
+
referencedText(section, section.getAttribute('aria-labelledby')) ||
|
|
6476
|
+
section.querySelector('legend, h1, h2, h3, h4, h5, h6')?.textContent?.trim();
|
|
6477
|
+
return (explicit || section.innerText || section.textContent || '').replace(/\s+/g, ' ').trim();
|
|
6478
|
+
}
|
|
6479
|
+
const needle = normalize(payload.label);
|
|
6480
|
+
const contextNeedle = normalize(payload.contextText);
|
|
6481
|
+
const sectionNeedle = normalize(payload.sectionText);
|
|
6482
|
+
const result = [];
|
|
6483
|
+
for (let index = 0; index < elements.length; index++) {
|
|
6484
|
+
const element = elements[index];
|
|
6485
|
+
if (!element)
|
|
6486
|
+
continue;
|
|
6487
|
+
if (!visible(element))
|
|
6488
|
+
continue;
|
|
6489
|
+
const kind = kindOf(element);
|
|
6490
|
+
if (!kind || (payload.controlType && kind !== payload.controlType))
|
|
6491
|
+
continue;
|
|
6492
|
+
const name = nameOf(element);
|
|
6493
|
+
if (!name)
|
|
6494
|
+
continue;
|
|
6495
|
+
const normalizedName = normalize(name);
|
|
6496
|
+
const exactLabel = normalizedName === needle;
|
|
6497
|
+
if (payload.exact ? !exactLabel : !normalizedName.includes(needle))
|
|
6498
|
+
continue;
|
|
6499
|
+
const context = nearestContext(element, name);
|
|
6500
|
+
const section = nearestSection(element);
|
|
6501
|
+
if (contextNeedle && !normalize(context).includes(contextNeedle))
|
|
6502
|
+
continue;
|
|
6503
|
+
if (sectionNeedle && !normalize(section).includes(sectionNeedle))
|
|
6504
|
+
continue;
|
|
6505
|
+
result.push({ index, kind, name, exactLabel, contextText: context, sectionText: section });
|
|
5215
6506
|
}
|
|
5216
|
-
return
|
|
5217
|
-
|
|
5218
|
-
|
|
5219
|
-
|
|
5220
|
-
name,
|
|
5221
|
-
before,
|
|
5222
|
-
after,
|
|
5223
|
-
};
|
|
5224
|
-
}, { label, exact, checked: desiredChecked, controlType: controlType ?? null });
|
|
5225
|
-
if (!result.matched)
|
|
5226
|
-
continue;
|
|
5227
|
-
if (result.success)
|
|
5228
|
-
return;
|
|
5229
|
-
if (result.reason === 'radio-uncheck') {
|
|
5230
|
-
throw new Error(`setChecked: radio "${result.name}" cannot be unchecked directly; choose a different option instead`);
|
|
6507
|
+
return result;
|
|
6508
|
+
}, { label, exact, controlType: controlType ?? null, contextText: contextText ?? '', sectionText: sectionText ?? '' });
|
|
6509
|
+
for (const candidate of metadata) {
|
|
6510
|
+
matches.push({ ...candidate, locator: candidates.nth(candidate.index), frameUrl: frame.url() });
|
|
5231
6511
|
}
|
|
5232
|
-
throw new Error(`setChecked: matched ${result.kind} "${result.name}" but could not set it to ${String(desiredChecked)}`);
|
|
5233
6512
|
}
|
|
5234
|
-
|
|
5235
|
-
|
|
6513
|
+
if (!exact) {
|
|
6514
|
+
const exactMatches = matches.filter(match => match.exactLabel);
|
|
6515
|
+
if (exactMatches.length > 0)
|
|
6516
|
+
return exactMatches;
|
|
6517
|
+
}
|
|
6518
|
+
return matches;
|
|
6519
|
+
}
|
|
6520
|
+
/**
|
|
6521
|
+
* Set a checkbox/radio by accessible label instead of brittle coordinate clicks.
|
|
6522
|
+
* Helps custom form UIs that keep the real control opacity-hidden but still interactive.
|
|
6523
|
+
*/
|
|
6524
|
+
export async function setCheckedControl(page, label, opts) {
|
|
6525
|
+
if (!label || label !== label.trim()) {
|
|
6526
|
+
throw new Error('setChecked: label must be a trimmed, non-empty string');
|
|
6527
|
+
}
|
|
6528
|
+
if (opts?.contextText !== undefined && (!opts.contextText || opts.contextText !== opts.contextText.trim())) {
|
|
6529
|
+
throw new Error('setChecked: contextText must be a trimmed, non-empty string when provided');
|
|
6530
|
+
}
|
|
6531
|
+
if (opts?.sectionText !== undefined && (!opts.sectionText || opts.sectionText !== opts.sectionText.trim())) {
|
|
6532
|
+
throw new Error('setChecked: sectionText must be a trimmed, non-empty string when provided');
|
|
6533
|
+
}
|
|
6534
|
+
const exact = opts?.exact ?? false;
|
|
6535
|
+
const desiredChecked = opts?.checked ?? true;
|
|
6536
|
+
const controlType = opts?.controlType;
|
|
6537
|
+
let target = null;
|
|
6538
|
+
if (opts?.fieldKey) {
|
|
6539
|
+
target = await findAuthoredFieldByKeyInPage(page, opts.fieldKey, 'toggle');
|
|
6540
|
+
if (!target) {
|
|
6541
|
+
throw new Error(`setChecked: fieldKey "${opts.fieldKey}" did not resolve to a visible checkbox/radio; refresh geometra_form_schema`);
|
|
6542
|
+
}
|
|
6543
|
+
}
|
|
6544
|
+
if (!target) {
|
|
6545
|
+
const matches = await collectToggleMatches(page, label, exact, controlType, opts?.contextText, opts?.sectionText);
|
|
6546
|
+
if (matches.length === 0) {
|
|
6547
|
+
const kindLabel = controlType ?? 'checkbox/radio';
|
|
6548
|
+
const context = [opts?.contextText && `context "${opts.contextText}"`, opts?.sectionText && `section "${opts.sectionText}"`]
|
|
6549
|
+
.filter(Boolean).join(' and ');
|
|
6550
|
+
throw new Error(`setChecked: no visible ${kindLabel} matching "${label}"${context ? ` in ${context}` : ''}`);
|
|
6551
|
+
}
|
|
6552
|
+
if (matches.length > 1) {
|
|
6553
|
+
const details = matches.slice(0, 5).map(match => `"${match.name}"${match.frameUrl ? ` in ${match.frameUrl}` : ''}`).join(', ');
|
|
6554
|
+
throw new Error(`setChecked: ambiguous label "${label}" matched ${matches.length} controls (${details}); provide fieldKey, contextText, or sectionText`);
|
|
6555
|
+
}
|
|
6556
|
+
target = matches[0].locator;
|
|
6557
|
+
}
|
|
6558
|
+
await assertLocatorMutable(target, 'toggle', 'setChecked');
|
|
6559
|
+
const result = await applyToggleState(target, desiredChecked);
|
|
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
|
+
}
|
|
6566
|
+
if (result.reason === 'radio-uncheck') {
|
|
6567
|
+
throw new Error(`setChecked: radio "${result.name}" cannot be unchecked directly; choose a different option instead`);
|
|
6568
|
+
}
|
|
6569
|
+
throw new Error(`setChecked: matched ${result.kind} "${result.name}" but could not set it to ${String(desiredChecked)}`);
|
|
5236
6570
|
}
|
|
5237
6571
|
export async function wheelAt(page, deltaX, deltaY, x, y) {
|
|
5238
6572
|
if (x !== undefined && y !== undefined && Number.isFinite(x) && Number.isFinite(y)) {
|