Skip to content
129 changes: 90 additions & 39 deletions src/jsc/bindings/bindings.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,61 @@ bool readFlagsAndProcessPromise(JSValue& instanceValue, ExpectFlags& flags, JSGl
return false;
}

// PASS when each element of the sample is deep-equal to an element of the other array.
static ALWAYS_INLINE AsymmetricMatcherResult arrayContainsEach(JSGlobalObject* globalObject, ThrowScope& throwScope, JSObject* expectedArray, unsigned expectedLength, JSObject* otherArray, unsigned otherLength)
{
// A empty array is all array's subset
if (expectedLength == 0) {
return AsymmetricMatcherResult::PASS;
}

// O(m*n) but works for now
for (unsigned m = 0; m < expectedLength; m++) {
JSValue expectedValue = expectedArray->getIndex(globalObject, m);
RETURN_IF_EXCEPTION(throwScope, AsymmetricMatcherResult::FAIL);
bool found = false;

for (unsigned n = 0; n < otherLength; n++) {
JSValue otherValue = otherArray->getIndex(globalObject, n);
RETURN_IF_EXCEPTION(throwScope, AsymmetricMatcherResult::FAIL);
Vector<std::pair<JSValue, JSValue>, 16> stack;
MarkedArgumentBuffer gcBuffer;
bool foundNow = Bun__deepEquals<false, true, false>(globalObject, expectedValue, otherValue, gcBuffer, stack, throwScope, true);
RETURN_IF_EXCEPTION(throwScope, AsymmetricMatcherResult::FAIL);
if (foundNow) {
found = true;
break;
}
}

if (!found) {
return AsymmetricMatcherResult::FAIL;
}
}

return AsymmetricMatcherResult::PASS;
}

// For expect.arrayContaining(): a Proxy of an array is an array, as the sample and as the value.
static NEVER_INLINE AsymmetricMatcherResult proxiedArrayContains(JSGlobalObject* globalObject, ThrowScope& scope, JSValue expectedValue, JSValue otherValue)
{
bool expectedIsArray = isArray(globalObject, expectedValue);
RETURN_IF_EXCEPTION(scope, AsymmetricMatcherResult::FAIL);
bool otherIsArray = expectedIsArray && isArray(globalObject, otherValue);
RETURN_IF_EXCEPTION(scope, AsymmetricMatcherResult::FAIL);
if (!otherIsArray)
return AsymmetricMatcherResult::FAIL;

JSObject* expectedArray = asObject(expectedValue);
JSObject* otherArray = asObject(otherValue);
constexpr uint64_t maxLength = std::numeric_limits<unsigned>::max();
uint64_t expectedLength = JSC::toLength(globalObject, expectedArray);
RETURN_IF_EXCEPTION(scope, AsymmetricMatcherResult::FAIL);
uint64_t otherLength = JSC::toLength(globalObject, otherArray);
RETURN_IF_EXCEPTION(scope, AsymmetricMatcherResult::FAIL);
return arrayContainsEach(globalObject, scope, expectedArray, static_cast<unsigned>(std::min(expectedLength, maxLength)), otherArray, static_cast<unsigned>(std::min(otherLength, maxLength)));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
Comment thread
robobun marked this conversation as resolved.
Resolved
}

