diff --git a/packages/bun-types/test.d.ts b/packages/bun-types/test.d.ts index 714a2f71d6d1..77ac4b8e40be 100644 --- a/packages/bun-types/test.d.ts +++ b/packages/bun-types/test.d.ts @@ -100,6 +100,10 @@ declare module "bun:test" { function advanceTimersToNextTimer(): typeof vi; function runAllTimers(): typeof vi; function runOnlyPendingTimers(): typeof vi; + function advanceTimersByTimeAsync(milliseconds: number): Promise; + function advanceTimersToNextTimerAsync(): Promise; + function runAllTimersAsync(): Promise; + function runOnlyPendingTimersAsync(): Promise; function getTimerCount(): number; function clearAllTimers(): void; function isFakeTimers(): boolean; @@ -165,7 +169,7 @@ declare module "bun:test" { /** * Vitest-compatible mocking utilities, for migrating tests from Vitest to Bun. */ - export const vi: { + export interface VitestUtils { /** * Create a mock function */ @@ -193,10 +197,72 @@ declare module "bun:test" { advanceTimersToNextTimer: typeof jest.advanceTimersToNextTimer; runAllTimers: typeof jest.runAllTimers; runOnlyPendingTimers: typeof jest.runOnlyPendingTimers; + advanceTimersByTimeAsync: typeof jest.advanceTimersByTimeAsync; + advanceTimersToNextTimerAsync: typeof jest.advanceTimersToNextTimerAsync; + runAllTimersAsync: typeof jest.runAllTimersAsync; + runOnlyPendingTimersAsync: typeof jest.runOnlyPendingTimersAsync; getTimerCount: typeof jest.getTimerCount; clearAllTimers: typeof jest.clearAllTimers; isFakeTimers: typeof jest.isFakeTimers; - }; + /** + * Sets the current system time used by `Date.now()` and `new Date()`. + * Call with no argument to restore the real clock. + */ + setSystemTime: typeof setSystemTime; + /** + * Returns the mocked system time as a `Date`, or `null` when the clock is + * not mocked. + */ + getMockedSystemTime(): Date | null; + /** + * Returns the real system time in milliseconds, even when the clock is + * mocked. + */ + getRealSystemTime(): number; + /** + * Returns `true` if the given value is a mock function created by + * `vi.fn()` or `vi.spyOn()`. + */ + isMockFunction(value: unknown): value is Mock<(...args: any[]) => any>; + /** + * Type helper for mocked functions and modules. At runtime it returns its + * argument unchanged. + */ + mocked any>( + item: T, + options?: boolean | { partial?: boolean; deep?: boolean }, + ): Mock; + mocked(item: T, options?: boolean | { partial?: boolean; deep?: boolean }): T; + /** + * Sets an environment variable on `process.env` and remembers the + * original value. Pass `undefined` to remove the variable. + * Restore with `vi.unstubAllEnvs()`. + */ + stubEnv(name: string, value: string | undefined): VitestUtils; + /** + * Restores every environment variable changed with `vi.stubEnv()`. + */ + unstubAllEnvs(): VitestUtils; + /** + * Sets a property on `globalThis` and remembers the original value. + * Restore with `vi.unstubAllGlobals()`. + */ + stubGlobal(name: string, value: unknown): VitestUtils; + /** + * Restores every global changed with `vi.stubGlobal()`. + */ + unstubAllGlobals(): VitestUtils; + } + + /** + * Vitest-compatible mocking utilities, for migrating tests from Vitest to Bun. + */ + export const vi: VitestUtils; + + /** + * Alias of {@link vi}, matching the `vitest` export of the vitest module. + */ + export const vitest: VitestUtils; interface FunctionLike { readonly name: string; @@ -249,6 +315,10 @@ declare module "bun:test" { * even when the --concurrent flag is used. */ serial: Describe; + /** + * Alias of {@link Describe.serial}, matching vitest's `describe.sequential`. + */ + sequential: Describe; /** * Runs this group of tests, only if `condition` is true. * @@ -257,6 +327,12 @@ declare module "bun:test" { * @param condition if these tests should run */ if(condition: boolean): Describe; + /** + * Alias of {@link Describe.if}, matching vitest's `describe.runIf`. + * + * @param condition if these tests should run + */ + runIf(condition: boolean): Describe; /** * Skips this group of tests, if `condition` is true. * @@ -297,6 +373,15 @@ declare module "bun:test" { * @param fn the function that defines the tests */ export const describe: Describe<[]>; + /** + * Alias of {@link describe}, matching vitest's `suite` export. + */ + export const suite: Describe<[]>; + /** + * Accepts a benchmark declaration for vitest compatibility. Bun's test + * runner does not execute benchmarks: the callback never runs. + */ + export function bench(label: string, fn: () => void | Promise, options?: object): void; /** * Skips a group of related tests. * @@ -399,6 +484,17 @@ declare module "bun:test" { fn: (() => void | Promise) | ((done: (err?: unknown) => void) => void), options?: HookOptions, ): void; + /** + * Runs a function after the test finishes, but only if the test failed. + * + * Can only be called inside a test, not in `describe` blocks. + * + * @param fn the function to run + */ + export function onTestFailed( + fn: (() => void | Promise) | ((done: (err?: unknown) => void) => void), + options?: HookOptions, + ): void; /** * Sets the default timeout for all tests in the current file. A timeout set on an * individual test overrides this value. The default timeout is 5000ms (5 seconds). @@ -517,6 +613,10 @@ declare module "bun:test" { * runs, regardless of the `--todo` flag. */ failing: Test; + /** + * Alias of {@link Test.failing}, matching vitest's `test.fails`. + */ + fails: Test; /** * Runs the test concurrently with other concurrent tests. */ @@ -526,6 +626,10 @@ declare module "bun:test" { * even when the --concurrent flag is used. */ serial: Test; + /** + * Alias of {@link Test.serial}, matching vitest's `test.sequential`. + */ + sequential: Test; /** * Runs this test, if `condition` is true. * @@ -534,6 +638,12 @@ declare module "bun:test" { * @param condition if the test should run */ if(condition: boolean): Test; + /** + * Alias of {@link Test.if}, matching vitest's `test.runIf`. + * + * @param condition if the test should run + */ + runIf(condition: boolean): Test; /** * Skips this test, if `condition` is true. * diff --git a/src/jsc/JSValue.rs b/src/jsc/JSValue.rs index 09e31b639806..2267803a7db6 100644 --- a/src/jsc/JSValue.rs +++ b/src/jsc/JSValue.rs @@ -1492,6 +1492,46 @@ impl JSValue { JSC__JSValue__putMayBeIndex(self, global, key, value) }) } + /// Own-property read with an explicit key encoding: the prototype chain + /// is not consulted, non-ASCII UTF-8 key bytes are read as UTF-8 (the + /// byte-slice [`Self::get`] reads Latin-1), and an own property that + /// exists with the value `undefined` returns `Some(undefined)`, not + /// `None`. + pub fn get_own_encoded( + self, + global: &JSGlobalObject, + key: &bun_core::EncodedSlice, + ) -> JsResult> { + // SAFETY: `key` is valid for the duration of the call. + let v = unsafe { crate::cpp::JSC__JSValue__getOwnEncoded(self, global, key) }?; + if v.0 == JSValue::PROPERTY_DOES_NOT_EXIST.0 { + Ok(None) + } else { + Ok(Some(v)) + } + } + /// [`Self::delete_property`] with an explicit key encoding. + pub fn delete_property_encoded( + self, + global: &JSGlobalObject, + key: &bun_core::EncodedSlice, + ) -> JsResult { + crate::call_check_slow(global, || JSC__JSValue__deleteProperty(self, global, key)) + } + /// Method-table put: runs custom `put` overrides (for example + /// `process.env`'s setter). [`Self::put`] is a `putDirect` and bypasses + /// them. + pub fn put_generic( + self, + global: &JSGlobalObject, + key: impl AsRef<[u8]>, + value: JSValue, + ) -> JsResult<()> { + let key = bun_core::EncodedSlice::from_bytes(key.as_ref()); + host_fn::from_js_host_call_generic(global, || { + JSC__JSValue__putGeneric(self, global, &key, value) + }) + } pub fn put_to_property_key( target: JSValue, global: &JSGlobalObject, @@ -2077,6 +2117,12 @@ unsafe extern "C" { key: &bun_core::String, value: JSValue, ); + safe fn JSC__JSValue__putGeneric( + this: JSValue, + global: &JSGlobalObject, + key: &bun_core::EncodedSlice, + value: JSValue, + ); safe fn JSC__JSValue__putIndex(this: JSValue, global: &JSGlobalObject, i: u32, value: JSValue); safe fn JSC__JSValue__upsertBunStringArray( this: JSValue, diff --git a/src/jsc/bindings/JSMockFunction.cpp b/src/jsc/bindings/JSMockFunction.cpp index dc6f98304245..5d8afb794041 100644 --- a/src/jsc/bindings/JSMockFunction.cpp +++ b/src/jsc/bindings/JSMockFunction.cpp @@ -1423,6 +1423,17 @@ extern "C" [[ZIG_EXPORT(nothrow)]] double JSMock__getCurrentUnixTimeMs() return WTF::WallTime::now().secondsSinceEpoch().milliseconds(); } +// Helper function for native code to read the overriden Date.now() time (NaN = no override) +extern "C" [[ZIG_EXPORT(nothrow)]] double JSMock__getOverridenDateNow(JSC::JSGlobalObject* globalObject) +{ + return globalObject->overridenDateNow; +} + +extern "C" [[ZIG_EXPORT(nothrow)]] bool JSMock__isMockFunction(JSC::EncodedJSValue encodedValue) +{ + return !!dynamicDowncast(JSC::JSValue::decode(encodedValue)); +} + BUN_DEFINE_HOST_FUNCTION(JSMock__jsNow, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callframe)) { return JSValue::encode(jsNumber(globalObject->jsDateNow())); diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index 99347f460cbb..f5da532ccee9 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -4165,6 +4165,49 @@ void JSC__JSValue__putNonEnumerable(JSC::EncodedJSValue JSValue0, JSC::JSGlobalO object->putDirect(arg1->vm(), Zig::toIdentifier(*arg2, arg1), JSC::JSValue::decode(JSValue3), JSC::PropertyAttribute::DontEnum | 0); } +// Encoding-aware own-property read: the key's EncodedSlice bits decide +// Latin-1 vs UTF-8 vs UTF-16, integer-index names ("0") are safe, and the +// prototype chain is not consulted. Returns deleted if the own property +// does not exist. +extern "C" [[ZIG_EXPORT(zero_is_throw)]] JSC::EncodedJSValue JSC__JSValue__getOwnEncoded(JSC::EncodedJSValue target, JSC::JSGlobalObject* globalObject, const EncodedSlice* key) +{ + ASSERT_NO_PENDING_EXCEPTION(globalObject); + JSValue value = JSC::JSValue::decode(target); + ASSERT_WITH_MESSAGE(!value.isEmpty(), "getOwn() must not be called on empty value"); + + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + const auto identifier = Zig::toIdentifier(*key, globalObject); + const auto property = JSC::PropertyName(identifier); + + PropertySlot slot(value, PropertySlot::InternalMethodType::GetOwnProperty); + bool hasSlot = value.getOwnPropertySlot(globalObject, property, slot); + RETURN_IF_EXCEPTION(scope, {}); + if (!hasSlot) + return JSValue::encode(JSValue::decode(JSC::JSValue::ValueDeleted)); + + JSValue result = slot.getValue(globalObject, property); + RETURN_IF_EXCEPTION(scope, {}); + + return JSValue::encode(result); +} + +// Generic (method-table) put: runs custom `put` overrides such as +// JSEnvironmentVariableMap's, which JSC__JSValue__put (a putDirect) bypasses. +extern "C" [[ZIG_EXPORT(check_slow)]] void JSC__JSValue__putGeneric(JSC::EncodedJSValue target, JSC::JSGlobalObject* globalObject, const EncodedSlice* key, JSC::EncodedJSValue value) +{ + auto& vm = JSC::getVM(globalObject); + ThrowScope scope = DECLARE_THROW_SCOPE(vm); + JSC::JSObject* object = JSC::JSValue::decode(target).getObject(); + if (!object) + return; + JSC::Identifier identifier = Zig::toIdentifier(*key, globalObject); + JSC::PutPropertySlot slot(object, false); + object->methodTable()->put(object, globalObject, identifier, JSC::JSValue::decode(value), slot); + RETURN_IF_EXCEPTION(scope, ); +} + void JSC__JSValue__putToPropertyKey(JSC::EncodedJSValue JSValue0, JSC::JSGlobalObject* arg1, JSC::EncodedJSValue arg2, JSC::EncodedJSValue arg3) { auto& vm = JSC::getVM(arg1); diff --git a/src/runtime/cli/test_command.rs b/src/runtime/cli/test_command.rs index dd2400aa3cf1..54901611d6ae 100644 --- a/src/runtime/cli/test_command.rs +++ b/src/runtime/cli/test_command.rs @@ -1868,6 +1868,8 @@ impl TestCommand { unhandled_errors_between_tests: 0, summary: Summary::default(), node_test_used: false, + stubbed_envs: Vec::new(), + stubbed_globals: Vec::new(), }, repeat_count: 1, last_printed_dot: core::cell::Cell::new(false), diff --git a/src/runtime/jsc_hooks.rs b/src/runtime/jsc_hooks.rs index 029b356845de..b3ed83ff37bf 100644 --- a/src/runtime/jsc_hooks.rs +++ b/src/runtime/jsc_hooks.rs @@ -1760,6 +1760,10 @@ fn stop_active_handles(vm: &mut VirtualMachine, reason: StopReason) -> SweepResu unsafe { (*all).event_loop_delay.disable() }; } } + // vi.stubEnv / vi.stubGlobal state is per-thread, not per-global. + if reason == StopReason::TestIsolation { + crate::test_runner::jest::Jest::reset_stubs_for_isolation(vm.global()); + } // Entries that stay registered across a test-isolation swap. let mut kept: Vec = Vec::new(); loop { diff --git a/src/runtime/test_runner/Execution.rs b/src/runtime/test_runner/Execution.rs index e944e19d335f..d8d432a459ef 100644 --- a/src/runtime/test_runner/Execution.rs +++ b/src/runtime/test_runner/Execution.rs @@ -628,36 +628,44 @@ impl Execution { } } - fn on_sequence_completed(buntest: NonNull, sequence: &mut ExecutionSequence) { - let elapsed_ns: u64 = if sequence.started_at.eql(&Timespec::EPOCH) { - 0 - } else { - sequence.started_at.since_now_force_real_time() - }; + /// The result this sequence gets when it completes. Pure so the + /// `onTestFailed` gate can ask for the outcome before the deferred + /// failure modes (assertion counts, failing/todo inversion) are applied + /// to `sequence.result` by `on_sequence_completed`. + fn resolve_final_result(sequence: &ExecutionSequence) -> Result { + let mut result = sequence.result; match sequence.expect_assertions { ExpectAssertions::NotSet => {} ExpectAssertions::AtLeastOne => { - if sequence.expect_call_count == 0 - && sequence.result.is_pass(PendingIs::PendingIsPass) - { - sequence.result = Result::FailBecauseExpectedHasAssertions; + if sequence.expect_call_count == 0 && result.is_pass(PendingIs::PendingIsPass) { + result = Result::FailBecauseExpectedHasAssertions; } } ExpectAssertions::Exact(expected) => { if sequence.expect_call_count != expected - && sequence.result.is_pass(PendingIs::PendingIsPass) + && result.is_pass(PendingIs::PendingIsPass) { - sequence.result = Result::FailBecauseExpectedAssertionCount; + result = Result::FailBecauseExpectedAssertionCount; } } } - if sequence.result == Result::Pending { - sequence.result = match sequence.entry_mode() { + if result == Result::Pending { + result = match sequence.entry_mode() { ScopeMode::Failing => Result::FailBecauseFailingTestPassed, ScopeMode::Todo => Result::FailBecauseTodoPassed, _ => Result::Pass, }; } + result + } + + fn on_sequence_completed(buntest: NonNull, sequence: &mut ExecutionSequence) { + let elapsed_ns: u64 = if sequence.started_at.eql(&Timespec::EPOCH) { + 0 + } else { + sequence.started_at.since_now_force_real_time() + }; + sequence.result = Self::resolve_final_result(sequence); if let Some(first_entry) = sequence.first_entry { if sequence.test_entry.is_some() || sequence.result != Result::Pass { // SAFETY: deref parent BunTest at point-of-use. `sequence` aliases @@ -1004,6 +1012,11 @@ fn step_sequence_one( Execution::on_entry_started(next_item); if let Some(cb) = next_item.callback.as_ref() { + if next_item.only_on_failure && !Execution::resolve_final_result(sequence).is_fail() { + // onTestFailed() callback on a sequence that is not failing: skip it. + Execution::advance_sequence(buntest_ptr, sequence_ptr, group); + return Ok(None); // run again + } group_log::log(format_args!("runSequence queued callback")); let entry_data = EntryData { diff --git a/src/runtime/test_runner/ScopeFunctions.rs b/src/runtime/test_runner/ScopeFunctions.rs index 5ac34a7f1975..2d4864cbef89 100644 --- a/src/runtime/test_runner/ScopeFunctions.rs +++ b/src/runtime/test_runner/ScopeFunctions.rs @@ -77,6 +77,11 @@ impl ScopeFunctions { pub(crate) fn get_failing(this: &Self, global: &JSGlobalObject) -> JsResult { this.generic_extend(global, BaseScopeCfg { self_mode: SelfMode::Failing, ..Default::default() }, b"get .failing", "failing") } + /// vitest spells `.failing` as `.fails`. + #[bun_jsc::host_fn(getter)] + pub(crate) fn get_fails(this: &Self, global: &JSGlobalObject) -> JsResult { + this.generic_extend(global, BaseScopeCfg { self_mode: SelfMode::Failing, ..Default::default() }, b"get .fails", "fails") + } #[bun_jsc::host_fn(getter)] pub(crate) fn get_concurrent(this: &Self, global: &JSGlobalObject) -> JsResult { this.generic_extend(global, BaseScopeCfg { self_concurrent: SelfConcurrent::Yes, ..Default::default() }, b"get .concurrent", "concurrent") @@ -85,6 +90,11 @@ impl ScopeFunctions { pub(crate) fn get_serial(this: &Self, global: &JSGlobalObject) -> JsResult { this.generic_extend(global, BaseScopeCfg { self_concurrent: SelfConcurrent::No, ..Default::default() }, b"get .serial", "serial") } + /// vitest spells `.serial` as `.sequential`. + #[bun_jsc::host_fn(getter)] + pub(crate) fn get_sequential(this: &Self, global: &JSGlobalObject) -> JsResult { + this.generic_extend(global, BaseScopeCfg { self_concurrent: SelfConcurrent::No, ..Default::default() }, b"get .sequential", "sequential") + } #[bun_jsc::host_fn(getter)] pub(crate) fn get_only(this: &Self, global: &JSGlobalObject) -> JsResult { this.generic_extend(global, BaseScopeCfg { self_only: true, ..Default::default() }, b"get .only", "only") @@ -93,6 +103,11 @@ impl ScopeFunctions { pub(crate) fn fn_if(this: &Self, global: &JSGlobalObject, frame: &CallFrame) -> JsResult { this.generic_if(global, frame, BaseScopeCfg { self_mode: SelfMode::Skip, ..Default::default() }, b"call .if()", true, "if") } + /// vitest spells `.if` as `.runIf`. + #[bun_jsc::host_fn(method)] + pub(crate) fn fn_run_if(this: &Self, global: &JSGlobalObject, frame: &CallFrame) -> JsResult { + this.generic_if(global, frame, BaseScopeCfg { self_mode: SelfMode::Skip, ..Default::default() }, b"call .runIf()", true, "runIf") + } #[bun_jsc::host_fn(method)] pub(crate) fn fn_skip_if(this: &Self, global: &JSGlobalObject, frame: &CallFrame) -> JsResult { this.generic_if(global, frame, BaseScopeCfg { self_mode: SelfMode::Skip, ..Default::default() }, b"call .skipIf()", false, "skipIf") @@ -426,6 +441,7 @@ impl ScopeFunctions { timeout: options.timeout, retry_count: options.retry.unwrap_or(0), repeat_count: options.repeats, + ..Default::default() }, base, bun_test::AddedInPhase::Collection, diff --git a/src/runtime/test_runner/bun_test.rs b/src/runtime/test_runner/bun_test.rs index 98f2ae78561b..1f37face4448 100644 --- a/src/runtime/test_runner/bun_test.rs +++ b/src/runtime/test_runner/bun_test.rs @@ -115,7 +115,7 @@ pub mod js_fns { } /// Tags accepted by `generic_hook`. Superset of `DescribeScope::HookTag` - /// (adds `OnTestFinished`). + /// (adds `OnTestFinished` and `OnTestFailed`). // was a const-generic param (`adt_const_params` is unstable); // reshaped to runtime dispatch with per-tag thin host_fn wrappers below. #[derive(Copy, Clone, PartialEq, Eq, strum::IntoStaticStr)] @@ -130,6 +130,8 @@ pub mod js_fns { AfterAll, #[strum(serialize = "onTestFinished")] OnTestFinished, + #[strum(serialize = "onTestFailed")] + OnTestFailed, } impl GenericHookTag { const fn as_hook_tag(self) -> Option { @@ -138,9 +140,13 @@ pub mod js_fns { Self::BeforeEach => Some(HookTag::BeforeEach), Self::AfterEach => Some(HookTag::AfterEach), Self::AfterAll => Some(HookTag::AfterAll), - Self::OnTestFinished => None, + Self::OnTestFinished | Self::OnTestFailed => None, } } + /// True for the hooks that only register inside a running test. + const fn is_test_scoped(self) -> bool { + matches!(self, Self::OnTestFinished | Self::OnTestFailed) + } /// Per-variant signature string: the tag name plus `"()"`. const fn sig(self) -> &'static [u8] { match self { @@ -149,6 +155,7 @@ pub mod js_fns { Self::AfterEach => b"afterEach()", Self::AfterAll => b"afterAll()", Self::OnTestFinished => b"onTestFinished()", + Self::OnTestFailed => b"onTestFailed()", } } } @@ -187,11 +194,12 @@ pub mod js_fns { let cfg = ExecutionEntryCfg { has_done_parameter, timeout: args.options.timeout, + only_on_failure: tag == GenericHookTag::OnTestFailed, ..Default::default() }; let Some(bun_test) = bun_test_root.get_active_file_unless_in_preload(global_this.bun_vm()) else { - if tag == GenericHookTag::OnTestFinished { + if tag.is_test_scoped() { return Err(global_this.throw(format_args!( "Cannot call {}() in preload. It can only be called inside a test.", tag_name @@ -211,7 +219,7 @@ pub mod js_fns { match bun_test.phase { Phase::Collection => { - if tag == GenericHookTag::OnTestFinished { + if tag.is_test_scoped() { return Err(global_this.throw(format_args!( "Cannot call {}() outside of a test. It can only be called inside a test.", tag_name @@ -229,7 +237,7 @@ pub mod js_fns { Phase::Execution => { let active = bun_test.get_current_state_data(); let Some((sequence, _)) = bun_test.execution.get_current_and_valid_execution_sequence(&active) else { - return Err(if tag == GenericHookTag::OnTestFinished { + return Err(if tag.is_test_scoped() { global_this.throw(format_args!( "Cannot call {}() here. It cannot be called inside a concurrent test. Use test.serial or remove test.concurrent.", tag_name @@ -266,21 +274,30 @@ pub mod js_fns { } } } - GenericHookTag::OnTestFinished => 'blk: { - // Find the last entry in the sequence - let Some(mut last_entry) = sequence_ref.active_entry.map(|p| p.as_ptr()) else { + GenericHookTag::OnTestFinished | GenericHookTag::OnTestFailed => 'blk: { + let Some(mut append_point) = sequence_ref.active_entry.map(|p| p.as_ptr()) else { return Err(global_this.throw(format_args!( "Cannot call {}() here. Call it inside a test instead.", tag_name ))); }; + // onTestFailed entries append at the tail. + // onTestFinished entries splice before that gated + // tail, whatever the registration order, so every + // finished callback runs (and can still fail the + // test) before the failure gate is evaluated. + let keep_before_gated = tag == GenericHookTag::OnTestFinished; // SAFETY: intrusive linked-list traversal unsafe { - while let Some(next_entry) = (*last_entry).next { - last_entry = next_entry; + let mut cursor = (*append_point).next; + while let Some(entry) = cursor { + if !(keep_before_gated && (*entry).only_on_failure) { + append_point = entry; + } + cursor = (*entry).next; } } - break 'blk last_entry; + break 'blk append_point; } _ => { return Err(global_this.throw(format_args!( @@ -301,6 +318,10 @@ pub mod js_fns { let new_item_ptr = bun_core::heap::into_raw(new_item); // SAFETY: append_point is a valid linked-list node; new_item_ptr just allocated unsafe { + // A hook entry that fails skips only itself, so the + // remaining onTestFinished/onTestFailed entries still + // run (and the failure gate sees the failed result). + (*new_item_ptr).failure_skip_past = Some(new_item_ptr); (*new_item_ptr).next = (*append_point).next; (*append_point).next = Some(new_item_ptr); } @@ -340,6 +361,7 @@ pub mod js_fns { hook!(after_each, AfterEach); hook!(after_all, AfterAll); hook!(on_test_finished, OnTestFinished); + hook!(on_test_failed, OnTestFailed); } } @@ -1861,6 +1883,8 @@ pub struct ExecutionEntryCfg { pub(crate) retry_count: u32, /// Number of times to repeat a test (0 = run once, 1 = run twice, etc.) pub(crate) repeat_count: u32, + /// `onTestFailed()`: run the callback only when the sequence has failed. + pub(crate) only_on_failure: bool, } #[derive(Copy, Clone, PartialEq, Eq)] @@ -1884,6 +1908,8 @@ pub struct ExecutionEntry { pub(crate) retry_count: u32, /// Number of times to repeat a test (0 = run once, 1 = run twice, etc.) pub(crate) repeat_count: u32, + /// `onTestFailed()`: run the callback only when the sequence has failed. + pub(crate) only_on_failure: bool, pub(crate) next: Option<*mut ExecutionEntry>, /// if this entry fails, go to the entry 'failure_skip_past.next' @@ -1907,6 +1933,7 @@ impl ExecutionEntry { added_in_phase: phase, retry_count: cfg.retry_count, repeat_count: cfg.repeat_count, + only_on_failure: cfg.only_on_failure, timespec: Timespec::EPOCH, next: None, failure_skip_past: None, diff --git a/src/runtime/test_runner/jest.classes.ts b/src/runtime/test_runner/jest.classes.ts index 570d16f38a4c..241a3a31b7a1 100644 --- a/src/runtime/test_runner/jest.classes.ts +++ b/src/runtime/test_runner/jest.classes.ts @@ -826,6 +826,10 @@ export default [ getter: "getFailing", cache: true, }, + fails: { + getter: "getFails", + cache: true, + }, concurrent: { getter: "getConcurrent", cache: true, @@ -834,6 +838,10 @@ export default [ getter: "getSerial", cache: true, }, + sequential: { + getter: "getSequential", + cache: true, + }, only: { getter: "getOnly", cache: true, @@ -842,6 +850,10 @@ export default [ fn: "fnIf", length: 1, }, + runIf: { + fn: "fnRunIf", + length: 1, + }, skipIf: { fn: "fnSkipIf", length: 1, diff --git a/src/runtime/test_runner/jest.rs b/src/runtime/test_runner/jest.rs index d0ddb7033f31..13ae6b0a4efb 100644 --- a/src/runtime/test_runner/jest.rs +++ b/src/runtime/test_runner/jest.rs @@ -148,9 +148,16 @@ pub struct TestRunner<'a> { /// Set once any `node:test` registration API is called; gates `process.on('exit')` dispatch at the end of the run. pub(crate) node_test_used: bool, + /// vi.stubEnv / vi.stubGlobal registries: key bytes plus the original + /// value (`None` = absent, so restore deletes). First stub of a key wins. + pub(crate) stubbed_envs: StubRegistry, + pub(crate) stubbed_globals: StubRegistry, + pub(crate) bun_test_root: bun_test::BunTestRoot, } +pub(crate) type StubRegistry = Vec<(Box<[u8]>, Option)>; + impl<'a> TestRunner<'a> { pub(crate) fn get_active_timeout(&self) -> bun_core::Timespec { let Some(active_file) = self.bun_test_root.active_file.as_deref() else { @@ -331,7 +338,7 @@ pub mod Jest { } fn create_test_module(global_object: &JSGlobalObject) -> JsResult { - let module = JSValue::create_empty_object(global_object, 23); + let module = JSValue::create_empty_object(global_object, 44); let test_scope_functions = create_bound( global_object, @@ -361,6 +368,8 @@ pub mod Jest { "describe", )?; module.put(global_object, b"describe", describe_scope_functions); + // vitest alias for `describe`. + module.put(global_object, b"suite", describe_scope_functions); let xdescribe_scope_functions = create_bound( global_object, @@ -398,6 +407,11 @@ pub mod Jest { b"onTestFinished", jsc::JSFunction::create(global_object, "onTestFinished", generic_hook::__jsc_host_on_test_finished, 1, Default::default()), ); + module.put( + global_object, + b"onTestFailed", + jsc::JSFunction::create(global_object, "onTestFailed", generic_hook::__jsc_host_on_test_failed, 1, Default::default()), + ); module.put( global_object, b"setDefaultTimeout", @@ -406,7 +420,6 @@ pub mod Jest { module.put(global_object, b"expect", jsc::codegen::js::get_constructor::(global_object)); module.put(global_object, b"expectTypeOf", jsc::codegen::js::get_constructor::(global_object)); - // will add more 9 properties in the module here so we need to allocate 23 properties create_mock_objects(global_object, module); Ok(module) @@ -442,20 +455,279 @@ pub mod Jest { module.put(global_object, b"spyOn", spy_on); module.put(global_object, b"expect", jsc::codegen::js::get_constructor::(global_object)); - let vi = JSValue::create_empty_object(global_object, 6 + fake_timers::TIMER_FNS_COUNT); + let vi = JSValue::create_empty_object(global_object, 15 + fake_timers::TIMER_FNS_COUNT); vi.put(global_object, b"fn", mock_fn); vi.put(global_object, b"mock", mock_module_fn); vi.put(global_object, b"spyOn", spy_on); vi.put(global_object, b"restoreAllMocks", restore_all_mocks); vi.put(global_object, b"resetAllMocks", reset_all_mocks); vi.put(global_object, b"clearAllMocks", clear_all_mocks); + vi.put(global_object, b"setSystemTime", set_system_time); + for &(name, arity, func) in VI_FNS { + vi.put(global_object, name.as_bytes(), jsc::JSFunction::create(global_object, name, func, arity, Default::default())); + } module.put(global_object, b"vi", vi); + module.put(global_object, b"vitest", vi); + + // `vitest run` collects bench() entries but never executes them. + module.put(global_object, b"bench", jsc::JSFunction::create(global_object, "bench", __jsc_host_js_bench_noop, 2, Default::default())); + + // Unimplemented vitest exports throw on call. A missing named export + // would fail the whole file at load with a SyntaxError instead. + const UNIMPLEMENTED_VITEST_EXPORTS: &[&str] = &[ + "assert", + "assertType", + "aroundAll", + "aroundEach", + "BenchmarkRunner", + "chai", + "createExpect", + "EvaluatedModules", + "inject", + "recordArtifact", + "should", + "Snapshots", + "TestRunner", + ]; + for &name in UNIMPLEMENTED_VITEST_EXPORTS { + let stub = jsc::JSFunction::create(global_object, name, __jsc_host_js_vitest_unimplemented, 0, Default::default()); + module.put(global_object, name.as_bytes(), stub); + } fake_timers::put_timers_fns(global_object, jest, vi); } + // `#[bun_jsc::host_fn]` emits a `__jsc_host_{name}` shim with the raw + // `JSHostFn` ABI, which is what `JSFunction::create` expects. + const VI_FNS: &[(&str, u32, jsc::JSHostFn)] = &[ + ("isMockFunction", 1, __jsc_host_js_is_mock_function), + ("mocked", 1, __jsc_host_js_vi_mocked), + ("stubEnv", 2, __jsc_host_js_stub_env), + ("unstubAllEnvs", 0, __jsc_host_js_unstub_all_envs), + ("stubGlobal", 2, __jsc_host_js_stub_global), + ("unstubAllGlobals", 0, __jsc_host_js_unstub_all_globals), + ("getMockedSystemTime", 0, __jsc_host_js_get_mocked_system_time), + ("getRealSystemTime", 0, __jsc_host_js_get_real_system_time), + ]; + + #[bun_jsc::host_fn] + fn js_vitest_unimplemented(global_object: &JSGlobalObject, callframe: &CallFrame) -> JsResult { + let name = callframe + .callee() + .get(global_object, "name")? + .unwrap_or(JSValue::UNDEFINED); + let name_utf8 = name.to_utf8(global_object)?; + Err(global_object.throw(format_args!( + "{}() is not yet implemented in bun:test", + bstr::BStr::new(name_utf8.slice()) + ))) + } + + #[bun_jsc::host_fn] + fn js_bench_noop(_global_object: &JSGlobalObject, _callframe: &CallFrame) -> JsResult { + Ok(JSValue::UNDEFINED) + } + + #[bun_jsc::host_fn] + fn js_is_mock_function(_global_object: &JSGlobalObject, callframe: &CallFrame) -> JsResult { + let [value] = callframe.arguments_as_array::<1>(); + Ok(JSValue::from(JSMock__isMockFunction(value))) + } + + #[bun_jsc::host_fn] + fn js_vi_mocked(_global_object: &JSGlobalObject, callframe: &CallFrame) -> JsResult { + // vi.mocked() is a TypeScript-level cast; at runtime it returns its argument. + Ok(callframe.arguments_as_array::<1>()[0]) + } + + #[bun_jsc::host_fn] + fn js_get_mocked_system_time(global_object: &JSGlobalObject, _callframe: &CallFrame) -> JsResult { + // NaN is `JSGlobalObject::overridenDateNow`'s "no override" sentinel. + let ms = JSMock__getOverridenDateNow(global_object); + if ms.is_nan() { + return Ok(JSValue::NULL); + } + Ok(JSValue::from_date_number(global_object, ms)) + } + + #[bun_jsc::host_fn] + fn js_get_real_system_time(_global_object: &JSGlobalObject, _callframe: &CallFrame) -> JsResult { + Ok(JSValue::js_number(JSMock__getCurrentUnixTimeMs())) + } + + /// Which `TestRunner` stub registry a `vi.stubEnv`/`vi.stubGlobal` call + /// uses. The registries live on the runner (per-run state, dropped with + /// it on the JS thread), not in a thread-local or process global. + #[derive(Copy, Clone)] + enum StubKind { + Env, + Global, + } + impl StubKind { + fn signature(self) -> &'static str { + match self { + StubKind::Env => "vi.stubEnv", + StubKind::Global => "vi.stubGlobal", + } + } + fn registry<'r>(self, runner: &'r mut TestRunner<'static>) -> &'r mut StubRegistry { + match self { + StubKind::Env => &mut runner.stubbed_envs, + StubKind::Global => &mut runner.stubbed_globals, + } + } + } + + fn get_process_env(global_object: &JSGlobalObject) -> JsResult { + let global_this = global_object.to_js_value(); + let Some(process) = global_this.get(global_object, "process")? else { + return Err(global_object.throw(format_args!("process is not available"))); + }; + let Some(env) = process.get(global_object, "env")? else { + return Err(global_object.throw(format_args!("process.env is not available"))); + }; + Ok(env) + } + + fn stub_value( + global_object: &JSGlobalObject, + target: JSValue, + kind: StubKind, + key: JSValue, + value: JSValue, + ) -> JsResult<()> { + if !key.is_string() { + return Err(global_object.throw(format_args!("{}() expects a string name", kind.signature()))); + } + if runner().is_none() { + return Err(global_object.throw(format_args!("{}() can only be used in bun test", kind.signature()))); + } + let key_utf8 = key.to_utf8(global_object)?; + let key_bytes = key_utf8.slice(); + let key_slice = bun_core::EncodedSlice::from_bytes(key_bytes); + + // Read the original before the borrow of the runner: the property + // access can run user JS (a getter), which may re-enter these fns. + let mut original = target.get_own_encoded(global_object, &key_slice)?; + if matches!(kind, StubKind::Env) { + // Env values are strings, and the Windows env map reports a missing + // name as `undefined`: treat `undefined` as absent so restore + // deletes instead of writing the string "undefined". + original = original.filter(|v| !v.is_undefined()); + } + // Windows env names are ASCII-case-insensitive: PATH and Path are one + // variable, so they must share one registry entry. + let same_key = |k: &[u8]| -> bool { + if cfg!(windows) && matches!(kind, StubKind::Env) { + k.eq_ignore_ascii_case(key_bytes) + } else { + k == key_bytes + } + }; + if let Some(runner) = runner() { + let registry = kind.registry(runner); + if !registry.iter().any(|(k, _)| same_key(k)) { + registry.push(( + Box::<[u8]>::from(key_bytes), + original.map(|v| jsc::Strong::create(v, global_object)), + )); + } + } + + if value.is_undefined() && matches!(kind, StubKind::Env) { + // vitest: stubEnv(name, undefined) removes the variable, while + // stubGlobal(name, undefined) defines the global as undefined. + target.delete_property_encoded(global_object, &key_slice)?; + } else { + // Method-table put: process.env has a custom `put` (value + // coercion, Windows case handling) that a putDirect bypasses. + target.put_generic(global_object, key_bytes, value)?; + } + Ok(()) + } + + fn unstub_all( + global_object: &JSGlobalObject, + target: JSValue, + kind: StubKind, + ) -> JsResult<()> { + // Take the entries out before the restore puts run: a put can run + // user JS (a setter), which may re-enter these fns. + let stubbed = match runner() { + Some(runner) => core::mem::take(kind.registry(runner)), + None => return Ok(()), + }; + for (key, original) in stubbed { + match original { + Some(strong) => { + target.put_generic(global_object, &*key, strong.get())?; + } + None => { + target.delete_property_encoded( + global_object, + &bun_core::EncodedSlice::from_bytes(&key), + )?; + } + } + } + Ok(()) + } + + /// `bun test --isolate` file boundary: restore stubbed env vars, and drop + /// the stored originals so they cannot pin the outgoing realm. Global-stub + /// originals belong to that discarded realm, so they get no restore. + pub(crate) fn reset_stubs_for_isolation(global_object: &JSGlobalObject) { + let restored = get_process_env(global_object) + .and_then(|env| unstub_all(global_object, env, StubKind::Env)); + if restored.is_err() { + global_object.clear_exception_except_termination(); + } + if let Some(runner) = runner() { + runner.stubbed_envs.clear(); + runner.stubbed_globals.clear(); + } + } + + #[bun_jsc::host_fn] + fn js_stub_env(global_object: &JSGlobalObject, callframe: &CallFrame) -> JsResult { + let [key, value] = callframe.arguments_as_array::<2>(); + let env = get_process_env(global_object)?; + // process.env values are strings; coerce like an env assignment does. + let value = if value.is_undefined() { + value + } else { + let utf8 = value.to_utf8(global_object)?; + bun_string_jsc::create_utf8_for_js(global_object, utf8.slice())? + }; + stub_value(global_object, env, StubKind::Env, key, value)?; + Ok(callframe.this()) + } + + #[bun_jsc::host_fn] + fn js_unstub_all_envs(global_object: &JSGlobalObject, callframe: &CallFrame) -> JsResult { + let env = get_process_env(global_object)?; + unstub_all(global_object, env, StubKind::Env)?; + Ok(callframe.this()) + } + + #[bun_jsc::host_fn] + fn js_stub_global(global_object: &JSGlobalObject, callframe: &CallFrame) -> JsResult { + let [key, value] = callframe.arguments_as_array::<2>(); + stub_value(global_object, global_object.to_js_value(), StubKind::Global, key, value)?; + Ok(callframe.this()) + } + + #[bun_jsc::host_fn] + fn js_unstub_all_globals(global_object: &JSGlobalObject, callframe: &CallFrame) -> JsResult { + unstub_all(global_object, global_object.to_js_value(), StubKind::Global)?; + Ok(callframe.this()) + } + unsafe extern "C" { pub(crate) safe fn Bun__Jest__testModuleObject(global: &JSGlobalObject) -> JSValue; + safe fn JSMock__isMockFunction(value: JSValue) -> bool; + safe fn JSMock__getOverridenDateNow(global: &JSGlobalObject) -> f64; + safe fn JSMock__getCurrentUnixTimeMs() -> f64; } bun_jsc::jsc_abi_extern! { pub(crate) fn JSMock__jsMockFn(global: *mut JSGlobalObject, frame: *mut CallFrame) -> JSValue; diff --git a/src/runtime/test_runner/timers/FakeTimers.rs b/src/runtime/test_runner/timers/FakeTimers.rs index 9604d487b296..60d23f046a26 100644 --- a/src/runtime/test_runner/timers/FakeTimers.rs +++ b/src/runtime/test_runner/timers/FakeTimers.rs @@ -4,7 +4,7 @@ use bun_threading::RwLock; use bun_core::Environment; use bun_core::Timespec; -use bun_jsc::{CallFrame, JSFunction, JSGlobalObject, JSHostFn, JSValue, JsResult}; +use bun_jsc::{CallFrame, JSFunction, JSGlobalObject, JSHostFn, JSPromise, JSValue, JsResult}; use crate::api::cron::CronJob; use crate::jsc::virtual_machine::VirtualMachine; use crate::timer::{ @@ -415,8 +415,7 @@ fn advance_timers_to_next_timer(global: &JSGlobalObject, frame: &CallFrame) -> J Ok(frame.this()) } -#[bun_jsc::host_fn] -fn advance_timers_by_time(global: &JSGlobalObject, frame: &CallFrame) -> JsResult { +fn advance_timers_by_time_impl(global: &JSGlobalObject, frame: &CallFrame) -> JsResult<()> { error_unless_fake_timers(global)?; let arg = frame.arguments_as_array::<1>()[0]; @@ -448,6 +447,12 @@ fn advance_timers_by_time(global: &JSGlobalObject, frame: &CallFrame) -> JsResul CURRENT_TIME.set(global, &target, None); advanced?; + Ok(()) +} + +#[bun_jsc::host_fn] +fn advance_timers_by_time(global: &JSGlobalObject, frame: &CallFrame) -> JsResult { + advance_timers_by_time_impl(global, frame)?; Ok(frame.this()) } @@ -469,6 +474,40 @@ fn run_all_timers(global: &JSGlobalObject, frame: &CallFrame) -> JsResult JsResult { + advance_timers_by_time_impl(global, frame)?; + Ok(JSPromise::resolved_promise_value(global, frame.this())) +} + +#[bun_jsc::host_fn] +fn advance_timers_to_next_timer_async(global: &JSGlobalObject, frame: &CallFrame) -> JsResult { + error_unless_fake_timers(global)?; + FakeTimers::execute_next(global)?; + Ok(JSPromise::resolved_promise_value(global, frame.this())) +} + +#[bun_jsc::host_fn] +fn run_only_pending_timers_async(global: &JSGlobalObject, frame: &CallFrame) -> JsResult { + error_unless_fake_timers(global)?; + FakeTimers::execute_only_pending_timers(global)?; + Ok(JSPromise::resolved_promise_value(global, frame.this())) +} + +#[bun_jsc::host_fn] +fn run_all_timers_async(global: &JSGlobalObject, frame: &CallFrame) -> JsResult { + error_unless_fake_timers(global)?; + FakeTimers::execute_all_timers(global)?; + Ok(JSPromise::resolved_promise_value(global, frame.this())) +} + #[bun_jsc::host_fn] fn get_timer_count(global: &JSGlobalObject, _frame: &CallFrame) -> JsResult { error_unless_fake_timers(global)?; @@ -508,6 +547,10 @@ const FAKE_TIMERS_FNS: &[(&str, u32, JSHostFn)] = &[ ("advanceTimersByTime", 1, __jsc_host_advance_timers_by_time), ("runOnlyPendingTimers", 0, __jsc_host_run_only_pending_timers), ("runAllTimers", 0, __jsc_host_run_all_timers), + ("advanceTimersToNextTimerAsync", 0, __jsc_host_advance_timers_to_next_timer_async), + ("advanceTimersByTimeAsync", 1, __jsc_host_advance_timers_by_time_async), + ("runOnlyPendingTimersAsync", 0, __jsc_host_run_only_pending_timers_async), + ("runAllTimersAsync", 0, __jsc_host_run_all_timers_async), ("getTimerCount", 0, __jsc_host_get_timer_count), ("clearAllTimers", 0, __jsc_host_clear_all_timers), ("isFakeTimers", 0, __jsc_host_is_fake_timers), diff --git a/test/js/bun/test/vitest-compat.test.ts b/test/js/bun/test/vitest-compat.test.ts new file mode 100644 index 000000000000..7ce70b96f41a --- /dev/null +++ b/test/js/bun/test/vitest-compat.test.ts @@ -0,0 +1,567 @@ +// Tests for the vitest compatibility surface of bun:test (issue #40990). +// `bun test` aliases the "vitest" specifier to "bun:test", so this file +// imports through the alias on purpose. +import { bunEnv, bunExe, isWindows, tempDir } from "harness"; +import * as vitestModule from "vitest"; +import { bench, describe, expect, onTestFailed, suite, test, vi, vitest } from "vitest"; + +suite("suite is describe", () => { + test("suite registers a describe block", () => { + expect(suite).toBe(describe as unknown as typeof suite); + }); +}); + +suite.each([[1], [2]])("suite.each %i", n => { + test("runs", () => { + expect(n).toBeGreaterThan(0); + }); +}); + +test("vitest is an alias of vi", () => { + expect(vitest).toBe(vi); +}); + +describe("unimplemented exports are throwing stubs, not missing", () => { + const stubNames = [ + "assert", + "assertType", + "aroundAll", + "aroundEach", + "BenchmarkRunner", + "chai", + "createExpect", + "EvaluatedModules", + "inject", + "recordArtifact", + "should", + "Snapshots", + "TestRunner", + ] as const; + + test.each([...stubNames])("%s is exported and throws on call with its own name", name => { + const member = (vitestModule as Record)[name]; + expect(typeof member).toBe("function"); + expect(() => (member as () => void)()).toThrow(`${name}() is not yet implemented in bun:test`); + }); +}); + +test("bench is a no-op that never runs its callback", () => { + expect(typeof bench).toBe("function"); + expect(() => + bench("name", () => { + throw new Error("bench callback must not run"); + }), + ).not.toThrow(); +}); + +describe("vi members", () => { + test("vi.setSystemTime and vi.getMockedSystemTime", () => { + expect(vi.getMockedSystemTime()).toBeNull(); + vi.setSystemTime(new Date("2020-01-01T00:00:00Z")); + try { + expect(Date.now()).toBe(1577836800000); + const mocked = vi.getMockedSystemTime(); + expect(mocked).toBeInstanceOf(Date); + expect(mocked!.getTime()).toBe(1577836800000); + } finally { + vi.setSystemTime(); + } + expect(vi.getMockedSystemTime()).toBeNull(); + }); + + test("vi.getRealSystemTime ignores the mocked clock", () => { + vi.setSystemTime(new Date("2020-01-01T00:00:00Z")); + try { + const real = vi.getRealSystemTime(); + expect(typeof real).toBe("number"); + expect(real).toBeGreaterThan(1577836800000); + } finally { + vi.setSystemTime(); + } + }); + + test("vi.isMockFunction", () => { + expect(vi.isMockFunction(vi.fn())).toBe(true); + expect(vi.isMockFunction(vi.spyOn({ a() {} }, "a"))).toBe(true); + expect(vi.isMockFunction(() => {})).toBe(false); + expect(vi.isMockFunction(undefined)).toBe(false); + expect(vi.isMockFunction(42)).toBe(false); + }); + + test("vi.mocked returns its argument", () => { + const fn = vi.fn(); + expect(vi.mocked(fn)).toBe(fn); + const plain = {}; + expect(vi.mocked(plain)).toBe(plain); + }); + + test("vi.stubEnv and vi.unstubAllEnvs", () => { + process.env.VITEST_COMPAT_EXISTING = "original"; + delete process.env.VITEST_COMPAT_ADDED; + try { + vi.stubEnv("VITEST_COMPAT_EXISTING", "stubbed"); + vi.stubEnv("VITEST_COMPAT_EXISTING", "stubbed-twice"); + vi.stubEnv("VITEST_COMPAT_ADDED", "added"); + expect(process.env.VITEST_COMPAT_EXISTING).toBe("stubbed-twice"); + expect(process.env.VITEST_COMPAT_ADDED).toBe("added"); + + vi.unstubAllEnvs(); + expect(process.env.VITEST_COMPAT_EXISTING).toBe("original"); + expect(process.env.VITEST_COMPAT_ADDED).toBeUndefined(); + } finally { + vi.unstubAllEnvs(); + delete process.env.VITEST_COMPAT_EXISTING; + delete process.env.VITEST_COMPAT_ADDED; + } + }); + + test("vi.stubEnv(name, undefined) removes the variable", () => { + process.env.VITEST_COMPAT_REMOVE = "here"; + try { + vi.stubEnv("VITEST_COMPAT_REMOVE", undefined); + expect(process.env.VITEST_COMPAT_REMOVE).toBeUndefined(); + vi.unstubAllEnvs(); + expect(process.env.VITEST_COMPAT_REMOVE).toBe("here"); + } finally { + vi.unstubAllEnvs(); + delete process.env.VITEST_COMPAT_REMOVE; + } + }); + + test("vi.stubGlobal and vi.unstubAllGlobals", () => { + const originalFetch = globalThis.fetch; + try { + vi.stubGlobal("__vitest_compat_new__", 42); + expect((globalThis as Record).__vitest_compat_new__).toBe(42); + + vi.stubGlobal("fetch", "not-a-function"); + expect(globalThis.fetch as unknown).toBe("not-a-function"); + + vi.unstubAllGlobals(); + expect("__vitest_compat_new__" in globalThis).toBe(false); + expect(globalThis.fetch).toBe(originalFetch); + } finally { + vi.unstubAllGlobals(); + globalThis.fetch = originalFetch; + delete (globalThis as Record).__vitest_compat_new__; + } + }); + + test.skipIf(!isWindows)("vi.stubEnv dedups names case-insensitively on Windows", () => { + process.env.VITEST_COMPAT_CASE = "original"; + try { + vi.stubEnv("VITEST_COMPAT_CASE", "one"); + vi.stubEnv("vitest_compat_case", "two"); + expect(process.env.VITEST_COMPAT_CASE).toBe("two"); + vi.unstubAllEnvs(); + expect(process.env.VITEST_COMPAT_CASE).toBe("original"); + } finally { + vi.unstubAllEnvs(); + delete process.env.VITEST_COMPAT_CASE; + } + }); + + test("vi.stubEnv('TZ', ...) fires the timezone side effect", () => { + const originalTZ = process.env.TZ; + process.env.TZ = "Etc/UTC"; + try { + vi.stubEnv("TZ", "Etc/GMT-2"); + expect(new Date(0).getTimezoneOffset()).toBe(-120); + vi.unstubAllEnvs(); + expect(new Date(0).getTimezoneOffset()).toBe(0); + } finally { + vi.unstubAllEnvs(); + if (originalTZ === undefined) { + delete process.env.TZ; + } else { + process.env.TZ = originalTZ; + } + } + }); + + test("vi.stubGlobal(name, undefined) defines undefined instead of deleting", () => { + const original = globalThis.structuredClone; + try { + vi.stubGlobal("structuredClone", undefined); + expect(globalThis.structuredClone).toBeUndefined(); + expect("structuredClone" in globalThis).toBe(true); + vi.unstubAllGlobals(); + expect(globalThis.structuredClone).toBe(original); + } finally { + vi.unstubAllGlobals(); + globalThis.structuredClone = original; + } + }); + + test("an integer-like name stubs and restores without crashing", () => { + const g = globalThis as Record; + try { + vi.stubGlobal("0", "zero"); + expect(g["0"]).toBe("zero"); + vi.unstubAllGlobals(); + expect("0" in g).toBe(false); + } finally { + vi.unstubAllGlobals(); + delete g["0"]; + } + }); + + test("stubbing an inherited name restores by delete", () => { + const g = globalThis as Record; + try { + vi.stubGlobal("hasOwnProperty", "stubbed"); + expect(g.hasOwnProperty).toBe("stubbed"); + vi.unstubAllGlobals(); + // The name only existed on Object.prototype, so restore removes the + // own property instead of copying the inherited value onto globalThis. + expect(Object.getOwnPropertyDescriptor(globalThis, "hasOwnProperty")).toBeUndefined(); + expect(typeof g.hasOwnProperty).toBe("function"); + } finally { + vi.unstubAllGlobals(); + delete g.hasOwnProperty; + } + }); + + test("restore keeps a global that was already undefined", () => { + const g = globalThis as Record; + try { + g.__vitest_compat_undef__ = undefined; + vi.stubGlobal("__vitest_compat_undef__", "stubbed"); + expect(g.__vitest_compat_undef__).toBe("stubbed"); + vi.unstubAllGlobals(); + // The property existed with the value undefined, so restore keeps it + // instead of deleting it. + expect("__vitest_compat_undef__" in g).toBe(true); + expect(g.__vitest_compat_undef__).toBeUndefined(); + } finally { + vi.unstubAllGlobals(); + delete g.__vitest_compat_undef__; + } + }); + + test("non-ASCII names stub and restore correctly", () => { + const g = globalThis as Record; + try { + vi.stubGlobal("π", 3.14); + expect(g["π"]).toBe(3.14); + vi.stubEnv("ÜBER_COMPAT", "ja"); + expect(process.env["ÜBER_COMPAT"]).toBe("ja"); + + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + expect("π" in g).toBe(false); + expect(process.env["ÜBER_COMPAT"]).toBeUndefined(); + } finally { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + delete g["π"]; + delete process.env["ÜBER_COMPAT"]; + } + }); + + test("stub calls chain like vitest's utils object", () => { + try { + expect(vi.stubEnv("VITEST_COMPAT_CHAIN", "1").stubGlobal("__vitest_compat_chain__", 2)).toBe(vi); + expect(vi.unstubAllGlobals().unstubAllEnvs()).toBe(vi); + } finally { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + } + }); + + test("vi.stubEnv rejects a non-string name", () => { + expect(() => (vi.stubEnv as (k: unknown, v: unknown) => void)(42, "x")).toThrow( + "vi.stubEnv() expects a string name", + ); + }); +}); + +describe("async fake timer variants", () => { + test("advanceTimersByTimeAsync flushes an awaiting callback", async () => { + vi.useFakeTimers(); + try { + let done = false; + setTimeout(async () => { + await Promise.resolve(); + done = true; + }, 100); + await vi.advanceTimersByTimeAsync(100); + expect(done).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + + test("advanceTimersByTimeAsync flushes microtasks between timers", async () => { + // Pins vitest semantics: timer A's continuation (after an await) schedules + // timer B inside the advanced window, and B must fire in the same advance. + vi.useFakeTimers(); + try { + const order: string[] = []; + setTimeout(async () => { + order.push("A"); + await Promise.resolve(); + setTimeout(() => order.push("B"), 50); + }, 100); + await vi.advanceTimersByTimeAsync(200); + expect(order).toEqual(["A", "B"]); + } finally { + vi.useRealTimers(); + } + }); + + test("runAllTimersAsync and friends resolve", async () => { + vi.useFakeTimers(); + try { + let count = 0; + setTimeout(() => count++, 1); + setTimeout(() => count++, 2); + await vi.runAllTimersAsync(); + expect(count).toBe(2); + + setTimeout(() => count++, 1); + await vi.advanceTimersToNextTimerAsync(); + expect(count).toBe(3); + + setTimeout(() => count++, 1); + await vi.runOnlyPendingTimersAsync(); + expect(count).toBe(4); + } finally { + vi.useRealTimers(); + } + }); + + test("jest object also has the async variants", () => { + const { jest } = vitestModule as unknown as { jest: Record }; + expect(typeof jest.advanceTimersByTimeAsync).toBe("function"); + expect(typeof jest.runAllTimersAsync).toBe("function"); + expect(typeof jest.runOnlyPendingTimersAsync).toBe("function"); + expect(typeof jest.advanceTimersToNextTimerAsync).toBe("function"); + }); +}); + +describe("test and describe modifiers", () => { + test.fails("test.fails expects the test to fail", () => { + throw new Error("expected failure"); + }); + + test.runIf(true)("test.runIf(true) runs", () => { + expect(true).toBe(true); + }); + + test.runIf(false)("test.runIf(false) skips", () => { + throw new Error("must not run"); + }); + + describe.sequential("describe.sequential works", () => { + test("runs", () => { + expect(true).toBe(true); + }); + }); + + test.sequential("test.sequential runs", () => { + expect(true).toBe(true); + }); +}); + +describe("onTestFailed", () => { + let passHookRan = false; + test("does not run after a passing test", () => { + onTestFailed(() => { + passHookRan = true; + }); + expect(true).toBe(true); + }); + test("(checked in the next test)", () => { + expect(passHookRan).toBe(false); + }); + + test.concurrent("runs after a failing test, in registration context", async () => { + using dir = tempDir("vitest-compat-failhook", { + "failhook.test.ts": ` + import { test, onTestFailed, onTestFinished, expect } from "vitest"; + test("fails sync", () => { + onTestFailed(() => console.log("FAILED_HOOK_SYNC")); + onTestFinished(() => console.log("FINISHED_HOOK")); + expect(1).toBe(2); + }); + test("fails async", async () => { + onTestFailed(async () => { + await Promise.resolve(); + console.log("FAILED_HOOK_ASYNC"); + }); + throw new Error("boom"); + }); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "failhook.test.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toContain("FAILED_HOOK_SYNC"); + expect(stdout).toContain("FINISHED_HOOK"); + expect(stdout).toContain("FAILED_HOOK_ASYNC"); + expect(stderr).toContain("2 fail"); + expect(exitCode).toBe(1); + }); + + test.concurrent("runs for failure modes decided at sequence completion", async () => { + using dir = tempDir("vitest-compat-failhook-deferred", { + "deferred.test.ts": ` + import { test, onTestFailed, onTestFinished, expect } from "vitest"; + test("assertion count mismatch", () => { + onTestFailed(() => console.log("FAILED_HOOK_ASSERTIONS")); + expect.assertions(2); + expect(1).toBe(1); + }); + test.fails("test.fails whose body passes", () => { + onTestFailed(() => console.log("FAILED_HOOK_FAILS_PASSED")); + expect(1).toBe(1); + }); + test.fails("test.fails whose body throws", () => { + onTestFailed(() => console.log("FAILED_HOOK_FAILS_THREW")); + throw new Error("expected"); + }); + test("onTestFinished failure still triggers an earlier onTestFailed", () => { + onTestFailed(() => console.log("FAILED_AFTER_FINISHED")); + onTestFinished(() => { + throw new Error("finished boom"); + }); + }); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "deferred.test.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toContain("FAILED_HOOK_ASSERTIONS"); + expect(stdout).toContain("FAILED_HOOK_FAILS_PASSED"); + // A test.fails test whose body throws ends up passing, so its hook must not run. + expect(stdout).not.toContain("FAILED_HOOK_FAILS_THREW"); + expect(stdout).toContain("FAILED_AFTER_FINISHED"); + expect(stderr).toContain("3 fail"); + expect(exitCode).toBe(1); + }); + + test.concurrent("cannot be called outside of a test", async () => { + using dir = tempDir("vitest-compat-failhook-outside", { + "outside.test.ts": ` + import { test, onTestFailed } from "vitest"; + onTestFailed(() => {}); + test("never runs", () => {}); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "outside.test.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toContain("Cannot call onTestFailed() outside of a test"); + // The file fails at load, so no test registers or passes. + expect(stderr).not.toContain("(pass)"); + expect(exitCode).toBe(1); + }); +}); + +test.concurrent("stub stores reset at the --isolate file boundary", async () => { + using dir = tempDir("vitest-compat-isolate-stubs", { + "a.test.ts": ` + import { test, vi, expect } from "vitest"; + test("stub env and global, never unstub", () => { + process.env.ISO_LEAK = "original"; + vi.stubEnv("ISO_LEAK", "stubbed"); + vi.stubGlobal("structuredClone", "broken"); + expect(process.env.ISO_LEAK).toBe("stubbed"); + }); + `, + "b.test.ts": ` + import { test, vi, expect } from "vitest"; + test("previous file's stub records are gone", () => { + // The realm swap rebuilt process.env, and the boundary cleared the + // stub stores: unstubAll* must be no-ops, not restore stale values + // recorded in the previous file's realm. + const cloneBefore = globalThis.structuredClone; + expect(typeof cloneBefore).toBe("function"); + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + expect(process.env.ISO_LEAK).toBeUndefined(); + expect(globalThis.structuredClone).toBe(cloneBefore); + }); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "--isolate", "a.test.ts", "b.test.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).not.toContain("ISO_LEAK"); + expect(stderr).toContain("2 pass"); + expect(exitCode).toBe(0); +}); + +test.concurrent("a static import of a previously missing export no longer kills the file", async () => { + using dir = tempDir("vitest-compat-static-import", { + "static-import.test.ts": ` + import { test, suite, vitest, onTestFailed, assert, chai } from "vitest"; + suite("loads", () => { + test("passes", () => { + vitest.fn(); + // assert and chai are importable; only calling them throws. + void assert; + void chai; + void onTestFailed; + }); + }); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "static-import.test.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).not.toContain("SyntaxError"); + expect(stdout).not.toContain("SyntaxError"); + expect(exitCode).toBe(0); +}); + +test.concurrent("vi.stubEnv outside bun test throws", async () => { + using dir = tempDir("vitest-compat-stub-outside", { + "stub-outside.ts": ` + import { vi } from "bun:test"; + try { + vi.stubEnv("X", "1"); + console.log("NO_THROW"); + } catch (e) { + console.log("THREW:", (e as Error).message); + } + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "stub-outside.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toContain("THREW: vi.stubEnv() can only be used in bun test"); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); +});