chromium/third_party/google-closure-library/closure/goog/labs/testing/environment.js

/**
 * @license
 * Copyright The Closure Library Authors.
 * SPDX-License-Identifier: Apache-2.0
 */

goog.module('goog.labs.testing.Environment');
goog.module.declareLegacyNamespace();

const DebugConsole = goog.require('goog.debug.Console');
const MockClock = goog.require('goog.testing.MockClock');
const MockControl = goog.require('goog.testing.MockControl');
const PropertyReplacer = goog.require('goog.testing.PropertyReplacer');
const TestCase = goog.require('goog.testing.TestCase');
const Thenable = goog.require('goog.Thenable');
const asserts = goog.require('goog.asserts');

/** @suppress {extraRequire} Declares globals */
goog.require('goog.testing.jsunit');


/**
 * JsUnit environments allow developers to customize the existing testing
 * lifecycle by hitching additional setUp and tearDown behaviors to tests.
 *
 * Environments will run their setUp steps in the order in which they
 * are instantiated and registered. During tearDown, the environments will
 * unwind the setUp and execute in reverse order.
 *
 * See http://go/jsunit-env for more information.
 */
class Environment {
  constructor() {
    // Use the same EnvironmentTestCase instance across all Environment objects.
    if (!Environment.activeTestCase_) {
      const testcase = new EnvironmentTestCase();

      Environment.activeTestCase_ = testcase;
    }
    Environment.activeTestCase_.registerEnvironment_(this);

    /**
     * Mocks are not type-checkable. To reduce burden on tests that are type
     * checked, this is typed as "?" to turn off JSCompiler checking.
     * TODO(user): Enable a type-checked mocking library.
     * @type {?}
     */
    this.mockControl = null;

    /** @type {?MockClock} */
    this.mockClock = null;

    /** @private {boolean} */
    this.shouldMakeMockControl_ = false;

    /** @protected {boolean} */
    this.mockClockOn = false;

    /** @const {!DebugConsole} */
    this.console = Environment.console_;

    /** @const {!PropertyReplacer} */
    this.replacer = new PropertyReplacer();
  }

  /**
   * Runs immediately before the setUpPage phase of JsUnit tests.
   * @return {!IThenable<*>|undefined} An optional Promise which must be
   *     resolved before the test is executed.
   */
  setUpPage() {
    if (this.mockClockOn && !this.hasMockClock()) {
      this.mockClock = new MockClock(true);
    }
  }

  /** Runs immediately after the tearDownPage phase of JsUnit tests. */
  tearDownPage() {
    // If we created the mockClock, we'll also dispose it.
    if (this.hasMockClock()) {
      this.mockClock.dispose();
    }
  }

  /**
   * Runs immediately before the setUp phase of JsUnit tests.
   * @return {!IThenable<*>|undefined} An optional Promise which must be
   *     resolved before the test case is executed.
   */
  setUp() {}

  /**
   * Runs immediately after the tearDown phase of JsUnit tests.
   * @return {!IThenable<*>|undefined} An optional Promise which must be
   *     resolved before the next test case is executed.
   */
  tearDown() {
    // Make sure promises and other stuff that may still be scheduled,
    // get a
    // chance to run (and throw errors).
    if (this.mockClock) {
      for (let i = 0; i < 100; i++) {
        this.mockClock.tick(1000);
      }
      // If we created the mockClock, we'll also reset it.
      if (this.hasMockClock()) {
        this.mockClock.reset();
      }
    }
    // Reset all changes made by the PropertyReplacer.
    this.replacer.reset();
    // Make sure the user did not forget to call $replayAll & $verifyAll
    // in their test. This is a noop if they did. This is important
    // because:
    // - Engineers thinks that not all their tests need to replay and
    // verify.
    //   That lets tests sneak in that call mocks but never replay those
    //   calls.
    // - Then some well meaning maintenance engineer wants to update the
    // test
    //   with some new mock, adds a replayAll and BOOM the test fails
    //   because completely unrelated mocks now get replayed.
    if (this.mockControl) {
      try {
        this.mockControl.$verifyAll();
        this.mockControl.$replayAll();
        this.mockControl.$verifyAll();
      } finally {
        this.mockControl.$resetAll();
        if (this.shouldMakeMockControl_) {
          // If we created the mockControl, we'll also tear it down.
          this.mockControl.$tearDown();
        }
      }
    }
    // Verifying the mockControl may throw, so if cleanup needs to
    // happen, add it further up in the function.
  }

  /**
   * Create a new {@see MockControl} accessible via
   * `env.mockControl` for each test. If your test has more than one
   * testing environment, don't call this on more than one of them.
   * @return {!Environment} For chaining.
   */
  withMockControl() {
    if (!this.shouldMakeMockControl_) {
      this.shouldMakeMockControl_ = true;
      this.mockControl = new MockControl();
    }
    return this;
  }

