#![cfg(unix)]
mod common;
use std::error::Error;
use std::fs;
use std::time::{Duration, Instant};
use common::{assert_success, read_until_contains, stderr, stdout, AttachedSession, CliHarness};
use rmux_proto::TerminalSize;
#[test]
fn foreground_run_shell_suppresses_stdout_like_tmux() -> Result<(), Box<dyn Error>> {
let harness = CliHarness::new("run-shell-stdout")?;
let _daemon = harness.start_hidden_daemon()?;
let output = harness.run(&["run-shell", "printf hello"])?;
assert_eq!(output.status.code(), Some(0));
assert!(stdout(&output).is_empty());
assert!(stderr(&output).is_empty());
Ok(())
}
#[test]
fn run_shell_exports_tmux_env_matching_mux_socket() -> Result<(), Box<dyn Error>> {
let harness = CliHarness::new("run-shell-tmux-env")?;
let _daemon = harness.start_hidden_daemon()?;
let output_path = harness.tmpdir().join("mux-env.txt");
let command = format!(
"printf '%s\n%s\n' \"$TMUX\" \"$RMUX\" > {}",
shell_quote(&output_path)
);
let output = harness.run(&["run-shell", &command])?;
assert_eq!(output.status.code(), Some(0));
assert!(stdout(&output).is_empty());
assert!(stderr(&output).is_empty());
let rendered = fs::read_to_string(&output_path)?;
let lines = rendered.lines().collect::<Vec<_>>();
assert_eq!(
lines.len(),
2,
"unexpected run-shell env output: {rendered:?}"
);
assert_eq!(lines[0], lines[1]);
let parts = lines[0].split(',').collect::<Vec<_>>();
assert_eq!(
parts.len(),
3,
"TMUX must be <socket>,<pid>,<id>: {}",
lines[0]
);
assert!(!parts[0].is_empty(), "TMUX socket path must be present");
assert!(parts[1].parse::<u32>().is_ok(), "TMUX pid must be numeric");
assert_eq!(parts[2], "0");
Ok(())
}
#[test]
fn run_shell_exports_parseable_tmux_program_shim() -> Result<(), Box<dyn Error>> {
let harness = CliHarness::new("run-shell-tmux-program")?;
let _daemon = harness.start_hidden_daemon()?;
let output_path = harness.tmpdir().join("tmux-version.txt");
let command = format!("\"$TMUX_PROGRAM\" -V > {}", shell_quote(&output_path));
let output = harness.run(&["run-shell", &command])?;
assert_eq!(output.status.code(), Some(0));
assert!(stdout(&output).is_empty());
let rendered = fs::read_to_string(output_path)?;
assert!(
rendered.starts_with("tmux "),
"unexpected tmux shim version output: {rendered:?}"
);
assert!(stderr(&output).is_empty());
Ok(())
}
#[test]
fn run_shell_nonzero_preserves_exit_status_without_stdout() -> Result<(), Box<dyn Error>> {
let harness = CliHarness::new("run-shell-nonzero")?;
let _daemon = harness.start_hidden_daemon()?;
let output = harness.run(&["run-shell", "printf hidden; exit 9"])?;
assert_eq!(output.status.code(), Some(9));
assert!(stdout(&output).is_empty());
assert!(stderr(&output).is_empty());
Ok(())
}
#[test]
fn source_file_run_shell_nonzero_preserves_exit_status_without_stdout() -> Result<(), Box<dyn Error>>
{
let harness = CliHarness::new("source-file-run-shell-nonzero")?;
let _daemon = harness.start_hidden_daemon()?;
let config = harness.tmpdir().join("run-shell-nonzero.conf");
fs::write(&config, "run-shell 'printf hidden; exit 7'\n")?;
let output = harness.run(&["source-file", config.to_str().expect("utf-8 config path")])?;
assert_eq!(output.status.code(), Some(7));
assert!(stdout(&output).is_empty());
assert!(stderr(&output).is_empty());
Ok(())
}
#[test]
fn source_file_commands_follow_implicit_selected_window_context() -> Result<(), Box<dyn Error>> {
let harness = CliHarness::new("source-file-implicit-window-context")?;
let _daemon = harness.start_hidden_daemon()?;
assert_success(&harness.run(&["new-session", "-d", "-s", "a"])?);
let config = harness.tmpdir().join("implicit-window-context.conf");
fs::write(
&config,
"new-window -d -t a:1 -n w1\nselect-window -t a:1\nmove-window -t a:3\n",
)?;
let output = harness.run(&["source-file", config.to_str().expect("utf-8 config path")])?;
assert_success(&output);
let windows = harness.run(&[
"list-windows",
"-t",
"a",
"-F",
"#{window_index}:#{window_active}:#{window_name}",
])?;
let listed = stdout(&windows);
let lines = listed.lines().collect::<Vec<_>>();
assert_eq!(lines.len(), 2);
assert!(
lines[0].starts_with("0:0:"),
"initial shell window should remain inactive: {listed:?}"
);
assert_eq!(lines[1], "3:1:w1");
assert!(stderr(&windows).is_empty());
Ok(())
}
#[test]
fn run_shell_dash_e_is_rejected_like_tmux() -> Result<(), Box<dyn Error>> {
let harness = CliHarness::new("run-shell-stderr")?;
let _daemon = harness.start_hidden_daemon()?;
let output = harness.run(&["run-shell", "-E", "printf err >&2"])?;
assert_eq!(output.status.code(), Some(1));
assert!(stdout(&output).is_empty());
assert_eq!(stderr(&output), "command run-shell: unknown flag -E\n");
Ok(())
}
#[test]
fn run_shell_stdout_is_drained_without_cli_output() -> Result<(), Box<dyn Error>> {
let harness = CliHarness::new("run-shell-stdout-cap")?;
let _daemon = harness.start_hidden_daemon()?;
let command = "dd if=/dev/zero bs=1049600 count=1 2>/dev/null | tr '\\000' A";
let output = harness.run(&["run-shell", command])?;
assert_eq!(output.status.code(), Some(0));
assert!(stdout(&output).is_empty());
assert!(stderr(&output).is_empty());
Ok(())
}
#[test]
fn run_shell_preserves_spaced_path_arguments() -> Result<(), Box<dyn Error>> {
let harness = CliHarness::new("run-shell-spaced-path")?;
let _daemon = harness.start_hidden_daemon()?;
let spaced_path = harness.tmpdir().join("name with spaces");
let output = harness.run(&[
"run-shell",
"env",
"-C",
harness.tmpdir().to_str().expect("utf-8 test path"),
"touch",
"name with spaces",
])?;
assert_eq!(output.status.code(), Some(0));
assert!(stderr(&output).is_empty());
assert!(spaced_path.is_file());
assert!(!harness.tmpdir().join("name").exists());
assert!(!harness.tmpdir().join("with").exists());
assert!(!harness.tmpdir().join("spaces").exists());
Ok(())
}
#[test]
fn run_shell_preserves_shell_metacharacters_and_backslashes() -> Result<(), Box<dyn Error>> {
let harness = CliHarness::new("run-shell-metacharacters")?;
let _daemon = harness.start_hidden_daemon()?;
let output_path = harness.tmpdir().join("metacharacters.txt");
let command = format!("printf '%s' 'x;y\\z' > {}", shell_quote(&output_path));
let output = harness.run(&["run-shell", &command])?;
assert_eq!(output.status.code(), Some(0));
assert!(stdout(&output).is_empty());
assert!(stderr(&output).is_empty());
assert_eq!(fs::read_to_string(output_path)?, "x;y\\z");
Ok(())
}
#[test]
fn if_shell_dispatches_nested_supported_command() -> Result<(), Box<dyn Error>> {
let harness = CliHarness::new("if-shell-dispatch")?;
let _daemon = harness.start_hidden_daemon()?;
assert_success(&harness.run(&[
"if-shell",
"-F",
"1",
"set-buffer -b selected yes",
"set-buffer -b selected no",
])?);
let output = harness.run(&["show-buffer", "-b", "selected"])?;
assert_eq!(output.status.code(), Some(0));
assert_eq!(stdout(&output), "yes");
assert!(stderr(&output).is_empty());
Ok(())
}
#[test]
fn nested_commands_report_missing_session_without_invalid_target_wrapper(
) -> Result<(), Box<dyn Error>> {
let harness = CliHarness::new("nested-missing-session-error")?;
let _daemon = harness.start_hidden_daemon()?;
let run_shell = harness.run(&["run-shell", "-C", "has-session -t missing"])?;
assert_eq!(run_shell.status.code(), Some(1));
assert!(stdout(&run_shell).is_empty());
assert_eq!(stderr(&run_shell), "can't find session: missing\n");
let if_shell = harness.run(&["if-shell", "-F", "1", "has-session -t missing"])?;
assert_eq!(if_shell.status.code(), Some(1));
assert!(stdout(&if_shell).is_empty());
assert_eq!(stderr(&if_shell), "can't find session: missing\n");
Ok(())
}
#[test]
fn run_shell_commands_use_explicit_target_for_implicit_nested_target() -> Result<(), Box<dyn Error>>
{
let harness = CliHarness::new("run-shell-c-explicit-target")?;
let _daemon = harness.start_hidden_daemon()?;
assert_success(&harness.run(&["new-session", "-d", "-s", "alpha"])?);
assert_success(&harness.run(&["new-session", "-d", "-s", "beta"])?);
assert_success(&harness.run(&["run-shell", "-t", "alpha:0.0", "-C", "split-window -h"])?);
let panes = harness.run(&["list-panes", "-a", "-F", "#{session_name}:#{pane_index}"])?;
assert_eq!(panes.status.code(), Some(0), "stderr={}", stderr(&panes));
assert!(stderr(&panes).is_empty());
let panes = stdout(&panes);
assert!(
panes.lines().any(|line| line == "alpha:1"),
"run-shell -C -t should use the explicit target, got:\n{panes}"
);
assert!(
!panes.lines().any(|line| line == "beta:1"),
"run-shell -C -t should not ignore the explicit target, got:\n{panes}"
);
Ok(())
}
#[test]
fn prompts_without_attached_client_report_no_current_client() -> Result<(), Box<dyn Error>> {
let harness = CliHarness::new("prompt-no-current-client")?;
let _daemon = harness.start_hidden_daemon()?;
assert_success(&harness.run(&["new-session", "-d", "-s", "alpha"])?);
let command_prompt = harness.run(&["command-prompt", "-p", "prompt", "display-message %1"])?;
assert_eq!(command_prompt.status.code(), Some(1));
assert!(stdout(&command_prompt).is_empty());
assert_eq!(stderr(&command_prompt), "no current client\n");
let confirm_before = harness.run(&["confirm-before", "-p", "sure", "display-message ok"])?;
assert_eq!(confirm_before.status.code(), Some(1));
assert!(stdout(&confirm_before).is_empty());
assert_eq!(stderr(&confirm_before), "no current client\n");
Ok(())
}
#[test]
fn if_shell_preserves_nested_stdout_from_output_commands() -> Result<(), Box<dyn Error>> {
let harness = CliHarness::new("if-shell-output")?;
let _daemon = harness.start_hidden_daemon()?;
let marker = "if_shell_capture_marker";
assert_success(&harness.run(&["new-session", "-d", "-s", "alpha"])?);
assert_success(&harness.run(&["set-buffer", "-b", "selected", "yes"])?);
let display = harness.run(&[
"if-shell",
"-F",
"-t",
"alpha:0.0",
"1",
"display-message -p -t alpha:0.0 #{session_name}",
])?;
assert_eq!(display.status.code(), Some(0));
assert_eq!(stdout(&display), "alpha\n");
assert!(stderr(&display).is_empty());
let show_buffer = harness.run(&["if-shell", "-F", "1", "show-buffer -b selected"])?;
assert_eq!(show_buffer.status.code(), Some(0));
assert_eq!(stdout(&show_buffer), "yes");
assert!(stderr(&show_buffer).is_empty());
let list_sessions =
harness.run(&["if-shell", "-F", "1", "list-sessions -F #{session_name}"])?;
assert_eq!(list_sessions.status.code(), Some(0));
assert_eq!(stdout(&list_sessions), "alpha\n");
assert!(stderr(&list_sessions).is_empty());
assert_success(&harness.run(&[
"send-keys",
"-t",
"alpha:0.0",
&format!("printf '{marker}\\n'"),
"Enter",
])?);
let capture = wait_for_if_shell_capture(&harness, marker)?;
assert!(stdout(&capture).contains(marker));
assert!(stderr(&capture).is_empty());
Ok(())
}
#[test]
fn if_shell_format_truthiness_matches_tmux_numeric_zero_prefix() -> Result<(), Box<dyn Error>> {
let harness = CliHarness::new("if-shell-format-truthiness")?;
let _daemon = harness.start_hidden_daemon()?;
let leading_zero = harness.run(&[
"if-shell",
"-F",
"09",
"display-message -p TRUE",
"display-message -p FALSE",
])?;
assert_eq!(leading_zero.status.code(), Some(0));
assert_eq!(stdout(&leading_zero), "FALSE\n");
let repeated_zero = harness.run(&[
"if-shell",
"-F",
"00",
"display-message -p TRUE",
"display-message -p FALSE",
])?;
assert_eq!(repeated_zero.status.code(), Some(0));
assert_eq!(stdout(&repeated_zero), "FALSE\n");
let exact_zero = harness.run(&[
"if-shell",
"-F",
"0",
"display-message -p TRUE",
"display-message -p FALSE",
])?;
assert_eq!(exact_zero.status.code(), Some(0));
assert_eq!(stdout(&exact_zero), "FALSE\n");
Ok(())
}
#[test]
fn if_shell_nested_run_shell_preserves_spaced_path_arguments() -> Result<(), Box<dyn Error>> {
let harness = CliHarness::new("if-shell-run-shell-spaced-path")?;
let _daemon = harness.start_hidden_daemon()?;
let spaced_path = harness.tmpdir().join("nested name with spaces");
let nested_command = format!(
"run-shell env -C {} touch 'nested name with spaces'",
harness.tmpdir().display()
);
let output = harness.run(&["if-shell", "-F", "1", &nested_command])?;
assert_eq!(output.status.code(), Some(0));
assert!(stderr(&output).is_empty());
assert!(spaced_path.is_file());
assert!(!harness.tmpdir().join("nested").exists());
assert!(!harness.tmpdir().join("name").exists());
assert!(!harness.tmpdir().join("with").exists());
assert!(!harness.tmpdir().join("spaces").exists());
Ok(())
}
#[test]
fn if_shell_nested_run_shell_preserves_shell_metacharacters_and_backslashes(
) -> Result<(), Box<dyn Error>> {
let harness = CliHarness::new("if-shell-run-shell-metacharacters")?;
let _daemon = harness.start_hidden_daemon()?;
let output_path = harness.tmpdir().join("nested-metacharacters.txt");
let shell_command = format!("printf '%s' 'x;y\\z' > {}", shell_quote(&output_path));
let nested_command = format!("run-shell {}", shell_quote_str(&shell_command));
let output = harness.run(&["if-shell", "-F", "1", &nested_command])?;
assert_eq!(output.status.code(), Some(0));
assert!(stdout(&output).is_empty());
assert!(stderr(&output).is_empty());
assert_eq!(fs::read_to_string(output_path)?, "x;y\\z");
Ok(())
}
#[test]
fn source_file_rejects_non_tmux_switch_client_f_flag() -> Result<(), Box<dyn Error>> {
let harness = CliHarness::new("source-file-switch-client-f")?;
let _daemon = harness.start_hidden_daemon()?;
let config = harness.tmpdir().join("unsupported-switch-client.conf");
fs::write(&config, "switch-client -f read-only\n")?;
let output = harness.run(&["source-file", config.to_str().expect("utf-8 config path")])?;
assert_eq!(output.status.code(), Some(1));
assert_eq!(
stdout(&output),
format!(
"{}:1: command switch-client: unknown flag -f\n",
config.display()
)
);
assert!(stderr(&output).is_empty());
Ok(())
}
#[test]
fn source_file_reports_invalid_command_flags_on_stdout_with_line_prefix(
) -> Result<(), Box<dyn Error>> {
let harness = CliHarness::new("source-file-invalid-command-flags")?;
let _daemon = harness.start_hidden_daemon()?;
let config = harness.tmpdir().join("unsupported-command-flags.conf");
fs::write(
&config,
"capture-pane -M\nchoose-tree -y 1\nlist-keys -O name\nlist-keys -Oname\nlist-clients -r\nlist-buffers -O name\n",
)?;
let output = harness.run(&["source-file", config.to_str().expect("utf-8 config path")])?;
assert_eq!(output.status.code(), Some(1));
assert_eq!(
stdout(&output),
format!(
"{}:1: command capture-pane: unknown flag -M\n{}:2: command choose-tree: unknown flag -y\n{}:3: command list-keys: unknown flag -O\n{}:4: command list-keys: unknown flag -O\n{}:5: command list-clients: unknown flag -r\n{}:6: command list-buffers: unknown flag -O\n",
config.display(),
config.display(),
config.display(),
config.display(),
config.display(),
config.display()
)
);
assert!(stderr(&output).is_empty());
Ok(())
}
#[test]
fn source_file_parse_errors_report_input_line_number() -> Result<(), Box<dyn Error>> {
let harness = CliHarness::new("source-file-line-number")?;
let _daemon = harness.start_hidden_daemon()?;
let config = harness.tmpdir().join("bad-line.conf");
fs::write(
&config,
"set-option -g @before ok\nbogus-command\nset-option -g @after ok\n",
)?;
let output = harness.run(&["source-file", config.to_str().expect("utf-8 config path")])?;
assert_eq!(output.status.code(), Some(1));
assert_eq!(
stdout(&output),
format!("{}:2: unknown command: bogus-command\n", config.display())
);
assert!(stderr(&output).is_empty());
let before = harness.run(&["show-options", "-gqv", "@before"])?;
assert!(stdout(&before).is_empty());
let after = harness.run(&["show-options", "-gqv", "@after"])?;
assert!(stdout(&after).is_empty());
Ok(())
}
#[cfg(unix)]
#[test]
fn source_file_parse_only_does_not_run_plugin_shell_commands() -> Result<(), Box<dyn Error>> {
let harness = CliHarness::new("source-file-parse-only-cli")?;
let _daemon = harness.start_hidden_daemon()?;
let config = harness.tmpdir().join("parse-only.conf");
let marker = harness.tmpdir().join("must-not-run");
fs::write(
&config,
format!(
"set -g @parse-only-probe yes\nrun-shell 'touch {}'\n",
shell_quote(&marker)
),
)?;
let output = harness.run(&[
"source-file",
"-n",
"-v",
config.to_str().expect("utf-8 config path"),
])?;
assert_eq!(output.status.code(), Some(0));
assert!(
stdout(&output).contains("set-option -g @parse-only-probe yes"),
"parse-only should report parsed commands, got {:?}",
stdout(&output)
);
assert!(
stdout(&output).contains("run-shell"),
"parse-only should report plugin run-shell commands, got {:?}",
stdout(&output)
);
assert!(stderr(&output).is_empty());
assert!(
!marker.exists(),
"source-file -n must parse config without running plugin shell commands"
);
Ok(())
}
#[test]
fn source_file_parse_only_validates_command_flags_like_tmux() -> Result<(), Box<dyn Error>> {
let harness = CliHarness::new("source-file-parse-only-invalid-cli")?;
let _daemon = harness.start_hidden_daemon()?;
let config = harness.tmpdir().join("bad.conf");
fs::write(&config, "new-window -Q\n")?;
let output = harness.run(&[
"source-file",
"-n",
config.to_str().expect("utf-8 config path"),
])?;
assert_eq!(output.status.code(), Some(1));
assert!(
stdout(&output).contains("command new-window: unknown flag -Q"),
"stdout={:?}",
stdout(&output)
);
assert!(stderr(&output).is_empty());
Ok(())
}
#[cfg(unix)]
#[test]
fn source_file_recursion_through_tmux_shim_is_bounded() -> Result<(), Box<dyn Error>> {
let harness = CliHarness::new("source-file-shim-recursion")?;
let _daemon = harness.start_hidden_daemon()?;
let config = harness.tmpdir().join("self.conf");
fs::write(
&config,
format!("run-shell 'tmux source-file {}'\n", shell_quote(&config)),
)?;
let output = harness.run(&["source-file", config.to_str().expect("utf-8 config path")])?;
assert_eq!(output.status.code(), Some(1));
assert!(stdout(&output).is_empty());
assert!(stderr(&output).is_empty());
Ok(())
}
#[test]
fn startup_config_parse_errors_skip_bad_file_without_aborting_startup() -> Result<(), Box<dyn Error>>
{
let harness = CliHarness::new("startup-config-nonfatal")?;
let config = harness.tmpdir().join("bad-startup.conf");
fs::write(
&config,
"definitely-not-a-command\nset-option -g @after-startup yes\n",
)?;
let output = harness.run(&[
"-f",
config.to_str().expect("utf-8 config path"),
"new-session",
"-d",
"-s",
"boot",
])?;
assert_success(&output);
assert_success(&harness.run(&["has-session", "-t", "boot"])?);
let option = harness.run(&["show-options", "-gqv", "@after-startup"])?;
assert_success(&option);
assert!(stdout(&option).is_empty());
Ok(())
}
#[test]
fn startup_config_run_shell_can_call_back_into_daemon() -> Result<(), Box<dyn Error>> {
let harness = CliHarness::new("startup-config-reentrant")?;
let config = harness.tmpdir().join("reentrant.conf");
let marker = harness.tmpdir().join("startup-ok.txt");
let command = format!(
"{} -S {} show-options -g > {}",
shell_quote_str(env!("CARGO_BIN_EXE_rmux")),
shell_quote(harness.socket_path()),
shell_quote(&marker)
);
fs::write(
&config,
format!("run-shell {}\n", shell_quote_str(&command)),
)?;
let output = harness.run(&[
"-f",
config.to_str().expect("utf-8 config path"),
"new-session",
"-d",
"-s",
"boot",
])?;
assert_success(&output);
wait_for_file(&marker)?;
Ok(())
}
#[cfg(unix)]
#[test]
fn tmux_fallback_loads_minimal_tpm_plugin_through_shim() -> Result<(), Box<dyn Error>> {
use std::os::unix::fs::PermissionsExt;
let harness = CliHarness::new("tmux-fallback-tpm")?;
let home = harness.tmpdir().join("home");
let tpm_dir = home.join(".tmux/plugins/tpm");
let spaced_plugin_dir = home.join(".tmux/plugins/plugin with spaces");
let nested_plugin_dir = home.join(".tmux/plugins/nested");
fs::create_dir_all(&tpm_dir)?;
fs::create_dir_all(&spaced_plugin_dir)?;
fs::create_dir_all(&nested_plugin_dir)?;
let tpm = tpm_dir.join("tpm");
fs::write(
&tpm,
"#!/bin/sh\n\
set -eu\n\
case \"$(tmux -V)\" in tmux\\ *) tmux set-option -g @tpm-version-ok yes ;; *) tmux set-option -g @tpm-version-ok no ;; esac\n\
tmux set-option -g @tpm-loaded yes\n\
tmux source-file \"$HOME/.tmux/plugins/plugin with spaces/plugin.tmux\"\n\
tmux source-file \"$HOME/.tmux/plugins/nested/nested.tmux\"\n\
tmux set-option -g @plugin-status JOB\n\
tmux set-option -g status-right 'X#(tmux show-options -gqv @plugin-status)Y'\n",
)?;
let mut permissions = fs::metadata(&tpm)?.permissions();
permissions.set_mode(0o700);
fs::set_permissions(&tpm, permissions)?;
fs::write(
spaced_plugin_dir.join("plugin.tmux"),
"set -g @spaced-plugin yes\n\
set-hook -g after-new-window 'set -g @hook-plugin-fired yes'\n",
)?;
fs::write(
nested_plugin_dir.join("nested.tmux"),
"set -g @nested-source yes\nsource-file ~/.tmux/plugins/nested/deeper.tmux\n",
)?;
fs::write(
nested_plugin_dir.join("deeper.tmux"),
"set -g @recursive-source yes\n",
)?;
fs::write(
home.join(".tmux.conf"),
"set -g @plugin 'tmux-plugins/tpm'\n\
set -g @plugin 'local/plugin with spaces'\n\
run-shell '~/.tmux/plugins/tpm/tpm'\n",
)?;
assert_success(
&harness.run_with(&["new-session", "-d", "-s", "plugins"], |command| {
command.env_remove("RMUX_DISABLE_TMUX_FALLBACK");
})?,
);
wait_for_option_value(&harness, "@tpm-loaded", "yes")?;
wait_for_option_value(&harness, "@tpm-version-ok", "yes")?;
wait_for_option_value(&harness, "@spaced-plugin", "yes")?;
wait_for_option_value(&harness, "@nested-source", "yes")?;
wait_for_option_value(&harness, "@recursive-source", "yes")?;
wait_for_option_value(
&harness,
"status-right",
"X#(tmux show-options -gqv @plugin-status)Y",
)?;
assert_success(&harness.run(&["new-window", "-d", "-t", "plugins:", "-n", "hookprobe"])?);
wait_for_option_value(&harness, "@hook-plugin-fired", "yes")?;
Ok(())
}
#[cfg(unix)]
#[test]
fn tmux_fallback_tpm_reads_all_plugin_entries_from_tmux_conf() -> Result<(), Box<dyn Error>> {
let harness = CliHarness::new("tmux-fallback-tpm-plugin-list")?;
let home = harness.tmpdir().join("home");
let plugins_root = home.join(".tmux/plugins");
fs::create_dir_all(&plugins_root)?;
let tpm_dir = plugins_root.join("tpm");
fs::create_dir_all(&tpm_dir)?;
let tpm = tpm_dir.join("tpm");
fs::write(
&tpm,
"#!/bin/sh\n\
set -eu\n\
case \"$(tmux -V)\" in tmux\\ *) tmux set-option -g @tpm-version-ok yes ;; *) tmux set-option -g @tpm-version-ok no ;; esac\n\
tmux set-environment -g TMUX_PLUGIN_MANAGER_PATH \"$HOME/.tmux/plugins/\"\n\
plugins=$(grep -E '^[[:space:]]*set(-option)?[[:space:]]+-g[[:space:]]+@plugin' \"$HOME/.tmux.conf\" | sed -E \"s/.*@plugin[[:space:]]+['\\\"]?([^'\\\"]+)['\\\"]?.*/\\1/\")\n\
for plugin in $plugins; do\n\
name=${plugin##*/}\n\
for file in \"$HOME/.tmux/plugins/$name\"/*.tmux; do\n\
[ -f \"$file\" ] || continue\n\
\"$file\"\n\
done\n\
done\n",
)?;
make_executable(&tpm)?;
write_executable_plugin(
&plugins_root,
"tmux-sensible",
"sensible.tmux",
"#!/bin/sh\n\
tmux set-option -g @sensible-loaded yes\n\
tmux bind-key R run-shell \"tmux source-file $HOME/.tmux.conf >/dev/null\"\n",
)?;
write_executable_plugin(
&plugins_root,
"tmux-resurrect",
"resurrect.tmux",
"#!/bin/sh\n\
tmux set-option -g @resurrect-save-script-path \"$HOME/.tmux/plugins/tmux-resurrect/scripts/save.sh\"\n\
tmux bind-key C-s run-shell \"$HOME/.tmux/plugins/tmux-resurrect/scripts/save.sh\"\n",
)?;
write_executable_plugin(
&plugins_root,
"tmux-continuum",
"continuum.tmux",
"#!/bin/sh\n\
tmux set-option -g @continuum-save-last-timestamp 123\n\
tmux set-option -g status-right '#(tmux show-options -gqv @continuum-save-last-timestamp)'\n",
)?;
write_executable_plugin(
&plugins_root,
"vim-tmux-navigator",
"vim-tmux-navigator.tmux",
"#!/bin/sh\n\
tmux bind-key -n C-h if-shell \"true\" \"send-keys C-h\" \"select-pane -L\"\n",
)?;
fs::write(
home.join(".tmux.conf"),
"set -g @plugin 'tmux-plugins/tpm'\n\
set -g @plugin 'tmux-plugins/tmux-sensible'\n\
set-option -g @plugin 'tmux-plugins/tmux-resurrect'\n\
set -g @plugin 'tmux-plugins/tmux-continuum'\n\
set -g @plugin 'christoomey/vim-tmux-navigator'\n\
run-shell '~/.tmux/plugins/tpm/tpm'\n",
)?;
assert_success(
&harness.run_with(&["new-session", "-d", "-s", "tpm-list"], |command| {
command.env_remove("RMUX_DISABLE_TMUX_FALLBACK");
command.env("HOME", &home);
})?,
);
wait_for_option_value(&harness, "@tpm-version-ok", "yes")?;
wait_for_environment_value(
&harness,
"TMUX_PLUGIN_MANAGER_PATH",
&format!("{}/", plugins_root.display()),
)?;
wait_for_option_value(&harness, "@sensible-loaded", "yes")?;
wait_for_option_value(
&harness,
"@resurrect-save-script-path",
&format!("{}/tmux-resurrect/scripts/save.sh", plugins_root.display()),
)?;
wait_for_option_value(&harness, "@continuum-save-last-timestamp", "123")?;
let keys = wait_for_list_keys_containing(&harness, "bind-key -T root C-h if-shell")?;
let normalized_keys = normalize_tmux_table_spaces(&keys);
for expected in [
"bind-key -T prefix R run-shell",
"bind-key -T prefix C-s run-shell",
"bind-key -T root C-h if-shell",
] {
assert!(
normalized_keys.contains(expected),
"expected TPM-loaded binding containing {expected:?}, got:\n{keys}"
);
}
Ok(())
}
#[cfg(unix)]
#[test]
fn tmux_fallback_runs_representative_executable_plugin_scripts() -> Result<(), Box<dyn Error>> {
let harness = CliHarness::new("tmux-fallback-common-plugins")?;
let home = harness.tmpdir().join("home");
let fake_bin = harness.tmpdir().join("fake-bin");
let external_tmux_marker = harness.tmpdir().join("external-tmux-called");
let plugins_root = home.join(".tmux/plugins");
fs::create_dir_all(&plugins_root)?;
fs::create_dir_all(&fake_bin)?;
let fake_tmux = fake_bin.join("tmux");
fs::write(
&fake_tmux,
format!(
"#!/bin/sh\nprintf called >> {}\nexit 97\n",
shell_quote(&external_tmux_marker)
),
)?;
make_executable(&fake_tmux)?;
let tpm_dir = plugins_root.join("tpm");
fs::create_dir_all(&tpm_dir)?;
let tpm = tpm_dir.join("tpm");
fs::write(
&tpm,
"#!/bin/sh\n\
set -eu\n\
tmux set-environment -g TMUX_PLUGIN_MANAGER_PATH \"$HOME/.tmux/plugins/\"\n\
for plugin in tmux-sensible tmux-resurrect tmux-continuum tmux-yank tmux-open vim-tmux-navigator tmux-status-fixture; do\n\
for file in \"$HOME/.tmux/plugins/$plugin\"/*.tmux; do\n\
[ -f \"$file\" ] || continue\n\
\"$file\"\n\
done\n\
done\n",
)?;
make_executable(&tpm)?;
write_executable_plugin(
&plugins_root,
"tmux-sensible",
"sensible.tmux",
"#!/bin/sh\n\
tmux list-keys >/dev/null\n\
tmux bind-key C-b send-prefix\n\
tmux bind-key R run-shell \"tmux source-file $HOME/.tmux.conf >/dev/null; tmux display-message reloaded\"\n",
)?;
write_executable_plugin(
&plugins_root,
"tmux-resurrect",
"resurrect.tmux",
"#!/bin/sh\n\
tmux set-option -g @resurrect-save-script-path \"$HOME/.tmux/plugins/tmux-resurrect/scripts/save.sh\"\n\
tmux bind-key C-s run-shell \"$HOME/.tmux/plugins/tmux-resurrect/scripts/save.sh\"\n",
)?;
write_executable_plugin(
&plugins_root,
"tmux-continuum",
"continuum.tmux",
"#!/bin/sh\n\
tmux display-message -p -F '#{start_time}' >/dev/null\n\
tmux set-option -g @continuum-save-last-timestamp 123\n",
)?;
write_executable_plugin(
&plugins_root,
"tmux-yank",
"yank.tmux",
"#!/bin/sh\n\
tmux bind-key -T copy-mode-vi y send-keys -X copy-pipe-and-cancel \"tmux display-message yank\"\n\
tmux bind-key Y run-shell -b \"$HOME/.tmux/plugins/tmux-yank/scripts/copy_pane_pwd.sh\"\n",
)?;
write_executable_plugin(
&plugins_root,
"tmux-open",
"open.tmux",
"#!/bin/sh\n\
tmux bind-key -T copy-mode-vi o send-keys -X copy-pipe-and-cancel \"tmux run-shell -b 'cd #{pane_current_path}; printf open >/dev/null'\"\n",
)?;
write_executable_plugin(
&plugins_root,
"vim-tmux-navigator",
"vim-tmux-navigator.tmux",
"#!/bin/sh\n\
tmux bind-key -n C-h if-shell \"true\" \"send-keys C-h\" \"select-pane -L\"\n",
)?;
write_executable_plugin(
&plugins_root,
"tmux-status-fixture",
"status.tmux",
"#!/bin/sh\n\
tmux set-option -g @plugin-status COMMON\n\
tmux set-option -g status-left 'X#(tmux show-options -gqv @plugin-status)Y'\n",
)?;
fs::write(
home.join(".tmux.conf"),
"set -g @plugin 'tmux-plugins/tpm'\n\
set -g @plugin 'tmux-plugins/tmux-sensible'\n\
set -g @plugin 'tmux-plugins/tmux-resurrect'\n\
set -g @plugin 'tmux-plugins/tmux-continuum'\n\
set -g @plugin 'tmux-plugins/tmux-yank'\n\
set -g @plugin 'tmux-plugins/tmux-open'\n\
set -g @plugin 'christoomey/vim-tmux-navigator'\n\
run-shell '~/.tmux/plugins/tpm/tpm'\n",
)?;
let original_path = std::env::var("PATH").unwrap_or_default();
let fake_first_path = format!("{}:{original_path}", fake_bin.display());
assert_success(&harness.run_with(
&["new-session", "-d", "-s", "commonplugins"],
|command| {
command.env_remove("RMUX_DISABLE_TMUX_FALLBACK");
command.env("HOME", &home);
command.env("PATH", &fake_first_path);
},
)?);
wait_for_environment_value(
&harness,
"TMUX_PLUGIN_MANAGER_PATH",
&format!("{}/", plugins_root.display()),
)?;
wait_for_option_value(
&harness,
"@resurrect-save-script-path",
&format!("{}/tmux-resurrect/scripts/save.sh", plugins_root.display()),
)?;
wait_for_option_value(&harness, "@continuum-save-last-timestamp", "123")?;
wait_for_option_value(&harness, "@plugin-status", "COMMON")?;
wait_for_option_value(
&harness,
"status-left",
"X#(tmux show-options -gqv @plugin-status)Y",
)?;
let keys = wait_for_list_keys_containing(&harness, "bind-key -T prefix C-s run-shell")?;
let normalized_keys = normalize_tmux_table_spaces(&keys);
for expected in [
"bind-key -T prefix C-b send-prefix",
"bind-key -T prefix R run-shell",
"bind-key -T copy-mode-vi y send-keys -X copy-pipe-and-cancel",
"bind-key -T copy-mode-vi o send-keys -X copy-pipe-and-cancel",
"bind-key -T root C-h if-shell",
] {
assert!(
normalized_keys.contains(expected),
"expected plugin binding containing {expected:?}, got:\n{keys}"
);
}
assert!(
!external_tmux_marker.exists(),
"plugin scripts escaped the per-socket rmux tmux shim"
);
Ok(())
}
#[cfg(unix)]
#[test]
fn tmux_fallback_shim_preempts_external_tmux_in_path() -> Result<(), Box<dyn Error>> {
use std::os::unix::fs::PermissionsExt;
let harness = CliHarness::new("tmux-fallback-shim-path")?;
let home = harness.tmpdir().join("home");
let external_bin = harness.tmpdir().join("external-bin");
let marker = harness.tmpdir().join("external-tmux-called");
fs::create_dir_all(&external_bin)?;
let fake_tmux = external_bin.join("tmux");
fs::write(
&fake_tmux,
format!(
"#!/bin/sh\nprintf called >> {}\nexit 99\n",
shell_quote(&marker)
),
)?;
let mut permissions = fs::metadata(&fake_tmux)?.permissions();
permissions.set_mode(0o700);
fs::set_permissions(&fake_tmux, permissions)?;
fs::write(
home.join(".tmux.conf"),
"run-shell 'tmux set-option -g @shim-precedence yes'\n",
)?;
let original_path = std::env::var("PATH").unwrap_or_default();
let fake_first_path = format!("{}:{original_path}", external_bin.display());
assert_success(
&harness.run_with(&["new-session", "-d", "-s", "shimpath"], |command| {
command.env_remove("RMUX_DISABLE_TMUX_FALLBACK");
command.env("PATH", &fake_first_path);
})?,
);
wait_for_option_value(&harness, "@shim-precedence", "yes")?;
assert!(
!marker.exists(),
"run-shell used external tmux instead of the per-socket rmux shim"
);
Ok(())
}
#[cfg(unix)]
#[test]
fn status_job_plugin_renders_through_tmux_shim() -> Result<(), Box<dyn Error>> {
let harness = CliHarness::new("status-job-plugin-shim")?;
let _daemon = harness.start_hidden_daemon()?;
assert_success(&harness.run(&["new-session", "-d", "-s", "statusjob"])?);
assert_success(&harness.run(&["set-option", "-g", "@plugin-status", "RENDERED"])?);
for (option, value) in [
("status-left", "X#(tmux show-options -gqv @plugin-status)Y"),
("status-left-length", "32"),
("status-interval", "1"),
("status-right", ""),
("window-status-format", ""),
("window-status-current-format", ""),
] {
assert_success(&harness.run(&["set-option", "-g", option, value])?);
}
let mut attach =
AttachedSession::spawn(&harness, "statusjob", TerminalSize { cols: 80, rows: 8 })?;
attach.wait_for_raw_mode(Duration::from_secs(5))?;
let deadline = Instant::now() + Duration::from_secs(6);
while Instant::now() < deadline {
if read_until_contains(
attach.master_mut(),
"XRENDEREDY",
Duration::from_millis(250),
)
.is_ok()
{
return Ok(());
}
}
Err("status job did not render the tmux shim result".into())
}
#[test]
fn source_file_verbose_prefixes_each_command_with_path_and_line() -> Result<(), Box<dyn Error>> {
let harness = CliHarness::new("source-file-verbose-prefix")?;
let _daemon = harness.start_hidden_daemon()?;
let config = harness.tmpdir().join("verbose.conf");
fs::write(&config, "set-buffer -b sf yes\ndisplay-message -p hello\n")?;
let output = harness.run(&[
"source-file",
"-v",
config.to_str().expect("utf-8 config path"),
])?;
assert_eq!(output.status.code(), Some(0));
assert_eq!(
stdout(&output),
format!(
"{}:1: set-buffer -b sf yes\n{}:2: display-message -p hello\nhello\n",
config.display(),
config.display()
)
);
assert!(stderr(&output).is_empty());
Ok(())
}
#[test]
fn source_file_missing_path_reports_plain_no_such_file_surface() -> Result<(), Box<dyn Error>> {
let harness = CliHarness::new("source-file-missing")?;
let _daemon = harness.start_hidden_daemon()?;
let missing = harness.tmpdir().join("missing.conf");
let output = harness.run(&["source-file", missing.to_str().expect("utf-8 config path")])?;
assert_eq!(output.status.code(), Some(1));
assert!(stdout(&output).is_empty());
assert_eq!(
stderr(&output),
format!("{}: No such file or directory\n", missing.display())
);
Ok(())
}
#[test]
fn source_file_directory_is_rejected() -> Result<(), Box<dyn Error>> {
let harness = CliHarness::new("source-file-directory")?;
let _daemon = harness.start_hidden_daemon()?;
let output = harness.run(&[
"source-file",
harness.tmpdir().to_str().expect("utf-8 path"),
])?;
assert_eq!(output.status.code(), Some(1));
assert!(stdout(&output).is_empty());
assert_eq!(
stderr(&output),
format!("{}: Is a directory\n", harness.tmpdir().display())
);
Ok(())
}
#[test]
fn source_file_large_comments_do_not_trip_command_length_limit() -> Result<(), Box<dyn Error>> {
let harness = CliHarness::new("source-file-large-comments")?;
let _daemon = harness.start_hidden_daemon()?;
let config = harness.tmpdir().join("comments.conf");
let mut contents = "#\n".repeat(16 * 1024);
contents.push_str("set-buffer -b source-size ok\n");
fs::write(&config, contents)?;
let output = harness.run(&["source-file", config.to_str().expect("utf-8 config path")])?;
assert_success(&output);
let buffer = harness.run(&["show-buffer", "-b", "source-size"])?;
assert_eq!(buffer.status.code(), Some(0));
assert_eq!(stdout(&buffer), "ok");
assert!(stderr(&buffer).is_empty());
Ok(())
}
#[test]
fn source_file_accepts_long_command_arguments() -> Result<(), Box<dyn Error>> {
let harness = CliHarness::new("source-file-long-command")?;
let _daemon = harness.start_hidden_daemon()?;
let config = harness.tmpdir().join("long-command.conf");
let payload = "x".repeat(20 * 1024);
fs::write(&config, format!("set-buffer -b source-long {payload}\n"))?;
let output = harness.run(&["source-file", config.to_str().expect("utf-8 config path")])?;
assert_success(&output);
let buffer = harness.run(&["show-buffer", "-b", "source-long"])?;
assert_eq!(buffer.status.code(), Some(0));
assert_eq!(stdout(&buffer), payload);
assert!(stderr(&buffer).is_empty());
Ok(())
}
#[test]
fn source_file_without_server_does_not_auto_start() -> Result<(), Box<dyn Error>> {
let harness = CliHarness::new("source-file-no-autostart")?;
let config = harness.tmpdir().join("would-create-session.conf");
fs::write(&config, "new-session -d -s made_by_source\n")?;
let output = harness.run(&["source-file", config.to_str().expect("utf-8 config path")])?;
assert_eq!(output.status.code(), Some(1));
assert!(stdout(&output).is_empty());
assert!(
stderr(&output).contains("server") || stderr(&output).contains("error connecting to"),
"source-file must fail before starting a server, got stderr: {:?}",
stderr(&output)
);
let list = harness.run(&["list-sessions"])?;
assert_eq!(list.status.code(), Some(1));
assert!(stdout(&list).is_empty());
assert!(
stderr(&list).contains("server") || stderr(&list).contains("error connecting to"),
"no daemon should be left behind by source-file, got stderr: {:?}",
stderr(&list)
);
Ok(())
}
#[test]
fn if_shell_nested_load_buffer_resolves_relative_paths_against_caller_cwd(
) -> Result<(), Box<dyn Error>> {
let harness = CliHarness::new("if-shell-load-buffer-relative")?;
let _daemon = harness.start_hidden_daemon()?;
let caller_dir = harness.tmpdir().join("caller");
let nested_dir = caller_dir.join("nested");
fs::create_dir_all(&nested_dir)?;
fs::write(nested_dir.join("input.txt"), "loaded via nested if-shell")?;
assert_success(&harness.run_with(
&[
"if-shell",
"-F",
"1",
"load-buffer -b loaded nested/input.txt",
],
|command| {
command.current_dir(&caller_dir);
},
)?);
let show = harness.run(&["show-buffer", "-b", "loaded"])?;
assert_eq!(show.status.code(), Some(0));
assert_eq!(stdout(&show), "loaded via nested if-shell");
assert!(stderr(&show).is_empty());
Ok(())
}
#[test]
fn if_shell_supports_representative_public_commands() -> Result<(), Box<dyn Error>> {
let harness = CliHarness::new("if-shell-surface")?;
let _daemon = harness.start_hidden_daemon()?;
let buffer_path = harness.tmpdir().join("loaded-buffer.txt");
fs::write(&buffer_path, "loaded from file")?;
assert_success(&harness.run(&["new-session", "-d", "-s", "alpha"])?);
assert_success(&harness.run(&["split-window", "-h", "-t", "alpha"])?);
assert_success(&harness.run(&["if-shell", "-F", "1", "set-option -g status off"])?);
let show_options = harness.run(&["if-shell", "-F", "1", "show-options -g"])?;
assert_eq!(show_options.status.code(), Some(0));
assert!(stdout(&show_options).contains("status off"));
assert!(stderr(&show_options).is_empty());
let load_buffer_command = format!("load-buffer -b loaded {}", buffer_path.display());
assert_success(&harness.run(&["if-shell", "-F", "1", &load_buffer_command])?);
let show_buffer = harness.run(&["show-buffer", "-b", "loaded"])?;
assert_eq!(show_buffer.status.code(), Some(0));
assert_eq!(stdout(&show_buffer), "loaded from file");
assert!(stderr(&show_buffer).is_empty());
assert_success(&harness.run(&[
"if-shell",
"-F",
"1",
"select-layout -t alpha:0 even-horizontal",
])?);
let windows = harness.run(&["list-windows", "-t", "alpha", "-F", "#{window_layout}"])?;
assert_eq!(windows.status.code(), Some(0));
assert_eq!(
stdout(&windows),
"89f5,80x24,0,0{39x24,0,0,0,40x24,40,0,1}\n"
);
assert!(stderr(&windows).is_empty());
assert_success(&harness.run(&["if-shell", "-F", "1", "select-pane -t alpha:0.1"])?);
let panes = harness.run(&[
"list-panes",
"-t",
"alpha",
"-F",
"#{pane_index}:#{pane_active}",
])?;
assert_eq!(panes.status.code(), Some(0));
assert!(stdout(&panes).contains("1:1"));
assert!(stderr(&panes).is_empty());
Ok(())
}
#[test]
fn hook_surface_smoke_matches_supported_cli_behavior() -> Result<(), Box<dyn Error>> {
let harness = CliHarness::new("hook-surface-smoke")?;
let _daemon = harness.start_hidden_daemon()?;
assert_success(&harness.run(&["new-session", "-d", "-s", "alpha"])?);
assert_success(&harness.run(&[
"set-hook",
"-t",
"al",
"client-attached",
"display-message hi",
])?);
assert_success(&harness.run(&[
"set-hook",
"-g",
"window-resized",
"set-buffer -b resized yes",
])?);
assert_success(&harness.run(&["resize-window", "-t", "alpha:0", "-x", "90", "-y", "24"])?);
wait_for_buffer(&harness, "resized", "yes")?;
let output = harness.run(&["show-hooks", "-t", "al", "client-attached"])?;
assert_eq!(output.status.code(), Some(0));
assert_eq!(stdout(&output), "client-attached[0] display-message hi\n");
assert!(stderr(&output).is_empty());
let bindings = harness.run(&["list-keys", "-T", "prefix", "C-b"])?;
assert_eq!(bindings.status.code(), Some(0));
assert_eq!(stdout(&bindings), "bind-key -T prefix C-b send-prefix\n");
assert!(stderr(&bindings).is_empty());
Ok(())
}
#[test]
fn show_hooks_global_filter_finds_window_and_pane_hooks() -> Result<(), Box<dyn Error>> {
let harness = CliHarness::new("show-hooks-global-filter")?;
let _daemon = harness.start_hidden_daemon()?;
let hooks = [
"pane-focus-in",
"pane-focus-out",
"pane-exited",
"pane-died",
"pane-set-clipboard",
"window-layout-changed",
"window-resized",
];
assert_success(&harness.run(&["new-session", "-d", "-s", "alpha"])?);
for hook in hooks {
assert_success(&harness.run(&["set-hook", "-g", hook, "display-message hooked"])?);
let output = harness.run(&["show-hooks", "-g", hook])?;
assert_eq!(
output.status.code(),
Some(0),
"show-hooks failed for {hook}"
);
assert_eq!(
stdout(&output),
format!("{hook}[0] display-message hooked\n")
);
assert!(stderr(&output).is_empty());
}
Ok(())
}
#[test]
fn set_hook_canonicalizes_and_validates_command_body() -> Result<(), Box<dyn Error>> {
let harness = CliHarness::new("set-hook-command-body")?;
let _daemon = harness.start_hidden_daemon()?;
assert_success(&harness.run(&["new-session", "-d", "-s", "alpha"])?);
let display_alias = harness.run(&["set-hook", "-g", "pane-exited", "display hi"])?;
assert_success(&display_alias);
let displayed = harness.run(&["show-hooks", "-g", "pane-exited"])?;
assert_eq!(displayed.status.code(), Some(0));
assert_eq!(stdout(&displayed), "pane-exited[0] display-message hi\n");
assert!(stderr(&displayed).is_empty());
let select_alias = harness.run(&["set-hook", "-g", "pane-exited", "selectw -t :0"])?;
assert_success(&select_alias);
let selected = harness.run(&["show-hooks", "-g", "pane-exited"])?;
assert_eq!(selected.status.code(), Some(0));
assert_eq!(stdout(&selected), "pane-exited[0] select-window -t :0\n");
assert!(stderr(&selected).is_empty());
let invalid = harness.run(&["set-hook", "-g", "pane-exited", "next \"win\""])?;
assert_eq!(invalid.status.code(), Some(1));
assert!(stdout(&invalid).is_empty());
assert!(
stderr(&invalid).contains("too many arguments"),
"stderr={:?}",
stderr(&invalid)
);
Ok(())
}
#[test]
fn pane_died_hook_fires_for_remain_on_exit_pane() -> Result<(), Box<dyn Error>> {
let harness = CliHarness::new("pane-died-hook")?;
let _daemon = harness.start_hidden_daemon()?;
assert_success(&harness.run(&["new-session", "-d", "-s", "alpha"])?);
assert_success(&harness.run(&["set-option", "-g", "remain-on-exit", "on"])?);
assert_success(&harness.run(&["set-hook", "-g", "pane-died", "set-buffer -b died yes"])?);
assert_success(&harness.run(&["split-window", "-t", "alpha", "exit 0"])?);
wait_for_buffer(&harness, "died", "yes")?;
Ok(())
}
fn wait_for_buffer(
harness: &CliHarness,
buffer: &str,
expected: &str,
) -> Result<(), Box<dyn Error>> {
let deadline = std::time::Instant::now() + Duration::from_secs(2);
loop {
let output = harness.run(&["show-buffer", "-b", buffer])?;
if output.status.code() == Some(0) && stdout(&output).trim_end() == expected {
return Ok(());
}
if std::time::Instant::now() >= deadline {
return Err(format!(
"timed out waiting for buffer {buffer}={expected:?}; last status={:?} stdout={:?} stderr={:?}",
output.status.code(),
stdout(&output),
stderr(&output)
)
.into());
}
std::thread::sleep(Duration::from_millis(25));
}
}
#[test]
fn wait_for_signal_succeeds_without_waiters() -> Result<(), Box<dyn Error>> {
let harness = CliHarness::new("wait-for-signal")?;
let _daemon = harness.start_hidden_daemon()?;
let output = harness.run(&["wait-for", "-S", "no-waiters"])?;
assert_success(&output);
Ok(())
}
fn wait_for_if_shell_capture(
harness: &CliHarness,
marker: &str,
) -> Result<std::process::Output, Box<dyn Error>> {
let mut last = None;
for _ in 0..100 {
let output = harness.run(&["if-shell", "-F", "1", "capture-pane -p -t alpha:0.0"])?;
if output.status.code() == Some(0) && stdout(&output).contains(marker) {
return Ok(output);
}
last = Some(output);
std::thread::sleep(Duration::from_millis(20));
}
let last = last.expect("capture was attempted");
Err(format!(
"if-shell capture output never contained marker {marker}; status={:?} stdout={:?} stderr={:?}",
last.status.code(),
stdout(&last),
stderr(&last)
)
.into())
}
fn wait_for_file(path: &std::path::Path) -> Result<(), Box<dyn Error>> {
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while std::time::Instant::now() < deadline {
if path.is_file() {
return Ok(());
}
std::thread::sleep(Duration::from_millis(20));
}
Err(format!("timed out waiting for {}", path.display()).into())
}
fn wait_for_option_value(
harness: &CliHarness,
option: &str,
expected: &str,
) -> Result<(), Box<dyn Error>> {
let deadline = std::time::Instant::now() + Duration::from_secs(5);
let mut last = None;
while std::time::Instant::now() < deadline {
let output = harness.run(&["show-options", "-gqv", option])?;
if output.status.code() == Some(0) && stdout(&output).trim_end() == expected {
return Ok(());
}
last = Some(output);
std::thread::sleep(Duration::from_millis(20));
}
let last = last.expect("show-options was attempted");
Err(format!(
"timed out waiting for option {option}={expected:?}; status={:?} stdout={:?} stderr={:?}",
last.status.code(),
stdout(&last),
stderr(&last)
)
.into())
}
fn wait_for_environment_value(
harness: &CliHarness,
name: &str,
expected: &str,
) -> Result<(), Box<dyn Error>> {
let deadline = std::time::Instant::now() + Duration::from_secs(5);
let mut last = None;
while std::time::Instant::now() < deadline {
let output = harness.run(&["show-environment", "-g", name])?;
if output.status.code() == Some(0)
&& stdout(&output).trim_end() == format!("{name}={expected}")
{
return Ok(());
}
last = Some(output);
std::thread::sleep(Duration::from_millis(20));
}
let last = last.expect("show-environment was attempted");
Err(format!(
"timed out waiting for environment {name}={expected:?}; status={:?} stdout={:?} stderr={:?}",
last.status.code(),
stdout(&last),
stderr(&last)
)
.into())
}
fn wait_for_list_keys_containing(
harness: &CliHarness,
expected: &str,
) -> Result<String, Box<dyn Error>> {
let deadline = std::time::Instant::now() + Duration::from_secs(5);
let mut last = None;
while std::time::Instant::now() < deadline {
let output = harness.run(&["list-keys"])?;
if output.status.code() == Some(0)
&& normalize_tmux_table_spaces(&stdout(&output)).contains(expected)
{
return Ok(stdout(&output));
}
last = Some(output);
std::thread::sleep(Duration::from_millis(20));
}
let last = last.expect("list-keys was attempted");
Err(format!(
"timed out waiting for list-keys to contain {expected:?}; status={:?} stdout={:?} stderr={:?}",
last.status.code(),
stdout(&last),
stderr(&last)
)
.into())
}
fn normalize_tmux_table_spaces(value: &str) -> String {
value
.lines()
.map(|line| line.split_whitespace().collect::<Vec<_>>().join(" "))
.collect::<Vec<_>>()
.join("\n")
}
#[cfg(unix)]
fn write_executable_plugin(
plugins_root: &std::path::Path,
plugin: &str,
name: &str,
contents: &str,
) -> Result<(), Box<dyn Error>> {
let dir = plugins_root.join(plugin);
fs::create_dir_all(&dir)?;
let path = dir.join(name);
fs::write(&path, contents)?;
make_executable(&path)
}
#[cfg(unix)]
fn make_executable(path: &std::path::Path) -> Result<(), Box<dyn Error>> {
use std::os::unix::fs::PermissionsExt;
let mut permissions = fs::metadata(path)?.permissions();
permissions.set_mode(0o700);
fs::set_permissions(path, permissions)?;
Ok(())
}
fn shell_quote(path: &std::path::Path) -> String {
shell_quote_str(&path.display().to_string())
}
fn shell_quote_str(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\\''"))
}