working companion prototype
This commit is contained in:
@@ -0,0 +1,297 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user