chrometools-mcp 2.4.2 → 3.1.2

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.
Files changed (48) hide show
  1. package/CHANGELOG.md +540 -0
  2. package/COMPONENT_MAPPING_SPEC.md +1217 -0
  3. package/README.md +494 -38
  4. package/bridge/bridge-client.js +472 -0
  5. package/bridge/bridge-service.js +399 -0
  6. package/bridge/install.js +241 -0
  7. package/browser/browser-manager.js +107 -2
  8. package/browser/page-manager.js +226 -69
  9. package/docs/CHROME_EXTENSION.md +219 -0
  10. package/docs/PAGE_OBJECT_MODEL_CONCEPT.md +1756 -0
  11. package/element-finder-utils.js +138 -28
  12. package/extension/background.js +643 -0
  13. package/extension/content.js +715 -0
  14. package/extension/icons/create-icons.js +164 -0
  15. package/extension/icons/icon128.png +0 -0
  16. package/extension/icons/icon16.png +0 -0
  17. package/extension/icons/icon48.png +0 -0
  18. package/extension/manifest.json +58 -0
  19. package/extension/popup/popup.css +437 -0
  20. package/extension/popup/popup.html +102 -0
  21. package/extension/popup/popup.js +415 -0
  22. package/extension/recorder-overlay.css +93 -0
  23. package/figma-tools.js +120 -0
  24. package/index.js +3347 -2518
  25. package/models/BaseInputModel.js +93 -0
  26. package/models/CheckboxGroupModel.js +199 -0
  27. package/models/CheckboxModel.js +103 -0
  28. package/models/ColorInputModel.js +53 -0
  29. package/models/DateInputModel.js +67 -0
  30. package/models/RadioGroupModel.js +126 -0
  31. package/models/RangeInputModel.js +60 -0
  32. package/models/SelectModel.js +97 -0
  33. package/models/TextInputModel.js +34 -0
  34. package/models/TextareaModel.js +59 -0
  35. package/models/TimeInputModel.js +49 -0
  36. package/models/index.js +122 -0
  37. package/package.json +3 -2
  38. package/pom/apom-converter.js +267 -0
  39. package/pom/apom-tree-converter.js +515 -0
  40. package/pom/element-id-generator.js +175 -0
  41. package/recorder/page-object-generator.js +16 -0
  42. package/recorder/scenario-executor.js +80 -2
  43. package/server/tool-definitions.js +839 -656
  44. package/server/tool-groups.js +3 -2
  45. package/server/tool-schemas.js +367 -296
  46. package/server/websocket-bridge.js +447 -0
  47. package/utils/selector-resolver.js +186 -0
  48. package/utils/ui-framework-detector.js +392 -0
@@ -7,12 +7,25 @@
7
7
  import puppeteer from 'puppeteer';
8
8
  import { spawn } from 'child_process';
9
9
  import http from 'http';
10
+ import path from 'path';
11
+ import { fileURLToPath } from 'url';
10
12
  import { getChromePath, getTempDir, isWSL, CHROME_DEBUG_PORT } from '../utils/platform-utils.js';
13
+ import { handleNewTab, openPages, lastPage } from './page-manager.js';
14
+
15
+ // Get extension path (use forward slashes for Chrome on Windows)
16
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
17
+ const EXTENSION_PATH = path.join(__dirname, '..', 'extension').replace(/\\/g, '/');
11
18
 
12
19
  // Global browser instance (persists between requests)
13
20
  let browserPromise = null;
14
21
  let chromeProcess = null;
15
22
 
