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", None, 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::{AtomicBool, 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, CaptureHttpMode, 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
207async fn execute_test(
208    project: Arc<ProjectConfig>,
209    info: Arc<TestInfo>,
210    factory: TestCaseFactory,
211    serial_mutex: Option<Arc<tokio::sync::Mutex<()>>>,
212    worker_id: isize,
213) -> eyre::Result<Test> {
214    let project_for_scope = Arc::clone(&project);
215    let info_for_scope = Arc::clone(&info);
216    config::PROJECT
217        .scope(project_for_scope, async {
218            TEST_INFO
219                .scope(info_for_scope, async {
220                    let test_name = info.name.clone();
221                    publish(EventBody::Start)?;
222
223                    let retry_count = AtomicUsize::new(project.retry.count.unwrap_or(0));
224                    let serial_mutex_clone = serial_mutex.clone();
225                    let f = || async {
226                        // Acquire serial guard just before test execution
227                        let _serial_guard = if let Some(ref mutex) = serial_mutex_clone {
228                            Some(mutex.lock().await)
229                        } else {
230                            None
231                        };
232
233                        let started_at = SystemTime::now();
234                        let request_started = std::time::Instant::now();
235                        let res = factory().await;
236                        let ended_at = SystemTime::now();
237
238                        if res.is_err() && retry_count.load(Ordering::SeqCst) > 0 {
239                            let test_result = match &res {
240                                Ok(_) => Ok(()),
241                                Err(e) => Err(Error::ErrorReturned(format!("{e:?}"))),
242                            };
243                            let test = Test {
244                                result: test_result,
245                                info: Arc::clone(&info),
246                                worker_id,
247                                started_at,
248                                ended_at,
249                                request_time: request_started.elapsed(),
250                            };
251                            publish(EventBody::Retry(test))?;
252                            retry_count.fetch_sub(1, Ordering::SeqCst);
253                        };
254                        res
255                    };
256                    let started_at = SystemTime::now();
257                    let started = std::time::Instant::now();
258                    let fut = f.retry(project.retry.backoff());
259                    let fut = std::panic::AssertUnwindSafe(fut).catch_unwind();
260                    let res = fut.await;
261                    let request_time = started.elapsed();
262                    let ended_at = SystemTime::now();
263
264                    let result = match res {
265                        Ok(Ok(_)) => {
266                            debug!("{test_name} ok");
267                            Ok(())
268                        }
269                        Ok(Err(e)) => {
270                            debug!("{test_name} failed: {e:#}");
271                            Err(Error::ErrorReturned(format!("{e:?}")))
272                        }
273                        Err(e) => {
274                            let panic_message =
275                                if let Some(panic_message) = e.downcast_ref::<&str>() {
276                                    format!("{test_name} failed with message: {panic_message}")
277                                } else if let Some(panic_message) = e.downcast_ref::<String>() {
278                                    format!("{test_name} failed with message: {panic_message}")
279                                } else {
280                                    format!("{test_name} failed with unknown message")
281                                };
282                            let e = eyre::eyre!(panic_message);
283                            Err(Error::Panicked(format!("{e:?}")))
284                        }
285                    };
286
287                    let test = Test {
288                        result,
289                        info: Arc::clone(&info),
290                        worker_id,
291                        started_at,
292                        ended_at,
293                        request_time,
294                    };
295
296                    publish(EventBody::End(test.clone()))?;
297
298                    eyre::Ok(test)
299                })
300                .await
301        })
302        .await
303}
304
305/// Clear barrier after use.
306pub(crate) fn clear_reporter_barrier() {
307    match REPORTER_BARRIER.lock() {
308        Ok(mut barrier) => {
309            *barrier = None;
310        }
311        Err(e) => {
312            error!("failed to clear reporter barrier (poisoned lock): {e}");
313        }
314    }
315}
316
317/// Test execution errors.
318///
319/// Represents the different ways a test can fail during execution.
320/// These errors are captured and reported by the runner system.
321#[derive(Debug, Clone, thiserror::Error)]
322pub enum Error {
323    #[error("panic: {0}")]
324    Panicked(String),
325    #[error("error: {0}")]
326    ErrorReturned(String),
327}
328
329/// Represents the result of a check/assertion within a test.
330///
331/// Checks are created by assertion macros (`check!`, `check_eq!`, etc.) and
332/// track both the success/failure status and the original expression that
333/// was evaluated. This information is used for detailed test reporting.
334///
335/// # Examples
336///
337/// ```rust,ignore
338/// use tanu_core::runner::Check;
339///
340/// // Create a successful check
341/// let check = Check::success("response.status() == 200");
342/// assert!(check.result);
343///
344/// // Create a failed check
345/// let check = Check::error("user_count != 0");
346/// assert!(!check.result);
347/// ```
348#[derive(Debug, Clone)]
349pub struct Check {
350    pub result: bool,
351    pub expr: String,
352}
353
354impl Check {
355    pub fn success(expr: impl Into<String>) -> Check {
356        Check {
357            result: true,
358            expr: expr.into(),
359        }
360    }
361
362    pub fn error(expr: impl Into<String>) -> Check {
363        Check {
364            result: false,
365            expr: expr.into(),
366        }
367    }
368}
369
370/// A test execution event with full context.
371///
372/// Events are published throughout test execution and include the project,
373/// module, and test name for complete traceability. The event body contains
374/// the specific event data (start, check, HTTP, retry, or end).
375///
376/// # Event Flow
377///
378/// 1. `Start` - Test begins execution
379/// 2. `Check` - Assertion results (can be multiple per test)
380/// 3. `Http` - HTTP request/response logs (can be multiple per test)
381/// 4. `Retry` - Test retry attempts (if configured)
382/// 5. `End` - Test completion with final result
383#[derive(Debug, Clone)]
384pub struct Event {
385    pub project: ProjectName,
386    pub module: ModuleName,
387    pub test: ModuleName,
388    pub body: EventBody,
389}
390
391/// The specific event data published during test execution.
392///
393/// Each event type carries different information:
394/// - `Start`: Signals test execution beginning
395/// - `Check`: Contains assertion results with expression details
396/// - `Call`: HTTP/gRPC request/response logs for debugging
397/// - `Retry`: Indicates a test retry attempt
398/// - `End`: Final test result with timing and outcome
399/// - `Summary`: Overall test execution summary with counts and timing
400///
401/// A log from a call (HTTP, gRPC, etc.)
402#[derive(Debug, Clone)]
403pub enum CallLog {
404    Http(Box<http::Log>),
405    #[cfg(feature = "grpc")]
406    Grpc(Box<crate::grpc::Log>),
407}
408
409#[derive(Debug, Clone)]
410pub enum EventBody {
411    Start,
412    Check(Box<Check>),
413    Call(CallLog),
414    Retry(Test),
415    End(Test),
416    Summary(TestSummary),
417}
418
419impl From<EventBody> for Event {
420    fn from(body: EventBody) -> Self {
421        let project = crate::config::get_config();
422        let test_info = crate::runner::get_test_info();
423        Event {
424            project: project.name.clone(),
425            module: test_info.module.clone(),
426            test: test_info.name.clone(),
427            body,
428        }
429    }
430}
431
432/// Final test execution result.
433///
434/// Contains the complete outcome of a test execution including metadata,
435/// execution time, and the final result (success or specific error type).
436/// This is published in the `End` event when a test completes.
437#[derive(Debug, Clone)]
438pub struct Test {
439    pub info: Arc<TestInfo>,
440    pub worker_id: isize,
441    pub started_at: SystemTime,
442    pub ended_at: SystemTime,
443    pub request_time: Duration,
444    pub result: Result<(), Error>,
445}
446
447/// Overall test execution summary.
448///
449/// Contains aggregate information about the entire test run including
450/// total counts, timing, and success/failure statistics.
451/// This is published in the `Summary` event when all tests complete.
452#[derive(Debug, Clone)]
453pub struct TestSummary {
454    pub total_tests: usize,
455    pub passed_tests: usize,
456    pub failed_tests: usize,
457    pub skipped_tests: usize,
458    pub total_time: Duration,
459    pub test_prep_time: Duration,
460}
461
462/// Test metadata and identification.
463///
464/// Contains the module and test name for a test case. This information
465/// is used for test filtering, reporting, and event context throughout
466/// the test execution pipeline.
467#[derive(Debug, Clone, Default)]
468pub struct TestInfo {
469    pub module: String,
470    pub name: String,
471    pub serial_group: Option<String>,
472    pub line: u32,
473    pub ordered: bool,
474}
475
476impl TestInfo {
477    /// Full test name including module
478    pub fn full_name(&self) -> String {
479        format!("{}::{}", self.module, self.name)
480    }
481
482    /// Unique test name including project and module names
483    pub fn unique_name(&self, project: &str) -> String {
484        format!("{project}::{}::{}", self.module, self.name)
485    }
486}
487
488/// Pool of reusable worker IDs for timeline visualization.
489///
490/// Worker IDs are assigned to tests when they start executing and returned
491/// to the pool when they complete. This allows timeline visualization tools
492/// to display tests in lanes based on which worker executed them.
493#[derive(Debug)]
494pub struct WorkerIds {
495    enabled: bool,
496    ids: Mutex<Vec<isize>>,
497}
498
499impl WorkerIds {
500    /// Creates a new worker ID pool with IDs from 0 to concurrency-1.
501    ///
502    /// If `concurrency` is `None`, the pool is disabled and `acquire()` always returns -1.
503    pub fn new(concurrency: Option<usize>) -> Self {
504        match concurrency {
505            Some(c) => Self {
506                enabled: true,
507                ids: Mutex::new((0..c as isize).collect()),
508            },
509            None => Self {
510                enabled: false,
511                ids: Mutex::new(Vec::new()),
512            },
513        }
514    }
515
516    /// Acquires a worker ID from the pool.
517    ///
518    /// Returns -1 if the pool is disabled, empty, or the mutex is poisoned.
519    pub fn acquire(&self) -> isize {
520        if !self.enabled {
521            return -1;
522        }
523        self.ids
524            .lock()
525            .ok()
526            .and_then(|mut guard| guard.pop())
527            .unwrap_or(-1)
528    }
529
530    /// Returns a worker ID to the pool.
531    ///
532    /// Does nothing if the pool is disabled, the mutex is poisoned, or id is negative.
533    pub fn release(&self, id: isize) {
534        if !self.enabled || id < 0 {
535            return;
536        }
537        if let Ok(mut guard) = self.ids.lock() {
538            guard.push(id);
539        }
540    }
541}
542
543type TestCaseFactory = Arc<
544    dyn Fn() -> Pin<Box<dyn futures::Future<Output = eyre::Result<()>> + Send + 'static>>
545        + Sync
546        + Send
547        + 'static,
548>;
549
550/// Configuration options for test runner behavior.
551///
552/// Controls various aspects of test execution including logging,
553/// concurrency, and channel management. These options can be set
554/// via the builder pattern on the `Runner`.
555///
556/// # Examples
557///
558/// ```rust,ignore
559/// use tanu_core::Runner;
560///
561/// let mut runner = Runner::new();
562/// runner.capture_http(); // Enable HTTP logging
563/// runner.set_concurrency(4); // Limit to 4 concurrent tests
564/// ```
565#[derive(Debug, Clone)]
566pub struct Options {
567    pub debug: bool,
568    pub capture_http: CaptureHttpMode,
569    pub capture_rust: bool,
570    pub terminate_channel: bool,
571    pub concurrency: Option<usize>,
572    /// Whether to mask sensitive data (API keys, tokens) in HTTP logs.
573    /// Defaults to `true` (masked). Set to `false` with `--show-sensitive` flag.
574    pub mask_sensitive: bool,
575    /// Whether to abort test execution after the first failure.
576    pub fail_fast: bool,
577}
578
579impl Default for Options {
580    fn default() -> Self {
581        Self {
582            debug: false,
583            capture_http: CaptureHttpMode::Off,
584            capture_rust: false,
585            terminate_channel: false,
586            concurrency: None,
587            mask_sensitive: true, // Masked by default for security
588            fail_fast: false,
589        }
590    }
591}
592
593/// Trait for filtering test cases during execution.
594///
595/// Filters allow selective test execution based on project configuration
596/// and test metadata. Multiple filters can be applied simultaneously,
597/// and a test must pass all filters to be executed.
598///
599/// # Examples
600///
601/// ```rust,ignore
602/// use tanu_core::runner::{Filter, TestInfo, ProjectConfig};
603///
604/// struct CustomFilter;
605///
606/// impl Filter for CustomFilter {
607///     fn filter(&self, project: &ProjectConfig, info: &TestInfo) -> bool {
608///         // Only run tests with "integration" in the name
609///         info.name.contains("integration")
610///     }
611/// }
612/// ```
613pub trait Filter {
614    fn filter(&self, project: &ProjectConfig, info: &TestInfo) -> bool;
615}
616
617/// Filters tests to only run from specified projects.
618///
619/// When project names are provided, only tests from those projects
620/// will be executed. If the list is empty, all projects are included.
621///
622/// # Examples
623///
624/// ```rust,ignore
625/// use tanu_core::runner::ProjectFilter;
626///
627/// let filter = ProjectFilter { project_names: &["staging".to_string()] };
628/// // Only tests from "staging" project will run
629/// ```
630pub struct ProjectFilter<'a> {
631    project_names: &'a [String],
632}
633
634impl Filter for ProjectFilter<'_> {
635    fn filter(&self, project: &ProjectConfig, _info: &TestInfo) -> bool {
636        if self.project_names.is_empty() {
637            return true;
638        }
639
640        self.project_names
641            .iter()
642            .any(|project_name| &project.name == project_name)
643    }
644}
645
646/// Filters tests to only run from specified modules.
647///
648/// When module names are provided, only tests from those modules
649/// will be executed. If the list is empty, all modules are included.
650/// Module names correspond to Rust module paths.
651///
652/// # Examples
653///
654/// ```rust,ignore
655/// use tanu_core::runner::ModuleFilter;
656///
657/// let filter = ModuleFilter { module_names: &["api".to_string(), "auth".to_string()] };
658/// // Only tests from "api" and "auth" modules will run
659/// ```
660pub struct ModuleFilter<'a> {
661    module_names: &'a [String],
662}
663
664impl Filter for ModuleFilter<'_> {
665    fn filter(&self, _project: &ProjectConfig, info: &TestInfo) -> bool {
666        if self.module_names.is_empty() {
667            return true;
668        }
669
670        self.module_names
671            .iter()
672            .any(|module_name| &info.module == module_name)
673    }
674}
675
676/// Filters tests to only run specific named tests.
677///
678/// When test names are provided, only those exact tests will be executed.
679/// Test names should include the module (e.g., "api::health_check").
680/// If the list is empty, all tests are included.
681///
682/// # Examples
683///
684/// ```rust,ignore
685/// use tanu_core::runner::TestNameFilter;
686///
687/// let filter = TestNameFilter {
688///     test_names: &["api::health_check".to_string(), "auth::login".to_string()]
689/// };
690/// // Only the specified tests will run
691/// ```
692pub struct TestNameFilter<'a> {
693    test_names: &'a [String],
694}
695
696impl Filter for TestNameFilter<'_> {
697    fn filter(&self, _project: &ProjectConfig, info: &TestInfo) -> bool {
698        if self.test_names.is_empty() {
699            return true;
700        }
701
702        self.test_names
703            .iter()
704            .any(|test_name| &info.full_name() == test_name)
705    }
706}
707
708/// Filters out tests that are configured to be ignored.
709///
710/// This filter reads the `test_ignore` configuration from each project
711/// and excludes those tests from execution. Tests are matched by their
712/// full name (module::test_name).
713///
714/// # Configuration
715///
716/// In `tanu.toml`:
717/// ```toml
718/// [[projects]]
719/// name = "staging"
720/// test_ignore = ["flaky_test", "slow_integration_test"]
721/// ```
722///
723/// # Examples
724///
725/// ```rust,ignore
726/// use tanu_core::runner::TestIgnoreFilter;
727///
728/// let filter = TestIgnoreFilter::default();
729/// // Tests listed in test_ignore config will be skipped
730/// ```
731pub struct TestIgnoreFilter {
732    test_ignores: HashMap<String, Vec<String>>,
733}
734
735impl Default for TestIgnoreFilter {
736    fn default() -> TestIgnoreFilter {
737        TestIgnoreFilter {
738            test_ignores: get_tanu_config()
739                .projects
740                .iter()
741                .map(|proj| (proj.name.clone(), proj.test_ignore.clone()))
742                .collect(),
743        }
744    }
745}
746
747impl Filter for TestIgnoreFilter {
748    fn filter(&self, project: &ProjectConfig, info: &TestInfo) -> bool {
749        let Some(test_ignore) = self.test_ignores.get(&project.name) else {
750            return true;
751        };
752
753        test_ignore
754            .iter()
755            .all(|test_name| &info.full_name() != test_name)
756    }
757}
758
759/// The main test execution engine for tanu.
760///
761/// `Runner` is responsible for orchestrating the entire test execution pipeline:
762/// test discovery, filtering, concurrent execution, retry handling, event publishing,
763/// and result reporting. It supports multiple projects, configurable concurrency,
764/// and pluggable reporters.
765///
766/// # Features
767///
768/// - **Concurrent Execution**: Tests run in parallel with configurable limits
769/// - **Retry Logic**: Automatic retry with exponential backoff for flaky tests
770/// - **Event System**: Real-time event publishing for UI integration
771/// - **Filtering**: Filter tests by project, module, or test name
772/// - **Reporting**: Support for multiple output formats via reporters
773/// - **HTTP Logging**: Capture and log all HTTP requests/responses
774///
775/// # Examples
776///
777/// ```rust,ignore
778/// use tanu_core::{Runner, reporter::TableReporter};
779///
780/// let mut runner = Runner::new();
781/// runner.capture_http();
782/// runner.set_concurrency(8);
783/// runner.add_reporter(TableReporter::new());
784///
785/// // Add tests (typically done by procedural macros)
786/// runner.add_test("health_check", "api", None, test_factory);
787///
788/// // Run all tests
789/// runner.run(&[], &[], &[]).await?;
790/// ```
791///
792/// # Architecture
793///
794/// Tests are executed in separate tokio tasks with:
795/// - Project-scoped configuration
796/// - Test-scoped context for event publishing  
797/// - Semaphore-based concurrency control
798/// - Panic recovery and error handling
799/// - Automatic retry with configurable backoff
800#[derive(Default)]
801pub struct Runner {
802    cfg: Config,
803    options: Options,
804    test_cases: Vec<(Arc<TestInfo>, TestCaseFactory)>,
805    reporters: Vec<Box<dyn Reporter + Send>>,
806}
807
808impl Runner {
809    /// Creates a new runner with the global tanu configuration.
810    ///
811    /// This loads the configuration from `tanu.toml` and sets up
812    /// default options. Use `with_config()` for custom configuration.
813    ///
814    /// # Examples
815    ///
816    /// ```rust,ignore
817    /// use tanu_core::Runner;
818    ///
819    /// let runner = Runner::new();
820    /// ```
821    pub fn new() -> Runner {
822        Runner::with_config(get_tanu_config().clone())
823    }
824
825    /// Creates a new runner with the specified configuration.
826    ///
827    /// This allows for custom configuration beyond what's in `tanu.toml`,
828    /// useful for testing or programmatic setup.
829    ///
830    /// # Examples
831    ///
832    /// ```rust,ignore
833    /// use tanu_core::{Runner, Config};
834    ///
835    /// let config = Config::default();
836    /// let runner = Runner::with_config(config);
837    /// ```
838    pub fn with_config(cfg: Config) -> Runner {
839        Runner {
840            cfg,
841            options: Options::default(),
842            test_cases: Vec::new(),
843            reporters: Vec::new(),
844        }
845    }
846
847    /// Enables HTTP request/response logging.
848    ///
849    /// When enabled, all HTTP requests made via tanu's HTTP client
850    /// will be logged and included in test reports. This is useful
851    /// for debugging API tests and understanding request/response flow.
852    ///
853    /// # Examples
854    ///
855    /// ```rust,ignore
856    /// let mut runner = Runner::new();
857    /// runner.capture_http();
858    /// ```
859    pub fn capture_http(&mut self) {
860        self.options.capture_http = CaptureHttpMode::All;
861    }
862
863    /// Sets the HTTP capture mode.
864    pub fn set_capture_http_mode(&mut self, mode: CaptureHttpMode) {
865        self.options.capture_http = mode;
866    }
867
868    /// Enables Rust logging output during test execution.
869    ///
870    /// This initializes the tracing subscriber to capture debug, info,
871    /// warn, and error logs from tests and the framework itself.
872    /// Useful for debugging test execution issues.
873    ///
874    /// # Examples
875    ///
876    /// ```rust,ignore
877    /// let mut runner = Runner::new();
878    /// runner.capture_rust();
879    /// ```
880    pub fn capture_rust(&mut self) {
881        self.options.capture_rust = true;
882    }
883
884    /// Configures the runner to close the event channel after test execution.
885    ///
886    /// By default, the event channel remains open for continued monitoring.
887    /// This option closes the channel when all tests complete, signaling
888    /// that no more events will be published.
889    ///
890    /// # Examples
891    ///
892    /// ```rust,ignore
893    /// let mut runner = Runner::new();
894    /// runner.terminate_channel();
895    /// ```
896    pub fn terminate_channel(&mut self) {
897        self.options.terminate_channel = true;
898    }
899
900    /// Adds a reporter for test output formatting.
901    ///
902    /// Reporters receive test events and format them for different output
903    /// destinations (console, files, etc.). Multiple reporters can be added
904    /// to generate multiple output formats simultaneously.
905    ///
906    /// # Examples
907    ///
908    /// ```rust,ignore
909    /// use tanu_core::{Runner, reporter::TableReporter};
910    ///
911    /// let mut runner = Runner::new();
912    /// runner.add_reporter(TableReporter::new());
913    /// ```
914    pub fn add_reporter(&mut self, reporter: impl Reporter + 'static + Send) {
915        self.reporters.push(Box::new(reporter));
916    }
917
918    /// Adds a boxed reporter for test output formatting.
919    ///
920    /// Similar to `add_reporter()` but accepts an already-boxed reporter.
921    /// Useful when working with dynamic reporter selection.
922    ///
923    /// # Examples
924    ///
925    /// ```rust,ignore
926    /// use tanu_core::{Runner, reporter::ListReporter};
927    ///
928    /// let mut runner = Runner::new();
929    /// let reporter: Box<dyn Reporter + Send> = Box::new(ListReporter::new());
930    /// runner.add_boxed_reporter(reporter);
931    /// ```
932    pub fn add_boxed_reporter(&mut self, reporter: Box<dyn Reporter + 'static + Send>) {
933        self.reporters.push(reporter);
934    }
935
936    /// Add a test case to the runner.
937    pub fn add_test(
938        &mut self,
939        name: &str,
940        module: &str,
941        serial_group: Option<&str>,
942        line: u32,
943        ordered: bool,
944        factory: TestCaseFactory,
945    ) {
946        self.test_cases.push((
947            Arc::new(TestInfo {
948                name: name.into(),
949                module: module.into(),
950                serial_group: serial_group.map(|s| s.to_string()),
951                line,
952                ordered,
953            }),
954            factory,
955        ));
956    }
957
958    /// Sets the maximum number of tests to run concurrently.
959    ///
960    /// By default, tests run with unlimited concurrency. This setting
961    /// allows you to limit concurrent execution to reduce resource usage
962    /// or avoid overwhelming external services.
963    ///
964    /// # Examples
965    ///
966    /// ```rust,ignore
967    /// let mut runner = Runner::new();
968    /// runner.set_concurrency(4); // Max 4 tests at once
969    /// ```
970    pub fn set_concurrency(&mut self, concurrency: usize) {
971        self.options.concurrency = Some(concurrency);
972    }
973
974    /// Disables sensitive data masking in HTTP logs.
975    ///
976    /// By default, sensitive data (Authorization headers, API keys in URLs, etc.)
977    /// is masked with `*****` when HTTP logging is enabled. Call this method
978    /// to show the actual values instead.
979    ///
980    /// # Examples
981    ///
982    /// ```rust,ignore
983    /// let mut runner = Runner::new();
984    /// runner.capture_http(); // Enable HTTP logging
985    /// runner.show_sensitive(); // Show actual values instead of *****
986    /// ```
987    pub fn show_sensitive(&mut self) {
988        self.options.mask_sensitive = false;
989    }
990
991    /// Enables fail-fast mode: abort test execution after the first failure.
992    pub fn set_fail_fast(&mut self, fail_fast: bool) {
993        self.options.fail_fast = fail_fast;
994    }
995
996    /// Executes all registered tests with optional filtering.
997    ///
998    /// Runs tests concurrently according to the configured options and filters.
999    /// Tests can be filtered by project name, module name, or specific test names.
1000    /// Empty filter arrays mean "include all".
1001    ///
1002    /// # Parameters
1003    ///
1004    /// - `project_names`: Only run tests from these projects (empty = all projects)
1005    /// - `module_names`: Only run tests from these modules (empty = all modules)  
1006    /// - `test_names`: Only run these specific tests (empty = all tests)
1007    ///
1008    /// # Examples
1009    ///
1010    /// ```rust,ignore
1011    /// let mut runner = Runner::new();
1012    ///
1013    /// // Run all tests
1014    /// runner.run(&[], &[], &[]).await?;
1015    ///
1016    /// // Run only "staging" project tests
1017    /// runner.run(&["staging".to_string()], &[], &[]).await?;
1018    ///
1019    /// // Run specific test
1020    /// runner.run(&[], &[], &["api::health_check".to_string()]).await?;
1021    /// ```
1022    ///
1023    /// # Errors
1024    ///
1025    /// Returns an error if:
1026    /// - Any test fails (unless configured to continue on failure)
1027    /// - A test panics and cannot be recovered
1028    /// - Reporter setup or execution fails
1029    /// - Event channel operations fail
1030    #[allow(clippy::too_many_lines)]
1031    pub async fn run(
1032        &mut self,
1033        project_names: &[String],
1034        module_names: &[String],
1035        test_names: &[String],
1036    ) -> eyre::Result<()> {
1037        // Set masking configuration for HTTP logs
1038        crate::masking::set_mask_sensitive(self.options.mask_sensitive);
1039
1040        if self.options.capture_rust {
1041            tracing_subscriber::fmt::init();
1042        }
1043
1044        let reporters = std::mem::take(&mut self.reporters);
1045
1046        // Set up barrier for all reporters + runner
1047        // This ensures all reporters subscribe before tests start
1048        setup_reporter_barrier(reporters.len())?;
1049
1050        let reporter_handles: Vec<_> = reporters
1051            .into_iter()
1052            .map(|mut reporter| tokio::spawn(async move { reporter.run().await }))
1053            .collect();
1054
1055        // Wait for all reporters to subscribe before starting tests
1056        wait_reporter_barrier().await;
1057
1058        let project_filter = ProjectFilter { project_names };
1059        let module_filter = ModuleFilter { module_names };
1060        let test_name_filter = TestNameFilter { test_names };
1061        let test_ignore_filter = TestIgnoreFilter::default();
1062
1063        let start = std::time::Instant::now();
1064        let fail_fast = self.options.fail_fast;
1065        let cancelled = Arc::new(AtomicBool::new(false));
1066        let handles: FuturesUnordered<_> = {
1067            // Create a semaphore to limit concurrency
1068            let concurrency = self.options.concurrency;
1069            let semaphore = Arc::new(tokio::sync::Semaphore::new(
1070                concurrency.unwrap_or(tokio::sync::Semaphore::MAX_PERMITS),
1071            ));
1072
1073            // Worker ID pool for timeline visualization (only when concurrency is specified)
1074            let worker_ids = Arc::new(WorkerIds::new(concurrency));
1075
1076            // Per-group mutexes for serial execution (project-scoped)
1077            // Key format: "project_name::group_name"
1078            let serial_groups: Arc<
1079                tokio::sync::RwLock<std::collections::HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
1080            > = Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new()));
1081
1082            let projects = self.cfg.projects.clone();
1083            let projects = if projects.is_empty() {
1084                vec![Arc::new(ProjectConfig {
1085                    name: "default".into(),
1086                    ..Default::default()
1087                })]
1088            } else {
1089                projects
1090            };
1091
1092            // Collect all tests and apply filters
1093            let mut all_tests: Vec<_> = self
1094                .test_cases
1095                .iter()
1096                .cartesian_product(projects.into_iter())
1097                .map(|((info, factory), project)| (project, Arc::clone(info), factory.clone()))
1098                .filter(move |(project, info, _)| test_name_filter.filter(project, info))
1099                .filter(move |(project, info, _)| module_filter.filter(project, info))
1100                .filter(move |(project, info, _)| project_filter.filter(project, info))
1101                .filter(move |(project, info, _)| test_ignore_filter.filter(project, info))
1102                .collect();
1103
1104            // Separate ordered and non-ordered tests
1105            let (mut ordered_tests, non_ordered_tests): (Vec<_>, Vec<_>) =
1106                all_tests.drain(..).partition(|(_, info, _)| info.ordered);
1107
1108            // Sort ordered tests by (serial_group, line) to ensure source order within groups
1109            ordered_tests
1110                .sort_by_key(|(_project, info, _factory)| (info.serial_group.clone(), info.line));
1111
1112            // Group ordered tests by (project_name, serial_group) for sequential execution
1113            let mut ordered_groups: std::collections::HashMap<String, Vec<_>> =
1114                std::collections::HashMap::new();
1115            for (project, info, factory) in ordered_tests {
1116                let key = format!(
1117                    "{}::{}",
1118                    project.name,
1119                    info.serial_group.as_deref().unwrap_or("")
1120                );
1121                ordered_groups
1122                    .entry(key)
1123                    .or_default()
1124                    .push((project, info, factory));
1125            }
1126
1127            // Create futures for ordered test groups (each group runs sequentially)
1128            let ordered_handles = ordered_groups.into_iter().map(|(group_key, tests)| {
1129                let semaphore = semaphore.clone();
1130                let worker_ids = worker_ids.clone();
1131                let serial_groups = serial_groups.clone();
1132                let cancelled = cancelled.clone();
1133
1134                tokio::spawn(async move {
1135                    // Get serial mutex for this group once
1136                    let serial_mutex = {
1137                        let mut write_lock = serial_groups.write().await;
1138                        write_lock
1139                            .entry(group_key.clone())
1140                            .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
1141                            .clone()
1142                    };
1143
1144                    // Run all tests in this group sequentially (await each before starting next)
1145                    let mut group_failed = false;
1146                    let mut group_error: Option<eyre::Report> = None;
1147                    for (project, info, factory) in tests {
1148                        if cancelled.load(Ordering::Relaxed) {
1149                            break;
1150                        }
1151
1152                        // Acquire semaphore for this test
1153                        let _permit = semaphore
1154                            .acquire()
1155                            .await
1156                            .map_err(|e| eyre::eyre!("failed to acquire semaphore: {e}"));
1157
1158                        if _permit.is_err() {
1159                            continue;
1160                        }
1161
1162                        // Acquire worker ID
1163                        let worker_id = worker_ids.acquire();
1164
1165                        let result = execute_test(
1166                            project,
1167                            info,
1168                            factory,
1169                            Some(serial_mutex.clone()),
1170                            worker_id,
1171                        )
1172                        .await;
1173                        worker_ids.release(worker_id);
1174
1175                        match result {
1176                            Ok(test) => {
1177                                if test.result.is_err() {
1178                                    group_failed = true;
1179                                }
1180                            }
1181                            Err(e) => {
1182                                group_failed = true;
1183                                if group_error.is_none() {
1184                                    group_error = Some(e);
1185                                }
1186                            }
1187                        }
1188                    }
1189                    if group_failed {
1190                        if let Some(e) = group_error {
1191                            return Err(e);
1192                        }
1193                        eyre::bail!("one or more tests failed");
1194                    }
1195                    eyre::Ok(())
1196                })
1197            });
1198
1199            // Create futures for non-ordered tests (parallel execution as before)
1200            let non_ordered_handles =
1201                non_ordered_tests
1202                    .into_iter()
1203                    .map(|(project, info, factory)| {
1204                        let semaphore = semaphore.clone();
1205                        let worker_ids = worker_ids.clone();
1206                        let serial_groups = serial_groups.clone();
1207                        let cancelled = cancelled.clone();
1208                        tokio::spawn(async move {
1209                            if cancelled.load(Ordering::Relaxed) {
1210                                return Ok(());
1211                            }
1212
1213                            // Step 1: Acquire serial group mutex FIRST (if needed) - project-scoped
1214                            // This ensures tests in the same group don't hold semaphore permits unnecessarily
1215                            let serial_mutex = match &info.serial_group {
1216                                Some(group_name) => {
1217                                    // Create project-scoped key: "project_name::group_name"
1218                                    let key = format!("{}::{}", project.name, group_name);
1219
1220                                    // Get or create mutex for this project+group
1221                                    let read_lock = serial_groups.read().await;
1222                                    if let Some(mutex) = read_lock.get(&key) {
1223                                        Some(Arc::clone(mutex))
1224                                    } else {
1225                                        drop(read_lock);
1226                                        let mut write_lock = serial_groups.write().await;
1227                                        Some(
1228                                            write_lock
1229                                                .entry(key)
1230                                                .or_insert_with(|| {
1231                                                    Arc::new(tokio::sync::Mutex::new(()))
1232                                                })
1233                                                .clone(),
1234                                        )
1235                                    }
1236                                }
1237                                None => None,
1238                            };
1239
1240                            // Step 2: Acquire global semaphore AFTER serial mutex
1241                            // This prevents blocking other tests while waiting for serial group
1242                            let _permit = semaphore
1243                                .acquire()
1244                                .await
1245                                .map_err(|e| eyre::eyre!("failed to acquire semaphore: {e}"))?;
1246
1247                            // Acquire worker ID from pool
1248                            let worker_id = worker_ids.acquire();
1249
1250                            let result = execute_test(
1251                                project,
1252                                info,
1253                                factory,
1254                                serial_mutex.clone(),
1255                                worker_id,
1256                            )
1257                            .await
1258                            .and_then(|test| {
1259                                let is_err = test.result.is_err();
1260                                eyre::ensure!(!is_err);
1261                                eyre::Ok(())
1262                            });
1263
1264                            // Return worker ID to pool
1265                            worker_ids.release(worker_id);
1266
1267                            result
1268                        })
1269                    });
1270
1271            // Combine both ordered and non-ordered handles
1272            let all_handles = FuturesUnordered::new();
1273            for handle in ordered_handles {
1274                all_handles.push(handle);
1275            }
1276            for handle in non_ordered_handles {
1277                all_handles.push(handle);
1278            }
1279            all_handles
1280        };
1281        let test_prep_time = start.elapsed();
1282        debug!(
1283            "created handles for {} test cases",
1284            test_prep_time.as_secs_f32()
1285        );
1286
1287        let mut has_any_error = false;
1288        let total_tests = handles.len();
1289        let options = self.options.clone();
1290        let runner = async move {
1291            let mut handles = handles;
1292            let mut failed_tests = 0;
1293            let mut processed_tests = 0;
1294
1295            while let Some(result) = handles.next().await {
1296                processed_tests += 1;
1297                match result {
1298                    Ok(res) => {
1299                        if let Err(e) = res {
1300                            debug!("test case failed: {e:#}");
1301                            has_any_error = true;
1302                            failed_tests += 1;
1303                            if fail_fast {
1304                                cancelled.store(true, Ordering::Relaxed);
1305                                break;
1306                            }
1307                        }
1308                    }
1309                    Err(e) => {
1310                        if e.is_panic() {
1311                            // Resume the panic on the main task
1312                            error!("{e}");
1313                            has_any_error = true;
1314                            failed_tests += 1;
1315                            if fail_fast {
1316                                cancelled.store(true, Ordering::Relaxed);
1317                                break;
1318                            }
1319                        }
1320                    }
1321                }
1322            }
1323
1324            if total_tests == 0 {
1325                console::Term::stdout().write_line("no test cases found")?;
1326            }
1327
1328            // Count remaining skipped tasks (when fail-fast triggered early exit)
1329            let skipped_tests = total_tests - processed_tests;
1330            let passed_tests = total_tests - failed_tests - skipped_tests;
1331            let total_time = start.elapsed();
1332
1333            // Publish summary event
1334            let summary = TestSummary {
1335                total_tests,
1336                passed_tests,
1337                failed_tests,
1338                skipped_tests,
1339                total_time,
1340                test_prep_time,
1341            };
1342
1343            // Create a dummy event for summary (since it doesn't belong to a specific test)
1344            let summary_event = Event {
1345                project: "".to_string(),
1346                module: "".to_string(),
1347                test: "".to_string(),
1348                body: EventBody::Summary(summary),
1349            };
1350
1351            if let Ok(guard) = CHANNEL.lock() {
1352                if let Some((tx, _)) = guard.as_ref() {
1353                    let _ = tx.send(summary_event);
1354                }
1355            }
1356            debug!("all test finished. sending stop signal to the background tasks.");
1357
1358            if options.terminate_channel {
1359                let Ok(mut guard) = CHANNEL.lock() else {
1360                    eyre::bail!("failed to acquire runner channel lock");
1361                };
1362                guard.take(); // closing the runner channel.
1363            }
1364
1365            if has_any_error {
1366                eyre::bail!("one or more tests failed");
1367            }
1368
1369            eyre::Ok(())
1370        };
1371
1372        let runner_result = runner.await;
1373
1374        for handle in reporter_handles {
1375            match handle.await {
1376                Ok(Ok(())) => {}
1377                Ok(Err(e)) => error!("reporter failed: {e:#}"),
1378                Err(e) => error!("reporter task panicked: {e:#}"),
1379            }
1380        }
1381
1382        // Clean up barrier
1383        clear_reporter_barrier();
1384
1385        debug!("runner stopped");
1386
1387        runner_result
1388    }
1389
1390    /// Returns a list of all registered test metadata.
1391    ///
1392    /// This provides access to test information without executing the tests.
1393    /// Useful for building test UIs, generating reports, or implementing
1394    /// custom filtering logic.
1395    ///
1396    /// # Examples
1397    ///
1398    /// ```rust,ignore
1399    /// let runner = Runner::new();
1400    /// let tests = runner.list();
1401    ///
1402    /// for test in tests {
1403    ///     println!("Test: {}", test.full_name());
1404    /// }
1405    /// ```
1406    pub fn list(&self) -> Vec<&TestInfo> {
1407        self.test_cases
1408            .iter()
1409            .map(|(meta, _test)| meta.as_ref())
1410            .collect::<Vec<_>>()
1411    }
1412}
1413
1414#[cfg(test)]
1415mod test {
1416    use super::*;
1417    use crate::config::RetryConfig;
1418    use crate::ProjectConfig;
1419    use std::sync::Arc;
1420
1421    fn create_config() -> Config {
1422        Config {
1423            projects: vec![Arc::new(ProjectConfig {
1424                name: "default".into(),
1425                ..Default::default()
1426            })],
1427            ..Default::default()
1428        }
1429    }
1430
1431    fn create_config_with_retry() -> Config {
1432        Config {
1433            projects: vec![Arc::new(ProjectConfig {
1434                name: "default".into(),
1435                retry: RetryConfig {
1436                    count: Some(1),
1437                    ..Default::default()
1438                },
1439                ..Default::default()
1440            })],
1441            ..Default::default()
1442        }
1443    }
1444
1445    #[tokio::test]
1446    async fn runner_fail_because_no_retry_configured() -> eyre::Result<()> {
1447        let mut server = mockito::Server::new_async().await;
1448        let m1 = server
1449            .mock("GET", "/")
1450            .with_status(500)
1451            .expect(1)
1452            .create_async()
1453            .await;
1454        let m2 = server
1455            .mock("GET", "/")
1456            .with_status(200)
1457            .expect(0)
1458            .create_async()
1459            .await;
1460
1461        let factory: TestCaseFactory = Arc::new(move || {
1462            let url = server.url();
1463            Box::pin(async move {
1464                let client = crate::http::Client::new();
1465                let res = client.get(&url).send().await?;
1466                if res.status().is_success() {
1467                    Ok(())
1468                } else {
1469                    eyre::bail!("request failed")
1470                }
1471            })
1472        });
1473
1474        let _runner_rx = subscribe()?;
1475        let mut runner = Runner::with_config(create_config());
1476        runner.add_test("retry_test", "module", None, 0, false, factory);
1477
1478        let result = runner.run(&[], &[], &[]).await;
1479        m1.assert_async().await;
1480        m2.assert_async().await;
1481
1482        assert!(result.is_err());
1483        Ok(())
1484    }
1485
1486    #[tokio::test]
1487    async fn runner_retry_successful_after_failure() -> eyre::Result<()> {
1488        let mut server = mockito::Server::new_async().await;
1489        let m1 = server
1490            .mock("GET", "/")
1491            .with_status(500)
1492            .expect(1)
1493            .create_async()
1494            .await;
1495        let m2 = server
1496            .mock("GET", "/")
1497            .with_status(200)
1498            .expect(1)
1499            .create_async()
1500            .await;
1501
1502        let factory: TestCaseFactory = Arc::new(move || {
1503            let url = server.url();
1504            Box::pin(async move {
1505                let client = crate::http::Client::new();
1506                let res = client.get(&url).send().await?;
1507                if res.status().is_success() {
1508                    Ok(())
1509                } else {
1510                    eyre::bail!("request failed")
1511                }
1512            })
1513        });
1514
1515        let _runner_rx = subscribe()?;
1516        let mut runner = Runner::with_config(create_config_with_retry());
1517        runner.add_test("retry_test", "module", None, 0, false, factory);
1518
1519        let result = runner.run(&[], &[], &[]).await;
1520        m1.assert_async().await;
1521        m2.assert_async().await;
1522
1523        assert!(result.is_ok());
1524
1525        Ok(())
1526    }
1527
1528    #[tokio::test]
1529    async fn spawned_task_panics_without_task_local_context() {
1530        let project = Arc::new(ProjectConfig {
1531            name: "default".to_string(),
1532            ..Default::default()
1533        });
1534        let test_info = Arc::new(TestInfo {
1535            module: "mod".to_string(),
1536            name: "test".to_string(),
1537            serial_group: None,
1538            line: 0,
1539            ordered: false,
1540        });
1541
1542        crate::config::PROJECT
1543            .scope(
1544                project,
1545                TEST_INFO.scope(test_info, async move {
1546                    let handle = tokio::spawn(async move {
1547                        let _ = crate::config::get_config();
1548                    });
1549
1550                    let join_err = handle.await.expect_err("spawned task should panic");
1551                    assert!(join_err.is_panic());
1552                }),
1553            )
1554            .await;
1555    }
1556
1557    #[tokio::test]
1558    async fn scope_current_propagates_task_local_context_into_spawned_task() {
1559        let project = Arc::new(ProjectConfig {
1560            name: "default".to_string(),
1561            ..Default::default()
1562        });
1563        let test_info = Arc::new(TestInfo {
1564            module: "mod".to_string(),
1565            name: "test".to_string(),
1566            serial_group: None,
1567            line: 0,
1568            ordered: false,
1569        });
1570
1571        crate::config::PROJECT
1572            .scope(
1573                project,
1574                TEST_INFO.scope(test_info, async move {
1575                    let handle = tokio::spawn(super::scope_current(async move {
1576                        let _ = crate::config::get_config();
1577                        let _ = super::get_test_info();
1578                    }));
1579
1580                    handle.await.expect("spawned task should not panic");
1581                }),
1582            )
1583            .await;
1584    }
1585
1586    #[tokio::test]
1587    #[serial_test::serial]
1588    async fn masking_masks_sensitive_query_params_in_http_logs() -> eyre::Result<()> {
1589        use crate::masking;
1590
1591        // Ensure masking is enabled
1592        masking::set_mask_sensitive(true);
1593
1594        let mut server = mockito::Server::new_async().await;
1595        let _mock = server
1596            .mock("GET", mockito::Matcher::Any)
1597            .with_status(200)
1598            .create_async()
1599            .await;
1600
1601        let factory: TestCaseFactory = Arc::new(move || {
1602            let url = server.url();
1603            Box::pin(async move {
1604                let client = crate::http::Client::new();
1605                // Make request with sensitive query param embedded in URL
1606                let _res = client
1607                    .get(format!("{url}?access_token=secret_token_123&user=john"))
1608                    .send()
1609                    .await?;
1610                Ok(())
1611            })
1612        });
1613
1614        let mut rx = subscribe()?;
1615        let mut runner = Runner::with_config(create_config());
1616        runner.add_test(
1617            "masking_query_test",
1618            "masking_module",
1619            None,
1620            0,
1621            false,
1622            factory,
1623        );
1624
1625        runner.run(&[], &[], &[]).await?;
1626
1627        // Collect HTTP events for this specific test
1628        let mut found_http_event = false;
1629        while let Ok(event) = rx.try_recv() {
1630            // Filter to only our test's events
1631            if event.test != "masking_query_test" {
1632                continue;
1633            }
1634            if let EventBody::Call(CallLog::Http(log)) = event.body {
1635                found_http_event = true;
1636                let url_str = log.request.url.to_string();
1637
1638                // Verify sensitive param is masked
1639                assert!(
1640                    url_str.contains("access_token=*****"),
1641                    "access_token should be masked, got: {url_str}"
1642                );
1643                // Non-sensitive params should not be masked
1644                assert!(
1645                    url_str.contains("user=john"),
1646                    "user should not be masked, got: {url_str}"
1647                );
1648            }
1649        }
1650
1651        assert!(found_http_event, "Should have received HTTP event");
1652        Ok(())
1653    }
1654
1655    #[tokio::test]
1656    #[serial_test::serial]
1657    async fn masking_masks_sensitive_headers_in_http_logs() -> eyre::Result<()> {
1658        use crate::masking;
1659
1660        // Ensure masking is enabled
1661        masking::set_mask_sensitive(true);
1662
1663        let mut server = mockito::Server::new_async().await;
1664        let _mock = server
1665            .mock("GET", "/")
1666            .with_status(200)
1667            .create_async()
1668            .await;
1669
1670        let factory: TestCaseFactory = Arc::new(move || {
1671            let url = server.url();
1672            Box::pin(async move {
1673                let client = crate::http::Client::new();
1674                // Make request with sensitive headers
1675                let _res = client
1676                    .get(&url)
1677                    .header("authorization", "Bearer secret_bearer_token")
1678                    .header("x-api-key", "my_secret_api_key")
1679                    .header("content-type", "application/json")
1680                    .send()
1681                    .await?;
1682                Ok(())
1683            })
1684        });
1685
1686        let mut rx = subscribe()?;
1687        let mut runner = Runner::with_config(create_config());
1688        runner.add_test(
1689            "masking_headers_test",
1690            "masking_module",
1691            None,
1692            0,
1693            false,
1694            factory,
1695        );
1696
1697        runner.run(&[], &[], &[]).await?;
1698
1699        // Collect HTTP events for this specific test
1700        let mut found_http_event = false;
1701        while let Ok(event) = rx.try_recv() {
1702            // Filter to only our test's events
1703            if event.test != "masking_headers_test" {
1704                continue;
1705            }
1706            if let EventBody::Call(CallLog::Http(log)) = event.body {
1707                found_http_event = true;
1708
1709                // Verify sensitive headers are masked
1710                if let Some(auth) = log.request.headers.get("authorization") {
1711                    assert_eq!(
1712                        auth.to_str().unwrap(),
1713                        "*****",
1714                        "authorization header should be masked"
1715                    );
1716                }
1717                if let Some(api_key) = log.request.headers.get("x-api-key") {
1718                    assert_eq!(
1719                        api_key.to_str().unwrap(),
1720                        "*****",
1721                        "x-api-key header should be masked"
1722                    );
1723                }
1724                // Non-sensitive headers should not be masked
1725                if let Some(content_type) = log.request.headers.get("content-type") {
1726                    assert_eq!(
1727                        content_type.to_str().unwrap(),
1728                        "application/json",
1729                        "content-type header should not be masked"
1730                    );
1731                }
1732            }
1733        }
1734
1735        assert!(found_http_event, "Should have received HTTP event");
1736        Ok(())
1737    }
1738
1739    #[tokio::test]
1740    #[serial_test::serial]
1741    async fn masking_show_sensitive_disables_masking_in_http_logs() -> eyre::Result<()> {
1742        use crate::masking;
1743
1744        masking::set_mask_sensitive(true);
1745
1746        let mut server = mockito::Server::new_async().await;
1747        let _mock = server
1748            .mock("GET", "/")
1749            .with_status(200)
1750            .create_async()
1751            .await;
1752
1753        let factory: TestCaseFactory = Arc::new(move || {
1754            let url = server.url();
1755            Box::pin(async move {
1756                let client = crate::http::Client::new();
1757                let _res = client
1758                    .get(format!("{url}?access_token=secret_token_123"))
1759                    .header("authorization", "Bearer secret_bearer_token")
1760                    .send()
1761                    .await?;
1762                Ok(())
1763            })
1764        });
1765
1766        let mut rx = subscribe()?;
1767        let mut runner = Runner::with_config(create_config());
1768        runner.capture_http();
1769        runner.show_sensitive();
1770        runner.add_test(
1771            "show_sensitive_test",
1772            "masking_module",
1773            None,
1774            0,
1775            false,
1776            factory,
1777        );
1778
1779        runner.run(&[], &[], &[]).await?;
1780
1781        let mut found_http_event = false;
1782        while let Ok(event) = rx.try_recv() {
1783            if event.test != "show_sensitive_test" {
1784                continue;
1785            }
1786            if let EventBody::Call(CallLog::Http(log)) = event.body {
1787                found_http_event = true;
1788                let url_str = log.request.url.to_string();
1789                assert!(
1790                    url_str.contains("access_token=secret_token_123"),
1791                    "access_token should not be masked when show_sensitive is enabled"
1792                );
1793                if let Some(auth) = log.request.headers.get("authorization") {
1794                    assert_eq!(
1795                        auth.to_str().unwrap(),
1796                        "Bearer secret_bearer_token",
1797                        "authorization header should not be masked when show_sensitive is enabled"
1798                    );
1799                }
1800            }
1801        }
1802
1803        assert!(found_http_event, "Should have received HTTP event");
1804        Ok(())
1805    }
1806
1807    fn passing_factory() -> TestCaseFactory {
1808        Arc::new(|| Box::pin(async { Ok(()) }))
1809    }
1810
1811    fn failing_factory() -> TestCaseFactory {
1812        Arc::new(|| Box::pin(async { eyre::bail!("intentional failure") }))
1813    }
1814
1815    #[tokio::test]
1816    #[serial_test::serial]
1817    async fn runner_fail_fast_skips_remaining_tests() -> eyre::Result<()> {
1818        let mut rx = subscribe()?;
1819        let mut runner = Runner::with_config(create_config());
1820        runner.set_concurrency(1);
1821        runner.set_fail_fast(true);
1822
1823        // Failing test added first so it is spawned first and runs first
1824        // under the single-threaded #[tokio::test] runtime.
1825        runner.add_test("ff_fail", "module", None, 0, false, failing_factory());
1826        runner.add_test("ff_pass1", "module", None, 1, false, passing_factory());
1827        runner.add_test("ff_pass2", "module", None, 2, false, passing_factory());
1828
1829        let result = runner.run(&[], &[], &[]).await;
1830        assert!(result.is_err());
1831
1832        let mut summary = None;
1833        while let Ok(event) = rx.try_recv() {
1834            if let EventBody::Summary(s) = event.body {
1835                summary = Some(s);
1836            }
1837        }
1838
1839        let summary = summary.expect("should have received Summary event");
1840        assert!(
1841            summary.failed_tests >= 1,
1842            "should have at least one failure"
1843        );
1844        assert!(
1845            summary.skipped_tests >= 1,
1846            "fail-fast should have skipped remaining tests"
1847        );
1848
1849        Ok(())
1850    }
1851
1852    #[tokio::test]
1853    #[serial_test::serial]
1854    async fn runner_without_fail_fast_runs_all_tests() -> eyre::Result<()> {
1855        let mut rx = subscribe()?;
1856        let mut runner = Runner::with_config(create_config());
1857        runner.set_concurrency(1);
1858        // fail_fast is false by default
1859
1860        runner.add_test("noff_fail", "module", None, 0, false, failing_factory());
1861        runner.add_test("noff_pass1", "module", None, 1, false, passing_factory());
1862        runner.add_test("noff_pass2", "module", None, 2, false, passing_factory());
1863
1864        let result = runner.run(&[], &[], &[]).await;
1865        assert!(result.is_err());
1866
1867        let mut summary = None;
1868        while let Ok(event) = rx.try_recv() {
1869            if let EventBody::Summary(s) = event.body {
1870                summary = Some(s);
1871            }
1872        }
1873
1874        let summary = summary.expect("should have received Summary event");
1875        assert_eq!(summary.failed_tests, 1, "should have exactly one failure");
1876        assert_eq!(summary.passed_tests, 2, "should have two passed tests");
1877        assert_eq!(summary.skipped_tests, 0, "should have no skipped tests");
1878
1879        Ok(())
1880    }
1881
1882    // Verify that HTTP Call events are published to the channel regardless of
1883    // the capture_http mode (the HTTP client always publishes; the reporter
1884    // decides what to display).
1885    #[tokio::test]
1886    #[serial_test::serial]
1887    async fn capture_http_events_published_for_all_tests_regardless_of_mode() -> eyre::Result<()> {
1888        let mut server = mockito::Server::new_async().await;
1889        let _mock = server
1890            .mock("GET", mockito::Matcher::Any)
1891            .with_status(200)
1892            .create_async()
1893            .await;
1894
1895        let make_http_factory = |url: String| -> TestCaseFactory {
1896            Arc::new(move || {
1897                let url = url.clone();
1898                Box::pin(async move {
1899                    let client = crate::http::Client::new();
1900                    client.get(&url).send().await?;
1901                    Ok(())
1902                })
1903            })
1904        };
1905
1906        let failing_http_factory = |url: String| -> TestCaseFactory {
1907            Arc::new(move || {
1908                let url = url.clone();
1909                Box::pin(async move {
1910                    let client = crate::http::Client::new();
1911                    client.get(&url).send().await?;
1912                    eyre::bail!("intentional failure after http call");
1913                })
1914            })
1915        };
1916
1917        let url = server.url();
1918
1919        // Run with OnFailure mode
1920        let mut rx = subscribe()?;
1921        let mut runner = Runner::with_config(create_config());
1922        runner.set_capture_http_mode(CaptureHttpMode::OnFailure);
1923        runner.add_test(
1924            "ch_pass",
1925            "ch_module",
1926            None,
1927            0,
1928            false,
1929            make_http_factory(url.clone()),
1930        );
1931        runner.add_test(
1932            "ch_fail",
1933            "ch_module",
1934            None,
1935            1,
1936            false,
1937            failing_http_factory(url.clone()),
1938        );
1939
1940        let _ = runner.run(&[], &[], &[]).await;
1941
1942        // Both tests should have published Call events — capture mode only
1943        // affects reporter display, not event publishing.
1944        let mut pass_has_call = false;
1945        let mut fail_has_call = false;
1946        while let Ok(event) = rx.try_recv() {
1947            if let EventBody::Call(CallLog::Http(_)) = &event.body {
1948                match event.test.as_str() {
1949                    "ch_pass" => pass_has_call = true,
1950                    "ch_fail" => fail_has_call = true,
1951                    _ => {}
1952                }
1953            }
1954        }
1955
1956        assert!(
1957            pass_has_call,
1958            "passing test should still publish HTTP Call event"
1959        );
1960        assert!(
1961            fail_has_call,
1962            "failing test should still publish HTTP Call event"
1963        );
1964
1965        Ok(())
1966    }
1967
1968    #[test]
1969    fn set_capture_http_mode_stores_mode() {
1970        let mut runner = Runner::new();
1971        assert_eq!(runner.options.capture_http, CaptureHttpMode::Off);
1972
1973        runner.capture_http();
1974        assert_eq!(runner.options.capture_http, CaptureHttpMode::All);
1975
1976        runner.set_capture_http_mode(CaptureHttpMode::OnFailure);
1977        assert_eq!(runner.options.capture_http, CaptureHttpMode::OnFailure);
1978
1979        runner.set_capture_http_mode(CaptureHttpMode::Off);
1980        assert_eq!(runner.options.capture_http, CaptureHttpMode::Off);
1981    }
1982}