jasmine/promiseUtils.js

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

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

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

  const IS_TRACKING_RETURN_VALUES = Symbol('isTrackingReturnValues');
  // this doesn't seem to be a memory hog, but we can turn this off if needed.
  let alwaysTrackReturnValues = true;
  const logSevere = log.severe.bind(log);

  let 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 jq = cx.actual;
    cx.message = function () {
      let msg = 'Expected ' + (this.isNot ? 'no ' :  '') + 'class "' + expected + '" ';
      if (jq && jq[0]) {
        msg += 'but class list was "' + jq[0].classList + '".';
      } else {
        msg += 'but no DOM was found.';
      }
      return msg;
    };
    return jq && jq.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 'the ' + type.getTypeName() + ' "' + val.encodeToString() + '"';
        } else if (type.isComplex()) {
          return jasmine.pp(val);
        } 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);

        return 'Expected ' + actualString + (cx.isNot ? 'not ' : '') +
          ' to be equivalent to ' + expectedString + '.'  + (message ? ' ' + message : '');
      };
      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.)
   *
   * @mixin
   * @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
     * @param {string} [message]
     * @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 {module:nmodule/js/rc/jasmine/promiseUtils~PromiseResolutionParams} [params]
     * @example
     *   var prom = new Promise(function (resolve, reject) {
     *     afterAWhile(reject);
     *   });
     *   expect(prom).toBeRejected(); //jasmine will wait the default timeout
     *   expect(prom).toBeRejected({ within: 1000 }); //fail spec if it hasn't rejected in 1 second
     */
    toBeRejected: function (params) {
      var actual = monitorState(this.actual);

      // noinspection JSIgnoredPromiseFromCall
      jasmineWaitsForPromise(actual
        .then(function () {
          expect('resolved').not.toBe('resolved');
        })
        .catch(function () {
        })
        .finally(function () {
          expect(actual.$state).toBe('rejected');
        }), params);

      return true;
    },

    /**
     * Expect that the promise is completed with a `rejected` state and an
     * expected value passed to the failure handler.
     *
     * @param {*} expected
     * @param {module:nmodule/js/rc/jasmine/promiseUtils~PromiseResolutionParams} [params]
     * @example
     *   var prom = new Promise(function (resolve, reject) {
     *     afterAWhile(function () {
     *       reject(new Error('sock mismatch'));
     *     });
     *   });
     *   // jasmine will wait the default timeout
     *   expect(prom).toBeRejectedWith(new Error('sock mismatch'));
     *
     *   // fail spec if it hasn't rejected in 1 second
     *   expect(prom).toBeRejectedWith(new Error('sock mismatch'), { within: 1000 });
     */
    toBeRejectedWith: function (expected, params) {
      var actual = monitorState(this.actual);

      // noinspection JSIgnoredPromiseFromCall
      jasmineWaitsForPromise(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);
          }
        }), params);

      return true;
    },

    /**
     * Expect that the promise is completed with a `resolved` state.
     *
     * @param {module:nmodule/js/rc/jasmine/promiseUtils~PromiseResolutionParams} [params]
     * @example
     *   var prom = new Promise(function (resolve, reject) {
     *     afterAWhile(resolve);
     *   });
     *   expect(prom).toBeResolved(); //jasmine will wait the default timeout
     *   expect(prom).toBeResolved({ within: 1000 }); //fail spec if it hasn't resolved in 1 second
     */
    toBeResolved: function (params) {
      var actual = monitorState(this.actual);

      // noinspection JSIgnoredPromiseFromCall
      jasmineWaitsForPromise(actual
        .catch(failWithRejection)
        .finally(function () {
          expect(actual.$state).toBe('resolved');
        }), params);

      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 {*} expected
     * @param {module:nmodule/js/rc/jasmine/promiseUtils~PromiseResolutionParams} [params]
     * @example
     *   var prom = new Promise(function (resolve, reject) {
     *     afterAWhile(function () {
     *       df.resolve('socks valid');
     *     });
     *   });
     *   expect(prom).toBeResolvedWith('socks valid'); //jasmine will wait
     *   expect(prom).toBeResolvedWith('socks valid', { within: 1000 }); // fail spec if it hasn't
     *                                                                   // resolved within 1 second
     */
    toBeResolvedWith: function (expected, params) {
      var actual = monitorState(this.actual);

      // noinspection JSIgnoredPromiseFromCall
      jasmineWaitsForPromise(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');
        }), params);

      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 {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.
     *
     * @param {JQuery} dom
     * @param {JQuery.Event} event
     */
    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;
    }
  };

  // tell JSDoc to consider jasmine Matchers augmented by our custom matchers.

  /**
   * @member
   * @alias jasmine.Matchers#
   * @mixes CustomMatchers
   */
  /**
   * @member
   * @alias jasmine.Matchers#not
   * @mixes CustomMatchers
   */


