chrometools-mcp 3.3.8 → 3.4.0
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 +51 -0
- package/README.md +159 -24
- package/SPEC-pom-integration.md +227 -0
- package/SPEC-swagger-api-tools.md +3101 -0
- package/index.js +591 -209
- 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 +159 -24
- 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/utils/post-click-diagnostics.js +14 -4
- package/nul +0 -0
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,11 +33,50 @@ 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
|
+
|
|
58
|
+
// Helper: detect login/auth page (broad detection)
|
|
59
|
+
function isLoginPage() {
|
|
60
|
+
// Classic password form
|
|
61
|
+
if (document.querySelector('form input[type="password"]')) return true;
|
|
62
|
+
// Phone/OTP login (input[type="tel"] inside a form with few fields)
|
|
63
|
+
const telInput = document.querySelector('form input[type="tel"]');
|
|
64
|
+
if (telInput) {
|
|
65
|
+
const form = telInput.closest('form');
|
|
66
|
+
if (form && form.querySelectorAll('input:not([type="hidden"])').length <= 3) return true;
|
|
67
|
+
}
|
|
68
|
+
// URL-based detection (/login, /signin, /auth in path)
|
|
69
|
+
if (/\/(login|signin|sign-in|auth|authenticate)(\/|$|\?)/i.test(window.location.pathname)) return true;
|
|
70
|
+
// Class/ID-based detection
|
|
71
|
+
if (document.querySelector('[class*="login-form"], [class*="signin-form"], [class*="auth-form"], [id*="login-form"], [id*="signin-form"]')) return true;
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
|
|
33
75
|
// Detect page type
|
|
34
|
-
if (
|
|
76
|
+
if (isLoginPage()) {
|
|
35
77
|
hints.pageType = 'login';
|
|
36
78
|
hints.suggestedNext.push('Fill login credentials and submit');
|
|
37
|
-
hints.commonSelectors.usernameField = 'input[type="email"], input[name*="user"], input[name*="email"]';
|
|
79
|
+
hints.commonSelectors.usernameField = 'input[type="email"], input[type="tel"], input[name*="user"], input[name*="email"], input[name*="phone"]';
|
|
38
80
|
hints.commonSelectors.passwordField = 'input[type="password"]';
|
|
39
81
|
hints.commonSelectors.submitButton = 'button[type="submit"], input[type="submit"]';
|
|
40
82
|
} else if (document.querySelector('form') && document.querySelectorAll('form input').length > 3) {
|
|
@@ -51,6 +93,18 @@ export function generateNavigationHints(page, url) {
|
|
|
51
93
|
hints.suggestedNext.push('Browse items or use filters');
|
|
52
94
|
}
|
|
53
95
|
|
|
96
|
+
// Detect auth redirect (landed on login page with returnUrl param)
|
|
97
|
+
const returnUrlPatterns = ['returnUrl', 'return_url', 'redirect', 'redirect_uri', 'next', 'return_to', 'RelayState'];
|
|
98
|
+
const urlParams = new URL(window.location.href).searchParams;
|
|
99
|
+
const returnParam = returnUrlPatterns.find(p => urlParams.has(p));
|
|
100
|
+
if (hints.pageType === 'login' && returnParam) {
|
|
101
|
+
hints.authRedirect = {
|
|
102
|
+
detected: true,
|
|
103
|
+
returnUrl: urlParams.get(returnParam),
|
|
104
|
+
};
|
|
105
|
+
hints.suggestedNext = ['⚠️ Auth redirect detected — complete login first, then navigate to intended page'];
|
|
106
|
+
}
|
|
107
|
+
|
|
54
108
|
// Available actions
|
|
55
109
|
const forms = document.querySelectorAll('form');
|
|
56
110
|
if (forms.length > 0) {
|
|
@@ -126,18 +180,46 @@ export async function generateClickHints(page, selector) {
|
|
|
126
180
|
suggestedNext: [],
|
|
127
181
|
};
|
|
128
182
|
|
|
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
|
-
}
|
|
183
|
+
// Check for modals — find the topmost visible one
|
|
184
|
+
const modalEls = document.querySelectorAll('[role="dialog"], .modal, [class*="modal"], mat-dialog-container, .cdk-overlay-pane [role="dialog"], [class*="dialog"]');
|
|
185
|
+
const visibleModals = Array.from(modalEls).filter(m => m.offsetWidth > 0 && m.offsetHeight > 0);
|
|
186
|
+
// Deduplicate: skip modals nested inside another visible modal
|
|
187
|
+
const topModals = visibleModals.filter(modal => {
|
|
188
|
+
return !visibleModals.some(other => other !== modal && other.contains(modal));
|
|
140
189
|
});
|
|
190
|
+
// Take only the topmost modal (highest z-index or last in DOM)
|
|
191
|
+
const topModal = topModals.length > 0 ? topModals[topModals.length - 1] : null;
|
|
192
|
+
if (topModal) {
|
|
193
|
+
hints.modalOpened = true;
|
|
194
|
+
// Extract modal title
|
|
195
|
+
const titleEl = topModal.querySelector('h1, h2, h3, .modal-title, [mat-dialog-title], .dialog-title');
|
|
196
|
+
const title = titleEl ? titleEl.textContent.trim().substring(0, 100) : null;
|
|
197
|
+
// Extract body text (excluding title and buttons)
|
|
198
|
+
let bodyText = null;
|
|
199
|
+
const bodyEl = topModal.querySelector('.modal-body, [mat-dialog-content], .dialog-content, .dialog-body');
|
|
200
|
+
if (bodyEl) {
|
|
201
|
+
bodyText = bodyEl.textContent.trim().substring(0, 200);
|
|
202
|
+
} else if (!titleEl) {
|
|
203
|
+
// Fallback: get modal text directly
|
|
204
|
+
bodyText = topModal.textContent.trim().substring(0, 200);
|
|
205
|
+
}
|
|
206
|
+
// Extract action buttons (from footer or dialog-actions, limit to 5)
|
|
207
|
+
const actionButtons = [];
|
|
208
|
+
const actionsContainer = topModal.querySelector('.modal-footer, [mat-dialog-actions], .dialog-actions, .dialog-footer');
|
|
209
|
+
const btnScope = actionsContainer || topModal;
|
|
210
|
+
btnScope.querySelectorAll('button, [mat-button], [mat-raised-button], [mat-flat-button], a[role="button"]').forEach(btn => {
|
|
211
|
+
const text = btn.textContent.trim();
|
|
212
|
+
if (text && text.length < 50 && actionButtons.length < 5) actionButtons.push(text);
|
|
213
|
+
});
|
|
214
|
+
hints.newElements.push({
|
|
215
|
+
type: 'modal',
|
|
216
|
+
selector: getSafeClassSelector(topModal) || '[role="dialog"]',
|
|
217
|
+
title: title,
|
|
218
|
+
text: bodyText,
|
|
219
|
+
actions: actionButtons.length > 0 ? actionButtons : undefined,
|
|
220
|
+
});
|
|
221
|
+
hints.suggestedNext.push('Interact with modal or close it');
|
|
222
|
+
}
|
|
141
223
|
|
|
142
224
|
// Check for new alerts/notifications
|
|
143
225
|
const alerts = document.querySelectorAll('.alert, [role="alert"], .notification');
|
|
@@ -152,14 +234,67 @@ export async function generateClickHints(page, selector) {
|
|
|
152
234
|
}
|
|
153
235
|
});
|
|
154
236
|
|
|
155
|
-
// Check for dropdowns
|
|
156
|
-
const
|
|
157
|
-
|
|
237
|
+
// Check for dropdowns, overlays, menus
|
|
238
|
+
const overlaySelectors = [
|
|
239
|
+
'[class*="dropdown"][class*="open"]',
|
|
240
|
+
'[class*="dropdown"][class*="show"]',
|
|
241
|
+
'.cdk-overlay-pane',
|
|
242
|
+
'[role="listbox"]',
|
|
243
|
+
'[role="menu"]',
|
|
244
|
+
'.mat-select-panel',
|
|
245
|
+
'.mat-mdc-select-panel',
|
|
246
|
+
'[class*="overlay"][class*="open"]',
|
|
247
|
+
'.p-dropdown-panel',
|
|
248
|
+
'.ant-dropdown:not(.ant-dropdown-hidden)',
|
|
249
|
+
'[class*="select-options"]',
|
|
250
|
+
].join(', ');
|
|
251
|
+
const overlayEls = document.querySelectorAll(overlaySelectors);
|
|
252
|
+
// Deduplicate: skip overlays that are children of another matched overlay
|
|
253
|
+
const overlays = Array.from(overlayEls).filter(el => el.offsetWidth > 0 && el.offsetHeight > 0);
|
|
254
|
+
const dedupedOverlays = overlays.filter(overlay => {
|
|
255
|
+
return !overlays.some(other => other !== overlay && other.contains(overlay));
|
|
256
|
+
});
|
|
257
|
+
dedupedOverlays.forEach(overlay => {
|
|
258
|
+
// Determine type: explicit menu roles, or custom select-options pattern (but NOT mat-option-text)
|
|
259
|
+
const isMenu = overlay.matches('[role="menu"]') ||
|
|
260
|
+
overlay.querySelector('[role="menuitem"]') !== null ||
|
|
261
|
+
(overlay.matches('[class*="select-options"]') && !overlay.querySelector('mat-option'));
|
|
262
|
+
const isDropdown = overlay.matches('[role="listbox"]') ||
|
|
263
|
+
overlay.querySelector('mat-option, [role="option"]') !== null;
|
|
264
|
+
const type = isMenu ? 'menu' : 'dropdown';
|
|
265
|
+
// Extract items — use specific selectors to avoid duplicates from nested matches
|
|
266
|
+
let itemEls;
|
|
267
|
+
if (isDropdown) {
|
|
268
|
+
itemEls = overlay.querySelectorAll('mat-option, [role="option"]');
|
|
269
|
+
} else if (isMenu) {
|
|
270
|
+
itemEls = overlay.querySelectorAll('[role="menuitem"], [class*="option-text"]');
|
|
271
|
+
} else {
|
|
272
|
+
itemEls = overlay.querySelectorAll('mat-option, [role="option"], [role="menuitem"], li, .dropdown-item, .p-dropdown-item, .ant-dropdown-menu-item, [class*="option-text"]');
|
|
273
|
+
}
|
|
274
|
+
const items = [];
|
|
275
|
+
const totalCount = itemEls.length;
|
|
276
|
+
itemEls.forEach((item, i) => {
|
|
277
|
+
if (i < 10 && item.textContent.trim()) {
|
|
278
|
+
items.push(item.textContent.trim().substring(0, 80));
|
|
279
|
+
}
|
|
280
|
+
});
|
|
158
281
|
hints.newElements.push({
|
|
159
|
-
type
|
|
160
|
-
|
|
282
|
+
type,
|
|
283
|
+
items: items.length > 0 ? items : undefined,
|
|
284
|
+
totalCount: totalCount,
|
|
161
285
|
});
|
|
162
|
-
hints.suggestedNext.push('Select option from dropdown');
|
|
286
|
+
hints.suggestedNext.push(type === 'menu' ? 'Select menu item' : 'Select option from dropdown');
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// Detect auth redirect after click (session expired, protected route)
|
|
290
|
+
const clickUrl = window.location.href;
|
|
291
|
+
const isLoginLike = document.querySelector('form input[type="password"]')
|
|
292
|
+
|| (document.querySelector('form input[type="tel"]') && document.querySelectorAll('form input:not([type="hidden"])').length <= 3)
|
|
293
|
+
|| /\/(login|signin|sign-in|auth|authenticate)(\/|$|\?)/i.test(window.location.pathname)
|
|
294
|
+
|| document.querySelector('[class*="login-form"], [class*="signin-form"], [class*="auth-form"]');
|
|
295
|
+
if (/[?&](returnUrl|return_url|redirect|redirect_uri|next|return_to)=/i.test(clickUrl) && isLoginLike) {
|
|
296
|
+
hints.authRedirect = true;
|
|
297
|
+
hints.suggestedNext.push('⚠️ Auth redirect — landed on login page. Session may not be established.');
|
|
163
298
|
}
|
|
164
299
|
|
|
165
300
|
return hints;
|
|
@@ -288,10 +423,10 @@ export async function generatePageHints(page) {
|
|
|
288
423
|
};
|
|
289
424
|
|
|
290
425
|
// Common patterns
|
|
291
|
-
const
|
|
292
|
-
if (
|
|
293
|
-
const form =
|
|
294
|
-
hints.commonPatterns.loginForm = form && form.id ? `#${form.id}` : 'form
|
|
426
|
+
const loginInput = document.querySelector('form input[type="password"]') || document.querySelector('form input[type="tel"]');
|
|
427
|
+
if (loginInput) {
|
|
428
|
+
const form = loginInput.closest('form');
|
|
429
|
+
hints.commonPatterns.loginForm = form && form.id ? `#${form.id}` : 'form';
|
|
295
430
|
}
|
|
296
431
|
|
|
297
432
|
const searchInput = document.querySelector('input[type="search"], input[placeholder*="search" i]');
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* utils/openapi/helpers.js
|
|
3
|
+
*
|
|
4
|
+
* Shared utilities for OpenAPI processing.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Compare two arrays for shallow equality
|
|
9
|
+
*/
|
|
10
|
+
export function arraysEqual(a, b) {
|
|
11
|
+
if (!a || !b || a.length !== b.length) return false;
|
|
12
|
+
return a.every((v, i) => v === b[i]);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Build a signature from object properties (key + type) for schema matching.
|
|
17
|
+
* Used by _findSchemaName to avoid false positives from key-only comparison.
|
|
18
|
+
*/
|
|
19
|
+
export function schemaSignature(properties) {
|
|
20
|
+
if (!properties) return '';
|
|
21
|
+
return Object.entries(properties)
|
|
22
|
+
.map(([k, v]) => `${k}:${v.type || v.$circularRef || '?'}`)
|
|
23
|
+
.sort()
|
|
24
|
+
.join(',');
|
|
25
|
+
}
|