bun:test: return an object when constructing a mock function#31370
bun:test: return an object when constructing a mock function#31370robobun wants to merge 1 commit into
Conversation
JSMockFunction used the same host callback for call and construct, so `new mockFn()` / `Reflect.construct(mockFn, ...)` could return a primitive. JSC requires native construct callbacks to return an object, and Interpreter::executeConstruct asserts on the result, so constructing a mock with no object-returning implementation hit `ASSERTION FAILED: isCell()` in debug builds. Give JSMockFunction a dedicated construct callback that follows ordinary [[Construct]] semantics: create `this` from new.target's prototype, run the mock with that `this`, and return the mock's result only when it is an object.
|
Updated 4:46 PM PT - May 24th, 2026
❌ @robobun, your commit 50b36e2 has 2 failures in
🧪 To try this PR locally: bunx bun-pr 31370That installs a local version of the PR into your bun-31370 --bun |
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (2)
WalkthroughThis PR adds explicit ChangesMock function constructor support
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
|
Closing as a duplicate of #30212, which fixes the same |
| JSObject* newTarget = asObject(callframe->newTarget()); | ||
| JSGlobalObject* functionGlobalObject = getFunctionRealm(lexicalGlobalObject, newTarget); | ||
| RETURN_IF_EXCEPTION(scope, {}); | ||
| Structure* structure = InternalFunction::createSubclassStructure(lexicalGlobalObject, newTarget, functionGlobalObject->objectStructureForObjectConstructor()); | ||
| RETURN_IF_EXCEPTION(scope, {}); | ||
| JSObject* thisObject = JSC::constructEmptyObject(vm, structure); |
There was a problem hiding this comment.
🟡 Now that new fn() returns an object, the common Jest assertion expect(new fn()).toBeInstanceOf(fn) is reachable — but it throws TypeError because JSMockFunction never installs a .prototype own property, so createSubclassStructure falls back to Object.prototype and OrdinaryHasInstance rejects the non-object fn.prototype. The missing .prototype is pre-existing and the construct callback itself is spec-correct, so this can be a follow-up: give each JSMockFunction a fresh .prototype object (as Jest's plain-JS mockConstructor gets automatically) so the default-new.target case wires up the prototype chain.
Extended reasoning...
What the bug is
jsMockFunctionConstruct derives the new instance's structure via InternalFunction::createSubclassStructure(lexicalGlobalObject, newTarget, functionGlobalObject->objectStructureForObjectConstructor()). When newTarget is the mock function itself (the ordinary new fn() case), createSubclassStructure reads newTarget.prototype. But JSMockFunction is an InternalFunction subclass whose create() only calls Base::finishCreation(vm) and sets m_originalName — it never does putDirectWithoutTransition(vm, vm.propertyNames->prototype, ...) the way other Bun InternalFunction constructors (e.g. JSBufferList, NodeVMScript, JSX509Certificate) do, and InternalFunction::finishCreation does not auto-create one. So fn.prototype is undefined.
Code path that triggers it
const fn = jest.fn();
const inst = new fn();
Object.getPrototypeOf(inst) === Object.prototype; // true in Bun, false in Jest
inst instanceof fn; // TypeError in Bun, true in Jest
expect(inst).toBeInstanceOf(fn); // throws in Bun, passes in JestStep-by-step:
new fn()entersjsMockFunctionConstructwithcallframe->newTarget() === fn.createSubclassStructuredoesnewTarget->get(..., vm.propertyNames->prototype)→undefined(no own.prototype, nothing onJSMockFunctionPrototype/Function.prototype).- Since the result is not an object, it returns the supplied base structure,
objectStructureForObjectConstructor(), whose prototype isObject.prototype. constructEmptyObjectproducesthisObjectwith[[Prototype]] = Object.prototype.- Later,
inst instanceof fnruns OrdinaryHasInstance: step 4 readsfn.prototype→undefined; step 5 ("If P is not an Object, throw a TypeError") throws.
Why existing code doesn't prevent it
The construct callback correctly implements OrdinaryCreateFromConstructor (fall back to %Object.prototype% when newTarget.prototype is not an object), so it is spec-compliant given its inputs. The gap is upstream: unlike a plain JS function mockConstructor() {} — which is what Jest uses and which gets an auto-created .prototype object — JSMockFunction never installs one. The new test only checks typeof instance === "object" and Reflect.construct(fn5, [], Base) (where Base supplies the prototype), so the default-new.target prototype chain is never asserted.
Impact
The PR description motivates this change with "Jest's class-mocking pattern (const instance = new MockedClass())", but the canonical assertion for that pattern — expect(instance).toBeInstanceOf(MockedClass) — throws in Bun while passing in Jest/Vitest. This is a real Jest-compat gap that becomes reachable for the first time now that new fn() returns an object instead of undefined.
That said, the root cause is pre-existing: jest.fn().prototype was already undefined before this PR, and ({}) instanceof jest.fn() already threw TypeError. This PR strictly improves behavior (assertion crash / undefined → an object), and the construct callback itself is correct.
How to fix
Give each JSMockFunction a fresh .prototype object in JSMockFunction::create (e.g. function->putDirect(vm, vm.propertyNames->prototype, constructEmptyObject(globalObject), PropertyAttribute::DontEnum)), mirroring what JSC does for ordinary JS functions and what other Bun InternalFunction constructors do explicitly. With that in place, createSubclassStructure will pick up fn.prototype, Object.getPrototypeOf(new fn()) === fn.prototype, and instanceof / toBeInstanceOf work as in Jest. This is somewhat orthogonal to the construct callback added here, so it's reasonable as a follow-up rather than a blocker for this crash fix.
| JSObject* thisObject = JSC::constructEmptyObject(vm, structure); | ||
|
|
||
| JSValue returnValue = JSValue::decode(jsMockFunctionCallImpl(lexicalGlobalObject, callframe, thisObject)); | ||
| RETURN_IF_EXCEPTION(scope, {}); | ||
| if (returnValue && returnValue.isObject()) { | ||
| return JSValue::encode(returnValue); | ||
| } | ||
|
|
||
| return JSValue::encode(thisObject); |
There was a problem hiding this comment.
🟡 Now that new fn() actually works, it might be worth pushing thisObject into fn->instances here so fn.mock.instances[0] is the constructed instance — that's the canonical use case for mock.instances in Jest. This is technically pre-existing (Bun has never populated mock.instances for any call), so feel free to defer to a follow-up, but flagging since the PR description says this matches how Jest mocks behave when constructed.
Extended reasoning...
What's missing
JSMockFunction declares an instances array (exposed as fn.mock.instances), lazily initializes it in getInstances(), clears it in clear(), and visits it for GC — but nothing in the file ever pushes to it. jsMockFunctionCallImpl pushes to calls, contexts, invocationCallOrder, and returnValues, but not instances. The new jsMockFunctionConstruct creates thisObject and runs the mock implementation against it, but likewise never appends thisObject to fn->instances.
How Jest behaves
In Jest, mockConstructor is a plain JS function that pushes this to mock.instances on every invocation (call or construct). The Jest docs describe mock.instances as "an array that contains all the object instances that have been instantiated from this mock function using new". So after const inst = new fn(), Jest guarantees fn.mock.instances[0] === inst. This is the primary documented use case for mock.instances.
Why it surfaces with this PR
This is strictly a pre-existing gap — on main, mock.instances is always an empty array regardless of how the mock is invoked. However, before this PR, new fn() either asserted in debug builds or returned undefined in release, so the construct path was effectively unusable and nobody could meaningfully reach for mock.instances. This PR makes new fn() return a real object for the first time and explicitly states it "matches how Jest mocks behave when constructed", which makes the empty mock.instances array much more visible.
Step-by-step example
With this PR applied:
const fn = jest.fn(function () { this.x = 42; });
const inst = new fn();jsMockFunctionConstructruns, allocatesthisObjectfromnew.target.prototype.- It calls
jsMockFunctionCallImpl(..., thisObject), which pushes[ ]tofn->calls,thisObjecttofn->contexts, an id toinvocationCallOrder, and a result object toreturnValues— but never touchesfn->instances. - The implementation returns
undefined(not an object), sojsMockFunctionConstructreturnsthisObject. instis the constructed object,fn.mock.contexts[0] === inst✅, butfn.mock.instancesis still[]❌. In Jest,fn.mock.instances[0] === inst.
Impact
Not a regression and not a crash — mock.contexts[0] already captures the same value, and the PR's stated goal (fixing the isCell() assertion) is fully achieved. But users who follow Jest's docs for testing class mocks (expect(fn.mock.instances[0]).toBe(instance)) will find an empty array.
Suggested fix
In jsMockFunctionConstruct, after creating thisObject (or after the call returns), push it to fn->instances the same way jsMockFunctionCallImpl pushes thisValue to fn->contexts. For full Jest parity, jsMockFunctionCallImpl would also push thisValue to instances on every call (Jest pushes this unconditionally), but at minimum the construct path should populate it. Fine as a follow-up if you'd rather keep this PR scoped to the assertion fix.
What does this PR do?
Fixes a fuzzer-found assertion failure (
ASSERTION FAILED: isCell()inJSCJSValue.h:1043) triggered by constructing abun:testmock function:JSMockFunctionregistered the same host callback (jsMockFunctionCall) for both call and construct, so constructing a mock returned whatever the call path produced —undefinedwhen there is no implementation, or any primitive the implementation returns. JSC requires native construct callbacks to return an object;Interpreter::executeConstruct(the path used byReflect.construct, proxies, andJSC::constructfrom C++) doesasObject(result)on the returned value, which asserts in debug builds. In release buildsnew mockFn()silently evaluated toundefined/a primitive, which also breaks Jest's class-mocking pattern (const instance = new MockedClass()).This PR gives
JSMockFunctiona dedicated construct callback that follows ordinary[[Construct]]semantics:thisfromnew.target'sprototype(falling back toObject.prototype, likeOrdinaryCreateFromConstructor)this(somock.calls/mock.contexts/mock.resultsare recorded as before, and the implementation sees the new instance asthis)thisThis matches how Jest mocks behave when constructed, since Jest's
mockConstructoris a plain JS function.How did you verify your code works?
Reflect.construct(Bun.jest(...).mock(), [])script no longer hit the assertion with a debug (ASAN) build.test/js/bun/test/mock-fn.test.jscoveringnew fn(), implementations that return primitives/objects,thishandling, andReflect.constructwith a customnew.target. The test fails on currentbun(returnsundefinedfromnew fn()) and passes with this change; assertions are written to also hold under Jest/Vitest, which this file supports.bun bd test test/js/bun/test/mock-fn.test.js,mock-disposable.test.ts,mock/mock-module.test.ts,spyMatchers.test.ts, andexpect-toHaveReturnedWith.test.jsall pass, including withBUN_JSC_validateExceptionChecks=1.