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 = 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 { 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 { // 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 { 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 { 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(); } }