Skip to content

Commit 4dc385b

Browse files
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 <[email protected]>
1 parent 1fff08d commit 4dc385b

31 files changed

+306
-42
lines changed

docs/06-configuration.md

+80-7
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ Arguments passed to the CLI will always take precedence over the CLI options con
5555
- `verbose`: if `true`, enables verbose output (though there currently non-verbose output is not supported)
5656
- `snapshotDir`: specifies a fixed location for storing snapshot files. Use this if your snapshots are ending up in the wrong location
5757
- `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)
58-
- `require`: extra modules to require before tests are run. Modules are required in the [worker processes](./01-writing-tests.md#test-isolation)
58+
- `require`: [extra modules to load before test files](#requiring-extra-modules)
5959
- `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.
6060
- `nodeArguments`: Configure Node.js arguments used to launch worker processes.
6161
- `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
8484

8585
```js
8686
export default {
87-
require: ['./_my-test-helper']
87+
require: ['./_my-test-helper.js']
8888
};
8989
```
9090

9191
```js
9292
export default function factory() {
9393
return {
94-
require: ['./_my-test-helper']
94+
require: ['./_my-test-helper.js']
9595
};
9696
};
9797
```
@@ -120,14 +120,14 @@ The module export can either be a plain object or a factory function which retur
120120

121121
```js
122122
module.exports = {
123-
require: ['./_my-test-helper']
123+
require: ['./_my-test-helper.js']
124124
};
125125
```
126126

127127
```js
128128
module.exports = () => {
129129
return {
130-
require: ['./_my-test-helper']
130+
require: ['./_my-test-helper.js']
131131
};
132132
};
133133
```
@@ -154,14 +154,14 @@ The default export can either be a plain object or a factory function which retu
154154

155155
```js
156156
export default {
157-
require: ['./_my-test-helper']
157+
require: ['./_my-test-helper.js']
158158
};
159159
```
160160

161161
```js
162162
export default function factory() {
163163
return {
164-
require: ['./_my-test-helper']
164+
require: ['./_my-test-helper.js']
165165
};
166166
};
167167
```
@@ -258,6 +258,79 @@ export default {
258258
};
259259
```
260260

