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 is available const result = await this.smartshellInstance.exec('tsrun --version', { cwd: process.cwd(), onError: () => { // Ignore error } }); if (result.exitCode !== 0) { return { available: false, error: 'tsrun not found. Install with: pnpm install --save-dev @git.zone/tsrun', }; } 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 */ 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 tsrun command 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(testFile); const initFile = plugins.path.join(testDir, '00init.ts'); let runCommand = `tsrun ${testFile}${tsrunOptions}`; const initFileExists = await plugins.smartfile.fs.fileExists(initFile); // If 00init.ts exists, run it first let loaderPath: string | null = null; 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(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); runCommand = `tsrun ${loaderPath}${tsrunOptions}`; } const execResultStreaming = await this.smartshellInstance.execStreamingSilent(runCommand); // 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 } }; 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: ${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 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; } }