diff --git a/lib/spy.js b/lib/spy.js index 3bd26b1..36621a1 100644 --- a/lib/spy.js +++ b/lib/spy.js @@ -13,6 +13,165 @@ module.exports = function (chai, _) { var Assertion = chai.Assertion , flag = _.flag , i = _.inspect + , ID_KEY = typeof Symbol === 'undefined' ? '__id' : Symbol('id') + , STATE_KEY = typeof Symbol === 'undefined' ? '__state' : Symbol('state') + , spyAmount = 0 + , DEFAULT_SANDBOX = new Sandbox() + + /** + * # Sandbox constructor (function) + * + * Initialize new Sandbox instance + * + * @returns new sandbox + * @api private + */ + + function Sandbox() { + this[STATE_KEY] = {}; + } + + /** + * # Sandbox.on (function) + * + * Wraps an object method into spy assigned to sandbox. All calls will + * pass through to the original function. + * + * var spy = chai.spy.sandbox(); + * var isArray = spy.on(Array, 'isArray'); + * + * @param {Object} object + * @param {String} method name to spy on + * @returns created spy + * @api public + */ + + Sandbox.prototype.on = function (object, methodName) { + var method = chai.spy('object.' + methodName, object[methodName]); + + method[ID_KEY] = ++spyAmount; + this[STATE_KEY][method[ID_KEY]] = method; + method.__spy.tracked = { + object: object + , methodName: methodName + , originalMethod: object[methodName] + , isOwnMethod: object.hasOwnProperty(methodName) + }; + object[methodName] = method; + + return method; + }; + + /** + * # Sandbox.object (function) + * + * Creates an object with spied methods if first argument is a String. + * Wraps passed object's methods with spies if first argument is an Object. + * In case, if first parameter is an object and second is omit wraps + * all enumerable methods of passed object. + * + * var spy = chai.spy.sandbox(); + * var object = spy.object('Array', [ 'push', 'pop' ]); + * var array = spy.object([], [ 'push', 'pop' ]); + * + * @param {String|Object} name object or object name + * @param {String[]} [methods] method names + * @returns object with spied methods + * @api public + */ + + Sandbox.prototype.object = function (name, methods) { + var defs = {}; + var sandbox = this; + var object; + + if (name && typeof name === 'object') { + object = name; + methods = methods || Object.keys(object); + methods.forEach(function (methodName) { + var method = object[methodName]; + + if (typeof method === 'function' && !method.__spy) { + sandbox.on(object, methodName); + } + }) + } else { + object = methods.reduce(function (object, methodName) { + object[methodName] = chai.spy(name + '.' + methodName); + return object; + }, {}); + } + + return object; + }; + + /** + * # Sandbox.restore (function) + * + * Restores previously wrapped object's method. + * Restores all spied objects of a sandbox if called without parameters. + * + * var spy = chai.spy.sandbox(); + * var object = spy.on(Array, 'isArray'); + * spy.restore(Array, 'isArray'); // or spy.restore(); + * + * @param {Object} [object] + * @param {String} [methodName] method name + * @api public + */ + + Sandbox.prototype.restore = function (object, methodName) { + var hasFilter = Boolean(object && methodName); + var sandbox = this; + + Object.keys(this[STATE_KEY]).some(function (spyId) { + var spy = sandbox[STATE_KEY][spyId]; + var tracked = spy.__spy.tracked; + var isObjectSpied = !object || object === tracked.object; + var isMethodSpied = !methodName || methodName === tracked.methodName; + + delete sandbox[STATE_KEY][spyId]; + + if (!isObjectSpied && !isMethodSpied) { + return false; + } + + sandbox.restoreTrackedObject(spy); + + if (hasFilter) { + return true; + } + }); + }; + + /** + * # Sandbox.restoreTrackedObject (function) + * + * Restores tracked object's method + * + * var spy = chai.spy.sandbox(); + * var isArray = spy.on(Array, 'isArray'); + * spy.restoreTrackedObject(isArray); + * + * @param {Spy} spy + * @api private + */ + + Sandbox.prototype.restoreTrackedObject = function (spy) { + var tracked = spy.__spy.tracked; + + if (!tracked) { + return; + } + + if (tracked.isOwnMethod) { + tracked.object[tracked.methodName] = tracked.originalMethod; + } else { + delete tracked.object[tracked.methodName]; + } + + spy.__spy.tracked = null; + }; /** * # chai.spy (function) @@ -62,10 +221,11 @@ module.exports = function (chai, _) { proxy.prototype = fn.prototype; proxy.toString = function toString() { - var l = this.__spy.calls.length; + var state = this.__spy; + var l = state.calls.length; var s = "{ Spy"; - if (this.__spy.name) - s += " '" + this.__spy.name + "'"; + if (state.name) + s += " '" + state.name + "'"; if (l > 0) s += ", " + l + " call" + (l > 1 ? 's' : ''); s += " }"; @@ -75,7 +235,7 @@ module.exports = function (chai, _) { /** * # proxy.reset (function) * - * Resets __spy object parameters for instantiation and reuse + * Resets spy's state object parameters for instantiation and reuse * @returns proxy spy object */ proxy.reset = function() { @@ -91,59 +251,76 @@ module.exports = function (chai, _) { } /** - * # chai.spy.on (function) + * # chai.spy.sandbox (function) * - * Wraps an object method into spy. All calls will - * pass through to the original function. + * Creates sandbox which allow to restore spied objects with spy.on. + * All calls will pass through to the original function. * - * var spy = chai.spy.on(Array, 'isArray'); + * var spy = chai.spy.sandbox(); + * var isArray = spy.on(Array, 'isArray'); * * @param {Object} object - * @param {...String} method names to spy on + * @param {String} method name to spy on * @returns passed object * @api public */ - chai.spy.on = function (object) { - var methodNames = Array.prototype.slice.call(arguments, 1); + chai.spy.sandbox = function () { + return new Sandbox() + }; - methodNames.forEach(function(methodName) { - object[methodName] = chai.spy(object[methodName]); - }); + /** + * # chai.spy.on (function) + * + * The same as Sandbox.on. + * Assignes newly created spy to DEFAULT sandbox + * + * var isArray = chai.spy.on(Array, 'isArray'); + * + * @see Sandbox.on + * @api public + */ - return object; + chai.spy.on = function () { + return DEFAULT_SANDBOX.on.apply(DEFAULT_SANDBOX, arguments) }; /** * # chai.spy.object (function) * - * Creates an object with spied methods. + * The same as Sandbox.object. + * Assignes newly created spy to DEFAULT sandbox * * var object = chai.spy.object('Array', [ 'push', 'pop' ]); + * var array = chai.spy.object([], [ 'push', 'pop' ]); * - * @param {String} [name] object name - * @param {String[]|Object} method names or method definitions - * @returns object with spied methods + * @see Sandbox.object * @api public */ - chai.spy.object = function (name, methods) { - var defs = {}; - - if (name && typeof name === 'object') { - methods = name; - name = 'object'; - } + chai.spy.object = function () { + return DEFAULT_SANDBOX.object.apply(DEFAULT_SANDBOX, arguments) + }; - if (methods && !Array.isArray(methods)) { - defs = methods; - methods = Object.keys(methods); - } + /** + * # chai.spy.restore (function) + * + * The same as Sandbox.restore. + * Restores spy assigned to DEFAULT sandbox + * + * var array = [] + * chai.spy.on(array, 'push'); + * expect(array.push).to.be.spy // true + * + * chai.spy.restore() + * expect(array.push).to.be.spy // false + * + * @see Sandbox.restore + * @api public + */ - return methods.reduce(function (object, methodName) { - object[methodName] = chai.spy(name + '.' + methodName, defs[methodName]); - return object; - }, {}); + chai.spy.restore = function () { + return DEFAULT_SANDBOX.restore.apply(DEFAULT_SANDBOX, arguments) }; /** diff --git a/test/spies.js b/test/spies.js index c9de8a2..35c718e 100644 --- a/test/spies.js +++ b/test/spies.js @@ -209,7 +209,8 @@ describe('Chai Spies', function () { }); it('should spy specified object method', function () { - var array = chai.spy.on([], 'push'); + var array = [] + chai.spy.on(array, 'push'); array.push(1, 2); array.push.should.be.a.spy; @@ -225,7 +226,7 @@ describe('Chai Spies', function () { }); it('should spy multiple object methods passed as array', function () { - var array = chai.spy.on([], 'push', 'pop'); + var array = chai.spy.object([], ['push', 'pop']); array.push.should.be.a.spy; array.pop.should.be.a.spy; @@ -424,21 +425,18 @@ describe('Chai Spies', function () { object.pop.should.be.a.spy; }); - it('should create an anonymous spy object', function () { - var object = chai.spy.object([ 'push' ]); - - object.push.should.be.a.spy; - }); - - it('should create a spy object with specified method definitions', function () { - var object = chai.spy.object('array', { - push: function () { - return 'push'; + it('should wrap each method in spy', function () { + var array = [] + var object = chai.spy.object({ + push: function() { + return array.push.apply(array, arguments) } }); + object.push(1, 2, 3) + object.push.should.be.a.spy; - object.push().should.equal('push'); + array.should.have.length(3) }); it('should create an anonymous spy object with methods implementation', function () { @@ -453,7 +451,7 @@ describe('Chai Spies', function () { }); }); - describe('reset method', function() { + describe('reset method', function () { it('should reset spy object values to defaults when called', function() { var name = 'proxy'; var spy = chai.spy(name); @@ -482,4 +480,39 @@ describe('Chai Spies', function () { spy.__spy.name.should.be.equal(name); }); }); + + describe('spy restore', function () { + var array; + + beforeEach(function () { + array = []; + chai.spy.on(array, 'push'); + }) + + it('should restore all methods of tracked objects', function () { + chai.spy.restore(); + + array.push.should.not.be.spy; + }); + + it('should restore all methods on an object', function () { + chai.spy.on(array, 'pop'); + chai.spy.restore(array); + + array.push.should.not.be.spy; + array.pop.should.not.be.spy; + }); + + it('should restore a particular method on an particular object', function () { + chai.spy.restore(array, 'push'); + + array.push.should.not.be.spy; + }); + + it('should not throw if there are not tracked objects', function () { + chai.spy.restore(); + + chai.spy.restore.should.not.throw(Error); + }); + }); });