use std::io::Write as _;
use std::time::Duration;
use tests_pty::Pty;
use super::widgets::{Level, Notice};
use super::{LineCaps, Sink, Spinner, Terminal};
const NOTICE_TEXT: &str = "⚠ summarizer falling back to qwen:0.5b…";
const QUESTION: &str = "⊘ web_fetch wants example.com — [a]llow once, [d]eny > ";
const DWELL: Duration = Duration::from_millis(250);
fn notice() -> Notice<'static> {
Notice::new(Level::Warn, "", NOTICE_TEXT)
}
#[test]
#[ignore = "child process of the notice/arbiter regression test"]
fn notice_under_spinner_child() {
if std::env::var_os("NEWT_NOTICE_PTY_CHILD").as_deref() != Some("spinner".as_ref()) {
return;
}
let spinner =
Spinner::start_with_caps(LineCaps::Own, "compressing context…", Sink::Stdout, true)
.expect("the pty is a real terminal, so the spinner takes the line");
std::thread::sleep(DWELL);
notice().emit(LineCaps::Own, Sink::Stdout, true);
std::thread::sleep(DWELL);
drop(spinner);
}
#[test]
#[ignore = "child process of the notice/arbiter regression test"]
fn notice_under_prompt_window_child() {
if std::env::var_os("NEWT_NOTICE_PTY_CHILD").as_deref() != Some("prompt".as_ref()) {
return;
}
let spinner =
Spinner::start_with_caps(LineCaps::Own, "compressing context…", Sink::Stdout, true)
.expect("spinner");
std::thread::sleep(DWELL);
{
let window = Terminal::suspend_for_prompt(crate::tty::TerminalTaker::PlainCliConfirm);
window.ask(QUESTION).expect("the question reaches the pty");
std::thread::sleep(Duration::from_millis(100));
notice().emit(LineCaps::Own, Sink::Stdout, true);
std::thread::sleep(Duration::from_millis(100));
}
drop(spinner);
}
#[test]
#[ignore = "child process of the notice/arbiter regression test"]
fn legacy_notice_under_prompt_window_child() {
if std::env::var_os("NEWT_NOTICE_PTY_CHILD").as_deref() != Some("legacy".as_ref()) {
return;
}
let spinner =
Spinner::start_with_caps(LineCaps::Own, "compressing context…", Sink::Stdout, true)
.expect("spinner");
std::thread::sleep(DWELL);
{
let window = Terminal::suspend_for_prompt(crate::tty::TerminalTaker::PlainCliConfirm);
window.ask(QUESTION).expect("the question reaches the pty");
std::thread::sleep(Duration::from_millis(100));
let mut out = std::io::stdout();
let _ = write!(out, "\r\x1b[K\x1b[33m{NOTICE_TEXT}\x1b[0m\n");
let _ = out.flush();
std::thread::sleep(Duration::from_millis(100));
}
drop(spinner);
}
#[test]
#[ignore = "child process of the notice/arbiter regression test"]
fn protocol_mode_prompt_child() {
if std::env::var_os("NEWT_NOTICE_PTY_CHILD").as_deref() != Some("protocol".as_ref()) {
return;
}
super::caps::enter_protocol_mode();
let window = Terminal::suspend_for_prompt(crate::tty::TerminalTaker::PlainCliConfirm);
let asked = window.ask(QUESTION);
let mut buf = String::new();
let read = window.read_line_into(&mut buf);
assert!(
asked.is_err(),
"ask must refuse in protocol mode rather than report a question it \
never put on screen"
);
assert!(read.is_err(), "read must refuse, not return EOF: {read:?}");
assert!(
buf.is_empty(),
"nothing may be written into the caller's buffer"
);
window
.notice(NOTICE_TEXT)
.expect("a dropped notice is not an error");
drop(window);
let terminal = {
use std::os::fd::{FromRawFd, OwnedFd};
let raw = unsafe { libc::dup(1) };
assert!(
raw >= 0,
"dup(1) failed: {}",
std::io::Error::last_os_error()
);
std::fs::File::from(unsafe { OwnedFd::from_raw_fd(raw) })
};
let window =
Terminal::suspend_for_prompt_to(terminal, crate::tty::TerminalTaker::PlainCliConfirm);
let asked = window.ask(QUESTION);
let mut buf = String::new();
let read = window.read_line_into(&mut buf);
assert!(
asked.is_err(),
"the File door must refuse in protocol mode too — the veto sits in \
the shared constructor precisely so neither door can miss it"
);
assert!(read.is_err(), "the File door must refuse to read: {read:?}");
assert!(
buf.is_empty(),
"nothing may be written into the caller's buffer"
);
window
.notice(NOTICE_TEXT)
.expect("a dropped notice is not an error");
drop(window);
}
fn run_scenario(scenario: &str, child_test: &str) -> String {
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_NOTICE_PTY_CHILD", scenario)
.stdin(pty.slave_stdio())
.stdout(pty.slave_stdio())
.stderr(std::process::Stdio::null())
.spawn()
.expect("spawn the pty child");
let status = child.wait().expect("wait for the pty child");
let screen = pty.screen_to_eof();
assert!(
status.success(),
"the {scenario} scenario child failed.\n\nscreen:\n{screen:?}"
);
screen
}
fn frames(s: &str) -> Vec<char> {
s.chars()
.filter(|c| ('\u{2800}'..='\u{28FF}').contains(c))
.collect()
}
#[serial_test::serial(tty_arbiter)]
#[test]
fn a_notice_emitted_under_a_live_spinner_is_not_interleaved() {
let screen = run_scenario(
"spinner",
"tty::pty_notice_test::notice_under_spinner_child",
);
let start = screen
.find(NOTICE_TEXT)
.unwrap_or_else(|| panic!("the notice never reached the terminal.\n\nscreen:\n{screen:?}"));
let end = screen[start..]
.find('\n')
.map(|i| start + i)
.unwrap_or(screen.len());
assert!(
frames(&screen[start..end]).is_empty(),
"a spinner frame was painted INSIDE the notice line.\n\nnotice row:\n{:?}",
&screen[start..end]
);
assert!(
!frames(&screen[end..]).is_empty(),
"the spinner never resumed after the notice, so the ephemeral row was \
not returned.\n\nafter the notice:\n{:?}",
&screen[end..]
);
}
fn window_between_question_and_notice(screen: &str) -> String {
let q_end = screen
.find(QUESTION)
.map(|i| i + QUESTION.len())
.unwrap_or_else(|| {
panic!("the question never reached the terminal.\n\nscreen:\n{screen:?}")
});
let n_start = screen[q_end..]
.find(NOTICE_TEXT)
.map(|i| q_end + i)
.unwrap_or_else(|| panic!("the notice never reached the terminal.\n\nscreen:\n{screen:?}"));
screen[q_end..n_start].to_string()
}
#[serial_test::serial(tty_arbiter)]
#[test]
fn a_notice_emitted_under_a_prompt_window_does_not_overwrite_the_question() {
let screen = run_scenario(
"prompt",
"tty::pty_notice_test::notice_under_prompt_window_child",
);
let window = window_between_question_and_notice(&screen);
assert!(
!window.contains("\x1b[K"),
"an erase escape was written between the question and the notice — the \
question's row was wiped and the operator is blocked on a question \
they cannot see.\n\nwindow:\n{window:?}"
);
assert!(
frames(&window).is_empty(),
"the ticker painted over the question while it was up.\n\nwindow:\n{window:?}"
);
assert!(
screen.contains(QUESTION),
"the question did not survive.\n\nscreen:\n{screen:?}"
);
}
#[serial_test::serial(tty_arbiter)]
#[test]
fn the_legacy_unleased_notice_did_overwrite_the_question() {
let screen = run_scenario(
"legacy",
"tty::pty_notice_test::legacy_notice_under_prompt_window_child",
);
let window = window_between_question_and_notice(&screen);
assert!(
window.contains("\x1b[K"),
"the control no longer reproduces the bug, so the migrated path's \
assertion proves nothing.\n\nwindow:\n{window:?}"
);
}
fn run_scenario_bounded(scenario: &str, child_test: &str, budget: Duration) -> (String, bool) {
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_NOTICE_PTY_CHILD", scenario)
.stdin(pty.slave_stdio())
.stdout(pty.slave_stdio())
.stderr(if scenario.starts_with("migration-") {
pty.slave_stdio()
} else {
std::process::Stdio::null()
})
.spawn()
.expect("spawn the pty child");
let deadline = std::time::Instant::now() + budget;
let mut screen = String::new();
let mut answered = false;
let exited = loop {
screen.push_str(&pty.screen());
if scenario.starts_with("migration-modal") && !answered && screen.contains(QUESTION) {
pty.type_in("yes\r");
answered = true;
}
match child.try_wait().expect("poll the pty child") {
Some(status) => {
assert!(
status.success(),
"the {scenario} scenario child failed.\n\nscreen:\n{screen:?}"
);
break true;
}
None if std::time::Instant::now() >= deadline => {
let _ = child.kill();
let _ = child.wait();
break false;
}
None => std::thread::sleep(Duration::from_millis(20)),
}
};
screen.push_str(&pty.screen_when_finished(exited));
(screen, exited)
}
#[serial_test::serial(tty_arbiter)]
#[test]
fn protocol_mode_emits_no_prompt_bytes_and_does_not_wait() {
let (screen, exited) = run_scenario_bounded(
"protocol",
"tty::pty_notice_test::protocol_mode_prompt_child",
crate::test_guard::HANG_GUARD,
);
assert!(
exited,
"the protocol-mode child BLOCKED. `read_line` waited for an operator \
who cannot exist on a JSON-RPC wire.\n\nscreen:\n{screen:?}"
);
assert!(
!screen.contains(QUESTION),
"the question reached a machine protocol channel.\n\nscreen:\n{screen:?}"
);
assert!(
!screen.contains(NOTICE_TEXT),
"a notice reached a machine protocol channel.\n\nscreen:\n{screen:?}"
);
}
#[cfg(test)]
#[path = "pty_migration_notice_tests.rs"]
mod migration;