@plugjs/expect5
Version:
Unit Testing for the PlugJS Build System ========================================
482 lines (403 loc) • 15.2 kB
text/typescript
/* 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 })
}