chrometools-mcp 3.3.8 → 3.3.9
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/CHANGELOG.md +40 -0
- package/README.md +129 -24
- package/SPEC-pom-integration.md +227 -0
- package/SPEC-swagger-api-tools.md +3101 -0
- package/index.js +503 -198
- package/package.json +2 -1
- package/pom/apom-tree-converter.js +5 -26
- package/recorder/page-object-generator.js +45 -1
- package/server/tool-definitions.js +54 -5
- package/server/tool-schemas.js +29 -0
- package/test-swagger-phase1.mjs +959 -0
- package/utils/api-generators/api-models-python.js +448 -0
- package/utils/api-generators/api-models-typescript.js +375 -0
- package/utils/code-generators/code-generator-base.js +111 -6
- package/utils/code-generators/playwright-python.js +74 -0
- package/utils/code-generators/playwright-typescript.js +69 -0
- package/utils/code-generators/pom-integrator.js +373 -0
- package/utils/code-generators/selenium-java.js +72 -0
- package/utils/code-generators/selenium-python.js +75 -0
- package/utils/hints-generator.js +114 -19
- package/utils/openapi/helpers.js +25 -0
- package/utils/openapi/parser.js +448 -0
- package/utils/openapi/ref-resolver.js +149 -0
- package/utils/openapi/type-mapper.js +174 -0
- package/nul +0 -0
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* utils/code-generators/pom-integrator.js
|
|
3
|
+
*
|
|
4
|
+
* Links POM elements with scenario actions via selector matching.
|
|
5
|
+
* Also parses existing POM files to extract element metadata.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Match action selector to POM element
|
|
10
|
+
* @param {string} actionSelector - Selector from recorded action
|
|
11
|
+
* @param {Array} pomElements - Array of POM element metadata
|
|
12
|
+
* @returns {Object|null} - Matched POM element or null
|
|
13
|
+
*/
|
|
14
|
+
export function matchActionToPomElement(actionSelector, pomElements) {
|
|
15
|
+
if (!actionSelector || !pomElements || pomElements.length === 0) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// 1. Exact match
|
|
20
|
+
const exact = pomElements.find(el => el.selector === actionSelector);
|
|
21
|
+
if (exact) return exact;
|
|
22
|
+
|
|
23
|
+
// 2. Normalized match - strip tag prefix (e.g., "input#username" -> "#username")
|
|
24
|
+
const normalizedAction = normalizeSelector(actionSelector);
|
|
25
|
+
const normalized = pomElements.find(el => normalizeSelector(el.selector) === normalizedAction);
|
|
26
|
+
if (normalized) return normalized;
|
|
27
|
+
|
|
28
|
+
// 3. Key-based match - compare by id, name, or data-testid values
|
|
29
|
+
const actionKeys = extractSelectorKeys(actionSelector);
|
|
30
|
+
if (actionKeys) {
|
|
31
|
+
for (const el of pomElements) {
|
|
32
|
+
const elKeys = extractSelectorKeys(el.selector);
|
|
33
|
+
if (elKeys && keysMatch(actionKeys, elKeys)) {
|
|
34
|
+
return el;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// 4. No match
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Normalize selector by stripping tag prefix
|
|
45
|
+
* "input#username" -> "#username"
|
|
46
|
+
* "input[name='email']" -> "[name='email']"
|
|
47
|
+
*/
|
|
48
|
+
function normalizeSelector(selector) {
|
|
49
|
+
// Strip leading tag name before #, ., or [
|
|
50
|
+
return selector.replace(/^[a-z]+(?=[#.\[])/, '');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Extract key identifiers from selector
|
|
55
|
+
* Returns { type, value } or null
|
|
56
|
+
*/
|
|
57
|
+
function extractSelectorKeys(selector) {
|
|
58
|
+
// ID selector: #username or input#username
|
|
59
|
+
const idMatch = selector.match(/#([a-zA-Z0-9_-]+)/);
|
|
60
|
+
if (idMatch) return { type: 'id', value: idMatch[1] };
|
|
61
|
+
|
|
62
|
+
// Name attribute: [name="email"] or input[name="email"]
|
|
63
|
+
const nameMatch = selector.match(/\[name=["']([^"']+)["']\]/);
|
|
64
|
+
if (nameMatch) return { type: 'name', value: nameMatch[1] };
|
|
65
|
+
|
|
66
|
+
// Data-testid: [data-testid="submit-btn"]
|
|
67
|
+
const testIdMatch = selector.match(/\[data-testid=["']([^"']+)["']\]/);
|
|
68
|
+
if (testIdMatch) return { type: 'data-testid', value: testIdMatch[1] };
|
|
69
|
+
|
|
70
|
+
// Data-test: [data-test="submit-btn"]
|
|
71
|
+
const dataTestMatch = selector.match(/\[data-test=["']([^"']+)["']\]/);
|
|
72
|
+
if (dataTestMatch) return { type: 'data-test', value: dataTestMatch[1] };
|
|
73
|
+
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Compare two extracted keys
|
|
79
|
+
*/
|
|
80
|
+
function keysMatch(a, b) {
|
|
81
|
+
// Same type and value
|
|
82
|
+
if (a.type === b.type && a.value === b.value) return true;
|
|
83
|
+
|
|
84
|
+
// Cross-type: id matches name if values are the same
|
|
85
|
+
if (a.value === b.value) return true;
|
|
86
|
+
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Parse existing POM file to extract element metadata
|
|
92
|
+
* @param {string} fileContent - POM file content
|
|
93
|
+
* @param {string} framework - Framework identifier
|
|
94
|
+
* @returns {Object} - { className, elements: [{name, selector, methodName, methodType}] }
|
|
95
|
+
*/
|
|
96
|
+
export function parsePomFile(fileContent, framework) {
|
|
97
|
+
switch (framework) {
|
|
98
|
+
case 'playwright-typescript':
|
|
99
|
+
return parsePlaywrightTypeScript(fileContent);
|
|
100
|
+
case 'playwright-python':
|
|
101
|
+
return parsePlaywrightPython(fileContent);
|
|
102
|
+
case 'selenium-python':
|
|
103
|
+
return parseSeleniumPython(fileContent);
|
|
104
|
+
case 'selenium-java':
|
|
105
|
+
return parseSeleniumJava(fileContent);
|
|
106
|
+
default:
|
|
107
|
+
throw new Error(`Unsupported framework for POM parsing: ${framework}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Parse Playwright TypeScript POM file
|
|
113
|
+
* Looks for: this.X = page.locator('...')
|
|
114
|
+
* async fillX( / async clickX(
|
|
115
|
+
*/
|
|
116
|
+
function parsePlaywrightTypeScript(content) {
|
|
117
|
+
const classMatch = content.match(/export\s+class\s+(\w+)/);
|
|
118
|
+
const className = classMatch ? classMatch[1] : 'UnknownPage';
|
|
119
|
+
|
|
120
|
+
const elements = [];
|
|
121
|
+
// Match both: page.locator('selector') and page.locator("selector")
|
|
122
|
+
// Handle selectors that contain the other type of quotes inside
|
|
123
|
+
const locatorRegex = /this\.(\w+)\s*=\s*page\.locator\((?:'([^']*)'|"([^"]*)")\)/g;
|
|
124
|
+
let match;
|
|
125
|
+
|
|
126
|
+
while ((match = locatorRegex.exec(content)) !== null) {
|
|
127
|
+
const name = match[1];
|
|
128
|
+
const selector = match[2] || match[3]; // group 2 for single-quoted, group 3 for double-quoted
|
|
129
|
+
if (name === 'page') continue;
|
|
130
|
+
|
|
131
|
+
const methodInfo = findTypeScriptMethod(content, name);
|
|
132
|
+
elements.push({
|
|
133
|
+
name,
|
|
134
|
+
selector,
|
|
135
|
+
tag: guessTagFromSelector(selector),
|
|
136
|
+
type: guessTypeFromSelector(selector),
|
|
137
|
+
methodName: methodInfo.methodName,
|
|
138
|
+
methodType: methodInfo.methodType
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return { className, elements };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Find TypeScript method for element
|
|
147
|
+
*/
|
|
148
|
+
function findTypeScriptMethod(content, elementName) {
|
|
149
|
+
const capName = elementName.charAt(0).toUpperCase() + elementName.slice(1);
|
|
150
|
+
|
|
151
|
+
// Check for fill method
|
|
152
|
+
const fillRegex = new RegExp(`async\\s+fill${capName}\\s*\\(`);
|
|
153
|
+
if (fillRegex.test(content)) {
|
|
154
|
+
return { methodName: `fill${capName}`, methodType: 'fill' };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Check for click method
|
|
158
|
+
const clickRegex = new RegExp(`async\\s+click${capName}\\s*\\(`);
|
|
159
|
+
if (clickRegex.test(content)) {
|
|
160
|
+
return { methodName: `click${capName}`, methodType: 'click' };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Check for select method
|
|
164
|
+
const selectRegex = new RegExp(`async\\s+select${capName}\\s*\\(`);
|
|
165
|
+
if (selectRegex.test(content)) {
|
|
166
|
+
return { methodName: `select${capName}`, methodType: 'select' };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Default based on element name heuristics
|
|
170
|
+
return { methodName: elementName, methodType: 'click' };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Parse Playwright Python POM file
|
|
175
|
+
* Looks for: self.X = page.locator('...')
|
|
176
|
+
* def fill_X( / def click_X(
|
|
177
|
+
*/
|
|
178
|
+
function parsePlaywrightPython(content) {
|
|
179
|
+
const classMatch = content.match(/class\s+(\w+)/);
|
|
180
|
+
const className = classMatch ? classMatch[1] : 'UnknownPage';
|
|
181
|
+
|
|
182
|
+
const elements = [];
|
|
183
|
+
const locatorRegex = /self\.(\w+)\s*=\s*page\.locator\((?:'([^']*)'|"([^"]*)")\)/g;
|
|
184
|
+
let match;
|
|
185
|
+
|
|
186
|
+
while ((match = locatorRegex.exec(content)) !== null) {
|
|
187
|
+
const name = match[1];
|
|
188
|
+
const selector = match[2] || match[3];
|
|
189
|
+
if (name === 'page') continue;
|
|
190
|
+
|
|
191
|
+
const methodInfo = findPythonMethod(content, name);
|
|
192
|
+
elements.push({
|
|
193
|
+
name,
|
|
194
|
+
selector,
|
|
195
|
+
tag: guessTagFromSelector(selector),
|
|
196
|
+
type: guessTypeFromSelector(selector),
|
|
197
|
+
methodName: methodInfo.methodName,
|
|
198
|
+
methodType: methodInfo.methodType
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return { className, elements };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Find Python method for element
|
|
207
|
+
*/
|
|
208
|
+
function findPythonMethod(content, elementName) {
|
|
209
|
+
const fillRegex = new RegExp(`def\\s+fill_${elementName}\\s*\\(`);
|
|
210
|
+
if (fillRegex.test(content)) {
|
|
211
|
+
return { methodName: `fill_${elementName}`, methodType: 'fill' };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const clickRegex = new RegExp(`def\\s+click_${elementName}\\s*\\(`);
|
|
215
|
+
if (clickRegex.test(content)) {
|
|
216
|
+
return { methodName: `click_${elementName}`, methodType: 'click' };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const selectRegex = new RegExp(`def\\s+select_${elementName}\\s*\\(`);
|
|
220
|
+
if (selectRegex.test(content)) {
|
|
221
|
+
return { methodName: `select_${elementName}`, methodType: 'select' };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return { methodName: elementName, methodType: 'click' };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Parse Selenium Python POM file
|
|
229
|
+
* Looks for: X = (By.CSS_SELECTOR, '...')
|
|
230
|
+
* def fill_X( / def click_X(
|
|
231
|
+
*/
|
|
232
|
+
function parseSeleniumPython(content) {
|
|
233
|
+
const classMatch = content.match(/class\s+(\w+)/);
|
|
234
|
+
const className = classMatch ? classMatch[1] : 'UnknownPage';
|
|
235
|
+
|
|
236
|
+
const elements = [];
|
|
237
|
+
// Match: ELEMENT_NAME = (By.ID, 'value') or (By.CSS_SELECTOR, 'value') etc.
|
|
238
|
+
const locatorRegex = /(\w+)\s*=\s*\(By\.(\w+),\s*['"]([^'"]+)['"]\)/g;
|
|
239
|
+
let match;
|
|
240
|
+
|
|
241
|
+
while ((match = locatorRegex.exec(content)) !== null) {
|
|
242
|
+
const name = match[1].toLowerCase();
|
|
243
|
+
const byType = match[2];
|
|
244
|
+
const value = match[3];
|
|
245
|
+
const selector = convertByToSelector(byType, value);
|
|
246
|
+
|
|
247
|
+
const methodInfo = findPythonMethod(content, name);
|
|
248
|
+
elements.push({
|
|
249
|
+
name,
|
|
250
|
+
selector,
|
|
251
|
+
tag: guessTagFromSelector(selector),
|
|
252
|
+
type: guessTypeFromSelector(selector),
|
|
253
|
+
methodName: methodInfo.methodName,
|
|
254
|
+
methodType: methodInfo.methodType
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return { className, elements };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Parse Selenium Java POM file
|
|
263
|
+
* Looks for: By X = By.cssSelector("...")
|
|
264
|
+
* void fillX( / void clickX(
|
|
265
|
+
*/
|
|
266
|
+
function parseSeleniumJava(content) {
|
|
267
|
+
const classMatch = content.match(/public\s+class\s+(\w+)/);
|
|
268
|
+
const className = classMatch ? classMatch[1] : 'UnknownPage';
|
|
269
|
+
|
|
270
|
+
const elements = [];
|
|
271
|
+
// Match: private static final By ELEMENT = By.id("value") or By.cssSelector("value")
|
|
272
|
+
const locatorRegex = /(?:private\s+)?(?:static\s+)?(?:final\s+)?By\s+(\w+)\s*=\s*By\.(\w+)\(["']([^"']+)["']\)/g;
|
|
273
|
+
let match;
|
|
274
|
+
|
|
275
|
+
while ((match = locatorRegex.exec(content)) !== null) {
|
|
276
|
+
const name = match[1].toLowerCase();
|
|
277
|
+
const byMethod = match[2];
|
|
278
|
+
const value = match[3];
|
|
279
|
+
const selector = convertJavaByToSelector(byMethod, value);
|
|
280
|
+
|
|
281
|
+
const methodInfo = findJavaMethod(content, match[1]);
|
|
282
|
+
elements.push({
|
|
283
|
+
name,
|
|
284
|
+
selector,
|
|
285
|
+
tag: guessTagFromSelector(selector),
|
|
286
|
+
type: guessTypeFromSelector(selector),
|
|
287
|
+
methodName: methodInfo.methodName,
|
|
288
|
+
methodType: methodInfo.methodType
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return { className, elements };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Find Java method for element
|
|
297
|
+
*/
|
|
298
|
+
function findJavaMethod(content, elementName) {
|
|
299
|
+
// Convert UPPER_CASE to camelCase for method lookup
|
|
300
|
+
// e.g., SUBMIT_BTN -> submitBtn, EMAIL -> email
|
|
301
|
+
let camelName;
|
|
302
|
+
if (elementName === elementName.toUpperCase()) {
|
|
303
|
+
// ALL_CAPS: split by _, lowercase, then camelCase
|
|
304
|
+
camelName = elementName.toLowerCase().replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
305
|
+
} else {
|
|
306
|
+
camelName = elementName.replace(/_([a-z])/gi, (_, c) => c.toUpperCase());
|
|
307
|
+
}
|
|
308
|
+
const capName = camelName.charAt(0).toUpperCase() + camelName.slice(1);
|
|
309
|
+
|
|
310
|
+
const fillRegex = new RegExp(`void\\s+fill${capName}\\s*\\(`);
|
|
311
|
+
if (fillRegex.test(content)) {
|
|
312
|
+
return { methodName: `fill${capName}`, methodType: 'fill' };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const clickRegex = new RegExp(`void\\s+click${capName}\\s*\\(`);
|
|
316
|
+
if (clickRegex.test(content)) {
|
|
317
|
+
return { methodName: `click${capName}`, methodType: 'click' };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const selectRegex = new RegExp(`void\\s+select${capName}\\s*\\(`);
|
|
321
|
+
if (selectRegex.test(content)) {
|
|
322
|
+
return { methodName: `select${capName}`, methodType: 'select' };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return { methodName: camelName, methodType: 'click' };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Convert Selenium Python By type to CSS selector
|
|
330
|
+
*/
|
|
331
|
+
function convertByToSelector(byType, value) {
|
|
332
|
+
switch (byType) {
|
|
333
|
+
case 'ID': return `#${value}`;
|
|
334
|
+
case 'NAME': return `[name="${value}"]`;
|
|
335
|
+
case 'CLASS_NAME': return `.${value}`;
|
|
336
|
+
case 'CSS_SELECTOR': return value;
|
|
337
|
+
case 'TAG_NAME': return value;
|
|
338
|
+
default: return value;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Convert Selenium Java By method to CSS selector
|
|
344
|
+
*/
|
|
345
|
+
function convertJavaByToSelector(byMethod, value) {
|
|
346
|
+
switch (byMethod) {
|
|
347
|
+
case 'id': return `#${value}`;
|
|
348
|
+
case 'name': return `[name="${value}"]`;
|
|
349
|
+
case 'className': return `.${value}`;
|
|
350
|
+
case 'cssSelector': return value;
|
|
351
|
+
case 'tagName': return value;
|
|
352
|
+
default: return value;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Guess HTML tag from selector
|
|
358
|
+
*/
|
|
359
|
+
function guessTagFromSelector(selector) {
|
|
360
|
+
const tagMatch = selector.match(/^(input|textarea|select|button|a)\b/);
|
|
361
|
+
return tagMatch ? tagMatch[1] : 'element';
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Guess element type from selector
|
|
366
|
+
*/
|
|
367
|
+
function guessTypeFromSelector(selector) {
|
|
368
|
+
if (selector.includes('type="password"')) return 'password';
|
|
369
|
+
if (selector.includes('type="email"')) return 'email';
|
|
370
|
+
if (selector.includes('type="text"')) return 'text';
|
|
371
|
+
if (selector.includes('type="submit"')) return 'submit';
|
|
372
|
+
return 'element';
|
|
373
|
+
}
|
|
@@ -283,6 +283,78 @@ export class SeleniumJavaGenerator extends CodeGeneratorBase {
|
|
|
283
283
|
}
|
|
284
284
|
}
|
|
285
285
|
|
|
286
|
+
// ========================================
|
|
287
|
+
// POM INTEGRATION
|
|
288
|
+
// ========================================
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Generate POM import
|
|
292
|
+
*/
|
|
293
|
+
generatePomImports(className, importPath) {
|
|
294
|
+
return [`import ${importPath || 'pages'}.${className};`];
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Generate POM instantiation
|
|
299
|
+
*/
|
|
300
|
+
generatePomInstantiation(className) {
|
|
301
|
+
const varName = className.charAt(0).toLowerCase() + className.slice(1);
|
|
302
|
+
return [this.indent(`${className} ${varName} = new ${className}(driver);`), ''];
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Generate POM goto
|
|
307
|
+
*/
|
|
308
|
+
generatePomGoto(url) {
|
|
309
|
+
const varName = this.options.pomClassName.charAt(0).toLowerCase() + this.options.pomClassName.slice(1);
|
|
310
|
+
return [this.indent(`${varName}.goTo();`)];
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Generate POM-based action
|
|
315
|
+
*/
|
|
316
|
+
generatePomAction(action, pomElement) {
|
|
317
|
+
const varName = this.options.pomClassName.charAt(0).toLowerCase() + this.options.pomClassName.slice(1);
|
|
318
|
+
const lines = [];
|
|
319
|
+
|
|
320
|
+
const comment = this.generateActionComment(action);
|
|
321
|
+
if (comment.length > 0) lines.push(...comment);
|
|
322
|
+
|
|
323
|
+
switch (action.type) {
|
|
324
|
+
case 'type': {
|
|
325
|
+
const text = action.data?.text || '';
|
|
326
|
+
lines.push(this.indent(`${varName}.${pomElement.methodName}("${this.escapeString(text)}");`));
|
|
327
|
+
break;
|
|
328
|
+
}
|
|
329
|
+
case 'click':
|
|
330
|
+
if (pomElement.methodType === 'click') {
|
|
331
|
+
lines.push(this.indent(`${varName}.${pomElement.methodName}();`));
|
|
332
|
+
} else {
|
|
333
|
+
lines.push(this.indent(`${varName}.${pomElement.name}.click();`));
|
|
334
|
+
}
|
|
335
|
+
break;
|
|
336
|
+
case 'select': {
|
|
337
|
+
const value = action.data?.value || '';
|
|
338
|
+
lines.push(this.indent(`${varName}.${pomElement.methodName}("${this.escapeString(value)}");`));
|
|
339
|
+
break;
|
|
340
|
+
}
|
|
341
|
+
case 'hover': {
|
|
342
|
+
lines.push(this.indent(`WebElement element = driver.findElement(By.cssSelector("${this.escapeString(pomElement.selector)}"));`));
|
|
343
|
+
lines.push(this.indent(`Actions hoverActions = new Actions(driver);`));
|
|
344
|
+
lines.push(this.indent(`hoverActions.moveToElement(element).perform();`));
|
|
345
|
+
break;
|
|
346
|
+
}
|
|
347
|
+
case 'navigate':
|
|
348
|
+
lines.push(this.indent(`${varName}.goTo();`));
|
|
349
|
+
break;
|
|
350
|
+
default:
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (lines.length > 0) lines.push('');
|
|
355
|
+
return lines;
|
|
356
|
+
}
|
|
357
|
+
|
|
286
358
|
/**
|
|
287
359
|
* Generate URL assertion
|
|
288
360
|
*/
|
|
@@ -303,6 +303,81 @@ export class SeleniumPythonGenerator extends CodeGeneratorBase {
|
|
|
303
303
|
}
|
|
304
304
|
}
|
|
305
305
|
|
|
306
|
+
// ========================================
|
|
307
|
+
// POM INTEGRATION
|
|
308
|
+
// ========================================
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Generate POM import
|
|
312
|
+
*/
|
|
313
|
+
generatePomImports(className, importPath) {
|
|
314
|
+
if (importPath) {
|
|
315
|
+
return [`from ${importPath} import ${className}`];
|
|
316
|
+
}
|
|
317
|
+
const moduleName = className.replace(/([A-Z])/g, '_$1').toLowerCase().replace(/^_/, '');
|
|
318
|
+
return [`from ${moduleName} import ${className}`];
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Generate POM instantiation
|
|
323
|
+
*/
|
|
324
|
+
generatePomInstantiation(className) {
|
|
325
|
+
const varName = className.replace(/([A-Z])/g, '_$1').toLowerCase().replace(/^_/, '');
|
|
326
|
+
return [this.indent(`${varName} = ${className}(driver)`), ''];
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Generate POM goto
|
|
331
|
+
*/
|
|
332
|
+
generatePomGoto(url) {
|
|
333
|
+
const varName = this.options.pomClassName.replace(/([A-Z])/g, '_$1').toLowerCase().replace(/^_/, '');
|
|
334
|
+
return [this.indent(`${varName}.goto()`)];
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Generate POM-based action
|
|
339
|
+
*/
|
|
340
|
+
generatePomAction(action, pomElement) {
|
|
341
|
+
const varName = this.options.pomClassName.replace(/([A-Z])/g, '_$1').toLowerCase().replace(/^_/, '');
|
|
342
|
+
const lines = [];
|
|
343
|
+
|
|
344
|
+
const comment = this.generateActionComment(action);
|
|
345
|
+
if (comment.length > 0) lines.push(...comment);
|
|
346
|
+
|
|
347
|
+
switch (action.type) {
|
|
348
|
+
case 'type': {
|
|
349
|
+
const text = action.data?.text || '';
|
|
350
|
+
lines.push(this.indent(`${varName}.${pomElement.methodName}("${this.escapeString(text)}")`));
|
|
351
|
+
break;
|
|
352
|
+
}
|
|
353
|
+
case 'click':
|
|
354
|
+
if (pomElement.methodType === 'click') {
|
|
355
|
+
lines.push(this.indent(`${varName}.${pomElement.methodName}()`));
|
|
356
|
+
} else {
|
|
357
|
+
lines.push(this.indent(`${varName}.${pomElement.name}.click()`));
|
|
358
|
+
}
|
|
359
|
+
break;
|
|
360
|
+
case 'select': {
|
|
361
|
+
const value = action.data?.value || '';
|
|
362
|
+
lines.push(this.indent(`${varName}.${pomElement.methodName}("${this.escapeString(value)}")`));
|
|
363
|
+
break;
|
|
364
|
+
}
|
|
365
|
+
case 'hover': {
|
|
366
|
+
lines.push(this.indent(`element = ${varName}.driver.find_element(By.CSS_SELECTOR, "${this.escapeString(pomElement.selector)}")`));
|
|
367
|
+
lines.push(this.indent(`ActionChains(${varName}.driver).move_to_element(element).perform()`));
|
|
368
|
+
break;
|
|
369
|
+
}
|
|
370
|
+
case 'navigate':
|
|
371
|
+
lines.push(this.indent(`${varName}.goto()`));
|
|
372
|
+
break;
|
|
373
|
+
default:
|
|
374
|
+
return null;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (lines.length > 0) lines.push('');
|
|
378
|
+
return lines;
|
|
379
|
+
}
|
|
380
|
+
|
|
306
381
|
/**
|
|
307
382
|
* Generate URL assertion
|
|
308
383
|
*/
|
package/utils/hints-generator.js
CHANGED
|
@@ -6,7 +6,10 @@
|
|
|
6
6
|
/**
|
|
7
7
|
* Generate hints after page navigation
|
|
8
8
|
*/
|
|
9
|
-
export function generateNavigationHints(page, url) {
|
|
9
|
+
export async function generateNavigationHints(page, url) {
|
|
10
|
+
// Wait for SPA frameworks (Angular, React, Vue) to render after navigation
|
|
11
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
12
|
+
|
|
10
13
|
return page.evaluate(() => {
|
|
11
14
|
// Helper to get safe class selector (filters Tailwind special chars)
|
|
12
15
|
function getSafeClassSelector(element) {
|
|
@@ -30,6 +33,28 @@ export function generateNavigationHints(page, url) {
|
|
|
30
33
|
commonSelectors: {},
|
|
31
34
|
};
|
|
32
35
|
|
|
36
|
+
// Extract page heading (h1 first, then common framework title patterns)
|
|
37
|
+
// Helper: check if element is truly visible (not sr-only / visually-hidden)
|
|
38
|
+
function isReallyVisible(el) {
|
|
39
|
+
if (!el || el.offsetWidth === 0) return false;
|
|
40
|
+
if (el.offsetWidth <= 1 && el.offsetHeight <= 1) return false; // sr-only pattern
|
|
41
|
+
const style = getComputedStyle(el);
|
|
42
|
+
if (style.clip === 'rect(1px, 1px, 1px, 1px)' || style.clipPath === 'inset(50%)') return false;
|
|
43
|
+
if (style.visibility === 'hidden' || style.opacity === '0') return false;
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
const headingCandidates = [
|
|
47
|
+
document.querySelector('h1'),
|
|
48
|
+
document.querySelector('.page-title, [class*="page-title"]'),
|
|
49
|
+
document.querySelector('[class*="page-header"] h1, [class*="page-header"] h2'),
|
|
50
|
+
];
|
|
51
|
+
for (const el of headingCandidates) {
|
|
52
|
+
if (el && isReallyVisible(el)) {
|
|
53
|
+
hints.heading = el.textContent.trim().substring(0, 100);
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
33
58
|
// Detect page type
|
|
34
59
|
if (document.querySelector('form input[type="password"]')) {
|
|
35
60
|
hints.pageType = 'login';
|
|
@@ -126,18 +151,46 @@ export async function generateClickHints(page, selector) {
|
|
|
126
151
|
suggestedNext: [],
|
|
127
152
|
};
|
|
128
153
|
|
|
129
|
-
// Check for modals
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
type: 'modal',
|
|
136
|
-
selector: getSafeClassSelector(modal) || '[role="dialog"]',
|
|
137
|
-
});
|
|
138
|
-
hints.suggestedNext.push('Interact with modal or close it');
|
|
139
|
-
}
|
|
154
|
+
// Check for modals — find the topmost visible one
|
|
155
|
+
const modalEls = document.querySelectorAll('[role="dialog"], .modal, [class*="modal"], mat-dialog-container, .cdk-overlay-pane [role="dialog"], [class*="dialog"]');
|
|
156
|
+
const visibleModals = Array.from(modalEls).filter(m => m.offsetWidth > 0 && m.offsetHeight > 0);
|
|
157
|
+
// Deduplicate: skip modals nested inside another visible modal
|
|
158
|
+
const topModals = visibleModals.filter(modal => {
|
|
159
|
+
return !visibleModals.some(other => other !== modal && other.contains(modal));
|
|
140
160
|
});
|
|
161
|
+
// Take only the topmost modal (highest z-index or last in DOM)
|
|
162
|
+
const topModal = topModals.length > 0 ? topModals[topModals.length - 1] : null;
|
|
163
|
+
if (topModal) {
|
|
164
|
+
hints.modalOpened = true;
|
|
165
|
+
// Extract modal title
|
|
166
|
+
const titleEl = topModal.querySelector('h1, h2, h3, .modal-title, [mat-dialog-title], .dialog-title');
|
|
167
|
+
const title = titleEl ? titleEl.textContent.trim().substring(0, 100) : null;
|
|
168
|
+
// Extract body text (excluding title and buttons)
|
|
169
|
+
let bodyText = null;
|
|
170
|
+
const bodyEl = topModal.querySelector('.modal-body, [mat-dialog-content], .dialog-content, .dialog-body');
|
|
171
|
+
if (bodyEl) {
|
|
172
|
+
bodyText = bodyEl.textContent.trim().substring(0, 200);
|
|
173
|
+
} else if (!titleEl) {
|
|
174
|
+
// Fallback: get modal text directly
|
|
175
|
+
bodyText = topModal.textContent.trim().substring(0, 200);
|
|
176
|
+
}
|
|
177
|
+
// Extract action buttons (from footer or dialog-actions, limit to 5)
|
|
178
|
+
const actionButtons = [];
|
|
179
|
+
const actionsContainer = topModal.querySelector('.modal-footer, [mat-dialog-actions], .dialog-actions, .dialog-footer');
|
|
180
|
+
const btnScope = actionsContainer || topModal;
|
|
181
|
+
btnScope.querySelectorAll('button, [mat-button], [mat-raised-button], [mat-flat-button], a[role="button"]').forEach(btn => {
|
|
182
|
+
const text = btn.textContent.trim();
|
|
183
|
+
if (text && text.length < 50 && actionButtons.length < 5) actionButtons.push(text);
|
|
184
|
+
});
|
|
185
|
+
hints.newElements.push({
|
|
186
|
+
type: 'modal',
|
|
187
|
+
selector: getSafeClassSelector(topModal) || '[role="dialog"]',
|
|
188
|
+
title: title,
|
|
189
|
+
text: bodyText,
|
|
190
|
+
actions: actionButtons.length > 0 ? actionButtons : undefined,
|
|
191
|
+
});
|
|
192
|
+
hints.suggestedNext.push('Interact with modal or close it');
|
|
193
|
+
}
|
|
141
194
|
|
|
142
195
|
// Check for new alerts/notifications
|
|
143
196
|
const alerts = document.querySelectorAll('.alert, [role="alert"], .notification');
|
|
@@ -152,15 +205,57 @@ export async function generateClickHints(page, selector) {
|
|
|
152
205
|
}
|
|
153
206
|
});
|
|
154
207
|
|
|
155
|
-
// Check for dropdowns
|
|
156
|
-
const
|
|
157
|
-
|
|
208
|
+
// Check for dropdowns, overlays, menus
|
|
209
|
+
const overlaySelectors = [
|
|
210
|
+
'[class*="dropdown"][class*="open"]',
|
|
211
|
+
'[class*="dropdown"][class*="show"]',
|
|
212
|
+
'.cdk-overlay-pane',
|
|
213
|
+
'[role="listbox"]',
|
|
214
|
+
'[role="menu"]',
|
|
215
|
+
'.mat-select-panel',
|
|
216
|
+
'.mat-mdc-select-panel',
|
|
217
|
+
'[class*="overlay"][class*="open"]',
|
|
218
|
+
'.p-dropdown-panel',
|
|
219
|
+
'.ant-dropdown:not(.ant-dropdown-hidden)',
|
|
220
|
+
'[class*="select-options"]',
|
|
221
|
+
].join(', ');
|
|
222
|
+
const overlayEls = document.querySelectorAll(overlaySelectors);
|
|
223
|
+
// Deduplicate: skip overlays that are children of another matched overlay
|
|
224
|
+
const overlays = Array.from(overlayEls).filter(el => el.offsetWidth > 0 && el.offsetHeight > 0);
|
|
225
|
+
const dedupedOverlays = overlays.filter(overlay => {
|
|
226
|
+
return !overlays.some(other => other !== overlay && other.contains(overlay));
|
|
227
|
+
});
|
|
228
|
+
dedupedOverlays.forEach(overlay => {
|
|
229
|
+
// Determine type: explicit menu roles, or custom select-options pattern (but NOT mat-option-text)
|
|
230
|
+
const isMenu = overlay.matches('[role="menu"]') ||
|
|
231
|
+
overlay.querySelector('[role="menuitem"]') !== null ||
|
|
232
|
+
(overlay.matches('[class*="select-options"]') && !overlay.querySelector('mat-option'));
|
|
233
|
+
const isDropdown = overlay.matches('[role="listbox"]') ||
|
|
234
|
+
overlay.querySelector('mat-option, [role="option"]') !== null;
|
|
235
|
+
const type = isMenu ? 'menu' : 'dropdown';
|
|
236
|
+
// Extract items — use specific selectors to avoid duplicates from nested matches
|
|
237
|
+
let itemEls;
|
|
238
|
+
if (isDropdown) {
|
|
239
|
+
itemEls = overlay.querySelectorAll('mat-option, [role="option"]');
|
|
240
|
+
} else if (isMenu) {
|
|
241
|
+
itemEls = overlay.querySelectorAll('[role="menuitem"], [class*="option-text"]');
|
|
242
|
+
} else {
|
|
243
|
+
itemEls = overlay.querySelectorAll('mat-option, [role="option"], [role="menuitem"], li, .dropdown-item, .p-dropdown-item, .ant-dropdown-menu-item, [class*="option-text"]');
|
|
244
|
+
}
|
|
245
|
+
const items = [];
|
|
246
|
+
const totalCount = itemEls.length;
|
|
247
|
+
itemEls.forEach((item, i) => {
|
|
248
|
+
if (i < 10 && item.textContent.trim()) {
|
|
249
|
+
items.push(item.textContent.trim().substring(0, 80));
|
|
250
|
+
}
|
|
251
|
+
});
|
|
158
252
|
hints.newElements.push({
|
|
159
|
-
type
|
|
160
|
-
|
|
253
|
+
type,
|
|
254
|
+
items: items.length > 0 ? items : undefined,
|
|
255
|
+
totalCount: totalCount,
|
|
161
256
|
});
|
|
162
|
-
hints.suggestedNext.push('Select option from dropdown');
|
|
163
|
-
}
|
|
257
|
+
hints.suggestedNext.push(type === 'menu' ? 'Select menu item' : 'Select option from dropdown');
|
|
258
|
+
});
|
|
164
259
|
|
|
165
260
|
return hints;
|
|
166
261
|
}, selector);
|