Enhance require feature
* Allow loading of ES modules * Automatically invoke the default export (if any) and await the result * Pass arguments from the config to the default export (relies on "advanced" IPC for child processes) * Document that local files are loaded through the providers (e.g. `@ava/typescript`) Co-authored-by: Mark Wubben <mark@novemberborn.net>
This commit is contained in:
parent
1fff08d8b4
commit
4dc385b486
31 changed files with 306 additions and 42 deletions
|
|
@ -55,7 +55,7 @@ Arguments passed to the CLI will always take precedence over the CLI options con
|
|||
- `verbose`: if `true`, enables verbose output (though there currently non-verbose output is not supported)
|
||||
- `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. [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#test-isolation)
|
||||
- `require`: [extra modules to load before test files](#requiring-extra-modules)
|
||||
- `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.
|
||||
- `sortTestFiles`: A comparator function to sort test files with. Available only when using a `ava.config.*` file. See an example use case [here](recipes/splitting-tests-ci.md).
|
||||
|
|
@ -84,14 +84,14 @@ The default export can either be a plain object or a factory function which retu
|
|||
|
||||
```js
|
||||
export default {
|
||||
require: ['./_my-test-helper']
|
||||
require: ['./_my-test-helper.js']
|
||||
};
|
||||
```
|
||||
|
||||
```js
|
||||
export default function factory() {
|
||||
return {
|
||||
require: ['./_my-test-helper']
|
||||
require: ['./_my-test-helper.js']
|
||||
};
|
||||
};
|
||||
```
|
||||
|
|
@ -120,14 +120,14 @@ The module export can either be a plain object or a factory function which retur
|
|||
|
||||
```js
|
||||
module.exports = {
|
||||
require: ['./_my-test-helper']
|
||||
require: ['./_my-test-helper.js']
|
||||
};
|
||||
```
|
||||
|
||||
```js
|
||||
module.exports = () => {
|
||||
return {
|
||||
require: ['./_my-test-helper']
|
||||
require: ['./_my-test-helper.js']
|
||||
};
|
||||
};
|
||||
```
|
||||
|
|
@ -154,14 +154,14 @@ The default export can either be a plain object or a factory function which retu
|
|||
|
||||
```js
|
||||
export default {
|
||||
require: ['./_my-test-helper']
|
||||
require: ['./_my-test-helper.js']
|
||||
};
|
||||
```
|
||||
|
||||
```js
|
||||
export default function factory() {
|
||||
return {
|
||||
require: ['./_my-test-helper']
|
||||
require: ['./_my-test-helper.js']
|
||||
};
|
||||
};
|
||||
```
|
||||
|
|
@ -258,6 +258,79 @@ export default {
|
|||
};
|
||||
```
|
||||
|
||||
## Requiring extra modules
|
||||
|
||||
Use the `require` configuration to load extra modules before test files are loaded. Relative paths are resolved against the project directory and can be loaded through `@ava/typescript`. Otherwise, modules are loaded from within the `node_modules` directory inside the project.
|
||||
|
||||
You may specify a single value, or an array of values:
|
||||
|
||||
`ava.config.js`:
|
||||
```js
|
||||
export default {
|
||||
require: './_my-test-helper.js'
|
||||
}
|
||||
```
|
||||
```js
|
||||
export default {
|
||||
require: ['./_my-test-helper.js']
|
||||
}
|
||||
```
|
||||
|
||||
If the module exports a function, it is called and awaited:
|
||||
|
||||
`_my-test-helper.js`:
|
||||
```js
|
||||
export default function () {
|
||||
// Additional setup
|
||||
}
|
||||
```
|
||||
|
||||
`_my-test-helper.cjs`:
|
||||
```js
|
||||
module.exports = function () {
|
||||
// Additional setup
|
||||
}
|
||||
```
|
||||
|
||||
In CJS files, a `default` export is also supported:
|
||||
|
||||
```js
|
||||
exports.default = function () {
|
||||
// Never called
|
||||
}
|
||||
```
|
||||
|
||||
You can provide arguments:
|
||||
|
||||
`ava.config.js`:
|
||||
```js
|
||||
export default {
|
||||
require: [
|
||||
['./_my-test-helper.js', 'my', 'arguments']
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`_my-test-helper.js`:
|
||||
```js
|
||||
export default function (first, second) { // 'my', 'arguments'
|
||||
// Additional setup
|
||||
}
|
||||
```
|
||||
|
||||
Arguments are copied using the [structured clone algorithm](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm). This means `Map` values survive, but a `Buffer` will come out as a `Uint8Array`.
|
||||
|
||||
You can load dependencies installed in your project:
|
||||
|
||||
`ava.config.js`:
|
||||
```js
|
||||
export default {
|
||||
require: '@babel/register'
|
||||
}
|
||||
```
|
||||
|
||||
These may also export a function which is then invoked, and can receive arguments.
|
||||
|
||||
## 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.
|
||||
|
|
|
|||
15
lib/api.js
15
lib/api.js
|
|
@ -9,7 +9,6 @@ import commonPathPrefix from 'common-path-prefix';
|
|||
import Emittery from 'emittery';
|
||||
import ms from 'ms';
|
||||
import pMap from 'p-map';
|
||||
import resolveCwd from 'resolve-cwd';
|
||||
import tempDir from 'temp-dir';
|
||||
|
||||
import fork from './fork.js';
|
||||
|
|
@ -22,15 +21,13 @@ import RunStatus from './run-status.js';
|
|||
import scheduler from './scheduler.js';
|
||||
import serializeError from './serialize-error.js';
|
||||
|
||||
function resolveModules(modules) {
|
||||
return arrify(modules).map(name => {
|
||||
const modulePath = resolveCwd.silent(name);
|
||||
|
||||
if (modulePath === undefined) {
|
||||
throw new Error(`Could not resolve required module ’${name}’`);
|
||||
function normalizeRequireOption(require) {
|
||||
return arrify(require).map(name => {
|
||||
if (typeof name === 'string') {
|
||||
return arrify(name);
|
||||
}
|
||||
|
||||
return modulePath;
|
||||
return name;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -81,7 +78,7 @@ export default class Api extends Emittery {
|
|||
super();
|
||||
|
||||
this.options = {match: [], moduleTypes: {}, ...options};
|
||||
this.options.require = resolveModules(this.options.require);
|
||||
this.options.require = normalizeRequireOption(this.options.require);
|
||||
|
||||
this._cacheDir = null;
|
||||
this._interruptHandler = () => {};
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ const createWorker = (options, execArgv) => {
|
|||
silent: true,
|
||||
env: {NODE_ENV: 'test', ...process.env, ...options.environmentVariables},
|
||||
execArgv: [...execArgv, ...additionalExecArgv],
|
||||
serialization: 'advanced',
|
||||
});
|
||||
postMessage = controlFlow(worker);
|
||||
close = async () => worker.kill();
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
import {mkdir} from 'node:fs/promises';
|
||||
import {createRequire} from 'node:module';
|
||||
import {join as joinPath, resolve as resolvePath} from 'node:path';
|
||||
import process from 'node:process';
|
||||
import {pathToFileURL} from 'node:url';
|
||||
import {workerData} from 'node:worker_threads';
|
||||
|
||||
import setUpCurrentlyUnhandled from 'currently-unhandled';
|
||||
import writeFileAtomic from 'write-file-atomic';
|
||||
|
||||
import {set as setChalk} from '../chalk.js';
|
||||
import nowAndTimers from '../now-and-timers.cjs';
|
||||
|
|
@ -174,9 +177,56 @@ const run = async options => {
|
|||
return require(ref);
|
||||
};
|
||||
|
||||
const loadRequiredModule = async ref => {
|
||||
// If the provider can load the module, assume it's a local file and not a
|
||||
// dependency.
|
||||
for (const provider of providers) {
|
||||
if (provider.canLoad(ref)) {
|
||||
return provider.load(ref, {requireFn: require});
|
||||
}
|
||||
}
|
||||
|
||||
// Try to load the module as a file, relative to the project directory.
|
||||
// Match load() behavior.
|
||||
const fullPath = resolvePath(projectDir, ref);
|
||||
try {
|
||||
for (const extension of extensionsToLoadAsModules) {
|
||||
if (fullPath.endsWith(`.${extension}`)) {
|
||||
return await import(pathToFileURL(fullPath)); // eslint-disable-line no-await-in-loop
|
||||
}
|
||||
}
|
||||
|
||||
return require(fullPath);
|
||||
} catch (error) {
|
||||
// If the module could not be found, assume it's not a file but a dependency.
|
||||
if (error.code === 'ERR_MODULE_NOT_FOUND' || error.code === 'MODULE_NOT_FOUND') {
|
||||
return importFromProject(ref);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
let importFromProject = async ref => {
|
||||
// Do not use the cacheDir since it's not guaranteed to be inside node_modules.
|
||||
const avaCacheDir = joinPath(projectDir, 'node_modules', '.cache', 'ava');
|
||||
await mkdir(avaCacheDir, {recursive: true});
|
||||
const stubPath = joinPath(avaCacheDir, 'import-from-project.mjs');
|
||||
await writeFileAtomic(stubPath, 'export const importFromProject = ref => import(ref);\n');
|
||||
({importFromProject} = await import(pathToFileURL(stubPath)));
|
||||
return importFromProject(ref);
|
||||
};
|
||||
|
||||
try {
|
||||
for await (const ref of (options.require || [])) {
|
||||
await load(ref);
|
||||
for await (const [ref, ...args] of (options.require ?? [])) {
|
||||
const loadedModule = await loadRequiredModule(ref);
|
||||
|
||||
if (typeof loadedModule === 'function') { // CJS module
|
||||
await loadedModule(...args);
|
||||
} else if (typeof loadedModule.default === 'function') { // ES module, or exports.default from CJS
|
||||
const {default: fn} = loadedModule;
|
||||
await fn(...args);
|
||||
}
|
||||
}
|
||||
|
||||
// Install dependency tracker after the require configuration has been evaluated
|
||||
|
|
|
|||
|
|
@ -359,25 +359,6 @@ for (const opt of options) {
|
|||
});
|
||||
});
|
||||
|
||||
test(`Node.js-style --require CLI argument - workerThreads: ${opt.workerThreads}`, async t => {
|
||||
const requirePath = './' + path.relative('.', path.join(__dirname, 'fixture/install-global.cjs')).replace(/\\/g, '/');
|
||||
|
||||
const api = await apiCreator({
|
||||
...opt,
|
||||
require: [requirePath],
|
||||
});
|
||||
|
||||
return api.run({files: [path.join(__dirname, 'fixture/validate-installed-global.cjs')]})
|
||||
.then(runStatus => {
|
||||
t.equal(runStatus.stats.passedTests, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test(`Node.js-style --require CLI argument module not found - workerThreads: ${opt.workerThreads}`, t => {
|
||||
t.rejects(apiCreator({...opt, require: ['foo-bar']}), /^Could not resolve required module ’foo-bar’$/);
|
||||
t.end();
|
||||
});
|
||||
|
||||
test(`caching is enabled by default - workerThreads: ${opt.workerThreads}`, async t => {
|
||||
fs.rmSync(path.join(__dirname, 'fixture/caching/node_modules'), {recursive: true, force: true});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,2 +0,0 @@
|
|||
'use strict';
|
||||
global.foo = 'bar';
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
const test = require('../../entrypoints/main.cjs');
|
||||
|
||||
test('test', t => t.is(global.foo, 'bar'));
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"type": "module",
|
||||
"ava": {
|
||||
"require": "./required.cjs"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
exports.called = false;
|
||||
|
||||
exports.default = function () {
|
||||
exports.called = true;
|
||||
};
|
||||
7
test/config-require/fixtures/exports-default/test.js
Normal file
7
test/config-require/fixtures/exports-default/test.js
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import test from 'ava';
|
||||
|
||||
import required from './required.cjs';
|
||||
|
||||
test('exports.default is called', t => {
|
||||
t.true(required.called);
|
||||
});
|
||||
6
test/config-require/fixtures/failed-import/package.json
Normal file
6
test/config-require/fixtures/failed-import/package.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"type": "module",
|
||||
"ava": {
|
||||
"require": "@babel/register"
|
||||
}
|
||||
}
|
||||
5
test/config-require/fixtures/failed-import/test.js
Normal file
5
test/config-require/fixtures/failed-import/test.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import test from 'ava';
|
||||
|
||||
test('should not make it this far', t => {
|
||||
t.fail();
|
||||
});
|
||||
5
test/config-require/fixtures/non-json/ava.config.js
Normal file
5
test/config-require/fixtures/non-json/ava.config.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export default {
|
||||
require: [
|
||||
['./required.mjs', new Map([['hello', 'world']])],
|
||||
],
|
||||
};
|
||||
3
test/config-require/fixtures/non-json/package.json
Normal file
3
test/config-require/fixtures/non-json/package.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"type": "module"
|
||||
}
|
||||
5
test/config-require/fixtures/non-json/required.mjs
Normal file
5
test/config-require/fixtures/non-json/required.mjs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export let receivedArgs = null; // eslint-disable-line import/no-mutable-exports
|
||||
|
||||
export default function (...args) {
|
||||
receivedArgs = args;
|
||||
}
|
||||
7
test/config-require/fixtures/non-json/test.js
Normal file
7
test/config-require/fixtures/non-json/test.js
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import test from 'ava';
|
||||
|
||||
import {receivedArgs} from './required.mjs';
|
||||
|
||||
test('non-JSON arguments can be provided', t => {
|
||||
t.deepEqual(receivedArgs, [new Map([['hello', 'world']])]);
|
||||
});
|
||||
1
test/config-require/fixtures/require-dependency/.gitignore
vendored
Normal file
1
test/config-require/fixtures/require-dependency/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
!node_modules/@ava
|
||||
5
test/config-require/fixtures/require-dependency/node_modules/@ava/stub/index.js
generated
vendored
Normal file
5
test/config-require/fixtures/require-dependency/node_modules/@ava/stub/index.js
generated
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export let required = false; // eslint-disable-line import/no-mutable-exports
|
||||
|
||||
export default function () {
|
||||
required = true;
|
||||
}
|
||||
4
test/config-require/fixtures/require-dependency/node_modules/@ava/stub/package.json
generated
vendored
Normal file
4
test/config-require/fixtures/require-dependency/node_modules/@ava/stub/package.json
generated
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"name": "@ava/stub",
|
||||
"type": "module"
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"type": "module",
|
||||
"ava": {
|
||||
"require": [
|
||||
"@ava/stub"
|
||||
]
|
||||
}
|
||||
}
|
||||
6
test/config-require/fixtures/require-dependency/test.js
Normal file
6
test/config-require/fixtures/require-dependency/test.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import {required} from '@ava/stub';
|
||||
import test from 'ava';
|
||||
|
||||
test('loads dependencies', t => {
|
||||
t.true(required);
|
||||
});
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"type": "module",
|
||||
"ava": {
|
||||
"require": "./required.js"
|
||||
}
|
||||
}
|
||||
5
test/config-require/fixtures/single-argument/required.js
Normal file
5
test/config-require/fixtures/single-argument/required.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export let required = false; // eslint-disable-line import/no-mutable-exports
|
||||
|
||||
export default function () {
|
||||
required = true;
|
||||
}
|
||||
7
test/config-require/fixtures/single-argument/test.js
Normal file
7
test/config-require/fixtures/single-argument/test.js
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import test from 'ava';
|
||||
|
||||
import {required} from './required.js';
|
||||
|
||||
test('loads when given as a single argument', t => {
|
||||
t.true(required);
|
||||
});
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
export default {
|
||||
require: [
|
||||
['./required.mjs', 'hello', 'world'],
|
||||
['./required.cjs', 'goodbye'],
|
||||
'./side-effect.js',
|
||||
],
|
||||
};
|
||||
3
test/config-require/fixtures/with-arguments/package.json
Normal file
3
test/config-require/fixtures/with-arguments/package.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"type": "module"
|
||||
}
|
||||
7
test/config-require/fixtures/with-arguments/required.cjs
Normal file
7
test/config-require/fixtures/with-arguments/required.cjs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
function init(...args) {
|
||||
init.receivedArgs = args;
|
||||
}
|
||||
|
||||
init.receivedArgs = null;
|
||||
|
||||
module.exports = init;
|
||||
5
test/config-require/fixtures/with-arguments/required.mjs
Normal file
5
test/config-require/fixtures/with-arguments/required.mjs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export let receivedArgs = null; // eslint-disable-line import/no-mutable-exports
|
||||
|
||||
export default function (...args) {
|
||||
receivedArgs = args;
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export default Date.now();
|
||||
15
test/config-require/fixtures/with-arguments/test.js
Normal file
15
test/config-require/fixtures/with-arguments/test.js
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import test from 'ava';
|
||||
|
||||
import cjs from './required.cjs';
|
||||
import {receivedArgs} from './required.mjs';
|
||||
|
||||
test('receives arguments from config', t => {
|
||||
t.deepEqual(receivedArgs, ['hello', 'world']);
|
||||
t.deepEqual(cjs.receivedArgs, ['goodbye']);
|
||||
});
|
||||
|
||||
test('side-effects are execute when tests loaded, before test code', async t => {
|
||||
const now = Date.now();
|
||||
const sideEffect = await import('./side-effect.js');
|
||||
t.true(sideEffect.default < now);
|
||||
});
|
||||
38
test/config-require/test.js
Normal file
38
test/config-require/test.js
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import test from '@ava/test';
|
||||
|
||||
import {cwd, fixture} from '../helpers/exec.js';
|
||||
|
||||
test('loads required modules with arguments', async t => {
|
||||
const result = await fixture([], {cwd: cwd('with-arguments')});
|
||||
t.is(result.stats.passed.length, 2);
|
||||
});
|
||||
|
||||
test('non-JSON arguments can be provided (worker threads)', async t => {
|
||||
const result = await fixture([], {cwd: cwd('non-json')});
|
||||
t.is(result.stats.passed.length, 1);
|
||||
});
|
||||
|
||||
test('non-JSON arguments can be provided (child process)', async t => {
|
||||
const result = await fixture(['--no-worker-threads'], {cwd: cwd('non-json')});
|
||||
t.is(result.stats.passed.length, 1);
|
||||
});
|
||||
|
||||
test('loads required modules, not as an array', async t => {
|
||||
const result = await fixture([], {cwd: cwd('single-argument')});
|
||||
t.is(result.stats.passed.length, 1);
|
||||
});
|
||||
|
||||
test('calls exports.default (CJS)', async t => {
|
||||
const result = await fixture([], {cwd: cwd('exports-default')});
|
||||
t.is(result.stats.passed.length, 1);
|
||||
});
|
||||
|
||||
test('loads dependencies', async t => {
|
||||
const result = await fixture([], {cwd: cwd('require-dependency')});
|
||||
t.is(result.stats.passed.length, 1);
|
||||
});
|
||||
|
||||
test('crashes if module cannot be loaded', async t => {
|
||||
const result = await t.throwsAsync(fixture([], {cwd: cwd('failed-import')}));
|
||||
t.is(result.stats.uncaughtExceptions.length, 1);
|
||||
});
|
||||
Loading…
Reference in a new issue