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/bun.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2937,6 +2937,11 @@ declare module "bun" {
* Returns true if all properties in the subset exist in the
* other and have equal values.
*
* Two arrays match as a whole, not as a subset. They need the same length,
* and each enumerable property of one array has to match the same property
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
* of the other. A property that one array lacks, such as a hole, is
* `undefined` there.
*
* This also powers expect().toMatchObject in `bun:test`
*/
function deepMatch(subset: unknown, a: unknown): boolean;
Expand Down
69 changes: 61 additions & 8 deletions src/jsc/bindings/bindings.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,7 @@ template bool Bun__deepMatch<true>(JSValue objValue, JSValue subsetValue, JSGlob
template bool Bun__deepMatch<false>(JSValue objValue, JSValue subsetValue, JSGlobalObject* globalObject, ThrowScope& throwScope, Bun::DeepMatchState& state, bool isMatchingObjectContaining);

extern "C" bool Expect_readFlagsAndProcessPromise(JSC::EncodedJSValue instanceValue, JSC::JSGlobalObject* globalObject, ExpectFlags* flags, JSC::EncodedJSValue* value, AsymmetricMatcherConstructorType* constructorType);
extern "C" bool Expect_isAsymmetricMatcher(JSC::EncodedJSValue value);

extern "C" int8_t AsymmetricMatcherConstructorType__fromJS(JSC::JSGlobalObject* globalObject, JSC::EncodedJSValue encodedValue)
{
Expand Down Expand Up @@ -768,6 +769,42 @@ static JSValue asymmetricMatchMethod(JSGlobalObject* globalObject, JSValue value
return {};
}

// The key step of Jest's equals() for two arrays: a key that only one has can stand for undefined in the other, as in `[, 1]` and `[undefined, 1]`.
static NEVER_INLINE bool reconcileArrayKeys(JSGlobalObject* globalObject, ThrowScope& scope, bool withMatchers, JSObject* obj, PropertyNameArrayBuilder& objProps, JSObject* subsetObj, PropertyNameArrayBuilder& subsetProps)
{
size_t unshared[2] = { 0, 0 };
for (bool ofSubset : { true, false }) {
JSObject* holder = ofSubset ? subsetObj : obj;
JSObject* other = ofSubset ? obj : subsetObj;
PropertyNameArrayBuilder& from = ofSubset ? subsetProps : objProps;
PropertyNameArrayBuilder& into = ofSubset ? objProps : subsetProps;
for (size_t i = 0, count = from.size(); i < count; i++) {
// add() skips a name that the list has. The walk compares the names that subsetProps gets here.
size_t held = into.size();
into.add(from[i]);
if (into.size() == held)
continue;
// A private name is not reconciled: both arrays need it. Code compiled ahead of time lists no private field, and counts them to the same end.
if (from[i].isPrivateName())
return false;
JSValue value = holder->get(globalObject, from[i]);
RETURN_IF_EXCEPTION(scope, false);
bool canBeAbsent = value.isUndefined() || (withMatchers && (Expect_isAsymmetricMatcher(JSValue::encode(value)) || asymmetricMatchMethod(globalObject, value)));
bool otherOwnsIt = false;
if (canBeAbsent || !ofSubset) {
otherOwnsIt = other->hasOwnProperty(globalObject, from[i]);
RETURN_IF_EXCEPTION(scope, false);
// Jest's walk needs a key of the received array to be undefined or a matcher there, or a property of the expected array.
if (!canBeAbsent && !otherOwnsIt)
return false;
}
unshared[ofSubset] += !canBeAbsent || otherOwnsIt;
}
}
// Jest compares the counts: a key of one array can be a property of the other that isn't enumerable, and the walk compares those.
return unshared[0] == unshared[1];
}

// Jest and Vitest treat any object with an asymmetricMatch() method as a matcher. Returns nullopt if neither or both are.
static NEVER_INLINE std::optional<bool> matchUserAsymmetricMatcher(JSGlobalObject* globalObject, ThrowScope& scope, JSValue a, JSValue b)
{
Expand Down Expand Up @@ -2611,7 +2648,7 @@ bool Bun__deepMatch(
// - two "simple" arrays
// similar to what is done in deepEquals (canPerformFastPropertyEnumerationForIterationBun)

// arrays should match exactly
// Two arrays match as a whole, not as a subset: the keys of both are compared, and the lengths as in Vitest (Jest's toMatchObject() skips the lengths).
bool objIsArray = isArray(globalObject, objValue);
RETURN_IF_EXCEPTION(throwScope, false);
bool subsetIsArray = objIsArray && isArray(globalObject, subsetValue);
Expand All @@ -2623,26 +2660,42 @@ bool Bun__deepMatch(
PropertyNameArrayBuilder objProps(vm, PropertyNameMode::StringsAndSymbols, PrivateSymbolMode::Include);
obj->getPropertyNames(globalObject, objProps, DontEnumPropertiesMode::Exclude);
RETURN_IF_EXCEPTION(throwScope, false);
size_t objCount = objProps.size();
size_t subsetCount = subsetProps.size();
size_t objPrivateFields = 0;
size_t subsetPrivateFields = 0;
#if ENABLE(AOT)
if (vm.aotState().someClassHasPrivateNames) [[unlikely]] {
objCount += JSC::AOT::PropertyWalk::countPrivateFieldsOfCompiledClasses(globalObject, obj);
objPrivateFields = JSC::AOT::PropertyWalk::countPrivateFieldsOfCompiledClasses(globalObject, obj);
RETURN_IF_EXCEPTION(throwScope, false);
subsetCount += JSC::AOT::PropertyWalk::countPrivateFieldsOfCompiledClasses(globalObject, subsetObj);
subsetPrivateFields = JSC::AOT::PropertyWalk::countPrivateFieldsOfCompiledClasses(globalObject, subsetObj);
RETURN_IF_EXCEPTION(throwScope, false);
}
#endif
if (objCount != subsetCount) {
if (isMatchingObjectContaining) {
// For expect.objectContaining(array) the two arrays need as many keys, and the walk needs each key of the sample.
if (objProps.size() + objPrivateFields != subsetProps.size() + subsetPrivateFields) {
return false;
}
} else if (objPrivateFields != subsetPrivateFields) {
// Neither list of keys has the private fields of compiled classes: two arrays that match as a whole have as many.
return false;
} else if (objProps.data()->propertyNameVector() != subsetProps.data()->propertyNameVector()) [[unlikely]] {
Comment thread
robobun marked this conversation as resolved.
Resolved
bool canMatch = reconcileArrayKeys(globalObject, throwScope, enableAsymmetricMatchers, obj, objProps, subsetObj, subsetProps);
RETURN_IF_EXCEPTION(throwScope, false);
if (!canMatch) {
return false;
}
}
}

for (const auto& property : subsetProps) {
JSValue prop = obj->getIfPropertyExists(globalObject, property);
RETURN_IF_EXCEPTION(throwScope, false);
if (prop.isEmpty()) {
return false;
if (prop.isEmpty()) [[unlikely]] {
// Between two arrays, a key that the received one lacks is undefined there.
if (!subsetIsArray || isMatchingObjectContaining) {
return false;
}
prop = jsUndefined();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
Comment thread
robobun marked this conversation as resolved.
Resolved
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}

JSValue subsetProp = subsetObj->get(globalObject, property);
Expand Down
8 changes: 7 additions & 1 deletion src/runtime/test_runner/expect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -620,6 +620,12 @@ impl Expect {
false
}

/// Called by C++ to ask about a value without running a matcher
#[unsafe(no_mangle)]
pub(crate) extern "C" fn Expect_isAsymmetricMatcher(value: JSValue) -> bool {
Self::is_asymmetric_matcher(value)
}

/// Called by C++ when matching with asymmetric matchers
#[unsafe(no_mangle)]
pub(crate) extern "C" fn Expect_readFlagsAndProcessPromise(
Expand Down Expand Up @@ -3428,7 +3434,7 @@ unsafe extern "C" {
}

// Exports: handled by #[unsafe(no_mangle)] on:
// ExpectMatcherUtils_createSigleton, Expect_readFlagsAndProcessPromise, ExpectCustomAsymmetricMatcher__execute
// ExpectMatcherUtils_createSigleton, Expect_isAsymmetricMatcher, Expect_readFlagsAndProcessPromise, ExpectCustomAsymmetricMatcher__execute

#[cfg(test)]
mod tests {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// Guards: two lists match as a whole, and a key that one of them lacks stands for undefined there. A private field is no such key: both lists need it. Plain Bun took a private field that holds undefined for a key that the other list may lack and passed the pair, and an executable, which counts private fields, failed it.
import { expect, test } from "bun:test";
class Bare extends Array {}
class Unset extends Array { #kept; }
class Other extends Array { #other; }
class Two extends Array { #first; #second; }
class Held extends Array { #kept; static of(kept, ...items) { const list = super.of(...items); list.#kept = kept; return list; } }
const says = check => { try { return check() === false ? "false" : "passes"; } catch (error) { return error.constructor.name; } };
const holed = list => { delete list[0]; return list; };
test("one list alone has the field", () => {
console.log(says(() => expect(Unset.of(1)).toMatchObject([1])), says(() => expect([1]).toMatchObject(Unset.of(1))), says(() => expect(Unset.of(1)).toMatchObject(Bare.of(1))), says(() => expect(Unset.of(1)).toMatchObject(Other.of(1))), says(() => expect(Two.of(1)).toMatchObject(Unset.of(1))), says(() => expect(Unset.of(1)).toMatchObject(Two.of(1))));
console.log(says(() => expect(holed(Unset.of(0, 1))).toMatchObject([undefined, 1])), says(() => expect([undefined, 1]).toMatchObject(holed(Unset.of(0, 1)))), says(() => expect(holed(Unset.of(0, 1))).toMatchObject(Other.of(undefined, 1))));
console.log(says(() => Bun.deepMatch([1], Unset.of(1))), says(() => Bun.deepMatch(Unset.of(1), [1])), says(() => Bun.deepMatch(Unset.of(1), Other.of(1))), says(() => Bun.deepMatch([undefined, 1], holed(Unset.of(0, 1)))));
});
test("both lists have the field", () => {
console.log(says(() => expect(Held.of(1, 1)).toMatchObject(Held.of(1, 1))), says(() => expect(Held.of(2, 1)).toMatchObject(Held.of(1, 1))), says(() => expect(Held.of(1, 1)).toMatchObject(Held.of(expect.any(Number), 1))));
console.log(says(() => expect(holed(Held.of(1, 0, 1))).toMatchObject(Held.of(1, undefined, 1))), says(() => expect(Held.of(1, undefined, 1)).toMatchObject(holed(Held.of(1, 0, 1)))), says(() => expect(holed(Held.of(2, 0, 1))).toMatchObject(Held.of(1, undefined, 1))));
console.log(says(() => Bun.deepMatch(Held.of(1, undefined, 1), holed(Held.of(1, 0, 1)))), says(() => Bun.deepMatch(Held.of(2, undefined, 1), holed(Held.of(1, 0, 1)))));
});
test("objectContaining", () => {
console.log(says(() => expect(Unset.of(1)).toEqual(expect.objectContaining([1]))), says(() => expect(Unset.of(1)).toEqual(expect.objectContaining(Unset.of(1)))), says(() => expect(Unset.of(1)).toEqual(expect.objectContaining(Other.of(1)))), says(() => expect(holed(Unset.of(0, 1))).toEqual(expect.objectContaining(Unset.of(undefined, 1)))));
});
7 changes: 7 additions & 0 deletions test/js/bun/bun-object/deep-match.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ describe("Bun.deepMatch", () => {
[{}, "foo", 1],
[{}, "foo", 1],
],
// NOTE: a hole is the same as `undefined`, in either array
[[, 1], [undefined, 1]],
[[undefined, 1], [, 1]],
[{ a: [[, 1]] }, { a: [[undefined, 1]], b: 2 }],

// Maps
[new Map(), new Map()],
Expand Down Expand Up @@ -99,6 +103,9 @@ describe("Bun.deepMatch", () => {
[[null], [undefined]],
[[], [undefined]],
[["a", "b", "c"], ["a", "b", "d"]],
[[, 1], [null, 1]], // NOTE: a hole is the same as nothing but `undefined`
[[0, 1], [, 1]],
[[, 1], [{}, 1]],

// Maps
// FIXME: I assume this is incorrect but I need confirmation on expected behavior.
Expand Down
Loading
Loading