diff --git a/doc/api/test.md b/doc/api/test.md index a7af114880ea87..c68274d810e831 100644 --- a/doc/api/test.md +++ b/doc/api/test.md @@ -1246,6 +1246,9 @@ added: - v18.9.0 - v16.19.0 changes: + - version: REPLACEME + pr-url: https://github.com/nodejs/node/pull/54225 + description: Added the `cwd` option. - version: REPLACEME pr-url: https://github.com/nodejs/node/pull/53927 description: Added the `isolation` option. @@ -1273,6 +1276,9 @@ changes: parallel. If `false`, it would only run one test file at a time. **Default:** `false`. + * `cwd`: {string} Specifies the current working directory to be used by the test runner. + The cwd serves as the base path for resolving files according to the [test runner execution model][]. + **Default:** `process.cwd()`. * `files`: {Array} An array containing the list of files to run. **Default** matching files from [test runner execution model][]. * `forceExit`: {boolean} Configures the test runner to exit the process once diff --git a/lib/internal/test_runner/runner.js b/lib/internal/test_runner/runner.js index 9590ef8dcf75bf..246cfdf4ee1baf 100644 --- a/lib/internal/test_runner/runner.js +++ b/lib/internal/test_runner/runner.js @@ -54,6 +54,7 @@ const { validateObject, validateOneOf, validateInteger, + validateString, } = require('internal/validators'); const { getInspectPort, isUsingInspector, isInspectorMessage } = require('internal/util/inspector'); const { isRegExp } = require('internal/util/types'); @@ -536,6 +537,7 @@ function run(options = kEmptyObject) { setup, only, globPatterns, + cwd = process.cwd(), } = options; if (files != null) { @@ -560,6 +562,8 @@ function run(options = kEmptyObject) { validateArray(globPatterns, 'options.globPatterns'); } + validateString(cwd, 'options.cwd'); + if (globPatterns?.length > 0 && files?.length > 0) { throw new ERR_INVALID_ARG_VALUE( 'options.globPatterns', globPatterns, 'is not supported when specifying \'options.files\'', @@ -625,9 +629,6 @@ function run(options = kEmptyObject) { }; const root = createTestTree(rootTestOptions, globalOptions); - // This const should be replaced by a run option in the future. - const cwd = process.cwd(); - let testFiles = files ?? createTestFileList(globPatterns, cwd); if (shard) { @@ -698,7 +699,7 @@ function run(options = kEmptyObject) { root.harness.bootstrapPromise = promise; for (let i = 0; i < testFiles.length; ++i) { - const testFile = testFiles[i]; + const testFile = resolve(cwd, testFiles[i]); const fileURL = pathToFileURL(testFile); const parent = i === 0 ? undefined : parentURL; let threw = false; diff --git a/test/parallel/test-runner-no-isolation-different-cwd-watch.mjs b/test/parallel/test-runner-no-isolation-different-cwd-watch.mjs new file mode 100644 index 00000000000000..232b9af8286184 --- /dev/null +++ b/test/parallel/test-runner-no-isolation-different-cwd-watch.mjs @@ -0,0 +1,57 @@ +import { allowGlobals, mustCall, mustNotCall } from '../common/index.mjs'; +import * as fixtures from '../common/fixtures.mjs'; +import { deepStrictEqual } from 'node:assert'; +import { run } from 'node:test'; + +try { + const controller = new AbortController(); + const stream = run({ + cwd: fixtures.path('test-runner', 'no-isolation'), + isolation: 'none', + watch: true, + signal: controller.signal, + }); + + stream.on('test:fail', () => mustNotCall()); + stream.on('test:pass', mustCall(5)); + stream.on('data', function({ type }) { + if (type === 'test:watch:drained') { + controller.abort(); + } + }); + // eslint-disable-next-line no-unused-vars + for await (const _ of stream); + allowGlobals(globalThis.GLOBAL_ORDER); + deepStrictEqual(globalThis.GLOBAL_ORDER, [ + 'before one: ', + 'suite one', + 'before two: ', + 'suite two', + + 'beforeEach one: suite one - test', + 'beforeEach two: suite one - test', + 'suite one - test', + 'afterEach one: suite one - test', + 'afterEach two: suite one - test', + + 'beforeEach one: test one', + 'beforeEach two: test one', + 'test one', + 'afterEach one: test one', + 'afterEach two: test one', + + 'before suite two: suite two', + + 'beforeEach one: suite two - test', + 'beforeEach two: suite two - test', + 'suite two - test', + 'afterEach one: suite two - test', + 'afterEach two: suite two - test', + + 'after one: ', + 'after two: ', + ]); +} catch (err) { + console.error(err); + process.exit(1); +} diff --git a/test/parallel/test-runner-no-isolation-different-cwd.mjs b/test/parallel/test-runner-no-isolation-different-cwd.mjs new file mode 100644 index 00000000000000..6a15cc4f2b6b32 --- /dev/null +++ b/test/parallel/test-runner-no-isolation-different-cwd.mjs @@ -0,0 +1,48 @@ +import { allowGlobals, mustCall } from '../common/index.mjs'; +import * as fixtures from '../common/fixtures.mjs'; +import { strictEqual } from 'node:assert'; +import { run } from 'node:test'; + +const stream = run({ + cwd: fixtures.path('test-runner', 'no-isolation'), + isolation: 'none', +}); + +let errors = 0; +stream.on('test:fail', () => { + errors++; +}); +stream.on('test:pass', mustCall(5)); +// eslint-disable-next-line no-unused-vars +for await (const _ of stream); +strictEqual(errors, 0); +allowGlobals(globalThis.GLOBAL_ORDER); +strictEqual(globalThis.GLOBAL_ORDER, [ + 'before one: ', + 'suite one', + 'before two: ', + 'suite two', + + 'beforeEach one: suite one - test', + 'beforeEach two: suite one - test', + 'suite one - test', + 'afterEach one: suite one - test', + 'afterEach two: suite one - test', + + 'beforeEach one: test one', + 'beforeEach two: test one', + 'test one', + 'afterEach one: test one', + 'afterEach two: test one', + + 'before suite two: suite two', + + 'beforeEach one: suite two - test', + 'beforeEach two: suite two - test', + 'suite two - test', + 'afterEach one: suite two - test', + 'afterEach two: suite two - test', + + 'after one: ', + 'after two: ', +]); diff --git a/test/parallel/test-runner-run.mjs b/test/parallel/test-runner-run.mjs index 7a575da9c95275..355bbaee13fcd5 100644 --- a/test/parallel/test-runner-run.mjs +++ b/test/parallel/test-runner-run.mjs @@ -481,6 +481,13 @@ describe('require(\'node:test\').run', { concurrency: true }, () => { }); }); + it('should only allow a string in options.cwd', async () => { + [Symbol(), {}, [], () => {}, 0, 1, 0n, 1n, true, false] + .forEach((cwd) => assert.throws(() => run({ cwd }), { + code: 'ERR_INVALID_ARG_TYPE' + })); + }); + it('should only allow object as options', () => { [Symbol(), [], () => {}, 0, 1, 0n, 1n, '', '1', true, false] .forEach((options) => assert.throws(() => run(options), { @@ -513,6 +520,33 @@ describe('require(\'node:test\').run', { concurrency: true }, () => { for await (const _ of stream); assert.match(stderr, /Warning: node:test run\(\) is being called recursively/); }); + + it('should run with different cwd', async () => { + const stream = run({ + cwd: fixtures.path('test-runner', 'cwd') + }); + stream.on('test:fail', common.mustNotCall()); + stream.on('test:pass', common.mustCall(1)); + + // eslint-disable-next-line no-unused-vars + for await (const _ of stream); + }); + + it('should run with different cwd while in watch mode', async () => { + const controller = new AbortController(); + const stream = run({ + cwd: fixtures.path('test-runner', 'cwd'), + watch: true, + signal: controller.signal, + }).on('data', function({ type }) { + if (type === 'test:watch:drained') { + controller.abort(); + } + }); + + stream.on('test:fail', common.mustNotCall()); + stream.on('test:pass', common.mustCall(1)); + }); }); describe('forceExit', () => {