//! 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; const SESSION: &str = "socktop-swipe-selftest"; 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() -> 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 title, is the window zoomed) fn visible() -> (String, String, bool) { let s = tmux(&[ "display-message", "-p", "-t", SESSION, "#{window_name}\t#{pane_title}\t#{window_zoomed_flag}", ]); let f: Vec<&str> = s.split('\t').collect(); (f[0].into(), f[1].into(), f[2] == "1") } struct Cleanup; impl Drop for Cleanup { fn drop(&mut self) { let _ = Command::new("tmux") .args(["kill-session", "-t", SESSION]) .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; } let _cleanup = Cleanup; let cfg = config(); 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!["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(); 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(); 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(); 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(); 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(); 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(); 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().0, "r1c0"); } #[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("ed).unwrap(); assert_eq!(&reparsed, argv, "quoting must survive the round trip through sh"); }