/** * Golden File Comparison Utilities * * Compares generated output against golden reference files for regression testing. */ import { existsSync } from 'node:fs'; import { readFile, readdir } from 'node:fs/promises'; import { join, relative } from 'node:path'; export interface FileDifference { path: string; type: 'missing-in-golden' | 'missing-in-generated' | 'content-mismatch'; goldenContent?: string; generatedContent?: string; } export interface ComparisonResult { pass: boolean; differences: FileDifference[]; filesCompared: number; filesSkipped: number; } export interface ComparisonOptions { /** * Files to exclude from comparison (glob patterns relative to base dir) * Example: ['**\/*.json', 'secrets/**'] */ excludePatterns?: string[]; } /** * Compare two directories recursively * * @param goldenDir - Path to golden reference directory * @param generatedDir - Path to generated output directory * @param options - Comparison options * @returns Comparison result with any differences found */ export async function compareDirectories( goldenDir: string, generatedDir: string, options: ComparisonOptions = {}, ): Promise { const differences: FileDifference[] = []; let filesCompared = 0; let filesSkipped = 0; // Files to skip (contain encrypted secrets that change each run) const skipPatterns = options.excludePatterns || []; const shouldSkip = (path: string): boolean => { return skipPatterns.some((pattern) => { // Simple glob matching - just check if path includes pattern return path.includes(pattern.replace('**/', '').replace('*', '')); }); }; // Get all files from both directories const goldenFiles = await getAllFiles(goldenDir); const generatedFiles = await getAllFiles(generatedDir); // Convert to relative paths for comparison const goldenRelativePaths = new Set(goldenFiles.map((f) => relative(goldenDir, f))); const generatedRelativePaths = new Set(generatedFiles.map((f) => relative(generatedDir, f))); // Check for files in golden but not in generated for (const relativePath of goldenRelativePaths) { if (!generatedRelativePaths.has(relativePath)) { // Skip if matches exclude pattern if (shouldSkip(relativePath)) { filesSkipped++; continue; } differences.push({ path: relativePath, type: 'missing-in-generated', }); } } // Check for files in generated but not in golden for (const relativePath of generatedRelativePaths) { if (!goldenRelativePaths.has(relativePath)) { // Skip if matches exclude pattern if (shouldSkip(relativePath)) { filesSkipped++; continue; } differences.push({ path: relativePath, type: 'missing-in-golden', }); } } // Compare content of files that exist in both for (const relativePath of goldenRelativePaths) { if (generatedRelativePaths.has(relativePath)) { // Skip files that match exclude patterns if (shouldSkip(relativePath)) { filesSkipped++; continue; } const goldenPath = join(goldenDir, relativePath); const generatedPath = join(generatedDir, relativePath); const goldenContent = await readFile(goldenPath, 'utf-8'); const generatedContent = await readFile(generatedPath, 'utf-8'); if (goldenContent !== generatedContent) { differences.push({ path: relativePath, type: 'content-mismatch', goldenContent, generatedContent, }); } filesCompared++; } } return { pass: differences.length === 0, differences, filesCompared, filesSkipped, }; } /** * Get all files in a directory recursively * * @param dir - Directory path * @returns Array of absolute file paths */ async function getAllFiles(dir: string): Promise { const files: string[] = []; if (!existsSync(dir)) { return files; } const entries = await readdir(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = join(dir, entry.name); if (entry.isDirectory()) { const subFiles = await getAllFiles(fullPath); files.push(...subFiles); } else { files.push(fullPath); } } return files; } /** * Format differences for display * * @param differences - Array of differences * @returns Formatted string */ export function formatDifferences(differences: FileDifference[]): string { const lines: string[] = []; for (const diff of differences) { switch (diff.type) { case 'missing-in-golden': lines.push(`❌ File exists in generated but not in golden: ${diff.path}`); lines.push(' (Run with --update-golden to add this file)'); break; case 'missing-in-generated': lines.push(`❌ File exists in golden but not in generated: ${diff.path}`); break; case 'content-mismatch': lines.push(`❌ Content mismatch: ${diff.path}`); if (diff.goldenContent && diff.generatedContent) { lines.push(' Golden (first 200 chars):'); lines.push(` ${diff.goldenContent.substring(0, 200).replace(/\n/g, '\\n')}`); lines.push(' Generated (first 200 chars):'); lines.push(` ${diff.generatedContent.substring(0, 200).replace(/\n/g, '\\n')}`); } break; } lines.push(''); } return lines.join('\n'); }