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 +11 -0
- package/README.md +30 -0
- package/index.js +88 -11
- package/package.json +1 -1
- package/utils/hints-generator.js +46 -6
- package/utils/post-click-diagnostics.js +14 -4
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
|
|
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
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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
|
+
"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",
|
package/utils/hints-generator.js
CHANGED
|
@@ -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 (
|
|
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
|
|
387
|
-
if (
|
|
388
|
-
const form =
|
|
389
|
-
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';
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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)
|