cli_testing_specialist/runner/
bats_executor.rs

1use crate::error::{Error, Result};
2use crate::types::{EnvironmentInfo, TestReport, TestResult, TestStatus, TestSuite};
3use chrono::Utc;
4use indicatif::{ProgressBar, ProgressStyle};
5use log::{debug, info, warn};
6use regex::Regex;
7use std::fs;
8use std::path::{Path, PathBuf};
9use std::process::{Command, Stdio};
10use std::time::{Duration, Instant};
11
12/// BATS test executor with TAP (Test Anything Protocol) parser
13pub struct BatsExecutor {
14    /// Timeout per test suite in seconds
15    timeout: u64,
16
17    /// Binary name being tested
18    binary_name: String,
19
20    /// Binary version (if available)
21    binary_version: Option<String>,
22
23    /// Categories to skip (optional)
24    skip_categories: Option<Vec<String>>,
25}
26
27impl BatsExecutor {
28    /// Create new BATS executor with default timeout (300 seconds)
29    pub fn new(binary_name: String, binary_version: Option<String>) -> Self {
30        Self {
31            timeout: 300,
32            binary_name,
33            binary_version,
34            skip_categories: None,
35        }
36    }
37
38    /// Create new BATS executor with custom timeout
39    pub fn with_timeout(binary_name: String, binary_version: Option<String>, timeout: u64) -> Self {
40        Self {
41            timeout,
42            binary_name,
43            binary_version,
44            skip_categories: None,
45        }
46    }
47
48    /// Set categories to skip
49    pub fn with_skip_categories(mut self, skip: Vec<String>) -> Self {
50        self.skip_categories = Some(skip);
51        self
52    }
53
54    /// Verify BATS is installed and available
55    ///
56    /// # Examples
57    ///
58    /// ```no_run
59    /// use cli_testing_specialist::runner::BatsExecutor;
60    ///
61    /// match BatsExecutor::verify_bats_installed() {
62    ///     Ok(version) => println!("BATS version: {}", version),
63    ///     Err(e) => eprintln!("BATS not installed: {}", e),
64    /// }
65    /// # Ok::<(), cli_testing_specialist::error::CliTestError>(())
66    /// ```
67    pub fn verify_bats_installed() -> Result<String> {
68        let output = Command::new("bats")
69            .arg("--version")
70            .output()
71            .map_err(|e| {
72                Error::BatsExecutionFailed(format!(
73                    "BATS not found. Please install BATS: https://github.com/bats-core/bats-core\nError: {}",
74                    e
75                ))
76            })?;
77
78        if !output.status.success() {
79            return Err(Error::BatsExecutionFailed(
80                "BATS is installed but --version failed".to_string(),
81            ));
82        }
83
84        let version = String::from_utf8_lossy(&output.stdout);
85        let version_str = version
86            .lines()
87            .next()
88            .unwrap_or("unknown")
89            .trim()
90            .to_string();
91
92        info!("BATS version: {}", version_str);
93        Ok(version_str)
94    }
95
96    /// Find all BATS files in a directory
97    pub fn find_bats_files(test_dir: &Path) -> Result<Vec<PathBuf>> {
98        if !test_dir.exists() {
99            return Err(Error::Config(format!(
100                "Test directory not found: {}",
101                test_dir.display()
102            )));
103        }
104
105        let mut bats_files = Vec::new();
106
107        for entry in fs::read_dir(test_dir)? {
108            let entry = entry?;
109            let path = entry.path();
110
111            if path.is_file() {
112                if let Some(ext) = path.extension() {
113                    if ext == "bats" {
114                        bats_files.push(path);
115                    }
116                }
117            }
118        }
119
120        if bats_files.is_empty() {
121            return Err(Error::Config(format!(
122                "No BATS files found in directory: {}",
123                test_dir.display()
124            )));
125        }
126
127        // Sort for consistent ordering
128        bats_files.sort();
129        info!("Found {} BATS files", bats_files.len());
130
131        Ok(bats_files)
132    }
133
134    /// Execute all BATS files and generate report
135    pub fn run_tests(&self, test_dir: &Path) -> Result<TestReport> {
136        let start_time = Instant::now();
137        let started_at = Utc::now();
138
139        // Verify BATS is installed
140        let bats_version = Self::verify_bats_installed()?;
141
142        // Find all BATS files
143        let mut bats_files = Self::find_bats_files(test_dir)?;
144
145        // Filter out skipped categories if specified
146        if let Some(ref skip_cats) = self.skip_categories {
147            let original_count = bats_files.len();
148            bats_files.retain(|path| {
149                if let Some(file_stem) = path.file_stem().and_then(|s| s.to_str()) {
150                    !skip_cats
151                        .iter()
152                        .any(|skip_cat| file_stem.contains(skip_cat))
153                } else {
154                    true
155                }
156            });
157            let skipped_count = original_count - bats_files.len();
158            if skipped_count > 0 {
159                info!(
160                    "Skipped {} test suite(s) based on skip categories",
161                    skipped_count
162                );
163            }
164        }
165
166        info!("Executing {} test suites", bats_files.len());
167
168        // Create single tokio runtime for all test executions
169        let runtime = tokio::runtime::Runtime::new().map_err(|e| {
170            Error::BatsExecutionFailed(format!("Failed to create async runtime: {}", e))
171        })?;
172
173        // Create progress bar
174        let pb = ProgressBar::new(bats_files.len() as u64);
175        pb.set_style(
176            ProgressStyle::default_bar()
177                .template(
178                    "{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} {msg}",
179                )
180                .unwrap()
181                .progress_chars("#>-"),
182        );
183
184        // Execute each BATS file
185        let mut suites = Vec::new();
186        for bats_file in bats_files.iter() {
187            let suite_name = bats_file
188                .file_stem()
189                .and_then(|s| s.to_str())
190                .unwrap_or("unknown");
191
192            let suite_start_time = Instant::now();
193            pb.set_message(format!(
194                "Running {} (timeout: {}s)",
195                suite_name, self.timeout
196            ));
197
198            match self.execute_suite(bats_file, &runtime) {
199                Ok(suite) => {
200                    let passed = suite.passed_count();
201                    let total = suite.total_count();
202                    let elapsed = suite_start_time.elapsed();
203
204                    info!(
205                        "Suite '{}': {}/{} tests passed in {:.1}s",
206                        suite.name,
207                        passed,
208                        total,
209                        elapsed.as_secs_f64()
210                    );
211
212                    pb.set_message(format!(
213                        "{} ✓ ({}/{}) {:.1}s",
214                        suite_name,
215                        passed,
216                        total,
217                        elapsed.as_secs_f64()
218                    ));
219                    suites.push(suite);
220                }
221                Err(e) => {
222                    let elapsed = suite_start_time.elapsed();
223                    warn!(
224                        "Failed to execute suite '{}' after {:.1}s: {}",
225                        suite_name,
226                        elapsed.as_secs_f64(),
227                        e
228                    );
229                    pb.set_message(format!(
230                        "{} ✗ (timeout after {:.0}s)",
231                        suite_name,
232                        elapsed.as_secs_f64()
233                    ));
234
235                    // Print user-friendly error message
236                    eprintln!("\n⚠️  Warning: {}", e);
237                    eprintln!("    Continuing with remaining test suites...\n");
238
239                    // Continue with other suites
240                }
241            }
242
243            pb.inc(1);
244        }
245
246        pb.finish_with_message("All test suites completed");
247
248        let total_duration = start_time.elapsed();
249        let finished_at = Utc::now();
250
251        // Gather environment information
252        let environment = self.gather_environment_info(bats_version);
253
254        Ok(TestReport {
255            binary_name: self.binary_name.clone(),
256            binary_version: self.binary_version.clone(),
257            suites,
258            total_duration,
259            started_at,
260            finished_at,
261            environment,
262            security_findings: vec![], // TODO: Extract from SecurityCheck tests
263        })
264    }
265
266    /// Execute a single BATS suite with timeout
267    fn execute_suite(
268        &self,
269        bats_file: &Path,
270        runtime: &tokio::runtime::Runtime,
271    ) -> Result<TestSuite> {
272        let suite_start = Instant::now();
273        let started_at = Utc::now();
274
275        let suite_name = bats_file
276            .file_stem()
277            .and_then(|s| s.to_str())
278            .unwrap_or("unknown");
279
280        debug!("Executing BATS file: {}", bats_file.display());
281
282        // Execute BATS with TAP output and timeout with periodic progress updates
283        let timeout_duration = std::time::Duration::from_secs(self.timeout);
284        let bats_file_path = bats_file.to_path_buf();
285        let suite_name_clone = suite_name.to_string();
286
287        let output = runtime
288            .block_on(async move {
289                // Wrap execution in timeout
290                tokio::time::timeout(timeout_duration, async move {
291                    let mut execution = tokio::task::spawn_blocking(move || {
292                        Command::new("bats")
293                            .arg("--formatter")
294                            .arg("tap")
295                            .arg("--verbose-run")
296                            .arg(&bats_file_path)
297                            .stdout(Stdio::piped())
298                            .stderr(Stdio::piped())
299                            .output()
300                    });
301
302                    // Progress ticker that prints every 30 seconds
303                    let mut interval = tokio::time::interval(std::time::Duration::from_secs(30));
304                    interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
305
306                    let mut elapsed_secs = 0u64;
307                    let timeout_secs = timeout_duration.as_secs();
308
309                    loop {
310                        tokio::select! {
311                            result = &mut execution => {
312                                // result is Result<Result<Output, io::Error>, JoinError>
313                                return result.map_err(|e| std::io::Error::other(
314                                    format!("Task join error: {}", e)
315                                ))?;
316                            }
317                            _ = interval.tick() => {
318                                elapsed_secs += 30;
319                                if elapsed_secs < timeout_secs {
320                                    eprintln!("  ⏳ Still running '{}' ({}/{}s elapsed)...",
321                                        suite_name_clone, elapsed_secs, timeout_secs);
322                                }
323                            }
324                        }
325                    }
326                })
327                .await
328            })
329            .map_err(|_| {
330                // Timeout error from tokio::time::timeout
331                Error::BatsExecutionFailed(format!(
332                    "Test suite '{}' timed out after {} seconds. \
333                     This may indicate a hanging test (e.g., waiting for user input). \
334                     Check the test file: {}",
335                    suite_name,
336                    self.timeout,
337                    bats_file.display()
338                ))
339            })?
340            .map_err(|e| Error::BatsExecutionFailed(format!("Failed to execute BATS: {}", e)))?;
341
342        let stdout = String::from_utf8_lossy(&output.stdout).to_string();
343        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
344
345        debug!("BATS stdout:\n{}", stdout);
346        if !stderr.is_empty() {
347            debug!("BATS stderr:\n{}", stderr);
348        }
349
350        // Parse TAP output
351        let tests = self.parse_tap_output(&stdout, bats_file)?;
352
353        let duration = suite_start.elapsed();
354        let finished_at = Utc::now();
355
356        let suite_name = bats_file
357            .file_stem()
358            .and_then(|s| s.to_str())
359            .unwrap_or("unknown")
360            .to_string();
361
362        Ok(TestSuite {
363            name: suite_name,
364            file_path: bats_file.to_string_lossy().to_string(),
365            tests,
366            duration,
367            started_at,
368            finished_at,
369        })
370    }
371
372    /// Parse TAP (Test Anything Protocol) output from BATS
373    fn parse_tap_output(&self, output: &str, bats_file: &Path) -> Result<Vec<TestResult>> {
374        let mut tests = Vec::new();
375        let lines: Vec<&str> = output.lines().collect();
376
377        // TAP format:
378        // 1..N (plan)
379        // ok 1 test name
380        // not ok 2 test name
381        // # (comments/diagnostics)
382
383        let test_line_re = Regex::new(r"^(ok|not ok)\s+(\d+)\s+(.+)$").unwrap();
384        let skip_re = Regex::new(r"#\s*skip").unwrap();
385
386        for line in lines {
387            if let Some(caps) = test_line_re.captures(line) {
388                let status_str = &caps[1];
389                let test_num = &caps[2];
390                let test_name = caps[3].trim();
391
392                // Check if test was skipped
393                let is_skipped = skip_re.is_match(test_name);
394
395                let status = if is_skipped {
396                    TestStatus::Skipped
397                } else if status_str == "ok" {
398                    TestStatus::Passed
399                } else {
400                    TestStatus::Failed
401                };
402
403                // Extract clean test name (remove skip directive)
404                let clean_name = skip_re.replace(test_name, "").trim().to_string();
405
406                tests.push(TestResult {
407                    name: clean_name,
408                    status,
409                    duration: Duration::from_millis(100), // Default duration, BATS doesn't provide timing
410                    output: String::new(),
411                    error_message: if status == TestStatus::Failed {
412                        Some(format!("Test {} failed", test_num))
413                    } else {
414                        None
415                    },
416                    file_path: bats_file.to_string_lossy().to_string(),
417                    line_number: None,
418                    tags: vec![], // TODO: Extract from BATS comments
419                    priority: crate::types::TestPriority::Important, // TODO: Determine from test metadata
420                });
421
422                debug!("Parsed test: {} - {:?}", test_name, status);
423            }
424        }
425
426        if tests.is_empty() {
427            warn!("No tests found in TAP output");
428        }
429
430        Ok(tests)
431    }
432
433    /// Gather environment information
434    fn gather_environment_info(&self, bats_version: String) -> EnvironmentInfo {
435        let shell_version = Command::new("bash")
436            .arg("--version")
437            .output()
438            .ok()
439            .and_then(|o| String::from_utf8(o.stdout).ok())
440            .and_then(|s| s.lines().next().map(|l| l.to_string()))
441            .unwrap_or_else(|| "unknown".to_string());
442
443        let os_version = if cfg!(target_os = "macos") {
444            Command::new("sw_vers")
445                .arg("-productVersion")
446                .output()
447                .ok()
448                .and_then(|o| String::from_utf8(o.stdout).ok())
449                .map(|s| s.trim().to_string())
450                .unwrap_or_else(|| "unknown".to_string())
451        } else if cfg!(target_os = "linux") {
452            Command::new("uname")
453                .arg("-r")
454                .output()
455                .ok()
456                .and_then(|o| String::from_utf8(o.stdout).ok())
457                .map(|s| s.trim().to_string())
458                .unwrap_or_else(|| "unknown".to_string())
459        } else {
460            "unknown".to_string()
461        };
462
463        EnvironmentInfo {
464            os_version,
465            shell_version,
466            bats_version,
467            ..Default::default()
468        }
469    }
470}
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475
476    #[test]
477    fn test_parse_tap_output_success() {
478        let executor = BatsExecutor::new("test-cli".to_string(), None);
479        let tap_output = r#"
4801..3
481ok 1 test one
482ok 2 test two
483ok 3 test three
484"#;
485
486        let bats_file = Path::new("/tmp/test.bats");
487        let results = executor.parse_tap_output(tap_output, bats_file).unwrap();
488
489        assert_eq!(results.len(), 3);
490        assert_eq!(results[0].name, "test one");
491        assert_eq!(results[0].status, TestStatus::Passed);
492        assert_eq!(results[1].name, "test two");
493        assert_eq!(results[2].name, "test three");
494    }
495
496    #[test]
497    fn test_parse_tap_output_failures() {
498        let executor = BatsExecutor::new("test-cli".to_string(), None);
499        let tap_output = r#"
5001..3
501ok 1 test one
502not ok 2 test two
503ok 3 test three
504"#;
505
506        let bats_file = Path::new("/tmp/test.bats");
507        let results = executor.parse_tap_output(tap_output, bats_file).unwrap();
508
509        assert_eq!(results.len(), 3);
510        assert_eq!(results[0].status, TestStatus::Passed);
511        assert_eq!(results[1].status, TestStatus::Failed);
512        assert!(results[1].error_message.is_some());
513        assert_eq!(results[2].status, TestStatus::Passed);
514    }
515
516    #[test]
517    fn test_parse_tap_output_skipped() {
518        let executor = BatsExecutor::new("test-cli".to_string(), None);
519        let tap_output = r#"
5201..2
521ok 1 test one # skip
522ok 2 test two
523"#;
524
525        let bats_file = Path::new("/tmp/test.bats");
526        let results = executor.parse_tap_output(tap_output, bats_file).unwrap();
527
528        assert_eq!(results.len(), 2);
529        assert_eq!(results[0].status, TestStatus::Skipped);
530        assert_eq!(results[1].status, TestStatus::Passed);
531    }
532
533    #[test]
534    fn test_executor_creation() {
535        let executor = BatsExecutor::new("test-cli".to_string(), Some("1.0.0".to_string()));
536        assert_eq!(executor.binary_name, "test-cli");
537        assert_eq!(executor.binary_version, Some("1.0.0".to_string()));
538        assert_eq!(executor.timeout, 300);
539
540        let custom = BatsExecutor::with_timeout("cli".to_string(), None, 600);
541        assert_eq!(custom.timeout, 600);
542    }
543}