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[]; beforeEach?: ITestFunction; afterEach?: ITestFunction; parent?: ITestSuite; children: ITestSuite[]; } class TestBuilder { private _tap: Tap; private _tags: string[] = []; private _priority: 'high' | 'medium' | 'low' = 'medium'; private _retryCount?: number; private _timeoutMs?: number; constructor(tap: Tap) { 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) { 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) { 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) { 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 { 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(this); return builder.tags(...tags); } public priority(level: 'high' | 'medium' | 'low') { const builder = new TestBuilder(this); return builder.priority(level); } public retry(count: number) { const builder = new TestBuilder(this); return builder.retry(count); } public timeout(ms: number) { const builder = new TestBuilder(this); return builder.timeout(ms); } /** * skips a test * tests marked with tap.skip.test() are never executed */ public skip = { test: (descriptionArg: string, functionArg: ITestFunction) => { console.log(`skipped test: ${descriptionArg}`); this._skipCount++; }, testParallel: (descriptionArg: string, functionArg: ITestFunction) => { console.log(`skipped test: ${descriptionArg}`); this._skipCount++; }, }; /** * only executes tests marked as ONLY */ public only = { test: (descriptionArg: string, testFunctionArg: ITestFunction) => { this.test(descriptionArg, testFunctionArg, 'only'); }, }; private _tapPreTasks: PreTask[] = []; private _tapTests: TapTest[] = []; private _tapTestsOnly: TapTest[] = []; private _currentSuite: ITestSuite | null = null; private _rootSuites: ITestSuite[] = []; /** * Normal test function, will run one by one * @param testDescription - A description of what the test does * @param testFunction - A Function that returns a Promise and resolves or rejects */ public test( testDescription: string, testFunction: ITestFunction, modeArg: 'normal' | 'only' | 'skip' = 'normal' ): TapTest { const localTest = new TapTest({ description: testDescription, testFunction, parallel: false, }); // 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; } public preTask(descriptionArg: string, functionArg: IPreTaskFunction) { this._tapPreTasks.push(new PreTask(descriptionArg, functionArg)); } /** * A parallel test that will not be waited for before the next starts. * @param testDescription - A description of what the test does * @param testFunction - A Function that returns a Promise and resolves or rejects */ public testParallel(testDescription: string, testFunction: ITestFunction) { 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) { 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) { 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[] = []): TapTest[] { 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 = 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> = []; // Collect all tests including those in suites let allTests: TapTest[] = [...this._tapTests]; for (const suite of this._rootSuites) { this._collectTests(suite, allTests); } // safeguard against empty test array if (allTests.length === 0 && this._tapTestsOnly.length === 0) { console.log('no tests specified. Ending here!'); return; } // determine which tests to run let concerningTests: TapTest[]; if (this._tapTestsOnly.length > 0) { concerningTests = this._tapTestsOnly; } else { 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 for (const preTask of this._tapPreTasks) { await preTask.run(); } // Count actual tests that will be run console.log(`1..${concerningTests.length}`); // 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 const failReasons: string[] = []; const executionNotes: string[] = []; // collect failed tests for (const tapTest of concerningTests) { if (tapTest.status !== 'success' && tapTest.status !== 'skipped') { failReasons.push( `Test ${tapTest.testKey + 1} failed with status ${tapTest.status}:\n` + `|| ${tapTest.description}\n` + `|| for more information please take a look the logs above`, ); } } // render fail Reasons for (const failReason of failReasons) { console.log(failReason); } if (optionsArg && optionsArg.throwOnError && failReasons.length > 0) { if (!smartenvInstance.isBrowser && typeof process !== 'undefined') process.exit(1); } if (smartenvInstance.isBrowser) { globalPromise.resolve(); } } /** * Run tests in a suite with lifecycle hooks */ private async _runSuite( parentSuite: ITestSuite | null, suites: ITestSuite[], promiseArray: Promise[], context: { testKey: number } ) { for (const suite of suites) { // Run beforeEach from parent suites const beforeEachFunctions: ITestFunction[] = []; 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[] = []; 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 (typeof process !== 'undefined') { if (directArg) { process.exit(codeArg); } else { setTimeout(() => { process.exit(codeArg); }, 10); } } } /** * handle errors */ public threw(err: Error) { console.log(err); } /** * Explicitly fail the current test with a custom message * @param message - The failure message to display */ public fail(message: string = 'Test failed'): never { throw new Error(message); } } export const tap = new Tap();