use std::os::fd::RawFd;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
pub(crate) struct TestTty {
master: RawFd,
saved_in: RawFd,
saved_out: RawFd,
saved_err: Option<RawFd>,
painted: Arc<Mutex<Vec<u8>>>,
stop: Arc<AtomicBool>,
responder: Option<std::thread::JoinHandle<()>>,
}
impl TestTty {
pub(crate) fn install() -> Self {
Self::install_at(10, 1)
}
pub(crate) fn install_at(row: u16, col: u16) -> Self {
assert!(row > 0 && col > 0, "cursor replies are one-based");
unsafe {
let (mut master, mut slave) = (-1, -1);
let mut size = libc::winsize {
ws_row: 24,
ws_col: 80,
ws_xpixel: 0,
ws_ypixel: 0,
};
assert_eq!(
libc::openpty(
&mut master,
&mut slave,
std::ptr::null_mut(),
std::ptr::null_mut::<libc::termios>(),
std::ptr::from_mut(&mut size),
),
0,
"openpty for the test's terminal"
);
let saved_in = libc::dup(0);
let saved_out = libc::dup(1);
assert!(libc::dup2(slave, 0) >= 0, "dup2 onto fd 0");
assert!(libc::dup2(slave, 1) >= 0, "dup2 onto fd 1");
libc::close(slave);
assert_eq!(libc::isatty(0), 1, "fd 0 must be a tty for crossterm");
let _ = crossterm::terminal::disable_raw_mode();
let painted = Arc::new(Mutex::new(Vec::new()));
let stop = Arc::new(AtomicBool::new(false));
let cursor_reply = format!("\x1b[{row};{col}R").into_bytes();
let responder = {
let painted = Arc::clone(&painted);
let stop = Arc::clone(&stop);
std::thread::spawn(move || {
answer_terminal_queries(master, &painted, &stop, &cursor_reply);
})
};
Self {
master,
saved_in,
saved_out,
saved_err: None,
painted,
stop,
responder: Some(responder),
}
}
}
pub(crate) fn capture_stderr(&mut self) {
assert!(self.saved_err.is_none());
unsafe {
let saved = libc::dup(2);
assert!(saved >= 0);
assert!(libc::dup2(1, 2) >= 0);
self.saved_err = Some(saved);
}
}
pub(crate) fn type_bytes(&self, bytes: &[u8]) {
let n = unsafe { libc::write(self.master, bytes.as_ptr().cast(), bytes.len()) };
assert_eq!(n as usize, bytes.len(), "write to pty master");
}
pub(crate) fn painted(&self) -> String {
let buf = self
.painted
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
String::from_utf8_lossy(&buf).into_owned()
}
pub(crate) fn type_when_painted(
&self,
needle: &str,
bytes: &[u8],
) -> std::thread::JoinHandle<bool> {
let master = self.master;
let painted = Arc::clone(&self.painted);
let needle = needle.as_bytes().to_vec();
let bytes = bytes.to_vec();
std::thread::spawn(move || {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
let saw_prompt = loop {
let saw = painted
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.windows(needle.len())
.any(|window| window == needle);
if saw || std::time::Instant::now() >= deadline {
break saw;
}
std::thread::sleep(std::time::Duration::from_millis(5));
};
let n = unsafe { libc::write(master, bytes.as_ptr().cast(), bytes.len()) };
assert_eq!(n as usize, bytes.len(), "write delayed terminal input");
saw_prompt
})
}
pub(crate) fn wait_for_painted_after(
&self,
from: usize,
needle: &str,
timeout: std::time::Duration,
) -> bool {
wait_for_after(&self.painted, from, needle, timeout)
}
}
fn wait_for_after(
buf: &Mutex<Vec<u8>>,
from: usize,
needle: &str,
timeout: std::time::Duration,
) -> bool {
tests_pty::wait_for_bytes(buf, from, needle, timeout)
}
fn answer_terminal_queries(
master: RawFd,
painted: &Mutex<Vec<u8>>,
stop: &AtomicBool,
cursor_reply: &[u8],
) {
let mut buf = [0u8; 4096];
let mut pending = Vec::new();
while !stop.load(Ordering::SeqCst) {
let mut pfd = libc::pollfd {
fd: master,
events: libc::POLLIN,
revents: 0,
};
if unsafe { libc::poll(&mut pfd, 1, 20) } <= 0 {
continue;
}
let n = unsafe { libc::read(master, buf.as_mut_ptr().cast(), buf.len()) };
if n <= 0 {
break; }
let chunk = &buf[..n as usize];
pending.extend_from_slice(chunk);
painted
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.extend_from_slice(chunk);
while let Some(at) = find(&pending, b"\x1b[6n") {
unsafe { libc::write(master, cursor_reply.as_ptr().cast(), cursor_reply.len()) };
pending.drain(..at + 4);
}
if pending.len() > 8 {
let keep = pending.len() - 8; pending.drain(..keep);
}
}
}
fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack.windows(needle.len()).position(|w| w == needle)
}
impl Drop for TestTty {
fn drop(&mut self) {
let _ = crossterm::terminal::disable_raw_mode();
self.stop.store(true, Ordering::SeqCst);
if let Some(h) = self.responder.take() {
let _ = h.join();
}
unsafe {
if let Some(saved) = self.saved_err.take() {
libc::dup2(saved, 2);
libc::close(saved);
}
libc::dup2(self.saved_in, 0);
libc::dup2(self.saved_out, 1);
libc::close(self.saved_in);
libc::close(self.saved_out);
libc::close(self.master);
}
}
}
pub(crate) fn termios_of(fd: RawFd) -> libc::termios {
unsafe {
let mut t: libc::termios = std::mem::zeroed();
assert_eq!(libc::tcgetattr(fd, &mut t), 0, "tcgetattr fd {fd}");
t
}
}
pub(crate) fn set_canonical_echo(fd: RawFd) {
unsafe {
let mut t = termios_of(fd);
t.c_lflag |= libc::ICANON | libc::ECHO;
assert_eq!(libc::tcsetattr(fd, libc::TCSANOW, &t), 0, "tcsetattr");
}
}
pub(crate) fn is_canonical(fd: RawFd) -> bool {
termios_of(fd).c_lflag & libc::ICANON != 0
}
pub(crate) fn echoes(fd: RawFd) -> bool {
termios_of(fd).c_lflag & libc::ECHO != 0
}
const TRANSIENT_LFLAGS: libc::tcflag_t = libc::PENDIN | libc::FLUSHO;
pub(crate) fn modes_equal(a: &libc::termios, b: &libc::termios) -> bool {
mode_diff(a, b).is_empty()
}
pub(crate) fn mode_diff(a: &libc::termios, b: &libc::termios) -> String {
let mut out = Vec::new();
if a.c_lflag & !TRANSIENT_LFLAGS != b.c_lflag & !TRANSIENT_LFLAGS {
out.push(format!("c_lflag {:#x} -> {:#x}", a.c_lflag, b.c_lflag));
}
if a.c_iflag != b.c_iflag {
out.push(format!("c_iflag {:#x} -> {:#x}", a.c_iflag, b.c_iflag));
}
if a.c_oflag != b.c_oflag {
out.push(format!("c_oflag {:#x} -> {:#x}", a.c_oflag, b.c_oflag));
}
if a.c_cflag != b.c_cflag {
out.push(format!("c_cflag {:#x} -> {:#x}", a.c_cflag, b.c_cflag));
}
for (i, (x, y)) in a.c_cc.iter().zip(b.c_cc.iter()).enumerate() {
if x != y {
out.push(format!("c_cc[{i}] {x} -> {y}"));
}
}
out.join(", ")
}
#[cfg(test)]
mod wait_for_after_tests {
use super::wait_for_after;
use std::sync::{Arc, Mutex};
use std::time::Duration;
#[test]
fn finds_a_needle_already_present_after_the_offset() {
let buf = Mutex::new(b"before SHOWN after".to_vec());
assert!(wait_for_after(&buf, 7, "SHOWN", Duration::from_millis(200)));
}
#[test]
fn ignores_an_occurrence_before_the_offset() {
let buf = Mutex::new(b"SHOWN before, nothing after".to_vec());
assert!(!wait_for_after(
&buf,
11,
"SHOWN",
Duration::from_millis(200)
));
}
#[test]
fn observes_a_needle_appended_after_the_wait_begins() {
let buf = Arc::new(Mutex::new(b"before ".to_vec()));
let (entered_tx, entered_rx) = std::sync::mpsc::channel::<()>();
let writer = std::thread::spawn({
let buf = Arc::clone(&buf);
move || {
newt_core::test_guard::recv_guarded(
&entered_rx,
"wait_for_bytes entering its poll loop",
);
buf.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.extend_from_slice(b"SHOWN after");
}
});
let mut signaled = false;
assert!(tests_pty::wait_for_bytes_instrumented(
&buf,
7,
"SHOWN",
newt_core::test_guard::HANG_GUARD,
move || {
if !signaled {
signaled = true;
let _ = entered_tx.send(());
}
},
));
writer.join().expect("writer thread");
}
#[test]
fn consults_the_injected_clock_rather_than_a_hardcoded_poll_count() {
let buf = Mutex::new(b"before after".to_vec()); let polls = std::sync::atomic::AtomicUsize::new(0);
let clock_calls = std::sync::atomic::AtomicUsize::new(0);
let anchor = std::time::Instant::now();
let step = Duration::from_millis(10);
const NOT_EXPIRED_CHECKS: usize = 5;
let timeout = step * u32::try_from(NOT_EXPIRED_CHECKS).unwrap() + Duration::from_millis(5);
let seen = tests_pty::wait_for_bytes_with_clock(
&buf,
7,
"SHOWN",
timeout,
|| {
let call = clock_calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
anchor + step * u32::try_from(call).unwrap()
},
|| {
polls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
},
);
assert!(!seen, "a needle that never arrives must return false");
assert_eq!(
polls.load(std::sync::atomic::Ordering::SeqCst),
NOT_EXPIRED_CHECKS + 1,
"must poll exactly as many times as the scripted clock says \"not \
yet\" plus the one check that observes expiry — a hardcoded \
iteration count diverges from this script"
);
}
}