297 lines
7.8 KiB
TypeScript
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);
|
|
} |