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
25static CACHED_PLUGIN_CLASS: OnceLock<Py<PyAny>> = OnceLock::new();
27
28static FIRST_RUN: AtomicBool = AtomicBool::new(true);
30
31const 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#[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 #[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 fn set_collected_count(&self, count: usize) {
154 *self.collected_count.lock() = count;
155 }
156
157 fn set_exit_status(&self, status: i32) {
159 *self.exit_status.lock() = status;
160 }
161
162 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 pub fn take_results(&self) -> Vec<TestResult> {
191 std::mem::take(&mut *self.results.lock())
192 }
193
194 pub fn collected_count(&self) -> usize {
196 *self.collected_count.lock()
197 }
198
199 pub fn exit_status(&self) -> i32 {
201 *self.exit_status.lock()
202 }
203}
204
205#[derive(Debug, Clone, Default)]
207pub struct EmbeddedExecutorConfig {
208 pub workers: Option<u32>,
210 pub maxfail: Option<u32>,
212 pub test_timeout_secs: u64,
214 pub extra_args: Vec<String>,
216}
217
218#[derive(Debug)]
220pub struct EmbeddedExecutor {
221 config: EmbeddedExecutorConfig,
222 initialized: bool,
224 python_version: Option<(u8, u8, u8)>,
226 python_path: Option<PathBuf>,
228}
229
230impl EmbeddedExecutor {
231 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 executor.initialize()?;
242
243 Ok(executor)
244 }
245
246 pub fn configure(&mut self, config: EmbeddedExecutorConfig) {
248 self.config = config;
249 }
250
251 fn initialize(&mut self) -> Result<()> {
253 Python::with_gil(|py| {
254 let version = py.version_info();
256 self.python_version = Some((version.major, version.minor, version.patch));
257
258 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 self.setup_python_path(py)?;
269
270 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 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 if let Some(ref python_path) = self.python_path {
309 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 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 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 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 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 let mut keys_to_remove = Vec::with_capacity(test_paths.len() * 2);
375
376 for test_path in test_paths {
377 let module_path = test_path
379 .trim_end_matches(".py")
380 .replace('/', ".");
381
382 if modules.contains(&module_path)? {
384 keys_to_remove.push(module_path.clone());
385 }
386
387 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 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 fn reset_signal_handlers(py: Python) -> PyResult<()> {
416 let signal = py.import_bound("signal")?;
417
418 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 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 fn reset_asyncio(py: Python) -> PyResult<()> {
440 if let Ok(asyncio) = py.import_bound("asyncio") {
441 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 fn is_xdist_available(py: Python) -> bool {
452 py.import_bound("xdist").is_ok()
453 }
454
455 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 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 let test_paths: Vec<&str> = node_ids
491 .iter()
492 .filter_map(|id| id.split("::").next())
493 .collect();
494
495 let is_first_run = FIRST_RUN.swap(false, Ordering::SeqCst);
497 if !is_first_run {
498 Self::clear_test_modules(py, &test_paths)?;
499 Self::reset_signal_handlers(py)?;
501 Self::reset_asyncio(py)?;
503 }
504
505 let collector = Py::new(py, RustResultCollector::new()).map_err(|e| {
507 DaemonError::Other(format!("Failed to create result collector: {}", e))
508 })?;
509
510 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 let mut args: Vec<String> = node_ids.to_vec();
519
520 args.push("-v".to_string());
522 args.push("--tb=short".to_string());
523 args.push("--no-header".to_string());
524
525 args.push("-p".to_string());
527 args.push("no:cacheprovider".to_string());
528
529 if let Some(maxfail) = self.config.maxfail {
531 args.push(format!("--maxfail={}", maxfail));
532 }
533
534 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 args.extend(self.config.extra_args.clone());
547
548 let py_args = PyList::new_bound(py, &args);
550
551 let plugins = PyList::new_bound(py, [plugin_instance]);
553
554 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 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 let collector_ref = collector.borrow(py);
579 let results = collector_ref.take_results();
580
581 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 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 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 pub fn python_version(&self) -> Option<(u8, u8, u8)> {
625 self.python_version
626 }
627
628 pub fn is_available() -> bool {
630 Python::with_gil(|py| {
631 let version = py.version_info();
633 if version.major < 3 || (version.major == 3 && version.minor < 8) {
634 return false;
635 }
636
637 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(self, node_id)
654 }
655
656 async fn run_tests(&self, node_ids: &[String]) -> Vec<TestResult> {
657 match EmbeddedExecutor::run_tests(self, node_ids) {
659 Ok(results) => results,
660 Err(e) => {
661 warn!("Embedded execution failed: {}", e);
662 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 }
695}
696
697#[cfg(test)]
698mod tests {
699 use super::*;
700
701 #[test]
702 fn test_embedded_executor_creation() {
703 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 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 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 let results1 = collector_ref.take_results();
790 assert_eq!(results1.len(), 1);
791
792 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 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 TestExecutor::configure(&mut executor, config);
855 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 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 collector_ref.report(
877 "test.py::test_1".to_string(),
878 "passed".to_string(),
879 1.5, None,
881 );
882 collector_ref.report(
883 "test.py::test_2".to_string(),
884 "passed".to_string(),
885 0.001, 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 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 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 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 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}