diff --git a/Cargo.lock b/Cargo.lock index 5b5824c49d..6a548969d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3006,7 +3006,6 @@ dependencies = [ "libc", "log", "parking_lot 0.11.2", - "rand 0.8.5", "regex", "rope", "serde", diff --git a/crates/fs2/Cargo.toml b/crates/fs2/Cargo.toml index 81911010aa..36f4e9c9c9 100644 --- a/crates/fs2/Cargo.toml +++ b/crates/fs2/Cargo.toml @@ -35,7 +35,6 @@ gpui2 = { path = "../gpui2", optional = true} [dev-dependencies] gpui2 = { path = "../gpui2", features = ["test-support"] } -rand.workspace = true [features] test-support = ["gpui2/test-support"] diff --git a/crates/fs2/src/fs2.rs b/crates/fs2/src/fs2.rs index 112978d06e..6ff8676473 100644 --- a/crates/fs2/src/fs2.rs +++ b/crates/fs2/src/fs2.rs @@ -1222,62 +1222,57 @@ pub fn copy_recursive<'a>( #[cfg(test)] mod tests { use super::*; - use gpui2::{Executor, TestDispatcher}; - use rand::prelude::*; + use gpui2::Executor; use serde_json::json; - #[test] - fn test_fake_fs() { - let dispatcher = TestDispatcher::new(StdRng::seed_from_u64(0)); - let executor = Executor::new(Arc::new(dispatcher)); + #[gpui2::test] + async fn test_fake_fs(executor: Executor) { let fs = FakeFs::new(executor.clone()); - executor.block(async move { - fs.insert_tree( - "/root", - json!({ - "dir1": { - "a": "A", - "b": "B" - }, - "dir2": { - "c": "C", - "dir3": { - "d": "D" - } + fs.insert_tree( + "/root", + json!({ + "dir1": { + "a": "A", + "b": "B" + }, + "dir2": { + "c": "C", + "dir3": { + "d": "D" } - }), - ) + } + }), + ) + .await; + + assert_eq!( + fs.files(), + vec![ + PathBuf::from("/root/dir1/a"), + PathBuf::from("/root/dir1/b"), + PathBuf::from("/root/dir2/c"), + PathBuf::from("/root/dir2/dir3/d"), + ] + ); + + fs.insert_symlink("/root/dir2/link-to-dir3", "./dir3".into()) .await; - assert_eq!( - fs.files(), - vec![ - PathBuf::from("/root/dir1/a"), - PathBuf::from("/root/dir1/b"), - PathBuf::from("/root/dir2/c"), - PathBuf::from("/root/dir2/dir3/d"), - ] - ); - - fs.insert_symlink("/root/dir2/link-to-dir3", "./dir3".into()) - .await; - - assert_eq!( - fs.canonicalize("/root/dir2/link-to-dir3".as_ref()) - .await - .unwrap(), - PathBuf::from("/root/dir2/dir3"), - ); - assert_eq!( - fs.canonicalize("/root/dir2/link-to-dir3/d".as_ref()) - .await - .unwrap(), - PathBuf::from("/root/dir2/dir3/d"), - ); - assert_eq!( - fs.load("/root/dir2/link-to-dir3/d".as_ref()).await.unwrap(), - "D", - ); - }); + assert_eq!( + fs.canonicalize("/root/dir2/link-to-dir3".as_ref()) + .await + .unwrap(), + PathBuf::from("/root/dir2/dir3"), + ); + assert_eq!( + fs.canonicalize("/root/dir2/link-to-dir3/d".as_ref()) + .await + .unwrap(), + PathBuf::from("/root/dir2/dir3/d"), + ); + assert_eq!( + fs.load("/root/dir2/link-to-dir3/d".as_ref()).await.unwrap(), + "D", + ); } } diff --git a/crates/gpui2/src/gpui2.rs b/crates/gpui2/src/gpui2.rs index ef95fdfdf2..9fcc2e4478 100644 --- a/crates/gpui2/src/gpui2.rs +++ b/crates/gpui2/src/gpui2.rs @@ -17,6 +17,8 @@ mod styled; mod subscription; mod svg_renderer; mod taffy; +#[cfg(any(test, feature = "test-support"))] +mod test; mod text_system; mod util; mod view; @@ -48,6 +50,8 @@ pub use styled::*; pub use subscription::*; pub use svg_renderer::*; pub use taffy::{AvailableSpace, LayoutId}; +#[cfg(any(test, feature = "test-support"))] +pub use test::*; pub use text_system::*; pub use util::arc_cow::ArcCow; pub use view::*; diff --git a/crates/gpui2/src/platform/test/dispatcher.rs b/crates/gpui2/src/platform/test/dispatcher.rs index a181e1995c..746a5ed0c0 100644 --- a/crates/gpui2/src/platform/test/dispatcher.rs +++ b/crates/gpui2/src/platform/test/dispatcher.rs @@ -75,6 +75,10 @@ impl TestDispatcher { count: self.state.lock().random.gen_range(0..10), } } + + pub fn run_until_parked(&self) { + while self.poll() {} + } } impl Clone for TestDispatcher { diff --git a/crates/gpui2/src/test.rs b/crates/gpui2/src/test.rs new file mode 100644 index 0000000000..ffad9aab8b --- /dev/null +++ b/crates/gpui2/src/test.rs @@ -0,0 +1,51 @@ +use crate::TestDispatcher; +use rand::prelude::*; +use std::{ + env, + panic::{self, RefUnwindSafe}, +}; + +pub fn run_test( + mut num_iterations: u64, + max_retries: usize, + test_fn: &mut (dyn RefUnwindSafe + Fn(TestDispatcher)), + on_fail_fn: Option, + _fn_name: String, // todo!("re-enable fn_name") +) { + let starting_seed = env::var("SEED") + .map(|seed| seed.parse().expect("invalid SEED variable")) + .unwrap_or(0); + let is_randomized = num_iterations > 1; + if let Ok(iterations) = env::var("ITERATIONS") { + num_iterations = iterations.parse().expect("invalid ITERATIONS variable"); + } + + for seed in starting_seed..starting_seed + num_iterations { + let mut retry = 0; + loop { + if is_randomized { + eprintln!("seed = {seed}"); + } + let result = panic::catch_unwind(|| { + let dispatcher = TestDispatcher::new(StdRng::seed_from_u64(seed)); + test_fn(dispatcher); + }); + + match result { + Ok(_) => break, + Err(error) => { + if retry < max_retries { + println!("retrying: attempt {}", retry); + retry += 1; + } else { + if is_randomized { + eprintln!("failing seed: {}", seed); + } + on_fail_fn.map(|f| f()); + panic::resume_unwind(error); + } + } + } + } + } +} diff --git a/crates/gpui2_macros/src/gpui2_macros.rs b/crates/gpui2_macros/src/gpui2_macros.rs index 7fd8d5ea36..59fd046c83 100644 --- a/crates/gpui2_macros/src/gpui2_macros.rs +++ b/crates/gpui2_macros/src/gpui2_macros.rs @@ -2,6 +2,7 @@ use proc_macro::TokenStream; mod derive_element; mod style_helpers; +mod test; #[proc_macro] pub fn style_helpers(args: TokenStream) -> TokenStream { @@ -12,3 +13,8 @@ pub fn style_helpers(args: TokenStream) -> TokenStream { pub fn derive_element(input: TokenStream) -> TokenStream { derive_element::derive_element(input) } + +#[proc_macro_attribute] +pub fn test(args: TokenStream, function: TokenStream) -> TokenStream { + test::test(args, function) +} diff --git a/crates/gpui2_macros/src/test.rs b/crates/gpui2_macros/src/test.rs new file mode 100644 index 0000000000..4185e4850a --- /dev/null +++ b/crates/gpui2_macros/src/test.rs @@ -0,0 +1,255 @@ +use proc_macro::TokenStream; +use proc_macro2::Ident; +use quote::{format_ident, quote}; +use std::mem; +use syn::{ + parse_macro_input, parse_quote, spanned::Spanned as _, AttributeArgs, DeriveInput, FnArg, + GenericParam, Generics, ItemFn, Lit, Meta, NestedMeta, Type, WhereClause, +}; + +pub fn test(args: TokenStream, function: TokenStream) -> TokenStream { + let args = syn::parse_macro_input!(args as AttributeArgs); + let mut max_retries = 0; + let mut num_iterations = 1; + let mut on_failure_fn_name = quote!(None); + + for arg in args { + match arg { + NestedMeta::Meta(Meta::NameValue(meta)) => { + let key_name = meta.path.get_ident().map(|i| i.to_string()); + let result = (|| { + match key_name.as_deref() { + Some("retries") => max_retries = parse_int(&meta.lit)?, + Some("iterations") => num_iterations = parse_int(&meta.lit)?, + Some("on_failure") => { + if let Lit::Str(name) = meta.lit { + let mut path = syn::Path { + leading_colon: None, + segments: Default::default(), + }; + for part in name.value().split("::") { + path.segments.push(Ident::new(part, name.span()).into()); + } + on_failure_fn_name = quote!(Some(#path)); + } else { + return Err(TokenStream::from( + syn::Error::new( + meta.lit.span(), + "on_failure argument must be a string", + ) + .into_compile_error(), + )); + } + } + _ => { + return Err(TokenStream::from( + syn::Error::new(meta.path.span(), "invalid argument") + .into_compile_error(), + )) + } + } + Ok(()) + })(); + + if let Err(tokens) = result { + return tokens; + } + } + other => { + return TokenStream::from( + syn::Error::new_spanned(other, "invalid argument").into_compile_error(), + ) + } + } + } + + let mut inner_fn = parse_macro_input!(function as ItemFn); + if max_retries > 0 && num_iterations > 1 { + return TokenStream::from( + syn::Error::new_spanned(inner_fn, "retries and randomized iterations can't be mixed") + .into_compile_error(), + ); + } + let inner_fn_attributes = mem::take(&mut inner_fn.attrs); + let inner_fn_name = format_ident!("_{}", inner_fn.sig.ident); + let outer_fn_name = mem::replace(&mut inner_fn.sig.ident, inner_fn_name.clone()); + + let mut outer_fn: ItemFn = if inner_fn.sig.asyncness.is_some() { + // Pass to the test function the number of app contexts that it needs, + // based on its parameter list. + let mut cx_vars = proc_macro2::TokenStream::new(); + let mut cx_teardowns = proc_macro2::TokenStream::new(); + let mut inner_fn_args = proc_macro2::TokenStream::new(); + for (ix, arg) in inner_fn.sig.inputs.iter().enumerate() { + if let FnArg::Typed(arg) = arg { + if let Type::Path(ty) = &*arg.ty { + let last_segment = ty.path.segments.last(); + match last_segment.map(|s| s.ident.to_string()).as_deref() { + Some("StdRng") => { + inner_fn_args.extend(quote!(rand::SeedableRng::seed_from_u64(seed),)); + continue; + } + Some("Executor") => { + inner_fn_args.extend(quote!(gpui2::Executor::new( + std::sync::Arc::new(dispatcher.clone()) + ),)); + continue; + } + _ => {} + } + } else if let Type::Reference(ty) = &*arg.ty { + if let Type::Path(ty) = &*ty.elem { + let last_segment = ty.path.segments.last(); + if let Some("TestAppContext") = + last_segment.map(|s| s.ident.to_string()).as_deref() + { + let cx_varname = format_ident!("cx_{}", ix); + cx_vars.extend(quote!( + let mut #cx_varname = gpui2::TestAppContext::new( + std::sync::Arc::new(dispatcher.clone()) + ); + )); + cx_teardowns.extend(quote!( + #cx_varname.remove_all_windows(); + dispatcher.run_until_parked(); + #cx_varname.update(|cx| cx.clear_globals()); + dispatcher.run_until_parked(); + )); + inner_fn_args.extend(quote!(&mut #cx_varname,)); + continue; + } + } + } + } + + return TokenStream::from( + syn::Error::new_spanned(arg, "invalid argument").into_compile_error(), + ); + } + + parse_quote! { + #[test] + fn #outer_fn_name() { + #inner_fn + + gpui2::run_test( + #num_iterations as u64, + #max_retries, + &mut |dispatcher| { + let executor = gpui2::Executor::new(std::sync::Arc::new(dispatcher.clone())); + #cx_vars + executor.block(#inner_fn_name(#inner_fn_args)); + #cx_teardowns + }, + #on_failure_fn_name, + stringify!(#outer_fn_name).to_string(), + ); + } + } + } else { + // Pass to the test function the number of app contexts that it needs, + // based on its parameter list. + let mut cx_vars = proc_macro2::TokenStream::new(); + let mut cx_teardowns = proc_macro2::TokenStream::new(); + let mut inner_fn_args = proc_macro2::TokenStream::new(); + for (ix, arg) in inner_fn.sig.inputs.iter().enumerate() { + if let FnArg::Typed(arg) = arg { + if let Type::Path(ty) = &*arg.ty { + let last_segment = ty.path.segments.last(); + + if let Some("StdRng") = last_segment.map(|s| s.ident.to_string()).as_deref() { + inner_fn_args.extend(quote!(rand::SeedableRng::seed_from_u64(seed),)); + continue; + } + } else if let Type::Reference(ty) = &*arg.ty { + if let Type::Path(ty) = &*ty.elem { + let last_segment = ty.path.segments.last(); + match last_segment.map(|s| s.ident.to_string()).as_deref() { + Some("AppContext") => { + let cx_varname = format_ident!("cx_{}", ix); + let cx_varname_lock = format_ident!("cx_{}_lock", ix); + cx_vars.extend(quote!( + let mut #cx_varname = gpui2::TestAppContext::new( + std::sync::Arc::new(dispatcher.clone()) + ); + let mut #cx_varname_lock = cx_varname.app.lock(); + )); + inner_fn_args.extend(quote!(&mut #cx_varname_lock,)); + cx_teardowns.extend(quote!( + #cx_varname.remove_all_windows(); + dispatcher.run_until_parked(); + #cx_varname.update(|cx| cx.clear_globals()); + dispatcher.run_until_parked(); + )); + continue; + } + Some("TestAppContext") => { + let cx_varname = format_ident!("cx_{}", ix); + cx_vars.extend(quote!( + let mut #cx_varname = gpui2::TestAppContext::new( + std::sync::Arc::new(dispatcher.clone()) + ); + )); + cx_teardowns.extend(quote!( + #cx_varname.remove_all_windows(); + dispatcher.run_until_parked(); + #cx_varname.update(|cx| cx.clear_globals()); + dispatcher.run_until_parked(); + )); + inner_fn_args.extend(quote!(&mut #cx_varname,)); + continue; + } + _ => {} + } + } + } + } + + return TokenStream::from( + syn::Error::new_spanned(arg, "invalid argument").into_compile_error(), + ); + } + + parse_quote! { + #[test] + fn #outer_fn_name() { + #inner_fn + + gpui2::run_test( + #num_iterations as u64, + #max_retries, + &mut |dispatcher| { + #cx_vars + #inner_fn_name(#inner_fn_args); + #cx_teardowns + }, + #on_failure_fn_name, + stringify!(#outer_fn_name).to_string(), + ); + } + } + }; + outer_fn.attrs.extend(inner_fn_attributes); + + TokenStream::from(quote!(#outer_fn)) +} + +fn parse_int(literal: &Lit) -> Result { + let result = if let Lit::Int(int) = &literal { + int.base10_parse() + } else { + Err(syn::Error::new(literal.span(), "must be an integer")) + }; + + result.map_err(|err| TokenStream::from(err.into_compile_error())) +} + +fn parse_bool(literal: &Lit) -> Result { + let result = if let Lit::Bool(result) = &literal { + Ok(result.value) + } else { + Err(syn::Error::new(literal.span(), "must be a boolean")) + }; + + result.map_err(|err| TokenStream::from(err.into_compile_error())) +}