use portable_pty::{native_pty_system, CommandBuilder, PtySize};
use proc_cli::core::filters::strip_verbatim_prefix;
use std::io::{BufRead, BufReader, Read, Write};
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::sync::{Arc, Mutex, MutexGuard};
use std::time::{Duration, Instant};
#[cfg(unix)]
const LEAVE_ALT_SCREEN: &str = "\x1b[?1049l";
#[derive(Clone, Copy, Debug)]
enum Step {
Expect(&'static str),
Send(&'static str),
#[cfg_attr(not(unix), allow(dead_code))]
Resize(u16, u16),
#[cfg_attr(unix, allow(dead_code))]
Pause(Duration),
#[cfg_attr(unix, allow(dead_code))]
StillRunning,
#[cfg(unix)]
Raw,
#[cfg(unix)]
Signal(i32),
}
#[derive(Debug)]
struct PtyRun {
output: String,
#[cfg_attr(unix, allow(dead_code))]
raw: Vec<u8>,
exit_code: u32,
#[cfg(unix)]
ended_by: Option<String>,
#[cfg(unix)]
mode_at_signal: Option<u64>,
#[cfg(unix)]
mode_at_end: Option<u64>,
}
#[cfg(unix)]
#[allow(clippy::unnecessary_cast)]
fn local_flags(master: &dyn portable_pty::MasterPty) -> Option<u64> {
master.get_termios().map(|t| t.local_flags.bits() as u64)
}
#[cfg(unix)]
#[allow(clippy::unnecessary_cast)]
fn is_cooked(flags: Option<u64>) -> bool {
let wanted = (libc::ICANON | libc::ECHO) as u64;
flags.is_some_and(|flags| flags & wanted == wanted)
}
const SESSION_LIMIT: Duration = Duration::from_secs(20);
struct ChildGuard(Box<dyn portable_pty::Child + Send + Sync>);
impl Drop for ChildGuard {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
fn plain(raw: &[u8]) -> String {
let text = String::from_utf8_lossy(raw).into_owned();
if cfg!(unix) {
return text;
}
let chars: Vec<char> = text.chars().collect();
let mut out = String::new();
let mut i = 0;
while i < chars.len() {
if chars[i] != '\x1b' {
out.push(chars[i]);
i += 1;
continue;
}
match chars.get(i + 1) {
None => break,
Some('[') => {
let mut j = i + 2;
while j < chars.len() && !('\x40'..='\x7e').contains(&chars[j]) {
j += 1;
}
if j == chars.len() {
break;
}
let params: String = chars[i + 2..j].iter().collect();
match chars[j] {
'C' => {
let n: usize = params.parse().unwrap_or(1);
out.push_str(&" ".repeat(n));
}
'H' | 'd' | 'B' | 'E' => out.push('\n'),
_ => {}
}
i = j + 1;
}
Some(']') => {
let mut j = i + 2;
while j < chars.len()
&& chars[j] != '\u{7}'
&& !(chars[j] == '\x1b' && chars.get(j + 1) == Some(&'\\'))
{
j += 1;
}
if j >= chars.len() {
break;
}
i = if chars[j] == '\u{7}' { j + 1 } else { j + 2 };
}
Some(_) => i += 2,
}
}
out
}
#[cfg(windows)]
fn answer_cursor_queries(raw: &[u8], answered: &mut usize, writer: &mut dyn Write) {
let asked = raw.windows(4).filter(|w| *w == b"\x1b[6n").count();
while *answered < asked {
writer
.write_all(b"\x1b[1;1R")
.expect("answer the cursor query");
writer.flush().expect("flush the pty");
*answered += 1;
}
}
fn run_in_pty(args: &[&str], script: &[Step]) -> PtyRun {
try_run_in_pty(args, script).unwrap_or_else(|why| panic!("{why}"))
}
fn try_run_in_pty(args: &[&str], script: &[Step]) -> Result<PtyRun, String> {
try_run_program_in_pty(env!("CARGO_BIN_EXE_proc"), args, script)
}
fn try_run_program_in_pty(program: &str, args: &[&str], script: &[Step]) -> Result<PtyRun, String> {
let deadline = Instant::now() + SESSION_LIMIT;
let pair = native_pty_system()
.openpty(PtySize {
rows: 40,
cols: 120,
pixel_width: 0,
pixel_height: 0,
})
.expect("open a pseudo-terminal");
let mut cmd = CommandBuilder::new(program);
cmd.args(args);
cmd.env("TERM", "xterm-256color");
cmd.env_remove("NO_COLOR");
let child = pair
.slave
.spawn_command(cmd)
.expect("spawn proc in the pty");
drop(pair.slave);
let mut guard = ChildGuard(child);
let mut writer = pair.master.take_writer().expect("pty writer");
let mut reader = pair.master.try_clone_reader().expect("pty reader");
let captured = Arc::new(Mutex::new(Vec::<u8>::new()));
let sink = Arc::clone(&captured);
let reading = std::thread::spawn(move || {
let mut chunk = [0u8; 4096];
while let Ok(n) = reader.read(&mut chunk) {
if n == 0 {
break;
}
sink.lock().unwrap().extend_from_slice(&chunk[..n]);
}
});
let text = || plain(&captured.lock().unwrap());
#[cfg(windows)]
let mut answered = 0;
#[cfg(windows)]
let mut answer = |writer: &mut Box<dyn Write + Send>| {
answer_cursor_queries(&captured.lock().unwrap(), &mut answered, writer)
};
let mut cursor = 0;
#[cfg(unix)]
let mut mode_at_signal = None;
for step in script {
match *step {
#[cfg(unix)]
Step::Raw => {
let until = Instant::now() + Duration::from_secs(5);
while is_cooked(local_flags(&*pair.master)) {
if Instant::now() >= until {
return Err(format!(
"the terminal never went raw; proc printed:\n{:?}",
text()
));
}
std::thread::sleep(Duration::from_millis(10));
}
}
#[cfg(unix)]
Step::Signal(signal) => {
mode_at_signal = local_flags(&*pair.master);
let pid = guard.0.process_id().expect("the child's PID");
unsafe {
libc::kill(pid as i32, signal);
}
}
Step::Send(keys) => {
writer
.write_all(keys.as_bytes())
.expect("type into the pty");
writer.flush().expect("flush the pty");
}
Step::Pause(span) => std::thread::sleep(span),
Step::StillRunning => assert!(
guard.0.try_wait().expect("poll the child").is_none(),
"proc exited early; it printed:\n{:?}",
text()
),
Step::Resize(cols, rows) => {
pair.master
.resize(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
})
.expect("resize the pty");
}
Step::Expect(wanted) => loop {
#[cfg(windows)]
answer(&mut writer);
let over =
guard.0.try_wait().expect("poll the child").is_some() && reading.is_finished();
let seen = text();
if let Some(at) = seen.get(cursor..).and_then(|rest| rest.find(wanted)) {
cursor += at + wanted.len();
break;
}
if over {
return Err(format!(
"proc ended without printing {wanted:?}; it printed:\n{seen:?}"
));
}
if Instant::now() >= deadline {
return Err(format!(
"timed out waiting for {wanted:?}; proc printed:\n{seen:?}"
));
}
std::thread::sleep(Duration::from_millis(10));
},
}
}
let status = loop {
#[cfg(windows)]
answer(&mut writer);
match guard.0.try_wait().expect("poll the child") {
Some(status) => break status,
None => {
assert!(
Instant::now() < deadline,
"proc did not exit in {SESSION_LIMIT:?}; it printed:\n{:?}",
text()
);
std::thread::sleep(Duration::from_millis(10));
}
}
};
#[cfg(unix)]
let mode_at_end = local_flags(&*pair.master);
#[cfg(windows)]
{
let master = pair.master;
std::thread::spawn(move || drop(master));
}
let drained = Instant::now() + Duration::from_secs(5);
while !reading.is_finished() && Instant::now() < drained {
std::thread::sleep(Duration::from_millis(5));
}
let raw = captured.lock().unwrap().clone();
Ok(PtyRun {
output: text(),
raw,
exit_code: status.exit_code(),
#[cfg(unix)]
ended_by: status.signal().map(String::from),
#[cfg(unix)]
mode_at_signal,
#[cfg(unix)]
mode_at_end,
})
}
#[test]
fn harness_runs_a_script() {
let run = run_in_pty(&["--version"], &[]);
assert_eq!(run.exit_code, 0, "{}", run.output);
assert!(run.output.contains("proc"), "{}", run.output);
}
#[cfg(unix)]
#[test]
fn the_comma_notice_is_coloured_on_a_terminal() {
let run = run_in_pty(&["kill", "99999999,99999998", "--dry-run"], &[]);
assert_eq!(run.exit_code, 2, "{}", run.output);
assert!(
run.output
.contains("\x1b[1;36mnote:\x1b[0m comma-separated targets are deprecated"),
"{:?}",
run.output
);
}
static BUSY: Mutex<()> = Mutex::new(());
#[cfg(unix)]
const SPIN: &str = "end=$(( $(date +%s) + 120 )); n=0; \
while :; do n=$((n+1)); \
if [ $((n % 50000)) -eq 0 ] && [ \"$(date +%s)\" -ge \"$end\" ]; then exit 0; fi; done";
struct Owned {
child: Child,
port: Option<u16>,
_tmp: tempfile::TempDir,
dir: PathBuf,
_busy: Option<MutexGuard<'static, ()>>,
}
fn fresh_dir() -> (tempfile::TempDir, PathBuf) {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().canonicalize().unwrap();
let dir = PathBuf::from(strip_verbatim_prefix(&dir.to_string_lossy()));
(tmp, dir)
}
impl Owned {
fn listener() -> Self {
let (tmp, dir) = fresh_dir();
let mut child = Command::new(listener_path())
.current_dir(&dir)
.stdout(Stdio::piped())
.stdin(Stdio::null())
.spawn()
.expect("spawn the listener example");
let mut line = String::new();
let mut stdout = BufReader::new(child.stdout.take().unwrap());
let read = stdout.read_line(&mut line);
let port = read
.ok()
.and_then(|_| line.trim().strip_prefix("PORT "))
.and_then(|p| p.parse().ok());
if port.is_none() {
let _ = child.kill();
let _ = child.wait();
panic!("the listener printed {line:?} instead of `PORT <n>`");
}
Owned {
child,
port,
_tmp: tmp,
dir,
_busy: None,
}
}
fn busy() -> Self {
let guard = BUSY.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
let (tmp, dir) = fresh_dir();
#[cfg(unix)]
let mut command = {
let mut command = Command::new("sh");
command.args(["-c", SPIN]);
command
};
#[cfg(windows)]
let mut command = {
let mut command = Command::new(listener_path());
command.arg("--busy");
command
};
let child = command
.current_dir(&dir)
.stdin(Stdio::null())
.stdout(Stdio::null())
.spawn()
.expect("spawn a busy process");
let busy = Owned {
child,
port: None,
_tmp: tmp,
dir,
_busy: Some(guard),
};
busy.wait_until_stuck();
busy
}
fn wait_until_stuck(&self) {
let deadline = Instant::now() + Duration::from_secs(30);
loop {
let out = Command::new(env!("CARGO_BIN_EXE_proc"))
.args(["stuck", "--timeout", "0", "--in", &self.dir(), "--json"])
.output()
.expect("run proc stuck");
let json: serde_json::Value =
serde_json::from_slice(&out.stdout).expect("proc stuck prints JSON");
let listed = json["processes"].as_array().is_some_and(|items| {
items
.iter()
.any(|p| p["pid"].as_u64() == Some(u64::from(self.child.id())))
});
if listed {
return;
}
assert!(
Instant::now() < deadline,
"the busy shell was never seen as stuck: {json}"
);
}
}
fn pid(&self) -> String {
self.child.id().to_string()
}
fn dir(&self) -> String {
self.dir.to_string_lossy().into_owned()
}
fn port(&self) -> String {
self.port.expect("a listener").to_string()
}
#[cfg(unix)]
fn signal(&self, sig: i32) {
unsafe {
libc::kill(self.child.id() as i32, sig);
}
}
fn alive(&mut self) -> bool {
matches!(self.child.try_wait(), Ok(None))
}
fn ended_within(&mut self, limit: Duration) -> bool {
let deadline = Instant::now() + limit;
while Instant::now() < deadline {
if !self.alive() {
return true;
}
std::thread::sleep(Duration::from_millis(50));
}
false
}
#[cfg(unix)]
fn state(&self) -> String {
let out = Command::new("ps")
.args(["-o", "stat=", "-p", &self.pid()])
.output()
.expect("run ps");
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
#[cfg(unix)]
fn wait_state(&self, stopped: bool, limit: Duration) -> bool {
let deadline = Instant::now() + limit;
while Instant::now() < deadline {
if self.state().starts_with('T') == stopped {
return true;
}
std::thread::sleep(Duration::from_millis(50));
}
false
}
}
impl Drop for Owned {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
#[cfg(unix)]
struct Orphan {
pid: i32,
gone: std::cell::Cell<bool>,
_tmp: tempfile::TempDir,
dir: PathBuf,
}
#[cfg(unix)]
impl Orphan {
fn start() -> Self {
let (tmp, dir) = fresh_dir();
let out = Command::new("sh")
.args(["-c", "sleep 300 >/dev/null 2>&1 </dev/null & echo $!"])
.current_dir(&dir)
.stdin(Stdio::null())
.output()
.expect("spawn the orphan's parent");
let pid: i32 = String::from_utf8_lossy(&out.stdout)
.trim()
.parse()
.expect("the shell prints the sleeper's pid");
let orphan = Orphan {
pid,
gone: std::cell::Cell::new(false),
_tmp: tmp,
dir,
};
let deadline = Instant::now() + Duration::from_secs(5);
while orphan.ppid() != Some(1) {
assert!(Instant::now() < deadline, "the sleeper was not reparented");
std::thread::sleep(Duration::from_millis(50));
}
orphan
}
fn ppid(&self) -> Option<i32> {
let out = Command::new("ps")
.args(["-o", "ppid=", "-p", &self.pid.to_string()])
.output()
.ok()?;
String::from_utf8_lossy(&out.stdout).trim().parse().ok()
}
fn dir(&self) -> String {
self.dir.to_string_lossy().into_owned()
}
fn alive(&self) -> bool {
let alive = unsafe { libc::kill(self.pid, 0) == 0 };
if !alive {
self.gone.set(true);
}
alive
}
fn gone_within(&self, limit: Duration) -> bool {
let deadline = Instant::now() + limit;
while Instant::now() < deadline {
let out = Command::new("ps")
.args(["-o", "stat=", "-p", &self.pid.to_string()])
.output()
.expect("run ps");
let state = String::from_utf8_lossy(&out.stdout).trim().to_string();
if state.is_empty() || state.starts_with('Z') {
self.gone.set(true);
return true;
}
std::thread::sleep(Duration::from_millis(50));
}
false
}
}
#[cfg(unix)]
impl Drop for Orphan {
fn drop(&mut self) {
if self.gone.get() {
return;
}
unsafe {
libc::kill(self.pid, libc::SIGKILL);
}
}
}
fn listener_path() -> PathBuf {
let mut path = std::env::current_exe().unwrap();
path.pop();
path.pop();
path.push("examples");
path.push(format!("listener{}", std::env::consts::EXE_SUFFIX));
assert!(path.exists(), "build the example first: {}", path.display());
path
}
const WAIT: Duration = Duration::from_secs(5);
const BUSY_PATIENCE: Duration = Duration::from_secs(30);
fn ask(args: &[&str], script: &[Step]) -> PtyRun {
let deadline = Instant::now() + BUSY_PATIENCE;
loop {
match try_run_in_pty(args, script) {
Ok(run) => return run,
Err(why) if why.contains("No stuck processes found") && Instant::now() < deadline => {}
Err(why) => panic!("{why}"),
}
}
}
fn answers_y(args: &[&str], prompt: &'static str) -> PtyRun {
ask(
args,
&[Step::Expect(prompt), Step::Send("y"), Step::Send("\r")],
)
}
fn answers_n(args: &[&str], prompt: &'static str, declined: &'static str) -> PtyRun {
let run = ask(
args,
&[
Step::Expect(prompt),
Step::Send("n"),
Step::Send("\r"),
Step::Expect(declined),
],
);
assert!(run.output.contains(declined), "{}", run.output);
run
}
fn answers_enter(args: &[&str], prompt: &'static str, declined: &'static str) -> PtyRun {
let run = ask(
args,
&[
Step::Expect(prompt),
Step::Send("\r"),
Step::Expect(declined),
],
);
assert!(run.output.contains(declined), "{}", run.output);
run
}
fn assert_ok(run: &PtyRun) {
assert_eq!(run.exit_code, 0, "{}", run.output);
}
#[test]
fn kill_asks_first() {
let mut p = Owned::listener();
let args = ["kill", &p.pid()];
let run = answers_n(&args, "Kill 1 process", "Cancelled");
assert_ok(&run);
assert!(p.alive(), "answering n left it running");
let run = answers_enter(&args, "Kill 1 process", "Cancelled");
assert_ok(&run);
assert!(p.alive(), "Enter alone left it running");
let run = answers_y(&args, "Kill 1 process");
assert_ok(&run);
assert!(
p.ended_within(WAIT),
"answering y killed it: {}",
run.output
);
}
#[test]
fn stop_asks_first() {
let mut p = Owned::listener();
let args = ["stop", &p.pid(), "--timeout", "1"];
let run = answers_n(&args, "Stop 1 process", "Cancelled");
assert_ok(&run);
assert!(p.alive());
let run = answers_enter(&args, "Stop 1 process", "Cancelled");
assert_ok(&run);
assert!(p.alive());
let run = answers_y(&args, "Stop 1 process");
assert_ok(&run);
assert!(
p.ended_within(WAIT),
"answering y stopped it: {}",
run.output
);
}
#[test]
fn free_asks_first() {
let mut p = Owned::listener();
let target = format!(":{}", p.port());
let args = ["free", &target];
let run = answers_n(&args, "Kill 1 process to free 1 port", "Aborted");
assert_ok(&run);
assert!(p.alive());
let run = answers_enter(&args, "Kill 1 process to free 1 port", "Aborted");
assert_ok(&run);
assert!(p.alive());
let run = answers_y(&args, "Kill 1 process to free 1 port");
assert_ok(&run);
assert!(p.ended_within(WAIT), "answering y freed it: {}", run.output);
}
#[cfg(unix)]
#[test]
fn freeze_asks_first() {
let p = Owned::listener();
let args = ["freeze", &p.pid()];
let run = answers_n(&args, "Freeze 1 process", "Cancelled");
assert_ok(&run);
assert!(!p.state().starts_with('T'), "answering n left it running");
let run = answers_enter(&args, "Freeze 1 process", "Cancelled");
assert_ok(&run);
assert!(!p.state().starts_with('T'));
let run = answers_y(&args, "Freeze 1 process");
assert_ok(&run);
assert!(
p.wait_state(true, WAIT),
"answering y froze it ({}): {}",
p.state(),
run.output
);
}
#[cfg(unix)]
#[test]
fn thaw_asks_first() {
let p = Owned::listener();
p.signal(libc::SIGSTOP);
assert!(p.wait_state(true, WAIT), "the test froze its listener");
let args = ["thaw", &p.pid()];
let run = answers_n(&args, "Thaw 1 process", "Cancelled");
assert_ok(&run);
assert!(p.state().starts_with('T'), "answering n left it frozen");
let run = answers_enter(&args, "Thaw 1 process", "Cancelled");
assert_ok(&run);
assert!(p.state().starts_with('T'));
let run = answers_y(&args, "Thaw 1 process");
assert_ok(&run);
assert!(
p.wait_state(false, WAIT),
"answering y thawed it ({}): {}",
p.state(),
run.output
);
}
#[test]
fn unstick_asks_first() {
let mut p = Owned::busy();
let dir = p.dir();
let args = ["unstick", "--force", "--timeout", "0", "--in", &dir];
let run = answers_n(&args, "Unstick 1 process", "Aborted");
assert_ok(&run);
assert!(p.alive());
let run = answers_enter(&args, "Unstick 1 process", "Aborted");
assert_ok(&run);
assert!(p.alive());
let run = answers_y(&args, "Unstick 1 process");
assert_ok(&run);
assert!(
p.ended_within(WAIT),
"answering y terminated it: {}",
run.output
);
}
#[test]
fn unstick_without_force_says_it_will_only_try_to_recover() {
let mut p = Owned::busy();
let dir = p.dir();
let args = ["unstick", "--timeout", "0", "--in", &dir];
let run = answers_n(&args, "Unstick 1 process", "Aborted");
assert_ok(&run);
assert!(
run.output.contains("Will attempt recovery only"),
"{}",
run.output
);
assert!(p.alive());
}
#[test]
fn stuck_kill_asks_first() {
let mut p = Owned::busy();
let dir = p.dir();
let args = ["stuck", "--kill", "--timeout", "0", "--in", &dir];
let run = answers_n(&args, "Kill 1 stuck process", "Cancelled");
assert_ok(&run);
assert!(p.alive());
let run = answers_enter(&args, "Kill 1 stuck process", "Cancelled");
assert_ok(&run);
assert!(p.alive());
let run = answers_y(&args, "Kill 1 stuck process");
assert_ok(&run);
assert!(
p.ended_within(WAIT),
"answering y killed it: {}",
run.output
);
}
#[cfg(unix)]
#[test]
fn orphans_kill_asks_first() {
let o = Orphan::start();
let dir = o.dir();
let args = ["orphans", "--kill", "--in", &dir];
let run = answers_n(&args, "Kill 1 orphaned process", "Cancelled");
assert_ok(&run);
assert!(o.alive());
let run = answers_enter(&args, "Kill 1 orphaned process", "Cancelled");
assert_ok(&run);
assert!(o.alive());
let run = answers_y(&args, "Kill 1 orphaned process");
assert_ok(&run);
assert!(o.gone_within(WAIT), "answering y killed it: {}", run.output);
}
fn watch_quits_on(keys: &'static str) {
let p = Owned::listener();
let pid = p.pid();
let run = run_in_pty(
&["watch", &pid],
&[Step::Expect("Watching"), Step::Send(keys)],
);
assert_ok(&run);
#[cfg(unix)]
assert!(
run.output.ends_with(LEAVE_ALT_SCREEN),
"the screen is left last: {:?}",
&run.output[run.output.len().saturating_sub(60)..]
);
#[cfg(windows)]
{
let raw = String::from_utf8_lossy(&run.raw);
let entered = raw.find("\x1b[?1049h");
let left = raw.rfind("\x1b[?1049l");
assert!(
matches!((entered, left), (Some(e), Some(l)) if e < l),
"the alternate screen is entered, then left: {raw:?}"
);
}
}
#[test]
fn watch_quits_on_q() {
watch_quits_on("q");
}
#[cfg(unix)]
#[test]
fn watch_quits_on_escape() {
watch_quits_on("\x1b");
}
#[cfg(unix)]
#[test]
fn watch_quits_on_ctrl_c() {
watch_quits_on("\x03");
}
#[test]
fn watch_shows_the_pid() {
let p = Owned::listener();
let pid = p.pid();
let run = run_in_pty(
&["watch", &pid],
&[
Step::Expect("Watching"),
Step::Expect("listener"),
Step::Send("q"),
],
);
assert_ok(&run);
assert!(run.output.contains(&pid), "{}", run.output);
}
#[cfg(unix)]
#[test]
fn watch_redraws_every_interval() {
let p = Owned::listener();
let pid = p.pid();
let run = run_in_pty(
&["watch", &pid, "--interval", "0.2"],
&[
Step::Expect("Watching"),
Step::Expect("Watching"),
Step::Expect("Watching"),
Step::Send("q"),
],
);
assert_ok(&run);
assert!(run.output.contains("Refresh: 0.2s"), "{}", run.output);
assert!(
run.output.ends_with(LEAVE_ALT_SCREEN),
"the screen is left last"
);
}
#[cfg(unix)]
#[test]
fn watch_keeps_running_when_another_key_is_pressed() {
let p = Owned::listener();
let pid = p.pid();
let run = run_in_pty(
&["watch", &pid, "--interval", "0.2"],
&[
Step::Expect("Watching"),
Step::Send("x\r"),
Step::Expect("Watching"),
Step::Expect("Watching"),
Step::Send("q"),
],
);
assert_ok(&run);
}
#[cfg(windows)]
#[test]
fn watch_ignores_other_keys_and_quits_on_q() {
let p = Owned::listener();
let pid = p.pid();
let run = run_in_pty(
&["watch", &pid, "--interval", "0.2"],
&[
Step::Expect("Watching"),
Step::Send("x"),
Step::Pause(Duration::from_millis(1000)),
Step::StillRunning,
Step::Send("q"),
],
);
assert_ok(&run);
assert!(run.output.contains(&pid), "{}", run.output);
}
#[cfg(windows)]
#[test]
fn watch_goes_on_when_no_key_is_pressed() {
let p = Owned::listener();
let pid = p.pid();
let run = run_in_pty(
&["watch", &pid, "--interval", "0.2"],
&[
Step::Expect("Watching"),
Step::Pause(Duration::from_millis(1000)),
Step::StillRunning,
Step::Send("q"),
],
);
assert_ok(&run);
assert!(run.output.contains("Refresh: 0.2s"), "{}", run.output);
}
#[cfg(unix)]
#[test]
fn watch_draws_the_new_size_after_a_resize() {
let p = Owned::listener();
let pid = p.pid();
let run = run_in_pty(
&["watch", &pid, "--interval", "0.2"],
&[
Step::Expect("Watching"),
Step::Expect("listener"),
Step::Resize(120, 5),
Step::Expect(" ... and 1 more (use --limit to control)"),
Step::Resize(80, 40),
Step::Expect("listener"),
Step::Send("q"),
],
);
assert_ok(&run);
assert!(
run.output.ends_with(LEAVE_ALT_SCREEN),
"the screen is left last"
);
}
#[test]
fn watch_says_when_nothing_matches() {
let run = run_in_pty(
&["watch", "99999999"],
&[
Step::Expect("No matching processes found."),
Step::Send("q"),
],
);
assert_ok(&run);
assert!(run.output.contains("0 processes"), "{}", run.output);
}
#[test]
fn watch_shows_the_sort_in_its_header() {
let p = Owned::listener();
let pid = p.pid();
let run = run_in_pty(
&["watch", &pid, "--sort", "mem"],
&[Step::Expect("Sort: Memory"), Step::Send("q")],
);
assert_ok(&run);
}
#[test]
fn watch_verbose_lists_the_details() {
let p = Owned::listener();
let pid = p.pid();
let dir = p.dir();
let run = run_in_pty(
&["watch", &pid, "--verbose"],
&[
Step::Expect("cwd: "),
Step::Expect("uptime: "),
Step::Send("q"),
],
);
assert_ok(&run);
#[cfg(unix)]
assert!(
run.output.contains(&format!("cwd: {dir}")),
"{}",
run.output
);
#[cfg(windows)]
{
let same_dir = |shown: &str| {
let resolve = |path: &str| {
std::fs::canonicalize(path)
.map(|p| strip_verbatim_prefix(&p.to_string_lossy()).to_lowercase())
};
matches!((resolve(shown), resolve(&dir)), (Ok(a), Ok(b)) if a == b)
};
let shown: Vec<&str> = run
.output
.split("cwd: ")
.skip(1)
.map(|rest| rest.lines().next().unwrap_or("").trim())
.collect();
assert!(
shown.iter().any(|path| same_dir(path)),
"no cwd line names {dir}: {shown:?}"
);
}
assert!(run.output.contains("cmd: "), "{}", run.output);
}
#[cfg(unix)]
const END_SIGNALS: [(i32, &str); 4] = [
(libc::SIGTERM, "SIGTERM"),
(libc::SIGINT, "SIGINT"),
(libc::SIGHUP, "SIGHUP"),
(libc::SIGQUIT, "SIGQUIT"),
];
#[cfg(unix)]
fn assert_ended_by(run: &PtyRun, signal: i32, what: &str) {
let name = unsafe { std::ffi::CStr::from_ptr(libc::strsignal(signal)) }
.to_string_lossy()
.into_owned();
assert_eq!(
run.ended_by.as_deref(),
Some(name.as_str()),
"{what}: exit {}: {}",
run.exit_code,
run.output
);
}
#[cfg(unix)]
fn last_at(raw: &str, needle: &str) -> Option<usize> {
raw.rfind(needle)
}
#[cfg(unix)]
fn assert_terminal_back(run: &PtyRun, what: &str) {
let raw = String::from_utf8_lossy(&run.raw);
assert!(
is_cooked(run.mode_at_end),
"{what}: ICANON and ECHO are on again at the end: {:x?}\n{raw:?}",
run.mode_at_end
);
let hidden = last_at(&raw, "\x1b[?25l");
let shown = last_at(&raw, "\x1b[?25h");
assert!(
hidden.is_some() && shown > hidden,
"{what}: the cursor is shown after it was hidden: {raw:?}"
);
}
#[cfg(unix)]
fn ask_with_stdout(redirect: &str, args: &[&str], script: &[Step]) -> PtyRun {
let command = format!("\"$@\" {redirect}");
let mut shell = vec!["-c", command.as_str(), "sh", env!("CARGO_BIN_EXE_proc")];
shell.extend_from_slice(args);
let deadline = Instant::now() + BUSY_PATIENCE;
loop {
match try_run_program_in_pty("sh", &shell, script) {
Ok(run) => return run,
Err(_) if Instant::now() < deadline => {}
Err(why) => panic!("{why}"),
}
}
}
#[cfg(unix)]
#[test]
fn watch_puts_the_terminal_back_when_a_signal_ends_it() {
for (signal, name) in END_SIGNALS {
let p = Owned::listener();
let run = run_in_pty(
&["watch", &p.pid()],
&[Step::Expect("Watching"), Step::Raw, Step::Signal(signal)],
);
assert_ended_by(&run, signal, name);
assert!(
!is_cooked(run.mode_at_signal),
"{name}: watch had turned raw mode on: {:x?}",
run.mode_at_signal
);
let raw = String::from_utf8_lossy(&run.raw);
let entered = last_at(&raw, "\x1b[?1049h");
let left = last_at(&raw, LEAVE_ALT_SCREEN);
assert!(
entered.is_some() && left > entered,
"{name}: the alternate screen is left: {raw:?}"
);
assert_terminal_back(&run, name);
}
}
#[cfg(unix)]
#[test]
fn a_prompt_puts_the_terminal_back_when_a_signal_ends_it() {
for (signal, name) in END_SIGNALS {
let mut p = Owned::listener();
let run = run_in_pty(
&["kill", &p.pid()],
&[
Step::Expect("Kill 1 process"),
Step::Raw,
Step::Signal(signal),
],
);
assert_ended_by(&run, signal, name);
assert!(
!is_cooked(run.mode_at_signal),
"{name}: the prompt had turned line editing and echo off: {:x?}",
run.mode_at_signal
);
assert_terminal_back(&run, name);
assert!(p.alive(), "{name}: the signal killed the target");
}
}
#[cfg(unix)]
#[test]
fn ctrl_c_at_a_prompt_puts_the_terminal_back() {
let mut p = Owned::listener();
let run = run_in_pty(
&["kill", &p.pid()],
&[
Step::Expect("Kill 1 process"),
Step::Raw,
Step::Send("\x03"),
],
);
assert_ended_by(&run, libc::SIGINT, "Ctrl+C");
assert_terminal_back(&run, "Ctrl+C");
assert!(p.alive(), "Ctrl+C killed the target");
}
#[cfg(unix)]
fn assert_list_beside_the_question(run: &PtyRun, listed: &str, asked: &str, file: &PathBuf) {
let list = run.output.find(listed);
let question = run.output.find(asked);
assert!(
list.is_some() && question.is_some() && list < question,
"the list ({listed:?}) comes before the question ({asked:?}):\n{}",
run.output
);
let stdout = std::fs::read_to_string(file).unwrap_or_default();
assert!(
!stdout.contains(listed),
"the list is not in stdout, which is the command's output:\n{stdout}"
);
}
#[cfg(unix)]
#[test]
fn kill_shows_its_list_on_the_terminal_when_stdout_is_a_file() {
let mut p = Owned::listener();
let (_tmp, dir) = fresh_dir();
let file = dir.join("stdout.txt");
let redirect = format!("> '{}'", file.display());
let run = ask_with_stdout(
&redirect,
&["kill", &p.pid()],
&[
Step::Expect("Found 1 process to kill"),
Step::Expect("Kill 1 process"),
Step::Send("n"),
Step::Send("\r"),
],
);
assert_ok(&run);
assert_list_beside_the_question(&run, "Found 1 process to kill", "Kill 1 process", &file);
assert!(p.alive());
let stdout = std::fs::read_to_string(&file).unwrap();
assert!(stdout.contains("Cancelled"), "{stdout}");
}
#[cfg(unix)]
#[test]
fn kill_shows_its_list_when_the_reader_of_stdout_has_left() {
let mut p = Owned::listener();
let run = ask_with_stdout(
"| true",
&["kill", &p.pid()],
&[
Step::Expect("Found 1 process to kill"),
Step::Expect("Kill 1 process"),
Step::Send("n"),
Step::Send("\r"),
],
);
assert_ok(&run);
assert!(!run.output.contains("panicked"), "{}", run.output);
assert!(p.alive());
}
#[cfg(unix)]
#[test]
fn free_shows_its_list_on_the_terminal_when_stdout_is_a_file() {
let mut p = Owned::listener();
let target = format!(":{}", p.port());
let (_tmp, dir) = fresh_dir();
let file = dir.join("stdout.txt");
let redirect = format!("> '{}'", file.display());
let run = ask_with_stdout(
&redirect,
&["free", &target],
&[
Step::Expect("Found 1 process to free"),
Step::Expect("Kill 1 process to free 1 port"),
Step::Send("n"),
Step::Send("\r"),
],
);
assert_ok(&run);
assert_list_beside_the_question(
&run,
"Found 1 process to free",
"Kill 1 process to free 1 port",
&file,
);
assert!(p.alive());
}
#[cfg(unix)]
#[test]
fn unstick_shows_its_list_on_the_terminal_when_stdout_is_a_file() {
let mut p = Owned::busy();
let dir = p.dir();
let (_tmp, out_dir) = fresh_dir();
let file = out_dir.join("stdout.txt");
let redirect = format!("> '{}'", file.display());
let run = ask_with_stdout(
&redirect,
&["unstick", "--force", "--timeout", "0", "--in", &dir],
&[
Step::Expect("Found stuck 1 process"),
Step::Expect("Unstick 1 process"),
Step::Send("n"),
Step::Send("\r"),
],
);
assert_ok(&run);
assert_list_beside_the_question(&run, "Found stuck 1 process", "Unstick 1 process", &file);
assert!(p.alive());
}
#[cfg(unix)]
#[test]
fn stuck_shows_its_list_on_the_terminal_when_stdout_is_a_file() {
let mut p = Owned::busy();
let dir = p.dir();
let (_tmp, out_dir) = fresh_dir();
let file = out_dir.join("stdout.txt");
let redirect = format!("> '{}'", file.display());
let run = ask_with_stdout(
&redirect,
&["stuck", "--kill", "--timeout", "0", "--in", &dir],
&[
Step::Expect("CPU%"),
Step::Expect("Kill 1 stuck process"),
Step::Send("n"),
Step::Send("\r"),
],
);
assert_ok(&run);
assert_list_beside_the_question(&run, "CPU%", "Kill 1 stuck process", &file);
assert!(p.alive());
}
#[cfg(unix)]
#[test]
fn orphans_shows_its_list_on_the_terminal_when_stdout_is_a_file() {
let o = Orphan::start();
let dir = o.dir();
let (_tmp, out_dir) = fresh_dir();
let file = out_dir.join("stdout.txt");
let redirect = format!("> '{}'", file.display());
let run = ask_with_stdout(
&redirect,
&["orphans", "--kill", "--in", &dir],
&[
Step::Expect("CPU%"),
Step::Expect("Kill 1 orphaned process"),
Step::Send("n"),
Step::Send("\r"),
],
);
assert_ok(&run);
assert_list_beside_the_question(&run, "CPU%", "Kill 1 orphaned process", &file);
assert!(o.alive());
}
#[cfg(unix)]
#[test]
fn watch_without_a_terminal_on_stdin_shows_one_snapshot_and_says_so() {
let p = Owned::listener();
let pid = p.pid();
let run = try_run_program_in_pty(
"sh",
&[
"-c",
"\"$@\" < /dev/null",
"sh",
env!("CARGO_BIN_EXE_proc"),
"watch",
&pid,
],
&[
Step::Expect("stdin is not a terminal"),
Step::Expect("CPU%"),
],
)
.unwrap_or_else(|why| panic!("{why}"));
assert_ok(&run);
assert!(run.output.contains(&pid), "{}", run.output);
let raw = String::from_utf8_lossy(&run.raw);
assert!(
!raw.contains("\x1b[?1049h"),
"no alternate screen is drawn: {raw:?}"
);
assert!(
!run.output.contains("Failed to initialize"),
"{}",
run.output
);
}