chrometools-mcp 3.3.9 → 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 CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [3.4.0] - 2026-02-08
6
+
7
+ ### Added
8
+ - **Adaptive click strategy with elementFromPoint pre-check** — Before each click, verifies the target element is topmost via `document.elementFromPoint()`. If covered by another element (e.g. small button under `<a routerLink>`), uses DOM dispatch to bypass coordinate hit-testing. Fixes clicks on absolutely-positioned elements over links.
9
+ - **Auth redirect detection** — `navigateTo`, `openBrowser`, and `click` now warn when landing on a login page with returnUrl parameter. Broad login detection: password forms, phone/OTP forms, URL path (`/login`, `/signin`, `/auth`), CSS class matching.
10
+ - **Post-click element detachment detection** — Detects when clicked element is removed from DOM during click (Angular `*ngFor` + Zone.js pattern). Shows actionable hint with app fix (trackBy) and executeScript workaround.
11
+ - **Auth redirect in post-click diagnostics** — `formatDiagnosticsForAI` detects navigation to login pages with returnUrl and shows targeted warning.
12
+
13
+ ### Performance
14
+ - **findElementsByText early exit** — Stops DOM traversal at 40 results, preventing 120s timeout on heavy Angular Material pages with CDK overlay.
15
+
5
16
  ## [3.3.9] - 2026-02-08
6
17
 
7
18
  ### Added
package/README.md CHANGED
@@ -500,6 +500,10 @@ Click an element with optional result screenshot. **PREFERRED**: Use APOM ID fro
500
500
  - **Use case**: Buttons, links, form submissions, Django admin forms
501
501
  - **Returns**: Confirmation text + optional screenshot + network diagnostics
502
502
  - **Performance**: 2-10x faster without screenshot, instant with skipNetworkWait
503
+ - **Click strategy**: Three-tier fallback for maximum compatibility:
504
+ 1. Puppeteer native click (trusted CDP events)
505
+ 2. CDP coordinate click at element center (trusted, bypasses interception check)
506
+ 3. JavaScript `element.click()` (untrusted, last resort)
503
507
  - **Example**:
