Compare commits

...

6 Commits

8 changed files with 399 additions and 26 deletions

View File

@ -1,5 +1,27 @@
# Changelog # Changelog
## 2025-05-24 - 1.11.1 - fix(tstest)
Clear timeout identifiers after successful test execution and add local CLAUDE settings
- Ensure timeout IDs are cleared when tests complete to prevent lingering timeouts
- Add .claude/settings.local.json with updated permission settings for CLI commands
## 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) ## 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. Improve file range filtering and summary logging by skipping test files outside the specified range and reporting them in the final summary.

View File

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

@ -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

@ -31,6 +31,34 @@ export class TapParser {
constructor(public fileName: string, logger?: TsTestLogger) { constructor(public fileName: string, logger?: TsTestLogger) {
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,16 +29,22 @@ 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() {
// Move previous log files if --logfile option is used
if (this.logger.options.logFile) {
await this.movePreviousLogFiles();
}
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()];
@ -142,7 +149,34 @@ 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;
let timeoutId: NodeJS.Timeout;
const timeoutPromise = new Promise<void>((_resolve, reject) => {
timeoutId = 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
]);
// Clear timeout if test completed successfully
clearTimeout(timeoutId);
} catch (error) {
// Handle timeout error
tapParser.handleTimeout(this.timeoutSeconds);
}
} else {
await tapParser.handleTapProcess(execResultStreaming.childProcess);
}
return tapParser; return tapParser;
} }
@ -167,7 +201,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>
@ -200,9 +234,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
@ -215,12 +250,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);
@ -259,6 +294,33 @@ export class TsTest {
return logStore.join('\n'); return logStore.join('\n');
} }
); );
// Handle timeout if specified
if (this.timeoutSeconds !== null) {
const timeoutMs = this.timeoutSeconds * 1000;
let timeoutId: NodeJS.Timeout;
const timeoutPromise = new Promise<void>((_resolve, reject) => {
timeoutId = setTimeout(() => {
reject(new Error(`Test file timed out after ${this.timeoutSeconds} seconds`));
}, timeoutMs);
});
try {
await Promise.race([
evaluatePromise,
timeoutPromise
]);
// Clear timeout if test completed successfully
clearTimeout(timeoutId);
} 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();
@ -271,4 +333,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

@ -37,7 +37,7 @@ export interface TestSummary {
} }
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;
@ -247,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;
@ -254,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
@ -424,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;
}
} }