Skip to main content
Glama
setup.ts14.5 kB
/** * E2E Test Setup * Provides utilities for testing the MCP server as a whole */ import { spawn, ChildProcess, execFileSync } from 'child_process'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; import { vi } from 'vitest'; /** * Device availability and auto-launch utilities */ export interface DeviceSetupResult { androidAvailable: boolean; iosAvailable: boolean; androidDeviceId: string | null; iosDeviceId: string | null; androidLaunched: boolean; iosLaunched: boolean; } /** * Check if Android device/emulator is available */ export async function isAndroidAvailable(): Promise<boolean> { try { const result = execFileSync('adb', ['devices'], { encoding: 'utf-8', timeout: 10000 }); const lines = result.split('\n').filter(l => l.includes('device') && !l.includes('List')); return lines.length > 0; } catch { return false; } } /** * Check if iOS simulator is booted */ export async function isIOSAvailable(): Promise<boolean> { try { const result = execFileSync('xcrun', ['simctl', 'list', 'devices'], { encoding: 'utf-8', timeout: 10000 }); return result.includes('(Booted)'); } catch { return false; } } /** * Get first booted iOS device UDID */ export async function getBootedIOSDevice(): Promise<string | null> { try { const result = execFileSync('xcrun', ['simctl', 'list', 'devices'], { encoding: 'utf-8', timeout: 10000 }); const bootedMatch = result.match(/([A-F0-9-]{36})\) \(Booted\)/); return bootedMatch ? bootedMatch[1] : null; } catch { return null; } } /** * Get first connected Android device ID */ export async function getAndroidDeviceId(): Promise<string | null> { try { const result = execFileSync('adb', ['devices'], { encoding: 'utf-8', timeout: 10000 }); const lines = result.split('\n'); for (const line of lines) { const match = line.match(/^([\w-]+)\s+device$/); if (match) return match[1]; } return null; } catch { return null; } } /** * List available Android AVDs */ export async function listAndroidAvds(): Promise<string[]> { try { const result = execFileSync('emulator', ['-list-avds'], { encoding: 'utf-8', timeout: 10000 }); return result.split('\n').filter(line => line.trim().length > 0); } catch { return []; } } /** * List available iOS simulators */ export async function listIOSSimulators(): Promise<Array<{ udid: string; name: string; state: string }>> { try { const result = execFileSync('xcrun', ['simctl', 'list', 'devices', 'available', '--json'], { encoding: 'utf-8', timeout: 10000 }); const parsed = JSON.parse(result); const devices: Array<{ udid: string; name: string; state: string }> = []; for (const runtime of Object.values(parsed.devices) as Array<Array<{ udid: string; name: string; state: string; isAvailable?: boolean }>>) { for (const device of runtime) { if (device.isAvailable !== false) { devices.push({ udid: device.udid, name: device.name, state: device.state, }); } } } return devices; } catch { return []; } } /** * Launch Android emulator if none is running * @param avdName Optional specific AVD name, otherwise uses first available * @param timeoutMs Timeout to wait for emulator to boot (default: 120s) * @returns Device ID if launched successfully, null otherwise */ export async function ensureAndroidEmulator( avdName?: string, timeoutMs = 120000 ): Promise<string | null> { // Check if already available if (await isAndroidAvailable()) { console.log('[setup] Android emulator already running'); return getAndroidDeviceId(); } // Get AVD to launch const avds = await listAndroidAvds(); const targetAvd = avdName || avds[0]; if (!targetAvd) { console.log('[setup] No Android AVDs available to launch'); return null; } console.log(`[setup] Launching Android emulator: ${targetAvd}...`); // Launch emulator in background (non-blocking) const emulatorProcess = spawn('emulator', ['-avd', targetAvd, '-no-snapshot-load'], { detached: true, stdio: 'ignore', }); emulatorProcess.unref(); // Wait for device to be available const startTime = Date.now(); const pollInterval = 3000; while (Date.now() - startTime < timeoutMs) { await new Promise(resolve => setTimeout(resolve, pollInterval)); if (await isAndroidAvailable()) { // Wait a bit more for the device to be fully ready await new Promise(resolve => setTimeout(resolve, 5000)); // Wait for boot completion try { execFileSync('adb', ['wait-for-device'], { timeout: 30000 }); // Check if boot is complete const bootCompleted = execFileSync('adb', ['shell', 'getprop', 'sys.boot_completed'], { encoding: 'utf-8', timeout: 10000, }).trim(); if (bootCompleted === '1') { console.log('[setup] Android emulator booted successfully'); return getAndroidDeviceId(); } } catch { // Continue waiting } } } console.log('[setup] Timeout waiting for Android emulator to boot'); return null; } /** * Launch iOS simulator if none is running * @param udidOrName Optional specific simulator UDID or name, otherwise uses first available iPhone * @param timeoutMs Timeout to wait for simulator to boot (default: 60s) * @returns Device UDID if launched successfully, null otherwise */ export async function ensureIOSSimulator( udidOrName?: string, timeoutMs = 60000 ): Promise<string | null> { // Check if already available if (await isIOSAvailable()) { console.log('[setup] iOS simulator already running'); return getBootedIOSDevice(); } // Get simulator to launch const simulators = await listIOSSimulators(); let targetSim: { udid: string; name: string } | undefined; if (udidOrName) { targetSim = simulators.find(s => s.udid === udidOrName || s.name === udidOrName); } else { // Prefer iPhone simulators targetSim = simulators.find(s => s.name.includes('iPhone')) || simulators[0]; } if (!targetSim) { console.log('[setup] No iOS simulators available to launch'); return null; } console.log(`[setup] Launching iOS simulator: ${targetSim.name}...`); try { // Boot the simulator execFileSync('xcrun', ['simctl', 'boot', targetSim.udid], { timeout: 30000 }); // Open Simulator.app to show the UI spawn('open', ['-a', 'Simulator'], { detached: true, stdio: 'ignore' }).unref(); // Wait for simulator to be fully booted const startTime = Date.now(); const pollInterval = 2000; while (Date.now() - startTime < timeoutMs) { await new Promise(resolve => setTimeout(resolve, pollInterval)); if (await isIOSAvailable()) { console.log('[setup] iOS simulator booted successfully'); return targetSim.udid; } } console.log('[setup] Timeout waiting for iOS simulator to boot'); return null; } catch (error) { const message = error instanceof Error ? error.message : String(error); // Check if already booted if (message.includes('already booted')) { console.log('[setup] iOS simulator already booted'); return targetSim.udid; } console.log(`[setup] Failed to boot iOS simulator: ${message}`); return null; } } /** * Ensure both Android emulator and iOS simulator are available * Launches them if not running * @returns Setup result with device availability and IDs */ export async function ensureDevicesAvailable(): Promise<DeviceSetupResult> { console.log('[setup] Checking device availability...'); // Check initial state const initialAndroid = await isAndroidAvailable(); const initialIOS = await isIOSAvailable(); const result: DeviceSetupResult = { androidAvailable: initialAndroid, iosAvailable: initialIOS, androidDeviceId: null, iosDeviceId: null, androidLaunched: false, iosLaunched: false, }; // Launch missing devices in parallel const promises: Promise<void>[] = []; if (!initialAndroid) { promises.push( ensureAndroidEmulator().then(deviceId => { if (deviceId) { result.androidAvailable = true; result.androidDeviceId = deviceId; result.androidLaunched = true; } }) ); } else { result.androidDeviceId = await getAndroidDeviceId(); } if (!initialIOS) { promises.push( ensureIOSSimulator().then(deviceId => { if (deviceId) { result.iosAvailable = true; result.iosDeviceId = deviceId; result.iosLaunched = true; } }) ); } else { result.iosDeviceId = await getBootedIOSDevice(); } await Promise.all(promises); console.log(`[setup] Device setup complete:`); console.log(` Android: ${result.androidAvailable ? 'available' : 'not available'} (${result.androidDeviceId || 'none'})${result.androidLaunched ? ' [launched]' : ''}`); console.log(` iOS: ${result.iosAvailable ? 'available' : 'not available'} (${result.iosDeviceId || 'none'})${result.iosLaunched ? ' [launched]' : ''}`); return result; } /** * Test client wrapper for E2E tests */ export class TestClient { private client: Client | null = null; private transport: StdioClientTransport | null = null; private serverProcess: ChildProcess | null = null; /** * Start the MCP server and connect a client */ async connect(): Promise<void> { // Spawn the server process this.serverProcess = spawn('node', ['dist/index.js'], { cwd: process.cwd(), stdio: ['pipe', 'pipe', 'pipe'], }); // Create transport using the server's stdio this.transport = new StdioClientTransport({ command: 'node', args: ['dist/index.js'], }); // Create and connect client this.client = new Client( { name: 'specter-mcp-test-client', version: '1.0.0', }, { capabilities: {}, } ); await this.client.connect(this.transport); } /** * Disconnect client and stop server */ async disconnect(): Promise<void> { if (this.client) { await this.client.close(); this.client = null; } if (this.transport) { await this.transport.close(); this.transport = null; } if (this.serverProcess) { this.serverProcess.kill('SIGTERM'); this.serverProcess = null; } } /** * List all available tools */ async listTools(): Promise<{ name: string; description?: string }[]> { if (!this.client) { throw new Error('Client not connected'); } const result = await this.client.listTools(); return result.tools.map((t) => ({ name: t.name, description: t.description, })); } /** * Call a tool with arguments */ async callTool<T = unknown>( name: string, args: Record<string, unknown> = {} ): Promise<{ result: T; isError: boolean; }> { if (!this.client) { throw new Error('Client not connected'); } const response = await this.client.callTool({ name, arguments: args, }); const content = response.content[0]; if (content?.type !== 'text') { throw new Error('Unexpected response type'); } const parsed = JSON.parse(content.text); return { result: parsed.error ? parsed : parsed, isError: response.isError ?? false, }; } } /** * In-process test helper that doesn't spawn a subprocess * Useful for faster unit-like E2E tests */ export class InProcessTestClient { private registry: typeof import('../../src/tools/register.js') | null = null; async setup(): Promise<void> { // Dynamically import to avoid circular dependencies this.registry = await import('../../src/tools/register.js'); await this.registry.registerAllTools(); } async teardown(): Promise<void> { if (this.registry) { this.registry.getToolRegistry().clear(); } } async listTools(): Promise<string[]> { if (!this.registry) { throw new Error('Test client not set up'); } return this.registry.getToolRegistry().listTools().map((t) => t.name); } async callTool<T = unknown>( name: string, args: Record<string, unknown> = {} ): Promise<T> { if (!this.registry) { throw new Error('Test client not set up'); } const tool = this.registry.getToolRegistry().getTool(name); if (!tool) { throw new Error(`Tool not found: ${name}`); } return tool.handler(args) as Promise<T>; } } /** * Create a mock for shell commands in E2E tests */ export async function mockShellCommand( responses: Record<string, { stdout: string; stderr?: string; exitCode?: number }> ): Promise<void> { const shellModule = vi.mocked( await import('../../src/utils/shell.js') ); shellModule.executeShell.mockImplementation(async (command, args = []) => { const key = `${command} ${args.join(' ')}`.trim(); const response = responses[key] || responses[command]; if (!response) { return { stdout: '', stderr: `Command not mocked: ${key}`, exitCode: 1, }; } return { stdout: response.stdout, stderr: response.stderr ?? '', exitCode: response.exitCode ?? 0, }; }); } /** * Wait for a condition with timeout */ export async function waitFor( condition: () => boolean | Promise<boolean>, timeoutMs = 5000, intervalMs = 100 ): Promise<void> { const startTime = Date.now(); while (Date.now() - startTime < timeoutMs) { if (await condition()) { return; } await new Promise((resolve) => setTimeout(resolve, intervalMs)); } throw new Error(`Timeout waiting for condition after ${timeoutMs}ms`); } /** * Create temporary test fixtures */ export function createFixtures(): { cleanup: () => Promise<void>; paths: Record<string, string>; } { const paths: Record<string, string> = {}; const filesToCleanup: string[] = []; return { paths, cleanup: async () => { // Clean up any created files for (const file of filesToCleanup) { try { await import('fs/promises').then((fs) => fs.unlink(file)); } catch { // Ignore cleanup errors } } }, }; }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/abd3lraouf/specter-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server