UNPKG

run-verify

Version:
770 lines (689 loc) 21.4 kB
/** * @typedef {Function} CheckFunction * @description A function that performs a verification check * @param {*} [result] - Result from previous check function * @param {Function} [next] - Callback to continue to next check function * @returns {Promise<*>|*} - May return a promise or value directly */ /** * @typedef {Object} DeferObject * @property {number} timeout - Timeout duration in milliseconds * @property {EventEmitter} event - Event emitter for defer operations * @property {Object} handlers - Resolve and reject handlers * @property {Function} resolve - Resolves the deferred operation * @property {Function} reject - Rejects the deferred operation * @property {Function} onResolve - Adds a resolve handler * @property {Function} onReject - Adds a reject handler * @property {Function} pending - Checks if defer is still pending * @property {Function} clear - Clears the defer state * @property {Function} wait - Waits for defer to complete * @property {Function} waitAgain - Waits again for defer to complete */ "use strict"; /* eslint-disable complexity, no-magic-numbers, max-statements, prefer-const */ // // runVerify(checkFunc1, [checkFunc2, ..., checkFuncN], doneFunc); // // Will call each checkFunc in series. // // checkFunc can take 0, 1, or 2 params // // For 0 param: // - return a promise if it wants to be async // - its resolved value used as result for next checkFunc // - or only sync (its returned value used as result for next checkFunc) // // For 1 param: (next) or (result) // - take a next callback (param name must start with one of: next, cb, callback, done) // - take a result // - return a promise if it wants to be async // - its resolved value used as result for next checkFunc // - or only sync (its returned value used as result for next checkFunc) // // For 2 params: (result, next) // - result - result from previous checkFunc // - next - callback to continue to next checkFunc // // If async/await is supported natively, then checkFunc can be async and will // always be treated as a promise returning function. // // For next callback: (err, result) // // If any checkFunc throws or call next with err, then execution terminates // and done is called with err. // // If all checkFunc completed successfully, done is called with (null, result) // const assert = require("assert"); const { EventEmitter } = require("events"); const { WRAPPED_FN, IS_FINALLY, DEFER_EVENT, DEFER_WAIT, DEFER_OBJ } = require("./symbols"); function detectWantCallbackByParamName(checkFunc, index, done) { // takes single param, ambiguous function type // function could be asking for next cb // or want the previous result const funcStr = checkFunc.toString().trim(); let params; // match fat arrow function const fatIx = funcStr.indexOf("=>"); if (fatIx > 0 && (funcStr.startsWith("()") || funcStr[0] !== "(")) { // () => ... // next => ... params = funcStr.substring(0, fatIx); } else { // match for (param) const match = funcStr.match(/^[^\(]*\(([^\)]+)\)/); /* istanbul ignore next */ if (!match || !match[1]) { /* istanbul ignore next */ return done(new Error(`runVerify param ${index} unable to match arg name`)); } params = match[1]; } params = params.trim().toLowerCase(); return ( params.startsWith("next") || params.startsWith("cb") || params.startsWith("callback") || params.startsWith("done") ); } const errorMsg = (error, message) => { error.message = message; return error; }; function _runVerify(args, errorFromCall) { const finallyCbs = args.filter(x => x[IS_FINALLY] === true); const checkFuncs = args.filter(x => x[IS_FINALLY] !== true); const lastIx = checkFuncs.length - 1; const done = checkFuncs[lastIx]; let index = 0; let timeoutTimer; let failError; const defers = []; let completed; if (checkFuncs.length < 2) { throw errorMsg(errorFromCall, "runVerify - must pass done function"); } const invokeFinally = (err, result) => { assert(!completed, "bug: invokeFinally already called"); completed = true; failError = err && errorMsg(errorFromCall, err.message); const onFail = checkFuncs[index]; let error = err; if (err && onFail && onFail[WRAPPED_FN] && onFail._onFailVerify) { try { onFail[WRAPPED_FN](err, result); } catch (err2) { error = err2; } } let returnFinallyCbs = []; try { finallyCbs.forEach(wrap => returnFinallyCbs.push(wrap[WRAPPED_FN]())); returnFinallyCbs = returnFinallyCbs.filter(x => x); } catch (err2) { error = err2; } const invokeDone = () => { clearTimeout(timeoutTimer); if (done.length > 1) { return done(error, result); } else { return done(error); } }; if (returnFinallyCbs.length > 0) { Promise.all(returnFinallyCbs) .catch(err2 => { if (!error) error = err2; }) .then(invokeDone); } else { invokeDone(); } }; const invokeCheckFunc = prevResult => { if (failError) { return undefined; } if (index >= lastIx) { if (defers.length && !defers.every(x => x.invoked)) { // wait for all defer to resolve return undefined; } return invokeFinally(undefined, prevResult); } const nextCheckFunc = r => { index++; return invokeCheckFunc(r); }; let wrap = {}; let checkFunc = checkFuncs[index]; const addDefer = defer => { defers.push(defer); const invokeDeferHandlers = (handlers, value) => { for (const h of handlers) { try { h(value); } catch (err) { defer.failed = true; defer.error = err; break; } } return undefined; }; const onDefer = (err, r) => { if (!failError && !defer.invoked) { defer.invoked = true; if (!err) { invokeDeferHandlers(defer.handlers.resolve, r); } else { invokeDeferHandlers(defer.handlers.reject, err); } const errors = defers.map(x => x.error).filter(x => x); // fail as soon as a defer failed if (errors.length > 0) { if (!defer[DEFER_WAIT]) { return invokeFinally(errors[0]); } else { return undefined; } } // // If: // - defer is not waiting - all defers resolved - and all checkFunc executed // Then: - finish test with invokeFinally // if (!defer._waiting && defers.every(x => x.invoked) && index >= lastIx) { const results = defers.map(x => x.result); return invokeFinally(undefined, results.length === 1 ? results[0] : results); } } return undefined; }; defer.setAwait({ resolve: r => onDefer(undefined, r), reject: err => onDefer(err), errorFromCall, timeoutMsg: `from runVerify`, waitTimeout: defer.timeout }); }; if (checkFunc.hasOwnProperty(WRAPPED_FN)) { wrap = checkFunc; // skip onFailVerify functions if (wrap._onFailVerify) { return nextCheckFunc(prevResult); } if (wrap._timeout) { clearTimeout(timeoutTimer); timeoutTimer = setTimeout(() => { failError = errorMsg( errorFromCall, `runVerify: test timeout after ${wrap._timeout}ms while waiting for \ run check function number ${index + 1}` ); wrap[WRAPPED_FN](failError); invokeFinally(failError); }, wrap._timeout); return nextCheckFunc(prevResult); } checkFunc = wrap[WRAPPED_FN]; } if (checkFunc[DEFER_EVENT]) { const defer = checkFunc[DEFER_OBJ] || checkFunc; if (defers.indexOf(defer) < 0) { addDefer(defer); } if (!defer[DEFER_WAIT] || checkFunc === defer) { return setTimeout(() => nextCheckFunc(prevResult)); } } const tof = typeof checkFunc; if (tof !== "function") { return invokeFinally( errorMsg(errorFromCall, `runVerify param ${index} is not a function: type ${tof}`) ); } let cbNext; let wantResult = checkFunc.length > 0; if (checkFunc.constructor.name === "AsyncFunction") { cbNext = false; } else if (checkFunc.length > 1) { cbNext = true; } else if ( wrap._withCallback === true || detectWantCallbackByParamName(checkFunc, index, invokeFinally) ) { cbNext = true; wantResult = false; } const prevIndex = index++; const expectError = Boolean(wrap._expectError); const failExpectError = () => { return errorMsg( errorFromCall, `runVerify expecting error from check function number ${prevIndex}` ); }; const invokeWithExpectError = err => { if (wrap._expectError === "has") { if (err.message.indexOf(wrap._expectErrorMsg) < 0) { return invokeFinally( errorMsg( errorFromCall, `runVerify expecting error with message has '${wrap._expectErrorMsg}'` ) ); } } else if (wrap._expectError === "toBe") { if (err.message !== wrap._expectErrorMsg) { return invokeFinally( errorMsg( errorFromCall, `runVerify expecting error with message to be '${wrap._expectErrorMsg}'` ), undefined, index ); } } return invokeCheckFunc(err); }; if (cbNext) { try { const next = expectError ? err => { if (err) return invokeWithExpectError(err); return invokeFinally(failExpectError()); } : (err, r) => { if (err) return invokeFinally(err); return invokeCheckFunc(r); }; if (wantResult) { return checkFunc(prevResult, next); } else { return checkFunc(next); } } catch (err) { return expectError ? invokeWithExpectError(err) : invokeFinally(err); } } else { let result; try { if (wantResult) { result = checkFunc(prevResult); } else { result = checkFunc(); } } catch (err) { return expectError ? invokeWithExpectError(err) : invokeFinally(err); } if (result && result.then && result.catch) { if (expectError) { let error; return result .catch(err => { error = err; }) .then(() => { if (error === undefined) { return invokeFinally(failExpectError()); } else { return invokeWithExpectError(error); } }); } else { return result.then(invokeCheckFunc).catch(invokeFinally); } } else if (expectError) { return invokeFinally(failExpectError()); } else { return invokeCheckFunc(result); } } }; invokeCheckFunc(); } /** * Runs a series of check functions in sequence, with support for async operations * @param {...CheckFunction} args - Check functions to run in sequence, last argument is done callback * @description * Will call each checkFunc in series. * * checkFunc can take 0, 1, or 2 params: * * For 0 param: * - return a promise if it wants to be async * - its resolved value used as result for next checkFunc * - or only sync (its returned value used as result for next checkFunc) * * For 1 param: (next) or (result) * - take a next callback (param name must start with one of: next, cb, callback, done) * - take a result * - return a promise if it wants to be async * - its resolved value used as result for next checkFunc * - or only sync (its returned value used as result for next checkFunc) * * For 2 params: (result, next) * - result - result from previous checkFunc * - next - callback to continue to next checkFunc */ function runVerify(...args) { const errorFromCall = new Error(); /* istanbul ignore next */ if (Error.captureStackTrace) { /* istanbul ignore next */ Error.captureStackTrace(errorFromCall, runVerify); } return _runVerify(args, errorFromCall); } /** * Promise-based version of runVerify * @param {...CheckFunction} args - Check functions to run in sequence * @returns {Promise<*>} Promise that resolves with the final result or rejects with an error */ function asyncVerify(...args) { const errorFromCall = new Error(); /* istanbul ignore next */ if (Error.captureStackTrace) { /* istanbul ignore next */ Error.captureStackTrace(errorFromCall, asyncVerify); } return new Promise((resolve, reject) => { _runVerify([...args, (err, res) => (err ? reject(err) : resolve(res))], errorFromCall); }); } /** * Creates a function that wraps a value in asyncVerify * @param {...CheckFunction} args - Check functions to run after the wrapped value * @returns {Function} Function that takes a value and runs asyncVerify with it */ function wrapAsyncVerify(...args) { return x => asyncVerify(() => x, ...args); } /** * Creates a function that wraps a value in runVerify * @param {...CheckFunction} args - Check functions to run after the wrapped value * @returns {Function} Function that takes a value and runs runVerify with it */ function wrapVerify(...args) { return x => runVerify(() => x, ...args); } /** * Basic function wrapper that marks a function with WRAPPED_FN symbol * @param {Function} fn - Function to wrap * @returns {Object} Wrapped function object */ const wrapFn = fn => { return { [WRAPPED_FN]: fn }; }; /** * Wraps a check function with additional verification capabilities * @param {Function} fn - Function to wrap * @returns {Object} Wrapped function with additional properties for verification */ const wrapCheck = fn => { const wrap = wrapFn(fn); Object.defineProperty(wrap, "expectError", { get() { wrap._expectError = true; return wrap; } }); Object.defineProperty(wrap, "withCallback", { get() { wrap._withCallback = true; return wrap; } }); Object.defineProperty(wrap, "onFailVerify", { get() { wrap._onFailVerify = true; return wrap; } }); wrap.expectErrorHas = msg => { wrap._expectError = "has"; wrap._expectErrorMsg = msg; return wrap; }; wrap.expectErrorToBe = msg => { wrap._expectError = "toBe"; wrap._expectErrorMsg = msg; return wrap; }; wrap.runTimeout = delay => { wrap._timeout = delay; return wrap; }; return wrap; }; /** * Marks a check function to expect an error * @param {Function} fn - Function to wrap * @returns {Object} Wrapped function that expects an error */ const expectError = fn => { return wrapCheck(fn).expectError; }; /** * Marks a check function to expect an error containing specific text * @param {Function} fn - Function to wrap * @param {string} msg - Text that should be contained in the error * @returns {Object} Wrapped function that expects an error with specific text */ const expectErrorHas = (fn, msg) => { return wrapCheck(fn).expectErrorHas(msg); }; /** * Marks a check function to expect an exact error message * @param {Function} fn - Function to wrap * @param {string} msg - Exact error message to expect * @returns {Object} Wrapped function that expects an exact error message */ const expectErrorToBe = (fn, msg) => { return wrapCheck(fn).expectErrorToBe(msg); }; /** * Marks a function to be called on verification failure * @param {Function} fn - Function to wrap * @returns {Object} Wrapped function to be called on failure */ const onFailVerify = fn => { return wrapCheck(fn).onFailVerify; }; /** * Marks a function as using callbacks * @param {Function} fn - Function to wrap * @returns {Object} Wrapped function marked as using callbacks */ const withCallback = fn => { return wrapCheck(fn).withCallback; }; /** * Creates a timeout check function * @param {number} delay - Timeout duration in milliseconds * @param {Function} [fn] - Optional function to run before timeout * @returns {Object} Wrapped function with timeout */ const runTimeout = (delay, fn) => { return wrapCheck(fn || (() => {})).runTimeout(delay); }; /** * Creates a deferred object for handling async operations * @param {number} timeout - Timeout duration in milliseconds * @returns {DeferObject} Deferred object with event handling capabilities */ const runDefer = timeout => { const event = new EventEmitter(); const handlers = { resolve: [], reject: [] }; const d = { timeout, event, handlers, [DEFER_EVENT]: event, resolve(r) { event.emit("resolve", r); if (!this._waiting && !this.invoked) { this.invoked = true; this.result = r; } }, reject(err) { event.emit("reject", err); if (!this._waiting && !this.invoked) { this.invoked = true; this.failed = true; this.error = err; } }, onResolve(cb) { handlers.resolve.push(cb); return d; }, onReject(cb) { handlers.reject.push(cb); return d; }, pending() { return !d.invoked; }, clear() { const fn = () => { if (d._waited) { d._waited = false; } if (d.invoked) { d.invoked = false; d.failed = false; } }; fn(); return fn; }, setAwait({ resolve, reject, errorFromCall, timeoutMsg, waitTimeout }) { if (d.invoked) { if (d.failed) { reject(d.error); } else { resolve(d.result); } } else { d._waiting = true; let timer; let handler; const resolveCb = r => handler("resolve", r); const rejectCb = err => handler("reject", err); handler = (type, v) => { clearTimeout(timer); d._waiting = false; event.removeListener("resolve", resolveCb); event.removeListener("reject", rejectCb); if (type === "reject") { d.failed = true; d.error = v; reject(v); } else { d.result = v; resolve(v); } // set this last, to allow resolve/reject to handle it if they need to d.invoked = true; }; event.on("resolve", resolveCb); event.on("reject", rejectCb); if (waitTimeout > 0) { timer = setTimeout(() => { return ( d.invoked || event.emit( "reject", errorMsg(errorFromCall, `defer timeout after ${waitTimeout}ms - ${timeoutMsg}`) ) ); }, waitTimeout).unref(); } } }, waitAgain(waitTimeout) { return d.wait(waitTimeout, true); }, wait(waitTimeout, again) { const errorFromCall = new Error(); /* istanbul ignore next */ if (Error.captureStackTrace) { /* istanbul ignore next */ Error.captureStackTrace(errorFromCall, d.wait); } const waitFn = () => { const canWait = again || !d._waited; assert( canWait, "defer already waited. To wait again, call waitAgain([ms]) or wait([ms], true), or you should clear it first." ); d._waited = true; return new Promise((resolve, reject) => { d.setAwait({ resolve, reject, errorFromCall, timeoutMsg: "from defer.wait", waitTimeout }); }); }; d[DEFER_WAIT] = true; waitFn[DEFER_OBJ] = d; waitFn[DEFER_EVENT] = event; return waitFn; } }; return d; }; /** * Creates a function to be run at the end of verification * @param {Function} fn - Function to run at the end * @returns {Object} Wrapped function marked as a finally handler */ const runFinally = fn => { const wrap = wrapFn(fn); wrap[IS_FINALLY] = true; return wrap; }; /** * @module run-verify */ module.exports = { /** Run verification checks in sequence */ runVerify, /** Create a function that wraps a value in runVerify */ wrapVerify, /** Run verification checks in sequence, returning a promise */ asyncVerify, /** Create a function that wraps a value in asyncVerify */ wrapAsyncVerify, /** Wrap a check function with additional capabilities */ wrapCheck, /** Mark a function to expect an error */ expectError, /** Mark a function to expect an error containing specific text */ expectErrorHas, /** Mark a function to expect an exact error message */ expectErrorToBe, /** Mark a function to be called on verification failure */ onFailVerify, /** Mark a function as using callbacks */ withCallback, /** Create a timeout check function */ runTimeout, /** Create a function to run at the end of verification */ runFinally, /** Create a deferred object for handling async operations */ runDefer };