Merge pull request #10 from modelcontextprotocol/e2e-testing
E2E testing
This commit is contained in:
parent
6a3130a075
commit
ace3220fca
2 changed files with 11726 additions and 17 deletions
339
test/setup/global-setup.ts
Normal file
339
test/setup/global-setup.ts
Normal 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
|
||||
}
|
||||
}
|
||||
174
test/setup/global-teardown.ts
Normal file
174
test/setup/global-teardown.ts
Normal 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!')
|
||||
}
|
||||
Loading…
Reference in a new issue