feat(docker): add Docker test file support and runtime adapter
This commit is contained in:
9
test/test.example.latest.docker.sh
Executable file
9
test/test.example.latest.docker.sh
Executable 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"
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ export interface ParserConfig {
|
||||
|
||||
const KNOWN_RUNTIMES: Set<string> = new Set(['node', 'chromium', 'deno', 'bun']);
|
||||
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'];
|
||||
|
||||
// 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}`;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<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> {
|
||||
this.logger.testFileStart(fileNameArg, 'node.js', index, total);
|
||||
const tapParser = new TapParser(fileNameArg + ':node', this.logger);
|
||||
|
||||
Reference in New Issue
Block a user