Skip to main content

cabin_test/
lib.rs

1//! Test plan + sequential test runner for Cabin's `test`
2//! targets.
3//!
4//! `cabin test` is intentionally a thin layer on top of the
5//! existing build pipeline:
6//!
7//! 1. The CLI builds the selected `test` targets through the
8//!    ordinary `cabin-build` planner - no test-specific build
9//!    machinery is invented here.
10//! 2. This crate turns the resulting [`cabin_build::BuildGraph`]
11//!    into a deterministic [`TestPlan`].
12//! 3. [`run_tests`] executes the plan sequentially, captures
13//!    stdout / stderr from each test executable, and produces a
14//!    [`TestSummary`] describing what passed and what failed.
15//!
16//! Crate boundary: this crate does not parse manifests, build
17//! dependency graphs, generate Ninja, or know about config /
18//! patches.  The CLI orchestrates those layers and hands a
19//! finished `BuildGraph` plus the per-package CWD policy to
20//! [`plan_tests`] / [`run_tests`].
21
22use 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, Instant};
30
31use cabin_build::{BuildGraph, Dialect};
32use cabin_core::TargetKind;
33use cabin_workspace::{PackageGraph, WorkspacePackage};
34use thiserror::Error;
35
36/// One executable in a [`TestPlan`].
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct TestExecutable {
39    /// Workspace package the test belongs to.  Used both for
40    /// summary output and for the executable's working directory.
41    pub package: String,
42    /// Manifest-declared target name (without any path / extension).
43    pub target: String,
44    /// Filesystem path of the linked test executable.
45    pub executable: PathBuf,
46    /// Manifest directory of the producing package.  Used as the
47    /// working directory when the executable runs so tests can
48    /// reach repository-relative fixture data deterministically.
49    pub working_dir: PathBuf,
50    /// Deterministic env overlay applied on top of the
51    /// inherited environment when the executable runs.  Intended
52    /// for `CABIN_*` keys produced by the orchestration layer
53    /// via `cabin_env::package_env`.  Empty by default; callers
54    /// that do not populate the overlay see the inherited
55    /// environment unchanged.
56    pub env: BTreeMap<String, OsString>,
57}
58
59/// A finalized, ordered list of `test` executables to run.
60///
61/// Ordering is deterministic: by package name, then by target
62/// name.  Build it with [`plan_tests`] and consume it with
63/// [`run_tests`].  Empty plans are allowed; the CLI decides
64/// whether an empty plan is an error or a clean no-op.
65#[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    /// Iterate executables in the plan's documented order.
81    pub fn iter(&self) -> std::slice::Iter<'_, TestExecutable> {
82        self.executables.iter()
83    }
84
85    /// Number of executables to run.
86    pub fn len(&self) -> usize {
87        self.executables.len()
88    }
89
90    /// `true` if the plan has no executables.
91    pub fn is_empty(&self) -> bool {
92        self.executables.is_empty()
93    }
94
95    /// Apply `f` to every executable in the plan.  Used by the
96    /// orchestration layer to attach a `CABIN_*` env overlay
97    /// after planning without exposing the executables vec
98    /// directly.
99    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
106/// Build a [`TestPlan`] from a finished [`BuildGraph`] plus the
107/// originating [`PackageGraph`].
108///
109/// The plan picks every `test` target whose linked
110/// executable appears in `graph.default_outputs` (i.e. every
111/// `test` the build was asked to produce). `test`
112/// targets that the planner did *not* build are absent from the
113/// plan - that is the contract: callers select which test targets
114/// to build (typically through the planner's manifest-target
115/// selector list), and `plan_tests` runs exactly the ones whose
116/// executable exists in the graph.
117///
118/// If `selected_packages` is `Some`, the plan is restricted to
119/// those package indices; passing `None` walks the graph's
120/// primary set, matching the planner's default selection.
121///
122/// Ordering is `(package_name, target_name)` ascending - the
123/// same order `cabin metadata` and the planner emit, so plans
124/// are deterministic across runs.
125pub fn plan_tests(
126    package_graph: &PackageGraph,
127    build_graph: &BuildGraph,
128    selected_packages: Option<&[usize]>,
129) -> TestPlan {
130    // `default_outputs` are UTF-8 build-graph paths; borrow each as a
131    // native `&Path` for the filesystem comparison below.
132    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            // Skip tests the planner was not asked to build.
151            // Callers that pass a narrower manifest-target
152            // selector list rely on this to drop targets that did
153            // not make it into the graph.
154            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.to_string(),
161                target: target.name.to_string(),
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    // The planner names every `test` executable
186    // `<build_dir>/<profile>/packages/<pkg>/<target>` using the
187    // dialect's executable spelling - bare on GNU/Clang, `<target>.exe`
188    // under MSVC.  Build the tail with that same spelling (the dialect is
189    // the planner's own, carried on the graph) and scan
190    // `default_outputs` for it, rather than re-deriving the full path
191    // here, so the planner stays the single source of truth for output
192    // paths - and so Windows `.exe` test binaries are matched, not
193    // silently skipped.
194    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/// Result of running one test executable.
202#[derive(Debug, Clone, PartialEq, Eq)]
203pub struct TestRunResult {
204    /// The executable that was run.
205    pub executable: TestExecutable,
206    /// Outcome classification (passed / failed).
207    pub status: TestRunStatus,
208    /// Captured stdout, in order of arrival.
209    pub stdout: Vec<u8>,
210    /// Captured stderr, in order of arrival.
211    pub stderr: Vec<u8>,
212}
213
214/// Outcome of one test executable.
215#[derive(Debug, Clone, Copy, PartialEq, Eq)]
216pub enum TestRunStatus {
217    /// Process exited with status `0`.
218    Passed,
219    /// Process exited with a non-zero status.  The exit status is
220    /// included so callers can render `(exit code N)`.
221    Failed { code: Option<i32> },
222}
223
224impl TestRunStatus {
225    /// `true` for successful outcomes only.
226    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/// Aggregate summary of one `cabin test` run.
242#[derive(Debug, Clone, PartialEq, Eq)]
243pub struct TestSummary {
244    /// Per-executable results in execution order.
245    pub results: Vec<TestRunResult>,
246    /// Wall-clock time the whole run took, measured by
247    /// [`run_tests`] across every executable in the plan.
248    pub elapsed: Duration,
249}
250
251impl TestSummary {
252    /// Total number of executables run.
253    pub fn total(&self) -> usize {
254        self.results.len()
255    }
256
257    /// Number of executables that exited with status `0`.
258    pub fn passed(&self) -> usize {
259        self.results
260            .iter()
261            .filter(|r| r.status.is_success())
262            .count()
263    }
264
265    /// Number of executables that exited non-zero.
266    pub fn failed(&self) -> usize {
267        self.results
268            .iter()
269            .filter(|r| !r.status.is_success())
270            .count()
271    }
272
273    /// `true` if every executable in the summary passed.
274    pub fn all_passed(&self) -> bool {
275        self.results.iter().all(|r| r.status.is_success())
276    }
277}
278
279/// Sink for test executable output.  The runner forwards stdout /
280/// stderr chunks to this sink while each process is still
281/// running, and also keeps a full captured copy in
282/// [`TestRunResult`].  Tests in this crate use [`null_sink`] to
283/// discard output.
284pub trait TestOutputSink {
285    /// Called zero or more times per executable with stdout bytes.
286    ///
287    /// # Errors
288    /// Returns the implementor's [`io::Error`] if the sink fails to
289    /// write the supplied stdout bytes.
290    fn write_stdout(&mut self, executable: &TestExecutable, bytes: &[u8]) -> io::Result<()>;
291    /// Called zero or more times per executable with stderr bytes.
292    ///
293    /// # Errors
294    /// Returns the implementor's [`io::Error`] if the sink fails to
295    /// write the supplied stderr bytes.
296    fn write_stderr(&mut self, executable: &TestExecutable, bytes: &[u8]) -> io::Result<()>;
297
298    /// Called exactly once per executable, immediately after it
299    /// exits and before the next executable starts.  Streaming
300    /// sinks render the per-test result line here so multi-test
301    /// runs report progress the way `cargo test` does.  The
302    /// default implementation does nothing.
303    ///
304    /// # Errors
305    /// Returns the implementor's [`io::Error`] if the sink fails to
306    /// write the result line.
307    fn test_finished(&mut self, result: &TestRunResult) -> io::Result<()> {
308        let _ = result;
309        Ok(())
310    }
311}
312
313impl TestOutputSink for () {
314    fn write_stdout(&mut self, _executable: &TestExecutable, _bytes: &[u8]) -> io::Result<()> {
315        Ok(())
316    }
317    fn write_stderr(&mut self, _executable: &TestExecutable, _bytes: &[u8]) -> io::Result<()> {
318        Ok(())
319    }
320}
321
322/// A `TestOutputSink` that discards all bytes - useful for unit
323/// tests of the runner itself.
324pub fn null_sink() -> impl TestOutputSink {}
325
326/// A `TestOutputSink` that streams bytes to the supplied
327/// stdout/stderr writers.  Each non-empty write prepends a header
328/// so the user can tell which executable is speaking.
329pub struct StreamingSink<W1, W2> {
330    /// Writer for captured stdout (typically the parent process's
331    /// stdout).
332    pub stdout: W1,
333    /// Writer for captured stderr (typically the parent process's
334    /// stderr).
335    pub stderr: W2,
336}
337
338/// Stream `bytes` to `writer` under a `---- <label>: <pkg>:<target> ----`
339/// header, appending a trailing newline when the payload lacks one.  A
340/// no-op for empty `bytes`.
341fn write_labeled<W: Write>(
342    writer: &mut W,
343    executable: &TestExecutable,
344    bytes: &[u8],
345    label: &str,
346) -> io::Result<()> {
347    if !bytes.is_empty() {
348        writeln!(
349            writer,
350            "---- {label}: {}:{} ----",
351            executable.package, executable.target
352        )?;
353        writer.write_all(bytes)?;
354        if !bytes.ends_with(b"\n") {
355            writer.write_all(b"\n")?;
356        }
357    }
358    Ok(())
359}
360
361impl<W1: Write, W2: Write> TestOutputSink for StreamingSink<W1, W2> {
362    fn write_stdout(&mut self, executable: &TestExecutable, bytes: &[u8]) -> io::Result<()> {
363        write_labeled(&mut self.stdout, executable, bytes, "stdout")
364    }
365    fn write_stderr(&mut self, executable: &TestExecutable, bytes: &[u8]) -> io::Result<()> {
366        write_labeled(&mut self.stderr, executable, bytes, "stderr")
367    }
368    fn test_finished(&mut self, result: &TestRunResult) -> io::Result<()> {
369        writeln!(self.stdout, "{}", render_result_line(result))
370    }
371}
372
373/// Run every executable in `plan` sequentially in the order
374/// produced by [`plan_tests`].  Each test runs to completion
375/// before the next starts; the runner does not introduce
376/// parallelism in this release.  The returned [`TestSummary`]
377/// preserves the plan's order so output stays deterministic.
378///
379/// A test executable's stdout / stderr are forwarded to `sink`
380/// while the process is running and also captured to memory for
381/// the returned summary.  Streaming sinks (see [`StreamingSink`])
382/// write a header for each non-empty output chunk so multi-test
383/// runs are easy to read.
384///
385/// # Panics
386///
387/// Panics if a spawned child process does not expose stdout or
388/// stderr after the runner configured both streams as piped.
389///
390/// # Errors
391/// Returns [`TestRunError`]: `Spawn` if a test executable cannot be
392/// started, `Wait` if waiting on a running child fails, `OutputIo`
393/// if reading the child's stdout/stderr fails, and `SinkIo` if
394/// forwarding captured output to `sink` fails (propagated from the
395/// sink's `write_stdout` / `write_stderr`).
396pub fn run_tests<S: TestOutputSink>(
397    plan: &TestPlan,
398    sink: &mut S,
399) -> Result<TestSummary, TestRunError> {
400    let started = Instant::now();
401    let mut results: Vec<TestRunResult> = Vec::with_capacity(plan.executables.len());
402    for executable in &plan.executables {
403        let mut command = Command::new(&executable.executable);
404        command.current_dir(&executable.working_dir);
405        command.stdout(Stdio::piped()).stderr(Stdio::piped());
406        // Tests inherit the user's PATH plus whatever Cabin's
407        // own caller has set, with the per-executable env
408        // overlay applied on top so the orchestration layer can
409        // surface deterministic CABIN_* values without forcing
410        // every test fixture to re-derive them.
411        for (key, value) in &executable.env {
412            command.env(key, value);
413        }
414        // Retry on `ETXTBSY`: a sibling thread that forks while we
415        // are mid-`write`/`chmod` of another executable can leave a
416        // writable fd to this file briefly inherited in its
417        // not-yet-`execve`d child, which makes our own `execve`
418        // race-fail.  The window clears within milliseconds.
419        let mut child = retry_on_etxtbsy(SPAWN_RETRY_ATTEMPTS, SPAWN_RETRY_BASE_DELAY, || {
420            command.spawn()
421        })
422        .map_err(|source| TestRunError::Spawn {
423            package: executable.package.clone(),
424            target: executable.target.clone(),
425            executable: executable.executable.clone(),
426            source,
427        })?;
428
429        let stdout = child
430            .stdout
431            .take()
432            .expect("stdout is piped before child spawn");
433        let stderr = child
434            .stderr
435            .take()
436            .expect("stderr is piped before child spawn");
437        let (tx, rx) = mpsc::channel();
438        let stdout_thread = spawn_output_reader(OutputStream::Stdout, stdout, tx.clone());
439        let stderr_thread = spawn_output_reader(OutputStream::Stderr, stderr, tx);
440
441        let mut stdout = Vec::new();
442        let mut stderr = Vec::new();
443        let output_result = forward_output_events(executable, sink, rx, &mut stdout, &mut stderr);
444        if let Err(err) = output_result {
445            let _ = child.kill();
446            let _ = child.wait();
447            let _ = stdout_thread.join();
448            let _ = stderr_thread.join();
449            return Err(err);
450        }
451        let status = child.wait().map_err(|source| TestRunError::Wait {
452            package: executable.package.clone(),
453            target: executable.target.clone(),
454            executable: executable.executable.clone(),
455            source,
456        })?;
457        let _ = stdout_thread.join();
458        let _ = stderr_thread.join();
459
460        let result = TestRunResult {
461            executable: executable.clone(),
462            status: TestRunStatus::from_status(status),
463            stdout,
464            stderr,
465        };
466        sink.test_finished(&result).map_err(TestRunError::SinkIo)?;
467        results.push(result);
468    }
469    Ok(TestSummary {
470        results,
471        elapsed: started.elapsed(),
472    })
473}
474
475/// Total spawn attempts before an `ETXTBSY` failure is surfaced.
476const SPAWN_RETRY_ATTEMPTS: u32 = 8;
477/// Backoff before the first spawn retry; doubles on each retry, so
478/// eight attempts wait up to ~127ms in total before giving up.
479const SPAWN_RETRY_BASE_DELAY: Duration = Duration::from_millis(1);
480
481/// Call `attempt`, retrying with exponential backoff while it fails
482/// with [`io::ErrorKind::ExecutableFileBusy`] (`ETXTBSY`).  Any other
483/// outcome - success or a different error - returns immediately, and
484/// the final attempt's result is returned even if still busy.  Always
485/// calls `attempt` at least once.
486fn retry_on_etxtbsy<T>(
487    max_attempts: u32,
488    base_delay: Duration,
489    mut attempt: impl FnMut() -> io::Result<T>,
490) -> io::Result<T> {
491    let mut delay = base_delay;
492    let mut result = attempt();
493    for _ in 1..max_attempts {
494        match &result {
495            Err(err) if err.kind() == io::ErrorKind::ExecutableFileBusy => {}
496            _ => return result,
497        }
498        thread::sleep(delay);
499        delay = delay.saturating_mul(2);
500        result = attempt();
501    }
502    result
503}
504
505#[derive(Debug, Clone, Copy)]
506enum OutputStream {
507    Stdout,
508    Stderr,
509}
510
511struct OutputEvent {
512    stream: OutputStream,
513    bytes: Vec<u8>,
514}
515
516fn spawn_output_reader<R: Read + Send + 'static>(
517    stream: OutputStream,
518    mut reader: R,
519    tx: mpsc::Sender<Result<OutputEvent, io::Error>>,
520) -> thread::JoinHandle<()> {
521    thread::spawn(move || {
522        let mut buf = [0_u8; 8192];
523        loop {
524            match reader.read(&mut buf) {
525                Ok(0) => break,
526                Ok(n) => {
527                    if tx
528                        .send(Ok(OutputEvent {
529                            stream,
530                            bytes: buf[..n].to_vec(),
531                        }))
532                        .is_err()
533                    {
534                        break;
535                    }
536                }
537                Err(source) => {
538                    let _ = tx.send(Err(source));
539                    break;
540                }
541            }
542        }
543    })
544}
545
546fn forward_output_events<S: TestOutputSink>(
547    executable: &TestExecutable,
548    sink: &mut S,
549    rx: mpsc::Receiver<Result<OutputEvent, io::Error>>,
550    stdout: &mut Vec<u8>,
551    stderr: &mut Vec<u8>,
552) -> Result<(), TestRunError> {
553    for event in rx {
554        let event = event.map_err(TestRunError::OutputIo)?;
555        match event.stream {
556            OutputStream::Stdout => {
557                sink.write_stdout(executable, &event.bytes)
558                    .map_err(TestRunError::SinkIo)?;
559                stdout.extend_from_slice(&event.bytes);
560            }
561            OutputStream::Stderr => {
562                sink.write_stderr(executable, &event.bytes)
563                    .map_err(TestRunError::SinkIo)?;
564                stderr.extend_from_slice(&event.bytes);
565            }
566        }
567    }
568    Ok(())
569}
570
571/// Format the `cargo test`-shaped one-line summary:
572/// `test result: ok.  P passed; F failed; 0 ignored; 0 measured;
573/// FO filtered out; finished in T.TTs`.  Centralized here so the
574/// CLI does not invent its own format.
575///
576/// Cabin has no ignore or benchmark mechanism, so the `ignored`
577/// and `measured` fields render as constant zeros to keep the
578/// line shaped exactly like `cargo test`'s. `filtered_out` is
579/// the number of `test` targets the invocation deselected (via
580/// `--test <NAME>`), supplied by the orchestration layer.
581pub fn render_summary_line(summary: &TestSummary, filtered_out: usize) -> String {
582    let passed = summary.passed();
583    let failed = summary.failed();
584    let outcome = if failed == 0 { "ok" } else { "FAILED" };
585    let elapsed = summary.elapsed.as_secs_f64();
586    format!(
587        "test result: {outcome}. {passed} passed; {failed} failed; 0 ignored; 0 measured; {filtered_out} filtered out; finished in {elapsed:.2}s"
588    )
589}
590
591/// Render everything the CLI prints after the last per-test
592/// result line: the `cargo test`-shaped `failures:` name recap
593/// (only when something failed) followed by the summary line,
594/// each preceded by a blank line.  The returned string carries no
595/// trailing newline; callers terminate it themselves.
596pub fn render_epilogue(summary: &TestSummary, filtered_out: usize) -> String {
597    // Writing into a `String` is infallible, so the `writeln!`
598    // results below are safely discarded.
599    use std::fmt::Write as _;
600
601    let mut out = String::new();
602    let failed: Vec<&TestRunResult> = summary
603        .results
604        .iter()
605        .filter(|r| !r.status.is_success())
606        .collect();
607    if !failed.is_empty() {
608        out.push_str("\nfailures:\n");
609        for result in failed {
610            let _ = writeln!(
611                out,
612                "    {}:{}",
613                result.executable.package, result.executable.target
614            );
615        }
616    }
617    out.push('\n');
618    out.push_str(&render_summary_line(summary, filtered_out));
619    out
620}
621
622/// Render the per-test result line emitted after each executable
623/// finishes.
624pub fn render_result_line(result: &TestRunResult) -> String {
625    let label = match result.status {
626        TestRunStatus::Passed => "ok".to_owned(),
627        TestRunStatus::Failed { code: Some(c) } => format!("FAILED (exit {c})"),
628        TestRunStatus::Failed { code: None } => "FAILED (terminated by signal)".to_owned(),
629    };
630    format!(
631        "test {}:{} ... {label}",
632        result.executable.package, result.executable.target
633    )
634}
635
636/// Errors produced while running tests.
637#[derive(Debug, Error)]
638pub enum TestRunError {
639    /// The OS could not start the test executable.
640    #[error("failed to start test target `{package}:{target}` ({}): {source}", .executable.display())]
641    Spawn {
642        package: String,
643        target: String,
644        executable: PathBuf,
645        #[source]
646        source: io::Error,
647    },
648    /// The OS started the test executable, but waiting for it to
649    /// finish failed.
650    #[error("failed to wait for test target `{package}:{target}` ({}): {source}", .executable.display())]
651    Wait {
652        package: String,
653        target: String,
654        executable: PathBuf,
655        #[source]
656        source: io::Error,
657    },
658    /// Reading stdout / stderr from the child process failed.
659    #[error("failed to read captured test output: {0}")]
660    OutputIo(#[source] io::Error),
661    /// Writing captured stdout / stderr to the sink failed.  The
662    /// runner stops at the first failure rather than continuing
663    /// silently.
664    #[error("failed to write captured test output: {0}")]
665    SinkIo(#[source] io::Error),
666}
667
668#[cfg(test)]
669mod tests {
670    use super::*;
671    // `assert_fs` is only used by the Unix-only fixture tests below.
672    #[cfg(unix)]
673    use assert_fs::TempDir;
674    #[cfg(unix)]
675    use assert_fs::prelude::*;
676    #[cfg(unix)]
677    use std::os::unix::fs::PermissionsExt;
678
679    // The fixture-based tests below run a fake test executable.  On Unix
680    // that fixture is a `#!/bin/sh` script marked executable; Windows
681    // has no equivalent that `Command::new` can spawn directly (a
682    // `.bat` needs `cmd`, a real `.exe` needs a compiler), so those
683    // tests are Unix-only.  The production `cabin test` path is covered
684    // on Windows by the `library-with-tests` example end-to-end test,
685    // which runs real compiled `.exe` test targets.
686    #[cfg(unix)]
687    fn write_executable(file: &assert_fs::fixture::ChildPath, body: &str) {
688        file.write_str(body).unwrap();
689        let mut perms = std::fs::metadata(file.path()).unwrap().permissions();
690        perms.set_mode(0o755);
691        std::fs::set_permissions(file.path(), perms).unwrap();
692    }
693
694    #[test]
695    fn plan_orders_executables_by_package_then_target() {
696        let plan = TestPlan {
697            executables: vec![
698                TestExecutable {
699                    package: "alpha".into(),
700                    target: "z_test".into(),
701                    executable: PathBuf::from("/tmp/x"),
702                    working_dir: PathBuf::from("/tmp"),
703                    env: BTreeMap::new(),
704                },
705                TestExecutable {
706                    package: "alpha".into(),
707                    target: "a_test".into(),
708                    executable: PathBuf::from("/tmp/x"),
709                    working_dir: PathBuf::from("/tmp"),
710                    env: BTreeMap::new(),
711                },
712            ],
713        };
714        // sanity: TestPlan does not reorder; ordering is the
715        // plan_tests() job.  We test here that summary_line is
716        // stable for a known shape.
717        let summary = TestSummary {
718            results: plan
719                .executables
720                .iter()
721                .map(|e| TestRunResult {
722                    executable: e.clone(),
723                    status: TestRunStatus::Passed,
724                    stdout: Vec::new(),
725                    stderr: Vec::new(),
726                })
727                .collect(),
728            elapsed: Duration::ZERO,
729        };
730        assert_eq!(summary.total(), 2);
731        assert_eq!(summary.passed(), 2);
732        assert!(summary.all_passed());
733        assert_eq!(
734            render_summary_line(&summary, 0),
735            "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s"
736        );
737    }
738
739    fn result_for(target: &str, status: TestRunStatus) -> TestRunResult {
740        TestRunResult {
741            executable: TestExecutable {
742                package: "demo".into(),
743                target: target.into(),
744                executable: PathBuf::from("/tmp/x"),
745                working_dir: PathBuf::from("/tmp"),
746                env: BTreeMap::new(),
747            },
748            status,
749            stdout: Vec::new(),
750            stderr: Vec::new(),
751        }
752    }
753
754    #[test]
755    fn epilogue_is_blank_line_plus_summary_when_everything_passes() {
756        let summary = TestSummary {
757            results: vec![result_for("pass_test", TestRunStatus::Passed)],
758            elapsed: Duration::ZERO,
759        };
760        assert_eq!(
761            render_epilogue(&summary, 2),
762            "\ntest result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 2 filtered out; finished in 0.00s"
763        );
764    }
765
766    #[test]
767    fn epilogue_recaps_failed_test_names_before_the_summary() {
768        let summary = TestSummary {
769            results: vec![
770                result_for("fail_test", TestRunStatus::Failed { code: Some(1) }),
771                result_for("pass_test", TestRunStatus::Passed),
772            ],
773            elapsed: Duration::ZERO,
774        };
775        assert_eq!(
776            render_epilogue(&summary, 0),
777            "\nfailures:\n    demo:fail_test\n\ntest result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s"
778        );
779    }
780
781    #[test]
782    #[cfg(unix)]
783    fn run_tests_reports_pass_and_fail_in_summary() {
784        struct RecordingSink {
785            finished: Vec<String>,
786        }
787        impl TestOutputSink for RecordingSink {
788            fn write_stdout(&mut self, _e: &TestExecutable, _b: &[u8]) -> io::Result<()> {
789                Ok(())
790            }
791            fn write_stderr(&mut self, _e: &TestExecutable, _b: &[u8]) -> io::Result<()> {
792                Ok(())
793            }
794            fn test_finished(&mut self, result: &TestRunResult) -> io::Result<()> {
795                self.finished.push(result.executable.target.clone());
796                Ok(())
797            }
798        }
799
800        let dir = TempDir::new().unwrap();
801        let pass = dir.child("pass_test");
802        let fail = dir.child("fail_test");
803        write_executable(&pass, "#!/bin/sh\nexit 0\n");
804        write_executable(&fail, "#!/bin/sh\nexit 1\n");
805        let plan = TestPlan {
806            executables: vec![
807                TestExecutable {
808                    package: "demo".into(),
809                    target: "fail_test".into(),
810                    executable: fail.to_path_buf(),
811                    working_dir: dir.path().to_path_buf(),
812                    env: BTreeMap::new(),
813                },
814                TestExecutable {
815                    package: "demo".into(),
816                    target: "pass_test".into(),
817                    executable: pass.to_path_buf(),
818                    working_dir: dir.path().to_path_buf(),
819                    env: BTreeMap::new(),
820                },
821            ],
822        };
823        let mut sink = RecordingSink {
824            finished: Vec::new(),
825        };
826        let summary = run_tests(&plan, &mut sink).unwrap();
827        assert_eq!(summary.total(), 2);
828        assert_eq!(summary.passed(), 1);
829        assert_eq!(summary.failed(), 1);
830        assert!(!summary.all_passed());
831        // execution order matches the plan's input order
832        // (run_tests does not re-sort; that is plan_tests's job).
833        assert_eq!(summary.results[0].executable.target, "fail_test");
834        assert!(matches!(
835            summary.results[0].status,
836            TestRunStatus::Failed { code: Some(1) }
837        ));
838        assert_eq!(summary.results[1].executable.target, "pass_test");
839        assert!(summary.results[1].status.is_success());
840        // the sink saw each completion as it happened, in order.
841        assert_eq!(sink.finished, vec!["fail_test", "pass_test"]);
842    }
843
844    #[test]
845    #[cfg(unix)]
846    fn run_tests_forwards_output_before_process_exits() {
847        struct MarkerSink {
848            marker: PathBuf,
849        }
850
851        impl TestOutputSink for MarkerSink {
852            fn write_stdout(
853                &mut self,
854                _executable: &TestExecutable,
855                bytes: &[u8],
856            ) -> io::Result<()> {
857                if bytes
858                    .windows("ready".len())
859                    .any(|window| window == b"ready")
860                {
861                    std::fs::write(&self.marker, b"seen")?;
862                }
863                Ok(())
864            }
865
866            fn write_stderr(
867                &mut self,
868                _executable: &TestExecutable,
869                _bytes: &[u8],
870            ) -> io::Result<()> {
871                Ok(())
872            }
873        }
874
875        let dir = TempDir::new().unwrap();
876        let marker = dir.child("sink-saw-output");
877        let script = dir.child("streaming_test");
878        write_executable(
879            &script,
880            r#"#!/bin/sh
881printf 'ready\n'
882i=0
883while [ "$i" -lt 40 ]; do
884  if [ -f "$MARKER" ]; then
885    exit 0
886  fi
887  i=$((i + 1))
888  sleep 0.05
889done
890exit 42
891"#,
892        );
893        let plan = TestPlan {
894            executables: vec![TestExecutable {
895                package: "demo".into(),
896                target: "streaming_test".into(),
897                executable: script.to_path_buf(),
898                working_dir: dir.path().to_path_buf(),
899                env: BTreeMap::from([("MARKER".to_owned(), marker.path().as_os_str().to_owned())]),
900            }],
901        };
902        let mut sink = MarkerSink {
903            marker: marker.to_path_buf(),
904        };
905        let summary = run_tests(&plan, &mut sink).unwrap();
906
907        assert!(summary.all_passed(), "{summary:?}");
908        assert_eq!(summary.results[0].stdout, b"ready\n");
909    }
910
911    #[test]
912    fn render_result_line_includes_exit_code_for_failures() {
913        let exe = TestExecutable {
914            package: "demo".into(),
915            target: "fail_test".into(),
916            executable: PathBuf::from("/tmp/x"),
917            working_dir: PathBuf::from("/tmp"),
918            env: BTreeMap::new(),
919        };
920        let result = TestRunResult {
921            executable: exe.clone(),
922            status: TestRunStatus::Failed { code: Some(42) },
923            stdout: Vec::new(),
924            stderr: Vec::new(),
925        };
926        assert_eq!(
927            render_result_line(&result),
928            "test demo:fail_test ... FAILED (exit 42)"
929        );
930        let result = TestRunResult {
931            executable: exe,
932            status: TestRunStatus::Passed,
933            stdout: Vec::new(),
934            stderr: Vec::new(),
935        };
936        assert_eq!(render_result_line(&result), "test demo:fail_test ... ok");
937    }
938
939    #[test]
940    fn streaming_sink_skips_empty_output() {
941        let mut sink = StreamingSink {
942            stdout: Vec::<u8>::new(),
943            stderr: Vec::<u8>::new(),
944        };
945        let exe = TestExecutable {
946            package: "demo".into(),
947            target: "x".into(),
948            executable: PathBuf::from("/tmp/x"),
949            working_dir: PathBuf::from("/tmp"),
950            env: BTreeMap::new(),
951        };
952        sink.write_stdout(&exe, &[]).unwrap();
953        sink.write_stderr(&exe, &[]).unwrap();
954        assert!(sink.stdout.is_empty());
955        assert!(sink.stderr.is_empty());
956        sink.write_stdout(&exe, b"hello").unwrap();
957        sink.test_finished(&TestRunResult {
958            executable: exe,
959            status: TestRunStatus::Passed,
960            stdout: Vec::new(),
961            stderr: Vec::new(),
962        })
963        .unwrap();
964        let out = String::from_utf8(sink.stdout).unwrap();
965        assert!(out.contains("---- stdout: demo:x ----"));
966        assert!(out.contains("hello"));
967        assert!(out.contains("test demo:x ... ok\n"));
968        assert!(out.ends_with('\n'));
969    }
970
971    #[test]
972    fn retry_on_etxtbsy_retries_until_spawn_succeeds() {
973        let mut calls = 0;
974        let result = retry_on_etxtbsy(8, Duration::ZERO, || {
975            calls += 1;
976            if calls < 3 {
977                Err(io::Error::from(io::ErrorKind::ExecutableFileBusy))
978            } else {
979                Ok(99)
980            }
981        });
982        assert_eq!(result.unwrap(), 99);
983        assert_eq!(calls, 3);
984    }
985
986    #[test]
987    fn retry_on_etxtbsy_gives_up_after_max_attempts() {
988        let mut calls = 0;
989        let result: io::Result<()> = retry_on_etxtbsy(4, Duration::ZERO, || {
990            calls += 1;
991            Err(io::Error::from(io::ErrorKind::ExecutableFileBusy))
992        });
993        assert_eq!(
994            result.unwrap_err().kind(),
995            io::ErrorKind::ExecutableFileBusy
996        );
997        assert_eq!(calls, 4);
998    }
999
1000    #[test]
1001    fn retry_on_etxtbsy_does_not_retry_other_errors() {
1002        let mut calls = 0;
1003        let result: io::Result<()> = retry_on_etxtbsy(8, Duration::ZERO, || {
1004            calls += 1;
1005            Err(io::Error::from(io::ErrorKind::PermissionDenied))
1006        });
1007        assert_eq!(result.unwrap_err().kind(), io::ErrorKind::PermissionDenied);
1008        assert_eq!(calls, 1);
1009    }
1010}