diff --git a/src/js/internal/primordials.js b/src/js/internal/primordials.js index e8be2528d218..53f7e1b59864 100644 --- a/src/js/internal/primordials.js +++ b/src/js/internal/primordials.js @@ -171,6 +171,7 @@ export default { ), SetPrototypeGetSize: getGetter(Set, "size"), String, + TypedArrayPrototypeGetByteLength: getGetter(Uint8Array, "byteLength"), TypedArrayPrototypeGetLength: getGetter(Uint8Array, "length"), TypedArrayPrototypeGetSymbolToStringTag: getGetter(Uint8Array, Symbol.toStringTag), Uint8ClampedArray, diff --git a/src/js/internal/util/comparisons.ts b/src/js/internal/util/comparisons.ts new file mode 100644 index 000000000000..3b092f274c96 --- /dev/null +++ b/src/js/internal/util/comparisons.ts @@ -0,0 +1,1058 @@ +// Ported from Node.js lib/internal/util/comparisons.js (v26.3.0). +// https://github.com/nodejs/node/blob/v26.3.0/lib/internal/util/comparisons.js +// +// This implements the comparison algorithm documented for +// `assert.deepEqual()`, `assert.deepStrictEqual()` and +// `assert.partialDeepStrictEqual()`. It intentionally does not share code with +// `Bun.deepEquals()` (the Jest `expect().toEqual()` algorithm): the two differ +// on prototype identity, `RegExp#lastIndex`, `Error#cause` / `errors`, +// objects with unobservable state (Promise, WeakMap, WeakSet), boxed +// primitives, sparse arrays and the `==` coercion used by the legacy +// `assert.deepEqual()`. +"use strict"; + +const { + SafeSet, + TypedArrayPrototypeGetByteLength, + TypedArrayPrototypeGetSymbolToStringTag, +} = require("internal/primordials"); + +const { + isAnyArrayBuffer, + isArrayBufferView, + isBigIntObject, + isBooleanObject, + isBoxedPrimitive, + isCryptoKey, + isDate, + isFloat16Array, + isFloat32Array, + isFloat64Array, + isKeyObject, + isMap, + isNativeError, + isNumberObject, + isPromise, + isRegExp, + isSet, + isStringObject, + isSymbolObject, + isWeakMap, + isWeakSet, +} = require("node:util/types"); + +const ArrayIsArray = Array.isArray; +const ArrayPrototypePush = Array.prototype.push; +const BigIntPrototypeValueOf = BigInt.prototype.valueOf; +const BooleanPrototypeValueOf = Boolean.prototype.valueOf; +const BufferCompare = Buffer.compare; +const DatePrototypeGetTime = Date.prototype.getTime; +const ErrorIsError = Error.isError; +const NumberPrototypeValueOf = Number.prototype.valueOf; +const ObjectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +const ObjectGetOwnPropertySymbols = Object.getOwnPropertySymbols; +const ObjectGetPrototypeOf = Object.getPrototypeOf; +const ObjectIs = Object.is; +const ObjectKeys = Object.keys; +const ObjectPrototypeHasOwnProperty = Object.prototype.hasOwnProperty; +const ObjectPrototypePropertyIsEnumerable = Object.prototype.propertyIsEnumerable; +const ObjectPrototypeToString = Object.prototype.toString; +const StringPrototypeValueOf = String.prototype.valueOf; +const SymbolPrototypeValueOf = Symbol.prototype.valueOf; +const SymbolToStringTag = Symbol.toStringTag; +const URLConstructor = URL; + +// Node's `internalBinding('util').getOwnNonIndexProperties`: the own property +// keys of `obj` excluding array indices, honoring the PropertyFilter bits +// below. Implemented natively so large arrays and typed arrays do not pay for +// materializing every index key. +const getOwnNonIndexProperties: (obj: object, filter: number) => PropertyKey[] = $newCppFunction( + "UtilInspect.cpp", + "jsFunctionGetOwnNonIndexProperties", + 2, +); + +// Matches V8's `PropertyFilter` values used by Node's +// `internalBinding('util').getOwnNonIndexProperties`. +const ONLY_ENUMERABLE = 2; +const SKIP_SYMBOLS = 16; + +const wellKnownConstructors = new SafeSet() + .add(Array) + .add(ArrayBuffer) + .add(BigInt) + .add(BigInt64Array) + .add(BigUint64Array) + .add(Boolean) + .add(Buffer) + .add(DataView) + .add(Date) + .add(Error) + .add(Float16Array) + .add(Float32Array) + .add(Float64Array) + .add(Function) + .add(Int16Array) + .add(Int32Array) + .add(Int8Array) + .add(Map) + .add(Number) + .add(Object) + .add(Promise) + .add(RegExp) + .add(Set) + .add(String) + .add(Symbol) + .add(Uint16Array) + .add(Uint32Array) + .add(Uint8Array) + .add(Uint8ClampedArray) + .add(WeakMap) + .add(WeakSet); + +const kStrict = 2; +const kStrictWithoutPrototypes = 3; +const kLoose = 0; +const kPartial = 1; + +const kNoIterator = 0; +const kIsArray = 1; +const kIsSet = 2; +const kIsMap = 3; + +// `node:crypto` is only loaded once a KeyObject or CryptoKey is compared. +let KeyObject; + +function areEqualKeyObjects(a, b) { + KeyObject ??= require("node:crypto").KeyObject; + // KeyObject#equals compares the key type and the underlying key material. + return KeyObject.prototype.equals.$call(a, b); +} + +function areEqualCryptoKeys(a, b, mode, memos) { + KeyObject ??= require("node:crypto").KeyObject; + return ( + a.type === b.type && + a.extractable === b.extractable && + innerDeepEqual(a.algorithm, b.algorithm, mode, memos) && + innerDeepEqual(a.usages, b.usages, mode, memos) && + KeyObject.from(a).equals(KeyObject.from(b)) + ); +} + +function hasOwn(obj, key) { + return ObjectPrototypeHasOwnProperty.$call(obj, key); +} + +function hasEnumerable(obj, key) { + return ObjectPrototypePropertyIsEnumerable.$call(obj, key); +} + +// `isError` from Node's `internal/util`: native errors (including cross-realm +// ones) or anything inheriting from `Error`. +function isError(e) { + return ErrorIsError(e) || e instanceof Error; +} + +// `isURL` from Node's `internal/url`. +function isURL(value) { + return value instanceof URLConstructor; +} + +// `internalBinding('buffer').compare` equivalent: a byte-wise memcmp of two +// ArrayBufferViews (not necessarily Uint8Arrays). +function compareViewBytes(a, b) { + return BufferCompare( + new Uint8Array(a.buffer, a.byteOffset, a.byteLength), + new Uint8Array(b.buffer, b.byteOffset, b.byteLength), + ); +} + +// Check if they have the same source and flags +function areSimilarRegExps(a, b) { + return a.source === b.source && a.flags === b.flags && a.lastIndex === b.lastIndex; +} + +function isPartialUint8Array(a, b) { + const lenA = TypedArrayPrototypeGetByteLength(a); + const lenB = TypedArrayPrototypeGetByteLength(b); + if (lenA < lenB) { + return false; + } + let offsetA = 0; + for (let offsetB = 0; offsetB < lenB; offsetB++) { + while (a[offsetA] !== b[offsetB]) { + offsetA++; + if (offsetA > lenA - lenB + offsetB) { + return false; + } + } + offsetA++; + } + return true; +} + +function isPartialArrayBufferView(a, b) { + if (a.byteLength < b.byteLength) { + return false; + } + return isPartialUint8Array( + new Uint8Array(a.buffer, a.byteOffset, a.byteLength), + new Uint8Array(b.buffer, b.byteOffset, b.byteLength), + ); +} + +function areSimilarFloatArrays(a, b) { + const len = TypedArrayPrototypeGetByteLength(a); + if (len !== TypedArrayPrototypeGetByteLength(b)) { + return false; + } + for (let offset = 0; offset < len; offset++) { + if (a[offset] !== b[offset]) { + return false; + } + } + return true; +} + +function areSimilarTypedArrays(a, b) { + return a.byteLength === b.byteLength && compareViewBytes(a, b) === 0; +} + +function areEqualArrayBuffers(buf1, buf2) { + return buf1.byteLength === buf2.byteLength && BufferCompare(new Uint8Array(buf1), new Uint8Array(buf2)) === 0; +} + +function isEqualBoxedPrimitive(val1, val2) { + if (isNumberObject(val1)) { + return isNumberObject(val2) && ObjectIs(NumberPrototypeValueOf.$call(val1), NumberPrototypeValueOf.$call(val2)); + } + if (isStringObject(val1)) { + return isStringObject(val2) && StringPrototypeValueOf.$call(val1) === StringPrototypeValueOf.$call(val2); + } + if (isBooleanObject(val1)) { + return isBooleanObject(val2) && BooleanPrototypeValueOf.$call(val1) === BooleanPrototypeValueOf.$call(val2); + } + if (isBigIntObject(val1)) { + return isBigIntObject(val2) && BigIntPrototypeValueOf.$call(val1) === BigIntPrototypeValueOf.$call(val2); + } + // The caller already verified `isBoxedPrimitive(val1)`, so a Symbol object + // is the only case left. + return isSymbolObject(val2) && SymbolPrototypeValueOf.$call(val1) === SymbolPrototypeValueOf.$call(val2); +} + +function isEnumerableOrIdentical(val1, val2, prop, mode, memos) { + return ( + hasEnumerable(val2, prop) || // This is handled by Object.keys() + (mode === kPartial && (val2[prop] === undefined || (prop === "message" && val2[prop] === ""))) || + innerDeepEqual(val1[prop], val2[prop], mode, memos) + ); +} + +function innerDeepEqual(val1, val2, mode, memos) { + // All identical values are equivalent, as determined by ===. + if (val1 === val2) { + return val1 !== 0 || ObjectIs(val1, val2) || mode === kLoose; + } + + // Check more closely if val1 and val2 are equal. + if (mode !== kLoose) { + if (typeof val1 === "number") { + // Check for NaN + return val1 !== val1 && val2 !== val2; + } + if (typeof val2 !== "object" || typeof val1 !== "object" || val1 === null || val2 === null) { + return false; + } + } else { + if (val1 === null || typeof val1 !== "object") { + return ( + (val2 === null || typeof val2 !== "object") && + // Check for NaN + // eslint-disable-next-line eqeqeq + (val1 == val2 || (val1 !== val1 && val2 !== val2)) + ); + } + if (val2 === null || typeof val2 !== "object") { + return false; + } + } + return objectComparisonStart(val1, val2, mode, memos); +} + +function hasUnequalTag(val1, val2) { + return val1[SymbolToStringTag] !== val2[SymbolToStringTag]; +} + +function slowHasUnequalTag(val1Tag, val1, val2) { + if (val1[SymbolToStringTag] !== undefined && val2[SymbolToStringTag] !== undefined) { + return val1[SymbolToStringTag] !== val2[SymbolToStringTag]; + } + return val1Tag !== ObjectPrototypeToString.$call(val2); +} + +function objectComparisonStart(val1, val2, mode, memos) { + if (mode === kStrict) { + const constructor1 = val1.constructor; + if (wellKnownConstructors.has(constructor1) || (constructor1 !== undefined && !hasOwn(val1, "constructor"))) { + if (constructor1 !== val2.constructor) { + return false; + } + } else if (ObjectGetPrototypeOf(val1) !== ObjectGetPrototypeOf(val2)) { + return false; + } + } + + if (ArrayIsArray(val1)) { + if ( + !ArrayIsArray(val2) || + (val1.length !== val2.length && (mode !== kPartial || val1.length < val2.length)) || + hasUnequalTag(val1, val2) + ) { + return false; + } + + const filter = mode !== kLoose ? ONLY_ENUMERABLE : ONLY_ENUMERABLE | SKIP_SYMBOLS; + const keys2 = getOwnNonIndexProperties(val2, filter); + if (mode !== kPartial && keys2.length !== getOwnNonIndexProperties(val1, filter).length) { + return false; + } + return keyCheck(val1, val2, mode, memos, kIsArray, keys2); + } + + let val1Tag; + if (val1[SymbolToStringTag] === undefined && (val1Tag = ObjectPrototypeToString.$call(val1)) === "[object Object]") { + if (slowHasUnequalTag(val1Tag, val1, val2)) { + return false; + } + return keyCheck(val1, val2, mode, memos, kNoIterator); + } else if (isSet(val1)) { + if ( + !isSet(val2) || + (val1.size !== val2.size && (mode !== kPartial || val1.size < val2.size)) || + hasUnequalTag(val1, val2) + ) { + return false; + } + return keyCheck(val1, val2, mode, memos, kIsSet); + } else if (isMap(val1)) { + if ( + !isMap(val2) || + (val1.size !== val2.size && (mode !== kPartial || val1.size < val2.size)) || + hasUnequalTag(val1, val2) + ) { + return false; + } + return keyCheck(val1, val2, mode, memos, kIsMap); + } else if (isArrayBufferView(val1)) { + if (TypedArrayPrototypeGetSymbolToStringTag(val1) !== TypedArrayPrototypeGetSymbolToStringTag(val2)) { + return false; + } + if (mode === kPartial && val1.byteLength !== val2.byteLength) { + if (!isPartialArrayBufferView(val1, val2)) { + return false; + } + } else if (mode === kLoose && (isFloat32Array(val1) || isFloat64Array(val1) || isFloat16Array(val1))) { + if (!areSimilarFloatArrays(val1, val2)) { + return false; + } + } else if (!areSimilarTypedArrays(val1, val2)) { + return false; + } + // Buffer.compare returns true, so val1.length === val2.length. If they both + // only contain numeric keys, we don't need to exam further than checking + // the symbols. + const filter = mode !== kLoose ? ONLY_ENUMERABLE : ONLY_ENUMERABLE | SKIP_SYMBOLS; + const keys2 = getOwnNonIndexProperties(val2, filter); + if (mode !== kPartial && keys2.length !== getOwnNonIndexProperties(val1, filter).length) { + return false; + } + return keyCheck(val1, val2, mode, memos, kNoIterator, keys2); + } else if (isDate(val1)) { + if (!isDate(val2) || hasUnequalTag(val1, val2)) { + return false; + } + const time1 = DatePrototypeGetTime.$call(val1); + const time2 = DatePrototypeGetTime.$call(val2); + if (time1 !== time2 && (time1 === time1 || time2 === time2)) { + return false; + } + } else if (isRegExp(val1)) { + if (!isRegExp(val2) || !areSimilarRegExps(val1, val2) || hasUnequalTag(val1, val2)) { + return false; + } + } else if (isAnyArrayBuffer(val1)) { + if (!isAnyArrayBuffer(val2) || hasUnequalTag(val1, val2)) { + return false; + } + if (mode !== kPartial || val1.byteLength === val2.byteLength) { + if (!areEqualArrayBuffers(val1, val2)) { + return false; + } + } else if (!isPartialUint8Array(new Uint8Array(val1), new Uint8Array(val2))) { + return false; + } + } else if ( + slowHasUnequalTag(val1Tag ?? ObjectPrototypeToString.$call(val1), val1, val2) || + ArrayIsArray(val2) || + isArrayBufferView(val2) || + isSet(val2) || + isMap(val2) || + isDate(val2) || + isRegExp(val2) || + isAnyArrayBuffer(val2) + ) { + return false; + } else if (isError(val1)) { + // Do not compare the stack as it might differ even though the error itself + // is otherwise identical. + if ( + !isError(val2) || + !isEnumerableOrIdentical(val1, val2, "message", mode, memos) || + !isEnumerableOrIdentical(val1, val2, "name", mode, memos) || + !isEnumerableOrIdentical(val1, val2, "cause", mode, memos) || + !isEnumerableOrIdentical(val1, val2, "errors", mode, memos) + ) { + return false; + } + const hasOwnVal2Cause = hasOwn(val2, "cause"); + if (hasOwnVal2Cause !== hasOwn(val1, "cause") && (mode !== kPartial || hasOwnVal2Cause)) { + return false; + } + } else if (isBoxedPrimitive(val1)) { + if (!isEqualBoxedPrimitive(val1, val2)) { + return false; + } + } else if (isURL(val1)) { + if (!isURL(val2) || val1.href !== val2.href) { + return false; + } + } else if (isKeyObject(val1)) { + if (!isKeyObject(val2) || !areEqualKeyObjects(val1, val2)) { + return false; + } + } else if (isCryptoKey(val1)) { + if (!isCryptoKey(val2) || !areEqualCryptoKeys(val1, val2, mode, memos)) { + return false; + } + } else if ( + isBoxedPrimitive(val2) || + isNativeError(val2) || + val2 instanceof Error || + isWeakMap(val1) || + isWeakSet(val1) || + isPromise(val1) + ) { + return false; + } + + return keyCheck(val1, val2, mode, memos, kNoIterator); +} + +function partialSymbolEquiv(val1, val2, keys2) { + const symbolKeys = ObjectGetOwnPropertySymbols(val2); + if (symbolKeys.length !== 0) { + for (const key of symbolKeys) { + if (hasEnumerable(val2, key)) { + ArrayPrototypePush.$call(keys2, key); + } + } + } + return true; +} + +function keyCheck(val1, val2, mode, memos, iterationType, keys2?) { + // For all remaining Object pairs, including Array, objects and Maps, + // equivalence is determined by having: + // a) The same number of owned enumerable properties + // b) The same set of keys/indexes (although not necessarily the same order) + // c) Equivalent values for every corresponding key/index + // d) For Sets and Maps, equal contents + // Note: this accounts for both named and indexed properties on Arrays. + const isArrayLikeObject = keys2 !== undefined; + + if (keys2 === undefined) { + keys2 = ObjectKeys(val2); + } + let keys1; + + if (!isArrayLikeObject) { + // The pair must have the same number of owned properties. + if (mode === kPartial) { + if (!partialSymbolEquiv(val1, val2, keys2)) { + return false; + } + } else if (keys2.length !== (keys1 = ObjectKeys(val1)).length) { + return false; + } else if (mode === kStrict || mode === kStrictWithoutPrototypes) { + for (const key of ObjectGetOwnPropertySymbols(val1)) { + if (hasEnumerable(val1, key)) { + ArrayPrototypePush.$call(keys1, key); + } + } + for (const key of ObjectGetOwnPropertySymbols(val2)) { + if (hasEnumerable(val2, key)) { + ArrayPrototypePush.$call(keys2, key); + } + } + if (keys1.length !== keys2.length) { + return false; + } + } + } + + if ( + keys2.length === 0 && + (iterationType === kNoIterator || (iterationType === kIsArray && val2.length === 0) || val2.size === 0) + ) { + return true; + } + + if (memos === null) { + return objEquiv(val1, val2, mode, keys1, keys2, memos, iterationType); + } + return handleCycles(val1, val2, mode, keys1, keys2, memos, iterationType); +} + +function handleCycles(val1, val2, mode, keys1, keys2, memos, iterationType) { + // Use memos to handle cycles. + if (memos === undefined) { + memos = { + set: undefined, + a: val1, + b: val2, + c: undefined, + d: undefined, + deep: false, + }; + return objEquiv(val1, val2, mode, keys1, keys2, memos, iterationType); + } + + if (memos.set === undefined) { + if (memos.deep === false) { + if (memos.a === val1) { + return memos.b === val2; + } + if (memos.b === val2) { + return false; + } + memos.c = val1; + memos.d = val2; + memos.deep = true; + const result = objEquiv(val1, val2, mode, keys1, keys2, memos, iterationType); + memos.deep = false; + // objEquiv may have created the set in a deeper recursive call. + const { set } = memos; + if (set !== undefined) { + set.delete(memos.c); + set.delete(memos.d); + } + return result; + } + memos.set = new SafeSet(); + memos.set.add(memos.a); + memos.set.add(memos.b); + memos.set.add(memos.c); + memos.set.add(memos.d); + } + + const { set } = memos; + + const originalSize = set.size; + set.add(val1); + set.add(val2); + const newSize = set.size; + if (originalSize !== newSize - 2) { + return originalSize === newSize; + } + + const areEq = objEquiv(val1, val2, mode, keys1, keys2, memos, iterationType); + + set.delete(val1); + set.delete(val2); + + return areEq; +} + +// See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Equality_comparisons_and_sameness#Loose_equality_using +// Sadly it is not possible to detect corresponding values properly in case the +// type is a string, number, bigint or boolean. The reason is that those values +// can match lots of different string values (e.g., 1n == '+00001'). +function findLooseMatchingPrimitives(prim) { + switch (typeof prim) { + case "undefined": + return null; + case "object": // Only pass in null as object! + return undefined; + case "symbol": + return false; + case "string": + case "number": + // Loose equal entries exist only if the value is possible to convert to + // a regular number and not NaN. + prim = +prim; + if (prim !== prim) { + return false; + } + } + return true; +} + +function setMightHaveLoosePrim(a, b, prim) { + const altValue = findLooseMatchingPrimitives(prim); + if (altValue != null) return altValue; + + return !b.has(altValue) && a.has(altValue); +} + +function mapMightHaveLoosePrim(a, b, prim, item2, memo) { + const altValue = findLooseMatchingPrimitives(prim); + if (altValue != null) { + return altValue; + } + const item1 = a.get(altValue); + if ((item1 === undefined && !a.has(altValue)) || !innerDeepEqual(item1, item2, kLoose, memo)) { + return false; + } + return !b.has(altValue) && innerDeepEqual(item1, item2, kLoose, memo); +} + +function partialObjectSetEquiv(array, a, b, mode, memo) { + let aPos = 0; + let direction = 1; + let start = 0; + let end = array.length - 1; + for (const val1 of a) { + aPos++; + if (!b.has(val1)) { + let innerStart = start; + if (direction === 1) { + if (innerDeepEqual(val1, array[start], mode, memo)) { + if (start === end) { + return true; + } + start += 1; + continue; + } + if (start === end) { + // The last element of set b might match a later element in set a. + continue; + } + direction = -1; + innerStart += 1; + } + let matched = true; + if (!innerDeepEqual(val1, array[end], mode, memo)) { + direction = 1; + matched = arrayHasEqualElement(array, val1, mode, memo, innerDeepEqual, innerStart, end); + } + if (matched) { + if (start === end) { + return true; + } + end -= 1; + } + } + if (a.size - aPos <= end - start) { + return false; + } + } + return false; +} + +function arrayHasEqualElement(array, val1, mode, memo, comparator, start, end) { + for (let i = end - 1; i >= start; i--) { + if (comparator(val1, array[i], mode, memo)) { + // Move the matching element to make sure we do not check that again. + array[i] = array[end]; + return true; + } + } + return false; +} + +function setObjectEquiv(array, a, b, mode, memo) { + let direction = 1; + let start = 0; + let end = array.length - 1; + const comparator = mode !== kLoose ? objectComparisonStart : innerDeepEqual; + const extraChecks = mode === kLoose || array.length !== a.size; + for (const val1 of a) { + if (extraChecks) { + if (typeof val1 === "object") { + if (b.has(val1)) { + continue; + } + } else if (b.has(val1)) { + continue; + } else if (mode !== kLoose) { + return false; + } + } + + let innerStart = start; + if (direction === 1) { + if (comparator(val1, array[start], mode, memo)) { + start += 1; + continue; + } + if (start === end) { + return false; + } + direction = -1; + innerStart += 1; + } + if (!comparator(val1, array[end], mode, memo)) { + direction = 1; + if (!arrayHasEqualElement(array, val1, mode, memo, comparator, innerStart, end)) { + return false; + } + } + end -= 1; + } + return true; +} + +function compareSmallSets(a, b, val, iteratorB, mode, memo) { + const iteratorA = a.values(); + const firstA = iteratorA.next().value; + const first = innerDeepEqual(firstA, val, mode, memo); + if (first) { + if (b.size === 1) { + // Partial mode && a.size === 1 || b.size === 1 + return true; + } + const secondA = iteratorA.next().value; + return b.has(secondA) || innerDeepEqual(secondA, iteratorB.next().value, mode, memo); + } + return ( + a.size !== 1 && + innerDeepEqual(iteratorA.next().value, val, mode, memo) && + (b.size === 1 || // Partial mode + b.has(firstA) || // Primitive or reference equal + innerDeepEqual(firstA, iteratorB.next().value, mode, memo)) + ); +} + +function setEquiv(a, b, mode, memo) { + // This is a lazily initiated Set of entries which have to be compared + // pairwise. + let array; + + const iteratorB = b.values(); + for (const val of iteratorB) { + if (!a.has(val)) { + if ((typeof val !== "object" || val === null) && (mode !== kLoose || !setMightHaveLoosePrim(a, b, val))) { + return false; + } + + if (array === undefined) { + if (a.size < 3) { + return compareSmallSets(a, b, val, iteratorB, mode, memo); + } + array = []; + } + // If the specified value doesn't exist in the second set it's a object + // (or in loose mode: a non-matching primitive). Find the + // deep-(mode-)equal element in a set copy to reduce duplicate checks. + ArrayPrototypePush.$call(array, val); + } + } + + if (array === undefined) { + return true; + } + if (mode === kPartial) { + return partialObjectSetEquiv(array, a, b, mode, memo); + } + return setObjectEquiv(array, a, b, mode, memo); +} + +function partialObjectMapEquiv(array, a, b, mode, memo) { + let aPos = 0; + let direction = 1; + let start = 0; + let end = array.length - 1; + for (const { 0: key1, 1: item1 } of a) { + aPos++; + if (typeof key1 === "object" && key1 !== null) { + let innerStart = start; + if (direction === 1) { + const key2 = array[start]; + if (objectComparisonStart(key1, key2, mode, memo) && innerDeepEqual(item1, b.get(key2), mode, memo)) { + if (start === end) { + return true; + } + start += 1; + continue; + } + if (start === end) { + // The last element of map b might match a later element in map a. + continue; + } + direction = -1; + innerStart += 1; + } + let matched = true; + const key2 = array[end]; + if (!objectComparisonStart(key1, key2, mode, memo) || !innerDeepEqual(item1, b.get(key2), mode, memo)) { + direction = 1; + matched = arrayHasEqualMapElement(array, key1, item1, b, mode, memo, objectComparisonStart, innerStart, end); + } + if (matched) { + if (start === end) { + return true; + } + end -= 1; + } + } + if (a.size - aPos <= end - start) { + return false; + } + } + return false; +} + +function arrayHasEqualMapElement(array, key1, item1, b, mode, memo, comparator, start, end) { + for (let i = end - 1; i >= start; i--) { + const key2 = array[i]; + if (comparator(key1, key2, mode, memo) && innerDeepEqual(item1, b.get(key2), mode, memo)) { + // Move the matching element to make sure we do not check that again. + array[i] = array[end]; + return true; + } + } + return false; +} + +function mapObjectEquiv(array, a, b, mode, memo) { + let direction = 1; + let start = 0; + let end = array.length - 1; + const comparator = mode !== kLoose ? objectComparisonStart : innerDeepEqual; + const extraChecks = mode === kLoose || array.length !== a.size; + + for (const { 0: key1, 1: item1 } of a) { + if (extraChecks && (typeof key1 !== "object" || key1 === null)) { + if (b.has(key1)) { + if (mode !== kLoose || innerDeepEqual(item1, b.get(key1), mode, memo)) { + continue; + } + } else if (mode !== kLoose) { + return false; + } + } + + let innerStart = start; + if (direction === 1) { + const key2 = array[start]; + if (comparator(key1, key2, mode, memo) && innerDeepEqual(item1, b.get(key2), mode, memo)) { + start += 1; + continue; + } + if (start === end) { + return false; + } + direction = -1; + innerStart += 1; + } + const key2 = array[end]; + if (!comparator(key1, key2, mode, memo) || !innerDeepEqual(item1, b.get(key2), mode, memo)) { + direction = 1; + if (!arrayHasEqualMapElement(array, key1, item1, b, mode, memo, comparator, innerStart, end)) { + return false; + } + } + end -= 1; + } + return true; +} + +function mapEquiv(a, b, mode, memo) { + let array; + + for (const { 0: key2, 1: item2 } of b) { + if (typeof key2 === "object" && key2 !== null) { + if (array === undefined) { + if (a.size === 1) { + const { 0: key1, 1: item1 } = a.entries().next().value; + return innerDeepEqual(key1, key2, mode, memo) && innerDeepEqual(item1, item2, mode, memo); + } + array = []; + } + ArrayPrototypePush.$call(array, key2); + } else { + // By directly retrieving the value we prevent another b.has(key2) check in + // almost all possible cases. + const item1 = a.get(key2); + if ((item1 === undefined && !a.has(key2)) || !innerDeepEqual(item1, item2, mode, memo)) { + if (mode !== kLoose) return false; + // Fast path to detect missing string, symbol, undefined and null + // keys. + if (!mapMightHaveLoosePrim(a, b, key2, item2, memo)) return false; + if (array === undefined) { + array = []; + } + ArrayPrototypePush.$call(array, key2); + } + } + } + + if (array === undefined) { + return true; + } + + if (mode === kPartial) { + return partialObjectMapEquiv(array, a, b, mode, memo); + } + + return mapObjectEquiv(array, a, b, mode, memo); +} + +function partialSparseArrayEquiv(a, b, mode, memos, startA, startB) { + let aPos = startA; + const keysA = ObjectKeys(a); + const keysB = ObjectKeys(b); + const lenA = keysA.length - startA; + const lenB = keysB.length - startB; + if (lenA < lenB) { + return false; + } + for (let i = 0; i < lenB; i++) { + const keyB = keysB[startB + i]; + while (!innerDeepEqual(a[keysA[aPos]], b[keyB], mode, memos)) { + aPos++; + if (aPos > keysA.length - lenB + i) { + return false; + } + } + aPos++; + } + return true; +} + +function partialArrayEquiv(a, b, mode, memos) { + let aPos = 0; + for (let i = 0; i < b.length; i++) { + let isSparse = b[i] === undefined && !hasOwn(b, i); + if (isSparse) { + return partialSparseArrayEquiv(a, b, mode, memos, aPos, i); + } + while (!(isSparse = a[aPos] === undefined && !hasOwn(a, aPos)) && !innerDeepEqual(a[aPos], b[i], mode, memos)) { + aPos++; + if (aPos > a.length - b.length + i) { + return false; + } + } + if (isSparse) { + return partialSparseArrayEquiv(a, b, mode, memos, aPos, i); + } + aPos++; + } + return true; +} + +function sparseArrayEquiv(a, b, mode, memos, i) { + const keysA = ObjectKeys(a); + const keysB = ObjectKeys(b); + if (keysA.length !== keysB.length) { + return false; + } + for (; i < keysB.length; i++) { + const key = keysB[i]; + if ((a[key] === undefined && !hasOwn(a, key)) || !innerDeepEqual(a[key], b[key], mode, memos)) { + return false; + } + } + return true; +} + +function objEquiv(a, b, mode, keys1, keys2, memos, iterationType) { + const keys2Length = keys2.length; + // The pair must have equivalent values for every corresponding key. + if (keys2Length > 0) { + let i = 0; + // Ordered keys + if (keys1 !== undefined) { + for (; i < keys2Length; i++) { + const key = keys2[i]; + if (keys1[i] !== key) { + break; + } + if (!innerDeepEqual(a[key], b[key], mode, memos)) { + return false; + } + } + } + // Unordered keys + for (; i < keys2Length; i++) { + const key = keys2[i]; + // It is faster to get the whole descriptor and to check it's enumerable + // property in V8 13.0 compared to calling Object.propertyIsEnumerable() + // and accessing the property regularly. + const descriptor = ObjectGetOwnPropertyDescriptor(a, key); + if (descriptor === undefined || descriptor.enumerable !== true) { + return false; + } + const value = descriptor.writable !== undefined ? descriptor.value : a[key]; + if (!innerDeepEqual(value, b[key], mode, memos)) { + return false; + } + } + } + + if (iterationType === kIsArray) { + if (mode === kPartial) { + return partialArrayEquiv(a, b, mode, memos); + } + for (let i = 0; i < a.length; i++) { + if (b[i] === undefined) { + if (!hasOwn(b, i)) return sparseArrayEquiv(a, b, mode, memos, i); + if ((a[i] !== undefined || !hasOwn(a, i)) && (mode !== kLoose || a[i] !== null)) return false; + } else if ( + (a[i] === undefined || !innerDeepEqual(a[i], b[i], mode, memos)) && + (mode !== kLoose || b[i] !== null) + ) { + return false; + } + } + } else if (iterationType === kIsSet) { + if (!setEquiv(a, b, mode, memos)) { + return false; + } + } else if (iterationType === kIsMap) { + if (!mapEquiv(a, b, mode, memos)) { + return false; + } + } + + return true; +} + +// Only handle cycles when they are detected. +let detectCycles: (val1: unknown, val2: unknown, mode: number, memos?: unknown) => boolean = function ( + val1, + val2, + mode, +) { + try { + return innerDeepEqual(val1, val2, mode, null); + } catch { + // Stack overflow: the values (probably) contain cycles. Switch to the + // slower, memoized comparison permanently. + detectCycles = innerDeepEqual; + return innerDeepEqual(val1, val2, mode, undefined); + } +}; + +export default { + isDeepEqual(val1, val2) { + return detectCycles(val1, val2, kLoose); + }, + isDeepStrictEqual(val1, val2, skipPrototype?) { + return detectCycles(val1, val2, skipPrototype ? kStrictWithoutPrototypes : kStrict); + }, + isPartialStrictEqual(val1, val2) { + return detectCycles(val1, val2, kPartial); + }, +}; diff --git a/src/js/node/assert.ts b/src/js/node/assert.ts index 7e842a348ebe..734b518beb0f 100644 --- a/src/js/node/assert.ts +++ b/src/js/node/assert.ts @@ -21,47 +21,35 @@ "use strict"; -const { SafeMap, SafeSet, SafeWeakSet } = require("internal/primordials"); -const { - isKeyObject, - isPromise, - isRegExp, - isMap, - isSet, - isDate, - isWeakSet, - isWeakMap, - isAnyArrayBuffer, -} = require("node:util/types"); +const { isPromise, isRegExp } = require("node:util/types"); const { innerOk } = require("internal/assert/utils"); const { validateFunction } = require("internal/validators"); -const ArrayFrom = Array.from; const ArrayPrototypeIndexOf = Array.prototype.indexOf; const ArrayPrototypeJoin = Array.prototype.join; const ArrayPrototypePush = Array.prototype.push; const ArrayPrototypeSlice = Array.prototype.slice; -const ArrayBufferIsView = ArrayBuffer.isView; const NumberIsNaN = Number.isNaN; const ObjectAssign = Object.assign; const ObjectIs = Object.is; const ObjectKeys = Object.keys; const ObjectPrototypeIsPrototypeOf = Object.prototype.isPrototypeOf; -const ReflectHas = Reflect.has; -const ReflectOwnKeys = Reflect.ownKeys; const RegExpPrototypeExec = RegExp.prototype.exec; const StringPrototypeIndexOf = String.prototype.indexOf; const StringPrototypeSlice = String.prototype.slice; const StringPrototypeSplit = String.prototype.split; -const SymbolIterator = Symbol.iterator; type nodeAssert = typeof import("node:assert"); -function isDeepEqual(a, b) { - return Bun.deepEquals(a, b, false); -} -function isDeepStrictEqual(a, b) { - return Bun.deepEquals(a, b, true); +let isDeepEqual; +let isDeepStrictEqual; +let isPartialStrictEqual; + +function lazyLoadComparison() { + const comparison = require("internal/util/comparisons"); + isDeepEqual = comparison.isDeepEqual; + isDeepStrictEqual = comparison.isDeepStrictEqual; + isPartialStrictEqual = comparison.isPartialStrictEqual; } var _inspect; @@ -247,6 +235,7 @@ assert.deepEqual = function deepEqual(actual, expected, message) { if (arguments.length < 2) { throw $ERR_MISSING_ARGS("actual", "expected"); } + if (isDeepEqual === undefined) lazyLoadComparison(); if (!isDeepEqual(actual, expected)) { innerFail({ actual, @@ -269,6 +258,7 @@ assert.notDeepEqual = function notDeepEqual(actual, expected, message) { if (arguments.length < 2) { throw $ERR_MISSING_ARGS("actual", "expected"); } + if (isDeepEqual === undefined) lazyLoadComparison(); if (isDeepEqual(actual, expected)) { innerFail({ actual, @@ -293,6 +283,7 @@ assert.deepStrictEqual = function deepStrictEqual(actual, expected, message) { if (arguments.length < 2) { throw $ERR_MISSING_ARGS("actual", "expected"); } + if (isDeepEqual === undefined) lazyLoadComparison(); if (!isDeepStrictEqual(actual, expected)) { innerFail({ actual, @@ -317,6 +308,7 @@ function notDeepStrictEqual(actual, expected, message) { if (arguments.length < 2) { throw $ERR_MISSING_ARGS("actual", "expected"); } + if (isDeepEqual === undefined) lazyLoadComparison(); if (isDeepStrictEqual(actual, expected)) { innerFail({ actual, @@ -372,181 +364,20 @@ assert.notStrictEqual = function notStrictEqual(actual, expected, message) { } }; -function isSpecial(obj) { - return obj == null || typeof obj !== "object" || Error.isError(obj) || isRegExp(obj) || isDate(obj); -} - -const typesToCallDeepStrictEqualWith = [isKeyObject, isWeakSet, isWeakMap, Buffer.isBuffer]; -const SafeSetPrototypeIterator = SafeSet.prototype[SymbolIterator]; -const SafeMapPrototypeIterator = SafeMap.prototype[SymbolIterator]; -const SafeMapPrototypeHas = SafeMap.prototype.has; -const SafeMapPrototypeGet = SafeMap.prototype.get; -const SafeMapPrototypeSet = SafeMap.prototype.set; -const SafeMapPrototypeDelete = SafeMap.prototype.delete; - /** - * Compares two objects or values recursively to check if they are equal. - * @param {any} actual - The actual value to compare. - * @param {any} expected - The expected value to compare. - * @param {Set} [comparedObjects=new Set()] - Set to track compared objects for handling circular references. - * @returns {boolean} - Returns `true` if the actual value matches the expected value, otherwise `false`. - * @example - * compareBranch({a: 1, b: 2, c: 3}, {a: 1, b: 2}); // true - */ -function compareBranch(actual, expected, comparedObjects?) { - // Check for Map object equality (subset check for partialDeepStrictEqual) - if (isMap(actual) && isMap(expected)) { - if (expected.size > actual.size) { - return false; // `expected` can't be a subset if it has more elements - } - - comparedObjects ??= new SafeWeakSet(); - - // Handle circular references - if (comparedObjects.has(actual)) { - return true; - } - comparedObjects.add(actual); - - const expectedIterator = SafeMapPrototypeIterator.$call(expected); - - for (const { 0: key, 1: expectedValue } of expectedIterator) { - if (!SafeMapPrototypeHas.$call(actual, key)) { - return false; - } - const actualValue = SafeMapPrototypeGet.$call(actual, key); - if (!compareBranch(actualValue, expectedValue, comparedObjects)) { - return false; - } - } - - return true; - } - - // Check for ArrayBuffer object equality - if ( - ArrayBufferIsView(actual) || - isAnyArrayBuffer(actual) || - ArrayBufferIsView(expected) || - isAnyArrayBuffer(expected) - ) { - return Bun.deepEquals(actual, expected, true); - } - - for (const type of typesToCallDeepStrictEqualWith) { - if (type(actual) || type(expected)) { - return isDeepStrictEqual(actual, expected); - } - } - - // Check for Set object equality - if (isSet(actual) && isSet(expected)) { - if (expected.size > actual.size) { - return false; // `expected` can't be a subset if it has more elements - } - - const actualArray = ArrayFrom(SafeSetPrototypeIterator.$call(actual)); - const expectedIterator = SafeSetPrototypeIterator.$call(expected); - const usedIndices = new SafeSet(); - - expectedIteration: for (const expectedItem of expectedIterator) { - for (let actualIdx = 0; actualIdx < actualArray.length; actualIdx++) { - if (!usedIndices.has(actualIdx) && isDeepStrictEqual(actualArray[actualIdx], expectedItem)) { - usedIndices.add(actualIdx); - continue expectedIteration; - } - } - return false; - } - - return true; - } - - // Check if expected array is a subset of actual array - if ($isArray(actual) && $isArray(expected)) { - if (expected.length > actual.length) { - return false; - } - - // Create a map to count occurrences of each element in the expected array - const expectedCounts = new SafeMap(); - for (const expectedItem of expected) { - let found = false; - for (const { 0: key, 1: count } of expectedCounts) { - if (isDeepStrictEqual(key, expectedItem)) { - SafeMapPrototypeSet.$call(expectedCounts, key, count + 1); - found = true; - break; - } - } - if (!found) { - SafeMapPrototypeSet.$call(expectedCounts, expectedItem, 1); - } - } - - // Create a map to count occurrences of relevant elements in the actual array - for (const actualItem of actual) { - for (const { 0: key, 1: count } of expectedCounts) { - if (isDeepStrictEqual(key, actualItem)) { - if (count === 1) { - SafeMapPrototypeDelete.$call(expectedCounts, key); - } else { - SafeMapPrototypeSet.$call(expectedCounts, key, count - 1); - } - break; - } - } - } - - return !expectedCounts.size; - } - - // Comparison done when at least one of the values is not an object - if (isSpecial(actual) || isSpecial(expected)) { - return isDeepStrictEqual(actual, expected); - } - - // Use Reflect.ownKeys() instead of Object.keys() to include symbol properties - const keysExpected = ReflectOwnKeys(expected); - - comparedObjects ??= new SafeWeakSet(); - - // Handle circular references - if (comparedObjects.has(actual)) { - return true; - } - comparedObjects.add(actual); - - if (AssertionError === undefined) loadAssertionError(); - // Check if all expected keys and values match - for (let i = 0; i < keysExpected.length; i++) { - const key = keysExpected[i]; - assert( - ReflectHas(actual, key), - new AssertionError({ message: `Expected key ${String(key)} not found in actual object` }), - ); - if (!compareBranch(actual[key], expected[key], comparedObjects)) { - return false; - } - } - - return true; -} - -/** - * The strict equivalence assertion test between two objects + * The partial deep strict equivalence assertion tests a partial deep strict + * equality relation. * @param {any} actual * @param {any} expected * @param {string | Error} [message] * @returns {void} */ assert.partialDeepStrictEqual = function partialDeepStrictEqual(actual, expected, message) { - // emitExperimentalWarning("assert.partialDeepStrictEqual"); if (arguments.length < 2) { throw $ERR_MISSING_ARGS("actual", "expected"); } - - if (!compareBranch(actual, expected)) { + if (isDeepEqual === undefined) lazyLoadComparison(); + if (!isPartialStrictEqual(actual, expected)) { innerFail({ actual, expected, @@ -644,6 +475,7 @@ function expectedException(actual, expected, message, fn) { } else if (keys.length === 0) { throw $ERR_INVALID_ARG_VALUE("error", expected, "may not be an empty object"); } + if (isDeepEqual === undefined) lazyLoadComparison(); for (const key of keys) { if ( typeof actual[key] === "string" && diff --git a/src/js/node/util.ts b/src/js/node/util.ts index 30d2549af133..98ccb68775fb 100644 --- a/src/js/node/util.ts +++ b/src/js/node/util.ts @@ -22,8 +22,11 @@ function isFunction(value) { return typeof value === "function"; } -const deepEquals = Bun.deepEquals; -const isDeepStrictEqual = (a, b) => deepEquals(a, b, true); +let internalDeepEqual; +function isDeepStrictEqual(val1, val2) { + internalDeepEqual ??= require("internal/util/comparisons").isDeepStrictEqual; + return internalDeepEqual(val1, val2); +} const parseArgs = $newRustFunction("parse_args.rs", "parseArgs", 1); diff --git a/src/jsc/bindings/UtilInspect.cpp b/src/jsc/bindings/UtilInspect.cpp index ae09d5b11cdb..7cc826c1b4fa 100644 --- a/src/jsc/bindings/UtilInspect.cpp +++ b/src/jsc/bindings/UtilInspect.cpp @@ -1,5 +1,6 @@ #include "root.h" #include "headers.h" +#include "UtilInspect.h" #include "JavaScriptCore/JSObject.h" #include "JavaScriptCore/JSFunction.h" #include "JavaScriptCore/JSString.h" @@ -7,11 +8,74 @@ #include "JavaScriptCore/JSGlobalObject.h" #include "ZigGlobalObject.h" #include "JavaScriptCore/ObjectConstructor.h" +#include "JavaScriptCore/JSArray.h" +#include "JavaScriptCore/PropertyNameArray.h" +#include "JavaScriptCore/Symbol.h" namespace Bun { using namespace JSC; +// Node's `internalBinding('util').getOwnNonIndexProperties(object, filter)`: +// the own property keys of `object` excluding array indices. `filter` uses +// V8's PropertyFilter bits (ONLY_ENUMERABLE = 2, SKIP_SYMBOLS = 16). +JSC_DEFINE_HOST_FUNCTION(jsFunctionGetOwnNonIndexProperties, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + JSValue objectValue = callFrame->argument(0); + if (!objectValue.isObject()) [[unlikely]] { + throwTypeError(globalObject, scope, "getOwnNonIndexProperties expects an object"_s); + return {}; + } + JSObject* object = asObject(objectValue); + + constexpr int32_t kOnlyEnumerable = 2; + constexpr int32_t kSkipSymbols = 16; + int32_t filter = callFrame->argument(1).toInt32(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + + auto dontEnumMode = (filter & kOnlyEnumerable) ? DontEnumPropertiesMode::Exclude : DontEnumPropertiesMode::Include; + auto nameMode = (filter & kSkipSymbols) ? PropertyNameMode::Strings : PropertyNameMode::StringsAndSymbols; + PropertyNameArrayBuilder properties(vm, nameMode, PrivateSymbolMode::Exclude); + + if (object->hasNonReifiedStaticProperties()) [[unlikely]] { + object->reifyAllStaticProperties(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + } + + if (object->type() == ProxyObjectType) [[unlikely]] { + // getOwnNonIndexPropertyNames does not consult the `ownKeys` trap, so + // collect every own key through the method table and drop the indices. + PropertyNameArrayBuilder allProperties(vm, nameMode, PrivateSymbolMode::Exclude); + object->methodTable()->getOwnPropertyNames(object, globalObject, allProperties, dontEnumMode); + RETURN_IF_EXCEPTION(scope, {}); + for (const auto& identifier : allProperties) { + if (parseIndex(identifier)) + continue; + properties.add(identifier); + } + } else { + object->getOwnNonIndexPropertyNames(globalObject, properties, dontEnumMode); + RETURN_IF_EXCEPTION(scope, {}); + } + + JSArray* result = constructEmptyArray(globalObject, nullptr, properties.size()); + RETURN_IF_EXCEPTION(scope, {}); + unsigned index = 0; + for (const auto& identifier : properties) { + JSValue key; + if (identifier.isSymbol()) + key = Symbol::create(vm, static_cast(*identifier.impl())); + else + key = jsOwnedString(vm, identifier.string()); + result->putDirectIndex(globalObject, index++, key); + RETURN_IF_EXCEPTION(scope, {}); + } + return JSValue::encode(result); +} + Structure* createUtilInspectOptionsStructure(VM& vm, JSC::JSGlobalObject* globalObject) { Structure* structure = globalObject->structureCache().emptyObjectStructureForPrototype(globalObject, globalObject->objectPrototype(), 3); diff --git a/src/jsc/bindings/UtilInspect.h b/src/jsc/bindings/UtilInspect.h index 3a8d708c975f..c1ac18e34fc1 100644 --- a/src/jsc/bindings/UtilInspect.h +++ b/src/jsc/bindings/UtilInspect.h @@ -1,7 +1,12 @@ #pragma once +#include "root.h" + namespace Bun { JSC::Structure* createUtilInspectOptionsStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObject); +// Node's `internalBinding('util').getOwnNonIndexProperties(object, filter)`. +JSC_DECLARE_HOST_FUNCTION(jsFunctionGetOwnNonIndexProperties); + } diff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index adb5ed6ecdda..8c4ca6d2471f 100644 --- a/src/jsc/bindings/ZigGlobalObject.cpp +++ b/src/jsc/bindings/ZigGlobalObject.cpp @@ -3036,6 +3036,9 @@ void GlobalObject::addBuiltinGlobals(JSC::VM& vm) // ----- Public Properties ----- + // `Object.prototype.toString.call(globalThis) === "[object global]"`, like Node.js. + putDirect(vm, vm.propertyNames->toStringTagSymbol, jsNontrivialString(vm, "global"_s), PropertyAttribute::DontEnum | PropertyAttribute::ReadOnly | 0); + // a direct accessor (uses js functions for get and set) cannot be on the lookup table. i think. putDirectAccessor( this, diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index 555e1c7d76ec..2726d823f83f 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -1172,7 +1172,10 @@ std::optional specialObjectsDequal(JSC::JSGlobalObject* globalObject, Mark JSC::DateInstance* left = uncheckedDowncast(c1); JSC::DateInstance* right = uncheckedDowncast(c2); - return left->internalNumber() == right->internalNumber(); + double leftTime = left->internalNumber(); + double rightTime = right->internalNumber(); + // Invalid dates are equal to each other: SameValue, not ==. + return leftTime == rightTime || (std::isnan(leftTime) && std::isnan(rightTime)); } case RegExpObjectType: { if (c2Type != RegExpObjectType) { diff --git a/test/expectations.txt b/test/expectations.txt index 24e09b7de2ad..c558b53a5867 100644 --- a/test/expectations.txt +++ b/test/expectations.txt @@ -25,7 +25,11 @@ test/js/node/test/parallel/test-inspector-enabled.js [ FAIL ] # but the file is a verbatim upstream port. Quarantined on the failing # linux-x64-musl matrix only; still runs everywhere else (build 63145: # alpine 3.23 x64 + x64-baseline only). +# test-net-connect-memleak.js is the same test over net instead of tls and +# fails the same way on the same two alpine x64 targets (build 66749) once +# anything shifts the startup heap layout again. [ LINUX-X64-MUSL ] test/js/node/test/parallel/test-tls-connect-memleak.js [ FLAKY ] # JSC FinalizationRegistry callback delivery vs setImmediate timing on musl x64 +[ LINUX-X64-MUSL ] test/js/node/test/parallel/test-net-connect-memleak.js [ FLAKY ] # JSC FinalizationRegistry callback delivery vs setImmediate timing on musl x64 # Vendored node v26.3.0 stream tests blocked on missing native subsystems (see PR #31826) test/js/node/test/parallel/test-stream-pipeline.js [ SKIP ] # block at L271 hangs: pipeline(rs, req) writes 11x'hello' raw after a never-ended GET's \r\n\r\n; node's llhttp rejects lowercase 'h' as a method char (HPE_INVALID_METHOD -> clientError -> 400+close -> req 'close' -> pipeline callback fires), but bun's uWS HttpParser buffers any incomplete run of valid tchars waiting for the request-line, so the connection stays open and the callback never fires. Pre-existing server-parser leniency; needs uWS HttpParser to reject non-uppercase method bytes like llhttp. @@ -33,6 +37,26 @@ test/js/node/test/parallel/test-stream-wrap.js [ FAIL ] # needs internal/test/bi test/js/node/test/parallel/test-stream-wrap-drain.js [ FAIL ] # needs internal/js_stream_socket (net.Socket({handle}) libuv compat layer) test/js/node/test/parallel/test-stream-wrap-encoding.js [ FAIL ] # needs internal/js_stream_socket (net.Socket({handle}) libuv compat layer) +# Pre-existing compat gaps unmasked by assert.deepStrictEqual implementing +# Node's algorithm, which distinguishes Buffer from Uint8Array (their +# assertions used to false-pass because Bun.deepEquals does not compare +# constructors): +# +# 1. Over `serialization: "advanced"` IPC, Node's v8 DefaultDeserializer +# revives every Uint8Array view as a Buffer, so +# `{ buffer: Buffer.from("Hello!") }` round-trips as a Buffer in Node but +# as a plain Uint8Array in Bun (same for `v8.deserialize(v8.serialize(b))`). +test/js/node/test/parallel/test-child-process-advanced-serialization.js [ FAIL ] # Buffer arrives as a plain Uint8Array over advanced IPC +# 2. Node's `Buffer.from(string)` allocates out of the 8 KB pool, so +# stream/iter's concatBytes copies such a chunk into a plain Uint8Array +# (the chunk never covers its whole backing allocation). Bun's Buffers are +# not pool-allocated, so the same fast path hands back the pushed Buffer +# itself and `bytes()` returns a Buffer where Node returns a Uint8Array. +# Making concatBytes always re-view Buffers is not an option: Node's +# test-stream-iter-transform-sync.js pins the opposite (a Buffer out of +# `bytes()`) for exact-allocation (unpooled) chunks like zlib output. +test/js/node/test/parallel/test-stream-iter-readable-interop.js [ FAIL ] # bytes() returns the pushed Buffer; Node's is pooled so it copies to a Uint8Array + # Pre-existing fs.watch leak unmasked by this PR's eval-entry exception fix: # the child's thrown leak error ("fs.watch(dir) leaked N MB") used to be # swallowed by the silent-exit-0 eval bug (uncaught throw in a CJS -e script diff --git a/test/js/bun/bun-object/deep-equals.spec.ts b/test/js/bun/bun-object/deep-equals.spec.ts index 6d6994176192..331c88c9fc40 100644 --- a/test/js/bun/bun-object/deep-equals.spec.ts +++ b/test/js/bun/bun-object/deep-equals.spec.ts @@ -29,6 +29,14 @@ describe.each([true, false])("Bun.deepEquals(a, b, strict: %p)", strict => { expect(Bun.deepEquals(a, b, false)).toBe(false); }); + it("invalid dates are equal", () => { + expect(deepEquals(new Date(NaN), new Date(NaN))).toBe(true); + expect(new Date(NaN)).toEqual(new Date(NaN)); + expect(new Date(NaN)).toStrictEqual(new Date(NaN)); + expect(deepEquals(new Date(NaN), new Date(0))).toBe(false); + expect(deepEquals(new Date(0), new Date(NaN))).toBe(false); + }); + // https://github.com/nodejs/node/issues/10258 it("fake dates are not equal", () => { function FakeDate() {} diff --git a/test/js/node/assert/assert-deep-equal.test.ts b/test/js/node/assert/assert-deep-equal.test.ts new file mode 100644 index 000000000000..3df728921f2f --- /dev/null +++ b/test/js/node/assert/assert-deep-equal.test.ts @@ -0,0 +1,291 @@ +import { describe, expect, test } from "bun:test"; +import assert from "node:assert"; +import { isDeepStrictEqual } from "node:util"; +import { runInNewContext } from "node:vm"; + +// `assert.deepEqual`, `assert.deepStrictEqual` and +// `assert.partialDeepStrictEqual` implement Node's comparison algorithm +// (lib/internal/util/comparisons.js). It is not the same algorithm as +// `Bun.deepEquals` / `expect().toEqual`: it compares prototypes, +// `RegExp#lastIndex`, `Error#cause` / `AggregateError#errors`, treats objects +// with unobservable state (Promise, WeakMap, WeakSet) as never equal, and the +// legacy `deepEqual` uses `==` coercion. +// +// Every expectation in this file matches the behavior of Node.js v26.3.0. + +const AssertionError = assert.AssertionError; + +type MakePair = () => [unknown, unknown]; + +function expectStrictEqual(make: MakePair) { + const [a, b] = make(); + assert.deepStrictEqual(a, b); + expect(() => assert.notDeepStrictEqual(a, b)).toThrow(AssertionError); + expect(isDeepStrictEqual(a, b)).toBe(true); + // The relation is symmetric. + const [c, d] = make(); + assert.deepStrictEqual(d, c); +} + +function expectNotStrictEqual(make: MakePair) { + const [a, b] = make(); + expect(() => assert.deepStrictEqual(a, b)).toThrow(AssertionError); + assert.notDeepStrictEqual(a, b); + expect(isDeepStrictEqual(a, b)).toBe(false); + const [c, d] = make(); + expect(() => assert.deepStrictEqual(d, c)).toThrow(AssertionError); +} + +function expectLooseEqual(make: MakePair) { + const [a, b] = make(); + assert.deepEqual(a, b); + expect(() => assert.notDeepEqual(a, b)).toThrow(AssertionError); + const [c, d] = make(); + assert.deepEqual(d, c); +} + +function expectNotLooseEqual(make: MakePair) { + const [a, b] = make(); + expect(() => assert.deepEqual(a, b)).toThrow(AssertionError); + assert.notDeepEqual(a, b); + const [c, d] = make(); + expect(() => assert.deepEqual(d, c)).toThrow(AssertionError); +} + +describe("assert.deepStrictEqual", () => { + const unequal: [string, MakePair][] = [ + [ + "RegExp with a different lastIndex", + () => { + const re = /a/g; + re.lastIndex = 3; + return [re, /a/g]; + }, + ], + ["null prototype object vs plain object", () => [{ __proto__: null }, {}]], + ["Buffer vs Uint8Array with the same contents", () => [Buffer.from([1, 2]), new Uint8Array([1, 2])]], + ["promises resolved with different values", () => [Promise.resolve(1), Promise.resolve(2)]], + ["promises resolved with the same value", () => [Promise.resolve(1), Promise.resolve(1)]], + ["extra own enumerable property on a Date", () => [Object.assign(new Date(0), { x: 1 }), new Date(0)]], + ["extra own enumerable property on a RegExp", () => [Object.assign(/a/, { x: 1 }), /a/]], + [ + "extra own enumerable property on a typed array", + () => [Object.assign(new Uint8Array(2), { x: 1 }), new Uint8Array(2)], + ], + [ + "AggregateError with different errors", + () => [new AggregateError([new TypeError("a")], "m"), new AggregateError([new TypeError("b")], "m")], + ], + ["Error with a different cause", () => [new Error("m", { cause: 1 }), new Error("m", { cause: 2 })]], + ["Error with and without an own cause", () => [new Error("m", { cause: undefined }), new Error("m")]], + ["WeakMap instances", () => [new WeakMap(), new WeakMap()]], + ["WeakSet instances", () => [new WeakSet(), new WeakSet()]], + ["cross-realm array", () => [runInNewContext("[1, 2]"), [1, 2]]], + [ + "class instance vs plain object", + () => [ + new (class Foo { + a = 1; + })(), + { a: 1 }, + ], + ], + ["objects with different symbol keys", () => [{ [Symbol("x")]: 1 }, { [Symbol("x")]: 1 }]], + ["object with a symbol key vs an empty object", () => [{ [Symbol("x")]: 1 }, {}]], + ["enumerable undefined property vs missing property", () => [{ a: undefined }, {}]], + [ + "sparse array vs array with undefined", + () => [ + [, 1], + [undefined, 1], + ], + ], + ["-0 vs +0 in an array", () => [[-0], [+0]]], + ]; + for (const [name, make] of unequal) { + test(`unequal: ${name}`, () => expectNotStrictEqual(make)); + } + + const equal: [string, MakePair][] = [ + ["invalid dates", () => [new Date(NaN), new Date(NaN)]], + ["transparent proxy vs its target's shape", () => [new Proxy({ a: 1 }, {}), { a: 1 }]], + ["transparent proxy of an array vs an equal array", () => [new Proxy([1, 2], {}), [1, 2]]], + [ + "RegExp with the same lastIndex", + () => { + const a = /a/g; + a.lastIndex = 3; + const b = /a/g; + b.lastIndex = 3; + return [a, b]; + }, + ], + ["errors with deep-equal causes", () => [new Error("m", { cause: { a: 1 } }), new Error("m", { cause: { a: 1 } })]], + [ + "AggregateError with equal errors", + () => [new AggregateError([new TypeError("a")], "m"), new AggregateError([new TypeError("a")], "m")], + ], + [ + "null prototype objects with the same properties", + () => [ + { __proto__: null, a: 1 }, + { __proto__: null, a: 1 }, + ], + ], + [ + "instances of the same class", + (() => { + class Foo { + a = 1; + } + return () => [new Foo(), new Foo()] as [unknown, unknown]; + })(), + ], + [ + "objects sharing the same symbol key", + (() => { + const sym = Symbol("x"); + return () => [{ [sym]: 1 }, { [sym]: 1 }] as [unknown, unknown]; + })(), + ], + [ + "maps with object keys in different order", + () => [ + new Map([ + [{ a: 1 }, 1], + [{ b: 2 }, 2], + ]), + new Map([ + [{ b: 2 }, 2], + [{ a: 1 }, 1], + ]), + ], + ], + ["sets with objects in different order", () => [new Set([{ a: 1 }, { b: 2 }]), new Set([{ b: 2 }, { a: 1 }])]], + ]; + for (const [name, make] of equal) { + test(`equal: ${name}`, () => expectStrictEqual(make)); + } +}); + +// https://github.com/oven-sh/bun/issues/29030 +test("deepStrictEqual compares prototypes", () => { + expect(() => assert.deepStrictEqual({}, Object.create(null))).toThrow(AssertionError); + expect(() => assert.deepStrictEqual(Object.create(null), {})).toThrow(AssertionError); +}); + +// https://github.com/oven-sh/bun/issues/28760 +test("sets holding duplicate structurally-equal objects are not equal to sets without them", () => { + const a = () => new Set([{ a: 1 }, { a: 1 }]); + const b = () => new Set([{ a: 1 }, { a: 2 }]); + expect(() => assert.deepEqual(a(), b())).toThrow(AssertionError); + expect(() => assert.deepStrictEqual(a(), b())).toThrow(AssertionError); +}); + +// https://github.com/oven-sh/bun/issues/23877 +test("deepEqual uses == for primitives", () => { + assert.deepEqual("+00000000", false); + expect(() => assert.notDeepEqual("+00000000", false)).toThrow(AssertionError); +}); + +describe("assert.deepEqual", () => { + const equal: [string, MakePair][] = [ + ["objects whose values are == but not ===", () => [{ a: 1 }, { a: "1" }]], + ["arrays whose elements are == but not ===", () => [[0], [false]]], + ["null vs undefined", () => [null, undefined]], + ["maps with == keys", () => [new Map([[1, "a"]]), new Map([["1", "a"]])]], + ["sets with == values", () => [new Set([1]), new Set(["1"])]], + ["invalid dates", () => [new Date(NaN), new Date(NaN)]], + ]; + for (const [name, make] of equal) { + test(`equal: ${name}`, () => expectLooseEqual(make)); + } + + const unequal: [string, MakePair][] = [ + ["enumerable undefined property vs missing property", () => [{ a: undefined }, {}]], + [ + "sparse array vs array with undefined", + () => [ + [, 1], + [undefined, 1], + ], + ], + ["dates with different times", () => [new Date(0), new Date(1)]], + ["objects with different Symbol.toStringTag", () => [{ [Symbol.toStringTag]: "a" }, { [Symbol.toStringTag]: "b" }]], + ]; + for (const [name, make] of unequal) { + test(`unequal: ${name}`, () => expectNotLooseEqual(make)); + } + + test("loose equality still distinguishes sparse holes from absent indices", () => { + // eslint-disable-next-line no-sparse-arrays + assert.deepEqual([, 1], [, 1]); + // eslint-disable-next-line no-sparse-arrays + expect(() => assert.deepEqual([, 1], [1])).toThrow(AssertionError); + }); +}); + +describe("assert.partialDeepStrictEqual", () => { + test("expected subset matches", () => { + assert.partialDeepStrictEqual({ a: { b: { c: 1 } }, z: 9 }, { a: { b: {} } }); + assert.partialDeepStrictEqual([1, 2, 3], [2]); + assert.partialDeepStrictEqual(new Set([{ a: 1 }, { b: 2 }]), new Set([{ b: 2 }])); + assert.partialDeepStrictEqual( + new Map([ + ["a", 1], + ["b", 2], + ]), + new Map([["b", 2]]), + ); + }); + + test("circular structures with different values are not equal", () => { + const a: any = { x: 1 }; + a.self = a; + const b: any = { x: 2 }; + b.self = b; + expect(() => assert.partialDeepStrictEqual(a, b)).toThrow(AssertionError); + }); + + test("circular structures with equal values are equal", () => { + const a: any = { x: 1 }; + a.self = a; + const b: any = { x: 1 }; + b.self = b; + assert.partialDeepStrictEqual(a, b); + }); + + test("URLs with different hrefs are not equal", () => { + expect(() => assert.partialDeepStrictEqual(new URL("http://a.com/"), new URL("http://b.com/"))).toThrow( + AssertionError, + ); + assert.partialDeepStrictEqual(new URL("http://a.com/"), new URL("http://a.com/")); + }); + + test("array elements of the expected subset must appear in order", () => { + assert.partialDeepStrictEqual([1, 2, 3], [1, 3]); + expect(() => assert.partialDeepStrictEqual([1, 2, 3], [3, 1])).toThrow(AssertionError); + }); + + test("prototypes are not compared", () => { + assert.partialDeepStrictEqual({ __proto__: null, a: 1 }, { a: 1 }); + class Foo { + a = 1; + } + assert.partialDeepStrictEqual(new Foo(), { a: 1 }); + }); + + test("boxed primitives are compared by value", () => { + expect(() => assert.partialDeepStrictEqual(Object("a"), Object("b"))).toThrow(AssertionError); + assert.partialDeepStrictEqual(Object("a"), Object("a")); + }); +}); + +describe("regexp properties", () => { + test("lastIndex participates in strict and loose comparison", () => { + const a = /a/g; + a.lastIndex = 3; + expect(() => assert.deepEqual(a, /a/g)).toThrow(AssertionError); + expect(isDeepStrictEqual(a, /a/g)).toBe(false); + }); +});