186 lines
6.3 KiB
Rust
186 lines
6.3 KiB
Rust
//! Shared helpers for the integration tests.
|
|||
|
|
//!
|
||
|
|
//! Every test uses real iroh endpoints on loopback, its own temporary SQLite
|
||
|
|
//! files and independent agent instances. Only discovery is substituted; iroh,
|
||
|
|
//! the handshake, message passing and persistent storage are not.
|
||
|
|
//!
|
||
|
|
//! Synchronisation is always "wait for a specific event or condition, under one
|
||
|
|
//! overall deadline", never a fixed multi-second sleep.
|
||
|
|
|
||
|
|
#![allow(dead_code, clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||
|
|
|
||
|
|
use std::future::Future;
|
||
|
|
use std::path::Path;
|
||
|
|
use std::sync::Arc;
|
||
|
|
use std::time::{Duration, Instant};
|
||
|
|
|
||
|
|
use tempfile::TempDir;
|
||
|
|
use tokio::sync::broadcast::error::RecvError;
|
||
|
|
use tsunagi::agent::Event;
|
||
|
|
use tsunagi::config::{AgentConfig, StoragePaths, TransportPolicy};
|
||
|
|
use tsunagi::discovery::SharedMemoryDiscovery;
|
||
|
|
use tsunagi::identity::{NetworkName, NetworkSecret};
|
||
|
|
use tsunagi::{Agent, Result};
|
||
|
|
|
||
|
|
/// Installs a tracing subscriber when `TSUNAGI_TEST_LOG` is set.
|
||
|
|
///
|
||
|
|
/// The library never installs a global subscriber itself; tests opt in.
|
||
|
|
pub fn init_tracing() {
|
||
|
|
use std::sync::Once;
|
||
|
|
static ONCE: Once = Once::new();
|
||
|
|
ONCE.call_once(|| {
|
||
|
|
if let Ok(filter) = std::env::var("TSUNAGI_TEST_LOG") {
|
||
|
|
let _ = tracing_subscriber::fmt()
|
||
|
|
.with_env_filter(tracing_subscriber::EnvFilter::new(filter))
|
||
|
|
.with_writer(std::io::stderr)
|
||
|
|
.try_init();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Overall deadline for anything a test waits on.
|
||
|
|
pub const DEADLINE: Duration = Duration::from_secs(30);
|
||
|
|
|
||
|
|
/// How often condition polling re-checks. Never used as the primary sync.
|
||
|
|
const POLL_INTERVAL: Duration = Duration::from_millis(25);
|
||
|
|
|
||
|
|
/// Builds a fully local configuration rooted at `dir`.
|
||
|
|
///
|
||
|
|
/// Loopback binding with a dynamic port, no relay, no address lookup and no
|
||
|
|
/// port mapping, so the suite needs neither the internet nor privileges.
|
||
|
|
/// Timeouts are shortened so that failure paths finish quickly.
|
||
|
|
pub fn local_config(dir: &Path) -> AgentConfig {
|
||
|
|
let limits = tsunagi::Limits {
|
||
|
|
dial_timeout: Duration::from_millis(1500),
|
||
|
|
handshake_timeout: Duration::from_secs(5),
|
||
|
|
..Default::default()
|
||
|
|
};
|
||
|
|
AgentConfig::new(StoragePaths::under(dir))
|
||
|
|
.with_transport(TransportPolicy::LocalOnly)
|
||
|
|
.with_loopback_bind()
|
||
|
|
.with_discovery_interval(Duration::from_millis(150))
|
||
|
|
.with_limits(limits)
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Builds a local configuration wired to a shared in-memory discovery table.
|
||
|
|
pub fn config_with(dir: &Path, discovery: &SharedMemoryDiscovery) -> AgentConfig {
|
||
|
|
local_config(dir).with_discovery(Arc::new(discovery.clone()))
|
||
|
|
}
|
||
|
|
|
||
|
|
/// A temporary directory plus the agent running on it.
|
||
|
|
pub struct TestAgent {
|
||
|
|
pub dir: TempDir,
|
||
|
|
pub agent: Agent,
|
||
|
|
}
|
||
|
|
|
||
|
|
impl TestAgent {
|
||
|
|
/// Starts an agent on a fresh temporary directory.
|
||
|
|
pub async fn spawn(discovery: &SharedMemoryDiscovery) -> Result<Self> {
|
||
|
|
init_tracing();
|
||
|
|
let dir = TempDir::new().expect("temp dir");
|
||
|
|
let agent = Agent::spawn(config_with(dir.path(), discovery)).await?;
|
||
|
|
Ok(Self { dir, agent })
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Starts an agent with a caller-supplied configuration on a fresh dir.
|
||
|
|
pub async fn spawn_with(
|
||
|
|
build: impl FnOnce(AgentConfig) -> AgentConfig,
|
||
|
|
discovery: &SharedMemoryDiscovery,
|
||
|
|
) -> Result<Self> {
|
||
|
|
init_tracing();
|
||
|
|
let dir = TempDir::new().expect("temp dir");
|
||
|
|
let agent = Agent::spawn(build(config_with(dir.path(), discovery))).await?;
|
||
|
|
Ok(Self { dir, agent })
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Stops the agent and returns the directory so it can be reopened.
|
||
|
|
pub async fn stop(self) -> TempDir {
|
||
|
|
self.agent.shutdown().await;
|
||
|
|
self.dir
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// A network name and a fresh high-entropy secret.
|
||
|
|
pub fn network(name: &str) -> (NetworkName, NetworkSecret) {
|
||
|
|
(
|
||
|
|
NetworkName::new(name).expect("valid network name"),
|
||
|
|
NetworkSecret::generate(),
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Waits for an event matching `predicate`, under the global deadline.
|
||
|
|
pub async fn wait_event<T>(
|
||
|
|
rx: &mut tokio::sync::broadcast::Receiver<Event>,
|
||
|
|
predicate: impl Fn(&Event) -> Option<T>,
|
||
|
|
) -> T {
|
||
|
|
let deadline = Instant::now() + DEADLINE;
|
||
|
|
let mut seen: Vec<String> = Vec::new();
|
||
|
|
loop {
|
||
|
|
let remaining = deadline
|
||
|
|
.checked_duration_since(Instant::now())
|
||
|
|
.unwrap_or_default();
|
||
|
|
assert!(!remaining.is_zero(), "timed out waiting for an event");
|
||
|
|
|
||
|
|
match tokio::time::timeout(remaining, rx.recv()).await {
|
||
|
|
Ok(Ok(event)) => {
|
||
|
|
if let Some(value) = predicate(&event) {
|
||
|
|
return value;
|
||
|
|
}
|
||
|
|
if seen.len() < 12 {
|
||
|
|
let label = format!("{event:?}");
|
||
|
|
seen.push(label.chars().take(70).collect());
|
||
|
|
}
|
||
|
|
}
|
||
|
|
Ok(Err(RecvError::Lagged(skipped))) => {
|
||
|
|
panic!("event subscriber lagged, missed {skipped} events; first seen: {seen:#?}");
|
||
|
|
}
|
||
|
|
Ok(Err(RecvError::Closed)) => panic!("event channel closed while waiting"),
|
||
|
|
Err(_) => panic!("timed out waiting for an event"),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Polls an async condition until it returns `Some`, under the global deadline.
|
||
|
|
pub async fn wait_until<T, F, Fut>(what: &str, mut probe: F) -> T
|
||
|
|
where
|
||
|
|
F: FnMut() -> Fut,
|
||
|
|
Fut: Future<Output = Option<T>>,
|
||
|
|
{
|
||
|
|
let deadline = Instant::now() + DEADLINE;
|
||
|
|
loop {
|
||
|
|
if let Some(value) = probe().await {
|
||
|
|
return value;
|
||
|
|
}
|
||
|
|
assert!(
|
||
|
|
Instant::now() < deadline,
|
||
|
|
"timed out waiting for condition: {what}"
|
||
|
|
);
|
||
|
|
tokio::time::sleep(POLL_INTERVAL).await;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Lets a few discovery rounds pass.
|
||
|
|
///
|
||
|
|
/// Only ever used before asserting that something did **not** happen; waiting
|
||
|
|
/// for success always goes through [`wait_event`] or [`wait_until`].
|
||
|
|
pub async fn settle() {
|
||
|
|
tokio::time::sleep(Duration::from_millis(900)).await;
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Waits until `agent` has `count` authenticated peers in `network`.
|
||
|
|
pub async fn wait_for_peers(
|
||
|
|
agent: &Agent,
|
||
|
|
network: tsunagi::NetworkId,
|
||
|
|
count: usize,
|
||
|
|
) -> Vec<iroh::EndpointId> {
|
||
|
|
wait_until(&format!("{count} peers in {network}"), || async move {
|
||
|
|
let status = agent.network_status(network).await.ok()?;
|
||
|
|
if status.peers.len() >= count {
|
||
|
|
Some(status.connected_peers())
|
||
|
|
} else {
|
||
|
|
None
|
||
|
|
}
|
||
|
|
})
|
||
|
|
.await
|
||
|
|
}
|