diff --git a/.xo-config.cjs b/.xo-config.cjs index 3ef0fe052..717d451c3 100644 --- a/.xo-config.cjs +++ b/.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/**', diff --git a/ava.config.cjs b/ava.config.cjs index 4055bdc09..63ed49fd3 100644 --- a/ava.config.cjs +++ b/ava.config.cjs @@ -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. + }, }; diff --git a/docs/06-configuration.md b/docs/06-configuration.md index 8c711daa8..eae555d07 100644 --- a/docs/06-configuration.md +++ b/docs/06-configuration.md @@ -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 { @@ -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 = { @@ -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: @@ -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: diff --git a/lib/cli.js b/lib/cli.js index 3d5629721..ec3dd8837 100644 --- a/lib/cli.js +++ b/lib/cli.js @@ -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; } diff --git a/lib/load-config.js b/lib/load-config.js index e2bec99b8..2ed77119a 100644 --- a/lib/load-config.js +++ b/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'; @@ -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'); @@ -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(' & ')}`); @@ -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)) { diff --git a/test-tap/fixture/.fake-root b/test-tap/fixture/.fake-root new file mode 100644 index 000000000..e69de29bb diff --git a/test-tap/helper/cli.js b/test-tap/helper/cli.js index 1e9358e2f..06f0c8f7e 100644 --- a/test-tap/helper/cli.js +++ b/test-tap/helper/cli.js @@ -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'], }); diff --git a/test/.fake-root b/test/.fake-root new file mode 100644 index 000000000..e69de29bb diff --git a/test/config/fixtures/monorepo/ava.config.mjs b/test/config/fixtures/monorepo/ava.config.mjs new file mode 100644 index 000000000..46df8cd72 --- /dev/null +++ b/test/config/fixtures/monorepo/ava.config.mjs @@ -0,0 +1,3 @@ +export default { + files: ['foo.js'], +}; diff --git a/test/config/fixtures/monorepo/package/foo.js b/test/config/fixtures/monorepo/package/foo.js new file mode 100644 index 000000000..c135f4fa8 --- /dev/null +++ b/test/config/fixtures/monorepo/package/foo.js @@ -0,0 +1,5 @@ +import test from 'ava'; + +test('foo', t => { + t.pass(); +}); diff --git a/test/config/fixtures/monorepo/package/package.json b/test/config/fixtures/monorepo/package/package.json new file mode 100644 index 000000000..bedb411a9 --- /dev/null +++ b/test/config/fixtures/monorepo/package/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/test/config/integration.js b/test/config/integration.js index 05865c4c2..0e97f96cb 100644 --- a/test/config/integration.js +++ b/test/config/integration.js @@ -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'); diff --git a/test/config/loader.js b/test/config/loader.js index 2c7745472..abae57011 100644 --- a/test/config/loader.js +++ b/test/config/loader.js @@ -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', @@ -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)); }); diff --git a/test/config/snapshots/integration.js.md b/test/config/snapshots/integration.js.md index 7ee552098..be719a126 100644 --- a/test/config/snapshots/integration.js.md +++ b/test/config/snapshots/integration.js.md @@ -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', + }, + ] diff --git a/test/config/snapshots/integration.js.snap b/test/config/snapshots/integration.js.snap index 8bc49cba6..5eaf95774 100644 Binary files a/test/config/snapshots/integration.js.snap and b/test/config/snapshots/integration.js.snap differ diff --git a/test/config/snapshots/next-gen.js.md b/test/config/snapshots/next-gen.js.md index 2c58b6f1a..95b2c37fb 100644 --- a/test/config/snapshots/next-gen.js.md +++ b/test/config/snapshots/next-gen.js.md @@ -8,13 +8,13 @@ Generated by [AVA](https://avajs.dev). > error message - 'Error loading error.mjs' + 'Error loading error.mjs: 🙈' ## fails when .mjs config does not have a default export > error message - 'Error loading no-default-export.mjs' + 'Error loading no-default-export.mjs: no-default-export.mjs must have a default export' ## handles errors when loading .js config as ESM diff --git a/test/config/snapshots/next-gen.js.snap b/test/config/snapshots/next-gen.js.snap index 90b32567b..640cb470b 100644 Binary files a/test/config/snapshots/next-gen.js.snap and b/test/config/snapshots/next-gen.js.snap differ