Add karma_web_test and karma_web_test_suite

Closes #363

PiperOrigin-RevId: 228963937
diff --git a/defs.bzl b/defs.bzl
index 3f4b0b3..4f68fa2 100644
--- a/defs.bzl
+++ b/defs.bzl
@@ -21,8 +21,13 @@
 load("//:package.bzl", "VERSION")
 load("//internal/devserver:ts_devserver.bzl", _ts_devserver = "ts_devserver_macro")
 load(
+    "//internal/karma:karma_web_test.bzl",
+    _karma_web_test = "karma_web_test",
+    _karma_web_test_suite = "karma_web_test_suite",
+)
+load(
     "//internal/karma:ts_web_test.bzl",
-    _ts_web_test = "ts_web_test_macro",
+    _ts_web_test = "ts_web_test",
     _ts_web_test_suite = "ts_web_test_suite",
 )
 load("//internal/protobufjs:ts_proto_library.bzl", _ts_proto_library = "ts_proto_library")
@@ -38,6 +43,8 @@
 # TODO(alexeagle): make ts_web_test && ts_web_test_suite work in google3
 ts_web_test = _ts_web_test
 ts_web_test_suite = _ts_web_test_suite
+karma_web_test = _karma_web_test
+karma_web_test_suite = _karma_web_test_suite
 ts_proto_library = _ts_proto_library
 # DO NOT ADD MORE rules here unless they appear in the generated docsite.
 # Run yarn skydoc to re-generate the docsite.
diff --git a/examples/testing/BUILD.bazel b/examples/testing/BUILD.bazel
index ee484a9..d2d7e28 100644
--- a/examples/testing/BUILD.bazel
+++ b/examples/testing/BUILD.bazel
@@ -1,4 +1,4 @@
-load("//internal:defaults.bzl", "ts_library", "ts_web_test_suite")
+load("//internal:defaults.bzl", "karma_web_test_suite", "ts_library", "ts_web_test_suite")
 
 ts_library(
     name = "lib",
@@ -28,6 +28,40 @@
     ],
 )
 
