feat(docker): add Docker test file support and runtime adapter
This commit is contained in:
		
							
								
								
									
										251
									
								
								ts/tstest.classes.runtime.docker.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										251
									
								
								ts/tstest.classes.runtime.docker.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,251 @@ | ||||
| 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(); | ||||
|   } | ||||
| } | ||||
		Reference in New Issue
	
	Block a user