Skip to content

Commit

Permalink
Split out test utils
Browse files Browse the repository at this point in the history
  • Loading branch information
RReverser committed Sep 10, 2024
1 parent b64e2a5 commit 7025317
Show file tree
Hide file tree
Showing 10 changed files with 366 additions and 321 deletions.
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"all-devices",
"client",
"server",
"test",
],
"rust-analyzer.check.workspace": false
}
4 changes: 3 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"]
Expand Down
32 changes: 16 additions & 16 deletions benches/image_array.rs
Original file line number Diff line number Diff line change
@@ -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::<dyn Camera>()
.next()
.context("No camera found")?;
camera.set_connected(true).await?;
camera.start_exposure(0.001, true).await?;
Expand All @@ -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();
});
});
}

Expand Down
1 change: 1 addition & 0 deletions clippy.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
doc-valid-idents = ["ConformU", ".."]
305 changes: 4 additions & 301 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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>(P);

impl<P: tracing_forest::Processor> tracing_forest::Processor for FilteredProcessor<P> {
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<char> {
let target_parts = target.split("::").collect::<Vec<_>>();

Some(match target_parts.as_slice() {
["discovery", ..] | [_, "discovery", ..] => '🔍',
["client", ..] => '📡',
["server", ..] => '🏭',
["conformu" | "test_utils", ..] => '🧪',
_ => return None,
})
}

fn target_tag(event: &tracing::Event<'_>) -> Option<tracing_forest::Tag> {
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<Self> {
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<Arc<Self>> {
// 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<Weak<TestEnv>> = 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(())
}
}
Loading

0 comments on commit 7025317

Please sign in to comment.