From 61e96a889ba11d41b408408e282cda33b62aeef2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 31 May 2026 00:00:46 +0000 Subject: [PATCH 1/2] fix(bun:test): return an object when a mock function is constructed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JSMockFunction registered jsMockFunctionCall as both the call and the construct handler. A native construct callback must return an object — Interpreter::executeConstruct passes the result straight through asObject() — but jsMockFunctionCall can return undefined, a number, or any other primitive, so constructing a mock (e.g. Reflect.construct on jest.fn()) crashed on a non-object cell. Split the body into jsMockFunctionCallImpl parameterized on the `this` value and add a dedicated jsMockFunctionConstruct handler. It creates `this` from newTarget's prototype, runs the mock against it, and returns the mock's result only when that result is an object, otherwise the freshly created instance — matching `new` on an ordinary function. A construct-specific handler is used rather than checking callframe->newTarget() inside the shared handler, because newTarget() aliases thisValue() in JSC and would misfire on ordinary calls that happen to have a non-undefined receiver. --- src/jsc/bindings/JSMockFunction.cpp | 36 +++++++++++++++++++-- test/js/bun/test/mock-fn.test.js | 50 +++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 3 deletions(-) diff --git a/src/jsc/bindings/JSMockFunction.cpp b/src/jsc/bindings/JSMockFunction.cpp index 1c1c7fe70f6..32a14927ada 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); + + // A native construct callback must return an object, so build `this` from + // newTarget's prototype and fall back to it when the mock returns a non-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 returnValue = JSValue::decode(jsMockFunctionCallImpl(lexicalGlobalObject, callframe, thisObject)); + 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 7f6a244d980..1714f394a1f 100644 --- a/test/js/bun/test/mock-fn.test.js +++ b/test/js/bun/test/mock-fn.test.js @@ -117,6 +117,56 @@ describe("mock()", () => { expect(fn).toHaveBeenCalledWith(); }); + test("are constructable with new", () => { + // no implementation: `new` should produce a fresh object, like `new` on an ordinary function + const fn = jest.fn(); + const instance = new fn(1, 2); + expect(typeof instance).toBe("object"); + expect(instance).not.toBe(null); + expect(fn.mock.calls).toEqual([[1, 2]]); + expect(fn.mock.contexts[0]).toBe(instance); + + // Reflect.construct used to crash when the mock returned a non-object + const reflected = Reflect.construct(fn, []); + expect(typeof reflected).toBe("object"); + + // implementation operating on `this` + const withImpl = jest.fn(function (value) { + this.value = value; + }); + const constructed = new withImpl(42); + expect(constructed.value).toBe(42); + expect(withImpl.mock.contexts[0]).toBe(constructed); + + // implementation returning an object wins over the created `this` + const returnsObject = jest.fn(() => ({ a: 1 })); + expect(new returnsObject()).toEqual({ a: 1 }); + + // primitive return values are ignored by `new`, like ordinary functions + const returnsPrimitive = jest.fn().mockReturnValue(42); + expect(typeof new returnsPrimitive()).toBe("object"); + + // newTarget.prototype is respected + const classLike = jest.fn(); + classLike.prototype = { + greet() { + return "hello"; + }, + }; + const classInstance = new classLike(); + expect(classInstance.greet()).toBe("hello"); + expect(classInstance instanceof classLike).toBe(true); + + // Reflect.construct with an explicit newTarget honors its prototype + class Base {} + expect(Reflect.construct(jest.fn(), [], Base)).toBeInstanceOf(Base); + + // a normal call with a non-undefined `this` must still return the mock's value + const method = jest.fn().mockReturnValue(42); + const host = { method }; + expect(host.method()).toBe(42); + }); + test("mockName returns this", () => { const fn = jest.fn(); expect(fn.mockName()).toBe(fn); From 63463603cc4bd267e9a2400338ecb861f42bf2c2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 31 May 2026 00:07:42 +0000 Subject: [PATCH 2/2] fix(bun:test): record mock.instances on every call (matches jest) Populate fn->instances alongside fn->contexts in the shared call helper so `new`/Reflect.construct invocations appear in mock.instances, mirroring jest (which records each call's `this`). Verified identical output against jest for plain construct, object-returning impl, this-mutating impl, and regular non-construct calls. --- src/jsc/bindings/JSMockFunction.cpp | 14 ++++++++++++++ test/js/bun/test/mock-fn.test.js | 4 ++++ 2 files changed, 18 insertions(+) diff --git a/src/jsc/bindings/JSMockFunction.cpp b/src/jsc/bindings/JSMockFunction.cpp index 32a14927ada..d02325a8a9f 100644 --- a/src/jsc/bindings/JSMockFunction.cpp +++ b/src/jsc/bindings/JSMockFunction.cpp @@ -879,6 +879,20 @@ static JSC::EncodedJSValue jsMockFunctionCallImpl(JSGlobalObject* lexicalGlobalO fn->contexts.set(vm, fn, contexts); } + JSC::JSArray* instances = fn->instances.get(); + if (instances) { + instances->push(globalObject, thisValue); + RETURN_IF_EXCEPTION(scope, {}); + } else { + JSC::ObjectInitializationScope object(vm); + instances = JSC::JSArray::tryCreateUninitializedRestricted( + object, + globalObject->arrayStructureForIndexingTypeDuringAllocation(JSC::ArrayWithContiguous), + 1); + instances->initializeIndex(object, 0, thisValue); + fn->instances.set(vm, fn, instances); + } + auto invocationId = JSMockModule::nextInvocationId(); JSC::JSArray* invocationCallOrder = fn->invocationCallOrder.get(); if (invocationCallOrder) { diff --git a/test/js/bun/test/mock-fn.test.js b/test/js/bun/test/mock-fn.test.js index 1714f394a1f..948ca064e36 100644 --- a/test/js/bun/test/mock-fn.test.js +++ b/test/js/bun/test/mock-fn.test.js @@ -125,10 +125,13 @@ describe("mock()", () => { expect(instance).not.toBe(null); expect(fn.mock.calls).toEqual([[1, 2]]); expect(fn.mock.contexts[0]).toBe(instance); + // `new` calls are recorded in mock.instances + expect(fn.mock.instances[0]).toBe(instance); // Reflect.construct used to crash when the mock returned a non-object const reflected = Reflect.construct(fn, []); expect(typeof reflected).toBe("object"); + expect(fn.mock.instances[1]).toBe(reflected); // implementation operating on `this` const withImpl = jest.fn(function (value) { @@ -137,6 +140,7 @@ describe("mock()", () => { const constructed = new withImpl(42); expect(constructed.value).toBe(42); expect(withImpl.mock.contexts[0]).toBe(constructed); + expect(withImpl.mock.instances[0]).toBe(constructed); // implementation returning an object wins over the created `this` const returnsObject = jest.fn(() => ({ a: 1 }));