Skip to content

fix(bun:test): constructing a mock whose implementation returns a primitive now yields an object#31362

Closed
robobun wants to merge 1 commit into
mainfrom
farm/4e0236f3/mock-construct-object
Closed

fix(bun:test): constructing a mock whose implementation returns a primitive now yields an object#31362
robobun wants to merge 1 commit into
mainfrom
farm/4e0236f3/mock-construct-object

Conversation

@robobun

@robobun robobun commented May 24, 2026

Copy link
Copy Markdown
Collaborator

Problem

JSMockFunction registered jsMockFunctionCall as both its call and construct callback. A native construct callback must return a JSObjectInterpreter::executeConstruct calls asObject() on the result unconditionally — but a mock's implementation can produce any value: undefined for a bare jest.fn() or a spy on a missing property, or any primitive via mockReturnValue(...).

Constructing such a mock through a path that goes via JSC::construct() (Reflect.construct, Proxy construct, super()) hits ASSERTION FAILED: isCell() in debug builds and reinterprets the primitive's bits as an object pointer in release builds. Found by fuzzing (fingerprint JSCJSValue.h(1043)):

import { jest } from "bun:test";
const spy = jest.spyOn(someObject, "missingProperty");
Reflect.construct(spy, []); // ASSERTION FAILED: isCell()

Through the bytecode new mock() path the primitive silently escaped to JS instead, so new (jest.fn())() evaluated to undefined, which also diverges from Jest.

Fix

Give JSMockFunction a dedicated construct callback. It runs the existing mock logic (calls are still recorded, results still tracked) and, when the implementation result is not an object, follows ordinary [[Construct]] semantics: return a fresh object created from newTarget.prototype, falling back to Object.prototype. Implementations that return an object keep returning that object. This matches Jest, where mocks are plain JS functions and new mock() yields an instance when the implementation returns a primitive.

Tests

Added to test/js/bun/test/mock-fn.test.js (file is runnable under Jest/Vitest/Bun; the new assertions match Jest behavior):

  • constructing jest.fn() / jest.fn().mockReturnValue(42) via new and Reflect.construct returns an object, and the calls are recorded
  • constructing a mock whose implementation returns an object still returns that object
  • (Bun-only) constructing a spy on a missing property no longer crashes

Before this change the new tests fail with Received: "undefined" on release builds and crash with the isCell() assertion on debug builds; the original fuzzer reproducer now exits cleanly.

…entation returns a primitive

JSMockFunction registered jsMockFunctionCall as both its call and construct
callback. A native construct callback must return a JSObject, but a mock's
implementation can produce any value (undefined for a bare jest.fn() or a spy
on a missing property, any primitive via mockReturnValue, etc.). Constructing
such a mock through Reflect.construct, a Proxy, or super() funnels through
JSC::construct(), which asserts isCell()/isObject() on the result in debug
builds and reinterprets the primitive as an object pointer in release builds.

Give JSMockFunction a dedicated construct callback that runs the same mock
logic and, when the implementation result is not an object, follows ordinary
[[Construct]] semantics by returning a fresh object created from
newTarget.prototype (falling back to Object.prototype). This also matches
Jest, where mocks are plain JS functions and 'new mock()' yields an instance
when the implementation returns a primitive.
@robobun

robobun commented May 24, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 11:06 AM PT - May 24th, 2026

@robobun, your commit 855241d has 2 failures in Build #57655 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 31362

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

bun-31362 --bun

@coderabbitai

coderabbitai Bot commented May 24, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 158cc2ad-7ab9-41b6-8840-f882ae842843

📥 Commits

Reviewing files that changed from the base of the PR and between 49c97de and 855241d.

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

Walkthrough

This PR adds [[Construct]] semantics to mock functions. A new jsMockFunctionConstruct host function delegates to the call path and ensures construction always returns an object, even when the mock implementation returns a primitive. Constructor wiring and tests verify the behavior.

Changes

Mock Function Construct Support

Layer / File(s) Summary
Mock function construct implementation
src/jsc/bindings/JSMockFunction.cpp
Forward-declare jsMockFunctionConstruct, implement it to call the mock as a function and return an object (allocating from newTarget.prototype if the call returns a primitive), and wire it as the JSMockFunction constructor callback.
Construction behavior tests
test/js/bun/test/mock-fn.test.js
Verify jest.fn and spyOn are constructable via new and Reflect.construct, always return objects even when the mock implementation returns a primitive, and correctly track call counts for construction paths.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: adding proper construct behavior to mock functions to return objects instead of primitives.
Description check ✅ Passed The description includes both required sections: 'Problem' explains the issue in detail, and the fix and tests are described under 'Fix' and 'Tests' sections, though the 'How did you verify your code works?' template section is not explicitly used.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Fix crash constructing jest.fn() via Reflect.construct #30212 - Fixes the identical isCell() crash when constructing jest.fn() mocks by splitting the call/construct callback in JSMockFunction.cpp

🤖 Generated with Claude Code

@robobun

robobun commented May 24, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as a duplicate of #30212, which fixes the same isCell() assertion (mock construct callback returning a non-object) with a more complete approach: it allocates the this object up front, passes it to the implementation, and honors new.target.prototype, with broader test coverage. Deferring to that PR.

