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.
- package/CHANGELOG.md +540 -0
- package/COMPONENT_MAPPING_SPEC.md +1217 -0
- package/README.md +494 -38
- package/bridge/bridge-client.js +472 -0
- package/bridge/bridge-service.js +399 -0
- package/bridge/install.js +241 -0
- package/browser/browser-manager.js +107 -2
- package/browser/page-manager.js +226 -69
- package/docs/CHROME_EXTENSION.md +219 -0
- package/docs/PAGE_OBJECT_MODEL_CONCEPT.md +1756 -0
- package/element-finder-utils.js +138 -28
- package/extension/background.js +643 -0
- package/extension/content.js +715 -0
- package/extension/icons/create-icons.js +164 -0
- package/extension/icons/icon128.png +0 -0
- package/extension/icons/icon16.png +0 -0
- package/extension/icons/icon48.png +0 -0
- package/extension/manifest.json +58 -0
- package/extension/popup/popup.css +437 -0
- package/extension/popup/popup.html +102 -0
- package/extension/popup/popup.js +415 -0
- package/extension/recorder-overlay.css +93 -0
- package/figma-tools.js +120 -0
- package/index.js +3347 -2518
- package/models/BaseInputModel.js +93 -0
- package/models/CheckboxGroupModel.js +199 -0
- package/models/CheckboxModel.js +103 -0
- package/models/ColorInputModel.js +53 -0
- package/models/DateInputModel.js +67 -0
- package/models/RadioGroupModel.js +126 -0
- package/models/RangeInputModel.js +60 -0
- package/models/SelectModel.js +97 -0
- package/models/TextInputModel.js +34 -0
- package/models/TextareaModel.js +59 -0
- package/models/TimeInputModel.js +49 -0
- package/models/index.js +122 -0
- package/package.json +3 -2
- package/pom/apom-converter.js +267 -0
- package/pom/apom-tree-converter.js +515 -0
- package/pom/element-id-generator.js +175 -0
- package/recorder/page-object-generator.js +16 -0
- package/recorder/scenario-executor.js +80 -2
- package/server/tool-definitions.js +839 -656
- package/server/tool-groups.js +3 -2
- package/server/tool-schemas.js +367 -296
- package/server/websocket-bridge.js +447 -0
- package/utils/selector-resolver.js +186 -0
- 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
|
+
}
|