////////////////////////////////////////////////////////////////
// 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;
    };
  };

  /**
   * @param {number} timeout wait this long (in ms) as the default promise timeout for just the
   * current spec, rather than switch all returns to doPromise etc.
   * @since Niagara 4.15
   */
  promiseUtils.useDefaultTimeoutForSpec = function (timeout) {
    spyOn(promiseUtils, '$getDefaultTimeout').andReturn(timeout);
  };

  /**
   * 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} [within=5000] optional timeout in milliseconds
   * @returns {Promise} promise that may be resolved or rejected
   */
  promiseUtils.executePromise = function executePromise(promise, timeoutMessage, within) {
    return jasmineWaitsForPromise(promise, { timeoutMessage, within });
  };

  /**
   * @typedef {object} module:nmodule/js/rc/jasmine/promiseUtils~PromiseResolutionParams
   * @property {number} [within] promise must settle within this many milliseconds
   * @property {string} [timeoutMessage] fail the spec with this message if the promise does not
   * settle in time
   */
  /**
   * @param {Promise} promise
   * @param {module:nmodule/js/rc/jasmine/promiseUtils~PromiseResolutionParams} [params]
   * @returns {Promise}
   */
  function jasmineWaitsForPromise(promise, { timeoutMessage, within = promiseUtils.$getDefaultTimeout() } = {}) {
    var done,
        result,
        fail = false,
        df = deferred();

    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, within);

    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 || promiseUtils.$getDefaultTimeout();
    return asyncUtils.waitForTrue(func, timeout)
      .catch(function (error) {
        let message = 'timed out after ' + timeout + ' msec waiting for ' +
          (msg || 'something to happen');
        if (error.toString().indexOf('timed out after') === -1) {
          logSevere('error during waitForTrue', error);
          message += " with errors while waiting " + error.toString();
        }

        throw new Error(message);
      });
  };

  /**
   * 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 || promiseUtils.$getDefaultTimeout();
    var msg = (func.identity || 'spy') + ' to be called ' + times +
      (times === 1 ? ' time' : ' times');

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

  /**
   * This will both wait for a spy function to be called, and for the promise it returns to be
   * resolved.
   *
   * The spy must be tracking return values already - use `.andTrackReturnValues()`. This is so the
   * matcher can monitor the resolution status of the returned promises.
   *
   * @param func
   * @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.<*|Array.<*>>} if `times` is omitted, this will resolve to the result
   * of the first call to the function. Otherwise, this will resolve to an array (of `times` length)
   * of that many calls to the function.
   */
  promiseUtils.waitForResolved = function (func, times, timeout) {
    if (!promiseUtils.isTrackingReturnValues(func)) {
      throw new Error('spy function tracking return values required (use .andTrackReturnValues)');
    }

    const df = deferred();
    let done;

    promiseUtils.waitForTrue(() => func.calls.length >= (times || 1))
      .then(() => {
        const calls = func.calls;
        if (times === undefined) {
          return calls[0].result;
        } else {
          return Promise.all(calls.map((call) => call.result));
        }
      })
      .then((r) => {
        done = true;
        df.resolve(r);
      }, df.reject);

    return promiseUtils.waitForTrue(
      () => done,
      func.identity + ' to be called and resolved ' + (times || 1) + ' times',
      timeout
    )
      .then(() => df.promise);
  };

  /**
   * 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 `.then()` on the result of `doPromise()`.
   *
   * This ensures that `doPromise()` works correctly with the `runs/waits` async
   * API presented by Jasmine 1.3.
   */
  promiseUtils.enforcePromiseAPI = function () {
    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);
    };
  };

  /**
   * Jasmine does not store return values by default. Patch it in so that each call object, in
   * addition to `object` and `args`, stores a `result` property.
   * @since Niagara 4.13
   */
  promiseUtils.trackSpyReturnValues = function () {
    jasmine.Spy.prototype.andTrackReturnValues = function () {
      // here, we need the that = this pattern because the plan must reference both this (the spy)
      // and this (the object on which the spy is called)
      const that = this;

      if (that[IS_TRACKING_RETURN_VALUES]) {
        return that;
      }

      that[IS_TRACKING_RETURN_VALUES] = true;

      let plan = that.plan;
      Object.defineProperty(that, 'plan', {
        enumerable: true,
        configurable: true,
        get() {
          return function () {
            const result = plan.apply(this, arguments);
            const calls = that.calls;
            calls[calls.length - 1].result = result;
            that.mostRecentCall.result = result;
            return result;
          };
        },
        set(p) {
          plan = p;
        }
      });

      return that;
    };

    const { createSpy } = jasmine;
    jasmine.createSpy = function () {
      const spy = createSpy.apply(this, arguments);
      if (alwaysTrackReturnValues) {
        spy.andTrackReturnValues();
      }
      return spy;
    };
  };

  /**
   * @param {function} spy
   * @returns {boolean} true if this is a spy function that is tracking return values
   * @since Niagara 4.13
   */
  promiseUtils.isTrackingReturnValues = function (spy) {
    return typeof spy === 'function' && !!spy[IS_TRACKING_RETURN_VALUES];
  };

  /**
   * @private
   * @param {boolean} track
   */
  promiseUtils.$alwaysTrackReturnValues = function (track) {
    alwaysTrackReturnValues = track;
  };

  /**
   * @private
   * @returns {number}
   */
  promiseUtils.$getDefaultTimeout = function () {
    return DEFAULT_TIMEOUT;
  };

  /**
   * 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;
  };

  /**
   * when doing expect(method).toHaveBeenCalledWith(component), jasmine JSON
   * stringifies the component to print the error. a component's JSON structure
   * is so huge that this will actually lock up the browser and kill tests. i
   * found myself having to do
   * expect(method.mostRecentCall.args[0] === component).toBe(true).
   * yuck. let's simplify the pretty printing a bit.
   */
  promiseUtils.prettyPrintBajaObjects = function () {
    var StringPrettyPrinter = jasmine.StringPrettyPrinter;
    var emitObject = StringPrettyPrinter.prototype.emitObject;
    StringPrettyPrinter.prototype.emitObject = function (obj, name) {
      if (require.defined('bajaux/Widget') && obj instanceof require('bajaux/Widget')) {
        this.append('bajaux Widget [' + obj.constructor.name + ']');
        return;
      }

      if (obj === null ||
        obj === undefined ||
        typeof obj.getType !== 'function' ||
        typeof obj.equivalent !== 'function') {

        //probably not a baja object
        return emitObject.call(this, obj);
      }

      const addSpacers = () => {
        for (let i = 0; i < this.$depth; ++i) { this.append(' '); }
      };

      this.$depth = this.$depth || 0;
      addSpacers();

      var type = obj.getType();
      if (type.is('baja:Complex')) {
        name = name || obj.getName() || '';
        var props = obj.getSlots().properties().toArray();
        this.append(name + '[' + type + ']');
        if (props.length) {
          this.append(': {');
          this.$depth += 2;
          props.forEach((slot) => {
            this.append('\n');
            this.emitObject(obj.get(slot), String(slot));
          });
          this.$depth -= 2;
          addSpacers();
          this.append('\n}');
        } else {
          this.append(' ');
        }
      } else if (type.is('baja:Simple')) {
        name = name || '';
        this.append(name + '[' + type + ']: {"' + obj.encodeToString() + '"} ');
      }
    };
  };

  promiseUtils.waitForReturnedPromises();
  promiseUtils.trackSpyReturnValues();
  promiseUtils.enforcePromiseAPI();
  promiseUtils.prettyPrintBajaObjects();

  return promiseUtils;
});