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