#![allow(clippy::missing_errors_doc)]
#![allow(clippy::missing_panics_doc)]
use std::{
io::Write,
path::{Path, PathBuf},
process::Stdio,
sync::atomic::{AtomicU32, Ordering},
time::Duration,
};
use tokio::{
io::{AsyncBufReadExt, BufReader, Lines},
process::{Child, ChildStderr, Command},
task::JoinHandle,
};
static TEST_COUNTER: AtomicU32 = AtomicU32::new(0);
const SERVER_STARTUP_TIMEOUT: Duration = Duration::from_secs(10);
const TEST_LOG_DIR: &str = "tmp/test-logs";
#[cfg_attr(coverage_nightly, coverage(off))]
fn binary_path() -> PathBuf {
if let Ok(path) = std::env::var("REOVIM_TEST_BINARY") {
return PathBuf::from(path);
}
if let Ok(exe) = std::env::current_exe() {
let debug_dir = exe
.parent() .and_then(Path::parent); if let Some(dir) = debug_dir {
let candidate = dir.join("reovim");
if candidate.exists() {
return candidate;
}
}
}
let manifest_dir = env!("CARGO_MANIFEST_DIR");
PathBuf::from(manifest_dir)
.parent()
.expect("lib/testing should have parent")
.parent()
.expect("lib should have parent (workspace root)")
.join("target/debug/reovim")
}
#[cfg_attr(coverage_nightly, coverage(off))]
fn workspace_module_dir() -> PathBuf {
if let Ok(exe) = std::env::current_exe() {
let debug_dir = exe
.parent() .and_then(Path::parent); if let Some(dir) = debug_dir
&& dir.exists()
{
return dir.to_path_buf();
}
}
let manifest_dir = env!("CARGO_MANIFEST_DIR");
PathBuf::from(manifest_dir)
.parent()
.expect("lib/testing should have parent")
.parent()
.expect("lib should have parent (workspace root)")
.join("target/debug")
}
#[cfg_attr(coverage_nightly, coverage(off))]
async fn read_port_preserving_reader(
stderr: ChildStderr,
) -> Result<
(u16, Lines<BufReader<ChildStderr>>, Vec<String>),
Box<dyn std::error::Error + Send + Sync>,
> {
let mut reader = BufReader::new(stderr).lines();
let mut early_lines = Vec::new();
while let Some(line) = reader.next_line().await? {
early_lines.push(line.clone());
if line.starts_with("Warning:") {
continue;
}
if line.contains("!!!! PANIC !!!!") {
return Err(format!("Server panicked: {line}").into());
}
if let Some(rest) = line.strip_prefix("Listening on 127.0.0.1:") {
let port = rest.parse::<u16>()?;
return Ok((port, reader, early_lines));
}
}
Err("Server exited without outputting port".into())
}
#[cfg_attr(coverage_nightly, coverage(off))]
fn spawn_log_capture_task(
mut reader: Lines<BufReader<ChildStderr>>,
log_path: PathBuf,
early_lines: Vec<String>,
) -> JoinHandle<()> {
tokio::spawn(async move {
let Ok(mut file) = std::fs::File::create(&log_path) else {
eprintln!("Failed to create log file: {}", log_path.display());
return;
};
let _ = writeln!(file, "=== Server Log Started ===");
for line in early_lines {
let _ = writeln!(file, "{line}");
}
while let Ok(Some(line)) = reader.next_line().await {
let _ = writeln!(file, "{line}");
}
let _ = writeln!(file, "=== Server Log Ended ===");
})
}
pub struct TestServerHarness {
process: Child,
port: u16,
log_file: Option<PathBuf>,
#[allow(dead_code)]
log_task: Option<JoinHandle<()>>,
}
#[cfg_attr(coverage_nightly, coverage(off))]
impl TestServerHarness {
pub async fn spawn() -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
let test_name = std::thread::current()
.name()
.unwrap_or("unknown_test")
.to_string();
Self::spawn_inner(&test_name, &[], &[]).await
}
pub async fn spawn_with_name(
test_name: &str,
) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
Self::spawn_inner(test_name, &[], &[]).await
}
pub async fn spawn_with_modules(
modules: &[&str],
) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
let test_name = std::thread::current()
.name()
.unwrap_or("unknown_test")
.to_string();
Self::spawn_inner(&test_name, modules, &[]).await
}
pub async fn spawn_with_modules_and_env(
modules: &[&str],
env_vars: &[(&str, &str)],
) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
let test_name = std::thread::current()
.name()
.unwrap_or("unknown_test")
.to_string();
Self::spawn_inner(&test_name, modules, env_vars).await
}
pub async fn spawn_with_env(
env_vars: &[(&str, &str)],
) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
let test_name = std::thread::current()
.name()
.unwrap_or("unknown_test")
.to_string();
Self::spawn_inner(&test_name, &[], env_vars).await
}
async fn spawn_inner(
test_name: &str,
extra_modules: &[&str],
env_vars: &[(&str, &str)],
) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
let log_dir = PathBuf::from(TEST_LOG_DIR);
std::fs::create_dir_all(&log_dir)?;
let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S");
let log_file = log_dir.join(format!("{test_name}_{timestamp}.log"));
let _test_id = TEST_COUNTER.fetch_add(1, Ordering::SeqCst);
let log_level = std::env::var("REOVIM_LOG").unwrap_or_else(|_| "debug".to_string());
let module_dir = workspace_module_dir();
let mut cmd = Command::new(binary_path());
cmd.args(["server", "--grpc", "0"])
.env("REOVIM_LOG", &log_level)
.env("RUST_LOG", &log_level) .env("REOVIM_MODULE_PATH", &module_dir)
.stdout(Stdio::null())
.stderr(Stdio::piped())
.kill_on_drop(true);
if !extra_modules.is_empty() {
cmd.env("REOVIM_EXTRA_MODULES", extra_modules.join(","));
}
for (key, val) in env_vars {
cmd.env(key, val);
}
let mut process = cmd.spawn()?;
let stderr = process.stderr.take().ok_or("Failed to capture stderr")?;
let (port, reader, early_lines) =
tokio::time::timeout(SERVER_STARTUP_TIMEOUT, read_port_preserving_reader(stderr))
.await
.map_err(|_| "Server startup timed out (10s)")?
.map_err(|e| format!("Failed to read port: {e}"))?;
let log_task = spawn_log_capture_task(reader, log_file.clone(), early_lines);
Ok(Self {
process,
port,
log_file: Some(log_file),
log_task: Some(log_task),
})
}
#[must_use]
pub const fn port(&self) -> u16 {
self.port
}
#[must_use]
pub fn log_path(&self) -> Option<&Path> {
self.log_file.as_deref()
}
}
#[cfg_attr(coverage_nightly, coverage(off))]
impl Drop for TestServerHarness {
fn drop(&mut self) {
let _ = self.process.start_kill();
let _ = self.process.try_wait();
}
}
#[cfg(test)]
#[path = "harness_tests.rs"]
mod tests;