karma icon indicating copy to clipboard operation
karma copied to clipboard

Support for ESM config files (with "type": "module")

Open tschaub opened this issue 3 years ago • 19 comments

If a project's package.json has "type": "module", Karma fails to load the config file. I'm wondering if there would be interest in supporting ESM config files in addition to CJS (and the other currently supported flavors).

While this might not be complete or the desired solution, it looks like something like this could add ESM config support: https://github.com/tschaub/karma/commit/99f1a92ec85a1dd2efbcd0faf1ad653e4b29cf8e

tschaub avatar Apr 26 '21 20:04 tschaub

Yes, we would be happy to accept a PR adding support for the ESM config file.

https://github.com/tschaub/karma/commit/99f1a92ec85a1dd2efbcd0faf1ad653e4b29cf8e is a good start, but it will need some more work. In particular, we'll need to mention this feature in the documentation and add e2e test to prevent regressions (I think we can use .mjs instead of type: module for the purpose of testing for simplicity).

devoto13 avatar May 04 '21 14:05 devoto13

@devoto13

If I can free up some time, I will try to tackle this. I started a new job so it has kept me very busy! This seems like a natural extension of my previous 2 contributions.

Notes:

Both static import statements and dynamic import() calls both support CommonJS AND ECMA Modules.

The trick is, we would have to use import() to replace require calls when retrieving the config file path that is passed to us, which makes the use of Promise a requirement.

On top of that, I don't know if import() would work the same way as require with the coffeescript, Livescript, or ts-node packages. See: https://github.com/karma-runner/karma/blob/v6.3.2/lib/config.js#L428-L446

The last time I checked, it didn't work because Node's API for intercepting modules isn't yet stable.

I wouldn't hold your breath waiting for me until you see at least a draft PR. It may be a while before I can start.

@tschaub

If you have a more immediate need, you could consume the JavaScript API immediately.

  1. let myKarmaConfig be the result of passing null as the first argument to karma.config.parseConfig()
    • (set the 2nd and 3rd arguments as needed)
  2. Get your config module (with import())
  3. Get the default export
  4. If the default export is a function, call it, passing myKarmaConfig as the only argument.
    • Is the return value a promise?
      • If yes, wait for it to resolve before continuing
    • NOTE: don't use the return value or promise resolution value as your config. myKarmaConfig was updated as a side effect in your config file.
  5. myKarmaConfig should now be a complete config that you can pass to new karma.Server(myKarmaConfig)
  6. Now instead of karma start my.conf.js, you can use node startKarma.js (or whatever you named the file.)

I have a proof of concept published, but it is more complicated than anything that would be implemented in this repo (I tend to over build things). It was written before Karma natively supported Promises and before exception handling was improved, so the end result would need to change before you use it.

  • https://github.com/promise-file/promise-file/blob/v0.1.0/packages/promise-file-module/src/promiseModuleExecutorFactory.js#L67-L146
  • (incomplete) Karma handling: https://github.com/promise-file/promise-file/blob/v0.1.0/packages/promise-file-karma-config/index.js

npetruzzelli avatar May 28 '21 13:05 npetruzzelli

I wouldn't hold your breath waiting for me until you see at least a draft PR. It may be a while before I can start.

I really need to stop saying this prematurely.

See: #3679

npetruzzelli avatar May 30 '21 00:05 npetruzzelli

Is there a workaround for this?

kruncher avatar Jul 02 '21 14:07 kruncher

Karma accepts config files with the .js, .ts and .coffee extensions.

  • http://karma-runner.github.io/6.3/config/configuration-file.html#overview

If the project package.json has type:"module", just renaming karma.conf.jskarma.conf.ts will make to read config files where it contains CJS module usages.

netil avatar Aug 06 '21 04:08 netil

~20 lines quick fix

NODE_OPTIONS="$NODE_OPTIONS -r $PWD/hook.cjs" karma start karma.config.mjs
// hook.cjs
const { addHook } = require('pirates')

