Compare commits

...

10 Commits

13 changed files with 567 additions and 46 deletions

View File

@ -1,5 +1,44 @@
# 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)
Add --startFrom and --stopAt options to filter test files by range
- Introduced CLI options --startFrom and --stopAt in ts/index.ts for selective test execution
- Added validation to ensure provided range values are positive and startFrom is not greater than stopAt
- Propagated file range filtering into test grouping in tstest.classes.tstest.ts, applying the range filter across serial and parallel groups
- Updated usage messages to include the new options
## 2025-05-23 - 1.9.4 - fix(docs)
Update documentation and configuration for legal notices and CI permissions. This commit adds a new local settings file for tool permissions, refines the legal and trademark sections in the readme, and improves glob test files with clearer log messages.
- Added .claude/settings.local.json to configure permissions for various CLI commands
- Revised legal and trademark documentation in the readme to clarify company ownership and usage guidelines
- Updated glob test files with improved console log messages for better clarity during test discovery
## 2025-05-23 - 1.9.3 - fix(tstest)
Fix test timing display issue and update TAP protocol documentation

19
license Normal file
View File

@ -0,0 +1,19 @@
Copyright (c) 2014 Task Venture Capital GmbH (hello@task.vc)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -1,6 +1,6 @@
{
"name": "@git.zone/tstest",
"version": "1.9.3",
"version": "1.11.0",
"private": false,
"description": "a test utility to run tests that match test/**/*.ts",
"exports": {

148
readme.md
View File

@ -68,8 +68,11 @@ tstest "test/unit/*.ts"
| `--verbose`, `-v` | Show all console output from tests |
| `--no-color` | Disable colored output |
| `--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) |
| `--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
@ -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.
### Automatic Logging
### Enhanced Test Logging
The `--logfile` option provides intelligent test logging with automatic organization:
Use `--logfile` to automatically save test output:
```bash
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
@ -620,8 +697,51 @@ tstest test/ --json > test-results.json
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
### 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
- 🐛 Fixed test timing display issue (removed duplicate timing in output)
- 📝 Improved internal protocol design documentation
@ -648,13 +768,21 @@ tstest test/ --quiet
- 📊 Enhanced TAP parser for better test reporting
- 🐛 Fixed glob pattern handling in shell scripts
## Contribution
## License and Legal Information
We are always happy for code contributions. If you are not the code contributing type that is ok. Still, maintaining Open Source repositories takes considerable time and thought. If you like the quality of what we do and our modules are useful to you we would appreciate a little monthly contribution: You can [contribute one time](https://lossless.link/contribute-onetime) or [contribute monthly](https://lossless.link/contribute). :)
This repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license.md) file within this repository.
## License
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
> MIT licensed | **&copy;** [Lossless GmbH](https://lossless.gmbh)
| By using this npm module you agree to our [privacy policy](https://lossless.gmbH/privacy)
### Trademarks
[![repo-footer](https://lossless.gitlab.io/publicrelations/repofooter.svg)](https://maintainedby.lossless.com)
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.
### Company Information
Task Venture Capital GmbH
Registered at District court Bremen HRB 35230 HB, Germany
For any legal inquiries or if you require further information, please contact us via email at hello@task.vc.
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.

View File

@ -0,0 +1,8 @@
import { tap } from '../../ts_tapbundle/index.js';
tap.test('spec file test', async () => {
console.log('This is a .spec.ts file that should be found by glob');
return true;
});
tap.start();

View File

@ -0,0 +1,8 @@
import { tap } from '../../../ts_tapbundle/index.js';
tap.test('nested glob pattern test', async () => {
console.log('This test file is in a nested directory');
return true;
});
tap.start();

View File

@ -0,0 +1,8 @@
import { tap } from '../../ts_tapbundle/index.js';
tap.test('glob pattern test', async () => {
console.log('This test file should be found by glob patterns');
return true;
});
tap.start();

View File

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

View File

@ -13,6 +13,9 @@ export const runCli = async () => {
const logOptions: LogOptions = {};
let testPath: string | null = null;
let tags: string[] = [];
let startFromFile: number | null = null;
let stopAtFile: number | null = null;
let timeoutSeconds: number | null = null;
// Parse options
for (let i = 0; i < args.length; i++) {
@ -42,6 +45,45 @@ export const runCli = async () => {
tags = args[++i].split(',');
}
break;
case '--startFrom':
if (i + 1 < args.length) {
const value = parseInt(args[++i], 10);
if (isNaN(value) || value < 1) {
console.error('Error: --startFrom must be a positive integer');
process.exit(1);
}
startFromFile = value;
} else {
console.error('Error: --startFrom requires a number argument');
process.exit(1);
}
break;
case '--stopAt':
if (i + 1 < args.length) {
const value = parseInt(args[++i], 10);
if (isNaN(value) || value < 1) {
console.error('Error: --stopAt must be a positive integer');
process.exit(1);
}
stopAtFile = value;
} else {
console.error('Error: --stopAt requires a number argument');
process.exit(1);
}
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:
if (!arg.startsWith('-')) {
testPath = arg;
@ -49,16 +91,25 @@ export const runCli = async () => {
}
}
// Validate test file range options
if (startFromFile !== null && stopAtFile !== null && startFromFile > stopAtFile) {
console.error('Error: --startFrom cannot be greater than --stopAt');
process.exit(1);
}
if (!testPath) {
console.error('You must specify a test directory/file/pattern as argument. Please try again.');
console.error('\nUsage: tstest <path> [options]');
console.error('\nOptions:');
console.error(' --quiet, -q Minimal output');
console.error(' --verbose, -v Verbose output');
console.error(' --no-color Disable colored output');
console.error(' --json Output results as JSON');
console.error(' --logfile Write logs to .nogit/testlogs/[testfile].log');
console.error(' --tags Run only tests with specified tags (comma-separated)');
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');
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(' --stopAt <n> Stop running at test file number n');
console.error(' --timeout <s> Timeout test files after s seconds');
process.exit(1);
}
@ -73,6 +124,11 @@ export const runCli = async () => {
executionMode = TestExecutionMode.DIRECTORY;
}
const tsTestInstance = new TsTest(process.cwd(), testPath, executionMode, logOptions, tags);
const tsTestInstance = new TsTest(process.cwd(), testPath, executionMode, logOptions, tags, startFromFile, stopAtFile, timeoutSeconds);
await tsTestInstance.run();
};
// Execute CLI when this file is run directly
if (import.meta.url === `file://${process.argv[1]}`) {
runCli();
}

View File

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

View File

@ -31,6 +31,34 @@ export class TapParser {
constructor(public fileName: string, logger?: TsTestLogger) {
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() {
this.activeTapTestResult = new TapTestResult(this.testStore.length + 1);
@ -69,7 +97,7 @@ export class TapParser {
} else if (this.testStatusRegex.test(logLine)) {
logLineIsTapProtocol = true;
const regexResult = this.testStatusRegex.exec(logLine);
const testId = parseInt(regexResult[2]);
// const testId = parseInt(regexResult[2]); // Currently unused
const testOk = (() => {
if (regexResult[1] === 'ok') {
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"
let testDuration = 0;
let isSkipped = false;
let isTodo = false;
if (testMetadata) {
const timeMatch = testMetadata.match(/time=(\d+)ms/);
const skipMatch = testMetadata.match(/SKIP\s*(.*)/);
const todoMatch = testMetadata.match(/TODO\s*(.*)/);
// const skipMatch = testMetadata.match(/SKIP\s*(.*)/); // Currently unused
// const todoMatch = testMetadata.match(/TODO\s*(.*)/); // Currently unused
if (timeMatch) {
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
@ -305,13 +328,16 @@ export class TapParser {
this.logger.error(`Only ${this.receivedTests} out of ${this.expectedTests} completed!`);
}
}
if (!this.expectedTests) {
if (!this.expectedTests && this.receivedTests === 0) {
if (this.logger) {
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) {
if (this.logger) {
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) {
if (this.logger) {

View File

@ -16,6 +16,9 @@ export class TsTest {
public executionMode: TestExecutionMode;
public logger: TsTestLogger;
public filterTags: string[];
public startFromFile: number | null;
public stopAtFile: number | null;
public timeoutSeconds: number | null;
public smartshellInstance = new plugins.smartshell.Smartshell({
executor: 'bash',
@ -26,18 +29,26 @@ export class TsTest {
public tsbundleInstance = new plugins.tsbundle.TsBundle();
constructor(cwdArg: string, testPathArg: string, executionModeArg: TestExecutionMode, logOptions: LogOptions = {}, tags: string[] = []) {
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.testDir = new TestDirectory(cwdArg, testPathArg, executionModeArg);
this.logger = new TsTestLogger(logOptions);
this.filterTags = tags;
this.startFromFile = startFromFile;
this.stopAtFile = stopAtFile;
this.timeoutSeconds = timeoutSeconds;
}
async run() {
// Move previous log files if --logfile option is used
if (this.logger.options.logFile) {
await this.movePreviousLogFiles();
}
const testGroups = await this.testDir.getTestFileGroups();
const allFiles = [...testGroups.serial, ...Object.values(testGroups.parallelGroups).flat()];
// Log test discovery
// Log test discovery - always show full count
this.logger.testDiscovery(
allFiles.length,
this.testDir.testPath,
@ -50,7 +61,7 @@ export class TsTest {
// Execute serial tests first
for (const fileNameArg of testGroups.serial) {
fileIndex++;
await this.runSingleTest(fileNameArg, fileIndex, allFiles.length, tapCombinator);
await this.runSingleTestOrSkip(fileNameArg, fileIndex, allFiles.length, tapCombinator);
}
// Execute parallel groups sequentially
@ -64,7 +75,7 @@ export class TsTest {
// Run all tests in this group in parallel
const parallelPromises = groupFiles.map(async (fileNameArg) => {
fileIndex++;
return this.runSingleTest(fileNameArg, fileIndex, allFiles.length, tapCombinator);
return this.runSingleTestOrSkip(fileNameArg, fileIndex, allFiles.length, tapCombinator);
});
await Promise.all(parallelPromises);
@ -75,6 +86,24 @@ export class TsTest {
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) {
switch (true) {
case process.env.CI && fileNameArg.includes('.nonci.'):
@ -120,7 +149,30 @@ export class TsTest {
const execResultStreaming = await this.smartshellInstance.execStreamingSilent(
`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;
}
@ -145,7 +197,7 @@ export class TsTest {
});
server.addRoute(
'/test',
new plugins.typedserver.servertools.Handler('GET', async (req, res) => {
new plugins.typedserver.servertools.Handler('GET', async (_req, res) => {
res.type('.html');
res.write(`
<html>
@ -178,9 +230,10 @@ export class TsTest {
});
});
// lets do the browser bit
// lets do the browser bit with timeout handling
await this.smartbrowserInstance.start();
const evaluation = await this.smartbrowserInstance.evaluateOnPage(
const evaluatePromise = this.smartbrowserInstance.evaluateOnPage(
`http://localhost:3007/test?bundleName=${bundleFileName}`,
async () => {
// lets enable real time comms
@ -193,12 +246,12 @@ export class TsTest {
const originalError = console.error;
// Override console methods to capture the logs
console.log = (...args) => {
console.log = (...args: any[]) => {
logStore.push(args.join(' '));
ws.send(args.join(' '));
originalLog(...args);
};
console.error = (...args) => {
console.error = (...args: any[]) => {
logStore.push(args.join(' '));
ws.send(args.join(' '));
originalError(...args);
@ -237,6 +290,29 @@ export class TsTest {
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 server.stop();
wss.close();
@ -249,4 +325,50 @@ export class TsTest {
}
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;
totalPassed: number;
totalFailed: number;
totalSkipped: number;
totalDuration: number;
fileResults: TestFileResult[];
skippedFiles: string[];
}
export class TsTestLogger {
private options: LogOptions;
public readonly options: LogOptions;
private startTime: number;
private fileResults: TestFileResult[] = [];
private currentFileResult: TestFileResult | null = null;
@ -245,6 +247,44 @@ export class TsTestLogger {
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
if (this.options.logFile) {
this.currentTestLogFile = null;
@ -252,7 +292,7 @@ export class TsTestLogger {
}
// TAP output forwarding (for TAP protocol messages)
tapOutput(message: string, isError: boolean = false) {
tapOutput(message: string, _isError: boolean = false) {
if (this.options.json) return;
// 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
browserConsole(message: string, level: string = 'log') {
if (this.options.json) {
@ -317,15 +370,17 @@ export class TsTestLogger {
}
// Final summary
summary() {
summary(skippedFiles: string[] = []) {
const totalDuration = Date.now() - this.startTime;
const summary: TestSummary = {
totalFiles: this.fileResults.length,
totalFiles: this.fileResults.length + skippedFiles.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),
totalSkipped: skippedFiles.length,
totalDuration,
fileResults: this.fileResults
fileResults: this.fileResults,
skippedFiles
};
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(`│ Passed: ${summary.totalPassed.toString().padStart(14)}`, '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('└────────────────────────────────┘', '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;
}
}