UNPKG

vineguard-mcp-server-standalone

Version:

VineGuard MCP Server v2.1 - Intelligent QA Workflow System with advanced test generation for Jest/RTL, Cypress, and Playwright. Features smart project analysis, progressive testing strategies, and comprehensive quality patterns for React/Vue/Angular proje

446 lines (444 loc) 15.1 kB
/** * Test organization and file naming logic * Handles proper test file naming, directory structure, and organization patterns */ import * as path from 'path'; import * as fs from 'fs/promises'; export class TestOrganizer { projectRoot; constructor(projectRoot) { this.projectRoot = projectRoot; } /** * Generate proper test file name based on source file and test type */ static generateTestFileName(sourceFileName, testType, framework, fileExtension) { const baseName = path.basename(sourceFileName, path.extname(sourceFileName)); const ext = fileExtension || this.getTestFileExtension(sourceFileName, framework); switch (framework) { case 'jest': case 'vitest': // Jest/Vitest: Component.unit.test.tsx, utils.integration.test.ts return `${baseName}.${testType}.test${ext}`; case 'cypress': // Cypress: login.cy.ts, checkout-flow.cy.ts return `${this.kebabCase(baseName)}.cy.ts`; case 'playwright': // Playwright: login.spec.ts, checkout-flow.spec.ts return `${this.kebabCase(baseName)}.spec.ts`; default: return `${baseName}.test${ext}`; } } /** * Determine the appropriate test directory for a given test type and framework */ static getTestDirectory(sourceFilePath, testType, framework, projectRoot) { const sourceDir = path.dirname(sourceFilePath); switch (framework) { case 'jest': case 'vitest': // Jest/Vitest: Use __tests__ directory next to source files return path.join(sourceDir, '__tests__'); case 'cypress': // Cypress: Root-level cypress directory const cypressBaseDir = path.join(projectRoot, 'cypress', 'e2e'); switch (testType) { case 'e2e': return path.join(cypressBaseDir, 'user-flows'); case 'smoke': return path.join(cypressBaseDir, 'smoke'); case 'visual': return path.join(cypressBaseDir, 'visual'); case 'accessibility': return path.join(cypressBaseDir, 'accessibility'); default: return cypressBaseDir; } case 'playwright': // Playwright: Root-level playwright directory const playwrightBaseDir = path.join(projectRoot, 'playwright', 'tests'); switch (testType) { case 'e2e': return path.join(playwrightBaseDir, 'e2e'); case 'smoke': return path.join(playwrightBaseDir, 'smoke'); case 'visual': return path.join(playwrightBaseDir, 'visual'); case 'accessibility': return path.join(playwrightBaseDir, 'accessibility'); default: return playwrightBaseDir; } default: return path.join(sourceDir, '__tests__'); } } /** * Get the complete test file information */ static getTestFileInfo(sourceFilePath, testType, framework, projectRoot) { const fileName = this.generateTestFileName(path.basename(sourceFilePath), testType, framework); const directory = this.getTestDirectory(sourceFilePath, testType, framework, projectRoot); const filePath = path.join(directory, fileName); const relativePath = path.relative(projectRoot, filePath); return { fileName, filePath, directory, relativePath, testType, framework }; } /** * Analyze current project structure and test organization */ async analyzeProjectStructure() { const hasTests = await this.hasExistingTests(); const testDirectories = await this.findTestDirectories(); const frameworks = await this.detectTestFrameworks(); const testPattern = await this.detectTestPattern(); const coverageGaps = await this.findCoverageGaps(); return { hasTests, testDirectories, frameworks, testPattern, coverageGaps }; } /** * Create test directory structure if it doesn't exist */ async ensureTestDirectoryExists(testDirectory) { try { await fs.access(testDirectory); } catch (error) { // Directory doesn't exist, create it await fs.mkdir(testDirectory, { recursive: true }); // Create .gitkeep file to ensure directory is tracked const gitkeepPath = path.join(testDirectory, '.gitkeep'); await fs.writeFile(gitkeepPath, ''); console.error(`[VineGuard] Created test directory: ${testDirectory}`); } } /** * Generate test file with proper directory structure */ async createTestFile(testFileInfo, testContent) { // Ensure directory exists await this.ensureTestDirectoryExists(testFileInfo.directory); // Write test file await fs.writeFile(testFileInfo.filePath, testContent, 'utf-8'); console.error(`[VineGuard] Created test file: ${testFileInfo.relativePath}`); return testFileInfo.filePath; } /** * Check if project already has tests */ async hasExistingTests() { try { const files = await this.findFiles(this.projectRoot, /\.(test|spec)\.(js|jsx|ts|tsx)$/); const testDirs = await this.findTestDirectories(); return files.length > 0 || testDirs.length > 0; } catch (error) { return false; } } /** * Find all test directories in the project */ async findTestDirectories() { const testDirs = []; const commonTestDirs = ['__tests__', 'test', 'tests', 'spec', 'cypress', 'playwright']; for (const dirName of commonTestDirs) { const dirPath = path.join(this.projectRoot, dirName); try { const stat = await fs.stat(dirPath); if (stat.isDirectory()) { testDirs.push(dirPath); } } catch (error) { // Directory doesn't exist, continue } } // Also look for __tests__ directories in subdirectories const nestedTestDirs = await this.findFiles(this.projectRoot, /__tests__$/); testDirs.push(...nestedTestDirs); return testDirs; } /** * Detect which test frameworks are installed/configured */ async detectTestFrameworks() { const frameworks = []; try { const packageJsonPath = path.join(this.projectRoot, 'package.json'); const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf-8')); const allDeps = { ...packageJson.dependencies, ...packageJson.devDependencies }; if (allDeps.jest || allDeps['@jest/core']) frameworks.push('jest'); if (allDeps.vitest) frameworks.push('vitest'); if (allDeps.cypress) frameworks.push('cypress'); if (allDeps['@playwright/test']) frameworks.push('playwright'); } catch (error) { // package.json not found or invalid } return frameworks; } /** * Detect current test organization pattern */ async detectTestPattern() { const testFiles = await this.findFiles(this.projectRoot, /\.(test|spec)\.(js|jsx|ts|tsx)$/); let collocatedCount = 0; let separateCount = 0; for (const testFile of testFiles) { const dir = path.dirname(testFile); const dirName = path.basename(dir); if (dirName === '__tests__' || dirName === 'test' || dirName === 'tests') { separateCount++; } else { collocatedCount++; } } if (collocatedCount === 0 && separateCount === 0) { return 'separate'; // Default for new projects } if (separateCount === 0) return 'collocated'; if (collocatedCount === 0) return 'separate'; return 'mixed'; } /** * Find source files that don't have corresponding tests */ async findCoverageGaps() { const sourceFiles = await this.findFiles(this.projectRoot, /\.(js|jsx|ts|tsx|vue|svelte)$/, ['node_modules', 'dist', 'build', '__tests__', 'test', 'tests', 'cypress', 'playwright']); const testFiles = await this.findFiles(this.projectRoot, /\.(test|spec)\.(js|jsx|ts|tsx)$/); const gaps = []; for (const sourceFile of sourceFiles) { const baseName = path.basename(sourceFile, path.extname(sourceFile)); const hasTest = testFiles.some(testFile => { const testBaseName = path.basename(testFile) .replace(/\.(test|spec)\..*$/, '') .replace(/\.(unit|integration|e2e|accessibility)$/, ''); return testBaseName === baseName; }); if (!hasTest) { gaps.push(sourceFile); } } return gaps; } /** * Find files matching a pattern, excluding certain directories */ async findFiles(dir, pattern, excludeDirs = ['node_modules', '.git', 'dist', 'build']) { const files = []; try { const items = await fs.readdir(dir); for (const item of items) { if (excludeDirs.includes(item) || item.startsWith('.')) continue; const fullPath = path.join(dir, item); const stat = await fs.stat(fullPath); if (stat.isDirectory()) { if (pattern.test(item)) { files.push(fullPath); } else { files.push(...await this.findFiles(fullPath, pattern, excludeDirs)); } } else if (pattern.test(item)) { files.push(fullPath); } } } catch (error) { // Directory not accessible } return files; } /** * Get appropriate file extension for test files */ static getTestFileExtension(sourceFileName, framework) { const sourceExt = path.extname(sourceFileName); if (framework === 'cypress' || framework === 'playwright') { return '.ts'; // E2E tests typically use TypeScript } switch (sourceExt) { case '.tsx': return '.tsx'; case '.ts': return '.ts'; case '.jsx': return '.jsx'; case '.js': default: return '.js'; } } /** * Convert string to kebab-case */ static kebabCase(str) { return str .replace(/([a-z])([A-Z])/g, '$1-$2') .replace(/[\s_]+/g, '-') .toLowerCase(); } /** * Generate project-specific test configuration */ async generateTestConfig(framework) { const structure = await this.analyzeProjectStructure(); switch (framework) { case 'jest': return this.generateJestConfig(structure); case 'cypress': return this.generateCypressConfig(structure); case 'playwright': return this.generatePlaywrightConfig(structure); default: return ''; } } /** * Generate Jest configuration */ generateJestConfig(structure) { return `{ "preset": "ts-jest", "testEnvironment": "jsdom", "roots": ["<rootDir>/src"], "testMatch": [ "**/__tests__/**/*.{js,jsx,ts,tsx}", "**/*.{test,spec}.{js,jsx,ts,tsx}" ], "collectCoverageFrom": [ "src/**/*.{js,jsx,ts,tsx}", "!src/**/*.d.ts", "!src/**/*.stories.{js,jsx,ts,tsx}", "!src/test-utils/**/*" ], "coverageThreshold": { "global": { "branches": 75, "functions": 80, "lines": 80, "statements": 80 } }, "setupFilesAfterEnv": ["<rootDir>/src/test-utils/setup.ts"], "moduleNameMapper": { "^@/(.*)$": "<rootDir>/src/$1", "\\\\.(css|less|scss|sass)$": "identity-obj-proxy" }, "transform": { "^.+\\\\.(ts|tsx)$": "ts-jest" } }`; } /** * Generate Cypress configuration */ generateCypressConfig(structure) { return `import { defineConfig } from 'cypress'; export default defineConfig({ e2e: { baseUrl: 'http://localhost:3000', specPattern: 'cypress/e2e/**/*.cy.{js,jsx,ts,tsx}', supportFile: 'cypress/support/e2e.ts', videosFolder: 'cypress/videos', screenshotsFolder: 'cypress/screenshots', video: true, screenshot: true, viewportWidth: 1280, viewportHeight: 720, defaultCommandTimeout: 10000, requestTimeout: 10000, responseTimeout: 10000, retries: { runMode: 2, openMode: 0 } }, component: { devServer: { framework: 'react', bundler: 'vite' }, specPattern: 'src/**/*.cy.{js,jsx,ts,tsx}', supportFile: 'cypress/support/component.ts' } });`; } /** * Generate Playwright configuration */ generatePlaywrightConfig(structure) { return `import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ testDir: './playwright/tests', outputDir: './playwright/test-results', fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 1 : undefined, reporter: [ ['html'], ['json', { outputFile: 'playwright/test-results/results.json' }] ], use: { baseURL: 'http://localhost:3000', trace: 'on-first-retry', screenshot: 'only-on-failure', video: 'retain-on-failure' }, projects: [ { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, { name: 'firefox', use: { ...devices['Desktop Firefox'] } }, { name: 'webkit', use: { ...devices['Desktop Safari'] } }, { name: 'Mobile Chrome', use: { ...devices['Pixel 5'] } }, { name: 'Mobile Safari', use: { ...devices['iPhone 12'] } } ], webServer: { command: 'npm run dev', port: 3000, reuseExistingServer: !process.env.CI } });`; } } //# sourceMappingURL=test-organization.js.map