@applicvision/js-toolbox
Version:
A collection of tools for modern JavaScript development
454 lines (384 loc) • 9.83 kB
JavaScript
/**
* @typedef {(context: {artifact: object, artifacts: unknown[]}) => void} TestFunction
* @typedef {{skip?: boolean, todo?: boolean, only?: boolean}} TestOptions
* @typedef {{timeout?: number} & TestOptions} TestUnitOptions
*/
class TestRunnable {
skip = false
todo = false
only = false
#suite
path = ''
level = 0
startTime = 0
endTime = 0
/**
* @param {string} description
* @param {TestOptions} [options]
*/
constructor(description, options) {
this.description = description
this.skip = options?.skip ?? false
this.todo = options?.todo ?? false
this.only = options?.only ?? false
}
set suite(suite) {
this.#suite = suite
this.path = `${suite.path}/${this.description}`
this.level = suite.level + 1
}
get suite() {
return this.#suite
}
get shouldRun() {
return !this.skip && !this.todo
}
get hasRun() {
return Boolean(this.startTime) && Boolean(this.endTime)
}
get duration() {
const duration = this.endTime && this.endTime - this.startTime
if (typeof duration == 'bigint') {
return Number(duration) / 1e6
}
return duration
}
run() {
// implemented by subclasses
}
matchesFilter({ filter, only } = {}) {
if (only) {
return this.only
}
return (!filter ||
filter.every(regex => regex.test(this.path))
)
}
}
/**
* @typedef {{before: (() => void)[], beforeEach: (() => void)[], after: (() => void)[], afterEach: (() => void)[]}} Hooks
*/
class TestSuite extends TestRunnable {
#components = []
/** @type {Error?} */
error = null
/** @type {Hooks} */
#hooks = {
before: [],
beforeEach: [],
after: [],
afterEach: []
}
status = {
passed: 0,
skipped: 0,
failed: 0,
get totalRun() {
const { passed, skipped, failed } = this
return passed + skipped + failed
}
}
get progress() {
// console.log('getting progress', this.description)
return this.status.totalRun / this.totalTests
}
report(status) {
Object.entries(status)
.forEach(([type, change]) => this.status[type] += change)
this.suite?.report(status)
this.loggerInstance?.updateProgress?.()
}
resolveComponentsToRun(filter) {
let totalTests = 0
this.componentsToRun = this.#components.filter(component => {
const matchesFilter = component.matchesFilter(filter)
let matchedTests = 0
if (component instanceof TestSuite) {
matchedTests = component.resolveComponentsToRun(matchesFilter ? undefined : filter)
} else if (matchesFilter) {
matchedTests = 1
}
totalTests += matchedTests
return matchedTests
})
this.totalTests = totalTests
return totalTests
}
async run(options = {}) {
const { bail, timeFunction = Date.now } = options
const suiteLogger = getRootSuite().logger.suiteLogger(this)
this.loggerInstance = suiteLogger
if (!this.shouldRun) {
return
}
this.startTime = timeFunction()
await this.#runHooks('before', suiteLogger)
for (const component of this.componentsToRun) {
await this.#runHooks('beforeEach', suiteLogger)
await component.run(options)
await this.#runHooks('afterEach', suiteLogger)
if (component.error) {
this.error = component.error
if (bail) {
break
}
}
}
await this.#runHooks('after', suiteLogger)
this.endTime = timeFunction()
suiteLogger?.finished?.()
}
/**
* @param {keyof Hooks} hookType
* @param {{startHooks?: Function, finishedHook?: Function, finishedHooks?: Function}} logger
*/
async #runHooks(hookType, logger) {
const hooks = this.#hooks[hookType]
if (hooks.length == 0) { return }
logger?.startHooks?.(hookType, hooks.length)
for (let index = 0; index < hooks.length; index += 1) {
await hooks[index]()
logger?.finishedHook?.(hookType, index + 1, hooks.length)
}
logger?.finishedHooks?.(hookType)
}
addComponent(component) {
this.#components.push(component)
component.suite = this
}
get failed() {
return this.#components.some(component => component.failed)
}
addBeforeHook(hookFunction) { this.#hooks.before.push(hookFunction) }
addBeforeEachHook(hookFunction) { this.#hooks.beforeEach.push(hookFunction) }
addAfterHook(hookFunction) { this.#hooks.after.push(hookFunction) }
addAfterEachHook(hookFunction) { this.#hooks.afterEach.push(hookFunction) }
}
export class RootSuite extends TestSuite {
testContextStack = []
logger
constructor(logger = new DefaultLogger()) {
super('root')
this.logger = logger
}
async addTestFile(filename, content) {
const suite = new TestSuite(filename)
this.addComponent(suite)
this.testContextStack.push(suite)
await content()
this.testContextStack.pop()
}
addTestUnit(test) {
const { currentSuite } = this
if (currentSuite) {
currentSuite.addComponent(test)
} else {
test.run()
}
}
addTestSuite(suite, suiteFunction) {
const { currentSuite } = this
if (currentSuite) {
currentSuite.addComponent(suite)
}
this.testContextStack.push(suite)
suiteFunction(suite)
this.testContextStack.pop()
if (!currentSuite) {
suite.resolveComponentsToRun({})
suite.run()
}
return currentSuite == this
}
get currentSuite() {
return this.testContextStack.at(-1)
}
async run(options) {
const { only, filter, ...otherOptions } = options
this.resolveComponentsToRun({ filter, only })
for (const component of this.componentsToRun) {
await component.run(otherOptions)
if (component.error) {
this.error = component.error
if (otherOptions.bail) {
break
}
}
}
}
}
export const getRootSuite = (() => {
let rootSuite
return (logger) => {
if (!rootSuite) {
rootSuite = new RootSuite(logger)
}
return rootSuite
}
})()
export class TestTimeoutError extends Error {
constructor(timeout) {
super(`Test execution time exceeded time limit (${timeout} ms)`)
this.timeout = timeout
}
}
export class DefaultLogger {
/**
* @param {TestSuite} suite the suite to log
*/
suiteLogger(suite) {
console.log()
console.group(suite.description)
return {
finished() {
console.groupEnd()
console.log()
}
}
}
/**
* @param {TestUnit} test - The test to log
*/
testLogger(test) {
if (test.skip || test.todo) return console.log('Skip', test.description)
return {
finished() {
if (test.error) {
console.log('✗', test.description)
console.log(test.error)
} else {
console.log('✓', test.description)
}
}
}
}
}
class TestUnit extends TestRunnable {
timeout = 4_000
artifact = {}
artifacts = []
/**
* @param {string} description
* @param {TestUnitOptions} options
* @param {TestFunction} [testFunction]
*/
constructor(description, options, testFunction = () => { }) {
super(description, options)
this.timeout = options.timeout ?? 4_000
this.testFunction = testFunction
}
get testInterface() {
return {
artifact: this.artifact,
artifacts: this.artifacts
}
}
async run({ timeFunction = Date.now } = {}) {
const logger = getRootSuite().logger.testLogger(this)
if (!this.shouldRun) {
this.suite.report(this.status)
return
}
this.startTime = timeFunction()
let timeoutId
try {
await Promise.race([
this.testFunction(this.testInterface),
new Promise((_, reject) => timeoutId = setTimeout(
reject,
this.timeout,
new TestTimeoutError(this.timeout)
))
])
} catch (error) {
this.error = error
}
clearTimeout(timeoutId)
this.endTime = timeFunction()
logger.finished()
this.suite.report(this.status)
}
get status() {
return {
passed: this.error ? 0 : 1,
skipped: this.shouldRun ? 0 : 1,
failed: this.error ? 1 : 0
}
}
get failed() {
return Boolean(this.error)
}
}
/**
* @overload
* @param {string} description
* @param {TestUnitOptions} options
* @param {TestFunction} [testFunction]
* @returns {void}
*/
/**
* @overload
* @param {string} description
* @param {TestFunction} [testFunction]
* @returns {void}
*/
/**
* @param {string} description
* @param {TestUnitOptions|TestFunction} [optionsOrFunction]
* @param {TestFunction} [testFunction]
* @this {TestUnitOptions|undefined}
*/
export function test(description, optionsOrFunction, testFunction) {
if (typeof optionsOrFunction == 'function') {
testFunction = optionsOrFunction
optionsOrFunction = {}
}
const test = new TestUnit(description, { ...optionsOrFunction, ...this }, testFunction)
getRootSuite().addTestUnit(test)
}
test.skip = test.bind({ skip: true })
test.todo = test.bind({ todo: true })
test.only = test.bind({ only: true })
export const it = test
/**
* @overload
* @param {string} description
* @param {TestOptions} options
* @param {TestFunction} [testFunction]
* @returns {void}
*/
/**
* @overload
* @param {string} description
* @param {TestFunction} [testFunction]
* @returns {void}
*/
/**
* @param {string} description
* @param {TestOptions|TestFunction} [optionsOrFunction]
* @param {TestFunction} [suiteFunction]
* @this {TestOptions|undefined}
*/
export function describe(description, optionsOrFunction, suiteFunction) {
if (typeof optionsOrFunction == 'function') {
suiteFunction = optionsOrFunction
optionsOrFunction = {}
}
const suite = new TestSuite(description, { ...optionsOrFunction, ...this })
getRootSuite().addTestSuite(suite, suiteFunction)
}
describe.skip = describe.bind({ skip: true })
describe.todo = describe.bind({ todo: true })
describe.only = describe.bind({ only: true })
export function before(hookFunction) {
getRootSuite().currentSuite?.addBeforeHook(hookFunction)
}
export function beforeEach(hookFunction) {
getRootSuite().currentSuite?.addBeforeEachHook(hookFunction)
}
export function after(hookFunction) {
getRootSuite().currentSuite?.addAfterHook(hookFunction)
}
export function afterEach(hookFunction) {
getRootSuite().currentSuite?.addAfterEachHook(hookFunction)
}