This adds support for identifying which worker is running a test file when tests execute concurrently, similar to JEST_WORKER_ID in Jest, VITEST_POOL_ID in Vitest, and MOCHA_WORKER_ID in Mocha. When running with --test-isolation=process (default), each test file runs in a separate child process and receives a unique worker ID from 1 to N. When running with --test-isolation=none, all tests run in the same process and the worker ID is always 1. This enables users to allocate separate resources (databases, ports, etc.) for each test worker to avoid conflicts during concurrent execution. Changes: - Add WorkerIdPool class to manage worker ID allocation and reuse - Set NODE_TEST_WORKER_ID environment variable for child processes - Add context.workerId getter to TestContext class - Add tests for worker ID functionality - Add documentation for context.workerId Fixes: https://github.com/nodejs/node/issues/55842 PR-URL: https://github.com/nodejs/node/pull/61394 Reviewed-By: Matteo Collina <matteo.collina@gmail.com> Reviewed-By: Pietro Marchini <pietro.marchini94@gmail.com> Reviewed-By: Jacob Smith <jacob@frende.me>
970 lines
30 KiB
JavaScript
970 lines
30 KiB
JavaScript
'use strict';
|
|
|
|
const {
|
|
ArrayIsArray,
|
|
ArrayPrototypeEvery,
|
|
ArrayPrototypeFilter,
|
|
ArrayPrototypeFind,
|
|
ArrayPrototypeForEach,
|
|
ArrayPrototypeIncludes,
|
|
ArrayPrototypeJoin,
|
|
ArrayPrototypeMap,
|
|
ArrayPrototypePush,
|
|
ArrayPrototypePushApply,
|
|
ArrayPrototypeShift,
|
|
ArrayPrototypeSlice,
|
|
ArrayPrototypeSome,
|
|
ArrayPrototypeSort,
|
|
MathMax,
|
|
ObjectAssign,
|
|
PromisePrototypeThen,
|
|
PromiseWithResolvers,
|
|
SafeMap,
|
|
SafePromiseAll,
|
|
SafePromiseAllReturnVoid,
|
|
SafePromiseAllSettledReturnVoid,
|
|
SafeSet,
|
|
String,
|
|
StringPrototypeIndexOf,
|
|
StringPrototypeSlice,
|
|
StringPrototypeStartsWith,
|
|
Symbol,
|
|
TypedArrayPrototypeGetLength,
|
|
TypedArrayPrototypeSubarray,
|
|
} = primordials;
|
|
|
|
const { spawn } = require('child_process');
|
|
const { finished } = require('internal/streams/end-of-stream');
|
|
const { availableParallelism } = require('os');
|
|
const { resolve, sep, isAbsolute } = require('path');
|
|
const { DefaultDeserializer, DefaultSerializer } = require('v8');
|
|
const { getOptionValue, getOptionsAsFlagsFromBinding } = require('internal/options');
|
|
const { Interface } = require('internal/readline/interface');
|
|
const { deserializeError } = require('internal/error_serdes');
|
|
const { Buffer } = require('buffer');
|
|
const { FilesWatcher } = require('internal/watch_mode/files_watcher');
|
|
const console = require('internal/console/global');
|
|
const {
|
|
codes: {
|
|
ERR_INVALID_ARG_TYPE,
|
|
ERR_INVALID_ARG_VALUE,
|
|
ERR_TEST_FAILURE,
|
|
},
|
|
} = require('internal/errors');
|
|
const esmLoader = require('internal/modules/esm/loader');
|
|
const {
|
|
validateArray,
|
|
validateBoolean,
|
|
validateFunction,
|
|
validateObject,
|
|
validateOneOf,
|
|
validateInteger,
|
|
validateString,
|
|
validateStringArray,
|
|
} = require('internal/validators');
|
|
const { getInspectPort, isUsingInspector, isInspectorMessage } = require('internal/util/inspector');
|
|
const { isRegExp } = require('internal/util/types');
|
|
const { pathToFileURL } = require('internal/url');
|
|
const {
|
|
kEmptyObject,
|
|
} = require('internal/util');
|
|
const { kEmitMessage } = require('internal/test_runner/tests_stream');
|
|
const {
|
|
createTestTree,
|
|
startSubtestAfterBootstrap,
|
|
} = require('internal/test_runner/harness');
|
|
const {
|
|
kAborted,
|
|
kCancelledByParent,
|
|
kSubtestsFailed,
|
|
kTestCodeFailure,
|
|
kTestTimeoutFailure,
|
|
Test,
|
|
} = require('internal/test_runner/test');
|
|
const { FastBuffer } = require('internal/buffer');
|
|
|
|
const {
|
|
convertStringToRegExp,
|
|
countCompletedTest,
|
|
kDefaultPattern,
|
|
parseCommandLine,
|
|
} = require('internal/test_runner/utils');
|
|
const { Glob } = require('internal/fs/glob');
|
|
const { once } = require('events');
|
|
const { validatePath } = require('internal/fs/utils');
|
|
const { loadPreloadModules } = require('internal/process/pre_execution');
|
|
const {
|
|
triggerUncaughtException,
|
|
exitCodes: { kGenericUserError },
|
|
} = internalBinding('errors');
|
|
let debug = require('internal/util/debuglog').debuglog('test_runner', (fn) => {
|
|
debug = fn;
|
|
});
|
|
|
|
const kIsolatedProcessName = Symbol('kIsolatedProcessName');
|
|
const kFilterArgs = [
|
|
'--test',
|
|
'--experimental-test-coverage',
|
|
'--watch',
|
|
'--experimental-default-config-file',
|
|
];
|
|
const kFilterArgValues = [
|
|
'--test-reporter',
|
|
'--test-reporter-destination',
|
|
'--experimental-config-file',
|
|
];
|
|
const kDiagnosticsFilterArgs = ['tests', 'suites', 'pass', 'fail', 'cancelled', 'skipped', 'todo', 'duration_ms'];
|
|
|
|
const kCanceledTests = new SafeSet()
|
|
.add(kCancelledByParent).add(kAborted).add(kTestTimeoutFailure);
|
|
|
|
let kResistStopPropagation;
|
|
|
|
// Worker ID pool management for concurrent test execution
|
|
class WorkerIdPool {
|
|
#nextId = 0;
|
|
#maxConcurrency;
|
|
|
|
constructor(maxConcurrency) {
|
|
this.#maxConcurrency = maxConcurrency;
|
|
}
|
|
|
|
acquire() {
|
|
const id = (this.#nextId++ % this.#maxConcurrency) + 1;
|
|
return id;
|
|
}
|
|
}
|
|
|
|
function createTestFileList(patterns, cwd) {
|
|
const hasUserSuppliedPattern = patterns != null;
|
|
if (!patterns || patterns.length === 0) {
|
|
patterns = [kDefaultPattern];
|
|
}
|
|
const glob = new Glob(patterns, {
|
|
__proto__: null,
|
|
cwd,
|
|
exclude: (name) => name === 'node_modules',
|
|
});
|
|
const results = glob.globSync();
|
|
|
|
if (hasUserSuppliedPattern && results.length === 0 && ArrayPrototypeEvery(glob.matchers, (m) => !m.hasMagic())) {
|
|
console.error(`Could not find '${ArrayPrototypeJoin(patterns, ', ')}'`);
|
|
process.exit(kGenericUserError);
|
|
}
|
|
|
|
return ArrayPrototypeSort(results);
|
|
}
|
|
|
|
function filterExecArgv(arg, i, arr) {
|
|
return !ArrayPrototypeIncludes(kFilterArgs, arg) &&
|
|
!ArrayPrototypeSome(kFilterArgValues, (p) => arg === p || (i > 0 && arr[i - 1] === p) || StringPrototypeStartsWith(arg, `${p}=`));
|
|
}
|
|
|
|
function getRunArgs(path, { forceExit,
|
|
inspectPort,
|
|
testNamePatterns,
|
|
testSkipPatterns,
|
|
only,
|
|
argv: suppliedArgs,
|
|
execArgv,
|
|
rerunFailuresFilePath,
|
|
root: { timeout },
|
|
cwd }) {
|
|
const processNodeOptions = getOptionsAsFlagsFromBinding();
|
|
const runArgs = ArrayPrototypeFilter(processNodeOptions, filterExecArgv);
|
|
|
|
/**
|
|
* Node supports V8 options passed via cli.
|
|
* These options are being consumed by V8 and are not stored in nodeOptions.
|
|
*
|
|
* We need to propagate these options to the child processes manually.
|
|
*
|
|
* An example of such option are --allow-natives-syntax and --expose-gc
|
|
*/
|
|
const nodeOptionsSet = new SafeSet(processNodeOptions);
|
|
const unknownProcessExecArgv = ArrayPrototypeFilter(
|
|
process.execArgv,
|
|
(arg) => !nodeOptionsSet.has(arg),
|
|
);
|
|
ArrayPrototypePushApply(runArgs, unknownProcessExecArgv);
|
|
|
|
if (forceExit === true) {
|
|
ArrayPrototypePush(runArgs, '--test-force-exit');
|
|
}
|
|
if (isUsingInspector()) {
|
|
ArrayPrototypePush(runArgs, `--inspect-port=${getInspectPort(inspectPort)}`);
|
|
}
|
|
if (testNamePatterns != null) {
|
|
ArrayPrototypeForEach(testNamePatterns, (pattern) => ArrayPrototypePush(runArgs, `--test-name-pattern=${pattern}`));
|
|
}
|
|
if (testSkipPatterns != null) {
|
|
ArrayPrototypeForEach(testSkipPatterns, (pattern) => ArrayPrototypePush(runArgs, `--test-skip-pattern=${pattern}`));
|
|
}
|
|
if (only === true) {
|
|
ArrayPrototypePush(runArgs, '--test-only');
|
|
}
|
|
if (timeout != null) {
|
|
ArrayPrototypePush(runArgs, `--test-timeout=${timeout}`);
|
|
}
|
|
if (rerunFailuresFilePath) {
|
|
ArrayPrototypePush(runArgs, `--test-rerun-failures=${rerunFailuresFilePath}`);
|
|
}
|
|
|
|
ArrayPrototypePushApply(runArgs, execArgv);
|
|
|
|
if (path === kIsolatedProcessName) {
|
|
ArrayPrototypePush(runArgs, '--test');
|
|
ArrayPrototypePushApply(runArgs, ArrayPrototypeSlice(process.argv, 1));
|
|
} else {
|
|
ArrayPrototypePush(runArgs, path);
|
|
}
|
|
|
|
ArrayPrototypePushApply(runArgs, suppliedArgs);
|
|
|
|
return runArgs;
|
|
}
|
|
|
|
const serializer = new DefaultSerializer();
|
|
serializer.writeHeader();
|
|
const v8Header = serializer.releaseBuffer();
|
|
const kV8HeaderLength = TypedArrayPrototypeGetLength(v8Header);
|
|
const kSerializedSizeHeader = 4 + kV8HeaderLength;
|
|
|
|
class FileTest extends Test {
|
|
// This class maintains two buffers:
|
|
#reportBuffer = []; // Parsed items waiting for this.isClearToSend()
|
|
#rawBuffer = []; // Raw data waiting to be parsed
|
|
#rawBufferSize = 0;
|
|
#reportedChildren = 0;
|
|
failedSubtests = false;
|
|
|
|
constructor(options) {
|
|
super(options);
|
|
this.loc ??= {
|
|
__proto__: null,
|
|
line: 1,
|
|
column: 1,
|
|
file: resolve(this.name),
|
|
};
|
|
this.timeout = null;
|
|
}
|
|
|
|
#skipReporting() {
|
|
return this.#reportedChildren > 0 && (!this.error || this.error.failureType === kSubtestsFailed);
|
|
}
|
|
#checkNestedComment(comment) {
|
|
const firstSpaceIndex = StringPrototypeIndexOf(comment, ' ');
|
|
if (firstSpaceIndex === -1) return false;
|
|
const secondSpaceIndex = StringPrototypeIndexOf(comment, ' ', firstSpaceIndex + 1);
|
|
return secondSpaceIndex === -1 &&
|
|
ArrayPrototypeIncludes(kDiagnosticsFilterArgs, StringPrototypeSlice(comment, 0, firstSpaceIndex));
|
|
}
|
|
#handleReportItem(item) {
|
|
const isTopLevel = item.data.nesting === 0;
|
|
if (isTopLevel) {
|
|
if (item.type === 'test:plan' && this.#skipReporting()) {
|
|
return;
|
|
}
|
|
if (item.type === 'test:diagnostic' && this.#checkNestedComment(item.data.message)) {
|
|
return;
|
|
}
|
|
}
|
|
if (item.data.details?.error) {
|
|
item.data.details.error = deserializeError(item.data.details.error);
|
|
}
|
|
if (item.type === 'test:pass' || item.type === 'test:fail') {
|
|
item.data.testNumber = isTopLevel ? (this.root.harness.counters.topLevel + 1) : item.data.testNumber;
|
|
countCompletedTest({
|
|
__proto__: null,
|
|
name: item.data.name,
|
|
finished: true,
|
|
skipped: item.data.skip !== undefined,
|
|
isTodo: item.data.todo !== undefined,
|
|
passed: item.type === 'test:pass',
|
|
cancelled: kCanceledTests.has(item.data.details?.error?.failureType),
|
|
nesting: item.data.nesting,
|
|
reportedType: item.data.details?.type,
|
|
}, this.root.harness);
|
|
}
|
|
this.reporter[kEmitMessage](item.type, item.data);
|
|
}
|
|
#accumulateReportItem(item) {
|
|
if (item.type !== 'test:pass' && item.type !== 'test:fail') {
|
|
return;
|
|
}
|
|
this.#reportedChildren++;
|
|
if (item.data.nesting === 0 && item.type === 'test:fail') {
|
|
this.failedSubtests = true;
|
|
}
|
|
}
|
|
#drainReportBuffer() {
|
|
if (this.#reportBuffer.length > 0) {
|
|
ArrayPrototypeForEach(this.#reportBuffer, (ast) => this.#handleReportItem(ast));
|
|
this.#reportBuffer = [];
|
|
}
|
|
}
|
|
addToReport(item) {
|
|
this.#accumulateReportItem(item);
|
|
if (!this.isClearToSend()) {
|
|
ArrayPrototypePush(this.#reportBuffer, item);
|
|
return;
|
|
}
|
|
this.#drainReportBuffer();
|
|
this.#handleReportItem(item);
|
|
}
|
|
reportStarted() {}
|
|
drain() {
|
|
this.#drainRawBuffer();
|
|
this.#drainReportBuffer();
|
|
}
|
|
report() {
|
|
this.drain();
|
|
const skipReporting = this.#skipReporting();
|
|
if (!skipReporting) {
|
|
super.reportStarted();
|
|
super.report();
|
|
}
|
|
}
|
|
parseMessage(readData) {
|
|
let dataLength = TypedArrayPrototypeGetLength(readData);
|
|
if (dataLength === 0) return;
|
|
const partialV8Header = readData[dataLength - 1] === v8Header[0];
|
|
|
|
if (partialV8Header) {
|
|
// This will break if v8Header length (2 bytes) is changed.
|
|
// However it is covered by tests.
|
|
readData = TypedArrayPrototypeSubarray(readData, 0, dataLength - 1);
|
|
dataLength--;
|
|
}
|
|
|
|
if (this.#rawBuffer[0] && TypedArrayPrototypeGetLength(this.#rawBuffer[0]) < kSerializedSizeHeader) {
|
|
this.#rawBuffer[0] = Buffer.concat([this.#rawBuffer[0], readData]);
|
|
} else {
|
|
ArrayPrototypePush(this.#rawBuffer, readData);
|
|
}
|
|
this.#rawBufferSize += dataLength;
|
|
this.#processRawBuffer();
|
|
|
|
if (partialV8Header) {
|
|
ArrayPrototypePush(this.#rawBuffer, TypedArrayPrototypeSubarray(v8Header, 0, 1));
|
|
this.#rawBufferSize++;
|
|
}
|
|
}
|
|
#drainRawBuffer() {
|
|
while (this.#rawBuffer.length > 0) {
|
|
this.#processRawBuffer();
|
|
}
|
|
}
|
|
#processRawBuffer() {
|
|
// This method is called when it is known that there is at least one message
|
|
let bufferHead = this.#rawBuffer[0];
|
|
let headerIndex = bufferHead.indexOf(v8Header);
|
|
let nonSerialized = new FastBuffer();
|
|
|
|
while (bufferHead && headerIndex !== 0) {
|
|
const nonSerializedData = headerIndex === -1 ?
|
|
bufferHead :
|
|
bufferHead.slice(0, headerIndex);
|
|
nonSerialized = Buffer.concat([nonSerialized, nonSerializedData]);
|
|
this.#rawBufferSize -= TypedArrayPrototypeGetLength(nonSerializedData);
|
|
if (headerIndex === -1) {
|
|
ArrayPrototypeShift(this.#rawBuffer);
|
|
} else {
|
|
this.#rawBuffer[0] = TypedArrayPrototypeSubarray(bufferHead, headerIndex);
|
|
}
|
|
bufferHead = this.#rawBuffer[0];
|
|
headerIndex = bufferHead?.indexOf(v8Header);
|
|
}
|
|
|
|
if (TypedArrayPrototypeGetLength(nonSerialized) > 0) {
|
|
this.addToReport({
|
|
__proto__: null,
|
|
type: 'test:stdout',
|
|
data: { __proto__: null, file: this.name, message: nonSerialized.toString('utf-8') },
|
|
});
|
|
}
|
|
|
|
while (bufferHead?.length >= kSerializedSizeHeader) {
|
|
// We call `readUInt32BE` manually here, because this is faster than first converting
|
|
// it to a buffer and using `readUInt32BE` on that.
|
|
const fullMessageSize = (
|
|
bufferHead[kV8HeaderLength] << 24 |
|
|
bufferHead[kV8HeaderLength + 1] << 16 |
|
|
bufferHead[kV8HeaderLength + 2] << 8 |
|
|
bufferHead[kV8HeaderLength + 3]
|
|
) + kSerializedSizeHeader;
|
|
|
|
if (this.#rawBufferSize < fullMessageSize) break;
|
|
|
|
const concatenatedBuffer = this.#rawBuffer.length === 1 ?
|
|
this.#rawBuffer[0] : Buffer.concat(this.#rawBuffer, this.#rawBufferSize);
|
|
|
|
const deserializer = new DefaultDeserializer(
|
|
TypedArrayPrototypeSubarray(concatenatedBuffer, kSerializedSizeHeader, fullMessageSize),
|
|
);
|
|
|
|
bufferHead = TypedArrayPrototypeSubarray(concatenatedBuffer, fullMessageSize);
|
|
this.#rawBufferSize = TypedArrayPrototypeGetLength(bufferHead);
|
|
this.#rawBuffer = this.#rawBufferSize !== 0 ? [bufferHead] : [];
|
|
|
|
deserializer.readHeader();
|
|
const item = deserializer.readValue();
|
|
this.addToReport(item);
|
|
}
|
|
}
|
|
}
|
|
|
|
function runTestFile(path, filesWatcher, opts) {
|
|
const watchMode = filesWatcher != null;
|
|
const testPath = path === kIsolatedProcessName ? '' : path;
|
|
const testOpts = { __proto__: null, signal: opts.signal };
|
|
const subtest = opts.root.createSubtest(FileTest, testPath, testOpts, async (t) => {
|
|
const args = getRunArgs(path, opts);
|
|
const stdio = ['pipe', 'pipe', 'pipe'];
|
|
const env = { __proto__: null, NODE_TEST_CONTEXT: 'child-v8', ...(opts.env || process.env) };
|
|
|
|
// Acquire a worker ID from the pool for process isolation mode
|
|
let workerId;
|
|
if (opts.workerIdPool) {
|
|
workerId = opts.workerIdPool.acquire();
|
|
env.NODE_TEST_WORKER_ID = String(workerId);
|
|
debug('Assigned worker ID %d to test file: %s', workerId, path);
|
|
}
|
|
|
|
if (watchMode) {
|
|
stdio.push('ipc');
|
|
env.WATCH_REPORT_DEPENDENCIES = '1';
|
|
}
|
|
if (opts.root.harness.shouldColorizeTestFiles) {
|
|
env.FORCE_COLOR = '1';
|
|
}
|
|
|
|
const child = spawn(
|
|
process.execPath, args,
|
|
{
|
|
__proto__: null,
|
|
signal: t.signal,
|
|
encoding: 'utf8',
|
|
env,
|
|
stdio,
|
|
cwd: opts.cwd,
|
|
},
|
|
);
|
|
if (watchMode) {
|
|
filesWatcher.runningProcesses.set(path, child);
|
|
filesWatcher.watcher.watchChildProcessModules(child, path);
|
|
}
|
|
|
|
let err;
|
|
|
|
child.on('error', (error) => {
|
|
err = error;
|
|
});
|
|
|
|
child.stdout.on('data', (data) => {
|
|
subtest.parseMessage(data);
|
|
});
|
|
|
|
const rl = new Interface({ __proto__: null, input: child.stderr });
|
|
rl.on('line', (line) => {
|
|
if (isInspectorMessage(line)) {
|
|
process.stderr.write(line + '\n');
|
|
return;
|
|
}
|
|
|
|
// stderr cannot be treated as TAP, per the spec. However, we want to
|
|
// surface stderr lines to improve the DX. Inject each line into the
|
|
// test output as an unknown token as if it came from the TAP parser.
|
|
subtest.addToReport({
|
|
__proto__: null,
|
|
type: 'test:stderr',
|
|
data: { __proto__: null, file: path, message: line + '\n' },
|
|
});
|
|
});
|
|
|
|
const { 0: { 0: code, 1: signal } } = await SafePromiseAll([
|
|
once(child, 'exit', { __proto__: null, signal: t.signal }),
|
|
finished(child.stdout, { __proto__: null, signal: t.signal }),
|
|
]);
|
|
|
|
// Close readline interface to prevent memory leak
|
|
rl.close();
|
|
|
|
if (watchMode) {
|
|
filesWatcher.runningProcesses.delete(path);
|
|
filesWatcher.runningSubtests.delete(path);
|
|
(async () => {
|
|
try {
|
|
await subTestEnded;
|
|
} finally {
|
|
if (filesWatcher.runningSubtests.size === 0) {
|
|
opts.root.reporter[kEmitMessage]('test:watch:drained');
|
|
opts.root.postRun();
|
|
}
|
|
}
|
|
})();
|
|
}
|
|
|
|
if (code !== 0 || signal !== null) {
|
|
if (!err) {
|
|
const failureType = subtest.failedSubtests ? kSubtestsFailed : kTestCodeFailure;
|
|
err = ObjectAssign(new ERR_TEST_FAILURE('test failed', failureType), {
|
|
__proto__: null,
|
|
exitCode: code,
|
|
signal: signal,
|
|
// The stack will not be useful since the failures came from tests
|
|
// in a child process.
|
|
stack: undefined,
|
|
});
|
|
}
|
|
|
|
throw err;
|
|
}
|
|
});
|
|
const subTestEnded = subtest.start();
|
|
return subTestEnded;
|
|
}
|
|
|
|
function watchFiles(testFiles, opts) {
|
|
const runningProcesses = new SafeMap();
|
|
const runningSubtests = new SafeMap();
|
|
const watcherMode = opts.hasFiles ? 'filter' : 'all';
|
|
const watcher = new FilesWatcher({ __proto__: null, debounce: 200, mode: watcherMode, signal: opts.signal });
|
|
if (!opts.hasFiles) {
|
|
watcher.watchPath(opts.cwd);
|
|
}
|
|
const filesWatcher = { __proto__: null, watcher, runningProcesses, runningSubtests };
|
|
opts.root.harness.watching = true;
|
|
|
|
async function restartTestFile(file) {
|
|
const runningProcess = runningProcesses.get(file);
|
|
if (runningProcess) {
|
|
runningProcess.kill();
|
|
await once(runningProcess, 'exit');
|
|
}
|
|
if (!runningSubtests.size) {
|
|
// Reset the topLevel counter
|
|
opts.root.harness.counters.topLevel = 0;
|
|
}
|
|
|
|
await runningSubtests.get(file);
|
|
runningSubtests.set(file, runTestFile(file, filesWatcher, opts));
|
|
}
|
|
|
|
// Watch for changes in current filtered files
|
|
const onChanged = ({ owners, eventType }) => {
|
|
if (!opts.hasFiles && (eventType === 'rename' || eventType === 'change')) {
|
|
const updatedTestFiles = createTestFileList(opts.globPatterns, opts.cwd);
|
|
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) {
|
|
owners = new SafeSet().add(newFileName);
|
|
const resolveFileName = isAbsolute(newFileName) ? newFileName : resolve(opts.cwd, newFileName);
|
|
watcher.filterFile(resolveFileName, owners);
|
|
}
|
|
|
|
if (!newFileName && previousFileName) {
|
|
return; // Avoid rerunning files when file deleted
|
|
}
|
|
}
|
|
// Reset the root start time to recalculate the duration
|
|
// of the run
|
|
opts.root.clearExecutionTime();
|
|
opts.root.reporter[kEmitMessage]('test:watch:restarted');
|
|
|
|
// Restart test files
|
|
if (opts.isolation === 'none') {
|
|
PromisePrototypeThen(restartTestFile(kIsolatedProcessName), undefined, (error) => {
|
|
triggerUncaughtException(error, true /* fromPromise */);
|
|
});
|
|
} else {
|
|
watcher.unfilterFilesOwnedBy(owners);
|
|
PromisePrototypeThen(SafePromiseAllReturnVoid(testFiles, async (file) => {
|
|
if (!owners.has(file)) {
|
|
return;
|
|
}
|
|
|
|
await restartTestFile(file);
|
|
}, undefined, (error) => {
|
|
triggerUncaughtException(error, true /* fromPromise */);
|
|
}));
|
|
}
|
|
};
|
|
|
|
watcher.on('changed', onChanged);
|
|
|
|
// Cleanup function to remove event listener and prevent memory leak
|
|
const cleanup = () => {
|
|
watcher.removeListener('changed', onChanged);
|
|
opts.root.harness.watching = false;
|
|
opts.root.postRun();
|
|
};
|
|
|
|
if (opts.signal) {
|
|
kResistStopPropagation ??= require('internal/event_target').kResistStopPropagation;
|
|
opts.signal.addEventListener(
|
|
'abort',
|
|
cleanup,
|
|
{ __proto__: null, once: true, [kResistStopPropagation]: true },
|
|
);
|
|
}
|
|
|
|
// Expose cleanup method for proper resource management
|
|
filesWatcher.cleanup = cleanup;
|
|
|
|
return filesWatcher;
|
|
}
|
|
|
|
function run(options = kEmptyObject) {
|
|
validateObject(options, 'options');
|
|
|
|
let {
|
|
testNamePatterns,
|
|
testSkipPatterns,
|
|
shard,
|
|
coverageExcludeGlobs,
|
|
coverageIncludeGlobs,
|
|
} = options;
|
|
const {
|
|
concurrency,
|
|
timeout,
|
|
signal,
|
|
files,
|
|
forceExit,
|
|
inspectPort,
|
|
isolation = 'process',
|
|
watch,
|
|
setup,
|
|
globalSetupPath,
|
|
only,
|
|
globPatterns,
|
|
coverage = false,
|
|
lineCoverage = 0,
|
|
branchCoverage = 0,
|
|
functionCoverage = 0,
|
|
execArgv = [],
|
|
argv = [],
|
|
cwd = process.cwd(),
|
|
rerunFailuresFilePath,
|
|
env,
|
|
} = options;
|
|
|
|
if (files != null) {
|
|
validateArray(files, 'options.files');
|
|
}
|
|
if (watch != null) {
|
|
validateBoolean(watch, 'options.watch');
|
|
}
|
|
if (forceExit != null) {
|
|
validateBoolean(forceExit, 'options.forceExit');
|
|
|
|
if (forceExit && watch) {
|
|
throw new ERR_INVALID_ARG_VALUE(
|
|
'options.forceExit', watch, 'is not supported with watch mode',
|
|
);
|
|
}
|
|
}
|
|
if (only != null) {
|
|
validateBoolean(only, 'options.only');
|
|
}
|
|
if (globPatterns != null) {
|
|
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\'',
|
|
);
|
|
}
|
|
|
|
if (rerunFailuresFilePath) {
|
|
validatePath(rerunFailuresFilePath, 'options.rerunFailuresFilePath');
|
|
}
|
|
|
|
if (shard != null) {
|
|
validateObject(shard, 'options.shard');
|
|
// Avoid re-evaluating the shard object in case it's a getter
|
|
shard = { __proto__: null, index: shard.index, total: shard.total };
|
|
|
|
validateInteger(shard.total, 'options.shard.total', 1);
|
|
validateInteger(shard.index, 'options.shard.index', 1, shard.total);
|
|
|
|
if (watch) {
|
|
throw new ERR_INVALID_ARG_VALUE('options.shard', watch, 'shards not supported with watch mode');
|
|
}
|
|
}
|
|
if (setup != null) {
|
|
validateFunction(setup, 'options.setup');
|
|
}
|
|
if (testNamePatterns != null) {
|
|
if (!ArrayIsArray(testNamePatterns)) {
|
|
testNamePatterns = [testNamePatterns];
|
|
}
|
|
|
|
testNamePatterns = ArrayPrototypeMap(testNamePatterns, (value, i) => {
|
|
if (isRegExp(value)) {
|
|
return value;
|
|
}
|
|
const name = `options.testNamePatterns[${i}]`;
|
|
if (typeof value === 'string') {
|
|
return convertStringToRegExp(value, name);
|
|
}
|
|
throw new ERR_INVALID_ARG_TYPE(name, ['string', 'RegExp'], value);
|
|
});
|
|
}
|
|
if (testSkipPatterns != null) {
|
|
if (!ArrayIsArray(testSkipPatterns)) {
|
|
testSkipPatterns = [testSkipPatterns];
|
|
}
|
|
|
|
testSkipPatterns = ArrayPrototypeMap(testSkipPatterns, (value, i) => {
|
|
if (isRegExp(value)) {
|
|
return value;
|
|
}
|
|
const name = `options.testSkipPatterns[${i}]`;
|
|
if (typeof value === 'string') {
|
|
return convertStringToRegExp(value, name);
|
|
}
|
|
throw new ERR_INVALID_ARG_TYPE(name, ['string', 'RegExp'], value);
|
|
});
|
|
}
|
|
validateOneOf(isolation, 'options.isolation', ['process', 'none']);
|
|
validateBoolean(coverage, 'options.coverage');
|
|
if (coverageExcludeGlobs != null) {
|
|
if (!ArrayIsArray(coverageExcludeGlobs)) {
|
|
coverageExcludeGlobs = [coverageExcludeGlobs];
|
|
}
|
|
validateStringArray(coverageExcludeGlobs, 'options.coverageExcludeGlobs');
|
|
}
|
|
if (coverageIncludeGlobs != null) {
|
|
if (!ArrayIsArray(coverageIncludeGlobs)) {
|
|
coverageIncludeGlobs = [coverageIncludeGlobs];
|
|
}
|
|
validateStringArray(coverageIncludeGlobs, 'options.coverageIncludeGlobs');
|
|
}
|
|
validateInteger(lineCoverage, 'options.lineCoverage', 0, 100);
|
|
validateInteger(branchCoverage, 'options.branchCoverage', 0, 100);
|
|
validateInteger(functionCoverage, 'options.functionCoverage', 0, 100);
|
|
|
|
validateStringArray(argv, 'options.argv');
|
|
validateStringArray(execArgv, 'options.execArgv');
|
|
|
|
if (globalSetupPath != null) {
|
|
validatePath(globalSetupPath, 'options.globalSetupPath');
|
|
}
|
|
|
|
if (env != null) {
|
|
validateObject(env);
|
|
|
|
if (isolation === 'none') {
|
|
throw new ERR_INVALID_ARG_VALUE('options.env', env, 'is not supported with isolation=\'none\'');
|
|
}
|
|
}
|
|
|
|
const rootTestOptions = { __proto__: null, concurrency, timeout, signal };
|
|
const globalOptions = {
|
|
__proto__: null,
|
|
// parseCommandLine() should not be used here. However, The existing run()
|
|
// behavior has relied on it, so removing it must be done in a semver major.
|
|
...parseCommandLine(),
|
|
setup, // This line can be removed when parseCommandLine() is removed here.
|
|
coverage,
|
|
coverageExcludeGlobs,
|
|
coverageIncludeGlobs,
|
|
rerunFailuresFilePath,
|
|
lineCoverage: lineCoverage,
|
|
branchCoverage: branchCoverage,
|
|
functionCoverage: functionCoverage,
|
|
cwd,
|
|
globalSetupPath,
|
|
};
|
|
const root = createTestTree(rootTestOptions, globalOptions);
|
|
let testFiles = files ?? createTestFileList(globPatterns, cwd);
|
|
const { isTestRunner } = globalOptions;
|
|
|
|
if (shard) {
|
|
testFiles = ArrayPrototypeFilter(testFiles, (_, index) => index % shard.total === shard.index - 1);
|
|
}
|
|
|
|
let teardown;
|
|
let postRun;
|
|
let filesWatcher;
|
|
let runFiles;
|
|
|
|
// Create worker ID pool for concurrent test execution.
|
|
// Use concurrency from globalOptions which has been processed by parseCommandLine().
|
|
const effectiveConcurrency = globalOptions.concurrency ?? concurrency;
|
|
let maxConcurrency = 1;
|
|
if (effectiveConcurrency === true) {
|
|
maxConcurrency = MathMax(availableParallelism() - 1, 1);
|
|
} else if (typeof effectiveConcurrency === 'number') {
|
|
maxConcurrency = effectiveConcurrency;
|
|
}
|
|
const workerIdPool = new WorkerIdPool(maxConcurrency);
|
|
debug(
|
|
'Created worker ID pool with max concurrency: %d, ' +
|
|
'effectiveConcurrency: %s, testFiles: %d',
|
|
maxConcurrency,
|
|
effectiveConcurrency,
|
|
testFiles.length,
|
|
);
|
|
|
|
const opts = {
|
|
__proto__: null,
|
|
root,
|
|
signal,
|
|
inspectPort,
|
|
testNamePatterns,
|
|
testSkipPatterns,
|
|
hasFiles: files != null,
|
|
globPatterns,
|
|
only,
|
|
forceExit,
|
|
cwd,
|
|
isolation,
|
|
argv,
|
|
execArgv,
|
|
rerunFailuresFilePath,
|
|
env,
|
|
workerIdPool: isolation === 'process' ? workerIdPool : null,
|
|
};
|
|
|
|
if (isolation === 'process') {
|
|
if (process.env.NODE_TEST_CONTEXT !== undefined) {
|
|
process.emitWarning('node:test run() is being called recursively within a test file. skipping running files.');
|
|
root.postRun();
|
|
return root.reporter;
|
|
}
|
|
|
|
if (watch) {
|
|
filesWatcher = watchFiles(testFiles, opts);
|
|
} else {
|
|
postRun = () => root.postRun();
|
|
teardown = () => root.harness.teardown();
|
|
}
|
|
|
|
runFiles = () => {
|
|
root.harness.bootstrapPromise = null;
|
|
root.harness.buildPromise = null;
|
|
return SafePromiseAllSettledReturnVoid(testFiles, (path) => {
|
|
const subtest = runTestFile(path, filesWatcher, opts);
|
|
filesWatcher?.runningSubtests.set(path, subtest);
|
|
return subtest;
|
|
});
|
|
};
|
|
} else if (isolation === 'none') {
|
|
// For isolation=none, set worker ID to 1 in the current process
|
|
process.env.NODE_TEST_WORKER_ID = '1';
|
|
debug('Set NODE_TEST_WORKER_ID=1 for isolation=none');
|
|
|
|
if (watch) {
|
|
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;
|
|
const subtest = runTestFile(kIsolatedProcessName, filesWatcher, opts);
|
|
filesWatcher?.runningSubtests.set(kIsolatedProcessName, subtest);
|
|
return subtest;
|
|
};
|
|
} else {
|
|
runFiles = async () => {
|
|
const { promise, resolve: finishBootstrap } = PromiseWithResolvers();
|
|
|
|
await root.runInAsyncScope(async () => {
|
|
const parentURL = pathToFileURL(cwd + sep).href;
|
|
const cascadedLoader = esmLoader.getOrInitializeCascadedLoader();
|
|
let topLevelTestCount = 0;
|
|
|
|
root.harness.bootstrapPromise = root.harness.bootstrapPromise ?
|
|
SafePromiseAllReturnVoid([root.harness.bootstrapPromise, promise]) :
|
|
promise;
|
|
|
|
// We need to setup the user modules in the test runner if we are running with
|
|
// --test-isolation=none and --test in order to avoid loading the user modules
|
|
// BEFORE the creation of the root test (that would cause them to get lost).
|
|
if (isTestRunner) {
|
|
// If we are not coming from the test runner entry point, the user-required and imported
|
|
// modules have already been loaded.
|
|
// Since it's possible to delete modules from require.cache, a CommonJS module
|
|
// could otherwise be executed twice.
|
|
loadPreloadModules();
|
|
}
|
|
const userImports = getOptionValue('--import');
|
|
for (let i = 0; i < userImports.length; i++) {
|
|
await cascadedLoader.import(userImports[i], parentURL, kEmptyObject);
|
|
}
|
|
|
|
for (let i = 0; i < testFiles.length; ++i) {
|
|
const testFile = testFiles[i];
|
|
const fileURL = pathToFileURL(resolve(cwd, testFile));
|
|
const parent = i === 0 ? undefined : parentURL;
|
|
let threw = false;
|
|
let importError;
|
|
|
|
root.entryFile = resolve(testFile);
|
|
debug('loading test file:', fileURL.href);
|
|
try {
|
|
await cascadedLoader.import(fileURL, parent, { __proto__: null });
|
|
} catch (err) {
|
|
threw = true;
|
|
importError = err;
|
|
}
|
|
|
|
debug(
|
|
'loaded "%s": top level test count before = %d and after = %d',
|
|
testFile,
|
|
topLevelTestCount,
|
|
root.subtests.length,
|
|
);
|
|
if (topLevelTestCount === root.subtests.length) {
|
|
// This file had no tests in it. Add the placeholder test.
|
|
const subtest = root.createSubtest(Test, testFile, kEmptyObject, undefined, {
|
|
__proto__: null,
|
|
loc: [1, 1, resolve(testFile)],
|
|
});
|
|
if (threw) {
|
|
subtest.fail(importError);
|
|
}
|
|
startSubtestAfterBootstrap(subtest);
|
|
}
|
|
|
|
topLevelTestCount = root.subtests.length;
|
|
}
|
|
});
|
|
|
|
debug('beginning test execution');
|
|
root.entryFile = null;
|
|
finishBootstrap();
|
|
return root.processPendingSubtests();
|
|
};
|
|
}
|
|
}
|
|
|
|
const runChain = async () => {
|
|
if (root.harness?.bootstrapPromise) {
|
|
await root.harness.bootstrapPromise;
|
|
}
|
|
if (typeof setup === 'function') {
|
|
await setup(root.reporter);
|
|
}
|
|
|
|
await runFiles();
|
|
postRun?.();
|
|
teardown?.();
|
|
};
|
|
|
|
runChain();
|
|
return root.reporter;
|
|
}
|
|
|
|
module.exports = {
|
|
FileTest, // Exported for tests only
|
|
run,
|
|
};
|