Skip to main content

rpytest_daemon/
executor.rs

1//! Python subprocess executor for running pytest tests.
2//!
3//! This module provides two execution strategies:
4//! - `PythonExecutor`: Subprocess-based execution (default, always available)
5//! - `EmbeddedExecutor`: PyO3-based embedded execution (requires `embedded-python` feature)
6//!
7//! Use `create_executor()` to automatically select the best available executor.
8
9use 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/// Trait for test executors providing a unified interface for running tests.
25#[async_trait]
26pub trait TestExecutor: Send + Sync + std::fmt::Debug {
27    /// Run a single test and return the result.
28    async fn run_test(&self, node_id: &str) -> Result<TestResult>;
29
30    /// Run multiple tests and return all results.
31    async fn run_tests(&self, node_ids: &[String]) -> Vec<TestResult>;
32
33    /// Configure the executor with the given settings.
34    fn configure(&mut self, config: ExecutorConfig);
35
36    /// Get the execution mode name for logging.
37    fn execution_mode(&self) -> &'static str;
38
39    /// Kill all running processes.
40    fn kill_all(&self);
41}
42
43/// Create the best available executor based on the requested mode.
44///
45/// - `Embedded`: Use PyO3 embedded Python (fails if feature not enabled or Python unavailable)
46/// - `Subprocess`: Always use subprocess execution
47/// - `Pooled`: Use worker pool (requires async - use `create_pooled_executor` instead)
48/// - `Auto`: Try embedded first, fall back to subprocess
49pub 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                // Pass python_path to EmbeddedExecutor for virtualenv support
55                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; // Silence unused variable warning
69                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            // Pooled mode requires async initialization - use create_pooled_executor instead
80            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                    // Pass python_path to EmbeddedExecutor for virtualenv support
89                    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
109/// Create a pooled executor with warm workers.
110///
111/// This is async because it needs to spawn and wait for worker processes.
112pub 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/// Executor configuration.
129#[derive(Debug, Clone, Serialize, Deserialize, Default)]
130pub struct ExecutorConfig {
131    /// Number of parallel workers
132    pub workers: Option<u32>,
133    /// Stop after N failures
134    pub maxfail: Option<u32>,
135    /// Batch size for grouping tests
136    pub batch_size: usize,
137    /// Timeout per test in seconds
138    pub test_timeout_secs: u64,
139    /// Extra pytest arguments
140    pub extra_args: Vec<String>,
141}
142
143impl ExecutorConfig {
144    /// Create default config with adaptive batch sizing.
145    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    /// Calculate optimal batch size based on CPU count.
156    /// Larger batches reduce process spawn overhead but too large = poor parallelism.
157    pub fn optimal_batch_size() -> usize {
158        let cpus = num_cpus::get();
159        // Scale batch size with CPU count, minimum 500 for fewer subprocess spawns
160        // Each subprocess has ~200-300ms startup overhead
161        (cpus * 50).max(500)
162    }
163}
164
165/// Executor that runs tests via Python subprocess.
166#[derive(Debug, Clone)]
167pub struct PythonExecutor {
168    /// Python interpreter path
169    python_path: PathBuf,
170    /// Executor configuration
171    config: ExecutorConfig,
172    /// Running processes
173    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    /// Create a new executor.
188    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    /// Configure the executor.
197    pub fn configure(&mut self, config: ExecutorConfig) {
198        self.config = config;
199    }
200
201    /// Get Python path.
202    pub fn python_path(&self) -> &PathBuf {
203        &self.python_path
204    }
205
206    /// Run a single test.
207    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        // Parse output to determine outcome
211        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    /// Run multiple tests in batch.
225    pub async fn run_tests(&self, node_ids: &[String]) -> Vec<TestResult> {
226        if node_ids.is_empty() {
227            return Vec::new();
228        }
229
230        // Split into batches
231        let batches: Vec<Vec<String>> = node_ids
232            .chunks(self.config.batch_size)
233            .map(|c| c.to_vec())
234            .collect();
235
236        // Run batches concurrently if workers > 1
237        let workers = self.config.workers.unwrap_or(1) as usize;
238
239        if workers > 1 && batches.len() > 1 {
240            // Parallel execution using tokio's native concurrency
241            // Limit concurrent batches to worker count using semaphore
242            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; // Hold permit until batch completes
251                    executor.run_batch(&batch).await
252                });
253                handles.push(handle);
254            }
255
256            // Collect results from all handles
257            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                        // Continue with other batches
264                    }
265                }
266            }
267            all_results
268        } else {
269            // Sequential execution
270            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    /// Run a batch of tests.
280    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    /// Run pytest with the given arguments.
286    async fn run_pytest(&self, node_ids: &[String], _output_file: Option<&str>) -> String {
287        // Allow tests to bypass spawning a real Python interpreter.
288        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        // Add node IDs
300        for node_id in node_ids {
301            args.push(node_id.clone());
302        }
303
304        // Add configuration
305        // Use -v for verbose output with full test names (needed for parsing outcomes)
306        args.push("-v".to_string());
307        args.push("--tb=short".to_string());
308        args.push("--no-header".to_string());
309
310        // Add maxfail
311        if let Some(maxfail) = self.config.maxfail {
312            args.push("--maxfail".to_string());
313            args.push(maxfail.to_string());
314        }
315
316        // Add extra args
317        args.extend(self.config.extra_args.clone());
318
319        // Allow output capture
320        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        // Read output
355        let output = Self::read_output(stdout, stderr).await;
356
357        // Wait for process to complete
358        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    /// Read stdout and stderr from a child process.
375    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    /// Parse batch output into individual test results.
410    /// Optimized single-pass implementation that extracts all outcomes efficiently.
411    fn parse_batch_output(&self, node_ids: &[String], output: String) -> Vec<TestResult> {
412        let mut results = Vec::with_capacity(node_ids.len());
413
414        // Pre-allocate HashMap with expected capacity (2x for test name variants)
415        let mut line_outcomes: HashMap<String, TestOutcome> =
416            HashMap::with_capacity(node_ids.len() * 2);
417
418        // Single pass: check all patterns at once for each line
419        for line in output.lines() {
420            // Split once and check the status word
421            let mut parts = line.split_whitespace();
422            let test_ref = match parts.next() {
423                Some(t) => t,
424                None => continue,
425            };
426
427            // Find outcome from the status word (usually second token)
428            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                // Store outcome by full reference
446                line_outcomes.insert(test_ref.to_string(), outcome.clone());
447                // Also store by test name (after last ::) for fallback matching
448                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        // Check summary for overall counts
456        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            // Try to find outcome from line-by-line parsing
465            if let Some(line_outcome) = line_outcomes.get(node_id) {
466                outcome = line_outcome.clone();
467            } else {
468                // Try matching just the test name (after ::)
469                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 still not found, use summary as fallback
478            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                    // Default to passed if we can't determine otherwise
489                    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    /// Parse pytest output to determine outcome.
507    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    /// Extract duration from pytest output.
524    fn extract_duration(output: &str) -> Option<u64> {
525        // Look for patterns like "0.12s" or "12ms"
526        for line in output.lines() {
527            if line.contains("passed") || line.contains("failed") {
528                // Try to extract duration
529                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    /// Extract error message from output.
545    fn extract_message(output: &str) -> Option<String> {
546        // Look for assertion errors or exceptions
547        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    /// Kill all running processes.
559    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/// Executor using a warm worker pool for maximum throughput.
592///
593/// Pre-spawns Python worker processes that stay alive between test runs,
594/// eliminating subprocess spawn overhead (200-300ms per batch).
595#[derive(Debug)]
596pub struct PooledExecutor {
597    /// Worker pool
598    pool: Arc<WorkerPool>,
599    /// Test scheduler for load balancing
600    scheduler: TestScheduler,
601    /// Configuration
602    config: ExecutorConfig,
603}
604
605impl PooledExecutor {
606    /// Create a new pooled executor.
607    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    /// Update duration history for a test.
626    pub fn update_duration(&mut self, node_id: &str, duration_ms: u64) {
627        self.scheduler.update_duration(node_id, duration_ms);
628    }
629
630    /// Get the scheduler for external updates.
631    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        // Use smarter batching: minimum batch size of 50 tests to avoid pytest.main() overhead
653        // Each pytest.main() call has ~50-100ms overhead, so small batches are inefficient
654        const MIN_BATCH_SIZE: usize = 50;
655        let test_count = node_ids.len();
656
657        // Calculate how many workers we actually need
658        let actual_workers = if test_count <= MIN_BATCH_SIZE {
659            1 // Run all in single worker for small test sets
660        } else {
661            // Use enough workers to keep batches >= MIN_BATCH_SIZE
662            let max_workers_needed = (test_count + MIN_BATCH_SIZE - 1) / MIN_BATCH_SIZE;
663            workers.min(max_workers_needed)
664        };
665
666        // Use load-balanced batching
667        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        // Execute batches in parallel using the worker pool
681        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        // Workers are managed by the pool - they'll be cleaned up on drop
694        // For now, we don't forcefully kill them mid-execution
695    }
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        // Auto mode should succeed (either embedded or subprocess)
729        let executor = create_executor(ExecutionMode::Auto, PathBuf::from("python"));
730        assert!(executor.is_ok());
731        let executor = executor.unwrap();
732        // Should be either "embedded" or "subprocess"
733        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        // Only test if embedded Python is available
742        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        // Config is stored internally - verify it was set
767        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        // Output without explicit test result lines, but with summary
856        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        // Should not panic even with no processes
922        executor.kill_all();
923    }
924
925    #[test]
926    fn test_executor_config_optimal_batch_size() {
927        let batch_size = ExecutorConfig::optimal_batch_size();
928        // Minimum batch size is 500 to reduce subprocess spawn overhead
929        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}