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:
Bartłomiej Kalemba 2023-06-25 15:25:46 +02:00 committed by GitHub
parent 1fff08d8b4
commit 4dc385b486
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 306 additions and 42 deletions

View file

@ -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.

View file

@ -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 = () => {};

View file

@ -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();

View file

@ -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

View file

@ -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});

View file

@ -1,2 +0,0 @@
'use strict';
global.foo = 'bar';

View file

@ -1,3 +0,0 @@
const test = require('../../entrypoints/main.cjs');
test('test', t => t.is(global.foo, 'bar'));

View file

@ -0,0 +1,6 @@
{
"type": "module",
"ava": {
"require": "./required.cjs"
}
}

View file

@ -0,0 +1,5 @@
exports.called = false;
exports.default = function () {
exports.called = true;
};

View file

@ -0,0 +1,7 @@
import test from 'ava';
import required from './required.cjs';
test('exports.default is called', t => {
t.true(required.called);
});

View file

@ -0,0 +1,6 @@
{
"type": "module",
"ava": {
"require": "@babel/register"
}
}

View file

@ -0,0 +1,5 @@
import test from 'ava';
test('should not make it this far', t => {
t.fail();
});

View file

@ -0,0 +1,5 @@
export default {
require: [
['./required.mjs', new Map([['hello', 'world']])],
],
};

View file

@ -0,0 +1,3 @@
{
"type": "module"
}

View file

@ -0,0 +1,5 @@
export let receivedArgs = null; // eslint-disable-line import/no-mutable-exports
export default function (...args) {
receivedArgs = args;
}

View 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']])]);
});

View file

@ -0,0 +1 @@
!node_modules/@ava

View file

@ -0,0 +1,5 @@
export let required = false; // eslint-disable-line import/no-mutable-exports
export default function () {
required = true;
}

View file

@ -0,0 +1,4 @@
{
"name": "@ava/stub",
"type": "module"
}

View file

@ -0,0 +1,8 @@
{
"type": "module",
"ava": {
"require": [
"@ava/stub"
]
}
}

View file

@ -0,0 +1,6 @@
import {required} from '@ava/stub';
import test from 'ava';
test('loads dependencies', t => {
t.true(required);
});

View file

@ -0,0 +1,6 @@
{
"type": "module",
"ava": {
"require": "./required.js"
}
}

View file

@ -0,0 +1,5 @@
export let required = false; // eslint-disable-line import/no-mutable-exports
export default function () {
required = true;
}

View 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);
});

View file

@ -0,0 +1,7 @@
export default {
require: [
['./required.mjs', 'hello', 'world'],
['./required.cjs', 'goodbye'],
'./side-effect.js',
],
};

View file

@ -0,0 +1,3 @@
{
"type": "module"
}

View file

@ -0,0 +1,7 @@
function init(...args) {
init.receivedArgs = args;
}
init.receivedArgs = null;
module.exports = init;

View file

@ -0,0 +1,5 @@
export let receivedArgs = null; // eslint-disable-line import/no-mutable-exports
export default function (...args) {
receivedArgs = args;
}

View file

@ -0,0 +1 @@
export default Date.now();

View 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);
});

View 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);
});