const sameObject = require('same-object')
const tmp = require('test-tmp')
const b4a = require('b4a')
const { getSnapshot, createTypedArray } = require('./lib/snapshot')
const { INDENT, RUNNER, IS_NODE, IS_BARE, DEFAULT_TIMEOUT } = require('./lib/constants')
const AssertionError = require('./lib/assertion-error')
const TracingPromise = require('./lib/tracing-promise')
const Promise = TracingPromise.Untraced // never trace internal onces
const highDefTimer = IS_NODE ? highDefTimerNode : highDefTimerFallback
const program = IS_NODE ? process : global.Bare
// loaded on demand since it's error flow and we want ultra fast positive test runs
const lazy = {
_errors: null,
_tmatch: null,
get errors () {
if (!lazy._errors) lazy._errors = require('./lib/errors.js')
return lazy._errors
},
get tmatch () {
if (!lazy._tmatch) lazy._tmatch = require('tmatch')
return lazy._tmatch
}
}
class Runner {
constructor () {
this.tests = { count: 0, pass: 0 }
this.assertions = { count: 0, pass: 0 }
this.next = null
this.solos = new Set()
this.padded = true
this.started = false
this.defaultTimeout = DEFAULT_TIMEOUT
this.bail = false
this.unstealth = false
this.skipAll = false
this.explicitSolo = false
this.source = true
this._timer = highDefTimer()
this._log = console.log.bind(console)
this._paused = null
this._resume = null
const ondeadlock = () => {
if (this.next && this.next._checkDeadlock === false) return
program.off('beforeExit', ondeadlock)
this.end()
}
program.on('beforeExit', ondeadlock)
}
resume () {
if (!this._paused) return
this._resume()
this._resume = this._paused = null
}
pause () {
if (this._paused) return
this._paused = new Promise((resolve) => { this._resume = resolve })
}
async _wait () {
await wait()
await this._paused
}
async queue (test) {
this.start()
if (test._isSolo) {
this.solos.add(test)
}
await this._wait()
if (this.explicitSolo && !test._isSolo) {
return false
}
if (this._shouldTest(test)) {
while (this.next !== null) {
const next = this.next
await next
if (next === this.next) this.next = null
}
if (test._isSkip) {
this._skip('SKIP', test)
return false
}
if (test._isTodo) {
this._skip('TODO', test)
return false
}
if (!this._shouldTest(test)) {
return false
}
this.next = test
test._header()
if (!IS_NODE && !IS_BARE) this._autoExit(test)
return true
}
return false
}
_skip (reason, test) {
if (this._shouldTest(test)) {
test._header()
this.tests.pass++
this.tests.count++
this.assert(false, true, this.tests.count, '- ' + test.name + ' # ' + reason, null)
}
}
_shouldTest (test) {
return test._isHook || (!this.skipAll && (this.solos.size === 0 || this.solos.has(test)))
}
async _autoExit (test) {
try {
await test
await wait(10) // wait 10 ticks...
if (this.next === test) {
this.end()
}
} catch {}
}
log (...message) {
this._log(...message)
this.padded = false
}
padding () {
if (this.padded) return
this.padded = true
this.log()
}
start () {
if (this.started) return
this.started = true
this.log('TAP version 13')
}
comment (...message) {
this.log('#', ...message)
}
end () {
if (this.next) {
if (!this.next._isEnded && !(this.next._hasPlan && this.next._planned === 0)) {
this.next._onend(prematureEnd(this.next, 'Test did not end (' + this.next.name + ')'))
return
}
if (!this.next._isResolved) {
if (this.next._isDone) {
this.next._onend(new Error('Teardown did not end (unresolved promise)'))
return
}
this.next._onend(new Error('Test appears deadlocked (unresolved promise)'))
return
}
}
if (this.bail && this.skipAll) {
this.log('Bail out!')
}
this.padding()
this.log('1..' + this.tests.count)
this.log('# tests = ' + this.tests.pass + '/' + this.tests.count + ' pass')
this.log('# asserts = ' + this.assertions.pass + '/' + this.assertions.count + ' pass')
this.log('# time = ' + this._timer() + 'ms')
this.log()
if (this.tests.count === this.tests.pass && this.assertions.count === this.assertions.pass) this.log('# ok')
else this.log('# not ok')
}
assert (indent, ok, number, message, explanation, stealth) {
const ind = indent ? INDENT : ''
if (ok) {
if (!stealth || this.unstealth) this.log(ind + 'ok ' + number, message)
} else {
if (IS_NODE) process.exitCode = 1
if (IS_BARE) global.Bare.exitCode = 1
this.log(ind + 'not ok ' + number, message)
if (explanation) this.log(lazy.errors.stringify(explanation))
if (this.bail && !this.skipAll) this.skipAll = true
if (!this.unstealth && stealth) throw new AssertionError({ message: 'Stealth assertion failed' })
}
}
}
class Test {
constructor (name, parent, opts = {}) {
this._resolve = null
this._reject = null
this._promise = new Promise((resolve, reject) => {
this._resolve = resolve
this._reject = reject
})
this._parents = []
this._main = parent ? parent._main : this
this._runner = getRunner()
this.name = name
this.passes = 0
this.fails = 0
this.assertions = 0
this._isEnded = false
this._isDone = false
this._isHook = opts?.hook || false
this._isSolo = opts?.solo || false
this._isSkip = opts?.skip || false
this._isTodo = opts?.todo || false
this._isResolved = false
this._isQueued = false
this._isMain = this._main === this
this._isStealth = opts?.stealth || parent?._isStealth || false
this._checkDeadlock = opts?.deadlock !== false
// allow destructuring by binding the functions
this.comment = this._comment.bind(this)
this.timeout = this._timeout.bind(this)
this.teardown = this._teardown.bind(this)
this.test = this._test.bind(this)
this.plan = this._plan.bind(this)
this.pass = this._pass.bind(this)
this.fail = this._fail.bind(this)
this.ok = this._ok.bind(this)
this.absent = this._absent.bind(this)
this.is = this._is.bind(this, true)
this.is.coercively = this._is.bind(this, false)
this.not = this._not.bind(this, true)
this.not.coercively = this._not.bind(this, false)
this.alike = this._alike.bind(this, true)
this.alike.coercively = this._alike.bind(this, false)
this.unlike = this._unlike.bind(this, true)
this.unlike.coercively = this._unlike.bind(this, false)
this.exception = this._exception.bind(this, false)
this.exception.all = this._exception.bind(this, true)
this.execution = this._execution.bind(this)
this.stealth = this._stealth.bind(this)
this.snapshot = this._snapshot.bind(this)
this.end = this._end.bind(this)
this._parent = parent
this._first = true
this._wait = false
this._planned = 0
this._hasPlan = false
this._active = 0
this._timer = null
this._headerLogged = false
this._to = null
this._teardowns = []
this._tickers = new Map()
while (parent) {
this._parents.push(parent)
parent = parent._parent
}
}
then (...args) {
return this._promise.then(...args)
}
catch (...args) {
return this._promise.catch(...args)
}
finally (...args) {
return this._promise.finally(...args)
}
_header () {
if (this._headerLogged) return
this._headerLogged = true
this._runner.start()
this._runner.padding()
this._runner.comment(this.name || 'test')
}
tmp () { return tmp(this) }
_planDoneOrEnd () {
return this._isEnded || (this._hasPlan && this._planned === 0)
}
_timeout (ms) {
if (!ms) {
if (this._to) clearTimeout(this._to)
this._to = null
return
}
const ontimeout = () => {
this._to = null
this._onend(new Error('Test timed out after ' + ms + ' ms'))
}
if (this._to) clearTimeout(this._to)
this._to = setTimeout(ontimeout, ms)
if (this._to.unref) this._to.unref()
}
_plan (n) {
if (typeof n !== 'number' || n < 0) {
throw new Error('Plan takes a positive whole number only')
}
this._hasPlan = true
this._planned = n
}
_comment (...m) {
if (this._isResolved) throw new Error('Can\'t comment after end')
this._runner.log(INDENT + '#', ...m)
}
_message (message) {
let m = '- '
if (!this._isMain) {
for (let i = this._parents.length - 2; i >= 0; i--) {
const p = this._parents[i]
if (!p.name) continue
m += '(' + p.name + ') - '
}
if (this.name) {
m += '(' + this.name + ') - '
}
}
if (message) {
m += message
}
return m
}
_tick (ok) {
if (ok) this.passes++
else this.fails++
this.assertions++
}
_track (topLevel, ok) {
if (topLevel) {
this._runner.tests.count++
if (ok) this._runner.tests.pass++
return this._runner.tests.count
}
if (this._hasPlan) this._planned--
this._tick(ok)
if (!this._isMain) this._main._tick(ok)
this._runner.assertions.count++
if (ok) this._runner.assertions.pass++
return this._main.assertions
}
_assertion (ok, message, explanation, caller, top, isStealth = this._isStealth) {
this._runner.assert(!this._main._isResolved, ok, this._track(false, ok), this._message(message), explanation, isStealth)
if (this._isEnded || this._isDone) {
throw new AssertionError({ message: 'Assertion after end' })
}
if (this._hasPlan && this._planned < 0) {
throw new AssertionError({ message: 'Too many assertions' })
}
if (this._hasPlan && this._planned === 0) {
this._checkEnd()
}
}
_fail (message = 'failed') {
const explanation = explain(false, message, 'fail', this._fail)
this._assertion(false, message, explanation, this._fail, undefined)
}
_pass (message = 'passed') {
this._assertion(true, message, null, this._pass, undefined)
}
_ok (assertion, message = 'expected truthy value') {
const ok = assertion
const explanation = explain(ok, message, 'ok', this._ok)
this._assertion(ok, message, explanation, this._ok, undefined)
}
_absent (assertion, message = 'expected falsy value') {
const ok = !assertion
const explanation = explain(ok, message, 'absent', this._absent)
this._assertion(ok, message, explanation, this._absent, undefined)
}
_is (strict, actual, expected, message = 'should be equal') {
const ok = strict ? actual === expected : actual == expected // eslint-disable-line
const explanation = explain(ok, message, 'is', this._is, actual, expected)
this._assertion(ok, message, explanation, this._is, undefined)
}
_not (strict, actual, expected, message = 'should not be equal') {
const ok = strict ? actual !== expected : actual != expected // eslint-disable-line
const explanation = explain(ok, message, 'not', this._not, actual, expected)
this._assertion(ok, message, explanation, this._not, undefined)
}
_alike (strict, actual, expected, message = 'should deep equal') {
const ok = sameObject(actual, expected, { strict })
const explanation = explain(ok, message, 'alike', this._alike, actual, expected)
this._assertion(ok, message, explanation, this._alike, undefined)
}
_unlike (strict, actual, expected, message = 'should not deep equal') {
const ok = sameObject(actual, expected, { strict }) === false
const explanation = explain(ok, message, 'unlike', this._unlike, actual, expected)
this._assertion(ok, message, explanation, this._unlike, undefined)
}
_teardown (fn, opts = {}) {
if (this._isDone) throw new Error('Can\'t add teardown after end')
this._teardowns.push([opts.order || 0, !!opts.force, fn])
}
async _exception (natives, functionOrPromise, expectedError, message) {
if (typeof expectedError === 'string') {
message = expectedError
expectedError = undefined
}
const top = originFrame(this._exception)
const pristineMessage = message === undefined
let ok = null
let actual = false
if (pristineMessage) message = 'should throw'
this._active++
try {
if (typeof functionOrPromise === 'function') functionOrPromise = functionOrPromise()
if (isPromise(functionOrPromise)) {
if (pristineMessage) message = 'should reject'
await functionOrPromise
}
ok = false
} catch (err) {
const native = natives === false && isUncaught(err)
if (native) throw err
if (!expectedError) {
ok = true
} else {
ok = lazy.tmatch(err, expectedError)
}
actual = err
} finally {
this._active--
}
const explanation = explain(ok, message, 'exception', this._exception, actual, expectedError, top)
this._assertion(ok, message, explanation, this._execution, top)
this._checkEnd()
}
async _execution (functionOrPromise, message) {
const top = originFrame(this._execution)
const pristineMessage = message === undefined
let ok = false
let error = null
if (pristineMessage) message = 'should return'
const time = highDefTimer()
this._active++
try {
if (typeof functionOrPromise === 'function') functionOrPromise = functionOrPromise()
if (isPromise(functionOrPromise)) {
if (pristineMessage) message = 'should resolve'
await functionOrPromise
}
ok = true
} catch (err) {
error = err
} finally {
this._active--
}
const elapsed = time()
const explanation = explain(ok, message, 'execution', this._execution, error, null, top)
this._assertion(ok, message, explanation, this._execution, top)
this._checkEnd()
return elapsed
}
_stealth (name, opts, fn) {
if (typeof name === 'function') return this.stealth(null, null, name)
if (typeof opts === 'function') return this.stealth(name, null, opts)
return this.test(name, { ...opts, stealth: true }, fn)
}
_snapshot (actual, message = 'should match snapshot') {
const top = originFrame(this._snapshot)
if (!top) {
this._assertion(true, message, null, this._snapshot, undefined)
return
}
if (b4a.isBuffer(actual)) {
actual = new Uint8Array(actual.buffer, actual.byteOffset, actual.byteLength)
}
const filename = top.getFileName()
const key = (this.name || '') + ' ' + this._message(message)
const expected = getSnapshot(filename, key + ' - ' + this._getTick(key), actual)
const ok = sameObject(actual, expected, { strict: true })
const explanation = explain(ok, message, 'snapshot', this._snapshot, actual, expected)
this._assertion(ok, message, explanation, this._snapshot, undefined)
}
_getTick (key) {
const tick = this._tickers.get(key) || 0
this._tickers.set(key, 1 + tick)
return tick
}
_test (name, opts, fn) {
if (typeof name === 'function') return this.test(null, null, name)
if (typeof opts === 'function') return this.test(name, null, opts)
const t = new Test(name, this, opts)
if (this._hasPlan) this._planned--
this._active++
return fn ? t._run(fn, opts || {}) : t
}
async _run (fn, opts) {
this._isQueued = true
if (!this._parent) {
if (!(await this._runner.queue(this))) return
}
this._onstart(opts)
this._wait = true
try {
await fn(this)
} catch (err) {
if (!(err instanceof AssertionError && err.message === 'ERR_ASSERTION: Stealth assertion failed')) {
this._wait = false
await this._runTeardown(err)
throw err
}
}
if (!this._hasPlan) this.end()
this._wait = false
this._checkEnd()
return await this
}
_end () {
this._isEnded = true
if (this._hasPlan && this._planned > 0) {
throw prematureEnd(this, 'Too few assertions')
}
this._checkEnd()
}
_checkEnd () {
if (this._active || this._wait) return
if (this._isEnded || (this._hasPlan && this._planned === 0)) this._done()
}
_done () {
if (this._isDone) return
this._isDone = true
if (this._teardowns.length) {
this._teardowns.sort(cmp)
this._runTeardown(null)
} else {
this._onend(null)
}
if (this._parent) {
const p = this._parent
this._parent._active--
this._parent = null
p._checkEnd()
}
}
async _runTeardown (error) {
const forced = !!error
let fired = false
const t = setTimeout(() => {
fired = true
this.comment('...teardown still running after 250ms')
}, 250)
if (t.unref) t.unref()
const time = highDefTimer()
for (const [, force, teardown] of this._teardowns) {
try {
if (force || !forced) await teardown()
} catch (err) {
if (!error) error = err
}
}
clearTimeout(t)
if (fired) this.comment('...teardown time ' + time() + 'ms')
this._onend(error)
}
_onstart (opts) {
const to = this._isMain
? (opts && opts.timeout !== undefined) ? opts.timeout : this._runner.defaultTimeout // main tests need a default timeout, unless opt-out
: opts && opts.timeout // non main ones do not
if (this._isMain) {
if (!this._isQueued) {
if (this._runner.next) throw new Error('Only run test can be running at the same time')
this._runner.next = this
}
this._header()
this._timer = highDefTimer()
}
if (to) this._timeout(to)
}
_onend (err) {
if (this._isResolved) return
this._timeout(0) // just to be sure incase someone ran this during teardown...
const ok = (this.fails === 0)
if (this._isMain && !err) {
const time = this._timer ? ' # time = ' + this._timer() + 'ms' : ''
this._runner.assert(false, ok, this._track(true, ok), '- ' + (this.name || '') + time, null)
}
this._isResolved = true
this._isDone = true
if (this._isMain && this._runner.next === this) {
this._runner.next = null
}
if (err) this._reject(err)
else this._resolve(ok)
// if test is running without deadlock detection, trigger "io" to rerun it in case idle now
if (this._checkDeadlock === false) setImmediate(() => {})
}
}
exports = module.exports = test
exports.Test = Test
exports.test = test
exports.hook = hook
exports.solo = solo
exports.skip = skip
exports.todo = todo
exports.configure = configure
exports.pause = pause
exports.resume = resume
exports.stealth = stealth
// Used by snapshots
exports.createTypedArray = createTypedArray
function configure ({ timeout = DEFAULT_TIMEOUT, bail = false, solo = false, unstealth = false, source = true, coverage = false } = {}) {
const runner = getRunner()
if (runner.tests.count > 0 || runner.assertions.count > 0) {
throw new Error('Configuration must happen prior to registering any tests')
}
if (coverage) require('bare-cov')({ dir: typeof coverage === 'string' ? coverage : undefined })
runner.defaultTimeout = timeout
runner.bail = bail
runner.explicitSolo = solo
runner.unstealth = unstealth
runner.source = source
}
function highDefTimerNode () {
const then = process.hrtime.bigint()
return function () {
const now = process.hrtime.bigint()
return Number(now - then) / 1e6
}
}
function highDefTimerFallback () {
const then = Date.now()
return function () {
const now = Date.now()
return now - then
}
}
function cmp (a, b) {
return a[0] - b[0]
}
function test (name, opts, fn, overrides) {
if (typeof name === 'function') return test(null, null, name, overrides)
if (typeof opts === 'function') return test(name, null, opts, overrides)
opts = { ...opts, ...overrides }
const t = new Test(name, null, opts)
if (fn) return t._run(fn, opts)
if (t._isTodo) return t._run(() => {}, opts)
if (t._isSkip) {
throw new Error('An inverted test cannot be skipped')
}
if (t._isSolo) {
t._runner.solo = t
}
t._onstart(opts)
return t
}
function hook (name, opts, fn) {
return test(name, opts, fn, { hook: true })
}
function solo (name, opts, fn) {
if (!name && !opts && !fn) return test.configure({ solo: true })
return test(name, opts, fn, { solo: true })
}
function skip (name, opts, fn) {
return test(name, opts, fn, { skip: true })
}
function todo (name, opts, fn) {
return test(name, opts, fn, { todo: true })
}
function pause () {
getRunner().pause()
}
function resume () {
getRunner().resume()
}
function wait (ticks = 1) {
return new Promise(resolve => {
tickish(function loop () {
if (--ticks <= 0) return resolve()
tickish(loop)
})
})
}
function tickish (fn) {
if (IS_NODE) { // do both types of tick in node to flush both queues
process.nextTick(queueMicrotask, fn)
} else {
queueMicrotask(fn)
}
}
function explain (ok, message, assert, stackStartFunction, actual, expected, top = !ok && originFrame(stackStartFunction), extra) {
const runner = getRunner()
return ok ? null : lazy.errors.explain(ok, message, assert, stackStartFunction, actual, expected, runner.source ? top : null, extra)
}
function originFrame (stackStartFunction) {
if (!Error.captureStackTrace) return undefined
const { prepareStackTrace } = Error
Error.prepareStackTrace = (_, stack) => {
if (stack[0].getFunctionName() === '[brittle.error]') return null
if (stack[0].getMethodName() === 'coercively') return stack[1]
return stack[0]
}
const err = {}
Error.captureStackTrace(err, stackStartFunction)
const { stack: top } = err
Error.prepareStackTrace = prepareStackTrace
return top
}
function isPromise (p) {
return !!(p && typeof p.then === 'function')
}
function isUncaught (err) {
return err instanceof SyntaxError ||
err instanceof ReferenceError ||
err instanceof TypeError ||
err instanceof EvalError ||
err instanceof RangeError
}
function getRunner () {
if (!global[RUNNER]) global[RUNNER] = new Runner()
return global[RUNNER]
}
function prematureEnd (t, message) {
const details = t._hasPlan
? ' [assertion count (' + t.assertions + ') did not reach plan (' + (t.assertions + t._planned) + ')]'
: ''
return new Error(message + details)
}
function stealth (name, opts, fn) {
return test(name, opts, fn, { stealth: true })
}