@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.
Files changed (95) hide show
  1. package/CLAUDE.md +344 -0
  2. package/LICENSE +21 -0
  3. package/README.md +250 -0
  4. package/chrome-extension/README.md +41 -0
  5. package/chrome-extension/background.js +3917 -0
  6. package/chrome-extension/chrome-session-manager.js +706 -0
  7. package/chrome-extension/content.css +181 -0
  8. package/chrome-extension/content.js +3022 -0
  9. package/chrome-extension/data-buffer.js +435 -0
  10. package/chrome-extension/dom-tracker.js +411 -0
  11. package/chrome-extension/extension-config.js +78 -0
  12. package/chrome-extension/firebase-client.js +278 -0
  13. package/chrome-extension/firebase-config.js +32 -0
  14. package/chrome-extension/firebase-config.module.js +22 -0
  15. package/chrome-extension/firebase-config.module.template.js +27 -0
  16. package/chrome-extension/firebase-config.template.js +36 -0
  17. package/chrome-extension/frame-capture.js +407 -0
  18. package/chrome-extension/icon128.png +1 -0
  19. package/chrome-extension/icon16.png +1 -0
  20. package/chrome-extension/icon48.png +1 -0
  21. package/chrome-extension/license-helper.js +181 -0
  22. package/chrome-extension/logger.js +23 -0
  23. package/chrome-extension/manifest.json +73 -0
  24. package/chrome-extension/network-tracker.js +510 -0
  25. package/chrome-extension/offscreen.html +10 -0
  26. package/chrome-extension/options.html +203 -0
  27. package/chrome-extension/options.js +282 -0
  28. package/chrome-extension/pako.min.js +2 -0
  29. package/chrome-extension/performance-monitor.js +533 -0
  30. package/chrome-extension/pii-redactor.js +405 -0
  31. package/chrome-extension/popup.html +532 -0
  32. package/chrome-extension/popup.js +2446 -0
  33. package/chrome-extension/upload-manager.js +323 -0
  34. package/chrome-extension/web-vitals.iife.js +1 -0
  35. package/config/api-keys.json +11 -0
  36. package/config/chrome-pilot-config.json +45 -0
  37. package/package.json +126 -0
  38. package/scripts/cleanup-processes.js +109 -0
  39. package/scripts/config-manager.js +280 -0
  40. package/scripts/generate-extension-config.js +53 -0
  41. package/scripts/setup-security.js +64 -0
  42. package/src/capture/architecture.js +426 -0
  43. package/src/capture/error-handling-tests.md +38 -0
  44. package/src/capture/error-handling-types.ts +360 -0
  45. package/src/capture/index.js +508 -0
  46. package/src/capture/interfaces.js +625 -0
  47. package/src/capture/memory-manager.js +713 -0
  48. package/src/capture/types.js +342 -0
  49. package/src/chrome-controller.js +2658 -0
  50. package/src/cli.js +19 -0
  51. package/src/config-loader.js +303 -0
  52. package/src/database.js +2178 -0
  53. package/src/firebase-license-manager.js +462 -0
  54. package/src/firebase-privacy-guard.js +397 -0
  55. package/src/http-server.js +1516 -0
  56. package/src/index-direct.js +157 -0
  57. package/src/index-modular.js +219 -0
  58. package/src/index-monolithic-backup.js +2230 -0
  59. package/src/index.js +305 -0
  60. package/src/legacy/chrome-controller-old.js +1406 -0
  61. package/src/legacy/index-express.js +625 -0
  62. package/src/legacy/index-old.js +977 -0
  63. package/src/legacy/routes.js +260 -0
  64. package/src/legacy/shared-storage.js +101 -0
  65. package/src/logger.js +10 -0
  66. package/src/mcp/handlers/chrome-tool-handler.js +306 -0
  67. package/src/mcp/handlers/element-tool-handler.js +51 -0
  68. package/src/mcp/handlers/frame-tool-handler.js +957 -0
  69. package/src/mcp/handlers/request-handler.js +104 -0
  70. package/src/mcp/handlers/workflow-tool-handler.js +636 -0
  71. package/src/mcp/server.js +68 -0
  72. package/src/mcp/tools/index.js +701 -0
  73. package/src/middleware/auth.js +371 -0
  74. package/src/middleware/security.js +267 -0
  75. package/src/port-discovery.js +258 -0
  76. package/src/routes/admin.js +182 -0
  77. package/src/services/browser-daemon.js +494 -0
  78. package/src/services/chrome-service.js +375 -0
  79. package/src/services/failover-manager.js +412 -0
  80. package/src/services/git-safety-service.js +675 -0
  81. package/src/services/heartbeat-manager.js +200 -0
  82. package/src/services/http-client.js +195 -0
  83. package/src/services/process-manager.js +318 -0
  84. package/src/services/process-tracker.js +574 -0
  85. package/src/services/profile-manager.js +449 -0
  86. package/src/services/project-manager.js +415 -0
  87. package/src/services/session-manager.js +497 -0
  88. package/src/services/session-registry.js +491 -0
  89. package/src/services/unified-session-manager.js +678 -0
  90. package/src/shared-storage-old.js +267 -0
  91. package/src/standalone-server.js +53 -0
  92. package/src/utils/extension-path.js +145 -0
  93. package/src/utils.js +187 -0
  94. package/src/validation/log-transformer.js +125 -0
  95. 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
+ }