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