  /**
   * Create a {@see MockClock} for each test. The clock will be
   * installed (override i.e. setTimeout) by default. It can be accessed
   * using `env.mockClock`. If your test has more than one testing
   * environment, don't call this on more than one of them.
   * @return {!Environment} For chaining.
   */
  withMockClock() {
    if (!this.hasMockClock()) {
      this.mockClockOn = true;
      this.mockClock = new MockClock(true);
    }
    return this;
  }

  /**
   * @return {boolean}
   * @protected
   */
  hasMockClock() {
    return this.mockClockOn && !!this.mockClock && !this.mockClock.isDisposed();
  }

  /**
   * Creates a basic strict mock of a `toMock`. For more advanced mocking,
   * please use the MockControl directly.
   * @param {?Function|?Object} toMock
   * @return {?}
   */
  mock(toMock) {
    if (!this.shouldMakeMockControl_) {
      throw new Error(
          'MockControl not available on this environment. ' +
          'Call withMockControl if this environment is expected ' +
          'to contain a MockControl.');
    }
    const mock = this.mockControl.createStrictMock(toMock);
    // Mocks are not type-checkable. To reduce burden on tests that are
    // type checked, this is typed as "?" to turn off JSCompiler
    // checking.
    // TODO(user): Enable a type-checked mocking library.
    return /** @type {?} */ (mock);
  }

  /**
   * Creates a basic loose mock of a `toMock`. For more advanced mocking,
   * please use the MockControl directly.
   * @param {?Function|?Object} toMock
   * @param {boolean=} ignoreUnexpectedCalls Defaults to false.
   * @return {?}
   */
  looseMock(toMock, ignoreUnexpectedCalls = false) {
    if (!this.shouldMakeMockControl_) {
      throw new Error(
          'MockControl not available on this environment. ' +
          'Call withMockControl if this environment is expected ' +
          'to contain a MockControl.');
    }
    const mock =
        this.mockControl.createLooseMock(toMock, ignoreUnexpectedCalls);
    // Mocks are not type-checkable. To reduce burden on tests that are type
    // checked, this is typed as "?" to turn off JSCompiler checking.
    // TODO(user): Enable a type-checked mocking library.
    return /** @type {?} */ (mock);
  }
}

/**
 * @private {?EnvironmentTestCase}
 */
Environment.activeTestCase_ = null;

// TODO(johnlenz): make this package private when it moves out of labs.
/**
 * @return {?TestCase}
 * @nocollapse
 */
Environment.getTestCaseIfActive = function() {
  return Environment.activeTestCase_;
};

/** @private @const {!DebugConsole} */
Environment.console_ = new DebugConsole();

// Activate logging to the browser's console by default.
Environment.console_.setCapturing(true);

/**
 * An internal TestCase used to hook environments into the JsUnit test runner.
 * Environments cannot be used in conjunction with custom TestCases for JsUnit.
 * @private @final @constructor
 * @extends {TestCase}
 */
function EnvironmentTestCase() {
  EnvironmentTestCase.base(this, 'constructor', document.title);

  /** @private {!Array<!Environment>}> */
  this.environments_ = [];

  /** @private {!Object} */
  this.testobj_ = goog.global;  // default

  // Automatically install this TestCase when any environment is used in a test.
  TestCase.initializeTestRunner(this);
}
goog.inherits(EnvironmentTestCase, TestCase);

/**
 * Override setLifecycleObj to allow incoming test object to provide only
 * runTests and shouldRunTests. The other lifecycle methods are controlled by
 * this environment.
 * @param {!Object} obj
 * @override
 */
EnvironmentTestCase.prototype.setLifecycleObj = function(obj) {
  asserts.assert(
      this.testobj_ == goog.global,
      'A test method object has already been provided ' +
          'and only one is supported.');

  // Store the test object so we can call lifecyle methods when needed.
  this.testobj_ = obj;

  if (this.testobj_['runTests']) {
    this.runTests = this.testobj_['runTests'].bind(this.testobj_);
  }
  if (this.testobj_['shouldRunTests']) {
    this.shouldRunTests = this.testobj_['shouldRunTests'].bind(this.testobj_);
  }
};

/**
 * @override
 * @return {!TestCase.Test}
 */
EnvironmentTestCase.prototype.createTest = function(
    name, ref, scope, objChain) {
  return new EnvironmentTest(name, ref, scope, objChain);
};

/**
 * Adds an environment to the JsUnit test.
 * @param {!Environment} env
 * @private
 */
EnvironmentTestCase.prototype.registerEnvironment_ = function(env) {
  this.environments_.push(env);
};

/**
 * @override
 * @return {!IThenable<*>|undefined}
 */
EnvironmentTestCase.prototype.setUpPage = function() {
  const setUpPageFns = this.environments_.map(env => {
    return () => env.setUpPage();
  });

  // User defined setUpPage method.
  if (this.testobj_['setUpPage']) {
    setUpPageFns.push(() => this.testobj_['setUpPage']());
  }
  return this.callAndChainPromises_(setUpPageFns);
};

/**
 * @override
 * @return {!IThenable<*>|undefined}
 */
