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::{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
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/// - `Http`: HTTP 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#[derive(Debug, Clone)]
401pub enum EventBody {
402 Start,
403 Check(Box<Check>),
404 Http(Box<http::Log>),
405 Retry(Test),
406 End(Test),
407 Summary(TestSummary),
408}
409
410impl From<EventBody> for Event {
411 fn from(body: EventBody) -> Self {
412 let project = crate::config::get_config();
413 let test_info = crate::runner::get_test_info();
414 Event {
415 project: project.name.clone(),
416 module: test_info.module.clone(),
417 test: test_info.name.clone(),
418 body,
419 }
420 }
421}
422
423/// Final test execution result.
424///
425/// Contains the complete outcome of a test execution including metadata,
426/// execution time, and the final result (success or specific error type).
427/// This is published in the `End` event when a test completes.
428#[derive(Debug, Clone)]
429pub struct Test {
430 pub info: Arc<TestInfo>,
431 pub worker_id: isize,
432 pub started_at: SystemTime,
433 pub ended_at: SystemTime,
434 pub request_time: Duration,
435 pub result: Result<(), Error>,
436}
437
438/// Overall test execution summary.
439///
440/// Contains aggregate information about the entire test run including
441/// total counts, timing, and success/failure statistics.
442/// This is published in the `Summary` event when all tests complete.
443#[derive(Debug, Clone)]
444pub struct TestSummary {
445 pub total_tests: usize,
446 pub passed_tests: usize,
447 pub failed_tests: usize,
448 pub total_time: Duration,
449 pub test_prep_time: Duration,
450}
451
452/// Test metadata and identification.
453///
454/// Contains the module and test name for a test case. This information
455/// is used for test filtering, reporting, and event context throughout
456/// the test execution pipeline.
457#[derive(Debug, Clone, Default)]
458pub struct TestInfo {
459 pub module: String,
460 pub name: String,
461 pub serial_group: Option<String>,
462 pub line: u32,
463 pub ordered: bool,
464}
465
466impl TestInfo {
467 /// Full test name including module
468 pub fn full_name(&self) -> String {
469 format!("{}::{}", self.module, self.name)
470 }
471
472 /// Unique test name including project and module names
473 pub fn unique_name(&self, project: &str) -> String {
474 format!("{project}::{}::{}", self.module, self.name)
475 }
476}
477
478/// Pool of reusable worker IDs for timeline visualization.
479///
480/// Worker IDs are assigned to tests when they start executing and returned
481/// to the pool when they complete. This allows timeline visualization tools
482/// to display tests in lanes based on which worker executed them.
483#[derive(Debug)]
484pub struct WorkerIds {
485 enabled: bool,
486 ids: Mutex<Vec<isize>>,
487}
488
489impl WorkerIds {
490 /// Creates a new worker ID pool with IDs from 0 to concurrency-1.
491 ///
492 /// If `concurrency` is `None`, the pool is disabled and `acquire()` always returns -1.
493 pub fn new(concurrency: Option<usize>) -> Self {
494 match concurrency {
495 Some(c) => Self {
496 enabled: true,
497 ids: Mutex::new((0..c as isize).collect()),
498 },
499 None => Self {
500 enabled: false,
501 ids: Mutex::new(Vec::new()),
502 },
503 }
504 }
505
506 /// Acquires a worker ID from the pool.
507 ///
508 /// Returns -1 if the pool is disabled, empty, or the mutex is poisoned.
509 pub fn acquire(&self) -> isize {
510 if !self.enabled {
511 return -1;
512 }
513 self.ids
514 .lock()
515 .ok()
516 .and_then(|mut guard| guard.pop())
517 .unwrap_or(-1)
518 }
519
520 /// Returns a worker ID to the pool.
521 ///
522 /// Does nothing if the pool is disabled, the mutex is poisoned, or id is negative.
523 pub fn release(&self, id: isize) {
524 if !self.enabled || id < 0 {
525 return;
526 }
527 if let Ok(mut guard) = self.ids.lock() {
528 guard.push(id);
529 }
530 }
531}
532
533type TestCaseFactory = Arc<
534 dyn Fn() -> Pin<Box<dyn futures::Future<Output = eyre::Result<()>> + Send + 'static>>
535 + Sync
536 + Send
537 + 'static,
538>;
539
540/// Configuration options for test runner behavior.
541///
542/// Controls various aspects of test execution including logging,
543/// concurrency, and channel management. These options can be set
544/// via the builder pattern on the `Runner`.
545///
546/// # Examples
547///
548/// ```rust,ignore
549/// use tanu_core::Runner;
550///
551/// let mut runner = Runner::new();
552/// runner.capture_http(); // Enable HTTP logging
553/// runner.set_concurrency(4); // Limit to 4 concurrent tests
554/// ```
555#[derive(Debug, Clone)]
556pub struct Options {
557 pub debug: bool,
558 pub capture_http: bool,
559 pub capture_rust: bool,
560 pub terminate_channel: bool,
561 pub concurrency: Option<usize>,
562 /// Whether to mask sensitive data (API keys, tokens) in HTTP logs.
563 /// Defaults to `true` (masked). Set to `false` with `--show-sensitive` flag.
564 pub mask_sensitive: bool,
565}
566
567impl Default for Options {
568 fn default() -> Self {
569 Self {
570 debug: false,
571 capture_http: false,
572 capture_rust: false,
573 terminate_channel: false,
574 concurrency: None,
575 mask_sensitive: true, // Masked by default for security
576 }
577 }
578}
579
580/// Trait for filtering test cases during execution.
581///
582/// Filters allow selective test execution based on project configuration
583/// and test metadata. Multiple filters can be applied simultaneously,
584/// and a test must pass all filters to be executed.
585///
586/// # Examples
587///
588/// ```rust,ignore
589/// use tanu_core::runner::{Filter, TestInfo, ProjectConfig};
590///
591/// struct CustomFilter;
592///
593/// impl Filter for CustomFilter {
594/// fn filter(&self, project: &ProjectConfig, info: &TestInfo) -> bool {
595/// // Only run tests with "integration" in the name
596/// info.name.contains("integration")
597/// }
598/// }
599/// ```
600pub trait Filter {
601 fn filter(&self, project: &ProjectConfig, info: &TestInfo) -> bool;
602}
603
604/// Filters tests to only run from specified projects.
605///
606/// When project names are provided, only tests from those projects
607/// will be executed. If the list is empty, all projects are included.
608///
609/// # Examples
610///
611/// ```rust,ignore
612/// use tanu_core::runner::ProjectFilter;
613///
614/// let filter = ProjectFilter { project_names: &["staging".to_string()] };
615/// // Only tests from "staging" project will run
616/// ```
617pub struct ProjectFilter<'a> {
618 project_names: &'a [String],
619}
620
621impl Filter for ProjectFilter<'_> {
622 fn filter(&self, project: &ProjectConfig, _info: &TestInfo) -> bool {
623 if self.project_names.is_empty() {
624 return true;
625 }
626
627 self.project_names
628 .iter()
629 .any(|project_name| &project.name == project_name)
630 }
631}
632
633/// Filters tests to only run from specified modules.
634///
635/// When module names are provided, only tests from those modules
636/// will be executed. If the list is empty, all modules are included.
637/// Module names correspond to Rust module paths.
638///
639/// # Examples
640///
641/// ```rust,ignore
642/// use tanu_core::runner::ModuleFilter;
643///
644/// let filter = ModuleFilter { module_names: &["api".to_string(), "auth".to_string()] };
645/// // Only tests from "api" and "auth" modules will run
646/// ```
647pub struct ModuleFilter<'a> {
648 module_names: &'a [String],
649}
650
651impl Filter for ModuleFilter<'_> {
652 fn filter(&self, _project: &ProjectConfig, info: &TestInfo) -> bool {
653 if self.module_names.is_empty() {
654 return true;
655 }
656
657 self.module_names
658 .iter()
659 .any(|module_name| &info.module == module_name)
660 }
661}
662
663/// Filters tests to only run specific named tests.
664///
665/// When test names are provided, only those exact tests will be executed.
666/// Test names should include the module (e.g., "api::health_check").
667/// If the list is empty, all tests are included.
668///
669/// # Examples
670///
671/// ```rust,ignore
672/// use tanu_core::runner::TestNameFilter;
673///
674/// let filter = TestNameFilter {
675/// test_names: &["api::health_check".to_string(), "auth::login".to_string()]
676/// };
677/// // Only the specified tests will run
678/// ```
679pub struct TestNameFilter<'a> {
680 test_names: &'a [String],
681}
682
683impl Filter for TestNameFilter<'_> {
684 fn filter(&self, _project: &ProjectConfig, info: &TestInfo) -> bool {
685 if self.test_names.is_empty() {
686 return true;
687 }
688
689 self.test_names
690 .iter()
691 .any(|test_name| &info.full_name() == test_name)
692 }
693}
694
695/// Filters out tests that are configured to be ignored.
696///
697/// This filter reads the `test_ignore` configuration from each project
698/// and excludes those tests from execution. Tests are matched by their
699/// full name (module::test_name).
700///
701/// # Configuration
702///
703/// In `tanu.toml`:
704/// ```toml
705/// [[projects]]
706/// name = "staging"
707/// test_ignore = ["flaky_test", "slow_integration_test"]
708/// ```
709///
710/// # Examples
711///
712/// ```rust,ignore
713/// use tanu_core::runner::TestIgnoreFilter;
714///
715/// let filter = TestIgnoreFilter::default();
716/// // Tests listed in test_ignore config will be skipped
717/// ```
718pub struct TestIgnoreFilter {
719 test_ignores: HashMap<String, Vec<String>>,
720}
721
722impl Default for TestIgnoreFilter {
723 fn default() -> TestIgnoreFilter {
724 TestIgnoreFilter {
725 test_ignores: get_tanu_config()
726 .projects
727 .iter()
728 .map(|proj| (proj.name.clone(), proj.test_ignore.clone()))
729 .collect(),
730 }
731 }
732}
733
734impl Filter for TestIgnoreFilter {
735 fn filter(&self, project: &ProjectConfig, info: &TestInfo) -> bool {
736 let Some(test_ignore) = self.test_ignores.get(&project.name) else {
737 return true;
738 };
739
740 test_ignore
741 .iter()
742 .all(|test_name| &info.full_name() != test_name)
743 }
744}
745
746/// The main test execution engine for tanu.
747///
748/// `Runner` is responsible for orchestrating the entire test execution pipeline:
749/// test discovery, filtering, concurrent execution, retry handling, event publishing,
750/// and result reporting. It supports multiple projects, configurable concurrency,
751/// and pluggable reporters.
752///
753/// # Features
754///
755/// - **Concurrent Execution**: Tests run in parallel with configurable limits
756/// - **Retry Logic**: Automatic retry with exponential backoff for flaky tests
757/// - **Event System**: Real-time event publishing for UI integration
758/// - **Filtering**: Filter tests by project, module, or test name
759/// - **Reporting**: Support for multiple output formats via reporters
760/// - **HTTP Logging**: Capture and log all HTTP requests/responses
761///
762/// # Examples
763///
764/// ```rust,ignore
765/// use tanu_core::{Runner, reporter::TableReporter};
766///
767/// let mut runner = Runner::new();
768/// runner.capture_http();
769/// runner.set_concurrency(8);
770/// runner.add_reporter(TableReporter::new());
771///
772/// // Add tests (typically done by procedural macros)
773/// runner.add_test("health_check", "api", None, test_factory);
774///
775/// // Run all tests
776/// runner.run(&[], &[], &[]).await?;
777/// ```
778///
779/// # Architecture
780///
781/// Tests are executed in separate tokio tasks with:
782/// - Project-scoped configuration
783/// - Test-scoped context for event publishing
784/// - Semaphore-based concurrency control
785/// - Panic recovery and error handling
786/// - Automatic retry with configurable backoff
787#[derive(Default)]
788pub struct Runner {
789 cfg: Config,
790 options: Options,
791 test_cases: Vec<(Arc<TestInfo>, TestCaseFactory)>,
792 reporters: Vec<Box<dyn Reporter + Send>>,
793}
794
795impl Runner {
796 /// Creates a new runner with the global tanu configuration.
797 ///
798 /// This loads the configuration from `tanu.toml` and sets up
799 /// default options. Use `with_config()` for custom configuration.
800 ///
801 /// # Examples
802 ///
803 /// ```rust,ignore
804 /// use tanu_core::Runner;
805 ///
806 /// let runner = Runner::new();
807 /// ```
808 pub fn new() -> Runner {
809 Runner::with_config(get_tanu_config().clone())
810 }
811
812 /// Creates a new runner with the specified configuration.
813 ///
814 /// This allows for custom configuration beyond what's in `tanu.toml`,
815 /// useful for testing or programmatic setup.
816 ///
817 /// # Examples
818 ///
819 /// ```rust,ignore
820 /// use tanu_core::{Runner, Config};
821 ///
822 /// let config = Config::default();
823 /// let runner = Runner::with_config(config);
824 /// ```
825 pub fn with_config(cfg: Config) -> Runner {
826 Runner {
827 cfg,
828 options: Options::default(),
829 test_cases: Vec::new(),
830 reporters: Vec::new(),
831 }
832 }
833
834 /// Enables HTTP request/response logging.
835 ///
836 /// When enabled, all HTTP requests made via tanu's HTTP client
837 /// will be logged and included in test reports. This is useful
838 /// for debugging API tests and understanding request/response flow.
839 ///
840 /// # Examples
841 ///
842 /// ```rust,ignore
843 /// let mut runner = Runner::new();
844 /// runner.capture_http();
845 /// ```
846 pub fn capture_http(&mut self) {
847 self.options.capture_http = true;
848 }
849
850 /// Enables Rust logging output during test execution.
851 ///
852 /// This initializes the tracing subscriber to capture debug, info,
853 /// warn, and error logs from tests and the framework itself.
854 /// Useful for debugging test execution issues.
855 ///
856 /// # Examples
857 ///
858 /// ```rust,ignore
859 /// let mut runner = Runner::new();
860 /// runner.capture_rust();
861 /// ```
862 pub fn capture_rust(&mut self) {
863 self.options.capture_rust = true;
864 }
865
866 /// Configures the runner to close the event channel after test execution.
867 ///
868 /// By default, the event channel remains open for continued monitoring.
869 /// This option closes the channel when all tests complete, signaling
870 /// that no more events will be published.
871 ///
872 /// # Examples
873 ///
874 /// ```rust,ignore
875 /// let mut runner = Runner::new();
876 /// runner.terminate_channel();
877 /// ```
878 pub fn terminate_channel(&mut self) {
879 self.options.terminate_channel = true;
880 }
881
882 /// Adds a reporter for test output formatting.
883 ///
884 /// Reporters receive test events and format them for different output
885 /// destinations (console, files, etc.). Multiple reporters can be added
886 /// to generate multiple output formats simultaneously.
887 ///
888 /// # Examples
889 ///
890 /// ```rust,ignore
891 /// use tanu_core::{Runner, reporter::TableReporter};
892 ///
893 /// let mut runner = Runner::new();
894 /// runner.add_reporter(TableReporter::new());
895 /// ```
896 pub fn add_reporter(&mut self, reporter: impl Reporter + 'static + Send) {
897 self.reporters.push(Box::new(reporter));
898 }
899
900 /// Adds a boxed reporter for test output formatting.
901 ///
902 /// Similar to `add_reporter()` but accepts an already-boxed reporter.
903 /// Useful when working with dynamic reporter selection.
904 ///
905 /// # Examples
906 ///
907 /// ```rust,ignore
908 /// use tanu_core::{Runner, reporter::ListReporter};
909 ///
910 /// let mut runner = Runner::new();
911 /// let reporter: Box<dyn Reporter + Send> = Box::new(ListReporter::new());
912 /// runner.add_boxed_reporter(reporter);
913 /// ```
914 pub fn add_boxed_reporter(&mut self, reporter: Box<dyn Reporter + 'static + Send>) {
915 self.reporters.push(reporter);
916 }
917
918 /// Add a test case to the runner.
919 pub fn add_test(
920 &mut self,
921 name: &str,
922 module: &str,
923 serial_group: Option<&str>,
924 line: u32,
925 ordered: bool,
926 factory: TestCaseFactory,
927 ) {
928 self.test_cases.push((
929 Arc::new(TestInfo {
930 name: name.into(),
931 module: module.into(),
932 serial_group: serial_group.map(|s| s.to_string()),
933 line,
934 ordered,
935 }),
936 factory,
937 ));
938 }
939
940 /// Sets the maximum number of tests to run concurrently.
941 ///
942 /// By default, tests run with unlimited concurrency. This setting
943 /// allows you to limit concurrent execution to reduce resource usage
944 /// or avoid overwhelming external services.
945 ///
946 /// # Examples
947 ///
948 /// ```rust,ignore
949 /// let mut runner = Runner::new();
950 /// runner.set_concurrency(4); // Max 4 tests at once
951 /// ```
952 pub fn set_concurrency(&mut self, concurrency: usize) {
953 self.options.concurrency = Some(concurrency);
954 }
955
956 /// Disables sensitive data masking in HTTP logs.
957 ///
958 /// By default, sensitive data (Authorization headers, API keys in URLs, etc.)
959 /// is masked with `*****` when HTTP logging is enabled. Call this method
960 /// to show the actual values instead.
961 ///
962 /// # Examples
963 ///
964 /// ```rust,ignore
965 /// let mut runner = Runner::new();
966 /// runner.capture_http(); // Enable HTTP logging
967 /// runner.show_sensitive(); // Show actual values instead of *****
968 /// ```
969 pub fn show_sensitive(&mut self) {
970 self.options.mask_sensitive = false;
971 }
972
973 /// Executes all registered tests with optional filtering.
974 ///
975 /// Runs tests concurrently according to the configured options and filters.
976 /// Tests can be filtered by project name, module name, or specific test names.
977 /// Empty filter arrays mean "include all".
978 ///
979 /// # Parameters
980 ///
981 /// - `project_names`: Only run tests from these projects (empty = all projects)
982 /// - `module_names`: Only run tests from these modules (empty = all modules)
983 /// - `test_names`: Only run these specific tests (empty = all tests)
984 ///
985 /// # Examples
986 ///
987 /// ```rust,ignore
988 /// let mut runner = Runner::new();
989 ///
990 /// // Run all tests
991 /// runner.run(&[], &[], &[]).await?;
992 ///
993 /// // Run only "staging" project tests
994 /// runner.run(&["staging".to_string()], &[], &[]).await?;
995 ///
996 /// // Run specific test
997 /// runner.run(&[], &[], &["api::health_check".to_string()]).await?;
998 /// ```
999 ///
1000 /// # Errors
1001 ///
1002 /// Returns an error if:
1003 /// - Any test fails (unless configured to continue on failure)
1004 /// - A test panics and cannot be recovered
1005 /// - Reporter setup or execution fails
1006 /// - Event channel operations fail
1007 #[allow(clippy::too_many_lines)]
1008 pub async fn run(
1009 &mut self,
1010 project_names: &[String],
1011 module_names: &[String],
1012 test_names: &[String],
1013 ) -> eyre::Result<()> {
1014 // Set masking configuration for HTTP logs
1015 crate::masking::set_mask_sensitive(self.options.mask_sensitive);
1016
1017 if self.options.capture_rust {
1018 tracing_subscriber::fmt::init();
1019 }
1020
1021 let reporters = std::mem::take(&mut self.reporters);
1022
1023 // Set up barrier for all reporters + runner
1024 // This ensures all reporters subscribe before tests start
1025 setup_reporter_barrier(reporters.len())?;
1026
1027 let reporter_handles: Vec<_> = reporters
1028 .into_iter()
1029 .map(|mut reporter| tokio::spawn(async move { reporter.run().await }))
1030 .collect();
1031
1032 // Wait for all reporters to subscribe before starting tests
1033 wait_reporter_barrier().await;
1034
1035 let project_filter = ProjectFilter { project_names };
1036 let module_filter = ModuleFilter { module_names };
1037 let test_name_filter = TestNameFilter { test_names };
1038 let test_ignore_filter = TestIgnoreFilter::default();
1039
1040 let start = std::time::Instant::now();
1041 let handles: FuturesUnordered<_> = {
1042 // Create a semaphore to limit concurrency
1043 let concurrency = self.options.concurrency;
1044 let semaphore = Arc::new(tokio::sync::Semaphore::new(
1045 concurrency.unwrap_or(tokio::sync::Semaphore::MAX_PERMITS),
1046 ));
1047
1048 // Worker ID pool for timeline visualization (only when concurrency is specified)
1049 let worker_ids = Arc::new(WorkerIds::new(concurrency));
1050
1051 // Per-group mutexes for serial execution (project-scoped)
1052 // Key format: "project_name::group_name"
1053 let serial_groups: Arc<
1054 tokio::sync::RwLock<std::collections::HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
1055 > = Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new()));
1056
1057 let projects = self.cfg.projects.clone();
1058 let projects = if projects.is_empty() {
1059 vec![Arc::new(ProjectConfig {
1060 name: "default".into(),
1061 ..Default::default()
1062 })]
1063 } else {
1064 projects
1065 };
1066
1067 // Collect all tests and apply filters
1068 let mut all_tests: Vec<_> = self
1069 .test_cases
1070 .iter()
1071 .cartesian_product(projects.into_iter())
1072 .map(|((info, factory), project)| (project, Arc::clone(info), factory.clone()))
1073 .filter(move |(project, info, _)| test_name_filter.filter(project, info))
1074 .filter(move |(project, info, _)| module_filter.filter(project, info))
1075 .filter(move |(project, info, _)| project_filter.filter(project, info))
1076 .filter(move |(project, info, _)| test_ignore_filter.filter(project, info))
1077 .collect();
1078
1079 // Separate ordered and non-ordered tests
1080 let (mut ordered_tests, non_ordered_tests): (Vec<_>, Vec<_>) =
1081 all_tests.drain(..).partition(|(_, info, _)| info.ordered);
1082
1083 // Sort ordered tests by (serial_group, line) to ensure source order within groups
1084 ordered_tests
1085 .sort_by_key(|(_project, info, _factory)| (info.serial_group.clone(), info.line));
1086
1087 // Group ordered tests by (project_name, serial_group) for sequential execution
1088 let mut ordered_groups: std::collections::HashMap<String, Vec<_>> =
1089 std::collections::HashMap::new();
1090 for (project, info, factory) in ordered_tests {
1091 let key = format!(
1092 "{}::{}",
1093 project.name,
1094 info.serial_group.as_deref().unwrap_or("")
1095 );
1096 ordered_groups
1097 .entry(key)
1098 .or_default()
1099 .push((project, info, factory));
1100 }
1101
1102 // Create futures for ordered test groups (each group runs sequentially)
1103 let ordered_handles = ordered_groups.into_iter().map(|(group_key, tests)| {
1104 let semaphore = semaphore.clone();
1105 let worker_ids = worker_ids.clone();
1106 let serial_groups = serial_groups.clone();
1107
1108 tokio::spawn(async move {
1109 // Get serial mutex for this group once
1110 let serial_mutex = {
1111 let mut write_lock = serial_groups.write().await;
1112 write_lock
1113 .entry(group_key.clone())
1114 .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
1115 .clone()
1116 };
1117
1118 // Run all tests in this group sequentially (await each before starting next)
1119 let mut group_failed = false;
1120 let mut group_error: Option<eyre::Report> = None;
1121 for (project, info, factory) in tests {
1122 // Acquire semaphore for this test
1123 let _permit = semaphore
1124 .acquire()
1125 .await
1126 .map_err(|e| eyre::eyre!("failed to acquire semaphore: {e}"));
1127
1128 if _permit.is_err() {
1129 continue;
1130 }
1131
1132 // Acquire worker ID
1133 let worker_id = worker_ids.acquire();
1134
1135 let result = execute_test(
1136 project,
1137 info,
1138 factory,
1139 Some(serial_mutex.clone()),
1140 worker_id,
1141 )
1142 .await;
1143 worker_ids.release(worker_id);
1144
1145 match result {
1146 Ok(test) => {
1147 if test.result.is_err() {
1148 group_failed = true;
1149 }
1150 }
1151 Err(e) => {
1152 group_failed = true;
1153 if group_error.is_none() {
1154 group_error = Some(e);
1155 }
1156 }
1157 }
1158 }
1159 if group_failed {
1160 if let Some(e) = group_error {
1161 return Err(e);
1162 }
1163 eyre::bail!("one or more tests failed");
1164 }
1165 eyre::Ok(())
1166 })
1167 });
1168
1169 // Create futures for non-ordered tests (parallel execution as before)
1170 let non_ordered_handles =
1171 non_ordered_tests
1172 .into_iter()
1173 .map(|(project, info, factory)| {
1174 let semaphore = semaphore.clone();
1175 let worker_ids = worker_ids.clone();
1176 let serial_groups = serial_groups.clone();
1177 tokio::spawn(async move {
1178 // Step 1: Acquire serial group mutex FIRST (if needed) - project-scoped
1179 // This ensures tests in the same group don't hold semaphore permits unnecessarily
1180 let serial_mutex = match &info.serial_group {
1181 Some(group_name) => {
1182 // Create project-scoped key: "project_name::group_name"
1183 let key = format!("{}::{}", project.name, group_name);
1184
1185 // Get or create mutex for this project+group
1186 let read_lock = serial_groups.read().await;
1187 if let Some(mutex) = read_lock.get(&key) {
1188 Some(Arc::clone(mutex))
1189 } else {
1190 drop(read_lock);
1191 let mut write_lock = serial_groups.write().await;
1192 Some(
1193 write_lock
1194 .entry(key)
1195 .or_insert_with(|| {
1196 Arc::new(tokio::sync::Mutex::new(()))
1197 })
1198 .clone(),
1199 )
1200 }
1201 }
1202 None => None,
1203 };
1204
1205 // Step 2: Acquire global semaphore AFTER serial mutex
1206 // This prevents blocking other tests while waiting for serial group
1207 let _permit = semaphore
1208 .acquire()
1209 .await
1210 .map_err(|e| eyre::eyre!("failed to acquire semaphore: {e}"))?;
1211
1212 // Acquire worker ID from pool
1213 let worker_id = worker_ids.acquire();
1214
1215 let result = execute_test(
1216 project,
1217 info,
1218 factory,
1219 serial_mutex.clone(),
1220 worker_id,
1221 )
1222 .await
1223 .and_then(|test| {
1224 let is_err = test.result.is_err();
1225 eyre::ensure!(!is_err);
1226 eyre::Ok(())
1227 });
1228
1229 // Return worker ID to pool
1230 worker_ids.release(worker_id);
1231
1232 result
1233 })
1234 });
1235
1236 // Combine both ordered and non-ordered handles
1237 let all_handles = FuturesUnordered::new();
1238 for handle in ordered_handles {
1239 all_handles.push(handle);
1240 }
1241 for handle in non_ordered_handles {
1242 all_handles.push(handle);
1243 }
1244 all_handles
1245 };
1246 let test_prep_time = start.elapsed();
1247 debug!(
1248 "created handles for {} test cases",
1249 test_prep_time.as_secs_f32()
1250 );
1251
1252 let mut has_any_error = false;
1253 let total_tests = handles.len();
1254 let options = self.options.clone();
1255 let runner = async move {
1256 let results = handles.collect::<Vec<_>>().await;
1257 if results.is_empty() {
1258 console::Term::stdout().write_line("no test cases found")?;
1259 }
1260
1261 let mut failed_tests = 0;
1262 for result in results {
1263 match result {
1264 Ok(res) => {
1265 if let Err(e) = res {
1266 debug!("test case failed: {e:#}");
1267 has_any_error = true;
1268 failed_tests += 1;
1269 }
1270 }
1271 Err(e) => {
1272 if e.is_panic() {
1273 // Resume the panic on the main task
1274 error!("{e}");
1275 has_any_error = true;
1276 failed_tests += 1;
1277 }
1278 }
1279 }
1280 }
1281
1282 let passed_tests = total_tests - failed_tests;
1283 let total_time = start.elapsed();
1284
1285 // Publish summary event
1286 let summary = TestSummary {
1287 total_tests,
1288 passed_tests,
1289 failed_tests,
1290 total_time,
1291 test_prep_time,
1292 };
1293
1294 // Create a dummy event for summary (since it doesn't belong to a specific test)
1295 let summary_event = Event {
1296 project: "".to_string(),
1297 module: "".to_string(),
1298 test: "".to_string(),
1299 body: EventBody::Summary(summary),
1300 };
1301
1302 if let Ok(guard) = CHANNEL.lock() {
1303 if let Some((tx, _)) = guard.as_ref() {
1304 let _ = tx.send(summary_event);
1305 }
1306 }
1307 debug!("all test finished. sending stop signal to the background tasks.");
1308
1309 if options.terminate_channel {
1310 let Ok(mut guard) = CHANNEL.lock() else {
1311 eyre::bail!("failed to acquire runner channel lock");
1312 };
1313 guard.take(); // closing the runner channel.
1314 }
1315
1316 if has_any_error {
1317 eyre::bail!("one or more tests failed");
1318 }
1319
1320 eyre::Ok(())
1321 };
1322
1323 let runner_result = runner.await;
1324
1325 for handle in reporter_handles {
1326 match handle.await {
1327 Ok(Ok(())) => {}
1328 Ok(Err(e)) => error!("reporter failed: {e:#}"),
1329 Err(e) => error!("reporter task panicked: {e:#}"),
1330 }
1331 }
1332
1333 // Clean up barrier
1334 clear_reporter_barrier();
1335
1336 debug!("runner stopped");
1337
1338 runner_result
1339 }
1340
1341 /// Returns a list of all registered test metadata.
1342 ///
1343 /// This provides access to test information without executing the tests.
1344 /// Useful for building test UIs, generating reports, or implementing
1345 /// custom filtering logic.
1346 ///
1347 /// # Examples
1348 ///
1349 /// ```rust,ignore
1350 /// let runner = Runner::new();
1351 /// let tests = runner.list();
1352 ///
1353 /// for test in tests {
1354 /// println!("Test: {}", test.full_name());
1355 /// }
1356 /// ```
1357 pub fn list(&self) -> Vec<&TestInfo> {
1358 self.test_cases
1359 .iter()
1360 .map(|(meta, _test)| meta.as_ref())
1361 .collect::<Vec<_>>()
1362 }
1363}
1364
1365#[cfg(test)]
1366mod test {
1367 use super::*;
1368 use crate::config::RetryConfig;
1369 use crate::ProjectConfig;
1370 use std::sync::Arc;
1371
1372 fn create_config() -> Config {
1373 Config {
1374 projects: vec![Arc::new(ProjectConfig {
1375 name: "default".into(),
1376 ..Default::default()
1377 })],
1378 ..Default::default()
1379 }
1380 }
1381
1382 fn create_config_with_retry() -> Config {
1383 Config {
1384 projects: vec![Arc::new(ProjectConfig {
1385 name: "default".into(),
1386 retry: RetryConfig {
1387 count: Some(1),
1388 ..Default::default()
1389 },
1390 ..Default::default()
1391 })],
1392 ..Default::default()
1393 }
1394 }
1395
1396 #[tokio::test]
1397 async fn runner_fail_because_no_retry_configured() -> eyre::Result<()> {
1398 let mut server = mockito::Server::new_async().await;
1399 let m1 = server
1400 .mock("GET", "/")
1401 .with_status(500)
1402 .expect(1)
1403 .create_async()
1404 .await;
1405 let m2 = server
1406 .mock("GET", "/")
1407 .with_status(200)
1408 .expect(0)
1409 .create_async()
1410 .await;
1411
1412 let factory: TestCaseFactory = Arc::new(move || {
1413 let url = server.url();
1414 Box::pin(async move {
1415 let client = crate::http::Client::new();
1416 let res = client.get(&url).send().await?;
1417 if res.status().is_success() {
1418 Ok(())
1419 } else {
1420 eyre::bail!("request failed")
1421 }
1422 })
1423 });
1424
1425 let _runner_rx = subscribe()?;
1426 let mut runner = Runner::with_config(create_config());
1427 runner.add_test("retry_test", "module", None, 0, false, factory);
1428
1429 let result = runner.run(&[], &[], &[]).await;
1430 m1.assert_async().await;
1431 m2.assert_async().await;
1432
1433 assert!(result.is_err());
1434 Ok(())
1435 }
1436
1437 #[tokio::test]
1438 async fn runner_retry_successful_after_failure() -> eyre::Result<()> {
1439 let mut server = mockito::Server::new_async().await;
1440 let m1 = server
1441 .mock("GET", "/")
1442 .with_status(500)
1443 .expect(1)
1444 .create_async()
1445 .await;
1446 let m2 = server
1447 .mock("GET", "/")
1448 .with_status(200)
1449 .expect(1)
1450 .create_async()
1451 .await;
1452
1453 let factory: TestCaseFactory = Arc::new(move || {
1454 let url = server.url();
1455 Box::pin(async move {
1456 let client = crate::http::Client::new();
1457 let res = client.get(&url).send().await?;
1458 if res.status().is_success() {
1459 Ok(())
1460 } else {
1461 eyre::bail!("request failed")
1462 }
1463 })
1464 });
1465
1466 let _runner_rx = subscribe()?;
1467 let mut runner = Runner::with_config(create_config_with_retry());
1468 runner.add_test("retry_test", "module", None, 0, false, factory);
1469
1470 let result = runner.run(&[], &[], &[]).await;
1471 m1.assert_async().await;
1472 m2.assert_async().await;
1473
1474 assert!(result.is_ok());
1475
1476 Ok(())
1477 }
1478
1479 #[tokio::test]
1480 async fn spawned_task_panics_without_task_local_context() {
1481 let project = Arc::new(ProjectConfig {
1482 name: "default".to_string(),
1483 ..Default::default()
1484 });
1485 let test_info = Arc::new(TestInfo {
1486 module: "mod".to_string(),
1487 name: "test".to_string(),
1488 serial_group: None,
1489 line: 0,
1490 ordered: false,
1491 });
1492
1493 crate::config::PROJECT
1494 .scope(
1495 project,
1496 TEST_INFO.scope(test_info, async move {
1497 let handle = tokio::spawn(async move {
1498 let _ = crate::config::get_config();
1499 });
1500
1501 let join_err = handle.await.expect_err("spawned task should panic");
1502 assert!(join_err.is_panic());
1503 }),
1504 )
1505 .await;
1506 }
1507
1508 #[tokio::test]
1509 async fn scope_current_propagates_task_local_context_into_spawned_task() {
1510 let project = Arc::new(ProjectConfig {
1511 name: "default".to_string(),
1512 ..Default::default()
1513 });
1514 let test_info = Arc::new(TestInfo {
1515 module: "mod".to_string(),
1516 name: "test".to_string(),
1517 serial_group: None,
1518 line: 0,
1519 ordered: false,
1520 });
1521
1522 crate::config::PROJECT
1523 .scope(
1524 project,
1525 TEST_INFO.scope(test_info, async move {
1526 let handle = tokio::spawn(super::scope_current(async move {
1527 let _ = crate::config::get_config();
1528 let _ = super::get_test_info();
1529 }));
1530
1531 handle.await.expect("spawned task should not panic");
1532 }),
1533 )
1534 .await;
1535 }
1536
1537 #[tokio::test]
1538 #[serial_test::serial]
1539 async fn masking_masks_sensitive_query_params_in_http_logs() -> eyre::Result<()> {
1540 use crate::masking;
1541
1542 // Ensure masking is enabled
1543 masking::set_mask_sensitive(true);
1544
1545 let mut server = mockito::Server::new_async().await;
1546 let _mock = server
1547 .mock("GET", mockito::Matcher::Any)
1548 .with_status(200)
1549 .create_async()
1550 .await;
1551
1552 let factory: TestCaseFactory = Arc::new(move || {
1553 let url = server.url();
1554 Box::pin(async move {
1555 let client = crate::http::Client::new();
1556 // Make request with sensitive query param embedded in URL
1557 let _res = client
1558 .get(format!("{url}?access_token=secret_token_123&user=john"))
1559 .send()
1560 .await?;
1561 Ok(())
1562 })
1563 });
1564
1565 let mut rx = subscribe()?;
1566 let mut runner = Runner::with_config(create_config());
1567 runner.add_test(
1568 "masking_query_test",
1569 "masking_module",
1570 None,
1571 0,
1572 false,
1573 factory,
1574 );
1575
1576 runner.run(&[], &[], &[]).await?;
1577
1578 // Collect HTTP events for this specific test
1579 let mut found_http_event = false;
1580 while let Ok(event) = rx.try_recv() {
1581 // Filter to only our test's events
1582 if event.test != "masking_query_test" {
1583 continue;
1584 }
1585 if let EventBody::Http(log) = event.body {
1586 found_http_event = true;
1587 let url_str = log.request.url.to_string();
1588
1589 // Verify sensitive param is masked
1590 assert!(
1591 url_str.contains("access_token=*****"),
1592 "access_token should be masked, got: {url_str}"
1593 );
1594 // Non-sensitive params should not be masked
1595 assert!(
1596 url_str.contains("user=john"),
1597 "user should not be masked, got: {url_str}"
1598 );
1599 }
1600 }
1601
1602 assert!(found_http_event, "Should have received HTTP event");
1603 Ok(())
1604 }
1605
1606 #[tokio::test]
1607 #[serial_test::serial]
1608 async fn masking_masks_sensitive_headers_in_http_logs() -> eyre::Result<()> {
1609 use crate::masking;
1610
1611 // Ensure masking is enabled
1612 masking::set_mask_sensitive(true);
1613
1614 let mut server = mockito::Server::new_async().await;
1615 let _mock = server
1616 .mock("GET", "/")
1617 .with_status(200)
1618 .create_async()
1619 .await;
1620
1621 let factory: TestCaseFactory = Arc::new(move || {
1622 let url = server.url();
1623 Box::pin(async move {
1624 let client = crate::http::Client::new();
1625 // Make request with sensitive headers
1626 let _res = client
1627 .get(&url)
1628 .header("authorization", "Bearer secret_bearer_token")
1629 .header("x-api-key", "my_secret_api_key")
1630 .header("content-type", "application/json")
1631 .send()
1632 .await?;
1633 Ok(())
1634 })
1635 });
1636
1637 let mut rx = subscribe()?;
1638 let mut runner = Runner::with_config(create_config());
1639 runner.add_test(
1640 "masking_headers_test",
1641 "masking_module",
1642 None,
1643 0,
1644 false,
1645 factory,
1646 );
1647
1648 runner.run(&[], &[], &[]).await?;
1649
1650 // Collect HTTP events for this specific test
1651 let mut found_http_event = false;
1652 while let Ok(event) = rx.try_recv() {
1653 // Filter to only our test's events
1654 if event.test != "masking_headers_test" {
1655 continue;
1656 }
1657 if let EventBody::Http(log) = event.body {
1658 found_http_event = true;
1659
1660 // Verify sensitive headers are masked
1661 if let Some(auth) = log.request.headers.get("authorization") {
1662 assert_eq!(
1663 auth.to_str().unwrap(),
1664 "*****",
1665 "authorization header should be masked"
1666 );
1667 }
1668 if let Some(api_key) = log.request.headers.get("x-api-key") {
1669 assert_eq!(
1670 api_key.to_str().unwrap(),
1671 "*****",
1672 "x-api-key header should be masked"
1673 );
1674 }
1675 // Non-sensitive headers should not be masked
1676 if let Some(content_type) = log.request.headers.get("content-type") {
1677 assert_eq!(
1678 content_type.to_str().unwrap(),
1679 "application/json",
1680 "content-type header should not be masked"
1681 );
1682 }
1683 }
1684 }
1685
1686 assert!(found_http_event, "Should have received HTTP event");
1687 Ok(())
1688 }
1689
1690 #[tokio::test]
1691 #[serial_test::serial]
1692 async fn masking_show_sensitive_disables_masking_in_http_logs() -> eyre::Result<()> {
1693 use crate::masking;
1694
1695 masking::set_mask_sensitive(true);
1696
1697 let mut server = mockito::Server::new_async().await;
1698 let _mock = server
1699 .mock("GET", "/")
1700 .with_status(200)
1701 .create_async()
1702 .await;
1703
1704 let factory: TestCaseFactory = Arc::new(move || {
1705 let url = server.url();
1706 Box::pin(async move {
1707 let client = crate::http::Client::new();
1708 let _res = client
1709 .get(format!("{url}?access_token=secret_token_123"))
1710 .header("authorization", "Bearer secret_bearer_token")
1711 .send()
1712 .await?;
1713 Ok(())
1714 })
1715 });
1716
1717 let mut rx = subscribe()?;
1718 let mut runner = Runner::with_config(create_config());
1719 runner.capture_http();
1720 runner.show_sensitive();
1721 runner.add_test(
1722 "show_sensitive_test",
1723 "masking_module",
1724 None,
1725 0,
1726 false,
1727 factory,
1728 );
1729
1730 runner.run(&[], &[], &[]).await?;
1731
1732 let mut found_http_event = false;
1733 while let Ok(event) = rx.try_recv() {
1734 if event.test != "show_sensitive_test" {
1735 continue;
1736 }
1737 if let EventBody::Http(log) = event.body {
1738 found_http_event = true;
1739 let url_str = log.request.url.to_string();
1740 assert!(
1741 url_str.contains("access_token=secret_token_123"),
1742 "access_token should not be masked when show_sensitive is enabled"
1743 );
1744 if let Some(auth) = log.request.headers.get("authorization") {
1745 assert_eq!(
1746 auth.to_str().unwrap(),
1747 "Bearer secret_bearer_token",
1748 "authorization header should not be masked when show_sensitive is enabled"
1749 );
1750 }
1751 }
1752 }
1753
1754 assert!(found_http_event, "Should have received HTTP event");
1755 Ok(())
1756 }
1757}