Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Modernize require config #3184

Merged
merged 18 commits into from Jun 25, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
87 changes: 80 additions & 7 deletions docs/06-configuration.md
Expand Up @@ -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).
Expand Down Expand Up @@ -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']
};
};
```
Expand Down Expand Up @@ -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']
};
};
```
Expand All @@ -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']
};
};
```
Expand Down Expand Up @@ -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.
Expand Down
15 changes: 6 additions & 9 deletions lib/api.js
Expand Up @@ -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';
Expand All @@ -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;
});
}

Expand Down Expand Up @@ -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 = () => {};
Expand Down
1 change: 1 addition & 0 deletions lib/fork.js
Expand Up @@ -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();
Expand Down
54 changes: 52 additions & 2 deletions lib/worker/base.js
@@ -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';
Expand Down Expand Up @@ -174,9 +177,56 @@
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});
}
}

Check warning on line 187 in lib/worker/base.js

View check run for this annotation

Codecov / codecov/patch

lib/worker/base.js#L184-L187

Added lines #L184 - L187 were not covered by tests

// 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;
}

Check warning on line 207 in lib/worker/base.js

View check run for this annotation

Codecov / codecov/patch

lib/worker/base.js#L205-L207

Added lines #L205 - L207 were not covered by tests
};

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
Expand Down
19 changes: 0 additions & 19 deletions test-tap/api.js
Expand Up @@ -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});

Expand Down
2 changes: 0 additions & 2 deletions test-tap/fixture/install-global.cjs

This file was deleted.

3 changes: 0 additions & 3 deletions test-tap/fixture/validate-installed-global.cjs

This file was deleted.

6 changes: 6 additions & 0 deletions test/config-require/fixtures/exports-default/package.json
@@ -0,0 +1,6 @@
{
"type": "module",
"ava": {
"require": "./required.cjs"
}
}
5 changes: 5 additions & 0 deletions test/config-require/fixtures/exports-default/required.cjs
@@ -0,0 +1,5 @@
exports.called = false;

exports.default = function () {
exports.called = true;
};
7 changes: 7 additions & 0 deletions test/config-require/fixtures/exports-default/test.js
@@ -0,0 +1,7 @@
import test from 'ava';

import required from './required.cjs';

test('exports.default is called', t => {
t.true(required.called);
});
6 changes: 6 additions & 0 deletions test/config-require/fixtures/failed-import/package.json
@@ -0,0 +1,6 @@
{
"type": "module",
"ava": {
"require": "@babel/register"
}
}
5 changes: 5 additions & 0 deletions test/config-require/fixtures/failed-import/test.js
@@ -0,0 +1,5 @@
import test from 'ava';

test('should not make it this far', t => {
t.fail();
});
5 changes: 5 additions & 0 deletions test/config-require/fixtures/non-json/ava.config.js
@@ -0,0 +1,5 @@
export default {
require: [
['./required.mjs', new Map([['hello', 'world']])],
],
};
3 changes: 3 additions & 0 deletions test/config-require/fixtures/non-json/package.json
@@ -0,0 +1,3 @@
{
"type": "module"
}
5 changes: 5 additions & 0 deletions test/config-require/fixtures/non-json/required.mjs
@@ -0,0 +1,5 @@
export let receivedArgs = null; // eslint-disable-line import/no-mutable-exports

export default function (...args) {
receivedArgs = args;
}
7 changes: 7 additions & 0 deletions test/config-require/fixtures/non-json/test.js
@@ -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 change: 1 addition & 0 deletions test/config-require/fixtures/require-dependency/.gitignore
@@ -0,0 +1 @@
!node_modules/@ava

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions test/config-require/fixtures/require-dependency/package.json
@@ -0,0 +1,8 @@
{
"type": "module",
"ava": {
"require": [
"@ava/stub"
]
}
}
6 changes: 6 additions & 0 deletions test/config-require/fixtures/require-dependency/test.js
@@ -0,0 +1,6 @@
import {required} from '@ava/stub';
import test from 'ava';

test('loads dependencies', t => {
t.true(required);
});
6 changes: 6 additions & 0 deletions test/config-require/fixtures/single-argument/package.json
@@ -0,0 +1,6 @@
{
"type": "module",
"ava": {
"require": "./required.js"
}
}
5 changes: 5 additions & 0 deletions test/config-require/fixtures/single-argument/required.js
@@ -0,0 +1,5 @@
export let required = false; // eslint-disable-line import/no-mutable-exports

export default function () {
required = true;
}
7 changes: 7 additions & 0 deletions test/config-require/fixtures/single-argument/test.js
@@ -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);
});