Skip to main content

probador/
runner.rs

1//! Test runner implementation
2
3use crate::config::CliConfig;
4use crate::error::CliResult;
5use crate::output::ProgressReporter;
6use serde::{Deserialize, Serialize};
7use std::time::{Duration, Instant};
8
9/// Test execution result
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct TestResult {
12    /// Test name
13    pub name: String,
14    /// Whether the test passed
15    pub passed: bool,
16    /// Error message if failed
17    pub error: Option<String>,
18    /// Test duration
19    pub duration: Duration,
20    /// Output from the test
21    pub output: String,
22}
23
24impl TestResult {
25    /// Create a passing test result
26    #[must_use]
27    pub fn pass(name: impl Into<String>, duration: Duration) -> Self {
28        Self {
29            name: name.into(),
30            passed: true,
31            error: None,
32            duration,
33            output: String::new(),
34        }
35    }
36
37    /// Create a failing test result
38    #[must_use]
39    pub fn fail(name: impl Into<String>, error: impl Into<String>, duration: Duration) -> Self {
40        Self {
41            name: name.into(),
42            passed: false,
43            error: Some(error.into()),
44            duration,
45            output: String::new(),
46        }
47    }
48
49    /// Add output to the result
50    #[must_use]
51    pub fn with_output(mut self, output: impl Into<String>) -> Self {
52        self.output = output.into();
53        self
54    }
55}
56
57/// Aggregated test results
58#[derive(Debug, Clone, Default, Serialize, Deserialize)]
59pub struct TestResults {
60    /// Individual test results
61    pub results: Vec<TestResult>,
62    /// Total duration
63    pub duration: Duration,
64}
65
66impl TestResults {
67    /// Create new empty results
68    #[must_use]
69    pub fn new() -> Self {
70        Self::default()
71    }
72
73    /// Add a test result
74    pub fn add(&mut self, result: TestResult) {
75        self.results.push(result);
76    }
77
78    /// Get number of passed tests
79    #[must_use]
80    pub fn passed(&self) -> usize {
81        self.results.iter().filter(|r| r.passed).count()
82    }
83
84    /// Get number of failed tests
85    #[must_use]
86    pub fn failed(&self) -> usize {
87        self.results.iter().filter(|r| !r.passed).count()
88    }
89
90    /// Get total number of tests
91    #[must_use]
92    pub fn total(&self) -> usize {
93        self.results.len()
94    }
95
96    /// Check if all tests passed
97    #[must_use]
98    pub fn all_passed(&self) -> bool {
99        self.results.iter().all(|r| r.passed)
100    }
101
102    /// Get failed tests
103    #[must_use]
104    pub fn failures(&self) -> Vec<&TestResult> {
105        self.results.iter().filter(|r| !r.passed).collect()
106    }
107}
108
109/// Test runner for executing Probar tests
110#[derive(Debug)]
111pub struct TestRunner {
112    config: CliConfig,
113    reporter: ProgressReporter,
114}
115
116impl TestRunner {
117    /// Create a new test runner
118    #[must_use]
119    pub fn new(config: CliConfig) -> Self {
120        let reporter =
121            ProgressReporter::new(config.color.should_color(), config.verbosity.is_quiet());
122        Self { config, reporter }
123    }
124
125    /// Run tests with optional filter
126    ///
127    /// # Errors
128    ///
129    /// Returns error if test discovery or execution fails
130    pub fn run(&mut self, filter: Option<&str>) -> CliResult<TestResults> {
131        let start = Instant::now();
132        let mut results = TestResults::new();
133
134        // Discover tests (placeholder - actual implementation would scan for tests)
135        let tests = Self::discover_tests(filter);
136
137        if tests.is_empty() {
138            self.reporter.warning("No tests found");
139            results.duration = start.elapsed();
140            return Ok(results);
141        }
142
143        self.reporter.header("Running Tests");
144        self.reporter
145            .start_progress(tests.len() as u64, "Starting...");
146
147        for test_name in tests {
148            self.reporter.set_message(&test_name);
149
150            let test_start = Instant::now();
151            let result = Self::run_single_test(&test_name, test_start);
152
153            if result.passed {
154                self.reporter.success(&test_name);
155            } else {
156                self.reporter.failure(&format!(
157                    "{}: {}",
158                    test_name,
159                    result.error.as_deref().unwrap_or("unknown error")
160                ));
161
162                if self.config.fail_fast {
163                    results.add(result);
164                    break;
165                }
166            }
167
168            results.add(result);
169            self.reporter.increment(1);
170        }
171
172        self.reporter.finish();
173        results.duration = start.elapsed();
174
175        self.reporter.summary(
176            results.passed(),
177            results.failed(),
178            0, // skipped
179            results.duration,
180        );
181
182        Ok(results)
183    }
184
185    /// Discover tests matching the filter using `cargo test --list`
186    fn discover_tests(filter: Option<&str>) -> Vec<String> {
187        let mut cmd = std::process::Command::new("cargo");
188        cmd.args(["test", "--", "--list", "--format", "terse"]);
189
190        if let Some(pattern) = filter {
191            cmd.arg(pattern);
192        }
193
194        match cmd.output() {
195            Ok(output) => {
196                if output.status.success() {
197                    String::from_utf8_lossy(&output.stdout)
198                        .lines()
199                        .filter(|line| line.ends_with(": test"))
200                        .map(|line| line.trim_end_matches(": test").to_string())
201                        .collect()
202                } else {
203                    Vec::new()
204                }
205            }
206            Err(_) => Vec::new(),
207        }
208    }
209
210    /// Run a single test using `cargo test`
211    fn run_single_test(name: &str, start: Instant) -> TestResult {
212        let output = std::process::Command::new("cargo")
213            .args(["test", "--", "--exact", name, "--nocapture"])
214            .output();
215
216        match output {
217            Ok(result) => {
218                let stdout = String::from_utf8_lossy(&result.stdout);
219                let stderr = String::from_utf8_lossy(&result.stderr);
220                let combined_output = format!("{stdout}\n{stderr}");
221
222                if result.status.success() {
223                    TestResult::pass(name, start.elapsed()).with_output(&combined_output)
224                } else {
225                    let error_msg = if stderr.contains("FAILED") {
226                        stderr
227                            .lines()
228                            .find(|l| l.contains("FAILED") || l.contains("panicked"))
229                            .unwrap_or("Test failed")
230                            .to_string()
231                    } else {
232                        "Test execution failed".to_string()
233                    };
234                    TestResult::fail(name, error_msg, start.elapsed()).with_output(&combined_output)
235                }
236            }
237            Err(e) => TestResult::fail(
238                name,
239                format!("Failed to execute test: {e}"),
240                start.elapsed(),
241            ),
242        }
243    }
244
245    /// Get the reporter (for testing)
246    #[must_use]
247    pub const fn reporter(&self) -> &ProgressReporter {
248        &self.reporter
249    }
250}
251
252#[cfg(test)]
253#[allow(clippy::unwrap_used, clippy::expect_used)]
254mod tests {
255    use super::*;
256
257    mod test_result_tests {
258        use super::*;
259
260        #[test]
261        fn test_pass_result() {
262            let result = TestResult::pass("test_1", Duration::from_millis(100));
263            assert!(result.passed);
264            assert!(result.error.is_none());
265            assert_eq!(result.name, "test_1");
266        }
267
268        #[test]
269        fn test_fail_result() {
270            let result = TestResult::fail("test_2", "assertion failed", Duration::from_millis(50));
271            assert!(!result.passed);
272            assert_eq!(result.error, Some("assertion failed".to_string()));
273        }
274
275        #[test]
276        fn test_with_output() {
277            let result = TestResult::pass("test_3", Duration::from_millis(10))
278                .with_output("test output here");
279            assert_eq!(result.output, "test output here");
280        }
281    }
282
283    mod test_results_tests {
284        use super::*;
285
286        #[test]
287        fn test_new_results() {
288            let results = TestResults::new();
289            assert_eq!(results.total(), 0);
290            assert_eq!(results.passed(), 0);
291            assert_eq!(results.failed(), 0);
292        }
293
294        #[test]
295        fn test_add_results() {
296            let mut results = TestResults::new();
297            results.add(TestResult::pass("test_1", Duration::from_millis(10)));
298            results.add(TestResult::fail(
299                "test_2",
300                "error",
301                Duration::from_millis(10),
302            ));
303            results.add(TestResult::pass("test_3", Duration::from_millis(10)));
304
305            assert_eq!(results.total(), 3);
306            assert_eq!(results.passed(), 2);
307            assert_eq!(results.failed(), 1);
308        }
309
310        #[test]
311        fn test_all_passed() {
312            let mut results = TestResults::new();
313            results.add(TestResult::pass("test_1", Duration::from_millis(10)));
314            results.add(TestResult::pass("test_2", Duration::from_millis(10)));
315            assert!(results.all_passed());
316
317            results.add(TestResult::fail(
318                "test_3",
319                "error",
320                Duration::from_millis(10),
321            ));
322            assert!(!results.all_passed());
323        }
324
325        #[test]
326        fn test_failures() {
327            let mut results = TestResults::new();
328            results.add(TestResult::pass("test_1", Duration::from_millis(10)));
329            results.add(TestResult::fail(
330                "test_2",
331                "error1",
332                Duration::from_millis(10),
333            ));
334            results.add(TestResult::fail(
335                "test_3",
336                "error2",
337                Duration::from_millis(10),
338            ));
339
340            let failures = results.failures();
341            assert_eq!(failures.len(), 2);
342            assert_eq!(failures[0].name, "test_2");
343            assert_eq!(failures[1].name, "test_3");
344        }
345    }
346
347    mod test_runner_tests {
348        use super::*;
349
350        #[test]
351        fn test_new_runner() {
352            let config = CliConfig::default();
353            let runner = TestRunner::new(config);
354            assert!(runner.reporter().use_color || !runner.reporter().use_color);
355        }
356
357        #[test]
358        #[ignore = "Spawns cargo test --list subprocess - causes nested builds in CI"]
359        fn test_run_no_tests() {
360            let config = CliConfig::default();
361            let mut runner = TestRunner::new(config);
362            let results = runner.run(None).unwrap();
363            assert_eq!(results.total(), 0);
364        }
365
366        #[test]
367        #[ignore = "Spawns cargo test --list subprocess - causes nested builds in CI"]
368        fn test_run_with_filter() {
369            let config = CliConfig::default();
370            let mut runner = TestRunner::new(config);
371            let results = runner.run(Some("game::*")).unwrap();
372            assert_eq!(results.total(), 0);
373        }
374
375        #[test]
376        fn test_runner_with_config() {
377            let config = CliConfig::default();
378            let runner = TestRunner::new(config);
379            // Just verify it constructs and reporter is accessible
380            let _reporter = runner.reporter();
381        }
382    }
383
384    mod test_result_additional_tests {
385        use super::*;
386
387        #[test]
388        fn test_with_output() {
389            let result =
390                TestResult::pass("test", Duration::from_millis(10)).with_output("Some output text");
391            assert_eq!(result.output, "Some output text");
392        }
393
394        #[test]
395        fn test_debug() {
396            let result = TestResult::pass("test", Duration::from_millis(10));
397            let debug = format!("{result:?}");
398            assert!(debug.contains("TestResult"));
399        }
400
401        #[test]
402        fn test_clone() {
403            let result = TestResult::fail("test", "error", Duration::from_millis(10));
404            let cloned = result.clone();
405            assert_eq!(result.name, cloned.name);
406            assert_eq!(result.error, cloned.error);
407        }
408
409        #[test]
410        fn test_serialize() {
411            let result = TestResult::pass("test", Duration::from_millis(10));
412            let json = serde_json::to_string(&result).unwrap();
413            assert!(json.contains("test"));
414        }
415    }
416
417    mod test_results_additional_tests {
418        use super::*;
419
420        #[test]
421        fn test_default() {
422            let results = TestResults::default();
423            assert!(results.results.is_empty());
424        }
425
426        #[test]
427        fn test_duration_tracking() {
428            let mut results = TestResults::new();
429            results.duration = Duration::from_secs(5);
430            assert_eq!(results.duration.as_secs(), 5);
431        }
432
433        #[test]
434        fn test_serialize() {
435            let mut results = TestResults::new();
436            results.add(TestResult::pass("test1", Duration::from_millis(10)));
437            let json = serde_json::to_string(&results).unwrap();
438            assert!(json.contains("test1"));
439        }
440
441        #[test]
442        fn test_debug() {
443            let results = TestResults::new();
444            let debug = format!("{results:?}");
445            assert!(debug.contains("TestResults"));
446        }
447
448        #[test]
449        fn test_clone() {
450            let mut results = TestResults::new();
451            results.add(TestResult::pass("test", Duration::from_millis(10)));
452            let cloned = results.clone();
453            assert_eq!(results.total(), cloned.total());
454        }
455    }
456}