1use 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
25fn ensure_pythonhome() {
33 if std::env::var("PYTHONHOME").is_ok() {
34 debug!("PYTHONHOME already set");
35 return;
36 }
37
38 if let Ok(venv) = std::env::var("VIRTUAL_ENV") {
40 let python = std::path::Path::new(&venv).join("bin").join("python3");
43 if python.exists() {
44 if let Some(base_prefix) = query_python_base_prefix(&python) {
45 info!(
46 "Auto-detected PYTHONHOME from VIRTUAL_ENV's base_prefix: {}",
47 base_prefix
48 );
49 std::env::set_var("PYTHONHOME", &base_prefix);
50 return;
51 }
52 }
53 }
54
55 if let Some(base_prefix) = query_python_base_prefix(std::path::Path::new("python3")) {
57 info!("Auto-detected PYTHONHOME from python3: {}", base_prefix);
58 std::env::set_var("PYTHONHOME", &base_prefix);
59 return;
60 }
61
62 warn!("Could not auto-detect PYTHONHOME; embedded Python may fail to initialize");
63}
64
65fn query_python_base_prefix(python: &std::path::Path) -> Option<String> {
67 match std::process::Command::new(python)
68 .args(["-c", "import sys; print(sys.base_prefix)"])
69 .stdout(std::process::Stdio::piped())
70 .stderr(std::process::Stdio::null())
71 .output()
72 {
73 Ok(output) if output.status.success() => {
74 let prefix = String::from_utf8_lossy(&output.stdout).trim().to_string();
75 if !prefix.is_empty() {
76 Some(prefix)
77 } else {
78 None
79 }
80 }
81 Ok(_) => {
82 debug!("python3 exited with non-zero status while querying base_prefix");
83 None
84 }
85 Err(e) => {
86 debug!("Failed to run python3 for base_prefix detection: {}", e);
87 None
88 }
89 }
90}
91
92static CACHED_PLUGIN_CLASS: OnceLock<Py<PyAny>> = OnceLock::new();
94
95static FIRST_RUN: AtomicBool = AtomicBool::new(true);
97
98const COLLECTOR_PLUGIN: &str = r#"
102import pytest
103
104class RpytestCollectorPlugin:
105 """Pytest plugin that collects results and streams them to Rust."""
106
107 def __init__(self, collector):
108 self.collector = collector
109 self._collected_count = 0
110 self._passed = 0
111 self._failed = 0
112 self._skipped = 0
113 self._errors = 0
114 # OPTIMIZATION: Batch results locally instead of calling Rust for each test
115 self._results_batch = []
116
117 def pytest_runtest_logreport(self, report):
118 """Called for each test phase (setup, call, teardown)."""
119 # Only record the 'call' phase for test outcomes
120 # setup/teardown phases are tracked separately
121 if report.when == 'call':
122 # Batch locally instead of calling Rust per-result
123 self._results_batch.append((
124 report.nodeid,
125 report.outcome,
126 report.duration,
127 getattr(report, 'longreprtext', None)
128 ))
129
130 # Track summary counts
131 if report.outcome == 'passed':
132 self._passed += 1
133 elif report.outcome == 'failed':
134 self._failed += 1
135 elif report.outcome == 'skipped':
136 self._skipped += 1
137 elif report.when == 'setup' and report.outcome == 'skipped':
138 # Handle skip during setup (e.g., skip marker)
139 self._results_batch.append((
140 report.nodeid,
141 'skipped',
142 report.duration,
143 getattr(report, 'longreprtext', None)
144 ))
145 self._skipped += 1
146 elif report.when in ('setup', 'teardown') and report.outcome == 'failed':
147 # Handle errors during setup/teardown
148 self._results_batch.append((
149 report.nodeid,
150 'error',
151 report.duration,
152 getattr(report, 'longreprtext', None)
153 ))
154 self._errors += 1
155
156 def pytest_collection_finish(self, session):
157 """Called after collection is complete."""
158 self._collected_count = len(session.items)
159 self.collector.set_collected_count(self._collected_count)
160
161 def pytest_sessionfinish(self, session, exitstatus):
162 """Called after the entire session finishes."""
163 # Single Rust call with all results (instead of one per test)
164 if self._results_batch:
165 self.collector.report_batch(self._results_batch)
166 self.collector.set_exit_status(exitstatus)
167"#;
168
169#[pyclass]
171pub struct RustResultCollector {
172 results: Arc<Mutex<Vec<TestResult>>>,
173 collected_count: Arc<Mutex<usize>>,
174 exit_status: Arc<Mutex<i32>>,
175}
176
177#[pymethods]
178impl RustResultCollector {
179 #[new]
180 fn new() -> Self {
181 RustResultCollector {
182 results: Arc::new(Mutex::new(Vec::new())),
183 collected_count: Arc::new(Mutex::new(0)),
184 exit_status: Arc::new(Mutex::new(-1)),
185 }
186 }
187
188 #[pyo3(signature = (nodeid, outcome, duration, message=None))]
190 fn report(&self, nodeid: String, outcome: String, duration: f64, message: Option<String>) {
191 let test_outcome = match outcome.as_str() {
192 "passed" => TestOutcome::Passed,
193 "failed" => TestOutcome::Failed,
194 "skipped" => TestOutcome::Skipped,
195 "error" => TestOutcome::Error,
196 "xfail" => TestOutcome::Xfail,
197 "xpass" => TestOutcome::Xpass,
198 _ => TestOutcome::Error,
199 };
200
201 let result = TestResult {
202 node_id: nodeid,
203 outcome: test_outcome,
204 duration_ms: (duration * 1000.0) as u64,
205 message,
206 stdout: None,
207 stderr: None,
208 };
209
210 self.results.lock().push(result);
211 }
212
213 fn set_collected_count(&self, count: usize) {
215 *self.collected_count.lock() = count;
216 }
217
218 fn set_exit_status(&self, status: i32) {
220 *self.exit_status.lock() = status;
221 }
222
223 fn report_batch(&self, results: Vec<(String, String, f64, Option<String>)>) {
225 let mut lock = self.results.lock();
226 for (nodeid, outcome, duration, message) in results {
227 let test_outcome = match outcome.as_str() {
228 "passed" => TestOutcome::Passed,
229 "failed" => TestOutcome::Failed,
230 "skipped" => TestOutcome::Skipped,
231 "error" => TestOutcome::Error,
232 "xfail" => TestOutcome::Xfail,
233 "xpass" => TestOutcome::Xpass,
234 _ => TestOutcome::Error,
235 };
236
237 lock.push(TestResult {
238 node_id: nodeid,
239 outcome: test_outcome,
240 duration_ms: (duration * 1000.0) as u64,
241 message,
242 stdout: None,
243 stderr: None,
244 });
245 }
246 }
247}
248
249impl RustResultCollector {
250 pub fn take_results(&self) -> Vec<TestResult> {
252 std::mem::take(&mut *self.results.lock())
253 }
254
255 pub fn collected_count(&self) -> usize {
257 *self.collected_count.lock()
258 }
259
260 pub fn exit_status(&self) -> i32 {
262 *self.exit_status.lock()
263 }
264}
265
266#[derive(Debug, Clone, Default)]
268pub struct EmbeddedExecutorConfig {
269 pub workers: Option<u32>,
271 pub maxfail: Option<u32>,
273 pub test_timeout_secs: u64,
275 pub extra_args: Vec<String>,
277}
278
279#[derive(Debug)]
281pub struct EmbeddedExecutor {
282 config: EmbeddedExecutorConfig,
283 initialized: bool,
285 python_version: Option<(u8, u8, u8)>,
287 python_path: Option<PathBuf>,
289}
290
291impl EmbeddedExecutor {
292 pub fn new(python_path: Option<PathBuf>) -> Result<Self> {
294 ensure_pythonhome();
297
298 let mut executor = EmbeddedExecutor {
299 config: EmbeddedExecutorConfig::default(),
300 initialized: false,
301 python_version: None,
302 python_path,
303 };
304
305 executor.initialize()?;
307
308 Ok(executor)
309 }
310
311 pub fn configure(&mut self, config: EmbeddedExecutorConfig) {
313 self.config = config;
314 }
315
316 fn initialize(&mut self) -> Result<()> {
318 Python::with_gil(|py| {
319 let version = py.version_info();
321 self.python_version = Some((version.major, version.minor, version.patch));
322
323 if version.major < 3 || (version.major == 3 && version.minor < 8) {
325 return Err(DaemonError::Other(format!(
326 "Python 3.8+ required, found {}.{}.{}",
327 version.major, version.minor, version.patch
328 )));
329 }
330
331 self.setup_python_path(py)?;
334
335 py.import_bound("pytest").map_err(|e| {
337 DaemonError::Other(format!("pytest not installed or not importable: {}", e))
338 })?;
339
340 self.initialized = true;
341 info!(
342 "Embedded Python {}.{}.{} initialized successfully",
343 version.major, version.minor, version.patch
344 );
345
346 Ok(())
347 })
348 }
349
350 fn setup_python_path(&self, py: Python) -> Result<()> {
358 let sys = py
359 .import_bound("sys")
360 .map_err(|e| DaemonError::Other(format!("Failed to import sys: {}", e)))?;
361
362 let path = sys
363 .getattr("path")
364 .map_err(|e| DaemonError::Other(format!("Failed to get sys.path: {}", e)))?;
365
366 let path_list: &Bound<PyList> = path
367 .downcast()
368 .map_err(|e| DaemonError::Other(format!("sys.path is not a list: {}", e)))?;
369
370 if let Some(ref python_path) = self.python_path {
374 if let Some(venv_root) = python_path.parent().and_then(|p| p.parent()) {
376 if let Some(site_packages) = Self::find_site_packages(venv_root) {
377 let sp_str = site_packages.to_string_lossy().to_string();
378 if !path_list.contains(&sp_str).unwrap_or(false) {
379 path_list.insert(0, &sp_str).map_err(|e| {
380 DaemonError::Other(format!("Failed to insert into sys.path: {}", e))
381 })?;
382 debug!("Added {} to sys.path from python_path", sp_str);
383 }
384 }
385 }
386 }
387
388 if let Ok(venv) = std::env::var("VIRTUAL_ENV") {
390 let venv_root = std::path::Path::new(&venv);
391 if let Some(site_packages) = Self::find_site_packages(venv_root) {
392 let sp_str = site_packages.to_string_lossy().to_string();
393 if !path_list.contains(&sp_str).unwrap_or(false) {
394 path_list.insert(0, &sp_str).map_err(|e| {
395 DaemonError::Other(format!("Failed to insert into sys.path: {}", e))
396 })?;
397 debug!("Added {} to sys.path from VIRTUAL_ENV", sp_str);
398 }
399 }
400 }
401
402 Ok(())
403 }
404
405 fn find_site_packages(venv_root: &std::path::Path) -> Option<PathBuf> {
408 let lib_dir = venv_root.join("lib");
409 if !lib_dir.exists() {
410 return None;
411 }
412
413 if let Ok(entries) = std::fs::read_dir(&lib_dir) {
415 for entry in entries.flatten() {
416 let name = entry.file_name();
417 let name_str = name.to_string_lossy();
418 if name_str.starts_with("python")
419 && entry.file_type().map(|t| t.is_dir()).unwrap_or(false)
420 {
421 let site_packages = entry.path().join("site-packages");
422 if site_packages.exists() {
423 debug!("Found site-packages at: {}", site_packages.display());
424 return Some(site_packages);
425 }
426 }
427 }
428 }
429
430 None
431 }
432
433 fn clear_test_modules(py: Python, test_paths: &[&str]) -> PyResult<()> {
436 let sys = py.import_bound("sys")?;
437 let modules_attr = sys.getattr("modules")?;
438 let modules: &Bound<PyDict> = modules_attr.downcast()?;
439
440 let mut keys_to_remove = Vec::with_capacity(test_paths.len() * 2);
442
443 for test_path in test_paths {
444 let module_path = test_path.trim_end_matches(".py").replace('/', ".");
446
447 if modules.contains(&module_path)? {
449 keys_to_remove.push(module_path.clone());
450 }
451
452 if let Some(parent) = std::path::Path::new(test_path).parent() {
454 let parent_str = parent.to_string_lossy();
455 if !parent_str.is_empty() {
456 let conftest = format!("{}.conftest", parent_str.replace('/', "."));
457 if modules.contains(&conftest)? {
458 keys_to_remove.push(conftest);
459 }
460 } else {
461 if modules.contains("conftest")? {
463 keys_to_remove.push("conftest".to_string());
464 }
465 }
466 }
467 }
468
469 for key in &keys_to_remove {
470 let _ = modules.del_item(key);
471 }
472
473 if !keys_to_remove.is_empty() {
474 debug!(
475 "Cleared {} test modules from sys.modules",
476 keys_to_remove.len()
477 );
478 }
479 Ok(())
480 }
481
482 fn reset_signal_handlers(py: Python) -> PyResult<()> {
484 let signal = py.import_bound("signal")?;
485
486 if let (Ok(sigalrm), Ok(sig_dfl)) = (signal.getattr("SIGALRM"), signal.getattr("SIG_DFL")) {
488 let _ = signal.call_method1("signal", (sigalrm, sig_dfl));
489 }
490
491 if let (Ok(sigterm), Ok(sig_dfl)) = (signal.getattr("SIGTERM"), signal.getattr("SIG_DFL")) {
493 let _ = signal.call_method1("signal", (sigterm, sig_dfl));
494 }
495
496 debug!("Reset signal handlers");
497 Ok(())
498 }
499
500 fn reset_asyncio(py: Python) -> PyResult<()> {
502 if let Ok(asyncio) = py.import_bound("asyncio") {
503 if let Ok(new_loop) = asyncio.call_method0("new_event_loop") {
505 let _ = asyncio.call_method1("set_event_loop", (new_loop,));
506 }
507 }
508 debug!("Reset asyncio event loop");
509 Ok(())
510 }
511
512 fn is_xdist_available(py: Python) -> bool {
514 py.import_bound("xdist").is_ok()
515 }
516
517 fn get_or_create_plugin_class(py: Python) -> Result<Bound<'_, PyAny>> {
519 if let Some(cached) = CACHED_PLUGIN_CLASS.get() {
520 return Ok(cached.bind(py).clone());
521 }
522
523 let plugin_module = PyModule::from_code_bound(
524 py,
525 COLLECTOR_PLUGIN,
526 "rpytest_collector_plugin.py",
527 "rpytest_collector_plugin",
528 )
529 .map_err(|e| DaemonError::Other(format!("Failed to compile plugin module: {}", e)))?;
530
531 let plugin_class = plugin_module
532 .getattr("RpytestCollectorPlugin")
533 .map_err(|e| DaemonError::Other(format!("Failed to get plugin class: {}", e)))?;
534
535 let _ = CACHED_PLUGIN_CLASS.set(plugin_class.unbind());
536
537 Ok(CACHED_PLUGIN_CLASS.get().unwrap().bind(py).clone())
538 }
539
540 pub fn run_tests(&self, node_ids: &[String]) -> Result<Vec<TestResult>> {
542 if node_ids.is_empty() {
543 return Ok(Vec::new());
544 }
545
546 if !self.initialized {
547 return Err(DaemonError::Other(
548 "Embedded Python not initialized".to_string(),
549 ));
550 }
551
552 Python::with_gil(|py| {
553 let test_paths: Vec<&str> = node_ids
555 .iter()
556 .filter_map(|id| id.split("::").next())
557 .collect();
558
559 let is_first_run = FIRST_RUN.swap(false, Ordering::SeqCst);
561 if !is_first_run {
562 Self::clear_test_modules(py, &test_paths)?;
563 Self::reset_signal_handlers(py)?;
565 Self::reset_asyncio(py)?;
567 }
568
569 let collector = Py::new(py, RustResultCollector::new()).map_err(|e| {
571 DaemonError::Other(format!("Failed to create result collector: {}", e))
572 })?;
573
574 let plugin_class = Self::get_or_create_plugin_class(py)?;
576
577 let plugin_instance = plugin_class
578 .call1((collector.clone_ref(py),))
579 .map_err(|e| {
580 DaemonError::Other(format!("Failed to create plugin instance: {}", e))
581 })?;
582
583 let mut args: Vec<String> = node_ids.to_vec();
585
586 args.push("-v".to_string());
588 args.push("--tb=short".to_string());
589 args.push("--no-header".to_string());
590
591 args.push("-p".to_string());
593 args.push("no:cacheprovider".to_string());
594
595 if let Some(maxfail) = self.config.maxfail {
597 args.push(format!("--maxfail={}", maxfail));
598 }
599
600 if let Some(workers) = self.config.workers {
602 if workers > 1 && Self::is_xdist_available(py) {
603 args.push("-n".to_string());
604 args.push(workers.to_string());
605 args.push("--dist=loadscope".to_string());
606 } else if workers > 1 {
607 warn!("pytest-xdist not available, running tests sequentially");
608 }
609 }
610
611 args.extend(self.config.extra_args.clone());
613
614 let py_args = PyList::new_bound(py, &args);
616
617 let plugins = PyList::new_bound(py, [plugin_instance]);
619
620 let pytest = py
622 .import_bound("pytest")
623 .map_err(|e| DaemonError::Other(format!("Failed to import pytest: {}", e)))?;
624
625 debug!("Running pytest with {} tests", node_ids.len());
626
627 let kwargs = PyDict::new_bound(py);
629 kwargs
630 .set_item("plugins", plugins)
631 .map_err(|e| DaemonError::Other(format!("Failed to set plugins kwarg: {}", e)))?;
632
633 let exit_code: i32 = pytest
634 .call_method("main", (py_args,), Some(&kwargs))
635 .map_err(|e| DaemonError::Other(format!("pytest.main() failed: {}", e)))?
636 .extract()
637 .unwrap_or(-1);
638
639 debug!("pytest.main() completed with exit code {}", exit_code);
640
641 let collector_ref = collector.borrow(py);
643 let results = collector_ref.take_results();
644
645 if results.is_empty() && !node_ids.is_empty() {
647 warn!(
648 "No results collected from pytest plugin (exit code: {})",
649 exit_code
650 );
651 return Ok(node_ids
653 .iter()
654 .map(|id| TestResult {
655 node_id: id.clone(),
656 outcome: if exit_code == 0 {
657 TestOutcome::Passed
658 } else {
659 TestOutcome::Error
660 },
661 duration_ms: 0,
662 message: Some(format!("pytest exit code: {}", exit_code)),
663 stdout: None,
664 stderr: None,
665 })
666 .collect());
667 }
668
669 info!(
670 "Collected {} results from {} tests",
671 results.len(),
672 node_ids.len()
673 );
674
675 Ok(results)
676 })
677 }
678
679 pub fn run_test(&self, node_id: &str) -> Result<TestResult> {
681 let results = self.run_tests(&[node_id.to_string()])?;
682 results
683 .into_iter()
684 .next()
685 .ok_or_else(|| DaemonError::Other(format!("No result for test: {}", node_id)))
686 }
687
688 pub fn python_version(&self) -> Option<(u8, u8, u8)> {
690 self.python_version
691 }
692
693 pub fn is_available() -> bool {
695 ensure_pythonhome();
697
698 std::panic::catch_unwind(|| {
702 Python::with_gil(|py| {
703 let version = py.version_info();
705 if version.major < 3 || (version.major == 3 && version.minor < 8) {
706 return false;
707 }
708
709 py.import_bound("pytest").is_ok()
711 })
712 })
713 .unwrap_or_else(|_| {
714 warn!("Embedded Python initialization panicked; falling back to subprocess mode");
715 false
716 })
717 }
718}
719
720impl Default for EmbeddedExecutor {
721 fn default() -> Self {
722 Self::new(None).expect("Failed to create EmbeddedExecutor")
723 }
724}
725
726#[async_trait]
727impl TestExecutor for EmbeddedExecutor {
728 async fn run_test(&self, node_id: &str) -> Result<TestResult> {
729 EmbeddedExecutor::run_test(self, node_id)
731 }
732
733 async fn run_tests(&self, node_ids: &[String]) -> Vec<TestResult> {
734 match EmbeddedExecutor::run_tests(self, node_ids) {
736 Ok(results) => results,
737 Err(e) => {
738 warn!("Embedded execution failed: {}", e);
739 node_ids
741 .iter()
742 .map(|id| TestResult {
743 node_id: id.clone(),
744 outcome: TestOutcome::Error,
745 duration_ms: 0,
746 message: Some(format!("Embedded execution error: {}", e)),
747 stdout: None,
748 stderr: None,
749 })
750 .collect()
751 }
752 }
753 }
754
755 fn configure(&mut self, config: ExecutorConfig) {
756 self.config = EmbeddedExecutorConfig {
757 workers: config.workers,
758 maxfail: config.maxfail,
759 test_timeout_secs: config.test_timeout_secs,
760 extra_args: config.extra_args.clone(),
761 };
762 }
763
764 fn execution_mode(&self) -> &'static str {
765 "embedded"
766 }
767
768 fn kill_all(&self) {
769 }
772}
773
774#[cfg(test)]
775mod tests {
776 use super::*;
777
778 #[test]
779 fn test_embedded_executor_creation() {
780 if EmbeddedExecutor::is_available() {
782 let executor = EmbeddedExecutor::new(None);
783 assert!(executor.is_ok());
784
785 let executor = executor.unwrap();
786 let version = executor.python_version();
787 assert!(version.is_some());
788
789 let (major, minor, _) = version.unwrap();
790 assert!(major >= 3);
791 assert!(minor >= 8 || major > 3);
792 }
793 }
794
795 #[test]
796 fn test_rust_result_collector() {
797 Python::with_gil(|py| {
798 let collector = Py::new(py, RustResultCollector::new()).unwrap();
799 let collector_ref = collector.borrow(py);
800
801 collector_ref.report(
803 "test_file.py::test_func".to_string(),
804 "passed".to_string(),
805 0.123,
806 None,
807 );
808
809 let results = collector_ref.take_results();
810 assert_eq!(results.len(), 1);
811 assert_eq!(results[0].node_id, "test_file.py::test_func");
812 assert!(matches!(results[0].outcome, TestOutcome::Passed));
813 assert_eq!(results[0].duration_ms, 123);
814 });
815 }
816
817 #[test]
818 fn test_rust_result_collector_multiple() {
819 Python::with_gil(|py| {
820 let collector = Py::new(py, RustResultCollector::new()).unwrap();
821 let collector_ref = collector.borrow(py);
822
823 collector_ref.report(
825 "test_a.py::test_1".to_string(),
826 "passed".to_string(),
827 0.05,
828 None,
829 );
830 collector_ref.report(
831 "test_b.py::test_2".to_string(),
832 "failed".to_string(),
833 0.1,
834 Some("AssertionError".to_string()),
835 );
836 collector_ref.report(
837 "test_c.py::test_3".to_string(),
838 "skipped".to_string(),
839 0.001,
840 Some("reason: not implemented".to_string()),
841 );
842
843 let results = collector_ref.take_results();
844 assert_eq!(results.len(), 3);
845 assert!(matches!(results[0].outcome, TestOutcome::Passed));
846 assert!(matches!(results[1].outcome, TestOutcome::Failed));
847 assert!(matches!(results[2].outcome, TestOutcome::Skipped));
848 assert_eq!(results[1].message, Some("AssertionError".to_string()));
849 });
850 }
851
852 #[test]
853 fn test_rust_result_collector_take_clears() {
854 Python::with_gil(|py| {
855 let collector = Py::new(py, RustResultCollector::new()).unwrap();
856 let collector_ref = collector.borrow(py);
857
858 collector_ref.report(
859 "test.py::test_1".to_string(),
860 "passed".to_string(),
861 0.01,
862 None,
863 );
864
865 let results1 = collector_ref.take_results();
867 assert_eq!(results1.len(), 1);
868
869 let results2 = collector_ref.take_results();
871 assert!(results2.is_empty());
872 });
873 }
874
875 #[test]
876 fn test_rust_result_collector_outcomes() {
877 Python::with_gil(|py| {
878 let collector = Py::new(py, RustResultCollector::new()).unwrap();
879 let collector_ref = collector.borrow(py);
880
881 let outcomes = vec![
883 ("passed", TestOutcome::Passed),
884 ("failed", TestOutcome::Failed),
885 ("skipped", TestOutcome::Skipped),
886 ("error", TestOutcome::Error),
887 ("xfail", TestOutcome::Xfail),
888 ("xpass", TestOutcome::Xpass),
889 ];
890
891 for (i, (outcome_str, _)) in outcomes.iter().enumerate() {
892 collector_ref.report(
893 format!("test.py::test_{}", i),
894 outcome_str.to_string(),
895 0.01,
896 None,
897 );
898 }
899
900 let results = collector_ref.take_results();
901 assert_eq!(results.len(), outcomes.len());
902
903 for (i, (_, expected_outcome)) in outcomes.iter().enumerate() {
904 assert_eq!(
905 results[i].outcome, *expected_outcome,
906 "Mismatch at index {}",
907 i
908 );
909 }
910 });
911 }
912
913 #[test]
914 fn test_embedded_executor_execution_mode() {
915 if EmbeddedExecutor::is_available() {
916 let executor = EmbeddedExecutor::new(None).unwrap();
917 assert_eq!(executor.execution_mode(), "embedded");
918 }
919 }
920
921 #[test]
922 fn test_embedded_executor_configure() {
923 use crate::executor::TestExecutor;
924
925 if EmbeddedExecutor::is_available() {
926 let mut executor = EmbeddedExecutor::new(None).unwrap();
927 let config = ExecutorConfig {
928 workers: Some(4),
929 maxfail: Some(10),
930 batch_size: 100,
931 test_timeout_secs: 120,
932 extra_args: vec!["--tb=long".to_string()],
933 };
934 TestExecutor::configure(&mut executor, config);
936 assert_eq!(executor.execution_mode(), "embedded");
938 }
939 }
940
941 #[test]
942 fn test_embedded_executor_kill_all() {
943 if EmbeddedExecutor::is_available() {
944 let executor = EmbeddedExecutor::new(None).unwrap();
945 executor.kill_all();
947 }
948 }
949
950 #[test]
951 fn test_duration_conversion() {
952 Python::with_gil(|py| {
953 let collector = Py::new(py, RustResultCollector::new()).unwrap();
954 let collector_ref = collector.borrow(py);
955
956 collector_ref.report(
958 "test.py::test_1".to_string(),
959 "passed".to_string(),
960 1.5, None,
962 );
963 collector_ref.report(
964 "test.py::test_2".to_string(),
965 "passed".to_string(),
966 0.001, None,
968 );
969
970 let results = collector_ref.take_results();
971 assert_eq!(results[0].duration_ms, 1500);
972 assert_eq!(results[1].duration_ms, 1);
973 });
974 }
975
976 #[test]
977 fn test_embedded_executor_run_passing_test() {
978 use std::fs;
979 use tempfile::TempDir;
980
981 if !EmbeddedExecutor::is_available() {
982 return;
983 }
984
985 let dir = TempDir::new().unwrap();
987 let test_file = dir.path().join("test_example.py");
988 fs::write(&test_file, "def test_passing():\n assert 1 + 1 == 2\n").unwrap();
989
990 let executor = EmbeddedExecutor::new(None).unwrap();
991 let node_id = format!("{}::test_passing", test_file.to_string_lossy());
992
993 let result = executor.run_test(&node_id);
994 assert!(result.is_ok());
995 let result = result.unwrap();
996 assert!(matches!(result.outcome, TestOutcome::Passed));
997 }
998
999 #[test]
1000 fn test_embedded_executor_run_failing_test() {
1001 use std::fs;
1002 use tempfile::TempDir;
1003
1004 if !EmbeddedExecutor::is_available() {
1005 return;
1006 }
1007
1008 let dir = TempDir::new().unwrap();
1010 let test_file = dir.path().join("test_fail.py");
1011 fs::write(&test_file, "def test_failing():\n assert 1 == 2\n").unwrap();
1012
1013 let executor = EmbeddedExecutor::new(None).unwrap();
1014 let node_id = format!("{}::test_failing", test_file.to_string_lossy());
1015
1016 let result = executor.run_test(&node_id);
1017 assert!(result.is_ok());
1018 let result = result.unwrap();
1019 assert!(matches!(result.outcome, TestOutcome::Failed));
1020 }
1021
1022 #[test]
1023 fn test_embedded_executor_run_multiple_tests() {
1024 use std::fs;
1025 use tempfile::TempDir;
1026
1027 if !EmbeddedExecutor::is_available() {
1028 return;
1029 }
1030
1031 let dir = TempDir::new().unwrap();
1033 let test_file = dir.path().join("test_multi.py");
1034 fs::write(
1035 &test_file,
1036 "def test_one():\n assert True\n\ndef test_two():\n assert True\n\ndef test_three():\n assert False\n",
1037 )
1038 .unwrap();
1039
1040 let executor = EmbeddedExecutor::new(None).unwrap();
1041 let file_path = test_file.to_string_lossy();
1042 let node_ids = vec![
1043 format!("{}::test_one", file_path),
1044 format!("{}::test_two", file_path),
1045 format!("{}::test_three", file_path),
1046 ];
1047
1048 let results = executor.run_tests(&node_ids);
1049 assert!(results.is_ok());
1050 let results = results.unwrap();
1051 assert_eq!(results.len(), 3);
1052 assert!(matches!(results[0].outcome, TestOutcome::Passed));
1053 assert!(matches!(results[1].outcome, TestOutcome::Passed));
1054 assert!(matches!(results[2].outcome, TestOutcome::Failed));
1055 }
1056
1057 #[test]
1058 fn test_embedded_executor_run_empty() {
1059 if !EmbeddedExecutor::is_available() {
1060 return;
1061 }
1062
1063 let executor = EmbeddedExecutor::new(None).unwrap();
1064 let results = executor.run_tests(&[]);
1065 assert!(results.is_ok());
1066 assert!(results.unwrap().is_empty());
1067 }
1068
1069 #[test]
1070 fn test_embedded_executor_run_skipped_test() {
1071 use std::fs;
1072 use tempfile::TempDir;
1073
1074 if !EmbeddedExecutor::is_available() {
1075 return;
1076 }
1077
1078 let dir = TempDir::new().unwrap();
1080 let test_file = dir.path().join("test_skip.py");
1081 fs::write(
1082 &test_file,
1083 "import pytest\n\n@pytest.mark.skip(reason='test skip')\ndef test_skipped():\n assert True\n",
1084 )
1085 .unwrap();
1086
1087 let executor = EmbeddedExecutor::new(None).unwrap();
1088 let node_id = format!("{}::test_skipped", test_file.to_string_lossy());
1089
1090 let result = executor.run_test(&node_id);
1091 assert!(result.is_ok());
1092 let result = result.unwrap();
1093 assert!(matches!(result.outcome, TestOutcome::Skipped));
1094 }
1095}