622 lines
		
	
	
		
			21 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			622 lines
		
	
	
		
			21 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| import { coloredString as cs } from '@push.rocks/consolecolor';
 | ||
| import * as plugins from './tstest.plugins.js';
 | ||
| import * as fs from 'fs';
 | ||
| import * as path from 'path';
 | ||
| 
 | ||
| export interface LogOptions {
 | ||
|   quiet?: boolean;
 | ||
|   verbose?: boolean;
 | ||
|   noColor?: boolean;
 | ||
|   json?: boolean;
 | ||
|   logFile?: boolean;
 | ||
| }
 | ||
| 
 | ||
| export interface TestFileResult {
 | ||
|   file: string;
 | ||
|   passed: number;
 | ||
|   failed: number;
 | ||
|   total: number;
 | ||
|   duration: number;
 | ||
|   tests: Array<{
 | ||
|     name: string;
 | ||
|     passed: boolean;
 | ||
|     duration: number;
 | ||
|     error?: string;
 | ||
|   }>;
 | ||
| }
 | ||
| 
 | ||
| export interface TestSummary {
 | ||
|   totalFiles: number;
 | ||
|   totalTests: number;
 | ||
|   totalPassed: number;
 | ||
|   totalFailed: number;
 | ||
|   totalSkipped: number;
 | ||
|   totalDuration: number;
 | ||
|   fileResults: TestFileResult[];
 | ||
|   skippedFiles: string[];
 | ||
| }
 | ||
| 
 | ||
