chore(app): remove unnecessary tests

This commit is contained in:
Nils Andreas Svee committed 2026-10-05 22:04:07 +07:00
1 parent 5067ceeb1c
commit b1b0b62848
6 files changed
-1261

No files matched your search

-38
View File
@@ -228,41 +228,3 @@ fn process_tui_event(
_ => false, _ => 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::<String>();
assert!(text.contains("incomplete:dropped=1"));
assert_eq!(text.contains("display paused"), paused);
}
}
}
}
-226
View File
@@ -1558,229 +1558,3 @@ fn format_optional_hex(value: Option<u64>) -> String {
fn duration_ns(value: Duration) -> i128 { fn duration_ns(value: Duration) -> i128 {
i128::try_from(value.as_nanos()).unwrap_or(i128::MAX) 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::<String>())
.collect::<Vec<_>>()
.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::<Vec<_>>();
// 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);
}
}
@@ -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
);
}
}
-530
View File
@@ -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::<u64>()
.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}");
}
}
@@ -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<oneshot::Sender<()>>,
done: Option<JoinHandle<Result<(), irtt_server::ServerRuntimeError>>>,
}
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();
}
}
-327
View File
@@ -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<u8>,
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);
});
}