Skip to content

bun:test: return an object when a mock is constructed - #34656

Closed
robobun wants to merge 2 commits into
mainfrom
farm/4e0236f3/mock-fn-construct-object
Closed

robobun wants to merge 2 commits into
mainfrom
farm/4e0236f3/mock-fn-construct-object

Conversation

@robobun

@robobun robobun commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator

Fuzzer hit the isCell() assertion in JSCJSValue.h with:

const m = jest.fn();
Reflect.construct(m, []);

JSMockFunction registered jsMockFunctionCall for both call and construct. With no implementation that function returns jsUndefined(), and JSC::Interpreter::executeConstruct then does asObject() on the result (native constructors must return an object). In release builds this surfaced as Reflect.construct(jest.fn(), []) evaluating to undefined.

This adds a dedicated construct handler that allocates the instance, records it in mock.instances, hands it to the shared call path as this, and returns it whenever the implementation does not produce an object. As a side effect mock.instances now actually tracks constructed objects like Jest does (it was always empty before).

JSMockFunction used the same native function for both call and
construct. When invoked via Reflect.construct with no implementation,
it returned undefined, and JSC::Interpreter::executeConstruct then
called asObject() on that value, tripping the isCell() assertion.

Give JSMockFunction its own construct handler that allocates an
instance, records it in mock.instances, passes it as this to the
shared call path, and returns it whenever the implementation does
not produce an object. This also makes mock.instances actually
track constructed objects like Jest does.
@robobun

robobun commented Jul 18, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 10:34 AM PT - Jul 18th, 2026

@robobun, your commit b145d52 is building: #75509

@coderabbitai

coderabbitai Bot commented Jul 18, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

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: 01e9aa46-8cf3-4bfd-9b71-2c4ebb01610d

📥 Commits

Reviewing files that changed from the base of the PR and between d52f9d5 and b145d52.

📒 Files selected for processing (2)
  • src/jsc/bindings/JSMockFunction.cpp
  • test/js/bun/test/mock-fn.test.js

Walkthrough

Changes

Mock function construction

Layer / File(s) Summary
Constructor handler and wiring
src/jsc/bindings/JSMockFunction.cpp
Adds jsMockFunctionConstruct, wires it into JSMockFunction, tracks constructed instances, invokes mock behavior, and applies constructor return semantics.
Reflect.construct behavior tests
test/js/bun/test/mock-fn.test.js
Tests constructed instances, contexts, calls, and object-versus-primitive return behavior for jest.fn mocks.

Possibly related PRs

  • oven-sh/bun#34575: Changes mock construction handling and related Reflect.construct assertions.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: constructed mocks now return an object.
