diff --git a/doc/api/test.md b/doc/api/test.md index b1a8e0030bd43fb..201017b870f754b 100644 --- a/doc/api/test.md +++ b/doc/api/test.md @@ -1239,6 +1239,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: v22.6.0 pr-url: https://github.com/nodejs/node/pull/53866 description: Added the `globPatterns` option. @@ -1263,6 +1266,9 @@ changes: parallel. If `false`, it would only run one test file at a time. **Default:** `false`. + * `cwd`: {string} Specifies the current working directory (cwd) 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 e13fda658842299..c227c9bc6e60a08 100644 --- a/lib/internal/test_runner/runner.js +++ b/lib/internal/test_runner/runner.js @@ -50,6 +50,7 @@ const { validateFunction, validateObject, validateInteger, + validateString, } = require('internal/validators'); const { getInspectPort, isUsingInspector, isInspectorMessage } = require('internal/util/inspector'); const { isRegExp } = require('internal/util/types'); @@ -87,8 +88,7 @@ const kCanceledTests = new SafeSet() let kResistStopPropagation; -function createTestFileList(patterns) { - const cwd = process.cwd(); +function createTestFileList(patterns, cwd) { const hasUserSuppliedPattern = patterns != null; if (!patterns || patterns.length === 0) { patterns = [kDefaultPattern]; @@ -110,7 +110,7 @@ function createTestFileList(patterns) { function filterExecArgv(arg, i, arr) { return !ArrayPrototypeIncludes(kFilterArgs, arg) && - !ArrayPrototypeSome(kFilterArgValues, (p) => arg === p || (i > 0 && arr[i - 1] === p) || StringPrototypeStartsWith(arg, `${p}=`)); + !ArrayPrototypeSome(kFilterArgValues, (p) => arg === p || (i > 0 && arr[i - 1] === p) || StringPrototypeStartsWith(arg, `${p}=`)); } function getRunArgs(path, { forceExit, inspectPort, testNamePatterns, testSkipPatterns, only }) { @@ -167,7 +167,7 @@ class FileTest extends Test { if (firstSpaceIndex === -1) return false; const secondSpaceIndex = StringPrototypeIndexOf(comment, ' ', firstSpaceIndex + 1); return secondSpaceIndex === -1 && - ArrayPrototypeIncludes(kDiagnosticsFilterArgs, StringPrototypeSlice(comment, 0, firstSpaceIndex)); + ArrayPrototypeIncludes(kDiagnosticsFilterArgs, StringPrototypeSlice(comment, 0, firstSpaceIndex)); } #handleReportItem(item) { const isTopLevel = item.data.nesting === 0; @@ -417,16 +417,19 @@ function watchFiles(testFiles, opts) { const watcherMode = opts.hasFiles ? 'filter' : 'all'; const watcher = new FilesWatcher({ __proto__: null, debounce: 200, mode: watcherMode, signal: opts.signal }); if (!opts.hasFiles) { - watcher.watchPath(process.cwd()); // TODO: https://github.com/nodejs/node/issues/53867 before closing this MR + watcher.watchPath(opts.watchedDir); } const filesWatcher = { __proto__: null, watcher, runningProcesses, runningSubtests }; opts.root.harness.watching = true; // Watch for changes in current filtered files watcher.on('changed', ({ owners, eventType }) => { if (!opts.hasFiles && (eventType === 'rename' || eventType === 'change')) { - const updatedTestFiles = createTestFileList(opts.globPatterns); + const updatedTestFiles = createTestFileList(opts.globPatterns, opts.watchedDir); const newFileName = ArrayPrototypeFind(updatedTestFiles, (x) => !ArrayPrototypeIncludes(testFiles, x)); + const previousFileName = ArrayPrototypeFind(testFiles, (x) => !ArrayPrototypeIncludes(updatedTestFiles, x)); + + testFiles = updatedTestFiles; // When file renamed (created / deleted) we need to update the watcher if (newFileName) { @@ -437,8 +440,6 @@ function watchFiles(testFiles, opts) { if (!newFileName && previousFileName) { return; // Avoid rerunning files when file deleted } - - testFiles = updatedTestFiles; } watcher.unfilterFilesOwnedBy(owners); @@ -491,6 +492,7 @@ function run(options = kEmptyObject) { setup, only, globPatterns, + cwd = process.cwd(), } = options; if (files != null) { @@ -515,6 +517,10 @@ function run(options = kEmptyObject) { validateArray(globPatterns, 'options.globPatterns'); } + if (cwd != null) { + 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\'', @@ -584,7 +590,7 @@ function run(options = kEmptyObject) { root.postRun(); return root.reporter; } - let testFiles = files ?? createTestFileList(globPatterns); + let testFiles = files ?? createTestFileList(globPatterns, cwd); if (shard) { testFiles = ArrayPrototypeFilter(testFiles, (_, index) => index % shard.total === shard.index - 1); @@ -604,6 +610,7 @@ function run(options = kEmptyObject) { globPatterns, only, forceExit, + watchedDir: cwd, }; if (watch) { filesWatcher = watchFiles(testFiles, opts); diff --git a/test/parallel/test-runner-run-watch.mjs b/test/parallel/test-runner-run-watch.mjs index 371e70c9beddfd1..17cc6d75b454e43 100644 --- a/test/parallel/test-runner-run-watch.mjs +++ b/test/parallel/test-runner-run-watch.mjs @@ -4,10 +4,10 @@ import { describe, it, beforeEach } from 'node:test'; import assert from 'node:assert'; import { spawn } from 'node:child_process'; import { once } from 'node:events'; -import { writeFileSync, renameSync, unlinkSync, existsSync } from 'node:fs'; +import { writeFileSync, renameSync, unlinkSync, existsSync, mkdtempSync } from 'node:fs'; import util from 'internal/util'; import tmpdir from '../common/tmpdir.js'; -import { join } from 'node:path'; +import { join, basename } from 'node:path'; if (common.isIBMi) common.skip('IBMi does not support `fs.watch()`'); @@ -222,4 +222,22 @@ describe('test runner watch mode', () => { it('should run new tests when a new file is created in the watched directory', async () => { await testWatch({ action: 'create', fileToCreate: 'new-test-file.test.js' }); }); + + it('should run new tests when a new file is created in a different cwd', async () => { + const newTestWithoutDep = ` + const test = require('node:test'); + test('test without dep has ran'); + `; + const differentCwd = mkdtempSync(`${tmpdir.path}/different-cwd`); + const testFileName = 'test-without-dep.js'; + const newTestFilePath = join(differentCwd, testFileName); + writeFileSync(newTestFilePath, newTestWithoutDep); + const differentCwdTmpPath = basename(differentCwd); + + await testWatch({ + action: 'create', + fileToCreate: `${differentCwdTmpPath}/new-test-file.test.js`, + cwd: differentCwd + }); + }); }); diff --git a/test/parallel/test-runner-run.mjs b/test/parallel/test-runner-run.mjs index 7a575da9c95275e..415e0f0343ebfb3 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), { diff --git a/test/parallel/test-runner-watch-mode.mjs b/test/parallel/test-runner-watch-mode.mjs index 4aa1251a482b475..bd62e22d6acecb1 100644 --- a/test/parallel/test-runner-watch-mode.mjs +++ b/test/parallel/test-runner-watch-mode.mjs @@ -4,9 +4,10 @@ import { describe, it, beforeEach } from 'node:test'; import { once } from 'node:events'; import assert from 'node:assert'; import { spawn } from 'node:child_process'; -import { writeFileSync, renameSync, unlinkSync, existsSync } from 'node:fs'; +import { writeFileSync, renameSync, unlinkSync, existsSync, mkdtempSync } from 'node:fs'; import util from 'internal/util'; import tmpdir from '../common/tmpdir.js'; +import { join, basename } from 'node:path'; if (common.isIBMi) common.skip('IBMi does not support `fs.watch()`'); @@ -41,13 +42,14 @@ async function testWatch({ fileToUpdate, file, action = 'update', - fileToCreate + fileToCreate, + cwd = tmpdir.path }) { const ran1 = util.createDeferredPromise(); const ran2 = util.createDeferredPromise(); const child = spawn(process.execPath, ['--watch', '--test', file ? fixturePaths[file] : undefined].filter(Boolean), - { encoding: 'utf8', stdio: 'pipe', cwd: tmpdir.path } + { encoding: 'utf8', stdio: 'pipe', cwd } ); let stdout = ''; let currentRun = ''; @@ -191,4 +193,22 @@ describe('test runner watch mode', () => { it('should run new tests when a new file is created in the watched directory', async () => { await testWatch({ action: 'create', fileToCreate: 'new-test-file.test.js' }); }); + + it('should run new tests when a new file is created in a different cwd', async () => { + const newTestWithoutDep = ` + const test = require('node:test'); + test('test without dep has ran'); + `; + const differentCwd = mkdtempSync(`${tmpdir.path}/different-cwd`); + const testFileName = 'test-without-dep.js'; + const newTestFilePath = join(differentCwd, testFileName); + writeFileSync(newTestFilePath, newTestWithoutDep); + const differentCwdTmpPath = basename(differentCwd); + + await testWatch({ + action: 'create', + fileToCreate: `${differentCwdTmpPath}/new-test-file.test.js`, + cwd: differentCwd + }); + }); });