Compare commits

...

6 Commits

9 changed files with 445 additions and 52 deletions

View File

@ -1,5 +1,29 @@
# Changelog # Changelog
## 2025-05-24 - 1.11.0 - feat(cli)
Add new timeout and file range options with enhanced logfile diff logging
- Introduce --timeout <seconds> option to safeguard tests from running too long
- Add --startFrom and --stopAt options to control the range of test files executed
- Enhance logfile organization by automatically moving previous logs and generating diff reports for failed or changed test outputs
- Update CLI argument parsing and internal timeout handling for both Node.js and browser tests
## 2025-05-24 - 1.10.2 - fix(tstest-logging)
Improve log file handling with log rotation and diff reporting
- Add .claude/settings.local.json to configure allowed shell and web operations
- Introduce movePreviousLogFiles function to archive previous log files when --logfile is used
- Enhance logging to generate error copies and diff reports between current and previous logs
- Add type annotations for console overrides in browser evaluations for improved stability
## 2025-05-23 - 1.10.1 - fix(tstest)
Improve file range filtering and summary logging by skipping test files outside the specified range and reporting them in the final summary.
- Introduce runSingleTestOrSkip to check file index against startFrom/stopAt values.
- Log skipped files with appropriate messages and add them to the summary.
- Update the logger to include total skipped files in the test summary.
- Add permission settings in .claude/settings.local.json to support new operations.
## 2025-05-23 - 1.10.0 - feat(cli) ## 2025-05-23 - 1.10.0 - feat(cli)
Add --startFrom and --stopAt options to filter test files by range Add --startFrom and --stopAt options to filter test files by range

View File

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

128
readme.md
View File

