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 { try { const result = await this.smartshellInstance.exec('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 ? versionMatch[1] : 'unknown'; return { available: true, version: `Deno ${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 { 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((_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; } }