chrometools-mcp 3.2.10 → 3.3.8
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 +169 -0
- package/README.md +14 -5
- package/angular-tools.js +9 -3
- package/bridge/bridge-client.js +62 -7
- package/bridge/bridge-service.js +80 -2
- package/browser/page-manager.js +83 -0
- package/extension/background.js +117 -0
- package/extension/content.js +3 -1
- package/extension/manifest.json +2 -1
- package/index.js +284 -48
- package/models/TextInputModel.js +56 -5
- package/models/index.js +20 -6
- package/nul +0 -0
- package/package.json +1 -1
- package/pom/apom-tree-converter.js +308 -39
- package/server/tool-definitions.js +3 -1
- package/server/tool-schemas.js +5 -0
- package/utils/hints-generator.js +46 -4
- package/utils/post-click-diagnostics.js +146 -47
package/extension/background.js
CHANGED
|
@@ -21,6 +21,10 @@ let nativePort = null;
|
|
|
21
21
|
let isConnected = false;
|
|
22
22
|
const tabsState = new Map(); // tabId -> {url, title, active, windowId}
|
|
23
23
|
|
|
24
|
+
// Network requests storage (persists across page navigations)
|
|
25
|
+
const networkRequests = new Map(); // requestId -> request info
|
|
26
|
+
const MAX_NETWORK_REQUESTS = 500;
|
|
27
|
+
|
|
24
28
|
// Recorder state (persisted in storage)
|
|
25
29
|
let recorderState = {
|
|
26
30
|
isRecording: false,
|
|
@@ -152,6 +156,24 @@ function handleBridgeMessage(message) {
|
|
|
152
156
|
sendToBridge({ type: 'pong', requestId: message.requestId });
|
|
153
157
|
break;
|
|
154
158
|
|
|
159
|
+
case 'get_network_requests':
|
|
160
|
+
// Return recent network requests
|
|
161
|
+
const requests = Array.from(networkRequests.values());
|
|
162
|
+
sendToBridge({
|
|
163
|
+
type: 'network_requests',
|
|
164
|
+
payload: { requests },
|
|
165
|
+
requestId: message.requestId
|
|
166
|
+
});
|
|
167
|
+
break;
|
|
168
|
+
|
|
169
|
+
case 'clear_network_requests':
|
|
170
|
+
networkRequests.clear();
|
|
171
|
+
sendToBridge({
|
|
172
|
+
type: 'network_requests_cleared',
|
|
173
|
+
requestId: message.requestId
|
|
174
|
+
});
|
|
175
|
+
break;
|
|
176
|
+
|
|
155
177
|
default:
|
|
156
178
|
console.log('[ChromeTools] Unknown message from Bridge:', message.type);
|
|
157
179
|
}
|
|
@@ -293,6 +315,101 @@ chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
|
|
|
293
315
|
}
|
|
294
316
|
});
|
|
295
317
|
|
|
318
|
+
// ============================================
|
|
319
|
+
// Network Request Tracking (via webRequest API)
|
|
320
|
+
// Persists across page navigations!
|
|
321
|
+
// ============================================
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Clean old network requests to prevent memory leak
|
|
325
|
+
*/
|
|
326
|
+
function cleanOldNetworkRequests() {
|
|
327
|
+
if (networkRequests.size > MAX_NETWORK_REQUESTS) {
|
|
328
|
+
const entries = Array.from(networkRequests.entries());
|
|
329
|
+
const removeCount = entries.length - MAX_NETWORK_REQUESTS;
|
|
330
|
+
for (let i = 0; i < removeCount; i++) {
|
|
331
|
+
networkRequests.delete(entries[i][0]);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Track when request starts (captures POST/PUT/PATCH before navigation)
|
|
337
|
+
chrome.webRequest.onBeforeRequest.addListener(
|
|
338
|
+
(details) => {
|
|
339
|
+
// Only track mutation requests (POST, PUT, PATCH, DELETE)
|
|
340
|
+
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(details.method)) {
|
|
341
|
+
const requestInfo = {
|
|
342
|
+
requestId: details.requestId,
|
|
343
|
+
url: details.url,
|
|
344
|
+
method: details.method,
|
|
345
|
+
type: details.type, // main_frame, xmlhttprequest, etc.
|
|
346
|
+
tabId: details.tabId,
|
|
347
|
+
timestamp: Date.now(),
|
|
348
|
+
status: 'pending',
|
|
349
|
+
initiator: details.initiator || null
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
networkRequests.set(details.requestId, requestInfo);
|
|
353
|
+
cleanOldNetworkRequests();
|
|
354
|
+
|
|
355
|
+
// Send to Bridge
|
|
356
|
+
sendToBridge({
|
|
357
|
+
type: 'network_request_started',
|
|
358
|
+
payload: requestInfo
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
console.log(`[ChromeTools] Network: ${details.method} ${details.url}`);
|
|
362
|
+
}
|
|
363
|
+
},
|
|
364
|
+
{ urls: ['<all_urls>'] }
|
|
365
|
+
);
|
|
366
|
+
|
|
367
|
+
// Track when request completes
|
|
368
|
+
chrome.webRequest.onCompleted.addListener(
|
|
369
|
+
(details) => {
|
|
370
|
+
const request = networkRequests.get(details.requestId);
|
|
371
|
+
if (request) {
|
|
372
|
+
request.status = details.statusCode;
|
|
373
|
+
request.statusText = details.statusLine;
|
|
374
|
+
request.completedAt = Date.now();
|
|
375
|
+
|
|
376
|
+
// Send update to Bridge
|
|
377
|
+
sendToBridge({
|
|
378
|
+
type: 'network_request_completed',
|
|
379
|
+
payload: {
|
|
380
|
+
requestId: details.requestId,
|
|
381
|
+
status: details.statusCode,
|
|
382
|
+
statusText: details.statusLine
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
console.log(`[ChromeTools] Network completed: ${request.method} ${request.url} -> ${details.statusCode}`);
|
|
387
|
+
}
|
|
388
|
+
},
|
|
389
|
+
{ urls: ['<all_urls>'] }
|
|
390
|
+
);
|
|
391
|
+
|
|
392
|
+
// Track request errors
|
|
393
|
+
chrome.webRequest.onErrorOccurred.addListener(
|
|
394
|
+
(details) => {
|
|
395
|
+
const request = networkRequests.get(details.requestId);
|
|
396
|
+
if (request) {
|
|
397
|
+
request.status = 'failed';
|
|
398
|
+
request.error = details.error;
|
|
399
|
+
request.completedAt = Date.now();
|
|
400
|
+
|
|
401
|
+
sendToBridge({
|
|
402
|
+
type: 'network_request_failed',
|
|
403
|
+
payload: {
|
|
404
|
+
requestId: details.requestId,
|
|
405
|
+
error: details.error
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
},
|
|
410
|
+
{ urls: ['<all_urls>'] }
|
|
411
|
+
);
|
|
412
|
+
|
|
296
413
|
// ============================================
|
|
297
414
|
// Icon Management
|
|
298
415
|
// ============================================
|
package/extension/content.js
CHANGED
|
@@ -149,8 +149,10 @@
|
|
|
149
149
|
|
|
150
150
|
return Array.from(element.classList).filter(cls => {
|
|
151
151
|
if (cls.length < 2) return false;
|
|
152
|
+
// Filter out Tailwind classes with special characters (colons, slashes, brackets)
|
|
153
|
+
if (/[:\/\[\]]/.test(cls)) return false;
|
|
152
154
|
return !unstablePatterns.some(p => p.test(cls));
|
|
153
|
-
}).slice(0, 3);
|
|
155
|
+
}).slice(0, 3).map(cls => CSS.escape(cls));
|
|
154
156
|
}
|
|
155
157
|
|
|
156
158
|
function getNthChildPath(element, maxDepth = 5) {
|
package/extension/manifest.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "ChromeTools MCP",
|
|
4
|
-
"version": "3.
|
|
4
|
+
"version": "3.3.6",
|
|
5
5
|
"description": "Tab tracking and scenario recording for chrometools-mcp",
|
|
6
6
|
|
|
7
7
|
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxLLqg7Nu1h9ogRVgQoVMRPv8Jp7uRJugZSGUh++Niq0xm3khJefBuJ3L0dSG6xb9tkjTdgqUyg81VUgJBDVw9Bxu6iz1uL17VnEGHDZKe5wpsEpG8o6ZsTWtKRDeoxmkCGSOSDsh/ihlJe8mFaqpBYz6RBaO28R89TNobVhSobTQPB1ptyEND7W7JnsnMOiMcTo9l6j9HrIHLoHj7tO42DHNI4tEyLxI7C6R3i5dLIdwwxJMj0Hhrx4Ncmh24AzPyZypxVvpa1V7HP3sAXGBoUjLd/SEaY8j50lnaIQI3AkYv86pS9l6EZ6y3XCuW7C7W9guTTL/7ZNawYoE2bJ1HwIDAQAB",
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
"scripting",
|
|
13
13
|
"storage",
|
|
14
14
|
"webNavigation",
|
|
15
|
+
"webRequest",
|
|
15
16
|
"nativeMessaging"
|
|
16
17
|
],
|
|
17
18
|
|
package/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
1
|
+
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import {Server} from "@modelcontextprotocol/sdk/server/index.js";
|
|
4
4
|
import {StdioServerTransport} from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
@@ -360,12 +360,65 @@ async function executeToolInternal(name, args) {
|
|
|
360
360
|
};
|
|
361
361
|
}
|
|
362
362
|
|
|
363
|
+
/**
|
|
364
|
+
* Check if identifier looks like an APOM ID (e.g., button_8, input_4, form_1)
|
|
365
|
+
*/
|
|
366
|
+
function isApomIdPattern(identifier) {
|
|
367
|
+
return /^[a-z]+_\d+$/.test(identifier);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Quick element registration - runs APOM analysis and registers elements
|
|
372
|
+
*/
|
|
373
|
+
async function quickRegisterElements(page) {
|
|
374
|
+
await page.evaluate((apomTreeConverterCode, selectorResolverCode) => {
|
|
375
|
+
// Inject utilities
|
|
376
|
+
if (typeof buildAPOMTree === 'undefined') {
|
|
377
|
+
eval(apomTreeConverterCode);
|
|
378
|
+
}
|
|
379
|
+
if (typeof registerElements === 'undefined') {
|
|
380
|
+
eval(selectorResolverCode);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Build APOM tree (interactive only for speed)
|
|
384
|
+
const apomData = buildAPOMTree(true);
|
|
385
|
+
|
|
386
|
+
// Flatten and register elements
|
|
387
|
+
const elementsArray = [];
|
|
388
|
+
function collectElements(node) {
|
|
389
|
+
if (!node) return;
|
|
390
|
+
if (node.id && node.selector) {
|
|
391
|
+
elementsArray.push({
|
|
392
|
+
id: node.id,
|
|
393
|
+
selector: node.selector,
|
|
394
|
+
metadata: { type: node.type, tag: node.tag }
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
if (node.children) {
|
|
398
|
+
node.children.forEach(child => collectElements(child));
|
|
399
|
+
}
|
|
400
|
+
// Also check for container keys (div_container_0, etc.)
|
|
401
|
+
Object.keys(node).forEach(key => {
|
|
402
|
+
if (Array.isArray(node[key])) {
|
|
403
|
+
node[key].forEach(child => collectElements(child));
|
|
404
|
+
}
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
collectElements(apomData.tree);
|
|
408
|
+
|
|
409
|
+
if (typeof registerElements !== 'undefined') {
|
|
410
|
+
registerElements(elementsArray);
|
|
411
|
+
}
|
|
412
|
+
}, apomTreeConverter, selectorResolver);
|
|
413
|
+
}
|
|
414
|
+
|
|
363
415
|
/**
|
|
364
416
|
* Helper: Resolve selector (ID or CSS selector)
|
|
365
417
|
* Injects selector-resolver and resolves element identifier
|
|
418
|
+
* Auto-refreshes element registry if APOM ID not found
|
|
366
419
|
*/
|
|
367
|
-
async function resolveSelector(page, identifier) {
|
|
368
|
-
|
|
420
|
+
async function resolveSelector(page, identifier, timeoutMs = 5000) {
|
|
421
|
+
const tryResolve = () => page.evaluate((id, selectorResolverCode) => {
|
|
369
422
|
// Inject selector resolver if not already loaded
|
|
370
423
|
if (typeof resolveSelector === 'undefined') {
|
|
371
424
|
eval(selectorResolverCode);
|
|
@@ -378,6 +431,20 @@ async function executeToolInternal(name, args) {
|
|
|
378
431
|
found: document.querySelector(resolved.selector) !== null
|
|
379
432
|
};
|
|
380
433
|
}, identifier, selectorResolver);
|
|
434
|
+
|
|
435
|
+
const timeoutPromise = new Promise((_, reject) =>
|
|
436
|
+
setTimeout(() => reject(new Error(`resolveSelector timed out after ${timeoutMs}ms`)), timeoutMs)
|
|
437
|
+
);
|
|
438
|
+
|
|
439
|
+
let resolved = await Promise.race([tryResolve(), timeoutPromise]);
|
|
440
|
+
|
|
441
|
+
// Auto-refresh: if looks like APOM ID but not found, re-register elements and retry
|
|
442
|
+
if (!resolved.found && isApomIdPattern(identifier)) {
|
|
443
|
+
await quickRegisterElements(page);
|
|
444
|
+
resolved = await Promise.race([tryResolve(), timeoutPromise]);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
return resolved;
|
|
381
448
|
}
|
|
382
449
|
|
|
383
450
|
if (name === "click") {
|
|
@@ -401,31 +468,56 @@ async function executeToolInternal(name, args) {
|
|
|
401
468
|
throw new Error(`Element not found: ${identifier}`);
|
|
402
469
|
}
|
|
403
470
|
|
|
404
|
-
// Capture timestamp BEFORE click for
|
|
471
|
+
// Capture timestamp and URL BEFORE click for diagnostics
|
|
405
472
|
const beforeClickTimestamp = Date.now();
|
|
473
|
+
const urlBeforeClick = page.url();
|
|
474
|
+
|
|
475
|
+
// ALWAYS scroll to element first to ensure it's in viewport
|
|
476
|
+
await element.evaluate(el => el.scrollIntoView({ behavior: 'instant', block: 'center' }));
|
|
477
|
+
|
|
478
|
+
// Click with timeout to prevent hanging on navigation
|
|
479
|
+
const clickWithTimeout = async (timeoutMs = 5000) => {
|
|
480
|
+
const clickPromise = element.click().catch(() => {
|
|
481
|
+
// If Puppeteer click fails, fallback to JS click
|
|
482
|
+
return element.evaluate(el => el.click());
|
|
483
|
+
});
|
|
484
|
+
const timeoutPromise = new Promise((_, reject) =>
|
|
485
|
+
setTimeout(() => reject(new Error('click timeout')), timeoutMs)
|
|
486
|
+
);
|
|
487
|
+
return Promise.race([clickPromise, timeoutPromise]).catch(() => {
|
|
488
|
+
// If click times out, try JS click as last resort
|
|
489
|
+
return element.evaluate(el => el.click());
|
|
490
|
+
});
|
|
491
|
+
};
|
|
406
492
|
|
|
407
|
-
|
|
408
|
-
try {
|
|
409
|
-
// Method 1: Puppeteer click (most reliable for most cases)
|
|
410
|
-
await element.click();
|
|
411
|
-
} catch (clickError) {
|
|
412
|
-
// Method 2: Scroll into view and try again
|
|
413
|
-
try {
|
|
414
|
-
await element.evaluate(el => el.scrollIntoView({ behavior: 'instant', block: 'center' }));
|
|
415
|
-
await new Promise(resolve => setTimeout(resolve, 100));
|
|
416
|
-
await element.click();
|
|
417
|
-
} catch (scrollClickError) {
|
|
418
|
-
// Method 3: JavaScript click (works for hidden/overlapping elements)
|
|
419
|
-
await element.evaluate(el => el.click());
|
|
420
|
-
}
|
|
421
|
-
}
|
|
493
|
+
await clickWithTimeout();
|
|
422
494
|
|
|
423
495
|
// NEW POST-CLICK PATTERN:
|
|
424
|
-
// 1. Run post-click diagnostics (waits
|
|
425
|
-
|
|
496
|
+
// 1. Run post-click diagnostics (waits for network requests within 200ms, max 10s timeout)
|
|
497
|
+
let diagnostics;
|
|
498
|
+
try {
|
|
499
|
+
diagnostics = await runPostClickDiagnostics(page, beforeClickTimestamp, {
|
|
500
|
+
skipNetworkWait: validatedArgs.skipNetworkWait,
|
|
501
|
+
networkWaitTimeout: validatedArgs.networkWaitTimeout,
|
|
502
|
+
urlBeforeAction: urlBeforeClick
|
|
503
|
+
});
|
|
504
|
+
} catch (diagError) {
|
|
505
|
+
// Diagnostics may fail if page navigated - create minimal diagnostics
|
|
506
|
+
diagnostics = {
|
|
507
|
+
networkActivity: { trackedRequests: [], bridgeRequests: [], stillPending: 0, pendingRequests: [] },
|
|
508
|
+
navigation: { from: urlBeforeClick, to: page.url(), likelyFormSubmit: true },
|
|
509
|
+
errors: { consoleErrors: [], networkErrors: [], totalErrors: 0 },
|
|
510
|
+
hasErrors: false
|
|
511
|
+
};
|
|
512
|
+
}
|
|
426
513
|
|
|
427
514
|
// 2. Generate AI hints after click
|
|
428
|
-
|
|
515
|
+
let hints;
|
|
516
|
+
try {
|
|
517
|
+
hints = await generateClickHints(page, identifier);
|
|
518
|
+
} catch (hintsError) {
|
|
519
|
+
hints = { modalOpened: false, newElements: [], suggestedNext: [] };
|
|
520
|
+
}
|
|
429
521
|
|
|
430
522
|
// 3. Format output with hints and diagnostics
|
|
431
523
|
let hintsText = '\n\n** AI HINTS **';
|
|
@@ -464,36 +556,60 @@ async function executeToolInternal(name, args) {
|
|
|
464
556
|
if (name === "type") {
|
|
465
557
|
const validatedArgs = schemas.TypeSchema.parse(args);
|
|
466
558
|
const page = await getLastOpenPage();
|
|
559
|
+
const timeout = validatedArgs.timeout || 30000;
|
|
467
560
|
|
|
468
|
-
//
|
|
469
|
-
const
|
|
561
|
+
// Wrap operation in timeout
|
|
562
|
+
const typeOperation = async () => {
|
|
563
|
+
// Get identifier (id or selector)
|
|
564
|
+
const identifier = validatedArgs.id || validatedArgs.selector;
|
|
470
565
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
566
|
+
// Resolve selector (supports both APOM ID and CSS selector)
|
|
567
|
+
const resolved = await resolveSelector(page, identifier);
|
|
568
|
+
if (!resolved.found) {
|
|
569
|
+
throw new Error(`Element not found: ${identifier}${resolved.isPageObjectId ? ' (APOM ID)' : ' (CSS selector)'}`);
|
|
570
|
+
}
|
|
476
571
|
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
572
|
+
const element = await page.$(resolved.selector);
|
|
573
|
+
if (!element) {
|
|
574
|
+
throw new Error(`Element not found: ${identifier}`);
|
|
575
|
+
}
|
|
481
576
|
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
delay: validatedArgs.delay !== undefined ? validatedArgs.delay : 30,
|
|
486
|
-
clearFirst: validatedArgs.clearFirst !== undefined ? validatedArgs.clearFirst : true,
|
|
487
|
-
};
|
|
577
|
+
// ALWAYS scroll to element first to ensure it's in viewport
|
|
578
|
+
await element.evaluate(el => el.scrollIntoView({ behavior: 'instant', block: 'center' }));
|
|
579
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
488
580
|
|
|
489
|
-
|
|
490
|
-
|
|
581
|
+
// Use input model to handle the element appropriately
|
|
582
|
+
const model = await getInputModel(element, page);
|
|
583
|
+
const options = {
|
|
584
|
+
delay: validatedArgs.delay !== undefined ? validatedArgs.delay : 30,
|
|
585
|
+
clearFirst: validatedArgs.clearFirst !== undefined ? validatedArgs.clearFirst : true,
|
|
586
|
+
};
|
|
491
587
|
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
588
|
+
await model.setValue(validatedArgs.text, options);
|
|
589
|
+
const description = model.getActionDescription(validatedArgs.text, identifier);
|
|
590
|
+
|
|
591
|
+
// Capture timestamp AFTER typing finishes - requests should start within 200ms of this
|
|
592
|
+
const afterTypeTimestamp = Date.now();
|
|
593
|
+
|
|
594
|
+
// Run diagnostics with 3s timeout for type (shorter than click's 10s)
|
|
595
|
+
const diagnostics = await runPostClickDiagnostics(page, afterTypeTimestamp, {
|
|
596
|
+
networkWaitTimeout: 3000
|
|
597
|
+
});
|
|
598
|
+
const diagnosticsText = formatDiagnosticsForAI(diagnostics);
|
|
599
|
+
|
|
600
|
+
return {
|
|
601
|
+
content: [
|
|
602
|
+
{ type: "text", text: `${description}${diagnosticsText}` }
|
|
603
|
+
],
|
|
604
|
+
};
|
|
496
605
|
};
|
|
606
|
+
|
|
607
|
+
// Execute with timeout
|
|
608
|
+
const timeoutPromise = new Promise((_, reject) =>
|
|
609
|
+
setTimeout(() => reject(new Error(`Type operation timed out after ${timeout}ms`)), timeout)
|
|
610
|
+
);
|
|
611
|
+
|
|
612
|
+
return Promise.race([typeOperation(), timeoutPromise]);
|
|
497
613
|
}
|
|
498
614
|
|
|
499
615
|
if (name === "getComputedCss") {
|
|
@@ -2096,20 +2212,25 @@ Start coding now.`;
|
|
|
2096
2212
|
};
|
|
2097
2213
|
}
|
|
2098
2214
|
|
|
2215
|
+
// Store previous analysis for diff calculation
|
|
2216
|
+
if (!global.previousApomAnalysis) {
|
|
2217
|
+
global.previousApomAnalysis = new Map(); // pageUrl -> analysis data
|
|
2218
|
+
}
|
|
2219
|
+
|
|
2099
2220
|
if (name === "analyzePage") {
|
|
2100
2221
|
const validatedArgs = schemas.AnalyzePageSchema.parse(args);
|
|
2101
2222
|
const page = await getLastOpenPage();
|
|
2102
2223
|
const pageUrl = page.url();
|
|
2103
2224
|
|
|
2104
2225
|
// APOM Tree format (default) - v2 with tree structure and positioning
|
|
2105
|
-
const apomResult = await page.evaluate((apomTreeConverterCode, selectorResolverCode, shouldRegister, includeAll) => {
|
|
2226
|
+
const apomResult = await page.evaluate((apomTreeConverterCode, selectorResolverCode, shouldRegister, includeAll, viewportOnly) => {
|
|
2106
2227
|
// Inject utilities
|
|
2107
2228
|
eval(apomTreeConverterCode);
|
|
2108
2229
|
eval(selectorResolverCode);
|
|
2109
2230
|
|
|
2110
2231
|
// Build APOM tree
|
|
2111
2232
|
// interactiveOnly = !includeAll (if includeAll is true, we want ALL elements)
|
|
2112
|
-
const apomData = buildAPOMTree(!includeAll);
|
|
2233
|
+
const apomData = buildAPOMTree(!includeAll, viewportOnly);
|
|
2113
2234
|
|
|
2114
2235
|
// Register elements in selector resolver if requested
|
|
2115
2236
|
if (shouldRegister) {
|
|
@@ -2142,7 +2263,43 @@ Start coding now.`;
|
|
|
2142
2263
|
}
|
|
2143
2264
|
|
|
2144
2265
|
return apomData;
|
|
2145
|
-
}, apomTreeConverter, selectorResolver, validatedArgs.registerElements !== false, validatedArgs.includeAll || false);
|
|
2266
|
+
}, apomTreeConverter, selectorResolver, validatedArgs.registerElements !== false, validatedArgs.includeAll || false, validatedArgs.viewportOnly || false);
|
|
2267
|
+
|
|
2268
|
+
// Handle diff mode
|
|
2269
|
+
if (validatedArgs.diff) {
|
|
2270
|
+
const previousAnalysis = global.previousApomAnalysis.get(pageUrl);
|
|
2271
|
+
|
|
2272
|
+
if (previousAnalysis) {
|
|
2273
|
+
// Calculate diff
|
|
2274
|
+
const diff = calculateApomDiff(previousAnalysis, apomResult);
|
|
2275
|
+
|
|
2276
|
+
// Store current analysis for next diff
|
|
2277
|
+
global.previousApomAnalysis.set(pageUrl, apomResult);
|
|
2278
|
+
|
|
2279
|
+
return {
|
|
2280
|
+
content: [{
|
|
2281
|
+
type: 'text',
|
|
2282
|
+
text: JSON.stringify({
|
|
2283
|
+
mode: 'diff',
|
|
2284
|
+
pageId: apomResult.pageId,
|
|
2285
|
+
url: apomResult.url,
|
|
2286
|
+
timestamp: apomResult.timestamp,
|
|
2287
|
+
previousTimestamp: previousAnalysis.timestamp,
|
|
2288
|
+
diff,
|
|
2289
|
+
metadata: apomResult.metadata,
|
|
2290
|
+
alerts: apomResult.alerts
|
|
2291
|
+
})
|
|
2292
|
+
}]
|
|
2293
|
+
};
|
|
2294
|
+
} else {
|
|
2295
|
+
// No previous analysis, return full result with note
|
|
2296
|
+
global.previousApomAnalysis.set(pageUrl, apomResult);
|
|
2297
|
+
apomResult._note = 'First analysis for this page, no diff available';
|
|
2298
|
+
}
|
|
2299
|
+
} else {
|
|
2300
|
+
// Store for future diff
|
|
2301
|
+
global.previousApomAnalysis.set(pageUrl, apomResult);
|
|
2302
|
+
}
|
|
2146
2303
|
|
|
2147
2304
|
return {
|
|
2148
2305
|
content: [{
|
|
@@ -2152,6 +2309,82 @@ Start coding now.`;
|
|
|
2152
2309
|
};
|
|
2153
2310
|
}
|
|
2154
2311
|
|
|
2312
|
+
/**
|
|
2313
|
+
* Calculate diff between two APOM analyses
|
|
2314
|
+
*/
|
|
2315
|
+
function calculateApomDiff(previous, current) {
|
|
2316
|
+
const previousElements = flattenApomTree(previous.tree);
|
|
2317
|
+
const currentElements = flattenApomTree(current.tree);
|
|
2318
|
+
|
|
2319
|
+
const previousIds = new Set(previousElements.map(e => e.id));
|
|
2320
|
+
const currentIds = new Set(currentElements.map(e => e.id));
|
|
2321
|
+
|
|
2322
|
+
const added = currentElements.filter(e => !previousIds.has(e.id));
|
|
2323
|
+
const removed = previousElements.filter(e => !currentIds.has(e.id));
|
|
2324
|
+
|
|
2325
|
+
// Find changed elements (same ID but different content)
|
|
2326
|
+
const changed = [];
|
|
2327
|
+
for (const curr of currentElements) {
|
|
2328
|
+
if (previousIds.has(curr.id)) {
|
|
2329
|
+
const prev = previousElements.find(e => e.id === curr.id);
|
|
2330
|
+
if (prev && JSON.stringify(prev.metadata) !== JSON.stringify(curr.metadata)) {
|
|
2331
|
+
changed.push({
|
|
2332
|
+
id: curr.id,
|
|
2333
|
+
type: curr.type,
|
|
2334
|
+
before: prev.metadata,
|
|
2335
|
+
after: curr.metadata
|
|
2336
|
+
});
|
|
2337
|
+
}
|
|
2338
|
+
}
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2341
|
+
return {
|
|
2342
|
+
added: added.length > 0 ? added : undefined,
|
|
2343
|
+
removed: removed.length > 0 ? removed : undefined,
|
|
2344
|
+
changed: changed.length > 0 ? changed : undefined,
|
|
2345
|
+
summary: {
|
|
2346
|
+
addedCount: added.length,
|
|
2347
|
+
removedCount: removed.length,
|
|
2348
|
+
changedCount: changed.length
|
|
2349
|
+
}
|
|
2350
|
+
};
|
|
2351
|
+
}
|
|
2352
|
+
|
|
2353
|
+
/**
|
|
2354
|
+
* Flatten APOM tree to array of elements
|
|
2355
|
+
*/
|
|
2356
|
+
function flattenApomTree(node, result = []) {
|
|
2357
|
+
if (!node) return result;
|
|
2358
|
+
|
|
2359
|
+
// Handle compact format: { "tag_id": [children] }
|
|
2360
|
+
if (typeof node === 'object' && !node.id && !node.tag) {
|
|
2361
|
+
const keys = Object.keys(node);
|
|
2362
|
+
for (const key of keys) {
|
|
2363
|
+
if (Array.isArray(node[key])) {
|
|
2364
|
+
node[key].forEach(child => flattenApomTree(child, result));
|
|
2365
|
+
}
|
|
2366
|
+
}
|
|
2367
|
+
return result;
|
|
2368
|
+
}
|
|
2369
|
+
|
|
2370
|
+
// Interactive element with id
|
|
2371
|
+
if (node.id) {
|
|
2372
|
+
result.push({
|
|
2373
|
+
id: node.id,
|
|
2374
|
+
tag: node.tag,
|
|
2375
|
+
type: node.type,
|
|
2376
|
+
metadata: node.metadata
|
|
2377
|
+
});
|
|
2378
|
+
}
|
|
2379
|
+
|
|
2380
|
+
// Process children
|
|
2381
|
+
if (node.children) {
|
|
2382
|
+
node.children.forEach(child => flattenApomTree(child, result));
|
|
2383
|
+
}
|
|
2384
|
+
|
|
2385
|
+
return result;
|
|
2386
|
+
}
|
|
2387
|
+
|
|
2155
2388
|
if (name === "getElementDetails") {
|
|
2156
2389
|
const validatedArgs = schemas.GetElementDetailsSchema.parse(args);
|
|
2157
2390
|
const page = await getLastOpenPage();
|
|
@@ -2462,6 +2695,9 @@ Start coding now.`;
|
|
|
2462
2695
|
const extensionConnected = isExtensionConnected();
|
|
2463
2696
|
const debugInfo = getWsDebugInfo();
|
|
2464
2697
|
|
|
2698
|
+
// Always log connection state for debugging
|
|
2699
|
+
console.error(`[chrometools-mcp] enableRecorder check: bridgeConnected=${debugInfo.bridgeConnected}, extensionConnected=${extensionConnected}, wsState=${debugInfo.readyState}`);
|
|
2700
|
+
|
|
2465
2701
|
if (extensionConnected) {
|
|
2466
2702
|
return {
|
|
2467
2703
|
content: [{
|
package/models/TextInputModel.js
CHANGED
|
@@ -7,25 +7,76 @@
|
|
|
7
7
|
|
|
8
8
|
import { BaseInputModel } from './BaseInputModel.js';
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Wrap operation with timeout to prevent hanging
|
|
12
|
+
*/
|
|
13
|
+
async function withTimeout(operation, timeoutMs, operationName) {
|
|
14
|
+
const timeoutPromise = new Promise((_, reject) =>
|
|
15
|
+
setTimeout(() => reject(new Error(`${operationName} timed out after ${timeoutMs}ms`)), timeoutMs)
|
|
16
|
+
);
|
|
17
|
+
return Promise.race([operation(), timeoutPromise]);
|
|
18
|
+
}
|
|
19
|
+
|
|
10
20
|
export class TextInputModel extends BaseInputModel {
|
|
11
21
|
static get inputTypes() {
|
|
12
22
|
return ['text', 'email', 'tel', 'password', 'search', 'url', 'number', null];
|
|
13
23
|
}
|
|
14
24
|
|
|
15
25
|
/**
|
|
16
|
-
* Type text into the input using keyboard simulation
|
|
26
|
+
* Type text into the input using keyboard simulation with JS fallback
|
|
17
27
|
* @param {string} value - Text to type
|
|
18
28
|
* @param {object} options - { delay, clearFirst }
|
|
19
29
|
*/
|
|
20
30
|
async setValue(value, options = {}) {
|
|
21
31
|
const { delay = 0, clearFirst = true } = options;
|
|
32
|
+
const opTimeout = 5000; // 5s timeout per operation
|
|
33
|
+
|
|
34
|
+
// Method 1: Try Puppeteer typing (works for most cases)
|
|
35
|
+
try {
|
|
36
|
+
// Focus and clear using JS (most reliable)
|
|
37
|
+
await withTimeout(
|
|
38
|
+
() => this.element.evaluate((el, shouldClear) => {
|
|
39
|
+
el.focus();
|
|
40
|
+
el.click();
|
|
41
|
+
if (shouldClear) {
|
|
42
|
+
el.select(); // Select all text
|
|
43
|
+
}
|
|
44
|
+
}, clearFirst),
|
|
45
|
+
opTimeout,
|
|
46
|
+
'focus-and-select'
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
// Small delay to ensure focus is established
|
|
50
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
51
|
+
|
|
52
|
+
// Type the new value
|
|
53
|
+
const typeTimeout = Math.max(opTimeout, value.length * delay + 5000);
|
|
54
|
+
await withTimeout(
|
|
55
|
+
() => this.element.type(value, { delay }),
|
|
56
|
+
typeTimeout,
|
|
57
|
+
'type'
|
|
58
|
+
);
|
|
22
59
|
|
|
23
|
-
|
|
24
|
-
await this.element.
|
|
25
|
-
|
|
60
|
+
// Verify the value was set
|
|
61
|
+
const actualValue = await this.element.evaluate(el => el.value);
|
|
62
|
+
if (actualValue.includes(value)) {
|
|
63
|
+
return; // Success
|
|
64
|
+
}
|
|
65
|
+
} catch (e) {
|
|
66
|
+
// Fall through to JS method
|
|
26
67
|
}
|
|
27
68
|
|
|
28
|
-
|
|
69
|
+
// Method 2: Fallback to direct JS value setting
|
|
70
|
+
await withTimeout(
|
|
71
|
+
() => this.element.evaluate((el, newValue) => {
|
|
72
|
+
el.focus();
|
|
73
|
+
el.value = newValue;
|
|
74
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
75
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
76
|
+
}, value),
|
|
77
|
+
opTimeout,
|
|
78
|
+
'js-set-value'
|
|
79
|
+
);
|
|
29
80
|
}
|
|
30
81
|
|
|
31
82
|
getActionDescription(value, identifier) {
|