super-pancake-automation
Version:
A lightweight DOM-based UI automation framework using Chrome DevTools Protocol
351 lines (303 loc) โข 13.9 kB
JavaScript
import chromeLauncher from 'chrome-launcher';
import { exec } from 'child_process';
import fetch from 'node-fetch';
import net from 'net';
import { launchFirefox } from './firefox-launcher.js';
/**
* Launch browser (Chrome or Firefox) based on browser type
* @param {Object} options
* @param {string} options.browser - Browser type ('chrome' or 'firefox')
* @param {boolean} options.headed - If true, launch with visible window
* @param {number} options.port - Port for remote debugging
* @param {number} options.maxRetries - Maximum retry attempts
*/
export async function launchBrowser({ browser = 'chrome', headed = false, port = null, maxRetries = 3 } = {}) {
const normalizedBrowser = browser.toLowerCase();
switch (normalizedBrowser) {
case 'chrome':
return await launchChrome({ headed, port, maxRetries });
case 'firefox':
const firefoxPort = port || 6000; // Firefox default port
return await launchFirefox({ headed, port: firefoxPort, maxRetries });
default:
throw new Error(`Unsupported browser: ${browser}. Supported browsers: chrome, firefox`);
}
}
/**
* Launch Chrome in headed or headless mode
* @param {Object} options
* @param {boolean} options.headed - If true, launch with visible window
*/
export async function launchChrome({ headed = false, port = null, maxRetries = 3 } = {}) {
// Check for environment variable override from UI
if (process.env.SUPER_PANCAKE_HEADLESS === 'true') {
headed = false;
console.log('๐ง Overriding headed mode: Running in headless mode per UI setting');
} else if (process.env.SUPER_PANCAKE_HEADLESS === 'false') {
headed = true;
console.log('๐ง Overriding headless mode: Running in headed mode per UI setting');
}
let lastError = null;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
console.log(`๐ Launching Chrome... [Attempt ${attempt}/${maxRetries}]`);
// Clean up any existing Chrome processes
if (port) {
await cleanupChromeProcesses(port);
// Verify port is available if specified
const isPortFree = await checkPortAvailability(port);
if (!isPortFree) {
console.log(`โ ๏ธ Port ${port} is still in use after cleanup, trying force cleanup...`);
await forceCleanupPort(port);
// Wait a bit more and check again
await new Promise(resolve => setTimeout(resolve, 1000));
const isPortFreeNow = await checkPortAvailability(port);
if (!isPortFreeNow) {
throw new Error(`Port ${port} is still occupied after force cleanup`);
}
}
} else {
// When no port specified, do general cleanup
await cleanupChromeProcesses();
}
const chrome = await chromeLauncher.launch({
chromeFlags: [
...(!headed ? ['--headless=new'] : [
'--new-window', // Open in new window for headed mode
'--window-position=100,100', // Position window visibly
'--window-size=1280,800',
'--start-maximized'
]),
'--disable-gpu',
...(headed ? [] : ['--window-size=1280,800']), // Only set size for headless
'--disable-infobars',
'--no-first-run',
'--no-default-browser-check',
...(!headed ? [] : ['--disable-web-security']), // More permissive for headed testing
'--no-sandbox',
'--disable-dev-shm-usage',
'--enable-automation',
'--disable-features=VizDisplayCompositor',
'--disable-background-timer-throttling',
'--disable-backgrounding-occluded-windows',
'--disable-renderer-backgrounding',
'--disable-extensions',
'--disable-plugins',
'--disable-default-apps'
],
// Let chrome-launcher auto-select port if none specified
...(port ? { port: port } : {}),
ignoreDefaultFlags: false,
handleSIGINT: true
});
// Verify Chrome actually launched and is accessible
await verifyChromeLaunched(chrome, chrome.port);
console.log(`โ
Chrome successfully launched at port ${chrome.port} in ${headed ? 'headed' : 'headless'} mode`);
// macOS: bring Chrome window to front and make it visible
if (headed && process.platform === 'darwin') {
try {
// Wait a moment for Chrome to fully load
await new Promise(resolve => setTimeout(resolve, 1500));
exec('osascript -e \'tell application "Google Chrome" to activate\'');
exec('osascript -e \'tell application "System Events" to set frontmost of process "Google Chrome" to true\'');
console.log('๐ฅ๏ธ Brought Chrome window to front');
} catch (osError) {
console.log('โ ๏ธ Could not bring Chrome to front:', osError.message);
}
}
return chrome;
} catch (error) {
lastError = error;
console.log(`โ Chrome launch attempt ${attempt} failed: ${error.message}`);
if (attempt < maxRetries) {
const delay = Math.min(2000 * attempt, 5000); // Progressive delay: 2s, 4s, 5s
console.log(`โณ Retrying Chrome launch in ${delay}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
// All attempts failed
const finalError = new Error(
`Failed to launch Chrome after ${maxRetries} attempts. ` +
`Last error: ${lastError?.message || 'Unknown error'}. ` +
`Troubleshooting: Ensure Chrome is installed and no other processes are using port ${port}.`
);
finalError.originalError = lastError;
finalError.port = port;
finalError.attempts = maxRetries;
throw finalError;
}
async function cleanupChromeProcesses(port) {
console.log('๐งน Enhanced Chrome process cleanup starting...');
const startTime = Date.now();
let cleanupAttempts = 0;
const maxCleanupAttempts = 3;
for (let attempt = 1; attempt <= maxCleanupAttempts; attempt++) {
cleanupAttempts++;
console.log(`๐ง Cleanup attempt ${attempt}/${maxCleanupAttempts}`);
try {
if (process.platform === 'win32') {
// Enhanced Windows cleanup - ONLY target automation Chrome instances
console.log('๐ช Windows Chrome cleanup (automation only)...');
if (port) {
// Kill specific debugging port processes only
const netstatResult = await execAsync(`netstat -ano | findstr :${port}`, { ignoreErrors: true });
if (netstatResult && netstatResult.trim()) {
console.log(`๐ Found processes on port ${port}, cleaning up...`);
// Extract PIDs and kill them
const lines = netstatResult.split('\n').filter(line => line.includes(':' + port));
for (const line of lines) {
const parts = line.trim().split(/\s+/);
const pid = parts[parts.length - 1];
if (pid && !isNaN(pid)) {
await execAsync(`taskkill /f /pid ${pid} 2>nul || echo PID ${pid} not found`);
}
}
}
console.log(`๐ฏ Cleaned up Chrome processes on port ${port}`);
} else {
// When no port specified, try to find automation-specific processes
// Use wmic to find processes with automation flags
await execAsync('wmic process where "CommandLine like \'%--enable-automation%\'" delete 2>nul || echo No automation processes');
await execAsync('wmic process where "CommandLine like \'%--headless%\'" delete 2>nul || echo No headless processes');
console.log('๐ฏ Cleaned up automation-specific Chrome processes');
}
} else if (process.platform === 'darwin') {
// Enhanced macOS cleanup - ONLY target automation Chrome instances
console.log('๐ macOS Chrome cleanup (automation only)...');
if (port) {
// Kill processes by specific port only
await execAsync(`lsof -ti:${port} | xargs kill -9 2>/dev/null || true`);
console.log(`๐ฏ Cleaned up Chrome processes on port ${port}`);
} else {
// When no port specified, only target automation-specific processes
// Look for Chrome processes with automation flags
await execAsync('pkill -f "chrome.*--enable-automation" 2>/dev/null || true');
await execAsync('pkill -f "chrome.*--headless" 2>/dev/null || true');
await execAsync('pkill -f "chrome.*--disable-dev-shm-usage" 2>/dev/null || true');
console.log('๐ฏ Cleaned up automation-specific Chrome processes');
}
// Only kill automation-specific Chrome processes with debugging ports
if (port) {
await execAsync(`pkill -f "chrome.*--remote-debugging-port=${port}" 2>/dev/null || true`);
await execAsync(`pkill -f "Chromium.*--remote-debugging-port=${port}" 2>/dev/null || true`);
}
// DO NOT force quit the Chrome application - user may have other tabs open
} else {
// Enhanced Linux cleanup - ONLY target automation Chrome instances
console.log('๐ง Linux Chrome cleanup (automation only)...');
if (port) {
// Kill processes by specific port only
await execAsync(`fuser -k ${port}/tcp 2>/dev/null || true`);
await execAsync(`lsof -ti:${port} | xargs kill -9 2>/dev/null || true`);
console.log(`๐ฏ Cleaned up Chrome processes on port ${port}`);
} else {
// When no port specified, only target automation-specific processes
await execAsync('pkill -f "chrome.*--enable-automation" 2>/dev/null || true');
await execAsync('pkill -f "chrome.*--headless" 2>/dev/null || true');
await execAsync('pkill -f "chrome.*--disable-dev-shm-usage" 2>/dev/null || true');
console.log('๐ฏ Cleaned up automation-specific Chrome processes');
}
}
// Verify cleanup by checking port availability
await new Promise(resolve => setTimeout(resolve, 2000)); // Wait for processes to die
const isPortFree = await checkPortAvailability(port);
if (isPortFree) {
const elapsed = Date.now() - startTime;
console.log(`โ
Chrome cleanup successful in ${elapsed}ms (${cleanupAttempts} attempts)`);
return;
} else {
console.log(`โ ๏ธ Port ${port} still occupied after cleanup attempt ${attempt}`);
if (attempt < maxCleanupAttempts) {
console.log('โณ Waiting before next cleanup attempt...');
await new Promise(resolve => setTimeout(resolve, 1000 * attempt)); // Progressive delay
}
}
} catch (error) {
console.log(`โ Cleanup attempt ${attempt} encountered error: ${error.message}`);
if (attempt < maxCleanupAttempts) {
await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
}
}
}
// Final verification
const finalPortCheck = await checkPortAvailability(port);
const totalElapsed = Date.now() - startTime;
if (!finalPortCheck) {
console.log(`โ ๏ธ Warning: Port ${port} may still be in use after ${totalElapsed}ms cleanup`);
console.log('๐ง Will attempt force cleanup in launch process...');
} else {
console.log(`โ
Final verification: Port ${port} is now available after ${totalElapsed}ms`);
}
}
async function checkPortAvailability(port) {
return new Promise((resolve) => {
const server = net.createServer();
server.listen(port, () => {
server.once('close', () => {
resolve(true); // Port is available
});
server.close();
});
server.on('error', () => {
resolve(false); // Port is in use
});
});
}
async function forceCleanupPort(port) {
console.log(`๐ง Force cleaning up port ${port}...`);
try {
if (process.platform === 'win32') {
await execAsync(`for /f "tokens=5" %a in ('netstat -ano ^| findstr :${port}') do taskkill /f /pid %a 2>nul`);
} else {
await execAsync(`lsof -ti:${port} | xargs kill -9 2>/dev/null || true`);
}
} catch (error) {
console.log(`โ ๏ธ Force cleanup failed: ${error.message}`);
}
}
async function verifyChromeLaunched(chrome, port) {
console.log('๐ Verifying Chrome launch...');
// Give Chrome time to fully start - increased wait time
await new Promise(resolve => setTimeout(resolve, 3000));
// Try multiple verification attempts
const maxAttempts = 3;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
console.log(`๐ Verification attempt ${attempt}/${maxAttempts} for port ${port}`);
const response = await fetch(`http://127.0.0.1:${port}/json/version`, {
timeout: 5000
});
if (!response.ok) {
throw new Error(`Chrome health check failed: ${response.status}`);
}
const version = await response.json();
console.log(`๐ฏ Chrome verification successful: ${version.Browser || 'Unknown version'}`);
return; // Success, exit
} catch (error) {
console.log(`โ ๏ธ Verification attempt ${attempt} failed: ${error.message}`);
if (attempt < maxAttempts) {
console.log('โณ Retrying verification in 2 seconds...');
await new Promise(resolve => setTimeout(resolve, 2000));
} else {
throw new Error(`Chrome launch verification failed after ${maxAttempts} attempts: ${error.message}`);
}
}
}
}
function execAsync(command, options = {}) {
return new Promise((resolve, reject) => {
const execOptions = {
timeout: options.timeout || 5000,
encoding: 'utf8'
};
exec(command, execOptions, (error, stdout, stderr) => {
if (error && error.code !== 1 && !options.ignoreErrors) {
// Ignore exit code 1 (no matches found) or if ignoreErrors is true
reject(error);
} else {
resolve(stdout);
}
});
});
}