Skip to main content

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;