261+
## Requiring extra modules
262+
263+
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.
264+
265+
You may specify a single value, or an array of values:
266+
267+
`ava.config.js`:
268+
```js
269+
export default {
270+
require: './_my-test-helper.js'
271+
}
272+
```
273+
```js
274+
export default {
275+
require: ['./_my-test-helper.js']
276+
}
277+
```
278+
279+
If the module exports a function, it is called and awaited:
280+
281+
`_my-test-helper.js`:
282+
```js
283+
export default function () {
284+
// Additional setup
285+
}
286+
```
287+
288+
`_my-test-helper.cjs`:
289+
```js
290+
module.exports = function () {
291+
// Additional setup
292+
}
293+
```
294+
295+
In CJS files, a `default` export is also supported:
296+
297+
```js
298+
exports.default = function () {
299+
// Never called
300+
}
301+
```
302+
303+
You can provide arguments:
304+
305+
`ava.config.js`:
306+
```js
307+
export default {
308+
require: [
309+
['./_my-test-helper.js', 'my', 'arguments']
310+
]
311+
}
312+
```
313+
314+
`_my-test-helper.js`:
315+
```js
316+
export default function (first, second) { // 'my', 'arguments'
317+
// Additional setup
318+
}
319+
```
320+
321+
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`.
322+
323+
You can load dependencies installed in your project:
324+
325+
`ava.config.js`:
326+
```js
327+
export default {
328+
require: '@babel/register'
329+
}
330+
```
331+
332+
These may also export a function which is then invoked, and can receive arguments.
333+
261334
## Node arguments
262335

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

lib/api.js

+6-9
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import commonPathPrefix from 'common-path-prefix';
99
import Emittery from 'emittery';
1010
import ms from 'ms';
1111
import pMap from 'p-map';
12-
import resolveCwd from 'resolve-cwd';
1312
import tempDir from 'temp-dir';
1413

1514
import fork from './fork.js';
@@ -22,15 +21,13 @@ import RunStatus from './run-status.js';
2221
import scheduler from './scheduler.js';
2322
import serializeError from './serialize-error.js';
2423

25-
function resolveModules(modules) {
26-
return arrify(modules).map(name => {
27-
const modulePath = resolveCwd.silent(name);
28-
29-
if (modulePath === undefined) {
30-
throw new Error(`Could not resolve required module ’${name}’`);
24+
function normalizeRequireOption(require) {
25+
return arrify(require).map(name => {
26+
if (typeof name === 'string') {
27+
return arrify(name);
3128
}
3229

33-
return modulePath;
30+
return name;
3431
});
3532
}
3633

@@ -81,7 +78,7 @@ export default class Api extends Emittery {
8178
super();
8279

8380
this.options = {match: [], moduleTypes: {}, ...options};
84-
this.options.require = resolveModules(this.options.require);
81+
this.options.require = normalizeRequireOption(this.options.require);
8582

8683
this._cacheDir = null;
8784
this._interruptHandler = () => {};

lib/fork.js

+1
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ const createWorker = (options, execArgv) => {
5353
silent: true,
5454
env: {NODE_ENV: 'test', ...process.env, ...options.environmentVariables},
5555
execArgv: [...execArgv, ...additionalExecArgv],
56+
serialization: 'advanced',
5657
});
5758
postMessage = controlFlow(worker);
5859
close = async () => worker.kill();

lib/worker/base.js

+52-2
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1+
import {mkdir} from 'node:fs/promises';
12
import {createRequire} from 'node:module';
3+
import {join as joinPath, resolve as resolvePath} from 'node:path';
24
import process from 'node:process';
35
import {pathToFileURL} from 'node:url';
46
import {workerData} from 'node:worker_threads';
57

68
import setUpCurrentlyUnhandled from 'currently-unhandled';
9+
import writeFileAtomic from 'write-file-atomic';
710

811
import {set as setChalk} from '../chalk.js';
912
import nowAndTimers from '../now-and-timers.cjs';
@@ -174,9 +177,56 @@ const run = async options => {
174177
return require(ref);
175178
};
176179

180+
const loadRequiredModule = async ref => {
181+
// If the provider can load the module, assume it's a local file and not a
182+
// dependency.
183+
for (const provider of providers) {
184+
if (provider.canLoad(ref)) {
185+
return provider.load(ref, {requireFn: require});
186+
}
187+
}
188+
189+
// Try to load the module as a file, relative to the project directory.
190+
// Match load() behavior.
191+
const fullPath = resolvePath(projectDir, ref);
192+
try {
193+
for (const extension of extensionsToLoadAsModules) {
194+
if (fullPath.endsWith(`.${extension}`)) {
195+
return await import(pathToFileURL(fullPath)); // eslint-disable-line no-await-in-loop
196+
}
197+
}
198+
199+
return require(fullPath);
200+
} catch (error) {
201+
// If the module could not be found, assume it's not a file but a dependency.
202+
if (error.code === 'ERR_MODULE_NOT_FOUND' || error.code === 'MODULE_NOT_FOUND') {
203+
return importFromProject(ref);
204+
}
205+
206+
throw error;
207+
}
208+
};
209+
210+
let importFromProject = async ref => {
211+
// Do not use the cacheDir since it's not guaranteed to be inside node_modules.
212+
const avaCacheDir = joinPath(projectDir, 'node_modules', '.cache', 'ava');
213+
await mkdir(avaCacheDir, {recursive: true});
214+
const stubPath = joinPath(avaCacheDir, 'import-from-project.mjs');
215+
await writeFileAtomic(stubPath, 'export const importFromProject = ref => import(ref);\n');
216+
({importFromProject} = await import(pathToFileURL(stubPath)));
217+
return importFromProject(ref);
218+
};
219+
177220
try {
178-
for await (const ref of (options.require || [])) {
179-
await load(ref);
221+
for await (const [ref, ...args] of (options.require ?? [])) {
222+
const loadedModule = await loadRequiredModule(ref);
223+
224+
if (typeof loadedModule === 'function') { // CJS module
225+
await loadedModule(...args);
226+
} else if (typeof loadedModule.default === 'function') { // ES module, or exports.default from CJS
227+
const {default: fn} = loadedModule;
228+
await fn(...args);
229+
}
180230
}
181231

182232
// Install dependency tracker after the require configuration has been evaluated

test-tap/api.js

-19
Original file line numberDiff line numberDiff line change
@@ -359,25 +359,6 @@ for (const opt of options) {
359359
});
360360
});
361361

362-
test(`Node.js-style --require CLI argument - workerThreads: ${opt.workerThreads}`, async t => {
363-
const requirePath = './' + path.relative('.', path.join(__dirname, 'fixture/install-global.cjs')).replace(/\\/g, '/');
364-
365-
const api = await apiCreator({
366-
...opt,
367-
require: [requirePath],
368-
});
369-
370-
return api.run({files: [path.join(__dirname, 'fixture/validate-installed-global.cjs')]})
371-
.then(runStatus => {
372-
t.equal(runStatus.stats.passedTests, 1);
373-
});
374-
});
375-
376-
test(`Node.js-style --require CLI argument module not found - workerThreads: ${opt.workerThreads}`, t => {
377-
t.rejects(apiCreator({...opt, require: ['foo-bar']}), /^Could not resolve required module foo-bar$/);
378-
t.end();
379-
});
380-
381362
test(`caching is enabled by default - workerThreads: ${opt.workerThreads}`, async t => {
382363
fs.rmSync(path.join(__dirname, 'fixture/caching/node_modules'), {recursive: true, force: true});
383364

test-tap/fixture/install-global.cjs

-2
This file was deleted.

test-tap/fixture/validate-installed-global.cjs

-3
This file was deleted.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"type": "module",
3+
"ava": {
4+
"require": "./required.cjs"
5+
}
6+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
exports.called = false;
2+
3+
exports.default = function () {
4+
exports.called = true;
5+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import test from 'ava';
2+
3+
import required from './required.cjs';
4+
5+
test('exports.default is called', t => {
6+
t.true(required.called);
7+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"type": "module",
3+
"ava": {
4+
"require": "@babel/register"
5+
}
6+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import test from 'ava';
2+
3+
test('should not make it this far', t => {
4+
t.fail();
5+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export default {
2+
require: [
3+
['./required.mjs', new Map([['hello', 'world']])],
4+
],
5+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"type": "module"
3+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export let receivedArgs = null; // eslint-disable-line import/no-mutable-exports
2+
3+
export default function (...args) {
4+
receivedArgs = args;
5+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import test from 'ava';
2+
3+
import {receivedArgs} from './required.mjs';
4+
5+
test('non-JSON arguments can be provided', t => {
6+
t.deepEqual(receivedArgs, [new Map([['hello', 'world']])]);
7+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
!node_modules/@ava

test/config-require/fixtures/require-dependency/node_modules/@ava/stub/index.js

+5
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

test/config-require/fixtures/require-dependency/node_modules/@ava/stub/package.json

+4
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"type": "module",
3+
"ava": {
4+
"require": [
5+
"@ava/stub"
6+
]
7+
}
8+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import {required} from '@ava/stub';
2+
import test from 'ava';
3+
4+
test('loads dependencies', t => {
5+
t.true(required);
6+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"type": "module",
3+
"ava": {
4+
"require": "./required.js"
5+
}
6+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export let required = false; // eslint-disable-line import/no-mutable-exports
2+
3+
export default function () {
4+
required = true;
5+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import test from 'ava';
2+
3+
import {required} from './required.js';
4+
5+
test('loads when given as a single argument', t => {
6+
t.true(required);
7+
});

0 commit comments

Comments
 (0)