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() { const testGroups = await this.testDir.getTestFileGroups(); let allFiles = [...testGroups.serial, ...Object.values(testGroups.parallelGroups).flat()]; // Apply file range filtering if specified if (this.startFromFile !== null || this.stopAtFile !== null) { const startIndex = this.startFromFile ? this.startFromFile - 1 : 0; // Convert to 0-based index const endIndex = this.stopAtFile ? this.stopAtFile : allFiles.length; allFiles = allFiles.slice(startIndex, endIndex); // Filter the serial and parallel groups based on remaining files testGroups.serial = testGroups.serial.filter(file => allFiles.includes(file)); Object.keys(testGroups.parallelGroups).forEach(groupName => { testGroups.parallelGroups[groupName] = testGroups.parallelGroups[groupName].filter(file => allFiles.includes(file)); // Remove empty groups if (testGroups.parallelGroups[groupName].length === 0) { delete testGroups.parallelGroups[groupName]; } }); } // Log test discovery 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.runSingleTest(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.runSingleTest(fileNameArg, fileIndex, allFiles.length, tapCombinator); }); await Promise.all(parallelPromises); this.logger.sectionEnd(); } } tapCombinator.evaluate(); } 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(); const evaluation = 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) => { logStore.push(args.join(' ')); ws.send(args.join(' ')); originalLog(...args); }; console.error = (...args) => { 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() {} }