From b96abc99cb241f8f5805ed2c2e2966e33bba7a2d Mon Sep 17 00:00:00 2001 From: Christopher Hiller Date: Wed, 30 May 2018 13:02:40 -0700 Subject: [PATCH] initial implementation of "functional" interface --- lib/behavior.js | 170 +++++++++++++++++++++++++++++++++++ lib/interfaces/common.js | 7 +- lib/interfaces/func.js | 24 +++++ lib/interfaces/index.js | 1 + lib/runnable.js | 140 +++++++++++++---------------- lib/suite.js | 86 ++++++++++++++++-- package.json | 3 +- test/interfaces/func.spec.js | 43 +++++++++ test/unit/runnable.spec.js | 27 ++---- 9 files changed, 390 insertions(+), 111 deletions(-) create mode 100644 lib/behavior.js create mode 100644 lib/interfaces/func.js create mode 100644 test/interfaces/func.spec.js diff --git a/lib/behavior.js b/lib/behavior.js new file mode 100644 index 0000000000..b772f223a0 --- /dev/null +++ b/lib/behavior.js @@ -0,0 +1,170 @@ +'use strict'; + +/** + * Built-in behaviors. + * These provide hooks into modifying Mocha's behavior for different use cases. + */ + +var utils = require('./utils'); + +/** + * Default Mocha behavior + */ +exports.Default = { + Suite: { + /** + * Runs the suite; called by `Suite.create()`. Calls its callback fn + * with the Suite as its context. + * @this {Suite} + */ + run: function() { + // FUTURE: async suites + this.fn.apply(this); + } + }, + Runnable: { + /** + * Determines whether or not we should provide a nodeback parameter and + * expect it to be called + * @this {Runnable} + * @returns {boolean} `true` if we should run the test fn with a callback + */ + shouldRunWithCallback: function() { + return Boolean(this.fn && this.fn.length); + }, + /** + * Runs the Runnable synchronously, or potentially returning a Promise + * @param {Function} done - Callback + */ + run: function(done) { + var result = this.fn.call(this.ctx); + if (result && typeof result.then === 'function') { + this.resetTimeout(); + result.then( + function() { + done(); + // Return null so libraries like bluebird do not warn about + // subsequently constructed Promises. + return null; + }, + function(reason) { + done( + reason || new Error('Promise rejected with no or falsy reason') + ); + } + ); + } else { + if (this.asyncOnly) { + return done( + new Error( + '--async-only option in use without declaring `done()` or returning a promise' + ) + ); + } + done(); + } + }, + /** + * Runs the Runnable, passing a nodeback function, which must be called to + * complete the test + * @param {Function} done - Callback + */ + runWithCallback: function(done) { + var result = this.fn.call(this.ctx, function(err) { + if ( + err instanceof Error || + Object.prototype.toString.call(err) === '[object Error]' + ) { + return done(err); + } + if (err) { + if (Object.prototype.toString.call(err) === '[object Object]') { + return done( + new Error('done() invoked with non-Error: ' + JSON.stringify(err)) + ); + } + return done(new Error('done() invoked with non-Error: ' + err)); + } + if (result && utils.isPromise(result)) { + return done( + new Error( + 'Resolution method is overspecified. Specify a callback *or* return a Promise; not both.' + ) + ); + } + done(); + }); + } + } +}; + +/** + * Provides a test context in a functional manner for use with + * lambdas/arrow functions. + * All Runnables must either return a promise or call `runnable.done()`, where + * `runnable` is the first parameter to the Runnable's callback (`fn`) + */ +exports.Functional = { + Suite: { + /** + * Runs the Suite. Calls its `fn` with NO context and the suite itself + * as the first parameter. + * @this {Suite} + */ + run: function(opts) { + this.fn.call(null, this); + } + }, + Runnable: { + /** + * Determines whether or not we should provide a nodeback parameter and + * expect it to be called; always false + * @this {Runnable} + * @returns false + */ + shouldRunWithCallback: function() { + return false; + }, + + /** + * Runs the Runnable expecting a call to ctx.done() or a Promise + * @param {Function} done - Callback + */ + run: function(done) { + this.ctx.done = function(err) { + if ( + err instanceof Error || + Object.prototype.toString.call(err) === '[object Error]' + ) { + return done(err); + } + if (err) { + if (Object.prototype.toString.call(err) === '[object Object]') { + return done( + new Error('done() invoked with non-Error: ' + JSON.stringify(err)) + ); + } + return done(new Error('done() invoked with non-Error: ' + err)); + } + done(); + }; + var result = this.fn.call(null, this.ctx); + if (result && typeof result.then === 'function') { + this.resetTimeout(); + result.then( + function() { + done(); + // Return null so libraries like bluebird do not warn about + // subsequently constructed Promises. + return null; + }, + function(reason) { + done( + reason || new Error('Promise rejected with no or falsy reason') + ); + } + ); + } + } + } +}; diff --git a/lib/interfaces/common.js b/lib/interfaces/common.js index 4ca340a608..5726988234 100644 --- a/lib/interfaces/common.js +++ b/lib/interfaces/common.js @@ -21,7 +21,7 @@ module.exports = function(suites, context, mocha) { */ runWithSuite: function runWithSuite(suite) { return function run() { - suite.run(); + suite.runIfRoot(); }; }, @@ -104,12 +104,13 @@ module.exports = function(suites, context, mocha) { var suite = Suite.create(suites[0], opts.title); suite.pending = Boolean(opts.pending); suite.file = opts.file; + suite.fn = opts.fn; suites.unshift(suite); if (opts.isOnly) { - suite.parent._onlySuites = suite.parent._onlySuites.concat(suite); + suite.appendExclusiveToParent(); } if (typeof opts.fn === 'function') { - opts.fn.call(suite); + suite.callBehavior('run', opts); suites.shift(); } else if (typeof opts.fn === 'undefined' && !suite.pending) { throw new Error( diff --git a/lib/interfaces/func.js b/lib/interfaces/func.js new file mode 100644 index 0000000000..37d4dd538f --- /dev/null +++ b/lib/interfaces/func.js @@ -0,0 +1,24 @@ +'use strict'; + +var bddInterface = require('./bdd'); +var behavior = require('../behavior'); + +/** + * Functional BDD-style interface: + * + * describe('Array', () => { + * describe('#indexOf()', () => { + * it('should return -1 when not present', () => { + * // ... + * }); + * it('passes ctx as param', test => {}) + * it('uses async as second param , ) + * }); + * }); + * + * @param {Suite} suite Root suite. + */ +module.exports = function funcInterface(suite) { + suite.behavior(behavior.Functional); + bddInterface(suite); +}; diff --git a/lib/interfaces/index.js b/lib/interfaces/index.js index 0bd810abb7..8c71e48615 100644 --- a/lib/interfaces/index.js +++ b/lib/interfaces/index.js @@ -4,3 +4,4 @@ exports.bdd = require('./bdd'); exports.tdd = require('./tdd'); exports.qunit = require('./qunit'); exports.exports = require('./exports'); +exports.func = require('./func'); diff --git a/lib/runnable.js b/lib/runnable.js index 73da817793..a1ef47f859 100644 --- a/lib/runnable.js +++ b/lib/runnable.js @@ -4,6 +4,7 @@ var Pending = require('./pending'); var debug = require('debug')('mocha:runnable'); var milliseconds = require('./ms'); var utils = require('./utils'); +var behavior = require('./behavior'); /** * Save timer references to avoid Sinon interfering (see GH-237). @@ -17,8 +18,6 @@ var clearTimeout = global.clearTimeout; var clearInterval = global.clearInterval; /* eslint-enable no-unused-vars, no-native-reassign */ -var toString = Object.prototype.toString; - module.exports = Runnable; /** @@ -33,8 +32,6 @@ function Runnable(title, fn) { this.title = title; this.fn = fn; this.body = (fn || '').toString(); - this.async = fn && fn.length; - this.sync = !this.async; this._timeout = 2000; this._slow = 75; this._enableTimeouts = true; @@ -42,6 +39,7 @@ function Runnable(title, fn) { this._retries = -1; this._currentRetry = 0; this.pending = false; + this._behavior = behavior.Default; } /** @@ -52,7 +50,7 @@ utils.inherits(Runnable, EventEmitter); /** * Set & get timeout `ms`. * - * @api private + * @private * @param {number|string} ms * @return {Runnable|number} ms or Runnable instance. */ @@ -78,7 +76,7 @@ Runnable.prototype.timeout = function(ms) { /** * Set or get slow `ms`. * - * @api private + * @private * @param {number|string} ms * @return {Runnable|number} ms or Runnable instance. */ @@ -97,7 +95,7 @@ Runnable.prototype.slow = function(ms) { /** * Set and get whether timeout is `enabled`. * - * @api private + * @private * @param {boolean} enabled * @return {Runnable|boolean} enabled or Runnable instance. */ @@ -115,7 +113,6 @@ Runnable.prototype.enableTimeouts = function(enabled) { * * @memberof Mocha.Runnable * @public - * @api public */ Runnable.prototype.skip = function() { throw new Pending('sync skip'); @@ -124,7 +121,7 @@ Runnable.prototype.skip = function() { /** * Check if this runnable or its parent suite is marked as pending. * - * @api private + * @private */ Runnable.prototype.isPending = function() { return this.pending || (this.parent && this.parent.isPending()); @@ -151,7 +148,7 @@ Runnable.prototype.isPassed = function() { /** * Set or get number of retries. * - * @api private + * @private */ Runnable.prototype.retries = function(n) { if (!arguments.length) { @@ -163,7 +160,7 @@ Runnable.prototype.retries = function(n) { /** * Set or get current retry * - * @api private + * @private */ Runnable.prototype.currentRetry = function(n) { if (!arguments.length) { @@ -178,7 +175,6 @@ Runnable.prototype.currentRetry = function(n) { * * @memberof Mocha.Runnable * @public - * @api public * @return {string} */ Runnable.prototype.fullTitle = function() { @@ -190,7 +186,6 @@ Runnable.prototype.fullTitle = function() { * * @memberof Mocha.Runnable * @public - * @api public * @return {string} */ Runnable.prototype.titlePath = function() { @@ -200,7 +195,7 @@ Runnable.prototype.titlePath = function() { /** * Clear the timeout. * - * @api private + * @private */ Runnable.prototype.clearTimeout = function() { clearTimeout(this.timer); @@ -209,7 +204,7 @@ Runnable.prototype.clearTimeout = function() { /** * Inspect the runnable void of private properties. * - * @api private + * @private * @return {string} */ Runnable.prototype.inspect = function() { @@ -234,7 +229,7 @@ Runnable.prototype.inspect = function() { /** * Reset the timeout. * - * @api private + * @private */ Runnable.prototype.resetTimeout = function() { var self = this; @@ -256,7 +251,7 @@ Runnable.prototype.resetTimeout = function() { /** * Set or get a list of whitelisted globals for this test run. * - * @api private + * @private * @param {string[]} globals */ Runnable.prototype.globals = function(globals) { @@ -270,7 +265,7 @@ Runnable.prototype.globals = function(globals) { * Run the test and invoke `fn(err)`. * * @param {Function} fn - * @api private + * @private */ Runnable.prototype.run = function(fn) { var self = this; @@ -322,8 +317,7 @@ Runnable.prototype.run = function(fn) { // for .resetTimeout() this.callback = done; - // explicit async with `done` argument - if (this.async) { + if (this.callBehavior('shouldRunWithCallback')) { this.resetTimeout(); // allows skip() to be used in an explicit async context @@ -336,10 +330,10 @@ Runnable.prototype.run = function(fn) { }; if (this.allowUncaught) { - return callFnAsync(this.fn); + return this.callBehavior('runWithCallback', done); } try { - callFnAsync(this.fn); + this.callBehavior('runWithCallback', done); } catch (err) { emitted = true; done(utils.getError(err)); @@ -351,7 +345,7 @@ Runnable.prototype.run = function(fn) { if (this.isPending()) { done(); } else { - callFn(this.fn); + this.callBehavior('run', done); } return; } @@ -361,65 +355,12 @@ Runnable.prototype.run = function(fn) { if (this.isPending()) { done(); } else { - callFn(this.fn); + this.callBehavior('run', done); } } catch (err) { emitted = true; done(utils.getError(err)); } - - function callFn(fn) { - var result = fn.call(ctx); - if (result && typeof result.then === 'function') { - self.resetTimeout(); - result.then( - function() { - done(); - // Return null so libraries like bluebird do not warn about - // subsequently constructed Promises. - return null; - }, - function(reason) { - done(reason || new Error('Promise rejected with no or falsy reason')); - } - ); - } else { - if (self.asyncOnly) { - return done( - new Error( - '--async-only option in use without declaring `done()` or returning a promise' - ) - ); - } - - done(); - } - } - - function callFnAsync(fn) { - var result = fn.call(ctx, function(err) { - if (err instanceof Error || toString.call(err) === '[object Error]') { - return done(err); - } - if (err) { - if (Object.prototype.toString.call(err) === '[object Object]') { - return done( - new Error('done() invoked with non-Error: ' + JSON.stringify(err)) - ); - } - return done(new Error('done() invoked with non-Error: ' + err)); - } - if (result && utils.isPromise(result)) { - return done( - new Error( - 'Resolution method is overspecified. Specify a callback *or* return a Promise; not both.' - ) - ); - } - - done(); - }); - } }; /** @@ -439,3 +380,48 @@ Runnable.prototype._timeoutError = function(ms) { } return new Error(msg); }; + +/** + * Mixes in a Behavior to this Runnable or returns current. + * @param {Object} [behavior] - Behavior mixin. If unset, returns current Behavior + * @public + * @returns {Object|Runnable} + */ +Runnable.prototype.behavior = function(behavior) { + if (arguments.length) { + var currentBehavior = Object.assign({}, this._behavior); + Object.keys(behavior).forEach(function(behaviorName) { + currentBehavior[behaviorName] = Object.assign( + {}, + currentBehavior[behaviorName] || {}, + behavior[behaviorName] + ); + }); + this._behavior = currentBehavior; + return this; + } + return this._behavior; +}; + +/** + * Calls a behavior method by name with array args, `Function.prototype.apply` style + * @param {string} name - Behavior method name + * @param {*[]} [args] - Array of arguments + * @private + * @returns {*} Whatever the behavior method returns + */ +Runnable.prototype.applyBehavior = function(name, args) { + return this.behavior().Runnable[name].apply(this, args || []); +}; + +/** + * Calls a behavior method by name with positional args, `Function.prototype.call` style + * @param {string} name - Behavior method name + * @param {...*} [arg] - Arguments + * @private + * @returns {*} Whatever the behavior method returns + */ +Runnable.prototype.callBehavior = function(name) { + var args = Array.prototype.slice.call(arguments); + return this.behavior().Runnable[name].apply(this, args.slice(1)); +}; diff --git a/lib/suite.js b/lib/suite.js index 91832ba3ad..1c20cb8b28 100644 --- a/lib/suite.js +++ b/lib/suite.js @@ -12,6 +12,7 @@ var utils = require('./utils'); var inherits = utils.inherits; var debug = require('debug')('mocha:suite'); var milliseconds = require('./ms'); +var behavior = require('./behavior'); /** * Expose `Suite`. @@ -26,7 +27,6 @@ exports = module.exports = Suite; * * @memberof Mocha * @public - * @api public * @param {Suite} parent * @param {string} title * @return {Suite} @@ -34,7 +34,6 @@ exports = module.exports = Suite; exports.create = function(parent, title) { var suite = new Suite(title, parent.ctx); suite.parent = parent; - title = suite.fullTitle(); parent.addSuite(suite); return suite; }; @@ -76,6 +75,7 @@ function Suite(title, parentContext) { this._onlyTests = []; this._onlySuites = []; this.delayed = false; + this._behavior = behavior.Default; } /** @@ -97,6 +97,7 @@ Suite.prototype.clone = function() { suite.retries(this.retries()); suite.enableTimeouts(this.enableTimeouts()); suite.slow(this.slow()); + suite.behavior(this.behavior()); suite.bail(this.bail()); return suite; }; @@ -211,6 +212,7 @@ Suite.prototype._createHook = function(title, fn) { hook.parent = this; hook.timeout(this.timeout()); hook.retries(this.retries()); + hook.behavior(this.behavior()); hook.enableTimeouts(this.enableTimeouts()); hook.slow(this.slow()); hook.ctx = this.ctx; @@ -328,6 +330,7 @@ Suite.prototype.addSuite = function(suite) { suite.enableTimeouts(this.enableTimeouts()); suite.slow(this.slow()); suite.bail(this.bail()); + suite.behavior(this.behavior()); this.suites.push(suite); this.emit('suite', suite); return this; @@ -344,6 +347,7 @@ Suite.prototype.addTest = function(test) { test.parent = this; test.timeout(this.timeout()); test.retries(this.retries()); + test.behavior(this.behavior()); test.enableTimeouts(this.enableTimeouts()); test.slow(this.slow()); test.ctx = this.ctx; @@ -358,7 +362,6 @@ Suite.prototype.addTest = function(test) { * * @memberof Mocha.Suite * @public - * @api public * @return {string} */ Suite.prototype.fullTitle = function() { @@ -371,7 +374,6 @@ Suite.prototype.fullTitle = function() { * * @memberof Mocha.Suite * @public - * @api public * @return {string} */ Suite.prototype.titlePath = function() { @@ -390,7 +392,6 @@ Suite.prototype.titlePath = function() { * * @memberof Mocha.Suite * @public - * @api public * @return {number} */ Suite.prototype.total = function() { @@ -405,7 +406,7 @@ Suite.prototype.total = function() { * Iterates through each suite recursively to find all tests. Applies a * function in the format `fn(test)`. * - * @api private + * @private * @param {Function} fn * @return {Suite} */ @@ -418,10 +419,79 @@ Suite.prototype.eachTest = function(fn) { }; /** - * This will run the root suite if we happen to be running in delayed mode. + * If this Suite is the root suite, emit 'run' event. + * Used by `--delay` + * @private */ -Suite.prototype.run = function run() { +Suite.prototype.runIfRoot = function run() { if (this.root) { this.emit('run'); } }; + +/** + * Mixes in a Behavior to this Suite or returns current. + * @param {Object} [behavior] - Behavior mixin. If unset, returns current Behavior + * @public + * @returns {Object|Suite} + */ +Suite.prototype.behavior = function(behavior) { + if (arguments.length) { + var currentBehavior = Object.assign({}, this._behavior); + Object.keys(behavior).forEach(function(behaviorName) { + currentBehavior[behaviorName] = Object.assign( + {}, + currentBehavior[behaviorName] || {}, + behavior[behaviorName] + ); + }); + this._behavior = currentBehavior; + return this; + } + return this._behavior; +}; + +/** + * Appends a child Suite to this Suite's list of exclusive child suites + * (i.e. "only"). + * @param {Suite} suite Child suite + * @private + * @returns {Suite} + */ +Suite.prototype.appendExclusiveChild = function(suite) { + this._onlySuites = this._onlySuites.concat(suite); + return this; +}; + +/** + * Appends this Suite to its parent's list of exclusive child suites + * @private + * @returns {Suite} + */ +Suite.prototype.appendExclusiveToParent = function() { + this.parent.appendExclusiveChild(this); + return this; +}; + +/** + * Calls a behavior method by name with array args, `Function.prototype.apply` style + * @param {string} name - Behavior method name + * @param {*[]} [args] - Array of arguments + * @private + * @returns {*} Whatever the behavior method returns + */ +Suite.prototype.applyBehavior = function(name, args) { + return this.behavior().Suite[name].apply(this, args || []); +}; + +/** + * Calls a behavior method by name with positional args, `Function.prototype.call` style + * @param {string} name - Behavior method name + * @param {...*} [arg] - Arguments + * @private + * @returns {*} Whatever the behavior method returns + */ +Suite.prototype.callBehavior = function(name) { + var args = Array.prototype.slice.call(arguments); + return this.behavior().Suite[name].apply(this, args.slice(1)); +}; diff --git a/package.json b/package.json index 4ca1c98041..64194644e4 100644 --- a/package.json +++ b/package.json @@ -480,6 +480,7 @@ "coffee-script": "^1.10.0", "coveralls": "^3.0.1", "cross-spawn": "^6.0.5", + "es6-promise": "^4.2.4", "eslint": "^4.19.1", "eslint-config-prettier": "^2.9.0", "eslint-config-semistandard": "^12.0.1", @@ -533,4 +534,4 @@ "singleQuote": true, "bracketSpacing": false } -} \ No newline at end of file +} diff --git a/test/interfaces/func.spec.js b/test/interfaces/func.spec.js new file mode 100644 index 0000000000..1c18418496 --- /dev/null +++ b/test/interfaces/func.spec.js @@ -0,0 +1,43 @@ +'use strict'; + +var Promise = require('es6-promise'); + +describe('behavior', function(suite) { + suite.timeout(200); + + it('should work synchronously', function(test) { + expect(1 + 1, 'to be', 2); + expect(2 + 2, 'to be', 4); + test.done(); + }); + + it('should work asynchronously', function(test) { + expect(1 - 1, 'to be', 0); + expect(2 - 1, 'to be', 1); + process.nextTick(function() { + test.done(); + }); + }); + + it('should work with a Promise', function() { + expect(1 - 1, 'to be', 0); + expect(2 - 1, 'to be', 1); + return Promise.resolve(); + }); + + it('should work with context methods', function(test) { + expect(1 - 1, 'to be', 0); + expect(2 - 1, 'to be', 1); + test.timeout(400); + return Promise.resolve(); + }); + + afterEach(function(hook) { + hook.timeout(200); + hook.done(); + }); + + afterEach(function() { + return Promise.resolve(); + }); +}); diff --git a/test/unit/runnable.spec.js b/test/unit/runnable.spec.js index 9ea00b6d07..0d21e15569 100644 --- a/test/unit/runnable.spec.js +++ b/test/unit/runnable.spec.js @@ -108,29 +108,12 @@ describe('Runnable(title, fn)', function() { }); }); - describe('when arity >= 1', function() { - it('should be .async', function() { - var run = new Runnable('foo', function(done) {}); - assert(run.async === 1); - assert(run.sync === false); - }); - }); - - describe('when arity == 0', function() { - it('should be .sync', function() { - var run = new Runnable('foo', function() {}); - assert(run.async === 0); - assert(run.sync === true); - }); - }); - describe('#globals', function() { - it('should allow for whitelisting globals', function(done) { - var test = new Runnable('foo', function() {}); - assert(test.async === 0); - assert(test.sync === true); - test.globals(['foobar']); - test.run(done); + it('should allow for whitelisting globals', function() { + var globals = ['foobar']; + var runnable = new Runnable('foo', function() {}); + runnable.globals(globals); + assert(runnable.globals() === globals); }); });