23
+ // Track if we connected to existing Chrome (extension won't be auto-loaded)
24
+ let connectedToExistingChrome = false;
25
+
26
+ // Track pages we've already seen to avoid double-handling
27
+ const knownTargets = new WeakSet();
28
+
16
29
  /**
17
30
  * Debug log helper (only logs to stderr when DEBUG=1)
18
31
  * @param {...any} args - Arguments to log
@@ -91,15 +104,24 @@ export async function getBrowser() {
91
104
  debugLog("Connected to existing Chrome instance");
92
105
  debugLog("WebSocket endpoint:", endpoint);
93
106
 
107
+ // Mark that we connected to existing Chrome (extension won't be auto-loaded)
108
+ connectedToExistingChrome = true;
109
+ console.error("[chrometools-mcp] Connected to existing Chrome. Extension may need manual installation.");
110
+
94
111
  // Set up disconnect handler to reset browserPromise
95
112
  browser.on('disconnected', () => {
96
113
  debugLog("Browser disconnected");
97
114
  browserPromise = null;
115
+ connectedToExistingChrome = false;
98
116
  });
99
117
 
118
+ // Set up new tab tracking
119
+ setupNewTabTracking(browser);
120
+
100
121
  return browser;
101
122
  } catch (connectError) {
102
123
  debugLog("No existing Chrome found, launching new instance...");
124
+ connectedToExistingChrome = false;
103
125
  }
104
126
 
105
127
  // Launch new Chrome with remote debugging enabled
@@ -109,12 +131,19 @@ export async function getBrowser() {
109
131
  debugLog("Chrome path:", chromePath);
110
132
  debugLog("User data dir:", userDataDir);
111
133
 
112
- chromeProcess = spawn(chromePath, [
134
+ // Build Chrome launch arguments
135
+ const chromeArgs = [
113
136
  `--remote-debugging-port=${CHROME_DEBUG_PORT}`,
114
137
  '--no-first-run',
115
138
  '--no-default-browser-check',
116
139
  `--user-data-dir=${userDataDir}`,
117
- ], {
140
+ // Auto-load ChromeTools extension
141
+ `--load-extension=${EXTENSION_PATH}`,
142
+ ];
143
+
144
+ debugLog("Extension path:", EXTENSION_PATH);
145
+
146
+ chromeProcess = spawn(chromePath, chromeArgs, {
118
147
  detached: true,
119
148
  stdio: 'ignore',
120
149
  });
@@ -141,6 +170,9 @@ export async function getBrowser() {
141
170
  browserPromise = null;
142
171
  });
143
172
 
173
+ // Set up new tab tracking
174
+ setupNewTabTracking(browser);
175
+
144
176
  return browser;
145
177
  } catch (error) {
146
178
  // Check if it's a display-related error in WSL
@@ -189,6 +221,61 @@ This requires an X server to display the browser GUI.
189
221
  return await browserPromise;
190
222
  }
191
223
 
224
+ /**
225
+ * Setup tracking for new tabs opened via window.open, target="_blank", etc.
226
+ * @param {Browser} browser - Puppeteer browser instance
227
+ */
228
+ function setupNewTabTracking(browser) {
229
+ browser.on('targetcreated', async (target) => {
230
+ // Only handle page targets (not service workers, etc.)
231
+ if (target.type() !== 'page') {
232
+ return;
233
+ }
234
+
235
+ // Skip if we've already processed this target
236
+ if (knownTargets.has(target)) {
237
+ return;
238
+ }
239
+ knownTargets.add(target);
240
+
241
+ try {
242
+ const page = await target.page();
243
+ if (!page) {
244
+ return;
245
+ }
246
+
247
+ // Check if this page is already tracked (created via getOrCreatePage)
248
+ const currentUrl = page.url();
249
+ for (const [url, trackedPage] of openPages.entries()) {
250
+ if (trackedPage === page) {
251
+ debugLog('Page already tracked, skipping:', url);
252
+ return;
253
+ }
254
+ }
255
+
256
+ // Get opener URL if available
257
+ const opener = target.opener();
258
+ let openerUrl = '';
259
+ if (opener) {
260
+ try {
261
+ const openerPage = await opener.page();
262
+ openerUrl = openerPage ? openerPage.url() : '';
263
+ } catch (e) {
264
+ // Opener might not be available
265
+ }
266
+ }
267
+
268
+ // Handle the new tab
269
+ await handleNewTab(page, openerUrl);
270
+
271
+ } catch (error) {
272
+ debugLog('Error handling new tab:', error.message);
273
+ }
274
+ });
275
+
276
+ debugLog('New tab tracking enabled');
277
+ }
278
+
192
279
  /**
193
280
  * Close browser and cleanup
194
281
  * @returns {Promise<void>}
@@ -203,4 +290,22 @@ export async function closeBrowser() {
203
290
  }
204
291
  browserPromise = null;
205
292
  }
293
+ connectedToExistingChrome = false;
294
+ }
295
+
296
+ /**
297
+ * Check if we connected to an existing Chrome instance
298
+ * (extension won't be auto-loaded in this case)
299
+ * @returns {boolean}
300
+ */
301
+ export function isConnectedToExistingChrome() {
302
+ return connectedToExistingChrome;
303
+ }
304
+
305
+ /**
306
+ * Get extension path for manual installation
307
+ * @returns {string}
308
+ */
309
+ export function getExtensionPath() {
310
+ return EXTENSION_PATH;
206
311
  }
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import { getBrowser } from './browser-manager.js';
8
- import { injectRecorder } from '../recorder/recorder-script.js';
8
+ // Note: injectRecorder removed - now using Chrome Extension for recording
9
9
 