@ -68,8 +68,11 @@ tstest "test/unit/*.ts"
| `--verbose`, `-v` | Show all console output from tests | | `--verbose`, `-v` | Show all console output from tests |
| `--no-color` | Disable colored output | | `--no-color` | Disable colored output |
| `--json` | Output results as JSON | | `--json` | Output results as JSON |
| `--logfile` | Save detailed logs to `.nogit/testlogs/[testname].log` | | `--logfile` | Save detailed logs with automatic error and diff tracking |
| `--tags <tags>` | Run only tests with specific tags (comma-separated) | | `--tags <tags>` | Run only tests with specific tags (comma-separated) |
| `--timeout <seconds>` | Timeout test files after specified seconds |
| `--startFrom <n>` | Start running from test file number n |
| `--stopAt <n>` | Stop running at test file number n |
### Example Outputs ### Example Outputs
@ -571,14 +574,88 @@ tstest "test/**/*.spec.ts" "test/**/*.test.ts"
**Important**: Always quote glob patterns to prevent shell expansion. Without quotes, the shell will expand the pattern and only pass the first matching file to tstest. **Important**: Always quote glob patterns to prevent shell expansion. Without quotes, the shell will expand the pattern and only pass the first matching file to tstest.
### Automatic Logging ### Enhanced Test Logging
The `--logfile` option provides intelligent test logging with automatic organization:
Use `--logfile` to automatically save test output:
```bash ```bash
tstest test/ --logfile tstest test/ --logfile
``` ```
This creates detailed logs in `.nogit/testlogs/[testname].log` for each test file. **Log Organization:**
- **Current Run**: `.nogit/testlogs/[testname].log`
- **Previous Run**: `.nogit/testlogs/previous/[testname].log`
- **Failed Tests**: `.nogit/testlogs/00err/[testname].log`
- **Changed Output**: `.nogit/testlogs/00diff/[testname].log`
**Features:**
- Previous logs are automatically moved to the `previous/` folder
- Failed tests create copies in `00err/` for quick identification
- Tests with changed output create diff reports in `00diff/`
- The `00err/` and `00diff/` folders are cleared on each run
**Example Diff Report:**
```
DIFF REPORT: test__api__integration.log
Generated: 2025-05-24T01:29:13.847Z
================================================================================
- [Line 8] ✅ api test passes (150ms)
+ [Line 8] ✅ api test passes (165ms)
================================================================================
Previous version had 40 lines
Current version has 40 lines
```
### Test Timeout Protection
Prevent runaway tests with the `--timeout` option:
```bash
# Timeout any test file that runs longer than 60 seconds
tstest test/ --timeout 60
# Shorter timeout for unit tests
tstest test/unit/ --timeout 10
```
When a test exceeds the timeout:
- The test process is terminated (SIGTERM)
- The test is marked as failed
- An error log is created in `.nogit/testlogs/00err/`
- Clear error message shows the timeout duration
### Test File Range Control
Run specific ranges of test files using `--startFrom` and `--stopAt`:
```bash
# Run tests starting from the 5th file
tstest test/ --startFrom 5
# Run only files 5 through 10
tstest test/ --startFrom 5 --stopAt 10
# Run only the first 3 test files
tstest test/ --stopAt 3
```
This is particularly useful for:
- Debugging specific test failures in large test suites
- Running tests in chunks on different CI runners
- Quickly testing changes to specific test files
The output shows which files are skipped:
```
⏭️ test/auth.test.ts (1/10)
Skipped: before start range (5)
⏭️ test/user.test.ts (2/10)
Skipped: before start range (5)
▶️ test/api.test.ts (5/10)
Runtime: node.js
✅ api endpoints work (145ms)
```
### Performance Analysis ### Performance Analysis
@ -620,8 +697,51 @@ tstest test/ --json > test-results.json
tstest test/ --quiet tstest test/ --quiet
``` ```
**Advanced CI Example:**
```bash
# Run tests with comprehensive logging and safety features
tstest test/ \
--timeout 300 \
--logfile \
--json > test-results.json
# Run specific test chunks in parallel CI jobs
tstest test/ --startFrom 1 --stopAt 10 # Job 1
tstest test/ --startFrom 11 --stopAt 20 # Job 2
tstest test/ --startFrom 21 # Job 3
```
### Debugging Failed Tests
When tests fail, use the enhanced logging features:
```bash
# Run with logging to capture detailed output
tstest test/ --logfile --verbose
# Check error logs
ls .nogit/testlogs/00err/
# Review diffs for flaky tests
cat .nogit/testlogs/00diff/test__api__endpoints.log
# Re-run specific failed tests
tstest test/api/endpoints.test.ts --verbose --timeout 60
```
## Changelog ## Changelog
### Version 1.10.0
- ⏱️ Added `--timeout <seconds>` option for test file timeout protection
- 🎯 Added `--startFrom <n>` and `--stopAt <n>` options for test file range control
- 📁 Enhanced `--logfile` with intelligent log organization:
- Previous logs moved to `previous/` folder
- Failed tests copied to `00err/` folder
- Changed tests create diff reports in `00diff/` folder
- 🔍 Improved test discovery to show skipped files with clear reasons
- 🐛 Fixed TypeScript compilation warnings and unused variables
- 📊 Test summaries now include skipped file counts
### Version 1.9.2 ### Version 1.9.2
- 🐛 Fixed test timing display issue (removed duplicate timing in output) - 🐛 Fixed test timing display issue (removed duplicate timing in output)
- 📝 Improved internal protocol design documentation - 📝 Improved internal protocol design documentation

View File

@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@git.zone/tstest', name: '@git.zone/tstest',
version: '1.10.0', version: '1.11.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

