252 lines
7.5 KiB
TypeScript
252 lines
7.5 KiB
TypeScript
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();
|
|
}
|
|
}
|