@dynamicu/chromedebug-mcp 2.2.0
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/CLAUDE.md +344 -0
- package/LICENSE +21 -0
- package/README.md +250 -0
- package/chrome-extension/README.md +41 -0
- package/chrome-extension/background.js +3917 -0
- package/chrome-extension/chrome-session-manager.js +706 -0
- package/chrome-extension/content.css +181 -0
- package/chrome-extension/content.js +3022 -0
- package/chrome-extension/data-buffer.js +435 -0
- package/chrome-extension/dom-tracker.js +411 -0
- package/chrome-extension/extension-config.js +78 -0
- package/chrome-extension/firebase-client.js +278 -0
- package/chrome-extension/firebase-config.js +32 -0
- package/chrome-extension/firebase-config.module.js +22 -0
- package/chrome-extension/firebase-config.module.template.js +27 -0
- package/chrome-extension/firebase-config.template.js +36 -0
- package/chrome-extension/frame-capture.js +407 -0
- package/chrome-extension/icon128.png +1 -0
- package/chrome-extension/icon16.png +1 -0
- package/chrome-extension/icon48.png +1 -0
- package/chrome-extension/license-helper.js +181 -0
- package/chrome-extension/logger.js +23 -0
- package/chrome-extension/manifest.json +73 -0
- package/chrome-extension/network-tracker.js +510 -0
- package/chrome-extension/offscreen.html +10 -0
- package/chrome-extension/options.html +203 -0
- package/chrome-extension/options.js +282 -0
- package/chrome-extension/pako.min.js +2 -0
- package/chrome-extension/performance-monitor.js +533 -0
- package/chrome-extension/pii-redactor.js +405 -0
- package/chrome-extension/popup.html +532 -0
- package/chrome-extension/popup.js +2446 -0
- package/chrome-extension/upload-manager.js +323 -0
- package/chrome-extension/web-vitals.iife.js +1 -0
- package/config/api-keys.json +11 -0
- package/config/chrome-pilot-config.json +45 -0
- package/package.json +126 -0
- package/scripts/cleanup-processes.js +109 -0
- package/scripts/config-manager.js +280 -0
- package/scripts/generate-extension-config.js +53 -0
- package/scripts/setup-security.js +64 -0
- package/src/capture/architecture.js +426 -0
- package/src/capture/error-handling-tests.md +38 -0
- package/src/capture/error-handling-types.ts +360 -0
- package/src/capture/index.js +508 -0
- package/src/capture/interfaces.js +625 -0
- package/src/capture/memory-manager.js +713 -0
- package/src/capture/types.js +342 -0
- package/src/chrome-controller.js +2658 -0
- package/src/cli.js +19 -0
- package/src/config-loader.js +303 -0
- package/src/database.js +2178 -0
- package/src/firebase-license-manager.js +462 -0
- package/src/firebase-privacy-guard.js +397 -0
- package/src/http-server.js +1516 -0
- package/src/index-direct.js +157 -0
- package/src/index-modular.js +219 -0
- package/src/index-monolithic-backup.js +2230 -0
- package/src/index.js +305 -0
- package/src/legacy/chrome-controller-old.js +1406 -0
- package/src/legacy/index-express.js +625 -0
- package/src/legacy/index-old.js +977 -0
- package/src/legacy/routes.js +260 -0
- package/src/legacy/shared-storage.js +101 -0
- package/src/logger.js +10 -0
- package/src/mcp/handlers/chrome-tool-handler.js +306 -0
- package/src/mcp/handlers/element-tool-handler.js +51 -0
- package/src/mcp/handlers/frame-tool-handler.js +957 -0
- package/src/mcp/handlers/request-handler.js +104 -0
- package/src/mcp/handlers/workflow-tool-handler.js +636 -0
- package/src/mcp/server.js +68 -0
- package/src/mcp/tools/index.js +701 -0
- package/src/middleware/auth.js +371 -0
- package/src/middleware/security.js +267 -0
- package/src/port-discovery.js +258 -0
- package/src/routes/admin.js +182 -0
- package/src/services/browser-daemon.js +494 -0
- package/src/services/chrome-service.js +375 -0
- package/src/services/failover-manager.js +412 -0
- package/src/services/git-safety-service.js +675 -0
- package/src/services/heartbeat-manager.js +200 -0
- package/src/services/http-client.js +195 -0
- package/src/services/process-manager.js +318 -0
- package/src/services/process-tracker.js +574 -0
- package/src/services/profile-manager.js +449 -0
- package/src/services/project-manager.js +415 -0
- package/src/services/session-manager.js +497 -0
- package/src/services/session-registry.js +491 -0
- package/src/services/unified-session-manager.js +678 -0
- package/src/shared-storage-old.js +267 -0
- package/src/standalone-server.js +53 -0
- package/src/utils/extension-path.js +145 -0
- package/src/utils.js +187 -0
- package/src/validation/log-transformer.js +125 -0
- package/src/validation/schemas.js +391 -0
|
@@ -0,0 +1,2658 @@
|
|
|
1
|
+
import puppeteer from 'puppeteer';
|
|
2
|
+
import { findAvailablePort } from './utils.js';
|
|
3
|
+
import { promises as fs } from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import os from 'os';
|
|
6
|
+
import { SessionManager } from './services/session-manager.js';
|
|
7
|
+
import { BrowserDaemon } from './services/browser-daemon.js';
|
|
8
|
+
import { FailoverManager } from './services/failover-manager.js';
|
|
9
|
+
import { getExtensionPath } from './utils/extension-path.js';
|
|
10
|
+
|
|
11
|
+
export class ChromeController {
|
|
12
|
+
constructor() {
|
|
13
|
+
this.browser = null;
|
|
14
|
+
this.page = null;
|
|
15
|
+
this.client = null;
|
|
16
|
+
this.paused = false;
|
|
17
|
+
this.logs = [];
|
|
18
|
+
this.debugPort = null;
|
|
19
|
+
this.defaultTimeout = 10000;
|
|
20
|
+
this.selectedElement = null;
|
|
21
|
+
this.processId = null; // Track our Chrome process
|
|
22
|
+
this.userDataDir = null; // Track user data directory for cleanup
|
|
23
|
+
|
|
24
|
+
// Tab Registry - ROBUST TAB TRACKING
|
|
25
|
+
this.tabRegistry = new Map(); // targetId -> { page, title, url, sessionId, createdAt }
|
|
26
|
+
this.activeTargetId = null; // Currently active tab Target ID
|
|
27
|
+
this.targetEventListenersSetup = false; // Track if event listeners are initialized
|
|
28
|
+
|
|
29
|
+
// Session management - NEW for Phase 1
|
|
30
|
+
this.sessionManager = new SessionManager();
|
|
31
|
+
this.sessionId = this.sessionManager.sessionId;
|
|
32
|
+
|
|
33
|
+
// Shared browser management - Phase 2 Enhanced Security
|
|
34
|
+
this.sharedBrowserConnection = null;
|
|
35
|
+
this.sharedSessionData = null;
|
|
36
|
+
this.failoverManager = new FailoverManager();
|
|
37
|
+
this.usingSharedBrowser = false;
|
|
38
|
+
this.claudeInstanceId = `claude-${this.sessionId}`;
|
|
39
|
+
|
|
40
|
+
// Auto-start daemon tracking - Phase 2 Enhanced Security
|
|
41
|
+
this.sharedDaemon = null; // Auto-started daemon instance
|
|
42
|
+
this.isDaemonOwner = false; // Whether this controller started the daemon
|
|
43
|
+
|
|
44
|
+
// Class-level lock to prevent race conditions
|
|
45
|
+
if (!ChromeController.daemonStartPromise) {
|
|
46
|
+
ChromeController.daemonStartPromise = null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Initialize database
|
|
50
|
+
this.initDatabase();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async initDatabase() {
|
|
54
|
+
const { database } = await import('./database.js');
|
|
55
|
+
this.database = database;
|
|
56
|
+
this.database.init();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
withTimeout(promise, timeoutMs = this.defaultTimeout, operation = 'operation') {
|
|
60
|
+
return Promise.race([
|
|
61
|
+
promise,
|
|
62
|
+
new Promise((_, reject) => {
|
|
63
|
+
setTimeout(() => {
|
|
64
|
+
reject(new Error(`${operation} timed out after ${timeoutMs}ms`));
|
|
65
|
+
}, timeoutMs);
|
|
66
|
+
})
|
|
67
|
+
]);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Tab Registry Management Methods - ROBUST TAB TRACKING
|
|
72
|
+
*/
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Registers a new tab in the registry with Target ID-based tracking
|
|
76
|
+
*/
|
|
77
|
+
async registerTab(page, targetId = null) {
|
|
78
|
+
if (!page) return null;
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
// Get target ID if not provided
|
|
82
|
+
if (!targetId) {
|
|
83
|
+
const target = page.target();
|
|
84
|
+
targetId = target._targetId || target.targetInfo().targetId;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Get page info
|
|
88
|
+
const url = page.url();
|
|
89
|
+
const title = await page.title().catch(() => 'Untitled');
|
|
90
|
+
|
|
91
|
+
const tabData = {
|
|
92
|
+
page,
|
|
93
|
+
title,
|
|
94
|
+
url,
|
|
95
|
+
sessionId: this.sessionId,
|
|
96
|
+
createdAt: new Date().toISOString(),
|
|
97
|
+
targetId
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
this.tabRegistry.set(targetId, tabData);
|
|
101
|
+
|
|
102
|
+
// Set as active tab if it's the first one or no active tab exists
|
|
103
|
+
if (!this.activeTargetId || this.tabRegistry.size === 1) {
|
|
104
|
+
this.setActiveTab(targetId);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
console.log(`[TabRegistry] Registered tab ${targetId} for session ${this.sessionId}`);
|
|
108
|
+
return tabData;
|
|
109
|
+
} catch (error) {
|
|
110
|
+
console.error(`[TabRegistry] Failed to register tab:`, error);
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Sets the active tab and updates this.page for backward compatibility
|
|
117
|
+
*/
|
|
118
|
+
setActiveTab(targetId) {
|
|
119
|
+
const tabData = this.tabRegistry.get(targetId);
|
|
120
|
+
if (tabData) {
|
|
121
|
+
this.activeTargetId = targetId;
|
|
122
|
+
this.page = tabData.page; // Maintain backward compatibility
|
|
123
|
+
console.log(`[TabRegistry] Active tab set to ${targetId}`);
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Gets tab data by target ID
|
|
131
|
+
*/
|
|
132
|
+
getTab(targetId) {
|
|
133
|
+
return this.tabRegistry.get(targetId);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Gets the currently active tab data
|
|
138
|
+
*/
|
|
139
|
+
getActiveTab() {
|
|
140
|
+
if (this.activeTargetId) {
|
|
141
|
+
return this.tabRegistry.get(this.activeTargetId);
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Lists all tabs in the registry for this session
|
|
148
|
+
*/
|
|
149
|
+
listTabs() {
|
|
150
|
+
return Array.from(this.tabRegistry.values()).filter(tab => tab.sessionId === this.sessionId);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Removes a tab from the registry
|
|
155
|
+
*/
|
|
156
|
+
unregisterTab(targetId) {
|
|
157
|
+
const wasActive = this.activeTargetId === targetId;
|
|
158
|
+
const removed = this.tabRegistry.delete(targetId);
|
|
159
|
+
|
|
160
|
+
if (removed) {
|
|
161
|
+
console.log(`[TabRegistry] Unregistered tab ${targetId}`);
|
|
162
|
+
|
|
163
|
+
// If this was the active tab, select a new active tab
|
|
164
|
+
if (wasActive) {
|
|
165
|
+
const availableTabs = Array.from(this.tabRegistry.keys());
|
|
166
|
+
if (availableTabs.length > 0) {
|
|
167
|
+
this.setActiveTab(availableTabs[0]);
|
|
168
|
+
} else {
|
|
169
|
+
this.activeTargetId = null;
|
|
170
|
+
this.page = null;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return removed;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Clears all tabs from the registry
|
|
180
|
+
*/
|
|
181
|
+
clearTabRegistry() {
|
|
182
|
+
this.tabRegistry.clear();
|
|
183
|
+
this.activeTargetId = null;
|
|
184
|
+
this.page = null;
|
|
185
|
+
console.log(`[TabRegistry] Cleared all tabs for session ${this.sessionId}`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Sets up Target API event listeners for robust tab tracking
|
|
190
|
+
*/
|
|
191
|
+
async setupTargetEventListeners() {
|
|
192
|
+
if (!this.browser || this.targetEventListenersSetup) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
// Create a browser-level CDP session for Target events
|
|
198
|
+
const browserTarget = this.browser.target();
|
|
199
|
+
const browserClient = await browserTarget.createCDPSession();
|
|
200
|
+
|
|
201
|
+
// Enable Target domain to receive events
|
|
202
|
+
await browserClient.send('Target.setDiscoverTargets', { discover: true });
|
|
203
|
+
|
|
204
|
+
// Listen for tab creation
|
|
205
|
+
browserClient.on('Target.targetCreated', async (params) => {
|
|
206
|
+
const { targetInfo } = params;
|
|
207
|
+
|
|
208
|
+
// Only handle page targets (tabs)
|
|
209
|
+
if (targetInfo.type === 'page') {
|
|
210
|
+
try {
|
|
211
|
+
console.log(`[TargetEvents] New tab created: ${targetInfo.targetId}`);
|
|
212
|
+
|
|
213
|
+
// Wait a bit for the page to be ready
|
|
214
|
+
setTimeout(async () => {
|
|
215
|
+
try {
|
|
216
|
+
const pages = await this.browser.pages();
|
|
217
|
+
const newPage = pages.find(p => {
|
|
218
|
+
const target = p.target();
|
|
219
|
+
const id = target._targetId || target.targetInfo().targetId;
|
|
220
|
+
return id === targetInfo.targetId;
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
if (newPage) {
|
|
224
|
+
await this.registerTab(newPage, targetInfo.targetId);
|
|
225
|
+
}
|
|
226
|
+
} catch (error) {
|
|
227
|
+
console.error(`[TargetEvents] Error registering new tab:`, error);
|
|
228
|
+
}
|
|
229
|
+
}, 100);
|
|
230
|
+
} catch (error) {
|
|
231
|
+
console.error(`[TargetEvents] Error handling target creation:`, error);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// Listen for tab destruction
|
|
237
|
+
browserClient.on('Target.targetDestroyed', (params) => {
|
|
238
|
+
const { targetId } = params;
|
|
239
|
+
console.log(`[TargetEvents] Tab destroyed: ${targetId}`);
|
|
240
|
+
this.unregisterTab(targetId);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// Listen for tab info changes (URL, title changes)
|
|
244
|
+
browserClient.on('Target.targetInfoChanged', (params) => {
|
|
245
|
+
const { targetInfo } = params;
|
|
246
|
+
|
|
247
|
+
if (targetInfo.type === 'page') {
|
|
248
|
+
const tabData = this.tabRegistry.get(targetInfo.targetId);
|
|
249
|
+
if (tabData) {
|
|
250
|
+
tabData.url = targetInfo.url;
|
|
251
|
+
tabData.title = targetInfo.title || 'Untitled';
|
|
252
|
+
console.log(`[TargetEvents] Tab info updated: ${targetInfo.targetId} - ${targetInfo.title}`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
this.targetEventListenersSetup = true;
|
|
258
|
+
console.log(`[TabRegistry] Target event listeners setup complete for session ${this.sessionId}`);
|
|
259
|
+
} catch (error) {
|
|
260
|
+
console.error(`[TabRegistry] Failed to setup target event listeners:`, error);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Initializes tab registry with existing tabs
|
|
266
|
+
*/
|
|
267
|
+
async initializeTabRegistry() {
|
|
268
|
+
if (!this.browser) {
|
|
269
|
+
console.warn(`[TabRegistry] Cannot initialize - no browser connection`);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
try {
|
|
274
|
+
const pages = await this.browser.pages();
|
|
275
|
+
console.log(`[TabRegistry] Initializing with ${pages.length} existing tabs`);
|
|
276
|
+
|
|
277
|
+
for (const page of pages) {
|
|
278
|
+
await this.registerTab(page);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Set up event listeners after registry initialization
|
|
282
|
+
await this.setupTargetEventListeners();
|
|
283
|
+
|
|
284
|
+
console.log(`[TabRegistry] Initialization complete - ${this.tabRegistry.size} tabs registered`);
|
|
285
|
+
} catch (error) {
|
|
286
|
+
console.error(`[TabRegistry] Failed to initialize tab registry:`, error);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Ensures the current page reference is valid and updates from registry if needed
|
|
292
|
+
*/
|
|
293
|
+
async ensureValidPageReference() {
|
|
294
|
+
// If this.page exists and is still valid, use it
|
|
295
|
+
if (this.page && !this.page.isClosed()) {
|
|
296
|
+
return this.page;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Try to get the active tab from registry
|
|
300
|
+
const activeTab = this.getActiveTab();
|
|
301
|
+
if (activeTab && activeTab.page && !activeTab.page.isClosed()) {
|
|
302
|
+
this.page = activeTab.page;
|
|
303
|
+
return this.page;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Get the first available tab from our session
|
|
307
|
+
const sessionTabs = this.listTabs();
|
|
308
|
+
for (const tab of sessionTabs) {
|
|
309
|
+
if (tab.page && !tab.page.isClosed()) {
|
|
310
|
+
this.setActiveTab(tab.targetId);
|
|
311
|
+
return this.page;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// If no valid tabs in our session, get any available page
|
|
316
|
+
if (this.browser) {
|
|
317
|
+
try {
|
|
318
|
+
const pages = await this.browser.pages();
|
|
319
|
+
for (const page of pages) {
|
|
320
|
+
if (!page.isClosed()) {
|
|
321
|
+
await this.registerTab(page);
|
|
322
|
+
return this.page;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
} catch (error) {
|
|
326
|
+
console.error(`[TabRegistry] Error finding valid page:`, error);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Session Isolation Methods - ENHANCED SECURITY
|
|
335
|
+
*/
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Gets all tabs from the registry (including other sessions)
|
|
339
|
+
*/
|
|
340
|
+
getAllTabs() {
|
|
341
|
+
return Array.from(this.tabRegistry.values());
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Gets tabs count by session ID
|
|
346
|
+
*/
|
|
347
|
+
getTabCountBySession() {
|
|
348
|
+
const sessionCounts = {};
|
|
349
|
+
for (const tab of this.tabRegistry.values()) {
|
|
350
|
+
sessionCounts[tab.sessionId] = (sessionCounts[tab.sessionId] || 0) + 1;
|
|
351
|
+
}
|
|
352
|
+
return sessionCounts;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Finds tabs created by other Claude instances (different sessionIds)
|
|
357
|
+
*/
|
|
358
|
+
getOtherInstanceTabs() {
|
|
359
|
+
return Array.from(this.tabRegistry.values()).filter(tab => tab.sessionId !== this.sessionId);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Safely switches to a tab from another session (if allowed)
|
|
364
|
+
*/
|
|
365
|
+
async switchToTab(targetId, options = {}) {
|
|
366
|
+
const tabData = this.tabRegistry.get(targetId);
|
|
367
|
+
if (!tabData) {
|
|
368
|
+
throw new Error(`Tab not found: ${targetId}`);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Check if this is a cross-session switch
|
|
372
|
+
if (tabData.sessionId !== this.sessionId) {
|
|
373
|
+
if (!options.allowCrossSession) {
|
|
374
|
+
throw new Error(`Cross-session tab access denied. Tab belongs to session ${tabData.sessionId}, current session is ${this.sessionId}`);
|
|
375
|
+
}
|
|
376
|
+
console.warn(`[TabRegistry] Cross-session tab switch: ${tabData.sessionId} → ${this.sessionId}`);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Verify the page is still valid
|
|
380
|
+
if (tabData.page.isClosed()) {
|
|
381
|
+
this.unregisterTab(targetId);
|
|
382
|
+
throw new Error(`Target tab is closed: ${targetId}`);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Switch to the tab
|
|
386
|
+
const success = this.setActiveTab(targetId);
|
|
387
|
+
if (success) {
|
|
388
|
+
console.log(`[TabRegistry] Switched to tab ${targetId} (Session: ${tabData.sessionId})`);
|
|
389
|
+
return {
|
|
390
|
+
success: true,
|
|
391
|
+
targetId,
|
|
392
|
+
sessionId: tabData.sessionId,
|
|
393
|
+
url: tabData.url,
|
|
394
|
+
title: tabData.title
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
throw new Error(`Failed to switch to tab: ${targetId}`);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Creates a new tab and registers it for this session
|
|
403
|
+
*/
|
|
404
|
+
async createNewTab(url = null) {
|
|
405
|
+
if (!this.browser) {
|
|
406
|
+
throw new Error('Browser not connected');
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
try {
|
|
410
|
+
const newPage = await this.browser.newPage();
|
|
411
|
+
|
|
412
|
+
if (url) {
|
|
413
|
+
await newPage.goto(url, { waitUntil: 'domcontentloaded' });
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const tabData = await this.registerTab(newPage);
|
|
417
|
+
|
|
418
|
+
if (tabData) {
|
|
419
|
+
console.log(`[TabRegistry] Created new tab ${tabData.targetId} for session ${this.sessionId}`);
|
|
420
|
+
return {
|
|
421
|
+
success: true,
|
|
422
|
+
targetId: tabData.targetId,
|
|
423
|
+
url: tabData.url,
|
|
424
|
+
title: tabData.title
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
throw new Error('Failed to register new tab');
|
|
429
|
+
} catch (error) {
|
|
430
|
+
console.error(`[TabRegistry] Error creating new tab:`, error);
|
|
431
|
+
throw error;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Safely kills our own Chrome process using Node.js native process.kill()
|
|
437
|
+
* This prevents command injection by validating PID and using native APIs
|
|
438
|
+
*/
|
|
439
|
+
async killOwnChromeProcess() {
|
|
440
|
+
try {
|
|
441
|
+
if (this.processId) {
|
|
442
|
+
// Security: Validate that processId is a valid number and contains no suspicious characters
|
|
443
|
+
const pidStr = String(this.processId).trim();
|
|
444
|
+
if (!/^\d+$/.test(pidStr)) {
|
|
445
|
+
console.error('Invalid or suspicious process ID for Chrome process:', this.processId);
|
|
446
|
+
this.processId = null;
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const pid = parseInt(pidStr, 10);
|
|
451
|
+
if (isNaN(pid) || pid <= 0 || pid > 4194304) {
|
|
452
|
+
console.error('Invalid process ID for Chrome process:', this.processId);
|
|
453
|
+
this.processId = null;
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Security: Use Node.js native process.kill() instead of shell commands
|
|
458
|
+
// This prevents command injection attacks
|
|
459
|
+
try {
|
|
460
|
+
// First try graceful termination
|
|
461
|
+
process.kill(pid, 'SIGTERM');
|
|
462
|
+
|
|
463
|
+
// Wait a bit for graceful shutdown
|
|
464
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
465
|
+
|
|
466
|
+
// Check if process still exists, if so force kill
|
|
467
|
+
try {
|
|
468
|
+
process.kill(pid, 0); // Signal 0 checks if process exists
|
|
469
|
+
// Process still exists, force kill
|
|
470
|
+
process.kill(pid, 'SIGKILL');
|
|
471
|
+
} catch (e) {
|
|
472
|
+
// Process doesn't exist anymore (ESRCH), good
|
|
473
|
+
if (e.code !== 'ESRCH') {
|
|
474
|
+
throw e;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
} catch (error) {
|
|
478
|
+
if (error.code === 'ESRCH') {
|
|
479
|
+
// Process already dead, ignore
|
|
480
|
+
} else if (error.code === 'EPERM') {
|
|
481
|
+
console.error('Permission denied killing Chrome process:', pid);
|
|
482
|
+
} else {
|
|
483
|
+
throw error;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
this.processId = null;
|
|
488
|
+
}
|
|
489
|
+
} catch (error) {
|
|
490
|
+
console.error('Error killing Chrome process:', error.message);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Safely validates and cleans up a directory path
|
|
496
|
+
* Prevents directory traversal attacks and validates paths
|
|
497
|
+
*/
|
|
498
|
+
async safeCleanupDirectory(dirPath) {
|
|
499
|
+
if (!dirPath || typeof dirPath !== 'string') {
|
|
500
|
+
throw new Error('Invalid directory path provided');
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// Security: Validate that the directory path is within expected locations
|
|
504
|
+
const tmpDir = os.tmpdir();
|
|
505
|
+
const resolvedPath = path.resolve(dirPath);
|
|
506
|
+
const resolvedTmpDir = path.resolve(tmpDir);
|
|
507
|
+
|
|
508
|
+
// Ensure the path is within the temp directory and contains chromedebug prefix
|
|
509
|
+
if (!resolvedPath.startsWith(resolvedTmpDir) || !path.basename(resolvedPath).startsWith('chromedebug-')) {
|
|
510
|
+
throw new Error(`Security: Refusing to clean up directory outside temp or without chromedebug prefix: ${dirPath}`);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
try {
|
|
514
|
+
// Use Node.js fs.rm() instead of shell commands to prevent injection
|
|
515
|
+
await fs.rm(resolvedPath, { recursive: true, force: true });
|
|
516
|
+
console.error(`Cleaned up directory: ${resolvedPath}`);
|
|
517
|
+
} catch (error) {
|
|
518
|
+
if (error.code === 'ENOENT') {
|
|
519
|
+
// Directory doesn't exist, ignore
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
throw error;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
async cleanupStaleProfiles() {
|
|
527
|
+
try {
|
|
528
|
+
const tmpDir = os.tmpdir();
|
|
529
|
+
const entries = await fs.readdir(tmpDir);
|
|
530
|
+
|
|
531
|
+
const chromePilotDirs = entries.filter(entry => entry.startsWith('chromedebug-'));
|
|
532
|
+
|
|
533
|
+
for (const dir of chromePilotDirs) {
|
|
534
|
+
try {
|
|
535
|
+
// Parse PID from directory name
|
|
536
|
+
const parts = dir.split('-');
|
|
537
|
+
if (parts.length >= 3) {
|
|
538
|
+
const pid = parseInt(parts[2], 10);
|
|
539
|
+
|
|
540
|
+
if (!isNaN(pid)) {
|
|
541
|
+
// Check if process is still alive
|
|
542
|
+
try {
|
|
543
|
+
process.kill(pid, 0);
|
|
544
|
+
// Process exists, skip this directory
|
|
545
|
+
console.error(`Skipping active ChromeDebug MCP directory: ${dir} (PID ${pid} is still running)`);
|
|
546
|
+
} catch (e) {
|
|
547
|
+
if (e.code === 'ESRCH') {
|
|
548
|
+
// Process doesn't exist, safe to clean up
|
|
549
|
+
const dirPath = path.join(tmpDir, dir);
|
|
550
|
+
console.error(`Cleaning up stale ChromeDebug MCP directory: ${dir} (PID ${pid} no longer exists)`);
|
|
551
|
+
await fs.rm(dirPath, { recursive: true, force: true });
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
} catch (error) {
|
|
557
|
+
console.error(`Error processing directory ${dir}:`, error.message);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
} catch (error) {
|
|
561
|
+
console.error('Error during stale profile cleanup:', error.message);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
async launch() {
|
|
566
|
+
// Clean up any stale profiles from previous runs using SessionManager
|
|
567
|
+
await this.sessionManager.cleanupStaleSessions();
|
|
568
|
+
|
|
569
|
+
let sessionData = null;
|
|
570
|
+
|
|
571
|
+
try {
|
|
572
|
+
// Close any existing browser
|
|
573
|
+
if (this.browser) {
|
|
574
|
+
await this.close();
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// Initialize failover manager
|
|
578
|
+
await this.failoverManager.initialize();
|
|
579
|
+
|
|
580
|
+
// Phase 2: Try connecting to shared browser daemon first
|
|
581
|
+
try {
|
|
582
|
+
const sharedResult = await this.connectToSharedBrowser();
|
|
583
|
+
if (sharedResult.success) {
|
|
584
|
+
return sharedResult;
|
|
585
|
+
}
|
|
586
|
+
} catch (error) {
|
|
587
|
+
console.warn('[ChromeController] Shared browser unavailable, falling back to individual browser:', error.message);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// Fallback to individual browser launch
|
|
591
|
+
return await this.launchIndividualBrowser();
|
|
592
|
+
} catch (error) {
|
|
593
|
+
// Clean up session if launch failed
|
|
594
|
+
if (sessionData) {
|
|
595
|
+
try {
|
|
596
|
+
await this.sessionManager.forceCleanupSession(sessionData.sessionId);
|
|
597
|
+
console.error(`Cleaned up session after launch failure: ${sessionData.sessionId}`);
|
|
598
|
+
} catch (cleanupError) {
|
|
599
|
+
console.error(`Failed to clean up session: ${sessionData.sessionId}`, cleanupError.message);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// Reset state
|
|
604
|
+
this.browser = null;
|
|
605
|
+
this.page = null;
|
|
606
|
+
this.client = null;
|
|
607
|
+
this.processId = null;
|
|
608
|
+
this.userDataDir = null;
|
|
609
|
+
|
|
610
|
+
throw new Error(`Failed to launch Chrome: ${error.message}`);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Attempts to connect to a shared browser daemon
|
|
616
|
+
* @returns {Promise<Object>} Connection result
|
|
617
|
+
*/
|
|
618
|
+
async connectToSharedBrowser() {
|
|
619
|
+
console.log('[ChromeController] Attempting to connect to shared browser daemon...');
|
|
620
|
+
|
|
621
|
+
// Check if we should use fallback based on failover manager
|
|
622
|
+
const shouldUseFallback = await this.failoverManager.shouldUseFallback('connect');
|
|
623
|
+
if (shouldUseFallback) {
|
|
624
|
+
// Instead of immediately failing, try to auto-start a daemon if none exists
|
|
625
|
+
console.log('[ChromeController] Failover manager recommends fallback, but checking for auto-start opportunity...');
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// Try to find a running daemon by checking common ports
|
|
629
|
+
const daemonPorts = [9222, 9223, 9224, 9225];
|
|
630
|
+
let daemon = null;
|
|
631
|
+
let daemonPort = null;
|
|
632
|
+
|
|
633
|
+
for (const port of daemonPorts) {
|
|
634
|
+
try {
|
|
635
|
+
// Simple check if something is listening on the port using Node.js http
|
|
636
|
+
const http = await import('http');
|
|
637
|
+
const checkPort = () => new Promise((resolve, reject) => {
|
|
638
|
+
const req = http.request({
|
|
639
|
+
hostname: 'localhost',
|
|
640
|
+
port: port,
|
|
641
|
+
path: '/json/version',
|
|
642
|
+
method: 'GET',
|
|
643
|
+
timeout: 2000
|
|
644
|
+
}, (res) => {
|
|
645
|
+
if (res.statusCode === 200) {
|
|
646
|
+
resolve(true);
|
|
647
|
+
} else {
|
|
648
|
+
resolve(false);
|
|
649
|
+
}
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
req.on('error', () => resolve(false));
|
|
653
|
+
req.on('timeout', () => resolve(false));
|
|
654
|
+
req.end();
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
const isListening = await checkPort();
|
|
658
|
+
if (isListening) {
|
|
659
|
+
daemonPort = port;
|
|
660
|
+
console.log(`[ChromeController] Found potential daemon on port ${port}`);
|
|
661
|
+
break;
|
|
662
|
+
}
|
|
663
|
+
} catch (error) {
|
|
664
|
+
// Port not available, continue
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
if (!daemonPort) {
|
|
669
|
+
try {
|
|
670
|
+
console.log('[ChromeController] No existing daemon found, starting new daemon...');
|
|
671
|
+
daemonPort = await this.autoStartDaemon();
|
|
672
|
+
} catch (error) {
|
|
673
|
+
console.warn('[ChromeController] Daemon auto-start failed, falling back to individual browser:', error.message);
|
|
674
|
+
// Now honor the failover manager's recommendation if auto-start also failed
|
|
675
|
+
if (shouldUseFallback) {
|
|
676
|
+
throw new Error('Failover manager recommends using individual browser after auto-start failed');
|
|
677
|
+
}
|
|
678
|
+
return null; // This will trigger individual browser fallback
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// Connect to the daemon's browser
|
|
683
|
+
this.browser = await puppeteer.connect({
|
|
684
|
+
browserURL: `http://localhost:${daemonPort}`,
|
|
685
|
+
defaultViewport: null
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
this.debugPort = daemonPort;
|
|
689
|
+
this.usingSharedBrowser = true;
|
|
690
|
+
|
|
691
|
+
// Initialize tab registry with existing tabs + robust tab tracking
|
|
692
|
+
await this.initializeTabRegistry();
|
|
693
|
+
|
|
694
|
+
// Create a new page for this session (isolated context)
|
|
695
|
+
this.page = await this.browser.newPage();
|
|
696
|
+
|
|
697
|
+
// Register the new page in our tab registry
|
|
698
|
+
await this.registerTab(this.page);
|
|
699
|
+
|
|
700
|
+
// Set up CDP session
|
|
701
|
+
this.client = await this.page.createCDPSession();
|
|
702
|
+
|
|
703
|
+
await Promise.all([
|
|
704
|
+
this.client.send('Debugger.enable'),
|
|
705
|
+
this.client.send('Runtime.enable'),
|
|
706
|
+
this.client.send('Console.enable')
|
|
707
|
+
]);
|
|
708
|
+
|
|
709
|
+
// Set up event listeners
|
|
710
|
+
this.setupCDPEventListeners();
|
|
711
|
+
|
|
712
|
+
// Notify failover manager of successful connection
|
|
713
|
+
await this.failoverManager.recordSuccessfulCheck(Date.now());
|
|
714
|
+
|
|
715
|
+
console.log(`[ChromeController] Successfully connected to shared browser daemon on port ${daemonPort}`);
|
|
716
|
+
|
|
717
|
+
return {
|
|
718
|
+
success: true,
|
|
719
|
+
message: `Connected to shared browser daemon on port ${daemonPort} (Session: ${this.sessionId})`,
|
|
720
|
+
browserWSEndpoint: this.browser.wsEndpoint(),
|
|
721
|
+
debugPort: this.debugPort,
|
|
722
|
+
sessionId: this.sessionId,
|
|
723
|
+
connectionType: 'shared-daemon',
|
|
724
|
+
usingSharedBrowser: true
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* Launches an individual browser instance (fallback method)
|
|
730
|
+
* @returns {Promise<Object>} Launch result
|
|
731
|
+
*/
|
|
732
|
+
async launchIndividualBrowser() {
|
|
733
|
+
console.log('[ChromeController] Launching individual browser instance...');
|
|
734
|
+
|
|
735
|
+
let sessionData = null;
|
|
736
|
+
|
|
737
|
+
try {
|
|
738
|
+
// Create session with UUID-based isolation - CORE SESSION ISOLATION FIX
|
|
739
|
+
sessionData = await this.sessionManager.createSession();
|
|
740
|
+
|
|
741
|
+
// Use session-managed debug port and profile path
|
|
742
|
+
this.debugPort = sessionData.debugPort;
|
|
743
|
+
this.userDataDir = sessionData.profilePath;
|
|
744
|
+
this.sessionId = sessionData.sessionId;
|
|
745
|
+
|
|
746
|
+
console.error(`Launching Chrome with debugging port ${this.debugPort}`);
|
|
747
|
+
console.error(`User data directory: ${this.userDataDir}`);
|
|
748
|
+
|
|
749
|
+
// Detect Chrome executable path
|
|
750
|
+
let executablePath;
|
|
751
|
+
const platform = process.platform;
|
|
752
|
+
|
|
753
|
+
if (platform === 'darwin') {
|
|
754
|
+
executablePath = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
|
755
|
+
} else if (platform === 'win32') {
|
|
756
|
+
executablePath = 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe';
|
|
757
|
+
const fs = await import('fs');
|
|
758
|
+
if (!fs.existsSync(executablePath)) {
|
|
759
|
+
executablePath = 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe';
|
|
760
|
+
}
|
|
761
|
+
} else {
|
|
762
|
+
executablePath = '/usr/bin/google-chrome';
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
console.error(`Using Chrome executable: ${executablePath}`);
|
|
766
|
+
|
|
767
|
+
// Get Chrome extension path with fallback logic
|
|
768
|
+
const extensionPath = getExtensionPath();
|
|
769
|
+
|
|
770
|
+
// Build launch args
|
|
771
|
+
const launchArgs = [
|
|
772
|
+
`--remote-debugging-port=${this.debugPort}`,
|
|
773
|
+
'--no-first-run',
|
|
774
|
+
'--no-default-browser-check',
|
|
775
|
+
`--user-data-dir=${this.userDataDir}`
|
|
776
|
+
];
|
|
777
|
+
|
|
778
|
+
// Add extension flags if extension found
|
|
779
|
+
if (extensionPath) {
|
|
780
|
+
launchArgs.push(
|
|
781
|
+
'--enable-extensions',
|
|
782
|
+
'--extensions-on-chrome-urls',
|
|
783
|
+
`--disable-extensions-except=${extensionPath}`,
|
|
784
|
+
`--load-extension=${extensionPath}`
|
|
785
|
+
);
|
|
786
|
+
} else {
|
|
787
|
+
console.warn('[ChromeController] ChromeDebug extension not found. Visual selection features will be unavailable.');
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// First, try to launch Chrome with a simpler configuration
|
|
791
|
+
try {
|
|
792
|
+
this.browser = await puppeteer.launch({
|
|
793
|
+
headless: false,
|
|
794
|
+
executablePath: executablePath,
|
|
795
|
+
args: launchArgs,
|
|
796
|
+
defaultViewport: null,
|
|
797
|
+
ignoreDefaultArgs: ['--disable-extensions', '--disable-default-apps', '--disable-component-extensions-with-background-pages'],
|
|
798
|
+
timeout: 60000
|
|
799
|
+
});
|
|
800
|
+
} catch (launchError) {
|
|
801
|
+
console.error('Simple launch failed, trying with additional flags...');
|
|
802
|
+
|
|
803
|
+
// Build fallback args with more flags
|
|
804
|
+
const fallbackArgs = [
|
|
805
|
+
`--remote-debugging-port=${this.debugPort}`,
|
|
806
|
+
'--no-first-run',
|
|
807
|
+
'--no-default-browser-check',
|
|
808
|
+
'--disable-features=VizDisplayCompositor',
|
|
809
|
+
`--user-data-dir=${this.userDataDir}`,
|
|
810
|
+
'--disable-setuid-sandbox',
|
|
811
|
+
'--no-sandbox',
|
|
812
|
+
'--disable-dev-shm-usage'
|
|
813
|
+
];
|
|
814
|
+
|
|
815
|
+
// Add extension flags if extension found
|
|
816
|
+
if (extensionPath) {
|
|
817
|
+
fallbackArgs.push(
|
|
818
|
+
'--enable-extensions',
|
|
819
|
+
'--extensions-on-chrome-urls',
|
|
820
|
+
`--disable-extensions-except=${extensionPath}`,
|
|
821
|
+
`--load-extension=${extensionPath}`
|
|
822
|
+
);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// If simple launch fails, try with more flags
|
|
826
|
+
this.browser = await puppeteer.launch({
|
|
827
|
+
headless: false,
|
|
828
|
+
executablePath: executablePath,
|
|
829
|
+
args: fallbackArgs,
|
|
830
|
+
defaultViewport: null,
|
|
831
|
+
ignoreDefaultArgs: ['--disable-extensions', '--disable-default-apps', '--disable-component-extensions-with-background-pages'],
|
|
832
|
+
timeout: 60000,
|
|
833
|
+
dumpio: true
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// Store the process ID for cleanup - UPDATE SESSION MANAGER
|
|
838
|
+
const browserProcess = this.browser.process();
|
|
839
|
+
this.processId = browserProcess ? browserProcess.pid : null;
|
|
840
|
+
|
|
841
|
+
// Update SessionManager with Chrome process ID
|
|
842
|
+
if (this.processId) {
|
|
843
|
+
this.sessionManager.setProcessId(this.processId);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// Set up disconnect handler
|
|
847
|
+
this.browser.on('disconnected', () => {
|
|
848
|
+
console.error('Browser disconnected');
|
|
849
|
+
this.browser = null;
|
|
850
|
+
this.page = null;
|
|
851
|
+
this.client = null;
|
|
852
|
+
this.processId = null;
|
|
853
|
+
this.clearTabRegistry(); // Clear registry on disconnect
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
// Initialize tab registry with existing tabs + robust tab tracking
|
|
857
|
+
await this.initializeTabRegistry();
|
|
858
|
+
|
|
859
|
+
// Get or create a page
|
|
860
|
+
const pages = await this.withTimeout(
|
|
861
|
+
this.browser.pages(),
|
|
862
|
+
this.defaultTimeout,
|
|
863
|
+
'get browser pages'
|
|
864
|
+
);
|
|
865
|
+
this.page = pages[0] || await this.withTimeout(
|
|
866
|
+
this.browser.newPage(),
|
|
867
|
+
this.defaultTimeout,
|
|
868
|
+
'create new page'
|
|
869
|
+
);
|
|
870
|
+
|
|
871
|
+
// Register the current page in our tab registry if not already registered
|
|
872
|
+
if (this.page) {
|
|
873
|
+
const target = this.page.target();
|
|
874
|
+
const targetId = target._targetId || target.targetInfo().targetId;
|
|
875
|
+
if (!this.tabRegistry.has(targetId)) {
|
|
876
|
+
await this.registerTab(this.page, targetId);
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// Set up CDP session
|
|
881
|
+
this.client = await this.withTimeout(
|
|
882
|
+
this.page.createCDPSession(),
|
|
883
|
+
this.defaultTimeout,
|
|
884
|
+
'create CDP session'
|
|
885
|
+
);
|
|
886
|
+
|
|
887
|
+
await this.withTimeout(
|
|
888
|
+
Promise.all([
|
|
889
|
+
this.client.send('Debugger.enable'),
|
|
890
|
+
this.client.send('Runtime.enable'),
|
|
891
|
+
this.client.send('Console.enable')
|
|
892
|
+
]),
|
|
893
|
+
this.defaultTimeout,
|
|
894
|
+
'enable CDP domains'
|
|
895
|
+
);
|
|
896
|
+
|
|
897
|
+
// Set up event listeners
|
|
898
|
+
this.setupCDPEventListeners();
|
|
899
|
+
|
|
900
|
+
// Get the WebSocket endpoint for future connections
|
|
901
|
+
const wsEndpoint = this.browser.wsEndpoint();
|
|
902
|
+
console.error(`Chrome WebSocket endpoint: ${wsEndpoint}`);
|
|
903
|
+
|
|
904
|
+
return {
|
|
905
|
+
success: true,
|
|
906
|
+
message: `Chrome launched successfully on port ${this.debugPort} (Session: ${this.sessionId})`,
|
|
907
|
+
browserWSEndpoint: wsEndpoint,
|
|
908
|
+
debugPort: this.debugPort,
|
|
909
|
+
processId: this.processId || 'unknown',
|
|
910
|
+
sessionId: this.sessionId,
|
|
911
|
+
profilePath: this.userDataDir,
|
|
912
|
+
connectionType: 'individual-browser',
|
|
913
|
+
usingSharedBrowser: false
|
|
914
|
+
};
|
|
915
|
+
} catch (error) {
|
|
916
|
+
// Clean up session if launch failed
|
|
917
|
+
if (sessionData) {
|
|
918
|
+
try {
|
|
919
|
+
await this.sessionManager.forceCleanupSession(sessionData.sessionId);
|
|
920
|
+
console.error(`Cleaned up session after launch failure: ${sessionData.sessionId}`);
|
|
921
|
+
} catch (cleanupError) {
|
|
922
|
+
console.error(`Failed to clean up session: ${sessionData.sessionId}`, cleanupError.message);
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
// Reset state
|
|
927
|
+
this.browser = null;
|
|
928
|
+
this.page = null;
|
|
929
|
+
this.client = null;
|
|
930
|
+
this.processId = null;
|
|
931
|
+
this.userDataDir = null;
|
|
932
|
+
|
|
933
|
+
throw new Error(`Failed to launch Chrome: ${error.message}`);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/**
|
|
938
|
+
* Sets up Chrome DevTools Protocol event listeners
|
|
939
|
+
*/
|
|
940
|
+
setupCDPEventListeners() {
|
|
941
|
+
if (!this.client) return;
|
|
942
|
+
|
|
943
|
+
this.client.on('Debugger.paused', () => {
|
|
944
|
+
this.paused = true;
|
|
945
|
+
});
|
|
946
|
+
|
|
947
|
+
this.client.on('Debugger.resumed', () => {
|
|
948
|
+
this.paused = false;
|
|
949
|
+
});
|
|
950
|
+
|
|
951
|
+
this.client.on('Console.messageAdded', (params) => {
|
|
952
|
+
this.logs.push({
|
|
953
|
+
level: params.message.level,
|
|
954
|
+
text: params.message.text,
|
|
955
|
+
timestamp: new Date().toISOString()
|
|
956
|
+
});
|
|
957
|
+
if (this.logs.length > 100) {
|
|
958
|
+
this.logs.shift();
|
|
959
|
+
}
|
|
960
|
+
});
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/**
|
|
964
|
+
* Auto-starts a shared browser daemon with race condition protection
|
|
965
|
+
* @returns {Promise<number>} The port the daemon is running on
|
|
966
|
+
*/
|
|
967
|
+
async autoStartDaemon() {
|
|
968
|
+
// Prevent concurrent daemon starts using class-level lock
|
|
969
|
+
if (ChromeController.daemonStartPromise) {
|
|
970
|
+
console.log('[ChromeController] Daemon start already in progress, waiting...');
|
|
971
|
+
return await ChromeController.daemonStartPromise;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
ChromeController.daemonStartPromise = this._doStartDaemon();
|
|
975
|
+
try {
|
|
976
|
+
const port = await ChromeController.daemonStartPromise;
|
|
977
|
+
return port;
|
|
978
|
+
} finally {
|
|
979
|
+
ChromeController.daemonStartPromise = null;
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
/**
|
|
984
|
+
* Internal method to actually start the daemon
|
|
985
|
+
* @returns {Promise<number>} The port the daemon is running on
|
|
986
|
+
*/
|
|
987
|
+
async _doStartDaemon() {
|
|
988
|
+
try {
|
|
989
|
+
const { BrowserDaemon } = await import('./services/browser-daemon.js');
|
|
990
|
+
this.sharedDaemon = new BrowserDaemon();
|
|
991
|
+
|
|
992
|
+
// Start daemon (it will find its own available port starting from 9222)
|
|
993
|
+
await this.sharedDaemon.start();
|
|
994
|
+
this.isDaemonOwner = true;
|
|
995
|
+
|
|
996
|
+
const daemonPort = this.sharedDaemon.config.daemonPort;
|
|
997
|
+
console.log(`[ChromeController] Started shared browser daemon on port ${daemonPort}`);
|
|
998
|
+
|
|
999
|
+
// Set up cleanup handlers for this specific daemon owner
|
|
1000
|
+
this.setupDaemonCleanup();
|
|
1001
|
+
|
|
1002
|
+
return daemonPort;
|
|
1003
|
+
} catch (error) {
|
|
1004
|
+
console.error('[ChromeController] Failed to start daemon:', error.message);
|
|
1005
|
+
this.sharedDaemon = null;
|
|
1006
|
+
this.isDaemonOwner = false;
|
|
1007
|
+
throw new Error(`Could not start shared browser daemon: ${error.message}`);
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
/**
|
|
1012
|
+
* Sets up cleanup handlers for auto-started daemon
|
|
1013
|
+
*/
|
|
1014
|
+
setupDaemonCleanup() {
|
|
1015
|
+
if (!this.isDaemonOwner) return;
|
|
1016
|
+
|
|
1017
|
+
const cleanupHandler = async () => {
|
|
1018
|
+
if (this.isDaemonOwner && this.sharedDaemon) {
|
|
1019
|
+
console.log('[ChromeController] Cleaning up auto-started daemon...');
|
|
1020
|
+
try {
|
|
1021
|
+
await this.sharedDaemon.gracefulShutdown();
|
|
1022
|
+
} catch (error) {
|
|
1023
|
+
console.error('[ChromeController] Error stopping daemon:', error.message);
|
|
1024
|
+
}
|
|
1025
|
+
this.sharedDaemon = null;
|
|
1026
|
+
this.isDaemonOwner = false;
|
|
1027
|
+
}
|
|
1028
|
+
};
|
|
1029
|
+
|
|
1030
|
+
// Register cleanup handlers
|
|
1031
|
+
process.once('exit', cleanupHandler);
|
|
1032
|
+
process.once('SIGINT', cleanupHandler);
|
|
1033
|
+
process.once('SIGTERM', cleanupHandler);
|
|
1034
|
+
process.once('uncaughtException', cleanupHandler);
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
async connectToExisting(port = 9222) {
|
|
1038
|
+
try {
|
|
1039
|
+
if (this.browser) {
|
|
1040
|
+
await this.close();
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
console.error(`Attempting to connect to Chrome on port ${port}...`);
|
|
1044
|
+
|
|
1045
|
+
// Try connecting with a timeout
|
|
1046
|
+
this.browser = await this.withTimeout(
|
|
1047
|
+
puppeteer.connect({
|
|
1048
|
+
browserURL: `http://localhost:${port}`,
|
|
1049
|
+
defaultViewport: null
|
|
1050
|
+
}),
|
|
1051
|
+
30000,
|
|
1052
|
+
`connect to Chrome on port ${port}`
|
|
1053
|
+
);
|
|
1054
|
+
|
|
1055
|
+
this.debugPort = port;
|
|
1056
|
+
console.error(`Successfully connected to existing Chrome on port ${port}`);
|
|
1057
|
+
|
|
1058
|
+
this.browser.on('disconnected', () => {
|
|
1059
|
+
console.error('Browser disconnected');
|
|
1060
|
+
this.browser = null;
|
|
1061
|
+
this.page = null;
|
|
1062
|
+
this.client = null;
|
|
1063
|
+
this.clearTabRegistry(); // Clear registry on disconnect
|
|
1064
|
+
});
|
|
1065
|
+
|
|
1066
|
+
// Initialize tab registry with existing tabs + robust tab tracking
|
|
1067
|
+
await this.initializeTabRegistry();
|
|
1068
|
+
|
|
1069
|
+
const pages = await this.withTimeout(
|
|
1070
|
+
this.browser.pages(),
|
|
1071
|
+
this.defaultTimeout,
|
|
1072
|
+
'get browser pages'
|
|
1073
|
+
);
|
|
1074
|
+
this.page = pages[0] || await this.withTimeout(
|
|
1075
|
+
this.browser.newPage(),
|
|
1076
|
+
this.defaultTimeout,
|
|
1077
|
+
'create new page'
|
|
1078
|
+
);
|
|
1079
|
+
|
|
1080
|
+
// Register the current page in our tab registry if not already registered
|
|
1081
|
+
if (this.page) {
|
|
1082
|
+
const target = this.page.target();
|
|
1083
|
+
const targetId = target._targetId || target.targetInfo().targetId;
|
|
1084
|
+
if (!this.tabRegistry.has(targetId)) {
|
|
1085
|
+
await this.registerTab(this.page, targetId);
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
this.client = await this.withTimeout(
|
|
1090
|
+
this.page.createCDPSession(),
|
|
1091
|
+
this.defaultTimeout,
|
|
1092
|
+
'create CDP session'
|
|
1093
|
+
);
|
|
1094
|
+
|
|
1095
|
+
await this.withTimeout(
|
|
1096
|
+
Promise.all([
|
|
1097
|
+
this.client.send('Debugger.enable'),
|
|
1098
|
+
this.client.send('Runtime.enable'),
|
|
1099
|
+
this.client.send('Console.enable')
|
|
1100
|
+
]),
|
|
1101
|
+
this.defaultTimeout,
|
|
1102
|
+
'enable CDP domains'
|
|
1103
|
+
);
|
|
1104
|
+
|
|
1105
|
+
this.client.on('Debugger.paused', () => {
|
|
1106
|
+
this.paused = true;
|
|
1107
|
+
});
|
|
1108
|
+
|
|
1109
|
+
this.client.on('Debugger.resumed', () => {
|
|
1110
|
+
this.paused = false;
|
|
1111
|
+
});
|
|
1112
|
+
|
|
1113
|
+
this.client.on('Console.messageAdded', (params) => {
|
|
1114
|
+
this.logs.push({
|
|
1115
|
+
level: params.message.level,
|
|
1116
|
+
text: params.message.text,
|
|
1117
|
+
timestamp: new Date().toISOString()
|
|
1118
|
+
});
|
|
1119
|
+
if (this.logs.length > 100) {
|
|
1120
|
+
this.logs.shift();
|
|
1121
|
+
}
|
|
1122
|
+
});
|
|
1123
|
+
|
|
1124
|
+
return {
|
|
1125
|
+
success: true,
|
|
1126
|
+
message: `Connected to existing Chrome instance on port ${port}`,
|
|
1127
|
+
browserWSEndpoint: this.browser.wsEndpoint(),
|
|
1128
|
+
debugPort: this.debugPort
|
|
1129
|
+
};
|
|
1130
|
+
} catch (error) {
|
|
1131
|
+
throw new Error(`Failed to connect to Chrome on port ${port}: ${error.message}`);
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
async isConnected() {
|
|
1136
|
+
if (!this.browser) return false;
|
|
1137
|
+
try {
|
|
1138
|
+
await this.withTimeout(
|
|
1139
|
+
this.browser.version(),
|
|
1140
|
+
5000,
|
|
1141
|
+
'browser version check'
|
|
1142
|
+
);
|
|
1143
|
+
return true;
|
|
1144
|
+
} catch (e) {
|
|
1145
|
+
return false;
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
async getConnectionStatus() {
|
|
1150
|
+
const connected = await this.isConnected();
|
|
1151
|
+
return {
|
|
1152
|
+
connected,
|
|
1153
|
+
debugPort: this.debugPort,
|
|
1154
|
+
browserWSEndpoint: this.browser?.wsEndpoint() || null
|
|
1155
|
+
};
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
async ensureConnected() {
|
|
1159
|
+
if (!await this.isConnected()) {
|
|
1160
|
+
throw new Error('Chrome not connected. Please launch Chrome first using launch_chrome.');
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
async navigateTo(url) {
|
|
1165
|
+
await this.ensureConnected();
|
|
1166
|
+
|
|
1167
|
+
// Use robust tab tracking to ensure we have a valid page
|
|
1168
|
+
const currentPage = await this.ensureValidPageReference();
|
|
1169
|
+
if (!currentPage) throw new Error('No valid page available');
|
|
1170
|
+
|
|
1171
|
+
try {
|
|
1172
|
+
await this.withTimeout(
|
|
1173
|
+
currentPage.goto(url, { waitUntil: 'domcontentloaded' }),
|
|
1174
|
+
30000,
|
|
1175
|
+
`navigate to ${url}`
|
|
1176
|
+
);
|
|
1177
|
+
|
|
1178
|
+
// Update tab registry with new URL
|
|
1179
|
+
if (this.activeTargetId) {
|
|
1180
|
+
const tabData = this.tabRegistry.get(this.activeTargetId);
|
|
1181
|
+
if (tabData) {
|
|
1182
|
+
tabData.url = url;
|
|
1183
|
+
tabData.title = await currentPage.title().catch(() => 'Loading...');
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
return { status: 'success', url };
|
|
1188
|
+
} catch (error) {
|
|
1189
|
+
throw new Error(`Failed to navigate to ${url}: ${error.message}`);
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
async pause() {
|
|
1194
|
+
await this.ensureConnected();
|
|
1195
|
+
if (!this.client) throw new Error('Chrome not connected');
|
|
1196
|
+
await this.withTimeout(
|
|
1197
|
+
this.client.send('Debugger.pause'),
|
|
1198
|
+
this.defaultTimeout,
|
|
1199
|
+
'pause execution'
|
|
1200
|
+
);
|
|
1201
|
+
return { status: 'paused' };
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
async resume() {
|
|
1205
|
+
await this.ensureConnected();
|
|
1206
|
+
if (!this.client) throw new Error('Chrome not connected');
|
|
1207
|
+
await this.withTimeout(
|
|
1208
|
+
this.client.send('Debugger.resume'),
|
|
1209
|
+
this.defaultTimeout,
|
|
1210
|
+
'resume execution'
|
|
1211
|
+
);
|
|
1212
|
+
return { status: 'resumed' };
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
async stepOver() {
|
|
1216
|
+
await this.ensureConnected();
|
|
1217
|
+
if (!this.client) throw new Error('Chrome not connected');
|
|
1218
|
+
await this.withTimeout(
|
|
1219
|
+
this.client.send('Debugger.stepOver'),
|
|
1220
|
+
this.defaultTimeout,
|
|
1221
|
+
'step over'
|
|
1222
|
+
);
|
|
1223
|
+
return { status: 'stepped' };
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
async evaluate(expression) {
|
|
1227
|
+
await this.ensureConnected();
|
|
1228
|
+
if (!this.client) throw new Error('Chrome not connected');
|
|
1229
|
+
|
|
1230
|
+
try {
|
|
1231
|
+
const result = await this.withTimeout(
|
|
1232
|
+
this.client.send('Runtime.evaluate', {
|
|
1233
|
+
expression: expression,
|
|
1234
|
+
returnByValue: true,
|
|
1235
|
+
generatePreview: true
|
|
1236
|
+
}),
|
|
1237
|
+
this.defaultTimeout,
|
|
1238
|
+
'JavaScript evaluation'
|
|
1239
|
+
);
|
|
1240
|
+
|
|
1241
|
+
if (result.exceptionDetails) {
|
|
1242
|
+
return {
|
|
1243
|
+
error: true,
|
|
1244
|
+
message: result.exceptionDetails.text || 'Evaluation error',
|
|
1245
|
+
exception: result.exceptionDetails
|
|
1246
|
+
};
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
return {
|
|
1250
|
+
result: result.result.value,
|
|
1251
|
+
type: result.result.type,
|
|
1252
|
+
className: result.result.className
|
|
1253
|
+
};
|
|
1254
|
+
} catch (error) {
|
|
1255
|
+
return {
|
|
1256
|
+
error: true,
|
|
1257
|
+
message: error.message
|
|
1258
|
+
};
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
async getScopes() {
|
|
1263
|
+
await this.ensureConnected();
|
|
1264
|
+
if (!this.client) throw new Error('Chrome not connected');
|
|
1265
|
+
|
|
1266
|
+
try {
|
|
1267
|
+
const { callFrames } = await this.client.send('Debugger.getStackTrace');
|
|
1268
|
+
|
|
1269
|
+
if (!callFrames || callFrames.length === 0) {
|
|
1270
|
+
return { scopes: [], message: 'No active call frames' };
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
const topFrame = callFrames[0];
|
|
1274
|
+
const scopes = [];
|
|
1275
|
+
|
|
1276
|
+
for (const scope of topFrame.scopeChain) {
|
|
1277
|
+
const scopeInfo = {
|
|
1278
|
+
type: scope.type,
|
|
1279
|
+
name: scope.name,
|
|
1280
|
+
variables: {}
|
|
1281
|
+
};
|
|
1282
|
+
|
|
1283
|
+
if (scope.object && scope.object.objectId) {
|
|
1284
|
+
const { result } = await this.client.send('Runtime.getProperties', {
|
|
1285
|
+
objectId: scope.object.objectId,
|
|
1286
|
+
ownProperties: true
|
|
1287
|
+
});
|
|
1288
|
+
|
|
1289
|
+
for (const prop of result) {
|
|
1290
|
+
if (prop.value) {
|
|
1291
|
+
scopeInfo.variables[prop.name] = {
|
|
1292
|
+
value: prop.value.value,
|
|
1293
|
+
type: prop.value.type
|
|
1294
|
+
};
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
scopes.push(scopeInfo);
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
return { scopes };
|
|
1303
|
+
} catch (error) {
|
|
1304
|
+
return {
|
|
1305
|
+
error: true,
|
|
1306
|
+
message: error.message || 'Failed to get scopes'
|
|
1307
|
+
};
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
async setBreakpoint(url, lineNumber) {
|
|
1312
|
+
await this.ensureConnected();
|
|
1313
|
+
if (!this.client) throw new Error('Chrome not connected');
|
|
1314
|
+
|
|
1315
|
+
try {
|
|
1316
|
+
const result = await this.withTimeout(
|
|
1317
|
+
this.client.send('Debugger.setBreakpointByUrl', {
|
|
1318
|
+
url: url,
|
|
1319
|
+
lineNumber: lineNumber
|
|
1320
|
+
}),
|
|
1321
|
+
this.defaultTimeout,
|
|
1322
|
+
'set breakpoint'
|
|
1323
|
+
);
|
|
1324
|
+
|
|
1325
|
+
return {
|
|
1326
|
+
breakpointId: result.breakpointId,
|
|
1327
|
+
locations: result.locations
|
|
1328
|
+
};
|
|
1329
|
+
} catch (error) {
|
|
1330
|
+
return {
|
|
1331
|
+
error: true,
|
|
1332
|
+
message: error.message
|
|
1333
|
+
};
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
getLogs() {
|
|
1338
|
+
return { logs: this.logs };
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
async takeScreenshot(options = {}) {
|
|
1342
|
+
await this.ensureConnected();
|
|
1343
|
+
|
|
1344
|
+
// Use robust tab tracking to ensure we have a valid page
|
|
1345
|
+
const currentPage = await this.ensureValidPageReference();
|
|
1346
|
+
if (!currentPage) throw new Error('Chrome not connected');
|
|
1347
|
+
|
|
1348
|
+
try {
|
|
1349
|
+
const screenshotOptions = {
|
|
1350
|
+
type: options.type || 'jpeg',
|
|
1351
|
+
fullPage: options.fullPage !== false
|
|
1352
|
+
};
|
|
1353
|
+
|
|
1354
|
+
// Always use lowRes mode unless explicitly disabled
|
|
1355
|
+
if (options.lowRes !== false) {
|
|
1356
|
+
const viewport = currentPage.viewport();
|
|
1357
|
+
|
|
1358
|
+
if (screenshotOptions.type === 'jpeg' || screenshotOptions.type === 'jpg') {
|
|
1359
|
+
screenshotOptions.quality = options.quality || 30;
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
let originalViewport = null;
|
|
1363
|
+
const targetWidth = 320;
|
|
1364
|
+
const targetHeight = 568;
|
|
1365
|
+
|
|
1366
|
+
if (viewport) {
|
|
1367
|
+
originalViewport = viewport;
|
|
1368
|
+
await currentPage.setViewport({
|
|
1369
|
+
width: targetWidth,
|
|
1370
|
+
height: targetHeight
|
|
1371
|
+
});
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
if (screenshotOptions.fullPage) {
|
|
1375
|
+
await currentPage.evaluate(() => {
|
|
1376
|
+
const maxHeight = 600;
|
|
1377
|
+
document.documentElement.style.maxHeight = `${maxHeight}px`;
|
|
1378
|
+
document.documentElement.style.overflow = 'hidden';
|
|
1379
|
+
document.body.style.maxHeight = `${maxHeight}px`;
|
|
1380
|
+
document.body.style.overflow = 'hidden';
|
|
1381
|
+
});
|
|
1382
|
+
|
|
1383
|
+
screenshotOptions.fullPage = false;
|
|
1384
|
+
screenshotOptions.clip = {
|
|
1385
|
+
x: 0,
|
|
1386
|
+
y: 0,
|
|
1387
|
+
width: targetWidth,
|
|
1388
|
+
height: Math.min(targetHeight, 600)
|
|
1389
|
+
};
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
screenshotOptions.encoding = 'base64';
|
|
1393
|
+
const screenshot = await this.withTimeout(
|
|
1394
|
+
currentPage.screenshot(screenshotOptions),
|
|
1395
|
+
30000,
|
|
1396
|
+
'take screenshot'
|
|
1397
|
+
);
|
|
1398
|
+
|
|
1399
|
+
// Restore page state
|
|
1400
|
+
if (options.fullPage !== false) {
|
|
1401
|
+
await currentPage.evaluate(() => {
|
|
1402
|
+
document.documentElement.style.maxHeight = '';
|
|
1403
|
+
document.documentElement.style.overflow = '';
|
|
1404
|
+
document.body.style.maxHeight = '';
|
|
1405
|
+
document.body.style.overflow = '';
|
|
1406
|
+
});
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
if (originalViewport) {
|
|
1410
|
+
await currentPage.setViewport(originalViewport);
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
const sizeInBytes = Math.ceil(screenshot.length * 3/4);
|
|
1414
|
+
const sizeInKB = Math.round(sizeInBytes / 1024);
|
|
1415
|
+
|
|
1416
|
+
if (sizeInKB > 50) {
|
|
1417
|
+
return {
|
|
1418
|
+
error: true,
|
|
1419
|
+
message: `Screenshot too large for AI processing (${sizeInKB}KB). Maximum recommended size is 50KB.`,
|
|
1420
|
+
size: `${sizeInKB}KB`
|
|
1421
|
+
};
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
return {
|
|
1425
|
+
screenshot: screenshot,
|
|
1426
|
+
size: `${sizeInKB}KB`,
|
|
1427
|
+
type: screenshotOptions.type,
|
|
1428
|
+
fullPage: screenshotOptions.fullPage,
|
|
1429
|
+
lowRes: true,
|
|
1430
|
+
quality: screenshotOptions.quality
|
|
1431
|
+
};
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
// File save or regular screenshot
|
|
1435
|
+
if (options.path) {
|
|
1436
|
+
screenshotOptions.path = options.path;
|
|
1437
|
+
if (options.quality && (screenshotOptions.type === 'jpeg' || screenshotOptions.type === 'jpg')) {
|
|
1438
|
+
screenshotOptions.quality = options.quality || 80;
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
await this.withTimeout(
|
|
1442
|
+
currentPage.screenshot(screenshotOptions),
|
|
1443
|
+
30000,
|
|
1444
|
+
'take screenshot'
|
|
1445
|
+
);
|
|
1446
|
+
|
|
1447
|
+
return {
|
|
1448
|
+
saved: true,
|
|
1449
|
+
path: options.path,
|
|
1450
|
+
type: screenshotOptions.type,
|
|
1451
|
+
fullPage: screenshotOptions.fullPage
|
|
1452
|
+
};
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
screenshotOptions.encoding = 'base64';
|
|
1456
|
+
if (options.quality && (screenshotOptions.type === 'jpeg' || screenshotOptions.type === 'jpg')) {
|
|
1457
|
+
screenshotOptions.quality = options.quality || 80;
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
const screenshot = await this.withTimeout(
|
|
1461
|
+
currentPage.screenshot(screenshotOptions),
|
|
1462
|
+
30000,
|
|
1463
|
+
'take screenshot'
|
|
1464
|
+
);
|
|
1465
|
+
|
|
1466
|
+
const sizeInBytes = Math.ceil(screenshot.length * 3/4);
|
|
1467
|
+
const sizeInKB = Math.round(sizeInBytes / 1024);
|
|
1468
|
+
|
|
1469
|
+
if (sizeInKB > 100) {
|
|
1470
|
+
return {
|
|
1471
|
+
screenshot: screenshot.substring(0, 1000) + '... [truncated]',
|
|
1472
|
+
size: `${sizeInKB}KB`,
|
|
1473
|
+
type: screenshotOptions.type,
|
|
1474
|
+
fullPage: screenshotOptions.fullPage,
|
|
1475
|
+
truncated: true,
|
|
1476
|
+
message: 'Screenshot too large. Use path parameter to save to file or lowRes: true for AI parsing.'
|
|
1477
|
+
};
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
return {
|
|
1481
|
+
screenshot: screenshot,
|
|
1482
|
+
size: `${sizeInKB}KB`,
|
|
1483
|
+
type: screenshotOptions.type,
|
|
1484
|
+
fullPage: screenshotOptions.fullPage
|
|
1485
|
+
};
|
|
1486
|
+
} catch (error) {
|
|
1487
|
+
return {
|
|
1488
|
+
error: true,
|
|
1489
|
+
message: error.message
|
|
1490
|
+
};
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
async getPageContent(options = {}) {
|
|
1495
|
+
await this.ensureConnected();
|
|
1496
|
+
|
|
1497
|
+
// Use robust tab tracking to ensure we have a valid page
|
|
1498
|
+
const currentPage = await this.ensureValidPageReference();
|
|
1499
|
+
if (!currentPage) throw new Error('Chrome not connected');
|
|
1500
|
+
|
|
1501
|
+
try {
|
|
1502
|
+
const includeText = options.includeText !== false;
|
|
1503
|
+
const includeHtml = options.includeHtml === true;
|
|
1504
|
+
const includeStructure = options.includeStructure !== false;
|
|
1505
|
+
|
|
1506
|
+
const result = await this.withTimeout(
|
|
1507
|
+
currentPage.evaluate(() => {
|
|
1508
|
+
const extractTextContent = (element) => {
|
|
1509
|
+
// Skip script and style tags
|
|
1510
|
+
if (element.tagName === 'SCRIPT' || element.tagName === 'STYLE') {
|
|
1511
|
+
return '';
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
let text = '';
|
|
1515
|
+
for (const node of element.childNodes) {
|
|
1516
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
1517
|
+
text += node.textContent.trim() + ' ';
|
|
1518
|
+
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
|
1519
|
+
text += extractTextContent(node) + ' ';
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
return text.trim();
|
|
1523
|
+
};
|
|
1524
|
+
|
|
1525
|
+
const extractStructure = (element, depth = 0, maxDepth = 10) => {
|
|
1526
|
+
if (depth > maxDepth) return null;
|
|
1527
|
+
|
|
1528
|
+
// Skip script and style tags in structure
|
|
1529
|
+
if (element.tagName === 'SCRIPT' || element.tagName === 'STYLE') {
|
|
1530
|
+
return null;
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
const structure = {
|
|
1534
|
+
tag: element.tagName.toLowerCase(),
|
|
1535
|
+
attributes: {}
|
|
1536
|
+
};
|
|
1537
|
+
|
|
1538
|
+
// Include important attributes
|
|
1539
|
+
const importantAttrs = ['id', 'class', 'href', 'src', 'alt', 'title', 'type', 'name', 'value', 'placeholder'];
|
|
1540
|
+
for (const attr of importantAttrs) {
|
|
1541
|
+
if (element.getAttribute(attr)) {
|
|
1542
|
+
structure.attributes[attr] = element.getAttribute(attr);
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
// Get text content for leaf nodes or important elements
|
|
1547
|
+
const importantTextElements = ['H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'P', 'A', 'BUTTON', 'LABEL', 'SPAN', 'DIV', 'LI', 'TD', 'TH'];
|
|
1548
|
+
if (importantTextElements.includes(element.tagName) || element.childElementCount === 0) {
|
|
1549
|
+
const text = element.textContent.trim();
|
|
1550
|
+
if (text && text.length > 0) {
|
|
1551
|
+
structure.text = text.length > 200 ? text.substring(0, 200) + '...' : text;
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
// Get children
|
|
1556
|
+
if (element.childElementCount > 0) {
|
|
1557
|
+
structure.children = [];
|
|
1558
|
+
for (const child of element.children) {
|
|
1559
|
+
const childStructure = extractStructure(child, depth + 1, maxDepth);
|
|
1560
|
+
if (childStructure) {
|
|
1561
|
+
structure.children.push(childStructure);
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
if (structure.children.length === 0) {
|
|
1565
|
+
delete structure.children;
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
return structure;
|
|
1570
|
+
};
|
|
1571
|
+
|
|
1572
|
+
const pageInfo = {
|
|
1573
|
+
url: window.location.href,
|
|
1574
|
+
title: document.title,
|
|
1575
|
+
meta: {}
|
|
1576
|
+
};
|
|
1577
|
+
|
|
1578
|
+
// Extract meta tags
|
|
1579
|
+
const metaTags = document.querySelectorAll('meta');
|
|
1580
|
+
metaTags.forEach(meta => {
|
|
1581
|
+
const name = meta.getAttribute('name') || meta.getAttribute('property');
|
|
1582
|
+
const content = meta.getAttribute('content');
|
|
1583
|
+
if (name && content) {
|
|
1584
|
+
pageInfo.meta[name] = content;
|
|
1585
|
+
}
|
|
1586
|
+
});
|
|
1587
|
+
|
|
1588
|
+
// Extract text content
|
|
1589
|
+
const textContent = extractTextContent(document.body);
|
|
1590
|
+
|
|
1591
|
+
// Extract HTML
|
|
1592
|
+
const htmlContent = document.documentElement.outerHTML;
|
|
1593
|
+
|
|
1594
|
+
// Extract structure
|
|
1595
|
+
const structure = extractStructure(document.body);
|
|
1596
|
+
|
|
1597
|
+
return {
|
|
1598
|
+
pageInfo,
|
|
1599
|
+
textContent,
|
|
1600
|
+
htmlContent,
|
|
1601
|
+
structure,
|
|
1602
|
+
// Additional useful info
|
|
1603
|
+
forms: document.forms.length,
|
|
1604
|
+
images: document.images.length,
|
|
1605
|
+
links: document.links.length,
|
|
1606
|
+
scripts: document.scripts.length
|
|
1607
|
+
};
|
|
1608
|
+
}),
|
|
1609
|
+
30000,
|
|
1610
|
+
'get page content'
|
|
1611
|
+
);
|
|
1612
|
+
|
|
1613
|
+
// Build response based on options
|
|
1614
|
+
const response = {
|
|
1615
|
+
url: result.pageInfo.url,
|
|
1616
|
+
title: result.pageInfo.title,
|
|
1617
|
+
meta: result.pageInfo.meta,
|
|
1618
|
+
statistics: {
|
|
1619
|
+
forms: result.forms,
|
|
1620
|
+
images: result.images,
|
|
1621
|
+
links: result.links,
|
|
1622
|
+
scripts: result.scripts
|
|
1623
|
+
}
|
|
1624
|
+
};
|
|
1625
|
+
|
|
1626
|
+
if (includeText) {
|
|
1627
|
+
response.text = result.textContent;
|
|
1628
|
+
response.textLength = result.textContent.length;
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
if (includeHtml) {
|
|
1632
|
+
response.html = result.htmlContent;
|
|
1633
|
+
response.htmlLength = result.htmlContent.length;
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
if (includeStructure) {
|
|
1637
|
+
response.structure = result.structure;
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
return response;
|
|
1641
|
+
} catch (error) {
|
|
1642
|
+
return {
|
|
1643
|
+
error: true,
|
|
1644
|
+
message: error.message
|
|
1645
|
+
};
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
async forceReset() {
|
|
1650
|
+
console.error('Force resetting Chrome...');
|
|
1651
|
+
|
|
1652
|
+
await this.killOwnChromeProcess();
|
|
1653
|
+
|
|
1654
|
+
// Clear tab registry before other cleanup
|
|
1655
|
+
this.clearTabRegistry();
|
|
1656
|
+
|
|
1657
|
+
// Use SessionManager for force cleanup - PHASE 1 SESSION ISOLATION
|
|
1658
|
+
if (this.sessionManager && this.sessionId) {
|
|
1659
|
+
try {
|
|
1660
|
+
await this.sessionManager.forceCleanupSession(this.sessionId);
|
|
1661
|
+
console.log(`[ChromeController] Force cleanup completed for session ${this.sessionId}`);
|
|
1662
|
+
} catch (error) {
|
|
1663
|
+
console.error('[ChromeController] Error during force cleanup:', error.message);
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
this.browser = null;
|
|
1668
|
+
this.page = null;
|
|
1669
|
+
this.client = null;
|
|
1670
|
+
this.paused = false;
|
|
1671
|
+
this.logs = [];
|
|
1672
|
+
this.selectedElement = null;
|
|
1673
|
+
this.processId = null;
|
|
1674
|
+
this.userDataDir = null;
|
|
1675
|
+
this.sessionId = null;
|
|
1676
|
+
this.targetEventListenersSetup = false; // Reset event listener state
|
|
1677
|
+
|
|
1678
|
+
return { status: 'reset complete' };
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
async close() {
|
|
1682
|
+
// Clear tab registry first
|
|
1683
|
+
this.clearTabRegistry();
|
|
1684
|
+
|
|
1685
|
+
if (this.browser) {
|
|
1686
|
+
try {
|
|
1687
|
+
await this.browser.close();
|
|
1688
|
+
} catch (e) {
|
|
1689
|
+
console.error('Error closing browser:', e.message);
|
|
1690
|
+
} finally {
|
|
1691
|
+
this.browser = null;
|
|
1692
|
+
this.page = null;
|
|
1693
|
+
this.client = null;
|
|
1694
|
+
this.processId = null;
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
// Use SessionManager for cleanup - PHASE 1 SESSION ISOLATION
|
|
1699
|
+
if (this.sessionManager) {
|
|
1700
|
+
try {
|
|
1701
|
+
await this.sessionManager.cleanup();
|
|
1702
|
+
console.log(`[ChromeController] Session cleanup completed for ${this.sessionId}`);
|
|
1703
|
+
} catch (error) {
|
|
1704
|
+
console.error('[ChromeController] Error during session cleanup:', error.message);
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
// Reset session-related state
|
|
1709
|
+
this.userDataDir = null;
|
|
1710
|
+
this.sessionId = null;
|
|
1711
|
+
this.targetEventListenersSetup = false; // Reset event listener state
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
/**
|
|
1715
|
+
* Alias for close() method for backward compatibility with tests
|
|
1716
|
+
*/
|
|
1717
|
+
async cleanup() {
|
|
1718
|
+
// Clean up auto-started daemon first
|
|
1719
|
+
if (this.isDaemonOwner && this.sharedDaemon) {
|
|
1720
|
+
console.log('[ChromeController] Cleaning up auto-started daemon during cleanup...');
|
|
1721
|
+
try {
|
|
1722
|
+
await this.sharedDaemon.gracefulShutdown();
|
|
1723
|
+
} catch (error) {
|
|
1724
|
+
console.error('[ChromeController] Error stopping daemon during cleanup:', error.message);
|
|
1725
|
+
}
|
|
1726
|
+
this.sharedDaemon = null;
|
|
1727
|
+
this.isDaemonOwner = false;
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
// Existing cleanup code...
|
|
1731
|
+
return this.close();
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
// Element selection methods
|
|
1735
|
+
async selectElement(selector, instruction) {
|
|
1736
|
+
await this.ensureConnected();
|
|
1737
|
+
if (!this.client) throw new Error('Chrome not connected');
|
|
1738
|
+
|
|
1739
|
+
// Use robust tab tracking to ensure we have a valid page
|
|
1740
|
+
const currentPage = await this.ensureValidPageReference();
|
|
1741
|
+
if (!currentPage) throw new Error('Chrome not connected');
|
|
1742
|
+
|
|
1743
|
+
try {
|
|
1744
|
+
if (!selector || typeof selector !== 'string') {
|
|
1745
|
+
throw new Error('Invalid selector provided');
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
const elementData = await currentPage.evaluate((sel) => {
|
|
1749
|
+
try {
|
|
1750
|
+
const element = document.querySelector(sel);
|
|
1751
|
+
if (!element) {
|
|
1752
|
+
return { error: `Element not found: ${sel}` };
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
// Highlight the selected element
|
|
1756
|
+
element.style.outline = '3px solid #E91E63';
|
|
1757
|
+
element.style.outlineOffset = '2px';
|
|
1758
|
+
|
|
1759
|
+
const computedStyles = window.getComputedStyle(element);
|
|
1760
|
+
const relevantStyles = {};
|
|
1761
|
+
|
|
1762
|
+
const styleProps = [
|
|
1763
|
+
'width', 'height', 'padding', 'margin', 'backgroundColor',
|
|
1764
|
+
'color', 'fontSize', 'fontWeight', 'border', 'borderRadius',
|
|
1765
|
+
'display', 'position', 'top', 'left', 'right', 'bottom',
|
|
1766
|
+
'transform', 'opacity', 'boxShadow', 'textAlign'
|
|
1767
|
+
];
|
|
1768
|
+
|
|
1769
|
+
styleProps.forEach(prop => {
|
|
1770
|
+
relevantStyles[prop] = computedStyles[prop];
|
|
1771
|
+
});
|
|
1772
|
+
|
|
1773
|
+
return {
|
|
1774
|
+
outerHTML: element.outerHTML,
|
|
1775
|
+
tagName: element.tagName.toLowerCase(),
|
|
1776
|
+
id: element.id,
|
|
1777
|
+
className: element.className,
|
|
1778
|
+
computedStyles: relevantStyles,
|
|
1779
|
+
boundingBox: element.getBoundingClientRect(),
|
|
1780
|
+
textContent: element.textContent.substring(0, 100)
|
|
1781
|
+
};
|
|
1782
|
+
} catch (error) {
|
|
1783
|
+
return { error: error.message };
|
|
1784
|
+
}
|
|
1785
|
+
}, selector);
|
|
1786
|
+
|
|
1787
|
+
if (elementData.error) {
|
|
1788
|
+
throw new Error(elementData.error);
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
this.selectedElement = {
|
|
1792
|
+
selector,
|
|
1793
|
+
instruction: instruction || '',
|
|
1794
|
+
elementData,
|
|
1795
|
+
timestamp: new Date().toISOString()
|
|
1796
|
+
};
|
|
1797
|
+
|
|
1798
|
+
this.logs.push({
|
|
1799
|
+
timestamp: new Date().toISOString(),
|
|
1800
|
+
type: 'element-selected',
|
|
1801
|
+
message: `Selected: ${selector} - ${elementData.tagName}${elementData.id ? '#' + elementData.id : ''}`
|
|
1802
|
+
});
|
|
1803
|
+
|
|
1804
|
+
return {
|
|
1805
|
+
success: true,
|
|
1806
|
+
message: 'Element selected and ready for MCP commands',
|
|
1807
|
+
element: {
|
|
1808
|
+
selector,
|
|
1809
|
+
tagName: elementData.tagName,
|
|
1810
|
+
id: elementData.id,
|
|
1811
|
+
classes: elementData.className,
|
|
1812
|
+
size: `${elementData.boundingBox.width}px × ${elementData.boundingBox.height}px`,
|
|
1813
|
+
instruction: instruction || 'No instruction provided'
|
|
1814
|
+
}
|
|
1815
|
+
};
|
|
1816
|
+
} catch (error) {
|
|
1817
|
+
this.logs.push({
|
|
1818
|
+
timestamp: new Date().toISOString(),
|
|
1819
|
+
type: 'selection-error',
|
|
1820
|
+
message: error.message
|
|
1821
|
+
});
|
|
1822
|
+
|
|
1823
|
+
return {
|
|
1824
|
+
success: false,
|
|
1825
|
+
error: error.message
|
|
1826
|
+
};
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
async getSelectedElement() {
|
|
1831
|
+
if (!this.selectedElement) {
|
|
1832
|
+
return {
|
|
1833
|
+
selected: false,
|
|
1834
|
+
message: 'No element currently selected'
|
|
1835
|
+
};
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
return {
|
|
1839
|
+
selected: true,
|
|
1840
|
+
...this.selectedElement
|
|
1841
|
+
};
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
async applyToSelectedElement(cssRules) {
|
|
1845
|
+
if (!this.selectedElement) {
|
|
1846
|
+
throw new Error('No element currently selected');
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
await this.ensureConnected();
|
|
1850
|
+
|
|
1851
|
+
// Use robust tab tracking to ensure we have a valid page
|
|
1852
|
+
const currentPage = await this.ensureValidPageReference();
|
|
1853
|
+
if (!currentPage) throw new Error('Chrome not connected');
|
|
1854
|
+
|
|
1855
|
+
const { selector } = this.selectedElement;
|
|
1856
|
+
|
|
1857
|
+
try {
|
|
1858
|
+
const injectionResult = await currentPage.evaluate((sel, cssRules) => {
|
|
1859
|
+
try {
|
|
1860
|
+
let styleEl = document.getElementById('chromedebug-styles');
|
|
1861
|
+
if (!styleEl) {
|
|
1862
|
+
styleEl = document.createElement('style');
|
|
1863
|
+
styleEl.id = 'chromedebug-styles';
|
|
1864
|
+
document.head.appendChild(styleEl);
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
styleEl.innerHTML += `\n/* ChromeDebug MCP - ${new Date().toISOString()} */\n${sel} { ${cssRules} }\n`;
|
|
1868
|
+
|
|
1869
|
+
return { success: true, message: 'Styles applied successfully' };
|
|
1870
|
+
} catch (error) {
|
|
1871
|
+
return { success: false, error: error.message };
|
|
1872
|
+
}
|
|
1873
|
+
}, selector, cssRules);
|
|
1874
|
+
|
|
1875
|
+
if (!injectionResult.success) {
|
|
1876
|
+
throw new Error(injectionResult.error);
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
this.logs.push({
|
|
1880
|
+
timestamp: new Date().toISOString(),
|
|
1881
|
+
type: 'css-applied',
|
|
1882
|
+
message: `Applied to ${selector}: ${cssRules}`
|
|
1883
|
+
});
|
|
1884
|
+
|
|
1885
|
+
return {
|
|
1886
|
+
success: true,
|
|
1887
|
+
applied: {
|
|
1888
|
+
selector,
|
|
1889
|
+
css: cssRules
|
|
1890
|
+
}
|
|
1891
|
+
};
|
|
1892
|
+
} catch (error) {
|
|
1893
|
+
return {
|
|
1894
|
+
success: false,
|
|
1895
|
+
error: error.message
|
|
1896
|
+
};
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
async executeOnSelectedElement(jsCode) {
|
|
1901
|
+
if (!this.selectedElement) {
|
|
1902
|
+
throw new Error('No element currently selected');
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
await this.ensureConnected();
|
|
1906
|
+
|
|
1907
|
+
// Use robust tab tracking to ensure we have a valid page
|
|
1908
|
+
const currentPage = await this.ensureValidPageReference();
|
|
1909
|
+
if (!currentPage) throw new Error('Chrome not connected');
|
|
1910
|
+
|
|
1911
|
+
const { selector } = this.selectedElement;
|
|
1912
|
+
const fixedSelector = this.fixSelector(selector);
|
|
1913
|
+
|
|
1914
|
+
try {
|
|
1915
|
+
const result = await currentPage.evaluate((sel, code) => {
|
|
1916
|
+
try {
|
|
1917
|
+
const element = document.querySelector(sel);
|
|
1918
|
+
if (!element) {
|
|
1919
|
+
throw new Error(`Element not found: ${sel}`);
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
const fn = new Function('element', code);
|
|
1923
|
+
return {
|
|
1924
|
+
success: true,
|
|
1925
|
+
result: fn(element)
|
|
1926
|
+
};
|
|
1927
|
+
} catch (error) {
|
|
1928
|
+
return {
|
|
1929
|
+
success: false,
|
|
1930
|
+
error: error.message
|
|
1931
|
+
};
|
|
1932
|
+
}
|
|
1933
|
+
}, fixedSelector, jsCode);
|
|
1934
|
+
|
|
1935
|
+
if (!result.success) {
|
|
1936
|
+
throw new Error(result.error);
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
return {
|
|
1940
|
+
success: true,
|
|
1941
|
+
result: result.result
|
|
1942
|
+
};
|
|
1943
|
+
} catch (error) {
|
|
1944
|
+
return {
|
|
1945
|
+
success: false,
|
|
1946
|
+
error: error.message
|
|
1947
|
+
};
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
async clearSelectedElement() {
|
|
1952
|
+
if (!this.selectedElement) {
|
|
1953
|
+
return {
|
|
1954
|
+
success: true,
|
|
1955
|
+
message: 'No element was selected'
|
|
1956
|
+
};
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
// Use robust tab tracking to clear element highlight
|
|
1960
|
+
const currentPage = await this.ensureValidPageReference();
|
|
1961
|
+
if (currentPage) {
|
|
1962
|
+
await currentPage.evaluate((sel) => {
|
|
1963
|
+
const element = document.querySelector(sel);
|
|
1964
|
+
if (element) {
|
|
1965
|
+
element.style.outline = '';
|
|
1966
|
+
element.style.outlineOffset = '';
|
|
1967
|
+
}
|
|
1968
|
+
}, this.selectedElement.selector).catch(() => {});
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
this.selectedElement = null;
|
|
1972
|
+
|
|
1973
|
+
return {
|
|
1974
|
+
success: true,
|
|
1975
|
+
message: 'Selected element cleared'
|
|
1976
|
+
};
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1979
|
+
// Workflow recording methods
|
|
1980
|
+
async storeWorkflowRecording(sessionId, url, title, includeLogs, actions, logs, name = null, screenshotSettings = null, functionTraces = null) {
|
|
1981
|
+
try {
|
|
1982
|
+
const { database } = await import('./database.js');
|
|
1983
|
+
|
|
1984
|
+
// Store the workflow recording
|
|
1985
|
+
const workflowId = database.storeWorkflowRecording(sessionId, url, title, includeLogs, name, screenshotSettings);
|
|
1986
|
+
|
|
1987
|
+
// Merge function traces into actions if provided
|
|
1988
|
+
let allActions = [...actions];
|
|
1989
|
+
if (functionTraces && functionTraces.length > 0) {
|
|
1990
|
+
console.log(`[ChromeController] Merging ${functionTraces.length} function traces into actions`);
|
|
1991
|
+
// Function traces are already in the correct format from the Chrome extension
|
|
1992
|
+
allActions = [...actions, ...functionTraces];
|
|
1993
|
+
// Sort all actions by timestamp to maintain chronological order
|
|
1994
|
+
allActions.sort((a, b) => a.timestamp - b.timestamp);
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
// Store actions with proper indexing
|
|
1998
|
+
const indexedActions = allActions.map((action, index) => ({
|
|
1999
|
+
...action,
|
|
2000
|
+
index: index
|
|
2001
|
+
}));
|
|
2002
|
+
|
|
2003
|
+
const actionsResult = database.storeWorkflowActions(workflowId, indexedActions);
|
|
2004
|
+
|
|
2005
|
+
// Store logs if provided
|
|
2006
|
+
if (includeLogs && logs && logs.length > 0) {
|
|
2007
|
+
const logsResult = database.storeWorkflowLogs(workflowId, logs);
|
|
2008
|
+
console.log(`Stored ${logsResult.storedCount} logs for workflow ${sessionId}`);
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
console.log(`Stored workflow recording ${sessionId} with ${actionsResult.storedCount} actions (including ${functionTraces ? functionTraces.length : 0} function traces)`);
|
|
2012
|
+
|
|
2013
|
+
return {
|
|
2014
|
+
success: true,
|
|
2015
|
+
sessionId: sessionId,
|
|
2016
|
+
workflowId: workflowId,
|
|
2017
|
+
actionsStored: actionsResult.storedCount,
|
|
2018
|
+
logsStored: (includeLogs && logs) ? logs.length : 0,
|
|
2019
|
+
functionTracesStored: functionTraces ? functionTraces.length : 0
|
|
2020
|
+
};
|
|
2021
|
+
} catch (error) {
|
|
2022
|
+
console.error('Error storing workflow recording:', error);
|
|
2023
|
+
throw error;
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
async getWorkflowRecording(sessionId) {
|
|
2028
|
+
try {
|
|
2029
|
+
const { database } = await import('./database.js');
|
|
2030
|
+
return database.getWorkflowRecording(sessionId);
|
|
2031
|
+
} catch (error) {
|
|
2032
|
+
console.error('Error retrieving workflow recording:', error);
|
|
2033
|
+
throw error;
|
|
2034
|
+
}
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
async listWorkflowRecordings() {
|
|
2038
|
+
try {
|
|
2039
|
+
const { database } = await import('./database.js');
|
|
2040
|
+
const recordings = database.listWorkflowRecordings();
|
|
2041
|
+
return { recordings };
|
|
2042
|
+
} catch (error) {
|
|
2043
|
+
console.error('Error listing workflow recordings:', error);
|
|
2044
|
+
throw error;
|
|
2045
|
+
}
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
async playWorkflowRecording(sessionId, speed = 1) {
|
|
2049
|
+
try {
|
|
2050
|
+
const { database } = await import('./database.js');
|
|
2051
|
+
const recording = database.getWorkflowRecording(sessionId);
|
|
2052
|
+
|
|
2053
|
+
if (!recording || recording.error) {
|
|
2054
|
+
throw new Error(`Workflow recording not found: ${sessionId}`);
|
|
2055
|
+
}
|
|
2056
|
+
|
|
2057
|
+
// For now, just return the recording data
|
|
2058
|
+
// In the future, this could actually replay the actions
|
|
2059
|
+
return {
|
|
2060
|
+
success: true,
|
|
2061
|
+
message: `Workflow recording ${sessionId} loaded successfully`,
|
|
2062
|
+
recording: recording
|
|
2063
|
+
};
|
|
2064
|
+
} catch (error) {
|
|
2065
|
+
console.error('Error playing workflow recording:', error);
|
|
2066
|
+
throw error;
|
|
2067
|
+
}
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2070
|
+
async playWorkflowByName(name, speed = 1) {
|
|
2071
|
+
try {
|
|
2072
|
+
const { database } = await import('./database.js');
|
|
2073
|
+
const recordings = database.listWorkflowRecordings();
|
|
2074
|
+
|
|
2075
|
+
// Find recording by name
|
|
2076
|
+
const recording = recordings.find(r => r.name === name);
|
|
2077
|
+
|
|
2078
|
+
if (!recording) {
|
|
2079
|
+
throw new Error(`Workflow recording with name "${name}" not found`);
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
// Use the session ID to get the full recording
|
|
2083
|
+
const fullRecording = database.getWorkflowRecording(recording.session_id);
|
|
2084
|
+
|
|
2085
|
+
if (!fullRecording || fullRecording.error) {
|
|
2086
|
+
throw new Error(`Could not load workflow recording: ${name}`);
|
|
2087
|
+
}
|
|
2088
|
+
|
|
2089
|
+
// For now, just return the recording data
|
|
2090
|
+
// In the future, this could actually replay the actions
|
|
2091
|
+
return {
|
|
2092
|
+
success: true,
|
|
2093
|
+
message: `Workflow recording "${name}" loaded successfully`,
|
|
2094
|
+
recording: fullRecording
|
|
2095
|
+
};
|
|
2096
|
+
} catch (error) {
|
|
2097
|
+
console.error('Error playing workflow by name:', error);
|
|
2098
|
+
throw error;
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2102
|
+
// Save restore point
|
|
2103
|
+
async saveRestorePoint(restorePointData) {
|
|
2104
|
+
try {
|
|
2105
|
+
const { database } = await import('./database.js');
|
|
2106
|
+
const result = database.storeRestorePoint(
|
|
2107
|
+
restorePointData.workflowId,
|
|
2108
|
+
restorePointData.actionIndex,
|
|
2109
|
+
restorePointData
|
|
2110
|
+
);
|
|
2111
|
+
return result;
|
|
2112
|
+
} catch (error) {
|
|
2113
|
+
console.error('Error saving restore point:', error);
|
|
2114
|
+
throw error;
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
|
|
2118
|
+
// Get restore point
|
|
2119
|
+
async getRestorePoint(restorePointId) {
|
|
2120
|
+
try {
|
|
2121
|
+
const { database } = await import('./database.js');
|
|
2122
|
+
return database.getRestorePoint(restorePointId);
|
|
2123
|
+
} catch (error) {
|
|
2124
|
+
console.error('Error getting restore point:', error);
|
|
2125
|
+
throw error;
|
|
2126
|
+
}
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
// List restore points for a workflow
|
|
2130
|
+
async listRestorePoints(workflowId) {
|
|
2131
|
+
try {
|
|
2132
|
+
const { database } = await import('./database.js');
|
|
2133
|
+
return database.listRestorePoints(workflowId);
|
|
2134
|
+
} catch (error) {
|
|
2135
|
+
console.error('Error listing restore points:', error);
|
|
2136
|
+
throw error;
|
|
2137
|
+
}
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2140
|
+
// Delete restore point
|
|
2141
|
+
async deleteRestorePoint(restorePointId) {
|
|
2142
|
+
try {
|
|
2143
|
+
const { database } = await import('./database.js');
|
|
2144
|
+
database.init();
|
|
2145
|
+
const deleteStmt = database.db.prepare('DELETE FROM restore_points WHERE id = ?');
|
|
2146
|
+
const result = deleteStmt.run(restorePointId);
|
|
2147
|
+
return { success: result.changes > 0 };
|
|
2148
|
+
} catch (error) {
|
|
2149
|
+
console.error('Error deleting restore point:', error);
|
|
2150
|
+
throw error;
|
|
2151
|
+
}
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2154
|
+
// Frame recording methods
|
|
2155
|
+
async getFrameSessionInfo(sessionId) {
|
|
2156
|
+
try {
|
|
2157
|
+
const { database } = await import('./database.js');
|
|
2158
|
+
return database.getFrameSessionInfo(sessionId);
|
|
2159
|
+
} catch (error) {
|
|
2160
|
+
console.error('Error getting frame session info:', error);
|
|
2161
|
+
throw error;
|
|
2162
|
+
}
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
async getFrame(sessionId, frameIndex) {
|
|
2166
|
+
try {
|
|
2167
|
+
const { database } = await import('./database.js');
|
|
2168
|
+
return database.getFrame(sessionId, frameIndex);
|
|
2169
|
+
} catch (error) {
|
|
2170
|
+
console.error('Error getting frame:', error);
|
|
2171
|
+
throw error;
|
|
2172
|
+
}
|
|
2173
|
+
}
|
|
2174
|
+
|
|
2175
|
+
async getFrameScreenshot(sessionId, frameIndex, includeMetadata = false) {
|
|
2176
|
+
try {
|
|
2177
|
+
const { database } = await import('./database.js');
|
|
2178
|
+
return database.getFrameScreenshot(sessionId, frameIndex, includeMetadata);
|
|
2179
|
+
} catch (error) {
|
|
2180
|
+
console.error('Error getting frame screenshot:', error);
|
|
2181
|
+
throw error;
|
|
2182
|
+
}
|
|
2183
|
+
}
|
|
2184
|
+
|
|
2185
|
+
async getScreenInteractions(sessionId) {
|
|
2186
|
+
try {
|
|
2187
|
+
const { database } = await import('./database.js');
|
|
2188
|
+
return database.getScreenInteractions(sessionId);
|
|
2189
|
+
} catch (error) {
|
|
2190
|
+
console.error('Error retrieving screen interactions:', error);
|
|
2191
|
+
throw error;
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
async getFrameInteractions(sessionId, frameIndex) {
|
|
2196
|
+
try {
|
|
2197
|
+
const { database } = await import('./database.js');
|
|
2198
|
+
return database.getFrameInteractions(sessionId, frameIndex);
|
|
2199
|
+
} catch (error) {
|
|
2200
|
+
console.error('Error retrieving frame interactions:', error);
|
|
2201
|
+
throw error;
|
|
2202
|
+
}
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2205
|
+
async getFrameSession(sessionId) {
|
|
2206
|
+
try {
|
|
2207
|
+
const { database } = await import('./database.js');
|
|
2208
|
+
return database.getFrameSession(sessionId);
|
|
2209
|
+
} catch (error) {
|
|
2210
|
+
console.error('Error getting frame session:', error);
|
|
2211
|
+
throw error;
|
|
2212
|
+
}
|
|
2213
|
+
}
|
|
2214
|
+
|
|
2215
|
+
async storeFrameBatch(sessionId, frames, sessionName = null) {
|
|
2216
|
+
try {
|
|
2217
|
+
const { database } = await import('./database.js');
|
|
2218
|
+
const result = await database.storeFrameBatch(sessionId, frames, sessionName);
|
|
2219
|
+
|
|
2220
|
+
// After storing frames, attempt to associate any deferred logs
|
|
2221
|
+
if (result && result.id) {
|
|
2222
|
+
console.log(`[storeFrameBatch] Frames stored successfully, checking for deferred logs...`);
|
|
2223
|
+
try {
|
|
2224
|
+
const deferredResult = await database.associateDeferredLogs(sessionId);
|
|
2225
|
+
if (deferredResult.success && deferredResult.logsAssociated > 0) {
|
|
2226
|
+
console.log(`[storeFrameBatch] Associated ${deferredResult.logsAssociated} deferred logs with new frames`);
|
|
2227
|
+
}
|
|
2228
|
+
} catch (deferredError) {
|
|
2229
|
+
console.error('[storeFrameBatch] Error associating deferred logs:', deferredError);
|
|
2230
|
+
// Don't fail the frame storage if deferred log association fails
|
|
2231
|
+
}
|
|
2232
|
+
}
|
|
2233
|
+
|
|
2234
|
+
return result;
|
|
2235
|
+
} catch (error) {
|
|
2236
|
+
console.error('Error storing frame batch:', error);
|
|
2237
|
+
throw error;
|
|
2238
|
+
}
|
|
2239
|
+
}
|
|
2240
|
+
|
|
2241
|
+
async associateLogsWithFrames(sessionId, logs) {
|
|
2242
|
+
try {
|
|
2243
|
+
const { database } = await import('./database.js');
|
|
2244
|
+
return database.associateLogsWithFrames(sessionId, logs);
|
|
2245
|
+
} catch (error) {
|
|
2246
|
+
console.error('Error associating logs with frames:', error);
|
|
2247
|
+
throw error;
|
|
2248
|
+
}
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2251
|
+
async streamLogsToFrames(sessionId, logs) {
|
|
2252
|
+
try {
|
|
2253
|
+
const { database } = await import('./database.js');
|
|
2254
|
+
return database.streamLogsToFrames(sessionId, logs);
|
|
2255
|
+
} catch (error) {
|
|
2256
|
+
console.error('Error streaming logs to frames:', error);
|
|
2257
|
+
throw error;
|
|
2258
|
+
}
|
|
2259
|
+
}
|
|
2260
|
+
|
|
2261
|
+
/*
|
|
2262
|
+
* SNAPSHOT FEATURE DISABLED (2025-10-01)
|
|
2263
|
+
*
|
|
2264
|
+
* Snapshot methods disabled - see SNAPSHOT_FEATURE_DISABLED.md
|
|
2265
|
+
*
|
|
2266
|
+
* WHY DISABLED:
|
|
2267
|
+
* - Snapshots without console logs are just screenshots (users can do this natively)
|
|
2268
|
+
* - Console log capture requires always-on monitoring (privacy concern)
|
|
2269
|
+
* - Core value proposition (screenshot + searchable logs) cannot be achieved cleanly
|
|
2270
|
+
*
|
|
2271
|
+
* TO RE-ENABLE:
|
|
2272
|
+
* 1. Implement privacy-conscious always-on log monitoring
|
|
2273
|
+
* 2. Uncomment these methods
|
|
2274
|
+
* 3. Re-enable database methods in database.js
|
|
2275
|
+
* 4. Re-enable HTTP endpoint in http-server.js
|
|
2276
|
+
* 5. Re-enable extension UI and handlers
|
|
2277
|
+
*/
|
|
2278
|
+
/*
|
|
2279
|
+
async storeSnapshot(sessionId, frameData, userNote = null) {
|
|
2280
|
+
try {
|
|
2281
|
+
const { database } = await import('./database.js');
|
|
2282
|
+
return database.storeSnapshot(sessionId, frameData, userNote);
|
|
2283
|
+
} catch (error) {
|
|
2284
|
+
console.error('Error storing snapshot:', error);
|
|
2285
|
+
throw error;
|
|
2286
|
+
}
|
|
2287
|
+
}
|
|
2288
|
+
|
|
2289
|
+
async getSnapshots(limit = 50) {
|
|
2290
|
+
try {
|
|
2291
|
+
const { database } = await import('./database.js');
|
|
2292
|
+
return database.getSnapshots(limit);
|
|
2293
|
+
} catch (error) {
|
|
2294
|
+
console.error('Error getting snapshots:', error);
|
|
2295
|
+
throw error;
|
|
2296
|
+
}
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2299
|
+
async takeSnapshot(userNote = null) {
|
|
2300
|
+
try {
|
|
2301
|
+
// Ensure browser is connected
|
|
2302
|
+
if (!this.page) {
|
|
2303
|
+
throw new Error('No active page - please navigate to a URL first');
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2306
|
+
// Take screenshot
|
|
2307
|
+
const screenshot = await this.takeScreenshot(true, true, 30);
|
|
2308
|
+
|
|
2309
|
+
// Get current logs
|
|
2310
|
+
const logs = await this.getLogs();
|
|
2311
|
+
|
|
2312
|
+
// Generate unique session ID for the snapshot
|
|
2313
|
+
const sessionId = `snapshot_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
2314
|
+
|
|
2315
|
+
// Create frame data structure
|
|
2316
|
+
const frameData = {
|
|
2317
|
+
timestamp: Date.now(),
|
|
2318
|
+
absoluteTimestamp: Date.now(),
|
|
2319
|
+
imageData: screenshot.imageData,
|
|
2320
|
+
logs: logs.logs || [],
|
|
2321
|
+
frameIndex: 0,
|
|
2322
|
+
index: 0
|
|
2323
|
+
};
|
|
2324
|
+
|
|
2325
|
+
// Store snapshot
|
|
2326
|
+
const result = await this.storeSnapshot(sessionId, frameData, userNote);
|
|
2327
|
+
|
|
2328
|
+
return {
|
|
2329
|
+
success: true,
|
|
2330
|
+
sessionId: result,
|
|
2331
|
+
timestamp: frameData.timestamp,
|
|
2332
|
+
userNote: userNote,
|
|
2333
|
+
logCount: frameData.logs.length,
|
|
2334
|
+
message: `Snapshot created successfully with ID: ${result}`
|
|
2335
|
+
};
|
|
2336
|
+
} catch (error) {
|
|
2337
|
+
console.error('Error taking snapshot:', error);
|
|
2338
|
+
throw error;
|
|
2339
|
+
}
|
|
2340
|
+
}
|
|
2341
|
+
*/
|
|
2342
|
+
|
|
2343
|
+
async listFrameSessions() {
|
|
2344
|
+
try {
|
|
2345
|
+
const { database } = await import('./database.js');
|
|
2346
|
+
return database.listRecordings();
|
|
2347
|
+
} catch (error) {
|
|
2348
|
+
console.error('Error listing frame sessions:', error);
|
|
2349
|
+
throw error;
|
|
2350
|
+
}
|
|
2351
|
+
}
|
|
2352
|
+
|
|
2353
|
+
async searchFrameLogs(sessionId, searchText, logLevel = 'all', maxResults = 50) {
|
|
2354
|
+
try {
|
|
2355
|
+
const { database } = await import('./database.js');
|
|
2356
|
+
const recording = database.getRecording(sessionId);
|
|
2357
|
+
if (!recording) {
|
|
2358
|
+
throw new Error('Recording not found');
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2361
|
+
const searchLower = searchText.toLowerCase();
|
|
2362
|
+
|
|
2363
|
+
// Build query to search logs across all frames
|
|
2364
|
+
let query = `
|
|
2365
|
+
SELECT
|
|
2366
|
+
f.frame_index,
|
|
2367
|
+
cl.level,
|
|
2368
|
+
cl.message,
|
|
2369
|
+
f.timestamp
|
|
2370
|
+
FROM console_logs cl
|
|
2371
|
+
JOIN frames f ON cl.frame_id = f.id
|
|
2372
|
+
WHERE f.recording_id = ?
|
|
2373
|
+
AND LOWER(cl.message) LIKE ?
|
|
2374
|
+
`;
|
|
2375
|
+
|
|
2376
|
+
const params = [recording.id, `%${searchLower}%`];
|
|
2377
|
+
|
|
2378
|
+
if (logLevel !== 'all') {
|
|
2379
|
+
query += ' AND cl.level = ?';
|
|
2380
|
+
params.push(logLevel);
|
|
2381
|
+
}
|
|
2382
|
+
|
|
2383
|
+
query += ' ORDER BY f.frame_index ASC, cl.relative_time ASC LIMIT ?';
|
|
2384
|
+
params.push(maxResults);
|
|
2385
|
+
|
|
2386
|
+
const stmt = database.db.prepare(query);
|
|
2387
|
+
const logs = stmt.all(...params);
|
|
2388
|
+
|
|
2389
|
+
return logs.map(log => ({
|
|
2390
|
+
frameIndex: log.frame_index,
|
|
2391
|
+
level: log.level,
|
|
2392
|
+
message: log.message,
|
|
2393
|
+
timestamp: log.timestamp
|
|
2394
|
+
}));
|
|
2395
|
+
} catch (error) {
|
|
2396
|
+
console.error('Error searching frame logs:', error);
|
|
2397
|
+
throw error;
|
|
2398
|
+
}
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2401
|
+
async getFrameLogsPaginated(sessionId, frameIndex, offset = 0, limit = 100, logLevel = 'all', searchText = null) {
|
|
2402
|
+
try {
|
|
2403
|
+
const { database } = await import('./database.js');
|
|
2404
|
+
const recording = database.getRecording(sessionId);
|
|
2405
|
+
if (!recording) {
|
|
2406
|
+
throw new Error('Recording not found');
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
// Get frame ID
|
|
2410
|
+
const frameStmt = database.db.prepare(`
|
|
2411
|
+
SELECT id FROM frames
|
|
2412
|
+
WHERE recording_id = ? AND frame_index = ?
|
|
2413
|
+
`);
|
|
2414
|
+
const frame = frameStmt.get(recording.id, frameIndex);
|
|
2415
|
+
|
|
2416
|
+
if (!frame) {
|
|
2417
|
+
throw new Error('Frame not found');
|
|
2418
|
+
}
|
|
2419
|
+
|
|
2420
|
+
// Build count query
|
|
2421
|
+
let countQuery = 'SELECT COUNT(*) as total FROM console_logs WHERE frame_id = ?';
|
|
2422
|
+
const countParams = [frame.id];
|
|
2423
|
+
|
|
2424
|
+
if (logLevel !== 'all') {
|
|
2425
|
+
countQuery += ' AND level = ?';
|
|
2426
|
+
countParams.push(logLevel);
|
|
2427
|
+
}
|
|
2428
|
+
|
|
2429
|
+
if (searchText) {
|
|
2430
|
+
countQuery += ' AND LOWER(message) LIKE ?';
|
|
2431
|
+
countParams.push(`%${searchText.toLowerCase()}%`);
|
|
2432
|
+
}
|
|
2433
|
+
|
|
2434
|
+
const countStmt = database.db.prepare(countQuery);
|
|
2435
|
+
const { total } = countStmt.get(...countParams);
|
|
2436
|
+
|
|
2437
|
+
// Build logs query
|
|
2438
|
+
let logsQuery = `
|
|
2439
|
+
SELECT level, message, relative_time
|
|
2440
|
+
FROM console_logs
|
|
2441
|
+
WHERE frame_id = ?
|
|
2442
|
+
`;
|
|
2443
|
+
const logsParams = [frame.id];
|
|
2444
|
+
|
|
2445
|
+
if (logLevel !== 'all') {
|
|
2446
|
+
logsQuery += ' AND level = ?';
|
|
2447
|
+
logsParams.push(logLevel);
|
|
2448
|
+
}
|
|
2449
|
+
|
|
2450
|
+
if (searchText) {
|
|
2451
|
+
logsQuery += ' AND LOWER(message) LIKE ?';
|
|
2452
|
+
logsParams.push(`%${searchText.toLowerCase()}%`);
|
|
2453
|
+
}
|
|
2454
|
+
|
|
2455
|
+
logsQuery += ' ORDER BY relative_time ASC LIMIT ? OFFSET ?';
|
|
2456
|
+
logsParams.push(limit, offset);
|
|
2457
|
+
|
|
2458
|
+
const logsStmt = database.db.prepare(logsQuery);
|
|
2459
|
+
const logs = logsStmt.all(...logsParams);
|
|
2460
|
+
|
|
2461
|
+
return {
|
|
2462
|
+
total,
|
|
2463
|
+
logs
|
|
2464
|
+
};
|
|
2465
|
+
} catch (error) {
|
|
2466
|
+
console.error('Error getting paginated frame logs:', error);
|
|
2467
|
+
throw error;
|
|
2468
|
+
}
|
|
2469
|
+
}
|
|
2470
|
+
|
|
2471
|
+
async deleteRecording(recordingId) {
|
|
2472
|
+
try {
|
|
2473
|
+
const { database } = await import('./database.js');
|
|
2474
|
+
const result = database.deleteRecording(recordingId);
|
|
2475
|
+
if (result) {
|
|
2476
|
+
return { success: true, message: 'Recording deleted successfully' };
|
|
2477
|
+
} else {
|
|
2478
|
+
return { success: false, error: 'Recording not found' };
|
|
2479
|
+
}
|
|
2480
|
+
} catch (error) {
|
|
2481
|
+
console.error('Error deleting recording:', error);
|
|
2482
|
+
throw error;
|
|
2483
|
+
}
|
|
2484
|
+
}
|
|
2485
|
+
|
|
2486
|
+
async deleteWorkflowRecording(recordingId) {
|
|
2487
|
+
try {
|
|
2488
|
+
const { database } = await import('./database.js');
|
|
2489
|
+
const result = database.deleteWorkflowRecording(recordingId);
|
|
2490
|
+
if (result) {
|
|
2491
|
+
return { success: true, message: 'Workflow recording deleted successfully' };
|
|
2492
|
+
} else {
|
|
2493
|
+
return { success: false, error: 'Workflow recording not found' };
|
|
2494
|
+
}
|
|
2495
|
+
} catch (error) {
|
|
2496
|
+
console.error('Error deleting workflow recording:', error);
|
|
2497
|
+
throw error;
|
|
2498
|
+
}
|
|
2499
|
+
}
|
|
2500
|
+
|
|
2501
|
+
// Helper function to fix CSS selectors by removing pseudo-classes and Tailwind responsive/state prefixes
|
|
2502
|
+
fixSelector(selector) {
|
|
2503
|
+
if (!selector || typeof selector !== 'string') return selector;
|
|
2504
|
+
|
|
2505
|
+
let fixedSelector = selector;
|
|
2506
|
+
|
|
2507
|
+
// Remove standard pseudo-classes like :hover, :focus, :active that can't be used in querySelector
|
|
2508
|
+
fixedSelector = fixedSelector.replace(/:(hover|focus|active|visited|focus-within|focus-visible|disabled)/g, '');
|
|
2509
|
+
|
|
2510
|
+
// Remove Tailwind CSS responsive and state prefixes (e.g., hover:, sm:, md:, lg:, disabled:)
|
|
2511
|
+
fixedSelector = fixedSelector.replace(/\.(hover|focus|active|disabled|sm|md|lg|xl|2xl)\\?:/g, '.');
|
|
2512
|
+
|
|
2513
|
+
// Handle both escaped and unescaped Tailwind prefixes
|
|
2514
|
+
fixedSelector = fixedSelector.replace(/\.(hover|focus|active|disabled|sm|md|lg|xl|2xl):/g, '.');
|
|
2515
|
+
|
|
2516
|
+
// Remove any trailing dots
|
|
2517
|
+
fixedSelector = fixedSelector.replace(/\.+$/, '');
|
|
2518
|
+
|
|
2519
|
+
// Remove empty class selectors (consecutive dots)
|
|
2520
|
+
fixedSelector = fixedSelector.replace(/\.\./g, '.');
|
|
2521
|
+
|
|
2522
|
+
// Clean up any remaining malformed parts
|
|
2523
|
+
fixedSelector = fixedSelector.replace(/\\:/g, ':'); // Unescape any remaining colons
|
|
2524
|
+
|
|
2525
|
+
return fixedSelector;
|
|
2526
|
+
}
|
|
2527
|
+
|
|
2528
|
+
async playWorkflow(workflow, actions, speed = 1) {
|
|
2529
|
+
try {
|
|
2530
|
+
// Use robust tab tracking to ensure we have a valid page
|
|
2531
|
+
const currentPage = await this.ensureValidPageReference();
|
|
2532
|
+
if (!currentPage) {
|
|
2533
|
+
throw new Error('No page available. Please navigate to a URL first.');
|
|
2534
|
+
}
|
|
2535
|
+
|
|
2536
|
+
const startTime = Date.now();
|
|
2537
|
+
let executedActions = 0;
|
|
2538
|
+
let lastError = null;
|
|
2539
|
+
|
|
2540
|
+
// Navigate to the initial URL if different from current
|
|
2541
|
+
if (workflow.url) {
|
|
2542
|
+
const currentUrl = currentPage.url();
|
|
2543
|
+
if (currentUrl !== workflow.url) {
|
|
2544
|
+
console.log(`Navigating to workflow URL: ${workflow.url}`);
|
|
2545
|
+
await currentPage.goto(workflow.url, { waitUntil: 'networkidle2' });
|
|
2546
|
+
await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for page to settle
|
|
2547
|
+
}
|
|
2548
|
+
}
|
|
2549
|
+
|
|
2550
|
+
// Execute each action
|
|
2551
|
+
for (const action of actions) {
|
|
2552
|
+
try {
|
|
2553
|
+
console.log(`Executing action ${executedActions + 1}/${actions.length}: ${action.type}`);
|
|
2554
|
+
|
|
2555
|
+
// Calculate delay based on speed
|
|
2556
|
+
const delay = action.timestamp && actions.indexOf(action) > 0
|
|
2557
|
+
? (action.timestamp - actions[actions.indexOf(action) - 1].timestamp) / speed
|
|
2558
|
+
: 500 / speed;
|
|
2559
|
+
|
|
2560
|
+
// Wait between actions
|
|
2561
|
+
if (delay > 0) {
|
|
2562
|
+
await new Promise(resolve => setTimeout(resolve, Math.min(delay, 5000))); // Cap at 5 seconds
|
|
2563
|
+
}
|
|
2564
|
+
|
|
2565
|
+
switch (action.type) {
|
|
2566
|
+
case 'click':
|
|
2567
|
+
if (action.selector) {
|
|
2568
|
+
const fixedSelector = this.fixSelector(action.selector);
|
|
2569
|
+
console.log(`Fixed selector: ${fixedSelector} (original: ${action.selector})`);
|
|
2570
|
+
|
|
2571
|
+
try {
|
|
2572
|
+
await currentPage.waitForSelector(fixedSelector, { timeout: 5000 });
|
|
2573
|
+
await currentPage.click(fixedSelector);
|
|
2574
|
+
} catch (selectorError) {
|
|
2575
|
+
console.error(`Failed with CSS selector, trying coordinates fallback`);
|
|
2576
|
+
if (action.x && action.y) {
|
|
2577
|
+
await currentPage.mouse.click(action.x, action.y);
|
|
2578
|
+
} else {
|
|
2579
|
+
throw selectorError;
|
|
2580
|
+
}
|
|
2581
|
+
}
|
|
2582
|
+
} else if (action.x && action.y) {
|
|
2583
|
+
await currentPage.mouse.click(action.x, action.y);
|
|
2584
|
+
}
|
|
2585
|
+
break;
|
|
2586
|
+
|
|
2587
|
+
case 'input':
|
|
2588
|
+
if (action.selector) {
|
|
2589
|
+
const fixedSelector = this.fixSelector(action.selector);
|
|
2590
|
+
console.log(`Fixed input selector: ${fixedSelector} (original: ${action.selector})`);
|
|
2591
|
+
|
|
2592
|
+
await currentPage.waitForSelector(fixedSelector, { timeout: 5000 });
|
|
2593
|
+
await currentPage.click(fixedSelector); // Focus the element
|
|
2594
|
+
|
|
2595
|
+
// Clear existing content
|
|
2596
|
+
await currentPage.evaluate((selector) => {
|
|
2597
|
+
const element = document.querySelector(selector);
|
|
2598
|
+
if (element) {
|
|
2599
|
+
element.value = '';
|
|
2600
|
+
}
|
|
2601
|
+
}, fixedSelector);
|
|
2602
|
+
|
|
2603
|
+
// Type new value
|
|
2604
|
+
if (action.value) {
|
|
2605
|
+
await currentPage.type(fixedSelector, action.value);
|
|
2606
|
+
}
|
|
2607
|
+
}
|
|
2608
|
+
break;
|
|
2609
|
+
|
|
2610
|
+
case 'keypress':
|
|
2611
|
+
if (action.key) {
|
|
2612
|
+
await currentPage.keyboard.press(action.key);
|
|
2613
|
+
}
|
|
2614
|
+
break;
|
|
2615
|
+
|
|
2616
|
+
case 'scroll':
|
|
2617
|
+
if (action.deltaY !== undefined) {
|
|
2618
|
+
await currentPage.evaluate((deltaY) => {
|
|
2619
|
+
window.scrollBy(0, deltaY);
|
|
2620
|
+
}, action.deltaY);
|
|
2621
|
+
}
|
|
2622
|
+
break;
|
|
2623
|
+
|
|
2624
|
+
case 'navigation':
|
|
2625
|
+
// Wait for navigation to complete
|
|
2626
|
+
await currentPage.waitForNavigation({ waitUntil: 'networkidle2', timeout: 10000 }).catch(() => {
|
|
2627
|
+
// Navigation might not happen, continue
|
|
2628
|
+
console.log('Navigation timeout, continuing...');
|
|
2629
|
+
});
|
|
2630
|
+
break;
|
|
2631
|
+
|
|
2632
|
+
default:
|
|
2633
|
+
console.warn(`Unknown action type: ${action.type}`);
|
|
2634
|
+
}
|
|
2635
|
+
|
|
2636
|
+
executedActions++;
|
|
2637
|
+
} catch (error) {
|
|
2638
|
+
console.error(`Error executing action ${executedActions + 1}:`, error);
|
|
2639
|
+
lastError = error.message;
|
|
2640
|
+
// Continue with next action on error
|
|
2641
|
+
}
|
|
2642
|
+
}
|
|
2643
|
+
|
|
2644
|
+
const duration = Date.now() - startTime;
|
|
2645
|
+
|
|
2646
|
+
return {
|
|
2647
|
+
success: executedActions === actions.length && !lastError,
|
|
2648
|
+
executedActions,
|
|
2649
|
+
totalActions: actions.length,
|
|
2650
|
+
duration,
|
|
2651
|
+
error: lastError
|
|
2652
|
+
};
|
|
2653
|
+
} catch (error) {
|
|
2654
|
+
console.error('Error playing workflow:', error);
|
|
2655
|
+
throw error;
|
|
2656
|
+
}
|
|
2657
|
+
}
|
|
2658
|
+
}
|