@j-o-r/sh
Version:
Execute shell commands on Linux-based systems from javascript
239 lines (226 loc) • 6.12 kB
JavaScript
import { jsType } from './SH.js'
/**
* Valid jsTypes for test methods
*/
const FNC = ['Function', 'AsyncFunction'];
// Settle async calls in a SYNC function
const SETTLE_ASYNC = 50;
/**
* @typedef {(function(): Promise<any>)} AsyncFunction
*/
/**
* @typedef {Object} testDefinition
* @prop {string} description
* @prop {Function|AsyncFunction} callback - syc/ async function
*/
/**
* @typedef {Object} testReport
* @prop {string} description
* @prop {number} duration - start time in MS
* @prop {boolean} executed - Has it been called?
*/
/**
* @typedef {Object} Report
* @prop {number} tests
* @prop {number} duration - start time in MS
* @prop {number} errors - number of errors
* @prop {number} executed - number of tests executed
*/
/**
* Get the current time
* used for calculating a duration
* @returns {number}
*/
function getNow() {
return new Date().getTime();
}
/**
* Get the duration based on a previous gathered start time
* @param {number} start - start time
* @returns {number}
*/
function getDuration(start) {
return new Date().getTime() - start;
}
class Test {
#catchErrors = false;
#currentTest = -1;
/** @type {testDefinition[]} */
#tests = [];
/** @type {testReport[]} */
#reports = [];
/**
* @type {Error[]}
*/
#errors = [];
/** verbosed **/
#quite = false;
/** Timeout in ms to settle async code blocks called from sync methods */
#TO = SETTLE_ASYNC;
/**
* @param {boolean} [quiet] - does not output a report when true, default `false`
*/
constructor(quiet = false) {
if (quiet) {
this.#quite = true;
}
}
/**
* Set the timeout when a synced function is called.
* This settles async code used in a sync function
* and give some time to catch errors (#HACK)
* @param {number} timeout - in MS, default 50
*/
syncTimeout(timeout) {
this.#TO = timeout;
}
/**
* Activate a listener on errors outside the call stack scope
* @param {boolean} active - register / unregister
*/
#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;
}
}
}
/**
*
* @param {string} description
* @param {Function|AsyncFunction} callback - sync / async function
* @throws Error when conditions are not met
* @returns {Test}
*/
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;
}
/**
* Execute tests
* @param {number[]} [execute] - limit the execution tests
* @returns {Promise<Report>}
*/
async run(execute) {
// Detect errors outside the call stack
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.#quite) process.stdout.write(`${i}. ${cb.description} `);
await Promise.resolve(cb.callback());
executed = true;
if (type === 'Function') {
// To settle async calls in sync functions
// (catching errors outside this call stack, that may throw later (=== bad practise))
await new Promise(resolve => setTimeout(resolve, this.#TO)); // This will pause the the current loop.
// add timeout for an honest execution time
start = start + this.#TO;
}
} catch (e) {
executed = true;
if (!errors) {
errors = true
}
error = e;
}
duration = getDuration(start);
if (!this.#quite && !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();
}
/**
* @returns {Report}
*/
#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 (tests !== executed) {
if (!this.#quite) console.log('** Not all tests have been executed **');
}
if (!this.#quite) {
console.log('--------------------------------------------------');
console.log(`Total: ${tests} tests, executed: ${executed} in ${duration} ms - errors: ${errors}`);
}
return { tests, executed, duration, errors };
}
/**
* Empty tests
*/
reset() {
this.#tests = [];
this.#reports = [];
this.#errors = [];
}
/**
* Handle an error for the current test
* @param {Error} err
* @param {boolean} [outside] default false, Error is catched ouside the callscope of the test
*/
#handleError(err, outside = false) {
// A global error is an error catched outside the callstack of the test
const ERR = outside? 'GLOBAL_ERROR' : 'ERROR'
if (this.#currentTest > -1) {
if (!this.#quite) process.stdout.write(`\n`);
// Register this error
// Always print out errors despite #quite
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 {
// Can been thrown from an other test
process.stdout.write(`\x1b[31m-- ${ERR} --\x1b[0m\n`);
}
console.error(err);
this.#errors.push(err);
}
}
}
export default Test;