import * as plugins from './tstest.plugins.js'; import * as paths from './tstest.paths.js'; import { coloredString as cs } from '@push.rocks/consolecolor'; import { TestDirectory } from './tstest.classes.testdirectory.js'; import { TapCombinator } from './tstest.classes.tap.combinator.js'; import { TapParser } from './tstest.classes.tap.parser.js'; import { TestExecutionMode } from './index.js'; import { TsTestLogger } from './tstest.logging.js'; import type { LogOptions } from './tstest.logging.js'; export class TsTest { public testDir: TestDirectory; public executionMode: TestExecutionMode; public logger: TsTestLogger; public filterTags: string[]; public startFromFile: number | null; public stopAtFile: number | null; public timeoutSeconds: number | null; public smartshellInstance = new plugins.smartshell.Smartshell({ executor: 'bash', pathDirectories: [paths.binDirectory], sourceFilePaths: [], }); public smartbrowserInstance = new plugins.smartbrowser.SmartBrowser(); 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, timeoutSeconds: 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; this.timeoutSeconds = timeoutSeconds; } 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 allFiles = [...testGroups.serial, ...Object.values(testGroups.parallelGroups).flat()]; // Log test discovery - always show full count this.logger.testDiscovery( allFiles.length, this.testDir.testPath, this.executionMode ); const tapCombinator = new TapCombinator(this.logger); // lets create the TapCombinator let fileIndex = 0; // Execute serial tests first for (const fileNameArg of testGroups.serial) { fileIndex++; await this.runSingleTestOrSkip(fileNameArg, fileIndex, allFiles.length, tapCombinator); } // Execute parallel groups sequentially const groupNames = Object.keys(testGroups.parallelGroups).sort(); for (const groupName of groupNames) { const groupFiles = testGroups.parallelGroups[groupName]; if (groupFiles.length > 0) { this.logger.sectionStart(`Parallel Group: ${groupName}`); // Run all tests in this group in parallel const parallelPromises = groupFiles.map(async (fileNameArg) => { fileIndex++; return this.runSingleTestOrSkip(fileNameArg, fileIndex, allFiles.length, tapCombinator); }); await Promise.all(parallelPromises); this.logger.sectionEnd(); } } tapCombinator.evaluate(); } public async runWatch(ignorePatterns: string[] = []) { const smartchokInstance = new plugins.smartchok.Smartchok([this.testDir.cwd]); console.clear(); this.logger.watchModeStart(); // Initial run await this.run(); // Set up file watcher const fileChanges = new Map(); const debounceTime = 300; // 300ms debounce const runTestsAfterChange = async () => { console.clear(); const changedFiles = Array.from(fileChanges.keys()); fileChanges.clear(); this.logger.watchModeRerun(changedFiles); await this.run(); this.logger.watchModeWaiting(); }; // Start watching before subscribing to events await smartchokInstance.start(); // Subscribe to file change events const changeObservable = await smartchokInstance.getObservableFor('change'); const addObservable = await smartchokInstance.getObservableFor('add'); const unlinkObservable = await smartchokInstance.getObservableFor('unlink'); const handleFileChange = (changedPath: string) => { // Skip if path matches ignore patterns if (ignorePatterns.some(pattern => changedPath.includes(pattern))) { return; } // Clear existing timeout for this file if any if (fileChanges.has(changedPath)) { clearTimeout(fileChanges.get(changedPath)); } // Set new timeout for this file const timeout = setTimeout(() => { fileChanges.delete(changedPath); if (fileChanges.size === 0) { runTestsAfterChange(); } }, debounceTime); fileChanges.set(changedPath, timeout); }; // Subscribe to all relevant events changeObservable.subscribe(([path]) => handleFileChange(path)); addObservable.subscribe(([path]) => handleFileChange(path)); unlinkObservable.subscribe(([path]) => handleFileChange(path)); this.logger.watchModeWaiting(); // Handle Ctrl+C to exit gracefully process.on('SIGINT', async () => { this.logger.watchModeStop(); await smartchokInstance.stop(); process.exit(0); }); // Keep the process running await new Promise(() => {}); // This promise never resolves } private async runSingleTestOrSkip(fileNameArg: string, fileIndex: number, totalFiles: number, tapCombinator: TapCombinator) { // Check if this file should be skipped based on range if (this.startFromFile !== null && fileIndex < this.startFromFile) { this.logger.testFileSkipped(fileNameArg, fileIndex, totalFiles, `before start range (${this.startFromFile})`); tapCombinator.addSkippedFile(fileNameArg); return; } if (this.stopAtFile !== null && fileIndex > this.stopAtFile) { this.logger.testFileSkipped(fileNameArg, fileIndex, totalFiles, `after stop range (${this.stopAtFile})`); tapCombinator.addSkippedFile(fileNameArg); return; } // File is in range, run it await this.runSingleTest(fileNameArg, fileIndex, totalFiles, tapCombinator); } private async runSingleTest(fileNameArg: string, fileIndex: number, totalFiles: number, tapCombinator: TapCombinator) { switch (true) { case process.env.CI && fileNameArg.includes('.nonci.'): this.logger.tapOutput(`Skipping ${fileNameArg} - marked as non-CI`); break; case fileNameArg.endsWith('.browser.ts') || fileNameArg.endsWith('.browser.nonci.ts'): const tapParserBrowser = await this.runInChrome(fileNameArg, fileIndex, totalFiles); tapCombinator.addTapParser(tapParserBrowser); break; case fileNameArg.endsWith('.both.ts') || fileNameArg.endsWith('.both.nonci.ts'): this.logger.sectionStart('Part 1: Chrome'); const tapParserBothBrowser = await this.runInChrome(fileNameArg, fileIndex, totalFiles); tapCombinator.addTapParser(tapParserBothBrowser); this.logger.sectionEnd(); this.logger.sectionStart('Part 2: Node'); const tapParserBothNode = await this.runInNode(fileNameArg, fileIndex, totalFiles); tapCombinator.addTapParser(tapParserBothNode); this.logger.sectionEnd(); break; default: const tapParserNode = await this.runInNode(fileNameArg, fileIndex, totalFiles); tapCombinator.addTapParser(tapParserNode); break; } } public async runInNode(fileNameArg: string, index: number, total: number): Promise { this.logger.testFileStart(fileNameArg, 'node.js', index, total); const tapParser = new TapParser(fileNameArg + ':node', this.logger); // tsrun options let tsrunOptions = ''; if (process.argv.includes('--web')) { tsrunOptions += ' --web'; } // Set filter tags as environment variable if (this.filterTags.length > 0) { process.env.TSTEST_FILTER_TAGS = this.filterTags.join(','); } // Check for 00init.ts file in test directory const testDir = plugins.path.dirname(fileNameArg); const initFile = plugins.path.join(testDir, '00init.ts'); let runCommand = `tsrun ${fileNameArg}${tsrunOptions}`; const initFileExists = await plugins.smartfile.fs.fileExists(initFile); // If 00init.ts exists, run it first if (initFileExists) { // Create a temporary loader file that imports both 00init.ts and the test file const absoluteInitFile = plugins.path.resolve(initFile); const absoluteTestFile = plugins.path.resolve(fileNameArg); const loaderContent = ` import '${absoluteInitFile.replace(/\\/g, '/')}'; import '${absoluteTestFile.replace(/\\/g, '/')}'; `; const loaderPath = plugins.path.join(testDir, `.loader_${plugins.path.basename(fileNameArg)}`); await plugins.smartfile.memory.toFs(loaderContent, loaderPath); runCommand = `tsrun ${loaderPath}${tsrunOptions}`; } const execResultStreaming = await this.smartshellInstance.execStreamingSilent(runCommand); // If we created a loader file, clean it up after test execution if (initFileExists) { const loaderPath = plugins.path.join(testDir, `.loader_${plugins.path.basename(fileNameArg)}`); const cleanup = () => { try { if (plugins.smartfile.fs.fileExistsSync(loaderPath)) { plugins.smartfile.fs.removeSync(loaderPath); } } catch (e) { // Ignore cleanup errors } }; execResultStreaming.childProcess.on('exit', cleanup); execResultStreaming.childProcess.on('error', cleanup); } // Start warning timer if no timeout was specified let warningTimer: NodeJS.Timeout | null = null; if (this.timeoutSeconds === null) { warningTimer = setTimeout(() => { console.error(''); console.error(cs('⚠️ WARNING: Test file is running for more than 1 minute', 'orange')); console.error(cs(` File: ${fileNameArg}`, 'orange')); console.error(cs(' Consider using --timeout option to set a timeout for test files.', 'orange')); console.error(cs(' Example: tstest test --timeout=300 (for 5 minutes)', 'orange')); console.error(''); }, 60000); // 1 minute } // Handle timeout if specified if (this.timeoutSeconds !== null) { const timeoutMs = this.timeoutSeconds * 1000; let timeoutId: NodeJS.Timeout; const timeoutPromise = new Promise((_resolve, reject) => { timeoutId = setTimeout(async () => { // Use smartshell's terminate() to kill entire process tree await execResultStreaming.terminate(); 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) { // Clear warning timer if it was set if (warningTimer) { clearTimeout(warningTimer); } // Handle timeout error tapParser.handleTimeout(this.timeoutSeconds); // Ensure entire process tree is killed if still running try { await execResultStreaming.kill(); // This kills the entire process tree with SIGKILL } catch (killError) { // Process tree might already be dead } await tapParser.evaluateFinalResult(); } } else { await tapParser.handleTapProcess(execResultStreaming.childProcess); } // Clear warning timer if it was set if (warningTimer) { clearTimeout(warningTimer); } return tapParser; } public async runInChrome(fileNameArg: string, index: number, total: number): Promise { this.logger.testFileStart(fileNameArg, 'chromium', index, total); // lets get all our paths sorted const tsbundleCacheDirPath = plugins.path.join(paths.cwd, './.nogit/tstest_cache'); const bundleFileName = fileNameArg.replace('/', '__') + '.js'; const bundleFilePath = plugins.path.join(tsbundleCacheDirPath, bundleFileName); // lets bundle the test await plugins.smartfile.fs.ensureEmptyDir(tsbundleCacheDirPath); await this.tsbundleInstance.build(process.cwd(), fileNameArg, bundleFilePath, { bundler: 'esbuild', }); // lets create a server const server = new plugins.typedserver.servertools.Server({ cors: true, port: 3007, }); server.addRoute( '/test', new plugins.typedserver.servertools.Handler('GET', async (_req, res) => { res.type('.html'); res.write(` `); res.end(); }) ); server.addRoute('*', new plugins.typedserver.servertools.HandlerStatic(tsbundleCacheDirPath)); await server.start(); // lets handle realtime comms const tapParser = new TapParser(fileNameArg + ':chrome', this.logger); const wss = new plugins.ws.WebSocketServer({ port: 8080 }); wss.on('connection', (ws) => { ws.on('message', (message) => { const messageStr = message.toString(); if (messageStr.startsWith('console:')) { const [, level, ...messageParts] = messageStr.split(':'); this.logger.browserConsole(messageParts.join(':'), level); } else { tapParser.handleTapLog(messageStr); } }); }); // lets do the browser bit with timeout handling await this.smartbrowserInstance.start(); const evaluatePromise = this.smartbrowserInstance.evaluateOnPage( `http://localhost:3007/test?bundleName=${bundleFileName}`, async () => { // lets enable real time comms const ws = new WebSocket('ws://localhost:8080'); await new Promise((resolve) => (ws.onopen = resolve)); // Ensure this function is declared with 'async' const logStore = []; const originalLog = console.log; const originalError = console.error; // Override console methods to capture the logs console.log = (...args: any[]) => { logStore.push(args.join(' ')); ws.send(args.join(' ')); originalLog(...args); }; console.error = (...args: any[]) => { logStore.push(args.join(' ')); ws.send(args.join(' ')); originalError(...args); }; const bundleName = new URLSearchParams(window.location.search).get('bundleName'); originalLog(`::TSTEST IN CHROMIUM:: Relevant Script name is: ${bundleName}`); try { // Dynamically import the test module const testModule = await import(`/${bundleName}`); if (testModule && testModule.default && testModule.default instanceof Promise) { // Execute the exported test function await testModule.default; } else if (testModule && testModule.default && typeof testModule.default.then === 'function') { console.log('!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!'); console.log('Test module default export is just promiselike: Something might be messing with your Promise implementation.'); console.log('!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!'); await testModule.default; } else if (globalThis.tapPromise && typeof globalThis.tapPromise.then === 'function') { console.log('!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!'); console.log('Using globalThis.tapPromise'); console.log('!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!'); await testModule.default; } else { console.error('!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!'); console.error('Test module does not export a default promise.'); console.error('!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!'); console.log(`We got: ${JSON.stringify(testModule)}`); } } catch (err) { console.error(err); } return logStore.join('\n'); } ); // Start warning timer if no timeout was specified let warningTimer: NodeJS.Timeout | null = null; if (this.timeoutSeconds === null) { warningTimer = setTimeout(() => { console.error(''); console.error(cs('⚠️ WARNING: Test file is running for more than 1 minute', 'orange')); console.error(cs(` File: ${fileNameArg}`, 'orange')); console.error(cs(' Consider using --timeout option to set a timeout for test files.', 'orange')); console.error(cs(' Example: tstest test --timeout=300 (for 5 minutes)', 'orange')); console.error(''); }, 60000); // 1 minute } // Handle timeout if specified if (this.timeoutSeconds !== null) { const timeoutMs = this.timeoutSeconds * 1000; let timeoutId: NodeJS.Timeout; const timeoutPromise = new Promise((_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) { // Clear warning timer if it was set if (warningTimer) { clearTimeout(warningTimer); } // Handle timeout error tapParser.handleTimeout(this.timeoutSeconds); } } else { await evaluatePromise; } // Clear warning timer if it was set if (warningTimer) { clearTimeout(warningTimer); } // Always clean up resources, even on timeout try { await this.smartbrowserInstance.stop(); } catch (error) { // Browser might already be stopped } try { await server.stop(); } catch (error) { // Server might already be stopped } try { wss.close(); } catch (error) { // WebSocket server might already be closed } console.log( `${cs('=> ', 'blue')} Stopped ${cs(fileNameArg, 'orange')} chromium instance and server.` ); // Always evaluate final result (handleTimeout just sets up the test state) await tapParser.evaluateFinalResult(); return tapParser; } 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 (plugins.smartfile.fs.isDirectorySync(errDir)) { plugins.smartfile.fs.removeSync(errDir); } if (plugins.smartfile.fs.isDirectorySync(diffDir)) { plugins.smartfile.fs.removeSync(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: string) => !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; } } }