Skip to main content

rpytest_daemon/
embedded.rs

1//! Embedded Python execution using PyO3.
2//!
3//! This module provides native Python execution by embedding the Python interpreter
4//! directly into the Rust daemon, eliminating subprocess overhead.
5//!
6//! Key features:
7//! - Direct pytest.main() calls without subprocess spawn
8//! - Streaming results via custom pytest plugin
9//! - Module cache cleanup between runs
10//! - Signal handler and event loop management
11//! - Python 3.8+ support via abi3 stable ABI
12
13use crate::error::{DaemonError, Result};
14use crate::executor::{ExecutorConfig, TestExecutor};
15use crate::models::{TestOutcome, TestResult};
16use async_trait::async_trait;
17use parking_lot::Mutex;
18use pyo3::prelude::*;
19use pyo3::types::{PyDict, PyList, PyModule};
20use std::path::PathBuf;
21use std::sync::atomic::{AtomicBool, Ordering};
22use std::sync::{Arc, OnceLock};
23use tracing::{debug, info, warn};
24
25/// Global cache for compiled plugin class - avoids recompiling Python on every run
26static CACHED_PLUGIN_CLASS: OnceLock<Py<PyAny>> = OnceLock::new();
27
28/// Track if this is the first test run (skip cleanup on first run)
29static FIRST_RUN: AtomicBool = AtomicBool::new(true);
30
31/// Python plugin code for collecting test results.
32/// This plugin hooks into pytest's reporting system to stream results to Rust.
33/// OPTIMIZED: Batches results locally and sends them all at once in pytest_sessionfinish
34const COLLECTOR_PLUGIN: &str = r#"
35import pytest
36
37class RpytestCollectorPlugin:
38    """Pytest plugin that collects results and streams them to Rust."""
39
40    def __init__(self, collector):
41        self.collector = collector
42        self._collected_count = 0
43        self._passed = 0
44        self._failed = 0
45        self._skipped = 0
46        self._errors = 0
47        # OPTIMIZATION: Batch results locally instead of calling Rust for each test
48        self._results_batch = []
49
50    def pytest_runtest_logreport(self, report):
51        """Called for each test phase (setup, call, teardown)."""
52        # Only record the 'call' phase for test outcomes
53        # setup/teardown phases are tracked separately
54        if report.when == 'call':
55            # Batch locally instead of calling Rust per-result
56            self._results_batch.append((
57                report.nodeid,
58                report.outcome,
59                report.duration,
60                getattr(report, 'longreprtext', None)
61            ))
62
63            # Track summary counts
64            if report.outcome == 'passed':
65                self._passed += 1
66            elif report.outcome == 'failed':
67                self._failed += 1
68            elif report.outcome == 'skipped':
69                self._skipped += 1
70        elif report.when == 'setup' and report.outcome == 'skipped':
71            # Handle skip during setup (e.g., skip marker)
72            self._results_batch.append((
73                report.nodeid,
74                'skipped',
75                report.duration,
76                getattr(report, 'longreprtext', None)
77            ))
78            self._skipped += 1
79        elif report.when in ('setup', 'teardown') and report.outcome == 'failed':
80            # Handle errors during setup/teardown
81            self._results_batch.append((
82                report.nodeid,
83                'error',
84                report.duration,
85                getattr(report, 'longreprtext', None)
86            ))
87            self._errors += 1
88
89    def pytest_collection_finish(self, session):
90        """Called after collection is complete."""
91        self._collected_count = len(session.items)
92        self.collector.set_collected_count(self._collected_count)
93
94    def pytest_sessionfinish(self, session, exitstatus):
95        """Called after the entire session finishes."""
96        # Single Rust call with all results (instead of one per test)
97        if self._results_batch:
98            self.collector.report_batch(self._results_batch)
99        self.collector.set_exit_status(exitstatus)
100"#;
101
102/// Rust-side result collector that receives results from the pytest plugin.
103#[pyclass]
104pub struct RustResultCollector {
105    results: Arc<Mutex<Vec<TestResult>>>,
106    collected_count: Arc<Mutex<usize>>,
107    exit_status: Arc<Mutex<i32>>,
108}
109
110#[pymethods]
111impl RustResultCollector {
112    #[new]
113    fn new() -> Self {
114        RustResultCollector {
115            results: Arc::new(Mutex::new(Vec::new())),
116            collected_count: Arc::new(Mutex::new(0)),
117            exit_status: Arc::new(Mutex::new(-1)),
118        }
119    }
120
121    /// Called by pytest hook for each test result.
122    #[pyo3(signature = (nodeid, outcome, duration, message=None))]
123    fn report(
124        &self,
125        nodeid: String,
126        outcome: String,
127        duration: f64,
128        message: Option<String>,
129    ) {
130        let test_outcome = match outcome.as_str() {
131            "passed" => TestOutcome::Passed,
132            "failed" => TestOutcome::Failed,
133            "skipped" => TestOutcome::Skipped,
134            "error" => TestOutcome::Error,
135            "xfail" => TestOutcome::Xfail,
136            "xpass" => TestOutcome::Xpass,
137            _ => TestOutcome::Error,
138        };
139
140        let result = TestResult {
141            node_id: nodeid,
142            outcome: test_outcome,
143            duration_ms: (duration * 1000.0) as u64,
144            message,
145            stdout: None,
146            stderr: None,
147        };
148
149        self.results.lock().push(result);
150    }
151
152    /// Set the total collected test count.
153    fn set_collected_count(&self, count: usize) {
154        *self.collected_count.lock() = count;
155    }
156
157    /// Set the pytest exit status.
158    fn set_exit_status(&self, status: i32) {
159        *self.exit_status.lock() = status;
160    }
161
162    /// OPTIMIZATION: Receive all results in a single batch (reduces FFI overhead)
163    fn report_batch(&self, results: Vec<(String, String, f64, Option<String>)>) {
164        let mut lock = self.results.lock();
165        for (nodeid, outcome, duration, message) in results {
166            let test_outcome = match outcome.as_str() {
167                "passed" => TestOutcome::Passed,
168                "failed" => TestOutcome::Failed,
169                "skipped" => TestOutcome::Skipped,
170                "error" => TestOutcome::Error,
171                "xfail" => TestOutcome::Xfail,
172                "xpass" => TestOutcome::Xpass,
173                _ => TestOutcome::Error,
174            };
175
176            lock.push(TestResult {
177                node_id: nodeid,
178                outcome: test_outcome,
179                duration_ms: (duration * 1000.0) as u64,
180                message,
181                stdout: None,
182                stderr: None,
183            });
184        }
185    }
186}
187
188impl RustResultCollector {
189    /// Extract all collected results.
190    pub fn take_results(&self) -> Vec<TestResult> {
191        std::mem::take(&mut *self.results.lock())
192    }
193
194    /// Get the collected test count.
195    pub fn collected_count(&self) -> usize {
196        *self.collected_count.lock()
197    }
198
199    /// Get the pytest exit status.
200    pub fn exit_status(&self) -> i32 {
201        *self.exit_status.lock()
202    }
203}
204
205/// Configuration for the embedded executor.
206#[derive(Debug, Clone, Default)]
207pub struct EmbeddedExecutorConfig {
208    /// Number of parallel workers (uses xdist if > 1)
209    pub workers: Option<u32>,
210    /// Stop after N failures
211    pub maxfail: Option<u32>,
212    /// Timeout per test in seconds
213    pub test_timeout_secs: u64,
214    /// Extra pytest arguments
215    pub extra_args: Vec<String>,
216}
217
218/// Embedded Python executor that runs pytest directly without subprocess.
219#[derive(Debug)]
220pub struct EmbeddedExecutor {
221    config: EmbeddedExecutorConfig,
222    /// Track whether Python has been initialized
223    initialized: bool,
224    /// Python version info
225    python_version: Option<(u8, u8, u8)>,
226    /// Path to Python interpreter (used to derive virtualenv site-packages)
227    python_path: Option<PathBuf>,
228}
229
230impl EmbeddedExecutor {
231    /// Create a new embedded executor with optional python_path for virtualenv support.
232    pub fn new(python_path: Option<PathBuf>) -> Result<Self> {
233        let mut executor = EmbeddedExecutor {
234            config: EmbeddedExecutorConfig::default(),
235            initialized: false,
236            python_version: None,
237            python_path,
238        };
239
240        // Initialize and validate Python
241        executor.initialize()?;
242
243        Ok(executor)
244    }
245
246    /// Configure the executor.
247    pub fn configure(&mut self, config: EmbeddedExecutorConfig) {
248        self.config = config;
249    }
250
251    /// Initialize embedded Python and validate environment.
252    fn initialize(&mut self) -> Result<()> {
253        Python::with_gil(|py| {
254            // Get Python version
255            let version = py.version_info();
256            self.python_version = Some((version.major, version.minor, version.patch));
257
258            // Validate minimum version (3.8+)
259            if version.major < 3 || (version.major == 3 && version.minor < 8) {
260                return Err(DaemonError::Other(format!(
261                    "Python 3.8+ required, found {}.{}.{}",
262                    version.major, version.minor, version.patch
263                )));
264            }
265
266            // FIRST: Setup sys.path with virtualenv packages
267            // This must happen BEFORE importing pytest so we can find it in the venv
268            self.setup_python_path(py)?;
269
270            // THEN: Verify pytest is available (now it can be found in venv)
271            py.import_bound("pytest").map_err(|e| {
272                DaemonError::Other(format!("pytest not installed or not importable: {}", e))
273            })?;
274
275            self.initialized = true;
276            info!(
277                "Embedded Python {}.{}.{} initialized successfully",
278                version.major, version.minor, version.patch
279            );
280
281            Ok(())
282        })
283    }
284
285    /// Setup Python path to include virtual environment packages.
286    ///
287    /// Priority order:
288    /// 1. Derive site-packages from stored python_path (e.g., .venv/bin/python → .venv/lib/pythonX.Y/site-packages)
289    /// 2. Use VIRTUAL_ENV environment variable as fallback
290    ///
291    /// Note: Handles version mismatches by scanning for any pythonX.Y directory
292    fn setup_python_path(&self, py: Python) -> Result<()> {
293        let sys = py.import_bound("sys").map_err(|e| {
294            DaemonError::Other(format!("Failed to import sys: {}", e))
295        })?;
296
297        let path = sys.getattr("path").map_err(|e| {
298            DaemonError::Other(format!("Failed to get sys.path: {}", e))
299        })?;
300
301        let path_list: &Bound<PyList> = path.downcast().map_err(|e| {
302            DaemonError::Other(format!("sys.path is not a list: {}", e))
303        })?;
304
305        // Priority 1: Use stored python_path to derive site-packages
306        // python_path is like /path/to/.venv/bin/python
307        // Derive: /path/to/.venv/lib/pythonX.Y/site-packages
308        if let Some(ref python_path) = self.python_path {
309            // Go up from bin/python to venv root
310            if let Some(venv_root) = python_path.parent().and_then(|p| p.parent()) {
311                if let Some(site_packages) = Self::find_site_packages(venv_root) {
312                    let sp_str = site_packages.to_string_lossy().to_string();
313                    if !path_list.contains(&sp_str).unwrap_or(false) {
314                        path_list.insert(0, &sp_str).map_err(|e| {
315                            DaemonError::Other(format!("Failed to insert into sys.path: {}", e))
316                        })?;
317                        debug!("Added {} to sys.path from python_path", sp_str);
318                    }
319                }
320            }
321        }
322
323        // Priority 2: Check VIRTUAL_ENV environment variable (fallback)
324        if let Ok(venv) = std::env::var("VIRTUAL_ENV") {
325            let venv_root = std::path::Path::new(&venv);
326            if let Some(site_packages) = Self::find_site_packages(venv_root) {
327                let sp_str = site_packages.to_string_lossy().to_string();
328                if !path_list.contains(&sp_str).unwrap_or(false) {
329                    path_list.insert(0, &sp_str).map_err(|e| {
330                        DaemonError::Other(format!("Failed to insert into sys.path: {}", e))
331                    })?;
332                    debug!("Added {} to sys.path from VIRTUAL_ENV", sp_str);
333                }
334            }
335        }
336
337        Ok(())
338    }
339
340    /// Find site-packages directory in a venv by scanning for pythonX.Y directories.
341    /// This handles version mismatches between PyO3's Python and the venv's Python.
342    fn find_site_packages(venv_root: &std::path::Path) -> Option<PathBuf> {
343        let lib_dir = venv_root.join("lib");
344        if !lib_dir.exists() {
345            return None;
346        }
347
348        // Scan for pythonX.Y directories
349        if let Ok(entries) = std::fs::read_dir(&lib_dir) {
350            for entry in entries.flatten() {
351                let name = entry.file_name();
352                let name_str = name.to_string_lossy();
353                if name_str.starts_with("python") && entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
354                    let site_packages = entry.path().join("site-packages");
355                    if site_packages.exists() {
356                        debug!("Found site-packages at: {}", site_packages.display());
357                        return Some(site_packages);
358                    }
359                }
360            }
361        }
362
363        None
364    }
365
366    /// Clear test modules from sys.modules to ensure fresh imports.
367    /// OPTIMIZED: Only clears modules we KNOW are test-related (O(test_paths) instead of O(all_modules))
368    fn clear_test_modules(py: Python, test_paths: &[&str]) -> PyResult<()> {
369        let sys = py.import_bound("sys")?;
370        let modules_attr = sys.getattr("modules")?;
371        let modules: &Bound<PyDict> = modules_attr.downcast()?;
372
373        // Pre-allocate for efficiency
374        let mut keys_to_remove = Vec::with_capacity(test_paths.len() * 2);
375
376        for test_path in test_paths {
377            // Convert file path to module path (e.g., "tests/test_foo.py" -> "tests.test_foo")
378            let module_path = test_path
379                .trim_end_matches(".py")
380                .replace('/', ".");
381
382            // Check if this exact module exists
383            if modules.contains(&module_path)? {
384                keys_to_remove.push(module_path.clone());
385            }
386
387            // Also check for conftest in same directory
388            if let Some(parent) = std::path::Path::new(test_path).parent() {
389                let parent_str = parent.to_string_lossy();
390                if !parent_str.is_empty() {
391                    let conftest = format!("{}.conftest", parent_str.replace('/', "."));
392                    if modules.contains(&conftest)? {
393                        keys_to_remove.push(conftest);
394                    }
395                } else {
396                    // Root-level conftest
397                    if modules.contains("conftest")? {
398                        keys_to_remove.push("conftest".to_string());
399                    }
400                }
401            }
402        }
403
404        for key in &keys_to_remove {
405            let _ = modules.del_item(key);
406        }
407
408        if !keys_to_remove.is_empty() {
409            debug!("Cleared {} test modules from sys.modules", keys_to_remove.len());
410        }
411        Ok(())
412    }
413
414    /// Reset signal handlers to defaults.
415    fn reset_signal_handlers(py: Python) -> PyResult<()> {
416        let signal = py.import_bound("signal")?;
417
418        // Reset SIGALRM (used by pytest-timeout)
419        if let (Ok(sigalrm), Ok(sig_dfl)) = (
420            signal.getattr("SIGALRM"),
421            signal.getattr("SIG_DFL"),
422        ) {
423            let _ = signal.call_method1("signal", (sigalrm, sig_dfl));
424        }
425
426        // Reset SIGTERM
427        if let (Ok(sigterm), Ok(sig_dfl)) = (
428            signal.getattr("SIGTERM"),
429            signal.getattr("SIG_DFL"),
430        ) {
431            let _ = signal.call_method1("signal", (sigterm, sig_dfl));
432        }
433
434        debug!("Reset signal handlers");
435        Ok(())
436    }
437
438    /// Reset asyncio event loop for clean state.
439    fn reset_asyncio(py: Python) -> PyResult<()> {
440        if let Ok(asyncio) = py.import_bound("asyncio") {
441            // Create a fresh event loop
442            if let Ok(new_loop) = asyncio.call_method0("new_event_loop") {
443                let _ = asyncio.call_method1("set_event_loop", (new_loop,));
444            }
445        }
446        debug!("Reset asyncio event loop");
447        Ok(())
448    }
449
450    /// Check if pytest-xdist is available.
451    fn is_xdist_available(py: Python) -> bool {
452        py.import_bound("xdist").is_ok()
453    }
454
455    /// Get or create the cached plugin class to avoid recompiling Python on every run.
456    fn get_or_create_plugin_class(py: Python) -> Result<Bound<'_, PyAny>> {
457        if let Some(cached) = CACHED_PLUGIN_CLASS.get() {
458            return Ok(cached.bind(py).clone());
459        }
460
461        let plugin_module = PyModule::from_code_bound(
462            py,
463            COLLECTOR_PLUGIN,
464            "rpytest_collector_plugin.py",
465            "rpytest_collector_plugin",
466        ).map_err(|e| DaemonError::Other(format!("Failed to compile plugin module: {}", e)))?;
467
468        let plugin_class = plugin_module.getattr("RpytestCollectorPlugin")
469            .map_err(|e| DaemonError::Other(format!("Failed to get plugin class: {}", e)))?;
470
471        let _ = CACHED_PLUGIN_CLASS.set(plugin_class.unbind());
472
473        Ok(CACHED_PLUGIN_CLASS.get().unwrap().bind(py).clone())
474    }
475
476    /// Run tests using embedded Python.
477    pub fn run_tests(&self, node_ids: &[String]) -> Result<Vec<TestResult>> {
478        if node_ids.is_empty() {
479            return Ok(Vec::new());
480        }
481
482        if !self.initialized {
483            return Err(DaemonError::Other(
484                "Embedded Python not initialized".to_string(),
485            ));
486        }
487
488        Python::with_gil(|py| {
489            // Cleanup from previous runs
490            let test_paths: Vec<&str> = node_ids
491                .iter()
492                .filter_map(|id| id.split("::").next())
493                .collect();
494
495            // OPTIMIZATION: Skip cleanup on first run - no stale state yet
496            let is_first_run = FIRST_RUN.swap(false, Ordering::SeqCst);
497            if !is_first_run {
498                Self::clear_test_modules(py, &test_paths)?;
499                // Only reset signal handlers if we've run tests before
500                Self::reset_signal_handlers(py)?;
501                // Only reset asyncio if we've run async tests before
502                Self::reset_asyncio(py)?;
503            }
504
505            // Create result collector
506            let collector = Py::new(py, RustResultCollector::new()).map_err(|e| {
507                DaemonError::Other(format!("Failed to create result collector: {}", e))
508            })?;
509
510            // OPTIMIZATION: Cache the plugin class to avoid recompiling Python on every run
511            let plugin_class = Self::get_or_create_plugin_class(py)?;
512
513            let plugin_instance = plugin_class.call1((collector.clone_ref(py),)).map_err(|e| {
514                DaemonError::Other(format!("Failed to create plugin instance: {}", e))
515            })?;
516
517            // Build pytest arguments
518            let mut args: Vec<String> = node_ids.to_vec();
519
520            // Add verbose output
521            args.push("-v".to_string());
522            args.push("--tb=short".to_string());
523            args.push("--no-header".to_string());
524
525            // Disable cache provider to avoid state pollution
526            args.push("-p".to_string());
527            args.push("no:cacheprovider".to_string());
528
529            // Add maxfail
530            if let Some(maxfail) = self.config.maxfail {
531                args.push(format!("--maxfail={}", maxfail));
532            }
533
534            // Add xdist workers if available and requested
535            if let Some(workers) = self.config.workers {
536                if workers > 1 && Self::is_xdist_available(py) {
537                    args.push("-n".to_string());
538                    args.push(workers.to_string());
539                    args.push("--dist=loadscope".to_string());
540                } else if workers > 1 {
541                    warn!("pytest-xdist not available, running tests sequentially");
542                }
543            }
544
545            // Add extra args
546            args.extend(self.config.extra_args.clone());
547
548            // Convert args to Python list
549            let py_args = PyList::new_bound(py, &args);
550
551            // Create plugins list
552            let plugins = PyList::new_bound(py, [plugin_instance]);
553
554            // Import pytest and run
555            let pytest = py.import_bound("pytest").map_err(|e| {
556                DaemonError::Other(format!("Failed to import pytest: {}", e))
557            })?;
558
559            debug!("Running pytest with {} tests", node_ids.len());
560
561            // Call pytest.main(args, plugins=[plugin])
562            let kwargs = PyDict::new_bound(py);
563            kwargs.set_item("plugins", plugins).map_err(|e| {
564                DaemonError::Other(format!("Failed to set plugins kwarg: {}", e))
565            })?;
566
567            let exit_code: i32 = pytest
568                .call_method("main", (py_args,), Some(&kwargs))
569                .map_err(|e| {
570                    DaemonError::Other(format!("pytest.main() failed: {}", e))
571                })?
572                .extract()
573                .unwrap_or(-1);
574
575            debug!("pytest.main() completed with exit code {}", exit_code);
576
577            // Extract results from collector
578            let collector_ref = collector.borrow(py);
579            let results = collector_ref.take_results();
580
581            // If no results were collected via the plugin, create error results
582            if results.is_empty() && !node_ids.is_empty() {
583                warn!(
584                    "No results collected from pytest plugin (exit code: {})",
585                    exit_code
586                );
587                // Return error results for all requested tests
588                return Ok(node_ids
589                    .iter()
590                    .map(|id| TestResult {
591                        node_id: id.clone(),
592                        outcome: if exit_code == 0 {
593                            TestOutcome::Passed
594                        } else {
595                            TestOutcome::Error
596                        },
597                        duration_ms: 0,
598                        message: Some(format!("pytest exit code: {}", exit_code)),
599                        stdout: None,
600                        stderr: None,
601                    })
602                    .collect());
603            }
604
605            info!(
606                "Collected {} results from {} tests",
607                results.len(),
608                node_ids.len()
609            );
610
611            Ok(results)
612        })
613    }
614
615    /// Run a single test.
616    pub fn run_test(&self, node_id: &str) -> Result<TestResult> {
617        let results = self.run_tests(&[node_id.to_string()])?;
618        results.into_iter().next().ok_or_else(|| {
619            DaemonError::Other(format!("No result for test: {}", node_id))
620        })
621    }
622
623    /// Get Python version info.
624    pub fn python_version(&self) -> Option<(u8, u8, u8)> {
625        self.python_version
626    }
627
628    /// Check if embedded Python is available and working.
629    pub fn is_available() -> bool {
630        Python::with_gil(|py| {
631            // Check Python version
632            let version = py.version_info();
633            if version.major < 3 || (version.major == 3 && version.minor < 8) {
634                return false;
635            }
636
637            // Check pytest is importable
638            py.import_bound("pytest").is_ok()
639        })
640    }
641}
642
643impl Default for EmbeddedExecutor {
644    fn default() -> Self {
645        Self::new(None).expect("Failed to create EmbeddedExecutor")
646    }
647}
648
649#[async_trait]
650impl TestExecutor for EmbeddedExecutor {
651    async fn run_test(&self, node_id: &str) -> Result<TestResult> {
652        // EmbeddedExecutor::run_test is synchronous, but we wrap it for the async trait
653        EmbeddedExecutor::run_test(self, node_id)
654    }
655
656    async fn run_tests(&self, node_ids: &[String]) -> Vec<TestResult> {
657        // EmbeddedExecutor::run_tests is synchronous
658        match EmbeddedExecutor::run_tests(self, node_ids) {
659            Ok(results) => results,
660            Err(e) => {
661                warn!("Embedded execution failed: {}", e);
662                // Return error results for all tests
663                node_ids
664                    .iter()
665                    .map(|id| TestResult {
666                        node_id: id.clone(),
667                        outcome: TestOutcome::Error,
668                        duration_ms: 0,
669                        message: Some(format!("Embedded execution error: {}", e)),
670                        stdout: None,
671                        stderr: None,
672                    })
673                    .collect()
674            }
675        }
676    }
677
678    fn configure(&mut self, config: ExecutorConfig) {
679        self.config = EmbeddedExecutorConfig {
680            workers: config.workers,
681            maxfail: config.maxfail,
682            test_timeout_secs: config.test_timeout_secs,
683            extra_args: config.extra_args.clone(),
684        };
685    }
686
687    fn execution_mode(&self) -> &'static str {
688        "embedded"
689    }
690
691    fn kill_all(&self) {
692        // Embedded execution doesn't spawn processes, nothing to kill
693        // But we could potentially interrupt the GIL in the future
694    }
695}
696
697#[cfg(test)]
698mod tests {
699    use super::*;
700
701    #[test]
702    fn test_embedded_executor_creation() {
703        // This test will only pass if Python 3.8+ with pytest is available
704        if EmbeddedExecutor::is_available() {
705            let executor = EmbeddedExecutor::new(None);
706            assert!(executor.is_ok());
707
708            let executor = executor.unwrap();
709            let version = executor.python_version();
710            assert!(version.is_some());
711
712            let (major, minor, _) = version.unwrap();
713            assert!(major >= 3);
714            assert!(minor >= 8 || major > 3);
715        }
716    }
717
718    #[test]
719    fn test_rust_result_collector() {
720        Python::with_gil(|py| {
721            let collector = Py::new(py, RustResultCollector::new()).unwrap();
722            let collector_ref = collector.borrow(py);
723
724            // Simulate reporting a result
725            collector_ref.report(
726                "test_file.py::test_func".to_string(),
727                "passed".to_string(),
728                0.123,
729                None,
730            );
731
732            let results = collector_ref.take_results();
733            assert_eq!(results.len(), 1);
734            assert_eq!(results[0].node_id, "test_file.py::test_func");
735            assert!(matches!(results[0].outcome, TestOutcome::Passed));
736            assert_eq!(results[0].duration_ms, 123);
737        });
738    }
739
740    #[test]
741    fn test_rust_result_collector_multiple() {
742        Python::with_gil(|py| {
743            let collector = Py::new(py, RustResultCollector::new()).unwrap();
744            let collector_ref = collector.borrow(py);
745
746            // Report multiple results
747            collector_ref.report(
748                "test_a.py::test_1".to_string(),
749                "passed".to_string(),
750                0.05,
751                None,
752            );
753            collector_ref.report(
754                "test_b.py::test_2".to_string(),
755                "failed".to_string(),
756                0.1,
757                Some("AssertionError".to_string()),
758            );
759            collector_ref.report(
760                "test_c.py::test_3".to_string(),
761                "skipped".to_string(),
762                0.001,
763                Some("reason: not implemented".to_string()),
764            );
765
766            let results = collector_ref.take_results();
767            assert_eq!(results.len(), 3);
768            assert!(matches!(results[0].outcome, TestOutcome::Passed));
769            assert!(matches!(results[1].outcome, TestOutcome::Failed));
770            assert!(matches!(results[2].outcome, TestOutcome::Skipped));
771            assert_eq!(results[1].message, Some("AssertionError".to_string()));
772        });
773    }
774
775    #[test]
776    fn test_rust_result_collector_take_clears() {
777        Python::with_gil(|py| {
778            let collector = Py::new(py, RustResultCollector::new()).unwrap();
779            let collector_ref = collector.borrow(py);
780
781            collector_ref.report(
782                "test.py::test_1".to_string(),
783                "passed".to_string(),
784                0.01,
785                None,
786            );
787
788            // First take should get results
789            let results1 = collector_ref.take_results();
790            assert_eq!(results1.len(), 1);
791
792            // Second take should be empty
793            let results2 = collector_ref.take_results();
794            assert!(results2.is_empty());
795        });
796    }
797
798    #[test]
799    fn test_rust_result_collector_outcomes() {
800        Python::with_gil(|py| {
801            let collector = Py::new(py, RustResultCollector::new()).unwrap();
802            let collector_ref = collector.borrow(py);
803
804            // Test all outcome types
805            let outcomes = vec![
806                ("passed", TestOutcome::Passed),
807                ("failed", TestOutcome::Failed),
808                ("skipped", TestOutcome::Skipped),
809                ("error", TestOutcome::Error),
810                ("xfail", TestOutcome::Xfail),
811                ("xpass", TestOutcome::Xpass),
812            ];
813
814            for (i, (outcome_str, _)) in outcomes.iter().enumerate() {
815                collector_ref.report(
816                    format!("test.py::test_{}", i),
817                    outcome_str.to_string(),
818                    0.01,
819                    None,
820                );
821            }
822
823            let results = collector_ref.take_results();
824            assert_eq!(results.len(), outcomes.len());
825
826            for (i, (_, expected_outcome)) in outcomes.iter().enumerate() {
827                assert_eq!(results[i].outcome, *expected_outcome, "Mismatch at index {}", i);
828            }
829        });
830    }
831
832    #[test]
833    fn test_embedded_executor_execution_mode() {
834        if EmbeddedExecutor::is_available() {
835            let executor = EmbeddedExecutor::new(None).unwrap();
836            assert_eq!(executor.execution_mode(), "embedded");
837        }
838    }
839
840    #[test]
841    fn test_embedded_executor_configure() {
842        use crate::executor::TestExecutor;
843
844        if EmbeddedExecutor::is_available() {
845            let mut executor = EmbeddedExecutor::new(None).unwrap();
846            let config = ExecutorConfig {
847                workers: Some(4),
848                maxfail: Some(10),
849                batch_size: 100,
850                test_timeout_secs: 120,
851                extra_args: vec!["--tb=long".to_string()],
852            };
853            // Use trait method which accepts ExecutorConfig
854            TestExecutor::configure(&mut executor, config);
855            // Configuration should not fail
856            assert_eq!(executor.execution_mode(), "embedded");
857        }
858    }
859
860    #[test]
861    fn test_embedded_executor_kill_all() {
862        if EmbeddedExecutor::is_available() {
863            let executor = EmbeddedExecutor::new(None).unwrap();
864            // Should not panic
865            executor.kill_all();
866        }
867    }
868
869    #[test]
870    fn test_duration_conversion() {
871        Python::with_gil(|py| {
872            let collector = Py::new(py, RustResultCollector::new()).unwrap();
873            let collector_ref = collector.borrow(py);
874
875            // Test duration conversion (seconds to milliseconds)
876            collector_ref.report(
877                "test.py::test_1".to_string(),
878                "passed".to_string(),
879                1.5, // 1.5 seconds
880                None,
881            );
882            collector_ref.report(
883                "test.py::test_2".to_string(),
884                "passed".to_string(),
885                0.001, // 1 millisecond
886                None,
887            );
888
889            let results = collector_ref.take_results();
890            assert_eq!(results[0].duration_ms, 1500);
891            assert_eq!(results[1].duration_ms, 1);
892        });
893    }
894
895    #[test]
896    fn test_embedded_executor_run_passing_test() {
897        use std::fs;
898        use tempfile::TempDir;
899
900        if !EmbeddedExecutor::is_available() {
901            return;
902        }
903
904        // Create a temporary test file
905        let dir = TempDir::new().unwrap();
906        let test_file = dir.path().join("test_example.py");
907        fs::write(&test_file, "def test_passing():\n    assert 1 + 1 == 2\n").unwrap();
908
909        let executor = EmbeddedExecutor::new(None).unwrap();
910        let node_id = format!(
911            "{}::test_passing",
912            test_file.to_string_lossy()
913        );
914
915        let result = executor.run_test(&node_id);
916        assert!(result.is_ok());
917        let result = result.unwrap();
918        assert!(matches!(result.outcome, TestOutcome::Passed));
919    }
920
921    #[test]
922    fn test_embedded_executor_run_failing_test() {
923        use std::fs;
924        use tempfile::TempDir;
925
926        if !EmbeddedExecutor::is_available() {
927            return;
928        }
929
930        // Create a temporary test file with a failing test
931        let dir = TempDir::new().unwrap();
932        let test_file = dir.path().join("test_fail.py");
933        fs::write(&test_file, "def test_failing():\n    assert 1 == 2\n").unwrap();
934
935        let executor = EmbeddedExecutor::new(None).unwrap();
936        let node_id = format!(
937            "{}::test_failing",
938            test_file.to_string_lossy()
939        );
940
941        let result = executor.run_test(&node_id);
942        assert!(result.is_ok());
943        let result = result.unwrap();
944        assert!(matches!(result.outcome, TestOutcome::Failed));
945    }
946
947    #[test]
948    fn test_embedded_executor_run_multiple_tests() {
949        use std::fs;
950        use tempfile::TempDir;
951
952        if !EmbeddedExecutor::is_available() {
953            return;
954        }
955
956        // Create a temporary test file with multiple tests
957        let dir = TempDir::new().unwrap();
958        let test_file = dir.path().join("test_multi.py");
959        fs::write(
960            &test_file,
961            "def test_one():\n    assert True\n\ndef test_two():\n    assert True\n\ndef test_three():\n    assert False\n",
962        )
963        .unwrap();
964
965        let executor = EmbeddedExecutor::new(None).unwrap();
966        let file_path = test_file.to_string_lossy();
967        let node_ids = vec![
968            format!("{}::test_one", file_path),
969            format!("{}::test_two", file_path),
970            format!("{}::test_three", file_path),
971        ];
972
973        let results = executor.run_tests(&node_ids);
974        assert!(results.is_ok());
975        let results = results.unwrap();
976        assert_eq!(results.len(), 3);
977        assert!(matches!(results[0].outcome, TestOutcome::Passed));
978        assert!(matches!(results[1].outcome, TestOutcome::Passed));
979        assert!(matches!(results[2].outcome, TestOutcome::Failed));
980    }
981
982    #[test]
983    fn test_embedded_executor_run_empty() {
984        if !EmbeddedExecutor::is_available() {
985            return;
986        }
987
988        let executor = EmbeddedExecutor::new(None).unwrap();
989        let results = executor.run_tests(&[]);
990        assert!(results.is_ok());
991        assert!(results.unwrap().is_empty());
992    }
993
994    #[test]
995    fn test_embedded_executor_run_skipped_test() {
996        use std::fs;
997        use tempfile::TempDir;
998
999        if !EmbeddedExecutor::is_available() {
1000            return;
1001        }
1002
1003        // Create a temporary test file with a skipped test
1004        let dir = TempDir::new().unwrap();
1005        let test_file = dir.path().join("test_skip.py");
1006        fs::write(
1007            &test_file,
1008            "import pytest\n\n@pytest.mark.skip(reason='test skip')\ndef test_skipped():\n    assert True\n",
1009        )
1010        .unwrap();
1011
1012        let executor = EmbeddedExecutor::new(None).unwrap();
1013        let node_id = format!(
1014            "{}::test_skipped",
1015            test_file.to_string_lossy()
1016        );
1017
1018        let result = executor.run_test(&node_id);
1019        assert!(result.is_ok());
1020        let result = result.unwrap();
1021        assert!(matches!(result.outcome, TestOutcome::Skipped));
1022    }
1023}