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
@@ -0,0 +1,447 @@
1
+ /**
2
+ * server/websocket-bridge.js
3
+ *
4
+ * WebSocket server for communication between Chrome Extension and MCP server
5
+ */
6
+
7
+ import { WebSocketServer } from 'ws';
8
+ import { saveScenario, listScenarios } from '../recorder/scenario-storage.js';
9
+ import { urlToProjectId } from '../utils/url-to-project.js';
10
+ import net from 'net';
11
+
12
+ const WS_PORT_START = 9223;
13
+ const WS_PORT_END = 9227;
14
+
15
+ // State
16
+ let wss = null;
17
+ let extensionConnection = null;
18
+ let isRunning = false;
19
+
20
+ // Tabs state from extension (source of truth)
21
+ export const extensionTabs = new Map();
22
+
23
+ // Callbacks for tab events
24
+ const tabEventCallbacks = [];
25
+
26
+ // Handler for syncing active tab (set by page-manager to avoid circular imports)
27
+ let activeTabSyncHandler = null;
28
+
29
+ /**
30
+ * Set handler for syncing active tab to Puppeteer's lastPage
31
+ * Called by page-manager during initialization
32
+ */
33
+ export function setActiveTabSyncHandler(handler) {
34
+ activeTabSyncHandler = handler;
35
+ }
36
+
37
+ /**
38
+ * Debug log helper
39
+ */
40
+ function debugLog(...args) {
41
+ if (process.env.DEBUG === '1') {
42
+ console.error('[ws-bridge]', ...args);
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Check if port is available
48
+ */
49
+ async function isPortAvailable(port) {
50
+ return new Promise((resolve) => {
51
+ const server = net.createServer();
52
+ server.once('error', () => resolve(false));
53
+ server.once('listening', () => {
54
+ server.close();
55
+ resolve(true);
56
+ });
57
+ server.listen(port, '127.0.0.1');
58
+ });
59
+ }
60
+
61
+ /**
62
+ * Find available port in range
63
+ */
64
+ async function findAvailablePort() {
65
+ for (let port = WS_PORT_START; port <= WS_PORT_END; port++) {
66
+ if (await isPortAvailable(port)) {
67
+ return port;
68
+ }
69
+ }
70
+ throw new Error(`No available ports in range ${WS_PORT_START}-${WS_PORT_END}`);
71
+ }
72
+
73
+
74
+ /**
75
+ * Start WebSocket server
76
+ */
77
+ export async function startWebSocketServer() {
78
+ if (isRunning) {
79
+ debugLog('WebSocket server already running');
80
+ return;
81
+ }
82
+
83
+ try {
84
+ // Find available port
85
+ const port = await findAvailablePort();
86
+
87
+ wss = new WebSocketServer({ port });
88
+
89
+ wss.on('connection', (ws) => {
90
+ debugLog('Extension connected');
91
+ extensionConnection = ws;
92
+
93
+ ws.on('message', (data) => {
94
+ try {
95
+ const message = JSON.parse(data.toString());
96
+ handleExtensionMessage(ws, message);
97
+ } catch (error) {
98
+ debugLog('Failed to parse message:', error.message);
99
+ }
100
+ });
101
+
102
+ ws.on('close', () => {
103
+ debugLog('Extension disconnected');
104
+ if (extensionConnection === ws) {
105
+ extensionConnection = null;
106
+ }
107
+ });
108
+
109
+ ws.on('error', (error) => {
110
+ debugLog('WebSocket error:', error.message);
111
+ });
112
+ });
113
+
114
+ wss.on('error', (error) => {
115
+ if (error.code === 'EADDRINUSE') {
116
+ debugLog(`Port ${port} is already in use`);
117
+ } else {
118
+ debugLog('WebSocket server error:', error.message);
119
+ }
120
+ });
121
+
122
+ isRunning = true;
123
+ console.error(`[chrometools-mcp] WebSocket server listening on port ${port}`);
124
+ debugLog(`WebSocket server listening on port ${port}`);
125
+
126
+ } catch (error) {
127
+ console.error('[chrometools-mcp] Failed to start WebSocket server:', error.message);
128
+ debugLog('Failed to start WebSocket server:', error.message);
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Stop WebSocket server
134
+ */
135
+ export function stopWebSocketServer() {
136
+ if (wss) {
137
+ wss.close();
138
+ wss = null;
139
+ isRunning = false;
140
+ debugLog('WebSocket server stopped');
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Handle messages from extension
146
+ */
147
+ async function handleExtensionMessage(ws, message) {
148
+ debugLog('Received:', message.type);
149
+
150
+ switch (message.type) {
151
+ case 'tabs_sync':
152
+ // Full tabs sync from extension
153
+ extensionTabs.clear();
154
+ if (message.payload?.tabs) {
155
+ message.payload.tabs.forEach(tab => {
156
+ extensionTabs.set(tab.tabId, tab);
157
+ });
158
+ }
159
+ debugLog(`Synced ${extensionTabs.size} tabs`);
160
+ notifyTabCallbacks('sync', null);
161
+
162
+ // Sync Puppeteer's lastPage to the currently active tab
163
+ if (activeTabSyncHandler) {
164
+ const activeTab = message.payload?.tabs?.find(tab => tab.active);
165
+ if (activeTab && activeTab.url) {
166
+ activeTabSyncHandler(activeTab.url).catch(err => {
167
+ debugLog(`Failed to sync lastPage on tabs_sync: ${err.message}`);
168
+ });
169
+ }
170
+ }
171
+ break;
172
+
173
+ case 'tab_created':
174
+ extensionTabs.set(message.payload.tabId, message.payload);
175
+ debugLog(`Tab created: ${message.payload.tabId}`);
176
+ notifyTabCallbacks('created', message.payload);
177
+ break;
178
+
179
+ case 'tab_closed':
180
+ extensionTabs.delete(message.payload.tabId);
181
+ debugLog(`Tab closed: ${message.payload.tabId}`);
182
+ notifyTabCallbacks('closed', message.payload);
183
+ break;
184
+
185
+ case 'tab_activated':
186
+ // Update active status
187
+ for (const [id, tab] of extensionTabs) {
188
+ tab.active = (id === message.payload.tabId);
189
+ }
190
+ debugLog(`Tab activated: ${message.payload.tabId}`);
191
+ notifyTabCallbacks('activated', message.payload);
192
+
193
+ // Sync Puppeteer's lastPage to match the user's active tab
194
+ if (activeTabSyncHandler) {
195
+ const activatedTab = extensionTabs.get(message.payload.tabId);
196
+ if (activatedTab && activatedTab.url) {
197
+ activeTabSyncHandler(activatedTab.url).catch(err => {
198
+ debugLog(`Failed to sync lastPage to activated tab: ${err.message}`);
199
+ });
200
+ }
201
+ }
202
+ break;
203
+
204
+ case 'tab_updated':
205
+ if (message.payload.tab) {
206
+ extensionTabs.set(message.payload.tabId, message.payload.tab);
207
+ }
208
+ debugLog(`Tab updated: ${message.payload.tabId}`);
209
+ notifyTabCallbacks('updated', message.payload);
210
+ break;
211
+
212
+ case 'scenario_save':
213
+ await handleScenarioSave(ws, message.payload, message.requestId);
214
+ break;
215
+
216
+ case 'scenario_list_request':
217
+ await handleScenarioListRequest(ws, message.requestId);
218
+ break;
219
+
220
+ case 'recorder_started':
221
+ debugLog('Recording started from extension:', message.payload?.startUrl);
222
+ break;
223
+
224
+ case 'ping':
225
+ sendToExtension({ type: 'pong' });
226
+ break;
227
+
228
+ default:
229
+ debugLog('Unknown message type:', message.type);
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Handle scenario save request from extension
235
+ */
236
+ async function handleScenarioSave(ws, scenario, requestId) {
237
+ try {
238
+ // Determine project ID from entry URL
239
+ const projectId = urlToProjectId(scenario.metadata?.entryUrl || '');
240
+
241
+ const result = await saveScenario(scenario, projectId);
242
+
243
+ sendToExtension({
244
+ type: 'scenario_saved',
245
+ payload: {
246
+ success: result.success,
247
+ filePath: result.filePath,
248
+ error: result.error
249
+ },
250
+ requestId
251
+ });
252
+
253
+ debugLog('Scenario saved:', scenario.name);
254
+
255
+ } catch (error) {
256
+ sendToExtension({
257
+ type: 'scenario_saved',
258
+ payload: {
259
+ success: false,
260
+ error: error.message
261
+ },
262
+ requestId
263
+ });
264
+ debugLog('Failed to save scenario:', error.message);
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Handle scenario list request from extension
270
+ */
271
+ async function handleScenarioListRequest(ws, requestId) {
272
+ try {
273
+ const scenarios = await listScenarios(null, true); // All projects
274
+
275
+ sendToExtension({
276
+ type: 'scenario_list_response',
277
+ payload: {
278
+ scenarios: scenarios.map(s => ({
279
+ name: s.name,
280
+ projectId: s.projectId,
281
+ metadata: s.metadata
282
+ }))
283
+ },
284
+ requestId
285
+ });
286
+
287
+ } catch (error) {
288
+ sendToExtension({
289
+ type: 'scenario_list_response',
290
+ payload: {
291
+ scenarios: [],
292
+ error: error.message
293
+ },
294
+ requestId
295
+ });
296
+ }
297
+ }
298
+
299
+ /**
300
+ * Send message to extension
301
+ */
302
+ export function sendToExtension(message) {
303
+ if (extensionConnection && extensionConnection.readyState === 1) { // WebSocket.OPEN
304
+ extensionConnection.send(JSON.stringify(message));
305
+ return true;
306
+ }
307
+ return false;
308
+ }
309
+
310
+ /**
311
+ * Request tabs sync from extension
312
+ */
313
+ export function requestTabsSync() {
314
+ return sendToExtension({ type: 'tabs_request' });
315
+ }
316
+
317
+ /**
318
+ * Get all tabs from extension
319
+ */
320
+ export function getTabsFromExtension() {
321
+ return Array.from(extensionTabs.values());
322
+ }
323
+
324
+ /**
325
+ * Get active tab from extension
326
+ */
327
+ export function getActiveTabFromExtension() {
328
+ for (const tab of extensionTabs.values()) {
329
+ if (tab.active) {
330
+ return tab;
331
+ }
332
+ }
333
+ return null;
334
+ }
335
+
336
+ /**
337
+ * Check if extension is connected
338
+ */
339
+ export function isExtensionConnected() {
340
+ return extensionConnection !== null && extensionConnection.readyState === 1;
341
+ }
342
+
343
+ /**
344
+ * Switch to tab by index or URL pattern via extension
345
+ * @param {number|string} tabIdentifier - Tab index or URL pattern
346
+ * @returns {object|null} - Tab info or null if not found
347
+ */
348
+ export function switchTabViaExtension(tabIdentifier) {
349
+ const tabs = Array.from(extensionTabs.values());
350
+
351
+ let targetTab = null;
352
+
353
+ if (typeof tabIdentifier === 'number') {
354
+ // Find by index
355
+ targetTab = tabs[tabIdentifier];
356
+ } else if (typeof tabIdentifier === 'string') {
357
+ // Find by URL pattern (partial match)
358
+ targetTab = tabs.find(t => t.url.toLowerCase().includes(tabIdentifier.toLowerCase()));
359
+ }
360
+
361
+ if (targetTab) {
362
+ // Send switch command to extension
363
+ sendToExtension({
364
+ type: 'switch_tab',
365
+ payload: { tabId: targetTab.tabId }
366
+ });
367
+ return targetTab;
368
+ }
369
+
370
+ return null;
371
+ }
372
+
373
+ /**
374
+ * Register callback for tab events
375
+ */
376
+ export function onTabEvent(callback) {
377
+ tabEventCallbacks.push(callback);
378
+ return () => {
379
+ const index = tabEventCallbacks.indexOf(callback);
380
+ if (index > -1) {
381
+ tabEventCallbacks.splice(index, 1);
382
+ }
383
+ };
384
+ }
385
+
386
+ /**
387
+ * Notify registered callbacks about tab events
388
+ */
389
+ function notifyTabCallbacks(eventType, payload) {
390
+ tabEventCallbacks.forEach(callback => {
391
+ try {
392
+ callback(eventType, payload);
393
+ } catch (error) {
394
+ debugLog('Tab callback error:', error.message);
395
+ }
396
+ });
397
+ }
398
+
399
+ /**
400
+ * Send recorder command to extension
401
+ */
402
+ export function sendRecorderCommand(command, options = {}) {
403
+ return sendToExtension({
404
+ type: `recorder_${command}`,
405
+ payload: options
406
+ });
407
+ }
408
+
409
+ /**
410
+ * Send command to extension and wait for response
411
+ */
412
+ export function sendExtensionCommand(message, timeout = 5000) {
413
+ return new Promise((resolve, reject) => {
414
+ if (!extensionConnection) {
415
+ reject(new Error('Extension not connected'));
416
+ return;
417
+ }
418
+
419
+ const requestId = `req_${Date.now()}_${Math.random()}`;
420
+ message.requestId = requestId;
421
+
422
+ // Set up response handler
423
+ const responseHandler = (data) => {
424
+ try {
425
+ const response = JSON.parse(data);
426
+ if (response.requestId === requestId) {
427
+ clearTimeout(timeoutHandle);
428
+ extensionConnection.off('message', responseHandler);
429
+ resolve(response);
430
+ }
431
+ } catch (err) {
432
+ // Ignore parse errors for other messages
433
+ }
434
+ };
435
+
436
+ extensionConnection.on('message', responseHandler);
437
+
438
+ // Set timeout
439
+ const timeoutHandle = setTimeout(() => {
440
+ extensionConnection.off('message', responseHandler);
441
+ reject(new Error(`Extension command timeout after ${timeout}ms`));
442
+ }, timeout);
443
+
444
+ // Send message
445
+ extensionConnection.send(JSON.stringify(message));
446
+ });
447
+ }
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Selector Resolver
3
+ * Resolves element identifiers (ID from Page Object or CSS selector) to actual elements
4
+ */
5
+
6
+ /**
7
+ * Global registry for Page Object element IDs
8
+ * Maps element IDs to their selectors
9
+ * Use window.__ELEMENT_REGISTRY__ in browser for persistence across page.evaluate calls
10
+ */
11
+ let elementRegistry;
12
+ if (typeof window !== 'undefined') {
13
+ // Browser context - use global registry
14
+ if (!window.__ELEMENT_REGISTRY__) {
15
+ window.__ELEMENT_REGISTRY__ = new Map();
16
+ }
17
+ elementRegistry = window.__ELEMENT_REGISTRY__;
18
+ } else {
19
+ // Node.js context
20
+ elementRegistry = new Map();
21
+ }
22
+
23
+ /**
24
+ * Register element from Page Object
25
+ * @param {string} id - Unique element ID
26
+ * @param {string} selector - CSS selector
27
+ * @param {Object} metadata - Additional element metadata
28
+ */
29
+ function registerElement(id, selector, metadata = {}) {
30
+ elementRegistry.set(id, {
31
+ selector,
32
+ metadata,
33
+ registeredAt: Date.now()
34
+ });
35
+ }
36
+
37
+ /**
38
+ * Register multiple elements from Page Object
39
+ * @param {Array} elements - Array of {id, selector, metadata}
40
+ */
41
+ function registerElements(elements) {
42
+ elements.forEach(el => {
43
+ registerElement(el.id, el.selector, el.metadata);
44
+ });
45
+ }
46
+
47
+ /**
48
+ * Clear element registry
49
+ */
50
+ function clearRegistry() {
51
+ elementRegistry.clear();
52
+ }
53
+
54
+ /**
55
+ * Resolve element identifier to CSS selector
56
+ * Supports:
57
+ * - Page Object ID (e.g., "login_email_input")
58
+ * - CSS selector (e.g., "input[name='email']")
59
+ *
60
+ * @param {string} identifier - Element ID or CSS selector
61
+ * @returns {Object} { selector, isPageObjectId, metadata }
62
+ */
63
+ function resolveSelector(identifier) {
64
+ // Check if it's a registered Page Object ID
65
+ if (elementRegistry.has(identifier)) {
66
+ const registered = elementRegistry.get(identifier);
67
+ return {
68
+ selector: registered.selector,
69
+ isPageObjectId: true,
70
+ metadata: registered.metadata,
71
+ originalId: identifier
72
+ };
73
+ }
74
+
75
+ // Otherwise, treat as CSS selector
76
+ return {
77
+ selector: identifier,
78
+ isPageObjectId: false,
79
+ metadata: {},
80
+ originalId: null
81
+ };
82
+ }
83
+
84
+ /**
85
+ * Find element by ID or selector
86
+ * @param {string} identifier - Element ID or CSS selector
87
+ * @returns {Element|null} - Found element or null
88
+ */
89
+ function findElement(identifier) {
90
+ const resolved = resolveSelector(identifier);
91
+ return document.querySelector(resolved.selector);
92
+ }
93
+
94
+ /**
95
+ * Find all elements by ID or selector
96
+ * @param {string} identifier - Element ID or CSS selector
97
+ * @returns {NodeList} - Found elements
98
+ */
99
+ function findElements(identifier) {
100
+ const resolved = resolveSelector(identifier);
101
+ return document.querySelectorAll(resolved.selector);
102
+ }
103
+
104
+ /**
105
+ * Get element information
106
+ * @param {string} identifier - Element ID or CSS selector
107
+ * @returns {Object|null} - Element information or null
108
+ */
109
+ function getElementInfo(identifier) {
110
+ const resolved = resolveSelector(identifier);
111
+ const element = document.querySelector(resolved.selector);
112
+
113
+ if (!element) return null;
114
+
115
+ return {
116
+ identifier,
117
+ selector: resolved.selector,
118
+ isPageObjectId: resolved.isPageObjectId,
119
+ tag: element.tagName.toLowerCase(),
120
+ type: element.type || null,
121
+ text: element.textContent?.trim().substring(0, 100) || '',
122
+ visible: element.offsetWidth > 0 && element.offsetHeight > 0,
123
+ metadata: resolved.metadata
124
+ };
125
+ }
126
+
127
+ /**
128
+ * Get all registered element IDs
129
+ * @returns {Array} - Array of registered IDs
130
+ */
131
+ function getRegisteredIds() {
132
+ return Array.from(elementRegistry.keys());
133
+ }
134
+
135
+ /**
136
+ * Get registry statistics
137
+ * @returns {Object} - Registry stats
138
+ */
139
+ function getRegistryStats() {
140
+ return {
141
+ count: elementRegistry.size,
142
+ ids: Array.from(elementRegistry.keys()),
143
+ oldestRegistration: Math.min(...Array.from(elementRegistry.values()).map(v => v.registeredAt)),
144
+ newestRegistration: Math.max(...Array.from(elementRegistry.values()).map(v => v.registeredAt))
145
+ };
146
+ }
147
+
148
+ /**
149
+ * Check if identifier is a Page Object ID
150
+ * @param {string} identifier
151
+ * @returns {boolean}
152
+ */
153
+ function isPageObjectId(identifier) {
154
+ return elementRegistry.has(identifier);
155
+ }
156
+
157
+ // Export for Node.js (server-side)
158
+ if (typeof module !== 'undefined' && module.exports) {
159
+ module.exports = {
160
+ registerElement,
161
+ registerElements,
162
+ clearRegistry,
163
+ resolveSelector,
164
+ findElement,
165
+ findElements,
166
+ getElementInfo,
167
+ getRegisteredIds,
168
+ getRegistryStats,
169
+ isPageObjectId,
170
+ elementRegistry
171
+ };
172
+ }
173
+
174
+ // Export for browser context (global scope)
175
+ if (typeof window !== 'undefined') {
176
+ window.registerElement = registerElement;
177
+ window.registerElements = registerElements;
178
+ window.clearRegistry = clearRegistry;
179
+ window.resolveSelector = resolveSelector;
180
+ window.findElement = findElement;
181
+ window.findElements = findElements;
182
+ window.getElementInfo = getElementInfo;
183
+ window.getRegisteredIds = getRegisteredIds;
184
+ window.getRegistryStats = getRegistryStats;
185
+ window.isPageObjectId = isPageObjectId;
186
+ }