@letsrunit/playwright 0.1.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.
Files changed (87) hide show
  1. package/README.md +44 -0
  2. package/dist/index.d.ts +106 -0
  3. package/dist/index.js +3006 -0
  4. package/dist/index.js.map +1 -0
  5. package/package.json +67 -0
  6. package/src/browser.ts +20 -0
  7. package/src/field/calendar.ts +300 -0
  8. package/src/field/date-group.ts +253 -0
  9. package/src/field/date-text-input.ts +270 -0
  10. package/src/field/index.ts +57 -0
  11. package/src/field/native-checkbox.ts +21 -0
  12. package/src/field/native-date.ts +62 -0
  13. package/src/field/native-input.ts +17 -0
  14. package/src/field/native-select.ts +75 -0
  15. package/src/field/otp.ts +22 -0
  16. package/src/field/radio-group.ts +27 -0
  17. package/src/field/slider.ts +132 -0
  18. package/src/field/types.ts +16 -0
  19. package/src/format-html.ts +17 -0
  20. package/src/index.ts +12 -0
  21. package/src/locator.ts +102 -0
  22. package/src/page-info.ts +33 -0
  23. package/src/screenshot.ts +84 -0
  24. package/src/scroll.ts +10 -0
  25. package/src/scrub-html.ts +333 -0
  26. package/src/selector/date-selector.ts +272 -0
  27. package/src/selector/field-selector.ts +121 -0
  28. package/src/selector/index.ts +2 -0
  29. package/src/snapshot.ts +55 -0
  30. package/src/suppress-interferences.ts +288 -0
  31. package/src/translations/af.ts +41 -0
  32. package/src/translations/ar.ts +7 -0
  33. package/src/translations/az.ts +40 -0
  34. package/src/translations/bg.ts +7 -0
  35. package/src/translations/bn.ts +40 -0
  36. package/src/translations/bs.ts +7 -0
  37. package/src/translations/ca.ts +41 -0
  38. package/src/translations/cs.ts +7 -0
  39. package/src/translations/da.ts +44 -0
  40. package/src/translations/de.ts +47 -0
  41. package/src/translations/el.ts +40 -0
  42. package/src/translations/en.ts +7 -0
  43. package/src/translations/es.ts +7 -0
  44. package/src/translations/et.ts +7 -0
  45. package/src/translations/eu.ts +7 -0
  46. package/src/translations/fa.ts +7 -0
  47. package/src/translations/fi.ts +39 -0
  48. package/src/translations/fr.ts +42 -0
  49. package/src/translations/ga.ts +40 -0
  50. package/src/translations/he.ts +45 -0
  51. package/src/translations/hi.ts +39 -0
  52. package/src/translations/hr.ts +7 -0
  53. package/src/translations/hu.ts +7 -0
  54. package/src/translations/hy.ts +7 -0
  55. package/src/translations/id.ts +7 -0
  56. package/src/translations/index.ts +68 -0
  57. package/src/translations/is.ts +7 -0
  58. package/src/translations/it.ts +7 -0
  59. package/src/translations/ja.ts +7 -0
  60. package/src/translations/ka.ts +36 -0
  61. package/src/translations/ko.ts +7 -0
  62. package/src/translations/lt.ts +7 -0
  63. package/src/translations/lv.ts +43 -0
  64. package/src/translations/nl.ts +43 -0
  65. package/src/translations/no.ts +46 -0
  66. package/src/translations/pl.ts +39 -0
  67. package/src/translations/pt.ts +41 -0
  68. package/src/translations/ro.ts +40 -0
  69. package/src/translations/ru.ts +7 -0
  70. package/src/translations/sk.ts +7 -0
  71. package/src/translations/sl.ts +7 -0
  72. package/src/translations/sv.ts +44 -0
  73. package/src/translations/sw.ts +7 -0
  74. package/src/translations/ta.ts +7 -0
  75. package/src/translations/th.ts +39 -0
  76. package/src/translations/tl.ts +7 -0
  77. package/src/translations/tr.ts +41 -0
  78. package/src/translations/uk.ts +7 -0
  79. package/src/translations/ur.ts +43 -0
  80. package/src/translations/vi.ts +7 -0
  81. package/src/translations/zh.ts +7 -0
  82. package/src/types.ts +37 -0
  83. package/src/unified-html-diff.ts +22 -0
  84. package/src/utils/date.ts +40 -0
  85. package/src/utils/pick-field-element.ts +48 -0
  86. package/src/utils/type-check.ts +7 -0
  87. package/src/wait.ts +170 -0
