257 lines
7.6 KiB
TypeScript
257 lines
7.6 KiB
TypeScript
import * as plugins from './tstest.plugins.js';
|
|
import { coloredString as cs } from '@push.rocks/consolecolor';
|
|
import {
|
|
RuntimeAdapter,
|
|
type DenoOptions,
|
|
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';
|
|
|
|
/**
|
|
* Deno runtime adapter
|
|
* Executes tests using the Deno runtime
|
|
*/
|
|
export class DenoRuntimeAdapter extends RuntimeAdapter {
|
|
readonly id: Runtime = 'deno';
|
|
readonly displayName: string = 'Deno';
|
|
|
|
constructor(
|
|
private logger: TsTestLogger,
|
|
private smartshellInstance: any, // SmartShell instance from @push.rocks/smartshell
|
|
private timeoutSeconds: number | null,
|
|
private filterTags: string[]
|
|
) {
|
|
super();
|
|
}
|
|
|
|
/**
|
|
* Get default Deno options
|
|
*/
|
|
protected getDefaultOptions(): DenoOptions {
|
|
return {
|
|
...super.getDefaultOptions(),
|
|
permissions: [
|
|
'--allow-read',
|
|
'--allow-env',
|
|
'--allow-net',
|
|
'--allow-write',
|
|
'--sloppy-imports', // Allow .js imports to resolve to .ts files
|
|
],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Check if Deno is available
|
|
*/
|
|
async checkAvailable(): Promise<RuntimeAvailability> {
|
|
try {
|
|
const result = await this.smartshellInstance.exec('deno --version', {
|
|
cwd: process.cwd(),
|
|
onError: () => {
|
|
// Ignore error
|
|
}
|
|
});
|
|
|
|
if (result.exitCode !== 0) {
|
|
return {
|
|
available: false,
|
|
error: 'Deno not found. Install from: https://deno.land/',
|
|
};
|
|
}
|
|
|
|
// Parse Deno version from output (first line is "deno X.Y.Z")
|
|
const versionMatch = result.stdout.match(/deno (\d+\.\d+\.\d+)/);
|
|
const version = versionMatch ? versionMatch[1] : 'unknown';
|
|
|
|
return {
|
|
available: true,
|
|
version: `Deno ${version}`,
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
available: false,
|
|
error: error.message,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create command configuration for Deno test execution
|
|
*/
|
|
createCommand(testFile: string, options?: DenoOptions): RuntimeCommand {
|
|
const mergedOptions = this.mergeOptions(options) as DenoOptions;
|
|
|
|
const args: string[] = ['run'];
|
|
|
|
// Add permissions
|
|
const permissions = mergedOptions.permissions || [
|
|
'--allow-read',
|
|
'--allow-env',
|
|
'--allow-net',
|
|
'--allow-write',
|
|
'--sloppy-imports',
|
|
];
|
|
args.push(...permissions);
|
|
|
|
// Add config file if specified
|
|
if (mergedOptions.configPath) {
|
|
args.push('--config', mergedOptions.configPath);
|
|
}
|
|
|
|
// Add import map if specified
|
|
if (mergedOptions.importMap) {
|
|
args.push('--import-map', mergedOptions.importMap);
|
|
}
|
|
|
|
// Add extra args
|
|
if (mergedOptions.extraArgs && mergedOptions.extraArgs.length > 0) {
|
|
args.push(...mergedOptions.extraArgs);
|
|
}
|
|
|
|
// Add test file
|
|
args.push(testFile);
|
|
|
|
// Set environment variables
|
|
const env = { ...mergedOptions.env };
|
|
|
|
if (this.filterTags.length > 0) {
|
|
env.TSTEST_FILTER_TAGS = this.filterTags.join(',');
|
|
}
|
|
|
|
return {
|
|
command: 'deno',
|
|
args,
|
|
env,
|
|
cwd: mergedOptions.cwd,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Execute a test file in Deno
|
|
*/
|
|
async run(
|
|
testFile: string,
|
|
index: number,
|
|
total: number,
|
|
options?: DenoOptions
|
|
): Promise<TapParser> {
|
|
this.logger.testFileStart(testFile, this.displayName, index, total);
|
|
const tapParser = new TapParser(testFile + ':deno', this.logger);
|
|
|
|
const mergedOptions = this.mergeOptions(options) as DenoOptions;
|
|
|
|
// Build Deno command
|
|
const command = this.createCommand(testFile, mergedOptions);
|
|
const fullCommand = `${command.command} ${command.args.join(' ')}`;
|
|
|
|
// Set filter tags as environment variable
|
|
if (this.filterTags.length > 0) {
|
|
process.env.TSTEST_FILTER_TAGS = this.filterTags.join(',');
|
|
}
|
|
|
|
// Check for 00init.ts file in test directory
|
|
const testDir = plugins.path.dirname(testFile);
|
|
const initFile = plugins.path.join(testDir, '00init.ts');
|
|
const initFileExists = await plugins.smartfile.fs.fileExists(initFile);
|
|
|
|
let runCommand = fullCommand;
|
|
let loaderPath: string | null = null;
|
|
|
|
// If 00init.ts exists, create a loader file
|
|
if (initFileExists) {
|
|
const absoluteInitFile = plugins.path.resolve(initFile);
|
|
const absoluteTestFile = plugins.path.resolve(testFile);
|
|
const loaderContent = `
|
|
import '${absoluteInitFile.replace(/\\/g, '/')}';
|
|
import '${absoluteTestFile.replace(/\\/g, '/')}';
|
|
`;
|
|
loaderPath = plugins.path.join(testDir, `.loader_${plugins.path.basename(testFile)}`);
|
|
await plugins.smartfile.memory.toFs(loaderContent, loaderPath);
|
|
|
|
// Rebuild command with loader file
|
|
const loaderCommand = this.createCommand(loaderPath, mergedOptions);
|
|
runCommand = `${loaderCommand.command} ${loaderCommand.args.join(' ')}`;
|
|
}
|
|
|
|
const execResultStreaming = await this.smartshellInstance.execStreamingSilent(runCommand);
|
|
|
|
// If we created a loader file, clean it up after test execution
|
|
if (loaderPath) {
|
|
const cleanup = () => {
|
|
try {
|
|
if (plugins.smartfile.fs.fileExistsSync(loaderPath)) {
|
|
plugins.smartfile.fs.removeSync(loaderPath);
|
|
}
|
|
} catch (e) {
|
|
// Ignore cleanup errors
|
|
}
|
|
};
|
|
|
|
execResultStreaming.childProcess.on('exit', cleanup);
|
|
execResultStreaming.childProcess.on('error', cleanup);
|
|
}
|
|
|
|
// Start warning timer if no timeout was specified
|
|
let warningTimer: NodeJS.Timeout | null = null;
|
|
if (this.timeoutSeconds === null) {
|
|
warningTimer = setTimeout(() => {
|
|
console.error('');
|
|
console.error(cs('⚠️ WARNING: Test file is running for more than 1 minute', 'orange'));
|
|
console.error(cs(` File: ${testFile}`, 'orange'));
|
|
console.error(cs(' Consider using --timeout option to set a timeout for test files.', 'orange'));
|
|
console.error(cs(' Example: tstest test --timeout=300 (for 5 minutes)', 'orange'));
|
|
console.error('');
|
|
}, 60000); // 1 minute
|
|
}
|
|
|
|
// Handle timeout if specified
|
|
if (this.timeoutSeconds !== null) {
|
|
const timeoutMs = this.timeoutSeconds * 1000;
|
|
let timeoutId: NodeJS.Timeout;
|
|
|
|
const timeoutPromise = new Promise<void>((_resolve, reject) => {
|
|
timeoutId = setTimeout(async () => {
|
|
// Use smartshell's terminate() to kill entire process tree
|
|
await execResultStreaming.terminate();
|
|
reject(new Error(`Test file timed out after ${this.timeoutSeconds} seconds`));
|
|
}, timeoutMs);
|
|
});
|
|
|
|
try {
|
|
await Promise.race([
|
|
tapParser.handleTapProcess(execResultStreaming.childProcess),
|
|
timeoutPromise
|
|
]);
|
|
// Clear timeout if test completed successfully
|
|
clearTimeout(timeoutId);
|
|
} catch (error) {
|
|
// Clear warning timer if it was set
|
|
if (warningTimer) {
|
|
clearTimeout(warningTimer);
|
|
}
|
|
// Handle timeout error
|
|
tapParser.handleTimeout(this.timeoutSeconds);
|
|
// Ensure entire process tree is killed if still running
|
|
try {
|
|
await execResultStreaming.kill(); // This kills the entire process tree with SIGKILL
|
|
} catch (killError) {
|
|
// Process tree might already be dead
|
|
}
|
|
await tapParser.evaluateFinalResult();
|
|
}
|
|
} else {
|
|
await tapParser.handleTapProcess(execResultStreaming.childProcess);
|
|
}
|
|
|
|
// Clear warning timer if it was set
|
|
if (warningTimer) {
|
|
clearTimeout(warningTimer);
|
|
}
|
|
|
|
return tapParser;
|
|
}
|
|
}
|