Compare commits

...

6 Commits

6 changed files with 93 additions and 16 deletions

View File

@ -1,5 +1,28 @@
# Changelog # Changelog
## 2025-05-24 - 1.11.4 - fix(logging)
Improve warning logging and add permission settings file
- Replace multiple logger.error calls with logger.warning for tests running over 1 minute
- Add warning method in tstest logger to display warning messages consistently
- Introduce .claude/settings.local.json to configure allowed permissions
## 2025-05-24 - 1.11.3 - fix(tstest)
Add timeout warning for long-running tests and introduce local settings configuration
- Add .claude/settings.local.json with permission configuration for local development
- Implement a timeout warning timer that notifies when tests run longer than 1 minute without an explicit timeout
- Clear the timeout warning timer upon test completion
- Remove unused import of logPrefixes in tstest.classes.tstest.ts
## 2025-05-24 - 1.11.2 - fix(tstest)
Improve timeout and error handling in test execution along with TAP parser timeout logic improvements.
- In the TAP parser, ensure that expected tests are properly set when no tests are defined to avoid false negatives on timeout.
- Use smartshell's terminate method and fallback kill to properly stop the entire process tree on timeout.
- Clean up browser, server, and WebSocket instances reliably even when a timeout occurs.
- Minor improvements in log file filtering and error logging for better clarity.
## 2025-05-24 - 1.11.1 - fix(tstest) ## 2025-05-24 - 1.11.1 - fix(tstest)
Clear timeout identifiers after successful test execution and add local CLAUDE settings Clear timeout identifiers after successful test execution and add local CLAUDE settings

View File

@ -1,6 +1,6 @@
{ {
"name": "@git.zone/tstest", "name": "@git.zone/tstest",
"version": "1.11.1", "version": "1.11.4",
"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: '1.11.1', version: '1.11.4',
description: 'a test utility to run tests that match test/**/*.ts' description: 'a test utility to run tests that match test/**/*.ts'
} }

View File

@ -36,18 +36,20 @@ export class TapParser {
* Handle test file timeout * Handle test file timeout
*/ */
public handleTimeout(timeoutSeconds: number) { public handleTimeout(timeoutSeconds: number) {
// If no tests have been defined yet, set expected to 1
if (this.expectedTests === 0) {
this.expectedTests = 1;
}
// Create a fake failing test result for timeout // Create a fake failing test result for timeout
this._getNewTapTestResult(); this._getNewTapTestResult();
this.activeTapTestResult.testOk = false; this.activeTapTestResult.testOk = false;
this.activeTapTestResult.testSettled = true; this.activeTapTestResult.testSettled = true;
this.testStore.push(this.activeTapTestResult); this.testStore.push(this.activeTapTestResult);
// Set expected vs received to force failure
this.expectedTests = 1;
this.receivedTests = 0;
// Log the timeout error // Log the timeout error
if (this.logger) { if (this.logger) {
// First log the test result
this.logger.testResult( this.logger.testResult(
`Test file timeout`, `Test file timeout`,
false, false,
@ -55,9 +57,9 @@ export class TapParser {
`Error: Test file exceeded timeout of ${timeoutSeconds} seconds` `Error: Test file exceeded timeout of ${timeoutSeconds} seconds`
); );
this.logger.testErrorDetails(`Test execution was terminated after ${timeoutSeconds} seconds`); this.logger.testErrorDetails(`Test execution was terminated after ${timeoutSeconds} seconds`);
// Force file end with failure
this.logger.testFileEnd(0, 1, timeoutSeconds * 1000);
} }
// Don't call evaluateFinalResult here, let the caller handle it
} }
private _getNewTapTestResult() { private _getNewTapTestResult() {

View File

@ -1,6 +1,5 @@
import * as plugins from './tstest.plugins.js'; import * as plugins from './tstest.plugins.js';
import * as paths from './tstest.paths.js'; import * as paths from './tstest.paths.js';
import * as logPrefixes from './tstest.logprefixes.js';
import { coloredString as cs } from '@push.rocks/consolecolor'; import { coloredString as cs } from '@push.rocks/consolecolor';
@ -19,6 +18,7 @@ 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,6 +45,15 @@ 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()];
@ -83,6 +92,12 @@ 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();
} }
@ -156,8 +171,9 @@ export class TsTest {
let timeoutId: NodeJS.Timeout; let timeoutId: NodeJS.Timeout;
const timeoutPromise = new Promise<void>((_resolve, reject) => { const timeoutPromise = new Promise<void>((_resolve, reject) => {
timeoutId = setTimeout(() => { timeoutId = setTimeout(async () => {
execResultStreaming.childProcess.kill('SIGTERM'); // Use smartshell's terminate() to kill entire process tree
await execResultStreaming.terminate();
reject(new Error(`Test file timed out after ${this.timeoutSeconds} seconds`)); reject(new Error(`Test file timed out after ${this.timeoutSeconds} seconds`));
}, timeoutMs); }, timeoutMs);
}); });
@ -172,6 +188,12 @@ export class TsTest {
} catch (error) { } catch (error) {
// Handle timeout error // Handle timeout error
tapParser.handleTimeout(this.timeoutSeconds); tapParser.handleTimeout(this.timeoutSeconds);
// Ensure entire process tree is killed if still running
try {
await execResultStreaming.kill(); // This kills the entire process tree with SIGKILL
} catch (killError) {
// Process tree might already be dead
}
} }
} else { } else {
await tapParser.handleTapProcess(execResultStreaming.childProcess); await tapParser.handleTapProcess(execResultStreaming.childProcess);
@ -321,13 +343,29 @@ export class TsTest {
await evaluatePromise; await evaluatePromise;
} }
await this.smartbrowserInstance.stop(); // Always clean up resources, even on timeout
await server.stop(); try {
wss.close(); await this.smartbrowserInstance.stop();
} catch (error) {
// Browser might already be stopped
}
try {
await server.stop();
} catch (error) {
// Server might already be stopped
}
try {
wss.close();
} catch (error) {
// WebSocket server might already be closed
}
console.log( console.log(
`${cs('=> ', 'blue')} Stopped ${cs(fileNameArg, 'orange')} chromium instance and server.` `${cs('=> ', 'blue')} Stopped ${cs(fileNameArg, 'orange')} chromium instance and server.`
); );
// lets create the tap parser // Always evaluate final result (handleTimeout just sets up the test state)
await tapParser.evaluateFinalResult(); await tapParser.evaluateFinalResult();
return tapParser; return tapParser;
} }
@ -351,7 +389,7 @@ export class TsTest {
// Get all .log files in log directory (not in subdirectories) // Get all .log files in log directory (not in subdirectories)
const files = await plugins.smartfile.fs.listFileTree(logDir, '*.log'); const files = await plugins.smartfile.fs.listFileTree(logDir, '*.log');
const logFiles = files.filter(file => !file.includes('/')); const logFiles = files.filter((file: string) => !file.includes('/'));
if (logFiles.length === 0) { if (logFiles.length === 0) {
return; return;

View File

@ -443,6 +443,20 @@ export class TsTestLogger {
this.log(this.format(`\n${status}`, statusColor)); this.log(this.format(`\n${status}`, statusColor));
} }
// Warning display
warning(message: string) {
if (this.options.json) {
this.logJson({ event: 'warning', message });
return;
}
if (this.options.quiet) {
console.log(`WARNING: ${message}`);
} else {
this.log(this.format(` ⚠️ ${message}`, 'orange'));
}
}
// Error display // Error display
error(message: string, file?: string, stack?: string) { error(message: string, file?: string, stack?: string) {
if (this.options.json) { if (this.options.json) {