#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]
use std::time::{Duration, Instant};
use libtmux::test::TestServer;
use tokio_util::sync::CancellationToken;
mod support;
use support::{args, bare_tools, json, prompt_ready};
#[tokio::test]
async fn s1_a_pending_answer_never_hides_real_output_containing_it() {
let guard = TestServer::builder().start().await.expect("tmux starts");
let tools = bare_tools(guard.server());
tools
.create_session(args(serde_json::json!({"name": "s1"})))
.await
.expect("session starts");
let pane = json(tools.list_panes().await.expect("panes"))["panes"][0]["id"]
.as_str()
.expect("pane id")
.to_owned();
prompt_ready(guard.server(), &pane).await;
let started = Instant::now();
tools
.send_keys(args(serde_json::json!({
"pane": pane,
"text": "(sleep 1; echo ready) &",
"enter": true
})))
.await
.expect("the background job starts");
tools
.send_keys(args(serde_json::json!({
"pane": pane,
"text": "y",
"enter": false
})))
.await
.expect("the pending keystroke is typed");
let view = json(
tools
.wait_for_text(
args(serde_json::json!({
"pane": pane,
"patterns": ["ready"],
"seconds": 5
})),
CancellationToken::new(),
tmux_mcp::Reporter::none(),
)
.await
.expect("wait completes"),
);
assert_eq!(view["outcome"], "matched", "{view}");
assert!(
started.elapsed() >= Duration::from_millis(800),
"matched after only {:?}, too fast to be the background job's real output",
started.elapsed()
);
guard.shutdown().await.expect("tmux fixture shuts down");
}
#[tokio::test]
async fn s2_after_a_submitted_commands_echo_is_not_the_match() {
let guard = TestServer::builder().start().await.expect("tmux starts");
let tools = bare_tools(guard.server());
tools
.create_session(args(serde_json::json!({"name": "s2-after"})))
.await
.expect("session starts");
let pane = json(tools.list_panes().await.expect("panes"))["panes"][0]["id"]
.as_str()
.expect("pane id")
.to_owned();
prompt_ready(guard.server(), &pane).await;
tools
.send_keys(args(serde_json::json!({
"pane": pane,
"text": "sleep 1; echo MARKER",
"enter": true
})))
.await
.expect("the command is sent and this call returns only once it is");
let started = Instant::now();
let view = json(
tools
.wait_for_text(
args(serde_json::json!({
"pane": pane,
"patterns": ["MARKER"],
"seconds": 5
})),
CancellationToken::new(),
tmux_mcp::Reporter::none(),
)
.await
.expect("wait completes"),
);
assert_eq!(
view["outcome"], "matched",
"must not time out over a masked echo either: {view}"
);
assert!(
started.elapsed() >= Duration::from_millis(800),
"matched after only {:?}, too fast to be sleep 1's real output -- the echo, not the \
output, was matched",
started.elapsed()
);
assert!(
view["text"]
.as_str()
.unwrap_or_default()
.lines()
.any(|line| line.trim() == "MARKER"),
"the bare output line must be in the reported text: {view}"
);
guard.shutdown().await.expect("tmux fixture shuts down");
}
#[tokio::test]
async fn s3_unsubmitted_text_is_pending_not_matched() {
let guard = TestServer::builder().start().await.expect("tmux starts");
let tools = bare_tools(guard.server());
tools
.create_session(args(serde_json::json!({"name": "s3"})))
.await
.expect("session starts");
let pane = json(tools.list_panes().await.expect("panes"))["panes"][0]["id"]
.as_str()
.expect("pane id")
.to_owned();
prompt_ready(guard.server(), &pane).await;
tools
.send_keys(args(serde_json::json!({
"pane": pane,
"text": "echo MARKER",
"enter": false
})))
.await
.expect("the line is typed but not submitted");
let started = Instant::now();
let view = json(
tools
.wait_for_text(
args(serde_json::json!({
"pane": pane,
"patterns": ["MARKER"],
"seconds": 1
})),
CancellationToken::new(),
tmux_mcp::Reporter::none(),
)
.await
.expect("wait completes"),
);
assert_eq!(view["outcome"], "pending", "{view}");
assert!(
started.elapsed() < Duration::from_millis(500),
"an unsubmitted pattern must not wait out the deadline: {:?}",
started.elapsed()
);
guard.shutdown().await.expect("tmux fixture shuts down");
}
#[tokio::test]
async fn s4_edits_are_applied_before_a_line_is_submitted() {
let guard = TestServer::builder().start().await.expect("tmux starts");
let tools = bare_tools(guard.server());
tools
.create_session(args(serde_json::json!({"name": "s4"})))
.await
.expect("session starts");
let pane = json(tools.list_panes().await.expect("panes"))["panes"][0]["id"]
.as_str()
.expect("pane id")
.to_owned();
prompt_ready(guard.server(), &pane).await;
tools
.send_keys(args(serde_json::json!({
"pane": pane,
"text": "xMARKER",
"enter": false
})))
.await
.expect("the typo is typed");
let backspaces: Vec<&str> = vec!["BSpace"; 7];
tools
.send_keys(args(serde_json::json!({
"pane": pane,
"keys": backspaces
})))
.await
.expect("the typo is erased in a separate call");
tools
.send_keys(args(serde_json::json!({
"pane": pane,
"text": "echo MARKER",
"enter": true
})))
.await
.expect("the corrected line is submitted");
let view = json(
tools
.wait_for_text(
args(serde_json::json!({
"pane": pane,
"patterns": ["MARKER"],
"seconds": 5
})),
CancellationToken::new(),
tmux_mcp::Reporter::none(),
)
.await
.expect("wait completes"),
);
assert!(
matches!(
view["outcome"].as_str(),
Some("matched" | "present_at_entry")
),
"{view}"
);
guard.shutdown().await.expect("tmux fixture shuts down");
}
#[tokio::test]
async fn s5_unsubmitted_type_ahead_into_a_cold_shell_is_never_matched() {
let guard = TestServer::builder().start().await.expect("tmux starts");
guard
.server()
.cmd(
libtmux::Command::new("set-option")
.arg("-g")
.arg("default-command")
.arg("stty raw -echo; sleep 0.4; exec cat"),
)
.await
.expect("the cold-shell fixture command is set");
let tools = bare_tools(guard.server());
tools
.create_session(args(serde_json::json!({"name": "s5"})))
.await
.expect("session starts");
let pane = json(tools.list_panes().await.expect("panes"))["panes"][0]["id"]
.as_str()
.expect("pane id")
.to_owned();
let queued = libtmux::test::retry_until(Duration::from_secs(5), async || {
tools
.send_keys(args(serde_json::json!({
"pane": pane,
"text": "MARKER",
"enter": false
})))
.await
.is_ok()
})
.await;
assert!(queued.is_ok(), "type-ahead is queued");
let view = json(
tools
.wait_for_text(
args(serde_json::json!({
"pane": pane,
"patterns": ["MARKER"],
"seconds": 2
})),
CancellationToken::new(),
tmux_mcp::Reporter::none(),
)
.await
.expect("wait completes"),
);
assert_ne!(
view["outcome"], "matched",
"unsubmitted type-ahead must never read as a match: {view}"
);
guard.shutdown().await.expect("tmux fixture shuts down");
}
#[tokio::test]
async fn s6_an_unrecognized_key_stops_discounting_the_line() {
let guard = TestServer::builder().start().await.expect("tmux starts");
guard
.server()
.cmd(
libtmux::Command::new("set-option")
.arg("-g")
.arg("default-command")
.arg("/bin/bash --noprofile --norc"),
)
.await
.expect("bash is set as the pane's command");
let tools = bare_tools(guard.server());
tools
.create_session(args(serde_json::json!({"name": "s6"})))
.await
.expect("session starts");
let pane = json(tools.list_panes().await.expect("panes"))["panes"][0]["id"]
.as_str()
.expect("pane id")
.to_owned();
prompt_ready(guard.server(), &pane).await;
tools
.send_keys(args(serde_json::json!({
"pane": pane,
"text": "MARKER",
"enter": false
})))
.await
.expect("the line is typed");
tools
.send_keys(args(serde_json::json!({
"pane": pane,
"keys": ["Left"]
})))
.await
.expect("an unmodelable key is sent");
tools
.send_keys(args(serde_json::json!({
"pane": pane,
"keys": ["Enter"]
})))
.await
.expect("the line is submitted");
let view = json(
tools
.wait_for_text(
args(serde_json::json!({
"pane": pane,
"patterns": ["MARKER"],
"seconds": 3
})),
CancellationToken::new(),
tmux_mcp::Reporter::none(),
)
.await
.expect("wait completes"),
);
assert!(
matches!(
view["outcome"].as_str(),
Some("matched" | "present_at_entry")
),
"an unrecognized key must stop discounting the line rather than keep hiding a real \
error message that happens to repeat it: {view}"
);
guard.shutdown().await.expect("tmux fixture shuts down");
}
#[tokio::test]
async fn a_resize_between_submit_and_output_does_not_defeat_the_mask() {
let guard = TestServer::builder().start().await.expect("tmux starts");
let tools = bare_tools(guard.server());
tools
.create_session(args(serde_json::json!({"name": "resize"})))
.await
.expect("session starts");
let pane = json(tools.list_panes().await.expect("panes"))["panes"][0]["id"]
.as_str()
.expect("pane id")
.to_owned();
prompt_ready(guard.server(), &pane).await;
tools
.send_keys(args(serde_json::json!({
"pane": pane,
"text": "sleep 1; echo MARKER",
"enter": true
})))
.await
.expect("the command is sent");
guard
.server()
.cmd(
libtmux::Command::new("resize-window")
.arg("-t")
.arg(&pane)
.arg("-x")
.arg("50")
.arg("-y")
.arg("20"),
)
.await
.expect("the window resizes");
let started = Instant::now();
let view = json(
tools
.wait_for_text(
args(serde_json::json!({
"pane": pane,
"patterns": ["MARKER"],
"seconds": 5
})),
CancellationToken::new(),
tmux_mcp::Reporter::none(),
)
.await
.expect("wait completes"),
);
assert_eq!(view["outcome"], "matched", "{view}");
assert!(
started.elapsed() >= Duration::from_millis(800),
"matched after only {:?}, too fast to be real output",
started.elapsed()
);
guard.shutdown().await.expect("tmux fixture shuts down");
}
#[tokio::test]
async fn s2_pasted_text_with_enter_is_not_the_match() {
let guard = TestServer::builder().start().await.expect("tmux starts");
let tools = bare_tools(guard.server());
tools
.create_session(args(serde_json::json!({"name": "s2-paste"})))
.await
.expect("session starts");
let pane = json(tools.list_panes().await.expect("panes"))["panes"][0]["id"]
.as_str()
.expect("pane id")
.to_owned();
prompt_ready(guard.server(), &pane).await;
tools
.paste_text(args(serde_json::json!({
"pane": pane,
"text": "sleep 1; echo MARKER",
"enter": true
})))
.await
.expect("the command is pasted and this call returns only once it is");
let started = Instant::now();
let view = json(
tools
.wait_for_text(
args(serde_json::json!({
"pane": pane,
"patterns": ["MARKER"],
"seconds": 5
})),
CancellationToken::new(),
tmux_mcp::Reporter::none(),
)
.await
.expect("wait completes"),
);
assert_eq!(
view["outcome"], "matched",
"must not time out over a masked echo either: {view}"
);
assert!(
started.elapsed() >= Duration::from_millis(800),
"matched after only {:?}, too fast to be sleep 1's real output -- the paste's own echo, \
not the output, was matched",
started.elapsed()
);
assert!(
view["text"]
.as_str()
.unwrap_or_default()
.lines()
.any(|line| line.trim() == "MARKER"),
"the bare output line must be in the reported text: {view}"
);
guard.shutdown().await.expect("tmux fixture shuts down");
}
#[tokio::test]
async fn s2_run_shell_commands_dispatch_line_is_not_the_match() {
let guard = TestServer::builder().start().await.expect("tmux starts");
let tools = bare_tools(guard.server());
tools
.create_session(args(serde_json::json!({"name": "s2-run"})))
.await
.expect("session starts");
let pane = json(tools.list_panes().await.expect("panes"))["panes"][0]["id"]
.as_str()
.expect("pane id")
.to_owned();
prompt_ready(guard.server(), &pane).await;
guard
.server()
.cmd(
libtmux::Command::new("resize-window")
.arg("-t")
.arg(&pane)
.arg("-x")
.arg("120")
.arg("-y")
.arg("24"),
)
.await
.expect("the window widens past the staged frame line's length");
let finished = json(
tools
.run_command(
args(serde_json::json!({
"pane": pane,
"command": "printf done",
"seconds": 5
})),
CancellationToken::new(),
tmux_mcp::Reporter::none(),
)
.await
.expect("the command runs"),
);
assert_eq!(finished["outcome"], "completed", "{finished}");
let echo = json(
tools
.wait_for_text(
args(serde_json::json!({"pane": pane, "patterns": ["eval"], "seconds": 1})),
CancellationToken::new(),
tmux_mcp::Reporter::none(),
)
.await
.expect("wait completes"),
);
assert!(
!matches!(
echo["outcome"].as_str(),
Some("matched" | "present_at_entry")
),
"run_shell_command's own dispatch line must not read as a match: {echo}"
);
let output = json(
tools
.wait_for_text(
args(serde_json::json!({"pane": pane, "patterns": ["done"], "seconds": 1})),
CancellationToken::new(),
tmux_mcp::Reporter::none(),
)
.await
.expect("wait completes"),
);
assert_eq!(
output["outcome"], "present_at_entry",
"the command's real output must still be found: {output}"
);
guard.shutdown().await.expect("tmux fixture shuts down");
}