@letsrunit/playwright 0.18.1 → 0.18.3
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/index.js +704 -175
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/browser.ts +7 -5
- package/src/field/aria-select.ts +117 -13
- package/src/field/calendar.ts +77 -11
- package/src/field/composite-date.ts +121 -0
- package/src/field/composite-select.ts +182 -0
- package/src/field/composite-slider.ts +87 -0
- package/src/field/composite-toggle.ts +49 -0
- package/src/field/date-group.ts +99 -69
- package/src/field/date-text-input.ts +49 -31
- package/src/field/index.ts +17 -8
- package/src/field/native-date.ts +7 -4
- package/src/field/radio-group.ts +74 -12
- package/src/field/slider.ts +9 -4
- package/src/field/toggle.ts +28 -5
- package/src/scrub-html.ts +5 -5
- package/src/suppress-interferences.ts +7 -5
- package/src/wait.ts +22 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@letsrunit/playwright",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.3",
|
|
4
4
|
"description": "Playwright extensions and utilities for letsrunit",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"testing",
|
|
@@ -42,8 +42,8 @@
|
|
|
42
42
|
},
|
|
43
43
|
"packageManager": "yarn@4.10.3",
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@letsrunit/utils": "0.18.
|
|
46
|
-
"@playwright/test": "
|
|
45
|
+
"@letsrunit/utils": "0.18.3",
|
|
46
|
+
"@playwright/test": "1.58.2",
|
|
47
47
|
"case": "^1.6.3",
|
|
48
48
|
"diff": "^8.0.3",
|
|
49
49
|
"fast-json-stable-stringify": "^2.1.0",
|
package/src/browser.ts
CHANGED
|
@@ -11,11 +11,13 @@ export async function browse(browser: Browser, options: BrowserContextOptions =
|
|
|
11
11
|
});
|
|
12
12
|
|
|
13
13
|
// Safety net against bundler-injected helpers inside page.evaluate
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
14
|
+
await context.addInitScript(
|
|
15
|
+
/* v8 ignore next — callback runs in browser context, not Node */
|
|
16
|
+
() => {
|
|
17
|
+
// define __name as a no-op if present
|
|
18
|
+
(window as any).__name = (window as any).__name || ((fn: any) => fn);
|
|
19
|
+
},
|
|
20
|
+
);
|
|
19
21
|
|
|
20
22
|
return await context.newPage();
|
|
21
23
|
}
|
package/src/field/aria-select.ts
CHANGED
|
@@ -1,32 +1,136 @@
|
|
|
1
1
|
import type { Loc, SetOptions, Value } from './types';
|
|
2
2
|
|
|
3
|
+
function cssAttrEquals(name: string, value: string): string {
|
|
4
|
+
return `[${name}=${JSON.stringify(value)}]`;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function caseInsensitiveExact(value: string): RegExp {
|
|
8
|
+
const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
9
|
+
return new RegExp(`^${escaped}$`, 'i');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function getComboboxRoot(el: Loc['el'], options?: SetOptions) {
|
|
13
|
+
const role = await el.getAttribute('role', options).catch(
|
|
14
|
+
/* v8 ignore next — attribute might be missing or element might have detached during the check */
|
|
15
|
+
() => null,
|
|
16
|
+
);
|
|
17
|
+
if (role === 'combobox') return el;
|
|
18
|
+
|
|
19
|
+
const byRole = el.locator('[role="combobox"]').first();
|
|
20
|
+
if ((await byRole.count()) > 0) return byRole;
|
|
21
|
+
|
|
22
|
+
const popupInput = el
|
|
23
|
+
.locator('input[aria-controls], input[aria-owns], input[aria-haspopup], textarea[aria-controls], textarea[aria-owns], textarea[aria-haspopup]')
|
|
24
|
+
.first();
|
|
25
|
+
if ((await popupInput.count()) > 0) return popupInput;
|
|
26
|
+
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function getControlledListbox(el: Loc['el'], options?: SetOptions) {
|
|
31
|
+
const ids: string[] = [];
|
|
32
|
+
for (const attr of ['aria-controls', 'aria-owns']) {
|
|
33
|
+
const raw = await el.getAttribute(attr, options).catch(
|
|
34
|
+
/* v8 ignore next — attribute might be missing or element might have detached during the check */
|
|
35
|
+
() => null,
|
|
36
|
+
);
|
|
37
|
+
if (!raw) continue;
|
|
38
|
+
ids.push(...raw.split(/\s+/).filter(Boolean));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
for (const id of ids) {
|
|
42
|
+
const listbox = el.page().locator(`[role="listbox"]${cssAttrEquals('id', id)}`).first();
|
|
43
|
+
if ((await listbox.count()) > 0) return listbox;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function getVisibleListbox(el: Loc['el'], options?: SetOptions) {
|
|
50
|
+
const listboxes = el.page().locator('[role="listbox"]:visible');
|
|
51
|
+
await listboxes.first().waitFor({ state: 'visible', timeout: options?.timeout }).catch(() => null);
|
|
52
|
+
if ((await listboxes.count()) > 0) return listboxes.first();
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function didSelectionApply(
|
|
57
|
+
root: Loc['el'],
|
|
58
|
+
option: Loc['el'],
|
|
59
|
+
before: string | null,
|
|
60
|
+
options?: SetOptions,
|
|
61
|
+
): Promise<boolean> {
|
|
62
|
+
const ariaSelected = await option.getAttribute('aria-selected', options).catch(() => null);
|
|
63
|
+
if (ariaSelected === 'true') return true;
|
|
64
|
+
|
|
65
|
+
const optionId = await option.getAttribute('id', options).catch(() => null);
|
|
66
|
+
const activeDescendant = await root.getAttribute('aria-activedescendant', options).catch(() => null);
|
|
67
|
+
if (optionId && activeDescendant === optionId) return true;
|
|
68
|
+
|
|
69
|
+
const after = await root
|
|
70
|
+
.evaluate((node) => {
|
|
71
|
+
if (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) {
|
|
72
|
+
return node.value;
|
|
73
|
+
}
|
|
74
|
+
return node.textContent?.trim() ?? '';
|
|
75
|
+
}, options)
|
|
76
|
+
.catch(() => null);
|
|
77
|
+
|
|
78
|
+
if (before !== null && after !== null && after !== before) return true;
|
|
79
|
+
|
|
80
|
+
const listbox = await getVisibleListbox(root, options);
|
|
81
|
+
if (!listbox) return true;
|
|
82
|
+
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
|
|
3
86
|
export async function selectAria({ el }: Loc, value: Value, options?: SetOptions): Promise<boolean> {
|
|
4
87
|
if (typeof value !== 'string' && typeof value !== 'number') return false;
|
|
5
88
|
|
|
6
|
-
const
|
|
7
|
-
if (
|
|
89
|
+
const root = await getComboboxRoot(el, options);
|
|
90
|
+
if (!root) return false;
|
|
91
|
+
|
|
92
|
+
let listbox = await getControlledListbox(root, options);
|
|
8
93
|
|
|
9
|
-
const
|
|
10
|
-
|
|
94
|
+
const ariaExpanded = await root.getAttribute('aria-expanded', options).catch(
|
|
95
|
+
/* v8 ignore next — attribute might be missing or element might have detached during the check */
|
|
96
|
+
() => null,
|
|
97
|
+
);
|
|
98
|
+
if (ariaExpanded !== 'true') {
|
|
99
|
+
await root.click(options);
|
|
100
|
+
listbox = listbox ?? (await getControlledListbox(root, options));
|
|
101
|
+
}
|
|
11
102
|
|
|
12
|
-
|
|
13
|
-
if (
|
|
103
|
+
listbox = listbox ?? (await getVisibleListbox(root, options));
|
|
104
|
+
if (!listbox) return false;
|
|
14
105
|
|
|
15
106
|
const stringValue = String(value);
|
|
16
|
-
const
|
|
107
|
+
const before = await root
|
|
108
|
+
.evaluate((node) => {
|
|
109
|
+
if (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) {
|
|
110
|
+
return node.value;
|
|
111
|
+
}
|
|
112
|
+
return node.textContent?.trim() ?? '';
|
|
113
|
+
}, options)
|
|
114
|
+
.catch(() => null);
|
|
17
115
|
|
|
18
116
|
// 1. By value attribute
|
|
19
|
-
const byValue = listbox.locator(
|
|
117
|
+
const byValue = listbox.locator(
|
|
118
|
+
`[role="option"]${cssAttrEquals('value', stringValue)}, ` +
|
|
119
|
+
`[role="option"]${cssAttrEquals('data-value', stringValue)}, ` +
|
|
120
|
+
`[role="option"]${cssAttrEquals('ng-reflect-value', stringValue)}`,
|
|
121
|
+
);
|
|
20
122
|
if ((await byValue.count()) >= 1) {
|
|
21
|
-
|
|
22
|
-
|
|
123
|
+
const option = byValue.first();
|
|
124
|
+
await option.click(options);
|
|
125
|
+
return await didSelectionApply(root, option, before, options);
|
|
23
126
|
}
|
|
24
127
|
|
|
25
128
|
// 2. By accessible name (case-insensitive)
|
|
26
|
-
const byName = listbox.getByRole('option', { name: stringValue });
|
|
129
|
+
const byName = listbox.getByRole('option', { name: caseInsensitiveExact(stringValue) });
|
|
27
130
|
if ((await byName.count()) >= 1) {
|
|
28
|
-
|
|
29
|
-
|
|
131
|
+
const option = byName.first();
|
|
132
|
+
await option.click(options);
|
|
133
|
+
return await didSelectionApply(root, option, before, options);
|
|
30
134
|
}
|
|
31
135
|
|
|
32
136
|
return false;
|
package/src/field/calendar.ts
CHANGED
|
@@ -41,6 +41,32 @@ async function isCalendarGrid(grid: Locator): Promise<boolean> {
|
|
|
41
41
|
return last >= 28;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
function isDayToken(text: string): boolean {
|
|
45
|
+
const day = Number(text.trim());
|
|
46
|
+
return Number.isInteger(day) && day >= 1 && day <= 31;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function isStructuralCalendar(root: Locator): Promise<boolean> {
|
|
50
|
+
const candidates = root.locator('button, [role="button"], [tabindex], div, span').filter({ visible: true });
|
|
51
|
+
const count = await candidates.count();
|
|
52
|
+
if (count < 28) return false;
|
|
53
|
+
|
|
54
|
+
const tokens = (await candidates.allInnerTexts()).map((text) => text.trim()).filter(isDayToken);
|
|
55
|
+
if (tokens.length < 28 || tokens.length > 120) return false;
|
|
56
|
+
|
|
57
|
+
const daySet = new Set(tokens.map(Number));
|
|
58
|
+
const hasStart = [1, 2, 3, 4, 5, 6, 7].some((day) => daySet.has(day));
|
|
59
|
+
const hasMiddle = [22, 23, 24, 25, 26, 27, 28].some((day) => daySet.has(day));
|
|
60
|
+
|
|
61
|
+
return daySet.size >= 28 && hasStart && hasMiddle;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function dayCellSelector(hasGridCells: boolean): string {
|
|
65
|
+
return hasGridCells
|
|
66
|
+
? 'td, [role="gridcell"]'
|
|
67
|
+
: 'button, [role="button"], [tabindex], div, span';
|
|
68
|
+
}
|
|
69
|
+
|
|
44
70
|
export async function getCalendar(
|
|
45
71
|
root: Locator,
|
|
46
72
|
options?: SetOptions,
|
|
@@ -57,6 +83,9 @@ export async function getCalendar(
|
|
|
57
83
|
return { calendar: root, tables: [root] };
|
|
58
84
|
}
|
|
59
85
|
/* v8 ignore stop */
|
|
86
|
+
if (!container && (await isStructuralCalendar(root))) {
|
|
87
|
+
return { calendar: root, tables: [root] };
|
|
88
|
+
}
|
|
60
89
|
if (!container) return null;
|
|
61
90
|
|
|
62
91
|
// 2. Find all valid calendar grids within the container
|
|
@@ -77,7 +106,15 @@ export async function getCalendar(
|
|
|
77
106
|
// 3. Deduplicate (prevents issues if container == grid)
|
|
78
107
|
const uniqueTables = tables.filter((t, i, self) => self.indexOf(t) === i);
|
|
79
108
|
|
|
80
|
-
|
|
109
|
+
if (uniqueTables.length > 0) {
|
|
110
|
+
return { calendar: container, tables: uniqueTables };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (await isStructuralCalendar(container)) {
|
|
114
|
+
return { calendar: container, tables: [container] };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return null;
|
|
81
118
|
}
|
|
82
119
|
|
|
83
120
|
function uniqueMonthYearPairs(pairs: MonthYear[]): MonthYear[] {
|
|
@@ -167,10 +204,29 @@ async function navigateToMonth(
|
|
|
167
204
|
const firstMonthTotal = currentMonths[0].year * 12 + currentMonths[0].month;
|
|
168
205
|
const diff = targetTotal - firstMonthTotal;
|
|
169
206
|
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
207
|
+
const prevSelector = [
|
|
208
|
+
'[aria-label*="prev"]',
|
|
209
|
+
'[aria-label*="Prev"]',
|
|
210
|
+
'[aria-label*="previous"]',
|
|
211
|
+
'[aria-label*="Previous"]',
|
|
212
|
+
'[class*="prev"]',
|
|
213
|
+
'[class*="Prev"]',
|
|
214
|
+
'[title*="prev"]',
|
|
215
|
+
'[title*="Prev"]',
|
|
216
|
+
'button:has-text("Previous")',
|
|
217
|
+
'button:has-text("Prev")',
|
|
218
|
+
].join(', ');
|
|
219
|
+
const nextSelector = [
|
|
220
|
+
'[aria-label*="next"]',
|
|
221
|
+
'[aria-label*="Next"]',
|
|
222
|
+
'[class*="next"]',
|
|
223
|
+
'[class*="Next"]',
|
|
224
|
+
'[title*="next"]',
|
|
225
|
+
'[title*="Next"]',
|
|
226
|
+
'button:has-text("Next")',
|
|
227
|
+
].join(', ');
|
|
228
|
+
|
|
229
|
+
const btn = diff < 0 ? root.locator(prevSelector).filter({ visible: true }).first() : root.locator(nextSelector).filter({ visible: true }).first();
|
|
174
230
|
|
|
175
231
|
if (!(await btn.count())) return false;
|
|
176
232
|
|
|
@@ -184,14 +240,12 @@ async function navigateToMonth(
|
|
|
184
240
|
const afterMonths = await getCurrentMonthsAndYears(root, target);
|
|
185
241
|
if (afterMonths.some((m) => m.year * 12 + m.month === targetTotal)) return true;
|
|
186
242
|
|
|
187
|
-
/* v8 ignore start */
|
|
188
243
|
if ((options?.retry ?? 0) < 3) {
|
|
189
244
|
const retry = (options?.retry ?? 0) + 1;
|
|
190
245
|
return await navigateToMonth(root, target, { ...options, wait: 50 + 50 * retry, retry }); // Retry max 3x
|
|
191
246
|
}
|
|
192
247
|
|
|
193
248
|
return false;
|
|
194
|
-
/* v8 ignore stop */
|
|
195
249
|
}
|
|
196
250
|
|
|
197
251
|
async function setDayByAriaLabel(table: Locator, value: Date, options?: SetOptions): Promise<boolean> {
|
|
@@ -239,17 +293,29 @@ async function setDayByAriaLabel(table: Locator, value: Date, options?: SetOptio
|
|
|
239
293
|
|
|
240
294
|
async function setDayByByCellText(table: Locator, value: Date, options?: SetOptions): Promise<boolean> {
|
|
241
295
|
const day = value.getDate();
|
|
242
|
-
const
|
|
296
|
+
const gridCells = table.locator('td, [role="gridcell"]').filter({ visible: true });
|
|
297
|
+
const hasGridCells = (await gridCells.count()) > 0;
|
|
298
|
+
const cells = table.locator(dayCellSelector(hasGridCells)).filter({ visible: true });
|
|
243
299
|
|
|
244
300
|
const allTexts = await cells.allInnerTexts();
|
|
245
301
|
const foundIndices = allTexts.map((text, i) => (text.trim() === String(day) ? i : -1)).filter((i) => i !== -1);
|
|
246
302
|
|
|
247
303
|
if (foundIndices.length === 0) return false;
|
|
248
304
|
|
|
249
|
-
const
|
|
250
|
-
|
|
305
|
+
const ordered =
|
|
306
|
+
foundIndices.length === 1 || day < 15 ? foundIndices : [...foundIndices].reverse();
|
|
251
307
|
|
|
252
|
-
|
|
308
|
+
for (const index of ordered) {
|
|
309
|
+
const cell = cells.nth(index);
|
|
310
|
+
try {
|
|
311
|
+
await cell.click(options);
|
|
312
|
+
return true;
|
|
313
|
+
} catch {
|
|
314
|
+
// If this candidate is not interactable, continue to the next matching day node.
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return false;
|
|
253
319
|
}
|
|
254
320
|
|
|
255
321
|
async function setDates(calendar: Locator, tables: Locator[], dates: Date[], options?: SetOptions): Promise<void> {
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { isArray, isDate, isRange } from '@letsrunit/utils';
|
|
2
|
+
import type { Range } from '@letsrunit/utils';
|
|
3
|
+
import type { Locator } from '@playwright/test';
|
|
4
|
+
import { setCalendarDate } from './calendar';
|
|
5
|
+
import type { Loc, SetOptions, Value } from './types';
|
|
6
|
+
|
|
7
|
+
function toDates(value: Date | Date[] | Range<Date>): Date[] {
|
|
8
|
+
if (isRange(value)) return [value.from, value.to];
|
|
9
|
+
if (isArray(value, isDate)) return value;
|
|
10
|
+
return [value];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function formatDate(date: Date, pattern: 'iso' | 'dmy' | 'mdy' | 'ymd'): string {
|
|
14
|
+
const y = date.getFullYear();
|
|
15
|
+
const m = date.getMonth() + 1;
|
|
16
|
+
const d = date.getDate();
|
|
17
|
+
|
|
18
|
+
const mm = String(m).padStart(2, '0');
|
|
19
|
+
const dd = String(d).padStart(2, '0');
|
|
20
|
+
|
|
21
|
+
if (pattern === 'iso' || pattern === 'ymd') return `${y}-${mm}-${dd}`;
|
|
22
|
+
if (pattern === 'dmy') return `${dd}/${mm}/${y}`;
|
|
23
|
+
return `${mm}/${dd}/${y}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function digits(value: string): number[] {
|
|
27
|
+
return (value.match(/\d+/g) ?? []).map((v) => Number.parseInt(v, 10));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function matchesDateLoosely(value: string, target: Date): boolean {
|
|
31
|
+
const nums = digits(value);
|
|
32
|
+
if (nums.length < 3) return false;
|
|
33
|
+
return nums.includes(target.getFullYear()) && nums.includes(target.getMonth() + 1) && nums.includes(target.getDate());
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function commitInput(input: Locator, text: string, options?: SetOptions): Promise<string> {
|
|
37
|
+
await input.click(options);
|
|
38
|
+
await input.clear(options);
|
|
39
|
+
await input.fill(text, options);
|
|
40
|
+
await input.press('Enter', options).catch(() => null);
|
|
41
|
+
await input.evaluate((node) => (node as HTMLInputElement).blur(), options).catch(() => null);
|
|
42
|
+
await input.evaluate(() => new Promise(requestAnimationFrame)).catch(() => null);
|
|
43
|
+
return input.inputValue(options).catch(() => '');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function setSingleInputDate(input: Locator, value: Date, options?: SetOptions): Promise<boolean> {
|
|
47
|
+
const patterns: Array<'iso' | 'dmy' | 'mdy' | 'ymd'> = ['iso', 'dmy', 'mdy', 'ymd'];
|
|
48
|
+
|
|
49
|
+
for (const pattern of patterns) {
|
|
50
|
+
const readBack = await commitInput(input, formatDate(value, pattern), options);
|
|
51
|
+
if (matchesDateLoosely(readBack, value)) return true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function getDateInputs(el: Locator, options?: SetOptions): Promise<Locator[]> {
|
|
58
|
+
const rootTag = await el.evaluate((node) => node.tagName.toLowerCase(), options);
|
|
59
|
+
if (rootTag === 'input') return [el];
|
|
60
|
+
|
|
61
|
+
const inputs = el.locator('input[type="text"], input:not([type]), input');
|
|
62
|
+
const count = await inputs.count();
|
|
63
|
+
if (count === 0) return [];
|
|
64
|
+
|
|
65
|
+
const result: Locator[] = [];
|
|
66
|
+
for (let i = 0; i < count; i++) {
|
|
67
|
+
const candidate = inputs.nth(i);
|
|
68
|
+
const visible = await candidate.isVisible(options).catch(() => false);
|
|
69
|
+
if (!visible) continue;
|
|
70
|
+
result.push(candidate);
|
|
71
|
+
}
|
|
72
|
+
return result;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function asLoc(el: Locator, options?: SetOptions): Promise<Loc> {
|
|
76
|
+
const tag = await el.evaluate((node) => node.tagName.toLowerCase(), options);
|
|
77
|
+
const type = await el
|
|
78
|
+
.getAttribute('type', options)
|
|
79
|
+
.then((s) => s && s.toLowerCase())
|
|
80
|
+
.catch(() => null);
|
|
81
|
+
return { el, tag, type };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function getPickerRootFromInput(input: Locator): Promise<Locator | null> {
|
|
85
|
+
const picker = input
|
|
86
|
+
.locator('xpath=ancestor::*[.//input and .//*[@aria-label="calendar"]][1]')
|
|
87
|
+
.first();
|
|
88
|
+
if ((await picker.count()) > 0) return picker;
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function setByCalendar(input: Locator, date: Date, options?: SetOptions): Promise<boolean> {
|
|
93
|
+
const picker = await getPickerRootFromInput(input);
|
|
94
|
+
if (!picker) return false;
|
|
95
|
+
const pickerLoc = await asLoc(picker, options);
|
|
96
|
+
return setCalendarDate(pickerLoc, date, options);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function setCompositeDate({ el }: Loc, value: Value, options?: SetOptions): Promise<boolean> {
|
|
100
|
+
if (!(value instanceof Date) && !isArray(value, isDate) && !isRange(value, isDate)) return false;
|
|
101
|
+
|
|
102
|
+
const dates = toDates(value as Date | Date[] | Range<Date>);
|
|
103
|
+
const inputs = await getDateInputs(el, options);
|
|
104
|
+
if (inputs.length === 0) return false;
|
|
105
|
+
|
|
106
|
+
if (dates.length === 1) {
|
|
107
|
+
const singleInput = await setSingleInputDate(inputs[0], dates[0], options);
|
|
108
|
+
if (singleInput) return true;
|
|
109
|
+
return setByCalendar(inputs[0], dates[0], options);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (inputs.length < 2) return false;
|
|
113
|
+
|
|
114
|
+
const first = await setSingleInputDate(inputs[0], dates[0], options);
|
|
115
|
+
const second = await setSingleInputDate(inputs[1], dates[1], options);
|
|
116
|
+
if (first && second) return true;
|
|
117
|
+
|
|
118
|
+
const firstCal = await setByCalendar(inputs[0], dates[0], options);
|
|
119
|
+
const secondCal = await setByCalendar(inputs[1], dates[1], options);
|
|
120
|
+
return firstCal && secondCal;
|
|
121
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import type { Locator } from '@playwright/test';
|
|
2
|
+
import type { Loc, SetOptions, Value } from './types';
|
|
3
|
+
|
|
4
|
+
function caseInsensitiveExact(value: string): RegExp {
|
|
5
|
+
const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
6
|
+
return new RegExp(`^${escaped}$`, 'i');
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
async function openControl(el: Locator, options?: SetOptions): Promise<void> {
|
|
10
|
+
const activator = el
|
|
11
|
+
.locator('button[aria-haspopup], [role="button"][aria-haspopup], input[readonly]')
|
|
12
|
+
.first();
|
|
13
|
+
if ((await activator.count()) > 0) {
|
|
14
|
+
await activator.click(options);
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
await el.click(options);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function getPopupCandidates(page: any): Locator {
|
|
21
|
+
return page.locator(
|
|
22
|
+
[
|
|
23
|
+
'[role="listbox"]:visible',
|
|
24
|
+
'[role="menu"]:visible',
|
|
25
|
+
'[role="dialog"]:visible',
|
|
26
|
+
'[role="presentation"]:visible',
|
|
27
|
+
'.cdk-overlay-pane:visible',
|
|
28
|
+
].join(', '),
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function looksLikeCompositeSelect(el: Locator, options?: SetOptions): Promise<boolean> {
|
|
33
|
+
const role = await el.getAttribute('role', options).catch(() => null);
|
|
34
|
+
if (role === 'combobox' || role === 'listbox') return false;
|
|
35
|
+
|
|
36
|
+
if ((await el.locator('input[type="radio"], input[type="checkbox"]').count()) > 0) return false;
|
|
37
|
+
if ((await el.locator('[role="slider"], [aria-valuenow]').count()) > 0) return false;
|
|
38
|
+
|
|
39
|
+
const cues = el.locator(
|
|
40
|
+
[
|
|
41
|
+
'[aria-haspopup]',
|
|
42
|
+
'[aria-controls]',
|
|
43
|
+
'[aria-owns]',
|
|
44
|
+
'input[readonly]',
|
|
45
|
+
'button[aria-haspopup]',
|
|
46
|
+
].join(', '),
|
|
47
|
+
);
|
|
48
|
+
return (await cues.count()) > 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function getOptionFromPopup(popup: Locator, value: string, options?: SetOptions): Promise<Locator | null> {
|
|
52
|
+
const byValue = popup.locator(
|
|
53
|
+
`[value=${JSON.stringify(value)}], [data-value=${JSON.stringify(value)}], [aria-label=${JSON.stringify(value)}]`,
|
|
54
|
+
);
|
|
55
|
+
if ((await byValue.count()) > 0) return byValue.first();
|
|
56
|
+
|
|
57
|
+
const byRole = popup.getByRole('option', { name: caseInsensitiveExact(value) });
|
|
58
|
+
if ((await byRole.count()) > 0) return byRole.first();
|
|
59
|
+
|
|
60
|
+
const byText = popup.getByText(caseInsensitiveExact(value), { exact: true });
|
|
61
|
+
if ((await byText.count()) > 0) return byText.first();
|
|
62
|
+
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function getPopupOptions(popup: Locator): Promise<Locator> {
|
|
67
|
+
return popup.locator(
|
|
68
|
+
[
|
|
69
|
+
'[role="option"]',
|
|
70
|
+
'[title]',
|
|
71
|
+
].join(', '),
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function toNumber(value: string | null): number | null {
|
|
76
|
+
if (!value) return null;
|
|
77
|
+
const n = Number.parseFloat(value);
|
|
78
|
+
return Number.isFinite(n) ? n : null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function getContextNumericValue(el: Locator, options?: SetOptions): Promise<number | null> {
|
|
82
|
+
const labelled = el.page().getByLabel('result').first();
|
|
83
|
+
if ((await labelled.count()) > 0) {
|
|
84
|
+
const text = await labelled.textContent(options).catch(() => null);
|
|
85
|
+
const parsed = toNumber(text);
|
|
86
|
+
if (parsed !== null) return parsed;
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function getRootText(el: Locator, options?: SetOptions): Promise<string> {
|
|
92
|
+
return (
|
|
93
|
+
(await el
|
|
94
|
+
.evaluate((node) => (node.textContent ?? '').replace(/\s+/g, ' ').trim(), options)
|
|
95
|
+
.catch(() => '')) || ''
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function setCompositeSelect({ el }: Loc, value: Value, options?: SetOptions): Promise<boolean> {
|
|
100
|
+
if (typeof value !== 'string' && typeof value !== 'number') return false;
|
|
101
|
+
if (!(await looksLikeCompositeSelect(el, options))) return false;
|
|
102
|
+
|
|
103
|
+
const stringValue = String(value);
|
|
104
|
+
const before = await getRootText(el, options);
|
|
105
|
+
const popupsBefore = await getPopupCandidates(el.page()).count();
|
|
106
|
+
|
|
107
|
+
await openControl(el, options);
|
|
108
|
+
|
|
109
|
+
const popups = await getPopupCandidates(el.page());
|
|
110
|
+
await popups.first().waitFor({ state: 'visible', timeout: options?.timeout }).catch(() => null);
|
|
111
|
+
const count = await popups.count();
|
|
112
|
+
if (count === 0) return false;
|
|
113
|
+
|
|
114
|
+
const popup = popups.nth(Math.max(0, count - 1));
|
|
115
|
+
const option = await getOptionFromPopup(popup, stringValue, options);
|
|
116
|
+
if (option) {
|
|
117
|
+
await option.click({ ...options, force: true });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const after = await getRootText(el, options);
|
|
121
|
+
if (after && before && after !== before) return true;
|
|
122
|
+
|
|
123
|
+
const popupsAfter = await getPopupCandidates(el.page()).count();
|
|
124
|
+
if (option && (popupsAfter < popupsBefore || popupsAfter < count)) return true;
|
|
125
|
+
|
|
126
|
+
const targetNum = toNumber(stringValue);
|
|
127
|
+
if (targetNum === null) return false;
|
|
128
|
+
|
|
129
|
+
const optionsLoc = await getPopupOptions(popup);
|
|
130
|
+
const totalOptions = await optionsLoc.count();
|
|
131
|
+
if (totalOptions === 0) return false;
|
|
132
|
+
|
|
133
|
+
const labels: string[] = [];
|
|
134
|
+
for (let i = 0; i < totalOptions; i++) {
|
|
135
|
+
const candidate = optionsLoc.nth(i);
|
|
136
|
+
const label =
|
|
137
|
+
(await candidate.getAttribute('title', options).catch(() => null)) ||
|
|
138
|
+
(await candidate.textContent(options).catch(() => '')) ||
|
|
139
|
+
'';
|
|
140
|
+
const normalized = label.replace(/\s+/g, ' ').trim();
|
|
141
|
+
if (!normalized) continue;
|
|
142
|
+
if (!labels.includes(normalized)) labels.push(normalized);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
for (const label of labels) {
|
|
146
|
+
await openControl(el, options);
|
|
147
|
+
const activePopups = getPopupCandidates(el.page());
|
|
148
|
+
const activePopupCount = await activePopups.count();
|
|
149
|
+
if (activePopupCount === 0) continue;
|
|
150
|
+
const activePopup = activePopups.nth(Math.max(0, activePopupCount - 1));
|
|
151
|
+
|
|
152
|
+
let candidate = activePopup.locator(`[title=${JSON.stringify(label)}]`).first();
|
|
153
|
+
if ((await candidate.count()) === 0) {
|
|
154
|
+
candidate = activePopup.getByText(caseInsensitiveExact(label), { exact: true }).first();
|
|
155
|
+
}
|
|
156
|
+
if ((await candidate.count()) === 0) continue;
|
|
157
|
+
|
|
158
|
+
await candidate.click({ ...options, force: true });
|
|
159
|
+
|
|
160
|
+
const numeric = await getContextNumericValue(el, options);
|
|
161
|
+
if (numeric !== null && Math.abs(numeric - targetNum) < 0.001) return true;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const current = await getContextNumericValue(el, options);
|
|
165
|
+
if (current === null) return false;
|
|
166
|
+
|
|
167
|
+
await openControl(el, options);
|
|
168
|
+
await el.focus(options).catch(() => null);
|
|
169
|
+
const page = el.page();
|
|
170
|
+
const key = targetNum > current ? 'ArrowDown' : 'ArrowUp';
|
|
171
|
+
|
|
172
|
+
for (let i = 0; i < 20; i++) {
|
|
173
|
+
await page.keyboard.press(key);
|
|
174
|
+
await page.keyboard.press('Enter');
|
|
175
|
+
const numeric = await getContextNumericValue(el, options);
|
|
176
|
+
if (numeric !== null && Math.abs(numeric - targetNum) < 0.001) return true;
|
|
177
|
+
await openControl(el, options);
|
|
178
|
+
await el.focus(options).catch(() => null);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return false;
|
|
182
|
+
}
|