From d99069461c419c477305d22a2175cda5e14a5b6d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:29:10 +0000 Subject: [PATCH 01/15] bun:test: compare an expected Date, Error, Map, Set or function by value in toMatchObject and Bun.deepMatch Subset matching becomes an isPartial mode of Bun__deepEquals. The expected value's type selects the comparison, so a Date, Error, Map, Set, RegExp, typed array, boxed primitive or function on the expected side goes through the same typed arm toEqual uses, and only a plain object or array is walked key by key. The untyped Bun__deepMatch walk is removed. --- packages/bun-types/bun.d.ts | 5 + src/jsc/JSValue.rs | 18 +- src/jsc/bindings/BunObject.cpp | 5 +- src/jsc/bindings/bindings.cpp | 366 +++++++----------- src/jsc/bindings/headers-handwritten.h | 49 +-- src/jsc/bindings/headers.h | 2 +- src/runtime/test_runner/expect.rs | 2 +- .../test_runner/expect/toMatchObject.rs | 2 +- test/js/bun/bun-object/deep-match.spec.ts | 119 ++++-- test/js/bun/test/expect.test.js | 92 +++++ .../test/snapshot-tests/bun-snapshots.test.ts | 6 + test/js/node/net/server.spec.ts | 4 +- test/vendor.json | 3 +- 13 files changed, 348 insertions(+), 325 deletions(-) diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index e6e36301b614..fa6e1de2b1de 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -2920,6 +2920,11 @@ declare module "bun" { * Returns true if all properties in the subset exist in the * other and have equal values. * + * A plain object or array in the subset is matched property by property. + * Any other object in the subset (a `Date`, `Error`, `Map`, `Set`, `RegExp`, + * typed array, boxed primitive or function) must be a value of the same type + * that `Bun.deepEquals` considers equal. + * * This also powers expect().toMatchObject in `bun:test` */ function deepMatch(subset: unknown, a: unknown): boolean; diff --git a/src/jsc/JSValue.rs b/src/jsc/JSValue.rs index 70c5c377612d..95e69d850d97 100644 --- a/src/jsc/JSValue.rs +++ b/src/jsc/JSValue.rs @@ -2819,20 +2819,11 @@ impl JSValue { }) } /// `JSValue.jestDeepMatch` — `expect(a).toMatchObject(b)` / - /// snapshot-property-matcher subset comparison. - pub fn jest_deep_match( - self, - subset: JSValue, - global: &JSGlobalObject, - replace_props_with_asymmetric_matchers: bool, - ) -> JsResult { + /// snapshot-property-matcher subset comparison. A matched asymmetric + /// matcher in `subset` is copied onto `self` for the failure diff. + pub fn jest_deep_match(self, subset: JSValue, global: &JSGlobalObject) -> JsResult { host_fn::from_js_host_call_generic(global, || { - JSC__JSValue__jestDeepMatch( - self, - subset, - global, - replace_props_with_asymmetric_matchers, - ) + JSC__JSValue__jestDeepMatch(self, subset, global) }) } @@ -2944,7 +2935,6 @@ unsafe extern "C" { this: JSValue, subset: JSValue, global: &JSGlobalObject, - replace_props: bool, ) -> bool; safe fn JSC__JSValue__asBigIntCompare( this: JSValue, diff --git a/src/jsc/bindings/BunObject.cpp b/src/jsc/bindings/BunObject.cpp index 42a00f2b4ebf..27589f03ab82 100644 --- a/src/jsc/bindings/BunObject.cpp +++ b/src/jsc/bindings/BunObject.cpp @@ -730,10 +730,9 @@ JSC_DEFINE_HOST_FUNCTION(functionBunDeepMatch, (JSGlobalObject * globalObject, J return {}; } - std::set objVisited; - std::set subsetVisited; + Vector, 16> stack; MarkedArgumentBuffer gcBuffer; - bool match = Bun__deepMatch(object, &objVisited, subset, &subsetVisited, globalObject, scope, &gcBuffer, false, false); + bool match = Bun__deepEquals(globalObject, object, subset, gcBuffer, stack, scope, true); RETURN_IF_EXCEPTION(scope, {}); return JSValue::encode(jsBoolean(match)); } diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index e08008192fa1..96fcaf872543 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -250,28 +250,8 @@ enum class AsymmetricMatcherConstructorType : int8_t { InstanceOf = 9, }; -// Ensure we instantiate the true and false variants of this function -template bool Bun__deepMatch( - JSValue objValue, - std::set* seenObjProperties, - JSValue subsetValue, - std::set* seenSubsetProperties, - JSGlobalObject* globalObject, - ThrowScope& throwScope, - MarkedArgumentBuffer* gcBuffer, - bool replacePropsWithAsymmetricMatchers, - bool isMatchingObjectContaining); - -template bool Bun__deepMatch( - JSValue objValue, - std::set* seenObjProperties, - JSValue subsetValue, - std::set* seenSubsetProperties, - JSGlobalObject* globalObject, - ThrowScope& throwScope, - MarkedArgumentBuffer* gcBuffer, - bool replacePropsWithAsymmetricMatchers, - bool isMatchingObjectContaining); +template +static bool subsetPropertiesMatch(JSC::JSGlobalObject* globalObject, JSObject* received, JSObject* expected, MarkedArgumentBuffer& gcBuffer, Vector, 16>& stack, ThrowScope& scope); extern "C" bool Expect_readFlagsAndProcessPromise(JSC::EncodedJSValue instanceValue, JSC::JSGlobalObject* globalObject, ExpectFlags* flags, JSC::EncodedJSValue* value, AsymmetricMatcherConstructorType* constructorType); @@ -560,10 +540,12 @@ AsymmetricMatcherResult matchAsymmetricMatcherAndGetFlags(JSGlobalObject* global JSValue patternObject = expectObjectContaining->m_objectValue.get(); if (patternObject.isObject()) { if (otherProp.isObject()) { - // SAFETY: visited property sets are not required when - // `enableAsymmetricMatchers` and `isMatchingObjectContaining` - // are both true - bool match = Bun__deepMatch(otherProp, nullptr, patternObject, nullptr, globalObject, throwScope, nullptr, false, true); + // The pattern is a subset at the top level only. Each property value is + // compared exactly, like jest's ObjectContaining: nested subset matching + // needs a nested objectContaining. + Vector, 16> stack; + MarkedArgumentBuffer gcBuffer; + bool match = subsetPropertiesMatch(globalObject, otherProp.getObject(), patternObject.getObject(), gcBuffer, stack, throwScope); RETURN_IF_EXCEPTION(throwScope, AsymmetricMatcherResult::FAIL); if (match) { return AsymmetricMatcherResult::PASS; @@ -695,7 +677,7 @@ JSValue getIndexWithoutAccessors(JSGlobalObject* globalObject, JSObject* obj, ui return JSValue(); } -template +template std::optional specialObjectsDequal(JSC::JSGlobalObject* globalObject, MarkedArgumentBuffer& gcBuffer, Vector, 16>& stack, ThrowScope& scope, JSCell* _Nonnull c1, JSCell* _Nonnull c2); template @@ -722,7 +704,7 @@ static ALWAYS_INLINE bool hasExtraOwnProperties(JSC::Structure* structure) // node compares the non-index own properties of typed arrays as well; // only the node entry point (checkPrototypes) pays for this. -template +template static bool nonIndexOwnPropertiesEqual(JSC::JSGlobalObject* globalObject, MarkedArgumentBuffer& gcBuffer, Vector, 16>& stack, ThrowScope& scope, JSC::JSObject* o1, JSC::JSObject* o2) { VM& vm = globalObject->vm(); @@ -758,7 +740,7 @@ static bool nonIndexOwnPropertiesEqual(JSC::JSGlobalObject* globalObject, Marked } JSValue v1 = slot1.getValue(globalObject, propertyName); RETURN_IF_EXCEPTION(scope, false); - bool eq = Bun__deepEquals(globalObject, v1, v2, gcBuffer, stack, scope, true); + bool eq = Bun__deepEquals(globalObject, v1, v2, gcBuffer, stack, scope, true); RETURN_IF_EXCEPTION(scope, false); if (!eq) { return false; @@ -809,7 +791,7 @@ static bool isWellKnownConstructor(JSValue value) || info == WebCore::JSBufferConstructor::info(); } -template +template bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2, MarkedArgumentBuffer& gcBuffer, Vector, 16>& stack, ThrowScope& scope, bool addToStack) { VM& vm = globalObject->vm(); @@ -956,10 +938,26 @@ bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2, } } - std::optional isSpecialEqual = specialObjectsDequal(globalObject, gcBuffer, stack, scope, c1, c2); + if constexpr (isPartial) { + // Subset matching: v2 is the expected value and decides how the pair is compared. + // An expected Date, Error, Map, function, ... matches only a received value of the + // same type, through its arm in specialObjectsDequal. An expected plain object is + // matched key by key against a received value of any type, so `{ message: "x" }` + // still matches an Error. When the types differ, every arm returns before it + // recurses, so dispatching on the expected type never swaps the received/expected + // order of a nested comparison. + std::optional isSpecialEqual = c1->type() == c2->type() + ? specialObjectsDequal(globalObject, gcBuffer, stack, scope, c1, c2) + : specialObjectsDequal(globalObject, gcBuffer, stack, scope, c2, c1); + RETURN_IF_EXCEPTION(scope, false); + if (isSpecialEqual.has_value()) return *isSpecialEqual; + RELEASE_AND_RETURN(scope, (subsetPropertiesMatch(globalObject, v1.getObject(), v2.getObject(), gcBuffer, stack, scope))); + } + + std::optional isSpecialEqual = specialObjectsDequal(globalObject, gcBuffer, stack, scope, c1, c2); RETURN_IF_EXCEPTION(scope, false); if (isSpecialEqual.has_value()) return WTF::move(*isSpecialEqual); - isSpecialEqual = specialObjectsDequal(globalObject, gcBuffer, stack, scope, c2, c1); + isSpecialEqual = specialObjectsDequal(globalObject, gcBuffer, stack, scope, c2, c1); RETURN_IF_EXCEPTION(scope, false); if (isSpecialEqual.has_value()) return WTF::move(*isSpecialEqual); JSObject* o1 = v1.getObject(); @@ -1007,7 +1005,7 @@ bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2, } } - auto eql = Bun__deepEquals(globalObject, left, right, gcBuffer, stack, scope, true); + auto eql = Bun__deepEquals(globalObject, left, right, gcBuffer, stack, scope, true); RETURN_IF_EXCEPTION(scope, false); if (!eql) return false; } @@ -1026,7 +1024,7 @@ bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2, if constexpr (checkPrototypes) { // node compares own enumerable non-index string+symbol props via getOwnNonIndexProperties; // the Bun.deepEquals symbol-only block below walks the prototype chain, so diverge here. - return nonIndexOwnPropertiesEqual(globalObject, gcBuffer, stack, scope, o1, o2); + return nonIndexOwnPropertiesEqual(globalObject, gcBuffer, stack, scope, o1, o2); } JSC::PropertyNameArrayBuilder a1(vm, PropertyNameMode::Symbols, PrivateSymbolMode::Exclude); @@ -1068,7 +1066,7 @@ bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2, return false; } - auto eql = Bun__deepEquals(globalObject, prop1, prop2, gcBuffer, stack, scope, true); + auto eql = Bun__deepEquals(globalObject, prop1, prop2, gcBuffer, stack, scope, true); RETURN_IF_EXCEPTION(scope, false); if (!eql) return false; } @@ -1202,7 +1200,7 @@ bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2, RETURN_IF_EXCEPTION(scope, false); if (same) continue; - auto eql = Bun__deepEquals(globalObject, left, right, gcBuffer, stack, scope, true); + auto eql = Bun__deepEquals(globalObject, left, right, gcBuffer, stack, scope, true); RETURN_IF_EXCEPTION(scope, false); if (!eql) { return false; @@ -1277,7 +1275,7 @@ bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2, return false; } - auto eql = Bun__deepEquals(globalObject, prop1, prop2, gcBuffer, stack, scope, true); + auto eql = Bun__deepEquals(globalObject, prop1, prop2, gcBuffer, stack, scope, true); RETURN_IF_EXCEPTION(scope, false); if (!eql) return false; } @@ -1298,6 +1296,84 @@ bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2, return true; } +// Subset matching for toMatchObject, Bun.deepMatch and expect.objectContaining. Every +// enumerable property of `expected` must exist on `received`, and the two values must be +// equal in the mode the template arguments select (`isPartial` recurses as a subset, +// otherwise exactly). Extra properties on `received` are ignored. An expected array +// matches only a received array of the same length with the same property count. +template +static bool subsetPropertiesMatch(JSC::JSGlobalObject* globalObject, JSObject* received, JSObject* expected, MarkedArgumentBuffer& gcBuffer, Vector, 16>& stack, ThrowScope& scope) +{ + VM& vm = globalObject->vm(); + PropertyNameArrayBuilder expectedProps(vm, PropertyNameMode::StringsAndSymbols, PrivateSymbolMode::Exclude); + expected->getPropertyNames(globalObject, expectedProps, DontEnumPropertiesMode::Exclude); + RETURN_IF_EXCEPTION(scope, false); + + bool expectedIsArray = isArray(globalObject, expected); + RETURN_IF_EXCEPTION(scope, false); + if (expectedIsArray) { + bool receivedIsArray = isArray(globalObject, received); + RETURN_IF_EXCEPTION(scope, false); + if (!receivedIsArray || received->getArrayLength() != expected->getArrayLength()) { + return false; + } + PropertyNameArrayBuilder receivedProps(vm, PropertyNameMode::StringsAndSymbols, PrivateSymbolMode::Exclude); + received->getPropertyNames(globalObject, receivedProps, DontEnumPropertiesMode::Exclude); + RETURN_IF_EXCEPTION(scope, false); + if (receivedProps.size() != expectedProps.size()) { + return false; + } + } + + for (const auto& property : expectedProps) { + JSValue receivedProp = received->getIfPropertyExists(globalObject, property); + RETURN_IF_EXCEPTION(scope, false); + if (receivedProp.isEmpty()) { + return false; + } + JSValue expectedProp = expected->get(globalObject, property); + RETURN_IF_EXCEPTION(scope, false); + + if constexpr (enableAsymmetricMatchers && isPartial) { + // toMatchObject and the snapshot property matchers copy a matched asymmetric + // matcher to the other side, so the failure diff does not flag that property. + JSCell* expectedCell = expectedProp.isCell() ? expectedProp.asCell() : nullptr; + JSCell* receivedCell = receivedProp.isCell() ? receivedProp.asCell() : nullptr; + if (expectedCell && expectedCell->type() == JSC::JSType(JSDOMWrapperType)) { + switch (matchAsymmetricMatcher(globalObject, expectedProp, receivedProp, scope)) { + case AsymmetricMatcherResult::FAIL: + return false; + case AsymmetricMatcherResult::PASS: + received->putDirectMayBeIndex(globalObject, property, expectedProp); + RETURN_IF_EXCEPTION(scope, false); + continue; + case AsymmetricMatcherResult::NOT_MATCHER: + break; + } + } else if (receivedCell && receivedCell->type() == JSC::JSType(JSDOMWrapperType)) { + switch (matchAsymmetricMatcher(globalObject, receivedProp, expectedProp, scope)) { + case AsymmetricMatcherResult::FAIL: + return false; + case AsymmetricMatcherResult::PASS: + expected->putDirectMayBeIndex(globalObject, property, receivedProp); + RETURN_IF_EXCEPTION(scope, false); + continue; + case AsymmetricMatcherResult::NOT_MATCHER: + break; + } + } + } + + bool eq = Bun__deepEquals(globalObject, receivedProp, expectedProp, gcBuffer, stack, scope, true); + RETURN_IF_EXCEPTION(scope, false); + if (!eq) { + return false; + } + } + + return true; +} + static bool isTemporalObject(JSC::JSObject* object) { return object->inherits() @@ -1368,18 +1444,20 @@ struct DeepEqualsMode { bool enableAsymmetricMatchers; bool checkPrototypes; bool skipPrototypeIdentity; + bool isPartial; bool (*deepEquals)(JSC::JSGlobalObject*, JSValue, JSValue, MarkedArgumentBuffer&, Vector, 16>&, ThrowScope&, bool); bool (*nonIndexOwnPropertiesEqual)(JSC::JSGlobalObject*, MarkedArgumentBuffer&, Vector, 16>&, ThrowScope&, JSObject*, JSObject*); }; -template +template static constexpr DeepEqualsMode deepEqualsMode { isStrict, enableAsymmetricMatchers, checkPrototypes, skipPrototypeIdentity, - &Bun__deepEquals, - checkPrototypes ? &nonIndexOwnPropertiesEqual : nullptr, + isPartial, + &Bun__deepEquals, + checkPrototypes ? &nonIndexOwnPropertiesEqual : nullptr, }; // The per-type comparisons (Map, Set, Date, typed arrays, ...) are compiled once @@ -1570,10 +1648,18 @@ static std::optional specialObjectsDequalSlow(const DeepEqualsMode& mode, RETURN_IF_EXCEPTION(scope, {}); auto rightCause = right->get(globalObject, cause); RETURN_IF_EXCEPTION(scope, {}); - bool causesEqual = mode.deepEquals(globalObject, leftCause, rightCause, gcBuffer, stack, scope, true); - RETURN_IF_EXCEPTION(scope, {}); - if (!causesEqual) { - return false; + // Subset matching only checks a cause the expected error (right) has. + if (!(mode.isPartial && rightCause.isUndefined())) { + bool causesEqual = mode.deepEquals(globalObject, leftCause, rightCause, gcBuffer, stack, scope, true); + RETURN_IF_EXCEPTION(scope, {}); + if (!causesEqual) { + return false; + } + } + + if (mode.isPartial) { + // Subset matching walks the expected error's enumerable properties. + break; } // check arbitrary enumerable properties. `.stack` is not checked. @@ -1964,7 +2050,7 @@ static std::optional specialObjectsDequalSlow(const DeepEqualsMode& mode, return std::nullopt; } -template +template std::optional specialObjectsDequal(JSC::JSGlobalObject* globalObject, MarkedArgumentBuffer& gcBuffer, Vector, 16>& stack, ThrowScope& scope, JSCell* _Nonnull c1, JSCell* _Nonnull c2) { VM& vm = globalObject->vm(); @@ -2001,7 +2087,7 @@ std::optional specialObjectsDequal(JSC::JSGlobalObject* globalObject, Mark JSValue key2; bool foundMatchingKey = false; while (iter2->next(globalObject, key2)) { - bool equal = Bun__deepEquals(globalObject, key1, key2, gcBuffer, stack, scope, false); + bool equal = Bun__deepEquals(globalObject, key1, key2, gcBuffer, stack, scope, false); RETURN_IF_EXCEPTION(scope, {}); if (equal) { foundMatchingKey = true; @@ -2046,7 +2132,7 @@ std::optional specialObjectsDequal(JSC::JSGlobalObject* globalObject, Mark JSValue key2; bool foundMatchingKey = false; while (iter2->nextKeyValue(globalObject, key2, value2)) { - bool keysEqual = Bun__deepEquals(globalObject, key1, key2, gcBuffer, stack, scope, false); + bool keysEqual = Bun__deepEquals(globalObject, key1, key2, gcBuffer, stack, scope, false); RETURN_IF_EXCEPTION(scope, {}); if (keysEqual) { foundMatchingKey = true; @@ -2061,7 +2147,7 @@ std::optional specialObjectsDequal(JSC::JSGlobalObject* globalObject, Mark // Compare both values below. } - bool valuesEqual = Bun__deepEquals(globalObject, value1, value2, gcBuffer, stack, scope, false); + bool valuesEqual = Bun__deepEquals(globalObject, value1, value2, gcBuffer, stack, scope, false); RETURN_IF_EXCEPTION(scope, {}); if (!valuesEqual) { return false; @@ -2099,7 +2185,7 @@ std::optional specialObjectsDequal(JSC::JSGlobalObject* globalObject, Mark case GlobalProxyType: case NumberObjectType: case BooleanObjectType: - return specialObjectsDequalSlow(deepEqualsMode, globalObject, gcBuffer, stack, scope, c1, c2); + return specialObjectsDequalSlow(deepEqualsMode, globalObject, gcBuffer, stack, scope, c1, c2); default: break; } @@ -2144,179 +2230,21 @@ std::optional specialObjectsDequal(JSC::JSGlobalObject* globalObject, Mark return std::nullopt; } -// The other combinations are instantiated by their uses in this file. This one is -// only reached from `Bun.deepEquals(a, b, true, true)` in BunObject.cpp. +// The other combinations are instantiated by their uses in this file. These two are +// only reached from `Bun.deepEquals(a, b, true, true)` and `Bun.deepMatch` in BunObject.cpp. template bool Bun__deepEquals(JSC::JSGlobalObject*, JSValue, JSValue, MarkedArgumentBuffer&, Vector, 16>&, ThrowScope&, bool); - -/** - * @brief `Bun.deepMatch(a, b)` - * - * @note - * The sets recording already visited properties (`seenObjProperties`, - * `seenSubsetProperties`, and `gcBuffer`) aren not needed when both - * `enableAsymmetricMatchers` and `isMatchingObjectContaining` are true. In - * this case, it is safe to pass a `nullptr`. - * - * `gcBuffer` ensures JSC's stack scan does not come up empty-handed and free - * properties currently within those stacks. Likely unnecessary, but better to - * be safe tnan sorry - * - * @tparam enableAsymmetricMatchers - * @param objValue - * @param seenObjProperties already visited properties of `objValue`. - * @param subsetValue - * @param seenSubsetProperties already visited properties of `subsetValue`. - * @param globalObject - * @param throwScope - * @param gcBuffer - * @param replacePropsWithAsymmetricMatchers - * @param isMatchingObjectContaining - * - * @return true - * @return false - */ -template -bool Bun__deepMatch( - JSValue objValue, - std::set* seenObjProperties, - JSValue subsetValue, - std::set* seenSubsetProperties, - JSGlobalObject* globalObject, - ThrowScope& throwScope, - MarkedArgumentBuffer* gcBuffer, - bool replacePropsWithAsymmetricMatchers, - bool isMatchingObjectContaining) -{ - - // Caller must ensure only objects are passed to this function. - ASSERT(objValue.isCell()); - ASSERT(subsetValue.isCell()); - VM& vm = globalObject->vm(); - if (!vm.isSafeToRecurse()) [[unlikely]] { - throwStackOverflowError(globalObject, throwScope); - return false; - } - - // fast path for reference equality. - if (objValue == subsetValue) return true; - JSObject* obj = objValue.getObject(); - JSObject* subsetObj = subsetValue.getObject(); - - PropertyNameArrayBuilder subsetProps(vm, PropertyNameMode::StringsAndSymbols, PrivateSymbolMode::Include); - subsetObj->getPropertyNames(globalObject, subsetProps, DontEnumPropertiesMode::Exclude); - RETURN_IF_EXCEPTION(throwScope, false); - - // TODO: add fast paths for: - // - two "simple" objects (using ->forEachProperty in both) - // - two "simple" arrays - // similar to what is done in deepEquals (canPerformFastPropertyEnumerationForIterationBun) - - // arrays should match exactly - bool objIsArray = isArray(globalObject, objValue); - RETURN_IF_EXCEPTION(throwScope, false); - bool subsetIsArray = objIsArray && isArray(globalObject, subsetValue); - RETURN_IF_EXCEPTION(throwScope, false); - if (subsetIsArray) { - if (obj->getArrayLength() != subsetObj->getArrayLength()) { - return false; - } - PropertyNameArrayBuilder objProps(vm, PropertyNameMode::StringsAndSymbols, PrivateSymbolMode::Include); - obj->getPropertyNames(globalObject, objProps, DontEnumPropertiesMode::Exclude); - RETURN_IF_EXCEPTION(throwScope, false); - if (objProps.size() != subsetProps.size()) { - return false; - } - } - - for (const auto& property : subsetProps) { - JSValue prop = obj->getIfPropertyExists(globalObject, property); - RETURN_IF_EXCEPTION(throwScope, false); - if (prop.isEmpty()) { - return false; - } - - JSValue subsetProp = subsetObj->get(globalObject, property); - RETURN_IF_EXCEPTION(throwScope, false); - - JSCell* subsetPropCell = !subsetProp.isEmpty() && subsetProp.isCell() ? subsetProp.asCell() : nullptr; - JSCell* propCell = prop.isCell() ? prop.asCell() : nullptr; - - if constexpr (enableAsymmetricMatchers) { - if (subsetPropCell && subsetPropCell->type() == JSC::JSType(JSDOMWrapperType)) { - switch (matchAsymmetricMatcher(globalObject, subsetProp, prop, throwScope)) { - case AsymmetricMatcherResult::FAIL: - return false; - case AsymmetricMatcherResult::PASS: - if (replacePropsWithAsymmetricMatchers) { - obj->putDirectMayBeIndex(globalObject, property, subsetProp); - RETURN_IF_EXCEPTION(throwScope, false); - } - // continue to next subset prop - continue; - case AsymmetricMatcherResult::NOT_MATCHER: - break; - } - } else if (propCell && propCell->type() == JSC::JSType(JSDOMWrapperType)) { - switch (matchAsymmetricMatcher(globalObject, prop, subsetProp, throwScope)) { - case AsymmetricMatcherResult::FAIL: - return false; - case AsymmetricMatcherResult::PASS: - if (replacePropsWithAsymmetricMatchers) { - subsetObj->putDirectMayBeIndex(globalObject, property, prop); - RETURN_IF_EXCEPTION(throwScope, false); - } - // continue to next subset prop - continue; - case AsymmetricMatcherResult::NOT_MATCHER: - break; - } - } - } - - if (subsetProp.isObject() and prop.isObject()) { - // if this is called from inside an objectContaining asymmetric matcher, it should behave slightly differently: - // in such case, it expects exhaustive matching of any nested object properties, not just a subset, - // and the user would need to opt-in to subset matching by using another nested objectContaining matcher - if (enableAsymmetricMatchers && isMatchingObjectContaining) { - Vector, 16> stack; - MarkedArgumentBuffer gcBuffer; - auto eql = Bun__deepEquals(globalObject, prop, subsetProp, gcBuffer, stack, throwScope, true); - RETURN_IF_EXCEPTION(throwScope, false); - if (!eql) return false; - } else { - ASSERT(seenObjProperties != nullptr); - ASSERT(seenSubsetProperties != nullptr); - ASSERT(gcBuffer != nullptr); - auto didInsertProp = seenObjProperties->insert(JSC::JSValue::encode(prop)); - auto didInsertSubset = seenSubsetProperties->insert(JSC::JSValue::encode(subsetProp)); - gcBuffer->append(prop); - gcBuffer->append(subsetProp); - // property cycle detected - if (!didInsertProp.second || !didInsertSubset.second) continue; - bool matched = Bun__deepMatch(prop, seenObjProperties, subsetProp, seenSubsetProperties, globalObject, throwScope, gcBuffer, replacePropsWithAsymmetricMatchers, isMatchingObjectContaining); - RETURN_IF_EXCEPTION(throwScope, false); - if (!matched) return false; - } - } else { - auto same = JSC::sameValue(globalObject, prop, subsetProp); - RETURN_IF_EXCEPTION(throwScope, false); - if (!same) return false; - } - } - - return true; -} +template bool Bun__deepEquals(JSC::JSGlobalObject*, JSValue, JSValue, MarkedArgumentBuffer&, Vector, 16>&, ThrowScope&, bool); // anonymous namespace to avoid name collision namespace { -template +template inline bool deepEqualsWrapperImpl(JSC::EncodedJSValue a, JSC::EncodedJSValue b, JSC::JSGlobalObject* global) { auto& vm = global->vm(); auto scope = DECLARE_THROW_SCOPE(vm); Vector, 16> stack; MarkedArgumentBuffer args; - bool result = Bun__deepEquals(global, JSC::JSValue::decode(a), JSC::JSValue::decode(b), args, stack, scope, true); + bool result = Bun__deepEquals(global, JSC::JSValue::decode(a), JSC::JSValue::decode(b), args, stack, scope, true); RELEASE_AND_RETURN(scope, result); } } @@ -3252,17 +3180,11 @@ bool Bun__deepEqualsNodeStrictSkipProto(JSC::EncodedJSValue JSValue0, JSC::Encod #undef IMPL_DEEP_EQUALS_WRAPPER -bool JSC__JSValue__jestDeepMatch(JSC::EncodedJSValue JSValue0, JSC::EncodedJSValue JSValue1, JSC::JSGlobalObject* globalObject, bool replacePropsWithAsymmetricMatchers) +// `expect(received).toMatchObject(expected)` and the snapshot property matchers: +// `expected` is a subset of `received`, with asymmetric matchers enabled. +bool JSC__JSValue__jestDeepMatch(JSC::EncodedJSValue received, JSC::EncodedJSValue expected, JSC::JSGlobalObject* globalObject) { - JSValue obj = JSValue::decode(JSValue0); - JSValue subset = JSValue::decode(JSValue1); - - ThrowScope scope = DECLARE_THROW_SCOPE(globalObject->vm()); - - std::set objVisited; - std::set subsetVisited; - MarkedArgumentBuffer gcBuffer; - RELEASE_AND_RETURN(scope, Bun__deepMatch(obj, &objVisited, subset, &subsetVisited, globalObject, scope, &gcBuffer, replacePropsWithAsymmetricMatchers, false)); + return deepEqualsWrapperImpl(received, expected, globalObject); } extern "C" bool Bun__JSValue__isAsyncContextFrame(JSC::EncodedJSValue value) diff --git a/src/jsc/bindings/headers-handwritten.h b/src/jsc/bindings/headers-handwritten.h index 81bd5101cb79..5d0f8d1ef07a 100644 --- a/src/jsc/bindings/headers-handwritten.h +++ b/src/jsc/bindings/headers-handwritten.h @@ -5,7 +5,6 @@ #include "JavaScriptCore/ArgList.h" #include #include -#include #ifndef HEADERS_HANDWRITTEN #define HEADERS_HANDWRITTEN @@ -431,53 +430,11 @@ extern "C" JSC::EncodedJSValue Bun__encoding__constructFromUTF16(void*, const ch extern "C" void Bun__EventLoop__runCallback2(JSC::JSGlobalObject* global, JSC::EncodedJSValue callback, JSC::EncodedJSValue thisValue, JSC::EncodedJSValue arg1, JSC::EncodedJSValue arg2); /// @note throws a JS exception and returns false if a stack overflow occurs -template +/// `isPartial` is subset matching (`toMatchObject`, `Bun.deepMatch`): `v2` is the expected +/// value, and only its enumerable properties are compared. +template bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSC::JSValue v1, JSC::JSValue v2, JSC::MarkedArgumentBuffer&, Vector, 16>& stack, JSC::ThrowScope& scope, bool addToStack); -/** - * @brief `Bun.deepMatch(a, b)` - * - * `object` and `subset` must be objects. In the future we should change the - * signature of this function to only take `JSC::JSCell`. For now, panics - * if either `object` or `subset` are not `JSCCell`. - * - * @note - * The sets recording already visited properties (`seenObjProperties` and - * `seenSubsetProperties`) aren not needed when both `enableAsymmetricMatchers` - * and `isMatchingObjectContaining` are true. In this case, it is safe to pass a - * `nullptr`. - * - * `gcBuffer` ensures JSC's stack scan does not come up empty-handed and free - * properties currently within those stacks. Likely unnecessary, but better to - * be safe tnan sorry - * - * - * @tparam enableAsymmetricMatchers - * @param objValue - * @param seenObjProperties already visited properties of `objValue`. - * @param subsetValue - * @param seenSubsetProperties already visited properties of `subsetValue`. - * @param globalObject - * @param Scope - * @param gcBuffer - * @param replacePropsWithAsymmetricMatchers - * @param isMatchingObjectContaining - * - * @return true - * @return false - */ -template -bool Bun__deepMatch( - JSC::JSValue object, - std::set* seenObjProperties, - JSC::JSValue subset, - std::set* seenSubsetProperties, - JSC::JSGlobalObject* globalObject, - JSC::ThrowScope& throwScope, - JSC::MarkedArgumentBuffer* gcBuffer, - bool replacePropsWithAsymmetricMatchers, - bool isMatchingObjectContaining); - extern "C" void Bun__remapStackFramePositions(void*, ZigStackFrame*, size_t); namespace Inspector { diff --git a/src/jsc/bindings/headers.h b/src/jsc/bindings/headers.h index a84471cb3164..a3c08f656d98 100644 --- a/src/jsc/bindings/headers.h +++ b/src/jsc/bindings/headers.h @@ -231,7 +231,7 @@ CPP_DECL bool JSC__JSValue__isSymbol(JSC::EncodedJSValue JSValue0); CPP_DECL bool JSC__JSValue__isTerminationException(JSC::EncodedJSValue JSValue0); CPP_DECL bool JSC__JSValue__isUInt32AsAnyInt(JSC::EncodedJSValue JSValue0); CPP_DECL bool JSC__JSValue__jestDeepEquals(JSC::EncodedJSValue JSValue0, JSC::EncodedJSValue JSValue1, JSC::JSGlobalObject* arg2); -CPP_DECL bool JSC__JSValue__jestDeepMatch(JSC::EncodedJSValue JSValue0, JSC::EncodedJSValue JSValue1, JSC::JSGlobalObject* arg2, bool arg3); +CPP_DECL bool JSC__JSValue__jestDeepMatch(JSC::EncodedJSValue JSValue0, JSC::EncodedJSValue JSValue1, JSC::JSGlobalObject* arg2); CPP_DECL bool JSC__JSValue__jestStrictDeepEquals(JSC::EncodedJSValue JSValue0, JSC::EncodedJSValue JSValue1, JSC::JSGlobalObject* arg2); CPP_DECL JSC::EncodedJSValue JSC__JSValue__jsNumberFromDouble(double arg0); CPP_DECL JSC::EncodedJSValue JSC__JSValue__jsNumberFromInt64(int64_t arg0); diff --git a/src/runtime/test_runner/expect.rs b/src/runtime/test_runner/expect.rs index fc594081687c..8ffa2affb12f 100644 --- a/src/runtime/test_runner/expect.rs +++ b/src/runtime/test_runner/expect.rs @@ -1137,7 +1137,7 @@ impl Expect { let prop_matchers = _prop_matchers; - if !value.jest_deep_match(prop_matchers, global_this, true)? { + if !value.jest_deep_match(prop_matchers, global_this)? { // TODO: print diff with properties from propertyMatchers let signature = Self::get_signature(fn_name, "propertyMatchers", false); let mut formatter = ConsoleObject::Formatter::new(global_this); diff --git a/src/runtime/test_runner/expect/toMatchObject.rs b/src/runtime/test_runner/expect/toMatchObject.rs index 67c3dccb75ba..4db398765a65 100644 --- a/src/runtime/test_runner/expect/toMatchObject.rs +++ b/src/runtime/test_runner/expect/toMatchObject.rs @@ -30,7 +30,7 @@ pub(crate) fn to_match_object( let property_matchers = args[0]; - let mut pass = received_object.jest_deep_match(property_matchers, global, true)?; + let mut pass = received_object.jest_deep_match(property_matchers, global)?; if not { pass = !pass; diff --git a/test/js/bun/bun-object/deep-match.spec.ts b/test/js/bun/bun-object/deep-match.spec.ts index 1a70301937ce..c5f91156fe70 100644 --- a/test/js/bun/bun-object/deep-match.spec.ts +++ b/test/js/bun/bun-object/deep-match.spec.ts @@ -77,6 +77,24 @@ describe("Bun.deepMatch", () => { new Set(["a", "b", "c"]), new Set(["a", "b", "c"]), ], + // values inside a Map are still matched as a subset + [new Map([["a", { b: 1 }]]), new Map([["a", { b: 1, c: 2 }]])], + + // Dates, Errors, RegExps and boxed primitives + [new Date(1), new Date(1)], + [{ d: new Date(1) }, { d: new Date(1) }], + [{ d: [new Date(1)] }, { d: [new Date(1)] }], + [new Error("a"), new Error("a")], + [{ e: new Error("a") }, { e: new Error("a", { cause: 1 }) }], + [{ e: new Error("a", { cause: { x: 1 } }) }, { e: new Error("a", { cause: { x: 1, y: 2 } }) }], + [{ e: new Error("a") }, { e: Object.assign(new Error("a"), { code: 1 }) }], + [{ r: /a/g }, { r: /a/g }], + [{ n: new Number(1) }, { n: new Number(1) }], + // a plain object in the subset matches the properties of any object + [{ message: "a" }, new Error("a")], + [{ e: { message: "a" } }, { e: new Error("a") }], + [{ d: {} }, { d: new Date() }], + [{ m: {} }, { m: new Map() }], ])("Bun.deepMatch(%p, %p) === true", (a, b) => { expect(Bun.deepMatch(a, b)).toBe(true); }); @@ -100,39 +118,63 @@ describe("Bun.deepMatch", () => { [[], [undefined]], [["a", "b", "c"], ["a", "b", "d"]], - // Maps - // FIXME: I assume this is incorrect but I need confirmation on expected behavior. - // [ - // new Map([ [1, 2], [2, 3], [3, 4] ]), - // new Map([ [1, 2], [2, 3] ]), - // ], - // [ - // new Map([ [1, 2], [2, 3], [3, 4] ]), - // new Map([ [1, 2], [2, 3], [3, 4], [4, 5] ]), - // ], - // [ - // new Map([ [1, 2], [2, 3], [3, 4], [4, 5] ]), - // new Map([ [1, 2], [2, 3], [3, 4] ]), - // ], + // Maps and Sets are compared by their entries, like `expect().toMatchObject()` in jest + [ + new Map([ [1, 2], [2, 3], [3, 4] ]), + new Map([ [1, 2], [2, 3] ]), + ], + [ + new Map([ [1, 2], [2, 3], [3, 4] ]), + new Map([ [1, 2], [2, 3], [3, 4], [4, 5] ]), + ], + [ + new Map([ [1, 2], [2, 3], [3, 4], [4, 5] ]), + new Map([ [1, 2], [2, 3], [3, 4] ]), + ], + [{ m: new Map([["a", 1]]) }, { m: new Map([["a", 2]]) }], + [{ m: new Map() }, { m: {} }], // Sets - // FIXME: I assume this is incorrect but I need confirmation on expected behavior. - // [ - // new Set([1, 2, 3]), - // new Set([4, 5, 6]), - // ], - // [ - // new Set([1, 2, 3]), - // new Set([1, 2]), - // ], - // [ - // new Set([1, 2]), - // new Set([1, 2, 3]), - // ], - // [ - // new Set(["a", "b", "c"]), - // new Set(["a", "b", "d"]), - // ], + [ + new Set([1, 2, 3]), + new Set([4, 5, 6]), + ], + [ + new Set([1, 2, 3]), + new Set([1, 2]), + ], + [ + new Set([1, 2]), + new Set([1, 2, 3]), + ], + [ + new Set(["a", "b", "c"]), + new Set(["a", "b", "d"]), + ], + [{ s: new Set() }, { s: {} }], + + // Dates are compared by their time value, Errors by name, message and cause + [new Date(1), new Date(2)], + [{ d: new Date(1) }, { d: new Date(2) }], + [{ d: new Date() }, { d: {} }], + [{ d: new Date(1) }, { d: [new Date(1)] }], + [new Error("a"), new Error("b")], + [{ e: new Error("a") }, { e: new Error("b") }], + [{ e: new Error("a") }, { e: new TypeError("a") }], + [{ e: new Error("a", { cause: 1 }) }, { e: new Error("a", { cause: 2 }) }], + [{ e: new Error("a", { cause: 1 }) }, { e: new Error("a") }], + [{ e: new Error("a") }, { e: { message: "b" } }], + [{ e: new Error("a") }, { e: {} }], + + // Functions match by identity only + [{ f: () => 1 }, { f: () => 1 }], + [{ f: () => 1 }, { f: {} }], + + // Boxed primitives and RegExps are compared by their value + [{ r: /a/ }, { r: /b/ }], + [{ r: /a/g }, { r: /a/i }], + [{ n: new Number(1) }, { n: new Number(2) }], + [{ s: new String("a") }, { s: new String("b") }], ])("Bun.deepMatch(%p, %p) === false", (a, b) => { expect(Bun.deepMatch(a, b)).toBe(false); }); @@ -193,16 +235,23 @@ describe("Bun.deepMatch", () => { }); }); - it("does not work on functions", () => { + it("compares functions by identity", () => { function foo() {} function bar() {} function baz(a) { return a; } expect(Bun.deepMatch(foo, foo)).toBe(true); - expect(Bun.deepMatch(foo, bar)).toBe(true); - // FIXME - // expect(Bun.deepMatch(foo, baz)).toBe(false); + expect(Bun.deepMatch(foo, bar)).toBe(false); + expect(Bun.deepMatch(foo, baz)).toBe(false); + expect(Bun.deepMatch({ f: foo }, { f: foo })).toBe(true); + expect(Bun.deepMatch({ f: foo }, { f: bar })).toBe(false); + }); + + it("matches a subset value that appears under several keys", () => { + const shared = { x: 1 }; + expect(Bun.deepMatch({ a: shared, b: shared }, { a: shared, b: { x: 1 } })).toBe(true); + expect(Bun.deepMatch({ a: shared, b: shared }, { a: shared, b: { x: 2 } })).toBe(false); }); describe("Invalid arguments", () => { diff --git a/test/js/bun/test/expect.test.js b/test/js/bun/test/expect.test.js index 9d09e5420ff6..f273b0a7743c 100644 --- a/test/js/bun/test/expect.test.js +++ b/test/js/bun/test/expect.test.js @@ -3606,6 +3606,98 @@ describe("expect()", () => { expect(a1).not.toMatchObject({ 1: 1 }); expect(a1).toMatchObject(a1); }); + + // jest's subsetEquality only walks the keys of a plain expected object. An expected + // Date, Error, Set, Map or function is a leaf compared with equals(). + test("an expected Date, Error, Set, Map or function is compared as a value", () => { + expect({ d: new Date(5) }).toMatchObject({ d: new Date(5) }); + expect({ d: new Date(5) }).not.toMatchObject({ d: new Date(6) }); + expect({ a: { d: new Date(5) } }).toMatchObject({ a: { d: new Date(5) } }); + expect({ a: { d: new Date(5) } }).not.toMatchObject({ a: { d: new Date(6) } }); + expect({ a: [new Date(5)] }).toMatchObject({ a: [new Date(5)] }); + expect({ a: [new Date(5)] }).not.toMatchObject({ a: [new Date(6)] }); + expect({ d: {} }).not.toMatchObject({ d: new Date(5) }); + expect({ d: { x: 1 } }).not.toMatchObject({ d: new Date(5) }); + expect({ d: new Date(5) }).not.toMatchObject({ d: { x: 1 } }); + expect(new Date(5)).toMatchObject(new Date(5)); + expect(new Date(5)).not.toMatchObject(new Date(6)); + + expect({ e: new Error("x") }).toMatchObject({ e: new Error("x") }); + expect({ e: new Error("x") }).not.toMatchObject({ e: new Error("y") }); + expect({ e: new TypeError("x") }).not.toMatchObject({ e: new Error("x") }); + expect({ e: {} }).not.toMatchObject({ e: new Error("x") }); + expect(new Error("x")).not.toMatchObject(new Error("y")); + expect({ e: new Error("x", { cause: 1 }) }).toMatchObject({ e: new Error("x") }); + expect({ e: new Error("x", { cause: { a: 1, b: 2 } }) }).toMatchObject({ e: new Error("x", { cause: { a: 1 } }) }); + expect({ e: new Error("x", { cause: { a: 1 } }) }).not.toMatchObject({ e: new Error("x", { cause: { a: 2 } }) }); + expect({ e: Object.assign(new Error("x"), { code: 1 }) }).toMatchObject({ e: new Error("x") }); + expect({ e: new Error("x") }).not.toMatchObject({ e: Object.assign(new Error("x"), { code: 1 }) }); + + expect({ s: new Set([1, 2]) }).toMatchObject({ s: new Set([1, 2]) }); + expect({ s: new Set([1, 2]) }).not.toMatchObject({ s: new Set([1, 3]) }); + expect({ s: new Set([1, 2, 3]) }).not.toMatchObject({ s: new Set([1, 2]) }); + expect({ s: {} }).not.toMatchObject({ s: new Set() }); + expect(new Set([1])).toMatchObject(new Set([1])); + expect(new Set([1])).not.toMatchObject(new Set([2])); + + expect({ m: new Map([[1, 2]]) }).toMatchObject({ m: new Map([[1, 2]]) }); + expect({ m: new Map([[1, 2]]) }).not.toMatchObject({ m: new Map([[1, 3]]) }); + expect({ m: new Map([[1, 2], [3, 4]]) }).not.toMatchObject({ m: new Map([[1, 2]]) }); + expect({ m: new Map([["k", { a: 1, b: 2 }]]) }).toMatchObject({ m: new Map([["k", { a: 1 }]]) }); + expect({ m: {} }).not.toMatchObject({ m: new Map() }); + + const fn = () => 1; + expect({ f: fn }).toMatchObject({ f: fn }); + expect({ f: fn }).not.toMatchObject({ f: () => 1 }); + expect({ f: {} }).not.toMatchObject({ f: fn }); + }); + + test("a plain expected object is matched against the properties of any received value", () => { + expect({ d: new Date(5) }).toMatchObject({ d: {} }); + expect({ e: new Error("x") }).toMatchObject({ e: { message: "x" } }); + expect({ e: new Error("x") }).not.toMatchObject({ e: { message: "y" } }); + expect(new Error("x")).toMatchObject({ message: "x" }); + expect({ m: new Map() }).toMatchObject({ m: {} }); + expect({ f: () => 1 }).toMatchObject({ f: {} }); + expect({ a: [1, 2] }).toMatchObject({ a: { 0: 1 } }); + expect({ a: { 0: 1 } }).not.toMatchObject({ a: [1] }); + }); + + test("an expected object reused under several keys is matched under each", () => { + const shared = { x: 1 }; + expect({ a: shared, b: shared }).toMatchObject({ a: shared, b: { x: 1 } }); + expect({ a: shared, b: { x: 2 } }).not.toMatchObject({ a: shared, b: shared }); + }); + + if (isBun) { + // jest walks the (empty) key list of these, so any received object matches. Bun + // compares them by value, as toEqual does. + test("an expected RegExp, boxed primitive or Temporal value is compared as a value", () => { + expect({ r: /a/g }).toMatchObject({ r: /a/g }); + expect({ r: /a/g }).not.toMatchObject({ r: /a/i }); + expect({ r: /a/ }).not.toMatchObject({ r: /b/ }); + expect({ r: {} }).not.toMatchObject({ r: /a/ }); + expect({ n: new Number(1) }).toMatchObject({ n: new Number(1) }); + expect({ n: new Number(1) }).not.toMatchObject({ n: new Number(2) }); + expect({ s: new String("a") }).not.toMatchObject({ s: new String("b") }); + expect({ t: Temporal.PlainDate.from("2024-01-01") }).toMatchObject({ t: Temporal.PlainDate.from("2024-01-01") }); + expect({ t: Temporal.PlainDate.from("2024-01-01") }).not.toMatchObject({ t: Temporal.PlainDate.from("2024-01-02") }); + expect({ t: {} }).not.toMatchObject({ t: Temporal.PlainDate.from("2024-01-01") }); + }); + + test("Bun.deepMatch compares an expected Date, Error, Set, Map or function as a value", () => { + expect(Bun.deepMatch(new Date(1), new Date(2))).toBe(false); + expect(Bun.deepMatch({ d: new Date(2) }, { d: new Date(1) })).toBe(false); + expect(Bun.deepMatch({ d: new Date(1) }, { d: new Date(1) })).toBe(true); + expect(Bun.deepMatch(new Error("b"), new Error("a"))).toBe(false); + expect(Bun.deepMatch({ e: new Error("x") }, { e: new Error("x") })).toBe(true); + expect(Bun.deepMatch({ d: new Date() }, { d: {} })).toBe(false); + expect(Bun.deepMatch({ d: {} }, { d: new Date() })).toBe(true); + expect(Bun.deepMatch({ s: new Set([1, 2]) }, { s: new Set([1, 3]) })).toBe(false); + expect(Bun.deepMatch({ m: new Map([[1, 2]]) }, { m: new Map([[1, 3]]) })).toBe(false); + expect(Bun.deepMatch({ f: () => 1 }, { f: () => 1 })).toBe(false); + }); + } }); describe("toMatch()", () => { diff --git a/test/js/bun/test/snapshot-tests/bun-snapshots.test.ts b/test/js/bun/test/snapshot-tests/bun-snapshots.test.ts index 41bbca2fcd5a..a67c64b98f5a 100644 --- a/test/js/bun/test/snapshot-tests/bun-snapshots.test.ts +++ b/test/js/bun/test/snapshot-tests/bun-snapshots.test.ts @@ -55,6 +55,12 @@ describe("toMatchSnapshot errors", () => { expect(() => { expect({ a: 3 }).toMatchSnapshot({ a: expect.any(BigInt) }); }).toThrow(); + expect(() => { + expect({ a: new Date(5) }).toMatchSnapshot({ a: new Date(6) }); + }).toThrow(); + expect(() => { + expect({ a: new Error("x") }).toMatchSnapshot({ a: new Error("y") }); + }).toThrow(); }); it("should throw if arguments are in the wrong order", () => { expect(() => { diff --git a/test/js/node/net/server.spec.ts b/test/js/node/net/server.spec.ts index 9eaab34fc617..a1e761a96f03 100644 --- a/test/js/node/net/server.spec.ts +++ b/test/js/node/net/server.spec.ts @@ -104,7 +104,9 @@ describe("net.Server.prototype", () => { it("has EventEmitter methods", () => { expect(net.Server.prototype.__proto__).toBe(EventEmitter.prototype); - expect(net.Server.prototype).toMatchObject(EventEmitter.prototype); + // toMatchObject compares functions by identity, and Server.prototype.constructor is Server + const { constructor: _, ...inherited } = EventEmitter.prototype; + expect(net.Server.prototype).toMatchObject(inherited); }); }); // diff --git a/test/vendor.json b/test/vendor.json index 31edf3111e88..a4c6b1b4578b 100644 --- a/test/vendor.json +++ b/test/vendor.json @@ -7,7 +7,8 @@ "ws*connection.test.ts": "TEMPORARY: elysia 1.4.28 asserts wasClean=false for a server-initiated ws.close(), but that's a clean Close handshake — Bun now reports wasClean=true to match Node/WHATWG (oven-sh/bun#31518). Fixed upstream in elysiajs/elysia#1908; remove this skip on the next elysia bump. (glob * = path separator, so it also matches Windows backslash paths)", "core*stop.test.ts": "TEMPORARY: elysia 1.4.28's 'does not shut down the server when stop(false) is called' awaits server.stop(false) and then expects a second fetch on the same keep-alive connection to still be served. As of oven-sh/bun#35130 a graceful stop(false) resolves only once every open connection has closed, so the await blocks until the pooled keep-alive times out and the follow-up fetch then hits a closed listener. Remove this skip once the upstream test is updated (the companion 'stop(true)' assertion in the same file is unaffected).", "adapter*web-standard*map-response.test.ts": "TEMPORARY: Bun's Response.redirect now puts the WHATWG serialization of the url into Location (oven-sh/bun#33126), matching Node and https://fetch.spec.whatwg.org/#dom-response-redirect, so Response.redirect('https://cunny.school') yields 'https://cunny.school/'. elysia 1.4.28's 'map redirect' test asserts the unserialized string; remove this skip once the upstream assertion expects the trailing slash.", - "adapter*web-standard*map-early-response.test.ts": "TEMPORARY: same as adapter*web-standard*map-response.test.ts; its 'map redirect' test asserts the unserialized Response.redirect Location value." + "adapter*web-standard*map-early-response.test.ts": "TEMPORARY: same as adapter*web-standard*map-response.test.ts; its 'map redirect' test asserts the unserialized Response.redirect Location value.", + "units*replace-schema-type.test.ts": "TEMPORARY: 8 of elysia 1.4.28's tests here toMatchObject one t.Numeric()/t.BooleanString()/t.ObjectString() schema against another. Every call creates fresh Decode/Encode closures under the enumerable Symbol.for('TypeBox.Transform') key, and toMatchObject now compares functions by identity (as Jest does) instead of matching any two functions, so independently created schemas no longer match. The test is unchanged upstream as of elysia 1.4.30; remove this skip once it stops comparing the transform closures (e.g. expect.any(Function))." } } ] From 9587d09a65da949f6f7c526a9cd7b1e1f365f461 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 3 Oct 2026 07:36:22 +0000 Subject: [PATCH 02/15] test: fix url.parse IPv6 expectations that a top level objectContaining never checked toMatchObject now applies an asymmetric matcher passed as the whole expected value, so the IPv6 spot checks ran for the first time and three of them asserted values node does not produce. --- test/js/bun/test/expect.test.js | 8 ++++++++ test/js/node/url/url-parse-ipv6.test.ts | 8 ++++---- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/test/js/bun/test/expect.test.js b/test/js/bun/test/expect.test.js index f273b0a7743c..69e668a88d0f 100644 --- a/test/js/bun/test/expect.test.js +++ b/test/js/bun/test/expect.test.js @@ -3663,6 +3663,14 @@ describe("expect()", () => { expect({ a: { 0: 1 } }).not.toMatchObject({ a: [1] }); }); + test("an asymmetric matcher passed as the whole expected value is applied", () => { + expect({ a: 1, b: 2 }).toMatchObject(expect.objectContaining({ a: 1 })); + expect({ a: 1, b: 2 }).not.toMatchObject(expect.objectContaining({ a: 2 })); + expect({ a: 1, b: 2 }).not.toMatchObject(expect.objectContaining({ c: 1 })); + expect([1]).toMatchObject(expect.any(Array)); + expect({ a: 1 }).not.toMatchObject(expect.any(Array)); + }); + test("an expected object reused under several keys is matched under each", () => { const shared = { x: 1 }; expect({ a: shared, b: shared }).toMatchObject({ a: shared, b: { x: 1 } }); diff --git a/test/js/node/url/url-parse-ipv6.test.ts b/test/js/node/url/url-parse-ipv6.test.ts index 30697f994010..e4441c6225dc 100644 --- a/test/js/node/url/url-parse-ipv6.test.ts +++ b/test/js/node/url/url-parse-ipv6.test.ts @@ -29,12 +29,12 @@ describe("Valid spot checks", () => { it.each([ // ports ["http://[::1]:", { host: "[::1]", hostname: "::1", port: null, path: "/", href: "http://[::1]/" }], // trailing colons are ignored - ["http://[::1]:1", { host: "[::1]", hostname: "::1", port: "1", path: "/", href: "http://[::1]/" }], + ["http://[::1]:1", { host: "[::1]:1", hostname: "::1", port: "1", path: "/", href: "http://[::1]:1/" }], // unicast ["http://[::0]", { host: "[::0]", path: "/" }], ["http://[::f]", { host: "[::f]", path: "/" }], - ["http://[::F]", { host: "[::F]", path: "/" }], + ["http://[::F]", { host: "[::f]", path: "/" }], // the host is lowercased // these are technically invalid unicast addresses but url.parse allows them ["http://[::7]", { host: "[::7]", path: "/" }], // ["http://[::z]", { host: "[::7]", path: "/" }], @@ -42,9 +42,9 @@ describe("Valid spot checks", () => { // full form-ish ["https://[::1:2:3:4:5]", { host: "[::1:2:3:4:5]", path: "/" }], - ["[0:0:0:1:2:3:4:5]", { host: "[0:0:0:1:2:3:4:5]", path: "/" }], + ["[0:0:0:1:2:3:4:5]", { host: null, path: "[0:0:0:1:2:3:4:5]" }], // w/o a protocol, it's treated as a path ])("Parsing '%s' succeeds", (input, expected) => { - expect(url.parse(input)).toMatchObject(expect.objectContaining(expected)); + expect(url.parse(input)).toMatchObject(expected); }); }); // From 4164bc6bfe5f5436e16871e54b127e765ac60d37 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 23 Oct 2026 08:30:13 +0000 Subject: [PATCH 03/15] Match an expected function of any kind by identity, and URLSearchParams in both directions Subset matching dispatched on JSFunctionType only, so an expected native constructor, mock function or callable Proxy was still walked key by key: `expect({ f: jest.fn() }).toMatchObject({ f: jest.fn() })` and `Bun.deepMatch(Set, Map)` passed. Any callable expected value now matches only itself. The URLSearchParams arm checks one direction and relies on the second call with the sides swapped. Subset matching calls an arm once, so `a=1&a=1` matched `a=1&b=1`. The arm now checks both directions in that mode. Add the cases of the per type pull requests for functions, Headers, URLSearchParams and Temporal values. --- packages/bun-types/bun.d.ts | 5 +- src/jsc/bindings/bindings.cpp | 32 +++++++--- .../bun-object/deep-equals-temporal.test.ts | 61 +++++++++++++++++++ test/js/bun/bun-object/deep-match.spec.ts | 27 +++++++- test/js/bun/test/expect.test.js | 40 ++++++++++++ 5 files changed, 153 insertions(+), 12 deletions(-) diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index c65a7c487ff4..9eb3600d34e0 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -2922,8 +2922,9 @@ declare module "bun" { * * A plain object or array in the subset is matched property by property. * Any other object in the subset (a `Date`, `Error`, `Map`, `Set`, `RegExp`, - * typed array, boxed primitive or function) must be a value of the same type - * that `Bun.deepEquals` considers equal. + * `ArrayBuffer`, typed array or boxed primitive) must be a value of the same + * type that `Bun.deepEquals` considers equal. A function in the subset + * matches only the same function. * * This also powers expect().toMatchObject in `bun:test` */ diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index c61bc965c9f4..5c05d0b963e6 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -959,6 +959,9 @@ bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2, // still matches an Error. When the types differ, every arm returns before it // recurses, so dispatching on the expected type never swaps the received/expected // order of a nested comparison. + // An expected function of any kind (a native constructor, a mock, a callable Proxy) + // matches only itself, and sameValue above has already accepted that. + if (c2->isCallable()) return false; std::optional isSpecialEqual = c1->type() == c2->type() ? specialObjectsDequal(globalObject, gcBuffer, stack, scope, c1, c2) : specialObjectsDequal(globalObject, gcBuffer, stack, scope, c2, c1); @@ -1484,6 +1487,18 @@ static constexpr DeepEqualsMode deepEqualsMode { checkPrototypes ? &nonIndexOwnPropertiesEqual : nullptr, }; +static bool urlSearchParamsEntriesFoundIn(WebCore::URLSearchParams& params, const WebCore::URLSearchParams& other) +{ + auto iter = params.createIterator(); + while (const auto& maybePair = iter.next()) { + const auto& maybeValue = other.get(maybePair->key); + if (!maybeValue || maybeValue != maybePair->value) { + return false; + } + } + return true; +} + // The per-type comparisons (Map, Set, Date, typed arrays, ...) are compiled once // and take the mode at runtime; only the dispatch and the plain-object tail in // `specialObjectsDequal` below stay specialised per mode. @@ -1929,19 +1944,18 @@ static std::optional specialObjectsDequalSlow(const DeepEqualsMode& mode, auto urlSearchParams2 = dynamicDowncast(c2); if (urlSearchParams1 && urlSearchParams2) { auto& wrapped1 = urlSearchParams1->wrapped(); - const auto& wrapped2 = urlSearchParams2->wrapped(); + auto& wrapped2 = urlSearchParams2->wrapped(); if (wrapped1.size() != wrapped2.size()) { return false; } - auto iter1 = wrapped1.createIterator(); - while (const auto& maybePair = iter1.next()) { - const auto& key = maybePair->key; - const auto& value = maybePair->value; - const auto& maybeValue = wrapped2.get(key); - if (!maybeValue || maybeValue != value) { - return false; - } + if (!urlSearchParamsEntriesFoundIn(wrapped1, wrapped2)) { + return false; + } + // The other modes call this arm a second time with the sides swapped. Subset + // matching calls it once, and `a=1&a=1` must not match `a=1&b=1`. + if (mode.isPartial && !urlSearchParamsEntriesFoundIn(wrapped2, wrapped1)) { + return false; } goto compareAsNormalValue; diff --git a/test/js/bun/bun-object/deep-equals-temporal.test.ts b/test/js/bun/bun-object/deep-equals-temporal.test.ts index 8432f32b23bf..47ceb16377f5 100644 --- a/test/js/bun/bun-object/deep-equals-temporal.test.ts +++ b/test/js/bun/bun-object/deep-equals-temporal.test.ts @@ -132,6 +132,67 @@ describe("expect().toEqual on Temporal values", () => { }); }); +// A Temporal object has no enumerable properties, so a key walk of an expected +// Temporal value accepts any received object. +describe("subset matching on Temporal values", () => { + const cases: [name: string, makeA: () => unknown, makeB: () => unknown][] = [ + [ + "Instant", + () => Temporal.Instant.from("2024-06-15T12:34:56Z"), + () => Temporal.Instant.from("2024-06-15T12:34:56.000000001Z"), + ], + [ + "PlainDateTime", + () => Temporal.PlainDateTime.from("2024-06-15T12:34:56"), + () => Temporal.PlainDateTime.from("2024-06-15T12:34:57"), + ], + ["PlainDate", () => Temporal.PlainDate.from("2024-01-01"), () => Temporal.PlainDate.from("2099-12-31")], + ["PlainTime", () => Temporal.PlainTime.from("12:34:56"), () => Temporal.PlainTime.from("12:34:56.000000001")], + [ + "ZonedDateTime", + () => Temporal.ZonedDateTime.from("2024-06-15T12:34:56+02:00[Europe/Berlin]"), + () => Temporal.ZonedDateTime.from("2024-06-15T10:34:56+00:00[UTC]"), + ], + ["PlainYearMonth", () => Temporal.PlainYearMonth.from("2024-06"), () => Temporal.PlainYearMonth.from("2024-07")], + ["PlainMonthDay", () => Temporal.PlainMonthDay.from("06-15"), () => Temporal.PlainMonthDay.from("06-16")], + ["Duration", () => Temporal.Duration.from("PT1H"), () => Temporal.Duration.from("PT60M")], + ]; + + describe.each(cases)("%s", (_, makeA, makeB) => { + it("toMatchObject compares it by value", () => { + expect({ when: makeA(), other: 1 }).toMatchObject({ when: makeA() }); + expect({ when: makeA(), other: 1 }).not.toMatchObject({ when: makeB() }); + expect([makeA()]).toMatchObject([makeA()]); + expect([makeA()]).not.toMatchObject([makeB()]); + expect(makeA()).toMatchObject(makeA() as object); + expect(makeA()).not.toMatchObject(makeB() as object); + }); + + it("Bun.deepMatch compares it by value", () => { + expect(Bun.deepMatch({ when: makeA() }, { when: makeA(), other: 1 })).toBe(true); + expect(Bun.deepMatch({ when: makeB() }, { when: makeA(), other: 1 })).toBe(false); + expect(Bun.deepMatch(makeA(), makeA())).toBe(true); + expect(Bun.deepMatch(makeB(), makeA())).toBe(false); + }); + }); + + it("an expected Temporal value does not match another class or a plain object", () => { + const date = Temporal.PlainDate.from("2024-06-15"); + const dateTime = Temporal.PlainDateTime.from("2024-06-15T00:00:00"); + expect({ when: dateTime }).not.toMatchObject({ when: date }); + expect({ when: {} }).not.toMatchObject({ when: date }); + expect(Bun.deepMatch(date, dateTime)).toBe(false); + expect(Bun.deepMatch(date, {})).toBe(false); + }); + + it("an expected plain object matches a received Temporal value through its getters", () => { + const date = Temporal.PlainDate.from("2024-06-15"); + expect({ when: date }).toMatchObject({ when: {} }); + expect(date).toMatchObject({ year: 2024, month: 6, day: 15 }); + expect(date).not.toMatchObject({ year: 2025 }); + }); +}); + describe("util.isDeepStrictEqual on Temporal values", () => { it("compares by value", () => { expect(isDeepStrictEqual(Temporal.PlainDate.from("2024-06-15"), Temporal.PlainDate.from("2024-06-15"))).toBe(true); diff --git a/test/js/bun/bun-object/deep-match.spec.ts b/test/js/bun/bun-object/deep-match.spec.ts index dab736b287f8..5f939793f160 100644 --- a/test/js/bun/bun-object/deep-match.spec.ts +++ b/test/js/bun/bun-object/deep-match.spec.ts @@ -90,6 +90,11 @@ describe("Bun.deepMatch", () => { [{ e: new Error("a") }, { e: Object.assign(new Error("a"), { code: 1 }) }], [{ r: /a/g }, { r: /a/g }], [{ n: new Number(1) }, { n: new Number(1) }], + // ArrayBuffers, Headers, URLSearchParams and URLs are compared by their contents + [{ b: new Uint8Array([1, 2]).buffer }, { b: new Uint8Array([1, 2]).buffer }], + [new Headers({ a: "1", b: "2" }), new Headers({ b: "2", a: "1" })], + [{ query: new URLSearchParams("a=1&b=2") }, { query: new URLSearchParams("a=1&b=2"), path: "/" }], + [{ u: new URL("https://example.com/a") }, { u: new URL("https://example.com/a") }], // a plain object in the subset matches the properties of any object [{ message: "a" }, new Error("a")], [{ e: { message: "a" } }, { e: new Error("a") }], @@ -166,9 +171,26 @@ describe("Bun.deepMatch", () => { [{ e: new Error("a") }, { e: { message: "b" } }], [{ e: new Error("a") }, { e: {} }], - // Functions match by identity only + // Functions match by identity only, native constructors and callable proxies too [{ f: () => 1 }, { f: () => 1 }], [{ f: () => 1 }, { f: {} }], + [{ f: Map }, { f: Set }], + [{ f: Map }, { f: {} }], + [{ f: new Proxy(() => 1, {}) }, { f: new Proxy(() => 1, {}) }], + [{ f: Object.assign(() => 1, { x: 1 }) }, { f: Object.assign(() => 1, { x: 1 }) }], + + // ArrayBuffers, Headers, URLSearchParams and URLs are compared by their contents + [{ b: new ArrayBuffer(0) }, { b: new Uint8Array([1, 2, 3]).buffer }], + [{ b: new Uint8Array([1, 2, 3]).buffer }, { b: new Uint8Array([3, 2, 1]).buffer }], + [{ b: new ArrayBuffer(0) }, { b: {} }], + [new Headers({ a: "1" }), new Headers({ a: "2" })], + [{ headers: new Headers({ a: "1" }) }, { headers: new Headers({ a: "1", b: "2" }) }], + [new URLSearchParams("a=1"), new URLSearchParams("a=2")], + [{ query: new URLSearchParams("a=1") }, { query: new URLSearchParams("a=1&b=2") }], + // the same size, and every entry of one side has its value on the other side + [{ query: new URLSearchParams("a=1&b=1") }, { query: new URLSearchParams("a=1&a=1") }], + [{ query: new URLSearchParams("a=1&a=1") }, { query: new URLSearchParams("a=1&b=1") }], + [{ u: new URL("https://example.com/a") }, { u: new URL("https://example.com/b") }], // Boxed primitives and RegExps are compared by their value [{ r: /a/ }, { r: /b/ }], @@ -246,6 +268,9 @@ describe("Bun.deepMatch", () => { expect(Bun.deepMatch(foo, baz)).toBe(false); expect(Bun.deepMatch({ f: foo }, { f: foo })).toBe(true); expect(Bun.deepMatch({ f: foo }, { f: bar })).toBe(false); + expect(Bun.deepMatch(Set, Set)).toBe(true); + expect(Bun.deepMatch(Set, Map)).toBe(false); + expect(Bun.deepMatch(Set, {})).toBe(false); }); it("matches a subset value that appears under several keys", () => { diff --git a/test/js/bun/test/expect.test.js b/test/js/bun/test/expect.test.js index 4d27b876b80b..ba3669946fef 100644 --- a/test/js/bun/test/expect.test.js +++ b/test/js/bun/test/expect.test.js @@ -3841,6 +3841,20 @@ describe("expect()", () => { expect({ f: {} }).not.toMatchObject({ f: fn }); }); + test("an expected native constructor, mock function or callable Proxy matches only itself", () => { + const mockFn = jest.fn(); + expect({ f: mockFn }).toMatchObject({ f: mockFn }); + expect({ f: mockFn }).toMatchObject({ f: expect.any(Function) }); + expect({ f: jest.fn() }).not.toMatchObject({ f: jest.fn() }); + expect({ f: Map }).toMatchObject({ f: Map }); + expect({ f: Map }).not.toMatchObject({ f: Set }); + expect({ f: {} }).not.toMatchObject({ f: Set }); + const proxy = new Proxy(() => 1, {}); + expect({ f: proxy }).toMatchObject({ f: proxy }); + expect({ f: new Proxy(() => 1, {}) }).not.toMatchObject({ f: new Proxy(() => 1, {}) }); + expect({ f: Object.assign(() => 1, { x: 1 }) }).not.toMatchObject({ f: Object.assign(() => 1, { x: 1 }) }); + }); + test("a plain expected object is matched against the properties of any received value", () => { expect({ d: new Date(5) }).toMatchObject({ d: {} }); expect({ e: new Error("x") }).toMatchObject({ e: { message: "x" } }); @@ -3882,6 +3896,32 @@ describe("expect()", () => { expect({ t: {} }).not.toMatchObject({ t: Temporal.PlainDate.from("2024-01-01") }); }); + test("an expected ArrayBuffer, typed array, URL, URLSearchParams or Headers is compared as a value", () => { + const bytes = (...b) => new Uint8Array(b).buffer; + expect({ b: bytes(1, 2) }).toMatchObject({ b: bytes(1, 2) }); + expect({ b: bytes(1, 2) }).not.toMatchObject({ b: bytes(1, 3) }); + expect({ b: bytes(1, 2) }).not.toMatchObject({ b: new ArrayBuffer(0) }); + expect({ b: {} }).not.toMatchObject({ b: new ArrayBuffer(0) }); + expect({ a: new Uint8Array([1, 2]) }).toMatchObject({ a: new Uint8Array([1, 2]) }); + expect({ a: new Uint8Array([1, 2, 3]) }).not.toMatchObject({ a: new Uint8Array([1, 2]) }); + expect({ a: [1, 2] }).not.toMatchObject({ a: new Uint8Array([1, 2]) }); + + expect({ u: new URL("https://example.com/a") }).toMatchObject({ u: new URL("https://example.com/a") }); + expect({ u: new URL("https://example.com/a") }).not.toMatchObject({ u: new URL("https://example.com/b") }); + + expect({ p: new URLSearchParams("a=1&b=2") }).toMatchObject({ p: new URLSearchParams("a=1&b=2") }); + expect({ p: new URLSearchParams("a=1") }).not.toMatchObject({ p: new URLSearchParams("a=2") }); + expect({ p: new URLSearchParams("a=1&b=2") }).not.toMatchObject({ p: new URLSearchParams("a=1") }); + // the same size, and every entry of one side has its value on the other side + expect({ p: new URLSearchParams("a=1&a=1") }).not.toMatchObject({ p: new URLSearchParams("a=1&b=1") }); + expect({ p: new URLSearchParams("a=1&b=1") }).not.toMatchObject({ p: new URLSearchParams("a=1&a=1") }); + + expect({ h: new Headers({ a: "1", b: "2" }) }).toMatchObject({ h: new Headers({ b: "2", a: "1" }) }); + expect({ h: new Headers({ a: "1" }) }).not.toMatchObject({ h: new Headers({ a: "2" }) }); + expect({ h: new Headers({ a: "1", b: "2" }) }).not.toMatchObject({ h: new Headers({ a: "1" }) }); + expect({ h: new Headers() }).not.toMatchObject({ h: new Headers({ a: "1" }) }); + }); + test("Bun.deepMatch compares an expected Date, Error, Set, Map or function as a value", () => { expect(Bun.deepMatch(new Date(1), new Date(2))).toBe(false); expect(Bun.deepMatch({ d: new Date(2) }, { d: new Date(1) })).toBe(false); From 8b34056bbbef745746e02fc4671d1f384a4713bf Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:02:23 +0000 Subject: [PATCH 04/15] Compare two primitive property values in the subset walk without a call The subset walk called Bun__deepEquals for every property. For two primitives that call only runs sameValue. Do that in place. --- src/jsc/bindings/bindings.cpp | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index 5c05d0b963e6..6cf1e4652380 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -1361,6 +1361,16 @@ static bool subsetPropertiesMatch(JSC::JSGlobalObject* globalObject, JSObject* r JSValue expectedProp = expected->get(globalObject, property); RETURN_IF_EXCEPTION(scope, false); + if (!receivedProp.isObject() && !expectedProp.isObject()) { + // Two primitives: neither is a matcher, and Bun__deepEquals would only do this. + bool same = JSC::sameValue(globalObject, receivedProp, expectedProp); + RETURN_IF_EXCEPTION(scope, false); + if (!same) { + return false; + } + continue; + } + if constexpr (enableAsymmetricMatchers && isPartial) { // toMatchObject and the snapshot property matchers copy a matched asymmetric // matcher to the other side, so the failure diff does not flag that property. From 607886f79cb63a275eb9c008c2aa376f9532c19b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:35:03 +0000 Subject: [PATCH 05/15] Format the new toMatchObject tests --- test/js/bun/test/expect.test.js | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/test/js/bun/test/expect.test.js b/test/js/bun/test/expect.test.js index ba3669946fef..ddfd8a086f34 100644 --- a/test/js/bun/test/expect.test.js +++ b/test/js/bun/test/expect.test.js @@ -3817,7 +3817,9 @@ describe("expect()", () => { expect({ e: {} }).not.toMatchObject({ e: new Error("x") }); expect(new Error("x")).not.toMatchObject(new Error("y")); expect({ e: new Error("x", { cause: 1 }) }).toMatchObject({ e: new Error("x") }); - expect({ e: new Error("x", { cause: { a: 1, b: 2 } }) }).toMatchObject({ e: new Error("x", { cause: { a: 1 } }) }); + expect({ e: new Error("x", { cause: { a: 1, b: 2 } }) }).toMatchObject({ + e: new Error("x", { cause: { a: 1 } }), + }); expect({ e: new Error("x", { cause: { a: 1 } }) }).not.toMatchObject({ e: new Error("x", { cause: { a: 2 } }) }); expect({ e: Object.assign(new Error("x"), { code: 1 }) }).toMatchObject({ e: new Error("x") }); expect({ e: new Error("x") }).not.toMatchObject({ e: Object.assign(new Error("x"), { code: 1 }) }); @@ -3831,7 +3833,12 @@ describe("expect()", () => { expect({ m: new Map([[1, 2]]) }).toMatchObject({ m: new Map([[1, 2]]) }); expect({ m: new Map([[1, 2]]) }).not.toMatchObject({ m: new Map([[1, 3]]) }); - expect({ m: new Map([[1, 2], [3, 4]]) }).not.toMatchObject({ m: new Map([[1, 2]]) }); + expect({ + m: new Map([ + [1, 2], + [3, 4], + ]), + }).not.toMatchObject({ m: new Map([[1, 2]]) }); expect({ m: new Map([["k", { a: 1, b: 2 }]]) }).toMatchObject({ m: new Map([["k", { a: 1 }]]) }); expect({ m: {} }).not.toMatchObject({ m: new Map() }); @@ -3891,8 +3898,12 @@ describe("expect()", () => { expect({ n: new Number(1) }).toMatchObject({ n: new Number(1) }); expect({ n: new Number(1) }).not.toMatchObject({ n: new Number(2) }); expect({ s: new String("a") }).not.toMatchObject({ s: new String("b") }); - expect({ t: Temporal.PlainDate.from("2024-01-01") }).toMatchObject({ t: Temporal.PlainDate.from("2024-01-01") }); - expect({ t: Temporal.PlainDate.from("2024-01-01") }).not.toMatchObject({ t: Temporal.PlainDate.from("2024-01-02") }); + expect({ t: Temporal.PlainDate.from("2024-01-01") }).toMatchObject({ + t: Temporal.PlainDate.from("2024-01-01"), + }); + expect({ t: Temporal.PlainDate.from("2024-01-01") }).not.toMatchObject({ + t: Temporal.PlainDate.from("2024-01-02"), + }); expect({ t: {} }).not.toMatchObject({ t: Temporal.PlainDate.from("2024-01-01") }); }); From f7f55910c871284cd55b56e2526dd7a309790bce Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:12:27 +0000 Subject: [PATCH 06/15] Keep Bun__deepMatch and check an expected Date and Error at its entry The isPartial mode of Bun__deepEquals took over the cycle rule and the typed arms of toEqual. A received object that refers to itself, a float array with NaN and Headers with two Set-Cookie values no longer matched an equal pattern. The mode is removed and the subset walk is as on main again. The walk lists the enumerable keys of the expected value. A Date and an Error have none, so any object matched. Before the walk, an expected Date now needs a Date with the same time value, and an expected Error needs an Error with the same message. The enumerable properties of the expected value are matched after that, as before. The comparison of Map, Set, functions and the other typed values is not part of this change. --- packages/bun-types/bun.d.ts | 7 +- src/jsc/JSValue.rs | 18 +- src/jsc/bindings/BunObject.cpp | 5 +- src/jsc/bindings/bindings.cpp | 444 +++++++++++------- src/jsc/bindings/headers-handwritten.h | 49 +- src/jsc/bindings/headers.h | 2 +- src/runtime/test_runner/expect.rs | 2 +- .../test_runner/expect/toMatchObject.rs | 2 +- .../bun-object/deep-equals-temporal.test.ts | 61 --- test/js/bun/bun-object/deep-match.spec.ts | 134 ++---- test/js/bun/test/expect.test.js | 156 ++---- .../test/snapshot-tests/bun-snapshots.test.ts | 4 +- test/js/node/net/server.spec.ts | 4 +- test/js/node/url/url-parse-ipv6.test.ts | 8 +- test/vendor.json | 3 +- 15 files changed, 420 insertions(+), 479 deletions(-) diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index 9eb3600d34e0..f39f04a033c8 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -2920,11 +2920,8 @@ declare module "bun" { * Returns true if all properties in the subset exist in the * other and have equal values. * - * A plain object or array in the subset is matched property by property. - * Any other object in the subset (a `Date`, `Error`, `Map`, `Set`, `RegExp`, - * `ArrayBuffer`, typed array or boxed primitive) must be a value of the same - * type that `Bun.deepEquals` considers equal. A function in the subset - * matches only the same function. + * A `Date` in the subset matches a `Date` with the same time value. + * An `Error` in the subset matches an `Error` with the same message. * * This also powers expect().toMatchObject in `bun:test` */ diff --git a/src/jsc/JSValue.rs b/src/jsc/JSValue.rs index 65fd602ec904..5645001447c9 100644 --- a/src/jsc/JSValue.rs +++ b/src/jsc/JSValue.rs @@ -2745,11 +2745,20 @@ impl JSValue { }) } /// `JSValue.jestDeepMatch` — `expect(a).toMatchObject(b)` / - /// snapshot-property-matcher subset comparison. A matched asymmetric - /// matcher in `subset` is copied onto `self` for the failure diff. - pub fn jest_deep_match(self, subset: JSValue, global: &JSGlobalObject) -> JsResult { + /// snapshot-property-matcher subset comparison. + pub fn jest_deep_match( + self, + subset: JSValue, + global: &JSGlobalObject, + replace_props_with_asymmetric_matchers: bool, + ) -> JsResult { host_fn::from_js_host_call_generic(global, || { - JSC__JSValue__jestDeepMatch(self, subset, global) + JSC__JSValue__jestDeepMatch( + self, + subset, + global, + replace_props_with_asymmetric_matchers, + ) }) } @@ -2861,6 +2870,7 @@ unsafe extern "C" { this: JSValue, subset: JSValue, global: &JSGlobalObject, + replace_props: bool, ) -> bool; safe fn JSC__JSValue__asBigIntCompare( this: JSValue, diff --git a/src/jsc/bindings/BunObject.cpp b/src/jsc/bindings/BunObject.cpp index 518e10ea38bc..ae462b274e2b 100644 --- a/src/jsc/bindings/BunObject.cpp +++ b/src/jsc/bindings/BunObject.cpp @@ -743,9 +743,10 @@ JSC_DEFINE_HOST_FUNCTION(functionBunDeepMatch, (JSGlobalObject * globalObject, J return {}; } - Vector, 16> stack; + std::set objVisited; + std::set subsetVisited; MarkedArgumentBuffer gcBuffer; - bool match = Bun__deepEquals(globalObject, object, subset, gcBuffer, stack, scope, true); + bool match = Bun__deepMatch(object, &objVisited, subset, &subsetVisited, globalObject, scope, &gcBuffer, false, false); RETURN_IF_EXCEPTION(scope, {}); return JSValue::encode(jsBoolean(match)); } diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index 6cf1e4652380..a7d2641ecb56 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -257,8 +257,28 @@ enum class AsymmetricMatcherConstructorType : int8_t { InstanceOf = 9, }; -template -static bool subsetPropertiesMatch(JSC::JSGlobalObject* globalObject, JSObject* received, JSObject* expected, MarkedArgumentBuffer& gcBuffer, Vector, 16>& stack, ThrowScope& scope); +// Ensure we instantiate the true and false variants of this function +template bool Bun__deepMatch( + JSValue objValue, + std::set* seenObjProperties, + JSValue subsetValue, + std::set* seenSubsetProperties, + JSGlobalObject* globalObject, + ThrowScope& throwScope, + MarkedArgumentBuffer* gcBuffer, + bool replacePropsWithAsymmetricMatchers, + bool isMatchingObjectContaining); + +template bool Bun__deepMatch( + JSValue objValue, + std::set* seenObjProperties, + JSValue subsetValue, + std::set* seenSubsetProperties, + JSGlobalObject* globalObject, + ThrowScope& throwScope, + MarkedArgumentBuffer* gcBuffer, + bool replacePropsWithAsymmetricMatchers, + bool isMatchingObjectContaining); extern "C" bool Expect_readFlagsAndProcessPromise(JSC::EncodedJSValue instanceValue, JSC::JSGlobalObject* globalObject, ExpectFlags* flags, JSC::EncodedJSValue* value, AsymmetricMatcherConstructorType* constructorType); @@ -548,12 +568,10 @@ AsymmetricMatcherResult matchAsymmetricMatcherAndGetFlags(JSGlobalObject* global JSValue patternObject = expectObjectContaining->m_objectValue.get(); if (patternObject.isObject()) { if (otherProp.isObject()) { - // The pattern is a subset at the top level only. Each property value is - // compared exactly, like jest's ObjectContaining: nested subset matching - // needs a nested objectContaining. - Vector, 16> stack; - MarkedArgumentBuffer gcBuffer; - bool match = subsetPropertiesMatch(globalObject, otherProp.getObject(), patternObject.getObject(), gcBuffer, stack, throwScope); + // SAFETY: visited property sets are not required when + // `enableAsymmetricMatchers` and `isMatchingObjectContaining` + // are both true + bool match = Bun__deepMatch(otherProp, nullptr, patternObject, nullptr, globalObject, throwScope, nullptr, false, true); RETURN_IF_EXCEPTION(throwScope, AsymmetricMatcherResult::FAIL); if (match) { return AsymmetricMatcherResult::PASS; @@ -690,7 +708,7 @@ JSValue getIndexWithoutAccessors(JSGlobalObject* globalObject, JSObject* obj, ui return JSValue(); } -template +template std::optional specialObjectsDequal(JSC::JSGlobalObject* globalObject, MarkedArgumentBuffer& gcBuffer, Vector, 16>& stack, ThrowScope& scope, JSCell* _Nonnull c1, JSCell* _Nonnull c2); template @@ -717,7 +735,7 @@ static ALWAYS_INLINE bool hasExtraOwnProperties(JSC::Structure* structure) // node compares the non-index own properties of typed arrays as well; // only the node entry point (checkPrototypes) pays for this. -template +template static bool nonIndexOwnPropertiesEqual(JSC::JSGlobalObject* globalObject, MarkedArgumentBuffer& gcBuffer, Vector, 16>& stack, ThrowScope& scope, JSC::JSObject* o1, JSC::JSObject* o2) { VM& vm = globalObject->vm(); @@ -753,7 +771,7 @@ static bool nonIndexOwnPropertiesEqual(JSC::JSGlobalObject* globalObject, Marked } JSValue v1 = slot1.getValue(globalObject, propertyName); RETURN_IF_EXCEPTION(scope, false); - bool eq = Bun__deepEquals(globalObject, v1, v2, gcBuffer, stack, scope, true); + bool eq = Bun__deepEquals(globalObject, v1, v2, gcBuffer, stack, scope, true); RETURN_IF_EXCEPTION(scope, false); if (!eq) { return false; @@ -804,7 +822,7 @@ static bool isWellKnownConstructor(JSValue value) || info == WebCore::JSBufferConstructor::info(); } -template +template bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2, MarkedArgumentBuffer& gcBuffer, Vector, 16>& stack, ThrowScope& scope, bool addToStack) { VM& vm = globalObject->vm(); @@ -951,29 +969,10 @@ bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2, } } - if constexpr (isPartial) { - // Subset matching: v2 is the expected value and decides how the pair is compared. - // An expected Date, Error, Map, function, ... matches only a received value of the - // same type, through its arm in specialObjectsDequal. An expected plain object is - // matched key by key against a received value of any type, so `{ message: "x" }` - // still matches an Error. When the types differ, every arm returns before it - // recurses, so dispatching on the expected type never swaps the received/expected - // order of a nested comparison. - // An expected function of any kind (a native constructor, a mock, a callable Proxy) - // matches only itself, and sameValue above has already accepted that. - if (c2->isCallable()) return false; - std::optional isSpecialEqual = c1->type() == c2->type() - ? specialObjectsDequal(globalObject, gcBuffer, stack, scope, c1, c2) - : specialObjectsDequal(globalObject, gcBuffer, stack, scope, c2, c1); - RETURN_IF_EXCEPTION(scope, false); - if (isSpecialEqual.has_value()) return *isSpecialEqual; - RELEASE_AND_RETURN(scope, (subsetPropertiesMatch(globalObject, v1.getObject(), v2.getObject(), gcBuffer, stack, scope))); - } - - std::optional isSpecialEqual = specialObjectsDequal(globalObject, gcBuffer, stack, scope, c1, c2); + std::optional isSpecialEqual = specialObjectsDequal(globalObject, gcBuffer, stack, scope, c1, c2); RETURN_IF_EXCEPTION(scope, false); if (isSpecialEqual.has_value()) return WTF::move(*isSpecialEqual); - isSpecialEqual = specialObjectsDequal(globalObject, gcBuffer, stack, scope, c2, c1); + isSpecialEqual = specialObjectsDequal(globalObject, gcBuffer, stack, scope, c2, c1); RETURN_IF_EXCEPTION(scope, false); if (isSpecialEqual.has_value()) return WTF::move(*isSpecialEqual); JSObject* o1 = v1.getObject(); @@ -1032,7 +1031,7 @@ bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2, } } - auto eql = Bun__deepEquals(globalObject, left, right, gcBuffer, stack, scope, true); + auto eql = Bun__deepEquals(globalObject, left, right, gcBuffer, stack, scope, true); RETURN_IF_EXCEPTION(scope, false); if (!eql) return false; } @@ -1051,7 +1050,7 @@ bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2, if constexpr (checkPrototypes) { // node compares own enumerable non-index string+symbol props via getOwnNonIndexProperties; // the Bun.deepEquals symbol-only block below walks the prototype chain, so diverge here. - return nonIndexOwnPropertiesEqual(globalObject, gcBuffer, stack, scope, o1, o2); + return nonIndexOwnPropertiesEqual(globalObject, gcBuffer, stack, scope, o1, o2); } JSC::PropertyNameArrayBuilder a1(vm, PropertyNameMode::Symbols, PrivateSymbolMode::Exclude); @@ -1093,7 +1092,7 @@ bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2, return false; } - auto eql = Bun__deepEquals(globalObject, prop1, prop2, gcBuffer, stack, scope, true); + auto eql = Bun__deepEquals(globalObject, prop1, prop2, gcBuffer, stack, scope, true); RETURN_IF_EXCEPTION(scope, false); if (!eql) return false; } @@ -1227,7 +1226,7 @@ bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2, RETURN_IF_EXCEPTION(scope, false); if (same) continue; - auto eql = Bun__deepEquals(globalObject, left, right, gcBuffer, stack, scope, true); + auto eql = Bun__deepEquals(globalObject, left, right, gcBuffer, stack, scope, true); RETURN_IF_EXCEPTION(scope, false); if (!eql) { return false; @@ -1302,7 +1301,7 @@ bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2, return false; } - auto eql = Bun__deepEquals(globalObject, prop1, prop2, gcBuffer, stack, scope, true); + auto eql = Bun__deepEquals(globalObject, prop1, prop2, gcBuffer, stack, scope, true); RETURN_IF_EXCEPTION(scope, false); if (!eql) return false; } @@ -1323,94 +1322,6 @@ bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2, return true; } -// Subset matching for toMatchObject, Bun.deepMatch and expect.objectContaining. Every -// enumerable property of `expected` must exist on `received`, and the two values must be -// equal in the mode the template arguments select (`isPartial` recurses as a subset, -// otherwise exactly). Extra properties on `received` are ignored. An expected array -// matches only a received array of the same length with the same property count. -template -static bool subsetPropertiesMatch(JSC::JSGlobalObject* globalObject, JSObject* received, JSObject* expected, MarkedArgumentBuffer& gcBuffer, Vector, 16>& stack, ThrowScope& scope) -{ - VM& vm = globalObject->vm(); - PropertyNameArrayBuilder expectedProps(vm, PropertyNameMode::StringsAndSymbols, PrivateSymbolMode::Exclude); - expected->getPropertyNames(globalObject, expectedProps, DontEnumPropertiesMode::Exclude); - RETURN_IF_EXCEPTION(scope, false); - - bool expectedIsArray = isArray(globalObject, expected); - RETURN_IF_EXCEPTION(scope, false); - if (expectedIsArray) { - bool receivedIsArray = isArray(globalObject, received); - RETURN_IF_EXCEPTION(scope, false); - if (!receivedIsArray || received->getArrayLength() != expected->getArrayLength()) { - return false; - } - PropertyNameArrayBuilder receivedProps(vm, PropertyNameMode::StringsAndSymbols, PrivateSymbolMode::Exclude); - received->getPropertyNames(globalObject, receivedProps, DontEnumPropertiesMode::Exclude); - RETURN_IF_EXCEPTION(scope, false); - if (receivedProps.size() != expectedProps.size()) { - return false; - } - } - - for (const auto& property : expectedProps) { - JSValue receivedProp = received->getIfPropertyExists(globalObject, property); - RETURN_IF_EXCEPTION(scope, false); - if (receivedProp.isEmpty()) { - return false; - } - JSValue expectedProp = expected->get(globalObject, property); - RETURN_IF_EXCEPTION(scope, false); - - if (!receivedProp.isObject() && !expectedProp.isObject()) { - // Two primitives: neither is a matcher, and Bun__deepEquals would only do this. - bool same = JSC::sameValue(globalObject, receivedProp, expectedProp); - RETURN_IF_EXCEPTION(scope, false); - if (!same) { - return false; - } - continue; - } - - if constexpr (enableAsymmetricMatchers && isPartial) { - // toMatchObject and the snapshot property matchers copy a matched asymmetric - // matcher to the other side, so the failure diff does not flag that property. - JSCell* expectedCell = expectedProp.isCell() ? expectedProp.asCell() : nullptr; - JSCell* receivedCell = receivedProp.isCell() ? receivedProp.asCell() : nullptr; - if (expectedCell && expectedCell->type() == JSC::JSType(JSDOMWrapperType)) { - switch (matchAsymmetricMatcher(globalObject, expectedProp, receivedProp, scope)) { - case AsymmetricMatcherResult::FAIL: - return false; - case AsymmetricMatcherResult::PASS: - received->putDirectMayBeIndex(globalObject, property, expectedProp); - RETURN_IF_EXCEPTION(scope, false); - continue; - case AsymmetricMatcherResult::NOT_MATCHER: - break; - } - } else if (receivedCell && receivedCell->type() == JSC::JSType(JSDOMWrapperType)) { - switch (matchAsymmetricMatcher(globalObject, receivedProp, expectedProp, scope)) { - case AsymmetricMatcherResult::FAIL: - return false; - case AsymmetricMatcherResult::PASS: - expected->putDirectMayBeIndex(globalObject, property, receivedProp); - RETURN_IF_EXCEPTION(scope, false); - continue; - case AsymmetricMatcherResult::NOT_MATCHER: - break; - } - } - } - - bool eq = Bun__deepEquals(globalObject, receivedProp, expectedProp, gcBuffer, stack, scope, true); - RETURN_IF_EXCEPTION(scope, false); - if (!eq) { - return false; - } - } - - return true; -} - static bool isTemporalObject(JSC::JSObject* object) { return object->inherits() @@ -1481,34 +1392,20 @@ struct DeepEqualsMode { bool enableAsymmetricMatchers; bool checkPrototypes; bool skipPrototypeIdentity; - bool isPartial; bool (*deepEquals)(JSC::JSGlobalObject*, JSValue, JSValue, MarkedArgumentBuffer&, Vector, 16>&, ThrowScope&, bool); bool (*nonIndexOwnPropertiesEqual)(JSC::JSGlobalObject*, MarkedArgumentBuffer&, Vector, 16>&, ThrowScope&, JSObject*, JSObject*); }; -template +template static constexpr DeepEqualsMode deepEqualsMode { isStrict, enableAsymmetricMatchers, checkPrototypes, skipPrototypeIdentity, - isPartial, - &Bun__deepEquals, - checkPrototypes ? &nonIndexOwnPropertiesEqual : nullptr, + &Bun__deepEquals, + checkPrototypes ? &nonIndexOwnPropertiesEqual : nullptr, }; -static bool urlSearchParamsEntriesFoundIn(WebCore::URLSearchParams& params, const WebCore::URLSearchParams& other) -{ - auto iter = params.createIterator(); - while (const auto& maybePair = iter.next()) { - const auto& maybeValue = other.get(maybePair->key); - if (!maybeValue || maybeValue != maybePair->value) { - return false; - } - } - return true; -} - // The per-type comparisons (Map, Set, Date, typed arrays, ...) are compiled once // and take the mode at runtime; only the dispatch and the plain-object tail in // `specialObjectsDequal` below stay specialised per mode. @@ -1697,18 +1594,10 @@ static std::optional specialObjectsDequalSlow(const DeepEqualsMode& mode, RETURN_IF_EXCEPTION(scope, {}); auto rightCause = right->get(globalObject, cause); RETURN_IF_EXCEPTION(scope, {}); - // Subset matching only checks a cause the expected error (right) has. - if (!(mode.isPartial && rightCause.isUndefined())) { - bool causesEqual = mode.deepEquals(globalObject, leftCause, rightCause, gcBuffer, stack, scope, true); - RETURN_IF_EXCEPTION(scope, {}); - if (!causesEqual) { - return false; - } - } - - if (mode.isPartial) { - // Subset matching walks the expected error's enumerable properties. - break; + bool causesEqual = mode.deepEquals(globalObject, leftCause, rightCause, gcBuffer, stack, scope, true); + RETURN_IF_EXCEPTION(scope, {}); + if (!causesEqual) { + return false; } // check arbitrary enumerable properties. `.stack` is not checked. @@ -1954,18 +1843,19 @@ static std::optional specialObjectsDequalSlow(const DeepEqualsMode& mode, auto urlSearchParams2 = dynamicDowncast(c2); if (urlSearchParams1 && urlSearchParams2) { auto& wrapped1 = urlSearchParams1->wrapped(); - auto& wrapped2 = urlSearchParams2->wrapped(); + const auto& wrapped2 = urlSearchParams2->wrapped(); if (wrapped1.size() != wrapped2.size()) { return false; } - if (!urlSearchParamsEntriesFoundIn(wrapped1, wrapped2)) { - return false; - } - // The other modes call this arm a second time with the sides swapped. Subset - // matching calls it once, and `a=1&a=1` must not match `a=1&b=1`. - if (mode.isPartial && !urlSearchParamsEntriesFoundIn(wrapped2, wrapped1)) { - return false; + auto iter1 = wrapped1.createIterator(); + while (const auto& maybePair = iter1.next()) { + const auto& key = maybePair->key; + const auto& value = maybePair->value; + const auto& maybeValue = wrapped2.get(key); + if (!maybeValue || maybeValue != value) { + return false; + } } goto compareAsNormalValue; @@ -2100,7 +1990,7 @@ static std::optional specialObjectsDequalSlow(const DeepEqualsMode& mode, return std::nullopt; } -template +template std::optional specialObjectsDequal(JSC::JSGlobalObject* globalObject, MarkedArgumentBuffer& gcBuffer, Vector, 16>& stack, ThrowScope& scope, JSCell* _Nonnull c1, JSCell* _Nonnull c2) { VM& vm = globalObject->vm(); @@ -2137,7 +2027,7 @@ std::optional specialObjectsDequal(JSC::JSGlobalObject* globalObject, Mark JSValue key2; bool foundMatchingKey = false; while (iter2->next(globalObject, key2)) { - bool equal = Bun__deepEquals(globalObject, key1, key2, gcBuffer, stack, scope, false); + bool equal = Bun__deepEquals(globalObject, key1, key2, gcBuffer, stack, scope, false); RETURN_IF_EXCEPTION(scope, {}); if (equal) { foundMatchingKey = true; @@ -2182,7 +2072,7 @@ std::optional specialObjectsDequal(JSC::JSGlobalObject* globalObject, Mark JSValue key2; bool foundMatchingKey = false; while (iter2->nextKeyValue(globalObject, key2, value2)) { - bool keysEqual = Bun__deepEquals(globalObject, key1, key2, gcBuffer, stack, scope, false); + bool keysEqual = Bun__deepEquals(globalObject, key1, key2, gcBuffer, stack, scope, false); RETURN_IF_EXCEPTION(scope, {}); if (keysEqual) { foundMatchingKey = true; @@ -2197,7 +2087,7 @@ std::optional specialObjectsDequal(JSC::JSGlobalObject* globalObject, Mark // Compare both values below. } - bool valuesEqual = Bun__deepEquals(globalObject, value1, value2, gcBuffer, stack, scope, false); + bool valuesEqual = Bun__deepEquals(globalObject, value1, value2, gcBuffer, stack, scope, false); RETURN_IF_EXCEPTION(scope, {}); if (!valuesEqual) { return false; @@ -2235,7 +2125,7 @@ std::optional specialObjectsDequal(JSC::JSGlobalObject* globalObject, Mark case GlobalProxyType: case NumberObjectType: case BooleanObjectType: - return specialObjectsDequalSlow(deepEqualsMode, globalObject, gcBuffer, stack, scope, c1, c2); + return specialObjectsDequalSlow(deepEqualsMode, globalObject, gcBuffer, stack, scope, c1, c2); default: break; } @@ -2280,21 +2170,215 @@ std::optional specialObjectsDequal(JSC::JSGlobalObject* globalObject, Mark return std::nullopt; } -// The other combinations are instantiated by their uses in this file. These two are -// only reached from `Bun.deepEquals(a, b, true, true)` and `Bun.deepMatch` in BunObject.cpp. +// A Date and an Error have no enumerable properties, so the key walk of Bun__deepMatch accepts +// any object in their place. Like Jest, an expected Date needs a Date with the same time value, +// and an expected Error needs an Error with the same message. +static bool subsetDateOrErrorMismatch(JSGlobalObject* globalObject, ThrowScope& scope, JSObject* obj, JSObject* subsetObj) +{ + if (auto* subsetDate = dynamicDowncast(subsetObj)) { + auto* objDate = dynamicDowncast(obj); + return !objDate || objDate->internalNumber() != subsetDate->internalNumber(); + } + ASSERT(subsetObj->type() == ErrorInstanceType); + + VM& vm = globalObject->vm(); + if (obj->type() != ErrorInstanceType) { + // `instanceof Error`, so that a DOMException counts. + bool isError = JSObject::defaultHasInstance(globalObject, obj, globalObject->errorPrototype()); + RETURN_IF_EXCEPTION(scope, true); + if (!isError) { + return true; + } + } + JSValue subsetMessage = subsetObj->get(globalObject, vm.propertyNames->message); + RETURN_IF_EXCEPTION(scope, true); + JSValue objMessage = obj->get(globalObject, vm.propertyNames->message); + RETURN_IF_EXCEPTION(scope, true); + bool sameMessage = JSC::sameValue(globalObject, objMessage, subsetMessage); + RETURN_IF_EXCEPTION(scope, true); + return !sameMessage; +} + +// The other combinations are instantiated by their uses in this file. This one is +// only reached from `Bun.deepEquals(a, b, true, true)` in BunObject.cpp. template bool Bun__deepEquals(JSC::JSGlobalObject*, JSValue, JSValue, MarkedArgumentBuffer&, Vector, 16>&, ThrowScope&, bool); -template bool Bun__deepEquals(JSC::JSGlobalObject*, JSValue, JSValue, MarkedArgumentBuffer&, Vector, 16>&, ThrowScope&, bool); + +/** + * @brief `Bun.deepMatch(a, b)` + * + * @note + * The sets recording already visited properties (`seenObjProperties`, + * `seenSubsetProperties`, and `gcBuffer`) aren not needed when both + * `enableAsymmetricMatchers` and `isMatchingObjectContaining` are true. In + * this case, it is safe to pass a `nullptr`. + * + * `gcBuffer` ensures JSC's stack scan does not come up empty-handed and free + * properties currently within those stacks. Likely unnecessary, but better to + * be safe tnan sorry + * + * @tparam enableAsymmetricMatchers + * @param objValue + * @param seenObjProperties already visited properties of `objValue`. + * @param subsetValue + * @param seenSubsetProperties already visited properties of `subsetValue`. + * @param globalObject + * @param throwScope + * @param gcBuffer + * @param replacePropsWithAsymmetricMatchers + * @param isMatchingObjectContaining + * + * @return true + * @return false + */ +template +bool Bun__deepMatch( + JSValue objValue, + std::set* seenObjProperties, + JSValue subsetValue, + std::set* seenSubsetProperties, + JSGlobalObject* globalObject, + ThrowScope& throwScope, + MarkedArgumentBuffer* gcBuffer, + bool replacePropsWithAsymmetricMatchers, + bool isMatchingObjectContaining) +{ + + // Caller must ensure only objects are passed to this function. + ASSERT(objValue.isCell()); + ASSERT(subsetValue.isCell()); + VM& vm = globalObject->vm(); + if (!vm.isSafeToRecurse()) [[unlikely]] { + throwStackOverflowError(globalObject, throwScope); + return false; + } + + // fast path for reference equality. + if (objValue == subsetValue) return true; + JSObject* obj = objValue.getObject(); + JSObject* subsetObj = subsetValue.getObject(); + + PropertyNameArrayBuilder subsetProps(vm, PropertyNameMode::StringsAndSymbols, PrivateSymbolMode::Include); + subsetObj->getPropertyNames(globalObject, subsetProps, DontEnumPropertiesMode::Exclude); + RETURN_IF_EXCEPTION(throwScope, false); + + JSC::JSType subsetType = subsetObj->type(); + if ((subsetType == JSDateType || subsetType == ErrorInstanceType) && !isMatchingObjectContaining) [[unlikely]] { + bool mismatch = subsetDateOrErrorMismatch(globalObject, throwScope, obj, subsetObj); + RETURN_IF_EXCEPTION(throwScope, false); + if (mismatch) return false; + } + + // TODO: add fast paths for: + // - two "simple" objects (using ->forEachProperty in both) + // - two "simple" arrays + // similar to what is done in deepEquals (canPerformFastPropertyEnumerationForIterationBun) + + // arrays should match exactly + bool objIsArray = isArray(globalObject, objValue); + RETURN_IF_EXCEPTION(throwScope, false); + bool subsetIsArray = objIsArray && isArray(globalObject, subsetValue); + RETURN_IF_EXCEPTION(throwScope, false); + if (subsetIsArray) { + if (obj->getArrayLength() != subsetObj->getArrayLength()) { + return false; + } + PropertyNameArrayBuilder objProps(vm, PropertyNameMode::StringsAndSymbols, PrivateSymbolMode::Include); + obj->getPropertyNames(globalObject, objProps, DontEnumPropertiesMode::Exclude); + RETURN_IF_EXCEPTION(throwScope, false); + if (objProps.size() != subsetProps.size()) { + return false; + } + } + + for (const auto& property : subsetProps) { + JSValue prop = obj->getIfPropertyExists(globalObject, property); + RETURN_IF_EXCEPTION(throwScope, false); + if (prop.isEmpty()) { + return false; + } + + JSValue subsetProp = subsetObj->get(globalObject, property); + RETURN_IF_EXCEPTION(throwScope, false); + + JSCell* subsetPropCell = !subsetProp.isEmpty() && subsetProp.isCell() ? subsetProp.asCell() : nullptr; + JSCell* propCell = prop.isCell() ? prop.asCell() : nullptr; + + if constexpr (enableAsymmetricMatchers) { + if (subsetPropCell && subsetPropCell->type() == JSC::JSType(JSDOMWrapperType)) { + switch (matchAsymmetricMatcher(globalObject, subsetProp, prop, throwScope)) { + case AsymmetricMatcherResult::FAIL: + return false; + case AsymmetricMatcherResult::PASS: + if (replacePropsWithAsymmetricMatchers) { + obj->putDirectMayBeIndex(globalObject, property, subsetProp); + RETURN_IF_EXCEPTION(throwScope, false); + } + // continue to next subset prop + continue; + case AsymmetricMatcherResult::NOT_MATCHER: + break; + } + } else if (propCell && propCell->type() == JSC::JSType(JSDOMWrapperType)) { + switch (matchAsymmetricMatcher(globalObject, prop, subsetProp, throwScope)) { + case AsymmetricMatcherResult::FAIL: + return false; + case AsymmetricMatcherResult::PASS: + if (replacePropsWithAsymmetricMatchers) { + subsetObj->putDirectMayBeIndex(globalObject, property, prop); + RETURN_IF_EXCEPTION(throwScope, false); + } + // continue to next subset prop + continue; + case AsymmetricMatcherResult::NOT_MATCHER: + break; + } + } + } + + if (subsetProp.isObject() and prop.isObject()) { + // if this is called from inside an objectContaining asymmetric matcher, it should behave slightly differently: + // in such case, it expects exhaustive matching of any nested object properties, not just a subset, + // and the user would need to opt-in to subset matching by using another nested objectContaining matcher + if (enableAsymmetricMatchers && isMatchingObjectContaining) { + Vector, 16> stack; + MarkedArgumentBuffer gcBuffer; + auto eql = Bun__deepEquals(globalObject, prop, subsetProp, gcBuffer, stack, throwScope, true); + RETURN_IF_EXCEPTION(throwScope, false); + if (!eql) return false; + } else { + ASSERT(seenObjProperties != nullptr); + ASSERT(seenSubsetProperties != nullptr); + ASSERT(gcBuffer != nullptr); + auto didInsertProp = seenObjProperties->insert(JSC::JSValue::encode(prop)); + auto didInsertSubset = seenSubsetProperties->insert(JSC::JSValue::encode(subsetProp)); + gcBuffer->append(prop); + gcBuffer->append(subsetProp); + // property cycle detected + if (!didInsertProp.second || !didInsertSubset.second) continue; + bool matched = Bun__deepMatch(prop, seenObjProperties, subsetProp, seenSubsetProperties, globalObject, throwScope, gcBuffer, replacePropsWithAsymmetricMatchers, isMatchingObjectContaining); + RETURN_IF_EXCEPTION(throwScope, false); + if (!matched) return false; + } + } else { + auto same = JSC::sameValue(globalObject, prop, subsetProp); + RETURN_IF_EXCEPTION(throwScope, false); + if (!same) return false; + } + } + + return true; +} // anonymous namespace to avoid name collision namespace { -template +template inline bool deepEqualsWrapperImpl(JSC::EncodedJSValue a, JSC::EncodedJSValue b, JSC::JSGlobalObject* global) { auto& vm = global->vm(); auto scope = DECLARE_THROW_SCOPE(vm); Vector, 16> stack; MarkedArgumentBuffer args; - bool result = Bun__deepEquals(global, JSC::JSValue::decode(a), JSC::JSValue::decode(b), args, stack, scope, true); + bool result = Bun__deepEquals(global, JSC::JSValue::decode(a), JSC::JSValue::decode(b), args, stack, scope, true); RELEASE_AND_RETURN(scope, result); } } @@ -3170,11 +3254,17 @@ bool Bun__deepEqualsNodeStrictSkipProto(JSC::EncodedJSValue JSValue0, JSC::Encod #undef IMPL_DEEP_EQUALS_WRAPPER -// `expect(received).toMatchObject(expected)` and the snapshot property matchers: -// `expected` is a subset of `received`, with asymmetric matchers enabled. -bool JSC__JSValue__jestDeepMatch(JSC::EncodedJSValue received, JSC::EncodedJSValue expected, JSC::JSGlobalObject* globalObject) +bool JSC__JSValue__jestDeepMatch(JSC::EncodedJSValue JSValue0, JSC::EncodedJSValue JSValue1, JSC::JSGlobalObject* globalObject, bool replacePropsWithAsymmetricMatchers) { - return deepEqualsWrapperImpl(received, expected, globalObject); + JSValue obj = JSValue::decode(JSValue0); + JSValue subset = JSValue::decode(JSValue1); + + ThrowScope scope = DECLARE_THROW_SCOPE(globalObject->vm()); + + std::set objVisited; + std::set subsetVisited; + MarkedArgumentBuffer gcBuffer; + RELEASE_AND_RETURN(scope, Bun__deepMatch(obj, &objVisited, subset, &subsetVisited, globalObject, scope, &gcBuffer, replacePropsWithAsymmetricMatchers, false)); } extern "C" bool Bun__JSValue__isAsyncContextFrame(JSC::EncodedJSValue value) diff --git a/src/jsc/bindings/headers-handwritten.h b/src/jsc/bindings/headers-handwritten.h index bea765405818..fccc54915676 100644 --- a/src/jsc/bindings/headers-handwritten.h +++ b/src/jsc/bindings/headers-handwritten.h @@ -5,6 +5,7 @@ #include "JavaScriptCore/ArgList.h" #include #include +#include #ifndef HEADERS_HANDWRITTEN #define HEADERS_HANDWRITTEN @@ -442,11 +443,53 @@ extern "C" JSC::EncodedJSValue Bun__encoding__constructFromUTF16(void*, const ch extern "C" void Bun__EventLoop__runCallback2(JSC::JSGlobalObject* global, JSC::EncodedJSValue callback, JSC::EncodedJSValue thisValue, JSC::EncodedJSValue arg1, JSC::EncodedJSValue arg2); /// @note throws a JS exception and returns false if a stack overflow occurs -/// `isPartial` is subset matching (`toMatchObject`, `Bun.deepMatch`): `v2` is the expected -/// value, and only its enumerable properties are compared. -template +template bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSC::JSValue v1, JSC::JSValue v2, JSC::MarkedArgumentBuffer&, Vector, 16>& stack, JSC::ThrowScope& scope, bool addToStack); +/** + * @brief `Bun.deepMatch(a, b)` + * + * `object` and `subset` must be objects. In the future we should change the + * signature of this function to only take `JSC::JSCell`. For now, panics + * if either `object` or `subset` are not `JSCCell`. + * + * @note + * The sets recording already visited properties (`seenObjProperties` and + * `seenSubsetProperties`) aren not needed when both `enableAsymmetricMatchers` + * and `isMatchingObjectContaining` are true. In this case, it is safe to pass a + * `nullptr`. + * + * `gcBuffer` ensures JSC's stack scan does not come up empty-handed and free + * properties currently within those stacks. Likely unnecessary, but better to + * be safe tnan sorry + * + * + * @tparam enableAsymmetricMatchers + * @param objValue + * @param seenObjProperties already visited properties of `objValue`. + * @param subsetValue + * @param seenSubsetProperties already visited properties of `subsetValue`. + * @param globalObject + * @param Scope + * @param gcBuffer + * @param replacePropsWithAsymmetricMatchers + * @param isMatchingObjectContaining + * + * @return true + * @return false + */ +template +bool Bun__deepMatch( + JSC::JSValue object, + std::set* seenObjProperties, + JSC::JSValue subset, + std::set* seenSubsetProperties, + JSC::JSGlobalObject* globalObject, + JSC::ThrowScope& throwScope, + JSC::MarkedArgumentBuffer* gcBuffer, + bool replacePropsWithAsymmetricMatchers, + bool isMatchingObjectContaining); + extern "C" void Bun__remapStackFramePositions(void*, ZigStackFrame*, size_t); namespace Inspector { diff --git a/src/jsc/bindings/headers.h b/src/jsc/bindings/headers.h index 4714e6f08562..d6d4b82bb96b 100644 --- a/src/jsc/bindings/headers.h +++ b/src/jsc/bindings/headers.h @@ -226,7 +226,7 @@ CPP_DECL bool JSC__JSValue__isSymbol(JSC::EncodedJSValue JSValue0); CPP_DECL bool JSC__JSValue__isTerminationException(JSC::EncodedJSValue JSValue0); CPP_DECL bool JSC__JSValue__isUInt32AsAnyInt(JSC::EncodedJSValue JSValue0); CPP_DECL bool JSC__JSValue__jestDeepEquals(JSC::EncodedJSValue JSValue0, JSC::EncodedJSValue JSValue1, JSC::JSGlobalObject* arg2); -CPP_DECL bool JSC__JSValue__jestDeepMatch(JSC::EncodedJSValue JSValue0, JSC::EncodedJSValue JSValue1, JSC::JSGlobalObject* arg2); +CPP_DECL bool JSC__JSValue__jestDeepMatch(JSC::EncodedJSValue JSValue0, JSC::EncodedJSValue JSValue1, JSC::JSGlobalObject* arg2, bool arg3); CPP_DECL bool JSC__JSValue__jestStrictDeepEquals(JSC::EncodedJSValue JSValue0, JSC::EncodedJSValue JSValue1, JSC::JSGlobalObject* arg2); CPP_DECL JSC::EncodedJSValue JSC__JSValue__jsNumberFromDouble(double arg0); CPP_DECL JSC::EncodedJSValue JSC__JSValue__jsNumberFromInt64(int64_t arg0); diff --git a/src/runtime/test_runner/expect.rs b/src/runtime/test_runner/expect.rs index 27da36a50c2e..c7efb52eaaf0 100644 --- a/src/runtime/test_runner/expect.rs +++ b/src/runtime/test_runner/expect.rs @@ -1110,7 +1110,7 @@ impl Expect { let prop_matchers = _prop_matchers; - if !value.jest_deep_match(prop_matchers, global_this)? { + if !value.jest_deep_match(prop_matchers, global_this, true)? { // TODO: print diff with properties from propertyMatchers let signature = Self::get_signature(fn_name, "propertyMatchers", false); let mut formatter = ConsoleObject::Formatter::new(global_this); diff --git a/src/runtime/test_runner/expect/toMatchObject.rs b/src/runtime/test_runner/expect/toMatchObject.rs index 4c0d50675508..d3eb487a937c 100644 --- a/src/runtime/test_runner/expect/toMatchObject.rs +++ b/src/runtime/test_runner/expect/toMatchObject.rs @@ -32,7 +32,7 @@ impl Expect { ); } - let mut pass = received_object.jest_deep_match(expected, global)?; + let mut pass = received_object.jest_deep_match(expected, global, true)?; if not { pass = !pass; diff --git a/test/js/bun/bun-object/deep-equals-temporal.test.ts b/test/js/bun/bun-object/deep-equals-temporal.test.ts index 47ceb16377f5..8432f32b23bf 100644 --- a/test/js/bun/bun-object/deep-equals-temporal.test.ts +++ b/test/js/bun/bun-object/deep-equals-temporal.test.ts @@ -132,67 +132,6 @@ describe("expect().toEqual on Temporal values", () => { }); }); -// A Temporal object has no enumerable properties, so a key walk of an expected -// Temporal value accepts any received object. -describe("subset matching on Temporal values", () => { - const cases: [name: string, makeA: () => unknown, makeB: () => unknown][] = [ - [ - "Instant", - () => Temporal.Instant.from("2024-06-15T12:34:56Z"), - () => Temporal.Instant.from("2024-06-15T12:34:56.000000001Z"), - ], - [ - "PlainDateTime", - () => Temporal.PlainDateTime.from("2024-06-15T12:34:56"), - () => Temporal.PlainDateTime.from("2024-06-15T12:34:57"), - ], - ["PlainDate", () => Temporal.PlainDate.from("2024-01-01"), () => Temporal.PlainDate.from("2099-12-31")], - ["PlainTime", () => Temporal.PlainTime.from("12:34:56"), () => Temporal.PlainTime.from("12:34:56.000000001")], - [ - "ZonedDateTime", - () => Temporal.ZonedDateTime.from("2024-06-15T12:34:56+02:00[Europe/Berlin]"), - () => Temporal.ZonedDateTime.from("2024-06-15T10:34:56+00:00[UTC]"), - ], - ["PlainYearMonth", () => Temporal.PlainYearMonth.from("2024-06"), () => Temporal.PlainYearMonth.from("2024-07")], - ["PlainMonthDay", () => Temporal.PlainMonthDay.from("06-15"), () => Temporal.PlainMonthDay.from("06-16")], - ["Duration", () => Temporal.Duration.from("PT1H"), () => Temporal.Duration.from("PT60M")], - ]; - - describe.each(cases)("%s", (_, makeA, makeB) => { - it("toMatchObject compares it by value", () => { - expect({ when: makeA(), other: 1 }).toMatchObject({ when: makeA() }); - expect({ when: makeA(), other: 1 }).not.toMatchObject({ when: makeB() }); - expect([makeA()]).toMatchObject([makeA()]); - expect([makeA()]).not.toMatchObject([makeB()]); - expect(makeA()).toMatchObject(makeA() as object); - expect(makeA()).not.toMatchObject(makeB() as object); - }); - - it("Bun.deepMatch compares it by value", () => { - expect(Bun.deepMatch({ when: makeA() }, { when: makeA(), other: 1 })).toBe(true); - expect(Bun.deepMatch({ when: makeB() }, { when: makeA(), other: 1 })).toBe(false); - expect(Bun.deepMatch(makeA(), makeA())).toBe(true); - expect(Bun.deepMatch(makeB(), makeA())).toBe(false); - }); - }); - - it("an expected Temporal value does not match another class or a plain object", () => { - const date = Temporal.PlainDate.from("2024-06-15"); - const dateTime = Temporal.PlainDateTime.from("2024-06-15T00:00:00"); - expect({ when: dateTime }).not.toMatchObject({ when: date }); - expect({ when: {} }).not.toMatchObject({ when: date }); - expect(Bun.deepMatch(date, dateTime)).toBe(false); - expect(Bun.deepMatch(date, {})).toBe(false); - }); - - it("an expected plain object matches a received Temporal value through its getters", () => { - const date = Temporal.PlainDate.from("2024-06-15"); - expect({ when: date }).toMatchObject({ when: {} }); - expect(date).toMatchObject({ year: 2024, month: 6, day: 15 }); - expect(date).not.toMatchObject({ year: 2025 }); - }); -}); - describe("util.isDeepStrictEqual on Temporal values", () => { it("compares by value", () => { expect(isDeepStrictEqual(Temporal.PlainDate.from("2024-06-15"), Temporal.PlainDate.from("2024-06-15"))).toBe(true); diff --git a/test/js/bun/bun-object/deep-match.spec.ts b/test/js/bun/bun-object/deep-match.spec.ts index 5f939793f160..23a7903dcc6b 100644 --- a/test/js/bun/bun-object/deep-match.spec.ts +++ b/test/js/bun/bun-object/deep-match.spec.ts @@ -77,29 +77,17 @@ describe("Bun.deepMatch", () => { new Set(["a", "b", "c"]), new Set(["a", "b", "c"]), ], - // values inside a Map are still matched as a subset - [new Map([["a", { b: 1 }]]), new Map([["a", { b: 1, c: 2 }]])], - // Dates, Errors, RegExps and boxed primitives + // Dates and Errors [new Date(1), new Date(1)], - [{ d: new Date(1) }, { d: new Date(1) }], + [{ d: new Date(1) }, { d: new Date(1), other: 1 }], [{ d: [new Date(1)] }, { d: [new Date(1)] }], [new Error("a"), new Error("a")], [{ e: new Error("a") }, { e: new Error("a", { cause: 1 }) }], - [{ e: new Error("a", { cause: { x: 1 } }) }, { e: new Error("a", { cause: { x: 1, y: 2 } }) }], [{ e: new Error("a") }, { e: Object.assign(new Error("a"), { code: 1 }) }], - [{ r: /a/g }, { r: /a/g }], - [{ n: new Number(1) }, { n: new Number(1) }], - // ArrayBuffers, Headers, URLSearchParams and URLs are compared by their contents - [{ b: new Uint8Array([1, 2]).buffer }, { b: new Uint8Array([1, 2]).buffer }], - [new Headers({ a: "1", b: "2" }), new Headers({ b: "2", a: "1" })], - [{ query: new URLSearchParams("a=1&b=2") }, { query: new URLSearchParams("a=1&b=2"), path: "/" }], - [{ u: new URL("https://example.com/a") }, { u: new URL("https://example.com/a") }], // a plain object in the subset matches the properties of any object [{ message: "a" }, new Error("a")], - [{ e: { message: "a" } }, { e: new Error("a") }], [{ d: {} }, { d: new Date() }], - [{ m: {} }, { m: new Map() }], ])("Bun.deepMatch(%p, %p) === true", (a, b) => { expect(Bun.deepMatch(a, b)).toBe(true); }); @@ -123,80 +111,50 @@ describe("Bun.deepMatch", () => { [[], [undefined]], [["a", "b", "c"], ["a", "b", "d"]], - // Maps and Sets are compared by their entries, like `expect().toMatchObject()` in jest - [ - new Map([ [1, 2], [2, 3], [3, 4] ]), - new Map([ [1, 2], [2, 3] ]), - ], - [ - new Map([ [1, 2], [2, 3], [3, 4] ]), - new Map([ [1, 2], [2, 3], [3, 4], [4, 5] ]), - ], - [ - new Map([ [1, 2], [2, 3], [3, 4], [4, 5] ]), - new Map([ [1, 2], [2, 3], [3, 4] ]), - ], - [{ m: new Map([["a", 1]]) }, { m: new Map([["a", 2]]) }], - [{ m: new Map() }, { m: {} }], + // Maps + // FIXME: I assume this is incorrect but I need confirmation on expected behavior. + // [ + // new Map([ [1, 2], [2, 3], [3, 4] ]), + // new Map([ [1, 2], [2, 3] ]), + // ], + // [ + // new Map([ [1, 2], [2, 3], [3, 4] ]), + // new Map([ [1, 2], [2, 3], [3, 4], [4, 5] ]), + // ], + // [ + // new Map([ [1, 2], [2, 3], [3, 4], [4, 5] ]), + // new Map([ [1, 2], [2, 3], [3, 4] ]), + // ], // Sets - [ - new Set([1, 2, 3]), - new Set([4, 5, 6]), - ], - [ - new Set([1, 2, 3]), - new Set([1, 2]), - ], - [ - new Set([1, 2]), - new Set([1, 2, 3]), - ], - [ - new Set(["a", "b", "c"]), - new Set(["a", "b", "d"]), - ], - [{ s: new Set() }, { s: {} }], - - // Dates are compared by their time value, Errors by name, message and cause + // FIXME: I assume this is incorrect but I need confirmation on expected behavior. + // [ + // new Set([1, 2, 3]), + // new Set([4, 5, 6]), + // ], + // [ + // new Set([1, 2, 3]), + // new Set([1, 2]), + // ], + // [ + // new Set([1, 2]), + // new Set([1, 2, 3]), + // ], + // [ + // new Set(["a", "b", "c"]), + // new Set(["a", "b", "d"]), + // ], + + // A Date is compared by its time value, an Error by its message [new Date(1), new Date(2)], [{ d: new Date(1) }, { d: new Date(2) }], [{ d: new Date() }, { d: {} }], [{ d: new Date(1) }, { d: [new Date(1)] }], [new Error("a"), new Error("b")], [{ e: new Error("a") }, { e: new Error("b") }], - [{ e: new Error("a") }, { e: new TypeError("a") }], - [{ e: new Error("a", { cause: 1 }) }, { e: new Error("a", { cause: 2 }) }], - [{ e: new Error("a", { cause: 1 }) }, { e: new Error("a") }], - [{ e: new Error("a") }, { e: { message: "b" } }], + [{ e: new Error("a") }, { e: { message: "a" } }], [{ e: new Error("a") }, { e: {} }], - - // Functions match by identity only, native constructors and callable proxies too - [{ f: () => 1 }, { f: () => 1 }], - [{ f: () => 1 }, { f: {} }], - [{ f: Map }, { f: Set }], - [{ f: Map }, { f: {} }], - [{ f: new Proxy(() => 1, {}) }, { f: new Proxy(() => 1, {}) }], - [{ f: Object.assign(() => 1, { x: 1 }) }, { f: Object.assign(() => 1, { x: 1 }) }], - - // ArrayBuffers, Headers, URLSearchParams and URLs are compared by their contents - [{ b: new ArrayBuffer(0) }, { b: new Uint8Array([1, 2, 3]).buffer }], - [{ b: new Uint8Array([1, 2, 3]).buffer }, { b: new Uint8Array([3, 2, 1]).buffer }], - [{ b: new ArrayBuffer(0) }, { b: {} }], - [new Headers({ a: "1" }), new Headers({ a: "2" })], - [{ headers: new Headers({ a: "1" }) }, { headers: new Headers({ a: "1", b: "2" }) }], - [new URLSearchParams("a=1"), new URLSearchParams("a=2")], - [{ query: new URLSearchParams("a=1") }, { query: new URLSearchParams("a=1&b=2") }], - // the same size, and every entry of one side has its value on the other side - [{ query: new URLSearchParams("a=1&b=1") }, { query: new URLSearchParams("a=1&a=1") }], - [{ query: new URLSearchParams("a=1&a=1") }, { query: new URLSearchParams("a=1&b=1") }], - [{ u: new URL("https://example.com/a") }, { u: new URL("https://example.com/b") }], - - // Boxed primitives and RegExps are compared by their value - [{ r: /a/ }, { r: /b/ }], - [{ r: /a/g }, { r: /a/i }], - [{ n: new Number(1) }, { n: new Number(2) }], - [{ s: new String("a") }, { s: new String("b") }], + [{ e: Object.assign(new Error("a"), { code: 1 }) }, { e: new Error("a") }], ])("Bun.deepMatch(%p, %p) === false", (a, b) => { expect(Bun.deepMatch(a, b)).toBe(false); }); @@ -257,26 +215,16 @@ describe("Bun.deepMatch", () => { }); }); - it("compares functions by identity", () => { + it("does not work on functions", () => { function foo() {} function bar() {} function baz(a) { return a; } expect(Bun.deepMatch(foo, foo)).toBe(true); - expect(Bun.deepMatch(foo, bar)).toBe(false); - expect(Bun.deepMatch(foo, baz)).toBe(false); - expect(Bun.deepMatch({ f: foo }, { f: foo })).toBe(true); - expect(Bun.deepMatch({ f: foo }, { f: bar })).toBe(false); - expect(Bun.deepMatch(Set, Set)).toBe(true); - expect(Bun.deepMatch(Set, Map)).toBe(false); - expect(Bun.deepMatch(Set, {})).toBe(false); - }); - - it("matches a subset value that appears under several keys", () => { - const shared = { x: 1 }; - expect(Bun.deepMatch({ a: shared, b: shared }, { a: shared, b: { x: 1 } })).toBe(true); - expect(Bun.deepMatch({ a: shared, b: shared }, { a: shared, b: { x: 2 } })).toBe(false); + expect(Bun.deepMatch(foo, bar)).toBe(true); + // FIXME + // expect(Bun.deepMatch(foo, baz)).toBe(false); }); describe("Invalid arguments", () => { diff --git a/test/js/bun/test/expect.test.js b/test/js/bun/test/expect.test.js index ddfd8a086f34..506c87804622 100644 --- a/test/js/bun/test/expect.test.js +++ b/test/js/bun/test/expect.test.js @@ -3796,144 +3796,47 @@ describe("expect()", () => { expect(a1).toMatchObject(a1); }); - // jest's subsetEquality only walks the keys of a plain expected object. An expected - // Date, Error, Set, Map or function is a leaf compared with equals(). - test("an expected Date, Error, Set, Map or function is compared as a value", () => { + // A Date and an Error have no enumerable properties. Jest and Vitest compare an expected + // Date by its time value and an expected Error by its message. + test("an expected Date is compared by its time value", () => { expect({ d: new Date(5) }).toMatchObject({ d: new Date(5) }); expect({ d: new Date(5) }).not.toMatchObject({ d: new Date(6) }); - expect({ a: { d: new Date(5) } }).toMatchObject({ a: { d: new Date(5) } }); expect({ a: { d: new Date(5) } }).not.toMatchObject({ a: { d: new Date(6) } }); expect({ a: [new Date(5)] }).toMatchObject({ a: [new Date(5)] }); expect({ a: [new Date(5)] }).not.toMatchObject({ a: [new Date(6)] }); expect({ d: {} }).not.toMatchObject({ d: new Date(5) }); expect({ d: { x: 1 } }).not.toMatchObject({ d: new Date(5) }); - expect({ d: new Date(5) }).not.toMatchObject({ d: { x: 1 } }); + expect({ d: 5 }).not.toMatchObject({ d: new Date(5) }); expect(new Date(5)).toMatchObject(new Date(5)); expect(new Date(5)).not.toMatchObject(new Date(6)); + // a plain expected object still matches the properties of a Date + expect({ d: new Date(5) }).toMatchObject({ d: {} }); + }); + test("an expected Error is compared by its message", () => { expect({ e: new Error("x") }).toMatchObject({ e: new Error("x") }); expect({ e: new Error("x") }).not.toMatchObject({ e: new Error("y") }); - expect({ e: new TypeError("x") }).not.toMatchObject({ e: new Error("x") }); + expect({ a: [new Error("x")] }).not.toMatchObject({ a: [new Error("y")] }); expect({ e: {} }).not.toMatchObject({ e: new Error("x") }); + expect({ e: { message: "x" } }).not.toMatchObject({ e: new Error("x") }); + expect({ e: "x" }).not.toMatchObject({ e: new Error("x") }); + expect(new Error("x")).toMatchObject(new Error("x")); expect(new Error("x")).not.toMatchObject(new Error("y")); - expect({ e: new Error("x", { cause: 1 }) }).toMatchObject({ e: new Error("x") }); - expect({ e: new Error("x", { cause: { a: 1, b: 2 } }) }).toMatchObject({ - e: new Error("x", { cause: { a: 1 } }), - }); - expect({ e: new Error("x", { cause: { a: 1 } }) }).not.toMatchObject({ e: new Error("x", { cause: { a: 2 } }) }); - expect({ e: Object.assign(new Error("x"), { code: 1 }) }).toMatchObject({ e: new Error("x") }); - expect({ e: new Error("x") }).not.toMatchObject({ e: Object.assign(new Error("x"), { code: 1 }) }); - - expect({ s: new Set([1, 2]) }).toMatchObject({ s: new Set([1, 2]) }); - expect({ s: new Set([1, 2]) }).not.toMatchObject({ s: new Set([1, 3]) }); - expect({ s: new Set([1, 2, 3]) }).not.toMatchObject({ s: new Set([1, 2]) }); - expect({ s: {} }).not.toMatchObject({ s: new Set() }); - expect(new Set([1])).toMatchObject(new Set([1])); - expect(new Set([1])).not.toMatchObject(new Set([2])); - - expect({ m: new Map([[1, 2]]) }).toMatchObject({ m: new Map([[1, 2]]) }); - expect({ m: new Map([[1, 2]]) }).not.toMatchObject({ m: new Map([[1, 3]]) }); - expect({ - m: new Map([ - [1, 2], - [3, 4], - ]), - }).not.toMatchObject({ m: new Map([[1, 2]]) }); - expect({ m: new Map([["k", { a: 1, b: 2 }]]) }).toMatchObject({ m: new Map([["k", { a: 1 }]]) }); - expect({ m: {} }).not.toMatchObject({ m: new Map() }); - - const fn = () => 1; - expect({ f: fn }).toMatchObject({ f: fn }); - expect({ f: fn }).not.toMatchObject({ f: () => 1 }); - expect({ f: {} }).not.toMatchObject({ f: fn }); - }); - - test("an expected native constructor, mock function or callable Proxy matches only itself", () => { - const mockFn = jest.fn(); - expect({ f: mockFn }).toMatchObject({ f: mockFn }); - expect({ f: mockFn }).toMatchObject({ f: expect.any(Function) }); - expect({ f: jest.fn() }).not.toMatchObject({ f: jest.fn() }); - expect({ f: Map }).toMatchObject({ f: Map }); - expect({ f: Map }).not.toMatchObject({ f: Set }); - expect({ f: {} }).not.toMatchObject({ f: Set }); - const proxy = new Proxy(() => 1, {}); - expect({ f: proxy }).toMatchObject({ f: proxy }); - expect({ f: new Proxy(() => 1, {}) }).not.toMatchObject({ f: new Proxy(() => 1, {}) }); - expect({ f: Object.assign(() => 1, { x: 1 }) }).not.toMatchObject({ f: Object.assign(() => 1, { x: 1 }) }); - }); - - test("a plain expected object is matched against the properties of any received value", () => { - expect({ d: new Date(5) }).toMatchObject({ d: {} }); + // a plain expected object still matches the properties of an Error expect({ e: new Error("x") }).toMatchObject({ e: { message: "x" } }); expect({ e: new Error("x") }).not.toMatchObject({ e: { message: "y" } }); - expect(new Error("x")).toMatchObject({ message: "x" }); - expect({ m: new Map() }).toMatchObject({ m: {} }); - expect({ f: () => 1 }).toMatchObject({ f: {} }); - expect({ a: [1, 2] }).toMatchObject({ a: { 0: 1 } }); - expect({ a: { 0: 1 } }).not.toMatchObject({ a: [1] }); - }); - - test("an asymmetric matcher passed as the whole expected value is applied", () => { - expect({ a: 1, b: 2 }).toMatchObject(expect.objectContaining({ a: 1 })); - expect({ a: 1, b: 2 }).not.toMatchObject(expect.objectContaining({ a: 2 })); - expect({ a: 1, b: 2 }).not.toMatchObject(expect.objectContaining({ c: 1 })); - expect([1]).toMatchObject(expect.any(Array)); - expect({ a: 1 }).not.toMatchObject(expect.any(Array)); }); - test("an expected object reused under several keys is matched under each", () => { - const shared = { x: 1 }; - expect({ a: shared, b: shared }).toMatchObject({ a: shared, b: { x: 1 } }); - expect({ a: shared, b: { x: 2 } }).not.toMatchObject({ a: shared, b: shared }); + test("an expected Date under a received object that refers to itself", () => { + /** @type {any} */ + const received = { at: new Date(5) }; + received.self = received; + expect(received).toMatchObject({ self: { at: new Date(5) } }); + expect(received).not.toMatchObject({ self: { at: new Date(6) } }); }); if (isBun) { - // jest walks the (empty) key list of these, so any received object matches. Bun - // compares them by value, as toEqual does. - test("an expected RegExp, boxed primitive or Temporal value is compared as a value", () => { - expect({ r: /a/g }).toMatchObject({ r: /a/g }); - expect({ r: /a/g }).not.toMatchObject({ r: /a/i }); - expect({ r: /a/ }).not.toMatchObject({ r: /b/ }); - expect({ r: {} }).not.toMatchObject({ r: /a/ }); - expect({ n: new Number(1) }).toMatchObject({ n: new Number(1) }); - expect({ n: new Number(1) }).not.toMatchObject({ n: new Number(2) }); - expect({ s: new String("a") }).not.toMatchObject({ s: new String("b") }); - expect({ t: Temporal.PlainDate.from("2024-01-01") }).toMatchObject({ - t: Temporal.PlainDate.from("2024-01-01"), - }); - expect({ t: Temporal.PlainDate.from("2024-01-01") }).not.toMatchObject({ - t: Temporal.PlainDate.from("2024-01-02"), - }); - expect({ t: {} }).not.toMatchObject({ t: Temporal.PlainDate.from("2024-01-01") }); - }); - - test("an expected ArrayBuffer, typed array, URL, URLSearchParams or Headers is compared as a value", () => { - const bytes = (...b) => new Uint8Array(b).buffer; - expect({ b: bytes(1, 2) }).toMatchObject({ b: bytes(1, 2) }); - expect({ b: bytes(1, 2) }).not.toMatchObject({ b: bytes(1, 3) }); - expect({ b: bytes(1, 2) }).not.toMatchObject({ b: new ArrayBuffer(0) }); - expect({ b: {} }).not.toMatchObject({ b: new ArrayBuffer(0) }); - expect({ a: new Uint8Array([1, 2]) }).toMatchObject({ a: new Uint8Array([1, 2]) }); - expect({ a: new Uint8Array([1, 2, 3]) }).not.toMatchObject({ a: new Uint8Array([1, 2]) }); - expect({ a: [1, 2] }).not.toMatchObject({ a: new Uint8Array([1, 2]) }); - - expect({ u: new URL("https://example.com/a") }).toMatchObject({ u: new URL("https://example.com/a") }); - expect({ u: new URL("https://example.com/a") }).not.toMatchObject({ u: new URL("https://example.com/b") }); - - expect({ p: new URLSearchParams("a=1&b=2") }).toMatchObject({ p: new URLSearchParams("a=1&b=2") }); - expect({ p: new URLSearchParams("a=1") }).not.toMatchObject({ p: new URLSearchParams("a=2") }); - expect({ p: new URLSearchParams("a=1&b=2") }).not.toMatchObject({ p: new URLSearchParams("a=1") }); - // the same size, and every entry of one side has its value on the other side - expect({ p: new URLSearchParams("a=1&a=1") }).not.toMatchObject({ p: new URLSearchParams("a=1&b=1") }); - expect({ p: new URLSearchParams("a=1&b=1") }).not.toMatchObject({ p: new URLSearchParams("a=1&a=1") }); - - expect({ h: new Headers({ a: "1", b: "2" }) }).toMatchObject({ h: new Headers({ b: "2", a: "1" }) }); - expect({ h: new Headers({ a: "1" }) }).not.toMatchObject({ h: new Headers({ a: "2" }) }); - expect({ h: new Headers({ a: "1", b: "2" }) }).not.toMatchObject({ h: new Headers({ a: "1" }) }); - expect({ h: new Headers() }).not.toMatchObject({ h: new Headers({ a: "1" }) }); - }); - - test("Bun.deepMatch compares an expected Date, Error, Set, Map or function as a value", () => { + test("Bun.deepMatch compares an expected Date and Error", () => { expect(Bun.deepMatch(new Date(1), new Date(2))).toBe(false); expect(Bun.deepMatch({ d: new Date(2) }, { d: new Date(1) })).toBe(false); expect(Bun.deepMatch({ d: new Date(1) }, { d: new Date(1) })).toBe(true); @@ -3941,9 +3844,22 @@ describe("expect()", () => { expect(Bun.deepMatch({ e: new Error("x") }, { e: new Error("x") })).toBe(true); expect(Bun.deepMatch({ d: new Date() }, { d: {} })).toBe(false); expect(Bun.deepMatch({ d: {} }, { d: new Date() })).toBe(true); - expect(Bun.deepMatch({ s: new Set([1, 2]) }, { s: new Set([1, 3]) })).toBe(false); - expect(Bun.deepMatch({ m: new Map([[1, 2]]) }, { m: new Map([[1, 3]]) })).toBe(false); - expect(Bun.deepMatch({ f: () => 1 }, { f: () => 1 })).toBe(false); + }); + + test("details of the Date and Error comparison", () => { + // an invalid Date equals no Date, as in toEqual and in Jest + expect({ d: new Date(NaN) }).not.toMatchObject({ d: new Date(NaN) }); + // as in Jest, the class, the name and the cause of an Error are not compared + expect({ e: new TypeError("x") }).toMatchObject({ e: new Error("x") }); + expect({ e: new Error("x", { cause: 1 }) }).toMatchObject({ e: new Error("x", { cause: 2 }) }); + // a DOMException is an Error + expect({ e: new DOMException("x") }).toMatchObject({ e: new Error("x") }); + expect({ e: new DOMException("x") }).not.toMatchObject({ e: new Error("y") }); + // the enumerable properties of the expected Date or Error are matched, as before + const withCode = () => Object.assign(new Error("x"), { code: 1 }); + expect({ e: Object.assign(withCode(), { extra: 2 }) }).toMatchObject({ e: withCode() }); + expect({ e: new Error("x") }).not.toMatchObject({ e: withCode() }); + expect({ d: new Date(5) }).not.toMatchObject({ d: Object.assign(new Date(5), { x: 1 }) }); }); } }); diff --git a/test/js/bun/test/snapshot-tests/bun-snapshots.test.ts b/test/js/bun/test/snapshot-tests/bun-snapshots.test.ts index a67c64b98f5a..e6d584239fa3 100644 --- a/test/js/bun/test/snapshot-tests/bun-snapshots.test.ts +++ b/test/js/bun/test/snapshot-tests/bun-snapshots.test.ts @@ -57,10 +57,10 @@ describe("toMatchSnapshot errors", () => { }).toThrow(); expect(() => { expect({ a: new Date(5) }).toMatchSnapshot({ a: new Date(6) }); - }).toThrow(); + }).toThrow("to match properties from received object"); expect(() => { expect({ a: new Error("x") }).toMatchSnapshot({ a: new Error("y") }); - }).toThrow(); + }).toThrow("to match properties from received object"); }); it("should throw if arguments are in the wrong order", () => { expect(() => { diff --git a/test/js/node/net/server.spec.ts b/test/js/node/net/server.spec.ts index 0221a6d811da..88a31eb9071e 100644 --- a/test/js/node/net/server.spec.ts +++ b/test/js/node/net/server.spec.ts @@ -104,9 +104,7 @@ describe("net.Server.prototype", () => { it("has EventEmitter methods", () => { expect((net.Server.prototype as any).__proto__).toBe(EventEmitter.prototype); - // toMatchObject compares functions by identity, and Server.prototype.constructor is Server - const { constructor: _, ...inherited } = EventEmitter.prototype; - expect(net.Server.prototype).toMatchObject(inherited); + expect(net.Server.prototype).toMatchObject(EventEmitter.prototype); }); }); // diff --git a/test/js/node/url/url-parse-ipv6.test.ts b/test/js/node/url/url-parse-ipv6.test.ts index e4441c6225dc..30697f994010 100644 --- a/test/js/node/url/url-parse-ipv6.test.ts +++ b/test/js/node/url/url-parse-ipv6.test.ts @@ -29,12 +29,12 @@ describe("Valid spot checks", () => { it.each([ // ports ["http://[::1]:", { host: "[::1]", hostname: "::1", port: null, path: "/", href: "http://[::1]/" }], // trailing colons are ignored - ["http://[::1]:1", { host: "[::1]:1", hostname: "::1", port: "1", path: "/", href: "http://[::1]:1/" }], + ["http://[::1]:1", { host: "[::1]", hostname: "::1", port: "1", path: "/", href: "http://[::1]/" }], // unicast ["http://[::0]", { host: "[::0]", path: "/" }], ["http://[::f]", { host: "[::f]", path: "/" }], - ["http://[::F]", { host: "[::f]", path: "/" }], // the host is lowercased + ["http://[::F]", { host: "[::F]", path: "/" }], // these are technically invalid unicast addresses but url.parse allows them ["http://[::7]", { host: "[::7]", path: "/" }], // ["http://[::z]", { host: "[::7]", path: "/" }], @@ -42,9 +42,9 @@ describe("Valid spot checks", () => { // full form-ish ["https://[::1:2:3:4:5]", { host: "[::1:2:3:4:5]", path: "/" }], - ["[0:0:0:1:2:3:4:5]", { host: null, path: "[0:0:0:1:2:3:4:5]" }], // w/o a protocol, it's treated as a path + ["[0:0:0:1:2:3:4:5]", { host: "[0:0:0:1:2:3:4:5]", path: "/" }], ])("Parsing '%s' succeeds", (input, expected) => { - expect(url.parse(input)).toMatchObject(expected); + expect(url.parse(input)).toMatchObject(expect.objectContaining(expected)); }); }); // diff --git a/test/vendor.json b/test/vendor.json index a4c6b1b4578b..31edf3111e88 100644 --- a/test/vendor.json +++ b/test/vendor.json @@ -7,8 +7,7 @@ "ws*connection.test.ts": "TEMPORARY: elysia 1.4.28 asserts wasClean=false for a server-initiated ws.close(), but that's a clean Close handshake — Bun now reports wasClean=true to match Node/WHATWG (oven-sh/bun#31518). Fixed upstream in elysiajs/elysia#1908; remove this skip on the next elysia bump. (glob * = path separator, so it also matches Windows backslash paths)", "core*stop.test.ts": "TEMPORARY: elysia 1.4.28's 'does not shut down the server when stop(false) is called' awaits server.stop(false) and then expects a second fetch on the same keep-alive connection to still be served. As of oven-sh/bun#35130 a graceful stop(false) resolves only once every open connection has closed, so the await blocks until the pooled keep-alive times out and the follow-up fetch then hits a closed listener. Remove this skip once the upstream test is updated (the companion 'stop(true)' assertion in the same file is unaffected).", "adapter*web-standard*map-response.test.ts": "TEMPORARY: Bun's Response.redirect now puts the WHATWG serialization of the url into Location (oven-sh/bun#33126), matching Node and https://fetch.spec.whatwg.org/#dom-response-redirect, so Response.redirect('https://cunny.school') yields 'https://cunny.school/'. elysia 1.4.28's 'map redirect' test asserts the unserialized string; remove this skip once the upstream assertion expects the trailing slash.", - "adapter*web-standard*map-early-response.test.ts": "TEMPORARY: same as adapter*web-standard*map-response.test.ts; its 'map redirect' test asserts the unserialized Response.redirect Location value.", - "units*replace-schema-type.test.ts": "TEMPORARY: 8 of elysia 1.4.28's tests here toMatchObject one t.Numeric()/t.BooleanString()/t.ObjectString() schema against another. Every call creates fresh Decode/Encode closures under the enumerable Symbol.for('TypeBox.Transform') key, and toMatchObject now compares functions by identity (as Jest does) instead of matching any two functions, so independently created schemas no longer match. The test is unchanged upstream as of elysia 1.4.30; remove this skip once it stops comparing the transform closures (e.g. expect.any(Function))." + "adapter*web-standard*map-early-response.test.ts": "TEMPORARY: same as adapter*web-standard*map-response.test.ts; its 'map redirect' test asserts the unserialized Response.redirect Location value." } } ] From f2ef13ebdedbb069bbf28768570d71aecfa0ade6 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 1 Nov 2026 23:55:37 +0000 Subject: [PATCH 07/15] bun:test: define subsetDateOrErrorMismatch below the Bun__deepEquals instantiation The helper sat directly above the comment of the explicit instantiation of Bun__deepEquals. A change to that comment and this helper then touch the same lines. It now sits between the instantiation and Bun__deepMatch. No behaviour changes. --- src/jsc/bindings/bindings.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index d3c9f07ef7e6..ef465977168c 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -2448,6 +2448,10 @@ std::optional specialObjectsDequal(JSC::JSGlobalObject* globalObject, Mark return std::nullopt; } +// The other combinations are instantiated by their uses in this file. This one is +// only reached from `Bun.deepEquals(a, b, true, true)` in BunObject.cpp. +template bool Bun__deepEquals(JSC::JSGlobalObject*, JSValue, JSValue, MarkedArgumentBuffer&, Vector, 16>&, ThrowScope&, bool); + // A Date and an Error have no enumerable properties, so the key walk of Bun__deepMatch accepts // any object in their place. Like Jest, an expected Date needs a Date with the same time value, // and an expected Error needs an Error with the same message. @@ -2477,10 +2481,6 @@ static bool subsetDateOrErrorMismatch(JSGlobalObject* globalObject, ThrowScope& return !sameMessage; } -// The other combinations are instantiated by their uses in this file. This one is -// only reached from `Bun.deepEquals(a, b, true, true)` in BunObject.cpp. -template bool Bun__deepEquals(JSC::JSGlobalObject*, JSValue, JSValue, MarkedArgumentBuffer&, Vector, 16>&, ThrowScope&, bool); - /** * @brief `Bun.deepMatch(a, b)` */ From abb3445a45628432e1f8ddd4d32f0b190af2757b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 1 Nov 2026 22:17:18 +0000 Subject: [PATCH 08/15] bun:test: compare an expected Map, Set and function in toMatchObject and Bun.deepMatch Bun__deepMatch walks the enumerable keys of the expected value. A Map, a Set and a function keep what they hold elsewhere, so an expected one matched any object. An asymmetric matcher that was the whole expected value was walked as a plain object too. - An expected Set or Map needs one of the same size. Each received member or entry must match an expected one as a subset. A candidate that does not match leaves nothing behind in the state of the walk. - An expected function matches only itself. A method that the expected object inherits is not compared. - A matcher that is the whole expected or received value is applied. The property loop, the members of a Set and the entries of a Map share one pair function, subsetPairMatches. --- packages/bun-types/bun.d.ts | 2 + src/jsc/bindings/bindings.cpp | 308 ++++++++++++------ src/jsc/bindings/headers-handwritten.h | 45 +++ test/js/bun/bun-object/deep-match.spec.ts | 88 +++-- test/js/bun/test/expect-extend.test.js | 12 + test/js/bun/test/expect.test.js | 260 +++++++++++++++ .../test/snapshot-tests/bun-snapshots.test.ts | 26 ++ test/js/bun/test/vitest-apis.test.ts | 16 +- test/js/node/net/server.spec.ts | 4 +- test/js/node/url/url-parse-ipv6.test.ts | 6 +- test/vendor.json | 3 +- 11 files changed, 636 insertions(+), 134 deletions(-) diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index e6aa5d2f3148..0ce80e86e15a 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -2939,6 +2939,8 @@ declare module "bun" { * * A `Date` in the subset matches a `Date` with the same time value. * An `Error` in the subset matches an `Error` with the same message. + * A `Set` or a `Map` in the subset matches one of the same size whose + * members match. A function in the subset matches only itself. * * This also powers expect().toMatchObject in `bun:test` */ diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index ef465977168c..02dc2966227b 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -207,6 +207,7 @@ #include "NodeVM.h" #include +#include #include "wtf-bindings.h" #if ASSERT_ENABLED @@ -2452,33 +2453,208 @@ std::optional specialObjectsDequal(JSC::JSGlobalObject* globalObject, Mark // only reached from `Bun.deepEquals(a, b, true, true)` in BunObject.cpp. template bool Bun__deepEquals(JSC::JSGlobalObject*, JSValue, JSValue, MarkedArgumentBuffer&, Vector, 16>&, ThrowScope&, bool); -// A Date and an Error have no enumerable properties, so the key walk of Bun__deepMatch accepts -// any object in their place. Like Jest, an expected Date needs a Date with the same time value, -// and an expected Error needs an Error with the same message. -static bool subsetDateOrErrorMismatch(JSGlobalObject* globalObject, ThrowScope& scope, JSObject* obj, JSObject* subsetObj) +// Applies `pattern` to `value`, or `value` to `pattern`, if one of the two is an asymmetric matcher. +static ALWAYS_INLINE AsymmetricMatcherResult matchEitherAsymmetricMatcher(JSGlobalObject* globalObject, ThrowScope& scope, JSValue value, JSValue pattern) { - if (auto* subsetDate = dynamicDowncast(subsetObj)) { - auto* objDate = dynamicDowncast(obj); - return !objDate || objDate->internalNumber() != subsetDate->internalNumber(); + if (mayBeAsymmetricMatcher(pattern)) + return matchAsymmetricMatcher(globalObject, pattern, value, scope); + if (mayBeAsymmetricMatcher(value)) + return matchAsymmetricMatcher(globalObject, value, pattern, scope); + if (oneMayHaveAsymmetricMatchMethod(globalObject, value, pattern)) { + if (auto matched = matchUserAsymmetricMatcher(globalObject, scope, value, pattern)) [[unlikely]] + return *matched ? AsymmetricMatcherResult::PASS : AsymmetricMatcherResult::FAIL; } - ASSERT(subsetObj->type() == ErrorInstanceType); + return AsymmetricMatcherResult::NOT_MATCHER; +} - VM& vm = globalObject->vm(); - if (obj->type() != ErrorInstanceType) { - // `instanceof Error`, so that a DOMException counts. - bool isError = JSObject::defaultHasInstance(globalObject, obj, globalObject->errorPrototype()); - RETURN_IF_EXCEPTION(scope, true); - if (!isError) { +// Not inline: JSObject::hasOwnProperty is about 900 bytes in each place that inlines subsetPairMatches. +static NEVER_INLINE bool subsetInheritsProperty(JSGlobalObject* globalObject, JSObject* patternHolder, const Identifier& name) +{ + return !patternHolder->hasOwnProperty(globalObject, name); +} + +// One (value, pattern) pair inside the two values of Bun__deepMatch. `name` says which property of +// `holder` and `patternHolder` the pair is. It is null when the pair is not a property: a member of +// a Set, or a key or a value of a Map. +template +static ALWAYS_INLINE bool subsetPairMatches(JSGlobalObject* globalObject, ThrowScope& scope, Bun::DeepMatchState& state, JSValue value, JSValue pattern, bool isMatchingObjectContaining, JSObject* holder, JSObject* patternHolder, const Identifier* name) +{ + if constexpr (enableAsymmetricMatchers) { + switch (matchEitherAsymmetricMatcher(globalObject, scope, value, pattern)) { + case AsymmetricMatcherResult::FAIL: + return false; + case AsymmetricMatcherResult::PASS: + if (name && mayBeAsymmetricMatcher(pattern)) + state.recordIfAsked(holder, patternHolder, *name, pattern, jsUndefined()); return true; + case AsymmetricMatcherResult::NOT_MATCHER: + break; } } - JSValue subsetMessage = subsetObj->get(globalObject, vm.propertyNames->message); - RETURN_IF_EXCEPTION(scope, true); - JSValue objMessage = obj->get(globalObject, vm.propertyNames->message); - RETURN_IF_EXCEPTION(scope, true); - bool sameMessage = JSC::sameValue(globalObject, objMessage, subsetMessage); - RETURN_IF_EXCEPTION(scope, true); - return !sameMessage; + + if (pattern.isObject() && value.isObject()) { + // if this is called from inside an objectContaining asymmetric matcher, it should behave slightly differently: + // in such case, it expects exhaustive matching of any nested object properties, not just a subset, + // and the user would need to opt-in to subset matching by using another nested objectContaining matcher + if (enableAsymmetricMatchers && isMatchingObjectContaining) { + Vector, 16> stack; + MarkedArgumentBuffer gcBuffer; + return Bun__deepEquals(globalObject, value, pattern, gcBuffer, stack, scope, true); + } + if (name && value != pattern && pattern.isCallable()) [[unlikely]] { + // Jest compares the own keys of a pattern. The walk also sees an inherited method, + // and two copies of a class have two sets of methods, so it is not compared. + bool isInherited = subsetInheritsProperty(globalObject, patternHolder, *name); + RETURN_IF_EXCEPTION(scope, false); + if (isInherited) return true; + } + state.gcBuffer.append(value); + state.gcBuffer.append(pattern); + if (name) + state.recordIfAsked(holder, patternHolder, *name, value, pattern); + if (!state.markSeen(value, pattern)) return true; + return Bun__deepMatch(value, pattern, globalObject, scope, state, isMatchingObjectContaining); + } + + if constexpr (enableAsymmetricMatchers) { + if (hasEqualityTesters(globalObject)) [[unlikely]] { + if (auto verdict = runEqualityTesters(globalObject, scope, value, pattern)) + return *verdict; + } + } + return JSC::sameValue(globalObject, value, pattern); +} + +// A pair that is not a property. +template +static bool subsetValueMatches(JSGlobalObject* globalObject, ThrowScope& scope, Bun::DeepMatchState& state, JSValue value, JSValue pattern) +{ + return subsetPairMatches(globalObject, scope, state, value, pattern, false, nullptr, nullptr, nullptr); +} + +using SubsetValueMatches = bool (*)(JSGlobalObject*, ThrowScope&, Bun::DeepMatchState&, JSValue, JSValue); + +// Like Jest's iterableEquality: the two Sets have the same size, and every received member is +// a member of the expected Set or matches one of its members as a subset. +static bool subsetSetMatches(JSGlobalObject* globalObject, ThrowScope& scope, Bun::DeepMatchState& state, JSSet* received, JSSet* expected, SubsetValueMatches valueMatches) +{ + if (received->size() != expected->size()) { + return false; + } + + VM& vm = globalObject->vm(); + auto receivedIter = JSSetIterator::create(vm, globalObject->setIteratorStructure(), received, IterationKind::Keys); + JSValue member; + while (receivedIter->next(globalObject, member)) { + bool has = expected->has(globalObject, member); + RETURN_IF_EXCEPTION(scope, false); + if (has) { + continue; + } + + bool found = false; + auto expectedIter = JSSetIterator::create(vm, globalObject->setIteratorStructure(), expected, IterationKind::Keys); + JSValue candidate; + while (!found && expectedIter->next(globalObject, candidate)) { + Bun::DeepMatchState::Trial trial(state); + found = valueMatches(globalObject, scope, state, member, candidate); + RETURN_IF_EXCEPTION(scope, false); + trial.matched = found; + } + if (!found) { + return false; + } + } + return true; +} + +// Like Jest's iterableEquality: the two Maps have the same size, and every received entry matches +// the expected entry under the same key, or an expected entry whose key and value both match. +static bool subsetMapMatches(JSGlobalObject* globalObject, ThrowScope& scope, Bun::DeepMatchState& state, JSMap* received, JSMap* expected, SubsetValueMatches valueMatches) +{ + if (received->size() != expected->size()) { + return false; + } + + VM& vm = globalObject->vm(); + auto receivedIter = JSMapIterator::create(vm, globalObject->mapIteratorStructure(), received, IterationKind::Entries); + JSValue key, value; + while (receivedIter->nextKeyValue(globalObject, key, value)) { + bool found = false; + bool hasKey = expected->has(globalObject, key); + RETURN_IF_EXCEPTION(scope, false); + if (hasKey) { + JSValue expectedValue = expected->get(globalObject, key); + RETURN_IF_EXCEPTION(scope, false); + Bun::DeepMatchState::Trial trial(state); + found = valueMatches(globalObject, scope, state, value, expectedValue); + RETURN_IF_EXCEPTION(scope, false); + trial.matched = found; + } + + if (!found) { + auto expectedIter = JSMapIterator::create(vm, globalObject->mapIteratorStructure(), expected, IterationKind::Entries); + JSValue candidateKey, candidateValue; + while (!found && expectedIter->nextKeyValue(globalObject, candidateKey, candidateValue)) { + Bun::DeepMatchState::Trial trial(state); + found = valueMatches(globalObject, scope, state, key, candidateKey); + RETURN_IF_EXCEPTION(scope, false); + if (found) { + found = valueMatches(globalObject, scope, state, value, candidateValue); + RETURN_IF_EXCEPTION(scope, false); + } + trial.matched = found; + } + } + if (!found) { + return false; + } + } + return true; +} + +// The enumerable properties of a Date, an Error, a Set, a Map and a function do not say what the +// object holds, so the key walk of Bun__deepMatch accepts any object in their place. Like Jest, an +// expected Date needs a Date with the same time value, an expected Error needs an Error with the +// same message, an expected Set or Map needs one of the same size whose members match, and an +// expected function matches only itself. +static NEVER_INLINE bool subsetSpecialObjectMatches(JSGlobalObject* globalObject, ThrowScope& scope, Bun::DeepMatchState& state, JSObject* obj, JSObject* subsetObj, SubsetValueMatches valueMatches) +{ + switch (subsetObj->type()) { + case JSDateType: { + auto* objDate = dynamicDowncast(obj); + return objDate && objDate->internalNumber() == uncheckedDowncast(subsetObj)->internalNumber(); + } + case ErrorInstanceType: { + VM& vm = globalObject->vm(); + if (obj->type() != ErrorInstanceType) { + // `instanceof Error`, so that a DOMException counts. + bool isError = JSObject::defaultHasInstance(globalObject, obj, globalObject->errorPrototype()); + RETURN_IF_EXCEPTION(scope, false); + if (!isError) { + return false; + } + } + JSValue subsetMessage = subsetObj->get(globalObject, vm.propertyNames->message); + RETURN_IF_EXCEPTION(scope, false); + JSValue objMessage = obj->get(globalObject, vm.propertyNames->message); + RETURN_IF_EXCEPTION(scope, false); + return JSC::sameValue(globalObject, objMessage, subsetMessage); + } + case JSSetType: + case JSMapType: { + if (obj->type() != subsetObj->type()) { + return false; + } + // What matches inside a Set or a Map is not a property, so it is not shown in place of a matcher. + WTF::SetForScope recordsNothing(state.records, false); + if (auto* expectedSet = dynamicDowncast(subsetObj)) + return subsetSetMatches(globalObject, scope, state, uncheckedDowncast(obj), expectedSet, valueMatches); + return subsetMapMatches(globalObject, scope, state, uncheckedDowncast(obj), uncheckedDowncast(subsetObj), valueMatches); + } + default: + return !subsetObj->isCallable(); + } } /** @@ -2530,10 +2706,10 @@ bool Bun__deepMatch( RETURN_IF_EXCEPTION(throwScope, false); JSC::JSType subsetType = subsetObj->type(); - if ((subsetType == JSDateType || subsetType == ErrorInstanceType) && !isMatchingObjectContaining) [[unlikely]] { - bool mismatch = subsetDateOrErrorMismatch(globalObject, throwScope, obj, subsetObj); + if (subsetType != FinalObjectType && subsetType != ArrayType && !isMatchingObjectContaining) [[unlikely]] { + bool matched = subsetSpecialObjectMatches(globalObject, throwScope, state, obj, subsetObj, subsetValueMatches); RETURN_IF_EXCEPTION(throwScope, false); - if (mismatch) return false; + if (!matched) return false; } // TODO: add fast paths for: @@ -2568,73 +2744,9 @@ bool Bun__deepMatch( JSValue subsetProp = subsetObj->get(globalObject, property); RETURN_IF_EXCEPTION(throwScope, false); - JSCell* subsetPropCell = !subsetProp.isEmpty() && subsetProp.isCell() ? subsetProp.asCell() : nullptr; - JSCell* propCell = prop.isCell() ? prop.asCell() : nullptr; - - if constexpr (enableAsymmetricMatchers) { - if (subsetPropCell && subsetPropCell->type() == JSC::JSType(JSDOMWrapperType)) { - switch (matchAsymmetricMatcher(globalObject, subsetProp, prop, throwScope)) { - case AsymmetricMatcherResult::FAIL: - return false; - case AsymmetricMatcherResult::PASS: - state.recordIfAsked(obj, subsetObj, property, subsetProp, jsUndefined()); - // continue to next subset prop - continue; - case AsymmetricMatcherResult::NOT_MATCHER: - break; - } - } else if (propCell && propCell->type() == JSC::JSType(JSDOMWrapperType)) { - switch (matchAsymmetricMatcher(globalObject, prop, subsetProp, throwScope)) { - case AsymmetricMatcherResult::FAIL: - return false; - case AsymmetricMatcherResult::PASS: - // continue to next subset prop - continue; - case AsymmetricMatcherResult::NOT_MATCHER: - break; - } - } else if (oneMayHaveAsymmetricMatchMethod(globalObject, prop, subsetProp)) { - if (auto matched = matchUserAsymmetricMatcher(globalObject, throwScope, prop, subsetProp)) [[unlikely]] { - if (!*matched) - return false; - continue; - } - } - } - - if (subsetProp.isObject() and prop.isObject()) { - // if this is called from inside an objectContaining asymmetric matcher, it should behave slightly differently: - // in such case, it expects exhaustive matching of any nested object properties, not just a subset, - // and the user would need to opt-in to subset matching by using another nested objectContaining matcher - if (enableAsymmetricMatchers && isMatchingObjectContaining) { - Vector, 16> stack; - MarkedArgumentBuffer gcBuffer; - auto eql = Bun__deepEquals(globalObject, prop, subsetProp, gcBuffer, stack, throwScope, true); - RETURN_IF_EXCEPTION(throwScope, false); - if (!eql) return false; - } else { - state.gcBuffer.append(prop); - state.gcBuffer.append(subsetProp); - state.recordIfAsked(obj, subsetObj, property, prop, subsetProp); - // This (value, pattern) pair already matched, or is still being compared (a cycle). - if (!state.seen.insert({ JSC::JSValue::encode(prop), JSC::JSValue::encode(subsetProp) }).second) continue; - bool matched = Bun__deepMatch(prop, subsetProp, globalObject, throwScope, state, isMatchingObjectContaining); - RETURN_IF_EXCEPTION(throwScope, false); - if (!matched) return false; - } - } else { - if constexpr (enableAsymmetricMatchers) { - if (hasEqualityTesters(globalObject)) [[unlikely]] { - if (auto verdict = runEqualityTesters(globalObject, throwScope, prop, subsetProp)) { - if (!*verdict) return false; - continue; - } - } - } - auto same = JSC::sameValue(globalObject, prop, subsetProp); - RETURN_IF_EXCEPTION(throwScope, false); - if (!same) return false; - } + bool matched = subsetPairMatches(globalObject, throwScope, state, prop, subsetProp, isMatchingObjectContaining, obj, subsetObj, &property); + RETURN_IF_EXCEPTION(throwScope, false); + if (!matched) return false; } return true; @@ -3693,7 +3805,19 @@ bool JSC__JSValue__jestDeepMatch(JSC::EncodedJSValue JSValue0, JSC::EncodedJSVal Bun::DeepMatchState state; state.records = !!replaced; - bool matched = Bun__deepMatch(obj, subset, globalObject, scope, state, false); + // A matcher can be the whole expected value, or the whole received value. + bool matched = false; + switch (matchEitherAsymmetricMatcher(globalObject, scope, obj, subset)) { + case AsymmetricMatcherResult::FAIL: + break; + case AsymmetricMatcherResult::PASS: + matched = true; + break; + case AsymmetricMatcherResult::NOT_MATCHER: + RETURN_IF_EXCEPTION(scope, false); + matched = Bun__deepMatch(obj, subset, globalObject, scope, state, false); + break; + } RETURN_IF_EXCEPTION(scope, false); if (replaced) { JSValue shown = matched == replaceWhen ? replaceMatchedProperties(globalObject, scope, state, obj, subset) : obj; diff --git a/src/jsc/bindings/headers-handwritten.h b/src/jsc/bindings/headers-handwritten.h index fc16d92d5f87..a7269aa35f6c 100644 --- a/src/jsc/bindings/headers-handwritten.h +++ b/src/jsc/bindings/headers-handwritten.h @@ -470,6 +470,51 @@ struct DeepMatchState { recorded.append(patternOfValue); recordedNames.append(name); } + + // False if this (value, pattern) pair already matched, or is still being compared (a cycle). + // The caller has put both in `gcBuffer`. + bool markSeen(JSC::JSValue value, JSC::JSValue pattern) + { + std::pair pair { JSC::JSValue::encode(value), JSC::JSValue::encode(pattern) }; + if (!seen.insert(pair).second) + return false; + if (openTrials) [[unlikely]] + seenInTrials.append(pair); + return true; + } + + // A member of a Set or an entry of a Map is tried against one candidate after another. The + // pairs that enter `seen` during a trial are listed in `seenInTrials`, so that a candidate that + // does not match leaves nothing behind in `seen` and `gcBuffer`. + unsigned openTrials { 0 }; + WTF::Vector> seenInTrials; + + struct Trial { + DeepMatchState& state; + size_t seenBegin; + size_t gcBegin; + bool matched { false }; + + Trial(DeepMatchState& state) + : state(state) + , seenBegin(state.seenInTrials.size()) + , gcBegin(state.gcBuffer.size()) + { + state.openTrials++; + } + + ~Trial() + { + if (!matched) { + for (size_t i = state.seenInTrials.size(); i-- > seenBegin;) + state.seen.erase(state.seenInTrials[i]); + state.seenInTrials.shrink(seenBegin); + state.gcBuffer.shrink(gcBegin); + } + if (!--state.openTrials) + state.seenInTrials.clear(); + } + }; }; } diff --git a/test/js/bun/bun-object/deep-match.spec.ts b/test/js/bun/bun-object/deep-match.spec.ts index 23a7903dcc6b..b5c05002eb14 100644 --- a/test/js/bun/bun-object/deep-match.spec.ts +++ b/test/js/bun/bun-object/deep-match.spec.ts @@ -77,6 +77,10 @@ describe("Bun.deepMatch", () => { new Set(["a", "b", "c"]), new Set(["a", "b", "c"]), ], + // a member or a value of `b` may be a superset of the one in `a` + [{ m: new Map([ ["foo", { a: 1 }] ]) }, { m: new Map([ ["foo", { a: 1, b: 2 }] ]) }], + [{ s: new Set([{ a: 1 }]) }, { s: new Set([{ a: 1, b: 2 }]) }], + [{ m: new Map([ ["foo", 1], ["bar", 2] ]) }, { m: new Map([ ["bar", 2], ["foo", 1] ]) }], // Dates and Errors [new Date(1), new Date(1)], @@ -88,6 +92,8 @@ describe("Bun.deepMatch", () => { // a plain object in the subset matches the properties of any object [{ message: "a" }, new Error("a")], [{ d: {} }, { d: new Date() }], + [{ m: {} }, { m: new Map([ ["foo", 1] ]) }], + [{ s: { size: 1 } }, { s: new Set([1]) }], ])("Bun.deepMatch(%p, %p) === true", (a, b) => { expect(Bun.deepMatch(a, b)).toBe(true); }); @@ -111,39 +117,45 @@ describe("Bun.deepMatch", () => { [[], [undefined]], [["a", "b", "c"], ["a", "b", "d"]], - // Maps - // FIXME: I assume this is incorrect but I need confirmation on expected behavior. - // [ - // new Map([ [1, 2], [2, 3], [3, 4] ]), - // new Map([ [1, 2], [2, 3] ]), - // ], - // [ - // new Map([ [1, 2], [2, 3], [3, 4] ]), - // new Map([ [1, 2], [2, 3], [3, 4], [4, 5] ]), - // ], - // [ - // new Map([ [1, 2], [2, 3], [3, 4], [4, 5] ]), - // new Map([ [1, 2], [2, 3], [3, 4] ]), - // ], + // Maps: the same size, and each entry of `b` matches an entry of `a` + [ + new Map([ [1, 2], [2, 3], [3, 4] ]), + new Map([ [1, 2], [2, 3] ]), + ], + [ + new Map([ [1, 2], [2, 3], [3, 4] ]), + new Map([ [1, 2], [2, 3], [3, 4], [4, 5] ]), + ], + [ + new Map([ [1, 2], [2, 3], [3, 4], [4, 5] ]), + new Map([ [1, 2], [2, 3], [3, 4] ]), + ], + [new Map([ ["foo", 1] ]), new Map([ ["foo", 2] ])], + [new Map([ ["foo", 1] ]), new Map([ ["bar", 1] ])], + [{ m: new Map([ ["foo", { a: 1, b: 2 }] ]) }, { m: new Map([ ["foo", { a: 1 }] ]) }], + [{ m: new Map() }, { m: {} }], + [{ m: new Map() }, { m: new Set() }], - // Sets - // FIXME: I assume this is incorrect but I need confirmation on expected behavior. - // [ - // new Set([1, 2, 3]), - // new Set([4, 5, 6]), - // ], - // [ - // new Set([1, 2, 3]), - // new Set([1, 2]), - // ], - // [ - // new Set([1, 2]), - // new Set([1, 2, 3]), - // ], - // [ - // new Set(["a", "b", "c"]), - // new Set(["a", "b", "d"]), - // ], + // Sets: the same size, and each member of `b` matches a member of `a` + [ + new Set([1, 2, 3]), + new Set([4, 5, 6]), + ], + [ + new Set([1, 2, 3]), + new Set([1, 2]), + ], + [ + new Set([1, 2]), + new Set([1, 2, 3]), + ], + [ + new Set(["a", "b", "c"]), + new Set(["a", "b", "d"]), + ], + [{ s: new Set([{ a: 1, b: 2 }]) }, { s: new Set([{ a: 1 }]) }], + [{ s: new Set() }, { s: {} }], + [{ s: new Set([1]) }, { s: [1] }], // A Date is compared by its time value, an Error by its message [new Date(1), new Date(2)], @@ -215,16 +227,20 @@ describe("Bun.deepMatch", () => { }); }); - it("does not work on functions", () => { + it("a function matches only itself", () => { function foo() {} function bar() {} function baz(a) { return a; } expect(Bun.deepMatch(foo, foo)).toBe(true); - expect(Bun.deepMatch(foo, bar)).toBe(true); - // FIXME - // expect(Bun.deepMatch(foo, baz)).toBe(false); + expect(Bun.deepMatch(foo, bar)).toBe(false); + expect(Bun.deepMatch(foo, baz)).toBe(false); + expect(Bun.deepMatch({ fn: foo }, { fn: foo, other: 1 })).toBe(true); + expect(Bun.deepMatch({ fn: foo }, { fn: bar })).toBe(false); + expect(Bun.deepMatch({ fn: foo }, { fn: {} })).toBe(false); + // a plain object in the subset matches the properties of a function + expect(Bun.deepMatch({ fn: { length: 1 } }, { fn: baz })).toBe(true); }); describe("Invalid arguments", () => { diff --git a/test/js/bun/test/expect-extend.test.js b/test/js/bun/test/expect-extend.test.js index 1426a2c7aac5..92a5195f7c0c 100644 --- a/test/js/bun/test/expect-extend.test.js +++ b/test/js/bun/test/expect-extend.test.js @@ -170,6 +170,18 @@ it("defines asymmetric variadic matchers that can be prefixed by not", () => { ).not.toThrow(); }); +it("applies an asymmetric matcher passed as the whole toMatchObject argument", () => { + expect({ value: 2 }).toMatchObject(expect._toCustomEqual({ value: 2 })); + expect({ value: 2 }).not.toMatchObject(expect._toCustomEqual({ value: 3 })); + expect(() => expect({ value: 2 }).toMatchObject(expect._toCustomEqual({ value: 3 }))).toThrow(); + + expect({ value: 2 }).toMatchObject(expect.not._toCustomEqual({ value: 3 })); + expect({ value: 2 }).not.toMatchObject(expect.not._toCustomEqual({ value: 2 })); + + // the matcher receives the whole object, which is not a number + expect({ value: 2 }).not.toMatchObject(expect._toBeDivisibleBy(2)); +}); + it("prints the Symbol into the error message", () => { const foo = Symbol("foo"); const bar = Symbol("bar"); diff --git a/test/js/bun/test/expect.test.js b/test/js/bun/test/expect.test.js index 6e443690abff..8054d0e70b2a 100644 --- a/test/js/bun/test/expect.test.js +++ b/test/js/bun/test/expect.test.js @@ -4234,6 +4234,226 @@ describe("expect()", () => { expect(received).not.toMatchObject({ self: { at: new Date(6) } }); }); + // A Set, a Map and a function do not keep what they hold in enumerable properties either. + // Jest and Vitest compare an expected Set or Map by its members, and a function by identity. + test("an expected Set is compared by its members", () => { + expect({ s: new Set([1, "a"]) }).toMatchObject({ s: new Set([1, "a"]) }); + expect({ s: new Set([1]) }).not.toMatchObject({ s: new Set([2]) }); + expect({ s: new Set([1, 2, 3]) }).not.toMatchObject({ s: new Set([1, 2]) }); + expect({ s: new Set([1]) }).not.toMatchObject({ s: new Set([1, 2]) }); + expect({ s: new Set([1]) }).not.toMatchObject({ s: new Set() }); + expect({ s: new Set() }).toMatchObject({ s: new Set() }); + expect({ s: new Set([NaN]) }).toMatchObject({ s: new Set([NaN]) }); + expect({ a: [new Set([1])] }).not.toMatchObject({ a: [new Set([2])] }); + expect(new Set([1])).toMatchObject(new Set([1])); + expect(new Set([1])).not.toMatchObject(new Set([2])); + // a member matches a member of the expected Set as a subset + expect({ s: new Set([{ a: 1, b: 2 }]) }).toMatchObject({ s: new Set([{ a: 1 }]) }); + expect({ s: new Set([{ a: 1 }]) }).not.toMatchObject({ s: new Set([{ a: 1, b: 2 }]) }); + expect({ s: new Set([{ a: 1 }, { a: 2 }]) }).toMatchObject({ s: new Set([{ a: 2 }, { a: 1 }]) }); + expect({ s: new Set([{ a: 1 }]) }).not.toMatchObject({ s: new Set([{ a: 2 }]) }); + expect({ s: new Set([[1, 2]]) }).not.toMatchObject({ s: new Set([[1]]) }); + expect({ s: new Set([new Date(5)]) }).toMatchObject({ s: new Set([new Date(5)]) }); + expect({ s: new Set([new Date(5)]) }).not.toMatchObject({ s: new Set([new Date(6)]) }); + expect({ s: new Set([new Set([1])]) }).toMatchObject({ s: new Set([new Set([1])]) }); + expect({ s: new Set([new Set([1])]) }).not.toMatchObject({ s: new Set([new Set([2])]) }); + expect({ s: new Set([5]) }).toMatchObject({ s: new Set([expect.any(Number)]) }); + expect({ s: new Set(["x"]) }).not.toMatchObject({ s: new Set([expect.any(Number)]) }); + expect({ s: new Set([{ id: 1, kind: "a" }]) }).toMatchObject({ + s: new Set([{ id: expect.any(Number), kind: "a" }]), + }); + // only a Set matches an expected Set + expect({ s: [1] }).not.toMatchObject({ s: new Set([1]) }); + expect({ s: {} }).not.toMatchObject({ s: new Set() }); + expect({ s: new Map() }).not.toMatchObject({ s: new Set() }); + expect({ s: new Set([1]) }).not.toMatchObject({ s: [1] }); + // a plain expected object still matches the properties of a Set + expect({ s: new Set([1]) }).toMatchObject({ s: {} }); + expect({ s: new Set([1]) }).toMatchObject({ s: { size: 1 } }); + expect({ s: new Set([1]) }).not.toMatchObject({ s: { size: 2 } }); + }); + + test("an expected Map is compared by its entries", () => { + expect({ m: new Map([["a", 1]]) }).toMatchObject({ m: new Map([["a", 1]]) }); + expect({ m: new Map([["a", 1]]) }).not.toMatchObject({ m: new Map([["a", 2]]) }); + expect({ m: new Map([["a", 1]]) }).not.toMatchObject({ m: new Map([["b", 1]]) }); + // prettier-ignore + expect({ m: new Map([["a", 1], ["b", 2]]) }).not.toMatchObject({ m: new Map([["a", 1]]) }); + // prettier-ignore + expect({ m: new Map([["a", 1]]) }).not.toMatchObject({ m: new Map([["a", 1], ["b", 2]]) }); + // prettier-ignore + expect({ m: new Map([["a", 1], ["b", 2]]) }).toMatchObject({ m: new Map([["b", 2], ["a", 1]]) }); + expect({ m: new Map([["a", undefined]]) }).not.toMatchObject({ m: new Map([["b", undefined]]) }); + expect({ m: new Map([[NaN, 1]]) }).toMatchObject({ m: new Map([[NaN, 1]]) }); + expect({ a: [new Map([["a", 1]])] }).not.toMatchObject({ a: [new Map([["a", 2]])] }); + expect(new Map([["a", 1]])).toMatchObject(new Map([["a", 1]])); + expect(new Map([["a", 1]])).not.toMatchObject(new Map([["a", 2]])); + // a value matches the expected value as a subset + expect({ m: new Map([["a", { x: 1, y: 2 }]]) }).toMatchObject({ m: new Map([["a", { x: 1 }]]) }); + expect({ m: new Map([["a", { x: 1 }]]) }).not.toMatchObject({ m: new Map([["a", { x: 1, y: 2 }]]) }); + expect({ m: new Map([["a", new Date(5)]]) }).not.toMatchObject({ m: new Map([["a", new Date(6)]]) }); + expect({ m: new Map([["a", new Set([1])]]) }).not.toMatchObject({ m: new Map([["a", new Set([2])]]) }); + expect({ m: new Map([["a", 5]]) }).toMatchObject({ m: new Map([["a", expect.any(Number)]]) }); + expect({ m: new Map([["a", "x"]]) }).not.toMatchObject({ m: new Map([["a", expect.any(Number)]]) }); + expect({ m: new Map([["a", { id: 1 }]]) }).toMatchObject({ m: new Map([["a", { id: expect.any(Number) }]]) }); + // a key that is not in the expected Map matches an expected key as a subset + expect({ m: new Map([[{ a: 1 }, 1]]) }).toMatchObject({ m: new Map([[{ a: 1 }, 1]]) }); + expect({ m: new Map([[{ a: 1 }, 1]]) }).not.toMatchObject({ m: new Map([[{ a: 2 }, 1]]) }); + expect({ m: new Map([[{ a: 1 }, 1]]) }).not.toMatchObject({ m: new Map([[{ a: 1 }, 2]]) }); + expect({ m: new Map([["a", 1]]) }).toMatchObject({ m: new Map([[expect.any(String), 1]]) }); + expect({ m: new Map([[1, 1]]) }).not.toMatchObject({ m: new Map([[expect.any(String), 1]]) }); + // only a Map matches an expected Map + expect({ m: {} }).not.toMatchObject({ m: new Map() }); + expect({ m: new Set() }).not.toMatchObject({ m: new Map() }); + expect({ m: new WeakMap() }).not.toMatchObject({ m: new Map() }); + // a plain expected object still matches the properties of a Map + expect({ m: new Map([["a", 1]]) }).toMatchObject({ m: {} }); + }); + + test("a Set member or a Map entry that fails one candidate is compared again with the next", () => { + // The first candidate compares { v: 2 } with `shared` and fails. The second one has to compare them again. + const shared = { v: 1 }; + expect({ + s: new Set([ + { k: { v: 2 }, z: 2 }, + { z: 2, k: { v: 1 } }, + ]), + }).not.toMatchObject({ + s: new Set([ + { k: shared, z: 1 }, + { z: 2, k: shared }, + ]), + }); + expect({ + s: new Set([ + { k: { v: 1 }, z: 2 }, + { z: 1, k: { v: 1 } }, + ]), + }).toMatchObject({ + s: new Set([ + { k: shared, z: 1 }, + { z: 2, k: shared }, + ]), + }); + // prettier-ignore + expect({ m: new Map([[{ id: 1 }, { k: { v: 2 }, z: 2 }], [{ id: 2 }, { z: 2, k: { v: 1 } }]]) }).not.toMatchObject({ + m: new Map([[{ id: 1 }, { k: shared, z: 1 }], [{ id: 1 }, { z: 2, k: shared }]]), + }); + // prettier-ignore + expect({ m: new Map([[{ id: 1 }, { k: { v: 1 }, z: 2 }], [{ id: 1 }, { z: 1, k: { v: 1 } }]]) }).toMatchObject({ + m: new Map([[{ id: 1 }, { k: shared, z: 1 }], [{ id: 1 }, { z: 2, k: shared }]]), + }); + }); + + test("ends on cycles through a Set or a Map", () => { + const throughSet = (/** @type {number} */ n) => { + /** @type {any} */ + const o = {}; + o.s = new Set([o]); + o.n = n; + return o; + }; + expect(throughSet(1)).toMatchObject(throughSet(1)); + expect(throughSet(1)).not.toMatchObject(throughSet(2)); + const throughMap = (/** @type {number} */ n) => { + /** @type {any} */ + const o = {}; + o.m = new Map([["self", o]]); + o.n = n; + return o; + }; + expect(throughMap(1)).toMatchObject(throughMap(1)); + expect(throughMap(1)).not.toMatchObject(throughMap(2)); + }); + + test("an expected function matches only itself", () => { + const f = () => 1; + const g = () => 1; + expect({ cb: f }).toMatchObject({ cb: f }); + expect({ cb: f }).not.toMatchObject({ cb: g }); + expect({ cb: {} }).not.toMatchObject({ cb: f }); + expect({ cb: 1 }).not.toMatchObject({ cb: f }); + expect({ a: [f] }).not.toMatchObject({ a: [g] }); + expect({ a: { b: { cb: f } } }).toMatchObject({ a: { b: { cb: f } } }); + expect({ a: { b: { cb: f } } }).not.toMatchObject({ a: { b: { cb: g } } }); + expect({ cb: class A {} }).not.toMatchObject({ cb: class B {} }); + expect({ cb: Map }).not.toMatchObject({ cb: Set }); + expect({ cb: f.bind(null) }).not.toMatchObject({ cb: f.bind(null) }); + expect({ cb: Object.assign(() => 1, { x: 1 }) }).not.toMatchObject({ cb: Object.assign(() => 1, { x: 1 }) }); + expect({ cb: jest.fn() }).not.toMatchObject({ cb: jest.fn() }); + expect({ s: new Set([f]) }).not.toMatchObject({ s: new Set([g]) }); + expect({ m: new Map([["a", f]]) }).not.toMatchObject({ m: new Map([["a", g]]) }); + expect({ cb: f }).toMatchObject({ cb: expect.any(Function) }); + expect({ cb: { x: 1 } }).not.toMatchObject({ cb: Object.assign(() => 1, { x: 1 }) }); + expect(() => expect({ cb: f }).toMatchObject({ cb: g })).toThrow(); + expect(() => expect({}).toMatchObject(f)).toThrow(); + // a function under two keys is compared under each + expect({ a: f, b: f }).toMatchObject({ a: f, b: f }); + expect({ a: f, b: g }).not.toMatchObject({ a: f, b: f }); + // a plain expected object still matches the properties of a function + expect({ cb: f }).toMatchObject({ cb: {} }); + // expect.objectContaining() compares two functions by identity, as before + expect({ cb: f }).toEqual(expect.objectContaining({ cb: f })); + expect({ cb: f }).not.toEqual(expect.objectContaining({ cb: g })); + expect({ cb: Array }).not.toEqual(expect.objectContaining({ cb: Object })); + }); + + test("a method that an expected object inherits is not compared", () => { + // two copies of one class: each has its own function for `method` + function Old() { + this.v = 1; + } + Old.prototype.method = function () {}; + function Copy() { + this.v = 1; + } + Copy.prototype.method = function () {}; + expect({ o: new Old() }).toMatchObject({ o: new Copy() }); + expect({ o: new Old() }).toMatchObject({ o: new Old() }); + // an own function is compared + class WithHandler { + handler = () => 1; + } + expect({ o: new WithHandler() }).not.toMatchObject({ o: new WithHandler() }); + }); + + test("an asymmetric matcher as the whole expected value is applied", () => { + expect({ a: 1, b: 2 }).toMatchObject(expect.objectContaining({ a: 1 })); + expect({ a: 1, b: 2 }).not.toMatchObject(expect.objectContaining({ c: 1 })); + expect({ a: 1 }).toMatchObject(expect.not.objectContaining({ b: 1 })); + expect({ a: 1 }).not.toMatchObject(expect.not.objectContaining({ a: 1 })); + expect([1]).toMatchObject(expect.any(Array)); + expect({ a: 1 }).not.toMatchObject(expect.any(Array)); + expect([1, 2]).toMatchObject(expect.arrayContaining([1])); + expect([1, 2]).not.toMatchObject(expect.arrayContaining([3])); + expect({ a: 1 }).toMatchObject(expect.anything()); + expect({ a: 1 }).toMatchObject(expect.any(Object)); + expect(() => expect({ a: 1 }).toMatchObject(expect.objectContaining({ c: 1 }))).toThrow(); + expect(() => expect({ a: 1 }).not.toMatchObject(expect.objectContaining({ a: 1 }))).toThrow(); + expect(() => expect({ a: 1 }).toMatchObject(expect.any(Array))).toThrow(); + expect(() => expect({ a: 1 }).not.toMatchObject(expect.any(Object))).toThrow(); + // matchers that never match an object + expect({ a: 1 }).not.toMatchObject(expect.closeTo(1)); + expect({ a: 1 }).not.toMatchObject(expect.stringContaining("a")); + expect({ a: 1 }).not.toMatchObject(expect.stringMatching(/a/)); + // matchers inside the matcher + expect({ a: { b: 1 }, c: "x" }).toMatchObject(expect.objectContaining({ a: { b: expect.any(Number) } })); + expect({ a: { b: 1 }, c: "x" }).not.toMatchObject(expect.objectContaining({ a: { b: expect.any(String) } })); + // an object with an asymmetricMatch() method is a matcher too + expect({ a: 1 }).toMatchObject({ asymmetricMatch: (/** @type {any} */ other) => other.a === 1 }); + expect({ a: 1 }).not.toMatchObject({ asymmetricMatch: (/** @type {any} */ other) => other.a === 2 }); + }); + + test("an asymmetric matcher as the whole received value is applied", () => { + expect(expect.any(Object)).toMatchObject({ a: 1 }); + expect(expect.any(Array)).not.toMatchObject({ a: 1 }); + expect(() => expect(expect.any(Array)).toMatchObject({ a: 1 })).toThrow(); + expect(expect.objectContaining({ a: 1 })).toMatchObject({ a: 1, b: 2 }); + expect(expect.objectContaining({ a: 2 })).not.toMatchObject({ a: 1, b: 2 }); + expect(expect.arrayContaining([1])).toMatchObject([1, 2]); + expect(expect.arrayContaining([3])).not.toMatchObject([1, 2]); + }); + if (isBun) { test("Bun.deepMatch compares an expected Date and Error", () => { expect(Bun.deepMatch(new Date(1), new Date(2))).toBe(false); @@ -4260,6 +4480,46 @@ describe("expect()", () => { expect({ e: new Error("x") }).not.toMatchObject({ e: withCode() }); expect({ d: new Date(5) }).not.toMatchObject({ d: Object.assign(new Date(5), { x: 1 }) }); }); + + test("Bun.deepMatch compares an expected Set, Map and function", () => { + expect(Bun.deepMatch(new Set([1]), new Set([2]))).toBe(false); + expect(Bun.deepMatch({ s: new Set([2]) }, { s: new Set([1]) })).toBe(false); + expect(Bun.deepMatch({ s: new Set([{ a: 1 }]) }, { s: new Set([{ a: 1, b: 2 }]) })).toBe(true); + expect(Bun.deepMatch({ s: new Set([{ a: 1, b: 2 }]) }, { s: new Set([{ a: 1 }]) })).toBe(false); + expect(Bun.deepMatch(new Map([["a", 1]]), new Map([["a", 2]]))).toBe(false); + expect(Bun.deepMatch({ m: new Map([["a", { x: 1 }]]) }, { m: new Map([["a", { x: 1, y: 2 }]]) })).toBe(true); + expect(Bun.deepMatch({ m: new Map() }, { m: {} })).toBe(false); + expect(Bun.deepMatch({ m: {} }, { m: new Map() })).toBe(true); + const f = () => 1; + expect(Bun.deepMatch(f, f)).toBe(true); + expect(Bun.deepMatch(f, () => 1)).toBe(false); + expect(Bun.deepMatch({ cb: f }, { cb: f })).toBe(true); + expect(Bun.deepMatch({ cb: f }, { cb: () => 1 })).toBe(false); + // Bun.deepMatch has no asymmetric matchers + expect(Bun.deepMatch({ s: new Set([expect.any(Number)]) }, { s: new Set([5]) })).toBe(false); + }); + + test("details of the Set, Map and function comparison", () => { + // a received Set or Map keeps its members when they match a matcher + const received = { s: new Set([5]), m: new Map([["a", 5]]) }; + expect(received).toMatchObject({ s: new Set([expect.any(Number)]), m: new Map([["a", expect.any(Number)]]) }); + expect([...received.s, ...received.m]).toEqual([5, ["a", 5]]); + // the class of a Set or a Map is not compared, as in toEqual + class MySet extends Set {} + expect({ s: new MySet([1]) }).toMatchObject({ s: new Set([1]) }); + expect({ s: new MySet([1]) }).not.toMatchObject({ s: new Set([2]) }); + // the enumerable properties of the expected Set or Map are matched, as before + const withTag = () => Object.assign(new Set([1]), { tag: 1 }); + expect({ s: Object.assign(withTag(), { extra: 2 }) }).toMatchObject({ s: withTag() }); + expect({ s: new Set([1]) }).not.toMatchObject({ s: withTag() }); + // an object in the expected Set needs an object, as a property does + expect({ s: new Set([1]) }).not.toMatchObject({ s: new Set([{}]) }); + // a function matches an expected object by its properties + expect({ cb: Object.assign(() => 1, { x: 1 }) }).toMatchObject({ cb: { x: 1 } }); + // expect.objectContaining() still takes a Set, a Map or a function as an object with no properties + expect({ a: 1 }).toEqual(expect.objectContaining(new Set([1]))); + expect({ a: 1 }).toEqual(expect.objectContaining(() => 1)); + }); } }); diff --git a/test/js/bun/test/snapshot-tests/bun-snapshots.test.ts b/test/js/bun/test/snapshot-tests/bun-snapshots.test.ts index e6d584239fa3..3c2bb714d272 100644 --- a/test/js/bun/test/snapshot-tests/bun-snapshots.test.ts +++ b/test/js/bun/test/snapshot-tests/bun-snapshots.test.ts @@ -61,6 +61,32 @@ describe("toMatchSnapshot errors", () => { expect(() => { expect({ a: new Error("x") }).toMatchSnapshot({ a: new Error("y") }); }).toThrow("to match properties from received object"); + expect(() => { + expect({ a: new Set([1]) }).toMatchSnapshot({ a: new Set([2]) }); + }).toThrow("to match properties from received object"); + expect(() => { + expect({ a: new Map([["k", 1]]) }).toMatchSnapshot({ a: new Map([["k", 2]]) }); + }).toThrow("to match properties from received object"); + expect(() => { + expect({ a: () => 1 }).toMatchSnapshot({ a: () => 1 }); + }).toThrow("to match properties from received object"); + expect(() => { + expect({ a: 1 }).toMatchSnapshot(expect.objectContaining({ b: 1 })); + }).toThrow("to match properties from received object"); + expect(() => { + expect({ a: 1 }).toMatchInlineSnapshot(expect.any(Array), `{"a": 1}`); + }).toThrow("to match properties from received object"); + }); + it("should print the received value if the property matchers are one matcher", () => { + expect({ a: 1, b: "two" }).toMatchInlineSnapshot( + expect.objectContaining({ a: expect.any(Number) }), + ` + { + "a": 1, + "b": "two", + } + `, + ); }); it("should throw if arguments are in the wrong order", () => { expect(() => { diff --git a/test/js/bun/test/vitest-apis.test.ts b/test/js/bun/test/vitest-apis.test.ts index 1e8221e52ca1..2bde9e02f1e6 100644 --- a/test/js/bun/test/vitest-apis.test.ts +++ b/test/js/bun/test/vitest-apis.test.ts @@ -1235,13 +1235,27 @@ describe("expect.addEqualityTesters", () => { expect(make()).not.toEqual(make()); expect(make()).not.toStrictEqual(make()); expect([make()]).not.toContainEqual(make()); - if (!(make() instanceof Map)) expect({ a: make() }).not.toMatchObject({ a: make() }); + expect({ a: make() }).not.toMatchObject({ a: make() }); } expect({ a: unequal, b: 1 }).not.toEqual({ b: 1, a: unequal }); expect(new Float64Array([1, 2])).toEqual(new Float64Array([1, 2])); expect(new Float64Array([1, 2])).not.toEqual(new Float64Array([1, 3])); }); + test("a tester decides for two functions before toMatchObject compares them by identity", () => { + const tagged = (tag: string) => Object.assign(() => {}, { tag }); + expect.addEqualityTesters([ + (a, b) => + typeof a === "function" && typeof b === "function" && "tag" in a && "tag" in b ? a.tag === b.tag : undefined, + ]); + expect({ cb: tagged("x") }).toMatchObject({ cb: tagged("x") }); + expect({ cb: tagged("x") }).not.toMatchObject({ cb: tagged("y") }); + expect({ s: new Set([tagged("x")]) }).toMatchObject({ s: new Set([tagged("x")]) }); + expect({ s: new Set([tagged("x")]) }).not.toMatchObject({ s: new Set([tagged("y")]) }); + expect({ m: new Map([["k", tagged("x")]]) }).toMatchObject({ m: new Map([["k", tagged("x")]]) }); + expect({ m: new Map([["k", tagged("x")]]) }).not.toMatchObject({ m: new Map([["k", tagged("y")]]) }); + }); + test("testers run on the elements of typed arrays", () => { expect.addEqualityTesters([ (a, b) => diff --git a/test/js/node/net/server.spec.ts b/test/js/node/net/server.spec.ts index 88a31eb9071e..741cd321e9a2 100644 --- a/test/js/node/net/server.spec.ts +++ b/test/js/node/net/server.spec.ts @@ -104,7 +104,9 @@ describe("net.Server.prototype", () => { it("has EventEmitter methods", () => { expect((net.Server.prototype as any).__proto__).toBe(EventEmitter.prototype); - expect(net.Server.prototype).toMatchObject(EventEmitter.prototype); + // `constructor` is enumerable on EventEmitter.prototype here, and net.Server.prototype has its own. + const { constructor, ...methods } = EventEmitter.prototype as any; + expect(net.Server.prototype).toMatchObject(methods); }); }); // diff --git a/test/js/node/url/url-parse-ipv6.test.ts b/test/js/node/url/url-parse-ipv6.test.ts index 30697f994010..1465776f5360 100644 --- a/test/js/node/url/url-parse-ipv6.test.ts +++ b/test/js/node/url/url-parse-ipv6.test.ts @@ -29,12 +29,12 @@ describe("Valid spot checks", () => { it.each([ // ports ["http://[::1]:", { host: "[::1]", hostname: "::1", port: null, path: "/", href: "http://[::1]/" }], // trailing colons are ignored - ["http://[::1]:1", { host: "[::1]", hostname: "::1", port: "1", path: "/", href: "http://[::1]/" }], + ["http://[::1]:1", { host: "[::1]:1", hostname: "::1", port: "1", path: "/", href: "http://[::1]:1/" }], // unicast ["http://[::0]", { host: "[::0]", path: "/" }], ["http://[::f]", { host: "[::f]", path: "/" }], - ["http://[::F]", { host: "[::F]", path: "/" }], + ["http://[::F]", { host: "[::f]", path: "/" }], // the host is lowercased // these are technically invalid unicast addresses but url.parse allows them ["http://[::7]", { host: "[::7]", path: "/" }], // ["http://[::z]", { host: "[::7]", path: "/" }], @@ -42,7 +42,7 @@ describe("Valid spot checks", () => { // full form-ish ["https://[::1:2:3:4:5]", { host: "[::1:2:3:4:5]", path: "/" }], - ["[0:0:0:1:2:3:4:5]", { host: "[0:0:0:1:2:3:4:5]", path: "/" }], + ["[0:0:0:1:2:3:4:5]", { host: null, path: "[0:0:0:1:2:3:4:5]" }], // w/o a protocol, it's treated as a path ])("Parsing '%s' succeeds", (input, expected) => { expect(url.parse(input)).toMatchObject(expect.objectContaining(expected)); }); diff --git a/test/vendor.json b/test/vendor.json index 31edf3111e88..63897ab55dba 100644 --- a/test/vendor.json +++ b/test/vendor.json @@ -7,7 +7,8 @@ "ws*connection.test.ts": "TEMPORARY: elysia 1.4.28 asserts wasClean=false for a server-initiated ws.close(), but that's a clean Close handshake — Bun now reports wasClean=true to match Node/WHATWG (oven-sh/bun#31518). Fixed upstream in elysiajs/elysia#1908; remove this skip on the next elysia bump. (glob * = path separator, so it also matches Windows backslash paths)", "core*stop.test.ts": "TEMPORARY: elysia 1.4.28's 'does not shut down the server when stop(false) is called' awaits server.stop(false) and then expects a second fetch on the same keep-alive connection to still be served. As of oven-sh/bun#35130 a graceful stop(false) resolves only once every open connection has closed, so the await blocks until the pooled keep-alive times out and the follow-up fetch then hits a closed listener. Remove this skip once the upstream test is updated (the companion 'stop(true)' assertion in the same file is unaffected).", "adapter*web-standard*map-response.test.ts": "TEMPORARY: Bun's Response.redirect now puts the WHATWG serialization of the url into Location (oven-sh/bun#33126), matching Node and https://fetch.spec.whatwg.org/#dom-response-redirect, so Response.redirect('https://cunny.school') yields 'https://cunny.school/'. elysia 1.4.28's 'map redirect' test asserts the unserialized string; remove this skip once the upstream assertion expects the trailing slash.", - "adapter*web-standard*map-early-response.test.ts": "TEMPORARY: same as adapter*web-standard*map-response.test.ts; its 'map redirect' test asserts the unserialized Response.redirect Location value." + "adapter*web-standard*map-early-response.test.ts": "TEMPORARY: same as adapter*web-standard*map-response.test.ts; its 'map redirect' test asserts the unserialized Response.redirect Location value.", + "units*replace-schema-type.test.ts": "TEMPORARY: 8 tests in elysia 1.4.28 compare schemas with toMatchObject where both sides hold TypeBox Transform closures (t.Numeric() and similar make new Decode and Encode functions on every call). toMatchObject now compares an expected function by identity, as Jest and Vitest do (oven-sh/bun#15674), so those tests fail. Remove this skip once the upstream test no longer compares the closures." } } ] From 020e2aab7307ee9efbf0658334435f79f25bf0b9 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 2 Nov 2026 10:01:39 +0000 Subject: [PATCH 09/15] bun:test: leave private names out of the key walk of toMatchObject and Bun.deepMatch Bun__deepMatch listed the keys of the expected value with PrivateSymbolMode::Include. So it compared the #fields of a class, which Jest, Vitest and toEqual do not see, and for each array it walked the builtins that Array.prototype keeps under private names. Both lists now leave private names out, as Bun__deepEquals does. Those builtins were also what made an expected array refuse an object that is not an array. That rule is now written out: an expected array needs an array. --- src/jsc/bindings/bindings.cpp | 11 ++- test/js/bun/test/expect.test.js | 76 +++++++++++++++++++ .../test/snapshot-tests/bun-snapshots.test.ts | 24 ++++++ 3 files changed, 107 insertions(+), 4 deletions(-) diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index 81597bb610cc..9c2389815ef4 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -2496,7 +2496,9 @@ bool Bun__deepMatch( } } - PropertyNameArrayBuilder subsetProps(vm, PropertyNameMode::StringsAndSymbols, PrivateSymbolMode::Include); + // Private names are left out, as in Bun__deepEquals: the fields of a class (#field) and the + // builtins that Array.prototype keeps under private names. + PropertyNameArrayBuilder subsetProps(vm, PropertyNameMode::StringsAndSymbols, PrivateSymbolMode::Exclude); subsetObj->getPropertyNames(globalObject, subsetProps, DontEnumPropertiesMode::Exclude); RETURN_IF_EXCEPTION(throwScope, false); @@ -2508,13 +2510,14 @@ bool Bun__deepMatch( // arrays should match exactly bool objIsArray = isArray(globalObject, objValue); RETURN_IF_EXCEPTION(throwScope, false); - bool subsetIsArray = objIsArray && isArray(globalObject, subsetValue); + bool subsetIsArray = isArray(globalObject, subsetValue); RETURN_IF_EXCEPTION(throwScope, false); if (subsetIsArray) { - if (obj->getArrayLength() != subsetObj->getArrayLength()) { + // An array pattern needs an array. An object pattern can be satisfied by one. + if (!objIsArray || obj->getArrayLength() != subsetObj->getArrayLength()) { return false; } - PropertyNameArrayBuilder objProps(vm, PropertyNameMode::StringsAndSymbols, PrivateSymbolMode::Include); + PropertyNameArrayBuilder objProps(vm, PropertyNameMode::StringsAndSymbols, PrivateSymbolMode::Exclude); obj->getPropertyNames(globalObject, objProps, DontEnumPropertiesMode::Exclude); RETURN_IF_EXCEPTION(throwScope, false); if (objProps.size() != subsetProps.size()) { diff --git a/test/js/bun/test/expect.test.js b/test/js/bun/test/expect.test.js index db7fe78a3959..e6a156f203d5 100644 --- a/test/js/bun/test/expect.test.js +++ b/test/js/bun/test/expect.test.js @@ -7,6 +7,7 @@ */ import { stripVTControlCharacters } from "node:util"; +import { runInNewContext } from "node:vm"; // import these functions typed with the bun:test types, // so this test can also be used to detect issues with the "bun:test" type definitions import test_interop from "./test-interop.js"; @@ -4194,6 +4195,81 @@ describe("expect()", () => { expect(a1).not.toMatchObject({ 1: 1 }); expect(a1).toMatchObject(a1); }); + + // Jest, Vitest and toEqual() do not see a #field of a class. + test("the private fields of a class are not compared", () => { + class Counter { + #n; + constructor(/** @type {number} */ n) { + this.#n = n; + this.name = "c"; + } + get n() { + return this.#n; + } + } + class Holder { + #o; + constructor(/** @type {number} */ n) { + this.#o = { n }; + } + get o() { + return this.#o; + } + } + expect(new Counter(1)).toMatchObject(new Counter(2)); + expect({ k: new Counter(1) }).toMatchObject({ k: new Counter(2) }); + expect([new Counter(1)]).toMatchObject([new Counter(2)]); + expect(new Holder(1)).toMatchObject(new Holder(2)); + expect({ name: "c" }).toMatchObject(new Counter(1)); + // the other properties are compared as before + const other = new Counter(1); + other.name = "d"; + expect(new Counter(1)).not.toMatchObject(other); + expect({}).not.toMatchObject(new Counter(1)); + }); + + test("an expected array needs an array", () => { + expect({}).not.toMatchObject([]); + expect({ k: {} }).not.toMatchObject({ k: [] }); + expect({ 0: 1, length: 1 }).not.toMatchObject([1]); + expect(new Uint8Array([1])).not.toMatchObject([1]); + const arrayLike = Object.create(Array.prototype); + arrayLike[0] = 1; + arrayLike.length = 1; + expect(arrayLike).not.toMatchObject([1]); + // an expected object can be satisfied by an array + expect([1]).toMatchObject({ 0: 1 }); + expect([]).toMatchObject({}); + // an array without a prototype and an array of another realm are arrays + expect(Object.setPrototypeOf(["x"], null)).toMatchObject(["x"]); + const otherRealm = () => runInNewContext("[1, { a: 1 }]"); + expect(otherRealm()).toMatchObject([1, { a: 1 }]); + expect([1, { a: 1 }]).toMatchObject(otherRealm()); + expect({ k: otherRealm() }).toMatchObject({ k: [1, { a: 1 }] }); + expect(otherRealm()).not.toMatchObject([1, { a: 2 }]); + }); + + if (isBun) { + test("Bun.deepMatch leaves out private fields, and an array in the subset needs an array", () => { + class Counter { + #n; + constructor(/** @type {number} */ n) { + this.#n = n; + } + get n() { + return this.#n; + } + } + expect(Bun.deepMatch(new Counter(2), new Counter(1))).toBe(true); + expect(Bun.deepMatch({ k: [new Counter(2)] }, { k: [new Counter(1)] })).toBe(true); + expect(Bun.deepMatch([], {})).toBe(false); + expect(Bun.deepMatch([1], { 0: 1, length: 1 })).toBe(false); + expect(Bun.deepMatch({ 0: 1 }, [1])).toBe(true); + expect(Bun.deepMatch(["x"], Object.setPrototypeOf(["x"], null))).toBe(true); + expect(Bun.deepMatch([1, 2], runInNewContext("[1, 2]"))).toBe(true); + }); + } }); describe("toMatch()", () => { diff --git a/test/js/bun/test/snapshot-tests/bun-snapshots.test.ts b/test/js/bun/test/snapshot-tests/bun-snapshots.test.ts index 41bbca2fcd5a..bcd478841901 100644 --- a/test/js/bun/test/snapshot-tests/bun-snapshots.test.ts +++ b/test/js/bun/test/snapshot-tests/bun-snapshots.test.ts @@ -33,6 +33,30 @@ test("ArrayBuffer values are serialized like typed arrays", () => { `); }); +test("property matchers leave out the private fields of a class", () => { + class Counter { + #n; + name = "c"; + constructor(n: number) { + this.#n = n; + } + get n() { + return this.#n; + } + } + expect({ k: new Counter(1), id: 5 }).toMatchInlineSnapshot( + { k: new Counter(2), id: expect.any(Number) }, + ` + { + "id": Any, + "k": Counter { + "name": "c", + }, + } + `, + ); +}); + describe("toMatchSnapshot errors", () => { it("should throw if property matchers exist and received is not an object", () => { expect(() => { From b97c159acfc852a4578d45d500a3ab68a1275dda Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 2 Nov 2026 10:33:16 +0000 Subject: [PATCH 10/15] test: a property matcher with a private field still compares its other properties --- test/js/bun/test/snapshot-tests/bun-snapshots.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/js/bun/test/snapshot-tests/bun-snapshots.test.ts b/test/js/bun/test/snapshot-tests/bun-snapshots.test.ts index bcd478841901..f016d0ac03bb 100644 --- a/test/js/bun/test/snapshot-tests/bun-snapshots.test.ts +++ b/test/js/bun/test/snapshot-tests/bun-snapshots.test.ts @@ -55,6 +55,10 @@ test("property matchers leave out the private fields of a class", () => { } `, ); + // the other properties of the instance are still compared + expect(() => { + expect({ k: new Counter(1) }).toMatchSnapshot({ k: Object.assign(new Counter(1), { name: "x" }) }); + }).toThrow("to match properties from received object"); }); describe("toMatchSnapshot errors", () => { From 484b9092f22ce11fb7e64f444e215cdb96171a0e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 2 Nov 2026 10:42:44 +0000 Subject: [PATCH 11/15] Remove a comment that repeats PrivateSymbolMode::Exclude --- src/jsc/bindings/bindings.cpp | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index 9c2389815ef4..6d16144b3ac9 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -2496,8 +2496,6 @@ bool Bun__deepMatch( } } - // Private names are left out, as in Bun__deepEquals: the fields of a class (#field) and the - // builtins that Array.prototype keeps under private names. PropertyNameArrayBuilder subsetProps(vm, PropertyNameMode::StringsAndSymbols, PrivateSymbolMode::Exclude); subsetObj->getPropertyNames(globalObject, subsetProps, DontEnumPropertiesMode::Exclude); RETURN_IF_EXCEPTION(throwScope, false); From bbfff5229c9f3145940a99a19823b23895483258 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 2 Nov 2026 10:44:58 +0000 Subject: [PATCH 12/15] Shorten the comments of the Set and Map rules to one line each --- src/jsc/bindings/bindings.cpp | 23 ++++++----------------- src/jsc/bindings/headers-handwritten.h | 7 ++----- 2 files changed, 8 insertions(+), 22 deletions(-) diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index 41d31201a86b..82a579a6b21c 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -760,9 +760,7 @@ static NEVER_INLINE std::optional matchUserAsymmetricMatcher(JSGlobalObjec return matched.toBoolean(globalObject); } -// Applies `pattern` to `value`, or `value` to `pattern`, if one of the two is an asymmetric matcher. -// A value that only has the type of a built-in matcher, such as a Response, does not hide a matcher -// on the other side. `patternWasMatcher` is set if the result comes from `pattern`. +// Applies whichever of `value` and `pattern` is an asymmetric matcher. A Response or a URL has the type of one and is none. static ALWAYS_INLINE AsymmetricMatcherResult matchEitherAsymmetricMatcher(JSGlobalObject* globalObject, ThrowScope& scope, JSValue value, JSValue pattern, bool* patternWasMatcher = nullptr) { if (mayBeAsymmetricMatcher(pattern)) { @@ -2460,9 +2458,7 @@ std::optional specialObjectsDequal(JSC::JSGlobalObject* globalObject, Mark // only reached from `Bun.deepEquals(a, b, true, true)` in BunObject.cpp. template bool Bun__deepEquals(JSC::JSGlobalObject*, JSValue, JSValue, MarkedArgumentBuffer&, Vector, 16>&, ThrowScope&, bool); -// One (value, pattern) pair inside the two values of Bun__deepMatch. `name` says which property of -// `holder` and `patternHolder` the pair is. It is null when the pair is not a property: a member of -// a Set, or a key or a value of a Map. +// One (value, pattern) pair of Bun__deepMatch. `name` is null if the pair is not a property: a member of a Set, a key or a value of a Map. template static ALWAYS_INLINE bool subsetPairMatches(JSGlobalObject* globalObject, ThrowScope& scope, Bun::DeepMatchState& state, JSValue value, JSValue pattern, bool isMatchingObjectContaining, JSObject* holder, JSObject* patternHolder, const Identifier* name) { @@ -2481,9 +2477,7 @@ static ALWAYS_INLINE bool subsetPairMatches(JSGlobalObject* globalObject, ThrowS } if (pattern.isObject() && value.isObject()) { - // if this is called from inside an objectContaining asymmetric matcher, it should behave slightly differently: - // in such case, it expects exhaustive matching of any nested object properties, not just a subset, - // and the user would need to opt-in to subset matching by using another nested objectContaining matcher + // Inside expect.objectContaining() a nested object has to be equal. A subset there needs its own objectContaining(). if (enableAsymmetricMatchers && isMatchingObjectContaining) { Vector, 16> stack; MarkedArgumentBuffer gcBuffer; @@ -2515,8 +2509,7 @@ static bool subsetValueMatches(JSGlobalObject* globalObject, ThrowScope& scope, using SubsetValueMatches = bool (*)(JSGlobalObject*, ThrowScope&, Bun::DeepMatchState&, JSValue, JSValue); -// Like Jest's iterableEquality: the two Sets have the same size, and every received member is -// a member of the expected Set or matches one of its members as a subset. +// Jest's iterableEquality: the same size, and each received member is in the expected Set or matches one of its members. static bool subsetSetMatches(JSGlobalObject* globalObject, ThrowScope& scope, Bun::DeepMatchState& state, JSSet* received, JSSet* expected, SubsetValueMatches valueMatches) { if (received->size() != expected->size()) { @@ -2549,8 +2542,7 @@ static bool subsetSetMatches(JSGlobalObject* globalObject, ThrowScope& scope, Bu return true; } -// Like Jest's iterableEquality: the two Maps have the same size, and every received entry matches -// the expected entry under the same key, or an expected entry whose key and value both match. +// Jest's iterableEquality: the same size, and each received entry matches the expected entry of its key, or one whose key and value match. static bool subsetMapMatches(JSGlobalObject* globalObject, ThrowScope& scope, Bun::DeepMatchState& state, JSMap* received, JSMap* expected, SubsetValueMatches valueMatches) { if (received->size() != expected->size()) { @@ -2594,10 +2586,7 @@ static bool subsetMapMatches(JSGlobalObject* globalObject, ThrowScope& scope, Bu return true; } -// The enumerable properties of a Date, an Error, a Set and a Map do not say what the object holds, -// so the key walk of Bun__deepMatch accepts any object in their place. Like Jest, an expected Date -// needs a Date with the same time value, an expected Error needs an Error with the same message, -// and an expected Set or Map needs one of the same size whose members match. +// A Date, an Error, a Set and a Map keep what they hold out of their enumerable properties, so the key walk accepts any object for them. static NEVER_INLINE bool subsetSpecialObjectMatches(JSGlobalObject* globalObject, ThrowScope& scope, Bun::DeepMatchState& state, JSObject* obj, JSObject* subsetObj, SubsetValueMatches valueMatches) { switch (subsetObj->type()) { diff --git a/src/jsc/bindings/headers-handwritten.h b/src/jsc/bindings/headers-handwritten.h index ff43c9e8b1a1..2202e4239be5 100644 --- a/src/jsc/bindings/headers-handwritten.h +++ b/src/jsc/bindings/headers-handwritten.h @@ -471,8 +471,7 @@ struct DeepMatchState { recordedNames.append(name); } - // False if this (value, pattern) pair already matched, or is still being compared (a cycle). - // The caller has put both in `gcBuffer`. + // False if this (value, pattern) pair already matched or is still being compared (a cycle). Both are in `gcBuffer` already. bool markSeen(JSC::JSValue value, JSC::JSValue pattern) { std::pair pair { JSC::JSValue::encode(value), JSC::JSValue::encode(pattern) }; @@ -483,9 +482,7 @@ struct DeepMatchState { return true; } - // A member of a Set or an entry of a Map is tried against one candidate after another. The - // pairs that enter `seen` during a trial are listed in `seenInTrials`. A candidate that does - // not match then leaves nothing behind in `seen`, `gcBuffer` and the records. + // One try of a Set member or a Map entry against a candidate. A try that fails takes back what it put in `seen`, `gcBuffer` and the records. unsigned openTrials { 0 }; WTF::Vector> seenInTrials; From db32a16b79a06c1c99f9b94013d11ccc127ffd4e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 2 Nov 2026 11:09:23 +0000 Subject: [PATCH 13/15] Shorten the comment of subsetDateOrErrorMismatch to one line --- src/jsc/bindings/bindings.cpp | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index 20eb8416393c..0ae2697234f1 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -2452,9 +2452,7 @@ std::optional specialObjectsDequal(JSC::JSGlobalObject* globalObject, Mark // only reached from `Bun.deepEquals(a, b, true, true)` in BunObject.cpp. template bool Bun__deepEquals(JSC::JSGlobalObject*, JSValue, JSValue, MarkedArgumentBuffer&, Vector, 16>&, ThrowScope&, bool); -// A Date and an Error have no enumerable properties, so the key walk of Bun__deepMatch accepts -// any object in their place. Like Jest, an expected Date needs a Date with the same time value, -// and an expected Error needs an Error with the same message. +// A Date and an Error have no enumerable properties. Like Jest, an expected one needs the same time value or the same message. static bool subsetDateOrErrorMismatch(JSGlobalObject* globalObject, ThrowScope& scope, JSObject* obj, JSObject* subsetObj) { if (auto* subsetDate = dynamicDowncast(subsetObj)) { From 26b7806ef959d9e02c83ba1806c4312dfadcde31 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 2 Nov 2026 11:09:34 +0000 Subject: [PATCH 14/15] test: toMatchObject compares an expected Set and Map under the vitest import too --- test/js/bun/test/vitest-flavor.test.ts | 36 ++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/test/js/bun/test/vitest-flavor.test.ts b/test/js/bun/test/vitest-flavor.test.ts index c343e3008134..4b325086604c 100644 --- a/test/js/bun/test/vitest-flavor.test.ts +++ b/test/js/bun/test/vitest-flavor.test.ts @@ -2600,6 +2600,42 @@ describe.concurrent("behavior depends on the module a function is imported from" }); }); + test("toMatchObject() compares an expected Set and Map under both imports", async () => { + const { log, exitCode } = await runTests({ + "a.test.js": ` + import * as bun from "bun:test"; + import * as vitest from "vitest"; + const outcome = fn => { try { fn(); return "passes"; } catch { return "fails"; } }; + const key = Symbol("key"); + for (const [name, { test, expect }] of Object.entries({ bun, vitest })) { + test(name, () => { + console.log(name, "Set:", [ + outcome(() => expect({ k: new Set([1]) }).toMatchObject({ k: new Set([1]) })), + outcome(() => expect({ k: new Set([1]) }).toMatchObject({ k: new Set([2]) })), + outcome(() => expect({ k: new Set([{ a: 1, b: 2 }]) }).toMatchObject({ k: new Set([{ a: 1 }]) })), + ].join(" ")); + console.log(name, "Map:", [ + outcome(() => expect({ k: new Map([["a", 1]]) }).toMatchObject({ k: new Map([["a", 1]]) })), + outcome(() => expect({ k: new Map([["a", 1]]) }).toMatchObject({ k: new Map([["a", 2]]) })), + ].join(" ")); + // Vitest does not read the symbol keys of a pattern, and it lets two invalid Dates match. + console.log(name, "symbol key:", outcome(() => expect({ [key]: new Set([1]) }).toMatchObject({ [key]: new Set([2]) }))); + console.log(name, "invalid Date:", outcome(() => expect({ k: new Set([new Date(NaN)]) }).toMatchObject({ k: new Set([new Date(NaN)]) }))); + }); + } + `, + }); + expect({ log, exitCode }).toEqual({ + log: ["bun", "vitest"].flatMap(name => [ + `${name} Set: passes fails passes`, + `${name} Map: passes fails`, + `${name} symbol key: fails`, + `${name} invalid Date: fails`, + ]), + exitCode: 0, + }); + }); + test("toHaveProperty() with an own key that is spelled like a path", async () => { const { log, exitCode } = await runTests({ "a.test.js": ` From 25329d0a929736deea903ac718a6c0dfae4c14e3 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 2 Nov 2026 21:02:37 +0000 Subject: [PATCH 15/15] toMatchObject: try the expected member behind the last match first in a Set or a Map The scan for a received member that is not in the expected Set or Map started at the first expected member each time, so two Sets of N cloned objects in the same order took N squared over 2 tries. The scan now keeps its iterator. It first tries the expected member behind the one that matched last. If that try fails, it starts at the first expected member as before and leaves that one member out. Members that are not found by identity and follow each other in the same order take one try each: 50 tries for 50 members, against 1,275. Another order keeps the old number of tries plus at most one for each member, and so does a Set with members found by identity between the others. One try is now the static helper subsetCandidateMatches. Tests: each order of four members for a Set and a Map, a failed try in each order, a matcher that throws, the Map path where the value under the same key differs, and the number of tries. --- src/jsc/bindings/bindings.cpp | 61 ++++++++++----- test/js/bun/test/expect.test.js | 131 ++++++++++++++++++++++++++++++++ 2 files changed, 172 insertions(+), 20 deletions(-) diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index 4adbe4c57fc3..e7e7fe453f18 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -2509,6 +2509,20 @@ static bool subsetValueMatches(JSGlobalObject* globalObject, ThrowScope& scope, using SubsetValueMatches = bool (*)(JSGlobalObject*, ThrowScope&, Bun::DeepMatchState&, JSValue, JSValue); +// One try of a Set member, or of the key and then the value of a Map entry, against a candidate. A try that fails takes back what it put in the state. +static bool subsetCandidateMatches(JSGlobalObject* globalObject, ThrowScope& scope, Bun::DeepMatchState& state, SubsetValueMatches valueMatches, JSValue value, JSValue candidate, JSValue entryValue = {}, JSValue candidateValue = {}) +{ + Bun::DeepMatchState::Trial trial(state); + bool matched = valueMatches(globalObject, scope, state, value, candidate); + RETURN_IF_EXCEPTION(scope, false); + if (matched && candidateValue) { + matched = valueMatches(globalObject, scope, state, entryValue, candidateValue); + RETURN_IF_EXCEPTION(scope, false); + } + trial.matched = matched; + return matched; +} + // Jest's iterableEquality: the same size, and each received member is in the expected Set or matches one of its members. static bool subsetSetMatches(JSGlobalObject* globalObject, ThrowScope& scope, Bun::DeepMatchState& state, JSSet* received, JSSet* expected, SubsetValueMatches valueMatches) { @@ -2518,6 +2532,8 @@ static bool subsetSetMatches(JSGlobalObject* globalObject, ThrowScope& scope, Bu VM& vm = globalObject->vm(); auto receivedIter = JSSetIterator::create(vm, globalObject->setIteratorStructure(), received, IterationKind::Keys); + // Sits behind the expected member that matched last. The one after it is tried first: members that are not in `expected` and follow each other in the same order take one try each. + JSSetIterator* candidates = nullptr; JSValue member; while (receivedIter->next(globalObject, member)) { bool has = expected->has(globalObject, member); @@ -2526,14 +2542,18 @@ static bool subsetSetMatches(JSGlobalObject* globalObject, ThrowScope& scope, Bu continue; } - bool found = false; - auto expectedIter = JSSetIterator::create(vm, globalObject->setIteratorStructure(), expected, IterationKind::Keys); - JSValue candidate; - while (!found && expectedIter->next(globalObject, candidate)) { - Bun::DeepMatchState::Trial trial(state); - found = valueMatches(globalObject, scope, state, member, candidate); - RETURN_IF_EXCEPTION(scope, false); - trial.matched = found; + JSValue first; + bool found = candidates && candidates->next(globalObject, first) && subsetCandidateMatches(globalObject, scope, state, valueMatches, member, first); + RETURN_IF_EXCEPTION(scope, false); + if (!found) { + candidates = JSSetIterator::create(vm, globalObject->setIteratorStructure(), expected, IterationKind::Keys); + JSValue candidate; + while (!found && candidates->next(globalObject, candidate)) { + if (candidate == first) + continue; + found = subsetCandidateMatches(globalObject, scope, state, valueMatches, member, candidate); + RETURN_IF_EXCEPTION(scope, false); + } } if (!found) { return false; @@ -2551,6 +2571,8 @@ static bool subsetMapMatches(JSGlobalObject* globalObject, ThrowScope& scope, Bu VM& vm = globalObject->vm(); auto receivedIter = JSMapIterator::create(vm, globalObject->mapIteratorStructure(), received, IterationKind::Entries); + // Sits behind the expected entry that matched last, as in subsetSetMatches. + JSMapIterator* candidates = nullptr; JSValue key, value; while (receivedIter->nextKeyValue(globalObject, key, value)) { bool found = false; @@ -2559,24 +2581,23 @@ static bool subsetMapMatches(JSGlobalObject* globalObject, ThrowScope& scope, Bu if (hasKey) { JSValue expectedValue = expected->get(globalObject, key); RETURN_IF_EXCEPTION(scope, false); - Bun::DeepMatchState::Trial trial(state); - found = valueMatches(globalObject, scope, state, value, expectedValue); + found = subsetCandidateMatches(globalObject, scope, state, valueMatches, value, expectedValue); RETURN_IF_EXCEPTION(scope, false); - trial.matched = found; } + JSValue firstKey, firstValue; + if (!found && candidates && candidates->nextKeyValue(globalObject, firstKey, firstValue)) { + found = subsetCandidateMatches(globalObject, scope, state, valueMatches, key, firstKey, value, firstValue); + RETURN_IF_EXCEPTION(scope, false); + } if (!found) { - auto expectedIter = JSMapIterator::create(vm, globalObject->mapIteratorStructure(), expected, IterationKind::Entries); + candidates = JSMapIterator::create(vm, globalObject->mapIteratorStructure(), expected, IterationKind::Entries); JSValue candidateKey, candidateValue; - while (!found && expectedIter->nextKeyValue(globalObject, candidateKey, candidateValue)) { - Bun::DeepMatchState::Trial trial(state); - found = valueMatches(globalObject, scope, state, key, candidateKey); + while (!found && candidates->nextKeyValue(globalObject, candidateKey, candidateValue)) { + if (candidateKey == firstKey) + continue; + found = subsetCandidateMatches(globalObject, scope, state, valueMatches, key, candidateKey, value, candidateValue); RETURN_IF_EXCEPTION(scope, false); - if (found) { - found = valueMatches(globalObject, scope, state, value, candidateValue); - RETURN_IF_EXCEPTION(scope, false); - } - trial.matched = found; } } if (!found) { diff --git a/test/js/bun/test/expect.test.js b/test/js/bun/test/expect.test.js index bb153798198c..5b730e8f9499 100644 --- a/test/js/bun/test/expect.test.js +++ b/test/js/bun/test/expect.test.js @@ -4353,6 +4353,13 @@ describe("expect()", () => { expect({ s: new Set([{ a: 1 }, { a: 2 }]) }).toMatchObject({ s: new Set([{ a: 2 }, { a: 1 }]) }); expect({ s: new Set([{ a: 1 }]) }).not.toMatchObject({ s: new Set([{ a: 2 }]) }); expect({ s: new Set([[1, 2]]) }).not.toMatchObject({ s: new Set([[1]]) }); + // as in Jest, two received members can match the same expected member + expect({ + s: new Set([ + { a: 1, b: 1 }, + { a: 1, b: 2 }, + ]), + }).toMatchObject({ s: new Set([{ a: 1 }, { c: 3 }]) }); expect({ s: new Set([new Date(5)]) }).toMatchObject({ s: new Set([new Date(5)]) }); expect({ s: new Set([new Date(5)]) }).not.toMatchObject({ s: new Set([new Date(6)]) }); expect({ s: new Set([new Set([1])]) }).toMatchObject({ s: new Set([new Set([1])]) }); @@ -4402,6 +4409,25 @@ describe("expect()", () => { expect({ m: new Map([[{ a: 1 }, 1]]) }).not.toMatchObject({ m: new Map([[{ a: 1 }, 2]]) }); expect({ m: new Map([["a", 1]]) }).toMatchObject({ m: new Map([[expect.any(String), 1]]) }); expect({ m: new Map([[1, 1]]) }).not.toMatchObject({ m: new Map([[expect.any(String), 1]]) }); + // as in Jest, two received entries can match the same expected entry + // prettier-ignore + expect({ m: new Map([[{ k: 1, x: 1 }, 1], [{ k: 1, x: 2 }, 1]]) }).toMatchObject({ + m: new Map([[{ k: 1 }, 1], [{ z: 9 }, 2]]), + }); + // the value under the same key differs, and another expected entry matches + // prettier-ignore + expect({ m: new Map([["z", 0], ["a", 1]]) }).toMatchObject({ + m: new Map([["a", 2], [expect.any(String), expect.any(Number)]]), + }); + const sameKey = { id: 1 }; + // prettier-ignore + expect({ m: new Map([[sameKey, { x: 1 }], [{ id: 1 }, { x: 2 }]]) }).toMatchObject({ + m: new Map([[sameKey, { x: 2 }], [{ id: 1 }, { x: 1 }]]), + }); + // prettier-ignore + expect({ m: new Map([[{ id: 0 }, 0], [sameKey, { x: 1 }]]) }).not.toMatchObject({ + m: new Map([[{ id: 0 }, 0], [sameKey, { x: 2 }]]), + }); // only a Map matches an expected Map expect({ m: {} }).not.toMatchObject({ m: new Map() }); expect({ m: new Set() }).not.toMatchObject({ m: new Map() }); @@ -4445,6 +4471,74 @@ describe("expect()", () => { }); }); + /** @type {(items: T[]) => T[][]} */ + const permutations = items => + items.length <= 1 + ? [items] + : items.flatMap((item, i) => + permutations([...items.slice(0, i), ...items.slice(i + 1)]).map(rest => [item, ...rest]), + ); + + test("the order of the members of a Set or a Map does not change the result", () => { + const ids = [0, 1, 2, 3]; + const members = () => ids.map(id => ({ id, extra: true })); + for (const order of permutations(ids)) { + expect({ s: new Set(members()) }).toMatchObject({ s: new Set(order.map(id => ({ id }))) }); + expect({ m: new Map(members().map(member => [member, 1])) }).toMatchObject({ + m: new Map(order.map(id => [{ id }, 1])), + }); + for (const missing of ids) { + // one expected member matches no received member + const expected = order.map(id => ({ id: id === missing ? -1 : id })); + expect({ s: new Set(members()) }).not.toMatchObject({ s: new Set(expected) }); + expect({ m: new Map(members().map(member => [member, 1])) }).not.toMatchObject({ + m: new Map(expected.map(key => [key, 1])), + }); + } + } + }); + + test("a Set member or a Map entry that fails a candidate leaves nothing behind, in each order", () => { + for (const received of permutations([0, 1, 2])) { + for (const expected of permutations([0, 1, 2])) { + for (const v of [1, 2]) { + // `shared` is in two candidates. { v: 2 } matches neither. + const shared = { v: 1 }; + const members = [{ k: { v: 1 }, z: 1 }, { k: { v }, z: 2 }, { z: 3 }]; + const patterns = [{ k: shared, z: 1 }, { z: 2, k: shared }, { z: 3 }]; + const set = expect({ s: new Set(received.map(i => members[i])) }); + (v === 1 ? set : set.not).toMatchObject({ s: new Set(expected.map(i => patterns[i])) }); + const map = expect({ m: new Map(received.map(i => [members[i], 1])) }); + (v === 1 ? map : map.not).toMatchObject({ m: new Map(expected.map(i => [patterns[i], 1])) }); + } + } + } + }); + + test("a matcher that throws ends the comparison of a Set or a Map with its error", () => { + const only = (/** @type {number} */ n) => ({ + asymmetricMatch: (/** @type {unknown} */ value) => { + if (value !== n) throw new RangeError("not my member"); + return true; + }, + }); + for (const order of [ + [0, 2, 1], + [2, 1, 0], + ]) { + expect(() => + expect({ s: new Set([0, 1, 2].map(id => ({ id }))) }).toMatchObject({ + s: new Set(order.map(id => ({ id: only(id) }))), + }), + ).toThrow(new RangeError("not my member")); + expect(() => + expect({ m: new Map([0, 1, 2].map(id => [{ id }, 1])) }).toMatchObject({ + m: new Map(order.map(id => [{ id: only(id) }, 1])), + }), + ).toThrow(new RangeError("not my member")); + } + }); + test("ends on cycles through a Set or a Map", () => { const throughSet = (/** @type {number} */ n) => { /** @type {any} */ @@ -4607,6 +4701,43 @@ describe("expect()", () => { expect({ a: 1 }).toEqual(expect.objectContaining(new Set([1]))); }); + test("members of a Set or a Map that follow each other in the same order take one try each", () => { + // Each expected member holds a matcher. One call of `tried` is one try of a candidate: [received id, candidate id]. + const tried = jest.fn((/** @type {unknown} */ value, /** @type {number} */ id) => value === id); + const counted = (/** @type {number} */ id) => ({ + id: { asymmetricMatch: (/** @type {unknown} */ value) => tried(value, id) }, + }); + const ids = Array.from({ length: 50 }, (_, id) => id); + + // A scan from the first expected member for each received member makes 1275 tries. + expect({ s: new Set(ids.map(id => ({ id }))) }).toMatchObject({ s: new Set(ids.map(counted)) }); + expect(tried).toHaveBeenCalledTimes(ids.length); + tried.mockClear(); + expect({ m: new Map(ids.map(id => [{ id }, 1])) }).toMatchObject({ + m: new Map(ids.map(id => [counted(id), 1])), + }); + expect(tried).toHaveBeenCalledTimes(ids.length); + tried.mockClear(); + + // Another order: the try behind the last match fails. The scan from the start then leaves that candidate out. + // prettier-ignore + const pairs = [[0, 1], [0, 0], [1, 2], [1, 1], [2, 0], [2, 1], [2, 2]]; + expect({ s: new Set([0, 1, 2].map(id => ({ id }))) }).toMatchObject({ s: new Set([1, 0, 2].map(counted)) }); + expect(tried.mock.calls).toEqual(pairs); + tried.mockClear(); + expect({ m: new Map([0, 1, 2].map(id => [{ id }, 1])) }).toMatchObject({ + m: new Map([1, 0, 2].map(id => [counted(id), 1])), + }); + expect(tried.mock.calls).toEqual(pairs); + tried.mockClear(); + + // A member that the expected Set holds itself does not move the first try, so the scan starts at the front again. + expect({ s: new Set(ids.flatMap(id => ["s" + id, { id }])) }).toMatchObject({ + s: new Set(ids.flatMap(id => ["s" + id, counted(id)])), + }); + expect(tried).toHaveBeenCalledTimes(1275); + }); + test("a pair that a Set or a Map compared first still shows its matcher under a property", () => { const item = { id: 5 }; const shape = { id: expect.any(Number) };