UNPKG

@applicvision/js-toolbox

Version:

A collection of tools for modern JavaScript development

454 lines (384 loc) 9.83 kB
/** * @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) }