cli_testing_specialist/runner/
bats_executor.rs1use 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
12pub struct BatsExecutor {
14 timeout: u64,
16
17 binary_name: String,
19
20 binary_version: Option<String>,
22
23 skip_categories: Option<Vec<String>>,
25}
26
27impl BatsExecutor {
28 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 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 pub fn with_skip_categories(mut self, skip: Vec<String>) -> Self {
50 self.skip_categories = Some(skip);
51 self
52 }
53
54 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 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 bats_files.sort();
117 info!("Found {} BATS files", bats_files.len());
118
119 Ok(bats_files)
120 }
121
122 pub fn run_tests(&self, test_dir: &Path) -> Result<TestReport> {
124 let start_time = Instant::now();
125 let started_at = Utc::now();
126
127 let bats_version = Self::verify_bats_installed()?;
129
130 let mut bats_files = Self::find_bats_files(test_dir)?;
132
133 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 let runtime = tokio::runtime::Runtime::new().map_err(|e| {
158 Error::BatsExecutionFailed(format!("Failed to create async runtime: {}", e))
159 })?;
160
161 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 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 eprintln!("\n⚠️ Warning: {}", e);
225 eprintln!(" Continuing with remaining test suites...\n");
226
227 }
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 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![], })
252 }
253
254 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 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 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 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 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 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 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 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 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 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 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), 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![], priority: crate::types::TestPriority::Important, });
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 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}