EnvironmentTestCase.prototype.setUp = function() {
  const setUpFns = [];
  // User defined configure method.
  if (this.testobj_['configureEnvironment']) {
    setUpFns.push(() => this.testobj_['configureEnvironment']());
  }
  const test = this.getCurrentTest();
  if (test instanceof EnvironmentTest) {
    setUpFns.push(...test.configureEnvironments);
  }

  this.environments_.forEach(env => {
    setUpFns.push(() => env.setUp());
  }, this);

  // User defined setUp method.
  if (this.testobj_['setUp']) {
    setUpFns.push(() => this.testobj_['setUp']());
  }
  return this.callAndChainPromises_(setUpFns);
};

/**
 * Calls a chain of methods and makes sure to properly chain them if any of the
 * methods returns a thenable.
 * @param {!Array<function()>} fns
 * @param {boolean=} ensureAllFnsCalled If true, this method calls each function
 *     even if one of them throws an Error or returns a rejected Promise. If
 *     there were any Errors thrown (or Promises rejected), the first Error will
 *     be rethrown after all of the functions are called.
 * @return {!IThenable<*>|undefined}
 * @private
 */
EnvironmentTestCase.prototype.callAndChainPromises_ = function(
    fns, ensureAllFnsCalled) {
  // Using await here (and making callAndChainPromises_ an async method)
  // causes many tests across google3 to start failing with errors like this:
  // "Timed out while waiting for a promise returned from setUp to resolve".

  const isThenable = (v) => Thenable.isImplementedBy(v) ||
      (typeof goog.global['Promise'] === 'function' &&
       v instanceof goog.global['Promise']);

  // Record the first error that occurs so that it can be rethrown in the case
  // where ensureAllFnsCalled is set.
  let firstError;
  const recordFirstError = (e) => {
    if (!firstError) {
      firstError = e instanceof Error ? e : new Error(e);
    }
  };

  // Call the fns, chaining results that are Promises.
  let lastFnResult;
  for (const fn of fns) {
    if (isThenable(lastFnResult)) {
      // The previous fn was async, so chain the next fn.
      const rejectedHandler = ensureAllFnsCalled ? (e) => {
        recordFirstError(e);
        return fn();
      } : undefined;
      lastFnResult = lastFnResult.then(() => fn(), rejectedHandler);
    } else {
      // The previous fn was not async, so simply call the next fn.
      try {
        lastFnResult = fn();
      } catch (e) {
        if (!ensureAllFnsCalled) {
          throw e;
        }
        recordFirstError(e);
      }
    }
  }

  // After all of the fns have been called, either throw the first error if
  // there was one, or otherwise return the result of the last fn.
  const resultFn = () => {
    if (firstError) {
      throw firstError;
    }
    return lastFnResult;
  };
  return isThenable(lastFnResult) ? lastFnResult.then(resultFn, resultFn) :
                                    resultFn();
};

/**
 * @override
 * @return {!IThenable<*>|undefined}
 */
EnvironmentTestCase.prototype.tearDown = function() {
  const tearDownFns = [];
  // User defined tearDown method.
  if (this.testobj_['tearDown']) {
    tearDownFns.push(() => this.testobj_['tearDown']());
  }

  // Execute the tearDown methods for the environment in the reverse order
  // in which they were registered to "unfold" the setUp.
  const reverseEnvironments = [...this.environments_].reverse();
  reverseEnvironments.forEach(env => {
    tearDownFns.push(() => env.tearDown());
  });
  // For tearDowns between tests make sure they run as much as possible to avoid
  // interference between tests.
  return this.callAndChainPromises_(
      tearDownFns, /* ensureAllFnsCalled= */ true);
};

/** @override */
EnvironmentTestCase.prototype.tearDownPage = function() {
  // User defined tearDownPage method.
  if (this.testobj_['tearDownPage']) {
    this.testobj_['tearDownPage']();
  }

  const reverseEnvironments = [...this.environments_].reverse();
  reverseEnvironments.forEach(env => {
    env.tearDownPage();
  });
};

/**
 * An internal Test used to hook environments into the JsUnit test runner.
 * @param {string} name The test name.
 * @param {function()} ref Reference to the test function or test object.
 * @param {?Object=} scope Optional scope that the test function should be
 *     called in.
 * @param {!Array<!Object>=} objChain A chain of objects used to populate setUps
 *     and tearDowns.
 * @private
 * @final
 * @constructor
 * @extends {TestCase.Test}
 */
function EnvironmentTest(name, ref, scope, objChain) {
  EnvironmentTest.base(this, 'constructor', name, ref, scope, objChain);

  /**
   * @type {!Array<function()>}
   */
  this.configureEnvironments =
      (objChain || [])
          .filter((obj) => typeof obj.configureEnvironment === 'function')
          .map(/**
                * @param  {{configureEnvironment: function()}} obj
                * @return {function()}
                */
               function(obj) {
                 return obj.configureEnvironment.bind(obj);
               });
}
goog.inherits(EnvironmentTest, TestCase.Test);

exports = Environment;