Skip to content

Commit

Permalink
Opt-in support for ES modules with .js extension and package type `mo…
Browse files Browse the repository at this point in the history
…dule`

* All files are loaded using `import` if `"jsLoader": "import"` is set in
  the config file.
* Only supported on Node >= 12.17.0. Older versions have missing or broken
  support for importing .js files.
* Fixes #170
  • Loading branch information
sgravrock committed May 11, 2021
1 parent a6e290c commit da3ecaa
Show file tree
Hide file tree
Showing 15 changed files with 277 additions and 67 deletions.
1 change: 1 addition & 0 deletions .eslintignore
@@ -1 +1,2 @@
spec/fixtures/cjs-syntax-error/syntax_error.js
spec/fixtures/js-loader-import/*.js
14 changes: 14 additions & 0 deletions README.md
Expand Up @@ -61,6 +61,20 @@ jasmine JASMINE_CONFIG_PATH=relative/path/to/your/jasmine.json
jasmine --config=relative/path/to/your/jasmine.json
```

## Using ES modules

If the name of a spec file or helper file ends in `.mjs`, Jasmine will load it
as an [ES module](https://nodejs.org/docs/latest-v13.x/api/esm.html) rather
than a CommonJS module. This allows the spec file or helper to import other
ES modules. No extra configuration is required.

You can also use ES modules with names ending in `.js` by adding
`"jsLoader": "import"` to `jasmine.json`. This should work for CommonJS modules
as well as ES modules. We expect to make it the default in a future release.
Please [log an issue](https://github.com/jasmine/jasmine-npm/issues) if you have
code that doesn't load correctly with `"jsLoader": "import"`.


# Filtering specs

Execute only those specs which filename match given glob:
Expand Down
37 changes: 32 additions & 5 deletions lib/jasmine.js
Expand Up @@ -87,18 +87,23 @@ Jasmine.prototype.addMatchers = function(matchers) {
};

Jasmine.prototype.loadSpecs = async function() {
for (const file of this.specFiles) {
await this.loader.load(file);
}
await this._loadFiles(this.specFiles);
};

Jasmine.prototype.loadHelpers = async function() {
for (const file of this.helperFiles) {
await this.loader.load(file);
await this._loadFiles(this.helperFiles);
};

Jasmine.prototype._loadFiles = async function(files) {
for (const file of files) {
await this.loader.load(file, this._alwaysImport || false);
}

};

Jasmine.prototype.loadRequires = function() {
// TODO: In 4.0, switch to calling _loadFiles
// (requires making this function async)
this.requires.forEach(function(r) {
require(r);
});
Expand Down Expand Up @@ -135,6 +140,17 @@ Jasmine.prototype.loadConfig = function(config) {
configuration.random = config.random;
}

if (config.jsLoader === 'import') {
checkForJsFileImportSupport();
this._alwaysImport = true;
} else if (config.jsLoader === 'require' || config.jsLoader === undefined) {
this._alwaysImport = false;
} else {
throw new Error(`"${config.jsLoader}" is not a valid value for the ` +
'jsLoader configuration property. Valid values are "import", ' +
'"require", and undefined.');
}

if (Object.keys(configuration).length > 0) {
this.env.configure(configuration);
}
Expand Down Expand Up @@ -240,6 +256,17 @@ var checkExit = function(jasmineRunner) {
};
};

function checkForJsFileImportSupport() {
const v = process.versions.node
.split('.')
.map(el => parseInt(el, 10));

if (v[0] < 12 || (v[0] === 12 && v[1] < 17)) {
console.warn('Warning: jsLoader: "import" may not work reliably on Node ' +
'versions before 12.17.');
}
}

Jasmine.prototype.execute = async function(files, filterString) {
this.completionReporter.exitHandler = this.checkExit;

Expand Down
4 changes: 2 additions & 2 deletions lib/loader.js
Expand Up @@ -6,8 +6,8 @@ function Loader(options) {
this.import_ = options.importShim || importShim;
}

Loader.prototype.load = function(path) {
if (path.endsWith('.mjs')) {
Loader.prototype.load = function(path, alwaysImport) {
if (alwaysImport || path.endsWith('.mjs')) {
// The ES module spec requires import paths to be valid URLs. As of v14,
// Node enforces this on Windows but not on other OSes.
const url = `file://${path}`;
Expand Down
5 changes: 5 additions & 0 deletions spec/fixtures/js-loader-default/aSpec.js
@@ -0,0 +1,5 @@
describe('a file with js extension', function() {
it('was loaded as a CommonJS module', function() {
expect(module.parent).toBeTruthy();
});
});
4 changes: 4 additions & 0 deletions spec/fixtures/js-loader-default/jasmine.json
@@ -0,0 +1,4 @@
{
"spec_dir": ".",
"spec_files": ["aSpec.js"]
}
3 changes: 3 additions & 0 deletions spec/fixtures/js-loader-import/anEsModule.js
@@ -0,0 +1,3 @@
export function foo() {
return 42;
}
7 changes: 7 additions & 0 deletions spec/fixtures/js-loader-import/anEsModuleSpec.js
@@ -0,0 +1,7 @@
import {foo} from './anEsModule.js';

describe('foo', function() {
it('returns 42', function() {
expect(foo()).toEqual(42);
});
});
5 changes: 5 additions & 0 deletions spec/fixtures/js-loader-import/jasmine.json
@@ -0,0 +1,5 @@
{
"spec_dir": ".",
"spec_files": ["anEsModuleSpec.js"],
"jsLoader": "import"
}
3 changes: 3 additions & 0 deletions spec/fixtures/js-loader-import/package.json
@@ -0,0 +1,3 @@
{
"type": "module"
}
5 changes: 5 additions & 0 deletions spec/fixtures/js-loader-require/aSpec.js
@@ -0,0 +1,5 @@
describe('a file with js extension', function() {
it('was loaded as a CommonJS module', function() {
expect(module.parent).toBeTruthy();
});
});
5 changes: 5 additions & 0 deletions spec/fixtures/js-loader-require/jasmine.json
@@ -0,0 +1,5 @@
{
"spec_dir": ".",
"spec_files": ["aSpec.js"],
"jsLoader": "require"
}
84 changes: 78 additions & 6 deletions spec/integration_spec.js
@@ -1,8 +1,29 @@
const child_process = require('child_process');

describe('Integration', function () {
beforeEach(function() {
jasmine.addMatchers({
toBeSuccess: function(matchersUtil) {
return {
compare: function(actual, expected) {
const result = { pass: actual.exitCode === 0 };

if (result.pass) {
result.message = 'Expected process not to succeed but it did.';
} else {
result.message = `Expected process to succeed but it exited ${actual.exitCode}.`;
}

result.message += '\n\nOutput:\n' + actual.output;
return result;
}
};
}
});
});

it('supports ES modules', async function () {
let {exitCode, output} = await runJasmine('spec/fixtures/esm');
let {exitCode, output} = await runJasmine('spec/fixtures/esm', true);
expect(exitCode).toEqual(0);
// Node < 14 outputs a warning when ES modules are used, e.g.:
// (node:5258) ExperimentalWarning: The ESM module loader is experimental.
Expand All @@ -20,15 +41,35 @@ describe('Integration', function () {
);
});

it('loads .js files using import when jsLoader is "import"', async function() {
await requireFunctioningJsImport();
expect(await runJasmine('spec/fixtures/js-loader-import', false)).toBeSuccess();
});

it('warns that jsLoader: "import" is not supported', async function() {
await requireBrokenJsImport();
const {output} = await runJasmine('spec/fixtures/js-loader-import', false);
expect(output).toContain('Warning: jsLoader: "import" may not work ' +
'reliably on Node versions before 12.17.');
});

it('loads .js files using require when jsLoader is "require"', async function() {
expect(await runJasmine('spec/fixtures/js-loader-require', false)).toBeSuccess();
});

it('loads .js files using require when jsLoader is undefined', async function() {
expect(await runJasmine('spec/fixtures/js-loader-default', false)).toBeSuccess();
});

it('handles load-time exceptions from CommonJS specs properly', async function () {
const {exitCode, output} = await runJasmine('spec/fixtures/cjs-load-exception');
const {exitCode, output} = await runJasmine('spec/fixtures/cjs-load-exception', false);
expect(exitCode).toEqual(1);
expect(output).toContain('Error: nope');
expect(output).toMatch(/at .*throws_on_load.js/);
});

it('handles load-time exceptions from ESM specs properly', async function () {
const {exitCode, output} = await runJasmine('spec/fixtures/esm-load-exception');
const {exitCode, output} = await runJasmine('spec/fixtures/esm-load-exception', true);
expect(exitCode).toEqual(1);
expect(output).toContain('Error: nope');
expect(output).toMatch(/at .*throws_on_load.mjs/);
Expand All @@ -42,18 +83,24 @@ describe('Integration', function () {
});

it('handles syntax errors in ESM specs properly', async function () {
const {exitCode, output} = await runJasmine('spec/fixtures/esm-syntax-error');
const {exitCode, output} = await runJasmine('spec/fixtures/esm-syntax-error', true);
expect(exitCode).toEqual(1);
expect(output).toContain('SyntaxError');
expect(output).toContain('syntax_error.mjs');
});
});

async function runJasmine(cwd) {
async function runJasmine(cwd, useExperimentalModulesFlag) {
return new Promise(function(resolve) {
const args = ['../../../bin/jasmine.js', '--config=jasmine.json'];

if (useExperimentalModulesFlag) {
args.unshift('--experimental-modules');
}

const child = child_process.spawn(
'node',
['--experimental-modules', '../../../bin/jasmine.js', '--config=jasmine.json'],
args,
{
cwd,
shell: false
Expand All @@ -71,3 +118,28 @@ async function runJasmine(cwd) {
});
});
}

async function requireFunctioningJsImport() {
if (!(await hasFunctioningJsImport())) {
pending("This Node version can't import .js files");
}
}

async function requireBrokenJsImport() {
if (await hasFunctioningJsImport()) {
pending("This Node version can import .js files");
}
}

async function hasFunctioningJsImport() {
try {
await import('./fixtures/js-loader-import/anEsModule.js');
return true;
} catch (e) {
if (e.code === 'ERR_MODULE_NOT_FOUND') {
throw e;
}

return false;
}
}
43 changes: 43 additions & 0 deletions spec/jasmine_spec.js
Expand Up @@ -175,8 +175,10 @@ describe('Jasmine', function() {

describe('loading configurations', function() {
beforeEach(function() {
this.loader = jasmine.createSpyObj('loader', ['load']);
this.fixtureJasmine = new Jasmine({
jasmineCore: this.fakeJasmineCore,
loader: this.loader,
projectBaseDir: 'spec/fixtures/sample_project'
});
});
Expand Down Expand Up @@ -273,6 +275,47 @@ describe('Jasmine', function() {
});
});

describe('with jsLoader: "require"', function () {
it('tells the loader not to always import', async function() {
this.configObject.jsLoader = 'require';

this.fixtureJasmine.loadConfig(this.configObject);
await this.fixtureJasmine.loadSpecs();

expect(this.loader.load).toHaveBeenCalledWith(jasmine.any(String), false);
});
});

describe('with jsLoader: "import"', function () {
it('tells the loader to always import', async function() {
this.configObject.jsLoader = 'import';

this.fixtureJasmine.loadConfig(this.configObject);
await this.fixtureJasmine.loadSpecs();

expect(this.loader.load).toHaveBeenCalledWith(jasmine.any(String), true);
});
});

describe('with jsLoader set to an invalid value', function () {
it('throws an error', function() {
this.configObject.jsLoader = 'bogus';
expect(() => {
this.fixtureJasmine.loadConfig(this.configObject);
}).toThrowError(/"bogus" is not a valid value/);
});
});

describe('with jsLoader undefined', function () {
it('tells the loader not to always import', async function() {
this.configObject.jsLoader = undefined;

this.fixtureJasmine.loadConfig(this.configObject);
await this.fixtureJasmine.loadSpecs();

expect(this.loader.load).toHaveBeenCalledWith(jasmine.any(String), false);
});
});
});

describe('from a file', function() {
Expand Down

0 comments on commit da3ecaa

Please sign in to comment.