chrometools-mcp 3.3.9 → 3.4.1
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 +18 -0
- package/README.md +30 -0
- package/bridge/bridge-client.js +27 -0
- package/browser/browser-manager.js +50 -0
- package/index.js +99 -11
- package/models/TextInputModel.js +61 -40
- 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,24 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [3.4.1] - 2026-02-12
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
- **Extension "not connected" after install** — Bridge connection now auto-retries after Chrome launches (previously MCP exhausted all reconnect attempts before Chrome even started)
|
|
9
|
+
- **Bridge auto-install on first run** — Native Messaging Host is automatically registered on MCP startup if not yet installed, eliminating the need to manually run `--install-bridge`
|
|
10
|
+
- **Browser autocomplete corrupting text input** — Temporarily suppresses autocomplete during typing to prevent stale data from overwriting input fields
|
|
11
|
+
|
|
12
|
+
## [3.4.0] - 2026-02-08
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
- **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.
|
|
16
|
+
- **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.
|
|
17
|
+
- **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.
|
|
18
|
+
- **Auth redirect in post-click diagnostics** — `formatDiagnosticsForAI` detects navigation to login pages with returnUrl and shows targeted warning.
|
|
19
|
+
|
|
20
|
+
### Performance
|
|
21
|
+
- **findElementsByText early exit** — Stops DOM traversal at 40 results, preventing 120s timeout on heavy Angular Material pages with CDK overlay.
|
|
22
|
+
|
|
5
23
|
## [3.3.9] - 2026-02-08
|
|
6
24
|
|
|
7
25
|
### 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/bridge/bridge-client.js
CHANGED
|
@@ -156,6 +156,33 @@ function scheduleReconnect() {
|
|
|
156
156
|
}, RECONNECT_DELAY);
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Reset reconnect state and retry connection to Bridge.
|
|
161
|
+
* Call this after Chrome launches (bridge-service may have just started).
|
|
162
|
+
*/
|
|
163
|
+
export async function retryBridgeConnection() {
|
|
164
|
+
if (isConnected && ws?.readyState === WebSocket.OPEN) {
|
|
165
|
+
return true; // Already connected
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Clean up any stale connection
|
|
169
|
+
if (ws) {
|
|
170
|
+
try { ws.close(); } catch (e) {}
|
|
171
|
+
ws = null;
|
|
172
|
+
}
|
|
173
|
+
isConnected = false;
|
|
174
|
+
|
|
175
|
+
// Reset reconnect counter so we get fresh attempts
|
|
176
|
+
reconnectAttempts = 0;
|
|
177
|
+
if (reconnectTimer) {
|
|
178
|
+
clearTimeout(reconnectTimer);
|
|
179
|
+
reconnectTimer = null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
logToFile('retryBridgeConnection: resetting and reconnecting');
|
|
183
|
+
return await connectToBridge();
|
|
184
|
+
}
|
|
185
|
+
|
|
159
186
|
/**
|
|
160
187
|
* Disconnect from Bridge
|
|
161
188
|
*/
|
|
@@ -23,6 +23,9 @@ let chromeProcess = null;
|
|
|
23
23
|
// Track if we connected to existing Chrome (extension won't be auto-loaded)
|
|
24
24
|
let connectedToExistingChrome = false;
|
|
25
25
|
|
|
26
|
+
// Ensure bridge reconnect is only triggered once per Chrome launch
|
|
27
|
+
let bridgeReconnectScheduled = false;
|
|
28
|
+
|
|
26
29
|
// Track pages we've already seen to avoid double-handling
|
|
27
30
|
const knownTargets = new WeakSet();
|
|
28
31
|
|
|
@@ -118,6 +121,9 @@ export async function getBrowser() {
|
|
|
118
121
|
// Set up new tab tracking
|
|
119
122
|
setupNewTabTracking(browser);
|
|
120
123
|
|
|
124
|
+
// Existing Chrome may already have bridge running — try to connect
|
|
125
|
+
scheduleBridgeReconnect();
|
|
126
|
+
|
|
121
127
|
return browser;
|
|
122
128
|
} catch (connectError) {
|
|
123
129
|
debugLog("No existing Chrome found, launching new instance...");
|
|
@@ -173,6 +179,10 @@ export async function getBrowser() {
|
|
|
173
179
|
// Set up new tab tracking
|
|
174
180
|
setupNewTabTracking(browser);
|
|
175
181
|
|
|
182
|
+
// Extension needs time to load and start the bridge service.
|
|
183
|
+
// Schedule bridge reconnection after Chrome is ready.
|
|
184
|
+
scheduleBridgeReconnect();
|
|
185
|
+
|
|
176
186
|
return browser;
|
|
177
187
|
} catch (error) {
|
|
178
188
|
// Check if it's a display-related error in WSL
|
|
@@ -221,6 +231,46 @@ This requires an X server to display the browser GUI.
|
|
|
221
231
|
return await browserPromise;
|
|
222
232
|
}
|
|
223
233
|
|
|
234
|
+
/**
|
|
235
|
+
* Schedule bridge reconnection after Chrome launches.
|
|
236
|
+
* Extension needs ~2-3s to load and start the bridge service via Native Messaging.
|
|
237
|
+
* Retries a few times in case the bridge takes longer to start.
|
|
238
|
+
*/
|
|
239
|
+
function scheduleBridgeReconnect() {
|
|
240
|
+
if (bridgeReconnectScheduled) return;
|
|
241
|
+
bridgeReconnectScheduled = true;
|
|
242
|
+
|
|
243
|
+
const delays = [2000, 3000, 5000]; // retry schedule in ms
|
|
244
|
+
let attempt = 0;
|
|
245
|
+
|
|
246
|
+
async function tryConnect() {
|
|
247
|
+
try {
|
|
248
|
+
const { retryBridgeConnection, isBridgeConnected } = await import('../bridge/bridge-client.js');
|
|
249
|
+
if (isBridgeConnected()) {
|
|
250
|
+
debugLog('Bridge already connected, skipping reconnect');
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
debugLog(`Bridge reconnect attempt ${attempt + 1}/${delays.length}`);
|
|
254
|
+
const connected = await retryBridgeConnection();
|
|
255
|
+
if (connected) {
|
|
256
|
+
console.error('[chrometools-mcp] Bridge connected after Chrome launch');
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
} catch (e) {
|
|
260
|
+
debugLog('Bridge reconnect error:', e.message);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
attempt++;
|
|
264
|
+
if (attempt < delays.length) {
|
|
265
|
+
setTimeout(tryConnect, delays[attempt]);
|
|
266
|
+
} else {
|
|
267
|
+
console.error('[chrometools-mcp] Bridge not available after Chrome launch. Run: npx chrometools-mcp --install-bridge');
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
setTimeout(tryConnect, delays[0]);
|
|
272
|
+
}
|
|
273
|
+
|
|
224
274
|
/**
|
|
225
275
|
* Setup tracking for new tabs opened via window.open, target="_blank", etc.
|
|
226
276
|
* @param {Browser} browser - Puppeteer browser instance
|
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
|
|
|
@@ -3921,6 +3998,17 @@ async function main() {
|
|
|
3921
3998
|
console.error("[chrometools-mcp] GUI mode requires X server (DISPLAY=" + (process.env.DISPLAY || "not set") + ")");
|
|
3922
3999
|
}
|
|
3923
4000
|
|
|
4001
|
+
// Auto-install bridge if not yet registered (required for extension <-> MCP communication)
|
|
4002
|
+
try {
|
|
4003
|
+
const { isBridgeInstalled, installBridge } = await import('./bridge/install.js');
|
|
4004
|
+
if (!isBridgeInstalled()) {
|
|
4005
|
+
console.error('[chrometools-mcp] Bridge not installed. Auto-installing...');
|
|
4006
|
+
await installBridge({ silent: true });
|
|
4007
|
+
}
|
|
4008
|
+
} catch (e) {
|
|
4009
|
+
console.error('[chrometools-mcp] Bridge auto-install failed:', e.message);
|
|
4010
|
+
}
|
|
4011
|
+
|
|
3924
4012
|
// Connect to Bridge Service (if running)
|
|
3925
4013
|
await startWebSocketServer();
|
|
3926
4014
|
|
package/models/TextInputModel.js
CHANGED
|
@@ -31,52 +31,73 @@ export class TextInputModel extends BaseInputModel {
|
|
|
31
31
|
const { delay = 0, clearFirst = true } = options;
|
|
32
32
|
const opTimeout = 5000; // 5s timeout per operation
|
|
33
33
|
|
|
34
|
-
//
|
|
34
|
+
// Suppress browser autocomplete to prevent field corruption
|
|
35
|
+
// (autocomplete can fire between focus and typing, filling the field with stale data)
|
|
36
|
+
const savedAutocomplete = await this.element.evaluate(el => {
|
|
37
|
+
const prev = el.getAttribute('autocomplete');
|
|
38
|
+
el.setAttribute('autocomplete', 'new-password');
|
|
39
|
+
return prev;
|
|
40
|
+
});
|
|
41
|
+
|
|
35
42
|
try {
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
el.
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
43
|
+
// Method 1: Try Puppeteer typing (works for most cases)
|
|
44
|
+
try {
|
|
45
|
+
// Focus and clear using JS (most reliable)
|
|
46
|
+
await withTimeout(
|
|
47
|
+
() => this.element.evaluate((el, shouldClear) => {
|
|
48
|
+
el.focus();
|
|
49
|
+
el.click();
|
|
50
|
+
if (shouldClear) {
|
|
51
|
+
el.value = '';
|
|
52
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
53
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
54
|
+
}
|
|
55
|
+
}, clearFirst),
|
|
56
|
+
opTimeout,
|
|
57
|
+
'focus-and-clear'
|
|
58
|
+
);
|
|
48
59
|
|
|
49
|
-
|
|
50
|
-
|
|
60
|
+
// Small delay to ensure focus is established
|
|
61
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
51
62
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
63
|
+
// Type the new value
|
|
64
|
+
const typeTimeout = Math.max(opTimeout, value.length * delay + 5000);
|
|
65
|
+
await withTimeout(
|
|
66
|
+
() => this.element.type(value, { delay }),
|
|
67
|
+
typeTimeout,
|
|
68
|
+
'type'
|
|
69
|
+
);
|
|
59
70
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
71
|
+
// Verify the value was set (exact match to catch autocomplete corruption)
|
|
72
|
+
const actualValue = await this.element.evaluate(el => el.value);
|
|
73
|
+
if (actualValue === value) {
|
|
74
|
+
return; // Success
|
|
75
|
+
}
|
|
76
|
+
} catch (e) {
|
|
77
|
+
// Fall through to JS method
|
|
64
78
|
}
|
|
65
|
-
} catch (e) {
|
|
66
|
-
// Fall through to JS method
|
|
67
|
-
}
|
|
68
79
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
+
// Method 2: Fallback to direct JS value setting
|
|
81
|
+
await withTimeout(
|
|
82
|
+
() => this.element.evaluate((el, newValue) => {
|
|
83
|
+
el.focus();
|
|
84
|
+
el.value = newValue;
|
|
85
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
86
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
87
|
+
}, value),
|
|
88
|
+
opTimeout,
|
|
89
|
+
'js-set-value'
|
|
90
|
+
);
|
|
91
|
+
} finally {
|
|
92
|
+
// Restore original autocomplete attribute
|
|
93
|
+
await this.element.evaluate((el, orig) => {
|
|
94
|
+
if (orig === null) {
|
|
95
|
+
el.removeAttribute('autocomplete');
|
|
96
|
+
} else {
|
|
97
|
+
el.setAttribute('autocomplete', orig);
|
|
98
|
+
}
|
|
99
|
+
}, savedAutocomplete).catch(() => {});
|
|
100
|
+
}
|
|
80
101
|
}
|
|
81
102
|
|
|
82
103
|
getActionDescription(value, identifier) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "chrometools-mcp",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.4.1",
|
|
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)
|