diff --git a/docs/test/mocks.mdx b/docs/test/mocks.mdx index 8308439c4277..e4b4b11d824c 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, and it replaces an export of a module namespace object, as `spyOn()` does. + ### 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..14ebeb06189d 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()`. diff --git a/src/aot/builtins/resolve/written_members.rs b/src/aot/builtins/resolve/written_members.rs index 55d64705d753..8a7524b1c784 100644 --- a/src/aot/builtins/resolve/written_members.rs +++ b/src/aot/builtins/resolve/written_members.rs @@ -116,7 +116,8 @@ fn find_writer(function: BuiltinId) -> Option { | BuiltinId::BunTestJestSpyOn | BuiltinId::BunTestViSpyOn | BuiltinId::BunTestVitestSpyOn - | BuiltinId::BunTestVitestViSpyOn => Writer::Spy, + | BuiltinId::BunTestVitestViSpyOn + | BuiltinId::BunTestJestReplaceProperty => Writer::Spy, BuiltinId::ObjectDefineProperty | BuiltinId::ReflectDefineProperty | BuiltinId::ReflectDeleteProperty diff --git a/src/aot/modules/test_replacements.rs b/src/aot/modules/test_replacements.rs index efdbd05f58b0..90ca3a6b48ed 100644 --- a/src/aot/modules/test_replacements.rs +++ b/src/aot/modules/test_replacements.rs @@ -483,7 +483,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", @@ -497,6 +497,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..07fa52e9c95f 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"); +// 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 }, + returns: b.any, + capability: "js", + throws: true, + uses: { + value: [b.runs("ToPrimitive")], + 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/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 66a117d79843..816c80da4472 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); @@ -286,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) @@ -355,6 +360,41 @@ 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) +{ + PutPropertySlot slot(object, true); + object->methodTable()->put(object, globalObject, key, value, slot); +} + +// 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)) { + 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::spyOnExportErrorMessage(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 +490,18 @@ 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; + // 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() { @@ -865,6 +911,39 @@ 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) { + if (attributes & SpyAttributeImportMetaEnv) + value = processEnvValueOfImportMetaEnvForTests(globalObject, m_spyIdentifier, value); + 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 a new own property with the default attributes, as an assignment makes. The restore deletes it. + if (!m_spyOriginal) + attributes = 0; + 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 +1033,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 +1090,19 @@ class JSMockFunction final : public JSC::InternalFunction { return; } + if (attributes & SpyAttributeAssigned) { + if (original) { + scope.release(); + assignProperty(globalObject, target, identifier, original); + 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; + } + if (attributes & SpyAttributeAccessor) { // Restoring the spy on the accessor's other half already put both halves back. unsigned currentAttributes = 0; @@ -1279,6 +1379,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; @@ -1412,6 +1518,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())); @@ -2952,11 +3066,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(); @@ -2984,84 +3093,53 @@ 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 }; + // 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; + unsigned slotAttributes { 0 }; + bool hasValue { false }; + bool isOwn { false }; + bool isOpaque { false }; + // A data property or a missing one, spied as a value. + bool isEasy { false }; +}; + +// 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(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 +3147,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 +3155,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 +3197,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::spyOnExportErrorMessage(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 +3422,266 @@ __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 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); + 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, 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); + 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. 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); + for (auto& entry : globalObject->mockModule.replacedProperties) { + auto* mock = uncheckedDowncast(entry.mock.get()); + if (mock->isInstalledOn(target, propertyKey) && (isInPreload || !mock->isMadeByPreload)) + return &entry; + } + return nullptr; +} + +// 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); + 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 {}; + } + // 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); + RETURN_IF_EXCEPTION(scope, {}); + if (property.isOwn) { + // 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'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 {}; + } + } + } + + 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; + // 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 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)); + 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, 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()); + existingMock->putReplacement(globalObject, callframe, value); + RETURN_IF_EXCEPTION(scope, {}); + return handle ? handle : existingHandle; + } + + // 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 = 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)); + return {}; + } + } + + // 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 (!env && 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 a new own property with the default attributes, as Jest's assignment makes. The restore deletes it. + if (!property.isOwn) + attributes = 0; + if (isNamespace) + spyAttributes |= JSMockFunction::SpyAttributeESModuleNamespace; + if (property.isOpaque) + spyAttributes |= JSMockFunction::SpyAttributeOpaque; + if (isAssigned) + spyAttributes |= JSMockFunction::SpyAttributeAssigned; + if (env) + spyAttributes |= JSMockFunction::SpyAttributeImportMetaEnv; + + if (!mock) { + mock = createMockFunction(vm, globalObject, false); + mock->isJest = true; + mock->isReplacedProperty = true; + } + if (isAssigned) { + if (env) + value = processEnvValueOfImportMetaEnvForTests(globalObject, propertyKey, value); + assignProperty(globalObject, installTarget, 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(installTarget, 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 { + +// 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, {}); + // A replacement through import.meta.env is installed on the process.env behind it. + if (JSObject* env = processEnvOfImportMetaEnvForTests(globalObject, target)) + target = env; + 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()); +} + +// 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 +3773,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..1d7f5b832119 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,13 @@ 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 its `Replaced` handle. Nothing else roots that mock. + 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/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/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 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 154bfab4154f..6be230199633 100644 --- a/test/cli/test/isolation.test.ts +++ b/test/cli/test/isolation.test.ts @@ -3105,6 +3105,128 @@ 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"); + jest.replaceProperty(target, "loading", "replaced by a"); + expect(target).toEqual({ loading: "replaced by a", 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..33550d83bc0d 100644 --- a/test/integration/bun-types/fixture/mocks.ts +++ b/test/integration/bun-types/fixture/mocks.ts @@ -46,6 +46,22 @@ 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); +// @ts-expect-error a method is spied on, not replaced +jest.replaceProperty(Math, "max", () => 1); +// @ts-expect-error a class is spied on, not replaced +jest.replaceProperty(globalThis, "Date", class {}); + // 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..44a3ab59feb2 100644 --- a/test/js/bun/test/mock-fn.test.js +++ b/test/js/bun/test/mock-fn.test.js @@ -2246,6 +2246,470 @@ 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"; + 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", () => { + 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 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); + 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("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 }; + 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.", + ); + }); + + // 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; + } + // 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(), process.env.TZ]).toEqual([-540, "Asia/Tokyo"]); + tz.restore(); + 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("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.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.skipIf(process.env.MODE !== undefined)( + "restore on a restored import.meta.env handle puts back the original of a later replacement", + () => { + // As in a describe() with afterEach(() => mode.restore()), whose tests call jest.replaceProperty() again. + const mode = jest.replaceProperty(import.meta.env, "MODE", "a"); + mode.restore(); + const later = jest.replaceProperty(import.meta.env, "MODE", "c"); + expect([later === mode, import.meta.env.MODE, process.env.MODE]).toEqual([false, "c", "c"]); + mode.restore(); + expect([import.meta.env.MODE, "MODE" in process.env]).toEqual(["test", 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); + }, + // 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); + replaced.restore(); + expect(target.value).toBe(1); + expect(traps).toEqual([ + ["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 })); + let isReplacing = true; + const busy = new Proxy( + { value: 1 }, + { + defineProperty(target, key, descriptor) { + 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)); + }); + } +}); + 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 });