Skip to content

fix(bun:test): return an object when constructing a mock function#31365

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

fix(bun:test): return an object when constructing a mock function#31365
robobun wants to merge 1 commit into
mainfrom
farm/4e0236f3/fix-mock-construct

Conversation

@robobun

@robobun robobun commented May 24, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Fixes a crash (debug assertion ASSERTION FAILED: isCell() in JSC::JSValue::asCell, JSCJSValue.h:1043) found by fuzzing, triggered by constructing a bun:test mock function when its invocation result is not an object:

import { mock } from "bun:test";
Reflect.construct(mock(), []); // assertion failure in debug builds
new (mock())();                // evaluates to `undefined` instead of an object

JSMockFunction passed jsMockFunctionCall as both its [[Call]] and [[Construct]] handler. JSC requires a native constructor to return an object (Interpreter::executeConstruct does asObject(result) on the return value), but the mock call handler can return any value — undefined when there is no implementation, whatever mockReturnValue() was given, etc. Constructing such a mock through Reflect.construct (or any C++ caller of JSC::construct) hit the assertion, and plain new on a mock returned a non-object, which violates constructor semantics.

This PR gives JSMockFunction a dedicated construct handler that mirrors how constructing an ordinary JavaScript function behaves (and matches Jest):

  • create this from newTarget.prototype (falling back to a plain object)
  • invoke the mock with that this, so calls/contexts are recorded and function () { this.x = 1 } implementations work
  • return the invocation result if it is an object, otherwise the newly created this

Behavior before/after:

expression before after
Reflect.construct(mock(), []) assertion failure (debug) returns an object
new (mock())() undefined returns an object, call recorded
new (mock(function () { this.x = 1 }))().x assertion failure (debug) 1
new (mock(() => someObject))() someObject someObject (unchanged)
new (mock().mockReturnValue(42))() assertion failure (debug) returns an object

How did you verify your code works?

  • The fuzzer reproduction and minimized variants no longer crash on a debug (ASAN + assertions) build.
  • Added tests in test/js/bun/test/mock-fn.test.js covering new and Reflect.construct on mocks with no implementation, with an implementation that assigns to this, with an object-returning implementation, and with a non-object mockReturnValue. 4 of the 5 new tests fail on the current release build and all pass with this change.
  • Existing mock/spy test suites (mock-fn, mock-disposable, mock-module*, spyMatchers, expect-toHaveReturnedWith) pass, including with BUN_JSC_validateExceptionChecks=1.

Mock functions used the call handler for both [[Call]] and [[Construct]].
A native constructor must return an object, so constructing a mock whose
invocation result was not an object (e.g. `Reflect.construct(jest.fn(), [])`)
failed the isCell() assertion in JSC::JSValue::asCell, and `new (jest.fn())()`
evaluated to undefined.

Give JSMockFunction a dedicated construct handler that mirrors ordinary
function construction: create `this` from newTarget.prototype, invoke the
mock with it, and return the invocation result if it is an object, otherwise
the newly created `this`.
@robobun

robobun commented May 24, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 12:06 PM PT - May 24th, 2026

@robobun, your commit ec96979 has 2 failures in Build #57669 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 31365

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

bun-31365 --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: b33207b4-fcbf-47dd-9fd3-6869614e5635

📥 Commits

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

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

Walkthrough

This PR extends Bun's mock function implementation to support JavaScript constructor semantics. Mock functions can now be called with new or Reflect.construct, creating instances and invoking the mock's implementation with the newly constructed this object.

Changes

Mock function constructor support

