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.
@@ -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
  */
@@ -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 modals = document.querySelectorAll('[role="dialog"], .modal, [class*="modal"]');
131
- modals.forEach(modal => {
132
- if (modal.offsetWidth > 0 && modal.offsetHeight > 0) {
133
- hints.modalOpened = true;
134
- hints.newElements.push({
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 dropdowns = document.querySelectorAll('[class*="dropdown"][class*="open"], [aria-expanded="true"]');
157
- if (dropdowns.length > 0) {
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: 'dropdown',
160
- count: dropdowns.length,
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);