Compare commits

...

7 Commits

8 changed files with 154 additions and 33 deletions

View File

@@ -1,5 +1,35 @@
# Changelog # Changelog
## 2025-07-24 - 2.3.2 - fix(tapbundle)
Fix TypeScript IDE warning about tapTools parameter possibly being undefined
- Changed ITestFunction from interface with optional parameter to union type
- Updated test runner to handle both function signatures (with and without tapTools)
- Resolves IDE warnings while maintaining backward compatibility
## 2025-05-26 - 2.3.1 - fix(tapParser/logger)
Fix test duration reporting and summary formatting in TAP parser and logger
- Introduce startTime in TapParser to capture the overall test duration
- Pass computed duration to logger methods in evaluateFinalResult for accurate timing
- Update summary output to format duration in a human-readable way (ms vs. s)
- Add local permission settings configuration to .claude/settings.local.json
## 2025-05-26 - 2.3.0 - feat(cli)
Add '--version' option and warn against global tstest usage in the tstest project
- Introduced a new '--version' CLI flag that prints the version from package.json
- Added logic in ts/index.ts to detect if tstest is run globally within its own project and issue a warning
- Added .claude/settings.local.json to configure allowed permissions for various commands
## 2025-05-26 - 2.2.6 - fix(tstest)
Improve timeout warning timer management and summary output formatting in the test runner.
- Removed the global timeoutWarningTimer and replaced it with local warning timers in runInNode and runInChrome methods.
- Added warnings when test files run for over one minute if no timeout is specified.
- Ensured proper clearing of warning timers on successful completion or timeout.
- Enhanced quiet mode summary output to clearly display passed and failed test counts.
## 2025-05-26 - 2.2.5 - fix(protocol) ## 2025-05-26 - 2.2.5 - fix(protocol)
Fix inline timing metadata parsing and enhance test coverage for performance metrics and timing edge cases Fix inline timing metadata parsing and enhance test coverage for performance metrics and timing edge cases

View File

