Repository: karma-runner/karma
Pull request #3635 (2021-01-26 17:41:56)

feat(config): improve `karma.config.parseConfig` error handling
This PR add the ability of Public API users of `parseConfig` to opt-in to throwing errors when a critical failure is encountered.

The current behavior is to exit the process immediately, which denies the API consumer the ability to log or respond to the error at their application's level. 

Related Issue: #3631

google-cla[bot]: 
Thanks for your pull request. It looks like this may be your first contribution to a Google open source project (if not, look below for help). Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

:memo: **Please visit <https://cla.developers.google.com/> to sign.**

Once you've signed (or fixed any issues), please reply here with `@googlebot I signed it!` and we'll verify it.

----

#### What to do if you already signed the CLA

##### Individual signers

*   It's possible we don't have your GitHub username or you're using a different email address on your commit. Check [your existing CLA data](https://cla.developers.google.com/clas) and verify that your [email is set on your git commits](https://help.github.com/articles/setting-your-email-in-git/).

##### Corporate signers

*   Your company has a Point of Contact who decides which employees are authorized to participate. Ask your POC to be added to the group of authorized contributors. If you don't know who your Point of Contact is, direct the Google project maintainer to [go/cla#troubleshoot](http://go/cla#troubleshoot) ([Public version](https://opensource.google/docs/cla/#troubleshoot)).
*   The email used to register you as an authorized contributor must be the email used for the Git commit. Check [your existing CLA data](https://cla.developers.google.com/clas) and verify that your [email is set on your git commits](https://help.github.com/articles/setting-your-email-in-git/).
*   The email used to register you as an authorized contributor must also be [attached to your GitHub account](https://github.com/settings/emails).
		

ℹ️ **Googlers: [Go here](https://goto.google.com/prinfo/https%3A%2F%2Fgithub.com%2Fkarma-runner%2Fkarma%2Fpull%2F3635) for more info**.

<!-- need_sender_cla -->
npetruzzelli: Before I clean up comments and attempt to write tests for the new code, I was hoping to get feedback on this first attempt.
AppVeyorBot: :white_check_mark: [Build karma 2885 completed](https://ci.appveyor.com/project/dignifiedquire/karma/builds/37457487) (commit https://github.com/karma-runner/karma/commit/efd3debc07 by @npetruzzelli)
karmarunnerbot: :white_check_mark: [Build karma 488 completed](https://ci.appveyor.com/project/karmarunnerbot/karma/builds/37457486) (commit https://github.com/karma-runner/karma/commit/efd3debc07 by @npetruzzelli)
karmarunnerbot: :white_check_mark: [Build karma 487 completed](https://ci.appveyor.com/project/karmarunnerbot/karma-8n04t/builds/37457489) (commit https://github.com/karma-runner/karma/commit/efd3debc07 by @npetruzzelli)
npetruzzelli: @googlebot I signed it!
karmarunnerbot: :x: [Build karma 489 failed](https://ci.appveyor.com/project/karmarunnerbot/karma/builds/37458534) (commit https://github.com/karma-runner/karma/commit/2e2583226a by @npetruzzelli)
AppVeyorBot: :x: [Build karma 2886 failed](https://ci.appveyor.com/project/dignifiedquire/karma/builds/37458536) (commit https://github.com/karma-runner/karma/commit/2e2583226a by @npetruzzelli)
karmarunnerbot: :x: [Build karma 488 failed](https://ci.appveyor.com/project/karmarunnerbot/karma-8n04t/builds/37458537) (commit https://github.com/karma-runner/karma/commit/2e2583226a by @npetruzzelli)
karmarunnerbot: :white_check_mark: [Build karma 490 completed](https://ci.appveyor.com/project/karmarunnerbot/karma/builds/37458593) (commit https://github.com/karma-runner/karma/commit/cdc64a3510 by @npetruzzelli)
AppVeyorBot: :white_check_mark: [Build karma 2887 completed](https://ci.appveyor.com/project/dignifiedquire/karma/builds/37458594) (commit https://github.com/karma-runner/karma/commit/cdc64a3510 by @npetruzzelli)
karmarunnerbot: :white_check_mark: [Build karma 489 completed](https://ci.appveyor.com/project/karmarunnerbot/karma-8n04t/builds/37458596) (commit https://github.com/karma-runner/karma/commit/cdc64a3510 by @npetruzzelli)
AppVeyorBot: :white_check_mark: [Build karma 2888 completed](https://ci.appveyor.com/project/dignifiedquire/karma/builds/37458737) (commit https://github.com/karma-runner/karma/commit/3a1cd83c70 by @npetruzzelli)
karmarunnerbot: :white_check_mark: [Build karma 490 completed](https://ci.appveyor.com/project/karmarunnerbot/karma-8n04t/builds/37458741) (commit https://github.com/karma-runner/karma/commit/3a1cd83c70 by @npetruzzelli)
karmarunnerbot: :white_check_mark: [Build karma 491 completed](https://ci.appveyor.com/project/karmarunnerbot/karma/builds/37458742) (commit https://github.com/karma-runner/karma/commit/3a1cd83c70 by @npetruzzelli)
npetruzzelli: I wasn't sure if you intended for `fail` to be used outside of `parseConfig`. If that was the case, the signature would need only a minor change.

```javascript
function fail (logArgs, failOptions) {
    const _logArgs = Array.isArray(logArgs) ? logArgs : [logArgs]
    log.error(..._logArgs )
    if (failOptions && failOptions.throwErrors === true) {
    // ...
}
// in `parseConfig`
fail (message, parseOptions)
fail ([message, e], parseOptions)



// -- OR --

function fail (logArgs, throwErrors) {
    const _logArgs = Array.isArray(logArgs) ? logArgs : [logArgs]
    log.error(..._logArgs )
    if (throwErrors === true) {
    // ...
}
```
devoto13: > I wasn't sure if you intended for fail to be used outside of parseConfig. If that was the case, the signature would need only a minor change.

Good to know! Let's keep it simple here and we can adjust the signature in the next PR (if/when necessary).
karmarunnerbot: :white_check_mark: [Build karma 495 completed](https://ci.appveyor.com/project/karmarunnerbot/karma/builds/37461632) (commit https://github.com/karma-runner/karma/commit/078db6fc9f by @npetruzzelli)
AppVeyorBot: :white_check_mark: [Build karma 2892 completed](https://ci.appveyor.com/project/dignifiedquire/karma/builds/37461634) (commit https://github.com/karma-runner/karma/commit/078db6fc9f by @npetruzzelli)
karmarunnerbot: :white_check_mark: [Build karma 494 completed](https://ci.appveyor.com/project/karmarunnerbot/karma-8n04t/builds/37461635) (commit https://github.com/karma-runner/karma/commit/078db6fc9f by @npetruzzelli)
AppVeyorBot: :white_check_mark: [Build karma 2895 completed](https://ci.appveyor.com/project/dignifiedquire/karma/builds/37476817) (commit https://github.com/karma-runner/karma/commit/7991b9c9e2 by @npetruzzelli)
karmarunnerbot: :white_check_mark: [Build karma 498 completed](https://ci.appveyor.com/project/karmarunnerbot/karma/builds/37476816) (commit https://github.com/karma-runner/karma/commit/7991b9c9e2 by @npetruzzelli)
karmarunnerbot: :white_check_mark: [Build karma 497 completed](https://ci.appveyor.com/project/karmarunnerbot/karma-8n04t/builds/37476818) (commit https://github.com/karma-runner/karma/commit/7991b9c9e2 by @npetruzzelli)

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c55633e0..d7b668de 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,19 @@
+## [6.0.4](https://github.com/karma-runner/karma/compare/v6.0.3...v6.0.4) (2021-02-01)
+
+
+### Bug Fixes
+
+* **cli:** temporarily disable strict parameters validation ([#3641](https://github.com/karma-runner/karma/issues/3641)) ([9c755e0](https://github.com/karma-runner/karma/commit/9c755e0d61f1e8fb0fed1281fc8a331d5f1734be)), closes [#3625](https://github.com/karma-runner/karma/issues/3625)
+* **client:** fix a false positive page reload error in Safari ([#3643](https://github.com/karma-runner/karma/issues/3643)) ([2a57b23](https://github.com/karma-runner/karma/commit/2a57b230cd6b27e1a6e903ca6557c5a6b3e31bf6))
+* ensure that Karma supports running tests on IE 11 ([#3642](https://github.com/karma-runner/karma/issues/3642)) ([dbd1943](https://github.com/karma-runner/karma/commit/dbd1943e6901c4cb86280db7663afde32f9ab86c))
+
+## [6.0.3](https://github.com/karma-runner/karma/compare/v6.0.2...v6.0.3) (2021-01-27)
+
+
+### Bug Fixes
+
+* **plugins:** refactor instantiatePlugin from preproprocessor ([#3628](https://github.com/karma-runner/karma/issues/3628)) ([e02858a](https://github.com/karma-runner/karma/commit/e02858ae0d0de3f05add976b10e4b6b935cc3dd7))
+
 ## [6.0.2](https://github.com/karma-runner/karma/compare/v6.0.1...v6.0.2) (2021-01-25)
 
 
diff --git a/client/karma.js b/client/karma.js
index 20a5e19e..5586bdc9 100644
--- a/client/karma.js
+++ b/client/karma.js
@@ -5,7 +5,6 @@ var util = require('../common/util')
 function Karma (updater, socket, iframe, opener, navigator, location, document) {
   this.updater = updater
   var startEmitted = false
-  var karmaNavigating = false
   var self = this
   var queryParams = util.parseQueryParams(location.search)
   var browserId = queryParams.id || util.generateId('manual-')
@@ -83,21 +82,21 @@ function Karma (updater, socket, iframe, opener, navigator, location, document)
 
   var childWindow = null
   function navigateContextTo (url) {
-    karmaNavigating = true
     if (self.config.useIframe === false) {
       // run in new window
       if (self.config.runInParent === false) {
         // If there is a window already open, then close it
         // DEV: In some environments (e.g. Electron), we don't have setter access for location
         if (childWindow !== null && childWindow.closed !== true) {
+          // The onbeforeunload listener was added by context to catch
+          // unexpected navigations while running tests.
+          childWindow.onbeforeunload = undefined
           childWindow.close()
         }
         childWindow = opener(url)
-        karmaNavigating = false
       // run context on parent element (client_with_context)
       // using window.__karma__.scriptUrls to get the html element strings and load them dynamically
       } else if (url !== 'about:blank') {
-        karmaNavigating = false
         var loadScript = function (idx) {
           if (idx < window.__karma__.scriptUrls.length) {
             var parser = new DOMParser()
@@ -128,15 +127,10 @@ function Karma (updater, socket, iframe, opener, navigator, location, document)
       }
     // run in iframe
     } else {
+      // The onbeforeunload listener was added by the context to catch
+      // unexpected navigations while running tests.
+      iframe.contentWindow.onbeforeunload = undefined
       iframe.src = policy.createURL(url)
-      karmaNavigating = false
-    }
-  }
-
-  this.onbeforeunload = function () {
-    if (!karmaNavigating) {
-      // TODO(vojta): show what test (with explanation about jasmine.UPDATE_INTERVAL)
-      self.error('Some of your tests did a full page reload!')
     }
   }
 
diff --git a/context/karma.js b/context/karma.js
index 677767c9..859d56d1 100644
--- a/context/karma.js
+++ b/context/karma.js
@@ -77,9 +77,9 @@ function ContextKarma (callParentKarmaMethod) {
     contextWindow.onerror = function () {
       return self.error.apply(self, arguments)
     }
-    // DEV: We must defined a function since we don't want to pass the event object
-    contextWindow.onbeforeunload = function (e, b) {
-      callParentKarmaMethod('onbeforeunload', [])
+
+    contextWindow.onbeforeunload = function () {
+      return self.error('Some of your tests did a full page reload!')
     }
 
     contextWindow.dump = function () {
diff --git a/lib/cli.js b/lib/cli.js
index 0aac17e9..6e7fdac4 100644
--- a/lib/cli.js
+++ b/lib/cli.js
@@ -153,7 +153,7 @@ function describeRoot () {
     .command('stop [configFile]', 'Stop the server.', describeStop)
     .command('completion', 'Shell completion for karma.', describeCompletion)
     .demandCommand(1, 'Command not specified.')
-    .strict()
+    .strictCommands()
     .describe('help', 'Print usage and options.')
     .describe('version', 'Print current version.')
 }
@@ -164,6 +164,7 @@ function describeInit (yargs) {
       'INIT - Initialize a config file.\n\n' +
       'Usage:\n' +
       '  $0 init [configFile]')
+    .strictCommands(false)
     .version(false)
     .positional('configFile', {
       describe: 'Name of the generated Karma configuration file',
@@ -215,6 +216,7 @@ function describeRun (yargs) {
       'RUN - Run the tests (requires running server).\n\n' +
       'Usage:\n' +
       '  $0 run [configFile] [-- <clientArgs>]')
+    .strictCommands(false)
     .version(false)
     .positional('configFile', {
       describe: 'Path to the Karma configuration file',
@@ -247,6 +249,7 @@ function describeStop (yargs) {
       'STOP - Stop the server (requires running server).\n\n' +
       'Usage:\n' +
       '  $0 stop [configFile]')
+    .strictCommands(false)
     .version(false)
     .positional('configFile', {
       describe: 'Path to the Karma configuration file',
diff --git a/lib/config.js b/lib/config.js
index 1ea49b85..b08bbde7 100644
--- a/lib/config.js
+++ b/lib/config.js
@@ -351,7 +351,26 @@ const CONFIG_SYNTAX_HELP = '  module.exports = function(config) {\n' +
   '    });\n' +
   '  };\n'
 
-function parseConfig (configFilePath, cliOptions) {
+function parseConfig (configFilePath, cliOptions, parseOptions) {
+  function fail () {
+    log.error(...arguments)
+    if (parseOptions && parseOptions.throwErrors === true) {
+      const errorMessage = Array.from(arguments).join(' ')
+      throw new Error(errorMessage)
+    } else {
+      const warningMessage =
+        'The `parseConfig()` function historically called `process.exit(1)`' +
+        ' when it failed. This behavior is now deprecated and function will' +
+        ' throw an error in the next major release. To suppress this warning' +
+        ' pass `throwErrors: true` as a third argument to opt-in into the new' +
+        ' behavior and adjust your code to respond to the exception' +
+        ' accordingly.' +
+        ' Example: `parseConfig(path, cliOptions, { throwErrors: true })`'
+      log.warn(warningMessage)
+      process.exit(1)
+    }
+  }
+
   let configModule
   if (configFilePath) {
     try {
@@ -360,8 +379,6 @@ function parseConfig (configFilePath, cliOptions) {
         configModule = configModule.default
       }
     } catch (e) {
-      log.error('Error in config file!\n  ' + e.stack || e)
-
       const extension = path.extname(configFilePath)
       if (extension === '.coffee' && !COFFEE_SCRIPT_AVAILABLE) {
         log.error('You need to install CoffeeScript.\n  npm install coffeescript --save-dev')
@@ -370,11 +387,10 @@ function parseConfig (configFilePath, cliOptions) {
       } else if (extension === '.ts' && !TYPE_SCRIPT_AVAILABLE) {
         log.error('You need to install TypeScript.\n  npm install typescript ts-node --save-dev')
       }
-      return process.exit(1)
+      return fail('Error in config file!\n  ' + e.stack || e)
     }
     if (!helper.isFunction(configModule)) {
-      log.error('Config file must export a function!\n' + CONFIG_SYNTAX_HELP)
-      return process.exit(1)
+      return fail('Config file must export a function!\n' + CONFIG_SYNTAX_HELP)
     }
   } else {
     configModule = () => {} // if no config file path is passed, we define a dummy config module.
@@ -395,8 +411,7 @@ function parseConfig (configFilePath, cliOptions) {
   try {
     configModule(config)
   } catch (e) {
-    log.error('Error in config file!\n', e)
-    return process.exit(1)
+    return fail('Error in config file!\n', e)
   }
 
   // merge the config from config file and cliOptions (precedence)
diff --git a/lib/plugin.js b/lib/plugin.js
index 2e0ad5a2..20a18059 100644
--- a/lib/plugin.js
+++ b/lib/plugin.js
@@ -50,4 +50,40 @@ function resolve (plugins, emitter) {
   return modules
 }
 
-exports.resolve = resolve
+/**
+  Create a function to handle errors in plugin loading.
+  @param {Object} injector, the dict of dependency injection objects.
+  @return function closed over injector, which reports errors.
+*/
+function createInstantiatePlugin (injector) {
+  const emitter = injector.get('emitter')
+  // Cache to avoid report errors multiple times per plugin.
+  const pluginInstances = new Map()
+  return function instantiatePlugin (kind, name) {
+    if (pluginInstances.has(name)) {
+      return pluginInstances.get(name)
+    }
+
+    let p
+    try {
+      p = injector.get(`${kind}:${name}`)
+      if (!p) {
+        log.error(`Failed to instantiate ${kind} ${name}`)
+        emitter.emit('load_error', kind, name)
+      }
+    } catch (e) {
+      if (e.message.includes(`No provider for "${kind}:${name}"`)) {
+        log.error(`Cannot load "${name}", it is not registered!\n  Perhaps you are missing some plugin?`)
+      } else {
+        log.error(`Cannot load "${name}"!\n  ` + e.stack)
+      }
+      emitter.emit('load_error', kind, name)
+    }
+    pluginInstances.set(name, p, `${kind}:${name}`)
+    return p
+  }
+}
+
+createInstantiatePlugin.$inject = ['injector']
+
+module.exports = { resolve, createInstantiatePlugin }
diff --git a/lib/preprocessor.js b/lib/preprocessor.js
index 4b56a363..b6bf7569 100644
--- a/lib/preprocessor.js
+++ b/lib/preprocessor.js
@@ -70,36 +70,8 @@ async function runProcessors (preprocessors, file, content) {
   file.sha = CryptoUtils.sha1(content)
 }
 
-function createPriorityPreprocessor (config = {}, preprocessorPriority, basePath, injector) {
-  const emitter = injector.get('emitter')
-  const instances = new Map()
-
-  function instantiatePreprocessor (name) {
-    if (instances.has(name)) {
-      return instances.get(name)
-    }
-
-    let p
-    try {
-      p = injector.get('preprocessor:' + name)
-      if (!p) {
-        log.error(`Failed to instantiate preprocessor ${name}`)
-        emitter.emit('load_error', 'preprocessor', name)
-      }
-    } catch (e) {
-      if (e.message.includes(`No provider for "preprocessor:${name}"`)) {
-        log.error(`Can not load "${name}", it is not registered!\n  Perhaps you are missing some plugin?`)
-      } else {
-        log.error(`Can not load "${name}"!\n  ` + e.stack)
-      }
-      emitter.emit('load_error', 'preprocessor', name)
-    }
-
-    instances.set(name, p)
-    return p
-  }
-  _.union.apply(_, Object.values(config)).forEach(instantiatePreprocessor)
-
+function createPriorityPreprocessor (config = {}, preprocessorPriority, basePath, instantiatePlugin) {
+  _.union.apply(_, Object.values(config)).forEach((name) => instantiatePlugin('preprocessor', name))
   return async function preprocess (file) {
     const buffer = await tryToRead(file.originalPath, log)
     let isBinary = file.isBinary
@@ -121,7 +93,7 @@ function createPriorityPreprocessor (config = {}, preprocessorPriority, basePath
       .sort((a, b) => b[1] - a[1])
       .map((duo) => duo[0])
       .reduce((preProcs, name) => {
-        const p = instantiatePreprocessor(name)
+        const p = instantiatePlugin('preprocessor', name)
 
         if (!isBinary || (p && p.handleBinaryFiles)) {
           preProcs.push(p)
@@ -135,5 +107,5 @@ function createPriorityPreprocessor (config = {}, preprocessorPriority, basePath
   }
 }
 
-createPriorityPreprocessor.$inject = ['config.preprocessors', 'config.preprocessor_priority', 'config.basePath', 'injector']
+createPriorityPreprocessor.$inject = ['config.preprocessors', 'config.preprocessor_priority', 'config.basePath', 'instantiatePlugin']
 exports.createPriorityPreprocessor = createPriorityPreprocessor
diff --git a/lib/server.js b/lib/server.js
index 0cf94ce9..a6ae81da 100644
--- a/lib/server.js
+++ b/lib/server.js
@@ -63,7 +63,15 @@ class Server extends KarmaEventEmitter {
 
     this.loadErrors = []
 
-    const config = cfg.parseConfig(cliOptions.configFile, cliOptions)
+    let config
+    try {
+      config = cfg.parseConfig(cliOptions.configFile, cliOptions, { throwErrors: true })
+    } catch (parseConfigError) {
+      // TODO: change how `done` falls back to exit in next major version
+      //  SEE: https://github.com/karma-runner/karma/pull/3635#discussion_r565399378
+      (done || process.exit)(1)
+      return
+    }
 
     this.log.debug('Final config', util.inspect(config, false, /** depth **/ null))
 
@@ -76,6 +84,7 @@ class Server extends KarmaEventEmitter {
       watcher: ['value', watcher],
       launcher: ['factory', Launcher.factory],
       config: ['value', config],
+      instantiatePlugin: ['factory', plugin.createInstantiatePlugin],
       preprocess: ['factory', preprocessor.createPriorityPreprocessor],
       fileList: ['factory', FileList.factory],
       webServer: ['factory', createWebServer],
diff --git a/package-lock.json b/package-lock.json
index 34025e1a..5bde7941 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,6 +1,6 @@
 {
   "name": "karma",
-  "version": "6.0.2",
+  "version": "6.0.4",
   "lockfileVersion": 1,
   "requires": true,
   "dependencies": {
@@ -4297,31 +4297,31 @@
       }
     },
     "engine.io": {
-      "version": "4.0.5",
-      "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-4.0.5.tgz",
-      "integrity": "sha512-Ri+whTNr2PKklxQkfbGjwEo+kCBUM4Qxk4wtLqLrhH+b1up2NFL9g9pjYWiCV/oazwB0rArnvF/ZmZN2ab5Hpg==",
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-4.1.0.tgz",
+      "integrity": "sha512-vW7EAtn0HDQ4MtT5QbmCHF17TaYLONv2/JwdYsq9USPRZVM4zG7WB3k0Nc321z8EuSOlhGokrYlYx4176QhD0A==",
       "requires": {
         "accepts": "~1.3.4",
         "base64id": "2.0.0",
         "cookie": "~0.4.1",
         "cors": "~2.8.5",
-        "debug": "~4.1.0",
+        "debug": "~4.3.1",
         "engine.io-parser": "~4.0.0",
-        "ws": "^7.1.2"
+        "ws": "~7.4.2"
       },
       "dependencies": {
         "debug": {
-          "version": "4.1.1",
-          "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz",
-          "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==",
+          "version": "4.3.1",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz",
+          "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==",
           "requires": {
-            "ms": "^2.1.1"
+            "ms": "2.1.2"
           }
         },
         "ms": {
-          "version": "2.1.3",
-          "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
-          "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
+          "version": "2.1.2",
+          "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+          "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
         }
       }
     },
@@ -12451,54 +12451,54 @@
       }
     },
     "socket.io": {
-      "version": "3.0.4",
-      "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-3.0.4.tgz",
-      "integrity": "sha512-Vj1jUoO75WGc9txWd311ZJJqS9Dr8QtNJJ7gk2r7dcM/yGe9sit7qOijQl3GAwhpBOz/W8CwkD7R6yob07nLbA==",
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-3.1.0.tgz",
+      "integrity": "sha512-Aqg2dlRh6xSJvRYK31ksG65q4kmBOqU4g+1ukhPcoT6wNGYoIwSYPlCPuRwOO9pgLUajojGFztl6+V2opmKcww==",
       "requires": {
         "@types/cookie": "^0.4.0",
         "@types/cors": "^2.8.8",
-        "@types/node": "^14.14.7",
+        "@types/node": "^14.14.10",
         "accepts": "~1.3.4",
         "base64id": "~2.0.0",
-        "debug": "~4.1.0",
-        "engine.io": "~4.0.0",
-        "socket.io-adapter": "~2.0.3",
-        "socket.io-parser": "~4.0.1"
+        "debug": "~4.3.1",
+        "engine.io": "~4.1.0",
+        "socket.io-adapter": "~2.1.0",
+        "socket.io-parser": "~4.0.3"
       },
       "dependencies": {
         "@types/node": {
-          "version": "14.14.13",
-          "resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.13.tgz",
-          "integrity": "sha512-vbxr0VZ8exFMMAjCW8rJwaya0dMCDyYW2ZRdTyjtrCvJoENMpdUHOT/eTzvgyA5ZnqRZ/sI0NwqAxNHKYokLJQ=="
+          "version": "14.14.22",
+          "resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.22.tgz",
+          "integrity": "sha512-g+f/qj/cNcqKkc3tFqlXOYjrmZA+jNBiDzbP3kH+B+otKFqAdPgVTGP1IeKRdMml/aE69as5S4FqtxAbl+LaMw=="
         },
         "debug": {
-          "version": "4.1.1",
-          "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz",
-          "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==",
+          "version": "4.3.1",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz",
+          "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==",
           "requires": {
-            "ms": "^2.1.1"
+            "ms": "2.1.2"
           }
         },
         "ms": {
-          "version": "2.1.3",
-          "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
-          "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
+          "version": "2.1.2",
+          "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+          "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
         }
       }
     },
     "socket.io-adapter": {
-      "version": "2.0.3",
-      "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.0.3.tgz",
-      "integrity": "sha512-2wo4EXgxOGSFueqvHAdnmi5JLZzWqMArjuP4nqC26AtLh5PoCPsaRbRdah2xhcwTAMooZfjYiNVNkkmmSMaxOQ=="
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.1.0.tgz",
+      "integrity": "sha512-+vDov/aTsLjViYTwS9fPy5pEtTkrbEKsw2M+oVSoFGw6OD1IpvlV1VPhUzNbofCQ8oyMbdYJqDtGdmHQK6TdPg=="
     },
     "socket.io-parser": {
-      "version": "4.0.2",
-      "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.0.2.tgz",
-      "integrity": "sha512-Bs3IYHDivwf+bAAuW/8xwJgIiBNtlvnjYRc4PbXgniLmcP1BrakBoq/QhO24rgtgW7VZ7uAaswRGxutUnlAK7g==",
+      "version": "4.0.4",
+      "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.0.4.tgz",
+      "integrity": "sha512-t+b0SS+IxG7Rxzda2EVvyBZbvFPBCjJoyHuE0P//7OAsN23GItzDRdWa6ALxZI/8R5ygK7jAR6t028/z+7295g==",
       "requires": {
         "@types/component-emitter": "^1.2.10",
         "component-emitter": "~1.3.0",
-        "debug": "~4.1.0"
+        "debug": "~4.3.1"
       },
       "dependencies": {
         "component-emitter": {
@@ -12507,17 +12507,17 @@
           "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg=="
         },
         "debug": {
-          "version": "4.1.1",
-          "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz",
-          "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==",
+          "version": "4.3.1",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz",
+          "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==",
           "requires": {
-            "ms": "^2.1.1"
+            "ms": "2.1.2"
           }
         },
         "ms": {
-          "version": "2.1.3",
-          "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
-          "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
+          "version": "2.1.2",
+          "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+          "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
         }
       }
     },
@@ -13812,9 +13812,9 @@
       }
     },
     "ws": {
-      "version": "7.4.1",
-      "resolved": "https://registry.npmjs.org/ws/-/ws-7.4.1.tgz",
-      "integrity": "sha512-pTsP8UAfhy3sk1lSk/O/s4tjD0CRwvMnzvwr4OKGX7ZvqZtUyx4KIJB5JWbkykPoc55tixMGgTNoh3k4FkNGFQ=="
+      "version": "7.4.2",
+      "resolved": "https://registry.npmjs.org/ws/-/ws-7.4.2.tgz",
+      "integrity": "sha512-T4tewALS3+qsrpGI/8dqNMLIVdq/g/85U98HPMa6F0m6xTbvhXU6RCQLqPH3+SlomNV/LdY6RXEbBpMH6EOJnA=="
     },
     "xmlbuilder": {
       "version": "12.0.0",
diff --git a/package.json b/package.json
index 72c1487b..9e5f9f4a 100644
--- a/package.json
+++ b/package.json
@@ -428,7 +428,7 @@
     "qjobs": "^1.2.0",
     "range-parser": "^1.2.1",
     "rimraf": "^3.0.2",
-    "socket.io": "^3.0.4",
+    "socket.io": "^3.1.0",
     "source-map": "^0.6.1",
     "tmp": "0.2.1",
     "ua-parser-js": "^0.7.23",
@@ -484,7 +484,7 @@
   "engines": {
     "node": ">= 10"
   },
-  "version": "6.0.2",
+  "version": "6.0.4",
   "license": "MIT",
   "husky": {
     "hooks": {
diff --git a/static/context.js b/static/context.js
index 8cc67ebf..41750316 100644
--- a/static/context.js
+++ b/static/context.js
@@ -214,9 +214,9 @@ function ContextKarma (callParentKarmaMethod) {
     contextWindow.onerror = function () {
       return self.error.apply(self, arguments)
     }
-    // DEV: We must defined a function since we don't want to pass the event object
-    contextWindow.onbeforeunload = function (e, b) {
-      callParentKarmaMethod('onbeforeunload', [])
+
+    contextWindow.onbeforeunload = function () {
+      return self.error('Some of your tests did a full page reload!')
     }
 
     contextWindow.dump = function () {
diff --git a/static/karma.js b/static/karma.js
index 1c111145..23b7c1ae 100644
--- a/static/karma.js
+++ b/static/karma.js
@@ -15,7 +15,6 @@ var util = require('../common/util')
 function Karma (updater, socket, iframe, opener, navigator, location, document) {
   this.updater = updater
   var startEmitted = false
-  var karmaNavigating = false
   var self = this
   var queryParams = util.parseQueryParams(location.search)
   var browserId = queryParams.id || util.generateId('manual-')
@@ -93,21 +92,21 @@ function Karma (updater, socket, iframe, opener, navigator, location, document)
 
   var childWindow = null
   function navigateContextTo (url) {
-    karmaNavigating = true
     if (self.config.useIframe === false) {
       // run in new window
       if (self.config.runInParent === false) {
         // If there is a window already open, then close it
         // DEV: In some environments (e.g. Electron), we don't have setter access for location
         if (childWindow !== null && childWindow.closed !== true) {
+          // The onbeforeunload listener was added by context to catch
+          // unexpected navigations while running tests.
+          childWindow.onbeforeunload = undefined
           childWindow.close()
         }
         childWindow = opener(url)
-        karmaNavigating = false
       // run context on parent element (client_with_context)
       // using window.__karma__.scriptUrls to get the html element strings and load them dynamically
       } else if (url !== 'about:blank') {
-        karmaNavigating = false
         var loadScript = function (idx) {
           if (idx < window.__karma__.scriptUrls.length) {
             var parser = new DOMParser()
@@ -138,15 +137,10 @@ function Karma (updater, socket, iframe, opener, navigator, location, document)
       }
     // run in iframe
     } else {
+      // The onbeforeunload listener was added by the context to catch
+      // unexpected navigations while running tests.
+      iframe.contentWindow.onbeforeunload = undefined
       iframe.src = policy.createURL(url)
-      karmaNavigating = false
-    }
-  }
-
-  this.onbeforeunload = function () {
-    if (!karmaNavigating) {
-      // TODO(vojta): show what test (with explanation about jasmine.UPDATE_INTERVAL)
-      self.error('Some of your tests did a full page reload!')
     }
   }
 


Test patch:
diff --git a/test/client/karma.conf.js b/test/client/karma.conf.js
index 0b1281d7..838e3b78 100644
--- a/test/client/karma.conf.js
+++ b/test/client/karma.conf.js
@@ -14,35 +14,20 @@ const launchers = {
     browser: 'firefox',
     os: 'Windows',
     os_version: '10'
+  },
+  bs_safari: {
+    base: 'BrowserStack',
+    browser: 'Safari',
+    os: 'OS X',
+    os_version: 'Big Sur'
+  },
+  bs_ie: {
+    base: 'BrowserStack',
+    browser: 'IE',
+    browser_version: '11.0',
+    os: 'Windows',
+    os_version: '10'
   }
-  // bs_safari: {
-  //   base: 'BrowserStack',
-  //   browser: 'safari',
-  //   browser_version: '9.0',
-  //   os_version: 'El Capitan',
-  //   os: 'OS X'
-  // },
-  // bs_ie_11: {
-  //   base: 'BrowserStack',
-  //   browser: 'ie',
-  //   browser_version: '11.0',
-  //   os: 'Windows',
-  //   os_version: '10'
-  // },
-  // bs_ie_10: {
-  //   base: 'BrowserStack',
-  //   browser: 'ie',
-  //   browser_version: '10.0',
-  //   os: 'Windows',
-  //   os_version: '8'
-  // },
-  // bs_ie_9: {
-  //   base: 'BrowserStack',
-  //   browser: 'ie',
-  //   browser_version: '9.0',
-  //   os: 'Windows',
-  //   os_version: '7'
-  // }
 }
 
 // Verify the install. This will run async but that's ok we'll see the log.
diff --git a/test/client/karma.spec.js b/test/client/karma.spec.js
index b1c50a00..3e7af73d 100644
--- a/test/client/karma.spec.js
+++ b/test/client/karma.spec.js
@@ -17,12 +17,12 @@ describe('Karma', function () {
   beforeEach(function () {
     mockTestStatus = ''
     updater = {
-      updateTestStatus: (s) => {
+      updateTestStatus: function (s) {
         mockTestStatus = s
       }
     }
     socket = new MockSocket()
-    iframe = {}
+    iframe = { contentWindow: {} }
     windowNavigator = {}
     windowLocation = { search: '' }
     windowStub = sinon.stub().returns({})
@@ -454,7 +454,7 @@ describe('Karma', function () {
       clock.tick(500)
 
       ck.complete()
-      setTimeout(() => {
+      setTimeout(function () {
         assert(windowLocation.href === 'http://return.com')
         done()
       }, 5)
diff --git a/test/client/stringify.spec.js b/test/client/stringify.spec.js
index 921ec336..a3c744ca 100644
--- a/test/client/stringify.spec.js
+++ b/test/client/stringify.spec.js
@@ -49,7 +49,9 @@ describe('stringify', function () {
   if (window.Proxy) {
     it('should serialize proxied functions', function () {
       var defProxy = new Proxy(function (d, e, f) { return 'whatever' }, {})
-      assert.deepStrictEqual(stringify(defProxy), 'function () { ... }')
+      // In Safari stringified Proxy object has ProxyObject as a name, but
+      // in other browsers it does not.
+      assert.deepStrictEqual(/^function (ProxyObject)?\(\) { ... }$/.test(stringify(defProxy)), true)
     })
   }
 
diff --git a/test/e2e/cli.feature b/test/e2e/cli.feature
index 476d29a8..e4f23598 100644
--- a/test/e2e/cli.feature
+++ b/test/e2e/cli.feature
@@ -55,52 +55,24 @@ Feature: CLI
         --help     Print usage and options.                                  [boolean]
         --version  Print current version.                                    [boolean]
 
-      Unknown argument: strat
+      Unknown command: strat
       """
 
-  Scenario: Error when option is unknown
-    When I execute Karma with arguments: "start --invalid-option"
-    Then the stderr is exactly:
+  Scenario: No error when unknown option and argument are passed in
+    Given a configuration with:
       """
-      Karma - Spectacular Test Runner for JavaScript.
-
-      START - Start the server / do a single run.
-
-      Usage:
-        karma start [configFile]
-
-      Positionals:
-        configFile  Path to the Karma configuration file                      [string]
-
-      Options:
-        --help                           Print usage and options.            [boolean]
-        --port                           <integer> Port where the server is running.
-        --auto-watch                     Auto watch source files and run on change.
-        --detached                       Detach the server.
-        --no-auto-watch                  Do not watch source files.
-        --log-level                      <disable | error | warn | info | debug> Level
-                                         of logging.
-        --colors                         Use colors when reporting and printing logs.
-        --no-colors                      Do not use colors when reporting or printing
-                                         logs.
-        --reporters                      List of reporters (available: dots, progress,
-                                         junit, growl, coverage).
-        --browsers                       List of browsers to start (eg. --browsers
-                                         Chrome,ChromeCanary,Firefox).
-        --capture-timeout                <integer> Kill browser if does not capture in
-                                         given time [ms].
-        --single-run                     Run the test when browsers captured and exit.
-        --no-single-run                  Disable single-run.
-        --report-slower-than             <integer> Report tests that are slower than
-                                         given time [ms].
-        --fail-on-empty-test-suite       Fail on empty test suite.
-        --no-fail-on-empty-test-suite    Do not fail on empty test suite.
-        --fail-on-failing-test-suite     Fail on failing test suite.
-        --no-fail-on-failing-test-suite  Do not fail on failing test suite.
-        --format-error                   A path to a file that exports the format
-                                         function.                            [string]
-
-      Unknown arguments: invalid-option, invalidOption
+      files = ['basic/plus.js', 'basic/test.js'];
+      browsers = ['ChromeHeadlessNoSandbox'];
+      plugins = [
+        'karma-jasmine',
+        'karma-chrome-launcher'
+      ];
+      """
+    When I execute Karma with arguments: "start sandbox/karma.conf.js unknown-argument --unknown-option"
+    Then it passes with:
+      """
+      ..
+      Chrome Headless
       """
 
   Scenario: Init command help
diff --git a/test/unit/config.spec.js b/test/unit/config.spec.js
index 169c418a..d115a2b6 100644
--- a/test/unit/config.spec.js
+++ b/test/unit/config.spec.js
@@ -46,6 +46,8 @@ describe('config', () => {
       '/conf/invalid.js': () => {
         throw new SyntaxError('Unexpected token =')
       },
+      '/conf/export-not-function.js': 'not-a-function',
+      // '/conf/export-null.js': null, // Same as `/conf/not-exist.js`?
       '/conf/exclude.js': wrapCfg({ exclude: ['one.js', 'sub/two.js'] }),
       '/conf/absolute.js': wrapCfg({ files: ['http://some.com', 'https://more.org/file.js'] }),
       '/conf/both.js': wrapCfg({ files: ['one.js', 'two.js'], exclude: ['third.js'] }),
@@ -57,6 +59,7 @@ describe('config', () => {
     m = loadFile(path.join(__dirname, '/../../lib/config.js'), mocks, {
       global: {},
       process: mocks.process,
+      Error: Error, // Without this, chai's `.throw()` assertion won't correctly check against constructors.
       require (path) {
         if (mockConfigs[path]) {
           return mockConfigs[path]
@@ -123,7 +126,20 @@ describe('config', () => {
       expect(mocks.process.exit).to.have.been.calledWith(1)
     })
 
-    it('should throw and log error if invalid file', () => {
+    it('should log error and throw if file does not exist AND throwErrors is true', () => {
+      function parseConfig () {
+        e.parseConfig('/conf/not-exist.js', {}, { throwErrors: true })
+      }
+
+      expect(parseConfig).to.throw(Error, 'Error in config file!\n  Error: Cannot find module \'/conf/not-exist.js\'')
+      expect(logSpy).to.have.been.called
+      const event = logSpy.lastCall.args
+      expect(event.toString().split('\n').slice(0, 2)).to.be.deep.equal(
+        ['Error in config file!', '  Error: Cannot find module \'/conf/not-exist.js\''])
+      expect(mocks.process.exit).not.to.have.been.called
+    })
+
+    it('should log an error and exit if invalid file', () => {
       e.parseConfig('/conf/invalid.js', {})
 
       expect(logSpy).to.have.been.called
@@ -133,6 +149,32 @@ describe('config', () => {
       expect(mocks.process.exit).to.have.been.calledWith(1)
     })
 
+    it('should log an error and throw if invalid file AND throwErrors is true', () => {
+      function parseConfig () {
+        e.parseConfig('/conf/invalid.js', {}, { throwErrors: true })
+      }
+
+      expect(parseConfig).to.throw(Error, 'Error in config file!\n SyntaxError: Unexpected token =')
+      expect(logSpy).to.have.been.called
+      const event = logSpy.lastCall.args
+      expect(event[0]).to.eql('Error in config file!\n')
+      expect(event[1].message).to.eql('Unexpected token =')
+      expect(mocks.process.exit).not.to.have.been.called
+    })
+
+    it('should log error and throw if file does not export a function AND throwErrors is true', () => {
+      function parseConfig () {
+        e.parseConfig('/conf/export-not-function.js', {}, { throwErrors: true })
+      }
+
+      expect(parseConfig).to.throw(Error, 'Config file must export a function!\n')
+      expect(logSpy).to.have.been.called
+      const event = logSpy.lastCall.args
+      expect(event.toString().split('\n').slice(0, 1)).to.be.deep.equal(
+        ['Config file must export a function!'])
+      expect(mocks.process.exit).not.to.have.been.called
+    })
+
     it('should override config with given cli options', () => {
       const config = e.parseConfig('/home/config4.js', { port: 456, autoWatch: false })
 
diff --git a/test/unit/mocha-globals.js b/test/unit/mocha-globals.js
index 8afdd587..6659714e 100644
--- a/test/unit/mocha-globals.js
+++ b/test/unit/mocha-globals.js
@@ -1,6 +1,7 @@
 const sinon = require('sinon')
 const chai = require('chai')
 const logger = require('../../lib/logger')
+const recording = require('log4js/lib/appenders/recording')
 
 // publish globals that all specs can use
 global.expect = chai.expect
@@ -15,13 +16,14 @@ chai.use(require('chai-subset'))
 beforeEach(() => {
   global.sinon = sinon.createSandbox()
 
-  // set logger to log INFO, but do not append to console
-  // so that we can assert logs by logger.on('info', ...)
-  logger.setup('INFO', false, [])
+  // Use https://log4js-node.github.io/log4js-node/recording.html to verify logs
+  const vcr = { vcr: { type: 'recording' } }
+  logger.setup('INFO', false, vcr)
 })
 
 afterEach(() => {
   global.sinon.restore()
+  recording.erase()
 })
 
 // TODO(vojta): move to helpers or something
diff --git a/test/unit/plugin.spec.js b/test/unit/plugin.spec.js
new file mode 100644
index 00000000..3ec9824d
--- /dev/null
+++ b/test/unit/plugin.spec.js
@@ -0,0 +1,61 @@
+'use strict'
+
+const createInstantiatePlugin = require('../../lib/plugin').createInstantiatePlugin
+
+describe('plugin', () => {
+  describe('createInstantiatePlugin', () => {
+    it('creates the instantiatePlugin function', () => {
+      const fakeGet = sinon.stub()
+      const fakeInjector = { get: fakeGet }
+
+      expect(typeof createInstantiatePlugin(fakeInjector)).to.be.equal('function')
+      expect(fakeGet).to.have.been.calledWith('emitter')
+    })
+
+    it('creates the instantiatePlugin function', () => {
+      const fakes = {
+        emitter: { emit: sinon.stub() }
+      }
+      const fakeInjector = { get: (id) => fakes[id] }
+
+      const instantiatePlugin = createInstantiatePlugin(fakeInjector)
+      expect(typeof instantiatePlugin('kind', 'name')).to.be.equal('undefined')
+      expect(fakes.emitter.emit).to.have.been.calledWith('load_error', 'kind', 'name')
+    })
+
+    it('caches plugins', () => {
+      const fakes = {
+        emitter: { emit: sinon.stub() },
+        'kind:name': { my: 'plugin' }
+      }
+      const fakeInjector = {
+        get: (id) => {
+          return fakes[id]
+        }
+      }
+
+      const instantiatePlugin = createInstantiatePlugin(fakeInjector)
+      expect(instantiatePlugin('kind', 'name')).to.be.equal(fakes['kind:name'])
+      fakeInjector.get = (id) => { throw new Error('failed to cache') }
+      expect(instantiatePlugin('kind', 'name')).to.be.equal(fakes['kind:name'])
+    })
+
+    it('errors if the injector errors', () => {
+      const fakes = {
+        emitter: { emit: sinon.stub() }
+      }
+      const fakeInjector = {
+        get: (id) => {
+          if (id in fakes) {
+            return fakes[id]
+          }
+          throw new Error('fail')
+        }
+      }
+
+      const instantiatePlugin = createInstantiatePlugin(fakeInjector)
+      expect(typeof instantiatePlugin('unknown', 'name')).to.be.equal('undefined')
+      expect(fakes.emitter.emit).to.have.been.calledWith('load_error', 'unknown', 'name')
+    })
+  })
+})
diff --git a/test/unit/preprocessor.spec.js b/test/unit/preprocessor.spec.js
index 2b5fefbc..5f0b7860 100644
--- a/test/unit/preprocessor.spec.js
+++ b/test/unit/preprocessor.spec.js
@@ -1,18 +1,19 @@
 'use strict'
 
 const mocks = require('mocks')
-const di = require('di')
 const path = require('path')
 
-const events = require('../../lib/events')
-
 describe('preprocessor', () => {
   let m
   let mockFs
-  let emitterSetting
   // mimic first few bytes of a pdf file
   const binarydata = Buffer.from([0x25, 0x50, 0x44, 0x66, 0x46, 0x00])
 
+  // Each test will define a spy; the fakeInstatiatePlugin will return it.
+  let fakePreprocessor
+
+  const simpleFakeInstantiatePlugin = () => { return fakePreprocessor }
+
   beforeEach(() => {
     mockFs = mocks.fs.create({
       some: {
@@ -32,22 +33,16 @@ describe('preprocessor', () => {
       'graceful-fs': mockFs,
       minimatch: require('minimatch')
     }
-    emitterSetting = { emitter: ['value', new events.EventEmitter()] }
     m = mocks.loadFile(path.join(__dirname, '/../../lib/preprocessor.js'), mocks_)
   })
 
   it('should preprocess matching file', async () => {
-    const fakePreprocessor = sinon.spy((content, file, done) => {
+    fakePreprocessor = sinon.spy((content, file, done) => {
       file.path = file.path + '-preprocessed'
       done(null, 'new-content')
     })
 
-    const injector = new di.Injector([{
-      'preprocessor:fake': [
-        'factory', function () { return fakePreprocessor }
-      ]
-    }, emitterSetting])
-    const pp = m.createPriorityPreprocessor({ '**/*.js': ['fake'] }, {}, null, injector)
+    const pp = m.createPriorityPreprocessor({ '**/*.js': ['fake'] }, {}, null, simpleFakeInstantiatePlugin)
 
     const file = { originalPath: '/some/a.js', path: 'path' }
 
@@ -58,15 +53,12 @@ describe('preprocessor', () => {
   })
 
   it('should match directories starting with a dot', async () => {
-    const fakePreprocessor = sinon.spy((content, file, done) => {
+    fakePreprocessor = sinon.spy((content, file, done) => {
       file.path = file.path + '-preprocessed'
       done(null, 'new-content')
     })
 
-    const injector = new di.Injector([{
-      'preprocessor:fake': ['factory', function () { return fakePreprocessor }]
-    }, emitterSetting])
-    const pp = m.createPriorityPreprocessor({ '**/*.js': ['fake'] }, {}, null, injector)
+    const pp = m.createPriorityPreprocessor({ '**/*.js': ['fake'] }, {}, null, simpleFakeInstantiatePlugin)
 
     const file = { originalPath: '/some/.dir/a.js', path: 'path' }
 
@@ -77,15 +69,12 @@ describe('preprocessor', () => {
   })
 
   it('should get content if preprocessor is an async function or return Promise with content', async () => {
-    const fakePreprocessor = sinon.spy(async (content, file, done) => {
+    fakePreprocessor = sinon.spy(async (content, file, done) => {
       file.path = file.path + '-preprocessed'
       return 'new-content'
     })
 
-    const injector = new di.Injector([{
-      'preprocessor:fake': ['factory', function () { return fakePreprocessor }]
-    }, emitterSetting])
-    const pp = m.createPriorityPreprocessor({ '**/*.js': ['fake'] }, {}, null, injector)
+    const pp = m.createPriorityPreprocessor({ '**/*.js': ['fake'] }, {}, null, simpleFakeInstantiatePlugin)
 
     const file = { originalPath: '/some/.dir/a.js', path: 'path' }
 
@@ -96,15 +85,12 @@ describe('preprocessor', () => {
   })
 
   it('should get content if preprocessor is an async function still calling done()', async () => {
-    const fakePreprocessor = sinon.spy(async (content, file, done) => {
+    fakePreprocessor = sinon.spy(async (content, file, done) => {
       file.path = file.path + '-preprocessed'
       done(null, 'new-content')
     })
 
-    const injector = new di.Injector([{
-      'preprocessor:fake': ['factory', function () { return fakePreprocessor }]
-    }, emitterSetting])
-    const pp = m.createPriorityPreprocessor({ '**/*.js': ['fake'] }, {}, null, injector)
+    const pp = m.createPriorityPreprocessor({ '**/*.js': ['fake'] }, {}, null, simpleFakeInstantiatePlugin)
 
     const file = { originalPath: '/some/.dir/a.js', path: 'path' }
 
@@ -115,16 +101,13 @@ describe('preprocessor', () => {
   })
 
   it('should check patterns after creation when invoked', async () => {
-    const fakePreprocessor = sinon.spy((content, file, done) => {
+    fakePreprocessor = sinon.spy((content, file, done) => {
       file.path = file.path + '-preprocessed'
       done(null, 'new-content')
     })
 
-    const injector = new di.Injector([{
-      'preprocessor:fake': ['factory', function () { return fakePreprocessor }]
-    }, emitterSetting])
     const config = { '**/*.txt': ['fake'] }
-    const pp = m.createPriorityPreprocessor(config, {}, null, injector)
+    const pp = m.createPriorityPreprocessor(config, {}, null, simpleFakeInstantiatePlugin)
 
     const file = { originalPath: '/some/a.js', path: 'path' }
 
@@ -137,14 +120,11 @@ describe('preprocessor', () => {
   })
 
   it('should ignore not matching file', async () => {
-    const fakePreprocessor = sinon.spy((content, file, done) => {
+    fakePreprocessor = sinon.spy((content, file, done) => {
       done(null, '')
     })
 
-    const injector = new di.Injector([{
-      'preprocessor:fake': ['factory', function () { return fakePreprocessor }]
-    }, emitterSetting])
-    const pp = m.createPriorityPreprocessor({ '**/*.js': ['fake'] }, {}, null, injector)
+    const pp = m.createPriorityPreprocessor({ '**/*.js': ['fake'] }, {}, null, simpleFakeInstantiatePlugin)
 
     const file = { originalPath: '/some/a.txt', path: 'path' }
 
@@ -153,34 +133,33 @@ describe('preprocessor', () => {
   })
 
   it('should apply all preprocessors', async () => {
-    const fakePreprocessor1 = sinon.spy((content, file, done) => {
-      file.path = file.path + '-p1'
-      done(null, content + '-c1')
-    })
-
-    const fakePreprocessor2 = sinon.spy((content, file, done) => {
-      file.path = file.path + '-p2'
-      done(content + '-c2')
-    })
-
-    const injector = new di.Injector([{
-      'preprocessor:fake1': ['factory', function () { return fakePreprocessor1 }],
-      'preprocessor:fake2': ['factory', function () { return fakePreprocessor2 }]
-    }, emitterSetting])
+    const fakes = {
+      fake1: sinon.spy((content, file, done) => {
+        file.path = file.path + '-p1'
+        done(null, content + '-c1')
+      }),
+      fake2: sinon.spy((content, file, done) => {
+        file.path = file.path + '-p2'
+        done(content + '-c2')
+      })
+    }
+    function fakeInstatiatePlugin (kind, name) {
+      return fakes[name]
+    }
 
-    const pp = m.createPriorityPreprocessor({ '**/*.js': ['fake1', 'fake2'] }, {}, null, injector)
+    const pp = m.createPriorityPreprocessor({ '**/*.js': ['fake1', 'fake2'] }, {}, null, fakeInstatiatePlugin)
 
     const file = { originalPath: '/some/a.js', path: 'path' }
 
     await pp(file)
-    expect(fakePreprocessor1).to.have.been.calledOnce
-    expect(fakePreprocessor2).to.have.been.calledOnce
+    expect(fakes.fake1).to.have.been.calledOnce
+    expect(fakes.fake2).to.have.been.calledOnce
     expect(file.path).to.equal('path-p1-p2')
     expect(file.content).to.equal('content-c1-c2')
   })
 
   it('should compute SHA', async () => {
-    const pp = m.createPriorityPreprocessor({}, {}, null, new di.Injector([emitterSetting]))
+    const pp = m.createPriorityPreprocessor({}, {}, null, simpleFakeInstantiatePlugin)
     const file = { originalPath: '/some/a.js', path: 'path' }
 
     await pp(file)
@@ -198,15 +177,11 @@ describe('preprocessor', () => {
   })
 
   it('should compute SHA from content returned by a processor', async () => {
-    const fakePreprocessor = sinon.spy((content, file, done) => {
+    fakePreprocessor = sinon.spy((content, file, done) => {
       done(null, content + '-processed')
     })
 
-    const injector = new di.Injector([{
-      'preprocessor:fake': ['factory', function () { return fakePreprocessor }]
-    }, emitterSetting])
-
-    const pp = m.createPriorityPreprocessor({ '**/a.js': ['fake'] }, {}, null, injector)
+    const pp = m.createPriorityPreprocessor({ '**/a.js': ['fake'] }, {}, null, simpleFakeInstantiatePlugin)
 
     const fileProcess = { originalPath: '/some/a.js', path: 'path' }
     const fileSkip = { originalPath: '/some/b.js', path: 'path' }
@@ -221,15 +196,11 @@ describe('preprocessor', () => {
   })
 
   it('should return error if any preprocessor fails', () => {
-    const failingPreprocessor = sinon.spy((content, file, done) => {
+    fakePreprocessor = sinon.spy((content, file, done) => {
       done(new Error('Some error'), null)
     })
 
-    const injector = new di.Injector([{
-      'preprocessor:failing': ['factory', function () { return failingPreprocessor }]
-    }, emitterSetting])
-
-    const pp = m.createPriorityPreprocessor({ '**/*.js': ['failing'] }, {}, null, injector)
+    const pp = m.createPriorityPreprocessor({ '**/*.js': ['failing'] }, {}, null, simpleFakeInstantiatePlugin)
 
     const file = { originalPath: '/some/a.js', path: 'path' }
 
@@ -241,20 +212,20 @@ describe('preprocessor', () => {
   })
 
   it('should stop preprocessing after an error', async () => {
-    const failingPreprocessor = sinon.spy((content, file, done) => {
-      done(new Error('Some error'), null)
-    })
-
-    const fakePreprocessor = sinon.spy((content, file, done) => {
-      done(null, content)
-    })
+    const fakes = {
+      failing: sinon.spy((content, file, done) => {
+        done(new Error('Some error'), null)
+      }),
+      fake: sinon.spy((content, file, done) => {
+        done(null, content)
+      })
+    }
 
-    const injector = new di.Injector([{
-      'preprocessor:failing': ['factory', function () { return failingPreprocessor }],
-      'preprocessor:fake': ['factory', function () { return fakePreprocessor }]
-    }, emitterSetting])
+    function fakeInstantiatePlugin (kind, name) {
+      return fakes[name]
+    }
 
-    const pp = m.createPriorityPreprocessor({ '**/*.js': ['failing', 'fake'] }, {}, null, injector)
+    const pp = m.createPriorityPreprocessor({ '**/*.js': ['failing', 'fake'] }, {}, null, fakeInstantiatePlugin)
 
     const file = { originalPath: '/some/a.js', path: 'path' }
 
@@ -263,7 +234,7 @@ describe('preprocessor', () => {
     }, (err) => {
       expect(err.message).to.equal('Some error')
     })
-    expect(fakePreprocessor).not.to.have.been.called
+    expect(fakes.fake).not.to.have.been.called
   })
 
   describe('when fs.readFile fails', () => {
@@ -274,15 +245,11 @@ describe('preprocessor', () => {
     })
 
     it('should retry up to 3 times', async () => {
-      const fakePreprocessor = sinon.spy((content, file, done) => {
+      fakePreprocessor = sinon.spy((content, file, done) => {
         done(null, content)
       })
 
-      const injector = new di.Injector([{
-        'preprocessor:fake': ['factory', function () { return fakePreprocessor }]
-      }, emitterSetting])
-
-      const pp = m.createPriorityPreprocessor({ '**/*.js': ['fake'] }, {}, null, injector)
+      const pp = m.createPriorityPreprocessor({ '**/*.js': ['fake'] }, {}, null, simpleFakeInstantiatePlugin)
 
       await pp(file).then(() => {
         throw new Error('Should be rejected')
@@ -295,9 +262,7 @@ describe('preprocessor', () => {
     })
 
     it('should throw after 3 retries', async () => {
-      const injector = new di.Injector([{}, emitterSetting])
-
-      const pp = m.createPriorityPreprocessor({ '**/*.js': [] }, {}, null, injector)
+      const pp = m.createPriorityPreprocessor({ '**/*.js': [] }, {}, null, simpleFakeInstantiatePlugin)
 
       await pp(file).then(() => {
         throw new Error('Should be rejected')
@@ -309,15 +274,11 @@ describe('preprocessor', () => {
   })
 
   it('should not preprocess binary files by default', async () => {
-    const fakePreprocessor = sinon.spy((content, file, done) => {
+    fakePreprocessor = sinon.spy((content, file, done) => {
       done(null, content)
     })
 
-    const injector = new di.Injector([{
-      'preprocessor:fake': ['factory', function () { return fakePreprocessor }]
-    }, emitterSetting])
-
-    const pp = m.createPriorityPreprocessor({ '**/*': ['fake'] }, {}, null, injector)
+    const pp = m.createPriorityPreprocessor({ '**/*': ['fake'] }, {}, null, simpleFakeInstantiatePlugin)
 
     const file = { originalPath: '/some/photo.png', path: 'path' }
 
@@ -327,15 +288,11 @@ describe('preprocessor', () => {
   })
 
   it('should not preprocess files configured to be binary', async () => {
-    const fakePreprocessor = sinon.spy((content, file, done) => {
+    fakePreprocessor = sinon.spy((content, file, done) => {
       done(null, content)
     })
 
-    const injector = new di.Injector([{
-      'preprocessor:fake': ['factory', function () { return fakePreprocessor }]
-    }, emitterSetting])
-
-    const pp = m.createPriorityPreprocessor({ '**/*': ['fake'] }, {}, null, injector)
+    const pp = m.createPriorityPreprocessor({ '**/*': ['fake'] }, {}, null, simpleFakeInstantiatePlugin)
 
     const file = { originalPath: '/some/proto.pb', path: 'path', isBinary: true }
 
@@ -345,15 +302,11 @@ describe('preprocessor', () => {
   })
 
   it('should preprocess files configured not to be binary', async () => {
-    const fakePreprocessor = sinon.spy((content, file, done) => {
+    fakePreprocessor = sinon.spy((content, file, done) => {
       done(null, content)
     })
 
-    const injector = new di.Injector([{
-      'preprocessor:fake': ['factory', function () { return fakePreprocessor }]
-    }, emitterSetting])
-
-    const pp = m.createPriorityPreprocessor({ '**/*': ['fake'] }, {}, null, injector)
+    const pp = m.createPriorityPreprocessor({ '**/*': ['fake'] }, {}, null, simpleFakeInstantiatePlugin)
 
     // Explicit false for isBinary
     const file = { originalPath: '/some/proto.pb', path: 'path', isBinary: false }
@@ -364,16 +317,12 @@ describe('preprocessor', () => {
   })
 
   it('should preprocess binary files if handleBinaryFiles=true', async () => {
-    const fakePreprocessor = sinon.spy((content, file, done) => {
+    fakePreprocessor = sinon.spy((content, file, done) => {
       done(null, content)
     })
     fakePreprocessor.handleBinaryFiles = true
 
-    const injector = new di.Injector([{
-      'preprocessor:fake': ['factory', function () { return fakePreprocessor }]
-    }, emitterSetting])
-
-    const pp = m.createPriorityPreprocessor({ '**/*': ['fake'] }, {}, null, injector)
+    const pp = m.createPriorityPreprocessor({ '**/*': ['fake'] }, {}, null, simpleFakeInstantiatePlugin)
 
     const file = { originalPath: '/some/photo.png', path: 'path' }
 
@@ -383,15 +332,11 @@ describe('preprocessor', () => {
   })
 
   it('should not preprocess binary files with capital letters in extension', async () => {
-    const fakePreprocessor = sinon.spy((content, file, done) => {
+    fakePreprocessor = sinon.spy((content, file, done) => {
       done(null, content)
     })
 
-    const injector = new di.Injector([{
-      'preprocessor:fake': ['factory', function () { fakePreprocessor }]
-    }, emitterSetting])
-
-    const pp = m.createPriorityPreprocessor({ '**/*': ['fake'] }, {}, null, injector)
+    const pp = m.createPriorityPreprocessor({ '**/*': ['fake'] }, {}, null, simpleFakeInstantiatePlugin)
 
     const file = { originalPath: '/some/CAM_PHOTO.JPG', path: 'path' }
 
@@ -402,72 +347,68 @@ describe('preprocessor', () => {
 
   it('should merge lists of preprocessors using default priority', async () => {
     const callOrder = []
-    const fakePreprocessorA = sinon.spy((content, file, done) => {
-      callOrder.push('a')
-      done(null, content)
-    })
-    const fakePreprocessorB = sinon.spy((content, file, done) => {
-      callOrder.push('b')
-      done(null, content)
-    })
-    const fakePreprocessorC = sinon.spy((content, file, done) => {
-      callOrder.push('c')
-      done(null, content)
-    })
-    const fakePreprocessorD = sinon.spy((content, file, done) => {
-      callOrder.push('d')
-      done(null, content)
-    })
-
-    const injector = new di.Injector([{
-      'preprocessor:fakeA': ['factory', function () { return fakePreprocessorA }],
-      'preprocessor:fakeB': ['factory', function () { return fakePreprocessorB }],
-      'preprocessor:fakeC': ['factory', function () { return fakePreprocessorC }],
-      'preprocessor:fakeD': ['factory', function () { return fakePreprocessorD }]
-    }, emitterSetting])
+    const fakes = {
+      fakeA: sinon.spy((content, file, done) => {
+        callOrder.push('a')
+        done(null, content)
+      }),
+      fakeB: sinon.spy((content, file, done) => {
+        callOrder.push('b')
+        done(null, content)
+      }),
+      fakeC: sinon.spy((content, file, done) => {
+        callOrder.push('c')
+        done(null, content)
+      }),
+      fakeD: sinon.spy((content, file, done) => {
+        callOrder.push('d')
+        done(null, content)
+      })
+    }
+    function fakeInstantiatePlugin (kind, name) {
+      return fakes[name]
+    }
 
     const pp = m.createPriorityPreprocessor({
       '/*/a.js': ['fakeA', 'fakeB'],
       '/some/*': ['fakeB', 'fakeC'],
       '/some/a.js': ['fakeD']
-    }, {}, null, injector)
+    }, {}, null, fakeInstantiatePlugin)
 
     const file = { originalPath: '/some/a.js', path: 'path' }
 
     await pp(file)
-    expect(fakePreprocessorA).to.have.been.called
-    expect(fakePreprocessorB).to.have.been.called
-    expect(fakePreprocessorC).to.have.been.called
-    expect(fakePreprocessorD).to.have.been.called
+    expect(fakes.fakeA).to.have.been.called
+    expect(fakes.fakeB).to.have.been.called
+    expect(fakes.fakeC).to.have.been.called
+    expect(fakes.fakeD).to.have.been.called
 
     expect(callOrder).to.eql(['a', 'b', 'c', 'd'])
   })
 
   it('should merge lists of preprocessors obeying priority', async () => {
     const callOrder = []
-    const fakePreprocessorA = sinon.spy((content, file, done) => {
-      callOrder.push('a')
-      done(null, content)
-    })
-    const fakePreprocessorB = sinon.spy((content, file, done) => {
-      callOrder.push('b')
-      done(null, content)
-    })
-    const fakePreprocessorC = sinon.spy((content, file, done) => {
-      callOrder.push('c')
-      done(null, content)
-    })
-    const fakePreprocessorD = sinon.spy((content, file, done) => {
-      callOrder.push('d')
-      done(null, content)
-    })
-
-    const injector = new di.Injector([{
-      'preprocessor:fakeA': ['factory', function () { return fakePreprocessorA }],
-      'preprocessor:fakeB': ['factory', function () { return fakePreprocessorB }],
-      'preprocessor:fakeC': ['factory', function () { return fakePreprocessorC }],
-      'preprocessor:fakeD': ['factory', function () { return fakePreprocessorD }]
-    }, emitterSetting])
+    const fakes = {
+      fakeA: sinon.spy((content, file, done) => {
+        callOrder.push('a')
+        done(null, content)
+      }),
+      fakeB: sinon.spy((content, file, done) => {
+        callOrder.push('b')
+        done(null, content)
+      }),
+      fakeC: sinon.spy((content, file, done) => {
+        callOrder.push('c')
+        done(null, content)
+      }),
+      fakeD: sinon.spy((content, file, done) => {
+        callOrder.push('d')
+        done(null, content)
+      })
+    }
+    function fakeInstantiatePlugin (kind, name) {
+      return fakes[name]
+    }
 
     const priority = { fakeA: -1, fakeB: 1, fakeD: 100 }
 
@@ -475,15 +416,15 @@ describe('preprocessor', () => {
       '/*/a.js': ['fakeA', 'fakeB'],
       '/some/*': ['fakeB', 'fakeC'],
       '/some/a.js': ['fakeD']
-    }, priority, null, injector)
+    }, priority, null, fakeInstantiatePlugin)
 
     const file = { originalPath: '/some/a.js', path: 'path' }
 
     await pp(file)
-    expect(fakePreprocessorA).to.have.been.called
-    expect(fakePreprocessorB).to.have.been.called
-    expect(fakePreprocessorC).to.have.been.called
-    expect(fakePreprocessorD).to.have.been.called
+    expect(fakes.fakeA).to.have.been.called
+    expect(fakes.fakeB).to.have.been.called
+    expect(fakes.fakeC).to.have.been.called
+    expect(fakes.fakeD).to.have.been.called
 
     expect(callOrder).to.eql(['d', 'b', 'c', 'a'])
   })