504
508
  ```javascript
505
509
  // PREFERRED: Using APOM ID
@@ -1922,6 +1926,32 @@ npx chrometools-mcp --install-bridge
1922
1926
  - Close and reopen Chrome
1923
1927
  - Check Extension Service Worker console for errors
1924
1928
 
1929
+ ## Known Limitations
1930
+
1931
+ ### Angular *ngFor with Dynamic Bindings
1932
+
1933
+ In Angular apps using Zone.js, **any** programmatic click (including CDP trusted events) can trigger change detection **between** event listener callbacks. If `*ngFor` iterates over a getter that returns a new array reference each time (e.g., `[options]="getOptions()"`), Angular destroys and recreates all child elements mid-dispatch, causing `@HostListener('click')` on the target element to never fire. Only real hardware mouse events (physical mouse) are immune — CDP events, despite being `isTrusted: true`, are not dispatched through the OS event queue.
1934
+
1935
+ ChromeTools **automatically detects** this: after each click, it checks if the target element was removed from DOM. If so, the `ELEMENT DETACHED` hint is shown with a workaround guide.
1936
+
1937
+ **App fix** (recommended): add `trackBy` to `*ngFor`, or cache the array reference instead of returning a new one each time.
1938
+
1939
+ **Workaround** when app fix is not possible — use `executeScript` to call the Angular component API directly:
1940
+ ```javascript
1941
+ // 1. Find the component instance
1942
+ executeScript({ script: `
1943
+ const comp = ng.getComponent(document.querySelector('my-component'));
1944
+ // 2. Explore available events
1945
+ Object.keys(comp).filter(k => k.includes('Event'));
1946
+ ` })
1947
+
1948
+ // 3. Emit the event directly (bypasses DOM click entirely)
1949
+ executeScript({ script: `
1950
+ const comp = ng.getComponent(document.querySelector('my-component'));
1951
+ comp.selectedOptionChangeEvent.emit(comp.options.find(o => o.name === 'Delete'));
1952
+ ` })
1953
+ ```
1954
+
1925
1955
  ## Architecture
1926
1956
 
1927
1957
  - **Puppeteer** for Chrome automation
package/index.js CHANGED
@@ -352,6 +352,10 @@ async function executeToolInternal(name, args) {
352
352
  if (hints.heading) {
353
353
  hintsText += `\nPage heading: "${hints.heading}"`;
354
354
  }
355
+ if (hints.authRedirect) {
356
+ hintsText += `\n⚠️ AUTH REDIRECT: Page redirected to login (intended: ${hints.authRedirect.returnUrl || 'unknown'})`;
357
+ hintsText += `\n → Session/cookies not established. Login first, then retry navigation.`;
358
+ }
355
359
  if (hints.availableActions.length > 0) {
356
360
  hintsText += `\nAvailable actions: ${hints.availableActions.join(', ')}`;
357
361
  }
@@ -484,23 +488,74 @@ async function executeToolInternal(name, args) {
484
488
  // ALWAYS scroll to element first to ensure it's in viewport
485
489
  await element.evaluate(el => el.scrollIntoView({ behavior: 'instant', block: 'center' }));
486
490
 
487
- // Click with timeout to prevent hanging on navigation
491
+ // Click with adaptive fallback strategy:
492
+ //
493
+ // Pre-check: elementFromPoint() determines if element is the topmost at its center.
494
+ // This decides the click path — Puppeteer's click() doesn't always throw on interception.
495
+ //
496
+ // Path A (element covered by another, e.g. small button under <a routerLink>):
497
+ // → JS element.click() — DOM dispatch bypasses coordinate hit-testing
498
+ //
499
+ // Path B (element is topmost — normal case):
500
+ // Tier 1: Puppeteer native click (trusted CDP events)
501
+ // Tier 2: page.mouse.click at coordinates (trusted CDP, no interception check)
502
+ // Tier 3: JS element.click() (untrusted, last resort)
503
+ // Trusted CDP events (Tier 1 & 2) are critical for Angular/Zone.js apps where
504
+ // untrusted .click() triggers change detection mid-dispatch, destroying *ngFor elements.
488
505
  const clickWithTimeout = async (timeoutMs = 5000) => {
489
- const clickPromise = element.click().catch(() => {
490
- // If Puppeteer click fails, fallback to JS click
491
- return element.evaluate(el => el.click());
492
- });
493
- const timeoutPromise = new Promise((_, reject) =>
494
- setTimeout(() => reject(new Error('click timeout')), timeoutMs)
495
- );
496
- return Promise.race([clickPromise, timeoutPromise]).catch(() => {
497
- // If click times out, try JS click as last resort
498
- return element.evaluate(el => el.click());
506
+ const withTimeout = (promise) => Promise.race([
507
+ promise,
508
+ new Promise((_, reject) => setTimeout(() => reject(new Error('click timeout')), timeoutMs))
509
+ ]);
510
+
511
+ // Pre-check: is another element covering our target at its center coordinates?
512
+ const intercepted = await element.evaluate(el => {
513
+ const rect = el.getBoundingClientRect();
514
+ if (rect.width === 0 || rect.height === 0) return false;
515
+ const cx = rect.left + rect.width / 2;
516
+ const cy = rect.top + rect.height / 2;
517
+ const topEl = document.elementFromPoint(cx, cy);
518
+ // topEl is our element or a child of it — not intercepted
519
+ return topEl !== el && !el.contains(topEl);
499
520
  });
521
+
522
+ if (intercepted) {
523
+ // Path A: Element is covered (e.g., small button under <a routerLink>)
524
+ // Coordinate clicks would hit the covering element — use DOM dispatch
525
+ await element.evaluate(el => el.click());
526
+ return;
527
+ }
528
+
529
+ // Path B: Element is topmost — use trusted CDP events
530
+ // Tier 1: Puppeteer native click
531
+ try {
532
+ await withTimeout(element.click());
533
+ return;
534
+ } catch (e) { /* fall through to Tier 2 */ }
535
+
536
+ // Tier 2: CDP coordinate click — trusted events, bypasses Puppeteer's own checks
537
+ try {
538
+ const box = await element.boundingBox();
539
+ if (box) {
540
+ await withTimeout(page.mouse.click(box.x + box.width / 2, box.y + box.height / 2));
541
+ return;
542
+ }
543
+ } catch (e) { /* fall through to Tier 3 */ }
544
+
545
+ // Tier 3: JS click — untrusted, last resort
546
+ await element.evaluate(el => el.click());
500
547
  };
501
548
 
502
549
  await clickWithTimeout();
503
550
 
551
+ // Check if element was detached from DOM during click (Angular *ngFor + Zone.js pattern)
552
+ let elementDetached = false;
553
+ try {
554
+ elementDetached = await element.evaluate(el => !el.parentNode);
555
+ } catch (e) {
556
+ // Element handle may be invalid if page navigated — not a detachment issue
557
+ }
558
+
504
559
  // NEW POST-CLICK PATTERN:
505
560
  // 1. Run post-click diagnostics (waits for network requests within 200ms, max 10s timeout)
506
561
  let diagnostics;
@@ -556,6 +611,22 @@ async function executeToolInternal(name, args) {
556
611
  hintsText += `\nNew elements appeared: ${otherElements.map(e => e.text ? `${e.type}: ${e.text}` : e.type).join(', ')}`;
557
612
  }
558
613
 
614
+ // Auth redirect after click (session expired, protected route)
615
+ if (hints.authRedirect) {
616
+ hintsText += '\n⚠️ AUTH REDIRECT: Landed on login page. Session may have expired.';
617
+ }
618
+
619
+ // Element detached during click (Angular *ngFor + Zone.js)
620
+ if (elementDetached) {
621
+ hintsText += '\n⚠️ ELEMENT DETACHED: Element was removed from DOM during click — handler did NOT fire.';
622
+ hintsText += '\n Cause: Angular Zone.js triggers change detection mid-click, *ngFor recreates elements.';
623
+ hintsText += '\n App fix: add trackBy to *ngFor, or cache array reference instead of returning new one.';
624
+ hintsText += '\n Workaround via executeScript:';
625
+ hintsText += "\n 1. Find component: ng.getComponent(document.querySelector('component-tag'))";
626
+ hintsText += "\n 2. Explore API: Object.keys(comp).filter(k => k.includes('Event'))";
627
+ hintsText += '\n 3. Emit: comp.someChangeEvent.emit(selectedOption)';
628
+ }
629
+
559
630
  if (hints.suggestedNext.length > 0) {
560
631
  hintsText += `\nSuggested next: ${hints.suggestedNext.join('; ')}`;
561
632
  }
@@ -1654,6 +1725,10 @@ async function executeToolInternal(name, args) {
1654
1725
  if (hints.heading) {
1655
1726
  hintsText += `\nPage heading: "${hints.heading}"`;
1656
1727
  }
1728
+ if (hints.authRedirect) {
1729
+ hintsText += `\n⚠️ AUTH REDIRECT: Page redirected to login (intended: ${hints.authRedirect.returnUrl || 'unknown'})`;
1730
+ hintsText += `\n → Session/cookies not established. Login first, then retry navigation.`;
1731
+ }
1657
1732
  if (hints.availableActions.length > 0) {
1658
1733
  hintsText += `\nAvailable actions: ${hints.availableActions.join(', ')}`;
1659
1734
  }
@@ -2696,9 +2771,11 @@ Start coding now.`;
2696
2771
  }
