UNPKG

test

Version:

Node.js 18's node:test, as an npm package

818 lines (695 loc) 22.3 kB
// https://github.com/nodejs/node/blob/9eb363a3e00dbba572756c7ed314273f17ea8e2e/lib/internal/test_runner/test.js 'use strict' const { ArrayPrototypeMap, ArrayPrototypePush, ArrayPrototypeReduce, ArrayPrototypeShift, ArrayPrototypeSlice, ArrayPrototypeSome, ArrayPrototypeUnshift, FunctionPrototype, MathMax, Number, ObjectSeal, PromisePrototypeThen, PromiseResolve, ReflectApply, RegExpPrototypeExec, SafeMap, SafeSet, SafePromiseAll, SafePromiseRace, Symbol } = require('#internal/per_context/primordials') const { AsyncResource } = require('async_hooks') const { once } = require('events') const { AbortController } = require('#internal/abort_controller') const { codes: { ERR_INVALID_ARG_TYPE, ERR_TEST_FAILURE }, AbortError } = require('#internal/errors') const { getOptionValue } = require('#internal/options') const { MockTracker } = require('#internal/test_runner/mock') const { TestsStream } = require('#internal/test_runner/tests_stream') const { convertStringToRegExp, createDeferredCallback, isTestFailureError } = require('#internal/test_runner/utils') const { createDeferredPromise, kEmptyObject, once: runOnce } = require('#internal/util') const { isPromise } = require('#internal/util/types') const { validateAbortSignal, validateNumber, validateOneOf, validateUint32 } = require('#internal/validators') const { setTimeout } = require('#timers/promises') const { TIMEOUT_MAX } = require('#internal/timers') const { cpus, availableParallelism } = require('os') const { bigint: hrtime } = process.hrtime const kCallbackAndPromisePresent = 'callbackAndPromisePresent' const kCancelledByParent = 'cancelledByParent' const kParentAlreadyFinished = 'parentAlreadyFinished' const kSubtestsFailed = 'subtestsFailed' const kTestCodeFailure = 'testCodeFailure' const kTestTimeoutFailure = 'testTimeoutFailure' const kHookFailure = 'hookFailed' const kDefaultTimeout = null const noop = FunctionPrototype const isTestRunner = getOptionValue('--test') const testOnlyFlag = !isTestRunner && getOptionValue('--test-only') const testNamePatternFlag = isTestRunner ? null : getOptionValue('--test-name-pattern') const testNamePatterns = testNamePatternFlag?.length > 0 ? ArrayPrototypeMap( testNamePatternFlag, (re) => convertStringToRegExp(re, '--test-name-pattern') ) : null const kShouldAbort = Symbol('kShouldAbort') const kFilename = process.argv?.[1] const kHookNames = ObjectSeal(['before', 'after', 'beforeEach', 'afterEach']) const kUnwrapErrors = new SafeSet() .add(kTestCodeFailure).add(kHookFailure) .add('uncaughtException').add('unhandledRejection') function stopTest (timeout, signal) { if (timeout === kDefaultTimeout) { return once(signal, 'abort') } return PromisePrototypeThen(setTimeout(timeout, null, { ref: false, signal }), () => { throw new ERR_TEST_FAILURE( `test timed out after ${timeout}ms`, kTestTimeoutFailure ) }) } class TestContext { #test constructor (test) { this.#test = test } get signal () { return this.#test.signal } get name () { return this.#test.name } diagnostic (message) { this.#test.diagnostic(message) } get mock () { this.#test.mock = this.#test.mock ?? new MockTracker() return this.#test.mock } runOnly (value) { this.#test.runOnlySubtests = !!value } skip (message) { this.#test.skip(message) } todo (message) { this.#test.todo(message) } test (name, options, fn) { // eslint-disable-next-line no-use-before-define const subtest = this.#test.createSubtest(Test, name, options, fn) return subtest.start() } after (fn, options) { this.#test.createHook('after', fn, options) } beforeEach (fn, options) { this.#test.createHook('beforeEach', fn, options) } afterEach (fn, options) { this.#test.createHook('afterEach', fn, options) } } class Test extends AsyncResource { #abortController #outerSignal #reportedSubtest constructor (options) { super('Test') let { fn, name, parent, skip } = options const { concurrency, only, timeout, todo, signal } = options if (typeof fn !== 'function') { fn = noop } if (typeof name !== 'string' || name === '') { name = fn.name || '<anonymous>' } if (!(parent instanceof Test)) { parent = null } if (parent === null) { this.concurrency = 1 this.nesting = 0 this.only = testOnlyFlag this.reporter = new TestsStream() this.runOnlySubtests = this.only this.testNumber = 0 this.timeout = kDefaultTimeout } else { const nesting = parent.parent === null ? parent.nesting : parent.nesting + 1 this.concurrency = parent.concurrency this.nesting = nesting this.only = only ?? !parent.runOnlySubtests this.reporter = parent.reporter this.runOnlySubtests = !this.only this.testNumber = parent.subtests.length + 1 this.timeout = parent.timeout } switch (typeof concurrency) { case 'number': validateUint32(concurrency, 'options.concurrency', 1) this.concurrency = concurrency break case 'boolean': if (concurrency) { this.concurrency = parent === null ? MathMax((typeof availableParallelism === 'undefined' ? cpus().length : availableParallelism()) - 1, 1) : Infinity } else { this.concurrency = 1 } break default: if (concurrency != null) throw new ERR_INVALID_ARG_TYPE('options.concurrency', 'a number or boolean', concurrency) } if (timeout != null && timeout !== Infinity) { validateNumber(timeout, 'options.timeout', 0, TIMEOUT_MAX) this.timeout = timeout } if (testNamePatterns !== null) { // eslint-disable-next-line no-use-before-define const match = this instanceof TestHook || ArrayPrototypeSome( testNamePatterns, (re) => RegExpPrototypeExec(re, name) !== null ) if (!match) { skip = 'test name does not match pattern' } } if (testOnlyFlag && !this.only) { skip = '\'only\' option not set' } if (skip) { fn = noop } this.#abortController = new AbortController() this.#outerSignal = signal this.signal = this.#abortController.signal validateAbortSignal(signal, 'options.signal') this.#outerSignal?.addEventListener('abort', this.#abortHandler) this.fn = fn this.mock = null this.name = name this.parent = parent this.cancelled = false this.skipped = !!skip this.isTodo = !!todo this.startTime = null this.endTime = null this.passed = false this.error = null this.diagnostics = [] this.message = typeof skip === 'string' ? skip : typeof todo === 'string' ? todo : null this.activeSubtests = 0 this.pendingSubtests = [] this.readySubtests = new SafeMap() this.subtests = [] this.hooks = { before: [], after: [], beforeEach: [], afterEach: [] } this.waitingOn = 0 this.finished = false } hasConcurrency () { return this.concurrency > this.activeSubtests } addPendingSubtest (deferred) { ArrayPrototypePush(this.pendingSubtests, deferred) } async processPendingSubtests () { while (this.pendingSubtests.length > 0 && this.hasConcurrency()) { const deferred = ArrayPrototypeShift(this.pendingSubtests) await deferred.test.run() deferred.resolve() } } addReadySubtest (subtest) { this.readySubtests.set(subtest.testNumber, subtest) } processReadySubtestRange (canSend) { const start = this.waitingOn const end = start + this.readySubtests.size for (let i = start; i < end; i++) { const subtest = this.readySubtests.get(i) // Check if the specified subtest is in the map. If it is not, return // early to avoid trying to process any more tests since they would be // out of order. if (subtest === undefined) { return } // Call isClearToSend() in the loop so that it is: // - Only called if there are results to report in the correct order. // - Guaranteed to only be called a maximum of once per call to // processReadySubtestRange(). canSend = canSend || this.isClearToSend() if (!canSend) { return } if (i === 1 && this.parent !== null) { this.reportStarted() } // Report the subtest's results and remove it from the ready map. subtest.finalize() this.readySubtests.delete(i) } } createSubtest (Factory, name, options, fn, overrides) { if (typeof name === 'function') { fn = name } else if (name !== null && typeof name === 'object') { fn = options options = name } else if (typeof options === 'function') { fn = options } if (options === null || typeof options !== 'object') { options = kEmptyObject } let parent = this // If this test has already ended, attach this test to the root test so // that the error can be properly reported. const preventAddingSubtests = this.finished || this.buildPhaseFinished if (preventAddingSubtests) { while (parent.parent !== null) { parent = parent.parent } } const test = new Factory({ __proto__: null, fn, name, parent, ...options, ...overrides }) if (parent.waitingOn === 0) { parent.waitingOn = test.testNumber } if (preventAddingSubtests) { test.startTime = test.startTime || hrtime() test.fail( new ERR_TEST_FAILURE( 'test could not be started because its parent finished', kParentAlreadyFinished ) ) } ArrayPrototypePush(parent.subtests, test) return test } #abortHandler = () => { this.cancel(this.#outerSignal?.reason || new AbortError('The test was aborted')) } cancel (error) { if (this.endTime !== null) { return } this.fail(error || new ERR_TEST_FAILURE( 'test did not finish before its parent and was cancelled', kCancelledByParent ) ) this.startTime = this.startTime || this.endTime // If a test was canceled before it was started, e.g inside a hook this.cancelled = true this.#abortController.abort() } createHook (name, fn, options) { validateOneOf(name, 'hook name', kHookNames) // eslint-disable-next-line no-use-before-define const hook = new TestHook(fn, options) ArrayPrototypePush(this.hooks[name], hook) return hook } fail (err) { if (this.error !== null) { return } this.endTime = hrtime() this.passed = false this.error = err } pass () { if (this.endTime !== null) { return } this.endTime = hrtime() this.passed = true } skip (message) { this.skipped = true this.message = message } todo (message) { this.isTodo = true this.message = message } diagnostic (message) { ArrayPrototypePush(this.diagnostics, message) } start () { // If there is enough available concurrency to run the test now, then do // it. Otherwise, return a Promise to the caller and mark the test as // pending for later execution. if (!this.parent.hasConcurrency()) { const deferred = createDeferredPromise() deferred.test = this this.parent.addPendingSubtest(deferred) return deferred.promise } return this.run() } [kShouldAbort] () { if (this.signal.aborted) { return true } if (this.#outerSignal?.aborted) { this.cancel(this.#outerSignal.reason || new AbortError('The test was aborted')) return true } } getRunArgs () { const ctx = new TestContext(this) return { ctx, args: [ctx] } } async runHook (hook, args) { validateOneOf(hook, 'hook name', kHookNames) try { await ArrayPrototypeReduce(this.hooks[hook], async (prev, hook) => { await prev await hook.run(args) if (hook.error) { throw hook.error } }, PromiseResolve()) } catch (err) { const error = new ERR_TEST_FAILURE(`failed running ${hook} hook`, kHookFailure) error.cause = isTestFailureError(err) ? err.cause : err throw error } } async run () { if (this.parent !== null) { this.parent.activeSubtests++ } this.startTime = hrtime() if (this[kShouldAbort]()) { this.postRun() return } const { args, ctx } = this.getRunArgs() const after = runOnce(async () => { if (this.hooks.after.length > 0) { await this.runHook('after', { args, ctx }) } }) const afterEach = runOnce(async () => { if (this.parent?.hooks.afterEach.length > 0) { await this.parent.runHook('afterEach', { args, ctx }) } }) try { if (this.parent?.hooks.beforeEach.length > 0) { await this.parent.runHook('beforeEach', { args, ctx }) } const stopPromise = stopTest(this.timeout, this.signal) const runArgs = ArrayPrototypeSlice(args) ArrayPrototypeUnshift(runArgs, this.fn, ctx) if (this.fn.length === runArgs.length - 1) { // This test is using legacy Node.js error first callbacks. const { promise, cb } = createDeferredCallback() ArrayPrototypePush(runArgs, cb) const ret = ReflectApply(this.runInAsyncScope, this, runArgs) if (isPromise(ret)) { this.fail(new ERR_TEST_FAILURE( 'passed a callback but also returned a Promise', kCallbackAndPromisePresent )) await SafePromiseRace([ret, stopPromise]) } else { await SafePromiseRace([PromiseResolve(promise), stopPromise]) } } else { // This test is synchronous or using Promises. const promise = ReflectApply(this.runInAsyncScope, this, runArgs) await SafePromiseRace([PromiseResolve(promise), stopPromise]) } if (this[kShouldAbort]()) { this.postRun() return } await after() await afterEach() this.pass() } catch (err) { try { await after() } catch { /* Ignore error. */ } try { await afterEach() } catch { /* test is already failing, let's the error */ } if (isTestFailureError(err)) { if (err.failureType === kTestTimeoutFailure) { this.cancel(err) } else { this.fail(err) } } else { this.fail(new ERR_TEST_FAILURE(err, kTestCodeFailure)) } } // Clean up the test. Then, try to report the results and execute any // tests that were pending due to available concurrency. this.postRun() } postRun (pendingSubtestsError) { const counters = { __proto__: null, failed: 0, passed: 0, cancelled: 0, skipped: 0, todo: 0, totalFailed: 0 } // If the test was failed before it even started, then the end time will // be earlier than the start time. Correct that here. if (this.endTime < this.startTime) { this.endTime = hrtime() } if (this.startTime == null) this.startTime = this.endTime // The test has run, so recursively cancel any outstanding subtests and // mark this test as failed if any subtests failed. this.pendingSubtests = [] for (let i = 0; i < this.subtests.length; i++) { const subtest = this.subtests[i] if (!subtest.finished) { subtest.cancel(pendingSubtestsError) subtest.postRun(pendingSubtestsError) } // Check SKIP and TODO tests first, as those should not be counted as // failures. if (subtest.skipped) { counters.skipped++ } else if (subtest.isTodo) { counters.todo++ } else if (subtest.cancelled) { counters.cancelled++ } else if (!subtest.passed) { counters.failed++ } else { counters.passed++ } if (!subtest.passed) { counters.totalFailed++ } } if ((this.passed || this.parent === null) && counters.totalFailed > 0) { const subtestString = `subtest${counters.totalFailed > 1 ? 's' : ''}` const msg = `${counters.totalFailed} ${subtestString} failed` this.fail(new ERR_TEST_FAILURE(msg, kSubtestsFailed)) } this.#outerSignal?.removeEventListener('abort', this.#abortHandler) this.mock?.reset() if (this.parent !== null) { this.parent.activeSubtests-- this.parent.addReadySubtest(this) this.parent.processReadySubtestRange(false) this.parent.processPendingSubtests() } else if (!this.reported) { this.reported = true this.reporter.plan(this.nesting, kFilename, this.subtests.length) for (let i = 0; i < this.diagnostics.length; i++) { this.reporter.diagnostic(this.nesting, kFilename, this.diagnostics[i]) } this.reporter.diagnostic(this.nesting, kFilename, `tests ${this.subtests.length}`) this.reporter.diagnostic(this.nesting, kFilename, `pass ${counters.passed}`) this.reporter.diagnostic(this.nesting, kFilename, `fail ${counters.failed}`) this.reporter.diagnostic(this.nesting, kFilename, `cancelled ${counters.cancelled}`) this.reporter.diagnostic(this.nesting, kFilename, `skipped ${counters.skipped}`) this.reporter.diagnostic(this.nesting, kFilename, `todo ${counters.todo}`) this.reporter.diagnostic(this.nesting, kFilename, `duration_ms ${this.#duration()}`) this.reporter.push(null) } } isClearToSend () { return this.parent === null || ( this.parent.waitingOn === this.testNumber && this.parent.isClearToSend() ) } finalize () { // By the time this function is called, the following can be relied on: // - The current test has completed or been cancelled. // - All of this test's subtests have completed or been cancelled. // - It is the current test's turn to report its results. // Report any subtests that have not been reported yet. Since all of the // subtests have finished, it's safe to pass true to // processReadySubtestRange(), which will finalize all remaining subtests. this.processReadySubtestRange(true) // Output this test's results and update the parent's waiting counter. this.report() this.parent.waitingOn++ this.finished = true } #duration () { // Duration is recorded in BigInt nanoseconds. Convert to milliseconds. return Number(this.endTime - this.startTime) / 1_000_000 } report () { if (this.subtests.length > 0) { this.reporter.plan(this.subtests[0].nesting, kFilename, this.subtests.length) } else { this.reportStarted() } let directive const details = { __proto__: null, duration_ms: this.#duration() } if (this.skipped) { directive = this.reporter.getSkip(this.message) } else if (this.isTodo) { directive = this.reporter.getTodo(this.message) } if (this.passed) { this.reporter.ok(this.nesting, kFilename, this.testNumber, this.name, details, directive) } else { details.error = this.error this.reporter.fail(this.nesting, kFilename, this.testNumber, this.name, details, directive) } for (let i = 0; i < this.diagnostics.length; i++) { this.reporter.diagnostic(this.nesting, kFilename, this.diagnostics[i]) } } reportStarted () { if (this.#reportedSubtest || this.parent === null) { return } this.#reportedSubtest = true this.parent.reportStarted() this.reporter.start(this.nesting, kFilename, this.name) } } class TestHook extends Test { #args constructor (fn, options) { if (options === null || typeof options !== 'object') { options = kEmptyObject } const { timeout, signal } = options super({ __proto__: null, fn, timeout, signal }) } run (args) { this.#args = args return super.run() } getRunArgs () { return this.#args } postRun () { } } class ItTest extends Test { constructor (opt) { super(opt) } // eslint-disable-line no-useless-constructor getRunArgs () { return { ctx: { signal: this.signal, name: this.name }, args: [] } } } class Suite extends Test { constructor (options) { super(options) try { const { ctx, args } = this.getRunArgs() this.buildSuite = PromisePrototypeThen( PromiseResolve(this.runInAsyncScope(this.fn, ctx, args)), undefined, (err) => { this.fail(new ERR_TEST_FAILURE(err, kTestCodeFailure)) }) } catch (err) { this.fail(new ERR_TEST_FAILURE(err, kTestCodeFailure)) } this.fn = () => {} this.buildPhaseFinished = true } getRunArgs () { return { ctx: { signal: this.signal, name: this.name }, args: [] } } async run () { const hookArgs = this.getRunArgs() const afterEach = runOnce(async () => { if (this.parent?.hooks.afterEach.length > 0) { await this.parent.runHook('afterEach', hookArgs) } }) try { this.parent.activeSubtests++ await this.buildSuite this.startTime = hrtime() if (this[kShouldAbort]()) { this.subtests = [] this.postRun() return } if (this.parent?.hooks.beforeEach.length > 0) { await this.parent.runHook('beforeEach', hookArgs) } await this.runHook('before', hookArgs) const stopPromise = stopTest(this.timeout, this.signal) const subtests = this.skipped || this.error ? [] : this.subtests const promise = SafePromiseAll(subtests, (subtests) => subtests.start()) await SafePromiseRace([promise, stopPromise]) await this.runHook('after', hookArgs) await afterEach() this.pass() } catch (err) { try { await afterEach() } catch { /* test is already failing, let's the error */ } if (isTestFailureError(err)) { this.fail(err) } else { this.fail(new ERR_TEST_FAILURE(err, kTestCodeFailure)) } } this.postRun() } } module.exports = { ItTest, kCancelledByParent, kSubtestsFailed, kTestCodeFailure, kUnwrapErrors, Suite, Test }