feat(logging): Improve logging output, CLI option parsing, and test report formatting.

This commit is contained in:
2025-05-15 16:39:46 +00:00
parent 3fc4cee2b1
commit 78ffad2f7d
9 changed files with 644 additions and 168 deletions

View File

@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@git.zone/tstest',
version: '1.1.0',
version: '1.2.0',
description: 'a test utility to run tests that match test/**/*.ts'
}

View File

@ -1,4 +1,5 @@
import { TsTest } from './tstest.classes.tstest.js';
import type { LogOptions } from './tstest.logging.js';
export enum TestExecutionMode {
DIRECTORY = 'directory',
@ -7,12 +8,54 @@ export enum TestExecutionMode {
}
export const runCli = async () => {
if (!process.argv[2]) {
// Parse command line arguments
const args = process.argv.slice(2);
const logOptions: LogOptions = {};
let testPath: string | null = null;
// Parse options
for (let i = 0; i < args.length; i++) {
const arg = args[i];
switch (arg) {
case '--quiet':
case '-q':
logOptions.quiet = true;
break;
case '--verbose':
case '-v':
logOptions.verbose = true;
break;
case '--no-color':
logOptions.noColor = true;
break;
case '--json':
logOptions.json = true;
break;
case '--log-file':
if (i + 1 < args.length) {
logOptions.logFile = args[++i];
}
break;
default:
if (!arg.startsWith('-')) {
testPath = arg;
}
}
}
if (!testPath) {
console.error('You must specify a test directory/file/pattern as argument. Please try again.');
console.error('\nUsage: tstest <path> [options]');
console.error('\nOptions:');
console.error(' --quiet, -q Minimal output');
console.error(' --verbose, -v Verbose output');
console.error(' --no-color Disable colored output');
console.error(' --json Output results as JSON');
console.error(' --log-file Write logs to file');
process.exit(1);
}
const testPath = process.argv[2];
let executionMode: TestExecutionMode;
// Detect execution mode based on the argument
@ -24,6 +67,6 @@ export const runCli = async () => {
executionMode = TestExecutionMode.DIRECTORY;
}
const tsTestInstance = new TsTest(process.cwd(), testPath, executionMode);
const tsTestInstance = new TsTest(process.cwd(), testPath, executionMode, logOptions);
await tsTestInstance.run();
};

View File

@ -6,59 +6,37 @@ import { coloredString as cs } from '@push.rocks/consolecolor';
import { TapParser } from './tstest.classes.tap.parser.js';
import * as logPrefixes from './tstest.logprefixes.js';
import { TsTestLogger } from './tstest.logging.js';
export class TapCombinator {
tapParserStore: TapParser[] = [];
private logger: TsTestLogger;
constructor(logger: TsTestLogger) {
this.logger = logger;
}
addTapParser(tapParserArg: TapParser) {
this.tapParserStore.push(tapParserArg);
}
evaluate() {
console.log(
`${logPrefixes.TsTestPrefix} RESULTS FOR ${this.tapParserStore.length} TESTFILE(S):`
);
let failGlobal = false; // determine wether tstest should fail
// Call the logger's summary method
this.logger.summary();
// Check for failures
let failGlobal = false;
for (const tapParser of this.tapParserStore) {
if (!tapParser.expectedTests) {
if (!tapParser.expectedTests ||
tapParser.expectedTests !== tapParser.receivedTests ||
tapParser.getErrorTests().length > 0) {
failGlobal = true;
let overviewString =
logPrefixes.TsTestPrefix +
cs(` ${tapParser.fileName} ${plugins.figures.cross}`, 'red') +
` ${plugins.figures.pointer} ` +
`does not specify tests!`;
console.log(overviewString);
} else if (tapParser.expectedTests !== tapParser.receivedTests) {
failGlobal = true;
let overviewString =
logPrefixes.TsTestPrefix +
cs(` ${tapParser.fileName} ${plugins.figures.cross}`, 'red') +
` ${plugins.figures.pointer} ` +
tapParser.getTestOverviewAsString() +
`did not execute all specified tests!`;
console.log(overviewString);
} else if (tapParser.getErrorTests().length === 0) {
let overviewString =
logPrefixes.TsTestPrefix +
cs(` ${tapParser.fileName} ${plugins.figures.tick}`, 'green') +
` ${plugins.figures.pointer} ` +
tapParser.getTestOverviewAsString();
console.log(overviewString);
} else {
failGlobal = true;
let overviewString =
logPrefixes.TsTestPrefix +
cs(` ${tapParser.fileName} ${plugins.figures.cross}`, 'red') +
` ${plugins.figures.pointer} ` +
tapParser.getTestOverviewAsString();
console.log(overviewString);
break;
}
}
console.log(cs(plugins.figures.hamburger.repeat(48), 'cyan'));
if (!failGlobal) {
console.log(cs('FINAL RESULT: SUCCESS!', 'green'));
} else {
console.log(cs('FINAL RESULT: FAIL!', 'red'));
// Exit with error code if tests failed
if (failGlobal) {
process.exit(1);
}
}

View File

@ -7,6 +7,7 @@ import { coloredString as cs } from '@push.rocks/consolecolor';
import * as plugins from './tstest.plugins.js';
import { TapTestResult } from './tstest.classes.tap.testresult.js';
import * as logPrefixes from './tstest.logprefixes.js';
import { TsTestLogger } from './tstest.logging.js';
export class TapParser {
testStore: TapTestResult[] = [];
@ -19,11 +20,15 @@ export class TapParser {
activeTapTestResult: TapTestResult;
pretaskRegex = /^::__PRETASK:(.*)$/;
private logger: TsTestLogger;
/**
* the constructor for TapParser
*/
constructor(public fileName: string) {}
constructor(public fileName: string, logger?: TsTestLogger) {
this.logger = logger;
}
private _getNewTapTestResult() {
this.activeTapTestResult = new TapTestResult(this.testStore.length + 1);
@ -45,9 +50,9 @@ export class TapParser {
logLineIsTapProtocol = true;
const regexResult = this.expectedTestsRegex.exec(logLine);
this.expectedTests = parseInt(regexResult[2]);
console.log(
`${logPrefixes.TapPrefix} ${cs(`Expecting ${this.expectedTests} tests!`, 'blue')}`
);
if (this.logger) {
this.logger.tapOutput(`Expecting ${this.expectedTests} tests!`);
}
// initiating first TapResult
this._getNewTapTestResult();
@ -55,7 +60,9 @@ export class TapParser {
logLineIsTapProtocol = true;
const pretaskContentMatch = this.pretaskRegex.exec(logLine);
if (pretaskContentMatch && pretaskContentMatch[1]) {
console.log(`${logPrefixes.TapPretaskPrefix} Pretask ->${pretaskContentMatch[1]}: Success.`);
if (this.logger) {
this.logger.tapOutput(`Pretask -> ${pretaskContentMatch[1]}: Success.`);
}
}
} else if (this.testStatusRegex.test(logLine)) {
logLineIsTapProtocol = true;
@ -73,26 +80,20 @@ export class TapParser {
// test for protocol error
if (testId !== this.activeTapTestResult.id) {
console.log(
`${logPrefixes.TapErrorPrefix} Something is strange! Test Ids are not equal!`
);
if (this.logger) {
this.logger.error('Something is strange! Test Ids are not equal!');
}
}
this.activeTapTestResult.setTestResult(testOk);
if (testOk) {
console.log(
logPrefixes.TapPrefix,
`${cs(`T${testId} ${plugins.figures.tick}`, 'green')} ${plugins.figures.arrowRight} ` +
cs(testSubject, 'blue') +
` | ${cs(`${testDuration} ms`, 'orange')}`
);
if (this.logger) {
this.logger.testResult(testSubject, true, testDuration);
}
} else {
console.log(
logPrefixes.TapPrefix,
`${cs(`T${testId} ${plugins.figures.cross}`, 'red')} ${plugins.figures.arrowRight} ` +
cs(testSubject, 'blue') +
` | ${cs(`${testDuration} ms`, 'orange')}`
);
if (this.logger) {
this.logger.testResult(testSubject, false, testDuration);
}
}
}
@ -100,7 +101,9 @@ export class TapParser {
if (this.activeTapTestResult) {
this.activeTapTestResult.addLogLine(logLine);
}
console.log(logLine);
if (this.logger) {
this.logger.tapOutput(logLine);
}
}
if (this.activeTapTestResult && this.activeTapTestResult.testSettled) {
@ -172,38 +175,32 @@ export class TapParser {
// check wether all tests ran
if (this.expectedTests === this.receivedTests) {
console.log(
`${logPrefixes.TapPrefix} ${cs(
`${this.receivedTests} out of ${this.expectedTests} Tests completed!`,
'green'
)}`
);
if (this.logger) {
this.logger.tapOutput(`${this.receivedTests} out of ${this.expectedTests} Tests completed!`);
}
} else {
console.log(
`${logPrefixes.TapErrorPrefix} ${cs(
`Only ${this.receivedTests} out of ${this.expectedTests} completed!`,
'red'
)}`
);
if (this.logger) {
this.logger.error(`Only ${this.receivedTests} out of ${this.expectedTests} completed!`);
}
}
if (!this.expectedTests) {
console.log(cs('Error: No tests were defined. Therefore the testfile failed!', 'red'));
if (this.logger) {
this.logger.error('No tests were defined. Therefore the testfile failed!');
}
} else if (this.expectedTests !== this.receivedTests) {
console.log(
cs(
'Error: The amount of received tests and expectedTests is unequal! Therefore the testfile failed',
'red'
)
);
if (this.logger) {
this.logger.error('The amount of received tests and expectedTests is unequal! Therefore the testfile failed');
}
} else if (this.getErrorTests().length === 0) {
console.log(`${logPrefixes.TapPrefix} ${cs(`All tests are successfull!!!`, 'green')}`);
if (this.logger) {
this.logger.tapOutput('All tests are successfull!!!');
this.logger.testFileEnd(this.receivedTests, 0, 0);
}
} else {
console.log(
`${logPrefixes.TapPrefix} ${cs(
`${this.getErrorTests().length} tests threw an error!!!`,
'red'
)}`
);
if (this.logger) {
this.logger.tapOutput(`${this.getErrorTests().length} tests threw an error!!!`, true);
this.logger.testFileEnd(this.receivedTests - this.getErrorTests().length, this.getErrorTests().length, 0);
}
}
}
}

View File

@ -8,10 +8,13 @@ import { TestDirectory } from './tstest.classes.testdirectory.js';
import { TapCombinator } from './tstest.classes.tap.combinator.js';
import { TapParser } from './tstest.classes.tap.parser.js';
import { TestExecutionMode } from './index.js';
import { TsTestLogger } from './tstest.logging.js';
import type { LogOptions } from './tstest.logging.js';
export class TsTest {
public testDir: TestDirectory;
public executionMode: TestExecutionMode;
public logger: TsTestLogger;
public smartshellInstance = new plugins.smartshell.Smartshell({
executor: 'bash',
@ -22,62 +25,57 @@ export class TsTest {
public tsbundleInstance = new plugins.tsbundle.TsBundle();
constructor(cwdArg: string, testPathArg: string, executionModeArg: TestExecutionMode) {
constructor(cwdArg: string, testPathArg: string, executionModeArg: TestExecutionMode, logOptions: LogOptions = {}) {
this.executionMode = executionModeArg;
this.testDir = new TestDirectory(cwdArg, testPathArg, executionModeArg);
this.logger = new TsTestLogger(logOptions);
}
async run() {
const fileNamesToRun: string[] = await this.testDir.getTestFilePathArray();
console.log(cs(plugins.figures.hamburger.repeat(80), 'cyan'));
console.log('');
console.log(`${logPrefixes.TsTestPrefix} FOUND ${fileNamesToRun.length} TESTFILE(S):`);
for (const fileName of fileNamesToRun) {
console.log(`${logPrefixes.TsTestPrefix} ${cs(fileName, 'orange')}`);
}
console.log('-'.repeat(48));
console.log(''); // force new line
// Log test discovery
this.logger.testDiscovery(
fileNamesToRun.length,
this.testDir.testPath,
this.executionMode
);
const tapCombinator = new TapCombinator(); // lets create the TapCombinator
const tapCombinator = new TapCombinator(this.logger); // lets create the TapCombinator
let fileIndex = 0;
for (const fileNameArg of fileNamesToRun) {
fileIndex++;
switch (true) {
case process.env.CI && fileNameArg.includes('.nonci.'):
console.log('!!!!!!!!!!!');
console.log(
`not running testfile ${fileNameArg}, since we are CI and file name includes '.nonci.' tag`
);
console.log('!!!!!!!!!!!');
this.logger.tapOutput(`Skipping ${fileNameArg} - marked as non-CI`);
break;
case fileNameArg.endsWith('.browser.ts') || fileNameArg.endsWith('.browser.nonci.ts'):
const tapParserBrowser = await this.runInChrome(fileNameArg);
const tapParserBrowser = await this.runInChrome(fileNameArg, fileIndex, fileNamesToRun.length);
tapCombinator.addTapParser(tapParserBrowser);
break;
case fileNameArg.endsWith('.both.ts') || fileNameArg.endsWith('.both.nonci.ts'):
console.log('>>>>>>> TEST PART 1: chrome');
const tapParserBothBrowser = await this.runInChrome(fileNameArg);
this.logger.sectionStart('Part 1: Chrome');
const tapParserBothBrowser = await this.runInChrome(fileNameArg, fileIndex, fileNamesToRun.length);
tapCombinator.addTapParser(tapParserBothBrowser);
console.log(cs(`|`.repeat(16), 'cyan'));
console.log(''); // force new line
console.log('>>>>>>> TEST PART 2: node');
const tapParserBothNode = await this.runInNode(fileNameArg);
this.logger.sectionEnd();
this.logger.sectionStart('Part 2: Node');
const tapParserBothNode = await this.runInNode(fileNameArg, fileIndex, fileNamesToRun.length);
tapCombinator.addTapParser(tapParserBothNode);
this.logger.sectionEnd();
break;
default:
const tapParserNode = await this.runInNode(fileNameArg);
const tapParserNode = await this.runInNode(fileNameArg, fileIndex, fileNamesToRun.length);
tapCombinator.addTapParser(tapParserNode);
break;
}
console.log(cs(`^`.repeat(16), 'cyan'));
console.log(''); // force new line
}
tapCombinator.evaluate();
}
public async runInNode(fileNameArg: string): Promise<TapParser> {
console.log(`${cs('=> ', 'blue')} Running ${cs(fileNameArg, 'orange')} in node.js runtime.`);
console.log(`${cs(`= `.repeat(32), 'cyan')}`);
const tapParser = new TapParser(fileNameArg + ':node');
public async runInNode(fileNameArg: string, index: number, total: number): Promise<TapParser> {
this.logger.testFileStart(fileNameArg, 'node.js', index, total);
const tapParser = new TapParser(fileNameArg + ':node', this.logger);
// tsrun options
let tsrunOptions = '';
@ -92,9 +90,8 @@ export class TsTest {
return tapParser;
}
public async runInChrome(fileNameArg: string): Promise<TapParser> {
console.log(`${cs('=> ', 'blue')} Running ${cs(fileNameArg, 'orange')} in chromium runtime.`);
console.log(`${cs(`= `.repeat(32), 'cyan')}`);
public async runInChrome(fileNameArg: string, index: number, total: number): Promise<TapParser> {
this.logger.testFileStart(fileNameArg, 'chromium', index, total);
// lets get all our paths sorted
const tsbundleCacheDirPath = plugins.path.join(paths.cwd, './.nogit/tstest_cache');
@ -133,11 +130,17 @@ export class TsTest {
await server.start();
// lets handle realtime comms
const tapParser = new TapParser(fileNameArg + ':chrome');
const tapParser = new TapParser(fileNameArg + ':chrome', this.logger);
const wss = new plugins.ws.WebSocketServer({ port: 8080 });
wss.on('connection', (ws) => {
ws.on('message', (message) => {
tapParser.handleTapLog(message.toString());
const messageStr = message.toString();
if (messageStr.startsWith('console:')) {
const [, level, ...messageParts] = messageStr.split(':');
this.logger.browserConsole(messageParts.join(':'), level);
} else {
tapParser.handleTapLog(messageStr);
}
});
});

285
ts/tstest.logging.ts Normal file
View File

@ -0,0 +1,285 @@
import { coloredString as cs } from '@push.rocks/consolecolor';
import * as plugins from './tstest.plugins.js';
export interface LogOptions {
quiet?: boolean;
verbose?: boolean;
noColor?: boolean;
json?: boolean;
logFile?: string;
}
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;
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) return;
console.log(message);
if (this.options.logFile) {
// TODO: Implement file logging
}
}
// 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) {
console.log(JSON.stringify({ 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: []
};
if (this.options.json) {
console.log(JSON.stringify({ 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) {
console.log(JSON.stringify({ 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) {
console.log(JSON.stringify({ 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));
}
}
// TAP output forwarding
tapOutput(message: string, isError: boolean = false) {
if (this.options.json) return;
if (this.options.verbose || isError) {
const prefix = isError ? ' ⚠️ ' : ' ';
const color = isError ? 'red' : 'dim';
this.log(this.format(`${prefix}${message}`, color));
}
}
// Browser console
browserConsole(message: string, level: string = 'log') {
if (this.options.json) {
console.log(JSON.stringify({ 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) {
console.log(JSON.stringify({ 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) {
console.log(JSON.stringify({ 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'));
}
}
}
}