diff --git a/test/test.example.latest.docker.sh b/test/test.example.latest.docker.sh new file mode 100755 index 0000000..662c15f --- /dev/null +++ b/test/test.example.latest.docker.sh @@ -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" diff --git a/ts/tstest.classes.runtime.docker.ts b/ts/tstest.classes.runtime.docker.ts new file mode 100644 index 0000000..67890e4 --- /dev/null +++ b/ts/tstest.classes.runtime.docker.ts @@ -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 = 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(); + } +} diff --git a/ts/tstest.classes.runtime.parser.ts b/ts/tstest.classes.runtime.parser.ts index ea89181..ef972e2 100644 --- a/ts/tstest.classes.runtime.parser.ts +++ b/ts/tstest.classes.runtime.parser.ts @@ -29,7 +29,7 @@ export interface ParserConfig { const KNOWN_RUNTIMES: Set = new Set(['node', 'chromium', 'deno', 'bun']); const KNOWN_MODIFIERS: Set = new Set(['nonci']); -const VALID_EXTENSIONS: Set = new Set(['ts', 'tsx', 'mts', 'cts']); +const VALID_EXTENSIONS: Set = 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}`; +} diff --git a/ts/tstest.classes.testdirectory.ts b/ts/tstest.classes.testdirectory.ts index 034f986..4ddcf65 100644 --- a/ts/tstest.classes.testdirectory.ts +++ b/ts/tstest.classes.testdirectory.ts @@ -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); diff --git a/ts/tstest.classes.tstest.ts b/ts/tstest.classes.tstest.ts index 33a4380..7fa1f0f 100644 --- a/ts/tstest.classes.tstest.ts +++ b/ts/tstest.classes.tstest.ts @@ -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 { + 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 { this.logger.testFileStart(fileNameArg, 'node.js', index, total); const tapParser = new TapParser(fileNameArg + ':node', this.logger);