Skip to main content

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/// Barrier to synchronize reporter subscription before test execution starts.
122/// This prevents the race condition where tests publish events before reporters subscribe.
123pub(crate) static REPORTER_BARRIER: Lazy<Mutex<Option<Arc<tokio::sync::Barrier>>>> =
124    Lazy::new(|| Mutex::new(None));
125
126/// Publishes an event to the runner's event channel.
127///
128/// This function is used throughout the test execution pipeline to broadcast
129/// real-time events including test starts, check results, HTTP logs, retries,
130/// and test completions. All events are timestamped and include test context.
131///
132/// # Examples
133///
134/// ```rust,ignore
135/// use tanu_core::runner::{publish, EventBody, Check};
136///
137/// // Publish a successful check
138/// let check = Check::success("response.status() == 200");
139/// publish(EventBody::Check(Box::new(check)))?;
140///
141/// // Publish test start
142/// publish(EventBody::Start)?;
143/// ```
144///
145/// # Errors
146///
147/// Returns an error if:
148/// - The channel lock cannot be acquired
149/// - The channel has been closed
150/// - The send operation fails
151pub fn publish(e: impl Into<Event>) -> eyre::Result<()> {
152    let Ok(guard) = CHANNEL.lock() else {
153        eyre::bail!("failed to acquire runner channel lock");
154    };
155    let Some((tx, _)) = guard.deref() else {
156        eyre::bail!("runner channel has been already closed");
157    };
158
159    tx.send(e.into())
160        .wrap_err("failed to publish message to the runner channel")?;
161
162    Ok(())
163}
164
165/// Subscribe to the channel to see the real-time test execution events.
166pub fn subscribe() -> eyre::Result<broadcast::Receiver<Event>> {
167    let Ok(guard) = CHANNEL.lock() else {
168        eyre::bail!("failed to acquire runner channel lock");
169    };
170    let Some((tx, _)) = guard.deref() else {
171        eyre::bail!("runner channel has been already closed");
172    };
173
174    Ok(tx.subscribe())
175}
176
177/// Set up barrier for N reporters (called before spawning reporters).
178///
179/// This ensures all reporters subscribe before tests start executing,
180/// preventing the race condition where Start events are published before
181/// reporters are ready to receive them.
182pub(crate) fn setup_reporter_barrier(count: usize) -> eyre::Result<()> {
183    let Ok(mut barrier) = REPORTER_BARRIER.lock() else {
184        eyre::bail!("failed to acquire reporter barrier lock");
185    };
186    *barrier = Some(Arc::new(tokio::sync::Barrier::new(count + 1)));
187    Ok(())
188}
189
190/// Wait on barrier (called by reporters after subscribing, and by runner before tests).
191///
192/// If no barrier is set (standalone reporter use), this is a no-op.
193pub(crate) async fn wait_reporter_barrier() {
194    let barrier = match REPORTER_BARRIER.lock() {
195        Ok(guard) => guard.clone(),
196        Err(e) => {
197            error!("failed to acquire reporter barrier lock (poisoned): {e}");
198            return;
199        }
200    };
201
202    if let Some(b) = barrier {
203        b.wait().await;
204    }
205}
206
207/// Clear barrier after use.
208pub(crate) fn clear_reporter_barrier() {
209    match REPORTER_BARRIER.lock() {
210        Ok(mut barrier) => {
211            *barrier = None;
212        }
213        Err(e) => {
214            error!("failed to clear reporter barrier (poisoned lock): {e}");
215        }
216    }
217}
218
219/// Test execution errors.
220///
221/// Represents the different ways a test can fail during execution.
222/// These errors are captured and reported by the runner system.
223#[derive(Debug, Clone, thiserror::Error)]
224pub enum Error {
225    #[error("panic: {0}")]
226    Panicked(String),
227    #[error("error: {0}")]
228    ErrorReturned(String),
229}
230
231/// Represents the result of a check/assertion within a test.
232///
233/// Checks are created by assertion macros (`check!`, `check_eq!`, etc.) and
234/// track both the success/failure status and the original expression that
235/// was evaluated. This information is used for detailed test reporting.
236///
237/// # Examples
238///
239/// ```rust,ignore
240/// use tanu_core::runner::Check;
241///
242/// // Create a successful check
243/// let check = Check::success("response.status() == 200");
244/// assert!(check.result);
245///
246/// // Create a failed check
247/// let check = Check::error("user_count != 0");
248/// assert!(!check.result);
249/// ```
250#[derive(Debug, Clone)]
251pub struct Check {
252    pub result: bool,
253    pub expr: String,
254}
255
256impl Check {
257    pub fn success(expr: impl Into<String>) -> Check {
258        Check {
259            result: true,
260            expr: expr.into(),
261        }
262    }
263
264    pub fn error(expr: impl Into<String>) -> Check {
265        Check {
266            result: false,
267            expr: expr.into(),
268        }
269    }
270}
271
272/// A test execution event with full context.
273///
274/// Events are published throughout test execution and include the project,
275/// module, and test name for complete traceability. The event body contains
276/// the specific event data (start, check, HTTP, retry, or end).
277///
278/// # Event Flow
279///
280/// 1. `Start` - Test begins execution
281/// 2. `Check` - Assertion results (can be multiple per test)
282/// 3. `Http` - HTTP request/response logs (can be multiple per test)
283/// 4. `Retry` - Test retry attempts (if configured)
284/// 5. `End` - Test completion with final result
285#[derive(Debug, Clone)]
286pub struct Event {
287    pub project: ProjectName,
288    pub module: ModuleName,
289    pub test: ModuleName,
290    pub body: EventBody,
291}
292
293/// The specific event data published during test execution.
294///
295/// Each event type carries different information:
296/// - `Start`: Signals test execution beginning
297/// - `Check`: Contains assertion results with expression details
298/// - `Http`: HTTP request/response logs for debugging
299/// - `Retry`: Indicates a test retry attempt
300/// - `End`: Final test result with timing and outcome
301/// - `Summary`: Overall test execution summary with counts and timing
302#[derive(Debug, Clone)]
303pub enum EventBody {
304    Start,
305    Check(Box<Check>),
306    Http(Box<http::Log>),
307    Retry(Test),
308    End(Test),
309    Summary(TestSummary),
310}
311
312impl From<EventBody> for Event {
313    fn from(body: EventBody) -> Self {
314        let project = crate::config::get_config();
315        let test_info = crate::runner::get_test_info();
316        Event {
317            project: project.name.clone(),
318            module: test_info.module.clone(),
319            test: test_info.name.clone(),
320            body,
321        }
322    }
323}
324
325/// Final test execution result.
326///
327/// Contains the complete outcome of a test execution including metadata,
328/// execution time, and the final result (success or specific error type).
329/// This is published in the `End` event when a test completes.
330#[derive(Debug, Clone)]
331pub struct Test {
332    pub info: Arc<TestInfo>,
333    pub worker_id: isize,
334    pub started_at: SystemTime,
335    pub ended_at: SystemTime,
336    pub request_time: Duration,
337    pub result: Result<(), Error>,
338}
339
340/// Overall test execution summary.
341///
342/// Contains aggregate information about the entire test run including
343/// total counts, timing, and success/failure statistics.
344/// This is published in the `Summary` event when all tests complete.
345#[derive(Debug, Clone)]
346pub struct TestSummary {
347    pub total_tests: usize,
348    pub passed_tests: usize,
349    pub failed_tests: usize,
350    pub total_time: Duration,
351    pub test_prep_time: Duration,
352}
353
354/// Test metadata and identification.
355///
356/// Contains the module and test name for a test case. This information
357/// is used for test filtering, reporting, and event context throughout
358/// the test execution pipeline.
359#[derive(Debug, Clone, Default)]
360pub struct TestInfo {
361    pub module: String,
362    pub name: String,
363}
364
365impl TestInfo {
366    /// Full test name including module
367    pub fn full_name(&self) -> String {
368        format!("{}::{}", self.module, self.name)
369    }
370
371    /// Unique test name including project and module names
372    pub fn unique_name(&self, project: &str) -> String {
373        format!("{project}::{}::{}", self.module, self.name)
374    }
375}
376
377/// Pool of reusable worker IDs for timeline visualization.
378///
379/// Worker IDs are assigned to tests when they start executing and returned
380/// to the pool when they complete. This allows timeline visualization tools
381/// to display tests in lanes based on which worker executed them.
382#[derive(Debug)]
383pub struct WorkerIds {
384    enabled: bool,
385    ids: Mutex<Vec<isize>>,
386}
387
388impl WorkerIds {
389    /// Creates a new worker ID pool with IDs from 0 to concurrency-1.
390    ///
391    /// If `concurrency` is `None`, the pool is disabled and `acquire()` always returns -1.
392    pub fn new(concurrency: Option<usize>) -> Self {
393        match concurrency {
394            Some(c) => Self {
395                enabled: true,
396                ids: Mutex::new((0..c as isize).collect()),
397            },
398            None => Self {
399                enabled: false,
400                ids: Mutex::new(Vec::new()),
401            },
402        }
403    }
404
405    /// Acquires a worker ID from the pool.
406    ///
407    /// Returns -1 if the pool is disabled, empty, or the mutex is poisoned.
408    pub fn acquire(&self) -> isize {
409        if !self.enabled {
410            return -1;
411        }
412        self.ids
413            .lock()
414            .ok()
415            .and_then(|mut guard| guard.pop())
416            .unwrap_or(-1)
417    }
418
419    /// Returns a worker ID to the pool.
420    ///
421    /// Does nothing if the pool is disabled, the mutex is poisoned, or id is negative.
422    pub fn release(&self, id: isize) {
423        if !self.enabled || id < 0 {
424            return;
425        }
426        if let Ok(mut guard) = self.ids.lock() {
427            guard.push(id);
428        }
429    }
430}
431
432type TestCaseFactory = Arc<
433    dyn Fn() -> Pin<Box<dyn futures::Future<Output = eyre::Result<()>> + Send + 'static>>
434        + Sync
435        + Send
436        + 'static,
437>;
438
439/// Configuration options for test runner behavior.
440///
441/// Controls various aspects of test execution including logging,
442/// concurrency, and channel management. These options can be set
443/// via the builder pattern on the `Runner`.
444///
445/// # Examples
446///
447/// ```rust,ignore
448/// use tanu_core::Runner;
449///
450/// let mut runner = Runner::new();
451/// runner.capture_http(); // Enable HTTP logging
452/// runner.set_concurrency(4); // Limit to 4 concurrent tests
453/// ```
454#[derive(Debug, Clone)]
455pub struct Options {
456    pub debug: bool,
457    pub capture_http: bool,
458    pub capture_rust: bool,
459    pub terminate_channel: bool,
460    pub concurrency: Option<usize>,
461    /// Whether to mask sensitive data (API keys, tokens) in HTTP logs.
462    /// Defaults to `true` (masked). Set to `false` with `--show-sensitive` flag.
463    pub mask_sensitive: bool,
464}
465
466impl Default for Options {
467    fn default() -> Self {
468        Self {
469            debug: false,
470            capture_http: false,
471            capture_rust: false,
472            terminate_channel: false,
473            concurrency: None,
474            mask_sensitive: true, // Masked by default for security
475        }
476    }
477}
478
479/// Trait for filtering test cases during execution.
480///
481/// Filters allow selective test execution based on project configuration
482/// and test metadata. Multiple filters can be applied simultaneously,
483/// and a test must pass all filters to be executed.
484///
485/// # Examples
486///
487/// ```rust,ignore
488/// use tanu_core::runner::{Filter, TestInfo, ProjectConfig};
489///
490/// struct CustomFilter;
491///
492/// impl Filter for CustomFilter {
493///     fn filter(&self, project: &ProjectConfig, info: &TestInfo) -> bool {
494///         // Only run tests with "integration" in the name
495///         info.name.contains("integration")
496///     }
497/// }
498/// ```
499pub trait Filter {
500    fn filter(&self, project: &ProjectConfig, info: &TestInfo) -> bool;
501}
502
503/// Filters tests to only run from specified projects.
504///
505/// When project names are provided, only tests from those projects
506/// will be executed. If the list is empty, all projects are included.
507///
508/// # Examples
509///
510/// ```rust,ignore
511/// use tanu_core::runner::ProjectFilter;
512///
513/// let filter = ProjectFilter { project_names: &["staging".to_string()] };
514/// // Only tests from "staging" project will run
515/// ```
516pub struct ProjectFilter<'a> {
517    project_names: &'a [String],
518}
519
520impl Filter for ProjectFilter<'_> {
521    fn filter(&self, project: &ProjectConfig, _info: &TestInfo) -> bool {
522        if self.project_names.is_empty() {
523            return true;
524        }
525
526        self.project_names
527            .iter()
528            .any(|project_name| &project.name == project_name)
529    }
530}
531
532/// Filters tests to only run from specified modules.
533///
534/// When module names are provided, only tests from those modules
535/// will be executed. If the list is empty, all modules are included.
536/// Module names correspond to Rust module paths.
537///
538/// # Examples
539///
540/// ```rust,ignore
541/// use tanu_core::runner::ModuleFilter;
542///
543/// let filter = ModuleFilter { module_names: &["api".to_string(), "auth".to_string()] };
544/// // Only tests from "api" and "auth" modules will run
545/// ```
546pub struct ModuleFilter<'a> {
547    module_names: &'a [String],
548}
549
550impl Filter for ModuleFilter<'_> {
551    fn filter(&self, _project: &ProjectConfig, info: &TestInfo) -> bool {
552        if self.module_names.is_empty() {
553            return true;
554        }
555
556        self.module_names
557            .iter()
558            .any(|module_name| &info.module == module_name)
559    }
560}
561
562/// Filters tests to only run specific named tests.
563///
564/// When test names are provided, only those exact tests will be executed.
565/// Test names should include the module (e.g., "api::health_check").
566/// If the list is empty, all tests are included.
567///
568/// # Examples
569///
570/// ```rust,ignore
571/// use tanu_core::runner::TestNameFilter;
572///
573/// let filter = TestNameFilter {
574///     test_names: &["api::health_check".to_string(), "auth::login".to_string()]
575/// };
576/// // Only the specified tests will run
577/// ```
578pub struct TestNameFilter<'a> {
579    test_names: &'a [String],
580}
581
582impl Filter for TestNameFilter<'_> {
583    fn filter(&self, _project: &ProjectConfig, info: &TestInfo) -> bool {
584        if self.test_names.is_empty() {
585            return true;
586        }
587
588        self.test_names
589            .iter()
590            .any(|test_name| &info.full_name() == test_name)
591    }
592}
593
594/// Filters out tests that are configured to be ignored.
595///
596/// This filter reads the `test_ignore` configuration from each project
597/// and excludes those tests from execution. Tests are matched by their
598/// full name (module::test_name).
599///
600/// # Configuration
601///
602/// In `tanu.toml`:
603/// ```toml
604/// [[projects]]
605/// name = "staging"
606/// test_ignore = ["flaky_test", "slow_integration_test"]
607/// ```
608///
609/// # Examples
610///
611/// ```rust,ignore
612/// use tanu_core::runner::TestIgnoreFilter;
613///
614/// let filter = TestIgnoreFilter::default();
615/// // Tests listed in test_ignore config will be skipped
616/// ```
617pub struct TestIgnoreFilter {
618    test_ignores: HashMap<String, Vec<String>>,
619}
620
621impl Default for TestIgnoreFilter {
622    fn default() -> TestIgnoreFilter {
623        TestIgnoreFilter {
624            test_ignores: get_tanu_config()
625                .projects
626                .iter()
627                .map(|proj| (proj.name.clone(), proj.test_ignore.clone()))
628                .collect(),
629        }
630    }
631}
632
633impl Filter for TestIgnoreFilter {
634    fn filter(&self, project: &ProjectConfig, info: &TestInfo) -> bool {
635        let Some(test_ignore) = self.test_ignores.get(&project.name) else {
636            return true;
637        };
638
639        test_ignore
640            .iter()
641            .all(|test_name| &info.full_name() != test_name)
642    }
643}
644
645/// The main test execution engine for tanu.
646///
647/// `Runner` is responsible for orchestrating the entire test execution pipeline:
648/// test discovery, filtering, concurrent execution, retry handling, event publishing,
649/// and result reporting. It supports multiple projects, configurable concurrency,
650/// and pluggable reporters.
651///
652/// # Features
653///
654/// - **Concurrent Execution**: Tests run in parallel with configurable limits
655/// - **Retry Logic**: Automatic retry with exponential backoff for flaky tests
656/// - **Event System**: Real-time event publishing for UI integration
657/// - **Filtering**: Filter tests by project, module, or test name
658/// - **Reporting**: Support for multiple output formats via reporters
659/// - **HTTP Logging**: Capture and log all HTTP requests/responses
660///
661/// # Examples
662///
663/// ```rust,ignore
664/// use tanu_core::{Runner, reporter::TableReporter};
665///
666/// let mut runner = Runner::new();
667/// runner.capture_http();
668/// runner.set_concurrency(8);
669/// runner.add_reporter(TableReporter::new());
670///
671/// // Add tests (typically done by procedural macros)
672/// runner.add_test("health_check", "api", test_factory);
673///
674/// // Run all tests
675/// runner.run(&[], &[], &[]).await?;
676/// ```
677///
678/// # Architecture
679///
680/// Tests are executed in separate tokio tasks with:
681/// - Project-scoped configuration
682/// - Test-scoped context for event publishing  
683/// - Semaphore-based concurrency control
684/// - Panic recovery and error handling
685/// - Automatic retry with configurable backoff
686#[derive(Default)]
687pub struct Runner {
688    cfg: Config,
689    options: Options,
690    test_cases: Vec<(Arc<TestInfo>, TestCaseFactory)>,
691    reporters: Vec<Box<dyn Reporter + Send>>,
692}
693
694impl Runner {
695    /// Creates a new runner with the global tanu configuration.
696    ///
697    /// This loads the configuration from `tanu.toml` and sets up
698    /// default options. Use `with_config()` for custom configuration.
699    ///
700    /// # Examples
701    ///
702    /// ```rust,ignore
703    /// use tanu_core::Runner;
704    ///
705    /// let runner = Runner::new();
706    /// ```
707    pub fn new() -> Runner {
708        Runner::with_config(get_tanu_config().clone())
709    }
710
711    /// Creates a new runner with the specified configuration.
712    ///
713    /// This allows for custom configuration beyond what's in `tanu.toml`,
714    /// useful for testing or programmatic setup.
715    ///
716    /// # Examples
717    ///
718    /// ```rust,ignore
719    /// use tanu_core::{Runner, Config};
720    ///
721    /// let config = Config::default();
722    /// let runner = Runner::with_config(config);
723    /// ```
724    pub fn with_config(cfg: Config) -> Runner {
725        Runner {
726            cfg,
727            options: Options::default(),
728            test_cases: Vec::new(),
729            reporters: Vec::new(),
730        }
731    }
732
733    /// Enables HTTP request/response logging.
734    ///
735    /// When enabled, all HTTP requests made via tanu's HTTP client
736    /// will be logged and included in test reports. This is useful
737    /// for debugging API tests and understanding request/response flow.
738    ///
739    /// # Examples
740    ///
741    /// ```rust,ignore
742    /// let mut runner = Runner::new();
743    /// runner.capture_http();
744    /// ```
745    pub fn capture_http(&mut self) {
746        self.options.capture_http = true;
747    }
748
749    /// Enables Rust logging output during test execution.
750    ///
751    /// This initializes the tracing subscriber to capture debug, info,
752    /// warn, and error logs from tests and the framework itself.
753    /// Useful for debugging test execution issues.
754    ///
755    /// # Examples
756    ///
757    /// ```rust,ignore
758    /// let mut runner = Runner::new();
759    /// runner.capture_rust();
760    /// ```
761    pub fn capture_rust(&mut self) {
762        self.options.capture_rust = true;
763    }
764
765    /// Configures the runner to close the event channel after test execution.
766    ///
767    /// By default, the event channel remains open for continued monitoring.
768    /// This option closes the channel when all tests complete, signaling
769    /// that no more events will be published.
770    ///
771    /// # Examples
772    ///
773    /// ```rust,ignore
774    /// let mut runner = Runner::new();
775    /// runner.terminate_channel();
776    /// ```
777    pub fn terminate_channel(&mut self) {
778        self.options.terminate_channel = true;
779    }
780
781    /// Adds a reporter for test output formatting.
782    ///
783    /// Reporters receive test events and format them for different output
784    /// destinations (console, files, etc.). Multiple reporters can be added
785    /// to generate multiple output formats simultaneously.
786    ///
787    /// # Examples
788    ///
789    /// ```rust,ignore
790    /// use tanu_core::{Runner, reporter::TableReporter};
791    ///
792    /// let mut runner = Runner::new();
793    /// runner.add_reporter(TableReporter::new());
794    /// ```
795    pub fn add_reporter(&mut self, reporter: impl Reporter + 'static + Send) {
796        self.reporters.push(Box::new(reporter));
797    }
798
799    /// Adds a boxed reporter for test output formatting.
800    ///
801    /// Similar to `add_reporter()` but accepts an already-boxed reporter.
802    /// Useful when working with dynamic reporter selection.
803    ///
804    /// # Examples
805    ///
806    /// ```rust,ignore
807    /// use tanu_core::{Runner, reporter::ListReporter};
808    ///
809    /// let mut runner = Runner::new();
810    /// let reporter: Box<dyn Reporter + Send> = Box::new(ListReporter::new());
811    /// runner.add_boxed_reporter(reporter);
812    /// ```
813    pub fn add_boxed_reporter(&mut self, reporter: Box<dyn Reporter + 'static + Send>) {
814        self.reporters.push(reporter);
815    }
816
817    /// Add a test case to the runner.
818    pub fn add_test(&mut self, name: &str, module: &str, factory: TestCaseFactory) {
819        self.test_cases.push((
820            Arc::new(TestInfo {
821                name: name.into(),
822                module: module.into(),
823            }),
824            factory,
825        ));
826    }
827
828    /// Sets the maximum number of tests to run concurrently.
829    ///
830    /// By default, tests run with unlimited concurrency. This setting
831    /// allows you to limit concurrent execution to reduce resource usage
832    /// or avoid overwhelming external services.
833    ///
834    /// # Examples
835    ///
836    /// ```rust,ignore
837    /// let mut runner = Runner::new();
838    /// runner.set_concurrency(4); // Max 4 tests at once
839    /// ```
840    pub fn set_concurrency(&mut self, concurrency: usize) {
841        self.options.concurrency = Some(concurrency);
842    }
843
844    /// Disables sensitive data masking in HTTP logs.
845    ///
846    /// By default, sensitive data (Authorization headers, API keys in URLs, etc.)
847    /// is masked with `*****` when HTTP logging is enabled. Call this method
848    /// to show the actual values instead.
849    ///
850    /// # Examples
851    ///
852    /// ```rust,ignore
853    /// let mut runner = Runner::new();
854    /// runner.capture_http(); // Enable HTTP logging
855    /// runner.show_sensitive(); // Show actual values instead of *****
856    /// ```
857    pub fn show_sensitive(&mut self) {
858        self.options.mask_sensitive = false;
859    }
860
861    /// Executes all registered tests with optional filtering.
862    ///
863    /// Runs tests concurrently according to the configured options and filters.
864    /// Tests can be filtered by project name, module name, or specific test names.
865    /// Empty filter arrays mean "include all".
866    ///
867    /// # Parameters
868    ///
869    /// - `project_names`: Only run tests from these projects (empty = all projects)
870    /// - `module_names`: Only run tests from these modules (empty = all modules)  
871    /// - `test_names`: Only run these specific tests (empty = all tests)
872    ///
873    /// # Examples
874    ///
875    /// ```rust,ignore
876    /// let mut runner = Runner::new();
877    ///
878    /// // Run all tests
879    /// runner.run(&[], &[], &[]).await?;
880    ///
881    /// // Run only "staging" project tests
882    /// runner.run(&["staging".to_string()], &[], &[]).await?;
883    ///
884    /// // Run specific test
885    /// runner.run(&[], &[], &["api::health_check".to_string()]).await?;
886    /// ```
887    ///
888    /// # Errors
889    ///
890    /// Returns an error if:
891    /// - Any test fails (unless configured to continue on failure)
892    /// - A test panics and cannot be recovered
893    /// - Reporter setup or execution fails
894    /// - Event channel operations fail
895    #[allow(clippy::too_many_lines)]
896    pub async fn run(
897        &mut self,
898        project_names: &[String],
899        module_names: &[String],
900        test_names: &[String],
901    ) -> eyre::Result<()> {
902        // Set masking configuration for HTTP logs
903        crate::masking::set_mask_sensitive(self.options.mask_sensitive);
904
905        if self.options.capture_rust {
906            tracing_subscriber::fmt::init();
907        }
908
909        let reporters = std::mem::take(&mut self.reporters);
910
911        // Set up barrier for all reporters + runner
912        // This ensures all reporters subscribe before tests start
913        setup_reporter_barrier(reporters.len())?;
914
915        let reporter_handles: Vec<_> = reporters
916            .into_iter()
917            .map(|mut reporter| tokio::spawn(async move { reporter.run().await }))
918            .collect();
919
920        // Wait for all reporters to subscribe before starting tests
921        wait_reporter_barrier().await;
922
923        let project_filter = ProjectFilter { project_names };
924        let module_filter = ModuleFilter { module_names };
925        let test_name_filter = TestNameFilter { test_names };
926        let test_ignore_filter = TestIgnoreFilter::default();
927
928        let start = std::time::Instant::now();
929        let handles: FuturesUnordered<_> = {
930            // Create a semaphore to limit concurrency
931            let concurrency = self.options.concurrency;
932            let semaphore = Arc::new(tokio::sync::Semaphore::new(
933                concurrency.unwrap_or(tokio::sync::Semaphore::MAX_PERMITS),
934            ));
935
936            // Worker ID pool for timeline visualization (only when concurrency is specified)
937            let worker_ids = Arc::new(WorkerIds::new(concurrency));
938
939            let projects = self.cfg.projects.clone();
940            let projects = if projects.is_empty() {
941                vec![Arc::new(ProjectConfig {
942                    name: "default".into(),
943                    ..Default::default()
944                })]
945            } else {
946                projects
947            };
948            self.test_cases
949                .iter()
950                .cartesian_product(projects.into_iter())
951                .map(|((info, factory), project)| (project, Arc::clone(info), factory.clone()))
952                .filter(move |(project, info, _)| test_name_filter.filter(project, info))
953                .filter(move |(project, info, _)| module_filter.filter(project, info))
954                .filter(move |(project, info, _)| project_filter.filter(project, info))
955                .filter(move |(project, info, _)| test_ignore_filter.filter(project, info))
956                .map(|(project, info, factory)| {
957                    let semaphore = semaphore.clone();
958                    let worker_ids = worker_ids.clone();
959                    tokio::spawn(async move {
960                        let _permit = semaphore
961                            .acquire()
962                            .await
963                            .map_err(|e| eyre::eyre!("failed to acquire semaphore: {e}"))?;
964
965                        // Acquire worker ID from pool
966                        let worker_id = worker_ids.acquire();
967
968                        let project_for_scope = Arc::clone(&project);
969                        let info_for_scope = Arc::clone(&info);
970                        let result = config::PROJECT
971                            .scope(project_for_scope, async {
972                                TEST_INFO
973                                    .scope(info_for_scope, async {
974                                        let test_name = info.name.clone();
975                                        publish(EventBody::Start)?;
976
977                                        let retry_count =
978                                            AtomicUsize::new(project.retry.count.unwrap_or(0));
979                                        let f = || async {
980                                            let started_at = SystemTime::now();
981                                            let request_started = std::time::Instant::now();
982                                            let res = factory().await;
983                                            let ended_at = SystemTime::now();
984
985                                            if res.is_err()
986                                                && retry_count.load(Ordering::SeqCst) > 0
987                                            {
988                                                let test_result = match &res {
989                                                    Ok(_) => Ok(()),
990                                                    Err(e) => {
991                                                        Err(Error::ErrorReturned(format!("{e:?}")))
992                                                    }
993                                                };
994                                                let test = Test {
995                                                    result: test_result,
996                                                    info: Arc::clone(&info),
997                                                    worker_id,
998                                                    started_at,
999                                                    ended_at,
1000                                                    request_time: request_started.elapsed(),
1001                                                };
1002                                                publish(EventBody::Retry(test))?;
1003                                                retry_count.fetch_sub(1, Ordering::SeqCst);
1004                                            };
1005                                            res
1006                                        };
1007                                        let started_at = SystemTime::now();
1008                                        let started = std::time::Instant::now();
1009                                        let fut = f.retry(project.retry.backoff());
1010                                        let fut = std::panic::AssertUnwindSafe(fut).catch_unwind();
1011                                        let res = fut.await;
1012                                        let request_time = started.elapsed();
1013                                        let ended_at = SystemTime::now();
1014
1015                                        let result = match res {
1016                                            Ok(Ok(_)) => {
1017                                                debug!("{test_name} ok");
1018                                                Ok(())
1019                                            }
1020                                            Ok(Err(e)) => {
1021                                                debug!("{test_name} failed: {e:#}");
1022                                                Err(Error::ErrorReturned(format!("{e:?}")))
1023                                            }
1024                                            Err(e) => {
1025                                                let panic_message = if let Some(panic_message) =
1026                                                    e.downcast_ref::<&str>()
1027                                                {
1028                                                    format!(
1029                                                "{test_name} failed with message: {panic_message}"
1030                                            )
1031                                                } else if let Some(panic_message) =
1032                                                    e.downcast_ref::<String>()
1033                                                {
1034                                                    format!(
1035                                                "{test_name} failed with message: {panic_message}"
1036                                            )
1037                                                } else {
1038                                                    format!(
1039                                                        "{test_name} failed with unknown message"
1040                                                    )
1041                                                };
1042                                                let e = eyre::eyre!(panic_message);
1043                                                Err(Error::Panicked(format!("{e:?}")))
1044                                            }
1045                                        };
1046
1047                                        let is_err = result.is_err();
1048                                        publish(EventBody::End(Test {
1049                                            info,
1050                                            worker_id,
1051                                            started_at,
1052                                            ended_at,
1053                                            request_time,
1054                                            result,
1055                                        }))?;
1056
1057                                        eyre::ensure!(!is_err);
1058                                        eyre::Ok(())
1059                                    })
1060                                    .await
1061                            })
1062                            .await;
1063
1064                        // Return worker ID to pool
1065                        worker_ids.release(worker_id);
1066
1067                        result
1068                    })
1069                })
1070                .collect()
1071        };
1072        let test_prep_time = start.elapsed();
1073        debug!(
1074            "created handles for {} test cases; took {}s",
1075            handles.len(),
1076            test_prep_time.as_secs_f32()
1077        );
1078
1079        let mut has_any_error = false;
1080        let total_tests = handles.len();
1081        let options = self.options.clone();
1082        let runner = async move {
1083            let results = handles.collect::<Vec<_>>().await;
1084            if results.is_empty() {
1085                console::Term::stdout().write_line("no test cases found")?;
1086            }
1087
1088            let mut failed_tests = 0;
1089            for result in results {
1090                match result {
1091                    Ok(res) => {
1092                        if let Err(e) = res {
1093                            debug!("test case failed: {e:#}");
1094                            has_any_error = true;
1095                            failed_tests += 1;
1096                        }
1097                    }
1098                    Err(e) => {
1099                        if e.is_panic() {
1100                            // Resume the panic on the main task
1101                            error!("{e}");
1102                            has_any_error = true;
1103                            failed_tests += 1;
1104                        }
1105                    }
1106                }
1107            }
1108
1109            let passed_tests = total_tests - failed_tests;
1110            let total_time = start.elapsed();
1111
1112            // Publish summary event
1113            let summary = TestSummary {
1114                total_tests,
1115                passed_tests,
1116                failed_tests,
1117                total_time,
1118                test_prep_time,
1119            };
1120
1121            // Create a dummy event for summary (since it doesn't belong to a specific test)
1122            let summary_event = Event {
1123                project: "".to_string(),
1124                module: "".to_string(),
1125                test: "".to_string(),
1126                body: EventBody::Summary(summary),
1127            };
1128
1129            if let Ok(guard) = CHANNEL.lock() {
1130                if let Some((tx, _)) = guard.as_ref() {
1131                    let _ = tx.send(summary_event);
1132                }
1133            }
1134            debug!("all test finished. sending stop signal to the background tasks.");
1135
1136            if options.terminate_channel {
1137                let Ok(mut guard) = CHANNEL.lock() else {
1138                    eyre::bail!("failed to acquire runner channel lock");
1139                };
1140                guard.take(); // closing the runner channel.
1141            }
1142
1143            if has_any_error {
1144                eyre::bail!("one or more tests failed");
1145            }
1146
1147            eyre::Ok(())
1148        };
1149
1150        let runner_result = runner.await;
1151
1152        for handle in reporter_handles {
1153            match handle.await {
1154                Ok(Ok(())) => {}
1155                Ok(Err(e)) => error!("reporter failed: {e:#}"),
1156                Err(e) => error!("reporter task panicked: {e:#}"),
1157            }
1158        }
1159
1160        // Clean up barrier
1161        clear_reporter_barrier();
1162
1163        debug!("runner stopped");
1164
1165        runner_result
1166    }
1167
1168    /// Returns a list of all registered test metadata.
1169    ///
1170    /// This provides access to test information without executing the tests.
1171    /// Useful for building test UIs, generating reports, or implementing
1172    /// custom filtering logic.
1173    ///
1174    /// # Examples
1175    ///
1176    /// ```rust,ignore
1177    /// let runner = Runner::new();
1178    /// let tests = runner.list();
1179    ///
1180    /// for test in tests {
1181    ///     println!("Test: {}", test.full_name());
1182    /// }
1183    /// ```
1184    pub fn list(&self) -> Vec<&TestInfo> {
1185        self.test_cases
1186            .iter()
1187            .map(|(meta, _test)| meta.as_ref())
1188            .collect::<Vec<_>>()
1189    }
1190}
1191
1192#[cfg(test)]
1193mod test {
1194    use super::*;
1195    use crate::config::RetryConfig;
1196    use crate::ProjectConfig;
1197    use std::sync::Arc;
1198
1199    fn create_config() -> Config {
1200        Config {
1201            projects: vec![Arc::new(ProjectConfig {
1202                name: "default".into(),
1203                ..Default::default()
1204            })],
1205            ..Default::default()
1206        }
1207    }
1208
1209    fn create_config_with_retry() -> Config {
1210        Config {
1211            projects: vec![Arc::new(ProjectConfig {
1212                name: "default".into(),
1213                retry: RetryConfig {
1214                    count: Some(1),
1215                    ..Default::default()
1216                },
1217                ..Default::default()
1218            })],
1219            ..Default::default()
1220        }
1221    }
1222
1223    #[tokio::test]
1224    async fn runner_fail_because_no_retry_configured() -> eyre::Result<()> {
1225        let mut server = mockito::Server::new_async().await;
1226        let m1 = server
1227            .mock("GET", "/")
1228            .with_status(500)
1229            .expect(1)
1230            .create_async()
1231            .await;
1232        let m2 = server
1233            .mock("GET", "/")
1234            .with_status(200)
1235            .expect(0)
1236            .create_async()
1237            .await;
1238
1239        let factory: TestCaseFactory = Arc::new(move || {
1240            let url = server.url();
1241            Box::pin(async move {
1242                let client = crate::http::Client::new();
1243                let res = client.get(&url).send().await?;
1244                if res.status().is_success() {
1245                    Ok(())
1246                } else {
1247                    eyre::bail!("request failed")
1248                }
1249            })
1250        });
1251
1252        let _runner_rx = subscribe()?;
1253        let mut runner = Runner::with_config(create_config());
1254        runner.add_test("retry_test", "module", factory);
1255
1256        let result = runner.run(&[], &[], &[]).await;
1257        m1.assert_async().await;
1258        m2.assert_async().await;
1259
1260        assert!(result.is_err());
1261        Ok(())
1262    }
1263
1264    #[tokio::test]
1265    async fn runner_retry_successful_after_failure() -> eyre::Result<()> {
1266        let mut server = mockito::Server::new_async().await;
1267        let m1 = server
1268            .mock("GET", "/")
1269            .with_status(500)
1270            .expect(1)
1271            .create_async()
1272            .await;
1273        let m2 = server
1274            .mock("GET", "/")
1275            .with_status(200)
1276            .expect(1)
1277            .create_async()
1278            .await;
1279
1280        let factory: TestCaseFactory = Arc::new(move || {
1281            let url = server.url();
1282            Box::pin(async move {
1283                let client = crate::http::Client::new();
1284                let res = client.get(&url).send().await?;
1285                if res.status().is_success() {
1286                    Ok(())
1287                } else {
1288                    eyre::bail!("request failed")
1289                }
1290            })
1291        });
1292
1293        let _runner_rx = subscribe()?;
1294        let mut runner = Runner::with_config(create_config_with_retry());
1295        runner.add_test("retry_test", "module", factory);
1296
1297        let result = runner.run(&[], &[], &[]).await;
1298        m1.assert_async().await;
1299        m2.assert_async().await;
1300
1301        assert!(result.is_ok());
1302
1303        Ok(())
1304    }
1305
1306    #[tokio::test]
1307    async fn spawned_task_panics_without_task_local_context() {
1308        let project = Arc::new(ProjectConfig {
1309            name: "default".to_string(),
1310            ..Default::default()
1311        });
1312        let test_info = Arc::new(TestInfo {
1313            module: "mod".to_string(),
1314            name: "test".to_string(),
1315        });
1316
1317        crate::config::PROJECT
1318            .scope(
1319                project,
1320                TEST_INFO.scope(test_info, async move {
1321                    let handle = tokio::spawn(async move {
1322                        let _ = crate::config::get_config();
1323                    });
1324
1325                    let join_err = handle.await.expect_err("spawned task should panic");
1326                    assert!(join_err.is_panic());
1327                }),
1328            )
1329            .await;
1330    }
1331
1332    #[tokio::test]
1333    async fn scope_current_propagates_task_local_context_into_spawned_task() {
1334        let project = Arc::new(ProjectConfig {
1335            name: "default".to_string(),
1336            ..Default::default()
1337        });
1338        let test_info = Arc::new(TestInfo {
1339            module: "mod".to_string(),
1340            name: "test".to_string(),
1341        });
1342
1343        crate::config::PROJECT
1344            .scope(
1345                project,
1346                TEST_INFO.scope(test_info, async move {
1347                    let handle = tokio::spawn(super::scope_current(async move {
1348                        let _ = crate::config::get_config();
1349                        let _ = super::get_test_info();
1350                    }));
1351
1352                    handle.await.expect("spawned task should not panic");
1353                }),
1354            )
1355            .await;
1356    }
1357
1358    #[tokio::test]
1359    #[serial_test::serial]
1360    async fn masking_masks_sensitive_query_params_in_http_logs() -> eyre::Result<()> {
1361        use crate::masking;
1362
1363        // Ensure masking is enabled
1364        masking::set_mask_sensitive(true);
1365
1366        let mut server = mockito::Server::new_async().await;
1367        let _mock = server
1368            .mock("GET", mockito::Matcher::Any)
1369            .with_status(200)
1370            .create_async()
1371            .await;
1372
1373        let factory: TestCaseFactory = Arc::new(move || {
1374            let url = server.url();
1375            Box::pin(async move {
1376                let client = crate::http::Client::new();
1377                // Make request with sensitive query param embedded in URL
1378                let _res = client
1379                    .get(format!("{url}?access_token=secret_token_123&user=john"))
1380                    .send()
1381                    .await?;
1382                Ok(())
1383            })
1384        });
1385
1386        let mut rx = subscribe()?;
1387        let mut runner = Runner::with_config(create_config());
1388        runner.add_test("masking_query_test", "masking_module", factory);
1389
1390        runner.run(&[], &[], &[]).await?;
1391
1392        // Collect HTTP events for this specific test
1393        let mut found_http_event = false;
1394        while let Ok(event) = rx.try_recv() {
1395            // Filter to only our test's events
1396            if event.test != "masking_query_test" {
1397                continue;
1398            }
1399            if let EventBody::Http(log) = event.body {
1400                found_http_event = true;
1401                let url_str = log.request.url.to_string();
1402
1403                // Verify sensitive param is masked
1404                assert!(
1405                    url_str.contains("access_token=*****"),
1406                    "access_token should be masked, got: {url_str}"
1407                );
1408                // Non-sensitive params should not be masked
1409                assert!(
1410                    url_str.contains("user=john"),
1411                    "user should not be masked, got: {url_str}"
1412                );
1413            }
1414        }
1415
1416        assert!(found_http_event, "Should have received HTTP event");
1417        Ok(())
1418    }
1419
1420    #[tokio::test]
1421    #[serial_test::serial]
1422    async fn masking_masks_sensitive_headers_in_http_logs() -> eyre::Result<()> {
1423        use crate::masking;
1424
1425        // Ensure masking is enabled
1426        masking::set_mask_sensitive(true);
1427
1428        let mut server = mockito::Server::new_async().await;
1429        let _mock = server
1430            .mock("GET", "/")
1431            .with_status(200)
1432            .create_async()
1433            .await;
1434
1435        let factory: TestCaseFactory = Arc::new(move || {
1436            let url = server.url();
1437            Box::pin(async move {
1438                let client = crate::http::Client::new();
1439                // Make request with sensitive headers
1440                let _res = client
1441                    .get(&url)
1442                    .header("authorization", "Bearer secret_bearer_token")
1443                    .header("x-api-key", "my_secret_api_key")
1444                    .header("content-type", "application/json")
1445                    .send()
1446                    .await?;
1447                Ok(())
1448            })
1449        });
1450
1451        let mut rx = subscribe()?;
1452        let mut runner = Runner::with_config(create_config());
1453        runner.add_test("masking_headers_test", "masking_module", factory);
1454
1455        runner.run(&[], &[], &[]).await?;
1456
1457        // Collect HTTP events for this specific test
1458        let mut found_http_event = false;
1459        while let Ok(event) = rx.try_recv() {
1460            // Filter to only our test's events
1461            if event.test != "masking_headers_test" {
1462                continue;
1463            }
1464            if let EventBody::Http(log) = event.body {
1465                found_http_event = true;
1466
1467                // Verify sensitive headers are masked
1468                if let Some(auth) = log.request.headers.get("authorization") {
1469                    assert_eq!(
1470                        auth.to_str().unwrap(),
1471                        "*****",
1472                        "authorization header should be masked"
1473                    );
1474                }
1475                if let Some(api_key) = log.request.headers.get("x-api-key") {
1476                    assert_eq!(
1477                        api_key.to_str().unwrap(),
1478                        "*****",
1479                        "x-api-key header should be masked"
1480                    );
1481                }
1482                // Non-sensitive headers should not be masked
1483                if let Some(content_type) = log.request.headers.get("content-type") {
1484                    assert_eq!(
1485                        content_type.to_str().unwrap(),
1486                        "application/json",
1487                        "content-type header should not be masked"
1488                    );
1489                }
1490            }
1491        }
1492
1493        assert!(found_http_event, "Should have received HTTP event");
1494        Ok(())
1495    }
1496
1497    #[tokio::test]
1498    #[serial_test::serial]
1499    async fn masking_show_sensitive_disables_masking_in_http_logs() -> eyre::Result<()> {
1500        use crate::masking;
1501
1502        masking::set_mask_sensitive(true);
1503
1504        let mut server = mockito::Server::new_async().await;
1505        let _mock = server
1506            .mock("GET", "/")
1507            .with_status(200)
1508            .create_async()
1509            .await;
1510
1511        let factory: TestCaseFactory = Arc::new(move || {
1512            let url = server.url();
1513            Box::pin(async move {
1514                let client = crate::http::Client::new();
1515                let _res = client
1516                    .get(format!("{url}?access_token=secret_token_123"))
1517                    .header("authorization", "Bearer secret_bearer_token")
1518                    .send()
1519                    .await?;
1520                Ok(())
1521            })
1522        });
1523
1524        let mut rx = subscribe()?;
1525        let mut runner = Runner::with_config(create_config());
1526        runner.capture_http();
1527        runner.show_sensitive();
1528        runner.add_test("show_sensitive_test", "masking_module", factory);
1529
1530        runner.run(&[], &[], &[]).await?;
1531
1532        let mut found_http_event = false;
1533        while let Ok(event) = rx.try_recv() {
1534            if event.test != "show_sensitive_test" {
1535                continue;
1536            }
1537            if let EventBody::Http(log) = event.body {
1538                found_http_event = true;
1539                let url_str = log.request.url.to_string();
1540                assert!(
1541                    url_str.contains("access_token=secret_token_123"),
1542                    "access_token should not be masked when show_sensitive is enabled"
1543                );
1544                if let Some(auth) = log.request.headers.get("authorization") {
1545                    assert_eq!(
1546                        auth.to_str().unwrap(),
1547                        "Bearer secret_bearer_token",
1548                        "authorization header should not be masked when show_sensitive is enabled"
1549                    );
1550                }
1551            }
1552        }
1553
1554        assert!(found_http_event, "Should have received HTTP event");
1555        Ok(())
1556    }
1557}