Skip to content

bun:test: apply an asymmetric matcher passed as the whole toMatchObject or snapshot property matchers argument - #37912

Open
robobun wants to merge 1 commit into
mainfrom
farm/dc49bcc3/deep-match-top-level-matcher
Open

robobun wants to merge 1 commit into
mainfrom
farm/dc49bcc3/deep-match-top-level-matcher

Conversation

@robobun

@robobun robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • toMatchObject ignores an asymmetric matcher passed as the whole expected value, so these assertions pass in Bun 1.4.0 and on main (Jest and Vitest 4.1.9 fail both):
    expect({ a: 1, b: 2 }).toMatchObject(expect.objectContaining({ a: 2 }));
    expect({ a: 1 }).toMatchObject(expect.any(Array));
  • The property matchers argument of toMatchSnapshot / toMatchInlineSnapshot goes through the same code, so toMatchInlineSnapshot(expect.any(Array), ...) on an object also skips straight to the snapshot comparison, and toMatchSnapshot(expect.objectContaining({ a: 2 })) records a snapshot instead of failing.
  • The mirror case is wrong the other way: expect(expect.any(Object)).toMatchObject({ a: 1 }) always fails, while Jest applies the received-side matcher.
  • Cause: Bun__deepMatch (src/jsc/bindings/bindings.cpp:1960) only checks for matchers inside its per-property loop. When the matcher is the subset argument itself it is walked like a plain object; the Expect* matcher classes keep their state in internal fields and have no enumerable properties, so the walk is empty and the match is vacuously true. Bun__deepEquals (toEqual) already checks both values for a matcher before comparing structurally (bindings.cpp:748), which is why toEqual(expect.objectContaining(...)) works.

Fix

  • Before the property walk, when asymmetric matchers are enabled, run matchAsymmetricMatcher on the expected value and, failing that, on the received value, and return its PASS/FAIL verdict; NOT_MATCHER falls through to the existing walk. This is the same check Bun__deepEquals does at its top and the per-property loop does for each property, so nesting behaves as before.
  • Matches Jest: toMatchObject and the snapshot property matchers both call equals(received, expected, [iterableEquality, subsetEquality]), and equals applies an asymmetric matcher on either side before anything else, at the top level included.
  • The check is skipped while matching the sample of expect.objectContaining() (isMatchingObjectContaining). Jest's ObjectContaining always walks its sample's keys, so objectContaining behavior is unchanged for toEqual, toHaveBeenCalledWith, etc. Bun.deepMatch is unaffected because it instantiates the template with matchers disabled.
  • replacePropsWithAsymmetricMatchers has nothing to do at the top level (there is no parent property to rewrite), so a passing top-level matcher in a snapshot assertion stores the received value as-is. Jest's deepMerge crashes inside pretty-format in that situation, so there is no behavior to mirror there; the failing case fails like Jest.
  • Verified:
    • test/js/bun/test/expect.test.js (toMatchObject describe): two new tests, expected-side and received-side matchers, including expect.not.* inversion and nested matchers inside a top-level objectContaining. Both fail on the released binary and pass with this change; the same assertions pass under Vitest 4.1.9.
    • test/js/bun/test/expect-extend.test.js: a custom (expect.extend) asymmetric matcher used as the whole toMatchObject argument, also verified under Vitest. Fails on the released binary.
    • test/js/bun/test/snapshot-tests/snapshots/snapshot.test.ts: spawns a test file using a top-level matcher as the property matchers of toMatchSnapshot and toMatchInlineSnapshot, passing and failing each, and checks the resulting .snap file. Fails on the released binary (all four cases pass there and the failing toMatchSnapshot writes a snapshot).
    • Full expect.test.js (417 pass), expect-extend.test.js, spyMatchers.test.ts, jest-extended.test.js, the snapshot test directory and the objectContaining regression tests are unchanged, also with BUN_JSC_validateExceptionChecks=1.
  • Overlaps textually with Make toMatchObject and Bun.deepMatch compare Headers and URLSearchParams by their entries #37904 (a content comparison for Headers/URLSearchParams added a few lines below this check); the two are independent, and this check has to run first so that toMatchObject(expect.any(Headers)) on a Headers applies the matcher.

Background

  • Asymmetric matchers (expect.any(), expect.objectContaining(), matchers created by expect.extend) are instances of the Expect* classes defined in src/runtime/test_runner/jest.classes.ts. They all carry the JSDOMWrapperType JSType, which is how Bun__deepEquals / Bun__deepMatch cheaply decide whether to call matchAsymmetricMatcher; that function returns NOT_MATCHER for any other wrapper object (Headers, Blob, ...) and handles expect.not.* inversion itself.
  • Bun__deepMatch<enableAsymmetricMatchers> is the subset comparison behind toMatchObject, the property matchers argument of the snapshot matchers, expect.objectContaining() (with isMatchingObjectContaining, which switches nested objects to exact deepEquals) and Bun.deepMatch (matchers disabled). It walks the enumerable properties of the expected value and compares each against the received value.
  • replacePropsWithAsymmetricMatchers is set by toMatchObject and the snapshot matchers: when a property-level matcher passes, the received property is overwritten with the matcher so that the failure diff and the stored snapshot print Any<Date> instead of the volatile value.

