From 12520539e2b3557e636ade9ca780505bce77ee8d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 25 May 2026 01:23:46 +0000 Subject: [PATCH] fix(bun:test): return an object when constructing a mock function Mock functions registered jsMockFunctionCall as both their call and construct callbacks, so `new mockFn()` / `Reflect.construct(mockFn, [])` could return a non-object (e.g. undefined), violating the [[Construct]] contract and failing the isCell() assertion in JSC::Interpreter::executeConstruct. Add a dedicated construct callback that creates `this` from newTarget.prototype, runs the mock body with it, and returns the body's result only when it is an object, matching ordinary constructor semantics. --- src/jsc/bindings/JSMockFunction.cpp | 36 +++++++++++++++++++++-- test/js/bun/test/mock-fn.test.js | 44 +++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 3 deletions(-) diff --git a/src/jsc/bindings/JSMockFunction.cpp b/src/jsc/bindings/JSMockFunction.cpp index 1c1c7fe70f6..d6de1c2c454 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,36 @@ 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); + + // [[Construct]] must return an object. Mimic ordinary constructor semantics: + // create `this` from newTarget.prototype, run the mock body with it, and + // return the body's result only if it is an object. + JSObject* newTarget = asObject(callframe->newTarget()); + JSValue prototype = newTarget->get(lexicalGlobalObject, vm.propertyNames->prototype); + RETURN_IF_EXCEPTION(scope, {}); + + JSObject* thisObject = prototype.isObject() + ? JSC::constructEmptyObject(lexicalGlobalObject, asObject(prototype)) + : JSC::constructEmptyObject(lexicalGlobalObject); + + JSValue result = JSValue::decode(jsMockFunctionCallImpl(lexicalGlobalObject, callframe, thisObject)); + RETURN_IF_EXCEPTION(scope, {}); + + 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..fc1da9b0fa5 100644 --- a/test/js/bun/test/mock-fn.test.js +++ b/test/js/bun/test/mock-fn.test.js @@ -117,6 +117,34 @@ describe("mock()", () => { expect(fn).toHaveBeenCalledWith(); }); + test("can be constructed with new", () => { + // with no implementation, `new` returns a newly created object + const fn = jest.fn(); + const instance = new fn(); + expect(typeof instance).toBe("object"); + expect(instance).not.toBeNull(); + const reflected = Reflect.construct(fn, []); + expect(typeof reflected).toBe("object"); + expect(reflected).not.toBeNull(); + expect(fn).toHaveBeenCalledTimes(2); + + // a primitive return value is ignored, like ordinary constructors + const returnsPrimitive = jest.fn(() => 42); + expect(typeof new returnsPrimitive()).toBe("object"); + expect(typeof Reflect.construct(returnsPrimitive, [])).toBe("object"); + + // an object return value is passed through, like ordinary constructors + const result = { value: 42 }; + const returnsObject = jest.fn(() => result); + expect(new returnsObject()).toBe(result); + expect(Reflect.construct(returnsObject, [])).toBe(result); + + // mockReturnValue with a primitive still constructs an object + const mockedReturnValue = jest.fn().mockReturnValue(123); + expect(typeof new mockedReturnValue()).toBe("object"); + expect(typeof Reflect.construct(mockedReturnValue, [])).toBe("object"); + }); + test("mockName returns this", () => { const fn = jest.fn(); expect(fn.mockName()).toBe(fn); @@ -828,6 +856,22 @@ describe("spyOn", () => { expect(fn).not.toHaveBeenCalled(); }); + if (isBun) { + test("spy on a missing property can be constructed with new", () => { + // the spy's implementation returns the original value (undefined), which + // must not escape as the result of a [[Construct]] + const target = {}; + const fn = spyOn(target, "doesNotExist"); + const instance = new fn(); + expect(typeof instance).toBe("object"); + expect(instance).not.toBeNull(); + const reflected = Reflect.construct(fn, []); + expect(typeof reflected).toBe("object"); + expect(reflected).not.toBeNull(); + fn.mockRestore(); + }); + } + test("override impl after doesnt break restore", () => { var obj = { original() {