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'; // Runtime adapters import { parseTestFilename, isDockerTestFile, parseDockerTestFilename } from './tstest.classes.runtime.parser.js'; import { RuntimeAdapterRegistry } from './tstest.classes.runtime.adapter.js'; import { NodeRuntimeAdapter } from './tstest.classes.runtime.node.js'; import { ChromiumRuntimeAdapter } from './tstest.classes.runtime.chromium.js'; import { DenoRuntimeAdapter } from './tstest.classes.runtime.deno.js'; import { BunRuntimeAdapter } from './tstest.classes.runtime.bun.js'; import { DockerRuntimeAdapter } from './tstest.classes.runtime.docker.js'; // Test file directives import { parseDirectivesFromFile, mergeDirectives, directivesToRuntimeOptions, hasDirectives, } from './tstest.classes.testfile.directives.js'; // Before-script support import { loadBeforeScripts, runBeforeScript, type IBeforeScripts } from './tstest.classes.beforescripts.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(); public runtimeRegistry = new RuntimeAdapterRegistry(); public dockerAdapter: DockerRuntimeAdapter | null = null; private beforeScripts: IBeforeScripts | null = null; 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; // Register runtime adapters this.runtimeRegistry.register( new NodeRuntimeAdapter(this.logger, this.smartshellInstance, this.timeoutSeconds, this.filterTags) ); this.runtimeRegistry.register( new ChromiumRuntimeAdapter(this.logger, this.tsbundleInstance, this.smartbrowserInstance, this.timeoutSeconds) ); this.runtimeRegistry.register( new DenoRuntimeAdapter(this.logger, this.smartshellInstance, this.timeoutSeconds, this.filterTags) ); this.runtimeRegistry.register( new BunRuntimeAdapter(this.logger, this.smartshellInstance, this.timeoutSeconds, this.filterTags) ); // Initialize Docker adapter this.dockerAdapter = new DockerRuntimeAdapter( this.logger, this.smartshellInstance, this.timeoutSeconds, cwdArg ); } /** * Check and display available runtimes */ private async checkEnvironment() { const availability = await this.runtimeRegistry.checkAvailability(); this.logger.environmentCheck(availability); return availability; } async run() { // Check and display environment await this.checkEnvironment(); // Move previous log files if --logfile option is used if (this.logger.options.logFile) { await this.movePreviousLogFiles(); } // Load and execute test:before script (runs once per test run) this.beforeScripts = loadBeforeScripts(this.testDir.cwd); if (this.beforeScripts.beforeOnce) { const success = await runBeforeScript( this.smartshellInstance, this.beforeScripts.beforeOnce, 'test:before', this.logger, ); if (!success) { this.logger.error('test:before script failed. Aborting test run.'); process.exit(1); } } 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(); } /** * Clean up all long-lived resources so the Node.js event loop can drain naturally. */ public async cleanup() { this.smartshellInstance.smartexit.deregister(); } public async runWatch(ignorePatterns: string[] = []) { const smartwatchInstance = new plugins.smartwatch.Smartwatch([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 smartwatchInstance.start(); // Subscribe to file change events const changeObservable = await smartwatchInstance.getObservableFor('change'); const addObservable = await smartwatchInstance.getObservableFor('add'); const unlinkObservable = await smartwatchInstance.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 smartwatchInstance.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) { const fileName = plugins.path.basename(fileNameArg); // Check if this is a Docker test file if (isDockerTestFile(fileName)) { return await this.runDockerTest(fileNameArg, fileIndex, totalFiles, tapCombinator); } // Parse the filename to determine runtimes and modifiers (for TypeScript tests) const parsed = parseTestFilename(fileName, { strictUnknownRuntime: false }); // Check for nonci modifier in CI environment if (process.env.CI && parsed.modifiers.includes('nonci')) { this.logger.tapOutput(`Skipping ${fileNameArg} - marked as non-CI`); return; } // Show deprecation warning for legacy naming if (parsed.isLegacy) { console.warn(''); console.warn(cs('⚠️ DEPRECATION WARNING', 'orange')); console.warn(cs(` File: ${fileName}`, 'orange')); console.warn(cs(` Legacy naming detected. Please migrate to new naming convention.`, 'orange')); console.warn(cs(` Suggested: ${fileName.replace('.browser.', '.chromium.').replace('.both.', '.node+chromium.')}`, 'green')); console.warn(cs(` Run: tstest migrate --dry-run`, 'cyan')); console.warn(''); } // Get adapters for the specified runtimes const adapters = this.runtimeRegistry.getAdaptersForRuntimes(parsed.runtimes); if (adapters.length === 0) { this.logger.tapOutput(`Skipping ${fileNameArg} - no runtime adapters available`); return; } // Parse directives from the test file (e.g., // tstest:deno:allowAll) let directives = await parseDirectivesFromFile(fileNameArg); // Also check for directives in 00init.ts const testDir = plugins.path.dirname(fileNameArg); const initFile = plugins.path.join(testDir, '00init.ts'); const initFileExists = await plugins.smartfsInstance.file(initFile).exists(); if (initFileExists) { const initDirectives = await parseDirectivesFromFile(initFile); directives = mergeDirectives(initDirectives, directives); } // Execute tests for each runtime if (adapters.length === 1) { // Single runtime - no sections needed const adapter = adapters[0]; // Run test:before:testfile if defined if (this.beforeScripts?.beforeTestfile) { const success = await runBeforeScript( this.smartshellInstance, this.beforeScripts.beforeTestfile, `test:before:testfile (${fileName})`, this.logger, { TSTEST_FILE: fileNameArg, TSTEST_RUNTIME: adapter.id }, ); if (!success) { this.logger.error(`test:before:testfile failed for ${fileName}. Skipping test file.`); return; } } const options = hasDirectives(directives) ? directivesToRuntimeOptions(directives, adapter.id) : undefined; const tapParser = await adapter.run(fileNameArg, fileIndex, totalFiles, options); tapCombinator.addTapParser(tapParser); } else { // Multiple runtimes - use sections for (let i = 0; i < adapters.length; i++) { const adapter = adapters[i]; this.logger.sectionStart(`Part ${i + 1}: ${adapter.displayName}`); // Run test:before:testfile if defined (runs before each runtime) if (this.beforeScripts?.beforeTestfile) { const success = await runBeforeScript( this.smartshellInstance, this.beforeScripts.beforeTestfile, `test:before:testfile (${fileName} on ${adapter.displayName})`, this.logger, { TSTEST_FILE: fileNameArg, TSTEST_RUNTIME: adapter.id }, ); if (!success) { this.logger.error(`test:before:testfile failed for ${fileName} on ${adapter.displayName}. Skipping.`); this.logger.sectionEnd(); continue; } } const options = hasDirectives(directives) ? directivesToRuntimeOptions(directives, adapter.id) : undefined; const tapParser = await adapter.run(fileNameArg, fileIndex, totalFiles, options); tapCombinator.addTapParser(tapParser); this.logger.sectionEnd(); } } } /** * Execute a Docker test file */ private async runDockerTest( fileNameArg: string, fileIndex: number, totalFiles: number, tapCombinator: TapCombinator ): Promise { if (!this.dockerAdapter) { this.logger.tapOutput(cs('❌ Docker adapter not initialized', 'red')); return; } // Run test:before:testfile if defined if (this.beforeScripts?.beforeTestfile) { const success = await runBeforeScript( this.smartshellInstance, this.beforeScripts.beforeTestfile, `test:before:testfile (${fileNameArg} on Docker)`, this.logger, { TSTEST_FILE: fileNameArg, TSTEST_RUNTIME: 'docker' }, ); if (!success) { this.logger.error(`test:before:testfile failed for ${fileNameArg}. Skipping.`); return; } } try { const tapParser = await this.dockerAdapter.run(fileNameArg, fileIndex, totalFiles); tapCombinator.addTapParser(tapParser); } catch (error) { this.logger.tapOutput(cs(`❌ Docker test failed: ${error.message}`, 'red')); } } 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.fs.existsSync(errDir) && plugins.fs.statSync(errDir).isDirectory()) { plugins.fs.rmSync(errDir, { recursive: true, force: true }); } if (plugins.fs.existsSync(diffDir) && plugins.fs.statSync(diffDir).isDirectory()) { plugins.fs.rmSync(diffDir, { recursive: true, force: true }); } // Get all .log files in log directory (not in subdirectories) const entries = await plugins.smartfsInstance.directory(logDir).filter('*.log').list(); const logFiles = entries.filter((entry) => entry.isFile).map((entry) => entry.name); if (logFiles.length === 0) { return; } // Ensure previous directory exists await plugins.smartfsInstance.directory(previousDir).recursive().create(); // Move each log file to previous directory for (const filename of logFiles) { 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.smartfsInstance.file(sourcePath).copy(destPath); await plugins.smartfsInstance.file(sourcePath).delete(); } catch (error) { // Silently continue if a file can't be moved } } } catch (error) { // Directory might not exist, which is fine return; } } }