From 0cdb9786feae1a12e1129f639299d5413336d46b Mon Sep 17 00:00:00 2001 From: Christopher Hiller Date: Tue, 21 Apr 2020 12:44:24 -0700 Subject: [PATCH] add Root Hook Plugins (documentation will be in another PR) Adds "root hook plugins", a system to define root hooks via files loaded with `--require`. This enables root hooks to work in parallel mode. Because parallel mode runs files in a non-deterministic order, and files do not share a `Mocha` instance, it is not possible to share these hooks with other test files. This change also works well with third-party libraries for Mocha which need the behavior; these can now be trivially consumed by adding `--require` or `require: 'some-library'` in Mocha's config file. The way it works is: 1. When a file is loaded via `--require`, we check to see if that file exports a property named `mochaHooks` (can be multiple files). 1. If it does, we save a reference to the property. 1. After Yargs' validation phase, we use async middleware to execute root hook plugin functions--or if they are objects, just collect them--and we flatten all hooks found into four buckets corresponding to the four hook types. 1. Once `Mocha` is instantiated, if it is given a `rootHooks` option, those hooks are applied to the root suite. This works with parallel tests because we can save a reference to the flattened hooks in each worker process, and a new `Mocha` instance is created with them for each test file. * * * Tangential: - Because a root hook plugin can be defined as an `async` function, I noticed that `utils.type()` does not return `function` for async functions; it returns `asyncfunction`. I've added a (Node-specific, for now) test for this. - `handleRequires` is now `async`, since it will need to be anyway to support ESM and calls to `import()`. - fixed incorrect call to `fs.existsSync()` Ref: #4198 --- lib/cli/run-helpers.js | 60 +++++++-- lib/cli/run.js | 10 +- lib/mocha.js | 49 +++++++ lib/utils.js | 5 +- .../require/root-hook-defs-a.fixture.js | 16 +++ .../require/root-hook-defs-b.fixture.js | 36 +++++ .../require/root-hook-defs-c.fixture.js | 16 +++ .../require/root-hook-defs-d.fixture.js | 36 +++++ .../require/root-hook-test-2.fixture.js | 6 + .../options/require/root-hook-test.fixture.js | 6 + test/integration/options/require.spec.js | 125 ++++++++++++++++++ test/node-unit/cli/run-helpers.spec.js | 67 +++++++++- test/node-unit/utils.spec.js | 10 ++ test/unit/mocha.spec.js | 43 +++++- test/unit/utils.spec.js | 44 ++++-- 15 files changed, 500 insertions(+), 29 deletions(-) create mode 100644 test/integration/fixtures/options/require/root-hook-defs-a.fixture.js create mode 100644 test/integration/fixtures/options/require/root-hook-defs-b.fixture.js create mode 100644 test/integration/fixtures/options/require/root-hook-defs-c.fixture.js create mode 100644 test/integration/fixtures/options/require/root-hook-defs-d.fixture.js create mode 100644 test/integration/fixtures/options/require/root-hook-test-2.fixture.js create mode 100644 test/integration/fixtures/options/require/root-hook-test.fixture.js create mode 100644 test/integration/options/require.spec.js diff --git a/lib/cli/run-helpers.js b/lib/cli/run-helpers.js index ebb0bdd071..172fae654a 100644 --- a/lib/cli/run-helpers.js +++ b/lib/cli/run-helpers.js @@ -12,10 +12,9 @@ const path = require('path'); const debug = require('debug')('mocha:cli:run:helpers'); const watchRun = require('./watch-run'); const collectFiles = require('./collect-files'); +const {type} = require('../utils'); const {format} = require('util'); - -const cwd = (exports.cwd = process.cwd()); -const {createInvalidPluginError} = require('../errors'); +const {createInvalidPluginError, createUnsupportedError} = require('../errors'); /** * Exits Mocha when tests + code under test has finished execution (default) @@ -75,20 +74,60 @@ exports.list = str => Array.isArray(str) ? exports.list(str.join(',')) : str.split(/ *, */); /** - * `require()` the modules as required by `--require ` + * `require()` the modules as required by `--require `. + * + * Returns array of `mochaHooks` exports, if any. * @param {string[]} requires - Modules to require + * @returns {Promise} Any root hooks * @private */ -exports.handleRequires = (requires = []) => { - requires.forEach(mod => { +exports.handleRequires = async (requires = []) => + requires.reduce((acc, mod) => { let modpath = mod; - if (fs.existsSync(mod, {cwd}) || fs.existsSync(`${mod}.js`, {cwd})) { + // this is relative to cwd + if (fs.existsSync(mod) || fs.existsSync(`${mod}.js`)) { modpath = path.resolve(mod); - debug('resolved %s to %s', mod, modpath); + debug('resolved required file %s to %s', mod, modpath); + } + const requiredModule = require(modpath); + if (type(requiredModule) === 'object' && requiredModule.mochaHooks) { + const mochaHooksType = type(requiredModule.mochaHooks); + if (/function$/.test(mochaHooksType) || mochaHooksType === 'object') { + debug('found root hooks in required file %s', mod); + acc.push(requiredModule.mochaHooks); + } else { + throw createUnsupportedError( + 'mochaHooks must be an object or a function returning (or fulfilling with) an object' + ); + } } - require(modpath); debug('loaded required module "%s"', mod); - }); + return acc; + }, []); + +/** + * Loads root hooks as exported via `mochaHooks` from required files. + * These can be sync/async functions returning objects, or just objects. + * Flattens to a single object. + * @param {Array} rootHooks - Array of root hooks + * @private + * @returns {MochaRootHookObject} + */ +exports.loadRootHooks = async rootHooks => { + const rootHookObjects = await Promise.all( + rootHooks.map(async hook => (/function$/.test(type(hook)) ? hook() : hook)) + ); + + return rootHookObjects.reduce( + (acc, hook) => { + acc.beforeAll = acc.beforeAll.concat(hook.beforeAll || []); + acc.beforeEach = acc.beforeEach.concat(hook.beforeEach || []); + acc.afterAll = acc.afterAll.concat(hook.afterAll || []); + acc.afterEach = acc.afterEach.concat(hook.afterEach || []); + return acc; + }, + {beforeAll: [], beforeEach: [], afterAll: [], afterEach: []} + ); }; /** @@ -106,6 +145,7 @@ const singleRun = async (mocha, {exit}, fileCollectParams) => { debug('single run with %d file(s)', files.length); mocha.files = files; + // handles ESM modules await mocha.loadFilesAsync(); return mocha.run(exit ? exitMocha : exitMochaLater); }; diff --git a/lib/cli/run.js b/lib/cli/run.js index d024cbb0f2..1bd938b77a 100644 --- a/lib/cli/run.js +++ b/lib/cli/run.js @@ -18,6 +18,7 @@ const { list, handleRequires, validatePlugin, + loadRootHooks, runMocha } = require('./run-helpers'); const {ONE_AND_DONES, ONE_AND_DONE_ARGS} = require('./one-and-dones'); @@ -285,12 +286,17 @@ exports.builder = yargs => ); } + return true; + }) + .middleware(async argv => { // load requires first, because it can impact "plugin" validation - handleRequires(argv.require); + const rawRootHooks = await handleRequires(argv.require); validatePlugin(argv, 'reporter', Mocha.reporters); validatePlugin(argv, 'ui', Mocha.interfaces); - return true; + if (rawRootHooks.length) { + argv.rootHooks = await loadRootHooks(rawRootHooks); + } }) .array(types.array) .boolean(types.boolean) diff --git a/lib/mocha.js b/lib/mocha.js index 6817ff880b..1ab26958c8 100644 --- a/lib/mocha.js +++ b/lib/mocha.js @@ -118,6 +118,8 @@ exports.Test = require('./test'); * @param {number} [options.slow] - Slow threshold value. * @param {number|string} [options.timeout] - Timeout threshold value. * @param {string} [options.ui] - Interface name. + * @param {MochaRootHookObject} [options.rootHooks] - Hooks to bootstrap the root + * suite with */ function Mocha(options) { options = utils.assign({}, mocharc, options || {}); @@ -165,6 +167,10 @@ function Mocha(options) { this[opt](); } }, this); + + if (options.rootHooks) { + this.rootHooks(options.rootHooks); + } } /** @@ -959,3 +965,46 @@ Mocha.prototype.run = function(fn) { return runner.run(done); }; + +/** + * Assigns hooks to the root suite + * @param {MochaRootHookObject} [hooks] - Hooks to assign to root suite + * @chainable + */ +Mocha.prototype.rootHooks = function rootHooks(hooks) { + if (utils.type(hooks) === 'object') { + var beforeAll = [].concat(hooks.beforeAll || []); + var beforeEach = [].concat(hooks.beforeEach || []); + var afterAll = [].concat(hooks.afterAll || []); + var afterEach = [].concat(hooks.afterEach || []); + var rootSuite = this.suite; + beforeAll.forEach(function(hook) { + rootSuite.beforeAll(hook); + }); + beforeEach.forEach(function(hook) { + rootSuite.beforeEach(hook); + }); + afterAll.forEach(function(hook) { + rootSuite.afterAll(hook); + }); + afterEach.forEach(function(hook) { + rootSuite.afterEach(hook); + }); + } + return this; +}; + +/** + * An alternative way to define root hooks that works with parallel runs. + * @typedef {Object} MochaRootHookObject + * @property {Function|Function[]} [beforeAll] - "Before all" hook(s) + * @property {Function|Function[]} [beforeEach] - "Before each" hook(s) + * @property {Function|Function[]} [afterAll] - "After all" hook(s) + * @property {Function|Function[]} [afterEach] - "After each" hook(s) + */ + +/** + * An function that returns a {@link MochaRootHookObject}, either sync or async. + * @callback MochaRootHookFunction + * @returns {MochaRootHookObject|Promise} + */ diff --git a/lib/utils.js b/lib/utils.js index 4e6b194249..0134247022 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -63,8 +63,9 @@ exports.isString = function(obj) { exports.slug = function(str) { return str .toLowerCase() - .replace(/ +/g, '-') - .replace(/[^-\w]/g, ''); + .replace(/\s+/g, '-') + .replace(/[^-\w]/g, '') + .replace(/-{2,}/g, '-'); }; /** diff --git a/test/integration/fixtures/options/require/root-hook-defs-a.fixture.js b/test/integration/fixtures/options/require/root-hook-defs-a.fixture.js new file mode 100644 index 0000000000..8938816eee --- /dev/null +++ b/test/integration/fixtures/options/require/root-hook-defs-a.fixture.js @@ -0,0 +1,16 @@ +'use strict'; + +exports.mochaHooks = { + beforeAll() { + console.log('beforeAll'); + }, + beforeEach() { + console.log('beforeEach'); + }, + afterAll() { + console.log('afterAll'); + }, + afterEach() { + console.log('afterEach'); + } +}; diff --git a/test/integration/fixtures/options/require/root-hook-defs-b.fixture.js b/test/integration/fixtures/options/require/root-hook-defs-b.fixture.js new file mode 100644 index 0000000000..6aa0ed3342 --- /dev/null +++ b/test/integration/fixtures/options/require/root-hook-defs-b.fixture.js @@ -0,0 +1,36 @@ +'use strict'; + +exports.mochaHooks = { + beforeAll: [ + function() { + console.log('beforeAll array 1'); + }, + function() { + console.log('beforeAll array 2'); + } + ], + beforeEach: [ + function() { + console.log('beforeEach array 1'); + }, + function() { + console.log('beforeEach array 2'); + } + ], + afterAll: [ + function() { + console.log('afterAll array 1'); + }, + function() { + console.log('afterAll array 2'); + } + ], + afterEach: [ + function() { + console.log('afterEach array 1'); + }, + function() { + console.log('afterEach array 2'); + } + ] +}; diff --git a/test/integration/fixtures/options/require/root-hook-defs-c.fixture.js b/test/integration/fixtures/options/require/root-hook-defs-c.fixture.js new file mode 100644 index 0000000000..624973de75 --- /dev/null +++ b/test/integration/fixtures/options/require/root-hook-defs-c.fixture.js @@ -0,0 +1,16 @@ +'use strict'; + +exports.mochaHooks = async () => ({ + beforeAll() { + console.log('beforeAll'); + }, + beforeEach() { + console.log('beforeEach'); + }, + afterAll() { + console.log('afterAll'); + }, + afterEach() { + console.log('afterEach'); + } +}); diff --git a/test/integration/fixtures/options/require/root-hook-defs-d.fixture.js b/test/integration/fixtures/options/require/root-hook-defs-d.fixture.js new file mode 100644 index 0000000000..d073a35fcf --- /dev/null +++ b/test/integration/fixtures/options/require/root-hook-defs-d.fixture.js @@ -0,0 +1,36 @@ +'use strict'; + +exports.mochaHooks = async() => ({ + beforeAll: [ + function() { + console.log('beforeAll array 1'); + }, + function() { + console.log('beforeAll array 2'); + } + ], + beforeEach: [ + function() { + console.log('beforeEach array 1'); + }, + function() { + console.log('beforeEach array 2'); + } + ], + afterAll: [ + function() { + console.log('afterAll array 1'); + }, + function() { + console.log('afterAll array 2'); + } + ], + afterEach: [ + function() { + console.log('afterEach array 1'); + }, + function() { + console.log('afterEach array 2'); + } + ] +}); diff --git a/test/integration/fixtures/options/require/root-hook-test-2.fixture.js b/test/integration/fixtures/options/require/root-hook-test-2.fixture.js new file mode 100644 index 0000000000..4d00018d2c --- /dev/null +++ b/test/integration/fixtures/options/require/root-hook-test-2.fixture.js @@ -0,0 +1,6 @@ +// run with --require root-hook-defs-a.fixture.js --require +// root-hook-defs-b.fixture.js + +it('should also have some root hooks', function() { + // test +}); \ No newline at end of file diff --git a/test/integration/fixtures/options/require/root-hook-test.fixture.js b/test/integration/fixtures/options/require/root-hook-test.fixture.js new file mode 100644 index 0000000000..412895c87c --- /dev/null +++ b/test/integration/fixtures/options/require/root-hook-test.fixture.js @@ -0,0 +1,6 @@ +// run with --require root-hook-defs-a.fixture.js --require +// root-hook-defs-b.fixture.js + +it('should have some root hooks', function() { + // test +}); \ No newline at end of file diff --git a/test/integration/options/require.spec.js b/test/integration/options/require.spec.js new file mode 100644 index 0000000000..ca50af8607 --- /dev/null +++ b/test/integration/options/require.spec.js @@ -0,0 +1,125 @@ +'use strict'; + +var invokeMochaAsync = require('../helpers').invokeMochaAsync; + +describe('--require', function() { + describe('when mocha run in serial mode', function() { + it('should run root hooks when provided via mochaHooks object export', function() { + return expect( + invokeMochaAsync([ + '--require=' + + require.resolve( + '../fixtures/options/require/root-hook-defs-a.fixture.js' + ), + '--require=' + + require.resolve( + '../fixtures/options/require/root-hook-defs-b.fixture.js' + ), + require.resolve( + '../fixtures/options/require/root-hook-test.fixture.js' + ) + ])[1], + 'when fulfilled', + 'to contain output', + /beforeAll[\s\S]+?beforeAll array 1[\s\S]+?beforeAll array 2[\s\S]+?beforeEach[\s\S]+?beforeEach array 1[\s\S]+?beforeEach array 2[\s\S]+?afterEach[\s\S]+?afterEach array 1[\s\S]+?afterEach array 2[\s\S]+?afterAll[\s\S]+?afterAll array 1[\s\S]+?afterAll array 2/ + ); + }); + + it('should run root hooks when provided via mochaHooks function export', function() { + return expect( + invokeMochaAsync([ + '--require=' + + require.resolve( + '../fixtures/options/require/root-hook-defs-c.fixture.js' + ), + '--require=' + + require.resolve( + '../fixtures/options/require/root-hook-defs-d.fixture.js' + ), + require.resolve( + '../fixtures/options/require/root-hook-test.fixture.js' + ) + ])[1], + 'when fulfilled', + 'to contain output', + /beforeAll[\s\S]+?beforeAll array 1[\s\S]+?beforeAll array 2[\s\S]+?beforeEach[\s\S]+?beforeEach array 1[\s\S]+?beforeEach array 2[\s\S]+?afterEach[\s\S]+?afterEach array 1[\s\S]+?afterEach array 2[\s\S]+?afterAll[\s\S]+?afterAll array 1[\s\S]+?afterAll array 2/ + ); + }); + }); + + describe('when mocha in parallel mode', function() { + before(function() { + this.skip(); // TODO: remove when #4245 lands + }); + it('should run root hooks when provided via mochaHooks object exports', function() { + return expect( + invokeMochaAsync([ + '--require=' + + require.resolve( + '../fixtures/options/require/root-hook-defs-a.fixture.js' + ), + '--require=' + + require.resolve( + '../fixtures/options/require/root-hook-defs-b.fixture.js' + ), + '--parallel', + require.resolve( + '../fixtures/options/require/root-hook-test.fixture.js' + ) + ])[1], + 'when fulfilled', + 'to contain output', + /beforeAll[\s\S]+?beforeAll array 1[\s\S]+?beforeAll array 2[\s\S]+?beforeEach[\s\S]+?beforeEach array 1[\s\S]+?beforeEach array 2[\s\S]+?afterEach[\s\S]+?afterEach array 1[\s\S]+?afterEach array 2[\s\S]+?afterAll[\s\S]+?afterAll array 1[\s\S]+?afterAll array 2/ + ); + }); + + it('should run root hooks when provided via mochaHooks function export', function() { + return expect( + invokeMochaAsync([ + '--require=' + + require.resolve( + '../fixtures/options/require/root-hook-defs-c.fixture.js' + ), + '--require=' + + require.resolve( + '../fixtures/options/require/root-hook-defs-d.fixture.js' + ), + '--parallel', + require.resolve( + '../fixtures/options/require/root-hook-test.fixture.js' + ) + ])[1], + 'when fulfilled', + 'to contain output', + /beforeAll[\s\S]+?beforeAll array 1[\s\S]+?beforeAll array 2[\s\S]+?beforeEach[\s\S]+?beforeEach array 1[\s\S]+?beforeEach array 2[\s\S]+?afterEach[\s\S]+?afterEach array 1[\s\S]+?afterEach array 2[\s\S]+?afterAll[\s\S]+?afterAll array 1[\s\S]+?afterAll array 2/ + ); + }); + + describe('when running multiple jobs', function() { + it('should run root hooks when provided via mochaHooks object exports for each job', function() { + return expect( + invokeMochaAsync([ + '--require=' + + require.resolve( + '../fixtures/options/require/root-hook-defs-a.fixture.js' + ), + '--require=' + + require.resolve( + '../fixtures/options/require/root-hook-defs-b.fixture.js' + ), + '--parallel', + require.resolve( + '../fixtures/options/require/root-hook-test.fixture.js' + ), + require.resolve( + '../fixtures/options/require/root-hook-test-2.fixture.js' + ) + ])[1], + 'when fulfilled', + 'to contain output', + /(?:beforeAll[\s\S]+?beforeAll array 1[\s\S]+?beforeAll array 2[\s\S]+?beforeEach[\s\S]+?beforeEach array 1[\s\S]+?beforeEach array 2[\s\S]+?afterEach[\s\S]+?afterEach array 1[\s\S]+?afterEach array 2[\s\S]+?afterAll[\s\S]+?afterAll array 1[\s\S]+?afterAll array 2[\s\S]+?){2}/ + ); + }); + }); + }); +}); diff --git a/test/node-unit/cli/run-helpers.spec.js b/test/node-unit/cli/run-helpers.spec.js index 00357bbcb5..3169bbd0bb 100644 --- a/test/node-unit/cli/run-helpers.spec.js +++ b/test/node-unit/cli/run-helpers.spec.js @@ -1,8 +1,71 @@ 'use strict'; -const {validatePlugin, list} = require('../../../lib/cli/run-helpers'); +const { + validatePlugin, + list, + loadRootHooks +} = require('../../../lib/cli/run-helpers'); + +describe('helpers', function() { + describe('loadRootHooks()', function() { + describe('when passed nothing', function() { + it('should reject', async function() { + return expect(loadRootHooks(), 'to be rejected'); + }); + }); + + describe('when passed empty array of hooks', function() { + it('should return an empty MochaRootHooks object', async function() { + return expect(loadRootHooks([]), 'to be fulfilled with', { + beforeAll: [], + beforeEach: [], + afterAll: [], + afterEach: [] + }); + }); + }); + + describe('when passed an array containing hook objects and sync functions and async functions', function() { + it('should flatten them into a single object', async function() { + function a() {} + function b() {} + function d() {} + function g() {} + async function f() {} + function c() { + return { + beforeAll: d, + beforeEach: g + }; + } + async function e() { + return { + afterEach: f + }; + } + return expect( + loadRootHooks([ + { + beforeEach: a + }, + { + afterAll: b + }, + c, + e + ]), + 'to be fulfilled with', + { + beforeAll: [d], + beforeEach: [a, g], + afterAll: [b], + afterEach: [f] + } + ); + }); + }); + }); -describe('run helper functions', function() { describe('validatePlugin()', function() { describe('when used with "reporter" key', function() { it('should disallow an array of names', function() { diff --git a/test/node-unit/utils.spec.js b/test/node-unit/utils.spec.js index c0b6294450..720e93f6c3 100644 --- a/test/node-unit/utils.spec.js +++ b/test/node-unit/utils.spec.js @@ -16,5 +16,15 @@ describe('utils', function() { expect(utils.cwd(), 'to be', process.cwd()); }); }); + + describe('type()', function() { + it('should return "asyncfunction" if the parameter is an async function', function() { + expect( + utils.type(async () => {}), + 'to be', + 'asyncfunction' + ); + }); + }); }); }); diff --git a/test/unit/mocha.spec.js b/test/unit/mocha.spec.js index 96aad6e66c..5fb0263ec1 100644 --- a/test/unit/mocha.spec.js +++ b/test/unit/mocha.spec.js @@ -91,9 +91,16 @@ describe('Mocha', function() { }); describe('constructor', function() { + var mocha; + beforeEach(function() { + mocha = sandbox.createStubInstance(Mocha); + mocha.timeout.returnsThis(); + mocha.retries.returnsThis(); sandbox.stub(Mocha.prototype, 'timeout').returnsThis(); sandbox.stub(Mocha.prototype, 'global').returnsThis(); + sandbox.stub(Mocha.prototype, 'retries').returnsThis(); + sandbox.stub(Mocha.prototype, 'rootHooks').returnsThis(); }); it('should set _cleanReferencesAfterRun to true', function() { @@ -108,8 +115,8 @@ describe('Mocha', function() { }); }); - describe('when `timeout` is `false`', function() { - it('should set a timeout of 0', function() { + describe('when `timeout` option is `false`', function() { + it('should attempt to set timeout', function() { // eslint-disable-next-line no-new new Mocha({timeout: false}); expect(Mocha.prototype.timeout, 'to have a call satisfying', [0]).and( @@ -118,8 +125,8 @@ describe('Mocha', function() { }); }); - describe('when `global` option is provided', function() { - it('should configure `global` option', function() { + describe('when `global` option is an `Array`', function() { + it('should attempt to set globals', function() { // eslint-disable-next-line no-new new Mocha({global: ['singular']}); expect(Mocha.prototype.global, 'to have a call satisfying', [ @@ -127,6 +134,34 @@ describe('Mocha', function() { ]).and('was called once'); }); }); + + describe('when `retries` option is present', function() { + it('should attempt to set retries`', function() { + // eslint-disable-next-line no-new + new Mocha({retries: 1}); + expect(Mocha.prototype.retries, 'to have a call satisfying', [1]).and( + 'was called once' + ); + }); + }); + + describe('when `retries` option is not present', function() { + it('should not attempt to set retries', function() { + // eslint-disable-next-line no-new + new Mocha({}); + expect(Mocha.prototype.retries, 'was not called'); + }); + }); + + describe('when `rootHooks` option is truthy', function() { + it('shouid attempt to set root hooks', function() { + // eslint-disable-next-line no-new + new Mocha({rootHooks: ['a root hook']}); + expect(Mocha.prototype.rootHooks, 'to have a call satisfying', [ + ['a root hook'] + ]).and('was called once'); + }); + }); }); describe('instance method', function() { diff --git a/test/unit/utils.spec.js b/test/unit/utils.spec.js index 3c68ddd186..a54535b1a5 100644 --- a/test/unit/utils.spec.js +++ b/test/unit/utils.spec.js @@ -14,7 +14,7 @@ describe('lib/utils', function() { sandbox.restore(); }); - describe('clean', function() { + describe('clean()', function() { it('should remove the wrapping function declaration', function() { expect( utils.clean('function (one, two, three) {\n//code\n}'), @@ -149,7 +149,7 @@ describe('lib/utils', function() { }); }); - describe('stringify', function() { + describe('stringify()', function() { var stringify = utils.stringify; it('should return an object representation of a string created with a String constructor', function() { @@ -525,7 +525,7 @@ describe('lib/utils', function() { }); }); - describe('type', function() { + describe('type()', function() { /* eslint no-extend-native: off */ var type = utils.type; @@ -588,7 +588,7 @@ describe('lib/utils', function() { }); }); - describe('isPromise', function() { + describe('isPromise()', function() { it('should return true if the value is Promise-ish', function() { expect( utils.isPromise({ @@ -612,7 +612,7 @@ describe('lib/utils', function() { }); }); - describe('escape', function() { + describe('escape()', function() { it('replaces the usual xml suspects', function() { expect(utils.escape('a>bc>d>'), 'to be', '>a>bc>d>'); @@ -634,7 +634,7 @@ describe('lib/utils', function() { }); }); - describe('deprecate', function() { + describe('deprecate()', function() { var emitWarning; beforeEach(function() { @@ -674,7 +674,7 @@ describe('lib/utils', function() { }); }); - describe('warn', function() { + describe('warn()', function() { var emitWarning; beforeEach(function() { @@ -710,13 +710,17 @@ describe('lib/utils', function() { }); }); - describe('sQuote/dQuote', function() { + describe('sQuote()', function() { var str = 'xxx'; it('should return its input as string wrapped in single quotes', function() { var expected = "'xxx'"; expect(utils.sQuote(str), 'to be', expected); }); + }); + + describe('dQuote()', function() { + var str = 'xxx'; it('should return its input as string wrapped in double quotes', function() { var expected = '"xxx"'; @@ -724,7 +728,7 @@ describe('lib/utils', function() { }); }); - describe('createMap', function() { + describe('createMap()', function() { it('should return an object with a null prototype', function() { expect(Object.getPrototypeOf(utils.createMap()), 'to be', null); }); @@ -743,4 +747,26 @@ describe('lib/utils', function() { ); }); }); + + describe('slug()', function() { + it('should convert the string to lowercase', function() { + expect(utils.slug('FOO'), 'to be', 'foo'); + }); + + it('should convert whitespace to dashes', function() { + expect( + utils.slug('peanut butter\nand\tjelly'), + 'to be', + 'peanut-butter-and-jelly' + ); + }); + + it('should strip non-alphanumeric and non-dash characters', function() { + expect(utils.slug('murder-hornets!!'), 'to be', 'murder-hornets'); + }); + + it('should disallow consecutive dashes', function() { + expect(utils.slug('poppies & fritz'), 'to be', 'poppies-fritz'); + }); + }); });