chromium/extensions/renderer/resources/test_custom_bindings.js

// Copyright 2014 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

// test_custom_bindings.js
// mini-framework for ExtensionApiTest browser tests

const environmentSpecificBindings =
    require('test_environment_specific_bindings');
const GetExtensionAPIDefinitionsForTest =
    requireNative('apiDefinitions').GetExtensionAPIDefinitionsForTest;
const GetAPIFeatures = requireNative('test_features').GetAPIFeatures;
const userGestures = requireNative('user_gestures');

const GetModuleSystem = requireNative('v8_context').GetModuleSystem;

function handleException(message, error) {
  bindingUtil.handleException(message || 'Unknown error', error);
}

apiBridge.registerCustomHook(function(api) {
  const kFailureException = 'chrome.test.failure';

  const chromeTest = api.compiledApi;
  const apiFunctions = api.apiFunctions;

  chromeTest.tests = chromeTest.tests || [];

  let currentTest = null;
  let lastTest = null;
  let testsFailed = 0;
  let testCount = 1;
  let pendingCallbacks = 0;
  let pendingPromiseRejections = 0;

  function safeFunctionApply(func, args) {
    try {
      if (func)
        return $Function.apply(func, undefined, args);
    } catch (e) {
      if (e === kFailureException)
        throw e;
      handleException(e.message, e);
    }
  }

  function runNextTest() {
    // There may have been callbacks or promise rejections which were
    // interrupted by failure exceptions.
    pendingCallbacks = 0;
    pendingPromiseRejections = 0;

    lastTest = currentTest;
    currentTest = $Array.shift(chromeTest.tests);

    if (!currentTest) {
      allTestsDone();
      return;
    }

    try {
      chromeTest.log(`( RUN      ) ${testName(currentTest)}`);
      bindingUtil.setExceptionHandler(function(message, e) {
        if (e !== kFailureException)
          chromeTest.fail(`uncaught exception: ${message}`);
      });
      const result = $Function.call(currentTest);
      if (result instanceof Promise) {
        result.catch(e => handleException(e.message, e));
      }
    } catch (e) {
      handleException(e.message, e);
    }
  }

  // Helper function to get around the fact that function names in javascript
  // are read-only, and you can't assign one to anonymous functions.
  function testName(test) {
    return test ? (test.name || test.generatedName) : '(no test)';
  }

  function testDone() {
    environmentSpecificBindings.testDone(runNextTest);
  }

  function allTestsDone() {
    if (testsFailed == 0) {
      chromeTest.notifyPass();
    } else {
      chromeTest.notifyFail(`Failed ${testsFailed} of ${testCount} tests`);
    }
  }

  // Helper function for boolean asserts. Compares |test| to |expected|.
  function assertBool(test, expected, message) {
    if (test !== expected) {
      if (typeof test == 'string') {
        if (message)
          message = `${test}\n${message}`;
        else
          message = test;
      }
      chromeTest.fail(message);
    }
  }

  apiFunctions.setHandleRequest('callbackAdded', function() {
    pendingCallbacks++;

    let called = null;
    return function() {
      if (called != null) {
        const redundantPrefixLength = 'Error\n'.length;
        chromeTest.fail(
          'Callback has already been run. ' +
          'First call:\n' +
          $String.slice(called, redundantPrefixLength) + '\n' +
          'Second call:\n' +
          $String.slice(new Error().stack, redundantPrefixLength));
      }
      called = new Error().stack;

      pendingCallbacks--;
      if (pendingCallbacks == 0) {
        chromeTest.succeed();
      }
    };
  });

  apiFunctions.setHandleRequest('fail', function failHandler(message) {
    chromeTest.log(`(  FAILED  ) ${testName(currentTest)}`);

    let stack = {};
    // NOTE(devlin): captureStackTrace() populates a stack property of the
    // passed-in object with the stack trace. The second parameter (failHandler)
    // represents a function to serve as a relative point, and is removed from
    // the trace (so that everything doesn't include failHandler in the trace
    // itself). This (and other APIs) are documented here:
    // https://github.com/v8/v8/wiki/Stack%20Trace%20API. If we wanted to be
    // really fancy, there may be more sophisticated ways of doing this.
    Error.captureStackTrace(stack, failHandler);

    if (!message)
      message = 'FAIL (no message)';

    message += '\n' + stack.stack;
    console.log(`[FAIL] ${testName(currentTest)}: ${message}`);
    testsFailed++;
    testDone();

    // Interrupt the rest of the test.
    throw kFailureException;
  });

  apiFunctions.setHandleRequest('succeed', function() {
    chromeTest.assertEq(
        0, pendingPromiseRejections,
        'Test had pending promise rejections. This is likely the result of ' +
        'not waiting for the promise returned by `assertPromiseRejects()` to ' +
        'resolve. Instead, use `await assertPromiseRejects(...)` or ' +
        '`assertPromiseRejects(...).then(...).`.');
    console.log(`[SUCCESS] ${testName(currentTest)}`);
    chromeTest.log('(  SUCCESS )');
    testDone();
  });

  apiFunctions.setHandleRequest('getModuleSystem', function(context) {
    return GetModuleSystem(context);
  });

  apiFunctions.setHandleRequest('assertTrue', function(test, message) {
    assertBool(test, true, message);
  });

  apiFunctions.setHandleRequest('assertFalse', function(test, message) {
    assertBool(test, false, message);
  });

  apiFunctions.setHandleRequest('checkDeepEq', function(expected, actual) {
    if ((expected === null) != (actual === null))
      return false;

    if (expected === actual)
      return true;

    if (typeof expected !== typeof actual)
      return false;
    if ($Array.isArray(expected) !== $Array.isArray(actual))
      return false;

    // Handle the ArrayBuffer cases. Bail out in case of type mismatch, to
    // prevent the ArrayBuffer from being treated as an empty enumerable below.
    if ((actual instanceof ArrayBuffer) !== (expected instanceof ArrayBuffer))
      return false;
    if ((actual instanceof ArrayBuffer) && (expected instanceof ArrayBuffer)) {
      if (actual.byteLength != expected.byteLength)
        return false;
      let actualView = new Uint8Array(actual);
      let expectedView = new Uint8Array(expected);
      for (let i = 0; i < actualView.length; ++i) {
        if (actualView[i] != expectedView[i]) {
          return false;
        }
      }
      return true;
    }

    for (let p in actual) {
      if ($Object.hasOwnProperty(actual, p) &&
          !$Object.hasOwnProperty(expected, p)) {
        return false;
      }
    }
    for (let p in expected) {
      if ($Object.hasOwnProperty(expected, p) &&
          !$Object.hasOwnProperty(actual, p)) {
        return false;
      }
    }

    for (let p in expected) {
      let eq = true;
      switch (typeof expected[p]) {
        case 'object':
          eq = chromeTest.checkDeepEq(expected[p], actual[p]);
          break;
        case 'function':
          eq = (typeof actual[p] != 'undefined' &&
                expected[p].toString() == actual[p].toString());
          break;
        default:
          eq = expected[p] == actual[p] &&
               typeof expected[p] == typeof actual[p];
          break;
      }
      if (!eq)
        return false;
    }
    return true;
  });

  apiFunctions.setHandleRequest('assertEq',
                                function(expected, actual, message) {
    let errorMsg = 'API Test Error in ' + testName(currentTest);
    if (message)
      errorMsg += ': ' + message;
    if (typeof(expected) == 'object') {
      if (!chromeTest.checkDeepEq(expected, actual)) {
        errorMsg += '\nActual: ' + $JSON.stringify(actual) +
                    '\nExpected: ' + $JSON.stringify(expected);
        chromeTest.fail(errorMsg);
      }
      return;
    }
    if (expected != actual) {
      chromeTest.fail(
          `${errorMsg}\nActual: ${actual}\nExpected: ${expected}`);
    }
    if (typeof expected != typeof actual) {
      chromeTest.fail(`${errorMsg} (type mismatch)\nActual Type: ${
          typeof actual}\nExpected Type:${typeof expected}`);
    }
  });

  apiFunctions.setHandleRequest('assertNe',
                                function(expected, actual, message) {
    // Easy case: different types are always inequal.
    if (typeof expected != typeof actual)
      return;

    let errorMsg = 'API Test Error in ' + testName(currentTest);
    if (message)
      errorMsg += ': ' + message;

    if (typeof expected == 'object') {
      if (chromeTest.checkDeepEq(expected, actual)) {
        errorMsg += '\nExpected inequal values, but both are ' +
                    $JSON.stringify(expected);
        chromeTest.fail(errorMsg);
      }
      return;
    }

    if (expected == actual) {
      errorMsg += '\nExpected inequal values, but both are ' + expected;
      chromeTest.fail(errorMsg);
    }
  });

  apiFunctions.setHandleRequest('assertNoLastError', function() {
    if (chrome.runtime.lastError != undefined) {
      chromeTest.fail('lastError.message == ' +
                       chrome.runtime.lastError.message);
    }
  });

  apiFunctions.setHandleRequest('assertLastError', function(expectedError) {
    chromeTest.assertEq(typeof expectedError, 'string');
    chromeTest.assertTrue(
        chrome.runtime.lastError != undefined,
        `No lastError, but expected ${expectedError}`);
    chromeTest.assertEq(expectedError, chrome.runtime.lastError.message);
  });

  apiFunctions.setHandleRequest('assertThrows',
                                function(fn, self, args, message) {
    chromeTest.assertTrue(typeof fn == 'function');
    try {
      fn.apply(self, args);
      chromeTest.fail('Did not throw error: ' + fn);
    } catch (e) {
      if (e != kFailureException && message !== undefined) {
        if (message instanceof RegExp) {
          chromeTest.assertTrue(message.test(e.message),
                                e.message + ' should match ' + message)
        } else {
          chromeTest.assertEq(message, e.message);
        }
      }
    }
  });

  apiFunctions.setHandleRequest('loadScript', function(scriptUrl) {
    // Note: Importing scripts is different depending on if this script is
    // executing in a Service Worker context.
    const inServiceWorker = 'ServiceWorkerGlobalScope' in self;

    function createError(exception) {
      const errorStr = `Unable to load script: "${scriptUrl}"`;
      if (inServiceWorker) {
        return new Error(errorStr, {cause: exception});
      }
      return new Error(errorStr);
    }

    if (inServiceWorker) {
      try {
        importScripts(scriptUrl);
      } catch (e) {
        return Promise.reject(createError(e));
      }
      return Promise.resolve();
    }
    const script = document.createElement('script');
    const onScriptLoad = new Promise((resolve, reject) => {
      script.onload = resolve;
      function onError() {
        reject(createError());
      }
      script.onerror = onError;
    });
    script.src = scriptUrl;
    document.body.appendChild(script);
    return onScriptLoad;
  });

  apiFunctions.setHandleRequest('assertPromiseRejects',
                                function(promise, expectedMessage) {
    pendingPromiseRejections++;
    return promise.then(
        () => {
          pendingPromiseRejections--;
          chromeTest.assertTrue(pendingPromiseRejections >= 0,
                                'Negative pending promise rejection count!');
          chromeTest.fail(
              'Promise did not reject. Expected error: ' + expectedMessage);
        },
        (e) => {
          pendingPromiseRejections--;
          chromeTest.assertTrue(pendingPromiseRejections >= 0,
                                'Negative pending promise rejection count!');
          if (expectedMessage instanceof RegExp) {
            chromeTest.assertTrue(
                expectedMessage.test(e.toString()),
                `'${e.message}' should match '${expectedMessage}'`);
          } else {
            chromeTest.assertEq('string', typeof expectedMessage);
            chromeTest.assertEq(expectedMessage, e.toString());
          }
        });
  });

  // Wrapper for generating test functions, that takes care of calling
  // assertNoLastError() and (optionally) succeed() for you.
  apiFunctions.setHandleRequest('callback', function(func, expectedError) {
    if (func) {
      chromeTest.assertEq(typeof func, 'function');
    }
    const callbackCompleted = chromeTest.callbackAdded();

    return function() {
      if (expectedError == null) {
        chromeTest.assertNoLastError();
      } else {
        chromeTest.assertLastError(expectedError);
      }

      let result;
      if (func) {
        result = safeFunctionApply(func, arguments);
      }

      callbackCompleted();
      return result;
    };
  });

  apiFunctions.setHandleRequest('listenOnce', function(event, func) {
    const callbackCompleted = chromeTest.callbackAdded();
    const listener = function() {
      event.removeListener(listener);
      safeFunctionApply(func, arguments);
      callbackCompleted();
    };
    event.addListener(listener);
  });

  apiFunctions.setHandleRequest('listenForever', function(event, func) {
    const callbackCompleted = chromeTest.callbackAdded();

    const listener = function() {
      safeFunctionApply(func, arguments);
    };

    const done = function() {
      event.removeListener(listener);
      callbackCompleted();
    };

    event.addListener(listener);
    return done;
  });

  apiFunctions.setHandleRequest('callbackPass', function(func) {
    return chromeTest.callback(func);
  });

  apiFunctions.setHandleRequest('callbackFail', function(expectedError, func) {
    return chromeTest.callback(func, expectedError);
  });

  apiFunctions.setHandleRequest('runTests', function(tests) {
    chromeTest.tests = tests;
    testCount = chromeTest.tests.length;
    runNextTest();
  });

  apiFunctions.setHandleRequest('getApiDefinitions', function() {
    return GetExtensionAPIDefinitionsForTest();
  });

  apiFunctions.setHandleRequest('getApiFeatures', function() {
    return GetAPIFeatures();
  });

  apiFunctions.setHandleRequest('isProcessingUserGesture', function() {
    return userGestures.IsProcessingUserGesture();
  });

  apiFunctions.setHandleRequest('runWithUserGesture', function(callback) {
    chromeTest.assertEq(typeof(callback), 'function');
    return userGestures.RunWithUserGesture(callback);
  });

  apiFunctions.setHandleRequest('setExceptionHandler', function(callback) {
    chromeTest.assertEq(typeof(callback), 'function');
    bindingUtil.setExceptionHandler(callback);
  });

  environmentSpecificBindings.registerHooks(api);
});