// "use strict";
// UMD returnExports design pattern
(function(root, factory) {
/* istanbul ignore next */
if (typeof define === "function" && define.amd) {
// AMD. Register as an anonymous module.
define([], factory);
} else if (typeof module === "object" && module.exports) {
// Node. Does not work with strict CommonJS, but
// only CommonJS-like environments that support module.exports,
// like Node.
module.exports = factory();
} else {
// Browser globals (root is window)
root.CandyWrapper = factory();
}
}(this, function() {
function debug(...args) {
// console.log(...args);
}
/**
* Creates an alias method for an already existing method
* @param {Object} ctx The `this` object that the function will be called on.
* @param {String} aliasName The name of the new alias.
* @param {Function} fn The name of the function that the alias will call
* @param {...Any} args The arguments to bind to the function.
* @private
*/
function alias(ctx, aliasName, fn, ...args) {
ctx[aliasName] = fn.bind(ctx, ...args);
}
function killAlias(type, obj, prop) {
var myType = (type === "function") ? "FUNCTION" : "PROPERTY";
var otherType = (type === "function") ? "PROPERTY" : "FUNCTION";
Object.defineProperty(obj, prop, {
get: function() {
throw new Error(`ERROR: attempting to get ${prop} on ${myType}. Only available on ${otherType}.`);
},
set: function() {
throw new Error(`ERROR: attempting to set ${prop} on ${myType}. Only available on ${otherType}.`);
}
});
}
function walkObject(obj, cb) {
for (let key in obj) {
if (!obj.hasOwnProperty(key)) continue;
if (typeof obj[key] === "object") {
walkObject(obj[key], cb);
} else {
cb(obj, key);
}
}
}
function indent(num) {
var base = " ";
var indent = " ";
var ret = base;
for (let i = 0; i < num; i++) ret += indent;
return ret;
}
var wrapperCookie = "193fe616-09d1-4d5c-a5b9-ff6f3e79714c";
var wrapperCookieKey = "uniquePlaceToKeepAGuidForCandyWrapper";
/**
* `Wrapper` is the main class for candy-wrapper. A wrapper goes around an existing function, method, or property
* enabling you to monitor the behavior of the underlying wrapped thing or optionally changing the behavior of
* the wrapped thing when you want to. If you are new to candy-wrapper, or wrappers in general, you may want to
* check out the {@tutorial getting-started} tutorial.
*
* Below are all the different forms of the contructor, which are basically syntactic sugar for creating wrappers
* around lots of different kinds of things
*
* ``` js
* // creates a wrapper around an object's method
* new Wrapper(obj, method);
*
* // createa a wrapper around an object's property
* new Wrapper(obj, property);
*
* // creates a wrapper around a function and returns the new wrapped function
* // calling the old, unwrapped function doesn't trigger the wrapper
* var func = new Wrapper(func);
*
* // creates a new wrapped empty, anonymous function
* var stub = new Wrapper();
*
* // recursively wraps every property and method in an object
* new Wrapper(obj);
*
* // wraps a method but calls `func` instead of the underlying method
* new Wrapper(obj, method, func);
*
* // wraps a property but cals `func` instead of the setter / getter for the property
* new Wrapper(obj, property, func)
* ```
*
* Once you have wrapped a function or property, there are two main ways to work with it. One is through the `historyList`
* which contains all the historical calls to the `Wrapper`. The other is through {@link Trigger Triggers} which can change
* the behavior of the `Wrapper` as well as validating expectations every time the `Wrapper` is called. The `historyList` is
* a {@link Filter} array of all the calls that have been made, where each call is represented as a {@link Operation}.
* `Filters` have a number of convenience methods that make it easier to manipulate the `historyList` and select only the
* `Operations` that you are interested in validating.
*
* Likewise, `Triggers` are executed on the `Operation` as it is being used during the call. A `Trigger` has the opportunity
* to set the arguments or context for functions or the set value for properties before the wrapped function or property is
* called, and `Triggers` are called a second time to modify the return value or exceptions that are thrown after the wrapped
* function or property has executed. `Triggers` can be created on a `Wrapper` using the methods that start with `trigger` and
* `Triggers` are evaluated in the order they are defined on the `Wrapper`.
*
* `Wrappers` also hhave a number of configuration functions that define their behavior, such as when attempting to "rewrap"
* the same function or property. The `config` functions enable you to modify these behaviors. By default, rewrapping a function
* or property will return the existing `Wrapper` without throwing an error and without reseting any of the `Triggers` or
* `historyList`.
*
* Note that the `Wrapper` class is generic to wrapping both functions and properties; however, not all methods are equally
* applicable to functions and properties. It does not make any sense to specify a "set value" for a function, nor does it make
* any sense to supply a "context" (`this` value) for a property. When attempting to use a function-specific method on a
* wrapped property, the method will detect this and throw an `Error` immediately; and the same is true for attempting to use
* property-specific methods on functions.
*
* Here are some example of how to use a `Wrapper`:
*
* ``` js
* // a simple test object
* var myDrone = {
* name: "DJI",
* fly: function(direction) {
* return true;
* }
* }
*
* new Wrapper(myDrone, "name");
* new Wrapper(myDrone, "fly");
*
* myDrone.fly("north");
* myDrone.fly("west");
*
* // evaluating previous calls through the 'historyList' and 'Filters'
* myDrone.fly.historyList.filterFirst().expectCallArgs("north"); // true
* myDrone.fly.historyList.filterFirst().expectCallArgs("east"); // false
* myDrone.fly.expectReportAllFailtures(); // throws an error about the "east" expecation failing
*
* // modifying behavior using 'Triggers'
* myDrone.fly.triggerOnCallArgs("east").actionReturn(false); // will return 'false' when called with "east"
* myDrone.fly("east"); // false
* myDrone.fly("west"); // true (the default return value)
*
* // working with properties
* myDrone.name.triggerOnSet().actionThrowException(new Error("do not set the name"));
* myDrone.name = "Bob"; // throws Error: "do not set the name"
* var ret = myDrone.name; // ret = "DJI"
*
* // rewrapping
* var flyWrapper = new Wrapper(myDrone, "fly"); // doesn't change anything, just returns the original wrapper
* flyWrapper.configAllowRewrap(false); // disallow rewrapping
* new Wrapper(myDrone, "fly"); // throws an error
*
* // these sorts of things won't work
* myDrone.fly.triggerOnSet()... // throws an error -- functions don't have 'set'
* myDrone.fly.triggerOnCallContext()... // throws an error -- properties don't have 'this' values
* ```
* @throws {TypeError} If arguments aren't one of the valid signatures for the constructor.
* @extends {Function}
*/
class Wrapper extends Function {
/**
* Creates a new `Wrapper` instance. Currently, wrapping properties have some notable corner cases:
*
* 1. Wrapping a [non-configurable property](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperty)
* will result in an `Error` being thrown.
* 1. Wrapping a non-writable property will not prevent that property from being written, and if
* [getOwnPropertyDescriptor](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/getOwnPropertyDescriptor)
* is called on the property, the `writable` attribute will shown up as `undefined` (since setters and getters) are
* being used by the `Wrapper`).
*/
constructor(...args) {
super();
if (args[0] === null) {
throw new TypeError("Wrapper: bad arguments to constructor. RTFM.");
}
// if already wrapped, warn and return the existing Wrapper
if (Wrapper.isWrapper(...args)) {
return this._rewrap(...args);
}
// constructed like: wrapper()
if (args.length === 0) {
debug("creating empty wrapper");
return this._callConstructor(function() {});
}
// constructed like: wrapper(obj)
if (args.length === 1 && typeof args[0] === "object") {
var obj = args[0];
walkObject(obj, function(obj, key) {
new Wrapper(obj, key);
});
return obj;
}
// constructed like: wrapper(func)
if (args.length === 1 && typeof args[0] === "function") {
debug("wrapping function:", args[0].name);
return this._callConstructor(args[0]);
}
// wrapper(obj, method)
// wrapper(obj, property)
if (args.length === 2 &&
typeof args[0] === "object" &&
typeof args[1] === "string") {
let obj = args[0];
let key = args[1];
this.origObj = obj;
this.origProp = key;
// testing `typeof obj[key]` could trigger a getter
// get the value from the property descriptor instead
let desc = Object.getOwnPropertyDescriptor(obj, key);
if (desc && desc.value && typeof desc.value === "function") {
obj[key] = this._callConstructor(obj[key]);
return obj[key];
} else {
return this._propConstructor(obj, key);
}
}
// wrapper(obj, method, func)
// wrapper(obj, property, func)
if (args.length === 3 &&
typeof args[0] === "object" &&
typeof args[1] === "string" &&
typeof args[2] === "function") {
let obj = args[0];
let key = args[1];
let fn = args[2];
debug("wrapping method or property:", key);
/** @type {Object} The original object that the property belongs to */
this.origObj = obj;
this.origProp = key;
if (typeof obj[key] === "function") {
obj[key] = this._callConstructor(obj[key], fn);
return obj[key];
} else {
return this._propConstructor(obj, key, fn);
}
}
throw new TypeError("Wrapper: bad arguments to constructor. RTFM.");
}
_rewrap(...args) {
var oldWrapper;
// _rewrap(func)
if (args.length === 1 && typeof args[0] === "function") {
oldWrapper = args[0];
}
// _rewrap(obj, method)
// _rewrap(obj, property)
// _rewrap(obj, method, func)
// _rewrap(obj, property, func)
if ((args.length === 2 || args.length === 3) &&
typeof args[0] === "object" &&
typeof args[1] === "string") {
let obj = args[0];
let key = args[1];
if (typeof obj[key] === "function") {
oldWrapper = obj[key];
} else {
oldWrapper = Wrapper.getWrapperFromProperty(obj, key);
}
}
if (!oldWrapper.config.allowRewrap) {
throw new Error("Function or property already wrapped: rewrapping not allowed. Use configAllowRewrap(true) if you would like to enable rewrapping.");
}
// could check isWrapper here... but I'm not sure that's necessary
return oldWrapper.chainable;
}
_commonConstructor(type) {
/** @lends Wrapper# */
/** @type {Filter} A list of all the every time the `Wrapper` was used */
this.historyList = new Filter(this);
/** @type {String} The type of this Wrapper object. Options are "property" or "function". */
this.type = type;
}
_callConstructor(origFn, wrappedFn) {
/** @lends Wrapper# */
this._commonConstructor("function");
this.orig = origFn;
wrappedFn = wrappedFn || origFn;
this.wrapped = wrappedFn;
this.chainable = new Proxy(this, {
apply: (target, context, argList) => this._doCall(target, context, argList)
});
// mirror function name and length
Object.defineProperty(this, "name", {
writable: false,
enumerable: false,
configurable: true,
value: wrappedFn.name
});
Object.defineProperty(this, "length", {
writable: false,
enumerable: false,
configurable: true,
value: wrappedFn.length
});
this.configDefault();
this.configReset();
return this.chainable;
}
_doCall(target, context, argList) {
var funcName = this.wrapped.name || "<<anonymous>>";
debug(`calling wrapper on "${funcName}"`);
var op = new Operation(this, {
context: context,
argList: argList
});
// run pre-call triggers
this._runTriggerList("pre", op);
// run the wrapped function
var ret, exception = null;
if (this.config.callUnderlying) { // option to turn on / off whether the function gets called
try {
ret = this.wrapped.apply(op.context, op.argList);
} catch (ex) {
exception = ex;
}
}
op.retVal = ret;
op.exception = exception;
// run post-call triggers
this._runTriggerList("post", op);
debug("final op", op);
// save call
op.postCall = op.preCall = true; // in the future evaulate both pre and post calls
this.historyList.push(op);
// throw the exception...
if (op.exception && // if we have an exception
(!this.config.swallowException || ( // and the exception shouldn't be swallowed
this.config.swallowException && !op.exception.candyWrapperExpected // or the exception should be swallowed, but this error wasn't expected
)))
throw op.exception;
// ...or call the callback...
if (typeof op.callback.fn === "function") {
let cbFn = op.callback.fn;
let cbArgs = op.callback.argList || [];
let cbCtx = op.callback.context || {};
op.callback.retVal = cbFn.call(cbCtx, ...cbArgs);
}
// ...then return the return value.
return op.retVal;
}
_propConstructor(obj, prop, fn) {
/** @lends Wrapper# */
this._commonConstructor("property");
/** @type {Object} The results of `getOwnPropertyDescriptor` on the original property, saved so that it can be restored later. */
this.origPropDesc = Object.getOwnPropertyDescriptor(obj, prop);
if (this.origPropDesc === undefined) {
throw new TypeError(`Wrapper constructor: expected '${prop}' to exist on object`);
}
if (this.origPropDesc.configurable === false) {
throw new Error(`new Wrapper: can't wrap non-configurable property: '${prop}'`);
}
/** @type {any} The current value of the property. */
this.propValue = this.origPropDesc.value;
/** @type {Function} An optional custom function that will be called when getting or setting the property. */
this.getterFn = fn || this.origPropDesc.get;
/** @type {Function} An optional custom function that will be called when getting or setting the property. */
this.setterFn = fn || this.origPropDesc.set;
// create a proxy for the setter / getters
/** @type {Proxy.<Wrapper>} The thing that is returned representing the Wrapped and is intended to be used for chainable Wrapper calls */
this.chainable = new Proxy(this, {
apply: (target, context, argList) => {
switch (argList.length) {
case 0:
return this._doSetterGetter("get");
case 1:
return this._doSetterGetter("set", argList[0]);
/* istanbul ignore next */
default:
throw new Error("Wrong number of args to setter / getter. (How is that even possible?)");
}
}
});
// define the new property
Object.defineProperty(obj, prop, {
configurable: this.origPropDesc.configurable,
enumerable: this.origPropDesc.enumerable,
get: this.chainable,
set: this.chainable,
});
this.configDefault();
this.configReset();
return this.chainable;
}
_doSetterGetter(type, val) {
// create a new Operation for this property
var op = new Operation(this, {
getOrSet: type,
retVal: this.propValue,
setVal: val
});
debug(`_doSetterGetter ${op.getOrSet} "${op.setVal}"`);
// run pre-call trigger
this._runTriggerList("pre", op);
// do the set or get
var exception = null;
if (op.getOrSet === "get" && this.getterFn) {
try {
op.retVal = this.getterFn(op.getOrSet);
} catch (ex) {
exception = ex;
}
} else if (op.getOrSet === "set" && this.setterFn) {
try {
op.retVal = this.setterFn(op.getOrSet, op.setVal);
} catch (ex) {
exception = ex;
}
} else if (op.getOrSet === "set") {
// if no setter / getter function, just used the cached propValue
op.retVal = op.setVal;
this.propValue = op.setVal;
}
op.exception = exception;
// run post-call trigger
this._runTriggerList("post", op);
debug("final op", op);
// save this property Operation for future reference
this.historyList.push(op);
// always return the value
debug("settergetter returning", op.retVal);
if (op.exception && // if we have an exception
(!this.config.swallowException || ( // and the exception shouldn't be swallowed
this.config.swallowException && !op.exception.candyWrapperExpected // or the exception should be swallowed, but this error wasn't expected
)))
throw op.exception;
return op.retVal;
}
_runTriggerList(preOrPost, op) {
if (preOrPost === "pre") {
op.postCall = !(op.preCall = true); // only evalute pre calls
} else { // post
op.postCall = !(op.preCall = false); // only evaluate post calls
}
debug(`_runTriggerList ${preOrPost}`, this.triggerList);
for (let i = 0; i < this.triggerList.length; i++) {
let trigger = this.triggerList[i];
trigger._run(op);
}
}
/**
* Asserts that the current Wrapper is wrapping an property.
* @param {String} callerName The name of the calling function, to be included in
* the error message if the assertion fails.
* @throws {Error} If current Wrapper is not wrapping an property.
* @private
*/
_propOnly(callerName) {
if (this.type !== "property") {
throw new Error(`${callerName} is only supported for PROPERTY wrappers`);
}
}
/**
* Asserts that the current Wrapper is wrapping a function.
* @param {String} callerName The name of the calling function, to be included in
* the error message if the assertion fails.
* @throws {Error} If current Wrapper is not wrapping a function.
* @private
*/
_funcOnly(callerName) {
if (this.type !== "function") {
throw new Error(`${callerName} is only supported for FUNCTION wrappers`);
}
}
/**
* This static method checks whether something is a Wrapper or not, similar to `Array.isArray`.
* Works on functions, methods, and properties. This constructor has multiple signatures as illustrated
* in the example below:
* @example
*
* var testFunction = function(){};
*
* var testObject {
* prop: 1,
* meth: function() {}
* }
*
* Wrapper.isWrapper(testFunction); // false
* testFunction = new Wrapper(testFunction);
* Wrapper.isWrapper(testFunction); // true
*
* Wrapper.isWrapper(testObject, "prop"); // false
* new Wrapper(testObject, "prop");
* Wrapper.isWrapper(testObject, "prop"); // true
*
* Wrapper.isWrapper(testObject, "meth"); // false
* new Wrapper(testObject, "meth");
* Wrapper.isWrapper(testObject, "meth"); // true
*
* @return {Boolean} Returns `true` if the arguments are a Wrapper, `false` otherwise.
*/
static isWrapper(...args) {
if (args[0] === undefined) return false;
// called like: isWrapper(fn)
// checking to see if a function / method is a wrapper
if (args.length === 1 && typeof args[0] === "function") {
let w = args[0];
// console.log ("w", w);
if (w[wrapperCookieKey] === wrapperCookie) return true;
return false;
}
// called like: isWrapper(obj, method)
if (args.length === 2 &&
typeof args[0] === "object" &&
typeof args[1] === "string") {
let obj = args[0];
let key = args[1];
let desc = Object.getOwnPropertyDescriptor(obj, key);
if (desc === undefined)
return false;
// property is a function
if (desc.value && typeof desc.value === "function")
return Wrapper.isWrapper(obj[key]);
if (desc.value && typeof desc.value === "object")
return false;
if (typeof desc.set !== "function" ||
typeof desc.get !== "function")
return false;
return (Wrapper.isWrapper(desc.set) && Wrapper.isWrapper(desc.get));
}
return false;
}
/**
* If the property `key` of `Object` `obj` is a wrapped property, the `Wrapper` for that property
* will be returned. Otherwise `null` will be returned. It is safe to use this in all instances, including
* if the property `key` isn't defined on the `Object`. Note that this will not return a `Wrapper` for a
* wrapped function / method.
* @param {Object} obj The `Object` for the `obj[key]` combination for which a `Wrapper` will attempt to be retrieved.
* @param {String} key The `String` for the `obj[key]` combination for which a `Wrapper` will attempt to be retrieved.
* @return {Wrapper|null} Returns a `Wrapper` if the property has been wrapped, `null` otherwise.
* @example
* var testObj = {
* location: "home"
* };
* new Wrapper(testObj, "location"); // wrap the property 'testObj.location'
* var wrapper = Wrapper.getWrapperFromProperty(testObj, "location"); // returns the wrapper created above
*/
static getWrapperFromProperty(obj, key) {
if (typeof obj !== "object") {
throw new TypeError("getWrapperFromProperty; exepcted 'obj' argument to be Object");
}
if (typeof key !== "string") {
throw new TypeError("getWrapperFromProperty: expected 'key' argument to be String");
}
var desc = Object.getOwnPropertyDescriptor(obj, key);
if (desc === undefined)
return null;
var wrapper = desc.set;
if (typeof wrapper !== "function")
return null;
if (wrapper[wrapperCookieKey] === wrapperCookie) {
return wrapper;
} else {
return null;
}
}
/**
* This static method attempts to remove and destroy the Wrapper on the function, method, property or
* object that is passed to it. If an object has a mix of wrapped and non-wrapped methods and properties
* this `unwrap` will automatically detect and unwrap just those items which hvae been wrapped.
* @param {...any} args See examples for the various signatures for this method.
* @return {Function|Object|any} The original function, object, or the value of the property that has now been unwrapped.
* @example
* func = Wrapper.unwrap(func); // unwraps this function
* Wrapper.unwrap(obj, "property"); // unwraps the property or method at obj[property]
* Wrapper.unwrap(obj); // recursively unwraps all properties and methods on 'obj'
* @see {@link Wrapper#configUnwrap}
*/
static unwrap(...args) {
// unwrap(obj)
if (args.length === 1 && typeof args[0] === "object") {
var obj = args[0];
walkObject(obj, function(obj, key) {
if (Wrapper.isWrapper(obj, key)) Wrapper.unwrap(obj, key);
});
return obj;
}
// unwrap(func)
if (args.length === 1 && typeof args[0] === "function") {
debug("wrapping function:", args[0].name);
return args[0].configUnwrap();
}
// unwrap(obj, method)
// unwrap(obj, property)
// unwrap(obj, method, func)
// unwrap(obj, property, func)
if (args.length === 2 &&
typeof args[0] === "object" &&
typeof args[1] === "string") {
let obj = args[0];
let key = args[1];
if (!Wrapper.isWrapper(obj, key)) {
throw new TypeError("Wrapper.unwrap: attempting to unwrap non-wrapper");
}
this.origObj = obj;
this.origProp = key;
if (typeof obj[key] === "function") {
let methodWrapper = obj[key];
return methodWrapper.configUnwrap();
} else {
let propWrapper = Wrapper.getWrapperFromProperty(obj, key);
return propWrapper.configUnwrap();
}
}
throw new TypeError(`Wrapper.unwrap: unexpected arguments: ${args}`);
}
/**
* Resets the wrapper to the default configuration, undoing the effects of any `config` calls.
* @public
*/
configDefault() {
this.config = {
expectThrowsOnTrigger: true,
expectThrows: false,
callUnderlying: true,
allowRewrap: true,
swallowException: false
};
}
/**
* Resets the wrapper to its default state. Clears out all {@link Operation} records of previous calls,
* expected behaviors, pass / fail results, etc. This does not reset any configuration options,
* which {@link configDefault} can be used for.
* @return {Wrapper} Returns the Wrapper object so that this call can be chained
*/
configReset() {
/** @lends Wrapper# */
/** @type {Array<Trigger>} A list of {@link Trigger}s to be run whenever this Wrapper is called. */
this.triggerList = [];
/** @type {Array<String>} List of messages from failed `expect` calls. */
this.expectErrorList = [];
/** @type {Boolean} Whether or not all expectations have passed on this wrapper. */
this.expectPassed = true;
/**
* A nonsensical key and value used to identify if this object is a Wrapper
* @name uniquePlaceToKeepAGuidForCandyWrapper
* @type {String}
* @default "193fe616-09d1-4d5c-a5b9-ff6f3e79714c"
* @memberof Wrapper
* @instance
*/
this[wrapperCookieKey] = wrapperCookie;
if (this.type === "function") {
this._configCallReset();
}
if (this.type === "property") {
this._configPropReset();
}
return this.chainable;
}
_configCallReset() {
this.historyList.length = 0;
}
_configPropReset() {
this.historyList.length = 0;
}
/**
* Restores the wrapped property or function to its original value, and destroys this `Wrapper`.
* @return {Function|Object} The original function or object that was wrapped.
* @example
* var w = new Wrapper(obj, "method"); // create a new wrapper
* w.configUnwrap(); // destroys the wrapper
*/
configUnwrap() {
if (this.type === "function") return this._unwrapMethod();
return this._unwrapProp();
}
_unwrapProp() {
let obj = this.origObj;
let key = this.origProp;
let desc = this.origPropDesc;
let val = this.propValue;
delete obj[key];
// unwraps these:
// new Wrapper(obj, property)
// new Wrapper(obj, property, func)
Object.defineProperty(obj, key, {
configurable: desc.configurable,
enumerable: desc.enumerable,
writable: desc.writable,
value: val,
// get: desc.get,
// set: desc.set
});
return val;
}
_unwrapMethod() {
// destroy this wrapper so that it isn't mistakenly used in the future
var orig = this.orig;
this.wrapped = function() {
throw new Error("Calling Wrapper after it has been unwrapped");
};
this.unwrapped = true;
if (typeof this.origObj === "object" &&
typeof this.origProp === "string") {
// unwraps these:
// new Wrapper(obj, method)
// new Wrapper(obj, method, func)
let obj = this.origObj;
let key = this.origProp;
obj[key] = orig;
return orig;
} else {
// unwraps these:
// new Wrapper()
// new Wrapper(func)
return orig;
}
}
/**
* Determines whether wrapping a `Wrapper` should be allowed. Default is `true`, meaning that any attempt to wrap a
* function or property that is already wrapped will result in the currently-in-place `Wrapper` being returned (a new
* `Wrapper` will not be created). By passing `false` to `configAllowRewrap`, any attempt to rewrap a funciton or
* property will result in an `Error` being thrown.
* @param {Boolean} allow If `true` rewrapping will be allowed. If `false` an error will be thrown when attempting to
* rewrap.
* @throws {TyperError} If wrong number of args, or non-Boolean arguments
*/
configAllowRewrap(allow) {
validateArgsSingleBoolean("configAllowRewrap", allow);
this.config.allowRewrap = allow;
}
/**
* By default, the wrapped function or properter setter / getter will get called when the `Wrapper` gets called.
* This configuration option allows disabling calling of the underlying function or property setter / getter.
* @param {Boolean} callUnderlying If `true`, future calls to the `Wrapper` will invoke the underlying function or
* proprty setter / getter. If `false`, future calls to the `Wrapper` will **NOT** invoke the underlying function or
* proprty setter / getter.
* @throws {TyperError} If wrong number of args, or non-Boolean arguments
*/
configCallUnderlying(callUnderlying) {
validateArgsSingleBoolean("configCallUnderlying", callUnderlying);
this.config.callUnderlying = callUnderlying;
}
/**
* Configures whether or not {@link Trigger Triggers} will throw an {@link ExpectError} immediately if the expectation
* fails.
* @param {Boolean} doesThrow If `true` the expectation will throw an `ExpectError` immediately if the expecation fails.
* If `false`, it will save the failure message to be retrieved later with {@link expectReportAllFailures}.
* @throws {TyperError} If wrong number of args, or non-Boolean arguments
*/
configExpectThrowsOnTrigger(doesThrow) {
validateArgsSingleBoolean("configExpectThrowsOnTrigger", doesThrow);
this.config.expectThrowsOnTrigger = doesThrow;
}
/**
* By default, failed expectations will not throw on {@link Filter Filters}, but they will fail on {@link Trigger Triggers}.
* `configExpectThrows` can be used to configure a `Wrapper` so that all of its expectations will throw an {@link ExpectError}.
* @param {Boolean} doesThrow If `true` all expect functions will throw an {@link ExpectError} immediately upon failure. If
* `false`, expect functions will maintain their default behavior.
* @throws {TyperError} If wrong number of args, or non-Boolean arguments
*/
configExpectThrows(doesThrow) {
validateArgsSingleBoolean("configExpectThrows", doesThrow);
this.config.expectThrows = doesThrow;
}
/**
* When a {@link Trigger} expects an exception, it won't stop the exception from being thrown. When the `swallowException`
* argument to `configSwallowExpectException` is `true`, it will prevent the specific exception that was expected from
* being thrown. This is useful for testing, where tests may expect an error to be thrown -- which is actually indicates
* a successful test case. Allowing the error to be thrown would cause the test to fail. Note that this only swallows the
* specific `Error` that is expected, and all other errors will still be thrown.
*
* Note that even if an exception is swallowed, it will still be recorded to the `Wrapper`'s `historyList`.
* @param {Boolean} swallowException If set to `true` any expected exceptions won't be thrown; if `false`, the `Error` will
* be thrown anyway.
* @throws {TyperError} If wrong number of args, or non-Boolean arguments
*/
configSwallowExpectException(swallowException) {
validateArgsSingleBoolean("configSwallowExpectException", swallowException);
this.config.swallowException = swallowException;
}
/**
* The typical behavior for expectations called against a {@link Filter}, {@link Operation}
* is that they will return `true` or `false` immediately. This allows them to be used with assertion libraries,
* such as [Chai](http://chaijs.com/). Alternatively, `expectReportAllFailures` allows you to run all your expectations
* and then report all the expectations that did not pass.
*
* If all your expectations passed, this function will return true. If one or more of your expectations failed
* this function will aggregate all the failure messages and throw a {@link ExpectError} that includes all the
* error messages.
*
* An example error message might look something like:
*
* > ExpectError: 2 expectation(s) failed:<br>
* > expectReturn: expectation failed for: 10<br>
* > expectReturn: expectation failed for: 23<br>
*
* `expectReportAllFailures` draws upon a list of messages stored in the wrapper at `Wrapper.expectErrorList`. This
* list will continue to be added to as new expectations fail. `expectReportAllFailures` includes a convenience
* argument, `clear`, that will clear out the previous expectation messages and status so that
* a fresh set of expectations can be built up.
*
* Note that the default behavior for {@link Trigger}s is to immediately throw an `ExpectError` if the expectation
* fails. This prevents them from adding expectation failures to the list for future reporting. This behavior may
* be changed by calling {@link configExpectThrowsOnTrigger}. Likewise, if {@link configExpectThrows} has been set
* so that expecations always throw, there will be nothing cached for future validation.
* @param {Boolean} clear If `truthy`, the list of previous exception results will be cleared out. If absent or
* `falsey` the results will continue to be added to as new expectations are run.
* @return {Boolean} Returns `true` if all expectations have successfully passed.
* @throws {ExpectError} If any expectations have been evaluated and failed, but not immediately thrown.
*/
expectReportAllFailures(clear) {
if (this.expectPassed) return true;
var message = `${this.expectErrorList.length} expectation(s) failed:\n`;
// iterates sub errors from ExpectIterator
function errListToStr(errList, indentNum) {
var retMsg = "";
for (let k = 0; k < errList.length; k++) {
let subErr = errList[k];
retMsg += indent(indentNum) + subErr.message + "\n";
retMsg += subErr.diffToStr(indentNum + 1);
}
return retMsg;
}
// add the detailed failure messages
for (let i = 0; i < this.expectErrorList.length; i++) {
let err = this.expectErrorList[i];
let newMsg = indent(1) + err.message + "\n";
// add iterator error messages
newMsg += errListToStr(err.errorList, 2);
// add diff messages
newMsg += err.diffToStr(2);
message = message.concat(newMsg);
}
if (clear) {
this.expectErrorList.length = 0;
this.expectPassed = true;
}
throw new ExpectError(message);
}
/**
* Creates a new {@link Trigger} on the `Wrapper` that always executes.
* @return {Trigger} The `Trigger` that was created.
*/
triggerAlways() {
var t = new Trigger(this, function() {
return true;
});
this.triggerList.push(t);
return t;
}
/**
* Creates a new {@link Trigger} on the `Wrapper` that executes when the arguments to the wrapper match the arguments to this call.
* @param {...any} args A list of any arguments that, when matched, will cause this `Trigger` to execute.
* @return {Trigger} The `Trigger` that was created.
* @throws {TypeError} If called on a wrapped property
* @example
* // create a Wrapper around something
* var w = new Wrapper(obj, "method");
*
* // create a trigger that executes whenever the arguments exactly "beer"
* w.triggerOnCallArgs("drink", "beer")
* .actionReturn("yum!");
*
* // call the wrapped method and see what happens...
* obj.method("drink", "beer") // returns "yum!"
*/
triggerOnCallArgs(...args) {
this._funcOnly("triggerOnCallArgs");
var m = Match.value(args);
var t = new Trigger(this, function(op) {
return m.compare(op.argList);
});
this.triggerList.push(t);
return t;
}
/**
* Creates a new {@link Trigger} on the `Wrapper` that executes when the `this` value of the wrapped function
* matches the `context` argument.
* @param {Object} context An `Object` that, when matched to the `this` value of the function, will cause the `Trigger` to execute.
* @return {Trigger} The `Trigger` that was created.
* @throws {TypeError} If called on a wrapped property
* @example
* // create a Wrapper around something
* var w = new Wrapper(obj, "method");
*
* // create a trigger that executes whenever the arguments exactly "beer"
* w.triggerOnCallContext({location: "home"})
* .actionThrowException(new Error("You should be at work right now."));
*
* // call the wrapped method and see what happens...
* obj.method.call({location: "home"}, "hi mom") // throws "You should be at work right now."
*/
triggerOnCallContext(context) {
this._funcOnly("triggerOnCallContext");
validateArgsSingle("triggerOnCallContext", context);
var m = Match.value(context);
var t = new Trigger(this, function(op) {
var ret = m.compare(op.context);
return ret;
});
this.triggerList.push(t);
return t;
}
/**
* Creates a new {@link Trigger} on the `Wrapper` that executes the `Nth` time the wrapper is called.
* @param {Number} num What call number this `Trigger` should execute on. Counting starts from zero, so the first call is 0, the second is 1, etc.
* @return {Trigger} The `Trigger` that was created.
* @throws {TypeError} If called on a wrapped property or if first argument isn't a number
* @example
* // create a Wrapper around something
* var w = new Wrapper(obj, "method");
*
* // create a trigger that executes whenever the arguments exactly "beer"
* w.triggerOnCallNumber(2)
* .actionThrowException(new Error("BOOM!"));
*
* // call the wrapped method and see what happens...
* obj.method(); // nothing...
* obj.method(); // still nothing...
* obj.method(); // throws "BOOM!"
*/
triggerOnCallNumber(num) {
this._funcOnly("triggerOnCallNumber");
validateArgsSingleNumber("triggerOnCallNumber", num);
var count = 0;
var t = new Trigger(this, function(op) {
var ret = (count === num);
if (op.postCall) count++;
return ret;
});
this.triggerList.push(t);
return t;
}
/**
* Creates a {@link Trigger} on the `Wrapper` that executes whenever the exception matching `err` is thrown
* by the wrapped function / getter / setter.
* @param {Error|null} err Any `Error` (or class that inherits from `Error`, such as `TypeError`, `RangeError`, or
* custom `Error`s). If `err` exactly matches the error thrown then this trigger will execute. Exactly matching
* an `Error` requires that the `Error.name` and `Error.message` are strictly equal. If `err` is `null`, this `Trigger` will
* execute when there was no error.
* @return {Trigger} The `Trigger` that was created.
* @throws {TypeError} If argument isn't an `Error` or `null`
*/
triggerOnException(err) {
validateArgsSingleExceptionOrNull("triggerOnException", err);
var m = Match.value(err);
var t = new Trigger(this, function(op) {
var ret = m.compare(op.exception);
return ret;
});
this.triggerList.push(t);
return t;
}
/**
* Creates a {@link Trigger} on the `Wrapper` that executes whenever the return value of the wrapped
* function / getter / setter matches the value specified here.
* @param {any} retVal The return value that will cause this `Trigger` to execute. Note that `undefined`
* is allowable, but should be explicitly passed as `undefined` rather than simply not passing an argument.
* @return {Trigger} The `Trigger` that was created.
*/
triggerOnReturn(retVal) {
validateArgsSingle("triggerOnReturn", retVal);
var m = Match.value(retVal);
var t = new Trigger(this, function(op) {
var ret = m.compare(op.retVal);
return ret;
});
this.triggerList.push(t);
return t;
}
/**
* Creates a {@link Trigger} that gets executed based on the callback function `cb`.
* @param {Trigger~triggerCustomCallback} cb The callback function. See
* {@link Trigger~triggerCustomCallback triggerCustomCallback} for the description of the expected syntax and behavior
* of the callback.
* @return {Trigger} The `Trigger` that was created
* @throws {TypeError} If the `cb` argument isn't a `Function`
*/
triggerOnCustom(cb) {
validateArgsSingleFunction("triggerOnCustom", cb);
var t = new Trigger(this, cb);
this.triggerList.push(t);
return t;
}
/**
* This is the callback that is passed to {@link Wrapper#triggerOnCustom triggerOnCustom}. The most obvious thing to point out
* is that it returns `true` when the {@link Trigger} should execute, and `false` when the `Trigger`
* shouldn't execute; however, there are some unexpected behaviors with this callback that should
* be observed.
*
* First, every `Trigger` callback **is _called twice_ for every time the `Wrapper` is executed**.
* The callback is called once just before the `Wrapper` executes the wrapped function / setter / getter
* providing the callback with the opportunity to evaluate arguments and context before they are used. The
* callback is called again just after the wrapped function / setter / getter is called, giving the
* callback the opportunity to evaluate return values and exceptions.
*
* The first argument to the callback is `curr`, which is a `Operation`. `curr` has the property `preCall`
* set to `true` if the callback is being called before the function / getter / setter; and has the
* property `postCall` that is set to `true` if the callback is being called after the function /
* getter / setter. `curr` also has the property `curr.wrapper`, which references the {@link Wrapper}.
*
* Also note that throwing an exception in a custom trigger is not advised since it may adversely effect
* the behavior of the `Wrapper` and the running of any subsequent `Triggers`. If you want the wrapper to
* throw an exception, set `single.exception` to a `new Error()`; however, this is best done through an
* {@link actionThrowException} anyway.
* @callback Trigger~triggerCustomCallback
* @param {Operation} curr The current function call or property set / get.
* @returns {Boolean} Returns `true` if the actions and expectations associated with the `Trigger`
* should run. Returns `false` if this `Trigger` should not be executed.
*/
/**
* Creates a {@link Trigger} on the `Wrapper` that executes whenever a property is set. Only valid for
* `Wrappers` around a property.
* @return {Trigger} The `Trigger` that was created.
* @throws {TypeError} If called on a wrapped function
* @example
* var myObj = {
* name: "Bob"
* }
*
* new Wrapper(myObj, "name");
* myObj.name.triggerOnSet()
* .actionThrowException(new Error("BOOM!"));
*
* var ret = myObj.name; // doesn't throw
* myObj.name = "Mary"; // throws "BOOM!"
*/
triggerOnSet() {
this._propOnly("triggerOnSet");
var t = new Trigger(this, function(single) {
if (single.getOrSet === "set") return true;
return false;
});
this.triggerList.push(t);
return t;
}
/**
* Creates a {@link Trigger} on the `Wrapper` that executes when a property is set to the value
* corresponding to `setVal`
* @param {any} setVal When a property is set to this value, the `Trigger` will execute.
* @return {Trigger} The `Trigger` that was created.
* @throws {TypeError} If called on a wrapped function
*/
triggerOnSetVal(setVal) {
this._propOnly("triggerOnSetVal");
validateArgsSingle("triggerOnSetVal", setVal);
var m = Match.value(setVal);
var t = new Trigger(this, function(single) {
return m.compare(single.setVal);
});
this.triggerList.push(t);
return t;
}
/**
* Creates a {@link Trigger} on the `Wrapper` that executes the `Nth` time that the property is
* assigned to, where `Nth` corresponds to `num`
* @param {Number} num The `Nth` assignment that will cause this `Trigger` to execute. Note that
* `0` would be the first assignment, `1` the second, etc.
* @return {Trigger} The `Trigger` that was created.
* @throws {TypeError} If called on a wrapped function, or argument isn't a number
*/
triggerOnSetNumber(num) {
this._propOnly("triggerOnSetNumber");
validateArgsSingleNumber("triggerOnSetNumber", num);
var count = 0;
var t = new Trigger(this, function(single) {
if (single.getOrSet !== "set") return false;
var ret = (count === num);
if (single.postCall) count++;
return ret;
});
this.triggerList.push(t);
return t;
}
/**
* Creates a {@link Trigger} on the `Wrapper` that executes when the value of the property is retrieved.
* @return {Trigger} The `Trigger` that was created.
* @throws {TypeError} If called on a wrapped function
* @example
* var myObj = {
* name: "Bob"
* }
*
* new Wrapper(myObj, "name");
* myObj.name.triggerOnGet()
* .actionThrowException(new Error("BOOM!"));
*
* myObj.name = "Mary"; // doesn't throw
* var ret = myObj.name; // throws "BOOM!"
*/
triggerOnGet() {
this._propOnly("triggerOnGet");
var t = new Trigger(this, function(single) {
if (single.getOrSet === "get") return true;
return false;
});
this.triggerList.push(t);
return t;
}
/**
* Creates a {@link Trigger} on the `Wrapper` that executes the `Nth` time that the property value
* is retrieved.
* @param {number} num The `Nth` get that will cause this `Trigger` to execute. Note that
* `0` would be the first assignment, `1` the second, etc.
* @return {Trigger} The `Trigger` that was created.
* @throws {TypeError} If called on a wrapped function, or first argument isn't a number
* @example
* var myObj = {
* name: "Bob"
* }
*
* new Wrapper(myObj, "name");
* myObj.name.triggerOnGetNumber(2)
* .actionThrowException(new Error("BOOM!"));
*
* var ret;
* ret = myObj.name; // doesn't throw
* ret = myObj.name; // doesn't throw
* ret = myObj.name; // throws "BOOM!"
*/
triggerOnGetNumber(num) {
this._propOnly("triggerOnGetNumber");
validateArgsSingleNumber("triggerOnGetNumber", num);
var count = 0;
var t = new Trigger(this, function(single) {
if (single.getOrSet !== "get") return false;
var ret = (count === num);
if (single.postCall) count++;
return ret;
});
this.triggerList.push(t);
return t;
}
/**
* Creates a {@link Trigger} on the `Wrapper` that execuates the `Nth` time that the property is set
* OR get.
* @param {Number} num The `Nth` get that will cause this `Trigger` to execute. Note that
* `0` would be the first assignment, `1` the second, etc.
* @return {Trigger} The `Trigger` that was created.
* @throws {TypeError} If called on a wrapped function, or first argument isn't a number
* @example
* var myObj = {
* name: "Bob"
* }
*
* new Wrapper(myObj, "name");
* myObj.name.triggerOnTouchNumber(2)
* .actionThrowException(new Error("BOOM!"));
*
* var ret;
* ret = myObj.name; // doesn't throw
* myObj.name = "Mary"; // doesn't throw
* ret = myObj.name; // throws "BOOM!"
*/
triggerOnTouchNumber(num) {
this._propOnly("triggerOnTouchNumber");
validateArgsSingleNumber("triggerOnTouchNumber", num);
var count = 0;
var t = new Trigger(this, function(single) {
var ret = (count === num);
if (single.postCall) count++;
return ret;
});
this.triggerList.push(t);
return t;
}
}
/**
* A class that contains all the `expect` calls that gets mixed in to `Triggers` as well as
* `Operation`. This class really isn't intended to be used on its own.
* @private
*/
class Expect {
constructor() {
/**
* Evaluates whether the `call` or `get` returned the value `retVal`.
* @name expectReturn
* @function
* @instance
* @memberof Expect
* @param {any} retVal The value that is expected to be returned from the function call or property getter.
* @return {Trigger|Boolean} When called on a {@link Trigger}, the expectation is stored for future evaluation and the `Trigger` value is returned to make this chainable.
* When called on a {@link Operation}, the expectation is evaluated immediately and `true` is returned if the expectation passed; `false` if it failed.
* @throws {TypeError} If called with more or less than one argument
* @throws {ExpectError} If called by a `Trigger` that doesn't match the expected return value
*/
alias(this, "expectReturn",
this._expect, "expectReturn", "both", "post", validateArgsSingle,
function(single, retVal) {
var m = Match.value(retVal);
if (m.compare(single.retVal)) {
return null;
}
var err = new ExpectError("expectReturn: expectation failed for: " + retVal);
err.diff = m.lastDiff;
return err;
});
/**
* Evaluates whether the arguments to a function match the `...args`.
* @name expectCallArgs
* @function
* @instance
* @memberof Expect
* @param {...any} args The list of arguments to validate for the function call.
* @return {Trigger|Boolean} When called on a {@link Trigger}, the expectation is stored for future evaluation and the `Trigger` value is returned to make this chainable.
* When called on a {@link Operation}, the expectation is evaluated immediately and `true` is returned if the expectation passed; `false` if it failed.
* @throws {TypeError} If called with more or less than one argument
* @throws {ExpectError} If called by a `Trigger` that doesn't match the expected return value
*/
alias(this, "expectCallArgs",
this._expect, "expectCallArgs", "function", "pre", validateArgsAny,
function(single, ...args) {
var m = Match.value(args);
if (m.compare(single.argList)) {
return null;
}
var err = new ExpectError("expectCallArgs: expectation failed for: " + args);
err.diff = m.lastDiff;
return err;
});
/**
* Evaluates whether the context (`this`) of a function call matches the `context` parameter.
* @name expectCallContext
* @function
* @instance
* @memberof Expect
* @param {Object} context The expected `this` for the function. Is compared by a strict deep-equals.
* @return {Trigger|Boolean} When called on a {@link Trigger}, the expectation is stored for future evaluation and the `Trigger` value is returned to make this chainable.
* When called on a {@link Operation}, the expectation is evaluated immediately and `true` is returned if the expectation passed; `false` if it failed.
* @throws {TypeError} If called on a wrapped function, or with more or less than one argument
* @throws {ExpectError} If called by a `Trigger` that doesn't match the expected call context (`this` value)
*/
alias(this, "expectCallContext",
this._expect, "expectCallContext", "function", "pre", validateArgsSingle,
function(single, context) {
var m = Match.value(context);
if (m.compare(single.context)) {
return null;
}
var err = new ExpectError("expectCallContext: expectation failed for: " + context);
err.diff = m.lastDiff;
return err;
});
/**
* Expects that the function call or property set / get threw an `Error` that strictly matches the `exception` arguemnt.
* @name expectException
* @function
* @instance
* @memberof Expect
* @param {Error|null} exception The `Error` (or class that inherits from `Error`) that is expected to strictly match. A strict
* comparison between errors evaluates that the `Error.name` and `Error.message` are the exact same. If `exception` is `null`, it
* will expecct that there was no `Error` thrown.
* @return {Trigger|Boolean} When called on a {@link Trigger}, the expectation is stored for future evaluation and the `Trigger` value is returned to make this chainable.
* When called on a {@link Operation}, the expectation is evaluated immediately and `true` is returned if the expectation passed; `false` if it failed.
* @throws {TypeError} If called with more or less than one argument, or with an argument that isn't an `Error` or `null`
* @throws {ExpectError} If called by a `Trigger` that doesn't throw the expected exception (`Error`) or throwns an unexpected exception (if the expected exception was `null`)
*/
alias(this, "expectException",
this._expect, "expectException", "both", "post", validateArgsSingleExceptionOrNull,
function(single, exception) {
var m = Match.value(exception);
// expectation passed
if (m.compare(single.exception)) {
// hide a value on the error so that _doCall knows that we expected this error
if (single.exception) {
Object.defineProperty(single.exception, "candyWrapperExpected", {
value: true,
writable: true,
configurable: true,
enumerable: false
});
}
return null;
}
// expectation failed
var err = new ExpectError("expectException: expectation failed for: " + exception);
err.diff = m.lastDiff;
return err;
});
/**
* Evaluates the value that is set on a property during assignment (e.g. - `obj.prop = setVal`) and expects the value to
* strictly equal the `setVal` argument.
* @name expectSetVal
* @function
* @instance
* @memberof Expect
* @param {any} setVal The value that is expected to be set on the property. An `undefined` value is allowed, but the value `undefined` must be passed explicitly to `expectSetVal`.
* @return {Trigger|Boolean} When called on a {@link Trigger}, the expectation is stored for future evaluation and the `Trigger` value is returned to make this chainable.
* When called on a {@link Operation}, the expectation is evaluated immediately and `true` is returned if the expectation passed; `false` if it failed.
* @throws {TypeError} If called with more or less than one argument
* @throws {ExpectError} If called by a `Trigger` that doesn't receive the expected set argument
*/
alias(this, "expectSetVal",
this._expect, "expectSetVal", "property", "post", validateArgsSingle,
function(single, setVal) {
var m = Match.value(setVal);
if (m.compare(single.setVal)) {
return null;
}
var err = new ExpectError("expectSetVal: expectation failed for: " + setVal);
err.diff = m.lastDiff;
return err;
});
/**
* Evaluates the callback function
* @name expectCustom
* @function
* @instance
* @memberof Expect
* @param {Operation~customExpectCallback} cb Callback function that will determine whether the expecation passes or fails. See {@link Operation~customExpectCallback customExpectCallback} for more details.
* @return {Trigger|Boolean} When called on a {@link Trigger}, the expectation is stored for future evaluation and the `Trigger` value is returned to make this chainable.
* When called on a {@link Operation}, the expectation is evaluated immediately and `true` is returned if the expectation passed; `false` if it failed.
* @throws {TypeError} If called with more or less than one argument, or the first argument isn't a `Function`
* @throws {ExpectError} If called by a `Trigger` and the custom function returns an `Error` or a `String`
*/
alias(this, "expectCustom",
this._expect, "expectCustom", "both", "post", validateArgsFirstFunction,
function(single, cb, ...args) {
return cb.call(this, single, ...args);
});
/**
* This is a description of the callback used by {@link expectCustom}.
* @callback Operation~customExpectCallback
* @param {Operation} curr The current function call or property / set get.
* @return {null|ExpectError} `null` if expectation was successful. Returns {@link ExpectError} containing the message for the failed expectation otherwise.
*/
}
_addDeferredAction(name, argList) {
var action = {
name: name,
argList: argList
};
debug("adding action:", action);
this.actionList.push(action);
}
_getCurrCallOrDefer(name, ...args) {
if (this instanceof Operation) return this;
if (this instanceof ExpectIterator) return this.current;
if (this instanceof Trigger && this.currentCall) return this.currentCall;
return this._addDeferredAction(name, args);
}
static _softAssert(ctx, err) {
// let the iterator handle the errors
if (ctx instanceof ExpectIterator && ctx.current) return err;
// overall pass fail
var passed = (err === null) ? true : false;
// decide what to do with the error -- throw or store
if (!passed) {
// see if the config says we should throw
if ((ctx instanceof Trigger && ctx.wrapper.config.expectThrowsOnTrigger) ||
ctx.wrapper.config.expectThrows) {
err.message += "\n" + err.diffToStr();
throw err;
}
// store the exception for future reference
ctx.wrapper.expectErrorList.push(err);
}
ctx.wrapper.expectPassed = ctx.wrapper.expectPassed && passed;
return passed;
}
_expect(name, type, timing, validateFn, fn, ...args) {
if (typeof name !== "string") {
throw new TypeError("_expect: expected 'name' argument");
}
if (type === "property") this.wrapper._propOnly(name);
if (type === "function") this.wrapper._funcOnly(name);
if (typeof timing !== "string") {
throw new TypeError("_expect: expected 'timing' argument");
}
if (typeof validateFn !== "function") {
throw new TypeError("_expect: expected 'validateFn' argument");
}
if (typeof fn !== "function") {
throw new TypeError(`_expect: expected function argument`);
}
validateFn(name, ...args);
// get the current call, or save the args for when the call is actually run
var curr = this._getCurrCallOrDefer(name, ...args);
// if there isn't a current call, return 'this' to enable chaining
if (!curr) return this; // chainable
// check if this is the right time to run
var runNow = (timing === "both") ||
(curr.postCall && timing === "post") ||
(curr.preCall && timing === "pre");
if (!runNow) return this;
// test the expect
var msg = fn.call(this, curr, ...args);
// form the result into an error
var err;
if (typeof msg === "string") {
err = new ExpectError(msg);
} else {
err = msg;
}
var passed = Expect._softAssert(this, err);
return passed;
}
}
/**
* An Error thrown by a failed {@link Expect} call. A different error name so that it
* can be distinguished from other types of errors, plus lists of errors and diffs for
* more intelligent diagnostic information of what went wrong.
* @extends {Error}
*/
class ExpectError extends Error {
constructor(message) {
super(message);
this.name = "ExpectError";
this.errorList = []; // for ExpectIterator
this.diff = null;
}
diffToStr(indentNum = 1) {
if (!this.diff) return "";
var diffList = this.diff.toStringArray();
var retMsg = "";
for (let i = 0; i < diffList.length; i++) {
retMsg += indent(indentNum) + diffList[i] + "\n";
}
return retMsg;
}
}
/**
* This class is used to apply an expect method to a `Filter` array. It is used for functions
* such as {@link Filter#expectAll}, {@link Filter#expectSome}, and {@link Filter#expectNone}.
* @private
* @extends {Expect}
*/
class ExpectIterator extends Expect {
constructor(wrapper, iterType, filter) {
super();
this.wrapper = wrapper;
this.iterType = iterType;
this.filter = filter;
this.current = null;
}
_expect(...args) {
var results = [];
// iterate through all the ops in the filter...
for (let i = 0; i < this.filter.length; i++) {
this.current = this.filter[i];
// ...whatever `expect` was called, call it on each op
let ret = super._expect(...args);
results.push(ret);
}
this.current = null;
// now that we have our results, evaluate them
switch (this.iterType) {
case "all":
return this._expectAll(results);
case "some":
return this._expectSome(results);
case "none":
return this._expectNone(results);
}
}
_expectAll(results) {
var passed = true;
var errList = [];
// check to make sure all the resutls passed
for (let i = 0; i < results.length; i++) {
// any results failed, add a failure message to the list
if (results[i] !== null) {
errList.push(results[i]);
passed = false;
}
}
// create an error, if necessary
var err = null;
if (!passed) {
err = new ExpectError(`expectAll: all expectations should have passed, but ${errList.length} failed`);
err.errorList = errList;
}
// run the soft assertion
var ret = Expect._softAssert(this, err);
return ret;
}
_expectSome(results) {
var passed = false;
var errList = [];
// check to make sure at least one the resutls passed
for (let i = 0; i < results.length; i++) {
// any results failed, add a failure message to the list
if (results[i] === null) {
passed = true;
}
}
// create an error, if necessary
var err = null;
if (!passed) {
err = new ExpectError(`expectSome: at least one expectation should have passed, but none did`);
err.errorList = errList;
}
// run the soft assertion
var ret = Expect._softAssert(this, err);
return ret;
}
_expectNone(results) {
var passed = true;
var passList = [];
// check to make sure all the resutls passed
for (let i = 0; i < results.length; i++) {
// any results failed, add a failure message to the list
if (results[i] === null) {
passed = false;
passList.push(i);
}
}
// create an error, if necessary
var err = null;
if (!passed) {
err = new ExpectError(`expectNone: no expectations should have passed, but ${passList.length} passed`);
}
// run the soft assertion
var ret = Expect._softAssert(this, err);
return ret;
}
}
/**
* The {@link Wrapper#historyList} on a {@link Wrapper} contains all the information about the
* previous operations perfomed by a wrapper. Being able to easily assess this information is
* one of the important features of candy-wrapper, and so the `historyList` is a `Filter` that
* allows for easily selecting the parts of the operation history that you are interested in.
*
* Filters have two parts: first, the ability to select the operation records you care about using
* the `filter` calls; and second, the ability to perform `expect` operations on one or more of
* the operations in the `Filter`.
*
* The ability to filter the `historyList` using the `filter` functions is essentially just
* syntactic sugar on top of the `Array.map()` and `Array.filter()` classes. The methods such
* as `filterByCallArgs` and `filterByException` simply select the elements of the `Filter`
* that match the desired attributes. The `get` methods on the `Filter`, such as `getAllCallArgs`
* or `getAllExceptions` are just doing a map to create an `Array` of the features you care about.
*
* There are two different ways of performing `expect` calls on `Filters`. The first is to filter
* down to a single {@link Operation}, and perform expectations on that operation. This can be
* done using `filter` methods that return a single `Operation`, such as
* {@link Filter#filterFirst filterFirst}, {@link Filter#filterLast filterLast} or
* {@link Filter#filterByNumber filterByNumber}. The other way to perform expect calls on a `Filter`
* is by performing an expect on the whole list at once, such as using
* {@link Filter#expectCount expectCount}.
*
* Note that many of the `Filter` operations are chainable and combinable to make unique and
* powerful combinations out of the `Filter` methods.
* @extends {Array}
* @throws {TypeError} If first argument isn't a valid `Wrapper`
*/
class Filter extends Array {
constructor(wrapper, ...args) {
super(...args);
if (!(wrapper instanceof Wrapper)) {
throw new TypeError("Filter constructor: expected first argument to be of type Wrapper");
}
this.wrapper = wrapper;
/**
* Returns the members of the `Filter` that were occurances of when the property was set. Only
* works for properties.
* @name filterPropSet
* @function
* @memberof Filter
* @instance
* @returns {Filter} Returns a `Filter` containing just the {@link Operation} records where a property was set.
* @throws {TypeError} If called on a wrapped property
*/
alias(this, "filterPropSet",
this._filter, "filterSet", "property",
function(element) {
if (element.getOrSet === "set") return true;
});
/**
* Returns the members of the `Filter` that were occurances of when the property was retrieved
* (i.e. `get`). Only works for properties.
* @name filterPropGet
* @function
* @memberof Filter
* @instance
* @returns {Filter} Returns a `Filter` containing just the {@link Operation} records when the property was gotten.
* @throws {TypeError} If called on a wrapped property
*/
alias(this, "filterPropGet",
this._filter, "filterGet", "property",
function(element) {
if (element.getOrSet === "get") return true;
});
/**
* Returns the members of the `Filter` that were occurances of when the property was set to `setVal`
* @name filterPropSetByVal
* @function
* @memberof Filter
* @instance
* @param {any} setVal If `setVal` strictly matches the value of a `Operation` where the property was set, the record
* will be included in the results.
* @returns {Filter} Returns a `Filter` containing just the {@link Operation} records that are of type `set` and have a
* matching `setVal`.
* @throws {TypeError} If called on a wrapped property, or if called with more than one argument
*/
alias(this, "filterPropSetByVal",
this._filter, "filterPropSetByVal", "property",
function(element, index, array, ...args) {
validateArgsSingle("filterPropSetByVal", ...args);
var setVal = args[0];
var m = Match.value(setVal);
return m.compare(element.setVal);
});
/**
* Returns the members of the `Filter` that were function calls with arguments matching `...args`.
* @name filterByCallArgs
* @function
* @memberof Filter
* @instance
* @param {...any} args The function arguments that will be matched
* @returns {Filter} A `Filter` continaing the function calls where the function was called with
* arguments that match `...args`.
* @throws {TypeError} If called on a wrapped property
* @example
* new Wrapper(obj, someMethod); // create a new wrapper
*
* // do some function calls
* obj.someMethod("drink", "beer");
* obj.someMethod("store", "wine");
* obj.someMethod("drink", "beer");
* obj.someMethod("store", "beer");
* obj.someMethod("martini");
*
* // returns a Filter with the two calls above that match `args[0] === "drink"` and `args[1] === "beer"`
* obj.someMethod.historyList.filterByCallArgs("drink", "beer");
*/
alias(this, "filterByCallArgs",
this._filter, "filterByCallArgs", "function",
function(element, index, array, ...args) {
var m = Match.value([...args]);
return m.compare(element.argList);
});
/**
* Returns the members of the `Filter` that were function calls with a `this` context that matches the `context` argument.
* @name filterByCallContext
* @function
* @memberof Filter
* @instance
* @param {Object} context The context that the `this` value of the function will be evaluated against.
* @returns {Filter} A `Filter` containing the function calls where the `this` strictly and deeply matched `context`.
* @throws {TypeError} If called on a wrapped property, or with more than one argument
*/
alias(this, "filterByCallContext",
this._filter, "filterByCallContext", "function",
function(element, index, array, ...args) {
validateArgsSingle("filterByCallContext", ...args);
var context = args[0];
var m = Match.value(context);
return m.compare(element.context);
});
/**
* Returns the members of the `Filter` that threw exceptions that exactly matched `exception`.
* @name filterByException
* @function
* @memberof Filter
* @instance
* @param {Error|null} exception An `Error` (or any class that inherits from `Error`) that will be
* strictly matched. If `exception` is `null` the filter will include all {@link Operation} records that did not throw
* an `Error`.
* @returns {Filter} A `Filter` containing the function calls or property set / get that threw an
* `Error` that matches `exception`.
* @throws {TypeError} If called with more or less than one argument, or if the argument passed is not an `Error` or `null`
*/
alias(this, "filterByException",
this._filter, "filterByException", "both",
function(element, index, array, ...args) {
validateArgsSingleExceptionOrNull("filterByException", ...args);
var exception = args[0];
var m = Match.value(exception);
return m.compare(element.exception);
});
/**
* Returns the members of the `Filter` that are function calls or property set / get where their
* return value matches `retVal`.
* @name filterByReturn
* @function
* @memberof Filter
* @instance
* @param {any} retVal The value that will be matched against. May be `undefined`, but the value `undefined`
* must be explicitly passed to `filterByReturn`.
* @returns {Filter} A `Filter` containing the function calls or property set / get that returned a
* value stictly matching `retVal`.
* @throws {TypeError} If called with more or less than one argument
*/
alias(this, "filterByReturn",
this._filter, "filterByReturn", "both",
function(element, index, array, ...args) {
validateArgsSingle("filterByReturn", ...args);
var retVal = args[0];
var m = Match.value(retVal);
return m.compare(element.retVal);
});
/**
* Gets the first {@link Operation} from the filter. Is the same as
* `{@link Filter#filterByNumber filterByNumber}(0)`.
* @name filterFirst
* @function
* @memberof Filter
* @instance
* @returns {Operation} The first operation record in the `Filter`
* @see {@link Filter#filterByNumber filterByNumber}
* {@link Filter#filterSecond filterSecond}
* {@link Filter#filterThird filterThird}
* {@link Filter#filterFourth filterFourth}
* {@link Filter#filterFifth filterFifth}
*/
alias(this, "filterFirst", this.filterByNumber, 0);
/**
* Gets the second {@link Operation} from the filter. Is the same as
* `{@link Filter#filterByNumber filterByNumber}(1)`.
* @name filterSecond
* @function
* @memberof Filter
* @instance
* @returns {Operation} The second operation record in the `Filter`
* @see {@link Filter#filterByNumber filterByNumber}
* {@link Filter#filterFirst filterFirst}
* {@link Filter#filterThird filterThird}
* {@link Filter#filterFourth filterFourth}
* {@link Filter#filterFifth filterFifth}
*/
alias(this, "filterSecond", this.filterByNumber, 1);
/**
* Gets the third {@link Operation} from the filter. Is the same as
* `{@link Filter#filterByNumber filterByNumber}(2)`.
* @name filterThird
* @function
* @memberof Filter
* @instance
* @returns {Operation} The third operation record in the `Filter`
* @see {@link Filter#filterByNumber filterByNumber}
* {@link Filter#filterFirst filterFirst}
* {@link Filter#filterSecond filterSecond}
* {@link Filter#filterFourth filterFourth}
* {@link Filter#filterFifth filterFifth}
*/
alias(this, "filterThird", this.filterByNumber, 2);
/**
* Gets the fourth {@link Operation} from the filter. Is the same as
* `{@link Filter#filterByNumber filterByNumber}(3)`.
* @name filterFourth
* @function
* @memberof Filter
* @instance
* @returns {Operation} The fourth operation record in the `Filter`
* @see {@link Filter#filterByNumber filterByNumber}
* {@link Filter#filterFirst filterFirst}
* {@link Filter#filterSecond filterSecond}
* {@link Filter#filterThird filterThird}
* {@link Filter#filterFifth filterFifth}
*/
alias(this, "filterFourth", this.filterByNumber, 3);
/**
* Gets the fifth {@link Operation} from the filter. Is the same as
* `{@link Filter#filterByNumber filterByNumber}(4)`.
* @name filterFifth
* @function
* @memberof Filter
* @instance
* @returns {Operation} The fifth operation record in the `Filter`
* @see {@link Filter#filterByNumber filterByNumber}
* {@link Filter#filterFirst filterFirst}
* {@link Filter#filterSecond filterSecond}
* {@link Filter#filterThird filterThird}
* {@link Filter#filterFourth filterFourth}
*/
/**
* Returns an `Array` of all the arguments passed to the functions in the `Filter`
* @name getAllCallArgs
* @function
* @memberof Filter
* @instance
* @returns {Array} An `Array` of argument lists, where each argument list is
* @throws {TypeError} If called on a wrapped property
* an `Array` of the arguments that were passed to that call. If a function was called
* without arguments the array will empty (length of 0).
* @example
* new Wrapper(obj, someMethod); // create a new wrapper
*
* // do some function calls
* obj.someMethod("drink", "beer");
* obj.someMethod("store", "wine");
* obj.someMethod("martini");
* obj.someMethod();
*
* var list = obj.someMethod.historyList.getAllCallArgs();
* // list is: [["drink", "beer"], ["store", "wine"], ["martini"], [undefined]]
*/
alias(this, "getAllCallArgs",
this._get, "getAllCallArgs", "function", "argList");
/**
* Returns an `Array` of all the call contexts (`this` values) that the functions in the `Filter` were called with
* @name getAllCallContexts
* @function
* @memberof Filter
* @instance
* @returns {Array} An `Array` of call contexts / `this` values
* @throws {TypeError} If called on a wrapped property
*/
alias(this, "getAllCallContexts",
this._get, "getAllCallContexts", "function", "context");
/**
* Returns an `Array` of all the exceptions (e.g. `throw Error()`) that the functions in the `Filter` threw.
* @name getAllExceptions
* @function
* @memberof Filter
* @instance
* @returns {Array} An `Array` of exceptions. If no exception was thrown, the value at that position in the array will be `null`.
*/
alias(this, "getAllExceptions",
this._get, "getAllExceptions", "both", "exception");
/**
* Returns an `Array` of all the returnValues from function calls or property set / get.
* @name getAllReturns
* @function
* @memberof Filter
* @instance
* @returns {Array} An `Array` of all return values. If a function call didn't return a value, the value at
* that location in the array will be `undefined`.
*/
alias(this, "getAllReturns",
this._get, "getAllReturns", "both", "retVal");
/**
* Returns an `Array` of all the values a property was set to.
* @name getAllSetVals
* @function
* @memberof Filter
* @instance
* @returns {Array} An `Array` of the values the property was set to.
* @throws {TypeError} If called on a wrapped function
*/
alias(this, "getAllSetVals",
this._get, "getAllSetVals", "property", "setVal");
alias(this, "filterFifth", this.filterByNumber, 4);
}
_filter(name, type, fn, ...args) {
if (type === "property") this.wrapper._propOnly(name);
if (type === "function") this.wrapper._funcOnly(name);
// NOTE: `Array.filter()` creates a `new Array()`... which doesn't call
// `new Filter()` with the right args. Rewriting `Array.filter()` is the only
// solution...
var newFilter = new Filter(this.wrapper);
for (let i = 0; i < this.length; i++) {
if (fn(this[i], i, this, ...args)) {
newFilter.push(this[i]);
}
}
return newFilter;
}
_get(name, type, property) {
if (type === "property") this.wrapper._propOnly(name);
if (type === "function") this.wrapper._funcOnly(name);
// NOTE: `Array.map()` creates a `new Array()`... which doesn't call
// `new Filter()` with the right args. Rewriting `Array.map()` is the only
// solution...
var newFilter = new Filter(this.wrapper);
for (let i = 0; i < this.length; i++) {
newFilter.push(this[i][property]);
}
return newFilter;
}
/**
* Similar to {@link Filter#filterFirst filterFirst}, this returns the first member of
* the `Filter`; however, it also asserts that it is the ONLY member of the filter and will
* throw `TypeError` if there is more than one member in the `Filter`.
* @returns {Operation} Returns the first member of the `Filter`.
* @throws {TypeError} If there is more than one member in the `Filter`.
*/
filterOnly() {
if (this.length !== 1) {
throw new TypeError("filterOnly: expected exactly one value");
}
return this[0];
}
/**
* Returns the `Operation` at the position `num` in the `Filter`
* @param {Number} num A number indicating the index of the {@link Operation} record to be returned. These are "programming numbers"
* not "counting numbers", so if `num` is zero it returns the first `Operation` record, one for the second record, etc.
* @returns {Operation} The operation record at position `num` in the filter. Same as `historyList[num]` or
* `historyList[num]` but with some light error checking.
* @throws {RangeError} If `num` is less than zero or larger than the size of the `Filter`; or if the `Filter` is empty.
*/
filterByNumber(num) {
validateArgsSingleNumber("filterByNumber", num);
if (this.length === 0) {
throw new RangeError("filterByNumber: empty list");
}
if (num < 0 || num >= this.length) {
throw new RangeError("filterByNumber: 'num' out of bounds");
}
return this[num];
}
/**
* Similar to {@link Filter#filterFirst filterFirst}, but returns the last {@link Operation} record in the `Filter`.
* @returns {Operation} Returns the last operation record in the `Filter`
* @throws {RangeError} If the `Filter` is empty
*/
filterLast() {
if (this.length === 0) {
throw new RangeError("filterlast: empty list");
}
return this[this.length - 1];
}
/**
* Expects that `Filter` has `count` members.
* @param {Number} num How many members are expected to be in the `Filter`
* @return {Boolean} Returns `true` if `Filter` has `count` {@link Operation} records, `false` otherwise
* @throws {TypeError} If more or less than a single argument, or if the argument received isn't a number
*/
expectCount(num) {
validateArgsSingleNumber("expectCount", num);
var err = null;
var passed = (this.length === num);
if (!passed) err = new ExpectError(`expectCount: expected exactly ${num}`);
return Expect._softAssert(this, err);
}
/**
* Expects that `Filter` has `count` members.
* @param {Number} min How the miniumum number of members expected to be in the `Filter`
* @param {Number} max The maximum number of members expected to be in the `Filter`
* @return {Boolean} Returns `true` if `Filter` has `count` {@link Operation} records, `false` otherwise
* @throws {TypeError} If `min` or `max` arguments aren't of type `Number`.
*/
expectCountRange(min, max) {
if (typeof min !== "number") {
throw new TypeError("expectCountRange: expected 'min' to be of type Number");
}
if (typeof max !== "number") {
throw new TypeError("expectCountRange: expected 'max' to be of type Number");
}
var err = null;
var passed = ((this.length >= min) && (this.length <= max));
if (!passed) err = new ExpectError(`expectCountRange: expected no more than ${min} and fewer than ${max}`);
return Expect._softAssert(this, err);
}
/**
* Expects that `Filter` has at least `min` members.
* @param {Number} min How least number of members that are expected to be in the `Filter`
* @return {Boolean} Returns `true` if `arr` has at least `count` {@link Operation} records, `false` otherwise
* @throws {TypeError} If first argument isn't of type `Number`
*/
expectCountMin(min) {
validateArgsSingleNumber("expectCountMin", min);
var err = null;
var passed = (this.length >= min);
if (!passed) err = new ExpectError(`expectCountMin: expected no more than ${min}`);
return Expect._softAssert(this, err);
}
/**
* Expects that `Filter` has at most `max` members.
* @param {Number} max The maximum number of members that are expected to be in the `Filter`
* @return {Boolean} Returns `true` if `Filter` has less than `count` members, `false` otherwise
* @throws {TypeError} If first argument isn't of type `Number`
*/
expectCountMax(max) {
validateArgsSingleNumber("expectCountMax", max);
var err = null;
var passed = (this.length <= max);
if (!passed) err = new ExpectError(`expectCountMax: expected no more than ${max}`);
return Expect._softAssert(this, err);
}
/**
* Expects that all {@link Operation} records in the `Filter` pass the `expect` function
* that is chained off this method.
* @return {ExpectIterator} Returns an {@link ExpectIterator} that applies the next `expect`
* call to all the operation records in this `Filter`. If no `expect` call is performed,
* this method effectively does nothing.
* @example
* new Wrapper(obj, "method");
*
* // call the method twice with the argument "good"
* obj.method("good");
* obj.method("good");
*
* // expect that all method calls had the argument "good"
* obj.method.historyList.expectAll().expectCallArgs("good"); // true
* obj.method.expectReportAllFailure(); // no failures, doesn't throw
*
* // call the method one more time with the argument "bad"
* obj.method("bad");
*
* // expect that all method calls had the argument "good"
* obj.method.historyList.expectAll().expectCallArgs("good"); // false
* obj.method.expectReportAllFailure(); // throws an error
*/
expectAll() {
return new ExpectIterator(this.wrapper, "all", this);
}
/**
* Similar to {@link Filter#expectAll expectAll}, but the `expect` function is only expected
* to pass at least once.
* @return {ExpectIterator} Returns an {@link ExpectIterator} that applies the next `expect`
* call to all the operation records in this `Filter`. If no `expect` call is performed,
* this method effectively does nothing.
* @example
* new Wrapper(obj, "method");
*
* // call the method twice with the argument "bad"
* obj.method("bad");
* obj.method("bad");
*
* // expect that some method calls had the argument "good"
* obj.method.historyList.exepctSome().expectCallArgs("good"); // false
* obj.method.expectReportAllFailure(true); // throws
*
* // call the method one more time with the argument "good"
* obj.method("good");
*
* // expect that all method calls had the argument "good"
* obj.method.historyList.exepctSome().expectCallArgs("good"); // true
* obj.method.expectReportAllFailure(); // no failures, doesn't throw
*/
expectSome() {
return new ExpectIterator(this.wrapper, "some", this);
}
/**
* Similar to {@link Filter#expectAll expectAll}, but the `expect` function is expected to never
* be successful.
* @return {ExpectIterator} Returns an {@link ExpectIterator} that applies the next `expect`
* call to all the operation records in this `Filter`. If no `expect` call is performed,
* this method effectively does nothing.
* @example
* new Wrapper(obj, "method");
*
* // call the method twice with the argument "good"
* obj.method("good");
* obj.method("good");
*
* // expect that none of the method calls had the argument "bad"
* obj.method.historyList.expectNever().expectCallArgs("bad"); // true
* obj.method.expectReportAllFailure(); // no failures, doesn't throw
*
* // call the method one more time with the argument "bad"
* obj.method("bad");
*
* // expect that none of the method calls had the argument "bad"
* obj.method.historyList.expectNever().expectCallArgs("bad"); // false
* obj.method.expectReportAllFailure(); // throws an error
*/
expectNone() {
return new ExpectIterator(this.wrapper, "none", this);
}
}
/**
* This class represents a single function call, property get, or property set. It can represent any of those
* things in the current tense ("this is the operation that is currently running") or in the past tense ("the
* historyList is full of operations that have already run"). {@link Trigger Triggers} use `Operations` in
* the present tense, and have the opportunity to modify the `Operation` as it is happening. {@link Filter Filters}
* operate on the `historyList` of the {@link Wrapper}, which is the history of all the times a function has been
* called or a property has been set / get.
*
* `Operations` may either represent an action taken on function or a property, as represented by the `Operator.type`
* property. If a `Operator.type` is "property", the `Operator.getOrSet` property will further distinguish whether
* this operation was a `get` operation (that is, getting the value from the property) or `set` operation (that is,
* assigning a value to the property).
*
* `Operations` extend the `Expect` class, which enables expect methods to be run against the `Operator`. Again
* these expectations can be run in real-time during a `Trigger`, or after-the-fact from the `historyList`.
* @borrows Expect#expectCallArgs as expectCallArgs
* @borrows Expect#expectCallContext as expectCallContext
* @borrows Expect#expectCustom as expectCustom
* @borrows Expect#expectException as expectException
* @borrows Expect#expectReturn as expectReturn
* @borrows Expect#expectSetVal as expectSetVal
*/
class Operation extends Expect {
/**
* Creates a new `Operation`
* @param {Wrapper} wrapper The `Wrapper` that created this `Operation`
* @param {Object} desc The default values of the Operation
* @return {Operation} The newly created `Operation`
* @throws {TypeError} If first argument isn't a `Wrapper` or second argument isn't an `Object`
*/
constructor(wrapper, desc) {
/** @lends Operation# */
super();
if (!Wrapper.isWrapper(wrapper)) {
throw new TypeError("Operation constructor: expected 'wrapper' argument to be of type Wrapper");
}
if (typeof desc !== "object") {
throw new TypeError("Operation constructor: expected desc to be of type Object");
}
// common properties
/** @type {Wrapper} The {@link Wrapper} that created this call */
this.wrapper = wrapper;
/** @type {String} The type of operation record this is, either "function" or "property" */
this.type = wrapper.type;
/** @type {Boolean} Set by a {@link Trigger} to be `true` if the operation record is currently in it's "pre-call" phase -- that is before the call to the wrapped thing has actually happened. */
this.preCall = false;
/** @type {Boolean} Set by a {@link Trigger} to be `true` if the operation record is in it's "post-call" phase. */
this.postCall = false;
/** @type {any} The value that was or will be returned. */
this.retVal = desc.retVal;
/** @type {Error|null} The `Error` that was or will be thrown. If null, there was no `Error`. */
this.exception = desc.exception || null;
if (this.type === "function") {
return this._functionConstructor(desc);
}
/* istanbul ignore else */
if (this.type === "property") {
return this._propertyConstructor(desc);
}
// NOTE: not reached
}
_functionConstructor(desc) {
/** @lends Operation# */
/** @type {any} The `this` value that was or will be used for the function call. Only applies to functions. */
this.context = desc.context;
/** @type {...any} The arguments that were or will be passed to the function call. Only applies to functions. */
this.argList = desc.argList;
killAlias("function", this, "getOrSet");
killAlias("function", this, "setVal");
this.callback = {};
return this;
}
_propertyConstructor(desc) {
/** @lends Operation# */
/** @type {String} Whether this was a property "set" (assignment) or a property "get" (retreiving the value). Only applies to properties. */
this.getOrSet = desc.getOrSet;
/** @type {any} The value that was or will be used for a property "set". Only applies to properties. */
this.setVal = desc.setVal;
killAlias("property", this, "context");
killAlias("property", this, "argList");
return this;
}
}
/**
* A `Trigger` determines what `expect` or `action` calls get run in real-time on a wrapped function or property, so that a wrapped
* function or property can be evaluated as it is being run (through `expect` calls) or modify the behavior of a wrapped function or
* property (through the use of `action` calls).
*
* Triggers usally get created by calling a trigger function on the {@link Wrapper}, such as `triggerAlways` or `triggerOnCallArgs`.
* Note that triggers are always run in the order that they are added to a `Wrapper`, and any expectations or actions on a
* trigger are run in the order they were added to the `Trigger`. It is important to realize that `Triggers` are not very
* interesting by themselves -- without any associated `action` or `expectation` they do nothing.
*
* Every time a wrapped function is called or a property is touched, triggers will be evaluated twice: once just
* before the wrapped function is executed or property is modified, and once after. This provides the opportunity to
* modify arguments or context that will be used before the wrapper is executed, and provides the opportunity to change
* return values or errors after the wrapper is executed. Any `actions` or `expectations` associated with the trigger will
* run each time it is evaluated and successfully determines that it should execute. This means that expectations are evaluated
* in real-time as the code is running. By default expectations will throw an error if they are not met when triggered (unless
* `configExpectThrowsOnTrigger` is called with `false` for the `Wrapper`).
*
* While most useful 'Triggers' and the associated `actions` and `exceptions` have been defined, it may be useful to define
* your own. For that purpose, `triggerCustom` and `actionCustom` have been defined.
*
* @borrows Expect#expectCallArgs as expectCallArgs
* @borrows Expect#expectCallContext as expectCallContext
* @borrows Expect#expectCustom as expectCustom
* @borrows Expect#expectException as expectException
* @borrows Expect#expectReturn as expectReturn
* @borrows Expect#expectSetVal as expectSetVal
* @example
* var wrapper = new Wrapper(something);
* wrapper.triggerAlways() // every time the wrapper is called...
* .expectArgs("abc", 123) // ...throws error if the wrong args are not `"abc"` and `123`...
* .actionReturn(true); // ...and always change the return value of the function to `true`
*/
class Trigger extends Expect {
/**
* Creates a new Trigger
* @param {Wrapper} wrapper The `Wrapper` that created this `Trigger` and that will evaluate the trigger conditions when
* the wrapper is executed.
* @param {Function} triggerFn The function that will be called to determine whether or not the `actions` and `expectations`
* associated with this trigger should be executed.
* @return {Trigger} The newly created `Trigger`
*/
constructor(wrapper, triggerFn) {
super();
if (!(wrapper instanceof Wrapper)) {
throw new TypeError("Trigger constructor: expected first argument to be of type Wrapper");
}
if (typeof triggerFn !== "function") {
throw new TypeError("Trigger constructor: expected second argument to be of type Function");
}
this.wrapper = wrapper;
this.triggerFn = triggerFn;
this.currentCall = null;
this.actionList = [];
/**
* When triggered, this action will change the return value of a function call or property get to the value specified by `retVal`.
* @name actionReturn
* @function
* @instance
* @memberof Trigger
* @param {any} retVal The value that will be returned by the function or property when this action is triggered.
* @return {Trigger} Returns this `Trigger`, so that further actions or expectations can be chained.
*/
alias(this, "actionReturn",
this._action, "actionReturn", "both", "post", validateArgsSingle,
function(curr, retVal) {
curr.retVal = retVal;
});
/**
* Specifies a custom action that will be run when triggered.
* @name actionCustom
* @function
* @instance
* @memberof Trigger
* @param {Trigger~customActionCallback} fn The function to be run when this action is triggered.
* @return {Trigger} Returns this `Trigger`, so that further actions or expectations can be chained.
*/
alias(this, "actionCustom",
this._action, "actionCustom", "both", "both", validateArgsFirstFunction,
function(curr, fn, ...args) {
return fn.call(this, curr, ...args);
});
/**
* This is the callback for {@link actionCustom}. Note that actions get called every time a {@link Trigger}
* executes, which will usually be twice for everytime a `Wrapper` is called -- once before the call, to
* modify arguments and context, and once after the call to modify exceptions and return values. Use the
* `current.preCall` and `current.postCall` properties to determine which half of the `Wrapper` call is
* occuring.
* @callback Trigger~customActionCallback
* @param {Operation} current The currently executing function or property set / get.
*/
/**
* When triggered this action will set the return value to the value of the argumennt list specified by the
* index `num`
* @name actionReturnFromArg
* @function
* @instance
* @memberof Trigger
* @param {Number} num The index of the argument list to return when the wrapper is called. As an index, this
* `0` represents the first value, `1` the second, etc.
* @return {Trigger} Returns this `Trigger`, so that further actions or expectations can be chained.
* @example
* var anon = new Wrapper();
*
* w.triggerAlways().actionReturnFromArg(2);
*
* var ret = anon("I", "like", "sleeping");
* console.log (ret); // "sleeping"
*/
alias(this, "actionReturnFromArg",
this._action, "actionReturnFromArg", "function", "post", validateArgsSingleNumber,
function(curr, num) {
curr.retVal = curr.argList[num];
});
/**
* When triggered, this action sets the return value to the `this` value of the function.
* @name actionReturnContext
* @function
* @instance
* @memberof Trigger
* @return {Trigger} Returns this `Trigger`, so that further actions or expectations can be chained.
*/
alias(this, "actionReturnContext",
this._action, "actionReturnContext", "function", "post", validateArgsNone,
function(curr) {
curr.retVal = curr.context;
});
/**
* When triggered, this action sets the return value to a property on the `this` value that is specified by
* the `prop` value.
* @name actionReturnFromContext
* @function
* @instance
* @memberof Trigger
* @param {String} prop The property on the `this` context that should be returned when the `Wrapper` is called.
* @return {Trigger} Returns this `Trigger`, so that further actions or expectations can be chained.
*/
alias(this, "actionReturnFromContext",
this._action, "actionReturnFromContext", "function", "post", validateArgsSingleString,
function(curr, prop) {
curr.retVal = curr.context[prop];
});
/**
* When triggered, this action throws the error specified by the `err` argument.
* @name actionThrowException
* @function
* @instance
* @memberof Trigger
* @param {Error|null} err An instance of `Error`, or some class inheriting from `Error`, that will be thrown
* when this action is triggered. If `null` is used instead of an `Error` then nothing will be thrown and it
* has the effect of clearing any errors that would have been thrown.
* @return {Trigger} Returns this `Trigger`, so that further actions or expectations can be chained.
*/
alias(this, "actionThrowException",
this._action, "actionThrowException", "both", "post", validateArgsSingleExceptionOrNull,
function(curr, err) {
curr.exception = err;
});
/**
* When triggered, this action behaves as if assigning the value `setVal` to the property.
* @name actionSetVal
* @function
* @instance
* @memberof Trigger
* @param {any} setVal The value to be assigned to the property. May be `undefined`, but requires `undefined` to
* be explicitly passed as an argument to the function.
* @return {Trigger} Returns this `Trigger`, so that further actions or expectations can be chained.
*/
alias(this, "actionSetVal",
this._action, "actionSetVal", "property", "pre", validateArgsSingle,
function(curr, setVal) {
// curr.getOrSet = "set";
curr.setVal = setVal;
});
/**
* When triggered, this action causes the `Wrapper` to return a resolved (that is, successful) `Promise`. If a
* `retVal` argument is provided, it will be the value that the `Promise` resolves to. If no `retVal` is provided,
* then whatever value the `Wrapper` would have returned is wrapped in a promise.
* @name actionReturnResolvedPromise
* @function
* @instance
* @memberof Trigger
* @param {any} [retVal] The optional return value to wrap. If this is not specified, the value that is returned
* by the `Wrapper` will be used.
* @return {Trigger} Returns this `Trigger`, so that further actions or expectations can be chained.
*/
alias(this, "actionReturnResolvedPromise",
this._action, "actionReturnResolvedPromise", "both", "post", validateArgsNoneOrOne,
function(curr, retVal) {
retVal = retVal || curr.retVal;
curr.retVal = Promise.resolve(retVal);
});
/**
* Similar to {@link Trigger#actionReturnPromise actionReturnPromise}, this action causes the `Wrapper` to return a
* `Promise`, but in this case the `Promise` is one that has been rejected (that is, failed).
* @name actionReturnRejectedPromise
* @function
* @instance
* @memberof Trigger
* @param {Error|null} err An instance of `Error`, or inheriting from `Error`, that will be the error the `Promise`
* resolves to. This will appear as the error argument that is passed to the `.catch()` call on the `Promise`.
* @return {Trigger} Returns this `Trigger`, so that further actions or expectations can be chained.
*/
alias(this, "actionReturnRejectedPromise",
this._action, "actionReturnRejectedPromise", "both", "post", validateArgsNoneOrOneException,
function(curr, err) {
err = err || curr.exception;
curr.exception = null;
curr.retVal = Promise.reject(err);
});
/**
* When triggered, sets the call args to the function to the arguments specified by args
* @name actionCallArgs
* @function
* @instance
* @memberof Trigger
* @param {...any} args The arguments to be passed to the wrapped function
* @return {Trigger} Returns this `Trigger`, so that further actions or expectations can be chained.
*/
alias(this, "actionCallArgs",
this._action, "actionCallArgs", "function", "pre", validateArgsAny,
function(curr, ...args) {
curr.argList = args;
});
/**
* When triggered, sets the context (`this` value) of the function to the object specified by `ctx`
* @name actionCallContext
* @function
* @instance
* @memberof Trigger
* @param {ctx} args The context to be passed to the wrapped function
* @return {Trigger} Returns this `Trigger`, so that further actions or expectations can be chained.
*/
alias(this, "actionCallContext",
this._action, "actionCallContext", "function", "pre", validateArgsSingle,
function(curr, ctx) {
curr.context = ctx;
});
/**
* When triggered, this action will callback the function specified by `fn`. It may be combined with
* {@link Trigger#actionCallbackContext actionCallbackContext} and {@link Trigger#actionCallbackArgs actionCallbackArgs}
* to specify the `this` context and arguments to pass to the callback.
* @name actionCallbackFunction
* @function
* @instance
* @memberof Trigger
* @param {Function} fn The function to be called as a callback. This function is called after the completion of all `Triggers`.
* @return {Trigger} Returns this `Trigger`, so that further actions or expectations can be chained.
*/
alias(this, "actionCallbackFunction",
this._action, "actionCallbackFunction", "function", "post", validateArgsSingleFunction,
function(curr, fn) {
curr.callback.fn = fn;
});
/**
* Similar to {@link Trigger#actionCallbackFunction actionCallbackFunction}, but treats one the arguments specified by the
* index `num` as the callback function. It will throw an `Error` if the argument at `num` is not a function. It may be combined with
* {@link Trigger#actionCallbackContext actionCallbackContext} and {@link Trigger#actionCallbackArgs actionCallbackArgs}
* to specify the `this` context and arguments to pass to the callback.
* @name actionCallbackToArg
* @function
* @instance
* @memberof Trigger
* @param {Number} num The index into the array of arguments, where the resulting argument will be treated as a callback function.
* This function is called after the completion of all `Triggers`.
* @throws {Error} If the argument at `num` is not a function.
* @return {Trigger} Returns this `Trigger`, so that further actions or expectations can be chained.
*/
alias(this, "actionCallbackToArg",
this._action, "actionCallbackToArg", "function", "post", validateArgsSingleNumber,
function(curr, num) {
var cb = curr.argList[num];
if (typeof cb !== "function") {
curr.exception = new Error(`actionCallbackToArg: expected argument ${num} to be callback function`);
return;
}
curr.callback.fn = cb;
});
/**
* When triggered, this action sets the `this` context of the callback function. If no callback function is
* specified, this action has no meaningful impact.
* @name actionCallbackContext
* @function
* @instance
* @memberof Trigger
* @param {any} context The `this` context to use when calling the callback function.
* @return {Trigger} Returns this `Trigger`, so that further actions or expectations can be chained.
*/
alias(this, "actionCallbackContext",
this._action, "actionCallbackContext", "function", "post", validateArgsSingle,
function(curr, context) {
curr.callback.context = context;
});
/**
* When triggered, this action sets the arguments of the callback function. If no callback function is
* specified, this action has no meaningful impact.
* @name actionCallbackArgs
* @function
* @instance
* @memberof Trigger
* @param {...any} args The arguments to pass to the callback function.
* @return {Trigger} Returns this `Trigger`, so that further actions or expectations can be chained.
*/
alias(this, "actionCallbackArgs",
this._action, "actionCallbackArgs", "function", "post", validateArgsAny,
function(curr, ...args) {
curr.callback.argList = args;
});
}
_run(op) {
// check trigger to see if it should run
if (!this.triggerFn(op)) return;
// perform actions
this.currentCall = op;
for (let i = 0; i < this.actionList.length; i++) {
let action = this.actionList[i];
this[action.name](...action.argList);
}
this.currentCall = null;
}
_action(name, type, timing, validateFn, fn, ...args) {
if (typeof name !== "string") {
throw new TypeError("_action: expected 'name' argument");
}
if (type === "property") this.wrapper._propOnly(name);
if (type === "function") this.wrapper._funcOnly(name);
if (typeof timing !== "string") {
throw new TypeError("_action: expected 'timing' argument");
}
if (typeof validateFn !== "function") {
throw new TypeError("_action: expected 'validateFn' argument");
}
if (typeof fn !== "function") {
throw new TypeError(`_action: expected function argument`);
}
validateFn(name, ...args);
// get the current call, or save the args for when the call is actually run
var curr = this._getCurrCallOrDefer(name, ...args);
// if there isn't a current call, return 'this' to enable chaining
if (!curr) return this; // chainable
// check if this is the right time to run
var runNow = (timing === "both") ||
(curr.postCall && timing === "post") ||
(curr.preCall && timing === "pre");
if (!runNow) return this;
// call the callback
fn.call(this, curr, ...args);
return this;
}
}
/***************************************************
* helper functions for validating arguments
****************************************************/
function validateArgsSingleExceptionOrNull(name, ...args) {
if (args.length === 1 && (args[0] instanceof Error)) return;
if (args.length === 1 && (args[0] === null)) return;
throw new TypeError(`${name}: expected a single argument of type Error`);
}
function validateArgsSingleBoolean(name, ...args) {
if (args.length !== 1 ||
typeof args[0] !== "boolean") {
throw new TypeError(`${name}: expected a single argument of type Boolean`);
}
}
function validateArgsSingleFunction(name, ...args) {
if (args.length !== 1 ||
typeof args[0] !== "function") {
throw new TypeError(`${name}: expected a single argument of type Function`);
}
}
function validateArgsFirstFunction(name, ...args) {
if (typeof args[0] !== "function") {
throw new TypeError(`${name}: expected the first argument to be of type Function`);
}
}
function validateArgsSingleNumber(name, ...args) {
if (args.length !== 1 ||
typeof args[0] !== "number") {
throw new TypeError(`${name}: expected a single argument of type Number`);
}
}
function validateArgsSingleString(name, ...args) {
if (args.length !== 1 ||
typeof args[0] !== "string") {
throw new TypeError(`${name}: expected a single argument of type String`);
}
}
function validateArgsSingle(name, ...args) {
if (args.length !== 1) {
throw new TypeError(`${name}: expected a single argument of any type`);
}
}
function validateArgsNone(name, ...args) {
if (args.length !== 0) {
throw new TypeError(`${name}: didn't expect any args`);
}
}
function validateArgsNoneOrOne(name, ...args) {
if (args.length < 0 ||
args.length > 1) {
throw new TypeError(`${name}: expected exactly one or two args`);
}
}
function validateArgsNoneOrOneException(name, ...args) {
if (args.length === 0) return;
if (args.length === 1 && args[0] instanceof Error) return;
throw new TypeError(`${name}: expected first argument to be of type Error, or no arguments`);
}
function validateArgsAny() {}
var sandboxSingleton;
/**
* A Sandbox is a container for all your wrappers. It gives you a convenient way to track
* them, and unwrap everything when you are done with your wrappers. This is convenient for
* test suites, which may be wrapping and unwrapping the same objects on a regular basis.
*
* Sandbox also has a singleton -- a single instance of a Sandbox that can be used across
* multiple contexts.
*
* ``` js
* // create a new sandbox
* sb = new Sandbox();
*
* // create a new wrapper in the sandbox
* var wrapper = sb.newWrapper(obj, method);
*
* // destroy the sandbox, which unwraps the object
* sb.destroy();
*
* // also works with tests
* it("does a test", Sandbox.test(function() {
* var wrapper = this.sandbox.newWrapper(obj, method);
* }))
*
* // the singleton is a convenient way of accessing your sandbox anywhere
* function init() {
* var sb = Sandbox.singletonStart();
* sb.newWrapper(obj, method);
* }
*
* // get the same sandbox as the one in init
* var sb = Sandbox.singletonGetCurrent();
* sb.newWrapper(obj, method);
*
* // unwrap all the wrappers created on the singleton
* Sandbox.singletonEnd();
* ```
*/
class Sandbox {
/**
* Creates a new Sandbox
* @return {Sandbox} The newly created `Sandbox`
*/
constructor() {
this.wrapperList = new Set();
this.config = {
injectProperty: "sandbox"
};
}
/**
* Creates a Sandbox singleton. Future calls to `Sandbox.singletonGetCurrent` will retrieve the
* singletone. Makes it easy to work with the same `Sandbox` across multiple contexts.
* @return {Sandbox} The Sandbox singleton, a single global singleton.
* @throws {Error} If the Sandbox Singleton has already been created.
*/
static singletonStart() {
if (typeof sandboxSingleton === "object") {
throw new Error("Sandbox.singletonStart: already started");
}
sandboxSingleton = new Sandbox();
return sandboxSingleton;
}
/**
* Retreives the current sandbox singleton
* @return {Sandbox} The current sandbox singleton
* @throws {Error} If the Snadbox Singleton hasn't been created yet (i.e. - through `singletonStart`)
*/
static singletonGetCurrent() {
if (typeof sandboxSingleton !== "object") {
throw new Error("Sandbox.singletonGetCurrent: not started yet");
}
return sandboxSingleton;
}
/**
* Destroys the current sandbox singleton
* @return {Sandbox} The current sandbox singleton
* @throws {Error} If the Sandbox Singleton hasn't been created yet (i.e. - through `singletonStart`)
*/
static singletonEnd() {
if (typeof sandboxSingleton !== "object") {
throw new Error("Sandbox.singletonEnd: not started yet");
}
sandboxSingleton.destroy();
sandboxSingleton = undefined;
}
/**
* Creates a testing context, is designed to be passed to something like (Mocha's)[https://mochajs.org/]
* `it()` functions. The `this` context of the callback function will have a `.sandbox` value
* that will be a `Sandbox` for creating new wrappers. Any wrappers created with `this.sandbox.newWrapper()`
* will be automatically unwrapped when the test ends.
* @param {Function} fn The callback function that will be executed.
* @example
* it("does a test", Sandbox.test(function() {
* this.sandbox.newWrapper(...);
* }))
* // all wrappers are unwrapped when the sandbox exists
*/
static test(fn) {
return function(...args) {
var sb = new Sandbox();
this.sandbox = sb;
var ret = fn.call(this, ...args);
sb.destroy();
return ret;
};
}
/**
* This is the same as {@link Sandbox#test test}, but expects a done callback.
* @param {Function} fn The callback function that will be executed.
* @example
* it("does a test", Sandbox.test(function(done) {
* this.sandbox.newWrapper(...);
* done();
* }))
* // all wrappers are unwrapped when the sandbox exists
*/
static testAsync(fn) {
return function(done, ...args) {
var sb = new Sandbox();
this.sandbox = sb;
fn.call(this, done, ...args);
sb.destroy();
};
}
/**
* The same as `new Wrapper()` for creating a {@link Wrapper}, but the `Wrapper`
* will be contained in the Sandbox, guaranteeing that it will be unwrapped when
* the Sandbox is done. See {@link Wrapper} for details on this function.
*/
newWrapper(...args) {
var w = new Wrapper(...args);
if (args.length === 1 &&
typeof args[0] === "object") {
// if wrapped everything in an object, find everything and add it to our list
walkObject(args[0], (obj, key) => {
if (Wrapper.isWrapper(obj, key)) {
this.wrapperList.add(obj[key]);
}
});
} else {
// add single wrapper (single function, property, method) to list
this.wrapperList.add(w);
}
return w;
}
/**
* Destroys a Sandbox, unwrapping all the wrappers that belong to it.
*/
destroy() {
for (let w of this.wrapperList) {
w.configUnwrap();
}
this.wrapperList.length = 0;
}
}
var matcherRegistrySingleton;
/**
* A class for matching anything. Really, anything.
*
* Most {@link Trigger Triggers}, {@link Filter Filters} and expects use `Match` internally to match
* values, and passing a new `Match` to them will use the `Match` that you specify.
*
* Matching has two major flavors: matching by "value", where two things are strictly compared to
* see if they are they exact same; and matching by "type", where something is compared to a class
* of things to see if it belongs to that class.
*
* Examples of comparison by value are `3 === 3`, or `{a: 1} === {a: 1}`. You'll note that the latter
* doesn't really work with strict equals, so comparisons need to be a bit more sophisticated. To that
* end, objects and arrays are recursively compared for a strict match using `Match.value`. To help out with this are a
* number of registered types that can spot the differences between things. Looking at the `Match.addType`
* and `Match.getType` static methods, these help to register handlers for specific data types. Most
* data types are already registered, but feel free to add your own (or create a library of types and
* share with friends!). These registered types create {@link Diff Diffs} that represent the one-or-more
* differences between two items.
*
* Examples of comparison by type are `3 === "string"` or `{a: 1} === "object"`. You can use `Match.type`
* to compare something to it's type, again using the built-in types or the types of your own choosing.
*
* A `Match` object contains an original value or type and is ready to compare to anything passed to it
* using the `compare()` method. This makes matches easy to pass around, such as passing them as arguments
* to functions that do comparison.
* @example
* var matchVal = Match.value("bob");
* matchVal.compare("bob"); // true
* matchVal.compare("sally"); // false
* matchVal.compare(undefined); // false
*
* matchVal = Match.value(undefined);
* matchVal.compare(undefined); // true
*
* var matchType = Match.type("number");
* matchType.compare(3); // true
* matchType.compare(42); // true
* matchType.compare("test"); // false
* matchType.compare("3"); // false
*
* console.log (Match.getType("foo").name); // "string"
* console.log (Match.getType(12).name); // "number"
* console.log (Match.getType(true).name); // "boolean"
* console.log (Match.getType(null).name); // "null"
* console.log (Match.getType(new Error()).name); // "error"
* console.log (Match.getType(new Date()).name); // "date"
* console.log (Match.getType([1, 2, 3]).name); // "array"
* console.log (Match.getType({a: 1}).name); // "object"
*/
class Match {
constructor(opts) {
opts = opts || {};
// if !opts throw
if (opts.hasOwnProperty("value")) {
// if the value is a Match, just use that instead
if (opts.value instanceof Match) {
return opts.value;
}
this.value = opts.value;
this.matchType = "value";
} else if (opts.hasOwnProperty("type")) {
if (typeof opts.type !== "string") {
throw new TypeError("Match constructor: expected type name to be of type String");
}
// make sure the type exists in the registry
var reg = Match.getMatcherTypeRegistry();
if (!reg.matcherList.has(opts.type)) {
throw new TypeError(`Match constructor: type '${opts.type}' hasn't be registered`);
}
this.typeStr = opts.type;
this.matchType = "type";
// } else if (opts.custom) {
// this.custom = opts.custom;
} else {
throw new TypeError("Match: requires a value or type to match");
}
}
/**
* Compares a new value to the previously defined value.
* @param {any} value The value to be compared against the original value when the `Match` was created.
* @return {Boolean} Returns `true` if `value` matches the original value; `false` otherwise
*/
compare(value) {
// matching by explicit value
if (this.matchType === "value") {
var d = Match.diff(this.value, value);
this.lastDiff = d;
if (d.length === 0) return true;
}
// matching by type
if (this.matchType === "type") {
var type = Match.getType(value);
// match any parent types of the value
// for example, an "Error" is an "Object"; however, an "Object" is not an "Error"
while (type.parent && type.name !== this.typeStr) type = type.parent;
return (this.typeStr === type.name);
}
// TODO: if allowUndefined -- filter undefined
return false;
}
/**
* Returns the "type registry" singleton which contains a list and hierarchy of all
* the different registered types.
* @return {Object} The matcherRegistrySingleton
* @private
*/
static getMatcherTypeRegistry() {
if (typeof matcherRegistrySingleton === "object")
return matcherRegistrySingleton;
matcherRegistrySingleton = {};
matcherRegistrySingleton.matcherList = new Map();
matcherRegistrySingleton.matcherHierarchy = [];
Match.addType("number", null, testNumber, diffNumber);
Match.addType("object", null, testObject, diffObject);
Match.addType("string", null, testString, diffString);
Match.addType("null", null, testNull, diffNull);
Match.addType("boolean", null, testBoolean, diffBoolean);
Match.addType("undefined", null, testUndef, diffUndef);
Match.addType("array", "object", testArray, diffArray);
Match.addType("date", "object", testDate, diffDate);
Match.addType("regexp", "object", testRegex, diffRegex);
Match.addType("error", "object", testError, diffError);
return matcherRegistrySingleton;
}
/**
* A convenience class that creates a new Match and sets the value to be matched to `arg`.
* @param {arg} arg The argument to be matched
* @return {Match} The new `Match` instance
* @example
* var m = Match.value("foo"); // create new `Match` with value set to "foo"
* m.compare("foo"); // true
*/
static value(arg) {
return new Match({
value: arg
});
}
/**
* A convenience class that creates a new Match and sets the type to be matched to `arg`.
* @param {arg} arg The type to be matched. Built-in types include: "object", "array", "string",
* "number", "boolean", "number", "null", "undefined", "date", "regexp", and "error".
* @return {Match} The new `Match` instance
* @example
* var m = Match.type("boolean"); // create new `Match` with value set to "foo"
* m.compare(false); // true
* m.compare("test"); // false
*/
static type(arg) {
return new Match({
type: arg
});
}
/**
* Creates a deep diff between two values. If the arguments are `Objects` or `Arrays`, they are
* recurisvely iterated and compared.
* @param {any} value1 The first value to diff.
* @param {any} value2 The second value to diff.
* @return {Array} An `Array` of differences between `value1` and `value2`
* @example
* (
* {a: 1, b: 2, c: 3},
* {a: 1, c: 4}
* );
* // returns:
* // [{key: ".b", src: 2, dst: undefined},
* // {key: ".c", src: 3, dst: 4}]
*/
static diff(value1, value2) {
// if the first value is a Match create a type-based diff
if (value1 instanceof Match) {
let matched = value1.compare(value2);
let diff = new Diff("type-match");
if (!matched && value1.matchType === "type") {
diff.addDiff(value1, value2);
}
return diff;
}
// console.log ("value2 match?", value2 instanceof Match);
var matcher = Match.findCommonType(value1, value2);
debug("diff: matcher:", matcher);
if (!matcher) {
debug("common type not found, returning diff");
// throw new TypeError("diff: can't compare uncommon values");
return new Diff("type-mismatch").addDiff(value1, value2);
}
return matcher.diff(value1, value2);
}
/**
* Returns the most specific 'matcher' that can identify the type
* @param {any} value The value to get the type for
* @param {Map} [matcherList=matcherRegistrySingleton] The matcher list to retrieve the type from. Mosty used for recursion.
* @return {Object} The previously registered 'matcher' Object
* @example
* var matcher = Match.getType("foo");
* console.log (matcher.name); // "string"
*
* matcher = Match.getType(true);
* console.log (matcher.name); // "boolean"
*
* matcher = Match.getType(null);
* console.log (matcher.name); // "null"
*
* matcher = Match.getType(undefined);
* console.log (matcher.name); // "undefined"
*/
static getType(value, matcherList) {
if (!matcherList) {
let m = Match.getMatcherTypeRegistry();
matcherList = m.matcherHierarchy;
}
// check for the type in the provided list
for (let i = 0; i < matcherList.length; i++) {
let matcher = matcherList[i];
if (matcher.test(value)) {
debug("matcher found:", matcher.name);
// recursively check any children for the type
let nextType = Match.getType(value, matcher.children);
debug("next type:", nextType);
return (nextType ? nextType : matcher);
} else {
debug("didn't match:", matcher.name);
}
}
return null;
}
/**
* Adds a new type to the type registry
* @param {String} name The name of the type
* @param {String|null} parentName The parent of this type. For example, the parent
* of `Array` or `Error` is `Object`, since both `Array` and `Error` are objects. Care
* must be used in selecting the right parent, or else `getType` may fail and so will
* everything else. Use `null` if no parent type exists; however, all conceivable
* parent types have already been registered by default.
* @param {Function} testFn A function that when passed a value, will return `true`
* if it is of this type and `false` otherwise.
* @param {Function} diffFn A function that when passed two values of this type can
* tell the difference between them and return a proper diff `Array`.
* @example
* function testRegex(rex) {
* if (rex instanceof RegExp) return true;
* return false;
* }
*
* function diffRegex(rex1, rex2) {
* if (rex1.toString() !== rex2.toString()) return newDiff(rex1.toString(), rex2.toString());
* return [];
* }
*
* Match.addType("regexp", "object", testRegex, diffRegex);
*/
static addType(name, parentName, testFn, diffFn) {
if (typeof name !== "string") {
throw new TypeError(`Match.addType: 'name' should be string`);
}
if (matcherRegistrySingleton.matcherList.has(name)) {
throw new TypeError(`Match.addType: '${name}' already exists`);
}
var parentMatcher = matcherRegistrySingleton.matcherList.get(parentName);
parentMatcher = parentMatcher || null;
var matcher = {
name: name,
test: testFn,
diff: diffFn,
parent: parentMatcher,
children: []
};
matcherRegistrySingleton.matcherList.set(name, matcher);
if (!parentMatcher) {
matcherRegistrySingleton.matcherHierarchy.push(matcher);
} else {
parentMatcher.children.push(matcher);
}
}
/**
* Finds a common type between two matchers or values
* @param {any} matcher1 A matcher or any value that can be converted to a matcher using {@link Match.getType}
* @param {any} matcher2 A matcher or any value that can be converted to a matcher using {@link Match.getType}
* @return {Object|null} The matcher that is common to the type of both `matcher1` and `matcher2`
* or `null` if no common type can be found.
* @example
* var matcher = Match.findCommonType({}, new Error());
* console.log (matcher.name); // "object"
* var matcher = Match.findCommonType({}, "foo");
* console.log (matcher.name); // null
* @private
*/
static findCommonType(matcher1, matcher2) {
// convert values to matchers
if (!Match.isMatcher(matcher1)) {
debug("findCommonType: converting matcher1 value to matcher:", matcher1);
matcher1 = Match.getType(matcher1);
debug("matcher1:", matcher1);
}
if (!Match.isMatcher(matcher2)) {
debug("findCommonType: converting matcher2 value to matcher:", matcher2);
matcher2 = Match.getType(matcher2);
debug("matcher2:", matcher2);
}
var m1types = [];
var m2types = [];
// make a list of the names of all the parents
var p = matcher1;
while (p) {
m1types.unshift(p.name);
p = p.parent;
}
p = matcher2;
while (p) {
m2types.unshift(p.name);
p = p.parent;
}
debug("m1types", m1types);
debug("m2types", m2types);
// find the furthest most common type
var commonType = null;
for (let i = 0; i < m1types.length; i++) {
if (m1types[i] === m2types[i]) {
commonType = m1types[i];
} else {
break;
}
}
debug("commonType:", commonType);
// resolve common type name to matcher object
if (commonType) {
let m = Match.getMatcherTypeRegistry();
commonType = m.matcherList.get(commonType);
}
return commonType;
}
/**
* Checks whether an `Object` is a valid matcher
* @param {Object} matcher The object to check and see if it's a matcher.
* @return {Boolean} `true` if the `Object` is a matcher; `false` otherwise
* @private
*/
static isMatcher(matcher) {
debug("isMatcher:", matcher);
return (typeof matcher === "object" &&
matcher !== null &&
typeof matcher.name === "string" &&
typeof matcher.test === "function" &&
typeof matcher.diff === "function" &&
Array.isArray(matcher.children) &&
(
matcher.parent === null ||
Match.isMatcher(matcher.parent)
));
}
}
/**
* A class for storing diff results and subsequently turning them in to strings.
* @private
*/
class Diff extends Array {
constructor(name) {
super();
Object.defineProperty(this, "diffType", {
configurable: false,
enumerable: false,
value: name,
writable: false
});
}
/**
* Adds a new diff to the diff collection.
* @param {any} v1 The first value, typically the 'original' value, that didn't match.
* @param {any} v2 The second value that didn't match.
* @param {String} [key] The Object or Array property or index, generically described as a "key".
* Includes any decorators such as bracket or dot notation.
*/
addDiff(v1, v2, key) {
var ret = {
src: v1,
dst: v2
};
if (key !== undefined) {
ret.key = key;
}
this.push(ret);
return this;
}
/**
* Converts the diff to an array of descriptive strings that explain why two things are different.
* @return {Array.<String>} One string in the array for each difference.
*/
toStringArray() {
var msgList = [];
for (let val of this) {
let key = val.key;
if (key) msgList.push(`At ${key}: Expected: '${val.src}'; Got: '${val.dst}'`);
else msgList.push(`Expected: '${val.src}'; Got: '${val.dst}'`);
}
return msgList;
}
}
/***************************************************
* helper functions for testing various data types
****************************************************/
function testNumber(n) {
if (typeof n === "number") return true;
return false;
}
function diffNumber(n1, n2) {
if (n1 !== n2) return new Diff("number").addDiff(n1, n2);
return new Diff("number");
}
function testString(s) {
if (typeof s === "string") return true;
return false;
}
function diffString(s1, s2) {
if (s1 !== s2) return new Diff("string").addDiff(s1, s2);
return new Diff("string");
}
function testObject(o) {
if (typeof o === "object" && o !== null) return true;
return false;
}
function diffObject(o1, o2) {
var diff = new Diff("object");
// make a list of all the keys between the two objects
var keyList = new Set();
for (let key in o1) {
keyList.add(key);
}
for (let key in o2) {
keyList.add(key);
}
// for the combined list of keys, create a list of different keys or different values
for (let key of keyList) {
if ((key in o1) && (key in o2)) {
let childDiff = Match.diff(o1[key], o2[key]);
for (let childDiffMember of childDiff) {
let newKey = childDiffMember.key ? key + childDiffMember.key : key;
diff.addDiff(childDiffMember.src, childDiffMember.dst, "." + newKey);
}
} else {
diff.addDiff(o1[key], o2[key], "." + key);
}
}
debug("returning diff:", diff);
return diff;
}
function testArray(a) {
if (Array.isArray(a)) return true;
return false;
}
function diffArray(a1, a2) {
var diff = new Diff("array");
// for the longer of the arrays, create a list of different values
var len = (a1.length > a2.length) ? a1.length : a2.length;
for (let i = 0; i < len; i++) {
// recursive diff
let childDiff = Match.diff(a1[i], a2[i]);
for (let childDiffMember of childDiff) {
let newKey = childDiffMember.key ? `[${i}]` + childDiffMember.key : `[${i}]`;
diff.addDiff(childDiffMember.src, childDiffMember.dst, newKey);
}
}
return diff;
}
function testNull(n) {
if (n === null) return true;
return false;
}
function diffNull() {
// guaranteed that both n1 and n2 are of type "null"
// since there's no different types of null, there can be no real diff here
return new Diff("null");
}
function testBoolean(b) {
if (typeof b === "boolean") return true;
return false;
}
function diffBoolean(b1, b2) {
if (b1 !== b2) return new Diff("boolean").addDiff(b1, b2);
return new Diff("boolean");
}
function testDate(d) {
if (d instanceof Date) return true;
return false;
}
function diffDate(d1, d2) {
if (d1.getTime() !== d2.getTime()) return new Diff("date").addDiff(d1, d2);
return new Diff("date");
}
function testUndef(u) {
if (u === undefined) return true;
return false;
}
function diffUndef() {
// guaranteed that both n1 and n2 are of type "undefined"
// since there's no different types of undefined, there can be no real diff here
return new Diff("undefined");
}
function testRegex(rex) {
if (rex instanceof RegExp) return true;
return false;
}
function diffRegex(rex1, rex2) {
if (rex1.toString() !== rex2.toString()) return new Diff("regexp").addDiff(rex1.toString(), rex2.toString());
return new Diff("regexp");
}
function testError(e) {
if (e instanceof Error) return true;
}
function diffError(e1, e2) {
var diff = new Diff("error");
if (e1.name !== e2.name) diff.addDiff(e1.name, e2.name, "name");
if (e1.message !== e2.message) diff.addDiff(e1.message, e2.message, "message");
return diff;
}
/**
* Used for creating a group of behavior-based stubs and tests. Modules are like most JavaScript modules: a
* group of logically related behaviors -- for example, those that would be imported through a `require` call.
*
* Modules have interfaces that can created through `defineMethod` (for functions) and `defineProperty`. Every
* interface can have multiple {@link Behavior Behaviors}, that describe how the interface works.
*
* See the {@tutorial modules-and-behaviors} Tutorial for more information.
*
* @tutorial modules-and-behaviors
*/
class Module {
/**
* Creates a new Module
* @param {String} [moduleName] The optional name for the new module. Currently not used for anything, but may
* be used in the future for `submodule` functionality. Using a module name is encouraged for future compatibility.
* @return {Module} The newly created `Module`
*/
constructor(moduleName) {
this.moduleName = moduleName;
this.propertyMap = new Map();
this.behaviorMap = new Map();
this.testList = [];
}
/**
* Defines a new method / funciton interface on the module.
* @param {String} name The name of the interface (i.e. - the key used to access / call the method)
* @return {Interface} The interface that was created
* @throws {TypeError} If first argument isn't of type `String`
*/
defineMethod(name) {
validateArgsSingleString("defineMethod", name);
return this.defineInterface(name, "function");
}
/**
* Defines a new property interface on the module.
* @param {String} name The name of the interface / property
* @return {Interface} The interface that was created
* @throws {TypeError} If first argument isn't of type `String`
*/
defineProperty(name) {
validateArgsSingleString("defineProperty", name);
return this.defineInterface(name, "property");
}
/**
* Defines an interface (method or property) on the module
* @param {String} name The name of the interface to be created
* @param {String} type The type of interface, either "function" or "property"
* @return {Interface} The interface that was created
* @private
*/
defineInterface(name, type) {
if (this.getInterface(name)) {
throw new TypeError(`defineInterface: '${name}' is already defined`);
}
var newProp = new Interface(this, name, type);
this.propertyMap.set(name, newProp);
return newProp;
}
/**
* Defines a new behavior for the module.
* @param {String} behaviorName The name of the behavior
* @param {String} [interfaceName] An optional interface that the behavior is being defined for
* @return {Behavior} The Behavior that was created
* @throws {TypeError} If first argument isn't a string or isn't the name of a previously defined behavior.
* If second argument is specified but isn't a string, or isn't the name of a previously defined interface.
*/
defineBehavior(behaviorName, interfaceName) {
if (typeof behaviorName !== "string") {
throw new TypeError("defineBehavior: expected first argument 'behaviorName' to be of type String");
}
if (this.getBehavior(behaviorName)) {
throw new TypeError(`defineBehavior: behavior name '${behaviorName}' is already defined`);
}
if (this.getInterface(behaviorName)) {
throw new TypeError(`defineBehavior: behavior name '${behaviorName}' is already used as an interface name`);
}
var newBehavior = new Behavior(this, behaviorName);
this.behaviorMap.set(behaviorName, newBehavior);
if (interfaceName === undefined) return newBehavior;
if (typeof interfaceName !== "string") {
throw new TypeError("defineBehavior: expected second argument 'interfaceName' to be of type String");
}
var foundInterface = this.getInterface(interfaceName);
if (!foundInterface) {
throw new TypeError(`defineBehavior: interface '${interfaceName}' not found`);
}
return newBehavior[foundInterface.interfaceName]();
}
/**
* Returns the interface specified by `interfaceName`
* @param {String} interfaceName The name of the interface to get
* @return {Interface|undefined} The requrested interface or undefined if the interface wasn't found
* @throws {TypeError} If first argument isn't of type `String`
*/
getInterface(interfaceName) {
validateArgsSingleString("getInterface", interfaceName);
return this.propertyMap.get(interfaceName);
}
/**
* Returns the behavior specified by `behaviorName`
* @param {String} behaviorName The name of the behavior to get
* @return {Behavior|undefined} The requrested behavior or undefined if the behavior wasn't found
* @throws {TypeError} If first argument isn't of type `String`
*/
getBehavior(behaviorName) {
validateArgsSingleString("getBehavior", behaviorName);
return this.behaviorMap.get(behaviorName);
}
/**
* Returns a stub {@link Wrapper} for the specified behavior.
* @param {String} behaviorName The behavior to create a stub for
* @return {Wrapper} A stub that performs the specified behavior
* @throws {TypeError} If first argument isn't of type `String`
*/
getStub(behaviorName) {
validateArgsSingleString("getStub", behaviorName);
var behavior = this.getBehavior(behaviorName);
if (!behavior) {
throw new TypeError(`getStub: behavior '${behaviorName}' not defined`);
}
return behavior.getStub();
}
/**
* Specifies that one of the previously defined `Behaviors` should be tested by the module. Only
* the behaviors that have been specified through `defineTest` will be tested. Tests will be run
* in the order that they are defined.
* @param {String} behaviorName The name of the behavior to be tested
* @param {String} [desc] An optional description of the test, similar to what might be passed to
* the `test()` or `it()` function of a test runner. If not specified, the behavior name will be used.
* @throws {TypeError} If first argument isn't a string or isn't the name of a previously defined behavior.
* If second argument is provided, but isn't of type `String`.
*/
defineTest(behaviorName, desc) {
if (typeof behaviorName !== "string") {
throw new TypeError("defineTest: expected argument 'behaviorName' to be of type String");
}
var behavior = this.getBehavior(behaviorName);
if (!behavior) {
throw new TypeError(`defineTest: behavior '${behaviorName}' not defined`);
}
if (desc !== undefined && typeof desc !== "string") {
throw new TypeError("defineTest: expection optional argument 'desc' to be of type String");
}
desc = desc || behavior.behaviorName;
/**
* @typedef {Object} Module#Test
* @property {String} behaviorName The name of the behavior to be tested
* @property {String} desc A short description of the test...[any]
* @property {Function} fn The function that runs the test. No arguments required and no return value. Throws on failure.
*/
this.testList.push({
behaviorName: behaviorName,
desc: desc
});
}
_testFunctionFactory(behavior) {
return function(mod) {
return behavior.runTest(mod);
};
}
/**
* Looks up a test for the behavior specified by `behaviorName`
* @param {String} behaviorName The behavior that a test has been defined for
* @param {Object} module The module to be tested
* @return {Function|undefined} The function for running the test or `undefined`
*/
getTest(behaviorName, module) {
for (let i = 0; i < this.testList.length; i++) {
let test = this.testList[i];
if (test.behaviorName === behaviorName) {
let behavior = this.getBehavior(behaviorName);
var fn = this._testFunctionFactory(behavior);
if (typeof fn === "function") return fn(module);
}
}
}
/**
* Returns an array of tests.
* @returns {Module#Test[]} An array of test objects. Each object has a `desc` and `fn` that is ready to be passed to a
* test runner, such as Mocha, Jasmine, or QUnit.
*/
getTestList() {
// cheap deep-clone testList
var retTestList = JSON.parse(JSON.stringify(this.testList));
// add test function to corresponding testList entries
for (let i = 0; i < retTestList.length; i++) {
let name = retTestList[i].behaviorName;
let behav = this.getBehavior(name);
retTestList[i].fn = this._testFunctionFactory(behav);
}
return retTestList;
}
/**
* Runs all the tests in the testList.
* @param {Object} mod The module to be tested. Should support all the interfaces and behaviors that will be tested.
* @param {Function} cb A callback that receives the arguments `description` and `function` for each test to be run. Most
* testing framworks can simply pass a `test` or `it` function.
*/
runAllTests(mod, cb) {
if (typeof mod !== "object") {
throw new TypeError("runAllTests: expected first argument to be the module to be tested and be of type Object");
}
if (typeof cb !== "function") {
throw new TypeError("runAllTests: expected second argument to be callback function for running tests");
}
var runList = this.getTestList();
for (let i = 0; i < runList.length; i++) {
let test = runList[i];
let fn = test.fn(mod);
cb(test.desc, fn);
}
}
}
/**
* An interface is a method or property of an {@link Module} that is intended to serve as part of that Module's API.
* This simple class contains a minimal amount of information about the interface.
*/
class Interface {
constructor(module, name, type) {
/** @type {Module} The parent module of this interface */
this.module = module;
/** @type {String} The name of the interface, which behaves as the key to the object */
this.interfaceName = name;
/** @type {String} The type of interface, either `"function"` or `"property"` */
this.interfaceType = type;
}
}
// returns a function, and that function returns the specified value
function fnRetVal(val) {
var fn = function(retVal) {
return retVal;
};
return fn.bind(null, val);
}
/**
* A behavior describes how an interface should act in a specific instance. Every interface can (and should) have one or more
* behavior defined for it. The primary interface for behaviors is through {@link Module Module's} methods.
*/
class Behavior {
constructor(module, name) {
this.module = module;
this.behaviorName = name;
this.behaviorSequence = [];
this.chainable = new Proxy(this, {
get: (obj, property) => {
var found = this._lookupProperty(property);
if (!found) return obj[property];
return found;
}
});
return this.chainable;
}
_lookupProperty(property) {
// lookup interface
var foundInterface = this.module.getInterface(property);
if (foundInterface instanceof Interface) {
return this._addInterfaceBehavior(foundInterface);
}
// lookup behavior
var behavior = this.module.getBehavior(property);
if (behavior instanceof Behavior) {
return this._addBehavior(behavior);
}
return undefined;
}
_addInterfaceBehavior(interfaceObj) {
var interfaceBehavior = new InterfaceBehavior(this.module, this, interfaceObj);
this.behaviorSequence.push(interfaceBehavior);
return fnRetVal(interfaceBehavior);
}
_addBehavior(behavior) {
this.behaviorSequence.push(behavior);
return fnRetVal(this.chainable);
}
/**
* Returns a stub for this behavior
* @return {Wrapper} The stub for this behavior
*/
getStub() {
var stubObj = {};
function newStub(prop, type) {
if (type === "function") {
let stub = new Wrapper();
stubObj[prop] = stub;
return stub;
} else { // property
stubObj[prop] = undefined;
let stub = new Wrapper(stubObj, prop);
return stub;
}
}
// adds the correct behavior to the stub
function addInterfaceBehaviorToStub(stub, cnt, interfaceBehavior) {
var trigger;
var type = interfaceBehavior.interface.interfaceType;
if (type === "function") {
trigger = stub.triggerOnCallNumber(cnt);
if (interfaceBehavior.hasOwnProperty("args")) {
trigger.expectCallArgs(...interfaceBehavior.args);
}
if (interfaceBehavior.hasOwnProperty("context")) {
trigger.expectCallContext(interfaceBehavior.context);
}
if (interfaceBehavior.hasOwnProperty("retVal")) {
trigger.actionReturn(interfaceBehavior.retVal);
}
if (interfaceBehavior.hasOwnProperty("exception")) {
trigger.actionThrowException(interfaceBehavior.exception);
}
} else { // property
if (interfaceBehavior.hasOwnProperty("retVal")) {
let trigger = stub.triggerOnGetNumber(cnt);
trigger.actionReturn(interfaceBehavior.retVal);
}
if (interfaceBehavior.hasOwnProperty("exception")) {
let trigger = stub.triggerOnTouchNumber(cnt);
trigger.actionThrowException(interfaceBehavior.exception);
}
if (interfaceBehavior.hasOwnProperty("setVal")) {
let trigger = stub.triggerOnSetNumber(cnt);
trigger.actionSetVal(interfaceBehavior.setVal);
}
}
}
this._iterateInterfaceBehaviors(newStub, addInterfaceBehaviorToStub);
return stubObj;
}
/**
* Tests that the module specified by `mod` correctly implements this behavior
* @param {Object} mod The module to be tested
*/
runTest(mod) {
// create sandbox
var sandbox = new Sandbox();
var callList = [];
function newTest(prop) {
if (!mod.hasOwnProperty(prop)) {
throw new Error(`runTest: expected property '${prop}' to exist on module`);
}
var test = sandbox.newWrapper(mod, prop);
test.configSwallowExpectException(true);
return test;
}
function createTest(wrapper, cnt, interfaceBehavior) {
var trigger;
var type = interfaceBehavior.interface.interfaceType;
if (type === "function") {
// add call to list
callList.push(wrapper);
// configure wrapper's expectations
trigger = wrapper.triggerOnCallNumber(cnt);
if (interfaceBehavior.hasOwnProperty("retVal")) {
trigger.expectReturn(interfaceBehavior.retVal);
}
if (interfaceBehavior.hasOwnProperty("exception")) {
trigger.expectException(interfaceBehavior.exception);
}
if (interfaceBehavior.hasOwnProperty("args")) {
trigger.actionCallArgs(...interfaceBehavior.args);
}
if (interfaceBehavior.hasOwnProperty("context")) {
trigger.actionCallContext(interfaceBehavior.context);
}
} else {
if (interfaceBehavior.hasOwnProperty("retVal")) {
trigger = wrapper.triggerOnGetNumber(cnt);
trigger.expectReturn(interfaceBehavior.retVal);
callList.push(wrapper);
}
if (interfaceBehavior.hasOwnProperty("exception")) {
trigger = wrapper.triggerOnTouchNumber(cnt);
trigger.expectException(interfaceBehavior.exception);
callList.push(wrapper);
}
if (interfaceBehavior.hasOwnProperty("setVal")) {
trigger = wrapper.triggerOnSetNumber(cnt);
trigger.actionSetVal(interfaceBehavior.setVal);
callList.push(function() {
wrapper(true);
});
}
}
}
// iterate all behaviors creating wrappers on all tested interfaces
// and adding expected behaviors to each of those wrappers
this._iterateInterfaceBehaviors(newTest, createTest);
// Sandbox.test?
var testBehavior = function() {
// call module N times
for (let i = 0; i < callList.length; i++) {
callList[i]();
}
// unwrap all wrapped methods
sandbox.destroy();
};
return testBehavior;
}
// this iterates all the defined behaviors
// it calls `newFn` to create a new thing (probably a test or stub)
// and it calls `updateFn` to add functionality to that thing
_iterateInterfaceBehaviors(newFn, updateFn) {
var interfaceCountMap = new Map();
var interfaceMap = new Map();
function getOrCreateWrapper(interfaceBehavior) {
var name = interfaceBehavior.interface.interfaceName;
var type = interfaceBehavior.interface.interfaceType;
var interfaceFn = interfaceMap.get(name);
// if it hasn't been created, create it now
if (!interfaceFn) {
interfaceFn = newFn(name, type);
interfaceMap.set(name, interfaceFn);
}
return interfaceFn;
}
function incrementInterfaceCountMap(interfaceBehavior) {
var name = interfaceBehavior.interface.interfaceName;
if (interfaceCountMap.has(name)) {
let count = interfaceCountMap.get(name);
count++;
interfaceCountMap.set(name, count);
return count;
} else {
interfaceCountMap.set(name, 0);
return 0;
}
}
// recursively iterates all behaviors and adds any interface behaviors to the returned array
function gatherInterfaceBehaviors(behavior) {
var ret = [];
for (let i = 0; i < behavior.behaviorSequence.length; i++) {
let currBehavior = behavior.behaviorSequence[i];
if (currBehavior instanceof InterfaceBehavior) {
ret.push(currBehavior);
} else {
ret = ret.concat(gatherInterfaceBehaviors(currBehavior));
}
}
return ret;
}
// collect all the interface behaviors from the abstract behaviors
let interfaceBehaviorList = gatherInterfaceBehaviors(this);
// add all the interface behaviors to the stub
for (let i = 0; i < interfaceBehaviorList.length; i++) {
let behavior = interfaceBehaviorList[i];
let count = incrementInterfaceCountMap(behavior);
let wrapper = getOrCreateWrapper(behavior);
updateFn(wrapper, count, behavior);
}
}
}
/**
* Whereas {@link Behavior Behaviors} describe an abstract sequence of things that are expected to happen
* an `InterfaceBehavior` describes the concrete actions that an {@link Interface} is expected to take.
* The primary inteface to this class is through the {@link Module Module's} {@link Module#defineBehavior defineBehavior}
* method.
*/
class InterfaceBehavior {
constructor(module, behavior, interfaceObj) {
this.module = module;
this.interface = interfaceObj;
}
/**
* The interface returns the value specified by `retVal`
* @param {any} retVal The value to be returned by the interface
* @return {InterfaceBehavior} This interface, for chaining
*/
returns(retVal) {
validateArgsSingle("returns", retVal);
this.retVal = retVal;
return this;
}
/**
* The interface throws the `Error` specified by `err`
* @param {Error} err The error to be thrown
* @return {InterfaceBehavior} This interface, for chaining
*/
throws(err) {
validateArgsSingleExceptionOrNull("throws", err);
this.exception = err;
return this;
}
/**
* The arguments to be sent to the interface, as specified by `args`
* @param {...any} args The arguments to be passed to the interface
* @return {InterfaceBehavior} This interface, for chaining
*/
args(...args) {
validateArgsAny("args", ...args);
this.args = args;
return this;
}
/**
* The context (`this` value) to be passed to the interface. Only applicable
* to method / function interfaces.
* @param {any} ctx The value to set the context of the function to
* @return {InterfaceBehavior} This interface, for chaining
*/
context(ctx) {
validateArgsSingle("context", ctx);
this.context = ctx;
return this;
}
/**
* The value to be set on the property, as specified by `val`
* @param {any} val The value to set for the property
* @return {InterfaceBehavior} This interface, for chaining
*/
set(val) {
validateArgsSingle("set", val);
this.setVal = val;
return this;
}
}
// Just return a value to define the module export.
// This example returns an object, but the module
// can return a function as the exported value.
return {
Wrapper: Wrapper,
Operation: Operation,
Match: Match,
Trigger: Trigger,
ExpectError: ExpectError,
Sandbox: Sandbox,
Filter: Filter,
Module: Module,
Interface: Interface,
Behavior: Behavior,
InterfaceBehavior: InterfaceBehavior
};
}));
/* JSHINT */
/* globals define */