UNPKG

nstdlib-nightly

Version:

Node.js standard library converted to runtime-agnostic ES modules.

752 lines (688 loc) 21.4 kB
// 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 };