nstdlib-nightly
Version:
Node.js standard library converted to runtime-agnostic ES modules.
752 lines (688 loc) • 21.4 kB
JavaScript
// Source: https://github.com/nodejs/node/blob/65eff1eb/lib/internal/test_runner/runner.js
import { spawn } from "nstdlib/lib/child_process";
import { finished } from "nstdlib/lib/internal/streams/end-of-stream";
import { resolve } from "nstdlib/lib/path";
import { DefaultDeserializer, DefaultSerializer } from "nstdlib/lib/v8";
import { Interface } from "nstdlib/lib/internal/readline/interface";
import { deserializeError } from "nstdlib/lib/internal/error_serdes";
import { Buffer } from "nstdlib/lib/buffer";
import { FilesWatcher } from "nstdlib/lib/internal/watch_mode/files_watcher";
import * as console from "nstdlib/lib/internal/console/global";
import { codes as __codes__ } from "nstdlib/lib/internal/errors";
import {
validateArray,
validateBoolean,
validateFunction,
validateObject,
validateInteger,
} from "nstdlib/lib/internal/validators";
import {
getInspectPort,
isUsingInspector,
isInspectorMessage,
} from "nstdlib/lib/internal/util/inspector";
import { isRegExp } from "nstdlib/lib/internal/util/types";
import { kEmptyObject } from "nstdlib/lib/internal/util";
import { kEmitMessage } from "nstdlib/lib/internal/test_runner/tests_stream";
import { createTestTree } from "nstdlib/lib/internal/test_runner/harness";
import {
kAborted,
kCancelledByParent,
kSubtestsFailed,
kTestCodeFailure,
kTestTimeoutFailure,
Test,
} from "nstdlib/lib/internal/test_runner/test";
import {
convertStringToRegExp,
countCompletedTest,
kDefaultPattern,
parseCommandLine,
} from "nstdlib/lib/internal/test_runner/utils";
import { Glob } from "nstdlib/lib/internal/fs/glob";
import { once } from "nstdlib/lib/events";
import {
triggerUncaughtException,
exitCodes as __exitCodes__,
} from "nstdlib/stub/binding/errors";
import * as __hoisted_internal_event_target__ from "nstdlib/lib/internal/event_target";
const { ERR_INVALID_ARG_TYPE, ERR_INVALID_ARG_VALUE, ERR_TEST_FAILURE } =
__codes__;
const { kGenericUserError } = __exitCodes__;
const kFilterArgs = ["--test", "--experimental-test-coverage", "--watch"];
const kFilterArgValues = ["--test-reporter", "--test-reporter-destination"];
const kDiagnosticsFilterArgs = [
"tests",
"suites",
"pass",
"fail",
"cancelled",
"skipped",
"todo",
"duration_ms",
];
const kCanceledTests = new Set()
.add(kCancelledByParent)
.add(kAborted)
.add(kTestTimeoutFailure);
let kResistStopPropagation;
function createTestFileList(patterns) {
const cwd = process.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 &&
Array.prototype.every.call(glob.matchers, (m) => !m.hasMagic())
) {
console.error(
`Could not find '${Array.prototype.join.call(patterns, ", ")}'`,
);
process.exit(kGenericUserError);
}
return Array.prototype.sort.call(results);
}
function filterExecArgv(arg, i, arr) {
return (
!Array.prototype.includes.call(kFilterArgs, arg) &&
!Array.prototype.some.call(
kFilterArgValues,
(p) =>
arg === p ||
(i > 0 && arr[i - 1] === p) ||
String.prototype.startsWith.call(arg, `${p}=`),
)
);
}
function getRunArgs(
path,
{ forceExit, inspectPort, testNamePatterns, testSkipPatterns, only },
) {
const argv = Array.prototype.filter.call(process.execArgv, filterExecArgv);
if (forceExit === true) {
Array.prototype.push.call(argv, "--test-force-exit");
}
if (isUsingInspector()) {
Array.prototype.push.call(
argv,
`--inspect-port=${getInspectPort(inspectPort)}`,
);
}
if (testNamePatterns != null) {
Array.prototype.forEach.call(testNamePatterns, (pattern) =>
Array.prototype.push.call(argv, `--test-name-pattern=${pattern}`),
);
}
if (testSkipPatterns != null) {
Array.prototype.forEach.call(testSkipPatterns, (pattern) =>
Array.prototype.push.call(argv, `--test-skip-pattern=${pattern}`),
);
}
if (only === true) {
Array.prototype.push.call(argv, "--test-only");
}
Array.prototype.push.call(argv, path);
return argv;
}
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),
};
}
#skipReporting() {
return (
this.#reportedChildren > 0 &&
(!this.error || this.error.failureType === kSubtestsFailed)
);
}
#checkNestedComment(comment) {
const firstSpaceIndex = String.prototype.indexOf.call(comment, " ");
if (firstSpaceIndex === -1) return false;
const secondSpaceIndex = String.prototype.indexOf.call(
comment,
" ",
firstSpaceIndex + 1,
);
return (
secondSpaceIndex === -1 &&
Array.prototype.includes.call(
kDiagnosticsFilterArgs,
String.prototype.slice.call(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) {
Array.prototype.forEach.call(this.#reportBuffer, (ast) =>
this.#handleReportItem(ast),
);
this.#reportBuffer = [];
}
}
addToReport(item) {
this.#accumulateReportItem(item);
if (!this.isClearToSend()) {
Array.prototype.push.call(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 {
Array.prototype.push.call(this.#rawBuffer, readData);
}
this.#rawBufferSize += dataLength;
this.#proccessRawBuffer();
if (partialV8Header) {
Array.prototype.push.call(
this.#rawBuffer,
TypedArrayPrototypeSubarray(v8Header, 0, 1),
);
this.#rawBufferSize++;
}
}
#drainRawBuffer() {
while (this.#rawBuffer.length > 0) {
this.#proccessRawBuffer();
}
}
#proccessRawBuffer() {
// 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 = Buffer.alloc(0);
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) {
Array.prototype.shift.call(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 subtest = opts.root.createSubtest(
FileTest,
path,
{ __proto__: null, signal: opts.signal },
async (t) => {
const args = getRunArgs(path, opts);
const stdio = ["pipe", "pipe", "pipe"];
const env = {
__proto__: null,
...process.env,
NODE_TEST_CONTEXT: "child-v8",
};
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,
});
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 Promise.all([
once(child, "exit", { __proto__: null, signal: t.signal }),
finished(child.stdout, { __proto__: null, signal: t.signal }),
]);
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 = Object.assign(
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 Map();
const runningSubtests = new Map();
const watcher = new FilesWatcher({
__proto__: null,
debounce: 200,
mode: "filter",
signal: opts.signal,
});
const filesWatcher = {
__proto__: null,
watcher,
runningProcesses,
runningSubtests,
};
opts.root.harness.watching = true;
watcher.on("changed", ({ owners, eventType }) => {
if (!opts.hasFiles && eventType === "rename") {
const updatedTestFiles = createTestFileList(opts.globPatterns);
const newFileName = Array.prototype.find.call(
updatedTestFiles,
(x) => !Array.prototype.includes.call(testFiles, x),
);
const previousFileName = Array.prototype.find.call(
testFiles,
(x) => !Array.prototype.includes.call(updatedTestFiles, x),
);
testFiles = updatedTestFiles;
// When file renamed
if (newFileName && previousFileName) {
owners = new Set().add(newFileName);
watcher.filterFile(resolve(newFileName), owners);
}
if (!newFileName && previousFileName) {
return; // Avoid rerunning files when file deleted
}
}
watcher.unfilterFilesOwnedBy(owners);
Promise.prototype.then.call(
Promise.all(
testFiles,
async (file) => {
if (!owners.has(file)) {
return;
}
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));
},
undefined,
(error) => {
triggerUncaughtException(error, true /* fromPromise */);
},
),
);
});
if (opts.signal) {
kResistStopPropagation ??=
__hoisted_internal_event_target__.kResistStopPropagation;
opts.signal.addEventListener(
"abort",
() => {
opts.root.harness.watching = false;
opts.root.postRun();
},
{ __proto__: null, once: true, [kResistStopPropagation]: true },
);
}
return filesWatcher;
}
function run(options = kEmptyObject) {
validateObject(options, "options");
let { testNamePatterns, testSkipPatterns, shard } = options;
const {
concurrency,
timeout,
signal,
files,
forceExit,
inspectPort,
watch,
setup,
only,
globPatterns,
} = 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 (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 (!Array.isArray(testNamePatterns)) {
testNamePatterns = [testNamePatterns];
}
testNamePatterns = Array.prototype.map.call(
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 (!Array.isArray(testSkipPatterns)) {
testSkipPatterns = [testSkipPatterns];
}
testSkipPatterns = Array.prototype.map.call(
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);
},
);
}
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.
};
const root = createTestTree(rootTestOptions, globalOptions);
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;
}
let testFiles = files ?? createTestFileList(globPatterns);
if (shard) {
testFiles = Array.prototype.filter.call(
testFiles,
(_, index) => index % shard.total === shard.index - 1,
);
}
let postRun = () => root.postRun();
let teardown = () => root.harness.teardown();
let filesWatcher;
const opts = {
__proto__: null,
root,
signal,
inspectPort,
testNamePatterns,
testSkipPatterns,
hasFiles: files != null,
globPatterns,
only,
forceExit,
};
if (watch) {
filesWatcher = watchFiles(testFiles, opts);
postRun = undefined;
teardown = undefined;
}
const runFiles = () => {
root.harness.bootstrapPromise = null;
root.harness.allowTestsToRun = true;
return Promise.allSettled(testFiles, (path) => {
const subtest = runTestFile(path, filesWatcher, opts);
filesWatcher?.runningSubtests.set(path, subtest);
return subtest;
});
};
const setupPromise = Promise.resolve(setup?.(root.reporter));
Promise.prototype.then.call(
Promise.prototype.then.call(
Promise.prototype.then.call(setupPromise, runFiles),
postRun,
),
teardown,
);
return root.reporter;
}
export { FileTest };
export { run };