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> {
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 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 bats_files.sort();
129 info!("Found {} BATS files", bats_files.len());
130
131 Ok(bats_files)
132 }
133
134 pub fn run_tests(&self, test_dir: &Path) -> Result<TestReport> {
136 let start_time = Instant::now();
137 let started_at = Utc::now();
138
139 let bats_version = Self::verify_bats_installed()?;
141
142 let mut bats_files = Self::find_bats_files(test_dir)?;
144
145 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 let runtime = tokio::runtime::Runtime::new().map_err(|e| {
170 Error::BatsExecutionFailed(format!("Failed to create async runtime: {}", e))
171 })?;
172
173 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 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 eprintln!("\n⚠️ Warning: {}", e);
237 eprintln!(" Continuing with remaining test suites...\n");
238
239 }
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 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![], })
264 }
265
266 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 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 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 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 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 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 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 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 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 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 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), 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![], priority: crate::types::TestPriority::Important, });
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 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}