diff --git a/docs/06-configuration.md b/docs/06-configuration.md index dc92f54c7..a7b3fe457 100644 --- a/docs/06-configuration.md +++ b/docs/06-configuration.md @@ -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). @@ -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'] }; }; ``` @@ -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'] }; }; ``` @@ -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'] }; }; ``` @@ -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. diff --git a/lib/api.js b/lib/api.js index eaf223650..ace789d08 100644 --- a/lib/api.js +++ b/lib/api.js @@ -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'; @@ -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; }); } @@ -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 = () => {}; diff --git a/lib/fork.js b/lib/fork.js index 7630baa39..d551ef84c 100644 --- a/lib/fork.js +++ b/lib/fork.js @@ -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(); diff --git a/lib/worker/base.js b/lib/worker/base.js index cdd3c4a1a..63c8a7db5 100644 --- a/lib/worker/base.js +++ b/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'; @@ -174,9 +177,56 @@ const run = async options => { 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}); + } + } + + // 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; + } + }; + + 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 diff --git a/test-tap/api.js b/test-tap/api.js index b395d81f8..0697b2257 100644 --- a/test-tap/api.js +++ b/test-tap/api.js @@ -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}); diff --git a/test-tap/fixture/install-global.cjs b/test-tap/fixture/install-global.cjs deleted file mode 100644 index 22de4db13..000000000 --- a/test-tap/fixture/install-global.cjs +++ /dev/null @@ -1,2 +0,0 @@ -'use strict'; -global.foo = 'bar'; diff --git a/test-tap/fixture/validate-installed-global.cjs b/test-tap/fixture/validate-installed-global.cjs deleted file mode 100644 index f1cab146a..000000000 --- a/test-tap/fixture/validate-installed-global.cjs +++ /dev/null @@ -1,3 +0,0 @@ -const test = require('../../entrypoints/main.cjs'); - -test('test', t => t.is(global.foo, 'bar')); diff --git a/test/config-require/fixtures/exports-default/package.json b/test/config-require/fixtures/exports-default/package.json new file mode 100644 index 000000000..62b13ac45 --- /dev/null +++ b/test/config-require/fixtures/exports-default/package.json @@ -0,0 +1,6 @@ +{ + "type": "module", + "ava": { + "require": "./required.cjs" + } +} diff --git a/test/config-require/fixtures/exports-default/required.cjs b/test/config-require/fixtures/exports-default/required.cjs new file mode 100644 index 000000000..8ceb435bf --- /dev/null +++ b/test/config-require/fixtures/exports-default/required.cjs @@ -0,0 +1,5 @@ +exports.called = false; + +exports.default = function () { + exports.called = true; +}; diff --git a/test/config-require/fixtures/exports-default/test.js b/test/config-require/fixtures/exports-default/test.js new file mode 100644 index 000000000..3ea5bc6b9 --- /dev/null +++ b/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); +}); diff --git a/test/config-require/fixtures/failed-import/package.json b/test/config-require/fixtures/failed-import/package.json new file mode 100644 index 000000000..c79b571ec --- /dev/null +++ b/test/config-require/fixtures/failed-import/package.json @@ -0,0 +1,6 @@ +{ + "type": "module", + "ava": { + "require": "@babel/register" + } +} diff --git a/test/config-require/fixtures/failed-import/test.js b/test/config-require/fixtures/failed-import/test.js new file mode 100644 index 000000000..4be1a9985 --- /dev/null +++ b/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(); +}); diff --git a/test/config-require/fixtures/non-json/ava.config.js b/test/config-require/fixtures/non-json/ava.config.js new file mode 100644 index 000000000..34aa3ff57 --- /dev/null +++ b/test/config-require/fixtures/non-json/ava.config.js @@ -0,0 +1,5 @@ +export default { + require: [ + ['./required.mjs', new Map([['hello', 'world']])], + ], +}; diff --git a/test/config-require/fixtures/non-json/package.json b/test/config-require/fixtures/non-json/package.json new file mode 100644 index 000000000..bedb411a9 --- /dev/null +++ b/test/config-require/fixtures/non-json/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/test/config-require/fixtures/non-json/required.mjs b/test/config-require/fixtures/non-json/required.mjs new file mode 100644 index 000000000..0a9e1fb51 --- /dev/null +++ b/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; +} diff --git a/test/config-require/fixtures/non-json/test.js b/test/config-require/fixtures/non-json/test.js new file mode 100644 index 000000000..c2607362e --- /dev/null +++ b/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']])]); +}); diff --git a/test/config-require/fixtures/require-dependency/.gitignore b/test/config-require/fixtures/require-dependency/.gitignore new file mode 100644 index 000000000..4bae8d8f6 --- /dev/null +++ b/test/config-require/fixtures/require-dependency/.gitignore @@ -0,0 +1 @@ +!node_modules/@ava diff --git a/test/config-require/fixtures/require-dependency/node_modules/@ava/stub/index.js b/test/config-require/fixtures/require-dependency/node_modules/@ava/stub/index.js new file mode 100644 index 000000000..d363e318d --- /dev/null +++ b/test/config-require/fixtures/require-dependency/node_modules/@ava/stub/index.js @@ -0,0 +1,5 @@ +export let required = false; // eslint-disable-line import/no-mutable-exports + +export default function () { + required = true; +} diff --git a/test/config-require/fixtures/require-dependency/node_modules/@ava/stub/package.json b/test/config-require/fixtures/require-dependency/node_modules/@ava/stub/package.json new file mode 100644 index 000000000..c9094cfa8 --- /dev/null +++ b/test/config-require/fixtures/require-dependency/node_modules/@ava/stub/package.json @@ -0,0 +1,4 @@ +{ + "name": "@ava/stub", + "type": "module" +} diff --git a/test/config-require/fixtures/require-dependency/package.json b/test/config-require/fixtures/require-dependency/package.json new file mode 100644 index 000000000..cf1e31bb7 --- /dev/null +++ b/test/config-require/fixtures/require-dependency/package.json @@ -0,0 +1,8 @@ +{ + "type": "module", + "ava": { + "require": [ + "@ava/stub" + ] + } +} diff --git a/test/config-require/fixtures/require-dependency/test.js b/test/config-require/fixtures/require-dependency/test.js new file mode 100644 index 000000000..8f82ee74d --- /dev/null +++ b/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); +}); diff --git a/test/config-require/fixtures/single-argument/package.json b/test/config-require/fixtures/single-argument/package.json new file mode 100644 index 000000000..4f81b98d1 --- /dev/null +++ b/test/config-require/fixtures/single-argument/package.json @@ -0,0 +1,6 @@ +{ + "type": "module", + "ava": { + "require": "./required.js" + } +} diff --git a/test/config-require/fixtures/single-argument/required.js b/test/config-require/fixtures/single-argument/required.js new file mode 100644 index 000000000..d363e318d --- /dev/null +++ b/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; +} diff --git a/test/config-require/fixtures/single-argument/test.js b/test/config-require/fixtures/single-argument/test.js new file mode 100644 index 000000000..b12c733dd --- /dev/null +++ b/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); +}); diff --git a/test/config-require/fixtures/with-arguments/ava.config.js b/test/config-require/fixtures/with-arguments/ava.config.js new file mode 100644 index 000000000..b497f6dfd --- /dev/null +++ b/test/config-require/fixtures/with-arguments/ava.config.js @@ -0,0 +1,7 @@ +export default { + require: [ + ['./required.mjs', 'hello', 'world'], + ['./required.cjs', 'goodbye'], + './side-effect.js', + ], +}; diff --git a/test/config-require/fixtures/with-arguments/package.json b/test/config-require/fixtures/with-arguments/package.json new file mode 100644 index 000000000..bedb411a9 --- /dev/null +++ b/test/config-require/fixtures/with-arguments/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/test/config-require/fixtures/with-arguments/required.cjs b/test/config-require/fixtures/with-arguments/required.cjs new file mode 100644 index 000000000..ad5c055e6 --- /dev/null +++ b/test/config-require/fixtures/with-arguments/required.cjs @@ -0,0 +1,7 @@ +function init(...args) { + init.receivedArgs = args; +} + +init.receivedArgs = null; + +module.exports = init; diff --git a/test/config-require/fixtures/with-arguments/required.mjs b/test/config-require/fixtures/with-arguments/required.mjs new file mode 100644 index 000000000..0a9e1fb51 --- /dev/null +++ b/test/config-require/fixtures/with-arguments/required.mjs @@ -0,0 +1,5 @@ +export let receivedArgs = null; // eslint-disable-line import/no-mutable-exports + +export default function (...args) { + receivedArgs = args; +} diff --git a/test/config-require/fixtures/with-arguments/side-effect.js b/test/config-require/fixtures/with-arguments/side-effect.js new file mode 100644 index 000000000..549233956 --- /dev/null +++ b/test/config-require/fixtures/with-arguments/side-effect.js @@ -0,0 +1 @@ +export default Date.now(); diff --git a/test/config-require/fixtures/with-arguments/test.js b/test/config-require/fixtures/with-arguments/test.js new file mode 100644 index 000000000..8c1e1b43f --- /dev/null +++ b/test/config-require/fixtures/with-arguments/test.js @@ -0,0 +1,15 @@ +import test from 'ava'; + +import cjs from './required.cjs'; +import {receivedArgs} from './required.mjs'; + +test('receives arguments from config', t => { + t.deepEqual(receivedArgs, ['hello', 'world']); + t.deepEqual(cjs.receivedArgs, ['goodbye']); +}); + +test('side-effects are execute when tests loaded, before test code', async t => { + const now = Date.now(); + const sideEffect = await import('./side-effect.js'); + t.true(sideEffect.default < now); +}); diff --git a/test/config-require/test.js b/test/config-require/test.js new file mode 100644 index 000000000..f67844cdf --- /dev/null +++ b/test/config-require/test.js @@ -0,0 +1,38 @@ +import test from '@ava/test'; + +import {cwd, fixture} from '../helpers/exec.js'; + +test('loads required modules with arguments', async t => { + const result = await fixture([], {cwd: cwd('with-arguments')}); + t.is(result.stats.passed.length, 2); +}); + +test('non-JSON arguments can be provided (worker threads)', async t => { + const result = await fixture([], {cwd: cwd('non-json')}); + t.is(result.stats.passed.length, 1); +}); + +test('non-JSON arguments can be provided (child process)', async t => { + const result = await fixture(['--no-worker-threads'], {cwd: cwd('non-json')}); + t.is(result.stats.passed.length, 1); +}); + +test('loads required modules, not as an array', async t => { + const result = await fixture([], {cwd: cwd('single-argument')}); + t.is(result.stats.passed.length, 1); +}); + +test('calls exports.default (CJS)', async t => { + const result = await fixture([], {cwd: cwd('exports-default')}); + t.is(result.stats.passed.length, 1); +}); + +test('loads dependencies', async t => { + const result = await fixture([], {cwd: cwd('require-dependency')}); + t.is(result.stats.passed.length, 1); +}); + +test('crashes if module cannot be loaded', async t => { + const result = await t.throwsAsync(fixture([], {cwd: cwd('failed-import')})); + t.is(result.stats.uncaughtExceptions.length, 1); +});