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;
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.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    // 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}
247
248impl TestSummary {
249    /// Total number of executables run.
250    pub fn total(&self) -> usize {
251        self.results.len()
252    }
253
254    /// Number of executables that exited with status `0`.
255    pub fn passed(&self) -> usize {
256        self.results
257            .iter()
258            .filter(|r| r.status.is_success())
259            .count()
260    }
261
262    /// Number of executables that exited non-zero.
263    pub fn failed(&self) -> usize {
264        self.results
265            .iter()
266            .filter(|r| !r.status.is_success())
267            .count()
268    }
269
270    /// `true` if every executable in the summary passed.
271    pub fn all_passed(&self) -> bool {
272        self.results.iter().all(|r| r.status.is_success())
273    }
274}
275
276/// Sink for test executable output. The runner forwards stdout /
277/// stderr chunks to this sink while each process is still
278/// running, and also keeps a full captured copy in
279/// [`TestRunResult`]. Tests in this crate use [`null_sink`] to
280/// discard output.
281pub trait TestOutputSink {
282    /// Called zero or more times per executable with stdout bytes.
283    ///
284    /// # Errors
285    /// Returns the implementor's [`io::Error`] if the sink fails to
286    /// write the supplied stdout bytes.
287    fn write_stdout(&mut self, executable: &TestExecutable, bytes: &[u8]) -> io::Result<()>;
288    /// Called zero or more times per executable with stderr bytes.
289    ///
290    /// # Errors
291    /// Returns the implementor's [`io::Error`] if the sink fails to
292    /// write the supplied stderr bytes.
293    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
305/// A `TestOutputSink` that discards all bytes — useful for unit
306/// tests of the runner itself.
307pub fn null_sink() -> impl TestOutputSink {}
308
309/// A `TestOutputSink` that streams bytes to the supplied
310/// stdout/stderr writers. Each non-empty write prepends a header
311/// so the user can tell which executable is speaking.
312pub struct StreamingSink<W1, W2> {
313    /// Writer for captured stdout (typically the parent process's
314    /// stdout).
315    pub stdout: W1,
316    /// Writer for captured stderr (typically the parent process's
317    /// stderr).
318    pub stderr: W2,
319}
320
321/// Stream `bytes` to `writer` under a `---- <label>: <pkg>:<target> ----`
322/// header, appending a trailing newline when the payload lacks one. A
323/// no-op for empty `bytes`.
324fn 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
353/// Run every executable in `plan` sequentially in the order
354/// produced by [`plan_tests`]. Each test runs to completion
355/// before the next starts; the runner does not introduce
356/// parallelism in this release. The returned [`TestSummary`]
357/// preserves the plan's order so output stays deterministic.
358///
359/// A test executable's stdout / stderr are forwarded to `sink`
360/// while the process is running and also captured to memory for
361/// the returned summary. Streaming sinks (see [`StreamingSink`])
362/// write a header for each non-empty output chunk so multi-test
363/// runs are easy to read.
364///
365/// # Panics
366///
367/// Panics if a spawned child process does not expose stdout or
368/// stderr after the runner configured both streams as piped.
369///
370/// # Errors
371/// Returns [`TestRunError`]: `Spawn` if a test executable cannot be
372/// started, `Wait` if waiting on a running child fails, `OutputIo`
373/// if reading the child's stdout/stderr fails, and `SinkIo` if
374/// forwarding captured output to `sink` fails (propagated from the
375/// sink's `write_stdout` / `write_stderr`).
376pub 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        // Tests inherit the user's PATH plus whatever Cabin's
386        // own caller has set, with the per-executable env
387        // overlay applied on top so the orchestration layer can
388        // surface deterministic CABIN_* values without forcing
389        // every test fixture to re-derive them.
390        for (key, value) in &executable.env {
391            command.env(key, value);
392        }
393        // Retry on `ETXTBSY`: a sibling thread that forks while we
394        // are mid-`write`/`chmod` of another executable can leave a
395        // writable fd to this file briefly inherited in its
396        // not-yet-`execve`d child, which makes our own `execve`
397        // race-fail. The window clears within milliseconds.
398        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
449/// Total spawn attempts before an `ETXTBSY` failure is surfaced.
450const SPAWN_RETRY_ATTEMPTS: u32 = 8;
451/// Backoff before the first spawn retry; doubles on each retry, so
452/// eight attempts wait up to ~127ms in total before giving up.
453const SPAWN_RETRY_BASE_DELAY: Duration = Duration::from_millis(1);
454
455/// Call `attempt`, retrying with exponential backoff while it fails
456/// with [`io::ErrorKind::ExecutableFileBusy`] (`ETXTBSY`). Any other
457/// outcome — success or a different error — returns immediately, and
458/// the final attempt's result is returned even if still busy. Always
459/// calls `attempt` at least once.
460fn 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
545/// Format a one-line summary for display:
546/// `running N tests` / `test result: ok. P passed; F failed`.
547/// Centralized here so the CLI does not invent its own format.
548pub 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
556/// Render the per-test "running" header used by the CLI before
557/// each executable starts.
558pub fn render_running_line(executable: &TestExecutable) -> String {
559    format!("running test {}:{}", executable.package, executable.target)
560}
561
562/// Render the per-test result line emitted after each executable
563/// finishes.
564pub 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/// Errors produced while running tests.
577#[derive(Debug, Error)]
578pub enum TestRunError {
579    /// The OS could not start the test executable.
580    #[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    /// The OS started the test executable, but waiting for it to
589    /// finish failed.
590    #[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    /// Reading stdout / stderr from the child process failed.
599    #[error("failed to read captured test output: {0}")]
600    OutputIo(#[source] io::Error),
601    /// Writing captured stdout / stderr to the sink failed. The
602    /// runner stops at the first failure rather than continuing
603    /// silently.
604    #[error("failed to write captured test output: {0}")]
605    SinkIo(#[source] io::Error),
606}
607
608#[cfg(test)]
609mod tests {
610    use super::*;
611    // `assert_fs` is only used by the Unix-only fixture tests below.
612    #[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    // The fixture-based tests below run a fake test executable. On Unix
620    // that fixture is a `#!/bin/sh` script marked executable; Windows
621    // has no equivalent that `Command::new` can spawn directly (a
622    // `.bat` needs `cmd`, a real `.exe` needs a compiler), so those
623    // tests are Unix-only. The production `cabin test` path is covered
624    // on Windows by the `library-with-tests` example end-to-end test,
625    // which runs real compiled `.exe` test targets.
626    #[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        // sanity: TestPlan does not reorder; ordering is the
655        // plan_tests() job. We test here that summary_line is
656        // stable for a known shape.
657        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        // execution order matches the plan's input order
711        // (run_tests does not re-sort; that is plan_tests's job).
712        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}