diff --git a/godot-macros/src/itest.rs b/godot-macros/src/itest.rs index f6a3bbbdb..07dfb3606 100644 --- a/godot-macros/src/itest.rs +++ b/godot-macros/src/itest.rs @@ -8,7 +8,7 @@ use proc_macro2::TokenStream; use quote::{quote, ToTokens}; -use crate::util::{bail, path_ends_with, KvParser}; +use crate::util::{bail, extract_typename, ident, path_ends_with, KvParser}; use crate::ParseResult; pub fn attribute_itest(input_item: venial::Item) -> ParseResult { @@ -17,20 +17,21 @@ pub fn attribute_itest(input_item: venial::Item) -> ParseResult { _ => return bail!(&input_item, "#[itest] can only be applied to functions"), }; + let mut attr = KvParser::parse_required(&func.attributes, "itest", &func.name)?; + let skipped = attr.handle_alone("skip")?; + let focused = attr.handle_alone("focus")?; + let is_async = attr.handle_alone("async")?; + attr.finish()?; + // Note: allow attributes for things like #[rustfmt] or #[clippy] if func.generic_params.is_some() || func.params.len() > 1 - || func.return_ty.is_some() + || (func.return_ty.is_some() && !is_async) || func.where_clause.is_some() { return bad_signature(&func); } - let mut attr = KvParser::parse_required(&func.attributes, "itest", &func.name)?; - let skipped = attr.handle_alone("skip")?; - let focused = attr.handle_alone("focus")?; - attr.finish()?; - if skipped && focused { return bail!( func.name, @@ -47,9 +48,13 @@ pub fn attribute_itest(input_item: venial::Item) -> ParseResult { // Correct parameter type (crude macro check) -> reuse parameter name if path_ends_with(¶m.ty.tokens, "TestContext") { param.to_token_stream() + } else if is_async { + return bad_async_signature(&func); } else { return bad_signature(&func); } + } else if is_async { + return bad_async_signature(&func); } else { return bad_signature(&func); } @@ -57,14 +62,35 @@ pub fn attribute_itest(input_item: venial::Item) -> ParseResult { quote! { __unused_context: &crate::framework::TestContext } }; + if is_async + && func + .return_ty + .as_ref() + .and_then(extract_typename) + .map_or(true, |segment| segment.ident != "TaskHandle") + { + return bad_async_signature(&func); + } + let body = &func.body; + let (return_tokens, test_case_ty, plugin_name); + if is_async { + return_tokens = quote! { -> ::godot::builtin::TaskHandle }; + test_case_ty = quote! { crate::framework::AsyncRustTestCase }; + plugin_name = ident("__GODOT_ASYNC_ITEST"); + } else { + return_tokens = TokenStream::new(); + test_case_ty = quote! { crate::framework::RustTestCase }; + plugin_name = ident("__GODOT_ITEST"); + }; + Ok(quote! { - pub fn #test_name(#param) { + pub fn #test_name(#param) #return_tokens { #body } - ::godot::sys::plugin_add!(__GODOT_ITEST in crate::framework; crate::framework::RustTestCase { + ::godot::sys::plugin_add!(#plugin_name in crate::framework; #test_case_ty { name: #test_name_str, skipped: #skipped, focused: #focused, @@ -84,3 +110,13 @@ fn bad_signature(func: &venial::Function) -> Result f = func.name, ) } + +fn bad_async_signature(func: &venial::Function) -> Result { + bail!( + func, + "#[itest(async)] function must have one of these signatures:\ + \n fn {f}() -> TaskHandle {{ ... }}\ + \n fn {f}(ctx: &TestContext) -> TaskHandle {{ ... }}", + f = func.name, + ) +} diff --git a/itest/godot/TestRunner.gd b/itest/godot/TestRunner.gd index 0f204863a..0f27f059d 100644 --- a/itest/godot/TestRunner.gd +++ b/itest/godot/TestRunner.gd @@ -66,20 +66,24 @@ func _ready(): var property_tests = load("res://gen/GenPropertyTests.gd").new() - var success: bool = rust_runner.run_all_tests( + # Run benchmarks after all synchronous and asynchronous tests have completed. + var run_benchmarks = func (success: bool): + if success: + rust_runner.run_all_benchmarks(self) + + var exit_code: int = 0 if success else 1 + get_tree().quit(exit_code) + + rust_runner.run_all_tests( gdscript_tests, gdscript_suites.size(), allow_focus, self, filters, - property_tests + property_tests, + run_benchmarks ) - if success: - rust_runner.run_all_benchmarks(self) - - var exit_code: int = 0 if success else 1 - get_tree().quit(exit_code) class GDScriptTestCase: diff --git a/itest/rust/src/engine_tests/async_test.rs b/itest/rust/src/engine_tests/async_test.rs new file mode 100644 index 000000000..2fdd890ff --- /dev/null +++ b/itest/rust/src/engine_tests/async_test.rs @@ -0,0 +1,83 @@ +/* + * Copyright (c) godot-rust; Bromeon and contributors. + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +use godot::builtin::{ + godot_task, Callable, GuaranteedSignalFutureResolver, Signal, SignalFuture, Variant, +}; +use godot::classes::{Object, RefCounted}; +use godot::meta::ToGodot; +use godot::obj::{NewAlloc, NewGd}; + +use crate::framework::{itest, TestContext}; + +#[itest(async)] +fn start_async_task() -> TaskHandle { + let mut object = RefCounted::new_gd(); + let object_ref = object.clone(); + let signal = Signal::from_object_signal(&object, "custom_signal"); + + object.add_user_signal("custom_signal"); + + let signal_future: SignalFuture = signal.to_future(); + + let task_handle = godot_task(async move { + let result = signal_future.await; + + assert_eq!(result, 10); + drop(object_ref); + }); + + object.emit_signal("custom_signal", &[10.to_variant()]); + + task_handle +} + +#[itest] +fn cancel_async_task(ctx: &TestContext) { + let tree = ctx.scene_tree.get_tree().unwrap(); + let signal = Signal::from_object_signal(&tree, "process_frame"); + + let handle = godot_task(async move { + let _: () = signal.to_future().await; + + unreachable!(); + }); + + handle.cancel(); +} + +#[itest(async)] +fn async_task_guaranteed_signal_future() -> TaskHandle { + let mut obj = Object::new_alloc(); + + let signal = Signal::from_object_signal(&obj, "script_changed"); + + let handle = godot_task(async move { + let result: Option<()> = signal.to_guaranteed_future().await; + + assert!(result.is_none()); + }); + + obj.call_deferred("free", &[]); + + handle +} + +// Test that two callables created from the same future resolver (but cloned) are equal, while they are not equal to an unrelated +// callable. +#[itest] +fn resolver_callabable_equality() { + let resolver = GuaranteedSignalFutureResolver::::default(); + + let callable = Callable::from_custom(resolver.clone()); + let cloned_callable = Callable::from_custom(resolver.clone()); + let unrelated_callable = Callable::from_local_fn("fn", |_| Ok(Variant::nil())); + + assert_eq!(callable, cloned_callable); + assert_ne!(callable, unrelated_callable); + assert_ne!(cloned_callable, unrelated_callable); +} diff --git a/itest/rust/src/engine_tests/mod.rs b/itest/rust/src/engine_tests/mod.rs index 3419b5599..f20a21160 100644 --- a/itest/rust/src/engine_tests/mod.rs +++ b/itest/rust/src/engine_tests/mod.rs @@ -5,6 +5,8 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +#[cfg(since_api = "4.2")] +mod async_test; mod codegen_enums_test; mod codegen_test; mod engine_enum_test; diff --git a/itest/rust/src/framework/mod.rs b/itest/rust/src/framework/mod.rs index 7c08e9cde..514a55be3 100644 --- a/itest/rust/src/framework/mod.rs +++ b/itest/rust/src/framework/mod.rs @@ -24,10 +24,12 @@ pub use godot::test::{bench, itest}; // Registers all the `#[itest]` tests and `#[bench]` benchmarks. sys::plugin_registry!(pub(crate) __GODOT_ITEST: RustTestCase); +#[cfg(since_api = "4.2")] +sys::plugin_registry!(pub(crate) __GODOT_ASYNC_ITEST: AsyncRustTestCase); sys::plugin_registry!(pub(crate) __GODOT_BENCH: RustBenchmark); /// Finds all `#[itest]` tests. -fn collect_rust_tests(filters: &[String]) -> (Vec, usize, bool) { +fn collect_rust_tests(filters: &[String]) -> (Vec, HashSet<&str>, bool) { let mut all_files = HashSet::new(); let mut tests: Vec = vec![]; let mut is_focus_run = false; @@ -50,7 +52,38 @@ fn collect_rust_tests(filters: &[String]) -> (Vec, usize, bool) { // Sort alphabetically for deterministic run order tests.sort_by_key(|test| test.file); - (tests, all_files.len(), is_focus_run) + (tests, all_files, is_focus_run) +} + +/// Finds all `#[itest(async)]` tests. +#[cfg(since_api = "4.2")] +fn collect_async_rust_tests( + filters: &[String], + sync_focus_run: bool, +) -> (Vec, HashSet<&str>, bool) { + let mut all_files = HashSet::new(); + let mut tests = vec![]; + let mut is_focus_run = sync_focus_run; + + sys::plugin_foreach!(__GODOT_ASYNC_ITEST; |test: &AsyncRustTestCase| { + // First time a focused test is encountered, switch to "focused" mode and throw everything away. + if !is_focus_run && test.focused { + tests.clear(); + all_files.clear(); + is_focus_run = true; + } + + // Only collect tests if normal mode, or focus mode and test is focused. + if (!is_focus_run || test.focused) && passes_filter(filters, test.name) { + all_files.insert(test.file); + tests.push(*test); + } + }); + + // Sort alphabetically for deterministic run order + tests.sort_by_key(|test| test.file); + + (tests, all_files, is_focus_run) } /// Finds all `#[bench]` benchmarks. @@ -71,7 +104,7 @@ fn collect_rust_benchmarks() -> (Vec, usize) { // ---------------------------------------------------------------------------------------------------------------------------------------------- // Shared types - +#[derive(Clone)] pub struct TestContext { pub scene_tree: Gd, pub property_tests: Gd, @@ -108,6 +141,19 @@ pub struct RustTestCase { pub function: fn(&TestContext), } +#[cfg(since_api = "4.2")] +#[derive(Copy, Clone)] +pub struct AsyncRustTestCase { + pub name: &'static str, + pub file: &'static str, + pub skipped: bool, + /// If one or more tests are focused, only they will be executed. Helpful for debugging and working on specific features. + pub focused: bool, + #[allow(dead_code)] + pub line: u32, + pub function: fn(&TestContext) -> godot::builtin::TaskHandle, +} + #[derive(Copy, Clone)] pub struct RustBenchmark { pub name: &'static str, diff --git a/itest/rust/src/framework/runner.rs b/itest/rust/src/framework/runner.rs index 299809614..e26173651 100644 --- a/itest/rust/src/framework/runner.rs +++ b/itest/rust/src/framework/runner.rs @@ -7,7 +7,7 @@ use std::time::{Duration, Instant}; -use godot::builtin::{Array, GString, Variant, VariantArray}; +use godot::builtin::{Array, Callable, GString, Variant, VariantArray}; use godot::classes::{Engine, Node, Os}; use godot::global::godot_error; use godot::meta::ToGodot; @@ -18,19 +18,30 @@ use crate::framework::{ bencher, passes_filter, BenchResult, RustBenchmark, RustTestCase, TestContext, }; +#[cfg(since_api = "4.2")] +use super::AsyncRustTestCase; + +#[derive(Debug, Clone, Default)] +struct TestStats { + total: u64, + passed: u64, + skipped: u64, + failed_list: Vec, +} + #[derive(GodotClass, Debug)] #[class(init)] pub struct IntegrationTests { - total: i64, - passed: i64, - skipped: i64, - failed_list: Vec, + stats: TestStats, focus_run: bool, + #[cfg(before_api = "4.2")] + base: godot::obj::Base, } #[godot_api] impl IntegrationTests { - #[allow(clippy::uninlined_format_args)] + #[expect(clippy::uninlined_format_args)] + #[expect(clippy::too_many_arguments)] #[func] fn run_all_tests( &mut self, @@ -40,7 +51,8 @@ impl IntegrationTests { scene_tree: Gd, filters: VariantArray, property_tests: Gd, - ) -> bool { + on_finished: Callable, + ) { println!("{}Run{} Godot integration tests...", FMT_CYAN_BOLD, FMT_END); let filters: Vec = filters.iter_shared().map(|v| v.to::()).collect(); let gdscript_tests = gdscript_tests @@ -50,8 +62,14 @@ impl IntegrationTests { passes_filter(filters.as_slice(), &test_name) }) .collect::>(); - let (rust_tests, rust_file_count, focus_run) = - super::collect_rust_tests(filters.as_slice()); + + #[cfg(before_api = "4.2")] + let (rust_tests, rust_test_count, rust_file_count, focus_run) = + collect_rust_tests(&filters); + + #[cfg(since_api = "4.2")] + let (rust_tests, async_rust_tests, rust_test_count, rust_file_count, focus_run) = + collect_rust_tests(&filters); // Print based on focus/not focus. self.focus_run = focus_run; @@ -60,8 +78,7 @@ impl IntegrationTests { } println!( " Rust: found {} tests in {} files.", - rust_tests.len(), - rust_file_count + rust_test_count, rust_file_count ); if !focus_run { println!( @@ -72,18 +89,71 @@ impl IntegrationTests { } let clock = Instant::now(); - self.run_rust_tests(rust_tests, scene_tree, property_tests.clone()); + self.run_rust_tests(rust_tests, scene_tree.clone(), property_tests.clone()); let rust_time = clock.elapsed(); - property_tests.free(); let gdscript_time = if !focus_run { let extra_duration = self.run_gdscript_tests(gdscript_tests); - Some((clock.elapsed() - rust_time) + extra_duration) + Some((clock.elapsed() - rust_time, extra_duration)) } else { None }; - self.conclude_tests(rust_time, gdscript_time, allow_focus) + #[cfg(before_api = "4.2")] + { + use godot::obj::WithBaseField; + + property_tests.free(); + + let result = Self::conclude_tests( + &self.stats, + rust_time, + gdscript_time.map(|(elapsed, extra)| elapsed + extra), + allow_focus, + ); + + // on_finished will call back into self, so we have to make self re-entrant. We also can't call on_finished in deferred mode, + // since it's not available under the 4.1 API. + let base = self.base_mut(); + on_finished.callv(&[result.to_variant()].to_godot()); + + // We should do something with base to satisfy the compiler. + drop(base); + } + + #[cfg(since_api = "4.2")] + { + let stats = self.stats.clone(); + + let on_finalize_test = move |stats, property_tests: Gd| { + let gdscript_elapsed = gdscript_time + .as_ref() + .map(|gdtime| gdtime.0) + .unwrap_or_default(); + + let rust_async_time = clock.elapsed() - rust_time - gdscript_elapsed; + + property_tests.free(); + + let result = Self::conclude_tests( + &stats, + rust_time + rust_async_time, + gdscript_time.map(|(elapsed, extra)| elapsed + extra), + allow_focus, + ); + + // Calling deferred to break a potentially synchronous call stack and avoid re-entrancy. + on_finished.call(&[result.to_variant()]); + }; + + Self::run_async_rust_tests( + stats, + async_rust_tests, + scene_tree, + property_tests, + on_finalize_test, + ); + } } #[func] @@ -139,14 +209,69 @@ impl IntegrationTests { let mut last_file = None; for test in tests { - print_test_pre(test.name, test.file.to_string(), &mut last_file, false); + print_test_pre(test.name, test.file, last_file.as_deref(), false); + last_file = Some(test.file.to_string()); + let outcome = run_rust_test(&test, &ctx); - self.update_stats(&outcome, test.file, test.name); + Self::update_stats(&mut self.stats, &outcome, test.file, test.name); print_test_post(test.name, outcome); } } + #[cfg(since_api = "4.2")] + fn run_async_rust_tests( + stats: TestStats, + tests: Vec, + scene_tree: Gd, + property_tests: Gd, + on_finalize_test: impl FnOnce(TestStats, Gd) + 'static, + ) { + let mut tests_iter = tests.into_iter(); + + let Some(first_test) = tests_iter.next() else { + return on_finalize_test(stats, property_tests); + }; + + let ctx = TestContext { + scene_tree, + property_tests, + }; + + Self::run_async_rust_tests_step(tests_iter, first_test, ctx, stats, None, on_finalize_test); + } + + #[cfg(since_api = "4.2")] + fn run_async_rust_tests_step( + mut tests_iter: impl Iterator + 'static, + test: AsyncRustTestCase, + ctx: TestContext, + mut stats: TestStats, + mut last_file: Option, + on_finalize_test: impl FnOnce(TestStats, Gd) + 'static, + ) { + print_test_pre(test.name, test.file, last_file.as_deref(), true); + last_file.replace(test.file.to_string()); + + run_async_rust_test(&test, &ctx.clone(), move |outcome| { + Self::update_stats(&mut stats, &outcome, test.file, test.name); + print_test_post(test.name, outcome); + + if let Some(next) = tests_iter.next() { + return Self::run_async_rust_tests_step( + tests_iter, + next, + ctx, + stats, + last_file, + on_finalize_test, + ); + } + + on_finalize_test(stats, ctx.property_tests); + }); + } + fn run_gdscript_tests(&mut self, tests: VariantArray) -> Duration { let mut last_file = None; let mut extra_duration = Duration::new(0, 0); @@ -155,7 +280,9 @@ impl IntegrationTests { let test_file = get_property(&test, "suite_name"); let test_case = get_property(&test, "method_name"); - print_test_pre(&test_case, test_file.clone(), &mut last_file, true); + print_test_pre(&test_case, &test_file, last_file.as_deref(), true); + + last_file = Some(test_file.clone()); // If GDScript invokes Rust code that fails, the panic would break through; catch it. // TODO(bromeon): use try_call() once available. @@ -191,28 +318,28 @@ impl IntegrationTests { } }; - self.update_stats(&outcome, &test_file, &test_case); + Self::update_stats(&mut self.stats, &outcome, &test_file, &test_case); print_test_post(&test_case, outcome); } extra_duration } fn conclude_tests( - &self, + stats: &TestStats, rust_time: Duration, gdscript_time: Option, allow_focus: bool, ) -> bool { - let Self { + let TestStats { total, passed, skipped, .. - } = *self; + } = stats; // Consider 0 tests run as a failure too, because it's probably a problem with the run itself. let failed = total - passed - skipped; - let all_passed = failed == 0 && total != 0; + let all_passed = failed == 0 && *total != 0; let outcome = TestOutcome::from_bool(all_passed); @@ -220,7 +347,7 @@ impl IntegrationTests { let gdscript_time = gdscript_time.map(|t| t.as_secs_f32()); let focused_run = gdscript_time.is_none(); - let extra = if skipped > 0 { + let extra = if *skipped > 0 { format!(", {skipped} skipped") } else if focused_run { " (focused run)".to_string() @@ -241,12 +368,12 @@ impl IntegrationTests { if !all_passed { println!("\n Failed tests:"); let max = 10; - for test in self.failed_list.iter().take(max) { + for test in stats.failed_list.iter().take(max) { println!(" * {test}"); } - if self.failed_list.len() > max { - println!(" * ... and {} more.", self.failed_list.len() - max); + if stats.failed_list.len() > max { + println!(" * ... and {} more.", stats.failed_list.len() - max); } println!(); @@ -271,7 +398,9 @@ impl IntegrationTests { let mut last_file = None; for bench in benchmarks { - print_bench_pre(bench.name, bench.file.to_string(), &mut last_file); + print_bench_pre(bench.name, bench.file, last_file.as_deref()); + last_file = Some(bench.file.to_string()); + let result = bencher::run_benchmark(bench.function, bench.repetitions); print_bench_post(result); } @@ -282,16 +411,21 @@ impl IntegrationTests { println!("\nBenchmarks completed in {secs:.2}s."); } - fn update_stats(&mut self, outcome: &TestOutcome, test_file: &str, test_name: &str) { - self.total += 1; + fn update_stats( + stats: &mut TestStats, + outcome: &TestOutcome, + test_file: &str, + test_name: &str, + ) { + stats.total += 1; match outcome { - TestOutcome::Passed => self.passed += 1, - TestOutcome::Failed => self.failed_list.push(format!( + TestOutcome::Passed => stats.passed += 1, + TestOutcome::Failed => stats.failed_list.push(format!( "{} > {}", extract_file_subtitle(test_file), test_name )), - TestOutcome::Skipped => self.skipped += 1, + TestOutcome::Skipped => stats.skipped += 1, } } } @@ -320,7 +454,69 @@ fn run_rust_test(test: &RustTestCase, ctx: &TestContext) -> TestOutcome { TestOutcome::from_bool(success.is_ok()) } -fn print_test_pre(test_case: &str, test_file: String, last_file: &mut Option, flush: bool) { +#[cfg(since_api = "4.2")] +fn run_async_rust_test( + test: &AsyncRustTestCase, + ctx: &TestContext, + on_test_finished: impl FnOnce(TestOutcome) + 'static, +) { + if test.skipped { + return on_test_finished(TestOutcome::Skipped); + } + + // Explicit type to prevent tests from returning a value + let err_context = || format!("itest `{}` failed", test.name); + let success: Result = + godot::private::handle_panic(err_context, || (test.function)(ctx)); + + let Ok(task_handle) = success else { + return on_test_finished(TestOutcome::Failed); + }; + + check_async_test_task(task_handle, on_test_finished, ctx); +} + +#[cfg(since_api = "4.2")] +fn check_async_test_task( + task_handle: godot::builtin::TaskHandle, + on_test_finished: impl FnOnce(TestOutcome) + 'static, + ctx: &TestContext, +) { + use godot::builtin::is_godot_task_paniced; + use godot::classes::object::ConnectFlags; + use godot::obj::EngineEnum; + + if task_handle.is_pending() { + let next_ctx = ctx.clone(); + let mut callback = Some(on_test_finished); + let mut probably_task_handle = Some(task_handle); + + let deferred = Callable::from_local_fn("run_async_rust_test", move |_| { + check_async_test_task( + probably_task_handle + .take() + .expect("Callable will only be called once!"), + callback + .take() + .expect("Callable should not be called multiple times!"), + &next_ctx, + ); + Ok(Variant::nil()) + }); + + ctx.scene_tree + .get_tree() + .expect("The itest scene tree node is part of a Godot SceneTree") + .connect_ex("process_frame", &deferred) + .flags(ConnectFlags::ONE_SHOT.ord() as u32) + .done(); + return; + } + + on_test_finished(TestOutcome::from_bool(!is_godot_task_paniced(task_handle))); +} + +fn print_test_pre(test_case: &str, test_file: &str, last_file: Option<&str>, flush: bool) { print_file_header(test_file, last_file); print!(" -- {test_case} ... "); @@ -331,16 +527,13 @@ fn print_test_pre(test_case: &str, test_file: String, last_file: &mut Option) { +fn print_file_header(file: &str, last_file: Option<&str>) { // Check if we need to open a new category for a file. - let print_file = last_file.as_ref() != Some(&file); + let print_file = last_file != Some(file); if print_file { - println!("\n {}:", extract_file_subtitle(&file)); + println!("\n {}:", extract_file_subtitle(file)); } - - // State update for file-category-print - *last_file = Some(file); } fn extract_file_subtitle(file: &str) -> &str { @@ -364,7 +557,7 @@ fn print_test_post(test_case: &str, outcome: TestOutcome) { } } -fn print_bench_pre(benchmark: &str, bench_file: String, last_file: &mut Option) { +fn print_bench_pre(benchmark: &str, bench_file: &str, last_file: Option<&str>) { print_file_header(bench_file, last_file); let benchmark = if benchmark.len() > 26 { @@ -402,6 +595,47 @@ fn get_errors(test: &Variant) -> Array { .unwrap_or_default() } +#[cfg(before_api = "4.2")] +fn collect_rust_tests(filters: &[String]) -> (Vec, usize, usize, bool) { + let (rust_tests, rust_files, focus_run) = super::collect_rust_tests(filters); + + let rust_test_count = rust_tests.len(); + + (rust_tests, rust_test_count, rust_files.len(), focus_run) +} + +#[cfg(since_api = "4.2")] +fn collect_rust_tests( + filters: &[String], +) -> ( + Vec, + Vec, + usize, + usize, + bool, +) { + let (mut rust_tests, mut rust_files, focus_run) = super::collect_rust_tests(filters); + + let (async_rust_tests, async_rust_files, async_focus_run) = + super::collect_async_rust_tests(filters, focus_run); + + if !focus_run && async_focus_run { + rust_tests.clear(); + rust_files.clear(); + } + + let rust_test_count = rust_tests.len() + async_rust_tests.len(); + let rust_file_count = rust_files.union(&async_rust_files).count(); + + ( + rust_tests, + async_rust_tests, + rust_test_count, + rust_file_count, + focus_run || async_focus_run, + ) +} + #[must_use] enum TestOutcome { Passed,