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(
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                // Pass python_path to EmbeddedExecutor for virtualenv support
58                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; // Silence unused variable warning
72                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            // Pooled mode requires async initialization - use create_pooled_executor instead
83            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                    // Pass python_path to EmbeddedExecutor for virtualenv support
92                    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
112/// Create a pooled executor with warm workers.
113///
114/// This is async because it needs to spawn and wait for worker processes.
115pub 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/// Executor configuration.
128#[derive(Debug, Clone, Serialize, Deserialize, Default)]
129pub struct ExecutorConfig {
130    /// Number of parallel workers
131    pub workers: Option<u32>,
132    /// Stop after N failures
133    pub maxfail: Option<u32>,
134    /// Batch size for grouping tests
135    pub batch_size: usize,
136    /// Timeout per test in seconds
137    pub test_timeout_secs: u64,
138    /// Extra pytest arguments
139    pub extra_args: Vec<String>,
140}
141
142impl ExecutorConfig {
143    /// Create default config with adaptive batch sizing.
144    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    /// Calculate optimal batch size based on CPU count.
155    /// Larger batches reduce process spawn overhead but too large = poor parallelism.
156    pub fn optimal_batch_size() -> usize {
157        let cpus = num_cpus::get();
158        // Scale batch size with CPU count, minimum 500 for fewer subprocess spawns
159        // Each subprocess has ~200-300ms startup overhead
160        (cpus * 50).max(500)
161    }
162}
163
164/// Executor that runs tests via Python subprocess.
165#[derive(Debug, Clone)]
166pub struct PythonExecutor {
167    /// Python interpreter path
168    python_path: PathBuf,
169    /// Executor configuration
170    config: ExecutorConfig,
171    /// Running processes
172    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    /// Create a new executor.
187    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    /// Configure the executor.
196    pub fn configure(&mut self, config: ExecutorConfig) {
197        self.config = config;
198    }
199
200    /// Get Python path.
201    pub fn python_path(&self) -> &PathBuf {
202        &self.python_path
203    }
204
205    /// Run a single test.
206    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        // Parse output to determine outcome
210        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    /// Run multiple tests in batch.
224    pub async fn run_tests(&self, node_ids: &[String]) -> Vec<TestResult> {
225        if node_ids.is_empty() {
226            return Vec::new();
227        }
228
229        // Split into batches
230        let batches: Vec<Vec<String>> = node_ids
231            .chunks(self.config.batch_size)
232            .map(|c| c.to_vec())
233            .collect();
234
235        // Run batches concurrently if workers > 1
236        let workers = self.config.workers.unwrap_or(1) as usize;
237
238        if workers > 1 && batches.len() > 1 {
239            // Parallel execution using tokio's native concurrency
240            // Limit concurrent batches to worker count using semaphore
241            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; // Hold permit until batch completes
250                    executor.run_batch(&batch).await
251                });
252                handles.push(handle);
253            }
254
255            // Collect results from all handles
256            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                        // Continue with other batches
263                    }
264                }
265            }
266            all_results
267        } else {
268            // Sequential execution
269            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    /// Run a batch of tests.
279    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    /// Run pytest with the given arguments.
285    async fn run_pytest(&self, node_ids: &[String], _output_file: Option<&str>) -> String {
286        // Allow tests to bypass spawning a real Python interpreter.
287        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        // Add node IDs
299        for node_id in node_ids {
300            args.push(node_id.clone());
301        }
302
303        // Add configuration
304        // Use -v for verbose output with full test names (needed for parsing outcomes)
305        args.push("-v".to_string());
306        args.push("--tb=short".to_string());
307        args.push("--no-header".to_string());
308
309        // Add maxfail
310        if let Some(maxfail) = self.config.maxfail {
311            args.push("--maxfail".to_string());
312            args.push(maxfail.to_string());
313        }
314
315        // Add extra args
316        args.extend(self.config.extra_args.clone());
317
318        // Allow output capture
319        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        // Read output
354        let output = Self::read_output(stdout, stderr).await;
355
356        // Wait for process to complete
357        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    /// Read stdout and stderr from a child process.
374    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    /// Parse batch output into individual test results.
409    /// Optimized single-pass implementation that extracts all outcomes efficiently.
410    fn parse_batch_output(&self, node_ids: &[String], output: String) -> Vec<TestResult> {
411        let mut results = Vec::with_capacity(node_ids.len());
412
413        // Pre-allocate HashMap with expected capacity (2x for test name variants)
414        let mut line_outcomes: HashMap<String, TestOutcome> =
415            HashMap::with_capacity(node_ids.len() * 2);
416
417        // Single pass: check all patterns at once for each line
418        for line in output.lines() {
419            // Split once and check the status word
420            let mut parts = line.split_whitespace();
421            let test_ref = match parts.next() {
422                Some(t) => t,
423                None => continue,
424            };
425
426            // Find outcome from the status word (usually second token)
427            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                // Store outcome by full reference
445                line_outcomes.insert(test_ref.to_string(), outcome.clone());
446                // Also store by test name (after last ::) for fallback matching
447                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        // Check summary for overall counts
455        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            // Try to find outcome from line-by-line parsing
464            if let Some(line_outcome) = line_outcomes.get(node_id) {
465                outcome = line_outcome.clone();
466            } else {
467                // Try matching just the test name (after ::)
468                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 still not found, use summary as fallback
477            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                    // Default to passed if we can't determine otherwise
488                    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    /// Parse pytest output to determine outcome.
506    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    /// Extract duration from pytest output.
523    fn extract_duration(output: &str) -> Option<u64> {
524        // Look for patterns like "0.12s" or "12ms"
525        for line in output.lines() {
526            if line.contains("passed") || line.contains("failed") {
527                // Try to extract duration
528                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    /// Extract error message from output.
544    fn extract_message(output: &str) -> Option<String> {
545        // Look for assertion errors or exceptions
546        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    /// Kill all running processes.
558    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/// Executor using a warm worker pool for maximum throughput.
591///
592/// Pre-spawns Python worker processes that stay alive between test runs,
593/// eliminating subprocess spawn overhead (200-300ms per batch).
594#[derive(Debug)]
595pub struct PooledExecutor {
596    /// Worker pool
597    pool: Arc<WorkerPool>,
598    /// Test scheduler for load balancing
599    scheduler: TestScheduler,
600    /// Configuration
601    config: ExecutorConfig,
602}
603
604impl PooledExecutor {
605    /// Create a new pooled executor.
606    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    /// Update duration history for a test.
619    pub fn update_duration(&mut self, node_id: &str, duration_ms: u64) {
620        self.scheduler.update_duration(node_id, duration_ms);
621    }
622
623    /// Get the scheduler for external updates.
624    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        // Use smarter batching: minimum batch size of 50 tests to avoid pytest.main() overhead
646        // Each pytest.main() call has ~50-100ms overhead, so small batches are inefficient
647        const MIN_BATCH_SIZE: usize = 50;
648        let test_count = node_ids.len();
649
650        // Calculate how many workers we actually need
651        let actual_workers = if test_count <= MIN_BATCH_SIZE {
652            1 // Run all in single worker for small test sets
653        } else {
654            // Use enough workers to keep batches >= MIN_BATCH_SIZE
655            let max_workers_needed = (test_count + MIN_BATCH_SIZE - 1) / MIN_BATCH_SIZE;
656            workers.min(max_workers_needed)
657        };
658
659        // Use load-balanced batching
660        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        // Execute batches in parallel using the worker pool
670        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        // Workers are managed by the pool - they'll be cleaned up on drop
683        // For now, we don't forcefully kill them mid-execution
684    }
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        // Auto mode should succeed (either embedded or subprocess)
718        let executor = create_executor(ExecutionMode::Auto, PathBuf::from("python"));
719        assert!(executor.is_ok());
720        let executor = executor.unwrap();
721        // Should be either "embedded" or "subprocess"
722        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        // Only test if embedded Python is available
732        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        // Config is stored internally - verify it was set
757        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        // Output without explicit test result lines, but with summary
846        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        // Should not panic even with no processes
918        executor.kill_all();
919    }
920
921    #[test]
922    fn test_executor_config_optimal_batch_size() {
923        let batch_size = ExecutorConfig::optimal_batch_size();
924        // Minimum batch size is 500 to reduce subprocess spawn overhead
925        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}