Skip to main content

admixture_harness/
runner.rs

1//! Test runner that executes all registered integration tests.
2
3use crate::{ContextManager, TestDescriptor};
4use std::collections::HashMap;
5use tracing::{debug, error, info};
6
7/// Result of running all tests.
8#[derive(Debug)]
9pub struct TestResults {
10    pub total: usize,
11    pub passed: usize,
12    pub failed: usize,
13    pub failures: Vec<TestFailure>,
14}
15
16/// Information about a failed test.
17#[derive(Debug, Clone)]
18pub struct TestFailure {
19    pub name: String,
20    pub file: String,
21    pub line: u32,
22    pub error: String,
23}
24
25impl TestFailure {
26    /// Create a test failure for a context start failure.
27    fn context_start_failed(test: &TestDescriptor, error: String) -> Self {
28        Self {
29            name: test.name.to_string(),
30            file: test.file.to_string(),
31            line: test.line,
32            error: format!("Context startup failed: {}", error),
33        }
34    }
35
36    /// Create a test failure for a hook failure.
37    fn hook_failed(test: &TestDescriptor, hook_name: &str, error: String) -> Self {
38        Self {
39            name: test.name.to_string(),
40            file: test.file.to_string(),
41            line: test.line,
42            error: format!("Hook '{}' failed: {}", hook_name, error),
43        }
44    }
45
46    /// Create a test failure for a test failure.
47    fn test_failed(test: &TestDescriptor, error: String) -> Self {
48        Self {
49            name: test.name.to_string(),
50            file: test.file.to_string(),
51            line: test.line,
52            error,
53        }
54    }
55}
56
57impl TestResults {
58    /// Check if all tests passed.
59    pub fn all_passed(&self) -> bool {
60        self.failed == 0
61    }
62
63    /// Print a summary of test results.
64    pub fn print_summary(&self) {
65        println!("\n{}", "=".repeat(80));
66        println!("📊 Test Results Summary");
67        println!("{}", "=".repeat(80));
68
69        if self.failed == 0 {
70            println!("   ✅ All tests passed!");
71        } else {
72            println!("   ⚠️  Some tests failed");
73        }
74
75        println!();
76        println!("   Total:  {}", self.total);
77        println!("   ✅ Passed: {}", self.passed);
78
79        if !self.failures.is_empty() {
80            println!("   ❌ Failed: {}", self.failed);
81            println!("\n{}", "-".repeat(80));
82            println!("❌ Failed Tests:");
83            println!("{}", "-".repeat(80));
84            for failure in &self.failures {
85                println!("\n   Test: {}", failure.name);
86                println!("   Location: {}:{}", failure.file, failure.line);
87                println!("   Error: {}", failure.error);
88            }
89        }
90
91        println!("{}", "=".repeat(80));
92    }
93}
94
95/// Collect all registered integration tests.
96pub fn collect_tests() -> Vec<&'static TestDescriptor> {
97    inventory::iter::<TestDescriptor>().collect()
98}
99
100/// Run all registered integration tests, grouped by context type.
101///
102/// Tests sharing the same context type will reuse the same context instance.
103/// This function initializes basic tracing for output.
104#[tracing::instrument(name = "test_run", skip_all, fields(total_tests, context_types))]
105pub async fn run_all_tests() -> TestResults {
106    // Initialize basic tracing if not already initialized
107    let _ = tracing_subscriber::fmt()
108        .with_target(false)
109        .with_test_writer()
110        .try_init();
111
112    let tests = collect_tests();
113
114    tracing::Span::current().record("total_tests", tests.len());
115    info!("Starting integration test run");
116
117    // Group tests by context type
118    let mut grouped_tests: HashMap<&str, Vec<&TestDescriptor>> = HashMap::new();
119    for test in tests.iter() {
120        grouped_tests
121            .entry(test.context_type)
122            .or_default()
123            .push(test);
124    }
125
126    tracing::Span::current().record("context_types", grouped_tests.len());
127    info!("Grouped tests by context type");
128
129    run_all_tests_impl(tests, grouped_tests).await
130}
131
132/// Internal implementation of test runner.
133///
134/// This is exposed as a public API so that external tools (like admixture-tui)
135/// can run tests with custom tracing layers.
136pub async fn run_all_tests_impl(
137    tests: Vec<&'static TestDescriptor>,
138    grouped_tests: HashMap<&'static str, Vec<&'static TestDescriptor>>,
139) -> TestResults {
140    info!("Starting integration test run");
141
142    // Run all context groups in parallel
143    let futures: Vec<_> = grouped_tests
144        .into_values()
145        .map(|group| {
146            let group_slice: &'static [&'static TestDescriptor] = Box::leak(group.into_boxed_slice());
147            async move {
148                // Call the type-erased entry point for this context type
149                // Internally calls run_context_group<ConcreteType>
150                (group_slice[0].run_group)(group_slice).await
151            }
152        })
153        .collect();
154
155    let group_results = futures::future::join_all(futures).await;
156
157    // Aggregate results
158    let mut passed = 0;
159    let mut failed = 0;
160    let mut failures = Vec::new();
161
162    for group_result in group_results {
163        passed += group_result.passed;
164        failed += group_result.failed;
165        failures.extend(group_result.failures);
166    }
167
168    let results = TestResults {
169        total: tests.len(),
170        passed,
171        failed,
172        failures,
173    };
174
175    info!(
176        total = results.total,
177        passed = results.passed,
178        failed = results.failed,
179        "Test run completed"
180    );
181
182    results.print_summary();
183
184    results
185}
186
187/// Result from running a context group.
188///
189/// This is public so that it can be used in GroupRunnerFn.
190pub struct ContextGroupResult {
191    pub passed: usize,
192    pub failed: usize,
193    pub failures: Vec<TestFailure>,
194}
195
196/// Run all tests for a single context type with concrete type C.
197///
198/// This is the generic execution layer - all tests in the group share the same
199/// context type C, so no type erasure or downcasting is needed.
200#[tracing::instrument(
201    name = "context_group",
202    skip_all,
203    fields(
204        context_type = %context_type,
205        test_count = tests.len(),
206        status = tracing::field::Empty,
207    )
208)]
209pub async fn run_context_group<C: Send + 'static>(
210    context_type: &str,
211    tests: &[(crate::TestFn<C>, &'static TestDescriptor)],
212    manager: &dyn ContextManager<C>,
213    hooks: crate::Hooks<C>,
214) -> ContextGroupResult {
215    info!(
216        context_type = context_type,
217        test_count = tests.len(),
218        "CONTEXT_GROUP_START"
219    );
220
221    let mut passed = 0;
222    let mut failed = 0;
223    let mut failures = Vec::new();
224
225    // Start the context once for all tests - concrete type C!
226    tracing::Span::current().record("status", "starting context");
227    let ctx = match manager.start().await {
228        Ok(ctx) => ctx,
229        Err(e) => {
230            tracing::Span::current().record("status", "❌ failed to start");
231            let error_msg = e.to_string();
232            error!(error = %e, "Failed to start context");
233
234            // Mark all tests in this group as failed
235            for &(_, descriptor) in tests {
236                failures.push(TestFailure::context_start_failed(descriptor, error_msg.clone()));
237            }
238
239            return ContextGroupResult {
240                passed: 0,
241                failed: tests.len(),
242                failures,
243            };
244        }
245    };
246
247    tracing::Span::current().record("status", "✅ context ready");
248
249    // Run before_all hook - direct call, no downcast!
250    if let Some(before_all_fn) = hooks.before_all
251        && let Err(e) = before_all_fn(&ctx).await {
252        // before_all failed - fail all tests and stop
253        error!(
254            "before_all hook failed, skipping all tests in context {}",
255            context_type
256        );
257        tracing::Span::current().record("status", "❌ before_all failed");
258
259        for &(_, descriptor) in tests {
260            failures.push(TestFailure::hook_failed(
261                descriptor,
262                "before_all",
263                e.to_string(),
264            ));
265        }
266
267        // Best-effort context stop
268        if let Err(stop_err) = manager.stop(ctx).await {
269            error!(error = %stop_err, "Failed to stop context after before_all failure");
270        }
271
272        return ContextGroupResult {
273            passed: 0,
274            failed: tests.len(),
275            failures,
276        };
277    }
278
279    // Run all tests with the shared context
280    for &(test_fn, descriptor) in tests {
281        // Run before_each hook - direct call, no downcast!
282        if let Some(before_each_fn) = hooks.before_each
283            && let Err(e) = before_each_fn(&ctx).await {
284            failed += 1;
285            failures.push(TestFailure::hook_failed(
286                descriptor,
287                "before_each",
288                e.to_string(),
289            ));
290
291            // Run after_each even though test didn't run
292            if let Some(after_each_fn) = hooks.after_each {
293                let _ = after_each_fn(&ctx).await;
294            }
295
296            continue; // Skip to next test
297        }
298
299        // Run the test - direct call, no downcast!
300        let test_result = run_test(descriptor, test_fn, &ctx).await;
301
302        // Run after_each hook (always runs) - direct call, no downcast!
303        if let Some(after_each_fn) = hooks.after_each
304            && let Err(e) = after_each_fn(&ctx).await {
305            // after_each failed - mark test as failed
306            failed += 1;
307            failures.push(TestFailure::hook_failed(
308                descriptor,
309                "after_each",
310                e.to_string(),
311            ));
312            continue;
313        }
314
315        // Record test result
316        match test_result {
317            Ok(()) => {
318                passed += 1;
319            }
320            Err(e) => {
321                failed += 1;
322                failures.push(TestFailure::test_failed(descriptor, e.to_string()));
323            }
324        }
325    }
326
327    // Run after_all hook (best-effort, doesn't fail tests) - direct call, no downcast!
328    if let Some(after_all_fn) = hooks.after_all
329        && let Err(e) = after_all_fn(&ctx).await {
330        tracing::warn!("after_all hook failed (non-fatal): {}", e);
331    }
332
333    // Stop the context after all tests complete - concrete type C!
334    tracing::Span::current().record("status", "🛑 stopping");
335    if let Err(e) = manager.stop(ctx).await {
336        error!(error = %e, "Failed to stop context");
337        tracing::Span::current().record("status", "⚠️ completed with stop error");
338    } else {
339        tracing::Span::current().record("status", "✅ completed");
340    }
341
342    info!("CONTEXT_GROUP_END");
343
344    ContextGroupResult {
345        passed,
346        failed,
347        failures,
348    }
349}
350
351/// Run a single test with concrete context type - no downcast needed!
352#[tracing::instrument(
353    name = "test",
354    skip_all,
355    fields(
356        name = %descriptor.name,
357        context_type = %descriptor.context_type,
358        file = %descriptor.file,
359        line = descriptor.line,
360        result = tracing::field::Empty,
361    )
362)]
363async fn run_test<C>(
364    descriptor: &TestDescriptor,
365    test_fn: crate::TestFn<C>,
366    ctx: &C,
367) -> Result<(), Box<dyn std::error::Error + Send>> {
368    debug!("Running test");
369
370    match test_fn(ctx).await {
371        Ok(()) => {
372            tracing::Span::current().record("result", "✅ passed");
373            info!(name = descriptor.name, result = "✅ passed", "TEST_PASSED");
374            Ok(())
375        }
376        Err(e) => {
377            tracing::Span::current().record("result", "❌ failed");
378            error!(name = descriptor.name, error = %e, "TEST_FAILED");
379            Err(e)
380        }
381    }
382}
383
384/// Macro to generate a test runner function.
385///
386/// Use this in your test module to create a single test that runs all
387/// `#[admixture_test]` tests. Automatically detects if TUI mode is enabled
388/// via the ADMIXTURE_TUI environment variable.
389///
390/// # Example
391///
392/// ```ignore
393/// use admixture_harness::test_runner;
394///
395/// test_runner!();
396/// ```
397#[macro_export]
398macro_rules! test_runner {
399    () => {
400        #[tokio::test]
401        async fn __run_all_admixture_tests() {
402            let results = $crate::runner::run_all_tests().await;
403            assert!(results.all_passed(), "{} test(s) failed", results.failed);
404        }
405    };
406}