const revert = addHook(
  (code, _filename) =>
    code
      .replace(/\nfunction parseConfig/, 'async function parseConfig')
      .replace('require(configFilePath)', '(await import(configFilePath)).default'),
  {
    exts: ['.js'],
    ignoreNodeModules: false,
    matcher: filename => filename.endsWith('node_modules/karma/lib/config.js')
  }
);

void revert

loynoir avatar Dec 02 '21 11:12 loynoir

Above require hook, force import() config.

So

  • if you are using dialect, change -r your_dialect_require_hook to --loader your_dialect_import_hook, if your dialect support import hook.
  • if you are not using dialect, it should always works, as import(cjs) is always ok

loynoir avatar Dec 02 '21 11:12 loynoir

It appears that, even though it's not in the documentation, Karma can already take a config function whose return value is a Promise. That allows you to simply write

// karma.conf.js
module.exports = function(config) {
    return import("./karma.conf.mjs").then(val => config.set(val));
}

// karma.conf.mjs
import fooPlugin from "foo-plugin";

export default function(config) {
  config.set({
    ...,
    plugins: [fooPlugin]
  });
}

I needed this when I updated to Angular 13; why I needed it is kind of an epic tale. I'm using Karma with karma-webpack; and my webpack configuration uses the AngularWebpackPlugin. Previously I imported my webpack config file, which was written in ES module format, via the esm package. As of Angular 13, manual webpack configs now require that you pass their shipped code through Babel (!), configured with a linker plugin. The linker plugin (@angular/compiler-cli/linker/babel) is exposed via the exports field in package.json, which means that you cannot use the esm package, so my old setup is no longer possible.

I figured out the solution above, to use Node's own MJS support to dynamically import the Karma config file, which needs to be able to somehow import the Webpack config file. By writing my actual Karma config in MJS, I can simply write import webpackConfig from "./webpack.config.mjs"; and Node handles the heavy lifting, no Karma patches required. (The above works for me in Node 14, Karma 6.3.9.)

thw0rted avatar Dec 14 '21 18:12 thw0rted

// karma.conf.js
module.exports = function(config) {
    return import("./karma.conf.mjs").then(val => config.set(val));
}

