'use strict';

var EventEmitter = require('events').EventEmitter; var Pending = require('./pending'); var debug = require('debug')('mocha:runnable'); var milliseconds = require('ms'); var utils = require('./utils'); var createInvalidExceptionError = require('./errors')

.createInvalidExceptionError;

/**

* Save timer references to avoid Sinon interfering (see GH-237).
*/

var Date = global.Date; var setTimeout = global.setTimeout; var clearTimeout = global.clearTimeout; var toString = Object.prototype.toString;

module.exports = Runnable;

/**

* Initialize a new `Runnable` with the given `title` and callback `fn`.
*
* @class
* @extends external:EventEmitter
* @public
* @param {String} title
* @param {Function} fn
*/

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;
this.timedOut = false;
this._retries = -1;
this._currentRetry = 0;
this.pending = false;

}

/**

* Inherit from `EventEmitter.prototype`.
*/

utils.inherits(Runnable, EventEmitter);

/**

* Get current timeout value in msecs.
*
* @private
* @returns {number} current timeout threshold value
*/

/**

* @summary
* Set timeout threshold value (msecs).
*
* @description
* A string argument can use shorthand (e.g., "2s") and will be converted.
* The value will be clamped to range [<code>0</code>, <code>2^<sup>31</sup>-1</code>].
* If clamped value matches either range endpoint, timeouts will be disabled.
*
* @private
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/setTimeout#Maximum_delay_value}
* @param {number|string} ms - Timeout threshold value.
* @returns {Runnable} this
* @chainable
*/

Runnable.prototype.timeout = function(ms) {

if (!arguments.length) {
  return this._timeout;
}
if (typeof ms === 'string') {
  ms = milliseconds(ms);
}

// Clamp to range
var INT_MAX = Math.pow(2, 31) - 1;
var range = [0, INT_MAX];
ms = utils.clamp(ms, range);

// see #1652 for reasoning
if (ms === range[0] || ms === range[1]) {
  this._enableTimeouts = false;
}
debug('timeout %d', ms);
this._timeout = ms;
if (this.timer) {
  this.resetTimeout();
}
return this;

};

/**

* Set or get slow `ms`.
*
* @private
* @param {number|string} ms
* @return {Runnable|number} ms or Runnable instance.
*/

Runnable.prototype.slow = function(ms) {

if (!arguments.length || typeof ms === 'undefined') {
  return this._slow;
}
if (typeof ms === 'string') {
  ms = milliseconds(ms);
}
debug('slow %d', ms);
this._slow = ms;
return this;

};

/**

* Set and get whether timeout is `enabled`.
*
* @private
* @param {boolean} enabled
* @return {Runnable|boolean} enabled or Runnable instance.
*/

Runnable.prototype.enableTimeouts = function(enabled) {

if (!arguments.length) {
  return this._enableTimeouts;
}
debug('enableTimeouts %s', enabled);
this._enableTimeouts = enabled;
return this;

};

/**

* Halt and mark as pending.
*
* @memberof Mocha.Runnable
* @public
*/

Runnable.prototype.skip = function() {

throw new Pending('sync skip');

};

/**

* Check if this runnable or its parent suite is marked as pending.
*
* @private
*/

Runnable.prototype.isPending = function() {

return this.pending || (this.parent && this.parent.isPending());

};

/**

* Return `true` if this Runnable has failed.
* @return {boolean}
* @private
*/

Runnable.prototype.isFailed = function() {

return !this.isPending() && this.state === constants.STATE_FAILED;

};

/**

* Return `true` if this Runnable has passed.
* @return {boolean}
* @private
*/

Runnable.prototype.isPassed = function() {

return !this.isPending() && this.state === constants.STATE_PASSED;

};

/**

* Set or get number of retries.
*
* @private
*/

Runnable.prototype.retries = function(n) {

if (!arguments.length) {
  return this._retries;
}
this._retries = n;

};

/**

* Set or get current retry
*
* @private
*/

Runnable.prototype.currentRetry = function(n) {

if (!arguments.length) {
  return this._currentRetry;
}
this._currentRetry = n;

};

/**

* Return the full title generated by recursively concatenating the parent's
* full title.
*
* @memberof Mocha.Runnable
* @public
* @return {string}
*/

Runnable.prototype.fullTitle = function() {

return this.titlePath().join(' ');

};

/**

* Return the title path generated by concatenating the parent's title path with the title.
*
* @memberof Mocha.Runnable
* @public
* @return {string}
*/

Runnable.prototype.titlePath = function() {

return this.parent.titlePath().concat([this.title]);

};

/**

* Clear the timeout.
*
* @private
*/

