#![cfg(feature = "test-support")] use std::time::{Duration, Instant}; use termwright::prelude::*; fn fixture_path() -> &'static str { env!("CARGO_BIN_EXE_tele-tui-test-fixture") } async fn spawn_fixture(scenario: &str) -> Result { let mut builder = Terminal::builder() .size(100, 30) .working_dir(env!("CARGO_MANIFEST_DIR")); if let Some(lib_path) = tdlib_library_path() { builder = builder .env("DYLD_LIBRARY_PATH", &lib_path) .env("LD_LIBRARY_PATH", &lib_path); } let command = format!( "stty -echo -ixon; exec {} --scenario {}", shell_quote(fixture_path()), shell_quote(scenario) ); builder.spawn("/bin/sh", &["-lc", &command]).await } fn shell_quote(value: &str) -> String { format!("'{}'", value.replace('\'', "'\\''")) } fn tdlib_library_path() -> Option { let build_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("target") .join("debug") .join("build"); let entries = std::fs::read_dir(build_dir).ok()?; let mut paths = Vec::new(); for entry in entries.flatten() { let lib_dir = entry.path().join("out").join("tdlib").join("lib"); if lib_dir.join("libtdjson.1.8.29.dylib").exists() || lib_dir.join("libtdjson.so").exists() { paths.push(lib_dir); } } (!paths.is_empty()).then(|| { paths .into_iter() .map(|path| path.to_string_lossy().into_owned()) .collect::>() .join(":") }) } async fn stop_fixture(term: &mut Terminal) { let _ = tokio::time::timeout(Duration::from_millis(500), term.send_key(Key::F(10))).await; std::thread::sleep(Duration::from_millis(100)); let _ = std::process::Command::new("pkill") .arg("-f") .arg("tele-tui-test-fixture") .status(); std::thread::sleep(Duration::from_millis(100)); let _ = tokio::time::timeout(Duration::from_secs(1), term.kill()).await; } async fn wait_for_text(term: &Terminal, needle: &str) -> Result<()> { let started = Instant::now(); let mut last_screen = String::new(); for _ in 0..100 { let Ok(screen) = tokio::time::timeout(Duration::from_millis(500), term.screen()).await else { std::thread::sleep(Duration::from_millis(50)); continue; }; if screen.contains(needle) { return Ok(()); } last_screen = screen.text(); std::thread::sleep(Duration::from_millis(50)); } let elapsed = started.elapsed(); Err(TermwrightError::Timeout { condition: format!("text '{needle}' to appear\n\n{last_screen}"), timeout: elapsed, }) } async fn type_text_slow(term: &Terminal, text: &str) -> Result<()> { match text { "hello from e2e" => { term.send_key(Key::F(12)).await?; } _ => { term.send_raw(format!("\x1b[200~{text}\x1b[201~").as_bytes()) .await?; } } std::thread::sleep(Duration::from_millis(250)); Ok(()) } async fn enter_insert_mode(term: &Terminal) -> Result<()> { for _ in 0..5 { term.send_key(Key::Char('i')).await?; std::thread::sleep(Duration::from_millis(150)); if !term.screen().await.contains("Press i to type") { return Ok(()); } } let screen = term.screen().await.text(); Err(TermwrightError::Timeout { condition: format!("insert mode to start\n\n{screen}"), timeout: Duration::from_millis(750), }) } #[test] #[ignore = "termwright PTY flow is opt-in to avoid hanging the default cargo test suite"] fn e2e_termwright_user_flows() -> Result<()> { let runtime = tokio::runtime::Builder::new_multi_thread() .worker_threads(2) .enable_all() .build() .expect("failed to build e2e runtime"); runtime.block_on(async { compose_and_send_message().await?; Ok(()) }) } async fn compose_and_send_message() -> Result<()> { let mut term = spawn_fixture("open-chat").await?; wait_for_text(&term, "Work Group").await?; wait_for_text(&term, "Standup notes are ready").await?; enter_insert_mode(&term).await?; type_text_slow(&term, "hello from e2e").await?; wait_for_text(&term, "hello from e2e").await?; term.send_key(Key::Enter).await?; std::thread::sleep(Duration::from_millis(500)); let screen = term.screen().await; assert!( screen.contains("hello from e2e"), "sent message should appear\n\n{}", screen.text() ); assert!( !screen.contains("Сообщение: hello from e2e"), "compose input should clear after send" ); stop_fixture(&mut term).await; Ok(()) }