263 lines
		
	
	
		
			7.9 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			263 lines
		
	
	
		
			7.9 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| import * as plugins from './tstest.plugins.js';
 | |
| import { coloredString as cs } from '@push.rocks/consolecolor';
 | |
| import {
 | |
|   RuntimeAdapter,
 | |
|   type DenoOptions,
 | |
|   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';
 | |
| 
 | |
| /**
 | |
|  * Deno runtime adapter
 | |
|  * Executes tests using the Deno runtime
 | |
|  */
 | |
| export class DenoRuntimeAdapter extends RuntimeAdapter {
 | |
|   readonly id: Runtime = 'deno';
 | |
|   readonly displayName: string = 'Deno';
 | |
| 
 | |
|   constructor(
 | |
|     private logger: TsTestLogger,
 | |
|     private smartshellInstance: any, // SmartShell instance from @push.rocks/smartshell
 | |
|     private timeoutSeconds: number | null,
 | |
|     private filterTags: string[]
 | |
|   ) {
 | |
|     super();
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Get default Deno options
 | |
|    */
 | |
|   protected getDefaultOptions(): DenoOptions {
 | |
|     return {
 | |
|       ...super.getDefaultOptions(),
 | |
|       permissions: [
 | |
|         '--allow-read',
 | |
|         '--allow-env',
 | |
|         '--allow-net',
 | |
|         '--allow-write',
 | |
|         '--allow-sys',        // Allow system info access
 | |
|         '--allow-import',     // Allow npm/node imports
 | |
|         '--node-modules-dir', // Enable Node.js compatibility mode
 | |
|         '--sloppy-imports',   // Allow .js imports to resolve to .ts files
 | |
|       ],
 | |
|     };
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Check if Deno is available
 | |
|    */
 | |
|   async checkAvailable(): Promise<RuntimeAvailability> {
 | |
|     try {
 | |
|       const result = await this.smartshellInstance.execSilent('deno --version', {
 | |
|         cwd: process.cwd(),
 | |
|         onError: () => {
 | |
|           // Ignore error
 | |
|         }
 | |
|       });
 | |
| 
 | |
|       if (result.exitCode !== 0) {
 | |
|         return {
 | |
|           available: false,
 | |
|           error: 'Deno not found. Install from: https://deno.land/',
 | |
|         };
 | |
|       }
 | |
| 
 | |
|       // Parse Deno version from output (first line is "deno X.Y.Z")
 | |
|       const versionMatch = result.stdout.match(/deno (\d+\.\d+\.\d+)/);
 | |
|       const version = versionMatch ? `v${versionMatch[1]}` : 'unknown';
 | |
| 
 | |
|       return {
 | |
|         available: true,
 | |
|         version: version,
 | |
|       };
 | |
|     } catch (error) {
 | |
|       return {
 | |
|         available: false,
 | |
|         error: error.message,
 | |
|       };
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Create command configuration for Deno test execution
 | |
|    */
 | |
|   createCommand(testFile: string, options?: DenoOptions): RuntimeCommand {
 | |
|     const mergedOptions = this.mergeOptions(options) as DenoOptions;
 | |
| 
 | |
|     const args: string[] = ['run'];
 | |
| 
 | |
|     // Add permissions
 | |
|     const permissions = mergedOptions.permissions || [
 | |
|       '--allow-read',
 | |
|       '--allow-env',
 | |
|       '--allow-net',
 | |
|       '--allow-write',
 | |
|       '--allow-sys',
 | |
|       '--allow-import',
 | |
|       '--node-modules-dir',
 | |
|       '--sloppy-imports',
 | |
|     ];
 | |
|     args.push(...permissions);
 | |
| 
 | |
|     // Add config file if specified
 | |
|     if (mergedOptions.configPath) {
 | |
|       args.push('--config', mergedOptions.configPath);
 | |
|     }
 | |
| 
 | |
|     // Add import map if specified
 | |
|     if (mergedOptions.importMap) {
 | |
|       args.push('--import-map', mergedOptions.importMap);
 | |
|     }
 | |
| 
 | |
|     // Add extra args
 | |
|     if (mergedOptions.extraArgs && mergedOptions.extraArgs.length > 0) {
 | |
|       args.push(...mergedOptions.extraArgs);
 | |
|     }
 | |
| 
 | |
|     // Add test file
 | |
|     args.push(testFile);
 | |
| 
 | |
|     // Set environment variables
 | |
|     const env = { ...mergedOptions.env };
 | |
| 
 | |
|     if (this.filterTags.length > 0) {
 | |
|       env.TSTEST_FILTER_TAGS = this.filterTags.join(',');
 | |
|     }
 | |
| 
 | |
|     return {
 | |
|       command: 'deno',
 | |
|       args,
 | |
|       env,
 | |
|       cwd: mergedOptions.cwd,
 | |
|     };
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Execute a test file in Deno
 | |
|    */
 | |
|   async run(
 | |
|     testFile: string,
 | |
|     index: number,
 | |
|     total: number,
 | |
|     options?: DenoOptions
 | |
|   ): Promise<TapParser> {
 | |
|     this.logger.testFileStart(testFile, this.displayName, index, total);
 | |
|     const tapParser = new TapParser(testFile + ':deno', this.logger);
 | |
| 
 | |
|     const mergedOptions = this.mergeOptions(options) as DenoOptions;
 | |
| 
 | |
|     // Build Deno command
 | |
|     const command = this.createCommand(testFile, mergedOptions);
 | |
|     const fullCommand = `${command.command} ${command.args.join(' ')}`;
 | |
| 
 | |
|     // 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');
 | |
|     const initFileExists = await plugins.smartfile.fs.fileExists(initFile);
 | |
| 
 | |
|     let runCommand = fullCommand;
 | |
|     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);
 | |
| 
 | |
|       // Rebuild command with loader file
 | |
|       const loaderCommand = this.createCommand(loaderPath, mergedOptions);
 | |
|       runCommand = `${loaderCommand.command} ${loaderCommand.args.join(' ')}`;
 | |
|     }
 | |
| 
 | |
|     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<void>((_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;
 | |
|   }
 | |
| }
 |