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.
@@ -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 (document.querySelector('form input[type="password"]')) {
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 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
- }
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 dropdowns = document.querySelectorAll('[class*="dropdown"][class*="open"], [aria-expanded="true"]');
157
- if (dropdowns.length > 0) {
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: 'dropdown',
160
- count: dropdowns.length,
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 loginForm = document.querySelector('form input[type="password"]');
292
- if (loginForm) {
293
- const form = loginForm.closest('form');
294
- hints.commonPatterns.loginForm = form && form.id ? `#${form.id}` : 'form:has(input[type="password"])';
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
+ }