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,472 @@
1
+ /**
2
+ * bridge-client.js
3
+ *
4
+ * WebSocket client for MCP to connect to Bridge Service.
5
+ * Replaces the old websocket-bridge.js (which was a server).
6
+ *
7
+ * MCP is now a CLIENT that:
8
+ * - Connects to Bridge on port 9223
9
+ * - Receives full state on connect
10
+ * - Gets real-time updates (tabs, recordings)
11
+ * - Sends commands (start/stop recording, etc.)
12
+ */
13
+
14
+ import WebSocket from 'ws';
15
+
16
+ const BRIDGE_PORT = 9223;
17
+ const RECONNECT_DELAY = 2000;
18
+ const MAX_RECONNECT_ATTEMPTS = 5;
19
+
20
+ // State received from Bridge
21
+ let bridgeState = {
22
+ tabs: new Map(),
23
+ recordings: [],
24
+ recorderState: {
25
+ isRecording: false,
26
+ isPaused: false,
27
+ metadata: null,
28
+ secrets: {}
29
+ },
30
+ extensionConnected: false
31
+ };
32
+
33
+ // Connection state
34
+ let ws = null;
35
+ let isConnected = false;
36
+ let reconnectAttempts = 0;
37
+ let reconnectTimer = null;
38
+
39
+ // Callbacks
40
+ const messageCallbacks = new Map(); // requestId -> {resolve, reject, timeout}
41
+ const eventCallbacks = []; // Array of (eventType, payload) => void
42
+
43
+ // Handler for syncing active tab (set by page-manager)
44
+ let activeTabSyncHandler = null;
45
+
46
+ /**
47
+ * Debug log helper
48
+ */
49
+ function debugLog(...args) {
50
+ if (process.env.DEBUG === '1') {
51
+ console.error('[bridge-client]', ...args);
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Set handler for syncing active tab to Puppeteer's lastPage
57
+ */
58
+ export function setActiveTabSyncHandler(handler) {
59
+ activeTabSyncHandler = handler;
60
+ }
61
+
62
+ /**
63
+ * Connect to Bridge Service
64
+ */
65
+ export async function connectToBridge() {
66
+ return new Promise((resolve) => {
67
+ if (isConnected && ws?.readyState === WebSocket.OPEN) {
68
+ debugLog('Already connected to Bridge');
69
+ resolve(true);
70
+ return;
71
+ }
72
+
73
+ debugLog(`Connecting to Bridge on port ${BRIDGE_PORT}...`);
74
+
75
+ try {
76
+ ws = new WebSocket(`ws://127.0.0.1:${BRIDGE_PORT}`);
77
+
78
+ const connectTimeout = setTimeout(() => {
79
+ debugLog('Connection timeout');
80
+ ws?.close();
81
+ resolve(false);
82
+ }, 5000);
83
+
84
+ ws.on('open', () => {
85
+ clearTimeout(connectTimeout);
86
+ isConnected = true;
87
+ reconnectAttempts = 0;
88
+ debugLog('Connected to Bridge');
89
+ console.error('[chrometools-mcp] Connected to Bridge Service');
90
+ resolve(true);
91
+ });
92
+
93
+ ws.on('message', (data) => {
94
+ try {
95
+ const message = JSON.parse(data.toString());
96
+ handleBridgeMessage(message);
97
+ } catch (error) {
98
+ debugLog('Failed to parse message:', error.message);
99
+ }
100
+ });
101
+
102
+ ws.on('close', () => {
103
+ debugLog('Disconnected from Bridge');
104
+ isConnected = false;
105
+ ws = null;
106
+ scheduleReconnect();
107
+ });
108
+
109
+ ws.on('error', (error) => {
110
+ clearTimeout(connectTimeout);
111
+ debugLog('Connection error:', error.message);
112
+ resolve(false);
113
+ });
114
+
115
+ } catch (error) {
116
+ debugLog('Failed to create WebSocket:', error.message);
117
+ resolve(false);
118
+ }
119
+ });
120
+ }
121
+
122
+ /**
123
+ * Schedule reconnection attempt
124
+ */
125
+ function scheduleReconnect() {
126
+ if (reconnectTimer) return;
127
+ if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
128
+ debugLog(`Max reconnect attempts (${MAX_RECONNECT_ATTEMPTS}) reached`);
129
+ return;
130
+ }
131
+
132
+ reconnectAttempts++;
133
+ debugLog(`Scheduling reconnect attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}`);
134
+
135
+ reconnectTimer = setTimeout(async () => {
136
+ reconnectTimer = null;
137
+ await connectToBridge();
138
+ }, RECONNECT_DELAY);
139
+ }
140
+
141
+ /**
142
+ * Disconnect from Bridge
143
+ */
144
+ export function disconnectFromBridge() {
145
+ if (reconnectTimer) {
146
+ clearTimeout(reconnectTimer);
147
+ reconnectTimer = null;
148
+ }
149
+ if (ws) {
150
+ ws.close();
151
+ ws = null;
152
+ }
153
+ isConnected = false;
154
+ }
155
+
156
+ /**
157
+ * Handle messages from Bridge
158
+ */
159
+ function handleBridgeMessage(message) {
160
+ debugLog('Received:', message.type);
161
+
162
+ // Check if this is a response to a pending request
163
+ if (message.requestId && messageCallbacks.has(message.requestId)) {
164
+ const { resolve, timeout } = messageCallbacks.get(message.requestId);
165
+ clearTimeout(timeout);
166
+ messageCallbacks.delete(message.requestId);
167
+ resolve(message);
168
+ return;
169
+ }
170
+
171
+ // Handle different message types
172
+ switch (message.type) {
173
+ case 'initial_state':
174
+ // Full state received on connect
175
+ updateFullState(message.payload);
176
+ break;
177
+
178
+ case 'tabs_sync':
179
+ bridgeState.tabs.clear();
180
+ message.payload.tabs?.forEach(tab => {
181
+ bridgeState.tabs.set(tab.tabId, tab);
182
+ });
183
+ notifyEventCallbacks('tabs_sync', message.payload);
184
+ syncActiveTab(message.payload.tabs);
185
+ break;
186
+
187
+ case 'tab_created':
188
+ bridgeState.tabs.set(message.payload.tabId, message.payload);
189
+ notifyEventCallbacks('tab_created', message.payload);
190
+ break;
191
+
192
+ case 'tab_closed':
193
+ bridgeState.tabs.delete(message.payload.tabId);
194
+ notifyEventCallbacks('tab_closed', message.payload);
195
+ break;
196
+
197
+ case 'tab_activated':
198
+ for (const [id, tab] of bridgeState.tabs) {
199
+ tab.active = (id === message.payload.tabId);
200
+ }
201
+ notifyEventCallbacks('tab_activated', message.payload);
202
+ syncActiveTabById(message.payload.tabId);
203
+ break;
204
+
205
+ case 'tab_updated':
206
+ if (message.payload.tab) {
207
+ bridgeState.tabs.set(message.payload.tabId, message.payload.tab);
208
+ }
209
+ notifyEventCallbacks('tab_updated', message.payload);
210
+ break;
211
+
212
+ case 'recorder_state_changed':
213
+ bridgeState.recorderState = { ...bridgeState.recorderState, ...message.payload };
214
+ notifyEventCallbacks('recorder_state_changed', message.payload);
215
+ break;
216
+
217
+ case 'action_recorded':
218
+ bridgeState.recordings.push(message.payload);
219
+ notifyEventCallbacks('action_recorded', message.payload);
220
+ break;
221
+
222
+ case 'recordings_cleared':
223
+ bridgeState.recordings = [];
224
+ notifyEventCallbacks('recordings_cleared', null);
225
+ break;
226
+
227
+ case 'extension_disconnected':
228
+ bridgeState.extensionConnected = false;
229
+ notifyEventCallbacks('extension_disconnected', null);
230
+ break;
231
+
232
+ case 'pong':
233
+ // Handled by requestId callback
234
+ break;
235
+
236
+ default:
237
+ debugLog('Unknown message type:', message.type);
238
+ notifyEventCallbacks(message.type, message.payload);
239
+ }
240
+ }
241
+
242
+ /**
243
+ * Update full state from initial_state message
244
+ */
245
+ function updateFullState(state) {
246
+ bridgeState.tabs.clear();
247
+ state.tabs?.forEach(tab => {
248
+ bridgeState.tabs.set(tab.tabId, tab);
249
+ });
250
+ bridgeState.recordings = state.recordings || [];
251
+ bridgeState.recorderState = state.recorderState || bridgeState.recorderState;
252
+ bridgeState.extensionConnected = state.extensionConnected;
253
+
254
+ debugLog(`State updated: ${bridgeState.tabs.size} tabs, ${bridgeState.recordings.length} recordings`);
255
+ notifyEventCallbacks('state_updated', state);
256
+
257
+ // Sync active tab
258
+ syncActiveTab(state.tabs);
259
+ }
260
+
261
+ /**
262
+ * Sync Puppeteer's lastPage to active tab
263
+ */
264
+ function syncActiveTab(tabs) {
265
+ if (!activeTabSyncHandler || !tabs) return;
266
+
267
+ const activeTab = tabs.find(t => t.active);
268
+ if (activeTab?.url) {
269
+ activeTabSyncHandler(activeTab.url).catch(err => {
270
+ debugLog(`Failed to sync lastPage: ${err.message}`);
271
+ });
272
+ }
273
+ }
274
+
275
+ function syncActiveTabById(tabId) {
276
+ if (!activeTabSyncHandler) return;
277
+
278
+ const tab = bridgeState.tabs.get(tabId);
279
+ if (tab?.url) {
280
+ activeTabSyncHandler(tab.url).catch(err => {
281
+ debugLog(`Failed to sync lastPage: ${err.message}`);
282
+ });
283
+ }
284
+ }
285
+
286
+ /**
287
+ * Notify registered event callbacks
288
+ */
289
+ function notifyEventCallbacks(eventType, payload) {
290
+ eventCallbacks.forEach(callback => {
291
+ try {
292
+ callback(eventType, payload);
293
+ } catch (error) {
294
+ debugLog('Event callback error:', error.message);
295
+ }
296
+ });
297
+ }
298
+
299
+ /**
300
+ * Send message to Bridge
301
+ */
302
+ export function sendToBridge(message) {
303
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
304
+ debugLog('Cannot send: not connected');
305
+ return false;
306
+ }
307
+ ws.send(JSON.stringify(message));
308
+ return true;
309
+ }
310
+
311
+ /**
312
+ * Send command and wait for response
313
+ */
314
+ export function sendCommand(type, payload = {}, timeout = 5000) {
315
+ return new Promise((resolve, reject) => {
316
+ if (!isConnected) {
317
+ reject(new Error('Not connected to Bridge'));
318
+ return;
319
+ }
320
+
321
+ const requestId = `req_${Date.now()}_${Math.random()}`;
322
+
323
+ const timeoutHandle = setTimeout(() => {
324
+ messageCallbacks.delete(requestId);
325
+ reject(new Error(`Command timeout: ${type}`));
326
+ }, timeout);
327
+
328
+ messageCallbacks.set(requestId, { resolve, reject, timeout: timeoutHandle });
329
+
330
+ sendToBridge({ type, payload, requestId });
331
+ });
332
+ }
333
+
334
+ // ============================================
335
+ // Public API (compatible with old websocket-bridge.js)
336
+ // ============================================
337
+
338
+ /**
339
+ * Start connection to Bridge (replaces startWebSocketServer)
340
+ */
341
+ export async function startWebSocketServer() {
342
+ await connectToBridge();
343
+ }
344
+
345
+ /**
346
+ * Stop connection (replaces stopWebSocketServer)
347
+ */
348
+ export function stopWebSocketServer() {
349
+ disconnectFromBridge();
350
+ }
351
+
352
+ /**
353
+ * Check if connected to Bridge (replaces isExtensionConnected)
354
+ */
355
+ export function isExtensionConnected() {
356
+ return isConnected && bridgeState.extensionConnected;
357
+ }
358
+
359
+ /**
360
+ * Check if connected to Bridge
361
+ */
362
+ export function isBridgeConnected() {
363
+ return isConnected;
364
+ }
365
+
366
+ /**
367
+ * Get tabs from Bridge state
368
+ */
369
+ export function getTabsFromExtension() {
370
+ return Array.from(bridgeState.tabs.values());
371
+ }
372
+
373
+ /**
374
+ * Get active tab
375
+ */
376
+ export function getActiveTabFromExtension() {
377
+ for (const tab of bridgeState.tabs.values()) {
378
+ if (tab.active) return tab;
379
+ }
380
+ return null;
381
+ }
382
+
383
+ /**
384
+ * Get debug info
385
+ */
386
+ export function getWsDebugInfo() {
387
+ return {
388
+ bridgeConnected: isConnected,
389
+ extensionConnected: bridgeState.extensionConnected,
390
+ readyState: ws?.readyState,
391
+ tabCount: bridgeState.tabs.size,
392
+ recordingsCount: bridgeState.recordings.length,
393
+ recorderState: bridgeState.recorderState
394
+ };
395
+ }
396
+
397
+ /**
398
+ * Export extensionTabs for compatibility
399
+ */
400
+ export const extensionTabs = bridgeState.tabs;
401
+
402
+ /**
403
+ * Request tabs sync
404
+ */
405
+ export function requestTabsSync() {
406
+ return sendToBridge({ type: 'get_tabs' });
407
+ }
408
+
409
+ /**
410
+ * Switch tab via Bridge
411
+ */
412
+ export function switchTabViaExtension(tabIdentifier) {
413
+ const tabs = Array.from(bridgeState.tabs.values());
414
+ let targetTab = null;
415
+
416
+ if (typeof tabIdentifier === 'number') {
417
+ targetTab = tabs[tabIdentifier];
418
+ } else if (typeof tabIdentifier === 'string') {
419
+ targetTab = tabs.find(t => t.url.toLowerCase().includes(tabIdentifier.toLowerCase()));
420
+ }
421
+
422
+ if (targetTab) {
423
+ sendToBridge({
424
+ type: 'switch_tab',
425
+ payload: { tabId: targetTab.tabId }
426
+ });
427
+ return targetTab;
428
+ }
429
+ return null;
430
+ }
431
+
432
+ /**
433
+ * Register callback for tab events
434
+ */
435
+ export function onTabEvent(callback) {
436
+ eventCallbacks.push(callback);
437
+ return () => {
438
+ const index = eventCallbacks.indexOf(callback);
439
+ if (index > -1) eventCallbacks.splice(index, 1);
440
+ };
441
+ }
442
+
443
+ /**
444
+ * Send recorder command
445
+ */
446
+ export function sendRecorderCommand(command, options = {}) {
447
+ return sendToBridge({
448
+ type: `recorder_${command}`,
449
+ payload: options
450
+ });
451
+ }
452
+
453
+ /**
454
+ * Send command and wait for response
455
+ */
456
+ export function sendExtensionCommand(message, timeout = 5000) {
457
+ return sendCommand(message.type, message.payload, timeout);
458
+ }
459
+
460
+ /**
461
+ * Get recorder state
462
+ */
463
+ export function getRecorderState() {
464
+ return bridgeState.recorderState;
465
+ }
466
+
467
+ /**
468
+ * Get recordings
469
+ */
470
+ export function getRecordings() {
471
+ return bridgeState.recordings;
472
+ }