Runnable.prototype.clearTimeout = function() {

clearTimeout(this.timer);

};

/**

* Inspect the runnable void of private properties.
*
* @private
* @return {string}
*/

Runnable.prototype.inspect = function() {

return JSON.stringify(
  this,
  function(key, val) {
    if (key[0] === '_') {
      return;
    }
    if (key === 'parent') {
      return '#<Suite>';
    }
    if (key === 'ctx') {
      return '#<Context>';
    }
    return val;
  },
  2
);

};

/**

* Reset the timeout.
*
* @private
*/

Runnable.prototype.resetTimeout = function() {

var self = this;
var ms = this.timeout() || 1e9;

if (!this._enableTimeouts) {
  return;
}
this.clearTimeout();
this.timer = setTimeout(function() {
  if (!self._enableTimeouts) {
    return;
  }
  self.callback(self._timeoutError(ms));
  self.timedOut = true;
}, ms);

};

/**

* Set or get a list of whitelisted globals for this test run.
*
* @private
* @param {string[]} globals
*/

Runnable.prototype.globals = function(globals) {

if (!arguments.length) {
  return this._allowedGlobals;
}
this._allowedGlobals = globals;

};

/**

* Run the test and invoke `fn(err)`.
*
* @param {Function} fn
* @private
*/

Runnable.prototype.run = function(fn) {

var self = this;
var start = new Date();
var ctx = this.ctx;
var finished;
var emitted;

// Sometimes the ctx exists, but it is not runnable
if (ctx && ctx.runnable) {
  ctx.runnable(this);
}

// called multiple times
function multiple(err) {
  if (emitted) {
    return;
  }
  emitted = true;
  var msg = 'done() called multiple times';
  if (err && err.message) {
    err.message += " (and Mocha's " + msg + ')';
    self.emit('error', err);
  } else {
    self.emit('error', new Error(msg));
  }
}

// finished
function done(err) {
  var ms = self.timeout();
  if (self.timedOut) {
    return;
  }

  if (finished) {
    return multiple(err);
  }

  self.clearTimeout();
  self.duration = new Date() - start;
  finished = true;
  if (!err && self.duration > ms && self._enableTimeouts) {
    err = self._timeoutError(ms);
  }
  fn(err);
}

// for .resetTimeout()
this.callback = done;

// explicit async with `done` argument
if (this.async) {
  this.resetTimeout();

  // allows skip() to be used in an explicit async context
  this.skip = function asyncSkip() {
    done(new Pending('async skip call'));
    // halt execution.  the Runnable will be marked pending
    // by the previous call, and the uncaught handler will ignore
    // the failure.
    throw new Pending('async skip; aborting execution');
  };

  if (this.allowUncaught) {
    return callFnAsync(this.fn);
  }
  try {
    callFnAsync(this.fn);
  } catch (err) {
    emitted = true;
    done(Runnable.toValueOrError(err));
  }
  return;
}

if (this.allowUncaught) {
  if (this.isPending()) {
    done();
  } else {
    callFn(this.fn);
  }
  return;
}

// sync or promise-returning
try {
  if (this.isPending()) {
    done();
  } else {
    callFn(this.fn);
  }
} catch (err) {
  emitted = true;
  done(Runnable.toValueOrError(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();
  });
}

};

/**

* Instantiates a "timeout" error
*
* @param {number} ms - Timeout (in milliseconds)
* @returns {Error} a "timeout" error
* @private
*/

Runnable.prototype._timeoutError = function(ms) {

var msg =
  'Timeout of ' +
  ms +
  'ms exceeded. For async tests and hooks, ensure "done()" is called; if returning a Promise, ensure it resolves.';
if (this.file) {
  msg += ' (' + this.file + ')';
}
return new Error(msg);

};

var constants = utils.defineConstants(

/**
 * {@link Runnable}-related constants.
 * @public
 * @memberof Runnable
 * @readonly
 * @static
 * @alias constants
 * @enum {string}
 */
{
  /**
   * Value of `state` prop when a `Runnable` has failed
   */
  STATE_FAILED: 'failed',
  /**
   * Value of `state` prop when a `Runnable` has passed
   */
  STATE_PASSED: 'passed'
}

);

/**

* Given `value`, return identity if truthy, otherwise create an "invalid exception" error and return that.
* @param {*} [value] - Value to return, if present
* @returns {*|Error} `value`, otherwise an `Error`
* @private
*/

Runnable.toValueOrError = function(value) {

return (
  value ||
  createInvalidExceptionError(
    'Runnable failed with falsy or undefined exception. Please throw an Error instead.',
    value
  )
);

};

Runnable.constants = constants;