import { spawn, execSync, type ChildProcess } from 'node:child_process';
import { existsSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { homedir } from 'node:os';
import { log } from '../utils/logger.js';
import { WdaStartError } from '../types/wda.js';
import { resolveDevice } from '../utils/device-resolver.js';
import { simctlJson } from './simctl.js';
import type { SimctlListOutput, SimDevice } from '../types/simctl.js';
const DEFAULT_PORT = 8100;
const DEFAULT_PROJECT_PATH = '~/Projects/WebDriverAgent/WebDriverAgent.xcodeproj';
const DEFAULT_SCHEME = 'WebDriverAgentRunner';
const DEFAULT_TIMEOUT = 180; // seconds
const POLL_INTERVAL = 2000; // ms
const POLL_BACKOFF_MAX = 5000; // ms
export interface WdaProcess {
process: ChildProcess;
port: number;
device: string;
deviceName: string;
output: string;
startTime: number;
}
export interface WdaStartOptions {
device: string;
projectPath?: string;
scheme?: string;
port?: number;
timeout?: number;
force?: boolean;
}
export interface WdaStartResult {
url: string;
device: string;
deviceName: string;
startupTime: number;
}
// Registry of running WDA processes by port
const wdaRegistry = new Map<number, WdaProcess>();
/**
* Expand ~ to home directory
*/
function expandPath(path: string): string {
if (path.startsWith('~/')) {
return resolve(homedir(), path.slice(2));
}
return resolve(path);
}
/**
* Check if a port is in use by checking WDA status endpoint
*/
async function isPortInUse(port: number): Promise<boolean> {
try {
const response = await fetch(`http://localhost:${port}/status`, {
signal: AbortSignal.timeout(1000),
});
return response.ok;
} catch {
return false;
}
}
/**
* Kill all xcodebuild WebDriverAgent processes forcefully
*/
function forceKillAllWda(): void {
try {
execSync('pkill -9 -f "xcodebuild.*WebDriverAgent"', {
encoding: 'utf-8',
timeout: 5000,
});
} catch {
// No processes found
}
try {
execSync('pkill -9 -f "WebDriverAgentRunner"', {
encoding: 'utf-8',
timeout: 5000,
});
} catch {
// No processes found
}
}
/**
* Get device info by UDID
*/
function getDeviceByUdid(udid: string): SimDevice | null {
const listOutput = simctlJson<SimctlListOutput>('list', ['devices']);
for (const runtime of Object.keys(listOutput.devices)) {
const devices = listOutput.devices[runtime];
for (const device of devices) {
if (device.udid === udid) {
return device;
}
}
}
return null;
}
/**
* Wait for WDA server to be ready by polling /status endpoint
*/
async function waitForWdaReady(
port: number,
timeout: number,
proc: ChildProcess
): Promise<void> {
const startTime = Date.now();
const endTime = startTime + timeout * 1000;
let pollInterval = POLL_INTERVAL;
while (Date.now() < endTime) {
// Check if process has exited
if (proc.exitCode !== null) {
throw new WdaStartError(
'WDA process exited unexpectedly during startup',
'startup',
wdaRegistry.get(port)?.output
);
}
try {
const response = await fetch(`http://localhost:${port}/status`, {
signal: AbortSignal.timeout(2000),
});
if (response.ok) {
const data = await response.json() as { value?: { state?: string } };
if (data.value?.state) {
return; // WDA is ready
}
}
} catch {
// Not ready yet, continue polling
}
// Exponential backoff
await new Promise((r) => setTimeout(r, pollInterval));
pollInterval = Math.min(pollInterval * 1.2, POLL_BACKOFF_MAX);
}
throw new WdaStartError(
`WDA server did not become ready within ${timeout} seconds`,
'timeout',
wdaRegistry.get(port)?.output
);
}
/**
* Kill process group
*/
function killProcessGroup(proc: ChildProcess): void {
if (proc.pid && proc.exitCode === null) {
try {
// Kill the entire process group
process.kill(-proc.pid, 'SIGTERM');
} catch {
// Try regular kill if process group kill fails
try {
proc.kill('SIGTERM');
} catch {
// Process may already be dead
}
}
}
}
/**
* Start WDA and wait for it to be ready
*/
export async function startWda(options: WdaStartOptions): Promise<WdaStartResult> {
const port = options.port ?? DEFAULT_PORT;
const timeout = options.timeout ?? DEFAULT_TIMEOUT;
const projectPath = expandPath(options.projectPath ?? DEFAULT_PROJECT_PATH);
const scheme = options.scheme ?? DEFAULT_SCHEME;
// Pre-flight: Resolve device
let udid: string;
try {
udid = resolveDevice(options.device);
} catch {
throw new WdaStartError(
`Device not found: ${options.device}`,
'preflight'
);
}
// Pre-flight: Verify device is booted
const device = getDeviceByUdid(udid);
if (!device) {
throw new WdaStartError(
`Device not found with UDID: ${udid}`,
'preflight'
);
}
if (device.state !== 'Booted') {
throw new WdaStartError(
`Device "${device.name}" is not booted (state: ${device.state}). Boot it first with simctl_boot.`,
'preflight'
);
}
// Pre-flight: Check project exists
if (!existsSync(projectPath)) {
throw new WdaStartError(
`WebDriverAgent project not found at: ${projectPath}. ` +
`Clone it with: git clone https://github.com/appium/WebDriverAgent.git`,
'preflight'
);
}
// Pre-flight: Check if port is in use
if (await isPortInUse(port)) {
if (options.force) {
log(`Port ${port} in use, force killing existing WDA processes...`);
// Stop registered process if any
await stopWda(port);
// Force kill all WDA processes
forceKillAllWda();
// Wait for cleanup
await new Promise((r) => setTimeout(r, 5000));
// Verify port is now free
if (await isPortInUse(port)) {
throw new WdaStartError(
`Failed to free port ${port}. Try stopping WDA manually or rebooting the simulator.`,
'preflight'
);
}
} else {
throw new WdaStartError(
`Port ${port} is already in use. Use force=true to restart, or specify a different port.`,
'preflight'
);
}
}
// Check if we already have a process on this port in registry
if (wdaRegistry.has(port)) {
if (options.force) {
await stopWda(port);
} else {
throw new WdaStartError(
`WDA already running on port ${port}. Use force=true to restart.`,
'preflight'
);
}
}
// Build xcodebuild arguments
const args = [
'test',
'-project', projectPath,
'-scheme', scheme,
'-destination', `platform=iOS Simulator,id=${udid}`,
'CODE_SIGN_IDENTITY=-',
'CODE_SIGNING_REQUIRED=NO',
'CODE_SIGNING_ALLOWED=NO',
`USE_PORT=${port}`,
];
log(`Starting WDA: xcodebuild ${args.join(' ')}`);
const startTime = Date.now();
// Spawn xcodebuild with detached for process group cleanup
const proc = spawn('xcodebuild', args, {
detached: true,
stdio: ['ignore', 'pipe', 'pipe'],
cwd: dirname(projectPath),
});
log(`Spawned xcodebuild with PID ${proc.pid}`);
// Store in registry
const wdaProcess: WdaProcess = {
process: proc,
port,
device: udid,
deviceName: device.name,
output: '',
startTime,
};
wdaRegistry.set(port, wdaProcess);
// Capture output for debugging
proc.stdout?.on('data', (data: Buffer) => {
const text = data.toString();
wdaProcess.output += text;
// Check for ServerURLHere pattern as secondary confirmation
if (text.includes('ServerURLHere')) {
log('WDA: ServerURLHere detected in output');
}
});
proc.stderr?.on('data', (data: Buffer) => {
wdaProcess.output += data.toString();
});
// Cleanup on exit
proc.on('exit', (code, signal) => {
log(`WDA process on port ${port} exited with code ${code}, signal ${signal}`);
wdaRegistry.delete(port);
});
proc.on('error', (error) => {
log(`WDA process error: ${error.message}`);
wdaRegistry.delete(port);
});
// Wait for WDA to be ready
try {
await waitForWdaReady(port, timeout, proc);
} catch (error) {
// Cleanup on failure
killProcessGroup(proc);
wdaRegistry.delete(port);
throw error;
}
const startupTime = (Date.now() - startTime) / 1000;
return {
url: `http://localhost:${port}`,
device: udid,
deviceName: device.name,
startupTime,
};
}
/**
* Stop WDA process on a given port
*/
export async function stopWda(port: number = DEFAULT_PORT): Promise<boolean> {
const wdaProcess = wdaRegistry.get(port);
if (wdaProcess) {
log(`Stopping WDA on port ${port}...`);
killProcessGroup(wdaProcess.process);
wdaRegistry.delete(port);
// Wait for process to terminate
await new Promise<void>((resolve) => {
const checkExit = () => {
if (wdaProcess.process.exitCode !== null) {
resolve();
} else {
setTimeout(checkExit, 100);
}
};
// Force kill after 5 seconds
setTimeout(() => {
if (wdaProcess.process.exitCode === null) {
try {
process.kill(-wdaProcess.process.pid!, 'SIGKILL');
} catch {
try {
wdaProcess.process.kill('SIGKILL');
} catch {
// Already dead
}
}
}
resolve();
}, 5000);
checkExit();
});
return true;
}
return false;
}
/**
* Stop all WDA processes
*/
export async function stopAllWda(): Promise<void> {
const ports = Array.from(wdaRegistry.keys());
for (const port of ports) {
await stopWda(port);
}
}
/**
* Get running WDA processes
*/
export function getRunningWda(): Map<number, WdaProcess> {
return new Map(wdaRegistry);
}
/**
* Check if WDA is running on a port
*/
export function isWdaRunning(port: number = DEFAULT_PORT): boolean {
return wdaRegistry.has(port);
}