diff --git a/.vscode/settings.json b/.vscode/settings.json index 6c67b1c..3cdb4e0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,6 +3,7 @@ "all-devices", "client", "server", + "test", ], "rust-analyzer.check.workspace": false } diff --git a/Cargo.toml b/Cargo.toml index b32797e..7c5cc4e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -57,7 +57,7 @@ tracing-futures = { version = "0.2.5", features = ["futures-03"], optional = tru windows-sys = { version = "0.59.0", features = ["Win32_Networking_WinSock"] } [dev-dependencies] -ascom-alpaca = { path = ".", features = ["client", "server"] } +ascom-alpaca = { path = ".", features = ["client", "server", "test"] } criterion = { version = "0.5.1" } ctor = "0.2.8" serial_test = "3.1.1" @@ -70,11 +70,13 @@ tokio = { workspace = true, features = ["rt-multi-thread", "process"] } [[bench]] name = "image_array" harness = false +required-features = ["client", "camera", "test"] [features] all-devices = ["camera", "covercalibrator", "dome", "filterwheel", "focuser", "observingconditions", "rotator", "safetymonitor", "switch", "telescope"] __anydevice = [] +test = [] camera = ["__anydevice", "dep:bytemuck", "dep:mediatype", "dep:ndarray", "dep:serde-ndim", "dep:time"] covercalibrator = ["__anydevice"] diff --git a/benches/image_array.rs b/benches/image_array.rs index d4295e2..36dad51 100644 --- a/benches/image_array.rs +++ b/benches/image_array.rs @@ -1,25 +1,23 @@ -use ascom_alpaca::api::TypedDevice; -use ascom_alpaca::Client; +use ascom_alpaca::api::Camera; +use ascom_alpaca::test_utils::OmniSim; use criterion::{criterion_group, criterion_main, Criterion}; use eyre::ContextCompat; use std::time::Duration; fn download_image_array(c: &mut Criterion) { - c.bench_function("download_image_array", |b| { - let runtime = tokio::runtime::Runtime::new().unwrap(); + let runtime = tokio::runtime::Runtime::new().unwrap(); + let test_env = runtime + .block_on(OmniSim::acquire()) + .expect("Failed to acquire test environment"); + + c.bench_function("download_image_array", move |b| { let camera = runtime - .block_on(async move { - // Create client against the default Alpaca simulators port. - let client = Client::new("http://localhost:32323/")?; - let camera = client - .get_devices() - .await? - .find_map(|device| match device { - TypedDevice::Camera(camera) => Some(camera), - #[allow(unreachable_patterns)] - _ => None, - }) + .block_on(async { + let camera = test_env + .devices() + .iter::() + .next() .context("No camera found")?; camera.set_connected(true).await?; camera.start_exposure(0.001, true).await?; @@ -33,7 +31,9 @@ fn download_image_array(c: &mut Criterion) { }) .expect("Failed to capture a test image"); - b.iter_with_large_drop(|| runtime.block_on(camera.image_array()).unwrap()); + b.iter_with_large_drop(|| { + runtime.block_on(camera.image_array()).unwrap(); + }); }); } diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 0000000..285a64e --- /dev/null +++ b/clippy.toml @@ -0,0 +1 @@ +doc-valid-idents = ["ConformU", ".."] diff --git a/src/lib.rs b/src/lib.rs index c5491a4..33a2fc8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -365,310 +365,13 @@ pub mod discovery; mod errors; mod response; +/// Utilities for testing Alpaca client and server implementations. +#[cfg(feature = "test")] +pub mod test_utils; + pub use api::Devices; #[cfg(feature = "client")] pub use client::Client; pub use errors::{ASCOMError, ASCOMErrorCode, ASCOMResult}; #[cfg(feature = "server")] pub use server::{BoundServer, Server}; - -#[cfg(test)] -mod test_utils { - use crate::api::{DevicePath, DeviceType}; - use crate::{Client, Devices, Server}; - use net_literals::addr; - use std::net::SocketAddr; - use std::process::Stdio; - use std::sync::{Arc, Weak}; - use tokio::io::{AsyncBufReadExt, BufReader}; - use tokio::process::{Child, Command}; - use tokio::sync::Mutex; - use tracing_subscriber::prelude::*; - - // A helper that allows to skip spans without events. - struct FilteredProcessor

(P); - - impl tracing_forest::Processor for FilteredProcessor

{ - fn process(&self, tree: tracing_forest::tree::Tree) -> tracing_forest::processor::Result { - fn is_used(tree: &tracing_forest::tree::Tree) -> bool { - match tree { - tracing_forest::tree::Tree::Span(span) => span.nodes().iter().any(is_used), - tracing_forest::tree::Tree::Event(_) => true, - } - } - - if is_used(&tree) { - self.0.process(tree) - } else { - Ok(()) - } - } - } - - fn target_icon(target: &str) -> Option { - let target_parts = target.split("::").collect::>(); - - Some(match target_parts.as_slice() { - ["discovery", ..] | [_, "discovery", ..] => '๐Ÿ”', - ["client", ..] => '๐Ÿ“ก', - ["server", ..] => '๐Ÿญ', - ["conformu" | "test_utils", ..] => '๐Ÿงช', - _ => return None, - }) - } - - fn target_tag(event: &tracing::Event<'_>) -> Option { - let target = event.metadata().target().strip_prefix("ascom_alpaca::")?; - - let mut builder = tracing_forest::Tag::builder() - .prefix(target) - .level(*event.metadata().level()); - - if let Some(icon) = target_icon(target) { - builder = builder.icon(icon); - } - - Some(builder.build()) - } - - #[ctor::ctor] - fn prepare_test_env() { - unsafe { - std::env::set_var("RUST_BACKTRACE", "full"); - } - - tracing_subscriber::registry() - .with( - tracing_subscriber::filter::Targets::new() - .with_target("ascom_alpaca", tracing::Level::INFO), - ) - .with(tracing_forest::ForestLayer::new( - FilteredProcessor(tracing_forest::printer::TestCapturePrinter::new()), - target_tag, - )) - .with(tracing_error::ErrorLayer::default()) - .init(); - - color_eyre::config::HookBuilder::default() - .add_frame_filter(Box::new(|frames| { - frames.retain(|frame| { - frame.filename.as_ref().map_or(false, |filename| { - // Only keep our own files in the backtrace to reduce noise. - filename.starts_with(env!("CARGO_MANIFEST_DIR")) - }) - }); - })) - .install() - .expect("Failed to install color_eyre"); - } - - #[derive(Debug, Clone, Copy)] - pub(crate) enum TestKind { - AlpacaProtocol, - Conformance, - } - - impl TestKind { - const fn as_arg(self) -> &'static str { - match self { - Self::AlpacaProtocol => "alpacaprotocol", - Self::Conformance => "conformance", - } - } - } - - pub(crate) struct TestEnv { - _server: Child, - devices: Devices, - } - - impl TestEnv { - async fn new() -> eyre::Result { - const ADDR: SocketAddr = addr!("127.0.0.1:32323"); - - let mut server = - Command::new(r"C:\Program Files\ASCOM\OmniSimulator\ascom.alpaca.simulators.exe") - .arg(format!("--urls=http://{ADDR}")) - .stdin(Stdio::null()) - .stdout(Stdio::inherit()) - .stderr(Stdio::inherit()) - .kill_on_drop(true) - .spawn()?; - - tokio::select! { - () = async { - while tokio::net::TcpStream::connect(ADDR).await.is_err() { - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - } - } => {} - server_exited = server.wait() => eyre::bail!("Simulator process exited early: {}", server_exited?), - () = tokio::time::sleep(std::time::Duration::from_secs(10)) => eyre::bail!("Simulator process didn't start in time") - } - - Ok(Self { - _server: server, - devices: Client::new_from_addr(ADDR).get_devices().await?.collect(), - }) - } - - // Get or create a shared instance of the test environment. - // While one is acquired, the simulators process is guaranteed to run in the background. - pub(crate) async fn acquire() -> eyre::Result> { - // Note: the static variable should only contain a Weak copy, otherwise the test environment - // would never be dropped, and we want it to be dropped at the end of the last strong copy - // (last running test). - static TEST_ENV: Mutex> = Mutex::const_new(Weak::new()); - - let mut lock = TEST_ENV.lock().await; - - Ok(match lock.upgrade() { - Some(env) => env, - None => { - let env = Arc::new(Self::new().await?); - *lock = Arc::downgrade(&env); - env - } - }) - } - - pub(crate) async fn run_test(ty: DeviceType, kind: TestKind) -> eyre::Result<()> { - let env = Self::acquire().await?; - - let proxy = Server { - devices: env.devices.clone(), - listen_addr: addr!("127.0.0.1:0"), - ..Default::default() - }; - - let proxy = proxy.bind().await?; - - // Get the IP and the random port assigned by the OS. - let listen_addr = proxy.listen_addr(); - - let proxy_task = proxy.start(); - - let device_url = format!( - "http://{listen_addr}/api/v1/{device_path}/0", - device_path = DevicePath(ty) - ); - - let tests_task = async { - let mut conformu = Command::new(r"C:\Program Files\ASCOM\ConformU\conformu.exe") - .arg(kind.as_arg()) - .arg(device_url) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .spawn()?; - - let output = conformu.stdout.take().expect("stdout should be piped"); - - let reader = BufReader::new(output); - let mut lines = reader.lines(); - - while let Some(line) = lines.next_line().await? { - // This is fragile, but ConformU doesn't provide structured output. - // Use known widths of the fields to parse them. - // https://github.com/ASCOMInitiative/ConformU/blob/cb32ac3d230e99636c639ccf4ac68dd3ae955c26/ConformU/AlpacaProtocolTestManager.cs - - // Skip .NET stacktraces. - if line.starts_with(" at ") { - continue; - } - - let line = match kind { - // skip date and time before doing any other checks - TestKind::Conformance => line.get(13..).unwrap_or(&line), - // In protocol tests, the date and time are not present - TestKind::AlpacaProtocol => &line, - } - .trim_ascii_end(); - - // Skip empty lines. - if line.is_empty() { - continue; - } - - if parse_log_line(line, kind).is_none() { - tracing::debug!("{line}"); - } - } - - let exit_status = conformu.wait().await?; - - eyre::ensure!( - exit_status.success(), - "ConformU exited with an error code: {exit_status}" - ); - - Ok(()) - }; - - tokio::select! { - proxy_result = proxy_task => match proxy_result? {}, - tests_result = tests_task => tests_result, - } - } - } - - fn split_with_whitespace<'line>(line: &mut &'line str, len: usize) -> Option<&'line str> { - if *line.as_bytes().get(len)? != b' ' { - return None; - } - let part = line[..len].trim_end_matches(' '); - *line = &line[len + 1..]; - Some(part) - } - - #[allow(clippy::cognitive_complexity)] - fn parse_log_line(mut line: &str, test_kind: TestKind) -> Option<()> { - let outcome; - - macro_rules! trace_outcome { - (@impl $args:tt) => { - match outcome { - "OK" => tracing::trace! $args, - "INFO" => tracing::info! $args, - "WARN" => tracing::warn! $args, - "DEBUG" | "" => tracing::debug! $args, - "ISSUE" | "ERROR" => tracing::error! $args, - _ => return None, - } - }; - - ($target:literal, $($args:tt)*) => { - trace_outcome!(@impl (target: concat!("ascom_alpaca::conformu::", $target), $($args)*, "{line}")) - }; - } - - match test_kind { - TestKind::AlpacaProtocol => { - let http_method = split_with_whitespace(&mut line, 3) - .filter(|&http_method| matches!(http_method, "GET" | "PUT" | ""))?; - - let method = split_with_whitespace(&mut line, 25)?; - - outcome = split_with_whitespace(&mut line, 6)?; - - let test; - - (test, line) = match line.split_once(" - ") { - Some((test, line)) => (Some(test), line), - None => (None, line), - }; - - trace_outcome!("alpaca", test, method, outcome, http_method); - } - - TestKind::Conformance => { - let method = - split_with_whitespace(&mut line, 35).filter(|&method| !method.is_empty())?; - - outcome = split_with_whitespace(&mut line, 8)?; - - trace_outcome!("conformance", method, outcome); - } - } - - Some(()) - } -} diff --git a/src/macros.rs b/src/macros.rs index 8397cb6..e91fd81 100644 --- a/src/macros.rs +++ b/src/macros.rs @@ -397,7 +397,7 @@ macro_rules! rpc_mod { #[cfg(test)] mod conformu { use super::DeviceType; - use $crate::test_utils::{TestEnv, TestKind}; + use $crate::test_utils::ConformU; $( #[cfg(feature = $path)] @@ -408,12 +408,12 @@ macro_rules! rpc_mod { #[tokio::test] async fn alpaca() -> eyre::Result<()> { - TestEnv::run_test(DeviceType::$trait_name, TestKind::AlpacaProtocol).await + ConformU::AlpacaProtocol.run_proxy_test(DeviceType::$trait_name).await } #[tokio::test] async fn conformance() -> eyre::Result<()> { - TestEnv::run_test(DeviceType::$trait_name, TestKind::Conformance).await + ConformU::Conformance.run_proxy_test(DeviceType::$trait_name).await } } )* diff --git a/src/test_utils/conformu.rs b/src/test_utils/conformu.rs new file mode 100644 index 0000000..92a4b7d --- /dev/null +++ b/src/test_utils/conformu.rs @@ -0,0 +1,136 @@ +use std::process::Stdio; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::process::Command; + +/// The kind of test to run with ConformU. +#[derive(Debug, Clone, Copy)] +pub enum ConformU { + /// Check the specified Alpaca device for Alpaca protocol conformance. + AlpacaProtocol, + + /// Check the specified device for ASCOM device interface conformance with all tests enabled. + Conformance, +} + +impl ConformU { + const fn as_arg(self) -> &'static str { + match self { + Self::AlpacaProtocol => "alpacaprotocol", + Self::Conformance => "conformance", + } + } + + /// Run the specified test with ConformU against the specified device URL. + pub async fn run(self, device_url: &str) -> eyre::Result<()> { + let mut conformu = Command::new(r"C:\Program Files\ASCOM\ConformU\conformu.exe") + .arg(self.as_arg()) + .arg(device_url) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .spawn()?; + + let output = conformu.stdout.take().expect("stdout should be piped"); + + let reader = BufReader::new(output); + let mut lines = reader.lines(); + + while let Some(line) = lines.next_line().await? { + // This is fragile, but ConformU doesn't provide structured output. + // Use known widths of the fields to parse them. + // https://github.com/ASCOMInitiative/ConformU/blob/cb32ac3d230e99636c639ccf4ac68dd3ae955c26/ConformU/AlpacaProtocolTestManager.cs + + // Skip .NET stacktraces. + if line.starts_with(" at ") { + continue; + } + + let line = match self { + // skip date and time before doing any other checks + Self::Conformance => line.get(13..).unwrap_or(&line), + // In protocol tests, the date and time are not present + Self::AlpacaProtocol => &line, + } + .trim_ascii_end(); + + // Skip empty lines. + if line.is_empty() { + continue; + } + + if self.parse_log_line(line).is_none() { + tracing::debug!("{line}"); + } + } + + let exit_status = conformu.wait().await?; + + eyre::ensure!( + exit_status.success(), + "ConformU exited with an error code: {exit_status}" + ); + + Ok(()) + } + + #[allow(clippy::cognitive_complexity)] + fn parse_log_line(self, mut line: &str) -> Option<()> { + let outcome; + + macro_rules! trace_outcome { + (@impl $args:tt) => { + match outcome { + "OK" => tracing::trace! $args, + "INFO" => tracing::info! $args, + "WARN" => tracing::warn! $args, + "DEBUG" | "" => tracing::debug! $args, + "ISSUE" | "ERROR" => tracing::error! $args, + _ => return None, + } + }; + + ($target:literal, $($args:tt)*) => { + trace_outcome!(@impl (target: concat!("ascom_alpaca::conformu::", $target), $($args)*, "{line}")) + }; + } + + match self { + Self::AlpacaProtocol => { + let http_method = split_with_whitespace(&mut line, 3) + .filter(|&http_method| matches!(http_method, "GET" | "PUT" | ""))?; + + let method = split_with_whitespace(&mut line, 25)?; + + outcome = split_with_whitespace(&mut line, 6)?; + + let test; + + (test, line) = match line.split_once(" - ") { + Some((test, line)) => (Some(test), line), + None => (None, line), + }; + + trace_outcome!("alpaca", test, method, outcome, http_method); + } + + Self::Conformance => { + let method = + split_with_whitespace(&mut line, 35).filter(|&method| !method.is_empty())?; + + outcome = split_with_whitespace(&mut line, 8)?; + + trace_outcome!("conformance", method, outcome); + } + } + + Some(()) + } +} + +fn split_with_whitespace<'line>(line: &mut &'line str, len: usize) -> Option<&'line str> { + if *line.as_bytes().get(len)? != b' ' { + return None; + } + let part = line[..len].trim_end_matches(' '); + *line = &line[len + 1..]; + Some(part) +} diff --git a/src/test_utils/logging_env.rs b/src/test_utils/logging_env.rs new file mode 100644 index 0000000..1751806 --- /dev/null +++ b/src/test_utils/logging_env.rs @@ -0,0 +1,78 @@ +use tracing_subscriber::prelude::*; + +// A helper that allows to skip spans without events. +struct FilteredProcessor

(P); + +impl tracing_forest::Processor for FilteredProcessor

{ + fn process(&self, tree: tracing_forest::tree::Tree) -> tracing_forest::processor::Result { + fn is_used(tree: &tracing_forest::tree::Tree) -> bool { + match tree { + tracing_forest::tree::Tree::Span(span) => span.nodes().iter().any(is_used), + tracing_forest::tree::Tree::Event(_) => true, + } + } + + if is_used(&tree) { + self.0.process(tree) + } else { + Ok(()) + } + } +} + +fn target_icon(target: &str) -> Option { + let target_parts = target.split("::").collect::>(); + + Some(match target_parts.as_slice() { + ["discovery", ..] | [_, "discovery", ..] => '๐Ÿ”', + ["client", ..] => '๐Ÿ“ก', + ["server", ..] => '๐Ÿญ', + ["conformu" | "test_utils", ..] => '๐Ÿงช', + _ => return None, + }) +} + +fn target_tag(event: &tracing::Event<'_>) -> Option { + let target = event.metadata().target().strip_prefix("ascom_alpaca::")?; + + let mut builder = tracing_forest::Tag::builder() + .prefix(target) + .level(*event.metadata().level()); + + if let Some(icon) = target_icon(target) { + builder = builder.icon(icon); + } + + Some(builder.build()) +} + +#[ctor::ctor] +fn prepare_test_env() { + unsafe { + std::env::set_var("RUST_BACKTRACE", "full"); + } + + tracing_subscriber::registry() + .with( + tracing_subscriber::filter::Targets::new() + .with_target("ascom_alpaca", tracing::Level::INFO), + ) + .with(tracing_forest::ForestLayer::new( + FilteredProcessor(tracing_forest::printer::TestCapturePrinter::new()), + target_tag, + )) + .with(tracing_error::ErrorLayer::default()) + .init(); + + color_eyre::config::HookBuilder::default() + .add_frame_filter(Box::new(|frames| { + frames.retain(|frame| { + frame.filename.as_ref().map_or(false, |filename| { + // Only keep our own files in the backtrace to reduce noise. + filename.starts_with(env!("CARGO_MANIFEST_DIR")) + }) + }); + })) + .install() + .expect("Failed to install color_eyre"); +} diff --git a/src/test_utils/mod.rs b/src/test_utils/mod.rs new file mode 100644 index 0000000..c4b6d7e --- /dev/null +++ b/src/test_utils/mod.rs @@ -0,0 +1,48 @@ +#[cfg(test)] +mod logging_env; + +#[cfg(feature = "server")] +mod conformu; +#[cfg(feature = "server")] +pub use conformu::ConformU; + +#[cfg(feature = "client")] +mod omnisim; +#[cfg(feature = "client")] +pub use omnisim::OmniSim; + +#[cfg(test)] +impl ConformU { + pub(crate) async fn run_proxy_test(self, ty: crate::api::DeviceType) -> eyre::Result<()> { + use crate::api::DevicePath; + use crate::Server; + use net_literals::addr; + + let env = OmniSim::acquire().await?; + + let proxy = Server { + devices: env.devices().clone(), + listen_addr: addr!("127.0.0.1:0"), + ..Default::default() + }; + + let proxy = proxy.bind().await?; + + // Get the IP and the random port assigned by the OS. + let listen_addr = proxy.listen_addr(); + + let proxy_task = proxy.start(); + + let device_url = format!( + "http://{listen_addr}/api/v1/{device_path}/0", + device_path = DevicePath(ty) + ); + + let tests_task = self.run(&device_url); + + tokio::select! { + proxy_result = proxy_task => match proxy_result? {}, + tests_result = tests_task => tests_result, + } + } +} diff --git a/src/test_utils/omnisim.rs b/src/test_utils/omnisim.rs new file mode 100644 index 0000000..8860bb7 --- /dev/null +++ b/src/test_utils/omnisim.rs @@ -0,0 +1,76 @@ +use crate::{Client, Devices}; +use net_literals::addr; +use std::net::SocketAddr; +use std::process::Stdio; +use std::sync::{Arc, Weak}; +use tokio::process::{Child, Command}; +use tokio::sync::Mutex; + +/// A helper that manages [ASCOM Alpaca Simulators](https://github.com/ASCOMInitiative/ASCOM.Alpaca.Simulators). +/// +/// Acquiring this helper via [`acquire`](`Self::acquire`) ensures that the simulators process is running in the background (either by launching it or reusing an existing instance). +/// This is helpful for client integration tests that require the simulators to be running. +/// +/// You can retrieve the device clients exposed by the simulators via the [`devices`](`Self::devices`) method. +#[derive(custom_debug::Debug)] +pub struct OmniSim { + #[debug(skip)] + _server: Child, + devices: Devices, +} + +impl OmniSim { + async fn new() -> eyre::Result { + const ADDR: SocketAddr = addr!("127.0.0.1:32323"); + + let mut server = + Command::new(r"C:\Program Files\ASCOM\OmniSimulator\ascom.alpaca.simulators.exe") + .arg(format!("--urls=http://{ADDR}")) + .stdin(Stdio::null()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .kill_on_drop(true) + .spawn()?; + + tokio::select! { + () = async { + while tokio::net::TcpStream::connect(ADDR).await.is_err() { + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + } => {} + server_exited = server.wait() => eyre::bail!("Simulator process exited early: {}", server_exited?), + () = tokio::time::sleep(std::time::Duration::from_secs(10)) => eyre::bail!("Simulator process didn't start in time") + } + + Ok(Self { + _server: server, + devices: Client::new_from_addr(ADDR).get_devices().await?.collect(), + }) + } + + /// Get or create a shared instance of the test environment. + /// + /// Note that the simulators process is stopped when the last instance of this helper is dropped - make sure to keep it alive for the duration of the tests. + pub async fn acquire() -> eyre::Result> { + // Note: the static variable should only contain a Weak copy, otherwise the test environment + // would never be dropped, and we want it to be dropped at the end of the last strong copy + // (last running test). + static TEST_ENV: Mutex> = Mutex::const_new(Weak::new()); + + let mut lock = TEST_ENV.lock().await; + + Ok(match lock.upgrade() { + Some(env) => env, + None => { + let env = Arc::new(Self::new().await?); + *lock = Arc::downgrade(&env); + env + } + }) + } + + /// Get the devices exposed by the simulators. + pub const fn devices(&self) -> &Devices { + &self.devices + } +}