UNPKG

@plugjs/expect5

Version:

Unit Testing for the PlugJS Build System ========================================

328 lines (279 loc) 12 kB
import { AssertionError } from 'node:assert' import { BuildFailure } from '@plugjs/plug' import { assert } from '@plugjs/plug/asserts' import { $blu, $grn, $gry, $ms, $p, $plur, $red, $wht, $ylw, ERROR, NOTICE, WARN, githubAnnotation, log } from '@plugjs/plug/logging' import { dirnameFromUrl, filenameFromUrl } from '@plugjs/plug/paths' import { Suite, skip } from './execution/executable' import { runSuite } from './execution/executor' import * as setup from './execution/setup' import { diff } from './expectation/diff' import { expect } from './expectation/expect' import { printDiff } from './expectation/print' import { ExpectationError, stringifyValue } from './expectation/types' import type { Files } from '@plugjs/plug/files' import type { Logger } from '@plugjs/plug/logging' import type { Context, PipeParameters, Plug } from '@plugjs/plug/pipe' import type { Record } from './execution/executor' import type { TestOptions } from './index' const _pending = '\u22EF' // middle ellipsis const _success = '\u2714' // heavy check mark const _failure = '\u2718' // heavy ballot x const _details = '\u2192' // rightwards arrow /** Writes some info about the current {@link Files} being passed around. */ export class Test implements Plug<void> { constructor(...args: PipeParameters<'test'>) constructor(private readonly _options: TestOptions = {}) {} async pipe(files: Files, context: Context): Promise<void> { assert(files.length, 'No files available for running tests') const { globals = true, genericErrorDiffs = true, maxFailures = Number.POSITIVE_INFINITY, summary = false, } = this._options // Inject globals if we were told to do so... if (globals) { const anyGlobal = globalThis as any anyGlobal['describe'] = setup.describe anyGlobal['fdescribe'] = setup.fdescribe anyGlobal['xdescribe'] = setup.xdescribe anyGlobal['it'] = setup.it anyGlobal['fit'] = setup.fit anyGlobal['xit'] = setup.xit anyGlobal['afterAll'] = setup.afterAll anyGlobal['afterEach'] = setup.afterEach anyGlobal['beforeAll'] = setup.beforeAll anyGlobal['beforeEach'] = setup.beforeEach anyGlobal['xafterAll'] = setup.xafterAll anyGlobal['xafterEach'] = setup.xafterEach anyGlobal['xbeforeAll'] = setup.xbeforeAll anyGlobal['xbeforeEach'] = setup.xbeforeEach anyGlobal['skip'] = skip anyGlobal['expect'] = expect anyGlobal['log'] = log anyGlobal['dirnameFromUrl'] = dirnameFromUrl anyGlobal['filenameFromUrl'] = filenameFromUrl } // Create our _root_ Suite const suite = new Suite(undefined, '', async () => { let count = 0 for (const file of files.absolutePaths()) { log.debug('Importing', $p(file), 'in suite', $gry(`(${++ count}/${files.length})`)) await import(file) } }) // Setup our suite counts await suite.setup() const snum = suite.specs const fnum = files.length const smsg = $plur(snum, 'spec', 'specs') const fmsg = $plur(fnum, 'file', 'files') assert(snum, 'No specs configured by test files') // Run our suite and setup listeners const execution = runSuite(suite) execution.on('suite:start', (current) => { if (current.parent === suite) { if (suite.flag !== 'only') context.log.notice('') context.log.enter(NOTICE, `${$wht(current.name)}`) context.log.notice('') } else if (current.parent) { context.log.enter(NOTICE, `${$blu(_details)} ${$wht(current.name)}`) } else { context.log.notice(`Running ${smsg} from ${fmsg}`) if (suite.flag === 'only') context.log.notice('') } }) execution.on('suite:done', (current) => { if (current.parent) context.log.leave() }) execution.on('spec:start', (spec) => { context.log.enter(NOTICE, `${$blu(_pending)} ${spec.name}`) }) execution.on('spec:skip', (spec, ms) => { if (suite.flag === 'only') return context.log.leave() context.log.leave(WARN, `${$ylw(_pending)} ${spec.name} ${$ms(ms, $ylw('skipped'))}`) }) execution.on('spec:pass', (spec, ms, slow) => { if (slow) { context.log.leave(WARN, `${$ylw(_success)} ${spec.name} ${$ms(ms, $ylw('slow'))}`) } else { context.log.leave(NOTICE, `${$grn(_success)} ${spec.name} ${$ms(ms)}`) } }) execution.on('spec:fail', (spec, ms, { number }) => { context.log.leave(ERROR, `${$red(_failure)} ${spec.name} ${$ms(ms)} ` + `${$gry('[')}${$red('failed')}${$gry('|')}${$red(`${number}`)}${$gry(']')}`) }) execution.on('hook:fail', (hook, ms, { number }) => { context.log.error(`${$red(_failure)} Hook "${hook.name}" ${$ms(ms)} ` + `${$gry('[')}${$red('failed')}${$gry('|')}${$red(`${number}`)}${$gry(']')}`) }) // Await execution const { failed, passed, skipped, failures, time, records } = await execution.result // Dump all (or a limited number of) failures const limit = Math.min(failures.length, maxFailures) for (let i = 0; i < limit; i ++) { if (i === 0) context.log.error('') const { source, error, number } = failures[i]! const names: string[] = [ '' ] for (let p = source.parent; p?.parent; p = p.parent) { if (p) names.unshift(p.name) } const details = names.join(` ${$gry(_details)} `) + $wht(source.name) context.log.enter(ERROR, `${$gry('[')}${$red(number)}${$gry(']:')} ${details}`) dumpError(context.log, error, genericErrorDiffs) context.log.leave() } // Summary if (summary) { context.log.notice('') context.log.notice($wht('Test execution summary:')) records.forEach((record) => dumpRecords(context.log, record)) } // Epilogue const totals: string[] = [ `${passed} ${$gry('passed')}` ] if (skipped) totals.push(`${skipped} ${$gry('skipped')}`) if (failed) totals.push(`${failed} ${$gry('failed')}`) if (failures.length) totals.push(`${failures.length} ${$gry('total failures')}`) const epilogue = `${$gry('(')}${totals.join($gry(', '))}${$gry(')')}` const message = `Ran ${smsg} from ${fmsg} ${epilogue} ${$ms(time)}` if (failures.length) { context.log.error(message) throw new BuildFailure() } else if (suite.flag === 'only') { context.log.error('') context.log.error(message) throw new BuildFailure('Suite running in focus ("only") mode') } else if (skipped) { context.log.warn('') context.log.warn(message) } else { context.log.notice('') context.log.notice(message) } } } /* ========================================================================== * * RECORDS REPORTING * * ========================================================================== */ function dumpRecords(log: Logger, record: Record): void { if (record.type === 'suite') { log.enter(NOTICE, `${$wht(record.name)}`) for (const r of record.records) dumpRecords(log, r) log.leave() } else if (record.type === 'spec') { switch (record.result) { case 'passed': if (record.slow) { log.notice(`${$ylw(_success)} ${record.name} ${$ms(record.ms, $ylw('slow'))}`) } else { log.notice(`${$grn(_success)} ${record.name} ${$ms(record.ms)}`) } break case 'skipped': log.notice(`${$ylw(_pending)} ${record.name} ${$ms(record.ms, $ylw('skipped'))}`) break case 'failed': log.notice( `${$red(_failure)} ${record.name} ${$ms(record.ms)} ` + `${$gry('[')}${$red('failed')}${$gry('|')}${$red(`${record.failure}`)}${$gry(']')}`) break } } else if (record.type === 'hook') { log.error(`${$red(_failure)} Hook "${record.name}" ${$gry('[')}${$red('failed')}${$gry('|')}${$red(`${record.failure}`)}${$gry(']')}`) } } /* ========================================================================== * * ERROR REPORTING * * ========================================================================== */ function dumpError(log: Logger, error: any, genericErrorDiffs: boolean): void { // First and foremost, our own expectation errors if (error instanceof ExpectationError) { log.enter(ERROR, `${$gry('Expectation Error:')} ${$red(error.message)}`) githubAnnotation({ type: 'error', title: 'Expectation Error' }, error.message) try { dumpProps(log, 17, error) dumpStack(log, error) if (error.diff) printDiff(log, error.diff) } finally { log.error('') log.leave() } // Assertion errors are another kind of exception we support } else if (error instanceof AssertionError) { const [ message = 'Unknown Error', ...lines ] = error.message.split('\n') log.enter(ERROR, `${$gry('Assertion Error:')} ${$red(message)}`) githubAnnotation({ type: 'error', title: 'Assertion Error' }, message) try { dumpProps(log, 15, error) dumpStack(log, error) // If we print diffs from generic errors, we take over if (genericErrorDiffs) { // if this is a generated message ignore all extra lines if (! error.generatedMessage) for (const line of lines) log.error(' ', line) printDiff(log, diff(error.actual, error.expected)) } else { // trim initial empty lines while (lines.length && (! lines[0])) lines.shift() for (const line of lines) log.error(' ', line) } } finally { log.error('') log.leave() } // Any other error also gets printed somewhat nicely } else if (error instanceof Error) { const message = error.message || (error instanceof BuildFailure ? 'Build Failure' : 'Unknown Error') const string = Object.getPrototypeOf(error)?.constructor?.name || 'Error' // Chai calls its own assertion errors "AssertionError" const type = string === 'AssertionError' ? `${$gry('Assertion Error')}: ` : string === 'Error' ? '' : `${$gry(string)}: ` log.enter(ERROR, `${type}${$red(message)}`) githubAnnotation({ type: 'error', title: string }, message) try { dumpProps(log, type.length, error) dumpStack(log, error) // if there are "actual" or "expected" properties on the error, diff! if (genericErrorDiffs && (('actual' in error) || ('expected' in error))) { printDiff(log, diff((error as any).actual, (error as any).expected)) } } finally { log.error('') log.leave() } // Anthing else just gets dumped out... } else /* coverage ignore next */ { // This should never happen, as executor converts evertything to errors... log.error($gry('Uknown error:'), error) } } function dumpProps(log: Logger, pad: number, error: Error): void { Object.keys(error) .filter((k) => ![ 'diff', // expectations error, 'actual', // assertion error, chai 'expected', // assertion error, chai, 'generatedMessage', // assertion error, 'message', // error 'showDiff', // chai 'stack', // error ].includes(k)) .filter((k) => !(error[k as keyof typeof error] === null)) .filter((k) => !(error[k as keyof typeof error] === undefined)) .forEach((k) => { const value = error[k as keyof typeof error] if ((k === 'code') && (value === 'ERR_ASSERTION')) return const details = typeof value === 'string' ? value : stringifyValue(value) log.error($gry(`${k}:`.padStart(pad - 1)), $ylw(details)) }) } function dumpStack(log: Logger, error: Error): void { if (! error.stack) return log.error('<no stack trace>') error.stack .split('\n') .filter((line) => line.match(/^\s+at\s+/)) .map((line) => line.trim()) .forEach((line) => log.error(line)) }