chore(app): remove unnecessary tests
This commit is contained in:
6 files changed
-1261
No files matched your search
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Reference in new issue
Block a user