Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 48 additions & 3 deletions src/jsc/bindings/JSMockFunction.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@
}

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);
Expand Down Expand Up @@ -462,7 +463,7 @@
}

JSMockFunction(JSC::VM& vm, JSC::Structure* structure, CallbackKind wrapKind)
: Base(vm, structure, jsMockFunctionCall, jsMockFunctionCall)
: Base(vm, structure, jsMockFunctionCall, jsMockFunctionConstruct)
{
initMock();
}
Expand Down Expand Up @@ -826,7 +827,7 @@
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<Zig::GlobalObject>(lexicalGlobalObject);
auto& vm = JSC::getVM(globalObject);
Expand All @@ -838,7 +839,6 @@
}

JSC::ArgList args = JSC::ArgList(callframe);
JSValue thisValue = callframe->thisValue();
JSC::JSArray* argumentsArray = nullptr;
{
JSC::ObjectInitializationScope object(vm);
Expand Down Expand Up @@ -879,6 +879,20 @@
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);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

auto invocationId = JSMockModule::nextInvocationId();
JSC::JSArray* invocationCallOrder = fn->invocationCallOrder.get();
if (invocationCallOrder) {
Expand Down Expand Up @@ -981,6 +995,37 @@
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. Behave like `new` on an
// ordinary JS function: create `this` from newTarget's prototype, run the mock
// with it, and return the mock'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);

Check notice on line 1017 in src/jsc/bindings/JSMockFunction.cpp

View check run for this annotation

Claude / Claude Code Review

Mock functions lack a default .prototype, so instanceof throws on constructed instances

Pre-existing nit, not a regression: `JSMockFunction` never installs a `.prototype` own-property (InternalFunction doesn't auto-create one, unlike JSFunction), so `jest.fn().prototype` is `undefined`. With construct now actually working, the default case `const fn = jest.fn(); (new fn()) instanceof fn` throws `TypeError: instanceof called on an object with an invalid prototype property`, and `Object.getPrototypeOf(new fn())` is `Object.prototype` rather than `fn.prototype` — both diverge from Jes
Comment thread
claude[bot] marked this conversation as resolved.

JSValue returnValue = JSValue::decode(jsMockFunctionCallImpl(lexicalGlobalObject, callframe, thisObject));
RETURN_IF_EXCEPTION(scope, {});

if (returnValue && returnValue.isObject()) {
return JSValue::encode(returnValue);
}

return JSValue::encode(thisObject);
}
Comment thread
claude[bot] marked this conversation as resolved.

void JSMockFunctionPrototype::finishCreation(JSC::VM& vm, JSC::JSGlobalObject* globalObject)
{
Base::finishCreation(vm);
Expand Down
75 changes: 75 additions & 0 deletions test/js/bun/test/mock-fn.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,57 @@ 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);
// `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) {
this.value = value;
});
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 }));
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 uses its prototype
function NewTarget() {}
NewTarget.prototype = { marker: true };
const withNewTarget = Reflect.construct(jest.fn(), [], NewTarget);
expect(Object.getPrototypeOf(withNewTarget)).toBe(NewTarget.prototype);
});

test("mockName returns this", () => {
const fn = jest.fn();
expect(fn.mockName()).toBe(fn);
Expand Down Expand Up @@ -828,6 +879,30 @@ describe("spyOn", () => {
expect(fn).not.toHaveBeenCalled();
});

test("constructing a spy calls the original with the new instance", () => {
var obj = {
Original: function () {
this.ok = true;
},
};
const fn = spyOn(obj, "Original");
const instance = Reflect.construct(obj.Original, []);
expect(typeof instance).toBe("object");
expect(instance.ok).toBe(true);
expect(fn).toHaveBeenCalledTimes(1);
fn.mockRestore();
});

if (isBun) {
test("constructing a spy on a missing property returns an object", () => {
const target = {};
const fn = spyOn(target, "doesNotExist");
expect(typeof Reflect.construct(fn, [])).toBe("object");
expect(typeof new fn()).toBe("object");
fn.mockRestore();
});
}

test("override impl after doesnt break restore", () => {
var obj = {
original() {
Expand Down
Loading