Compare commits

...

4 Commits

12 changed files with 571 additions and 179 deletions

View File

@ -1,5 +1,24 @@
# Changelog # Changelog
## 2025-05-15 - 1.3.0 - feat(logger)
Improve logging output and add --logfile support for persistent logs
- Add new .claude/settings.local.json with logging permissions configuration
- Remove obsolete readme.plan.md
- Introduce test/test.console.ts to capture and display console outputs during tests
- Update CLI in ts/index.ts to replace '--log-file' with '--logfile' flag
- Enhance TsTestLogger to support file logging, clean ANSI sequences, and improved JSON output
- Forward TAP protocol logs to testConsoleOutput in TapParser for better console distinction
## 2025-05-15 - 1.2.0 - feat(logging)
Improve logging output, CLI option parsing, and test report formatting.
- Added a centralized TsTestLogger with support for multiple verbosity levels, JSON output, and file logging (TODO).
- Integrated new logger into CLI parsing, TapParser, TapCombinator, and TsTest classes to ensure consistent and structured output.
- Introduced new CLI options (--quiet, --verbose, --no-color, --json, --log-file) for enhanced user control.
- Enhanced visual design with progress indicators, detailed error aggregation, and performance summaries.
- Updated documentation and logging code to align with improved CI/CD behavior, including skipping non-CI tests.
## 2025-05-15 - 1.1.0 - feat(cli) ## 2025-05-15 - 1.1.0 - feat(cli)
Enhance test discovery with support for single file and glob pattern execution using improved CLI argument detection Enhance test discovery with support for single file and glob pattern execution using improved CLI argument detection

View File

