test.rs

  1//! Test support for GPUI.
  2//!
  3//! GPUI provides first-class support for testing, which includes a macro to run test that rely on having a context,
  4//! and a test implementation of the `ForegroundExecutor` and `BackgroundExecutor` which ensure that your tests run
  5//! deterministically even in the face of arbitrary parallelism.
  6//!
  7//! The output of the `gpui::test` macro is understood by other rust test runners, so you can use it with `cargo test`
  8//! or `cargo-nextest`, or another runner of your choice.
  9//!
 10//! To make it possible to test collaborative user interfaces (like Zed) you can ask for as many different contexts
 11//! as you need.
 12//!
 13//! ## Example
 14//!
 15//! ```
 16//! use gpui;
 17//!
 18//! #[gpui::test]
 19//! async fn test_example(cx: &TestAppContext) {
 20//!   assert!(true)
 21//! }
 22//!
 23//! #[gpui::test]
 24//! async fn test_collaboration_example(cx_a: &TestAppContext, cx_b: &TestAppContext) {
 25//!   assert!(true)
 26//! }
 27//! ```
 28use crate::{Entity, Subscription, TestAppContext, TestDispatcher};
 29use futures::StreamExt as _;
 30use proptest::prelude::{Just, Strategy, any};
 31use std::{
 32    env,
 33    panic::{self, RefUnwindSafe, UnwindSafe},
 34    pin::Pin,
 35};
 36
 37/// Strategy injected into `#[gpui::property_test]` tests to control the seed
 38/// given to the scheduler. Doesn't shrink, since all scheduler seeds are
 39/// equivalent in complexity. If `$SEED` is set, it always uses that value.
 40///
 41/// Note: this function is not intended to be used directly. Rather, it is
 42/// public so that it can be used from the `property_test` macro.
 43pub fn seed_strategy() -> impl Strategy<Value = u64> {
 44    match std::env::var("SEED") {
 45        Ok(val) => Just(val.parse().unwrap()).boxed(),
 46        Err(_) => any::<u64>().no_shrink().boxed(),
 47    }
 48}
 49
 50/// Applies a fixed RNG seed to a proptest config so that case generation
 51/// is deterministic. Uses `$SEED` if set, otherwise defaults to `0`.
 52/// This bridges the GPUI `SEED` env var to proptest's RNG seed, so that
 53/// a single variable controls both the scheduler seed and case generation.
 54///
 55/// Note: this function is not intended to be used directly. Rather, it is
 56/// public so that it can be used from the `property_test` macro.
 57pub fn apply_seed_to_proptest_config(
 58    mut config: proptest::test_runner::Config,
 59) -> proptest::test_runner::Config {
 60    let seed = env::var("SEED")
 61        .ok()
 62        .and_then(|val| val.parse::<u64>().ok())
 63        .unwrap_or(0);
 64    config.rng_seed = proptest::test_runner::RngSeed::Fixed(seed);
 65    config
 66}
 67
 68/// Similar to [`run_test`], but only runs the callback once, allowing
 69/// [`FnOnce`] callbacks. This is intended for use with the
 70/// `gpui::property_test` macro and generally should not be used directly.
 71///
 72/// Doesn't support many features of [`run_test`], since these are provided by
 73/// proptest.
 74pub fn run_test_once(seed: u64, test_fn: Box<dyn UnwindSafe + FnOnce(TestDispatcher)>) {
 75    let result = panic::catch_unwind(|| {
 76        let dispatcher = TestDispatcher::new(seed);
 77        let scheduler = dispatcher.scheduler().clone();
 78        test_fn(dispatcher);
 79        scheduler.end_test();
 80    });
 81
 82    match result {
 83        Ok(()) => {}
 84        Err(e) => panic::resume_unwind(e),
 85    }
 86}
 87
 88/// Run the given test function with the configured parameters.
 89/// This is intended for use with the `gpui::test` macro
 90/// and generally should not be used directly.
 91pub fn run_test(
 92    num_iterations: usize,
 93    explicit_seeds: &[u64],
 94    max_retries: usize,
 95    test_fn: &mut (dyn RefUnwindSafe + Fn(TestDispatcher, u64)),
 96    on_fail_fn: Option<fn()>,
 97) {
 98    let (seeds, is_multiple_runs) = calculate_seeds(num_iterations as u64, explicit_seeds);
 99
100    for seed in seeds {
101        let mut attempt = 0;
102        loop {
103            if is_multiple_runs {
104                eprintln!("seed = {seed}");
105            }
106            let result = panic::catch_unwind(|| {
107                let dispatcher = TestDispatcher::new(seed);
108                let scheduler = dispatcher.scheduler().clone();
109                test_fn(dispatcher, seed);
110                scheduler.end_test();
111            });
112
113            match result {
114                Ok(_) => break,
115                Err(error) => {
116                    if attempt < max_retries {
117                        println!("attempt {} failed, retrying", attempt);
118                        attempt += 1;
119                        // The panic payload might itself trigger an unwind on drop:
120                        // https://doc.rust-lang.org/std/panic/fn.catch_unwind.html#notes
121                        std::mem::forget(error);
122                    } else {
123                        if is_multiple_runs {
124                            eprintln!("failing seed: {seed}");
125                            eprintln!(
126                                "You can rerun from this seed by setting the environmental variable SEED to {seed}"
127                            );
128                        }
129                        if let Some(on_fail_fn) = on_fail_fn {
130                            on_fail_fn()
131                        }
132                        panic::resume_unwind(error);
133                    }
134                }
135            }
136        }
137    }
138}
139
140fn calculate_seeds(
141    iterations: u64,
142    explicit_seeds: &[u64],
143) -> (impl Iterator<Item = u64> + '_, bool) {
144    let iterations = env::var("ITERATIONS")
145        .ok()
146        .map(|var| var.parse().expect("invalid ITERATIONS variable"))
147        .unwrap_or(iterations);
148
149    let env_num = env::var("SEED")
150        .map(|seed| seed.parse().expect("invalid SEED variable as integer"))
151        .ok();
152
153    let empty_range = || 0..0;
154
155    let iter = {
156        let env_range = if let Some(env_num) = env_num {
157            env_num..env_num + 1
158        } else {
159            empty_range()
160        };
161
162        // if `iterations` is 1 and !(`explicit_seeds` is non-empty || `SEED` is set), then add     the run `0`
163        // if `iterations` is 1 and  (`explicit_seeds` is non-empty || `SEED` is set), then discard the run `0`
164        // if `iterations` isn't 1 and `SEED` is set, do `SEED..SEED+iterations`
165        // otherwise, do `0..iterations`
166        let iterations_range = match (iterations, env_num) {
167            (1, None) if explicit_seeds.is_empty() => 0..1,
168            (1, None) | (1, Some(_)) => empty_range(),
169            (iterations, Some(env)) => env..env + iterations,
170            (iterations, None) => 0..iterations,
171        };
172
173        // if `SEED` is set, ignore `explicit_seeds`
174        let explicit_seeds = if env_num.is_some() {
175            &[]
176        } else {
177            explicit_seeds
178        };
179
180        env_range
181            .chain(iterations_range)
182            .chain(explicit_seeds.iter().copied())
183    };
184    let is_multiple_runs = iter.clone().nth(1).is_some();
185    (iter, is_multiple_runs)
186}
187
188/// A test struct for converting an observation callback into a stream.
189pub struct Observation<T> {
190    rx: Pin<Box<async_channel::Receiver<T>>>,
191    _subscription: Subscription,
192}
193
194impl<T: 'static> futures::Stream for Observation<T> {
195    type Item = T;
196
197    fn poll_next(
198        mut self: std::pin::Pin<&mut Self>,
199        cx: &mut std::task::Context<'_>,
200    ) -> std::task::Poll<Option<Self::Item>> {
201        self.rx.poll_next_unpin(cx)
202    }
203}
204
205/// observe returns a stream of the change events from the given `Entity`
206pub fn observe<T: 'static>(entity: &Entity<T>, cx: &mut TestAppContext) -> Observation<()> {
207    let (tx, rx) = async_channel::unbounded();
208    let _subscription = cx.update(|cx| {
209        cx.observe(entity, move |_, _| {
210            let _ = pollster::block_on(tx.send(()));
211        })
212    });
213    let rx = Box::pin(rx);
214
215    Observation { rx, _subscription }
216}