From b1b0b62848cc3cba0c383f740c9a29d8a13e37c8 Mon Sep 17 00:00:00 2001 From: Nils Andreas Svee Date: Mon, 5 Oct 2026 22:04:07 +0700 Subject: [PATCH] chore(app): remove unnecessary tests --- crates/irtt-app/src/cmd/tui/run.rs | 38 -- crates/irtt-app/src/cmd/tui/ui.rs | 226 -------- crates/irtt-app/src/shared/client/worker.rs | 66 --- crates/irtt-app/tests/cli.rs | 530 ------------------ .../irtt-app/tests/support/in_tree_server.rs | 74 --- crates/irtt-app/tests/tui.rs | 327 ----------- 6 files changed, 1261 deletions(-) delete mode 100644 crates/irtt-app/tests/cli.rs delete mode 100644 crates/irtt-app/tests/support/in_tree_server.rs delete mode 100644 crates/irtt-app/tests/tui.rs diff --git a/crates/irtt-app/src/cmd/tui/run.rs b/crates/irtt-app/src/cmd/tui/run.rs index 01d7384..c0a89d0 100644 --- a/crates/irtt-app/src/cmd/tui/run.rs +++ b/crates/irtt-app/src/cmd/tui/run.rs @@ -228,41 +228,3 @@ fn process_tui_event( _ => false, } } - -#[cfg(test)] -mod tests { - use super::*; - use ratatui::{backend::TestBackend, Terminal}; - - #[tokio::test(flavor = "current_thread")] - async fn subscription_lag_keeps_incomplete_statistics_visible_while_paused() { - for paused in [false, true] { - let (sender, mut events) = tokio::sync::broadcast::channel(1); - sender.send(ManagedEvent::Started).unwrap(); - sender.send(ManagedEvent::Started).unwrap(); - let mut state = TuiState::default(); - if paused { - state.toggle_pause(); - } - let Err(RecvError::Lagged(dropped)) = events.recv().await else { - panic!("subscription must report real broadcast overflow"); - }; - state.mark_dropped_managed_events(dropped); - let mut terminal = Terminal::new(TestBackend::new(200, 40)).unwrap(); - for _ in 0..2 { - terminal - .draw(|frame| super::super::ui::draw_dashboard(frame, &state)) - .unwrap(); - let text = terminal - .backend() - .buffer() - .content() - .iter() - .map(|cell| cell.symbol()) - .collect::(); - assert!(text.contains("incomplete:dropped=1")); - assert_eq!(text.contains("display paused"), paused); - } - } - } -} diff --git a/crates/irtt-app/src/cmd/tui/ui.rs b/crates/irtt-app/src/cmd/tui/ui.rs index 4003a2d..afd0de1 100644 --- a/crates/irtt-app/src/cmd/tui/ui.rs +++ b/crates/irtt-app/src/cmd/tui/ui.rs @@ -1558,229 +1558,3 @@ fn format_optional_hex(value: Option) -> String { fn duration_ns(value: Duration) -> i128 { i128::try_from(value.as_nanos()).unwrap_or(i128::MAX) } - -#[cfg(test)] -mod tests { - use super::*; - use irtt_client::{ClientTimestamp, OneWayDelaySample, PacketMeta, RttSample, ServerTiming}; - use ratatui::backend::TestBackend; - use std::time::SystemTime; - - fn measure(state: &mut TuiState, label: &str, seq: u32, at: Instant, effective_ns: i128) { - let target = TargetInstance { - id: label.into(), - generation: 1, - }; - let received_at = ClientTimestamp { - mono: at, - wall: SystemTime::UNIX_EPOCH + Duration::from_secs(1), - }; - let sent_at = ClientTimestamp { - mono: at - Duration::from_millis(10), - wall: received_at.wall - Duration::from_millis(10), - }; - let remote = "127.0.0.1:2112".parse().unwrap(); - state.process_target_event( - &target, - &ClientEvent::EchoSent { - seq, - remote, - scheduled_at: None, - sent_at, - bytes: 64, - send_call: Duration::ZERO, - timer_error: None, - }, - ); - state.process_target_event( - &target, - &ClientEvent::EchoReply { - seq, - remote, - sent_at, - received_at, - rtt: RttSample { - raw: Duration::from_millis(10), - adjusted: Some(SignedDuration::from_nanos(effective_ns)), - effective: SignedDuration::from_nanos(effective_ns), - }, - server_timing: Some(ServerTiming { - receive_wall_ns: None, - receive_mono_ns: None, - send_wall_ns: None, - send_mono_ns: None, - midpoint_wall_ns: None, - midpoint_mono_ns: None, - processing: Some(Duration::from_nanos((10_000_000 - effective_ns) as u64)), - }), - one_way: Some(OneWayDelaySample { - client_to_server: Some(SignedDuration::from_nanos(-3_000_000)), - server_to_client: Some(SignedDuration::from_nanos(effective_ns + 3_000_000)), - }), - received_stats: None, - bytes: 64, - packet_meta: PacketMeta::default(), - }, - ); - } - - fn screen(state: &TuiState, width: u16, height: u16) -> String { - let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); - terminal.draw(|frame| draw_dashboard(frame, state)).unwrap(); - terminal - .backend() - .buffer() - .content() - .chunks(usize::from(width)) - .map(|row| row.iter().map(|cell| cell.symbol()).collect::()) - .collect::>() - .join("\n") - } - - #[test] - fn target_focus_and_details_remain_usable_across_sizes() { - let mut state = TuiState::with_target_labels( - TuiConfig::default(), - (0..8).map(|i| format!("target{i}")), - ); - let now = Instant::now(); - measure( - &mut state, - "target7", - 0, - now - Duration::from_secs(5), - 8_000_000, - ); - measure(&mut state, "target7", 1, now, 5_000_000); - for (width, height) in [(56, 18), (80, 24), (120, 40)] { - let area = Rect::new(0, 0, width, height); - state.handle_key(KeyCode::Home, area); - state.handle_key(KeyCode::BackTab, area); - let text = screen(&state, width, height); - assert!(text.contains(">target7"), "{text}"); - assert!( - text.contains("jitter σ") && text.contains("loss %"), - "{text}" - ); - assert!( - text.contains(&format_optional_ns_i128(Some(-3_000_000))) - && text.contains(&format_optional_ns_i128(Some(5_000_000))), - "{text}" - ); - state.handle_key(KeyCode::Char('d'), area); - assert!(screen(&state, width, height).contains("target: target7")); - state.handle_key(KeyCode::End, area); - assert!(screen(&state, width, height).contains("target7: reply")); - state.handle_key(KeyCode::Tab, area); - // Selecting another target resets detail scrolling and exposes its own data. - assert!(screen(&state, width, height).contains("target: target0")); - state.handle_key(KeyCode::Esc, area); - } - assert!(screen(&state, 40, 10).contains("terminal too small")); - } - - #[test] - fn history_reset_keeps_latest_summary_and_statistics_while_paused() { - let mut state = TuiState::default(); - let area = Rect::new(0, 0, 100, 40); - state.handle_key(KeyCode::Char('p'), area); - measure(&mut state, "target", 0, Instant::now(), 8_000_000); - state.handle_key(KeyCode::Char('r'), area); - let text = screen(&state, 100, 40); - assert!( - text.contains(&format_optional_ns_i128(Some(8_000_000))) - && text.contains("display paused") - ); - assert!(text.contains("waiting for primary replies")); - state.handle_key(KeyCode::Char('d'), area); - let text = screen(&state, 100, 40); - assert!(text.contains("sent 1 received 1 unique 1")); - assert!(text.contains(&format!( - "effective RTT: {}", - format_optional_ns_i128(Some(8_000_000)) - ))); - state.handle_key(KeyCode::Esc, area); - measure(&mut state, "target", 1, Instant::now(), 5_000_000); - assert!(!screen(&state, 100, 40).contains("waiting for primary replies")); - } - - #[test] - fn graph_controls_preserve_signed_metrics_and_history_range() { - let mut state = - TuiState::with_target_labels(TuiConfig::default(), ["a".to_owned(), "b".to_owned()]); - let area = Rect::new(0, 0, 100, 40); - let now = Instant::now(); - for label in ["a", "b"] { - measure( - &mut state, - label, - 0, - now - Duration::from_secs(120), - -2_000_000, - ); - measure( - &mut state, - label, - 1, - now - Duration::from_secs(60), - -2_000_000, - ); - measure(&mut state, label, 2, now, -2_000_000); - } - state.handle_key(KeyCode::Home, area); - let viewport = state - .graph_viewport - .range(now, state.newest_graph_sample_time()); - assert_eq!(viewport.start, now - Duration::from_secs(120)); - assert_eq!(viewport.end, now - Duration::from_secs(60)); - for metric in [ - GraphMetric::EffectiveRtt, - GraphMetric::RawRtt, - GraphMetric::AdjustedRtt, - GraphMetric::ClientToServer, - GraphMetric::ServerToClient, - GraphMetric::ServerProcessing, - ] { - assert_eq!(state.graph_metric, metric); - let series = state - .targets - .iter() - .enumerate() - .map(|(i, target)| target_metric_series(target, i, viewport, metric).unwrap()) - .collect::>(); - // Both endpoints belong to the viewport, for both targets. - assert!(series.iter().all(|series| series.data.len() == 2)); - if matches!( - metric, - GraphMetric::EffectiveRtt | GraphMetric::AdjustedRtt | GraphMetric::ClientToServer - ) { - assert!(series - .iter() - .all(|series| series.data.iter().all(|(_, y)| *y < 0.0))); - assert!(chart_y_bounds(&series, metric.axis_kind()).0 < 0.0); - } - state.handle_key(KeyCode::Char('m'), area); - } - state.handle_key(KeyCode::PageDown, area); - assert_eq!(state.graph_viewport.range(now, Some(now)).end, now); - state.handle_key(KeyCode::Left, area); - assert_eq!( - state.graph_viewport.range(now, Some(now)).end, - now - Duration::from_secs(15) - ); - state.handle_key(KeyCode::Right, area); - state.handle_key(KeyCode::PageUp, area); - assert_eq!( - state.graph_viewport.range(now, Some(now)).end, - now - Duration::from_secs(60) - ); - state.handle_key(KeyCode::Char('+'), area); - assert_eq!(state.graph_viewport.window, Duration::from_secs(40)); - state.handle_key(KeyCode::Char('-'), area); - assert_eq!(state.graph_viewport.window, DEFAULT_GRAPH_WINDOW); - state.handle_key(KeyCode::Char('0'), area); - assert!(!state.graph_viewport.range(now, Some(now)).is_live); - state.handle_key(KeyCode::End, area); - assert!(state.graph_viewport.range(now, Some(now)).is_live); - } -} diff --git a/crates/irtt-app/src/shared/client/worker.rs b/crates/irtt-app/src/shared/client/worker.rs index 828d7a2..c3e7379 100644 --- a/crates/irtt-app/src/shared/client/worker.rs +++ b/crates/irtt-app/src/shared/client/worker.rs @@ -65,69 +65,3 @@ impl Drop for ManagedWorker { } } } - -#[cfg(test)] -mod tests { - use super::*; - use irtt_client::managed::{ - ManagedClient, ManagedClientConfig, ManagedCompletionPolicy, ManagedEndReason, - }; - - // The frontend executor must be able to stop a worker while awaiting its - // completion; a blocking join here would deadlock this current-thread runtime. - #[test] - fn completion_leaves_frontend_free_to_stop_and_preserves_outcome() { - let (task, handle) = ManagedClient::task( - ManagedClientConfig { - completion: ManagedCompletionPolicy::ExplicitStop, - ..Default::default() - }, - vec![], - ) - .unwrap(); - let worker = ManagedWorker::start(task, handle.clone()).unwrap(); - tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .unwrap() - .block_on(async { - let (outcome, ()) = tokio::time::timeout(Duration::from_secs(2), async { - tokio::join!(worker.join(), handle.stop()) - }) - .await - .expect("worker completion must allow frontend stop"); - let outcome = outcome.unwrap(); - assert_eq!(outcome.end_reason, ManagedEndReason::StopRequested); - assert_eq!(handle.status().final_outcome.as_deref(), Some(&outcome)); - }); - } - - // Cancelling the frontend's completion wait must stop and join the worker, - // rather than detaching an ongoing measurement task. - #[test] - fn cancelling_completion_stops_and_joins_worker() { - use std::{ - future::Future, - task::{Context, Waker}, - }; - let (task, handle) = ManagedClient::task( - ManagedClientConfig { - completion: ManagedCompletionPolicy::ExplicitStop, - ..Default::default() - }, - vec![], - ) - .unwrap(); - let worker = ManagedWorker::start(task, handle.clone()).unwrap(); - let mut completion = Box::pin(worker.join()); - assert!(completion - .as_mut() - .poll(&mut Context::from_waker(Waker::noop())) - .is_pending()); - drop(completion); - assert_eq!( - handle.status().final_outcome.as_ref().unwrap().end_reason, - ManagedEndReason::StopRequested - ); - } -} diff --git a/crates/irtt-app/tests/cli.rs b/crates/irtt-app/tests/cli.rs deleted file mode 100644 index fb453f7..0000000 --- a/crates/irtt-app/tests/cli.rs +++ /dev/null @@ -1,530 +0,0 @@ -#[cfg(feature = "client")] -#[path = "support/in_tree_server.rs"] -mod in_tree_server; - -#[cfg(feature = "client")] -use irtt_server::ServerConfig; -use std::process::Command; - -#[cfg(feature = "client")] -struct ClientProcess(std::process::Child); - -#[cfg(feature = "client")] -impl Drop for ClientProcess { - fn drop(&mut self) { - let _ = self.0.kill(); - let _ = self.0.wait(); - } -} - -#[cfg(feature = "client")] -impl ClientProcess { - fn wait(&mut self) -> std::process::ExitStatus { - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); - loop { - if let Some(status) = self.0.try_wait().unwrap() { - return status; - } - assert!( - std::time::Instant::now() < deadline, - "client did not terminate within five seconds" - ); - std::thread::sleep(std::time::Duration::from_millis(10)); - } - } -} - -// Dispatch/help/availability belong to the application and run in every build. -#[test] -fn dispatcher_reports_and_runs_the_enabled_applets() { - let dispatcher = env!("CARGO_BIN_EXE_irtt-rs"); - let help = Command::new(dispatcher).arg("--help").output().unwrap(); - assert!(help.status.success()); - let help = String::from_utf8(help.stdout).unwrap(); - for (applet, enabled) in [ - ("client", cfg!(feature = "client")), - ("server", cfg!(feature = "server")), - ("tui", cfg!(feature = "tui")), - ] { - let row = help - .lines() - .find(|line| line.trim_start().starts_with(applet)) - .unwrap(); - assert_eq!(row.contains("not available"), !enabled); - let output = Command::new(dispatcher) - .args([applet, "--help"]) - .output() - .unwrap(); - assert_eq!(output.status.success(), enabled, "{applet}"); - if !enabled { - assert!(String::from_utf8(output.stderr) - .unwrap() - .contains("not available")); - } - } - let binaries = [ - dispatcher, - #[cfg(feature = "client")] - env!("CARGO_BIN_EXE_irtt-client"), - #[cfg(feature = "server")] - env!("CARGO_BIN_EXE_irtt-server"), - #[cfg(feature = "tui")] - env!("CARGO_BIN_EXE_irtt-tui"), - ]; - for binary in binaries { - assert!(Command::new(binary) - .arg("--help") - .output() - .unwrap() - .status - .success()); - assert!(!Command::new(binary) - .arg("--invalid-option") - .output() - .unwrap() - .status - .success()); - } -} - -// The default presentation must reach a meaningful packet summary for a finite run. -#[cfg(feature = "client")] -#[test] -fn default_client_output_emits_a_packet_summary() { - use std::{io::Read, process::Stdio, thread}; - let server = in_tree_server::InTreeServer::start(ServerConfig::default()); - let mut client = ClientProcess( - Command::new(env!("CARGO_BIN_EXE_irtt-client")) - .env_clear() - .args([ - "--duration", - "100ms", - "--interval", - "20ms", - &server.addr.to_string(), - ]) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .unwrap(), - ); - let mut stdout = client.0.stdout.take().unwrap(); - let stdout = thread::spawn(move || { - let mut text = String::new(); - stdout.read_to_string(&mut text).unwrap(); - text - }); - let mut stderr = client.0.stderr.take().unwrap(); - let stderr = thread::spawn(move || { - let mut text = String::new(); - stderr.read_to_string(&mut text).unwrap(); - text - }); - let status = client.wait(); - let stdout = stdout.join().unwrap(); - let stderr = stderr.join().unwrap(); - assert!(status.success(), "{stderr}"); - assert!(stdout.contains("irtt-rs summary"), "{stdout}"); - let packets = stdout - .lines() - .find(|line| line.starts_with("packets:")) - .expect("default output must include the packet summary"); - for field in ["sent=", "received="] { - let count = packets - .split_whitespace() - .find_map(|word| word.strip_prefix(field)) - .unwrap() - .parse::() - .unwrap(); - assert!( - count > 0, - "summary must report {field} a positive count: {packets}" - ); - } -} - -#[cfg(feature = "client")] -#[test] -fn target_authentication_inherits_overrides_or_disables_the_global_key() { - use clap::Parser; - use irtt_app::cmd::client::args::ClientArgs; - use irtt_client::{managed::TargetAuth, Authentication, HmacKey}; - let args = ClientArgs::try_parse_from([ - "irtt-client", - "--hmac", - "CONSPICUOUS_GLOBAL_HMAC_KEY", - "inherit=127.0.0.1:2112", - "override=127.0.0.1:2113@hmac=CONSPICUOUS_TARGET_HMAC_KEY", - "disable=127.0.0.1:2114@hmac=", - ]) - .unwrap(); - let setup = args.prepare().unwrap(); - assert_eq!( - setup.managed_config().client.auth, - Authentication::Hmac(HmacKey::new(b"CONSPICUOUS_GLOBAL_HMAC_KEY".as_slice())) - ); - let targets = setup.managed_targets(); - let authentication: Vec<_> = targets.iter().map(|target| target.auth.clone()).collect(); - assert_eq!( - authentication, - [ - TargetAuth::Inherit, - TargetAuth::Override(Authentication::Hmac(HmacKey::new( - b"CONSPICUOUS_TARGET_HMAC_KEY".as_slice() - ))), - TargetAuth::Override(Authentication::Unauthenticated) - ] - ); - for output in [format!("{args:?}"), format!("{setup:?}")] { - assert!(!output.contains("CONSPICUOUS_GLOBAL_HMAC_KEY")); - assert!(!output.contains("CONSPICUOUS_TARGET_HMAC_KEY")); - } -} - -// A real authenticated server detects broken client argument-to-config mapping. -#[cfg(feature = "client")] -#[test] -fn client_arguments_export_a_real_client_run() { - use std::{io::Read, process::Stdio, thread}; - let dispatcher = env!("CARGO_BIN_EXE_irtt-rs"); - for option in ["--dscp", "--ttl"] { - assert!(!Command::new(dispatcher) - .args(["client", option, "256", "127.0.0.1:9"]) - .output() - .unwrap() - .status - .success()); - } - let server = - in_tree_server::InTreeServer::start(ServerConfig::default().with_hmac_key(b"cli-test")); - let mut client = ClientProcess( - Command::new(dispatcher) - .args([ - "client", - "--duration", - "100ms", - "--interval", - "20ms", - "--hmac", - "cli-test", - "--length", - "128", - "--sfill", - "pattern:ab", - "--clock", - "wall", - "--tstamp", - "both", - "--stats", - "both", - "--ttl", - "64", - "--format", - "jsonl", - "--columns", - "target,event,bytes", - &format!("edge={}", server.addr), - ]) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .unwrap(), - ); - // Drain both pipes while waiting so output cannot block client termination. - let mut stdout = client.0.stdout.take().unwrap(); - let stdout = thread::spawn(move || { - let mut bytes = Vec::new(); - stdout.read_to_end(&mut bytes).unwrap(); - bytes - }); - let mut stderr = client.0.stderr.take().unwrap(); - let stderr = thread::spawn(move || { - let mut bytes = Vec::new(); - stderr.read_to_end(&mut bytes).unwrap(); - bytes - }); - let status = client.wait(); - let stdout = stdout.join().unwrap(); - let stderr = stderr.join().unwrap(); - assert!(status.success(), "{}", String::from_utf8_lossy(&stderr)); - let stdout = String::from_utf8(stdout).unwrap(); - let rows: Vec<_> = stdout.lines().collect(); - assert!(!rows.is_empty()); - assert!(rows.iter().all(|row| row.starts_with('{') - && row.ends_with('}') - && row.contains("\"target\":\"edge\""))); - assert!(rows - .iter() - .any(|row| row.contains("\"event\":\"echo_reply\"") && row.contains("\"bytes\":128"))); - assert!(rows - .iter() - .any(|row| row.contains("\"event\":\"session_closed\""))); -} - -#[cfg(any(feature = "client", feature = "tui"))] -#[test] -fn malformed_target_diagnostics_do_not_expose_authentication_keys() { - let secret = "malformed-target-secret-92817"; - for applet in [ - #[cfg(feature = "client")] - "client", - #[cfg(feature = "tui")] - "tui", - ] { - let output = Command::new(env!("CARGO_BIN_EXE_irtt-rs")) - .args([applet, &format!(r"edge=127.0.0.1:9@hmac={secret}\q")]) - .output() - .unwrap(); - assert!(!output.status.success()); - let stderr = String::from_utf8(output.stderr).unwrap(); - assert!( - !stderr.trim().is_empty(), - "malformed target must report a diagnostic" - ); - assert!(!stderr.contains(secret), "target authentication key leaked"); - assert!(!String::from_utf8_lossy(&output.stdout).contains(secret)); - } -} - -#[cfg(feature = "server")] -#[test] -fn server_arguments_resolve_binds_and_map_policy() { - use clap::{CommandFactory, FromArgMatches, Parser}; - use irtt_app::cmd::server::ServerArgs; - use irtt_server::TimestampAllowance; - use std::time::Duration; - // Isolate zero-argument defaults from the test runner's environment without - // mutating process-global environment in this parallel test process. - let defaults = ServerArgs::from_arg_matches( - &ServerArgs::command() - .mut_args(|arg| arg.env(None::<&str>)) - .try_get_matches_from(["irtt-server"]) - .unwrap(), - ) - .unwrap(); - assert_eq!( - defaults.resolve_binds(), - vec![ - "[::]:2112".parse().unwrap(), - "0.0.0.0:2112".parse().unwrap() - ] - ); - assert_eq!( - defaults.server_config(), - irtt_server::ServerConfig::default() - ); - let args = ServerArgs::try_parse_from([ - "irtt-server", - "--bind", - "127.0.0.1:2113", - "--bind", - "[::1]:2114", - "--hmac", - "server-cli-test", - "--max-sessions", - "3", - "--max-packet-length", - "96", - "--min-interval", - "25ms", - "--burst", - "2", - "--idle-timeout", - "8s", - "--max-duration", - "1m", - "--timestamp-allowance", - "single", - "--no-dscp", - ]) - .unwrap(); - assert_eq!( - args.resolve_binds(), - vec![ - "127.0.0.1:2113".parse().unwrap(), - "[::1]:2114".parse().unwrap() - ] - ); - let config = args.server_config(); - assert_eq!(config.hmac_key(), Some(b"server-cli-test".as_slice())); - assert_eq!(config.max_sessions(), 3); - assert_eq!(config.max_packet_length(), 96); - assert_eq!(config.min_send_interval(), Duration::from_millis(25)); - assert_eq!(config.burst_allowance(), 2); - assert_eq!(config.idle_timeout(), Duration::from_secs(8)); - assert_eq!(config.max_test_duration(), Some(Duration::from_secs(60))); - assert_eq!(config.timestamp_allowance(), TimestampAllowance::Single); - assert!(!config.dscp_allowed()); -} - -#[cfg(feature = "server")] -#[test] -fn server_command_line_binds_replace_the_environment_bind() { - use std::net::UdpSocket; - // Occupied ports give bounded, observable startup failures, without starting - // a daemon or needing a process/signal harness merely to inspect arguments. - let environment = UdpSocket::bind("127.0.0.1:0").unwrap(); - let explicit = UdpSocket::bind("127.0.0.1:0").unwrap(); - let environment_addr = environment.local_addr().unwrap().to_string(); - let explicit_addr = explicit.local_addr().unwrap().to_string(); - for use_explicit in [false, true] { - let mut command = Command::new(env!("CARGO_BIN_EXE_irtt-server")); - command - .env_clear() - .env("IRTT_SERVER_BIND", &environment_addr) - .env("IRTT_SERVER_MAX_SESSIONS", "invalid") - .args(["--max-sessions", "3"]); - if use_explicit { - command.args(["--bind", "127.0.0.1:0", "--bind", &explicit_addr]); - } - let output = command.output().unwrap(); - assert!(!output.status.success()); - let stderr = String::from_utf8(output.stderr).unwrap(); - let expected = if use_explicit { - &explicit_addr - } else { - &environment_addr - }; - assert!( - stderr.contains(expected), - "startup must attempt the selected bind: {stderr}" - ); - assert!( - !stderr.contains("listening on"), - "partial startup must not announce listeners" - ); - } -} - -// Exercise stdin replacement and EOF through the actual application process. -#[cfg(feature = "client")] -#[test] -fn targets_stdin_supersedes_the_active_set_and_stops_on_eof() { - use std::{ - io::{BufRead, BufReader, Read, Write}, - process::Stdio, - sync::mpsc, - thread, - time::{Duration, Instant}, - }; - let server = in_tree_server::InTreeServer::start(ServerConfig::default()); - let mut client = ClientProcess( - Command::new(env!("CARGO_BIN_EXE_irtt-client")) - .env_clear() - .args([ - "--duration", - "0", - "--targets-stdin", - "--interval", - "20ms", - "--format", - "jsonl", - "--columns", - "target,event", - ]) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .unwrap(), - ); - let stdout = client.0.stdout.take().unwrap(); - let (tx, rx) = mpsc::channel(); - let reader = thread::spawn(move || { - for line in BufReader::new(stdout).lines() { - if tx.send(line.unwrap()).is_err() { - break; - } - } - }); - let mut stdin = client.0.stdin.take().unwrap(); - let mut rows = Vec::new(); - for target in ["first", "second"] { - write!(stdin, "\r\n{target}={}\r\n", server.addr).unwrap(); - stdin.flush().unwrap(); - let deadline = Instant::now() + Duration::from_secs(5); - loop { - let row = rx - .recv_timeout(deadline.saturating_duration_since(Instant::now())) - .expect("stdin target set did not produce a reply"); - let replied = row.contains(&format!("\"target\":\"{target}\"")) - && row.contains("\"event\":\"echo_reply\""); - rows.push(row); - if replied { - break; - } - } - } - // Retirement can finish after the newer target's first reply. - let deadline = Instant::now() + Duration::from_secs(5); - while !rows.iter().any(|row| { - row.contains("\"target\":\"first\"") && row.contains("\"event\":\"session_closed\"") - }) { - rows.push( - rx.recv_timeout(deadline.saturating_duration_since(Instant::now())) - .expect("superseded stdin target did not retire"), - ); - } - drop(stdin); - let status = client.wait(); - reader.join().unwrap(); - rows.extend(rx.try_iter()); - let mut stderr = String::new(); - client - .0 - .stderr - .take() - .unwrap() - .read_to_string(&mut stderr) - .unwrap(); - assert!(status.success(), "{stderr}"); - assert!(rows - .iter() - .any(|row| row.contains("\"target\":\"second\"") - && row.contains("\"event\":\"session_closed\""))); -} - -// Record bounds and decoding errors must terminate the real stdin frontend, -// even when no managed events are available to wake it. -#[cfg(feature = "client")] -#[test] -fn targets_stdin_invalid_records_are_fatal_and_empty_eof_is_normal() { - use std::{ - io::{Read, Write}, - process::Stdio, - }; - for (record, success, diagnostic) in [ - (Vec::new(), true, ""), - (b"\r\n\n".to_vec(), true, ""), - (b"\n=127.0.0.1:9\n".to_vec(), false, "line 2"), - (vec![0xff, b'\n'], false, "line 1"), - (vec![b'x'; 64 * 1024 + 2], false, "line 1"), - ] { - let mut client = ClientProcess( - Command::new(env!("CARGO_BIN_EXE_irtt-client")) - .env_clear() - .args(["--duration", "0", "--targets-stdin"]) - .stdin(Stdio::piped()) - .stdout(Stdio::null()) - .stderr(Stdio::piped()) - .spawn() - .unwrap(), - ); - let mut stdin = client.0.stdin.take().unwrap(); - stdin.write_all(&record).unwrap(); - drop(stdin); - let status = client.wait(); - let mut stderr = String::new(); - client - .0 - .stderr - .take() - .unwrap() - .read_to_string(&mut stderr) - .unwrap(); - assert_eq!(status.success(), success, "{stderr}"); - assert!(stderr.contains(diagnostic), "{stderr}"); - } -} diff --git a/crates/irtt-app/tests/support/in_tree_server.rs b/crates/irtt-app/tests/support/in_tree_server.rs deleted file mode 100644 index faf4fcd..0000000 --- a/crates/irtt-app/tests/support/in_tree_server.rs +++ /dev/null @@ -1,74 +0,0 @@ -use std::{ - net::{IpAddr, Ipv4Addr, SocketAddr}, - sync::mpsc, - thread::{self, JoinHandle}, - time::Duration, -}; - -use irtt_server::{Server, ServerConfig}; -use tokio::sync::oneshot; - -const STARTUP_TIMEOUT: Duration = Duration::from_secs(2); - -pub struct InTreeServer { - pub addr: SocketAddr, - shutdown: Option>, - done: Option>>, -} - -impl InTreeServer { - pub fn start(config: ServerConfig) -> Self { - let (startup_tx, startup_rx) = mpsc::sync_channel(1); - let (shutdown_tx, shutdown_rx) = oneshot::channel(); - let done = thread::spawn(move || { - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("failed to build in-tree test server runtime"); - runtime.block_on(async move { - let bind_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0); - let mut server = Server::bind(bind_addr, config).await?; - startup_tx - .send(server.local_addr()?) - .expect("test stopped waiting for in-tree server startup"); - server - .run(async { - let _ = shutdown_rx.await; - }) - .await - }) - }); - - let addr = match startup_rx.recv_timeout(STARTUP_TIMEOUT) { - Ok(addr) => addr, - Err(error) => { - let _ = shutdown_tx.send(()); - let result = done.join().expect("in-tree test server thread panicked"); - panic!("in-tree test server failed before startup ({error}): {result:?}"); - } - }; - - Self { - addr, - shutdown: Some(shutdown_tx), - done: Some(done), - } - } - - fn stop_inner(&mut self) { - if let Some(shutdown) = self.shutdown.take() { - let _ = shutdown.send(()); - } - if let Some(done) = self.done.take() { - done.join() - .expect("in-tree test server thread panicked") - .expect("in-tree test server stopped with an error"); - } - } -} - -impl Drop for InTreeServer { - fn drop(&mut self) { - self.stop_inner(); - } -} diff --git a/crates/irtt-app/tests/tui.rs b/crates/irtt-app/tests/tui.rs deleted file mode 100644 index 4a32c6c..0000000 --- a/crates/irtt-app/tests/tui.rs +++ /dev/null @@ -1,327 +0,0 @@ -#![cfg(all(unix, feature = "tui"))] - -#[path = "support/in_tree_server.rs"] -mod in_tree_server; - -use irtt_server::ServerConfig; -use nix::{ - poll::{poll, PollFd, PollFlags}, - pty::{openpty, Winsize}, - sys::{ - signal::{kill, Signal}, - termios::{tcgetattr, Termios}, - }, - unistd::Pid, -}; -use std::{ - fs::File, - io::{Read, Write}, - os::{ - fd::{AsFd, AsRawFd}, - unix::process::CommandExt, - }, - process::{Child, Command, ExitStatus, Stdio}, - time::{Duration, Instant}, -}; - -// Real terminal ownership: keep both ends open to inspect restoration after exit, -// drain output while waiting, and always reap the child on assertion failures. -struct TuiProcess { - child: Child, - master: File, - slave: File, - original: Termios, - output: Vec, - cursor_answered: bool, -} -impl TuiProcess { - fn start(mut command: Command) -> Self { - let pty = openpty( - Some(&Winsize { - ws_row: 40, - ws_col: 160, - ws_xpixel: 0, - ws_ypixel: 0, - }), - None, - ) - .unwrap(); - let slave = File::from(pty.slave); - let original = tcgetattr(&slave).unwrap(); - command - .env("TERM", "xterm-256color") - .stdin(Stdio::from(slave.try_clone().unwrap())) - .stdout(Stdio::from(slave.try_clone().unwrap())) - .stderr(Stdio::from(slave.try_clone().unwrap())); - // Only async-signal-safe operations run between fork and exec. - unsafe { - command.pre_exec(|| { - if nix::libc::setsid() == -1 { - return Err(std::io::Error::last_os_error()); - } - Ok(()) - }); - } - Self { - child: command.spawn().unwrap(), - master: File::from(pty.master), - slave, - original, - output: vec![], - cursor_answered: false, - } - } - fn pump(&mut self) { - let mut fd = [PollFd::new(self.master.as_fd(), PollFlags::POLLIN)]; - poll(&mut fd, 20_u16).unwrap(); - if fd[0].revents().unwrap().contains(PollFlags::POLLIN) { - let mut buffer = [0; 65536]; - if let Ok(count) = self.master.read(&mut buffer) { - self.output.extend_from_slice(&buffer[..count]); - if !self.cursor_answered && self.output.windows(4).any(|bytes| bytes == b"\x1b[6n") - { - self.master.write_all(b"\x1b[1;1R").unwrap(); - self.cursor_answered = true; - } - } - } - } - fn await_text(&mut self, text: &str) { - let deadline = Instant::now() + Duration::from_secs(5); - while !String::from_utf8_lossy(&self.output).contains(text) { - self.pump(); - assert!( - Instant::now() < deadline, - "missing {text:?}: {}", - String::from_utf8_lossy(&self.output) - ); - assert!( - self.child.try_wait().unwrap().is_none(), - "TUI exited before {text:?}: {}", - String::from_utf8_lossy(&self.output) - ); - } - } - fn keys(&mut self, keys: &[u8]) { - self.master.write_all(keys).unwrap(); - } - fn signal(&self, signal: Signal) { - kill(Pid::from_raw(self.child.id() as i32), signal).unwrap(); - } - fn finish(&mut self, success: bool) -> ExitStatus { - let deadline = Instant::now() + Duration::from_secs(5); - let status = loop { - self.pump(); - if let Some(status) = self.child.try_wait().unwrap() { - break status; - } - assert!( - Instant::now() < deadline, - "TUI did not finish: {}", - String::from_utf8_lossy(&self.output[self.output.len().saturating_sub(3000)..]) - ); - }; - self.pump(); - assert_eq!( - status.success(), - success, - "{}", - String::from_utf8_lossy(&self.output) - ); - let restored = tcgetattr(&self.master).unwrap(); - assert_eq!(restored, self.original, "raw mode was not restored"); - let output = String::from_utf8_lossy(&self.output); - assert!( - output.contains("\x1b[?1049l"), - "alternate screen was not left: {output}" - ); - assert!( - output.contains("\x1b[?25h"), - "cursor was not restored: {output}" - ); - status - } -} -impl Drop for TuiProcess { - fn drop(&mut self) { - let _ = self.child.kill(); - let _ = self.child.wait(); - } -} -fn tui(target: &str, duration: &str) -> Command { - let mut cmd = Command::new(env!("CARGO_BIN_EXE_irtt-tui")); - cmd.args(["--duration", duration, "--interval", "20ms", target]); - cmd -} - -#[test] -fn terminal_controls_and_exit_paths_restore_the_terminal() { - let server = in_tree_server::InTreeServer::start(ServerConfig::default()); - let target = server.addr.to_string(); - let mut command = tui(&format!("first={target}"), "0"); - command.arg(format!("second={target}")); - let mut process = TuiProcess::start(command); - process.await_text("active"); - process.keys(b"p"); - process.await_text("paused"); - let size = Winsize { - ws_row: 12, - ws_col: 50, - ws_xpixel: 0, - ws_ypixel: 0, - }; - assert_eq!( - unsafe { nix::libc::ioctl(process.slave.as_raw_fd(), nix::libc::TIOCSWINSZ, &size) }, - 0 - ); - process.signal(Signal::SIGWINCH); - process.await_text("small"); - let size = Winsize { - ws_row: 40, - ws_col: 160, - ws_xpixel: 0, - ws_ypixel: 0, - }; - assert_eq!( - unsafe { nix::libc::ioctl(process.slave.as_raw_fd(), nix::libc::TIOCSWINSZ, &size) }, - 0 - ); - process.signal(Signal::SIGWINCH); - process.keys(b"\td"); - process.await_text("arrows"); - // Each key waits for a real draw, avoiding a timing-dependent escape batch. - for key in [ - b"\x1b[B".as_slice(), - b"\x1b[C", - b"\x1b[6~", - b"\x1b[F", - b"\x1b[H", - b"d", - b"m", - b"+", - b"-", - b"0", - b"\x1b[D", - b"\x1b[C", - b"\x1b[5~", - b"\x1b[6~", - b"\x1b[H", - b"\x1b[F", - b"r", - b"p", - ] { - let frames = process - .output - .windows(6) - .filter(|bytes| *bytes == b"\x1b[?25l") - .count(); - process.keys(key); - let deadline = Instant::now() + Duration::from_secs(5); - while process - .output - .windows(6) - .filter(|bytes| *bytes == b"\x1b[?25l") - .count() - == frames - { - process.pump(); - assert!(Instant::now() < deadline, "input did not redraw: {key:?}"); - } - } - process.keys(b"q"); - process.finish(true); - - for action in [None, Some(b"\x03".as_slice()), Some(b"signal".as_slice())] { - let mut process = - TuiProcess::start(tui(&target, if action.is_none() { "100ms" } else { "0" })); - process.await_text("active"); - match action { - Some(b"signal") => process.signal(Signal::SIGTERM), - Some(keys) => process.keys(keys), - None => {} - } - process.finish(true); - } - // An adverse early-close proxy forwards the real Rust server's OPEN and - // echo bytes. Only the documented CLOSE flag is added to the first echo. - // This exercises the application's continuous peer-close exit policy. - std::thread::scope(|scope| { - use irtt_proto::{decode_request, DecodedRequestKind, FLAG_CLOSE}; - use std::net::UdpSocket; - let proxy = UdpSocket::bind("127.0.0.1:0").unwrap(); - let proxy_addr = proxy.local_addr().unwrap(); - proxy - .set_read_timeout(Some(Duration::from_secs(2))) - .unwrap(); - let upstream = UdpSocket::bind("127.0.0.1:0").unwrap(); - upstream.connect(server.addr).unwrap(); - upstream - .set_read_timeout(Some(Duration::from_secs(2))) - .unwrap(); - let peer = scope.spawn(move || { - let mut packet = [0; 65536]; - loop { - let (count, client) = proxy.recv_from(&mut packet).unwrap(); - let echo = matches!( - decode_request(&packet[..count]).unwrap().kind, - DecodedRequestKind::Echo { .. } - ); - upstream.send(&packet[..count]).unwrap(); - let count = upstream.recv(&mut packet).unwrap(); - if echo { - packet[3] |= FLAG_CLOSE; - } - proxy.send_to(&packet[..count], client).unwrap(); - if echo { - break; - } - } - }); - let mut process = TuiProcess::start(tui(&proxy_addr.to_string(), "0")); - process.finish(false); - assert!(String::from_utf8_lossy(&process.output) - .contains("continuous run ended because of peer closure")); - peer.join().unwrap(); - }); - - let mut command = Command::new(std::env::current_exe().unwrap()); - command - .args(["--exact", "cancelled_frontend_fixture", "--nocapture"]) - .env("IRTT_TUI_CANCEL_TARGET", &target); - TuiProcess::start(command).finish(true); - - // Opening failure must be reported and restore the terminal too. - let mut process = TuiProcess::start(tui("127.0.0.1:99999", "100ms")); - process.finish(false); - assert!(String::from_utf8_lossy(&process.output) - .contains("no managed target completed successfully")); -} - -// The parent runs this fixture in its own PTY. Dropping the -// actual frontend future must restore that terminal and join measurement. -#[test] -fn cancelled_frontend_fixture() { - let Ok(target) = std::env::var("IRTT_TUI_CANCEL_TARGET") else { - return; - }; - use clap::Parser; - use std::{ - future::{poll_fn, Future}, - task::Poll, - }; - let args = irtt_app::cmd::tui::TuiArgs::parse_from(["irtt-tui", "--duration", "0", &target]); - let (_shutdown, receiver) = tokio::sync::watch::channel(false); - tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .unwrap() - .block_on(async { - let mut frontend = Box::pin(irtt_app::cmd::tui::run_tui(args, receiver)); - poll_fn(|cx| { - assert!(frontend.as_mut().poll(cx).is_pending()); - Poll::Ready(()) - }) - .await; - drop(frontend); - }); -}