@robobun robobun closed this May 24, 2026
@robobun robobun deleted the farm/4e0236f3/mock-construct-object branch May 24, 2026 18:06
Comment on lines +988 to +1011
JSC_DEFINE_HOST_FUNCTION(jsMockFunctionConstruct, (JSGlobalObject * globalObject, CallFrame* callframe))
{
auto& vm = JSC::getVM(globalObject);
auto scope = DECLARE_THROW_SCOPE(vm);

JSValue returnValue = JSValue::decode(jsMockFunctionCall(globalObject, callframe));
RETURN_IF_EXCEPTION(scope, {});

if (returnValue && returnValue.isObject()) {
return JSValue::encode(returnValue);
}

JSValue newTarget = callframe->newTarget();
JSValue prototype = jsUndefined();
if (newTarget && newTarget.isObject()) {
prototype = asObject(newTarget)->get(globalObject, vm.propertyNames->prototype);
RETURN_IF_EXCEPTION(scope, {});
}

JSObject* thisObject = prototype.isObject()
? JSC::constructEmptyObject(globalObject, asObject(prototype))
: JSC::constructEmptyObject(globalObject);
RELEASE_AND_RETURN(scope, JSValue::encode(thisObject));
}

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.

🟡 This fixes the crash, but the comment claiming "ordinary [[Construct]] semantics" overstates what the code does: ordinary [[Construct]] allocates the receiver from newTarget.prototype before the body runs and binds it as this, whereas here the implementation runs first (via jsMockFunctionCall, with callframe->thisValue()) and the fresh object is created only afterward. So const fn = jest.fn(function () { this.value = 42 }); new fn() returns an empty object in Bun but {value: 42} in Jest, and mock.contexts records the wrong receiver. Not a regression — strictly better than the prior crash/undefined — so fine as a follow-up; relatedly (and pre-existing), nothing ever pushes onto fn->instances, so mock.instances stays [] after new fn() even though there is now an instance to record.

Extended reasoning...

What the gap is

jsMockFunctionConstruct does:

  1. jsMockFunctionCall(globalObject, callframe) — which reads thisValue = callframe->thisValue() and invokes the implementation via Bun::call(..., thisValue, args) as a regular call
  2. only after that, allocates a fresh object from newTarget.prototype and returns it when the implementation result was not an object

Ordinary ECMA-262 [[Construct]] does the opposite: it creates the receiver from newTarget.prototype before evaluating the function body, binds it as this, runs the body, and returns that receiver if the body returned a non-object. Jest's mocks are plain JS functions (the wrapper does impl.apply(this, args)), so they get this for free.

Concrete walkthrough

const fn = jest.fn(function () { this.value = 42; });
const inst = new fn();
  • Jest: new fn() creates a fresh this from fn.prototype, the wrapper does impl.apply(this, args), this.value = 42 lands on the fresh object, the wrapper returns undefined, [[Construct]] returns the mutated this. inst.value === 42, and fn.mock.contexts[0] === inst.
  • Bun after this PR: jsMockFunctionCall runs the implementation with callframe->thisValue() (the native construct frame's this, not a fresh instance), so this.value = 42 lands on whatever that is. The implementation returns undefined, so jsMockFunctionConstruct then allocates a brand-new empty object at lines 1007-1009 and returns it. inst.value === undefined, and mock.contexts[0] is not inst.

The new tests don't catch this because they only assert typeof result === "object" or check an implementation that explicitly returns an object — none exercise an implementation that mutates this without returning.

Why this is not a regression

Before this PR the same input either returned undefined (new fn() bytecode path) or crashed with the isCell() assertion (Reflect.construct path). The implementation has always been invoked via Bun::call with callframe->thisValue(), so this-mutation was never preserved and mock.contexts already recorded the same value it does now. Returning an empty object is strictly better than crashing or returning a primitive; this is an incomplete fix, not a new bug.

Adjacent pre-existing gap: mock.instances

Grepping the file, fn->instances is declared, cleared, lazily created, GC-visited and exposed on the mock object — but nothing ever pushes onto it, in either jsMockFunctionCall or the new jsMockFunctionConstruct. So fn.mock.instances is always [] in Bun, whereas in Jest new fn() records the instance. This was equally true before the PR (it's a long-standing stub), so it's out of scope for a crash fix; mentioning it only because the construct path now actually produces an instance object that could be recorded, and the fix below naturally gives you the value to push.

On the refutation

One verifier argued the mock.instances part should be dropped entirely as a pre-existing missing feature with nothing in the diff to comment on. That's fair — it is folded in here as a one-line aside on the same construct callback rather than a standalone finding, and is explicitly flagged as pre-existing / follow-up rather than something to change in this PR.

Suggested follow-up fix

To fully match Jest, jsMockFunctionConstruct should allocate the fresh object from newTarget.prototype first, then invoke the mock with that object as thisValue (this requires either threading a thisValue into jsMockFunctionCall or extracting a helper that takes one). That same object can then be pushed onto mock.contexts and mock.instances. None of that needs to block this PR — the crash fix is correct as-is — but the "ordinary [[Construct]] semantics" comment should probably be softened in the meantime.

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