bun:test: return an object when constructing a mock function#31373
bun:test: return an object when constructing a mock function#31373robobun wants to merge 1 commit into
Conversation
|
Updated 8:13 PM PT - May 24th, 2026
❌ @robobun, your commit a170803 has 2 failures in
🧪 To try this PR locally: bunx bun-pr 31373That installs a local version of the PR into your bun-31373 --bun |
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (2)
WalkthroughThis PR adds proper constructor support to Jest mock functions in Bun's JSC implementation. It refactors the mock call logic into a shared internal helper, implements a dedicated construct handler that derives the correct ChangesMock function constructor implementation
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
| JSObject* thisObject = prototype ? JSC::constructEmptyObject(lexicalGlobalObject, prototype) : JSC::constructEmptyObject(lexicalGlobalObject); | ||
|
|
||
| JSValue result = JSValue::decode(jsMockFunctionCallImpl(lexicalGlobalObject, callframe, thisObject)); | ||
| RETURN_IF_EXCEPTION(scope, {}); | ||
|
|
||
| // [[Construct]] must return an object, so fall back to the newly created | ||
| // instance when the mock implementation produced a primitive. | ||
| if (result && result.isObject()) | ||
| return JSValue::encode(result); | ||
| return JSValue::encode(thisObject); |
There was a problem hiding this comment.
🟣 Pre-existing, not introduced here: mock.instances is exposed on the mock object but is never populated — jsMockFunctionCallImpl pushes to calls/contexts/invocationCallOrder/returnValues but never to fn->instances. Now that mocks have a real [[Construct]] path, users will reach for fn.mock.instances[0] (the canonical Jest API for constructor calls) and find it empty. Might be worth pushing thisValue into instances alongside contexts while you're in here, but it shouldn't block this crash fix.
Extended reasoning...
What the bug is
JSMockFunction declares a WriteBarrier<JSArray> instances field, lazily creates it via getInstances(), exposes it at offset 2 of the mock-object structure (the mock.instances property), clears it in clear(), and visits it in GC. But nothing in jsMockFunctionCallImpl (or anywhere else in the file) ever pushes to it. So fn.mock.instances is always an empty array regardless of how many times the mock is called or constructed.
In Jest, mock.instances records the this value for every invocation — for new fn() that's the newly created instance, and for plain calls it's whatever this was bound to. It is the documented way to assert on constructed instances (e.g. expect(Foo.mock.instances[0]).toBe(a)).
Code path
Walk through const fn = jest.fn(); const inst = new fn(); after this PR:
jsMockFunctionConstructruns, buildsthisObjectfromnew.target.prototype.- It calls
jsMockFunctionCallImpl(globalObject, callframe, thisObject). - Inside the impl,
fn->callsgets[ [] ],fn->contextsgets[thisObject],fn->invocationCallOrdergets[n], andfn->returnValuesgets[{type:'return', value:undefined}]. - There is no statement of the form
fn->instances...->push(...)orinstances->initializeIndex(...)— grep forinstancesin the file and you'll find only the declaration,clear(),getInstances(), the GC visit, and the structure offset. jsMockFunctionConstructreturnsthisObject.fn.mock.instancesis still[];fn.mock.contextsis[inst].
The PR's own test asserts expect(fn.mock.contexts[0]).toBe(instance) rather than fn.mock.instances[0], which is consistent with instances being empty.
Why existing code doesn't prevent it
There simply is no write site. The array is wired up end-to-end on the read side (lazy creation, structure slot, GC) but the population step was never implemented. Before this PR it mattered less because constructing a mock didn't have proper semantics anyway (it returned primitives / asserted in debug). Now that [[Construct]] is real, the gap becomes user-visible in the exact scenario this PR enables.
Impact
Tests ported from Jest that do things like:
const Ctor = jest.fn();
const a = new Ctor();
expect(Ctor.mock.instances).toContain(a); // fails: instances is []will fail under bun:test even though new Ctor() now behaves correctly. The workaround is mock.contexts, but that's not what Jest users typically reach for when testing constructors.
How to fix
In jsMockFunctionCallImpl, mirror the contexts push for instances: after pushing thisValue to fn->contexts, also push it to fn->instances (lazily creating the array the same way). Jest records this in instances for every call, not just constructor calls, so doing it unconditionally in the shared impl matches upstream semantics and keeps instances.length === calls.length.
Severity
Pre-existing. instances was never populated before this PR either; this change just makes the omission relevant. It shouldn't block landing the crash fix, but it's a natural follow-up (or a one-line addition here if the author prefers).
What does this PR do?
Fixes a debug assertion (
ASSERTION FAILED: isCell()inJSValue::asCell()) found by fuzzing, triggered by constructing abun:testmock function whose implementation produces a primitive:JSMockFunctionregisteredjsMockFunctionCallas both its call and construct callback, so[[Construct]]returned whatever the mock implementation produced — includingundefinedand other primitives. JSC requires native constructors to return an object:Interpreter::executeConstruct()ends withasObject(result), which asserts in debug builds (and is a type confusion in release builds) when the result isn't a cell. Through the bytecode path,new mock()silently evaluated to a primitive, which violates constructor semantics.How did you fix it?
Gave
JSMockFunctiona dedicated construct entry point that follows ordinary constructor semantics, matching how Jest'smockConstructorbehaves as a plain JS function:new.target's.prototype(falling back toObject.prototype),this(somock.contextsrecords the instance and implementations see it asthis),The call path is unchanged.
Tests
Added
mock constructor callstests totest/js/bun/test/mock-fn.test.jscoveringnew/Reflect.constructon mocks with no implementation, primitive-returning implementations, object-returning implementations, and a spy on a missing property. The first three fail on currentmain(primitives escape fromnew); the spy case crashes debug builds. The original fuzzer reproduction runs cleanly with this change.