@letsrunit/playwright 0.18.2 → 0.19.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/dist/index.js +702 -173
- 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 +2 -2
- package/src/suppress-interferences.ts +7 -5
- package/src/wait.ts +22 -8
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { Locator } from '@playwright/test';
|
|
2
|
+
import type { Loc, SetOptions, Value } from './types';
|
|
3
|
+
|
|
4
|
+
async function getSliderHandle(el: Locator, options?: SetOptions): Promise<Locator | null> {
|
|
5
|
+
const byAria = el.locator('[aria-valuenow], [role="slider"]').first();
|
|
6
|
+
if ((await byAria.count()) > 0) return byAria;
|
|
7
|
+
|
|
8
|
+
const focusables = el.locator('[tabindex]');
|
|
9
|
+
if ((await focusables.count()) > 0) return focusables.first();
|
|
10
|
+
|
|
11
|
+
const buttons = el.locator('button');
|
|
12
|
+
if ((await buttons.count()) > 0) return buttons.first();
|
|
13
|
+
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function toNumber(value: string | null): number | null {
|
|
18
|
+
if (!value) return null;
|
|
19
|
+
const n = Number.parseFloat(value);
|
|
20
|
+
return Number.isFinite(n) ? n : null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function getValueFromHandle(handle: Locator, options?: SetOptions): Promise<number | null> {
|
|
24
|
+
const fromAria = await handle.getAttribute('aria-valuenow', options).catch(() => null);
|
|
25
|
+
const parsedAria = toNumber(fromAria);
|
|
26
|
+
if (parsedAria !== null) return parsedAria;
|
|
27
|
+
|
|
28
|
+
const fromText = await handle.textContent(options).catch(() => null);
|
|
29
|
+
return toNumber(fromText);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function getValueFromContext(el: Locator, options?: SetOptions): Promise<number | null> {
|
|
33
|
+
const labelled = el.page().getByLabel('result').first();
|
|
34
|
+
if ((await labelled.count()) > 0) {
|
|
35
|
+
const text = await labelled.textContent(options).catch(() => null);
|
|
36
|
+
const parsed = toNumber(text);
|
|
37
|
+
if (parsed !== null) return parsed;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const siblingNumber = await el
|
|
41
|
+
.evaluate((node) => {
|
|
42
|
+
const parent = node.parentElement;
|
|
43
|
+
if (!parent) return null;
|
|
44
|
+
for (const child of Array.from(parent.children)) {
|
|
45
|
+
if (child === node) continue;
|
|
46
|
+
const text = (child.textContent ?? '').trim();
|
|
47
|
+
if (!text) continue;
|
|
48
|
+
const n = Number.parseFloat(text);
|
|
49
|
+
if (Number.isFinite(n)) return n;
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}, options)
|
|
53
|
+
.catch(() => null);
|
|
54
|
+
|
|
55
|
+
return typeof siblingNumber === 'number' ? siblingNumber : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function readValue(el: Locator, handle: Locator, options?: SetOptions): Promise<number | null> {
|
|
59
|
+
const fromHandle = await getValueFromHandle(handle, options);
|
|
60
|
+
if (fromHandle !== null) return fromHandle;
|
|
61
|
+
return getValueFromContext(el, options);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function setCompositeSlider({ el }: Loc, value: Value, options?: SetOptions): Promise<boolean> {
|
|
65
|
+
if (typeof value !== 'number') return false;
|
|
66
|
+
|
|
67
|
+
const handle = await getSliderHandle(el, options);
|
|
68
|
+
if (!handle) return false;
|
|
69
|
+
|
|
70
|
+
const initial = await readValue(el, handle, options);
|
|
71
|
+
if (initial === null) return false;
|
|
72
|
+
if (Math.abs(initial - value) < 0.001) return true;
|
|
73
|
+
|
|
74
|
+
await handle.focus(options);
|
|
75
|
+
const page = el.page();
|
|
76
|
+
const key = value > initial ? 'ArrowRight' : 'ArrowLeft';
|
|
77
|
+
const maxPresses = Math.max(1, Math.min(200, Math.abs(Math.round(value - initial)) * 2));
|
|
78
|
+
|
|
79
|
+
for (let i = 0; i < maxPresses; i++) {
|
|
80
|
+
await page.keyboard.press(key);
|
|
81
|
+
const current = await readValue(el, handle, options);
|
|
82
|
+
if (current !== null && Math.abs(current - value) < 0.001) return true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const finalValue = await readValue(el, handle, options);
|
|
86
|
+
return finalValue !== null && Math.abs(finalValue - value) < 0.001;
|
|
87
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { Locator } from '@playwright/test';
|
|
2
|
+
import type { Loc, SetOptions, Value } from './types';
|
|
3
|
+
|
|
4
|
+
async function getToggleTarget(el: Locator, options?: SetOptions): Promise<Locator | null> {
|
|
5
|
+
const candidates = el.locator('button, [role="button"], [role="switch"], [role="checkbox"], [aria-checked], [tabindex]');
|
|
6
|
+
if ((await candidates.count()) === 0) return null;
|
|
7
|
+
|
|
8
|
+
const visible = candidates.filter({ visible: true });
|
|
9
|
+
if ((await visible.count()) > 0) return visible.first();
|
|
10
|
+
|
|
11
|
+
return candidates.first();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function readToggleState(target: Locator, options?: SetOptions): Promise<boolean | null> {
|
|
15
|
+
const ariaChecked = await target.getAttribute('aria-checked', options).catch(() => null);
|
|
16
|
+
if (ariaChecked === 'true') return true;
|
|
17
|
+
if (ariaChecked === 'false') return false;
|
|
18
|
+
|
|
19
|
+
const checked = await target.getAttribute('checked', options).catch(() => null);
|
|
20
|
+
if (checked !== null) return checked !== 'false';
|
|
21
|
+
|
|
22
|
+
const className = await target.getAttribute('class', options).catch(() => null);
|
|
23
|
+
if (className) {
|
|
24
|
+
const lower = className.toLowerCase();
|
|
25
|
+
if (/(^|\s|-)checked(\s|$|-)/.test(lower)) return true;
|
|
26
|
+
if (/(^|\s|-)unchecked(\s|$|-)/.test(lower)) return false;
|
|
27
|
+
if (/switch/.test(lower)) return /checked/.test(lower);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function setCompositeToggle({ el }: Loc, value: Value, options?: SetOptions): Promise<boolean> {
|
|
34
|
+
if (typeof value !== 'boolean' && value !== null) return false;
|
|
35
|
+
|
|
36
|
+
const target = await getToggleTarget(el, options);
|
|
37
|
+
if (!target) return false;
|
|
38
|
+
|
|
39
|
+
const initial = await readToggleState(target, options);
|
|
40
|
+
if (initial === null) return false;
|
|
41
|
+
|
|
42
|
+
const desired = Boolean(value);
|
|
43
|
+
if (initial !== desired) {
|
|
44
|
+
await target.click(options);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const next = await readToggleState(target, options);
|
|
48
|
+
return next === desired;
|
|
49
|
+
}
|
package/src/field/date-group.ts
CHANGED
|
@@ -25,35 +25,40 @@ async function getCandidateLocs(el: Locator, options?: SetOptions): Promise<Cand
|
|
|
25
25
|
|
|
26
26
|
return Promise.all(
|
|
27
27
|
candidates.map(async (c) => {
|
|
28
|
-
const info = await c.evaluate(
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
attrs
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
options
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
28
|
+
const info = await c.evaluate(
|
|
29
|
+
/* v8 ignore start — callback runs in browser context, not Node */
|
|
30
|
+
(node) => {
|
|
31
|
+
const e = node as HTMLInputElement | HTMLSelectElement;
|
|
32
|
+
const attrs: Record<string, string> = {};
|
|
33
|
+
for (const attr of e.attributes) {
|
|
34
|
+
attrs[attr.name] = attr.value;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let options: { value: string; text: string }[] = [];
|
|
38
|
+
if (e.tagName.toLowerCase() === 'select') {
|
|
39
|
+
options = Array.from((e as HTMLSelectElement).options).map((o) => ({
|
|
40
|
+
value: o.value,
|
|
41
|
+
text: o.text,
|
|
42
|
+
}));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
tag: e.tagName.toLowerCase(),
|
|
47
|
+
type: e.getAttribute('type'),
|
|
48
|
+
name: e.getAttribute('name'),
|
|
49
|
+
id: e.getAttribute('id'),
|
|
50
|
+
ariaLabel: e.getAttribute('aria-label'),
|
|
51
|
+
placeholder: e.getAttribute('placeholder'),
|
|
52
|
+
min: e.getAttribute('min'),
|
|
53
|
+
max: e.getAttribute('max'),
|
|
54
|
+
inputMode: e.getAttribute('inputmode'),
|
|
55
|
+
attrs,
|
|
56
|
+
options,
|
|
57
|
+
};
|
|
58
|
+
},
|
|
59
|
+
/* v8 ignore stop */
|
|
60
|
+
options,
|
|
61
|
+
);
|
|
57
62
|
return { el: c, ...info };
|
|
58
63
|
}),
|
|
59
64
|
);
|
|
@@ -100,54 +105,79 @@ async function behavioralProbe(candidateLocs: CandidateInfo[], scores: Score[],
|
|
|
100
105
|
for (let i = 0; i < candidateLocs.length; i++) {
|
|
101
106
|
const loc = candidateLocs[i];
|
|
102
107
|
if (loc.tag === 'input') {
|
|
103
|
-
const can_be_day = await loc.el.evaluate(
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
108
|
+
const can_be_day = await loc.el.evaluate(
|
|
109
|
+
/* v8 ignore start — callback runs in browser context, not Node */
|
|
110
|
+
(node) => {
|
|
111
|
+
const e = node as HTMLInputElement;
|
|
112
|
+
const old = e.value;
|
|
113
|
+
e.value = '31';
|
|
114
|
+
const valid = e.checkValidity();
|
|
115
|
+
e.value = old;
|
|
116
|
+
return valid;
|
|
117
|
+
},
|
|
118
|
+
/* v8 ignore stop */
|
|
119
|
+
options,
|
|
120
|
+
);
|
|
111
121
|
if (can_be_day) scores[i].day += 1;
|
|
112
122
|
|
|
113
|
-
const cannot_be_day = await loc.el.evaluate(
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
123
|
+
const cannot_be_day = await loc.el.evaluate(
|
|
124
|
+
/* v8 ignore start — callback runs in browser context, not Node */
|
|
125
|
+
(node) => {
|
|
126
|
+
const e = node as HTMLInputElement;
|
|
127
|
+
const old = e.value;
|
|
128
|
+
e.value = '32';
|
|
129
|
+
const valid = !e.checkValidity();
|
|
130
|
+
e.value = old;
|
|
131
|
+
return valid;
|
|
132
|
+
},
|
|
133
|
+
/* v8 ignore stop */
|
|
134
|
+
options,
|
|
135
|
+
);
|
|
121
136
|
if (cannot_be_day) scores[i].day += 1;
|
|
122
137
|
|
|
123
|
-
const can_be_month = await loc.el.evaluate(
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
138
|
+
const can_be_month = await loc.el.evaluate(
|
|
139
|
+
/* v8 ignore start — callback runs in browser context, not Node */
|
|
140
|
+
(node) => {
|
|
141
|
+
const e = node as HTMLInputElement;
|
|
142
|
+
const old = e.value;
|
|
143
|
+
e.value = '12';
|
|
144
|
+
const valid = e.checkValidity();
|
|
145
|
+
e.value = old;
|
|
146
|
+
return valid;
|
|
147
|
+
},
|
|
148
|
+
/* v8 ignore stop */
|
|
149
|
+
options,
|
|
150
|
+
);
|
|
131
151
|
if (can_be_month) scores[i].month += 1;
|
|
132
152
|
|
|
133
|
-
const cannot_be_month = await loc.el.evaluate(
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
153
|
+
const cannot_be_month = await loc.el.evaluate(
|
|
154
|
+
/* v8 ignore start — callback runs in browser context, not Node */
|
|
155
|
+
(node) => {
|
|
156
|
+
const e = node as HTMLInputElement;
|
|
157
|
+
const old = e.value;
|
|
158
|
+
e.value = '13';
|
|
159
|
+
const valid = !e.checkValidity();
|
|
160
|
+
e.value = old;
|
|
161
|
+
return valid;
|
|
162
|
+
},
|
|
163
|
+
/* v8 ignore stop */
|
|
164
|
+
options,
|
|
165
|
+
);
|
|
141
166
|
if (cannot_be_month) scores[i].month += 1;
|
|
142
167
|
|
|
143
|
-
const can_be_year = await loc.el.evaluate(
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
168
|
+
const can_be_year = await loc.el.evaluate(
|
|
169
|
+
/* v8 ignore start — callback runs in browser context, not Node */
|
|
170
|
+
(node) => {
|
|
171
|
+
const e = node as HTMLInputElement;
|
|
172
|
+
const old = e.value;
|
|
173
|
+
e.value = '2033';
|
|
174
|
+
const valid = e.checkValidity();
|
|
175
|
+
e.value = old;
|
|
176
|
+
return valid;
|
|
177
|
+
},
|
|
178
|
+
/* v8 ignore stop */
|
|
179
|
+
options,
|
|
180
|
+
);
|
|
151
181
|
if (can_be_year) scores[i].year += 1;
|
|
152
182
|
}
|
|
153
183
|
}
|
|
@@ -24,13 +24,13 @@ function buildDateString(date: Date, order: DateOrder, sep: string, pad: boolean
|
|
|
24
24
|
|
|
25
25
|
function parseDateString(value: string, order: DateOrder, sep: string): Date | null {
|
|
26
26
|
const raw = value.trim();
|
|
27
|
-
|
|
27
|
+
if (!raw) return null;
|
|
28
28
|
|
|
29
29
|
const tokens = raw.split(sep);
|
|
30
|
-
|
|
30
|
+
if (tokens.length !== 3) return null;
|
|
31
31
|
|
|
32
32
|
const nums = tokens.map((t) => Number(t));
|
|
33
|
-
|
|
33
|
+
if (nums.some((n) => !Number.isFinite(n))) return null;
|
|
34
34
|
|
|
35
35
|
const map: Record<'day' | 'month' | 'year', number> = { day: 0, month: 0, year: 0 };
|
|
36
36
|
for (let i = 0; i < 3; i++) map[order[i]] = nums[i];
|
|
@@ -39,41 +39,57 @@ function parseDateString(value: string, order: DateOrder, sep: string): Date | n
|
|
|
39
39
|
const month = map.month;
|
|
40
40
|
const day = map.day;
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
if (month < 1 || month > 12) return null;
|
|
43
|
+
if (day < 1 || day > 31) return null;
|
|
44
44
|
|
|
45
45
|
const dt = new Date(year, month - 1, day);
|
|
46
|
-
|
|
46
|
+
if (dt.getFullYear() !== year || dt.getMonth() !== month - 1 || dt.getDate() !== day) return null;
|
|
47
47
|
|
|
48
48
|
return dt;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
function matchesDateLoosely(value: string, date: Date): boolean {
|
|
52
|
+
const digits = (value.match(/\d+/g) ?? []).map((d) => Number.parseInt(d, 10));
|
|
53
|
+
if (digits.length < 3) return false;
|
|
54
|
+
|
|
55
|
+
const year = date.getFullYear();
|
|
56
|
+
const month = date.getMonth() + 1;
|
|
57
|
+
const day = date.getDate();
|
|
58
|
+
|
|
59
|
+
return digits.includes(year) && digits.includes(month) && digits.includes(day);
|
|
60
|
+
}
|
|
61
|
+
|
|
51
62
|
async function inferLocaleAndPattern(el: Locator, options?: SetOptions): Promise<{
|
|
52
63
|
locale: string;
|
|
53
64
|
order: DateOrder;
|
|
54
65
|
sep: string;
|
|
55
66
|
}> {
|
|
56
|
-
return el.evaluate(
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
if (
|
|
67
|
+
return el.evaluate(
|
|
68
|
+
/* v8 ignore start — callback runs in browser context, not Node */
|
|
69
|
+
() => {
|
|
70
|
+
const lang = document.documentElement.getAttribute('lang') || navigator.language || 'en-US';
|
|
71
|
+
const dtf = new Intl.DateTimeFormat(lang, { year: 'numeric', month: '2-digit', day: '2-digit' });
|
|
72
|
+
|
|
73
|
+
const parts = dtf.formatToParts(new Date(2033, 10, 22)); // 22 Nov 2033, disambiguates day vs month
|
|
74
|
+
const order: Array<'day' | 'month' | 'year'> = [];
|
|
75
|
+
let sep = '/';
|
|
76
|
+
|
|
77
|
+
for (const p of parts) {
|
|
78
|
+
if (p.type === 'day' || p.type === 'month' || p.type === 'year') order.push(p.type);
|
|
79
|
+
if (p.type === 'literal') {
|
|
80
|
+
const lit = p.value.trim();
|
|
81
|
+
if (lit) sep = lit;
|
|
82
|
+
}
|
|
69
83
|
}
|
|
70
|
-
}
|
|
71
84
|
|
|
72
|
-
|
|
73
|
-
|
|
85
|
+
// Fallback if Intl returns something odd
|
|
86
|
+
const finalOrder = order.length === 3 ? order : (['day', 'month', 'year'] as const);
|
|
74
87
|
|
|
75
|
-
|
|
76
|
-
|
|
88
|
+
return { locale: lang, order: finalOrder as any, sep };
|
|
89
|
+
},
|
|
90
|
+
/* v8 ignore stop */
|
|
91
|
+
options,
|
|
92
|
+
);
|
|
77
93
|
}
|
|
78
94
|
|
|
79
95
|
async function fillAndReadBack(el: Locator, s: string, options?: SetOptions, nextInput?: Locator): Promise<string> {
|
|
@@ -83,11 +99,11 @@ async function fillAndReadBack(el: Locator, s: string, options?: SetOptions, nex
|
|
|
83
99
|
if (nextInput) {
|
|
84
100
|
await nextInput.focus(options);
|
|
85
101
|
} else {
|
|
86
|
-
await el.evaluate(
|
|
102
|
+
await el.evaluate((el) => el.blur(), options);
|
|
87
103
|
}
|
|
88
104
|
|
|
89
105
|
// Some frameworks need a tiny tick.
|
|
90
|
-
await el.evaluate(
|
|
106
|
+
await el.evaluate(() => new Promise(requestAnimationFrame));
|
|
91
107
|
|
|
92
108
|
return await el.inputValue(options);
|
|
93
109
|
}
|
|
@@ -122,8 +138,12 @@ async function setDateValue(
|
|
|
122
138
|
backParts.length !== dates.length ||
|
|
123
139
|
dates.some((date, i) => {
|
|
124
140
|
const part = backParts[i]?.trim();
|
|
141
|
+
if (!part) return true;
|
|
142
|
+
|
|
125
143
|
const parsed = parseDateString(part, order, sep);
|
|
126
|
-
|
|
144
|
+
if (parsed && sameYMD(parsed, date)) return false;
|
|
145
|
+
|
|
146
|
+
return !matchesDateLoosely(part, date);
|
|
127
147
|
});
|
|
128
148
|
|
|
129
149
|
return !failed;
|
|
@@ -199,10 +219,8 @@ async function setInputValue(
|
|
|
199
219
|
value2?: Date,
|
|
200
220
|
options?: SetOptions,
|
|
201
221
|
): Promise<boolean> {
|
|
202
|
-
/* v8 ignore start */
|
|
203
222
|
if (await el.evaluate((el) => (el as HTMLInputElement).readOnly, options)) return false;
|
|
204
223
|
if (el2 && (await el2.evaluate((el) => (el as HTMLInputElement).readOnly, options))) return false;
|
|
205
|
-
/* v8 ignore stop */
|
|
206
224
|
|
|
207
225
|
const { combinations, localeSep } = await formatCombinations(el, options);
|
|
208
226
|
let fallbackMatch: [DateOrder, string, boolean] | null = null;
|
|
@@ -236,12 +254,12 @@ async function setInputValue(
|
|
|
236
254
|
if (fallbackMatch) {
|
|
237
255
|
const [order, sep, pad] = fallbackMatch;
|
|
238
256
|
const success = await setDateValue(el, value, order, sep, pad, options, el2);
|
|
239
|
-
|
|
257
|
+
|
|
240
258
|
if (el2 && value2) {
|
|
241
259
|
const success2 = await setDateValue(el2, value2, order, sep, pad, options);
|
|
242
260
|
return success && success2;
|
|
243
261
|
}
|
|
244
|
-
|
|
262
|
+
|
|
245
263
|
return success;
|
|
246
264
|
}
|
|
247
265
|
|
package/src/field/index.ts
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import { chain, isArray, isRange } from '@letsrunit/utils';
|
|
2
2
|
import type { Locator } from '@playwright/test';
|
|
3
3
|
import { pickFieldElement } from '../utils/pick-field-element';
|
|
4
|
+
import { selectAria } from './aria-select';
|
|
4
5
|
import { setCalendarDate } from './calendar';
|
|
6
|
+
import { setCompositeDate } from './composite-date';
|
|
7
|
+
import { setCompositeSelect } from './composite-select';
|
|
8
|
+
import { setCompositeSlider } from './composite-slider';
|
|
9
|
+
import { setCompositeToggle } from './composite-toggle';
|
|
5
10
|
import { setDateGroup } from './date-group';
|
|
6
11
|
import { setDateTextInput } from './date-text-input';
|
|
7
12
|
import { setNativeCheckbox } from './native-checkbox';
|
|
8
13
|
import { setNativeDate } from './native-date';
|
|
9
14
|
import { setNativeInput } from './native-input';
|
|
10
|
-
import { selectAria } from './aria-select';
|
|
11
15
|
import { selectNative } from './native-select';
|
|
12
16
|
import { setOtpValue } from './otp';
|
|
13
17
|
import { setRadioGroup } from './radio-group';
|
|
@@ -42,22 +46,27 @@ export async function setFieldValue(el: Locator, value: Value, options?: SetOpti
|
|
|
42
46
|
setCalendarDate,
|
|
43
47
|
setOtpValue,
|
|
44
48
|
setSliderValue,
|
|
49
|
+
// generic non-semantic composite controls
|
|
50
|
+
setCompositeToggle,
|
|
51
|
+
setCompositeSelect,
|
|
52
|
+
setCompositeSlider,
|
|
53
|
+
setCompositeDate,
|
|
45
54
|
// fallback (eg contenteditable or will fail)
|
|
46
55
|
setFallback,
|
|
47
56
|
);
|
|
48
57
|
|
|
49
|
-
/* v8 ignore start */
|
|
50
58
|
if ((await el.count()) > 1) {
|
|
51
59
|
el = await pickFieldElement(el);
|
|
52
60
|
}
|
|
53
|
-
/* v8 ignore stop */
|
|
54
61
|
|
|
55
62
|
const tag = await el.evaluate((e) => e.tagName.toLowerCase(), options);
|
|
56
|
-
const type =
|
|
57
|
-
.getAttribute('type', options)
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
63
|
+
const type = (
|
|
64
|
+
await el.getAttribute('type', options).catch(
|
|
65
|
+
/* v8 ignore next — attribute might be missing or element might have detached during the check */
|
|
66
|
+
() => null,
|
|
67
|
+
)
|
|
68
|
+
)?.toLowerCase();
|
|
69
|
+
const loc = { el, tag, type: type || null };
|
|
61
70
|
|
|
62
71
|
await setValue(loc, value, options);
|
|
63
72
|
}
|
package/src/field/native-date.ts
CHANGED
|
@@ -11,10 +11,13 @@ async function setSingleDate({ el, tag, type }: Loc, value: Date, options?: SetO
|
|
|
11
11
|
'input[type=date], input[type=datetime-local], input[type=month], input[type=week], input[type=time]',
|
|
12
12
|
);
|
|
13
13
|
if ((await inputs.count()) === 1) {
|
|
14
|
-
const isVisible = await inputs.evaluate(
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
const isVisible = await inputs.evaluate(
|
|
15
|
+
/* v8 ignore next — callback runs in browser context, not Node */
|
|
16
|
+
(e) => {
|
|
17
|
+
const style = window.getComputedStyle(e);
|
|
18
|
+
return style.display !== 'none' && style.visibility !== 'hidden' && e.getAttribute('type') !== 'hidden';
|
|
19
|
+
},
|
|
20
|
+
);
|
|
18
21
|
if (isVisible) {
|
|
19
22
|
target = inputs;
|
|
20
23
|
targetType = (await target.getAttribute('type', options)) || null;
|
package/src/field/radio-group.ts
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
import type { Loc, SetOptions, Value } from './types';
|
|
2
2
|
|
|
3
|
+
function caseInsensitiveExact(value: string): RegExp {
|
|
4
|
+
const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
5
|
+
return new RegExp(`^${escaped}$`, 'i');
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function caseInsensitiveLooseExact(value: string): RegExp {
|
|
9
|
+
const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
10
|
+
return new RegExp(`^\\s*${escaped}\\s*$`, 'i');
|
|
11
|
+
}
|
|
12
|
+
|
|
3
13
|
export async function setRadioGroup(
|
|
4
14
|
{ el }: Loc,
|
|
5
15
|
value: Value,
|
|
@@ -24,21 +34,73 @@ export async function setRadioGroup(
|
|
|
24
34
|
return true;
|
|
25
35
|
}
|
|
26
36
|
|
|
27
|
-
//
|
|
28
|
-
const
|
|
29
|
-
if ((await
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
if (ariaChecked !== 'true') await item.click(options);
|
|
37
|
+
// 2b. Native wrappers: label text contains value
|
|
38
|
+
const wrappedRadio = el.locator('label').filter({ hasText: caseInsensitiveLooseExact(stringValue) }).locator('input[type=radio]');
|
|
39
|
+
if ((await wrappedRadio.count()) >= 1) {
|
|
40
|
+
const wrappedLabel = el.locator('label').filter({ hasText: caseInsensitiveLooseExact(stringValue) }).first();
|
|
41
|
+
await wrappedLabel.click({ ...options, force: true });
|
|
33
42
|
return true;
|
|
34
43
|
}
|
|
35
44
|
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
45
|
+
const ariaRadios = el.getByRole('radio');
|
|
46
|
+
const hasAriaRadios = (await ariaRadios.count()) > 0;
|
|
47
|
+
|
|
48
|
+
// 3. ARIA: by accessible name
|
|
49
|
+
if (hasAriaRadios) {
|
|
50
|
+
const ariaRadioByName = el.getByRole('radio', { name: caseInsensitiveExact(stringValue) });
|
|
51
|
+
if ((await ariaRadioByName.count()) >= 1) {
|
|
52
|
+
const item = ariaRadioByName.first();
|
|
53
|
+
const ariaChecked = await item.getAttribute('aria-checked', options).catch(
|
|
54
|
+
/* v8 ignore next — attribute might be missing or element might have detached during the check */
|
|
55
|
+
() => null,
|
|
56
|
+
);
|
|
57
|
+
if (ariaChecked !== 'true') await item.click(options);
|
|
58
|
+
|
|
59
|
+
const nextAriaChecked = await item.getAttribute('aria-checked', options).catch(
|
|
60
|
+
/* v8 ignore next — attribute might be missing or element might have detached during the check */
|
|
61
|
+
() => null,
|
|
62
|
+
);
|
|
63
|
+
return nextAriaChecked === 'true';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 4. ARIA: by value-like attributes
|
|
67
|
+
const ariaRadioByValue = el.locator(
|
|
68
|
+
`[role="radio"][value="${stringValue}"], ` +
|
|
69
|
+
`[role="radio"][data-value="${stringValue}"], ` +
|
|
70
|
+
`[role="radio"][aria-label="${stringValue}"]`,
|
|
71
|
+
);
|
|
72
|
+
if ((await ariaRadioByValue.count()) >= 1) {
|
|
73
|
+
const item = ariaRadioByValue.first();
|
|
74
|
+
const ariaChecked = await item.getAttribute('aria-checked', options).catch(
|
|
75
|
+
/* v8 ignore next — attribute might be missing or element might have detached during the check */
|
|
76
|
+
() => null,
|
|
77
|
+
);
|
|
78
|
+
if (ariaChecked !== 'true') await item.click(options);
|
|
79
|
+
|
|
80
|
+
const nextAriaChecked = await item.getAttribute('aria-checked', options).catch(
|
|
81
|
+
/* v8 ignore next — attribute might be missing or element might have detached during the check */
|
|
82
|
+
() => null,
|
|
83
|
+
);
|
|
84
|
+
return nextAriaChecked === 'true';
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 5. Native: click role radio by accessible name as last resort
|
|
89
|
+
const roleRadio = el.getByRole('radio', { name: caseInsensitiveLooseExact(stringValue) });
|
|
90
|
+
if ((await roleRadio.count()) >= 1) {
|
|
91
|
+
await roleRadio.first().click(options);
|
|
92
|
+
return await roleRadio.first().isChecked(options).catch(() => false);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 6. Fallback: iterate label text manually
|
|
96
|
+
const labels = el.locator('label');
|
|
97
|
+
const labelCount = await labels.count();
|
|
98
|
+
for (let i = 0; i < labelCount; i++) {
|
|
99
|
+
const label = labels.nth(i);
|
|
100
|
+
const text = (await label.textContent(options).catch(() => ''))?.replace(/\s+/g, ' ').trim().toLowerCase();
|
|
101
|
+
if (text !== stringValue.trim().toLowerCase()) continue;
|
|
102
|
+
|
|
103
|
+
await label.click({ ...options, force: true });
|
|
42
104
|
return true;
|
|
43
105
|
}
|
|
44
106
|
|