@geometra/proxy 1.19.7 → 1.19.9
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/dist/a11y-enrich.d.ts.map +1 -1
- package/dist/a11y-enrich.js +249 -5
- package/dist/a11y-enrich.js.map +1 -1
- package/dist/dom-actions.d.ts.map +1 -1
- package/dist/dom-actions.js +443 -62
- package/dist/dom-actions.js.map +1 -1
- package/dist/extractor.d.ts.map +1 -1
- package/dist/extractor.js +41 -0
- package/dist/extractor.js.map +1 -1
- package/dist/geometry-ws.d.ts.map +1 -1
- package/dist/geometry-ws.js +62 -66
- package/dist/geometry-ws.js.map +1 -1
- package/dist/types.d.ts +9 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
package/dist/dom-actions.js
CHANGED
|
@@ -5,24 +5,140 @@ function delay(ms) {
|
|
|
5
5
|
}
|
|
6
6
|
const LABELED_CONTROL_SELECTOR = 'input, select, textarea, button, [role="combobox"], [role="textbox"], [aria-haspopup="listbox"], [contenteditable="true"]';
|
|
7
7
|
const OPTION_PICKER_SELECTOR = '[role="option"], [role="menuitem"], [role="treeitem"], button, li, [data-value], [aria-selected], [aria-checked]';
|
|
8
|
+
function normalizedOptionLabel(value) {
|
|
9
|
+
return value.replace(/\s+/g, ' ').trim().toLowerCase();
|
|
10
|
+
}
|
|
11
|
+
function semanticSelectionAliases(value) {
|
|
12
|
+
const normalized = normalizedOptionLabel(value);
|
|
13
|
+
const aliases = new Set([normalized]);
|
|
14
|
+
if (normalized === 'yes' || normalized === 'true') {
|
|
15
|
+
for (const alias of ['agree', 'agreed', 'accept', 'accepted', 'consent', 'acknowledge', 'read', 'opt in']) {
|
|
16
|
+
aliases.add(alias);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
if (normalized === 'no' || normalized === 'false') {
|
|
20
|
+
for (const alias of ['decline', 'declined', 'disagree', 'deny', 'opt out', 'prefer not']) {
|
|
21
|
+
aliases.add(alias);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
if (normalized === 'decline') {
|
|
25
|
+
for (const alias of ['prefer not', 'opt out', 'do not']) {
|
|
26
|
+
aliases.add(alias);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return [...aliases];
|
|
30
|
+
}
|
|
31
|
+
function hasNegativeSelectionCue(value) {
|
|
32
|
+
return /\b(no|not|do not|don't|decline|disagree|deny|opt out|prefer not)\b/.test(value);
|
|
33
|
+
}
|
|
34
|
+
function hasPositiveSelectionCue(value) {
|
|
35
|
+
return /\b(yes|agree|accept|consent|acknowledge|opt in|allow|read)\b/.test(value);
|
|
36
|
+
}
|
|
37
|
+
function selectionMatchScore(candidate, expected, exact) {
|
|
38
|
+
if (!candidate)
|
|
39
|
+
return null;
|
|
40
|
+
const normalizedCandidate = normalizedOptionLabel(candidate);
|
|
41
|
+
const normalizedExpected = normalizedOptionLabel(expected);
|
|
42
|
+
if (!normalizedCandidate || !normalizedExpected)
|
|
43
|
+
return null;
|
|
44
|
+
const expectsPositive = normalizedExpected === 'yes' || normalizedExpected === 'true';
|
|
45
|
+
const expectsNegative = normalizedExpected === 'no' || normalizedExpected === 'false' || normalizedExpected === 'decline';
|
|
46
|
+
if (exact)
|
|
47
|
+
return normalizedCandidate === normalizedExpected ? 0 : null;
|
|
48
|
+
if (normalizedCandidate === normalizedExpected)
|
|
49
|
+
return 0;
|
|
50
|
+
if (normalizedCandidate.includes(normalizedExpected))
|
|
51
|
+
return normalizedCandidate.length - normalizedExpected.length;
|
|
52
|
+
if (expectsPositive && hasNegativeSelectionCue(normalizedCandidate))
|
|
53
|
+
return null;
|
|
54
|
+
if (expectsNegative && hasPositiveSelectionCue(normalizedCandidate))
|
|
55
|
+
return null;
|
|
56
|
+
const aliases = semanticSelectionAliases(normalizedExpected);
|
|
57
|
+
for (const alias of aliases) {
|
|
58
|
+
if (alias !== normalizedExpected && normalizedCandidate.includes(alias)) {
|
|
59
|
+
return 40 + normalizedCandidate.length - alias.length;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const tokens = normalizedExpected.split(' ').filter(token => token.length >= 3);
|
|
63
|
+
if (tokens.length >= 2) {
|
|
64
|
+
const matchedTokens = tokens.filter(token => normalizedCandidate.includes(token));
|
|
65
|
+
if (matchedTokens.length >= Math.min(2, tokens.length)) {
|
|
66
|
+
return 80 + (tokens.length - matchedTokens.length) * 10;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
function distanceFromPreferredAnchor(box, anchor) {
|
|
72
|
+
if (anchor?.x === undefined && anchor?.y === undefined)
|
|
73
|
+
return 0;
|
|
74
|
+
const centerX = box.x + box.width / 2;
|
|
75
|
+
const centerY = box.y + box.height / 2;
|
|
76
|
+
return Math.abs(centerX - (anchor?.x ?? centerX)) + Math.abs(centerY - (anchor?.y ?? centerY));
|
|
77
|
+
}
|
|
78
|
+
function browserDisplayedValues(el) {
|
|
79
|
+
const values = new Set();
|
|
80
|
+
const push = (value) => {
|
|
81
|
+
const trimmed = value?.trim();
|
|
82
|
+
if (trimmed && trimmed.length <= 240)
|
|
83
|
+
values.add(trimmed);
|
|
84
|
+
};
|
|
85
|
+
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
|
|
86
|
+
push(el.value);
|
|
87
|
+
push(el.getAttribute('aria-valuetext') ?? undefined);
|
|
88
|
+
push(el.getAttribute('aria-label') ?? undefined);
|
|
89
|
+
}
|
|
90
|
+
if (el instanceof HTMLSelectElement) {
|
|
91
|
+
push(el.selectedOptions[0]?.textContent ?? undefined);
|
|
92
|
+
push(el.value);
|
|
93
|
+
}
|
|
94
|
+
push(el.getAttribute('aria-valuetext') ?? undefined);
|
|
95
|
+
push(el.getAttribute('aria-label') ?? undefined);
|
|
96
|
+
push(el.textContent ?? undefined);
|
|
97
|
+
let current = el.parentElement;
|
|
98
|
+
for (let depth = 0; current && depth < 4; depth++) {
|
|
99
|
+
const role = current.getAttribute('role');
|
|
100
|
+
if (role === 'listbox' || role === 'menu') {
|
|
101
|
+
current = current.parentElement;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const className = typeof current.className === 'string' ? current.className.toLowerCase() : '';
|
|
105
|
+
const looksLikeFieldContainer = role === 'combobox' ||
|
|
106
|
+
current.getAttribute('aria-haspopup') === 'listbox' ||
|
|
107
|
+
current.tagName.toLowerCase() === 'button' ||
|
|
108
|
+
className.includes('select') ||
|
|
109
|
+
className.includes('combo') ||
|
|
110
|
+
className.includes('chip');
|
|
111
|
+
if (looksLikeFieldContainer)
|
|
112
|
+
push(current.textContent ?? undefined);
|
|
113
|
+
current = current.parentElement;
|
|
114
|
+
}
|
|
115
|
+
return [...values];
|
|
116
|
+
}
|
|
8
117
|
async function firstVisible(locator, opts) {
|
|
9
118
|
try {
|
|
10
119
|
const count = Math.min(await locator.count(), opts?.maxCandidates ?? 8);
|
|
11
|
-
let
|
|
120
|
+
let bestVisible = null;
|
|
121
|
+
let bestQualified = null;
|
|
12
122
|
for (let i = 0; i < count; i++) {
|
|
13
123
|
const candidate = locator.nth(i);
|
|
14
124
|
if (!(await candidate.isVisible()))
|
|
15
125
|
continue;
|
|
16
|
-
if (!firstAnyVisible)
|
|
17
|
-
firstAnyVisible = candidate;
|
|
18
126
|
const box = await candidate.boundingBox();
|
|
19
127
|
if (!box)
|
|
20
128
|
continue;
|
|
129
|
+
const score = distanceFromPreferredAnchor(box, opts?.preferredAnchor);
|
|
130
|
+
if (!bestVisible || score < bestVisible.score) {
|
|
131
|
+
bestVisible = { locator: candidate, score };
|
|
132
|
+
}
|
|
21
133
|
if ((opts?.minWidth ?? 0) <= box.width && (opts?.minHeight ?? 0) <= box.height) {
|
|
22
|
-
|
|
134
|
+
if (!bestQualified || score < bestQualified.score) {
|
|
135
|
+
bestQualified = { locator: candidate, score };
|
|
136
|
+
}
|
|
23
137
|
}
|
|
24
138
|
}
|
|
25
|
-
|
|
139
|
+
if (bestQualified)
|
|
140
|
+
return bestQualified.locator;
|
|
141
|
+
return opts?.fallbackToAnyVisible === false ? null : bestVisible?.locator ?? null;
|
|
26
142
|
}
|
|
27
143
|
catch {
|
|
28
144
|
/* ignore */
|
|
@@ -45,7 +161,77 @@ async function locatorAnchorY(locator) {
|
|
|
45
161
|
const bounds = await locator.boundingBox();
|
|
46
162
|
return bounds ? bounds.y + bounds.height / 2 : undefined;
|
|
47
163
|
}
|
|
48
|
-
async function
|
|
164
|
+
async function resolveMeaningfulClickTarget(locator) {
|
|
165
|
+
const baseHandle = await locator.elementHandle();
|
|
166
|
+
if (!baseHandle)
|
|
167
|
+
return { handle: null };
|
|
168
|
+
const targetHandle = (await baseHandle.evaluateHandle((el) => {
|
|
169
|
+
function isTextLikeControl(node) {
|
|
170
|
+
if (node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement)
|
|
171
|
+
return true;
|
|
172
|
+
if (node instanceof HTMLInputElement) {
|
|
173
|
+
return !['checkbox', 'radio', 'file', 'button', 'submit', 'reset', 'hidden', 'range', 'color'].includes(node.type);
|
|
174
|
+
}
|
|
175
|
+
const role = node.getAttribute('role');
|
|
176
|
+
return role === 'textbox' || role === 'combobox';
|
|
177
|
+
}
|
|
178
|
+
function visible(node) {
|
|
179
|
+
if (!(node instanceof HTMLElement))
|
|
180
|
+
return false;
|
|
181
|
+
const rect = node.getBoundingClientRect();
|
|
182
|
+
if (rect.width <= 0 || rect.height <= 0)
|
|
183
|
+
return false;
|
|
184
|
+
const style = getComputedStyle(node);
|
|
185
|
+
return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0';
|
|
186
|
+
}
|
|
187
|
+
if (!(el instanceof HTMLElement))
|
|
188
|
+
return el;
|
|
189
|
+
const rect = el.getBoundingClientRect();
|
|
190
|
+
if (!isTextLikeControl(el) || (rect.width >= 48 && rect.height >= 18))
|
|
191
|
+
return el;
|
|
192
|
+
let best = el;
|
|
193
|
+
let bestScore = Number.POSITIVE_INFINITY;
|
|
194
|
+
let current = el.parentElement;
|
|
195
|
+
let depth = 0;
|
|
196
|
+
while (current && depth < 6) {
|
|
197
|
+
if (visible(current)) {
|
|
198
|
+
const candidate = current.getBoundingClientRect();
|
|
199
|
+
const className = typeof current.className === 'string' ? current.className.toLowerCase() : '';
|
|
200
|
+
const role = current.getAttribute('role');
|
|
201
|
+
const looksLikeControl = role === 'combobox' ||
|
|
202
|
+
role === 'button' ||
|
|
203
|
+
current.getAttribute('aria-haspopup') === 'listbox' ||
|
|
204
|
+
className.includes('control') ||
|
|
205
|
+
className.includes('select') ||
|
|
206
|
+
className.includes('combo') ||
|
|
207
|
+
className.includes('input');
|
|
208
|
+
if (candidate.width >= rect.width &&
|
|
209
|
+
candidate.height >= rect.height &&
|
|
210
|
+
candidate.width > 0 &&
|
|
211
|
+
candidate.height > 0 &&
|
|
212
|
+
candidate.width <= window.innerWidth * 0.98 &&
|
|
213
|
+
candidate.height <= Math.max(window.innerHeight * 0.9, 320) &&
|
|
214
|
+
(candidate.width >= 48 || candidate.height >= 18)) {
|
|
215
|
+
const score = candidate.width * candidate.height + depth * 1000 - (looksLikeControl ? 20000 : 0);
|
|
216
|
+
if (score < bestScore) {
|
|
217
|
+
best = current;
|
|
218
|
+
bestScore = score;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
current = current.parentElement;
|
|
223
|
+
depth++;
|
|
224
|
+
}
|
|
225
|
+
return best;
|
|
226
|
+
}));
|
|
227
|
+
const bounds = await targetHandle.boundingBox();
|
|
228
|
+
return {
|
|
229
|
+
handle: targetHandle,
|
|
230
|
+
anchorX: bounds ? bounds.x + bounds.width / 2 : undefined,
|
|
231
|
+
anchorY: bounds ? bounds.y + bounds.height / 2 : undefined,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
async function findLabeledControl(frame, fieldLabel, exact, opts) {
|
|
49
235
|
const directCandidates = [
|
|
50
236
|
frame.getByLabel(fieldLabel, { exact }),
|
|
51
237
|
frame.getByRole('combobox', { name: fieldLabel, exact }),
|
|
@@ -53,7 +239,7 @@ async function findLabeledControl(frame, fieldLabel, exact) {
|
|
|
53
239
|
frame.getByRole('button', { name: fieldLabel, exact }),
|
|
54
240
|
];
|
|
55
241
|
for (const candidate of directCandidates) {
|
|
56
|
-
const visible = await firstVisible(candidate, {
|
|
242
|
+
const visible = await firstVisible(candidate, { preferredAnchor: opts?.preferredAnchor });
|
|
57
243
|
if (visible)
|
|
58
244
|
return visible;
|
|
59
245
|
}
|
|
@@ -137,14 +323,19 @@ async function findLabeledControl(frame, fieldLabel, exact) {
|
|
|
137
323
|
continue;
|
|
138
324
|
if (!visible(el))
|
|
139
325
|
continue;
|
|
326
|
+
const rect = el.getBoundingClientRect();
|
|
140
327
|
const explicit = explicitLabelText(el);
|
|
141
328
|
if (matches(explicit)) {
|
|
142
|
-
const
|
|
329
|
+
const centerX = rect.left + rect.width / 2;
|
|
330
|
+
const centerY = rect.top + rect.height / 2;
|
|
331
|
+
const anchorDistance = payload.anchorX === null && payload.anchorY === null
|
|
332
|
+
? 0
|
|
333
|
+
: Math.abs(centerX - (payload.anchorX ?? centerX)) + Math.abs(centerY - (payload.anchorY ?? centerY));
|
|
334
|
+
const score = controlPriority(el) + anchorDistance / 8;
|
|
143
335
|
if (!best || score < best.score)
|
|
144
336
|
best = { index: i, score };
|
|
145
337
|
continue;
|
|
146
338
|
}
|
|
147
|
-
const rect = el.getBoundingClientRect();
|
|
148
339
|
for (const labelNode of labelNodes) {
|
|
149
340
|
const labelText = labelNode.textContent?.trim();
|
|
150
341
|
if (!matches(labelText))
|
|
@@ -157,23 +348,38 @@ async function findLabeledControl(frame, fieldLabel, exact) {
|
|
|
157
348
|
const verticalDistance = rect.top >= labelRect.bottom - 12
|
|
158
349
|
? rect.top - labelRect.bottom
|
|
159
350
|
: 200 + Math.abs(rect.top - labelRect.top);
|
|
160
|
-
const
|
|
351
|
+
const centerX = rect.left + rect.width / 2;
|
|
352
|
+
const centerY = rect.top + rect.height / 2;
|
|
353
|
+
const anchorDistance = payload.anchorX === null && payload.anchorY === null
|
|
354
|
+
? 0
|
|
355
|
+
: Math.abs(centerX - (payload.anchorX ?? centerX)) + Math.abs(centerY - (payload.anchorY ?? centerY));
|
|
356
|
+
const score = 100 + verticalDistance * 3 + horizontalDistance + anchorDistance / 8 + controlPriority(el);
|
|
161
357
|
if (!best || score < best.score)
|
|
162
358
|
best = { index: i, score };
|
|
163
359
|
}
|
|
164
360
|
}
|
|
165
361
|
return best?.index ?? -1;
|
|
166
|
-
}, {
|
|
362
|
+
}, {
|
|
363
|
+
fieldLabel,
|
|
364
|
+
exact,
|
|
365
|
+
anchorX: opts?.preferredAnchor?.x ?? null,
|
|
366
|
+
anchorY: opts?.preferredAnchor?.y ?? null,
|
|
367
|
+
});
|
|
167
368
|
return bestIndex >= 0 ? fallbackCandidates.nth(bestIndex) : null;
|
|
168
369
|
}
|
|
169
370
|
function textMatches(candidate, expected, exact) {
|
|
170
|
-
|
|
371
|
+
return selectionMatchScore(candidate, expected, exact) !== null;
|
|
372
|
+
}
|
|
373
|
+
function displayedValueMatchesSelection(candidate, expected, exact, selectedOptionText) {
|
|
374
|
+
if (textMatches(candidate, expected, exact))
|
|
375
|
+
return true;
|
|
376
|
+
if (!candidate || !selectedOptionText || exact)
|
|
171
377
|
return false;
|
|
172
|
-
const normalizedCandidate = candidate
|
|
173
|
-
const
|
|
174
|
-
if (!normalizedCandidate || !
|
|
378
|
+
const normalizedCandidate = normalizedOptionLabel(candidate);
|
|
379
|
+
const normalizedSelectedOption = normalizedOptionLabel(selectedOptionText);
|
|
380
|
+
if (!normalizedCandidate || normalizedCandidate.length < 2 || !normalizedSelectedOption)
|
|
175
381
|
return false;
|
|
176
|
-
return
|
|
382
|
+
return (normalizedSelectedOption.includes(normalizedCandidate) || normalizedCandidate.includes(normalizedSelectedOption));
|
|
177
383
|
}
|
|
178
384
|
async function openDropdownControl(page, fieldLabel, exact) {
|
|
179
385
|
for (const frame of page.frames()) {
|
|
@@ -181,10 +387,23 @@ async function openDropdownControl(page, fieldLabel, exact) {
|
|
|
181
387
|
if (!locator)
|
|
182
388
|
continue;
|
|
183
389
|
await locator.scrollIntoViewIfNeeded();
|
|
184
|
-
const
|
|
390
|
+
const handle = await locator.elementHandle();
|
|
391
|
+
const clickTarget = await resolveMeaningfulClickTarget(locator);
|
|
185
392
|
const editable = await locatorIsEditable(locator);
|
|
186
|
-
|
|
187
|
-
|
|
393
|
+
if (clickTarget.handle) {
|
|
394
|
+
await clickTarget.handle.scrollIntoViewIfNeeded();
|
|
395
|
+
await clickTarget.handle.click();
|
|
396
|
+
}
|
|
397
|
+
else {
|
|
398
|
+
await locator.click();
|
|
399
|
+
}
|
|
400
|
+
return {
|
|
401
|
+
locator,
|
|
402
|
+
handle,
|
|
403
|
+
editable,
|
|
404
|
+
anchorX: clickTarget.anchorX,
|
|
405
|
+
anchorY: clickTarget.anchorY ?? await locatorAnchorY(locator),
|
|
406
|
+
};
|
|
188
407
|
}
|
|
189
408
|
throw new Error(`listboxPick: no visible combobox/dropdown matching field "${fieldLabel}"`);
|
|
190
409
|
}
|
|
@@ -221,12 +440,7 @@ async function typeIntoActiveEditableElement(page, text) {
|
|
|
221
440
|
}
|
|
222
441
|
return false;
|
|
223
442
|
}
|
|
224
|
-
async function clickVisibleOptionCandidate(page, label, exact,
|
|
225
|
-
const roleOption = await firstVisible(page.getByRole('option', { name: label, exact }));
|
|
226
|
-
if (roleOption) {
|
|
227
|
-
await roleOption.click();
|
|
228
|
-
return true;
|
|
229
|
-
}
|
|
443
|
+
async function clickVisibleOptionCandidate(page, label, exact, anchor) {
|
|
230
444
|
for (const frame of page.frames()) {
|
|
231
445
|
const candidates = frame.locator(OPTION_PICKER_SELECTOR);
|
|
232
446
|
const count = await candidates.count();
|
|
@@ -236,14 +450,63 @@ async function clickVisibleOptionCandidate(page, label, exact, anchorY) {
|
|
|
236
450
|
function normalize(value) {
|
|
237
451
|
return value.replace(/\s+/g, ' ').trim().toLowerCase();
|
|
238
452
|
}
|
|
239
|
-
function
|
|
453
|
+
function aliases(value) {
|
|
454
|
+
const out = new Set([value]);
|
|
455
|
+
if (value === 'yes' || value === 'true') {
|
|
456
|
+
for (const alias of ['agree', 'agreed', 'accept', 'accepted', 'consent', 'acknowledge', 'read', 'opt in']) {
|
|
457
|
+
out.add(alias);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
if (value === 'no' || value === 'false') {
|
|
461
|
+
for (const alias of ['decline', 'declined', 'disagree', 'deny', 'opt out', 'prefer not']) {
|
|
462
|
+
out.add(alias);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
if (value === 'decline') {
|
|
466
|
+
for (const alias of ['prefer not', 'opt out', 'do not']) {
|
|
467
|
+
out.add(alias);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
return [...out];
|
|
471
|
+
}
|
|
472
|
+
function hasNegativeCue(value) {
|
|
473
|
+
return /\b(no|not|do not|don't|decline|disagree|deny|opt out|prefer not)\b/.test(value);
|
|
474
|
+
}
|
|
475
|
+
function hasPositiveCue(value) {
|
|
476
|
+
return /\b(yes|agree|accept|consent|acknowledge|opt in|allow|read)\b/.test(value);
|
|
477
|
+
}
|
|
478
|
+
function matchScore(candidate) {
|
|
240
479
|
if (!candidate)
|
|
241
|
-
return
|
|
480
|
+
return null;
|
|
242
481
|
const normalizedCandidate = normalize(candidate);
|
|
243
482
|
const normalizedExpected = normalize(payload.label);
|
|
244
483
|
if (!normalizedCandidate || !normalizedExpected)
|
|
245
|
-
return
|
|
246
|
-
|
|
484
|
+
return null;
|
|
485
|
+
const expectsPositive = normalizedExpected === 'yes' || normalizedExpected === 'true';
|
|
486
|
+
const expectsNegative = normalizedExpected === 'no' || normalizedExpected === 'false' || normalizedExpected === 'decline';
|
|
487
|
+
if (payload.exact)
|
|
488
|
+
return normalizedCandidate === normalizedExpected ? 0 : null;
|
|
489
|
+
if (normalizedCandidate === normalizedExpected)
|
|
490
|
+
return 0;
|
|
491
|
+
if (normalizedCandidate.includes(normalizedExpected))
|
|
492
|
+
return normalizedCandidate.length - normalizedExpected.length;
|
|
493
|
+
if (expectsPositive && hasNegativeCue(normalizedCandidate))
|
|
494
|
+
return null;
|
|
495
|
+
if (expectsNegative && hasPositiveCue(normalizedCandidate))
|
|
496
|
+
return null;
|
|
497
|
+
for (const alias of aliases(normalizedExpected)) {
|
|
498
|
+
if (alias !== normalizedExpected && normalizedCandidate.includes(alias)) {
|
|
499
|
+
return 40 + normalizedCandidate.length - alias.length;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
const tokens = normalizedExpected.split(' ').filter(token => token.length >= 3);
|
|
503
|
+
if (tokens.length >= 2) {
|
|
504
|
+
const matches = tokens.filter(token => normalizedCandidate.includes(token));
|
|
505
|
+
if (matches.length >= Math.min(2, tokens.length)) {
|
|
506
|
+
return 80 + (tokens.length - matches.length) * 10;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
return null;
|
|
247
510
|
}
|
|
248
511
|
function visible(el) {
|
|
249
512
|
if (!(el instanceof HTMLElement))
|
|
@@ -267,61 +530,173 @@ async function clickVisibleOptionCandidate(page, label, exact, anchorY) {
|
|
|
267
530
|
if (!visible(el))
|
|
268
531
|
continue;
|
|
269
532
|
const candidateText = el.getAttribute('aria-label')?.trim() || el.textContent?.trim() || '';
|
|
270
|
-
|
|
533
|
+
const match = matchScore(candidateText);
|
|
534
|
+
if (match === null)
|
|
271
535
|
continue;
|
|
272
536
|
const rect = el.getBoundingClientRect();
|
|
537
|
+
const centerX = rect.left + rect.width / 2;
|
|
273
538
|
const centerY = rect.top + rect.height / 2;
|
|
274
539
|
const upwardPenalty = payload.anchorY === null || centerY >= payload.anchorY - 16
|
|
275
540
|
? 0
|
|
276
541
|
: 140;
|
|
277
|
-
const
|
|
278
|
-
const
|
|
542
|
+
const verticalProximity = payload.anchorY === null ? rect.top : Math.abs(centerY - payload.anchorY);
|
|
543
|
+
const horizontalProximity = payload.anchorX === null ? 0 : Math.abs(centerX - payload.anchorX);
|
|
544
|
+
const score = popupWeight(el) + upwardPenalty + verticalProximity + horizontalProximity / 2 + match * 2;
|
|
279
545
|
if (!best || score < best.score)
|
|
280
546
|
best = { index: i, score };
|
|
281
547
|
}
|
|
282
548
|
return best?.index ?? -1;
|
|
283
|
-
}, { label, exact,
|
|
549
|
+
}, { label, exact, anchorX: anchor?.x ?? null, anchorY: anchor?.y ?? null });
|
|
284
550
|
if (bestIndex >= 0) {
|
|
551
|
+
const selectedText = (await candidates
|
|
552
|
+
.nth(bestIndex)
|
|
553
|
+
.evaluate(el => el.getAttribute('aria-label')?.trim() || el.textContent?.trim() || '')
|
|
554
|
+
.catch(() => '')) || null;
|
|
285
555
|
await candidates.nth(bestIndex).click();
|
|
286
|
-
return
|
|
556
|
+
return selectedText;
|
|
287
557
|
}
|
|
288
558
|
}
|
|
289
|
-
return
|
|
559
|
+
return null;
|
|
290
560
|
}
|
|
291
|
-
async function
|
|
561
|
+
async function locatorDisplayedValues(locator) {
|
|
292
562
|
try {
|
|
293
|
-
return await locator.evaluate(
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
return ariaValueText;
|
|
303
|
-
const ariaLabel = el.getAttribute('aria-label')?.trim();
|
|
304
|
-
if (ariaLabel)
|
|
305
|
-
return ariaLabel;
|
|
306
|
-
const text = el.textContent?.trim();
|
|
307
|
-
return text || undefined;
|
|
308
|
-
});
|
|
563
|
+
return await locator.evaluate(browserDisplayedValues);
|
|
564
|
+
}
|
|
565
|
+
catch {
|
|
566
|
+
return [];
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
async function elementHandleDisplayedValues(handle) {
|
|
570
|
+
try {
|
|
571
|
+
return await handle.evaluate(browserDisplayedValues);
|
|
309
572
|
}
|
|
310
573
|
catch {
|
|
311
|
-
return
|
|
574
|
+
return [];
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
async function visibleOptionIsSelected(page, label, exact, anchor) {
|
|
578
|
+
for (const frame of page.frames()) {
|
|
579
|
+
const candidates = frame.locator(OPTION_PICKER_SELECTOR);
|
|
580
|
+
const count = await candidates.count();
|
|
581
|
+
if (count === 0)
|
|
582
|
+
continue;
|
|
583
|
+
const selected = await candidates.evaluateAll((elements, payload) => {
|
|
584
|
+
function normalize(value) {
|
|
585
|
+
return value.replace(/\s+/g, ' ').trim().toLowerCase();
|
|
586
|
+
}
|
|
587
|
+
function aliases(value) {
|
|
588
|
+
const out = new Set([value]);
|
|
589
|
+
if (value === 'yes' || value === 'true') {
|
|
590
|
+
for (const alias of ['agree', 'agreed', 'accept', 'accepted', 'consent', 'acknowledge', 'read', 'opt in']) {
|
|
591
|
+
out.add(alias);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
if (value === 'no' || value === 'false') {
|
|
595
|
+
for (const alias of ['decline', 'declined', 'disagree', 'deny', 'opt out', 'prefer not']) {
|
|
596
|
+
out.add(alias);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
if (value === 'decline') {
|
|
600
|
+
for (const alias of ['prefer not', 'opt out', 'do not']) {
|
|
601
|
+
out.add(alias);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
return [...out];
|
|
605
|
+
}
|
|
606
|
+
function hasNegativeCue(value) {
|
|
607
|
+
return /\b(no|not|do not|don't|decline|disagree|deny|opt out|prefer not)\b/.test(value);
|
|
608
|
+
}
|
|
609
|
+
function hasPositiveCue(value) {
|
|
610
|
+
return /\b(yes|agree|accept|consent|acknowledge|opt in|allow|read)\b/.test(value);
|
|
611
|
+
}
|
|
612
|
+
function matchScore(candidate) {
|
|
613
|
+
if (!candidate)
|
|
614
|
+
return null;
|
|
615
|
+
const normalizedCandidate = normalize(candidate);
|
|
616
|
+
const normalizedExpected = normalize(payload.label);
|
|
617
|
+
if (!normalizedCandidate || !normalizedExpected)
|
|
618
|
+
return null;
|
|
619
|
+
const expectsPositive = normalizedExpected === 'yes' || normalizedExpected === 'true';
|
|
620
|
+
const expectsNegative = normalizedExpected === 'no' || normalizedExpected === 'false' || normalizedExpected === 'decline';
|
|
621
|
+
if (payload.exact)
|
|
622
|
+
return normalizedCandidate === normalizedExpected ? 0 : null;
|
|
623
|
+
if (normalizedCandidate === normalizedExpected)
|
|
624
|
+
return 0;
|
|
625
|
+
if (normalizedCandidate.includes(normalizedExpected))
|
|
626
|
+
return normalizedCandidate.length - normalizedExpected.length;
|
|
627
|
+
if (expectsPositive && hasNegativeCue(normalizedCandidate))
|
|
628
|
+
return null;
|
|
629
|
+
if (expectsNegative && hasPositiveCue(normalizedCandidate))
|
|
630
|
+
return null;
|
|
631
|
+
for (const alias of aliases(normalizedExpected)) {
|
|
632
|
+
if (alias !== normalizedExpected && normalizedCandidate.includes(alias)) {
|
|
633
|
+
return 40 + normalizedCandidate.length - alias.length;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
return null;
|
|
637
|
+
}
|
|
638
|
+
function visible(el) {
|
|
639
|
+
if (!(el instanceof HTMLElement))
|
|
640
|
+
return false;
|
|
641
|
+
const rect = el.getBoundingClientRect();
|
|
642
|
+
if (rect.width <= 0 || rect.height <= 0)
|
|
643
|
+
return false;
|
|
644
|
+
const style = getComputedStyle(el);
|
|
645
|
+
return style.display !== 'none' && style.visibility !== 'hidden';
|
|
646
|
+
}
|
|
647
|
+
function isSelected(el) {
|
|
648
|
+
return (el.getAttribute('aria-selected') === 'true' ||
|
|
649
|
+
el.getAttribute('aria-checked') === 'true' ||
|
|
650
|
+
el.getAttribute('data-selected') === 'true' ||
|
|
651
|
+
el.getAttribute('data-state') === 'checked' ||
|
|
652
|
+
el.getAttribute('data-state') === 'on');
|
|
653
|
+
}
|
|
654
|
+
let bestScore = Number.POSITIVE_INFINITY;
|
|
655
|
+
for (const el of elements) {
|
|
656
|
+
if (!(el instanceof Element))
|
|
657
|
+
continue;
|
|
658
|
+
if (!visible(el))
|
|
659
|
+
continue;
|
|
660
|
+
if (!isSelected(el))
|
|
661
|
+
continue;
|
|
662
|
+
const text = el.getAttribute('aria-label')?.trim() || el.textContent?.trim() || '';
|
|
663
|
+
const match = matchScore(text);
|
|
664
|
+
if (match === null)
|
|
665
|
+
continue;
|
|
666
|
+
const rect = el.getBoundingClientRect();
|
|
667
|
+
const centerX = rect.left + rect.width / 2;
|
|
668
|
+
const centerY = rect.top + rect.height / 2;
|
|
669
|
+
const distance = payload.anchorX === null && payload.anchorY === null
|
|
670
|
+
? 0
|
|
671
|
+
: Math.abs(centerX - (payload.anchorX ?? centerX)) + Math.abs(centerY - (payload.anchorY ?? centerY));
|
|
672
|
+
bestScore = Math.min(bestScore, match * 2 + distance / 2);
|
|
673
|
+
}
|
|
674
|
+
return Number.isFinite(bestScore);
|
|
675
|
+
}, { label, exact, anchorX: anchor?.x ?? null, anchorY: anchor?.y ?? null });
|
|
676
|
+
if (selected)
|
|
677
|
+
return true;
|
|
312
678
|
}
|
|
679
|
+
return false;
|
|
313
680
|
}
|
|
314
|
-
async function confirmListboxSelection(page, fieldLabel, label, exact) {
|
|
681
|
+
async function confirmListboxSelection(page, fieldLabel, label, exact, anchor, currentHandle, selectedOptionText) {
|
|
682
|
+
if (currentHandle) {
|
|
683
|
+
const immediateValues = await elementHandleDisplayedValues(currentHandle);
|
|
684
|
+
if (immediateValues.some(value => displayedValueMatchesSelection(value, label, exact, selectedOptionText))) {
|
|
685
|
+
return true;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
315
688
|
const deadline = Date.now() + 1500;
|
|
316
689
|
while (Date.now() < deadline) {
|
|
317
690
|
for (const frame of page.frames()) {
|
|
318
|
-
const locator = await findLabeledControl(frame, fieldLabel, exact);
|
|
691
|
+
const locator = await findLabeledControl(frame, fieldLabel, exact, { preferredAnchor: anchor });
|
|
319
692
|
if (!locator)
|
|
320
693
|
continue;
|
|
321
|
-
const
|
|
322
|
-
if (
|
|
694
|
+
const values = await locatorDisplayedValues(locator);
|
|
695
|
+
if (values.some(value => displayedValueMatchesSelection(value, label, exact, selectedOptionText)))
|
|
323
696
|
return true;
|
|
324
697
|
}
|
|
698
|
+
if (await visibleOptionIsSelected(page, label, exact, anchor))
|
|
699
|
+
return true;
|
|
325
700
|
await delay(100);
|
|
326
701
|
}
|
|
327
702
|
return false;
|
|
@@ -478,12 +853,15 @@ export async function selectNativeOption(page, x, y, opt) {
|
|
|
478
853
|
* Custom listbox / combobox (ARIA): optional click to open, then `getByRole('option')`.
|
|
479
854
|
*/
|
|
480
855
|
export async function pickListboxOption(page, label, opts) {
|
|
481
|
-
let
|
|
856
|
+
let anchor;
|
|
482
857
|
const exact = opts?.exact ?? false;
|
|
483
858
|
let attemptedSelection = false;
|
|
859
|
+
let selectedOptionText;
|
|
860
|
+
let openedHandle;
|
|
484
861
|
if (opts?.fieldLabel) {
|
|
485
862
|
const opened = await openDropdownControl(page, opts.fieldLabel, exact);
|
|
486
|
-
|
|
863
|
+
anchor = { x: opened.anchorX, y: opened.anchorY };
|
|
864
|
+
openedHandle = opened.handle;
|
|
487
865
|
const query = opts.query ?? label;
|
|
488
866
|
if (query && opened.editable) {
|
|
489
867
|
await typeIntoEditableLocator(page, opened.locator, query);
|
|
@@ -495,15 +873,18 @@ export async function pickListboxOption(page, label, opts) {
|
|
|
495
873
|
}
|
|
496
874
|
else if (opts?.openX !== undefined && opts?.openY !== undefined) {
|
|
497
875
|
await page.mouse.click(opts.openX, opts.openY);
|
|
498
|
-
|
|
876
|
+
anchor = { x: opts.openX, y: opts.openY };
|
|
499
877
|
await delay(120);
|
|
500
878
|
}
|
|
501
879
|
const deadline = Date.now() + 3000;
|
|
502
880
|
while (Date.now() < deadline) {
|
|
503
|
-
|
|
881
|
+
selectedOptionText = (await clickVisibleOptionCandidate(page, label, exact, anchor)) ?? undefined;
|
|
882
|
+
if (selectedOptionText) {
|
|
504
883
|
attemptedSelection = true;
|
|
505
|
-
if (!opts?.fieldLabel ||
|
|
884
|
+
if (!opts?.fieldLabel ||
|
|
885
|
+
await confirmListboxSelection(page, opts.fieldLabel, label, exact, anchor, openedHandle, selectedOptionText)) {
|
|
506
886
|
return;
|
|
887
|
+
}
|
|
507
888
|
}
|
|
508
889
|
await delay(120);
|
|
509
890
|
}
|