Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
31 changes: 25 additions & 6 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 @@ -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();
}
Expand Down Expand Up @@ -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 jsMockFunctionCallOrConstruct(JSGlobalObject* lexicalGlobalObject, CallFrame* callframe, bool isConstruct)
{
Zig::GlobalObject* globalObject = uncheckedDowncast<Zig::GlobalObject>(lexicalGlobalObject);
auto& vm = JSC::getVM(globalObject);
Expand All @@ -839,6 +840,14 @@ JSC_DEFINE_HOST_FUNCTION(jsMockFunctionCall, (JSGlobalObject * lexicalGlobalObje

JSC::ArgList args = JSC::ArgList(callframe);
JSValue thisValue = callframe->thisValue();
if (isConstruct) {
thisValue = JSC::constructEmptyObject(globalObject);
}
Comment on lines +843 to +845

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 The synthesized this is created with constructEmptyObject(globalObject), which always uses Object.prototype and ignores callframe->newTarget(). As a result new (jest.fn())() instanceof fn and Reflect.construct(jest.fn(), [], Foo) instanceof Foo are both false, whereas in Jest (and per ordinary [[Construct]]) they are true. Consider deriving the prototype from newTarget (e.g. via InternalFunction::createSubclassStructure / getFunctionRealm, as done in NodeVMScript.cpp and other native constructors here).

Extended reasoning...

What the bug is

In the new construct path, the freshly-allocated this is created with the single-argument overload JSC::constructEmptyObject(globalObject). That overload returns a JSFinalObject whose [[Prototype]] is globalObject->objectPrototype() — i.e. plain Object.prototype. It never consults callframe->newTarget().

Per the ECMAScript [[Construct]] internal method (step OrdinaryCreateFromConstructor), the new object's [[Prototype]] must come from newTarget.prototype. The PR description explicitly says the goal is to "match ordinary [[Construct]] semantics and Jest's behavior", but for the prototype chain it matches neither: in Jest, mock functions are real JS functions, so new fn() goes through ordinary [[Construct]] and the instance inherits from fn.prototype.

Code path that triggers it

if (isConstruct) {
    thisValue = JSC::constructEmptyObject(globalObject);   // <-- always Object.prototype
}

callframe->newTarget() is available here (this is the native-construct entry point) but is never read. Whether the call comes from new fn(), Reflect.construct(fn, []), or Reflect.construct(fn, [], Foo), the resulting object's prototype is the same: Object.prototype.

Why nothing else prevents it

The PR's own "with explicit newTarget" test only asserts typeof result === 'object', which a plain {} satisfies. It would fail if it asserted result instanceof newTarget — which is the observable contract newTarget exists to provide. None of the other added tests check the prototype chain either.

Step-by-step proof

const fn = jest.fn();
const inst = new fn();
Object.getPrototypeOf(inst) === Object.prototype  // true (bug) — should be fn.prototype
inst instanceof fn                                 // false (bug) — true in Jest

class Foo {}
const r = Reflect.construct(jest.fn(), [], Foo);
Object.getPrototypeOf(r) === Foo.prototype         // false (bug) — should be true
r instanceof Foo                                   // false (bug) — true in Jest

fn.prototype.hello = () => 1;
new fn().hello                                     // undefined (bug) — defined in Jest

In each case, constructEmptyObject(globalObject) produced { __proto__: Object.prototype }, so OrdinaryHasInstance walks the chain, never finds fn.prototype / Foo.prototype, and returns false.

Impact

Not a crash and strictly better than the pre-PR behavior (which crashed/returned undefined), but it's an observable correctness divergence in the exact lines this PR adds. Tests that mock a constructor and then assert expect(new Mock()).toBeInstanceOf(Mock) — which pass under Jest — will fail. Methods placed on fn.prototype are unreachable from constructed instances.

How to fix

Use the same pattern this codebase already uses in ~15 native constructors (e.g. NodeVMScript.cpp, JSX509Certificate.cpp, NapiClass.cpp): read callframe->newTarget(), resolve its function realm, derive a structure via InternalFunction::createSubclassStructure (falling back to a default if newTarget.prototype isn't an object), and pass that structure to constructEmptyObject. Then new fn() instanceof fn and Reflect.construct(fn, [], Foo) instanceof Foo will both hold, matching Jest.

Comment on lines +843 to +845

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟣 Pre-existing (non-blocking): mock.instances is still never populated — fn->instances is declared, GC-visited, cleared, and exposed on the mock object, but nothing ever pushes to it. Now that there's a dedicated construct branch with the freshly-allocated thisValue in hand, this would be the natural place to also push it onto fn->instances to match Jest's mock.instances semantics. Fine as a follow-up.

Extended reasoning...

What the issue is

In Jest, mockFn.mock.instances is the array of this values created when the mock is invoked as a constructor (via new or Reflect.construct). In Bun's implementation, the storage for this exists end-to-end — fn->instances is declared as a WriteBarrier<JSArray> on JSMockFunction, lazily created in getInstances(), cleared in clear(), visited by the GC, and wired into the mock object structure at offset 2 — but nothing in JSMockFunction.cpp ever pushes a value into it. As a result, fn.mock.instances is always [] regardless of how the mock is invoked.

Code path

In jsMockFunctionCallOrConstruct, every invocation pushes into fn->calls, fn->contexts, fn->invocationCallOrder, and fn->returnValues. There is no corresponding push into fn->instances. With this PR, the isConstruct branch now explicitly synthesizes thisValue = JSC::constructEmptyObject(globalObject) — which is exactly the value Jest records in mock.instances — but the value is only used as the call receiver and the fallback return; it's never recorded in instances.

Why nothing currently catches this

Existing tests in test/js/bun/test/mock-fn.test.js only assert that mock.instances is empty after mockClear() / mockReset(), which trivially passes since the array is never populated in the first place. No test asserts that constructing a mock adds an entry, so the gap is silent.

Step-by-step proof

  1. const Fn = jest.fn(); const inst = new Fn();
  2. JSC dispatches to jsMockFunctionConstructjsMockFunctionCallOrConstruct(..., /*isConstruct*/ true).
  3. thisValue is set to a fresh empty object.
  4. The function pushes to calls, contexts, invocationCallOrder, and returnValues, then returns encodeReturn(jsUndefined())thisValue.
  5. inst is the fresh object, but Fn.mock.instances is still [] — in Jest it would be [inst].

Why this is pre-existing

This gap predates the PR entirely. Before this change, jsMockFunctionCall was used for both [[Call]] and [[Construct]] and likewise never touched fn->instances. The PR neither introduces nor worsens the behavior; its scope is fixing a debug-build assertion crash, and it succeeds at that. This comment is raised only because the new explicit construct branch is the obvious and correct place to add the missing push, and the PR description references matching "Jest's behavior" for construct.

Suggested fix (follow-up)

In the if (isConstruct) block (or alongside the other array pushes), push thisValue onto fn->instances, mirroring the pattern used for contexts:

if (isConstruct) {
    thisValue = JSC::constructEmptyObject(globalObject);
    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);
    }
}

(Note: Jest actually pushes this for every invocation, not just construct calls, so a fuller fix might push unconditionally — but that's a larger compat decision and definitely follow-up territory.)

auto encodeReturn = [&](JSValue returnValue) -> JSC::EncodedJSValue {
if (isConstruct && !returnValue.isObject()) [[unlikely]]
return JSValue::encode(thisValue);
return JSValue::encode(returnValue);
};
JSC::JSArray* argumentsArray = nullptr;
{
JSC::ObjectInitializationScope object(vm);
Expand Down Expand Up @@ -954,16 +963,16 @@ JSC_DEFINE_HOST_FUNCTION(jsMockFunctionCall, (JSGlobalObject * lexicalGlobalObje
fn->returnValues.set(vm, fn, returnValuesArray);
}

return JSValue::encode(returnValue);
return encodeReturn(returnValue);
}
case JSMockImplementation::Kind::ReturnValue: {
JSValue returnValue = impl->underlyingValue.get();
setReturnValue(createMockResult(vm, globalObject, "return"_s, returnValue));
return JSValue::encode(returnValue);
return encodeReturn(returnValue);
}
case JSMockImplementation::Kind::ReturnThis: {
setReturnValue(createMockResult(vm, globalObject, "return"_s, thisValue));
return JSValue::encode(thisValue);
return encodeReturn(thisValue);
}
case JSMockImplementation::Kind::RejectedValue: {
JSValue rejectedPromise = JSC::JSPromise::rejectedPromise(globalObject, impl->underlyingValue.get());
Expand All @@ -978,7 +987,17 @@ JSC_DEFINE_HOST_FUNCTION(jsMockFunctionCall, (JSGlobalObject * lexicalGlobalObje
}

setReturnValue(createMockResult(vm, globalObject, "return"_s, jsUndefined()));
return JSValue::encode(jsUndefined());
return encodeReturn(jsUndefined());
}

JSC_DEFINE_HOST_FUNCTION(jsMockFunctionCall, (JSGlobalObject * lexicalGlobalObject, CallFrame* callframe))
{
return jsMockFunctionCallOrConstruct(lexicalGlobalObject, callframe, false);
}

JSC_DEFINE_HOST_FUNCTION(jsMockFunctionConstruct, (JSGlobalObject * lexicalGlobalObject, CallFrame* callframe))
{
return jsMockFunctionCallOrConstruct(lexicalGlobalObject, callframe, true);
}

void JSMockFunctionPrototype::finishCreation(JSC::VM& vm, JSC::JSGlobalObject* globalObject)
Expand Down
72 changes: 72 additions & 0 deletions test/js/bun/test/mock-fn-construct.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { describe, expect, jest, spyOn, test } from "bun:test";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 nit: Per CLAUDE.md (root and test/CLAUDE.md), tests should be added to the existing file rather than creating a new one. test/js/bun/test/mock-fn.test.js already covers jest.fn() behavior — these construct tests would fit there as a new describe block (and that file is wired up via test-interop.js to run under real Jest/Vitest, which would also validate the parity claim).

Extended reasoning...

The repo's contributor guidelines explicitly call out test placement. Root CLAUDE.md (line 55) says "Default: add your test to the existing test file for the code you're changing. Do not create a new file," and test/CLAUDE.md (line 152) repeats the same rule. This PR creates a brand-new test/js/bun/test/mock-fn-construct.test.ts instead of extending the existing test suite for mock functions.

The existing canonical file is test/js/bun/test/mock-fn.test.js (~1000 lines), which already exercises jest.fn(), mock(), spyOn(), mockReturnValue, mockReturnThis, and friends. The new Reflect.construct cases are squarely in that file's domain — a new describe("Reflect.construct on mock functions") block would slot in naturally alongside the existing coverage.

There's also a concrete benefit beyond convention: mock-fn.test.js is intentionally written to run under Bun, real Jest, and Vitest via test-interop.js. The PR description claims the new construct semantics "match ordinary [[Construct]] semantics and Jest's behavior." Putting the tests in the cross-runner file would actually verify that claim against real Jest, whereas the new standalone file imports from bun:test directly and only ever runs under Bun.

Step-by-step:

  1. PR adds tests for jest.fn() construct behavior → falls under "mock function behavior."
  2. test/js/bun/test/mock-fn.test.js exists and is the designated home for that surface area.
  3. CLAUDE.md says: existing file → add there, don't create a new one.
  4. New file was created instead → convention violation.

The only minor friction is that the new file uses TypeScript annotations (this: { x: number }, as never, Record<string, unknown>) while the existing file is .js. These are trivial to strip — function () { this.x = 1 } works fine without the this annotation, and the casts can be dropped or replaced with // @ts-ignore / plain {}. Not enough to justify a separate file.

Fix: Move the describe("Reflect.construct on mock functions") block into test/js/bun/test/mock-fn.test.js, drop the TS-only syntax, and delete mock-fn-construct.test.ts. Non-blocking — purely organizational.


describe("Reflect.construct on mock functions", () => {
test("jest.fn() with no implementation", () => {
const fn = jest.fn();
const result = Reflect.construct(fn, []);
expect(typeof result).toBe("object");
expect(result).not.toBe(fn);
});

test("jest.fn() with implementation returning a primitive", () => {
const fn = jest.fn(() => 42);
const result = Reflect.construct(fn, []);
expect(typeof result).toBe("object");
});

test("jest.fn() with implementation returning an object", () => {
const obj = { a: 1 };
const fn = jest.fn(() => obj);
const result = Reflect.construct(fn, []);
expect(result).toBe(obj);
});

test("jest.fn().mockReturnValue(primitive)", () => {
const fn = jest.fn().mockReturnValue(42);
const result = Reflect.construct(fn, []);
expect(typeof result).toBe("object");
});

test("jest.fn().mockReturnThis()", () => {
const fn = jest.fn().mockReturnThis();
const result = Reflect.construct(fn, []);
expect(typeof result).toBe("object");
});

test("spyOn an undefined property", () => {
const obj: Record<string, unknown> = {};
const spy = spyOn(obj, "x" as never);
const result = Reflect.construct(spy, []);
expect(typeof result).toBe("object");
});

test("with explicit newTarget", () => {
const fn = jest.fn();
const result = Reflect.construct(fn, [], function () {});
expect(typeof result).toBe("object");
});

test("implementation receives constructed this", () => {
const fn = jest.fn(function (this: { x: number }) {
this.x = 1;
});
const result = Reflect.construct(fn, []);
expect(result).toEqual({ x: 1 });
});

test("new on jest.fn() returns an object", () => {
const fn = jest.fn();
const result = new (fn as any)();
expect(typeof result).toBe("object");
expect(result).not.toBe(fn);
});

test("regular call preserves this", () => {
const fn = jest.fn(function (this: unknown) {
return this;
});
const obj = { fn };
expect(obj.fn()).toBe(obj);
expect(fn.call(123 as any)).toBe(123);
});
});
Loading