tstest/ts/tstest.logging.ts

358 lines
11 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
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: []
};
// Only set up test log file if --logfile option is specified
if (this.options.logFile) {
const baseFilename = path.basename(filename, '.ts');
this.currentTestLogFile = path.join('.nogit', 'testlogs', `${baseFilename}.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.currentFileResult.duration += duration;
}
if (this.options.json) {
this.logJson({ event: 'testResult', testName, passed, duration, error });
return;
}
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'));
}
}
}
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;
// Show console output from test files only in verbose mode
if (this.options.verbose) {
this.log(this.format(` ${message}`, 'dim'));
}
// 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));
}
}
// 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'));
}
}
}
}