/**
* Story 04: Test Suite Migration Utility Implementation
*
* TestSuiteMigrationUtility provides comprehensive migration capabilities for converting
* legacy string array command formats to enhanced parameter structure across the entire
* Villenele test suite.
*
* Key capabilities:
* 1. Automated legacy pattern detection and conversion
* 2. File system scanning and batch processing
* 3. Migration audit and verification reporting
* 4. Rollback capability for troubleshooting
* 5. Parameter validation and error handling
* 6. Performance benchmarking and compatibility validation
*
* CRITICAL: No mocks in production code - uses real file system operations and validation
*/
import { EnhancedCommandParameter } from './post-websocket-command-executor';
import fs from 'fs';
import path from 'path';
import { promisify } from 'util';
const readFileAsync = promisify(fs.readFile);
const writeFileAsync = promisify(fs.writeFile);
const readDirAsync = promisify(fs.readdir);
const statAsync = promisify(fs.stat);
/**
* Legacy pattern detection result
*/
export interface LegacyPatternDetection {
legacyStringArrays: LegacyPatternMatch[];
totalLegacyPatterns: number;
migrationRequired: boolean;
fileLocation?: string;
}
/**
* Individual legacy pattern match
*/
export interface LegacyPatternMatch {
pattern: string;
lineNumber: number;
columnStart: number;
columnEnd: number;
context: string;
}
/**
* Migration coverage calculation result
*/
export interface MigrationCoverage {
totalFiles: number;
filesRequiringMigration: number;
filesAlreadyMigrated: number;
coveragePercentage: number;
unmigrated: string[];
}
/**
* Migration audit report
*/
export interface MigrationAuditReport {
totalFilesScanned: number;
filesConverted: number;
filesSkipped: number;
remainingLegacyPatterns: number;
migrationCompletionPercentage: number;
conversionDetails: FileConversionDetail[];
performanceMetrics: PerformanceMetrics;
}
/**
* Individual file conversion detail
*/
export interface FileConversionDetail {
filePath: string;
originalSize: number;
convertedSize: number;
legacyPatternsFound: number;
legacyPatternsConverted: number;
conversionTime: number;
backupCreated: boolean;
}
/**
* Performance metrics for migration process
*/
export interface PerformanceMetrics {
totalMigrationTime: number;
averageFileProcessingTime: number;
memoryUsageBefore: number;
memoryUsageAfter: number;
filesPerSecond: number;
}
/**
* Migration operation result
*/
export interface MigrationResult {
success: boolean;
migrated: boolean;
originalContent: string;
convertedContent: string;
patternsConverted: number;
error?: string;
backupPath?: string;
}
/**
* Migration validation error
*/
export class MigrationValidationError extends Error {
constructor(message: string, public readonly details?: unknown) {
super(message);
this.name = 'MigrationValidationError';
}
}
/**
* TestSuiteMigrationUtility - Comprehensive migration utility for Villenele test suite
*/
export class TestSuiteMigrationUtility {
private static readonly LEGACY_PATTERN_REGEX = /postWebSocketCommands\s*:\s*\[\s*'[^']*'(?:\s*,\s*'[^']*')*\s*\]/g;
private static readonly STRING_ARRAY_PATTERN = /'([^']*)'/g;
private static readonly BACKUP_SUFFIX = '.pre-migration-backup';
constructor() {
// Initialize utility with clean state
}
/**
* Migrate a single file from legacy to enhanced parameter structure
*/
async migrateFile(filePath: string): Promise<MigrationResult> {
try {
const originalContent = await readFileAsync(filePath, 'utf-8');
const detection = await this.detectLegacyPatterns(originalContent, filePath);
if (!detection.migrationRequired) {
return {
success: true,
migrated: false,
originalContent,
convertedContent: originalContent,
patternsConverted: 0
};
}
// Create backup before migration
const backupPath = filePath + TestSuiteMigrationUtility.BACKUP_SUFFIX;
await writeFileAsync(backupPath, originalContent);
// Convert content
const convertedContent = await this.convertLegacyConfiguration(originalContent);
// Write migrated content back to original file
await writeFileAsync(filePath, convertedContent);
return {
success: true,
migrated: true,
originalContent,
convertedContent,
patternsConverted: detection.totalLegacyPatterns,
backupPath
};
} catch (error) {
return {
success: false,
migrated: false,
originalContent: '',
convertedContent: '',
patternsConverted: 0,
error: error instanceof Error ? error.message : String(error)
};
}
}
/**
* Convert legacy configuration content to enhanced parameter structure
*/
async convertLegacyConfiguration(content: string): Promise<string> {
let convertedContent = content;
let conversionCount = 0;
// Find and replace all legacy postWebSocketCommands patterns
convertedContent = convertedContent.replace(
TestSuiteMigrationUtility.LEGACY_PATTERN_REGEX,
(match) => {
conversionCount++;
return this.convertLegacyMatch(match);
}
);
return convertedContent;
}
/**
* Convert a single legacy match to enhanced parameter structure
*/
private convertLegacyMatch(match: string): string {
// Extract the string array part
const arrayMatch = match.match(/\[([^\]]+)\]/);
if (!arrayMatch) {
return match; // Return unchanged if pattern doesn't match expected format
}
const arrayContent = arrayMatch[1];
const commands: string[] = [];
// Extract individual commands from the array
let stringMatch;
const stringRegex = new RegExp(TestSuiteMigrationUtility.STRING_ARRAY_PATTERN);
while ((stringMatch = stringRegex.exec(arrayContent)) !== null) {
commands.push(stringMatch[1]);
}
// Convert to enhanced parameter structure
const enhancedCommands = commands.map(cmd =>
`{initiator: 'mcp-client', command: '${cmd}'}`
).join(', ');
return `postWebSocketCommands: [${enhancedCommands}]`;
}
/**
* Detect legacy patterns in content
*/
async detectLegacyPatterns(content: string, filePath?: string): Promise<LegacyPatternDetection> {
const legacyMatches: LegacyPatternMatch[] = [];
const lines = content.split('\n');
let match;
const regex = new RegExp(TestSuiteMigrationUtility.LEGACY_PATTERN_REGEX);
while ((match = regex.exec(content)) !== null) {
const lineNumber = this.getLineNumber(content, match.index);
const line = lines[lineNumber - 1];
const columnStart = match.index - content.lastIndexOf('\n', match.index) - 1;
legacyMatches.push({
pattern: match[0],
lineNumber,
columnStart,
columnEnd: columnStart + match[0].length,
context: line.trim()
});
}
return {
legacyStringArrays: legacyMatches,
totalLegacyPatterns: legacyMatches.length,
migrationRequired: legacyMatches.length > 0,
fileLocation: filePath
};
}
/**
* Get line number for character index
*/
private getLineNumber(content: string, index: number): number {
return content.substring(0, index).split('\n').length;
}
/**
* Get all test files in directory structure
*/
async getAllTestFiles(rootDirectory: string): Promise<string[]> {
const testFiles: string[] = [];
const scanDirectory = async (dir: string): Promise<void> => {
try {
const entries = await readDirAsync(dir);
for (const entry of entries) {
const fullPath = path.join(dir, entry);
const stat = await statAsync(fullPath);
if (stat.isDirectory() && entry !== 'node_modules' && entry !== '.git') {
await scanDirectory(fullPath);
} else if (stat.isFile() && (entry.endsWith('.test.ts') || entry.endsWith('.spec.ts'))) {
testFiles.push(fullPath);
}
}
} catch (error) {
// Skip directories that can't be read
}
};
await scanDirectory(rootDirectory);
return testFiles;
}
/**
* Calculate migration coverage across all test files
*/
async calculateMigrationCoverage(testFiles: string[]): Promise<MigrationCoverage> {
let filesRequiringMigration = 0;
let filesAlreadyMigrated = 0;
const unmigrated: string[] = [];
// Exclude migration test files from coverage calculation
const excludePatterns = [
'story4-test-suite-migration.test.ts',
'test-suite-migration-utility.test.ts'
];
for (const filePath of testFiles) {
// Skip files that are testing migration functionality itself
const fileName = path.basename(filePath);
if (excludePatterns.some(pattern => fileName.includes(pattern))) {
continue;
}
try {
const content = await readFileAsync(filePath, 'utf-8');
const legacyDetection = await this.detectLegacyPatterns(content, filePath);
if (legacyDetection.migrationRequired) {
filesRequiringMigration++;
unmigrated.push(filePath);
} else {
filesAlreadyMigrated++;
}
} catch (error) {
// Skip files that can't be read
}
}
const totalRelevantFiles = filesAlreadyMigrated + filesRequiringMigration;
const coveragePercentage = totalRelevantFiles > 0
? (filesAlreadyMigrated / totalRelevantFiles) * 100
: 100;
return {
totalFiles: totalRelevantFiles,
filesRequiringMigration,
filesAlreadyMigrated,
coveragePercentage,
unmigrated
};
}
/**
* Migrate test configuration object
*/
async migrateTestConfiguration(config: any): Promise<any> {
const migratedConfig = { ...config };
if (config.postWebSocketCommands && Array.isArray(config.postWebSocketCommands)) {
// Check if already migrated (contains objects)
if (config.postWebSocketCommands.some((cmd: any) => typeof cmd === 'object')) {
return migratedConfig; // Already migrated
}
// Convert string array to enhanced parameter structure
migratedConfig.postWebSocketCommands = config.postWebSocketCommands.map((cmd: string) => ({
initiator: 'mcp-client' as const,
command: cmd
}));
}
return migratedConfig;
}
/**
* Convert command array to enhanced parameters
*/
async convertCommandArray(commands: string[]): Promise<EnhancedCommandParameter[]> {
return commands.map(command => ({
initiator: 'mcp-client' as const,
command
}));
}
/**
* Convert single command to enhanced parameter
*/
async convertSingleCommand(command: string): Promise<EnhancedCommandParameter> {
return {
initiator: 'mcp-client',
command
};
}
/**
* Migrate complex configuration arrays
*/
async migrateComplexConfiguration(configs: any[]): Promise<any[]> {
const migratedConfigs = [];
for (const config of configs) {
const migratedConfig = await this.migrateTestConfiguration(config);
migratedConfigs.push(migratedConfig);
}
return migratedConfigs;
}
/**
* Validate enhanced parameter format
*/
async validateEnhancedFormat(config: any): Promise<void> {
if (config.postWebSocketCommands && Array.isArray(config.postWebSocketCommands)) {
// Check for legacy string arrays
const hasLegacyStrings = config.postWebSocketCommands.some((cmd: any) =>
typeof cmd === 'string'
);
if (hasLegacyStrings) {
throw new MigrationValidationError(
'Legacy string array format detected. Please use enhanced parameter structure with {initiator, command} objects.'
);
}
// Validate enhanced parameter structure
for (let i = 0; i < config.postWebSocketCommands.length; i++) {
const cmd = config.postWebSocketCommands[i];
if (typeof cmd !== 'object' || cmd === null) {
throw new MigrationValidationError(
`Command at index ${i} must be an object with enhanced parameter structure`
);
}
if (!cmd.hasOwnProperty('initiator')) {
throw new MigrationValidationError(
`Command at index ${i} missing required 'initiator' field`
);
}
if (!cmd.hasOwnProperty('command')) {
throw new MigrationValidationError(
`Command at index ${i} missing required 'command' field`
);
}
if (cmd.initiator !== 'browser' && cmd.initiator !== 'mcp-client') {
throw new MigrationValidationError(
`Command at index ${i}: initiator must be 'browser' or 'mcp-client'`
);
}
if (typeof cmd.command !== 'string' || cmd.command.trim().length === 0) {
throw new MigrationValidationError(
`Command at index ${i}: command must be non-empty string`
);
}
}
}
}
/**
* Rollback migration to original format
*/
async rollbackMigration(_convertedContent: string, originalContent: string): Promise<string> {
return originalContent;
}
/**
* Rollback test configuration to original format
*/
async rollbackTestConfiguration(_migratedConfig: any, originalConfig: any): Promise<any> {
return { ...originalConfig };
}
/**
* Generate comprehensive migration audit report
*/
async generateMigrationAuditReport(testFiles: string[]): Promise<MigrationAuditReport> {
const startTime = Date.now();
const memoryBefore = process.memoryUsage().heapUsed;
const conversionDetails: FileConversionDetail[] = [];
let totalConverted = 0;
let totalSkipped = 0;
let totalRemainingPatterns = 0;
for (const filePath of testFiles) {
const fileStartTime = Date.now();
try {
const originalContent = await readFileAsync(filePath, 'utf-8');
const originalSize = originalContent.length;
const legacyDetection = await this.detectLegacyPatterns(originalContent, filePath);
if (legacyDetection.migrationRequired) {
const convertedContent = await this.convertLegacyConfiguration(originalContent);
const postConversionDetection = await this.detectLegacyPatterns(convertedContent);
totalConverted++;
totalRemainingPatterns += postConversionDetection.totalLegacyPatterns;
conversionDetails.push({
filePath,
originalSize,
convertedSize: convertedContent.length,
legacyPatternsFound: legacyDetection.totalLegacyPatterns,
legacyPatternsConverted: legacyDetection.totalLegacyPatterns - postConversionDetection.totalLegacyPatterns,
conversionTime: Date.now() - fileStartTime,
backupCreated: false // Would be true if actual backup was created
});
} else {
totalSkipped++;
}
} catch (error) {
totalSkipped++;
}
}
const totalTime = Date.now() - startTime;
const memoryAfter = process.memoryUsage().heapUsed;
return {
totalFilesScanned: testFiles.length,
filesConverted: totalConverted,
filesSkipped: totalSkipped,
remainingLegacyPatterns: totalRemainingPatterns,
migrationCompletionPercentage: testFiles.length > 0
? ((testFiles.length - totalRemainingPatterns) / testFiles.length) * 100
: 100,
conversionDetails,
performanceMetrics: {
totalMigrationTime: totalTime,
averageFileProcessingTime: testFiles.length > 0 ? totalTime / testFiles.length : 0,
memoryUsageBefore: memoryBefore,
memoryUsageAfter: memoryAfter,
filesPerSecond: totalTime > 0 ? (testFiles.length / totalTime) * 1000 : 0
}
};
}
/**
* Validate Jest compatibility for migrated configuration
*/
async validateJestCompatibility(config: any): Promise<boolean> {
try {
await this.validateEnhancedFormat(config);
// Additional Jest-specific validations
if (config.postWebSocketCommands) {
for (const cmd of config.postWebSocketCommands) {
if (typeof cmd === 'object') {
// Ensure serializable for Jest
JSON.stringify(cmd);
}
}
}
return true;
} catch (error) {
return false;
}
}
}