diff --git a/src/jsc/bindings/JSMockFunction.cpp b/src/jsc/bindings/JSMockFunction.cpp index 1c1c7fe70f6e..39bc68ed236f 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,39 @@ JSC_DEFINE_HOST_FUNCTION(jsMockFunctionCall, (JSGlobalObject * lexicalGlobalObje return JSValue::encode(jsUndefined()); } +// Native constructors must return an object, so behave like `new` on an ordinary JS function: +// run the mock with a freshly created `this` and return its result only when it is an object. +JSC_DEFINE_HOST_FUNCTION(jsMockFunctionConstruct, (JSGlobalObject * lexicalGlobalObject, CallFrame* callframe)) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + if (!dynamicDowncast(callframe->jsCallee())) [[unlikely]] { + throwTypeError(lexicalGlobalObject, scope, "Expected callee to be mock function"_s); + return {}; + } + + JSValue newTarget = callframe->newTarget(); + JSObject* thisObject = nullptr; + if (newTarget && newTarget.isObject()) { + JSValue prototype = asObject(newTarget)->get(lexicalGlobalObject, vm.propertyNames->prototype); + RETURN_IF_EXCEPTION(scope, {}); + if (prototype.isObject()) + thisObject = JSC::constructEmptyObject(lexicalGlobalObject, asObject(prototype)); + } + if (!thisObject) + thisObject = JSC::constructEmptyObject(lexicalGlobalObject); + + callframe->setThisValue(thisObject); + JSValue returnValue = JSValue::decode(jsMockFunctionCall(lexicalGlobalObject, callframe)); + RETURN_IF_EXCEPTION(scope, {}); + + if (returnValue && 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 7f6a244d9806..ff74d09a1e75 100644 --- a/test/js/bun/test/mock-fn.test.js +++ b/test/js/bun/test/mock-fn.test.js @@ -794,6 +794,35 @@ describe("mock()", () => { expect(bar()()).toBe(true); }); + + test("can be invoked with new", () => { + const noImpl = jest.fn(); + const instance = new noImpl(); + expect(instance).toBeInstanceOf(Object); + expect(noImpl.mock.contexts[0]).toBe(instance); + expect(Reflect.construct(noImpl, [])).toBeInstanceOf(Object); + + const primitiveImpl = jest.fn(() => 42); + expect(new primitiveImpl()).toBeInstanceOf(Object); + expect(primitiveImpl.mock.results[0]).toEqual({ type: "return", value: 42 }); + + const usesThis = jest.fn(function () { + this.x = 1; + }); + expect(new usesThis()).toEqual({ x: 1 }); + + const returnsObject = jest.fn(() => ({ y: 2 })); + expect(new returnsObject()).toEqual({ y: 2 }); + + const throws = jest.fn(() => { + throw new Error("boom"); + }); + expect(() => new throws()).toThrow("boom"); + + class Target {} + const withNewTarget = jest.fn(); + expect(Object.getPrototypeOf(Reflect.construct(withNewTarget, [], Target))).toBe(Target.prototype); + }); }); describe("spyOn", () => { @@ -1011,6 +1040,13 @@ describe("spyOn", () => { expect(arr[14]()).toBe(456); expect(fn).not.toHaveBeenCalled(); }); + + test("constructing a spy on a missing property returns an object", () => { + const target = {}; + const fn = spyOn(target, "missing"); + expect(Reflect.construct(fn, [])).toBeInstanceOf(Object); + expect(new fn()).toBeInstanceOf(Object); + }); } // spyOn does not work with getters/setters yet.