test_runner: add cwd option to run

PR-URL: https://github.com/nodejs/node/pull/54705
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
Reviewed-By: Colin Ihrig <cjihrig@gmail.com>
pull/55255/head
Pietro Marchini 2024-10-03 16:45:49 +01:00 committed by GitHub
parent fe45be207b
commit 1c7795e52e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 283 additions and 50 deletions

View File

@ -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

View File

@ -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,
{

View File

@ -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) {

View File

@ -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;

View File

@ -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);

View File

@ -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: <root>',
'suite one',
'before two: <root>',
'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: <root>',
'after two: <root>',
]);

View File

@ -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);
});
});
});

View File

@ -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', () => {

View File

@ -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');