Skip to main content
Glama
testRunner.ts13.8 kB
#!/usr/bin/env ts-node import * as fs from 'fs'; import * as path from 'path'; import { format } from 'util'; import { loadScenario } from './loadScenario'; import { setupMcpxContainer, ensureNetwork, teardownNetwork } from './docker'; import { startPlaywrightMcp, PlaywrightMcpHandle } from './playwrightMcp'; import { runSingleStep } from './runSingleStep'; import { validateOutput } from './validator'; import type { Scenario, SlackCleanupConfig } from './types'; import { stopAndRemove } from './docker'; import { ensureDir } from './utils'; import { createGrafanaServiceAccountToken, waitForGrafanaHealthy, assertGrafanaHealthy, } from './grafana'; import axios from 'axios'; import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { startDependentContainers, stopDependentContainers } from './dependentContainers'; import { createAgentController } from './aiAgents'; import type { AiAgentController } from './aiAgents'; let lastScenarioWarnings: string[] = []; let lastScenarioLabel = ''; let lastScenarioDir = ''; function formatWarningMessage(args: unknown[]): string { if (!args.length) { return ''; } try { return format(...(args as [unknown, ...unknown[]])); } catch { return args .map((arg) => { if (arg instanceof Error) { return arg.message; } if (typeof arg === 'string') { return arg; } try { return JSON.stringify(arg); } catch { return String(arg); } }) .join(' '); } } function emitScenarioWarningsSummary(): void { if (!lastScenarioWarnings.length) { return; } const location = lastScenarioDir ? ` (${lastScenarioDir})` : ''; const label = lastScenarioLabel || 'unknown scenario'; console.log(`\n⚠️ Warnings during scenario "${label}"${location}:`); for (const warning of lastScenarioWarnings) { console.log(` - ${warning}`); } } async function cleanupSlackMessages(configs?: SlackCleanupConfig[]) { if (!configs?.length) { return; } for (const config of configs) { const tokenEnv = config.tokenEnvVar ?? 'SLACK_MCP_XOXP_TOKEN'; const token = process.env[tokenEnv]; if (!token) { console.warn( `⚠️ Slack cleanup skipped for channel ${config.channelId}: missing ${tokenEnv}` ); continue; } const maxAgeMinutes = config.maxAgeMinutes ?? 30; const historyLimit = config.messageLimit ?? 20; const fragment = config.textFragment; try { const oldestSeconds = Date.now() / 1000 - maxAgeMinutes * 60; const params: Record<string, string | number> = { channel: config.channelId, limit: historyLimit, }; if (maxAgeMinutes > 0) { params.oldest = oldestSeconds.toFixed(6); } const historyResp = await axios.get('https://slack.com/api/conversations.history', { params, headers: { Authorization: `Bearer ${token}`, }, }); if (!historyResp.data?.ok) { console.warn( `⚠️ Slack cleanup unable to fetch history for ${config.channelId}: ${ historyResp.data?.error || 'unknown error' }` ); continue; } const messages: Array<{ ts?: string; text?: string }> = historyResp.data.messages ?? []; const matches = messages.filter( (msg) => typeof msg.text === 'string' && msg.text.includes(fragment) ); if (matches.length === 0) { console.log( `ℹ️ Slack cleanup found no messages containing "${fragment}" in ${config.channelId}` ); continue; } for (const msg of matches.slice(0, historyLimit)) { if (!msg.ts) { continue; } try { const body = new URLSearchParams({ channel: config.channelId, ts: msg.ts }); const deleteResp = await axios.post( 'https://slack.com/api/chat.delete', body.toString(), { headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/x-www-form-urlencoded', }, } ); if (!deleteResp.data?.ok) { console.warn( `⚠️ Slack cleanup failed to delete message ${msg.ts}: ${ deleteResp.data?.error || 'unknown error' }` ); } else { console.log(`🧹 Deleted Slack message ${msg.ts} from ${config.channelId}`); } } catch (err) { console.warn( `⚠️ Slack cleanup error deleting message ${msg.ts}:`, (err as Error).message ); } } } catch (err) { console.warn( `⚠️ Slack cleanup error while processing channel ${config.channelId}:`, (err as Error).message ); } } } async function runScenario(scenarioDir: string) { const scenarioWarnings: string[] = []; const originalWarn = console.warn; console.warn = ((...args: Parameters<typeof console.warn>) => { const formatted = formatWarningMessage(args as unknown[]); scenarioWarnings.push(formatted); originalWarn.apply(console, args as unknown[]); }) as typeof console.warn; const normalizedDir = path.isAbsolute(scenarioDir) ? path.relative(process.cwd(), scenarioDir) : path.normalize(scenarioDir); lastScenarioDir = normalizedDir; lastScenarioWarnings = []; lastScenarioLabel = path.basename(normalizedDir); let runName = lastScenarioLabel; try { const scenario: Scenario = loadScenario(scenarioDir); let agentController: AiAgentController | undefined; const dirName = path.basename(scenarioDir); // e.g. sample-backend runName = scenario.name ?? dirName; if (scenario.disableTest) { console.log(`Skipping scenario: ${scenario.name || dirName}`); return; } if (scenario.aiAgent) { agentController = createAgentController(scenario.aiAgent, { verboseOutput: scenario.verboseOutput ?? false, }); const prepResult = await agentController.prepare(); if (prepResult === 'skip') { console.log('Skipping scenario because required AI agent is unavailable.'); return; } } const networkName = `net-${dirName}`; const mcpxName = 'e2e-mcpx-gateway'; // main container name let pw: PlaywrightMcpHandle | undefined; const mountDir = path.resolve(scenarioDir, scenario.configMount || 'config'); // Ensure config dir exists so snapshotting won't throw ENOENT ensureDir(mountDir); // If we’re going to clean that mount, capture what was there to begin with: let initialConfigFiles: string[] = []; if (scenario.cleanConfigMount && scenario.configMount) { try { initialConfigFiles = fs.readdirSync(mountDir); console.log(`Initial files in ${mountDir}:`, initialConfigFiles); } catch (e) { console.warn('⚠️ Failed to snapshot initial config-mount:', e); } } console.log(`=== Running scenario: ${runName} ===`); try { // 0) Ensure the network exists console.log(`→ Ensuring network "${networkName}" exists`); await ensureNetwork(networkName); console.log(`→ Network "${networkName}" is ready`); // 1) start any dependent containers (e.g. grafana, playwright-mcp, etc.) if (scenario.dependentContainers?.length) { console.log('→ Starting dependent containers…'); await startDependentContainers(networkName, scenario.dependentContainers); } // 2) If Grafana is a dependency, wait for health and create a short-lived API token. if (scenario.dependentContainers?.some((d) => d.name === 'grafana')) { // Runner talks to Grafana via the host-published port const hostGrafana = 'http://127.0.0.1:3000'; await waitForGrafanaHealthy(hostGrafana, 120_000); await assertGrafanaHealthy(hostGrafana, 'admin', 'admin'); const token = await createGrafanaServiceAccountToken({ baseUrl: hostGrafana, adminUser: 'admin', adminPass: 'admin', saName: 'mcpx-e2e', tokenName: 'mcpx-e2e-token', ttlSeconds: 3600, }); // The grafana MCP container should use the host alias // (portable on Linux when docker run includes: --add-host host.docker.internal:host-gateway) const grafanaUrl = 'http://host.docker.internal:3000'; scenario.env = { ...(scenario.env ?? {}), GRAFANA_URL: grafanaUrl, GRAFANA_API_KEY: token, }; console.log('→ Grafana token created and injected into scenario env'); } // 3) Ensure the MCPX container is set up await setupMcpxContainer(mcpxName, scenarioDir, scenario, networkName); if (agentController) { await agentController.start(); } // run scenario steps let browserClient: Client | undefined; let browserTransport: SSEClientTransport | undefined; for (const step of scenario.steps) { let base: string; const stepName = step.name ?? step.toolName; console.log(`→ Step: ${stepName} (${step.kind})`); if (step.kind === 'browser') { if (!pw) pw = await startPlaywrightMcp(); if (!pw) throw new Error('Playwright MCP failed to start'); // Only first browser step creates the transport+client if (!browserTransport) { const url = `${pw.baseUrl}/messages`; console.log(` → Connecting to MCP server at ${url}`); browserTransport = new SSEClientTransport(new URL(url)); browserClient = new Client({ name: 'e2e', version: '1.0.0' }); await browserClient.connect(browserTransport); } base = pw.baseUrl; // host:port, NO path } else { base = 'http://localhost:9000'; } const output = await runSingleStep( step, base, browserClient, browserTransport, scenario.verboseOutput ?? false, agentController ); const result = validateOutput(output, step.expected); if (!result.success) { throw new Error(`Validation failed: ${result.errors?.join(' | ')}`); } console.log('✅ Validation passed'); } } finally { console.log('→ Cleaning up...'); // If requested, remove only the files that appeared during the test: if (scenario.cleanConfigMount && scenario.configMount) { const mountDir = path.resolve(scenarioDir, scenario.configMount); try { console.log(`🧹 Cleaning new files in ${mountDir}`); const allFiles = fs.readdirSync(mountDir); for (const file of allFiles) { if (!initialConfigFiles.includes(file)) { const full = path.join(mountDir, file); console.log(` → removing ${file}`); fs.rmSync(full, { recursive: true, force: true }); } } } catch (cleanupErr) { console.warn(`⚠️ Couldn't clean mount ${mountDir}:`, cleanupErr); } } if (agentController) { await agentController .cleanup() .catch((e) => console.warn('[cleanup] aiAgent:', (e as Error).message)); } if (scenario.cleanup?.slackMessages?.length) { console.log('→ Slack cleanup starting'); await cleanupSlackMessages(scenario.cleanup.slackMessages).catch((e) => console.warn('[cleanup] slack:', (e as Error).message) ); } if (pw) { pw.closeTransport(); //End SSE stream first await pw.shutdown().catch((e) => console.warn('[cleanup] playwright:', e.message)); } await stopAndRemove(mcpxName).catch((e) => console.warn('[cleanup] MCPX:', e.message)); if (scenario.dependentContainers?.length) { await stopDependentContainers(scenario.dependentContainers).catch((e) => console.warn('[cleanup] dependents:', e.message) ); } await teardownNetwork(networkName).catch((e) => console.warn('[cleanup] network:', e.message) ); console.log('→ Cleanup complete'); } } finally { const warningsSnapshot = scenarioWarnings.filter( (message) => typeof message === 'string' && message.trim().length > 0 ); console.warn = originalWarn; lastScenarioWarnings = warningsSnapshot; lastScenarioLabel = runName; const warningsFile = process.env.MCPX_E2E_WARNINGS_FILE; if (warningsFile && warningsSnapshot.length) { try { fs.appendFileSync( warningsFile, `${JSON.stringify({ scenario: runName, scenarioDir: normalizedDir, warnings: warningsSnapshot, timestamp: new Date().toISOString(), })}\n` ); } catch (err) { originalWarn('⚠️ Failed to append scenario warnings file:', (err as Error).message); } } } } /* ------------------------------------------------------------------ */ if (require.main === module) { const dir = process.argv[2]; if (!dir) { console.error('usage: ts-node src/testRunner.ts <scenario-dir>'); process.exit(1); } (async () => { try { await runScenario(dir); console.log('🎉 Scenario completed'); } catch (err) { console.error('💔 Scenario error:', (err as Error).message); process.exitCode = 1; } finally { emitScenarioWarningsSummary(); process.exit(process.exitCode ?? 0); } })(); }

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/TheLunarCompany/lunar'

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