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;
29
30use cabin_build::BuildGraph;
31use cabin_core::TargetKind;
32use cabin_workspace::{PackageGraph, WorkspacePackage};
33use thiserror::Error;
34
35/// One executable in a [`TestPlan`].
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct TestExecutable {
38    /// Workspace package the test belongs to. Used both for
39    /// summary output and for the executable's working directory.
40    pub package: String,
41    /// Manifest-declared target name (without any path / extension).
42    pub target: String,
43    /// Filesystem path of the linked test executable.
44    pub executable: PathBuf,
45    /// Manifest directory of the producing package. Used as the
46    /// working directory when the executable runs so tests can
47    /// reach repository-relative fixture data deterministically.
48    pub working_dir: PathBuf,
49    /// Deterministic env overlay applied on top of the
50    /// inherited environment when the executable runs. Intended
51    /// for `CABIN_*` keys produced by the orchestration layer
52    /// via `cabin_env::package_env`. Empty by default; callers
53    /// that do not populate the overlay see the inherited
54    /// environment unchanged.
55    pub env: BTreeMap<String, OsString>,
56}
57
58/// A finalized, ordered list of `test` executables to run.
59///
60/// Ordering is deterministic: by package name, then by target
61/// name. Build it with [`plan_tests`] and consume it with
62/// [`run_tests`]. Empty plans are allowed; the CLI decides
63/// whether an empty plan is an error or a clean no-op.
64#[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    /// Iterate executables in the plan's documented order.
80    pub fn iter(&self) -> std::slice::Iter<'_, TestExecutable> {
81        self.executables.iter()
82    }
83
84    /// Number of executables to run.
85    pub fn len(&self) -> usize {
86        self.executables.len()
87    }
88
89    /// `true` if the plan has no executables.
90    pub fn is_empty(&self) -> bool {
91        self.executables.is_empty()
92    }
93
94    /// Apply `f` to every executable in the plan. Used by the
95    /// orchestration layer to attach a `CABIN_*` env overlay
96    /// after planning without exposing the executables vec
97    /// directly.
98    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
105/// Build a [`TestPlan`] from a finished [`BuildGraph`] plus the
106/// originating [`PackageGraph`].
107///
108/// The plan picks every `test` target whose linked
109/// executable appears in `graph.default_outputs` (i.e. every
110/// `test` the build was asked to produce). `test`
111/// targets that the planner did *not* build are absent from the
112/// plan — that is the contract: callers select which test targets
113/// to build (typically through the planner's manifest-target
114/// selector list), and `plan_tests` runs exactly the ones whose
115/// executable exists in the graph.
116///
117/// If `selected_packages` is `Some`, the plan is restricted to
118/// those package indices; passing `None` walks the graph's
119/// primary set, matching the planner's default selection.
120///
121/// Ordering is `(package_name, target_name)` ascending — the
122/// same order `cabin metadata` and the planner emit, so plans
123/// are deterministic across runs.
124pub 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            // Skip tests the planner was not asked to build.
148            // Callers that pass a narrower manifest-target
149            // selector list rely on this to drop targets that did
150            // not make it into the graph.
151            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    // The planner names every `test` executable
180    // `<build_dir>/<profile>/packages/<pkg>/<target>` with no
181    // extension on POSIX. We scan `default_outputs` for the
182    // matching tail rather than re-deriving the path here so the
183    // planner stays the single source of truth for output paths.
184    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/// Result of running one test executable.
191#[derive(Debug, Clone, PartialEq, Eq)]
192pub struct TestRunResult {
193    /// The executable that was run.
194    pub executable: TestExecutable,
195    /// Outcome classification (passed / failed).
196    pub status: TestRunStatus,
197    /// Captured stdout, in order of arrival.
198    pub stdout: Vec<u8>,
199    /// Captured stderr, in order of arrival.
200    pub stderr: Vec<u8>,
201}
202
203/// Outcome of one test executable.
204#[derive(Debug, Clone, Copy, PartialEq, Eq)]
205pub enum TestRunStatus {
206    /// Process exited with status `0`.
207    Passed,
208    /// Process exited with a non-zero status. The exit status is
209    /// included so callers can render `(exit code N)`.
210    Failed { code: Option<i32> },
211}
212
213impl TestRunStatus {
214    /// `true` for successful outcomes only.
215    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/// Aggregate summary of one `cabin test` run.
231#[derive(Debug, Clone, PartialEq, Eq)]
232pub struct TestSummary {
233    /// Per-executable results in execution order.
234    pub results: Vec<TestRunResult>,
235}
236
237impl TestSummary {
238    /// Total number of executables run.
239    pub fn total(&self) -> usize {
240        self.results.len()
241    }
242
243    /// Number of executables that exited with status `0`.
244    pub fn passed(&self) -> usize {
245        self.results
246            .iter()
247            .filter(|r| r.status.is_success())
248            .count()
249    }
250
251    /// Number of executables that exited non-zero.
252    pub fn failed(&self) -> usize {
253        self.results
254            .iter()
255            .filter(|r| !r.status.is_success())
256            .count()
257    }
258
259    /// `true` if every executable in the summary passed.
260    pub fn all_passed(&self) -> bool {
261        self.results.iter().all(|r| r.status.is_success())
262    }
263}
264
265/// Sink for test executable output. The runner forwards stdout /
266/// stderr chunks to this sink while each process is still
267/// running, and also keeps a full captured copy in
268/// [`TestRunResult`]. Tests in this crate use [`null_sink`] to
269/// discard output.
270pub trait TestOutputSink {
271    /// Called zero or more times per executable with stdout bytes.
272    ///
273    /// # Errors
274    /// Returns the implementor's [`io::Error`] if the sink fails to
275    /// write the supplied stdout bytes.
276    fn write_stdout(&mut self, executable: &TestExecutable, bytes: &[u8]) -> io::Result<()>;
277    /// Called zero or more times per executable with stderr bytes.
278    ///
279    /// # Errors
280    /// Returns the implementor's [`io::Error`] if the sink fails to
281    /// write the supplied stderr bytes.
282    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
294/// A `TestOutputSink` that discards all bytes — useful for unit
295/// tests of the runner itself.
296pub fn null_sink() -> impl TestOutputSink {}
297
298/// A `TestOutputSink` that streams bytes to the supplied
299/// stdout/stderr writers. Each non-empty write prepends a header
300/// so the user can tell which executable is speaking.
301pub struct StreamingSink<W1, W2> {
302    /// Writer for captured stdout (typically the parent process's
303    /// stdout).
304    pub stdout: W1,
305    /// Writer for captured stderr (typically the parent process's
306    /// stderr).
307    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
341/// Run every executable in `plan` sequentially in the order
342/// produced by [`plan_tests`]. Each test runs to completion
343/// before the next starts; the runner does not introduce
344/// parallelism in this release. The returned [`TestSummary`]
345/// preserves the plan's order so output stays deterministic.
346///
347/// A test executable's stdout / stderr are forwarded to `sink`
348/// while the process is running and also captured to memory for
349/// the returned summary. Streaming sinks (see [`StreamingSink`])
350/// write a header for each non-empty output chunk so multi-test
351/// runs are easy to read.
352///
353/// # Panics
354///
355/// Panics if a spawned child process does not expose stdout or
356/// stderr after the runner configured both streams as piped.
357///
358/// # Errors
359/// Returns [`TestRunError`]: `Spawn` if a test executable cannot be
360/// started, `Wait` if waiting on a running child fails, `OutputIo`
361/// if reading the child's stdout/stderr fails, and `SinkIo` if
362/// forwarding captured output to `sink` fails (propagated from the
363/// sink's `write_stdout` / `write_stderr`).
364pub 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        // Tests inherit the user's PATH plus whatever Cabin's
374        // own caller has set, with the per-executable env
375        // overlay applied on top so the orchestration layer can
376        // surface deterministic CABIN_* values without forcing
377        // every test fixture to re-derive them.
378        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
495/// Format a one-line summary for display:
496/// `running N tests` / `test result: ok. P passed; F failed`.
497/// Centralized here so the CLI does not invent its own format.
498pub 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
506/// Render the per-test "running" header used by the CLI before
507/// each executable starts.
508pub fn render_running_line(executable: &TestExecutable) -> String {
509    format!("running test {}:{}", executable.package, executable.target)
510}
511
512/// Render the per-test result line emitted after each executable
513/// finishes.
514pub 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/// Errors produced while running tests.
527#[derive(Debug, Error)]
528pub enum TestRunError {
529    /// The OS could not start the test executable.
530    #[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    /// The OS started the test executable, but waiting for it to
539    /// finish failed.
540    #[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    /// Reading stdout / stderr from the child process failed.
549    #[error("failed to read captured test output: {0}")]
550    OutputIo(#[source] io::Error),
551    /// Writing captured stdout / stderr to the sink failed. The
552    /// runner stops at the first failure rather than continuing
553    /// silently.
554    #[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        // sanity: TestPlan does not reorder; ordering is the
593        // plan_tests() job. We test here that summary_line is
594        // stable for a known shape.
595        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        // execution order matches the plan's input order
648        // (run_tests does not re-sort; that is plan_tests's job).
649        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}