@letsrunit/playwright 0.19.0 → 0.19.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 +177 -9
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/fallback-locator.ts +195 -0
- package/src/fuzzy-locator.ts +14 -13
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@letsrunit/playwright",
|
|
3
|
-
"version": "0.19.
|
|
3
|
+
"version": "0.19.1",
|
|
4
4
|
"description": "Playwright extensions and utilities for letsrunit",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"testing",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
},
|
|
43
43
|
"packageManager": "yarn@4.10.3",
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@letsrunit/utils": "0.19.
|
|
45
|
+
"@letsrunit/utils": "0.19.1",
|
|
46
46
|
"@playwright/test": "1.58.2",
|
|
47
47
|
"case": "^1.6.3",
|
|
48
48
|
"diff": "^8.0.3",
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { Locator } from '@playwright/test';
|
|
2
|
+
|
|
3
|
+
type LocatorMethod = (...args: any[]) => any;
|
|
4
|
+
type ProxyProperty = string | symbol;
|
|
5
|
+
|
|
6
|
+
const ACTION_METHODS = new Set([
|
|
7
|
+
'blur',
|
|
8
|
+
'check',
|
|
9
|
+
'clear',
|
|
10
|
+
'click',
|
|
11
|
+
'dblclick',
|
|
12
|
+
'dispatchEvent',
|
|
13
|
+
'dragTo',
|
|
14
|
+
'fill',
|
|
15
|
+
'focus',
|
|
16
|
+
'hover',
|
|
17
|
+
'press',
|
|
18
|
+
'pressSequentially',
|
|
19
|
+
'scrollIntoViewIfNeeded',
|
|
20
|
+
'selectOption',
|
|
21
|
+
'setChecked',
|
|
22
|
+
'setInputFiles',
|
|
23
|
+
'tap',
|
|
24
|
+
'type',
|
|
25
|
+
'uncheck',
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
const LOCATOR_CHAIN_METHODS = new Set([
|
|
29
|
+
'and',
|
|
30
|
+
'first',
|
|
31
|
+
'last',
|
|
32
|
+
'locator',
|
|
33
|
+
'nth',
|
|
34
|
+
'or',
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
const NO_WAIT_OPTION_INDEX: Record<string, number> = {
|
|
38
|
+
dragTo: 1,
|
|
39
|
+
fill: 1,
|
|
40
|
+
press: 1,
|
|
41
|
+
pressSequentially: 1,
|
|
42
|
+
selectOption: 1,
|
|
43
|
+
setInputFiles: 1,
|
|
44
|
+
type: 1,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
function buildOrLocator(candidates: Locator[]): Locator {
|
|
48
|
+
let result = candidates[0];
|
|
49
|
+
for (const candidate of candidates.slice(1)) {
|
|
50
|
+
result = result.or(candidate);
|
|
51
|
+
}
|
|
52
|
+
return result;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function firstPresentFallback(candidates: Locator[]): Promise<Locator | null> {
|
|
56
|
+
for (const candidate of candidates.slice(1)) {
|
|
57
|
+
try {
|
|
58
|
+
if ((await candidate.count()) > 0) return candidate;
|
|
59
|
+
} catch {}
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function withNoWaitTimeout(method: string, args: unknown[]): unknown[] {
|
|
65
|
+
const next = [...args];
|
|
66
|
+
const optionIndex = NO_WAIT_OPTION_INDEX[method] ?? 0;
|
|
67
|
+
const current = next[optionIndex];
|
|
68
|
+
|
|
69
|
+
if (current && typeof current === 'object') {
|
|
70
|
+
next[optionIndex] = { ...(current as Record<string, unknown>), timeout: 0 };
|
|
71
|
+
} else {
|
|
72
|
+
next[optionIndex] = { timeout: 0 };
|
|
73
|
+
}
|
|
74
|
+
return next;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function handleExpect(primary: Locator, candidates: Locator[]) {
|
|
78
|
+
return (expression: string, options: unknown) => {
|
|
79
|
+
if (expression.includes('to.be.visible') || expression.includes('to.be.attached')) {
|
|
80
|
+
return (buildOrLocator(candidates) as any)._expect(expression, options);
|
|
81
|
+
}
|
|
82
|
+
return (primary as any)._expect(expression, options);
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function handleAll(candidates: Locator[]) {
|
|
87
|
+
return async () => {
|
|
88
|
+
const all: Locator[] = [];
|
|
89
|
+
for (const candidate of candidates) {
|
|
90
|
+
all.push(...(await candidate.all()));
|
|
91
|
+
}
|
|
92
|
+
return all;
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function handleLocatorChain(prop: string, candidates: Locator[]) {
|
|
97
|
+
return (...args: any[]) =>
|
|
98
|
+
createFallbackLocator(
|
|
99
|
+
candidates.map((candidate) => {
|
|
100
|
+
const method = (candidate as any)[prop] as LocatorMethod;
|
|
101
|
+
return method.apply(candidate, args);
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function handleCount(primary: Locator, candidates: Locator[]) {
|
|
107
|
+
return async () => {
|
|
108
|
+
const primaryCount = await primary.count();
|
|
109
|
+
if (primaryCount > 0) return primaryCount;
|
|
110
|
+
|
|
111
|
+
const fallback = await firstPresentFallback(candidates);
|
|
112
|
+
if (!fallback) return primaryCount;
|
|
113
|
+
|
|
114
|
+
return fallback.count();
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function handleAction(prop: string, primary: Locator, candidates: Locator[], primaryMethod: LocatorMethod) {
|
|
119
|
+
return async (...args: any[]) => {
|
|
120
|
+
try {
|
|
121
|
+
return await primaryMethod.apply(primary, args);
|
|
122
|
+
} catch (error) {
|
|
123
|
+
const fallback = await firstPresentFallback(candidates);
|
|
124
|
+
if (!fallback) throw error;
|
|
125
|
+
const fallbackMethod = (fallback as any)[prop] as LocatorMethod;
|
|
126
|
+
return fallbackMethod.apply(fallback, withNoWaitTimeout(prop, args));
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function handleAsyncFallback(prop: string, primaryMethod: LocatorMethod, primary: Locator, candidates: Locator[]) {
|
|
132
|
+
return (...args: any[]) => {
|
|
133
|
+
const result = primaryMethod.apply(primary, args);
|
|
134
|
+
if (!result || typeof result !== 'object' || typeof (result as Promise<unknown>).catch !== 'function') {
|
|
135
|
+
return result;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return (result as Promise<unknown>).catch(async (error: unknown) => {
|
|
139
|
+
const fallback = await firstPresentFallback(candidates);
|
|
140
|
+
if (!fallback) throw error;
|
|
141
|
+
const fallbackMethod = (fallback as any)[prop] as LocatorMethod;
|
|
142
|
+
return fallbackMethod.apply(fallback, args);
|
|
143
|
+
});
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function formatLocatorChain(candidates: Locator[]): string {
|
|
148
|
+
const parts = candidates.map((candidate) => candidate.toString());
|
|
149
|
+
if (parts.length === 0) return '';
|
|
150
|
+
if (parts.length === 1) return parts[0];
|
|
151
|
+
return `${parts[0]} {fuzzy}`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function createFallbackLocator(candidates: Locator[]): Locator {
|
|
155
|
+
const primary = candidates[0];
|
|
156
|
+
const unsupported = new Set(['filter', 'getByRole', 'getByLabel']);
|
|
157
|
+
|
|
158
|
+
const proxy = new Proxy(primary as unknown as object, {
|
|
159
|
+
get(_target, prop: ProxyProperty) {
|
|
160
|
+
if (typeof prop !== 'string') return (primary as any)[prop];
|
|
161
|
+
|
|
162
|
+
switch (prop) {
|
|
163
|
+
case 'toString':
|
|
164
|
+
return () => formatLocatorChain(candidates);
|
|
165
|
+
case 'all':
|
|
166
|
+
return handleAll(candidates);
|
|
167
|
+
case '_expect':
|
|
168
|
+
return handleExpect(primary, candidates);
|
|
169
|
+
case 'count':
|
|
170
|
+
return handleCount(primary, candidates);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (unsupported.has(prop)) {
|
|
174
|
+
return () => {
|
|
175
|
+
throw new Error(`FallbackLocator does not support ${prop}`);
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (LOCATOR_CHAIN_METHODS.has(prop)) {
|
|
180
|
+
return handleLocatorChain(prop, candidates);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const primaryMethod = (primary as any)[prop] as LocatorMethod;
|
|
184
|
+
if (typeof primaryMethod !== 'function') return primaryMethod;
|
|
185
|
+
|
|
186
|
+
if (ACTION_METHODS.has(prop)) {
|
|
187
|
+
return handleAction(prop, primary, candidates, primaryMethod);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return handleAsyncFallback(prop, primaryMethod, primary, candidates);
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
return proxy as Locator;
|
|
195
|
+
}
|
package/src/fuzzy-locator.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Locator, Page } from '@playwright/test';
|
|
2
|
+
import { createFallbackLocator } from './fallback-locator';
|
|
2
3
|
|
|
3
4
|
function debug(...args: unknown[]) {
|
|
4
5
|
if (process.env.LETSRUNIT_DEBUG_FUZZY_LOCATOR === '1') {
|
|
@@ -8,10 +9,16 @@ function debug(...args: unknown[]) {
|
|
|
8
9
|
}
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
|
-
* Locates an element using Playwright selectors, with
|
|
12
|
+
* Locates an element using Playwright selectors, with ordered runtime fallbacks.
|
|
12
13
|
*/
|
|
13
14
|
export async function fuzzyLocator(page: Page, selector: string): Promise<Locator> {
|
|
14
15
|
debug('input selector:', selector);
|
|
16
|
+
const candidates = buildFuzzyCandidates(page, selector);
|
|
17
|
+
debug('enabled fallbacks:', candidates.length > 1 ? String(candidates.length - 1) : '(none)');
|
|
18
|
+
return createFallbackLocator(candidates);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function buildFuzzyCandidates(page: Page, selector: string): Locator[] {
|
|
15
22
|
const primary = page.locator(selector);
|
|
16
23
|
const candidates: Array<{ name: string; locator: Locator | null }> = [
|
|
17
24
|
{ name: 'relaxNameToHasText', locator: tryRelaxNameToHasText(page, selector) },
|
|
@@ -21,19 +28,13 @@ export async function fuzzyLocator(page: Page, selector: string): Promise<Locato
|
|
|
21
28
|
{ name: 'asField', locator: tryAsField(page, selector) },
|
|
22
29
|
];
|
|
23
30
|
|
|
24
|
-
|
|
25
|
-
const enabled: string[] = [];
|
|
26
|
-
|
|
31
|
+
const all = [primary];
|
|
27
32
|
for (const candidate of candidates) {
|
|
28
33
|
if (!candidate.locator) continue;
|
|
29
|
-
|
|
30
|
-
|
|
34
|
+
debug('enabling fallback:', candidate.name, candidate.locator.toString());
|
|
35
|
+
all.push(candidate.locator);
|
|
31
36
|
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const result = combined.first();
|
|
35
|
-
debug('returning locator:', result.toString());
|
|
36
|
-
return result;
|
|
37
|
+
return all;
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
// Preserve the selector but relax [name="..."] to [has-text="..."]
|
|
@@ -65,12 +66,12 @@ function tryTagInsteadOfRole(page: Page, selector: string): Locator | null {
|
|
|
65
66
|
|
|
66
67
|
// If a role selector with a name filter fails, try proximity-based fallback while
|
|
67
68
|
// preserving the role and any remainder of the selector.
|
|
68
|
-
// Example: role=switch[name="Adres tonen"i] → text=Adres tonen >>
|
|
69
|
+
// Example: role=switch[name="Adres tonen"i] → text=Adres tonen >> xpath=following-sibling::* >> role=switch
|
|
69
70
|
function tryRoleNameProximity(page: Page, selector: string): Locator | null {
|
|
70
71
|
const matchRole = selector.match(/^role=(\w+)\s*\[name="([^"]+)"i?](.*)$/i);
|
|
71
72
|
if (!matchRole) return null;
|
|
72
73
|
const [, role, name, rest] = matchRole;
|
|
73
|
-
const proximitySelector = `text=${name} >>
|
|
74
|
+
const proximitySelector = `text=${name} >> xpath=following-sibling::* >> role=${role}${rest}`;
|
|
74
75
|
return page.locator(proximitySelector);
|
|
75
76
|
}
|
|
76
77
|
|