Description check ✅ Passed The description explains the PR purpose and behavior change, but the verification section is missing.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/jsc/bindings/JSMockFunction.cpp`:
- Around line 991-995: Update the mock instance construction logic near the
construct frame to use the construct call’s newTarget when selecting the
prototype, preserving the existing fallback for non-object prototypes and
ensuring Reflect.construct(fn, ..., Subclass) inherits from NewTarget.prototype;
add a regression test in test/js/bun/test/mock-fn.test.js covering this
Reflect.construct or subclass behavior.

In `@test/js/bun/test/mock-fn.test.js`:
- Around line 807-826: Extend the “Reflect.construct on a mock returns an object
and records the instance” test with a non-default newTarget case, using a
distinct constructor or subclass whose prototype is observable. Call
Reflect.construct with that newTarget and assert the returned instance inherits
from NewTarget.prototype while preserving the mock’s recording expectations.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 0251daf0-e5c5-4bfa-b499-a850b386f239

📥 Commits

Reviewing files that changed from the base of the PR and between a227ad9 and d52f9d5.

📒 Files selected for processing (2)
  • src/jsc/bindings/JSMockFunction.cpp
  • test/js/bun/test/mock-fn.test.js

Comment thread src/jsc/bindings/JSMockFunction.cpp Outdated
Comment thread test/js/bun/test/mock-fn.test.js
@robobun

robobun commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator Author

Updated to look up .prototype on newTarget instead of the callee, so Reflect.construct(mock, args, Sub) now produces an instance of Sub. Added a test case covering that.

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Don't register native functions as their own constructors #34575 - Fixes the same Reflect.construct(jest.fn(), []) crash by adding an identical jsMockFunctionConstruct handler and populating mock.instances; is a superset that also fixes the same bug in other native constructors

🤖 Generated with Claude Code

@robobun

robobun commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as a duplicate of #34575, which fixes the same JSMockFunction construct path plus the same bug class in JSFFIFunction and several native module helpers.

@robobun robobun closed this Jul 18, 2026
Comment thread src/jsc/bindings/JSMockFunction.cpp Outdated
Comment on lines +991 to +995
JSValue prototype = fn->get(globalObject, vm.propertyNames->prototype);
RETURN_IF_EXCEPTION(scope, {});
JSObject* instance = prototype.isObject()
? JSC::constructEmptyObject(globalObject, asObject(prototype))
: JSC::constructEmptyObject(globalObject);

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.

🟡 The instance's [[Prototype]] is derived from fn (the callee) rather than callframe->newTarget(). Per ES OrdinaryCreateFromConstructor semantics — and matching every other InternalFunction construct handler in this directory (JSDOMFile.cpp, JSX509Certificate.cpp, NodeFSStatBinding.cpp, etc.) — the prototype lookup should happen on newTarget, so Reflect.construct(jest.fn(), [], C) should yield an instance whose prototype is C.prototype. In Jest, mocks are plain JS functions and honor newTarget naturally.

Extended reasoning...

What the bug is

jsMockFunctionConstruct allocates the newly-constructed instance by reading .prototype from callframe->jsCallee():

JSMockFunction* fn = dynamicDowncast<JSMockFunction>(callframe->jsCallee());
...
JSValue prototype = fn->get(globalObject, vm.propertyNames->prototype);

Per the ES [[Construct]] internal method (specifically OrdinaryCreateFromConstructor), the [[Prototype]] of the new instance is derived from newTarget.prototype, not from the directly-called function. When invoked as new fn() these are the same object, but when invoked via Reflect.construct(fn, args, NewTarget) or via super() in a subclass, newTarget differs from the callee and must be the source of the prototype.

Code path that triggers it

class C {}
const m = jest.fn();
const x = Reflect.construct(m, [], C);
Object.getPrototypeOf(x) === C.prototype; // should be true

Step-by-step:

  1. Reflect.construct(m, [], C) invokes m.[[Construct]]([], C). JSC dispatches to jsMockFunctionConstruct with callframe->jsCallee() == m and callframe->newTarget() == C.
  2. The handler reads m->get(globalObject, vm.propertyNames->prototype). JSMockFunction is an InternalFunction with no own .prototype property, so this returns undefined.
  3. prototype.isObject() is false, so the handler falls through to constructEmptyObject(globalObject), producing an instance whose [[Prototype]] is Object.prototype.
  4. Object.getPrototypeOf(x) === C.prototype evaluates to false.

In Jest, jest.fn() returns a plain JS function, so the engine's ordinary [[Construct]] runs OrdinaryCreateFromConstructor(newTarget, ...) and the same expression evaluates to true.

Why existing code doesn't prevent it

Nothing in the new handler consults callframe->newTarget(). The common case new fn() happens to work only because newTarget === callee there. Every other native construct handler in src/jsc/bindings/ (e.g. JSDOMFile.cpp:70, JSX509Certificate.cpp:181, NodeFSStatBinding.cpp:824, JSStringDecoder.cpp, NodeVMScript.cpp) reads callFrame->newTarget() for exactly this reason.

Impact

Observable divergence from both the ES spec and Jest, but only on the 3-arg Reflect.construct / subclass-super() path with a mock as the target — an uncommon shape. It is not a regression: before this PR, constructing a mock either asserted (debug) or returned undefined (release). The primary crash fix and mock.instances tracking are unaffected.

Fix

Read the prototype from newTarget instead of the callee:

JSObject* newTarget = asObject(callframe->newTarget());
JSValue prototype = newTarget->get(globalObject, vm.propertyNames->prototype);
RETURN_IF_EXCEPTION(scope, {});

(or equivalently use InternalFunction::createSubclassStructure / the pattern from the neighboring handlers).

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