Skip to main content

testx/
runner.rs

1use std::collections::HashMap;
2use std::io::{BufRead, BufReader};
3use std::path::PathBuf;
4use std::process::{Command, Stdio};
5use std::sync::mpsc;
6use std::thread;
7use std::time::{Duration, Instant};
8
9use crate::adapters::TestRunResult;
10use crate::config::Config;
11use crate::detection::DetectionEngine;
12use crate::error::{Result, TestxError};
13use crate::events::{EventBus, Stream, TestEvent};
14
15/// Configuration for a test run.
16#[derive(Debug, Clone)]
17pub struct RunnerConfig {
18    /// Project directory to run tests in.
19    pub project_dir: PathBuf,
20
21    /// Override adapter selection by name.
22    pub adapter_override: Option<String>,
23
24    /// Extra arguments to pass to the test runner.
25    pub extra_args: Vec<String>,
26
27    /// Maximum time to wait for test completion.
28    pub timeout: Option<Duration>,
29
30    /// Environment variables to set for the test process.
31    pub env: HashMap<String, String>,
32
33    /// Number of times to retry failed tests.
34    pub retries: u32,
35
36    /// Stop on first test failure.
37    pub fail_fast: bool,
38
39    /// Test name filter pattern.
40    pub filter: Option<String>,
41
42    /// Exclude pattern.
43    pub exclude: Option<String>,
44
45    /// Verbose mode (print commands, detection details).
46    pub verbose: bool,
47}
48
49impl Default for RunnerConfig {
50    fn default() -> Self {
51        Self {
52            project_dir: PathBuf::from("."),
53            adapter_override: None,
54            extra_args: Vec::new(),
55            timeout: None,
56            env: HashMap::new(),
57            retries: 0,
58            fail_fast: false,
59            filter: None,
60            exclude: None,
61            verbose: false,
62        }
63    }
64}
65
66impl RunnerConfig {
67    pub fn new(project_dir: PathBuf) -> Self {
68        Self {
69            project_dir,
70            ..Default::default()
71        }
72    }
73
74    /// Merge values from a Config file (CLI args take precedence).
75    pub fn merge_config(&mut self, config: &Config) {
76        if self.adapter_override.is_none() {
77            self.adapter_override = config.adapter.clone();
78        }
79        if self.extra_args.is_empty() {
80            self.extra_args = config.args.clone();
81        }
82        if self.timeout.is_none() {
83            self.timeout = config.timeout.filter(|&t| t > 0).map(Duration::from_secs);
84        }
85        for (key, value) in &config.env {
86            self.env.entry(key.clone()).or_insert_with(|| value.clone());
87        }
88    }
89}
90
91/// Execution result with raw output captured for display purposes.
92#[derive(Debug, Clone)]
93pub struct ExecutionOutput {
94    pub stdout: String,
95    pub stderr: String,
96    pub exit_code: i32,
97    pub duration: Duration,
98    pub timed_out: bool,
99}
100
101/// The main test runner engine.
102pub struct Runner {
103    engine: DetectionEngine,
104    config: RunnerConfig,
105    event_bus: EventBus,
106}
107
108impl Runner {
109    pub fn new(config: RunnerConfig) -> Self {
110        Self {
111            engine: DetectionEngine::new(),
112            config,
113            event_bus: EventBus::new(),
114        }
115    }
116
117    pub fn with_event_bus(mut self, event_bus: EventBus) -> Self {
118        self.event_bus = event_bus;
119        self
120    }
121
122    pub fn event_bus(&self) -> &EventBus {
123        &self.event_bus
124    }
125
126    pub fn event_bus_mut(&mut self) -> &mut EventBus {
127        &mut self.event_bus
128    }
129
130    pub fn config(&self) -> &RunnerConfig {
131        &self.config
132    }
133
134    pub fn engine(&self) -> &DetectionEngine {
135        &self.engine
136    }
137
138    /// Run tests, auto-detecting the adapter or using the configured override.
139    pub fn run(&mut self) -> Result<(TestRunResult, ExecutionOutput)> {
140        let (adapter_index, adapter_name, framework) = self.resolve_adapter()?;
141
142        self.event_bus.emit(TestEvent::RunStarted {
143            adapter: adapter_name.clone(),
144            framework: framework.clone(),
145            project_dir: self.config.project_dir.clone(),
146        });
147
148        // Phase 1: borrow engine immutably to build command and check runner
149        let (mut cmd, _adapter_name_check) = {
150            let adapter = self.engine.adapter(adapter_index);
151
152            if let Some(missing) = adapter.check_runner() {
153                return Err(TestxError::RunnerNotFound { runner: missing });
154            }
155
156            let cmd = adapter
157                .build_command(&self.config.project_dir, &self.config.extra_args)
158                .map_err(|e| TestxError::ExecutionFailed {
159                    command: adapter_name.clone(),
160                    source: std::io::Error::other(e.to_string()),
161                })?;
162
163            (cmd, adapter.name().to_string())
164        };
165
166        // Set environment variables
167        for (key, value) in &self.config.env {
168            cmd.env(key, value);
169        }
170
171        if self.config.verbose {
172            eprintln!("cmd: {:?}", cmd);
173        }
174
175        // Phase 2: execute (borrows self mutably for event bus)
176        let exec_output = self.execute_command(&mut cmd)?;
177
178        // Phase 3: parse (borrows engine immutably again)
179        let adapter = self.engine.adapter(adapter_index);
180        let mut result = adapter.parse_output(
181            &exec_output.stdout,
182            &exec_output.stderr,
183            exec_output.exit_code,
184        );
185
186        // Use wall-clock time if parser didn't capture duration
187        if result.duration.as_millis() == 0 {
188            result.duration = exec_output.duration;
189        }
190
191        self.event_bus.emit(TestEvent::RunFinished {
192            result: result.clone(),
193        });
194        self.event_bus.flush();
195
196        Ok((result, exec_output))
197    }
198
199    /// Run tests using a specific adapter by index.
200    pub fn run_with_adapter(
201        &mut self,
202        adapter_index: usize,
203    ) -> Result<(TestRunResult, ExecutionOutput)> {
204        // Phase 1: borrow engine to build command
205        let (mut cmd, adapter_name) = {
206            let adapter = self.engine.adapter(adapter_index);
207            let name = adapter.name().to_string();
208
209            if let Some(missing) = adapter.check_runner() {
210                return Err(TestxError::RunnerNotFound { runner: missing });
211            }
212
213            let cmd = adapter
214                .build_command(&self.config.project_dir, &self.config.extra_args)
215                .map_err(|e| TestxError::ExecutionFailed {
216                    command: name.clone(),
217                    source: std::io::Error::other(e.to_string()),
218                })?;
219
220            (cmd, name)
221        };
222
223        self.event_bus.emit(TestEvent::RunStarted {
224            adapter: adapter_name.clone(),
225            framework: adapter_name.clone(),
226            project_dir: self.config.project_dir.clone(),
227        });
228
229        for (key, value) in &self.config.env {
230            cmd.env(key, value);
231        }
232
233        // Phase 2: execute
234        let exec_output = self.execute_command(&mut cmd)?;
235
236        // Phase 3: parse
237        let adapter = self.engine.adapter(adapter_index);
238        let mut result = adapter.parse_output(
239            &exec_output.stdout,
240            &exec_output.stderr,
241            exec_output.exit_code,
242        );
243
244        if result.duration.as_millis() == 0 {
245            result.duration = exec_output.duration;
246        }
247
248        self.event_bus.emit(TestEvent::RunFinished {
249            result: result.clone(),
250        });
251        self.event_bus.flush();
252
253        Ok((result, exec_output))
254    }
255
256    /// Resolve which adapter to use: explicit override or auto-detect.
257    fn resolve_adapter(&self) -> Result<(usize, String, String)> {
258        if let Some(name) = &self.config.adapter_override {
259            let index = self
260                .engine
261                .adapters()
262                .iter()
263                .position(|a| a.name().to_lowercase() == name.to_lowercase())
264                .ok_or_else(|| TestxError::AdapterNotFound { name: name.clone() })?;
265
266            let adapter = self.engine.adapter(index);
267            Ok((
268                index,
269                adapter.name().to_string(),
270                adapter.name().to_string(),
271            ))
272        } else {
273            let detected = self
274                .engine
275                .detect(&self.config.project_dir)
276                .ok_or_else(|| TestxError::NoFrameworkDetected {
277                    path: self.config.project_dir.clone(),
278                })?;
279
280            let adapter = self.engine.adapter(detected.adapter_index);
281            Ok((
282                detected.adapter_index,
283                adapter.name().to_string(),
284                detected.detection.framework.clone(),
285            ))
286        }
287    }
288
289    /// Execute a command, streaming output line-by-line and respecting timeouts.
290    fn execute_command(&mut self, cmd: &mut Command) -> Result<ExecutionOutput> {
291        let start = Instant::now();
292
293        // On Unix, spawn in a new process group so we can kill the entire tree
294        #[cfg(unix)]
295        {
296            use std::os::unix::process::CommandExt;
297            // SAFETY: pre_exec is called after fork, before exec. setpgid(0,0)
298            // makes the child the leader of its own process group.
299            unsafe {
300                cmd.pre_exec(|| {
301                    if libc::setpgid(0, 0) != 0 {
302                        return Err(std::io::Error::last_os_error());
303                    }
304                    Ok(())
305                });
306            }
307        }
308
309        let mut child = cmd
310            .stdout(Stdio::piped())
311            .stderr(Stdio::piped())
312            .spawn()
313            .map_err(|e| TestxError::ExecutionFailed {
314                command: format!("{:?}", cmd),
315                source: e,
316            })?;
317
318        // Take ownership of stdout/stderr pipes
319        let child_stdout = child.stdout.take();
320        let child_stderr = child.stderr.take();
321
322        // Channel for collecting lines from both streams
323        let (tx, rx) = mpsc::channel();
324
325        // Spawn stdout reader thread
326        let tx_out = tx.clone();
327        let stdout_handle = thread::spawn(move || {
328            let mut lines = Vec::new();
329            if let Some(pipe) = child_stdout {
330                let reader = BufReader::new(pipe);
331                for line in reader.lines().map_while(|r| r.ok()) {
332                    let _ = tx_out.send((Stream::Stdout, line.clone()));
333                    lines.push(line);
334                }
335            }
336            lines
337        });
338
339        // Spawn stderr reader thread
340        let stderr_handle = thread::spawn(move || {
341            let mut lines = Vec::new();
342            if let Some(pipe) = child_stderr {
343                let reader = BufReader::new(pipe);
344                for line in reader.lines().map_while(|r| r.ok()) {
345                    let _ = tx.send((Stream::Stderr, line.clone()));
346                    lines.push(line);
347                }
348            }
349            lines
350        });
351
352        // Process events from stream readers
353        let timeout = self.config.timeout;
354        let mut timed_out = false;
355
356        // Drop rx in a non-blocking way if we have a timeout
357        if let Some(timeout_dur) = timeout {
358            loop {
359                match rx.recv_timeout(Duration::from_millis(100)) {
360                    Ok((stream, line)) => {
361                        self.event_bus.emit(TestEvent::RawOutput { stream, line });
362                    }
363                    Err(mpsc::RecvTimeoutError::Timeout) => {
364                        if start.elapsed() > timeout_dur {
365                            timed_out = true;
366                            // Kill the entire process group (child + grandchildren)
367                            #[cfg(unix)]
368                            {
369                                // Send SIGKILL to the process group (-pid)
370                                let pid = child.id() as libc::pid_t;
371                                if unsafe { libc::kill(-pid, libc::SIGKILL) } != 0 {
372                                    eprintln!(
373                                        "warning: failed to kill process group {}: {}",
374                                        pid,
375                                        std::io::Error::last_os_error()
376                                    );
377                                    let _ = child.kill();
378                                }
379                            }
380                            #[cfg(not(unix))]
381                            {
382                                let _ = child.kill();
383                            }
384                            let _ = child.wait();
385                            break;
386                        }
387                    }
388                    Err(mpsc::RecvTimeoutError::Disconnected) => break,
389                }
390            }
391        } else {
392            // No timeout — just drain events
393            for (stream, line) in rx {
394                self.event_bus.emit(TestEvent::RawOutput { stream, line });
395            }
396        }
397
398        // Collect results from threads — log warnings if reader threads panicked
399        let stdout_lines = match stdout_handle.join() {
400            Ok(lines) => lines,
401            Err(_) => {
402                eprintln!("warning: stdout reader thread panicked");
403                Vec::new()
404            }
405        };
406        let stderr_lines = match stderr_handle.join() {
407            Ok(lines) => lines,
408            Err(_) => {
409                eprintln!("warning: stderr reader thread panicked");
410                Vec::new()
411            }
412        };
413
414        let exit_code = if timed_out {
415            124
416        } else {
417            child.wait().map(|s| s.code().unwrap_or(1)).unwrap_or(1)
418        };
419
420        let duration = start.elapsed();
421
422        if timed_out && let Some(secs) = self.config.timeout {
423            self.event_bus.emit(TestEvent::Warning {
424                message: format!("Test timed out after {}s", secs.as_secs()),
425            });
426        }
427
428        Ok(ExecutionOutput {
429            stdout: stdout_lines.join("\n"),
430            stderr: stderr_lines.join("\n"),
431            exit_code,
432            duration,
433            timed_out,
434        })
435    }
436}
437
438/// Build a RunnerConfig from CLI args and config file.
439pub fn build_runner_config(
440    project_dir: PathBuf,
441    config: &Config,
442    extra_args: Vec<String>,
443    timeout: Option<u64>,
444    verbose: bool,
445) -> RunnerConfig {
446    let mut rc = RunnerConfig::new(project_dir);
447    rc.extra_args = extra_args;
448    rc.timeout = timeout.map(Duration::from_secs);
449    rc.verbose = verbose;
450    rc.merge_config(config);
451    rc
452}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457
458    #[test]
459    fn runner_config_default() {
460        let cfg = RunnerConfig::default();
461        assert_eq!(cfg.project_dir, PathBuf::from("."));
462        assert!(cfg.adapter_override.is_none());
463        assert!(cfg.extra_args.is_empty());
464        assert!(cfg.timeout.is_none());
465        assert!(cfg.env.is_empty());
466        assert_eq!(cfg.retries, 0);
467        assert!(!cfg.fail_fast);
468        assert!(cfg.filter.is_none());
469        assert!(cfg.exclude.is_none());
470        assert!(!cfg.verbose);
471    }
472
473    #[test]
474    fn runner_config_new() {
475        let cfg = RunnerConfig::new(PathBuf::from("/tmp/project"));
476        assert_eq!(cfg.project_dir, PathBuf::from("/tmp/project"));
477    }
478
479    #[test]
480    fn runner_config_merge_config() {
481        let mut rc = RunnerConfig::new(PathBuf::from("."));
482
483        let config = Config {
484            adapter: Some("python".into()),
485            args: vec!["-v".into()],
486            timeout: Some(60),
487            env: HashMap::from([("CI".into(), "true".into())]),
488            ..Default::default()
489        };
490
491        rc.merge_config(&config);
492
493        assert_eq!(rc.adapter_override.as_deref(), Some("python"));
494        assert_eq!(rc.extra_args, vec!["-v"]);
495        assert_eq!(rc.timeout, Some(Duration::from_secs(60)));
496        assert_eq!(rc.env.get("CI").map(|s| s.as_str()), Some("true"));
497    }
498
499    #[test]
500    fn runner_config_merge_cli_takes_precedence() {
501        let mut rc = RunnerConfig::new(PathBuf::from("."));
502        rc.adapter_override = Some("rust".into());
503        rc.extra_args = vec!["--release".into()];
504        rc.timeout = Some(Duration::from_secs(30));
505        rc.env.insert("CI".into(), "false".into());
506
507        let config = Config {
508            adapter: Some("python".into()),
509            args: vec!["-v".into()],
510            timeout: Some(60),
511            env: HashMap::from([("CI".into(), "true".into())]),
512            ..Default::default()
513        };
514
515        rc.merge_config(&config);
516
517        // CLI values should win
518        assert_eq!(rc.adapter_override.as_deref(), Some("rust"));
519        assert_eq!(rc.extra_args, vec!["--release"]);
520        assert_eq!(rc.timeout, Some(Duration::from_secs(30)));
521        assert_eq!(rc.env.get("CI").map(|s| s.as_str()), Some("false"));
522    }
523
524    #[test]
525    fn build_runner_config_function() {
526        let mut config = Config::default();
527        config.env.insert("FOO".into(), "bar".into());
528
529        let rc = build_runner_config(
530            PathBuf::from("/tmp"),
531            &config,
532            vec!["--arg".into()],
533            Some(30),
534            true,
535        );
536
537        assert_eq!(rc.project_dir, PathBuf::from("/tmp"));
538        assert_eq!(rc.extra_args, vec!["--arg"]);
539        assert_eq!(rc.timeout, Some(Duration::from_secs(30)));
540        assert!(rc.verbose);
541        assert_eq!(rc.env.get("FOO").map(|s| s.as_str()), Some("bar"));
542    }
543
544    #[test]
545    fn runner_new() {
546        let cfg = RunnerConfig::new(PathBuf::from("."));
547        let runner = Runner::new(cfg);
548        assert_eq!(runner.config().project_dir, PathBuf::from("."));
549        assert_eq!(runner.event_bus().handler_count(), 0);
550    }
551
552    #[test]
553    fn runner_with_event_bus() {
554        use crate::events::CountingHandler;
555
556        let cfg = RunnerConfig::new(PathBuf::from("."));
557        let mut bus = EventBus::new();
558        bus.subscribe(Box::new(CountingHandler::default()));
559
560        let runner = Runner::new(cfg).with_event_bus(bus);
561        assert_eq!(runner.event_bus().handler_count(), 1);
562    }
563
564    #[test]
565    fn runner_resolve_adapter_not_found() {
566        let mut cfg = RunnerConfig::new(PathBuf::from("."));
567        cfg.adapter_override = Some("nonexistent_language".into());
568
569        let runner = Runner::new(cfg);
570        let result = runner.resolve_adapter();
571        assert!(result.is_err());
572
573        match result.unwrap_err() {
574            TestxError::AdapterNotFound { name } => {
575                assert_eq!(name, "nonexistent_language");
576            }
577            other => panic!("expected AdapterNotFound, got: {}", other),
578        }
579    }
580
581    #[test]
582    fn runner_resolve_adapter_by_name() {
583        let dir = tempfile::tempdir().unwrap();
584        let mut cfg = RunnerConfig::new(dir.path().to_path_buf());
585        cfg.adapter_override = Some("Rust".into());
586
587        let runner = Runner::new(cfg);
588        let (index, name, _) = runner.resolve_adapter().unwrap();
589        assert_eq!(name, "Rust");
590        assert!(index < runner.engine().adapters().len());
591    }
592
593    #[test]
594    fn runner_resolve_adapter_case_insensitive() {
595        let dir = tempfile::tempdir().unwrap();
596        let mut cfg = RunnerConfig::new(dir.path().to_path_buf());
597        cfg.adapter_override = Some("python".into());
598
599        let runner = Runner::new(cfg);
600        let (_, name, _) = runner.resolve_adapter().unwrap();
601        assert_eq!(name, "Python");
602    }
603
604    #[test]
605    fn runner_resolve_adapter_auto_detect() {
606        let dir = tempfile::tempdir().unwrap();
607        std::fs::write(
608            dir.path().join("Cargo.toml"),
609            "[package]\nname = \"test\"\n",
610        )
611        .unwrap();
612
613        let cfg = RunnerConfig::new(dir.path().to_path_buf());
614        let runner = Runner::new(cfg);
615        let (_, name, framework) = runner.resolve_adapter().unwrap();
616        assert_eq!(name, "Rust");
617        assert_eq!(framework, "cargo test");
618    }
619
620    #[test]
621    fn runner_resolve_adapter_no_framework() {
622        let dir = tempfile::tempdir().unwrap();
623        let cfg = RunnerConfig::new(dir.path().to_path_buf());
624        let runner = Runner::new(cfg);
625        let result = runner.resolve_adapter();
626        assert!(result.is_err());
627
628        match result.unwrap_err() {
629            TestxError::NoFrameworkDetected { path } => {
630                assert_eq!(path, dir.path().to_path_buf());
631            }
632            other => panic!("expected NoFrameworkDetected, got: {}", other),
633        }
634    }
635
636    #[test]
637    fn execution_output_fields() {
638        let output = ExecutionOutput {
639            stdout: "hello".into(),
640            stderr: "world".into(),
641            exit_code: 0,
642            duration: Duration::from_millis(100),
643            timed_out: false,
644        };
645
646        assert_eq!(output.stdout, "hello");
647        assert_eq!(output.stderr, "world");
648        assert_eq!(output.exit_code, 0);
649        assert!(!output.timed_out);
650    }
651
652    #[test]
653    fn execution_output_timed_out() {
654        let output = ExecutionOutput {
655            stdout: String::new(),
656            stderr: "Timed out".into(),
657            exit_code: 124,
658            duration: Duration::from_secs(30),
659            timed_out: true,
660        };
661
662        assert!(output.timed_out);
663        assert_eq!(output.exit_code, 124);
664    }
665}