import { spawn, ChildProcess } from 'child_process'; import * as path from 'path'; import type { IFixtureServer } from './FixtureServer'; /** * Browser window configuration for E2E tests. */ export interface BrowserWindowConfig { /** X position of the window (default: 0) */ x: number; /** Y position of the window (default: 0) */ y: number; /** Window width (default: 1920) */ width: number; /** Window height (default: 1080) */ height: number; } /** * Result of browser launch operation. */ export interface BrowserLaunchResult { success: boolean; pid?: number; url?: string; error?: string; } /** * E2E Test Browser Launcher. * * Launches a real Chrome browser window for E2E testing with nut.js automation. * The browser displays HTML fixtures served by FixtureServer and is positioned * at a fixed location for deterministic template matching. * * IMPORTANT: This creates a REAL browser window on the user's screen. * It requires: * - Chrome/Chromium installed * - Display available (not headless) * - macOS permissions granted */ export class E2ETestBrowserLauncher { private browserProcess: ChildProcess | null = null; private windowConfig: BrowserWindowConfig; constructor( private fixtureServer: IFixtureServer, windowConfig?: Partial ) { this.windowConfig = { x: windowConfig?.x ?? 0, y: windowConfig?.y ?? 0, width: windowConfig?.width ?? 1920, height: windowConfig?.height ?? 1080, }; } /** * Launch Chrome browser pointing to the fixture server. * * @param initialFixtureStep - Optional step number to navigate to initially * @returns BrowserLaunchResult indicating success or failure */ async launch(initialFixtureStep?: number): Promise { if (this.browserProcess) { return { success: false, error: 'Browser already launched. Call close() first.', }; } if (!this.fixtureServer.isRunning()) { return { success: false, error: 'Fixture server is not running. Start it before launching browser.', }; } const url = initialFixtureStep ? this.fixtureServer.getFixtureUrl(initialFixtureStep) : `${this.getBaseUrl()}/all-steps.html`; const chromePath = this.findChromePath(); if (!chromePath) { return { success: false, error: 'Chrome/Chromium not found. Please install Chrome browser.', }; } const args = this.buildChromeArgs(url); try { this.browserProcess = spawn(chromePath, args, { detached: false, stdio: ['ignore', 'pipe', 'pipe'], }); // Give browser time to start await this.waitForBrowserStart(); if (this.browserProcess.pid) { return { success: true, pid: this.browserProcess.pid, url, }; } else { return { success: false, error: 'Browser process started but no PID available', }; } } catch (error) { return { success: false, error: `Failed to launch browser: ${error}`, }; } } /** * Navigate the browser to a specific fixture step. */ async navigateToStep(stepNumber: number): Promise { // Note: This would require browser automation to navigate // For now, we'll log the intent - actual navigation happens via nut.js const url = this.fixtureServer.getFixtureUrl(stepNumber); console.log(`[E2ETestBrowserLauncher] Navigate to step ${stepNumber}: ${url}`); } /** * Close the browser process. */ async close(): Promise { if (!this.browserProcess) { return; } return new Promise((resolve) => { if (!this.browserProcess) { resolve(); return; } // Set up listener for process exit this.browserProcess.once('exit', () => { this.browserProcess = null; resolve(); }); // Try graceful termination first this.browserProcess.kill('SIGTERM'); // Force kill after timeout setTimeout(() => { if (this.browserProcess) { this.browserProcess.kill('SIGKILL'); this.browserProcess = null; resolve(); } }, 3000); }); } /** * Check if browser is running. */ isRunning(): boolean { return this.browserProcess !== null && !this.browserProcess.killed; } /** * Get the browser process PID. */ getPid(): number | undefined { return this.browserProcess?.pid; } /** * Get the base URL of the fixture server. */ private getBaseUrl(): string { // Extract from fixture server return `http://localhost:3456`; } /** * Find Chrome/Chromium executable path. */ private findChromePath(): string | null { const platform = process.platform; const paths: string[] = []; if (platform === 'darwin') { paths.push( '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Chromium.app/Contents/MacOS/Chromium', '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', ); } else if (platform === 'linux') { paths.push( '/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser', '/snap/bin/chromium', ); } else if (platform === 'win32') { const programFiles = process.env['PROGRAMFILES'] || 'C:\\Program Files'; const programFilesX86 = process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)'; const localAppData = process.env['LOCALAPPDATA'] || ''; paths.push( path.join(programFiles, 'Google', 'Chrome', 'Application', 'chrome.exe'), path.join(programFilesX86, 'Google', 'Chrome', 'Application', 'chrome.exe'), path.join(localAppData, 'Google', 'Chrome', 'Application', 'chrome.exe'), ); } // Check if any path exists const fs = require('fs'); for (const chromePath of paths) { try { if (fs.existsSync(chromePath)) { return chromePath; } } catch { continue; } } return null; } /** * Build Chrome command line arguments. */ private buildChromeArgs(url: string): string[] { const { x, y, width, height } = this.windowConfig; return [ // Disable various Chrome features for cleaner automation '--disable-extensions', '--disable-plugins', '--disable-sync', '--disable-translate', '--disable-background-networking', '--disable-default-apps', '--disable-hang-monitor', '--disable-popup-blocking', '--disable-prompt-on-repost', '--disable-client-side-phishing-detection', '--disable-component-update', // Window positioning `--window-position=${x},${y}`, `--window-size=${width},${height}`, // Start with specific window settings '--start-maximized=false', '--no-first-run', '--no-default-browser-check', // Disable GPU for more consistent rendering in automation '--disable-gpu', // Open DevTools disabled for cleaner screenshots // '--auto-open-devtools-for-tabs', // Start with the URL url, ]; } /** * Wait for browser to start and window to be ready. */ private async waitForBrowserStart(): Promise { // Give Chrome time to: // 1. Start the process // 2. Create the window // 3. Load the initial page await new Promise(resolve => setTimeout(resolve, 2000)); } } /** * Factory function to create a browser launcher with default settings. */ export function createE2EBrowserLauncher( fixtureServer: IFixtureServer, config?: Partial ): E2ETestBrowserLauncher { return new E2ETestBrowserLauncher(fixtureServer, config); }