UNPKG

@j-o-r/sh

Version:

Execute shell commands on Linux-based systems from javascript

297 lines (277 loc) 7.53 kB
import { nextTick } from 'process'; import { jsType } from './SH.js'; import AsyncTracker from './AsyncTracker.js'; /** * Valid jsTypes for test callbacks (from {@link jsType}). * @type {('Function'|'AsyncFunction')[]} */ const FNC = ['Function', 'AsyncFunction']; /** * Default timeout (ms) to settle async calls in sync test functions. * @type {number} */ const SETTLE_ASYNC = 50; /** * @typedef {() => Promise<any>} AsyncFunction * @description Async test callback. */ /** * @typedef {Object} TestDefinition * @property {string} description - Test name/description. * @property {Function | AsyncFunction} callback - Sync/async test function. */ /** * @typedef {Object} TestReport * @property {string} description - Test description. * @property {number} duration - Execution duration (ms). * @property {boolean} executed - Whether the test ran. */ /** * @typedef {Object} TestReportSummary * @property {number} tests - Total tests defined. * @property {number} duration - Total execution time (ms). * @property {number} errors - Number of failures. * @property {number} executed - Number of tests run. */ /** * Returns current timestamp (ms) for duration calculations. * * @returns {number} Current time (Date.getTime()). */ function getNow() { return new Date().getTime(); } /** * Calculates duration from start time. * * @param {number} start - Start timestamp (ms). * @returns {number} Duration (ms). */ function getDuration(start) { return new Date().getTime() - start; } class Test { /** @type {AsyncTracker} */ #promiseTracker; #catchErrors = false; #currentTest = -1; /** @type {TestDefinition[]} */ #tests = []; /** @type {TestReport[]} */ #reports = []; /** @type {Error[]} */ #errors = []; #quiet = false; #TO = SETTLE_ASYNC; /** * Creates a test runner instance. * * Tracks tests, errors, unresolved promises via {@link AsyncTracker}. * Supports sync/async callbacks; detects global errors. * * @param {boolean} [quiet=false] - Suppress console reports. * @example * const t = new Test(); * t.add('basic', () => { throw new Error('fail'); }); * const report = await t.run(); */ constructor(quiet = false) { if (quiet) { this.#quiet = true; } this.#promiseTracker = new AsyncTracker(); this.#promiseTracker.enable('PROMISE'); } /** * Sets timeout for settling async in sync tests (hack for late errors). * * @param {number} timeout - Timeout (ms); default 50. * @example * t.syncTimeout(100); */ syncTimeout(timeout) { this.#TO = timeout; } /** * Adds a test case. * * @param {string} description - Test name. * @param {Function | AsyncFunction} callback - Test function. * @returns {Test} Self for chaining. * @throws {Error} Invalid description (non-string) or callback (not function). * @example * t.add('check 1+1', () => expect(1+1).toBe(2)); */ add(description, callback) { if (typeof description !== 'string') { throw new Error(`'description' should be a string`); } if (!FNC.includes(jsType(callback))) { throw new Error(`'callback' should be a (async) Function`); } this.#tests.push({ description, callback }); return this; } /** * Runs tests (all or selected); returns summary. * * Prints progress/errors; checks unresolved promises post-run. * * @param {number[]} [execute] - Indices of tests to run (default: all). * @returns {Promise<TestReportSummary>} Summary stats. * @example * await t.run([0, 2]); // Run tests 0 and 2 */ async run(execute) { this.#detectErrors(true); let errors = false; let i = 0; const len = this.#tests.length; for (; i < len; i++) { this.#currentTest = i; if (execute && !execute.includes(i)) { continue; } let duration = 0; let start = getNow(); let executed = false; const cb = this.#tests[i]; const type = jsType(cb.callback); this.#reports[i] = { description: cb.description, duration, executed }; let error; try { if (!this.#quiet) process.stdout.write(`${i}. ${cb.description} `); await Promise.resolve(cb.callback()); executed = true; if (type === 'Function') { await new Promise(resolve => setTimeout(resolve, this.#TO)); start = start + this.#TO; } } catch (e) { executed = true; if (!errors) { errors = true; } error = e; } duration = getDuration(start); if (!this.#quiet && !error) process.stdout.write(`(duration: ${duration} ms)\n`); this.#reports[i].duration = duration; this.#reports[i].executed = executed; if (error) { this.#handleError(error); } } this.#currentTest = -1; this.#detectErrors(false); return this.#report(); } /** * Prints unresolved promises report (if any). * * @example * t.unresolved(); */ unresolved() { const count = this.#promiseTracker.report(true); if (count > 0) { console.log('--------------------------------------------------'); console.log(`${count} Unresolved Promises detected.`); } } /** * Resets all tests, reports, errors, tracker. * * @example * t.reset(); */ reset() { this.#tests = []; this.#reports = []; this.#errors = []; this.#promiseTracker.reset(); } /** * Toggles global error listener (uncaughtException). * * @private * @param {boolean} active - Enable/disable. */ #detectErrors(active = true) { const errListener = (err) => { this.#handleError(err, true); }; if (active) { if (!this.#catchErrors) { process.on('uncaughtException', errListener); this.#catchErrors = true; } } else { if (this.#catchErrors) { process.removeListener('uncaughtException', errListener); this.#catchErrors = false; } } } /** * Generates final report summary. * * @private * @returns {Promise<TestReportSummary>} */ async #report() { let duration = 0; let executed = 0; const tests = this.#tests.length; let i = 0; const len = this.#reports.length; for (; i < len; i++) { const r = this.#reports[i]; if (r) { duration = r.duration + duration; if (r.executed) { executed = 1 + executed; } } } const errors = this.#errors.length; if (!this.#quiet) { console.log('--------------------------------------------------'); console.log(`Total: ${tests} tests, executed: ${executed} in ${duration} ms - errors: ${errors}`); if (tests !== executed) { console.log('** Not all tests have been executed **'); } setTimeout(() => { const count = this.#promiseTracker.report(true); if (count > 0) { console.log('--------------------------------------------------'); console.log(`${count} Unresolved Promises detected.`); } }, 10); } return { tests, executed, duration, errors }; } /** * Handles/logs test errors (current or global). * * @private * @param {Error} err - Error instance. * @param {boolean} [outside=false] - Global error flag. */ #handleError(err, outside = false) { const ERR = outside ? 'GLOBAL_ERROR' : 'ERROR'; if (this.#currentTest > -1) { if (!this.#quiet) process.stdout.write(`\n`); const description = this.#reports[this.#currentTest].description; const executed = this.#reports[this.#currentTest].executed; if (executed) { process.stdout.write(`\x1b[31m-- ${ERR} Test: ${this.#currentTest}. ${description} --\x1b[0m\n`); } else { process.stdout.write(`\x1b[31m-- ${ERR} --\x1b[0m\n`); } console.error(err); this.#errors.push(err); } } } export default Test;