@ -15,6 +15,7 @@ export const runCli = async () => {
let tags: string[] = []; let tags: string[] = [];
let startFromFile: number | null = null; let startFromFile: number | null = null;
let stopAtFile: number | null = null; let stopAtFile: number | null = null;
let timeoutSeconds: number | null = null;
// Parse options // Parse options
for (let i = 0; i < args.length; i++) { for (let i = 0; i < args.length; i++) {
@ -70,6 +71,19 @@ export const runCli = async () => {
process.exit(1); process.exit(1);
} }
break; break;
case '--timeout':
if (i + 1 < args.length) {
const value = parseInt(args[++i], 10);
if (isNaN(value) || value < 1) {
console.error('Error: --timeout must be a positive integer (seconds)');
process.exit(1);
}
timeoutSeconds = value;
} else {
console.error('Error: --timeout requires a number argument (seconds)');
process.exit(1);
}
break;
default: default:
if (!arg.startsWith('-')) { if (!arg.startsWith('-')) {
testPath = arg; testPath = arg;
@ -95,6 +109,7 @@ export const runCli = async () => {
console.error(' --tags <tags> Run only tests with specified tags (comma-separated)'); console.error(' --tags <tags> Run only tests with specified tags (comma-separated)');
console.error(' --startFrom <n> Start running from test file number n'); console.error(' --startFrom <n> Start running from test file number n');
console.error(' --stopAt <n> Stop running at test file number n'); console.error(' --stopAt <n> Stop running at test file number n');
console.error(' --timeout <s> Timeout test files after s seconds');
process.exit(1); process.exit(1);
} }
@ -109,7 +124,7 @@ export const runCli = async () => {
executionMode = TestExecutionMode.DIRECTORY; executionMode = TestExecutionMode.DIRECTORY;
} }
const tsTestInstance = new TsTest(process.cwd(), testPath, executionMode, logOptions, tags, startFromFile, stopAtFile); const tsTestInstance = new TsTest(process.cwd(), testPath, executionMode, logOptions, tags, startFromFile, stopAtFile, timeoutSeconds);
await tsTestInstance.run(); await tsTestInstance.run();
}; };

View File

