jasmine/promiseUtils.js

/**
 * @copyright 2018 Tridium, Inc. All Rights Reserved.
 */

/*jshint browser: true *//* eslint-env browser */
define([ 'Promise',
        'nmodule/js/rc/asyncUtils/asyncUtils' ], function (
         Promise,
         asyncUtils) {
  
  'use strict';

  /**
   * API Status: **Private**
   *
   * Module with utility functions for running async Jasmine specs.
   *
   * @exports nmodule/js/rc/jasmine/promiseUtils
   */
  var promiseUtils = {},
      WAIT_FOR_TRUE_TIMEOUT = 5000,

      firstCallToExecutePromise = false,
      currentSpec,
      promiseAPIEnforced;
  
  function deferred() {
    var doResolve,
        doReject,
        // eslint-disable-next-line promise/avoid-new
        promise = new Promise(function (resolve, reject) {
          doResolve = resolve;
          doReject = reject;
        });
    return {
      resolve: function (val) {
        doResolve(val);
        return promise;
      },
      reject: function (err) {
        doReject(err);
        return promise;
      },
      promise: promise
    };
  }


////////////////////////////////////////////////////////////////
// Custom matcher support
////////////////////////////////////////////////////////////////

  function hasClass(cx, expected) {
    var actual = cx.actual;
    cx.message = function () {
      return 'Expected ' + (this.isNot ? 'no ' :  '') + 'class "' + expected +
        '" but class list was "' + actual[0].classList + '".';
    };
    return actual.hasClass(expected);
  }

  function isTag(cx, expected) {
    var actual = cx.actual,
        tagName = actual && String(actual.prop('tagName')).toLowerCase();

    cx.message = function () {
      return 'Expected tag name to be "' + expected + '" but was "' +
        tagName + '".';
    };

    return tagName === expected.toLowerCase();
  }

  /**
   * Workaround because :focus selector is wonky especially in PhantomJS:
   * https://github.com/ariya/phantomjs/issues/10427
   */
  function isFocused(cx) {
    var actual = cx.actual,
        elem = actual[0];

    cx.message = function () {
      return 'Expected element ' + actual.prop('tagName') +
        (this.isNot ? ' not' : '') + ' to have focus.';
    };

    return elem === elem.ownerDocument.activeElement;
  }

  function isTypeOneOf(cx, types) {
    var actual = cx.actual,
        type = actual && actual.prop('type'),
        i;

    cx.message = function () {
      return 'Expected input type ' + (cx.isNot ? 'not ' : '') +
        'to be one of (' + types.join() + '), but was "' + type + '".';
    };

    for (i = 0; i < types.length; i++) {
      if (type === types[i]) {
        return true;
      }
    }
  }

  function isEquivalentTo(cx, expected, message) {
    function encodeToString(val) {
      if (val && typeof val.getType === 'function') {
        var type = val.getType();
        if (type.isSimple()) {
          return val.encodeToString();
        } else if (type.isComplex()) {
          return String(val); //jasmine pretty-printing Complexes gets real hairy
        } else {
          return type + ' instance';
        }
      }

      return jasmine.pp(val);
    }

    var actual = cx.actual;
    var expectedString = encodeToString(expected);

    if (actual === null || actual === undefined || typeof actual.equivalent !== 'function') {
      cx.message = function () {
        var msg = 'Expected ' + actual + ' to be a Baja value equivalent to ' + expectedString + '.';
        return message ? msg + ' ' + message : msg;
      };
      return false;
    } else {
      cx.message = function () {
        var actualString = encodeToString(actual);

        var msg = 'Expected ' + actualString + (cx.isNot ? 'not ' : '') +
        ' to be equivalent to ' + expectedString + '.'  + ' ' + message;
        
        return message ? msg + ' ' + message : msg;
      };
      return actual.equivalent(expected);
    }
  }

  function isAnError(cx, expected) {
    var actual = cx.actual;

    if (!(actual instanceof Error)) {
      cx.message = function () {
        return 'Expected ' + actual + (cx.isNot ? 'not ' : '') +
          'to be an error.';
      };
      return false;
    }

    if (typeof expected === 'string') {
      cx.message = function () {
        return 'Expected ' + (cx.isNot ? 'not ' : '') + 'to get error ' +
          'message "' + expected + '" but was "' + actual.message + '".';
      };
      return actual.message === expected;
    }

    return true;
  }

  function expectTrigger(func, not, dom, event, args, callback) {
    var expectedHandlerArgs = args,
        actualHandlerArgs = [],
        triggered = 'not triggered',
        prom;

    if (!dom || !event) {
      throw new Error('dom and event arguments required for toTrigger()');
    }

    if (typeof func !== 'function') {
      throw new Error('must call toTrigger with a function');
    }

    dom.on(event, function (e) {
      triggered = 'triggered';
      actualHandlerArgs.push(Array.prototype.slice.call(arguments, 1, expectedHandlerArgs.length + 1));
    });

    try {
      prom = Promise.resolve(func());
    } catch (e) {
      prom = Promise.reject(e);
    }

    promiseUtils.executePromise(prom
      .finally(function () {
        var timeout;

        if (not) {
          timeout = expectedHandlerArgs[0];
          if (typeof timeout !== 'number') {
            timeout = 50;
          }
          waits(timeout);
          runs(function () {
            expect(triggered).toBe('not triggered');
          });
        } else {
          waitsFor(function () {
            return triggered === 'triggered';
          }, 1000, event + ' event to be triggered');
          runs(function () {
            expect(actualHandlerArgs).toContain(expectedHandlerArgs);
            if (callback) {
              callback();
            }
          });
        }
      }));
  }

  function monitorState(promise) {
    var prom = Promise.resolve(promise)
      .then(function (result) {
        prom.$state = 'resolved';
        return result;
      })
      .catch(function (err) {
        prom.$state = 'rejected';
        throw err;
      });
    return prom;
  }

  function failWithRejection(err) {
    if (err instanceof Error) {
      err = {
        message: err.message,
        stack: err.stack
      };
    }
    expect(err).toBe(undefined);
  }

  /**
   * Custom matchers for use in Jasmine specs. Add them to your tests by using
   * {@link module:nmodule/js/rc/jasmine/promiseUtils.addCustomMatchers}.
   *
   * (This namespace is for documentation purposes only.)
   *
   * @namespace
   * @alias CustomMatchers
   * @private
   */
  var customMatchers = {
    /**
     * Expect that the value is an `Error`, optionally matching the error
     * message.
     *
     * @param {Error} actual
     * @param {String} [expected] expected error string
     * @example
     *   var err = new Error('my message');
     *
     *   expect(err).toBeAnError(); //don't care what the message is
     *   expect(err).toBeAnError('my message');
     */
    toBeAnError: function (expected) {
      return isAnError(this, expected);
    },

    /**
     * Expect that the value is equivalent to another, using the BajaScript
     * `.equivalent()` function. The value must be a `baja.Object` for the
     * test to pass.
     *
     * @param {baja.Object} actual
     * @param {*} expected
     * @example
     *   var rt1 = baja.RelTime.make(12345),
     *       rt2 = baja.RelTime.make(12345);
     *   expect(rt1).toBeEquivalentTo(rt2);
     */
    toBeEquivalentTo: function (expected, message) {
      return isEquivalentTo(this, expected, message);
    },

    /**
     * Expect that the promise is completed with a `rejected` state.
     *
     * @param {Promise} actual
     * @example
     *   var prom = new Promise(function (resolve, reject) {
     *     afterAWhile(reject);
     *   });
     *   expect(prom).toBeRejected(); //jasmine will wait
     */
    toBeRejected: function () {
      var actual = monitorState(this.actual);

      promiseUtils.executePromise(actual
        .then(function () {
          expect('resolved').not.toBe('resolved');
        })
        .catch(function () {
        })
        .finally(function () {
          expect(actual.$state).toBe('rejected');
        }));

      return true;
    },

    /**
     * Expect that the promise is completed with a `rejected` state and an
     * expected value passed to the failure handler.
     *
     * @param {Promise} actual
     * @param {*} expected
     * @example
     *   var prom = new Promise(function (resolve, reject) {
     *     afterAWhile(function () {
     *       reject('sock mismatch');
     *     });
     *   });
     *   expect(prom).toBeRejectedWith('sock mismatch'); //jasmine will wait
     */
    toBeRejectedWith: function (expected) {
      var actual = monitorState(this.actual);

      promiseUtils.executePromise(actual
        .finally(function () {
          expect(actual.$state).toBe('rejected');
        })
        .catch(function (err) {
          if (expected instanceof Error) {
            expect(err).toEqual(jasmine.any(Error));
            expect(String(err)).toBe(String(expected));
          } else {
            expect(err).toEqual(expected);
          }
        }));

      return true;
    },

    /**
     * Expect that the promise is completed with a `resolved` state.
     *
     * @param {Promise} actual
     * @example
     *   var prom = new Promise(function (resolve, reject) {
     *     afterAWhile(resolve);
     *   });
     *   expect(prom).toBeResolved(); //jasmine will wait
     */
    toBeResolved: function () {
      var actual = monitorState(this.actual);

      promiseUtils.executePromise(actual
        .catch(failWithRejection)
        .finally(function () {
          expect(actual.$state).toBe('resolved');
        }));

      return true;
    },

    /**
     * Expect that the promise is completed with a `resolved` state and an
     * expected value passed to the success handler.
     *
     * If the value is a `baja.Object` then the comparison will be done with
     * `toBeEquivalentTo()`, otherwise the standard Jasmine `toEqual()`.
     *
     * @param {Promise} actual
     * @param {*} expected
     * @example
     *   var prom = new Promise(function (resolve, reject) {
     *     afterAWhile(function () {
     *       df.resolve('socks valid');
     *     });
     *   });
     *   expect(prom).toBeResolvedWith('socks valid'); //jasmine will wait
     */
    toBeResolvedWith: function (expected) {
      var actual = monitorState(this.actual);

      promiseUtils.executePromise(actual
        .then(function (result) {
          if (result && typeof result.equivalent === 'function') {
            expect(result).toBeEquivalentTo(expected);
          } else {
            expect(result).toEqual(expected);
          }
        })
        .catch(failWithRejection)
        .finally(function () {
          expect(actual.$state).toBe('resolved');
        }));

      return true;
    },

    /**
     * Expect that the jQuery element has the given CSS class.
     *
     * @param {jQuery} actual
     * @param {String} expected CSS class name
     * @example
     *   var elem = $('&lt;div class="socks shoes"/&gt;');
     *   expect(elem).toHaveClass('socks');
     */
    toHaveClass: function (expected) {
      return hasClass(this, expected);
    },

    /**
     * Expect that the jQuery element has the given tag name.
     *
     * @param {jQuery} actual
     * @param {String} expected HTML tag name (case insensitive)
     * @example
     *   var elem = $('&lt;table/&gt;');
     *   expect(elem).toBeTag('table');
     */
    toBeTag: function (expected) {
      return isTag(this, expected);
    },

    /**
     * Expect that the jQuery element is an `input` tag, and has the given
     * `type` attribute (note that in browsers that do not support the new
     * HTML5 input types, this will fall back to `text` so that the tests
     * continue to pass).
     *
     * @param {jQuery} actual
     * @param {String} expected input `type` attribute
     * @example
     *   var input = $('&lt;input type="date"/&gt;');
     *   expect(input).toBeInputType('date');
     */
    toBeInputType: function (expected) {
      var types = expected === 'text' ? [ 'text' ] : [ expected, 'text' ];
      return isTag(this, 'input') && isTypeOneOf(this, types);
    },

    /**
     * Expect that the jQuery element currently has focus.
     *
     * @param {jQuery} actual
     * @example
     *   var myInput = $('#myInput');
     *   myInput.focus();
     *   expect(myInput).toHaveFocus();
     */
    toHaveFocus: function () {
      return isFocused(this);
    },

    /**
     * Verifies that a function causes a certain event to be triggered on a
     * DOM element.
     *
     * @param {Function} actual the function that will be called
     * @param {jQuery} dom the DOM element on which to listen for events
     * @param {String} event the name of the event to listen for
     * @example
     *   <caption>Just check that the event was triggered.</caption>
     *   var dom = $('&lt;div/&gt;');
     *   function trigger() { dom.trigger('helloEvent'); }
     *   expect(trigger).toTrigger(dom, 'helloEvent');
     *
     * @example
     *   <caption>You can also check for any additional arguments to be passed.
     *   </caption>
     *
     *   var dom = $('&lt;div/&gt;');
     *   function trigger() { dom.trigger('helloEvent', 'this too'); }
     *   expect(trigger).toTrigger(dom, 'helloEvent', 'this too');
     *
     * @example
     *   <caption>It also works with functions that return a promise. (But not
     *   with a promise passed directly, since you can't tell if it will work
     *   synchronously or asynchronously until called. Thanks jQuery!)</caption>
     *
     *   var dom = $('&lt;div/&gt;');
     *   function trigger() {
     *     return $.Deferred().resolve().then(function () {
     *       dom.trigger('helloEvent');
     *     });
     *   }
     *   expect(trigger).toTrigger(dom, 'helloEvent');
     *
     * @example
     *   <caption>Ensure that a certain event is *not* triggered. By default
     *   it will wait 50 ms without a trigger before passing the test. To
     *   wait a different amount of time just pass it as the third argument.
     *   </caption>
     *
     *   var dom = $('&lt;div/&gt;');
     *   function trigger() { dom.trigger('helloEvent', 'this too'); }
     *   //just wait 25ms before passing the test.
     *   expect(trigger).not.toTrigger(dom, 'someOtherEvent', 25);
     */
    toTrigger: function (dom, event) {
      var func = this.actual,
          not = this.isNot,
          args = Array.prototype.slice.call(arguments, 2);

      expectTrigger(func, not, dom, event, args);

      return !not;
    },

    /**
     * Works exactly the same as `toTrigger`, but verifies that the event
     * triggers exactly once. Useful for verifying that events bubbling up from
     * child elements get appropriately caught and swallowed up by a delegate
     * handler.
     */
    toTriggerOne: function (dom, event) {
      var func = this.actual,
          not = this.isNot,
          args = Array.prototype.slice.call(arguments, 2),
          count = 0;

      dom.on(event, function () {
        count++;
      });

      expectTrigger(func, not, dom, event, args, function () {
        expect(count).toBe(1);
      });

      return !not;
    }
  };


////////////////////////////////////////////////////////////////
// Module exports
////////////////////////////////////////////////////////////////

  /**
   * Adds {@link CustomMatchers|custom matchers} to the Jasmine instance
   * (what is bound to `this` in a `beforeEach` function, for instance).
   *
   * @see CustomMatchers
   * @param [spec] The current Jasmine spec; if not given,
   * `jasmine.getEnv().currentSpec` will be used
   * @example
   *   beforeEach(function () {
   *     promiseUtils.addCustomMatchers(this);
   *   });
   *   //or
   *   beforeEach(promiseUtils.addCustomMatchers);
   */
  promiseUtils.addCustomMatchers = function addCustomMatchers(spec) {
    (spec || jasmine.getEnv().currentSpec).addMatchers(customMatchers);

    jasmine.Spy.prototype.andResolve = function (value) {
      this.plan = function () { return Promise.resolve(value); };
      return this;
    };

    jasmine.Spy.prototype.andReject = function () {
      return this.andRejectWith(new Error());
    };

    jasmine.Spy.prototype.andRejectWith = function (err) {
      var error = err instanceof Error ? err : new Error(err);
      this.plan = function () { return Promise.reject(error); };
      return this;
    };
  };

  /**
   * Runs a promise, using the Jasmine `runs/waitsFor` functions to ensure its
   * completion. This method only cares that the promise is settled (resolved
   * or rejected) - if you wish to assert that the promise resolves
   * successfully, use `doPromise` instead.
   *
   * @param {Promise} promise
   * @param {String} [timeoutMessage] optional message to present if timeout occurs
   * @param {Number} [timeout=5000] optional timeout in milliseconds
   * @returns {Promise} promise that may be resolved or rejected
   */
  promiseUtils.executePromise = function executePromise(promise, timeoutMessage, timeout) {
    var done,
        result,
        fail = false,
        df = deferred();

    if (promiseAPIEnforced) {
      if (!firstCallToExecutePromise || jasmine.getEnv().currentSpec !== currentSpec) {
        throw new Error('Multiple calls to executePromise/doPromise/' +
          'toBeResolved/toBeRejected in a single call to ' +
          'it/beforeEach/afterEach');
      }
    }

    firstCallToExecutePromise = false;

    if (!promise || (typeof promise.then !== 'function')) {
      return df.reject('must call executePromise with a promise instance');
    }

    runs(function () {
      promise
        .then(function (r) {
          done = true;
          result = r;
        }, function (err) {
          done = true;
          fail = true;
          result = err;
        });
    });

    waitsFor(function () {
      return done;
    }, timeoutMessage, timeout);

    runs(function () {
      if (fail) {
        df.reject(result);
      } else {
        df.resolve(result);
      }
    });

    return df.promise;
  };

  /**
   * Run a promise, using `setTimeout` to check for the truthiness of the
   * condition function. This will not use `waitsFor/runs` and as such can be
   * used in conjunction with `doPromise/executePromise`.
   *
   * @param {Function} func resolve the promise when this function returns a
   * truthy value, or a promise that resolves to a truthy value
   * @param {String} [msg] the message to reject with upon timeout
   * @param {Number} [timeout] the time, in milliseconds, after which to give
   * up waiting and reject
   * @returns {Promise}
   */
  promiseUtils.waitForTrue = function (func, msg, timeout) {
    timeout = timeout || WAIT_FOR_TRUE_TIMEOUT;
    return asyncUtils.waitForTrue(func, timeout)
      .catch(function () {
        throw new Error('timed out after ' + timeout + ' msec waiting for ' +
          (msg || 'something to happen'));
      });
  };

  /**
   * Return a promise that waits a certain number of milliseconds before
   * resolving. This will not use `waitsFor/runs` and as such can be
   * used in conjunction with `doPromise/executePromise`.
   *
   * @param {Number} [interval=0]
   * @returns {Promise}
   */
  promiseUtils.waitInterval = function (interval) {
    // eslint-disable-next-line promise/avoid-new
    return new Promise(function (resolve, reject) {
      setTimeout(resolve, interval || 0);
    });
  };

  /**
   * Return a promise that resolves after the given Jasmine spy has been called
   * the specified number of times.
   *
   * @param {Function} func a Jasmine spy
   * @param {Number} [times=1] the number of times to expect the function to
   * have been called. Defaults to 1.
   * @param {Number} [timeout=5000] the time, in milliseconds, after which to give
   * up waiting and reject.
   * @returns {Promise}
   */
  promiseUtils.waitForCalled = function (func, times, timeout) {
    times = times || 1;
    timeout = timeout || 5000;
    var msg = (func.identity || 'spy') + ' to be called ' + times +
      (times === 1 ? ' time' : ' times');

    return promiseUtils.waitForTrue(function () {
      return func.callCount >= times;
    }, msg, timeout);
  };

  /**
   * Runs a promise, using the Jasmine `runs/waitsFor` functions to ensure its
   * completion. This function will verify that the promise is resolved -
   * failing the promise will fail the test.
   *
   * @param {Promise} promise
   * @param {String} [timeoutMessage] optional message to present if timeout occurs
   * @param {Number} [timeout=5000] optional timeout in milliseconds
   * @returns {Promise} promise that is verified to have been resolved
   * (if the input promise rejects, the test will fail).
   * @example
   *   promiseUtils.doPromise(editor.read()
   *     .then(function (result) {
   *       expect(result).toBe('my expected read value');
   *     }, function (err) {
   *       //not necessary to assert anything here - failing the promise will
   *       //automatically fail the test.
   *       //if you want to verify fail behavior, use toBeRejected() or
   *       //toBeRejectedWith() custom matchers.
   *     }));
   */
  promiseUtils.doPromise = function doPromise(promise, timeoutMessage, timeout) {
    promise = monitorState(promise);
    var prom = promiseUtils.executePromise(promise
        .finally(function () {
          expect(promise.$state).toBe('resolved');
        })
        .catch(failWithRejection), timeoutMessage, timeout);

    if (promiseAPIEnforced) {
      prom.then = function () {
        throw new Error('cannot call .then() on result of doPromise ' +
          '(double check your parentheses!)');
      };
    }

    return prom;
  };
  
  var origExecute = jasmine.Block.prototype.execute;

  /**
   * Ensure that the following contract is followed when using `doPromise` and
   * `executePromise`:
   *
   * - You may not call `doPromise()` or `executePromise()` more than once
   *   during a single call to `it()`, `beforeEach()`, or `afterEach()`.
   * - You may not call `.then()` on the result of `doPromise()`.
   * - You may not nest one call to `doPromise()` inside of another.
   *
   * This ensures that `doPromise()` works correctly with the `runs/waits` async
   * API presented by Jasmine 1.3.
   */
  promiseUtils.enforcePromiseAPI = function () {
    if (promiseAPIEnforced) { return; }

    var _execute = jasmine.Block.prototype.execute;

    jasmine.Block.prototype.execute = function (description, func) {
      currentSpec = jasmine.getEnv().currentSpec;
      firstCallToExecutePromise = true;
      var result = _execute.apply(this, arguments);
      currentSpec = null;
      return result;
    };

    promiseAPIEnforced = true;
  };
  
  var waitForReturnedPromisesCalled;
  
  /**
   * Alters the default behavior of `it()`. If a `Promise` is returned from an
   * `it()` call, Jasmine will wait for that promise to resolve (up to the
   * default timeout) before completing the spec.
   * 
   * @example
   * promiseUtils.waitForReturnedPromises();
   * 
   * it('waits for a returned promise to resolve', function () {
   *   return promiseUtils.waitInterval(1000)
   *     .then(function () {
   *       expect('a').toBe('b'); //correctly fails, because Jasmine waited
   *     });
   * });
   */
  promiseUtils.waitForReturnedPromises = function () {
    if (waitForReturnedPromisesCalled) { return; }
    waitForReturnedPromisesCalled = true;
    
    var Block = jasmine.Block,
        execute = Block.prototype.execute;

    Block.prototype.execute = function () {
      var func = this.func;
      this.func = function () {
        var result = func.apply(this, arguments);
        if (typeof (result && result.then) === 'function') {
          promiseUtils.doPromise(result);
        }
        return result;
      };
      return execute.apply(this, arguments);
    };
  };

  /**
   * By default, promiseUtils augments Jasmine block execution to support
   * returning promises from blocks and validating manual calls to 
   * `doPromise`, `executePromise`, and promise matchers.
   * 
   * Call this to restore original Jasmine block execution. There will not
   * typically be a reason to call this in practice, but it is provided just in
   * case.
   */
  promiseUtils.noConflict = function () {
    jasmine.Block.prototype.execute = origExecute;
    waitForReturnedPromisesCalled = false;
    promiseAPIEnforced = false;
  };
  
  promiseUtils.waitForReturnedPromises();
  promiseUtils.enforcePromiseAPI();

  return promiseUtils;
});