@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
package/package.json ADDED
@@ -0,0 +1,67 @@
1
+ {
2
+ "name": "@letsrunit/playwright",
3
+ "version": "0.1.0",
4
+ "description": "Playwright extensions and utilities for letsrunit",
5
+ "keywords": [
6
+ "testing",
7
+ "playwright",
8
+ "automation",
9
+ "letsrunit"
10
+ ],
11
+ "license": "MIT",
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "https://github.com/letsrunit/letsrunit.git",
15
+ "directory": "packages/playwright"
16
+ },
17
+ "bugs": "https://github.com/letsrunit/letsrunit/issues",
18
+ "homepage": "https://github.com/letsrunit/letsrunit#readme",
19
+ "private": false,
20
+ "type": "module",
21
+ "main": "./dist/index.js",
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "files": [
26
+ "dist",
27
+ "src",
28
+ "README.md"
29
+ ],
30
+ "scripts": {
31
+ "build": "../../node_modules/.bin/tsup",
32
+ "test": "vitest run",
33
+ "test:cov": "vitest run --coverage",
34
+ "typecheck": "tsc --noEmit"
35
+ },
36
+ "packageManager": "yarn@4.10.3",
37
+ "dependencies": {
38
+ "@letsrunit/utils": "workspace:*",
39
+ "@playwright/test": "^1.57.0",
40
+ "case": "^1.6.3",
41
+ "diff": "^8.0.3",
42
+ "fast-json-stable-stringify": "^2.1.0",
43
+ "jsdom": "^27.4.0",
44
+ "metascraper-description": "^5.49.15",
45
+ "metascraper-image": "^5.49.15",
46
+ "metascraper-logo": "^5.49.15",
47
+ "metascraper-logo-favicon": "^5.49.15",
48
+ "metascraper-title": "^5.49.15",
49
+ "metascraper-url": "^5.49.15",
50
+ "rehype-format": "^5.0.1",
51
+ "rehype-parse": "^9.0.1",
52
+ "rehype-stringify": "^10.0.1",
53
+ "unified": "^11.0.5"
54
+ },
55
+ "devDependencies": {
56
+ "@types/jsdom": "^27.0.0",
57
+ "vitest": "^4.0.17"
58
+ },
59
+ "module": "./dist/index.js",
60
+ "types": "./dist/index.d.ts",
61
+ "exports": {
62
+ ".": {
63
+ "types": "./dist/index.d.ts",
64
+ "import": "./dist/index.js"
65
+ }
66
+ }
67
+ }
package/src/browser.ts ADDED
@@ -0,0 +1,20 @@
1
+ import type { Browser, BrowserContextOptions, Page } from '@playwright/test';
2
+
3
+ // Launch a minimal, headless Chromium instance.
4
+ export async function browse(browser: Browser, options: BrowserContextOptions = {}): Promise<Page> {
5
+ const context = await browser.newContext({
6
+ viewport: { width: 1920, height: 1080 },
7
+ userAgent:
8
+ 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
9
+ locale: 'en-US',
10
+ ...options,
11
+ });
12
+
13
+ // Safety net against bundler-injected helpers inside page.evaluate
14
+ await context.addInitScript(() => {
15
+ // define __name as a no-op if present
16
+ (window as any).__name = (window as any).__name || ((fn: any) => fn);
17
+ });
18
+
19
+ return await context.newPage();
20
+ }
@@ -0,0 +1,300 @@
1
+ import { isArray, isDate, isRange } from '@letsrunit/utils';
2
+ import { uniqueItem } from '@letsrunit/utils/src/array';
3
+ import type { Locator } from '@playwright/test';
4
+ import { formatDate, formatDateForInput, getMonthNames } from '../utils/date';
5
+ import { waitForAnimationsToFinish } from '../wait';
6
+ import type { Loc, SetOptions, Value } from './types';
7
+
8
+ type MonthYear = { month: number; year: number };
9
+
10
+ async function getDialog(root: Locator, options?: SetOptions) {
11
+ const role = await root.getAttribute('role', options).catch(() => null);
12
+
13
+ if (role !== 'combobox') return null;
14
+
15
+ const ariaControls = await root.getAttribute('aria-controls', options).catch(() => null);
16
+ if (!ariaControls) return null;
17
+
18
+ const calendar = root.page().locator(`#${ariaControls}`);
19
+ if ((await calendar.count()) === 0) {
20
+ await root.click(options);
21
+ }
22
+
23
+ const count = await calendar.count();
24
+ return count > 0 ? calendar : null;
25
+ }
26
+
27
+ async function isCalendarGrid(grid: Locator): Promise<boolean> {
28
+ const cells = grid.locator('td, [role="gridcell"]');
29
+ const cellCount = await cells.count();
30
+ if (cellCount < 28 || cellCount > 80) return false; // Sanity check
31
+
32
+ const texts = await cells.allTextContents();
33
+ const days = texts
34
+ .map((t) => t.trim())
35
+ .filter((t) => /^\d{1,2}$/.test(t))
36
+ .map(Number)
37
+ .filter((n) => n >= 1 && n <= 31);
38
+
39
+ // Expect sequential numbering from 1 to at least 28. Ignore other cells with other content.
40
+ const last = days.reduce((max, cur) => (cur === max + 1 ? cur : max), 0);
41
+ return last >= 28;
42
+ }
43
+
44
+ export async function getCalendar(
45
+ root: Locator,
46
+ options?: SetOptions,
47
+ ): Promise<{ calendar: Locator; tables: Locator[] } | null> {
48
+ const gridSelector = 'table, [role="grid"]';
49
+
50
+ // 1. Identify the container (either the root itself or a linked dialog/popup)
51
+ let container: Locator | null =
52
+ (await root.locator(gridSelector).count()) > 0 ? root : await getDialog(root, options);
53
+
54
+ // Fallback: Check if the root itself is a valid grid (e.g. inline MUI DateCalendar)
55
+ if (!container && (await isCalendarGrid(root))) {
56
+ return { calendar: root, tables: [root] };
57
+ }
58
+ if (!container) return null;
59
+
60
+ // 2. Find all valid calendar grids within the container
61
+ // This handles both <table> and <div role="grid">
62
+ const found = await container.locator(gridSelector).all();
63
+ const tables: Locator[] = [];
64
+
65
+ // If the container itself matches the selector, check it first
66
+ if (await isCalendarGrid(container)) {
67
+ tables.push(container);
68
+ }
69
+
70
+ for (const grid of found) {
71
+ if (await isCalendarGrid(grid)) {
72
+ tables.push(grid);
73
+ }
74
+ }
75
+
76
+ // 3. Deduplicate (prevents issues if container == grid)
77
+ const uniqueTables = tables.filter((t, i, self) => self.indexOf(t) === i);
78
+
79
+ return uniqueTables.length > 0 ? { calendar: container, tables: uniqueTables } : null;
80
+ }
81
+
82
+ function uniqueMonthYearPairs(pairs: MonthYear[]): MonthYear[] {
83
+ const map = pairs.reduce(
84
+ (acc, r) => acc.set(`${r.year}-${r.month}`, r),
85
+ new Map<string, { month: number; year: number }>(),
86
+ );
87
+ return Array.from(map.values());
88
+ }
89
+
90
+ function inferYearForMonth(target: Date, month: number): number {
91
+ const ty = target.getFullYear();
92
+ const tm = target.getMonth();
93
+ const targetTotal = ty * 12 + tm;
94
+
95
+ const candidates = [ty - 1, ty, ty + 1].map((y) => ({ year: y, total: y * 12 + month }));
96
+ candidates.sort((a, b) => Math.abs(a.total - targetTotal) - Math.abs(b.total - targetTotal));
97
+ return candidates[0].year;
98
+ }
99
+
100
+ export async function getCurrentMonthsAndYears(root: Locator, target: Date): Promise<MonthYear[]> {
101
+ const lang = await root
102
+ .page()
103
+ .locator('html')
104
+ .getAttribute('lang')
105
+ .catch(() => undefined);
106
+ const locales = lang && !lang.startsWith('en') ? [lang, 'en-US'] : ['en-US'];
107
+
108
+ const text = await root.innerText();
109
+ const monthSets = locales.map(getMonthNames);
110
+
111
+ // Look for month year pairs
112
+ const pairs = monthSets.flatMap((months) => {
113
+ const matches = text.matchAll(new RegExp(`(${months.join('|')})\\W*(\\d{4})`, 'gi'));
114
+ return Array.from(matches)
115
+ .map((m) => ({
116
+ month: months.findIndex((x) => x.toLowerCase() === m[1].toLowerCase()),
117
+ year: Number.parseInt(m[2], 10),
118
+ }))
119
+ .filter((r) => r.month !== -1);
120
+ });
121
+
122
+ if (pairs.length) {
123
+ return uniqueMonthYearPairs(pairs);
124
+ }
125
+
126
+ // Fallback looking for months only
127
+ const months = monthSets
128
+ .flatMap((ms) => ms.map((name, i) => (new RegExp(name, 'i').test(text) ? i : -1)))
129
+ .filter(uniqueItem)
130
+ .filter((i) => i >= 0)
131
+ .sort((a, b) => a - b);
132
+
133
+ const years = (text.match(/\b20\d{2}\b/g) ?? [])
134
+ .map((y) => Number.parseInt(y, 10))
135
+ .filter(uniqueItem)
136
+ .sort((a, b) => a - b);
137
+
138
+ if (!months.length) return [];
139
+
140
+ if (years.length === 1) {
141
+ return months.map((month) => ({ month, year: years[0] }));
142
+ }
143
+
144
+ // Heuristic: months >= July belong to y0 and <= June belongs to y1
145
+ if (years.length === 2) {
146
+ return months
147
+ .map((month) => ({ month, year: month >= 6 ? years[0] : years[1] }))
148
+ .sort((a, b) => a.year * 12 + a.month - (b.year * 12 + b.month));
149
+ }
150
+
151
+ // Year can't be determined, infer from target date
152
+ return months
153
+ .map((month) => ({ month, year: inferYearForMonth(target, month) }))
154
+ .sort((a, b) => a.year * 12 + a.month - (b.year * 12 + b.month));
155
+ }
156
+
157
+ async function navigateToMonth(
158
+ root: Locator,
159
+ target: Date,
160
+ options?: SetOptions & { wait?: number; retry?: number },
161
+ ): Promise<boolean> {
162
+ const currentMonths = await getCurrentMonthsAndYears(root, target);
163
+ if (currentMonths.length === 0) return false;
164
+
165
+ const targetTotal = target.getFullYear() * 12 + target.getMonth();
166
+ if (currentMonths.find((m) => m.year * 12 + m.month === targetTotal)) {
167
+ return true;
168
+ }
169
+
170
+ const firstMonthTotal = currentMonths[0].year * 12 + currentMonths[0].month;
171
+ const diff = targetTotal - firstMonthTotal;
172
+
173
+ const btn =
174
+ diff < 0
175
+ ? root.locator('button[aria-label*="prev"], button[class*="prev"]').filter({ visible: true }).first()
176
+ : root.locator('button[aria-label*="next"], button[class*="next"]').filter({ visible: true }).first();
177
+
178
+ if (!(await btn.count())) return false;
179
+
180
+ for (let i = 0; i < Math.abs(diff); i++) {
181
+ await btn.click(options);
182
+ await root.page().waitForTimeout(options?.wait ?? 50);
183
+ }
184
+
185
+ await waitForAnimationsToFinish(root);
186
+
187
+ const afterMonths = await getCurrentMonthsAndYears(root, target);
188
+ if (afterMonths.some((m) => m.year * 12 + m.month === targetTotal)) return true;
189
+
190
+ if ((options?.retry ?? 0) < 3) {
191
+ const retry = (options?.retry ?? 0) + 1;
192
+ return await navigateToMonth(root, target, { ...options, wait: 50 + 50 * retry, retry }); // Retry max 3x
193
+ }
194
+
195
+ return false;
196
+ }
197
+
198
+ async function setDayByAriaLabel(table: Locator, value: Date, options?: SetOptions): Promise<boolean> {
199
+ const formats = ['DD/MM/YYYY', 'MM/DD/YYYY', 'DD-MM-YYYY', 'YYYY-MM-DD'];
200
+ const candidateLabels = [value.toDateString(), ...formats.map((fmt) => formatDate(value, fmt))];
201
+
202
+ const cells = (
203
+ await Promise.all(
204
+ candidateLabels.map(async (label) => {
205
+ const cell = table.locator(`[aria-label="${label}"], [aria-label*="${label}"]`).first();
206
+ return (await cell.isVisible(options)) ? cell : null;
207
+ }),
208
+ )
209
+ ).filter((l): l is Locator => l !== null);
210
+
211
+ if (cells.length === 0) return false;
212
+
213
+ if (cells.length === 1) {
214
+ await cells[0].click(options);
215
+ return true;
216
+ }
217
+
218
+ // Disambiguate using day 22 in case of dd/mm/yyyy vs mm/dd/yyyy
219
+ const probeDate = new Date(value.getFullYear(), value.getMonth(), 22);
220
+ const probeResults = await Promise.all(
221
+ formats.map(async (fmt) => {
222
+ const label = formatDate(probeDate, fmt);
223
+ const cell = table.locator(`[aria-label="${label}"], [aria-label*="${label}"]`).first();
224
+ return (await cell.isVisible(options)) ? fmt : null;
225
+ }),
226
+ );
227
+
228
+ const finalFormat = probeResults.find((fmt) => fmt !== null);
229
+ const finalCell = finalFormat
230
+ ? table
231
+ .locator(`[aria-label="${formatDate(value, finalFormat)}"], [aria-label*="${formatDate(value, finalFormat)}"]`)
232
+ .first()
233
+ : cells[0];
234
+
235
+ await finalCell.click(options);
236
+ return true;
237
+ }
238
+
239
+ async function setDayByByCellText(table: Locator, value: Date, options?: SetOptions): Promise<boolean> {
240
+ const day = value.getDate();
241
+ const cells = table.locator('td, [role="gridcell"]').filter({ visible: true });
242
+
243
+ const allTexts = await cells.allInnerTexts();
244
+ const foundIndices = allTexts.map((text, i) => (text.trim() === String(day) ? i : -1)).filter((i) => i !== -1);
245
+
246
+ if (foundIndices.length === 0) return false;
247
+
248
+ const index = foundIndices.length === 1 || day < 15 ? foundIndices[0] : foundIndices[foundIndices.length - 1];
249
+ await cells.nth(index).click(options);
250
+
251
+ return true;
252
+ }
253
+
254
+ async function setDates(calendar: Locator, tables: Locator[], dates: Date[], options?: SetOptions): Promise<void> {
255
+ let method: 'aria' | 'text' | undefined = undefined;
256
+
257
+ for (const date of dates) {
258
+ if (!(await navigateToMonth(calendar, date, options))) {
259
+ throw new Error(`Failed to navigate to "${formatDateForInput(date, 'month')}"`);
260
+ }
261
+
262
+ const currentMonths = await getCurrentMonthsAndYears(calendar, date);
263
+ const targetTotal = date.getFullYear() * 12 + date.getMonth();
264
+ const tableIndex = currentMonths.findIndex((m) => m.year * 12 + m.month === targetTotal);
265
+ const table = tables[tableIndex] ?? tables[0];
266
+
267
+ if (method !== 'text' && (await setDayByAriaLabel(table, date, options))) {
268
+ method ??= 'aria';
269
+ continue;
270
+ }
271
+
272
+ if (method !== 'aria' && (await setDayByByCellText(table, date, options))) {
273
+ method ??= 'text';
274
+ continue;
275
+ }
276
+
277
+ throw new Error(`Failed to set date "${formatDateForInput(date, 'date')}"`);
278
+ }
279
+ }
280
+
281
+ export async function setCalendarDate({ el, tag }: Loc, value: Value, options?: SetOptions): Promise<boolean> {
282
+ if (!(value instanceof Date) && !isArray(value, isDate) && !isRange(value, isDate)) return false;
283
+
284
+ const dates = isArray(value, isDate) ? value : isRange(value) ? [value.from, value.to] : [value];
285
+
286
+ const { calendar, tables } = (await getCalendar(el, options)) ?? {};
287
+ if (!calendar || !tables || tables.length === 0) return false;
288
+
289
+ try {
290
+ await setDates(calendar, tables, dates, options);
291
+ } finally {
292
+ // Close the dialog
293
+ if (tag === 'input') {
294
+ await el.page().keyboard.press('Escape').catch();
295
+ await el.blur().catch();
296
+ }
297
+ }
298
+
299
+ return true;
300
+ }
@@ -0,0 +1,253 @@
1
+ import type { Locator } from '@playwright/test';
2
+ import { clearSelect } from './native-select';
3
+ import type { Loc, SetOptions, Value } from './types';
4
+
5
+ type CandidateInfo = {
6
+ el: Locator;
7
+ tag: string;
8
+ type: string | null;
9
+ name: string | null;
10
+ id: string | null;
11
+ ariaLabel: string | null;
12
+ placeholder: string | null;
13
+ min: string | null;
14
+ max: string | null;
15
+ inputMode: string | null;
16
+ attrs: Record<string, string>;
17
+ options: { value: string; text: string }[];
18
+ };
19
+
20
+ type Score = { day: number; month: number; year: number };
21
+
22
+ async function getCandidateLocs(el: Locator, options?: SetOptions): Promise<CandidateInfo[]> {
23
+ const candidates = await el.locator('input, select').all();
24
+ if (candidates.length < 2 || candidates.length > 3) return [];
25
+
26
+ return Promise.all(
27
+ candidates.map(async (c) => {
28
+ const info = await c.evaluate((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
+ };
56
+ }, options);
57
+ return { el: c, ...info };
58
+ }),
59
+ );
60
+ }
61
+
62
+ function scoreByAttributes(candidateLocs: CandidateInfo[]): Score[] {
63
+ const scores: Score[] = candidateLocs.map(() => ({ day: 0, month: 0, year: 0 }));
64
+
65
+ candidateLocs.forEach((loc, i) => {
66
+ const text = [loc.name, loc.id, loc.ariaLabel, loc.placeholder, ...Object.values(loc.attrs)]
67
+ .join(' ')
68
+ .toLowerCase();
69
+
70
+ // 1. Text signals
71
+ if (/\b(day|dd|d)\b/i.test(text)) scores[i].day += 5;
72
+ if (/\b(month|mm|m)\b/i.test(text)) scores[i].month += 5;
73
+ if (/\b(year|yyyy|yy|y)\b/i.test(text)) scores[i].year += 5;
74
+
75
+ // 2. Attribute signals
76
+ if (loc.max) {
77
+ const max = parseInt(loc.max, 10);
78
+ if (max >= 1 && max <= 31) scores[i].day += 2;
79
+ if (max >= 1 && max <= 12) scores[i].month += 2;
80
+ if (max > 1900) scores[i].year += 2;
81
+ }
82
+
83
+ if (loc.tag === 'select') {
84
+ if (loc.options.length >= 12 && loc.options.length <= 13) {
85
+ scores[i].month += 3;
86
+ }
87
+ const allNumeric = loc.options.every((o) => !isNaN(parseInt(o.value, 10)) || !o.value);
88
+ if (allNumeric && loc.options.length >= 12 && loc.options.length <= 13) {
89
+ scores[i].month += 2;
90
+ }
91
+ }
92
+ });
93
+
94
+ return scores;
95
+ }
96
+
97
+ async function behavioralProbe(candidateLocs: CandidateInfo[], scores: Score[], options?: SetOptions) {
98
+ const sorted = [...scores].sort((a, b) => Math.max(b.day, b.month, b.year) - Math.max(a.day, a.month, a.year));
99
+ if (sorted[0].day < 2 && sorted[0].month < 2 && sorted[0].year < 2) {
100
+ for (let i = 0; i < candidateLocs.length; i++) {
101
+ const loc = candidateLocs[i];
102
+ if (loc.tag === 'input') {
103
+ const can_be_day = await loc.el.evaluate((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;
110
+ }, options);
111
+ if (can_be_day) scores[i].day += 1;
112
+
113
+ const cannot_be_day = await loc.el.evaluate((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;
120
+ }, options);
121
+ if (cannot_be_day) scores[i].day += 1;
122
+
123
+ const can_be_month = await loc.el.evaluate((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;
130
+ }, options);
131
+ if (can_be_month) scores[i].month += 1;
132
+
133
+ const cannot_be_month = await loc.el.evaluate((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;
140
+ }, options);
141
+ if (cannot_be_month) scores[i].month += 1;
142
+
143
+ const can_be_year = await loc.el.evaluate((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;
150
+ }, options);
151
+ if (can_be_year) scores[i].year += 1;
152
+ }
153
+ }
154
+ }
155
+ }
156
+
157
+ function applyTieBreakers(candidateLocs: CandidateInfo[], scores: Score[]) {
158
+ // Tie-breaker: typical order D-M-Y or Y-M-D
159
+ if (candidateLocs.length === 3) {
160
+ scores[0].day += 0.1;
161
+ scores[1].month += 0.1;
162
+ scores[2].year += 0.1;
163
+ }
164
+ }
165
+
166
+ function resolveDateFields(
167
+ scores: Score[],
168
+ ): { day: number; month: number; year: number } | null {
169
+ const result: { day?: number; month?: number; year?: number } = {};
170
+ const used = new Set<number>();
171
+
172
+ for (const field of ['year', 'month', 'day'] as const) {
173
+ let bestIdx = -1;
174
+ let bestScore = -1;
175
+ for (let i = 0; i < scores.length; i++) {
176
+ if (!used.has(i) && scores[i][field] > bestScore) {
177
+ bestScore = scores[i][field];
178
+ bestIdx = i;
179
+ }
180
+ }
181
+ if (bestIdx !== -1 && bestScore > 0) {
182
+ result[field] = bestIdx;
183
+ used.add(bestIdx);
184
+ }
185
+ }
186
+
187
+ if (result.day === undefined || result.month === undefined || result.year === undefined) {
188
+ return null;
189
+ }
190
+
191
+ return result as { day: number; month: number; year: number };
192
+ }
193
+
194
+ async function setMonthField(monthLoc: CandidateInfo, monthVal: number, options?: SetOptions) {
195
+ if (monthLoc.tag === 'select') {
196
+ const options_list = monthLoc.options;
197
+ const valueMatch = options_list.find((o) => parseInt(o.value, 10) === monthVal);
198
+ if (valueMatch) {
199
+ await monthLoc.el.selectOption(valueMatch.value, options);
200
+ } else {
201
+ // Try to match by index (considering common 0-indexed or 1-indexed headers)
202
+ // Usually month selects have 12 or 13 (with placeholder) options
203
+ const offset = options_list.length === 13 ? 1 : 0;
204
+ await monthLoc.el.selectOption({ index: monthVal - 1 + offset }, options);
205
+ }
206
+ } else {
207
+ await monthLoc.el.fill(String(monthVal), options);
208
+ }
209
+ }
210
+
211
+ async function clearFields(locs: CandidateInfo[], options?: SetOptions) {
212
+ for (const loc of locs) {
213
+ if (loc.tag === 'select') {
214
+ await clearSelect(loc.el, options);
215
+ } else {
216
+ await loc.el.clear(options);
217
+ }
218
+ }
219
+ }
220
+
221
+ export async function setDateGroup({ el, tag }: Loc, value: Value, options?: SetOptions): Promise<boolean> {
222
+ if (!(value instanceof Date) && value !== null) return false;
223
+ if (tag === 'input' || tag === 'textarea' || tag === 'select') return false;
224
+
225
+ const candidateLocs = await getCandidateLocs(el, options);
226
+ if (candidateLocs.length === 0) return false;
227
+
228
+ if (value === null) {
229
+ await clearFields(candidateLocs, options);
230
+ return true;
231
+ }
232
+
233
+ const scores = scoreByAttributes(candidateLocs);
234
+ await behavioralProbe(candidateLocs, scores, options);
235
+ applyTieBreakers(candidateLocs, scores);
236
+
237
+ const result = resolveDateFields(scores);
238
+
239
+ if (!result) {
240
+ const candidatesStr = candidateLocs.map((c) => `${c.tag}[name=${c.name}]`).join(', ');
241
+ throw new Error(`Could not reliably detect date fields for ${tag}. Detected candidates: ${candidatesStr}`);
242
+ }
243
+
244
+ const dayVal = value.getDate();
245
+ const monthVal = value.getMonth() + 1;
246
+ const yearVal = value.getFullYear();
247
+
248
+ await candidateLocs[result.day].el.fill(String(dayVal), options);
249
+ await setMonthField(candidateLocs[result.month], monthVal, options);
250
+ await candidateLocs[result.year].el.fill(String(yearVal), options);
251
+
252
+ return true;
253
+ }