Merge pull request #10 from modelcontextprotocol/e2e-testing

E2E testing
This commit is contained in:
Glen Maddern 2025-06-30 21:07:45 +10:00 • committed by GitHub
commit ace3220fca
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 11726 additions and 17 deletions

339
test/setup/global-setup.ts Normal file
View file

@ -0,0 +1,339 @@
import { spawn, ChildProcess } from 'child_process'
import { join, dirname } from 'path'
import { fileURLToPath } from 'url'
import { writeFileSync, mkdirSync, readFileSync } from 'fs'
import { createServer, Server } from 'http'
import { parse } from 'url'
import { extname } from 'path'
const __dirname = dirname(fileURLToPath(import.meta.url))
const rootDir = join(__dirname, '../..')
const testDir = join(__dirname, '..')
const cacheDir = join(testDir, 'node_modules/.cache/use-mcp-tests')
const testStateFile = join(cacheDir, 'test-state.json')
interface GlobalState {
honoServer?: ChildProcess
staticServer?: Server
staticPort?: number
honoPort?: number
processGroupId?: number
allChildProcesses?: Set<number>
}
declare global {
var __INTEGRATION_TEST_STATE__: GlobalState
}
function waitForOutput(process: ChildProcess, targetOutput: string, timeout = 30000): Promise<void> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(`Timeout waiting for: ${targetOutput}`))
}, timeout)
const onData = (data: Buffer) => {
const output = data.toString()
console.log(`[server output] ${output}`)
if (output.includes(targetOutput)) {
clearTimeout(timer)
process.stdout?.off('data', onData)
process.stderr?.off('data', onData)
resolve()
}
}
process.stdout?.on('data', onData)
process.stderr?.on('data', onData)
})
}
async function checkPortAvailable(port: number): Promise<boolean> {
return new Promise((resolve) => {
const server = require('net').createServer()
server.listen(port, () => {
server.close(() => {
// Wait a bit for the port to fully close
setTimeout(() => resolve(true), 100)
})
})
server.on('error', (err: any) => {
console.log(`Port ${port} check failed: ${err.message}`)
resolve(false)
})
})
}
async function checkPortNotUsedByWrangler(port: number): Promise<boolean> {
// First check if port is available at network level
const networkAvailable = await checkPortAvailable(port)
if (!networkAvailable) {
return false
}
// Then check if there are wrangler processes using this port
return new Promise((resolve) => {
const { spawn } = require('child_process')
const lsof = spawn('lsof', ['-ti', `:${port}`])
let output = ''
lsof.stdout?.on('data', (data: Buffer) => {
output += data.toString()
})
lsof.on('close', () => {
if (output.trim()) {
// Port is in use
resolve(false)
} else {
// Port is free
resolve(true)
}
})
lsof.on('error', () => {
// lsof failed, assume port is available
resolve(true)
})
})
}
function findAvailablePortFromBase(basePort: number): Promise<number> {
return new Promise(async (resolve, reject) => {
for (let port = basePort; port < basePort + 20; port++) {
if (await checkPortNotUsedByWrangler(port)) {
resolve(port)
return
}
}
reject(new Error(`No available ports found starting from ${basePort}. Please stop any existing wrangler/workerd processes.`))
})
}
function runCommand(command: string, args: string[], cwd: string): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd,
stdio: 'inherit',
shell: true,
})
child.on('close', (code) => {
if (code === 0) {
resolve()
} else {
reject(new Error(`Command failed with code ${code}: ${command} ${args.join(' ')}`))
}
})
child.on('error', reject)
})
}
function findAvailablePort(startPort = 8000): Promise<number> {
return new Promise((resolve) => {
const server = createServer()
server
.listen(startPort, () => {
const port = (server.address() as any)?.port
server.close(() => resolve(port))
})
.on('error', () => {
resolve(findAvailablePort(startPort + 1))
})
})
}
export default async function globalSetup() {
console.log('🔧 Setting up integration test environment...')
const state: GlobalState = {
allChildProcesses: new Set<number>(),
}
globalThis.__INTEGRATION_TEST_STATE__ = state
// Set up signal handlers for cleanup
const cleanup = () => {
// Kill all tracked child processes
if (state.allChildProcesses) {
for (const pid of state.allChildProcesses) {
try {
process.kill(pid, 'SIGTERM')
setTimeout(() => {
try {
process.kill(pid, 'SIGKILL')
} catch (e) {
// Process already dead, ignore
}
}, 1000)
} catch (e) {
// Process already dead, ignore
}
}
}
// Also try process group cleanup as backup
if (state.processGroupId) {
try {
process.kill(-state.processGroupId, 'SIGTERM')
setTimeout(() => {
try {
process.kill(-state.processGroupId!, 'SIGKILL')
} catch (e) {
// Ignore errors
}
}, 1000)
} catch (e) {
// Ignore errors - process group may not exist
}
}
if (state.staticServer) {
state.staticServer.close()
state.staticServer.closeAllConnections?.()
}
}
process.on('SIGINT', cleanup)
process.on('SIGTERM', cleanup)
process.on('exit', cleanup)
try {
// Step 1: Build use-mcp library
console.log('📦 Building use-mcp library...')
await runCommand('pnpm', ['build'], rootDir)
// Step 2: Build inspector example
console.log('📦 Building inspector example...')
const inspectorDir = join(rootDir, 'examples/inspector')
await runCommand('pnpm', ['build'], inspectorDir)
// Step 3: Find available port and start hono-mcp server
console.log('🔍 Finding available port starting from 9901...')
const honoPort = await findAvailablePortFromBase(9901)
console.log(`📍 Using port ${honoPort} for hono-mcp server`)
console.log('🚀 Starting hono-mcp server...')
const honoDir = join(rootDir, 'examples/servers/hono-mcp')
const honoServer = spawn('pnpm', ['dev', `--port=${honoPort}`], {
cwd: honoDir,
stdio: ['ignore', 'pipe', 'pipe'],
shell: true,
detached: false, // Keep in same process group initially
})
// Track all child processes
if (honoServer.pid) {
state.allChildProcesses!.add(honoServer.pid)
}
// Store the process group ID and port
state.processGroupId = honoServer.pid
state.honoPort = honoPort
state.honoServer = honoServer
honoServer.stdout?.on('data', (data) => {
console.log(`[hono-mcp] ${data.toString()}`)
})
honoServer.stderr?.on('data', (data) => {
console.log(`[hono-mcp] ${data.toString()}`)
})
// Track when the process exits to remove it from our tracking
honoServer.on('exit', () => {
if (honoServer.pid) {
state.allChildProcesses!.delete(honoServer.pid)
}
})
// Wait for hono server to be ready
await waitForOutput(honoServer, 'Ready on')
state.honoServer = honoServer
// Step 4: Start simple static file server for inspector
console.log('🌐 Starting static file server for inspector...')
const inspectorDistDir = join(inspectorDir, 'dist')
const staticPort = await findAvailablePort(8000)
const staticServer = createServer((req, res) => {
const pathname = parse(req.url || '').pathname || '/'
let filePath = join(inspectorDistDir, pathname === '/' ? 'index.html' : pathname)
// Always disable keep-alive
res.setHeader('Connection', 'close')
try {
const data = readFileSync(filePath)
const ext = extname(filePath)
const contentType =
{
'.html': 'text/html',
'.js': 'application/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
}[ext] || 'text/plain'
res.writeHead(200, { 'Content-Type': contentType })
res.end(data)
} catch (e) {
// Fallback to index.html for SPA routing
try {
const indexData = readFileSync(join(inspectorDistDir, 'index.html'))
res.writeHead(200, { 'Content-Type': 'text/html' })
res.end(indexData)
} catch (e2) {
res.writeHead(404, { 'Content-Type': 'text/plain' })
res.end('Not Found')
}
}
})
// Configure for immediate shutdown
staticServer.keepAliveTimeout = 0
staticServer.headersTimeout = 1
await new Promise<void>((resolve) => {
staticServer.listen(staticPort, () => {
console.log(`📁 Static server running on http://localhost:${staticPort}`)
resolve()
})
})
state.staticServer = staticServer
state.staticPort = staticPort
// Write state to file for tests to read
mkdirSync(cacheDir, { recursive: true })
writeFileSync(
testStateFile,
JSON.stringify(
{
honoPort: state.honoPort,
staticPort: state.staticPort,
},
null,
2,
),
)
console.log('✅ Integration test environment ready!')
} catch (error) {
console.error('❌ Failed to set up integration test environment:', error)
// Cleanup on failure
if (state.honoServer) {
state.honoServer.kill()
}
if (state.staticServer) {
state.staticServer.close()
}
throw error
}
}

