use std::time::{Duration, Instant};
use tests_pty::{screen_grid, signal_winch, Pty};
use crate::interaction_view::InlineGuard;
use crate::prompt_visibility_test::wait_for_child;
const CHILD_TEST: &str = "interaction_view_pty_test::interaction_view_child";
const REACH_TIMEOUT: Duration = Duration::from_secs(60);
const EXIT_TIMEOUT: Duration = Duration::from_secs(60);
#[test]
#[ignore = "child process of the interaction-view PTY acceptance test"]
fn interaction_view_child() {
let Some(mode) = std::env::var_os("NEWT_INTERACTION_PTY_CHILD") else {
return;
};
match mode.to_string_lossy().as_ref() {
"clean" => {
let _guard = InlineGuard::enter().expect("enter raw mode");
hold();
}
"panic" => {
let _guard = InlineGuard::enter().expect("enter raw mode");
hold();
panic!("synthetic failure while the interaction frame is up (#1876)");
}
"error" => {
fn inner() -> std::io::Result<()> {
let _guard = InlineGuard::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");
}
"nested" => {
let _outer = InlineGuard::enter().expect("enter raw mode (outer)");
{
let _inner = InlineGuard::enter().expect("enter raw mode (inner)");
hold();
} hold();
}
"present" => {
let interaction = fixture_interaction();
let outcome =
crate::interaction_view::present(&interaction).expect("the frame runs and returns");
println!("OUTCOME:{outcome:?}");
}
"active_turn" => {
use newt_core::tty::{LineCaps, Sink, Spinner, Terminal};
let spinner = Spinner::start_with_caps(LineCaps::Own, "thinking…", Sink::Stdout, true)
.expect("the pty is a real terminal, so the spinner takes the line");
std::thread::sleep(Duration::from_millis(400));
let interaction = fixture_interaction();
let outcome = {
let _window =
Terminal::suspend_for_prompt(newt_core::tty::TerminalTaker::RichSurfaceModal);
crate::interaction_view::present(&interaction).expect("the frame runs")
};
drop(spinner);
println!("OUTCOME:{outcome:?}");
}
"committed_frame" => committed_frame_case(5, false, true, false),
"committed_frame_held" => committed_frame_case(5, true, false, false),
"committed_frame_scroll" => committed_frame_case(22, false, true, false),
"committed_frame_contested" => committed_frame_case(18, true, false, false),
"committed_frame_partial" => committed_frame_case(5, false, true, true),
"committed_frame_partial_scroll" => committed_frame_case(23, false, true, true),
"cockpit_resize" => {
crate::cockpit::presenter::panel_resize_case();
}
"cockpit_live_resize" => {
crate::cockpit::presenter::panel_live_resize_case();
}
"cockpit_panel_loop" => {
crate::cockpit::presenter::cockpit_panel_loop_case();
}
"cockpit_acceptance" => {
crate::cockpit::presenter::cockpit_acceptance_case();
}
"startup_migration" => {
crate::cockpit::presenter::startup_migration_case();
}
"persona_migration" => {
crate::cockpit::presenter::persona_migration_case();
}
"cockpit_migration" => {
crate::cockpit::presenter::cockpit_migration_case();
}
"cockpit_bang" => {
crate::cockpit::presenter::cockpit_bang_case();
}
"cockpit_buffered_input" => {
crate::cockpit::presenter::cockpit_buffered_input_case();
}
"cockpit_clarification_input" => {
crate::cockpit::presenter::cockpit_clarification_input_case();
}
#[cfg(feature = "rich-tui")]
"cockpit_psyche" => {
crate::cockpit::presenter::cockpit_psyche_case();
}
#[cfg(feature = "live-spill")]
"cockpit_pager" => {
crate::cockpit::presenter::cockpit_pager_case();
}
other => panic!("unknown child mode {other:?}"),
}
}
fn hold() {
use std::io::Read as _;
let mut byte = [0u8; 1];
let _ = std::io::stdin().read(&mut byte);
}
const FRAME_HOLDER_ROWS: u16 = 3;
const FRAME_HOLDER_SENTINEL: &str = "HOLDER-ROW";
fn committed_frame_case(lines: u16, held: bool, window: bool, partial: bool) {
use std::io::Write as _;
let mut out = std::io::stdout();
write!(out, "\x1b[2J\x1b[H").expect("clear");
for n in 0..lines {
writeln!(out, "TRANSCRIPT-{n:02}").expect("print");
}
out.flush().expect("flush");
let holder = held.then(|| {
let (_, rows) = crossterm::terminal::size().expect("size");
let holder = crate::inline_viewport::lease_bottom_rows(
FRAME_HOLDER_ROWS,
newt_core::tty::OnCollision::Refuse,
)
.expect("the holder takes the bottom rows first");
for r in 0..FRAME_HOLDER_ROWS {
write!(
out,
"\x1b[{};1H{FRAME_HOLDER_SENTINEL}-{r}",
rows - FRAME_HOLDER_ROWS + r + 1
)
.expect("paint");
}
write!(out, "\x1b[{};1H", lines + 1).expect("park");
out.flush().expect("flush");
holder
});
if partial {
write!(out, "PARTIAL-END").expect("partial sentinel without newline");
out.flush().expect("flush partial sentinel");
}
let interaction = fixture_interaction();
let window = window.then(|| {
newt_core::tty::Terminal::suspend_for_prompt(
newt_core::tty::TerminalTaker::RichSurfaceModal,
)
});
let (outcome, _) = crate::interaction_view::present(&interaction).expect("the frame runs");
drop(window);
println!("COMMITTED-AFTER-FRAME");
drop(holder);
println!("OUTCOME:{outcome:?}");
hold();
}
fn fixture_interaction() -> newt_core::interaction_surface::SurfaceInteraction {
use newt_interaction::{
ChoiceOption, Control, ControlId, ControlKind, InteractionDefinition, InteractionKind,
OptionId, Requirement, SemanticRole,
};
let option = |id: &str, key: &str, label: &str| ChoiceOption {
id: OptionId::new(id).expect("valid option id"),
role: SemanticRole::Allow,
label: label.to_string(),
key: key.to_string(),
aliases: Vec::new(),
};
let mut definition = InteractionDefinition::new(
InteractionKind::Choice,
"\u{2298} run_command wants to run `bash` \u{2014} outside the granted exec allowlist.",
vec![Control {
id: ControlId::new("decision").expect("valid control id"),
kind: ControlKind::Choice {
options: vec![
option("allow_once", "a", "allow once"),
option("deny", "d", "deny (default)"),
],
},
label: String::new(),
requirement: Requirement::Required,
}],
);
definition.note = Some("Esc=back \u{b7} Ctrl-C/Ctrl-D=exit".into());
newt_core::interaction_surface::SurfaceInteraction::blocking(definition)
}
fn answer_cursor_report(pty: &Pty, screen: &str, answered: &mut usize) {
let asked = screen.matches("\u{1b}[6n").count();
while *answered < asked {
pty.type_in("\u{1b}[1;1R");
*answered += 1;
}
}
fn is_harness_noise(line: &str) -> bool {
let t = line.trim();
t.is_empty()
|| t.starts_with("running ")
|| t.starts_with("test ")
|| t.starts_with("test result:")
|| t.starts_with("failures")
|| t.starts_with("OUTCOME:")
|| t.starts_with("interaction_view_pty_test::")
|| t.starts_with("note: ")
|| t.starts_with("thread '")
}
#[allow(dead_code)]
fn visible_lines(screen: &str) -> Vec<String> {
let mut out = String::new();
let mut chars = screen.chars().peekable();
while let Some(c) = chars.next() {
if c != '\u{1b}' {
out.push(c);
continue;
}
match chars.peek() {
Some('[') => {
chars.next();
for f in chars.by_ref() {
if ('\u{40}'..='\u{7e}').contains(&f) {
break;
}
}
}
Some(']') => {
chars.next();
while let Some(f) = chars.next() {
if f == '\u{7}' || (f == '\u{1b}' && chars.peek() == Some(&'\\')) {
break;
}
}
}
_ => {
chars.next();
}
}
}
out.replace('\r', "\n")
.lines()
.map(str::to_string)
.collect()
}
fn spawn_child(pty: &Pty, mode: &str) -> std::process::Child {
let mut child = std::process::Command::new(
std::env::current_exe().expect("the test binary re-invokes itself"),
);
child
.args(["--exact", CHILD_TEST, "--ignored", "--nocapture"])
.env("NEWT_INTERACTION_PTY_CHILD", mode)
.env("TERM", "xterm-256color")
.stdin(pty.slave_stdio())
.stdout(pty.slave_stdio())
.stderr(std::process::Stdio::null());
if matches!(
mode,
"cockpit_bang"
| "cockpit_buffered_input"
| "cockpit_pager"
| "cockpit_panel_loop"
| "cockpit_psyche"
) {
child.env("NEWT_EDIT_MODE", "emacs");
}
child.spawn().expect("spawn the pty child")
}
pub(crate) fn drive_cockpit_resize() {
drive_cockpit_case("cockpit_resize");
}
pub(crate) fn drive_cockpit_live_resize() {
drive_cockpit_case("cockpit_live_resize");
}
pub(crate) fn drive_startup_migration() {
drive_cockpit_case("startup_migration");
}
pub(crate) fn drive_persona_migration() {
drive_cockpit_case("persona_migration");
}
pub(crate) fn drive_cockpit_migration() {
drive_cockpit_case("cockpit_migration");
}
pub(crate) fn drive_cockpit_acceptance() {
drive_cockpit_case("cockpit_acceptance");
}
pub(crate) fn drive_cockpit_buffered_input() {
drive_cockpit_case("cockpit_buffered_input");
}
pub(crate) fn drive_cockpit_clarification_input() {
drive_cockpit_case("cockpit_clarification_input");
}
#[cfg(feature = "rich-tui")]
pub(crate) fn drive_cockpit_psyche() {
const TITLE: &str = "psyche — operator dials";
let pty = Pty::open_with_cursor_reply(1, 1);
pty.resize(30, 100);
let baseline = pty.termios_snapshot();
let mut child = spawn_child(&pty, "cockpit_psyche");
let mut transcript = String::new();
let result = (|| -> Result<(), String> {
if !pty.wait_for_screen_after(TITLE, "Esc cancel", REACH_TIMEOUT) {
return Err("the psyche panel never painted its footer".into());
}
let frame = pty.screen();
transcript.push_str(&frame);
let rows = screen_grid(&frame);
let titles = rows.iter().filter(|row| row.contains(TITLE)).count();
if titles != 1 {
return Err(format!(
"panel title drawn {titles} times, want 1: {rows:?}"
));
}
const LABELS: [&str; 4] = ["agreeableness", "extraversion", "warmth", "prosocial"];
for row in &rows {
let n = LABELS.iter().filter(|l| row.contains(**l)).count();
if n > 1 {
return Err(format!(
"dial rows interleaved on one row {row:?}: {rows:?}"
));
}
}
for label in LABELS {
let n = rows.iter().filter(|row| row.contains(label)).count();
if n != 1 {
return Err(format!("dial {label:?} drawn {n} times, want 1: {rows:?}"));
}
}
pty.type_in("\x1b");
if !pty.wait_for_screen_after("PSYCHE_RETURNED", "^D exit", REACH_TIMEOUT) {
return Err("Esc did not close the panel and restore the editor".into());
}
transcript.push_str(&pty.screen());
pty.type_in("after-psyche\r");
if !pty.wait_for_screen("PSYCHE_RESTORED", REACH_TIMEOUT) {
return Err("the next submission was lost or contaminated by panel keys".into());
}
if pty.termios_snapshot() != baseline {
return Err("panel/cockpit did not restore the exact terminal mode".into());
}
pty.type_in("\n");
Ok(())
})();
if result.is_err() {
let _ = child.kill();
}
let status = wait_for_child(&mut child, EXIT_TIMEOUT);
transcript.push_str(&pty.screen_to_eof());
assert!(result.is_ok(), "{result:?}: {transcript:?}");
assert!(
status.is_some_and(|status| status.success()),
"{status:?}: {transcript:?}"
);
}
#[cfg(feature = "live-spill")]
pub(crate) fn drive_cockpit_pager() {
for sizes in [
&[][..],
&[(12, 46), (30, 100), (24, 80)][..],
&[(12, 46), (30, 100)][..],
] {
drive_cockpit_pager_sizes(sizes);
}
}
#[cfg(feature = "live-spill")]
fn drive_cockpit_pager_sizes(sizes: &[(u16, u16)]) {
let pty = Pty::open_with_cursor_reply(1, 1);
pty.resize(24, 80);
let baseline = pty.termios_snapshot();
let mut child = spawn_child(&pty, "cockpit_pager");
let mut transcript = String::new();
let result = (|| -> Result<(), String> {
if !pty.wait_for_screen_after("spill-row-00", "╯", REACH_TIMEOUT) {
return Err("pager never painted its bottom border".into());
}
let first = pty.screen();
transcript.push_str(&first);
let (primary, frame) = first
.split_once("\x1b[?1049h")
.ok_or("pager must enter the real alternate screen, not stdout capture")?;
let (_, after_history) = primary
.split_once("HISTORY_BEFORE_PAGER")
.ok_or("fixture must paint primary-screen history before the pager")?;
if after_history.contains('\n')
|| after_history.contains("\x1b[1;1H\x1b[J")
|| after_history.contains("\x1b[2J")
{
return Err(
"pager loan moved or cleared visible history before alternate entry".into(),
);
}
let rows = screen_grid(frame);
if !rows.get(1).is_some_and(|row| row.contains("spill-row-00"))
|| !rows.get(2).is_some_and(|row| row.contains("spill-row-01"))
|| frame.contains("^D exit")
{
return Err(format!(
"pager must show separate body rows and occlude the composer: {rows:?}"
));
}
for &(height, width) in sizes {
pty.resize(height, width);
signal_winch(child.id());
if !pty.wait_for_screen("╯", REACH_TIMEOUT) {
return Err(format!("no repaint after resize to {width}x{height}"));
}
let repaint = pty.screen();
transcript.push_str(&repaint);
let rows = screen_grid(&repaint);
if rows.len() != usize::from(height)
|| rows
.last()
.is_none_or(|row| row.chars().nth(usize::from(width - 1)) != Some('╯'))
|| !rows.get(1).is_some_and(|row| row.contains("spill-row-00"))
|| repaint.contains("^D exit")
{
return Err(format!(
"pager geometry or input ownership lost after resize: {rows:?}"
));
}
}
pty.type_in("z\x1b");
if !pty.wait_for_screen_after("PAGER_RETURNED", "^D exit", REACH_TIMEOUT) {
return Err("pager did not return the editor after Esc".into());
}
let tail = pty.screen();
transcript.push_str(&tail);
if !tail.contains("\x1b[?1049l") || tail.contains("spill-row-") {
return Err("pager pixels escaped into the restored transcript".into());
}
let (_, restored) = tail.split_once("\x1b[?1049l").unwrap();
if restored.contains("\x1b[1;1H\x1b[J") || restored.contains("\x1b[2J") {
return Err("pager release cleared the restored primary screen".into());
}
let rows = screen_grid(&format!("{primary}{restored}"));
if !rows
.first()
.is_some_and(|row| row.contains("HISTORY_BEFORE_PAGER"))
{
return Err(format!("prior visible history was not restored: {rows:?}"));
}
pty.type_in("after-view\r");
if !pty.wait_for_screen("PAGER_RESTORED", REACH_TIMEOUT) {
return Err("the next submission was lost or contaminated by modal keys".into());
}
if pty.termios_snapshot() != baseline {
return Err("pager/cockpit did not restore the exact terminal mode".into());
}
pty.type_in("\n");
Ok(())
})();
if result.is_err() {
let _ = child.kill();
}
let status = wait_for_child(&mut child, EXIT_TIMEOUT);
transcript.push_str(&pty.screen_to_eof());
assert!(result.is_ok(), "{result:?}: {transcript:?}");
assert!(
status.is_some_and(|status| status.success()),
"{status:?}: {transcript:?}"
);
}
pub(crate) fn erasing_grid(stream: &str, rows: usize) -> Vec<String> {
erasing_screen(stream, rows).0
}
pub(crate) fn erasing_screen(stream: &str, rows: usize) -> (Vec<String>, (usize, usize)) {
let mut grid: Vec<Vec<char>> = vec![Vec::new(); rows];
let (mut row, mut col) = (0usize, 0usize);
let mut chars = stream.chars().peekable();
while let Some(c) = chars.next() {
match c {
'\n' => {
col = 0;
if row + 1 == rows {
grid.remove(0);
grid.push(Vec::new());
} else {
row += 1;
}
}
'\r' => col = 0,
'\u{1b}' if chars.peek() == Some(&'[') => {
chars.next();
let mut params = String::new();
let mut fin = '\0';
for f in chars.by_ref() {
if ('\u{40}'..='\u{7e}').contains(&f) {
fin = f;
break;
}
params.push(f);
}
let n = |i: usize, d: usize| {
params
.split(';')
.nth(i)
.and_then(|p| p.parse().ok())
.unwrap_or(d)
};
match fin {
'H' => (row, col) = ((n(0, 1) - 1).min(rows - 1), n(1, 1) - 1),
'J' | 'K' => {
let mode = n(0, 0);
let cut = |line: &mut Vec<char>, from: usize, to: usize| {
for cell in line.iter_mut().take(to).skip(from) {
*cell = ' ';
}
};
let width = grid[row].len();
match mode {
0 => cut(&mut grid[row], col, width),
1 => cut(&mut grid[row], 0, col + 1),
_ => cut(&mut grid[row], 0, width),
}
if fin == 'J' {
let (from, to) = match mode {
0 => (row + 1, rows),
1 => (0, row),
_ => (0, rows),
};
for line in &mut grid[from..to] {
line.clear();
}
}
}
_ => {}
}
}
'\u{1b}' => {
chars.next();
}
c if !c.is_control() => {
let line = &mut grid[row];
if line.len() <= col {
line.resize(col + 1, ' ');
}
line[col] = c;
col += 1;
}
_ => {}
}
}
let lines = grid
.into_iter()
.map(|l| l.into_iter().collect::<String>().trim_end().to_string())
.collect();
(lines, (row, col))
}
fn panel_repaint_rows(stream: &str, terminal_rows: usize) -> Vec<String> {
erasing_grid(stream, terminal_rows)
}
fn panel_height(rows: &[String]) -> Option<usize> {
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)
}
fn wait_for_panel_repaint(
pty: &Pty,
terminal_rows: usize,
ready: impl Fn(&[String]) -> bool,
) -> Result<(String, Vec<String>), String> {
let deadline = Instant::now() + REACH_TIMEOUT;
let mut repaint = String::new();
let mut rows = Vec::new();
const FRAME_END: &str = "\x1b[?25l";
while pty.wait_for_screen(
FRAME_END,
deadline.saturating_duration_since(Instant::now()),
) {
repaint.push_str(&pty.screen());
if let Some(end) = repaint.rfind(FRAME_END) {
rows = panel_repaint_rows(&repaint[..end + FRAME_END.len()], terminal_rows);
if ready(&rows) {
return Ok((repaint, rows));
}
}
if Instant::now() >= deadline {
break;
}
}
Err(format!(
"panel repaint did not reach the requested state: {rows:?}; bytes: {repaint:?}"
))
}
#[test]
fn panel_repaint_discards_erased_zoom_frame_in_one_drain() {
let repaint = concat!(
"\x1b[1;1H╭ zoom ╮\x1b[2;1H❯ stale selection",
"\x1b[20;1H╰──────╯\x1b[?25l",
"\x1b[1;1H\x1b[J",
"\x1b[13;1H╭ loop ╮\x1b[14;1H❯ panel-row-03",
"\x1b[20;1H╰──────╯\x1b[?25l",
);
let rows = panel_repaint_rows(repaint, 20);
assert_eq!(rows.iter().position(|row| row.contains('╭')), Some(12));
assert_eq!(rows.iter().rposition(|row| row.contains('╰')), Some(19));
assert!(rows[13].contains("❯ panel-row-03"));
assert!(!rows.iter().any(|row| row.contains("stale selection")));
}
pub(crate) fn drive_cockpit_panel_loop() {
let pty = Pty::open_with_cursor_reply(1, 1);
pty.resize(24, 80);
let baseline = pty.termios_snapshot();
let mut child = spawn_child(&pty, "cockpit_panel_loop");
let mut transcript = String::new();
let result = (|| -> Result<(), String> {
let (repaint, _) = wait_for_panel_repaint(&pty, 24, |rows| {
panel_height(rows) == Some(8) && rows.iter().any(|row| row.contains("❯ panel-row-00"))
})?;
transcript.push_str(&repaint);
pty.type_in("jjj");
let (repaint, _) = wait_for_panel_repaint(&pty, 24, |rows| {
rows.iter().any(|row| row.contains("❯ panel-row-03"))
})?;
transcript.push_str(&repaint);
for &(height, width) in &[(24u16, 50u16), (24, 100), (40, 100), (16, 100)] {
pty.resize(height, width);
signal_winch(child.id());
let (repaint, rows) = wait_for_panel_repaint(&pty, usize::from(height), |rows| {
rows.iter()
.any(|row| row.chars().nth(usize::from(width - 1)) == Some('╯'))
&& rows.iter().any(|row| row.contains("❯ panel-row-03"))
})?;
transcript.push_str(&repaint);
if repaint.contains("\x1b[1;1H\x1b[J") || repaint.contains("\x1b[2J") {
return Err(format!("resize to {width}x{height} erased from row 0"));
}
if !rows
.iter()
.any(|row| row.chars().nth(usize::from(width - 1)) == Some('╯'))
{
return Err(format!("border not at the new width {width}: {rows:?}"));
}
if !rows.iter().any(|row| row.contains("❯ panel-row-03")) {
return Err(format!(
"selection lost after resize to {width}x{height}: {rows:?}"
));
}
}
let mut terminal_height = 16;
for (keys, want, what) in [
("\x1b[1;2A", Some(9), "Shift-Up grows one row"),
("\x1b[1;2B", Some(8), "Shift-Down shrinks one row"),
("\x1a", Some(16), "Ctrl-Z fills the 16-row screen"),
(
"resize:12x60",
Some(12),
"zoomed, terminal narrowed and shortened",
),
(
"resize:20x100",
Some(20),
"zoomed, terminal widened and lengthened",
),
("\x1a", Some(8), "Ctrl-Z again restores the prior height"),
] {
let mut width = 100;
if let Some(size) = keys.strip_prefix("resize:") {
let (h, w) = size.split_once('x').expect("HxW");
let (h, w): (u16, u16) = (h.parse().unwrap(), w.parse().unwrap());
width = w;
terminal_height = usize::from(h);
pty.resize(h, w);
signal_winch(child.id());
} else {
pty.type_in(keys);
}
let (repaint, rows) = wait_for_panel_repaint(&pty, terminal_height, |rows| {
panel_height(rows) == want
&& rows.iter().any(|row| row.contains("❯ panel-row-03"))
&& rows
.iter()
.any(|row| row.chars().nth(usize::from(width - 1)) == Some('╯'))
})
.map_err(|error| format!("{what}: {error}"))?;
transcript.push_str(&repaint);
if panel_height(&rows) != want {
return Err(format!(
"{what}: height {:?}: {rows:?}",
panel_height(&rows)
));
}
if keys.starts_with("resize:")
&& !rows
.iter()
.any(|row| row.chars().nth(usize::from(width - 1)) == Some('╯'))
{
return Err(format!("{what}: border not at width {width}: {rows:?}"));
}
if !rows.iter().any(|row| row.contains("❯ panel-row-03")) {
return Err(format!("selection lost after: {what}: {rows:?}"));
}
}
pty.type_in("\x1b");
if !pty.wait_for_screen("PANEL_CLOSED", REACH_TIMEOUT) {
return Err("Esc did not close the panel".into());
}
transcript.push_str(&pty.screen());
pty.type_in("\r");
if !pty.wait_for_screen("PANEL_LOOP_RESTORED", REACH_TIMEOUT) {
return Err("the draft was not handed back intact".into());
}
if pty.termios_snapshot() != baseline {
return Err("panel/cockpit did not restore the exact terminal mode".into());
}
pty.type_in("\n");
Ok(())
})();
if result.is_err() {
let _ = child.kill();
}
let status = wait_for_child(&mut child, EXIT_TIMEOUT);
transcript.push_str(&pty.screen_to_eof());
assert!(result.is_ok(), "{result:?}: {transcript:?}");
assert!(
status.is_some_and(|status| status.success()),
"{status:?}: {transcript:?}"
);
}
pub(crate) fn drive_cockpit_panel_close() {
let pty = Pty::open_with_cursor_reply(1, 1);
pty.resize(24, 80);
let mut child = spawn_child(&pty, "cockpit_panel_loop");
let mut transcript = String::new();
let result = (|| -> Result<(), String> {
if !pty.wait_for_screen_after("panel-row-00", "╯", REACH_TIMEOUT) {
return Err("the panel never painted".into());
}
pty.type_in("jjj");
if !pty.wait_for_screen("panel-row-03", REACH_TIMEOUT) {
return Err("the selection did not move".into());
}
transcript.push_str(&pty.screen());
let open = erasing_grid(&transcript, 24);
if !open.iter().any(|row| row.contains("panel-row-03")) {
return Err(format!(
"control: the panel is not on the replayed screen: {open:?}"
));
}
pty.type_in("\x1b");
if !pty.wait_for_screen("PANEL_CLOSED", REACH_TIMEOUT) {
return Err("Esc did not close the panel".into());
}
transcript.push_str(&pty.screen());
let live = erasing_grid(&transcript, 24);
if !live.iter().any(|row| row.contains("HISTORY_ABOVE_PANEL")) {
return Err(format!(
"close erased the history above the panel: {live:?}"
));
}
if let Some(row) = live.iter().find(|row| row.contains("panel-row-")) {
return Err(format!("panel text left after close: {row:?}: {live:?}"));
}
pty.type_in("\r");
if !pty.wait_for_screen("PANEL_LOOP_RESTORED", REACH_TIMEOUT) {
return Err("the draft was not handed back intact".into());
}
pty.type_in("\n");
Ok(())
})();
if result.is_err() {
let _ = child.kill();
}
let status = wait_for_child(&mut child, EXIT_TIMEOUT);
transcript.push_str(&pty.screen_to_eof());
assert!(result.is_ok(), "{result:?}: {transcript:?}");
assert!(
status.is_some_and(|status| status.success()),
"{status:?}: {transcript:?}"
);
}
pub(crate) fn drive_cockpit_bang() {
let pty = Pty::open_with_cursor_reply(1, 1);
let baseline = pty.termios_snapshot();
let mut child = spawn_child(&pty, "cockpit_bang");
let mut failure = None;
for (step, input) in ["hello child\r", "\x03", "\x04", "after interrupts\r"]
.into_iter()
.cycle()
.take(8)
.enumerate()
{
if !pty.wait_for_screen(&format!("BANG_READY_{step}>"), REACH_TIMEOUT) {
failure = Some(format!("child {step} never became ready"));
break;
}
if pty.termios_snapshot() != baseline {
failure = Some(format!(
"child {step} did not receive the inherited cooked terminal"
));
break;
}
pty.type_in(input);
if !pty.wait_for_screen_after(&format!("BANG_RETURN_{step}"), "^D exit", REACH_TIMEOUT) {
failure = Some(format!("child {step} did not return to the editor"));
break;
}
if !pty.is_raw() {
failure = Some(format!("editor {step} did not regain raw input"));
break;
}
pty.type_in(&format!("next-{step}\r"));
}
let restored = if failure.is_none() && pty.wait_for_screen("BANG_RESTORED", REACH_TIMEOUT) {
let restored = pty.termios_snapshot();
pty.type_in("\n");
Some(restored)
} else {
failure.get_or_insert_with(|| "the cockpit never restored the terminal".into());
None
};
if failure.is_some() {
let _ = child.kill();
}
let status = wait_for_child(&mut child, EXIT_TIMEOUT);
let screen = pty.screen_to_eof();
assert!(failure.is_none(), "{failure:?}: {screen:?}");
assert!(
status.is_some_and(|status| status.success()),
"{status:?}: {screen:?}"
);
assert!(screen.contains("BANG_READ_0:0:hello child"), "{screen:?}");
assert!(
!screen.contains("BANG_READ_1:"),
"Ctrl-C must interrupt the child: {screen:?}"
);
assert!(
screen.contains("BANG_READ_2:1:"),
"EOF must reach the child: {screen:?}"
);
assert!(
screen.contains("BANG_READ_3:0:after interrupts"),
"{screen:?}"
);
assert_eq!(
restored,
Some(baseline),
"full inherited terminal mode after cockpit shutdown"
);
}
fn drive_cockpit_case(mode: &str) {
let pty = Pty::open();
let mut child = spawn_child(&pty, mode);
let status = wait_for_child(&mut child, EXIT_TIMEOUT);
let screen = pty.screen_to_eof();
assert!(
status.is_some_and(|status| status.success()),
"{mode} child failed: {status:?}; {screen:?}"
);
}
struct Outcome {
raw_after: bool,
raw_during: bool,
screen: String,
}
fn drive(mode: &str) -> Outcome {
let pty = Pty::open();
let mut child = spawn_child(&pty, mode);
let mut screen = String::new();
let raw_during = {
let deadline = Instant::now() + REACH_TIMEOUT;
loop {
screen.push_str(&pty.screen());
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());
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,
screen,
}
}
struct NestedOutcome {
raw_during_outer: bool,
raw_after_inner_drop: bool,
raw_after_all: bool,
}
fn drive_nested() -> NestedOutcome {
let pty = Pty::open();
let mut child = spawn_child(&pty, "nested");
let raw_during_outer = {
let deadline = Instant::now() + REACH_TIMEOUT;
loop {
if pty.is_raw() {
break true;
}
if Instant::now() >= deadline {
break false;
}
std::thread::sleep(Duration::from_millis(20));
}
};
pty.type_in("x");
std::thread::sleep(Duration::from_millis(250));
let raw_after_inner_drop = pty.is_raw();
pty.type_in("x");
let _ = std::thread::scope(|scope| {
let waiter = scope.spawn(|| wait_for_child(&mut child, EXIT_TIMEOUT));
while !waiter.is_finished() {
let _ = pty.screen();
std::thread::sleep(Duration::from_millis(20));
}
waiter.join().expect("child reaper thread")
});
std::thread::sleep(Duration::from_millis(80));
let _ = pty.screen();
NestedOutcome {
raw_during_outer,
raw_after_inner_drop,
raw_after_all: pty.is_raw(),
}
}
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 the_interaction_frame_never_enters_the_alternate_screen() {
let out = drive("clean");
assert!(
out.raw_during,
"the pty was never raw; nothing was exercised"
);
assert!(
!out.screen.contains("\x1b[?1049h"),
"the interaction frame entered the alternate screen, which the \
plain-scroller carve-out does not permit during a turn; screen={:?}",
out.screen
);
}
#[serial_test::serial(interaction_pty)]
#[test]
#[ignore = "real-PTY acceptance tier; weekly, release, and scoped PTY CI only"]
fn a_clean_close_leaves_the_terminal_exactly_as_it_was_found() {
let out = drive("clean");
assert_restored("clean", &out);
assert!(!out.screen.contains("\x1b[6n"), "{:?}", out.screen);
}
#[serial_test::serial(interaction_pty)]
#[test]
#[ignore = "real-PTY acceptance tier; weekly, release, and scoped PTY CI only"]
fn a_panic_while_the_frame_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_frame_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_nested_frame_does_not_restore_the_terminal_early() {
let out = drive_nested();
assert!(
out.raw_during_outer,
"the pty was never raw — the test proved nothing, because it never \
observed the state that must be preserved"
);
assert!(
out.raw_after_inner_drop,
"the INNER frame's drop handed the terminal back while the OUTER \
frame was still up: raw mode was lost mid-modal, so the outer \
frame's keyboard is line-buffered and kernel-echoed (#1770)"
);
assert!(
!out.raw_after_all,
"both frames are gone and the terminal is still RAW"
);
}
struct Frames {
first: String,
after_resize: String,
tail: String,
raw_during: bool,
raw_after: bool,
}
fn collect(pty: &Pty, ticks: usize) -> String {
let mut out = String::new();
let mut answered = 0usize;
for _ in 0..ticks {
out.push_str(&pty.screen());
answer_cursor_report(pty, &out, &mut answered);
std::thread::sleep(Duration::from_millis(20));
}
out
}
fn drive_present(rows: u16, cols: u16, resize_to: Option<(u16, u16)>, key: &str) -> Frames {
let pty = Pty::open();
pty.resize(rows, cols);
let mut child = spawn_child(&pty, "present");
let mut first = String::new();
let mut answered = 0usize;
let raw_during = {
let deadline = Instant::now() + REACH_TIMEOUT;
loop {
first.push_str(&pty.screen());
answer_cursor_report(&pty, &first, &mut answered);
if pty.is_raw() {
break true;
}
if Instant::now() >= deadline {
break false;
}
std::thread::sleep(Duration::from_millis(20));
}
};
for _ in 0..30 {
first.push_str(&pty.screen());
answer_cursor_report(&pty, &first, &mut answered);
std::thread::sleep(Duration::from_millis(20));
}
let after_resize = match resize_to {
Some((r, c)) => {
pty.resize(r, c);
signal_winch(child.id());
collect(&pty, 35)
}
None => String::new(),
};
pty.type_in(key);
let mut tail = String::new();
let _ = std::thread::scope(|scope| {
let waiter = scope.spawn(|| wait_for_child(&mut child, EXIT_TIMEOUT));
while !waiter.is_finished() {
tail.push_str(&pty.screen());
std::thread::sleep(Duration::from_millis(20));
}
waiter.join().expect("child reaper thread")
});
std::thread::sleep(Duration::from_millis(120));
tail.push_str(&pty.screen());
Frames {
first,
after_resize,
tail,
raw_during,
raw_after: pty.is_raw(),
}
}
fn overflowing(segment: &str, width: usize) -> Vec<String> {
screen_grid(segment)
.into_iter()
.filter(|l| !is_harness_noise(l) && l.chars().count() > width)
.collect()
}
#[serial_test::serial(interaction_pty)]
#[test]
#[ignore = "real-PTY acceptance tier; weekly, release, and scoped PTY CI only"]
fn a_resize_reflows_the_frame_and_still_restores_the_terminal() {
const NARROW: usize = 46;
let f = drive_present(24, 100, Some((12, NARROW as u16)), "d\r");
assert!(
f.raw_during,
"the pty was never raw — nothing was exercised"
);
assert!(!f.raw_after, "the terminal was left RAW after a resize");
assert!(
f.tail.contains("OUTCOME:"),
"the frame did not answer after the resize; tail={:?}",
f.tail
);
assert!(
!f.after_resize.trim().is_empty(),
"the frame drew nothing after SIGWINCH — it did not notice the resize"
);
let over = overflowing(&f.after_resize, NARROW);
assert!(
over.is_empty(),
"after resizing to {NARROW} columns the frame still drew wider: {over:#?}"
);
}
#[serial_test::serial(interaction_pty)]
#[test]
#[ignore = "real-PTY acceptance tier; weekly, release, and scoped PTY CI only"]
fn inspect_pending_grant_returns_without_answering() {
let f = drive_present(24, 100, None, "\x1bOS\r\x1bOSd\r");
assert!(f.raw_during);
assert!(!f.raw_after);
assert!(f.tail.contains("inspect pending request"), "{:?}", f.tail);
assert!(f.tail.contains("Answer(\"deny\")"), "{:?}", f.tail);
assert!(!f.tail.contains("Answer(\"allow_once\")"));
}
#[serial_test::serial(interaction_pty)]
#[test]
#[ignore = "real-PTY acceptance tier; weekly, release, and scoped PTY CI only"]
fn a_narrow_terminal_wraps_rather_than_overflowing() {
const COLS: usize = 28;
let f = drive_present(20, COLS as u16, None, "d\r");
assert!(
f.raw_during,
"the pty was never raw — nothing was exercised"
);
assert!(!f.raw_after, "the terminal was left RAW");
let over = overflowing(&f.first, COLS);
assert!(
over.is_empty(),
"the frame overflowed a {COLS}-column terminal: {over:#?}"
);
let flat = screen_grid(&f.first).join(" ");
assert!(
flat.contains("run_command") || flat.contains("bash"),
"the body vanished at {COLS} columns rather than wrapping: {flat:?}"
);
assert!(f.tail.contains("OUTCOME:"), "the frame did not answer");
}
fn painted(pump: &mut crate::panel_raw_mode_pty_test::Pump<'_>, needle: &str, rows: usize) -> bool {
let deadline = Instant::now() + REACH_TIMEOUT;
loop {
pump.pump();
if erasing_grid(&pump.transcript, rows)
.iter()
.any(|row| row.contains(needle))
{
return true;
}
if Instant::now() >= deadline {
return false;
}
std::thread::sleep(Duration::from_millis(20));
}
}
struct Committed {
grid: Vec<String>,
open_grid: Vec<String>,
cursor: (usize, usize),
stream: String,
}
fn drive_committed_frame(mode: &str, answer_queries: bool) -> Committed {
const ROWS: usize = 24;
let pty = Pty::open();
pty.resize(ROWS as u16, 80);
let mut child = spawn_child(&pty, mode);
let mut pump = crate::panel_raw_mode_pty_test::Pump::new(&pty, answer_queries, ROWS);
let mut open_grid = Vec::new();
let result = (|| -> Result<(), String> {
if !painted(&mut pump, "(default)", ROWS) {
return Err("the frame never painted its options".into());
}
open_grid = erasing_grid(&pump.transcript, ROWS);
pty.type_in("\r");
pump.until("OUTCOME:").ok_or("the frame never answered")?;
Ok(())
})();
let raw_after = pty.is_raw();
pty.type_in("x\n");
let status = wait_for_child(&mut child, EXIT_TIMEOUT);
pump.pump();
let stream = pump.transcript;
assert!(result.is_ok(), "{mode}: {result:?}; stream={stream:?}");
assert!(
status.is_some_and(|status| status.success()),
"{mode}: {status:?}; stream={stream:?}"
);
assert!(
!raw_after,
"{mode}: the terminal was left RAW; stream={stream:?}"
);
let before_outcome = stream.split("OUTCOME:").next().unwrap_or_default();
let (grid, cursor) = erasing_screen(before_outcome, ROWS);
Committed {
grid,
open_grid,
cursor,
stream,
}
}
fn assert_frame_erased(mode: &str, c: &Committed) {
for needle in ["╭", "╰", "run_command", "(default)", "decision required"] {
assert!(
!c.grid.iter().any(|row| row.contains(needle)),
"{mode}: {needle:?} is still on screen after the frame closed: {:#?}\nstream={:?}",
c.grid,
c.stream
);
}
}
fn assert_committed_under(mode: &str, c: &Committed, last: &str) {
let k = c
.grid
.iter()
.position(|row| row.trim_end() == last)
.unwrap_or_else(|| panic!("{mode}: {last} is not on screen: {:#?}", c.grid));
assert_eq!(
c.grid.get(k + 1).map(|row| row.trim_end()),
Some("COMMITTED-AFTER-FRAME"),
"{mode}: the line committed after the frame is not directly under \
{last}: {:#?}\nstream={:?}",
c.grid,
c.stream
);
assert_eq!(
c.cursor,
(k + 2, 0),
"{mode}: the cursor did not come back to the transcript: {:#?}",
c.grid
);
}
#[serial_test::serial(interaction_pty)]
#[test]
#[ignore = "real-PTY acceptance tier; weekly, release, and scoped PTY CI only"]
fn after_a_frame_the_next_line_prints_directly_under_the_transcript() {
let c = drive_committed_frame("committed_frame", true);
assert_committed_under("committed_frame", &c, "TRANSCRIPT-04");
assert_frame_erased("committed_frame", &c);
assert!(
c.grid[6..].iter().all(|row| row.is_empty()),
"rows under the committed line are not blank: {:#?}",
c.grid
);
}
#[serial_test::serial(interaction_pty)]
#[test]
#[ignore = "real-PTY acceptance tier; weekly, release, and scoped PTY CI only"]
fn a_partial_transcript_line_survives_opening_and_closing_a_frame() {
for mode in ["committed_frame_partial", "committed_frame_partial_scroll"] {
let c = drive_committed_frame(mode, true);
assert!(
c.open_grid
.iter()
.any(|row| row.trim_end() == "PARTIAL-END"),
"{mode}: opening erased the partial line: {:#?}; stream={:?}",
c.open_grid,
c.stream
);
let row = c
.grid
.iter()
.position(|row| row.trim_end() == "PARTIAL-ENDCOMMITTED-AFTER-FRAME")
.unwrap_or_else(|| {
panic!(
"{mode}: closing lost the line or its return column: {:#?}; stream={:?}",
c.grid, c.stream
)
});
assert_eq!(c.cursor, (row + 1, 0));
assert_frame_erased(mode, &c);
}
}
#[serial_test::serial(interaction_pty)]
#[test]
#[ignore = "real-PTY acceptance tier; weekly, release, and scoped PTY CI only"]
fn after_a_frame_beside_a_row_holder_the_next_line_prints_under_the_transcript() {
let c = drive_committed_frame("committed_frame_held", true);
assert_committed_under("committed_frame_held", &c, "TRANSCRIPT-04");
assert_frame_erased("committed_frame_held", &c);
for r in 0..usize::from(FRAME_HOLDER_ROWS) {
assert!(
c.grid[21 + r].contains(&format!("{FRAME_HOLDER_SENTINEL}-{r}")),
"holder row {r} was erased or overwritten: {:#?}\nstream={:?}",
c.grid,
c.stream
);
}
}
#[serial_test::serial(interaction_pty)]
#[test]
#[ignore = "real-PTY acceptance tier; weekly, release, and scoped PTY CI only"]
fn a_frame_near_the_bottom_scrolls_the_transcript_and_the_next_line_follows_it() {
let c = drive_committed_frame("committed_frame_scroll", true);
assert_committed_under("committed_frame_scroll", &c, "TRANSCRIPT-21");
assert_frame_erased("committed_frame_scroll", &c);
assert!(
!c.grid.iter().any(|row| row.contains("TRANSCRIPT-00")),
"the frame could not have fit without scrolling, yet row 0 is still \
there: {:#?}",
c.grid
);
}
#[serial_test::serial(interaction_pty)]
#[test]
#[ignore = "real-PTY acceptance tier; weekly, release, and scoped PTY CI only"]
fn a_frame_shifted_above_a_holder_still_returns_the_cursor_to_the_transcript() {
let c = drive_committed_frame("committed_frame_contested", true);
assert_eq!(
c.grid[18].trim_end(),
"COMMITTED-AFTER-FRAME",
"the committed line is not on the cursor's row: {:#?}\nstream={:?}",
c.grid,
c.stream
);
assert_eq!(c.cursor, (19, 0), "{:#?}", c.grid);
assert_frame_erased("committed_frame_contested", &c);
for r in 0..usize::from(FRAME_HOLDER_ROWS) {
assert!(
c.grid[21 + r].contains(&format!("{FRAME_HOLDER_SENTINEL}-{r}")),
"holder row {r} was erased or overwritten: {:#?}",
c.grid
);
}
}
#[serial_test::serial(interaction_pty)]
#[test]
#[ignore = "real-PTY acceptance tier; weekly, release, and scoped PTY CI only"]
fn a_terminal_that_does_not_report_the_cursor_still_gets_a_frame_and_no_guess() {
let c = drive_committed_frame("committed_frame", false);
assert!(
c.grid
.iter()
.any(|row| row.trim_end() == "COMMITTED-AFTER-FRAME"),
"{:#?}",
c.grid
);
assert_frame_erased("committed_frame/silent", &c);
}
fn drive_active_turn(key: &str) -> (String, usize, String, bool, bool) {
let pty = Pty::open();
pty.resize(24, 100);
let mut child = spawn_child(&pty, "active_turn");
let mut all = String::new();
let saw_spinner = {
let deadline = Instant::now() + REACH_TIMEOUT;
loop {
all.push_str(&pty.screen());
if all.chars().any(|c| ('\u{2800}'..='\u{28FF}').contains(&c)) {
break true;
}
if Instant::now() >= deadline {
break false;
}
std::thread::sleep(Duration::from_millis(20));
}
};
assert!(
saw_spinner,
"no spinner frames appeared — the scenario never had a live writer to \
be interrupted by, so it would prove nothing; screen={all:?}"
);
let mut answered = 0usize;
let mut raw_during = false;
let deadline = Instant::now() + REACH_TIMEOUT;
while Instant::now() < deadline {
all.push_str(&pty.screen());
answer_cursor_report(&pty, &all, &mut answered);
if pty.is_raw() {
raw_during = true;
}
if raw_during && all.contains("\u{1b}[6n") {
break;
}
std::thread::sleep(Duration::from_millis(20));
}
for _ in 0..25 {
all.push_str(&pty.screen());
answer_cursor_report(&pty, &all, &mut answered);
std::thread::sleep(Duration::from_millis(20));
}
let frame_start = all.find("\u{1b}[6n").expect("the frame queried the cursor");
pty.type_in(key);
let mut tail = String::new();
let _ = std::thread::scope(|scope| {
let waiter = scope.spawn(|| wait_for_child(&mut child, EXIT_TIMEOUT));
while !waiter.is_finished() {
tail.push_str(&pty.screen());
std::thread::sleep(Duration::from_millis(20));
}
waiter.join().expect("child reaper thread")
});
std::thread::sleep(Duration::from_millis(120));
tail.push_str(&pty.screen());
(all, frame_start, tail, raw_during, pty.is_raw())
}
#[serial_test::serial(interaction_pty)]
#[test]
#[ignore = "real-PTY acceptance tier; weekly, release, and scoped PTY CI only"]
fn a_frame_opened_mid_turn_is_not_painted_over_by_the_spinner() {
let (all, frame_start, tail, raw_during, raw_after) = drive_active_turn("d\r");
assert!(raw_during, "the frame never took raw mode");
assert!(!raw_after, "the terminal was left RAW");
assert!(
tail.contains("OUTCOME:"),
"the frame never answered; tail={tail:?}"
);
let window = &all[frame_start..];
let grid = screen_grid(window).join(" ");
assert!(
grid.contains("run_command") || grid.contains("allow once"),
"the frame did not draw over the spinner: {grid:?}"
);
assert!(
all[..frame_start]
.chars()
.any(|c| ('\u{2800}'..='\u{28FF}').contains(&c)),
"no spinner ran before the frame opened; the scenario is vacuous"
);
let intruders: Vec<char> = window
.chars()
.filter(|c| ('\u{2800}'..='\u{28FF}').contains(c))
.collect();
assert!(
intruders.is_empty(),
"a spinner frame painted over the interaction frame after it opened — \
the #1312 failure, where the operator cannot read what they are being \
asked; intruders={intruders:?}"
);
}