diff --git a/package-lock.json b/package-lock.json index a43123b..d675b77 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codi-test-framework", - "version": "0.0.32", + "version": "0.0.34", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codi-test-framework", - "version": "0.0.32", + "version": "0.0.34", "license": "MIT", "dependencies": { "chalk": "^5.3.0", diff --git a/src/core/describe.js b/src/core/describe.js new file mode 100644 index 0000000..ea2d607 --- /dev/null +++ b/src/core/describe.js @@ -0,0 +1,32 @@ +import chalk from 'chalk'; +import { state } from '../state/TestState.js'; + +/** + * Create a test suite + * @async + * @function describe + * @param {string} description - Description of the test suite + * @param {Function} callback - Suite callback function + * @returns {Promise} + */ +export async function describe(description, callback) { + const suite = { + description, + tests: [], + startTime: performance.now() + }; + + state.pushSuite(suite); + console.log(chalk.bold.cyan(`\n${description}`)); + + try { + await Promise.resolve(callback()); + } catch (error) { + console.error(chalk.red(`Suite failed: ${description}`)); + console.error(chalk.red(error.stack)); + } finally { + suite.duration = performance.now() - suite.startTime; + state.testResults.push(suite); + state.popSuite(); + } +} \ No newline at end of file diff --git a/src/core/it.js b/src/core/it.js new file mode 100644 index 0000000..5a0ab5a --- /dev/null +++ b/src/core/it.js @@ -0,0 +1,39 @@ +import chalk from 'chalk'; +import { state } from '../state/TestState.js'; + +/** + * Create a test case + * @async + * @function it + * @param {string} description - Test case description + * @param {Function} callback - Test callback function + * @returns {Promise} + * @throws {Error} If called outside a describe block + */ +export async function it(description, callback) { + if (!state.currentSuite) { + throw new Error('Test case defined outside of describe block'); + } + + const test = { + description, + startTime: performance.now() + }; + + try { + await Promise.resolve(callback()); + test.status = 'passed'; + test.duration = performance.now() - test.startTime; + state.passedTests++; + console.log(chalk.green(` ✅ ${description} (${test.duration.toFixed(2)}ms)`)); + } catch (error) { + test.status = 'failed'; + test.error = error; + test.duration = performance.now() - test.startTime; + state.failedTests++; + console.error(chalk.red(` ⛔ ${description} (${test.duration.toFixed(2)}ms)`)); + console.error(chalk.red(` ${error.message}`)); + } + + state.currentSuite.tests.push(test); +} \ No newline at end of file diff --git a/src/runners/nodeRunner.js b/src/runners/nodeRunner.js new file mode 100644 index 0000000..0cd594e --- /dev/null +++ b/src/runners/nodeRunner.js @@ -0,0 +1,111 @@ +import fs from 'fs'; +import path from 'path'; +import chalk from 'chalk'; +import { state } from '../state/TestState.js'; +import { excludePattern } from '../util/regex.js'; + +/** + * Run a single test file + * @async + * @function runTestFile + * @param {string} testFile - Path to test file + * @returns {Promise} + */ +async function runTestFile(testFile) { + try { + const fileUrl = path.isAbsolute(testFile) + ? `file://${testFile}` + : `file://${path.resolve(testFile)}`; + + await import(fileUrl); + } catch (error) { + console.error(chalk.red(`\nError running test file ${chalk.underline(testFile)}:`)); + console.error(chalk.red(error.stack)); + state.failedTests++; + } +} + +/** + * Run tests in a directory + * @async + * @function runTests + * @param {string} testDirectory - Directory containing tests + * @param {boolean} [returnResults=false] - Whether to return results + * @param {object} [codiConfig={}] - Configuration object + * @returns {Promise} Test results if returnResults is true + */ +export async function runTests(testDirectory, returnResults = false, codiConfig = {}) { + state.resetCounters(); + state.startTimer(); + + let testFiles = fs.readdirSync(testDirectory, { recursive: true }) + .filter(file => file.endsWith('.mjs')); + + if (codiConfig.excludeDirectories) { + const matcher = excludePattern(codiConfig.excludeDirectories); + testFiles = testFiles.filter(file => !matcher(file)); + } + + console.log(chalk.bold.magenta(`\nRunning tests in directory: ${chalk.underline(testDirectory)}`)); + console.log(chalk.bold.magenta(`Found ${testFiles.length} test file(s)\n`)); + + for (const file of testFiles) { + await runTestFile(path.join(testDirectory, file)); + } + + const summary = { + passedTests: state.passedTests, + failedTests: state.failedTests, + testResults: state.testResults, + executionTime: state.getExecutionTime() + }; + + console.log(chalk.bold.cyan('\nTest Summary:')); + console.log(chalk.green(` Passed: ${summary.passedTests}`)); + console.log(chalk.red(` Failed: ${summary.failedTests}`)); + console.log(chalk.blue(` Time: ${summary.executionTime}s`)); + + if (returnResults) return summary; + + if (state.failedTests > 0) { + console.log(chalk.red('\nSome tests failed.')); + process.exit(1); + } else { + console.log(chalk.green('\nAll tests passed.')); + process.exit(0); + } +} + +/** + * Run a single test function + * @async + * @function runTestFunction + * @param {Function} testFn - Test function to run + * @returns {Promise} Test results + */ +export async function runTestFunction(testFn) { + const suite = { + description: `Function: ${testFn.name}`, + tests: [], + startTime: performance.now() + }; + + state.pushSuite(suite); + + try { + await Promise.resolve(testFn()); + } catch (error) { + console.error(`Error in test ${testFn.name}:`, error); + state.failedTests++; + } finally { + suite.duration = performance.now() - suite.startTime; + state.testResults.push(suite); + state.popSuite(); + } + + return { + passedTests: state.passedTests, + failedTests: state.failedTests, + testResults: state.testResults + }; +} \ No newline at end of file diff --git a/src/runners/webRunner.js b/src/runners/webRunner.js new file mode 100644 index 0000000..dce3d12 --- /dev/null +++ b/src/runners/webRunner.js @@ -0,0 +1,175 @@ +import { state } from '../state/TestState.js'; +import chalk from 'chalk'; + +/** + * Run a web test file + * @async + * @function runWebTestFile + * @param {string} testFile - Path to test file + * @param {object} [options={}] - Options for running the test + * @returns {Promise} + */ +export async function runWebTestFile(testFile, options = {}) { + const { + timeout = 5000, + silent = false + } = options; + + try { + const startTime = performance.now(); + + const testPromise = import(testFile); + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error(`Test file ${testFile} timed out after ${timeout}ms`)), timeout); + }); + + await Promise.race([testPromise, timeoutPromise]); + + const duration = performance.now() - startTime; + if (!silent) { + console.log(chalk.green(`✅ ${path.basename(testFile)} (${duration.toFixed(2)}ms)`)); + } + } catch (error) { + console.error(`Error running test file ${testFile}:`); + console.error(error.stack); + state.failedTests++; + } +} + +/** + * Run web tests + * @async + * @function runWebTests + * @param {string[]} testFiles - Array of test files + * @param {object} [options={}] - Options for running the tests + * @returns {Promise} Test results + */ +export async function runWebTests(testFiles, options = {}) { + const { + parallel = false, + timeout = 5000, + silent = false, + batchSize = 5 + } = options; + + state.resetCounters(); + state.startTimer(); + + if (!silent) { + console.log(chalk.bold.magenta(`\nRunning ${testFiles.length} web test file(s)`)); + } + + try { + if (parallel) { + if (batchSize > 0) { + // Run tests in batches + const batches = []; + for (let i = 0; i < testFiles.length; i += batchSize) { + batches.push(testFiles.slice(i, i + batchSize)); + } + + for (const [index, batch] of batches.entries()) { + if (!silent) { + console.log(chalk.blue(`\nBatch ${index + 1}/${batches.length}`)); + } + + await Promise.all( + batch.map(file => runWebTestFile(file, { timeout, silent })) + ); + } + } else { + // Run all tests in parallel + await Promise.all( + testFiles.map(file => runWebTestFile(file, { timeout, silent })) + ); + } + } else { + // Run tests sequentially + for (const file of testFiles) { + await runWebTestFile(file, { timeout, silent }); + } + } + } catch (error) { + console.error(chalk.red('\nTest execution failed:')); + console.error(chalk.red(error.stack)); + } + + const summary = { + totalTests: state.passedTests + state.failedTests, + passedTests: state.passedTests, + failedTests: state.failedTests, + executionTime: state.getExecutionTime(), + testResults: state.testResults + }; + + if (!silent) { + console.log(chalk.bold.cyan('\nTest Summary:')); + console.log(chalk.blue(` Total: ${summary.totalTests}`)); + console.log(chalk.green(` Passed: ${summary.passedTests}`)); + console.log(chalk.red(` Failed: ${summary.failedTests}`)); + console.log(chalk.blue(` Time: ${summary.executionTime}s`)); + } + + return summary; +} + +/** + * Run a web test function + * @async + * @function runWebTestFunction + * @param {Function} testFn - Test function to run + * @param {object} [options={}] - Options for running the test + * @returns {Promise} Test results + */ +export async function runWebTestFunction(testFn, options = {}) { + const { + timeout = 5000, + silent = false + } = options; + + state.resetCounters(); + state.startTimer(); + + const suite = { + description: `Function: ${testFn.name}`, + tests: [], + startTime: performance.now() + }; + + state.pushSuite(suite); + + try { + const testPromise = Promise.resolve(testFn()); + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error(`Test function ${testFn.name} timed out after ${timeout}ms`)), timeout); + }); + + await Promise.race([testPromise, timeoutPromise]); + } catch (error) { + console.error(chalk.red(`Error in test ${testFn.name}:`)); + console.error(chalk.red(error.stack)); + state.failedTests++; + } finally { + suite.duration = performance.now() - suite.startTime; + state.testResults.push(suite); + state.popSuite(); + } + + const summary = { + totalTests: state.passedTests + state.failedTests, + passedTests: state.passedTests, + failedTests: state.failedTests, + executionTime: state.getExecutionTime(), + testResults: state.testResults + }; + + if (!silent) { + console.log(chalk.bold.cyan('\nTest Summary:')); + console.log(chalk.blue(` Total: ${summary.totalTests}`)); + console.log(chalk.green(` Passed: ${summary.passedTests}`)); + console.log(chalk.red(` Failed: ${summary.failedTests}`)); + console.log(chalk.blue(` Time: ${summary.executionTime}s`)); + } + + return summary; +} \ No newline at end of file diff --git a/src/state/TestState.js b/src/state/TestState.js new file mode 100644 index 0000000..6cf419d --- /dev/null +++ b/src/state/TestState.js @@ -0,0 +1,85 @@ +// src/state/TestState.js + +/** + * Class representing the state of test execution + * @class TestState + */ +class TestState { + /** + * Create a test state instance + * @constructor + */ + constructor() { + /** @type {number} Number of passed tests */ + this.passedTests = 0; + + /** @type {number} Number of failed tests */ + this.failedTests = 0; + + /** @type {Array} Collection of test results */ + this.testResults = []; + + /** @type {Array} Stack of active test suites */ + this.suiteStack = []; + + /** @type {number|null} Test start time */ + this.startTime = null; + } + + /** + * Reset all counters and state + * @method + */ + resetCounters() { + this.passedTests = 0; + this.failedTests = 0; + this.testResults = []; + this.suiteStack = []; + } + + /** + * Start the test timer + * @method + */ + startTimer() { + this.startTime = performance.now(); + } + + /** + * Get the total execution time + * @method + * @returns {string} Execution time in seconds + */ + getExecutionTime() { + return ((performance.now() - this.startTime) / 1000).toFixed(2); + } + + /** + * Get the current test suite + * @method + * @returns {object|null} Current test suite or null if none active + */ + get currentSuite() { + return this.suiteStack[this.suiteStack.length - 1] || null; + } + + /** + * Add a new suite to the stack + * @method + * @param {object} suite - Test suite to add + */ + pushSuite(suite) { + this.suiteStack.push(suite); + } + + /** + * Remove and return the top suite from the stack + * @method + * @returns {object|undefined} Removed test suite + */ + popSuite() { + return this.suiteStack.pop(); + } +} + +export const state = new TestState(); \ No newline at end of file diff --git a/src/testRunner.js b/src/testRunner.js index 3ba6c4e..298419f 100755 --- a/src/testRunner.js +++ b/src/testRunner.js @@ -1,9 +1,27 @@ +import chalk from 'chalk'; import fs from 'fs'; import path from 'path'; -import chalk from 'chalk'; +// Import runTests directly to use in runCLI +import { runTests as nodeRunTests } from './runners/nodeRunner.js'; import assertions from './assertions/_assertions.js'; -import { excludePattern } from './util/regex.js'; -// Assertion functions + +// Core exports +export { describe } from './core/describe.js'; +export { it } from './core/it.js'; + +// Runner exports - re-export everything +export { + runTests, + runTestFunction +} from './runners/nodeRunner.js'; + +export { + runWebTests, + runWebTestFile, + runWebTestFunction +} from './runners/webRunner.js'; + +// Assertion exports export const assertEqual = assertions.assertEqual; export const assertNotEqual = assertions.assertNotEqual; export const assertTrue = assertions.assertTrue; @@ -11,172 +29,23 @@ export const assertFalse = assertions.assertFalse; export const assertThrows = assertions.assertThrows; export const assertNoDuplicates = assertions.assertNoDuplicates; -let passedTests = 0; -let failedTests = 0; -let testResults = []; -let version = 'v0.0.34'; - -export async function describe(description, callback) { - console.log(chalk.bold.cyan(`\n${description}`)); - const describe = { - [description]: [] - } - testResults.push(describe); - await callback(); -} - -export async function it(description, callback) { - const itObj = { - [description]: [] - } - - const currentDescribeObj = testResults[testResults.length - 1]; - - try { - await callback(); - console.log(chalk.green(` ✅ ${description}`)); - itObj[description] = 'passed'; - passedTests++; - } catch (error) { - console.error(chalk.red(` ⛔ ${description}`)); - console.error(chalk.red(` ${error.message}`)); - itObj[description] = 'failed'; - failedTests++; - } - - Object.values(currentDescribeObj)[0].push(itObj); -} - -// Function to run a single test file -async function runTestFile(testFile) { - try { - // Convert the file path to a valid file:// URL on Windows - const fileUrl = path.isAbsolute(testFile) - ? `file://${testFile}` - : `file://${path.resolve(testFile)}`; - - // Import the test file as an ES module using the file URL - await import(fileUrl); - } catch (error) { - console.error(chalk.red(`\nError running test file ${chalk.underline(testFile)}:`)); - console.error(chalk.red(error.stack)); - failedTests++; - } -} - -// Function to run all test files in a directory -export async function runTests(testDirectory, returnResults = false, codiConfig) { - // Read all files in the test directory - const matcher = excludePattern(codiConfig.excludeDirectories); - let testFiles = fs.readdirSync(testDirectory, { recursive: true }).filter(file => file.endsWith('.mjs')); - testFiles = testFiles.filter(file => !matcher(file)); - - console.log(chalk.bold.magenta(`\nRunning tests in directory: ${chalk.underline(testDirectory)}`)); - console.log(chalk.bold.magenta(`Found ${testFiles.length} test file(s)\n`)); - - // Run each test file sequentially - for (const file of testFiles) { - const testFile = path.join(testDirectory, file); - await runTestFile(testFile); - } - - // Print the test summary - console.log(chalk.bold.cyan('\nTest Summary:')); - console.log(chalk.green(` Passed: ${passedTests}`)); - console.log(chalk.red(` Failed: ${failedTests}`)); - - if (returnResults) { - - let results = { - passedTests, - failedTests, - testResults - }; - - return results; - } - - // Exit the process with the appropriate status code - if (failedTests > 0) { - console.log(chalk.red('\nSome tests failed.')); - process.exit(1); - } else { - console.log(chalk.green('\nAll tests passed.')); - process.exit(0); - } -} - -// Function to run a single test file -async function runWebTestFile(testFile) { - try { - await import(testFile); - } catch (error) { - console.error(`Error running test file ${testFile}:`); - console.error(error.stack); - failedTests++; - } -} +export const version = 'v0.0.34'; -// Function to run all test files -export async function runWebTests(testFiles) { - console.log(`Running ${testFiles.length} test file(s)`); - - // Run each test file sequentially - for (const file of testFiles) { - await runWebTestFile(file); - } - - console.log(chalk.bold.cyan('\nTest Summary:')); - console.log(chalk.green(` Passed: ${passedTests}`)); - console.log(chalk.red(` Failed: ${failedTests}`)); - - return { - passedTests, - failedTests, - testResults - } -} - - -export async function runTestFunction(testFn) { - - try { - await testFn(); - } catch (error) { - console.error(`Error in test ${testFn.name}:`, error); - } - - console.log(chalk.bold.cyan('\nTest Summary:')); - console.log(chalk.green(` Passed: ${passedTests}`)); - console.log(chalk.red(` Failed: ${failedTests}`)); - - return { - passedTests, - failedTests, - testResults - } -} - -// CLI function +/** + * CLI entry point for running tests + * @function runCLI + */ export async function runCLI() { const testDirectory = process.argv[2]; const returnResults = process.argv.includes('--returnResults'); const returnVersion = process.argv.includes('--version'); + const configPathIndex = process.argv.indexOf('--config'); let codiConfig = {}; - try { - const currentDir = process.cwd(); - const codiFilePath = path.join(currentDir, 'codi.json'); - - // await fs.access(codiFilePath); - - const codiFileContent = fs.readFileSync(codiFilePath, 'utf-8'); - codiConfig = JSON.parse(codiFileContent); - } - catch (err) { - console.log(err); - } + const configPath = configPathIndex !== -1 + ? process.argv[configPathIndex + 1] + : path.join(process.cwd(), 'codi.json'); if (returnVersion) { console.log(chalk.blue(`🐶 Woof! Woof!: ${chalk.green(version)}`)); @@ -188,11 +57,19 @@ export async function runCLI() { process.exit(1); } + try { + const fileContent = fs.readFileSync(configPath, 'utf8'); + codiConfig = JSON.parse(fileContent); + } catch (err) { + if (err.code !== 'ENOENT') { + throw err; + } + console.log(chalk.yellow(`No config file found at ${configPath}, proceeding with default settings`)); + } + console.log(chalk.bold.cyan('='.repeat(40))); console.log(chalk.bold.cyan('Running tests...')); console.log(chalk.bold.cyan('='.repeat(40))); - console.log(codiConfig); - runTests(testDirectory, returnResults, codiConfig); - + await nodeRunTests(testDirectory, returnResults, codiConfig); } \ No newline at end of file diff --git a/tests/example.test.mjs b/tests/example.test.mjs index c628a69..4ce93ea 100644 --- a/tests/example.test.mjs +++ b/tests/example.test.mjs @@ -1,12 +1,10 @@ import { describe, it, assertEqual, assertNotEqual, assertTrue, assertFalse, assertThrows, assertNoDuplicates, runTestFunction } from '../src/testRunner.js'; - import { helloworld } from '../example/example.mjs'; - import pkg from '../example/common.cjs'; const { helloCommon } = pkg; +// First test suite await describe('I am an Example Test Suite', () => { - helloworld(); helloCommon(); @@ -44,15 +42,12 @@ await describe('I am an Example Test Suite', () => { }); }); - +// Nested describe for a new context await describe('Running testFunction', async () => { - function testFunction() { it('First', () => { assertEqual(1, 1, 'Expected 1 to equal 1'); - }) + }); } - await runTestFunction(testFunction); - }); \ No newline at end of file