1use std::collections::{BTreeMap, BTreeSet};
23use std::ffi::OsString;
24use std::io::{self, Read, Write};
25use std::path::{Path, PathBuf};
26use std::process::{Command, ExitStatus, Stdio};
27use std::sync::mpsc;
28use std::thread;
29use std::time::Duration;
30
31use cabin_build::{BuildGraph, Dialect};
32use cabin_core::TargetKind;
33use cabin_workspace::{PackageGraph, WorkspacePackage};
34use thiserror::Error;
35
36#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct TestExecutable {
39 pub package: String,
42 pub target: String,
44 pub executable: PathBuf,
46 pub working_dir: PathBuf,
50 pub env: BTreeMap<String, OsString>,
57}
58
59#[derive(Debug, Clone, Default)]
66pub struct TestPlan {
67 executables: Vec<TestExecutable>,
68}
69
70impl<'a> IntoIterator for &'a TestPlan {
71 type Item = &'a TestExecutable;
72 type IntoIter = std::slice::Iter<'a, TestExecutable>;
73
74 fn into_iter(self) -> Self::IntoIter {
75 self.executables.iter()
76 }
77}
78
79impl TestPlan {
80 pub fn iter(&self) -> std::slice::Iter<'_, TestExecutable> {
82 self.executables.iter()
83 }
84
85 pub fn len(&self) -> usize {
87 self.executables.len()
88 }
89
90 pub fn is_empty(&self) -> bool {
92 self.executables.is_empty()
93 }
94
95 pub fn for_each_executable_mut(&mut self, mut f: impl FnMut(&mut TestExecutable)) {
100 for exe in &mut self.executables {
101 f(exe);
102 }
103 }
104}
105
106pub fn plan_tests(
126 package_graph: &PackageGraph,
127 build_graph: &BuildGraph,
128 selected_packages: Option<&[usize]>,
129) -> TestPlan {
130 let outputs: BTreeSet<&Path> = build_graph
133 .default_outputs
134 .iter()
135 .map(|p| p.as_std_path())
136 .collect();
137
138 let pkg_indices: Vec<usize> = match selected_packages {
139 Some(s) => s.to_vec(),
140 None => package_graph.primary_packages.clone(),
141 };
142
143 let mut entries: Vec<TestExecutable> = Vec::new();
144 for idx in pkg_indices {
145 let package = &package_graph.packages[idx];
146 for target in &package.package.targets {
147 if target.kind != TargetKind::Test {
148 continue;
149 }
150 let Some(exe) =
155 expected_executable(package, target.name.as_str(), build_graph.dialect, &outputs)
156 else {
157 continue;
158 };
159 entries.push(TestExecutable {
160 package: package.package.name.as_str().to_owned(),
161 target: target.name.as_str().to_owned(),
162 executable: exe.to_path_buf(),
163 working_dir: package.manifest_dir.clone(),
164 env: BTreeMap::new(),
165 });
166 }
167 }
168
169 entries.sort_by(|a, b| {
170 a.package
171 .cmp(&b.package)
172 .then_with(|| a.target.cmp(&b.target))
173 });
174 TestPlan {
175 executables: entries,
176 }
177}
178
179fn expected_executable<'a>(
180 package: &WorkspacePackage,
181 target_name: &str,
182 dialect: Dialect,
183 outputs: &BTreeSet<&'a Path>,
184) -> Option<&'a Path> {
185 let exe_name = dialect.executable_name(target_name);
195 let needle_tail: PathBuf = ["packages", package.package.name.as_str(), &exe_name]
196 .iter()
197 .collect();
198 outputs.iter().copied().find(|p| p.ends_with(&needle_tail))
199}
200
201#[derive(Debug, Clone, PartialEq, Eq)]
203pub struct TestRunResult {
204 pub executable: TestExecutable,
206 pub status: TestRunStatus,
208 pub stdout: Vec<u8>,
210 pub stderr: Vec<u8>,
212}
213
214#[derive(Debug, Clone, Copy, PartialEq, Eq)]
216pub enum TestRunStatus {
217 Passed,
219 Failed { code: Option<i32> },
222}
223
224impl TestRunStatus {
225 pub const fn is_success(self) -> bool {
227 matches!(self, TestRunStatus::Passed)
228 }
229
230 fn from_status(status: ExitStatus) -> Self {
231 if status.success() {
232 Self::Passed
233 } else {
234 Self::Failed {
235 code: status.code(),
236 }
237 }
238 }
239}
240
241#[derive(Debug, Clone, PartialEq, Eq)]
243pub struct TestSummary {
244 pub results: Vec<TestRunResult>,
246}
247
248impl TestSummary {
249 pub fn total(&self) -> usize {
251 self.results.len()
252 }
253
254 pub fn passed(&self) -> usize {
256 self.results
257 .iter()
258 .filter(|r| r.status.is_success())
259 .count()
260 }
261
262 pub fn failed(&self) -> usize {
264 self.results
265 .iter()
266 .filter(|r| !r.status.is_success())
267 .count()
268 }
269
270 pub fn all_passed(&self) -> bool {
272 self.results.iter().all(|r| r.status.is_success())
273 }
274}
275
276pub trait TestOutputSink {
282 fn write_stdout(&mut self, executable: &TestExecutable, bytes: &[u8]) -> io::Result<()>;
288 fn write_stderr(&mut self, executable: &TestExecutable, bytes: &[u8]) -> io::Result<()>;
294}
295
296impl TestOutputSink for () {
297 fn write_stdout(&mut self, _executable: &TestExecutable, _bytes: &[u8]) -> io::Result<()> {
298 Ok(())
299 }
300 fn write_stderr(&mut self, _executable: &TestExecutable, _bytes: &[u8]) -> io::Result<()> {
301 Ok(())
302 }
303}
304
305pub fn null_sink() -> impl TestOutputSink {}
308
309pub struct StreamingSink<W1, W2> {
313 pub stdout: W1,
316 pub stderr: W2,
319}
320
321fn write_labeled<W: Write>(
325 writer: &mut W,
326 executable: &TestExecutable,
327 bytes: &[u8],
328 label: &str,
329) -> io::Result<()> {
330 if !bytes.is_empty() {
331 writeln!(
332 writer,
333 "---- {label}: {}:{} ----",
334 executable.package, executable.target
335 )?;
336 writer.write_all(bytes)?;
337 if !bytes.ends_with(b"\n") {
338 writer.write_all(b"\n")?;
339 }
340 }
341 Ok(())
342}
343
344impl<W1: Write, W2: Write> TestOutputSink for StreamingSink<W1, W2> {
345 fn write_stdout(&mut self, executable: &TestExecutable, bytes: &[u8]) -> io::Result<()> {
346 write_labeled(&mut self.stdout, executable, bytes, "stdout")
347 }
348 fn write_stderr(&mut self, executable: &TestExecutable, bytes: &[u8]) -> io::Result<()> {
349 write_labeled(&mut self.stderr, executable, bytes, "stderr")
350 }
351}
352
353pub fn run_tests<S: TestOutputSink>(
377 plan: &TestPlan,
378 sink: &mut S,
379) -> Result<TestSummary, TestRunError> {
380 let mut results: Vec<TestRunResult> = Vec::with_capacity(plan.executables.len());
381 for executable in &plan.executables {
382 let mut command = Command::new(&executable.executable);
383 command.current_dir(&executable.working_dir);
384 command.stdout(Stdio::piped()).stderr(Stdio::piped());
385 for (key, value) in &executable.env {
391 command.env(key, value);
392 }
393 let mut child = retry_on_etxtbsy(SPAWN_RETRY_ATTEMPTS, SPAWN_RETRY_BASE_DELAY, || {
399 command.spawn()
400 })
401 .map_err(|source| TestRunError::Spawn {
402 package: executable.package.clone(),
403 target: executable.target.clone(),
404 executable: executable.executable.clone(),
405 source,
406 })?;
407
408 let stdout = child
409 .stdout
410 .take()
411 .expect("stdout is piped before child spawn");
412 let stderr = child
413 .stderr
414 .take()
415 .expect("stderr is piped before child spawn");
416 let (tx, rx) = mpsc::channel();
417 let stdout_thread = spawn_output_reader(OutputStream::Stdout, stdout, tx.clone());
418 let stderr_thread = spawn_output_reader(OutputStream::Stderr, stderr, tx);
419
420 let mut stdout = Vec::new();
421 let mut stderr = Vec::new();
422 let output_result = forward_output_events(executable, sink, rx, &mut stdout, &mut stderr);
423 if let Err(err) = output_result {
424 let _ = child.kill();
425 let _ = child.wait();
426 let _ = stdout_thread.join();
427 let _ = stderr_thread.join();
428 return Err(err);
429 }
430 let status = child.wait().map_err(|source| TestRunError::Wait {
431 package: executable.package.clone(),
432 target: executable.target.clone(),
433 executable: executable.executable.clone(),
434 source,
435 })?;
436 let _ = stdout_thread.join();
437 let _ = stderr_thread.join();
438
439 results.push(TestRunResult {
440 executable: executable.clone(),
441 status: TestRunStatus::from_status(status),
442 stdout,
443 stderr,
444 });
445 }
446 Ok(TestSummary { results })
447}
448
449const SPAWN_RETRY_ATTEMPTS: u32 = 8;
451const SPAWN_RETRY_BASE_DELAY: Duration = Duration::from_millis(1);
454
455fn retry_on_etxtbsy<T>(
461 max_attempts: u32,
462 base_delay: Duration,
463 mut attempt: impl FnMut() -> io::Result<T>,
464) -> io::Result<T> {
465 let mut delay = base_delay;
466 let mut result = attempt();
467 for _ in 1..max_attempts {
468 match &result {
469 Err(err) if err.kind() == io::ErrorKind::ExecutableFileBusy => {}
470 _ => return result,
471 }
472 thread::sleep(delay);
473 delay = delay.saturating_mul(2);
474 result = attempt();
475 }
476 result
477}
478
479#[derive(Debug, Clone, Copy)]
480enum OutputStream {
481 Stdout,
482 Stderr,
483}
484
485struct OutputEvent {
486 stream: OutputStream,
487 bytes: Vec<u8>,
488}
489
490fn spawn_output_reader<R: Read + Send + 'static>(
491 stream: OutputStream,
492 mut reader: R,
493 tx: mpsc::Sender<Result<OutputEvent, io::Error>>,
494) -> thread::JoinHandle<()> {
495 thread::spawn(move || {
496 let mut buf = [0_u8; 8192];
497 loop {
498 match reader.read(&mut buf) {
499 Ok(0) => break,
500 Ok(n) => {
501 if tx
502 .send(Ok(OutputEvent {
503 stream,
504 bytes: buf[..n].to_vec(),
505 }))
506 .is_err()
507 {
508 break;
509 }
510 }
511 Err(source) => {
512 let _ = tx.send(Err(source));
513 break;
514 }
515 }
516 }
517 })
518}
519
520fn forward_output_events<S: TestOutputSink>(
521 executable: &TestExecutable,
522 sink: &mut S,
523 rx: mpsc::Receiver<Result<OutputEvent, io::Error>>,
524 stdout: &mut Vec<u8>,
525 stderr: &mut Vec<u8>,
526) -> Result<(), TestRunError> {
527 for event in rx {
528 let event = event.map_err(TestRunError::OutputIo)?;
529 match event.stream {
530 OutputStream::Stdout => {
531 sink.write_stdout(executable, &event.bytes)
532 .map_err(TestRunError::SinkIo)?;
533 stdout.extend_from_slice(&event.bytes);
534 }
535 OutputStream::Stderr => {
536 sink.write_stderr(executable, &event.bytes)
537 .map_err(TestRunError::SinkIo)?;
538 stderr.extend_from_slice(&event.bytes);
539 }
540 }
541 }
542 Ok(())
543}
544
545pub fn render_summary_line(summary: &TestSummary) -> String {
549 let total = summary.total();
550 let passed = summary.passed();
551 let failed = summary.failed();
552 let outcome = if failed == 0 { "ok" } else { "FAILED" };
553 format!("test result: {outcome}. {passed} passed; {failed} failed (of {total})")
554}
555
556pub fn render_running_line(executable: &TestExecutable) -> String {
559 format!("running test {}:{}", executable.package, executable.target)
560}
561
562pub fn render_result_line(result: &TestRunResult) -> String {
565 let label = match result.status {
566 TestRunStatus::Passed => "ok".to_owned(),
567 TestRunStatus::Failed { code: Some(c) } => format!("FAILED (exit {c})"),
568 TestRunStatus::Failed { code: None } => "FAILED (terminated by signal)".to_owned(),
569 };
570 format!(
571 "test {}:{} ... {label}",
572 result.executable.package, result.executable.target
573 )
574}
575
576#[derive(Debug, Error)]
578pub enum TestRunError {
579 #[error("failed to start test target `{package}:{target}` ({}): {source}", .executable.display())]
581 Spawn {
582 package: String,
583 target: String,
584 executable: PathBuf,
585 #[source]
586 source: io::Error,
587 },
588 #[error("failed to wait for test target `{package}:{target}` ({}): {source}", .executable.display())]
591 Wait {
592 package: String,
593 target: String,
594 executable: PathBuf,
595 #[source]
596 source: io::Error,
597 },
598 #[error("failed to read captured test output: {0}")]
600 OutputIo(#[source] io::Error),
601 #[error("failed to write captured test output: {0}")]
605 SinkIo(#[source] io::Error),
606}
607
608#[cfg(test)]
609mod tests {
610 use super::*;
611 #[cfg(unix)]
613 use assert_fs::TempDir;
614 #[cfg(unix)]
615 use assert_fs::prelude::*;
616 #[cfg(unix)]
617 use std::os::unix::fs::PermissionsExt;
618
619 #[cfg(unix)]
627 fn write_executable(file: &assert_fs::fixture::ChildPath, body: &str) {
628 file.write_str(body).unwrap();
629 let mut perms = std::fs::metadata(file.path()).unwrap().permissions();
630 perms.set_mode(0o755);
631 std::fs::set_permissions(file.path(), perms).unwrap();
632 }
633
634 #[test]
635 fn plan_orders_executables_by_package_then_target() {
636 let plan = TestPlan {
637 executables: vec![
638 TestExecutable {
639 package: "alpha".into(),
640 target: "z_test".into(),
641 executable: PathBuf::from("/tmp/x"),
642 working_dir: PathBuf::from("/tmp"),
643 env: BTreeMap::new(),
644 },
645 TestExecutable {
646 package: "alpha".into(),
647 target: "a_test".into(),
648 executable: PathBuf::from("/tmp/x"),
649 working_dir: PathBuf::from("/tmp"),
650 env: BTreeMap::new(),
651 },
652 ],
653 };
654 let summary = TestSummary {
658 results: plan
659 .executables
660 .iter()
661 .map(|e| TestRunResult {
662 executable: e.clone(),
663 status: TestRunStatus::Passed,
664 stdout: Vec::new(),
665 stderr: Vec::new(),
666 })
667 .collect(),
668 };
669 assert_eq!(summary.total(), 2);
670 assert_eq!(summary.passed(), 2);
671 assert!(summary.all_passed());
672 assert_eq!(
673 render_summary_line(&summary),
674 "test result: ok. 2 passed; 0 failed (of 2)"
675 );
676 }
677
678 #[test]
679 #[cfg(unix)]
680 fn run_tests_reports_pass_and_fail_in_summary() {
681 let dir = TempDir::new().unwrap();
682 let pass = dir.child("pass_test");
683 let fail = dir.child("fail_test");
684 write_executable(&pass, "#!/bin/sh\nexit 0\n");
685 write_executable(&fail, "#!/bin/sh\nexit 1\n");
686 let plan = TestPlan {
687 executables: vec![
688 TestExecutable {
689 package: "demo".into(),
690 target: "fail_test".into(),
691 executable: fail.to_path_buf(),
692 working_dir: dir.path().to_path_buf(),
693 env: BTreeMap::new(),
694 },
695 TestExecutable {
696 package: "demo".into(),
697 target: "pass_test".into(),
698 executable: pass.to_path_buf(),
699 working_dir: dir.path().to_path_buf(),
700 env: BTreeMap::new(),
701 },
702 ],
703 };
704 let mut sink = null_sink();
705 let summary = run_tests(&plan, &mut sink).unwrap();
706 assert_eq!(summary.total(), 2);
707 assert_eq!(summary.passed(), 1);
708 assert_eq!(summary.failed(), 1);
709 assert!(!summary.all_passed());
710 assert_eq!(summary.results[0].executable.target, "fail_test");
713 assert!(matches!(
714 summary.results[0].status,
715 TestRunStatus::Failed { code: Some(1) }
716 ));
717 assert_eq!(summary.results[1].executable.target, "pass_test");
718 assert!(summary.results[1].status.is_success());
719 }
720
721 #[test]
722 #[cfg(unix)]
723 fn run_tests_forwards_output_before_process_exits() {
724 struct MarkerSink {
725 marker: PathBuf,
726 }
727
728 impl TestOutputSink for MarkerSink {
729 fn write_stdout(
730 &mut self,
731 _executable: &TestExecutable,
732 bytes: &[u8],
733 ) -> io::Result<()> {
734 if bytes
735 .windows("ready".len())
736 .any(|window| window == b"ready")
737 {
738 std::fs::write(&self.marker, b"seen")?;
739 }
740 Ok(())
741 }
742
743 fn write_stderr(
744 &mut self,
745 _executable: &TestExecutable,
746 _bytes: &[u8],
747 ) -> io::Result<()> {
748 Ok(())
749 }
750 }
751
752 let dir = TempDir::new().unwrap();
753 let marker = dir.child("sink-saw-output");
754 let script = dir.child("streaming_test");
755 write_executable(
756 &script,
757 r#"#!/bin/sh
758printf 'ready\n'
759i=0
760while [ "$i" -lt 40 ]; do
761 if [ -f "$MARKER" ]; then
762 exit 0
763 fi
764 i=$((i + 1))
765 sleep 0.05
766done
767exit 42
768"#,
769 );
770 let plan = TestPlan {
771 executables: vec![TestExecutable {
772 package: "demo".into(),
773 target: "streaming_test".into(),
774 executable: script.to_path_buf(),
775 working_dir: dir.path().to_path_buf(),
776 env: BTreeMap::from([("MARKER".to_owned(), marker.path().as_os_str().to_owned())]),
777 }],
778 };
779 let mut sink = MarkerSink {
780 marker: marker.to_path_buf(),
781 };
782 let summary = run_tests(&plan, &mut sink).unwrap();
783
784 assert!(summary.all_passed(), "{summary:?}");
785 assert_eq!(summary.results[0].stdout, b"ready\n");
786 }
787
788 #[test]
789 fn render_result_line_includes_exit_code_for_failures() {
790 let exe = TestExecutable {
791 package: "demo".into(),
792 target: "fail_test".into(),
793 executable: PathBuf::from("/tmp/x"),
794 working_dir: PathBuf::from("/tmp"),
795 env: BTreeMap::new(),
796 };
797 let result = TestRunResult {
798 executable: exe.clone(),
799 status: TestRunStatus::Failed { code: Some(42) },
800 stdout: Vec::new(),
801 stderr: Vec::new(),
802 };
803 assert_eq!(
804 render_result_line(&result),
805 "test demo:fail_test ... FAILED (exit 42)"
806 );
807 let result = TestRunResult {
808 executable: exe,
809 status: TestRunStatus::Passed,
810 stdout: Vec::new(),
811 stderr: Vec::new(),
812 };
813 assert_eq!(render_result_line(&result), "test demo:fail_test ... ok");
814 }
815
816 #[test]
817 fn streaming_sink_skips_empty_output() {
818 let mut sink = StreamingSink {
819 stdout: Vec::<u8>::new(),
820 stderr: Vec::<u8>::new(),
821 };
822 let exe = TestExecutable {
823 package: "demo".into(),
824 target: "x".into(),
825 executable: PathBuf::from("/tmp/x"),
826 working_dir: PathBuf::from("/tmp"),
827 env: BTreeMap::new(),
828 };
829 sink.write_stdout(&exe, &[]).unwrap();
830 sink.write_stderr(&exe, &[]).unwrap();
831 assert!(sink.stdout.is_empty());
832 assert!(sink.stderr.is_empty());
833 sink.write_stdout(&exe, b"hello").unwrap();
834 let out = String::from_utf8(sink.stdout).unwrap();
835 assert!(out.contains("---- stdout: demo:x ----"));
836 assert!(out.contains("hello"));
837 assert!(out.ends_with('\n'));
838 }
839
840 #[test]
841 fn retry_on_etxtbsy_retries_until_spawn_succeeds() {
842 let mut calls = 0;
843 let result = retry_on_etxtbsy(8, Duration::ZERO, || {
844 calls += 1;
845 if calls < 3 {
846 Err(io::Error::from(io::ErrorKind::ExecutableFileBusy))
847 } else {
848 Ok(99)
849 }
850 });
851 assert_eq!(result.unwrap(), 99);
852 assert_eq!(calls, 3);
853 }
854
855 #[test]
856 fn retry_on_etxtbsy_gives_up_after_max_attempts() {
857 let mut calls = 0;
858 let result: io::Result<()> = retry_on_etxtbsy(4, Duration::ZERO, || {
859 calls += 1;
860 Err(io::Error::from(io::ErrorKind::ExecutableFileBusy))
861 });
862 assert_eq!(
863 result.unwrap_err().kind(),
864 io::ErrorKind::ExecutableFileBusy
865 );
866 assert_eq!(calls, 4);
867 }
868
869 #[test]
870 fn retry_on_etxtbsy_does_not_retry_other_errors() {
871 let mut calls = 0;
872 let result: io::Result<()> = retry_on_etxtbsy(8, Duration::ZERO, || {
873 calls += 1;
874 Err(io::Error::from(io::ErrorKind::PermissionDenied))
875 });
876 assert_eq!(result.unwrap_err().kind(), io::ErrorKind::PermissionDenied);
877 assert_eq!(calls, 1);
878 }
879}