This won't quite work, or at least it shouldn't (I admit, I haven't tried it). You are close. The value that import() resolves with will be an object representing the exports of the module.

Long Version

// karma.conf.js
const path = require('path')
const absoluteModulePath = path.resolve('./karma.conf.mjs')

module.exports = function(config) {
    // The default module loader only supports File and HTTP(S) urls. File paths
    // without a protocol is an error.
    return import('file:' + absoluteModulePath).then( function(retrievedModule) {
        let defaultExport
        
        // We are only interested in the default export, so we can keep this simple.
        if (typeof retrievedModule?.default !== 'undefined') {
          // The expectation is that `import()` will be used for JavaScript
          // modules and that the result of this will always have a property named
          // "default" regardless of whether it was a CommonJS module or an
          // ECMAScript module.
          defaultExport = retrievedModule.default
        } else {
          // If module promise details are such that CommonJS modules are not
          // assigned to a "default" property on the module object, then this will
          // handle that.
          defaultExport = retrievedModule
        }
        
        if (typeof defaultExport === 'function') {
          return defaultExport(config)
        }
    })
}

Short version:

// karma.conf.js
const path = require('path')
const absoluteModulePath = path.resolve('./karma.conf.mjs')
module.exports = function(config) {
    return import('file:' + absoluteModulePath).then( function(retrievedModule) {
        const defaultExport = (typeof retrievedModule?.default !== 'undefined') ? retrievedModule.default : retrievedModule        
        if (typeof defaultExport === 'function') {
          return defaultExport(config)
        }
    })
}

If you want to be extra cautious, handle errors (such as if the default export isn't a function).

For import() to work at all, you must be using at least version 11 of Node.js. Karma 6.x must support version 10, so it can't take advantage of this method. Version 12 is the earliest LTS version that supports the method.

https://nodejs.org/docs/latest-v12.x/api/esm.html#esm_import_expressions

npetruzzelli avatar Dec 17 '21 19:12 npetruzzelli

The downside to import(), at least as it currently stands, it that it does not support JSON files (doesn't apply to us here, but I'm mentioning for the curious).

You can work around this by checking the path's extension and using the normal require() to retrieve the file and supply the contents to an immediately executed. If necessary, you can always construct your own require function:

https://nodejs.org/docs/latest-v12.x/api/module.html#module_module_createrequire_filename

npetruzzelli avatar Dec 17 '21 19:12 npetruzzelli

It appears that, even though it's not in the documentation

It is documented in the public API page: http://karma-runner.github.io/6.3/dev/public-api.html#karmaconfig

Though it may be worth adding a link to it from the config file page, in the Overview section, just after the line that describes the export:

Within the configuration file, the configuration code is put together by setting module.exports to point to a function which accepts one argument: the configuration object.

npetruzzelli avatar Dec 17 '21 20:12 npetruzzelli

PR: #3733

npetruzzelli avatar Dec 17 '21 20:12 npetruzzelli

Sorry I missed all the activity, I was already off the clock for the weekend. I forgot to come back after tweaking my setup and post what I actually wound up using:

module.exports = function(config) {
    return import("./karma.conf.mjs").then(mod => {
        mod.default(config);
    });
}

That let me convert my old karma.config.js from module.exports = function(config) { config.set(...) } to karma.config.mjs with:

/** @type {(config: import("karma").Config) => void} */
export default function(config) {
    /** @type {import("karma").ConfigOptions} */
    const karmaOptions = { ... };
    config.set(karmaOptions);
}

Obviously this isn't production-ready to work with any arbitrary config-file syntax supported by Karma, but it allowed me to migrate from the way I was already writing my config (in CJS) to ESM syntax with a tiny bit of boilerplate and very few changes otherwise. I'm not saying this is the right solution for Karma going forward but it should get people watching this issue up and running until official support comes along.

thw0rted avatar Dec 20 '21 10:12 thw0rted

Is there a way to do this without karma.config.parseConfig? E.g. import config from './karma.conf.mjs';?

alexanderby avatar Dec 20 '21 19:12 alexanderby

I tried patching parseConfig function in config.js file with

if (typeof configFilePath === 'function') {
  configModule = configFilePath;
}

and it works! basePath or files should be updated if config was in a folder other than root.

alexanderby avatar Dec 20 '21 20:12 alexanderby

You only need to use parseConfig if you are using the public API. If you are using the CLI, then returning a promise as illustrated above should be all that is needed.

My examples were lengthy, @thw0rted boiled it down to the most minimal version possible. The only change I would make is to return the result of mod.default(config) in case the function itself returns a promise.

module.exports = function(config) {
    return import("./karma.conf.mjs").then(mod => {
        return mod.default(config);
    });
}

or, since it is a single statement, we can take advantage of syntax built into arrow functions:

module.exports = function(config) {
    return import("./karma.conf.mjs").then(mod => mod.default(config));
}

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Arrow_functions#comparing_traditional_functions_to_arrow_functions

npetruzzelli avatar Dec 30 '21 17:12 npetruzzelli

@npetruzzelli I need to run a new karma.Server and need to parse a config with options for it.

alexanderby avatar Jan 03 '22 18:01 alexanderby

Since you are using the public API, I recommend checking out the public API docs: http://karma-runner.github.io/6.3/dev/public-api.html#karmaconfig

When using the public API, you will still need an intermediate file like the one described above:

// karma.conf.js
module.exports = function(config) {
    return import("./karma.conf.mjs").then(mod => mod.default(config));
}

This is because, even if the files for your config and new karma.Server are using ESM, internally, Karma v6 still uses the require method and we can't update this to use dynamic import() until (at least) the next major version of Karma.

npetruzzelli avatar Jan 11 '22 13:01 npetruzzelli

For me that works:

  1. renamed karma.conf.js -> karma.conf.cjs
  2. then run karma by: karma start karma.conf.cjs

b4rtaz avatar Mar 08 '23 14:03 b4rtaz