@tontoko/fast-playwright-mcp 0.1.1 → 0.1.2
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/README.md +116 -96
- package/lib/diagnostics/page-analyzer.js +2 -1
- package/lib/services/selector-resolver.js +541 -0
- package/lib/tab.js +47 -24
- package/lib/tools/base-tool-handler.js +3 -19
- package/lib/tools/evaluate.js +11 -7
- package/lib/tools/inspect-html.js +238 -0
- package/lib/tools/keyboard.js +12 -14
- package/lib/tools/screenshot.js +15 -13
- package/lib/tools/shared-element-utils.js +60 -0
- package/lib/tools/snapshot.js +21 -23
- package/lib/tools.js +2 -0
- package/lib/types/batch.js +1 -1
- package/lib/types/html-inspection.js +106 -0
- package/lib/types/selectors.js +126 -0
- package/lib/utilities/html-inspector.js +514 -0
- package/lib/utilities/index.js +24 -0
- package/lib/utils/tool-patterns.js +3 -30
- package/package.json +1 -1
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
4
|
+
var __defProp = Object.defineProperty;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
7
|
+
var __toESM = (mod, isNodeMode, target) => {
|
|
8
|
+
target = mod != null ? __create(__getProtoOf(mod)) : {};
|
|
9
|
+
const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
|
|
10
|
+
for (let key of __getOwnPropNames(mod))
|
|
11
|
+
if (!__hasOwnProp.call(to, key))
|
|
12
|
+
__defProp(to, key, {
|
|
13
|
+
get: () => mod[key],
|
|
14
|
+
enumerable: true
|
|
15
|
+
});
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
19
|
+
|
|
20
|
+
// src/services/selector-resolver.ts
|
|
21
|
+
import {
|
|
22
|
+
isCSSSelector,
|
|
23
|
+
isRefSelector,
|
|
24
|
+
isRoleSelector,
|
|
25
|
+
isTextSelector,
|
|
26
|
+
ResolutionStrategy,
|
|
27
|
+
SelectorConfidence,
|
|
28
|
+
validateElementSelector
|
|
29
|
+
} from "../types/selectors.js";
|
|
30
|
+
import { tabDebug } from "../utils/log.js";
|
|
31
|
+
var GENERIC_TAG_SELECTOR = /^[a-z]+$/;
|
|
32
|
+
|
|
33
|
+
class SelectorResolver {
|
|
34
|
+
page;
|
|
35
|
+
defaultTimeoutMs = 3000;
|
|
36
|
+
constructor(page) {
|
|
37
|
+
this.page = page;
|
|
38
|
+
}
|
|
39
|
+
async resolveSelectors(selectors, options = {}) {
|
|
40
|
+
const startTime = Date.now();
|
|
41
|
+
const {
|
|
42
|
+
timeoutMs = this.defaultTimeoutMs,
|
|
43
|
+
continueOnError = true,
|
|
44
|
+
executionStrategy = "hybrid"
|
|
45
|
+
} = options;
|
|
46
|
+
tabDebug(`Resolving ${selectors.length} selectors using ${executionStrategy} strategy`);
|
|
47
|
+
const validatedSelectors = this._validateSelectors(selectors);
|
|
48
|
+
const categorized = this._categorizeSelectors(validatedSelectors);
|
|
49
|
+
try {
|
|
50
|
+
switch (executionStrategy) {
|
|
51
|
+
case "parallel":
|
|
52
|
+
return await this._resolveParallel(categorized, timeoutMs, continueOnError);
|
|
53
|
+
case "sequential":
|
|
54
|
+
return await this._resolveSequential(categorized, timeoutMs, continueOnError);
|
|
55
|
+
default:
|
|
56
|
+
return await this._resolveHybrid(categorized, timeoutMs, continueOnError);
|
|
57
|
+
}
|
|
58
|
+
} catch (error) {
|
|
59
|
+
tabDebug(`Selector resolution failed after ${Date.now() - startTime}ms:`, error);
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
async resolveSingleSelector(selector, options = {}) {
|
|
64
|
+
const { timeoutMs = this.defaultTimeoutMs } = options;
|
|
65
|
+
const startTime = Date.now();
|
|
66
|
+
try {
|
|
67
|
+
validateElementSelector(selector);
|
|
68
|
+
const result = await this._resolveSelectorWithTimeout(selector, timeoutMs);
|
|
69
|
+
if (result.locator && !result.error) {
|
|
70
|
+
const metadata = await this._extractElementMetadata(result.locator);
|
|
71
|
+
const suggestions = this._generateSuggestions(selector, metadata);
|
|
72
|
+
return {
|
|
73
|
+
...result,
|
|
74
|
+
metadata,
|
|
75
|
+
suggestions
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
return result;
|
|
79
|
+
} catch (error) {
|
|
80
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
81
|
+
tabDebug(`Single selector resolution failed: ${errorMessage}`);
|
|
82
|
+
return {
|
|
83
|
+
locator: this.page.locator("not-found"),
|
|
84
|
+
selector,
|
|
85
|
+
confidence: 0,
|
|
86
|
+
strategy: this._getStrategyForSelector(selector),
|
|
87
|
+
resolutionTimeMs: Date.now() - startTime,
|
|
88
|
+
error: errorMessage
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
_validateSelectors(selectors) {
|
|
93
|
+
return selectors.map((selector, index) => {
|
|
94
|
+
try {
|
|
95
|
+
return validateElementSelector(selector);
|
|
96
|
+
} catch (error) {
|
|
97
|
+
throw new Error(`Invalid selector at index ${index}: ${error instanceof Error ? error.message : String(error)}`);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
_categorizeSelectors(selectors) {
|
|
102
|
+
const categorized = {
|
|
103
|
+
refs: [],
|
|
104
|
+
css: [],
|
|
105
|
+
roles: [],
|
|
106
|
+
text: []
|
|
107
|
+
};
|
|
108
|
+
for (const selector of selectors) {
|
|
109
|
+
if (isRefSelector(selector)) {
|
|
110
|
+
categorized.refs.push(selector);
|
|
111
|
+
} else if (isCSSSelector(selector)) {
|
|
112
|
+
categorized.css.push(selector);
|
|
113
|
+
} else if (isRoleSelector(selector)) {
|
|
114
|
+
categorized.roles.push(selector);
|
|
115
|
+
} else if (isTextSelector(selector)) {
|
|
116
|
+
categorized.text.push(selector);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return categorized;
|
|
120
|
+
}
|
|
121
|
+
async _resolveParallel(categorized, timeoutMs, continueOnError) {
|
|
122
|
+
const allSelectors = [
|
|
123
|
+
...categorized.refs,
|
|
124
|
+
...categorized.css,
|
|
125
|
+
...categorized.roles,
|
|
126
|
+
...categorized.text
|
|
127
|
+
];
|
|
128
|
+
const promises = allSelectors.map(async (selector) => {
|
|
129
|
+
try {
|
|
130
|
+
return await this._resolveSelectorWithTimeout(selector, timeoutMs);
|
|
131
|
+
} catch (error) {
|
|
132
|
+
if (!continueOnError) {
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
return this._createErrorResult(selector, error);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
return await Promise.all(promises);
|
|
139
|
+
}
|
|
140
|
+
async _resolveSequential(categorized, timeoutMs, continueOnError) {
|
|
141
|
+
const allSelectors = [
|
|
142
|
+
...categorized.refs,
|
|
143
|
+
...categorized.css,
|
|
144
|
+
...categorized.roles,
|
|
145
|
+
...categorized.text
|
|
146
|
+
];
|
|
147
|
+
const promises = allSelectors.map(async (selector, index) => {
|
|
148
|
+
await new Promise((resolve) => setTimeout(resolve, index * 10));
|
|
149
|
+
try {
|
|
150
|
+
return await this._resolveSelectorWithTimeout(selector, timeoutMs);
|
|
151
|
+
} catch (error) {
|
|
152
|
+
if (!continueOnError) {
|
|
153
|
+
throw error;
|
|
154
|
+
}
|
|
155
|
+
return this._createErrorResult(selector, error);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
return await Promise.all(promises);
|
|
159
|
+
}
|
|
160
|
+
async _resolveHybrid(categorized, timeoutMs, continueOnError) {
|
|
161
|
+
const results = [];
|
|
162
|
+
if (categorized.refs.length > 0) {
|
|
163
|
+
const refPromises = categorized.refs.map(async (selector) => {
|
|
164
|
+
try {
|
|
165
|
+
return await this._resolveSelectorWithTimeout(selector, timeoutMs);
|
|
166
|
+
} catch (error) {
|
|
167
|
+
if (!continueOnError) {
|
|
168
|
+
throw error;
|
|
169
|
+
}
|
|
170
|
+
return this._createErrorResult(selector, error);
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
const refResults = await Promise.all(refPromises);
|
|
174
|
+
results.push(...refResults);
|
|
175
|
+
}
|
|
176
|
+
const otherSelectors = [
|
|
177
|
+
...categorized.css,
|
|
178
|
+
...categorized.roles,
|
|
179
|
+
...categorized.text
|
|
180
|
+
];
|
|
181
|
+
const otherPromises = otherSelectors.map(async (selector, index) => {
|
|
182
|
+
await new Promise((resolve) => setTimeout(resolve, index * 10));
|
|
183
|
+
try {
|
|
184
|
+
return await this._resolveSelectorWithTimeout(selector, timeoutMs);
|
|
185
|
+
} catch (error) {
|
|
186
|
+
if (!continueOnError) {
|
|
187
|
+
throw error;
|
|
188
|
+
}
|
|
189
|
+
return this._createErrorResult(selector, error);
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
const otherResults = await Promise.all(otherPromises);
|
|
193
|
+
results.push(...otherResults);
|
|
194
|
+
return results;
|
|
195
|
+
}
|
|
196
|
+
async _resolveSelectorWithTimeout(selector, timeoutMs) {
|
|
197
|
+
const startTime = Date.now();
|
|
198
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
199
|
+
setTimeout(() => {
|
|
200
|
+
reject(new Error(`Selector resolution timed out after ${timeoutMs}ms`));
|
|
201
|
+
}, timeoutMs);
|
|
202
|
+
});
|
|
203
|
+
try {
|
|
204
|
+
const result = await Promise.race([
|
|
205
|
+
this._resolveSingleSelectorInternal(selector),
|
|
206
|
+
timeoutPromise
|
|
207
|
+
]);
|
|
208
|
+
return {
|
|
209
|
+
...result,
|
|
210
|
+
resolutionTimeMs: Date.now() - startTime
|
|
211
|
+
};
|
|
212
|
+
} catch (error) {
|
|
213
|
+
return this._createErrorResult(selector, error, Date.now() - startTime);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
async _resolveSingleSelectorInternal(selector) {
|
|
217
|
+
if (isRefSelector(selector)) {
|
|
218
|
+
return await this._resolveRefSelector(selector);
|
|
219
|
+
}
|
|
220
|
+
if (isRoleSelector(selector)) {
|
|
221
|
+
return await this._resolveRoleSelector(selector);
|
|
222
|
+
}
|
|
223
|
+
if (isCSSSelector(selector)) {
|
|
224
|
+
return await this._resolveCSSSelector(selector);
|
|
225
|
+
}
|
|
226
|
+
if (isTextSelector(selector)) {
|
|
227
|
+
return await this._resolveTextSelector(selector);
|
|
228
|
+
}
|
|
229
|
+
throw new Error("Unknown selector type");
|
|
230
|
+
}
|
|
231
|
+
async _resolveRefSelector(selector) {
|
|
232
|
+
try {
|
|
233
|
+
const locator = this.page.locator(`aria-ref=${selector.ref}`);
|
|
234
|
+
const count = await locator.count();
|
|
235
|
+
if (count === 0) {
|
|
236
|
+
return {
|
|
237
|
+
locator: this.page.locator("not-found"),
|
|
238
|
+
selector,
|
|
239
|
+
confidence: 0,
|
|
240
|
+
strategy: ResolutionStrategy.REF,
|
|
241
|
+
error: `Ref ${selector.ref} not found in current page state`
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
return {
|
|
245
|
+
locator,
|
|
246
|
+
selector,
|
|
247
|
+
confidence: SelectorConfidence.HIGH,
|
|
248
|
+
strategy: ResolutionStrategy.REF
|
|
249
|
+
};
|
|
250
|
+
} catch (error) {
|
|
251
|
+
throw new Error(`Failed to resolve ref selector: ${error instanceof Error ? error.message : String(error)}`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
async _resolveRoleSelector(selector) {
|
|
255
|
+
try {
|
|
256
|
+
let locator = this.page.getByRole(selector.role);
|
|
257
|
+
let confidence = SelectorConfidence.MEDIUM;
|
|
258
|
+
if (selector.text) {
|
|
259
|
+
locator = locator.filter({ hasText: selector.text });
|
|
260
|
+
confidence = SelectorConfidence.HIGH;
|
|
261
|
+
}
|
|
262
|
+
const count = await locator.count();
|
|
263
|
+
if (count === 0) {
|
|
264
|
+
const alternatives = await this._findRoleAlternatives(selector.role, selector.text);
|
|
265
|
+
return {
|
|
266
|
+
locator: this.page.locator("not-found"),
|
|
267
|
+
selector,
|
|
268
|
+
confidence: 0,
|
|
269
|
+
strategy: ResolutionStrategy.ROLE_SEQUENTIAL,
|
|
270
|
+
alternatives,
|
|
271
|
+
error: selector.text ? `No elements found with role "${selector.role}" and text "${selector.text}"` : `No elements found with role "${selector.role}"`
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
if (count > 1 && !selector.text) {
|
|
275
|
+
const candidates = await this._getMatchCandidates(locator);
|
|
276
|
+
const candidateDescriptions = candidates.map((c, i) => {
|
|
277
|
+
const attrs = Object.entries(c.attributes).filter(([key]) => ["id", "name", "data-testid", "aria-label"].includes(key)).map(([key, value]) => `${key}="${value}"`).join(" ");
|
|
278
|
+
const prefix = attrs ? `[${attrs}] ` : "";
|
|
279
|
+
const truncatedText = c.text.length > 50 ? `${c.text.substring(0, 50)}...` : c.text;
|
|
280
|
+
return ` ${i + 1}) ${prefix}text: "${truncatedText}"`;
|
|
281
|
+
}).join(`
|
|
282
|
+
`);
|
|
283
|
+
return {
|
|
284
|
+
locator: this.page.locator("not-found"),
|
|
285
|
+
selector,
|
|
286
|
+
confidence: 0,
|
|
287
|
+
strategy: ResolutionStrategy.ROLE_SEQUENTIAL,
|
|
288
|
+
error: `Multiple elements (${count}) found with role "${selector.role}". Please be more specific:
|
|
289
|
+
${candidateDescriptions}
|
|
290
|
+
Consider adding text filter or using a more specific selector.`
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
return {
|
|
294
|
+
locator,
|
|
295
|
+
selector,
|
|
296
|
+
confidence,
|
|
297
|
+
strategy: ResolutionStrategy.ROLE_SEQUENTIAL
|
|
298
|
+
};
|
|
299
|
+
} catch (error) {
|
|
300
|
+
throw new Error(`Failed to resolve role selector: ${error instanceof Error ? error.message : String(error)}`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
async _resolveCSSSelector(selector) {
|
|
304
|
+
try {
|
|
305
|
+
const locator = this.page.locator(selector.css);
|
|
306
|
+
const count = await locator.count();
|
|
307
|
+
if (count === 0) {
|
|
308
|
+
return {
|
|
309
|
+
locator: this.page.locator("not-found"),
|
|
310
|
+
selector,
|
|
311
|
+
confidence: 0,
|
|
312
|
+
strategy: ResolutionStrategy.CSS_PARALLEL,
|
|
313
|
+
error: `No elements found matching CSS selector "${selector.css}"`
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
let confidence = SelectorConfidence.MEDIUM;
|
|
317
|
+
if (selector.css.startsWith("#")) {
|
|
318
|
+
confidence = SelectorConfidence.HIGH;
|
|
319
|
+
} else if (count > 1 && GENERIC_TAG_SELECTOR.test(selector.css)) {
|
|
320
|
+
const candidates = await this._getMatchCandidates(locator);
|
|
321
|
+
const candidateDescriptions = candidates.map((c, i) => {
|
|
322
|
+
const attrs = Object.entries(c.attributes).filter(([key]) => ["id", "class", "name", "data-testid"].includes(key)).map(([key, value]) => `${key}="${value}"`).join(" ");
|
|
323
|
+
const prefix = attrs ? `[${attrs}] ` : "";
|
|
324
|
+
const truncatedText = c.text.length > 50 ? `${c.text.substring(0, 50)}...` : c.text;
|
|
325
|
+
return ` ${i + 1}) ${prefix}text: "${truncatedText}"`;
|
|
326
|
+
}).join(`
|
|
327
|
+
`);
|
|
328
|
+
return {
|
|
329
|
+
locator: this.page.locator("not-found"),
|
|
330
|
+
selector,
|
|
331
|
+
confidence: 0,
|
|
332
|
+
strategy: ResolutionStrategy.CSS_PARALLEL,
|
|
333
|
+
error: `Multiple elements (${count}) found with CSS selector "${selector.css}". Please be more specific:
|
|
334
|
+
${candidateDescriptions}
|
|
335
|
+
Consider using a more specific selector like ID or adding :nth-child().`
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
return {
|
|
339
|
+
locator,
|
|
340
|
+
selector,
|
|
341
|
+
confidence,
|
|
342
|
+
strategy: ResolutionStrategy.CSS_PARALLEL
|
|
343
|
+
};
|
|
344
|
+
} catch (error) {
|
|
345
|
+
throw new Error(`Failed to resolve CSS selector: ${error instanceof Error ? error.message : String(error)}`);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
async _resolveTextSelector(selector) {
|
|
349
|
+
try {
|
|
350
|
+
let locator = this.page.getByText(selector.text);
|
|
351
|
+
if (selector.tag) {
|
|
352
|
+
locator = this.page.locator(selector.tag).filter({ hasText: selector.text });
|
|
353
|
+
}
|
|
354
|
+
const count = await locator.count();
|
|
355
|
+
if (count === 0) {
|
|
356
|
+
const alternatives = await this._findTextAlternatives(selector.text);
|
|
357
|
+
return {
|
|
358
|
+
locator: this.page.locator("not-found"),
|
|
359
|
+
selector,
|
|
360
|
+
confidence: 0,
|
|
361
|
+
strategy: ResolutionStrategy.TEXT_FALLBACK,
|
|
362
|
+
alternatives,
|
|
363
|
+
error: selector.tag ? `No elements found with text "${selector.text}" in ${selector.tag} tags` : `No elements found with text "${selector.text}"`
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
let confidence = SelectorConfidence.MEDIUM;
|
|
367
|
+
if (selector.tag) {
|
|
368
|
+
confidence = SelectorConfidence.HIGH;
|
|
369
|
+
} else if (count > 3) {
|
|
370
|
+
const candidates = await this._getMatchCandidates(locator);
|
|
371
|
+
const candidateDescriptions = candidates.map((c, i) => {
|
|
372
|
+
const attrs = Object.entries(c.attributes).filter(([key]) => ["id", "class", "role", "data-testid"].includes(key)).map(([key, value]) => `${key}="${value}"`).join(" ");
|
|
373
|
+
const tagName = c.attributes.tagName || "element";
|
|
374
|
+
const attrString = attrs ? ` ${attrs}` : "";
|
|
375
|
+
const truncatedText = c.text.length > 50 ? `${c.text.substring(0, 50)}...` : c.text;
|
|
376
|
+
return ` ${i + 1}) <${tagName}${attrString}> text: "${truncatedText}"`;
|
|
377
|
+
}).join(`
|
|
378
|
+
`);
|
|
379
|
+
return {
|
|
380
|
+
locator: this.page.locator("not-found"),
|
|
381
|
+
selector,
|
|
382
|
+
confidence: 0,
|
|
383
|
+
strategy: ResolutionStrategy.TEXT_FALLBACK,
|
|
384
|
+
error: `Multiple elements (${count}) found with text "${selector.text}". Please be more specific:
|
|
385
|
+
${candidateDescriptions}
|
|
386
|
+
Consider using CSS selector or role with text filter.`
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
return {
|
|
390
|
+
locator,
|
|
391
|
+
selector,
|
|
392
|
+
confidence,
|
|
393
|
+
strategy: ResolutionStrategy.TEXT_FALLBACK
|
|
394
|
+
};
|
|
395
|
+
} catch (error) {
|
|
396
|
+
throw new Error(`Failed to resolve text selector: ${error instanceof Error ? error.message : String(error)}`);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
async _getMatchCandidates(locator, limit = 5) {
|
|
400
|
+
const count = await locator.count();
|
|
401
|
+
const maxCount = Math.min(count, limit);
|
|
402
|
+
const promises = [];
|
|
403
|
+
for (let i = 0;i < maxCount; i++) {
|
|
404
|
+
const element = locator.nth(i);
|
|
405
|
+
promises.push(Promise.all([
|
|
406
|
+
element.textContent(),
|
|
407
|
+
element.evaluate((el) => {
|
|
408
|
+
const attrs = {};
|
|
409
|
+
for (const attr of el.attributes) {
|
|
410
|
+
attrs[attr.name] = attr.value;
|
|
411
|
+
}
|
|
412
|
+
return attrs;
|
|
413
|
+
})
|
|
414
|
+
]).then(([text, attributes]) => ({
|
|
415
|
+
index: i,
|
|
416
|
+
text: text?.trim() || "",
|
|
417
|
+
attributes
|
|
418
|
+
})).catch(() => null));
|
|
419
|
+
}
|
|
420
|
+
const results = await Promise.all(promises);
|
|
421
|
+
return results.filter((result) => result !== null);
|
|
422
|
+
}
|
|
423
|
+
async _findRoleAlternatives(role, text) {
|
|
424
|
+
const alternatives = [];
|
|
425
|
+
const commonRoleAlternatives = {
|
|
426
|
+
button: ["link", "menuitem"],
|
|
427
|
+
link: ["button", "menuitem"],
|
|
428
|
+
textbox: ["searchbox", "combobox"],
|
|
429
|
+
checkbox: ["radio", "switch"]
|
|
430
|
+
};
|
|
431
|
+
const roleAlts = commonRoleAlternatives[role] || [];
|
|
432
|
+
const rolePromises = roleAlts.map(async (altRole) => {
|
|
433
|
+
try {
|
|
434
|
+
const locator = text ? this.page.getByRole(altRole).filter({ hasText: text }) : this.page.getByRole(altRole);
|
|
435
|
+
const count = await locator.count();
|
|
436
|
+
if (count > 0) {
|
|
437
|
+
return { role: altRole, text };
|
|
438
|
+
}
|
|
439
|
+
return null;
|
|
440
|
+
} catch {
|
|
441
|
+
return null;
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
const results = await Promise.all(rolePromises);
|
|
445
|
+
const validAlternatives = results.filter((alt) => alt !== null);
|
|
446
|
+
alternatives.push(...validAlternatives);
|
|
447
|
+
return alternatives.slice(0, 3);
|
|
448
|
+
}
|
|
449
|
+
async _findTextAlternatives(text) {
|
|
450
|
+
const alternatives = [];
|
|
451
|
+
const partialText = text.substring(0, Math.floor(text.length / 2));
|
|
452
|
+
if (partialText.length > 3) {
|
|
453
|
+
try {
|
|
454
|
+
const count = await this.page.getByText(partialText, { exact: false }).count();
|
|
455
|
+
if (count > 0) {
|
|
456
|
+
alternatives.push({ text: partialText });
|
|
457
|
+
}
|
|
458
|
+
} catch {}
|
|
459
|
+
}
|
|
460
|
+
return alternatives.slice(0, 2);
|
|
461
|
+
}
|
|
462
|
+
async _extractElementMetadata(locator) {
|
|
463
|
+
try {
|
|
464
|
+
const element = locator.first();
|
|
465
|
+
const [
|
|
466
|
+
tagName,
|
|
467
|
+
attributes,
|
|
468
|
+
textContent,
|
|
469
|
+
boundingBox,
|
|
470
|
+
isVisible,
|
|
471
|
+
isEnabled
|
|
472
|
+
] = await Promise.all([
|
|
473
|
+
element.evaluate((el) => el.tagName.toLowerCase()),
|
|
474
|
+
element.evaluate((el) => {
|
|
475
|
+
const attrs = {};
|
|
476
|
+
for (const attr of el.attributes) {
|
|
477
|
+
attrs[attr.name] = attr.value;
|
|
478
|
+
}
|
|
479
|
+
return attrs;
|
|
480
|
+
}),
|
|
481
|
+
element.textContent() || "",
|
|
482
|
+
element.boundingBox().catch(() => null),
|
|
483
|
+
element.isVisible().catch(() => false),
|
|
484
|
+
element.isEnabled().catch(() => false)
|
|
485
|
+
]);
|
|
486
|
+
return {
|
|
487
|
+
tagName,
|
|
488
|
+
attributes,
|
|
489
|
+
textContent: textContent || "",
|
|
490
|
+
boundingBox: boundingBox || undefined,
|
|
491
|
+
isVisible,
|
|
492
|
+
isEnabled
|
|
493
|
+
};
|
|
494
|
+
} catch (error) {
|
|
495
|
+
tabDebug("Failed to extract element metadata:", error);
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
_generateSuggestions(selector, metadata) {
|
|
500
|
+
const suggestions = [];
|
|
501
|
+
if (!metadata) {
|
|
502
|
+
return suggestions;
|
|
503
|
+
}
|
|
504
|
+
if (metadata.attributes["data-testid"] && !isCSSSelector(selector)) {
|
|
505
|
+
suggestions.push(`Consider using CSS selector with data-testid: [data-testid="${metadata.attributes["data-testid"]}"]`);
|
|
506
|
+
}
|
|
507
|
+
if (metadata.attributes.id && !isCSSSelector(selector)) {
|
|
508
|
+
suggestions.push(`Consider using CSS selector with ID: #${metadata.attributes.id}`);
|
|
509
|
+
}
|
|
510
|
+
if (metadata.attributes.role && !isRoleSelector(selector)) {
|
|
511
|
+
suggestions.push(`Consider using role selector: role="${metadata.attributes.role}"`);
|
|
512
|
+
}
|
|
513
|
+
return suggestions;
|
|
514
|
+
}
|
|
515
|
+
_createErrorResult(selector, error, resolutionTimeMs = 0) {
|
|
516
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
517
|
+
return {
|
|
518
|
+
locator: this.page.locator("not-found"),
|
|
519
|
+
selector,
|
|
520
|
+
confidence: 0,
|
|
521
|
+
strategy: this._getStrategyForSelector(selector),
|
|
522
|
+
resolutionTimeMs,
|
|
523
|
+
error: errorMessage
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
_getStrategyForSelector(selector) {
|
|
527
|
+
if (isRefSelector(selector)) {
|
|
528
|
+
return ResolutionStrategy.REF;
|
|
529
|
+
}
|
|
530
|
+
if (isCSSSelector(selector)) {
|
|
531
|
+
return ResolutionStrategy.CSS_PARALLEL;
|
|
532
|
+
}
|
|
533
|
+
if (isRoleSelector(selector)) {
|
|
534
|
+
return ResolutionStrategy.ROLE_SEQUENTIAL;
|
|
535
|
+
}
|
|
536
|
+
return ResolutionStrategy.TEXT_FALLBACK;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
export {
|
|
540
|
+
SelectorResolver
|
|
541
|
+
};
|
package/lib/tab.js
CHANGED
|
@@ -21,10 +21,10 @@ var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
|
21
21
|
import { EventEmitter } from "node:events";
|
|
22
22
|
import { TIMEOUTS } from "./config/constants.js";
|
|
23
23
|
import { ManualPromise } from "./manual-promise.js";
|
|
24
|
+
import { SelectorResolver } from "./services/selector-resolver.js";
|
|
24
25
|
import { callOnPageNoTrace, waitForCompletion } from "./tools/utils.js";
|
|
25
26
|
import { logUnhandledError } from "./utils/log.js";
|
|
26
27
|
import { snapshotDebug, tabDebug } from "./utils/log.js";
|
|
27
|
-
var REF_VALUE_REGEX = /\[ref=([^\]]+)\]/;
|
|
28
28
|
var TabEvents = {
|
|
29
29
|
modalState: "modalState"
|
|
30
30
|
};
|
|
@@ -41,6 +41,7 @@ class Tab extends EventEmitter {
|
|
|
41
41
|
_downloads = [];
|
|
42
42
|
_customRefMappings = new Map;
|
|
43
43
|
_customRefCounter = 0;
|
|
44
|
+
_selectorResolver;
|
|
44
45
|
_navigationState = {
|
|
45
46
|
isNavigating: false,
|
|
46
47
|
lastNavigationStart: 0
|
|
@@ -50,6 +51,7 @@ class Tab extends EventEmitter {
|
|
|
50
51
|
this.context = context;
|
|
51
52
|
this.page = page;
|
|
52
53
|
this._onPageClose = onPageClose;
|
|
54
|
+
this._selectorResolver = new SelectorResolver(page);
|
|
53
55
|
page.on("console", (event) => this._handleConsoleMessage(messageToConsoleMessage(event)));
|
|
54
56
|
page.on("pageerror", (error) => this._handleConsoleMessage(pageErrorToConsoleMessage(error)));
|
|
55
57
|
page.on("request", (request) => this._requests.set(request, null));
|
|
@@ -368,24 +370,55 @@ class Tab extends EventEmitter {
|
|
|
368
370
|
}
|
|
369
371
|
return `element_${this._customRefCounter}`;
|
|
370
372
|
}
|
|
373
|
+
async resolveElementLocators(selectors, options) {
|
|
374
|
+
tabDebug(`Resolving ${selectors.length} element locators`);
|
|
375
|
+
try {
|
|
376
|
+
return await this._selectorResolver.resolveSelectors(selectors, options);
|
|
377
|
+
} catch (error) {
|
|
378
|
+
tabDebug("Failed to resolve element locators:", error);
|
|
379
|
+
throw error;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
async resolveSingleElementLocator(selector, options) {
|
|
383
|
+
tabDebug("Resolving single element locator:", selector);
|
|
384
|
+
try {
|
|
385
|
+
return await this._selectorResolver.resolveSingleSelector(selector, options);
|
|
386
|
+
} catch (error) {
|
|
387
|
+
tabDebug("Failed to resolve single element locator:", error);
|
|
388
|
+
throw error;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
371
391
|
async refLocator(params) {
|
|
372
|
-
|
|
392
|
+
const { selector } = params;
|
|
393
|
+
tabDebug("Using selector system for element:", params.element);
|
|
394
|
+
const result = await this._selectorResolver.resolveSingleSelector(selector);
|
|
395
|
+
if (result.error || !result.locator) {
|
|
396
|
+
const errorMessage = `Failed to resolve selector for element "${params.element}": ${result.error || "Unknown error"}`;
|
|
397
|
+
const alternativesMessage = result.alternatives ? `. Alternatives: ${JSON.stringify(result.alternatives)}` : "";
|
|
398
|
+
throw new Error(errorMessage + alternativesMessage);
|
|
399
|
+
}
|
|
400
|
+
return result.locator.describe(params.element);
|
|
373
401
|
}
|
|
374
402
|
async refLocators(params) {
|
|
375
|
-
const
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
const selector = this._customRefMappings.get(param.ref);
|
|
379
|
-
if (selector) {
|
|
380
|
-
return this.page.locator(selector).describe(param.element);
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
if (!snapshot.includes(`[ref=${param.ref}]`)) {
|
|
384
|
-
const availableRefs = this._getAvailableRefs(snapshot);
|
|
385
|
-
throw new Error(`Ref ${param.ref} not found. Available refs: [${availableRefs.join(", ")}]. Element: ${param.element}. Consider capturing a new snapshot if the page has changed.`);
|
|
403
|
+
const selectors = params.map((p) => {
|
|
404
|
+
if (!p.selector) {
|
|
405
|
+
throw new Error(`Missing selector for element: ${p.element}`);
|
|
386
406
|
}
|
|
387
|
-
return
|
|
407
|
+
return p.selector;
|
|
388
408
|
});
|
|
409
|
+
const resolutionResults = await this._selectorResolver.resolveSelectors(selectors);
|
|
410
|
+
const results = [];
|
|
411
|
+
for (let i = 0;i < resolutionResults.length; i++) {
|
|
412
|
+
const result = resolutionResults[i];
|
|
413
|
+
const param = params[i];
|
|
414
|
+
if (result.error || !result.locator) {
|
|
415
|
+
const errorMessage = `Failed to resolve selector for element "${param.element}": ${result.error || "Unknown error"}`;
|
|
416
|
+
const alternativesMessage = result.alternatives ? `. Alternatives: ${JSON.stringify(result.alternatives)}` : "";
|
|
417
|
+
throw new Error(errorMessage + alternativesMessage);
|
|
418
|
+
}
|
|
419
|
+
results.push(result.locator.describe(param.element));
|
|
420
|
+
}
|
|
421
|
+
return results;
|
|
389
422
|
}
|
|
390
423
|
async waitForTimeout(time) {
|
|
391
424
|
if (this._javaScriptBlocked()) {
|
|
@@ -396,16 +429,6 @@ class Tab extends EventEmitter {
|
|
|
396
429
|
return page.evaluate((timeout) => new Promise((f) => setTimeout(f, timeout)), time);
|
|
397
430
|
});
|
|
398
431
|
}
|
|
399
|
-
_getAvailableRefs(snapshot) {
|
|
400
|
-
const refMatches = snapshot.match(/\[ref=([^\]]+)\]/g);
|
|
401
|
-
if (!refMatches) {
|
|
402
|
-
return [];
|
|
403
|
-
}
|
|
404
|
-
return refMatches.map((match) => {
|
|
405
|
-
const refValue = REF_VALUE_REGEX.exec(match);
|
|
406
|
-
return refValue ? refValue[1] : "";
|
|
407
|
-
}).filter(Boolean);
|
|
408
|
-
}
|
|
409
432
|
}
|
|
410
433
|
function messageToConsoleMessage(message) {
|
|
411
434
|
return {
|
|
@@ -79,28 +79,12 @@ class BaseToolHandler {
|
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
class BaseElementToolHandler extends BaseToolHandler {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
return await tab.refLocator({
|
|
85
|
-
ref: params.ref,
|
|
86
|
-
element: params.element
|
|
87
|
-
});
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
validateElementParams(params) {
|
|
91
|
-
if (params.ref && !params.element) {
|
|
92
|
-
throw new Error("Element description is required when ref is provided");
|
|
93
|
-
}
|
|
94
|
-
if (params.element && !params.ref) {
|
|
95
|
-
throw new Error("Element ref is required when element description is provided");
|
|
96
|
-
}
|
|
82
|
+
resolveElementLocator(_tab, _params) {
|
|
83
|
+
return Promise.resolve(undefined);
|
|
97
84
|
}
|
|
85
|
+
validateElementParams(_params) {}
|
|
98
86
|
handleToolError(error, response, params) {
|
|
99
87
|
super.handleToolError(error, response, params);
|
|
100
|
-
if (params.element && params.ref) {
|
|
101
|
-
response.addResult("Suggestion: Verify element selector is still valid");
|
|
102
|
-
response.addResult("Suggestion: Check if element is visible and interactable");
|
|
103
|
-
}
|
|
104
88
|
}
|
|
105
89
|
}
|
|
106
90
|
function createToolWithBaseHandler(config, handlerClass) {
|