#![cfg(unix)]
use portable_pty::{CommandBuilder, PtySize, native_pty_system};
use std::io::Read;
use std::sync::mpsc;
use std::time::{Duration, Instant};
const COLS: u16 = 120;
const ROWS: u16 = 36;
struct Tui {
parser: vt100::Parser,
rx: mpsc::Receiver<Vec<u8>>,
writer: Box<dyn std::io::Write + Send>,
child: Box<dyn portable_pty::Child + Send + Sync>,
raw: Vec<u8>,
_master: Box<dyn portable_pty::MasterPty + Send>,
}
impl Tui {
fn spawn(extra_args: &[&str]) -> Self {
let pty = native_pty_system()
.openpty(PtySize {
rows: ROWS,
cols: COLS,
pixel_width: 0,
pixel_height: 0,
})
.unwrap();
let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_gpur"));
cmd.args(["--mock", "--no-splash", "--tick-ms", "100"]);
cmd.args(extra_args);
cmd.env("TERM", "xterm-256color");
cmd.env("COLORTERM", "truecolor");
let child = pty.slave.spawn_command(cmd).unwrap();
drop(pty.slave);
let mut reader = pty.master.try_clone_reader().unwrap();
let (tx, rx) = mpsc::channel::<Vec<u8>>();
std::thread::spawn(move || {
let mut buf = [0u8; 8192];
while let Ok(n) = reader.read(&mut buf) {
if n == 0 || tx.send(buf[..n].to_vec()).is_err() {
break;
}
}
});
Tui {
parser: vt100::Parser::new(ROWS, COLS, 0),
rx,
writer: pty.master.take_writer().unwrap(),
child,
raw: Vec::new(),
_master: pty.master,
}
}
fn pump_once(&mut self, timeout: Duration) -> bool {
match self.rx.recv_timeout(timeout) {
Ok(bytes) => {
self.raw.extend_from_slice(&bytes);
self.parser.process(&bytes);
true
}
Err(_) => false,
}
}
fn screen_text(&self) -> String {
self.parser.screen().contents()
}
fn wait_for(&mut self, what: &str, pred: impl Fn(&str) -> bool) {
let deadline = Instant::now() + Duration::from_secs(10);
loop {
if pred(&self.screen_text()) {
return;
}
assert!(
Instant::now() < deadline,
"timed out waiting for {what}; screen:\n{}",
self.screen_text()
);
self.pump_once(Duration::from_millis(100));
}
}
fn send(&mut self, keys: &str) {
self.writer.write_all(keys.as_bytes()).unwrap();
self.writer.flush().unwrap();
}
fn wait_exit(&mut self) -> portable_pty::ExitStatus {
let deadline = Instant::now() + Duration::from_secs(10);
loop {
self.pump_once(Duration::from_millis(50));
if let Ok(Some(status)) = self.child.try_wait() {
while self.pump_once(Duration::from_millis(100)) {}
return status;
}
assert!(Instant::now() < deadline, "child did not exit");
}
}
}
#[test]
fn renders_dashboard_and_process_table() {
let mut t = Tui::spawn(&[]);
t.wait_for("GPU cards", |s| {
s.contains("Mock GPU 0") && s.contains("Mock GPU 1")
});
t.wait_for("process table", |s| s.contains("COMMAND"));
t.wait_for("meters", |s| s.contains("GPU ") && s.contains("MEM "));
t.send("q");
assert!(t.wait_exit().success());
}
#[test]
fn fold_toggles_card() {
let mut t = Tui::spawn(&[]);
t.wait_for("cards", |s| s.contains("Mock GPU 0"));
t.send("0");
t.wait_for("folded summary", |s| s.contains("▸ 0·Mock GPU 0"));
t.send("0");
t.wait_for("unfolded card", |s| !s.contains("▸ 0·Mock GPU 0"));
t.send("q");
t.wait_exit();
}
#[test]
fn filter_narrows_process_table() {
let mut t = Tui::spawn(&[]);
t.wait_for("process rows", |s| s.contains("COMMAND"));
t.send("/gpur\r");
t.wait_for("filter caption", |s| s.contains("filter:gpur"));
t.send("q");
t.wait_exit();
}
#[test]
fn quit_restores_terminal_modes() {
let mut t = Tui::spawn(&[]);
t.wait_for("cards", |s| s.contains("Mock GPU 0"));
t.send("q");
assert!(t.wait_exit().success());
let raw = String::from_utf8_lossy(&t.raw);
assert!(raw.contains("[?1049l"), "alt screen not left");
assert!(raw.contains("[?1006l"), "mouse capture not disabled");
}
#[test]
fn survives_resize_storm() {
let mut t = Tui::spawn(&[]);
t.wait_for("cards", |s| s.contains("Mock GPU 0"));
for (rows, cols) in [
(5, 5),
(2, 40),
(50, 3),
(1, 1),
(200, 250),
(10, 30),
(ROWS, COLS),
] {
t._master
.resize(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
})
.unwrap();
t.pump_once(Duration::from_millis(50));
}
for _ in 0..10 {
t.pump_once(Duration::from_millis(100));
}
let deadline = Instant::now() + Duration::from_secs(15);
loop {
assert!(
!matches!(t.child.try_wait(), Ok(Some(_))),
"app exited during/after resize storm"
);
t.send("?");
let round = Instant::now() + Duration::from_secs(2);
while Instant::now() < round {
t.pump_once(Duration::from_millis(100));
if t.screen_text().contains("any key closes") {
break;
}
}
if t.screen_text().contains("any key closes") {
break;
}
assert!(
Instant::now() < deadline,
"no response to '?' after resize storm; screen:\n{}",
t.screen_text()
);
}
t.send(" "); t.send("q");
assert!(t.wait_exit().success());
}
#[test]
fn help_overlay_opens_and_closes() {
let mut t = Tui::spawn(&[]);
t.wait_for("cards", |s| s.contains("Mock GPU 0"));
t.send("?");
t.wait_for("help overlay", |s| s.contains("any key closes"));
t.send("x"); t.wait_for("overlay gone", |s| {
!s.contains("any key closes") && !s.contains("SIGTERM")
});
t.send("q");
t.wait_exit();
}
#[test]
fn sigterm_restores_terminal_modes() {
let mut t = Tui::spawn(&[]);
t.wait_for("cards", |s| s.contains("Mock GPU 0"));
let pid = t.child.process_id().expect("child pid") as i32;
unsafe { libc_kill(pid) };
let status = t.wait_exit();
assert!(!status.success()); let raw = String::from_utf8_lossy(&t.raw);
assert!(raw.contains("[?1049l"), "alt screen not left on SIGTERM");
assert!(
raw.contains("[?1006l"),
"mouse capture not disabled on SIGTERM"
);
}
#[test]
fn process_rows_show_real_content() {
let mut t = Tui::spawn(&[]);
let pid = t.child.process_id().expect("child pid").to_string();
t.wait_for("own process row", move |s| {
s.contains(&pid) && s.contains("gpur")
});
t.send("q");
t.wait_exit();
}
#[test]
fn sort_cycle_and_reverse_update_caption() {
let mut t = Tui::spawn(&[]);
t.wait_for("default sort", |s| s.contains("gpu-mem↓"));
t.send("s");
t.wait_for("cycled to gpu%", |s| s.contains("gpu%↓"));
t.send("r");
t.wait_for("reversed arrow", |s| s.contains("gpu%↑"));
t.send("q");
t.wait_exit();
}
#[test]
fn process_cursor_highlight_moves() {
let mut t = Tui::spawn(&[]);
t.wait_for("process rows", |s| s.contains("COMMAND"));
t.send("p"); let first = highlighted_row(&mut t).expect("a highlighted row");
t.send("j");
let deadline = Instant::now() + Duration::from_secs(10);
loop {
t.pump_once(Duration::from_millis(100));
if let Some(now) = highlighted_row(&mut t)
&& now != first
{
break;
}
assert!(Instant::now() < deadline, "cursor highlight never moved");
}
t.send("q");
t.wait_exit();
}
fn highlighted_row(t: &mut Tui) -> Option<String> {
t.pump_once(Duration::from_millis(50));
let screen = t.parser.screen();
for row in 0..ROWS {
if matches!(
screen.cell(row, 2).map(|c| c.bgcolor()),
Some(vt100::Color::Rgb(0x45, 0x47, 0x5a))
) {
let text: String = (0..COLS)
.filter_map(|c| screen.cell(row, c).map(|cl| cl.contents()))
.collect();
if !text.trim().is_empty() {
return Some(text);
}
}
}
None
}
#[test]
fn kill_dialog_opens_and_cancels() {
let mut t = Tui::spawn(&[]);
t.wait_for("process rows", |s| s.contains("COMMAND"));
t.send("p");
t.send("x");
t.wait_for("confirm popup", |s| s.contains("send SIGTERM to"));
t.send("n"); t.wait_for("popup gone", |s| !s.contains("send SIGTERM to"));
assert!(
!matches!(t.child.try_wait(), Ok(Some(_))),
"app died after cancelled kill"
);
t.send("q");
assert!(t.wait_exit().success());
}
unsafe fn libc_kill(pid: i32) {
unsafe extern "C" {
fn kill(pid: i32, sig: i32) -> i32;
}
unsafe {
kill(pid, 15);
}
}