407 lines
13 KiB
TypeScript
407 lines
13 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;
|
||
totalDuration: number;
|
||
fileResults: TestFileResult[];
|
||
}
|
||
|
||
export class TsTestLogger {
|
||
private 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));
|
||
}
|
||
|
||
// 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}`);
|
||
}
|
||
}
|
||
|
||
// 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() {
|
||
const totalDuration = Date.now() - this.startTime;
|
||
const summary: TestSummary = {
|
||
totalFiles: this.fileResults.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),
|
||
totalDuration,
|
||
fileResults: this.fileResults
|
||
};
|
||
|
||
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'));
|
||
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) {
|
||
const avgDuration = Math.round(totalDuration / summary.totalTests);
|
||
const slowestTest = this.fileResults
|
||
.flatMap(r => r.tests)
|
||
.sort((a, b) => b.duration - a.duration)[0];
|
||
|
||
this.log(this.format('\n⏱️ Performance Metrics:', 'cyan'));
|
||
this.log(this.format(` Average per test: ${avgDuration}ms`, 'white'));
|
||
if (slowestTest) {
|
||
this.log(this.format(` Slowest test: ${slowestTest.name} (${slowestTest.duration}ms)`, 'yellow'));
|
||
}
|
||
}
|
||
|
||
// 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));
|
||
}
|
||
|
||
// 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'));
|
||
}
|
||
}
|
||
}
|
||
} |