From ec9697921ec0386c30f9c8f2e8c753170b94047e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 24 May 2026 18:57:02 +0000 Subject: [PATCH] fix(bun:test): return an object when constructing a mock function 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`. --- src/jsc/bindings/JSMockFunction.cpp | 39 +++++++++++++++++++++++++++- test/js/bun/test/mock-fn.test.js | 40 +++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/src/jsc/bindings/JSMockFunction.cpp b/src/jsc/bindings/JSMockFunction.cpp index 1c1c7fe70f6..a42fb7f6cc4 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(); } @@ -981,6 +982,42 @@ JSC_DEFINE_HOST_FUNCTION(jsMockFunctionCall, (JSGlobalObject * lexicalGlobalObje return JSValue::encode(jsUndefined()); } +// Native constructors must always return an object, so this mirrors the behavior of +// constructing a plain JavaScript function: create `this` from newTarget.prototype, +// invoke the mock with it, and return the result if it is an object, otherwise `this`. +JSC_DEFINE_HOST_FUNCTION(jsMockFunctionConstruct, (JSGlobalObject * globalObject, CallFrame* callframe)) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + JSMockFunction* fn = dynamicDowncast(callframe->jsCallee()); + if (!fn) [[unlikely]] { + throwTypeError(globalObject, scope, "Expected callee to be mock function"_s); + return {}; + } + + JSObject* newTarget = callframe->newTarget().getObject(); + if (!newTarget) [[unlikely]] { + newTarget = fn; + } + JSValue prototype = newTarget->get(globalObject, vm.propertyNames->prototype); + RETURN_IF_EXCEPTION(scope, {}); + 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); +} + 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..b93efdda516 100644 --- a/test/js/bun/test/mock-fn.test.js +++ b/test/js/bun/test/mock-fn.test.js @@ -794,6 +794,46 @@ describe("mock()", () => { expect(bar()()).toBe(true); }); + + describe("constructing a mock", () => { + test("new on a mock with no implementation returns an object and records the call", () => { + const fn = jest.fn(); + const instance = new fn(1, 2); + expect(typeof instance).toBe("object"); + expect(instance).not.toBeNull(); + expect(fn).toHaveBeenCalledTimes(1); + expect(fn.mock.calls[0]).toEqual([1, 2]); + expect(fn.mock.contexts[0]).toBe(instance); + }); + + test("Reflect.construct on a mock returns an object", () => { + const fn = jest.fn(); + const instance = Reflect.construct(fn, []); + expect(typeof instance).toBe("object"); + expect(instance).not.toBeNull(); + }); + + test("new calls the implementation with the newly created `this`", () => { + const fn = jest.fn(function () { + this.x = 42; + }); + const instance = new fn(); + expect(instance.x).toBe(42); + }); + + test("new returns the implementation's return value when it is an object", () => { + const result = { a: 1 }; + const fn = jest.fn(() => result); + expect(new fn()).toBe(result); + }); + + test("new ignores non-object return values", () => { + const fn = jest.fn().mockReturnValue(42); + const instance = new fn(); + expect(typeof instance).toBe("object"); + expect(instance).not.toBeNull(); + }); + }); }); describe("spyOn", () => {