#![allow(clippy::missing_errors_doc)]
#![allow(clippy::missing_panics_doc)]
use std::{
collections::HashMap,
io::Write,
sync::atomic::{AtomicU32, Ordering},
time::Duration,
};
use reovim_client_cli::GrpcClient;
use super::harness::TestServerHarness;
const MAX_CONNECT_ATTEMPTS: u32 = 20;
const RETRY_BASE_MS: u64 = 50;
const RETRY_MAX_MS: u64 = 200;
const DEFAULT_DELAY_MS: u64 = 50;
static TEMP_FILE_COUNTER: AtomicU32 = AtomicU32::new(0);
struct KeySequence {
keys: String,
delay_ms: u64,
}
pub struct IntegrationTest {
harness: TestServerHarness,
addr: String,
initial_content: Option<String>,
initial_cursor: Option<(u16, u16)>,
key_sequences: Vec<KeySequence>,
default_delay: u64,
}
#[cfg_attr(coverage_nightly, coverage(off))]
impl IntegrationTest {
pub async fn new() -> Self {
let harness = TestServerHarness::spawn()
.await
.expect("Failed to spawn server");
let addr = format!("127.0.0.1:{}", harness.port());
Self {
harness,
addr,
initial_content: None,
initial_cursor: None,
key_sequences: Vec::new(),
default_delay: DEFAULT_DELAY_MS,
}
}
pub async fn with_modules(modules: &[&str]) -> Self {
let harness = TestServerHarness::spawn_with_modules(modules)
.await
.expect("Failed to spawn server with extra modules");
let addr = format!("127.0.0.1:{}", harness.port());
Self {
harness,
addr,
initial_content: None,
initial_cursor: None,
key_sequences: Vec::new(),
default_delay: DEFAULT_DELAY_MS,
}
}
pub async fn with_env(env_vars: &[(&str, &str)]) -> Self {
let harness = TestServerHarness::spawn_with_env(env_vars)
.await
.expect("Failed to spawn server with env vars");
let addr = format!("127.0.0.1:{}", harness.port());
Self {
harness,
addr,
initial_content: None,
initial_cursor: None,
key_sequences: Vec::new(),
default_delay: DEFAULT_DELAY_MS,
}
}
#[must_use]
pub fn log_path(&self) -> Option<&std::path::Path> {
self.harness.log_path()
}
async fn connect_with_retry(&self) -> Result<GrpcClient, String> {
let mut attempts = 0;
loop {
match GrpcClient::connect(&self.addr).await {
Ok(c) => return Ok(c),
Err(_) if attempts < MAX_CONNECT_ATTEMPTS => {
attempts += 1;
let delay = std::cmp::min(
RETRY_BASE_MS + u64::from(attempts) * RETRY_BASE_MS,
RETRY_MAX_MS,
);
tokio::time::sleep(Duration::from_millis(delay)).await;
}
Err(e) => {
return Err(format!(
"Failed to connect after {MAX_CONNECT_ATTEMPTS} attempts: {e}"
));
}
}
}
}
#[must_use]
pub fn with_buffer(mut self, content: &str) -> Self {
self.initial_content = Some(content.to_string());
self
}
#[must_use]
pub fn with_file(mut self, path: &str) -> Self {
let content = std::fs::read_to_string(path)
.unwrap_or_else(|e| panic!("Failed to read file '{path}': {e}"));
self.initial_content = Some(content);
self
}
#[must_use]
#[allow(clippy::missing_const_for_fn)]
pub fn with_cursor_at(mut self, line: u16, col: u16) -> Self {
self.initial_cursor = Some((line, col));
self
}
#[must_use]
pub fn send_keys(mut self, keys: &str) -> Self {
self.key_sequences.push(KeySequence {
keys: keys.to_string(),
delay_ms: self.default_delay,
});
self
}
#[must_use]
pub fn with_delay(mut self, ms: u64) -> Self {
if let Some(last) = self.key_sequences.last_mut() {
last.delay_ms = ms;
}
self
}
#[allow(clippy::too_many_lines)]
pub async fn run(self) -> TestResult {
let temp_path = {
let id = TEMP_FILE_COUNTER.fetch_add(1, Ordering::SeqCst);
let path = format!("/tmp/reovim-test-{}-{id}.txt", std::process::id());
let content = self.initial_content.as_deref().unwrap_or("");
let mut file = std::fs::File::create(&path).expect("Failed to create temp file");
file.write_all(content.as_bytes())
.expect("Failed to write temp file");
path
};
let mut client = self.connect_with_retry().await.expect("Failed to connect");
client
.send_keys(&format!(":e {temp_path}<CR>"))
.await
.expect("Failed to open buffer file");
tokio::time::sleep(Duration::from_millis(50)).await;
if let Some((line, col)) = self.initial_cursor {
if line > 0 {
client
.send_keys(&format!("{line}j"))
.await
.expect("Failed to move cursor down");
}
if col > 0 {
client
.send_keys(&format!("{col}l"))
.await
.expect("Failed to move cursor right");
}
}
for seq in &self.key_sequences {
client
.send_keys(&seq.keys)
.await
.expect("Failed to inject keys");
tokio::time::sleep(Duration::from_millis(seq.delay_ms)).await;
}
let buffer_response = client
.get_buffer_content(None)
.await
.expect("Failed to get buffer content");
let cursor_response = client.get_cursor().await.expect("Failed to get cursor");
let mode_response = client.get_mode().await.expect("Failed to get mode");
let register_response = client
.get_registers(vec![])
.await
.expect("Failed to get registers");
drop(client);
let buffer_content = buffer_response.lines.join("\n");
let (cursor_line, cursor_column) = cursor_response
.position
.map_or((0, 0), |pos| (pos.line, pos.column));
let registers = register_response
.registers
.into_iter()
.map(|entry| {
(
entry.name,
RegisterInfo {
content: entry.content,
yank_type: entry.yank_type,
},
)
})
.collect();
#[allow(clippy::cast_possible_truncation)]
TestResult {
buffer_content,
cursor_line: cursor_line as u16,
cursor_column: cursor_column as u16,
mode_display: mode_response.display,
edit_mode: mode_response.name,
registers,
harness: self.harness,
temp_path: Some(temp_path),
}
}
}
#[derive(Debug, Clone)]
pub struct RegisterInfo {
pub content: String,
pub yank_type: String,
}
pub struct TestResult {
pub buffer_content: String,
pub cursor_line: u16,
pub cursor_column: u16,
pub mode_display: String,
pub edit_mode: String,
pub registers: HashMap<String, RegisterInfo>,
harness: TestServerHarness,
temp_path: Option<String>,
}
#[cfg_attr(coverage_nightly, coverage(off))]
impl Drop for TestResult {
fn drop(&mut self) {
if let Some(path) = &self.temp_path {
let _ = std::fs::remove_file(path);
}
}
}
#[cfg_attr(coverage_nightly, coverage(off))]
impl TestResult {
#[must_use]
pub fn log_path(&self) -> Option<&std::path::Path> {
self.harness.log_path()
}
fn log_hint(&self) -> String {
self.log_path()
.map(|p| format!("\n\nServer log: {}", p.display()))
.unwrap_or_default()
}
pub fn assert_buffer_eq(&self, expected: &str) {
assert!(
self.buffer_content.trim_end() == expected.trim_end(),
"assertion `left == right` failed: Buffer content mismatch\n\
Expected:\n{}\n\
Actual:\n{}{}",
expected,
self.buffer_content,
self.log_hint()
);
}
pub fn assert_buffer_contains(&self, expected: &str) {
assert!(
self.buffer_content.contains(expected),
"Buffer does not contain '{}'\nActual:\n{}{}",
expected,
self.buffer_content,
self.log_hint()
);
}
pub fn assert_cursor(&self, line: u16, col: u16) {
assert!(
(self.cursor_line, self.cursor_column) == (line, col),
"Cursor mismatch: expected (line={}, col={}), got (line={}, col={}){}",
line,
col,
self.cursor_line,
self.cursor_column,
self.log_hint()
);
}
pub fn assert_register(&self, reg: &str, expected_content: &str, expected_type: &str) {
let register = self.registers.get(reg).unwrap_or_else(|| {
panic!(
"Register '{}' not found. Available: {:?}{}",
reg,
self.registers.keys().collect::<Vec<_>>(),
self.log_hint()
)
});
assert!(
register.content.trim_end() == expected_content.trim_end(),
"Register '{}' content mismatch\nExpected: '{}'\nActual: '{}'{}",
reg,
expected_content,
register.content,
self.log_hint()
);
assert!(
register.yank_type == expected_type,
"Register '{}' type mismatch\nExpected: '{}'\nActual: '{}'{}",
reg,
expected_type,
register.yank_type,
self.log_hint()
);
}
pub fn assert_normal_mode(&self) {
if !self.edit_mode.to_lowercase().contains("normal")
&& !self.mode_display.to_uppercase().contains("NORMAL")
{
panic!(
"Expected normal mode, got: {} ({}){}",
self.mode_display,
self.edit_mode,
self.log_hint()
);
}
}
pub fn assert_insert_mode(&self) {
if !self.edit_mode.to_lowercase().contains("insert")
&& !self.mode_display.to_uppercase().contains("INSERT")
{
panic!(
"Expected insert mode, got: {} ({}){}",
self.mode_display,
self.edit_mode,
self.log_hint()
);
}
}
pub fn assert_visual_mode(&self) {
if !self.edit_mode.to_lowercase().contains("visual")
&& !self.mode_display.to_uppercase().contains("VISUAL")
{
panic!(
"Expected visual mode, got: {} ({}){}",
self.mode_display,
self.edit_mode,
self.log_hint()
);
}
}
}
#[cfg(test)]
mod b11_repro {
use super::*;
#[test]
fn b11_register_population_from_grpc() {
let mut registers = HashMap::new();
registers.insert(
"\"".to_string(),
RegisterInfo {
content: "hello\n".to_string(),
yank_type: "line".to_string(),
},
);
let info = registers.get("\"").expect("register should exist");
assert_eq!(info.content.trim_end(), "hello");
assert_eq!(info.yank_type, "line");
assert!(!registers.is_empty(), "#722 fixed: registers populated via gRPC");
}
}