@j-o-r/sh
Version:
Execute shell commands on Linux-based systems from javascript
297 lines (277 loc) • 7.53 kB
JavaScript
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;