#![cfg(all(feature = "control-mode", feature = "test-support"))]
use std::time::Duration;
use libtmux::control::{ControlEvents, ControlMode, ControlSender, Event};
use libtmux::test::TestServer;
use libtmux::{Command, NewWindowOptions};
use static_assertions::assert_impl_all;
use tokio_stream::StreamExt as _;
assert_impl_all!(ControlSender: Clone, Send, Sync, Unpin);
assert_impl_all!(ControlEvents: Send, Sync, Unpin, futures_core::Stream);
assert_impl_all!(ControlMode: Send, Sync, Unpin);
async fn wait_for(
events: &mut ControlEvents,
mut wanted: impl FnMut(&Event) -> bool,
) -> Option<Event> {
let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
loop {
match tokio::time::timeout_at(deadline, events.next_event()).await {
Ok(Some(event)) if wanted(&event) => return Some(event),
Ok(Some(_)) => {}
Ok(None) | Err(_) => return None,
}
}
}
#[tokio::test]
async fn commands_travel_down_one_connection() {
let guard = TestServer::builder().start().await.expect("tmux starts");
let server = guard.server();
let session = server.new_session("control").await.expect("session");
let control = ControlMode::attach(server, session.id())
.await
.expect("control mode attaches");
let listed = control
.send(Command::new("list-windows").arg("-F").arg("#{window_name}"))
.await
.expect("the command is answered");
assert!(listed.succeeded());
assert_eq!(listed.output().len(), 1);
let refused = control
.send(Command::new("kill-window").arg("-t").arg("@999"))
.await
.expect("the command is answered");
assert!(!refused.succeeded(), "tmux closed the block with %error");
assert!(!refused.output().is_empty(), "the reason is preserved");
assert_ne!(listed.number(), refused.number());
control.shutdown().await.expect("control mode shuts down");
guard.shutdown().await.expect("tmux fixture shuts down");
}
#[tokio::test]
async fn the_server_reports_changes_as_they_happen() {
let guard = TestServer::builder().start().await.expect("tmux starts");
let server = guard.server();
let session = server.new_session("watched").await.expect("session");
let (commands, mut events) = ControlMode::attach(server, session.id())
.await
.expect("control mode attaches")
.split();
session
.new_window(NewWindowOptions::new("appeared").command("sleep 300"))
.await
.expect("window is created");
let reported = wait_for(
&mut events,
|event| matches!(event, Event::Other { name, .. } if name.starts_with("window-")),
)
.await;
assert!(
reported.is_some(),
"a window appearing is reported without polling",
);
let listed = commands
.send(Command::new("list-windows").arg("-F").arg("#{window_name}"))
.await
.expect("a command sent in reaction to an event is answered");
assert!(listed.succeeded());
assert_eq!(listed.output().len(), 2);
drop(commands);
events.shutdown().await.expect("control mode shuts down");
guard.shutdown().await.expect("tmux fixture shuts down");
}
#[tokio::test]
async fn watching_and_sending_run_at_the_same_time() {
let guard = TestServer::builder().start().await.expect("tmux starts");
let server = guard.server();
let session = server.new_session("concurrent").await.expect("session");
let (commands, mut events) = ControlMode::attach(server, session.id())
.await
.expect("control mode attaches")
.split();
let sender = commands.clone();
let watcher = tokio::spawn(async move {
wait_for(
&mut events,
|event| matches!(event, Event::Other { name, .. } if name.starts_with("window-")),
)
.await
.map(|_| events)
});
let created = sender
.send(
Command::new("new-window")
.arg("-d")
.arg("-n")
.arg("concurrent")
.arg("sleep 300"),
)
.await
.expect("the command is answered");
assert!(created.succeeded());
let events = watcher
.await
.expect("the watcher task finishes")
.expect("the watcher saw the window the sender created");
drop((commands, sender));
events.shutdown().await.expect("control mode shuts down");
guard.shutdown().await.expect("tmux fixture shuts down");
}
#[tokio::test]
async fn pane_output_keeps_bytes_no_string_would_hold() {
let guard = TestServer::builder().start().await.expect("tmux starts");
let server = guard.server();
let session = server.new_session("binary").await.expect("session");
let (commands, mut events) = ControlMode::attach(server, session.id())
.await
.expect("control mode attaches")
.split();
commands
.send(
Command::new("new-window")
.arg("-d")
.arg("-n")
.arg("emitting")
.arg(r"printf '\377\303\050'; sleep 300"),
)
.await
.expect("the command is answered");
let output = wait_for(
&mut events,
|event| matches!(event, Event::Output { bytes, .. } if bytes.contains(&0xff)),
)
.await
.expect("the pane's output arrives");
let Event::Output { bytes, .. } = output else {
panic!("the matched event is pane output");
};
let start = bytes
.windows(3)
.position(|window| window == [0xff, 0xc3, b'('])
.expect("the exact bytes the pane wrote are preserved");
assert_eq!(&bytes[start..start + 3], [0xff, 0xc3, b'(']);
drop(commands);
events.shutdown().await.expect("control mode shuts down");
guard.shutdown().await.expect("tmux fixture shuts down");
}
#[tokio::test]
async fn events_compose_with_the_async_ecosystem() {
let guard = TestServer::builder().start().await.expect("tmux starts");
let server = guard.server();
let session = server.new_session("streamed").await.expect("session");
let (commands, events) = ControlMode::attach(server, session.id())
.await
.expect("control mode attaches")
.split();
session
.new_window(NewWindowOptions::new("streamed").command("sleep 300"))
.await
.expect("window is created");
let named = events
.timeout(Duration::from_secs(10))
.filter_map(Result::ok)
.filter(|event| matches!(event, Event::Other { name, .. } if name.starts_with("window-")));
let mut named = std::pin::pin!(named);
assert!(
named.next().await.is_some(),
"the stream yields the window notification",
);
drop(commands);
guard.shutdown().await.expect("tmux fixture shuts down");
}
#[tokio::test]
async fn attaching_finishes_before_anything_can_be_missed() {
let guard = TestServer::builder().start().await.expect("tmux starts");
let server = guard.server();
let session = server.new_session("racing").await.expect("session");
for round in 0..20 {
let (commands, mut events) = ControlMode::attach(server, session.id())
.await
.expect("control mode attaches")
.split();
let name = format!("round-{round}");
session
.new_window(NewWindowOptions::new(name.as_str()).command("sleep 300"))
.await
.expect("window is created");
assert!(
wait_for(&mut events, |event| {
matches!(event, Event::Other { name, .. } if name.starts_with("window-"))
})
.await
.is_some(),
"round {round}: the window that appeared right after attaching is reported",
);
drop(commands);
events.shutdown().await.expect("control mode shuts down");
}
guard.shutdown().await.expect("tmux fixture shuts down");
}
#[tokio::test]
async fn either_half_keeps_the_connection_alive_on_its_own() {
let guard = TestServer::builder().start().await.expect("tmux starts");
let server = guard.server();
let session = server.new_session("halves").await.expect("session");
let (commands, mut events) = ControlMode::attach(server, session.id())
.await
.expect("control mode attaches")
.split();
drop(commands);
session
.new_window(NewWindowOptions::new("watched").command("sleep 300"))
.await
.expect("window is created");
assert!(
wait_for(&mut events, |event| {
matches!(event, Event::Other { name, .. } if name.starts_with("window-"))
})
.await
.is_some(),
"events still arrive after the sender is gone",
);
events.shutdown().await.expect("control mode shuts down");
let (commands, events) = ControlMode::attach(server, session.id())
.await
.expect("control mode attaches")
.split();
drop(events);
assert!(
commands
.send(Command::new("list-windows"))
.await
.expect("the connection outlives the events handle")
.succeeded(),
);
guard.shutdown().await.expect("tmux fixture shuts down");
}
#[tokio::test]
async fn shutting_down_does_not_wait_for_a_sender_that_is_still_alive() {
let guard = TestServer::builder().start().await.expect("tmux starts");
let server = guard.server();
let session = server.new_session("stopping").await.expect("session");
let (commands, events) = ControlMode::attach(server, session.id())
.await
.expect("control mode attaches")
.split();
tokio::time::timeout(Duration::from_secs(5), events.shutdown())
.await
.expect("shutdown does not wait on the sender")
.expect("control mode shuts down");
assert!(
commands.send(Command::new("list-windows")).await.is_err(),
"a command sent after shutdown fails rather than hangs",
);
guard.shutdown().await.expect("tmux fixture shuts down");
}
#[tokio::test]
async fn one_pane_can_be_watched_without_the_protocol_showing() {
use libtmux::SplitDirection;
use libtmux::SplitOptions;
use libtmux::control::PaneOutput;
let guard = TestServer::builder().start().await.expect("tmux starts");
let server = guard.server();
let session = server.new_session("streaming").await.expect("session");
let window = session
.try_windows()
.await
.expect("windows")
.into_iter()
.next()
.expect("one window");
let watched = window
.split(
SplitOptions::new(SplitDirection::Below)
.command(r"while true; do printf 'watched '; sleep 0.1; done"),
)
.await
.expect("pane is created");
window
.split(
SplitOptions::new(SplitDirection::Right)
.command(r"while true; do printf 'ignored '; sleep 0.1; done"),
)
.await
.expect("pane is created");
let mut output: PaneOutput = watched.stream_output().await.expect("the pane streams");
assert_eq!(output.pane(), watched.id());
let mut collected = Vec::new();
while collected.len() < 64 {
let chunk = tokio::time::timeout(Duration::from_secs(10), output.next_chunk())
.await
.expect("the pane keeps writing")
.expect("the pane is still open");
collected.extend_from_slice(&chunk);
}
let seen = String::from_utf8_lossy(&collected);
assert!(
seen.contains("watched"),
"the watched pane's output: {seen}"
);
assert!(
!seen.contains("ignored"),
"the other pane's output is filtered out: {seen}",
);
output.shutdown().await.expect("the connection shuts down");
guard.shutdown().await.expect("tmux fixture shuts down");
}
#[tokio::test]
async fn a_command_holding_spaces_survives_the_text_protocol() {
let guard = TestServer::builder().start().await.expect("tmux starts");
let server = guard.server();
let session = server.new_session("quoting").await.expect("session");
let control = ControlMode::attach(server, session.id())
.await
.expect("control mode attaches");
let result = control
.send(
Command::new("set-option")
.arg("-s")
.arg("@spaced")
.arg("a b c"),
)
.await
.expect("the command is answered");
assert!(result.succeeded(), "tmux parsed the quoted token");
assert_eq!(
server
.get_option("@spaced")
.await
.expect("read")
.expect("the option is set"),
"a b c",
);
control.shutdown().await.expect("control mode shuts down");
guard.shutdown().await.expect("tmux fixture shuts down");
}
#[tokio::test]
async fn a_command_that_cannot_be_a_line_is_refused_before_it_is_sent() {
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt as _;
let guard = TestServer::builder().start().await.expect("tmux starts");
let server = guard.server();
let session = server
.new_session("unrepresentable")
.await
.expect("session");
let control = ControlMode::attach(server, session.id())
.await
.expect("control mode attaches");
let error = control
.send(
Command::new("set-option")
.arg("-s")
.arg("@binary")
.arg(OsString::from_vec(vec![0xff])),
)
.await
.expect_err("a non-UTF-8 argument cannot be a control-mode line");
assert!(
matches!(
error,
libtmux::Error::ControlMode {
kind: libtmux::ControlModeErrorKind::UnrepresentableCommand,
..
},
),
"the reason is distinguishable from the connection closing: {error:?}",
);
assert!(
control
.send(Command::new("list-windows"))
.await
.expect("the connection still works")
.succeeded(),
);
control.shutdown().await.expect("control mode shuts down");
guard.shutdown().await.expect("tmux fixture shuts down");
}