use libtest_mimic::Failed;
use std::path::PathBuf;
use std::process::Stdio;
#[derive(Clone)]
pub struct DeviceSuite {
pub crate_dir: PathBuf,
pub test_target: String,
pub cargo_args: Vec<String>,
pub program: String,
}
impl DeviceSuite {
pub fn new(crate_dir: impl Into<PathBuf>, test_target: impl Into<String>) -> Self {
DeviceSuite {
crate_dir: crate_dir.into(),
test_target: test_target.into(),
cargo_args: Vec::new(),
program: "cargo".into(),
}
}
pub fn test(&self, test_path: impl Into<String>) -> DeviceTest {
DeviceTest { suite: self.clone(), test_path: test_path.into(), state: State::Idle }
}
}
enum State {
Idle,
Running(tokio::task::JoinHandle<Result<std::process::ExitStatus, String>>),
Done(Result<(), String>),
}
pub struct DeviceTest {
suite: DeviceSuite,
test_path: String,
state: State,
}
impl DeviceTest {
pub fn test_path(&self) -> &str {
&self.test_path
}
pub fn start(&mut self) {
assert!(
matches!(self.state, State::Idle),
"device test '{}' started twice",
self.test_path
);
if !self.suite.crate_dir.is_dir() {
self.state = State::Done(Err(format!(
"device crate dir {} does not exist",
self.suite.crate_dir.display()
)));
return;
}
let mut cmd = tokio::process::Command::new(&self.suite.program);
cmd.arg("test")
.args(&self.suite.cargo_args)
.args(["--test", &self.suite.test_target, "--", "--exact", &self.test_path])
.current_dir(&self.suite.crate_dir)
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.kill_on_drop(true);
self.state =
State::Running(tokio::spawn(async move { cmd.status().await.map_err(|e| e.to_string()) }));
}
pub async fn failure_context(&mut self) -> Option<String> {
let finished = match &self.state {
State::Done(r) => r.is_err(),
State::Running(handle) => handle.is_finished(),
State::Idle => false,
};
if !finished {
return None;
}
self.verdict()
.await
.err()
.map(|f| f.message().unwrap_or("device test failed").to_string())
}
pub async fn verdict(&mut self) -> Result<(), Failed> {
let result = match std::mem::replace(&mut self.state, State::Idle) {
State::Idle => {
return Err(Failed::from(format!(
"device test '{}' was never started",
self.test_path
)))
}
State::Running(handle) => match handle.await {
Err(join) => Err(format!("device test task failed: {join}")),
Ok(Err(spawn)) => Err(format!(
"spawning {} in {}: {spawn}",
self.suite.program,
self.suite.crate_dir.display()
)),
Ok(Ok(status)) if status.success() => Ok(()),
Ok(Ok(status)) => {
Err(format!("device-side test '{}' reported failure ({status})", self.test_path))
}
},
State::Done(result) => result,
};
self.state = State::Done(result.clone());
result.map_err(Failed::from)
}
}
#[macro_export]
macro_rules! paired_suite {
(@device_test $name:ident) => { concat!("tests::", stringify!($name)) };
(@device_test $name:ident $dt:expr) => { $dt };
(
device_suite: $suite:expr,
$(
scenario $name:ident $(, device_test: $dt:expr)? , |$cx:ident, $dev:ident| $body:block
)*
) => {
fn main() -> ::std::process::ExitCode {
let __suite = $suite;
$crate::run(::std::vec![
$(
{
let __suite = __suite.clone();
$crate::BancTest::new(
stringify!($name),
move |$cx: $crate::TestCx| {
::std::boxed::Box::pin(async move {
let mut $dev =
__suite.test($crate::paired_suite!(@device_test $name $($dt)?));
let __body: ::std::result::Result<(), $crate::Failed> =
async { $body Ok(()) }.await;
if let ::std::result::Result::Err(e) = __body {
let msg = e.message().unwrap_or("test failed").to_string();
return ::std::result::Result::Err(
match $dev.failure_context().await {
::std::option::Option::Some(dev_err) => $crate::Failed::from(
::std::format!("{msg}\ndevice side: {dev_err}"),
),
::std::option::Option::None => $crate::Failed::from(msg),
},
);
}
$dev.verdict().await
})
},
)
}
),*
])
}
};
}
#[cfg(test)]
mod tests {
use super::*;
fn suite(program: &str) -> DeviceSuite {
let mut s = DeviceSuite::new(std::env::temp_dir(), "join");
s.program = program.into();
s
}
#[tokio::test]
async fn verdict_tracks_child_exit() {
let mut ok = suite("true").test("tests::x");
ok.start();
assert!(ok.verdict().await.is_ok());
assert!(ok.verdict().await.is_ok());
let mut bad = suite("false").test("tests::x");
bad.start();
assert!(bad.verdict().await.is_err());
assert!(bad.verdict().await.is_err());
}
#[tokio::test]
async fn missing_crate_dir_fails_at_start_with_context() {
let mut suite = DeviceSuite::new("/nonexistent/fw-crate", "join");
suite.program = "true".into();
let mut dt = suite.test("tests::x");
dt.start();
let ctx = dt.failure_context().await.unwrap();
assert!(ctx.contains("does not exist"), "unexpected context: {ctx}");
assert!(dt.verdict().await.is_err());
}
#[cfg(unix)]
#[tokio::test]
async fn no_failure_context_while_device_still_running() {
use std::os::unix::fs::PermissionsExt;
let script = std::env::temp_dir().join(format!("banc-slow-{}", std::process::id()));
std::fs::write(&script, "#!/bin/sh\nsleep 5\n").unwrap();
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
let mut suite = DeviceSuite::new(std::env::temp_dir(), "join");
suite.program = script.to_str().unwrap().into();
let mut dt = suite.test("tests::x");
dt.start();
assert!(dt.failure_context().await.is_none());
assert!(matches!(dt.state, State::Running(_)));
std::fs::remove_file(script).ok();
}
#[tokio::test]
async fn never_started_is_a_failure() {
let mut dt = suite("true").test("tests::x");
let err = dt.verdict().await.unwrap_err();
assert!(err.message().unwrap().contains("never started"));
}
#[tokio::test]
async fn spawn_error_is_reported_not_panicked() {
let mut dt = suite("/nonexistent/no-such-program").test("tests::x");
dt.start();
assert!(dt.verdict().await.is_err());
}
}