feat(tstest): Enhance tstest with fluent API, suite grouping, tag filtering, fixture & snapshot testing, and parallel execution improvements
This commit is contained in:
		| @@ -2,7 +2,137 @@ import * as plugins from './tapbundle.plugins.js'; | ||||
|  | ||||
| import { type IPreTaskFunction, PreTask } from './tapbundle.classes.pretask.js'; | ||||
| import { TapTest, type ITestFunction } from './tapbundle.classes.taptest.js'; | ||||
|  | ||||
| export interface ITestSuite { | ||||
|   description: string; | ||||
|   tests: TapTest<any>[]; | ||||
|   beforeEach?: ITestFunction<any>; | ||||
|   afterEach?: ITestFunction<any>; | ||||
|   parent?: ITestSuite; | ||||
|   children: ITestSuite[]; | ||||
| } | ||||
|  | ||||
| class TestBuilder<T> { | ||||
|   private _tap: Tap<T>; | ||||
|   private _tags: string[] = []; | ||||
|   private _priority: 'high' | 'medium' | 'low' = 'medium'; | ||||
|   private _retryCount?: number; | ||||
|   private _timeoutMs?: number; | ||||
|    | ||||
|   constructor(tap: Tap<T>) { | ||||
|     this._tap = tap; | ||||
|   } | ||||
|    | ||||
|   tags(...tags: string[]) { | ||||
|     this._tags = tags; | ||||
|     return this; | ||||
|   } | ||||
|    | ||||
|   priority(level: 'high' | 'medium' | 'low') { | ||||
|     this._priority = level; | ||||
|     return this; | ||||
|   } | ||||
|    | ||||
|   retry(count: number) { | ||||
|     this._retryCount = count; | ||||
|     return this; | ||||
|   } | ||||
|    | ||||
|   timeout(ms: number) { | ||||
|     this._timeoutMs = ms; | ||||
|     return this; | ||||
|   } | ||||
|    | ||||
|   test(description: string, testFunction: ITestFunction<T>) { | ||||
|     const test = this._tap.test(description, testFunction, 'normal'); | ||||
|      | ||||
|     // Apply settings to the test | ||||
|     if (this._tags.length > 0) { | ||||
|       test.tags = this._tags; | ||||
|     } | ||||
|     test.priority = this._priority; | ||||
|      | ||||
|     if (this._retryCount !== undefined) { | ||||
|       test.tapTools.retry(this._retryCount); | ||||
|     } | ||||
|     if (this._timeoutMs !== undefined) { | ||||
|       test.timeoutMs = this._timeoutMs; | ||||
|     } | ||||
|      | ||||
|     return test; | ||||
|   } | ||||
|    | ||||
|   testOnly(description: string, testFunction: ITestFunction<T>) { | ||||
|     const test = this._tap.test(description, testFunction, 'only'); | ||||
|      | ||||
|     // Apply settings to the test | ||||
|     if (this._tags.length > 0) { | ||||
|       test.tags = this._tags; | ||||
|     } | ||||
|     test.priority = this._priority; | ||||
|      | ||||
|     if (this._retryCount !== undefined) { | ||||
|       test.tapTools.retry(this._retryCount); | ||||
|     } | ||||
|     if (this._timeoutMs !== undefined) { | ||||
|       test.timeoutMs = this._timeoutMs; | ||||
|     } | ||||
|      | ||||
|     return test; | ||||
|   } | ||||
|    | ||||
|   testSkip(description: string, testFunction: ITestFunction<T>) { | ||||
|     const test = this._tap.test(description, testFunction, 'skip'); | ||||
|      | ||||
|     // Apply settings to the test | ||||
|     if (this._tags.length > 0) { | ||||
|       test.tags = this._tags; | ||||
|     } | ||||
|     test.priority = this._priority; | ||||
|      | ||||
|     if (this._retryCount !== undefined) { | ||||
|       test.tapTools.retry(this._retryCount); | ||||
|     } | ||||
|     if (this._timeoutMs !== undefined) { | ||||
|       test.timeoutMs = this._timeoutMs; | ||||
|     } | ||||
|      | ||||
|     return test; | ||||
|   } | ||||
| } | ||||
|  | ||||
| export class Tap<T> { | ||||
|   private _skipCount = 0; | ||||
|   private _filterTags: string[] = []; | ||||
|    | ||||
|   constructor() { | ||||
|     // Get filter tags from environment | ||||
|     if (typeof process !== 'undefined' && process.env && process.env.TSTEST_FILTER_TAGS) { | ||||
|       this._filterTags = process.env.TSTEST_FILTER_TAGS.split(','); | ||||
|     } | ||||
|   } | ||||
|    | ||||
|   // Fluent test builder | ||||
|   public tags(...tags: string[]) { | ||||
|     const builder = new TestBuilder<T>(this); | ||||
|     return builder.tags(...tags); | ||||
|   } | ||||
|    | ||||
|   public priority(level: 'high' | 'medium' | 'low') { | ||||
|     const builder = new TestBuilder<T>(this); | ||||
|     return builder.priority(level); | ||||
|   } | ||||
|    | ||||
|   public retry(count: number) { | ||||
|     const builder = new TestBuilder<T>(this); | ||||
|     return builder.retry(count); | ||||
|   } | ||||
|    | ||||
|   public timeout(ms: number) { | ||||
|     const builder = new TestBuilder<T>(this); | ||||
|     return builder.timeout(ms); | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * skips a test | ||||
|    * tests marked with tap.skip.test() are never executed | ||||
| @@ -10,9 +140,11 @@ export class Tap<T> { | ||||
|   public skip = { | ||||
|     test: (descriptionArg: string, functionArg: ITestFunction<T>) => { | ||||
|       console.log(`skipped test: ${descriptionArg}`); | ||||
|       this._skipCount++; | ||||
|     }, | ||||
|     testParallel: (descriptionArg: string, functionArg: ITestFunction<T>) => { | ||||
|       console.log(`skipped test: ${descriptionArg}`); | ||||
|       this._skipCount++; | ||||
|     }, | ||||
|   }; | ||||
|  | ||||
| @@ -28,6 +160,8 @@ export class Tap<T> { | ||||
|   private _tapPreTasks: PreTask[] = []; | ||||
|   private _tapTests: TapTest<any>[] = []; | ||||
|   private _tapTestsOnly: TapTest<any>[] = []; | ||||
|   private _currentSuite: ITestSuite | null = null; | ||||
|   private _rootSuites: ITestSuite[] = []; | ||||
|  | ||||
|   /** | ||||
|    * Normal test function, will run one by one | ||||
| @@ -37,17 +171,26 @@ export class Tap<T> { | ||||
|   public test( | ||||
|     testDescription: string, | ||||
|     testFunction: ITestFunction<T>, | ||||
|     modeArg: 'normal' | 'only' | 'skip' = 'normal', | ||||
|     modeArg: 'normal' | 'only' | 'skip' = 'normal' | ||||
|   ): TapTest<T> { | ||||
|     const localTest = new TapTest<T>({ | ||||
|       description: testDescription, | ||||
|       testFunction, | ||||
|       parallel: false, | ||||
|     }); | ||||
|     if (modeArg === 'normal') { | ||||
|       this._tapTests.push(localTest); | ||||
|     } else if (modeArg === 'only') { | ||||
|       this._tapTestsOnly.push(localTest); | ||||
|      | ||||
|     // No options applied here - use the fluent builder syntax instead | ||||
|      | ||||
|     // If we're in a suite, add test to the suite | ||||
|     if (this._currentSuite) { | ||||
|       this._currentSuite.tests.push(localTest); | ||||
|     } else { | ||||
|       // Otherwise add to global test list | ||||
|       if (modeArg === 'normal') { | ||||
|         this._tapTests.push(localTest); | ||||
|       } else if (modeArg === 'only') { | ||||
|         this._tapTestsOnly.push(localTest); | ||||
|       } | ||||
|     } | ||||
|     return localTest; | ||||
|   } | ||||
| @@ -62,32 +205,109 @@ export class Tap<T> { | ||||
|    * @param testFunction - A Function that returns a Promise and resolves or rejects | ||||
|    */ | ||||
|   public testParallel(testDescription: string, testFunction: ITestFunction<T>) { | ||||
|     this._tapTests.push( | ||||
|       new TapTest({ | ||||
|         description: testDescription, | ||||
|         testFunction, | ||||
|         parallel: true, | ||||
|       }), | ||||
|     ); | ||||
|     const localTest = new TapTest({ | ||||
|       description: testDescription, | ||||
|       testFunction, | ||||
|       parallel: true, | ||||
|     }); | ||||
|      | ||||
|     if (this._currentSuite) { | ||||
|       this._currentSuite.tests.push(localTest); | ||||
|     } else { | ||||
|       this._tapTests.push(localTest); | ||||
|     } | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Create a test suite for grouping related tests | ||||
|    */ | ||||
|   public describe(description: string, suiteFunction: () => void) { | ||||
|     const suite: ITestSuite = { | ||||
|       description, | ||||
|       tests: [], | ||||
|       children: [], | ||||
|       parent: this._currentSuite, | ||||
|     }; | ||||
|      | ||||
|     // Add to parent or root | ||||
|     if (this._currentSuite) { | ||||
|       this._currentSuite.children.push(suite); | ||||
|     } else { | ||||
|       this._rootSuites.push(suite); | ||||
|     } | ||||
|      | ||||
|     // Execute suite function in context | ||||
|     const previousSuite = this._currentSuite; | ||||
|     this._currentSuite = suite; | ||||
|     try { | ||||
|       suiteFunction(); | ||||
|     } finally { | ||||
|       this._currentSuite = previousSuite; | ||||
|     } | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Set up a function to run before each test in the current suite | ||||
|    */ | ||||
|   public beforeEach(setupFunction: ITestFunction<any>) { | ||||
|     if (this._currentSuite) { | ||||
|       this._currentSuite.beforeEach = setupFunction; | ||||
|     } else { | ||||
|       throw new Error('beforeEach can only be used inside a describe block'); | ||||
|     } | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Set up a function to run after each test in the current suite | ||||
|    */ | ||||
|   public afterEach(teardownFunction: ITestFunction<any>) { | ||||
|     if (this._currentSuite) { | ||||
|       this._currentSuite.afterEach = teardownFunction; | ||||
|     } else { | ||||
|       throw new Error('afterEach can only be used inside a describe block'); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * collect all tests from suites | ||||
|    */ | ||||
|   private _collectTests(suite: ITestSuite, tests: TapTest<any>[] = []): TapTest<any>[] { | ||||
|     tests.push(...suite.tests); | ||||
|     for (const childSuite of suite.children) { | ||||
|       this._collectTests(childSuite, tests); | ||||
|     } | ||||
|     return tests; | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * starts the test evaluation | ||||
|    */ | ||||
|   public async start(optionsArg?: { throwOnError: boolean }) { | ||||
|     // lets set the tapbundle promise | ||||
|     const smartenvInstance = new plugins.smartenv.Smartenv(); | ||||
|     const globalPromise = plugins.smartpromise.defer(); | ||||
|     smartenvInstance.isBrowser | ||||
|       ? ((globalThis as any).tapbundleDeferred = plugins.smartpromise.defer()) | ||||
|       ? ((globalThis as any).tapbundleDeferred = globalPromise) | ||||
|       : null; | ||||
|     // Also set tapPromise for backwards compatibility | ||||
|     smartenvInstance.isBrowser | ||||
|       ? ((globalThis as any).tapPromise = globalPromise.promise) | ||||
|       : null; | ||||
|      | ||||
|     // Path helpers will be initialized by the Node.js environment if available | ||||
|  | ||||
|     // lets continue with running the tests | ||||
|     const promiseArray: Array<Promise<any>> = []; | ||||
|      | ||||
|     // Collect all tests including those in suites | ||||
|     let allTests: TapTest<any>[] = [...this._tapTests]; | ||||
|     for (const suite of this._rootSuites) { | ||||
|       this._collectTests(suite, allTests); | ||||
|     } | ||||
|  | ||||
|     // safeguard against empty test array | ||||
|     if (this._tapTests.length === 0) { | ||||
|     if (allTests.length === 0 && this._tapTestsOnly.length === 0) { | ||||
|       console.log('no tests specified. Ending here!'); | ||||
|       // TODO: throw proper error | ||||
|       return; | ||||
|     } | ||||
|  | ||||
| @@ -96,7 +316,19 @@ export class Tap<T> { | ||||
|     if (this._tapTestsOnly.length > 0) { | ||||
|       concerningTests = this._tapTestsOnly; | ||||
|     } else { | ||||
|       concerningTests = this._tapTests; | ||||
|       concerningTests = allTests; | ||||
|     } | ||||
|      | ||||
|     // Filter tests by tags if specified | ||||
|     if (this._filterTags.length > 0) { | ||||
|       concerningTests = concerningTests.filter(test => { | ||||
|         // Skip tests without tags when filtering is active | ||||
|         if (!test.tags || test.tags.length === 0) { | ||||
|           return false; | ||||
|         } | ||||
|         // Check if test has any of the filter tags | ||||
|         return test.tags.some(tag => this._filterTags.includes(tag)); | ||||
|       }); | ||||
|     } | ||||
|  | ||||
|     // lets run the pretasks | ||||
| @@ -104,16 +336,43 @@ export class Tap<T> { | ||||
|       await preTask.run(); | ||||
|     } | ||||
|  | ||||
|     // Count actual tests that will be run | ||||
|     console.log(`1..${concerningTests.length}`); | ||||
|     for (let testKey = 0; testKey < concerningTests.length; testKey++) { | ||||
|       const currentTest = concerningTests[testKey]; | ||||
|       const testPromise = currentTest.run(testKey); | ||||
|      | ||||
|     // Run tests from suites with lifecycle hooks | ||||
|     let testKey = 0; | ||||
|      | ||||
|     // Run root suite tests with lifecycle hooks | ||||
|     if (this._rootSuites.length > 0) { | ||||
|       await this._runSuite(null, this._rootSuites, promiseArray, { testKey }); | ||||
|       // Update testKey after running suite tests | ||||
|       for (const suite of this._rootSuites) { | ||||
|         const suiteTests = this._collectTests(suite); | ||||
|         testKey += suiteTests.length; | ||||
|       } | ||||
|     } | ||||
|      | ||||
|     // Run non-suite tests (tests added directly without describe) | ||||
|     const nonSuiteTests = concerningTests.filter(test => { | ||||
|       // Check if test is not in any suite | ||||
|       for (const suite of this._rootSuites) { | ||||
|         const suiteTests = this._collectTests(suite); | ||||
|         if (suiteTests.includes(test)) { | ||||
|           return false; | ||||
|         } | ||||
|       } | ||||
|       return true; | ||||
|     }); | ||||
|      | ||||
|     for (const currentTest of nonSuiteTests) { | ||||
|       const testPromise = currentTest.run(testKey++); | ||||
|       if (currentTest.parallel) { | ||||
|         promiseArray.push(testPromise); | ||||
|       } else { | ||||
|         await testPromise; | ||||
|       } | ||||
|     } | ||||
|      | ||||
|     await Promise.all(promiseArray); | ||||
|  | ||||
|     // when tests have been run and all promises are fullfilled | ||||
| @@ -121,7 +380,7 @@ export class Tap<T> { | ||||
|     const executionNotes: string[] = []; | ||||
|     // collect failed tests | ||||
|     for (const tapTest of concerningTests) { | ||||
|       if (tapTest.status !== 'success') { | ||||
|       if (tapTest.status !== 'success' && tapTest.status !== 'skipped') { | ||||
|         failReasons.push( | ||||
|           `Test ${tapTest.testKey + 1} failed with status ${tapTest.status}:\n` + | ||||
|             `|| ${tapTest.description}\n` + | ||||
| @@ -136,21 +395,86 @@ export class Tap<T> { | ||||
|     } | ||||
|  | ||||
|     if (optionsArg && optionsArg.throwOnError && failReasons.length > 0) { | ||||
|       if (!smartenvInstance.isBrowser) process.exit(1); | ||||
|       if (!smartenvInstance.isBrowser && typeof process !== 'undefined') process.exit(1); | ||||
|     } | ||||
|     if (smartenvInstance.isBrowser) { | ||||
|       (globalThis as any).tapbundleDeferred.resolve(); | ||||
|       globalPromise.resolve(); | ||||
|     } | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Run tests in a suite with lifecycle hooks | ||||
|    */ | ||||
|   private async _runSuite( | ||||
|     parentSuite: ITestSuite | null, | ||||
|     suites: ITestSuite[], | ||||
|     promiseArray: Promise<any>[], | ||||
|     context: { testKey: number } | ||||
|   ) { | ||||
|     for (const suite of suites) { | ||||
|       // Run beforeEach from parent suites | ||||
|       const beforeEachFunctions: ITestFunction<any>[] = []; | ||||
|       let currentSuite: ITestSuite | null = suite; | ||||
|       while (currentSuite) { | ||||
|         if (currentSuite.beforeEach) { | ||||
|           beforeEachFunctions.unshift(currentSuite.beforeEach); | ||||
|         } | ||||
|         currentSuite = currentSuite.parent || null; | ||||
|       } | ||||
|        | ||||
|       // Run tests in this suite | ||||
|       for (const test of suite.tests) { | ||||
|         // Create wrapper test function that includes lifecycle hooks | ||||
|         const originalFunction = test.testFunction; | ||||
|         test.testFunction = async (tapTools) => { | ||||
|           // Run all beforeEach hooks | ||||
|           for (const beforeEach of beforeEachFunctions) { | ||||
|             await beforeEach(tapTools); | ||||
|           } | ||||
|            | ||||
|           // Run the actual test | ||||
|           const result = await originalFunction(tapTools); | ||||
|            | ||||
|           // Run afterEach hooks in reverse order | ||||
|           const afterEachFunctions: ITestFunction<any>[] = []; | ||||
|           currentSuite = suite; | ||||
|           while (currentSuite) { | ||||
|             if (currentSuite.afterEach) { | ||||
|               afterEachFunctions.push(currentSuite.afterEach); | ||||
|             } | ||||
|             currentSuite = currentSuite.parent || null; | ||||
|           } | ||||
|            | ||||
|           for (const afterEach of afterEachFunctions) { | ||||
|             await afterEach(tapTools); | ||||
|           } | ||||
|            | ||||
|           return result; | ||||
|         }; | ||||
|          | ||||
|         const testPromise = test.run(context.testKey++); | ||||
|         if (test.parallel) { | ||||
|           promiseArray.push(testPromise); | ||||
|         } else { | ||||
|           await testPromise; | ||||
|         } | ||||
|       } | ||||
|        | ||||
|       // Recursively run child suites | ||||
|       await this._runSuite(suite, suite.children, promiseArray, context); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   public async stopForcefully(codeArg = 0, directArg = false) { | ||||
|     console.log(`tap stopping forcefully! Code: ${codeArg} / Direct: ${directArg}`); | ||||
|     if (directArg) { | ||||
|       process.exit(codeArg); | ||||
|     } else { | ||||
|       setTimeout(() => { | ||||
|     if (typeof process !== 'undefined') { | ||||
|       if (directArg) { | ||||
|         process.exit(codeArg); | ||||
|       }, 10); | ||||
|       } else { | ||||
|         setTimeout(() => { | ||||
|           process.exit(codeArg); | ||||
|         }, 10); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
| @@ -170,4 +494,4 @@ export class Tap<T> { | ||||
|   } | ||||
| } | ||||
|  | ||||
| export let tap = new Tap(); | ||||
| export const tap = new Tap(); | ||||
|   | ||||
		Reference in New Issue
	
	Block a user