reovim_testing/harness.rs
1//! Test server harness that spawns a reovim server process.
2//!
3//! This is the core **mechanism** for integration testing - it handles
4//! server lifecycle without any knowledge of what's being tested.
5//!
6//! # Log Capture (#428, #431)
7//!
8//! All spawned servers automatically capture stderr to per-test log files.
9//! Use `spawn()` for automatic test name extraction from thread name, or
10//! `spawn_with_name(name)` for explicit naming.
11//!
12//! # Debug Logging
13//!
14//! **DO NOT use `std::fs::OpenOptions` for debug logging in tests.**
15//!
16//! If you need debug output during test development, use the built-in log
17//! capture infrastructure (`spawn()` or `spawn_with_name()`). Writing directly
18//! to files with `OpenOptions` creates cleanup burdens and can leave debug
19//! artifacts in the codebase.
20//!
21//! Server logs are automatically captured to `tmp/test-logs/{test_name}_{timestamp}.log`.
22
23// Test infrastructure - suppress pedantic docs requirements
24#![allow(clippy::missing_errors_doc)]
25#![allow(clippy::missing_panics_doc)]
26
27use std::{
28 io::Write,
29 path::{Path, PathBuf},
30 process::Stdio,
31 sync::atomic::{AtomicU32, Ordering},
32 time::Duration,
33};
34
35use tokio::{
36 io::{AsyncBufReadExt, BufReader, Lines},
37 process::{Child, ChildStderr, Command},
38 task::JoinHandle,
39};
40
41/// Counter for unique test identifiers
42static TEST_COUNTER: AtomicU32 = AtomicU32::new(0);
43
44/// Server startup timeout
45const SERVER_STARTUP_TIMEOUT: Duration = Duration::from_secs(10);
46
47/// Default log directory for test logs
48const TEST_LOG_DIR: &str = "tmp/test-logs";
49
50/// Get path to the reovim binary.
51///
52/// Resolution order:
53/// 1. `REOVIM_TEST_BINARY` env var (explicit override)
54/// 2. Inferred from `std::env::current_exe()` — the test binary lives in
55/// `target/<target-dir>/debug/deps/`, so `../../reovim` gives the
56/// server binary. This works with any target directory including
57/// `cargo-llvm-cov`'s `target/llvm-cov-target/`.
58/// 3. Fallback to `{workspace}/target/debug/reovim` via `CARGO_MANIFEST_DIR`.
59#[cfg_attr(coverage_nightly, coverage(off))]
60fn binary_path() -> PathBuf {
61 // Check for override (useful for testing release builds)
62 if let Ok(path) = std::env::var("REOVIM_TEST_BINARY") {
63 return PathBuf::from(path);
64 }
65
66 // Infer from the running test binary's location.
67 // Test binaries live in `target/<dir>/debug/deps/test_name-hash`.
68 // The server binary is at `target/<dir>/debug/reovim`.
69 if let Ok(exe) = std::env::current_exe() {
70 let debug_dir = exe
71 .parent() // .../debug/deps/
72 .and_then(Path::parent); // .../debug/
73 if let Some(dir) = debug_dir {
74 let candidate = dir.join("reovim");
75 if candidate.exists() {
76 return candidate;
77 }
78 }
79 }
80
81 // Fallback: compile-time workspace root
82 let manifest_dir = env!("CARGO_MANIFEST_DIR");
83 PathBuf::from(manifest_dir)
84 .parent()
85 .expect("lib/testing should have parent")
86 .parent()
87 .expect("lib should have parent (workspace root)")
88 .join("target/debug/reovim")
89}
90
91/// Get the target/debug directory for module loading.
92///
93/// Uses the same target-dir detection as `binary_path()` so modules are
94/// loaded from the correct target directory (works under `cargo-llvm-cov`).
95#[cfg_attr(coverage_nightly, coverage(off))]
96fn workspace_module_dir() -> PathBuf {
97 if let Ok(exe) = std::env::current_exe() {
98 let debug_dir = exe
99 .parent() // .../debug/deps/
100 .and_then(Path::parent); // .../debug/
101 if let Some(dir) = debug_dir
102 && dir.exists()
103 {
104 return dir.to_path_buf();
105 }
106 }
107
108 // Fallback: compile-time workspace root
109 let manifest_dir = env!("CARGO_MANIFEST_DIR");
110 PathBuf::from(manifest_dir)
111 .parent()
112 .expect("lib/testing should have parent")
113 .parent()
114 .expect("lib should have parent (workspace root)")
115 .join("target/debug")
116}
117
118/// Read port from stderr and return the reader for continued use.
119///
120/// Returns the reader so logs can continue to be captured after port extraction.
121#[cfg_attr(coverage_nightly, coverage(off))]
122async fn read_port_preserving_reader(
123 stderr: ChildStderr,
124) -> Result<
125 (u16, Lines<BufReader<ChildStderr>>, Vec<String>),
126 Box<dyn std::error::Error + Send + Sync>,
127> {
128 let mut reader = BufReader::new(stderr).lines();
129 let mut early_lines = Vec::new();
130
131 while let Some(line) = reader.next_line().await? {
132 // Save all lines for the log file
133 early_lines.push(line.clone());
134
135 if line.starts_with("Warning:") {
136 continue;
137 }
138 if line.contains("!!!! PANIC !!!!") {
139 return Err(format!("Server panicked: {line}").into());
140 }
141 if let Some(rest) = line.strip_prefix("Listening on 127.0.0.1:") {
142 let port = rest.parse::<u16>()?;
143 return Ok((port, reader, early_lines));
144 }
145 }
146 Err("Server exited without outputting port".into())
147}
148
149/// Spawn a background task that writes stderr lines to a log file.
150#[cfg_attr(coverage_nightly, coverage(off))]
151fn spawn_log_capture_task(
152 mut reader: Lines<BufReader<ChildStderr>>,
153 log_path: PathBuf,
154 early_lines: Vec<String>,
155) -> JoinHandle<()> {
156 tokio::spawn(async move {
157 let Ok(mut file) = std::fs::File::create(&log_path) else {
158 eprintln!("Failed to create log file: {}", log_path.display());
159 return;
160 };
161
162 // Write header
163 let _ = writeln!(file, "=== Server Log Started ===");
164
165 // Write early lines (captured during port extraction)
166 for line in early_lines {
167 let _ = writeln!(file, "{line}");
168 }
169
170 // Continue reading and writing
171 while let Ok(Some(line)) = reader.next_line().await {
172 let _ = writeln!(file, "{line}");
173 }
174
175 let _ = writeln!(file, "=== Server Log Ended ===");
176 })
177}
178
179/// Test harness that spawns a server process.
180///
181/// Automatically cleans up the server process when dropped.
182///
183/// # Log Capture
184///
185/// All spawn methods capture server logs to `tmp/test-logs/{test_name}_{timestamp}.log`.
186/// - `spawn()` - Auto-extracts test name from thread (recommended)
187/// - `spawn_with_name(name)` - Explicit test name for custom naming
188pub struct TestServerHarness {
189 process: Child,
190 port: u16,
191 /// Path to the log file (if log capture is enabled).
192 log_file: Option<PathBuf>,
193 /// Background task capturing stderr (if log capture is enabled).
194 #[allow(dead_code)]
195 log_task: Option<JoinHandle<()>>,
196}
197
198#[cfg_attr(coverage_nightly, coverage(off))]
199impl TestServerHarness {
200 /// Spawn server with automatic log capture.
201 ///
202 /// Server stderr is captured to `tmp/test-logs/{test_name}_{timestamp}.log`.
203 /// Test name is auto-extracted from the current thread name (set by cargo test).
204 ///
205 /// For explicit test naming, use `spawn_with_name()`.
206 ///
207 /// # Errors
208 ///
209 /// Returns error if:
210 /// - Binary not found at expected path
211 /// - Server fails to start within timeout
212 /// - Server panics during startup
213 /// - Log directory cannot be created
214 pub async fn spawn() -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
215 let test_name = std::thread::current()
216 .name()
217 .unwrap_or("unknown_test")
218 .to_string();
219 Self::spawn_inner(&test_name, &[], &[]).await
220 }
221
222 /// Spawn server with explicit test name for log capture.
223 ///
224 /// Server stderr is captured to `tmp/test-logs/{test_name}_{timestamp}.log`.
225 /// This is invaluable for debugging test failures as it preserves the
226 /// server's tracing output including mode transitions, command executions,
227 /// and resolver calls.
228 ///
229 /// The server is started with `REOVIM_LOG=debug` by default for full
230 /// tracing visibility.
231 ///
232 /// Use this when you need a custom test name (e.g., multi-client tests
233 /// with a `_server` suffix).
234 ///
235 /// # Example
236 ///
237 /// ```ignore
238 /// let harness = TestServerHarness::spawn_with_name("test_multi_client_server").await?;
239 /// // ... run test ...
240 /// // On failure, check: tmp/test-logs/test_multi_client_server_20260124_120000.log
241 /// ```
242 ///
243 /// # Errors
244 ///
245 /// Returns error if:
246 /// - Binary not found at expected path
247 /// - Server fails to start within timeout
248 /// - Server panics during startup
249 /// - Log directory cannot be created
250 pub async fn spawn_with_name(
251 test_name: &str,
252 ) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
253 Self::spawn_inner(test_name, &[], &[]).await
254 }
255
256 /// Spawn server with extra modules loaded.
257 ///
258 /// Sets `REOVIM_EXTRA_MODULES` env var on the spawned server process.
259 /// Test name is auto-extracted from the current thread name.
260 ///
261 /// # Example
262 ///
263 /// ```ignore
264 /// let harness = TestServerHarness::spawn_with_modules(&["textobjects"]).await?;
265 /// ```
266 ///
267 /// # Errors
268 ///
269 /// Returns error if server fails to spawn or start.
270 pub async fn spawn_with_modules(
271 modules: &[&str],
272 ) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
273 let test_name = std::thread::current()
274 .name()
275 .unwrap_or("unknown_test")
276 .to_string();
277 Self::spawn_inner(&test_name, modules, &[]).await
278 }
279
280 /// Spawn server with extra modules and custom environment variables.
281 ///
282 /// # Errors
283 ///
284 /// Returns error if server fails to spawn or start.
285 pub async fn spawn_with_modules_and_env(
286 modules: &[&str],
287 env_vars: &[(&str, &str)],
288 ) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
289 let test_name = std::thread::current()
290 .name()
291 .unwrap_or("unknown_test")
292 .to_string();
293 Self::spawn_inner(&test_name, modules, env_vars).await
294 }
295
296 /// Spawn server with custom environment variables.
297 ///
298 /// Passes additional env vars to the server process. Useful for
299 /// overriding `XDG_DATA_HOME` to provide test fixture data.
300 ///
301 /// # Example
302 ///
303 /// ```ignore
304 /// let harness = TestServerHarness::spawn_with_env(
305 /// &[("XDG_DATA_HOME", "/tmp/test-fixtures")],
306 /// ).await?;
307 /// ```
308 ///
309 /// # Errors
310 ///
311 /// Returns error if server fails to spawn or start.
312 pub async fn spawn_with_env(
313 env_vars: &[(&str, &str)],
314 ) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
315 let test_name = std::thread::current()
316 .name()
317 .unwrap_or("unknown_test")
318 .to_string();
319 Self::spawn_inner(&test_name, &[], env_vars).await
320 }
321
322 /// Internal spawn implementation.
323 async fn spawn_inner(
324 test_name: &str,
325 extra_modules: &[&str],
326 env_vars: &[(&str, &str)],
327 ) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
328 // Create log directory
329 let log_dir = PathBuf::from(TEST_LOG_DIR);
330 std::fs::create_dir_all(&log_dir)?;
331
332 // Generate log file path with timestamp
333 let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S");
334 let log_file = log_dir.join(format!("{test_name}_{timestamp}.log"));
335
336 let _test_id = TEST_COUNTER.fetch_add(1, Ordering::SeqCst);
337
338 // Use debug level by default for test log capture
339 let log_level = std::env::var("REOVIM_LOG").unwrap_or_else(|_| "debug".to_string());
340
341 // Use worktree modules instead of globally installed ones (#433)
342 let module_dir = workspace_module_dir();
343
344 let mut cmd = Command::new(binary_path());
345 cmd.args(["server", "--grpc", "0"])
346 .env("REOVIM_LOG", &log_level)
347 .env("RUST_LOG", &log_level) // Enable tracing output for log capture
348 .env("REOVIM_MODULE_PATH", &module_dir)
349 .stdout(Stdio::null())
350 .stderr(Stdio::piped())
351 .kill_on_drop(true);
352
353 // Set extra modules if specified
354 if !extra_modules.is_empty() {
355 cmd.env("REOVIM_EXTRA_MODULES", extra_modules.join(","));
356 }
357
358 // Set custom environment variables
359 for (key, val) in env_vars {
360 cmd.env(key, val);
361 }
362
363 let mut process = cmd.spawn()?;
364
365 // Extract stderr for port reading and log capture
366 let stderr = process.stderr.take().ok_or("Failed to capture stderr")?;
367
368 // Read port while preserving the reader for continued log capture
369 let (port, reader, early_lines) =
370 tokio::time::timeout(SERVER_STARTUP_TIMEOUT, read_port_preserving_reader(stderr))
371 .await
372 .map_err(|_| "Server startup timed out (10s)")?
373 .map_err(|e| format!("Failed to read port: {e}"))?;
374
375 // Spawn background task to continue capturing logs
376 let log_task = spawn_log_capture_task(reader, log_file.clone(), early_lines);
377
378 Ok(Self {
379 process,
380 port,
381 log_file: Some(log_file),
382 log_task: Some(log_task),
383 })
384 }
385
386 /// Get the port the server is listening on.
387 #[must_use]
388 pub const fn port(&self) -> u16 {
389 self.port
390 }
391
392 /// Get the path to the log file.
393 ///
394 /// Returns the path to the log file where server stderr is captured.
395 /// All spawn methods enable log capture, so this always returns `Some`.
396 #[must_use]
397 pub fn log_path(&self) -> Option<&Path> {
398 self.log_file.as_deref()
399 }
400}
401
402#[cfg_attr(coverage_nightly, coverage(off))]
403impl Drop for TestServerHarness {
404 fn drop(&mut self) {
405 // Explicitly kill the server process to ensure cleanup.
406 // This is a belt-and-suspenders approach alongside kill_on_drop(true).
407 // We use start_kill() which is non-blocking, then try_wait() to reap.
408 let _ = self.process.start_kill();
409 let _ = self.process.try_wait();
410 }
411}
412
413#[cfg(test)]
414#[path = "harness_tests.rs"]
415mod tests;