#![cfg(unix)]
use std::time::Duration;
use rust_expect::Session;
use rust_expect::types::ProcessExitStatus;
#[tokio::test]
async fn output_survives_reap_before_first_read() {
let mut session = Session::spawn("/bin/echo", &["hello"])
.await
.expect("spawn should succeed");
tokio::time::sleep(Duration::from_millis(50)).await;
while session.is_running() {
tokio::time::sleep(Duration::from_millis(5)).await;
}
let m = session
.expect_timeout("hello", Duration::from_secs(5))
.await
.expect("final output must survive a reap-before-read on macOS");
assert!(m.matched.contains("hello"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn fast_exit_output_survives_reap_looped() {
for i in 0..100 {
let mut session = Session::spawn("/bin/echo", &["hello"])
.await
.expect("spawn should succeed");
tokio::time::sleep(Duration::from_millis(5)).await;
while session.is_running() {
tokio::time::sleep(Duration::from_millis(2)).await;
}
let m = session
.expect_timeout("hello", Duration::from_secs(5))
.await
.unwrap_or_else(|e| panic!("iter {i}: output lost after reap: {e:?}"));
assert!(m.matched.contains("hello"), "iter {i}");
}
}
#[tokio::test]
async fn eof_drain_preserves_final_chunk() {
let mut session = Session::spawn("/bin/sh", &["-c", "printf 'first\\nlast-line'; exit 0"])
.await
.expect("spawn should succeed");
let m = session
.expect_timeout("last-line", Duration::from_secs(5))
.await
.expect("final chunk must not be lost on EOF");
assert!(m.matched.contains("last-line"));
}
#[tokio::test]
async fn wait_reports_real_exit_status() {
let mut session = Session::spawn("/bin/sh", &["-c", "exit 7"])
.await
.expect("spawn should succeed");
let status = session
.wait_timeout(Duration::from_secs(5))
.await
.expect("wait should return promptly, not hang");
assert_eq!(status, ProcessExitStatus::Exited(7), "got {status:?}");
}
#[tokio::test]
async fn expect_eof_resolves_promptly() {
let mut session = Session::spawn("/bin/sh", &["-c", "printf done; exit 0"])
.await
.expect("spawn should succeed");
session
.expect_timeout("done", Duration::from_secs(5))
.await
.expect("should see child output");
session
.expect_eof_timeout(Duration::from_secs(5))
.await
.expect("expect_eof should resolve at child exit");
}
#[tokio::test]
async fn child_controlling_terminal_is_pts() {
let mut session = Session::spawn("/bin/sh", &["-c", "tty; exit 0"])
.await
.expect("spawn should succeed");
let out = session
.expect_eof_timeout(Duration::from_secs(5))
.await
.expect("expect_eof");
let text = out.before;
let names_pts = text.contains("/dev/ttys") || text.contains("/dev/pts/");
assert!(
names_pts,
"child should have a controlling pts (/dev/ttys* or /dev/pts/*), got: {text:?}"
);
assert!(
!text.contains("not a tty"),
"child unexpectedly had no controlling terminal: {text:?}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 8)]
#[ignore = "macOS PTY-cap stress; manual only: cargo test -- --ignored"]
async fn pty_allocation_stress_under_concurrency() {
const BATCH: usize = 250;
const WAVES: usize = 4;
for wave in 0..WAVES {
let mut handles = Vec::with_capacity(BATCH);
for _ in 0..BATCH {
handles.push(tokio::spawn(async {
let mut s = Session::spawn("/bin/sh", &["-c", "exit 0"]).await?;
s.wait_timeout(Duration::from_secs(10)).await.ok();
Ok::<(), rust_expect::ExpectError>(())
}));
}
for h in handles {
h.await
.expect("spawn task panicked")
.unwrap_or_else(|e| panic!("wave {wave}: every spawn should allocate a PTY: {e}"));
}
}
}
#[tokio::test]
async fn concurrent_spawns_all_allocate() {
let mut handles = Vec::new();
for _ in 0..32 {
handles.push(tokio::spawn(async {
let mut s = Session::spawn("/bin/sh", &["-c", "printf ok; exit 0"])
.await
.expect("concurrent spawn should allocate a PTY");
s.expect_timeout("ok", Duration::from_secs(5))
.await
.expect("child output");
s.wait_timeout(Duration::from_secs(5)).await.ok();
}));
}
for h in handles {
h.await.expect("task should not panic");
}
}