From 3d880fffe9ced975c333709b2c612da7f79f51b0 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 16 Aug 2026 03:48:16 +0000 Subject: [PATCH] Compare Temporal objects by value in toMatchObject and Bun.deepMatch Bun__deepMatch walks the expected object's enumerable properties. Temporal objects keep their value in internal slots and have none, so a Temporal expected value matched any received object in toMatchObject, Bun.deepMatch, expect.objectContaining, and snapshot property matchers. Route a Temporal expected value through the comparator deepEquals already uses, and replace the local Temporal class check with JSC::temporalType. --- src/jsc/bindings/bindings.cpp | 19 ++-- .../bun-object/deep-equals-temporal.test.ts | 87 +++++++++++++++++++ 2 files changed, 93 insertions(+), 13 deletions(-) diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index b024bc42ca6c..9783faaaa190 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -1177,18 +1177,6 @@ bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2, return true; } -static bool isTemporalObject(JSC::JSObject* object) -{ - return object->inherits() - || object->inherits() - || object->inherits() - || object->inherits() - || object->inherits() - || object->inherits() - || object->inherits() - || object->inherits(); -} - // Temporal objects keep their state in internal slots and have no own // properties, so the generic own-property walk would call any two instances // of a class equal. Compare the internal fields instead, the way JSDateType @@ -1237,7 +1225,7 @@ static std::optional temporalObjectsDequal(JSC::JSObject* o1, JSC::JSObjec } // `o1` is not a Temporal object; a Temporal `o2` can then never be equal // (and must not reach the own-property walk). - if (isTemporalObject(o2)) + if (JSC::temporalType(o2) != JSC::TemporalType::None) return false; return std::nullopt; } @@ -1980,6 +1968,11 @@ bool Bun__deepMatch( JSObject* obj = objValue.getObject(); JSObject* subsetObj = subsetValue.getObject(); + // A Temporal subset has no enumerable properties to walk (its value lives + // in internal slots), so the property loop below would accept any object. + if (JSC::temporalType(subsetValue) != JSC::TemporalType::None) + return temporalObjectsDequal(obj, subsetObj).value(); + PropertyNameArrayBuilder subsetProps(vm, PropertyNameMode::StringsAndSymbols, PrivateSymbolMode::Include); subsetObj->getPropertyNames(globalObject, subsetProps, DontEnumPropertiesMode::Exclude); RETURN_IF_EXCEPTION(throwScope, false); 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 41f30262d122..84b72a1f83ce 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,93 @@ describe("expect().toEqual on Temporal values", () => { }); }); +// Subset matching walks the expected object's enumerable properties, and a +// Temporal object has none, so without dedicated handling a Temporal expected +// value accepted 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 a Temporal property 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()]); + }); + + it("toMatchObject compares a Temporal received value by value", () => { + expect(makeA()).toMatchObject(makeA()); + expect(makeA()).not.toMatchObject(makeB()); + }); + + it("expect.objectContaining compares a Temporal pattern by value", () => { + expect(makeA()).toEqual(expect.objectContaining(makeA())); + expect(makeA()).not.toEqual(expect.objectContaining(makeB())); + }); + + it("Bun.deepMatch compares Temporal values 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("a Temporal expected value does not match a different 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("a plain expected object still matches a Temporal received 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 }); + expect(Bun.deepMatch({ year: 2024 }, date)).toBe(true); + expect(Bun.deepMatch({ year: 2025 }, date)).toBe(false); + }); + + it("extra own properties are ignored, matching toEqual", () => { + const withExtra = Object.assign(Temporal.PlainDate.from("2024-06-15"), { extra: 1 }); + expect({ when: withExtra }).toMatchObject({ when: Temporal.PlainDate.from("2024-06-15") }); + expect({ when: Temporal.PlainDate.from("2024-06-15") }).toMatchObject({ when: withExtra }); + expect({ when: withExtra }).not.toMatchObject({ when: Temporal.PlainDate.from("2024-06-16") }); + }); + + it("snapshot property matchers compare a Temporal property by value", () => { + const received = { when: Temporal.PlainDate.from("2024-01-01"), id: 1 }; + // The property matchers are checked before the snapshot itself is compared. + expect(() => + expect(received).toMatchInlineSnapshot({ when: Temporal.PlainDate.from("2099-12-31") }, "not compared"), + ).toThrow("to match properties from received object"); + }); +}); + 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);