use std::time::{Duration, Instant};
use tests_pty::{screen_grid, signal_winch, Pty};
use crate::panel::PanelRawGuard;
use crate::prompt_visibility_test::wait_for_child;
const CHILD_TEST: &str = "panel_raw_mode_pty_test::panel_raw_mode_child";
const REACH_TIMEOUT: Duration = Duration::from_secs(60);
const EXIT_TIMEOUT: Duration = Duration::from_secs(60);
const PANEL_TEST_HEIGHT: u16 = 6;
const PANEL_SENTINEL: &str = "PANEL-DREW-1950";
const PANEL_SENTINEL_OK: &str = "PANEL-OPENED-1950";
const PANEL_BODY_SENTINEL: &str = "PANEL-BODY-1977";
const PROMPT_SENTINEL: &str = "PROMPT-ROWS-1977";
const HOLDER_ROWS: u16 = 3;
const HOLDER_SENTINEL: &str = "HOLDER-ROW";
struct HeldPanel;
impl crate::panel::Screen for HeldPanel {
fn draw(&self, frame: &mut ratatui::Frame) {
use ratatui::widgets::{Block, BorderType, Paragraph};
let body = (0..30)
.map(|n| format!("panel-row-{n:02}"))
.collect::<Vec<_>>();
frame.render_widget(
Paragraph::new(body.join("\n")).block(
Block::bordered()
.border_type(BorderType::Rounded)
.title("held"),
),
frame.area(),
);
}
fn key(&mut self, key: crate::panel::Key) -> crate::panel::Flow {
match key {
crate::panel::Key::Esc => crate::panel::Flow::Close(false),
_ => crate::panel::Flow::Stay,
}
}
}
#[test]
#[ignore = "child process of the panel raw-mode PTY acceptance test"]
fn panel_raw_mode_child() {
let Some(mode) = std::env::var_os("NEWT_PANEL_PTY_CHILD") else {
return;
};
let hold = || {
use std::io::Read as _;
let mut byte = [0u8; 1];
let _ = std::io::stdin().read(&mut byte);
};
match mode.to_string_lossy().as_ref() {
"clean" => {
let _guard = PanelRawGuard::enter().expect("enter raw mode");
hold();
}
"panic" => {
let _guard = PanelRawGuard::enter().expect("enter raw mode");
hold();
panic!("synthetic failure while the panel is up (#1889)");
}
"error" => {
fn inner() -> std::io::Result<()> {
let _guard = PanelRawGuard::enter()?;
{
use std::io::Read as _;
let mut byte = [0u8; 1];
let _ = std::io::stdin().read(&mut byte);
}
Err(std::io::Error::other("synthetic draw failure"))
}
assert!(inner().is_err(), "the fixture must take the error path");
}
"inline_contended" => {
let _guard = PanelRawGuard::enter().expect("enter raw mode");
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let reader = {
let stop = stop.clone();
std::thread::spawn(move || {
while !stop.load(std::sync::atomic::Ordering::Relaxed) {
let _ = crossterm::event::poll(Duration::from_millis(50));
let _ = crossterm::event::read();
}
})
};
std::thread::sleep(Duration::from_millis(150));
match crate::panel::make_terminal(PANEL_TEST_HEIGHT) {
Ok(mut terminal) => {
terminal
.draw(|f| {
f.render_widget(
ratatui::widgets::Paragraph::new(PANEL_SENTINEL),
f.area(),
);
})
.expect("the panel draws");
println!("{PANEL_SENTINEL_OK}");
}
Err(e) => println!("PANEL-FAILED {e}"),
}
use std::io::Write as _;
std::io::stdout().flush().ok();
stop.store(true, std::sync::atomic::Ordering::Relaxed);
drop(reader);
}
"inline_overpainted" => {
let _guard = PanelRawGuard::enter().expect("enter raw mode");
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let reader = {
let stop = stop.clone();
std::thread::spawn(move || {
while !stop.load(std::sync::atomic::Ordering::Relaxed) {
let _ = crossterm::event::poll(Duration::from_millis(50));
let _ = crossterm::event::read();
}
})
};
std::thread::sleep(Duration::from_millis(150));
let prompt_lease = crate::inline_viewport::lease_bottom_rows(
PANEL_TEST_HEIGHT,
newt_core::tty::OnCollision::Refuse,
)
.expect("the prompt takes the bottom rows first");
let mut prompt = crate::inline_viewport::inline_terminal(prompt_lease)
.expect("prompt viewport opens");
let paint_prompt = |t: &mut crate::inline_viewport::InlineTerm, tick: usize| {
let filled = (0..PANEL_TEST_HEIGHT)
.map(|r| format!("{PROMPT_SENTINEL} r{r} t{tick}"))
.collect::<Vec<_>>()
.join("\n");
t.clear().ok();
t.draw(|f| {
f.render_widget(ratatui::widgets::Paragraph::new(filled), f.area());
})
.ok();
};
paint_prompt(&mut prompt, 0);
match crate::panel::make_terminal(PANEL_TEST_HEIGHT) {
Ok(mut terminal) => {
let body = (0..PANEL_TEST_HEIGHT)
.map(|r| format!("{PANEL_BODY_SENTINEL} r{r}"))
.collect::<Vec<_>>()
.join("\n");
terminal
.draw(|f| {
f.render_widget(ratatui::widgets::Paragraph::new(body), f.area());
})
.expect("the panel draws");
println!("{PANEL_SENTINEL_OK}");
for tick in 1..=5 {
paint_prompt(&mut prompt, tick);
std::thread::sleep(Duration::from_millis(20));
}
}
Err(e) => println!("PANEL-FAILED {e}"),
}
use std::io::Write as _;
std::io::stdout().flush().ok();
stop.store(true, std::sync::atomic::Ordering::Relaxed);
drop(reader);
}
"panel_held" => {
use std::io::Write as _;
use std::sync::{Arc, Mutex};
let holder = crate::inline_viewport::lease_bottom_rows(
HOLDER_ROWS,
newt_core::tty::OnCollision::Refuse,
)
.expect("the holder takes the bottom rows first");
let paint = |top: u16, park: u16| {
let mut out = std::io::stdout();
for r in 0..HOLDER_ROWS {
let _ = write!(out, "\x1b[{};1H{HOLDER_SENTINEL}-{r}", top + r + 1);
}
let _ = write!(out, "\x1b[{park};1H");
let _ = out.flush();
};
let (_, rows) = crossterm::terminal::size().expect("size");
paint(
rows - HOLDER_ROWS,
rows - HOLDER_ROWS - PANEL_TEST_HEIGHT + 1,
);
let holder = Arc::new(Mutex::new(holder));
if std::env::var_os("NEWT_PANEL_HELD_MOVE").is_some() {
let holder = holder.clone();
std::thread::spawn(move || loop {
std::thread::sleep(Duration::from_millis(10));
let (_, now) = crossterm::terminal::size().expect("size");
if now == rows {
continue;
}
let top = now.saturating_sub(HOLDER_ROWS);
let moved = holder.lock().unwrap().relocate(
newt_core::tty::Region::Rows {
top,
height: HOLDER_ROWS,
},
newt_core::tty::OnCollision::Refuse,
);
paint(top, 1);
println!("HOLDER_MOVED:{moved}");
return;
});
}
print!("HOLDER_READY");
std::io::stdout().flush().ok();
let result = crate::panel::drive(&mut HeldPanel, PANEL_TEST_HEIGHT, None);
println!(
"DRIVE_RESULT:{:?}",
result.as_ref().map_err(ToString::to_string)
);
println!("HOLDER_STILL_HOLDS:{:?}", holder.lock().unwrap().region());
hold();
}
other => panic!("unknown child mode {other:?}"),
}
}
struct Outcome {
raw_after: bool,
raw_during: bool,
answered_cursor_query: bool,
screen: String,
}
fn drive(mode: &str) -> Outcome {
let pty = Pty::open();
let mut child = std::process::Command::new(
std::env::current_exe().expect("the test binary re-invokes itself"),
)
.args(["--exact", CHILD_TEST, "--ignored", "--nocapture"])
.env("NEWT_PANEL_PTY_CHILD", mode)
.stdin(pty.slave_stdio())
.stdout(pty.slave_stdio())
.stderr(std::process::Stdio::null())
.spawn()
.expect("spawn the pty child");
let mut screen = String::new();
let mut answered_cursor_query = false;
let raw_during = {
let deadline = Instant::now() + REACH_TIMEOUT;
loop {
screen.push_str(&pty.screen());
if !answered_cursor_query && screen.contains("\u{1b}[6n") {
pty.type_in("\u{1b}[3;1R");
answered_cursor_query = true;
}
if pty.is_raw() {
break true;
}
if Instant::now() >= deadline {
break false;
}
std::thread::sleep(Duration::from_millis(20));
}
};
pty.type_in("x");
let _status = std::thread::scope(|scope| {
let waiter = scope.spawn(|| wait_for_child(&mut child, EXIT_TIMEOUT));
while !waiter.is_finished() {
screen.push_str(&pty.screen());
if !answered_cursor_query && screen.contains("\u{1b}[6n") {
pty.type_in("\u{1b}[3;1R");
answered_cursor_query = true;
}
std::thread::sleep(Duration::from_millis(20));
}
waiter.join().expect("child reaper thread")
});
std::thread::sleep(Duration::from_millis(80));
screen.push_str(&pty.screen());
Outcome {
raw_after: pty.is_raw(),
raw_during,
answered_cursor_query,
screen,
}
}
fn assert_restored(what: &str, out: &Outcome) {
assert!(
out.raw_during,
"{what}: the pty was never raw — the test proved nothing about \
restoration, because it never observed the state being restored FROM"
);
assert!(
!out.raw_after,
"{what}: the terminal was left in RAW mode — an operator's shell would \
be unusable. This is kernel state via tcgetattr, not an inference \
from escape bytes; screen={:?}",
out.screen
);
}
#[serial_test::serial(interaction_pty)]
#[test]
#[ignore = "real-PTY acceptance tier; weekly, release, and scoped PTY CI only"]
fn a_clean_panel_close_leaves_the_terminal_exactly_as_it_was_found() {
assert_restored("clean", &drive("clean"));
}
#[serial_test::serial(interaction_pty)]
#[test]
#[ignore = "real-PTY acceptance tier; weekly, release, and scoped PTY CI only"]
fn a_panic_while_the_panel_is_up_still_restores_the_terminal() {
assert_restored("panic", &drive("panic"));
}
#[serial_test::serial(interaction_pty)]
#[test]
#[ignore = "real-PTY acceptance tier; weekly, release, and scoped PTY CI only"]
fn an_error_return_while_the_panel_is_up_still_restores_the_terminal() {
assert_restored("error", &drive("error"));
}
#[serial_test::serial(interaction_pty)]
#[test]
#[ignore = "real-PTY acceptance tier; weekly, release, and scoped PTY CI only"]
fn a_panel_still_opens_when_the_cursor_reply_is_taken_by_another_reader() {
let out = drive("inline_contended");
assert!(
out.answered_cursor_query,
"the child never emitted ESC[6n, so nothing was contended and this \
test proved nothing; screen={:?}",
out.screen
);
assert!(
out.raw_during,
"the pty was never raw; screen={:?}",
out.screen
);
assert!(
!out.screen.contains("PANEL-FAILED"),
"the panel refused to open — this is #1950; screen={:?}",
out.screen
);
assert!(
out.screen.contains(PANEL_SENTINEL_OK) && out.screen.contains(PANEL_SENTINEL),
"the panel did not DRAW. Opening without drawing leaves the operator \
the same blank screen the bug produced; screen={:?}",
out.screen
);
assert!(
!out.raw_after,
"the terminal was left RAW; screen={:?}",
out.screen
);
}
#[serial_test::serial(interaction_pty)]
#[test]
#[ignore = "real-PTY acceptance tier; weekly, release, and scoped PTY CI only"]
fn a_panel_body_survives_a_competing_bottom_anchored_viewport() {
let out = drive("inline_overpainted");
let grid = screen_grid(&out.screen);
assert!(
out.screen.contains(PANEL_SENTINEL_OK),
"the panel never opened, so this says nothing about overpainting; \
screen={:?}",
out.screen
);
assert!(
grid.iter().any(|line| line.contains(PROMPT_SENTINEL)),
"the competing prompt never painted, so nothing contended; grid={grid:#?}"
);
assert!(
out.screen.contains(PANEL_BODY_SENTINEL),
"the panel never drew its body; that is a different bug; screen={:?}",
out.screen
);
assert!(
grid.iter().any(|line| line.contains(PANEL_BODY_SENTINEL)),
"the panel body was overpainted by the prompt viewport — #1977. It \
reached the terminal (control 3) and is gone from the screen; \
grid={grid:#?}"
);
}
fn drive_held(shrink_to: Option<(u16, usize)>, holder_follows: bool, answer_dsr: bool) -> Held {
let pty = Pty::open();
pty.resize(24, 80);
let baseline = pty.termios_snapshot();
let mut command = std::process::Command::new(
std::env::current_exe().expect("the test binary re-invokes itself"),
);
command
.args(["--exact", CHILD_TEST, "--ignored", "--nocapture"])
.env("NEWT_PANEL_PTY_CHILD", "panel_held")
.env("TERM", "xterm-256color")
.stdin(pty.slave_stdio())
.stdout(pty.slave_stdio())
.stderr(std::process::Stdio::null());
if holder_follows {
command.env("NEWT_PANEL_HELD_MOVE", "1");
}
let mut child = command.spawn().expect("spawn the pty child");
drop(command);
let panel_height = |repaint: &str| {
let rows = screen_grid(repaint);
let top = rows.iter().position(|row| row.contains('╭'));
let bottom = rows.iter().rposition(|row| row.contains('╰'));
top.zip(bottom).map(|(top, bottom)| bottom - top + 1)
};
let mut pump = Pump::new(&pty, answer_dsr, 24);
let result = (|| -> Result<(), String> {
let step = |pump: &mut Pump, what: &str, act: &dyn Fn(&mut Pump), want: usize| {
pump.mark();
act(pump);
let Some(repaint) = pump.until("╯") else {
return Err(format!("no repaint after: {what}"));
};
match panel_height(&repaint) {
Some(height) if height == want => Ok(()),
other => Err(format!(
"{what}: height {other:?}, want {want}: {repaint:?}"
)),
}
};
if pump.until("HOLDER_READY").is_none() || pump.until("╯").is_none() {
return Err("the panel never painted".into());
}
step(
&mut pump,
"Ctrl-Z is refused, the panel keeps its rows",
&|_| pty.type_in("\x1a"),
6,
)?;
step(
&mut pump,
"Shift-Up grows one row",
&|_| pty.type_in("\x1b[1;2A"),
7,
)?;
if let Some((rows, panel_rows)) = shrink_to {
pump.rows = usize::from(rows);
step(
&mut pump,
"the terminal shrinks",
&|pump| {
pty.resize(rows, 80);
if holder_follows {
assert!(pump.until("HOLDER_MOVED:true").is_some());
}
signal_winch(child.id());
},
panel_rows,
)?;
}
pump.mark();
pty.type_in("\x1b");
if pump.until("HOLDER_STILL_HOLDS").is_none() {
return Err("Esc did not close the panel".into());
}
Ok(())
})();
let returned = result.is_ok() || pump.until("HOLDER_STILL_HOLDS").is_some();
let termios_restored = returned && pty.termios_snapshot() == baseline;
pump.pump();
pty.type_in("\n");
if result.is_err() {
let _ = child.kill();
}
let status = wait_for_child(&mut child, EXIT_TIMEOUT);
let mut transcript = pump.transcript;
transcript.push_str(&pty.screen_when_finished(status.is_some()));
Held {
result,
stream: transcript,
termios_restored,
exit_ok: status.is_some_and(|status| status.success()),
}
}
pub(crate) struct Pump<'a> {
pty: &'a Pty,
pub(crate) transcript: String,
mark: usize,
answer_dsr: bool,
pub(crate) rows: usize,
answered_through: usize,
}
impl<'a> Pump<'a> {
pub(crate) fn new(pty: &'a Pty, answer_dsr: bool, rows: usize) -> Self {
Self {
pty,
transcript: String::new(),
mark: 0,
answer_dsr,
rows,
answered_through: 0,
}
}
pub(crate) fn pump(&mut self) {
const QUERY: &str = "\x1b[6n";
let chunk = self.pty.screen();
let from = self.transcript.len().saturating_sub(QUERY.len() - 1);
self.transcript.push_str(&chunk);
if !self.answer_dsr {
return;
}
let mut at = from;
while let Some(found) = self.transcript[at..].find(QUERY) {
let query = at + found;
if query >= self.answered_through {
let (_, (row, col)) = crate::interaction_view_pty_test::erasing_screen(
&self.transcript[..query],
self.rows,
);
self.pty.type_in(&format!("\x1b[{};{}R", row + 1, col + 1));
self.answered_through = query + QUERY.len();
}
at = query + QUERY.len();
}
}
pub(crate) fn mark(&mut self) {
self.pump();
self.mark = self.transcript.len();
}
pub(crate) fn until(&mut self, needle: &str) -> Option<String> {
let deadline = Instant::now() + REACH_TIMEOUT;
loop {
self.pump();
if let Some(at) = self.transcript[self.mark..].find(needle) {
let end = self.mark + at + needle.len();
let text = self.transcript[self.mark..end].to_string();
self.mark = end;
return Some(text);
}
if Instant::now() >= deadline {
return None;
}
std::thread::sleep(Duration::from_millis(20));
}
}
}
struct Held {
result: Result<(), String>,
stream: String,
termios_restored: bool,
exit_ok: bool,
}
fn assert_holder_untouched(held: &Held, rows: usize, top: usize) {
let stream = held.stream.split("DRIVE_RESULT").next().unwrap_or_default();
let screen = crate::interaction_view_pty_test::erasing_grid(stream, rows);
for r in 0..usize::from(HOLDER_ROWS) {
assert!(
screen[top + r].contains(&format!("{HOLDER_SENTINEL}-{r}")),
"holder row {r} was overwritten or erased: {screen:#?}\nstream={:?}",
held.stream
);
}
}
#[serial_test::serial(interaction_pty)]
#[test]
#[ignore = "real-PTY acceptance tier; weekly, release, and scoped PTY CI only"]
fn a_panel_beside_a_row_holder_stays_open_at_its_granted_height() {
let held = drive_held(None, false, false);
assert!(held.result.is_ok(), "{:?}: {:?}", held.result, held.stream);
assert!(
held.stream.contains("DRIVE_RESULT:Ok(false)"),
"drive did not return Ok: {:?}",
held.stream
);
assert!(
held.termios_restored,
"termios not restored: {:?}",
held.stream
);
assert!(held.exit_ok, "{:?}", held.stream);
assert_holder_untouched(&held, 24, 21);
}
#[serial_test::serial(interaction_pty)]
#[test]
#[ignore = "real-PTY acceptance tier; weekly, release, and scoped PTY CI only"]
fn a_panel_beside_a_row_holder_survives_a_terminal_shrink_that_still_fits() {
let held = drive_held(Some((10, 7)), true, false);
assert!(held.result.is_ok(), "{:?}: {:?}", held.result, held.stream);
assert!(
held.stream.contains("HOLDER_MOVED:true"),
"{:?}",
held.stream
);
assert!(
held.stream.contains("DRIVE_RESULT:Ok(false)"),
"drive did not return Ok: {:?}",
held.stream
);
assert!(
held.termios_restored,
"termios not restored: {:?}",
held.stream
);
assert!(held.exit_ok, "{:?}", held.stream);
}
#[serial_test::serial(interaction_pty)]
#[test]
#[ignore = "real-PTY acceptance tier; weekly, release, and scoped PTY CI only"]
fn a_shrink_that_leaves_no_room_degrades_the_panel_instead_of_closing_it() {
let held = drive_held(Some((8, 5)), true, false);
assert!(held.result.is_ok(), "{:?}: {:?}", held.result, held.stream);
assert!(
held.stream.contains("DRIVE_RESULT:Ok(false)"),
"drive did not return Ok: {:?}",
held.stream
);
assert!(
held.termios_restored,
"termios not restored: {:?}",
held.stream
);
assert!(held.exit_ok, "{:?}", held.stream);
}
#[serial_test::serial(interaction_pty)]
#[test]
#[ignore = "real-PTY acceptance tier; weekly, release, and scoped PTY CI only"]
fn a_grow_beside_a_holder_leaves_its_rows_when_the_terminal_answers_the_cursor_query() {
let held = drive_held(None, false, true);
assert!(held.result.is_ok(), "{:?}: {:?}", held.result, held.stream);
assert!(
held.stream.contains("DRIVE_RESULT:Ok(false)"),
"drive did not return Ok: {:?}",
held.stream
);
assert!(held.stream.contains("\x1b[6n"), "control: the panel asked");
assert_holder_untouched(&held, 24, 21);
}