@letsrunit/playwright 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +18 -3
- package/dist/index.js +559 -381
- package/dist/index.js.map +1 -1
- package/package.json +3 -6
- package/src/browser.ts +2 -1
- package/src/field/aria-select.ts +3 -3
- package/src/field/calendar.ts +9 -8
- package/src/field/date-group.ts +12 -12
- package/src/field/date-text-input.ts +14 -10
- package/src/field/index.ts +3 -1
- package/src/field/native-date.ts +1 -1
- package/src/field/radio-group.ts +2 -2
- package/src/field/toggle.ts +2 -2
- package/src/fuzzy-locator.ts +24 -29
- package/src/index.ts +1 -0
- package/src/scrub-html.ts +42 -5
- package/src/snapshot.ts +16 -2
- package/src/suppress-interferences.ts +11 -3
- package/src/wait.ts +11 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@letsrunit/playwright",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Playwright extensions and utilities for letsrunit",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"testing",
|
|
@@ -38,13 +38,11 @@
|
|
|
38
38
|
],
|
|
39
39
|
"scripts": {
|
|
40
40
|
"build": "../../node_modules/.bin/tsup",
|
|
41
|
-
"test": "vitest run",
|
|
42
|
-
"test:cov": "vitest run --coverage",
|
|
43
41
|
"typecheck": "tsc --noEmit"
|
|
44
42
|
},
|
|
45
43
|
"packageManager": "yarn@4.10.3",
|
|
46
44
|
"dependencies": {
|
|
47
|
-
"@letsrunit/utils": "0.
|
|
45
|
+
"@letsrunit/utils": "0.8.0",
|
|
48
46
|
"@playwright/test": "^1.57.0",
|
|
49
47
|
"case": "^1.6.3",
|
|
50
48
|
"diff": "^8.0.3",
|
|
@@ -62,8 +60,7 @@
|
|
|
62
60
|
"unified": "^11.0.5"
|
|
63
61
|
},
|
|
64
62
|
"devDependencies": {
|
|
65
|
-
"@types/jsdom": "^27.0.0"
|
|
66
|
-
"vitest": "^4.0.17"
|
|
63
|
+
"@types/jsdom": "^27.0.0"
|
|
67
64
|
},
|
|
68
65
|
"module": "./dist/index.js",
|
|
69
66
|
"types": "./dist/index.d.ts",
|
package/src/browser.ts
CHANGED
|
@@ -11,7 +11,8 @@ export async function browse(browser: Browser, options: BrowserContextOptions =
|
|
|
11
11
|
});
|
|
12
12
|
|
|
13
13
|
// Safety net against bundler-injected helpers inside page.evaluate
|
|
14
|
-
|
|
14
|
+
// v8 ignore next — callback runs in browser context, not Node
|
|
15
|
+
await context.addInitScript(/* v8 ignore next */ () => {
|
|
15
16
|
// define __name as a no-op if present
|
|
16
17
|
(window as any).__name = (window as any).__name || ((fn: any) => fn);
|
|
17
18
|
});
|
package/src/field/aria-select.ts
CHANGED
|
@@ -3,13 +3,13 @@ import type { Loc, SetOptions, Value } from './types';
|
|
|
3
3
|
export async function selectAria({ el }: Loc, value: Value, options?: SetOptions): Promise<boolean> {
|
|
4
4
|
if (typeof value !== 'string' && typeof value !== 'number') return false;
|
|
5
5
|
|
|
6
|
-
const role = await el.getAttribute('role', options).catch(() => null);
|
|
6
|
+
const role = await el.getAttribute('role', options).catch(/* v8 ignore next */ () => null);
|
|
7
7
|
if (role !== 'combobox') return false;
|
|
8
8
|
|
|
9
|
-
const ariaControls = await el.getAttribute('aria-controls', options).catch(() => null);
|
|
9
|
+
const ariaControls = await el.getAttribute('aria-controls', options).catch(/* v8 ignore next */ () => null);
|
|
10
10
|
if (!ariaControls) return false;
|
|
11
11
|
|
|
12
|
-
const ariaExpanded = await el.getAttribute('aria-expanded', options).catch(() => null);
|
|
12
|
+
const ariaExpanded = await el.getAttribute('aria-expanded', options).catch(/* v8 ignore next */ () => null);
|
|
13
13
|
if (ariaExpanded !== 'true') await el.click(options);
|
|
14
14
|
|
|
15
15
|
const stringValue = String(value);
|
package/src/field/calendar.ts
CHANGED
|
@@ -52,9 +52,11 @@ export async function getCalendar(
|
|
|
52
52
|
(await root.locator(gridSelector).count()) > 0 ? root : await getDialog(root, options);
|
|
53
53
|
|
|
54
54
|
// Fallback: Check if the root itself is a valid grid (e.g. inline MUI DateCalendar)
|
|
55
|
+
/* v8 ignore start */
|
|
55
56
|
if (!container && (await isCalendarGrid(root))) {
|
|
56
57
|
return { calendar: root, tables: [root] };
|
|
57
58
|
}
|
|
59
|
+
/* v8 ignore stop */
|
|
58
60
|
if (!container) return null;
|
|
59
61
|
|
|
60
62
|
// 2. Find all valid calendar grids within the container
|
|
@@ -63,9 +65,8 @@ export async function getCalendar(
|
|
|
63
65
|
const tables: Locator[] = [];
|
|
64
66
|
|
|
65
67
|
// If the container itself matches the selector, check it first
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
68
|
+
/* v8 ignore next */
|
|
69
|
+
if (await isCalendarGrid(container)) tables.push(container);
|
|
69
70
|
|
|
70
71
|
for (const grid of found) {
|
|
71
72
|
if (await isCalendarGrid(grid)) {
|
|
@@ -98,11 +99,7 @@ function inferYearForMonth(target: Date, month: number): number {
|
|
|
98
99
|
}
|
|
99
100
|
|
|
100
101
|
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);
|
|
102
|
+
const lang = await root.page().locator('html').getAttribute('lang').catch(() => undefined);
|
|
106
103
|
const locales = lang && !lang.startsWith('en') ? [lang, 'en-US'] : ['en-US'];
|
|
107
104
|
|
|
108
105
|
const text = await root.innerText();
|
|
@@ -187,12 +184,14 @@ async function navigateToMonth(
|
|
|
187
184
|
const afterMonths = await getCurrentMonthsAndYears(root, target);
|
|
188
185
|
if (afterMonths.some((m) => m.year * 12 + m.month === targetTotal)) return true;
|
|
189
186
|
|
|
187
|
+
/* v8 ignore start */
|
|
190
188
|
if ((options?.retry ?? 0) < 3) {
|
|
191
189
|
const retry = (options?.retry ?? 0) + 1;
|
|
192
190
|
return await navigateToMonth(root, target, { ...options, wait: 50 + 50 * retry, retry }); // Retry max 3x
|
|
193
191
|
}
|
|
194
192
|
|
|
195
193
|
return false;
|
|
194
|
+
/* v8 ignore stop */
|
|
196
195
|
}
|
|
197
196
|
|
|
198
197
|
async function setDayByAriaLabel(table: Locator, value: Date, options?: SetOptions): Promise<boolean> {
|
|
@@ -210,10 +209,12 @@ async function setDayByAriaLabel(table: Locator, value: Date, options?: SetOptio
|
|
|
210
209
|
|
|
211
210
|
if (cells.length === 0) return false;
|
|
212
211
|
|
|
212
|
+
/* v8 ignore start */
|
|
213
213
|
if (cells.length === 1) {
|
|
214
214
|
await cells[0].click(options);
|
|
215
215
|
return true;
|
|
216
216
|
}
|
|
217
|
+
/* v8 ignore stop */
|
|
217
218
|
|
|
218
219
|
// Disambiguate using day 22 in case of dd/mm/yyyy vs mm/dd/yyyy
|
|
219
220
|
const probeDate = new Date(value.getFullYear(), value.getMonth(), 22);
|
package/src/field/date-group.ts
CHANGED
|
@@ -25,7 +25,7 @@ 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((node) => {
|
|
28
|
+
const info = await c.evaluate(/* v8 ignore start */ (node) => {
|
|
29
29
|
const e = node as HTMLInputElement | HTMLSelectElement;
|
|
30
30
|
const attrs: Record<string, string> = {};
|
|
31
31
|
for (const attr of e.attributes) {
|
|
@@ -52,7 +52,7 @@ async function getCandidateLocs(el: Locator, options?: SetOptions): Promise<Cand
|
|
|
52
52
|
inputMode: e.getAttribute('inputmode'),
|
|
53
53
|
attrs,
|
|
54
54
|
options,
|
|
55
|
-
};
|
|
55
|
+
}; /* v8 ignore stop */
|
|
56
56
|
}, options);
|
|
57
57
|
return { el: c, ...info };
|
|
58
58
|
}),
|
|
@@ -100,53 +100,53 @@ async function behavioralProbe(candidateLocs: CandidateInfo[], scores: Score[],
|
|
|
100
100
|
for (let i = 0; i < candidateLocs.length; i++) {
|
|
101
101
|
const loc = candidateLocs[i];
|
|
102
102
|
if (loc.tag === 'input') {
|
|
103
|
-
const can_be_day = await loc.el.evaluate((node) => {
|
|
103
|
+
const can_be_day = await loc.el.evaluate(/* v8 ignore start */ (node) => {
|
|
104
104
|
const e = node as HTMLInputElement;
|
|
105
105
|
const old = e.value;
|
|
106
106
|
e.value = '31';
|
|
107
107
|
const valid = e.checkValidity();
|
|
108
108
|
e.value = old;
|
|
109
|
-
return valid;
|
|
109
|
+
return valid; /* v8 ignore stop */
|
|
110
110
|
}, options);
|
|
111
111
|
if (can_be_day) scores[i].day += 1;
|
|
112
112
|
|
|
113
|
-
const cannot_be_day = await loc.el.evaluate((node) => {
|
|
113
|
+
const cannot_be_day = await loc.el.evaluate(/* v8 ignore start */ (node) => {
|
|
114
114
|
const e = node as HTMLInputElement;
|
|
115
115
|
const old = e.value;
|
|
116
116
|
e.value = '32';
|
|
117
117
|
const valid = !e.checkValidity();
|
|
118
118
|
e.value = old;
|
|
119
|
-
return valid;
|
|
119
|
+
return valid; /* v8 ignore stop */
|
|
120
120
|
}, options);
|
|
121
121
|
if (cannot_be_day) scores[i].day += 1;
|
|
122
122
|
|
|
123
|
-
const can_be_month = await loc.el.evaluate((node) => {
|
|
123
|
+
const can_be_month = await loc.el.evaluate(/* v8 ignore start */ (node) => {
|
|
124
124
|
const e = node as HTMLInputElement;
|
|
125
125
|
const old = e.value;
|
|
126
126
|
e.value = '12';
|
|
127
127
|
const valid = e.checkValidity();
|
|
128
128
|
e.value = old;
|
|
129
|
-
return valid;
|
|
129
|
+
return valid; /* v8 ignore stop */
|
|
130
130
|
}, options);
|
|
131
131
|
if (can_be_month) scores[i].month += 1;
|
|
132
132
|
|
|
133
|
-
const cannot_be_month = await loc.el.evaluate((node) => {
|
|
133
|
+
const cannot_be_month = await loc.el.evaluate(/* v8 ignore start */ (node) => {
|
|
134
134
|
const e = node as HTMLInputElement;
|
|
135
135
|
const old = e.value;
|
|
136
136
|
e.value = '13';
|
|
137
137
|
const valid = !e.checkValidity();
|
|
138
138
|
e.value = old;
|
|
139
|
-
return valid;
|
|
139
|
+
return valid; /* v8 ignore stop */
|
|
140
140
|
}, options);
|
|
141
141
|
if (cannot_be_month) scores[i].month += 1;
|
|
142
142
|
|
|
143
|
-
const can_be_year = await loc.el.evaluate((node) => {
|
|
143
|
+
const can_be_year = await loc.el.evaluate(/* v8 ignore start */ (node) => {
|
|
144
144
|
const e = node as HTMLInputElement;
|
|
145
145
|
const old = e.value;
|
|
146
146
|
e.value = '2024';
|
|
147
147
|
const valid = e.checkValidity();
|
|
148
148
|
e.value = old;
|
|
149
|
-
return valid;
|
|
149
|
+
return valid; /* v8 ignore stop */
|
|
150
150
|
}, options);
|
|
151
151
|
if (can_be_year) scores[i].year += 1;
|
|
152
152
|
}
|
|
@@ -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
|
-
if (!raw) return null;
|
|
27
|
+
/* v8 ignore next */ if (!raw) return null;
|
|
28
28
|
|
|
29
29
|
const tokens = raw.split(sep);
|
|
30
|
-
if (tokens.length !== 3) return null;
|
|
30
|
+
/* v8 ignore next */ if (tokens.length !== 3) return null;
|
|
31
31
|
|
|
32
32
|
const nums = tokens.map((t) => Number(t));
|
|
33
|
-
if (nums.some((n) => !Number.isFinite(n))) return null;
|
|
33
|
+
/* v8 ignore next */ 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,11 +39,11 @@ 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
|
-
if (month < 1 || month > 12) return null;
|
|
43
|
-
if (day < 1 || day > 31) return null;
|
|
42
|
+
/* v8 ignore next */ if (month < 1 || month > 12) return null;
|
|
43
|
+
/* v8 ignore next */ if (day < 1 || day > 31) return null;
|
|
44
44
|
|
|
45
45
|
const dt = new Date(year, month - 1, day);
|
|
46
|
-
if (dt.getFullYear() !== year || dt.getMonth() !== month - 1 || dt.getDate() !== day) return null;
|
|
46
|
+
/* v8 ignore next */ if (dt.getFullYear() !== year || dt.getMonth() !== month - 1 || dt.getDate() !== day) return null;
|
|
47
47
|
|
|
48
48
|
return dt;
|
|
49
49
|
}
|
|
@@ -53,7 +53,7 @@ async function inferLocaleAndPattern(el: Locator, options?: SetOptions): Promise
|
|
|
53
53
|
order: DateOrder;
|
|
54
54
|
sep: string;
|
|
55
55
|
}> {
|
|
56
|
-
return el.evaluate(() => {
|
|
56
|
+
return el.evaluate(/* v8 ignore start */ () => {
|
|
57
57
|
const lang = document.documentElement.getAttribute('lang') || navigator.language || 'en-US';
|
|
58
58
|
const dtf = new Intl.DateTimeFormat(lang, { year: 'numeric', month: '2-digit', day: '2-digit' });
|
|
59
59
|
|
|
@@ -72,7 +72,7 @@ async function inferLocaleAndPattern(el: Locator, options?: SetOptions): Promise
|
|
|
72
72
|
// Fallback if Intl returns something odd
|
|
73
73
|
const finalOrder = order.length === 3 ? order : (['day', 'month', 'year'] as const);
|
|
74
74
|
|
|
75
|
-
return { locale: lang, order: finalOrder as any, sep };
|
|
75
|
+
return { locale: lang, order: finalOrder as any, sep }; /* v8 ignore stop */
|
|
76
76
|
}, options);
|
|
77
77
|
}
|
|
78
78
|
|
|
@@ -83,11 +83,11 @@ async function fillAndReadBack(el: Locator, s: string, options?: SetOptions, nex
|
|
|
83
83
|
if (nextInput) {
|
|
84
84
|
await nextInput.focus(options);
|
|
85
85
|
} else {
|
|
86
|
-
await el.evaluate((el) => el.blur(), options);
|
|
86
|
+
await el.evaluate(/* v8 ignore next */ (el) => el.blur(), options);
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
// Some frameworks need a tiny tick.
|
|
90
|
-
await el.evaluate(() => new Promise(requestAnimationFrame));
|
|
90
|
+
await el.evaluate(/* v8 ignore next */ () => new Promise(requestAnimationFrame));
|
|
91
91
|
|
|
92
92
|
return await el.inputValue(options);
|
|
93
93
|
}
|
|
@@ -199,8 +199,10 @@ async function setInputValue(
|
|
|
199
199
|
value2?: Date,
|
|
200
200
|
options?: SetOptions,
|
|
201
201
|
): Promise<boolean> {
|
|
202
|
+
/* v8 ignore start */
|
|
202
203
|
if (await el.evaluate((el) => (el as HTMLInputElement).readOnly, options)) return false;
|
|
203
204
|
if (el2 && (await el2.evaluate((el) => (el as HTMLInputElement).readOnly, options))) return false;
|
|
205
|
+
/* v8 ignore stop */
|
|
204
206
|
|
|
205
207
|
const { combinations, localeSep } = await formatCombinations(el, options);
|
|
206
208
|
let fallbackMatch: [DateOrder, string, boolean] | null = null;
|
|
@@ -234,10 +236,12 @@ async function setInputValue(
|
|
|
234
236
|
if (fallbackMatch) {
|
|
235
237
|
const [order, sep, pad] = fallbackMatch;
|
|
236
238
|
const success = await setDateValue(el, value, order, sep, pad, options, el2);
|
|
239
|
+
/* v8 ignore start */
|
|
237
240
|
if (el2 && value2) {
|
|
238
241
|
const success2 = await setDateValue(el2, value2, order, sep, pad, options);
|
|
239
242
|
return success && success2;
|
|
240
243
|
}
|
|
244
|
+
/* v8 ignore stop */
|
|
241
245
|
return success;
|
|
242
246
|
}
|
|
243
247
|
|
package/src/field/index.ts
CHANGED
|
@@ -46,15 +46,17 @@ export async function setFieldValue(el: Locator, value: Value, options?: SetOpti
|
|
|
46
46
|
setFallback,
|
|
47
47
|
);
|
|
48
48
|
|
|
49
|
+
/* v8 ignore start */
|
|
49
50
|
if ((await el.count()) > 1) {
|
|
50
51
|
el = await pickFieldElement(el);
|
|
51
52
|
}
|
|
53
|
+
/* v8 ignore stop */
|
|
52
54
|
|
|
53
55
|
const tag = await el.evaluate((e) => e.tagName.toLowerCase(), options);
|
|
54
56
|
const type = await el
|
|
55
57
|
.getAttribute('type', options)
|
|
56
58
|
.then((s) => s && s.toLowerCase())
|
|
57
|
-
.catch(() => null);
|
|
59
|
+
.catch(/* v8 ignore next */ () => null);
|
|
58
60
|
const loc = { el, tag, type };
|
|
59
61
|
|
|
60
62
|
await setValue(loc, value, options);
|
package/src/field/native-date.ts
CHANGED
|
@@ -11,7 +11,7 @@ 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((e) => {
|
|
14
|
+
const isVisible = await inputs.evaluate(/* v8 ignore next */ (e) => {
|
|
15
15
|
const style = window.getComputedStyle(e);
|
|
16
16
|
return style.display !== 'none' && style.visibility !== 'hidden' && e.getAttribute('type') !== 'hidden';
|
|
17
17
|
});
|
package/src/field/radio-group.ts
CHANGED
|
@@ -28,7 +28,7 @@ export async function setRadioGroup(
|
|
|
28
28
|
const ariaRadio = el.locator(`[role="radio"][value="${stringValue}"]`);
|
|
29
29
|
if ((await ariaRadio.count()) >= 1) {
|
|
30
30
|
const item = ariaRadio.first();
|
|
31
|
-
const ariaChecked = await item.getAttribute('aria-checked', options).catch(() => null);
|
|
31
|
+
const ariaChecked = await item.getAttribute('aria-checked', options).catch(/* v8 ignore next */ () => null);
|
|
32
32
|
if (ariaChecked !== 'true') await item.click(options);
|
|
33
33
|
return true;
|
|
34
34
|
}
|
|
@@ -37,7 +37,7 @@ export async function setRadioGroup(
|
|
|
37
37
|
const ariaRadioByLabel = el.getByLabel(stringValue, { exact: true }).locator('[role="radio"]');
|
|
38
38
|
if ((await ariaRadioByLabel.count()) >= 1) {
|
|
39
39
|
const item = ariaRadioByLabel.first();
|
|
40
|
-
const ariaChecked = await item.getAttribute('aria-checked', options).catch(() => null);
|
|
40
|
+
const ariaChecked = await item.getAttribute('aria-checked', options).catch(/* v8 ignore next */ () => null);
|
|
41
41
|
if (ariaChecked !== 'true') await item.click(options);
|
|
42
42
|
return true;
|
|
43
43
|
}
|
package/src/field/toggle.ts
CHANGED
|
@@ -3,10 +3,10 @@ import type { Loc, SetOptions, Value } from './types';
|
|
|
3
3
|
export async function setToggle({ el }: Loc, value: Value, options?: SetOptions): Promise<boolean> {
|
|
4
4
|
if (typeof value !== 'boolean' && value !== null) return false;
|
|
5
5
|
|
|
6
|
-
const role = await el.getAttribute('role', options).catch(() => null);
|
|
6
|
+
const role = await el.getAttribute('role', options).catch(/* v8 ignore next */ () => null);
|
|
7
7
|
if (role !== 'checkbox' && role !== 'switch') return false;
|
|
8
8
|
|
|
9
|
-
const ariaChecked = await el.getAttribute('aria-checked', options).catch(() => null);
|
|
9
|
+
const ariaChecked = await el.getAttribute('aria-checked', options).catch(/* v8 ignore next */ () => null);
|
|
10
10
|
const isChecked = ariaChecked === 'true';
|
|
11
11
|
|
|
12
12
|
if (Boolean(value) !== isChecked) await el.click(options);
|
package/src/fuzzy-locator.ts
CHANGED
|
@@ -1,31 +1,26 @@
|
|
|
1
1
|
import { Locator, Page } from '@playwright/test';
|
|
2
2
|
|
|
3
|
-
type LocatorOptions = Parameters<Page['locator']>[1];
|
|
4
|
-
|
|
5
3
|
/**
|
|
6
|
-
* Locates an element using Playwright selectors, with fallbacks.
|
|
4
|
+
* Locates an element using Playwright selectors, with lazy fallbacks.
|
|
7
5
|
*/
|
|
8
6
|
export async function fuzzyLocator(page: Page, selector: string): Promise<Locator> {
|
|
9
|
-
const primary = page.locator(selector)
|
|
10
|
-
|
|
7
|
+
const primary = page.locator(selector);
|
|
8
|
+
const candidates = [
|
|
9
|
+
tryRelaxNameToHasText(page, selector),
|
|
10
|
+
tryTagInsteadOfRole(page, selector),
|
|
11
|
+
tryRoleNameProximity(page, selector),
|
|
12
|
+
tryFieldAlternative(page, selector),
|
|
13
|
+
tryAsField(page, selector),
|
|
14
|
+
];
|
|
11
15
|
|
|
12
|
-
|
|
13
|
-
(await tryRelaxNameToHasText(page, selector)) ||
|
|
14
|
-
(await tryTagInsteadOfRole(page, selector)) ||
|
|
15
|
-
(await tryRoleNameProximity(page, selector)) ||
|
|
16
|
-
(await tryFieldAlternative(page, selector)) ||
|
|
17
|
-
(await tryAsField(page, selector)) ||
|
|
18
|
-
primary
|
|
19
|
-
); // Nothing found, return the original locator (so caller can still wait/assert)
|
|
20
|
-
}
|
|
16
|
+
let combined = primary;
|
|
21
17
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
if (await loc.count()) return loc;
|
|
18
|
+
for (const candidate of candidates) {
|
|
19
|
+
if (!candidate) continue;
|
|
20
|
+
combined = combined.or(candidate);
|
|
26
21
|
}
|
|
27
22
|
|
|
28
|
-
return
|
|
23
|
+
return combined.first();
|
|
29
24
|
}
|
|
30
25
|
|
|
31
26
|
// Preserve the selector but relax [name="..."] to [has-text="..."]
|
|
@@ -34,51 +29,51 @@ async function firstMatch(page: Page, sel: string | string[], opts: LocatorOptio
|
|
|
34
29
|
// - role=link[name="Foo"] → role=link:has-text="Foo"
|
|
35
30
|
// - css=button[name="Save"i]:visible → css=button:visible:has-text="Save"
|
|
36
31
|
// - [name="Hello"] → :has-text="Hello"
|
|
37
|
-
|
|
32
|
+
function tryRelaxNameToHasText(page: Page, selector: string): Locator | null {
|
|
38
33
|
const matchAnyNameFull = selector.match(/^(role=.*)\[name="([^"]+)"i?](.*)$/i);
|
|
39
34
|
if (!matchAnyNameFull) return null;
|
|
40
35
|
const [, pre, nameText, post] = matchAnyNameFull;
|
|
41
36
|
const containsSelector = `${pre}${post}`;
|
|
42
|
-
return
|
|
37
|
+
return page.locator(containsSelector, { hasText: nameText });
|
|
43
38
|
}
|
|
44
39
|
|
|
45
40
|
// Try using the tag name for `link`, `button` and `option` instead fo the aria role.
|
|
46
41
|
// This keeps all other parts of the selector intact (prefix/suffix, additional filters).
|
|
47
42
|
// Examples:
|
|
48
43
|
// - role=button[name="Foo"] → css=button:has-text="Save"
|
|
49
|
-
|
|
44
|
+
function tryTagInsteadOfRole(page: Page, selector: string): Locator | null {
|
|
50
45
|
const matchAnyNameFull = selector.match(/^role=(link|button|option)\s*\[name="([^"]+)"i?](.*)$/i);
|
|
51
46
|
if (!matchAnyNameFull) return null;
|
|
52
47
|
const [, role, nameText, post] = matchAnyNameFull;
|
|
53
48
|
const tag = role === 'link' ? 'a' : role;
|
|
54
49
|
const containsSelector = `css=${tag}${post}`;
|
|
55
|
-
return
|
|
50
|
+
return page.locator(containsSelector, { hasText: nameText });
|
|
56
51
|
}
|
|
57
52
|
|
|
58
53
|
// If a role selector with a name filter fails, try proximity-based fallback while
|
|
59
54
|
// preserving the role and any remainder of the selector.
|
|
60
55
|
// Example: role=switch[name="Adres tonen"i] → text=Adres tonen >> .. >> role=switch
|
|
61
|
-
|
|
56
|
+
function tryRoleNameProximity(page: Page, selector: string): Locator | null {
|
|
62
57
|
const matchRole = selector.match(/^role=(\w+)\s*\[name="([^"]+)"i?](.*)$/i);
|
|
63
58
|
if (!matchRole) return null;
|
|
64
59
|
const [, role, name, rest] = matchRole;
|
|
65
60
|
const proximitySelector = `text=${name} >> .. >> role=${role}${rest}`;
|
|
66
|
-
return
|
|
61
|
+
return page.locator(proximitySelector);
|
|
67
62
|
}
|
|
68
63
|
|
|
69
64
|
// Try alternatives if field is not found
|
|
70
65
|
// field="foo" → #foo > input (only when name is a valid CSS identifier)
|
|
71
|
-
|
|
66
|
+
function tryFieldAlternative(page: Page, selector: string): Locator | null {
|
|
72
67
|
const matchField = selector.match(/^field="([^"]+)"i?$/i);
|
|
73
68
|
if (!matchField) return null;
|
|
74
69
|
const [, field] = matchField;
|
|
75
70
|
// Skip if the name contains characters invalid in a CSS ID selector
|
|
76
71
|
if (!/^[a-zA-Z0-9_-]+$/.test(field)) return null;
|
|
77
|
-
return
|
|
72
|
+
return page.locator(`#${field} > input`);
|
|
78
73
|
}
|
|
79
74
|
|
|
80
75
|
// Try matching using the field selector in case of role mismatch
|
|
81
|
-
|
|
76
|
+
function tryAsField(page: Page, selector: string): Locator | null {
|
|
82
77
|
const matchRole = selector.match(/^role=(\w+)\s*\[name="([^"]+)"i?](.*)$/i);
|
|
83
78
|
if (!matchRole) return null;
|
|
84
79
|
|
|
@@ -102,5 +97,5 @@ async function tryAsField(page: Page, selector: string): Promise<Locator | null>
|
|
|
102
97
|
|
|
103
98
|
if (!fieldRoles.has(role.toLowerCase())) return null;
|
|
104
99
|
|
|
105
|
-
return
|
|
100
|
+
return page.locator(`field=${name}${rest}`);
|
|
106
101
|
}
|
package/src/index.ts
CHANGED
package/src/scrub-html.ts
CHANGED
|
@@ -31,6 +31,9 @@ export type ScrubHtmlOptions = {
|
|
|
31
31
|
replaceBrInHeadings?: boolean;
|
|
32
32
|
/** Limit lists to max items: -1 mean no limit. Default: -1 */
|
|
33
33
|
limitLists?: number;
|
|
34
|
+
/** Strip utility-framework classes (Tailwind, Bootstrap, UnoCSS, Windi) from class
|
|
35
|
+
* attributes. Removes the attribute entirely when all classes are stripped. Default: false */
|
|
36
|
+
dropUtilityClasses?: boolean;
|
|
34
37
|
};
|
|
35
38
|
|
|
36
39
|
const HTML_MIN_ATTR_THRESHOLD = 250_000; // ~70k tokens
|
|
@@ -48,6 +51,7 @@ function getDefaults(contentLength: number): Required<ScrubHtmlOptions> {
|
|
|
48
51
|
dropComments: true,
|
|
49
52
|
replaceBrInHeadings: true,
|
|
50
53
|
limitLists: contentLength >= HTML_LIMIT_LISTS_THRESHOLD ? 20 : -1,
|
|
54
|
+
dropUtilityClasses: false,
|
|
51
55
|
};
|
|
52
56
|
}
|
|
53
57
|
|
|
@@ -183,6 +187,7 @@ export async function realScrubHtml(
|
|
|
183
187
|
if (o.dropComments) dropHtmlComments(doc);
|
|
184
188
|
if (o.replaceBrInHeadings) replaceBrsInHeadings(doc);
|
|
185
189
|
if (o.limitLists >= 0) limitListsAndRows(doc, o.limitLists);
|
|
190
|
+
if (o.dropUtilityClasses) stripUtilityClasses(doc);
|
|
186
191
|
if (o.normalizeWhitespace) normalizeWhitespace(doc.body);
|
|
187
192
|
|
|
188
193
|
return doc.body.innerHTML;
|
|
@@ -193,11 +198,8 @@ export async function realScrubHtml(
|
|
|
193
198
|
function hasHiddenAncestor(el: Element): boolean {
|
|
194
199
|
let p: Element | null = el.parentElement;
|
|
195
200
|
while (p) {
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
p.hasAttribute('inert') ||
|
|
199
|
-
p.getAttribute('aria-hidden') === 'true'
|
|
200
|
-
) return true;
|
|
201
|
+
/* v8 ignore next */
|
|
202
|
+
if (p.hasAttribute('hidden') || p.hasAttribute('inert') || p.getAttribute('aria-hidden') === 'true') return true;
|
|
201
203
|
|
|
202
204
|
const style = p.getAttribute('style') || '';
|
|
203
205
|
if (/\bdisplay\s*:\s*none\b/i.test(style)) return true;
|
|
@@ -317,6 +319,41 @@ function replaceBrsInHeadings(doc: Document) {
|
|
|
317
319
|
});
|
|
318
320
|
}
|
|
319
321
|
|
|
322
|
+
// ---- Utility class detection ----
|
|
323
|
+
|
|
324
|
+
// Any class containing ':' is a Tailwind/Windi/UnoCSS variant prefix (hover:, sm:, dark:focus:, …)
|
|
325
|
+
const UTILITY_VARIANT_RE = /:/;
|
|
326
|
+
|
|
327
|
+
// Prefix-based utility patterns (Tailwind + Bootstrap)
|
|
328
|
+
const UTILITY_PREFIX_RE = /^-?(?:p[xytblrse]?|m[xytblrse]?|gap|space-[xy]|w|h|min-w|min-h|max-w|max-h|size|basis|inset|top|right|bottom|left|start|end|z|text|bg|border|ring|shadow|outline|fill|stroke|divide|accent|caret|from|via|to|decoration|font|leading|tracking|indent|line-clamp|columns|aspect|object|opacity|rotate|scale|translate|skew|transition|duration|ease|delay|animate|rounded|overflow|overscroll|scroll|snap|touch|cursor|pointer-events|select|resize|flex|grid|col|row|order|auto-cols|auto-rows|items|justify|content|self|place|float|clear|list|whitespace|break|hyphens|mix-blend|bg-blend|backdrop|d|g|fs|fw|lh|align|position)-/i;
|
|
329
|
+
|
|
330
|
+
// Standalone keywords that are utilities on their own (no suffix)
|
|
331
|
+
const UTILITY_STANDALONE = new Set([
|
|
332
|
+
'flex', 'grid', 'block', 'hidden', 'inline', 'inline-block', 'inline-flex', 'inline-grid',
|
|
333
|
+
'contents', 'flow-root', 'list-item', 'table', 'container', 'truncate',
|
|
334
|
+
'grow', 'shrink', 'static', 'relative', 'absolute', 'fixed', 'sticky',
|
|
335
|
+
'visible', 'invisible', 'collapse', 'isolate',
|
|
336
|
+
'underline', 'overline', 'line-through', 'no-underline',
|
|
337
|
+
'uppercase', 'lowercase', 'capitalize', 'normal-case',
|
|
338
|
+
'italic', 'not-italic', 'antialiased', 'subpixel-antialiased',
|
|
339
|
+
'sr-only', 'not-sr-only', 'clearfix', 'row', 'col',
|
|
340
|
+
]);
|
|
341
|
+
|
|
342
|
+
function isUtilityClass(token: string): boolean {
|
|
343
|
+
if (UTILITY_VARIANT_RE.test(token)) return true;
|
|
344
|
+
const base = token.startsWith('-') ? token.slice(1) : token;
|
|
345
|
+
if (UTILITY_STANDALONE.has(base)) return true;
|
|
346
|
+
return UTILITY_PREFIX_RE.test(token);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function stripUtilityClasses(doc: Document) {
|
|
350
|
+
for (const el of doc.body.querySelectorAll<HTMLElement>('[class]')) {
|
|
351
|
+
const kept = el.className.split(/\s+/).filter((t) => t && !isUtilityClass(t));
|
|
352
|
+
if (kept.length === 0) el.removeAttribute('class');
|
|
353
|
+
else el.className = kept.join(' ');
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
320
357
|
function limitListsAndRows(doc: Document, limit: number) {
|
|
321
358
|
// lists
|
|
322
359
|
doc.querySelectorAll('ul, ol').forEach((list) => {
|
package/src/snapshot.ts
CHANGED
|
@@ -1,16 +1,30 @@
|
|
|
1
1
|
import { sleep } from '@letsrunit/utils';
|
|
2
2
|
import type { Page } from '@playwright/test';
|
|
3
3
|
import { screenshot } from './screenshot';
|
|
4
|
+
import { realScrubHtml } from './scrub-html';
|
|
4
5
|
import type { Snapshot } from './types';
|
|
5
6
|
import { waitForDomIdle } from './wait';
|
|
6
7
|
|
|
7
|
-
export
|
|
8
|
+
export type SnapshotOptions = {
|
|
9
|
+
/** Strip utility-framework classes (Tailwind, Bootstrap, UnoCSS, Windi) from the captured HTML. */
|
|
10
|
+
dropUtilityClasses?: boolean;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export async function snapshot(page: Page, opts: SnapshotOptions = {}): Promise<Snapshot> {
|
|
8
14
|
await sleep(500);
|
|
9
15
|
await waitForDomIdle(page);
|
|
10
16
|
|
|
11
17
|
const [url, html, file] = await Promise.all([page.url(), getContentWithMarkedHidden(page), screenshot(page)]);
|
|
12
18
|
|
|
13
|
-
|
|
19
|
+
const finalHtml = opts.dropUtilityClasses
|
|
20
|
+
? await realScrubHtml({ html, url }, {
|
|
21
|
+
dropHidden: false, dropHead: false, dropSvg: false, pickMain: false,
|
|
22
|
+
stripAttributes: 0, normalizeWhitespace: false, dropComments: false,
|
|
23
|
+
replaceBrInHeadings: false, limitLists: -1, dropUtilityClasses: true,
|
|
24
|
+
})
|
|
25
|
+
: html;
|
|
26
|
+
|
|
27
|
+
return { url, html: finalHtml, screenshot: file };
|
|
14
28
|
}
|
|
15
29
|
|
|
16
30
|
async function getContentWithMarkedHidden(page: Page): Promise<string> {
|