From d95dccf5812d4d78d1fc2e00fdc2c78edc208e79 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 8 Nov 2026 09:08:05 +0000 Subject: [PATCH 01/16] bun test: add jest.replaceProperty() The handle it returns is a JSMockFunction spy registered in activeSpies, so jest.restoreAllMocks(), the restoreMocks option and the end of a test file restore it the way they restore a spy. The target holds a plain value and not the mock, so a vector of Strong handles on JSMockModule roots the mock until it is restored. On an object with a put() of its own (process.env, import.meta.env, a typed array) the value is assigned, as Jest assigns it. Elsewhere it is defined with the attributes the property has. spyOn's target resolution moves into resolveSpiedProperty(), which both functions share. --- docs/test/mocks.mdx | 25 +- packages/bun-types/test.d.ts | 21 +- src/jsc/bindings/JSMockFunction.cpp | 521 ++++++++++++++++---- src/jsc/bindings/JSMockFunction.h | 10 + test/cli/test/isolation.test.ts | 121 +++++ test/integration/bun-types/fixture/mocks.ts | 12 + test/js/bun/test/mock-fn.test.js | 320 ++++++++++++ test/js/bun/test/vi-utils.test.ts | 19 +- 8 files changed, 949 insertions(+), 100 deletions(-) diff --git a/docs/test/mocks.mdx b/docs/test/mocks.mdx index 61f5b4ec0993..2655bf757444 100644 --- a/docs/test/mocks.mdx +++ b/docs/test/mocks.mdx @@ -268,6 +268,29 @@ test("spy on a getter", () => { This also works on plain properties, such as `spyOn(process, "platform", "get")`, and on accessors inherited from a class. +### Replacing a Property with jest.replaceProperty() + +Use `jest.replaceProperty()` to give a data property another value for a while. It returns a handle with `replaceValue()` to change the value again and `restore()` to put the original back. `jest.restoreAllMocks()`, the `restoreMocks` option and the end of the test file restore it too. + +```ts title="test.ts" icon="/icons/typescript.svg" +import { test, expect, jest } from "bun:test"; + +const config = { mode: "production", retries: 1 }; + +test("replace a property", () => { + const mode = jest.replaceProperty(config, "mode", "test"); + expect(config.mode).toBe("test"); + + mode.replaceValue("staging"); + expect(config.mode).toBe("staging"); + + mode.restore(); + expect(config.mode).toBe("production"); +}); +``` + +As in Jest, the property must exist and be configurable. For a method use `spyOn(object, "method")`, and for a getter or setter use `spyOn(object, "property", "get")` or `"set"`. Unlike Jest, Bun also replaces a property that is not writable, and keeps its attributes. + ### Classes Calling a mock with `new` constructs its implementation, so you can mock or spy on a class the same way as a function. @@ -728,7 +751,7 @@ Call `mock.restore()` in an `afterEach` block, or in your test preload script, i ### Cleanup After Each Test File -When a test file ends, Bun restores any spies that are still active and clears every mock's recorded calls. Return values and implementations set with `mockReturnValue()`, `mockImplementation()` and similar methods stay until you reset the mock. A mock in a module that several test files import keeps them from one file to the next. +When a test file ends, Bun restores any spies and replaced properties that are still active and clears every mock's recorded calls. Return values and implementations set with `mockReturnValue()`, `mockImplementation()` and similar methods stay until you reset the mock. A mock in a module that several test files import keeps them from one file to the next. Spies and mocks made in a `--preload` script, or in one of its `beforeAll()` hooks, aren't restored or cleared. They stay the same for every test file. diff --git a/packages/bun-types/test.d.ts b/packages/bun-types/test.d.ts index 5802792ca63a..e5fc7c1e345e 100644 --- a/packages/bun-types/test.d.ts +++ b/packages/bun-types/test.d.ts @@ -334,6 +334,23 @@ declare module "bun:test" { obj: T, methodOrPropertyValue: K, ): Mock any>>; + /** + * Replaces a data property of `object` with `value`. The property must exist, be configurable, and hold + * neither a function (use `jest.spyOn(object, key)`) nor an accessor (use `jest.spyOn(object, key, "get")`). + * + * `jest.restoreAllMocks()`, the `restoreMocks` option and the end of the test file put the original back. + * + * @example + * const config = { mode: "production" }; + * const mode = jest.replaceProperty(config, "mode", "test"); + * mode.replaceValue("staging"); + * mode.restore(); + */ + function replaceProperty( + object: T, + propertyKey: K, + value: T[K], + ): Replaced; /** * Constructs the type of a mock function, such as the return type of `jest.fn()`. @@ -3263,7 +3280,9 @@ declare module "bun:test" { // export type MockFunctionMetadataType = MockMetadataType; type MockFunctionResult = - MockFunctionResultIncomplete | MockFunctionResultReturn | MockFunctionResultThrow; + | MockFunctionResultIncomplete + | MockFunctionResultReturn + | MockFunctionResultThrow; interface MockFunctionResultIncomplete { type: "incomplete"; diff --git a/src/jsc/bindings/JSMockFunction.cpp b/src/jsc/bindings/JSMockFunction.cpp index ec12ab280441..6914adf3118a 100644 --- a/src/jsc/bindings/JSMockFunction.cpp +++ b/src/jsc/bindings/JSMockFunction.cpp @@ -27,6 +27,8 @@ #include #include #include +#include +#include #include #include #include @@ -112,6 +114,8 @@ JSC_DECLARE_HOST_FUNCTION(jsMockFunctionGetMockName); JSC_DECLARE_HOST_FUNCTION(jsMockFunctionMockClear); JSC_DECLARE_HOST_FUNCTION(jsMockFunctionMockReset); JSC_DECLARE_HOST_FUNCTION(jsMockFunctionMockRestore); +JSC_DECLARE_HOST_FUNCTION(jsReplacedPropertyRestore); +JSC_DECLARE_HOST_FUNCTION(jsReplacedPropertyReplaceValue); JSC_DECLARE_HOST_FUNCTION(jsMockFunctionMockImplementation); JSC_DECLARE_HOST_FUNCTION(jsMockFunctionMockImplementationOnce); JSC_DECLARE_HOST_FUNCTION(jsMockFunctionMockName); @@ -355,6 +359,37 @@ static void putOwnPropertyStorage(JSGlobalObject* globalObject, JSObject* object static constexpr unsigned descriptorAttributes = PropertyAttribute::ReadOnly | PropertyAttribute::DontEnum | PropertyAttribute::DontDelete | PropertyAttribute::Accessor; +// `object[key] = value` in strict mode: what an object's own put() makes of it, such as process.env's conversion to a string. +static void assignProperty(JSGlobalObject* globalObject, JSObject* object, PropertyName key, JSValue value) +{ + PutPropertySlot slot(object, true); + object->methodTable()->put(object, globalObject, key, value, slot); +} + +// True if `object` is a module namespace object. Its exports are replaced in the module that declares them. +// `callframe` is that of the spyOn() or replaceProperty() call, for the message of a compiled executable that refuses. +static bool replaceExportOfNamespace(JSGlobalObject* globalObject, JSC::CallFrame* callframe, JSObject* object, const Identifier& propertyKey, JSValue replacement) +{ + if (JSModuleNamespaceObject* moduleNamespaceObject = tryJSDynamicCast(object)) { + moduleNamespaceObject->overrideExportValue(globalObject, propertyKey, replacement); + return true; + } +#if ENABLE(AOT) + if (JSC::AOT::isModuleNamespace(object)) { + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + bool isReplaced = JSC::AOT::tryReplaceExport(object, propertyKey.impl(), replacement); + RETURN_IF_EXCEPTION(scope, true); + if (!isReplaced) + throwTypeError(globalObject, scope, JSC::AOT::refusedSpyOnExportMessage(object, propertyKey.impl(), Bun::fileOfCallerThatWasNotCompiled(vm, callframe))); + return true; + } +#else + UNUSED_PARAM(callframe); +#endif + return false; +} + static void putOwnProperty(JSGlobalObject* globalObject, JSObject* object, PropertyName key, const PropertyDescriptor& descriptor) { JSValue storage = descriptor.isAccessorDescriptor() ? GetterSetter::create(globalObject->vm(), globalObject, descriptor.getter(), descriptor.setter()) : descriptor.value(); @@ -450,12 +485,16 @@ class JSMockFunction final : public JSC::InternalFunction { bool hasLazyPrototype { true }; // Preloads and their beforeAll() hooks run once for all test files, so the end of a test file leaves it alone. bool isMadeByPreload { false }; + // Made by jest.replaceProperty(). It is never called. mockModule().replacedProperties roots it until it is restored. + bool isReplacedProperty { false }; static constexpr unsigned SpyAttributeESModuleNamespace = 1 << 30; // The spy is the property's getter or setter, or what its getter returns. static constexpr unsigned SpyAttributeAccessor = 1 << 29; // isOpaqueProperty() was true at install time, so restore the same way. static constexpr unsigned SpyAttributeOpaque = 1 << 28; + // The replacement was assigned, as `object[key] = value`, because the object has its own put(). The restore assigns too. + static constexpr unsigned SpyAttributeAssigned = 1 << 27; JSString* jsName() { @@ -865,6 +904,37 @@ class JSMockFunction final : public JSC::InternalFunction { bool isRestoredSpy() const { return isSpy && !m_spyTarget; } + bool isInstalledOn(JSObject* target, const Identifier& key) const + { + return m_spyTarget && m_spyTarget.get() == target && m_spyIdentifier == key; + } + + // Writes another value where a replaced property is installed. False once the property is restored. + bool putReplacement(JSGlobalObject* globalObject, JSC::CallFrame* callframe, JSValue value) + { + JSObject* target = m_spyTarget.get(); + if (!target) + return false; + unsigned attributes = m_spyAttributes; + if (attributes & SpyAttributeAssigned) { + assignProperty(globalObject, target, m_spyIdentifier, value); + return true; + } + bool isOpaque = attributes & SpyAttributeOpaque; + ThroughLockOfBuiltins throughLock(globalObject->vm(), isOpaque); + if (attributes & SpyAttributeESModuleNamespace) { + replaceExportOfNamespace(globalObject, callframe, target, m_spyIdentifier, value); + return true; + } + // The original may be a custom property, whose attributes would make putOwnPropertyStorage() treat the value as its getter. + attributes &= PropertyAttribute::ReadOnly | PropertyAttribute::DontEnum | PropertyAttribute::DontDelete; + // A replacement of an inherited property is an own property that the restore deletes. + if (!m_spyOriginal) + attributes &= ~PropertyAttribute::DontDelete; + putOwnPropertyStorage(globalObject, target, m_spyIdentifier, value, attributes, isOpaque); + return true; + } + // Puts the spied-on property back as it was; that can throw (a module namespace export, an indexed slot). void clearSpy(JSGlobalObject* globalObject, bool shouldReset = true) { @@ -954,6 +1024,14 @@ class JSMockFunction final : public JSC::InternalFunction { m_spyIdentifier = JSC::Identifier(); m_spyAttributes = 0; leave(List::Spies); + if (isReplacedProperty) [[unlikely]] + forgetReplacedProperty(); + } + + NEVER_INLINE void forgetReplacedProperty() + { + auto& replaced = mockModule().replacedProperties; + replaced.removeFirstMatching([&](auto& entry) { return entry.mock.get() == this; }); } void setFallbackImplementation(JSMockImplementation::Kind kind, JSValue value) @@ -1003,6 +1081,15 @@ class JSMockFunction final : public JSC::InternalFunction { return; } + if (attributes & SpyAttributeAssigned) { + scope.release(); + if (original) + assignProperty(globalObject, target, identifier, original); + else + JSCell::deleteProperty(target, globalObject, identifier); + return; + } + if (attributes & SpyAttributeAccessor) { // Restoring the spy on the accessor's other half already put both halves back. unsigned currentAttributes = 0; @@ -1412,6 +1499,14 @@ JSMockModule JSMockModule::create(JSC::JSGlobalObject* globalObject) [](const JSC::LazyProperty::Initializer& init) { init.set(JSC::JSFunction::create(init.vm, init.owner, 2, String(), jsMockFunctionWithImplementationCleanup, ImplementationVisibility::Public)); }); + mock.replacedPropertyRestoreFunction.initLater( + [](const JSC::LazyProperty::Initializer& init) { + init.set(JSC::JSFunction::create(init.vm, init.owner, 0, "restore"_s, jsReplacedPropertyRestore, ImplementationVisibility::Public)); + }); + mock.replacedPropertyReplaceValueFunction.initLater( + [](const JSC::LazyProperty::Initializer& init) { + init.set(JSC::JSFunction::create(init.vm, init.owner, 1, "replaceValue"_s, jsReplacedPropertyReplaceValue, ImplementationVisibility::Public)); + }); mock.mockWithImplementationCleanupDataStructure.initLater( [](const JSC::LazyProperty::Initializer& init) { init.set(Bun::MockWithImplementationCleanupData::createStructure(init.vm, init.owner, init.owner->objectPrototype())); @@ -2984,84 +3079,54 @@ enum class SpyAccess : uint8_t { Set, }; -__attribute__((minsize)) static JSC::EncodedJSValue spyOn(JSC::JSGlobalObject* lexicalGlobalObject, JSC::CallFrame* callframe, bool isVitest) +// The object a spy or a replacement goes on, and what `key` is there. +struct SpiedProperty { + JSObject* object { nullptr }; + // Set when `isEasy` and the property exists: a data property's value, read without running script unless the object is opaque. + JSValue value; + // Filled when the property is not a plain data property, or the object is opaque. + PropertyDescriptor descriptor; + unsigned slotAttributes { 0 }; + bool hasValue { false }; + bool isOwn { false }; + bool isOpaque { false }; + // A data property or a missing one, spied as a value. Otherwise an accessor, a custom property, or a getter or setter spy. + bool isEasy { false }; +}; + +// Some objects stand for another one (a GlobalProxy, a node:vm global, a mocked module's namespace): the spy goes on that one. +// `function` names the caller in error messages. +__attribute__((minsize)) static void resolveSpiedProperty(Zig::GlobalObject* globalObject, JSObject* object, const Identifier& propertyKey, SpyAccess access, ASCIILiteral function, SpiedProperty& result) { - auto& vm = JSC::getVM(lexicalGlobalObject); + auto& vm = JSC::getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); - auto* globalObject = dynamicDowncast(lexicalGlobalObject); - if (!globalObject) [[unlikely]] { - throwVMError(lexicalGlobalObject, scope, "Cannot run spyOn from a different global context"_s); - return {}; - } - - JSValue objectValue = callframe->argument(0); - JSValue propertyKeyValue = callframe->argument(1); - JSValue accessTypeValue = callframe->argument(2); - - if (callframe->argumentCount() < 2 || !objectValue.isObject()) { - throwVMError(globalObject, scope, "spyOn(target, prop) expects a target object and a property key"_s); - return {}; - } - - Identifier propertyKey = propertyKeyValue.toPropertyKey(globalObject); - RETURN_IF_EXCEPTION(scope, {}); - - if (propertyKey.isNull()) { - throwVMError(globalObject, scope, "spyOn(target, prop) expects a property key"_s); - return {}; - } - - SpyAccess access = SpyAccess::Value; - if (accessTypeValue.toBoolean(globalObject)) { - if (accessTypeValue.isString()) { - auto accessType = asString(accessTypeValue)->view(globalObject); - RETURN_IF_EXCEPTION(scope, {}); - if (accessType.data == "get"_s) - access = SpyAccess::Get; - else if (accessType.data == "set"_s) - access = SpyAccess::Set; - } - if (access == SpyAccess::Value) { - throwTypeError(globalObject, scope, "spyOn(target, prop, accessType) expects accessType to be \"get\" or \"set\""_s); - return {}; - } - } - - JSC::JSObject* object = objectValue.getObject(); if (object->type() == JSC::JSType::GlobalProxyType) object = uncheckedDowncast(object)->target(); // A WebAssembly GC object's Structure can't change, so the direct stores below would abort. if (object->type() == JSC::JSType::WebAssemblyGCObjectType) { - throwTypeError(globalObject, scope, "spyOn(target, prop) does not support WebAssembly GC objects"_s); - return {}; + throwTypeError(globalObject, scope, makeString(function, " does not support WebAssembly GC objects"_s)); + return; } // A node:vm global reads a key that the contextified object inherits from that object, but says it has no such key. So spy there. if (auto* contextGlobal = dynamicDowncast(object)) { if (JSObject* contextified = contextGlobal->contextifiedObject()) { bool isOwn = contextified->hasOwnProperty(globalObject, propertyKey); - RETURN_IF_EXCEPTION(scope, {}); + RETURN_IF_EXCEPTION(scope, ); bool isInherited = !isOwn && contextified->hasProperty(globalObject, propertyKey); - RETURN_IF_EXCEPTION(scope, {}); + RETURN_IF_EXCEPTION(scope, ); if (isInherited) object = contextified; } } - // In tests `import.meta.env` is backed by `process.env`, so spy there. - if (object->structure()->typeInfo().prohibitsPropertyCaching()) [[unlikely]] { - if (JSObject* env = processEnvOfImportMetaEnvForTests(globalObject, object)) - object = env; - RETURN_IF_EXCEPTION(scope, {}); - } - // A module mock's exports are read live from the object its factory returned, so spy on that object. #if ENABLE(AOT) while (globalObject->aotInstance()) { JSObject* exports = Bun::objectHoldingExportOfCompiledModuleMock(globalObject, object, propertyKey); - RETURN_IF_EXCEPTION(scope, {}); + RETURN_IF_EXCEPTION(scope, ); if (!exports) break; object = exports; @@ -3069,7 +3134,7 @@ __attribute__((minsize)) static JSC::EncodedJSValue spyOn(JSC::JSGlobalObject* l #endif while (auto* moduleNamespaceObject = dynamicDowncast(object)) { JSObject* exports = Bun::objectHoldingExportOfModuleMock(globalObject, moduleNamespaceObject, propertyKey); - RETURN_IF_EXCEPTION(scope, {}); + RETURN_IF_EXCEPTION(scope, ); if (!exports) break; object = exports; @@ -3077,33 +3142,32 @@ __attribute__((minsize)) static JSC::EncodedJSValue spyOn(JSC::JSGlobalObject* l if (auto* target = dynamicDowncast(object); target && propertyKey == vm.propertyNames->prototype) { target->prototypeProperty(globalObject); - RETURN_IF_EXCEPTION(scope, {}); + RETURN_IF_EXCEPTION(scope, ); } JSC::PropertySlot slot(object, JSC::PropertySlot::InternalMethodType::HasProperty); bool hasValue = object->getPropertySlot(globalObject, propertyKey, slot); - RETURN_IF_EXCEPTION(scope, {}); + RETURN_IF_EXCEPTION(scope, ); // A String object's indices and `length` are own properties, but come without a slotBase(). bool isOwn = hasValue && (!slot.slotBase() || slot.slotBase() == object); // An object of compiled code has what its layout lacks, such as an index key, in a second object, which is then the slotBase(). if (hasValue && !isOwn && vm.hasCompiledCode()) [[unlikely]] { isOwn = object->hasOwnProperty(globalObject, propertyKey); - RETURN_IF_EXCEPTION(scope, {}); + RETURN_IF_EXCEPTION(scope, ); } unsigned slotAttributes = hasValue ? slot.attributes() : 0; bool isEasy = access == SpyAccess::Value && (!hasValue || slot.isValue()); - PropertyDescriptor descriptor; bool isOpaque = isOpaqueProperty(globalObject, object, propertyKey); - RETURN_IF_EXCEPTION(scope, {}); + RETURN_IF_EXCEPTION(scope, ); if (hasValue && (!isEasy || isOpaque)) { - JSObject* holder = findPropertyDescriptor(globalObject, object, propertyKey, descriptor); - RETURN_IF_EXCEPTION(scope, {}); + JSObject* holder = findPropertyDescriptor(globalObject, object, propertyKey, result.descriptor); + RETURN_IF_EXCEPTION(scope, ); // An opaque property's slot only says that the property exists, so go by the descriptor. if (isOpaque) { isOwn = holder == object; - slotAttributes = descriptor.attributes() & descriptorAttributes; - isEasy = access == SpyAccess::Value && descriptor.isDataDescriptor(); + slotAttributes = result.descriptor.attributes() & descriptorAttributes; + isEasy = access == SpyAccess::Value && result.descriptor.isDataDescriptor(); } } @@ -3120,45 +3184,103 @@ __attribute__((minsize)) static JSC::EncodedJSValue spyOn(JSC::JSGlobalObject* l if (message.isNull()) message = makeString("Cannot spy on '"_s, String(propertyKey.impl()), "' because this property of a built-in object is read-only in a compiled executable. Spy on a function of your own that calls it (https://bun.com/docs/compiler/compatibility#built-in-objects-are-read-only)"_s); throwTypeError(globalObject, scope, message); - return {}; + return; } #endif + JSValue value; + if (isEasy && hasValue) { + if (slot.isTaintedByOpaqueObject()) [[unlikely]] { + // if it's a Proxy or JSModuleNamespaceObject + value = object->get(globalObject, propertyKey); + RETURN_IF_EXCEPTION(scope, ); + } else { + value = slot.getValue(globalObject, propertyKey); + RETURN_IF_EXCEPTION(scope, ); + } + } - // True if the object is a module namespace object. Its exports are replaced in the module that declares them. - auto replaceExport = [&](JSValue replacement) { - if (JSModuleNamespaceObject* moduleNamespaceObject = tryJSDynamicCast(object)) { - moduleNamespaceObject->overrideExportValue(globalObject, propertyKey, replacement); - return true; + result.object = object; + result.value = value; + result.slotAttributes = slotAttributes; + result.hasValue = hasValue; + result.isOwn = isOwn; + result.isOpaque = isOpaque; + result.isEasy = isEasy; +} + +__attribute__((minsize)) static JSC::EncodedJSValue spyOn(JSC::JSGlobalObject* lexicalGlobalObject, JSC::CallFrame* callframe, bool isVitest) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + auto* globalObject = dynamicDowncast(lexicalGlobalObject); + if (!globalObject) [[unlikely]] { + throwVMError(lexicalGlobalObject, scope, "Cannot run spyOn from a different global context"_s); + return {}; + } + + JSValue objectValue = callframe->argument(0); + JSValue propertyKeyValue = callframe->argument(1); + JSValue accessTypeValue = callframe->argument(2); + + if (callframe->argumentCount() < 2 || !objectValue.isObject()) { + throwVMError(globalObject, scope, "spyOn(target, prop) expects a target object and a property key"_s); + return {}; + } + + Identifier propertyKey = propertyKeyValue.toPropertyKey(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + + if (propertyKey.isNull()) { + throwVMError(globalObject, scope, "spyOn(target, prop) expects a property key"_s); + return {}; + } + + SpyAccess access = SpyAccess::Value; + if (accessTypeValue.toBoolean(globalObject)) { + if (accessTypeValue.isString()) { + auto accessType = asString(accessTypeValue)->view(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + if (accessType.data == "get"_s) + access = SpyAccess::Get; + else if (accessType.data == "set"_s) + access = SpyAccess::Set; } -#if ENABLE(AOT) - if (JSC::AOT::isModuleNamespace(object)) { - bool isReplaced = JSC::AOT::tryReplaceExport(object, propertyKey.impl(), replacement); - RETURN_IF_EXCEPTION(scope, true); - if (!isReplaced) - throwTypeError(globalObject, scope, JSC::AOT::refusedSpyOnExportMessage(object, propertyKey.impl(), Bun::fileOfCallerThatWasNotCompiled(vm, callframe))); - return true; + if (access == SpyAccess::Value) { + throwTypeError(globalObject, scope, "spyOn(target, prop, accessType) expects accessType to be \"get\" or \"set\""_s); + return {}; } -#endif - return false; + } + + JSObject* target = objectValue.getObject(); + // In tests `import.meta.env` is backed by `process.env`, so spy there. + if (target->structure()->typeInfo().prohibitsPropertyCaching()) [[unlikely]] { + if (JSObject* env = processEnvOfImportMetaEnvForTests(globalObject, target)) + target = env; + RETURN_IF_EXCEPTION(scope, {}); + } + + SpiedProperty property; + resolveSpiedProperty(globalObject, target, propertyKey, access, "spyOn(target, prop)"_s, property); + RETURN_IF_EXCEPTION(scope, {}); + JSObject* object = property.object; + PropertyDescriptor& descriptor = property.descriptor; + bool hasValue = property.hasValue; + bool isOwn = property.isOwn; + bool isOpaque = property.isOpaque; + bool isEasy = property.isEasy; + unsigned slotAttributes = property.slotAttributes; + + auto replaceExport = [&](JSValue replacement) { + return replaceExportOfNamespace(globalObject, callframe, object, propertyKey, replacement); }; // easymode: regular property or missing property if (isEasy) { - JSValue value = jsUndefined(); - if (hasValue) { - if (slot.isTaintedByOpaqueObject()) [[unlikely]] { - // if it's a Proxy or JSModuleNamespaceObject - value = object->get(globalObject, propertyKey); - RETURN_IF_EXCEPTION(scope, {}); - } else { - value = slot.getValue(globalObject, propertyKey); - RETURN_IF_EXCEPTION(scope, {}); - } - - if (dynamicDowncast(value)) { - return JSValue::encode(value); - } + JSValue value = hasValue ? property.value : jsUndefined(); + if (dynamicDowncast(value)) { + return JSValue::encode(value); } bool isNamespace = isModuleNamespace(object); @@ -3287,6 +3409,218 @@ __attribute__((minsize)) static JSC::EncodedJSValue spyOn(JSC::JSGlobalObject* l return JSValue::encode(mock); } +// The `Replaced` handle of jest.replaceProperty(): `{ restore, replaceValue }`, bound to the mock that remembers the original. +// Both work when destructured. The target and the key travel as bound arguments, so that replaceValue() can install the +// replacement again after a restore. +static JSObject* createReplacedPropertyHandle(Zig::GlobalObject* globalObject, JSMockFunction* mock, JSObject* target, const Identifier& propertyKey) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + auto& mockModule = globalObject->mockModule; + + JSObject* handle = JSC::constructEmptyObject(globalObject); + MarkedArgumentBuffer boundArguments; + boundArguments.append(handle); + boundArguments.append(target); + boundArguments.append(identifierToJSValue(vm, propertyKey)); + + auto* restore = JSC::JSBoundFunction::create(vm, globalObject, mockModule.replacedPropertyRestoreFunction.getInitializedOnMainThread(globalObject), mock, ArgList(), 0, nullptr, makeSource("restore"_s, SourceOrigin(), SourceTaintedOrigin::Untainted)); + RETURN_IF_EXCEPTION(scope, nullptr); + auto* replaceValue = JSC::JSBoundFunction::create(vm, globalObject, mockModule.replacedPropertyReplaceValueFunction.getInitializedOnMainThread(globalObject), mock, boundArguments, 1, nullptr, makeSource("replaceValue"_s, SourceOrigin(), SourceTaintedOrigin::Untainted)); + RETURN_IF_EXCEPTION(scope, nullptr); + handle->putDirect(vm, Identifier::fromString(vm, "restore"_s), restore); + handle->putDirect(vm, Identifier::fromString(vm, "replaceValue"_s), replaceValue); + return handle; +} + +// The entry of an earlier jest.replaceProperty() on the same object and key that is not restored yet. +static JSMockModule::ReplacedProperty* findReplacedProperty(Zig::GlobalObject* globalObject, JSObject* target, const Identifier& propertyKey) +{ + for (auto& entry : globalObject->mockModule.replacedProperties) { + if (uncheckedDowncast(entry.mock.get())->isInstalledOn(target, propertyKey)) + return &entry; + } + return nullptr; +} + +// jest.replaceProperty(object, key, value): Jest 30's checks, in its order and with its messages. A function is for spyOn(), an accessor for spyOn(..., "get" | "set"). +// Jest assigns the value. So does this on an object with a put() of its own, such as process.env, a typed array or import.meta.env. +// On any other object the value is defined with the attributes the property has, so a read-only data property can be replaced too. +// `mock` and `handle` are those of an earlier replacement that was restored, whose replaceValue() installs it again. Returns the handle. +__attribute__((minsize)) static JSValue replaceProperty(Zig::GlobalObject* globalObject, JSC::CallFrame* callframe, JSObject* target, const Identifier& propertyKey, JSValue value, JSMockFunction* mock, JSObject* handle) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + SpiedProperty property; + resolveSpiedProperty(globalObject, target, propertyKey, SpyAccess::Value, "replaceProperty(target, prop, value)"_s, property); + RETURN_IF_EXCEPTION(scope, {}); + JSObject* object = property.object; + String keyForMessage = propertyKeyForMessage(propertyKey); + + if (!property.hasValue) { + throwVMError(globalObject, scope, makeString("Property `"_s, keyForMessage, "` does not exist in the provided object"_s)); + return {}; + } + // In tests `import.meta.env` shows Vite's variables with a default that `process.env` doesn't have. The restore deletes those. + if (JSObject* env = processEnvOfImportMetaEnvForTests(globalObject, object)) { + property.isOwn = env->hasOwnProperty(globalObject, propertyKey); + RETURN_IF_EXCEPTION(scope, {}); + } + + bool isNamespace = isModuleNamespace(object); + // What the replacement is defined with. The restore writes the original back with the attributes of its storage. + unsigned attributes = property.slotAttributes; + JSValue original = property.value; + if (!property.isEasy) { + // A getter, a setter, or a property that a native getter serves. + const PropertyDescriptor& descriptor = property.descriptor; + if (descriptor.isAccessorDescriptor()) { + if (!descriptor.configurable()) { + throwVMError(globalObject, scope, makeString("Property `"_s, keyForMessage, "` is not declared configurable"_s)); + return {}; + } + bool hasGetter = descriptor.getterPresent() && !descriptor.getter().isUndefined(); + throwVMError(globalObject, scope, makeString("Cannot replace the `"_s, keyForMessage, "` property because it has a "_s, hasGetter ? "getter"_s : "setter"_s, ". Use `jest.spyOn(object, '"_s, keyForMessage, "', '"_s, hasGetter ? "get"_s : "set"_s, "').mockReturnValue(value)` instead."_s)); + return {}; + } + attributes = descriptor.attributes() & descriptorAttributes; + original = descriptor.value(); + } + // An export of a module namespace object is not configurable, but overrideExportValue() replaces it all the same. + if (!isNamespace && (attributes & PropertyAttribute::DontDelete)) { + throwVMError(globalObject, scope, makeString("Property `"_s, keyForMessage, "` is not declared configurable"_s)); + return {}; + } + if (original.isCallable()) { + throwTypeError(globalObject, scope, makeString("Cannot replace the `"_s, keyForMessage, "` property because it is a function. Use `jest.spyOn(object, '"_s, keyForMessage, "')` instead."_s)); + return {}; + } + + if (auto* existing = findReplacedProperty(globalObject, object, propertyKey)) { + uncheckedDowncast(existing->mock.get())->putReplacement(globalObject, callframe, value); + RETURN_IF_EXCEPTION(scope, {}); + return handle ? handle : existing->handle.get(); + } + + bool isAssigned = !isNamespace && !property.isOpaque && object->structure()->typeInfo().overridesPut(); +#if ENABLE(AOT) + // A locked built-in takes a direct store only, under the lock. + if (object->structure()->mayHaveImmutableProperties()) + isAssigned = false; +#endif + + unsigned spyAttributes = attributes; + // A property that a native getter serves is restored from its storage, not from the value the getter returned. + if (!property.isEasy && property.isOwn && !isAssigned) { + original = getOwnPropertyStorage(globalObject, object, propertyKey, spyAttributes); + RETURN_IF_EXCEPTION(scope, {}); + } + // A replacement that shadows an inherited property is deleted on restore, so it has to be configurable. + if (!property.isOwn) + attributes &= ~PropertyAttribute::DontDelete; + if (isNamespace) + spyAttributes |= JSMockFunction::SpyAttributeESModuleNamespace; + if (property.isOpaque) + spyAttributes |= JSMockFunction::SpyAttributeOpaque; + if (isAssigned) + spyAttributes |= JSMockFunction::SpyAttributeAssigned; + + if (!mock) { + mock = createMockFunction(vm, globalObject, false); + mock->isJest = true; + mock->isReplacedProperty = true; + } + if (isAssigned) { + assignProperty(globalObject, object, propertyKey, value); + RETURN_IF_EXCEPTION(scope, {}); + } else { + ThroughLockOfBuiltins throughLock(vm, property.isOpaque); + if (!replaceExportOfNamespace(globalObject, callframe, object, propertyKey, value)) + putOwnPropertyStorage(globalObject, object, propertyKey, value, attributes, property.isOpaque); + RETURN_IF_EXCEPTION(scope, {}); + } + mock->didInstall(object, propertyKey, property.isOwn ? original : JSValue(), spyAttributes); + + if (!handle) { + handle = createReplacedPropertyHandle(globalObject, mock, object, propertyKey); + RETURN_IF_EXCEPTION(scope, {}); + } + globalObject->mockModule.replacedProperties.append({ JSC::Strong(vm, mock), JSC::Strong(vm, handle) }); + return handle; +} + +JSC_DEFINE_HOST_FUNCTION_WITH_ATTRIBUTES(jsJestReplaceProperty, __attribute__((minsize)), (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callframe)) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + auto* globalObject = dynamicDowncast(lexicalGlobalObject); + if (!globalObject) [[unlikely]] { + throwVMError(lexicalGlobalObject, scope, "Cannot run replaceProperty from a different global context"_s); + return {}; + } + + JSValue objectValue = callframe->argument(0); + JSValue propertyKeyValue = callframe->argument(1); + JSValue value = callframe->argument(2); + + if (!objectValue.isObject()) { + JSString* type = objectValue.isUndefinedOrNull() ? (objectValue.isNull() ? vm.smallStrings.nullString() : vm.smallStrings.undefinedString()) : jsTypeStringForValue(globalObject, objectValue); + auto typeView = type->view(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + throwVMError(globalObject, scope, makeString("Cannot use replaceProperty on a primitive value; "_s, typeView.data, " given"_s)); + return {}; + } + if (propertyKeyValue.isUndefinedOrNull()) { + throwVMError(globalObject, scope, "No property name supplied"_s); + return {}; + } + Identifier propertyKey = propertyKeyValue.toPropertyKey(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + + RELEASE_AND_RETURN(scope, JSValue::encode(replaceProperty(globalObject, callframe, objectValue.getObject(), propertyKey, value, nullptr, nullptr))); +} + +namespace Bun { + +JSC_DEFINE_HOST_FUNCTION(jsReplacedPropertyRestore, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callframe)) +{ + auto scope = DECLARE_THROW_SCOPE(globalObject->vm()); + auto* mock = dynamicDowncast(callframe->thisValue()); + if (!mock) [[unlikely]] + return JSValue::encode(jsUndefined()); + mock->clearSpy(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + return JSValue::encode(jsUndefined()); +} + +// Bound arguments: the handle to return, the target, the key. The value follows them. +JSC_DEFINE_HOST_FUNCTION(jsReplacedPropertyReplaceValue, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callframe)) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + auto* mock = dynamicDowncast(callframe->thisValue()); + JSValue handle = callframe->argument(0); + if (!mock) [[unlikely]] + return JSValue::encode(handle); + JSValue value = callframe->argument(3); + + bool isInstalled = mock->putReplacement(lexicalGlobalObject, callframe, value); + RETURN_IF_EXCEPTION(scope, {}); + if (!isInstalled) { + // Restored already. Jest assigns the value. This installs the replacement again, so that restore() still puts the original back. + JSObject* target = callframe->argument(1).getObject(); + Identifier propertyKey = callframe->argument(2).toPropertyKey(lexicalGlobalObject); + RETURN_IF_EXCEPTION(scope, {}); + replaceProperty(uncheckedDowncast(mock->globalObject()), callframe, target, propertyKey, value, mock, handle.getObject()); + RETURN_IF_EXCEPTION(scope, {}); + } + return JSValue::encode(handle); +} + +} // namespace Bun + BUN_DEFINE_HOST_FUNCTION(JSMock__jsSpyOn, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callframe)) { return spyOn(globalObject, callframe, false); @@ -3378,6 +3712,7 @@ extern "C" __attribute__((minsize)) void JSMock__putMockFunctionUtilities(Zig::G vi->putDirectNativeFunction(vm, globalObject, Identifier::fromString(vm, "spyOn"_s), 2, jsVitestSpyOn, ImplementationVisibility::Public, NoIntrinsic, 0); jest->putDirectNativeFunction(vm, globalObject, Identifier::fromString(vm, "fn"_s), 1, jsJestFn, ImplementationVisibility::Public, NoIntrinsic, 0); jest->putDirectNativeFunction(vm, globalObject, Identifier::fromString(vm, "spyOn"_s), 2, jsJestSpyOn, ImplementationVisibility::Public, NoIntrinsic, 0); + jest->putDirectNativeFunction(vm, globalObject, Identifier::fromString(vm, "replaceProperty"_s), 3, jsJestReplaceProperty, ImplementationVisibility::Public, NoIntrinsic, 0); } #undef CHECK_IS_MOCK_FUNCTION diff --git a/src/jsc/bindings/JSMockFunction.h b/src/jsc/bindings/JSMockFunction.h index cb3e034016fd..c10438921aab 100644 --- a/src/jsc/bindings/JSMockFunction.h +++ b/src/jsc/bindings/JSMockFunction.h @@ -39,6 +39,8 @@ class JSMockModule final { V(Structure, mockModuleStructure) \ V(Structure, activeSpySetStructure) \ V(JSFunction, withImplementationCleanupFunction) \ + V(JSFunction, replacedPropertyRestoreFunction) \ + V(JSFunction, replacedPropertyReplaceValueFunction) \ V(JSC::Structure, mockWithImplementationCleanupDataStructure) \ V(JSCell, lazyPrototype) \ V(JSString, defaultName) \ @@ -68,6 +70,14 @@ class JSMockModule final { // A JSMap from the promises given to didStartDynamicImport() to their keys. Settled ones get swept. JSC::WriteBarrier dynamicImports; + // The mock behind each jest.replaceProperty() that is not restored yet, and the `Replaced` handle it returned. + // The target holds the replacement value and not the mock, so nothing else roots the mock until it is restored. + struct ReplacedProperty { + JSC::Strong mock; + JSC::Strong handle; + }; + Vector replacedProperties; + // True until the first test starts. A preload, or a beforeAll() of one, can only run before that. bool preloadMayBeRunning { true }; diff --git a/test/cli/test/isolation.test.ts b/test/cli/test/isolation.test.ts index 154bfab4154f..a6b2da770395 100644 --- a/test/cli/test/isolation.test.ts +++ b/test/cli/test/isolation.test.ts @@ -3105,6 +3105,127 @@ describe.concurrent("mocks and spies do not outlive their test file", () => { expect({ results: results(stderr), exitCode }).toEqual({ results: ["(pass) a", "(pass) b"], exitCode: 0 }); }); + const replaced = `[[process, "platform"], [shared, "value"], [Shared.prototype, "inheritedValue"], [shared, "inheritedValue"]]`; + const replacedFiles = { + "shared.ts": ` + export class Shared {} + Shared.prototype.inheritedValue = "original"; + export const shared = Object.assign(new Shared(), { value: "original" }); + `, + "a.test.ts": ` + import { expect, jest, test } from "bun:test"; + import { shared, Shared } from "./shared.ts"; + test("a", () => { + for (const [object, key] of ${replaced}) jest.replaceProperty(object, key, "replaced"); + expect(${replaced}.map(([object, key]) => object[key])).toEqual(["replaced", "replaced", "replaced", "replaced"]); + expect(Object.hasOwn(shared, "inheritedValue")).toBe(true); + }); + `, + "b.test.ts": ` + import { expect, test } from "bun:test"; + import { shared, Shared } from "./shared.ts"; + test("b", () => { + expect(${replaced}.map(([object, key]) => object[key])).toEqual([process.env.ISOLATION_PLATFORM, "original", "original", "original"]); + expect(Object.hasOwn(shared, "inheritedValue")).toBe(false); + }); + `, + }; + + test.each(modes)("replaced properties are restored: bun test %s", async flags => { + using dir = tempDir("replaced-of-a-file", replacedFiles); + const { stderr, exitCode } = await runTests(String(dir), flags.split(" "), ["./a.test.ts", "./b.test.ts"], { + ...bunEnv, + ISOLATION_PLATFORM: process.platform, + }); + expect({ results: results(stderr), exitCode }, stderr).toEqual({ results: ["(pass) a", "(pass) b"], exitCode: 0 }); + }); + + test("replaced properties of a file that fails to load are restored", async () => { + using dir = tempDir("replaced-of-a-file", { + ...replacedFiles, + "a.test.ts": ` + import { jest } from "bun:test"; + import { shared, Shared } from "./shared.ts"; + for (const [object, key] of ${replaced}) jest.replaceProperty(object, key, "replaced"); + throw new Error("thrown while loading"); + `, + }); + const { stderr, exitCode } = await runTests(String(dir), [], ["./a.test.ts", "./b.test.ts"], { + ...bunEnv, + ISOLATION_PLATFORM: process.platform, + }); + expect(stderr).toContain("error: thrown while loading"); + expect({ results: results(stderr), exitCode }, stderr).toEqual({ results: ["(pass) b"], exitCode: 1 }); + }); + + test("a replaced property that can't be restored is reported once, under the file that made it", async () => { + using dir = tempDir("replaced-of-a-file", { + "a.test.ts": ` + import { jest, test } from "bun:test"; + test("a", () => { + let isOver = false; + globalThis.target = new Proxy({ value: "original" }, { + defineProperty(...args) { + if (isOver) throw new Error("the trap refuses"); + return Reflect.defineProperty(...args); + }, + }); + jest.replaceProperty(target, "value", "replaced"); + isOver = true; + }); + `, + "b.test.ts": ` + import { expect, jest, test } from "bun:test"; + test("b", () => { + expect(target.value).toBe("replaced"); + jest.restoreAllMocks(); + expect(target.value).toBe("replaced"); + }); + `, + }); + const { stderr, exitCode } = await runTests(String(dir), [], ["./a.test.ts", "./b.test.ts"]); + const lines = stderr.split("\n").filter(line => /^(error: |[ab]\.test\.ts:$|\((pass|fail)\) )/.test(line)); + expect( + lines.map(line => line.replace(/ \[[\d.]+ms\]$/, "")), + stderr, + ).toEqual(["a.test.ts:", "(pass) a", "error: the trap refuses", "b.test.ts:", "(pass) b"]); + expect(exitCode).toBe(1); + }); + + test("properties replaced by a preload or its beforeAll() are kept across files", async () => { + using dir = tempDir("replaced-of-a-preload", { + "preload.ts": ` + import { beforeAll, jest } from "bun:test"; + globalThis.target = { loading: "original", hook: "original", file: "original" }; + jest.replaceProperty(target, "loading", "replaced"); + beforeAll(() => { + jest.replaceProperty(target, "hook", "replaced"); + }); + `, + "a.test.ts": ` + import { expect, jest, test } from "bun:test"; + test("a", () => { + jest.replaceProperty(target, "file", "replaced"); + expect(target).toEqual({ loading: "replaced", hook: "replaced", file: "replaced" }); + }); + `, + "b.test.ts": ` + import { expect, jest, test } from "bun:test"; + test("b", () => { + expect(target).toEqual({ loading: "replaced", hook: "replaced", file: "original" }); + jest.restoreAllMocks(); + expect(target).toEqual({ loading: "original", hook: "original", file: "original" }); + }); + `, + }); + const { stderr, exitCode } = await runTests( + String(dir), + ["--preload", "./preload.ts"], + ["./a.test.ts", "./b.test.ts"], + ); + expect({ results: results(stderr), exitCode }, stderr).toEqual({ results: ["(pass) a", "(pass) b"], exitCode: 0 }); + }); + test("a preload's afterAll() sees the calls made in every file", async () => { using dir = tempDir("mocks-of-a-preload", { "preload.ts": ` diff --git a/test/integration/bun-types/fixture/mocks.ts b/test/integration/bun-types/fixture/mocks.ts index d0ccb679ca1f..e355707d37dd 100644 --- a/test/integration/bun-types/fixture/mocks.ts +++ b/test/integration/bun-types/fixture/mocks.ts @@ -46,6 +46,18 @@ expectType>>().is num expectType>>().is number>>(); expectType>>().is number>>(); +// jest.replaceProperty() types the value and the handle after the property, as in Jest. +const config = { mode: "production" as "production" | "test", retries: 1 }; +const replaced = jest.replaceProperty(config, "mode", "test"); +expectType(replaced).is>(); +expectType(replaced.replaceValue("production")).is>(); +expectType(replaced.restore()).is(); +expectType(jest.replaceProperty(process.env, "NODE_ENV", "test")).is>(); +// @ts-expect-error the value has the property's type +jest.replaceProperty(config, "retries", "many"); +// @ts-expect-error the property must exist +jest.replaceProperty(config, "notAProperty", 1); + // Same for `ReturnType`. expectType>().is any>>(); expectType>().is any>>(); diff --git a/test/js/bun/test/mock-fn.test.js b/test/js/bun/test/mock-fn.test.js index 76e14c699481..3879cbe48de0 100644 --- a/test/js/bun/test/mock-fn.test.js +++ b/test/js/bun/test/mock-fn.test.js @@ -2246,6 +2246,326 @@ describe("spyOn", () => { }); }); +// Vitest has no replaceProperty. +(isBun || jest.replaceProperty ? describe : describe.skip)("replaceProperty", () => { + test("replaces a data property and restores it", () => { + const target = { value: 1 }; + const replaced = jest.replaceProperty(target, "value", 2); + expect(target.value).toBe(2); + expect(Object.keys(replaced).sort()).toEqual(["replaceValue", "restore"]); + expect(replaced.replaceValue(3)).toBe(replaced); + expect(target.value).toBe(3); + expect(replaced.restore()).toBeUndefined(); + expect(Object.getOwnPropertyDescriptor(target, "value")).toEqual({ + value: 1, + writable: true, + enumerable: true, + configurable: true, + }); + replaced.restore(); + expect(target.value).toBe(1); + }); + + test("keeps the attributes of the property", () => { + const target = Object.defineProperty({}, "value", { + value: 1, + enumerable: false, + writable: true, + configurable: true, + }); + const replaced = jest.replaceProperty(target, "value", 2); + expect(target.value).toBe(2); + expect(Object.keys(target)).toEqual([]); + replaced.restore(); + expect(Object.getOwnPropertyDescriptor(target, "value")).toEqual({ + value: 1, + writable: true, + enumerable: false, + configurable: true, + }); + }); + + // Jest assigns the value, which a read-only property refuses. + test.skipIf(!isBun)("replaces a read-only property, which stays read-only", () => { + const target = Object.defineProperty({}, "value", { value: 1, writable: false, configurable: true }); + const replaced = jest.replaceProperty(target, "value", 2); + expect(target.value).toBe(2); + expect(() => { + "use strict"; + target.value = 3; + }).toThrow(TypeError); + replaced.restore(); + expect(Object.getOwnPropertyDescriptor(target, "value")).toEqual({ + value: 1, + writable: false, + enumerable: false, + configurable: true, + }); + }); + + test("an index of an array", () => { + const target = [1, 2, 3]; + const replaced = jest.replaceProperty(target, 1, 20); + expect(target).toEqual([1, 20, 3]); + replaced.restore(); + expect(target).toEqual([1, 2, 3]); + }); + + test("an element of a typed array converts the value as an assignment does", () => { + const target = new Uint8Array([1, 2]); + const replaced = jest.replaceProperty(target, 1, { valueOf: () => 20 }); + expect(target).toEqual(new Uint8Array([1, 20])); + replaced.replaceValue("30"); + expect(target).toEqual(new Uint8Array([1, 30])); + replaced.restore(); + expect(target).toEqual(new Uint8Array([1, 2])); + }); + + test("process.env converts the value to a string, as an assignment does", () => { + process.env.REPLACE_PROPERTY_TEST = "original"; + const replaced = jest.replaceProperty(process.env, "REPLACE_PROPERTY_TEST", 1); + expect(process.env.REPLACE_PROPERTY_TEST).toBe("1"); + replaced.replaceValue(true); + expect(process.env.REPLACE_PROPERTY_TEST).toBe("true"); + replaced.restore(); + expect(process.env.REPLACE_PROPERTY_TEST).toBe("original"); + delete process.env.REPLACE_PROPERTY_TEST; + }); + + test("restore and replaceValue work when destructured", () => { + const target = { value: 1 }; + const { restore, replaceValue } = jest.replaceProperty(target, "value", 2); + replaceValue(5); + expect(target.value).toBe(5); + restore(); + expect(target.value).toBe(1); + }); + + test("an inherited property is shadowed, and the shadow deleted on restore", () => { + const prototype = { value: 1 }; + const target = Object.create(prototype); + const replaced = jest.replaceProperty(target, "value", 2); + expect(target.value).toBe(2); + expect(Object.hasOwn(target, "value")).toBe(true); + expect(prototype.value).toBe(1); + replaced.restore(); + expect(Object.hasOwn(target, "value")).toBe(false); + expect(target.value).toBe(1); + }); + + test("the same object and key give the same handle, and one restore", () => { + const target = { value: 1 }; + const first = jest.replaceProperty(target, "value", 2); + const second = jest.replaceProperty(target, "value", 3); + expect(second).toBe(first); + expect(target.value).toBe(3); + first.restore(); + expect(target.value).toBe(1); + }); + + test("replaceValue on the shadow of an inherited property", () => { + const prototype = { value: 1 }; + const target = Object.create(prototype); + const replaced = jest.replaceProperty(target, "value", 2); + replaced.replaceValue(3); + expect([target.value, prototype.value, Object.hasOwn(target, "value")]).toEqual([3, 1, true]); + replaced.restore(); + expect([target.value, prototype.value, Object.hasOwn(target, "value")]).toEqual([1, 1, false]); + }); + + test("a function object is a valid target", () => { + function target() {} + target.count = 1; + const replaced = jest.replaceProperty(target, "count", 2); + expect(target.count).toBe(2); + replaced.restore(); + expect(target.count).toBe(1); + }); + + test("a value of any type, a function included", () => { + const target = { value: 1 }; + const fn = () => 2; + const replaced = jest.replaceProperty(target, "value", fn); + expect(target.value).toBe(fn); + replaced.replaceValue(undefined); + expect(target.value).toBeUndefined(); + expect("value" in target).toBe(true); + replaced.restore(); + expect(target.value).toBe(1); + }); + + test("restoreAllMocks restores every replaced property, even one whose handle is gone", () => { + const target = { a: 1, b: 1 }; + jest.replaceProperty(target, "a", 2); + const replaced = jest.replaceProperty(target, "b", 2); + if (isBun) Bun.gc(true); + expect(target).toEqual({ a: 2, b: 2 }); + jest.restoreAllMocks(); + expect(target).toEqual({ a: 1, b: 1 }); + // Restoring again changes nothing. + replaced.restore(); + target.b = 3; + jest.restoreAllMocks(); + expect(target).toEqual({ a: 1, b: 3 }); + }); + + test("resetAllMocks and clearAllMocks leave a replaced property alone", () => { + const target = { value: 1 }; + jest.replaceProperty(target, "value", 2); + jest.clearAllMocks(); + jest.resetAllMocks(); + expect(target.value).toBe(2); + jest.restoreAllMocks(); + expect(target.value).toBe(1); + }); + + test("replaceValue after restore replaces the property again, and restore puts the original back again", () => { + const target = { value: 1 }; + const replaced = jest.replaceProperty(target, "value", 2); + replaced.restore(); + expect(replaced.replaceValue(9)).toBe(replaced); + expect(target.value).toBe(9); + replaced.restore(); + expect(target.value).toBe(1); + // As in a describe() with afterEach(() => replaced.restore()), whose tests call replaceValue(). + replaced.replaceValue(10); + expect(target.value).toBe(10); + jest.restoreAllMocks(); + expect(target.value).toBe(1); + }); + + test("a symbol key", () => { + const key = Symbol("value"); + const target = { [key]: 1 }; + const replaced = jest.replaceProperty(target, key, 2); + expect(target[key]).toBe(2); + replaced.restore(); + expect(target[key]).toBe(1); + }); + + test("refuses what Jest refuses, with Jest's messages", () => { + const thrownBy = (object, key) => { + try { + jest.replaceProperty(object, key, 2); + } catch (error) { + return [error.constructor, error.message]; + } + return "no error"; + }; + const symbol = Symbol("value"); + expect(thrownBy(null, "value")).toEqual([Error, "Cannot use replaceProperty on a primitive value; null given"]); + expect(thrownBy(undefined, "value")).toEqual([ + Error, + "Cannot use replaceProperty on a primitive value; undefined given", + ]); + expect(thrownBy(1, "value")).toEqual([Error, "Cannot use replaceProperty on a primitive value; number given"]); + expect(thrownBy("value", "length")).toEqual([ + Error, + "Cannot use replaceProperty on a primitive value; string given", + ]); + expect(thrownBy({}, null)).toEqual([Error, "No property name supplied"]); + expect(thrownBy({}, undefined)).toEqual([Error, "No property name supplied"]); + expect(thrownBy({}, "value")).toEqual([Error, "Property `value` does not exist in the provided object"]); + expect(thrownBy({}, symbol)).toEqual([Error, "Property `Symbol(value)` does not exist in the provided object"]); + const fixed = Object.defineProperty({}, "value", { value: 1, configurable: false, writable: true }); + expect(thrownBy(fixed, "value")).toEqual([Error, "Property `value` is not declared configurable"]); + expect(thrownBy(Object.create(fixed), "value")).toEqual([Error, "Property `value` is not declared configurable"]); + expect(thrownBy(Object.freeze({ value: 1 }), "value")).toEqual([ + Error, + "Property `value` is not declared configurable", + ]); + const fixedAccessor = Object.defineProperty({}, "value", { get: () => 1, configurable: false }); + expect(thrownBy(fixedAccessor, "value")).toEqual([Error, "Property `value` is not declared configurable"]); + const getter = { + get value() { + return 1; + }, + }; + const getterMessage = + "Cannot replace the `value` property because it has a getter. Use `jest.spyOn(object, 'value', 'get').mockReturnValue(value)` instead."; + expect(thrownBy(getter, "value")).toEqual([Error, getterMessage]); + expect(thrownBy(Object.create(getter), "value")).toEqual([Error, getterMessage]); + expect(thrownBy({ get value() {}, set value(v) {} }, "value")).toEqual([Error, getterMessage]); + expect(thrownBy({ set value(v) {} }, "value")).toEqual([ + Error, + "Cannot replace the `value` property because it has a setter. Use `jest.spyOn(object, 'value', 'set').mockReturnValue(value)` instead.", + ]); + const functionMessage = + "Cannot replace the `value` property because it is a function. Use `jest.spyOn(object, 'value')` instead."; + expect(thrownBy({ value() {} }, "value")).toEqual([TypeError, functionMessage]); + expect(thrownBy(Object.create({ value() {} }), "value")).toEqual([TypeError, functionMessage]); + expect(thrownBy({ value: class {} }, "value")).toEqual([TypeError, functionMessage]); + expect(thrownBy({ [symbol]: () => {} }, symbol)).toEqual([ + TypeError, + "Cannot replace the `Symbol(value)` property because it is a function. Use `jest.spyOn(object, 'Symbol(value)')` instead.", + ]); + }); + + if (isBun) { + test("a property of a built-in object", () => { + const { platform } = process; + const replaced = jest.replaceProperty(process, "platform", "haiku"); + expect(process.platform).toBe("haiku"); + replaced.restore(); + expect(process.platform).toBe(platform); + }); + + test("an export of a module namespace object", async () => { + const ns = await import("./mock/mock-module-fixture.ts"); + const original = ns.default; + const replaced = jest.replaceProperty(ns, "default", "replaced"); + expect(ns.default).toBe("replaced"); + replaced.replaceValue("replaced again"); + expect(ns.default).toBe("replaced again"); + expect(jest.replaceProperty(ns, "default", "and again")).toBe(replaced); + expect(ns.default).toBe("and again"); + replaced.restore(); + expect(ns.default).toBe(original); + expect(() => jest.replaceProperty(ns, "fn", 1)).toThrow( + "Cannot replace the `fn` property because it is a function. Use `jest.spyOn(object, 'fn')` instead.", + ); + }); + + test("import.meta.env", () => { + expect([import.meta.env.MODE, "MODE" in process.env, import.meta.env.DEV]).toEqual(["test", false, true]); + const mode = jest.replaceProperty(import.meta.env, "MODE", "production"); + const dev = jest.replaceProperty(import.meta.env, "DEV", false); + expect([import.meta.env.MODE, process.env.MODE, import.meta.env.DEV]).toEqual([ + "production", + "production", + false, + ]); + mode.restore(); + dev.restore(); + expect([import.meta.env.MODE, "MODE" in process.env, import.meta.env.DEV, "DEV" in process.env]).toEqual([ + "test", + false, + true, + false, + ]); + }); + + test("through a Proxy", () => { + const target = { value: 1 }; + const traps = []; + const proxy = new Proxy(target, { + defineProperty(target, key, descriptor) { + traps.push(["defineProperty", key]); + return Reflect.defineProperty(target, key, descriptor); + }, + }); + const replaced = jest.replaceProperty(proxy, "value", 2); + expect(target.value).toBe(2); + replaced.restore(); + expect(target.value).toBe(1); + expect(traps).toEqual([ + ["defineProperty", "value"], + ["defineProperty", "value"], + ]); + }); + } +}); + if (isBun) { describe("new on a mock", () => { test("a class implementation constructs", () => { diff --git a/test/js/bun/test/vi-utils.test.ts b/test/js/bun/test/vi-utils.test.ts index f0dd70c59f99..c0c9a56c25d2 100644 --- a/test/js/bun/test/vi-utils.test.ts +++ b/test/js/bun/test/vi-utils.test.ts @@ -829,10 +829,10 @@ describe.concurrent("vi.setConfig", () => { describe("the options that reset mocks and stubs before each test", () => { const file = (module: string, config = "") => ` - import { beforeAll, beforeEach, test, vi } from "${module}"; + import { beforeAll, beforeEach, jest, test, vi } from "${module}"; ${config} const fn = vi.fn(() => "original"); - const object = { method: () => "original" }; + const object = { method: () => "original", property: "original" }; fn("top level"); beforeAll(() => { fn("beforeAll"); }); beforeEach(() => { fn("beforeEach"); }); @@ -840,11 +840,12 @@ describe.concurrent("vi.setConfig", () => { fn("first"); fn.mockReturnValue("changed"); vi.spyOn(object, "method").mockReturnValue("spied"); + jest.replaceProperty(object, "property", "replaced"); vi.stubEnv("STUBBED", "1"); vi.stubGlobal("stubbed", 1); }); test("second", () => { - console.log(JSON.stringify({ calls: fn.mock.calls.flat(), returns: fn(), method: object.method(), env: process.env.STUBBED, global: typeof stubbed })); + console.log(JSON.stringify({ calls: fn.mock.calls.flat(), returns: fn(), method: object.method(), property: object.property, env: process.env.STUBBED, global: typeof stubbed })); }); `; const seen = async ( @@ -875,6 +876,7 @@ describe.concurrent("vi.setConfig", () => { calls: ["top level", "beforeAll", "beforeEach", "first", "beforeEach"], returns: "changed", method: "spied", + property: "replaced", env: "1", global: "number", }; @@ -892,12 +894,19 @@ describe.concurrent("vi.setConfig", () => { test.each([ [{ clearMocks: true }, cleared], [{ mockReset: true }, { ...cleared, returns: "original", method: "original" }], - [{ restoreMocks: true }, { method: "original" }], + [{ restoreMocks: true }, { method: "original", property: "original" }], [{ unstubEnvs: true }, { env: undefined }], [{ unstubGlobals: true }, { global: "undefined" }], [ { clearMocks: true, mockReset: true, restoreMocks: true, unstubEnvs: true, unstubGlobals: true }, - { ...cleared, returns: "original", method: "original", env: undefined, global: "undefined" }, + { + ...cleared, + returns: "original", + method: "original", + property: "original", + env: undefined, + global: "undefined", + }, ], ])("%j", async (options, difference) => { expect(await seen("bun:test", options, through)).toEqual({ ...nothing, ...difference }); From 3bf021727ea2de7b3b5552e2f6981cd7eccbe955 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 8 Nov 2026 14:45:11 +0000 Subject: [PATCH 02/16] [autofix.ci] apply automated fixes --- packages/bun-types/test.d.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/bun-types/test.d.ts b/packages/bun-types/test.d.ts index e5fc7c1e345e..c72c35ee75d2 100644 --- a/packages/bun-types/test.d.ts +++ b/packages/bun-types/test.d.ts @@ -3280,9 +3280,7 @@ declare module "bun:test" { // export type MockFunctionMetadataType = MockMetadataType; type MockFunctionResult = - | MockFunctionResultIncomplete - | MockFunctionResultReturn - | MockFunctionResultThrow; + MockFunctionResultIncomplete | MockFunctionResultReturn | MockFunctionResultThrow; interface MockFunctionResultIncomplete { type: "incomplete"; From 1df36ed61df3acbf458d1e71d85afecb1a9bf254 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 8 Nov 2026 14:46:12 +0000 Subject: [PATCH 03/16] Shorten comments --- src/jsc/bindings/JSMockFunction.cpp | 22 ++++++++-------------- src/jsc/bindings/JSMockFunction.h | 3 +-- 2 files changed, 9 insertions(+), 16 deletions(-) diff --git a/src/jsc/bindings/JSMockFunction.cpp b/src/jsc/bindings/JSMockFunction.cpp index 6914adf3118a..c0500617b857 100644 --- a/src/jsc/bindings/JSMockFunction.cpp +++ b/src/jsc/bindings/JSMockFunction.cpp @@ -359,15 +359,14 @@ static void putOwnPropertyStorage(JSGlobalObject* globalObject, JSObject* object static constexpr unsigned descriptorAttributes = PropertyAttribute::ReadOnly | PropertyAttribute::DontEnum | PropertyAttribute::DontDelete | PropertyAttribute::Accessor; -// `object[key] = value` in strict mode: what an object's own put() makes of it, such as process.env's conversion to a string. +// `object[key] = value` in strict mode, so the object's own put() runs (process.env converts to a string). static void assignProperty(JSGlobalObject* globalObject, JSObject* object, PropertyName key, JSValue value) { PutPropertySlot slot(object, true); object->methodTable()->put(object, globalObject, key, value, slot); } -// True if `object` is a module namespace object. Its exports are replaced in the module that declares them. -// `callframe` is that of the spyOn() or replaceProperty() call, for the message of a compiled executable that refuses. +// True if `object` is a module namespace object, whose exports are replaced in the module that declares them. static bool replaceExportOfNamespace(JSGlobalObject* globalObject, JSC::CallFrame* callframe, JSObject* object, const Identifier& propertyKey, JSValue replacement) { if (JSModuleNamespaceObject* moduleNamespaceObject = tryJSDynamicCast(object)) { @@ -3082,7 +3081,7 @@ enum class SpyAccess : uint8_t { // The object a spy or a replacement goes on, and what `key` is there. struct SpiedProperty { JSObject* object { nullptr }; - // Set when `isEasy` and the property exists: a data property's value, read without running script unless the object is opaque. + // A data property's value, when `isEasy` and the property exists. JSValue value; // Filled when the property is not a plain data property, or the object is opaque. PropertyDescriptor descriptor; @@ -3090,12 +3089,11 @@ struct SpiedProperty { bool hasValue { false }; bool isOwn { false }; bool isOpaque { false }; - // A data property or a missing one, spied as a value. Otherwise an accessor, a custom property, or a getter or setter spy. + // A data property or a missing one, spied as a value. bool isEasy { false }; }; -// Some objects stand for another one (a GlobalProxy, a node:vm global, a mocked module's namespace): the spy goes on that one. -// `function` names the caller in error messages. +// Resolves the object that stands in for `object` (a GlobalProxy, a node:vm global, a mocked module's namespace) and describes `propertyKey` there. __attribute__((minsize)) static void resolveSpiedProperty(Zig::GlobalObject* globalObject, JSObject* object, const Identifier& propertyKey, SpyAccess access, ASCIILiteral function, SpiedProperty& result) { auto& vm = JSC::getVM(globalObject); @@ -3409,9 +3407,7 @@ __attribute__((minsize)) static JSC::EncodedJSValue spyOn(JSC::JSGlobalObject* l return JSValue::encode(mock); } -// The `Replaced` handle of jest.replaceProperty(): `{ restore, replaceValue }`, bound to the mock that remembers the original. -// Both work when destructured. The target and the key travel as bound arguments, so that replaceValue() can install the -// replacement again after a restore. +// `{ restore, replaceValue }`, bound to the mock so that both work when destructured. The bound target and key let replaceValue() install the replacement again after a restore. static JSObject* createReplacedPropertyHandle(Zig::GlobalObject* globalObject, JSMockFunction* mock, JSObject* target, const Identifier& propertyKey) { auto& vm = JSC::getVM(globalObject); @@ -3443,10 +3439,8 @@ static JSMockModule::ReplacedProperty* findReplacedProperty(Zig::GlobalObject* g return nullptr; } -// jest.replaceProperty(object, key, value): Jest 30's checks, in its order and with its messages. A function is for spyOn(), an accessor for spyOn(..., "get" | "set"). -// Jest assigns the value. So does this on an object with a put() of its own, such as process.env, a typed array or import.meta.env. -// On any other object the value is defined with the attributes the property has, so a read-only data property can be replaced too. -// `mock` and `handle` are those of an earlier replacement that was restored, whose replaceValue() installs it again. Returns the handle. +// jest.replaceProperty(object, key, value), with Jest 30's checks and messages. Returns the handle. +// `mock` and `handle` are those of a restored replacement that replaceValue() installs again. __attribute__((minsize)) static JSValue replaceProperty(Zig::GlobalObject* globalObject, JSC::CallFrame* callframe, JSObject* target, const Identifier& propertyKey, JSValue value, JSMockFunction* mock, JSObject* handle) { auto& vm = JSC::getVM(globalObject); diff --git a/src/jsc/bindings/JSMockFunction.h b/src/jsc/bindings/JSMockFunction.h index c10438921aab..1d7f5b832119 100644 --- a/src/jsc/bindings/JSMockFunction.h +++ b/src/jsc/bindings/JSMockFunction.h @@ -70,8 +70,7 @@ class JSMockModule final { // A JSMap from the promises given to didStartDynamicImport() to their keys. Settled ones get swept. JSC::WriteBarrier dynamicImports; - // The mock behind each jest.replaceProperty() that is not restored yet, and the `Replaced` handle it returned. - // The target holds the replacement value and not the mock, so nothing else roots the mock until it is restored. + // The mock behind each jest.replaceProperty() that is not restored yet, and its `Replaced` handle. Nothing else roots that mock. struct ReplacedProperty { JSC::Strong mock; JSC::Strong handle; From 7430fa7cebca8fd5e58da6119b79c0dc96c1d952 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 8 Nov 2026 14:49:08 +0000 Subject: [PATCH 04/16] One-line comment on replaceProperty() --- src/jsc/bindings/JSMockFunction.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/jsc/bindings/JSMockFunction.cpp b/src/jsc/bindings/JSMockFunction.cpp index c0500617b857..2240b54a46aa 100644 --- a/src/jsc/bindings/JSMockFunction.cpp +++ b/src/jsc/bindings/JSMockFunction.cpp @@ -3439,8 +3439,7 @@ static JSMockModule::ReplacedProperty* findReplacedProperty(Zig::GlobalObject* g return nullptr; } -// jest.replaceProperty(object, key, value), with Jest 30's checks and messages. Returns the handle. -// `mock` and `handle` are those of a restored replacement that replaceValue() installs again. +// jest.replaceProperty(object, key, value) with Jest 30's checks. Returns the handle, or the given one when replaceValue() installs a restored `mock` again. __attribute__((minsize)) static JSValue replaceProperty(Zig::GlobalObject* globalObject, JSC::CallFrame* callframe, JSObject* target, const Identifier& propertyKey, JSValue value, JSMockFunction* mock, JSObject* handle) { auto& vm = JSC::getVM(globalObject); From 1c50179e81e929f66a95e012ff8805575c84ca98 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 8 Nov 2026 15:18:32 +0000 Subject: [PATCH 05/16] WIP review fixes --- src/aot/modules/test_replacements.rs | 3 +- src/codegen/builtins/host/modules.builtins.ts | 22 ++++- src/jsc/bindings/JSMockFunction.cpp | 96 +++++++++++++++---- src/runtime/test_runner/vi_utils.rs | 2 + .../compile-trim-runtime-texts.test.ts | 9 +- test/cli/test/isolation.test.ts | 3 +- 6 files changed, 111 insertions(+), 24 deletions(-) diff --git a/src/aot/modules/test_replacements.rs b/src/aot/modules/test_replacements.rs index 94c2f59af6b5..27a47ffa42a8 100644 --- a/src/aot/modules/test_replacements.rs +++ b/src/aot/modules/test_replacements.rs @@ -480,7 +480,7 @@ fn leads_to_mocks(builtin: BuiltinId) -> bool { builtin == test_runner_object() || returns_test_runner_object(builtin) || mocks_modules(builtin) } -const MEMBERS_THAT_LEAVE_MODULES_ALONE: [&[u8]; 30] = [ +const MEMBERS_THAT_LEAVE_MODULES_ALONE: [&[u8]; 31] = [ b"advanceTimersByTime", b"advanceTimersToNextTimer", b"clearAllMocks", @@ -494,6 +494,7 @@ const MEMBERS_THAT_LEAVE_MODULES_ALONE: [&[u8]; 30] = [ b"isMockFunction", b"mocked", b"now", + b"replaceProperty", b"resetAllMocks", b"restore", b"restoreAllMocks", diff --git a/src/codegen/builtins/host/modules.builtins.ts b/src/codegen/builtins/host/modules.builtins.ts index bcab88d2ab5f..b485f80a0a9a 100644 --- a/src/codegen/builtins/host/modules.builtins.ts +++ b/src/codegen/builtins/host/modules.builtins.ts @@ -78,6 +78,26 @@ const spyOn = { } satisfies b.FunctionField; const stubbedName = b.keyFrom("name"); +// JSMock__jsReplaceProperty (jsJestReplaceProperty) keeps what the data property holds, weakly, and defines or assigns `value` under the name on the object itself. +const replacedName = b.keyFrom("name"); +const replaceProperty = { + args: { object: b.object, name: b.propertyKey.loose, value: b.any }, + returns: b.any, + capability: "js", + throws: true, + uses: { + value: b.inspected, + object: [ + b.readByKey(replacedName, b.nativeMemory), + b.definesName(replacedName, b.operand("value")), + b.deletedByKey(replacedName), + b.runs("PrototypeChain"), + ...b.ownPropertyTraps, + ], + }, + implementation: b.generic, +} satisfies b.FunctionField; + const stubGlobal = [ { args: { name: b.propertyKey.loose, value: b.any, global: b.builtin("globalThis") }, @@ -516,7 +536,7 @@ export default b.declarations( functionsOfRunner("bun:test", passedByJest), functionsOfRunner("bun:test/vitest", passedByVitest), b.fn("bun:test.spyOn", spyOn), - b.namespace("bun:test.jest", { spyOn, resetModules: b.otherwise, useFakeTimers: b.otherwise }), + b.namespace("bun:test.jest", { spyOn, replaceProperty, resetModules: b.otherwise, useFakeTimers: b.otherwise }), b.namespace("bun:test.vi", { spyOn, resetModules: b.otherwise, useFakeTimers: b.otherwise, stubGlobal }), b.fn("bun:test/vitest.spyOn", spyOn), b.namespace("bun:test/vitest.vi", { diff --git a/src/jsc/bindings/JSMockFunction.cpp b/src/jsc/bindings/JSMockFunction.cpp index 2240b54a46aa..1ad8931d79f9 100644 --- a/src/jsc/bindings/JSMockFunction.cpp +++ b/src/jsc/bindings/JSMockFunction.cpp @@ -359,6 +359,11 @@ static void putOwnPropertyStorage(JSGlobalObject* globalObject, JSObject* object static constexpr unsigned descriptorAttributes = PropertyAttribute::ReadOnly | PropertyAttribute::DontEnum | PropertyAttribute::DontDelete | PropertyAttribute::Accessor; +static String propertyKeyForMessage(const Identifier& key) +{ + return key.isSymbol() ? makeString("Symbol("_s, key.string(), ')') : key.string(); +} + // `object[key] = value` in strict mode, so the object's own put() runs (process.env converts to a string). static void assignProperty(JSGlobalObject* globalObject, JSObject* object, PropertyName key, JSValue value) { @@ -1081,11 +1086,19 @@ class JSMockFunction final : public JSC::InternalFunction { } if (attributes & SpyAttributeAssigned) { - scope.release(); - if (original) + // The original of an import.meta.env variable is the string process.env holds, which import.meta.env's put() would convert. + if (JSObject* env = processEnvOfImportMetaEnvForTests(globalObject, target)) + target = env; + RETURN_IF_EXCEPTION(scope, ); + if (original) { + scope.release(); assignProperty(globalObject, target, identifier, original); - else - JSCell::deleteProperty(target, globalObject, identifier); + return; + } + bool isDeleted = JSCell::deleteProperty(target, globalObject, identifier); + RETURN_IF_EXCEPTION(scope, ); + if (!isDeleted) + throwTypeError(globalObject, scope, makeString("Cannot restore the `"_s, propertyKeyForMessage(identifier), "` property because it cannot be deleted"_s)); return; } @@ -1365,6 +1378,12 @@ extern "C" void JSMock__didFinishTestFile(Zig::GlobalObject* globalObject) JSMockFunction::didFinishTestFile(globalObject); } +// The test file's global is thrown away with everything it replaced. The roots would keep it alive. +extern "C" void JSMock__didDiscardTestFileGlobal(Zig::GlobalObject* globalObject) +{ + globalObject->mockModule.replacedProperties.clear(); +} + JSMockModule JSMockModule::create(JSC::JSGlobalObject* globalObject) { JSMockModule mock; @@ -3046,11 +3065,6 @@ __attribute__((minsize)) static void wrapFunction(Zig::GlobalObject* globalObjec mock->setInitialImplementation(globalObject, function); } -static String propertyKeyForMessage(const Identifier& key) -{ - return key.isSymbol() ? makeString("Symbol("_s, key.string(), ')') : key.string(); -} - static String propertyKeyForFunctionName(const Identifier& key) { return key.isSymbol() ? makeString('[', key.string(), ']') : key.string(); @@ -3407,7 +3421,7 @@ __attribute__((minsize)) static JSC::EncodedJSValue spyOn(JSC::JSGlobalObject* l return JSValue::encode(mock); } -// `{ restore, replaceValue }`, bound to the mock so that both work when destructured. The bound target and key let replaceValue() install the replacement again after a restore. +// `{ restore, replaceValue }`, bound to the mock so that both work when destructured. The bound target and key serve a handle whose mock is restored already. static JSObject* createReplacedPropertyHandle(Zig::GlobalObject* globalObject, JSMockFunction* mock, JSObject* target, const Identifier& propertyKey) { auto& vm = JSC::getVM(globalObject); @@ -3420,7 +3434,7 @@ static JSObject* createReplacedPropertyHandle(Zig::GlobalObject* globalObject, J boundArguments.append(target); boundArguments.append(identifierToJSValue(vm, propertyKey)); - auto* restore = JSC::JSBoundFunction::create(vm, globalObject, mockModule.replacedPropertyRestoreFunction.getInitializedOnMainThread(globalObject), mock, ArgList(), 0, nullptr, makeSource("restore"_s, SourceOrigin(), SourceTaintedOrigin::Untainted)); + auto* restore = JSC::JSBoundFunction::create(vm, globalObject, mockModule.replacedPropertyRestoreFunction.getInitializedOnMainThread(globalObject), mock, boundArguments, 0, nullptr, makeSource("restore"_s, SourceOrigin(), SourceTaintedOrigin::Untainted)); RETURN_IF_EXCEPTION(scope, nullptr); auto* replaceValue = JSC::JSBoundFunction::create(vm, globalObject, mockModule.replacedPropertyReplaceValueFunction.getInitializedOnMainThread(globalObject), mock, boundArguments, 1, nullptr, makeSource("replaceValue"_s, SourceOrigin(), SourceTaintedOrigin::Untainted)); RETURN_IF_EXCEPTION(scope, nullptr); @@ -3430,10 +3444,13 @@ static JSObject* createReplacedPropertyHandle(Zig::GlobalObject* globalObject, J } // The entry of an earlier jest.replaceProperty() on the same object and key that is not restored yet. +// A test file replaces over a preload's replacement with a record of its own, so that the end of the file gives the preload's value back. static JSMockModule::ReplacedProperty* findReplacedProperty(Zig::GlobalObject* globalObject, JSObject* target, const Identifier& propertyKey) { + bool isInPreload = globalObject->mockModule.preloadMayBeRunning && JSMock__isInPreload(globalObject); for (auto& entry : globalObject->mockModule.replacedProperties) { - if (uncheckedDowncast(entry.mock.get())->isInstalledOn(target, propertyKey)) + auto* mock = uncheckedDowncast(entry.mock.get()); + if (mock->isInstalledOn(target, propertyKey) && (isInPreload || !mock->isMadeByPreload)) return &entry; } return nullptr; @@ -3455,19 +3472,34 @@ __attribute__((minsize)) static JSValue replaceProperty(Zig::GlobalObject* globa throwVMError(globalObject, scope, makeString("Property `"_s, keyForMessage, "` does not exist in the provided object"_s)); return {}; } - // In tests `import.meta.env` shows Vite's variables with a default that `process.env` doesn't have. The restore deletes those. - if (JSObject* env = processEnvOfImportMetaEnvForTests(globalObject, object)) { + // In tests `import.meta.env` is a view of `process.env` with Vite's variables converted, and with a default for the ones it lacks. + // The original is what `process.env` holds, and the restore deletes a default. + JSObject* env = processEnvOfImportMetaEnvForTests(globalObject, object); + RETURN_IF_EXCEPTION(scope, {}); + if (env) { property.isOwn = env->hasOwnProperty(globalObject, propertyKey); RETURN_IF_EXCEPTION(scope, {}); + if (property.isOwn) { + property.value = env->get(globalObject, propertyKey); + RETURN_IF_EXCEPTION(scope, {}); + // TZ is an own accessor of process.env even when the variable is not set. Deleting it puts the zone back. + if (property.value.isUndefined()) + property.isOwn = false; + } } bool isNamespace = isModuleNamespace(object); // What the replacement is defined with. The restore writes the original back with the attributes of its storage. unsigned attributes = property.slotAttributes; - JSValue original = property.value; + // A Proxy's `get` trap may answer anything. Its target's descriptor holds what the restore defines again. + JSValue original = property.isOpaque && property.isEasy ? property.descriptor.value() : property.value; if (!property.isEasy) { - // A getter, a setter, or a property that a native getter serves. + // A getter, a setter, or a property that a native getter serves. A Proxy's `has` trap may also claim a key that no descriptor describes. const PropertyDescriptor& descriptor = property.descriptor; + if (!descriptor.isAccessorDescriptor() && !descriptor.isDataDescriptor()) { + throwVMError(globalObject, scope, makeString("Property `"_s, keyForMessage, "` does not exist in the provided object"_s)); + return {}; + } if (descriptor.isAccessorDescriptor()) { if (!descriptor.configurable()) { throwVMError(globalObject, scope, makeString("Property `"_s, keyForMessage, "` is not declared configurable"_s)); @@ -3491,14 +3523,27 @@ __attribute__((minsize)) static JSValue replaceProperty(Zig::GlobalObject* globa } if (auto* existing = findReplacedProperty(globalObject, object, propertyKey)) { - uncheckedDowncast(existing->mock.get())->putReplacement(globalObject, callframe, value); + // The write may run script that adds to or removes from the vector behind `existing`. + JSValue existingHandle = existing->handle.get(); + auto* existingMock = uncheckedDowncast(existing->mock.get()); + existingMock->putReplacement(globalObject, callframe, value); RETURN_IF_EXCEPTION(scope, {}); - return handle ? handle : existing->handle.get(); + return handle ? handle : existingHandle; } - bool isAssigned = !isNamespace && !property.isOpaque && object->structure()->typeInfo().overridesPut(); + // A shadow of an inherited property is a new own property, which an object that is not extensible refuses. + if (!property.isOwn && !isNamespace) { + bool isExtensible = object->isExtensible(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + if (!isExtensible) { + throwTypeError(globalObject, scope, makeString("Cannot replace the `"_s, keyForMessage, "` property because the object is not extensible"_s)); + return {}; + } + } + + // An assignment refuses a read-only property, and a locked built-in takes a direct store only, under the lock. + bool isAssigned = !isNamespace && !property.isOpaque && !(attributes & PropertyAttribute::ReadOnly) && object->structure()->typeInfo().overridesPut(); #if ENABLE(AOT) - // A locked built-in takes a direct store only, under the lock. if (object->structure()->mayHaveImmutableProperties()) isAssigned = false; #endif @@ -3577,12 +3622,23 @@ JSC_DEFINE_HOST_FUNCTION_WITH_ATTRIBUTES(jsJestReplaceProperty, __attribute__((m namespace Bun { +// Bound arguments: the handle, the target, the key. JSC_DEFINE_HOST_FUNCTION(jsReplacedPropertyRestore, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callframe)) { auto scope = DECLARE_THROW_SCOPE(globalObject->vm()); auto* mock = dynamicDowncast(callframe->thisValue()); if (!mock) [[unlikely]] return JSValue::encode(jsUndefined()); + if (mock->isRestoredSpy()) { + // Jest's restore() always puts the original back. A later replacement of the same property is restored instead. + JSObject* target = callframe->argument(1).getObject(); + Identifier propertyKey = callframe->argument(2).toPropertyKey(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + auto* active = findReplacedProperty(uncheckedDowncast(mock->globalObject()), target, propertyKey); + if (!active) + return JSValue::encode(jsUndefined()); + mock = uncheckedDowncast(active->mock.get()); + } mock->clearSpy(globalObject); RETURN_IF_EXCEPTION(scope, {}); return JSValue::encode(jsUndefined()); diff --git a/src/runtime/test_runner/vi_utils.rs b/src/runtime/test_runner/vi_utils.rs index b2615520ce26..b0a26a201a24 100644 --- a/src/runtime/test_runner/vi_utils.rs +++ b/src/runtime/test_runner/vi_utils.rs @@ -15,6 +15,7 @@ unsafe extern "C" { safe fn JSMock__resetAllMocks(global: &JSGlobalObject); safe fn JSMock__clearAllMocks(global: &JSGlobalObject); safe fn JSMock__didFinishTestFile(global: &JSGlobalObject); + safe fn JSMock__didDiscardTestFileGlobal(global: &JSGlobalObject); safe fn JSMock__willRunTest(global: &JSGlobalObject); } @@ -75,6 +76,7 @@ pub(crate) fn on_test_file_end(global: &JSGlobalObject, next_load_shares_global: runner.vi_config = runner.vi_config_of_preload; } if !next_load_shares_global { + JSMock__didDiscardTestFileGlobal(global); return; } run(global, JSMock__didFinishTestFile); diff --git a/test/bundler/compile-trim-runtime-texts.test.ts b/test/bundler/compile-trim-runtime-texts.test.ts index 9c10bff24bbc..6eabb0f354cf 100644 --- a/test/bundler/compile-trim-runtime-texts.test.ts +++ b/test/bundler/compile-trim-runtime-texts.test.ts @@ -76,7 +76,14 @@ function whatIsCompressedIn(bytes: Buffer): Uint8Array[] { return contents; } -const heldByWhatStays = ["exhaustive-deps", "rules-of-hooks", "use-memo", "Substitutions for pattern "]; +const heldByWhatStays = [ + "exhaustive-deps", + "rules-of-hooks", + "use-memo", + "Substitutions for pattern ", + // jest.replaceProperty()'s message for a function, which the checker's message 2630 ends with too. + " because it is a function.", +]; const stillLeftOnLinux = [ "error-message", "no-console", diff --git a/test/cli/test/isolation.test.ts b/test/cli/test/isolation.test.ts index a6b2da770395..6be230199633 100644 --- a/test/cli/test/isolation.test.ts +++ b/test/cli/test/isolation.test.ts @@ -3206,7 +3206,8 @@ describe.concurrent("mocks and spies do not outlive their test file", () => { import { expect, jest, test } from "bun:test"; test("a", () => { jest.replaceProperty(target, "file", "replaced"); - expect(target).toEqual({ loading: "replaced", hook: "replaced", file: "replaced" }); + jest.replaceProperty(target, "loading", "replaced by a"); + expect(target).toEqual({ loading: "replaced by a", hook: "replaced", file: "replaced" }); }); `, "b.test.ts": ` From a52fb1a0500092dc4b729b9055f6b1694456f820 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 8 Nov 2026 15:19:17 +0000 Subject: [PATCH 06/16] WIP tests --- docs/test/mocks.mdx | 2 +- test/js/bun/test/mock-fn.test.js | 112 ++++++++++++++++++++++++------- 2 files changed, 88 insertions(+), 26 deletions(-) diff --git a/docs/test/mocks.mdx b/docs/test/mocks.mdx index 2655bf757444..4aa30a0c099f 100644 --- a/docs/test/mocks.mdx +++ b/docs/test/mocks.mdx @@ -289,7 +289,7 @@ test("replace a property", () => { }); ``` -As in Jest, the property must exist and be configurable. For a method use `spyOn(object, "method")`, and for a getter or setter use `spyOn(object, "property", "get")` or `"set"`. Unlike Jest, Bun also replaces a property that is not writable, and keeps its attributes. +As in Jest, the property must exist and be configurable. For a method use `spyOn(object, "method")`, and for a getter or setter use `spyOn(object, "property", "get")` or `"set"`. Unlike Jest, Bun also replaces a property that is not writable, and keeps its attributes, and it replaces an export of a module namespace object, as `spyOn()` does. ### Classes diff --git a/test/js/bun/test/mock-fn.test.js b/test/js/bun/test/mock-fn.test.js index 3879cbe48de0..a03f3329369d 100644 --- a/test/js/bun/test/mock-fn.test.js +++ b/test/js/bun/test/mock-fn.test.js @@ -2323,13 +2323,22 @@ describe("spyOn", () => { test("process.env converts the value to a string, as an assignment does", () => { process.env.REPLACE_PROPERTY_TEST = "original"; - const replaced = jest.replaceProperty(process.env, "REPLACE_PROPERTY_TEST", 1); - expect(process.env.REPLACE_PROPERTY_TEST).toBe("1"); - replaced.replaceValue(true); - expect(process.env.REPLACE_PROPERTY_TEST).toBe("true"); - replaced.restore(); - expect(process.env.REPLACE_PROPERTY_TEST).toBe("original"); - delete process.env.REPLACE_PROPERTY_TEST; + try { + const replaced = jest.replaceProperty(process.env, "REPLACE_PROPERTY_TEST", 1); + expect(process.env.REPLACE_PROPERTY_TEST).toBe("1"); + replaced.replaceValue(true); + expect(process.env.REPLACE_PROPERTY_TEST).toBe("true"); + replaced.restore(); + expect(process.env.REPLACE_PROPERTY_TEST).toBe("original"); + } finally { + delete process.env.REPLACE_PROPERTY_TEST; + } + }); + + test("refuses to add a property to an object that is not extensible", () => { + const target = Object.freeze(Object.create({ value: 1 })); + expect(() => jest.replaceProperty(target, "value", 2)).toThrow(TypeError); + expect(Object.hasOwn(target, "value")).toBe(false); }); test("restore and replaceValue work when destructured", () => { @@ -2434,6 +2443,19 @@ describe("spyOn", () => { expect(target.value).toBe(1); }); + test("restore on a restored handle puts back the original of a later replacement of the same property", () => { + // As in a describe() with afterEach(() => replaced.restore()), whose tests call jest.replaceProperty() again. + const target = { value: 1 }; + const replaced = jest.replaceProperty(target, "value", 2); + replaced.restore(); + const later = jest.replaceProperty(target, "value", 3); + expect(later).not.toBe(replaced); + replaced.restore(); + expect(target.value).toBe(1); + later.restore(); + expect(target.value).toBe(1); + }); + test("a symbol key", () => { const key = Symbol("value"); const target = { [key]: 1 }; @@ -2526,24 +2548,43 @@ describe("spyOn", () => { ); }); - test("import.meta.env", () => { - expect([import.meta.env.MODE, "MODE" in process.env, import.meta.env.DEV]).toEqual(["test", false, true]); - const mode = jest.replaceProperty(import.meta.env, "MODE", "production"); - const dev = jest.replaceProperty(import.meta.env, "DEV", false); - expect([import.meta.env.MODE, process.env.MODE, import.meta.env.DEV]).toEqual([ - "production", - "production", - false, - ]); - mode.restore(); - dev.restore(); - expect([import.meta.env.MODE, "MODE" in process.env, import.meta.env.DEV, "DEV" in process.env]).toEqual([ - "test", - false, - true, - false, - ]); - }); + test.skipIf("MODE" in process.env || "DEV" in process.env || "PROD" in process.env || "TZ" in process.env)( + "import.meta.env", + () => { + expect([import.meta.env.MODE, "MODE" in process.env, import.meta.env.DEV]).toEqual(["test", false, true]); + const mode = jest.replaceProperty(import.meta.env, "MODE", "production"); + const dev = jest.replaceProperty(import.meta.env, "DEV", false); + expect([import.meta.env.MODE, process.env.MODE, import.meta.env.DEV]).toEqual([ + "production", + "production", + false, + ]); + mode.restore(); + dev.restore(); + expect([import.meta.env.MODE, "MODE" in process.env, import.meta.env.DEV, "DEV" in process.env]).toEqual([ + "test", + false, + true, + false, + ]); + // A variable that is set keeps the string it had, not the boolean import.meta.env shows. + process.env.PROD = "0"; + try { + const prod = jest.replaceProperty(import.meta.env, "PROD", true); + expect([import.meta.env.PROD, process.env.PROD]).toEqual([true, "1"]); + prod.restore(); + expect([import.meta.env.PROD, process.env.PROD]).toEqual([false, "0"]); + } finally { + delete process.env.PROD; + } + // TZ is an own accessor of process.env while it is not set. The restore deletes it, which puts the zone back. + const zone = new Date(0).getTimezoneOffset(); + const tz = jest.replaceProperty(import.meta.env, "TZ", "Asia/Tokyo"); + expect(new Date(0).getTimezoneOffset()).toBe(-540); + tz.restore(); + expect([new Date(0).getTimezoneOffset(), process.env.TZ]).toEqual([zone, undefined]); + }, + ); test("through a Proxy", () => { const target = { value: 1 }; @@ -2553,6 +2594,8 @@ describe("spyOn", () => { traps.push(["defineProperty", key]); return Reflect.defineProperty(target, key, descriptor); }, + // The restore defines what the target holds, not what the trap answers. + get: () => 99, }); const replaced = jest.replaceProperty(proxy, "value", 2); expect(target.value).toBe(2); @@ -2562,6 +2605,25 @@ describe("spyOn", () => { ["defineProperty", "value"], ["defineProperty", "value"], ]); + // A `has` trap that claims a key no descriptor describes. + expect(() => jest.replaceProperty(new Proxy({}, { has: () => true }), "value", 2)).toThrow( + "Property `value` does not exist in the provided object", + ); + // The write runs a trap that replaces other properties, which moves the records around. + const objects = Array.from({ length: 20 }, () => ({ value: 1 })); + const busy = new Proxy( + { value: 1 }, + { + defineProperty(target, key, descriptor) { + for (const object of objects) jest.replaceProperty(object, "value", 2); + return Reflect.defineProperty(target, key, descriptor); + }, + }, + ); + const first = jest.replaceProperty(busy, "value", 2); + expect(jest.replaceProperty(busy, "value", 3)).toBe(first); + jest.restoreAllMocks(); + expect([busy.value, ...objects.map(object => object.value)]).toEqual(Array(21).fill(1)); }); } }); From cabe3ccecad65bde9a6bea9d4a67fd7cc3cdba14 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 8 Nov 2026 18:10:39 +0000 Subject: [PATCH 07/16] Tests: run the import.meta.env test, make the busy Proxy test deterministic --- src/jsc/bindings/JSMockFunction.cpp | 6 +-- test/js/bun/test/mock-fn.test.js | 78 +++++++++++++++-------------- 2 files changed, 42 insertions(+), 42 deletions(-) diff --git a/src/jsc/bindings/JSMockFunction.cpp b/src/jsc/bindings/JSMockFunction.cpp index 1ad8931d79f9..d7fb2bc01138 100644 --- a/src/jsc/bindings/JSMockFunction.cpp +++ b/src/jsc/bindings/JSMockFunction.cpp @@ -3443,8 +3443,7 @@ static JSObject* createReplacedPropertyHandle(Zig::GlobalObject* globalObject, J return handle; } -// The entry of an earlier jest.replaceProperty() on the same object and key that is not restored yet. -// A test file replaces over a preload's replacement with a record of its own, so that the end of the file gives the preload's value back. +// The entry of an earlier jest.replaceProperty() on the same object and key that is not restored yet. A test file gets its own record over a preload's. static JSMockModule::ReplacedProperty* findReplacedProperty(Zig::GlobalObject* globalObject, JSObject* target, const Identifier& propertyKey) { bool isInPreload = globalObject->mockModule.preloadMayBeRunning && JSMock__isInPreload(globalObject); @@ -3472,8 +3471,7 @@ __attribute__((minsize)) static JSValue replaceProperty(Zig::GlobalObject* globa throwVMError(globalObject, scope, makeString("Property `"_s, keyForMessage, "` does not exist in the provided object"_s)); return {}; } - // In tests `import.meta.env` is a view of `process.env` with Vite's variables converted, and with a default for the ones it lacks. - // The original is what `process.env` holds, and the restore deletes a default. + // import.meta.env converts Vite's variables and gives the ones process.env lacks a default. The original is what process.env holds. JSObject* env = processEnvOfImportMetaEnvForTests(globalObject, object); RETURN_IF_EXCEPTION(scope, {}); if (env) { diff --git a/test/js/bun/test/mock-fn.test.js b/test/js/bun/test/mock-fn.test.js index a03f3329369d..2bb93f059a56 100644 --- a/test/js/bun/test/mock-fn.test.js +++ b/test/js/bun/test/mock-fn.test.js @@ -2548,43 +2548,43 @@ describe("spyOn", () => { ); }); - test.skipIf("MODE" in process.env || "DEV" in process.env || "PROD" in process.env || "TZ" in process.env)( - "import.meta.env", - () => { - expect([import.meta.env.MODE, "MODE" in process.env, import.meta.env.DEV]).toEqual(["test", false, true]); - const mode = jest.replaceProperty(import.meta.env, "MODE", "production"); - const dev = jest.replaceProperty(import.meta.env, "DEV", false); - expect([import.meta.env.MODE, process.env.MODE, import.meta.env.DEV]).toEqual([ - "production", - "production", - false, - ]); - mode.restore(); - dev.restore(); - expect([import.meta.env.MODE, "MODE" in process.env, import.meta.env.DEV, "DEV" in process.env]).toEqual([ - "test", - false, - true, - false, - ]); - // A variable that is set keeps the string it had, not the boolean import.meta.env shows. - process.env.PROD = "0"; - try { - const prod = jest.replaceProperty(import.meta.env, "PROD", true); - expect([import.meta.env.PROD, process.env.PROD]).toEqual([true, "1"]); - prod.restore(); - expect([import.meta.env.PROD, process.env.PROD]).toEqual([false, "0"]); - } finally { - delete process.env.PROD; - } - // TZ is an own accessor of process.env while it is not set. The restore deletes it, which puts the zone back. - const zone = new Date(0).getTimezoneOffset(); - const tz = jest.replaceProperty(import.meta.env, "TZ", "Asia/Tokyo"); - expect(new Date(0).getTimezoneOffset()).toBe(-540); - tz.restore(); - expect([new Date(0).getTimezoneOffset(), process.env.TZ]).toEqual([zone, undefined]); - }, - ); + // TZ is an own accessor of process.env even while it is not set, so `in` would skip always. + test.skipIf( + [process.env.MODE, process.env.DEV, process.env.PROD, process.env.TZ].some(value => value !== undefined), + )("import.meta.env", () => { + expect([import.meta.env.MODE, "MODE" in process.env, import.meta.env.DEV]).toEqual(["test", false, true]); + const mode = jest.replaceProperty(import.meta.env, "MODE", "production"); + const dev = jest.replaceProperty(import.meta.env, "DEV", false); + expect([import.meta.env.MODE, process.env.MODE, import.meta.env.DEV]).toEqual([ + "production", + "production", + false, + ]); + mode.restore(); + dev.restore(); + expect([import.meta.env.MODE, "MODE" in process.env, import.meta.env.DEV, "DEV" in process.env]).toEqual([ + "test", + false, + true, + false, + ]); + // A variable that is set keeps the string it had, not the boolean import.meta.env shows. + process.env.PROD = ""; + try { + const prod = jest.replaceProperty(import.meta.env, "PROD", true); + expect([import.meta.env.PROD, process.env.PROD]).toEqual([true, "1"]); + prod.restore(); + expect([import.meta.env.PROD, process.env.PROD]).toEqual([false, ""]); + } finally { + delete process.env.PROD; + } + // TZ is an own accessor of process.env while it is not set. The restore deletes it, which puts the zone back. + const zone = new Date(0).getTimezoneOffset(); + const tz = jest.replaceProperty(import.meta.env, "TZ", "Asia/Tokyo"); + expect(new Date(0).getTimezoneOffset()).toBe(-540); + tz.restore(); + expect([new Date(0).getTimezoneOffset(), process.env.TZ]).toEqual([zone, undefined]); + }); test("through a Proxy", () => { const target = { value: 1 }; @@ -2611,17 +2611,19 @@ describe("spyOn", () => { ); // The write runs a trap that replaces other properties, which moves the records around. const objects = Array.from({ length: 20 }, () => ({ value: 1 })); + let isReplacing = true; const busy = new Proxy( { value: 1 }, { defineProperty(target, key, descriptor) { - for (const object of objects) jest.replaceProperty(object, "value", 2); + if (isReplacing) for (const object of objects) jest.replaceProperty(object, "value", 2); return Reflect.defineProperty(target, key, descriptor); }, }, ); const first = jest.replaceProperty(busy, "value", 2); expect(jest.replaceProperty(busy, "value", 3)).toBe(first); + isReplacing = false; jest.restoreAllMocks(); expect([busy.value, ...objects.map(object => object.value)]).toEqual(Array(21).fill(1)); }); From 66a783462cf20e14d1b4954e209269c70c92d2de Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 8 Nov 2026 18:40:01 +0000 Subject: [PATCH 08/16] AOT: list the replaceProperty row as not checked; always assign through import.meta.env --- src/jsc/bindings/JSMockFunction.cpp | 3 ++- test/aot/builtins/differential/not-checked.txt | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/jsc/bindings/JSMockFunction.cpp b/src/jsc/bindings/JSMockFunction.cpp index d7fb2bc01138..b52fabff3ba4 100644 --- a/src/jsc/bindings/JSMockFunction.cpp +++ b/src/jsc/bindings/JSMockFunction.cpp @@ -3540,7 +3540,8 @@ __attribute__((minsize)) static JSValue replaceProperty(Zig::GlobalObject* globa } // An assignment refuses a read-only property, and a locked built-in takes a direct store only, under the lock. - bool isAssigned = !isNamespace && !property.isOpaque && !(attributes & PropertyAttribute::ReadOnly) && object->structure()->typeInfo().overridesPut(); + // import.meta.env has no storage of its own, so it is always assigned. + bool isAssigned = !isNamespace && !property.isOpaque && (env || !(attributes & PropertyAttribute::ReadOnly)) && object->structure()->typeInfo().overridesPut(); #if ENABLE(AOT) if (object->structure()->mayHaveImmutableProperties()) isAssigned = false; diff --git a/test/aot/builtins/differential/not-checked.txt b/test/aot/builtins/differential/not-checked.txt index 1a31f5908216..8463eebc7476 100644 --- a/test/aot/builtins/differential/not-checked.txt +++ b/test/aot/builtins/differential/not-checked.txt @@ -80,6 +80,7 @@ BunTestAroundEach/0: it is not called: it reaches outside JavaScript BunTestBeforeAll/0: it is not called: it reaches outside JavaScript BunTestBeforeEach/0: it is not called: it reaches outside JavaScript BunTestDescribe/0: it is not called: it reaches outside JavaScript +BunTestJestReplaceProperty/0: it is not called: it reaches outside JavaScript BunTestJestSpyOn/0: it is not called: it reaches outside JavaScript BunTestOnTestFinished/0: it is not called: it reaches outside JavaScript BunTestSpyOn/0: it is not called: it reaches outside JavaScript From 68d98bc1f226a06623538084d6092b310f4ccf27 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 8 Nov 2026 18:40:41 +0000 Subject: [PATCH 09/16] One-line comment --- src/jsc/bindings/JSMockFunction.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/jsc/bindings/JSMockFunction.cpp b/src/jsc/bindings/JSMockFunction.cpp index b52fabff3ba4..fee9253e68c1 100644 --- a/src/jsc/bindings/JSMockFunction.cpp +++ b/src/jsc/bindings/JSMockFunction.cpp @@ -3539,8 +3539,7 @@ __attribute__((minsize)) static JSValue replaceProperty(Zig::GlobalObject* globa } } - // An assignment refuses a read-only property, and a locked built-in takes a direct store only, under the lock. - // import.meta.env has no storage of its own, so it is always assigned. + // An assignment refuses a read-only property. import.meta.env has no storage of its own, so it is always assigned. bool isAssigned = !isNamespace && !property.isOpaque && (env || !(attributes & PropertyAttribute::ReadOnly)) && object->structure()->typeInfo().overridesPut(); #if ENABLE(AOT) if (object->structure()->mayHaveImmutableProperties()) From ffea66d16e4f2a502f5e49879e18f12a99c0d619 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 8 Nov 2026 18:59:36 +0000 Subject: [PATCH 10/16] AOT: replaceProperty writes its first operand as spyOn does, and converts the value --- src/aot/builtins/resolve/written_members.rs | 7 ++++--- src/codegen/builtins/host/modules.builtins.ts | 5 +++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/aot/builtins/resolve/written_members.rs b/src/aot/builtins/resolve/written_members.rs index 67ce2011b793..0cd085660890 100644 --- a/src/aot/builtins/resolve/written_members.rs +++ b/src/aot/builtins/resolve/written_members.rs @@ -109,9 +109,10 @@ pub(super) fn writes_on_first_operand(function: BuiltinId) -> bool { fn find_writer(function: BuiltinId) -> Option { let writer = match function { - BuiltinId::BunTestSpyOn | BuiltinId::BunTestJestSpyOn | BuiltinId::BunTestViSpyOn => { - Writer::Spy - } + BuiltinId::BunTestSpyOn + | BuiltinId::BunTestJestSpyOn + | BuiltinId::BunTestViSpyOn + | BuiltinId::BunTestJestReplaceProperty => Writer::Spy, BuiltinId::ObjectDefineProperty | BuiltinId::ReflectDefineProperty | BuiltinId::ReflectDeleteProperty diff --git a/src/codegen/builtins/host/modules.builtins.ts b/src/codegen/builtins/host/modules.builtins.ts index b485f80a0a9a..10a765fb205d 100644 --- a/src/codegen/builtins/host/modules.builtins.ts +++ b/src/codegen/builtins/host/modules.builtins.ts @@ -78,7 +78,8 @@ const spyOn = { } satisfies b.FunctionField; const stubbedName = b.keyFrom("name"); -// JSMock__jsReplaceProperty (jsJestReplaceProperty) keeps what the data property holds, weakly, and defines or assigns `value` under the name on the object itself. +// jsJestReplaceProperty keeps what the data property holds, weakly, and defines or assigns `value` under the name on the object itself. +// An assignment to process.env or a typed array converts the value. const replacedName = b.keyFrom("name"); const replaceProperty = { args: { object: b.object, name: b.propertyKey.loose, value: b.any }, @@ -86,7 +87,7 @@ const replaceProperty = { capability: "js", throws: true, uses: { - value: b.inspected, + value: [b.runs("ToPrimitive")], object: [ b.readByKey(replacedName, b.nativeMemory), b.definesName(replacedName, b.operand("value")), From 784f2d2e79936fd2f5216595b55b36ebfe011b51 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 8 Nov 2026 19:00:55 +0000 Subject: [PATCH 11/16] One-line comment --- src/codegen/builtins/host/modules.builtins.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/codegen/builtins/host/modules.builtins.ts b/src/codegen/builtins/host/modules.builtins.ts index 10a765fb205d..07fa52e9c95f 100644 --- a/src/codegen/builtins/host/modules.builtins.ts +++ b/src/codegen/builtins/host/modules.builtins.ts @@ -78,8 +78,7 @@ const spyOn = { } satisfies b.FunctionField; const stubbedName = b.keyFrom("name"); -// jsJestReplaceProperty keeps what the data property holds, weakly, and defines or assigns `value` under the name on the object itself. -// An assignment to process.env or a typed array converts the value. +// jsJestReplaceProperty keeps what the data property holds, weakly, and defines or assigns `value` under the name, which process.env converts. const replacedName = b.keyFrom("name"); const replaceProperty = { args: { object: b.object, name: b.propertyKey.loose, value: b.any }, From 1b162b5be6a4a44843f16e304f8a1f7378a22be3 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 8 Nov 2026 22:14:33 +0000 Subject: [PATCH 12/16] Tests: the TZ case needs process.env's TZ accessor --- test/js/bun/test/mock-fn.test.js | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/test/js/bun/test/mock-fn.test.js b/test/js/bun/test/mock-fn.test.js index 2bb93f059a56..287247436bb0 100644 --- a/test/js/bun/test/mock-fn.test.js +++ b/test/js/bun/test/mock-fn.test.js @@ -2578,12 +2578,15 @@ describe("spyOn", () => { } finally { delete process.env.PROD; } - // TZ is an own accessor of process.env while it is not set. The restore deletes it, which puts the zone back. - const zone = new Date(0).getTimezoneOffset(); - const tz = jest.replaceProperty(import.meta.env, "TZ", "Asia/Tokyo"); - expect(new Date(0).getTimezoneOffset()).toBe(-540); - tz.restore(); - expect([new Date(0).getTimezoneOffset(), process.env.TZ]).toEqual([zone, undefined]); + // TZ is an own accessor of process.env while it is not set, unless another file in this process deleted it. + // The restore deletes it, which puts the zone back. + if (Object.getOwnPropertyDescriptor(process.env, "TZ")) { + const zone = new Date(0).getTimezoneOffset(); + const tz = jest.replaceProperty(import.meta.env, "TZ", "Asia/Tokyo"); + expect(new Date(0).getTimezoneOffset()).toBe(-540); + tz.restore(); + expect([new Date(0).getTimezoneOffset(), process.env.TZ]).toEqual([zone, undefined]); + } }); test("through a Proxy", () => { From 5dfa60fa015677c4d46b290e127f5cc81b96e8b2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 8 Nov 2026 22:34:37 +0000 Subject: [PATCH 13/16] replaceProperty: an unset import.meta.env variable that process.env keeps an accessor for does not exist The first call replaced it and its restore deleted the accessor, so a second call threw. Both calls now throw the same error. --- src/jsc/bindings/JSMockFunction.cpp | 8 +++++--- test/js/bun/test/mock-fn.test.js | 20 ++++++++++++++------ 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/src/jsc/bindings/JSMockFunction.cpp b/src/jsc/bindings/JSMockFunction.cpp index fee9253e68c1..b275ba313116 100644 --- a/src/jsc/bindings/JSMockFunction.cpp +++ b/src/jsc/bindings/JSMockFunction.cpp @@ -3480,9 +3480,11 @@ __attribute__((minsize)) static JSValue replaceProperty(Zig::GlobalObject* globa if (property.isOwn) { property.value = env->get(globalObject, propertyKey); RETURN_IF_EXCEPTION(scope, {}); - // TZ is an own accessor of process.env even when the variable is not set. Deleting it puts the zone back. - if (property.value.isUndefined()) - property.isOwn = false; + // process.env holds strings. Its accessor for an unset TZ reads undefined: the variable does not exist, as in Node. + if (property.value.isUndefined()) { + throwVMError(globalObject, scope, makeString("Property `"_s, keyForMessage, "` does not exist in the provided object"_s)); + return {}; + } } } diff --git a/test/js/bun/test/mock-fn.test.js b/test/js/bun/test/mock-fn.test.js index 287247436bb0..334e44f24b3b 100644 --- a/test/js/bun/test/mock-fn.test.js +++ b/test/js/bun/test/mock-fn.test.js @@ -2578,15 +2578,23 @@ describe("spyOn", () => { } finally { delete process.env.PROD; } - // TZ is an own accessor of process.env while it is not set, unless another file in this process deleted it. - // The restore deletes it, which puts the zone back. - if (Object.getOwnPropertyDescriptor(process.env, "TZ")) { - const zone = new Date(0).getTimezoneOffset(); + // An unset TZ does not exist, whether process.env still has its accessor for it or a file deleted it. + expect(() => jest.replaceProperty(import.meta.env, "TZ", "Asia/Tokyo")).toThrow( + "Property `TZ` does not exist in the provided object", + ); + expect(process.env.TZ).toBeUndefined(); + // A set TZ is assigned, so the zone changes with it. + const zone = new Date(0).getTimezoneOffset(); + process.env.TZ = "America/New_York"; + try { const tz = jest.replaceProperty(import.meta.env, "TZ", "Asia/Tokyo"); - expect(new Date(0).getTimezoneOffset()).toBe(-540); + expect([new Date(0).getTimezoneOffset(), process.env.TZ]).toEqual([-540, "Asia/Tokyo"]); tz.restore(); - expect([new Date(0).getTimezoneOffset(), process.env.TZ]).toEqual([zone, undefined]); + expect([new Date(0).getTimezoneOffset(), process.env.TZ]).toEqual([300, "America/New_York"]); + } finally { + delete process.env.TZ; } + expect(new Date(0).getTimezoneOffset()).toBe(zone); }); test("through a Proxy", () => { From 597b140e67aafd602b49a8fa490e4b97d71050ad Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 8 Nov 2026 22:52:04 +0000 Subject: [PATCH 14/16] replaceProperty: only an accessor of process.env that reads undefined is an unset variable A value of undefined on an object put in place of process.env is a value, and is replaced. --- src/jsc/bindings/JSMockFunction.cpp | 10 ++++++---- test/js/bun/test/mock-fn.test.js | 17 +++++++++++++++++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/jsc/bindings/JSMockFunction.cpp b/src/jsc/bindings/JSMockFunction.cpp index b275ba313116..40fb2021c024 100644 --- a/src/jsc/bindings/JSMockFunction.cpp +++ b/src/jsc/bindings/JSMockFunction.cpp @@ -3475,13 +3475,15 @@ __attribute__((minsize)) static JSValue replaceProperty(Zig::GlobalObject* globa JSObject* env = processEnvOfImportMetaEnvForTests(globalObject, object); RETURN_IF_EXCEPTION(scope, {}); if (env) { - property.isOwn = env->hasOwnProperty(globalObject, propertyKey); + PropertySlot envSlot(env, PropertySlot::InternalMethodType::GetOwnProperty); + property.isOwn = env->getOwnPropertySlotInline(globalObject, propertyKey, envSlot); RETURN_IF_EXCEPTION(scope, {}); if (property.isOwn) { - property.value = env->get(globalObject, propertyKey); + // On Windows process.env is a Proxy whose target holds the TZ accessor, so read it through the Proxy. + property.value = envSlot.isValue() ? envSlot.getValue(globalObject, propertyKey) : env->get(globalObject, propertyKey); RETURN_IF_EXCEPTION(scope, {}); - // process.env holds strings. Its accessor for an unset TZ reads undefined: the variable does not exist, as in Node. - if (property.value.isUndefined()) { + // process.env's accessor for an unset TZ reads undefined: the variable does not exist, as in Node. A value of undefined on an object put in place of process.env is a value. + if (!envSlot.isValue() && property.value.isUndefined()) { throwVMError(globalObject, scope, makeString("Property `"_s, keyForMessage, "` does not exist in the provided object"_s)); return {}; } diff --git a/test/js/bun/test/mock-fn.test.js b/test/js/bun/test/mock-fn.test.js index 334e44f24b3b..bc55c5add90d 100644 --- a/test/js/bun/test/mock-fn.test.js +++ b/test/js/bun/test/mock-fn.test.js @@ -2597,6 +2597,23 @@ describe("spyOn", () => { expect(new Date(0).getTimezoneOffset()).toBe(zone); }); + test("import.meta.env over an object put in place of process.env", () => { + const env = process.env; + process.env = { VALUE: undefined, get ACCESSOR() {}, set ACCESSOR(v) {} }; + try { + // A value of undefined is a value. An accessor that reads undefined is an unset variable. + const replaced = jest.replaceProperty(import.meta.env, "VALUE", "x"); + expect([import.meta.env.VALUE, process.env.VALUE]).toEqual(["x", "x"]); + replaced.restore(); + expect([import.meta.env.VALUE, Object.hasOwn(process.env, "VALUE")]).toEqual([undefined, true]); + expect(() => jest.replaceProperty(import.meta.env, "ACCESSOR", "x")).toThrow( + "Property `ACCESSOR` does not exist in the provided object", + ); + } finally { + process.env = env; + } + }); + test("through a Proxy", () => { const target = { value: 1 }; const traps = []; From 96feaed4416903a0ddbd2272f801db653867bc8c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 8 Nov 2026 23:13:22 +0000 Subject: [PATCH 15/16] replaceProperty: install on the process.env behind import.meta.env at call time The restore resolved process.env again. A test that puts a copy in place of process.env and takes it away before the restore got the copy's original written to the real process.env. The spy now records the process.env of the call, and replaceValue converts a Vite boolean as import.meta.env's put() does. --- src/jsc/bindings/ImportMetaEnv.cpp | 16 +++++++++++++-- src/jsc/bindings/JSMockFunction.cpp | 30 +++++++++++++++++------------ test/js/bun/test/mock-fn.test.js | 24 +++++++++++++++++++++++ 3 files changed, 56 insertions(+), 14 deletions(-) diff --git a/src/jsc/bindings/ImportMetaEnv.cpp b/src/jsc/bindings/ImportMetaEnv.cpp index 952bf228e247..1ab3d03fc100 100644 --- a/src/jsc/bindings/ImportMetaEnv.cpp +++ b/src/jsc/bindings/ImportMetaEnv.cpp @@ -205,6 +205,14 @@ bool ImportMetaEnv::getOwnPropertySlotByIndex(JSObject* object, JSGlobalObject* return getOwnPropertySlot(object, globalObject, Identifier::from(getVM(globalObject), index), slot); } +// What process.env stores for a write of `value` to import.meta.env: a Vite boolean becomes "1" or "". +static JSValue processEnvValueOfImportMetaEnv(JSGlobalObject* globalObject, PropertyName propertyName, JSValue value) +{ + if (isBoolean(viteVariableNamed(propertyName))) + return value.toBoolean(globalObject) ? getVM(globalObject).smallStrings.singleCharacterString('1') : jsEmptyString(getVM(globalObject)); + return value; +} + bool ImportMetaEnv::put(JSCell* cell, JSGlobalObject* globalObject, PropertyName propertyName, JSValue value, PutPropertySlot& slot) { auto& vm = getVM(globalObject); @@ -215,8 +223,7 @@ bool ImportMetaEnv::put(JSCell* cell, JSGlobalObject* globalObject, PropertyName JSObject* env = uncheckedDowncast(cell)->processEnv(globalObject); RETURN_IF_EXCEPTION(scope, false); - if (isBoolean(viteVariableNamed(propertyName))) - value = value.toBoolean(globalObject) ? vm.smallStrings.singleCharacterString('1') : jsEmptyString(vm); + value = processEnvValueOfImportMetaEnv(globalObject, propertyName, value); PutPropertySlot envSlot(env, slot.isStrictMode()); RELEASE_AND_RETURN(scope, env->methodTable()->put(env, globalObject, propertyName, value, envSlot)); } @@ -293,4 +300,9 @@ JSObject* processEnvOfImportMetaEnvForTests(JSGlobalObject* globalObject, JSObje return env ? env->processEnv(globalObject) : nullptr; } +JSValue processEnvValueOfImportMetaEnvForTests(JSGlobalObject* globalObject, PropertyName propertyName, JSValue value) +{ + return processEnvValueOfImportMetaEnv(globalObject, propertyName, value); +} + } diff --git a/src/jsc/bindings/JSMockFunction.cpp b/src/jsc/bindings/JSMockFunction.cpp index 40fb2021c024..9615a508bda6 100644 --- a/src/jsc/bindings/JSMockFunction.cpp +++ b/src/jsc/bindings/JSMockFunction.cpp @@ -290,6 +290,7 @@ static bool isJSFunctionOrCompiledCallee(JSObject* object) } JSObject* processEnvOfImportMetaEnvForTests(JSGlobalObject*, JSObject*); +JSValue processEnvValueOfImportMetaEnvForTests(JSGlobalObject*, PropertyName, JSValue); // Script never sees what putDirect() stores on a Proxy, or on a node:vm global for a key that the contextified object has. static bool isModuleNamespace(JSObject* object) @@ -499,6 +500,8 @@ class JSMockFunction final : public JSC::InternalFunction { static constexpr unsigned SpyAttributeOpaque = 1 << 28; // The replacement was assigned, as `object[key] = value`, because the object has its own put(). The restore assigns too. static constexpr unsigned SpyAttributeAssigned = 1 << 27; + // Replaced through import.meta.env, which converts a Vite boolean on the way to process.env. The target is the process.env of that time. + static constexpr unsigned SpyAttributeImportMetaEnv = 1 << 26; JSString* jsName() { @@ -921,6 +924,8 @@ class JSMockFunction final : public JSC::InternalFunction { return false; unsigned attributes = m_spyAttributes; if (attributes & SpyAttributeAssigned) { + if (attributes & SpyAttributeImportMetaEnv) + value = processEnvValueOfImportMetaEnvForTests(globalObject, m_spyIdentifier, value); assignProperty(globalObject, target, m_spyIdentifier, value); return true; } @@ -1086,10 +1091,6 @@ class JSMockFunction final : public JSC::InternalFunction { } if (attributes & SpyAttributeAssigned) { - // The original of an import.meta.env variable is the string process.env holds, which import.meta.env's put() would convert. - if (JSObject* env = processEnvOfImportMetaEnvForTests(globalObject, target)) - target = env; - RETURN_IF_EXCEPTION(scope, ); if (original) { scope.release(); assignProperty(globalObject, target, identifier, original); @@ -3471,9 +3472,10 @@ __attribute__((minsize)) static JSValue replaceProperty(Zig::GlobalObject* globa throwVMError(globalObject, scope, makeString("Property `"_s, keyForMessage, "` does not exist in the provided object"_s)); return {}; } - // import.meta.env converts Vite's variables and gives the ones process.env lacks a default. The original is what process.env holds. + // import.meta.env converts Vite's variables and gives the ones process.env lacks a default. The original is what process.env holds, and the spy is installed there: a test may put another object in place of process.env before the restore. JSObject* env = processEnvOfImportMetaEnvForTests(globalObject, object); RETURN_IF_EXCEPTION(scope, {}); + JSObject* installTarget = env ? env : object; if (env) { PropertySlot envSlot(env, PropertySlot::InternalMethodType::GetOwnProperty); property.isOwn = env->getOwnPropertySlotInline(globalObject, propertyKey, envSlot); @@ -3524,7 +3526,7 @@ __attribute__((minsize)) static JSValue replaceProperty(Zig::GlobalObject* globa return {}; } - if (auto* existing = findReplacedProperty(globalObject, object, propertyKey)) { + if (auto* existing = findReplacedProperty(globalObject, installTarget, propertyKey)) { // The write may run script that adds to or removes from the vector behind `existing`. JSValue existingHandle = existing->handle.get(); auto* existingMock = uncheckedDowncast(existing->mock.get()); @@ -3535,7 +3537,7 @@ __attribute__((minsize)) static JSValue replaceProperty(Zig::GlobalObject* globa // A shadow of an inherited property is a new own property, which an object that is not extensible refuses. if (!property.isOwn && !isNamespace) { - bool isExtensible = object->isExtensible(globalObject); + bool isExtensible = installTarget->isExtensible(globalObject); RETURN_IF_EXCEPTION(scope, {}); if (!isExtensible) { throwTypeError(globalObject, scope, makeString("Cannot replace the `"_s, keyForMessage, "` property because the object is not extensible"_s)); @@ -3543,10 +3545,10 @@ __attribute__((minsize)) static JSValue replaceProperty(Zig::GlobalObject* globa } } - // An assignment refuses a read-only property. import.meta.env has no storage of its own, so it is always assigned. - bool isAssigned = !isNamespace && !property.isOpaque && (env || !(attributes & PropertyAttribute::ReadOnly)) && object->structure()->typeInfo().overridesPut(); + // An assignment refuses a read-only property. import.meta.env has no storage of its own, so its process.env is always assigned. + bool isAssigned = env || (!isNamespace && !property.isOpaque && !(attributes & PropertyAttribute::ReadOnly) && object->structure()->typeInfo().overridesPut()); #if ENABLE(AOT) - if (object->structure()->mayHaveImmutableProperties()) + if (!env && object->structure()->mayHaveImmutableProperties()) isAssigned = false; #endif @@ -3565,6 +3567,8 @@ __attribute__((minsize)) static JSValue replaceProperty(Zig::GlobalObject* globa spyAttributes |= JSMockFunction::SpyAttributeOpaque; if (isAssigned) spyAttributes |= JSMockFunction::SpyAttributeAssigned; + if (env) + spyAttributes |= JSMockFunction::SpyAttributeImportMetaEnv; if (!mock) { mock = createMockFunction(vm, globalObject, false); @@ -3572,7 +3576,9 @@ __attribute__((minsize)) static JSValue replaceProperty(Zig::GlobalObject* globa mock->isReplacedProperty = true; } if (isAssigned) { - assignProperty(globalObject, object, propertyKey, value); + if (env) + value = processEnvValueOfImportMetaEnvForTests(globalObject, propertyKey, value); + assignProperty(globalObject, installTarget, propertyKey, value); RETURN_IF_EXCEPTION(scope, {}); } else { ThroughLockOfBuiltins throughLock(vm, property.isOpaque); @@ -3580,7 +3586,7 @@ __attribute__((minsize)) static JSValue replaceProperty(Zig::GlobalObject* globa putOwnPropertyStorage(globalObject, object, propertyKey, value, attributes, property.isOpaque); RETURN_IF_EXCEPTION(scope, {}); } - mock->didInstall(object, propertyKey, property.isOwn ? original : JSValue(), spyAttributes); + mock->didInstall(installTarget, propertyKey, property.isOwn ? original : JSValue(), spyAttributes); if (!handle) { handle = createReplacedPropertyHandle(globalObject, mock, object, propertyKey); diff --git a/test/js/bun/test/mock-fn.test.js b/test/js/bun/test/mock-fn.test.js index bc55c5add90d..650879e47dc3 100644 --- a/test/js/bun/test/mock-fn.test.js +++ b/test/js/bun/test/mock-fn.test.js @@ -2614,6 +2614,30 @@ describe("spyOn", () => { } }); + test.skipIf(process.env.DEV !== undefined)( + "import.meta.env replaces and restores the process.env of the time it was called", + () => { + // As in beforeEach(() => (process.env = { ...process.env })) with afterEach(() => (process.env = saved)). + const env = process.env; + const copy = { ...process.env, VALUE: "a" }; + process.env = copy; + let value, dev; + try { + value = jest.replaceProperty(import.meta.env, "VALUE", "b"); + dev = jest.replaceProperty(import.meta.env, "DEV", false); + expect([copy.VALUE, copy.DEV, import.meta.env.DEV]).toEqual(["b", "", false]); + } finally { + process.env = env; + } + // The copy is gone from process.env, so the handles still write to the copy. + dev.replaceValue(true); + expect([copy.DEV, "DEV" in env]).toEqual(["1", false]); + value.restore(); + dev.restore(); + expect([copy.VALUE, "DEV" in copy, "VALUE" in env, "DEV" in env]).toEqual(["a", false, false, false]); + }, + ); + test("through a Proxy", () => { const target = { value: 1 }; const traps = []; From 83f0ae9b90a474611f594cfa84986e79c761609f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 10 Nov 2026 00:28:18 +0000 Subject: [PATCH 16/16] replaceProperty: a shadow gets the default attributes, and the type refuses a method key Jest assigns the shadow of an inherited property, so it gets the default attributes on every kind of object. The type uses PropertyLikeKeys, as Jest's does. --- packages/bun-types/test.d.ts | 2 +- src/jsc/bindings/JSMockFunction.cpp | 8 ++++---- test/integration/bun-types/fixture/mocks.ts | 4 ++++ test/js/bun/test/mock-fn.test.js | 15 +++++++++++++++ 4 files changed, 24 insertions(+), 5 deletions(-) diff --git a/packages/bun-types/test.d.ts b/packages/bun-types/test.d.ts index c72c35ee75d2..14ebeb06189d 100644 --- a/packages/bun-types/test.d.ts +++ b/packages/bun-types/test.d.ts @@ -346,7 +346,7 @@ declare module "bun:test" { * mode.replaceValue("staging"); * mode.restore(); */ - function replaceProperty( + function replaceProperty>( object: T, propertyKey: K, value: T[K], diff --git a/src/jsc/bindings/JSMockFunction.cpp b/src/jsc/bindings/JSMockFunction.cpp index 9615a508bda6..820a2ffd587e 100644 --- a/src/jsc/bindings/JSMockFunction.cpp +++ b/src/jsc/bindings/JSMockFunction.cpp @@ -937,9 +937,9 @@ class JSMockFunction final : public JSC::InternalFunction { } // The original may be a custom property, whose attributes would make putOwnPropertyStorage() treat the value as its getter. attributes &= PropertyAttribute::ReadOnly | PropertyAttribute::DontEnum | PropertyAttribute::DontDelete; - // A replacement of an inherited property is an own property that the restore deletes. + // A replacement of an inherited property is a new own property with the default attributes, as an assignment makes. The restore deletes it. if (!m_spyOriginal) - attributes &= ~PropertyAttribute::DontDelete; + attributes = 0; putOwnPropertyStorage(globalObject, target, m_spyIdentifier, value, attributes, isOpaque); return true; } @@ -3558,9 +3558,9 @@ __attribute__((minsize)) static JSValue replaceProperty(Zig::GlobalObject* globa original = getOwnPropertyStorage(globalObject, object, propertyKey, spyAttributes); RETURN_IF_EXCEPTION(scope, {}); } - // A replacement that shadows an inherited property is deleted on restore, so it has to be configurable. + // A replacement that shadows an inherited property is a new own property with the default attributes, as Jest's assignment makes. The restore deletes it. if (!property.isOwn) - attributes &= ~PropertyAttribute::DontDelete; + attributes = 0; if (isNamespace) spyAttributes |= JSMockFunction::SpyAttributeESModuleNamespace; if (property.isOpaque) diff --git a/test/integration/bun-types/fixture/mocks.ts b/test/integration/bun-types/fixture/mocks.ts index e355707d37dd..33550d83bc0d 100644 --- a/test/integration/bun-types/fixture/mocks.ts +++ b/test/integration/bun-types/fixture/mocks.ts @@ -57,6 +57,10 @@ expectType(jest.replaceProperty(process.env, "NODE_ENV", "test")).is 1); +// @ts-expect-error a class is spied on, not replaced +jest.replaceProperty(globalThis, "Date", class {}); // Same for `ReturnType`. expectType>().is any>>(); diff --git a/test/js/bun/test/mock-fn.test.js b/test/js/bun/test/mock-fn.test.js index 650879e47dc3..1a41bd616b76 100644 --- a/test/js/bun/test/mock-fn.test.js +++ b/test/js/bun/test/mock-fn.test.js @@ -2362,6 +2362,21 @@ describe("spyOn", () => { expect(target.value).toBe(1); }); + test("the shadow of an inherited property has the default attributes, as an assignment makes", () => { + const defaults = { value: 2, writable: true, enumerable: true, configurable: true }; + for (const target of [{}, [], function () {}]) { + const prototype = Object.create(Object.getPrototypeOf(target)); + Object.defineProperty(prototype, "k", { value: 1, writable: true, enumerable: false, configurable: true }); + Object.setPrototypeOf(target, prototype); + const replaced = jest.replaceProperty(target, "k", 2); + expect(Object.getOwnPropertyDescriptor(target, "k")).toEqual(defaults); + replaced.replaceValue(3); + expect(Object.getOwnPropertyDescriptor(target, "k")).toEqual({ ...defaults, value: 3 }); + replaced.restore(); + expect([Object.hasOwn(target, "k"), target.k]).toEqual([false, 1]); + } + }); + test("the same object and key give the same handle, and one restore", () => { const target = { value: 1 }; const first = jest.replaceProperty(target, "value", 2);