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}