2697
2772
 
2698
2773
  const results = [];
2774
+ const MAX_RESULTS = 40; // Collect up to 40 to allow visible/hidden sorting, cap expensive selector generation
2699
2775
  const searchText = caseSensitive ? text : text.toLowerCase();
2700
2776
 
2701
2777
  document.querySelectorAll('*').forEach(el => {
2778
+ if (results.length >= MAX_RESULTS) return; // Stop expensive selector generation after enough results
2702
2779
  // Skip script, style, etc
2703
2780
  if (['SCRIPT', 'STYLE', 'NOSCRIPT', 'BR', 'HR'].includes(el.tagName)) return;
2704
2781
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chrometools-mcp",
3
- "version": "3.3.9",
3
+ "version": "3.4.0",
4
4
  "description": "MCP (Model Context Protocol) server for Chrome automation using Puppeteer. Persistent browser sessions, UI framework detection (MUI, Ant Design, etc.), Page Object support, visual testing, Figma comparison. Works seamlessly in WSL, Linux, macOS, and Windows.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -55,11 +55,28 @@ export async function generateNavigationHints(page, url) {
55
55
  }
56
56
  }
57
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
+
58
75
  // Detect page type
59
- if (document.querySelector('form input[type="password"]')) {
76
+ if (isLoginPage()) {
60
77
  hints.pageType = 'login';
61
78
  hints.suggestedNext.push('Fill login credentials and submit');
62
- 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"]';
63
80
  hints.commonSelectors.passwordField = 'input[type="password"]';
64
81
  hints.commonSelectors.submitButton = 'button[type="submit"], input[type="submit"]';
65
82
  } else if (document.querySelector('form') && document.querySelectorAll('form input').length > 3) {
@@ -76,6 +93,18 @@ export async function generateNavigationHints(page, url) {
76
93
  hints.suggestedNext.push('Browse items or use filters');
77
94
  }
78
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
+
79
108
  // Available actions
80
109
  const forms = document.querySelectorAll('form');
81
110
  if (forms.length > 0) {
@@ -257,6 +286,17 @@ export async function generateClickHints(page, selector) {
257
286
  hints.suggestedNext.push(type === 'menu' ? 'Select menu item' : 'Select option from dropdown');
258
287
  });
259
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.');
298
+ }
299
+
260
300
  return hints;
261
301
  }, selector);
