Skip to main content

banc_host/
device.rs

1//! The device half of a paired scenario: one on-target embedded-test,
2//! run as a child `cargo test` invocation in the firmware crate (which
3//! builds, flashes and executes it via that crate's configured runner,
4//! e.g. probe-rs).
5//!
6//! Lifecycle contract, enforced by [`crate::paired_suite!`]:
7//! - the test is spawned with kill-on-drop, so a host-side scenario that
8//!   fails early takes the build/flash child down with its trial runtime
9//!   instead of leaving the probe busy for the next scenario;
10//! - every scenario ends by awaiting [`DeviceTest::verdict`]; a scenario
11//!   that never started its device test fails rather than silently
12//!   passing on host-side evidence alone.
13
14use libtest_mimic::Failed;
15use std::path::PathBuf;
16use std::process::Stdio;
17
18/// How to invoke the device-side test crate. One per suite, cloned into
19/// each scenario.
20#[derive(Clone)]
21pub struct DeviceSuite {
22    /// Directory of the firmware crate whose tests run on-target.
23    pub crate_dir: PathBuf,
24    /// Test target within that crate: `cargo test --test <this>`.
25    pub test_target: String,
26    /// Extra cargo args, e.g. `["--release", "--offline"]`. Empty by default.
27    pub cargo_args: Vec<String>,
28    /// Program to invoke; "cargo" unless overridden (unit tests use this).
29    pub program: String,
30}
31
32impl DeviceSuite {
33    pub fn new(crate_dir: impl Into<PathBuf>, test_target: impl Into<String>) -> Self {
34        DeviceSuite {
35            crate_dir: crate_dir.into(),
36            test_target: test_target.into(),
37            cargo_args: Vec::new(),
38            program: "cargo".into(),
39        }
40    }
41
42    /// A handle for one on-target test, not yet started: the scenario
43    /// decides when to flash (typically after its network fixture is up).
44    pub fn test(&self, test_path: impl Into<String>) -> DeviceTest {
45        DeviceTest { suite: self.clone(), test_path: test_path.into(), state: State::Idle }
46    }
47}
48
49enum State {
50    Idle,
51    Running(tokio::task::JoinHandle<Result<std::process::ExitStatus, String>>),
52    Done(Result<(), String>),
53}
54
55pub struct DeviceTest {
56    suite: DeviceSuite,
57    test_path: String,
58    state: State,
59}
60
61impl DeviceTest {
62    /// Exact on-target test this handle runs (`--exact` filter).
63    pub fn test_path(&self) -> &str {
64        &self.test_path
65    }
66
67    /// Build/flash/run the on-target test. Output is inherited so device
68    /// logs interleave with the host's. Must be called from within the
69    /// trial's runtime; panics if started twice.
70    pub fn start(&mut self) {
71        assert!(
72            matches!(self.state, State::Idle),
73            "device test '{}' started twice",
74            self.test_path
75        );
76        // A missing crate dir would otherwise surface as a silent no-show:
77        // the child dies unspawned, the device transmits nothing, and the
78        // scenario times out on its first expect with no hint why (seen
79        // 2026-08-04, CI dispatched on a ref without the fw crate).
80        if !self.suite.crate_dir.is_dir() {
81            self.state = State::Done(Err(format!(
82                "device crate dir {} does not exist",
83                self.suite.crate_dir.display()
84            )));
85            return;
86        }
87        let mut cmd = tokio::process::Command::new(&self.suite.program);
88        cmd.arg("test")
89            .args(&self.suite.cargo_args)
90            .args(["--test", &self.suite.test_target, "--", "--exact", &self.test_path])
91            .current_dir(&self.suite.crate_dir)
92            .stdout(Stdio::inherit())
93            .stderr(Stdio::inherit())
94            .kill_on_drop(true);
95        self.state =
96            State::Running(tokio::spawn(async move { cmd.status().await.map_err(|e| e.to_string()) }));
97    }
98
99    /// If the device side has already failed (spawn error, missing crate
100    /// dir, early exit), return its error without waiting. Context for
101    /// host-side failures: a scenario that times out because the device
102    /// never ran should say so, not just "deadline elapsed".
103    pub async fn failure_context(&mut self) -> Option<String> {
104        let finished = match &self.state {
105            State::Done(r) => r.is_err(),
106            State::Running(handle) => handle.is_finished(),
107            State::Idle => false,
108        };
109        if !finished {
110            return None;
111        }
112        self.verdict()
113            .await
114            .err()
115            .map(|f| f.message().unwrap_or("device test failed").to_string())
116    }
117
118    /// The device-side result (semihosting exit code via the runner).
119    /// Idempotent: awaits the child on first call, cached afterwards, so a
120    /// scenario may consult it mid-body and the suite's trailing check is
121    /// still valid. Never started => failure, not a pass.
122    pub async fn verdict(&mut self) -> Result<(), Failed> {
123        let result = match std::mem::replace(&mut self.state, State::Idle) {
124            State::Idle => {
125                return Err(Failed::from(format!(
126                    "device test '{}' was never started",
127                    self.test_path
128                )))
129            }
130            State::Running(handle) => match handle.await {
131                Err(join) => Err(format!("device test task failed: {join}")),
132                Ok(Err(spawn)) => Err(format!(
133                    "spawning {} in {}: {spawn}",
134                    self.suite.program,
135                    self.suite.crate_dir.display()
136                )),
137                Ok(Ok(status)) if status.success() => Ok(()),
138                Ok(Ok(status)) => {
139                    Err(format!("device-side test '{}' reported failure ({status})", self.test_path))
140                }
141            },
142            State::Done(result) => result,
143        };
144        self.state = State::Done(result.clone());
145        result.map_err(Failed::from)
146    }
147}
148
149/// Generate a paired-scenario suite: a `harness = false` `main` where every
150/// scenario owns a [`DeviceTest`] handle bound to its on-target twin.
151///
152/// ```ignore
153/// banc_host::paired_suite! {
154///     device_suite: my_device_suite(),
155///     scenario join_ok, device_test: "tests::join_ok", |cx, device| {
156///         let mut net = Net::bind(&cx).await?;
157///         device.start();
158///         net.expect("JoinRequest", secs(120), |e| ...).await?;
159///     }
160///     // device_test omitted => derived as "tests::<scenario name>"
161///     scenario rx2_fallback, |cx, device| { ... }
162/// }
163/// ```
164///
165/// The macro wires what today is hand-written per scenario: the
166/// `BancTest`/`Box::pin` wrapper, the device handle (named from the scenario
167/// unless overridden), and the trailing `device.verdict().await` so no
168/// scenario can pass without its device half agreeing.
169#[macro_export]
170macro_rules! paired_suite {
171    (@device_test $name:ident) => { concat!("tests::", stringify!($name)) };
172    (@device_test $name:ident $dt:expr) => { $dt };
173    (
174        device_suite: $suite:expr,
175        $(
176            scenario $name:ident $(, device_test: $dt:expr)? , |$cx:ident, $dev:ident| $body:block
177        )*
178    ) => {
179        fn main() -> ::std::process::ExitCode {
180            let __suite = $suite;
181            $crate::run(::std::vec![
182                $(
183                    {
184                        let __suite = __suite.clone();
185                        $crate::BancTest::new(
186                            stringify!($name),
187                            move |$cx: $crate::TestCx| {
188                                ::std::boxed::Box::pin(async move {
189                                    let mut $dev =
190                                        __suite.test($crate::paired_suite!(@device_test $name $($dt)?));
191                                    let __body: ::std::result::Result<(), $crate::Failed> =
192                                        async { $body Ok(()) }.await;
193                                    if let ::std::result::Result::Err(e) = __body {
194                                        let msg = e.message().unwrap_or("test failed").to_string();
195                                        return ::std::result::Result::Err(
196                                            match $dev.failure_context().await {
197                                                ::std::option::Option::Some(dev_err) => $crate::Failed::from(
198                                                    ::std::format!("{msg}\ndevice side: {dev_err}"),
199                                                ),
200                                                ::std::option::Option::None => $crate::Failed::from(msg),
201                                            },
202                                        );
203                                    }
204                                    $dev.verdict().await
205                                })
206                            },
207                        )
208                    }
209                ),*
210            ])
211        }
212    };
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    fn suite(program: &str) -> DeviceSuite {
220        let mut s = DeviceSuite::new(std::env::temp_dir(), "join");
221        s.program = program.into();
222        s
223    }
224
225    #[tokio::test]
226    async fn verdict_tracks_child_exit() {
227        let mut ok = suite("true").test("tests::x");
228        ok.start();
229        assert!(ok.verdict().await.is_ok());
230        // Cached: a second consultation agrees.
231        assert!(ok.verdict().await.is_ok());
232
233        let mut bad = suite("false").test("tests::x");
234        bad.start();
235        assert!(bad.verdict().await.is_err());
236        assert!(bad.verdict().await.is_err());
237    }
238
239    #[tokio::test]
240    async fn missing_crate_dir_fails_at_start_with_context() {
241        let mut suite = DeviceSuite::new("/nonexistent/fw-crate", "join");
242        suite.program = "true".into();
243        let mut dt = suite.test("tests::x");
244        dt.start();
245        // Available immediately as context, before any verdict await.
246        let ctx = dt.failure_context().await.unwrap();
247        assert!(ctx.contains("does not exist"), "unexpected context: {ctx}");
248        assert!(dt.verdict().await.is_err());
249    }
250
251    #[cfg(unix)]
252    #[tokio::test]
253    async fn no_failure_context_while_device_still_running() {
254        use std::os::unix::fs::PermissionsExt;
255        let script = std::env::temp_dir().join(format!("banc-slow-{}", std::process::id()));
256        std::fs::write(&script, "#!/bin/sh\nsleep 5\n").unwrap();
257        std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
258
259        let mut suite = DeviceSuite::new(std::env::temp_dir(), "join");
260        suite.program = script.to_str().unwrap().into();
261        let mut dt = suite.test("tests::x");
262        dt.start();
263        assert!(dt.failure_context().await.is_none());
264        // Not consumed: the handle is still awaitable afterwards.
265        assert!(matches!(dt.state, State::Running(_)));
266        std::fs::remove_file(script).ok();
267    }
268
269    #[tokio::test]
270    async fn never_started_is_a_failure() {
271        let mut dt = suite("true").test("tests::x");
272        let err = dt.verdict().await.unwrap_err();
273        assert!(err.message().unwrap().contains("never started"));
274    }
275
276    #[tokio::test]
277    async fn spawn_error_is_reported_not_panicked() {
278        let mut dt = suite("/nonexistent/no-such-program").test("tests::x");
279        dt.start();
280        assert!(dt.verdict().await.is_err());
281    }
282}