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')); } } // 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 status = failed === 0 ? 'PASSED' : 'FAILED'; const color = failed === 0 ? 'green' : 'red'; this.log(this.format(` Summary: ${passed}/${total} ${status}`, color)); } // 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'; this.log(`\nSummary: ${summary.totalPassed}/${summary.totalTests} | ${totalDuration}ms | ${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')); } this.log(this.format(`│ Duration: ${totalDuration.toString().padStart(14)}ms │`, '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')); } }