@ -1,6 +1,6 @@
{ {
"name": "@git.zone/tstest", "name": "@git.zone/tstest",
"version": "1.1.0", "version": "1.3.0",
"private": false, "private": false,
"description": "a test utility to run tests that match test/**/*.ts", "description": "a test utility to run tests that match test/**/*.ts",
"main": "dist_ts/index.js", "main": "dist_ts/index.js",

View File

@ -1,51 +0,0 @@
# Plan for adding single file and glob pattern execution support to tstest
!! FIRST: Reread /home/philkunz/.claude/CLAUDE.md to ensure following all guidelines !!
## Goal - ✅ COMPLETED
- ✅ Make `tstest test/test.abc.ts` run the specified file directly
- ✅ Support glob patterns like `tstest test/*.spec.ts` or `tstest test/**/*.test.ts`
- ✅ Maintain backward compatibility with directory argument
## Current behavior - UPDATED
- ✅ tstest now supports three modes: directory, single file, and glob patterns
- ✅ Directory mode now searches recursively using `**/test*.ts` pattern
- ✅ Single file mode runs a specific test file
- ✅ Glob mode runs files matching the pattern
## Completed changes
### 1. ✅ Update cli argument handling in index.ts
- ✅ Detect argument type: file path, glob pattern, or directory
- ✅ Check if argument contains glob characters (*, **, ?, [], etc.)
- ✅ Pass appropriate mode to TsTest constructor
- ✅ Added TestExecutionMode enum
### 2. ✅ Modify TsTest constructor and class
- ✅ Add support for three modes: directory, file, glob
- ✅ Update constructor to accept pattern/path and mode
- ✅ Added executionMode property to track the mode
### 3. ✅ Update TestDirectory class
- ✅ Used `listFileTree` for glob pattern support
- ✅ Used `SmartFile.fromFilePath` for single file loading
- ✅ Refactored to support all three modes in `_init` method
- ✅ Return appropriate file array based on mode
- ✅ Changed default directory behavior to recursive search
- ✅ When directory argument: use `**/test*.ts` pattern for recursive search
- ✅ This ensures subdirectories are included in test discovery
### 4. ✅ Test the implementation
- ✅ Created test file `test/test.single.ts` for single file functionality
- ✅ Created test file `test/test.glob.ts` for glob pattern functionality
- ✅ Created test in subdirectory `test/subdir/test.sub.ts` for recursive search
- ✅ Tested with existing test files for backward compatibility
- ✅ Tested glob patterns: `test/test.*.ts` works correctly
- ✅ Verified that default behavior now includes subdirectories
## Implementation completed
1. ✅ CLI argument type detection implemented
2. ✅ TsTest class supports all three modes
3. ✅ TestDirectory handles files, globs, and directories
4. ✅ Default pattern changed from `test*.ts` to `**/test*.ts` for recursive search
5. ✅ Comprehensive tests added and all modes verified

21
test-output.log Normal file
View File

@ -0,0 +1,21 @@
🔍 Test Discovery
Mode: file
Pattern: test/test.single.ts
Found: 1 test file(s)
▶️ test/test.single.ts (1/1)
Runtime: node.js
✅ single file test execution (1ms)
Summary: 1/1 PASSED
📊 Test Summary
┌────────────────────────────────┐
│ Total Files: 1 │
│ Total Tests: 1 │
│ Passed: 1 │
│ Failed: 0 │
│ Duration: 1230ms │
└────────────────────────────────┘
ALL TESTS PASSED! 🎉

11
test/test.console.ts Normal file
View File

@ -0,0 +1,11 @@
import { expect, tap } from '@push.rocks/tapbundle';
tap.test('Test with console output', async () => {
console.log('Log message 1 from test');
console.log('Log message 2 from test');
console.error('Error message from test');
console.warn('Warning message from test');
expect(true).toBeTrue();
});
tap.start();

13
test/test.fail.ts Normal file
View File

@ -0,0 +1,13 @@
import { expect, tap } from '@push.rocks/tapbundle';
tap.test('This test should fail', async () => {
console.log('This test will fail on purpose');
expect(true).toBeFalse();
});
tap.test('This test should pass', async () => {
console.log('This test will pass');
expect(true).toBeTrue();
});
tap.start();

View File

@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@git.zone/tstest', name: '@git.zone/tstest',
version: '1.1.0', version: '1.3.0',
description: 'a test utility to run tests that match test/**/*.ts' 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 { TsTest } from './tstest.classes.tstest.js';
import type { LogOptions } from './tstest.logging.js';
export enum TestExecutionMode { export enum TestExecutionMode {
DIRECTORY = 'directory', DIRECTORY = 'directory',
@ -7,12 +8,53 @@ export enum TestExecutionMode {
} }
export const runCli = async () => { 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':
case '--logfile':
logOptions.logFile = true; // Set this as a flag, not a value
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('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(' --logfile Write logs to .nogit/testlogs/[testfile].log');
process.exit(1); process.exit(1);
} }
const testPath = process.argv[2];
let executionMode: TestExecutionMode; let executionMode: TestExecutionMode;
// Detect execution mode based on the argument // Detect execution mode based on the argument
@ -24,6 +66,6 @@ export const runCli = async () => {
executionMode = TestExecutionMode.DIRECTORY; executionMode = TestExecutionMode.DIRECTORY;
} }
const tsTestInstance = new TsTest(process.cwd(), testPath, executionMode); const tsTestInstance = new TsTest(process.cwd(), testPath, executionMode, logOptions);
await tsTestInstance.run(); 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 { TapParser } from './tstest.classes.tap.parser.js';
import * as logPrefixes from './tstest.logprefixes.js'; import * as logPrefixes from './tstest.logprefixes.js';
import { TsTestLogger } from './tstest.logging.js';
export class TapCombinator { export class TapCombinator {
tapParserStore: TapParser[] = []; tapParserStore: TapParser[] = [];
private logger: TsTestLogger;
constructor(logger: TsTestLogger) {
this.logger = logger;
}
addTapParser(tapParserArg: TapParser) { addTapParser(tapParserArg: TapParser) {
this.tapParserStore.push(tapParserArg); this.tapParserStore.push(tapParserArg);
} }
evaluate() { evaluate() {
console.log( // Call the logger's summary method
`${logPrefixes.TsTestPrefix} RESULTS FOR ${this.tapParserStore.length} TESTFILE(S):` this.logger.summary();
);
// Check for failures
let failGlobal = false; // determine wether tstest should fail let failGlobal = false;
for (const tapParser of this.tapParserStore) { for (const tapParser of this.tapParserStore) {
if (!tapParser.expectedTests) { if (!tapParser.expectedTests ||
tapParser.expectedTests !== tapParser.receivedTests ||
tapParser.getErrorTests().length > 0) {
failGlobal = true; failGlobal = true;
let overviewString = break;
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);
} }
} }
console.log(cs(plugins.figures.hamburger.repeat(48), 'cyan'));
if (!failGlobal) { // Exit with error code if tests failed
console.log(cs('FINAL RESULT: SUCCESS!', 'green')); if (failGlobal) {
} else {
console.log(cs('FINAL RESULT: FAIL!', 'red'));
process.exit(1); 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 * as plugins from './tstest.plugins.js';
import { TapTestResult } from './tstest.classes.tap.testresult.js'; import { TapTestResult } from './tstest.classes.tap.testresult.js';
import * as logPrefixes from './tstest.logprefixes.js'; import * as logPrefixes from './tstest.logprefixes.js';
import { TsTestLogger } from './tstest.logging.js';
export class TapParser { export class TapParser {
testStore: TapTestResult[] = []; testStore: TapTestResult[] = [];
@ -19,11 +20,15 @@ export class TapParser {
activeTapTestResult: TapTestResult; activeTapTestResult: TapTestResult;
pretaskRegex = /^::__PRETASK:(.*)$/; pretaskRegex = /^::__PRETASK:(.*)$/;
private logger: TsTestLogger;
/** /**
* the constructor for TapParser * the constructor for TapParser
*/ */
constructor(public fileName: string) {} constructor(public fileName: string, logger?: TsTestLogger) {
this.logger = logger;
}
private _getNewTapTestResult() { private _getNewTapTestResult() {
this.activeTapTestResult = new TapTestResult(this.testStore.length + 1); this.activeTapTestResult = new TapTestResult(this.testStore.length + 1);
@ -45,9 +50,9 @@ export class TapParser {
logLineIsTapProtocol = true; logLineIsTapProtocol = true;
const regexResult = this.expectedTestsRegex.exec(logLine); const regexResult = this.expectedTestsRegex.exec(logLine);
this.expectedTests = parseInt(regexResult[2]); this.expectedTests = parseInt(regexResult[2]);
console.log( if (this.logger) {
`${logPrefixes.TapPrefix} ${cs(`Expecting ${this.expectedTests} tests!`, 'blue')}` this.logger.tapOutput(`Expecting ${this.expectedTests} tests!`);
); }
// initiating first TapResult // initiating first TapResult
this._getNewTapTestResult(); this._getNewTapTestResult();
@ -55,7 +60,9 @@ export class TapParser {
logLineIsTapProtocol = true; logLineIsTapProtocol = true;
const pretaskContentMatch = this.pretaskRegex.exec(logLine); const pretaskContentMatch = this.pretaskRegex.exec(logLine);
if (pretaskContentMatch && pretaskContentMatch[1]) { 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)) { } else if (this.testStatusRegex.test(logLine)) {
logLineIsTapProtocol = true; logLineIsTapProtocol = true;
@ -73,26 +80,20 @@ export class TapParser {
// test for protocol error // test for protocol error
if (testId !== this.activeTapTestResult.id) { if (testId !== this.activeTapTestResult.id) {
console.log( if (this.logger) {
`${logPrefixes.TapErrorPrefix} Something is strange! Test Ids are not equal!` this.logger.error('Something is strange! Test Ids are not equal!');
); }
} }
this.activeTapTestResult.setTestResult(testOk); this.activeTapTestResult.setTestResult(testOk);
if (testOk) { if (testOk) {
console.log( if (this.logger) {
logPrefixes.TapPrefix, this.logger.testResult(testSubject, true, testDuration);
`${cs(`T${testId} ${plugins.figures.tick}`, 'green')} ${plugins.figures.arrowRight} ` + }
cs(testSubject, 'blue') +
` | ${cs(`${testDuration} ms`, 'orange')}`
);
} else { } else {
console.log( if (this.logger) {
logPrefixes.TapPrefix, this.logger.testResult(testSubject, false, testDuration);
`${cs(`T${testId} ${plugins.figures.cross}`, 'red')} ${plugins.figures.arrowRight} ` + }
cs(testSubject, 'blue') +
` | ${cs(`${testDuration} ms`, 'orange')}`
);
} }
} }
@ -100,7 +101,10 @@ export class TapParser {
if (this.activeTapTestResult) { if (this.activeTapTestResult) {
this.activeTapTestResult.addLogLine(logLine); this.activeTapTestResult.addLogLine(logLine);
} }
console.log(logLine); if (this.logger) {
// This is console output from the test file, not TAP protocol
this.logger.testConsoleOutput(logLine);
}
} }
if (this.activeTapTestResult && this.activeTapTestResult.testSettled) { if (this.activeTapTestResult && this.activeTapTestResult.testSettled) {
@ -172,38 +176,32 @@ export class TapParser {
// check wether all tests ran // check wether all tests ran
if (this.expectedTests === this.receivedTests) { if (this.expectedTests === this.receivedTests) {
console.log( if (this.logger) {
`${logPrefixes.TapPrefix} ${cs( this.logger.tapOutput(`${this.receivedTests} out of ${this.expectedTests} Tests completed!`);
`${this.receivedTests} out of ${this.expectedTests} Tests completed!`, }
'green'
)}`
);
} else { } else {
console.log( if (this.logger) {
`${logPrefixes.TapErrorPrefix} ${cs( this.logger.error(`Only ${this.receivedTests} out of ${this.expectedTests} completed!`);
`Only ${this.receivedTests} out of ${this.expectedTests} completed!`, }
'red'
)}`
);
} }
if (!this.expectedTests) { 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) { } else if (this.expectedTests !== this.receivedTests) {
console.log( if (this.logger) {
cs( this.logger.error('The amount of received tests and expectedTests is unequal! Therefore the testfile failed');
'Error: The amount of received tests and expectedTests is unequal! Therefore the testfile failed', }
'red'
)
);
} else if (this.getErrorTests().length === 0) { } 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 { } else {
console.log( if (this.logger) {
`${logPrefixes.TapPrefix} ${cs( this.logger.tapOutput(`${this.getErrorTests().length} tests threw an error!!!`, true);
`${this.getErrorTests().length} tests threw an error!!!`, this.logger.testFileEnd(this.receivedTests - this.getErrorTests().length, this.getErrorTests().length, 0);
'red' }
)}`
);
} }
} }
} }

View File

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

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

@ -0,0 +1,358 @@
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'));
}
}
}
}