…ct or property matchers argument

Bun__deepMatch only looked for asymmetric matchers while walking the
properties of the expected object. When the expected value itself was a
matcher (toMatchObject(expect.objectContaining(...)), or a matcher passed
as the property matchers of toMatchSnapshot / toMatchInlineSnapshot) it
was walked like a plain object. Matcher objects have no enumerable
properties, so the walk was empty and the assertion always passed. The
same applied to a matcher on the received side, which always failed.

Check both values for a matcher before the property walk, the way
Bun__deepEquals already does. expect.objectContaining() keeps walking its
sample as a plain object, as in Jest, and Bun.deepMatch is unaffected
because it does not enable asymmetric matchers.
@robobun

robobun commented Aug 12, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 3:06 PM PT - Aug 12th, 2026

❌ @robobun, your commit fb3cb9e has 3 failures in Build #93484 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 37912

That installs a local version of the PR into your bun-37912 executable, so you can run:

bun-37912 --bun

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 1 minute

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: cf700680-ce6e-49fb-a01c-88f5069b2e66

📥 Commits

Reviewing files that changed from the base of the PR and between 9a543cc and fb3cb9e.

📒 Files selected for processing (4)
  • src/jsc/bindings/bindings.cpp
  • test/js/bun/test/expect-extend.test.js
  • test/js/bun/test/expect.test.js
  • test/js/bun/test/snapshot-tests/snapshots/snapshot.test.ts

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Reproduced on the released binary (USE_SYSTEM_BUN=1 bun test): expect({ a: 1, b: 2 }).toMatchObject(expect.objectContaining({ a: 2 })) and expect({ a: 1 }).toMatchInlineSnapshot(expect.any(Array), ...) both pass there; Vitest 4.1.9 fails both. The new tests in expect.test.js, expect-extend.test.js and snapshot.test.ts fail on the released binary and pass with this branch.

Fix is in this PR (#37912); independent of #37904, which touches the lines just below this check.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — mirrors the existing top-level asymmetric-matcher check from Bun__deepEquals into Bun__deepMatch.

What was reviewed:

  • New block at bindings.cpp:1978 matches the pattern at :748 and the per-property check at :2046; .asCell() is guarded by the isCell() asserts above, and no RETURN_IF_EXCEPTION after matchAsymmetricMatcher is consistent with both existing call sites.
  • isMatchingObjectContaining guard keeps the expect.objectContaining sample walk (bindings.cpp:539) unchanged; Bun.deepMatch is unaffected via the enableAsymmetricMatchers template guard.
  • Tests cover built-in and expect.extend matchers on both sides, .not.* inversion, nesting, and both snapshot matcher variants including the written .snap file.
Extended reasoning...

Overview

The PR adds a top-level asymmetric-matcher check to Bun__deepMatch in src/jsc/bindings/bindings.cpp, so toMatchObject(expect.objectContaining(...)) and the snapshot property-matchers argument are actually applied instead of being walked as an empty object. Three test files gain coverage: expect.test.js (built-in matchers on both sides), expect-extend.test.js (custom matchers), and snapshot.test.ts (spawned subprocess exercising toMatchSnapshot/toMatchInlineSnapshot).

Security risks

None. This is test-runner matcher logic; the only inputs are values already reachable from user test code, and matchAsymmetricMatcher was already invoked on the same values one frame deeper.

Level of scrutiny

Moderate. The C++ change is ~30 lines in a hot equality path shared by toMatchObject, objectContaining, and snapshot property matchers, so I checked every call site: the objectContaining sample path (bindings.cpp:539) passes isMatchingObjectContaining=true and is skipped by the new guard; the top-level entry (bindings.cpp:3154) passes false and hits it; Bun__deepMatch<false> (Bun.deepMatch) compiles the block out via if constexpr. The new code is byte-for-byte the same shape as Bun__deepEquals at :748 and the existing per-property check at :2046, including its exception-handling stance (throwScope is threaded through and checked by the caller / next RETURN_IF_EXCEPTION at :2013).

Other factors

Test coverage is thorough — pass/fail for each matcher family, .not.* inversion, received-side matchers, nested matchers inside a top-level objectContaining, and a subprocess snapshot test that asserts both stdout markers and the exact .snap contents. The PR description states verification against Jest/Vitest and BUN_JSC_validateExceptionChecks=1. No prior human review comments to address.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant