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;
29
30use cabin_build::BuildGraph;
31use cabin_core::TargetKind;
32use cabin_workspace::{PackageGraph, WorkspacePackage};
33use thiserror::Error;
34
35#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct TestExecutable {
38 pub package: String,
41 pub target: String,
43 pub executable: PathBuf,
45 pub working_dir: PathBuf,
49 pub env: BTreeMap<String, OsString>,
56}
57
58#[derive(Debug, Clone, Default)]
65pub struct TestPlan {
66 executables: Vec<TestExecutable>,
67}
68
69impl<'a> IntoIterator for &'a TestPlan {
70 type Item = &'a TestExecutable;
71 type IntoIter = std::slice::Iter<'a, TestExecutable>;
72
73 fn into_iter(self) -> Self::IntoIter {
74 self.executables.iter()
75 }
76}
77
78impl TestPlan {
79 pub fn iter(&self) -> std::slice::Iter<'_, TestExecutable> {
81 self.executables.iter()
82 }
83
84 pub fn len(&self) -> usize {
86 self.executables.len()
87 }
88
89 pub fn is_empty(&self) -> bool {
91 self.executables.is_empty()
92 }
93
94 pub fn for_each_executable_mut(&mut self, mut f: impl FnMut(&mut TestExecutable)) {
99 for exe in &mut self.executables {
100 f(exe);
101 }
102 }
103}
104
105pub fn plan_tests(
125 package_graph: &PackageGraph,
126 build_graph: &BuildGraph,
127 selected_packages: Option<&[usize]>,
128) -> TestPlan {
129 let outputs: BTreeSet<&Path> = build_graph
130 .default_outputs
131 .iter()
132 .map(PathBuf::as_path)
133 .collect();
134
135 let pkg_indices: Vec<usize> = match selected_packages {
136 Some(s) => s.to_vec(),
137 None => package_graph.primary_packages.clone(),
138 };
139
140 let mut entries: Vec<TestExecutable> = Vec::new();
141 for idx in pkg_indices {
142 let package = &package_graph.packages[idx];
143 for target in &package.package.targets {
144 if target.kind != TargetKind::Test {
145 continue;
146 }
147 let Some(exe) = expected_executable(package, target.name.as_str(), &outputs) else {
152 continue;
153 };
154 entries.push(TestExecutable {
155 package: package.package.name.as_str().to_owned(),
156 target: target.name.as_str().to_owned(),
157 executable: exe.to_path_buf(),
158 working_dir: package.manifest_dir.clone(),
159 env: BTreeMap::new(),
160 });
161 }
162 }
163
164 entries.sort_by(|a, b| {
165 a.package
166 .cmp(&b.package)
167 .then_with(|| a.target.cmp(&b.target))
168 });
169 TestPlan {
170 executables: entries,
171 }
172}
173
174fn expected_executable<'a>(
175 package: &WorkspacePackage,
176 target_name: &str,
177 outputs: &BTreeSet<&'a Path>,
178) -> Option<&'a Path> {
179 let needle_tail: PathBuf = ["packages", package.package.name.as_str(), target_name]
185 .iter()
186 .collect();
187 outputs.iter().copied().find(|p| p.ends_with(&needle_tail))
188}
189
190#[derive(Debug, Clone, PartialEq, Eq)]
192pub struct TestRunResult {
193 pub executable: TestExecutable,
195 pub status: TestRunStatus,
197 pub stdout: Vec<u8>,
199 pub stderr: Vec<u8>,
201}
202
203#[derive(Debug, Clone, Copy, PartialEq, Eq)]
205pub enum TestRunStatus {
206 Passed,
208 Failed { code: Option<i32> },
211}
212
213impl TestRunStatus {
214 pub const fn is_success(self) -> bool {
216 matches!(self, TestRunStatus::Passed)
217 }
218
219 fn from_status(status: ExitStatus) -> Self {
220 if status.success() {
221 Self::Passed
222 } else {
223 Self::Failed {
224 code: status.code(),
225 }
226 }
227 }
228}
229
230#[derive(Debug, Clone, PartialEq, Eq)]
232pub struct TestSummary {
233 pub results: Vec<TestRunResult>,
235}
236
237impl TestSummary {
238 pub fn total(&self) -> usize {
240 self.results.len()
241 }
242
243 pub fn passed(&self) -> usize {
245 self.results
246 .iter()
247 .filter(|r| r.status.is_success())
248 .count()
249 }
250
251 pub fn failed(&self) -> usize {
253 self.results
254 .iter()
255 .filter(|r| !r.status.is_success())
256 .count()
257 }
258
259 pub fn all_passed(&self) -> bool {
261 self.results.iter().all(|r| r.status.is_success())
262 }
263}
264
265pub trait TestOutputSink {
271 fn write_stdout(&mut self, executable: &TestExecutable, bytes: &[u8]) -> io::Result<()>;
277 fn write_stderr(&mut self, executable: &TestExecutable, bytes: &[u8]) -> io::Result<()>;
283}
284
285impl TestOutputSink for () {
286 fn write_stdout(&mut self, _executable: &TestExecutable, _bytes: &[u8]) -> io::Result<()> {
287 Ok(())
288 }
289 fn write_stderr(&mut self, _executable: &TestExecutable, _bytes: &[u8]) -> io::Result<()> {
290 Ok(())
291 }
292}
293
294pub fn null_sink() -> impl TestOutputSink {}
297
298pub struct StreamingSink<W1, W2> {
302 pub stdout: W1,
305 pub stderr: W2,
308}
309
310impl<W1: Write, W2: Write> TestOutputSink for StreamingSink<W1, W2> {
311 fn write_stdout(&mut self, executable: &TestExecutable, bytes: &[u8]) -> io::Result<()> {
312 if !bytes.is_empty() {
313 writeln!(
314 self.stdout,
315 "---- stdout: {}:{} ----",
316 executable.package, executable.target
317 )?;
318 self.stdout.write_all(bytes)?;
319 if !bytes.ends_with(b"\n") {
320 self.stdout.write_all(b"\n")?;
321 }
322 }
323 Ok(())
324 }
325 fn write_stderr(&mut self, executable: &TestExecutable, bytes: &[u8]) -> io::Result<()> {
326 if !bytes.is_empty() {
327 writeln!(
328 self.stderr,
329 "---- stderr: {}:{} ----",
330 executable.package, executable.target
331 )?;
332 self.stderr.write_all(bytes)?;
333 if !bytes.ends_with(b"\n") {
334 self.stderr.write_all(b"\n")?;
335 }
336 }
337 Ok(())
338 }
339}
340
341pub fn run_tests<S: TestOutputSink>(
365 plan: &TestPlan,
366 sink: &mut S,
367) -> Result<TestSummary, TestRunError> {
368 let mut results: Vec<TestRunResult> = Vec::with_capacity(plan.executables.len());
369 for executable in &plan.executables {
370 let mut command = Command::new(&executable.executable);
371 command.current_dir(&executable.working_dir);
372 command.stdout(Stdio::piped()).stderr(Stdio::piped());
373 for (key, value) in &executable.env {
379 command.env(key, value);
380 }
381 let mut child = command.spawn().map_err(|source| TestRunError::Spawn {
382 package: executable.package.clone(),
383 target: executable.target.clone(),
384 executable: executable.executable.clone(),
385 source,
386 })?;
387
388 let stdout = child
389 .stdout
390 .take()
391 .expect("stdout is piped before child spawn");
392 let stderr = child
393 .stderr
394 .take()
395 .expect("stderr is piped before child spawn");
396 let (tx, rx) = mpsc::channel();
397 let stdout_thread = spawn_output_reader(OutputStream::Stdout, stdout, tx.clone());
398 let stderr_thread = spawn_output_reader(OutputStream::Stderr, stderr, tx);
399
400 let mut stdout = Vec::new();
401 let mut stderr = Vec::new();
402 let output_result = forward_output_events(executable, sink, rx, &mut stdout, &mut stderr);
403 if let Err(err) = output_result {
404 let _ = child.kill();
405 let _ = child.wait();
406 let _ = stdout_thread.join();
407 let _ = stderr_thread.join();
408 return Err(err);
409 }
410 let status = child.wait().map_err(|source| TestRunError::Wait {
411 package: executable.package.clone(),
412 target: executable.target.clone(),
413 executable: executable.executable.clone(),
414 source,
415 })?;
416 let _ = stdout_thread.join();
417 let _ = stderr_thread.join();
418
419 results.push(TestRunResult {
420 executable: executable.clone(),
421 status: TestRunStatus::from_status(status),
422 stdout,
423 stderr,
424 });
425 }
426 Ok(TestSummary { results })
427}
428
429#[derive(Debug, Clone, Copy)]
430enum OutputStream {
431 Stdout,
432 Stderr,
433}
434
435struct OutputEvent {
436 stream: OutputStream,
437 bytes: Vec<u8>,
438}
439
440fn spawn_output_reader<R: Read + Send + 'static>(
441 stream: OutputStream,
442 mut reader: R,
443 tx: mpsc::Sender<Result<OutputEvent, io::Error>>,
444) -> thread::JoinHandle<()> {
445 thread::spawn(move || {
446 let mut buf = [0_u8; 8192];
447 loop {
448 match reader.read(&mut buf) {
449 Ok(0) => break,
450 Ok(n) => {
451 if tx
452 .send(Ok(OutputEvent {
453 stream,
454 bytes: buf[..n].to_vec(),
455 }))
456 .is_err()
457 {
458 break;
459 }
460 }
461 Err(source) => {
462 let _ = tx.send(Err(source));
463 break;
464 }
465 }
466 }
467 })
468}
469
470fn forward_output_events<S: TestOutputSink>(
471 executable: &TestExecutable,
472 sink: &mut S,
473 rx: mpsc::Receiver<Result<OutputEvent, io::Error>>,
474 stdout: &mut Vec<u8>,
475 stderr: &mut Vec<u8>,
476) -> Result<(), TestRunError> {
477 for event in rx {
478 let event = event.map_err(TestRunError::OutputIo)?;
479 match event.stream {
480 OutputStream::Stdout => {
481 sink.write_stdout(executable, &event.bytes)
482 .map_err(TestRunError::SinkIo)?;
483 stdout.extend_from_slice(&event.bytes);
484 }
485 OutputStream::Stderr => {
486 sink.write_stderr(executable, &event.bytes)
487 .map_err(TestRunError::SinkIo)?;
488 stderr.extend_from_slice(&event.bytes);
489 }
490 }
491 }
492 Ok(())
493}
494
495pub fn render_summary_line(summary: &TestSummary) -> String {
499 let total = summary.total();
500 let passed = summary.passed();
501 let failed = summary.failed();
502 let outcome = if failed == 0 { "ok" } else { "FAILED" };
503 format!("test result: {outcome}. {passed} passed; {failed} failed (of {total})")
504}
505
506pub fn render_running_line(executable: &TestExecutable) -> String {
509 format!("running test {}:{}", executable.package, executable.target)
510}
511
512pub fn render_result_line(result: &TestRunResult) -> String {
515 let label = match result.status {
516 TestRunStatus::Passed => "ok".to_owned(),
517 TestRunStatus::Failed { code: Some(c) } => format!("FAILED (exit {c})"),
518 TestRunStatus::Failed { code: None } => "FAILED (terminated by signal)".to_owned(),
519 };
520 format!(
521 "test {}:{} ... {label}",
522 result.executable.package, result.executable.target
523 )
524}
525
526#[derive(Debug, Error)]
528pub enum TestRunError {
529 #[error("failed to start test target `{package}:{target}` ({}): {source}", .executable.display())]
531 Spawn {
532 package: String,
533 target: String,
534 executable: PathBuf,
535 #[source]
536 source: io::Error,
537 },
538 #[error("failed to wait for test target `{package}:{target}` ({}): {source}", .executable.display())]
541 Wait {
542 package: String,
543 target: String,
544 executable: PathBuf,
545 #[source]
546 source: io::Error,
547 },
548 #[error("failed to read captured test output: {0}")]
550 OutputIo(#[source] io::Error),
551 #[error("failed to write captured test output: {0}")]
555 SinkIo(#[source] io::Error),
556}
557
558#[cfg(test)]
559mod tests {
560 use super::*;
561 use assert_fs::TempDir;
562 use assert_fs::prelude::*;
563 use std::os::unix::fs::PermissionsExt;
564
565 fn write_executable(file: &assert_fs::fixture::ChildPath, body: &str) {
566 file.write_str(body).unwrap();
567 let mut perms = std::fs::metadata(file.path()).unwrap().permissions();
568 perms.set_mode(0o755);
569 std::fs::set_permissions(file.path(), perms).unwrap();
570 }
571
572 #[test]
573 fn plan_orders_executables_by_package_then_target() {
574 let plan = TestPlan {
575 executables: vec![
576 TestExecutable {
577 package: "alpha".into(),
578 target: "z_test".into(),
579 executable: PathBuf::from("/tmp/x"),
580 working_dir: PathBuf::from("/tmp"),
581 env: BTreeMap::new(),
582 },
583 TestExecutable {
584 package: "alpha".into(),
585 target: "a_test".into(),
586 executable: PathBuf::from("/tmp/x"),
587 working_dir: PathBuf::from("/tmp"),
588 env: BTreeMap::new(),
589 },
590 ],
591 };
592 let summary = TestSummary {
596 results: plan
597 .executables
598 .iter()
599 .map(|e| TestRunResult {
600 executable: e.clone(),
601 status: TestRunStatus::Passed,
602 stdout: Vec::new(),
603 stderr: Vec::new(),
604 })
605 .collect(),
606 };
607 assert_eq!(summary.total(), 2);
608 assert_eq!(summary.passed(), 2);
609 assert!(summary.all_passed());
610 assert_eq!(
611 render_summary_line(&summary),
612 "test result: ok. 2 passed; 0 failed (of 2)"
613 );
614 }
615
616 #[test]
617 fn run_tests_reports_pass_and_fail_in_summary() {
618 let dir = TempDir::new().unwrap();
619 let pass = dir.child("pass_test");
620 let fail = dir.child("fail_test");
621 write_executable(&pass, "#!/bin/sh\nexit 0\n");
622 write_executable(&fail, "#!/bin/sh\nexit 1\n");
623 let plan = TestPlan {
624 executables: vec![
625 TestExecutable {
626 package: "demo".into(),
627 target: "fail_test".into(),
628 executable: fail.to_path_buf(),
629 working_dir: dir.path().to_path_buf(),
630 env: BTreeMap::new(),
631 },
632 TestExecutable {
633 package: "demo".into(),
634 target: "pass_test".into(),
635 executable: pass.to_path_buf(),
636 working_dir: dir.path().to_path_buf(),
637 env: BTreeMap::new(),
638 },
639 ],
640 };
641 let mut sink = null_sink();
642 let summary = run_tests(&plan, &mut sink).unwrap();
643 assert_eq!(summary.total(), 2);
644 assert_eq!(summary.passed(), 1);
645 assert_eq!(summary.failed(), 1);
646 assert!(!summary.all_passed());
647 assert_eq!(summary.results[0].executable.target, "fail_test");
650 assert!(matches!(
651 summary.results[0].status,
652 TestRunStatus::Failed { code: Some(1) }
653 ));
654 assert_eq!(summary.results[1].executable.target, "pass_test");
655 assert!(summary.results[1].status.is_success());
656 }
657
658 #[test]
659 fn run_tests_forwards_output_before_process_exits() {
660 struct MarkerSink {
661 marker: PathBuf,
662 }
663
664 impl TestOutputSink for MarkerSink {
665 fn write_stdout(
666 &mut self,
667 _executable: &TestExecutable,
668 bytes: &[u8],
669 ) -> io::Result<()> {
670 if bytes
671 .windows("ready".len())
672 .any(|window| window == b"ready")
673 {
674 std::fs::write(&self.marker, b"seen")?;
675 }
676 Ok(())
677 }
678
679 fn write_stderr(
680 &mut self,
681 _executable: &TestExecutable,
682 _bytes: &[u8],
683 ) -> io::Result<()> {
684 Ok(())
685 }
686 }
687
688 let dir = TempDir::new().unwrap();
689 let marker = dir.child("sink-saw-output");
690 let script = dir.child("streaming_test");
691 write_executable(
692 &script,
693 r#"#!/bin/sh
694printf 'ready\n'
695i=0
696while [ "$i" -lt 40 ]; do
697 if [ -f "$MARKER" ]; then
698 exit 0
699 fi
700 i=$((i + 1))
701 sleep 0.05
702done
703exit 42
704"#,
705 );
706 let plan = TestPlan {
707 executables: vec![TestExecutable {
708 package: "demo".into(),
709 target: "streaming_test".into(),
710 executable: script.to_path_buf(),
711 working_dir: dir.path().to_path_buf(),
712 env: BTreeMap::from([("MARKER".to_owned(), marker.path().as_os_str().to_owned())]),
713 }],
714 };
715 let mut sink = MarkerSink {
716 marker: marker.to_path_buf(),
717 };
718 let summary = run_tests(&plan, &mut sink).unwrap();
719
720 assert!(summary.all_passed(), "{summary:?}");
721 assert_eq!(summary.results[0].stdout, b"ready\n");
722 }
723
724 #[test]
725 fn render_result_line_includes_exit_code_for_failures() {
726 let exe = TestExecutable {
727 package: "demo".into(),
728 target: "fail_test".into(),
729 executable: PathBuf::from("/tmp/x"),
730 working_dir: PathBuf::from("/tmp"),
731 env: BTreeMap::new(),
732 };
733 let result = TestRunResult {
734 executable: exe.clone(),
735 status: TestRunStatus::Failed { code: Some(42) },
736 stdout: Vec::new(),
737 stderr: Vec::new(),
738 };
739 assert_eq!(
740 render_result_line(&result),
741 "test demo:fail_test ... FAILED (exit 42)"
742 );
743 let result = TestRunResult {
744 executable: exe,
745 status: TestRunStatus::Passed,
746 stdout: Vec::new(),
747 stderr: Vec::new(),
748 };
749 assert_eq!(render_result_line(&result), "test demo:fail_test ... ok");
750 }
751
752 #[test]
753 fn streaming_sink_skips_empty_output() {
754 let mut sink = StreamingSink {
755 stdout: Vec::<u8>::new(),
756 stderr: Vec::<u8>::new(),
757 };
758 let exe = TestExecutable {
759 package: "demo".into(),
760 target: "x".into(),
761 executable: PathBuf::from("/tmp/x"),
762 working_dir: PathBuf::from("/tmp"),
763 env: BTreeMap::new(),
764 };
765 sink.write_stdout(&exe, &[]).unwrap();
766 sink.write_stderr(&exe, &[]).unwrap();
767 assert!(sink.stdout.is_empty());
768 assert!(sink.stderr.is_empty());
769 sink.write_stdout(&exe, b"hello").unwrap();
770 let out = String::from_utf8(sink.stdout).unwrap();
771 assert!(out.contains("---- stdout: demo:x ----"));
772 assert!(out.contains("hello"));
773 assert!(out.ends_with('\n'));
774 }
775}