UNPKG

@momsfriendlydevco/testa

Version:

Low-overhead, parallel-first testkit harness

495 lines (424 loc) 12.7 kB
import {cleanError} from './utils.js'; import {styleText} from 'node:util'; import TestaBase from './base.js'; import TestaContext from './context.js'; import timestring from 'timestring'; /** * Main Test class instance */ export default class TestaTest { // Basic state - id(String), location(location:Object), handler(Function), do(Function), title(String), describe(String) {{{ /** * Dev specified ID for the Test * This is useful mainly for dependency checking and referring to tests * * @type {String} */ _id = '#' + TestaBase.testNumber++; /** * Set the short, unique ID of a test * This is mainly used to refer to the test in greps or dependencies * * @param {String} id The new ID to set * @param {*...} [args] Additional args passed to `argsHandler()` * @returns {TestaTest} This chainable instance */ id(id, ...args) { this._id = id; return this.argsHandler(args); } /** * The fullly formatted location string, if any * * @type {Object?} * @property {String} file File path relative to `TestaBase.basePath` * @property {Number} line The line offset within the file * @property {Number} column The column offset within the file */ _location; /** /** * Specify the current test location * This should usually be fed the output of TestaUtils.getLocation() * * @param {Object} location Object containing the tests providence * @param {*...} [args] Additional args passed to `argsHandler()` * @returns {TestaTest} This chainable instance */ location(location, ...args) { this._location = location; return this.argsHandler(args); } /** * The raw, dev-specified test running function * * @type {Function} */ _handler; /** * Set the Test worker function * * @param {Function} cb The test worker function * @param {*...} [args] Additional args passed to `argsHandler()` * @returns {TestaTest} This chainable instance */ handler(cb, ...args) { this._handler = cb; return this.argsDeny(args); } /** * Set the Test worker function * * @alias handler() * @param {Function} cb The test worker function * @param {*...} [args] Additional args passed to `argsHandler()` * @returns {TestaTest} This chainable instance */ do(cb, ...args) { this._handler = cb; return this.argsHandler(args); } /** * A short, pithy title for a test * * @type {String} */ _title; /** * Set the short, pithy title for a test * * @param {String} title The new title to allocate * @param {*...} [args] Additional args passed to `argsHandler()` * @returns {TestaTest} This chainable instance */ title(title, ...args) { this._title = title; return this.argsHandler(args); } /** * Longer form, human-readable description for a test * * @type {String} */ _description; /** * Set the longer form, human-readable description for a test * * @param {String} description The new description to allocate * @param {*...} [args] Additional args passed to `argsHandler()` * @returns {TestaTest} This chainable instance */ describe(description, ...args) { this._description = description; return this.argsHandler(args); } // }}} // Flow - skip(), only(), priority(String|Number), depends(...String) {{{ /** * Whether this test is marked as pre-skipped * * @type {Boolean} */ _skip = false; /** * Optional payload for a reason a test was skipped * * @type {String} */ _skipReason; /** * Mark this test as pre-skipped * Can also use `context.skip()` within a test to do this dynamically * * @param {String} [reason] Optional reason a test was skipped * @param {*...} [args] Additional args passed to `argsHandler()` * @returns {TestaTest} This chainable instance */ skip(reason, ...args) { this._skip = true; this._skipReason = reason; return this.argsHandler(args); } /** * Whether this test is marked for 'only' consideration * If any tests have this marker only those tests are considered as candidates * @type {Boolean} */ _only = false; /** * Mark this test for 'only' consideration * If any tests have this marker only those tests are considered as candidates * * @param {*...} [args] Additional args passed to `argsTitleHandler()` * @returns {TestaTest} This chainable instance */ only(...args) { this._only = true; return this.argsTitleHandler(args); } /** * This tests priority execution order * Higher priorities are executed first * Two meta priorities: 'BEFORE' and 'AFTER' are also supported * * @type {Number|'BEFORE'|'AFTER'} */ _priority; /** * Set the priority of a test * This tests priority execution order * Two meta priorities: 'BEFORE' and 'AFTER' are also supported * * @param {Number|'BEFORE'|'AFTER'} level The level to set, Higher priorities are executed first * @param {*...} [args] Additional args passed to `argsTitleHandler()` * @returns {TestaTest} This chainable instance */ priority(level, ...args) { this._priority = level; return this.argsTitleHandler(args); } /** * List of other test IDs required before this test can execute * * @type {Array<String>} An array of IDs to await before execution */ _depends; /** * Set a list of test IDs to await before trying to run * * @param {String...} ids A list of other test IDs required before this test can execute * @returns {TestaTest} This chainable instance */ depends(...ids) { if (!ids.every(a => typeof a == 'string')) throw new Error('All arguments to Testa.depends() must be string IDs'); this._depends = [...(this._depends || []), ...ids]; return this; } // }}} // Timing - slow(Number|String), timeout(Number|String) {{{ /** * The amount of time before a test is considered slow to resolve * Can be any valid timestring * If unspecified this inherits from TestaBase.testDefaults._slow * * @type {Number|String} */ _slow; /** * Set the amount of time before a test is considered slow to resolve * Can be any valid timestring * * @param {Number|String} timing Either the time in milliseconds or any parsable timestring * @param {*...} [args] Additional args passed to `argsHandler()` * @returns {TestaTest} This chainable instance */ slow(timing, ...args) { this._slow = timing; return this.argsHandler(args); } /** * The amount of time before a test times out * Can be any valid timestring * If unspecified this inherits from TestaBase.testDefaults._timeout * * @type {Number|String} */ _timeout; /** * Set the amount of time before a test times out * Can be any valid timestring * * @param {Number|String} timing Either the time in milliseconds or any parsable timestring * @param {*...} [args] Additional args passed to `argsHandler()` * @returns {TestaTest} This chainable instance */ timeout(timing, ...args) { this._timeout = timing; return this.argsHandler(args); } // }}} // Flow - run(), skip(reason:String), abort() {{{ /** * The status of the current test * * @type {'idle'|'running'|'skipped'|'timeout'|'resolved'|'rejected'} */ _status = 'idle'; /** * The rejected error payload, if any */ _error; /** * Execute the test * * @param {Object} [options] Additional options to mutate behaviour * @param {Function} [options.onLog] Function called as `(msg:Array<Any>)` with logging output * @param {Function} [options.onStage] Function called as `(msg:Array<Any>)` when a test reaches a new stage * @param {Function} [options.onSkip] Function called as `(msg:Array<Any>)` when a test marks itself as skipped * @param {Function} [options.onSlow] Function to execute if the task exceeds its slowness timing * @param {Function} [options.onTimeout] Function to execute if the task exceeds its timeout and has been terminated * * @returns {Promise} A promise wrapper for the test */ run(options) { let settings = { /* eslint-disable no-unused-vars */ onLog: msg => {}, onStage: msg => {}, onSkip: msg => {}, onSlow: ()=> {}, onTimeout: ()=> {}, ...options, }; if (this._status != 'idle') throw new Error(`Only idle tests can be told to run. Current status: "${this._status}"`); this._status = 'running'; let context = new TestaContext({ test: this, log: (...msg) => settings.onLog(msg), stage: (...msg) => settings.onStage(msg), skip: (...msg) => { throw {SKIPPED: true, msg: msg.join(' ')}; }, }); let slowTimer, timeoutTimer; // Eventual timer handles for slow + timeout return Promise.resolve() .then(()=> { if (this._skip) throw {SKIPPED: true, msg: this._skipReason}; }) .then(()=> { // Pre-flight checks if (!this._handler) throw new Error('Test has no handler function'); // Calculate timings let slowMs = typeof this._slow == 'string' ? timestring(this._slow, 'ms') : this._slow; let timeoutMs = typeof this._timeout == 'string' ? timestring(this._timeout, 'ms') : this._timeout; // Set timers slowTimer = setTimeout(()=> settings.onSlow(this), slowMs); timeoutTimer = setTimeout(()=> { this._status = 'timeout'; this._error = new Error('Timeout'); this.abort(); settings.onTimeout(this); }, timeoutMs); }) .then(()=> this._handler.call(context, context)) .then(()=> { // TODO: Do something with result returns? this._status = 'resolved'; }) .catch(e => { if (e?.SKIPPED) { // Test got skipped out - either directly or dynamically this._status = 'skipped'; this._error = new Error('Test skipped'); settings.onSkip(e.msg); // Eat error and continue as if resolved } else { this._status = 'rejected'; this._error = e; throw e; } }) .finally(()=> { clearTimeout(slowTimer); clearTimeout(timeoutTimer); }) } /** * Try to abort a running test * * @returns {TestaTest} This chainable instance */ abort() { // FIXME: Stub return this; } // }}} // Built-ins - toString() {{{ /** * Try to compute the string representation of a test * * @param {'title'} type to compute the string representation * @returns {String} The closest match to the string that is computable */ toString(type) { switch (type) { case 'id': return this._id || '(No ID)'; case 'location': return (this._location ? `${this._location.file} +${this._location.line}` // NOTE: We purposely omit line here as its pretty useless : '(no location)' ) case 'title': return this._title || '(No title)'; default: return ( (typeof this._priority == 'string' ? `${this._priority}:` : '') + (this._title || this._id || this._location) ); } } // }}} // Arg processing - argsDeny() + argsHandler() {{{ /** * Refuse any more arguments * This is used for functions like `handler(...)` to prevent accidental misuse by passing too many operands to functions that dont accpet them * * @param {Array<*>} args Function arguments to examine * @returns {TestaTest} This chainable instance */ argsDeny(args) { if (args.length > 0) { throw new Error('Too many arguments passed'); } return this; } /** * Accept exactly one more argument - if its specified populate the handler as long as its not already been set * This is to allow shorthand usage for things like `test.skip(...)` or `test.id('My ID', ...)` * * @param {Array<*>} args Function arguments to examine * @returns {TestaTest} This chainable instance */ argsHandler(args) { if (!args[0]) { // No payload // Pass } else if (args.length > 1) { throw new Error('Only one optional operand is allowed - the function handler'); } else if (typeof args[0] == 'function') { this.handler(args[0]); } else { throw new Error('Dont know how to chain non-function payload!'); } return this; } /** * Accept possibly two arguments - a title and a handler function * This is the base functionality of the `test()` function by itself but can be used by some other functions like `before()`, `after()` etc. * * @param {Array<*>} args Function arguments to examine * @returns {TestaTest} This chainable instance */ argsTitleHandler(args) { if (!args[0]) { // No payload // Pass } else if (args.length > 2) { throw new Error('Only two optional operands are allowed - an optional title + a function handler'); } else if (typeof args[0] == 'string' && typeof args[1] == 'function') { this.id(args[0]).handler(args[1]); } else if (typeof args[0] == 'function') { this.handler(args[0]); } else { throw new Error('Dont know how to chain non-function payload!'); } return this; } // }}} // Misc state {{{ /** * Tracker for any relevent dumps from a test * * @type {Array<Object>} An array of dump objects * @property {String} path The dump file path */ _dumps = []; // }}} }