From c1a268efce2645732f54c148bae60686ea4d8059 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 13 Sep 2026 00:01:43 +0000 Subject: [PATCH 1/7] bun:test: run an asymmetric matcher at a key or index the other side lacks toEqual and the other loose comparisons returned a mismatch before they reached a matcher placed at a key or index the other side does not have. Jest reads the missing side with an ordinary property read and gives the matcher that value (undefined, or an inherited value). Bun__deepEquals now does the same in the non-strict path: the array loops, the structure fast path, and the property name slow path. toStrictEqual keeps its mismatch. Fixes #42529 --- src/jsc/bindings/bindings.cpp | 92 +++++++++++++++++++++++++++++++-- test/js/bun/test/expect.test.js | 87 +++++++++++++++++++++++++++++++ 2 files changed, 176 insertions(+), 3 deletions(-) diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index 890fea174a97..70c3ef500694 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -626,6 +626,25 @@ AsymmetricMatcherResult matchAsymmetricMatcher(JSGlobalObject* globalObject, JSV return result; } +// The values matchAsymmetricMatcherAndGetFlags handles. Runs no user code, so it is safe +// inside a Structure walk. +static bool isAsymmetricMatcher(JSValue value) +{ + if (value.isEmpty() || !value.isCell()) + return false; + JSCell* cell = value.asCell(); + if (cell->type() != JSC::JSType(JSDOMWrapperType)) + return false; + return cell->inherits() + || cell->inherits() + || cell->inherits() + || cell->inherits() + || cell->inherits() + || cell->inherits() + || cell->inherits() + || cell->inherits(); +} + template static void handlePromise(PromiseType* promise, JSC::JSGlobalObject* globalObject, JSC::EncodedJSValue ctx, Zig::FFIFunction resolverFunction, Zig::FFIFunction rejecterFunction) { @@ -1001,7 +1020,13 @@ bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2, } if constexpr (!isStrict) { - if (((left.isEmpty() || right.isEmpty()) && (left.isUndefined() || right.isUndefined()))) { + // A hole or an index past the end reads as undefined, which is what an + // asymmetric matcher on the other side receives. + if (left.isEmpty()) + left = jsUndefined(); + if (right.isEmpty()) + right = jsUndefined(); + if (left.isUndefined() && right.isUndefined()) { continue; } } @@ -1019,6 +1044,15 @@ bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2, continue; } + if constexpr (!isStrict && enableAsymmetricMatchers) { + if (isAsymmetricMatcher(right)) { + auto eql = Bun__deepEquals(globalObject, jsUndefined(), right, gcBuffer, stack, scope, true); + RETURN_IF_EXCEPTION(scope, false); + if (!eql) return false; + continue; + } + } + return false; } @@ -1061,6 +1095,11 @@ bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2, if (prop1.isUndefined() && prop2.isEmpty()) { continue; } + if constexpr (enableAsymmetricMatchers) { + if (prop2.isEmpty() && isAsymmetricMatcher(prop1)) { + prop2 = jsUndefined(); + } + } } if (!prop2) { @@ -1094,6 +1133,10 @@ bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2, bool sameStructure = o2Structure->id() == o1Structure->id(); // Comparing values runs user getters that can rehash this PropertyTable mid-walk (use-after-free), so collect the pairs first and compare after. MarkedArgumentBuffer pairs; + // Keys where one side holds an asymmetric matcher and the other side has no own + // property. Like Jest, the matcher receives an ordinary read of the missing side + // (undefined, or an inherited value), so these are looked up after the walks. + Vector matcherOnlyKeys; if (sameStructure) { o1Structure->forEachProperty(vm, [&](const PropertyTableEntry& entry) -> bool { if (entry.attributes() & PropertyAttribute::DontEnum || PropertyName(entry.key()).isPrivateName()) { @@ -1146,6 +1189,12 @@ bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2, if (left.isUndefined() && right.isEmpty()) { return true; } + if constexpr (enableAsymmetricMatchers) { + if (right.isEmpty() && isAsymmetricMatcher(left)) { + matcherOnlyKeys.append(Identifier::fromUid(vm, entry.key())); + return true; + } + } } if (!right) { @@ -1173,6 +1222,12 @@ bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2, // Membership check only; every left property is in `pairs` and compared below. if (o1->getDirectOffset(vm, JSC::PropertyName(entry.key())) == invalidOffset) { + if constexpr (!isStrict && enableAsymmetricMatchers) { + if (isAsymmetricMatcher(o2->getDirect(entry.offset()))) { + matcherOnlyKeys.append(Identifier::fromUid(vm, entry.key())); + return true; + } + } result = false; return false; } @@ -1208,6 +1263,18 @@ bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2, } } + for (const Identifier& key : matcherOnlyKeys) { + JSValue left = o1->get(globalObject, key); + RETURN_IF_EXCEPTION(scope, false); + JSValue right = o2->get(globalObject, key); + RETURN_IF_EXCEPTION(scope, false); + auto eql = Bun__deepEquals(globalObject, left, right, gcBuffer, stack, scope, true); + RETURN_IF_EXCEPTION(scope, false); + if (!eql) { + return false; + } + } + return true; } } @@ -1270,6 +1337,11 @@ bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2, if (prop1.isUndefined() && prop2.isEmpty()) { continue; } + if constexpr (enableAsymmetricMatchers) { + if (prop2.isEmpty() && isAsymmetricMatcher(prop1)) { + prop2 = jsUndefined(); + } + } } if (!prop2) { @@ -1282,6 +1354,7 @@ bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2, } // for the remaining properties in the other object, make sure they are undefined + // or an asymmetric matcher that accepts what the first object reads at that key for (; i < propertyArrayLength2; i++) { Identifier i2 = a2[i]; PropertyName propertyName2 = PropertyName(i2); @@ -1289,9 +1362,22 @@ bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2, JSValue prop2 = o2->getIfPropertyExists(globalObject, propertyName2); RETURN_IF_EXCEPTION(scope, false); - if (!prop2.isUndefined()) { - return false; + if (prop2.isUndefined()) { + continue; + } + + if constexpr (!isStrict && enableAsymmetricMatchers) { + if (isAsymmetricMatcher(prop2)) { + JSValue prop1 = o1->get(globalObject, propertyName2); + RETURN_IF_EXCEPTION(scope, false); + auto eql = Bun__deepEquals(globalObject, prop1, prop2, gcBuffer, stack, scope, true); + RETURN_IF_EXCEPTION(scope, false); + if (!eql) return false; + continue; + } } + + return false; } return true; diff --git a/test/js/bun/test/expect.test.js b/test/js/bun/test/expect.test.js index fbeb3d6c49cb..25e41dff5ef7 100644 --- a/test/js/bun/test/expect.test.js +++ b/test/js/bun/test/expect.test.js @@ -880,6 +880,93 @@ describe("expect()", () => { expect({ a: 123n }).toEqual({ a: expect.any(BigInt) }); expect({ a: 123n }).not.toEqual({ a: expect.any(g) }); }); + + // https://github.com/oven-sh/bun/issues/42529 + describe("a matcher at a key or index the other side lacks", () => { + expect.extend({ + optionalFn(received) { + return { pass: received === undefined || typeof received === "function", message: () => "" }; + }, + }); + const optionalFn = () => ANY(expect).optionalFn(); + + it("receives undefined when an object lacks the key", () => { + expect({ a: 1 }).toEqual({ a: 1, cb: optionalFn() }); + expect({ a: 1, cb: () => {} }).toEqual({ a: 1, cb: optionalFn() }); + expect({ a: 1, cb: 2 }).not.toEqual({ a: 1, cb: optionalFn() }); + expect({ a: 1 }).not.toEqual({ a: 1, cb: expect.any(Function) }); + expect({ a: 1 }).not.toEqual({ a: 1, cb: expect.anything() }); + }); + + it("receives undefined when the matcher is on the received side", () => { + expect({ a: 1, cb: optionalFn() }).toEqual({ a: 1 }); + expect({ a: 1, cb: expect.any(Function) }).not.toEqual({ a: 1 }); + }); + + it("receives undefined past the end of a shorter array", () => { + expect([1]).toEqual([1, optionalFn()]); + expect([1, optionalFn()]).toEqual([1]); + expect([1]).not.toEqual([1, expect.any(Function)]); + expect([1, expect.any(Function)]).not.toEqual([1]); + expect([optionalFn(), optionalFn()]).toEqual([]); + }); + + it("receives undefined at a hole", () => { + expect([, 1]).toEqual([optionalFn(), 1]); + expect([optionalFn(), 1]).toEqual([, 1]); + expect([, 1]).not.toEqual([expect.any(Function), 1]); + }); + + it("uses the verdict of a built-in matcher on undefined", () => { + expect({}).toEqual({ a: expect.not.stringContaining("x") }); + expect({}).toEqual({ a: expect.not.stringMatching(/x/) }); + expect({}).toEqual({ a: expect.not.arrayContaining([1]) }); + expect({}).not.toEqual({ a: expect.stringContaining("x") }); + expect({}).not.toEqual({ a: expect.any(String) }); + }); + + it("reads an inherited value for the missing key", () => { + class P { + get name() { + return "abc"; + } + } + expect(new P()).toEqual({ name: expect.any(String) }); + expect(new P()).not.toEqual({ name: expect.any(Number) }); + expect({ name: expect.any(String) }).toEqual(new P()); + }); + + it("reads an inherited value when the object has a getter of its own", () => { + // an own accessor keeps the object off the structure fast path + const received = { + get a() { + return 1; + }, + }; + expect(received).toEqual({ a: 1, cb: optionalFn() }); + expect({ a: 1, cb: optionalFn() }).toEqual(received); + expect(received).not.toEqual({ a: 1, cb: expect.any(Function) }); + expect(Object.create(received)).toEqual({ a: expect.any(Number), cb: optionalFn() }); + }); + + it("applies inside nested comparisons", () => { + expect([{ a: 1 }]).toContainEqual({ a: 1, cb: optionalFn() }); + expect(new Set([{ a: 1 }])).toEqual(new Set([{ a: 1, cb: optionalFn() }])); + expect(new Map([["k", { a: 1 }]])).toEqual(new Map([["k", { a: 1, cb: optionalFn() }]])); + expect({ list: [{ a: 1 }] }).toEqual({ list: expect.arrayContaining([{ a: 1, cb: optionalFn() }]) }); + expect({ list: [{ a: 1 }] }).not.toEqual({ + list: expect.arrayContaining([{ a: 1, cb: expect.any(Function) }]), + }); + }); + + it("stays a mismatch for toStrictEqual", () => { + expect({ a: 1 }).not.toStrictEqual({ a: 1, cb: optionalFn() }); + expect({ a: 1, cb: optionalFn() }).not.toStrictEqual({ a: 1 }); + expect([1]).not.toStrictEqual([1, optionalFn()]); + expect([1, optionalFn()]).not.toStrictEqual([1]); + expect([, 1]).not.toStrictEqual([optionalFn(), 1]); + }); + }); }); test("toThrow asymmetric matchers", () => { From 6ecd792a6c5c02846acd614ad3b2e718317df0dd Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 13 Sep 2026 01:02:49 +0000 Subject: [PATCH 2/7] bun:test: apply the missing-key matcher rule to Error properties and add crash-shape tests --- src/jsc/bindings/bindings.cpp | 21 +++++++++++++++++++-- test/js/bun/test/expect.test.js | 20 ++++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index 70c3ef500694..894d0eecdff1 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -1699,6 +1699,9 @@ static std::optional specialObjectsDequalSlow(const DeepEqualsMode& mode, if (prop1.isUndefined() && prop2.isEmpty()) { continue; } + if (mode.enableAsymmetricMatchers && prop2.isEmpty() && isAsymmetricMatcher(prop1)) { + prop2 = jsUndefined(); + } } if (!prop2) { @@ -1713,6 +1716,7 @@ static std::optional specialObjectsDequalSlow(const DeepEqualsMode& mode, } // for the remaining properties in the other object, make sure they are undefined + // or an asymmetric matcher that accepts what the first object reads at that key for (; i < propertyArrayLength2; i++) { Identifier i2 = a2[i]; if (i2 == vm.propertyNames->stack) continue; @@ -1721,9 +1725,22 @@ static std::optional specialObjectsDequalSlow(const DeepEqualsMode& mode, JSValue prop2 = right->getIfPropertyExists(globalObject, propertyName2); RETURN_IF_EXCEPTION(scope, {}); - if (!prop2.isUndefined()) { - return false; + if (prop2.isUndefined()) { + continue; } + + if (!mode.isStrict && mode.enableAsymmetricMatchers && isAsymmetricMatcher(prop2)) { + JSValue prop1 = left->get(globalObject, propertyName2); + RETURN_IF_EXCEPTION(scope, {}); + bool propertiesEqual = mode.deepEquals(globalObject, prop1, prop2, gcBuffer, stack, scope, true); + RETURN_IF_EXCEPTION(scope, {}); + if (!propertiesEqual) { + return false; + } + continue; + } + + return false; } return true; diff --git a/test/js/bun/test/expect.test.js b/test/js/bun/test/expect.test.js index 25e41dff5ef7..0f9f97984638 100644 --- a/test/js/bun/test/expect.test.js +++ b/test/js/bun/test/expect.test.js @@ -915,6 +915,26 @@ describe("expect()", () => { expect([, 1]).toEqual([optionalFn(), 1]); expect([optionalFn(), 1]).toEqual([, 1]); expect([, 1]).not.toEqual([expect.any(Function), 1]); + expect({ x: [, 1] }).not.toEqual({ x: [expect.any(Date), 1] }); + expect(Array(2)).not.toEqual([expect.any(Date), expect.any(Number)]); + expect(Array(2)).toEqual([optionalFn(), optionalFn()]); + }); + + it("does not crash when the side with the matcher is longer", () => { + expect([expect.any(Number)]).not.toEqual([]); + expect([]).not.toBeOneOf([[expect.any(Number)]]); + expect([[expect.any(Number)]]).not.toContainEqual([]); + expect(new Map([[expect.any(Number), 2]])).not.toContainEqual([]); + const withAccessor = [0]; + Object.defineProperty(withAccessor, 0, { get: () => "s", enumerable: true }); + expect(withAccessor).not.toEqual([expect.any(Number)]); + }); + + it("applies to the enumerable properties of an Error", () => { + const received = Object.assign(new Error("boom"), { code: "E1" }); + expect(received).toEqual(Object.assign(new Error("boom"), { code: "E1", cb: optionalFn() })); + expect(Object.assign(new Error("boom"), { code: "E1", cb: optionalFn() })).toEqual(received); + expect(received).not.toEqual(Object.assign(new Error("boom"), { code: "E1", cb: expect.any(Function) })); }); it("uses the verdict of a built-in matcher on undefined", () => { From ae98ead31347c997321b206aca09ca59f43d9ecd Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 13 Sep 2026 01:26:11 +0000 Subject: [PATCH 3/7] ci: retrigger From 394dc92971cf5a1f03cf4aa9a80a680d4b5cdd79 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 13 Sep 2026 01:28:09 +0000 Subject: [PATCH 4/7] bun:test: shorten comments in Bun__deepEquals --- src/jsc/bindings/bindings.cpp | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index 894d0eecdff1..86f7ef017460 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -626,8 +626,7 @@ AsymmetricMatcherResult matchAsymmetricMatcher(JSGlobalObject* globalObject, JSV return result; } -// The values matchAsymmetricMatcherAndGetFlags handles. Runs no user code, so it is safe -// inside a Structure walk. +// The values matchAsymmetricMatcherAndGetFlags handles. Runs no user code. static bool isAsymmetricMatcher(JSValue value) { if (value.isEmpty() || !value.isCell()) @@ -1020,8 +1019,7 @@ bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2, } if constexpr (!isStrict) { - // A hole or an index past the end reads as undefined, which is what an - // asymmetric matcher on the other side receives. + // a hole or an index past the end reads as undefined if (left.isEmpty()) left = jsUndefined(); if (right.isEmpty()) @@ -1133,9 +1131,8 @@ bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2, bool sameStructure = o2Structure->id() == o1Structure->id(); // Comparing values runs user getters that can rehash this PropertyTable mid-walk (use-after-free), so collect the pairs first and compare after. MarkedArgumentBuffer pairs; - // Keys where one side holds an asymmetric matcher and the other side has no own - // property. Like Jest, the matcher receives an ordinary read of the missing side - // (undefined, or an inherited value), so these are looked up after the walks. + // Keys with an asymmetric matcher on one side and no own property on the other. + // Read with get() after the walks, as Jest does (undefined or an inherited value). Vector matcherOnlyKeys; if (sameStructure) { o1Structure->forEachProperty(vm, [&](const PropertyTableEntry& entry) -> bool { From e287ff93c5e4059e657f933277ddbcb7f491692a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 13 Sep 2026 01:29:25 +0000 Subject: [PATCH 5/7] bun:test: shorten the matcherOnlyKeys comment --- src/jsc/bindings/bindings.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index 86f7ef017460..0d2dcb7e3b78 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -1131,8 +1131,7 @@ bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2, bool sameStructure = o2Structure->id() == o1Structure->id(); // Comparing values runs user getters that can rehash this PropertyTable mid-walk (use-after-free), so collect the pairs first and compare after. MarkedArgumentBuffer pairs; - // Keys with an asymmetric matcher on one side and no own property on the other. - // Read with get() after the walks, as Jest does (undefined or an inherited value). + // matcher on one side, no own property on the other: read with get() after the walks Vector matcherOnlyKeys; if (sameStructure) { o1Structure->forEachProperty(vm, [&](const PropertyTableEntry& entry) -> bool { From 39b2f7720509fa9bf3c54cf104095fc30350d80d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 1 Oct 2026 00:27:40 +0000 Subject: [PATCH 6/7] bun:test: walk the second object's extra names by name, keep the fast-path count exact The property name slow path and the Error walk compared the second object's extra names by position. A matcher at a shared name that sits after an extra name could then accept the extra name unseen. Both walks now skip every name the first object enumerates and check the rest. The structure fast path counted a deferred matcher-only key toward the membership budget of the reverse walk. Count only the pairs it compares. The crash shapes also run in a child process. --- src/jsc/bindings/bindings.cpp | 91 ++++++++++++++++++++++----------- test/js/bun/test/expect.test.js | 55 ++++++++++++++++++++ 2 files changed, 117 insertions(+), 29 deletions(-) diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index 4d399f87cdbf..4aeef0ff2153 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -1164,7 +1164,6 @@ bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2, if (entry.attributes() & PropertyAttribute::DontEnum || PropertyName(entry.key()).isPrivateName()) { return true; } - count++; JSValue left = o1->getDirect(entry.offset()); JSValue right; @@ -1199,6 +1198,8 @@ bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2, return false; } + // `remain` below counts only the properties in `pairs`. + count++; pairs.appendWithCrashOnOverflow(left); pairs.appendWithCrashOnOverflow(right); return true; @@ -1301,8 +1302,7 @@ bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2, } // take a property name from one, try to get it from both - size_t i; - for (i = 0; i < propertyArrayLength1; i++) { + for (size_t i = 0; i < propertyArrayLength1; i++) { Identifier i1 = a1[i]; PropertyName propertyName1 = PropertyName(i1); @@ -1350,31 +1350,55 @@ bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2, if (!eql) return false; } - // for the remaining properties in the other object, make sure they are undefined - // or an asymmetric matcher that accepts what the first object reads at that key - for (; i < propertyArrayLength2; i++) { - Identifier i2 = a2[i]; - PropertyName propertyName2 = PropertyName(i2); + // Every name of the second object that the first one does not enumerate must be + // undefined, or an asymmetric matcher that accepts what the first object reads there. + // In strict mode the name counts are equal, so the first loop covers every name. + if constexpr (!isStrict) { + for (size_t j = 0; j < propertyArrayLength2; j++) { + Identifier i2 = a2[j]; + PropertyName propertyName2 = PropertyName(i2); - JSValue prop2 = o2->getIfPropertyExists(globalObject, propertyName2); - RETURN_IF_EXCEPTION(scope, false); - - if (prop2.isUndefined()) { - continue; - } - - if constexpr (!isStrict && enableAsymmetricMatchers) { - if (isAsymmetricMatcher(prop2)) { - JSValue prop1 = o1->get(globalObject, propertyName2); + // same name at the same position: compared in the first loop + if (j < propertyArrayLength1 && a1[j] == i2) { + continue; + } + // otherwise look it up the way a1 was built (own names for node, the chain for Bun) + bool has1; + if constexpr (checkPrototypes) { + PropertySlot slot1(o1, PropertySlot::InternalMethodType::GetOwnProperty); + has1 = o1->methodTable()->getOwnPropertySlot(o1, globalObject, propertyName2, slot1); RETURN_IF_EXCEPTION(scope, false); - auto eql = Bun__deepEquals(globalObject, prop1, prop2, gcBuffer, stack, scope, true); + has1 = has1 && !(slot1.attributes() & PropertyAttribute::DontEnum); + } else { + PropertySlot slot1(o1, PropertySlot::InternalMethodType::HasProperty); + has1 = o1->getPropertySlot(globalObject, propertyName2, slot1); RETURN_IF_EXCEPTION(scope, false); - if (!eql) return false; + has1 = has1 && !(slot1.attributes() & PropertyAttribute::DontEnum); + } + if (has1) { continue; } - } - return false; + JSValue prop2 = o2->getIfPropertyExists(globalObject, propertyName2); + RETURN_IF_EXCEPTION(scope, false); + + if (prop2.isUndefined()) { + continue; + } + + if constexpr (enableAsymmetricMatchers) { + if (isAsymmetricMatcher(prop2)) { + JSValue prop1 = o1->get(globalObject, propertyName2); + RETURN_IF_EXCEPTION(scope, false); + auto eql = Bun__deepEquals(globalObject, prop1, prop2, gcBuffer, stack, scope, true); + RETURN_IF_EXCEPTION(scope, false); + if (!eql) return false; + continue; + } + } + + return false; + } } return true; @@ -1679,8 +1703,7 @@ static std::optional specialObjectsDequalSlow(const DeepEqualsMode& mode, } // take a property name from one, try to get it from both - size_t i; - for (i = 0; i < propertyArrayLength1; i++) { + for (size_t i = 0; i < propertyArrayLength1; i++) { Identifier i1 = a1[i]; if (i1 == vm.propertyNames->stack) continue; PropertyName propertyName1 = PropertyName(i1); @@ -1712,13 +1735,23 @@ static std::optional specialObjectsDequalSlow(const DeepEqualsMode& mode, } } - // for the remaining properties in the other object, make sure they are undefined - // or an asymmetric matcher that accepts what the first object reads at that key - for (; i < propertyArrayLength2; i++) { - Identifier i2 = a2[i]; + // Every name of the right Error that the left one does not enumerate must be + // undefined, or an asymmetric matcher that accepts what the left one reads there. + for (size_t j = 0; !mode.isStrict && j < propertyArrayLength2; j++) { + Identifier i2 = a2[j]; if (i2 == vm.propertyNames->stack) continue; PropertyName propertyName2 = PropertyName(i2); + if (j < propertyArrayLength1 && a1[j] == i2) { + continue; + } + PropertySlot slot1(left, PropertySlot::InternalMethodType::HasProperty); + bool has1 = left->getPropertySlot(globalObject, propertyName2, slot1); + RETURN_IF_EXCEPTION(scope, {}); + if (has1 && !(slot1.attributes() & PropertyAttribute::DontEnum)) { + continue; + } + JSValue prop2 = right->getIfPropertyExists(globalObject, propertyName2); RETURN_IF_EXCEPTION(scope, {}); @@ -1726,7 +1759,7 @@ static std::optional specialObjectsDequalSlow(const DeepEqualsMode& mode, continue; } - if (!mode.isStrict && mode.enableAsymmetricMatchers && isAsymmetricMatcher(prop2)) { + if (mode.enableAsymmetricMatchers && isAsymmetricMatcher(prop2)) { JSValue prop1 = left->get(globalObject, propertyName2); RETURN_IF_EXCEPTION(scope, {}); bool propertiesEqual = mode.deepEquals(globalObject, prop1, prop2, gcBuffer, stack, scope, true); diff --git a/test/js/bun/test/expect.test.js b/test/js/bun/test/expect.test.js index 04f5f2ff54d4..c7d245f568e4 100644 --- a/test/js/bun/test/expect.test.js +++ b/test/js/bun/test/expect.test.js @@ -930,6 +930,35 @@ describe("expect()", () => { expect(withAccessor).not.toEqual([expect.any(Number)]); }); + if (isBun) { + // the same shapes in a child process, so a crash fails one test instead of the whole file + it("does not crash the process when the side with the matcher is longer", async () => { + const { bunEnv, bunExe } = require("harness"); + const src = ` + const { expect } = require("bun:test"); + try { expect([1, expect.any(Number)]).toEqual([1]); } catch {} + try { expect([expect.any(Number)]).toEqual([]); } catch {} + try { expect([, 1]).toEqual([expect.any(Date), 1]); } catch {} + try { expect([]).toBeOneOf([[expect.any(Number)]]); } catch {} + try { expect([[expect.any(Number)]]).toContainEqual([]); } catch {} + try { expect(new Map([[expect.any(Number), 2]])).toContainEqual([]); } catch {} + console.log("ok"); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", src], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode, signalCode: proc.signalCode }).toMatchObject({ + stdout: "ok\n", + exitCode: 0, + signalCode: null, + }); + }); + } + it("applies to the enumerable properties of an Error", () => { const received = Object.assign(new Error("boom"), { code: "E1" }); expect(received).toEqual(Object.assign(new Error("boom"), { code: "E1", cb: optionalFn() })); @@ -969,6 +998,32 @@ describe("expect()", () => { expect(Object.create(received)).toEqual({ a: expect.any(Number), cb: optionalFn() }); }); + it("does not depend on the key order of the other side", () => { + const received = { + get a() { + return 1; + }, + }; + // the matcher key enumerates before the shared key + expect(received).toEqual({ cb: optionalFn(), a: expect.any(Number) }); + expect(received).toEqual({ cb: optionalFn(), a: 1 }); + // an extra key that enumerates before a matcher at the shared key stays a mismatch + expect(received).not.toEqual({ b: 2, a: expect.any(Number) }); + expect(received).not.toEqual({ b: 2, a: 1 }); + + const error = Object.assign(new Error("boom"), { code: "E1" }); + expect(error).toEqual(Object.assign(new Error("boom"), { cb: optionalFn(), code: "E1" })); + expect(error).not.toEqual(Object.assign(new Error("boom"), { extra: 2, code: expect.any(String) })); + }); + + it("does not let a matcher-only key hide an extra key on the other side", () => { + const received = { a: 1, cb: optionalFn() }; + Object.defineProperty(received, "k", { value: "x", enumerable: false }); + expect(received).not.toEqual({ a: 1, k: "y" }); + expect({ a: 1, k: "y" }).not.toEqual(received); + expect(received).toEqual({ a: 1 }); + }); + it("applies inside nested comparisons", () => { expect([{ a: 1 }]).toContainEqual({ a: 1, cb: optionalFn() }); expect(new Set([{ a: 1 }])).toEqual(new Set([{ a: 1, cb: optionalFn() }])); From b95a1daf6db32a1e91a778d87a54e7830ba3ce4a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 1 Oct 2026 01:05:25 +0000 Subject: [PATCH 7/7] bun:test: shorten two comments in the name walks --- src/jsc/bindings/bindings.cpp | 45 ++++++++++++++++++++--------------- 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index 4aeef0ff2153..f065e32785a2 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -1350,9 +1350,7 @@ bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2, if (!eql) return false; } - // Every name of the second object that the first one does not enumerate must be - // undefined, or an asymmetric matcher that accepts what the first object reads there. - // In strict mode the name counts are equal, so the first loop covers every name. + // names only the second object enumerates must be undefined or a matcher that accepts o1's read if constexpr (!isStrict) { for (size_t j = 0; j < propertyArrayLength2; j++) { Identifier i2 = a2[j]; @@ -1362,20 +1360,12 @@ bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2, if (j < propertyArrayLength1 && a1[j] == i2) { continue; } - // otherwise look it up the way a1 was built (own names for node, the chain for Bun) - bool has1; - if constexpr (checkPrototypes) { - PropertySlot slot1(o1, PropertySlot::InternalMethodType::GetOwnProperty); - has1 = o1->methodTable()->getOwnPropertySlot(o1, globalObject, propertyName2, slot1); - RETURN_IF_EXCEPTION(scope, false); - has1 = has1 && !(slot1.attributes() & PropertyAttribute::DontEnum); - } else { - PropertySlot slot1(o1, PropertySlot::InternalMethodType::HasProperty); - has1 = o1->getPropertySlot(globalObject, propertyName2, slot1); - RETURN_IF_EXCEPTION(scope, false); - has1 = has1 && !(slot1.attributes() & PropertyAttribute::DontEnum); - } - if (has1) { + // the chain lookup matches how a1 was built (the node entry point is strict only) + static_assert(!checkPrototypes); + PropertySlot slot1(o1, PropertySlot::InternalMethodType::HasProperty); + bool has1 = o1->getPropertySlot(globalObject, propertyName2, slot1); + RETURN_IF_EXCEPTION(scope, false); + if (has1 && !(slot1.attributes() & PropertyAttribute::DontEnum)) { continue; } @@ -1387,6 +1377,15 @@ bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2, } if constexpr (enableAsymmetricMatchers) { + // Jest counts an own non-enumerable key as present, so it stays a mismatch + if (has1) { + PropertySlot ownSlot(o1, PropertySlot::InternalMethodType::GetOwnProperty); + bool own1 = o1->methodTable()->getOwnPropertySlot(o1, globalObject, propertyName2, ownSlot); + RETURN_IF_EXCEPTION(scope, false); + if (own1) { + return false; + } + } if (isAsymmetricMatcher(prop2)) { JSValue prop1 = o1->get(globalObject, propertyName2); RETURN_IF_EXCEPTION(scope, false); @@ -1735,8 +1734,7 @@ static std::optional specialObjectsDequalSlow(const DeepEqualsMode& mode, } } - // Every name of the right Error that the left one does not enumerate must be - // undefined, or an asymmetric matcher that accepts what the left one reads there. + // names only the right Error enumerates must be undefined or a matcher that accepts left's read for (size_t j = 0; !mode.isStrict && j < propertyArrayLength2; j++) { Identifier i2 = a2[j]; if (i2 == vm.propertyNames->stack) continue; @@ -1760,6 +1758,15 @@ static std::optional specialObjectsDequalSlow(const DeepEqualsMode& mode, } if (mode.enableAsymmetricMatchers && isAsymmetricMatcher(prop2)) { + // Jest counts an own non-enumerable key as present, so it stays a mismatch + if (has1) { + PropertySlot ownSlot(left, PropertySlot::InternalMethodType::GetOwnProperty); + bool own1 = left->methodTable()->getOwnPropertySlot(left, globalObject, propertyName2, ownSlot); + RETURN_IF_EXCEPTION(scope, {}); + if (own1) { + return false; + } + } JSValue prop1 = left->get(globalObject, propertyName2); RETURN_IF_EXCEPTION(scope, {}); bool propertiesEqual = mode.deepEquals(globalObject, prop1, prop2, gcBuffer, stack, scope, true);