@@ -1,6 +1,6 @@
{ {
"name": "@git.zone/tstest", "name": "@git.zone/tstest",
"version": "2.2.5", "version": "2.3.2",
"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",
"exports": { "exports": {

View File

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

View File

@@ -8,6 +8,40 @@ export enum TestExecutionMode {
} }
export const runCli = async () => { export const runCli = async () => {
// Check if we're using global tstest in the tstest project itself
try {
const packageJsonPath = `${process.cwd()}/package.json`;
const fs = await import('fs');
if (fs.existsSync(packageJsonPath)) {
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
if (packageJson.name === '@git.zone/tstest') {
// Check if we're running from a global installation
const execPath = process.argv[1];
// Debug: log the paths (uncomment for debugging)
// console.log('DEBUG: Checking global tstest usage...');
// console.log('execPath:', execPath);
// console.log('cwd:', process.cwd());
// console.log('process.argv:', process.argv);
// Check if this is running from global installation
const isLocalCli = execPath.includes(process.cwd());
const isGlobalPnpm = process.argv.some(arg => arg.includes('.pnpm') && !arg.includes(process.cwd()));
const isGlobalNpm = process.argv.some(arg => arg.includes('npm/node_modules') && !arg.includes(process.cwd()));
if (!isLocalCli && (isGlobalPnpm || isGlobalNpm || !execPath.includes('node_modules'))) {
console.error('\n⚠ WARNING: You are using a globally installed tstest in the tstest project itself!');
console.error(' This means you are NOT testing your local changes.');
console.error(' Please use one of these commands instead:');
console.error(' • node cli.js <test-path>');
console.error(' • pnpm test <test-path>');
console.error(' • ./cli.js <test-path> (if executable)\n');
}
}
}
} catch (error) {
// Silently ignore any errors in this check
}
// Parse command line arguments // Parse command line arguments
const args = process.argv.slice(2); const args = process.argv.slice(2);
const logOptions: LogOptions = {}; const logOptions: LogOptions = {};
@@ -24,6 +58,18 @@ export const runCli = async () => {
const arg = args[i]; const arg = args[i];
switch (arg) { switch (arg) {
case '--version':
// Get version from package.json
try {
const fs = await import('fs');
const packagePath = new URL('../package.json', import.meta.url).pathname;
const packageData = JSON.parse(await fs.promises.readFile(packagePath, 'utf8'));
console.log(`tstest version ${packageData.version}`);
} catch (error) {
console.log('tstest version unknown');
}
process.exit(0);
break;
case '--quiet': case '--quiet':
case '-q': case '-q':
logOptions.quiet = true; logOptions.quiet = true;
@@ -115,6 +161,7 @@ export const runCli = async () => {
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('\nUsage: tstest <path> [options]');
console.error('\nOptions:'); console.error('\nOptions:');
console.error(' --version Show version information');
console.error(' --quiet, -q Minimal output'); console.error(' --quiet, -q Minimal output');
console.error(' --verbose, -v Verbose output'); console.error(' --verbose, -v Verbose output');
console.error(' --no-color Disable colored output'); console.error(' --no-color Disable colored output');

View File

@@ -22,6 +22,7 @@ export class TapParser {
private logger: TsTestLogger; private logger: TsTestLogger;
private protocolParser: ProtocolParser; private protocolParser: ProtocolParser;
private protocolVersion: string | null = null; private protocolVersion: string | null = null;
private startTime: number;
/** /**
* the constructor for TapParser * the constructor for TapParser
@@ -29,6 +30,7 @@ export class TapParser {
constructor(public fileName: string, logger?: TsTestLogger) { constructor(public fileName: string, logger?: TsTestLogger) {
this.logger = logger; this.logger = logger;
this.protocolParser = new ProtocolParser(); this.protocolParser = new ProtocolParser();
this.startTime = Date.now();
} }
/** /**
@@ -480,6 +482,7 @@ export class TapParser {
public async evaluateFinalResult() { public async evaluateFinalResult() {
this.receivedTests = this.testStore.length; this.receivedTests = this.testStore.length;
const duration = Date.now() - this.startTime;
// check wether all tests ran // check wether all tests ran
if (this.expectedTests === this.receivedTests) { if (this.expectedTests === this.receivedTests) {
@@ -494,23 +497,23 @@ export class TapParser {
if (!this.expectedTests && this.receivedTests === 0) { if (!this.expectedTests && this.receivedTests === 0) {
if (this.logger) { if (this.logger) {
this.logger.error('No tests were defined. Therefore the testfile failed!'); this.logger.error('No tests were defined. Therefore the testfile failed!');
this.logger.testFileEnd(0, 1, 0); // Count as 1 failure this.logger.testFileEnd(0, 1, duration); // Count as 1 failure
} }
} else if (this.expectedTests !== this.receivedTests) { } else if (this.expectedTests !== this.receivedTests) {
if (this.logger) { if (this.logger) {
this.logger.error('The amount of received tests and expectedTests is unequal! Therefore the testfile failed'); this.logger.error('The amount of received tests and expectedTests is unequal! Therefore the testfile failed');
const errorCount = this.getErrorTests().length || 1; // At least 1 error const errorCount = this.getErrorTests().length || 1; // At least 1 error
this.logger.testFileEnd(this.receivedTests - errorCount, errorCount, 0); this.logger.testFileEnd(this.receivedTests - errorCount, errorCount, duration);
} }
} else if (this.getErrorTests().length === 0) { } else if (this.getErrorTests().length === 0) {
if (this.logger) { if (this.logger) {
this.logger.tapOutput('All tests are successfull!!!'); this.logger.tapOutput('All tests are successfull!!!');
this.logger.testFileEnd(this.receivedTests, 0, 0); this.logger.testFileEnd(this.receivedTests, 0, duration);
} }
} else { } else {
if (this.logger) { if (this.logger) {
this.logger.tapOutput(`${this.getErrorTests().length} tests threw an error!!!`, true); this.logger.tapOutput(`${this.getErrorTests().length} tests threw an error!!!`, true);
this.logger.testFileEnd(this.receivedTests - this.getErrorTests().length, this.getErrorTests().length, 0); this.logger.testFileEnd(this.receivedTests - this.getErrorTests().length, this.getErrorTests().length, duration);
} }
} }
} }

View File

@@ -18,7 +18,6 @@ export class TsTest {
public startFromFile: number | null; public startFromFile: number | null;
public stopAtFile: number | null; public stopAtFile: number | null;
public timeoutSeconds: number | null; public timeoutSeconds: number | null;
private timeoutWarningTimer: NodeJS.Timeout | null = null;
public smartshellInstance = new plugins.smartshell.Smartshell({ public smartshellInstance = new plugins.smartshell.Smartshell({
executor: 'bash', executor: 'bash',
@@ -45,15 +44,6 @@ export class TsTest {
await this.movePreviousLogFiles(); await this.movePreviousLogFiles();
} }
// Start timeout warning timer if no timeout was specified
if (this.timeoutSeconds === null) {
this.timeoutWarningTimer = setTimeout(() => {
this.logger.warning('Test is running for more than 1 minute.');
this.logger.warning('Consider using --timeout option to set a timeout for test files.');
this.logger.warning('Example: tstest test --timeout=300 (for 5 minutes)');
}, 60000); // 1 minute
}
const testGroups = await this.testDir.getTestFileGroups(); const testGroups = await this.testDir.getTestFileGroups();
const allFiles = [...testGroups.serial, ...Object.values(testGroups.parallelGroups).flat()]; const allFiles = [...testGroups.serial, ...Object.values(testGroups.parallelGroups).flat()];
@@ -92,12 +82,6 @@ export class TsTest {
} }
} }
// Clear the timeout warning timer if it was set
if (this.timeoutWarningTimer) {
clearTimeout(this.timeoutWarningTimer);
this.timeoutWarningTimer = null;
}
tapCombinator.evaluate(); tapCombinator.evaluate();
} }
@@ -272,6 +256,19 @@ import '${absoluteTestFile.replace(/\\/g, '/')}';
execResultStreaming.childProcess.on('error', cleanup); execResultStreaming.childProcess.on('error', cleanup);
} }
// Start warning timer if no timeout was specified
let warningTimer: NodeJS.Timeout | null = null;
if (this.timeoutSeconds === null) {
warningTimer = setTimeout(() => {
console.error('');
console.error(cs('⚠️ WARNING: Test file is running for more than 1 minute', 'orange'));
console.error(cs(` File: ${fileNameArg}`, 'orange'));
console.error(cs(' Consider using --timeout option to set a timeout for test files.', 'orange'));
console.error(cs(' Example: tstest test --timeout=300 (for 5 minutes)', 'orange'));
console.error('');
}, 60000); // 1 minute
}
// Handle timeout if specified // Handle timeout if specified
if (this.timeoutSeconds !== null) { if (this.timeoutSeconds !== null) {
const timeoutMs = this.timeoutSeconds * 1000; const timeoutMs = this.timeoutSeconds * 1000;
@@ -293,6 +290,10 @@ import '${absoluteTestFile.replace(/\\/g, '/')}';
// Clear timeout if test completed successfully // Clear timeout if test completed successfully
clearTimeout(timeoutId); clearTimeout(timeoutId);
} catch (error) { } catch (error) {
// Clear warning timer if it was set
if (warningTimer) {
clearTimeout(warningTimer);
}
// Handle timeout error // Handle timeout error
tapParser.handleTimeout(this.timeoutSeconds); tapParser.handleTimeout(this.timeoutSeconds);
// Ensure entire process tree is killed if still running // Ensure entire process tree is killed if still running
@@ -307,6 +308,11 @@ import '${absoluteTestFile.replace(/\\/g, '/')}';
await tapParser.handleTapProcess(execResultStreaming.childProcess); await tapParser.handleTapProcess(execResultStreaming.childProcess);
} }
// Clear warning timer if it was set
if (warningTimer) {
clearTimeout(warningTimer);
}
return tapParser; return tapParser;
} }
@@ -425,6 +431,19 @@ import '${absoluteTestFile.replace(/\\/g, '/')}';
} }
); );
// Start warning timer if no timeout was specified
let warningTimer: NodeJS.Timeout | null = null;
if (this.timeoutSeconds === null) {
warningTimer = setTimeout(() => {
console.error('');
console.error(cs('⚠️ WARNING: Test file is running for more than 1 minute', 'orange'));
console.error(cs(` File: ${fileNameArg}`, 'orange'));
console.error(cs(' Consider using --timeout option to set a timeout for test files.', 'orange'));
console.error(cs(' Example: tstest test --timeout=300 (for 5 minutes)', 'orange'));
console.error('');
}, 60000); // 1 minute
}
// Handle timeout if specified // Handle timeout if specified
if (this.timeoutSeconds !== null) { if (this.timeoutSeconds !== null) {
const timeoutMs = this.timeoutSeconds * 1000; const timeoutMs = this.timeoutSeconds * 1000;
@@ -444,6 +463,10 @@ import '${absoluteTestFile.replace(/\\/g, '/')}';
// Clear timeout if test completed successfully // Clear timeout if test completed successfully
clearTimeout(timeoutId); clearTimeout(timeoutId);
} catch (error) { } catch (error) {
// Clear warning timer if it was set
if (warningTimer) {
clearTimeout(warningTimer);
}
// Handle timeout error // Handle timeout error
tapParser.handleTimeout(this.timeoutSeconds); tapParser.handleTimeout(this.timeoutSeconds);
} }
@@ -451,6 +474,11 @@ import '${absoluteTestFile.replace(/\\/g, '/')}';
await evaluatePromise; await evaluatePromise;
} }
// Clear warning timer if it was set
if (warningTimer) {
clearTimeout(warningTimer);
}
// Always clean up resources, even on timeout // Always clean up resources, even on timeout
try { try {
await this.smartbrowserInstance.stop(); await this.smartbrowserInstance.stop();
@@ -488,10 +516,10 @@ import '${absoluteTestFile.replace(/\\/g, '/')}';
try { try {
// Delete 00err and 00diff directories if they exist // Delete 00err and 00diff directories if they exist
if (await plugins.smartfile.fs.isDirectory(errDir)) { if (plugins.smartfile.fs.isDirectorySync(errDir)) {
plugins.smartfile.fs.removeSync(errDir); plugins.smartfile.fs.removeSync(errDir);
} }
if (await plugins.smartfile.fs.isDirectory(diffDir)) { if (plugins.smartfile.fs.isDirectorySync(diffDir)) {
plugins.smartfile.fs.removeSync(diffDir); plugins.smartfile.fs.removeSync(diffDir);
} }

View File

@@ -242,9 +242,13 @@ export class TsTestLogger {
if (!this.options.quiet) { if (!this.options.quiet) {
const total = passed + failed; const total = passed + failed;
const status = failed === 0 ? 'PASSED' : 'FAILED'; const durationStr = duration >= 1000 ? `${(duration / 1000).toFixed(1)}s` : `${duration}ms`;
const color = failed === 0 ? 'green' : 'red';
this.log(this.format(` Summary: ${passed}/${total} ${status}`, color)); if (failed === 0) {
this.log(this.format(` Summary: ${passed}/${total} PASSED in ${durationStr}`, 'green'));
} else {
this.log(this.format(` Summary: ${passed} passed, ${failed} failed of ${total} tests in ${durationStr}`, 'red'));
}
} }
// If using --logfile, handle error copy and diff detection // If using --logfile, handle error copy and diff detection
@@ -390,7 +394,13 @@ export class TsTestLogger {
if (this.options.quiet) { if (this.options.quiet) {
const status = summary.totalFailed === 0 ? 'PASSED' : 'FAILED'; const status = summary.totalFailed === 0 ? 'PASSED' : 'FAILED';
this.log(`\nSummary: ${summary.totalPassed}/${summary.totalTests} | ${totalDuration}ms | ${status}`); const durationStr = totalDuration >= 1000 ? `${(totalDuration / 1000).toFixed(1)}s` : `${totalDuration}ms`;
if (summary.totalFailed === 0) {
this.log(`\nSummary: ${summary.totalPassed}/${summary.totalTests} | ${durationStr} | ${status}`);
} else {
this.log(`\nSummary: ${summary.totalPassed} passed, ${summary.totalFailed} failed of ${summary.totalTests} tests | ${durationStr} | ${status}`);
}
return; return;
} }
@@ -404,7 +414,8 @@ export class TsTestLogger {
if (summary.totalSkipped > 0) { if (summary.totalSkipped > 0) {
this.log(this.format(`│ Skipped: ${summary.totalSkipped.toString().padStart(14)}`, 'yellow')); this.log(this.format(`│ Skipped: ${summary.totalSkipped.toString().padStart(14)}`, 'yellow'));
} }
this.log(this.format(`│ Duration: ${totalDuration.toString().padStart(14)}ms │`, 'white')); const durationStrFormatted = totalDuration >= 1000 ? `${(totalDuration / 1000).toFixed(1)}s` : `${totalDuration}ms`;
this.log(this.format(`│ Duration: ${durationStrFormatted.padStart(14)}`, 'white'));
this.log(this.format('└────────────────────────────────┘', 'dim')); this.log(this.format('└────────────────────────────────┘', 'dim'));
// File results // File results

View File

@@ -11,9 +11,9 @@ import { HrtMeasurement } from '@push.rocks/smarttime';
// interfaces // interfaces
export type TTestStatus = 'success' | 'error' | 'pending' | 'errorAfterSuccess' | 'timeout' | 'skipped'; export type TTestStatus = 'success' | 'error' | 'pending' | 'errorAfterSuccess' | 'timeout' | 'skipped';
export interface ITestFunction<T> { export type ITestFunction<T> =
(tapTools?: TapTools): Promise<T>; | ((tapTools: TapTools) => Promise<T>)
} | (() => Promise<T>);
export class TapTest<T = unknown> { export class TapTest<T = unknown> {
public description: string; public description: string;
@@ -173,7 +173,9 @@ export class TapTest<T = unknown> {
} }
// Run the test function with potential timeout // Run the test function with potential timeout
const testPromise = this.testFunction(this.tapTools); const testPromise = this.testFunction.length === 0
? (this.testFunction as () => Promise<T>)()
: (this.testFunction as (tapTools: TapTools) => Promise<T>)(this.tapTools);
const testReturnValue = timeoutPromise const testReturnValue = timeoutPromise
? await Promise.race([testPromise, timeoutPromise]) ? await Promise.race([testPromise, timeoutPromise])
: await testPromise; : await testPromise;