UNPKG

super-pancake-automation

Version:

A lightweight DOM-based UI automation framework using Chrome DevTools Protocol

685 lines (568 loc) 23.6 kB
import WebSocket from 'ws'; import fetch from 'node-fetch'; import { connectToFirefox, isFirefoxConnectionHealthy, closeFirefoxConnection, createRobustFirefoxConnection } from './firefox-browser.js'; /** * Connect to browser (Chrome or Firefox) based on browser type * @param {Object} options * @param {string} options.browser - Browser type ('chrome' or 'firefox') * @param {number} options.port - Port for remote debugging * @param {number} options.maxRetries - Maximum retry attempts */ export async function connectToBrowser({ browser = 'chrome', port = null, maxRetries = 3 } = {}) { const normalizedBrowser = browser.toLowerCase(); switch (normalizedBrowser) { case 'chrome': const chromePort = port || 9222; return await connectToChrome(chromePort, maxRetries); case 'firefox': const firefoxPort = port || 6000; return await connectToFirefox(firefoxPort, maxRetries); default: throw new Error(`Unsupported browser: ${browser}. Supported browsers: chrome, firefox`); } } /** * Check if browser connection is healthy * @param {WebSocket} ws - WebSocket connection * @param {string} browser - Browser type */ export function isBrowserConnectionHealthy(ws, browser = 'chrome') { const normalizedBrowser = browser.toLowerCase(); switch (normalizedBrowser) { case 'chrome': return isConnectionHealthy(ws); case 'firefox': return isFirefoxConnectionHealthy(ws); default: return isConnectionHealthy(ws); // Fallback to Chrome logic } } /** * Close browser connection safely * @param {WebSocket} ws - WebSocket connection * @param {string} browser - Browser type */ export function closeBrowserConnection(ws, browser = 'chrome') { const normalizedBrowser = browser.toLowerCase(); switch (normalizedBrowser) { case 'chrome': return closeConnection(ws); case 'firefox': return closeFirefoxConnection(ws); default: return closeConnection(ws); // Fallback to Chrome logic } } /** * Create robust browser connection with crash recovery * @param {Object} options * @param {string} options.browser - Browser type * @param {number} options.port - Port for remote debugging * @param {number} options.maxRetries - Maximum retry attempts */ export async function createRobustBrowserConnection({ browser = 'chrome', port = null, maxRetries = 3 } = {}) { const normalizedBrowser = browser.toLowerCase(); switch (normalizedBrowser) { case 'chrome': const chromePort = port || 9222; return await createRobustConnection(chromePort, maxRetries); case 'firefox': const firefoxPort = port || 6000; return await createRobustFirefoxConnection(firefoxPort, maxRetries); default: throw new Error(`Unsupported browser: ${browser}. Supported browsers: chrome, firefox`); } } async function waitForDebuggerReady(port = 9222, retries = 30, backoffMultiplier = 1.2) { let currentDelay = 500; // Start with 500ms delay for (let i = 0; i < retries; i++) { try { console.log(`🔄 Checking Chrome debugger readiness... [Attempt ${i + 1}/${retries}]`); const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 3000); // 3 second timeout per request const res = await fetch(`http://127.0.0.1:${port}/json`, { signal: controller.signal, headers: { 'Accept': 'application/json', 'User-Agent': 'SuperPancake-Automation' } }); clearTimeout(timeoutId); if (!res.ok) { throw new Error(`HTTP ${res.status}: ${res.statusText}`); } const targets = await res.json(); if (!targets || !Array.isArray(targets)) { throw new Error('Invalid targets response format'); } if (targets.length === 0) { throw new Error('No Chrome targets available'); } // Validate we have a page target with WebSocket URL const pageTarget = targets.find(target => target.type === 'page' && target.webSocketDebuggerUrl ); if (!pageTarget) { console.log(`⚠️ Found ${targets.length} targets but no suitable page target`); throw new Error('No suitable page target found'); } console.log(`✅ Chrome debugger ready! Found ${targets.length} targets`); console.log(`📍 Using target: ${pageTarget.title || 'Untitled'} (${pageTarget.url || 'about:blank'})`); return targets; } catch (err) { const isLastAttempt = i === retries - 1; if (err.name === 'AbortError') { console.log(`⏱️ Request timeout on attempt ${i + 1}`); } else if (err.code === 'ECONNREFUSED') { console.log(`🔌 Connection refused on attempt ${i + 1} - Chrome may not be started yet`); } else { console.log(`❌ Error on attempt ${i + 1}: ${err.message}`); } if (isLastAttempt) { const detailedError = new Error( `Chrome debugger connection failed after ${retries} attempts. ` + `Last error: ${err.message}. ` + `Ensure Chrome is running with --remote-debugging-port=${port}` ); detailedError.originalError = err; detailedError.port = port; detailedError.attempts = retries; throw detailedError; } // Exponential backoff with jitter const jitter = Math.random() * 200; // 0-200ms random jitter const delay = Math.min(currentDelay + jitter, 5000); // Cap at 5 seconds console.log(`⏳ Waiting ${Math.round(delay)}ms before retry...`); await new Promise(resolve => setTimeout(resolve, delay)); currentDelay = Math.min(currentDelay * backoffMultiplier, 5000); } } } export async function connectToChrome(port = 9222, maxRetries = 3) { let lastError = null; for (let attempt = 1; attempt <= maxRetries; attempt++) { try { console.log(`🔗 Attempting WebSocket connection... [Attempt ${attempt}/${maxRetries}]`); const targets = await waitForDebuggerReady(port); if (!targets || targets.length === 0) { throw new Error(`No Chrome targets available on port ${port}`); } // Find the best target (prefer page type) const pageTarget = targets.find(target => target.type === 'page' && target.webSocketDebuggerUrl ) || targets.find(target => target.webSocketDebuggerUrl); if (!pageTarget || !pageTarget.webSocketDebuggerUrl) { throw new Error('No suitable WebSocket URL available from Chrome targets'); } const wsUrl = pageTarget.webSocketDebuggerUrl; console.log(`📡 Connecting to WebSocket: ${wsUrl.substring(0, 50)}...`); const ws = await connectWebSocket(wsUrl, port); // Add enhanced connection health monitoring with auto-reconnect ws.isHealthy = true; ws.lastPingTime = Date.now(); ws.lastPongTime = Date.now(); ws.consecutiveFailures = 0; ws.reconnectAttempts = 0; ws.maxReconnectAttempts = 5; ws.originalUrl = wsUrl; ws.port = port; ws.isReconnecting = false; // Set up aggressive ping-pong for connection health (5 seconds instead of 30) const pingInterval = setInterval(() => { if (ws.readyState === WebSocket.OPEN) { try { ws.ping(); ws.lastPingTime = Date.now(); // Check if previous ping timed out const timeSinceLastPong = Date.now() - ws.lastPongTime; if (timeSinceLastPong > 15000) { // 15 second pong timeout ws.consecutiveFailures++; console.log(`⚠️ Ping timeout detected (${ws.consecutiveFailures} consecutive failures)`); if (ws.consecutiveFailures >= 3) { console.log('❌ Connection unhealthy - too many ping failures'); ws.isHealthy = false; ws.emit('connection-unhealthy', { reason: 'ping-timeout', failures: ws.consecutiveFailures }); } } } catch (pingError) { console.log(`❌ Ping failed: ${pingError.message}`); ws.consecutiveFailures++; ws.isHealthy = false; } } else { clearInterval(pingInterval); } }, 5000); // Ping every 5 seconds for faster failure detection ws.on('pong', () => { ws.lastPongTime = Date.now(); ws.consecutiveFailures = 0; // Reset failure counter on successful pong if (!ws.isHealthy) { console.log('✅ Connection recovered - pong received'); ws.isHealthy = true; } }); ws.on('close', (code, reason) => { clearInterval(pingInterval); ws.isHealthy = false; const reasonText = reason ? reason.toString() : 'Unknown'; console.log(`🔌 WebSocket connection closed (Code: ${code}, Reason: ${reasonText})`); // Attempt automatic reconnection if not a normal closure and within retry limits if (code !== 1000 && !ws.isReconnecting && ws.reconnectAttempts < ws.maxReconnectAttempts) { attemptReconnection(ws); } else { ws.emit('connection-closed', { code, reason: reasonText, reconnectable: false }); } }); ws.on('error', (error) => { ws.isHealthy = false; ws.consecutiveFailures++; console.log(`❌ WebSocket error (failure #${ws.consecutiveFailures}): ${error.message}`); // Attempt automatic reconnection on error if within retry limits if (!ws.isReconnecting && ws.reconnectAttempts < ws.maxReconnectAttempts) { attemptReconnection(ws); } else { ws.emit('connection-error', { error: error.message, failures: ws.consecutiveFailures, reconnectable: false }); } }); console.log(`✅ Successfully connected to Chrome WebSocket on attempt ${attempt}`); return ws; } catch (error) { lastError = error; console.log(`❌ Connection attempt ${attempt} failed: ${error.message}`); if (attempt < maxRetries) { const delay = Math.min(1000 * attempt, 3000); // Progressive delay: 1s, 2s, 3s console.log(`⏳ Retrying in ${delay}ms...`); await new Promise(resolve => setTimeout(resolve, delay)); } } } // All attempts failed const finalError = new Error( `Failed to connect to Chrome after ${maxRetries} attempts. ` + `Last error: ${lastError?.message || 'Unknown error'}. ` + `Troubleshooting: Ensure Chrome is running with --remote-debugging-port=${port} ` + 'and no firewall is blocking the connection.' ); finalError.originalError = lastError; finalError.port = port; finalError.attempts = maxRetries; throw finalError; } async function connectWebSocket(wsUrl, port) { return new Promise((resolve, reject) => { const startTime = Date.now(); // Enhanced WebSocket configuration for stability const ws = new WebSocket(wsUrl, { handshakeTimeout: 15000, // Increased to 15 seconds for slower systems perMessageDeflate: false, // Disable compression for stability skipUTF8Validation: false, // Keep validation for security maxPayload: 100 * 1024 * 1024 // 100MB max payload }); let isResolved = false; // Enhanced timeout with better error context const timeout = setTimeout(() => { if (!isResolved) { isResolved = true; const elapsed = Date.now() - startTime; ws.close(); reject(new Error( `WebSocket handshake timeout after ${elapsed}ms (limit: 15000ms). ` + `URL: ${wsUrl.substring(0, 100)}... ` + `Port: ${port}. Check if Chrome is responsive and not overloaded.` )); } }, 15000); ws.once('open', () => { if (!isResolved) { isResolved = true; clearTimeout(timeout); const elapsed = Date.now() - startTime; console.log(`🚀 WebSocket connection established in ${elapsed}ms`); resolve(ws); } }); ws.once('error', (error) => { if (!isResolved) { isResolved = true; clearTimeout(timeout); const elapsed = Date.now() - startTime; // Enhanced error reporting with context let errorMessage = `WebSocket connection failed after ${elapsed}ms. `; if (error.code) { switch (error.code) { case 'ECONNREFUSED': errorMessage += `Connection refused (${error.code}). Chrome may not be running on port ${port}.`; break; case 'ENOTFOUND': errorMessage += `Host not found (${error.code}). Check if localhost is accessible.`; break; case 'ECONNRESET': errorMessage += `Connection reset (${error.code}). Chrome may have crashed or restarted.`; break; case 'ETIMEDOUT': errorMessage += `Connection timeout (${error.code}). Network or Chrome may be slow.`; break; default: errorMessage += `Error code: ${error.code} - ${error.message}`; } } else { errorMessage += `${error.message || 'Unknown error'}`; } errorMessage += ` URL: ${wsUrl.substring(0, 100)}...`; reject(new Error(errorMessage)); } }); ws.once('close', (code, reason) => { if (!isResolved) { isResolved = true; clearTimeout(timeout); const elapsed = Date.now() - startTime; if (code !== 1000) { // 1000 is normal closure let closeMessage = `WebSocket closed unexpectedly after ${elapsed}ms. `; // Enhanced close code interpretation switch (code) { case 1006: closeMessage += `Abnormal closure (${code}). Connection lost without proper handshake.`; break; case 1011: closeMessage += `Server error (${code}). Chrome DevTools encountered an internal error.`; break; case 1012: closeMessage += `Service restart (${code}). Chrome DevTools is restarting.`; break; case 1013: closeMessage += `Try again later (${code}). Chrome DevTools is temporarily unavailable.`; break; default: closeMessage += `Close code: ${code}`; } if (reason) { closeMessage += ` Reason: ${reason.toString()}`; } closeMessage += ` URL: ${wsUrl.substring(0, 100)}...`; reject(new Error(closeMessage)); } } }); // Add connection state logging for debugging console.log(`🔗 Initiating WebSocket connection to ${wsUrl.substring(0, 50)}...`); }); } // Enhanced helper function to check if a WebSocket connection is healthy export function isConnectionHealthy(ws) { if (!ws || !ws.isHealthy) { return false; } if (ws.readyState !== WebSocket.OPEN) { return false; } // Check for too many consecutive failures if (ws.consecutiveFailures && ws.consecutiveFailures >= 3) { console.log(`⚠️ WebSocket connection unhealthy - ${ws.consecutiveFailures} consecutive failures`); return false; } // Check if we've received a pong recently (within last 20 seconds with faster ping) const timeSinceLastPong = Date.now() - (ws.lastPongTime || 0); if (timeSinceLastPong > 20000) { console.log(`⚠️ WebSocket connection may be stale - no pong for ${Math.round(timeSinceLastPong/1000)}s`); return false; } // Check if ping is working const timeSinceLastPing = Date.now() - (ws.lastPingTime || 0); if (timeSinceLastPing > 30000) { console.log(`⚠️ WebSocket ping system not active - last ping ${Math.round(timeSinceLastPing/1000)}s ago`); return false; } return true; } // Helper function to safely close WebSocket connections export function closeConnection(ws) { if (!ws) {return;} try { if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { ws.close(1000, 'Normal closure'); } ws.isHealthy = false; } catch (error) { console.log(`⚠️ Error closing WebSocket: ${error.message}`); } } // Automatic reconnection function with exponential backoff async function attemptReconnection(originalWs) { if (originalWs.isReconnecting) { return; // Already attempting reconnection } originalWs.isReconnecting = true; originalWs.reconnectAttempts++; console.log(`🔄 Attempting automatic reconnection [${originalWs.reconnectAttempts}/${originalWs.maxReconnectAttempts}]`); // Exponential backoff: 1s, 2s, 4s, 8s, 16s const delay = Math.min(1000 * Math.pow(2, originalWs.reconnectAttempts - 1), 16000); console.log(`⏳ Waiting ${delay}ms before reconnection attempt...`); await new Promise(resolve => setTimeout(resolve, delay)); try { // Get fresh targets and attempt to reconnect const targets = await waitForDebuggerReady(originalWs.port); if (!targets || targets.length === 0) { throw new Error(`No Chrome targets available on port ${originalWs.port} during reconnection`); } // Find a suitable target const pageTarget = targets.find(target => target.type === 'page' && target.webSocketDebuggerUrl ) || targets.find(target => target.webSocketDebuggerUrl); if (!pageTarget || !pageTarget.webSocketDebuggerUrl) { throw new Error('No suitable WebSocket URL available during reconnection'); } const newWsUrl = pageTarget.webSocketDebuggerUrl; console.log(`🔄 Reconnecting to: ${newWsUrl.substring(0, 50)}...`); const newWs = await connectWebSocket(newWsUrl, originalWs.port); // Copy connection monitoring properties to new WebSocket newWs.isHealthy = true; newWs.lastPingTime = Date.now(); newWs.lastPongTime = Date.now(); newWs.consecutiveFailures = 0; newWs.reconnectAttempts = originalWs.reconnectAttempts; newWs.maxReconnectAttempts = originalWs.maxReconnectAttempts; newWs.originalUrl = originalWs.originalUrl; newWs.port = originalWs.port; newWs.isReconnecting = false; console.log(`✅ Reconnection successful after ${originalWs.reconnectAttempts} attempts`); // Emit reconnection success event originalWs.emit('connection-reconnected', { attempts: originalWs.reconnectAttempts, newWebSocket: newWs }); return newWs; } catch (error) { originalWs.isReconnecting = false; console.log(`❌ Reconnection attempt ${originalWs.reconnectAttempts} failed: ${error.message}`); if (originalWs.reconnectAttempts < originalWs.maxReconnectAttempts) { // Will try again on next error/close event console.log('⏳ Will retry reconnection if another failure occurs'); } else { console.log(`🛑 Maximum reconnection attempts (${originalWs.maxReconnectAttempts}) reached`); originalWs.emit('connection-exhausted', { attempts: originalWs.reconnectAttempts, lastError: error.message }); } throw error; } } // Browser crash detection and recovery system export async function createRobustConnection(port = 9222, maxRetries = 3) { const connectionManager = { ws: null, isActive: false, crashCount: 0, maxCrashRecoveries: 3, healthCheckInterval: null, port: port, async initialize() { console.log('🛡️ Initializing robust browser connection...'); this.ws = await connectToChrome(this.port, maxRetries); this.isActive = true; this.startHealthMonitoring(); return this.ws; }, startHealthMonitoring() { console.log('💓 Starting browser health monitoring...'); this.healthCheckInterval = setInterval(async () => { if (!this.isActive) {return;} try { // Check if Chrome process is still running const isAlive = await this.checkBrowserAlive(); if (!isAlive) { console.log('💀 Browser crash detected!'); await this.handleCrash(); } else if (!isConnectionHealthy(this.ws)) { console.log('⚕️ Connection health issue detected'); await this.handleConnectionIssue(); } } catch (error) { console.log(`❌ Health check error: ${error.message}`); } }, 10000); // Check every 10 seconds }, async checkBrowserAlive() { try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 3000); const response = await fetch(`http://127.0.0.1:${this.port}/json`, { signal: controller.signal, headers: { 'User-Agent': 'SuperPancake-HealthCheck' } }); clearTimeout(timeoutId); return response.ok; } catch (error) { return false; } }, async handleCrash() { this.crashCount++; console.log(`🚨 Browser crash #${this.crashCount} detected`); if (this.crashCount > this.maxCrashRecoveries) { console.log(`🛑 Maximum crash recoveries (${this.maxCrashRecoveries}) exceeded`); this.isActive = false; this.ws?.emit('browser-crash-exhausted', { crashes: this.crashCount }); return; } console.log(`🔄 Attempting crash recovery ${this.crashCount}/${this.maxCrashRecoveries}...`); try { // Clean up old connection if (this.ws) { closeConnection(this.ws); } // Wait before recovery attempt const delay = Math.min(5000 * this.crashCount, 30000); // 5s, 10s, 15s... console.log(`⏳ Waiting ${delay}ms before crash recovery...`); await new Promise(resolve => setTimeout(resolve, delay)); // Attempt to restart browser connection this.ws = await connectToChrome(this.port, maxRetries); console.log('✅ Crash recovery successful! Browser reconnected.'); this.ws?.emit('browser-crash-recovered', { crashes: this.crashCount, recoveryTime: delay }); } catch (error) { console.log(`❌ Crash recovery ${this.crashCount} failed: ${error.message}`); this.ws?.emit('browser-crash-recovery-failed', { crashes: this.crashCount, error: error.message }); } }, async handleConnectionIssue() { console.log('🔧 Handling connection issue...'); try { // Attempt connection recovery without full restart if (this.ws && this.ws.readyState !== WebSocket.OPEN) { console.log('🔗 WebSocket not open, attempting reconnection...'); closeConnection(this.ws); this.ws = await connectToChrome(this.port, 2); // Fewer retries for connection issues console.log('✅ Connection issue resolved'); this.ws?.emit('connection-issue-resolved'); } } catch (error) { console.log(`❌ Connection issue recovery failed: ${error.message}`); // This will be caught by the crash detection on next health check } }, destroy() { console.log('🔒 Destroying robust connection manager...'); this.isActive = false; if (this.healthCheckInterval) { clearInterval(this.healthCheckInterval); this.healthCheckInterval = null; } if (this.ws) { closeConnection(this.ws); this.ws = null; } console.log('✅ Connection manager destroyed'); } }; return connectionManager; }