chrometools-mcp 2.5.0 → 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 +420 -0
  2. package/COMPONENT_MAPPING_SPEC.md +1217 -0
  3. package/README.md +406 -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/extension/background.js +643 -0
  12. package/extension/content.js +715 -0
  13. package/extension/icons/create-icons.js +164 -0
  14. package/extension/icons/icon128.png +0 -0
  15. package/extension/icons/icon16.png +0 -0
  16. package/extension/icons/icon48.png +0 -0
  17. package/extension/manifest.json +58 -0
  18. package/extension/popup/popup.css +437 -0
  19. package/extension/popup/popup.html +102 -0
  20. package/extension/popup/popup.js +415 -0
  21. package/extension/recorder-overlay.css +93 -0
  22. package/index.js +3347 -2901
  23. package/models/BaseInputModel.js +93 -0
  24. package/models/CheckboxGroupModel.js +199 -0
  25. package/models/CheckboxModel.js +103 -0
  26. package/models/ColorInputModel.js +53 -0
  27. package/models/DateInputModel.js +67 -0
  28. package/models/RadioGroupModel.js +126 -0
  29. package/models/RangeInputModel.js +60 -0
  30. package/models/SelectModel.js +97 -0
  31. package/models/TextInputModel.js +34 -0
  32. package/models/TextareaModel.js +59 -0
  33. package/models/TimeInputModel.js +49 -0
  34. package/models/index.js +122 -0
  35. package/package.json +3 -2
  36. package/pom/apom-converter.js +267 -0
  37. package/pom/apom-tree-converter.js +515 -0
  38. package/pom/element-id-generator.js +175 -0
  39. package/recorder/page-object-generator.js +16 -0
  40. package/recorder/scenario-executor.js +80 -2
  41. package/server/tool-definitions.js +839 -713
  42. package/server/tool-groups.js +1 -1
  43. package/server/tool-schemas.js +367 -326
  44. package/server/websocket-bridge.js +447 -0
  45. package/utils/selector-resolver.js +186 -0
  46. package/utils/ui-framework-detector.js +392 -0
  47. package/RELEASE_NOTES_v2.5.0.md +0 -109
  48. package/npm_publish_output.txt +0 -0
