import * as plugins from './tstest.plugins.js'; import { coloredString as cs } from '@push.rocks/consolecolor'; import { RuntimeAdapter, type RuntimeOptions, type RuntimeCommand, type RuntimeAvailability, } from './tstest.classes.runtime.adapter.js'; import { TapParser } from './tstest.classes.tap.parser.js'; import { TsTestLogger } from './tstest.logging.js'; import type { Runtime } from './tstest.classes.runtime.parser.js'; /** * Node.js runtime adapter * Executes tests using tsrun (TypeScript runner for Node.js) */ export class NodeRuntimeAdapter extends RuntimeAdapter { readonly id: Runtime = 'node'; readonly displayName: string = 'Node.js'; constructor( private logger: TsTestLogger, private smartshellInstance: any, // SmartShell instance from @push.rocks/smartshell private timeoutSeconds: number | null, private filterTags: string[] ) { super(); } /** * Check if Node.js and tsrun are available */ async checkAvailable(): Promise { try { // Check Node.js version const nodeVersion = process.version; // Check if tsrun module is available (imported as dependency) if (!plugins.tsrun || !plugins.tsrun.spawnPath) { return { available: false, error: 'tsrun module not found or outdated (requires version 1.6.0+)', }; } return { available: true, version: nodeVersion, }; } catch (error) { return { available: false, error: error.message, }; } } /** * Create command configuration for Node.js test execution */ createCommand(testFile: string, options?: RuntimeOptions): RuntimeCommand { const mergedOptions = this.mergeOptions(options); // Build tsrun options const args: string[] = []; if (process.argv.includes('--web')) { args.push('--web'); } // Add any extra args if (mergedOptions.extraArgs) { args.push(...mergedOptions.extraArgs); } // Set environment variables const env = { ...mergedOptions.env }; if (this.filterTags.length > 0) { env.TSTEST_FILTER_TAGS = this.filterTags.join(','); } return { command: 'tsrun', args: [testFile, ...args], env, cwd: mergedOptions.cwd, }; } /** * Execute a test file in Node.js using tsrun's spawnPath API */ async run( testFile: string, index: number, total: number, options?: RuntimeOptions ): Promise { this.logger.testFileStart(testFile, this.displayName, index, total); const tapParser = new TapParser(testFile + ':node', this.logger); const mergedOptions = this.mergeOptions(options); // Build spawn options const spawnOptions: any = { cwd: mergedOptions.cwd || process.cwd(), env: { ...mergedOptions.env }, args: [] as string[], stdio: 'pipe' as const, }; // Add --web flag if needed if (process.argv.includes('--web')) { spawnOptions.args.push('--web'); } // Set filter tags as environment variable if (this.filterTags.length > 0) { spawnOptions.env.TSTEST_FILTER_TAGS = this.filterTags.join(','); } // Check for 00init.ts file in test directory const testDir = plugins.path.dirname(testFile); const initFile = plugins.path.join(testDir, '00init.ts'); const initFileExists = await plugins.smartfile.fs.fileExists(initFile); // Determine which file to run let fileToRun = testFile; let loaderPath: string | null = null; // If 00init.ts exists, create a loader file if (initFileExists) { const absoluteInitFile = plugins.path.resolve(initFile); const absoluteTestFile = plugins.path.resolve(testFile); const loaderContent = ` import '${absoluteInitFile.replace(/\\/g, '/')}'; import '${absoluteTestFile.replace(/\\/g, '/')}'; `; loaderPath = plugins.path.join(testDir, `.loader_${plugins.path.basename(testFile)}`); await plugins.smartfile.memory.toFs(loaderContent, loaderPath); fileToRun = loaderPath; } // Spawn the test process using tsrun's spawnPath API // Pass undefined for fromFileUrl since fileToRun is already an absolute path const tsrunProcess = plugins.tsrun.spawnPath(fileToRun, undefined, spawnOptions); // If we created a loader file, clean it up after test execution if (loaderPath) { const cleanup = () => { try { if (plugins.smartfile.fs.fileExistsSync(loaderPath)) { plugins.smartfile.fs.removeSync(loaderPath); } } catch (e) { // Ignore cleanup errors } }; tsrunProcess.childProcess.on('exit', cleanup); tsrunProcess.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: ${testFile}`, '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 tsrun's terminate() to gracefully kill the process await tsrunProcess.terminate(); reject(new Error(`Test file timed out after ${this.timeoutSeconds} seconds`)); }, timeoutMs); }); try { await Promise.race([ tapParser.handleTapProcess(tsrunProcess.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 process is killed if still running try { tsrunProcess.kill('SIGKILL'); } catch (killError) { // Process might already be dead } await tapParser.evaluateFinalResult(); } } else { await tapParser.handleTapProcess(tsrunProcess.childProcess); } // Clear warning timer if it was set if (warningTimer) { clearTimeout(warningTimer); } return tapParser; } }