1use crate::error::Result;
10use crate::models::{ExecutionMode, TestOutcome, TestResult};
11use crate::pool::WorkerPool;
12use crate::scheduler::TestScheduler;
13use async_trait::async_trait;
14use parking_lot::Mutex;
15use serde::{Deserialize, Serialize};
16use std::collections::HashMap;
17use std::path::PathBuf;
18use std::process::Stdio;
19use std::sync::Arc;
20use tokio::io::{AsyncBufReadExt, BufReader};
21use tokio::process::{ChildStderr, ChildStdout, Command as AsyncCommand};
22use tracing::{debug, error, info, warn};
23
24#[async_trait]
26pub trait TestExecutor: Send + Sync + std::fmt::Debug {
27 async fn run_test(&self, node_id: &str) -> Result<TestResult>;
29
30 async fn run_tests(&self, node_ids: &[String]) -> Vec<TestResult>;
32
33 fn configure(&mut self, config: ExecutorConfig);
35
36 fn execution_mode(&self) -> &'static str;
38
39 fn kill_all(&self);
41}
42
43pub fn create_executor(mode: ExecutionMode, python_path: PathBuf) -> Result<Box<dyn TestExecutor>> {
50 match mode {
51 ExecutionMode::Embedded => {
52 #[cfg(feature = "embedded-python")]
53 {
54 match crate::embedded::EmbeddedExecutor::new(Some(python_path)) {
56 Ok(executor) => {
57 info!("Using embedded Python executor");
58 Ok(Box::new(executor))
59 }
60 Err(e) => Err(crate::error::DaemonError::Other(format!(
61 "Failed to create embedded executor: {}",
62 e
63 ))),
64 }
65 }
66 #[cfg(not(feature = "embedded-python"))]
67 {
68 let _ = python_path; Err(crate::error::DaemonError::Other(
70 "Embedded Python execution requires the 'embedded-python' feature".to_string(),
71 ))
72 }
73 }
74 ExecutionMode::Subprocess => {
75 info!("Using subprocess executor");
76 Ok(Box::new(PythonExecutor::new(python_path)))
77 }
78 ExecutionMode::Pooled => {
79 Err(crate::error::DaemonError::Other(
81 "Pooled executor requires async initialization. Use create_pooled_executor() instead.".to_string(),
82 ))
83 }
84 ExecutionMode::Auto => {
85 #[cfg(feature = "embedded-python")]
86 {
87 if crate::embedded::EmbeddedExecutor::is_available() {
88 match crate::embedded::EmbeddedExecutor::new(Some(python_path.clone())) {
90 Ok(executor) => {
91 info!("Auto-selected embedded Python executor");
92 return Ok(Box::new(executor));
93 }
94 Err(e) => {
95 info!(
96 "Embedded executor unavailable ({}), falling back to subprocess",
97 e
98 );
99 }
100 }
101 }
102 }
103 info!("Using subprocess executor (auto-selected)");
104 Ok(Box::new(PythonExecutor::new(python_path)))
105 }
106 }
107}
108
109pub async fn create_pooled_executor(
113 python_path: PathBuf,
114 worker_count: Option<usize>,
115 working_dir: PathBuf,
116) -> Result<Box<dyn TestExecutor>> {
117 let workers = worker_count.unwrap_or_else(num_cpus::get);
118 info!(
119 "Creating pooled executor with {} workers in {}",
120 workers,
121 working_dir.display()
122 );
123
124 let executor = PooledExecutor::new(python_path, workers, working_dir).await?;
125 Ok(Box::new(executor))
126}
127
128#[derive(Debug, Clone, Serialize, Deserialize, Default)]
130pub struct ExecutorConfig {
131 pub workers: Option<u32>,
133 pub maxfail: Option<u32>,
135 pub batch_size: usize,
137 pub test_timeout_secs: u64,
139 pub extra_args: Vec<String>,
141}
142
143impl ExecutorConfig {
144 pub fn new() -> Self {
146 ExecutorConfig {
147 workers: None,
148 maxfail: None,
149 batch_size: Self::optimal_batch_size(),
150 test_timeout_secs: 60,
151 extra_args: Vec::new(),
152 }
153 }
154
155 pub fn optimal_batch_size() -> usize {
158 let cpus = num_cpus::get();
159 (cpus * 50).max(500)
162 }
163}
164
165#[derive(Debug, Clone)]
167pub struct PythonExecutor {
168 python_path: PathBuf,
170 config: ExecutorConfig,
172 processes: Arc<Mutex<HashMap<String, tokio::process::Child>>>,
174}
175
176impl Default for PythonExecutor {
177 fn default() -> Self {
178 PythonExecutor {
179 python_path: PathBuf::from("python"),
180 config: ExecutorConfig::new(),
181 processes: Arc::new(Mutex::new(HashMap::new())),
182 }
183 }
184}
185
186impl PythonExecutor {
187 pub fn new(python_path: PathBuf) -> Self {
189 PythonExecutor {
190 python_path,
191 config: ExecutorConfig::new(),
192 processes: Arc::new(Mutex::new(HashMap::new())),
193 }
194 }
195
196 pub fn configure(&mut self, config: ExecutorConfig) {
198 self.config = config;
199 }
200
201 pub fn python_path(&self) -> &PathBuf {
203 &self.python_path
204 }
205
206 pub async fn run_test(&self, node_id: &str) -> Result<TestResult> {
208 let output = self.run_pytest(&[node_id.to_string()], None).await;
209
210 let outcome = Self::parse_pytest_output(&output);
212 let duration_ms = Self::extract_duration(&output).unwrap_or(0);
213
214 Ok(TestResult {
215 node_id: node_id.to_string(),
216 outcome,
217 duration_ms,
218 message: Self::extract_message(&output),
219 stdout: None,
220 stderr: None,
221 })
222 }
223
224 pub async fn run_tests(&self, node_ids: &[String]) -> Vec<TestResult> {
226 if node_ids.is_empty() {
227 return Vec::new();
228 }
229
230 let batches: Vec<Vec<String>> = node_ids
232 .chunks(self.config.batch_size)
233 .map(|c| c.to_vec())
234 .collect();
235
236 let workers = self.config.workers.unwrap_or(1) as usize;
238
239 if workers > 1 && batches.len() > 1 {
240 let semaphore = Arc::new(tokio::sync::Semaphore::new(workers));
243 let mut handles = Vec::with_capacity(batches.len());
244
245 for batch in batches {
246 let permit = semaphore.clone().acquire_owned().await;
247 let executor = self.clone();
248
249 let handle = tokio::spawn(async move {
250 let _permit = permit; executor.run_batch(&batch).await
252 });
253 handles.push(handle);
254 }
255
256 let mut all_results = Vec::with_capacity(node_ids.len());
258 for handle in handles {
259 match handle.await {
260 Ok(batch_results) => all_results.extend(batch_results),
261 Err(e) => {
262 error!("Batch execution failed: {}", e);
263 }
265 }
266 }
267 all_results
268 } else {
269 let mut results = Vec::with_capacity(node_ids.len());
271 for batch in batches {
272 let batch_results = self.run_batch(&batch).await;
273 results.extend(batch_results);
274 }
275 results
276 }
277 }
278
279 async fn run_batch(&self, node_ids: &[String]) -> Vec<TestResult> {
281 let output = self.run_pytest(node_ids, None).await;
282 self.parse_batch_output(node_ids, output)
283 }
284
285 async fn run_pytest(&self, node_ids: &[String], _output_file: Option<&str>) -> String {
287 if std::env::var("RPYTEST_FAKE_PYTEST").is_ok() {
289 let mut output = String::new();
290 for node_id in node_ids {
291 output.push_str(&format!("{} PASSED\n", node_id));
292 }
293 output.push_str(&format!("{} passed in 0.01s\n", node_ids.len()));
294 return output;
295 }
296
297 let mut args: Vec<String> = vec!["-m".to_string(), "pytest".to_string()];
298
299 for node_id in node_ids {
301 args.push(node_id.clone());
302 }
303
304 args.push("-v".to_string());
307 args.push("--tb=short".to_string());
308 args.push("--no-header".to_string());
309
310 if let Some(maxfail) = self.config.maxfail {
312 args.push("--maxfail".to_string());
313 args.push(maxfail.to_string());
314 }
315
316 args.extend(self.config.extra_args.clone());
318
319 args.push("--capture=no".to_string());
321
322 debug!("Running: {} {:?}", self.python_path.display(), args);
323
324 let mut child = match AsyncCommand::new(&self.python_path)
325 .args(&args)
326 .stdout(Stdio::piped())
327 .stderr(Stdio::piped())
328 .kill_on_drop(true)
329 .spawn()
330 {
331 Ok(child) => child,
332 Err(e) => {
333 error!("Failed to spawn pytest: {}", e);
334 return format!("ERROR: Failed to spawn pytest: {}", e);
335 }
336 };
337
338 let stdout = match child.stdout.take() {
339 Some(stdout) => stdout,
340 None => {
341 error!("Failed to capture stdout");
342 return "ERROR: Failed to capture stdout".to_string();
343 }
344 };
345
346 let stderr = match child.stderr.take() {
347 Some(stderr) => stderr,
348 None => {
349 error!("Failed to capture stderr");
350 return "ERROR: Failed to capture stderr".to_string();
351 }
352 };
353
354 let output = Self::read_output(stdout, stderr).await;
356
357 let status = match child.wait().await {
359 Ok(status) => status,
360 Err(e) => {
361 error!("Failed to wait on pytest: {}", e);
362 return format!("{}\nERROR: Failed to wait on pytest: {}", output, e);
363 }
364 };
365
366 let mut result = output;
367 if !status.success() {
368 result.push_str(&format!("\nExit code: {}", status));
369 }
370
371 result
372 }
373
374 async fn read_output(stdout: ChildStdout, stderr: ChildStderr) -> String {
376 let mut output = String::new();
377
378 let mut stdout_reader = BufReader::new(stdout).lines();
379 let mut stderr_reader = BufReader::new(stderr).lines();
380
381 loop {
382 tokio::select! {
383 line = stdout_reader.next_line() => {
384 match line {
385 Ok(Some(l)) => {
386 output.push_str(&l);
387 output.push('\n');
388 }
389 Ok(None) => break,
390 Err(_) => break,
391 }
392 }
393 line = stderr_reader.next_line() => {
394 match line {
395 Ok(Some(l)) => {
396 output.push_str(&l);
397 output.push('\n');
398 }
399 Ok(None) => {}
400 Err(_) => {}
401 }
402 }
403 }
404 }
405
406 output
407 }
408
409 fn parse_batch_output(&self, node_ids: &[String], output: String) -> Vec<TestResult> {
412 let mut results = Vec::with_capacity(node_ids.len());
413
414 let mut line_outcomes: HashMap<String, TestOutcome> =
416 HashMap::with_capacity(node_ids.len() * 2);
417
418 for line in output.lines() {
420 let mut parts = line.split_whitespace();
422 let test_ref = match parts.next() {
423 Some(t) => t,
424 None => continue,
425 };
426
427 let outcome = parts.find_map(|word| {
429 if word.starts_with("PASSED") {
430 Some(TestOutcome::Passed)
431 } else if word.starts_with("FAILED") {
432 Some(TestOutcome::Failed)
433 } else if word.starts_with("SKIPPED") {
434 Some(TestOutcome::Skipped)
435 } else if word.starts_with("XFAIL") {
436 Some(TestOutcome::Xfail)
437 } else if word.starts_with("XPASS") {
438 Some(TestOutcome::Xpass)
439 } else {
440 None
441 }
442 });
443
444 if let Some(outcome) = outcome {
445 line_outcomes.insert(test_ref.to_string(), outcome.clone());
447 if let Some(pos) = test_ref.rfind("::") {
449 let test_name = &test_ref[pos + 2..];
450 line_outcomes.insert(test_name.to_string(), outcome);
451 }
452 }
453 }
454
455 let summary_passed = output.contains(" passed]") || output.contains(" passed in");
457 let summary_failed = output.contains(" failed]") || output.contains(" failed in");
458 let summary_skipped = output.contains(" skipped]") || output.contains(" skipped in");
459 let has_errors = output.contains(" error]") || output.contains(" errors in");
460
461 for node_id in node_ids {
462 let mut outcome = TestOutcome::Error;
463
464 if let Some(line_outcome) = line_outcomes.get(node_id) {
466 outcome = line_outcome.clone();
467 } else {
468 if let Some(pos) = node_id.find("::") {
470 let test_name = &node_id[pos + 2..];
471 if let Some(line_outcome) = line_outcomes.get(test_name) {
472 outcome = line_outcome.clone();
473 }
474 }
475 }
476
477 if matches!(outcome, TestOutcome::Error) {
479 if summary_passed && !summary_failed {
480 outcome = TestOutcome::Passed;
481 } else if summary_failed && !summary_passed {
482 outcome = TestOutcome::Failed;
483 } else if has_errors {
484 outcome = TestOutcome::Error;
485 } else if summary_skipped {
486 outcome = TestOutcome::Skipped;
487 } else {
488 outcome = TestOutcome::Passed;
490 }
491 }
492
493 results.push(TestResult {
494 node_id: node_id.clone(),
495 outcome,
496 duration_ms: 0,
497 message: None,
498 stdout: None,
499 stderr: None,
500 });
501 }
502
503 results
504 }
505
506 fn parse_pytest_output(output: &str) -> TestOutcome {
508 if output.contains("1 passed") || output.contains("PASSED") {
509 return TestOutcome::Passed;
510 }
511 if output.contains("1 failed") || output.contains("FAILED") {
512 return TestOutcome::Failed;
513 }
514 if output.contains("1 skipped") || output.contains("SKIPPED") {
515 return TestOutcome::Skipped;
516 }
517 if output.contains("ERROR") {
518 return TestOutcome::Error;
519 }
520 TestOutcome::Error
521 }
522
523 fn extract_duration(output: &str) -> Option<u64> {
525 for line in output.lines() {
527 if line.contains("passed") || line.contains("failed") {
528 if let Some(idx) = line.find('[') {
530 if let Some(end_idx) = line[idx..].find(']') {
531 let duration_str = &line[idx + 1..idx + end_idx];
532 if duration_str.contains("s") {
533 if let Ok(seconds) = duration_str.replace("s", "").parse::<f64>() {
534 return Some((seconds * 1000.0) as u64);
535 }
536 }
537 }
538 }
539 }
540 }
541 None
542 }
543
544 fn extract_message(output: &str) -> Option<String> {
546 for line in output.lines() {
548 if line.contains("AssertionError")
549 || line.contains("Error:")
550 || line.contains("FAILED:")
551 {
552 return Some(line.to_string());
553 }
554 }
555 None
556 }
557
558 pub fn kill_all(&self) {
560 let mut processes = self.processes.lock();
561 for (_, child) in processes.iter_mut() {
562 drop(child.kill());
563 }
564 processes.clear();
565 }
566}
567
568#[async_trait]
569impl TestExecutor for PythonExecutor {
570 async fn run_test(&self, node_id: &str) -> Result<TestResult> {
571 PythonExecutor::run_test(self, node_id).await
572 }
573
574 async fn run_tests(&self, node_ids: &[String]) -> Vec<TestResult> {
575 PythonExecutor::run_tests(self, node_ids).await
576 }
577
578 fn configure(&mut self, config: ExecutorConfig) {
579 PythonExecutor::configure(self, config)
580 }
581
582 fn execution_mode(&self) -> &'static str {
583 "subprocess"
584 }
585
586 fn kill_all(&self) {
587 PythonExecutor::kill_all(self)
588 }
589}
590
591#[derive(Debug)]
596pub struct PooledExecutor {
597 pool: Arc<WorkerPool>,
599 scheduler: TestScheduler,
601 config: ExecutorConfig,
603}
604
605impl PooledExecutor {
606 pub async fn new(
608 python_path: PathBuf,
609 worker_count: usize,
610 working_dir: PathBuf,
611 ) -> Result<Self> {
612 let pool = WorkerPool::new(worker_count, python_path, working_dir)
613 .await
614 .map_err(|e| {
615 crate::error::DaemonError::Other(format!("Failed to create worker pool: {}", e))
616 })?;
617
618 Ok(Self {
619 pool,
620 scheduler: TestScheduler::new(),
621 config: ExecutorConfig::new(),
622 })
623 }
624
625 pub fn update_duration(&mut self, node_id: &str, duration_ms: u64) {
627 self.scheduler.update_duration(node_id, duration_ms);
628 }
629
630 pub fn scheduler_mut(&mut self) -> &mut TestScheduler {
632 &mut self.scheduler
633 }
634}
635
636#[async_trait]
637impl TestExecutor for PooledExecutor {
638 async fn run_test(&self, node_id: &str) -> Result<TestResult> {
639 let results = self.run_tests(&[node_id.to_string()]).await;
640 results.into_iter().next().ok_or_else(|| {
641 crate::error::DaemonError::Other(format!("No result for test: {}", node_id))
642 })
643 }
644
645 async fn run_tests(&self, node_ids: &[String]) -> Vec<TestResult> {
646 if node_ids.is_empty() {
647 return Vec::new();
648 }
649
650 let workers = self.config.workers.unwrap_or(num_cpus::get() as u32) as usize;
651
652 const MIN_BATCH_SIZE: usize = 50;
655 let test_count = node_ids.len();
656
657 let actual_workers = if test_count <= MIN_BATCH_SIZE {
659 1 } else {
661 let max_workers_needed = (test_count + MIN_BATCH_SIZE - 1) / MIN_BATCH_SIZE;
663 workers.min(max_workers_needed)
664 };
665
666 let batches = self.scheduler.split_balanced(node_ids, actual_workers);
668
669 info!(
670 "Running {} tests in {} batches (target batch size: {})",
671 node_ids.len(),
672 batches.len(),
673 if batches.is_empty() {
674 0
675 } else {
676 node_ids.len() / batches.len()
677 }
678 );
679
680 self.pool.execute_parallel(batches).await
682 }
683
684 fn configure(&mut self, config: ExecutorConfig) {
685 self.config = config;
686 }
687
688 fn execution_mode(&self) -> &'static str {
689 "pooled"
690 }
691
692 fn kill_all(&self) {
693 }
696}
697
698#[cfg(test)]
699mod tests {
700 use super::*;
701 use std::fs;
702 use tempfile::TempDir;
703
704 #[tokio::test]
705 async fn test_run_simple_test() {
706 let dir = TempDir::new().unwrap();
707 let test_file = dir.path().join("test_example.py");
708 fs::write(&test_file, "def test_simple():\n assert True\n").unwrap();
709
710 let executor = PythonExecutor::new(PathBuf::from("python"));
711 let result = executor.run_test("test_example.py::test_simple").await;
712
713 assert!(result.is_ok());
714 let result = result.unwrap();
715 assert_eq!(result.node_id, "test_example.py::test_simple");
716 }
717
718 #[test]
719 fn test_create_executor_subprocess() {
720 let executor = create_executor(ExecutionMode::Subprocess, PathBuf::from("python"));
721 assert!(executor.is_ok());
722 let executor = executor.unwrap();
723 assert_eq!(executor.execution_mode(), "subprocess");
724 }
725
726 #[test]
727 fn test_create_executor_auto() {
728 let executor = create_executor(ExecutionMode::Auto, PathBuf::from("python"));
730 assert!(executor.is_ok());
731 let executor = executor.unwrap();
732 assert!(
734 executor.execution_mode() == "embedded" || executor.execution_mode() == "subprocess"
735 );
736 }
737
738 #[cfg(feature = "embedded-python")]
739 #[test]
740 fn test_create_executor_embedded() {
741 if crate::embedded::EmbeddedExecutor::is_available() {
743 let executor = create_executor(ExecutionMode::Embedded, PathBuf::from("python"));
744 assert!(executor.is_ok());
745 let executor = executor.unwrap();
746 assert_eq!(executor.execution_mode(), "embedded");
747 }
748 }
749
750 #[test]
751 fn test_executor_config_default() {
752 let config = ExecutorConfig::new();
753 assert!(config.batch_size > 0);
754 assert_eq!(config.test_timeout_secs, 60);
755 assert!(config.workers.is_none());
756 assert!(config.maxfail.is_none());
757 }
758
759 #[test]
760 fn test_python_executor_configure() {
761 let mut executor = PythonExecutor::new(PathBuf::from("python"));
762 let mut config = ExecutorConfig::new();
763 config.workers = Some(4);
764 config.maxfail = Some(10);
765 executor.configure(config);
766 assert_eq!(executor.execution_mode(), "subprocess");
768 }
769
770 #[test]
771 fn test_python_executor_default() {
772 let executor = PythonExecutor::default();
773 assert_eq!(executor.python_path(), &PathBuf::from("python"));
774 }
775
776 #[test]
777 fn test_parse_batch_output_passed() {
778 let executor = PythonExecutor::default();
779 let output = "test_file.py::test_one PASSED\ntest_file.py::test_two PASSED\n";
780 let node_ids = vec![
781 "test_file.py::test_one".to_string(),
782 "test_file.py::test_two".to_string(),
783 ];
784
785 let results = executor.parse_batch_output(&node_ids, output.to_string());
786 assert_eq!(results.len(), 2);
787 assert!(matches!(results[0].outcome, TestOutcome::Passed));
788 assert!(matches!(results[1].outcome, TestOutcome::Passed));
789 }
790
791 #[test]
792 fn test_parse_batch_output_failed() {
793 let executor = PythonExecutor::default();
794 let output = "test_file.py::test_fail FAILED\n";
795 let node_ids = vec!["test_file.py::test_fail".to_string()];
796
797 let results = executor.parse_batch_output(&node_ids, output.to_string());
798 assert_eq!(results.len(), 1);
799 assert!(matches!(results[0].outcome, TestOutcome::Failed));
800 }
801
802 #[test]
803 fn test_parse_batch_output_skipped() {
804 let executor = PythonExecutor::default();
805 let output = "test_file.py::test_skip SKIPPED\n";
806 let node_ids = vec!["test_file.py::test_skip".to_string()];
807
808 let results = executor.parse_batch_output(&node_ids, output.to_string());
809 assert_eq!(results.len(), 1);
810 assert!(matches!(results[0].outcome, TestOutcome::Skipped));
811 }
812
813 #[test]
814 fn test_parse_batch_output_xfail() {
815 let executor = PythonExecutor::default();
816 let output = "test_file.py::test_xfail XFAIL\n";
817 let node_ids = vec!["test_file.py::test_xfail".to_string()];
818
819 let results = executor.parse_batch_output(&node_ids, output.to_string());
820 assert_eq!(results.len(), 1);
821 assert!(matches!(results[0].outcome, TestOutcome::Xfail));
822 }
823
824 #[test]
825 fn test_parse_batch_output_xpass() {
826 let executor = PythonExecutor::default();
827 let output = "test_file.py::test_xpass XPASS\n";
828 let node_ids = vec!["test_file.py::test_xpass".to_string()];
829
830 let results = executor.parse_batch_output(&node_ids, output.to_string());
831 assert_eq!(results.len(), 1);
832 assert!(matches!(results[0].outcome, TestOutcome::Xpass));
833 }
834
835 #[test]
836 fn test_parse_batch_output_mixed() {
837 let executor = PythonExecutor::default();
838 let output = "test_file.py::test_pass PASSED\ntest_file.py::test_fail FAILED\ntest_file.py::test_skip SKIPPED\n";
839 let node_ids = vec![
840 "test_file.py::test_pass".to_string(),
841 "test_file.py::test_fail".to_string(),
842 "test_file.py::test_skip".to_string(),
843 ];
844
845 let results = executor.parse_batch_output(&node_ids, output.to_string());
846 assert_eq!(results.len(), 3);
847 assert!(matches!(results[0].outcome, TestOutcome::Passed));
848 assert!(matches!(results[1].outcome, TestOutcome::Failed));
849 assert!(matches!(results[2].outcome, TestOutcome::Skipped));
850 }
851
852 #[test]
853 fn test_parse_batch_output_summary_fallback() {
854 let executor = PythonExecutor::default();
855 let output = "= 1 passed in 0.01s =\n";
857 let node_ids = vec!["test_file.py::test_one".to_string()];
858
859 let results = executor.parse_batch_output(&node_ids, output.to_string());
860 assert_eq!(results.len(), 1);
861 assert!(matches!(results[0].outcome, TestOutcome::Passed));
862 }
863
864 #[test]
865 fn test_parse_pytest_output() {
866 assert!(matches!(
867 PythonExecutor::parse_pytest_output("1 passed"),
868 TestOutcome::Passed
869 ));
870 assert!(matches!(
871 PythonExecutor::parse_pytest_output("PASSED"),
872 TestOutcome::Passed
873 ));
874 assert!(matches!(
875 PythonExecutor::parse_pytest_output("1 failed"),
876 TestOutcome::Failed
877 ));
878 assert!(matches!(
879 PythonExecutor::parse_pytest_output("FAILED"),
880 TestOutcome::Failed
881 ));
882 assert!(matches!(
883 PythonExecutor::parse_pytest_output("1 skipped"),
884 TestOutcome::Skipped
885 ));
886 assert!(matches!(
887 PythonExecutor::parse_pytest_output("ERROR"),
888 TestOutcome::Error
889 ));
890 }
891
892 #[test]
893 fn test_extract_duration() {
894 assert_eq!(
895 PythonExecutor::extract_duration("= 1 passed in [0.12s] ="),
896 Some(120)
897 );
898 assert_eq!(
899 PythonExecutor::extract_duration("= 1 failed in [1.5s] ="),
900 Some(1500)
901 );
902 assert_eq!(PythonExecutor::extract_duration("some random text"), None);
903 }
904
905 #[test]
906 fn test_extract_message() {
907 assert_eq!(
908 PythonExecutor::extract_message("AssertionError: expected 1 got 2"),
909 Some("AssertionError: expected 1 got 2".to_string())
910 );
911 assert_eq!(
912 PythonExecutor::extract_message("Error: something failed"),
913 Some("Error: something failed".to_string())
914 );
915 assert_eq!(PythonExecutor::extract_message("test passed ok"), None);
916 }
917
918 #[test]
919 fn test_python_executor_kill_all() {
920 let executor = PythonExecutor::default();
921 executor.kill_all();
923 }
924
925 #[test]
926 fn test_executor_config_optimal_batch_size() {
927 let batch_size = ExecutorConfig::optimal_batch_size();
928 assert!(batch_size >= 500);
930 }
931
932 #[tokio::test]
933 async fn test_run_tests_empty() {
934 let executor = PythonExecutor::default();
935 let results = executor.run_tests(&[]).await;
936 assert!(results.is_empty());
937 }
938}