Skip to main content
Glama
NorthSeacoder

Frontend Test Generation & Code Review MCP Server

run-tests.ts10.5 kB
/** * RunTestsTool - 执行测试命令 * * 职责: * 1. 执行测试框架命令 * 2. 解析测试结果 * 3. 返回结构化的测试报告 */ import { z } from 'zod'; import { BaseTool, ToolMetadata } from '../core/base-tool.js'; import { exec } from 'node:child_process'; import { promisify } from 'node:util'; import { logger } from '../utils/logger.js'; import { getAppContext } from '../core/app-context.js'; const execAsync = promisify(exec); // Zod schema for RunTestsInput export const RunTestsInputSchema = z.object({ testFiles: z.array(z.string()).optional().describe('要运行的测试文件路径(相对于 projectRoot)'), projectRoot: z.string().optional().describe('项目根目录绝对路径(默认当前目录)'), workspaceId: z.string().optional().describe('工作区 ID,如果启用 Worker 模式则必需'), framework: z.enum(['vitest', 'jest']).optional().describe('测试框架(可选,自动检测)'), watch: z.boolean().optional().describe('监听模式(默认 false)'), coverage: z.boolean().optional().describe('生成覆盖率报告(默认 false)'), timeout: z.number().optional().describe('超时时间(毫秒,默认 30000)'), }); export interface RunTestsInput { testFiles?: string[]; // 要运行的测试文件(可选,默认运行所有测试) projectRoot?: string; // 项目根目录(默认当前目录) workspaceId?: string; // 工作区 ID(Worker 模式需要) framework?: 'vitest' | 'jest'; // 测试框架(可选,自动检测) watch?: boolean; // 监听模式(默认 false) coverage?: boolean; // 生成覆盖率报告(默认 false) timeout?: number; // 超时时间(毫秒,默认 30000) } export interface RunTestsOutput { success: boolean; framework: string; summary: { total: number; passed: number; failed: number; skipped: number; duration: number; }; stdout: string; stderr: string; exitCode: number; } interface WorkerTestPayload { workDir: string; testFiles?: string[]; framework: 'vitest' | 'jest'; timeout?: number; } interface WorkerTestResult { summary: { total: number; passed: number; failed: number; skipped: number; duration: number; }; stdout: string; stderr: string; exitCode: number; } export class RunTestsTool extends BaseTool<RunTestsInput, RunTestsOutput> { // Expose Zod schema for FastMCP getZodSchema() { return RunTestsInputSchema; } getMetadata(): ToolMetadata { return { name: 'run-tests', description: '执行测试命令并返回结果。\n\n' + '💡 特性:\n' + '• 支持 Vitest 和 Jest\n' + '• 可指定测试文件或运行全部\n' + '• 支持监听模式\n' + '• 支持覆盖率报告\n' + '• 解析测试结果统计\n\n' + '⚠️ 注意:\n' + '• 需要项目中已安装测试框架\n' + '• 测试文件路径相对于 projectRoot\n' + '• 监听模式不会自动返回(不推荐在自动化中使用)', inputSchema: { type: 'object', properties: { testFiles: { type: 'array', items: { type: 'string' }, description: '要运行的测试文件路径(相对于 projectRoot)', }, projectRoot: { type: 'string', description: '项目根目录绝对路径(默认当前目录)', }, workspaceId: { type: 'string', description: '工作区 ID,如果启用 Worker 模式则必需', }, framework: { type: 'string', enum: ['vitest', 'jest'], description: '测试框架(可选,自动检测)', }, watch: { type: 'boolean', description: '监听模式(默认 false)', }, coverage: { type: 'boolean', description: '生成覆盖率报告(默认 false)', }, timeout: { type: 'number', description: '超时时间(毫秒,默认 30000)', }, }, }, category: 'test-operations', version: '3.0.0', }; } protected async executeImpl(input: RunTestsInput): Promise<RunTestsOutput> { const { testFiles, projectRoot = process.cwd(), workspaceId, framework, watch = false, coverage = false, timeout = 30000, } = input; // 自动检测测试框架 const detectedFramework = framework || (await this.detectFramework(projectRoot)); // 尝试使用 Worker 执行(仅在非 watch 模式下) if (!watch && !coverage && workspaceId) { const context = getAppContext(); const workerPool = (context as any).workerPool; if (workerPool && process.env.WORKER_ENABLED !== 'false') { try { logger.info('[RunTestsTool] Using worker execution', { workspaceId }); const result: WorkerTestResult = await workerPool.executeTask({ type: 'test', workspaceId, payload: { workDir: projectRoot, testFiles, framework: detectedFramework, timeout, } as WorkerTestPayload, timeout: timeout + 5000, // Add buffer time }); return { success: result.exitCode === 0, framework: detectedFramework, summary: result.summary, stdout: result.stdout, stderr: result.stderr, exitCode: result.exitCode, }; } catch (error) { logger.warn('[RunTestsTool] Worker execution failed, falling back to direct', { error: error instanceof Error ? error.message : String(error), }); // Fall through to direct execution } } } // 直接执行(或回退) logger.info('[RunTestsTool] Running tests directly', { framework: detectedFramework, testFiles: testFiles?.length || 'all', projectRoot, watch, coverage, }); // 构建测试命令 const command = this.buildTestCommand(detectedFramework, { testFiles, watch, coverage, }); logger.debug('[RunTestsTool] Executing command', { command }); // 执行测试 const startTime = Date.now(); let stdout = ''; let stderr = ''; let exitCode = 0; let success = false; try { const result = await execAsync(command, { cwd: projectRoot, timeout, maxBuffer: 10 * 1024 * 1024, // 10MB }); stdout = result.stdout; stderr = result.stderr; success = true; logger.info('[RunTestsTool] Tests passed'); } catch (error: any) { stdout = error.stdout || ''; stderr = error.stderr || ''; exitCode = error.code || 1; success = false; logger.warn('[RunTestsTool] Tests failed', { exitCode }); } const duration = Date.now() - startTime; // 解析测试结果 const summary = this.parseTestResults(stdout, stderr, detectedFramework); summary.duration = duration; logger.info('[RunTestsTool] Test execution completed', { success, total: summary.total, passed: summary.passed, failed: summary.failed, duration, }); return { success, framework: detectedFramework, summary, stdout, stderr, exitCode, }; } private async detectFramework(projectRoot: string): Promise<'vitest' | 'jest'> { try { const { detectProjectTestStack } = await import('./detect-stack.js'); const stack = await detectProjectTestStack(projectRoot); return stack.unit === 'jest' ? 'jest' : 'vitest'; } catch (error) { logger.warn('[RunTestsTool] Failed to detect framework, defaulting to vitest', { error }); return 'vitest'; } } private buildTestCommand( framework: string, options: { testFiles?: string[]; watch: boolean; coverage: boolean; } ): string { const { testFiles, watch, coverage } = options; let command: string; if (framework === 'jest') { command = 'npx jest'; if (watch) command += ' --watch'; if (coverage) command += ' --coverage'; if (testFiles && testFiles.length > 0) { command += ` ${testFiles.join(' ')}`; } } else { // vitest command = 'npx vitest run'; if (watch) command = 'npx vitest'; // vitest watch mode doesn't need 'run' if (coverage) command += ' --coverage'; if (testFiles && testFiles.length > 0) { command += ` ${testFiles.join(' ')}`; } } return command; } private parseTestResults( stdout: string, stderr: string, framework: string ): { total: number; passed: number; failed: number; skipped: number; duration: number; } { const combined = stdout + stderr; // Vitest 输出格式: // Test Files 1 passed (1) // Tests 3 passed (3) // Duration 123ms // Jest 输出格式: // Tests: 3 passed, 3 total // Snapshots: 0 total // Time: 0.123 s let total = 0; let passed = 0; let failed = 0; let skipped = 0; if (framework === 'vitest') { // 解析 Vitest 输出 const testMatch = combined.match(/Tests\s+(\d+)\s+passed[^(]*\((\d+)\)/i); if (testMatch) { passed = parseInt(testMatch[1], 10); total = parseInt(testMatch[2], 10); } const failMatch = combined.match(/Tests\s+(\d+)\s+failed/i); if (failMatch) { failed = parseInt(failMatch[1], 10); } const skipMatch = combined.match(/Tests\s+(\d+)\s+skipped/i); if (skipMatch) { skipped = parseInt(skipMatch[1], 10); } } else { // 解析 Jest 输出 const testMatch = combined.match(/Tests:\s+(?:(\d+)\s+failed,\s*)?(\d+)\s+passed,\s*(\d+)\s+total/i); if (testMatch) { failed = parseInt(testMatch[1] || '0', 10); passed = parseInt(testMatch[2], 10); total = parseInt(testMatch[3], 10); } const skipMatch = combined.match(/(\d+)\s+skipped/i); if (skipMatch) { skipped = parseInt(skipMatch[1], 10); } } return { total, passed, failed, skipped, duration: 0, // Will be set by caller }; } }

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/NorthSeacoder/fe-testgen-mcp'

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