Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: randomization support [WIP REFERENCE - DO NOT MERGE] #2301

Closed
wants to merge 13 commits into from
16 changes: 15 additions & 1 deletion bin/_mocha
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,9 @@ program
.option('--trace-deprecation', 'show stack traces on deprecations')
.option('--use_strict', 'enforce strict mode')
.option('--watch-extensions <ext>,...', 'additional extensions to monitor with --watch', list, [])
.option('--delay', 'wait for async suite definition');
.option('--delay', 'wait for async suite definition')
.option('--generate-seed', 'generate a random seed and exit')
.option('--random <seed>', 'randomize order of tests (with required random seed)');

program._name = 'mocha';

Expand Down Expand Up @@ -186,6 +188,11 @@ if (!process.env.LOADED_MOCHA_OPTS) {

program.parse(process.argv);

if (program.generateSeed) {
console.log(Mocha.seed());
process.exit(0);
}

// infinite stack traces

Error.stackTraceLimit = Infinity; // TODO: config
Expand Down Expand Up @@ -275,6 +282,13 @@ if (program.fgrep) {
mocha.fgrep(program.fgrep);
}

// --randomize

if (program.randomize) {
var matches = program.randomize.split(':');
mocha.randomize(matches[0], matches[1]);
}

// --invert

if (program.invert) {
Expand Down
5 changes: 5 additions & 0 deletions browser-entry.js
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,11 @@ mocha.run = function(fn) {

Mocha.process = process;

/**
* Expose Mocha.seed() for browser seed generation.
*/
mocha.seed = Mocha.seed;

/**
* Expose mocha.
*/
Expand Down
19 changes: 18 additions & 1 deletion lib/interfaces/bdd.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,16 +59,33 @@ module.exports = function(suite) {
};

/**
* Exclusive suite.
* Run tests "in order"; do not randomize.
*/
context.describe.inOrder = function func(title, fn) {
var suite = context.describe(title, fn);
suite.randomized(false);
return suite;
};
// these three are disabled anyway, so don't actually implement inOrder
context.describe.inOrder.skip = context.describe.skip.inOrder
= context.xdescribe.inOrder = context.describe.skip;

/**
* Exclusive suite.
*/
context.describe.only = function(title, fn) {
return common.suite.only({
title: title,
file: file,
fn: fn
});
};
context.describe.only.inOrder = context.describe.inOrder.only
= function(title, fn) {
var suite = context.describe.inOrder(title, fn);
mocha.grep(suite.fullTitle());
return suite;
};

/**
* Describe a specification or test-case
Expand Down
25 changes: 24 additions & 1 deletion lib/interfaces/qunit.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,6 @@ module.exports = function(suite) {
/**
* Exclusive Suite.
*/

context.suite.only = function(title) {
if (suites.length > 1) {
suites.shift();
Expand All @@ -68,6 +67,30 @@ module.exports = function(suite) {
});
};

/**
* Do not randomize.
*/
context.suite.inOrder = function(title) {
var suite = common.suite.create({
title: title,
file: file
});
suite.enableRandomize(false);
return suite;
};

/**
* Do not randomize + exclusive
*/
context.suite.only.inOrder = context.suite.inOrder.only = function(title) {
var suite = common.suite.only({
title: title,
file: file
});
suite.enableRandomize(false);
return suite;
};

/**
* Describe a specification or test-case
* with the given `title` and callback `fn`
Expand Down
15 changes: 15 additions & 0 deletions lib/interfaces/tdd.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,16 @@ module.exports = function(suite) {
});
};

/**
* Do not randomize.
*/
context.suite.inOrder = function(title, fn) {
var suite = context.suite(title, fn);
suite.randomized(false);
return suite;
};
context.suite.inOrder.skip = context.suite.skip;

/**
* Exclusive test-case.
*/
Expand All @@ -74,6 +84,11 @@ module.exports = function(suite) {
fn: fn
});
};
context.suite.only.inOrder = context.suite.inOrder.only
= function(title, fn) {
var suite = context.suite.inOrder(title, fn);
mocha.grep(suite.fullTitle());
};

/**
* Describe a specification or test-case with the given `title` and
Expand Down
56 changes: 56 additions & 0 deletions lib/mocha.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ var escapeRe = require('escape-string-regexp');
var path = require('path');
var reporters = require('./reporters');
var utils = require('./utils');
var Random = require('random-js');

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where is this number coming from?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd rather get the number from the JS API or, if not available, an npm module.

Copy link
Contributor

@TimothyGu TimothyGu Jun 14, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From https://www.npmjs.com/package/random-js#distributions

  • Random.integer(min, max)(engine): Produce an integer within the inclusive range [min, max]. min can be at its minimum -9007199254740992 (-2 ** 53). max can be at its maximum 9007199254740992 (2 ** 53).

Number.MAX_SAFE_INTEGER is the same value.

EDIT: Number.MAX_SAFE_INTEGER is only available in ES2015 and later.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, sorry. 0x1fffffffffffff is the value of Number.MAX_SAFE_INTEGER if Number.MAX_SAFE_INTEGER is implemented. But we're not guaranteed that, of course, and I didn't want to pull in some polyfill. I'll add a comment.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dasilvacontin Would prefer not to npm install --save max-safe-integer (don't know if that exists; probably does) if I can help it.

/**
* Expose `Mocha`.
Expand Down Expand Up @@ -68,6 +69,7 @@ function image(name) {
* - `ignoreLeaks` ignore global leaks
* - `fullTrace` display the full stack-trace on failing
* - `grep` string or regexp to filter tests with
* - `random` for randomization of tests w/ optional seed
*
* @param {Object} options
* @api public
Expand Down Expand Up @@ -99,6 +101,9 @@ function Mocha(options) {
if (options.slow) {
this.slow(options.slow);
}
if (options.random) {
this.randomize(options.random);
}
}

/**
Expand Down Expand Up @@ -301,6 +306,53 @@ Mocha.prototype.ignoreLeaks = function(ignore) {
return this;
};

/**
* Enable or disable randomization of test execution order within suites.
*
* @param {(boolean|number|string)} seed Random seed. `seed` must be
* a 32-bit unsigned integer, or a string which can convert to one.
* If `false`, randomization will be globally disabled (if it was enabled
* previously).
* @return {Mocha}
* @api public
*/
Mocha.prototype.randomize = function randomize(seed) {
if (!arguments.length) {
throw new Error('Generate a random seed using --generate-seed on the '
+ 'command line, or Mocha.seed() in the browser.');
}
if (seed !== false) {
var validSeed = utils.toSafeUnsignedInteger(seed);
if (isNaN(validSeed)) {
throw new Error('Invalid random seed. Expected unsigned 32-bit '
+ 'integer or value which can convert.');
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a particular use case for supporting numbers-in-strings directly in the randomization function? I would think it would be on the caller to do the conversion. Having to specify number conversion behavior in the randomization function itself complicates the API, and for little benefit given that it could be handled separately. And if it were handled separately, it would be easier to update in isolation if need be, or to test, etc.

The one place I can think of it being needed in Mocha is the CLI. Ideally, I'd say the string to seed conversion should be in its own utility function so that in the event it is needed in more than one place it could be reused, so that it can be tested separately from testing the stuff that uses it or the randomization it feeds into, and so we can consider exposing it to users/wrappers of Mocha if they want to get the same conversion method that Mocha uses for the seed.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right; string to integer conversion is necessary for the CLI only. At some point, we'll have to do it. I can move the implementation elsewhere.


var engine = Random.engines.mt19937().seed(validSeed);
this.options.randomConfig = {
shuffleTests: function shuffleTests(tests) {
Random.shuffle(engine, tests);
return tests;
},
seed: validSeed,
hex: '0x' + Number(validSeed).toString(16).toUpperCase() // for display
};
} else {
delete this.options.randomConfig;
}
return this;
};

/**
* Generate a random seed.
* @returns {string} Hexadecimal string
*/
Mocha.seed = function seed() {
var engine = Random.engines.mt19937()
.autoSeed();
return utils.toHex(Random.integer(0, utils.MAX_SAFE_INTEGER)(engine));
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It'd be nice to have a comment explaining why we use autoSeed to generate a seed to start a random generator to use to generate a seed to return rather than just using autoSeed to generate a seed to return. (I assume it's something like autoSeed spits out floating point or some format that's inadequate for people entering it on the commandline, but I'd like to know for sure... without going and looking up the docs for another library if possible.)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That comment would be something like "go read the docs for this other library". 😜

Basically, you can't get a value out of the engine unless it's seeded. So if we want a seed, then we should randomly generate one. And if we're randomly generating a seed, why not use the random number generator..?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@boneskull, I don't think you understood @ScottFreeCode's question. The question was if autoSeed() generates a number that can be directly returned, i.e. if the following was possible

Mocha.seed = function seed() {
  return Random.engines.mt19937().autoSeed();
};

The answer seems to be no, since autoSeed doesn't return a seed; rather it returns a seeded random generator using Random.generateEntropyArray. The generator is fed into the function returned by Random.integer. generateEntropyArray returns an array instead of a seed number.

There's also a minor inaccuracy in the JSDoc (s/32/53/), assuming we don't want to allow negative seeds (or random-js doesn't support them).

Altogether the code should IMO look like this:

/**
 * Generate a random seed.
 * @returns {number} Unsigned 53-bit integer
 */
Mocha.seed = function seed() {
  /*
   * Create an auto-seeded random number generator.
   * We can't use `Random.generateEntropyArray` (used by `autoSeed`)
   * for this function since our seed is an integer.
   */
  var generator = Random.engines.mt19937().autoSeed();

  /*
   * Return a random integer that can be used as a seed, using the
   * random number generator we just created.
   */
  return Random.integer(0, MAX_SAFE_INTEGER)(generator);
};


/**
* Enable global leak checking.
*
Expand Down Expand Up @@ -506,6 +558,10 @@ Mocha.prototype.run = function(fn) {
if (options.useColors !== undefined) {
exports.reporters.Base.useColors = options.useColors;
}
if (options.randomConfig) {
runner.randomConfig = options.randomConfig;
}

exports.reporters.Base.inlineDiffs = options.useInlineDiffs;

function done(failures) {
Expand Down
16 changes: 15 additions & 1 deletion lib/runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ function Runner(suite, delay) {
this._defaultGrep = /.*/;
this.grep(this._defaultGrep);
this.globals(this.globalProps().concat(extraGlobals()));
this.random = false;
}

/**
Expand Down Expand Up @@ -212,6 +213,19 @@ Runner.prototype.checkGlobals = function(test) {
}
};

/**
* Get an array of a Suite's Tests, potentially in random order.
* @returns {Array.<Test>} Array of tests (a copy)
* @api private
*/
Runner.prototype.suiteTests = function suiteTests(suite) {
var tests = suite.tests.slice();
if (this.randomConfig && suite.randomized()) {
return this.randomConfig.shuffleTests(tests);
}
return tests;
};

/**
* Fail the given `test`.
*
Expand Down Expand Up @@ -447,7 +461,7 @@ Runner.prototype.runTest = function(fn) {
*/
Runner.prototype.runTests = function(suite, fn) {
var self = this;
var tests = suite.tests.slice();
var tests = self.suiteTests(suite);
var test;

function hookErr(_, errSuite, after) {
Expand Down
23 changes: 23 additions & 0 deletions lib/suite.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ function Suite(title, parentContext) {
this._slow = 75;
this._bail = false;
this._retries = -1;
// this will cause randomization to occur by default if enabled
// globally.
this._randomized = true;
this._onlyTests = [];
this._onlySuites = [];
this.delayed = false;
Expand All @@ -86,6 +89,7 @@ Suite.prototype.clone = function() {
suite.enableTimeouts(this.enableTimeouts());
suite.slow(this.slow());
suite.bail(this.bail());
suite.randomized(this.randomized());
return suite;
};

Expand Down Expand Up @@ -187,6 +191,25 @@ Suite.prototype.isPending = function() {
return this.pending || (this.parent && this.parent.isPending());
};

/**
* If randomization is enabled, calling this with a falsy value
* will cause the Suite's tests to NOT be randomized.
*
* This cannot be used in the context of a test, since it will already be
* randomized (or not) by then.
*
* @param {boolean} enabled
* @return {Suite|Boolean} self or enabled
* @api private
*/
Suite.prototype.randomized = function randomized(enabled) {
if (!arguments.length) {
return this._randomized;
}
this._randomized = Boolean(enabled);
return this;
};

/**
* Run `fn(test[, done])` before running tests.
*
Expand Down
Loading