karma
karma copied to clipboard
Support for ESM config files (with "type": "module")
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
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
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.
- let
myKarmaConfig
be the result of passingnull
as the first argument tokarma.config.parseConfig()
- (set the 2nd and 3rd arguments as needed)
- Get your config module (with
import()
) - Get the default export
- 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.
- Is the return value a promise?
-
myKarmaConfig
should now be a complete config that you can pass tonew karma.Server(myKarmaConfig)
- Now instead of
karma start my.conf.js
, you can usenode 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
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
Is there a workaround for this?
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.js
→ karma.conf.ts
will make to read config files where it contains CJS module usages.
~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
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
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.)
// 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
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
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.
PR: #3733
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.
Is there a way to do this without karma.config.parseConfig
? E.g. import config from './karma.conf.mjs';
?
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.
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 I need to run a new karma.Server
and need to parse a config with options for it.
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.
For me that works:
- renamed
karma.conf.js
->karma.conf.cjs
- then run karma by:
karma start karma.conf.cjs