@letsrunit/playwright 0.7.0 → 0.7.1
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 +1 -1
- package/dist/index.js +221 -127
- 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/scrub-html.ts +2 -5
- 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.7.
|
|
3
|
+
"version": "0.7.1",
|
|
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.7.
|
|
45
|
+
"@letsrunit/utils": "0.7.1",
|
|
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/scrub-html.ts
CHANGED
|
@@ -193,11 +193,8 @@ export async function realScrubHtml(
|
|
|
193
193
|
function hasHiddenAncestor(el: Element): boolean {
|
|
194
194
|
let p: Element | null = el.parentElement;
|
|
195
195
|
while (p) {
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
p.hasAttribute('inert') ||
|
|
199
|
-
p.getAttribute('aria-hidden') === 'true'
|
|
200
|
-
) return true;
|
|
196
|
+
/* v8 ignore next */
|
|
197
|
+
if (p.hasAttribute('hidden') || p.hasAttribute('inert') || p.getAttribute('aria-hidden') === 'true') return true;
|
|
201
198
|
|
|
202
199
|
const style = p.getAttribute('style') || '';
|
|
203
200
|
if (/\bdisplay\s*:\s*none\b/i.test(style)) return true;
|
|
@@ -79,7 +79,7 @@ async function tryClick(page: Page, selectors: string[], _label: string) {
|
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
async function closeNativeJsAlerts(page: Page) {
|
|
82
|
-
page.on('dialog', (d) => d.accept().catch(() => {}));
|
|
82
|
+
page.on('dialog', /* v8 ignore next */ (d) => d.accept().catch(() => {}));
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
// 1) Known cookie CMPs (quick wins)
|
|
@@ -155,6 +155,7 @@ async function sweepNewsletter(page: Page, regex: TrRegExps): Promise<boolean> {
|
|
|
155
155
|
} catch {}
|
|
156
156
|
}
|
|
157
157
|
// click outside the modal (overlay)
|
|
158
|
+
/* v8 ignore start */
|
|
158
159
|
const overlay = page.locator('div[role="presentation"], .modal-backdrop, .overlay, .ReactModal__Overlay');
|
|
159
160
|
if (await overlay.count().catch(() => 0)) {
|
|
160
161
|
try {
|
|
@@ -162,6 +163,7 @@ async function sweepNewsletter(page: Page, regex: TrRegExps): Promise<boolean> {
|
|
|
162
163
|
return true;
|
|
163
164
|
} catch {}
|
|
164
165
|
}
|
|
166
|
+
/* v8 ignore stop */
|
|
165
167
|
}
|
|
166
168
|
return false;
|
|
167
169
|
}
|
|
@@ -173,6 +175,7 @@ async function sweepOverlays(page: Page, regex: TrRegExps): Promise<boolean> {
|
|
|
173
175
|
|
|
174
176
|
return await page
|
|
175
177
|
.evaluate(
|
|
178
|
+
/* v8 ignore start */
|
|
176
179
|
([source, flags]) => {
|
|
177
180
|
const acceptRx = new RegExp(source, flags);
|
|
178
181
|
const isBig = (el: Element) => {
|
|
@@ -192,7 +195,7 @@ async function sweepOverlays(page: Page, regex: TrRegExps): Promise<boolean> {
|
|
|
192
195
|
for (const el of candidates) {
|
|
193
196
|
// try a close button inside
|
|
194
197
|
const btn = el.querySelector<HTMLElement>(
|
|
195
|
-
'[aria-label
|
|
198
|
+
'[aria-label*=”close” i], button[aria-label*=”close” i], button:has(svg), .close, [data-close], .btn-close',
|
|
196
199
|
);
|
|
197
200
|
|
|
198
201
|
if (btn) {
|
|
@@ -221,9 +224,10 @@ async function sweepOverlays(page: Page, regex: TrRegExps): Promise<boolean> {
|
|
|
221
224
|
}
|
|
222
225
|
return false;
|
|
223
226
|
},
|
|
227
|
+
/* v8 ignore stop */
|
|
224
228
|
[acceptRxSource, acceptRxFlags],
|
|
225
229
|
)
|
|
226
|
-
.catch(() => false);
|
|
230
|
+
.catch(/* v8 ignore next */ () => false);
|
|
227
231
|
}
|
|
228
232
|
|
|
229
233
|
export async function suppressInterferences(page: Page, opts: Options = {}) {
|
|
@@ -264,7 +268,9 @@ export async function suppressInterferences(page: Page, opts: Options = {}) {
|
|
|
264
268
|
|
|
265
269
|
// Optional: bail if we are hitting a loop of re-spawning popups
|
|
266
270
|
if (actions >= maxActions) {
|
|
271
|
+
/* v8 ignore start */
|
|
267
272
|
if (opts.verbose) console.log(`[suppressInterferences] Max actions ${maxActions} reached, stopping.`);
|
|
273
|
+
/* v8 ignore stop */
|
|
268
274
|
break;
|
|
269
275
|
}
|
|
270
276
|
|
|
@@ -278,7 +284,9 @@ export async function suppressInterferences(page: Page, opts: Options = {}) {
|
|
|
278
284
|
const ranFor = Date.now() - startedAt;
|
|
279
285
|
|
|
280
286
|
if (quietFor >= quietPeriodMs && ranFor >= minSweepMs) {
|
|
287
|
+
/* v8 ignore start */
|
|
281
288
|
if (opts.verbose) console.log(`[suppressInterferences] Quiet for ${quietFor}ms, stopping early.`);
|
|
289
|
+
/* v8 ignore stop */
|
|
282
290
|
break;
|
|
283
291
|
}
|
|
284
292
|
|
package/src/wait.ts
CHANGED
|
@@ -15,6 +15,7 @@ export async function waitForMeta(page: Page, timeout = 2500) {
|
|
|
15
15
|
|
|
16
16
|
await page
|
|
17
17
|
.waitForFunction(
|
|
18
|
+
/* v8 ignore start */
|
|
18
19
|
() => {
|
|
19
20
|
const head = document.head;
|
|
20
21
|
if (!head) return false;
|
|
@@ -26,6 +27,7 @@ export async function waitForMeta(page: Page, timeout = 2500) {
|
|
|
26
27
|
head.querySelector('script[type="application/ld+json"]'),
|
|
27
28
|
);
|
|
28
29
|
},
|
|
30
|
+
/* v8 ignore stop */
|
|
29
31
|
{ timeout },
|
|
30
32
|
)
|
|
31
33
|
.catch(() => {});
|
|
@@ -37,6 +39,7 @@ export async function waitForDomIdle(
|
|
|
37
39
|
{ quiet = 500, timeout = 10_000 }: { quiet?: number; timeout?: number } = {},
|
|
38
40
|
) {
|
|
39
41
|
await page.waitForFunction(
|
|
42
|
+
/* v8 ignore start */
|
|
40
43
|
(q) =>
|
|
41
44
|
new Promise<boolean>((resolve) => {
|
|
42
45
|
let last = performance.now();
|
|
@@ -59,6 +62,7 @@ export async function waitForDomIdle(
|
|
|
59
62
|
};
|
|
60
63
|
tick();
|
|
61
64
|
}),
|
|
65
|
+
/* v8 ignore stop */
|
|
62
66
|
quiet,
|
|
63
67
|
{ timeout },
|
|
64
68
|
);
|
|
@@ -68,19 +72,22 @@ export async function waitForDomIdle(
|
|
|
68
72
|
// This helps with libraries that slide months using WAAPI (common in React UI libs).
|
|
69
73
|
export async function waitForAnimationsToFinish(root: Locator) {
|
|
70
74
|
await root.page().waitForFunction(
|
|
75
|
+
/* v8 ignore start */
|
|
71
76
|
(el) => {
|
|
72
77
|
const animations = (el as HTMLElement).getAnimations?.({ subtree: true }) ?? [];
|
|
73
78
|
return animations.every((a) => a.playState !== 'running');
|
|
74
79
|
},
|
|
80
|
+
/* v8 ignore stop */
|
|
75
81
|
await root.elementHandle(),
|
|
76
82
|
);
|
|
77
83
|
|
|
84
|
+
/* v8 ignore next */
|
|
78
85
|
await root.evaluate(() => new Promise<void>((r) => requestAnimationFrame(() => requestAnimationFrame(() => r()))));
|
|
79
86
|
}
|
|
80
87
|
|
|
81
88
|
export async function waitForUrlChange(page: Page, prevUrl: string, timeout: number) {
|
|
82
89
|
try {
|
|
83
|
-
await page.waitForFunction((u) => location.href !== u, prevUrl, { timeout });
|
|
90
|
+
await page.waitForFunction(/* v8 ignore next */ (u) => location.href !== u, prevUrl, { timeout });
|
|
84
91
|
return true;
|
|
85
92
|
} catch {
|
|
86
93
|
return false;
|
|
@@ -95,6 +102,7 @@ export async function waitUntilEnabled(page: Page, target: Locator, timeout: num
|
|
|
95
102
|
|
|
96
103
|
await page
|
|
97
104
|
.waitForFunction(
|
|
105
|
+
/* v8 ignore start */
|
|
98
106
|
(el) => {
|
|
99
107
|
if (!el || !(el as Element).isConnected) return true; // detached → treat as settled
|
|
100
108
|
const aria = (el as HTMLElement).getAttribute('aria-disabled');
|
|
@@ -104,6 +112,7 @@ export async function waitUntilEnabled(page: Page, target: Locator, timeout: num
|
|
|
104
112
|
(el as HTMLElement).getAttribute('disabled') !== null;
|
|
105
113
|
return !disabled;
|
|
106
114
|
},
|
|
115
|
+
/* v8 ignore stop */
|
|
107
116
|
handle,
|
|
108
117
|
{ timeout },
|
|
109
118
|
)
|
|
@@ -158,7 +167,7 @@ async function elementKind(target: Locator): Promise<'link' | 'button' | 'other'
|
|
|
158
167
|
if (role === 'link') return 'link';
|
|
159
168
|
if (role === 'button') return 'button';
|
|
160
169
|
|
|
161
|
-
const tag = await target.evaluate((el) => el.tagName.toLowerCase(), null, { timeout: PROBE }).catch(() => '');
|
|
170
|
+
const tag = await target.evaluate(/* v8 ignore next */ (el) => el.tagName.toLowerCase(), null, { timeout: PROBE }).catch(() => '');
|
|
162
171
|
if (tag === 'a') return 'link';
|
|
163
172
|
|
|
164
173
|
if (tag === 'button') return 'button';
|