10
10
  /**
11
11
  * Debug log helper (only logs to stderr when DEBUG=1)
@@ -21,6 +21,9 @@ function debugLog(...args) {
21
21
  export const openPages = new Map();
22
22
  export let lastPage = null;
23
23
 
24
+ // New tab events queue - for notifying AI about new tabs
25
+ export const newTabEvents = [];
26
+
24
27
  // Console logs storage
25
28
  export const consoleLogs = [];
26
29
 
@@ -30,9 +33,6 @@ export const networkRequests = [];
30
33
  // Page analysis cache
31
34
  export const pageAnalysisCache = new Map();
32
35
 
33
- // Track pages with recorder injected
34
- export const pagesWithRecorder = new WeakSet();
35
-
36
36
  // Track pages with network monitoring to prevent duplicate setup
37
37
  const pagesWithNetworkMonitoring = new WeakSet();
38
38
 
@@ -126,60 +126,7 @@ export async function setupNetworkMonitoring(page) {
126
126
  });
127
127
  }
128
128
 
129
- /**
130
- * Setup recorder auto-reinjection on navigation
131
- * @param {Page} page - Puppeteer page instance
132
- */
133
- export async function setupRecorderAutoReinjection(page) {
134
- let reinjectionTimeout = null;
135
- let lastUrl = null;
136
-
137
- // Handle navigation events (form submits, link clicks, history API)
138
- page.on('framenavigated', async (frame) => {
139
- // Only handle main frame navigation
140
- if (frame !== page.mainFrame()) return;
141
-
142
- // Get current URL
143
- const currentUrl = frame.url();
144
-
145
- // Skip if URL hasn't changed (prevents duplicate injections on same page)
146
- if (currentUrl === lastUrl) {
147
- return;
148
- }
149
- lastUrl = currentUrl;
150
-
151
- // Clear any pending reinjection
152
- if (reinjectionTimeout) {
153
- clearTimeout(reinjectionTimeout);
154
- }
155
-
156
- // Debounce reinjection (wait 100ms for navigation to settle)
157
- reinjectionTimeout = setTimeout(async () => {
158
- // Check if this page had recorder before
159
- if (pagesWithRecorder.has(page)) {
160
- try {
161
- // Project ID will be determined from URL in browser context
162
- await injectRecorder(page);
163
- } catch (error) {
164
- debugLog('Failed to re-inject recorder:', error.message);
165
- }
166
- }
167
- }, 100);
168
- });
169
-
170
- // Handle page reloads (F5, Ctrl+R) - use 'load' event
171
- page.on('load', async () => {
172
- // Check if this page had recorder before
173
- if (pagesWithRecorder.has(page)) {
174
- try {
175
- // Project ID will be determined from URL in browser context
176
- await injectRecorder(page);
177
- } catch (error) {
178
- debugLog('Failed to re-inject recorder after reload:', error.message);
179
- }
180
- }
181
- });
182
- }
129
+ // Note: setupRecorderAutoReinjection removed - Chrome Extension handles recording now
183
130
 
