UNPKG

@plugjs/expect5

Version:

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

343 lines (281 loc) 11.1 kB
import assert from 'node:assert' import { AsyncLocalStorage } from 'node:async_hooks' import { getSingleton } from '@plugjs/plug/utils' /** * A _callable_ (possibly async) function. * * When the timeout configured is reached, the passed `signal` will be aborted. */ export type Call = (this: undefined, signal: AbortSignal) => void | Promise<void> /** Flag types for an {@link Executable} */ export type Flag = 'skip' | 'only' | undefined /** An {@link Executor} notifying lifecycle events for {@link Executable}s */ export interface Executor { start(executable: Suite | Spec | Hook): { notify(error: Error): void, done(skip?: boolean): void, } } /* ========================================================================== */ /** Execute a {@link Call} invoking the {@link Done} */ function execute( call: Call, timeout: number, notify?: (error: Error) => void, ): Promise<Error | undefined> { return new Promise<Error | undefined>((resolve) => { let resolved = false /* Create the abort controller */ const abort = new AbortController() const handle = setTimeout(() => { /* coverage ignore if */ if (resolved) return const error = new Error(`Timeout of ${timeout} ms reached`) resolve(error) notify?.(error) resolved = true }, timeout).unref() /* Use a secondary promise to wrap the (possibly async) call */ void Promise.resolve().then(async () => { try { await call.call(undefined, abort.signal) resolve(undefined) resolved = true } catch (cause: any) { const error = cause instanceof Error ? cause : new Error(String(cause)) notify?.(error) resolve(error) resolved = true } finally { abort.abort('Spec finished') clearTimeout(handle) } }) }) } /* ========================================================================== */ /* Suite and skip storages must be unique _per process_ */ const suiteKey = Symbol.for('plugjs:expect5:singleton:suiteStorage') const skipKey = Symbol.for('plugjs:expect5:singleton:skipStorage') const suiteStorage = getSingleton(suiteKey, () => new AsyncLocalStorage<Suite>()) const skipStorage = getSingleton(skipKey, () => new AsyncLocalStorage<{ skipped: boolean }>()) export function getCurrentSuite(): Suite { const suite = suiteStorage.getStore() assert(suite, 'No suite found') return suite } export function skip(): void { const skipState = skipStorage.getStore() assert(skipState, 'The "skip" function can only be used in specs or hooks') skipState.skipped = true } /* ========================================================================== */ /** A symbol marking {@link Suite} instances */ const suiteMarker = Symbol.for('plugjs:expect5:types:Suite') /** Our {@link Suite} implementation */ export class Suite { private _beforeAll: Hook[] = [] private _beforeEach: Hook[] = [] private _afterAll: Hook[] = [] private _afterEach: Hook[] = [] private _suites: Suite[] = [] private _specs: Spec[] = [] private _children: (Suite | Spec)[] = [] private _setup: boolean = false constructor( public readonly parent: Suite | undefined, public readonly name: string, public readonly call: Call, public readonly timeout: number = 5000, public flag: Flag = undefined, ) {} static { (this.prototype as any)[suiteMarker] = suiteMarker } static [Symbol.hasInstance](instance: any): boolean { return instance && instance[suiteMarker] === suiteMarker } get specs(): number { return this._suites.reduce((n, s) => n + s.specs, 0) + this._specs.length } /** Add a child {@link Suite} to this */ addSuite(suite: Suite): void { assert.strictEqual(suite.parent, this, 'Suite is not a child of this') this._children.push(suite) this._suites.push(suite) } /** Add a {@link Spec} to this */ addSpec(spec: Spec): void { assert.strictEqual(spec.parent, this, 'Spec is not a child of this') this._children.push(spec) this._specs.push(spec) } /** Add a _before all_ {@link Hook} to this */ addBeforeAllHook(hook: Hook): void { assert.strictEqual(hook.parent, this, 'Hook is not a child of this') assert.strictEqual(hook.name, 'beforeAll', `Invalid before all hook name "${hook.name}"`) this._beforeAll.push(hook) } /** Add a _before each_ {@link Hook} to this */ addBeforeEachHook(hook: Hook): void { assert.strictEqual(hook.parent, this, 'Hook is not a child of this') assert.strictEqual(hook.name, 'beforeEach', `Invalid before each hook name "${hook.name}"`) this._beforeEach.push(hook) } /** Add a _after all_ {@link Hook} to this */ addAfterAllHook(hook: Hook): void { assert.strictEqual(hook.parent, this, 'Hook is not a child of this') assert.strictEqual(hook.name, 'afterAll', `Invalid after all hook name "${hook.name}"`) this._afterAll.push(hook) } /** Add a _after each_ {@link Hook} to this */ addAfterEachHook(hook: Hook): void { assert.strictEqual(hook.parent, this, 'Hook is not a child of this') assert.strictEqual(hook.name, 'afterEach', `Invalid after each hook name "${hook.name}"`) this._afterEach.push(hook) } /** * Setup this {@link Suite} invoking its main function, then initializing all * children {@link Suite Suites}, and finally normalizing execution flags. */ async setup(): Promise<void> { /* If this suite was already setup, this becomes a no-op */ if (this._setup) return /* Run the setup call */ this._setup = true await suiteStorage.run(this, async () => { const error = await execute(this.call, this.timeout) if (error) throw error }) /* Copy before and after hooks from parent */ if (this.parent) { this._beforeEach.unshift(...this.parent._beforeEach.map((h) => h.clone(this))) this._afterEach.push(...this.parent._afterEach.map((h) => h.clone(this))) } /* Setup all sub-suites of this instance */ for (const suite of this._suites) { await suite.setup() } /* Setup all before/after hooks in the spec */ for (const spec of this._specs) { spec.before.push(...this._beforeEach.map((h) => h.clone(spec))) spec.after.push(...this._afterEach.map((h) => h.clone(spec))) } /* If _any_ of this suite's children is marked as "only", then all children * not marked as such will be skipped, and this suite will also be marked * as "only" (to inform parent suites) */ const only = this._children.reduce((o, c) => o || (c.flag === 'only'), false) if (only) { this._children.forEach((c) => (c.flag !== 'only') && (c.flag = 'skip')) this.flag = 'only' } /* If _this_ suite is marked as only, any child not marked with "skip" will * be marked as "only" and included in the execution */ if (this.flag === 'only') { this._children.forEach((c) => (c.flag !== 'skip') && (c.flag = 'only')) } /* If all children are skipped, then this instance is skipped, too */ for (const child of this._children) { if (child.flag !== 'skip') return } this.flag = 'skip' } /** * Execute this suite, executing all {@link Hook hooks} and children * {@link Spec specs} and {@link Suite suites} */ async execute(executor: Executor, skip: boolean = false): Promise<Error | void> { const { done } = executor.start(this) /* Potentially skip this (and all children) */ if (skip || (this.flag === 'skip')) { for (const child of this._children) await child.execute(executor, true) return done() } /* Execute all our _before all_ hooks */ for (const hook of this._beforeAll) { const failed = await hook.execute(executor) /* Skip this (and all children on) _before all_ failure */ if (failed) { for (const child of this._children) await child.execute(executor, true) return done() } } /* Execute all our children (specs or suites) */ for (const child of this._children) await child.execute(executor) /* Execute all our _after all_ hooks (regardless of failures) */ for (const hook of this._afterAll) await hook.execute(executor) /* Done */ done() } } /* ========================================================================== */ /** A symbol marking {@link Spec} instances */ const specMarker = Symbol.for('plugjs:expect5:types:Spec') /** Our {@link Spec} implementation */ export class Spec { public before: Hook[] = [] public after: Hook[] = [] constructor( public readonly parent: Suite, public readonly name: string, public readonly call: Call, public readonly timeout: number = 5000, public flag: Flag = undefined, ) {} static { (this.prototype as any)[specMarker] = specMarker } static [Symbol.hasInstance](instance: any): boolean { return instance && instance[specMarker] === specMarker } /** Execute this spec */ async execute(executor: Executor, skip: boolean = false): Promise<void> { const { done, notify } = executor.start(this) /* Potentially skip this */ if (skip || (this.flag == 'skip')) return done(true) /* Execute all our _before each_ hooks */ for (const hook of this.before) { const failed = await hook.execute(executor) if (failed) return done(true) } /* Execute our spec */ const skipState = { skipped: false } await skipStorage.run(skipState, () => execute(this.call, this.timeout, notify)) /* Execute all our _after all_ hooks (regardless of failures) */ for (const hook of this.after) await hook.execute(executor) /* Done! */ return done(skipState.skipped) } } /* ========================================================================== */ /** A symbol marking {@link Hook} instances */ const hookMarker = Symbol.for('plugjs:expect5:types:Hook') /** Our {@link Hook} implementation */ export class Hook { constructor( public readonly parent: Suite | Spec, public readonly name: 'beforeAll' | 'afterAll' | 'beforeEach' | 'afterEach', public readonly call: Call, public readonly timeout: number = 5000, public readonly flag: Exclude<Flag, 'only'> = undefined, ) {} static { (this.prototype as any)[hookMarker] = hookMarker } static [Symbol.hasInstance](instance: any): boolean { return instance && instance[hookMarker] === hookMarker } /** Execute this hook */ async execute(executor: Executor): Promise<boolean> { if (this.flag === 'skip') return false const { done, notify } = executor.start(this) const skipState = { skipped: false } const error = await skipStorage.run(skipState, () => execute(this.call, this.timeout, notify)) done(skipState.skipped) return !! error } /** Clone this associating it with a new {@link Suite} or {@link Spec} */ clone(parent: Suite | Spec): Hook { return new Hook(parent, this.name, this.call, this.timeout, this.flag) } }