import * as plugins from './tstest.plugins.js'; import * as paths from './tstest.paths.js'; import * as logPrefixes from './tstest.logprefixes.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 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) { 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() { // 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(); } 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(','); } const execResultStreaming = await this.smartshellInstance.execStreamingSilent( `tsrun ${fileNameArg}${tsrunOptions}` ); await tapParser.handleTapProcess(execResultStreaming.childProcess); 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 await this.smartbrowserInstance.start(); await 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'); } ); await this.smartbrowserInstance.stop(); await server.stop(); wss.close(); console.log( `${cs('=> ', 'blue')} Stopped ${cs(fileNameArg, 'orange')} chromium instance and server.` ); // lets create the tap parser 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'); try { // Get all files in log directory const files = await plugins.smartfile.fs.listFileTree(logDir, '*.log'); if (files.length === 0) { return; } // Ensure previous directory exists await plugins.smartfile.fs.ensureDir(previousDir); // Move each file to previous directory for (const file of files) { const filename = plugins.path.basename(file); const sourcePath = plugins.path.join(logDir, filename); const destPath = plugins.path.join(previousDir, filename); try { // Read file content and write to new location const content = await plugins.smartfile.fs.toStringSync(sourcePath); await plugins.smartfile.fs.toFs(content, destPath); // Remove original file 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; } } }