Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/bun-types/test.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1029,11 +1029,16 @@ declare module "bun:test" {
/**
* Asserts that a value is deeply equal to what is expected.
*
* Two plain objects are equal when their properties are equal, whatever
* their classes are. Any other kind of object, such as a `Promise`, a
* `Map` or a `URL`, is only equal to an object of the same kind.
*
* @example
* expect(100 + 23).toBe(123);
* expect("d" + "og").toBe("dog");
* expect([456]).toEqual([456]);
* expect({ value: 1 }).toEqual({ value: 1 });
* expect(Promise.resolve()).not.toEqual({});
*
* @param expected the expected value
*/
Expand Down
55 changes: 52 additions & 3 deletions src/jsc/bindings/bindings.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -767,6 +767,40 @@ static bool nonIndexOwnPropertiesEqual(JSC::JSGlobalObject* globalObject, Marked
return true;
}

// A plain object and an array keep all of their state in own properties, and specialObjectsDequal has no case for them.
static ALWAYS_INLINE bool isPlainObjectOrArray(JSC::JSType type)
{
return type == FinalObjectType || type == ArrayType || type == DerivedArrayType;
}

// A module namespace has no state outside of its exports, and a Proxy is the kind of object that its target is.
static ALWAYS_INLINE bool ownPropertiesAreWholeState(JSCell* cell)
{
while (cell->type() == ProxyObjectType)
cell = uncheckedDowncast<ProxyObject>(cell)->target();
return isPlainObjectOrArray(cell->type()) || cell->type() == ModuleNamespaceObjectType;
}

// One copy of JSC's always-inline objectPrototypeToString for both operands of haveSameToStringTag.
static NEVER_INLINE JSString* objectToString(JSC::JSGlobalObject* globalObject, JSCell* cell)
{
return objectPrototypeToString(globalObject, cell);
}

// The property walk cannot tell a Promise or a Response from {}, so such objects must first have equal tags, like in jest's equals().
static NEVER_INLINE bool haveSameToStringTag(JSC::JSGlobalObject* globalObject, ThrowScope& scope, JSCell* c1, JSCell* c2)
{
JSString* tag1 = objectToString(globalObject, c1);
RETURN_IF_EXCEPTION(scope, false);
JSString* tag2 = objectToString(globalObject, c2);
RETURN_IF_EXCEPTION(scope, false);
if (tag1 == tag2)
return true;
bool sameTag = tag1->equal(globalObject, tag2);
RETURN_IF_EXCEPTION(scope, false);
return sameTag;
}

// node's wellKnownConstructors set (lib/internal/util/comparisons.js), matched for any realm.
static bool isWellKnownConstructor(JSValue value)
{
Expand Down Expand Up @@ -959,9 +993,23 @@ bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2,
std::optional<bool> isSpecialEqual = specialObjectsDequal<isStrict, enableAsymmetricMatchers, checkPrototypes, skipPrototypeIdentity>(globalObject, gcBuffer, stack, scope, c1, c2);
RETURN_IF_EXCEPTION(scope, false);
if (isSpecialEqual.has_value()) return WTF::move(*isSpecialEqual);
isSpecialEqual = specialObjectsDequal<isStrict, enableAsymmetricMatchers, checkPrototypes, skipPrototypeIdentity>(globalObject, gcBuffer, stack, scope, c2, c1);
RETURN_IF_EXCEPTION(scope, false);
if (isSpecialEqual.has_value()) return WTF::move(*isSpecialEqual);
const bool isPlain2 = isPlainObjectOrArray(c2->type());
if (!isPlain2) {
isSpecialEqual = specialObjectsDequal<isStrict, enableAsymmetricMatchers, checkPrototypes, skipPrototypeIdentity>(globalObject, gcBuffer, stack, scope, c2, c1);
RETURN_IF_EXCEPTION(scope, false);
if (isSpecialEqual.has_value()) return WTF::move(*isSpecialEqual);
}
if constexpr (!checkPrototypes) {
if (!isPlain2 || !isPlainObjectOrArray(c1->type())) {
// Objects of one Structure have one class and one prototype chain.
const bool sameStructure = c1->structureID() == c2->structureID() && c1->type() != ProxyObjectType;
if (!sameStructure && !(ownPropertiesAreWholeState(c1) && ownPropertiesAreWholeState(c2))) {
bool sameTag = haveSameToStringTag(globalObject, scope, c1, c2);
Comment thread
robobun marked this conversation as resolved.
RETURN_IF_EXCEPTION(scope, false);
if (!sameTag) return false;
}
}
}
Comment thread
robobun marked this conversation as resolved.
JSObject* o1 = v1.getObject();
JSObject* o2 = v2.getObject();

Expand Down Expand Up @@ -1891,6 +1939,7 @@ static std::optional<bool> specialObjectsDequalSlow(const DeepEqualsMode& mode,

goto compareAsNormalValue;

// A pair of different classes that gets here is decided by the toString tag comparison in Bun__deepEquals.
compareAsNormalValue:
break;
}
Expand Down
229 changes: 228 additions & 1 deletion test/js/bun/bun-object/deep-equals.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { bunEnv, bunExe, isASAN, isWindows } from "harness";
import vm from "node:vm";
import fs from "node:fs";
import vm, * as vmNamespace from "node:vm";

describe.each([true, false])("Bun.deepEquals(a, b, strict: %p)", strict => {
const deepEquals = (a: unknown, b: unknown) => Bun.deepEquals(a, b, strict);
Expand Down Expand Up @@ -40,6 +41,232 @@ describe.each([true, false])("Bun.deepEquals(a, b, strict: %p)", strict => {
expect(deepEquals(b, a)).toBe(false);
});

// https://github.com/oven-sh/bun/issues/42539
// Two objects are compared by their own enumerable properties. Those are the whole state of a
// plain object, an array and a module namespace. Any other object (a Promise, a WeakMap, a
// Response) must first have the same Object.prototype.toString tag as the other operand.
describe("objects that are not plain objects or arrays", () => {
class Tagged {
get [Symbol.toStringTag]() {
return "Tagged";
}
}
function argumentsOf(..._values: unknown[]) {
return arguments;
}

it.each([
["a Promise and {}", () => Promise.resolve(), () => ({})],
["a WeakSet and {}", () => new WeakSet(), () => ({})],
["a WeakMap and {}", () => new WeakMap(), () => ({})],
["a WeakRef and {}", () => new WeakRef({}), () => ({})],
["a DataView and {}", () => new DataView(new ArrayBuffer(8)), () => ({})],
["Math and {}", () => Math, () => ({})],
["a Response and {}", () => new Response(), () => ({})],
["a Blob and {}", () => new Blob([]), () => ({})],
["a URL and {}", () => new URL("http://a"), () => ({})],
["an AbortController and {}", () => new AbortController(), () => ({})],
["a generator and {}", () => (function* () {})(), () => ({})],
["a native constructor and {}", () => Map, () => ({})],
["a mock function and {}", () => jest.fn(), () => ({})],
["an arguments object and {}", () => argumentsOf(), () => ({})],
["an arguments object and an object with the same keys", () => argumentsOf(1, 2), () => ({ 0: 1, 1: 2 })],
["a Promise and a null-prototype object", () => Promise.resolve(), () => Object.create(null)],
["a Promise and a class instance", () => Promise.resolve(), () => new Tagged()],
["a Promise and a WeakSet", () => Promise.resolve(), () => new WeakSet()],
["a Request and a Response", () => new Request("http://a"), () => new Response()],
["a Proxy of a Promise and {}", () => new Proxy(Promise.resolve(), {}), () => ({})],
["a Proxy of a Promise and a Proxy of {}", () => new Proxy(Promise.resolve(), {}), () => new Proxy({}, {})],
["a module namespace and a Promise", () => vmNamespace, () => Promise.resolve()],
["{ a: Promise } and { a: {} }", () => ({ a: Promise.resolve() }), () => ({ a: {} })],
["[WeakMap] and [{}]", () => [new WeakMap()], () => [{}]],
["Map { 1 => Promise } and Map { 1 => {} }", () => new Map([[1, Promise.resolve()]]), () => new Map([[1, {}]])],
["Set { Promise } and Set { {} }", () => new Set([Promise.resolve()]), () => new Set([{}])],
] as [string, () => unknown, () => unknown][])("%s are not equal", (_, a, b) => {
expect(deepEquals(a(), b())).toBe(false);
expect(deepEquals(b(), a())).toBe(false);
});

it("two of them with the same tag are compared by their properties", () => {
expect(deepEquals(Promise.resolve(), Promise.resolve())).toBe(true);
expect(deepEquals(new WeakMap(), new WeakMap())).toBe(true);
expect(deepEquals(Object.assign(new WeakMap(), { a: 1 }), new WeakMap())).toBe(false);
expect(deepEquals(new URL("http://a"), new URL("http://a"))).toBe(true);
expect(deepEquals(new URL("http://a"), new URL("http://b"))).toBe(false);
expect(deepEquals(new Headers({ a: "1" }), new Headers({ a: "1" }))).toBe(true);
expect(deepEquals(new Headers({ a: "1" }), new Headers({ a: "2" }))).toBe(false);
expect(deepEquals(argumentsOf(1, 2), argumentsOf(1, 2))).toBe(true);
expect(deepEquals(argumentsOf(1, 2), argumentsOf(1, 3))).toBe(false);
expect(Bun.deepEquals(new Proxy(Promise.resolve(), {}), Promise.resolve())).toBe(true);
class MyPromise extends Promise<void> {}
expect(deepEquals(MyPromise.resolve(), Promise.resolve())).toBe(!strict);
expect(Bun.deepEquals(process.env, { ...process.env })).toBe(true);
});

it("two objects of one class are compared without a read of Symbol.toStringTag", () => {
let tagReads = 0;
class CountingPromise extends Promise<void> {
get [Symbol.toStringTag]() {
tagReads++;
return "Promise";
}
}
expect(deepEquals(CountingPromise.resolve(), CountingPromise.resolve())).toBe(true);
expect(tagReads).toBe(0);
expect(deepEquals(CountingPromise.resolve(), Promise.resolve())).toBe(!strict);
expect(tagReads).toBe(1);
});

it("an exception from a Symbol.toStringTag getter propagates", () => {
class Throws {
get [Symbol.toStringTag]() {
throw new Error("from the tag getter");
}
}
expect(() => deepEquals(new Throws(), Promise.resolve())).toThrow("from the tag getter");
expect(() => deepEquals(Promise.resolve(), new Throws())).toThrow("from the tag getter");
});
});

describe("plain objects, arrays and module namespaces", () => {
let tagReads = 0;
class Tagged {
constructor(public a = 1) {}
get [Symbol.toStringTag]() {
tagReads++;
return "Tagged";
}
}

// bun labels some plain data itself, for example `req.params` in Bun.serve is [object RequestParams].
// Tests compare those to object literals.
it("are compared by their properties whatever their tags are", () => {
tagReads = 0;
expect(Bun.deepEquals(new Tagged(), { a: 1 })).toBe(true);
expect(Bun.deepEquals({ a: 1 }, new Tagged())).toBe(true);
expect(deepEquals(new Tagged(), new Tagged())).toBe(true);
expect(deepEquals(new Tagged(1), new Tagged(2))).toBe(false);
expect(Bun.deepEquals(Object.create({ [Symbol.toStringTag]: "Inherited" }), {})).toBe(true);
expect(Bun.deepEquals(Object.defineProperty({ x: 1 }, Symbol.toStringTag, { value: "Own" }), { x: 1 })).toBe(
true,
);
expect(Bun.deepEquals(Object.defineProperty([1], Symbol.toStringTag, { value: "Own" }), [1])).toBe(true);
expect(Bun.deepEquals(Object.assign(Object.create(null), { a: 1 }), { a: 1 })).toBe(true);
expect(tagReads).toBe(0);

const formData = new FormData();
formData.append("a", "b");
expect(Bun.deepEquals(formData.toJSON(), { a: "b" })).toBe(true);
expect(Bun.deepEquals(new URLSearchParams("a=b").toJSON(), { a: "b" })).toBe(true);
});

it("req.params in a Bun.serve route equals an object literal with the same entries", async () => {
let params: unknown;
using server = Bun.serve({
port: 0,
routes: {
"/orgs/:orgId/repos/:repoId": req => {
params = req.params;
return new Response("ok");
},
},
});
const res = await fetch(new URL("/orgs/oven-sh/repos/bun", server.url).href);
expect(await res.text()).toBe("ok");
expect(Bun.deepEquals(params, { orgId: "oven-sh", repoId: "bun" })).toBe(true);
expect(params).toEqual({ orgId: "oven-sh", repoId: "bun" });
expect(deepEquals(params, { orgId: "oven-sh", repoId: "other" })).toBe(false);
});

it("fs.Stats, fs.StatFs and fs.Dirent equal a copy of their own properties", () => {
const stats = fs.statSync(import.meta.dir);
expect(Bun.deepEquals(stats, { ...stats })).toBe(true);
expect(stats).toEqual({ ...stats });
expect(deepEquals(stats, { ...stats, size: -1 })).toBe(false);

const bigintStats = fs.statSync(import.meta.dir, { bigint: true });
expect(Bun.deepEquals(bigintStats, { ...bigintStats })).toBe(true);

const statFs = fs.statfsSync(import.meta.dir);
expect(Bun.deepEquals(statFs, { ...statFs })).toBe(true);

const [dirent] = fs.readdirSync(import.meta.dir, { withFileTypes: true });
expect(Bun.deepEquals(dirent, { ...dirent })).toBe(true);
expect(dirent).toEqual({ ...dirent });
});

// Under Jest's CommonJS transform `import * as ns` is a plain object, so this comparison is common.
it("a module namespace is compared to a plain object by its exports", () => {
expect(Object.prototype.toString.call(vmNamespace)).toBe("[object Module]");
expect(Bun.deepEquals(vmNamespace, { ...vmNamespace })).toBe(true);
expect(Bun.deepEquals({ ...vmNamespace }, vmNamespace)).toBe(true);
expect(deepEquals(vmNamespace, vmNamespace)).toBe(true);
expect(deepEquals(vmNamespace, {})).toBe(false);
});

// A Proxy has no state of its own. It is the kind of object that its target is.
it("a Proxy of one of them is not asked for Symbol.toStringTag", () => {
const asked: PropertyKey[] = [];
const handler: ProxyHandler<object> = {
get(target, key, receiver) {
asked.push(key);
if (typeof key === "symbol") throw new Error("unknown key " + String(key));
return Reflect.get(target, key, receiver);
},
};
expect(Bun.deepEquals(new Proxy({ a: 1 }, handler), { a: 1 })).toBe(true);
expect(Bun.deepEquals({ a: 1 }, new Proxy({ a: 1 }, handler))).toBe(true);
expect(Bun.deepEquals(new Proxy({ a: 1 }, handler), new Proxy({ a: 1 }, handler))).toBe(true);
expect(Bun.deepEquals(new Proxy(new Proxy({ a: 1 }, handler), handler), { a: 1 })).toBe(true);
expect(deepEquals(new Proxy({ a: 1 }, handler), { a: 2 })).toBe(false);
expect(Bun.deepEquals(new Proxy([1], {}), [1])).toBe(true);
expect(asked).not.toContain(Symbol.toStringTag);

// The other operand is a Promise, so both tags are read.
expect(() => deepEquals(new Proxy({}, handler), Promise.resolve())).toThrow(
"unknown key Symbol(Symbol.toStringTag)",
);
expect(asked).toContain(Symbol.toStringTag);
});

it("a revoked Proxy still throws", () => {
const { proxy, revoke } = Proxy.revocable({}, {});
revoke();
expect(() => deepEquals(proxy, {})).toThrow(TypeError);
expect(() => deepEquals({}, proxy)).toThrow(TypeError);
expect(() => deepEquals(proxy, Promise.resolve())).toThrow(TypeError);
});
});

// Jest never reads the tag of a typed array: it compares two of them with iterableEquality.
it("typed arrays, Maps and Sets are compared without a read of Symbol.toStringTag", () => {
let tagReads = 0;
class CountingBytes extends Uint8Array {
get [Symbol.toStringTag]() {
tagReads++;
return "Uint8Array";
}
}
class CountingMap<K, V> extends Map<K, V> {
get [Symbol.toStringTag]() {
tagReads++;
return "Map";
}
}
class CountingSet<T> extends Set<T> {
get [Symbol.toStringTag]() {
tagReads++;
return "Set";
}
}
expect(deepEquals(new CountingBytes([1, 2]), new CountingBytes([1, 2]))).toBe(true);
expect(deepEquals(new CountingBytes([1, 2]), new CountingBytes([1, 3]))).toBe(false);
expect(deepEquals(new CountingMap([[1, 2]]), new CountingMap([[1, 2]]))).toBe(true);
expect(deepEquals(new CountingSet([1, 2]), new CountingSet([1, 2]))).toBe(true);
expect(deepEquals({ bytes: new CountingBytes([1]) }, { bytes: new CountingBytes([1]) })).toBe(true);
expect(tagReads).toBe(0);
});

it("fake maps are not equal", () => {
function FakeMap() {}
FakeMap.prototype = Map.prototype;
Expand Down
Loading
Loading