225 lines
		
	
	
		
			6.7 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			225 lines
		
	
	
		
			6.7 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| 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<RuntimeAvailability> {
 | |
|     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<TapParser> {
 | |
|     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<void>((_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;
 | |
|   }
 | |
| }
 |