252 lines
		
	
	
		
			7.5 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			252 lines
		
	
	
		
			7.5 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';
 | |
| import {
 | |
|   parseDockerTestFilename,
 | |
|   mapVariantToDockerfile,
 | |
|   isDockerTestFile
 | |
| } from './tstest.classes.runtime.parser.js';
 | |
| 
 | |
| /**
 | |
|  * Docker runtime adapter
 | |
|  * Executes shell test files inside Docker containers
 | |
|  * Pattern: test.{variant}.docker.sh
 | |
|  * Variants map to Dockerfiles: latest -> Dockerfile, others -> Dockerfile_{variant}
 | |
|  */
 | |
| export class DockerRuntimeAdapter extends RuntimeAdapter {
 | |
|   readonly id: Runtime = 'node'; // Using 'node' temporarily as Runtime type doesn't include 'docker'
 | |
|   readonly displayName: string = 'Docker';
 | |
| 
 | |
|   private builtImages: Set<string> = new Set(); // Track built images to avoid rebuilding
 | |
| 
 | |
|   constructor(
 | |
|     private logger: TsTestLogger,
 | |
|     private smartshellInstance: any, // SmartShell instance from @push.rocks/smartshell
 | |
|     private timeoutSeconds: number | null,
 | |
|     private cwd: string
 | |
|   ) {
 | |
|     super();
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Check if Docker CLI is available
 | |
|    */
 | |
|   async checkAvailable(): Promise<RuntimeAvailability> {
 | |
|     try {
 | |
|       const result = await this.smartshellInstance.exec('docker --version');
 | |
| 
 | |
|       if (result.exitCode !== 0) {
 | |
|         return {
 | |
|           available: false,
 | |
|           error: 'Docker command failed',
 | |
|         };
 | |
|       }
 | |
| 
 | |
|       // Extract version from output like "Docker version 24.0.5, build ced0996"
 | |
|       const versionMatch = result.stdout.match(/Docker version ([^,]+)/);
 | |
|       const version = versionMatch ? versionMatch[1] : 'unknown';
 | |
| 
 | |
|       return {
 | |
|         available: true,
 | |
|         version,
 | |
|       };
 | |
|     } catch (error) {
 | |
|       return {
 | |
|         available: false,
 | |
|         error: `Docker not found: ${error.message}`,
 | |
|       };
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Create command configuration for Docker test execution
 | |
|    * This is used for informational purposes
 | |
|    */
 | |
|   createCommand(testFile: string, options?: RuntimeOptions): RuntimeCommand {
 | |
|     const parsed = parseDockerTestFilename(testFile);
 | |
|     const dockerfilePath = mapVariantToDockerfile(parsed.variant, this.cwd);
 | |
|     const imageName = `tstest-${parsed.variant}`;
 | |
| 
 | |
|     return {
 | |
|       command: 'docker',
 | |
|       args: [
 | |
|         'run',
 | |
|         '--rm',
 | |
|         '-v',
 | |
|         `${this.cwd}/test:/test`,
 | |
|         imageName,
 | |
|         'taprun',
 | |
|         `/test/${plugins.path.basename(testFile)}`
 | |
|       ],
 | |
|       env: {},
 | |
|       cwd: this.cwd,
 | |
|     };
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Build a Docker image from the specified Dockerfile
 | |
|    */
 | |
|   private async buildDockerImage(dockerfilePath: string, imageName: string): Promise<void> {
 | |
|     // Check if image is already built
 | |
|     if (this.builtImages.has(imageName)) {
 | |
|       this.logger.tapOutput(`Using cached Docker image: ${imageName}`);
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     // Check if Dockerfile exists
 | |
|     if (!await plugins.smartfile.fs.fileExists(dockerfilePath)) {
 | |
|       throw new Error(
 | |
|         `Dockerfile not found: ${dockerfilePath}\n` +
 | |
|         `Expected Dockerfile for Docker test variant.`
 | |
|       );
 | |
|     }
 | |
| 
 | |
|     this.logger.tapOutput(`Building Docker image: ${imageName} from ${dockerfilePath}`);
 | |
| 
 | |
|     try {
 | |
|       const buildResult = await this.smartshellInstance.exec(
 | |
|         `docker build -f ${dockerfilePath} -t ${imageName} ${this.cwd}`,
 | |
|         {
 | |
|           cwd: this.cwd,
 | |
|         }
 | |
|       );
 | |
| 
 | |
|       if (buildResult.exitCode !== 0) {
 | |
|         throw new Error(`Docker build failed:\n${buildResult.stderr}`);
 | |
|       }
 | |
| 
 | |
|       this.builtImages.add(imageName);
 | |
|       this.logger.tapOutput(`✅ Docker image built successfully: ${imageName}`);
 | |
|     } catch (error) {
 | |
|       throw new Error(`Failed to build Docker image: ${error.message}`);
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Execute a Docker test file
 | |
|    */
 | |
|   async run(
 | |
|     testFile: string,
 | |
|     index: number,
 | |
|     total: number,
 | |
|     options?: RuntimeOptions
 | |
|   ): Promise<TapParser> {
 | |
|     this.logger.testFileStart(testFile, this.displayName, index, total);
 | |
| 
 | |
|     // Parse the Docker test filename
 | |
|     const parsed = parseDockerTestFilename(testFile);
 | |
|     const dockerfilePath = mapVariantToDockerfile(parsed.variant, this.cwd);
 | |
|     const imageName = `tstest-${parsed.variant}`;
 | |
| 
 | |
|     // Build the Docker image
 | |
|     await this.buildDockerImage(dockerfilePath, imageName);
 | |
| 
 | |
|     // Prepare the test file path relative to the mounted directory
 | |
|     // We need to get the path relative to cwd
 | |
|     const absoluteTestPath = plugins.path.isAbsolute(testFile)
 | |
|       ? testFile
 | |
|       : plugins.path.join(this.cwd, testFile);
 | |
| 
 | |
|     const relativeTestPath = plugins.path.relative(this.cwd, absoluteTestPath);
 | |
| 
 | |
|     // Create TAP parser
 | |
|     const tapParser = new TapParser(testFile + ':docker', this.logger);
 | |
| 
 | |
|     try {
 | |
|       // Build docker run command
 | |
|       const dockerArgs = [
 | |
|         'run',
 | |
|         '--rm',
 | |
|         '-v',
 | |
|         `${this.cwd}/test:/test`,
 | |
|         imageName,
 | |
|         'taprun',
 | |
|         `/test/${plugins.path.basename(testFile)}`
 | |
|       ];
 | |
| 
 | |
|       this.logger.tapOutput(`Executing: docker ${dockerArgs.join(' ')}`);
 | |
| 
 | |
|       // Execute the Docker container
 | |
|       const execPromise = this.smartshellInstance.execStreaming(
 | |
|         `docker ${dockerArgs.join(' ')}`,
 | |
|         {
 | |
|           cwd: this.cwd,
 | |
|         }
 | |
|       );
 | |
| 
 | |
|       // Set up timeout if configured
 | |
|       let timeoutHandle: NodeJS.Timeout | null = null;
 | |
|       if (this.timeoutSeconds) {
 | |
|         timeoutHandle = setTimeout(() => {
 | |
|           this.logger.tapOutput(`⏱️  Test timeout (${this.timeoutSeconds}s) - killing container`);
 | |
|           // Try to kill any running containers with this image
 | |
|           this.smartshellInstance.exec(`docker ps -q --filter ancestor=${imageName} | xargs -r docker kill`);
 | |
|         }, this.timeoutSeconds * 1000);
 | |
|       }
 | |
| 
 | |
|       // Stream output to TAP parser line by line
 | |
|       execPromise.childProcess.stdout.on('data', (data: Buffer) => {
 | |
|         const output = data.toString();
 | |
|         const lines = output.split('\n');
 | |
|         for (const line of lines) {
 | |
|           if (line.trim()) {
 | |
|             tapParser.handleTapLog(line);
 | |
|           }
 | |
|         }
 | |
|       });
 | |
| 
 | |
|       execPromise.childProcess.stderr.on('data', (data: Buffer) => {
 | |
|         const output = data.toString();
 | |
|         this.logger.tapOutput(cs(`[stderr] ${output}`, 'orange'));
 | |
|       });
 | |
| 
 | |
|       // Wait for completion
 | |
|       const result = await execPromise;
 | |
| 
 | |
|       // Clear timeout
 | |
|       if (timeoutHandle) {
 | |
|         clearTimeout(timeoutHandle);
 | |
|       }
 | |
| 
 | |
|       if (result.exitCode !== 0) {
 | |
|         this.logger.tapOutput(cs(`❌ Docker test failed with exit code ${result.exitCode}`, 'red'));
 | |
|       }
 | |
| 
 | |
|       // Evaluate final result
 | |
|       await tapParser.evaluateFinalResult();
 | |
| 
 | |
|     } catch (error) {
 | |
|       this.logger.tapOutput(cs(`❌ Error running Docker test: ${error.message}`, 'red'));
 | |
|       // Add a failing test result to the parser
 | |
|       tapParser.handleTapLog('not ok 1 - Docker test execution failed');
 | |
|       await tapParser.evaluateFinalResult();
 | |
|     }
 | |
| 
 | |
|     return tapParser;
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Clean up built Docker images (optional, can be called at end of test suite)
 | |
|    */
 | |
|   async cleanup(): Promise<void> {
 | |
|     for (const imageName of this.builtImages) {
 | |
|       try {
 | |
|         this.logger.tapOutput(`Removing Docker image: ${imageName}`);
 | |
|         await this.smartshellInstance.exec(`docker rmi ${imageName}`);
 | |
|       } catch (error) {
 | |
|         // Ignore cleanup errors
 | |
|         this.logger.tapOutput(cs(`Warning: Failed to remove image ${imageName}: ${error.message}`, 'orange'));
 | |
|       }
 | |
|     }
 | |
|     this.builtImages.clear();
 | |
|   }
 | |
| }
 |