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}