Files
socktop-swipe/tests/tmux_session.rs
T
jasonwitty 9226224507 Set window options per window; label panes where programs cannot clobber them
Two bugs found deploying to the LattePanda, both of which v1 also had.

Window options do not propagate from the session and new windows do not
inherit them. `set-option -t <session> pane-border-status` quietly applies to
whichever window happens to be current, so only ONE window ever got pane
borders -- v1's rack display has had unlabeled borders on two of its three
windows this whole time and nobody noticed, because the window that got them
was the one usually on screen. This is the same trap that makes remain-on-exit
useless here. pane-border-status, pane-border-format and allow-rename are now
set per window in place(), and a test asserts every window has them.

Pane labels no longer use `select-pane -T`. The pane *title* is writable by
whatever runs in the pane: unifly probes for Kitty graphics support on startup
and tmux consumed part of that probe as a title change, so the border read
"Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA" instead of "unifly". Labels now live in a
pane-scoped user option, @socktop_label, which no escape sequence can reach;
pane-border-format falls back to the title if it is somehow unset.

Also: `terminal:` now expands a leading ~/ like `binaries:` already did. A
window manager's PATH rarely includes ~/.cargo/bin, so a full path is the usual
answer there and should not have to be spelled out longhand.

Build cost corrected from guesses to measurements on the LattePanda (Atom
x5-Z8350, 4 cores, 1.9 GB, no swap, toolchain already present): 108 seconds,
peak 1.1 GB, 103 MB target directory, 946 KB binary. The README said twenty
minutes and the installer budgeted 600 MB; both were wrong.

Integration tests now use one tmux session name each -- cargo runs them in
parallel and they were tearing down each other's server state.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 13:24:16 -07:00

253 lines
8.0 KiB
Rust

//! 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()
}
fn config(session: &str) -> Config {
// `true` exits at once, which also exercises remain-on-exit keeping the
// pane addressable afterwards.
let yaml = format!(
r#"
session: {session}
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")
}
/// (active window name, active pane label, is the window zoomed)
fn visible(session: &str) -> (String, String, bool) {
let s = tmux(&[
"display-message",
"-p",
"-t",
session,
"#{window_name}\t#{@socktop_label}\t#{window_zoomed_flag}",
]);
let f: Vec<&str> = s.split('\t').collect();
(f[0].into(), f[1].into(), f[2] == "1")
}
/// 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);
impl Drop for Cleanup {
fn drop(&mut self) {
let _ = Command::new("tmux")
.args(["kill-session", "-t", self.0])
.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;
}
const SESSION: &str = "socktop-swipe-selftest-nav";
let _cleanup = Cleanup(SESSION);
let cfg = config(SESSION);
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.
let windows = tmux(&[
"list-windows",
"-t",
SESSION,
"-F",
"#{window_name} #{window_panes}",
]);
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.
let (win, _, zoomed) = visible(SESSION);
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();
let (win, title, zoomed) = visible(SESSION);
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();
let (win, _, zoomed) = visible(SESSION);
assert_eq!(
win, "r0c5",
"0x5 follows 0x0 despite the gap in column numbers"
);
assert!(!zoomed);
// Back must land on 0x0's LAST host, not its overview.
let pos = grid.apply(Move::Back);
mux.show(&grid, &pos).unwrap();
let (win, title, zoomed) = visible(SESSION);
assert_eq!(
(win.as_str(), title.as_str(), zoomed),
("r0c0", "delta", true)
);
// Up to unifly: a single-pane cell, so nothing to zoom.
let pos = grid.apply(Move::Up);
mux.show(&grid, &pos).unwrap();
let (win, _, zoomed) = visible(SESSION);
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();
let (win, title, zoomed) = visible(SESSION);
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();
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"
);
}
}
#[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;
assert_eq!(
argv,
&["/bin/echo", "-P", "a b; touch /tmp/socktop-swipe-pwned"]
);
let quoted = shell_words::join(argv.iter().map(String::as_str));
let reparsed = shell_words::split(&quoted).unwrap();
assert_eq!(
&reparsed, argv,
"quoting must survive the round trip through sh"
);
}