#![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, integration::RegisterInfo};
static STEP_TEMP_FILE_COUNTER: AtomicU32 = AtomicU32::new(0);
const MAX_CONNECT_ATTEMPTS: u32 = 20;
const RETRY_BASE_MS: u64 = 50;
const RETRY_MAX_MS: u64 = 200;
const DEFAULT_STEP_DELAY_MS: u64 = 30;
#[derive(Debug, Clone)]
pub struct StateSnapshot {
pub key: String,
pub buffer: String,
pub cursor_line: u16,
pub cursor_column: u16,
pub mode_display: String,
pub edit_mode: String,
pub registers: HashMap<String, RegisterInfo>,
}
#[cfg_attr(coverage_nightly, coverage(off))]
impl StateSnapshot {
#[must_use]
pub fn compact(&self) -> String {
format!(
"{} -> {} ({}:{})",
self.key, self.mode_display, self.cursor_line, self.cursor_column
)
}
#[must_use]
pub fn verbose(&self) -> String {
let buf_preview = if self.buffer.len() > 40 {
format!("{}...", &self.buffer[..40])
} else {
self.buffer.clone()
};
format!(
"{} -> {} ({}:{}) buf={:?}",
self.key, self.mode_display, self.cursor_line, self.cursor_column, buf_preview
)
}
}
#[derive(Debug, Clone)]
enum StepExpectation {
Cursor(u16, u16),
Buffer(String),
BufferContains(String),
ModeContains(String),
EditMode(String),
Register(String, String, String),
}
struct TestStep {
keys: String,
delay_ms: u64,
expectations: Vec<StepExpectation>,
}
pub struct StepTest {
harness: TestServerHarness,
addr: String,
initial_content: Option<String>,
initial_cursor: Option<(u16, u16)>,
steps: Vec<TestStep>,
default_delay: u64,
}
#[cfg_attr(coverage_nightly, coverage(off))]
impl StepTest {
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,
steps: Vec::new(),
default_delay: DEFAULT_STEP_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,
steps: Vec::new(),
default_delay: DEFAULT_STEP_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,
steps: Vec::new(),
default_delay: DEFAULT_STEP_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}"
));
}
}
}
}
async fn capture_state(&self, client: &mut GrpcClient, key: &str) -> StateSnapshot {
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");
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)]
StateSnapshot {
key: key.to_string(),
buffer: buffer_response.lines.join("\n"),
cursor_line: cursor_line as u16,
cursor_column: cursor_column as u16,
mode_display: mode_response.display,
edit_mode: mode_response.name,
registers,
}
}
#[must_use]
pub fn with_buffer(mut self, content: &str) -> Self {
self.initial_content = Some(content.to_string());
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]
#[allow(clippy::missing_const_for_fn)]
pub fn with_delay(mut self, ms: u64) -> Self {
self.default_delay = ms;
self
}
#[must_use]
pub fn step(mut self, keys: &str) -> Self {
self.steps.push(TestStep {
keys: keys.to_string(),
delay_ms: self.default_delay,
expectations: Vec::new(),
});
self
}
#[must_use]
pub fn expect_cursor(mut self, line: u16, col: u16) -> Self {
if let Some(step) = self.steps.last_mut() {
step.expectations.push(StepExpectation::Cursor(line, col));
}
self
}
#[must_use]
pub fn expect_buffer(mut self, expected: &str) -> Self {
if let Some(step) = self.steps.last_mut() {
step.expectations
.push(StepExpectation::Buffer(expected.to_string()));
}
self
}
#[must_use]
pub fn expect_buffer_contains(mut self, substring: &str) -> Self {
if let Some(step) = self.steps.last_mut() {
step.expectations
.push(StepExpectation::BufferContains(substring.to_string()));
}
self
}
#[must_use]
pub fn expect_mode_contains(mut self, substring: &str) -> Self {
if let Some(step) = self.steps.last_mut() {
step.expectations
.push(StepExpectation::ModeContains(substring.to_string()));
}
self
}
#[must_use]
pub fn expect_edit_mode(mut self, mode: &str) -> Self {
if let Some(step) = self.steps.last_mut() {
step.expectations
.push(StepExpectation::EditMode(mode.to_string()));
}
self
}
#[must_use]
pub fn expect_register(mut self, reg: &str, content: &str, yank_type: &str) -> Self {
if let Some(step) = self.steps.last_mut() {
step.expectations.push(StepExpectation::Register(
reg.to_string(),
content.to_string(),
yank_type.to_string(),
));
}
self
}
#[allow(clippy::too_many_lines)]
pub async fn run(self) -> StepTrace {
let temp_path = {
let id = STEP_TEMP_FILE_COUNTER.fetch_add(1, Ordering::SeqCst);
let path = format!("/tmp/reovim-step-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");
}
}
let initial = self.capture_state(&mut client, "(initial)").await;
let mut snapshots = Vec::new();
let mut failed_expectations: Vec<String> = Vec::new();
for step in &self.steps {
client
.send_keys(&step.keys)
.await
.expect("Failed to inject key");
tokio::time::sleep(Duration::from_millis(step.delay_ms)).await;
let snapshot = self.capture_state(&mut client, &step.keys).await;
for expectation in &step.expectations {
match expectation {
StepExpectation::Cursor(line, col) => {
if snapshot.cursor_line != *line || snapshot.cursor_column != *col {
failed_expectations.push(format!(
"After '{}': cursor expected ({}:{}), got ({}:{})",
step.keys, line, col, snapshot.cursor_line, snapshot.cursor_column
));
}
}
StepExpectation::Buffer(expected) => {
if snapshot.buffer.trim_end() != expected.trim_end() {
failed_expectations.push(format!(
"After '{}': buffer expected {:?}, got {:?}",
step.keys, expected, snapshot.buffer
));
}
}
StepExpectation::BufferContains(substring) => {
if !snapshot.buffer.contains(substring) {
failed_expectations.push(format!(
"After '{}': buffer expected to contain {:?}, got {:?}",
step.keys, substring, snapshot.buffer
));
}
}
StepExpectation::ModeContains(substring) => {
let mode_lower = snapshot.mode_display.to_lowercase();
let edit_lower = snapshot.edit_mode.to_lowercase();
let sub_lower = substring.to_lowercase();
if !mode_lower.contains(&sub_lower) && !edit_lower.contains(&sub_lower) {
failed_expectations.push(format!(
"After '{}': mode expected to contain {:?}, got display={:?} edit={:?}",
step.keys, substring, snapshot.mode_display, snapshot.edit_mode
));
}
}
StepExpectation::EditMode(mode) => {
if snapshot.edit_mode != *mode {
failed_expectations.push(format!(
"After '{}': edit_mode expected {:?}, got {:?}",
step.keys, mode, snapshot.edit_mode
));
}
}
StepExpectation::Register(reg, content, yank_type) => {
if let Some(register) = snapshot.registers.get(reg) {
if register.content.trim_end() != content.trim_end() {
failed_expectations.push(format!(
"After '{}': register '{}' content expected {:?}, got {:?}",
step.keys, reg, content, register.content
));
}
if register.yank_type != *yank_type {
failed_expectations.push(format!(
"After '{}': register '{}' type expected {:?}, got {:?}",
step.keys, reg, yank_type, register.yank_type
));
}
} else {
failed_expectations.push(format!(
"After '{}': register '{}' not found (available: {:?})",
step.keys,
reg,
snapshot.registers.keys().collect::<Vec<_>>()
));
}
}
}
}
snapshots.push(snapshot);
}
drop(client);
StepTrace {
initial,
snapshots,
failed_expectations,
harness: self.harness,
temp_path: Some(temp_path),
}
}
}
pub struct StepTrace {
pub initial: StateSnapshot,
pub snapshots: Vec<StateSnapshot>,
pub failed_expectations: Vec<String>,
harness: TestServerHarness,
temp_path: Option<String>,
}
#[cfg_attr(coverage_nightly, coverage(off))]
impl Drop for StepTrace {
fn drop(&mut self) {
if let Some(path) = &self.temp_path {
let _ = std::fs::remove_file(path);
}
}
}
#[cfg_attr(coverage_nightly, coverage(off))]
impl StepTrace {
#[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\n Server log: {}", p.display()))
.unwrap_or_default()
}
pub fn print_trace(&self) {
eprintln!("\n╔════════════════════════════════════════════════════════════╗");
eprintln!("║ STEP-BY-STEP TRACE ║");
eprintln!("╚════════════════════════════════════════════════════════════╝\n");
eprintln!(
"Initial: {} mode={} ({}:{})",
self.initial.key,
self.initial.mode_display,
self.initial.cursor_line,
self.initial.cursor_column
);
eprintln!(" Buffer: {:?}", self.initial.buffer);
for (i, snapshot) in self.snapshots.iter().enumerate() {
eprintln!(
"\nStep {}: '{}' -> {} ({}:{})",
i + 1,
snapshot.key,
snapshot.mode_display,
snapshot.cursor_line,
snapshot.cursor_column
);
eprintln!(" Buffer: {:?}", snapshot.buffer);
if !snapshot.registers.is_empty() {
eprintln!(" Registers:");
for (name, info) in &snapshot.registers {
eprintln!(" '{}': {:?} ({})", name, info.content, info.yank_type);
}
}
}
eprintln!("\n────────────────────────────────────────────────────────────");
if self.failed_expectations.is_empty() {
eprintln!("OK: All expectations passed");
} else {
eprintln!("FAIL: {} expectation(s) failed:", self.failed_expectations.len());
for failure in &self.failed_expectations {
eprintln!(" - {failure}");
}
}
if let Some(path) = self.log_path() {
eprintln!("\n Server log: {}", path.display());
}
eprintln!();
}
pub fn print_compact(&self) {
let line: String = std::iter::once(format!(
"[init {}:{}]",
self.initial.cursor_line, self.initial.cursor_column
))
.chain(
self.snapshots
.iter()
.map(|s| format!("'{}' -> {}:{}", s.key, s.cursor_line, s.cursor_column)),
)
.collect::<Vec<_>>()
.join(" → ");
eprintln!("Trace: {line}");
}
pub fn assert_ok(&self) {
if !self.failed_expectations.is_empty() {
self.print_trace();
panic!(
"Step test failed with {} errors:\n{}{}",
self.failed_expectations.len(),
self.failed_expectations.join("\n"),
self.log_hint()
);
}
}
#[must_use]
pub fn final_state(&self) -> &StateSnapshot {
self.snapshots.last().unwrap_or(&self.initial)
}
#[must_use]
pub fn state_after(&self, step: usize) -> Option<&StateSnapshot> {
self.snapshots.get(step)
}
}