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();
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();
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);
}
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();
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:?}"
);
}