From 6fbf94d2ef3ebdc32bf322232de969d99b81449c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 25 May 2026 09:26:41 +0000 Subject: [PATCH] fix(bun:test): return an object when constructing a mock function Mock functions registered the call callback as their construct callback, so `new mockFn()` / `Reflect.construct(mockFn, [])` could produce a non-object value (e.g. undefined), violating the [[Construct]] contract and failing the isCell assertion in JSC when the result was used. Add a dedicated construct callback that creates an instance from newTarget's prototype, runs the mock implementation with it as this, and returns the implementation result only when it is an object. --- src/jsc/bindings/JSMockFunction.cpp | 40 ++++++++++++++++++++++++++--- test/js/bun/test/mock-fn.test.js | 27 +++++++++++++++++++ 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/src/jsc/bindings/JSMockFunction.cpp b/src/jsc/bindings/JSMockFunction.cpp index 1c1c7fe70f6..5a67f59e354 100644 --- a/src/jsc/bindings/JSMockFunction.cpp +++ b/src/jsc/bindings/JSMockFunction.cpp @@ -86,6 +86,7 @@ inline To tryJSDynamicCast(JSC::WriteBarrier& from) } JSC_DECLARE_HOST_FUNCTION(jsMockFunctionCall); +JSC_DECLARE_HOST_FUNCTION(jsMockFunctionConstruct); JSC_DECLARE_CUSTOM_GETTER(jsMockFunctionGetter_protoImpl); JSC_DECLARE_CUSTOM_GETTER(jsMockFunctionGetter_mock); JSC_DECLARE_HOST_FUNCTION(jsMockFunctionGetter_mockGetLastCall); @@ -462,7 +463,7 @@ class JSMockFunction : public JSC::InternalFunction { } JSMockFunction(JSC::VM& vm, JSC::Structure* structure, CallbackKind wrapKind) - : Base(vm, structure, jsMockFunctionCall, jsMockFunctionCall) + : Base(vm, structure, jsMockFunctionCall, jsMockFunctionConstruct) { initMock(); } @@ -826,7 +827,7 @@ static JSValue createMockResult(JSC::VM& vm, Zig::GlobalObject* globalObject, co return result; } -JSC_DEFINE_HOST_FUNCTION(jsMockFunctionCall, (JSGlobalObject * lexicalGlobalObject, CallFrame* callframe)) +static JSC::EncodedJSValue jsMockFunctionCallImpl(JSGlobalObject* lexicalGlobalObject, CallFrame* callframe, JSValue thisValue) { Zig::GlobalObject* globalObject = uncheckedDowncast(lexicalGlobalObject); auto& vm = JSC::getVM(globalObject); @@ -838,7 +839,6 @@ JSC_DEFINE_HOST_FUNCTION(jsMockFunctionCall, (JSGlobalObject * lexicalGlobalObje } JSC::ArgList args = JSC::ArgList(callframe); - JSValue thisValue = callframe->thisValue(); JSC::JSArray* argumentsArray = nullptr; { JSC::ObjectInitializationScope object(vm); @@ -981,6 +981,40 @@ JSC_DEFINE_HOST_FUNCTION(jsMockFunctionCall, (JSGlobalObject * lexicalGlobalObje return JSValue::encode(jsUndefined()); } +JSC_DEFINE_HOST_FUNCTION(jsMockFunctionCall, (JSGlobalObject * lexicalGlobalObject, CallFrame* callframe)) +{ + return jsMockFunctionCallImpl(lexicalGlobalObject, callframe, callframe->thisValue()); +} + +JSC_DEFINE_HOST_FUNCTION(jsMockFunctionConstruct, (JSGlobalObject * lexicalGlobalObject, CallFrame* callframe)) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + JSValue newTarget = callframe->newTarget(); + JSObject* prototype = nullptr; + if (newTarget.isObject()) { + JSValue prototypeValue = asObject(newTarget)->get(lexicalGlobalObject, vm.propertyNames->prototype); + RETURN_IF_EXCEPTION(scope, {}); + if (prototypeValue.isObject()) + prototype = asObject(prototypeValue); + } + + JSObject* thisObject = prototype + ? JSC::constructEmptyObject(lexicalGlobalObject, prototype) + : JSC::constructEmptyObject(lexicalGlobalObject); + + JSValue result = JSValue::decode(jsMockFunctionCallImpl(lexicalGlobalObject, callframe, thisObject)); + RETURN_IF_EXCEPTION(scope, {}); + + // A [[Construct]] call must always produce an object. If the mock implementation returned a + // primitive, return the newly created instance instead, matching ordinary constructor semantics. + if (result && result.isObject()) + return JSValue::encode(result); + + return JSValue::encode(thisObject); +} + void JSMockFunctionPrototype::finishCreation(JSC::VM& vm, JSC::JSGlobalObject* globalObject) { Base::finishCreation(vm); diff --git a/test/js/bun/test/mock-fn.test.js b/test/js/bun/test/mock-fn.test.js index 7f6a244d980..e82cd7ea9fb 100644 --- a/test/js/bun/test/mock-fn.test.js +++ b/test/js/bun/test/mock-fn.test.js @@ -117,6 +117,33 @@ describe("mock()", () => { expect(fn).toHaveBeenCalledWith(); }); + test("are constructable", () => { + // Constructing a mock must always produce an object, even when the + // implementation returns a primitive or there is no implementation at all. + const fn = jest.fn(); + expect(new fn()).toBeInstanceOf(Object); + expect(Reflect.construct(fn, [])).toBeInstanceOf(Object); + expect(fn).toHaveBeenCalledTimes(2); + + const withPrimitive = jest.fn(() => 42); + expect(new withPrimitive()).toBeInstanceOf(Object); + + const withReturnValue = jest.fn(); + withReturnValue.mockReturnValue(5); + expect(new withReturnValue()).toBeInstanceOf(Object); + expect(Reflect.construct(withReturnValue, [])).toBeInstanceOf(Object); + + const result = { a: 1 }; + const withObject = jest.fn(() => result); + expect(new withObject()).toBe(result); + expect(Reflect.construct(withObject, [])).toBe(result); + + const thrower = jest.fn(() => { + throw new Error("boom"); + }); + expect(() => new thrower()).toThrow("boom"); + }); + test("mockName returns this", () => { const fn = jest.fn(); expect(fn.mockName()).toBe(fn);