+karma_web_test_suite(
+    name = "testing_karma",
+    browsers = [
+        "@io_bazel_rules_webtesting//browsers:chromium-local",
+        "@io_bazel_rules_webtesting//browsers:firefox-local",
+    ],
+    config_file = ":karma.conf.js",
+    static_files = [
+        "static_script.js",
+    ],
+    runtime_deps = [
+        ":tests_setup",
+    ],
+    deps = [
+        ":tests",
+        "@npm//karma-json-result-reporter",
+    ],
+)
+
+karma_web_test_suite(
+    name = "testing_karma_sauce",
+    browsers = [
+        "@io_bazel_rules_webtesting//browsers/sauce:chrome-win10",
+    ],
+    tags = [
+        "sauce",
+        # TODO(alexeagle): enable on CI once we have set the SAUCE env variables
+        "manual",
+    ],
+    deps = [
+        ":tests",
+    ],
+)
+
 ts_web_test_suite(
     name = "testing",
     browsers = [
diff --git a/examples/testing/karma.conf.js b/examples/testing/karma.conf.js
new file mode 100644
index 0000000..f96e698
--- /dev/null
+++ b/examples/testing/karma.conf.js
@@ -0,0 +1,12 @@
+module.exports = function(config) {
+  config.set({
+    plugins: ['karma-json-result-reporter'],
+    reporters: ['dots', 'progress', 'json-result'],
+    logLevel: config.LOG_DEBUG,
+    colors: false,
+    jsonResultReporter: {
+      outputFile: `${process.env['TEST_UNDECLARED_OUTPUTS_DIR']}/karma-result.json`,
+      isSynchronous: true,
+    },
+  });
+}
diff --git a/internal/defaults.bzl b/internal/defaults.bzl
index ba51502..ca2f423 100644
--- a/internal/defaults.bzl
+++ b/internal/defaults.bzl
@@ -14,7 +14,14 @@
 
 "Defaults for rules_typescript repository not meant to be used downstream"
 
-load("@build_bazel_rules_typescript//:defs.bzl", _ts_library = "ts_library", _ts_web_test_suite = "ts_web_test_suite")
+load(
+    "@build_bazel_rules_typescript//:defs.bzl",
+    _karma_web_test = "karma_web_test",
+    _karma_web_test_suite = "karma_web_test_suite",
+    _ts_library = "ts_library",
+    _ts_web_test = "ts_web_test",
+    _ts_web_test_suite = "ts_web_test_suite",
+)
 
 # We can't use the defaults for ts_library compiler and ts_web_test_suite karma
 # internally because the defaults are .js dependencies on the npm packages that are
@@ -22,8 +29,17 @@
 INTERNAL_TS_LIBRARY_COMPILER = "@build_bazel_rules_typescript//internal:tsc_wrapped_bin"
 INTERNAL_KARMA_BIN = "@build_bazel_rules_typescript//internal/karma:karma_bin"
 
+def karma_web_test(karma = INTERNAL_KARMA_BIN, **kwargs):
+    _karma_web_test(karma = karma, **kwargs)
+
+def karma_web_test_suite(karma = INTERNAL_KARMA_BIN, **kwargs):
+    _karma_web_test_suite(karma = karma, **kwargs)
+
 def ts_library(compiler = INTERNAL_TS_LIBRARY_COMPILER, **kwargs):
     _ts_library(compiler = compiler, **kwargs)
 
+def ts_web_test(karma = INTERNAL_KARMA_BIN, **kwargs):
+    _ts_web_test(karma = karma, **kwargs)
+
 def ts_web_test_suite(karma = INTERNAL_KARMA_BIN, **kwargs):
     _ts_web_test_suite(karma = karma, **kwargs)
diff --git a/internal/karma/karma.conf.js b/internal/karma/karma.conf.js
index 70dc9bf..1ef2a49 100644
--- a/internal/karma/karma.conf.js
+++ b/internal/karma/karma.conf.js
@@ -7,8 +7,21 @@
   const tmp = require('tmp');
   const child_process = require('child_process');
 
-  // Helper function to find a particular namedFile
-  // within the webTestMetadata webTestFiles
+  const DEBUG = false;
+
+  TMPL_env_vars
+
+  const configPath = 'TMPL_config_file';
+
+  if (DEBUG)
+    console.info(`Karma test starting with:
+    cwd: ${process.cwd()}
+    configPath: ${configPath}`);
+
+  /**
+   * Helper function to find a particular namedFile
+   * within the webTestMetadata webTestFiles
+   */
   function findNamedFile(webTestMetadata, key) {
     let result;
     webTestMetadata['webTestFiles'].forEach(entry => {
@@ -20,8 +33,10 @@
     return result;
   }
 
-  // Helper function to extract a browser archive
-  // and return the path to extract executable
+  /**
+   * Helper function to extract a browser archive
+   * and return the path to extract executable
+   */
   function extractWebArchive(extractExe, archiveFile, executablePath) {
     try {
       // Paths are relative to the root runfiles folder
@@ -34,6 +49,7 @@
       child_process.execFileSync(
           extractExe, [archiveFile, '.'],
           {stdio: [process.stdin, process.stdout, process.stderr]});
+    if (DEBUG) console.info(`Extracting web archive ${archiveFile} with ${extractExe} to ${extractedExecutablePath}`);
       return extractedExecutablePath;
     } catch (e) {
       console.error(`Failed to extract ${archiveFile}`);
@@ -41,17 +57,19 @@
     }
   }
 
-  // Chrome on Linux uses sandboxing, which needs user namespaces to be enabled.
-  // This is not available on all kernels and it might be turned off even if it is available.
-  // Notable examples where user namespaces are not available include:
-  // - In Debian it is compiled-in but disabled by default.
-  // - The Docker daemon for Windows or OSX does not support user namespaces.
-  // We can detect if user namespaces are supported via /proc/sys/kernel/unprivileged_userns_clone.
-  // For more information see:
-  // https://github.com/Googlechrome/puppeteer/issues/290
-  // https://superuser.com/questions/1094597/enable-user-namespaces-in-debian-kernel#1122977
-  // https://github.com/karma-runner/karma-chrome-launcher/issues/158
-  // https://github.com/angular/angular/pull/24906
+  /**
+   * Chrome on Linux uses sandboxing, which needs user namespaces to be enabled.
+   * This is not available on all kernels and it might be turned off even if it is available.
+   * Notable examples where user namespaces are not available include:
+   * - In Debian it is compiled-in but disabled by default.
+   * - The Docker daemon for Windows or OSX does not support user namespaces.
+   * We can detect if user namespaces are supported via /proc/sys/kernel/unprivileged_userns_clone.
+   * For more information see:
+   * https://github.com/Googlechrome/puppeteer/issues/290
+   * https://superuser.com/questions/1094597/enable-user-namespaces-in-debian-kernel#1122977
+   * https://github.com/karma-runner/karma-chrome-launcher/issues/158
+   * https://github.com/angular/angular/pull/24906
+   */
   function supportsSandboxing() {
     if (process.platform !== 'linux') {
       return true;
@@ -65,14 +83,177 @@
     return false;
   }
 
-  const browsers = [];
-  let customLaunchers = null;
+  /**
+   * Helper function to override base karma config values.
+   */
+  function overrideConfigValue(conf, name, value) {
+    if (conf.hasOwnProperty(name)) {
+      console.warn(
+          `Your karma configuration specifies '${name}' which will be overwritten by Bazel`);
+    }
+    conf[name] = value;
+  }
 
-  // WEB_TEST_METADATA is configured in rules_webtesting based on value
-  // of the browsers attribute passed to ts_web_test_suite
-  // We setup the karma configuration based on the values in this object
-  if (process.env['WEB_TEST_METADATA']) {
+  /**
+   * Helper function to merge base karma config values that are arrays.
+   */
+  function mergeConfigArray(conf, name, values) {
+    if (!conf[name]) {
+      conf[name] = [];
+    }
+    values.forEach(v => {
+      if (!conf[name].includes(v)) {
+        conf[name].push(v);
+      }
+    })
+  }
+
+  /**
+   * Configuration settings for karma under Bazel common to karma_web_test
+   * and karma_web_test_suite.
+   */
+  function configureBazelConfig(config, conf) {
+    // list of karma plugins
+    mergeConfigArray(conf, 'plugins', [
+      'karma-*',
+      '@bazel/karma',
+      'karma-sourcemap-loader',
+      'karma-chrome-launcher',
+      'karma-firefox-launcher',
+      'karma-sauce-launcher',
+    ]);
+
+    // list of karma preprocessors
+    if (!conf.preprocessors) {
+      conf.preprocessors = {}
+    }
+    conf.preprocessors['**/*.js'] = ['sourcemap'];
+
+    // list of test frameworks to use
+    overrideConfigValue(conf, 'frameworks', ['jasmine', 'concat_js']);
+
+    // test results reporter to use
+    // possible values: 'dots', 'progress'
+    // available reporters: https://npmjs.org/browse/keyword/karma-reporter
+    mergeConfigArray(conf, 'reporters', ['progress']);
+
+    // enable / disable colors in the output (reporters and logs)
+    if (!conf.colors) {
+      conf.colors = true;
+    }
+
+    // level of logging
+    // possible values: config.LOG_DISABLE || config.LOG_ERROR ||
+    // config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG
+    if (!conf.logLevel) {
+      conf.logLevel = config.LOG_INFO;
+    }
+
+    // enable / disable watching file and executing tests whenever
+    // any file changes
+    overrideConfigValue(conf, 'autoWatch', true);
+
+    // Continuous Integration mode
+    // if true, Karma captures browsers, runs the tests and exits
+    // note: run_karma.sh may override this as a command-line option.
+    overrideConfigValue(conf, 'singleRun', false);
+
+    // Concurrency level
+    // how many browser should be started simultaneous
+    overrideConfigValue(conf, 'concurrency', Infinity);
+
+    // base path that will be used to resolve all patterns
+    // (eg. files, exclude)
+    overrideConfigValue(conf, 'basePath', 'TMPL_runfiles_path');
+
+    if (process.env['IBAZEL_NOTIFY_CHANGES'] === 'y') {
+      // Tell karma to only listen for ibazel messages on stdin rather than
+      // watch all the input files This is from fork alexeagle/karma in the
+      // ibazel branch:
+      // https://github.com/alexeagle/karma/blob/576d262af50b10e63485b86aee99c5358958c4dd/lib/server.js#L172
+      overrideConfigValue(conf, 'watchMode', 'ibazel');
+    }
+  }
+
+  /**
+   * Configure the 'files' and 'proxies' configuration attributes.
+   * These are concatenated into a single file by karma-concat-js.
+   */
+  function configureFiles(conf) {
+    overrideConfigValue(conf, 'files', [
+      TMPL_bootstrap_files
+      TMPL_user_files
+    ].map(f => {
+      if (f.startsWith('NODE_MODULES/')) {
+        try {
+          // attempt to resolve in @bazel/karma nested node_modules first
+          return require.resolve(f.replace(/^NODE_MODULES\//, '@bazel/karma/node_modules/'));
+        } catch (e) {
+          // if that failed then attempt to resolve in root node_modules
+          return require.resolve(f.replace(/^NODE_MODULES\//, ''));
+        }
+      } else {
+        return require.resolve(f);
+      }
+    }));
+    overrideConfigValue(conf, 'exclude', []);
+    overrideConfigValue(conf, 'proxies', {});
+
+    // static files are added to the files array but
+    // configured to not be included so karma-concat-js does
+    // not included them in the bundle
+    [TMPL_static_files].forEach((f) => {
+      // In Windows, the runfile will probably not be symlinked. Se we need to
+      // serve the real file through karma, and proxy calls to the expected file
+      // location in the runfiles to the real file.
+      const resolvedFile = require.resolve(f);
+      conf.files.push({pattern: resolvedFile, included: false});
+      // Prefixing the proxy path with '/absolute' allows karma to load local
+      // files. This doesn't see to be an official API.
+      // https://github.com/karma-runner/karma/issues/2703
+      conf.proxies['/base/' + f] = '/absolute' + resolvedFile;
+    });
+
+    var requireConfigContent = `
+// A simplified version of Karma's requirejs.config.tpl.js for use with Karma under Bazel.
+// This does an explicit \`require\` on each test script in the files, otherwise nothing will be loaded.
+(function(){
+  var runtimeFiles = [TMPL_runtime_files].map(function(file) { return file.replace(/\\.js$/, ''); });
+  var allFiles = [TMPL_user_files];
+  var allTestFiles = [];
+  allFiles.forEach(function (file) {
+    if (/[^a-zA-Z0-9](spec|test)\\.js$/i.test(file) && !/\\/node_modules\\//.test(file)) {
+      allTestFiles.push(file.replace(/\\.js$/, ''))
+    }
+  });
+  require(runtimeFiles, function() { return require(allTestFiles, window.__karma__.start); });
+})();
+`;
+
+    const requireConfigFile = tmp.fileSync(
+        {keep: false, postfix: '.js', dir: process.env['TEST_TMPDIR']});
+    fs.writeFileSync(requireConfigFile.name, requireConfigContent);
+    conf.files.push(requireConfigFile.name);
+  }
+
+  /**
+   * Configure karma under karma_web_test_suite.
+   * `browsers` and `customLaunchers` are setup by Bazel.
+   */
+  function configureTsWebTestSuiteConfig(conf) {
+    // WEB_TEST_METADATA is configured in rules_webtesting based on value
+    // of the browsers attribute passed to karms_web_test_suite
+    // We setup the karma configuration based on the values in this object
+    if (!process.env['WEB_TEST_METADATA']) {
+      // This is a karma_web_test rule since there is no WEB_TEST_METADATA
+      return;
+    }
+
+    overrideConfigValue(conf, 'browsers', []);
+    overrideConfigValue(conf, 'customLaunchers', null);
+
     const webTestMetadata = require(process.env['WEB_TEST_METADATA']);
+    if (DEBUG) console.info(`WEB_TEST_METADATA: ${JSON.stringify(webTestMetadata, null, 2)}`);
     if (webTestMetadata['environment'] === 'sauce') {
       // If a sauce labs browser is chosen for the test such as
       // "@io_bazel_rules_webtesting//browsers/sauce:chrome-win10"
@@ -85,7 +266,7 @@
       }
       // 'capabilities' will specify the sauce labs configuration to use
       const capabilities = webTestMetadata['capabilities'];
-      customLaunchers = {
+      conf.customLaunchers = {
           'sauce': {
           base: 'SauceLabs',
           browserName: capabilities['browserName'],
@@ -93,7 +274,7 @@
           version: capabilities['version'],
         }
       };
-      browsers.push('sauce');
+      conf.browsers.push('sauce');
     } else if (webTestMetadata['environment'] === 'local') {
       // When a local chrome or firefox browser is chosen such as
       // "@io_bazel_rules_webtesting//browsers:chromium-local" or
@@ -115,15 +296,15 @@
           const browser = process.env['DISPLAY'] ? 'Chrome' : 'ChromeHeadless';
           if (!supportsSandboxing()) {
             const launcher = 'CustomChromeWithoutSandbox';
-            customLaunchers = {
+            conf.customLaunchers = {
               [launcher]: {
                 base: browser,
                 flags: ['--no-sandbox']
               }
             };
-            browsers.push(launcher);
+            conf.browsers.push(launcher);
           } else {
-            browsers.push(browser);
+            conf.browsers.push(browser);
           }
         }
         if (webTestNamedFiles['FIREFOX']) {
@@ -134,165 +315,76 @@
           } else {
             process.env.FIREFOX_BIN = require.resolve(webTestNamedFiles['FIREFOX']);
           }
-          browsers.push(process.env['DISPLAY'] ? 'Firefox' : 'FirefoxHeadless');
+          conf.browsers.push(process.env['DISPLAY'] ? 'Firefox' : 'FirefoxHeadless');
         }
       });
     } else {
-      console.warn(`Unknown WEB_TEST_METADATA environment '${webTestMetadata['environment']}'`);
-    }
-  }
-
-  // Fallback to using the system local chrome if no valid browsers have been
-  // configured above
-  if (!browsers.length) {
-    console.warn('No browsers configured. Configuring Karma to use system Chrome.');
-    browsers.push(process.env['DISPLAY'] ? 'Chrome': 'ChromeHeadless');
-  }
-
-  const proxies = {};
-  const files = [
-    TMPL_bootstrap_files
-    TMPL_user_files
-  ].map(f => {
-    if (f.startsWith('NODE_MODULES/')) {
-      try {
-        // attempt to resolve in @bazel/karma nested node_modules first
-        return require.resolve(f.replace(/^NODE_MODULES\//, '@bazel/karma/node_modules/'));
-      } catch (e) {
-        // if that failed then attempt to resolve in root node_modules
-        return require.resolve(f.replace(/^NODE_MODULES\//, ''));
-      }
-    } else {
-      return require.resolve(f);
-    }
-  });
-
-  // static files are added to the files array but
-  // configured to not be included so karma-concat-js does
-  // not included them in the bundle
-  [TMPL_static_files].forEach((f) => {
-    // In Windows, the runfile will probably not be symlinked. Se we need to
-    // serve the real file through karma, and proxy calls to the expected file
-    // location in the runfiles to the real file.
-    const resolvedFile = require.resolve(f);
-    files.push({pattern: resolvedFile, included: false});
-    // Prefixing the proxy path with '/absolute' allows karma to load local
-    // files. This doesn't see to be an official API.
-    // https://github.com/karma-runner/karma/issues/2703
-    proxies['/base/' + f] = '/absolute' + resolvedFile;
-  });
-
-  var requireConfigContent = `
-// A simplified version of Karma's requirejs.config.tpl.js for use with Karma under Bazel.
-// This does an explicit \`require\` on each test script in the files, otherwise nothing will be loaded.
-(function(){
-  var runtimeFiles = [TMPL_runtime_files].map(function(file) { return file.replace(/\\.js$/, ''); });
-  var allFiles = [TMPL_user_files];
-  var allTestFiles = [];
-  allFiles.forEach(function (file) {
-    if (/[^a-zA-Z0-9](spec|test)\\.js$/i.test(file) && !/\\/node_modules\\//.test(file)) {
-      allTestFiles.push(file.replace(/\\.js$/, ''))
-    }
-  });
-  require(runtimeFiles, function() { return require(allTestFiles, window.__karma__.start); });
-})();
-`;
-
-  const requireConfigFile = tmp.fileSync(
-      {keep: false, postfix: '.js', dir: process.env['TEST_TMPDIR']});
-  fs.writeFileSync(requireConfigFile.name, requireConfigContent);
-  files.push(requireConfigFile.name);
-
-  module.exports = function(config) {
-    const configuration = {
-      // list of karma plugins
-      plugins: [
-        'karma-*',
-        '@bazel/karma',
-        'karma-sourcemap-loader',
-        'karma-chrome-launcher',
-        'karma-firefox-launcher',
-        'karma-sauce-launcher',
-      ],
-
-      // list of karma preprocessors
-      preprocessors: {'**/*.js': ['sourcemap']},
-
-      // list of test frameworks to use
-      frameworks: ['jasmine', 'concat_js'],
-
-      // test results reporter to use
-      // possible values: 'dots', 'progress'
-      // available reporters: https://npmjs.org/browse/keyword/karma-reporter
-      reporters: ['progress'],
-
-      // web server port
-      port: 9876,
-
-      // enable / disable colors in the output (reporters and logs)
-      colors: true,
-
-      // level of logging
-      // possible values: config.LOG_DISABLE || config.LOG_ERROR ||
-      // config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG
-      logLevel: config.LOG_INFO,
-
-      // enable / disable watching file and executing tests whenever any file
-      // changes
-      autoWatch: true,
-
-      // start these browsers
-      // available browser launchers:
-      // https://npmjs.org/browse/keyword/karma-launcher
-      browsers: browsers,
-
-      // Continuous Integration mode
-      // if true, Karma captures browsers, runs the tests and exits
-      // note: run_karma.sh may override this as a command-line option.
-      singleRun: false,
-
-      // Concurrency level
-      // how many browser should be started simultaneous
-      concurrency: Infinity,
-
-      // base path that will be used to resolve all patterns (eg. files,
-      // exclude)
-      basePath: 'TMPL_runfiles_path',
-
-      // list of files passed to karma; these are concatenated into a single
-      // file by karma-concat-js
-      files,
-      proxies,
+      throw new Error(`Unknown WEB_TEST_METADATA environment '${webTestMetadata['environment']}'`);
     }
 
-    if (process.env['IBAZEL_NOTIFY_CHANGES'] === 'y') {
-      // Tell karma to only listen for ibazel messages on stdin rather than
-      // watch all the input files This is from fork alexeagle/karma in the
-      // ibazel branch:
-      // https://github.com/alexeagle/karma/blob/576d262af50b10e63485b86aee99c5358958c4dd/lib/server.js#L172
-      configuration.watchMode = 'ibazel';
+    if (!conf.browsers.length) {
+      throw new Error('No browsers configured in web test suite');
     }
 
     // Extra configuration is needed for saucelabs
     // See: https://github.com/karma-runner/karma-sauce-launcher
-    if (customLaunchers) {
+    if (conf.customLaunchers) {
       // set the test name for sauce labs to use
       // TEST_BINARY is set by Bazel and contains the name of the test
-      // target posfixed with the the browser name such as
+      // target postfixed with the browser name such as
       // 'examples/testing/testing_sauce_chrome-win10' for the
       // test target examples/testing:testing
-      configuration.sauceLabs = {
-        testName: process.env['TEST_BINARY'] || 'ts_web_test_suite'
-      };
+      if (!conf.sauceLabs) {
+        conf.sauceLabs = {}
+      }
+      conf.sauceLabs.testName = process.env['TEST_BINARY'] || 'karma';
 
-      // setup the custom launchers for saucelabs
-      configuration.customLaunchers = customLaunchers;
+      // Try "websocket" for a faster transmission first. Fallback to "polling" if necessary.
+      overrideConfigValue(conf, 'transports', ['websocket', 'polling']);
 
       // add the saucelabs reporter
-      configuration.reporters.push('saucelabs');
+      mergeConfigArray(conf, 'reporters', ['saucelabs']);
+    }
+  }
+
+  function configureTsWebTestConfig(conf) {
+    if (process.env['WEB_TEST_METADATA']) {
+      // This is a karma_web_test_suite rule since there is a WEB_TEST_METADATA
+      return;
     }
 
-    config.set(configuration);
+    // Fallback to using the system local chrome if no valid browsers have been
+    // configured above
+    if (!conf.browsers || !conf.browsers.length) {
+      console.warn('No browsers configured. Configuring Karma to use system Chrome.');
+      conf.browsers = [process.env['DISPLAY'] ? 'Chrome': 'ChromeHeadless'];
+    }
+  }
+
+  module.exports = function(config) {
+    let conf = {};
+
+    // Import the user's base karma configuration if specified
+    if (configPath) {
+      const baseConf = require(configPath);
+      if (typeof baseConf !== 'function') {
+        throw new Error('Invalid base karma configuration. Expected config function to be exported.');
+      }
+      const originalSetConfig = config.set;
+      config.set = function(c) { conf = c; }
+      baseConf(config);
+      config.set = originalSetConfig;
+      if (DEBUG) console.info(`Base karma configuration: ${JSON.stringify(conf, null, 2)}`);
+    }
+
+    configureBazelConfig(config, conf);
+    configureFiles(conf);
+    configureTsWebTestSuiteConfig(conf);
+    configureTsWebTestConfig(conf);
+
+    if (DEBUG) console.info(`Karma configuration: ${JSON.stringify(conf, null, 2)}`);
+
+    config.set(conf);
   }
 } catch (e) {
   console.error('Error in karma configuration', e.toString());
diff --git a/internal/karma/karma_web_test.bzl b/internal/karma/karma_web_test.bzl
new file mode 100644
index 0000000..f6be58f
--- /dev/null
+++ b/internal/karma/karma_web_test.bzl
@@ -0,0 +1,449 @@
+# Copyright 2017 The Bazel Authors. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"Unit testing with Karma"
+
+load("@build_bazel_rules_nodejs//internal/js_library:js_library.bzl", "write_amd_names_shim")
+load(
+    "@build_bazel_rules_nodejs//internal:node.bzl",
+    "expand_path_into_runfiles",
+    "sources_aspect",
+)
+load("@io_bazel_rules_webtesting//web/internal:constants.bzl", "DEFAULT_WRAPPED_TEST_TAGS")
+load("@io_bazel_rules_webtesting//web:web.bzl", "web_test_suite")
+load(":web_test.bzl", "COMMON_WEB_TEST_ATTRS")
+
+_CONF_TMPL = "//internal/karma:karma.conf.js"
+_DEFAULT_KARMA_BIN = "@npm//@bazel/karma/bin:karma"
+
+# Attributes for karma_web_test that are shared with ts_web_test which
+# uses Karma under the hood
+KARMA_GENERIC_WEB_TEST_ATTRS = dict(COMMON_WEB_TEST_ATTRS, **{
+    "bootstrap": attr.label_list(
+        doc = """JavaScript files to include *before* the module loader (require.js).
+        For example, you can include Reflect,js for TypeScript decorator metadata reflection,
+        or UMD bundles for third-party libraries.""",
+        allow_files = [".js"],
+    ),
+    "karma": attr.label(
+        doc = "karma binary label",
+        default = Label(_DEFAULT_KARMA_BIN),
+        executable = True,
+        cfg = "target",
+        allow_files = True,
+    ),
+    "static_files": attr.label_list(
+        doc = """Arbitrary files which are available to be served on request.
+        Files are served at:
+        `/base/<WORKSPACE_NAME>/<path-to-file>`, e.g.
+        `/base/build_bazel_rules_typescript/examples/testing/static_script.js`""",
+        allow_files = True,
+    ),
+    "runtime_deps": attr.label_list(
+        doc = """Dependencies which should be loaded after the module loader but before the srcs and deps.
+        These should be a list of targets which produce JavaScript such as `ts_library`.
+        The files will be loaded in the same order they are declared by that rule.""",
+        allow_files = True,
+        aspects = [sources_aspect],
+    ),
+    "_conf_tmpl": attr.label(
+        default = Label(_CONF_TMPL),
+        allow_single_file = True,
+    ),
+})
+
+# Attributes for karma_web_test that are specific to karma_web_test
+KARMA_WEB_TEST_ATTRS = dict(KARMA_GENERIC_WEB_TEST_ATTRS, **{
+    "config_file": attr.label(
+        doc = """User supplied Karma configuration file. Bazel will override
+        certain attributes of this configuration file. Attributes that are
+        overridden will be outputted to the test log.""",
+        allow_single_file = True,
+        aspects = [sources_aspect],
+    ),
+})
+
+# Helper function to convert a short path to a path that is
+# found in the MANIFEST file.
+def _short_path_to_manifest_path(ctx, short_path):
+    if short_path.startswith("../"):
+        return short_path[3:]
+    else:
+        return ctx.workspace_name + "/" + short_path
+
+# Write the AMD names shim bootstrap file
+def _write_amd_names_shim(ctx):
+    amd_names_shim = ctx.actions.declare_file(
+        "_%s.amd_names_shim.js" % ctx.label.name,
+        sibling = ctx.outputs.executable,
+    )
+    write_amd_names_shim(ctx.actions, amd_names_shim, ctx.attr.bootstrap)
+    return amd_names_shim
+
+# Generates the karma configuration file for the rule
+def _write_karma_config(ctx, files, amd_names_shim):
+    configuration = ctx.actions.declare_file(
+        "%s.conf.js" % ctx.label.name,
+        sibling = ctx.outputs.executable,
+    )
+
+    config_file = ""
+    if hasattr(ctx.file, "config_file"):
+        config_file = ctx.file.config_file
+        if hasattr(ctx.attr.config_file, "typescript"):
+            config_file = ctx.attr.config_file.typescript.es5_sources.to_list()[0]
+
+    # The files in the bootstrap attribute come before the require.js support.
+    # Note that due to frameworks = ['jasmine'], a few scripts will come before
+    # the bootstrap entries:
+    # jasmine-core/lib/jasmine-core/jasmine.js
+    # karma-jasmine/lib/boot.js
+    # karma-jasmine/lib/adapter.js
+    # This is desired so that the bootstrap entries can patch jasmine, as zone.js does.
+    bootstrap_entries = [
+        expand_path_into_runfiles(ctx, f.short_path)
+        for f in ctx.files.bootstrap
+    ]
+
+    # Explicitly list the requirejs library files here, rather than use
+    # `frameworks: ['requirejs']`
+    # so that we control the script order, and the bootstrap files come before
+    # require.js.
+    # That allows bootstrap files to have anonymous AMD modules, or to do some
+    # polyfilling before test libraries load.
+    # See https://github.com/karma-runner/karma/issues/699
+    # `NODE_MODULES/` is a prefix recogized by karma.conf.js to allow
+    # for a priority require of nested `@bazel/karma/node_modules` before
+    # looking in root node_modules.
+    bootstrap_entries += [
+        "NODE_MODULES/requirejs/require.js",
+        "NODE_MODULES/karma-requirejs/lib/adapter.js",
+        "/".join([ctx.workspace_name, amd_names_shim.short_path]),
+    ]
+
+    # Next we load the "runtime_deps" which we expect to contain named AMD modules
+    # Thus they should come after the require.js script, but before any srcs or deps
+    runtime_files = []
+    for d in ctx.attr.runtime_deps:
+        if not hasattr(d, "typescript"):
+            # Workaround https://github.com/bazelbuild/rules_nodejs/issues/57
+            # We should allow any JS source as long as it yields something that
+            # can be loaded by require.js
+            fail("labels in runtime_deps must be created by ts_library")
+        for src in d.typescript.es5_sources.to_list():
+            runtime_files.append(expand_path_into_runfiles(ctx, src.short_path))
+
+    # Finally we load the user's srcs and deps
+    user_entries = [
+        expand_path_into_runfiles(ctx, f.short_path)
+        for f in files.to_list()
+    ]
+
+    # Expand static_files paths to runfiles for config
+    static_files = [
+        expand_path_into_runfiles(ctx, f.short_path)
+        for f in ctx.files.static_files
+    ]
+
+    # root-relative (runfiles) path to the directory containing karma.conf
+    config_segments = len(configuration.short_path.split("/"))
+
+    # configuration_env_vars are set using process.env()
+    env_vars = ""
+    for k in ctx.attr.configuration_env_vars:
+        if k in ctx.var.keys():
+            env_vars += "process.env[\"%s\"]=\"%s\";\n" % (k, ctx.var[k])
+
+    ctx.actions.expand_template(
+        output = configuration,
+        template = ctx.file._conf_tmpl,
+        substitutions = {
+            "TMPL_bootstrap_files": "\n".join(["      '%s'," % e for e in bootstrap_entries]),
+            "TMPL_config_file": expand_path_into_runfiles(ctx, config_file.short_path) if config_file else "",
+            "TMPL_env_vars": env_vars,
+            "TMPL_runfiles_path": "/".join([".."] * config_segments),
+            "TMPL_runtime_files": "\n".join(["      '%s'," % e for e in runtime_files]),
+            "TMPL_static_files": "\n".join(["      '%s'," % e for e in static_files]),
+            "TMPL_user_files": "\n".join(["      '%s'," % e for e in user_entries]),
+        },
+    )
+
+    return configuration
+
+def run_karma_web_test(ctx):
+    """Creates an action that can run karma.
+
+    This is also used by ts_web_test_rule.
+
+    Args:
+      ctx: Bazel rule execution context
+
+    Returns:
+      The runfiles for the generated action.
+    """
+    files = depset(ctx.files.srcs)
+    for d in ctx.attr.deps + ctx.attr.runtime_deps:
+        if hasattr(d, "node_sources"):
+            files = depset(transitive = [files, d.node_sources])
+        elif hasattr(d, "files"):
+            files = depset(transitive = [files, d.files])
+
+    amd_names_shim = _write_amd_names_shim(ctx)
+
+    configuration = _write_karma_config(ctx, files, amd_names_shim)
+
+    ctx.actions.write(
+        output = ctx.outputs.executable,
+        is_executable = True,
+        content = """#!/usr/bin/env bash
+# Immediately exit if any command fails.
+set -e
+
+if [ -e "$RUNFILES_MANIFEST_FILE" ]; then
+  while read line; do
+    declare -a PARTS=($line)
+    if [ "${{PARTS[0]}}" == "{TMPL_karma}" ]; then
+      readonly KARMA=${{PARTS[1]}}
+    elif [ "${{PARTS[0]}}" == "{TMPL_conf}" ]; then
+      readonly CONF=${{PARTS[1]}}
+    fi
+  done < $RUNFILES_MANIFEST_FILE
+else
+  readonly KARMA=../{TMPL_karma}
+  readonly CONF=../{TMPL_conf}
+fi
+
+export HOME=$(mktemp -d)
+
+# Print the karma version in the test log
+echo $($KARMA --version)
+
+ARGV=( "start" $CONF )
+
+# Detect that we are running as a test, by using well-known environment
+# variables. See go/test-encyclopedia
+# Note: in Bazel 0.14 and later, TEST_TMPDIR is set for both bazel test and bazel run
+# so we also check for the BUILD_WORKSPACE_DIRECTORY which is set only for bazel run
+if [[ ! -z "${{TEST_TMPDIR}}" && ! -n "${{BUILD_WORKSPACE_DIRECTORY}}" ]]; then
+  ARGV+=( "--single-run" )
+fi
+
+$KARMA ${{ARGV[@]}}
+""".format(
+            TMPL_workspace = ctx.workspace_name,
+            TMPL_karma = _short_path_to_manifest_path(ctx, ctx.executable.karma.short_path),
+            TMPL_conf = _short_path_to_manifest_path(ctx, configuration.short_path),
+        ),
+    )
+
+    config_sources = []
+    if hasattr(ctx.file, "config_file"):
+        if ctx.file.config_file:
+            config_sources = [ctx.file.config_file]
+        if hasattr(ctx.attr.config_file, "node_sources"):
+            config_sources = ctx.attr.config_file.node_sources.to_list()
+
+    runfiles = [
+        configuration,
+        amd_names_shim,
+    ]
+    runfiles += config_sources
+    runfiles += ctx.files.srcs
+    runfiles += ctx.files.deps
+    runfiles += ctx.files.runtime_deps
+    runfiles += ctx.files.bootstrap
+    runfiles += ctx.files.static_files
+
+    return ctx.runfiles(
+        files = runfiles,
+        transitive_files = files,
+    ).merge(ctx.attr.karma[DefaultInfo].data_runfiles)
+
+def _karma_web_test_impl(ctx):
+    runfiles = run_karma_web_test(ctx)
+
+    return [DefaultInfo(
+        files = depset([ctx.outputs.executable]),
+        runfiles = runfiles,
+        executable = ctx.outputs.executable,
+    )]
+
+_karma_web_test = rule(
+    implementation = _karma_web_test_impl,
+    test = True,
+    executable = True,
+    attrs = KARMA_WEB_TEST_ATTRS,
+)
+
+def karma_web_test(
+        srcs = [],
+        deps = [],
+        data = [],
+        configuration_env_vars = [],
+        bootstrap = [],
+        runtime_deps = [],
+        static_files = [],
+        config_file = None,
+        tags = [],
+        **kwargs):
+    """Runs unit tests in a browser with Karma.
+
+    When executed under `bazel test`, this uses a headless browser for speed.
+    This is also because `bazel test` allows multiple targets to be tested together,
+    and we don't want to open a Chrome window on your machine for each one. Also,
+    under `bazel test` the test will execute and immediately terminate.
+
+    Running under `ibazel test` gives you a "watch mode" for your tests. The rule is
+    optimized for this case - the test runner server will stay running and just
+    re-serve the up-to-date JavaScript source bundle.
+
+    To debug a single test target, run it with `bazel run` instead. This will open a
+    browser window on your computer. Also you can use any other browser by opening
+    the URL printed when the test starts up. The test will remain running until you
+    cancel the `bazel run` command.
+
+    This rule will use your system Chrome by default. In the default case, your
+    environment must specify CHROME_BIN so that the rule will know which Chrome binary to run.
+    Other `browsers` and `customLaunchers` may be set using the a base Karma configuration
+    specified in the `config_file` attribute.
+
+    Args:
+      srcs: A list of JavaScript test files
+      deps: Other targets which produce JavaScript such as `ts_library`
+      data: Runtime dependencies
+      configuration_env_vars: Pass these configuration environment variables to the resulting binary.
+          Chooses a subset of the configuration environment variables (taken from ctx.var), which also
+          includes anything specified via the --define flag.
+          Note, this can lead to different outputs produced by this rule.
+      bootstrap: JavaScript files to include *before* the module loader (require.js).
+          For example, you can include Reflect,js for TypeScript decorator metadata reflection,
+          or UMD bundles for third-party libraries.
+      runtime_deps: Dependencies which should be loaded after the module loader but before the srcs and deps.
+          These should be a list of targets which produce JavaScript such as `ts_library`.
+          The files will be loaded in the same order they are declared by that rule.
+      static_files: Arbitrary files which are available to be served on request.
+          Files are served at:
+          `/base/<WORKSPACE_NAME>/<path-to-file>`, e.g.
+          `/base/build_bazel_rules_typescript/examples/testing/static_script.js`
+      config_file: User supplied Karma configuration file. Bazel will override
+          certain attributes of this configuration file. Attributes that are
+          overridden will be outputted to the test log.
+      tags: Standard Bazel tags, this macro adds tags for ibazel support
+      **kwargs: Passed through to `karma_web_test`
+    """
+
+    _karma_web_test(
+        srcs = srcs,
+        deps = deps,
+        data = data,
+        configuration_env_vars = configuration_env_vars,
+        bootstrap = bootstrap,
+        runtime_deps = runtime_deps,
+        static_files = static_files,
+        config_file = config_file,
+        tags = tags + [
+            # Users don't need to know that this tag is required to run under ibazel
+            "ibazel_notify_changes",
+        ],
+        **kwargs
+    )
+
+def karma_web_test_suite(
+        name,
+        browsers = ["@io_bazel_rules_webtesting//browsers:chromium-local"],
+        args = None,
+        browser_overrides = None,
+        config = None,
+        flaky = None,
+        local = None,
+        shard_count = None,
+        size = None,
+        tags = [],
+        test_suite_tags = None,
+        timeout = None,
+        visibility = None,
+        web_test_data = [],
+        wrapped_test_tags = None,
+        **remaining_keyword_args):
+    """Defines a test_suite of web_test targets that wrap a karma_web_test target.
+
+    This macro also accepts all parameters in karma_web_test. See karma_web_test docs
+    for details.
+
+    Args:
+      name: The base name of the test
+      browsers: A sequence of labels specifying the browsers to use.
+      args: Args for web_test targets generated by this extension.
+      browser_overrides: Dictionary; optional; default is an empty dictionary. A
+        dictionary mapping from browser names to browser-specific web_test
+        attributes, such as shard_count, flakiness, timeout, etc. For example:
+        {'//browsers:chrome-native': {'shard_count': 3, 'flaky': 1}
+         '//browsers:firefox-native': {'shard_count': 1, 'timeout': 100}}.
+      config: Label; optional; Configuration of web test features.
+      flaky: A boolean specifying that the test is flaky. If set, the test will
+        be retried up to 3 times (default: 0)
+      local: boolean; optional.
+      shard_count: The number of test shards to use per browser. (default: 1)
+      size: A string specifying the test size. (default: 'large')
+      tags: A list of test tag strings to apply to each generated web_test target.
+        This macro adds a couple for ibazel.
+      test_suite_tags: A list of tag strings for the generated test_suite.
+      timeout: A string specifying the test timeout (default: computed from size)
+      visibility: List of labels; optional.
+      web_test_data: Data dependencies for the web_test.
+      wrapped_test_tags: A list of test tag strings to use for the wrapped test
+      **remaining_keyword_args: Arguments for the wrapped test target.
+    """
+
+    # Check explicitly for None so that users can set this to the empty list
+    if wrapped_test_tags == None:
+        wrapped_test_tags = DEFAULT_WRAPPED_TEST_TAGS
+
+    size = size or "large"
+
+    wrapped_test_name = name + "_wrapped_test"
+
+    _karma_web_test(
+        name = wrapped_test_name,
+        args = args,
+        flaky = flaky,
+        local = local,
+        shard_count = shard_count,
+        size = size,
+        tags = wrapped_test_tags,
+        timeout = timeout,
+        visibility = ["//visibility:private"],
+        **remaining_keyword_args
+    )
+
+    web_test_suite(
+        name = name,
+        launcher = ":" + wrapped_test_name,
+        args = args,
+        browsers = browsers,
+        browser_overrides = browser_overrides,
+        config = config,
+        data = web_test_data,
+        flaky = flaky,
+        local = local,
+        shard_count = shard_count,
+        size = size,
+        tags = tags + [
+            # Users don't need to know that this tag is required to run under ibazel
+            "ibazel_notify_changes",
+        ],
+        test = wrapped_test_name,
+        test_suite_tags = test_suite_tags,
+        timeout = timeout,
+        visibility = visibility,
+    )
diff --git a/internal/karma/package.json b/internal/karma/package.json
index c5bf6d9..9ffa9c0 100644
--- a/internal/karma/package.json
+++ b/internal/karma/package.json
@@ -19,7 +19,7 @@
     "karma-chrome-launcher": "2.2.0",
     "karma-firefox-launcher": "1.1.0",
     "karma-jasmine": "1.1.1",
-    "karma-sauce-launcher": "1.2.0",
+    "karma-sauce-launcher": "2.0.2",
     "karma-sourcemap-loader": "0.3.7",
     "karma-requirejs": "1.1.0",
     "requirejs": "2.3.5",
diff --git a/internal/karma/ts_web_test.bzl b/internal/karma/ts_web_test.bzl
index bcdee11..9189076 100644
--- a/internal/karma/ts_web_test.bzl
+++ b/internal/karma/ts_web_test.bzl
@@ -11,279 +11,108 @@
 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 # See the License for the specific language governing permissions and
 # limitations under the License.
-"Unit testing with Karma"
+"Unit testing in a browser"
 
-load("@build_bazel_rules_nodejs//internal/js_library:js_library.bzl", "write_amd_names_shim")
-load(
-    "@build_bazel_rules_nodejs//internal:node.bzl",
-    "expand_path_into_runfiles",
-    "sources_aspect",
-)
 load("@io_bazel_rules_webtesting//web/internal:constants.bzl", "DEFAULT_WRAPPED_TEST_TAGS")
 load("@io_bazel_rules_webtesting//web:web.bzl", "web_test_suite")
+load(":karma_web_test.bzl", "KARMA_GENERIC_WEB_TEST_ATTRS", "run_karma_web_test")
 
-_CONF_TMPL = "//internal/karma:karma.conf.js"
-_DEFAULT_KARMA_BIN = "@npm//@bazel/karma/bin:karma"
-
-def _short_path_to_manifest_path(ctx, short_path):
-    if short_path.startswith("../"):
-        return short_path[3:]
-    else:
-        return ctx.workspace_name + "/" + short_path
+# Using generic karma_web_test attributes under the hood
+TS_WEB_TEST_ATTRS = dict(KARMA_GENERIC_WEB_TEST_ATTRS, **{})
 
 def _ts_web_test_impl(ctx):
-    conf = ctx.actions.declare_file(
-        "%s.conf.js" % ctx.label.name,
-        sibling = ctx.outputs.executable,
-    )
+    # Using karma_web_test under the hood
+    runfiles = run_karma_web_test(ctx)
 
-    files = depset(ctx.files.srcs)
-    for d in ctx.attr.deps + ctx.attr.runtime_deps:
-        if hasattr(d, "node_sources"):
-            files = depset(transitive = [files, d.node_sources])
-        elif hasattr(d, "files"):
-            files = depset(transitive = [files, d.files])
-
-    # Write the AMD names shim bootstrap file
-    amd_names_shim = ctx.actions.declare_file(
-        "_%s.amd_names_shim.js" % ctx.label.name,
-        sibling = ctx.outputs.executable,
-    )
-    write_amd_names_shim(ctx.actions, amd_names_shim, ctx.attr.bootstrap)
-
-    # The files in the bootstrap attribute come before the require.js support.
-    # Note that due to frameworks = ['jasmine'], a few scripts will come before
-    # the bootstrap entries:
-    # jasmine-core/lib/jasmine-core/jasmine.js
-    # karma-jasmine/lib/boot.js
-    # karma-jasmine/lib/adapter.js
-    # This is desired so that the bootstrap entries can patch jasmine, as zone.js does.
-    bootstrap_entries = [
-        expand_path_into_runfiles(ctx, f.short_path)
-        for f in ctx.files.bootstrap
-    ]
-
-    # Explicitly list the requirejs library files here, rather than use
-    # `frameworks: ['requirejs']`
-    # so that we control the script order, and the bootstrap files come before
-    # require.js.
-    # That allows bootstrap files to have anonymous AMD modules, or to do some
-    # polyfilling before test libraries load.
-    # See https://github.com/karma-runner/karma/issues/699
-    # `NODE_MODULES/` is a prefix recogized by karma.conf.js to allow
-    # for a priority require of nested `@bazel/karma/node_modules` before
-    # looking in root node_modules.
-    bootstrap_entries += [
-        "NODE_MODULES/requirejs/require.js",
-        "NODE_MODULES/karma-requirejs/lib/adapter.js",
-        "/".join([ctx.workspace_name, amd_names_shim.short_path]),
-    ]
-
-    # Next we load the "runtime_deps" which we expect to contain named AMD modules
-    # Thus they should come after the require.js script, but before any srcs or deps
-    runtime_files = []
-    for d in ctx.attr.runtime_deps:
-        if not hasattr(d, "typescript"):
-            # Workaround https://github.com/bazelbuild/rules_nodejs/issues/57
-            # We should allow any JS source as long as it yields something that
-            # can be loaded by require.js
-            fail("labels in runtime_deps must be created by ts_library")
-        for src in d.typescript.es5_sources.to_list():
-            runtime_files.append(expand_path_into_runfiles(ctx, src.short_path))
-
-    # Finally we load the user's srcs and deps
-    user_entries = [
-        expand_path_into_runfiles(ctx, f.short_path)
-        for f in files.to_list()
-    ]
-    static_files = [
-        expand_path_into_runfiles(ctx, f.short_path)
-        for f in ctx.files.static_files
-    ]
-
-    # root-relative (runfiles) path to the directory containing karma.conf
-    config_segments = len(conf.short_path.split("/"))
-
-    ctx.actions.expand_template(
-        output = conf,
-        template = ctx.file._conf_tmpl,
-        substitutions = {
-            "TMPL_bootstrap_files": "\n".join(["      '%s'," % e for e in bootstrap_entries]),
-            "TMPL_runfiles_path": "/".join([".."] * config_segments),
-            "TMPL_runtime_files": "\n".join(["      '%s'," % e for e in runtime_files]),
-            "TMPL_static_files": "\n".join(["      '%s'," % e for e in static_files]),
-            "TMPL_user_files": "\n".join(["      '%s'," % e for e in user_entries]),
-            "TMPL_workspace_name": ctx.workspace_name,
-        },
-    )
-
-    karma_runfiles = [
-        conf,
-        amd_names_shim,
-    ]
-    karma_runfiles += ctx.files.srcs
-    karma_runfiles += ctx.files.deps
-    karma_runfiles += ctx.files.runtime_deps
-    karma_runfiles += ctx.files.bootstrap
-    karma_runfiles += ctx.files.static_files
-
-    ctx.actions.write(
-        output = ctx.outputs.executable,
-        is_executable = True,
-        content = """#!/usr/bin/env bash
-if [ -e "$RUNFILES_MANIFEST_FILE" ]; then
-  while read line; do
-    declare -a PARTS=($line)
-    if [ "${{PARTS[0]}}" == "{TMPL_karma}" ]; then
-      readonly KARMA=${{PARTS[1]}}
-    elif [ "${{PARTS[0]}}" == "{TMPL_conf}" ]; then
-      readonly CONF=${{PARTS[1]}}
-    fi
-  done < $RUNFILES_MANIFEST_FILE
-else
-  readonly KARMA=../{TMPL_karma}
-  readonly CONF=../{TMPL_conf}
-fi
-
-export HOME=$(mktemp -d)
-ARGV=( "start" $CONF )
-
-# Detect that we are running as a test, by using well-known environment
-# variables. See go/test-encyclopedia
-# Note: in Bazel 0.14 and later, TEST_TMPDIR is set for both bazel test and bazel run
-# so we also check for the BUILD_WORKSPACE_DIRECTORY which is set only for bazel run
-if [[ ! -z "${{TEST_TMPDIR}}" && ! -n "${{BUILD_WORKSPACE_DIRECTORY}}" ]]; then
-  ARGV+=( "--single-run" )
-fi
-
-$KARMA ${{ARGV[@]}}
-""".format(
-            TMPL_workspace = ctx.workspace_name,
-            TMPL_karma = _short_path_to_manifest_path(ctx, ctx.executable.karma.short_path),
-            TMPL_conf = _short_path_to_manifest_path(ctx, conf.short_path),
-        ),
-    )
     return [DefaultInfo(
         files = depset([ctx.outputs.executable]),
-        runfiles = ctx.runfiles(
-            files = karma_runfiles,
-            transitive_files = files,
-            # Propagate karma_bin and its runfiles
-            collect_data = True,
-            collect_default = True,
-        ),
+        runfiles = runfiles,
         executable = ctx.outputs.executable,
     )]
 
-ts_web_test = rule(
+_ts_web_test = rule(
     implementation = _ts_web_test_impl,
     test = True,
     executable = True,
-    attrs = {
-        "srcs": attr.label_list(
-            doc = "JavaScript source files",
-            allow_files = [".js"],
-        ),
-        "bootstrap": attr.label_list(
-            doc = """JavaScript files to include *before* the module loader (require.js).
-            For example, you can include Reflect,js for TypeScript decorator metadata reflection,
-            or UMD bundles for third-party libraries.""",
-            allow_files = [".js"],
-        ),
-        "data": attr.label_list(
-            doc = "Runtime dependencies",
-        ),
-        "karma": attr.label(
-            default = Label(_DEFAULT_KARMA_BIN),
-            executable = True,
-            cfg = "target",
-            allow_files = True,
-        ),
-        "static_files": attr.label_list(
-            doc = """Arbitrary files which are available to be served on request.
-            Files are served at:
-            `/base/<WORKSPACE_NAME>/<path-to-file>`, e.g.
-            `/base/build_bazel_rules_typescript/examples/testing/static_script.js`""",
-            allow_files = True,
-        ),
-        "runtime_deps": attr.label_list(
-            doc = """Dependencies which should be loaded after the module loader but before the srcs and deps.
-            These should be a list of targets which produce JavaScript such as `ts_library`.
-            The files will be loaded in the same order they are declared by that rule.""",
-            allow_files = True,
-            aspects = [sources_aspect],
-        ),
-        "deps": attr.label_list(
-            doc = "Other targets which produce JavaScript such as `ts_library`",
-            allow_files = True,
-            aspects = [sources_aspect],
-        ),
-        "_conf_tmpl": attr.label(
-            default = Label(_CONF_TMPL),
-            allow_single_file = True,
-        ),
-    },
+    attrs = TS_WEB_TEST_ATTRS,
 )
-"""Runs unit tests in a browser.
 
-When executed under `bazel test`, this uses a headless browser for speed.
-This is also because `bazel test` allows multiple targets to be tested together,
-and we don't want to open a Chrome window on your machine for each one. Also,
-under `bazel test` the test will execute and immediately terminate.
-
-Running under `ibazel test` gives you a "watch mode" for your tests. The rule is
-optimized for this case - the test runner server will stay running and just
-re-serve the up-to-date JavaScript source bundle.
-
-To debug a single test target, run it with `bazel run` instead. This will open a
-browser window on your computer. Also you can use any other browser by opening
-the URL printed when the test starts up. The test will remain running until you
-cancel the `bazel run` command.
-
-Currently this rule uses Karma as the test runner, but this is an implementation
-detail. We might switch to another runner like Jest in the future.
-"""
-
-# This macro exists only to modify the users rule definition a bit.
-# DO NOT add composition of additional rules here.
-def ts_web_test_macro(
-        karma = Label(_DEFAULT_KARMA_BIN),
-        tags = [],
+def ts_web_test(
+        srcs = [],
+        deps = [],
         data = [],
+        configuration_env_vars = [],
+        bootstrap = [],
+        runtime_deps = [],
+        static_files = [],
+        tags = [],
         **kwargs):
-    """ibazel wrapper for `ts_web_test`
+    """Runs unit tests in a browser.
 
-    This macro re-exposes the `ts_web_test` rule with some extra tags so that
-    it behaves correctly under ibazel.
+    When executed under `bazel test`, this uses a headless browser for speed.
+    This is also because `bazel test` allows multiple targets to be tested together,
+    and we don't want to open a Chrome window on your machine for each one. Also,
+    under `bazel test` the test will execute and immediately terminate.
 
-    This is re-exported in `//:defs.bzl` as `ts_web_test` so if you load the rule
-    from there, you actually get this macro.
+    Running under `ibazel test` gives you a "watch mode" for your tests. The rule is
+    optimized for this case - the test runner server will stay running and just
+    re-serve the up-to-date JavaScript source bundle.
+
+    To debug a single test target, run it with `bazel run` instead. This will open a
+    browser window on your computer. Also you can use any other browser by opening
+    the URL printed when the test starts up. The test will remain running until you
+    cancel the `bazel run` command.
+
+    This rule will use your system Chrome. Your environment must specify CHROME_BIN
+    so that the rule will know which Chrome binary to run.
+
+    Currently this rule uses Karma as the test runner under the hood, but this is
+    an implementation detail. We might switch to another runner like Jest in the future.
 
     Args:
-      karma: karma binary label
-      tags: standard Bazel tags, this macro adds a couple for ibazel
-      data: runtime dependencies
-      **kwargs: passed through to `ts_web_test`
+      srcs: A list of JavaScript test files
+      deps: Other targets which produce JavaScript such as `ts_library`
+      data: Runtime dependencies
+      configuration_env_vars: Pass these configuration environment variables to the resulting binary.
+          Chooses a subset of the configuration environment variables (taken from ctx.var), which also
+          includes anything specified via the --define flag.
+          Note, this can lead to different outputs produced by this rule.
+      bootstrap: JavaScript files to include *before* the module loader (require.js).
+          For example, you can include Reflect,js for TypeScript decorator metadata reflection,
+          or UMD bundles for third-party libraries.
+      runtime_deps: Dependencies which should be loaded after the module loader but before the srcs and deps.
+          These should be a list of targets which produce JavaScript such as `ts_library`.
+          The files will be loaded in the same order they are declared by that rule.
+      static_files: Arbitrary files which are available to be served on request.
+          Files are served at:
+          `/base/<WORKSPACE_NAME>/<path-to-file>`, e.g.
+          `/base/build_bazel_rules_typescript/examples/testing/static_script.js`
+      tags: Standard Bazel tags, this macro adds tags for ibazel support as well as
+          `browser:chromium-system` to allow for filtering on systems with no
+          system Chrome.
+      **kwargs: Passed through to `ts_web_test`
     """
 
-    ts_web_test(
-        karma = karma,
+    _ts_web_test(
+        srcs = srcs,
+        deps = deps,
+        data = data,
+        configuration_env_vars = configuration_env_vars,
+        bootstrap = bootstrap,
+        runtime_deps = runtime_deps,
+        static_files = static_files,
         tags = tags + [
             # Users don't need to know that this tag is required to run under ibazel
             "ibazel_notify_changes",
             # Always attach this label to allow filtering, eg. envs w/ no browser
             "browser:chromium-system",
         ],
-        # Our binary dependency must be in data[] for collect_data to pick it up
-        # FIXME: maybe we can just ask the attr.karma for its runfiles attr
-        data = data + [karma],
         **kwargs
     )
 
 def ts_web_test_suite(
         name,
         browsers = ["@io_bazel_rules_webtesting//browsers:chromium-local"],
-        karma = Label(_DEFAULT_KARMA_BIN),
         args = None,
         browser_overrides = None,
         config = None,
@@ -300,10 +129,12 @@
         **remaining_keyword_args):
     """Defines a test_suite of web_test targets that wrap a ts_web_test target.
 
+    This macro also accepts all parameters in ts_web_test. See ts_web_test docs for
+    details.
+
     Args:
       name: The base name of the test.
       browsers: A sequence of labels specifying the browsers to use.
-      karma: karma binary label
       args: Args for web_test targets generated by this extension.
       browser_overrides: Dictionary; optional; default is an empty dictionary. A
         dictionary mapping from browser names to browser-specific web_test
@@ -316,12 +147,12 @@
       local: boolean; optional.
       shard_count: The number of test shards to use per browser. (default: 1)
       size: A string specifying the test size. (default: 'large')
-      tags: A list of test tag strings to apply to each generated web_test target.
+      tags: A list of test tag strings to apply to each generated web_test_suite target.
         This macro adds a couple for ibazel.
       test_suite_tags: A list of tag strings for the generated test_suite.
       timeout: A string specifying the test timeout (default: computed from size)
       visibility: List of labels; optional.
-      web_test_data: Data dependencies for the web_test.
+      web_test_data: Data dependencies for the web_test_suite.
       wrapped_test_tags: A list of test tag strings to use for the wrapped test
       **remaining_keyword_args: Arguments for the wrapped test target.
     """
@@ -334,16 +165,8 @@
 
     wrapped_test_name = name + "_wrapped_test"
 
-    # Users don't need to know that this tag is required to run under ibazel
-    tags = tags + ["ibazel_notify_changes"]
-
-    # Our binary dependency must be in data[] for collect_data to pick it up
-    # FIXME: maybe we can just ask the attr.karma for its runfiles attr
-    web_test_data = web_test_data + [karma]
-
-    ts_web_test(
+    _ts_web_test(
         name = wrapped_test_name,
-        karma = karma,
         args = args,
         flaky = flaky,
         local = local,
@@ -367,7 +190,10 @@
         local = local,
         shard_count = shard_count,
         size = size,
-        tags = tags,
+        tags = tags + [
+            # Users don't need to know that this tag is required to run under ibazel
+            "ibazel_notify_changes",
+        ],
         test = wrapped_test_name,
         test_suite_tags = test_suite_tags,
         timeout = timeout,
diff --git a/internal/karma/web_test.bzl b/internal/karma/web_test.bzl
new file mode 100644
index 0000000..b40d4e0
--- /dev/null
+++ b/internal/karma/web_test.bzl
@@ -0,0 +1,40 @@
+# Copyright 2017 The Bazel Authors. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"Common web_test attributes"
+
+load("@build_bazel_rules_nodejs//internal:node.bzl", "sources_aspect")
+
+# Attributes shared by any web_test rule (ts_web_test, karma_web_test, protractor_web_test)
+COMMON_WEB_TEST_ATTRS = {
+    "srcs": attr.label_list(
+        doc = "A list of JavaScript test files",
+        allow_files = [".js"],
+    ),
+    "configuration_env_vars": attr.string_list(
+        doc = """Pass these configuration environment variables to the resulting binary.
+        Chooses a subset of the configuration environment variables (taken from ctx.var), which also
+        includes anything specified via the --define flag.
+        Note, this can lead to different outputs produced by this rule.""",
+        default = [],
+    ),
+    "data": attr.label_list(
+        doc = "Runtime dependencies",
+        allow_files = True,
+    ),
+    "deps": attr.label_list(
+        doc = "Other targets which produce JavaScript such as `ts_library`",
+        allow_files = True,
+        aspects = [sources_aspect],
+    ),
+}
diff --git a/package.json b/package.json
index 4140a45..724a1f2 100644
--- a/package.json
+++ b/package.json
@@ -8,8 +8,9 @@
         "karma-chrome-launcher": "2.2.0",
         "karma-firefox-launcher": "1.1.0",
         "karma-jasmine": "1.1.1",
+        "karma-json-result-reporter": "1.0.0",
         "karma-requirejs": "1.1.0",
-        "karma-sauce-launcher": "1.2.0",
+        "karma-sauce-launcher": "2.0.2",
         "karma-sourcemap-loader": "0.3.7",
         "protobufjs": "5.0.3",
         "requirejs": "2.3.5",
diff --git a/yarn.lock b/yarn.lock
index 20b3f83..c6df00c 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -197,32 +197,6 @@
   resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a"
   integrity sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==
 
-archiver-utils@^1.3.0:
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/archiver-utils/-/archiver-utils-1.3.0.tgz#e50b4c09c70bf3d680e32ff1b7994e9f9d895174"
-  integrity sha1-5QtMCccL89aA4y/xt5lOn52JUXQ=
-  dependencies:
-    glob "^7.0.0"
-    graceful-fs "^4.1.0"
-    lazystream "^1.0.0"
-    lodash "^4.8.0"
-    normalize-path "^2.0.0"
-    readable-stream "^2.0.0"
-
-archiver@2.1.1:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/archiver/-/archiver-2.1.1.tgz#ff662b4a78201494a3ee544d3a33fe7496509ebc"
-  integrity sha1-/2YrSnggFJSj7lRNOjP+dJZQnrw=
-  dependencies:
-    archiver-utils "^1.3.0"
-    async "^2.0.0"
-    buffer-crc32 "^0.2.1"
-    glob "^7.0.0"
-    lodash "^4.8.0"
-    readable-stream "^2.0.0"
-    tar-stream "^1.5.0"
-    zip-stream "^1.2.0"
-
 are-we-there-yet@~1.1.2:
   version "1.1.5"
   resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz#4b35c2944f062a8bfcda66410760350fe9ddfc21"
@@ -333,19 +307,12 @@
   resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.0.tgz#78faed8c3d074ab81f22b4e985d79e8738f720f8"
   integrity sha512-jp/uFnooOiO+L211eZOoSyzpOITMXx1rBITauYykG3BRYPu8h0UcxsPNB04RR5vo4Tyz3+ay17tR6JVf9qzYWg==
 
-async@2.0.1:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/async/-/async-2.0.1.tgz#b709cc0280a9c36f09f4536be823c838a9049e25"
-  integrity sha1-twnMAoCpw28J9FNr6CPIOKkEniU=
-  dependencies:
-    lodash "^4.8.0"
-
 async@^1.5.2:
   version "1.5.2"
   resolved "https://registry.yarnpkg.com/async/-/async-1.5.2.tgz#ec6a61ae56480c0c3cb241c95618e20892f9672a"
   integrity sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo=
 
-async@^2.0.0, async@^2.1.2, async@~2.6.0:
+async@^2.1.2, async@~2.6.0:
   version "2.6.1"
   resolved "https://registry.yarnpkg.com/async/-/async-2.6.1.tgz#b245a23ca71930044ec53fa46aa00a3e87c6a610"
   integrity sha512-fNEiL2+AZt6AlAw/29Cr0UDe4sRAHCpEHh54WMz+Bb7QfNcFw4h3loofyJpLeQs4Yx7yuqu/2dLgM5hKOs6HlQ==
@@ -404,11 +371,6 @@
   resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-0.1.5.tgz#73926771923b5a19747ad666aa5cd4bf9c6e9ce8"
   integrity sha1-c5JncZI7Whl0etZmqlzUv5xunOg=
 
-base64-js@^1.0.2:
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.3.0.tgz#cab1e6118f051095e58b5281aea8c1cd22bfc0e3"
-  integrity sha512-ccav/yGvoa80BQDljCxsmmQ3Xvx60/UpBIij5QN21W3wBi/hhIC9OoO+KLpu9IJTS9j4DRVJ3aDDF9cMSoa2lw==
-
 base64id@1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/base64id/-/base64id-1.0.0.tgz#47688cb99bb6804f0e06d3e763b1c32e57d8e6b6"
@@ -453,14 +415,6 @@
   dependencies:
     buffer-more-ints "0.0.2"
 
-bl@^1.0.0:
-  version "1.2.2"
-  resolved "https://registry.yarnpkg.com/bl/-/bl-1.2.2.tgz#a160911717103c07410cef63ef51b397c025af9c"
-  integrity sha512-e8tQYnZodmebYDWGH7KMRvtzKXaJHx3BbilrgZCfvyLUYdKpK1t5PSPmpkny/SgiTSCnjfLW7v5rlONXVFkQEA==
-  dependencies:
-    readable-stream "^2.3.5"
-    safe-buffer "^5.1.1"
-
 bl@~1.1.2:
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/bl/-/bl-1.1.2.tgz#fdca871a99713aa00d19e3bbba41c44787a65398"
@@ -567,7 +521,7 @@
   resolved "https://registry.yarnpkg.com/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz#bd7dc26ae2972d0eda253be061dba992349c19f0"
   integrity sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==
 
-buffer-alloc@^1.1.0, buffer-alloc@^1.2.0:
+buffer-alloc@^1.2.0:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/buffer-alloc/-/buffer-alloc-1.2.0.tgz#890dd90d923a873e08e10e5fd51a57e5b7cce0ec"
   integrity sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==
@@ -575,11 +529,6 @@
     buffer-alloc-unsafe "^1.1.0"
     buffer-fill "^1.0.0"
 
-buffer-crc32@^0.2.1:
-  version "0.2.13"
-  resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242"
-  integrity sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=
-
 buffer-fill@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/buffer-fill/-/buffer-fill-1.0.0.tgz#f8f78b76789888ef39f205cd637f68e702122b2c"
@@ -595,14 +544,6 @@
   resolved "https://registry.yarnpkg.com/buffer-more-ints/-/buffer-more-ints-0.0.2.tgz#26b3885d10fa13db7fc01aae3aab870199e0124c"
   integrity sha1-JrOIXRD6E9t/wBquOquHAZngEkw=
 
-buffer@^5.1.0:
-  version "5.2.1"
-  resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.2.1.tgz#dd57fa0f109ac59c602479044dca7b8b3d0b71d6"
-  integrity sha512-c+Ko0loDaFfuPWiL02ls9Xd3GO3cPVmUobQ6t3rXNUk304u6hGq+8N/kFi+QEIKhzK3uwolVhLzszmfLmMLnqg==
-  dependencies:
-    base64-js "^1.0.2"
-    ieee754 "^1.1.4"
-
 buildmail@4.0.1:
   version "4.0.1"
   resolved "https://registry.yarnpkg.com/buildmail/-/buildmail-4.0.1.tgz#877f7738b78729871c9a105e3b837d2be11a7a72"
@@ -825,16 +766,6 @@
   resolved "https://registry.yarnpkg.com/component-inherit/-/component-inherit-0.0.3.tgz#645fc4adf58b72b649d5cae65135619db26ff143"
   integrity sha1-ZF/ErfWLcrZJ1crmUTVhnbJv8UM=
 
-compress-commons@^1.2.0:
-  version "1.2.2"
-  resolved "https://registry.yarnpkg.com/compress-commons/-/compress-commons-1.2.2.tgz#524a9f10903f3a813389b0225d27c48bb751890f"
-  integrity sha1-UkqfEJA/OoEzibAiXSfEi7dRiQ8=
-  dependencies:
-    buffer-crc32 "^0.2.1"
-    crc32-stream "^2.0.0"
-    normalize-path "^2.0.0"
-    readable-stream "^2.0.0"
-
 concat-map@0.0.1:
   version "0.0.1"
   resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
@@ -904,21 +835,6 @@
   resolved "https://registry.yarnpkg.com/corser/-/corser-2.0.1.tgz#8eda252ecaab5840dcd975ceb90d9370c819ff87"
   integrity sha1-jtolLsqrWEDc2XXOuQ2TcMgZ/4c=
 
-crc32-stream@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/crc32-stream/-/crc32-stream-2.0.0.tgz#e3cdd3b4df3168dd74e3de3fbbcb7b297fe908f4"
-  integrity sha1-483TtN8xaN10494/u8t7KX/pCPQ=
-  dependencies:
-    crc "^3.4.4"
-    readable-stream "^2.0.0"
-
-crc@^3.4.4:
-  version "3.8.0"
-  resolved "https://registry.yarnpkg.com/crc/-/crc-3.8.0.tgz#ad60269c2c856f8c299e2c4cc0de4556914056c6"
-  integrity sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==
-  dependencies:
-    buffer "^5.1.0"
-
 cryptiles@2.x.x:
   version "2.0.5"
   resolved "https://registry.yarnpkg.com/cryptiles/-/cryptiles-2.0.5.tgz#3bdfecdc608147c1c67202fa291e7dca59eaa3b8"
@@ -1105,13 +1021,6 @@
   resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59"
   integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=
 
-end-of-stream@^1.0.0:
-  version "1.4.1"
-  resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.1.tgz#ed29634d19baba463b6ce6b80a37213eab71ec43"
-  integrity sha512-1MkrZNvWTKCaigbn+W15elq2BB/L22nqrSY5DKlo3X6+vclJm8Bb5djXJBmEX6fS3+zCh/F4VBK5Z2KxJt4s2Q==
-  dependencies:
-    once "^1.4.0"
-
 engine.io-client@~3.1.0:
   version "3.1.6"
   resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-3.1.6.tgz#5bdeb130f8b94a50ac5cbeb72583e7a4a063ddfd"
@@ -1449,11 +1358,6 @@
   dependencies:
     null-check "^1.0.0"
 
-fs-constants@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad"
-  integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==
-
 fs-minipass@^1.2.5:
   version "1.2.5"
   resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-1.2.5.tgz#06c277218454ec288df77ada54a03b8702aacb9d"
@@ -1482,6 +1386,11 @@
     readable-stream "1.1.x"
     xregexp "2.0.0"
 
+fun-map@^3.3.1:
+  version "3.3.1"
+  resolved "https://registry.yarnpkg.com/fun-map/-/fun-map-3.3.1.tgz#6415fde3b93ad58f9ee9566236cff3e3c64b94cb"
+  integrity sha1-ZBX947k61Y+e6VZiNs/z48ZLlMs=
+
 gauge@~2.7.3:
   version "2.7.4"
   resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.7.4.tgz#2c03405c7538c39d7eb37b317022e325fb018bf7"
@@ -1596,7 +1505,7 @@
     pify "^2.0.0"
     pinkie-promise "^2.0.0"
 
-graceful-fs@^4.1.0, graceful-fs@^4.1.11, graceful-fs@^4.1.2:
+graceful-fs@^4.1.11, graceful-fs@^4.1.2:
   version "4.1.11"
   resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.11.tgz#0e8bdfe4d1ddb8854d64e04ea7c00e2a026e5658"
   integrity sha1-Dovf5NHduIVNZOBOp8AOKgJuVlg=
@@ -1850,11 +1759,6 @@
   dependencies:
     safer-buffer ">= 2.1.2 < 3"
 
-ieee754@^1.1.4:
-  version "1.1.12"
-  resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.12.tgz#50bf24e5b9c8bb98af4964c941cdb0918da7b60b"
-  integrity sha512-GguP+DRY+pJ3soyIiGPTvdiVXjZ+DbXOxGpXn3eMvNW4x4irjqXm4wHKscC+TfxSJ0yw/S1F24tqdMNsMZTiLA==
-
 ignore-walk@^3.0.1:
   version "3.0.1"
   resolved "https://registry.yarnpkg.com/ignore-walk/-/ignore-walk-3.0.1.tgz#a83e62e7d272ac0e3b551aaa82831a19b69f82f8"
@@ -2244,20 +2148,26 @@
   resolved "https://registry.yarnpkg.com/karma-jasmine/-/karma-jasmine-1.1.1.tgz#6fe840e75a11600c9d91e84b33c458e1c46a3529"
   integrity sha1-b+hA51oRYAydkehLM8RY4cRqNSk=
 
+karma-json-result-reporter@1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/karma-json-result-reporter/-/karma-json-result-reporter-1.0.0.tgz#9bfaa17d610470d08556e48ccbaf64d7950c7255"
+  integrity sha1-m/qhfWEEcNCFVuSMy69k15UMclU=
+  dependencies:
+    fun-map "^3.3.1"
+
 karma-requirejs@1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/karma-requirejs/-/karma-requirejs-1.1.0.tgz#fddae2cb87d7ebc16fb0222893564d7fee578798"
   integrity sha1-/driy4fX68FvsCIok1ZNf+5Xh5g=
 
-karma-sauce-launcher@1.2.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/karma-sauce-launcher/-/karma-sauce-launcher-1.2.0.tgz#6f2558ddef3cf56879fa27540c8ae9f8bfd16bca"
-  integrity sha512-lEhtGRGS+3Yw6JSx/vJY9iQyHNtTjcojrSwNzqNUOaDceKDu9dPZqA/kr69bUO9G2T6GKbu8AZgXqy94qo31Jg==
+karma-sauce-launcher@2.0.2:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/karma-sauce-launcher/-/karma-sauce-launcher-2.0.2.tgz#dbf98e70d86bf287b03a537cf637eb7aefa975c3"
+  integrity sha512-jLUFaJhHMcKpxFWUesyWYihzM5FvQiJsDwGcCtKeOy2lsWhkVw0V0Byqb1d+wU6myU1mribBtsIcub23HS4kWA==
   dependencies:
-    q "^1.5.0"
-    sauce-connect-launcher "^1.2.2"
-    saucelabs "^1.4.0"
-    wd "^1.4.0"
+    sauce-connect-launcher "^1.2.4"
+    saucelabs "^1.5.0"
+    selenium-webdriver "^4.0.0-alpha.1"
 
 karma-sourcemap-loader@0.3.7:
   version "0.3.7"
@@ -2322,13 +2232,6 @@
   resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.2.tgz#01146b36a6218e64e58f3a8d66de5d7fc6f6d051"
   integrity sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA==
 
-lazystream@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/lazystream/-/lazystream-1.0.0.tgz#f6995fe0f820392f61396be89462407bb77168e4"
-  integrity sha1-9plf4PggOS9hOWvolGJAe7dxaOQ=
-  dependencies:
-    readable-stream "^2.0.5"
-
 lcid@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/lcid/-/lcid-1.0.0.tgz#308accafa0bc483a3867b4b6f2b9506251d1b835"
@@ -2370,12 +2273,7 @@
   dependencies:
     immediate "~3.0.5"
 
-lodash@4.17.10:
-  version "4.17.10"
-  resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.10.tgz#1b7793cf7259ea38fb3661d4d38b3260af8ae4e7"
-  integrity sha512-UejweD1pDoXu+AD825lWwp4ZGtSwgnpZxb3JDViD7StjQz+Nb/6l093lx4OQ0foGWNRoc19mWy7BzL+UAK2iVg==
-
-lodash@^4.0.0, lodash@^4.15.0, lodash@^4.16.6, lodash@^4.17.10, lodash@^4.17.4, lodash@^4.5.0, lodash@^4.8.0:
+lodash@^4.0.0, lodash@^4.15.0, lodash@^4.16.6, lodash@^4.17.10, lodash@^4.17.4, lodash@^4.5.0:
   version "4.17.11"
   resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.11.tgz#b39ea6229ef607ecd89e2c8df12536891cac9b8d"
   integrity sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg==
@@ -2815,7 +2713,7 @@
   dependencies:
     ee-first "1.1.1"
 
-once@^1.3.0, once@^1.4.0:
+once@^1.3.0:
   version "1.4.0"
   resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1"
   integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E=
@@ -3111,11 +3009,6 @@
   resolved "https://registry.yarnpkg.com/q/-/q-1.4.1.tgz#55705bcd93c5f3673530c2c2cbc0c2b3addc286e"
   integrity sha1-VXBbzZPF82c1MMLCy8DCs63cKG4=
 
-q@^1.5.0:
-  version "1.5.1"
-  resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7"
-  integrity sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc=
-
 qjobs@^1.1.4:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/qjobs/-/qjobs-1.2.0.tgz#c45e9c61800bd087ef88d7e256423bdd49e5d071"
@@ -3185,7 +3078,7 @@
     isarray "0.0.1"
     string_decoder "~0.10.x"
 
-readable-stream@2, readable-stream@^2.0.0, readable-stream@^2.0.2, readable-stream@^2.0.5, readable-stream@^2.0.6, readable-stream@^2.3.0, readable-stream@^2.3.5:
+readable-stream@2, readable-stream@^2.0.2, readable-stream@^2.0.6, readable-stream@^2.3.0:
   version "2.3.6"
   resolved "http://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz#b11c27d88b8ff1fbe070643cf94b0c79ae1b0aaf"
   integrity sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==
@@ -3307,34 +3200,6 @@
     tough-cookie "~2.3.0"
     tunnel-agent "~0.4.1"
 
-request@2.85.0:
-  version "2.85.0"
-  resolved "http://registry.npmjs.org/request/-/request-2.85.0.tgz#5a03615a47c61420b3eb99b7dba204f83603e1fa"
-  integrity sha512-8H7Ehijd4js+s6wuVPLjwORxD4zeuyjYugprdOXlPSqaApmL/QOy+EB/beICHVCHkGMKNh5rvihb5ov+IDw4mg==
-  dependencies:
-    aws-sign2 "~0.7.0"
-    aws4 "^1.6.0"
-    caseless "~0.12.0"
-    combined-stream "~1.0.5"
-    extend "~3.0.1"
-    forever-agent "~0.6.1"
-    form-data "~2.3.1"
-    har-validator "~5.0.3"
-    hawk "~6.0.2"
-    http-signature "~1.2.0"
-    is-typedarray "~1.0.0"
-    isstream "~0.1.2"
-    json-stringify-safe "~5.0.1"
-    mime-types "~2.1.17"
-    oauth-sign "~0.8.2"
-    performance-now "^2.1.0"
-    qs "~6.5.1"
-    safe-buffer "^5.1.1"
-    stringstream "~0.0.5"
-    tough-cookie "~2.3.3"
-    tunnel-agent "^0.6.0"
-    uuid "^3.1.0"
-
 request@^2.0.0, request@^2.74.0:
   version "2.88.0"
   resolved "https://registry.yarnpkg.com/request/-/request-2.88.0.tgz#9c2fca4f7d35b592efe57c7f0a55e81052124fef"
@@ -3460,7 +3325,7 @@
   resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
   integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
 
-sauce-connect-launcher@^1.2.2:
+sauce-connect-launcher@^1.2.4:
   version "1.2.4"
   resolved "https://registry.yarnpkg.com/sauce-connect-launcher/-/sauce-connect-launcher-1.2.4.tgz#8d38f85242a9fbede1b2303b559f7e20c5609a1c"
   integrity sha512-X2vfwulR6brUGiicXKxPm1GJ7dBEeP1II450Uv4bHGrcGOapZNgzJvn9aioea5IC5BPp/7qjKdE3xbbTBIVXMA==
@@ -3471,7 +3336,7 @@
     lodash "^4.16.6"
     rimraf "^2.5.4"
 
-saucelabs@^1.4.0:
+saucelabs@^1.5.0:
   version "1.5.0"
   resolved "https://registry.yarnpkg.com/saucelabs/-/saucelabs-1.5.0.tgz#9405a73c360d449b232839919a86c396d379fd9d"
   integrity sha512-jlX3FGdWvYf4Q3LFfFWS1QvPg3IGCGWxIc8QBFdPTbpTJnt/v17FHXYVAn7C8sHf1yUXo2c7yIM0isDryfYtHQ==
@@ -3516,6 +3381,16 @@
     ws "^1.0.1"
     xml2js "0.4.4"
 
+selenium-webdriver@^4.0.0-alpha.1:
+  version "4.0.0-alpha.1"
+  resolved "https://registry.yarnpkg.com/selenium-webdriver/-/selenium-webdriver-4.0.0-alpha.1.tgz#cc93415e21d2dc1dfd85dfc5f6b55f3ac53933b1"
+  integrity sha512-z88rdjHAv3jmTZ7KSGUkTvo4rGzcDGMq0oXWHNIDK96Gs31JKVdu9+FMtT4KBrVoibg8dUicJDok6GnqqttO5Q==
+  dependencies:
+    jszip "^3.1.3"
+    rimraf "^2.5.4"
+    tmp "0.0.30"
+    xml2js "^0.4.17"
+
 semver@^5.3.0:
   version "5.4.1"
   resolved "https://registry.yarnpkg.com/semver/-/semver-5.4.1.tgz#e059c09d8571f0540823733433505d3a2f00b18e"
@@ -3904,19 +3779,6 @@
   dependencies:
     has-flag "^1.0.0"
 
-tar-stream@^1.5.0:
-  version "1.6.1"
-  resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-1.6.1.tgz#f84ef1696269d6223ca48f6e1eeede3f7e81f395"
-  integrity sha512-IFLM5wp3QrJODQFPm6/to3LJZrONdBY/otxcvDIQzu217zKye6yVR3hhi9lAjrC2Z+m/j5oDxMPb1qcd8cIvpA==
-  dependencies:
-    bl "^1.0.0"
-    buffer-alloc "^1.1.0"
-    end-of-stream "^1.0.0"
-    fs-constants "^1.0.0"
-    readable-stream "^2.3.0"
-    to-buffer "^1.1.0"
-    xtend "^4.0.0"
-
 tar@^4:
   version "4.4.6"
   resolved "https://registry.yarnpkg.com/tar/-/tar-4.4.6.tgz#63110f09c00b4e60ac8bcfe1bf3c8660235fbc9b"
@@ -3964,11 +3826,6 @@
   resolved "https://registry.yarnpkg.com/to-array/-/to-array-0.1.4.tgz#17e6c11f73dd4f3d74cda7a4ff3238e9ad9bf890"
   integrity sha1-F+bBH3PdTz10zaek/zI46a2b+JA=
 
-to-buffer@^1.1.0:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/to-buffer/-/to-buffer-1.1.1.tgz#493bd48f62d7c43fcded313a03dcadb2e1213a80"
-  integrity sha512-lx9B5iv7msuFYE3dytT+KE5tap+rNYw+K4jVkb9R/asAb+pbBSM17jtunHplhBe6RRJdZx3Pn2Jph24O32mOVg==
-
 to-object-path@^0.3.0:
   version "0.3.0"
   resolved "https://registry.yarnpkg.com/to-object-path/-/to-object-path-0.3.0.tgz#297588b7b0e7e0ac08e04e672f85c1f4999e17af"
@@ -4187,11 +4044,6 @@
   resolved "https://registry.yarnpkg.com/uws/-/uws-9.14.0.tgz#fac8386befc33a7a3705cbd58dc47b430ca4dd95"
   integrity sha512-HNMztPP5A1sKuVFmdZ6BPVpBQd5bUjNC8EFMFiICK+oho/OQsAJy5hnIx4btMHiOk8j04f/DbIlqnEZ9d72dqg==
 
-vargs@0.1.0:
-  version "0.1.0"
-  resolved "https://registry.yarnpkg.com/vargs/-/vargs-0.1.0.tgz#6b6184da6520cc3204ce1b407cac26d92609ebff"
-  integrity sha1-a2GE2mUgzDIEzhtAfKwm2SYJ6/8=
-
 verror@1.10.0:
   version "1.10.0"
   resolved "https://registry.yarnpkg.com/verror/-/verror-1.10.0.tgz#3a105ca17053af55d6e270c1f8288682e18da400"
@@ -4206,19 +4058,6 @@
   resolved "https://registry.yarnpkg.com/void-elements/-/void-elements-2.0.1.tgz#c066afb582bb1cb4128d60ea92392e94d5e9dbec"
   integrity sha1-wGavtYK7HLQSjWDqkjkulNXp2+w=
 
-wd@^1.4.0:
-  version "1.10.3"
-  resolved "https://registry.yarnpkg.com/wd/-/wd-1.10.3.tgz#395ac7eb58a98e556369f8f8e5f845d91fb152a3"
-  integrity sha512-ffqqZDtFFLeg5u/4pw2vYKECW+z+vW6vc+7rcqF15uu1/rmw3BydV84BONNc9DIcQ5Z7gQFS/hAuMvj53eVtSg==
-  dependencies:
-    archiver "2.1.1"
-    async "2.0.1"
-    lodash "4.17.10"
-    mkdirp "^0.5.1"
-    q "1.4.1"
-    request "2.85.0"
-    vargs "0.1.0"
-
 webdriver-js-extender@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/webdriver-js-extender/-/webdriver-js-extender-1.0.0.tgz#81c533a9e33d5bfb597b4e63e2cdb25b54777515"
@@ -4386,13 +4225,3 @@
   version "0.1.2"
   resolved "https://registry.yarnpkg.com/yeast/-/yeast-0.1.2.tgz#008e06d8094320c372dbc2f8ed76a0ca6c8ac419"
   integrity sha1-AI4G2AlDIMNy28L47XagymyKxBk=
-
-zip-stream@^1.2.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/zip-stream/-/zip-stream-1.2.0.tgz#a8bc45f4c1b49699c6b90198baacaacdbcd4ba04"
-  integrity sha1-qLxF9MG0lpnGuQGYuqyqzbzUugQ=
-  dependencies:
-    archiver-utils "^1.3.0"
-    compress-commons "^1.2.0"
-    lodash "^4.8.0"
-    readable-stream "^2.0.0"