#![allow(dead_code)]
use nix::libc;
use nix::pty::openpty;
use std::io::Read;
use std::os::fd::{AsFd, AsRawFd, OwnedFd};
use std::os::unix::process::CommandExt;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::Once;
use std::time::{Duration, Instant};
#[allow(clippy::unnecessary_cast)]
const TIOCSCTTY: libc::c_ulong = libc::TIOCSCTTY as libc::c_ulong;
pub fn example_path(name: &str) -> PathBuf {
let exe = std::env::current_exe().expect("test binary path");
let profile_dir = exe
.parent()
.and_then(|p| p.parent())
.expect("target/<profile>");
static BUILD: Once = Once::new();
BUILD.call_once(|| build_examples(profile_dir));
let path = profile_dir.join("examples").join(name);
assert!(
path.exists(),
"example `{name}` not built at {}; run `cargo build --examples`",
path.display(),
);
path
}
fn build_examples(profile_dir: &Path) {
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".to_string());
let target_dir = profile_dir.parent().expect("<target>/<profile>");
let profile = profile_dir
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("debug");
let mut command = Command::new(cargo);
command
.args(["build", "--examples"])
.arg("--target-dir")
.arg(target_dir);
if profile != "debug" {
command.arg("--profile").arg(profile);
}
match command.status() {
Ok(status) if status.success() => {}
Ok(status) => panic!("`cargo build --examples` failed: {status}"),
Err(e) => panic!("could not run `cargo build --examples`: {e}"),
}
}
pub struct Pty {
master: OwnedFd,
child: Child,
output: Vec<u8>,
}
impl Pty {
pub fn spawn(args: &[&str], rows: u16, cols: u16) -> Pty {
let pty = openpty(None, None).expect("openpty");
let master = pty.master;
let slave = pty.slave;
let mut command = Command::new(example_path("harness"));
command
.args(args)
.env("TERM", "xterm-256color")
.env("HOME", std::env::temp_dir())
.stdin(Stdio::from(slave.try_clone().expect("dup slave")))
.stdout(Stdio::from(slave.try_clone().expect("dup slave")))
.stderr(Stdio::from(slave.try_clone().expect("dup slave")));
unsafe {
command.pre_exec(|| {
nix::unistd::setsid().map_err(std::io::Error::from)?;
if libc::ioctl(0, TIOCSCTTY, 0) == -1 {
return Err(std::io::Error::last_os_error());
}
Ok(())
});
}
let child = command.spawn().expect("spawn harness");
drop(slave);
let mut pty = Pty {
master,
child,
output: Vec::new(),
};
pty.resize(rows, cols);
pty.settle();
pty
}
pub fn resize(&self, rows: u16, cols: u16) {
let ws = libc::winsize {
ws_row: rows,
ws_col: cols,
ws_xpixel: 0,
ws_ypixel: 0,
};
let rc = unsafe { libc::ioctl(self.master.as_raw_fd(), libc::TIOCSWINSZ, &raw const ws) };
assert_eq!(rc, 0, "TIOCSWINSZ failed");
}
pub fn send(&mut self, bytes: &[u8]) {
nix::unistd::write(self.master.as_fd(), bytes).expect("write to pty");
}
pub fn type_keys(&mut self, bytes: &[u8]) {
self.send(bytes);
self.settle();
}
pub fn settle(&mut self) {
let deadline = Instant::now() + Duration::from_secs(3);
let mut quiet_since: Option<Instant> = None;
while Instant::now() < deadline {
if self.read_once(Duration::from_millis(60)) {
quiet_since = None;
continue;
}
match quiet_since {
Some(t) if t.elapsed() >= Duration::from_millis(120) => return,
Some(_) => {}
None => quiet_since = Some(Instant::now()),
}
}
}
fn read_once(&mut self, timeout: Duration) -> bool {
use nix::poll::{PollFd, PollFlags, PollTimeout, poll};
let mut fds = [PollFd::new(self.master.as_fd(), PollFlags::POLLIN)];
let ms = u16::try_from(timeout.as_millis()).unwrap_or(u16::MAX);
match poll(&mut fds, PollTimeout::from(ms)) {
Ok(0) | Err(_) => return false,
Ok(_) => {}
}
let mut buf = [0u8; 8192];
match nix::unistd::read(self.master.as_fd(), &mut buf) {
Ok(0) | Err(_) => false,
Ok(n) => {
self.output.extend_from_slice(&buf[..n]);
true
}
}
}
pub fn output(&self) -> String {
String::from_utf8_lossy(&self.output).into_owned()
}
pub fn clear(&mut self) {
self.output.clear();
}
pub fn results(&self) -> Vec<String> {
let text = self.output();
let mut found = Vec::new();
for marker in ["@@LINE@@ ", "@@EOF@@", "@@INT@@", "@@ERR@@ "] {
for (index, _) in text.match_indices(marker) {
let rest = &text[index..];
let end = rest.find(['\r', '\n']).unwrap_or(rest.len());
found.push((index, rest[..end].trim().to_string()));
}
}
found.sort_by_key(|(index, _)| *index);
found.into_iter().map(|(_, text)| text).collect()
}
pub fn lines(&self) -> Vec<String> {
self.results()
.into_iter()
.filter_map(|r| {
let quoted = r.strip_prefix("@@LINE@@ ")?;
Some(unquote(quoted))
})
.collect()
}
pub fn panicked(&self) -> bool {
self.output().contains("panicked at")
}
pub fn is_alive(&mut self) -> bool {
matches!(self.child.try_wait(), Ok(None))
}
pub fn screen(&self, rows: usize, cols: usize) -> Screen {
let mut screen = Screen::new(rows, cols);
screen.feed(&self.output);
screen
}
}
impl Drop for Pty {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
fn unquote(quoted: &str) -> String {
let inner = quoted
.strip_prefix('"')
.and_then(|s| s.strip_suffix('"'))
.unwrap_or(quoted);
let mut out = String::new();
let mut chars = inner.chars();
while let Some(c) = chars.next() {
if c != '\\' {
out.push(c);
continue;
}
match chars.next() {
Some('n') => out.push('\n'),
Some('r') => out.push('\r'),
Some('t') => out.push('\t'),
Some('\\') => out.push('\\'),
Some('"') => out.push('"'),
Some(other) => {
out.push('\\');
out.push(other);
}
None => out.push('\\'),
}
}
out
}
pub struct Screen {
cells: Vec<Vec<char>>,
row: usize,
col: usize,
cols: usize,
pending_wrap: bool,
}
impl Screen {
pub fn new(rows: usize, cols: usize) -> Self {
Screen {
cells: vec![vec![' '; cols]; rows.max(1)],
row: 0,
col: 0,
cols,
pending_wrap: false,
}
}
fn ensure_row(&mut self) {
while self.row >= self.cells.len() {
self.cells.push(vec![' '; self.cols]);
}
}
fn wrap_now(&mut self) {
self.row += 1;
self.col = 0;
self.pending_wrap = false;
self.ensure_row();
}
fn put(&mut self, c: char) {
let w = rustline::char_width(c);
if w == 0 {
return;
}
if self.pending_wrap || self.col + w > self.cols {
self.wrap_now();
}
self.ensure_row();
self.cells[self.row][self.col] = c;
for k in 1..w {
self.cells[self.row][self.col + k] = '\0';
}
self.col += w;
if self.col >= self.cols {
self.col = self.cols;
self.pending_wrap = true;
}
}
fn erase_to_end_of_line(&mut self) {
let col = self.col.min(self.cols);
for c in self.cells[self.row][col..].iter_mut() {
*c = ' ';
}
}
fn csi(&mut self, param: &str, final_byte: char) {
let n: usize = param.parse().unwrap_or(1).max(1);
self.pending_wrap = false;
self.ensure_row();
match final_byte {
'A' => self.row = self.row.saturating_sub(n),
'B' => {
self.row += n;
self.ensure_row();
}
'C' => self.col = (self.col + n).min(self.cols),
'D' => self.col = self.col.saturating_sub(n),
'H' => {
self.row = 0;
self.col = 0;
}
'K' => self.erase_to_end_of_line(),
'J' => {
if param == "2" {
for row in self.cells.iter_mut() {
row.fill(' ');
}
} else {
self.erase_to_end_of_line();
for row in self.cells[self.row + 1..].iter_mut() {
row.fill(' ');
}
}
}
_ => {}
}
}
pub fn feed(&mut self, bytes: &[u8]) {
let chars: Vec<char> = String::from_utf8_lossy(bytes).chars().collect();
let mut i = 0;
while i < chars.len() {
let c = chars[i];
i += 1;
match c {
'\r' => {
self.col = 0;
self.pending_wrap = false;
}
'\n' => {
self.row += 1;
self.pending_wrap = false;
self.ensure_row();
}
'\x1b' if chars.get(i) == Some(&'[') => {
i += 1;
let start = i;
i += chars[i..]
.iter()
.take_while(|c| c.is_ascii_digit() || **c == ';' || **c == '?')
.count();
let param: String = chars[start..i].iter().collect();
let final_byte = chars.get(i).copied().unwrap_or(' ');
i += 1;
self.csi(¶m, final_byte);
}
'\x1b' => {}
c => self.put(c),
}
}
}
pub fn line(&self, row: usize) -> String {
self.cells
.get(row)
.map(|r| {
r.iter()
.filter(|c| **c != '\0')
.collect::<String>()
.trim_end()
.to_string()
})
.unwrap_or_default()
}
pub fn cursor(&self) -> (usize, usize) {
if self.pending_wrap {
(self.row + 1, 0)
} else {
(self.row, self.col)
}
}
}
pub fn drain_reader(mut reader: impl Read) -> String {
let mut buf = String::new();
let _ = reader.read_to_string(&mut buf);
buf
}