/**
* MCP Server Manager for E2E Tests
*
* Manages the lifecycle of real MCP server instances for testing,
* with unique port assignment to avoid conflicts in parallel execution.
*/
import { spawn, ChildProcess } from 'child_process';
import { join } from 'path';
export interface ServerManagerOptions {
port?: number;
timeout?: number;
debug?: boolean;
}
/**
* Manages MCP server lifecycle for E2E testing
*/
export class ServerManager {
private serverProcess: ChildProcess | null = null;
private readonly serverPath: string;
private readonly port: number;
private readonly timeout: number;
private readonly debug: boolean;
constructor(options: ServerManagerOptions = {}) {
// Look for server in the correct location relative to workspace root
this.serverPath = join(__dirname, '../../../../dist/askme-server/main.js');
this.port = options.port || 9001;
this.timeout = options.timeout || 10000;
this.debug = options.debug || false;
}
/**
* Start the MCP server
*/
async start(): Promise<void> {
if (this.serverProcess) {
throw new Error('Server is already running');
}
return new Promise((resolve, reject) => {
this.serverProcess = spawn('node', [this.serverPath, '--port', this.port.toString()], {
stdio: ['pipe', 'pipe', 'pipe'],
env: {
...process.env,
ASK_ME_MCP_DEBUG: '1' // Enable debug logging for tests
}
});
let serverReady = false;
const timeout = setTimeout(() => {
if (!serverReady) {
this.cleanup();
reject(new Error(`Server failed to start within ${this.timeout}ms`));
}
}, this.timeout * 2); // Double timeout for more reliability
// Handle process errors
this.serverProcess.on('error', (error) => {
clearTimeout(timeout);
this.cleanup();
reject(error);
});
this.serverProcess.on('exit', (code, signal) => {
if (!serverReady) {
clearTimeout(timeout);
this.cleanup();
reject(new Error(`Server exited early with code ${code}, signal ${signal}`));
}
});
// Check stderr for server ready message
this.serverProcess.stderr!.on('data', (data) => {
const message = data.toString();
if (this.debug) {
console.log('Server output:', message);
}
if (message.includes(`Browser bridge listening on http://localhost:${this.port}`) ||
message.includes('Server ready for connections') ||
message.includes(`http://localhost:${this.port}`)) {
serverReady = true;
clearTimeout(timeout);
// Give server a moment to fully initialize
setTimeout(() => resolve(), 500);
} else if (message.includes('Error:') || message.includes('EADDRINUSE')) {
clearTimeout(timeout);
this.cleanup();
reject(new Error(`Server startup error: ${message}`));
}
});
// Also check stdout in case of different logging
this.serverProcess.stdout!.on('data', (data) => {
const message = data.toString();
if (this.debug) {
console.log('Server stdout:', message);
}
});
});
}
/**
* Stop the MCP server
*/
async stop(): Promise<void> {
if (!this.serverProcess) {
return;
}
return new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
if (this.serverProcess) {
this.serverProcess.kill('SIGKILL');
}
this.cleanup();
resolve();
}, 10000);
this.serverProcess!.once('exit', () => {
clearTimeout(timeout);
this.cleanup();
resolve();
});
this.serverProcess!.kill('SIGTERM');
});
}
/**
* Send a mock request to the server to trigger UI updates
*/
async sendMockRequest(requestData: any): Promise<void> {
if (!this.serverProcess) {
throw new Error('Server is not running');
}
// For now, we'll use the stdin interface to send JSON-RPC messages
// that simulate tool calls which will trigger requests in the UI
const jsonRpcMessage = {
jsonrpc: '2.0',
id: Date.now(),
method: 'tools/call',
params: {
name: requestData.toolName || 'ask-one-question',
arguments: requestData.arguments || { question: requestData.question || 'Test question' }
}
};
try {
this.serverProcess.stdin!.write(JSON.stringify(jsonRpcMessage) + '\n');
} catch (error) {
throw new Error(`Failed to send mock request: ${error}`);
}
}
/**
* Get the server URL
*/
getServerUrl(): string {
return `http://localhost:${this.port}`;
}
/**
* Get the server port
*/
getPort(): number {
return this.port;
}
/**
* Check if server is running
*/
isRunning(): boolean {
return this.serverProcess !== null && !this.serverProcess.killed;
}
/**
* Cleanup server process
*/
private cleanup(): void {
if (this.serverProcess) {
this.serverProcess.removeAllListeners();
this.serverProcess = null;
}
}
}
/**
* Creates a server manager with a unique port for testing
*/
export function createTestServerManager(basePort: number = 9001, options: Omit<ServerManagerOptions, 'port'> = {}): ServerManager {
return new ServerManager({
...options,
port: basePort
});
}
/**
* Port allocation for different test suites to avoid conflicts
*/
export const TEST_PORTS = {
BASIC: 9001,
SINGLE_QUESTION: 9002,
MULTIPLE_CHOICE: 9003,
HYPOTHESIS_CHALLENGE: 9004,
CHOOSE_NEXT: 9005,
SSE_COMMUNICATION: 9006,
UI_FEATURES: 9007
} as const;
/**
* Generate unique port for each test to prevent parallel execution conflicts
*/
export function getUniqueTestPort(basePort: number): number {
// Use worker ID and timestamp to create unique ports
const workerId = process.env.TEST_WORKER_INDEX || '0';
const timestamp = Date.now();
const workerOffset = parseInt(workerId) * 100;
const timeOffset = (timestamp % 1000);
// Create unique port: base + worker offset + time offset
const uniquePort = basePort + workerOffset + Math.floor(timeOffset / 10);
// Ensure port is in valid range (avoid system ports)
return Math.min(Math.max(uniquePort, 9001), 65000);
}
/**
* Get available port by checking if it's free
*/
export async function getAvailablePort(basePort: number): Promise<number> {
const net = require('net');
const isPortAvailable = (port: number): Promise<boolean> => {
return new Promise((resolve) => {
const server = net.createServer();
server.listen(port, () => {
server.close(() => resolve(true));
});
server.on('error', () => resolve(false));
});
};
let port = getUniqueTestPort(basePort);
let attempts = 0;
while (attempts < 10) {
if (await isPortAvailable(port)) {
return port;
}
port = port + Math.floor(Math.random() * 100) + 1;
attempts++;
}
// Fallback to completely random port in safe range
return Math.floor(Math.random() * (65000 - 9001)) + 9001;
}