184
131
  /**
185
132
  * Get or create page for URL
@@ -238,10 +185,11 @@ export async function getOrCreatePage(url) {
238
185
  // Setup network monitoring with auto-reinitialization on navigation
239
186
  await setupNetworkMonitoring(page);
240
187
 
241
- // Setup recorder auto-reinjection on navigation
242
- setupRecorderAutoReinjection(page);
243
-
244
- await page.goto(url, { waitUntil: 'networkidle2' });
188
+ // Use longer timeout (60s) and less strict wait condition for slow sites like Yahoo
189
+ await page.goto(url, {
190
+ waitUntil: 'domcontentloaded', // Less strict than networkidle2, works for sites with continuous loading
191
+ timeout: 60000 // Increased from default 30s to 60s
192
+ });
245
193
  openPages.set(url, page);
246
194
  lastPage = page;
247
195
 
@@ -279,13 +227,6 @@ export async function getLastOpenPage() {
279
227
  throw new Error('No page is currently open. Use openBrowser first to open a page.');
280
228
  }
281
229
 
282
- // Setup recorder auto-reinjection if not already set up
283
- // Check if page already has navigation listener
284
- const listenerCount = lastPage.listenerCount('framenavigated');
285
- if (listenerCount === 0) {
286
- setupRecorderAutoReinjection(lastPage);
287
- }
288
-
289
230
  return lastPage;
290
231
  }
291
232
 
@@ -296,3 +237,219 @@ export async function getLastOpenPage() {
296
237
  export function setLastPage(page) {
297
238
  lastPage = page;
298
239
  }
240
+
241
+ /**
242
+ * Setup a new page with all monitoring (console, network, recorder)
243
+ * Used for both explicitly created pages and auto-detected new tabs
244
+ * @param {Page} page - Puppeteer page instance
245
+ * @param {string} [source='manual'] - How the page was created ('manual', 'popup', 'newTab')
246
+ * @returns {Promise<void>}
247
+ */
248
+ export async function setupNewPage(page, source = 'manual') {
249
+ // Set up console log capture
250
+ try {
251
+ const client = await page.target().createCDPSession();
252
+ await client.send('Runtime.enable');
253
+ await client.send('Log.enable');
254
+
255
+ client.on('Runtime.consoleAPICalled', (event) => {
256
+ const timestamp = new Date().toISOString();
257
+ const args = event.args.map(arg => {
258
+ if (arg.value !== undefined) return arg.value;
259
+ if (arg.description) return arg.description;
260
+ return String(arg);
261
+ });
262
+
263
+ consoleLogs.push({
264
+ type: event.type,
265
+ timestamp,
266
+ message: args.join(' '),
267
+ stackTrace: event.stackTrace
268
+ });
269
+ });
270
+
271
+ client.on('Log.entryAdded', (event) => {
272
+ const entry = event.entry;
273
+ consoleLogs.push({
274
+ type: entry.level,
275
+ timestamp: new Date(entry.timestamp).toISOString(),
276
+ message: entry.text,
277
+ source: entry.source,
278
+ url: entry.url,
279
+ lineNumber: entry.lineNumber
280
+ });
281
+ });
282
+ } catch (error) {
283
+ debugLog('Failed to setup console capture for new page:', error.message);
284
+ }
285
+
286
+ // Setup network monitoring
287
+ await setupNetworkMonitoring(page);
288
+
289
+ debugLog(`New page setup complete (source: ${source})`);
290
+ }
291
+
292
+ /**
293
+ * Handle a newly detected tab (from targetcreated event)
294
+ * @param {Page} page - New Puppeteer page
295
+ * @param {string} openerUrl - URL of the page that opened this tab
296
+ */
297
+ export async function handleNewTab(page, openerUrl) {
298
+ const timestamp = new Date().toISOString();
299
+
300
+ // Wait for the page to have a URL (initial about:blank -> actual URL)
301
+ let url = page.url();
302
+ let attempts = 0;
303
+ while ((url === 'about:blank' || url === '') && attempts < 20) {
304
+ await new Promise(resolve => setTimeout(resolve, 100));
305
+ url = page.url();
306
+ attempts++;
307
+ }
308
+
309
+ debugLog(`New tab detected: ${url} (opened from: ${openerUrl})`);
310
+
311
+ // Setup monitoring on the new page
312
+ await setupNewPage(page, 'newTab');
313
+
314
+ // Register the page
315
+ if (url && url !== 'about:blank') {
316
+ openPages.set(url, page);
317
+ }
318
+
319
+ // Make it the active page
320
+ lastPage = page;
321
+
322
+ // Add event to queue for AI notification
323
+ newTabEvents.push({
324
+ timestamp,
325
+ url,
326
+ openerUrl,
327
+ title: await page.title().catch(() => ''),
328
+ });
329
+
330
+ // Bring the new tab to front
331
+ try {
332
+ await page.bringToFront();
333
+ } catch (error) {
334
+ debugLog('Failed to bring new tab to front:', error.message);
335
+ }
336
+ }
337
+
338
+ /**
339
+ * Get and clear new tab events (for AI notification)
340
+ * @returns {Array} - Array of new tab events
341
+ */
342
+ export function getAndClearNewTabEvents() {
343
+ const events = [...newTabEvents];
344
+ newTabEvents.length = 0;
345
+ return events;
346
+ }
347
+
348
+ /**
349
+ * Get all open pages as array with metadata
350
+ * @returns {Promise<Array>} - Array of page info objects
351
+ */
352
+ export async function getAllPages() {
353
+ const pages = [];
354
+
355
+ for (const [url, page] of openPages.entries()) {
356
+ if (!page.isClosed()) {
357
+ try {
358
+ const title = await page.title().catch(() => '');
359
+ const currentUrl = page.url();
360
+ pages.push({
361
+ url: currentUrl,
362
+ originalUrl: url,
363
+ title,
364
+ isActive: page === lastPage,
365
+ });
366
+ } catch (error) {
367
+ // Page might be in invalid state, skip it
368
+ debugLog('Error getting page info:', error.message);
369
+ }
370
+ } else {
371
+ // Clean up closed pages
372
+ openPages.delete(url);
373
+ }
374
+ }
375
+
376
+ return pages;
377
+ }
378
+
379
+ /**
380
+ * Switch to a page by URL or index
381
+ * @param {string|number} identifier - URL (partial match) or index
382
+ * @returns {Promise<Page>} - The switched-to page
383
+ */
384
+ export async function switchToPage(identifier) {
385
+ const pages = await getAllPages();
386
+
387
+ if (pages.length === 0) {
388
+ throw new Error('No pages are currently open.');
389
+ }
390
+
391
+ let targetPage = null;
392
+
393
+ if (typeof identifier === 'number') {
394
+ // Switch by index
395
+ if (identifier < 0 || identifier >= pages.length) {
396
+ throw new Error(`Invalid tab index: ${identifier}. Valid range: 0-${pages.length - 1}`);
397
+ }
398
+ const targetUrl = pages[identifier].originalUrl;
399
+ targetPage = openPages.get(targetUrl);
400
+ } else {
401
+ // Switch by URL (partial match)
402
+ const matchingPage = pages.find(p =>
403
+ p.url.includes(identifier) || p.originalUrl.includes(identifier)
404
+ );
405
+ if (!matchingPage) {
406
+ throw new Error(`No page found matching: ${identifier}`);
407
+ }
408
+ targetPage = openPages.get(matchingPage.originalUrl);
409
+ }
410
+
411
+ if (!targetPage || targetPage.isClosed()) {
412
+ throw new Error('Target page is not available.');
413
+ }
414
+
415
+ // Make it the active page
416
+ lastPage = targetPage;
417
+
418
+ // Bring to front
419
+ await targetPage.bringToFront();
420
+
421
+ return targetPage;
422
+ }
423
+
424
+ /**
425
+ * Connect Puppeteer to a tab by URL (finds existing browser page)
426
+ * Used when switching tabs via extension - need to sync Puppeteer
427
+ * @param {string} url - URL of the tab to connect to
428
+ * @returns {Promise<Page|null>} - The page or null if not found
429
+ */
430
+ export async function connectToTabByUrl(url) {
431
+ const browser = await getBrowser();
432
+ const allPages = await browser.pages();
433
+
434
+ // Find page matching URL
435
+ for (const page of allPages) {
436
+ try {
437
+ const pageUrl = page.url();
438
+ if (pageUrl === url || pageUrl.includes(url) || url.includes(pageUrl)) {
439
+ // Setup monitoring if not already done
440
+ if (!openPages.has(url)) {
441
+ await setupNewPage(page, 'extension-switch');
442
+ openPages.set(url, page);
443
+ }
444
+ lastPage = page;
445
+ debugLog(`Connected Puppeteer to tab: ${url}`);
446
+ return page;
447
+ }
448
+ } catch (error) {
449
+ debugLog('Error checking page URL:', error.message);
450
+ }
451
+ }
452
+
453
+ debugLog(`No browser page found for URL: ${url}`);
454
+ return null;
455
+ }