View file

@ -0,0 +1,174 @@
function isProcessRunning(pid: number): boolean {
try {
// Sending signal 0 checks if process exists without actually sending a signal
process.kill(pid, 0)
return true
} catch (e) {
return false
}
}
async function findChildProcesses(parentPid: number): Promise<number[]> {
return new Promise((resolve) => {
const { spawn } = require('child_process')
// Use ps to find all child processes
const ps = spawn('ps', ['-o', 'pid,ppid', '-ax'])
let output = ''
ps.stdout?.on('data', (data: Buffer) => {
output += data.toString()
})
ps.on('close', () => {
const lines = output.split('\n')
const children: number[] = []
for (const line of lines) {
const match = line.trim().match(/^(\d+)\s+(\d+)/)
if (match) {
const pid = parseInt(match[1])
const ppid = parseInt(match[2])
if (ppid === parentPid) {
children.push(pid)
}
}
}
resolve(children)
})
ps.on('error', () => {
resolve([])
})
})
}
async function killProcessSafely(pid: number, name: string = 'process'): Promise<void> {
if (!isProcessRunning(pid)) {
console.log(`✅ ${name} (${pid}) already stopped`)
return
}
try {
console.log(`🛑 Sending SIGTERM to ${name} (${pid})`)
process.kill(pid, 'SIGTERM')
// Wait up to 3 seconds for graceful shutdown
for (let i = 0; i < 30; i++) {
await new Promise((resolve) => setTimeout(resolve, 100))
if (!isProcessRunning(pid)) {
console.log(`✅ ${name} (${pid}) gracefully stopped`)
return
}
}
// Force kill if still running
if (isProcessRunning(pid)) {
console.log(`⚡ Force killing ${name} (${pid})`)
process.kill(pid, 'SIGKILL')
// Give it a moment to die
await new Promise((resolve) => setTimeout(resolve, 100))
if (isProcessRunning(pid)) {
console.warn(`⚠️ ${name} (${pid}) still running after SIGKILL`)
} else {
console.log(`✅ ${name} (${pid}) force killed`)
}
}
} catch (e: any) {
if (e.code === 'ESRCH') {
console.log(`✅ ${name} (${pid}) already stopped`)
} else {
console.warn(`Error killing ${name} (${pid}):`, e.message)
}
}
}
export default async function globalTeardown() {
console.log('🧹 Cleaning up integration test environment...')
const state = globalThis.__INTEGRATION_TEST_STATE__
// Kill all tracked child processes first
if (state?.allChildProcesses && state.allChildProcesses.size > 0) {
console.log(`🛑 Cleaning up ${state.allChildProcesses.size} tracked child processes...`)
// Find all child processes of our tracked processes too
const allPidsToKill = new Set<number>()
for (const pid of state.allChildProcesses) {
allPidsToKill.add(pid)
// Find children of this process
const children = await findChildProcesses(pid)
for (const childPid of children) {
allPidsToKill.add(childPid)
}
}
console.log(`🛑 Found ${allPidsToKill.size} total processes to clean up (including children)`)
const killPromises = Array.from(allPidsToKill).map((pid) => killProcessSafely(pid, 'process'))
await Promise.all(killPromises)
}
// Also clean up by process group as backup
if (state?.processGroupId && isProcessRunning(state.processGroupId)) {
console.log('🛑 Cleaning up process group as backup...')
try {
// Send SIGTERM to the entire process group first
console.log(`💀 Sending SIGTERM to process group ${state.processGroupId}`)
process.kill(-state.processGroupId, 'SIGTERM')
// Wait a bit for graceful shutdown
await new Promise((resolve) => setTimeout(resolve, 1000))
// Check if any processes in the group are still running and force kill if needed
try {
process.kill(-state.processGroupId, 0) // Check if group exists
console.log(`⚡ Sending SIGKILL to process group ${state.processGroupId}`)
process.kill(-state.processGroupId, 'SIGKILL')
} catch (e: any) {
if (e.code !== 'ESRCH') {
console.warn('Error force-killing process group:', e.message)
}
}
} catch (e: any) {
if (e.code !== 'ESRCH') {
console.warn('Error terminating process group:', e.message)
}
}
}
if (state?.staticServer) {
console.log('🛑 Stopping static file server...')
await new Promise<void>((resolve) => {
state.staticServer?.close((err) => {
if (err) {
console.warn('Warning closing static server:', err.message)
}
resolve()
})
})
// Force close all keep-alive connections
state.staticServer?.closeAllConnections?.()
}
// Clear references
if (state) {
state.honoServer = undefined
state.staticServer = undefined
state.processGroupId = undefined
}
// Force garbage collection if available
if (global.gc) {
global.gc()
}
console.log('✅ Cleanup complete!')
}