2026-09-09 13:00:52 -07:00
|
|
|
//! End-to-end against a real tmux server.
|
|
|
|
|
//!
|
|
|
|
|
//! Covers what the unit tests deliberately cannot: that the grid's idea of
|
|
|
|
|
//! "sub-screen 3 of the cell at 0x0" lands on the pane a person would expect,
|
|
|
|
|
//! zoomed. Skipped when tmux is not installed.
|
|
|
|
|
|
|
|
|
|
use std::process::{Command, Stdio};
|
|
|
|
|
|
|
|
|
|
use socktop_swipe::config::Config;
|
|
|
|
|
use socktop_swipe::grid::{Grid, Move};
|
|
|
|
|
use socktop_swipe::monitor;
|
|
|
|
|
use socktop_swipe::session::tmux::Tmux;
|
|
|
|
|
use socktop_swipe::session::Multiplexer;
|
|
|
|
|
|
|
|
|
|
fn have_tmux() -> bool {
|
|
|
|
|
Command::new("tmux")
|
|
|
|
|
.arg("-V")
|
|
|
|
|
.stdout(Stdio::null())
|
|
|
|
|
.stderr(Stdio::null())
|
|
|
|
|
.status()
|
|
|
|
|
.map(|s| s.success())
|
|
|
|
|
.unwrap_or(false)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn tmux(args: &[&str]) -> String {
|
|
|
|
|
let out = Command::new("tmux").args(args).output().expect("tmux");
|
|
|
|
|
String::from_utf8_lossy(&out.stdout).trim().to_owned()
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-09 13:24:16 -07:00
|
|
|
fn config(session: &str) -> Config {
|
2026-09-09 13:00:52 -07:00
|
|
|
// `true` exits at once, which also exercises remain-on-exit keeping the
|
|
|
|
|
// pane addressable afterwards.
|
|
|
|
|
let yaml = format!(
|
|
|
|
|
r#"
|
2026-09-09 13:24:16 -07:00
|
|
|
session: {session}
|
2026-09-09 13:00:52 -07:00
|
|
|
binaries: {{ socktop: /bin/echo, unifly: /bin/echo, uptime-kuma-status: /bin/echo }}
|
|
|
|
|
touch: {{ device: /dev/null, width: 1280, height: 720, grab: false }}
|
|
|
|
|
screens:
|
|
|
|
|
- at: "-1x0"
|
|
|
|
|
type: unifly
|
|
|
|
|
- at: "0x0"
|
|
|
|
|
type: socktop
|
|
|
|
|
socktop_group: [alpha, bravo, charlie, delta]
|
|
|
|
|
layout: tiled
|
|
|
|
|
- at: "0x5"
|
|
|
|
|
type: socktop
|
|
|
|
|
socktop_group: "echo1, foxtrot"
|
|
|
|
|
layout: even-vertical
|
|
|
|
|
- at: "1x0"
|
|
|
|
|
type: uptime-kuma-status
|
|
|
|
|
url: https://example.invalid/status
|
|
|
|
|
"#
|
|
|
|
|
);
|
|
|
|
|
serde_yaml::from_str(&yaml).expect("test config should parse")
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-09 13:24:16 -07:00
|
|
|
/// (active window name, active pane label, is the window zoomed)
|
|
|
|
|
fn visible(session: &str) -> (String, String, bool) {
|
2026-09-09 13:00:52 -07:00
|
|
|
let s = tmux(&[
|
|
|
|
|
"display-message",
|
|
|
|
|
"-p",
|
|
|
|
|
"-t",
|
2026-09-09 13:24:16 -07:00
|
|
|
session,
|
|
|
|
|
"#{window_name}\t#{@socktop_label}\t#{window_zoomed_flag}",
|
2026-09-09 13:00:52 -07:00
|
|
|
]);
|
|
|
|
|
let f: Vec<&str> = s.split('\t').collect();
|
|
|
|
|
(f[0].into(), f[1].into(), f[2] == "1")
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-09 13:24:16 -07:00
|
|
|
/// Each test uses its own session name: cargo runs tests in parallel and they
|
|
|
|
|
/// would otherwise tear down each other's tmux server state.
|
|
|
|
|
struct Cleanup(&'static str);
|
2026-09-09 13:00:52 -07:00
|
|
|
impl Drop for Cleanup {
|
|
|
|
|
fn drop(&mut self) {
|
|
|
|
|
let _ = Command::new("tmux")
|
2026-09-09 13:24:16 -07:00
|
|
|
.args(["kill-session", "-t", self.0])
|
2026-09-09 13:00:52 -07:00
|
|
|
.stdout(Stdio::null())
|
|
|
|
|
.stderr(Stdio::null())
|
|
|
|
|
.status();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn session_layout_and_navigation_match_the_grid() {
|
|
|
|
|
if !have_tmux() {
|
|
|
|
|
eprintln!("skipping: tmux is not installed");
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-09-09 13:24:16 -07:00
|
|
|
const SESSION: &str = "socktop-swipe-selftest-nav";
|
|
|
|
|
let _cleanup = Cleanup(SESSION);
|
2026-09-09 13:00:52 -07:00
|
|
|
|
2026-09-09 13:24:16 -07:00
|
|
|
let cfg = config(SESSION);
|
2026-09-09 13:00:52 -07:00
|
|
|
let mut grid = Grid::new(monitor::build_cells(&cfg).unwrap()).unwrap();
|
|
|
|
|
let mux = Tmux::new(&cfg.session, false);
|
|
|
|
|
mux.build(&grid).expect("session should build");
|
|
|
|
|
|
|
|
|
|
// One window per cell, in grid order, named by coordinate.
|
2026-09-09 13:08:42 -07:00
|
|
|
let windows = tmux(&[
|
|
|
|
|
"list-windows",
|
|
|
|
|
"-t",
|
|
|
|
|
SESSION,
|
|
|
|
|
"-F",
|
|
|
|
|
"#{window_name} #{window_panes}",
|
|
|
|
|
]);
|
2026-09-09 13:00:52 -07:00
|
|
|
assert_eq!(
|
|
|
|
|
windows.lines().collect::<Vec<_>>(),
|
|
|
|
|
vec!["rm1c0 1", "r0c0 4", "r0c5 2", "r1c0 1"],
|
|
|
|
|
"windows should be one per cell, in grid order, with one pane per host"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Starts at 0x0's overview: not zoomed, so all four Pis are visible.
|
2026-09-09 13:24:16 -07:00
|
|
|
let (win, _, zoomed) = visible(SESSION);
|
2026-09-09 13:00:52 -07:00
|
|
|
assert_eq!(win, "r0c0");
|
|
|
|
|
assert!(!zoomed, "the overview must not be zoomed");
|
|
|
|
|
|
|
|
|
|
// Forward walks the sub-sequence, zooming each host in declaration order.
|
|
|
|
|
for expected in ["alpha", "bravo", "charlie", "delta"] {
|
|
|
|
|
let pos = grid.apply(Move::Forward);
|
|
|
|
|
mux.show(&grid, &pos).unwrap();
|
2026-09-09 13:24:16 -07:00
|
|
|
let (win, title, zoomed) = visible(SESSION);
|
2026-09-09 13:00:52 -07:00
|
|
|
assert_eq!(win, "r0c0");
|
|
|
|
|
assert_eq!(title, expected, "wrong host zoomed");
|
|
|
|
|
assert!(zoomed, "{expected} should be zoomed full-screen");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Past the last host, on to the next cell's overview.
|
|
|
|
|
let pos = grid.apply(Move::Forward);
|
|
|
|
|
mux.show(&grid, &pos).unwrap();
|
2026-09-09 13:24:16 -07:00
|
|
|
let (win, _, zoomed) = visible(SESSION);
|
2026-09-09 13:08:42 -07:00
|
|
|
assert_eq!(
|
|
|
|
|
win, "r0c5",
|
|
|
|
|
"0x5 follows 0x0 despite the gap in column numbers"
|
|
|
|
|
);
|
2026-09-09 13:00:52 -07:00
|
|
|
assert!(!zoomed);
|
|
|
|
|
|
|
|
|
|
// Back must land on 0x0's LAST host, not its overview.
|
|
|
|
|
let pos = grid.apply(Move::Back);
|
|
|
|
|
mux.show(&grid, &pos).unwrap();
|
2026-09-09 13:24:16 -07:00
|
|
|
let (win, title, zoomed) = visible(SESSION);
|
2026-09-09 13:08:42 -07:00
|
|
|
assert_eq!(
|
|
|
|
|
(win.as_str(), title.as_str(), zoomed),
|
|
|
|
|
("r0c0", "delta", true)
|
|
|
|
|
);
|
2026-09-09 13:00:52 -07:00
|
|
|
|
|
|
|
|
// Up to unifly: a single-pane cell, so nothing to zoom.
|
|
|
|
|
let pos = grid.apply(Move::Up);
|
|
|
|
|
mux.show(&grid, &pos).unwrap();
|
2026-09-09 13:24:16 -07:00
|
|
|
let (win, _, zoomed) = visible(SESSION);
|
2026-09-09 13:00:52 -07:00
|
|
|
assert_eq!(win, "rm1c0");
|
|
|
|
|
assert!(!zoomed, "a one-pane cell has nothing to zoom into");
|
|
|
|
|
|
|
|
|
|
// And back down to exactly the host we left.
|
|
|
|
|
let pos = grid.apply(Move::Down);
|
|
|
|
|
mux.show(&grid, &pos).unwrap();
|
2026-09-09 13:24:16 -07:00
|
|
|
let (win, title, zoomed) = visible(SESSION);
|
2026-09-09 13:00:52 -07:00
|
|
|
assert_eq!(
|
|
|
|
|
(win.as_str(), title.as_str(), zoomed),
|
|
|
|
|
("r0c0", "delta", true),
|
|
|
|
|
"returning must restore the host AND its zoom"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Down twice: through row 0 to kuma.
|
|
|
|
|
let pos = grid.apply(Move::Down);
|
|
|
|
|
mux.show(&grid, &pos).unwrap();
|
2026-09-09 13:24:16 -07:00
|
|
|
assert_eq!(visible(SESSION).0, "r1c0");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Window options do not propagate from the session, and new windows do not
|
|
|
|
|
/// inherit them. Setting one with `set-option -t <session>` quietly applies it
|
|
|
|
|
/// to whichever window is current, which is how v1 ended up with pane borders
|
|
|
|
|
/// on only one of its three windows. Assert EVERY window got them.
|
|
|
|
|
#[test]
|
|
|
|
|
fn window_options_are_set_on_every_window() {
|
|
|
|
|
if !have_tmux() {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const SESSION: &str = "socktop-swipe-selftest-opts";
|
|
|
|
|
let _cleanup = Cleanup(SESSION);
|
|
|
|
|
|
|
|
|
|
let cfg = config(SESSION);
|
|
|
|
|
let grid = Grid::new(monitor::build_cells(&cfg).unwrap()).unwrap();
|
|
|
|
|
Tmux::new(&cfg.session, false).build(&grid).unwrap();
|
|
|
|
|
|
|
|
|
|
for (window, want_labels) in [
|
|
|
|
|
("rm1c0", vec!["unifly"]),
|
|
|
|
|
("r0c0", vec!["alpha", "bravo", "charlie", "delta"]),
|
|
|
|
|
("r0c5", vec!["echo1", "foxtrot"]),
|
|
|
|
|
("r1c0", vec!["uptime kuma"]),
|
|
|
|
|
] {
|
|
|
|
|
let target = format!("{SESSION}:{window}");
|
|
|
|
|
for (opt, want) in [("pane-border-status", "top"), ("allow-rename", "off")] {
|
|
|
|
|
let got = tmux(&["show-options", "-w", "-t", &target, "-v", opt]);
|
|
|
|
|
assert_eq!(got, want, "{window} is missing the {opt} window option");
|
|
|
|
|
}
|
|
|
|
|
assert!(
|
|
|
|
|
tmux(&[
|
|
|
|
|
"show-options",
|
|
|
|
|
"-w",
|
|
|
|
|
"-t",
|
|
|
|
|
&target,
|
|
|
|
|
"-v",
|
|
|
|
|
"pane-border-format"
|
|
|
|
|
])
|
|
|
|
|
.contains("@socktop_label"),
|
|
|
|
|
"{window} is missing the pane-border-format"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Labels live in a pane-scoped user option precisely so the program in
|
|
|
|
|
// the pane cannot overwrite them with a title escape sequence.
|
|
|
|
|
let labels = tmux(&["list-panes", "-t", &target, "-F", "#{@socktop_label}"]);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
labels.lines().collect::<Vec<_>>(),
|
|
|
|
|
want_labels,
|
|
|
|
|
"{window} has the wrong pane labels"
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-09-09 13:00:52 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn pane_commands_are_quoted_not_interpreted() {
|
|
|
|
|
if !have_tmux() {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
// A profile name containing shell metacharacters must reach the program as
|
|
|
|
|
// one argument, not be re-split or evaluated by the sh tmux runs it under.
|
|
|
|
|
let cfg: Config = serde_yaml::from_str(
|
|
|
|
|
r#"
|
|
|
|
|
session: socktop-swipe-quoting-test
|
|
|
|
|
binaries: { socktop: /bin/echo }
|
|
|
|
|
touch: { device: /dev/null, width: 1, height: 1, grab: false }
|
|
|
|
|
screens:
|
|
|
|
|
- at: "0x0"
|
|
|
|
|
type: socktop
|
|
|
|
|
socktop_group: ["a b; touch /tmp/socktop-swipe-pwned"]
|
|
|
|
|
"#,
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
let cells = monitor::build_cells(&cfg).unwrap();
|
|
|
|
|
let argv = &cells[0].panes[0].command;
|
2026-09-09 13:08:42 -07:00
|
|
|
assert_eq!(
|
|
|
|
|
argv,
|
|
|
|
|
&["/bin/echo", "-P", "a b; touch /tmp/socktop-swipe-pwned"]
|
|
|
|
|
);
|
2026-09-09 13:00:52 -07:00
|
|
|
|
|
|
|
|
let quoted = shell_words::join(argv.iter().map(String::as_str));
|
|
|
|
|
let reparsed = shell_words::split("ed).unwrap();
|
2026-09-09 13:08:42 -07:00
|
|
|
assert_eq!(
|
|
|
|
|
&reparsed, argv,
|
|
|
|
|
"quoting must survive the round trip through sh"
|
|
|
|
|
);
|
2026-09-09 13:00:52 -07:00
|
|
|
}
|