580 lines
19 KiB
TypeScript
580 lines
19 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'));
|
||
}
|
||
}
|
||
|
||
// 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;
|
||
if (failed === 0) {
|
||
this.log(this.format(` Summary: ${passed}/${total} PASSED`, 'green'));
|
||
} else {
|
||
this.log(this.format(` Summary: ${passed} passed, ${failed} failed of ${total} tests`, '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';
|
||
if (summary.totalFailed === 0) {
|
||
this.log(`\nSummary: ${summary.totalPassed}/${summary.totalTests} | ${totalDuration}ms | ${status}`);
|
||
} else {
|
||
this.log(`\nSummary: ${summary.totalPassed} passed, ${summary.totalFailed} failed of ${summary.totalTests} tests | ${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'));
|
||
}
|
||
} |