diff --git a/doc/api/test.md b/doc/api/test.md index e02eba7f182bf7..5527d7fac5d0c2 100644 --- a/doc/api/test.md +++ b/doc/api/test.md @@ -1256,6 +1256,9 @@ added: - v18.9.0 - v16.19.0 changes: + - version: REPLACEME + pr-url: https://github.com/nodejs/node/pull/54705 + description: Added the `cwd` option. - version: REPLACEME pr-url: https://github.com/nodejs/node/pull/53937 description: Added coverage options. @@ -1286,6 +1289,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. + 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/coverage.js b/lib/internal/test_runner/coverage.js index 1b5ba7912dcb7d..e7997f4f646aa3 100644 --- a/lib/internal/test_runner/coverage.js +++ b/lib/internal/test_runner/coverage.js @@ -487,7 +487,6 @@ function sortCoverageFiles(a, b) { function setupCoverage(options) { let originalCoverageDirectory = process.env.NODE_V8_COVERAGE; - const cwd = process.cwd(); if (originalCoverageDirectory) { // NODE_V8_COVERAGE was already specified. Convert it to an absolute path @@ -495,7 +494,7 @@ function setupCoverage(options) { // so that no preexisting coverage files interfere with the results of the // coverage report. Then, once the coverage is computed, move the coverage // files back to the original NODE_V8_COVERAGE directory. - originalCoverageDirectory = resolve(cwd, originalCoverageDirectory); + originalCoverageDirectory = resolve(options.cwd, originalCoverageDirectory); } const coverageDirectory = mkdtempSync(join(tmpdir(), 'node-coverage-')); @@ -512,7 +511,7 @@ function setupCoverage(options) { return new TestCoverage( coverageDirectory, originalCoverageDirectory, - cwd, + options.cwd, options.coverageExcludeGlobs, options.coverageIncludeGlobs, { diff --git a/lib/internal/test_runner/harness.js b/lib/internal/test_runner/harness.js index 718e15cf024745..9109495fef03f3 100644 --- a/lib/internal/test_runner/harness.js +++ b/lib/internal/test_runner/harness.js @@ -116,7 +116,7 @@ function createProcessEventHandler(eventName, rootTest) { const name = test.hookType ? `Test hook "${test.hookType}"` : `Test "${test.name}"`; let locInfo = ''; if (test.loc) { - const relPath = relative(process.cwd(), test.loc.file); + const relPath = relative(rootTest.config.cwd, test.loc.file); locInfo = ` at ${relPath}:${test.loc.line}:${test.loc.column}`; } @@ -260,6 +260,7 @@ function lazyBootstrapRoot() { loc: entryFile ? [1, 1, entryFile] : undefined, }; const globalOptions = parseCommandLine(); + globalOptions.cwd = process.cwd(); createTestTree(rootTestOptions, globalOptions); globalRoot.reporter.on('test:summary', (data) => { if (!data.success) { diff --git a/lib/internal/test_runner/runner.js b/lib/internal/test_runner/runner.js index 2241df6b378253..18940ffdbe8b58 100644 --- a/lib/internal/test_runner/runner.js +++ b/lib/internal/test_runner/runner.js @@ -33,7 +33,7 @@ const { const { spawn } = require('child_process'); const { finished } = require('internal/streams/end-of-stream'); -const { resolve } = require('path'); +const { resolve, sep, isAbsolute } = require('path'); const { DefaultDeserializer, DefaultSerializer } = require('v8'); const { getOptionValue } = require('internal/options'); const { Interface } = require('internal/readline/interface'); @@ -56,6 +56,7 @@ const { validateObject, validateOneOf, validateInteger, + validateString, validateStringArray, } = require('internal/validators'); const { getInspectPort, isUsingInspector, isInspectorMessage } = require('internal/util/inspector'); @@ -63,7 +64,6 @@ const { isRegExp } = require('internal/util/types'); const { pathToFileURL } = require('internal/url'); const { createDeferredPromise, - getCWDURL, kEmptyObject, } = require('internal/util'); const { kEmitMessage } = require('internal/test_runner/tests_stream'); @@ -137,7 +137,8 @@ function getRunArgs(path, { forceExit, testSkipPatterns, only, argv: suppliedArgs, - execArgv }) { + execArgv, + cwd }) { const argv = ArrayPrototypeFilter(process.execArgv, filterExecArgv); if (forceExit === true) { ArrayPrototypePush(argv, '--test-force-exit'); @@ -494,7 +495,8 @@ function watchFiles(testFiles, opts) { // When file renamed (created / deleted) we need to update the watcher if (newFileName) { owners = new SafeSet().add(newFileName); - watcher.filterFile(resolve(newFileName), owners); + const resolveFileName = isAbsolute(newFileName) ? newFileName : resolve(opts.cwd, newFileName); + watcher.filterFile(resolveFileName, owners); } if (!newFileName && previousFileName) { @@ -562,6 +564,7 @@ function run(options = kEmptyObject) { functionCoverage = 0, execArgv = [], argv = [], + cwd = process.cwd(), } = options; if (files != null) { @@ -586,6 +589,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\'', @@ -673,12 +678,9 @@ function run(options = kEmptyObject) { lineCoverage: lineCoverage, branchCoverage: branchCoverage, functionCoverage: functionCoverage, + cwd, }; 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) { @@ -731,7 +733,8 @@ function run(options = kEmptyObject) { }; } else if (isolation === 'none') { if (watch) { - filesWatcher = watchFiles(testFiles, opts); + const absoluteTestFiles = ArrayPrototypeMap(testFiles, (file) => (isAbsolute(file) ? file : resolve(cwd, file))); + filesWatcher = watchFiles(absoluteTestFiles, opts); runFiles = async () => { root.harness.bootstrapPromise = null; root.harness.buildPromise = null; @@ -744,7 +747,7 @@ function run(options = kEmptyObject) { const { promise, resolve: finishBootstrap } = createDeferredPromise(); await root.runInAsyncScope(async () => { - const parentURL = getCWDURL().href; + const parentURL = pathToFileURL(cwd + sep).href; const cascadedLoader = esmLoader.getOrInitializeCascadedLoader(); let topLevelTestCount = 0; @@ -757,7 +760,7 @@ function run(options = kEmptyObject) { for (let i = 0; i < testFiles.length; ++i) { const testFile = testFiles[i]; - const fileURL = pathToFileURL(testFile); + const fileURL = pathToFileURL(resolve(cwd, testFile)); const parent = i === 0 ? undefined : parentURL; let threw = false; let importError; diff --git a/test/fixtures/test-runner-watch.mjs b/test/fixtures/test-runner-watch.mjs index 6780b31c541690..72b0a111d46723 100644 --- a/test/fixtures/test-runner-watch.mjs +++ b/test/fixtures/test-runner-watch.mjs @@ -6,6 +6,12 @@ const options = { file: { type: 'string', }, + cwd: { + type: 'string', + }, + isolation: { + type: 'string', + }, }; const { values, @@ -13,12 +19,24 @@ const { } = parseArgs({ args: process.argv.slice(2), options }); let files; +let cwd; +let isolation; if (values.file) { files = [values.file]; } +if (values.cwd) { + cwd = values.cwd; +} + +if (values.isolation) { + isolation = values.isolation; +} + run({ files, - watch: true + watch: true, + cwd, + isolation, }).compose(tap).pipe(process.stdout); 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..bdddd368d19f12 --- /dev/null +++ b/test/parallel/test-runner-no-isolation-different-cwd.mjs @@ -0,0 +1,34 @@ +import { allowGlobals, mustCall } from '../common/index.mjs'; +import * as fixtures from '../common/fixtures.mjs'; +import { deepStrictEqual } from 'node:assert'; +import { run } from 'node:test'; + +const stream = run({ + cwd: fixtures.path('test-runner', 'no-isolation'), + isolation: 'none', +}); + + +stream.on('test:pass', mustCall(4)); +// 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', + '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-watch.mjs b/test/parallel/test-runner-run-watch.mjs index 1bc4e95aa90e3d..fca8b492bc3ad0 100644 --- a/test/parallel/test-runner-run-watch.mjs +++ b/test/parallel/test-runner-run-watch.mjs @@ -1,6 +1,6 @@ // Flags: --expose-internals import * as common from '../common/index.mjs'; -import { describe, it, beforeEach } from 'node:test'; +import { describe, it, beforeEach, run } from 'node:test'; import assert from 'node:assert'; import { spawn } from 'node:child_process'; import { once } from 'node:events'; @@ -41,11 +41,23 @@ function refresh() { const runner = join(import.meta.dirname, '..', 'fixtures', 'test-runner-watch.mjs'); -async function testWatch({ fileToUpdate, file, action = 'update', cwd = tmpdir.path, fileToCreate }) { +async function testWatch( + { + fileToUpdate, + file, + action = 'update', + cwd = tmpdir.path, + fileToCreate, + runnerCwd, + isolation + } +) { const ran1 = util.createDeferredPromise(); const ran2 = util.createDeferredPromise(); const args = [runner]; if (file) args.push('--file', file); + if (runnerCwd) args.push('--cwd', runnerCwd); + if (isolation) args.push('--isolation', isolation); const child = spawn(process.execPath, args, { encoding: 'utf8', stdio: 'pipe', cwd }); @@ -91,10 +103,12 @@ async function testWatch({ fileToUpdate, file, action = 'update', cwd = tmpdir.p currentRun = ''; const fileToRenamePath = tmpdir.resolve(fileToUpdate); const newFileNamePath = tmpdir.resolve(`test-renamed-${fileToUpdate}`); - const interval = setInterval(() => renameSync(fileToRenamePath, newFileNamePath), common.platformTimeout(1000)); + const interval = setInterval(() => { + renameSync(fileToRenamePath, newFileNamePath); + clearInterval(interval); + }, common.platformTimeout(1000)); await ran2.promise; runs.push(currentRun); - clearInterval(interval); child.kill(); await once(child, 'exit'); @@ -129,11 +143,11 @@ async function testWatch({ fileToUpdate, file, action = 'update', cwd = tmpdir.p unlinkSync(fileToDeletePath); } else { ran2.resolve(); + clearInterval(interval); } }, common.platformTimeout(1000)); await ran2.promise; runs.push(currentRun); - clearInterval(interval); child.kill(); await once(child, 'exit'); @@ -150,15 +164,17 @@ async function testWatch({ fileToUpdate, file, action = 'update', cwd = tmpdir.p currentRun = ''; const newFilePath = tmpdir.resolve(fileToCreate); const interval = setInterval( - () => writeFileSync( - newFilePath, - 'module.exports = {};' - ), + () => { + writeFileSync( + newFilePath, + 'module.exports = {};' + ); + clearInterval(interval); + }, common.platformTimeout(1000) ); await ran2.promise; runs.push(currentRun); - clearInterval(interval); child.kill(); await once(child, 'exit'); @@ -221,7 +237,100 @@ 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 the watched directory', + async () => { + await testWatch({ action: 'create', fileToCreate: 'new-test-file.test.js' }); + }); + + describe('test runner watch mode with different cwd', () => { + it( + 'should execute run using a different cwd for the runner than the process cwd', + async () => { + await testWatch( + { + fileToUpdate: 'test.js', + action: 'rename', + cwd: import.meta.dirname, + runnerCwd: tmpdir.path + } + ); + }); + + it( + 'should execute run using a different cwd for the runner than the process cwd with isolation process', + async () => { + await testWatch( + { + fileToUpdate: 'test.js', + action: 'rename', + cwd: import.meta.dirname, + runnerCwd: tmpdir.path, + isolation: 'process' + } + ); + }); + + it('should run with different cwd while in watch mode', async () => { + const controller = new AbortController(); + const stream = run({ + cwd: tmpdir.path, + watch: true, + signal: controller.signal, + }).on('data', function({ type }) { + if (type === 'test:watch:drained') { + stream.removeAllListeners('test:fail'); + stream.removeAllListeners('test:pass'); + controller.abort(); + } + }); + + 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 and isolation "none"', async () => { + const controller = new AbortController(); + const stream = run({ + cwd: tmpdir.path, + watch: true, + signal: controller.signal, + isolation: 'none', + }).on('data', function({ type }) { + if (type === 'test:watch:drained') { + stream.removeAllListeners('test:fail'); + stream.removeAllListeners('test:pass'); + controller.abort(); + } + }); + + 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 and isolation "process"', async () => { + const controller = new AbortController(); + const stream = run({ + cwd: tmpdir.path, + watch: true, + signal: controller.signal, + isolation: 'process', + }).on('data', function({ type }) { + if (type === 'test:watch:drained') { + stream.removeAllListeners('test:fail'); + stream.removeAllListeners('test:pass'); + controller.abort(); + } + }); + + 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); + }); }); }); diff --git a/test/parallel/test-runner-run.mjs b/test/parallel/test-runner-run.mjs index 1460ef15ed8dea..9f13e1a1e56420 100644 --- a/test/parallel/test-runner-run.mjs +++ b/test/parallel/test-runner-run.mjs @@ -496,6 +496,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), { @@ -528,6 +535,73 @@ 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 handle a non-existent directory being provided as cwd', async () => { + const diagnostics = []; + const stream = run({ + cwd: fixtures.path('test-runner', 'cwd', 'non-existing') + }); + stream.on('test:fail', common.mustNotCall()); + stream.on('test:pass', common.mustNotCall()); + stream.on('test:stderr', common.mustNotCall()); + stream.on('test:diagnostic', ({ message }) => { + diagnostics.push(message); + }); + + // eslint-disable-next-line no-unused-vars + for await (const _ of stream); + for (const entry of [ + 'tests 0', + 'suites 0', + 'pass 0', + 'fail 0', + 'cancelled 0', + 'skipped 0', + 'todo 0', + ] + ) { + assert.strictEqual(diagnostics.includes(entry), true); + } + }); + + it('should handle a non-existent file being provided as cwd', async () => { + const diagnostics = []; + const stream = run({ + cwd: fixtures.path('test-runner', 'default-behavior', 'test', 'random.cjs') + }); + stream.on('test:fail', common.mustNotCall()); + stream.on('test:pass', common.mustNotCall()); + stream.on('test:stderr', common.mustNotCall()); + stream.on('test:diagnostic', ({ message }) => { + diagnostics.push(message); + }); + + // eslint-disable-next-line no-unused-vars + for await (const _ of stream); + for (const entry of [ + 'tests 0', + 'suites 0', + 'pass 0', + 'fail 0', + 'cancelled 0', + 'skipped 0', + 'todo 0', + ] + ) { + assert.strictEqual(diagnostics.includes(entry), true); + } + }); }); describe('forceExit', () => { diff --git a/test/parallel/test-runner-watch-mode.mjs b/test/parallel/test-runner-watch-mode.mjs index b6fa0a2fe0f131..0c56e4c20e5b37 100644 --- a/test/parallel/test-runner-watch-mode.mjs +++ b/test/parallel/test-runner-watch-mode.mjs @@ -4,7 +4,8 @@ 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 } from 'node:fs'; +import { setTimeout } from 'node:timers/promises'; import util from 'internal/util'; import tmpdir from '../common/tmpdir.js'; @@ -69,10 +70,10 @@ async function testWatch({ currentRun = ''; const content = fixtureContent[fileToUpdate]; const path = fixturePaths[fileToUpdate]; - const interval = setInterval(() => writeFileSync(path, content), common.platformTimeout(1000)); + writeFileSync(path, content); + await setTimeout(common.platformTimeout(1000)); await ran2.promise; runs.push(currentRun); - clearInterval(interval); child.kill(); await once(child, 'exit'); @@ -92,10 +93,10 @@ async function testWatch({ currentRun = ''; const fileToRenamePath = tmpdir.resolve(fileToUpdate); const newFileNamePath = tmpdir.resolve(`test-renamed-${fileToUpdate}`); - const interval = setInterval(() => renameSync(fileToRenamePath, newFileNamePath), common.platformTimeout(1000)); + renameSync(fileToRenamePath, newFileNamePath); + await setTimeout(common.platformTimeout(1000)); await ran2.promise; runs.push(currentRun); - clearInterval(interval); child.kill(); await once(child, 'exit'); @@ -114,16 +115,10 @@ async function testWatch({ runs.push(currentRun); currentRun = ''; const fileToDeletePath = tmpdir.resolve(fileToUpdate); - const interval = setInterval(() => { - if (existsSync(fileToDeletePath)) { - unlinkSync(fileToDeletePath); - } else { - ran2.resolve(); - } - }, common.platformTimeout(1000)); - await ran2.promise; + unlinkSync(fileToDeletePath); + await setTimeout(common.platformTimeout(2000)); + ran2.resolve(); runs.push(currentRun); - clearInterval(interval); child.kill(); await once(child, 'exit'); @@ -139,16 +134,10 @@ async function testWatch({ runs.push(currentRun); currentRun = ''; const newFilePath = tmpdir.resolve(fileToCreate); - const interval = setInterval( - () => writeFileSync( - newFilePath, - 'module.exports = {};' - ), - common.platformTimeout(1000) - ); + writeFileSync(newFilePath, 'module.exports = {};'); + await setTimeout(common.platformTimeout(1000)); await ran2.promise; runs.push(currentRun); - clearInterval(interval); child.kill(); await once(child, 'exit');