@scintilla-network/litest
Version:
Dependency-free test framework with full Vitest API compatibility. Zero-dependency replacement for Vitest to reduce risk of supply chain attacks.
580 lines (499 loc) • 20.9 kB
JavaScript
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { pathToFileURL } from 'url';
import { getTestContext, resetTestContext } from './describe.js';
import { TestReporter } from './reporter.js';
import { c } from './colors.js';
import { withTimeout, resetTestTimeout } from './timeout.js';
import { parseArgs, handleCommands } from './cli.js';
import {
setCurrentTestId,
clearTestHooks,
executeOnTestFinishedHooks,
executeOnTestFailedHooks,
resetTestHooks
} from './test-hooks.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
/**
* @typedef {import('./describe.js').TestSuite} TestSuite
* @typedef {import('./describe.js').TestContext} TestContext
* @typedef {import('./it.js').Test} Test
*/
class TestRunner {
constructor(options = {}) {
this.reporter = new TestReporter(options);
}
/**
* Finds all .spec.js files in the current directory and subdirectories
* @param {string} dir - The directory to search
* @returns {string[]} Array of spec file paths
*/
findSpecFiles(dir = process.cwd()) {
const specFiles = [];
const scanDirectory = (currentDir) => {
try {
const files = fs.readdirSync(currentDir);
for (const file of files) {
const fullPath = path.join(currentDir, file);
const stat = fs.statSync(fullPath);
if (stat.isDirectory() && !file.startsWith('.') && file !== 'node_modules') {
scanDirectory(fullPath);
} else if (file.endsWith('.spec.js')) {
specFiles.push(fullPath);
}
}
} catch (error) {
console.warn(c.warn(`Warning: Could not read directory ${currentDir}: ${error.message}`));
}
};
scanDirectory(dir);
return specFiles;
}
/**
* Validates and resolves a specific file path
* @param {string} filePath - The file path to validate
* @returns {string|null} The resolved path or null if invalid
*/
validateSpecFile(filePath) {
try {
const resolvedPath = path.resolve(filePath);
const stat = fs.statSync(resolvedPath);
if (stat.isFile() && resolvedPath.endsWith('.spec.js')) {
return resolvedPath;
} else if (stat.isFile()) {
console.error(c.error(`Error: ${filePath} is not a .spec.js file`));
return null;
} else {
console.error(c.error(`Error: ${filePath} is not a file`));
return null;
}
} catch (error) {
console.error(c.error(`Error: Cannot find file ${filePath}`));
return null;
}
}
/**
* Runs a single test
* @param {Test} test - The test to run
* @param {string} fullName - The full hierarchical name of the test
* @returns {Promise<Object>} The test result
*/
async runTest(test, fullName) {
const startTime = Date.now();
try {
this.reporter.onTestStart(fullName);
if (test.skip) {
return {
name: test.name,
fullName,
status: 'skipped',
error: null,
duration: 0
};
}
await test.fn();
const duration = Date.now() - startTime;
return {
name: test.name,
fullName,
status: 'passed',
error: null,
duration
};
} catch (error) {
const duration = Date.now() - startTime;
return {
name: test.name,
fullName,
status: 'failed',
error,
duration
};
}
}
/**
* Runs all tests in a test suite
* @param {TestSuite} suite - The test suite to run
* @param {string} parentName - The parent suite name
* @param {boolean} hasOnlyModifier - Whether any .only modifiers are present
* @param {Object[]} parentHooks - Accumulated hooks from parent suites
* @returns {Promise<Object[]>} Array of test results
*/
async runSuite(suite, parentName = '', hasOnlyModifier = false, parentHooks = { beforeEach: [], afterEach: [] }) {
const fullSuiteName = parentName ? `${parentName} ${suite.name}` : suite.name;
const results = [];
this.reporter.onSuiteStart(fullSuiteName);
// Check if this suite should be skipped
if (suite.skip) {
// Skip all tests in this suite
for (const test of suite.tests) {
results.push({
name: test.name,
fullName: `${fullSuiteName} ${test.name}`,
status: 'skipped',
error: null,
duration: 0
});
}
// Skip all nested suites
for (const nestedSuite of suite.suites) {
const nestedResults = await this.runSuite(nestedSuite, fullSuiteName, hasOnlyModifier, parentHooks);
results.push(...nestedResults);
}
this.reporter.onSuiteEnd(fullSuiteName);
return results;
}
// Check if we should run this suite based on .only modifiers
const shouldRunSuite = !hasOnlyModifier || suite.only || this.hasOnlyInChildren(suite);
if (!shouldRunSuite) {
this.reporter.onSuiteEnd(fullSuiteName);
return results;
}
try {
// Run beforeAll hooks for this suite
for (const hook of suite.hooks.beforeAll) {
try {
await withTimeout(hook, 10000); // 10 second timeout for hooks
} catch (hookError) {
throw new Error(`beforeAll hook failed: ${hookError.message}`);
}
}
// Accumulate beforeEach and afterEach hooks from parent suites and this suite
const currentHooks = {
beforeEach: [...parentHooks.beforeEach, ...suite.hooks.beforeEach],
afterEach: [...parentHooks.afterEach, ...suite.hooks.afterEach]
};
// Run tests in this suite
for (const test of suite.tests) {
const shouldRunTest = !hasOnlyModifier || test.only;
if (shouldRunTest) {
const result = await this.runTestWithHooks(test, `${fullSuiteName} ${test.name}`, currentHooks);
results.push(result);
this.reporter.onTestEnd(result);
}
}
// Run nested suites
for (const nestedSuite of suite.suites) {
const nestedResults = await this.runSuite(nestedSuite, fullSuiteName, hasOnlyModifier, currentHooks);
results.push(...nestedResults);
}
// Run afterAll hooks for this suite (in reverse order)
for (let i = suite.hooks.afterAll.length - 1; i >= 0; i--) {
try {
await withTimeout(suite.hooks.afterAll[i], 10000); // 10 second timeout for hooks
} catch (hookError) {
console.warn(c.warn(`afterAll hook failed: ${hookError.message}`));
}
}
} catch (error) {
// If a beforeAll or afterAll hook fails, mark all tests in this suite as failed
console.error(`Hook error in suite "${fullSuiteName}": ${error.message}`);
for (const test of suite.tests) {
if (!hasOnlyModifier || test.only) {
results.push({
name: test.name,
fullName: `${fullSuiteName} ${test.name}`,
status: 'failed',
error: new Error(`Suite hook failed: ${error.message}`),
duration: 0
});
}
}
}
this.reporter.onSuiteEnd(fullSuiteName);
return results;
}
/**
* Runs a single test with beforeEach and afterEach hooks
* @param {Test} test - The test to run
* @param {string} fullName - The full hierarchical name of the test
* @param {Object} hooks - The accumulated hooks to run
* @returns {Promise<Object>} The test result
*/
async runTestWithHooks(test, fullName, hooks) {
const testId = `${fullName}-${Date.now()}-${Math.random()}`;
setCurrentTestId(testId);
const startTime = Date.now();
let result;
try {
this.reporter.onTestStart(fullName);
if (test.skip) {
result = {
name: test.name,
fullName,
status: 'skipped',
error: null,
duration: 0
};
return result;
}
if (test.todo) {
result = {
name: test.name,
fullName,
status: 'todo',
error: null,
duration: 0
};
return result;
}
// Run test with retry logic
let attempts = 0;
const maxAttempts = test.retry + 1;
let lastError;
while (attempts < maxAttempts) {
attempts++;
try {
// Use custom timeout if specified, otherwise use global timeout
const testTimeout = test.timeout || undefined;
await withTimeout(async () => {
// Run beforeEach hooks
for (const hook of hooks.beforeEach) {
try {
await hook();
} catch (hookError) {
throw new Error(`beforeEach hook failed: ${hookError.message}`);
}
}
// Run the actual test
await test.fn();
// Run afterEach hooks (in reverse order)
for (let i = hooks.afterEach.length - 1; i >= 0; i--) {
try {
await hooks.afterEach[i]();
} catch (hookError) {
throw new Error(`afterEach hook failed: ${hookError.message}`);
}
}
}, testTimeout);
// Test passed
const duration = Date.now() - startTime;
// Handle test.fails - if test was expected to fail but passed
if (test.fails) {
result = {
name: test.name,
fullName,
status: 'failed',
error: new Error('Test was expected to fail but passed'),
duration
};
} else {
result = {
name: test.name,
fullName,
status: 'passed',
error: null,
duration
};
}
break;
} catch (error) {
lastError = error;
// Try to run afterEach hooks even if test failed
if (!error.message.includes('timed out')) {
try {
for (let i = hooks.afterEach.length - 1; i >= 0; i--) {
await hooks.afterEach[i]();
}
} catch (hookError) {
console.warn(c.warn(`afterEach hook failed after test failure: ${hookError.message}`));
}
}
// If this was the last attempt or test.fails
if (attempts >= maxAttempts) {
const duration = Date.now() - startTime;
// Handle test.fails - if test was expected to fail and did fail
if (test.fails) {
result = {
name: test.name,
fullName,
status: 'passed',
error: null,
duration
};
} else {
result = {
name: test.name,
fullName,
status: 'failed',
error: lastError,
duration
};
}
break;
}
}
}
return result;
} finally {
// Always execute test hooks
if (result) {
try {
if (result.status === 'failed') {
await executeOnTestFailedHooks(testId, result);
}
await executeOnTestFinishedHooks(testId, result);
} catch (hookError) {
console.warn(c.warn(`Test hook execution failed: ${hookError.message}`));
}
}
// Clean up test hooks
clearTestHooks(testId);
}
}
/**
* Checks if a suite has any .only modifiers in its children
* @param {TestSuite} suite - The test suite to check
* @returns {boolean}
*/
hasOnlyInChildren(suite) {
// Check tests
for (const test of suite.tests) {
if (test.only) return true;
}
// Check nested suites
for (const nestedSuite of suite.suites) {
if (nestedSuite.only || this.hasOnlyInChildren(nestedSuite)) {
return true;
}
}
return false;
}
/**
* Loads and runs a spec file
* @param {string} filePath - The path to the spec file
* @param {boolean} showFileHeader - Whether to show the file header
* @returns {Promise<Object[]>} Array of test results
*/
async runSpecFile(filePath, showFileHeader = true) {
// Start file tracking
this.reporter.onFileStart(filePath);
try {
// Reset test context and timeout before loading new file
resetTestContext();
resetTestTimeout();
// Import the spec file
const fileUrl = pathToFileURL(path.resolve(filePath)).href;
await import(fileUrl);
// Get the test context after loading the file
const context = getTestContext();
const results = [];
// Run all test suites
for (const suite of context.suites) {
const suiteResults = await this.runSuite(suite, '', context.hasOnly, { beforeEach: [], afterEach: [] });
results.push(...suiteResults);
}
// End file tracking
this.reporter.onFileEnd(filePath);
return results;
} catch (error) {
console.error(c.error(`❌ Error loading spec file ${filePath}:`));
if (error instanceof SyntaxError) {
console.error(c.muted(` Syntax Error: ${error.message}`));
if (error.stack) {
const stackLines = error.stack.split('\n').slice(0, 3);
stackLines.forEach(line => {
if (line.includes(filePath)) {
console.error(c.muted(` ${line.trim()}`));
}
});
}
} else if (error.code === 'ERR_MODULE_NOT_FOUND') {
console.error(c.muted(` Module not found: ${error.message}`));
console.error(c.muted(` Make sure all imports are correct and dependencies are installed`));
} else if (error.message.includes('Cannot resolve module')) {
console.error(c.muted(` Import Error: ${error.message}`));
console.error(c.muted(` Check your import statements and file paths`));
} else {
console.error(c.muted(` ${error.message}`));
if (process.env.DEBUG) {
console.error(c.muted(` ${error.stack}`));
}
}
// End file tracking even on error
this.reporter.onFileEnd(filePath);
return [];
}
}
/**
* Runs tests from specific files or searches for spec files
* @param {string[]} [filePaths] - Specific file paths to run
* @param {string} [searchDir] - The directory to search for spec files (if no specific files)
* @returns {Promise<void>}
*/
async run(filePaths = [], searchDir = null) {
this.reporter.onRunStart();
let specFiles = [];
if (filePaths.length > 0) {
// Validate specific file paths
for (const filePath of filePaths) {
const validatedPath = this.validateSpecFile(filePath);
if (validatedPath) {
specFiles.push(validatedPath);
} else {
process.exit(1);
}
}
} else {
// Search for spec files
specFiles = this.findSpecFiles(searchDir);
}
if (specFiles.length === 0) {
console.log(c.warn('No .spec.js files found.'));
process.exit(1); // Exit with error code when no tests found
}
// Set file count for reporter
this.reporter.setFileCount(specFiles.length);
// Run all spec files
for (const specFile of specFiles) {
await this.runSpecFile(specFile, true);
}
this.reporter.onRunEnd();
}
}
// CLI entry point
async function runTests(args) {
const runner = new TestRunner({ verbose: args.verbose });
try {
if (args.files.length > 0) {
// Run specific files
await runner.run(args.files);
} else if (args.directories.length > 0) {
// Search in specified directory (use first one)
await runner.run([], args.directories[0]);
} else {
// No arguments - search current directory
await runner.run([], process.cwd());
}
// Success - exit with code 0 (will be overridden by reporter if tests failed)
process.exit(0);
} catch (error) {
console.error(c.error('Fatal error:'), error.message);
if (process.env.DEBUG) {
console.error(error.stack);
}
process.exit(2); // Exit code 2 for fatal errors
}
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
const rawArgs = process.argv.slice(2);
const args = parseArgs(rawArgs);
// Handle commands that don't require running tests
if (handleCommands(args)) {
process.exit(0);
}
await runTests(args);
} else {
// Assume we want to check all files in the current directory by calling itself with '.'
const rawArgs = process.argv.slice(2);
const args = parseArgs(rawArgs);
// Handle commands that don't require running tests
if (handleCommands(args)) {
process.exit(0);
}
await runTests(args);
}
export { TestRunner };