Layer / File(s) Summary
Mock function constructor handler declaration, wiring, and implementation
src/jsc/bindings/JSMockFunction.cpp, test/js/bun/test/mock-fn.test.js
jsMockFunctionConstruct is declared and implemented to handle constructor calls: it resolves newTarget.prototype, creates a thisObject, invokes the mock with that context, and returns the mock result if it is an object or the thisObject otherwise. The JSMockFunction wiring is updated to use the new constructor handler. Tests verify construction with no implementation, with Reflect.construct, instance property initialization via this binding, object return value preservation, and non-object return value fallback to the instance.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main change: implementing proper object return behavior when constructing a mock function.
Description check ✅ Passed The description fully addresses both required template sections with comprehensive details about what the PR does and how it was verified.
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 same crash when constructing a mock function via new/Reflect.construct by adding a dedicated jsMockFunctionConstruct handler to 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 by giving JSMockFunction a dedicated construct handler (and already supersedes #28315). That PR takes the same approach — create this from new.target's prototype, invoke the mock with it, and fall back to the created instance when the result isn't an object — with equivalent test coverage, so there's no need for two open PRs for this fix.

@robobun robobun closed this May 24, 2026
@robobun robobun deleted the farm/4e0236f3/fix-mock-construct branch May 24, 2026 19:05
Comment on lines +1004 to +1019
JSObject* thisObject = prototype.isObject()
? JSC::constructEmptyObject(globalObject, asObject(prototype))
: JSC::constructEmptyObject(globalObject);

JSC::CallData callData = JSC::getCallData(fn);
ASSERT(callData.type != JSC::CallData::Type::None);
JSC::ArgList args = JSC::ArgList(callframe);
JSValue returnValue = JSC::call(globalObject, fn, callData, thisObject, args);
RETURN_IF_EXCEPTION(scope, {});

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

return 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.

🟡 Now that [[Construct]] actually works, it might be worth pushing the constructed instance onto fn->instances here so fn.mock.instances is populated like it is in Jest. The instances array is already declared, exposed on the mock object (offset 2), cleared in clear(), and GC-visited — it's just never written to, so fn.mock.instances stays empty after new fn(). This is pre-existing scaffolding that was never wired up rather than a regression, so feel free to leave it for a follow-up.

Extended reasoning...

What's missing

JSMockFunction declares a WriteBarrier<JSArray> instances field, lazily creates it in getInstances(), exposes it as the instances property at offset 2 of the mock-object structure, clears it in clear(), and visits it in visitAdditionalChildrenInGCThread. But nothing in the file ever pushes to it. jsMockFunctionCall records calls, contexts, invocationCallOrder, and returnValues, and the new jsMockFunctionConstruct simply delegates to jsMockFunctionCall via JSC::call without separately recording the constructed instance. The result is that fn.mock.instances is always an empty array, even after new fn().

Why it matters now

In Jest, mock.instances is the canonical API for tracking objects created by constructing a mock — new fn() pushes the resulting this (or the returned object) onto fn.mock.instances, while plain calls push undefined. Before this PR the [[Construct]] slot pointed at jsMockFunctionCall, so new (mock())() either returned undefined (release) or hit a debug assertion; the empty instances array was effectively unobservable because construction itself was broken. With this PR construction works correctly and the PR description says it "matches Jest", so users porting Jest tests that read fn.mock.instances[0] will now hit a visible compat gap.

Step-by-step

  1. const Fn = jest.fn(function () { this.x = 1 });
  2. const a = new Fn(); — enters jsMockFunctionConstruct, which builds thisObject, calls JSC::call(globalObject, fn, callData, thisObject, args) (recording calls[0] = [], contexts[0] = thisObject, etc.), and returns thisObject.
  3. Nothing in either function touches fn->instances.
  4. Fn.mock.instancesgetInstances() lazily allocates an empty array → [].
  5. In Jest the same code yields Fn.mock.instances[0] === a.

Suggested fix

In jsMockFunctionConstruct, after computing the final return value (returnValue.isObject() ? returnValue : thisObject), push it onto fn->instances the same way jsMockFunctionCall pushes to contexts (lazily creating the array on first use). For full Jest parity jsMockFunctionCall would also push undefined for non-construct calls, but the construct case is the important one and is squarely in scope for this PR.

Severity

This is pre-existing unwired scaffolding — the instances field has always been exposed and always been empty — so it is not a regression and shouldn't block the crash fix. Filing as a nit since this PR is the natural place to wire it up, but a follow-up is fine too.

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