| export class TsTestLogger {
 | ||
|   public readonly options: LogOptions;
 | ||
|   private startTime: number;
 | ||
|   private fileResults: TestFileResult[] = [];
 | ||
|   private currentFileResult: TestFileResult | null = null;
 | ||
|   private currentTestLogFile: string | null = null;
 | ||
|   private currentTestLogs: string[] = []; // Buffer for current test logs
 | ||
|   private currentTestFailed: boolean = false;
 | ||
|   
 | ||
|   constructor(options: LogOptions = {}) {
 | ||
|     this.options = options;
 | ||
|     this.startTime = Date.now();
 | ||
|   }
 | ||
|   
 | ||
|   private format(text: string, color?: string): string {
 | ||
|     if (this.options.noColor || !color) {
 | ||
|       return text;
 | ||
|     }
 | ||
|     return cs(text, color as any);
 | ||
|   }
 | ||
|   
 | ||
|   private log(message: string) {
 | ||
|     if (this.options.json) {
 | ||
|       // For JSON mode, skip console output
 | ||
|       // JSON output is handled by logJson method
 | ||
|       return;
 | ||
|     }
 | ||
|     
 | ||
|     console.log(message);
 | ||
|     
 | ||
|     // Log to the current test file log if we're in a test and --logfile is specified
 | ||
|     if (this.currentTestLogFile) {
 | ||
|       this.logToTestFile(message);
 | ||
|     }
 | ||
|   }
 | ||
|   
 | ||
|   private logToFile(message: string) {
 | ||
|     // This method is no longer used since we use logToTestFile for individual test logs
 | ||
|     // Keeping it for potential future use with a global log file
 | ||
|   }
 | ||
|   
 | ||
|   private logToTestFile(message: string) {
 | ||
|     try {
 | ||
|       // Remove ANSI color codes for file logging
 | ||
|       const cleanMessage = message.replace(/\u001b\[[0-9;]*m/g, '');
 | ||
|       
 | ||
|       // Append to test log file
 | ||
|       fs.appendFileSync(this.currentTestLogFile, cleanMessage + '\n');
 | ||
|     } catch (error) {
 | ||
|       // Silently fail to avoid disrupting the test run
 | ||
|     }
 | ||
|   }
 | ||
|   
 | ||
|   private logJson(data: any) {
 | ||
|     const jsonString = JSON.stringify(data);
 | ||
|     console.log(jsonString);
 | ||
|     
 | ||
|     // Also log to test file if --logfile is specified
 | ||
|     if (this.currentTestLogFile) {
 | ||
|       this.logToTestFile(jsonString);
 | ||
|     }
 | ||
|   }
 | ||
|   
 | ||
|   // Section separators
 | ||
|   sectionStart(title: string) {
 | ||
|     if (this.options.quiet || this.options.json) return;
 | ||
|     this.log(this.format(`\n━━━ ${title} ━━━`, 'cyan'));
 | ||
|   }
 | ||
|   
 | ||
|   sectionEnd() {
 | ||
|     if (this.options.quiet || this.options.json) return;
 | ||
|     this.log(this.format('─'.repeat(50), 'dim'));
 | ||
|   }
 | ||
|   
 | ||
|   // Progress indication
 | ||
|   progress(current: number, total: number, message: string) {
 | ||
|     if (this.options.quiet || this.options.json) return;
 | ||
|     const percentage = Math.round((current / total) * 100);
 | ||
|     const filled = Math.round((current / total) * 20);
 | ||
|     const empty = 20 - filled;
 | ||
|     
 | ||
|     this.log(this.format(`\n📊 Progress: ${current}/${total} (${percentage}%)`, 'cyan'));
 | ||
|     this.log(this.format(`[${'█'.repeat(filled)}${'░'.repeat(empty)}] ${message}`, 'dim'));
 | ||
|   }
 | ||
|   
 | ||
|   // Test discovery
 | ||
|   testDiscovery(count: number, pattern: string, executionMode: string) {
 | ||
|     if (this.options.json) {
 | ||
|       this.logJson({ event: 'discovery', count, pattern, executionMode });
 | ||
|       return;
 | ||
|     }
 | ||
|     
 | ||
|     if (this.options.quiet) {
 | ||
|       this.log(`Found ${count} tests`);
 | ||
|     } else {
 | ||
|       this.log(this.format(`\n🔍 Test Discovery`, 'bold'));
 | ||
|       this.log(this.format(`   Mode: ${executionMode}`, 'dim'));
 | ||
|       this.log(this.format(`   Pattern: ${pattern}`, 'dim'));
 | ||
|       this.log(this.format(`   Found: ${count} test file(s)`, 'green'));
 | ||
|     }
 | ||
|   }
 | ||
| 
 | ||
|   // Environment check - display available runtimes
 | ||
|   environmentCheck(availability: Map<string, { available: boolean; version?: string; error?: string }>) {
 | ||
|     if (this.options.json) {
 | ||
|       const runtimes: any = {};
 | ||
|       for (const [runtime, info] of availability) {
 | ||
|         runtimes[runtime] = info;
 | ||
|       }
 | ||
|       this.logJson({ event: 'environmentCheck', runtimes });
 | ||
|       return;
 | ||
|     }
 | ||
|     
 | ||
|     if (this.options.quiet) return;
 | ||
|     
 | ||
|     this.log(this.format('\n🌍 Test Environment', 'bold'));
 | ||
|     
 | ||
|     // Define runtime display names
 | ||
|     const runtimeNames: Record<string, string> = {
 | ||
|       node: 'Node.js',
 | ||
|       deno: 'Deno',
 | ||
|       bun: 'Bun',
 | ||
|       chromium: 'Chrome/Chromium'
 | ||
|     };
 | ||
|     
 | ||
|     // Display each runtime
 | ||
|     for (const [runtime, info] of availability) {
 | ||
|       const displayName = runtimeNames[runtime] || runtime;
 | ||
|       
 | ||
|       if (info.available) {
 | ||
|         const versionStr = info.version ? ` ${info.version}` : '';
 | ||
|         this.log(this.format(`   ✓ ${displayName}${versionStr}`, 'green'));
 | ||
|       } else {
 | ||
|         const errorStr = info.error ? ` (${info.error})` : '';
 | ||
|         this.log(this.format(`   ✗ ${displayName}${errorStr}`, 'dim'));
 | ||
|       }
 | ||
|     }
 | ||
|   }
 | ||
|   
 | ||
|   // Test execution
 | ||
|   testFileStart(filename: string, runtime: string, index: number, total: number) {
 | ||
|     this.currentFileResult = {
 | ||
|       file: filename,
 | ||
|       passed: 0,
 | ||
|       failed: 0,
 | ||
|       total: 0,
 | ||
|       duration: 0,
 | ||
|       tests: []
 | ||
|     };
 | ||
|     
 | ||
|     // Reset test-specific state
 | ||
|     this.currentTestLogs = [];
 | ||
|     this.currentTestFailed = false;
 | ||
|     
 | ||
|     // Only set up test log file if --logfile option is specified
 | ||
|     if (this.options.logFile) {
 | ||
|       // Create a safe filename that preserves directory structure
 | ||
|       // Convert relative path to a flat filename by replacing separators with __
 | ||
|       const relativeFilename = path.relative(process.cwd(), filename);
 | ||
|       const safeFilename = relativeFilename
 | ||
|         .replace(/\\/g, '/') // Normalize Windows paths
 | ||
|         .replace(/\//g, '__') // Replace path separators with double underscores
 | ||
|         .replace(/\.ts$/, '') // Remove .ts extension
 | ||
|         .replace(/^\.\.__|^\.__|^__/, ''); // Clean up leading separators from relative paths
 | ||
|       
 | ||
|       this.currentTestLogFile = path.join('.nogit', 'testlogs', `${safeFilename}.log`);
 | ||
|       
 | ||
|       // Ensure the directory exists
 | ||
|       const logDir = path.dirname(this.currentTestLogFile);
 | ||
|       if (!fs.existsSync(logDir)) {
 | ||
|         fs.mkdirSync(logDir, { recursive: true });
 | ||
|       }
 | ||
|       
 | ||
|       // Clear the log file for this test
 | ||
|       fs.writeFileSync(this.currentTestLogFile, '');
 | ||
|     }
 | ||
|     
 | ||
|     if (this.options.json) {
 | ||
|       this.logJson({ event: 'fileStart', filename, runtime, index, total });
 | ||
|       return;
 | ||
|     }
 | ||
|     
 | ||
|     if (this.options.quiet) return;
 | ||
|     
 | ||
|     this.log(this.format(`\n▶️  ${filename} (${index}/${total})`, 'blue'));
 | ||
|     this.log(this.format(`   Runtime: ${runtime}`, 'dim'));
 | ||
|   }
 | ||
|   
 | ||
|   testResult(testName: string, passed: boolean, duration: number, error?: string) {
 | ||
|     if (this.currentFileResult) {
 | ||
|       this.currentFileResult.tests.push({ name: testName, passed, duration, error });
 | ||
|       this.currentFileResult.total++;
 | ||
|       if (passed) {
 | ||
|         this.currentFileResult.passed++;
 | ||
|       } else {
 | ||
|         this.currentFileResult.failed++;
 | ||
|         this.currentTestFailed = true;
 | ||
|       }
 | ||
|       this.currentFileResult.duration += duration;
 | ||
|     }
 | ||
|     
 | ||
|     if (this.options.json) {
 | ||
|       this.logJson({ event: 'testResult', testName, passed, duration, error });
 | ||
|       return;
 | ||
|     }
 | ||
|     
 | ||
|     // If test failed and we have buffered logs, show them now
 | ||
|     if (!passed && this.currentTestLogs.length > 0 && !this.options.verbose) {
 | ||
|       this.log(this.format('   📋 Console output from failed test:', 'yellow'));
 | ||
|       this.currentTestLogs.forEach(logMessage => {
 | ||
|         this.log(this.format(`   ${logMessage}`, 'dim'));
 | ||
|       });
 | ||
|     }
 | ||
|     
 | ||
|     const icon = passed ? '✅' : '❌';
 | ||
|     const color = passed ? 'green' : 'red';
 | ||
|     
 | ||
|     if (this.options.quiet) {
 | ||
|       this.log(`${icon} ${testName}`);
 | ||
|     } else {
 | ||
|       this.log(this.format(`   ${icon} ${testName} (${duration}ms)`, color));
 | ||
|       if (error && !passed) {
 | ||
|         this.log(this.format(`      ${error}`, 'red'));
 | ||
|       }
 | ||
|     }
 | ||
|     
 | ||
|     // Clear logs after each test
 | ||
|     this.currentTestLogs = [];
 | ||
|   }
 | ||
|   
 | ||
|   testFileEnd(passed: number, failed: number, duration: number) {
 | ||
|     if (this.currentFileResult) {
 | ||
|       this.fileResults.push(this.currentFileResult);
 | ||
|       this.currentFileResult = null;
 | ||
|     }
 | ||
|     
 | ||
|     if (this.options.json) {
 | ||
|       this.logJson({ event: 'fileEnd', passed, failed, duration });
 | ||
|       return;
 | ||
|     }
 | ||
|     
 | ||
|     if (!this.options.quiet) {
 | ||
|       const total = passed + failed;
 | ||
|       const durationStr = duration >= 1000 ? `${(duration / 1000).toFixed(1)}s` : `${duration}ms`;
 | ||
|       
 | ||
|       if (failed === 0) {
 | ||
|         this.log(this.format(`   Summary: ${passed}/${total} PASSED in ${durationStr}`, 'green'));
 | ||
|       } else {
 | ||
|         this.log(this.format(`   Summary: ${passed} passed, ${failed} failed of ${total} tests in ${durationStr}`, 'red'));
 | ||
|       }
 | ||
|     }
 | ||
|     
 | ||
|     // If using --logfile, handle error copy and diff detection
 | ||
|     if (this.options.logFile && this.currentTestLogFile) {
 | ||
|       try {
 | ||
|         const logContent = fs.readFileSync(this.currentTestLogFile, 'utf-8');
 | ||
|         const logDir = path.dirname(this.currentTestLogFile);
 | ||
|         const logBasename = path.basename(this.currentTestLogFile);
 | ||
|         
 | ||
|         // Create error copy if there were failures
 | ||
|         if (failed > 0) {
 | ||
|           const errorDir = path.join(logDir, '00err');
 | ||
|           if (!fs.existsSync(errorDir)) {
 | ||
|             fs.mkdirSync(errorDir, { recursive: true });
 | ||
|           }
 | ||
|           const errorLogPath = path.join(errorDir, logBasename);
 | ||
|           fs.writeFileSync(errorLogPath, logContent);
 | ||
|         }
 | ||
|         
 | ||
|         // Check for previous version and create diff if changed
 | ||
|         const previousLogPath = path.join(logDir, 'previous', logBasename);
 | ||
|         if (fs.existsSync(previousLogPath)) {
 | ||
|           const previousContent = fs.readFileSync(previousLogPath, 'utf-8');
 | ||
|           
 | ||
|           // Simple check if content differs
 | ||
|           if (previousContent !== logContent) {
 | ||
|             const diffDir = path.join(logDir, '00diff');
 | ||
|             if (!fs.existsSync(diffDir)) {
 | ||
|               fs.mkdirSync(diffDir, { recursive: true });
 | ||
|             }
 | ||
|             const diffLogPath = path.join(diffDir, logBasename);
 | ||
|             const diffContent = this.createDiff(previousContent, logContent, logBasename);
 | ||
|             fs.writeFileSync(diffLogPath, diffContent);
 | ||
|           }
 | ||
|         }
 | ||
|       } catch (error) {
 | ||
|         // Silently fail to avoid disrupting the test run
 | ||
|       }
 | ||
|     }
 | ||
|     
 | ||
|     // Clear the current test log file reference only if using --logfile
 | ||
|     if (this.options.logFile) {
 | ||
|       this.currentTestLogFile = null;
 | ||
|     }
 | ||
|   }
 | ||
|   
 | ||
|   // TAP output forwarding (for TAP protocol messages)
 | ||
|   tapOutput(message: string, _isError: boolean = false) {
 | ||
|     if (this.options.json) return;
 | ||
|     
 | ||
|     // Never show raw TAP protocol messages in console
 | ||
|     // They are already processed by TapParser and shown in our format
 | ||
|     
 | ||
|     // Always log to test file if --logfile is specified
 | ||
|     if (this.currentTestLogFile) {
 | ||
|       this.logToTestFile(`   ${message}`);
 | ||
|     }
 | ||
|   }
 | ||
|   
 | ||
|   // Console output from test files (non-TAP output)
 | ||
|   testConsoleOutput(message: string) {
 | ||
|     if (this.options.json) return;
 | ||
|     
 | ||
|     // In verbose mode, show console output immediately
 | ||
|     if (this.options.verbose) {
 | ||
|       this.log(this.format(`   ${message}`, 'dim'));
 | ||
|     } else {
 | ||
|       // In non-verbose mode, buffer the logs
 | ||
|       this.currentTestLogs.push(message);
 | ||
|     }
 | ||
|     
 | ||
|     // Always log to test file if --logfile is specified
 | ||
|     if (this.currentTestLogFile) {
 | ||
|       this.logToTestFile(`   ${message}`);
 | ||
|     }
 | ||
|   }
 | ||
|   
 | ||
|   // Skipped test file
 | ||
|   testFileSkipped(filename: string, index: number, total: number, reason: string) {
 | ||
|     if (this.options.json) {
 | ||
|       this.logJson({ event: 'fileSkipped', filename, index, total, reason });
 | ||
|       return;
 | ||
|     }
 | ||
|     
 | ||
|     if (this.options.quiet) return;
 | ||
|     
 | ||
|     this.log(this.format(`\n⏭️  ${filename} (${index}/${total})`, 'yellow'));
 | ||
|     this.log(this.format(`   Skipped: ${reason}`, 'dim'));
 | ||
|   }
 | ||
|   
 | ||
|   // Browser console
 | ||
|   browserConsole(message: string, level: string = 'log') {
 | ||
|     if (this.options.json) {
 | ||
|       this.logJson({ event: 'browserConsole', message, level });
 | ||
|       return;
 | ||
|     }
 | ||
|     
 | ||
|     if (!this.options.quiet) {
 | ||
|       const prefix = level === 'error' ? '🌐❌' : '🌐';
 | ||
|       const color = level === 'error' ? 'red' : 'magenta';
 | ||
|       this.log(this.format(`   ${prefix} ${message}`, color));
 | ||
|     }
 | ||
|   }
 | ||
|   
 | ||
|   // Test error details display
 | ||
|   testErrorDetails(errorMessage: string) {
 | ||
|     if (this.options.json) {
 | ||
|       this.logJson({ event: 'testError', error: errorMessage });
 | ||
|       return;
 | ||
|     }
 | ||
|     
 | ||
|     if (!this.options.quiet) {
 | ||
|       this.log(this.format('      Error details:', 'red'));
 | ||
|       errorMessage.split('\n').forEach(line => {
 | ||
|         this.log(this.format(`        ${line}`, 'red'));
 | ||
|       });
 | ||
|     }
 | ||
|     
 | ||
|     // Always log to test file if --logfile is specified
 | ||
|     if (this.currentTestLogFile) {
 | ||
|       this.logToTestFile(`   Error: ${errorMessage}`);
 | ||
|     }
 | ||
|   }
 | ||
|   
 | ||
|   // Final summary
 | ||
|   summary(skippedFiles: string[] = []) {
 | ||
|     const totalDuration = Date.now() - this.startTime;
 | ||
|     const summary: TestSummary = {
 | ||
|       totalFiles: this.fileResults.length + skippedFiles.length,
 | ||
|       totalTests: this.fileResults.reduce((sum, r) => sum + r.total, 0),
 | ||
|       totalPassed: this.fileResults.reduce((sum, r) => sum + r.passed, 0),
 | ||
|       totalFailed: this.fileResults.reduce((sum, r) => sum + r.failed, 0),
 | ||
|       totalSkipped: skippedFiles.length,
 | ||
|       totalDuration,
 | ||
|       fileResults: this.fileResults,
 | ||
|       skippedFiles
 | ||
|     };
 | ||
|     
 | ||
|     if (this.options.json) {
 | ||
|       this.logJson({ event: 'summary', summary });
 | ||
|       return;
 | ||
|     }
 | ||
|     
 | ||
|     if (this.options.quiet) {
 | ||
|       const status = summary.totalFailed === 0 ? 'PASSED' : 'FAILED';
 | ||
|       const durationStr = totalDuration >= 1000 ? `${(totalDuration / 1000).toFixed(1)}s` : `${totalDuration}ms`;
 | ||
|       
 | ||
|       if (summary.totalFailed === 0) {
 | ||
|         this.log(`\nSummary: ${summary.totalPassed}/${summary.totalTests} | ${durationStr} | ${status}`);
 | ||
|       } else {
 | ||
|         this.log(`\nSummary: ${summary.totalPassed} passed, ${summary.totalFailed} failed of ${summary.totalTests} tests | ${durationStr} | ${status}`);
 | ||
|       }
 | ||
|       return;
 | ||
|     }
 | ||
|     
 | ||
|     // Detailed summary
 | ||
|     this.log(this.format('\n📊 Test Summary', 'bold'));
 | ||
|     this.log(this.format('┌────────────────────────────────┐', 'dim'));
 | ||
|     this.log(this.format(`│ Total Files:    ${summary.totalFiles.toString().padStart(14)} │`, 'white'));
 | ||
|     this.log(this.format(`│ Total Tests:    ${summary.totalTests.toString().padStart(14)} │`, 'white'));
 | ||
|     this.log(this.format(`│ Passed:         ${summary.totalPassed.toString().padStart(14)} │`, 'green'));
 | ||
|     this.log(this.format(`│ Failed:         ${summary.totalFailed.toString().padStart(14)} │`, summary.totalFailed > 0 ? 'red' : 'green'));
 | ||
|     if (summary.totalSkipped > 0) {
 | ||
|       this.log(this.format(`│ Skipped:        ${summary.totalSkipped.toString().padStart(14)} │`, 'yellow'));
 | ||
|     }
 | ||
|     const durationStrFormatted = totalDuration >= 1000 ? `${(totalDuration / 1000).toFixed(1)}s` : `${totalDuration}ms`;
 | ||
|     this.log(this.format(`│ Duration:       ${durationStrFormatted.padStart(14)} │`, 'white'));
 | ||
|     this.log(this.format('└────────────────────────────────┘', 'dim'));
 | ||
|     
 | ||
|     // File results
 | ||
|     if (summary.totalFailed > 0) {
 | ||
|       this.log(this.format('\n❌ Failed Tests:', 'red'));
 | ||
|       this.fileResults.forEach(fileResult => {
 | ||
|         if (fileResult.failed > 0) {
 | ||
|           this.log(this.format(`\n   ${fileResult.file}`, 'yellow'));
 | ||
|           fileResult.tests.filter(t => !t.passed).forEach(test => {
 | ||
|             this.log(this.format(`      ❌ ${test.name}`, 'red'));
 | ||
|             if (test.error) {
 | ||
|               this.log(this.format(`         ${test.error}`, 'dim'));
 | ||
|             }
 | ||
|           });
 | ||
|         }
 | ||
|       });
 | ||
|     }
 | ||
|     
 | ||
|     // Performance metrics
 | ||
|     if (this.options.verbose) {
 | ||
|       // Calculate metrics based on actual test durations
 | ||
|       const allTests = this.fileResults.flatMap(r => r.tests);
 | ||
|       const testDurations = allTests.map(t => t.duration);
 | ||
|       const sumOfTestDurations = testDurations.reduce((sum, d) => sum + d, 0);
 | ||
|       const avgTestDuration = allTests.length > 0 ? Math.round(sumOfTestDurations / allTests.length) : 0;
 | ||
|       
 | ||
|       // Find slowest test (exclude 0ms durations unless all are 0)
 | ||
|       const nonZeroDurations = allTests.filter(t => t.duration > 0);
 | ||
|       const testsToSort = nonZeroDurations.length > 0 ? nonZeroDurations : allTests;
 | ||
|       const slowestTest = testsToSort.sort((a, b) => b.duration - a.duration)[0];
 | ||
|       
 | ||
|       this.log(this.format('\n⏱️  Performance Metrics:', 'cyan'));
 | ||
|       this.log(this.format(`   Average per test: ${avgTestDuration}ms`, 'white'));
 | ||
|       if (slowestTest && slowestTest.duration > 0) {
 | ||
|         this.log(this.format(`   Slowest test: ${slowestTest.name} (${slowestTest.duration}ms)`, 'orange'));
 | ||
|       } else if (allTests.length > 0) {
 | ||
|         this.log(this.format(`   All tests completed in <1ms`, 'dim'));
 | ||
|       }
 | ||
|     }
 | ||
|     
 | ||
|     // Final status
 | ||
|     const status = summary.totalFailed === 0 ? 'ALL TESTS PASSED! 🎉' : 'SOME TESTS FAILED! ❌';
 | ||
|     const statusColor = summary.totalFailed === 0 ? 'green' : 'red';
 | ||
|     this.log(this.format(`\n${status}`, statusColor));
 | ||
|   }
 | ||
|   
 | ||
|   // Warning display
 | ||
|   warning(message: string) {
 | ||
|     if (this.options.json) {
 | ||
|       this.logJson({ event: 'warning', message });
 | ||
|       return;
 | ||
|     }
 | ||
|     
 | ||
|     if (this.options.quiet) {
 | ||
|       console.log(`WARNING: ${message}`);
 | ||
|     } else {
 | ||
|       this.log(this.format(`   ⚠️  ${message}`, 'orange'));
 | ||
|     }
 | ||
|   }
 | ||
|   
 | ||
|   // Error display
 | ||
|   error(message: string, file?: string, stack?: string) {
 | ||
|     if (this.options.json) {
 | ||
|       this.logJson({ event: 'error', message, file, stack });
 | ||
|       return;
 | ||
|     }
 | ||
|     
 | ||
|     if (this.options.quiet) {
 | ||
|       console.error(`ERROR: ${message}`);
 | ||
|     } else {
 | ||
|       this.log(this.format('\n⚠️  Error', 'red'));
 | ||
|       if (file) this.log(this.format(`   File: ${file}`, 'yellow'));
 | ||
|       this.log(this.format(`   ${message}`, 'red'));
 | ||
|       if (stack && this.options.verbose) {
 | ||
|         this.log(this.format(`   Stack:`, 'dim'));
 | ||
|         this.log(this.format(stack.split('\n').map(line => `     ${line}`).join('\n'), 'dim'));
 | ||
|       }
 | ||
|     }
 | ||
|   }
 | ||
|   
 | ||
|   // Create a diff between two log contents
 | ||
|   private createDiff(previousContent: string, currentContent: string, filename: string): string {
 | ||
|     const previousLines = previousContent.split('\n');
 | ||
|     const currentLines = currentContent.split('\n');
 | ||
|     
 | ||
|     let diff = `DIFF REPORT: ${filename}\n`;
 | ||
|     diff += `Generated: ${new Date().toISOString()}\n`;
 | ||
|     diff += '='.repeat(80) + '\n\n';
 | ||
|     
 | ||
|     // Simple line-by-line comparison
 | ||
|     const maxLines = Math.max(previousLines.length, currentLines.length);
 | ||
|     let hasChanges = false;
 | ||
|     
 | ||
|     for (let i = 0; i < maxLines; i++) {
 | ||
|       const prevLine = previousLines[i] || '';
 | ||
|       const currLine = currentLines[i] || '';
 | ||
|       
 | ||
|       if (prevLine !== currLine) {
 | ||
|         hasChanges = true;
 | ||
|         if (i < previousLines.length && i >= currentLines.length) {
 | ||
|           // Line was removed
 | ||
|           diff += `- [Line ${i + 1}] ${prevLine}\n`;
 | ||
|         } else if (i >= previousLines.length && i < currentLines.length) {
 | ||
|           // Line was added
 | ||
|           diff += `+ [Line ${i + 1}] ${currLine}\n`;
 | ||
|         } else {
 | ||
|           // Line was modified
 | ||
|           diff += `- [Line ${i + 1}] ${prevLine}\n`;
 | ||
|           diff += `+ [Line ${i + 1}] ${currLine}\n`;
 | ||
|         }
 | ||
|       }
 | ||
|     }
 | ||
|     
 | ||
|     if (!hasChanges) {
 | ||
|       diff += 'No changes detected.\n';
 | ||
|     }
 | ||
|     
 | ||
|     diff += '\n' + '='.repeat(80) + '\n';
 | ||
|     diff += `Previous version had ${previousLines.length} lines\n`;
 | ||
|     diff += `Current version has ${currentLines.length} lines\n`;
 | ||
|     
 | ||
|     return diff;
 | ||
|   }
 | ||
|   
 | ||
|   // Watch mode methods
 | ||
|   watchModeStart() {
 | ||
|     if (this.options.json) {
 | ||
|       this.logJson({ event: 'watchModeStart' });
 | ||
|       return;
 | ||
|     }
 | ||
|     
 | ||
|     this.log(this.format('\n👀 Watch Mode', 'cyan'));
 | ||
|     this.log(this.format('   Running tests in watch mode...', 'dim'));
 | ||
|     this.log(this.format('   Press Ctrl+C to exit\n', 'dim'));
 | ||
|   }
 | ||
|   
 | ||
|   watchModeWaiting() {
 | ||
|     if (this.options.json) {
 | ||
|       this.logJson({ event: 'watchModeWaiting' });
 | ||
|       return;
 | ||
|     }
 | ||
|     
 | ||
|     this.log(this.format('\n   Waiting for file changes...', 'dim'));
 | ||
|   }
 | ||
|   
 | ||
|   watchModeRerun(changedFiles: string[]) {
 | ||
|     if (this.options.json) {
 | ||
|       this.logJson({ event: 'watchModeRerun', changedFiles });
 | ||
|       return;
 | ||
|     }
 | ||
|     
 | ||
|     this.log(this.format('\n🔄 File changes detected:', 'cyan'));
 | ||
|     changedFiles.forEach(file => {
 | ||
|       this.log(this.format(`   • ${file}`, 'yellow'));
 | ||
|     });
 | ||
|     this.log(this.format('\n   Re-running tests...\n', 'dim'));
 | ||
|   }
 | ||
|   
 | ||
|   watchModeStop() {
 | ||
|     if (this.options.json) {
 | ||
|       this.logJson({ event: 'watchModeStop' });
 | ||
|       return;
 | ||
|     }
 | ||
|     
 | ||
|     this.log(this.format('\n\n👋 Stopping watch mode...', 'cyan'));
 | ||
|   }
 | ||
| } |