UNPKG

@plugjs/expect5

Version:

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

482 lines (403 loc) 15.2 kB
/* eslint-disable unicorn/no-instanceof-builtins */ /* eslint-disable no-fallthrough */ import { ExpectationError, isMatcher, stringifyConstructor, stringifyValue, } from './types' import type { Constructor } from './types' export interface BaseDiff { diff: boolean, error?: string, } export interface ValueDiff extends BaseDiff { value: any, } export interface ExpectedDiff extends ValueDiff { diff: true, expected: any, } export interface ExtraValueDiff extends BaseDiff { diff: true, extra: any, } export interface MissingValueDiff extends BaseDiff { diff: true, missing: any, } export interface ObjectDiff extends ValueDiff { diff: boolean, props?: Record<string, Diff>, values?: Diff[], mappings?: [ any, Diff ][] } export type Diff = | ValueDiff | ExpectedDiff | ObjectDiff | ExtraValueDiff | MissingValueDiff /* ========================================================================== * * IMPLEMENTATION INTERNALS * * ========================================================================== */ type Binary = Buffer | Uint8Array | ArrayBuffer | SharedArrayBuffer // eslint-disable-next-line @typescript-eslint/no-wrapper-object-types type BoxedPrimitive = Boolean | String | Number type Remarks = { actualMemos: any[], expectedMemos: any[], strict: boolean } /* ========================================================================== */ /** Find all enumerable keys of a number of objects */ function findEnumerableKeys(...objects: object[]): Set<string> { const keys = new Set<string>() for (const object of objects) { for (const key in object) { keys.add(key) } } return keys } /* ========================================================================== */ /** Compare two numbers, making NaNs equivalent */ function isSameNumberOrBothNaN(a: number, b: number): boolean { return a === b ? true : isNaN(a) ? isNaN(b) : false } /* ========================================================================== */ function errorDiff(value: any, message: string): ValueDiff { const error = `Expected ${stringifyValue(value)} ${message}` return { diff: true, value, error } } /* ========================================================================== */ function objectDiff<T extends Record<string, any>>( actual: T, expected: T, remarks: Remarks, keys?: Set<string>, ): ObjectDiff | ValueDiff { // default keys: all keys from both actual and expected objects if (! keys) keys = findEnumerableKeys(actual, expected) // no keys? no diff! if (! keys.size) return { diff: false, value: actual } // evaluate differences between objects let diff = false const props: Record<string, Diff> = {} for (const key of keys) { const act = actual[key] const exp = expected[key] // check for missing/extra property or differences let result: Diff if ((act === undefined) && (exp === undefined) && (! remarks.strict)) { result = { diff: false, value: undefined } } else if ((key in expected) && (!(key in actual))) { result = { diff: true, missing: exp } } else if ((key in actual) && (!(key in expected))) { result = { diff: true, extra: act } } else { result = diffValues(act, exp, remarks) } props[key] = result diff ||= result.diff } // return our differences return { diff, value: actual, props } } /* ========================================================================== */ function arrayDiff<T extends Record<number, any> & { length: number }>( actual: T, expected: T, remarks: Remarks, ): ObjectDiff { // make sure that the length of both arrays is the same if (actual.length !== expected.length) { return errorDiff(actual, `to have length ${expected.length} (length=${actual.length})`) } // prepare a set with _all_ keys from both expected and actual object const keys = findEnumerableKeys(actual, expected) // iterate through the array, checking equality for each item ad the given // index, and _removing_ the index from the set of keys to further analyse let valuesDiff = false const values = new Array<Diff>(expected.length) for (let i = 0; i < expected.length; i ++) { const result = values[i] = diffValues(actual[i], expected[i], remarks) valuesDiff = valuesDiff || result.diff keys.delete(String(i)) } // analyse the differences between leftover props (if any) const result = objectDiff(actual, expected, remarks, keys) const diff = result.diff || valuesDiff // all done! return { ...result, diff, values } } /* ========================================================================== */ function setDiff<T>( actual: Set<T>, expected: Set<T>, remarks: Remarks, ): ObjectDiff { // highlight if sets have different sizes... don't expand the returned error // as this might inject an extra "error: undefined" property in the final diff const error = actual.size === expected.size ? {} : errorDiff(actual, `to have size ${expected.size} (size=${actual.size})`) // check differences between sets const values: Diff[] = [] const missing = new Set<T>(expected) const extra = new Set<T>(actual) for (const act of extra) { for (const exp of missing) { const diff = diffValues(act, exp, remarks) if (diff.diff) continue values.push(diff) extra.delete(act) missing.delete(exp) } } // compare sets as "objects" (for extra properties) const result = objectDiff(actual, expected, remarks) const diff = !! (missing.size || extra.size || result.diff) // inject all extra and missing properties extra.forEach((value) => values.push({ diff: true, extra: value })) missing.forEach((value) => values.push({ diff: true, missing: value })) // done... return { ...error, ...result, diff, values } } /* ========================================================================== */ function mapDiff<K, V>( actual: Map<K, V>, expected: Map<K, V>, remarks: Remarks, ): ObjectDiff { // check mappings let diff = false const mappings: [ any, Diff ][] = [] for (const key of new Set([ ...actual.keys(), ...expected.keys() ])) { const act = actual.get(key) const exp = expected.get(key) if (! actual.has(key)) { mappings.push([ key, { diff: true, missing: exp } ]) diff = true } else if (! expected.has(key)) { mappings.push([ key, { diff: true, extra: act } ]) diff = true } else { const result = diffValues(act, exp, remarks) mappings.push([ key, result ]) diff = diff || result.diff } } // check other properties const result = objectDiff(actual, expected, remarks) diff = diff || result.diff // done... return { ...result, diff, mappings } } /* ========================================================================== */ function binaryDiff<T extends Binary>( actual: T, expected: T, actualData: Buffer, expectedData: Buffer, remarks: Remarks, ): ObjectDiff | ValueDiff { // make sure that the length of both arrays is the same if (actualData.length !== expectedData.length) { return errorDiff(actual, `to have length ${expectedData.length} (length=${actualData.length})`) } // remember keys const keys = findEnumerableKeys(actual, expected) // buffers have a ton of *enumerable* props we don't want to consider... if (actual instanceof Buffer) { for (const key in Buffer.prototype) { keys.delete(key) } } // check for equality const length = expectedData.length for (let i = 0; i < length; i ++) { keys.delete(String(i)) if (actualData[i] === expectedData[i]) continue let act = actualData.toString('hex', i, i + 6) let exp = expectedData.toString('hex', i, i + 6) if (act.length > 10) { act = act.substring(0, 10) + '\u2026' exp = exp.substring(0, 10) + '\u2026' } if (i > 0) { act = '\u2026' + act exp = '\u2026' + exp } return errorDiff(actual, `to equal at index ${i} (actual=${act}, expected=${exp})`) } // same contents, check extra properties return objectDiff(actual, expected, remarks, keys) } /* ========================================================================== */ function primitiveDiff<T extends BoxedPrimitive>( actual: T, expected: T, remarks: Remarks, ): ObjectDiff | ExpectedDiff | ValueDiff { if (actual.valueOf() !== expected.valueOf()) { return { diff: true, value: actual, expected: expected, } } // remove string indexes from properties const keys = findEnumerableKeys(actual, expected) if (actual instanceof String) { const length = actual.valueOf().length for (let i = 0; i < length; i ++) { keys.delete(String(i)) } } // return either an object diff or no diff at all... return keys.size ? objectDiff(actual, expected, remarks, keys) : { diff: false, value: actual, } } /* ========================================================================== * * MAIN "DIFF" * * ========================================================================== */ function diffValues(actual: any, expected: any, remarks: Remarks): Diff { // strict equality if (expected === actual) return { diff: false, value: expected } // oh, javascript! if (expected === null) { return { diff: true, value: actual, expected: null, } } // matchers! if (isMatcher(expected)) { try { expected.expect(actual) return { diff: false, value: actual } } catch (error) { if (error instanceof ExpectationError) { // if the error highlights a difference, simply return that // otherwise wrap the error into a new ErrorDiff and return it const { message, diff } = error return diff?.diff ? diff : { diff: true, value: actual, error: message } } else { throw error } } } // inspect types of what to compare const actualType = typeof actual const expectedType = typeof expected // if type is different then highlight the difference if (actualType !== expectedType) { return { diff: true, value: actual, expected: expected, } } // primitives switch (actualType) { // numbers are one-of-a-kind as NaN !== NaN case 'number': if (isSameNumberOrBothNaN(actual, expected)) { return { diff: false, value: NaN } } // primitives always must be strict === case 'bigint': case 'boolean': case 'function': case 'string': case 'symbol': case 'undefined': return { diff: true, value: actual, expected: expected, } // everything else is an object and must be checked } // check for cyclical dependencies, see node's commit here: // https://github.com/nodejs/node/commit/d3aafd02efd3a403d646a3044adcf14e63a88d32 const actualIndex = remarks.actualMemos.indexOf(actual) if (actualIndex !== -1) { if (actualIndex === remarks.expectedMemos.indexOf(expected)) { return { diff: false, value: actual } } } remarks.actualMemos.push(actual) remarks.expectedMemos.push(expected) // check that actual is _assignable_ from expected const prototype = Object.getPrototypeOf(expected) if (prototype && prototype.constructor) { if (! (actual instanceof prototype.constructor)) { return { ...errorDiff(actual, `to be instance of ${stringifyConstructor(prototype.constructor)}`), diff: true, expected, } } } /* == ARRAYS ============================================================== */ const checkInstance = <T>( ctor: Constructor<T>, callback: (actual: T, expected: T, remarks: Remarks) => Diff, ): Diff | undefined => (expected instanceof ctor) ? callback(actual as InstanceType<typeof ctor>, expected, remarks) : (actual instanceof ctor) ? { diff: true, value: actual, expected } : undefined return ( /* == ARRAYS ============================================================ */ checkInstance(Array, arrayDiff) || /* == SETS ============================================================== */ checkInstance(Set, (act, exp) => setDiff(act, exp, remarks)) || /* == MAPS ============================================================== */ checkInstance(Map, (act, exp) => mapDiff(act, exp, remarks)) || /* == BOXED PRIMITIVES ================================================== */ checkInstance(Boolean, primitiveDiff) || checkInstance(String, primitiveDiff) || checkInstance(Number, primitiveDiff) || /* == PROMISES (always error, must be ===) ============================== */ checkInstance(Promise, (act, exp) => errorDiff(act, `to strictly equal ${stringifyValue(exp)}`)) || /* == DATES ============================================================= */ checkInstance(Date, (act, exp) => (! isSameNumberOrBothNaN(act.getTime(), exp.getTime())) ? { diff: true, value: act, expected: exp, } : objectDiff(act, exp, remarks)) || /* == REGULAR EXPRESSIONS =============================================== */ checkInstance(RegExp, (act, exp) => ((act.source !== exp.source) || (act.flags !== exp.flags)) ? { diff: true, value: act, expected: exp, } : objectDiff(act, exp, remarks)) || /* == BINARY ARRAYS ===================================================== */ checkInstance(Buffer, (act, exp) => binaryDiff(act, exp, act, exp, remarks)) || checkInstance(Uint8Array, (act, exp) => binaryDiff(act, exp, Buffer.from(act), Buffer.from(exp), remarks)) || checkInstance(ArrayBuffer, (act, exp) => binaryDiff(act, exp, Buffer.from(act), Buffer.from(exp), remarks)) || checkInstance(SharedArrayBuffer, (act, exp) => binaryDiff(act, exp, Buffer.from(act), Buffer.from(exp), remarks)) || /* == OTHER TYPED ARRAYS ================================================ */ checkInstance(BigInt64Array, arrayDiff) || checkInstance(BigUint64Array, arrayDiff) || checkInstance(Float32Array, arrayDiff) || checkInstance(Float64Array, arrayDiff) || checkInstance(Int16Array, arrayDiff) || checkInstance(Int32Array, arrayDiff) || checkInstance(Int8Array, arrayDiff) || checkInstance(Uint16Array, arrayDiff) || checkInstance(Uint32Array, arrayDiff) || checkInstance(Uint8ClampedArray, arrayDiff) || /* == DONE ============================================================== */ objectDiff(actual as any, expected, remarks)) } /* ========================================================================== * * EXPORTS * * ========================================================================== */ export function diff(actual: any, expected: any, strict: boolean = false): Diff { return diffValues(actual, expected, { actualMemos: [], expectedMemos: [], strict }) }