Skip to content
Open
Show file tree
Hide file tree
Changes from 8 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
58 changes: 55 additions & 3 deletions src/jsc/bindings/JSMockFunction.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ inline To tryJSDynamicCast(JSC::WriteBarrier<WriteBarrierT>& 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);
Expand Down Expand Up @@ -238,6 +239,13 @@ class JSMockFunction : public JSC::InternalFunction {
JSMockFunction* function = new (NotNull, JSC::allocateCell<JSMockFunction>(vm)) JSMockFunction(vm, structure, kind);
function->finishCreation(vm);

// InternalFunction does not auto-create a `.prototype` like JSFunction does.
// Install a writable one with a `constructor` back-reference so mocks behave
// like ordinary JS functions under `new` (matches jest).
JSObject* prototype = JSC::constructEmptyObject(globalObject, globalObject->objectPrototype());
prototype->putDirect(vm, vm.propertyNames->constructor, function, static_cast<unsigned>(JSC::PropertyAttribute::DontEnum));
function->putDirect(vm, vm.propertyNames->prototype, prototype, static_cast<unsigned>(JSC::PropertyAttribute::DontEnum | JSC::PropertyAttribute::DontDelete));

// Do not forget to set the original name: https://github.com/oven-sh/bun/issues/8794
function->m_originalName.set(vm, function, globalObject->commonStrings().mockedFunctionString(globalObject));

Expand Down Expand Up @@ -462,7 +470,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();
}
Expand Down Expand Up @@ -826,7 +834,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<Zig::GlobalObject>(lexicalGlobalObject);
auto& vm = JSC::getVM(globalObject);
Expand All @@ -838,7 +846,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);
Expand Down Expand Up @@ -879,6 +886,20 @@ JSC_DEFINE_HOST_FUNCTION(jsMockFunctionCall, (JSGlobalObject * lexicalGlobalObje
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 +1002,37 @@ 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. 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);
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
96 changes: 96 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,78 @@ 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();
// like an ordinary function, a mock has a writable `.prototype` with a `constructor` back-reference
expect(typeof fn.prototype).toBe("object");
expect(fn.prototype.constructor).toBe(fn);
const instance = new fn(1, 2);
expect(typeof instance).toBe("object");
expect(instance).not.toBe(null);
// the instance inherits from the mock's prototype, so `instanceof` works without assigning one
expect(Object.getPrototypeOf(instance)).toBe(fn.prototype);
expect(instance instanceof fn).toBe(true);
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("mock.instances records `this` on every call, like mock.contexts", () => {
// jest-mock/@vitest/spy push `this` onto both instances and contexts on
// every call (no new.target check), so the two arrays stay in lock-step.
const fn = jest.fn();
const ctx = {};
fn.call(ctx);
fn();
expect(fn.mock.contexts).toEqual([ctx, undefined]);
expect(fn.mock.instances).toEqual([ctx, undefined]);

const instance = new fn();
expect(fn.mock.contexts).toEqual([ctx, undefined, instance]);
expect(fn.mock.instances).toEqual([ctx, undefined, instance]);
});

test("mockName returns this", () => {
const fn = jest.fn();
expect(fn.mockName()).toBe(fn);
Expand Down Expand Up @@ -828,6 +900,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