@ -10,6 +10,7 @@ import { TsTestLogger } from './tstest.logging.js';
export class TapCombinator { export class TapCombinator {
tapParserStore: TapParser[] = []; tapParserStore: TapParser[] = [];
skippedFiles: string[] = [];
private logger: TsTestLogger; private logger: TsTestLogger;
constructor(logger: TsTestLogger) { constructor(logger: TsTestLogger) {
@ -20,9 +21,13 @@ export class TapCombinator {
this.tapParserStore.push(tapParserArg); this.tapParserStore.push(tapParserArg);
} }
addSkippedFile(filename: string) {
this.skippedFiles.push(filename);
}
evaluate() { evaluate() {
// Call the logger's summary method // Call the logger's summary method with skipped files
this.logger.summary(); this.logger.summary(this.skippedFiles);
// Check for failures // Check for failures
let failGlobal = false; let failGlobal = false;

View File

@ -32,6 +32,34 @@ export class TapParser {
this.logger = logger; this.logger = logger;
} }
/**
* Handle test file timeout
*/
public handleTimeout(timeoutSeconds: number) {
// Create a fake failing test result for timeout
this._getNewTapTestResult();
this.activeTapTestResult.testOk = false;
this.activeTapTestResult.testSettled = true;
this.testStore.push(this.activeTapTestResult);
// Set expected vs received to force failure
this.expectedTests = 1;
this.receivedTests = 0;
// Log the timeout error
if (this.logger) {
this.logger.testResult(
`Test file timeout`,
false,
timeoutSeconds * 1000,
`Error: Test file exceeded timeout of ${timeoutSeconds} seconds`
);
this.logger.testErrorDetails(`Test execution was terminated after ${timeoutSeconds} seconds`);
// Force file end with failure
this.logger.testFileEnd(0, 1, timeoutSeconds * 1000);
}
}
private _getNewTapTestResult() { private _getNewTapTestResult() {
this.activeTapTestResult = new TapTestResult(this.testStore.length + 1); this.activeTapTestResult = new TapTestResult(this.testStore.length + 1);
} }
@ -69,7 +97,7 @@ export class TapParser {
} else if (this.testStatusRegex.test(logLine)) { } else if (this.testStatusRegex.test(logLine)) {
logLineIsTapProtocol = true; logLineIsTapProtocol = true;
const regexResult = this.testStatusRegex.exec(logLine); const regexResult = this.testStatusRegex.exec(logLine);
const testId = parseInt(regexResult[2]); // const testId = parseInt(regexResult[2]); // Currently unused
const testOk = (() => { const testOk = (() => {
if (regexResult[1] === 'ok') { if (regexResult[1] === 'ok') {
return true; return true;
@ -81,21 +109,16 @@ export class TapParser {
const testMetadata = regexResult[5]; // This will be either "time=XXXms" or "SKIP reason" or "TODO reason" const testMetadata = regexResult[5]; // This will be either "time=XXXms" or "SKIP reason" or "TODO reason"
let testDuration = 0; let testDuration = 0;
let isSkipped = false;
let isTodo = false;
if (testMetadata) { if (testMetadata) {
const timeMatch = testMetadata.match(/time=(\d+)ms/); const timeMatch = testMetadata.match(/time=(\d+)ms/);
const skipMatch = testMetadata.match(/SKIP\s*(.*)/); // const skipMatch = testMetadata.match(/SKIP\s*(.*)/); // Currently unused
const todoMatch = testMetadata.match(/TODO\s*(.*)/); // const todoMatch = testMetadata.match(/TODO\s*(.*)/); // Currently unused
if (timeMatch) { if (timeMatch) {
testDuration = parseInt(timeMatch[1]); testDuration = parseInt(timeMatch[1]);
} else if (skipMatch) {
isSkipped = true;
} else if (todoMatch) {
isTodo = true;
} }
// Skip/todo handling could be added here in the future
} }
// test for protocol error - disabled as it's not critical // test for protocol error - disabled as it's not critical
@ -305,13 +328,16 @@ export class TapParser {
this.logger.error(`Only ${this.receivedTests} out of ${this.expectedTests} completed!`); this.logger.error(`Only ${this.receivedTests} out of ${this.expectedTests} completed!`);
} }
} }
if (!this.expectedTests) { 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
} }
} 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
this.logger.testFileEnd(this.receivedTests - errorCount, errorCount, 0);
} }
} else if (this.getErrorTests().length === 0) { } else if (this.getErrorTests().length === 0) {
if (this.logger) { if (this.logger) {

View File

@ -18,6 +18,7 @@ export class TsTest {
public filterTags: string[]; public filterTags: string[];
public startFromFile: number | null; public startFromFile: number | null;
public stopAtFile: number | null; public stopAtFile: number | null;
public timeoutSeconds: number | null;
public smartshellInstance = new plugins.smartshell.Smartshell({ public smartshellInstance = new plugins.smartshell.Smartshell({
executor: 'bash', executor: 'bash',
@ -28,37 +29,26 @@ export class TsTest {
public tsbundleInstance = new plugins.tsbundle.TsBundle(); public tsbundleInstance = new plugins.tsbundle.TsBundle();
constructor(cwdArg: string, testPathArg: string, executionModeArg: TestExecutionMode, logOptions: LogOptions = {}, tags: string[] = [], startFromFile: number | null = null, stopAtFile: number | null = null) { constructor(cwdArg: string, testPathArg: string, executionModeArg: TestExecutionMode, logOptions: LogOptions = {}, tags: string[] = [], startFromFile: number | null = null, stopAtFile: number | null = null, timeoutSeconds: number | null = null) {
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); this.logger = new TsTestLogger(logOptions);
this.filterTags = tags; this.filterTags = tags;
this.startFromFile = startFromFile; this.startFromFile = startFromFile;
this.stopAtFile = stopAtFile; this.stopAtFile = stopAtFile;
this.timeoutSeconds = timeoutSeconds;
} }
async run() { async run() {
const testGroups = await this.testDir.getTestFileGroups(); // Move previous log files if --logfile option is used
let allFiles = [...testGroups.serial, ...Object.values(testGroups.parallelGroups).flat()]; if (this.logger.options.logFile) {
await this.movePreviousLogFiles();
// Apply file range filtering if specified
if (this.startFromFile !== null || this.stopAtFile !== null) {
const startIndex = this.startFromFile ? this.startFromFile - 1 : 0; // Convert to 0-based index
const endIndex = this.stopAtFile ? this.stopAtFile : allFiles.length;
allFiles = allFiles.slice(startIndex, endIndex);
// Filter the serial and parallel groups based on remaining files
testGroups.serial = testGroups.serial.filter(file => allFiles.includes(file));
Object.keys(testGroups.parallelGroups).forEach(groupName => {
testGroups.parallelGroups[groupName] = testGroups.parallelGroups[groupName].filter(file => allFiles.includes(file));
// Remove empty groups
if (testGroups.parallelGroups[groupName].length === 0) {
delete testGroups.parallelGroups[groupName];
}
});
} }
// Log test discovery const testGroups = await this.testDir.getTestFileGroups();
const allFiles = [...testGroups.serial, ...Object.values(testGroups.parallelGroups).flat()];
// Log test discovery - always show full count
this.logger.testDiscovery( this.logger.testDiscovery(
allFiles.length, allFiles.length,
this.testDir.testPath, this.testDir.testPath,
@ -71,7 +61,7 @@ export class TsTest {
// Execute serial tests first // Execute serial tests first
for (const fileNameArg of testGroups.serial) { for (const fileNameArg of testGroups.serial) {
fileIndex++; fileIndex++;
await this.runSingleTest(fileNameArg, fileIndex, allFiles.length, tapCombinator); await this.runSingleTestOrSkip(fileNameArg, fileIndex, allFiles.length, tapCombinator);
} }
// Execute parallel groups sequentially // Execute parallel groups sequentially
@ -85,7 +75,7 @@ export class TsTest {
// Run all tests in this group in parallel // Run all tests in this group in parallel
const parallelPromises = groupFiles.map(async (fileNameArg) => { const parallelPromises = groupFiles.map(async (fileNameArg) => {
fileIndex++; fileIndex++;
return this.runSingleTest(fileNameArg, fileIndex, allFiles.length, tapCombinator); return this.runSingleTestOrSkip(fileNameArg, fileIndex, allFiles.length, tapCombinator);
}); });
await Promise.all(parallelPromises); await Promise.all(parallelPromises);
@ -96,6 +86,24 @@ export class TsTest {
tapCombinator.evaluate(); tapCombinator.evaluate();
} }
private async runSingleTestOrSkip(fileNameArg: string, fileIndex: number, totalFiles: number, tapCombinator: TapCombinator) {
// Check if this file should be skipped based on range
if (this.startFromFile !== null && fileIndex < this.startFromFile) {
this.logger.testFileSkipped(fileNameArg, fileIndex, totalFiles, `before start range (${this.startFromFile})`);
tapCombinator.addSkippedFile(fileNameArg);
return;
}
if (this.stopAtFile !== null && fileIndex > this.stopAtFile) {
this.logger.testFileSkipped(fileNameArg, fileIndex, totalFiles, `after stop range (${this.stopAtFile})`);
tapCombinator.addSkippedFile(fileNameArg);
return;
}
// File is in range, run it
await this.runSingleTest(fileNameArg, fileIndex, totalFiles, tapCombinator);
}
private async runSingleTest(fileNameArg: string, fileIndex: number, totalFiles: number, tapCombinator: TapCombinator) { private async runSingleTest(fileNameArg: string, fileIndex: number, totalFiles: number, tapCombinator: TapCombinator) {
switch (true) { switch (true) {
case process.env.CI && fileNameArg.includes('.nonci.'): case process.env.CI && fileNameArg.includes('.nonci.'):
@ -141,7 +149,30 @@ export class TsTest {
const execResultStreaming = await this.smartshellInstance.execStreamingSilent( const execResultStreaming = await this.smartshellInstance.execStreamingSilent(
`tsrun ${fileNameArg}${tsrunOptions}` `tsrun ${fileNameArg}${tsrunOptions}`
); );
await tapParser.handleTapProcess(execResultStreaming.childProcess);
// Handle timeout if specified
if (this.timeoutSeconds !== null) {
const timeoutMs = this.timeoutSeconds * 1000;
const timeoutPromise = new Promise<void>((_resolve, reject) => {
setTimeout(() => {
execResultStreaming.childProcess.kill('SIGTERM');
reject(new Error(`Test file timed out after ${this.timeoutSeconds} seconds`));
}, timeoutMs);
});
try {
await Promise.race([
tapParser.handleTapProcess(execResultStreaming.childProcess),
timeoutPromise
]);
} catch (error) {
// Handle timeout error
tapParser.handleTimeout(this.timeoutSeconds);
}
} else {
await tapParser.handleTapProcess(execResultStreaming.childProcess);
}
return tapParser; return tapParser;
} }
@ -166,7 +197,7 @@ export class TsTest {
}); });
server.addRoute( server.addRoute(
'/test', '/test',
new plugins.typedserver.servertools.Handler('GET', async (req, res) => { new plugins.typedserver.servertools.Handler('GET', async (_req, res) => {
res.type('.html'); res.type('.html');
res.write(` res.write(`
<html> <html>
@ -199,9 +230,10 @@ export class TsTest {
}); });
}); });
// lets do the browser bit // lets do the browser bit with timeout handling
await this.smartbrowserInstance.start(); await this.smartbrowserInstance.start();
const evaluation = await this.smartbrowserInstance.evaluateOnPage(
const evaluatePromise = this.smartbrowserInstance.evaluateOnPage(
`http://localhost:3007/test?bundleName=${bundleFileName}`, `http://localhost:3007/test?bundleName=${bundleFileName}`,
async () => { async () => {
// lets enable real time comms // lets enable real time comms
@ -214,12 +246,12 @@ export class TsTest {
const originalError = console.error; const originalError = console.error;
// Override console methods to capture the logs // Override console methods to capture the logs
console.log = (...args) => { console.log = (...args: any[]) => {
logStore.push(args.join(' ')); logStore.push(args.join(' '));
ws.send(args.join(' ')); ws.send(args.join(' '));
originalLog(...args); originalLog(...args);
}; };
console.error = (...args) => { console.error = (...args: any[]) => {
logStore.push(args.join(' ')); logStore.push(args.join(' '));
ws.send(args.join(' ')); ws.send(args.join(' '));
originalError(...args); originalError(...args);
@ -258,6 +290,29 @@ export class TsTest {
return logStore.join('\n'); return logStore.join('\n');
} }
); );
// Handle timeout if specified
if (this.timeoutSeconds !== null) {
const timeoutMs = this.timeoutSeconds * 1000;
const timeoutPromise = new Promise<void>((_resolve, reject) => {
setTimeout(() => {
reject(new Error(`Test file timed out after ${this.timeoutSeconds} seconds`));
}, timeoutMs);
});
try {
await Promise.race([
evaluatePromise,
timeoutPromise
]);
} catch (error) {
// Handle timeout error
tapParser.handleTimeout(this.timeoutSeconds);
}
} else {
await evaluatePromise;
}
await this.smartbrowserInstance.stop(); await this.smartbrowserInstance.stop();
await server.stop(); await server.stop();
wss.close(); wss.close();
@ -270,4 +325,50 @@ export class TsTest {
} }
public async runInDeno() {} public async runInDeno() {}
private async movePreviousLogFiles() {
const logDir = plugins.path.join('.nogit', 'testlogs');
const previousDir = plugins.path.join('.nogit', 'testlogs', 'previous');
const errDir = plugins.path.join('.nogit', 'testlogs', '00err');
const diffDir = plugins.path.join('.nogit', 'testlogs', '00diff');
try {
// Delete 00err and 00diff directories if they exist
if (await plugins.smartfile.fs.isDirectory(errDir)) {
await plugins.smartfile.fs.remove(errDir);
}
if (await plugins.smartfile.fs.isDirectory(diffDir)) {
await plugins.smartfile.fs.remove(diffDir);
}
// Get all .log files in log directory (not in subdirectories)
const files = await plugins.smartfile.fs.listFileTree(logDir, '*.log');
const logFiles = files.filter(file => !file.includes('/'));
if (logFiles.length === 0) {
return;
}
// Ensure previous directory exists
await plugins.smartfile.fs.ensureDir(previousDir);
// Move each log file to previous directory
for (const file of logFiles) {
const filename = plugins.path.basename(file);
const sourcePath = plugins.path.join(logDir, filename);
const destPath = plugins.path.join(previousDir, filename);
try {
// Copy file to new location and remove original
await plugins.smartfile.fs.copy(sourcePath, destPath);
await plugins.smartfile.fs.remove(sourcePath);
} catch (error) {
// Silently continue if a file can't be moved
}
}
} catch (error) {
// Directory might not exist, which is fine
return;
}
}
} }

View File

@ -30,12 +30,14 @@ export interface TestSummary {
totalTests: number; totalTests: number;
totalPassed: number; totalPassed: number;
totalFailed: number; totalFailed: number;
totalSkipped: number;
totalDuration: number; totalDuration: number;
fileResults: TestFileResult[]; fileResults: TestFileResult[];
skippedFiles: string[];
} }
export class TsTestLogger { export class TsTestLogger {
private options: LogOptions; public readonly options: LogOptions;
private startTime: number; private startTime: number;
private fileResults: TestFileResult[] = []; private fileResults: TestFileResult[] = [];
private currentFileResult: TestFileResult | null = null; private currentFileResult: TestFileResult | null = null;
@ -245,6 +247,44 @@ export class TsTestLogger {
this.log(this.format(` Summary: ${passed}/${total} ${status}`, color)); this.log(this.format(` Summary: ${passed}/${total} ${status}`, color));
} }
// If using --logfile, handle error copy and diff detection
if (this.options.logFile && this.currentTestLogFile) {
try {
const logContent = fs.readFileSync(this.currentTestLogFile, 'utf-8');
const logDir = path.dirname(this.currentTestLogFile);
const logBasename = path.basename(this.currentTestLogFile);
// Create error copy if there were failures
if (failed > 0) {
const errorDir = path.join(logDir, '00err');
if (!fs.existsSync(errorDir)) {
fs.mkdirSync(errorDir, { recursive: true });
}
const errorLogPath = path.join(errorDir, logBasename);
fs.writeFileSync(errorLogPath, logContent);
}
// Check for previous version and create diff if changed
const previousLogPath = path.join(logDir, 'previous', logBasename);
if (fs.existsSync(previousLogPath)) {
const previousContent = fs.readFileSync(previousLogPath, 'utf-8');
// Simple check if content differs
if (previousContent !== logContent) {
const diffDir = path.join(logDir, '00diff');
if (!fs.existsSync(diffDir)) {
fs.mkdirSync(diffDir, { recursive: true });
}
const diffLogPath = path.join(diffDir, logBasename);
const diffContent = this.createDiff(previousContent, logContent, logBasename);
fs.writeFileSync(diffLogPath, diffContent);
}
}
} catch (error) {
// Silently fail to avoid disrupting the test run
}
}
// Clear the current test log file reference only if using --logfile // Clear the current test log file reference only if using --logfile
if (this.options.logFile) { if (this.options.logFile) {
this.currentTestLogFile = null; this.currentTestLogFile = null;
@ -252,7 +292,7 @@ export class TsTestLogger {
} }
// TAP output forwarding (for TAP protocol messages) // TAP output forwarding (for TAP protocol messages)
tapOutput(message: string, isError: boolean = false) { tapOutput(message: string, _isError: boolean = false) {
if (this.options.json) return; if (this.options.json) return;
// Never show raw TAP protocol messages in console // Never show raw TAP protocol messages in console
@ -282,6 +322,19 @@ export class TsTestLogger {
} }
} }
// Skipped test file
testFileSkipped(filename: string, index: number, total: number, reason: string) {
if (this.options.json) {
this.logJson({ event: 'fileSkipped', filename, index, total, reason });
return;
}
if (this.options.quiet) return;
this.log(this.format(`\n⏭ ${filename} (${index}/${total})`, 'yellow'));
this.log(this.format(` Skipped: ${reason}`, 'dim'));
}
// Browser console // Browser console
browserConsole(message: string, level: string = 'log') { browserConsole(message: string, level: string = 'log') {
if (this.options.json) { if (this.options.json) {
@ -317,15 +370,17 @@ export class TsTestLogger {
} }
// Final summary // Final summary
summary() { summary(skippedFiles: string[] = []) {
const totalDuration = Date.now() - this.startTime; const totalDuration = Date.now() - this.startTime;
const summary: TestSummary = { const summary: TestSummary = {
totalFiles: this.fileResults.length, totalFiles: this.fileResults.length + skippedFiles.length,
totalTests: this.fileResults.reduce((sum, r) => sum + r.total, 0), totalTests: this.fileResults.reduce((sum, r) => sum + r.total, 0),
totalPassed: this.fileResults.reduce((sum, r) => sum + r.passed, 0), totalPassed: this.fileResults.reduce((sum, r) => sum + r.passed, 0),
totalFailed: this.fileResults.reduce((sum, r) => sum + r.failed, 0), totalFailed: this.fileResults.reduce((sum, r) => sum + r.failed, 0),
totalSkipped: skippedFiles.length,
totalDuration, totalDuration,
fileResults: this.fileResults fileResults: this.fileResults,
skippedFiles
}; };
if (this.options.json) { if (this.options.json) {
@ -346,6 +401,9 @@ export class TsTestLogger {
this.log(this.format(`│ Total Tests: ${summary.totalTests.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(`│ 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(`│ Failed: ${summary.totalFailed.toString().padStart(14)}`, summary.totalFailed > 0 ? 'red' : 'green'));
if (summary.totalSkipped > 0) {
this.log(this.format(`│ Skipped: ${summary.totalSkipped.toString().padStart(14)}`, 'yellow'));
}
this.log(this.format(`│ Duration: ${totalDuration.toString().padStart(14)}ms │`, 'white')); this.log(this.format(`│ Duration: ${totalDuration.toString().padStart(14)}ms │`, 'white'));
this.log(this.format('└────────────────────────────────┘', 'dim')); this.log(this.format('└────────────────────────────────┘', 'dim'));
@ -404,4 +462,48 @@ export class TsTestLogger {
} }
} }
} }
// Create a diff between two log contents
private createDiff(previousContent: string, currentContent: string, filename: string): string {
const previousLines = previousContent.split('\n');
const currentLines = currentContent.split('\n');
let diff = `DIFF REPORT: ${filename}\n`;
diff += `Generated: ${new Date().toISOString()}\n`;
diff += '='.repeat(80) + '\n\n';
// Simple line-by-line comparison
const maxLines = Math.max(previousLines.length, currentLines.length);
let hasChanges = false;
for (let i = 0; i < maxLines; i++) {
const prevLine = previousLines[i] || '';
const currLine = currentLines[i] || '';
if (prevLine !== currLine) {
hasChanges = true;
if (i < previousLines.length && i >= currentLines.length) {
// Line was removed
diff += `- [Line ${i + 1}] ${prevLine}\n`;
} else if (i >= previousLines.length && i < currentLines.length) {
// Line was added
diff += `+ [Line ${i + 1}] ${currLine}\n`;
} else {
// Line was modified
diff += `- [Line ${i + 1}] ${prevLine}\n`;
diff += `+ [Line ${i + 1}] ${currLine}\n`;
}
}
}
if (!hasChanges) {
diff += 'No changes detected.\n';
}
diff += '\n' + '='.repeat(80) + '\n';
diff += `Previous version had ${previousLines.length} lines\n`;
diff += `Current version has ${currentLines.length} lines\n`;
return diff;
}
} }