From 27c950c1a11b4fad824275e72f9732f235b8e4a5 Mon Sep 17 00:00:00 2001
From: Philipp Kunz <code@philkunz.com>
Date: Fri, 23 May 2025 23:05:38 +0000
Subject: [PATCH] feat(cli): Add --startFrom and --stopAt options to filter
 test files by range

---
 changelog.md                |  8 ++++++
 ts/00_commitinfo_data.ts    |  2 +-
 ts/index.ts                 | 55 ++++++++++++++++++++++++++++++++-----
 ts/tstest.classes.tstest.ts | 25 +++++++++++++++--
 4 files changed, 80 insertions(+), 10 deletions(-)

diff --git a/changelog.md b/changelog.md
index ea29d1d..d8f6b52 100644
--- a/changelog.md
+++ b/changelog.md
@@ -1,5 +1,13 @@
 # Changelog
 
+## 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.
 
diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts
index 05a4204..d1459a9 100644
--- a/ts/00_commitinfo_data.ts
+++ b/ts/00_commitinfo_data.ts
@@ -3,6 +3,6 @@
  */
 export const commitinfo = {
   name: '@git.zone/tstest',
-  version: '1.9.4',
+  version: '1.10.0',
   description: 'a test utility to run tests that match test/**/*.ts'
 }
diff --git a/ts/index.ts b/ts/index.ts
index fbe65f7..7d2f5f4 100644
--- a/ts/index.ts
+++ b/ts/index.ts
@@ -13,6 +13,8 @@ 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;
   
   // Parse options
   for (let i = 0; i < args.length; i++) {
@@ -42,6 +44,32 @@ 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;
       default:
         if (!arg.startsWith('-')) {
           testPath = arg;
@@ -49,16 +77,24 @@ 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');
     process.exit(1);
   }
   
@@ -73,6 +109,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);
   await tsTestInstance.run();
 };
+
+// Execute CLI when this file is run directly
+if (import.meta.url === `file://${process.argv[1]}`) {
+  runCli();
+}
diff --git a/ts/tstest.classes.tstest.ts b/ts/tstest.classes.tstest.ts
index 9ed8a94..b0221e6 100644
--- a/ts/tstest.classes.tstest.ts
+++ b/ts/tstest.classes.tstest.ts
@@ -16,6 +16,8 @@ export class TsTest {
   public executionMode: TestExecutionMode;
   public logger: TsTestLogger;
   public filterTags: string[];
+  public startFromFile: number | null;
+  public stopAtFile: number | null;
 
   public smartshellInstance = new plugins.smartshell.Smartshell({
     executor: 'bash',
@@ -26,16 +28,35 @@ 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) {
     this.executionMode = executionModeArg;
     this.testDir = new TestDirectory(cwdArg, testPathArg, executionModeArg);
     this.logger = new TsTestLogger(logOptions);
     this.filterTags = tags;
+    this.startFromFile = startFromFile;
+    this.stopAtFile = stopAtFile;
   }
 
   async run() {
     const testGroups = await this.testDir.getTestFileGroups();
-    const allFiles = [...testGroups.serial, ...Object.values(testGroups.parallelGroups).flat()];
+    let allFiles = [...testGroups.serial, ...Object.values(testGroups.parallelGroups).flat()];
+    
+    // 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
     this.logger.testDiscovery(