@@ -0,0 +1,270 @@
1
+ import { cartesian, isArray, isDate, isRange, type Range } from '@letsrunit/utils';
2
+ import type { Locator } from '@playwright/test';
3
+ import type { Loc, SetOptions, Value } from './types';
4
+
5
+ type DateOrder = Array<'day' | 'month' | 'year'>;
6
+
7
+ function pad2(n: number): string {
8
+ return String(n).padStart(2, '0');
9
+ }
10
+
11
+ function buildDateString(date: Date, order: DateOrder, sep: string, pad: boolean): string {
12
+ const d = date.getDate();
13
+ const m = date.getMonth() + 1;
14
+ const y = date.getFullYear();
15
+
16
+ const parts: Record<'day' | 'month' | 'year', string> = {
17
+ day: pad ? pad2(d) : String(d),
18
+ month: pad ? pad2(m) : String(m),
19
+ year: String(y),
20
+ };
21
+
22
+ return order.map((p) => parts[p]).join(sep);
23
+ }
24
+
25
+ function parseDateString(value: string, order: DateOrder, sep: string): Date | null {
26
+ const raw = value.trim();
27
+ if (!raw) return null;
28
+
29
+ const tokens = raw.split(sep);
30
+ if (tokens.length !== 3) return null;
31
+
32
+ const nums = tokens.map((t) => Number(t));
33
+ if (nums.some((n) => !Number.isFinite(n))) return null;
34
+
35
+ const map: Record<'day' | 'month' | 'year', number> = { day: 0, month: 0, year: 0 };
36
+ for (let i = 0; i < 3; i++) map[order[i]] = nums[i];
37
+
38
+ const year = map.year;
39
+ const month = map.month;
40
+ const day = map.day;
41
+
42
+ if (month < 1 || month > 12) return null;
43
+ if (day < 1 || day > 31) return null;
44
+
45
+ const dt = new Date(year, month - 1, day);
46
+ if (dt.getFullYear() !== year || dt.getMonth() !== month - 1 || dt.getDate() !== day) return null;
47
+
48
+ return dt;
49
+ }
50
+
51
+ async function inferLocaleAndPattern(el: Locator, options?: SetOptions): Promise<{
52
+ locale: string;
53
+ order: DateOrder;
54
+ sep: string;
55
+ }> {
56
+ return el.evaluate(() => {
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;
69
+ }
70
+ }
71
+
72
+ // Fallback if Intl returns something odd
73
+ const finalOrder = order.length === 3 ? order : (['day', 'month', 'year'] as const);
74
+
75
+ return { locale: lang, order: finalOrder as any, sep };
76
+ }, options);
77
+ }
78
+
79
+ async function fillAndReadBack(el: Locator, s: string, options?: SetOptions, nextInput?: Locator): Promise<string> {
80
+ await el.clear(options);
81
+ await el.fill(s, options);
82
+
83
+ if (nextInput) {
84
+ await nextInput.focus(options);
85
+ } else {
86
+ await el.evaluate((el) => el.blur(), options);
87
+ }
88
+
89
+ // Some frameworks need a tiny tick.
90
+ await el.evaluate(() => new Promise(requestAnimationFrame));
91
+
92
+ return await el.inputValue(options);
93
+ }
94
+
95
+ function isAmbiguous(value: Date | Date[] | Range<Date>, value2?: Date): boolean {
96
+ if (value2 && value instanceof Date) value = [value, value2];
97
+ return toDateArray(value).every((date) => date.getDate() <= 12);
98
+ }
99
+
100
+ function toDateArray(value: Date | Date[] | Range<Date>): Date[] {
101
+ return isRange(value) ? [value.from, value.to] : isArray(value) ? value : [value];
102
+ }
103
+
104
+ async function setDateValue(
105
+ el: Locator,
106
+ value: Date | Date[] | Range<Date>,
107
+ order: DateOrder,
108
+ sep: string,
109
+ pad: boolean,
110
+ options?: SetOptions,
111
+ nextInput?: Locator,
112
+ ): Promise<boolean> {
113
+ const dates = toDateArray(value);
114
+ const glue = isRange(value) ? ' - ' : ',';
115
+
116
+ const s = dates.map((d) => buildDateString(d, order, sep, pad)).join(glue);
117
+ const back = await fillAndReadBack(el, s, options, nextInput);
118
+ if (!back) return false;
119
+
120
+ const backParts = back.split(glue);
121
+ const failed =
122
+ backParts.length !== dates.length ||
123
+ dates.some((date, i) => {
124
+ const part = backParts[i]?.trim();
125
+ const parsed = parseDateString(part, order, sep);
126
+ return !parsed || !sameYMD(parsed, date);
127
+ });
128
+
129
+ return !failed;
130
+ }
131
+
132
+ async function tryProbe(
133
+ el: Locator,
134
+ value: Date | Date[] | Range<Date>,
135
+ order: DateOrder,
136
+ sep: string,
137
+ pad: boolean,
138
+ options?: SetOptions,
139
+ nextInput?: Locator,
140
+ ): Promise<boolean | null> {
141
+ const baseDate = isRange(value) ? value.from : isArray(value) ? value[0] : value;
142
+ const y = baseDate.getFullYear();
143
+ const m = baseDate.getMonth();
144
+ const probeMonths = [m, m - 1, m + 1];
145
+
146
+ for (const month of probeMonths) {
147
+ const probeValue = isRange(value)
148
+ ? { from: new Date(y, month, 22), to: new Date(y, month, 23) }
149
+ : new Date(y, month, 22);
150
+
151
+ const success = await setDateValue(el, probeValue, order, sep, pad, options, nextInput);
152
+ if (success) return true;
153
+ }
154
+
155
+ return null;
156
+ }
157
+
158
+ function sameYMD(a: Date, b: Date): boolean {
159
+ return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
160
+ }
161
+
162
+ async function formatCombinations(el: Locator, options?: SetOptions) {
163
+ const { order: localeOrder, sep: localeSep } = await inferLocaleAndPattern(el, options);
164
+
165
+ const orders: DateOrder[] = [
166
+ localeOrder,
167
+ ['year', 'month', 'day'], // ISO order
168
+ ['day', 'month', 'year'],
169
+ ['month', 'day', 'year'],
170
+ ];
171
+
172
+ const seenOrders = new Set<string>();
173
+ const uniqueOrders: DateOrder[] = [];
174
+ for (const o of orders) {
175
+ const key = o.join(',');
176
+ if (!seenOrders.has(key)) {
177
+ seenOrders.add(key);
178
+ uniqueOrders.push(o);
179
+ }
180
+ }
181
+
182
+ const seps = Array.from(new Set([localeSep, '-', '/', '.']));
183
+ const pads = [true, false];
184
+
185
+ const combinations = cartesian(uniqueOrders, seps, pads);
186
+
187
+ // Re-order combinations to ensure that locale and iso is second
188
+ const score = ([o, s]: (typeof combinations)[number]) =>
189
+ o.join(s) === localeOrder.join(localeSep) ? 0 : o.join(s) === 'year-month-day'? 1 : 2;
190
+ combinations.sort((a, b) => score(a) - score(b));
191
+
192
+ return { combinations, localeSep };
193
+ }
194
+
195
+ async function setInputValue(
196
+ el: Locator,
197
+ value: Date | Date[] | Range<Date>,
198
+ el2?: Locator,
199
+ value2?: Date,
200
+ options?: SetOptions,
201
+ ): Promise<boolean> {
202
+ if (await el.evaluate((el) => (el as HTMLInputElement).readOnly, options)) return false;
203
+ if (el2 && (await el2.evaluate((el) => (el as HTMLInputElement).readOnly, options))) return false;
204
+
205
+ const { combinations, localeSep } = await formatCombinations(el, options);
206
+ let fallbackMatch: [DateOrder, string, boolean] | null = null;
207
+
208
+ for (const [order, sep, pad] of combinations) {
209
+ // Optimization: for non-locale separators, only try padded
210
+ if (sep !== localeSep && !pad) continue;
211
+
212
+ const success = await setDateValue(el, value, order, sep, pad, options, el2);
213
+ if (!success) continue;
214
+
215
+ if (el2 && value2) {
216
+ const success2 = await setDateValue(el2, value2, order, sep, pad, options);
217
+ if (!success2) continue;
218
+ }
219
+
220
+ if (!isAmbiguous(value, value2)) return true; // Done
221
+
222
+ const probeResult = await tryProbe(el, value, order, sep, pad, options, el2);
223
+
224
+ if (probeResult === true) {
225
+ await setDateValue(el, value, order, sep, pad, options, el2);
226
+ if (el2 && value2) await setDateValue(el2, value2, order, sep, pad, options);
227
+ return true; // Done
228
+ }
229
+
230
+ // Maybe out of range? Keep as fallback
231
+ if (probeResult === null && !fallbackMatch) fallbackMatch = [order, sep, pad];
232
+ }
233
+
234
+ if (fallbackMatch) {
235
+ const [order, sep, pad] = fallbackMatch;
236
+ const success = await setDateValue(el, value, order, sep, pad, options, el2);
237
+ if (el2 && value2) {
238
+ const success2 = await setDateValue(el2, value2, order, sep, pad, options);
239
+ return success && success2;
240
+ }
241
+ return success;
242
+ }
243
+
244
+ return false;
245
+ }
246
+
247
+ export async function setDateTextInput({ el, tag, type }: Loc, value: Value, options?: SetOptions): Promise<boolean> {
248
+ if (!(value instanceof Date) && !isArray(value, isDate) && !isRange(value, isDate)) return false;
249
+
250
+ if (tag === 'textarea' || tag === 'select') return false;
251
+
252
+ if (tag !== 'input' && isRange(value, isDate)) {
253
+ const inputs = el.locator('input[type=text], input:not([type])');
254
+ if ((await inputs.count()) === 2) {
255
+ return await setInputValue(inputs.nth(0), value.from, inputs.nth(1), value.to, options);
256
+ }
257
+ }
258
+
259
+ if (tag === 'input' && type && type !== 'text') return false;
260
+
261
+ let input = el;
262
+
263
+ if (tag !== 'input') {
264
+ const inputs = el.locator('input[type=text], input:not([type])');
265
+ if ((await inputs.count()) !== 1) return false;
266
+ input = inputs.nth(0);
267
+ }
268
+
269
+ return await setInputValue(input, value, undefined, undefined, options);
270
+ }
@@ -0,0 +1,57 @@
1
+ import { chain, isArray, isRange } from '@letsrunit/utils';
2
+ import type { Locator } from '@playwright/test';
3
+ import { pickFieldElement } from '../utils/pick-field-element';
4
+ import { setCalendarDate } from './calendar';
5
+ import { setDateGroup } from './date-group';
6
+ import { setDateTextInput } from './date-text-input';
7
+ import { setNativeCheckbox } from './native-checkbox';
8
+ import { setNativeDate } from './native-date';
9
+ import { setNativeInput } from './native-input';
10
+ import { selectNative } from './native-select';
11
+ import { setOtpValue } from './otp';
12
+ import { setRadioGroup } from './radio-group';
13
+ import { setSliderValue } from './slider';
14
+ import type { Loc, SetOptions, Value } from './types';
15
+
16
+ function toString(value: Value): string {
17
+ if (isRange(value)) return `${String(value.from)} - ${String(value.to)}`;
18
+ if (isArray(value)) return value.map((v) => String(v)).join('\n');
19
+ return String(value);
20
+ }
21
+
22
+ async function setFallback({ el }: Loc, value: Value, options?: SetOptions): Promise<boolean> {
23
+ await el.fill(toString(value), options); // Will likely fail, but will have a good error.
24
+ return true;
25
+ }
26
+
27
+ export async function setFieldValue(el: Locator, value: Value, options?: SetOptions): Promise<void> {
28
+ const setValue = chain(
29
+ // native
30
+ selectNative,
31
+ setNativeCheckbox,
32
+ setRadioGroup,
33
+ setNativeDate,
34
+ setNativeInput,
35
+ // aria / components
36
+ setDateTextInput,
37
+ setDateGroup,
38
+ setCalendarDate,
39
+ setOtpValue,
40
+ setSliderValue,
41
+ // fallback (eg contenteditable or will fail)
42
+ setFallback,
43
+ );
44
+
45
+ if ((await el.count()) > 1) {
46
+ el = await pickFieldElement(el);
47
+ }
48
+
49
+ const tag = await el.evaluate((e) => e.tagName.toLowerCase(), options);
50
+ const type = await el
51
+ .getAttribute('type', options)
52
+ .then((s) => s && s.toLowerCase())
53
+ .catch(() => null);
54
+ const loc = { el, tag, type };
55
+
56
+ await setValue(loc, value, options);
57
+ }
@@ -0,0 +1,21 @@
1
+ import type { Loc, SetOptions, Value } from './types';
2
+
3
+ export async function setNativeCheckbox({ el, tag, type }: Loc, value: Value, options?: SetOptions): Promise<boolean> {
4
+ if (typeof value !== 'boolean' && value !== null) return false;
5
+ if (tag === 'select' || tag === 'textarea' || tag === 'button') return false;
6
+
7
+ let target = el;
8
+
9
+ if (tag !== 'input' || (type !== 'checkbox' && type !== 'radio')) {
10
+ target = el.locator('input[type=checkbox], input[type=radio]');
11
+ if ((await target.count()) !== 1) return false;
12
+ }
13
+
14
+ if (value) {
15
+ await target.check(options);
16
+ } else {
17
+ await target.uncheck(options);
18
+ }
19
+
20
+ return true;
21
+ }
@@ -0,0 +1,62 @@
1
+ import { isDate, isRange, type Range } from '@letsrunit/utils';
2
+ import { formatDateForInput } from '../utils/date';
3
+ import type { Loc, SetOptions, Value } from './types';
4
+
5
+ async function setSingleDate({ el, tag, type }: Loc, value: Date, options?: SetOptions): Promise<boolean> {
6
+ let target = el;
7
+ let targetType = type;
8
+
9
+ if (tag !== 'input' || !type || !['date', 'datetime-local', 'month', 'week', 'time'].includes(type)) {
10
+ const inputs = el.locator(
11
+ 'input[type=date], input[type=datetime-local], input[type=month], input[type=week], input[type=time]',
12
+ );
13
+ if ((await inputs.count()) === 1) {
14
+ const isVisible = await inputs.evaluate((e) => {
15
+ const style = window.getComputedStyle(e);
16
+ return style.display !== 'none' && style.visibility !== 'hidden' && e.getAttribute('type') !== 'hidden';
17
+ });
18
+ if (isVisible) {
19
+ target = inputs;
20
+ targetType = (await target.getAttribute('type', options)) || null;
21
+ } else {
22
+ return false;
23
+ }
24
+ } else {
25
+ return false;
26
+ }
27
+ }
28
+
29
+ if (!targetType) return false;
30
+
31
+ const val = formatDateForInput(value, targetType);
32
+ await target.fill(val, options);
33
+
34
+ return true;
35
+ }
36
+
37
+ async function setDateRange({ el, tag }: Loc, value: Range<Date>, options?: SetOptions): Promise<boolean> {
38
+ if (tag === 'input' || tag === 'select' || tag === 'textarea' || tag === 'button') return false;
39
+
40
+ const inputs = el.locator('input[type=date]');
41
+ if ((await inputs.count()) !== 2) return false;
42
+
43
+ const from = formatDateForInput(value.from, 'date');
44
+ const to = formatDateForInput(value.to, 'date');
45
+
46
+ await inputs.nth(0).fill(from, options);
47
+ await inputs.nth(1).fill(to, options);
48
+
49
+ return true;
50
+ }
51
+
52
+ export async function setNativeDate(loc: Loc, value: Value, options?: SetOptions): Promise<boolean> {
53
+ if (value instanceof Date) {
54
+ return await setSingleDate(loc, value, options);
55
+ }
56
+
57
+ if (isRange(value, isDate)) {
58
+ return await setDateRange(loc, value, options);
59
+ }
60
+
61
+ return false;
62
+ }
@@ -0,0 +1,17 @@
1
+ import type { Loc, SetOptions, Value } from './types';
2
+
3
+ export async function setNativeInput({ el, tag }: Loc, value: Value, options?: SetOptions): Promise<boolean> {
4
+ if (tag !== 'input' && tag !== 'textarea') return false;
5
+
6
+ if (value === null) {
7
+ await el.clear(options);
8
+ return true;
9
+ }
10
+
11
+ if (typeof value === 'string' || typeof value === 'number') {
12
+ await el.fill(String(value), options);
13
+ return true;
14
+ }
15
+
16
+ return false;
17
+ }
@@ -0,0 +1,75 @@
1
+ import { isRange, type Scalar } from '@letsrunit/utils';
2
+ import { diffArray } from '@letsrunit/utils/src/array';
3
+ import type { Locator } from '@playwright/test';
4
+ import type { Loc, SetOptions, Value } from './types';
5
+
6
+ export async function clearSelect(el: Locator, opts?: SetOptions) {
7
+ const isMultiple = await el.evaluate((e) => (e as HTMLSelectElement).multiple, opts);
8
+ const options = await el.evaluate(
9
+ (e) =>
10
+ Array.from((e as HTMLSelectElement).options).map((o) => ({
11
+ value: o.value,
12
+ disabled: o.disabled,
13
+ })),
14
+ opts,
15
+ );
16
+
17
+ if (isMultiple) {
18
+ await el.selectOption([], opts);
19
+ return;
20
+ }
21
+
22
+ const empty = options.find((o) => o.value === '' && !o.disabled);
23
+ if (empty) {
24
+ await el.selectOption({ value: '' }, opts);
25
+ return;
26
+ }
27
+
28
+ const firstEnabled = options.find((o) => !o.disabled);
29
+ if (firstEnabled) {
30
+ await el.selectOption({ value: firstEnabled.value }, opts);
31
+ }
32
+ }
33
+
34
+ async function multiSelect(el: Locator, value: Scalar[], opts?: SetOptions) {
35
+ const isMultiple = await el.evaluate((e) => (e as HTMLSelectElement).multiple, opts);
36
+ if (!isMultiple) throw new Error('Select is not multiple');
37
+
38
+ const requested = value.map((v) => String(v));
39
+ const selected = await el.selectOption(requested, opts);
40
+
41
+ const missing = diffArray(selected, requested);
42
+ if (missing.length > 0) {
43
+ throw new Error(`Options not found in select: ${missing.join(', ')}`);
44
+ }
45
+ }
46
+
47
+ async function singleSelect(el: Locator, value: string, opts?: SetOptions) {
48
+ const result = await el.selectOption(value, opts);
49
+
50
+ if (result.length === 0) {
51
+ throw new Error(`Option "${value}" not found in select`);
52
+ }
53
+ }
54
+
55
+ export async function selectNative({ el, tag }: Loc, value: Value, options?: SetOptions): Promise<boolean> {
56
+ if (tag !== 'select') return false;
57
+
58
+ if (value instanceof Date || isRange(value)) {
59
+ return false;
60
+ }
61
+
62
+ if (value === null) {
63
+ await clearSelect(el, options);
64
+ return true;
65
+ }
66
+
67
+ if (Array.isArray(value)) {
68
+ if (value.some((v) => v instanceof Date)) return false;
69
+ await multiSelect(el, value, options);
70
+ return true;
71
+ }
72
+
73
+ await singleSelect(el, String(value), options);
74
+ return true;
75
+ }
@@ -0,0 +1,22 @@
1
+ import type { Loc, SetOptions, Value } from './types';
2
+
3
+ export async function setOtpValue({ el, tag }: Loc, value: Value, options?: SetOptions): Promise<boolean> {
4
+ if (typeof value !== 'string') return false;
5
+ if (tag === 'input' || tag === 'select' || tag === 'button' || tag === 'textarea') return false;
6
+
7
+ const chars = value.replace(/\W/g, '').split('');
8
+ if (chars.length < 3 || chars.length > 8) return false;
9
+
10
+ // Find all text inputs as child of el
11
+ const inputs = await el.locator('input[type="text"], input:not([type])').all();
12
+
13
+ // Check if the number of input matches the number of characters. If not, return false.
14
+ if (inputs.length !== chars.length) return false;
15
+
16
+ // Fill out each input with one character.
17
+ for (let i = 0; i < chars.length; i++) {
18
+ await inputs[i].fill(chars[i], options);
19
+ }
20
+
21
+ return true;
22
+ }
@@ -0,0 +1,27 @@
1
+ import type { Loc, SetOptions, Value } from './types';
2
+
3
+ export async function setRadioGroup(
4
+ { el }: Loc,
5
+ value: Value,
6
+ options?: SetOptions,
7
+ ): Promise<boolean> {
8
+ if (typeof value !== 'string' && typeof value !== 'number') return false;
9
+
10
+ const stringValue = String(value);
11
+ if (stringValue.includes('\n') || stringValue.includes('"')) return false;
12
+
13
+ const radio = el.locator(`input[type=radio][value="${stringValue}"]`);
14
+ if ((await radio.count()) === 1) {
15
+ await radio.check(options);
16
+ return true;
17
+ }
18
+
19
+ // Also try searching by label text if value doesn't match
20
+ const radioByLabel = el.getByLabel(String(value), { exact: true }).locator('input[type=radio]');
21
+ if ((await radioByLabel.count()) === 1) {
22
+ await radioByLabel.check(options);
23
+ return true;
24
+ }
25
+
26
+ return false;
27
+ }
@@ -0,0 +1,132 @@
1
+ import type { Locator } from '@playwright/test';
2
+ import type { Loc, SetOptions, Value } from './types';
3
+
4
+ export async function setSliderValue({ el, tag }: Loc, value: Value, options?: SetOptions): Promise<boolean> {
5
+ if (typeof value !== 'number') return false;
6
+ if (['input', 'select', 'button', 'textarea'].includes(tag)) return false;
7
+
8
+ const slider = await getSliderElement(el, options);
9
+ if (!slider) return false;
10
+
11
+ const { min, max, orientation, valuenow: initialValue } = await getSliderAttributes(slider, options);
12
+ if (value < min || value > max) {
13
+ throw new Error(`Value ${value} is out of range [${min}, ${max}]`);
14
+ }
15
+ if (initialValue === null) return false;
16
+ if (initialValue === value) return true;
17
+
18
+ const { centerX, centerY } = await prepareMouse(slider, options);
19
+ const page = slider.page();
20
+
21
+ try {
22
+ const ratio = await calculateRatio(slider, initialValue, value, centerX, centerY, orientation, options);
23
+ await seekValue(slider, initialValue, value, centerX, centerY, orientation, ratio, options);
24
+ } finally {
25
+ await page.mouse.up();
26
+ }
27
+
28
+ return true;
29
+ }
30
+
31
+ async function prepareMouse(slider: Locator, options?: SetOptions) {
32
+ await slider.scrollIntoViewIfNeeded(options);
33
+
34
+ const box = await slider.boundingBox(options);
35
+ if (!box) throw new Error('Slider has no bounding box');
36
+
37
+ // If height/width is 0, we use a small offset to ensure we hit the element's area
38
+ const centerX = box.x + (box.width || 10) / 2;
39
+ const centerY = box.y + (box.height || 10) / 2;
40
+ const page = slider.page();
41
+
42
+ await page.mouse.move(centerX, centerY);
43
+ await page.mouse.down();
44
+
45
+ return { centerX, centerY };
46
+ }
47
+
48
+ async function seekValue(
49
+ slider: Locator,
50
+ initialValue: number,
51
+ targetValue: number,
52
+ centerX: number,
53
+ centerY: number,
54
+ orientation: string,
55
+ ratio: number,
56
+ options?: SetOptions,
57
+ ) {
58
+ const page = slider.page();
59
+
60
+ for (let i = 0; i < 4; i++) {
61
+ const distance = (targetValue - initialValue) / ratio;
62
+ await moveMouse(page, centerX, centerY, orientation, distance);
63
+
64
+ const currentValAttr = await slider.getAttribute('aria-valuenow', options);
65
+ const currentVal = parseFloat(currentValAttr || '0');
66
+ if (currentVal === targetValue) break;
67
+
68
+ if (distance !== 0) {
69
+ const newRatio = (currentVal - initialValue) / distance;
70
+ if (newRatio !== 0) ratio = newRatio;
71
+ }
72
+ }
73
+ }
74
+
75
+ async function calculateRatio(
76
+ slider: Locator,
77
+ initialValue: number,
78
+ targetValue: number,
79
+ centerX: number,
80
+ centerY: number,
81
+ orientation: string,
82
+ options?: SetOptions,
83
+ ): Promise<number> {
84
+ const page = slider.page();
85
+ const testMove = targetValue > initialValue ? 20 : -20;
86
+ await moveMouse(page, centerX, centerY, orientation, testMove);
87
+
88
+ const valAfterMoveAttr = await slider.getAttribute('aria-valuenow', options);
89
+ const valAfterMove = parseFloat(valAfterMoveAttr || '0');
90
+ const diff = valAfterMove - initialValue;
91
+
92
+ if (diff === 0) {
93
+ throw new Error('Slider appears to be disabled or unresponsive');
94
+ }
95
+
96
+ return diff / testMove;
97
+ }
98
+
99
+ async function getSliderElement(el: Locator, options?: SetOptions): Promise<Locator | null> {
100
+ const role = await el.getAttribute('role', options).catch(() => null);
101
+ if (role === 'slider') return el;
102
+
103
+ const slider = el.getByRole('slider');
104
+ if ((await slider.count()) > 0) {
105
+ return slider.first();
106
+ }
107
+ return null;
108
+ }
109
+
110
+ async function getSliderAttributes(slider: Locator, options?: SetOptions) {
111
+ const [minStr, maxStr, orient, nowStr] = await Promise.all([
112
+ slider.getAttribute('aria-valuemin', options),
113
+ slider.getAttribute('aria-valuemax', options),
114
+ slider.getAttribute('aria-orientation', options),
115
+ slider.getAttribute('aria-valuenow', options),
116
+ ]);
117
+
118
+ return {
119
+ min: parseFloat(minStr || '0'),
120
+ max: parseFloat(maxStr || '100'),
121
+ orientation: orient || 'horizontal',
122
+ valuenow: nowStr !== null ? parseFloat(nowStr) : null,
123
+ };
124
+ }
125
+
126
+ async function moveMouse(page: any, centerX: number, centerY: number, orientation: string, distance: number) {
127
+ if (orientation === 'vertical') {
128
+ await page.mouse.move(centerX, centerY - distance);
129
+ } else {
130
+ await page.mouse.move(centerX + distance, centerY);
131
+ }
132
+ }