Files
gridpilot.gg/packages/infrastructure/adapters/automation/E2ETestBrowserLauncher.ts

297 lines
7.8 KiB
TypeScript

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<BrowserWindowConfig>
) {
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<BrowserLaunchResult> {
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<void> {
// 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<void> {
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<void> {
// 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<BrowserWindowConfig>
): E2ETestBrowserLauncher {
return new E2ETestBrowserLauncher(fixtureServer, config);
}