Skip to content

Commit

Permalink
Experimentally configure module formats for test files
Browse files Browse the repository at this point in the history
Fixes #2345.

Co-authored-by: Mark Wubben <mark@novemberborn.net>
  • Loading branch information
macarie and novemberborn committed Aug 23, 2020
1 parent 2b41fb0 commit 5c9dbb9
Show file tree
Hide file tree
Showing 30 changed files with 345 additions and 11 deletions.
7 changes: 6 additions & 1 deletion ava.config.js
@@ -1,4 +1,9 @@
const skipTests = [];
if (process.versions.node < '12.14.0') {
skipTests.push('!test/configurable-module-format/module.js');
}

export default {
files: ['test/**', '!test/**/{fixtures,helpers}/**'],
files: ['test/**', '!test/**/{fixtures,helpers}/**', ...skipTests],
ignoredByWatcher: ['{coverage,docs,media,test-d,test-tap}/**']
};
23 changes: 22 additions & 1 deletion docs/06-configuration.md
Expand Up @@ -52,7 +52,7 @@ Arguments passed to the CLI will always take precedence over the CLI options con
- `tap`: if `true`, enables the [TAP reporter](./05-command-line.md#tap-reporter)
- `verbose`: if `true`, enables verbose output
- `snapshotDir`: specifies a fixed location for storing snapshot files. Use this if your snapshots are ending up in the wrong location
- `extensions`: extensions of test files. Setting this overrides the default `["cjs", "mjs", "js"]` value, so make sure to include those extensions in the list
- `extensions`: extensions of test files. Setting this overrides the default `["cjs", "mjs", "js"]` value, so make sure to include those extensions in the list. [Experimentally you can configure how files are loaded](#configuring-module-formats)
- `require`: extra modules to require before tests are run. Modules are required in the [worker processes](./01-writing-tests.md#process-isolation)
- `timeout`: Timeouts in AVA behave differently than in other test frameworks. AVA resets a timer after each test, forcing tests to quit if no new test results were received within the specified timeout. This can be used to handle stalled tests. See our [timeout documentation](./07-test-timeouts.md) for more options.
- `nodeArguments`: Configure Node.js arguments used to launch worker processes.
Expand Down Expand Up @@ -213,6 +213,27 @@ export default {
};
```

### Configuring module formats

Node.js can only load non-standard extension as ES Modules when using [experimental loaders](https://nodejs.org/docs/latest/api/esm.html#esm_experimental_loaders). To use this you'll also have to configure AVA to `import()` your test file.

This is still an experimental feature. You can opt in to it by enabling the `configurableModuleFormat` experiment. Afterwards, you'll be able to specify per-extension module formats using an object form.

As with the array form, you need to explicitly list `js`, `cjs`, and `mjs` extensions. These **must** be set using the `true` value; other extensions are configurable using either `'commonjs'` or `'module'`:

`ava.config.js`:
```js
export default {
nonSemVerExperiments: {
configurableModuleFormat: true
},
extensions: {
js: true,
ts: 'module'
}
};
```

## Node arguments

The `nodeArguments` configuration may be used to specify additional arguments for launching worker processes. These are combined with `--node-arguments` passed on the CLI and any arguments passed to the `node` binary when starting AVA.
Expand Down
14 changes: 8 additions & 6 deletions lib/cli.js
Expand Up @@ -284,6 +284,7 @@ exports.run = async () => { // eslint-disable-line complexity
const TapReporter = require('./reporters/tap');
const Watcher = require('./watcher');
const normalizeExtensions = require('./extensions');
const normalizeModuleTypes = require('./module-types');
const {normalizeGlobs, normalizePattern} = require('./globs');
const normalizeNodeArguments = require('./node-arguments');
const validateEnvironmentVariables = require('./environment-variables');
Expand All @@ -301,12 +302,6 @@ exports.run = async () => { // eslint-disable-line complexity

const {type: defaultModuleType = 'commonjs'} = pkg || {};

const moduleTypes = {
cjs: 'commonjs',
mjs: 'module',
js: defaultModuleType
};

const providers = [];
if (Reflect.has(conf, 'babel')) {
try {
Expand Down Expand Up @@ -348,6 +343,13 @@ exports.run = async () => { // eslint-disable-line complexity
exit(error.message);
}

let moduleTypes;
try {
moduleTypes = normalizeModuleTypes(conf.extensions, defaultModuleType, experiments);
} catch (error) {
exit(error.message);
}

let globs;
try {
globs = normalizeGlobs({files: conf.files, ignoredByWatcher: conf.ignoredByWatcher, extensions, providers});
Expand Down
5 changes: 4 additions & 1 deletion lib/extensions.js
Expand Up @@ -2,8 +2,11 @@ module.exports = (configuredExtensions, providers = []) => {
// Combine all extensions possible for testing. Remove duplicate extensions.
const duplicates = new Set();
const seen = new Set();

const normalize = extensions => Array.isArray(extensions) ? extensions : Object.keys(extensions);

const combine = extensions => {
for (const ext of extensions) {
for (const ext of normalize(extensions)) {
if (seen.has(ext)) {
duplicates.add(ext);
} else {
Expand Down
2 changes: 1 addition & 1 deletion lib/load-config.js
Expand Up @@ -7,7 +7,7 @@ const pkgConf = require('pkg-conf');

const NO_SUCH_FILE = Symbol('no ava.config.js file');
const MISSING_DEFAULT_EXPORT = Symbol('missing default export');
const EXPERIMENTS = new Set(['disableSnapshotsInHooks', 'reverseTeardowns']);
const EXPERIMENTS = new Set(['configurableModuleFormat', 'disableSnapshotsInHooks', 'reverseTeardowns']);

// *Very* rudimentary support for loading ava.config.js files containing an `export default` statement.
const evaluateJsConfig = configFile => {
Expand Down
75 changes: 75 additions & 0 deletions lib/module-types.js
@@ -0,0 +1,75 @@
const requireTrueValue = value => {
if (value !== true) {
throw new TypeError('When specifying module types, use `true` for ’cjs’, ’mjs’ and ’js’ extensions');
}
};

const normalize = (extension, type, defaultModuleType) => {
switch (extension) {
case 'cjs':
requireTrueValue(type);
return 'commonjs';
case 'mjs':
requireTrueValue(type);
return 'module';
case 'js':
requireTrueValue(type);
return defaultModuleType;
default:
if (type !== 'commonjs' && type !== 'module') {
throw new TypeError(`Module type for ’${extension}’ must be ’commonjs’ or ’module’`);
}

return type;
}
};

const deriveFromObject = (extensionsObject, defaultModuleType) => {
const moduleTypes = {};
for (const [extension, type] of Object.entries(extensionsObject)) {
moduleTypes[extension] = normalize(extension, type, defaultModuleType);
}

return moduleTypes;
};

const deriveFromArray = (extensions, defaultModuleType) => {
const moduleTypes = {};
for (const extension of extensions) {
switch (extension) {
case 'cjs':
moduleTypes.cjs = 'commonjs';
break;
case 'mjs':
moduleTypes.mjs = 'module';
break;
case 'js':
moduleTypes.js = defaultModuleType;
break;
default:
moduleTypes[extension] = 'commonjs';
}
}

return moduleTypes;
};

module.exports = (configuredExtensions, defaultModuleType, experiments) => {
if (configuredExtensions === undefined) {
return {
cjs: 'commonjs',
mjs: 'module',
js: defaultModuleType
};
}

if (Array.isArray(configuredExtensions)) {
return deriveFromArray(configuredExtensions, defaultModuleType);
}

if (!experiments.configurableModuleFormat) {
throw new Error('You must enable the `configurableModuleFormat` experiment in order to specify module types');
}

return deriveFromObject(configuredExtensions, defaultModuleType);
};
26 changes: 26 additions & 0 deletions test/configurable-module-format/commonjs.js
@@ -0,0 +1,26 @@
const test = require('@ava/test');
const exec = require('../helpers/exec');

test('load js and cjs as commonjs (default configuration)', async t => {
const result = await exec.fixture(['*.js', '*.cjs']);
const files = new Set(result.stats.passed.map(({file}) => file));
t.is(files.size, 2);
t.true(files.has('test.cjs'));
t.true(files.has('test.js'));
});

test('load js and cjs as commonjs (using an extensions array)', async t => {
const result = await exec.fixture(['*.js', '*.cjs', '--config', 'array-extensions.config.js']);
const files = new Set(result.stats.passed.map(({file}) => file));
t.is(files.size, 2);
t.true(files.has('test.cjs'));
t.true(files.has('test.js'));
});

test('load js and cjs as commonjs (using an extensions object)', async t => {
const result = await exec.fixture(['*.js', '*.cjs', '--config', 'object-extensions.config.js']);
const files = new Set(result.stats.passed.map(({file}) => file));
t.is(files.size, 2);
t.true(files.has('test.cjs'));
t.true(files.has('test.js'));
});
16 changes: 16 additions & 0 deletions test/configurable-module-format/custom.js
@@ -0,0 +1,16 @@
const test = require('@ava/test');
const exec = require('../helpers/exec');

test('load ts as commonjs (using an extensions array)', async t => {
const result = await exec.fixture(['*.ts', '--config', 'array-custom.config.js']);
const files = new Set(result.stats.passed.map(({file}) => file));
t.is(files.size, 1);
t.true(files.has('test.ts'));
});

test('load ts as commonjs (using an extensions object)', async t => {
const result = await exec.fixture(['*.ts', '--config', 'object-custom.config.js']);
const files = new Set(result.stats.passed.map(({file}) => file));
t.is(files.size, 1);
t.true(files.has('test.ts'));
});
10 changes: 10 additions & 0 deletions test/configurable-module-format/experimental.js
@@ -0,0 +1,10 @@
const test = require('@ava/test');
const exec = require('../helpers/exec');

const stripLeadingFigures = string => string.replace(/^\W+/, '');

test('opt-in is required', async t => {
const result = await t.throwsAsync(exec.fixture(['--config', 'not-enabled.config.js']));
t.is(result.exitCode, 1);
t.snapshot(stripLeadingFigures(result.stderr.trim()));
});
@@ -0,0 +1,3 @@
export default {
extensions: ['js', 'ts']
};
@@ -0,0 +1,3 @@
export default {
extensions: ['js', 'cjs', 'mjs']
};
@@ -0,0 +1,9 @@
export default {
extensions: {
js: true,
ts: 'cjs'
},
nonSemVerExperiments: {
configurableModuleFormat: true
}
};
@@ -0,0 +1,8 @@
export default {
extensions: {
cjs: 'module'
},
nonSemVerExperiments: {
configurableModuleFormat: true
}
};
@@ -0,0 +1,8 @@
export default {
extensions: {
js: 'module'
},
nonSemVerExperiments: {
configurableModuleFormat: true
}
};
@@ -0,0 +1,8 @@
export default {
extensions: {
mjs: 'commonjs'
},
nonSemVerExperiments: {
configurableModuleFormat: true
}
};
@@ -0,0 +1,7 @@
export default {
extensions: {
js: true,
cjs: true,
mjs: true
}
};
@@ -0,0 +1,9 @@
export default {
extensions: {
js: true,
ts: 'commonjs'
},
nonSemVerExperiments: {
configurableModuleFormat: true
}
};
@@ -0,0 +1,10 @@
export default {
extensions: {
js: true,
cjs: true,
mjs: true
},
nonSemVerExperiments: {
configurableModuleFormat: true
}
};
1 change: 1 addition & 0 deletions test/configurable-module-format/fixtures/package.json
@@ -0,0 +1 @@
{}
5 changes: 5 additions & 0 deletions test/configurable-module-format/fixtures/test.cjs
@@ -0,0 +1,5 @@
const test = require('ava');

test('always passing test', t => {
t.pass();
});
5 changes: 5 additions & 0 deletions test/configurable-module-format/fixtures/test.js
@@ -0,0 +1,5 @@
const test = require('ava');

test('always passing test', t => {
t.pass();
});
5 changes: 5 additions & 0 deletions test/configurable-module-format/fixtures/test.mjs
@@ -0,0 +1,5 @@
import test from 'ava';

test('always passing test', t => {
t.pass();
});
8 changes: 8 additions & 0 deletions test/configurable-module-format/fixtures/test.ts
@@ -0,0 +1,8 @@
// eslint-disable-next-line ava/no-ignored-test-files
const test = require('ava');

test('always passing test', t => {
const numberWithTypes = 0;

t.is(numberWithTypes, 0);
});
24 changes: 24 additions & 0 deletions test/configurable-module-format/invalid-configurations.js
@@ -0,0 +1,24 @@
const test = require('@ava/test');
const exec = require('../helpers/exec');

const stripLeadingFigures = string => string.replace(/^\W+/, '');

test('cannot configure how js extensions should be loaded', async t => {
const result = await t.throwsAsync(exec.fixture(['--config', 'change-js-loading.config.js']));
t.snapshot(stripLeadingFigures(result.stderr.trim()));
});

test('cannot configure how cjs extensions should be loaded', async t => {
const result = await t.throwsAsync(exec.fixture(['--config', 'change-cjs-loading.config.js']));
t.snapshot(stripLeadingFigures(result.stderr.trim()));
});

test('cannot configure how mjs extensions should be loaded', async t => {
const result = await t.throwsAsync(exec.fixture(['--config', 'change-mjs-loading.config.js']));
t.snapshot(stripLeadingFigures(result.stderr.trim()));
});

test('custom extensions must be either commonjs or module', async t => {
const result = await t.throwsAsync(exec.fixture(['--config', 'bad-custom-type.config.js']));
t.snapshot(stripLeadingFigures(result.stderr.trim()));
});
23 changes: 23 additions & 0 deletions test/configurable-module-format/module.js
@@ -0,0 +1,23 @@
const test = require('@ava/test');
const exec = require('../helpers/exec');

test('load mjs as module (default configuration)', async t => {
const result = await exec.fixture(['*.mjs']);
const files = new Set(result.stats.passed.map(({file}) => file));
t.is(files.size, 1);
t.true(files.has('test.mjs'));
});

test('load mjs as module (using an extensions array)', async t => {
const result = await exec.fixture(['*.mjs', '--config', 'array-extensions.config.js']);
const files = new Set(result.stats.passed.map(({file}) => file));
t.is(files.size, 1);
t.true(files.has('test.mjs'));
});

test('load mjs as module (using an extensions object)', async t => {
const result = await exec.fixture(['*.mjs', '--config', 'object-extensions.config.js']);
const files = new Set(result.stats.passed.map(({file}) => file));
t.is(files.size, 1);
t.true(files.has('test.mjs'));
});
11 changes: 11 additions & 0 deletions test/configurable-module-format/snapshots/experimental.js.md
@@ -0,0 +1,11 @@
# Snapshot report for `test/configurable-module-format/experimental.js`

The actual snapshot is saved in `experimental.js.snap`.

Generated by [AVA](https://avajs.dev).

## opt-in is required

> Snapshot 1
'You must enable the `configurableModuleFormat` experiment in order to specify module types'
Binary file not shown.

0 comments on commit 5c9dbb9

Please sign in to comment.