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
JavaScript
/**
* 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