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        let mut cmd = tokio::process::Command::new(&self.suite.program);
77        cmd.arg("test")
78            .args(&self.suite.cargo_args)
79            .args(["--test", &self.suite.test_target, "--", "--exact", &self.test_path])
80            .current_dir(&self.suite.crate_dir)
81            .stdout(Stdio::inherit())
82            .stderr(Stdio::inherit())
83            .kill_on_drop(true);
84        self.state =
85            State::Running(tokio::spawn(async move { cmd.status().await.map_err(|e| e.to_string()) }));
86    }
87
88    /// The device-side result (semihosting exit code via the runner).
89    /// Idempotent: awaits the child on first call, cached afterwards, so a
90    /// scenario may consult it mid-body and the suite's trailing check is
91    /// still valid. Never started => failure, not a pass.
92    pub async fn verdict(&mut self) -> Result<(), Failed> {
93        let result = match std::mem::replace(&mut self.state, State::Idle) {
94            State::Idle => {
95                return Err(Failed::from(format!(
96                    "device test '{}' was never started",
97                    self.test_path
98                )))
99            }
100            State::Running(handle) => match handle.await {
101                Err(join) => Err(format!("device test task failed: {join}")),
102                Ok(Err(spawn)) => Err(format!(
103                    "spawning {} in {}: {spawn}",
104                    self.suite.program,
105                    self.suite.crate_dir.display()
106                )),
107                Ok(Ok(status)) if status.success() => Ok(()),
108                Ok(Ok(status)) => {
109                    Err(format!("device-side test '{}' reported failure ({status})", self.test_path))
110                }
111            },
112            State::Done(result) => result,
113        };
114        self.state = State::Done(result.clone());
115        result.map_err(Failed::from)
116    }
117}
118
119/// Generate a paired-scenario suite: a `harness = false` `main` where every
120/// scenario owns a [`DeviceTest`] handle bound to its on-target twin.
121///
122/// ```ignore
123/// banc_host::paired_suite! {
124///     device_suite: my_device_suite(),
125///     scenario join_ok, device_test: "tests::join_ok", |cx, device| {
126///         let mut net = Net::bind(&cx).await?;
127///         device.start();
128///         net.expect("JoinRequest", secs(120), |e| ...).await?;
129///     }
130///     // device_test omitted => derived as "tests::<scenario name>"
131///     scenario rx2_fallback, |cx, device| { ... }
132/// }
133/// ```
134///
135/// The macro wires what today is hand-written per scenario: the
136/// `BancTest`/`Box::pin` wrapper, the device handle (named from the scenario
137/// unless overridden), and the trailing `device.verdict().await` so no
138/// scenario can pass without its device half agreeing.
139#[macro_export]
140macro_rules! paired_suite {
141    (@device_test $name:ident) => { concat!("tests::", stringify!($name)) };
142    (@device_test $name:ident $dt:expr) => { $dt };
143    (
144        device_suite: $suite:expr,
145        $(
146            scenario $name:ident $(, device_test: $dt:expr)? , |$cx:ident, $dev:ident| $body:block
147        )*
148    ) => {
149        fn main() -> ::std::process::ExitCode {
150            let __suite = $suite;
151            $crate::run(::std::vec![
152                $(
153                    {
154                        let __suite = __suite.clone();
155                        $crate::BancTest::new(
156                            stringify!($name),
157                            move |$cx: $crate::TestCx| {
158                                ::std::boxed::Box::pin(async move {
159                                    let mut $dev =
160                                        __suite.test($crate::paired_suite!(@device_test $name $($dt)?));
161                                    let __body: ::std::result::Result<(), $crate::Failed> =
162                                        async { $body Ok(()) }.await;
163                                    __body?;
164                                    $dev.verdict().await
165                                })
166                            },
167                        )
168                    }
169                ),*
170            ])
171        }
172    };
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    fn suite(program: &str) -> DeviceSuite {
180        let mut s = DeviceSuite::new(std::env::temp_dir(), "join");
181        s.program = program.into();
182        s
183    }
184
185    #[tokio::test]
186    async fn verdict_tracks_child_exit() {
187        let mut ok = suite("true").test("tests::x");
188        ok.start();
189        assert!(ok.verdict().await.is_ok());
190        // Cached: a second consultation agrees.
191        assert!(ok.verdict().await.is_ok());
192
193        let mut bad = suite("false").test("tests::x");
194        bad.start();
195        assert!(bad.verdict().await.is_err());
196        assert!(bad.verdict().await.is_err());
197    }
198
199    #[tokio::test]
200    async fn never_started_is_a_failure() {
201        let mut dt = suite("true").test("tests::x");
202        let err = dt.verdict().await.unwrap_err();
203        assert!(err.message().unwrap().contains("never started"));
204    }
205
206    #[tokio::test]
207    async fn spawn_error_is_reported_not_panicked() {
208        let mut dt = suite("/nonexistent/no-such-program").test("tests::x");
209        dt.start();
210        assert!(dt.verdict().await.is_err());
211    }
212}