@@ -0,0 +1,643 @@
1
+ /**
2
+ * ChromeTools MCP Extension - Background Service Worker
3
+ *
4
+ * NEW ARCHITECTURE: Uses Native Messaging to communicate with Bridge Service
5
+ *
6
+ * - Extension is the EVENT PRODUCER
7
+ * - Bridge Service is the PERSISTENT INTERMEDIARY
8
+ * - Claude/MCP clients connect to Bridge as CONSUMERS
9
+ *
10
+ * Extension lifecycle:
11
+ * 1. On load: connect to Native Host (Bridge Service)
12
+ * 2. Send all events (tabs, recordings) to Bridge
13
+ * 3. Bridge stores state and broadcasts to connected clients
14
+ * 4. Extension doesn't care how many clients are connected
15
+ */
16
+
17
+ const HOST_NAME = 'com.chrometools.bridge';
18
+
19
+ // State
20
+ let nativePort = null;
21
+ let isConnected = false;
22
+ const tabsState = new Map(); // tabId -> {url, title, active, windowId}
23
+
24
+ // Recorder state (persisted in storage)
25
+ let recorderState = {
26
+ isRecording: false,
27
+ isPaused: false,
28
+ actions: [],
29
+ secrets: {},
30
+ startUrl: null,
31
+ startTabId: null,
32
+ currentTabId: null,
33
+ metadata: {
34
+ name: '',
35
+ description: '',
36
+ tags: []
37
+ }
38
+ };
39
+
40
+ // ============================================
41
+ // Native Messaging Connection
42
+ // ============================================
43
+
44
+ /**
45
+ * Connect to Native Messaging Host (Bridge Service)
46
+ */
47
+ function connectToNativeHost() {
48
+ console.log(`[ChromeTools] Connecting to Native Host: ${HOST_NAME}`);
49
+
50
+ try {
51
+ nativePort = chrome.runtime.connectNative(HOST_NAME);
52
+
53
+ nativePort.onMessage.addListener((message) => {
54
+ console.log('[ChromeTools] Message from Bridge:', message.type);
55
+ handleBridgeMessage(message);
56
+ });
57
+
58
+ nativePort.onDisconnect.addListener(() => {
59
+ const error = chrome.runtime.lastError;
60
+ console.log('[ChromeTools] Disconnected from Bridge:', error?.message || 'no error');
61
+ isConnected = false;
62
+ nativePort = null;
63
+ updateIcon(false);
64
+
65
+ // Try to reconnect after a delay
66
+ setTimeout(connectToNativeHost, 5000);
67
+ });
68
+
69
+ isConnected = true;
70
+ updateIcon(true);
71
+ console.log('[ChromeTools] Connected to Native Host');
72
+
73
+ // Send initial tabs state
74
+ syncAllTabs();
75
+
76
+ } catch (error) {
77
+ console.error('[ChromeTools] Failed to connect to Native Host:', error);
78
+ isConnected = false;
79
+ updateIcon(false);
80
+
81
+ // Retry connection
82
+ setTimeout(connectToNativeHost, 5000);
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Send message to Bridge Service
88
+ */
89
+ function sendToBridge(message) {
90
+ if (nativePort && isConnected) {
91
+ try {
92
+ nativePort.postMessage(message);
93
+ return true;
94
+ } catch (error) {
95
+ console.error('[ChromeTools] Failed to send to Bridge:', error);
96
+ return false;
97
+ }
98
+ }
99
+ return false;
100
+ }
101
+
102
+ /**
103
+ * Handle messages from Bridge Service
104
+ */
105
+ function handleBridgeMessage(message) {
106
+ switch (message.type) {
107
+ case 'bridge_ready':
108
+ console.log('[ChromeTools] Bridge is ready');
109
+ syncAllTabs();
110
+ break;
111
+
112
+ case 'start_recording':
113
+ case 'recorder_start':
114
+ startRecording(message.payload).then(() => {
115
+ sendToBridge({
116
+ type: 'recorder_started',
117
+ payload: { success: true, startUrl: recorderState.startUrl },
118
+ requestId: message.requestId
119
+ });
120
+ });
121
+ break;
122
+
123
+ case 'stop_recording':
124
+ case 'recorder_stop':
125
+ stopRecording().then((result) => {
126
+ sendToBridge({
127
+ type: 'recorder_stopped',
128
+ payload: result,
129
+ requestId: message.requestId
130
+ });
131
+ });
132
+ break;
133
+
134
+ case 'pause_recording':
135
+ case 'recorder_pause':
136
+ pauseRecording().then(() => {
137
+ sendToBridge({
138
+ type: 'recorder_paused',
139
+ payload: { isPaused: recorderState.isPaused },
140
+ requestId: message.requestId
141
+ });
142
+ });
143
+ break;
144
+
145
+ case 'switch_tab':
146
+ if (message.payload?.tabId) {
147
+ chrome.tabs.update(message.payload.tabId, { active: true });
148
+ }
149
+ break;
150
+
151
+ case 'ping':
152
+ sendToBridge({ type: 'pong', requestId: message.requestId });
153
+ break;
154
+
155
+ default:
156
+ console.log('[ChromeTools] Unknown message from Bridge:', message.type);
157
+ }
158
+ }
159
+
160
+ // ============================================
161
+ // Tab Tracking
162
+ // ============================================
163
+
164
+ /**
165
+ * Sync all current tabs to Bridge
166
+ */
167
+ async function syncAllTabs() {
168
+ try {
169
+ const tabs = await chrome.tabs.query({});
170
+ tabsState.clear();
171
+
172
+ const tabsData = tabs.map(tab => ({
173
+ tabId: tab.id,
174
+ windowId: tab.windowId,
175
+ url: tab.url || '',
176
+ title: tab.title || '',
177
+ active: tab.active,
178
+ index: tab.index
179
+ }));
180
+
181
+ tabsData.forEach(tab => {
182
+ tabsState.set(tab.tabId, tab);
183
+ });
184
+
185
+ sendToBridge({
186
+ type: 'tabs_sync',
187
+ payload: { tabs: tabsData }
188
+ });
189
+
190
+ console.log(`[ChromeTools] Synced ${tabsData.length} tabs to Bridge`);
191
+ } catch (error) {
192
+ console.error('[ChromeTools] Failed to sync tabs:', error);
193
+ }
194
+ }
195
+
196
+ // Tab event listeners
197
+ chrome.tabs.onCreated.addListener((tab) => {
198
+ const tabData = {
199
+ tabId: tab.id,
200
+ windowId: tab.windowId,
201
+ url: tab.url || '',
202
+ title: tab.title || '',
203
+ active: tab.active,
204
+ index: tab.index
205
+ };
206
+ tabsState.set(tab.id, tabData);
207
+
208
+ sendToBridge({
209
+ type: 'tab_created',
210
+ payload: tabData
211
+ });
212
+ });
213
+
214
+ chrome.tabs.onRemoved.addListener((tabId) => {
215
+ tabsState.delete(tabId);
216
+
217
+ sendToBridge({
218
+ type: 'tab_closed',
219
+ payload: { tabId }
220
+ });
221
+ });
222
+
223
+ chrome.tabs.onActivated.addListener(async (activeInfo) => {
224
+ // Update active status in local state
225
+ for (const [id, tab] of tabsState) {
226
+ tab.active = (id === activeInfo.tabId);
227
+ }
228
+
229
+ sendToBridge({
230
+ type: 'tab_activated',
231
+ payload: {
232
+ tabId: activeInfo.tabId,
233
+ windowId: activeInfo.windowId
234
+ }
235
+ });
236
+
237
+ // Update recording tab if recording
238
+ if (recorderState.isRecording && !recorderState.isPaused) {
239
+ const previousTabId = recorderState.currentTabId;
240
+
241
+ // Only record tab switch if switching to a different tab
242
+ if (previousTabId !== activeInfo.tabId) {
243
+ // Get tab info for the new tab
244
+ try {
245
+ const tab = await chrome.tabs.get(activeInfo.tabId);
246
+
247
+ // Record openTab action for tab switch
248
+ recordAction({
249
+ type: 'openTab',
250
+ data: {
251
+ url: tab.url,
252
+ title: tab.title,
253
+ switchToTab: true,
254
+ reason: 'tab_switch' // Indicates this was a manual tab switch
255
+ },
256
+ selector: null,
257
+ tabId: activeInfo.tabId,
258
+ tabUrl: tab.url
259
+ });
260
+
261
+ console.log(`[ChromeTools] Recorded tab switch from ${previousTabId} to ${activeInfo.tabId}`);
262
+ } catch (error) {
263
+ console.error('[ChromeTools] Failed to get tab info for recording:', error);
264
+ }
265
+
266
+ recorderState.currentTabId = activeInfo.tabId;
267
+ saveRecorderState();
268
+ }
269
+
270
+ // Inject content script if needed and notify about active recording
271
+ await injectContentScriptAndNotify(activeInfo.tabId, 'RECORDING_STARTED', {
272
+ actionCount: recorderState.actions.length
273
+ });
274
+ }
275
+ });
276
+
277
+ chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
278
+ if (changeInfo.url || changeInfo.title || changeInfo.status === 'complete') {
279
+ const tabData = {
280
+ tabId: tab.id,
281
+ windowId: tab.windowId,
282
+ url: tab.url || '',
283
+ title: tab.title || '',
284
+ active: tab.active,
285
+ index: tab.index
286
+ };
287
+ tabsState.set(tabId, tabData);
288
+
289
+ sendToBridge({
290
+ type: 'tab_updated',
291
+ payload: { tabId, tab: tabData, changeInfo }
292
+ });
293
+ }
294
+ });
295
+
296
+ // ============================================
297
+ // Icon Management
298
+ // ============================================
299
+
300
+ function updateIcon(connected) {
301
+ const iconSuffix = connected ? '' : '-gray';
302
+ const iconPath = {
303
+ 16: `icons/icon16${iconSuffix}.png`,
304
+ 48: `icons/icon48${iconSuffix}.png`,
305
+ 128: `icons/icon128${iconSuffix}.png`
306
+ };
307
+
308
+ // Check if gray icons exist, otherwise use default
309
+ chrome.action.setIcon({ path: iconPath }).catch(() => {
310
+ // Fallback to default icons if gray versions don't exist
311
+ chrome.action.setIcon({
312
+ path: {
313
+ 16: 'icons/icon16.png',
314
+ 48: 'icons/icon48.png',
315
+ 128: 'icons/icon128.png'
316
+ }
317
+ });
318
+ });
319
+
320
+ chrome.action.setTitle({
321
+ title: connected ? 'ChromeTools MCP (Connected)' : 'ChromeTools MCP (Disconnected)'
322
+ });
323
+
324
+ console.log(`[ChromeTools] Icon status: ${connected ? 'connected' : 'disconnected'}`);
325
+ }
326
+
327
+ // ============================================
328
+ // Content Script Injection
329
+ // ============================================
330
+
331
+ /**
332
+ * Inject content script into tab if not already present, then send message
333
+ */
334
+ async function injectContentScriptAndNotify(tabId, messageType, extraData = {}) {
335
+ try {
336
+ // First try to send message - if content script is already there, it will respond
337
+ await chrome.tabs.sendMessage(tabId, { type: messageType, ...extraData });
338
+ console.log(`[ChromeTools] Content script responded to ${messageType}`);
339
+ return true;
340
+ } catch (error) {
341
+ // Content script not present, inject it
342
+ console.log('[ChromeTools] Content script not present, injecting...');
343
+
344
+ try {
345
+ // Inject CSS first
346
+ await chrome.scripting.insertCSS({
347
+ target: { tabId },
348
+ files: ['recorder-overlay.css']
349
+ });
350
+
351
+ // Then inject JS
352
+ await chrome.scripting.executeScript({
353
+ target: { tabId },
354
+ files: ['content.js']
355
+ });
356
+
357
+ console.log('[ChromeTools] Content script injected');
358
+
359
+ // Wait a bit for script to initialize
360
+ await new Promise(resolve => setTimeout(resolve, 100));
361
+
362
+ // Now send the message
363
+ await chrome.tabs.sendMessage(tabId, { type: messageType, ...extraData });
364
+ console.log(`[ChromeTools] Sent ${messageType} after injection`);
365
+ return true;
366
+ } catch (injectError) {
367
+ console.error('[ChromeTools] Failed to inject content script:', injectError.message);
368
+ return false;
369
+ }
370
+ }
371
+ }
372
+
373
+ // ============================================
374
+ // Recorder Functions
375
+ // ============================================
376
+
377
+ async function loadRecorderState() {
378
+ try {
379
+ const stored = await chrome.storage.local.get('recorderState');
380
+ if (stored.recorderState) {
381
+ recorderState = { ...recorderState, ...stored.recorderState };
382
+ }
383
+ } catch (error) {
384
+ console.error('[ChromeTools] Failed to load recorder state:', error);
385
+ }
386
+ }
387
+
388
+ async function saveRecorderState() {
389
+ try {
390
+ await chrome.storage.local.set({ recorderState });
391
+ } catch (error) {
392
+ console.error('[ChromeTools] Failed to save recorder state:', error);
393
+ }
394
+ }
395
+
396
+ async function startRecording(options = {}) {
397
+ const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true });
398
+
399
+ recorderState.isRecording = true;
400
+ recorderState.isPaused = false;
401
+ recorderState.actions = [];
402
+ recorderState.secrets = {};
403
+ recorderState.startUrl = activeTab?.url || '';
404
+ recorderState.startTabId = activeTab?.id;
405
+ recorderState.currentTabId = activeTab?.id;
406
+ recorderState.metadata = {
407
+ name: options.name || '',
408
+ description: options.description || '',
409
+ tags: options.tags || []
410
+ };
411
+
412
+ await saveRecorderState();
413
+
414
+ // Inject content script if needed and notify about recording start
415
+ if (activeTab?.id) {
416
+ await injectContentScriptAndNotify(activeTab.id, 'RECORDING_STARTED');
417
+ }
418
+
419
+ // Notify Bridge about state change
420
+ sendToBridge({
421
+ type: 'recorder_state_changed',
422
+ payload: {
423
+ isRecording: true,
424
+ isPaused: false,
425
+ startUrl: recorderState.startUrl,
426
+ metadata: recorderState.metadata
427
+ }
428
+ });
429
+
430
+ console.log('[ChromeTools] Recording started');
431
+ }
432
+
433
+ async function stopRecording() {
434
+ const result = {
435
+ actions: recorderState.actions,
436
+ secrets: recorderState.secrets,
437
+ metadata: {
438
+ ...recorderState.metadata,
439
+ entryUrl: recorderState.startUrl,
440
+ recordedAt: new Date().toISOString()
441
+ }
442
+ };
443
+
444
+ // Notify content script in current recording tab to stop
445
+ if (recorderState.currentTabId) {
446
+ try {
447
+ await chrome.tabs.sendMessage(recorderState.currentTabId, { type: 'RECORDING_STOPPED' });
448
+ console.log('[ChromeTools] Notified content script about recording stop');
449
+ } catch (error) {
450
+ console.log('[ChromeTools] Content script not available:', error.message);
451
+ }
452
+ }
453
+
454
+ recorderState.isRecording = false;
455
+ recorderState.isPaused = false;
456
+
457
+ await saveRecorderState();
458
+
459
+ // Notify Bridge
460
+ sendToBridge({
461
+ type: 'recorder_state_changed',
462
+ payload: {
463
+ isRecording: false,
464
+ isPaused: false
465
+ }
466
+ });
467
+
468
+ // Also send recordings to Bridge for storage
469
+ sendToBridge({
470
+ type: 'recordings_cleared'
471
+ });
472
+
473
+ console.log('[ChromeTools] Recording stopped');
474
+ return result;
475
+ }
476
+
477
+ async function pauseRecording() {
478
+ recorderState.isPaused = !recorderState.isPaused;
479
+ await saveRecorderState();
480
+
481
+ // Notify content script about pause/resume
482
+ if (recorderState.currentTabId) {
483
+ try {
484
+ const messageType = recorderState.isPaused ? 'RECORDING_PAUSED' : 'RECORDING_RESUMED';
485
+ await chrome.tabs.sendMessage(recorderState.currentTabId, { type: messageType });
486
+ console.log(`[ChromeTools] Notified content script: ${messageType}`);
487
+ } catch (error) {
488
+ console.log('[ChromeTools] Content script not available for pause notification:', error.message);
489
+ }
490
+ }
491
+
492
+ sendToBridge({
493
+ type: 'recorder_state_changed',
494
+ payload: { isPaused: recorderState.isPaused }
495
+ });
496
+
497
+ console.log(`[ChromeTools] Recording ${recorderState.isPaused ? 'paused' : 'resumed'}`);
498
+ }
499
+
500
+ function recordAction(action) {
501
+ if (!recorderState.isRecording || recorderState.isPaused) return;
502
+
503
+ const actionWithMeta = {
504
+ ...action,
505
+ timestamp: Date.now(),
506
+ index: recorderState.actions.length
507
+ };
508
+
509
+ recorderState.actions.push(actionWithMeta);
510
+ saveRecorderState();
511
+
512
+ // Send to Bridge
513
+ sendToBridge({
514
+ type: 'action_recorded',
515
+ payload: actionWithMeta
516
+ });
517
+ }
518
+
519
+ // ============================================
520
+ // Message Handling from Content Scripts & Popup
521
+ // ============================================
522
+
523
+ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
524
+ console.log('[ChromeTools] Message from', sender.tab ? `tab ${sender.tab.id}` : 'popup', ':', message.type);
525
+
526
+ switch (message.type) {
527
+ // From content script
528
+ case 'ACTION':
529
+ if (recorderState.isRecording && sender.tab?.id === recorderState.currentTabId) {
530
+ recordAction({
531
+ ...message.action,
532
+ tabId: sender.tab?.id,
533
+ tabUrl: sender.tab?.url
534
+ });
535
+ sendResponse({ success: true });
536
+ } else {
537
+ sendResponse({ success: false, reason: 'Not recording on this tab' });
538
+ }
539
+ break;
540
+
541
+ case 'GET_RECORDING_STATE':
542
+ sendResponse({
543
+ isRecording: recorderState.isRecording,
544
+ isPaused: recorderState.isPaused,
545
+ actionCount: recorderState.actions.length
546
+ });
547
+ break;
548
+
549
+ case 'REGISTER_SECRET':
550
+ recorderState.secrets[message.paramName] = message.value;
551
+ saveRecorderState();
552
+ sendResponse({ success: true });
553
+ break;
554
+
555
+ // From popup
556
+ case 'START_RECORDING':
557
+ startRecording(message.options).then(() => {
558
+ sendResponse({ success: true });
559
+ });
560
+ return true;
561
+
562
+ case 'STOP_RECORDING':
563
+ stopRecording().then((result) => {
564
+ sendResponse({ success: true, ...result });
565
+ });
566
+ return true;
567
+
568
+ case 'PAUSE_RECORDING':
569
+ pauseRecording().then(() => {
570
+ sendResponse({ success: true, isPaused: recorderState.isPaused });
571
+ });
572
+ return true;
573
+
574
+ case 'CLEAR_ACTIONS':
575
+ recorderState.actions = [];
576
+ recorderState.secrets = {};
577
+ saveRecorderState();
578
+ sendToBridge({ type: 'recordings_cleared' });
579
+ sendResponse({ success: true });
580
+ break;
581
+
582
+ case 'FORCE_RESET':
583
+ recorderState.isRecording = false;
584
+ recorderState.isPaused = false;
585
+ recorderState.actions = [];
586
+ recorderState.secrets = {};
587
+ recorderState.metadata = null;
588
+ recorderState.entryUrl = null;
589
+ saveRecorderState();
590
+ sendToBridge({
591
+ type: 'recorder_state_changed',
592
+ payload: { isRecording: false, isPaused: false }
593
+ });
594
+ sendResponse({ success: true, message: 'Recording state reset' });
595
+ break;
596
+
597
+ case 'GET_STATE':
598
+ sendResponse({
599
+ isRecording: recorderState.isRecording,
600
+ isPaused: recorderState.isPaused,
601
+ actions: recorderState.actions,
602
+ metadata: recorderState.metadata,
603
+ isConnected: isConnected,
604
+ connectedInstances: isConnected ? 1 : 0,
605
+ scenarioName: recorderState.metadata?.name || '',
606
+ scenarioDescription: recorderState.metadata?.description || '',
607
+ scenarioTags: recorderState.metadata?.tags || []
608
+ });
609
+ break;
610
+
611
+ case 'SAVE_SCENARIO':
612
+ // Forward to Bridge for saving
613
+ sendToBridge({
614
+ type: 'scenario_save',
615
+ payload: message.scenario,
616
+ requestId: `save_${Date.now()}`
617
+ });
618
+ sendResponse({ success: true });
619
+ break;
620
+
621
+ default:
622
+ console.log('[ChromeTools] Unknown message:', message.type);
623
+ }
624
+ });
625
+
626
+ // ============================================
627
+ // Initialization
628
+ // ============================================
629
+
630
+ async function init() {
631
+ console.log('[ChromeTools] Initializing extension...');
632
+
633
+ // Load saved state
634
+ await loadRecorderState();
635
+
636
+ // Connect to Native Host
637
+ connectToNativeHost();
638
+
639
+ console.log('[ChromeTools] Extension initialized');
640
+ }
641
+
642
+ // Start
643
+ init();