-
Notifications
You must be signed in to change notification settings - Fork 4.7k
bun:test: return an object when constructing a mock function #31370
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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); | ||
|
|
@@ -462,7 +463,7 @@ | |
| } | ||
|
|
||
| JSMockFunction(JSC::VM& vm, JSC::Structure* structure, CallbackKind wrapKind) | ||
| : Base(vm, structure, jsMockFunctionCall, jsMockFunctionCall) | ||
| : Base(vm, structure, jsMockFunctionCall, jsMockFunctionConstruct) | ||
| { | ||
| initMock(); | ||
| } | ||
|
|
@@ -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); | ||
|
|
@@ -838,7 +839,6 @@ | |
| } | ||
|
|
||
| JSC::ArgList args = JSC::ArgList(callframe); | ||
| JSValue thisValue = callframe->thisValue(); | ||
| JSC::JSArray* argumentsArray = nullptr; | ||
| { | ||
| JSC::ObjectInitializationScope object(vm); | ||
|
|
@@ -981,6 +981,34 @@ | |
| 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); | ||
|
|
||
| // [[Construct]] must return an object: create `this` from new.target's prototype, | ||
| // run the mock, and return the mock's result only if it is an object. | ||
| JSObject* newTarget = asObject(callframe->newTarget()); | ||
| JSGlobalObject* functionGlobalObject = getFunctionRealm(lexicalGlobalObject, newTarget); | ||
| RETURN_IF_EXCEPTION(scope, {}); | ||
| Structure* structure = InternalFunction::createSubclassStructure(lexicalGlobalObject, newTarget, functionGlobalObject->objectStructureForObjectConstructor()); | ||
| RETURN_IF_EXCEPTION(scope, {}); | ||
| JSObject* thisObject = JSC::constructEmptyObject(vm, structure); | ||
|
Check warning on line 1001 in src/jsc/bindings/JSMockFunction.cpp
|
||
|
|
||
| JSValue returnValue = JSValue::decode(jsMockFunctionCallImpl(lexicalGlobalObject, callframe, thisObject)); | ||
| RETURN_IF_EXCEPTION(scope, {}); | ||
| if (returnValue && returnValue.isObject()) { | ||
| return JSValue::encode(returnValue); | ||
| } | ||
|
|
||
| return JSValue::encode(thisObject); | ||
|
Check warning on line 1009 in src/jsc/bindings/JSMockFunction.cpp
|
||
|
Comment on lines
+1001
to
+1009
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Now that Extended reasoning...What's missing
How Jest behavesIn Jest, Why it surfaces with this PRThis is strictly a pre-existing gap — on Step-by-step exampleWith this PR applied: const fn = jest.fn(function () { this.x = 42; });
const inst = new fn();
ImpactNot a regression and not a crash — Suggested fixIn |
||
| } | ||
|
|
||
| void JSMockFunctionPrototype::finishCreation(JSC::VM& vm, JSC::JSGlobalObject* globalObject) | ||
| { | ||
| Base::finishCreation(vm); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Now that
new fn()returns an object, the common Jest assertionexpect(new fn()).toBeInstanceOf(fn)is reachable — but it throwsTypeErrorbecauseJSMockFunctionnever installs a.prototypeown property, socreateSubclassStructurefalls back toObject.prototypeandOrdinaryHasInstancerejects the non-objectfn.prototype. The missing.prototypeis pre-existing and the construct callback itself is spec-correct, so this can be a follow-up: give eachJSMockFunctiona fresh.prototypeobject (as Jest's plain-JSmockConstructorgets automatically) so the default-new.targetcase wires up the prototype chain.Extended reasoning...
What the bug is
jsMockFunctionConstructderives the new instance's structure viaInternalFunction::createSubclassStructure(lexicalGlobalObject, newTarget, functionGlobalObject->objectStructureForObjectConstructor()). WhennewTargetis the mock function itself (the ordinarynew fn()case),createSubclassStructurereadsnewTarget.prototype. ButJSMockFunctionis anInternalFunctionsubclass whosecreate()only callsBase::finishCreation(vm)and setsm_originalName— it never doesputDirectWithoutTransition(vm, vm.propertyNames->prototype, ...)the way other BunInternalFunctionconstructors (e.g.JSBufferList,NodeVMScript,JSX509Certificate) do, andInternalFunction::finishCreationdoes not auto-create one. Sofn.prototypeisundefined.Code path that triggers it
Step-by-step:
new fn()entersjsMockFunctionConstructwithcallframe->newTarget() === fn.createSubclassStructuredoesnewTarget->get(..., vm.propertyNames->prototype)→undefined(no own.prototype, nothing onJSMockFunctionPrototype/Function.prototype).objectStructureForObjectConstructor(), whose prototype isObject.prototype.constructEmptyObjectproducesthisObjectwith[[Prototype]] = Object.prototype.inst instanceof fnruns OrdinaryHasInstance: step 4 readsfn.prototype→undefined; step 5 ("If P is not an Object, throw a TypeError") throws.Why existing code doesn't prevent it
The construct callback correctly implements
OrdinaryCreateFromConstructor(fall back to%Object.prototype%whennewTarget.prototypeis not an object), so it is spec-compliant given its inputs. The gap is upstream: unlike a plain JSfunction mockConstructor() {}— which is what Jest uses and which gets an auto-created.prototypeobject —JSMockFunctionnever installs one. The new test only checkstypeof instance === "object"andReflect.construct(fn5, [], Base)(whereBasesupplies the prototype), so the default-new.targetprototype chain is never asserted.Impact
The PR description motivates this change with "Jest's class-mocking pattern (
const instance = new MockedClass())", but the canonical assertion for that pattern —expect(instance).toBeInstanceOf(MockedClass)— throws in Bun while passing in Jest/Vitest. This is a real Jest-compat gap that becomes reachable for the first time now thatnew fn()returns an object instead ofundefined.That said, the root cause is pre-existing:
jest.fn().prototypewas alreadyundefinedbefore this PR, and({}) instanceof jest.fn()already threwTypeError. This PR strictly improves behavior (assertion crash /undefined→ an object), and the construct callback itself is correct.How to fix
Give each
JSMockFunctiona fresh.prototypeobject inJSMockFunction::create(e.g.function->putDirect(vm, vm.propertyNames->prototype, constructEmptyObject(globalObject), PropertyAttribute::DontEnum)), mirroring what JSC does for ordinary JS functions and what other BunInternalFunctionconstructors do explicitly. With that in place,createSubclassStructurewill pick upfn.prototype,Object.getPrototypeOf(new fn()) === fn.prototype, andinstanceof/toBeInstanceOfwork as in Jest. This is somewhat orthogonal to the construct callback added here, so it's reasonable as a follow-up rather than a blocker for this crash fix.