1#![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
26const MAX_CONNECT_ATTEMPTS: u32 = 20;
28const RETRY_BASE_MS: u64 = 50;
29const RETRY_MAX_MS: u64 = 200;
30
31const DEFAULT_DELAY_MS: u64 = 50;
33
34static TEMP_FILE_COUNTER: AtomicU32 = AtomicU32::new(0);
36
37struct KeySequence {
39 keys: String,
40 delay_ms: u64,
41}
42
43pub 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 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 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 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 #[must_use]
153 pub fn log_path(&self) -> Option<&std::path::Path> {
154 self.harness.log_path()
155 }
156
157 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 #[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 #[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 #[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 #[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 #[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 #[allow(clippy::too_many_lines)]
233 pub async fn run(self) -> TestResult {
234 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 let mut client = self.connect_with_retry().await.expect("Failed to connect");
247
248 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 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 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 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); let buffer_content = buffer_response.lines.join("\n");
296
297 let (cursor_line, cursor_column) = cursor_response
299 .position
300 .map_or((0, 0), |pos| (pos.line, pos.column));
301
302 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#[derive(Debug, Clone)]
333pub struct RegisterInfo {
334 pub content: String,
336 pub yank_type: String,
338}
339
340pub struct TestResult {
342 pub buffer_content: String,
344 pub cursor_line: u16,
346 pub cursor_column: u16,
348 pub mode_display: String,
350 pub edit_mode: String,
352 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 #[must_use]
373 pub fn log_path(&self) -> Option<&std::path::Path> {
374 self.harness.log_path()
375 }
376
377 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 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 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 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 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 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 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 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#[cfg(test)]
499mod b11_repro {
500 use super::*;
501
502 #[test]
503 fn b11_register_population_from_grpc() {
504 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}