tanu_core/runner.rs
1//! # Test Runner Module
2//!
3//! The core test execution engine for tanu. This module provides the `Runner` struct
4//! that orchestrates test discovery, execution, filtering, reporting, and event publishing.
5//! It supports concurrent test execution with retry capabilities and comprehensive
6//! event-driven reporting.
7//!
8//! ## Key Components
9//!
10//! - **`Runner`**: Main test execution engine
11//! - **Event System**: Real-time test execution events via channels
12//! - **Filtering**: Project, module, and test name filtering
13//! - **Reporting**: Pluggable reporter system for test output
14//! - **Retry Logic**: Configurable retry with exponential backoff
15//!
16//! ## Execution Flow (block diagram)
17//!
18//! ```text
19//! +-------------------+ +-------------------+ +---------------------+
20//! | Test registry | --> | Filter chain | --> | Semaphore |
21//! | add_test() | | project/module | | (concurrency ctrl) |
22//! +-------------------+ | test name/ignore | +---------------------+
23//! +-------------------+ |
24//! v
25//! +---------------------+
26//! | Tokio task spawn |
27//! | + task-local ctx |
28//! +---------------------+
29//! |
30//! v
31//! +---------------------+
32//! | Test execution |
33//! | + panic recovery |
34//! | + retry/backoff |
35//! +---------------------+
36//! |
37//! +----------------------------------------------------+
38//! v
39//! +-------------------+ +-------------------+ +-------------------+
40//! | Event channel | --> | Broadcast to all | --> | Reporter(s) |
41//! | Start/Check/HTTP | | subscribers | | List/Table/Null |
42//! | Retry/End/Summary | | | | (format output) |
43//! +-------------------+ +-------------------+ +-------------------+
44//! ```
45//!
46//! ## Basic Usage
47//!
48//! ```rust,ignore
49//! use tanu_core::Runner;
50//!
51//! let mut runner = Runner::new();
52//! runner.add_test("my_test", "my_module", test_factory);
53//! runner.run(&[], &[], &[]).await?;
54//! ```
55use backon::Retryable;
56use eyre::WrapErr;
57use futures::{stream::FuturesUnordered, FutureExt, StreamExt};
58use itertools::Itertools;
59use once_cell::sync::Lazy;
60use std::{
61 collections::HashMap,
62 ops::Deref,
63 pin::Pin,
64 sync::{
65 atomic::{AtomicUsize, Ordering},
66 Arc, Mutex,
67 },
68 time::{Duration, SystemTime},
69};
70use tokio::sync::broadcast;
71use tracing::*;
72
73use crate::{
74 config::{self, get_tanu_config, ProjectConfig},
75 http,
76 reporter::Reporter,
77 Config, ModuleName, ProjectName,
78};
79
80tokio::task_local! {
81 pub(crate) static TEST_INFO: Arc<TestInfo>;
82}
83
84pub(crate) fn get_test_info() -> Arc<TestInfo> {
85 TEST_INFO.with(Arc::clone)
86}
87
88/// Runs a future in the current tanu test context (project + test info), if any.
89///
90/// This is useful when spawning additional Tokio tasks (e.g. via `tokio::spawn`/`JoinSet`)
91/// from inside a `#[tanu::test]`, because Tokio task-locals are not propagated
92/// automatically.
93pub fn scope_current<F>(fut: F) -> impl std::future::Future<Output = F::Output> + Send
94where
95 F: std::future::Future + Send,
96 F::Output: Send,
97{
98 let project = crate::config::PROJECT.try_with(Arc::clone).ok();
99 let test_info = TEST_INFO.try_with(Arc::clone).ok();
100
101 async move {
102 match (project, test_info) {
103 (Some(project), Some(test_info)) => {
104 crate::config::PROJECT
105 .scope(project, TEST_INFO.scope(test_info, fut))
106 .await
107 }
108 (Some(project), None) => crate::config::PROJECT.scope(project, fut).await,
109 (None, Some(test_info)) => TEST_INFO.scope(test_info, fut).await,
110 (None, None) => fut.await,
111 }
112 }
113}
114
115// NOTE: Keep the runner receiver alive here so that sender never fails to send.
116#[allow(clippy::type_complexity)]
117pub(crate) static CHANNEL: Lazy<
118 Mutex<Option<(broadcast::Sender<Event>, broadcast::Receiver<Event>)>>,
119> = Lazy::new(|| Mutex::new(Some(broadcast::channel(1000))));
120
121/// Publishes an event to the runner's event channel.
122///
123/// This function is used throughout the test execution pipeline to broadcast
124/// real-time events including test starts, check results, HTTP logs, retries,
125/// and test completions. All events are timestamped and include test context.
126///
127/// # Examples
128///
129/// ```rust,ignore
130/// use tanu_core::runner::{publish, EventBody, Check};
131///
132/// // Publish a successful check
133/// let check = Check::success("response.status() == 200");
134/// publish(EventBody::Check(Box::new(check)))?;
135///
136/// // Publish test start
137/// publish(EventBody::Start)?;
138/// ```
139///
140/// # Errors
141///
142/// Returns an error if:
143/// - The channel lock cannot be acquired
144/// - The channel has been closed
145/// - The send operation fails
146pub fn publish(e: impl Into<Event>) -> eyre::Result<()> {
147 let Ok(guard) = CHANNEL.lock() else {
148 eyre::bail!("failed to acquire runner channel lock");
149 };
150 let Some((tx, _)) = guard.deref() else {
151 eyre::bail!("runner channel has been already closed");
152 };
153
154 tx.send(e.into())
155 .wrap_err("failed to publish message to the runner channel")?;
156
157 Ok(())
158}
159
160/// Subscribe to the channel to see the real-time test execution events.
161pub fn subscribe() -> eyre::Result<broadcast::Receiver<Event>> {
162 let Ok(guard) = CHANNEL.lock() else {
163 eyre::bail!("failed to acquire runner channel lock");
164 };
165 let Some((tx, _)) = guard.deref() else {
166 eyre::bail!("runner channel has been already closed");
167 };
168
169 Ok(tx.subscribe())
170}
171
172/// Test execution errors.
173///
174/// Represents the different ways a test can fail during execution.
175/// These errors are captured and reported by the runner system.
176#[derive(Debug, Clone, thiserror::Error)]
177pub enum Error {
178 #[error("panic: {0}")]
179 Panicked(String),
180 #[error("error: {0}")]
181 ErrorReturned(String),
182}
183
184/// Represents the result of a check/assertion within a test.
185///
186/// Checks are created by assertion macros (`check!`, `check_eq!`, etc.) and
187/// track both the success/failure status and the original expression that
188/// was evaluated. This information is used for detailed test reporting.
189///
190/// # Examples
191///
192/// ```rust,ignore
193/// use tanu_core::runner::Check;
194///
195/// // Create a successful check
196/// let check = Check::success("response.status() == 200");
197/// assert!(check.result);
198///
199/// // Create a failed check
200/// let check = Check::error("user_count != 0");
201/// assert!(!check.result);
202/// ```
203#[derive(Debug, Clone)]
204pub struct Check {
205 pub result: bool,
206 pub expr: String,
207}
208
209impl Check {
210 pub fn success(expr: impl Into<String>) -> Check {
211 Check {
212 result: true,
213 expr: expr.into(),
214 }
215 }
216
217 pub fn error(expr: impl Into<String>) -> Check {
218 Check {
219 result: false,
220 expr: expr.into(),
221 }
222 }
223}
224
225/// A test execution event with full context.
226///
227/// Events are published throughout test execution and include the project,
228/// module, and test name for complete traceability. The event body contains
229/// the specific event data (start, check, HTTP, retry, or end).
230///
231/// # Event Flow
232///
233/// 1. `Start` - Test begins execution
234/// 2. `Check` - Assertion results (can be multiple per test)
235/// 3. `Http` - HTTP request/response logs (can be multiple per test)
236/// 4. `Retry` - Test retry attempts (if configured)
237/// 5. `End` - Test completion with final result
238#[derive(Debug, Clone)]
239pub struct Event {
240 pub project: ProjectName,
241 pub module: ModuleName,
242 pub test: ModuleName,
243 pub body: EventBody,
244}
245
246/// The specific event data published during test execution.
247///
248/// Each event type carries different information:
249/// - `Start`: Signals test execution beginning
250/// - `Check`: Contains assertion results with expression details
251/// - `Http`: HTTP request/response logs for debugging
252/// - `Retry`: Indicates a test retry attempt
253/// - `End`: Final test result with timing and outcome
254/// - `Summary`: Overall test execution summary with counts and timing
255#[derive(Debug, Clone)]
256pub enum EventBody {
257 Start,
258 Check(Box<Check>),
259 Http(Box<http::Log>),
260 Retry(Test),
261 End(Test),
262 Summary(TestSummary),
263}
264
265impl From<EventBody> for Event {
266 fn from(body: EventBody) -> Self {
267 let project = crate::config::get_config();
268 let test_info = crate::runner::get_test_info();
269 Event {
270 project: project.name.clone(),
271 module: test_info.module.clone(),
272 test: test_info.name.clone(),
273 body,
274 }
275 }
276}
277
278/// Final test execution result.
279///
280/// Contains the complete outcome of a test execution including metadata,
281/// execution time, and the final result (success or specific error type).
282/// This is published in the `End` event when a test completes.
283#[derive(Debug, Clone)]
284pub struct Test {
285 pub info: Arc<TestInfo>,
286 pub worker_id: isize,
287 pub started_at: SystemTime,
288 pub ended_at: SystemTime,
289 pub request_time: Duration,
290 pub result: Result<(), Error>,
291}
292
293/// Overall test execution summary.
294///
295/// Contains aggregate information about the entire test run including
296/// total counts, timing, and success/failure statistics.
297/// This is published in the `Summary` event when all tests complete.
298#[derive(Debug, Clone)]
299pub struct TestSummary {
300 pub total_tests: usize,
301 pub passed_tests: usize,
302 pub failed_tests: usize,
303 pub total_time: Duration,
304 pub test_prep_time: Duration,
305}
306
307/// Test metadata and identification.
308///
309/// Contains the module and test name for a test case. This information
310/// is used for test filtering, reporting, and event context throughout
311/// the test execution pipeline.
312#[derive(Debug, Clone, Default)]
313pub struct TestInfo {
314 pub module: String,
315 pub name: String,
316}
317
318impl TestInfo {
319 /// Full test name including module
320 pub fn full_name(&self) -> String {
321 format!("{}::{}", self.module, self.name)
322 }
323
324 /// Unique test name including project and module names
325 pub fn unique_name(&self, project: &str) -> String {
326 format!("{project}::{}::{}", self.module, self.name)
327 }
328}
329
330/// Pool of reusable worker IDs for timeline visualization.
331///
332/// Worker IDs are assigned to tests when they start executing and returned
333/// to the pool when they complete. This allows timeline visualization tools
334/// to display tests in lanes based on which worker executed them.
335#[derive(Debug)]
336pub struct WorkerIds {
337 enabled: bool,
338 ids: Mutex<Vec<isize>>,
339}
340
341impl WorkerIds {
342 /// Creates a new worker ID pool with IDs from 0 to concurrency-1.
343 ///
344 /// If `concurrency` is `None`, the pool is disabled and `acquire()` always returns -1.
345 pub fn new(concurrency: Option<usize>) -> Self {
346 match concurrency {
347 Some(c) => Self {
348 enabled: true,
349 ids: Mutex::new((0..c as isize).collect()),
350 },
351 None => Self {
352 enabled: false,
353 ids: Mutex::new(Vec::new()),
354 },
355 }
356 }
357
358 /// Acquires a worker ID from the pool.
359 ///
360 /// Returns -1 if the pool is disabled, empty, or the mutex is poisoned.
361 pub fn acquire(&self) -> isize {
362 if !self.enabled {
363 return -1;
364 }
365 self.ids
366 .lock()
367 .ok()
368 .and_then(|mut guard| guard.pop())
369 .unwrap_or(-1)
370 }
371
372 /// Returns a worker ID to the pool.
373 ///
374 /// Does nothing if the pool is disabled, the mutex is poisoned, or id is negative.
375 pub fn release(&self, id: isize) {
376 if !self.enabled || id < 0 {
377 return;
378 }
379 if let Ok(mut guard) = self.ids.lock() {
380 guard.push(id);
381 }
382 }
383}
384
385type TestCaseFactory = Arc<
386 dyn Fn() -> Pin<Box<dyn futures::Future<Output = eyre::Result<()>> + Send + 'static>>
387 + Sync
388 + Send
389 + 'static,
390>;
391
392/// Configuration options for test runner behavior.
393///
394/// Controls various aspects of test execution including logging,
395/// concurrency, and channel management. These options can be set
396/// via the builder pattern on the `Runner`.
397///
398/// # Examples
399///
400/// ```rust,ignore
401/// use tanu_core::Runner;
402///
403/// let mut runner = Runner::new();
404/// runner.capture_http(); // Enable HTTP logging
405/// runner.set_concurrency(4); // Limit to 4 concurrent tests
406/// ```
407#[derive(Debug, Clone, Default)]
408pub struct Options {
409 pub debug: bool,
410 pub capture_http: bool,
411 pub capture_rust: bool,
412 pub terminate_channel: bool,
413 pub concurrency: Option<usize>,
414}
415
416/// Trait for filtering test cases during execution.
417///
418/// Filters allow selective test execution based on project configuration
419/// and test metadata. Multiple filters can be applied simultaneously,
420/// and a test must pass all filters to be executed.
421///
422/// # Examples
423///
424/// ```rust,ignore
425/// use tanu_core::runner::{Filter, TestInfo, ProjectConfig};
426///
427/// struct CustomFilter;
428///
429/// impl Filter for CustomFilter {
430/// fn filter(&self, project: &ProjectConfig, info: &TestInfo) -> bool {
431/// // Only run tests with "integration" in the name
432/// info.name.contains("integration")
433/// }
434/// }
435/// ```
436pub trait Filter {
437 fn filter(&self, project: &ProjectConfig, info: &TestInfo) -> bool;
438}
439
440/// Filters tests to only run from specified projects.
441///
442/// When project names are provided, only tests from those projects
443/// will be executed. If the list is empty, all projects are included.
444///
445/// # Examples
446///
447/// ```rust,ignore
448/// use tanu_core::runner::ProjectFilter;
449///
450/// let filter = ProjectFilter { project_names: &["staging".to_string()] };
451/// // Only tests from "staging" project will run
452/// ```
453pub struct ProjectFilter<'a> {
454 project_names: &'a [String],
455}
456
457impl Filter for ProjectFilter<'_> {
458 fn filter(&self, project: &ProjectConfig, _info: &TestInfo) -> bool {
459 if self.project_names.is_empty() {
460 return true;
461 }
462
463 self.project_names
464 .iter()
465 .any(|project_name| &project.name == project_name)
466 }
467}
468
469/// Filters tests to only run from specified modules.
470///
471/// When module names are provided, only tests from those modules
472/// will be executed. If the list is empty, all modules are included.
473/// Module names correspond to Rust module paths.
474///
475/// # Examples
476///
477/// ```rust,ignore
478/// use tanu_core::runner::ModuleFilter;
479///
480/// let filter = ModuleFilter { module_names: &["api".to_string(), "auth".to_string()] };
481/// // Only tests from "api" and "auth" modules will run
482/// ```
483pub struct ModuleFilter<'a> {
484 module_names: &'a [String],
485}
486
487impl Filter for ModuleFilter<'_> {
488 fn filter(&self, _project: &ProjectConfig, info: &TestInfo) -> bool {
489 if self.module_names.is_empty() {
490 return true;
491 }
492
493 self.module_names
494 .iter()
495 .any(|module_name| &info.module == module_name)
496 }
497}
498
499/// Filters tests to only run specific named tests.
500///
501/// When test names are provided, only those exact tests will be executed.
502/// Test names should include the module (e.g., "api::health_check").
503/// If the list is empty, all tests are included.
504///
505/// # Examples
506///
507/// ```rust,ignore
508/// use tanu_core::runner::TestNameFilter;
509///
510/// let filter = TestNameFilter {
511/// test_names: &["api::health_check".to_string(), "auth::login".to_string()]
512/// };
513/// // Only the specified tests will run
514/// ```
515pub struct TestNameFilter<'a> {
516 test_names: &'a [String],
517}
518
519impl Filter for TestNameFilter<'_> {
520 fn filter(&self, _project: &ProjectConfig, info: &TestInfo) -> bool {
521 if self.test_names.is_empty() {
522 return true;
523 }
524
525 self.test_names
526 .iter()
527 .any(|test_name| &info.full_name() == test_name)
528 }
529}
530
531/// Filters out tests that are configured to be ignored.
532///
533/// This filter reads the `test_ignore` configuration from each project
534/// and excludes those tests from execution. Tests are matched by their
535/// full name (module::test_name).
536///
537/// # Configuration
538///
539/// In `tanu.toml`:
540/// ```toml
541/// [[projects]]
542/// name = "staging"
543/// test_ignore = ["flaky_test", "slow_integration_test"]
544/// ```
545///
546/// # Examples
547///
548/// ```rust,ignore
549/// use tanu_core::runner::TestIgnoreFilter;
550///
551/// let filter = TestIgnoreFilter::default();
552/// // Tests listed in test_ignore config will be skipped
553/// ```
554pub struct TestIgnoreFilter {
555 test_ignores: HashMap<String, Vec<String>>,
556}
557
558impl Default for TestIgnoreFilter {
559 fn default() -> TestIgnoreFilter {
560 TestIgnoreFilter {
561 test_ignores: get_tanu_config()
562 .projects
563 .iter()
564 .map(|proj| (proj.name.clone(), proj.test_ignore.clone()))
565 .collect(),
566 }
567 }
568}
569
570impl Filter for TestIgnoreFilter {
571 fn filter(&self, project: &ProjectConfig, info: &TestInfo) -> bool {
572 let Some(test_ignore) = self.test_ignores.get(&project.name) else {
573 return true;
574 };
575
576 test_ignore
577 .iter()
578 .all(|test_name| &info.full_name() != test_name)
579 }
580}
581
582/// The main test execution engine for tanu.
583///
584/// `Runner` is responsible for orchestrating the entire test execution pipeline:
585/// test discovery, filtering, concurrent execution, retry handling, event publishing,
586/// and result reporting. It supports multiple projects, configurable concurrency,
587/// and pluggable reporters.
588///
589/// # Features
590///
591/// - **Concurrent Execution**: Tests run in parallel with configurable limits
592/// - **Retry Logic**: Automatic retry with exponential backoff for flaky tests
593/// - **Event System**: Real-time event publishing for UI integration
594/// - **Filtering**: Filter tests by project, module, or test name
595/// - **Reporting**: Support for multiple output formats via reporters
596/// - **HTTP Logging**: Capture and log all HTTP requests/responses
597///
598/// # Examples
599///
600/// ```rust,ignore
601/// use tanu_core::{Runner, reporter::TableReporter};
602///
603/// let mut runner = Runner::new();
604/// runner.capture_http();
605/// runner.set_concurrency(8);
606/// runner.add_reporter(TableReporter::new());
607///
608/// // Add tests (typically done by procedural macros)
609/// runner.add_test("health_check", "api", test_factory);
610///
611/// // Run all tests
612/// runner.run(&[], &[], &[]).await?;
613/// ```
614///
615/// # Architecture
616///
617/// Tests are executed in separate tokio tasks with:
618/// - Project-scoped configuration
619/// - Test-scoped context for event publishing
620/// - Semaphore-based concurrency control
621/// - Panic recovery and error handling
622/// - Automatic retry with configurable backoff
623#[derive(Default)]
624pub struct Runner {
625 cfg: Config,
626 options: Options,
627 test_cases: Vec<(Arc<TestInfo>, TestCaseFactory)>,
628 reporters: Vec<Box<dyn Reporter + Send>>,
629}
630
631impl Runner {
632 /// Creates a new runner with the global tanu configuration.
633 ///
634 /// This loads the configuration from `tanu.toml` and sets up
635 /// default options. Use `with_config()` for custom configuration.
636 ///
637 /// # Examples
638 ///
639 /// ```rust,ignore
640 /// use tanu_core::Runner;
641 ///
642 /// let runner = Runner::new();
643 /// ```
644 pub fn new() -> Runner {
645 Runner::with_config(get_tanu_config().clone())
646 }
647
648 /// Creates a new runner with the specified configuration.
649 ///
650 /// This allows for custom configuration beyond what's in `tanu.toml`,
651 /// useful for testing or programmatic setup.
652 ///
653 /// # Examples
654 ///
655 /// ```rust,ignore
656 /// use tanu_core::{Runner, Config};
657 ///
658 /// let config = Config::default();
659 /// let runner = Runner::with_config(config);
660 /// ```
661 pub fn with_config(cfg: Config) -> Runner {
662 Runner {
663 cfg,
664 options: Options::default(),
665 test_cases: Vec::new(),
666 reporters: Vec::new(),
667 }
668 }
669
670 /// Enables HTTP request/response logging.
671 ///
672 /// When enabled, all HTTP requests made via tanu's HTTP client
673 /// will be logged and included in test reports. This is useful
674 /// for debugging API tests and understanding request/response flow.
675 ///
676 /// # Examples
677 ///
678 /// ```rust,ignore
679 /// let mut runner = Runner::new();
680 /// runner.capture_http();
681 /// ```
682 pub fn capture_http(&mut self) {
683 self.options.capture_http = true;
684 }
685
686 /// Enables Rust logging output during test execution.
687 ///
688 /// This initializes the tracing subscriber to capture debug, info,
689 /// warn, and error logs from tests and the framework itself.
690 /// Useful for debugging test execution issues.
691 ///
692 /// # Examples
693 ///
694 /// ```rust,ignore
695 /// let mut runner = Runner::new();
696 /// runner.capture_rust();
697 /// ```
698 pub fn capture_rust(&mut self) {
699 self.options.capture_rust = true;
700 }
701
702 /// Configures the runner to close the event channel after test execution.
703 ///
704 /// By default, the event channel remains open for continued monitoring.
705 /// This option closes the channel when all tests complete, signaling
706 /// that no more events will be published.
707 ///
708 /// # Examples
709 ///
710 /// ```rust,ignore
711 /// let mut runner = Runner::new();
712 /// runner.terminate_channel();
713 /// ```
714 pub fn terminate_channel(&mut self) {
715 self.options.terminate_channel = true;
716 }
717
718 /// Adds a reporter for test output formatting.
719 ///
720 /// Reporters receive test events and format them for different output
721 /// destinations (console, files, etc.). Multiple reporters can be added
722 /// to generate multiple output formats simultaneously.
723 ///
724 /// # Examples
725 ///
726 /// ```rust,ignore
727 /// use tanu_core::{Runner, reporter::TableReporter};
728 ///
729 /// let mut runner = Runner::new();
730 /// runner.add_reporter(TableReporter::new());
731 /// ```
732 pub fn add_reporter(&mut self, reporter: impl Reporter + 'static + Send) {
733 self.reporters.push(Box::new(reporter));
734 }
735
736 /// Adds a boxed reporter for test output formatting.
737 ///
738 /// Similar to `add_reporter()` but accepts an already-boxed reporter.
739 /// Useful when working with dynamic reporter selection.
740 ///
741 /// # Examples
742 ///
743 /// ```rust,ignore
744 /// use tanu_core::{Runner, reporter::ListReporter};
745 ///
746 /// let mut runner = Runner::new();
747 /// let reporter: Box<dyn Reporter + Send> = Box::new(ListReporter::new());
748 /// runner.add_boxed_reporter(reporter);
749 /// ```
750 pub fn add_boxed_reporter(&mut self, reporter: Box<dyn Reporter + 'static + Send>) {
751 self.reporters.push(reporter);
752 }
753
754 /// Add a test case to the runner.
755 pub fn add_test(&mut self, name: &str, module: &str, factory: TestCaseFactory) {
756 self.test_cases.push((
757 Arc::new(TestInfo {
758 name: name.into(),
759 module: module.into(),
760 }),
761 factory,
762 ));
763 }
764
765 /// Sets the maximum number of tests to run concurrently.
766 ///
767 /// By default, tests run with unlimited concurrency. This setting
768 /// allows you to limit concurrent execution to reduce resource usage
769 /// or avoid overwhelming external services.
770 ///
771 /// # Examples
772 ///
773 /// ```rust,ignore
774 /// let mut runner = Runner::new();
775 /// runner.set_concurrency(4); // Max 4 tests at once
776 /// ```
777 pub fn set_concurrency(&mut self, concurrency: usize) {
778 self.options.concurrency = Some(concurrency);
779 }
780
781 /// Executes all registered tests with optional filtering.
782 ///
783 /// Runs tests concurrently according to the configured options and filters.
784 /// Tests can be filtered by project name, module name, or specific test names.
785 /// Empty filter arrays mean "include all".
786 ///
787 /// # Parameters
788 ///
789 /// - `project_names`: Only run tests from these projects (empty = all projects)
790 /// - `module_names`: Only run tests from these modules (empty = all modules)
791 /// - `test_names`: Only run these specific tests (empty = all tests)
792 ///
793 /// # Examples
794 ///
795 /// ```rust,ignore
796 /// let mut runner = Runner::new();
797 ///
798 /// // Run all tests
799 /// runner.run(&[], &[], &[]).await?;
800 ///
801 /// // Run only "staging" project tests
802 /// runner.run(&["staging".to_string()], &[], &[]).await?;
803 ///
804 /// // Run specific test
805 /// runner.run(&[], &[], &["api::health_check".to_string()]).await?;
806 /// ```
807 ///
808 /// # Errors
809 ///
810 /// Returns an error if:
811 /// - Any test fails (unless configured to continue on failure)
812 /// - A test panics and cannot be recovered
813 /// - Reporter setup or execution fails
814 /// - Event channel operations fail
815 #[allow(clippy::too_many_lines)]
816 pub async fn run(
817 &mut self,
818 project_names: &[String],
819 module_names: &[String],
820 test_names: &[String],
821 ) -> eyre::Result<()> {
822 if self.options.capture_rust {
823 tracing_subscriber::fmt::init();
824 }
825
826 let reporters = std::mem::take(&mut self.reporters);
827 let reporter_handles: Vec<_> = reporters
828 .into_iter()
829 .map(|mut reporter| tokio::spawn(async move { reporter.run().await }))
830 .collect();
831
832 let project_filter = ProjectFilter { project_names };
833 let module_filter = ModuleFilter { module_names };
834 let test_name_filter = TestNameFilter { test_names };
835 let test_ignore_filter = TestIgnoreFilter::default();
836
837 let start = std::time::Instant::now();
838 let handles: FuturesUnordered<_> = {
839 // Create a semaphore to limit concurrency
840 let concurrency = self.options.concurrency;
841 let semaphore = Arc::new(tokio::sync::Semaphore::new(
842 concurrency.unwrap_or(tokio::sync::Semaphore::MAX_PERMITS),
843 ));
844
845 // Worker ID pool for timeline visualization (only when concurrency is specified)
846 let worker_ids = Arc::new(WorkerIds::new(concurrency));
847
848 let projects = self.cfg.projects.clone();
849 let projects = if projects.is_empty() {
850 vec![Arc::new(ProjectConfig {
851 name: "default".into(),
852 ..Default::default()
853 })]
854 } else {
855 projects
856 };
857 self.test_cases
858 .iter()
859 .cartesian_product(projects.into_iter())
860 .map(|((info, factory), project)| (project, Arc::clone(info), factory.clone()))
861 .filter(move |(project, info, _)| test_name_filter.filter(project, info))
862 .filter(move |(project, info, _)| module_filter.filter(project, info))
863 .filter(move |(project, info, _)| project_filter.filter(project, info))
864 .filter(move |(project, info, _)| test_ignore_filter.filter(project, info))
865 .map(|(project, info, factory)| {
866 let semaphore = semaphore.clone();
867 let worker_ids = worker_ids.clone();
868 tokio::spawn(async move {
869 let _permit = semaphore
870 .acquire()
871 .await
872 .map_err(|e| eyre::eyre!("failed to acquire semaphore: {e}"))?;
873
874 // Acquire worker ID from pool
875 let worker_id = worker_ids.acquire();
876
877 let project_for_scope = Arc::clone(&project);
878 let info_for_scope = Arc::clone(&info);
879 let result = config::PROJECT
880 .scope(project_for_scope, async {
881 TEST_INFO
882 .scope(info_for_scope, async {
883 let test_name = info.name.clone();
884 publish(EventBody::Start)?;
885
886 let retry_count =
887 AtomicUsize::new(project.retry.count.unwrap_or(0));
888 let f = || async {
889 let started_at = SystemTime::now();
890 let request_started = std::time::Instant::now();
891 let res = factory().await;
892 let ended_at = SystemTime::now();
893
894 if res.is_err()
895 && retry_count.load(Ordering::SeqCst) > 0
896 {
897 let test_result = match &res {
898 Ok(_) => Ok(()),
899 Err(e) => {
900 Err(Error::ErrorReturned(format!("{e:?}")))
901 }
902 };
903 let test = Test {
904 result: test_result,
905 info: Arc::clone(&info),
906 worker_id,
907 started_at,
908 ended_at,
909 request_time: request_started.elapsed(),
910 };
911 publish(EventBody::Retry(test))?;
912 retry_count.fetch_sub(1, Ordering::SeqCst);
913 };
914 res
915 };
916 let started_at = SystemTime::now();
917 let started = std::time::Instant::now();
918 let fut = f.retry(project.retry.backoff());
919 let fut = std::panic::AssertUnwindSafe(fut).catch_unwind();
920 let res = fut.await;
921 let request_time = started.elapsed();
922 let ended_at = SystemTime::now();
923
924 let result = match res {
925 Ok(Ok(_)) => {
926 debug!("{test_name} ok");
927 Ok(())
928 }
929 Ok(Err(e)) => {
930 debug!("{test_name} failed: {e:#}");
931 Err(Error::ErrorReturned(format!("{e:?}")))
932 }
933 Err(e) => {
934 let panic_message = if let Some(panic_message) =
935 e.downcast_ref::<&str>()
936 {
937 format!(
938 "{test_name} failed with message: {panic_message}"
939 )
940 } else if let Some(panic_message) =
941 e.downcast_ref::<String>()
942 {
943 format!(
944 "{test_name} failed with message: {panic_message}"
945 )
946 } else {
947 format!(
948 "{test_name} failed with unknown message"
949 )
950 };
951 let e = eyre::eyre!(panic_message);
952 Err(Error::Panicked(format!("{e:?}")))
953 }
954 };
955
956 let is_err = result.is_err();
957 publish(EventBody::End(Test {
958 info,
959 worker_id,
960 started_at,
961 ended_at,
962 request_time,
963 result,
964 }))?;
965
966 eyre::ensure!(!is_err);
967 eyre::Ok(())
968 })
969 .await
970 })
971 .await;
972
973 // Return worker ID to pool
974 worker_ids.release(worker_id);
975
976 result
977 })
978 })
979 .collect()
980 };
981 let test_prep_time = start.elapsed();
982 debug!(
983 "created handles for {} test cases; took {}s",
984 handles.len(),
985 test_prep_time.as_secs_f32()
986 );
987
988 let mut has_any_error = false;
989 let total_tests = handles.len();
990 let options = self.options.clone();
991 let runner = async move {
992 let results = handles.collect::<Vec<_>>().await;
993 if results.is_empty() {
994 console::Term::stdout().write_line("no test cases found")?;
995 }
996
997 let mut failed_tests = 0;
998 for result in results {
999 match result {
1000 Ok(res) => {
1001 if let Err(e) = res {
1002 debug!("test case failed: {e:#}");
1003 has_any_error = true;
1004 failed_tests += 1;
1005 }
1006 }
1007 Err(e) => {
1008 if e.is_panic() {
1009 // Resume the panic on the main task
1010 error!("{e}");
1011 has_any_error = true;
1012 failed_tests += 1;
1013 }
1014 }
1015 }
1016 }
1017
1018 let passed_tests = total_tests - failed_tests;
1019 let total_time = start.elapsed();
1020
1021 // Publish summary event
1022 let summary = TestSummary {
1023 total_tests,
1024 passed_tests,
1025 failed_tests,
1026 total_time,
1027 test_prep_time,
1028 };
1029
1030 // Create a dummy event for summary (since it doesn't belong to a specific test)
1031 let summary_event = Event {
1032 project: "".to_string(),
1033 module: "".to_string(),
1034 test: "".to_string(),
1035 body: EventBody::Summary(summary),
1036 };
1037
1038 if let Ok(guard) = CHANNEL.lock() {
1039 if let Some((tx, _)) = guard.as_ref() {
1040 let _ = tx.send(summary_event);
1041 }
1042 }
1043 debug!("all test finished. sending stop signal to the background tasks.");
1044
1045 if options.terminate_channel {
1046 let Ok(mut guard) = CHANNEL.lock() else {
1047 eyre::bail!("failed to acquire runner channel lock");
1048 };
1049 guard.take(); // closing the runner channel.
1050 }
1051
1052 if has_any_error {
1053 eyre::bail!("one or more tests failed");
1054 }
1055
1056 eyre::Ok(())
1057 };
1058
1059 let runner_result = runner.await;
1060
1061 for handle in reporter_handles {
1062 match handle.await {
1063 Ok(Ok(())) => {}
1064 Ok(Err(e)) => error!("reporter failed: {e:#}"),
1065 Err(e) => error!("reporter task panicked: {e:#}"),
1066 }
1067 }
1068
1069 debug!("runner stopped");
1070
1071 runner_result
1072 }
1073
1074 /// Returns a list of all registered test metadata.
1075 ///
1076 /// This provides access to test information without executing the tests.
1077 /// Useful for building test UIs, generating reports, or implementing
1078 /// custom filtering logic.
1079 ///
1080 /// # Examples
1081 ///
1082 /// ```rust,ignore
1083 /// let runner = Runner::new();
1084 /// let tests = runner.list();
1085 ///
1086 /// for test in tests {
1087 /// println!("Test: {}", test.full_name());
1088 /// }
1089 /// ```
1090 pub fn list(&self) -> Vec<&TestInfo> {
1091 self.test_cases
1092 .iter()
1093 .map(|(meta, _test)| meta.as_ref())
1094 .collect::<Vec<_>>()
1095 }
1096}
1097
1098#[cfg(test)]
1099mod test {
1100 use super::*;
1101 use crate::config::RetryConfig;
1102 use crate::ProjectConfig;
1103 use std::sync::Arc;
1104
1105 fn create_config() -> Config {
1106 Config {
1107 projects: vec![Arc::new(ProjectConfig {
1108 name: "default".into(),
1109 ..Default::default()
1110 })],
1111 ..Default::default()
1112 }
1113 }
1114
1115 fn create_config_with_retry() -> Config {
1116 Config {
1117 projects: vec![Arc::new(ProjectConfig {
1118 name: "default".into(),
1119 retry: RetryConfig {
1120 count: Some(1),
1121 ..Default::default()
1122 },
1123 ..Default::default()
1124 })],
1125 ..Default::default()
1126 }
1127 }
1128
1129 #[tokio::test]
1130 async fn runner_fail_because_no_retry_configured() -> eyre::Result<()> {
1131 let mut server = mockito::Server::new_async().await;
1132 let m1 = server
1133 .mock("GET", "/")
1134 .with_status(500)
1135 .expect(1)
1136 .create_async()
1137 .await;
1138 let m2 = server
1139 .mock("GET", "/")
1140 .with_status(200)
1141 .expect(0)
1142 .create_async()
1143 .await;
1144
1145 let factory: TestCaseFactory = Arc::new(move || {
1146 let url = server.url();
1147 Box::pin(async move {
1148 let res = reqwest::get(url).await?;
1149 if res.status().is_success() {
1150 Ok(())
1151 } else {
1152 eyre::bail!("request failed")
1153 }
1154 })
1155 });
1156
1157 let _runner_rx = subscribe()?;
1158 let mut runner = Runner::with_config(create_config());
1159 runner.add_test("retry_test", "module", factory);
1160
1161 let result = runner.run(&[], &[], &[]).await;
1162 m1.assert_async().await;
1163 m2.assert_async().await;
1164
1165 assert!(result.is_err());
1166 Ok(())
1167 }
1168
1169 #[tokio::test]
1170 async fn runner_retry_successful_after_failure() -> eyre::Result<()> {
1171 let mut server = mockito::Server::new_async().await;
1172 let m1 = server
1173 .mock("GET", "/")
1174 .with_status(500)
1175 .expect(1)
1176 .create_async()
1177 .await;
1178 let m2 = server
1179 .mock("GET", "/")
1180 .with_status(200)
1181 .expect(1)
1182 .create_async()
1183 .await;
1184
1185 let factory: TestCaseFactory = Arc::new(move || {
1186 let url = server.url();
1187 Box::pin(async move {
1188 let res = reqwest::get(url).await?;
1189 if res.status().is_success() {
1190 Ok(())
1191 } else {
1192 eyre::bail!("request failed")
1193 }
1194 })
1195 });
1196
1197 let _runner_rx = subscribe()?;
1198 let mut runner = Runner::with_config(create_config_with_retry());
1199 runner.add_test("retry_test", "module", factory);
1200
1201 let result = runner.run(&[], &[], &[]).await;
1202 m1.assert_async().await;
1203 m2.assert_async().await;
1204
1205 assert!(result.is_ok());
1206
1207 Ok(())
1208 }
1209
1210 #[tokio::test]
1211 async fn spawned_task_panics_without_task_local_context() {
1212 let project = Arc::new(ProjectConfig {
1213 name: "default".to_string(),
1214 ..Default::default()
1215 });
1216 let test_info = Arc::new(TestInfo {
1217 module: "mod".to_string(),
1218 name: "test".to_string(),
1219 });
1220
1221 crate::config::PROJECT
1222 .scope(project, TEST_INFO.scope(test_info, async move {
1223 let handle = tokio::spawn(async move {
1224 let _ = crate::config::get_config();
1225 });
1226
1227 let join_err = handle.await.expect_err("spawned task should panic");
1228 assert!(join_err.is_panic());
1229 }))
1230 .await;
1231 }
1232
1233 #[tokio::test]
1234 async fn scope_current_propagates_task_local_context_into_spawned_task() {
1235 let project = Arc::new(ProjectConfig {
1236 name: "default".to_string(),
1237 ..Default::default()
1238 });
1239 let test_info = Arc::new(TestInfo {
1240 module: "mod".to_string(),
1241 name: "test".to_string(),
1242 });
1243
1244 crate::config::PROJECT
1245 .scope(project, TEST_INFO.scope(test_info, async move {
1246 let handle = tokio::spawn(super::scope_current(async move {
1247 let _ = crate::config::get_config();
1248 let _ = super::get_test_info();
1249 }));
1250
1251 handle.await.expect("spawned task should not panic");
1252 }))
1253 .await;
1254 }
1255}