441 lines
16 KiB
TypeScript
441 lines
16 KiB
TypeScript
import * as plugins from './tstest.plugins.js';
|
|
import * as paths from './tstest.paths.js';
|
|
|
|
import { coloredString as cs } from '@push.rocks/consolecolor';
|
|
|
|
import { TestDirectory } from './tstest.classes.testdirectory.js';
|
|
import { TapCombinator } from './tstest.classes.tap.combinator.js';
|
|
import { TapParser } from './tstest.classes.tap.parser.js';
|
|
import { TestExecutionMode } from './index.js';
|
|
import { TsTestLogger } from './tstest.logging.js';
|
|
import type { LogOptions } from './tstest.logging.js';
|
|
|
|
// Runtime adapters
|
|
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';
|
|
|
|
// Test file directives
|
|
import {
|
|
parseDirectivesFromFile,
|
|
mergeDirectives,
|
|
directivesToRuntimeOptions,
|
|
hasDirectives,
|
|
} from './tstest.classes.testfile.directives.js';
|
|
|
|
// Before-script support
|
|
import { loadBeforeScripts, runBeforeScript, type IBeforeScripts } from './tstest.classes.beforescripts.js';
|
|
|
|
export class TsTest {
|
|
public testDir: TestDirectory;
|
|
public executionMode: TestExecutionMode;
|
|
public logger: TsTestLogger;
|
|
public filterTags: string[];
|
|
public startFromFile: number | null;
|
|
public stopAtFile: number | null;
|
|
public timeoutSeconds: number | null;
|
|
|
|
public smartshellInstance = new plugins.smartshell.Smartshell({
|
|
executor: 'bash',
|
|
pathDirectories: [paths.binDirectory],
|
|
sourceFilePaths: [],
|
|
});
|
|
public smartbrowserInstance = new plugins.smartbrowser.SmartBrowser();
|
|
|
|
public tsbundleInstance = new plugins.tsbundle.TsBundle();
|
|
|
|
public runtimeRegistry = new RuntimeAdapterRegistry();
|
|
public dockerAdapter: DockerRuntimeAdapter | null = null;
|
|
|
|
private beforeScripts: IBeforeScripts | 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.testDir = new TestDirectory(cwdArg, testPathArg, executionModeArg);
|
|
this.logger = new TsTestLogger(logOptions);
|
|
this.filterTags = tags;
|
|
this.startFromFile = startFromFile;
|
|
this.stopAtFile = stopAtFile;
|
|
this.timeoutSeconds = timeoutSeconds;
|
|
|
|
// Register runtime adapters
|
|
this.runtimeRegistry.register(
|
|
new NodeRuntimeAdapter(this.logger, this.smartshellInstance, this.timeoutSeconds, this.filterTags)
|
|
);
|
|
this.runtimeRegistry.register(
|
|
new ChromiumRuntimeAdapter(this.logger, this.tsbundleInstance, this.smartbrowserInstance, this.timeoutSeconds)
|
|
);
|
|
this.runtimeRegistry.register(
|
|
new DenoRuntimeAdapter(this.logger, this.smartshellInstance, this.timeoutSeconds, this.filterTags)
|
|
);
|
|
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
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Check and display available runtimes
|
|
*/
|
|
private async checkEnvironment() {
|
|
const availability = await this.runtimeRegistry.checkAvailability();
|
|
this.logger.environmentCheck(availability);
|
|
return availability;
|
|
}
|
|
|
|
async run() {
|
|
// Check and display environment
|
|
await this.checkEnvironment();
|
|
|
|
// Move previous log files if --logfile option is used
|
|
if (this.logger.options.logFile) {
|
|
await this.movePreviousLogFiles();
|
|
}
|
|
|
|
// Load and execute test:before script (runs once per test run)
|
|
this.beforeScripts = loadBeforeScripts(this.testDir.cwd);
|
|
if (this.beforeScripts.beforeOnce) {
|
|
const success = await runBeforeScript(
|
|
this.smartshellInstance,
|
|
this.beforeScripts.beforeOnce,
|
|
'test:before',
|
|
this.logger,
|
|
);
|
|
if (!success) {
|
|
this.logger.error('test:before script failed. Aborting test run.');
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
const testGroups = await this.testDir.getTestFileGroups();
|
|
const allFiles = [...testGroups.serial, ...Object.values(testGroups.parallelGroups).flat()];
|
|
|
|
// Log test discovery - always show full count
|
|
this.logger.testDiscovery(
|
|
allFiles.length,
|
|
this.testDir.testPath,
|
|
this.executionMode
|
|
);
|
|
|
|
const tapCombinator = new TapCombinator(this.logger); // lets create the TapCombinator
|
|
let fileIndex = 0;
|
|
|
|
// Execute serial tests first
|
|
for (const fileNameArg of testGroups.serial) {
|
|
fileIndex++;
|
|
await this.runSingleTestOrSkip(fileNameArg, fileIndex, allFiles.length, tapCombinator);
|
|
}
|
|
|
|
// Execute parallel groups sequentially
|
|
const groupNames = Object.keys(testGroups.parallelGroups).sort();
|
|
for (const groupName of groupNames) {
|
|
const groupFiles = testGroups.parallelGroups[groupName];
|
|
|
|
if (groupFiles.length > 0) {
|
|
this.logger.sectionStart(`Parallel Group: ${groupName}`);
|
|
|
|
// Run all tests in this group in parallel
|
|
const parallelPromises = groupFiles.map(async (fileNameArg) => {
|
|
fileIndex++;
|
|
return this.runSingleTestOrSkip(fileNameArg, fileIndex, allFiles.length, tapCombinator);
|
|
});
|
|
|
|
await Promise.all(parallelPromises);
|
|
this.logger.sectionEnd();
|
|
}
|
|
}
|
|
|
|
tapCombinator.evaluate();
|
|
}
|
|
|
|
/**
|
|
* Clean up all long-lived resources so the Node.js event loop can drain naturally.
|
|
*/
|
|
public async cleanup() {
|
|
this.smartshellInstance.smartexit.deregister();
|
|
}
|
|
|
|
public async runWatch(ignorePatterns: string[] = []) {
|
|
const smartwatchInstance = new plugins.smartwatch.Smartwatch([this.testDir.cwd]);
|
|
|
|
console.clear();
|
|
this.logger.watchModeStart();
|
|
|
|
// Initial run
|
|
await this.run();
|
|
|
|
// Set up file watcher
|
|
const fileChanges = new Map<string, NodeJS.Timeout>();
|
|
const debounceTime = 300; // 300ms debounce
|
|
|
|
const runTestsAfterChange = async () => {
|
|
console.clear();
|
|
const changedFiles = Array.from(fileChanges.keys());
|
|
fileChanges.clear();
|
|
|
|
this.logger.watchModeRerun(changedFiles);
|
|
await this.run();
|
|
this.logger.watchModeWaiting();
|
|
};
|
|
|
|
// Start watching before subscribing to events
|
|
await smartwatchInstance.start();
|
|
|
|
// Subscribe to file change events
|
|
const changeObservable = await smartwatchInstance.getObservableFor('change');
|
|
const addObservable = await smartwatchInstance.getObservableFor('add');
|
|
const unlinkObservable = await smartwatchInstance.getObservableFor('unlink');
|
|
|
|
const handleFileChange = (changedPath: string) => {
|
|
// Skip if path matches ignore patterns
|
|
if (ignorePatterns.some(pattern => changedPath.includes(pattern))) {
|
|
return;
|
|
}
|
|
|
|
// Clear existing timeout for this file if any
|
|
if (fileChanges.has(changedPath)) {
|
|
clearTimeout(fileChanges.get(changedPath));
|
|
}
|
|
|
|
// Set new timeout for this file
|
|
const timeout = setTimeout(() => {
|
|
fileChanges.delete(changedPath);
|
|
if (fileChanges.size === 0) {
|
|
runTestsAfterChange();
|
|
}
|
|
}, debounceTime);
|
|
|
|
fileChanges.set(changedPath, timeout);
|
|
};
|
|
|
|
// Subscribe to all relevant events
|
|
changeObservable.subscribe(([path]) => handleFileChange(path));
|
|
addObservable.subscribe(([path]) => handleFileChange(path));
|
|
unlinkObservable.subscribe(([path]) => handleFileChange(path));
|
|
|
|
this.logger.watchModeWaiting();
|
|
|
|
// Handle Ctrl+C to exit gracefully
|
|
process.on('SIGINT', async () => {
|
|
this.logger.watchModeStop();
|
|
await smartwatchInstance.stop();
|
|
process.exit(0);
|
|
});
|
|
|
|
// Keep the process running
|
|
await new Promise(() => {}); // This promise never resolves
|
|
}
|
|
|
|
private async runSingleTestOrSkip(fileNameArg: string, fileIndex: number, totalFiles: number, tapCombinator: TapCombinator) {
|
|
// Check if this file should be skipped based on range
|
|
if (this.startFromFile !== null && fileIndex < this.startFromFile) {
|
|
this.logger.testFileSkipped(fileNameArg, fileIndex, totalFiles, `before start range (${this.startFromFile})`);
|
|
tapCombinator.addSkippedFile(fileNameArg);
|
|
return;
|
|
}
|
|
|
|
if (this.stopAtFile !== null && fileIndex > this.stopAtFile) {
|
|
this.logger.testFileSkipped(fileNameArg, fileIndex, totalFiles, `after stop range (${this.stopAtFile})`);
|
|
tapCombinator.addSkippedFile(fileNameArg);
|
|
return;
|
|
}
|
|
|
|
// File is in range, run it
|
|
await this.runSingleTest(fileNameArg, fileIndex, totalFiles, tapCombinator);
|
|
}
|
|
|
|
private async runSingleTest(fileNameArg: string, fileIndex: number, totalFiles: number, tapCombinator: TapCombinator) {
|
|
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
|
|
if (process.env.CI && parsed.modifiers.includes('nonci')) {
|
|
this.logger.tapOutput(`Skipping ${fileNameArg} - marked as non-CI`);
|
|
return;
|
|
}
|
|
|
|
// Show deprecation warning for legacy naming
|
|
if (parsed.isLegacy) {
|
|
console.warn('');
|
|
console.warn(cs('⚠️ DEPRECATION WARNING', 'orange'));
|
|
console.warn(cs(` File: ${fileName}`, 'orange'));
|
|
console.warn(cs(` Legacy naming detected. Please migrate to new naming convention.`, 'orange'));
|
|
console.warn(cs(` Suggested: ${fileName.replace('.browser.', '.chromium.').replace('.both.', '.node+chromium.')}`, 'green'));
|
|
console.warn(cs(` Run: tstest migrate --dry-run`, 'cyan'));
|
|
console.warn('');
|
|
}
|
|
|
|
// Get adapters for the specified runtimes
|
|
const adapters = this.runtimeRegistry.getAdaptersForRuntimes(parsed.runtimes);
|
|
|
|
if (adapters.length === 0) {
|
|
this.logger.tapOutput(`Skipping ${fileNameArg} - no runtime adapters available`);
|
|
return;
|
|
}
|
|
|
|
// Parse directives from the test file (e.g., // tstest:deno:allowAll)
|
|
let directives = await parseDirectivesFromFile(fileNameArg);
|
|
|
|
// Also check for directives in 00init.ts
|
|
const testDir = plugins.path.dirname(fileNameArg);
|
|
const initFile = plugins.path.join(testDir, '00init.ts');
|
|
const initFileExists = await plugins.smartfsInstance.file(initFile).exists();
|
|
if (initFileExists) {
|
|
const initDirectives = await parseDirectivesFromFile(initFile);
|
|
directives = mergeDirectives(initDirectives, directives);
|
|
}
|
|
|
|
// Execute tests for each runtime
|
|
if (adapters.length === 1) {
|
|
// Single runtime - no sections needed
|
|
const adapter = adapters[0];
|
|
|
|
// Run test:before:testfile if defined
|
|
if (this.beforeScripts?.beforeTestfile) {
|
|
const success = await runBeforeScript(
|
|
this.smartshellInstance,
|
|
this.beforeScripts.beforeTestfile,
|
|
`test:before:testfile (${fileName})`,
|
|
this.logger,
|
|
{ TSTEST_FILE: fileNameArg, TSTEST_RUNTIME: adapter.id },
|
|
);
|
|
if (!success) {
|
|
this.logger.error(`test:before:testfile failed for ${fileName}. Skipping test file.`);
|
|
return;
|
|
}
|
|
}
|
|
|
|
const options = hasDirectives(directives) ? directivesToRuntimeOptions(directives, adapter.id) : undefined;
|
|
const tapParser = await adapter.run(fileNameArg, fileIndex, totalFiles, options);
|
|
tapCombinator.addTapParser(tapParser);
|
|
} else {
|
|
// Multiple runtimes - use sections
|
|
for (let i = 0; i < adapters.length; i++) {
|
|
const adapter = adapters[i];
|
|
this.logger.sectionStart(`Part ${i + 1}: ${adapter.displayName}`);
|
|
|
|
// Run test:before:testfile if defined (runs before each runtime)
|
|
if (this.beforeScripts?.beforeTestfile) {
|
|
const success = await runBeforeScript(
|
|
this.smartshellInstance,
|
|
this.beforeScripts.beforeTestfile,
|
|
`test:before:testfile (${fileName} on ${adapter.displayName})`,
|
|
this.logger,
|
|
{ TSTEST_FILE: fileNameArg, TSTEST_RUNTIME: adapter.id },
|
|
);
|
|
if (!success) {
|
|
this.logger.error(`test:before:testfile failed for ${fileName} on ${adapter.displayName}. Skipping.`);
|
|
this.logger.sectionEnd();
|
|
continue;
|
|
}
|
|
}
|
|
|
|
const options = hasDirectives(directives) ? directivesToRuntimeOptions(directives, adapter.id) : undefined;
|
|
const tapParser = await adapter.run(fileNameArg, fileIndex, totalFiles, options);
|
|
tapCombinator.addTapParser(tapParser);
|
|
this.logger.sectionEnd();
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
// Run test:before:testfile if defined
|
|
if (this.beforeScripts?.beforeTestfile) {
|
|
const success = await runBeforeScript(
|
|
this.smartshellInstance,
|
|
this.beforeScripts.beforeTestfile,
|
|
`test:before:testfile (${fileNameArg} on Docker)`,
|
|
this.logger,
|
|
{ TSTEST_FILE: fileNameArg, TSTEST_RUNTIME: 'docker' },
|
|
);
|
|
if (!success) {
|
|
this.logger.error(`test:before:testfile failed for ${fileNameArg}. Skipping.`);
|
|
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'));
|
|
}
|
|
}
|
|
|
|
private async movePreviousLogFiles() {
|
|
const logDir = plugins.path.join('.nogit', 'testlogs');
|
|
const previousDir = plugins.path.join('.nogit', 'testlogs', 'previous');
|
|
const errDir = plugins.path.join('.nogit', 'testlogs', '00err');
|
|
const diffDir = plugins.path.join('.nogit', 'testlogs', '00diff');
|
|
|
|
try {
|
|
// Delete 00err and 00diff directories if they exist
|
|
if (plugins.fs.existsSync(errDir) && plugins.fs.statSync(errDir).isDirectory()) {
|
|
plugins.fs.rmSync(errDir, { recursive: true, force: true });
|
|
}
|
|
if (plugins.fs.existsSync(diffDir) && plugins.fs.statSync(diffDir).isDirectory()) {
|
|
plugins.fs.rmSync(diffDir, { recursive: true, force: true });
|
|
}
|
|
|
|
// Get all .log files in log directory (not in subdirectories)
|
|
const entries = await plugins.smartfsInstance.directory(logDir).filter('*.log').list();
|
|
const logFiles = entries.filter((entry) => entry.isFile).map((entry) => entry.name);
|
|
|
|
if (logFiles.length === 0) {
|
|
return;
|
|
}
|
|
|
|
// Ensure previous directory exists
|
|
await plugins.smartfsInstance.directory(previousDir).recursive().create();
|
|
|
|
// Move each log file to previous directory
|
|
for (const filename of logFiles) {
|
|
const sourcePath = plugins.path.join(logDir, filename);
|
|
const destPath = plugins.path.join(previousDir, filename);
|
|
|
|
try {
|
|
// Copy file to new location and remove original
|
|
await plugins.smartfsInstance.file(sourcePath).copy(destPath);
|
|
await plugins.smartfsInstance.file(sourcePath).delete();
|
|
} catch (error) {
|
|
// Silently continue if a file can't be moved
|
|
}
|
|
}
|
|
} catch (error) {
|
|
// Directory might not exist, which is fine
|
|
return;
|
|
}
|
|
}
|
|
}
|