'use strict';
const { callerInfo, explain } = require('./util.js');
/**
* @callback Contract
* @desc A code block containing one or more condition checks.
* A check is performed by calling one of a few special methods
* (equal, match, deepEqual, type etc)
* on the Report object.
* Contracts may be nested using the 'nested' method which accepts
* another contract and records a pass/failure in the parent accordingly.q
* A contract is always executed to the end.
* @param {Report} ok An object that records check results.
* @param {Any} [...list] Additional parameters
* (e.g. data structure to be validated)
* @returns {void} Returned value is ignored.
*/
const protocol = 1.1;
/**
* @public
* @classdesc
* The core of the refutable library, the report object contains info
* about passing and failing conditions.
*/
class Report {
// setup
/**
* @desc No constructor arguments supported.
* Contracts may need to be set up inside callbacks _after_ creation,
* hence this convention.
*/
constructor () {
this._count = 0;
this._failCount = 0;
this._descr = [];
this._evidence = [];
this._where = [];
this._condName = [];
this._info = [];
this._nested = [];
this._pending = new Set();
this._onDone = [];
this._done = false;
// TODO add caller info about the report itself
}
// Setup methods follow. They must be chainable, i.e. return this.
/**
* @desc Execute code when contract execution finishes.
* Report object cannot be modified at this point,
* and no additional checks my be present.
* @param {function} callback - first argument is report in question
* @returns {Report} this (chainable)
* @example
* report.onDone( r => { if (!r.getPass()) console.log(r.toString()) } )
*/
onDone (fn) {
if (typeof fn !== 'function')
throw new Error('onDone(): callback must be a function');
if (this.getDone())
fn(this);
else
this._onDone.push(fn);
return this;
}
/**
* @desc Execute code when contract execution finishes, if it failed.
* Report object cannot be modified at this point,
* and no additional checks my be present.
* @param {function} callback - first argument is report in question
* @returns {Report} this (chainable)
* @example
* report.onFail( r => console.log(r.toString()) );
*/
onFail (fn) {
if (typeof fn !== 'function')
throw new Error('onDone(): callback must be a function');
this._lock();
this._onDone.push(r => r.getPass() || fn(r));
return this;
}
// Running the contract
/**
* @desc apply given function to a Report object, lock report afterwards.
* If function is async (i.e. returns a {@link Promise}),
* the report will only be done() after the promise resolves.
* This is done so to ensure that all checks that await on a value
* are resolved.
* @param {Contract} contract The function to execute
* Additional parameters may be _prepended_ to contract
* and will be passed to it _after_ the Report object in question.
* @returns {Report} this (chainable)
* @example Basic usage
* const r = new Report().run( ok => ok.equal( 'war', 'peace', '1984' ) );
* r.getPass(); // false
* r.getDone(); // true
* r.toString();
* r(
* !1. 1984
* - war
* + peace
* )
*
* @example Passing additional arguments to callback.
* // The contract body is the last argument.
* new Report().run( { v: 4.2, colors: [ 'blue' ] }, (r, arg) => {
* r.type( arg, 'object' );
* r.type( arg.v, 'number' );
* r.cmpNum( arg.v, '>=', 3.14 );
* r.type( arg.colors, 'array' );
* });
* @example Async function
* const r = new Report().run(
* async ok => ok.equal( await 6*9, 42, 'fails but later' ) );
* r.getPass(); // true
* r.getDone(); // false
* // ...wait for event loop to tick
* r.getPass(); // false
* r.getDone(); // true
*/
run (...args) {
// TODO either async() should support additional args, or run() shouldn't
this._lock();
const block = args.pop();
if (typeof block !== 'function')
throw new Error('Last argument of run() must be a function, not ' + typeof block);
const result = block(this, ...args);
if (result instanceof Promise)
result.then( () => this.done() );
else
this.done();
return this;
}
/**
* @desc apply given function (contract) to a Report object.
* Multiple such contrats may be applied, and the report is not locked.
* Async function are permitted but may not behave as expected.
* @param {Contract} contract The function to execute
* Additional parameters may be _prepended_ to contract
* and will be passed to it _after_ the Report object in question.
* @returns {Report} this (chainable)
* @example Basic usage
* const r = new Report()
* .runSync( ok => ok.equal( 'war', 'peace', '1984' ) )
* .runSync( ok => ok.type ( [], 'array', 'some more checks' ) )
* .done();
*/
runSync (...args) {
this._lock();
const block = args.pop();
if (typeof block !== 'function')
throw new Error('Last argument of run() must be a function, not ' + typeof block);
const result = block( this, ...args ); /* eslint-disable-line no-unused-vars */
// TODO check that `result` is NOT a promise
return this;
}
/**
* @private
* @param {Report|Promise|false|any} evidence
* @param {string} descr
* @param {string} condName
* @param {string} where
* @return void
*/
setResult (evidence, descr, condName, where) {
this._lock();
const n = ++this._count;
if (descr)
this._descr[n] = descr;
// pass - return ASAP
if (!evidence)
return;
// nested report needs special handling
if (evidence instanceof Report) {
this._nested[n] = evidence;
if (evidence.getDone()) {
if (evidence.getPass())
return; // short-circuit if possible
evidence = []; // hack - failing without explanation
} else {
// nested contract is in async mode - coerce into a promise
const curry = evidence; /* eslint-disable-line */
evidence = new Promise( (resolve, reject) => {
curry.onDone( resolve );
});
}
}
// pending - we're in async mode
if (evidence instanceof Promise) {
this._pending.add(n);
where = where || callerInfo(2); // must report actual caller, not then
evidence.then( x => {
this._pending.delete(n);
this._setResult(n, x, condName, where );
if (this.getDone()) {
for (let i = this._onDone.length; i-- > 0; )
this._onDone[i](this);
}
});
return;
}
this._setResult(n, evidence, condName, where || callerInfo(2));
}
_setResult (n, evidence, condName, where) {
if (!evidence)
return;
// listify & stringify evidence, so that it doesn't change post-factum
if (!Array.isArray(evidence))
evidence = [evidence];
this._evidence[n] = evidence.map( x => _explain(x, Infinity) );
this._where[n] = where;
this._condName[n] = condName;
this._failCount++;
}
/**
* @desc Append an informational message to the report.
* Non-string values will be stringified via explain().
* @param {Any} message
* @returns {Report} chainable
*/
info ( ...message ) {
this._lock();
if (!this._info[this._count])
this._info[this._count] = [];
this._info[this._count].push( message.map( s => _explain(s) ).join(' ') );
return this;
}
/**
* @desc Locks the report object, so no modifications may be made later.
* Also if onDone callback(s) are present, they are executed
* unless there are pending async checks.
* @returns {Report} this (chainable)
*/
done (callback) {
if (callback !== undefined)
this.onDone(callback);
if (!this._done) {
this._done = true;
if (!this._pending.size) {
for (let i = this._onDone.length; i-- > 0; )
this._onDone[i](this);
}
}
return this;
}
// check if the Report object is still modifiable, throws otherwise.
_lock () {
if (this._done)
throw new Error('Attempt to modify a finished contract');
}
// Querying methods
/**
* @desc Tells whether the report is finished,
* i.e. done() was called & no pending async checks.
* @returns {boolean}
*/
getDone () {
return this._done && !this._pending.size; // is it even needed?
}
/**
* @desc Without argument returns whether the contract was fulfilled.
* As a special case, if no checks were run and the contract is finished,
* returns false, as in "someone must have forgotten to execute
* planned checks. Use pass() if no checks are planned.
*
* If a parameter is given, return the status of n-th check instead.
* @param {integer} n
* @returns {boolean}
*/
getPass (n) {
if (n === undefined)
return this._failCount === 0;
return (n > 0 && n <= this._count) ? !this._evidence[n] : undefined;
}
/**
* @desc Number of checks performed.
* @returns {number}
*/
getCount () {
return this._count;
}
/**
* @desc Whether the last check was a success.
* This is just a shortcut for foo.getDetails(foo.getCount).pass
* @returns {boolean}
*/
last () {
return this._count ? !this._evidence[this._count] : undefined;
}
/**
* @desc Number of checks failing.
* @returns {number}
*/
getFailCount () {
return this._failCount;
}
/**
* @desc Return a string of failing/passing checks.
* This may be useful for validating custom conditions.
* Consecutive passing checka are represented by numbers.
* A capital letter in the string represents failure.
* See also {@link Report#toString toString()}
* @returns {string}
* @example
* // 10 passing checks
* "r(10)"
* @example
* // 10 checks with 1 failure in the middle
* "r(5,N,4)"
* @example
* // 10 checks including a nested contract
* "r(3,r(1,N),6)"
* @example
* // no checks were run - auto-fail
* "r(Z)"
*/
getGhost () {
const ghost = [];
let streak = 0;
for (let i = 1; i <= this._count; i++) {
if (this._evidence[i] || this._nested[i]) {
if (streak) ghost.push(streak);
streak = 0;
ghost.push( this._nested[i] ? this._nested[i].getGhost() : 'N');
} else { /* eslint-desable-line curly */
streak++;
}
}
if (streak) ghost.push(streak);
return 'r(' + ghost.join(',') + ')';
}
/**
* @desc Returns serialized diff-like report with nesting and indentation.
* Passing conditions are merked with numbers, failing are prefixed
* with a bang (!).
*
* See also {@link Report#getGhost getGhost()}
* @returns {string}
* @example // no checks run
* const r = new Report();
* r.toString();
* r(
* )
* @example // pass
* const r = new Report();
* r.pass('foo bared');
* r.toString();
* r(
* 1. foo bared
* )
* @example // fail
* const r = new Report();
* r.equal('war', 'peace');
* r.toString();
* r(
* !1.
* ^ Condition equal failed at <file>:<line>:<char>
* - war
* + peace
* )
*/
toString () {
// TODO replace with refute.io when we buy the domain
return 'refute/' + protocol + '\n' + this.getLines().join('\n');
}
getLines (indent = '') {
const out = [indent + 'r('];
const last = indent + ')';
indent = indent + ' ';
const pad = prefix => s => indent + prefix + ' ' + s;
if (this._info[0])
out.push( ...this._info[0].map( pad(';') ) );
for (let n = 1; n <= this._count; n++) {
out.push( ...this.getLinesPartial( n, indent ) );
if (this._info[n])
out.push( ...this._info[n].map( pad(';') ) );
}
out.push(last);
return out;
}
getLinesPartial (n, indent = '') {
const out = [];
out.push(
indent
+ (this._pending.has(n) ? '...' : (this._evidence[n] ? '!' : '') )
+ n + (this._descr[n] ? '. ' + this._descr[n] : '.')
);
if (this._nested[n]) { /* eslint-disable-line curly */
out.push( ...this._nested[n].getLines(indent) );
} else if (this._evidence[n]) {
out.push( indent + ' ^ Condition `' + (this._condName[n] || 'check')
+ '` failed at ' + this._where[n] );
this._evidence[n].forEach( raw => {
// Handle multiline evidence
// TODO this is perl written in JS, rewrite more clearly
let [_, prefix, s] = raw.match( /^([-+|] )?(.*?)\n?$/s );
if (!prefix) prefix = '| ';
if (!s.match(/\n/)) { /* esline-disable-line curly */
out.push( indent + ' ' + prefix + s );
} else {
s.split('\n').forEach(
part => out.push( indent + ' ' + prefix + part ));
}
});
}
return out;
}
/**
* @desc returns a plain serializable object
* @returns {Object}
*/
toJSON () {
const n = this.getCount();
const details = [];
for (let i = 0; i <= n; i++) {
const node = this.getDetails(i);
// strip extra keys
for (const key in node) {
if (node[key] === undefined || (Array.isArray(node[key]) && node[key].length === 0))
delete node[key];
}
details.push(node);
}
return {
pass: this.getPass(),
count: this.getCount(),
details,
};
}
/**
* @desc Returns detailed report on a specific check
* @param {integer} n - check number, must be <= getCount()
* @returns {object}
*/
getDetails (n) {
// TODO validate n
// ugly but what can I do
if (n === 0) {
return {
n: 0,
info: this._info[0] || [],
};
}
let evidence = this._evidence[n];
if (evidence && !Array.isArray(evidence))
evidence = [evidence];
return {
n: n,
name: this._descr[n] || '',
pass: !evidence,
evidence: evidence || [],
where: this._where[n],
cond: this._condName[n],
info: this._info[n] || [],
nested: this._nested[n],
pending: this._pending.has(n),
};
}
}
// this is for stuff like `object foo = {"foo":42}`
// we don't want the explanation to be quoted!
function _explain ( item, depth ) {
if (typeof item === 'string' )
return item;
return explain( item, { depth } );
}
Report.prototype.explain = explain; // also make available via report
Report.protocol = protocol;
// part of addCondition
const knownChecks = new Set();
/* NOTE Please keep all addCondition invocations searchable via */
/* grep -r "^ *addCondition.*'" /
/**
* @memberOf refute
* @static
* @desc Create new check method available via all Report instances
* @param {string} name Name of the new condition.
* Must not be present in Report already, and should NOT start with
* get..., set..., or add... (these are reserved for Report itself)
* @param {Object} options Configuring the check's handling of arguments
* @param {integer} options.args The required number of arguments
* @param {integer} [options.minArgs] Minimum number of argument (defaults to args)
* @param {integer} [options.maxArgs] Maximum number of argument (defaults to args)
* @param {boolean} [options.hasOptions] If true, an optional object
can be supplied as last argument. It won't interfere with description.
* @param {boolean} [options.fun] The last argument is a callback
* @param {Function} implementation - a callback that takes {args} arguments
* and returns a falsey value if condition passes
* ("nothing to see here, move along"),
* or evidence if it fails
* (e.g. typically a got/expected diff).
*/
function addCondition (name, options, impl) {
if (typeof name !== 'string')
throw new Error('Condition name must be a string');
if (name.match(/^(_|get[_A-Z]|set[_A-Z])/))
throw new Error('Condition name must not start with get_, set_, or _');
// TODO must do something about name clashes, but later
// because eval in browser may (kind of legimitely) override conditions
if (!knownChecks.has(name) && Report.prototype[name])
throw new Error('Method already exists in Report: ' + name);
if (typeof options !== 'object')
throw new Error('bad options');
if (typeof impl !== 'function')
throw new Error('bad implementation');
const minArgs = options.minArgs || options.args;
if (!Number.isInteger(minArgs) || minArgs < 0)
throw new Error('args/minArgs must be nonnegative integer');
const maxArgs = options.maxArgs || options.args || Infinity;
if (maxArgs !== Infinity && (!Number.isInteger(minArgs) || maxArgs < minArgs))
throw new Error('maxArgs must be integer and greater than minArgs, or Infinity');
const descrFirst = options.descrFirst || options.fun || maxArgs > 10;
const hasOptions = !!options.hasOptions;
const maxArgsReal = maxArgs + (hasOptions ? 1 : 0);
// TODO alert unknown options
/**
* @private
* @param args variable
* @return {Report} returns self
*/
const code = function (...args) {
// TODO this code is cluttered, rewrite, maybe split into cases
// (descr last vs descr first vs functional arg)
// TODO const nArgs = args.length
const descr = descrFirst
? args.shift()
: ( (args.length > maxArgs && typeof args[args.length - 1] === 'string') ? args.pop() : undefined);
if (args.length > maxArgsReal || args.length < minArgs) {
// TODO provide different error messages for different cases
throw new Error('Condition ' + name + ' must have ' + minArgs + '..' + maxArgsReal + ' arguments '); // TODO
}
this.setResult( impl(...args), descr, name );
return this;
};
knownChecks.add(name);
Report.prototype[name] = code;
}
// The most basic conditions are defined right here
// in order to be sure we can validate the Report class itself.
/**
* @namespace conditions
* @desc Condition check library. These methods must be run on a
* {@link Report} object.
*/
/**
* @instance
* @memberOf conditions
* @method check
* @desc A generic check of a condition.
* @param evidence If false, 0, '', or undefined, the check is assumed to pass.
* Otherwise it fails, and this argument will be displayed as the reason why.
* @param {string} [description] The reason why we care about the check.
* @returns {Report}
*/
/**
* @instance
* @memberOf conditions
* @method pass
* @desc Always passes.
* @param {string} [description]
* @returns {Report}
*/
/**
* @instance
* @memberOf conditions
* @method fail
* @desc Always fails with a "failed deliberately" message.
* @param {string} [description]
* @returns {Report}
*/
/**
* @instance
* @memberOf conditions
* @method equal
* @desc Checks if === holds between two values.
* If not, both will be stringified and displayed as a diff.
* See deepEqual to check nested data structures ot objects.
* @param {any} actual
* @param {any} expected
* @param {string} [description]
* @returns {Report}
*/
/**
* @instance
* @memberOf conditions
* @method match
* @desc Checks if a string matches a regular expression.
* @param {string} actual
* @param {RegExp} expected
* @param {string} [description]
* @returns {Report}
*/
/**
* @instance
* @memberOf conditions
* @method nested
* @desc Verify a nested contract.
* @param {string} description
* @param {Contract} contract
* @returns {Report}
*/
addCondition( 'check',
{ args: 1 },
x => x
);
addCondition( 'pass',
{ args: 0 },
() => 0
);
addCondition( 'fail',
{ args: 0 },
() => 'failed deliberately'
);
addCondition( 'equal',
{ args: 2 },
(a, b) => a === b ? 0 : ['- ' + explain(a), '+ ' + explain(b)]
);
addCondition( 'match',
{ args: 2 },
// TODO function(str, rex)
(a, rex) => (a === undefined || a === null)
? ['' + a, 'Does not match : ' + rex]
: ('' + a).match(rex)
? 0
: [
'String : ' + a,
'Does not match : ' + rex,
]
);
addCondition( 'nested',
{ fun: 1, minArgs: 1 },
(...args) => new Report().run(...args).done()
);
module.exports = { Report, addCondition, explain };