index.js

// (function(){
  /**
   * @namespace
   * @desc
   * salep is a singleton object that manages all tests and cases.
   * This object exposed to global scope as 'salep'.
   */
  const salep = {
    tests: [],
    cases: [],
    isRunning: true,

    /**
     * @method on
     * @memberof salep
     * 
     * @desc
     * This function enables adding callbacks to events. For one specific
     * event there may be many callbacks.
     * 
     * @param {String}    eventName Event name to add callback
     * @param {Function}  callback  Callback
     * 
     * @example
     * salep.on('fail', function(testCase) {
     *   console.log(testCase.name + ' has failed!');
     * });
     */
    on: function(eventName, callback) {
      if (!callbacks[eventName]) {
        callbacks[eventName] = [];
      }
      callbacks[eventName].push(callback);
    },

    /**
     * @method off
     * @memberof salep
     *
     * @desc
     * This function allows removing callbacks from events. Every callback
     * added with 'on' function can be removed with this function.
     *
     * @param {String}    eventName         Event name to remove callback from
     * @param {Function}  callbackToRemove  Callback to remove
     *
     * @example
     * function myCallback(test) {
     *   // do some stuff
     * }
     * salep.on('testStart', myCallback);
     * ...
     * salep.off('testStart', myCallback);
     */
    off: function(eventName, callbackToRemove) {
      if (callbacks[eventName]) {
        for (var i = 0; i < callbacks[eventName].length; i++) {
          if (callbacks[eventName][i] === callbackToRemove) {
            callbacks[eventName].splice(i, 1);
            break
          }
        }
      }
    },

    /**
     * @method run
     * @memberof salep
     *
     * @desc
     * Enables salep testing. All the tests and cases before salep.run method
     * executed will be counted and recorded as skipped.
     * 
     * @deprecated
     * Since 0.2.0, salep starts in running mode as default. You don't need to
     * use run function unless you used stop function. 
     */
    run: function() {
      salep.isRunning = true;
    },

    /**
     * @method stop
     * @memberof salep
     *
     * @desc
     * Disables salep testing and returns all the collected information starting
     * from last stop function invoked or the beginning of program (if stop function
     * not invoked ever). After stop function invoked all following tests and cases 
     * will be counted and recorded as skipped.
     * 
     * @returns {Result} Result object containing test results
     * 
     * @deprecated 
     * Since 0.2.0, when used it will cause salep skip tests and cases,
     * this behaviour will continue until run function called.
     */
    stop: function() {
      salep.isRunning = false;
      var result = new Result({
        success: successCount,
        fail: failCount,
        skip: skipCount,
        total: totalCount,
        tests: salep.tests,
        cases: salep.cases
      });
      successCount = failCount = totalCount = skipCount = 0;
      return result;
    },

    /**
     * @method getResults
     * @memberof salep
     * 
     * @desc
     * This method will return results of tests and cases from
     * the beginning. If salep.stop is called at some point return value
     * will just have the results after that call.
     * 
     * @return {Result} Result object containing test results
     */
    getResults: function() {
      return new Result({
        success: successCount,
        fail: failCount,
        skip: skipCount,
        total: totalCount,
        tests: salep.tests,
        cases: salep.cases
      });
    },

    /**
     * @method test
     * @memberof salep
     * 
     * @desc
     * This function creates a new test inside salep scope with given name
     * and test function. Tests doesn't have success or fail status, they have
     * cases. All the cases written inside test function is belong to named test.
     * 
     * @param {String}    name  Name of the test
     * @param {Function}  func  Test function
     * 
     * @fires salep#testStart
     * @fires salep#skip
     * 
     * @example
     * salep.test('NewTest', function() {
     *   this.case('NewCase of NewTest', function() {
     *     // Case code goes here
     *   });
     * });
     */
    test: function(name, func) {
      var _test = new Test({
        name: name,
        level: level++
      });
      
      if (this instanceof Test) {
        this.tests.push(_test);
      } else {
        salep.tests.push(_test);
      }

      if (salep.isRunning && !skipNextEnabled) {
        testStart(_test);
        func.call(_test);
        _test.cases.forEach(function(_case) {
          if (_case.skipped) {
            skip(_case);
            return;
          } else {
            caseStart(_case);
            try {
              _test.beforeEachCb && _test.beforeEachCb();
              _case.caseFunction && _case.caseFunction();
              _test.afterEachCb && _test.afterEachCb();

              _case.success = true;
              success(_case);
            } catch (e) {
              _case.success = false;
              _case.reason = e;
              fail(_case);
            }
          }
        });
      } else {
        _test.skipped = true;
        skipNextEnabled = false;
        skip(_test);
      }

      level--;
    },

    /**
     * @method skipNext
     * @memberof salep
     * 
     * @desc
     * This function helps skipping tests/cases. If you want to skip a case or
     * test, run this function right before test or case definition.
     * 
     * @example
     * salep.skipNext();
     * salep.case("salep will skip this case", function() {
     *   if (!someFunction()) {
     *     throw "Exception";
     *   }
     * });
     */
    skipNext: function() {
      skipNextEnabled = true;
    },

    /**
     * @method case
     * @memberof salep
     * 
     * @desc
     * This function creates a new case inside salep scope with given name
     * and case function. Cases created in salep scope doesn't have parent.
     * When case function invoked if exception is thrown case would marked
     * as failed otherwise case marked as succeded.
     * 
     * @param {String}    name  Name of the case
     * @param {Function}  func  Case function
     * 
     * @fires salep#caseStart
     * @fires salep#success
     * @fires salep#fail
     * @fires salep#skip
     * 
     * @deprecated
     * since v0.2.2
     * 
     * @example
     * salep.case('NewFailCaseInsalepScope', function() {
     *   throw "Exception goes here";
     * });
     */
    case: function(name, func) {
      var _case = new Case({
        name: name
      });
      salep.cases.push(_case);

      if (salep.isRunning && !skipNextEnabled) {
        caseStart(_case);
        try {
          func();
          _case.success = true;
          success(_case);
        } catch (e) {
          _case.success = false;
          _case.reason = e;
          fail(_case);
        }
      } else {
        _case.skipped = true;
        _case.success = false;
        skipNextEnabled = false;
        skip(_case);
      }
    }
  };

  if (typeof global !== 'undefined') {
    global.salep = salep;
  }
  if (typeof window !== 'undefined') {
    window.salep = salep;
  }

  // Privates
  var skipNextEnabled = false;

  // Event mechanism
  var successCount = 0;
  var failCount = 0;
  var skipCount = 0;
  var totalCount = 0;
  var callbacks = {};

  function emit(eventName, data) {
    if (callbacks[eventName]) {
      callbacks[eventName].forEach(function(callback) {
        callback(data);
      });
    }
  }

  function testStart(test) {
    /**
     * This event fires before starting a test function.
     *
     * @event salep#testStart
     * @type {Test}
     */
    emit("testStart", test);
  }

  function caseStart(testCase) {
    totalCount++;
    /**
     * This event fires before starting a test case function.
     * 
     * @event salep#caseStart
     * @type {Case}
     */
    emit("caseStart", testCase);
  }

  function fail(testCase) {
    failCount++;
    /**
     * This event fires when a test case fails.
     * 
     * @event salep#fail
     * @type {Case}
     */
    emit("fail", testCase);
  }

  function success(testCase) {
    successCount++;
    /**
     * This event fires when a test case succeeds.
     * 
     * @event salep#success
     * @type {Case}
     */
    emit("success", testCase);
  }

  function skip(testOrCase) {
    if (testOrCase instanceof Case) {
      skipCount++;    
      totalCount++;
    }
    /**
     * This event fires when a test or case has skipped.
     * 
     * @event salep#skip
     * @type {Test|Case}
     */
    emit("skip", testOrCase);
  }

  // Testing functionalities
  var level = 0;

  /**
   * @class
   * 
   * This class represents a test which has a name and function.
   * Test function runs in a Test object scope created with given name.
   * So when you use 'this' inside test funcion it doesn't represents
   * global object instead it points to test object. This provides you
   * to add properties to test inside test cases and access them when you
   * get results. 
   * 
   * @example
   * salep.test("A test", function() {
   *    salep.case("object creation with string", function() {
   *      test.serverStatus = getServerStatus();
   *      // Continue to case
   *    });
   * });
   * 
   * ...
   * 
   * var result = salep.getResults();
   * result.tests.forEach(function(test) {
   *   if (test.name === "A test") {
   *     console.log("Server status was '" + test.serverStatus + "' when test ran");
   *   }
   * });
   */
  function Test(params) {
    /**
     * Name of the test.
     * 
     * @property {string} name
     */
    this.name = "";

    /**
     * @desc
     * Indicates if test skipped or not. If a test is skipped all cases inside
     * test will not be counted in anywhere.
     * 
     * @type {boolean}
     */
    this.skipped = false;

    /**
     * @desc
     * Indicates nesting level of test. A test can have tests too, every nested
     * case will have +1 level of its parent test. Root tests, created using
     * salep.test, have level of 0.
     * 
     * @type {number}
     */
    this.level = level;

    /**
     * @desc
     * This property is cases array which hold all cases defined in test.
     * 
     * @type {Case[]}
     */
    this.cases = [];

    /**
     * @desc
     * This property holds all nested tests defined in current test. 
     * All nested tests will have +1 level of current test.
     * 
     * @type {Test[]}
     */
    this.tests = [];

    /**
     * @method
     * 
     * @desc
     * This function creates a new test inside current test scope with given name
     * and test function.
     * 
     * @param {String}    name  Name of the test
     * @param {Function}  func  Test function
     * 
     * @fires salep#testStart
     * @fires salep#skip
     * 
     * @example
     * salep.test('A test', function() {
     *   this.test('An inner test', function() {
     *     this.case('This case belongs to inner test', function() {
     *       // Case 
     *     });
     *   });
     * });
     * 
     */
    this.test = salep.test.bind(this);

    this.beforeEachCb = null;
    this.afterEachCb = null;

    if (params) for (var param in params) {
      if (this.hasOwnProperty(param)) {
        this[param] = params[param];
      }
    }
  }

  /**
   * @method
   * 
   * @desc
   * This function allows setting a callback that runs before each
   * case. With this functionality you can set up environment you
   * will use in cases. If before each callback fails (throws 
   * exception), it causes all cases to be counted as failed too.
   * Before each callback should be set before all case definitions.
   * 
   * @example
   * salep.test("A test", function() {
   *   var instance = null;
   *   this.beforeEach(function() {
   *     instance = new ClassToTest();
   *   });
   *   
   *   this.case("foo case", function() {
   *     // This instance created before case runs
   *     instance.foo();
   *   });
   * 
   *   this.case("bar case", function() {
   *     // This instance isn't the same instance with foo case's instance
   *     instance.bar();
   *   });
   * });
   * 
   */
  Test.prototype.beforeEach = function(beforeEachCb) {
    if (beforeEachCb instanceof Function) {
      this.beforeEachCb = beforeEachCb;
    }
  };

  /**
   * @method
   * 
   * @desc
   * This function allows setting a callback that runs after each case.
   * If after each callback fails (throws exception), it causes all cases
   * to be counted as failed too. After each callback should be set 
   * before all case definitons.
   * 
   * @example
   * salep.test("File test", function() {
   *   var filePath = "path/to/file";
   * 
   *   // After each case remove file
   *   this.afterEach(function() {
   *     removeFile(filePath);
   *   });
   *   
   *   this.case("write to file case", function() {
   *     // Assume below function creates file in filePath
   *     writeToFile(filePath, "Text");
   *   });
   * });
   * 
   */
  Test.prototype.afterEach = function(afterEachCb) {
    if (afterEachCb instanceof Function) {
      this.afterEachCb = afterEachCb;
    }
  }

  /**
   * @method
   * 
   * @desc
   * This function creates a new case inside current test scope with given name
   * and case function.
   * 
   * @param {String}    name  Name of the case
   * @param {Function}  func  Case function
   * 
   * @fires salep#caseStart
   * @fires salep#success
   * @fires salep#fail
   * @fires salep#skip
   * 
   * @example
   * salep.test('A test', function() {
   *   this.case('Should succeed', function() {
   *     // Case code
   *   });
   * });
   */
  Test.prototype.case = function(name, func) {
    var _case = new Case({
      name: name,
      parent: this,
      caseFunction: func
    });
    this.cases.push(_case);

    if (!salep.isRunning || skipNextEnabled) {
      _case.skipped = true;
      _case.success = false;
      skipNextEnabled = false;
    }
  }

  /**
   * @class
   * 
   * @desc
   * This class represents case written inside tests and salep scope.
   * For every case ran or skipped in salep, there is a case object created and
   * stored. Those case objects are accessible from results.
   * 
   * @example
   * salep.test("A test", function() {
   *   salep.case("Case 1", function() {
   *     // Continue to case
   *   });
   * });
   * 
   * ...
   * 
   * var result = salep.getResults();
   * result.tests.forEach(function(test) {
   *   test.cases.forEach(function(case) {
   *     if (case.success === false) {
   *       console.log("Case [" + case.name + "] failed, reason: " + case.reason);
   *     }
   *   });
   * });
   * 
   */
  function Case(params) {
    /**
     * @desc
     * Name of the case
     * 
     * @type {string}
     */
    this.name = "";

    /**
     * @desc
     * Success status of case, true if case succeeded false otherwise
     * 
     * @type {boolean}
     */
    this.success = false;

    /**
     * @desc
     * Indicates if case skipped or not.
     * 
     * @type {boolean}
     */
    this.skipped = false;

    this.level = level;

    /**
     * @desc
     * Declares the reason of failure if case is failed. If case is
     * succeded or skipped this property equals to empty string
     * 
     * @type {string}
     */
    this.reason = "";

    /**
     * @desc
     * Indicates the parent test of case.
     * 
     * @type {Test}
     */
    this.parent = null;

    this.caseFunction = null;

    if (params) for (var param in params) {
      if (this.hasOwnProperty(param)) {
        this[param] = params[param];
      }
    }
  }

  /**
   * @class
   * 
   * @desc
   * This class represents results of salep tests. It helps you
   * see summary of tests with fail, success, skip and total counts.
   * Result also has all tests and their case objects, so if it is
   * needed you can iterate on them and see which test have which
   * cases, which case failed and why, etc.
   */
  function Result(params) {
    /**
     * @desc
     * Indicates number of successful cases
     * 
     * @type {number}
     */
    this.success = 0;
    /**
     * @desc
     * Indicates number of failed cases
     * 
     * @type {number}
     */
    this.fail = 0;
    /**
     * @desc
     * Indicates number of skipped cases
     * 
     * @type {number}
     */
    this.skip = 0;
    /**
     * @desc
     * Indicates number of total cases
     * 
     * @type {number}
     */
    this.total = 0;
    /**
     * @desc
     * This property holds all tests written in salep scope.
     * Nested tests written inside tests will be nested. Just salep.test's
     * are listed here. 
     * 
     * @type {Test[]}
     */
    this.tests = null;
    /**
     * @desc
     * This property holds all test cases written in salep scope. Cases
     * inside tests is not listed inside this property. Just salep.case's
     * are listed here.
     * 
     * @type {Case[]}
     */
    this.cases = null;

    if (params) for (var param in params) {
      if (this.hasOwnProperty(param)) {
        this[param] = params[param];
      }
    }
  }

  /**
   * @property reporter
   * @memberof salep
   * 
   * @desc
   * Reporter object enables you track status of tests. It basically
   * fires an event with formatted report message for each lifecycle events 
   * (testStart, caseStart, success etc.). You can use report message
   * by logging to console or file wherever appropiate. Since console.log
   * may not be available in global scope reporter doesn't log reports.
   * 
   * @example
   * salep.on("report", function(message) {
   *   console.log(message);
   * });
   * salep.reporter.running = true;
   */
  salep.reporter = new function Reporter() {
    /**
     * This event fires if reporter running status set to true
     * and a lifecycle event occured. It sends formatted report
     * message as argument.
     * 
     * @event salep#report
     * @type {string}
     */

    const PADDING_SIZE = 3;    

    var _running = true;
    /**
     * @desc
     * Sets/gets running status of reporter. It is set to true by default.
     * For better performance, if you don't need reports, it is advised to 
     * set running status to false.
     * 
     * @type {boolean}
     */
    Object.defineProperty(this, 'running', {
      get: function() {
        return _running;
      },
      set: function(value) {
        if (value === _running) {
          return;
        } else {
          _running = value;
          notify();
        }
      }
    });

    function notify() {
      if (_running) {
        salep.on("testStart", reportTestStart);
        salep.on("caseStart", reportCaseStart);
        salep.on("success", reportSuccess);
        salep.on("fail", reportFail);
        salep.on("skip", reportSkip);
      } else {
        salep.off("testStart", reportTestStart);
        salep.off("caseStart", reportCaseStart);
        salep.off("success", reportSuccess);
        salep.off("fail", reportFail);
        salep.off("skip", reportSkip);
      }
    }

    function reportTestStart(_test) {
      var padding = leftPad(_test.level)
      emit("report", "");
      emit("report", "");
      emit("report", padding + "Test [" + _test.name + "] started");
    }

    function reportCaseStart(_case) {
      if (_case.parent) {
        var padding = leftPad(_case.parent.level + 1);
        emit("report", padding + "Case [" + _case.name + "] started");
      } else {
        emit("report", "");
        emit("report", "Case [" + _case.name + "]");
      }
    }

    function reportSuccess(_case) {
      if (_case.parent) {
        var padding = leftPad(_case.parent.level + 2);
        emit("report", padding + "+ Succeded");
      } else {
        emit("report", "   + Succeeded");
      }
    }

    function reportFail(_case) {
      if (_case.parent) {
        var padding = leftPad(_case.parent.level + 2);
        emit("report", padding + "X Failed: " + _case.reason);
      } else {
        emit("report", "   X Failed: " + _case.reason);
      }
    }

    function reportSkip(testOrCase) {
      if (testOrCase instanceof Test) {    
        emit("report", "");
        var padding = leftPad(testOrCase.level);
        emit("report", padding + "Test [" + testOrCase.name + "]");
        emit("report", padding + "   O Skipped");
      } else {
        var padding = "   ";
        if (testOrCase.parent) {
          padding += leftPad(testOrCase.parent.level);
        } 
        emit("report", padding + "Case [" + testOrCase.name + "]");
        emit("report", padding + "   O Skipped");
      }
    }

    function leftPad(count) {
      var result = "";
      for (var i = 0; i < count * PADDING_SIZE; i++) {
        result += " ";
      }
      return result;
    }

    notify();
  };
// })();