@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.
@@ -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
+ }
@@ -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(/* v8 ignore start */ (node) => {
29
- const e = node as HTMLInputElement | HTMLSelectElement;
30
- const attrs: Record<string, string> = {};
31
- for (const attr of e.attributes) {
32
- attrs[attr.name] = attr.value;
33
- }
34
-
35
- let options: { value: string; text: string }[] = [];
36
- if (e.tagName.toLowerCase() === 'select') {
37
- options = Array.from((e as HTMLSelectElement).options).map((o) => ({
38
- value: o.value,
39
- text: o.text,
40
- }));
41
- }
42
-
43
- return {
44
- tag: e.tagName.toLowerCase(),
45
- type: e.getAttribute('type'),
46
- name: e.getAttribute('name'),
47
- id: e.getAttribute('id'),
48
- ariaLabel: e.getAttribute('aria-label'),
49
- placeholder: e.getAttribute('placeholder'),
50
- min: e.getAttribute('min'),
51
- max: e.getAttribute('max'),
52
- inputMode: e.getAttribute('inputmode'),
53
- attrs,
54
- options,
55
- }; /* v8 ignore stop */
56
- }, options);
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(/* v8 ignore start */ (node) => {
104
- const e = node as HTMLInputElement;
105
- const old = e.value;
106
- e.value = '31';
107
- const valid = e.checkValidity();
108
- e.value = old;
109
- return valid; /* v8 ignore stop */
110
- }, options);
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(/* v8 ignore start */ (node) => {
114
- const e = node as HTMLInputElement;
115
- const old = e.value;
116
- e.value = '32';
117
- const valid = !e.checkValidity();
118
- e.value = old;
119
- return valid; /* v8 ignore stop */
120
- }, options);
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(/* v8 ignore start */ (node) => {
124
- const e = node as HTMLInputElement;
125
- const old = e.value;
126
- e.value = '12';
127
- const valid = e.checkValidity();
128
- e.value = old;
129
- return valid; /* v8 ignore stop */
130
- }, options);
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(/* v8 ignore start */ (node) => {
134
- const e = node as HTMLInputElement;
135
- const old = e.value;
136
- e.value = '13';
137
- const valid = !e.checkValidity();
138
- e.value = old;
139
- return valid; /* v8 ignore stop */
140
- }, options);
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(/* v8 ignore start */ (node) => {
144
- const e = node as HTMLInputElement;
145
- const old = e.value;
146
- e.value = '2024';
147
- const valid = e.checkValidity();
148
- e.value = old;
149
- return valid; /* v8 ignore stop */
150
- }, options);
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
- /* v8 ignore next */ if (!raw) return null;
27
+ if (!raw) return null;
28
28
 
29
29
  const tokens = raw.split(sep);
30
- /* v8 ignore next */ if (tokens.length !== 3) return null;
30
+ if (tokens.length !== 3) return null;
31
31
 
32
32
  const nums = tokens.map((t) => Number(t));
33
- /* v8 ignore next */ if (nums.some((n) => !Number.isFinite(n))) return null;
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
- /* v8 ignore next */ if (month < 1 || month > 12) return null;
43
- /* v8 ignore next */ if (day < 1 || day > 31) return null;
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
- /* v8 ignore next */ if (dt.getFullYear() !== year || dt.getMonth() !== month - 1 || dt.getDate() !== day) return null;
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(/* v8 ignore start */ () => {
57
- const lang = document.documentElement.getAttribute('lang') || navigator.language || 'en-US';
58
- const dtf = new Intl.DateTimeFormat(lang, { year: 'numeric', month: '2-digit', day: '2-digit' });
59
-
60
- const parts = dtf.formatToParts(new Date(2033, 10, 22)); // 22 Nov 2033, disambiguates day vs month
61
- const order: Array<'day' | 'month' | 'year'> = [];
62
- let sep = '/';
63
-
64
- for (const p of parts) {
65
- if (p.type === 'day' || p.type === 'month' || p.type === 'year') order.push(p.type);
66
- if (p.type === 'literal') {
67
- const lit = p.value.trim();
68
- if (lit) sep = lit;
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
- // Fallback if Intl returns something odd
73
- const finalOrder = order.length === 3 ? order : (['day', 'month', 'year'] as const);
85
+ // Fallback if Intl returns something odd
86
+ const finalOrder = order.length === 3 ? order : (['day', 'month', 'year'] as const);
74
87
 
75
- return { locale: lang, order: finalOrder as any, sep }; /* v8 ignore stop */
76
- }, options);
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(/* v8 ignore next */ (el) => el.blur(), options);
102
+ await el.evaluate((el) => el.blur(), options);
87
103
  }
88
104
 
89
105
  // Some frameworks need a tiny tick.
90
- await el.evaluate(/* v8 ignore next */ () => new Promise(requestAnimationFrame));
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
- return !parsed || !sameYMD(parsed, date);
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
- /* v8 ignore start */
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
- /* v8 ignore stop */
262
+
245
263
  return success;
246
264
  }
247
265
 
@@ -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 = await el
57
- .getAttribute('type', options)
58
- .then((s) => s && s.toLowerCase())
59
- .catch(/* v8 ignore next */ () => null);
60
- const loc = { el, tag, type };
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
  }
@@ -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(/* v8 ignore next */ (e) => {
15
- const style = window.getComputedStyle(e);
16
- return style.display !== 'none' && style.visibility !== 'hidden' && e.getAttribute('type') !== 'hidden';
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;
@@ -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
- // 3. ARIA: exact value attribute
28
- const ariaRadio = el.locator(`[role="radio"][value="${stringValue}"]`);
29
- if ((await ariaRadio.count()) >= 1) {
30
- const item = ariaRadio.first();
31
- const ariaChecked = await item.getAttribute('aria-checked', options).catch(/* v8 ignore next */ () => null);
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
- // 4. ARIA: by label text
37
- const ariaRadioByLabel = el.getByLabel(stringValue, { exact: true }).locator('[role="radio"]');
38
- if ((await ariaRadioByLabel.count()) >= 1) {
39
- const item = ariaRadioByLabel.first();
40
- const ariaChecked = await item.getAttribute('aria-checked', options).catch(/* v8 ignore next */ () => null);
41
- if (ariaChecked !== 'true') await item.click(options);
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