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'; import { ProtocolEmitter, type ITestEvent } from '../dist_ts_tapbundle_protocol/index.js'; import type { ITapSettings } from './tapbundle.interfaces.js'; import { SettingsManager } from './tapbundle.classes.settingsmanager.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 protocolEmitter = new ProtocolEmitter(); private settingsManager = new SettingsManager(); 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) => { const skippedTest = this.test(descriptionArg, functionArg, 'skip'); return skippedTest; }, testParallel: (descriptionArg: string, functionArg: ITestFunction) => { const skippedTest = new TapTest({ description: descriptionArg, testFunction: functionArg, parallel: true, }); // Mark as skip mode skippedTest.tapTools.markAsSkipped('Marked as skip'); // Add to appropriate test list if (this._currentSuite) { this._currentSuite.tests.push(skippedTest); } else { this._tapTests.push(skippedTest); } return skippedTest; }, }; /** * only executes tests marked as ONLY */ public only = { test: (descriptionArg: string, testFunctionArg: ITestFunction) => { return this.test(descriptionArg, testFunctionArg, 'only'); }, testParallel: (descriptionArg: string, testFunctionArg: ITestFunction) => { const onlyTest = new TapTest({ description: descriptionArg, testFunction: testFunctionArg, parallel: true, }); // Add to only tests list this._tapTestsOnly.push(onlyTest); return onlyTest; }, }; /** * mark a test as todo (not yet implemented) */ public todo = { test: (descriptionArg: string, functionArg?: ITestFunction) => { const defaultFunc = (async () => {}) as ITestFunction; const todoTest = new TapTest({ description: descriptionArg, testFunction: functionArg || defaultFunc, parallel: false, }); // Mark as todo todoTest.tapTools.todo('Marked as todo'); // Add to appropriate test list if (this._currentSuite) { this._currentSuite.tests.push(todoTest); } else { this._tapTests.push(todoTest); } return todoTest; }, testParallel: (descriptionArg: string, functionArg?: ITestFunction) => { const defaultFunc = (async () => {}) as ITestFunction; const todoTest = new TapTest({ description: descriptionArg, testFunction: functionArg || defaultFunc, parallel: true, }); // Mark as todo todoTest.tapTools.todo('Marked as todo'); // Add to appropriate test list if (this._currentSuite) { this._currentSuite.tests.push(todoTest); } else { this._tapTests.push(todoTest); } return todoTest; }, }; private _tapPreTasks: PreTask[] = []; private _tapTests: TapTest[] = []; private _tapTestsOnly: TapTest[] = []; private _currentSuite: ITestSuite | null = null; private _rootSuites: ITestSuite[] = []; /** * Configure global test settings */ public settings(settings: ITapSettings): this { this.settingsManager.setGlobalSettings(settings); return this; } /** * Get current test settings */ public getSettings(): ITapSettings { return this.settingsManager.getSettings(); } /** * 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, }); // Apply default settings from settings manager const settings = this.settingsManager.getSettings(); if (settings.timeout !== undefined) { localTest.timeoutMs = settings.timeout; } if (settings.retries !== undefined) { localTest.tapTools.retry(settings.retries); } // Handle skip mode if (modeArg === 'skip') { localTest.tapTools.markAsSkipped('Marked as skip'); } // 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' || modeArg === 'skip') { 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, }); // Apply default settings from settings manager const settings = this.settingsManager.getSettings(); if (settings.timeout !== undefined) { localTest.timeoutMs = settings.timeout; } if (settings.retries !== undefined) { localTest.tapTools.retry(settings.retries); } 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(); } // Emit protocol header and TAP version console.log(this.protocolEmitter.emitProtocolHeader()); console.log(this.protocolEmitter.emitTapVersion(13)); // Emit test plan const plan = { start: 1, end: concerningTests.length }; console.log(this.protocolEmitter.emitPlan(plan)); // Run global beforeAll hook if configured const settings = this.settingsManager.getSettings(); if (settings.beforeAll) { try { await settings.beforeAll(); } catch (error) { console.error('Error in beforeAll hook:', error); throw error; } } // 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) { // Wrap test function with global lifecycle hooks const originalFunction = currentTest.testFunction; const testName = currentTest.description; currentTest.testFunction = async (tapTools) => { // Run global beforeEach if configured if (settings.beforeEach) { await settings.beforeEach(testName); } // Run the actual test let testPassed = true; let result: any; try { result = await originalFunction(tapTools); } catch (error) { testPassed = false; throw error; } finally { // Run global afterEach if configured if (settings.afterEach) { await settings.afterEach(testName, testPassed); } } return result; }; 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); } // Run global afterAll hook if configured if (settings.afterAll) { try { await settings.afterAll(); } catch (error) { console.error('Error in afterAll hook:', error); // Don't throw here, we want to complete the test run } } if (optionsArg && optionsArg.throwOnError && failReasons.length > 0) { if (!smartenvInstance.isBrowser && typeof process !== 'undefined') process.exit(1); } if (smartenvInstance.isBrowser) { globalPromise.resolve(); } } /** * Emit an event */ private emitEvent(event: ITestEvent) { console.log(this.protocolEmitter.emitEvent(event)); } /** * 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) { // Emit suite:started event this.emitEvent({ eventType: 'suite:started', timestamp: Date.now(), data: { suiteName: suite.description } }); // 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; const testName = test.description; test.testFunction = async (tapTools) => { // Run global beforeEach if configured const settings = this.settingsManager.getSettings(); if (settings.beforeEach) { await settings.beforeEach(testName); } // Run all suite beforeEach hooks for (const beforeEach of beforeEachFunctions) { await beforeEach(tapTools); } // Run the actual test let testPassed = true; let result: any; try { result = await originalFunction(tapTools); } catch (error) { testPassed = false; throw error; } finally { // 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); } // Run global afterEach if configured if (settings.afterEach) { await settings.afterEach(testName, testPassed); } } 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); // Emit suite:completed event this.emitEvent({ eventType: 'suite:completed', timestamp: Date.now(), data: { suiteName: suite.description } }); } } 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();