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