Skip to main content

reovim_testing/
integration.rs

1//! Fluent test builder for single-client integration tests.
2//!
3//! Provides a builder pattern for setting up test scenarios,
4//! running key sequences, and asserting results.
5//!
6//! # Protocol
7//!
8//! This module uses **gRPC v2** for communication with the server.
9//! The legacy JSON-RPC v1 protocol is no longer supported.
10
11// Test infrastructure - suppress pedantic docs requirements
12#![allow(clippy::missing_errors_doc)]
13#![allow(clippy::missing_panics_doc)]
14
15use std::{
16    collections::HashMap,
17    io::Write,
18    sync::atomic::{AtomicU32, Ordering},
19    time::Duration,
20};
21
22use reovim_client_cli::GrpcClient;
23
24use super::harness::TestServerHarness;
25
26/// Connection retry settings
27const MAX_CONNECT_ATTEMPTS: u32 = 20;
28const RETRY_BASE_MS: u64 = 50;
29const RETRY_MAX_MS: u64 = 200;
30
31/// Default delay between key sequences (allows event processing)
32const DEFAULT_DELAY_MS: u64 = 50;
33
34/// Counter for unique temp file names
35static TEMP_FILE_COUNTER: AtomicU32 = AtomicU32::new(0);
36
37/// Key sequence with optional delay
38struct KeySequence {
39    keys: String,
40    delay_ms: u64,
41}
42
43/// Integration test builder.
44///
45/// # Example
46///
47/// ```ignore
48/// let result = IntegrationTest::new()
49///     .await
50///     .with_buffer("hello world")
51///     .send_keys("dw")
52///     .run()
53///     .await;
54/// result.assert_buffer_eq("world");
55/// ```
56pub struct IntegrationTest {
57    harness: TestServerHarness,
58    addr: String,
59    initial_content: Option<String>,
60    initial_cursor: Option<(u16, u16)>,
61    key_sequences: Vec<KeySequence>,
62    default_delay: u64,
63}
64
65#[cfg_attr(coverage_nightly, coverage(off))]
66impl IntegrationTest {
67    /// Create new test with automatic log capture (spawns server).
68    ///
69    /// Server logs are captured to `tmp/test-logs/{test_name}_{timestamp}.log`.
70    /// This is invaluable for debugging test failures.
71    ///
72    /// # Panics
73    ///
74    /// Panics if server fails to spawn.
75    pub async fn new() -> Self {
76        let harness = TestServerHarness::spawn()
77            .await
78            .expect("Failed to spawn server");
79        let addr = format!("127.0.0.1:{}", harness.port());
80        Self {
81            harness,
82            addr,
83            initial_content: None,
84            initial_cursor: None,
85            key_sequences: Vec::new(),
86            default_delay: DEFAULT_DELAY_MS,
87        }
88    }
89
90    /// Create new test with extra modules loaded.
91    ///
92    /// Spawns a server with the default modules plus the specified extra
93    /// modules. Use this for testing functionality that requires modules
94    /// not in the defaults bundle (e.g., textobjects).
95    ///
96    /// # Example
97    ///
98    /// ```ignore
99    /// let result = IntegrationTest::with_modules(&["textobjects"])
100    ///     .await
101    ///     .with_buffer("hello world")
102    ///     .send_keys("diw")
103    ///     .run()
104    ///     .await;
105    /// result.assert_buffer_eq(" world");
106    /// ```
107    ///
108    /// # Panics
109    ///
110    /// Panics if server fails to spawn.
111    pub async fn with_modules(modules: &[&str]) -> Self {
112        let harness = TestServerHarness::spawn_with_modules(modules)
113            .await
114            .expect("Failed to spawn server with extra modules");
115        let addr = format!("127.0.0.1:{}", harness.port());
116        Self {
117            harness,
118            addr,
119            initial_content: None,
120            initial_cursor: None,
121            key_sequences: Vec::new(),
122            default_delay: DEFAULT_DELAY_MS,
123        }
124    }
125
126    /// Create new test with custom environment variables.
127    ///
128    /// Passes additional env vars to the server process. Useful for
129    /// overriding `XDG_DATA_HOME` to provide test fixture data.
130    ///
131    /// # Panics
132    ///
133    /// Panics if server fails to spawn.
134    pub async fn with_env(env_vars: &[(&str, &str)]) -> Self {
135        let harness = TestServerHarness::spawn_with_env(env_vars)
136            .await
137            .expect("Failed to spawn server with env vars");
138        let addr = format!("127.0.0.1:{}", harness.port());
139        Self {
140            harness,
141            addr,
142            initial_content: None,
143            initial_cursor: None,
144            key_sequences: Vec::new(),
145            default_delay: DEFAULT_DELAY_MS,
146        }
147    }
148
149    /// Get the path to the server log file for debugging.
150    ///
151    /// Returns `None` if log capture is not enabled.
152    #[must_use]
153    pub fn log_path(&self) -> Option<&std::path::Path> {
154        self.harness.log_path()
155    }
156
157    /// Connect to server with retry logic using gRPC.
158    async fn connect_with_retry(&self) -> Result<GrpcClient, String> {
159        let mut attempts = 0;
160        loop {
161            match GrpcClient::connect(&self.addr).await {
162                Ok(c) => return Ok(c),
163                Err(_) if attempts < MAX_CONNECT_ATTEMPTS => {
164                    attempts += 1;
165                    let delay = std::cmp::min(
166                        RETRY_BASE_MS + u64::from(attempts) * RETRY_BASE_MS,
167                        RETRY_MAX_MS,
168                    );
169                    tokio::time::sleep(Duration::from_millis(delay)).await;
170                }
171                Err(e) => {
172                    return Err(format!(
173                        "Failed to connect after {MAX_CONNECT_ATTEMPTS} attempts: {e}"
174                    ));
175                }
176            }
177        }
178    }
179
180    /// Set initial buffer content.
181    #[must_use]
182    pub fn with_buffer(mut self, content: &str) -> Self {
183        self.initial_content = Some(content.to_string());
184        self
185    }
186
187    /// Load initial content from file.
188    ///
189    /// # Panics
190    ///
191    /// Panics if file cannot be read.
192    #[must_use]
193    pub fn with_file(mut self, path: &str) -> Self {
194        let content = std::fs::read_to_string(path)
195            .unwrap_or_else(|e| panic!("Failed to read file '{path}': {e}"));
196        self.initial_content = Some(content);
197        self
198    }
199
200    /// Set initial cursor position (line, col) - 0-indexed.
201    #[must_use]
202    #[allow(clippy::missing_const_for_fn)]
203    pub fn with_cursor_at(mut self, line: u16, col: u16) -> Self {
204        self.initial_cursor = Some((line, col));
205        self
206    }
207
208    /// Add key sequence to send.
209    #[must_use]
210    pub fn send_keys(mut self, keys: &str) -> Self {
211        self.key_sequences.push(KeySequence {
212            keys: keys.to_string(),
213            delay_ms: self.default_delay,
214        });
215        self
216    }
217
218    /// Add delay after last key sequence.
219    #[must_use]
220    pub fn with_delay(mut self, ms: u64) -> Self {
221        if let Some(last) = self.key_sequences.last_mut() {
222            last.delay_ms = ms;
223        }
224        self
225    }
226
227    /// Run test and return result.
228    ///
229    /// # Panics
230    ///
231    /// Panics if any gRPC call fails.
232    #[allow(clippy::too_many_lines)]
233    pub async fn run(self) -> TestResult {
234        // Create buffer via temp file
235        let temp_path = {
236            let id = TEMP_FILE_COUNTER.fetch_add(1, Ordering::SeqCst);
237            let path = format!("/tmp/reovim-test-{}-{id}.txt", std::process::id());
238            let content = self.initial_content.as_deref().unwrap_or("");
239            let mut file = std::fs::File::create(&path).expect("Failed to create temp file");
240            file.write_all(content.as_bytes())
241                .expect("Failed to write temp file");
242            path
243        };
244
245        // Connect and set up buffer
246        let mut client = self.connect_with_retry().await.expect("Failed to connect");
247
248        // Open buffer file using key sequence (since we don't have a direct file open API yet)
249        // TODO: Add file open API to gRPC services
250        client
251            .send_keys(&format!(":e {temp_path}<CR>"))
252            .await
253            .expect("Failed to open buffer file");
254        tokio::time::sleep(Duration::from_millis(50)).await;
255
256        // Set initial cursor if specified
257        if let Some((line, col)) = self.initial_cursor {
258            if line > 0 {
259                client
260                    .send_keys(&format!("{line}j"))
261                    .await
262                    .expect("Failed to move cursor down");
263            }
264            if col > 0 {
265                client
266                    .send_keys(&format!("{col}l"))
267                    .await
268                    .expect("Failed to move cursor right");
269            }
270        }
271
272        // Inject keys
273        for seq in &self.key_sequences {
274            client
275                .send_keys(&seq.keys)
276                .await
277                .expect("Failed to inject keys");
278            tokio::time::sleep(Duration::from_millis(seq.delay_ms)).await;
279        }
280
281        // Gather results using gRPC
282        let buffer_response = client
283            .get_buffer_content(None)
284            .await
285            .expect("Failed to get buffer content");
286        let cursor_response = client.get_cursor().await.expect("Failed to get cursor");
287        let mode_response = client.get_mode().await.expect("Failed to get mode");
288        let register_response = client
289            .get_registers(vec![])
290            .await
291            .expect("Failed to get registers");
292        drop(client); // Drop client early to avoid significant_drop_tightening warning
293
294        // Parse buffer content (lines joined with newlines)
295        let buffer_content = buffer_response.lines.join("\n");
296
297        // Extract cursor position from nested Position message
298        let (cursor_line, cursor_column) = cursor_response
299            .position
300            .map_or((0, 0), |pos| (pos.line, pos.column));
301
302        // Populate registers from gRPC response
303        let registers = register_response
304            .registers
305            .into_iter()
306            .map(|entry| {
307                (
308                    entry.name,
309                    RegisterInfo {
310                        content: entry.content,
311                        yank_type: entry.yank_type,
312                    },
313                )
314            })
315            .collect();
316
317        #[allow(clippy::cast_possible_truncation)]
318        TestResult {
319            buffer_content,
320            cursor_line: cursor_line as u16,
321            cursor_column: cursor_column as u16,
322            mode_display: mode_response.display,
323            edit_mode: mode_response.name,
324            registers,
325            harness: self.harness,
326            temp_path: Some(temp_path),
327        }
328    }
329}
330
331/// Register information from debug endpoint.
332#[derive(Debug, Clone)]
333pub struct RegisterInfo {
334    /// Register content.
335    pub content: String,
336    /// Yank type: "linewise" or "characterwise".
337    pub yank_type: String,
338}
339
340/// Test result with assertion methods.
341pub struct TestResult {
342    /// Buffer content after test.
343    pub buffer_content: String,
344    /// Cursor line (0-indexed).
345    pub cursor_line: u16,
346    /// Cursor column (0-indexed).
347    pub cursor_column: u16,
348    /// Mode display name (e.g., "NORMAL").
349    pub mode_display: String,
350    /// Edit mode string.
351    pub edit_mode: String,
352    /// Register contents.
353    pub registers: HashMap<String, RegisterInfo>,
354    harness: TestServerHarness,
355    temp_path: Option<String>,
356}
357
358#[cfg_attr(coverage_nightly, coverage(off))]
359impl Drop for TestResult {
360    fn drop(&mut self) {
361        if let Some(path) = &self.temp_path {
362            let _ = std::fs::remove_file(path);
363        }
364    }
365}
366
367#[cfg_attr(coverage_nightly, coverage(off))]
368impl TestResult {
369    /// Get the path to the server log file for debugging.
370    ///
371    /// Returns `None` if log capture is not enabled.
372    #[must_use]
373    pub fn log_path(&self) -> Option<&std::path::Path> {
374        self.harness.log_path()
375    }
376
377    /// Format log path hint for assertion messages.
378    fn log_hint(&self) -> String {
379        self.log_path()
380            .map(|p| format!("\n\nServer log: {}", p.display()))
381            .unwrap_or_default()
382    }
383
384    /// Assert buffer equals expected (trimmed).
385    pub fn assert_buffer_eq(&self, expected: &str) {
386        assert!(
387            self.buffer_content.trim_end() == expected.trim_end(),
388            "assertion `left == right` failed: Buffer content mismatch\n\
389             Expected:\n{}\n\
390             Actual:\n{}{}",
391            expected,
392            self.buffer_content,
393            self.log_hint()
394        );
395    }
396
397    /// Assert buffer contains substring.
398    pub fn assert_buffer_contains(&self, expected: &str) {
399        assert!(
400            self.buffer_content.contains(expected),
401            "Buffer does not contain '{}'\nActual:\n{}{}",
402            expected,
403            self.buffer_content,
404            self.log_hint()
405        );
406    }
407
408    /// Assert cursor position (line, col) - 0-indexed.
409    pub fn assert_cursor(&self, line: u16, col: u16) {
410        assert!(
411            (self.cursor_line, self.cursor_column) == (line, col),
412            "Cursor mismatch: expected (line={}, col={}), got (line={}, col={}){}",
413            line,
414            col,
415            self.cursor_line,
416            self.cursor_column,
417            self.log_hint()
418        );
419    }
420
421    /// Assert register content and type.
422    pub fn assert_register(&self, reg: &str, expected_content: &str, expected_type: &str) {
423        let register = self.registers.get(reg).unwrap_or_else(|| {
424            panic!(
425                "Register '{}' not found. Available: {:?}{}",
426                reg,
427                self.registers.keys().collect::<Vec<_>>(),
428                self.log_hint()
429            )
430        });
431        assert!(
432            register.content.trim_end() == expected_content.trim_end(),
433            "Register '{}' content mismatch\nExpected: '{}'\nActual: '{}'{}",
434            reg,
435            expected_content,
436            register.content,
437            self.log_hint()
438        );
439        assert!(
440            register.yank_type == expected_type,
441            "Register '{}' type mismatch\nExpected: '{}'\nActual: '{}'{}",
442            reg,
443            expected_type,
444            register.yank_type,
445            self.log_hint()
446        );
447    }
448
449    /// Assert in normal mode.
450    pub fn assert_normal_mode(&self) {
451        if !self.edit_mode.to_lowercase().contains("normal")
452            && !self.mode_display.to_uppercase().contains("NORMAL")
453        {
454            panic!(
455                "Expected normal mode, got: {} ({}){}",
456                self.mode_display,
457                self.edit_mode,
458                self.log_hint()
459            );
460        }
461    }
462
463    /// Assert in insert mode.
464    pub fn assert_insert_mode(&self) {
465        if !self.edit_mode.to_lowercase().contains("insert")
466            && !self.mode_display.to_uppercase().contains("INSERT")
467        {
468            panic!(
469                "Expected insert mode, got: {} ({}){}",
470                self.mode_display,
471                self.edit_mode,
472                self.log_hint()
473            );
474        }
475    }
476
477    /// Assert in visual mode.
478    pub fn assert_visual_mode(&self) {
479        if !self.edit_mode.to_lowercase().contains("visual")
480            && !self.mode_display.to_uppercase().contains("VISUAL")
481        {
482            panic!(
483                "Expected visual mode, got: {} ({}){}",
484                self.mode_display,
485                self.edit_mode,
486                self.log_hint()
487            );
488        }
489    }
490}
491
492// =========================================================================
493// #722 repro: registers were always empty — assert_register always
494// panicked with "Register not found".
495// Fixed: run() now calls client.get_registers(vec![]) via gRPC.
496// =========================================================================
497
498#[cfg(test)]
499mod b11_repro {
500    use super::*;
501
502    #[test]
503    fn b11_register_population_from_grpc() {
504        // Verify that RegisterInfo can be constructed from gRPC response data.
505        // The fix: run() now calls client.get_registers() and maps
506        // RegisterEntry -> RegisterInfo into the HashMap.
507        let mut registers = HashMap::new();
508        registers.insert(
509            "\"".to_string(),
510            RegisterInfo {
511                content: "hello\n".to_string(),
512                yank_type: "line".to_string(),
513            },
514        );
515
516        let info = registers.get("\"").expect("register should exist");
517        assert_eq!(info.content.trim_end(), "hello");
518        assert_eq!(info.yank_type, "line");
519        assert!(!registers.is_empty(), "#722 fixed: registers populated via gRPC");
520    }
521}