use fno_agents::pty::{DrainOutcome, PtySession, DEFAULT_OUTPUT_RING_BYTES};
use portable_pty::CommandBuilder;
use std::time::{Duration, Instant};
fn wait_for_output(session: &PtySession, needle: &str, timeout: Duration) -> bool {
let start = Instant::now();
while start.elapsed() < timeout {
let snap = session.snapshot();
if String::from_utf8_lossy(&snap).contains(needle) {
return true;
}
std::thread::sleep(Duration::from_millis(25));
}
false
}
#[test]
fn spawns_child_and_drains_output() {
let mut cmd = CommandBuilder::new("bash");
cmd.arg("-c");
cmd.arg("printf 'PTY_SUBSTRATE_OK\\n'");
let session =
PtySession::spawn(cmd, 24, 80, DEFAULT_OUTPUT_RING_BYTES).expect("spawn should succeed");
assert!(
session.child_pid().is_some(),
"child pid should be reported"
);
assert!(
wait_for_output(&session, "PTY_SUBSTRATE_OK", Duration::from_secs(5)),
"drainer should capture the child's stdout; got: {:?}",
String::from_utf8_lossy(&session.snapshot())
);
let _ = session.wait();
assert!(!session.is_child_alive(), "child should have exited");
let start = Instant::now();
loop {
match session.drain_outcome() {
DrainOutcome::Eof => break,
DrainOutcome::Errored { kind, message } => {
panic!("clean exit should be EOF, got error: {kind} / {message}")
}
DrainOutcome::Running => {
if start.elapsed() > Duration::from_secs(3) {
panic!("drainer never recorded a terminal outcome");
}
std::thread::sleep(Duration::from_millis(25));
}
}
}
}
#[test]
fn input_round_trips_through_pty_echo() {
let cmd = CommandBuilder::new("cat");
let session =
PtySession::spawn(cmd, 24, 80, DEFAULT_OUTPUT_RING_BYTES).expect("spawn should succeed");
session
.write_input(b"roundtrip-marker-7be3\n")
.expect("write should succeed");
assert!(
wait_for_output(&session, "roundtrip-marker-7be3", Duration::from_secs(5)),
"input should round-trip via PTY echo; got: {:?}",
String::from_utf8_lossy(&session.snapshot())
);
assert!(session.is_child_alive(), "cat should still be running");
session.kill().expect("kill should succeed");
let _ = session.wait();
assert!(!session.is_child_alive(), "child should be dead after kill");
}
#[test]
fn resize_does_not_error_on_live_pty() {
let cmd = CommandBuilder::new("cat");
let session =
PtySession::spawn(cmd, 24, 80, DEFAULT_OUTPUT_RING_BYTES).expect("spawn should succeed");
session
.resize(40, 120)
.expect("resize should succeed on a live pty");
session.kill().expect("kill should succeed");
let _ = session.wait();
}
#[test]
fn self_short_id_env_reaches_a_grandchild_of_the_pty_child() {
let mut cmd = CommandBuilder::new("bash");
cmd.arg("-c");
cmd.arg(r#"bash -c 'printf "HOOK_SEES=%s\n" "$FNO_AGENTS_SELF_SHORT_ID"'"#);
cmd.env("FNO_AGENTS_SELF_SHORT_ID", "wk-propagation-7c1d");
let session =
PtySession::spawn(cmd, 24, 80, DEFAULT_OUTPUT_RING_BYTES).expect("spawn should succeed");
assert!(
wait_for_output(
&session,
"HOOK_SEES=wk-propagation-7c1d",
Duration::from_secs(5)
),
"FNO_AGENTS_SELF_SHORT_ID must reach a grandchild of the PTY child (the hook's vantage); got: {:?}",
String::from_utf8_lossy(&session.snapshot())
);
let _ = session.wait();
assert!(!session.is_child_alive(), "child should have exited");
}