@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,272 @@
1
+ export const createDateEngine = () => ({
2
+ // Single match (Playwright will call this in some contexts)
3
+ query(root: Element | Document, body: string): Element | null {
4
+ const all = this.queryAll(root, body);
5
+ return all[0] ?? null;
6
+ },
7
+
8
+ // All matches
9
+ queryAll(root: Element | Document, body: string): Element[] {
10
+ const targetDate = this._parseSelector(body);
11
+ if (!targetDate) return [];
12
+
13
+ const doc = root instanceof Document ? root : root.ownerDocument || document;
14
+ const locale = doc.documentElement.lang || 'en-US';
15
+
16
+ const candidates: { el: Element, type: 'full' | 'partial' }[] = [];
17
+ const walker = doc.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, null);
18
+
19
+ let currentNode = walker.nextNode();
20
+ while (currentNode) {
21
+ const el = currentNode as Element;
22
+ const { matched, type } = this._matchesDate(el, targetDate, locale, body);
23
+ if (matched) {
24
+ candidates.push({ el, type });
25
+ }
26
+ currentNode = walker.nextNode();
27
+ }
28
+
29
+ // Filter to keep only the most specific elements (the ones that don't have matching children)
30
+ // AND prioritize elements that match exactly if multiple are found in a parent-child relationship.
31
+ const results = candidates.filter(c => {
32
+ // If any candidate is a child of el, then el is not the most specific.
33
+ // BUT only if that child matches as much or more than the parent.
34
+ // If the parent is a "full" match (with year) and child is only "partial", parent might be better.
35
+ const el = c.el;
36
+ const hasMatchingDescendant = candidates.some(other => {
37
+ if (other.el === el || !el.contains(other.el)) return false;
38
+ return !(c.type === 'full' && other.type === 'partial');
39
+ });
40
+
41
+ return !hasMatchingDescendant;
42
+
43
+ }).map(c => c.el);
44
+
45
+ // If no results found, we might want to look at elements whose descendants COLLECTIVELY match.
46
+ // However, textContent already includes descendants.
47
+ // If we have nothing, it might be because specificity filter removed everything.
48
+ // If specificity filter removed something, we should check if we should keep it.
49
+
50
+ if (results.length === 0 && candidates.length > 0) {
51
+ // Check if we have multiple candidates that together might be why we failed.
52
+ // Actually, let's just return all candidates that don't have matching descendants.
53
+ // (That's what results is supposed to be).
54
+ }
55
+
56
+ return results;
57
+ },
58
+
59
+ _parseSelector(body: string): Date | null {
60
+ const now = new Date();
61
+ const trimmed = body.toLowerCase().trim();
62
+
63
+ if (trimmed === 'today') return now;
64
+ if (trimmed === 'tomorrow') {
65
+ const d = new Date(now);
66
+ d.setDate(d.getDate() + 1);
67
+ return d;
68
+ }
69
+ if (trimmed === 'yesterday') {
70
+ const d = new Date(now);
71
+ d.setDate(d.getDate() - 1);
72
+ return d;
73
+ }
74
+
75
+ // Relative: \d+ \w+ (?:ago|from now)
76
+ const relativeMatch = trimmed.match(/^(\d+)\s+(day|month|year)s?\s+(ago|from now)$/);
77
+ if (relativeMatch) {
78
+ const [_, amount, unit, direction] = relativeMatch;
79
+ const d = new Date(now);
80
+ const sign = direction === 'ago' ? -1 : 1;
81
+ const val = parseInt(amount, 10) * sign;
82
+
83
+ if (unit === 'day') d.setDate(d.getDate() + val);
84
+ else if (unit === 'month') d.setMonth(d.getMonth() + val);
85
+ else if (unit === 'year') d.setFullYear(d.getFullYear() + val);
86
+ return d;
87
+ }
88
+
89
+ // "today at 15:00"
90
+ const atMatch = trimmed.match(/^(today|tomorrow|yesterday)\s+at\s+(\d{1,2}):(\d{2})(?::(\d{2}))?(?:\s*(am|pm))?$/);
91
+ if (atMatch) {
92
+ const [_, dayStr, hourStr, min, sec, ampm] = atMatch;
93
+ const d = new Date(now);
94
+ if (dayStr === 'tomorrow') d.setDate(d.getDate() + 1);
95
+ if (dayStr === 'yesterday') d.setDate(d.getDate() - 1);
96
+
97
+ let hour = parseInt(hourStr, 10);
98
+ if (ampm === 'pm' && hour < 12) hour += 12;
99
+ if (ampm === 'am' && hour === 12) hour = 0;
100
+
101
+ d.setHours(hour, parseInt(min, 10), parseInt(sec || '0', 10), 0);
102
+ return d;
103
+ }
104
+
105
+ const absDate = new Date(body);
106
+ if (!isNaN(absDate.getTime())) return absDate;
107
+
108
+ return null;
109
+ },
110
+
111
+ _matchesDate(el: Element, targetDate: Date, locale: string, body: string): { matched: boolean, type: 'full' | 'partial' } {
112
+ const text = el.textContent?.trim() || '';
113
+ if (!text) return { matched: false, type: 'partial' };
114
+
115
+ // Normalize text: replace non-breaking spaces, multi-spaces, etc.
116
+ const normalizedText = text.replace(/\s+/g, ' ').trim();
117
+ const cleanText = normalizedText.toLowerCase().replace(/[.,\/#!$%\^&\*;:{}=\-_`~()]/g, ' ').replace(/\s+/g, ' ').trim();
118
+
119
+ const options: Intl.DateTimeFormatOptions[] = [
120
+ { year: 'numeric', month: 'short', day: 'numeric' },
121
+ { year: 'numeric', month: 'long', day: 'numeric' },
122
+ { year: '2-digit', month: 'numeric', day: 'numeric' },
123
+ { year: 'numeric', month: 'numeric', day: 'numeric' },
124
+ { month: 'short', day: 'numeric' },
125
+ { month: 'long', day: 'numeric' },
126
+ ];
127
+
128
+ let dateMatched = false;
129
+ let matchType: 'full' | 'partial' = 'partial';
130
+
131
+ for (const opt of options) {
132
+ const formatter = new Intl.DateTimeFormat(locale, opt);
133
+ const formatted = formatter.format(targetDate);
134
+
135
+ const normalizedFormatted = formatted.replace(/\s+/g, ' ').trim();
136
+ const cleanFormatted = normalizedFormatted.toLowerCase().replace(/[.,\/#!$%\^&\*;:{}=\-_`~()]/g, ' ').replace(/\s+/g, ' ').trim();
137
+
138
+ if (normalizedText.includes(normalizedFormatted) || cleanText.includes(cleanFormatted)) {
139
+ // If it's a numeric match and there's a year, check if the year matches correctly
140
+ if (opt.year && opt.month === 'numeric' && opt.day === 'numeric') {
141
+ const yearVal = targetDate.getFullYear().toString();
142
+ const shortYearVal = yearVal.slice(-2);
143
+ if (!normalizedText.includes(yearVal) && !normalizedText.includes(shortYearVal)) {
144
+ continue;
145
+ }
146
+ }
147
+
148
+ // If it's a month name match and there's a year, check if the year matches correctly
149
+ if (opt.year && (opt.month === 'short' || opt.month === 'long')) {
150
+ const yearVal = targetDate.getFullYear().toString();
151
+ const otherYearMatch = normalizedText.match(/\b\d{4}\b/g);
152
+ if (otherYearMatch && !otherYearMatch.includes(yearVal)) {
153
+ continue;
154
+ }
155
+ }
156
+
157
+ dateMatched = true;
158
+ if (opt.year) matchType = 'full';
159
+ break;
160
+ }
161
+
162
+ // Try with different separators if numeric
163
+ if (opt.month === 'numeric' && opt.day === 'numeric') {
164
+ const parts = formatter.formatToParts(targetDate);
165
+ const day = parts.find(p => p.type === 'day')?.value;
166
+ const month = parts.find(p => p.type === 'month')?.value;
167
+ const year = parts.find(p => p.type === 'year')?.value;
168
+
169
+ if (day && month) {
170
+ const seps = ['.', '-', '/', ' ', ''];
171
+ const d2 = day.padStart(2, '0');
172
+ const m2 = month.padStart(2, '0');
173
+
174
+ const dayVariations = Array.from(new Set([day, d2]));
175
+ const monthVariations = Array.from(new Set([month, m2]));
176
+
177
+ let numericMatched = false;
178
+ for (const sep of seps) {
179
+ for (const d of dayVariations) {
180
+ for (const m of monthVariations) {
181
+ if (year) {
182
+ const y2 = year.slice(-2);
183
+ const yearVariations = Array.from(new Set([year, y2]));
184
+ for (const y of yearVariations) {
185
+ if (normalizedText.includes(`${d}${sep}${m}${sep}${y}`) ||
186
+ normalizedText.includes(`${m}${sep}${d}${sep}${y}`) ||
187
+ normalizedText.includes(`${y}${sep}${m}${sep}${d}`)) {
188
+ numericMatched = true;
189
+ matchType = 'full';
190
+ break;
191
+ }
192
+ }
193
+ }
194
+ if (numericMatched) break;
195
+ // Without year - only if NOT numeric (to avoid matching 2025 as 2026 without year)
196
+ if (normalizedText.includes(`${d}${sep}${m}`) || normalizedText.includes(`${m}${sep}${d}`)) {
197
+ // Only count as partial match if NO year is present in text at all
198
+ if (!normalizedText.match(/\d{4}/)) {
199
+ numericMatched = true;
200
+ matchType = 'partial';
201
+ }
202
+ }
203
+ }
204
+ if (numericMatched) break;
205
+ }
206
+ if (numericMatched) break;
207
+ }
208
+ if (numericMatched) {
209
+ dateMatched = true;
210
+ break;
211
+ }
212
+ }
213
+ }
214
+ }
215
+
216
+ if (!dateMatched && normalizedText.includes(targetDate.toISOString().split('T')[0])) {
217
+ dateMatched = true;
218
+ matchType = 'full';
219
+ }
220
+
221
+ if (!dateMatched) return { matched: false, type: 'partial' };
222
+
223
+ // If we have a full match (with year), it's strong.
224
+ // If we have a partial match, we only accept it if the query itself doesn't specify a year
225
+ // OR if the year is implicitly current year and the element also lacks it.
226
+ const queryHasYear = body.match(/\d{4}|'\d{2}/);
227
+ if (queryHasYear && matchType === 'partial') {
228
+ // If query has year, we NEED year.
229
+ return { matched: false, type: 'partial' };
230
+ }
231
+
232
+ // Strict year check: if query has year, it MUST match.
233
+ // If query is relative ("today"), and element has a year, it MUST match the target year.
234
+ const elementYearMatch = normalizedText.match(/\b\d{4}\b/g);
235
+ if (elementYearMatch && elementYearMatch.some(y => parseInt(y, 10) !== targetDate.getFullYear())) {
236
+ // We only fail if the year found in text is NOT the target year.
237
+ // But wait, what if "Post date: Jan 4, 2026. Edit date: Jan 5, 2026"
238
+ // If we are looking for Jan 4, and we match the whole string, it has BOTH 2026.
239
+ // So we should check if ANY of the years match?
240
+ if (!elementYearMatch.includes(targetDate.getFullYear().toString())) {
241
+ return { matched: false, type: 'partial' };
242
+ }
243
+ }
244
+
245
+ // If query has time, we must also match time
246
+ const queryStr = body.toLowerCase();
247
+ const queryHasTime = queryStr.includes(' at ') || queryStr.includes(':') || (queryStr.includes('am') || queryStr.includes('pm'));
248
+
249
+ if (queryHasTime) {
250
+ const timeMatch = normalizedText.match(/(\d{1,2})[:.](\d{2})(?::(\d{2}))?\s*(am|pm|om|u)?/i);
251
+ if (timeMatch) {
252
+ const [_, h, m, s, ampm] = timeMatch;
253
+ let hour = parseInt(h, 10);
254
+ if (ampm?.toLowerCase() === 'pm' && hour < 12) hour += 12;
255
+ if (ampm?.toLowerCase() === 'am' && hour === 12) hour = 0;
256
+
257
+ if (hour !== targetDate.getHours() || parseInt(m, 10) !== targetDate.getMinutes()) {
258
+ return { matched: false, type: 'full' }; // full because it matched date but wrong time
259
+ }
260
+ if (s && parseInt(s, 10) !== targetDate.getSeconds()) {
261
+ return { matched: false, type: 'full' };
262
+ }
263
+ } else {
264
+ // Query has time but element doesn't?
265
+ // Check if ANY child has time. If we are doing collective matching, the parent should have it.
266
+ return { matched: false, type: 'full' };
267
+ }
268
+ }
269
+
270
+ return { matched: true, type: matchType };
271
+ },
272
+ });
@@ -0,0 +1,121 @@
1
+ export const createFieldEngine = () => ({
2
+ // Helper: compile matcher from `field=...` body
3
+ _compileMatcher(body: string): (s: string | null | undefined) => boolean {
4
+ const trimmed = body.trim();
5
+
6
+ // Support /regex/flags
7
+ if (trimmed.startsWith('/') && trimmed.lastIndexOf('/') > 0) {
8
+ const last = trimmed.lastIndexOf('/');
9
+ const pattern = trimmed.slice(1, last);
10
+ const flags = trimmed.slice(last + 1);
11
+ const re = new RegExp(pattern, flags);
12
+ return (s) => !!s && re.test(s);
13
+ }
14
+
15
+ const sanitize = (s: string) => s
16
+ .toLowerCase()
17
+ .replace(/[^\w\s]+/g, '')
18
+ .replace(/\s{2,}/g, ' ')
19
+ .replace(/\*\s*$/, '')
20
+ .trim();
21
+ const needle = sanitize(trimmed.replace(/^"(.*)"(i?)$/, '$1'));
22
+
23
+ return ((s) => !!s && sanitize(s) === needle);
24
+ },
25
+
26
+ // Single match (Playwright will call this in some contexts)
27
+ query(root: Element | Document, body: string): Element | null {
28
+ const all = this.queryAll(root, body);
29
+ return all[0] ?? null;
30
+ },
31
+
32
+ // All matches
33
+ queryAll(root: Element | Document, body: string): Element[] {
34
+ const match = this._compileMatcher(body);
35
+
36
+ // Candidate controls: native fields + common ARIA widgets
37
+ const candidates = Array.from(
38
+ root.querySelectorAll<HTMLElement>(
39
+ [
40
+ 'input',
41
+ 'textarea',
42
+ 'select',
43
+ '[role="textbox"]',
44
+ '[role="combobox"]',
45
+ '[role="spinbutton"]',
46
+ '[role="slider"]',
47
+ '[role="switch"]',
48
+ '[role="checkbox"]',
49
+ '[role="radio"]',
50
+ '[role="radiogroup"]',
51
+ '[contenteditable=""]',
52
+ '[contenteditable="true"]',
53
+ ].join(', '),
54
+ ),
55
+ );
56
+
57
+ const byId = new Map<string, Element>();
58
+ // Build quick lookup for aria-labelledby resolution
59
+ Array.from(root.querySelectorAll<HTMLElement>('[id]')).forEach((el) => byId.set(el.id, el));
60
+
61
+ function textFor(el: HTMLElement): string[] {
62
+ const texts: string[] = [];
63
+
64
+ // 1) Implicit <label> wrapping
65
+ let parent: HTMLElement | null = el.parentElement;
66
+ while (parent) {
67
+ if (parent.tagName === 'LABEL') {
68
+ texts.push(parent.textContent ?? '');
69
+ break;
70
+ }
71
+ parent = parent.parentElement;
72
+ }
73
+
74
+ // 2) <label for="...">
75
+ let id = (el as HTMLElement).id;
76
+ if (!id) { // support parent div/span id when element lacks one
77
+ const p = el.parentElement as HTMLElement | null;
78
+ if (p && (p.tagName === 'DIV' || p.tagName === 'SPAN') && p.id) {
79
+ id = p.id;
80
+ }
81
+ }
82
+ if (id) {
83
+ const forLabels = Array.from(
84
+ root.querySelectorAll<HTMLLabelElement>(`label[for="${CSS.escape(id)}"]`),
85
+ );
86
+ forLabels.forEach((l) => texts.push(l.textContent ?? ''));
87
+ }
88
+
89
+ // 3) aria-label
90
+ const ariaLabel = el.getAttribute('aria-label');
91
+ if (ariaLabel) texts.push(ariaLabel);
92
+
93
+ // 4) aria-labelledby
94
+ const labelledBy = el.getAttribute('aria-labelledby');
95
+ if (labelledBy) {
96
+ const extAriaLabel = labelledBy.split(/\s+/)
97
+ .map((idref) => byId.get(idref))
98
+ .filter((ref) => !!ref?.textContent.trim())
99
+ .map((ref) => ref!.textContent)
100
+ .join(' ');
101
+ if (extAriaLabel) texts.push(extAriaLabel);
102
+ }
103
+
104
+ // 5) field name
105
+ const name = el.getAttribute('name');
106
+ if (name) texts.push(name);
107
+
108
+ // 6) native placeholder on <input>/<textarea>, and some custom widgets mirror it as an attribute
109
+ const ph = (el as HTMLInputElement).placeholder ?? el.getAttribute('placeholder');
110
+ if (ph) texts.push(ph);
111
+
112
+ return texts;
113
+ }
114
+
115
+ // Filter candidates by label OR placeholder
116
+ return candidates
117
+ .map((el) => ({ el, texts: textFor(el) }))
118
+ .filter(({ texts }) => texts.some(match))
119
+ .map(({ el }) => el);
120
+ },
121
+ });
@@ -0,0 +1,2 @@
1
+ export * from './date-selector';
2
+ export * from './field-selector';
@@ -0,0 +1,55 @@
1
+ import { sleep } from '@letsrunit/utils';
2
+ import type { Page } from '@playwright/test';
3
+ import { screenshot } from './screenshot';
4
+ import type { Snapshot } from './types';
5
+ import { waitForDomIdle } from './wait';
6
+
7
+ export async function snapshot(page: Page): Promise<Snapshot> {
8
+ await sleep(500);
9
+ await waitForDomIdle(page);
10
+
11
+ const [url, html, file] = await Promise.all([page.url(), getContentWithMarkedHidden(page), screenshot(page)]);
12
+
13
+ return { url, html, screenshot: file };
14
+ }
15
+
16
+ async function getContentWithMarkedHidden(page: Page): Promise<string> {
17
+ try {
18
+ await page.evaluate(() => {
19
+ const changed: Element[] = [];
20
+
21
+ // expose undo
22
+ (window as any).__undoAriaHidden = () => {
23
+ for (const el of changed) el.removeAttribute('aria-hidden');
24
+ changed.length = 0;
25
+ delete (window as any).__undoAriaHidden;
26
+ };
27
+
28
+ const isHidden = (el: Element): boolean => {
29
+ if (el.hasAttribute('hidden')) return true;
30
+ if (el.hasAttribute('inert')) return true;
31
+ if (el.getAttribute('aria-hidden') === 'true') return true;
32
+
33
+ const cs = getComputedStyle(el as HTMLElement);
34
+ return cs.display === 'none' || cs.visibility === 'hidden' || cs.opacity === '0';
35
+ };
36
+
37
+ const walk = (el: Element) => {
38
+ if (isHidden(el)) {
39
+ if (!el.hasAttribute('aria-hidden')) {
40
+ el.setAttribute('aria-hidden', 'true');
41
+ changed.push(el);
42
+ }
43
+ return;
44
+ }
45
+ for (const c of el.children) walk(c);
46
+ };
47
+
48
+ for (const c of document.body.children) walk(c);
49
+ });
50
+
51
+ return await page.content();
52
+ } finally {
53
+ await page.evaluate(() => (window as any).__undoAriaHidden?.()).catch(() => {});
54
+ }
55
+ }