Skip to content

Commit c214512

Browse files
committed
Find ava.config.* files outside of project directory
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.
1 parent 44aebd9 commit c214512

17 files changed

+157
-88
lines changed

.xo-config.cjs

+6
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
// XO's AVA plugin will use the checked out code to resolve AVA configuration,
2+
// which causes all kinds of confusion when it finds our own ava.config.cjs file
3+
// or other ava.config.* fixtures.
4+
// Use the internal test flag to make XO behave like our own tests.
5+
require('node:process').env.AVA_FAKE_SCM_ROOT = '.fake-root';
6+
17
module.exports = {
28
ignores: [
39
'media/**',

ava.config.cjs

+3
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,7 @@
44
module.exports = {
55
files: ['test/**', '!test/**/{fixtures,helpers}/**'],
66
ignoredByWatcher: ['{coverage,docs,media,test-d,test-tap}/**'],
7+
environmentVariables: {
8+
AVA_FAKE_SCM_ROOT: '.fake-root', // This is an internal test flag.
9+
},
710
};

docs/06-configuration.md

+49-36
Original file line numberDiff line numberDiff line change
@@ -67,23 +67,25 @@ Provide the `typescript` option (and install [`@ava/typescript`](https://github.
6767

6868
## Using `ava.config.*` files
6969

70-
Rather than specifying the configuration in the `package.json` file you can use `ava.config.js` or `ava.config.cjs` files.
70+
Rather than specifying the configuration in the `package.json` file you can use `ava.config.js`, `ava.config.cjs` or `ava.config.mjs` files.
71+
72+
Note: AVA 3 recognizes `ava.config.mjs` files but refuses to load them. They work in AVA 4.
7173

7274
To use these files:
7375

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

78-
AVA 3 recognizes `ava.config.mjs` files but refuses to load them. This is changing in AVA 4, [see below](#next-generation-configuration).
80+
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.
7981

8082
### `ava.config.js`
8183

82-
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.
84+
AVA 4 follows Node.js' behavior, so if you've set `"type": "module"` you must use ESM, and otherwise you must use CommonJS.
8385

84-
This is changing in AVA 4, [see below](#next-generation-configuration).
86+
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.
8587

86-
The default export can either be a plain object or a factory function which returns a plain object:
88+
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:
8789

8890
```js
8991
export default {
@@ -115,13 +117,11 @@ export default ({projectDir}) => {
115117
};
116118
```
117119

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

122122
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.
123123

124-
The module export can either be a plain object or a factory function which returns a plain object:
124+
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:
125125

126126
```js
127127
module.exports = {
@@ -153,17 +153,49 @@ module.exports = ({projectDir}) => {
153153
};
154154
```
155155

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

158-
## Alternative configuration files
158+
Note that `ava.config.mjs` files are only supported in AVA 4.
159+
160+
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:
161+
162+
```js
163+
export default {
164+
require: ['./_my-test-helper']
165+
};
166+
```
167+
168+
```js
169+
export default function factory() {
170+
return {
171+
require: ['./_my-test-helper']
172+
};
173+
};
174+
```
175+
176+
The factory function is called with an object containing a `projectDir` property, which you could use to change the returned configuration:
177+
178+
```js
179+
export default ({projectDir}) => {
180+
if (projectDir === '/Users/username/projects/my-project') {
181+
return {
182+
// Config A
183+
};
184+
}
159185

160-
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.
186+
return {
187+
// Config B
188+
};
189+
};
190+
```
161191

162-
AVA 4 also supports `.mjs` extensions, [see below](#next-generation-configuration).
192+
## Alternative configuration files
193+
194+
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.
163195

164-
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.
196+
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.
165197

166-
The configuration file *must* be in the same directory as the `package.json` file.
198+
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.
167199

168200
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:
169201

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

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

191-
## Next generation configuration
192-
193-
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:
194-
195-
```js
196-
export default {
197-
nonSemVerExperiments: {
198-
nextGenConfig: true
199-
},
200-
files: ['unit-tests/**/*']
201-
};
202-
```
203-
204-
This also allows you to pass an `.mjs` file using the `--config` argument.
205-
206-
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.
207-
208-
You mustn't have an `ava.config.mjs` file next to an `ava.config.js` or `ava.config.cjs` file.
209-
210223
## Object printing depth
211224

212225
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:

lib/cli.js

+5-2
Original file line numberDiff line numberDiff line change
@@ -99,11 +99,14 @@ const FLAGS = {
9999
};
100100

101101
export default async function loadCli() { // eslint-disable-line complexity
102-
let conf = {};
103-
let confError = null;
102+
let conf;
103+
let confError;
104104
try {
105105
const {argv: {config: configFile}} = yargs(hideBin(process.argv)).help(false);
106106
conf = await loadConfig({configFile});
107+
if (conf.configFile && path.basename(conf.configFile) !== path.relative(conf.projectDir, conf.configFile)) {
108+
console.log(chalk.magenta(` ${figures.warning} Using configuration from ${conf.configFile}`));
109+
}
107110
} catch (error) {
108111
confError = error;
109112
}

lib/load-config.js

+49-43
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import fs from 'node:fs';
2-
import {createRequire} from 'node:module';
32
import path from 'node:path';
43
import process from 'node:process';
54
import url from 'node:url';
@@ -22,52 +21,22 @@ const importConfig = async ({configFile, fileForErrorMessage}) => {
2221
return config;
2322
};
2423

25-
const loadJsConfig = async ({projectDir, configFile = path.join(projectDir, 'ava.config.js')}) => {
26-
if (!configFile.endsWith('.js') || !fs.existsSync(configFile)) {
24+
const loadConfigFile = async ({projectDir, configFile}) => {
25+
if (!fs.existsSync(configFile)) {
2726
return null;
2827
}
2928

3029
const fileForErrorMessage = path.relative(projectDir, configFile);
3130
try {
32-
return {config: await importConfig({configFile, fileForErrorMessage}), fileForErrorMessage};
31+
return {config: await importConfig({configFile, fileForErrorMessage}), configFile, fileForErrorMessage};
3332
} catch (error) {
3433
throw Object.assign(new Error(`Error loading ${fileForErrorMessage}: ${error.message}`), {parent: error});
3534
}
3635
};
3736

38-
const loadCjsConfig = async ({projectDir, configFile = path.join(projectDir, 'ava.config.cjs')}) => {
39-
if (!configFile.endsWith('.cjs') || !fs.existsSync(configFile)) {
40-
return null;
41-
}
42-
43-
const fileForErrorMessage = path.relative(projectDir, configFile);
44-
try {
45-
const require = createRequire(import.meta.url);
46-
return {config: await require(configFile), fileForErrorMessage};
47-
} catch (error) {
48-
throw Object.assign(new Error(`Error loading ${fileForErrorMessage}`), {parent: error});
49-
}
50-
};
51-
52-
const loadMjsConfig = async ({projectDir, configFile = path.join(projectDir, 'ava.config.mjs')}) => {
53-
if (!configFile.endsWith('.mjs') || !fs.existsSync(configFile)) {
54-
return null;
55-
}
56-
57-
const fileForErrorMessage = path.relative(projectDir, configFile);
58-
try {
59-
return {config: await importConfig({configFile, fileForErrorMessage}), fileForErrorMessage};
60-
} catch (error) {
61-
throw Object.assign(new Error(`Error loading ${fileForErrorMessage}`), {parent: error});
62-
}
63-
};
64-
65-
function resolveConfigFile(projectDir, configFile) {
37+
function resolveConfigFile(configFile) {
6638
if (configFile) {
6739
configFile = path.resolve(configFile); // Relative to CWD
68-
if (path.basename(configFile) !== path.relative(projectDir, configFile)) {
69-
throw new Error('Config files must be located next to the package.json file');
70-
}
7140

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

49+
const gitScmFile = process.env.AVA_FAKE_SCM_ROOT || '.git';
50+
51+
async function findRepoRoot(fromDir) {
52+
const {root} = path.parse(fromDir);
53+
let dir = fromDir;
54+
while (root !== dir) {
55+
try {
56+
const stat = await fs.promises.stat(path.join(dir, gitScmFile)); // eslint-disable-line no-await-in-loop
57+
if (stat.isFile() || stat.isDirectory()) {
58+
return dir;
59+
}
60+
} catch {}
61+
62+
dir = path.dirname(dir);
63+
}
64+
65+
return root;
66+
}
67+
8068
export async function loadConfig({configFile, resolveFrom = process.cwd(), defaults = {}} = {}) {
8169
let packageConf = await packageConfig('ava', {cwd: resolveFrom});
8270
const filepath = packageJsonPath(packageConf);
8371
const projectDir = filepath === undefined ? resolveFrom : path.dirname(filepath);
8472

85-
configFile = resolveConfigFile(projectDir, configFile);
73+
const repoRoot = await findRepoRoot(projectDir);
74+
75+
// Conflicts are only allowed when an explicit config file is provided.
8676
const allowConflictWithPackageJson = Boolean(configFile);
77+
configFile = resolveConfigFile(configFile);
8778

88-
// TODO: Refactor resolution logic to implement https://github.com/avajs/ava/issues/2285.
89-
let [{config: fileConf, fileForErrorMessage} = {config: NO_SUCH_FILE, fileForErrorMessage: undefined}, ...conflicting] = (await Promise.all([
90-
loadJsConfig({projectDir, configFile}, true),
91-
loadCjsConfig({projectDir, configFile}),
92-
loadMjsConfig({projectDir, configFile}, true),
93-
])).filter(result => result !== null);
79+
let fileConf = NO_SUCH_FILE;
80+
let fileForErrorMessage;
81+
let conflicting = [];
82+
if (configFile) {
83+
const loaded = await loadConfigFile({projectDir, configFile});
84+
if (loaded !== null) {
85+
({config: fileConf, fileForErrorMessage} = loaded);
86+
}
87+
} else {
88+
let searchDir = projectDir;
89+
const stopAt = path.dirname(repoRoot);
90+
do {
91+
[{config: fileConf, fileForErrorMessage, configFile} = {config: NO_SUCH_FILE, fileForErrorMessage: undefined}, ...conflicting] = (await Promise.all([ // eslint-disable-line no-await-in-loop
92+
loadConfigFile({projectDir, configFile: path.join(searchDir, 'ava.config.js')}),
93+
loadConfigFile({projectDir, configFile: path.join(searchDir, 'ava.config.cjs')}),
94+
loadConfigFile({projectDir, configFile: path.join(searchDir, 'ava.config.mjs')}),
95+
])).filter(result => result !== null);
96+
97+
searchDir = path.dirname(searchDir);
98+
} while (fileConf === NO_SUCH_FILE && searchDir !== stopAt);
99+
}
94100

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

123-
const config = {...defaults, nonSemVerExperiments: {}, ...fileConf, ...packageConf, projectDir};
129+
const config = {...defaults, nonSemVerExperiments: {}, ...fileConf, ...packageConf, projectDir, configFile};
124130

125131
const {nonSemVerExperiments: experiments} = config;
126132
if (!isPlainObject(experiments)) {

test-tap/fixture/.fake-root

Whitespace-only changes.

test-tap/helper/cli.js

+6-2
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,12 @@ export function execCli(args, options, cb) {
2727
const processPromise = new Promise(resolve => {
2828
child = childProcess.spawn(process.execPath, [cliPath].concat(args), { // eslint-disable-line unicorn/prefer-spread
2929
cwd: dirname,
30-
env: {AVA_FORCE_CI: 'ci', ...env}, // Force CI to ensure the correct reporter is selected
31-
// env,
30+
env: {
31+
AVA_FORCE_CI: 'ci', // Force CI to ensure the correct reporter is selected
32+
AVA_FAKE_SCM_ROOT: '.fake-root', // This is an internal test flag.
33+
...env,
34+
},
35+
// Env,
3236
stdio: [null, 'pipe', 'pipe'],
3337
});
3438

test/.fake-root

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default {
2+
files: ['foo.js'],
3+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import test from 'ava';
2+
3+
test('foo', t => {
4+
t.pass();
5+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"type": "module"
3+
}

test/config/integration.js

+10
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,16 @@ test('resolves tests from an .mjs config file', async t => {
5353
t.snapshot(result.stats.passed, 'resolves test files from configuration');
5454
});
5555

56+
test('looks for config files outside of project directory', async t => {
57+
const options = {
58+
cwd: cwd('monorepo/package'),
59+
};
60+
61+
const result = await fixture([], options);
62+
63+
t.snapshot(result.stats.passed, 'resolves test files from configuration');
64+
});
65+
5666
test('use current working directory if `package.json` is not found', async t => {
5767
const cwd = tempy.directory();
5868
const testFilePath = path.join(cwd, 'test.js');

test/config/loader.js

+5-3
Original file line numberDiff line numberDiff line change
@@ -63,10 +63,12 @@ test.serial('explicit configFile option overrides package.json config', ok({
6363
t.is(conf.files, 'package-yes-explicit-yes-test-value');
6464
});
6565

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

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

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

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

test/config/snapshots/integration.js.md

+11
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,14 @@ Generated by [AVA](https://avajs.dev).
5959
title: 'test name',
6060
},
6161
]
62+
63+
## looks for config files outside of project directory
64+
65+
> resolves test files from configuration
66+
67+
[
68+
{
69+
file: 'foo.js',
70+
title: 'foo',
71+
},
72+
]
40 Bytes
Binary file not shown.

0 commit comments

Comments
 (0)