chrometools-mcp 3.2.6 → 3.3.6

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.
@@ -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
  // ============================================
@@ -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) {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "ChromeTools MCP",
4
- "version": "3.1.2",
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";
@@ -54,6 +54,8 @@ import {getToolsFromGroups, getAllGroupNames} from './server/tool-groups.js';
54
54
  import {executeElementAction} from './utils/element-actions.js';
55
55
  // Import hints generator
56
56
  import {generateClickHints, generateNavigationHints} from './utils/hints-generator.js';
57
+ // Import post-click diagnostics
58
+ import {runPostClickDiagnostics, formatDiagnosticsForAI} from './utils/post-click-diagnostics.js';
57
59
 
58
60
  // Import Recorder modules
59
61
  // Note: injectRecorder removed - now using Chrome Extension
@@ -313,12 +315,22 @@ async function executeToolInternal(name, args) {
313
315
 
314
316
  if (name === "openBrowser") {
315
317
  const validatedArgs = schemas.OpenBrowserSchema.parse(args);
318
+
319
+ // Capture timestamp BEFORE opening for diagnostics
320
+ const beforeOpenTimestamp = Date.now();
321
+
316
322
  const page = await getOrCreatePage(validatedArgs.url);
317
323
  const title = await page.title();
318
324
 
325
+ // Run post-navigation diagnostics (same as navigateTo)
326
+ const diagnostics = await runPostClickDiagnostics(page, beforeOpenTimestamp);
327
+
319
328
  // Generate AI hints
320
329
  const hints = await generateNavigationHints(page, validatedArgs.url);
321
330
 
331
+ // Format diagnostics for output
332
+ const diagnosticsText = formatDiagnosticsForAI(diagnostics);
333
+
322
334
  // Check if extension is connected
323
335
  const extensionConnected = isExtensionConnected();
324
336
  const usedExistingChrome = isConnectedToExistingChrome();
@@ -329,22 +341,84 @@ async function executeToolInternal(name, args) {
329
341
  extensionNote = `\n\n⚠️ EXTENSION NOT CONNECTED\nConnected to existing Chrome - extension needs manual installation.\n${instructions.installSteps.join('\n')}\n\nAlternative: ${instructions.alternativeFix}`;
330
342
  }
331
343
 
344
+ let hintsText = '\n\n** AI HINTS **';
345
+ hintsText += `\nPage type: ${hints.pageType}`;
346
+ if (hints.availableActions.length > 0) {
347
+ hintsText += `\nAvailable actions: ${hints.availableActions.join(', ')}`;
348
+ }
349
+ if (hints.suggestedNext.length > 0) {
350
+ hintsText += `\nSuggested next: ${hints.suggestedNext.join('; ')}`;
351
+ }
352
+
332
353
  return {
333
354
  content: [
334
355
  {
335
356
  type: "text",
336
- text: `Browser opened successfully!\nURL: ${validatedArgs.url}\nPage title: ${title}\n\nBrowser remains open for interaction.\n\n** AI HINTS **\nPage type: ${hints.pageType}\nAvailable actions: ${hints.availableActions.join(', ')}\nSuggested next: ${hints.suggestedNext.join('; ')}${extensionNote}`,
357
+ text: `Browser opened successfully!\nURL: ${validatedArgs.url}\nPage title: ${title}\n\nBrowser remains open for interaction.${hintsText}${diagnosticsText}${extensionNote}`,
337
358
  },
338
359
  ],
339
360
  };
340
361
  }
341
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
+
342
415
  /**
343
416
  * Helper: Resolve selector (ID or CSS selector)
344
417
  * Injects selector-resolver and resolves element identifier
418
+ * Auto-refreshes element registry if APOM ID not found
345
419
  */
346
- async function resolveSelector(page, identifier) {
347
- return await page.evaluate((id, selectorResolverCode) => {
420
+ async function resolveSelector(page, identifier, timeoutMs = 5000) {
421
+ const tryResolve = () => page.evaluate((id, selectorResolverCode) => {
348
422
  // Inject selector resolver if not already loaded
349
423
  if (typeof resolveSelector === 'undefined') {
350
424
  eval(selectorResolverCode);
@@ -357,6 +431,20 @@ async function executeToolInternal(name, args) {
357
431
  found: document.querySelector(resolved.selector) !== null
358
432
  };
359
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;
360
448
  }
361
449
 
362
450
  if (name === "click") {
@@ -380,26 +468,58 @@ async function executeToolInternal(name, args) {
380
468
  throw new Error(`Element not found: ${identifier}`);
381
469
  }
382
470
 
383
- // Try multiple click methods for better reliability
471
+ // Capture timestamp and URL BEFORE click for diagnostics
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
+ };
492
+
493
+ await clickWithTimeout();
494
+
495
+ // NEW POST-CLICK PATTERN:
496
+ // 1. Run post-click diagnostics (waits for network requests within 200ms, max 10s timeout)
497
+ let diagnostics;
384
498
  try {
385
- // Method 1: Puppeteer click (most reliable for most cases)
386
- await element.click();
387
- } catch (clickError) {
388
- // Method 2: Scroll into view and try again
389
- try {
390
- await element.evaluate(el => el.scrollIntoView({ behavior: 'instant', block: 'center' }));
391
- await new Promise(resolve => setTimeout(resolve, 100));
392
- await element.click();
393
- } catch (scrollClickError) {
394
- // Method 3: JavaScript click (works for hidden/overlapping elements)
395
- await element.evaluate(el => el.click());
396
- }
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
+ };
397
512
  }
398
- await new Promise(resolve => setTimeout(resolve, validatedArgs.waitAfter || 1500));
399
513
 
400
- // Generate AI hints after click
401
- const hints = await generateClickHints(page, identifier);
514
+ // 2. Generate AI hints after click
515
+ let hints;
516
+ try {
517
+ hints = await generateClickHints(page, identifier);
518
+ } catch (hintsError) {
519
+ hints = { modalOpened: false, newElements: [], suggestedNext: [] };
520
+ }
402
521
 
522
+ // 3. Format output with hints and diagnostics
403
523
  let hintsText = '\n\n** AI HINTS **';
404
524
  if (hints.modalOpened) hintsText += '\nModal opened - interact with it or close';
405
525
  if (hints.newElements.length > 0) {
@@ -409,8 +529,11 @@ async function executeToolInternal(name, args) {
409
529
  hintsText += `\nSuggested next: ${hints.suggestedNext.join('; ')}`;
410
530
  }
411
531
 
532
+ // 4. Add diagnostics to output
533
+ const diagnosticsText = formatDiagnosticsForAI(diagnostics);
534
+
412
535
  const content = [
413
- { type: "text", text: `Clicked: ${identifier}${hintsText}` }
536
+ { type: "text", text: `Clicked: ${identifier}${hintsText}${diagnosticsText}` }
414
537
  ];
415
538
 
416
539
  // Only add screenshot if requested
@@ -433,36 +556,60 @@ async function executeToolInternal(name, args) {
433
556
  if (name === "type") {
434
557
  const validatedArgs = schemas.TypeSchema.parse(args);
435
558
  const page = await getLastOpenPage();
559
+ const timeout = validatedArgs.timeout || 30000;
436
560
 
437
- // Get identifier (id or selector)
438
- const identifier = validatedArgs.id || validatedArgs.selector;
561
+ // Wrap operation in timeout
562
+ const typeOperation = async () => {
563
+ // Get identifier (id or selector)
564
+ const identifier = validatedArgs.id || validatedArgs.selector;
439
565
 
440
- // Resolve selector (supports both APOM ID and CSS selector)
441
- const resolved = await resolveSelector(page, identifier);
442
- if (!resolved.found) {
443
- throw new Error(`Element not found: ${identifier}${resolved.isPageObjectId ? ' (APOM ID)' : ' (CSS selector)'}`);
444
- }
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
+ }
445
571
 
446
- const element = await page.$(resolved.selector);
447
- if (!element) {
448
- throw new Error(`Element not found: ${identifier}`);
449
- }
572
+ const element = await page.$(resolved.selector);
573
+ if (!element) {
574
+ throw new Error(`Element not found: ${identifier}`);
575
+ }
450
576
 
451
- // Use input model to handle the element appropriately
452
- const model = await getInputModel(element, page);
453
- const options = {
454
- delay: validatedArgs.delay !== undefined ? validatedArgs.delay : 30,
455
- clearFirst: validatedArgs.clearFirst !== undefined ? validatedArgs.clearFirst : true,
456
- };
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));
457
580
 
458
- await model.setValue(validatedArgs.text, options);
459
- const description = model.getActionDescription(validatedArgs.text, identifier);
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
+ };
460
587
 
461
- return {
462
- content: [
463
- { type: "text", text: description }
464
- ],
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
+ };
465
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]);
466
613
  }
467
614
 
468
615
  if (name === "getComputedCss") {
@@ -1343,6 +1490,9 @@ async function executeToolInternal(name, args) {
1343
1490
  browserOpened = true;
1344
1491
  }
1345
1492
 
1493
+ // Capture timestamp BEFORE navigation for diagnostics
1494
+ const beforeNavTimestamp = Date.now();
1495
+
1346
1496
  // Navigate to the new URL (skip if we just created page with this URL)
1347
1497
  if (!browserOpened) {
1348
1498
  await page.goto(validatedArgs.url, { waitUntil: validatedArgs.waitUntil || 'networkidle2' });
@@ -1350,17 +1500,32 @@ async function executeToolInternal(name, args) {
1350
1500
 
1351
1501
  const title = await page.title();
1352
1502
 
1503
+ // Run post-navigation diagnostics (same as post-click)
1504
+ const diagnostics = await runPostClickDiagnostics(page, beforeNavTimestamp);
1505
+
1353
1506
  // Generate AI hints
1354
1507
  const hints = await generateNavigationHints(page, validatedArgs.url);
1355
1508
 
1509
+ // Format diagnostics for output
1510
+ const diagnosticsText = formatDiagnosticsForAI(diagnostics);
1511
+
1356
1512
  const message = browserOpened
1357
1513
  ? `Browser opened and navigated to: ${validatedArgs.url}`
1358
1514
  : `Navigated to: ${validatedArgs.url}`;
1359
1515
 
1516
+ let hintsText = '\n\n** AI HINTS **';
1517
+ hintsText += `\nPage type: ${hints.pageType}`;
1518
+ if (hints.availableActions.length > 0) {
1519
+ hintsText += `\nAvailable actions: ${hints.availableActions.join(', ')}`;
1520
+ }
1521
+ if (hints.suggestedNext.length > 0) {
1522
+ hintsText += `\nSuggested next: ${hints.suggestedNext.join('; ')}`;
1523
+ }
1524
+
1360
1525
  return {
1361
1526
  content: [{
1362
1527
  type: "text",
1363
- text: `${message}\nPage title: ${title}\n\n** AI HINTS **\nPage type: ${hints.pageType}\nAvailable actions: ${hints.availableActions.join(', ')}\nSuggested next: ${hints.suggestedNext.join('; ')}`
1528
+ text: `${message}\nPage title: ${title}${hintsText}${diagnosticsText}`
1364
1529
  }],
1365
1530
  };
1366
1531
  }
@@ -2413,6 +2578,9 @@ Start coding now.`;
2413
2578
  const extensionConnected = isExtensionConnected();
2414
2579
  const debugInfo = getWsDebugInfo();
2415
2580
 
2581
+ // Always log connection state for debugging
2582
+ console.error(`[chrometools-mcp] enableRecorder check: bridgeConnected=${debugInfo.bridgeConnected}, extensionConnected=${extensionConnected}, wsState=${debugInfo.readyState}`);
2583
+
2416
2584
  if (extensionConnected) {
2417
2585
  return {
2418
2586
  content: [{
@@ -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
- if (clearFirst) {
24
- await this.element.click({ clickCount: 3 });
25
- await this.page.keyboard.press('Backspace');
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
- await this.element.type(value, { delay });
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) {
package/models/index.js CHANGED
@@ -38,18 +38,32 @@ const MODEL_REGISTRY = [
38
38
  TextInputModel, // Default fallback for text-like inputs
39
39
  ];
40
40
 
41
+ /**
42
+ * Wrap operation with timeout to prevent hanging
43
+ */
44
+ async function withTimeout(operation, timeoutMs, operationName) {
45
+ const timeoutPromise = new Promise((_, reject) =>
46
+ setTimeout(() => reject(new Error(`${operationName} timed out after ${timeoutMs}ms`)), timeoutMs)
47
+ );
48
+ return Promise.race([operation(), timeoutPromise]);
49
+ }
50
+
41
51
  /**
42
52
  * Factory class for creating appropriate input models
43
53
  */
44
54
  export class InputModelFactory {
45
55
  /**
46
- * Get element info (tagName, inputType)
56
+ * Get element info (tagName, inputType) with timeout
47
57
  */
48
- static async getElementInfo(element) {
49
- return await element.evaluate(el => ({
50
- tagName: el.tagName.toLowerCase(),
51
- inputType: el.type?.toLowerCase() || null,
52
- }));
58
+ static async getElementInfo(element, timeoutMs = 5000) {
59
+ return await withTimeout(
60
+ () => element.evaluate(el => ({
61
+ tagName: el.tagName.toLowerCase(),
62
+ inputType: el.type?.toLowerCase() || null,
63
+ })),
64
+ timeoutMs,
65
+ 'getElementInfo'
66
+ );
53
67
  }
54
68
 
55
69
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chrometools-mcp",
3
- "version": "3.2.6",
3
+ "version": "3.3.6",
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",