Skip to content

Commit

Permalink
Find ava.config.* files outside of project directory
Browse files Browse the repository at this point in the history
Fixes #2285.

Find configuration files in parent directories, until a `.git` is
encountered.

Print a warning when AVA is run with a configuration file that is not
next to the `package.json`. This is now allowed when passing `--config`.

Update the documentation to reflect these changes. Remove documentation
of the experimental "next-gen" feature in AVA 3.
  • Loading branch information
novemberborn committed Oct 31, 2021
1 parent 44aebd9 commit c214512
Show file tree
Hide file tree
Showing 17 changed files with 157 additions and 88 deletions.
6 changes: 6 additions & 0 deletions .xo-config.cjs
@@ -1,3 +1,9 @@
// XO's AVA plugin will use the checked out code to resolve AVA configuration,
// which causes all kinds of confusion when it finds our own ava.config.cjs file
// or other ava.config.* fixtures.
// Use the internal test flag to make XO behave like our own tests.
require('node:process').env.AVA_FAKE_SCM_ROOT = '.fake-root';

module.exports = {
ignores: [
'media/**',
Expand Down
3 changes: 3 additions & 0 deletions ava.config.cjs
Expand Up @@ -4,4 +4,7 @@
module.exports = {
files: ['test/**', '!test/**/{fixtures,helpers}/**'],
ignoredByWatcher: ['{coverage,docs,media,test-d,test-tap}/**'],
environmentVariables: {
AVA_FAKE_SCM_ROOT: '.fake-root', // This is an internal test flag.
},
};
85 changes: 49 additions & 36 deletions docs/06-configuration.md
Expand Up @@ -67,23 +67,25 @@ Provide the `typescript` option (and install [`@ava/typescript`](https://github.

## Using `ava.config.*` files

Rather than specifying the configuration in the `package.json` file you can use `ava.config.js` or `ava.config.cjs` files.
Rather than specifying the configuration in the `package.json` file you can use `ava.config.js`, `ava.config.cjs` or `ava.config.mjs` files.

Note: AVA 3 recognizes `ava.config.mjs` files but refuses to load them. They work in AVA 4.

To use these files:

1. They must be in the same directory as your `package.json`
2. Your `package.json` must not contain an `ava` property (or, if it does, it must be an empty object)
3. You must not both have an `ava.config.js` *and* an `ava.config.cjs` file
1. Your `package.json` must not contain an `ava` property (or, if it does, it must be an empty object)
2. You must only have one `ava.config.*` file in any directory, so don't mix `ava.config.js` *and* `ava.config.cjs` files
3. AVA 3 requires these files be in the same directory as your `package.json` file

AVA 3 recognizes `ava.config.mjs` files but refuses to load them. This is changing in AVA 4, [see below](#next-generation-configuration).
AVA 4 searches your file system for `ava.config.*` files. First, when you run AVA, it finds the closest `package.json`. Starting in that directory it recursively checks the parent directories until it either reaches the file system root or encounters a `.git` file or directory. The first `ava.config.*` file found is selected. This allows you to use a single configuration file in a monorepo setup.

### `ava.config.js`

In AVA 3, for `ava.config.js` files you must use `export default`. You cannot use ["module scope"](https://nodejs.org/docs/latest-v12.x/api/modules.html#modules_the_module_scope). You cannot import dependencies.
AVA 4 follows Node.js' behavior, so if you've set `"type": "module"` you must use ESM, and otherwise you must use CommonJS.

This is changing in AVA 4, [see below](#next-generation-configuration).
In AVA 3, for `ava.config.js` files you must use `export default`. You cannot use ["module scope"](https://nodejs.org/docs/latest-v12.x/api/modules.html#modules_the_module_scope). You cannot import dependencies.

The default export can either be a plain object or a factory function which returns a plain object:
The default export can either be a plain object or a factory function which returns a plain object. Starting in AVA 4 you can export or return a promise for a plain object:

```js
export default {
Expand Down Expand Up @@ -115,13 +117,11 @@ export default ({projectDir}) => {
};
```

Note that the final configuration must not be a promise. This is changing in AVA 4, [see below](#next-generation-configuration).

### `ava.config.cjs`

For `ava.config.cjs` files you must assign `module.exports`. ["Module scope"](https://nodejs.org/docs/latest-v12.x/api/modules.html#modules_the_module_scope) is available. You can `require()` dependencies.

The module export can either be a plain object or a factory function which returns a plain object:
The module export can either be a plain object or a factory function which returns a plain object. Starting in AVA 4 you can export or return a promise for a plain object:

```js
module.exports = {
Expand Down Expand Up @@ -153,17 +153,49 @@ module.exports = ({projectDir}) => {
};
```

Note that the final configuration must not be a promise. This is changing in AVA 4, [see below](#next-generation-configuration).
### `ava.config.mjs`

## Alternative configuration files
Note that `ava.config.mjs` files are only supported in AVA 4.

The default export can either be a plain object or a factory function which returns a plain object. You can export or return a promise for a plain object:

```js
export default {
require: ['./_my-test-helper']
};
```

```js
export default function factory() {
return {
require: ['./_my-test-helper']
};
};
```

The factory function is called with an object containing a `projectDir` property, which you could use to change the returned configuration:

```js
export default ({projectDir}) => {
if (projectDir === '/Users/username/projects/my-project') {
return {
// Config A
};
}

The [CLI] lets you specify a specific configuration file, using the `--config` flag. This file must have either a `.js` or `.cjs` extension and is processed like an `ava.config.js` or `ava.config.cjs` file would be.
return {
// Config B
};
};
```

AVA 4 also supports `.mjs` extensions, [see below](#next-generation-configuration).
## Alternative configuration files

The [CLI] lets you specify a specific configuration file, using the `--config` flag. This file must have either a `.js`, `.cjs` or `.mjs` extension and is processed like an `ava.config.js`, `ava.config.cjs` or `ava.config.mjs` file would be.

When the `--config` flag is set, the provided file will override all configuration from the `package.json` and `ava.config.js` or `ava.config.cjs` files. The configuration is not merged.
When the `--config` flag is set, the provided file will override all configuration from the `package.json` and `ava.config.js`, `ava.config.cjs` or `ava.config.mjs` files. The configuration is not merged.

The configuration file *must* be in the same directory as the `package.json` file.
Note: In AVA 3 the configuration file *must* be in the same directory as the `package.json` file. This restriction does not apply to AVA 4.

You can use this to customize configuration for a specific test run. For instance, you may want to run unit tests separately from integration tests:

Expand All @@ -188,25 +220,6 @@ module.exports = {

You can now run your unit tests through `npx ava` and the integration tests through `npx ava --config integration-tests.config.cjs`.

## Next generation configuration

AVA 4 will add full support for ESM configuration files as well as allowing you to have asynchronous factory functions. If you're using Node.js 12 or later you can opt-in to these features in AVA 3 by enabling the `nextGenConfig` experiment. Say in an `ava.config.mjs` file:

```js
export default {
nonSemVerExperiments: {
nextGenConfig: true
},
files: ['unit-tests/**/*']
};
```

This also allows you to pass an `.mjs` file using the `--config` argument.

With this experiment enabled, AVA will no longer have special treatment for `ava.config.js` files. Instead AVA follows Node.js' behavior, so if you've set [`"type": "module"`](https://nodejs.org/docs/latest/api/packages.html#packages_type) you must use ESM, and otherwise you must use CommonJS.

You mustn't have an `ava.config.mjs` file next to an `ava.config.js` or `ava.config.cjs` file.

## Object printing depth

By default, AVA prints nested objects to a depth of `3`. However, when debugging tests with deeply nested objects, it can be useful to print with more detail. This can be done by setting [`util.inspect.defaultOptions.depth`](https://nodejs.org/api/util.html#util_util_inspect_defaultoptions) to the desired depth, before the test is executed:
Expand Down
7 changes: 5 additions & 2 deletions lib/cli.js
Expand Up @@ -99,11 +99,14 @@ const FLAGS = {
};

export default async function loadCli() { // eslint-disable-line complexity
let conf = {};
let confError = null;
let conf;
let confError;
try {
const {argv: {config: configFile}} = yargs(hideBin(process.argv)).help(false);
conf = await loadConfig({configFile});
if (conf.configFile && path.basename(conf.configFile) !== path.relative(conf.projectDir, conf.configFile)) {
console.log(chalk.magenta(` ${figures.warning} Using configuration from ${conf.configFile}`));
}
} catch (error) {
confError = error;
}
Expand Down
92 changes: 49 additions & 43 deletions lib/load-config.js
@@ -1,5 +1,4 @@
import fs from 'node:fs';
import {createRequire} from 'node:module';
import path from 'node:path';
import process from 'node:process';
import url from 'node:url';
Expand All @@ -22,52 +21,22 @@ const importConfig = async ({configFile, fileForErrorMessage}) => {
return config;
};

const loadJsConfig = async ({projectDir, configFile = path.join(projectDir, 'ava.config.js')}) => {
if (!configFile.endsWith('.js') || !fs.existsSync(configFile)) {
const loadConfigFile = async ({projectDir, configFile}) => {
if (!fs.existsSync(configFile)) {
return null;
}

const fileForErrorMessage = path.relative(projectDir, configFile);
try {
return {config: await importConfig({configFile, fileForErrorMessage}), fileForErrorMessage};
return {config: await importConfig({configFile, fileForErrorMessage}), configFile, fileForErrorMessage};
} catch (error) {
throw Object.assign(new Error(`Error loading ${fileForErrorMessage}: ${error.message}`), {parent: error});
}
};

const loadCjsConfig = async ({projectDir, configFile = path.join(projectDir, 'ava.config.cjs')}) => {
if (!configFile.endsWith('.cjs') || !fs.existsSync(configFile)) {
return null;
}

const fileForErrorMessage = path.relative(projectDir, configFile);
try {
const require = createRequire(import.meta.url);
return {config: await require(configFile), fileForErrorMessage};
} catch (error) {
throw Object.assign(new Error(`Error loading ${fileForErrorMessage}`), {parent: error});
}
};

const loadMjsConfig = async ({projectDir, configFile = path.join(projectDir, 'ava.config.mjs')}) => {
if (!configFile.endsWith('.mjs') || !fs.existsSync(configFile)) {
return null;
}

const fileForErrorMessage = path.relative(projectDir, configFile);
try {
return {config: await importConfig({configFile, fileForErrorMessage}), fileForErrorMessage};
} catch (error) {
throw Object.assign(new Error(`Error loading ${fileForErrorMessage}`), {parent: error});
}
};

function resolveConfigFile(projectDir, configFile) {
function resolveConfigFile(configFile) {
if (configFile) {
configFile = path.resolve(configFile); // Relative to CWD
if (path.basename(configFile) !== path.relative(projectDir, configFile)) {
throw new Error('Config files must be located next to the package.json file');
}

if (!configFile.endsWith('.js') && !configFile.endsWith('.cjs') && !configFile.endsWith('.mjs')) {
throw new Error('Config files must have .js, .cjs or .mjs extensions');
Expand All @@ -77,20 +46,57 @@ function resolveConfigFile(projectDir, configFile) {
return configFile;
}

const gitScmFile = process.env.AVA_FAKE_SCM_ROOT || '.git';

async function findRepoRoot(fromDir) {
const {root} = path.parse(fromDir);
let dir = fromDir;
while (root !== dir) {
try {
const stat = await fs.promises.stat(path.join(dir, gitScmFile)); // eslint-disable-line no-await-in-loop
if (stat.isFile() || stat.isDirectory()) {
return dir;
}
} catch {}

dir = path.dirname(dir);
}

return root;
}

export async function loadConfig({configFile, resolveFrom = process.cwd(), defaults = {}} = {}) {
let packageConf = await packageConfig('ava', {cwd: resolveFrom});
const filepath = packageJsonPath(packageConf);
const projectDir = filepath === undefined ? resolveFrom : path.dirname(filepath);

configFile = resolveConfigFile(projectDir, configFile);
const repoRoot = await findRepoRoot(projectDir);

// Conflicts are only allowed when an explicit config file is provided.
const allowConflictWithPackageJson = Boolean(configFile);
configFile = resolveConfigFile(configFile);

// TODO: Refactor resolution logic to implement https://github.com/avajs/ava/issues/2285.
let [{config: fileConf, fileForErrorMessage} = {config: NO_SUCH_FILE, fileForErrorMessage: undefined}, ...conflicting] = (await Promise.all([
loadJsConfig({projectDir, configFile}, true),
loadCjsConfig({projectDir, configFile}),
loadMjsConfig({projectDir, configFile}, true),
])).filter(result => result !== null);
let fileConf = NO_SUCH_FILE;
let fileForErrorMessage;
let conflicting = [];
if (configFile) {
const loaded = await loadConfigFile({projectDir, configFile});
if (loaded !== null) {
({config: fileConf, fileForErrorMessage} = loaded);
}
} else {
let searchDir = projectDir;
const stopAt = path.dirname(repoRoot);
do {
[{config: fileConf, fileForErrorMessage, configFile} = {config: NO_SUCH_FILE, fileForErrorMessage: undefined}, ...conflicting] = (await Promise.all([ // eslint-disable-line no-await-in-loop
loadConfigFile({projectDir, configFile: path.join(searchDir, 'ava.config.js')}),
loadConfigFile({projectDir, configFile: path.join(searchDir, 'ava.config.cjs')}),
loadConfigFile({projectDir, configFile: path.join(searchDir, 'ava.config.mjs')}),
])).filter(result => result !== null);

searchDir = path.dirname(searchDir);
} while (fileConf === NO_SUCH_FILE && searchDir !== stopAt);
}

if (conflicting.length > 0) {
throw new Error(`Conflicting configuration in ${fileForErrorMessage} and ${conflicting.map(({fileForErrorMessage}) => fileForErrorMessage).join(' & ')}`);
Expand Down Expand Up @@ -120,7 +126,7 @@ export async function loadConfig({configFile, resolveFrom = process.cwd(), defau
}
}

const config = {...defaults, nonSemVerExperiments: {}, ...fileConf, ...packageConf, projectDir};
const config = {...defaults, nonSemVerExperiments: {}, ...fileConf, ...packageConf, projectDir, configFile};

const {nonSemVerExperiments: experiments} = config;
if (!isPlainObject(experiments)) {
Expand Down
Empty file added test-tap/fixture/.fake-root
Empty file.
8 changes: 6 additions & 2 deletions test-tap/helper/cli.js
Expand Up @@ -27,8 +27,12 @@ export function execCli(args, options, cb) {
const processPromise = new Promise(resolve => {
child = childProcess.spawn(process.execPath, [cliPath].concat(args), { // eslint-disable-line unicorn/prefer-spread
cwd: dirname,
env: {AVA_FORCE_CI: 'ci', ...env}, // Force CI to ensure the correct reporter is selected
// env,
env: {
AVA_FORCE_CI: 'ci', // Force CI to ensure the correct reporter is selected
AVA_FAKE_SCM_ROOT: '.fake-root', // This is an internal test flag.
...env,
},
// Env,
stdio: [null, 'pipe', 'pipe'],
});

Expand Down
Empty file added test/.fake-root
Empty file.
3 changes: 3 additions & 0 deletions test/config/fixtures/monorepo/ava.config.mjs
@@ -0,0 +1,3 @@
export default {
files: ['foo.js'],
};
5 changes: 5 additions & 0 deletions test/config/fixtures/monorepo/package/foo.js
@@ -0,0 +1,5 @@
import test from 'ava';

test('foo', t => {
t.pass();
});
3 changes: 3 additions & 0 deletions test/config/fixtures/monorepo/package/package.json
@@ -0,0 +1,3 @@
{
"type": "module"
}
10 changes: 10 additions & 0 deletions test/config/integration.js
Expand Up @@ -53,6 +53,16 @@ test('resolves tests from an .mjs config file', async t => {
t.snapshot(result.stats.passed, 'resolves test files from configuration');
});

test('looks for config files outside of project directory', async t => {
const options = {
cwd: cwd('monorepo/package'),
};

const result = await fixture([], options);

t.snapshot(result.stats.passed, 'resolves test files from configuration');
});

test('use current working directory if `package.json` is not found', async t => {
const cwd = tempy.directory();
const testFilePath = path.join(cwd, 'test.js');
Expand Down
8 changes: 5 additions & 3 deletions test/config/loader.js
Expand Up @@ -63,10 +63,12 @@ test.serial('explicit configFile option overrides package.json config', ok({
t.is(conf.files, 'package-yes-explicit-yes-test-value');
});

test.serial('throws if configFile option is not in the same directory as the package.json file', notOk({
test.serial('configFile does not need to be in the same directory as the package.json file', ok({
fixture: 'package-yes-explicit-yes',
configFile: 'nested/explicit.js',
}));
}), (t, config) => {
t.is(path.relative(config.projectDir, config.configFile), path.normalize('nested/explicit.js'));
});

test.serial('throws if configFile option has an unsupported extension', notOk({
fixture: 'explicit-bad-extension',
Expand Down Expand Up @@ -99,7 +101,7 @@ test.serial('throws an error if a config factory does not return a plain object'

test.serial('throws an error if a config does not export a plain object', notOk('no-plain-config'));

test.serial('receives a `projectDir` property', ok('package-only'), (t, conf) => {
test.serial('receives a `projectDir` property', (...args) => ok('package-only')(...args), (t, conf) => {
t.assert(conf.projectDir.startsWith(FIXTURE_ROOT));
});

Expand Down
11 changes: 11 additions & 0 deletions test/config/snapshots/integration.js.md
Expand Up @@ -59,3 +59,14 @@ Generated by [AVA](https://avajs.dev).
title: 'test name',
},
]

## looks for config files outside of project directory

> resolves test files from configuration
[
{
file: 'foo.js',
title: 'foo',
},
]
Binary file modified test/config/snapshots/integration.js.snap
Binary file not shown.

0 comments on commit c214512

Please sign in to comment.