feat(docker): add Docker test file support and runtime adapter

This commit is contained in:
2025-10-26 19:35:10 +00:00
parent 1ea3b37d18
commit 592a4f33c0
5 changed files with 392 additions and 8 deletions

View 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"

View 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();
}
}

View File

@@ -29,7 +29,7 @@ export interface ParserConfig {
const KNOWN_RUNTIMES: Set<string> = new Set(['node', 'chromium', 'deno', 'bun']); const KNOWN_RUNTIMES: Set<string> = new Set(['node', 'chromium', 'deno', 'bun']);
const KNOWN_MODIFIERS: Set<string> = new Set(['nonci']); 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']; const ALL_RUNTIMES: Runtime[] = ['node', 'chromium', 'deno', 'bun'];
// Legacy mappings for backwards compatibility // Legacy mappings for backwards compatibility
@@ -228,3 +228,81 @@ export function getLegacyMigrationTarget(fileName: string): string | null {
return parts.join('.'); 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}`;
}

View File

@@ -74,12 +74,20 @@ export class TestDirectory {
case TestExecutionMode.DIRECTORY: case TestExecutionMode.DIRECTORY:
// Directory mode - now recursive with ** pattern // Directory mode - now recursive with ** pattern
const dirPath = plugins.path.join(this.cwd, this.testPath); const dirPath = plugins.path.join(this.cwd, this.testPath);
const testPattern = '**/test*.ts';
// Search for both TypeScript test files and Docker shell test files
const testFiles = await plugins.smartfile.fs.listFileTree(dirPath, testPattern); 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( this.testfileArray = await Promise.all(
testFiles.map(async (filePath) => { allTestFiles.map(async (filePath) => {
const absolutePath = plugins.path.isAbsolute(filePath) const absolutePath = plugins.path.isAbsolute(filePath)
? filePath ? filePath
: plugins.path.join(dirPath, filePath); : plugins.path.join(dirPath, filePath);

View File

@@ -11,12 +11,13 @@ import { TsTestLogger } from './tstest.logging.js';
import type { LogOptions } from './tstest.logging.js'; import type { LogOptions } from './tstest.logging.js';
// Runtime adapters // 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 { RuntimeAdapterRegistry } from './tstest.classes.runtime.adapter.js';
import { NodeRuntimeAdapter } from './tstest.classes.runtime.node.js'; import { NodeRuntimeAdapter } from './tstest.classes.runtime.node.js';
import { ChromiumRuntimeAdapter } from './tstest.classes.runtime.chromium.js'; import { ChromiumRuntimeAdapter } from './tstest.classes.runtime.chromium.js';
import { DenoRuntimeAdapter } from './tstest.classes.runtime.deno.js'; import { DenoRuntimeAdapter } from './tstest.classes.runtime.deno.js';
import { BunRuntimeAdapter } from './tstest.classes.runtime.bun.js'; import { BunRuntimeAdapter } from './tstest.classes.runtime.bun.js';
import { DockerRuntimeAdapter } from './tstest.classes.runtime.docker.js';
export class TsTest { export class TsTest {
public testDir: TestDirectory; public testDir: TestDirectory;
@@ -37,6 +38,7 @@ export class TsTest {
public tsbundleInstance = new plugins.tsbundle.TsBundle(); public tsbundleInstance = new plugins.tsbundle.TsBundle();
public runtimeRegistry = new RuntimeAdapterRegistry(); 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) { 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; this.executionMode = executionModeArg;
@@ -60,6 +62,14 @@ export class TsTest {
this.runtimeRegistry.register( this.runtimeRegistry.register(
new BunRuntimeAdapter(this.logger, this.smartshellInstance, this.timeoutSeconds, this.filterTags) 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) { 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); 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 }); const parsed = parseTestFilename(fileName, { strictUnknownRuntime: false });
// Check for nonci modifier in CI environment // 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> { public async runInNode(fileNameArg: string, index: number, total: number): Promise<TapParser> {
this.logger.testFileStart(fileNameArg, 'node.js', index, total); this.logger.testFileStart(fileNameArg, 'node.js', index, total);
const tapParser = new TapParser(fileNameArg + ':node', this.logger); const tapParser = new TapParser(fileNameArg + ':node', this.logger);