aios-core
Version:
Synkra AIOS: AI-Orchestrated System for Full Stack Development - Core Framework
1,081 lines (881 loc) • 36.9 kB
JavaScript
const fs = require('fs').promises;
const path = require('path');
const chalk = require('chalk');
/**
* Test quality assessment for Synkra AIOS test generation
* Evaluates generated test quality and provides improvement recommendations
*/
class TestQualityAssessment {
constructor(options = {}) {
this.rootPath = options.rootPath || process.cwd();
this.qualityReportsDir = path.join(this.rootPath, '.aios', 'quality-reports');
this.qualityStandards = this.initializeQualityStandards();
this.assessmentCache = new Map();
this.qualityHistory = [];
}
/**
* Initialize test quality assessment
*/
async initialize() {
try {
// Create quality reports directory
await fs.mkdir(this.qualityReportsDir, { recursive: true });
// Load existing quality history
await this.loadQualityHistory();
console.log(chalk.green('✅ Test quality assessment initialized'));
return true;
} catch (error) {
console.error(chalk.red(`Failed to initialize test quality assessment: ${error.message}`));
throw error;
}
}
/**
* Assess quality of a single test file
*/
async analyzeSingleTestFile(testFilePath) {
console.log(chalk.blue(`🔍 Assessing test quality: ${path.basename(testFilePath)}`));
const assessment = {
file_path: testFilePath,
analyzed_at: new Date().toISOString(),
overall_score: 0,
quality_rating: 'unknown',
metrics: {},
issues: [],
recommendations: [],
estimatedCoverage: 0,
};
try {
// Read test file content
const content = await fs.readFile(testFilePath, 'utf-8');
// Analyze various quality metrics
assessment.metrics = await this.analyzeQualityMetrics(_content);
// Calculate overall quality score
assessment.overall_score = this.calculateOverallScore(assessment.metrics);
// Determine quality rating
assessment.quality_rating = this.determineQualityRating(assessment.overall_score);
// Identify quality issues
assessment.issues = this.identifyQualityIssues(assessment.metrics, content);
// Generate improvement recommendations
assessment.recommendations = this.generateRecommendations(assessment.metrics, assessment.issues);
// Estimate coverage contribution
assessment.estimatedCoverage = this.estimateCoverageContribution(assessment.metrics);
// Cache assessment
this.assessmentCache.set(testFilePath, assessment);
console.log(chalk.green(`✅ Test quality assessed: ${assessment.quality_rating} (${assessment.overall_score.toFixed(1)}/10)`));
return assessment;
} catch (error) {
console.error(chalk.red(`Failed to assess test quality for ${testFilePath}: ${error.message}`));
throw error;
}
}
/**
* Assess quality of multiple test files
*/
async assessTestSuite(testFiles, options = {}) {
const suiteAssessment = {
assessed_at: new Date().toISOString(),
test_files: testFiles.length,
overall_suite_score: 0,
quality_distribution: {},
common_issues: [],
suite_recommendations: [],
file_assessments: {},
};
console.log(chalk.blue(`📋 Assessing test suite quality (${testFiles.length} files)`));
const scores = [];
const allIssues = [];
const qualityRatings = { excellent: 0, good: 0, fair: 0, poor: 0, very_poor: 0 };
// Assess each test file
for (const testFile of testFiles) {
try {
const assessment = await this.analyzeSingleTestFile(testFile);
suiteAssessment.file_assessments[testFile] = assessment;
scores.push(assessment.overall_score);
allIssues.push(...assessment.issues);
qualityRatings[assessment.quality_rating]++;
} catch (error) {
console.warn(chalk.yellow(`Failed to assess ${testFile}: ${error.message}`));
}
}
// Calculate suite-level metrics
suiteAssessment.overall_suite_score = scores.length > 0
? scores.reduce((sum, score) => sum + score, 0) / scores.length
: 0;
suiteAssessment.quality_distribution = qualityRatings;
suiteAssessment.common_issues = this.identifyCommonIssues(allIssues);
suiteAssessment.suite_recommendations = this.generateSuiteRecommendations(suiteAssessment);
// Save suite assessment
await this.saveSuiteAssessment(suiteAssessment);
console.log(chalk.green('✅ Test suite assessment completed'));
console.log(chalk.gray(` Overall score: ${suiteAssessment.overall_suite_score.toFixed(1)}/10`));
console.log(chalk.gray(` Files assessed: ${testFiles.length}`));
console.log(chalk.gray(` Common issues: ${suiteAssessment.common_issues.length}`));
return suiteAssessment;
}
/**
* Analyze quality metrics for test content
*/
async analyzeQualityMetrics(_content) {
const metrics = {
// Structure metrics
test_organization: this.analyzeTestOrganization(_content),
naming_quality: this.analyzeNamingQuality(_content),
// Content metrics
assertion_quality: this.analyzeAssertionQuality(_content),
test_coverage_breadth: this.analyzeTestCoverageBreadth(_content),
edge_case_coverage: this.analyzeEdgeCaseCoverage(_content),
// Code quality metrics
code_clarity: this.analyzeCodeClarity(_content),
maintainability: this.analyzeMaintainability(_content),
// Testing best practices
isolation: this.analyzeTestIsolation(_content),
reliability: this.analyzeTestReliability(_content),
performance: this.analyzeTestPerformance(_content),
// Framework usage
framework_usage: this.analyzeFrameworkUsage(_content),
mock_quality: this.analyzeMockQuality(_content),
};
return metrics;
}
/**
* Analyze test organization and structure
*/
analyzeTestOrganization(_content) {
const score = { value: 0, max: 10, details: {} };
// Check for describe blocks
const describeBlocks = content.match(/describe\s*\([^{]+\{/g) || [];
score.details.has_describe_blocks = describeBlocks.length > 0;
if (score.details.has_describe_blocks) score.value += 2;
// Check for nested organization
const nestedDescribe = content.match(/describe\s*\([^{]*\{\s*[\s\S]*?describe\s*\(/g) || [];
score.details.has_nested_structure = nestedDescribe.length > 0;
if (score.details.has_nested_structure) score.value += 1;
// Check for setup/teardown
const setupMethods = content.match(/\b(?:beforeEach|beforeAll|afterEach|afterAll)\s*\(/g) || [];
score.details.has_setup_teardown = setupMethods.length > 0;
if (score.details.has_setup_teardown) score.value += 2;
// Check for logical grouping
const testBlocks = content.match(/\b(?:it|test)\s*\(/g) || [];
score.details.test_count = testBlocks.length;
score.details.tests_per_describe = describeBlocks.length > 0
? testBlocks.length / describeBlocks.length
: testBlocks.length;
// Good ratio of tests per describe block
if (score.details.tests_per_describe >= 2 && score.details.tests_per_describe <= 8) {
score.value += 2;
}
// Check for comments and documentation
const comments = content.match(/\/\*[\s\S]*?\*\/|\/\/.*$/gm) || [];
score.details.has_documentation = comments.length > 0;
if (score.details.has_documentation) score.value += 1;
// Consistent indentation
const lines = content.split('\n');
const indentationConsistent = this.checkIndentationConsistency(lines);
score.details.consistent_indentation = indentationConsistent;
if (indentationConsistent) score.value += 2;
return score;
}
/**
* Analyze naming quality of tests
*/
analyzeNamingQuality(_content) {
const score = { value: 0, max: 10, details: {} };
// Extract test names
const testNames = this.extractTestNames(_content);
score.details.total_tests = testNames.length;
if (testNames.length === 0) return score;
// Check naming patterns
let descriptiveNames = 0;
let consistentPatterns = 0;
let appropriateLength = 0;
const patterns = {
should_pattern: /should\s+/i,
can_pattern: /can\s+/i,
when_pattern: /when\s+/i,
given_pattern: /given\s+/i,
};
for (const name of testNames) {
// Descriptive (contains action verbs or clear intent)
if (name.length > 20 && (name.includes('should') || name.includes('when') || name.includes('given'))) {
descriptiveNames++;
}
// Appropriate length (not too short, not too long)
if (name.length >= 15 && name.length <= 80) {
appropriateLength++;
}
// Consistent patterns
for (const pattern of Object.values(patterns)) {
if (pattern.test(name)) {
consistentPatterns++;
break;
}
}
}
score.details.descriptive_ratio = descriptiveNames / testNames.length;
score.details.length_appropriate_ratio = appropriateLength / testNames.length;
score.details.pattern_consistent_ratio = consistentPatterns / testNames.length;
// Scoring
score.value += score.details.descriptive_ratio * 4;
score.value += score.details.length_appropriate_ratio * 3;
score.value += score.details.pattern_consistent_ratio * 3;
return score;
}
/**
* Analyze assertion quality
*/
analyzeAssertionQuality(_content) {
const score = { value: 0, max: 10, details: {} };
// Count different types of assertions
const assertions = {
specific: content.match(/\.toBe\(|\.toEqual\(|\.toStrictEqual\(/g) || [],
existence: content.match(/\.toBeDefined\(|\.toBeNull\(|\.toBeUndefined\(/g) || [],
boolean: content.match(/\.toBeTruthy\(|\.toBeFalsy\(|\.toBeTrue\(|\.toBeFalse\(/g) || [],
numeric: content.match(/\.toBeGreaterThan\(|\.toBeLessThan\(|\.toBeCloseTo\(/g) || [],
array: content.match(/\.toHaveLength\(|\.toContain\(|\.toContainEqual\(/g) || [],
object: content.match(/\.toHaveProperty\(|\.toMatchObject\(/g) || [],
error: content.match(/\.toThrow\(|\.rejects\.|\.resolves\./g) || [],
custom: content.match(/\.toMatch\(|\.toMatchSnapshot\(/g) || [],
};
const totalAssertions = Object.values(assertions).reduce((sum, arr) => sum + arr.length, 0);
score.details.total_assertions = totalAssertions;
if (totalAssertions === 0) return score;
// Diversity of assertion types
const assertionTypes = Object.entries(assertions).filter(([_, arr]) => arr.length > 0).length;
score.details.assertion_type_diversity = assertionTypes / Object.keys(assertions).length;
score.value += score.details.assertion_type_diversity * 3;
// Specific vs generic assertions ratio
const specificAssertions = assertions.specific.length + assertions.numeric.length + assertions.object.length;
const genericAssertions = assertions.existence.length + assertions.boolean.length;
score.details.specific_ratio = specificAssertions / (specificAssertions + genericAssertions + 1);
score.value += score.details.specific_ratio * 4;
// Error handling coverage
score.details.error_handling_ratio = assertions.error.length / totalAssertions;
score.value += Math.min(score.details.error_handling_ratio * 10, 3);
return score;
}
/**
* Analyze test coverage breadth
*/
analyzeTestCoverageBreadth(_content) {
const score = { value: 0, max: 10, details: {} };
// Identify different test scenarios
const scenarios = {
happy_path: content.match(/should\s+.*(?:work|succeed|return|complete)/gi) || [],
error_cases: content.match(/should\s+.*(?:throw|fail|error|reject)/gi) || [],
edge_cases: content.match(/should\s+.*(?:empty|null|undefined|zero|negative|boundary|limit)/gi) || [],
async_cases: content.match(/async\s*\(|await\s+/g) || [],
integration: content.match(/integration|end.to.end|e2e/gi) || [],
};
// Count covered scenario types
const scenariosCovered = Object.entries(scenarios).filter(([_, matches]) => matches.length > 0).length;
score.details.scenario_coverage = scenariosCovered / Object.keys(scenarios).length;
score.value += score.details.scenario_coverage * 5;
// Test comprehensiveness
const testCount = (content.match(/\b(?:it|test)\s*\(/g) || []).length;
score.details.test_count = testCount;
if (testCount >= 10) score.value += 2;
else if (testCount >= 5) score.value += 1;
// Mock usage indicating interaction testing
const mockUsage = content.match(/\b(?:mock|spy|stub|fake)\b/gi) || [];
score.details.interaction_testing = mockUsage.length > 0;
if (score.details.interaction_testing) score.value += 3;
return score;
}
/**
* Analyze edge case coverage
*/
analyzeEdgeCaseCoverage(_content) {
const score = { value: 0, max: 10, details: {} };
const edgeCasePatterns = {
null_undefined: /\b(?:null|undefined)\b/gi,
empty_values: /\b(?:empty|blank|zero|''|""|\[\]|\{\})\b/gi,
boundary_values: /\b(?:min|max|first|last|boundary|limit)\b/gi,
negative_cases: /\b(?:negative|invalid|malformed|corrupt)\b/gi,
large_values: /\b(?:large|huge|maximum|overflow)\b/gi,
concurrent: /\b(?:concurrent|parallel|race|async)\b/gi,
};
let edgeCasesFound = 0;
const edgeCaseDetails = {};
for (const [category, pattern] of Object.entries(edgeCasePatterns)) {
const matches = content.match(pattern) || [];
edgeCaseDetails[category] = matches.length;
if (matches.length > 0) edgeCasesFound++;
}
score.details.edge_case_categories = edgeCasesFound;
score.details.edge_case_details = edgeCaseDetails;
score.details.edge_case_ratio = edgeCasesFound / Object.keys(edgeCasePatterns).length;
score.value = score.details.edge_case_ratio * 10;
return score;
}
/**
* Analyze code clarity and readability
*/
analyzeCodeClarity(_content) {
const score = { value: 0, max: 10, details: {} };
const lines = content.split('\n');
let clearLines = 0;
let complexLines = 0;
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('/*')) continue;
// Check line complexity
const complexity = this.calculateLineComplexity(trimmed);
if (complexity < 3) clearLines++;
else if (complexity > 5) complexLines++;
}
const totalCodeLines = lines.filter(line => {
const trimmed = line.trim();
return trimmed && !trimmed.startsWith('//') && !trimmed.startsWith('/*');
}).length;
score.details.clear_lines_ratio = totalCodeLines > 0 ? clearLines / totalCodeLines : 0;
score.details.complex_lines_ratio = totalCodeLines > 0 ? complexLines / totalCodeLines : 0;
// Variable naming clarity
const variables = content.match(/(?:let|const|var)\s+(\w+)/g) || [];
let descriptiveVariables = 0;
for (const variable of variables) {
const name = variable.match(/(?:let|const|var)\s+(\w+)/)[1];
if (name.length > 3 && !name.match(/^[a-z]$/)) {
descriptiveVariables++;
}
}
score.details.descriptive_variables_ratio = variables.length > 0
? descriptiveVariables / variables.length
: 1;
// Scoring
score.value += score.details.clear_lines_ratio * 4;
score.value += (1 - score.details.complex_lines_ratio) * 3;
score.value += score.details.descriptive_variables_ratio * 3;
return score;
}
/**
* Analyze maintainability factors
*/
analyzeMaintainability(_content) {
const score = { value: 0, max: 10, details: {} };
// DRY principle - look for repeated code patterns
const lines = content.split('\n').filter(line => line.trim());
const uniqueLines = new Set(lines.map(line => line.trim()));
score.details.code_duplication_ratio = 1 - (uniqueLines.size / lines.length);
// Helper function usage
const helperFunctions = content.match(/function\s+\w+Helper|const\s+\w+Helper/g) || [];
score.details.uses_helpers = helperFunctions.length > 0;
// Test data management
const testData = content.match(/const\s+(?:test|mock|fixture|sample)Data/gi) || [];
score.details.organized_test_data = testData.length > 0;
// Magic numbers/strings
const magicNumbers = content.match(/\b(?!0|1|2|10|100|1000)\d{3,}\b/g) || [];
const magicStrings = content.match(/'[^']{20,}'|"[^"]{20,}"/g) || [];
score.details.magic_values_count = magicNumbers.length + magicStrings.length;
// Scoring
score.value += (1 - Math.min(score.details.code_duplication_ratio, 0.5)) * 4;
score.value += score.details.uses_helpers ? 2 : 0;
score.value += score.details.organized_test_data ? 2 : 0;
score.value += Math.max(0, 2 - (score.details.magic_values_count * 0.5));
return score;
}
/**
* Analyze test isolation
*/
analyzeTestIsolation(_content) {
const score = { value: 0, max: 10, details: {} };
// Check for proper setup/teardown
const setupTeardown = content.match(/\b(?:beforeEach|afterEach)\s*\(/g) || [];
score.details.has_isolation_setup = setupTeardown.length > 0;
if (score.details.has_isolation_setup) score.value += 3;
// Check for shared state usage
const _sharedVariables = content.match(/\b(?:let|var)\s+\w+(?=\s*;|\s*=.*;\s*$)/gm) || [];
const sharedInDescribe = content.match(/describe[^{]*\{[^}]*(?:let|var)\s+\w+/g) || [];
score.details.shared_state_usage = sharedInDescribe.length;
// Penalty for excessive shared state
if (score.details.shared_state_usage > 3) score.value -= 2;
else if (score.details.shared_state_usage === 0) score.value += 2;
// Check for test dependencies (tests that depend on order)
const testOrder = content.includes('beforeAll') && !content.includes('afterAll');
score.details.potential_order_dependency = testOrder;
if (!testOrder) score.value += 2;
// Mock cleanup
const mockCleanup = content.match(/\b(?:jest\.clearAllMocks|sinon\.restore|vi\.clearAllMocks)\b/g) || [];
score.details.mock_cleanup = mockCleanup.length > 0;
if (score.details.mock_cleanup) score.value += 3;
return score;
}
/**
* Analyze test reliability
*/
analyzeTestReliability(_content) {
const score = { value: 0, max: 10, details: {} };
// Check for flaky patterns
const flakyPatterns = {
timeouts: content.match(/setTimeout|setInterval/g) || [],
dates: content.match(/new Date\(\)|Date\.now\(\)/g) || [],
random: content.match(/Math\.random\(\)|Math\.floor.*random/g) || [],
external_deps: content.match(/fetch\(|axios\.|http\./g) || [],
};
const totalFlakyPatterns = Object.values(flakyPatterns).reduce((sum, arr) => sum + arr.length, 0);
score.details.flaky_pattern_count = totalFlakyPatterns;
// Deterministic test data
const deterministicData = content.match(/const\s+\w+\s*=\s*\{|const\s+\w+\s*=\s*\[/g) || [];
score.details.uses_deterministic_data = deterministicData.length > 0;
// Proper async handling
const asyncTests = content.match(/async\s*\(|await\s+/g) || [];
const promiseHandling = content.match(/\.resolves\.|\.rejects\.|return\s+\w+\(/g) || [];
score.details.proper_async_handling = asyncTests.length > 0 && promiseHandling.length > 0;
// Scoring
score.value += Math.max(0, 4 - totalFlakyPatterns);
score.value += score.details.uses_deterministic_data ? 3 : 0;
score.value += score.details.proper_async_handling ? 3 : 0;
return score;
}
/**
* Analyze test performance considerations
*/
analyzeTestPerformance(_content) {
const score = { value: 0, max: 10, details: {} };
// Count expensive operations
const expensiveOps = {
file_io: content.match(/fs\.|readFile|writeFile/g) || [],
network: content.match(/fetch\(|axios\.|http\./g) || [],
heavy_computation: content.match(/for\s*\([^)]*1000|while\s*\(/g) || [],
large_objects: content.match(/Array\(\d{3,}\)|new Array\(\d{3,}\)/g) || [],
};
const totalExpensiveOps = Object.values(expensiveOps).reduce((sum, arr) => sum + arr.length, 0);
score.details.expensive_operations = totalExpensiveOps;
// Mocking of expensive operations
const mockedOps = content.match(/mock\w*\s*\(\s*['"`](?:fs|http|fetch)/g) || [];
score.details.mocked_expensive_ops = mockedOps.length;
// Timeout configurations
const timeouts = content.match(/timeout\s*\(\s*\d+/g) || [];
score.details.has_timeout_config = timeouts.length > 0;
// Scoring
score.value = 10; // Start with perfect score
score.value -= Math.min(totalExpensiveOps * 2, 6); // Penalty for expensive ops
score.value += Math.min(mockedOps.length, 3); // Bonus for mocking
score.value += score.details.has_timeout_config ? 1 : 0;
return Math.max(score.value, 0);
}
/**
* Analyze framework usage quality
*/
analyzeFrameworkUsage(_content) {
const score = { value: 0, max: 10, details: {} };
// Detect framework
const framework = this.detectTestFramework(_content);
score.details.framework = framework;
// Framework-specific best practices
switch (framework) {
case 'jest':
score.value += this.analyzeJestUsage(_content);
break;
case 'mocha':
score.value += this.analyzeMochaUsage(_content);
break;
case 'vitest':
score.value += this.analyzeVitestUsage(_content);
break;
default:
score.value += 5; // Neutral score for unknown framework
}
return score;
}
/**
* Analyze mock quality
*/
analyzeMockQuality(_content) {
const score = { value: 0, max: 10, details: {} };
// Mock usage patterns
const mocks = {
jest_mocks: content.match(/jest\.mock\(|jest\.fn\(\)|mockImplementation/g) || [],
sinon_mocks: content.match(/sinon\.mock\(|sinon\.spy\(|sinon\.stub\(/g) || [],
vitest_mocks: content.match(/vi\.mock\(|vi\.fn\(\)|mockImplementation/g) || [],
};
const totalMocks = Object.values(mocks).reduce((sum, arr) => sum + arr.length, 0);
score.details.total_mocks = totalMocks;
if (totalMocks === 0) {
score.value = 7; // Neutral score for no mocks
return score;
}
// Mock verification
const mockVerifications = content.match(/toHaveBeenCalled|toHaveBeenCalledWith|calledWith|called/g) || [];
score.details.mock_verifications = mockVerifications.length;
score.details.verification_ratio = mockVerifications.length / totalMocks;
// Mock cleanup
const mockCleanup = content.match(/mockRestore|mockClear|restore\(\)|clearAllMocks/g) || [];
score.details.mock_cleanup = mockCleanup.length > 0;
// Scoring
score.value += Math.min(score.details.verification_ratio * 6, 6);
score.value += score.details.mock_cleanup ? 2 : 0;
score.value += Math.min(totalMocks * 0.5, 2); // Bonus for using mocks appropriately
return score;
}
// Helper methods for analysis
checkIndentationConsistency(lines) {
const indentations = lines
.filter(line => line.trim())
.map(line => line.match(/^\s*/)[0].length)
.filter(indent => indent > 0);
if (indentations.length < 2) return true;
// Check if indentation follows a consistent pattern (2 or 4 spaces)
const commonIndent = indentations.reduce((acc, indent) => {
const factor = indent % 2 === 0 ? 2 : (indent % 4 === 0 ? 4 : 1);
acc[factor] = (acc[factor] || 0) + 1;
return acc;
}, {});
const mostCommon = Object.keys(commonIndent).reduce((a, b) =>
commonIndent[a] > commonIndent[b] ? a : b,
);
const consistent = indentations.filter(indent => indent % mostCommon === 0).length;
return consistent / indentations.length > 0.8;
}
extractTestNames(_content) {
const testMatches = content.match(/(?:it|test)\s*\(\s*['"`]([^'"`]+)['"`]/g) || [];
return testMatches.map(match => {
const nameMatch = match.match(/['"`]([^'"`]+)['"`]/);
return nameMatch ? nameMatch[1] : '';
}).filter(name => name);
}
calculateLineComplexity(line) {
let complexity = 0;
// Nesting indicators
complexity += (line.match(/[\(\)\[\]\{\}]/g) || []).length * 0.5;
// Logical operators
complexity += (line.match(/&&|\|\||!(?!=)/g) || []).length;
// Conditional statements
complexity += (line.match(/\?|\:|if|else|switch|case/g) || []).length;
// Function calls
complexity += (line.match(/\w+\s*\(/g) || []).length * 0.3;
return complexity;
}
detectTestFramework(_content) {
if (content.includes('jest') || content.includes('expect(')) return 'jest';
if (content.includes('mocha') || content.includes('chai')) return 'mocha';
if (content.includes('vitest') || content.includes('vi.')) return 'vitest';
return 'unknown';
}
analyzeJestUsage(_content) {
let score = 0;
// Good Jest practices
if (content.includes('expect.extend')) score += 1;
if (content.includes('jest.mock')) score += 1;
if (content.includes('toMatchSnapshot')) score += 1;
if (content.includes('toHaveBeenCalled')) score += 1;
if (content.includes('jest.clearAllMocks')) score += 1;
return Math.min(score, 5);
}
analyzeMochaUsage(_content) {
let score = 0;
// Good Mocha practices
if (content.includes('this.timeout')) score += 1;
if (content.includes('done')) score += 1;
if (content.includes('chai')) score += 1;
if (content.includes('sinon')) score += 1;
return Math.min(score, 4);
}
analyzeVitestUsage(_content) {
let score = 0;
// Good Vitest practices
if (content.includes('vi.mock')) score += 1;
if (content.includes('vi.spyOn')) score += 1;
if (content.includes('vi.clearAllMocks')) score += 1;
return Math.min(score, 3);
}
/**
* Calculate overall quality score
*/
calculateOverallScore(metrics) {
const weights = {
test_organization: 0.15,
naming_quality: 0.12,
assertion_quality: 0.15,
test_coverage_breadth: 0.15,
edge_case_coverage: 0.10,
code_clarity: 0.08,
maintainability: 0.08,
isolation: 0.07,
reliability: 0.05,
performance: 0.03,
framework_usage: 0.01,
mock_quality: 0.01,
};
let totalScore = 0;
let totalWeight = 0;
for (const [metric, weight] of Object.entries(weights)) {
if (metrics[metric]) {
const normalizedScore = (metrics[metric].value || metrics[metric]) / 10;
totalScore += normalizedScore * weight * 10;
totalWeight += weight;
}
}
return totalWeight > 0 ? totalScore / totalWeight : 0;
}
/**
* Determine quality rating from score
*/
determineQualityRating(score) {
if (score >= 9) return 'excellent';
if (score >= 7.5) return 'good';
if (score >= 6) return 'fair';
if (score >= 4) return 'poor';
return 'very_poor';
}
/**
* Identify quality issues from metrics
*/
identifyQualityIssues(metrics, content) {
const issues = [];
// Organization issues
if (metrics.test_organization.value < 5) {
issues.push({
category: 'organization',
severity: 'medium',
message: 'Poor test organization - missing describe blocks or setup/teardown',
suggestion: 'Use describe blocks to group related tests and add beforeEach/afterEach for setup',
});
}
// Naming issues
if (metrics.naming_quality.value < 5) {
issues.push({
category: 'naming',
severity: 'medium',
message: 'Poor test naming - tests should clearly describe what they verify',
suggestion: 'Use descriptive test names that explain the expected behavior',
});
}
// Assertion issues
if (metrics.assertion_quality.value < 6) {
issues.push({
category: 'assertions',
severity: 'high',
message: 'Weak assertions - too many generic or few specific assertions',
suggestion: 'Use specific assertions like toEqual() instead of toBeTruthy()',
});
}
// Coverage issues
if (metrics.test_coverage_breadth.value < 5) {
issues.push({
category: 'coverage',
severity: 'high',
message: 'Limited test coverage - missing error cases or edge cases',
suggestion: 'Add tests for error conditions, edge cases, and different input scenarios',
});
}
// Edge case issues
if (metrics.edge_case_coverage.value < 4) {
issues.push({
category: 'edge_cases',
severity: 'medium',
message: 'Insufficient edge case coverage',
suggestion: 'Add tests for null/undefined values, empty inputs, and boundary conditions',
});
}
// Reliability issues
if (metrics.reliability.value < 6) {
issues.push({
category: 'reliability',
severity: 'high',
message: 'Tests may be flaky due to timing, randomness, or external dependencies',
suggestion: 'Mock external dependencies and use deterministic test data',
});
}
return issues;
}
/**
* Generate improvement recommendations
*/
generateRecommendations(metrics, issues) {
const recommendations = [];
// Prioritize recommendations based on impact and severity
const _highImpactIssues = issues.filter(issue => issue.severity === 'high');
const _mediumImpactIssues = issues.filter(issue => issue.severity === 'medium');
// Add specific recommendations based on metrics
if (metrics.assertion_quality.value < 7) {
recommendations.push({
priority: 'high',
category: 'assertions',
action: 'Improve assertion specificity',
details: 'Replace generic assertions with specific ones that verify exact expected values',
});
}
if (metrics.test_coverage_breadth.value < 6) {
recommendations.push({
priority: 'high',
category: 'coverage',
action: 'Expand test scenarios',
details: 'Add tests for error handling, async operations, and integration scenarios',
});
}
if (metrics.maintainability.value < 6) {
recommendations.push({
priority: 'medium',
category: 'maintainability',
action: 'Reduce code duplication',
details: 'Extract common test setup into helper functions or use test factories',
});
}
if (metrics.isolation.value < 7) {
recommendations.push({
priority: 'medium',
category: 'isolation',
action: 'Improve test isolation',
details: 'Ensure tests can run independently by using proper setup/teardown',
});
}
return recommendations;
}
/**
* Estimate coverage contribution of the test
*/
estimateCoverageContribution(metrics) {
// Base coverage from test breadth
let coverage = metrics.test_coverage_breadth.value * 5; // 0-50%
// Bonus from assertion quality
coverage += metrics.assertion_quality.value * 2; // 0-20%
// Bonus from edge case coverage
coverage += metrics.edge_case_coverage.value * 2; // 0-20%
// Bonus from mock usage (interaction testing)
if (metrics.mock_quality.total_mocks > 0) {
coverage += 10; // 10% bonus
}
return Math.min(coverage, 95); // Cap at 95%
}
/**
* Identify common issues across multiple test files
*/
identifyCommonIssues(allIssues) {
const issueCounts = {};
for (const issue of allIssues) {
const key = `${issue.category}-${issue.message}`;
issueCounts[key] = (issueCounts[key] || 0) + 1;
}
// Return issues that appear in multiple files
return Object.entries(issueCounts)
.filter(([_, count]) => count > 1)
.map(([key, count]) => {
const [category, message] = key.split('-', 2);
return { category, message, occurrences: count };
})
.sort((a, b) => b.occurrences - a.occurrences);
}
/**
* Generate suite-level recommendations
*/
generateSuiteRecommendations(suiteAssessment) {
const recommendations = [];
if (suiteAssessment.overall_suite_score < 6) {
recommendations.push({
priority: 'high',
type: 'overall_quality',
message: 'Test suite quality is below acceptable standards',
action: 'Focus on improving test organization, assertions, and coverage breadth',
});
}
const poorQualityFiles = suiteAssessment.quality_distribution.poor +
suiteAssessment.quality_distribution.very_poor;
if (poorQualityFiles > suiteAssessment.test_files * 0.3) {
recommendations.push({
priority: 'high',
type: 'poor_quality_files',
message: `${poorQualityFiles} files have poor test quality`,
action: 'Prioritize refactoring tests with quality ratings of "poor" or "very_poor"',
});
}
if (suiteAssessment.common_issues.length > 3) {
recommendations.push({
priority: 'medium',
type: 'systematic_issues',
message: 'Multiple files share common quality issues',
action: 'Address systematic issues across the test suite with consistent patterns',
});
}
return recommendations;
}
// Data persistence methods
async loadQualityHistory() {
try {
const historyFile = path.join(this.qualityReportsDir, 'quality-history.json');
const exists = await fs.access(historyFile).then(() => true).catch(() => false);
if (exists) {
const data = JSON.parse(await fs.readFile(historyFile, 'utf-8'));
this.qualityHistory = data.quality_history || [];
}
} catch {
// No existing data, start fresh
}
}
async saveSuiteAssessment(suiteAssessment) {
try {
// Save individual suite assessment
const assessmentId = `suite-${Date.now()}`;
const assessmentFile = path.join(this.qualityReportsDir, `${assessmentId}.json`);
await fs.writeFile(assessmentFile, JSON.stringify(suiteAssessment, null, 2));
// Update quality history
this.qualityHistory.push({
assessment_id: assessmentId,
timestamp: suiteAssessment.assessed_at,
overall_score: suiteAssessment.overall_suite_score,
test_files: suiteAssessment.test_files,
});
// Save updated history
const historyFile = path.join(this.qualityReportsDir, 'quality-history.json');
const historyData = {
last_updated: new Date().toISOString(),
quality_history: this.qualityHistory.slice(-20), // Keep last 20 assessments
};
await fs.writeFile(historyFile, JSON.stringify(historyData, null, 2));
console.log(chalk.gray(`Quality assessment saved: ${assessmentFile}`));
} catch (error) {
console.warn(chalk.yellow(`Failed to save quality assessment: ${error.message}`));
}
}
/**
* Initialize quality standards configuration
*/
initializeQualityStandards() {
return {
minimum_scores: {
overall: 6.0,
test_organization: 5.0,
naming_quality: 5.0,
assertion_quality: 6.0,
test_coverage_breadth: 5.0,
reliability: 6.0,
},
target_scores: {
overall: 8.5,
test_organization: 8.0,
naming_quality: 7.5,
assertion_quality: 8.5,
test_coverage_breadth: 8.0,
reliability: 8.5,
},
quality_thresholds: {
excellent: 9.0,
good: 7.5,
fair: 6.0,
poor: 4.0,
},
};
}
/**
* Get quality trends over time
*/
getQualityTrends() {
if (this.qualityHistory.length < 2) {
return { trend: 'insufficient_data', message: 'Need at least 2 assessments for trend calculation' };
}
const recent = this.qualityHistory.slice(-5); // Last 5 assessments
const scores = recent.map(h => h.overall_score);
const firstScore = scores[0];
const lastScore = scores[scores.length - 1];
const difference = lastScore - firstScore;
let trend = 'stable';
if (difference > 0.5) trend = 'improving';
else if (difference < -0.5) trend = 'declining';
return {
trend,
difference: difference.toFixed(1),
current_score: lastScore,
assessment_count: recent.length,
};
}
}
module.exports = TestQualityAssessment;