When watch mode runs with isolation disabled, spawn the child test runner with the files or glob patterns from the current run instead of reusing the parent process argv. This keeps discovery scoped to the configured cwd and avoids rerunning the driver test when the parent argv points outside that cwd. Fixes: https://github.com/nodejs/node/issues/63689 Signed-off-by: Kamat, Trivikram <16024985+trivikr@users.noreply.github.com> Assisted-by: openai:gpt-5.5 PR-URL: https://github.com/nodejs/node/pull/63690 Fixes: https://github.com/nodejs/node/issues/63689 Refs: https://github.com/nodejs/reliability/blob/main/reports/2026-06-01.md#jstest-failure Reviewed-By: Matteo Collina <matteo.collina@gmail.com> Reviewed-By: Chemi Atlow <chemi@atlow.co.il>
1127 lines
35 KiB
JavaScript
1127 lines
35 KiB
JavaScript
'use strict';
|
|
|
|
const {
|
|
ArrayIsArray,
|
|
ArrayPrototypeEvery,
|
|
ArrayPrototypeFilter,
|
|
ArrayPrototypeFind,
|
|
ArrayPrototypeForEach,
|
|
ArrayPrototypeIncludes,
|
|
ArrayPrototypeJoin,
|
|
ArrayPrototypeMap,
|
|
ArrayPrototypePush,
|
|
ArrayPrototypePushApply,
|
|
ArrayPrototypeShift,
|
|
ArrayPrototypeSome,
|
|
ArrayPrototypeSort,
|
|
MathMax,
|
|
ObjectAssign,
|
|
PromisePrototypeThen,
|
|
PromiseWithResolvers,
|
|
SafeMap,
|
|
SafePromiseAll,
|
|
SafePromiseAllReturnVoid,
|
|
SafePromiseAllSettledReturnVoid,
|
|
SafeSet,
|
|
String,
|
|
StringFromCharCode,
|
|
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,
|
|
validateUint32,
|
|
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 {
|
|
emitExperimentalWarning,
|
|
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 {
|
|
createRandomSeed,
|
|
convertStringToRegExp,
|
|
countCompletedTest,
|
|
kDefaultPattern,
|
|
parseCommandLine,
|
|
} = require('internal/test_runner/utils');
|
|
const {
|
|
validateAndCanonicalizeTagFilter,
|
|
} = require('internal/test_runner/tag_filter');
|
|
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',
|
|
'--test-randomize',
|
|
'--watch',
|
|
'--experimental-default-config-file',
|
|
];
|
|
const kFilterArgValues = [
|
|
'--experimental-test-tag-filter',
|
|
'--test-reporter',
|
|
'--test-reporter-destination',
|
|
'--test-random-seed',
|
|
'--experimental-config-file',
|
|
];
|
|
const kDiagnosticsFilterArgs = ['tests', 'suites', 'pass', 'fail', 'cancelled', 'skipped', 'todo', 'duration_ms'];
|
|
|
|
const kCanceledTests = new SafeSet()
|
|
.add(kCancelledByParent).add(kAborted).add(kTestTimeoutFailure);
|
|
|
|
// Execution-ordered events are forwarded immediately, bypassing the
|
|
// per-file declaration-order buffer.
|
|
const kExecutionOrderedEvents = new SafeSet()
|
|
.add('test:enqueue').add('test:dequeue').add('test:complete');
|
|
|
|
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) => {
|
|
return arg === p ||
|
|
StringPrototypeStartsWith(arg, `${p}=`) ||
|
|
(p !== '--experimental-config-file' && i > 0 && arr[i - 1] === p);
|
|
});
|
|
}
|
|
|
|
function getRunArgs(path, { forceExit,
|
|
inspectPort,
|
|
testNamePatterns,
|
|
testSkipPatterns,
|
|
testTagFilterExpressions,
|
|
only,
|
|
hasFiles,
|
|
testFiles,
|
|
globPatterns,
|
|
argv: suppliedArgs,
|
|
execArgv,
|
|
rerunFailuresFilePath,
|
|
randomize,
|
|
randomSeed,
|
|
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 (testTagFilterExpressions != null) {
|
|
ArrayPrototypeForEach(testTagFilterExpressions, (value) => ArrayPrototypePush(runArgs, `--experimental-test-tag-filter=${value}`));
|
|
}
|
|
if (only === true) {
|
|
ArrayPrototypePush(runArgs, '--test-only');
|
|
}
|
|
if (timeout != null) {
|
|
ArrayPrototypePush(runArgs, `--test-timeout=${timeout}`);
|
|
}
|
|
if (rerunFailuresFilePath) {
|
|
ArrayPrototypePush(runArgs, `--test-rerun-failures=${rerunFailuresFilePath}`);
|
|
}
|
|
if (randomize) {
|
|
ArrayPrototypePush(runArgs, '--test-randomize');
|
|
}
|
|
if (randomSeed != null) {
|
|
ArrayPrototypePush(runArgs, `--test-random-seed=${randomSeed}`);
|
|
}
|
|
|
|
ArrayPrototypePushApply(runArgs, execArgv);
|
|
|
|
if (path === kIsolatedProcessName) {
|
|
ArrayPrototypePush(runArgs, '--test');
|
|
ArrayPrototypePush(runArgs, '--test-isolation=none');
|
|
ArrayPrototypePushApply(runArgs, hasFiles ? testFiles : globPatterns ?? []);
|
|
} 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;
|
|
#pendingPartialV8Header = false;
|
|
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) {
|
|
if (kExecutionOrderedEvents.has(item.type)) {
|
|
this.#handleReportItem(item);
|
|
return;
|
|
}
|
|
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 (this.#pendingPartialV8Header) {
|
|
readData = Buffer.concat([TypedArrayPrototypeSubarray(v8Header, 0, 1), readData]);
|
|
dataLength = TypedArrayPrototypeGetLength(readData);
|
|
this.#pendingPartialV8Header = false;
|
|
}
|
|
|
|
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 (dataLength > 0) {
|
|
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) {
|
|
this.#pendingPartialV8Header = true;
|
|
}
|
|
}
|
|
#drainRawBuffer() {
|
|
if (this.#pendingPartialV8Header) {
|
|
ArrayPrototypePush(this.#rawBuffer, TypedArrayPrototypeSubarray(v8Header, 0, 1));
|
|
this.#rawBufferSize++;
|
|
this.#pendingPartialV8Header = false;
|
|
}
|
|
|
|
while (this.#rawBuffer.length > 0) {
|
|
const prevBufferLength = this.#rawBuffer.length;
|
|
const prevBufferSize = this.#rawBufferSize;
|
|
this.#processRawBuffer();
|
|
|
|
if (this.#rawBuffer.length === prevBufferLength &&
|
|
this.#rawBufferSize === prevBufferSize) {
|
|
const bufferHead = this.#rawBuffer[0];
|
|
this.addToReport({
|
|
__proto__: null,
|
|
type: 'test:stdout',
|
|
data: {
|
|
__proto__: null,
|
|
file: this.name,
|
|
message: StringFromCharCode(bufferHead[0]),
|
|
},
|
|
});
|
|
|
|
if (TypedArrayPrototypeGetLength(bufferHead) === 1) {
|
|
ArrayPrototypeShift(this.#rawBuffer);
|
|
} else {
|
|
this.#rawBuffer[0] = TypedArrayPrototypeSubarray(bufferHead, 1);
|
|
}
|
|
this.#rawBufferSize--;
|
|
}
|
|
}
|
|
}
|
|
#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,
|
|
testTagFilters,
|
|
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,
|
|
randomize: suppliedRandomize,
|
|
randomSeed: suppliedRandomSeed,
|
|
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');
|
|
}
|
|
if (suppliedRandomize != null) {
|
|
validateBoolean(suppliedRandomize, 'options.randomize');
|
|
}
|
|
if (suppliedRandomSeed != null) {
|
|
validateUint32(suppliedRandomSeed, 'options.randomSeed');
|
|
}
|
|
let randomize = suppliedRandomize;
|
|
let randomSeed = suppliedRandomSeed;
|
|
|
|
if (randomSeed != null) {
|
|
randomize = true;
|
|
}
|
|
if (watch) {
|
|
if (randomSeed != null) {
|
|
throw new ERR_INVALID_ARG_VALUE(
|
|
'options.randomSeed',
|
|
randomSeed,
|
|
'is not supported with watch mode',
|
|
);
|
|
}
|
|
if (randomize) {
|
|
throw new ERR_INVALID_ARG_VALUE(
|
|
'options.randomize',
|
|
randomize,
|
|
'is not supported with watch mode',
|
|
);
|
|
}
|
|
}
|
|
if (rerunFailuresFilePath) {
|
|
validatePath(rerunFailuresFilePath, 'options.rerunFailuresFilePath');
|
|
// TODO(pmarchini): Support rerun-failures with randomization by
|
|
// persisting the randomization seed in the rerun state file.
|
|
if (randomSeed != null) {
|
|
throw new ERR_INVALID_ARG_VALUE(
|
|
'options.randomSeed',
|
|
randomSeed,
|
|
'is not supported with rerun failures mode',
|
|
);
|
|
}
|
|
if (randomize) {
|
|
throw new ERR_INVALID_ARG_VALUE(
|
|
'options.randomize',
|
|
randomize,
|
|
'is not supported with rerun failures mode',
|
|
);
|
|
}
|
|
}
|
|
if (randomize) {
|
|
randomSeed ??= createRandomSeed();
|
|
}
|
|
|
|
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 (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);
|
|
});
|
|
}
|
|
|
|
let testTagFilterExpressions = null;
|
|
if (testTagFilters != null) {
|
|
if (!ArrayIsArray(testTagFilters)) {
|
|
testTagFilters = [testTagFilters];
|
|
}
|
|
if (testTagFilters.length === 0) {
|
|
testTagFilters = null;
|
|
} else {
|
|
emitExperimentalWarning('Test tags');
|
|
testTagFilters = ArrayPrototypeMap(testTagFilters, (value, i) => (
|
|
validateAndCanonicalizeTagFilter(value, `options.testTagFilters[${i}]`)
|
|
));
|
|
testTagFilterExpressions = testTagFilters;
|
|
}
|
|
}
|
|
testTagFilterExpressions ??= options.testTagFilterExpressions;
|
|
|
|
validateOneOf(isolation, 'options.isolation', ['process', 'none']);
|
|
validateBoolean(coverage, 'options.coverage');
|
|
if (coverageExcludeGlobs != null) {
|
|
if (!ArrayIsArray(coverageExcludeGlobs)) {
|
|
coverageExcludeGlobs = [coverageExcludeGlobs];
|
|
}
|
|
validateStringArray(coverageExcludeGlobs, 'options.coverageExcludeGlobs');
|
|
} else if (coverage) {
|
|
coverageExcludeGlobs = [kDefaultPattern];
|
|
}
|
|
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,
|
|
randomize,
|
|
randomSeed,
|
|
testTagFilters,
|
|
};
|
|
|
|
const root = createTestTree(rootTestOptions, globalOptions);
|
|
let testFiles = files ?? createTestFileList(globPatterns, cwd);
|
|
const { isTestRunner } = globalOptions;
|
|
|
|
if (randomize) {
|
|
root.diagnostic(`Randomized test order seed: ${randomSeed}`);
|
|
}
|
|
|
|
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,
|
|
testTagFilters,
|
|
testTagFilterExpressions,
|
|
hasFiles: files != null,
|
|
globPatterns,
|
|
only,
|
|
forceExit,
|
|
cwd,
|
|
isolation,
|
|
argv,
|
|
execArgv,
|
|
rerunFailuresFilePath,
|
|
env,
|
|
workerIdPool: isolation === 'process' ? workerIdPool : null,
|
|
randomize,
|
|
randomSeed,
|
|
testFiles,
|
|
};
|
|
|
|
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,
|
|
};
|