262
302
  }
@@ -383,10 +423,10 @@ export async function generatePageHints(page) {
383
423
  };
384
424
 
385
425
  // Common patterns
386
- const loginForm = document.querySelector('form input[type="password"]');
387
- if (loginForm) {
388
- const form = loginForm.closest('form');
389
- 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';
390
430
  }
391
431
 
392
432
  const searchInput = document.querySelector('input[type="search"], input[placeholder*="search" i]');
@@ -278,10 +278,20 @@ export function formatDiagnosticsForAI(diagnostics) {
278
278
 
279
279
  // Page navigation detection (form submit in non-SPA apps)
280
280
  if (diagnostics.navigation) {
281
- output += `\n\n🔄 Page navigation detected (form submit):`;
282
- output += `\n From: ${diagnostics.navigation.from}`;
283
- output += `\n To: ${diagnostics.navigation.to}`;
284
- output += `\n → This indicates a successful form POST with page reload`;
281
+ const to = diagnostics.navigation.to || '';
282
+ const isAuthRedirect = /login|signin|auth/i.test(to)
283
+ && /[?&](returnUrl|return_url|redirect|next)/i.test(to);
284
+ if (isAuthRedirect) {
285
+ output += `\n\n⚠️ AUTH REDIRECT detected:`;
286
+ output += `\n From: ${diagnostics.navigation.from}`;
287
+ output += `\n To: ${diagnostics.navigation.to}`;
288
+ output += `\n → Session not established. Ensure login completes and cookies are set before navigating to protected routes.`;
289
+ } else {
290
+ output += `\n\n🔄 Page navigation detected (form submit):`;
291
+ output += `\n From: ${diagnostics.navigation.from}`;
292
+ output += `\n To: ${diagnostics.navigation.to}`;
293
+ output += `\n → This indicates a successful form POST with page reload`;
294
+ }
285
295
  }
286
296
 
287
297
  // Network activity - show all tracked requests (GET/POST/PUT/PATCH/DELETE)