Skip to main content
Glama

DollhouseMCP

by DollhouseMCP
qa-test-runner.jsโ€ข26.4 kB
#!/usr/bin/env node /** * QA Test Runner for DollhouseMCP * * Programmatically tests all MCP tools via the Inspector API * Addresses Issue #629 - Comprehensive QA Testing Process * Updated for Issue #663 - CI/CD QA Integration */ import fetch from 'node-fetch'; import { writeFileSync, mkdirSync } from 'fs'; import { dirname } from 'path'; import { spawn } from 'child_process'; import { fileURLToPath } from 'url'; import { existsSync } from 'fs'; import { discoverAvailableTools, validateToolExists, calculateAccurateSuccessRate, createTestResult, logTestResult, generateTestReport, isCI, ensureDirectoryExists } from './qa-utils.js'; import { TestDataCleanup } from './qa-cleanup-manager.js'; import { QAMetricsCollector, withMetrics } from './qa-metrics-collector.js'; import DashboardGenerator from './qa-dashboard-generator.js'; let INSPECTOR_URL = 'http://localhost:6277'; let MESSAGE_ENDPOINT = '/message'; let SESSION_TOKEN = process.env.MCP_SESSION_TOKEN || ''; // CI Environment Detection const CI_ENVIRONMENT = isCI(); const TEST_PERSONAS_DIR = process.env.TEST_PERSONAS_DIR || (CI_ENVIRONMENT ? '/tmp/test-personas' : undefined); class MCPTestRunner { constructor() { this.results = []; this.startTime = new Date(); this.availableTools = []; // Initialize as empty array to prevent race conditions this.isCI = CI_ENVIRONMENT; this.cleanup = []; // Track cleanup operations for CI (legacy - replaced by TestDataCleanup) this.mcpProcess = null; // Track the MCP server process this.serverReady = false; // Track server readiness // Initialize cleanup manager with unique test run ID this.testCleanup = new TestDataCleanup(`QA_TEST_RUNNER_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`); // Initialize metrics collector this.metricsCollector = new QAMetricsCollector(`QA_RUNNER_${Date.now()}`); // Set up CI-specific configurations if (this.isCI) { console.log('๐Ÿค– Running in CI environment'); console.log(`๐Ÿ“ TEST_PERSONAS_DIR: ${TEST_PERSONAS_DIR}`); // Create test personas directory if needed if (TEST_PERSONAS_DIR) { ensureDirectoryExists(TEST_PERSONAS_DIR); } } } async startMCPServer() { console.log('๐Ÿš€ Starting MCP Inspector for QA testing...'); const serverStartTime = Date.now(); // Check if dist/index.js exists const serverPath = 'dist/index.js'; if (!existsSync(serverPath)) { throw new Error('MCP server build not found at dist/index.js. Run "npm run build" first.'); } // Start the MCP Inspector process (which wraps the server) with auth disabled for testing this.mcpProcess = spawn('npx', ['@modelcontextprotocol/inspector', 'node', serverPath], { stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, TEST_MODE: 'true', NODE_ENV: 'test', DANGEROUSLY_OMIT_AUTH: 'true' // WARNING: Test-only configuration - NEVER use in production } }); // Set up process event handlers this.mcpProcess.on('error', (error) => { console.error('โŒ Failed to start MCP Inspector:', error.message); throw error; }); this.mcpProcess.on('exit', (code, signal) => { if (code !== 0 && code !== null) { console.warn(`โš ๏ธ MCP Inspector exited with code ${code}`); } }); // Capture output to extract session token and port let output = ''; this.mcpProcess.stdout.on('data', (data) => { const chunk = data.toString(); output += chunk; // Look for session token in output const tokenMatch = chunk.match(/๐Ÿ”‘ Session token: ([a-f0-9]+)/); if (tokenMatch) { SESSION_TOKEN = tokenMatch[1]; console.log('๐Ÿ”‘ Extracted session token from Inspector'); } // Look for port in output const portMatch = chunk.match(/Proxy server listening on localhost:(\d+)/); if (portMatch) { const port = portMatch[1]; INSPECTOR_URL = `http://localhost:${port}`; console.log(`๐Ÿ“ก Inspector running on port ${port}`); // Give the Inspector a moment to fully initialize the HTTP server setTimeout(() => { console.log(' Inspector HTTP server should be ready now'); }, 3000); } }); this.mcpProcess.stderr.on('data', (data) => { const stderr = data.toString(); console.warn('Inspector stderr:', stderr); // If there's a critical error, fail fast if (stderr.includes('Failed to connect to MCP server') || stderr.includes('Server process exited') || stderr.includes('ENOENT')) { console.error('โŒ Critical Inspector error detected'); throw new Error(`Inspector startup failed: ${stderr.trim()}`); } }); // Wait for server to be ready await this.waitForServerReady(); const serverEndTime = Date.now(); this.metricsCollector.recordServerStartup(serverStartTime, serverEndTime); console.log('โœ… MCP Inspector started and ready'); } async waitForServerReady(maxRetries = 20, delay = 2000) { console.log('โณ Waiting for MCP Inspector to be ready...'); for (let i = 0; i < maxRetries; i++) { try { // Try different common endpoints until one works const endpoints = ['/message', '/api/message', '/sessions', '/rpc']; let response = null; let workingEndpoint = null; for (const endpoint of endpoints) { try { const fullUrl = INSPECTOR_URL + endpoint; response = await fetch(fullUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ method: 'tools/list' }) }); if (response.ok || response.status !== 404) { workingEndpoint = endpoint; MESSAGE_ENDPOINT = endpoint; console.log(`๐Ÿ” Found working endpoint: ${endpoint}`); break; } } catch (error) { // Continue trying other endpoints } } if (!response) { throw new Error('No working endpoint found'); } if (response.ok) { console.log(`โœ… Inspector ready after ${(i + 1) * delay}ms`); console.log(`๐Ÿ“ก Using Inspector URL: ${INSPECTOR_URL}${MESSAGE_ENDPOINT}`); this.serverReady = true; return; } else { console.log(` HTTP ${response.status}: ${response.statusText}`); } } catch (error) { // Inspector not ready yet, continue waiting if (i === 0) { console.log(` Connecting to: ${INSPECTOR_URL}${MESSAGE_ENDPOINT}`); } if (i < 3) { console.log(` Connection error: ${error.message}`); // More detailed error for the first few attempts if (error.code) { console.log(` Error code: ${error.code}`); } } } console.log(` Attempt ${i + 1}/${maxRetries}: Inspector not ready, waiting ${delay}ms...`); await new Promise(resolve => setTimeout(resolve, delay)); } console.error('\n๐Ÿ” Debug Info:'); console.error(' Inspector URL:', INSPECTOR_URL + MESSAGE_ENDPOINT); console.error(' Session Token length:', SESSION_TOKEN.length); console.error(' Process still running:', this.mcpProcess && !this.mcpProcess.killed); throw new Error(`MCP Inspector failed to become ready after ${maxRetries * delay}ms`); } async stopMCPServer() { if (this.mcpProcess) { console.log('๐Ÿ›‘ Stopping MCP Inspector...'); // Try graceful shutdown first this.mcpProcess.kill('SIGTERM'); // Wait a bit for graceful shutdown await new Promise(resolve => setTimeout(resolve, 2000)); // Force kill if still running if (!this.mcpProcess.killed) { console.log('๐Ÿ”จ Force killing MCP Inspector...'); this.mcpProcess.kill('SIGKILL'); } this.mcpProcess = null; this.serverReady = false; console.log('โœ… MCP Inspector stopped'); } } async discoverAvailableTools() { if (!this.serverReady) { throw new Error('Cannot discover tools: MCP Inspector is not ready'); } const toolDiscoveryStartTime = Date.now(); // Use empty token since auth is disabled and full URL this.availableTools = await discoverAvailableTools(INSPECTOR_URL + MESSAGE_ENDPOINT, ''); const toolDiscoveryEndTime = Date.now(); this.metricsCollector.recordToolDiscovery(toolDiscoveryStartTime, toolDiscoveryEndTime, this.availableTools.length); return this.availableTools; } validateToolExists(toolName) { return validateToolExists(toolName, this.availableTools); } async callTool(toolName, params = {}) { const startTime = Date.now(); let success = false; let error = null; let result = null; let skipped = false; try { // Check if tool exists before calling (only if we have discovery data) if (!this.validateToolExists(toolName)) { skipped = true; error = 'Tool not available'; return createTestResult(toolName, params, startTime, false, null, error, true); } const response = await fetch(INSPECTOR_URL + MESSAGE_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ method: 'tools/call', params: { name: toolName, arguments: params } }) }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } result = await response.json(); success = true; return createTestResult(toolName, params, startTime, true, result.result); } catch (err) { success = false; error = err.message; return createTestResult(toolName, params, startTime, false, null, error); } finally { const endTime = Date.now(); this.metricsCollector.recordTestExecution(toolName, params, startTime, endTime, success, error, skipped); } } async testElementListing() { console.log('\n๐Ÿ” Testing Element Listing...'); const elementTypes = ['personas', 'skills', 'templates', 'agents']; for (const type of elementTypes) { const result = await this.callTool('list_elements', { type }); this.results.push(result); if (result.success) { const count = result.result?.content?.[0]?.text?.match(/Available \w+ \((\d+)\):/)?.[1] || 'unknown'; console.log(` โœ… ${type}: ${count} items`); } else { console.log(` โŒ ${type}: ${result.error}`); } } } async testMarketplaceBrowsing() { console.log('\n๐Ÿช Testing Marketplace Browsing...'); const tests = [ { name: 'Browse All', params: {} }, { name: 'Browse Personas', params: { category: 'personas' } }, { name: 'Search', params: { query: 'creative' } } ]; for (const test of tests) { const result = await this.callTool('browse_collection', { section: 'library', ...test.params }); this.results.push(result); if (result.skipped) { console.log(` โš ๏ธ ${test.name}: Skipped - ${result.error}`); } else if (result.success) { console.log(` โœ… ${test.name}: Success`); } else { console.log(` โŒ ${test.name}: ${result.error}`); } } } async testUserIdentity() { console.log('\n๐Ÿ‘ค Testing User Identity...'); // Get current identity let result = await this.callTool('get_user_identity'); this.results.push(result); console.log(` โœ… Get Identity: ${result.success ? 'Success' : result.error}`); // Set test identity with QA_TEST_ prefix const testUsername = 'QA_TEST_USER_qa-test-user'; result = await this.callTool('set_user_identity', { username: testUsername }); this.results.push(result); console.log(` โœ… Set Identity: ${result.success ? 'Success' : result.error}`); // Track test user identity for cleanup if (result.success) { this.testCleanup.trackArtifact('persona', testUsername, null, { type: 'test_user_identity' }); } // Verify identity was set result = await this.callTool('get_user_identity'); this.results.push(result); console.log(` โœ… Verify Identity: ${result.success ? 'Success' : result.error}`); } async testPersonaOperations() { console.log('\n๐ŸŽญ Testing Persona Operations...'); // Skip persona operations if no personas directory in CI if (this.isCI && TEST_PERSONAS_DIR) { console.log(' โ„น๏ธ Using CI test personas directory'); } // List elements to get one to work with let result = await this.callTool('list_elements', { type: 'personas' }); this.results.push(result); if (result.skipped) { console.log(` โš ๏ธ List Elements: Skipped - ${result.error}`); } else if (result.success) { // Try to activate an element (Creative Writer is usually available) result = await this.callTool('activate_element', { name: 'Creative Writer', type: 'personas' }); this.results.push(result); if (result.skipped) { console.log(` โš ๏ธ Activate Element: Skipped - ${result.error}`); } else { console.log(` โœ… Activate Element: ${result.success ? 'Success' : result.error}`); } // Get active elements result = await this.callTool('get_active_elements', { type: 'personas' }); this.results.push(result); if (result.skipped) { console.log(` โš ๏ธ Get Active: Skipped - ${result.error}`); } else { console.log(` โœ… Get Active: ${result.success ? 'Success' : result.error}`); } // Deactivate element result = await this.callTool('deactivate_element', { name: 'Creative Writer', type: 'personas' }); this.results.push(result); if (result.skipped) { console.log(` โš ๏ธ Deactivate: Skipped - ${result.error}`); } else { console.log(` โœ… Deactivate: ${result.success ? 'Success' : result.error}`); } } } async testPortfolioOperations() { console.log('\n๐Ÿ“ Testing Portfolio Operations...'); // Get portfolio status let result = await this.callTool('portfolio_status'); this.results.push(result); if (result.skipped) { console.log(` โš ๏ธ Portfolio Status: Skipped - ${result.error}`); } else { console.log(` โœ… Portfolio Status: ${result.success ? 'Success' : result.error}`); } // Get portfolio config result = await this.callTool('portfolio_config'); this.results.push(result); if (result.skipped) { console.log(` โš ๏ธ Portfolio Config: Skipped - ${result.error}`); } else { console.log(` โœ… Portfolio Config: ${result.success ? 'Success' : result.error}`); } } async testContentCreation() { console.log('\nโœจ Testing Content Creation...'); // Skip content creation tests in CI that require GitHub tokens if (this.isCI && !process.env.TEST_GITHUB_TOKEN) { console.log(' โš ๏ธ Skipping content creation tests in CI (no GitHub token)'); const result = createTestResult('create_element', {}, Date.now(), false, null, 'CI: GitHub token required', true); this.results.push(result); return false; } // Create a test element with QA_TEST_ prefix const testPersonaName = 'QA_TEST_PERSONA_Test_Persona'; const result = await this.callTool('create_element', { name: testPersonaName, type: 'personas', description: 'A test persona for QA validation - created by qa-test-runner' }); this.results.push(result); if (result.skipped) { console.log(` โš ๏ธ Create Element: Skipped - ${result.error}`); return false; } else { console.log(` โœ… Create Element: ${result.success ? 'Success' : result.error}`); // Track test persona for cleanup if (result.success) { this.testCleanup.trackArtifact('persona', testPersonaName, null, { type: 'test_persona', created_by: 'qa-test-runner', description: 'Test persona created for QA validation' }); // Legacy cleanup tracking (will be replaced by testCleanup) if (this.isCI) { this.cleanup.push(() => this.callTool('delete_element', { name: testPersonaName, type: 'personas', deleteData: true })); } } return result.success; } } async testErrorHandling() { console.log('\nโš ๏ธ Testing Error Handling...'); // Test with invalid parameters const tests = [ { tool: 'list_elements', params: { type: 'invalid_type' } }, { tool: 'activate_element', params: { name: 'NonExistentElement', type: 'personas' } }, { tool: 'get_collection_content', params: { path: 'invalid/path' } } ]; for (const test of tests) { const result = await this.callTool(test.tool, test.params); this.results.push(result); if (result.skipped) { console.log(` โš ๏ธ ${test.tool}: Skipped - ${result.error}`); } else if (!result.success) { console.log(` โœ… Expected error for ${test.tool}: ${result.error}`); } else { console.log(` โš ๏ธ Expected error but got success for ${test.tool}`); } } } calculateAccurateSuccessRate(results) { return calculateAccurateSuccessRate(results); } async performCleanup() { console.log('\n๐Ÿงน Performing comprehensive cleanup operations...'); try { // Run new cleanup system const cleanupResults = await this.testCleanup.cleanupAll(); console.log(`โœ… Cleanup completed: ${cleanupResults.cleaned} items cleaned, ${cleanupResults.failed} failed`); } catch (error) { console.warn(`โš ๏ธ New cleanup system failed: ${error.message}`); } // Legacy cleanup as fallback if (this.cleanup.length > 0) { console.log('๐Ÿงน Running legacy cleanup operations...'); for (const cleanupFn of this.cleanup) { try { await cleanupFn(); } catch (error) { console.warn(`โš ๏ธ Legacy cleanup failed: ${error.message}`); } } } } generateReport() { const endTime = new Date(); const duration = endTime - this.startTime; const stats = this.calculateAccurateSuccessRate(this.results); const totalTests = this.results.length; const report = { timestamp: endTime.toISOString(), duration: `${duration}ms`, environment: { ci: this.isCI, test_personas_dir: TEST_PERSONAS_DIR, github_token_available: !!process.env.TEST_GITHUB_TOKEN }, tool_discovery: { available_tools_count: this.availableTools.length, available_tools: this.availableTools }, summary: { total_tests: totalTests, executed_tests: stats.total, skipped_tests: stats.skipped, successful_tests: stats.successful, failed_tests: stats.total - stats.successful, success_rate: `${stats.percentage}%`, success_rate_note: "Based only on executed tests (excludes skipped)" }, results: this.results.map(r => ({ tool: r.tool, success: r.success, skipped: r.skipped || false, params: r.params, error: r.error || null, duration: r.duration })) }; // Ensure output directory exists const outputDir = 'docs/QA'; ensureDirectoryExists(outputDir); const filename = `qa-test-results-${new Date().toISOString().slice(0, 19).replaceAll(/[:.]/g, '-')}.json`; const filepath = `${outputDir}/${filename}`; // Track test result file for cleanup this.testCleanup.trackArtifact('result', filename, filepath, { type: 'test_results', created_by: 'qa-test-runner' }); try { writeFileSync(filepath, JSON.stringify(report, null, 2)); console.log(`\n๐Ÿ“Š Test Summary:`); console.log(` Environment: ${this.isCI ? 'CI' : 'Local'}`); console.log(` Available Tools: ${this.availableTools.length}`); console.log(` Total Tests: ${totalTests}`); console.log(` Executed Tests: ${stats.total}`); console.log(` Skipped Tests: ${stats.skipped}`); console.log(` Successful: ${stats.successful}`); console.log(` Failed: ${stats.total - stats.successful}`); console.log(` Success Rate: ${stats.percentage}% (based on executed tests only)`); console.log(` Duration: ${report.duration}`); console.log(` Report: ${filepath}`); return report; } catch (error) { console.error(`โŒ Failed to write report: ${error.message}`); console.log(`\n๐Ÿ“Š Test Summary (report save failed):`); console.log(` Environment: ${this.isCI ? 'CI' : 'Local'}`); console.log(` Available Tools: ${this.availableTools.length}`); console.log(` Total Tests: ${totalTests}`); console.log(` Executed Tests: ${stats.total}`); console.log(` Skipped Tests: ${stats.skipped}`); console.log(` Successful: ${stats.successful}`); console.log(` Failed: ${stats.total - stats.successful}`); console.log(` Success Rate: ${stats.percentage}% (based on executed tests only)`); console.log(` Duration: ${report.duration}`); return report; } } async runFullTestSuite() { console.log('๐Ÿš€ Starting DollhouseMCP QA Test Suite...'); console.log(`๐Ÿงน Test cleanup ID: ${this.testCleanup.testRunId}`); console.log(`๐Ÿ“Š Metrics collector ID: ${this.metricsCollector.testRunId}`); // Start metrics collection this.metricsCollector.startCollection(); if (this.isCI) { console.log('๐Ÿค– CI Environment Configuration:'); console.log(` TEST_PERSONAS_DIR: ${TEST_PERSONAS_DIR}`); console.log(` GitHub Token: ${process.env.TEST_GITHUB_TOKEN ? 'Available' : 'Not Available'}`); } let report = null; try { // Start the MCP server before running tests await this.startMCPServer(); console.log(`๐Ÿ“ก Connected to Inspector at ${INSPECTOR_URL}`); await this.discoverAvailableTools(); // Ensure availableTools is properly initialized before validation if (!Array.isArray(this.availableTools)) { this.availableTools = []; } await this.testElementListing(); await this.testMarketplaceBrowsing(); await this.testUserIdentity(); await this.testPersonaOperations(); await this.testPortfolioOperations(); await this.testContentCreation(); await this.testErrorHandling(); report = this.generateReport(); // End metrics collection and generate metrics report this.metricsCollector.endCollection(); const metricsReport = this.metricsCollector.generateReport(); if (metricsReport.filepath) { console.log(`๐Ÿ“Š Performance metrics saved to: ${metricsReport.filepath}`); // Auto-generate dashboard after metrics are saved try { console.log('๐Ÿ”„ Auto-updating QA metrics dashboard...'); const dashboardGenerator = new DashboardGenerator(); await dashboardGenerator.generateDashboard(); console.log('โœ… Dashboard updated automatically'); } catch (dashboardError) { console.warn(`โš ๏ธ Dashboard generation failed: ${dashboardError.message}`); // Don't fail the entire test run if dashboard generation fails } } return report; } catch (error) { console.error('โŒ Test suite failed:', error.message); // End metrics collection even on failure to capture partial data this.metricsCollector.endCollection(); const metricsReport = this.metricsCollector.generateReport(); if (metricsReport.filepath) { console.log(`๐Ÿ“Š Partial metrics saved despite failure: ${metricsReport.filepath}`); // Auto-generate dashboard even for partial metrics (test failures) try { console.log('๐Ÿ”„ Updating dashboard with partial metrics...'); const dashboardGenerator = new DashboardGenerator(); await dashboardGenerator.generateDashboard(); console.log('โœ… Dashboard updated with available data'); } catch (dashboardError) { console.warn(`โš ๏ธ Dashboard generation failed: ${dashboardError.message}`); } } // Log CI-specific error details if (this.isCI) { console.error('๐Ÿค– CI Environment Details:'); console.error(` Working Directory: ${process.cwd()}`); console.error(` Node Version: ${process.version}`); console.error(` Platform: ${process.platform}`); console.error(` Environment Variables: CI=${process.env.CI}`); } return null; } finally { // CRITICAL: Always stop the MCP server and cleanup try { await this.stopMCPServer(); } catch (serverError) { console.error(`โŒ CRITICAL: Failed to stop MCP server: ${serverError.message}`); } // CRITICAL: Always attempt cleanup, especially in CI // This ensures test artifacts are cleaned up even if tests fail try { await this.performCleanup(); } catch (cleanupError) { console.error(`โŒ CRITICAL: Cleanup failed: ${cleanupError.message}`); // In CI, cleanup failure is serious as it can cause test data accumulation if (this.isCI) { console.error('๐Ÿค– CI CLEANUP FAILURE - Test data may accumulate!'); } } } } } // Run if called directly if (import.meta.url === `file://${process.argv[1]}`) { const runner = new MCPTestRunner(); runner.runFullTestSuite().then(report => { process.exit(report ? 0 : 1); }); } export { MCPTestRunner };

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/DollhouseMCP/DollhouseMCP'

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