Skip to main content

reovim_testing/
presence.rs

1//! Presence testing utilities for multi-client E2E tests.
2//!
3//! Provides `PresenceTestClient` for presence-aware integration testing,
4//! wrapping the CLI's `GrpcClient` with convenient presence operations.
5//!
6//! # Protocol
7//!
8//! This module uses **gRPC v2** for communication with the server.
9//!
10//! # Example
11//!
12//! ```ignore
13//! use reovim_testing::{MultiClientPresenceTest, PresenceTestClient};
14//!
15//! MultiClientPresenceTest::with_clients(2)
16//!     .await
17//!     .run(|mut clients| async move {
18//!         let peers = clients[1].peers().await.unwrap();
19//!         assert_eq!(peers.len(), 1);
20//!         assert_eq!(peers[0].display_name, "client_0");
21//!     })
22//!     .await;
23//! ```
24
25// Test infrastructure - suppress pedantic docs requirements
26#![allow(clippy::missing_errors_doc)]
27#![allow(clippy::missing_panics_doc)]
28
29use std::time::Duration;
30
31use {reovim_client_cli::GrpcClient, reovim_protocol::v2::ClientPresence};
32
33use super::harness::TestServerHarness;
34
35/// Presence-aware test client wrapper.
36///
37/// Provides convenient methods for presence operations during E2E testing.
38/// Each client maintains its own gRPC connection and client ID.
39pub struct PresenceTestClient {
40    client: GrpcClient,
41    client_id: Option<u64>,
42    display_name: String,
43}
44
45#[cfg_attr(coverage_nightly, coverage(off))]
46impl PresenceTestClient {
47    /// Create a new presence test client (not yet joined).
48    ///
49    /// Call `join()` to register with the presence service.
50    pub fn new(client: GrpcClient, display_name: &str) -> Self {
51        Self {
52            client,
53            client_id: None,
54            display_name: display_name.to_string(),
55        }
56    }
57
58    /// Join the presence session.
59    ///
60    /// Registers this client with the server and receives an assigned client ID.
61    /// Returns the full join response including peer list.
62    pub async fn join(&mut self) -> Result<reovim_protocol::v2::JoinResponse, String> {
63        let response = self
64            .client
65            .presence_join("test", &self.display_name)
66            .await
67            .map_err(|e| format!("Join failed: {e}"))?;
68        self.client_id = Some(response.client_id);
69        Ok(response)
70    }
71
72    /// Leave the presence session.
73    ///
74    /// Unregisters this client from the server. Safe to call multiple times.
75    pub async fn leave(&mut self) -> Result<(), String> {
76        if self.client_id.take().is_some() {
77            // Identity resolved from session token (#483)
78            self.client
79                .presence_leave()
80                .await
81                .map_err(|e| format!("Leave failed: {e}"))?;
82        }
83        Ok(())
84    }
85
86    /// Get client ID (panics if not joined).
87    ///
88    /// # Panics
89    ///
90    /// Panics if `join()` has not been called or if the client has left.
91    #[must_use]
92    #[allow(clippy::missing_const_for_fn)] // expect() is not const
93    pub fn client_id(&self) -> u64 {
94        self.client_id
95            .expect("Client not joined - call join() first")
96    }
97
98    /// Check if this client has joined.
99    #[must_use]
100    pub const fn is_joined(&self) -> bool {
101        self.client_id.is_some()
102    }
103
104    /// Get this client's display name.
105    #[must_use]
106    pub fn display_name(&self) -> &str {
107        &self.display_name
108    }
109
110    /// Update cursor position.
111    ///
112    /// Note: This is now a no-op (Phase 14, #471).
113    /// Cursor tracking moved to `CursorMoved` notifications with `client_id`.
114    /// This method is preserved for test compatibility but does nothing.
115    #[allow(clippy::unused_async)] // API compatibility - async signature preserved
116    pub async fn update_cursor(&mut self, _line: u64, _col: u64) -> Result<(), String> {
117        // Cursor is no longer tracked via presence - it's tracked via CursorMoved notifications
118        Ok(())
119    }
120
121    /// Update buffer ID.
122    pub async fn update_buffer(&mut self, buffer_id: u64) -> Result<(), String> {
123        // Identity resolved from session token (#483)
124        self.client
125            .presence_update(Some(buffer_id), None)
126            .await
127            .map_err(|e| format!("Update buffer failed: {e}"))?;
128        Ok(())
129    }
130
131    /// Update mode.
132    pub async fn update_mode(&mut self, mode: &str) -> Result<(), String> {
133        // Identity resolved from session token (#483)
134        self.client
135            .presence_update(None, Some(mode.to_string()))
136            .await
137            .map_err(|e| format!("Update mode failed: {e}"))?;
138        Ok(())
139    }
140
141    /// Set follow mode (sync mode = 1).
142    pub async fn follow(&mut self, target_id: u64) -> Result<(), String> {
143        // Identity resolved from session token (#483)
144        self.client
145            .presence_set_sync_mode(1, Some(target_id))
146            .await
147            .map_err(|e| format!("Set follow mode failed: {e}"))?;
148        Ok(())
149    }
150
151    /// Set present mode (sync mode = 2).
152    pub async fn present(&mut self) -> Result<(), String> {
153        // Identity resolved from session token (#483)
154        self.client
155            .presence_set_sync_mode(2, None)
156            .await
157            .map_err(|e| format!("Set present mode failed: {e}"))?;
158        Ok(())
159    }
160
161    /// Set independent mode (sync mode = 0).
162    pub async fn independent(&mut self) -> Result<(), String> {
163        // Identity resolved from session token (#483)
164        self.client
165            .presence_set_sync_mode(0, None)
166            .await
167            .map_err(|e| format!("Set independent mode failed: {e}"))?;
168        Ok(())
169    }
170
171    /// Get all peers (excludes self).
172    pub async fn peers(&mut self) -> Result<Vec<ClientPresence>, String> {
173        let response = self
174            .client
175            .presence_list()
176            .await
177            .map_err(|e| format!("List peers failed: {e}"))?;
178        let my_id = self.client_id;
179        Ok(response
180            .clients
181            .into_iter()
182            .filter(|c| Some(c.client_id) != my_id)
183            .collect())
184    }
185
186    /// Get all clients including self.
187    pub async fn all_clients(&mut self) -> Result<Vec<ClientPresence>, String> {
188        let response = self
189            .client
190            .presence_list()
191            .await
192            .map_err(|e| format!("List clients failed: {e}"))?;
193        Ok(response.clients)
194    }
195
196    /// Access underlying gRPC client for other operations (e.g., `send_keys`).
197    #[allow(clippy::missing_const_for_fn)] // mutable reference prevents const
198    pub fn grpc(&mut self) -> &mut GrpcClient {
199        &mut self.client
200    }
201
202    // ─────────────────────────────────────────────────────────────────────────
203    // Per-client state (#471): Per-client state isolation helpers
204    // ─────────────────────────────────────────────────────────────────────────
205
206    /// Send keys using this client's ID for per-client mode routing.
207    ///
208    /// # Per-client state (#471): Per-client input routing
209    ///
210    /// Keys are processed using this client's per-client mode stack,
211    /// enabling multi-client mode isolation.
212    pub async fn send_keys(&mut self, keys: &str) -> Result<(), String> {
213        // Identity resolved from session token (#483)
214        self.client
215            .send_keys(keys)
216            .await
217            .map_err(|e| format!("Send keys failed: {e}"))?;
218        Ok(())
219    }
220
221    /// Get the current mode for this client.
222    ///
223    /// # Per-client state (#471): Per-client mode isolation
224    ///
225    /// Returns the mode from this client's per-client mode stack.
226    pub async fn get_mode(&mut self) -> Result<reovim_protocol::v2::GetModeResponse, String> {
227        let id = self.client_id();
228        self.client
229            .get_mode_for_client(id)
230            .await
231            .map_err(|e| format!("Get mode failed: {e}"))
232    }
233
234    /// Get cursor position for this client.
235    ///
236    /// # Per-client state (#471): Per-client cursor isolation
237    ///
238    /// Returns the cursor from this client's per-client editing state.
239    pub async fn get_cursor(&mut self) -> Result<(u64, u64), String> {
240        let id = self.client_id();
241        let response = self
242            .client
243            .get_cursor_for_client(id)
244            .await
245            .map_err(|e| format!("Get cursor failed: {e}"))?;
246        let pos = response.position.ok_or("No position in response")?;
247        Ok((pos.line, pos.column))
248    }
249
250    /// Get buffer content.
251    ///
252    /// Returns the content of the active buffer.
253    pub async fn get_buffer(&mut self) -> Result<String, String> {
254        let response = self
255            .client
256            .get_buffer_content(None)
257            .await
258            .map_err(|e| format!("Get buffer failed: {e}"))?;
259        Ok(response.lines.join("\n"))
260    }
261}
262
263/// Multi-client test with automatic presence join/leave.
264///
265/// Each client automatically joins on setup with display names `client_0`,
266/// `client_1`, etc. All clients are cleaned up on test completion.
267///
268/// # Example
269///
270/// ```ignore
271/// MultiClientPresenceTest::with_clients(2)
272///     .await
273///     .run(|mut clients| async move {
274///         // clients[0] and clients[1] are already joined
275///         let peers = clients[1].peers().await.unwrap();
276///         assert_eq!(peers.len(), 1);
277///     })
278///     .await;
279/// ```
280pub struct MultiClientPresenceTest {
281    harness: TestServerHarness,
282    client_count: usize,
283}
284
285#[cfg_attr(coverage_nightly, coverage(off))]
286impl MultiClientPresenceTest {
287    /// Create test with N presence-aware clients.
288    ///
289    /// Server logs are captured to `tmp/test-logs/{test_name}_presence_{timestamp}.log`.
290    ///
291    /// # Panics
292    ///
293    /// Panics if server fails to spawn.
294    pub async fn with_clients(n: usize) -> Self {
295        let test_name = std::thread::current()
296            .name()
297            .unwrap_or("unknown_presence_test")
298            .to_string();
299
300        Self {
301            harness: TestServerHarness::spawn_with_name(&format!("{test_name}_presence"))
302                .await
303                .expect("Failed to spawn server"),
304            client_count: n,
305        }
306    }
307
308    /// Get the path to the server log file for debugging.
309    #[must_use]
310    pub fn log_path(&self) -> Option<&std::path::Path> {
311        self.harness.log_path()
312    }
313
314    /// Get the server port.
315    #[must_use]
316    #[allow(clippy::missing_const_for_fn)] // harness.port() is not const
317    pub fn port(&self) -> u16 {
318        self.harness.port()
319    }
320
321    /// Run test with presence-aware clients.
322    ///
323    /// Each client is auto-joined with name `client_0`, `client_1`, etc.
324    /// All clients auto-leave when test completes.
325    #[allow(clippy::significant_drop_tightening)]
326    pub async fn run<F, Fut>(self, test_fn: F)
327    where
328        F: FnOnce(Vec<PresenceTestClient>) -> Fut,
329        Fut: std::future::Future<Output = ()>,
330    {
331        let addr = format!("127.0.0.1:{}", self.harness.port());
332        let mut clients = Vec::with_capacity(self.client_count);
333
334        // Create and join all clients
335        for i in 0..self.client_count {
336            let grpc = Self::connect_with_retry(&addr)
337                .await
338                .expect("Failed to connect");
339            let mut client = PresenceTestClient::new(grpc, &format!("client_{i}"));
340            client.join().await.expect("Failed to join presence");
341            // Small delay to ensure server processes the join
342            tokio::time::sleep(Duration::from_millis(5)).await;
343            clients.push(client);
344        }
345
346        // Run test
347        test_fn(clients).await;
348
349        // Cleanup is automatic when harness is dropped
350    }
351
352    /// Connect to server with retry logic.
353    async fn connect_with_retry(addr: &str) -> Result<GrpcClient, String> {
354        let mut attempts = 0;
355        loop {
356            match GrpcClient::connect(addr).await {
357                Ok(c) => return Ok(c),
358                Err(_) if attempts < 20 => {
359                    attempts += 1;
360                    tokio::time::sleep(Duration::from_millis(50)).await;
361                }
362                Err(e) => return Err(format!("Failed to connect after {attempts} attempts: {e}")),
363            }
364        }
365    }
366}