feat(docker): add Docker test file support and runtime adapter
This commit is contained in:
		
							
								
								
									
										9
									
								
								test/test.example.latest.docker.sh
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										9
									
								
								test/test.example.latest.docker.sh
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,9 @@ | ||||
| #!/bin/bash | ||||
| # Sample Docker test file | ||||
| # This file demonstrates the naming pattern: test.{baseName}.{variant}.docker.sh | ||||
| # The variant "latest" maps to the Dockerfile in the project root | ||||
|  | ||||
| echo "TAP version 13" | ||||
| echo "1..2" | ||||
| echo "ok 1 - Sample Docker test passes" | ||||
| echo "ok 2 - Docker environment is working" | ||||
							
								
								
									
										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(); | ||||
|   } | ||||
| } | ||||
| @@ -29,7 +29,7 @@ export interface ParserConfig { | ||||
|  | ||||
| const KNOWN_RUNTIMES: Set<string> = new Set(['node', 'chromium', 'deno', 'bun']); | ||||
| const KNOWN_MODIFIERS: Set<string> = new Set(['nonci']); | ||||
| const VALID_EXTENSIONS: Set<string> = new Set(['ts', 'tsx', 'mts', 'cts']); | ||||
| const VALID_EXTENSIONS: Set<string> = new Set(['ts', 'tsx', 'mts', 'cts', 'sh']); | ||||
| const ALL_RUNTIMES: Runtime[] = ['node', 'chromium', 'deno', 'bun']; | ||||
|  | ||||
| // Legacy mappings for backwards compatibility | ||||
| @@ -228,3 +228,81 @@ export function getLegacyMigrationTarget(fileName: string): string | null { | ||||
|  | ||||
|   return parts.join('.'); | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Docker test file information | ||||
|  */ | ||||
| export interface DockerTestFileInfo { | ||||
|   baseName: string; | ||||
|   variant: string; | ||||
|   isDockerTest: true; | ||||
|   original: string; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Check if a filename matches the Docker test pattern: *.{variant}.docker.sh | ||||
|  * Examples: test.latest.docker.sh, test.integration.npmci.docker.sh | ||||
|  */ | ||||
| export function isDockerTestFile(fileName: string): boolean { | ||||
|   // Must end with .docker.sh | ||||
|   if (!fileName.endsWith('.docker.sh')) { | ||||
|     return false; | ||||
|   } | ||||
|  | ||||
|   // Extract filename from path if needed | ||||
|   const name = fileName.split('/').pop() || fileName; | ||||
|  | ||||
|   // Must have at least 3 parts: [baseName, variant, docker, sh] | ||||
|   const parts = name.split('.'); | ||||
|   return parts.length >= 4 && parts[parts.length - 2] === 'docker' && parts[parts.length - 1] === 'sh'; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Parse a Docker test filename to extract variant and base name | ||||
|  * Pattern: test.{baseName}.{variant}.docker.sh | ||||
|  * Examples: | ||||
|  *   - test.latest.docker.sh -> { baseName: 'test', variant: 'latest' } | ||||
|  *   - test.integration.npmci.docker.sh -> { baseName: 'test.integration', variant: 'npmci' } | ||||
|  */ | ||||
| export function parseDockerTestFilename(filePath: string): DockerTestFileInfo { | ||||
|   // Extract just the filename from the path | ||||
|   const fileName = filePath.split('/').pop() || filePath; | ||||
|   const original = fileName; | ||||
|  | ||||
|   if (!isDockerTestFile(fileName)) { | ||||
|     throw new Error(`Not a valid Docker test file: "${fileName}". Expected pattern: *.{variant}.docker.sh`); | ||||
|   } | ||||
|  | ||||
|   // Remove .docker.sh suffix | ||||
|   const withoutSuffix = fileName.slice(0, -10); // Remove '.docker.sh' | ||||
|   const tokens = withoutSuffix.split('.'); | ||||
|  | ||||
|   if (tokens.length === 0) { | ||||
|     throw new Error(`Invalid Docker test file: empty basename in "${fileName}"`); | ||||
|   } | ||||
|  | ||||
|   // Last token before .docker.sh is the variant | ||||
|   const variant = tokens[tokens.length - 1]; | ||||
|  | ||||
|   // Everything else is the base name | ||||
|   const baseName = tokens.slice(0, -1).join('.'); | ||||
|  | ||||
|   return { | ||||
|     baseName: baseName || 'test', | ||||
|     variant, | ||||
|     isDockerTest: true, | ||||
|     original, | ||||
|   }; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Map a Docker variant to its corresponding Dockerfile path | ||||
|  * "latest" -> "Dockerfile" | ||||
|  * Other variants -> "Dockerfile_{variant}" | ||||
|  */ | ||||
| export function mapVariantToDockerfile(variant: string, baseDir: string): string { | ||||
|   if (variant === 'latest') { | ||||
|     return `${baseDir}/Dockerfile`; | ||||
|   } | ||||
|   return `${baseDir}/Dockerfile_${variant}`; | ||||
| } | ||||
|   | ||||
| @@ -74,12 +74,20 @@ export class TestDirectory { | ||||
|       case TestExecutionMode.DIRECTORY: | ||||
|         // Directory mode - now recursive with ** pattern | ||||
|         const dirPath = plugins.path.join(this.cwd, this.testPath); | ||||
|         const testPattern = '**/test*.ts'; | ||||
|          | ||||
|         const testFiles = await plugins.smartfile.fs.listFileTree(dirPath, testPattern); | ||||
|          | ||||
|  | ||||
|         // Search for both TypeScript test files and Docker shell test files | ||||
|         const tsPattern = '**/test*.ts'; | ||||
|         const dockerPattern = '**/*.docker.sh'; | ||||
|  | ||||
|         const [tsFiles, dockerFiles] = await Promise.all([ | ||||
|           plugins.smartfile.fs.listFileTree(dirPath, tsPattern), | ||||
|           plugins.smartfile.fs.listFileTree(dirPath, dockerPattern), | ||||
|         ]); | ||||
|  | ||||
|         const allTestFiles = [...tsFiles, ...dockerFiles]; | ||||
|  | ||||
|         this.testfileArray = await Promise.all( | ||||
|           testFiles.map(async (filePath) => { | ||||
|           allTestFiles.map(async (filePath) => { | ||||
|             const absolutePath = plugins.path.isAbsolute(filePath) | ||||
|               ? filePath | ||||
|               : plugins.path.join(dirPath, filePath); | ||||
|   | ||||
| @@ -11,12 +11,13 @@ import { TsTestLogger } from './tstest.logging.js'; | ||||
| import type { LogOptions } from './tstest.logging.js'; | ||||
|  | ||||
| // Runtime adapters | ||||
| import { parseTestFilename } from './tstest.classes.runtime.parser.js'; | ||||
| import { parseTestFilename, isDockerTestFile, parseDockerTestFilename } from './tstest.classes.runtime.parser.js'; | ||||
| import { RuntimeAdapterRegistry } from './tstest.classes.runtime.adapter.js'; | ||||
| import { NodeRuntimeAdapter } from './tstest.classes.runtime.node.js'; | ||||
| import { ChromiumRuntimeAdapter } from './tstest.classes.runtime.chromium.js'; | ||||
| import { DenoRuntimeAdapter } from './tstest.classes.runtime.deno.js'; | ||||
| import { BunRuntimeAdapter } from './tstest.classes.runtime.bun.js'; | ||||
| import { DockerRuntimeAdapter } from './tstest.classes.runtime.docker.js'; | ||||
|  | ||||
| export class TsTest { | ||||
|   public testDir: TestDirectory; | ||||
| @@ -37,6 +38,7 @@ export class TsTest { | ||||
|   public tsbundleInstance = new plugins.tsbundle.TsBundle(); | ||||
|  | ||||
|   public runtimeRegistry = new RuntimeAdapterRegistry(); | ||||
|   public dockerAdapter: DockerRuntimeAdapter | null = null; | ||||
|  | ||||
|   constructor(cwdArg: string, testPathArg: string, executionModeArg: TestExecutionMode, logOptions: LogOptions = {}, tags: string[] = [], startFromFile: number | null = null, stopAtFile: number | null = null, timeoutSeconds: number | null = null) { | ||||
|     this.executionMode = executionModeArg; | ||||
| @@ -60,6 +62,14 @@ export class TsTest { | ||||
|     this.runtimeRegistry.register( | ||||
|       new BunRuntimeAdapter(this.logger, this.smartshellInstance, this.timeoutSeconds, this.filterTags) | ||||
|     ); | ||||
|  | ||||
|     // Initialize Docker adapter | ||||
|     this.dockerAdapter = new DockerRuntimeAdapter( | ||||
|       this.logger, | ||||
|       this.smartshellInstance, | ||||
|       this.timeoutSeconds, | ||||
|       cwdArg | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
| @@ -211,8 +221,14 @@ export class TsTest { | ||||
|   } | ||||
|    | ||||
|   private async runSingleTest(fileNameArg: string, fileIndex: number, totalFiles: number, tapCombinator: TapCombinator) { | ||||
|     // Parse the filename to determine runtimes and modifiers | ||||
|     const fileName = plugins.path.basename(fileNameArg); | ||||
|  | ||||
|     // Check if this is a Docker test file | ||||
|     if (isDockerTestFile(fileName)) { | ||||
|       return await this.runDockerTest(fileNameArg, fileIndex, totalFiles, tapCombinator); | ||||
|     } | ||||
|  | ||||
|     // Parse the filename to determine runtimes and modifiers (for TypeScript tests) | ||||
|     const parsed = parseTestFilename(fileName, { strictUnknownRuntime: false }); | ||||
|  | ||||
|     // Check for nonci modifier in CI environment | ||||
| @@ -258,6 +274,28 @@ export class TsTest { | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Execute a Docker test file | ||||
|    */ | ||||
|   private async runDockerTest( | ||||
|     fileNameArg: string, | ||||
|     fileIndex: number, | ||||
|     totalFiles: number, | ||||
|     tapCombinator: TapCombinator | ||||
|   ): Promise<void> { | ||||
|     if (!this.dockerAdapter) { | ||||
|       this.logger.tapOutput(cs('❌ Docker adapter not initialized', 'red')); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     try { | ||||
|       const tapParser = await this.dockerAdapter.run(fileNameArg, fileIndex, totalFiles); | ||||
|       tapCombinator.addTapParser(tapParser); | ||||
|     } catch (error) { | ||||
|       this.logger.tapOutput(cs(`❌ Docker test failed: ${error.message}`, 'red')); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   public async runInNode(fileNameArg: string, index: number, total: number): Promise<TapParser> { | ||||
|     this.logger.testFileStart(fileNameArg, 'node.js', index, total); | ||||
|     const tapParser = new TapParser(fileNameArg + ':node', this.logger); | ||||
|   | ||||
		Reference in New Issue
	
	Block a user