AsymmetricMatcherResult matchAsymmetricMatcherAndGetFlags(JSGlobalObject* globalObject, JSValue matcherProp, JSValue otherProp, ThrowScope& throwScope, ExpectFlags& flags)
{
using namespace Bun::Bindgen::Generated;
Expand Down Expand Up @@ -512,45 +567,17 @@ AsymmetricMatcherResult matchAsymmetricMatcherAndGetFlags(JSGlobalObject* global
JSValue expectedArrayValue = expectArrayContaining->m_arrayValue.get();

// Note: isArray() accepts Proxy->Array, but jsDynamicCast returns null for Proxy.
JSArray* expectedArray = dynamicDowncast<JSArray>(expectedArrayValue);
JSArray* otherArray = dynamicDowncast<JSArray>(otherProp);
if (expectedArray && otherArray) {
unsigned expectedLength = expectedArray->length();
unsigned otherLength = otherArray->length();

// A empty array is all array's subset
if (expectedLength == 0) {
return AsymmetricMatcherResult::PASS;
if (JSArray* expectedArray = dynamicDowncast<JSArray>(expectedArrayValue)) [[likely]] {
if (JSArray* otherArray = dynamicDowncast<JSArray>(otherProp)) {
return arrayContainsEach(globalObject, throwScope, expectedArray, expectedArray->length(), otherArray, otherArray->length());
}

// O(m*n) but works for now
for (unsigned m = 0; m < expectedLength; m++) {
JSValue expectedValue = expectedArray->getIndex(globalObject, m);
RETURN_IF_EXCEPTION(throwScope, AsymmetricMatcherResult::FAIL);
bool found = false;

for (unsigned n = 0; n < otherLength; n++) {
JSValue otherValue = otherArray->getIndex(globalObject, n);
RETURN_IF_EXCEPTION(throwScope, AsymmetricMatcherResult::FAIL);
Vector<std::pair<JSValue, JSValue>, 16> stack;
MarkedArgumentBuffer gcBuffer;
bool foundNow = Bun__deepEquals<false, true, false>(globalObject, expectedValue, otherValue, gcBuffer, stack, throwScope, true);
RETURN_IF_EXCEPTION(throwScope, AsymmetricMatcherResult::FAIL);
if (foundNow) {
found = true;
break;
}
}

if (!found) {
return AsymmetricMatcherResult::FAIL;
}
// Only a Proxy can be an array without being a JSArray.
if (!dynamicDowncast<ProxyObject>(otherProp)) [[likely]] {
return AsymmetricMatcherResult::FAIL;
}

return AsymmetricMatcherResult::PASS;
}

return AsymmetricMatcherResult::FAIL;
return proxiedArrayContains(globalObject, throwScope, expectedArrayValue, otherProp);
} else if (auto* expectObjectContaining = dynamicDowncast<JSExpectObjectContaining>(matcherPropCell)) {
if (!readFlagsAndProcessPromise(matcherProp, flags, globalObject, otherProp, constructorType))
return AsymmetricMatcherResult::FAIL;
Expand Down Expand Up @@ -2448,6 +2475,17 @@ std::optional<bool> specialObjectsDequal(JSC::JSGlobalObject* globalObject, Mark
return std::nullopt;
}

// For the array gate of Bun__deepMatch. A Proxy of an array has no indexed storage: its length is the `length` that it serves.
static NEVER_INLINE bool arrayLengthsMatchSlow(JSGlobalObject* globalObject, ThrowScope& scope, JSObject* obj, JSObject* subsetObj)
{
VM& vm = globalObject->vm();
JSValue objLength = obj->type() == ProxyObjectType ? obj->get(globalObject, vm.propertyNames->length) : jsNumber(obj->getArrayLength());
RETURN_IF_EXCEPTION(scope, false);
JSValue subsetLength = subsetObj->type() == ProxyObjectType ? subsetObj->get(globalObject, vm.propertyNames->length) : jsNumber(subsetObj->getArrayLength());
RETURN_IF_EXCEPTION(scope, false);
return JSValue::strictEqual(globalObject, objLength, subsetLength);
}

// 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<true, false, false, true>(JSC::JSGlobalObject*, JSValue, JSValue, MarkedArgumentBuffer&, Vector<std::pair<JSC::JSValue, JSC::JSValue>, 16>&, ThrowScope&, bool);
Expand Down Expand Up @@ -2496,7 +2534,7 @@ bool Bun__deepMatch(
}
}

PropertyNameArrayBuilder subsetProps(vm, PropertyNameMode::StringsAndSymbols, PrivateSymbolMode::Include);
PropertyNameArrayBuilder subsetProps(vm, PropertyNameMode::StringsAndSymbols, PrivateSymbolMode::Exclude);
subsetObj->getPropertyNames(globalObject, subsetProps, DontEnumPropertiesMode::Exclude);
RETURN_IF_EXCEPTION(throwScope, false);

Expand All @@ -2508,13 +2546,26 @@ 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) {
return false;
}
PropertyNameArrayBuilder objProps(vm, PropertyNameMode::StringsAndSymbols, PrivateSymbolMode::Include);
if (hasIndexedProperties(obj->indexingType()) && hasIndexedProperties(subsetObj->indexingType())) [[likely]] {
ASSERT(obj->type() != ProxyObjectType && subsetObj->type() != ProxyObjectType);
if (obj->getArrayLength() != subsetObj->getArrayLength()) {
return false;
}
} else {
bool sameLength = arrayLengthsMatchSlow(globalObject, throwScope, obj, subsetObj);
RETURN_IF_EXCEPTION(throwScope, false);
if (!sameLength) {
return false;
}
}
Comment thread
robobun marked this conversation as resolved.
Resolved
PropertyNameArrayBuilder objProps(vm, PropertyNameMode::StringsAndSymbols, PrivateSymbolMode::Exclude);
obj->getPropertyNames(globalObject, objProps, DontEnumPropertiesMode::Exclude);
RETURN_IF_EXCEPTION(throwScope, false);
if (objProps.size() != subsetProps.size()) {
Expand Down
6 changes: 5 additions & 1 deletion src/runtime/test_runner/expect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2487,7 +2487,11 @@ pub(crate) struct ExpectArrayContaining {

impl ExpectArrayContaining {
fn create(global_this: &JSGlobalObject, flags: Flags, array_value: JSValue) -> JsResult<JSValue> {
if !array_value.js_type().is_array() {
let js_type = array_value.js_type();
let is_array = js_type.is_array()
|| (js_type == bun_jsc::JSType::ProxyObject
&& super::pretty_format::is_array(global_this, array_value)?);
if !is_array {
return Err(crate::throw_pretty_static!(
global_this,
"<d>expect.<r>arrayContaining<d>(<r>array<d>)<r>\n\nExpected a array\n",
Expand Down
2 changes: 1 addition & 1 deletion src/runtime/test_runner/pretty_format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3129,7 +3129,7 @@ pub(crate) fn to_number(global: &JSGlobalObject, value: JSValue) -> JsResult<f64
}

/// `Array.isArray(value)`, which sees through a Proxy.
fn is_array(global: &JSGlobalObject, value: JSValue) -> JsResult<bool> {
pub(crate) fn is_array(global: &JSGlobalObject, value: JSValue) -> JsResult<bool> {
unsafe extern "C" {
safe fn SnapshotFormat__isArray(global: &JSGlobalObject, value: JSValue) -> bool;
}
Expand Down
34 changes: 34 additions & 0 deletions test/js/bun/bun-object/deep-match.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,40 @@ describe("Bun.deepMatch", () => {
] as [any[], any[], boolean][])("Bun.deepMatch(%p, %p) === %p", (a, b, expected) => {
expect(Bun.deepMatch(a, b)).toBe(expected);
});

// https://github.com/oven-sh/bun/issues/9103
it("reads a Proxy of an array through the Proxy, as the subset and as the value", () => {
const P = <T extends object>(target: T) => new Proxy(target, {});
expect(Bun.deepMatch([{ a: 1 }], P([{ a: 1, b: 2 }]))).toBe(true);
expect(Bun.deepMatch(P([{ a: 1 }]), [{ a: 1, b: 2 }])).toBe(true);
expect(Bun.deepMatch(P([{ a: 1 }]), P([{ a: 1, b: 2 }]))).toBe(true);
expect(Bun.deepMatch({ list: [1, 2] }, { list: P([1, 2]), more: true })).toBe(true);
expect(Bun.deepMatch({ 0: 1 }, P([1]))).toBe(true);
expect(Bun.deepMatch([{ a: 1, b: 2 }], P([{ a: 1 }]))).toBe(false);
expect(Bun.deepMatch([1, 2], P([1, 2, 3]))).toBe(false);
expect(Bun.deepMatch(P([1, 2, 3]), [1, 2])).toBe(false);
expect(Bun.deepMatch(P([1, 2]), [1, 3])).toBe(false);
expect(Bun.deepMatch(P([1]), { 0: 1, length: 1 })).toBe(false);

// the `get` trap decides what the length is: this Proxy serves [1, <hole>]
const served = new Proxy([1], {
get: (target, key, receiver) => (key === "length" ? 2 : Reflect.get(target, key, receiver)),
});
const holed = () => Object.assign([1], { length: 2 });
expect(Bun.deepMatch(holed(), served)).toBe(true);
expect(Bun.deepMatch(served, holed())).toBe(true);
expect(Bun.deepMatch([1], served)).toBe(Bun.deepMatch([1], holed()));
expect(Bun.deepMatch(served, [1])).toBe(Bun.deepMatch(holed(), [1]));

const throws = new Proxy([1], {
get(target, key, receiver) {
if (key === "length") throw new RangeError("no length");
return Reflect.get(target, key, receiver);
},
});
expect(() => Bun.deepMatch(throws, [1])).toThrow(RangeError);
expect(() => Bun.deepMatch([1], throws)).toThrow(RangeError);
});
});

it("does not work on functions", () => {
Expand Down
Loading
Loading