Skip to main content

reovim_testing/
multi_client.rs

1//! Multi-client test utilities for concurrent client testing.
2//!
3//! Tests scenarios where multiple clients connect to the same server,
4//! such as collaborative editing and state synchronization.
5//!
6//! # Protocol
7//!
8//! This module uses **gRPC v2** for communication with the server.
9
10// Test infrastructure - suppress pedantic docs requirements
11#![allow(clippy::missing_errors_doc)]
12#![allow(clippy::missing_panics_doc)]
13
14use std::{
15    io::Write,
16    sync::atomic::{AtomicU32, Ordering},
17    time::Duration,
18};
19
20use reovim_client_cli::GrpcClient;
21
22use super::harness::TestServerHarness;
23
24/// Counter for unique temp file names
25static MULTI_CLIENT_FILE_COUNTER: AtomicU32 = AtomicU32::new(0);
26
27/// Client wrapper for multi-client tests.
28///
29/// Each method uses a persistent gRPC connection.
30pub struct TestClient {
31    addr: String,
32    id: usize,
33    client: Option<GrpcClient>,
34}
35
36#[cfg_attr(coverage_nightly, coverage(off))]
37impl TestClient {
38    /// Get or create a gRPC connection.
39    async fn get_client(&mut self) -> Result<&mut GrpcClient, String> {
40        if self.client.is_none() {
41            self.client = Some(Self::connect_with_retry(&self.addr).await?);
42        }
43        Ok(self.client.as_mut().unwrap())
44    }
45
46    /// Connect to server with retry logic.
47    async fn connect_with_retry(addr: &str) -> Result<GrpcClient, String> {
48        let mut attempts = 0;
49        loop {
50            match GrpcClient::connect(addr).await {
51                Ok(c) => return Ok(c),
52                Err(_) if attempts < 20 => {
53                    attempts += 1;
54                    tokio::time::sleep(Duration::from_millis(50)).await;
55                }
56                Err(e) => return Err(format!("Failed to connect: {e}")),
57            }
58        }
59    }
60
61    /// Send keys to the server.
62    #[allow(clippy::significant_drop_tightening)]
63    pub async fn send_keys(&mut self, keys: &str) -> Result<(), String> {
64        let client = self.get_client().await?;
65        client.send_keys(keys).await.map_err(|e| e.to_string())?;
66        Ok(())
67    }
68
69    /// Get cursor position (line, col).
70    #[allow(clippy::cast_possible_truncation, clippy::significant_drop_tightening)]
71    pub async fn get_cursor(&mut self) -> Result<(u16, u16), String> {
72        let client = self.get_client().await?;
73        let result = client.get_cursor().await.map_err(|e| e.to_string())?;
74        let (line, col) = result.position.map_or((0, 0), |pos| (pos.line, pos.column));
75        Ok((line as u16, col as u16))
76    }
77
78    /// Get buffer content.
79    #[allow(clippy::significant_drop_tightening)]
80    pub async fn get_buffer(&mut self) -> Result<String, String> {
81        let client = self.get_client().await?;
82        let result = client
83            .get_buffer_content(None)
84            .await
85            .map_err(|e| e.to_string())?;
86        Ok(result.lines.join("\n"))
87    }
88
89    /// Open a new buffer with optional content.
90    #[allow(clippy::significant_drop_tightening)]
91    pub async fn open_buffer(&mut self, content: &str) -> Result<(), String> {
92        let path = format!("/tmp/reovim-multiclient-{}-{}.txt", std::process::id(), self.id);
93        let mut file = std::fs::File::create(&path).map_err(|e| e.to_string())?;
94        file.write_all(content.as_bytes())
95            .map_err(|e| e.to_string())?;
96
97        let client = self.get_client().await?;
98        client
99            .send_keys(&format!(":e {path}<CR>"))
100            .await
101            .map_err(|e| e.to_string())?;
102
103        Ok(())
104    }
105
106    /// Get client ID.
107    #[must_use]
108    pub const fn id(&self) -> usize {
109        self.id
110    }
111}
112
113/// Multi-client test builder.
114///
115/// # Example
116///
117/// ```ignore
118/// MultiClientTest::with_clients(2)
119///     .await
120///     .with_buffer("initial")
121///     .run(|mut clients| async move {
122///         clients[0].send_keys("ihello<Esc>").await.unwrap();
123///         let content = clients[1].get_buffer().await.unwrap();
124///         assert!(content.contains("hello"));
125///     })
126///     .await;
127/// ```
128pub struct MultiClientTest {
129    harness: TestServerHarness,
130    client_count: usize,
131    initial_content: Option<String>,
132}
133
134#[cfg_attr(coverage_nightly, coverage(off))]
135impl MultiClientTest {
136    /// Create test with N clients.
137    ///
138    /// Server logs are captured to `tmp/test-logs/{test_name}_server_{timestamp}.log`.
139    ///
140    /// # Panics
141    ///
142    /// Panics if server fails to spawn.
143    pub async fn with_clients(n: usize) -> Self {
144        // Extract test name with suffix for unique log files
145        let test_name = std::thread::current()
146            .name()
147            .unwrap_or("unknown_multi_test")
148            .to_string();
149
150        Self {
151            harness: TestServerHarness::spawn_with_name(&format!("{test_name}_server"))
152                .await
153                .expect("Failed to spawn server"),
154            client_count: n,
155            initial_content: None,
156        }
157    }
158
159    /// Get the path to the server log file for debugging.
160    #[must_use]
161    pub fn log_path(&self) -> Option<&std::path::Path> {
162        self.harness.log_path()
163    }
164
165    /// Set initial buffer content (shared by all clients).
166    #[must_use]
167    pub fn with_buffer(mut self, content: &str) -> Self {
168        self.initial_content = Some(content.to_string());
169        self
170    }
171
172    /// Connect all clients and run test.
173    #[allow(clippy::significant_drop_tightening)]
174    pub async fn run<F, Fut>(self, test_fn: F)
175    where
176        F: FnOnce(Vec<TestClient>) -> Fut,
177        Fut: std::future::Future<Output = ()>,
178    {
179        let addr = format!("127.0.0.1:{}", self.harness.port());
180
181        // Create test clients
182        let mut clients = Vec::with_capacity(self.client_count);
183        for id in 0..self.client_count {
184            clients.push(TestClient {
185                addr: addr.clone(),
186                id,
187                client: None,
188            });
189        }
190
191        // Verify connectivity
192        for client in &mut clients {
193            assert!(
194                TestClient::connect_with_retry(&client.addr).await.is_ok(),
195                "Client {} failed to connect",
196                client.id
197            );
198        }
199
200        // Set up initial buffer via first client
201        if self.initial_content.is_some() || !clients.is_empty() {
202            let temp_path = {
203                let id = MULTI_CLIENT_FILE_COUNTER.fetch_add(1, Ordering::SeqCst);
204                let path = format!("/tmp/reovim-multi-test-{}-{id}.txt", std::process::id());
205                let content = self.initial_content.as_deref().unwrap_or("");
206                let mut file = std::fs::File::create(&path).expect("Failed to create temp file");
207                file.write_all(content.as_bytes())
208                    .expect("Failed to write temp file");
209                path
210            };
211            clients[0]
212                .send_keys(&format!(":e {temp_path}<CR>"))
213                .await
214                .expect("Failed to open buffer file");
215            tokio::time::sleep(Duration::from_millis(10)).await;
216        }
217
218        test_fn(clients).await;
219        // harness dropped here, server cleaned up
220    }
221}