use std::time::Duration;
use processkit::{Command, JobRunner, ProcessRunner};
use crate::common::{completes_within, poll_until};
fn isatty_probe() -> Command {
if cfg!(windows) {
Command::new("powershell").args([
"-NoProfile",
"-Command",
"if ([Console]::IsInputRedirected) { 'PIPE' } else { 'TTY' }",
])
} else {
Command::new("sh").args(["-c", "if [ -t 0 ]; then echo TTY; else echo PIPE; fi"])
}
}
fn terminal_identity_probe() -> Command {
if cfg!(windows) {
Command::new("powershell").args([
"-NoProfile",
"-NonInteractive",
"-Command",
"Write-Output ('COLUMNS=' + $env:COLUMNS); \
Write-Output ('LINES=' + $env:LINES)",
])
} else {
Command::new("sh").args([
"-c",
"printf 'TERM=%s\\nCOLUMNS=%s\\nLINES=%s\\n' \"$TERM\" \"$COLUMNS\" \"$LINES\"",
])
}
}
fn prompt_responder() -> Command {
if cfg!(windows) {
Command::new("powershell").args([
"-NoProfile",
"-Command",
"$l = [Console]::In.ReadLine(); Write-Output \"reply:$l\"",
])
} else {
Command::new("sh").args(["-c", "read line; printf 'reply:%s\\n' \"$line\""])
}
}
fn unterminated_passphrase_prompt() -> Command {
if cfg!(windows) {
Command::new("powershell").args([
"-NoProfile",
"-Command",
"[Console]::Error.Write('passphrase: '); \
$l = [Console]::In.ReadLine(); [Console]::Out.WriteLine(\"unlocked:$l\")",
])
} else {
Command::new("sh").args([
"-c",
"printf 'passphrase: ' >&2; read line; printf 'unlocked:%s\\n' \"$line\"",
])
}
}
fn sleeper() -> Command {
if cfg!(windows) {
Command::new("cmd").args(["/c", "ping", "-n", "30", "127.0.0.1"])
} else {
Command::new("sleep").arg("30")
}
}
#[cfg(unix)]
fn pid_alive(pid: u32) -> bool {
unsafe { libc::kill(pid as i32, 0) == 0 }
}
#[cfg(windows)]
fn pid_alive(pid: u32) -> bool {
crate::common::windows_pid_alive(pid)
}
#[tokio::test]
#[ignore = "spawns a real pseudo-terminal"]
async fn pty_child_sees_a_tty() {
let out = completes_within(
Duration::from_secs(20),
"pty isatty run",
JobRunner::new().output_string(&isatty_probe().use_pty()),
)
.await
.expect("pty run");
assert!(
out.stdout().contains("TTY"),
"an isatty child must see a terminal under PTY, got {:?}",
out.stdout()
);
let plain = completes_within(
Duration::from_secs(20),
"pipe isatty run",
JobRunner::new().output_string(&isatty_probe()),
)
.await
.expect("pipe run");
assert!(
plain.stdout().contains("PIPE"),
"without PTY the child sees a pipe, got {:?}",
plain.stdout()
);
}
#[tokio::test]
#[ignore = "spawns a real pseudo-terminal"]
async fn pty_child_receives_terminal_identity_matching_its_initial_size() {
let default = completes_within(
Duration::from_secs(20),
"default PTY terminal identity",
JobRunner::new().output_string(&terminal_identity_probe().use_pty()),
)
.await
.expect("default-size pty run");
assert!(
default.stdout().contains("COLUMNS=80") && default.stdout().contains("LINES=24"),
"default identity must match the default 80x24 PTY: {:?}",
default.stdout()
);
#[cfg(unix)]
assert!(
default.stdout().contains("TERM=xterm-256color"),
"Unix PTY identity must advertise xterm-256color: {:?}",
default.stdout()
);
let sized = completes_within(
Duration::from_secs(20),
"custom-size PTY terminal identity",
JobRunner::new().output_string(&terminal_identity_probe().use_pty().pty_size(101, 37)),
)
.await
.expect("custom-size pty run");
assert!(
sized.stdout().contains("COLUMNS=101") && sized.stdout().contains("LINES=37"),
"identity must match pty_size(101, 37): {:?}",
sized.stdout()
);
}
#[cfg(windows)]
#[tokio::test]
#[ignore = "spawns a real pseudo-terminal"]
async fn pty_unused_stdin_does_not_close_the_conpty_session() {
let child = Command::new("powershell").args([
"-NoProfile",
"-NonInteractive",
"-Command",
"Start-Sleep -Milliseconds 750; Write-Output READY",
]);
let result = completes_within(
Duration::from_secs(20),
"ConPTY child with unused stdin",
JobRunner::new().output_string(&child.use_pty()),
)
.await
.expect("pty run");
assert_eq!(
result.code(),
Some(0),
"closing the public stdin writer must not close the ConPTY session: {result:?}"
);
assert!(
result.stdout().contains("READY"),
"the child must reach its command body: {:?}",
result.stdout()
);
}
#[cfg(windows)]
#[tokio::test]
#[ignore = "spawns a real pseudo-terminal"]
async fn pty_finish_sends_console_eof_without_closing_the_session() {
let child = Command::new("powershell").args([
"-NoProfile",
"-NonInteractive",
"-Command",
"$count = 0; while ($null -ne [Console]::In.ReadLine()) { $count++ }; Write-Output \"lines:$count\"",
]);
let mut process = JobRunner::new()
.start(&child.use_pty().keep_stdin_open())
.await
.expect("start pty child");
let mut stdin = process.take_stdin().expect("pty stdin writer");
stdin.write_line("one").await.expect("write input");
stdin.finish().await.expect("finish input");
let result = completes_within(
Duration::from_secs(20),
"ConPTY console EOF",
process.output_string(),
)
.await
.expect("output");
assert_eq!(
result.code(),
Some(0),
"console EOF must be graceful: {result:?}"
);
assert!(
result.stdout().contains("lines:1"),
"the child must consume one line and then observe EOF: {:?}",
result.stdout()
);
}
#[tokio::test]
#[ignore = "spawns a real pseudo-terminal"]
async fn pty_prompt_response_round_trips_over_the_master() {
let mut proc = JobRunner::new()
.start(&prompt_responder().use_pty().keep_stdin_open())
.await
.expect("start pty child");
let mut stdin = proc.take_stdin().expect("pty stdin writer");
stdin.write_line("hello").await.expect("write the prompt");
drop(stdin);
let result = completes_within(
Duration::from_secs(20),
"pty prompt/response",
proc.output_string(),
)
.await
.expect("output");
assert!(
result.stdout().contains("reply:hello"),
"the master must carry the child's reply, got {:?}",
result.stdout()
);
}
#[tokio::test]
#[ignore = "spawns a real pseudo-terminal"]
async fn pty_wait_for_output_catches_an_unterminated_prompt_then_answers() {
let mut proc = JobRunner::new()
.start(&unterminated_passphrase_prompt().use_pty().keep_stdin_open())
.await
.expect("start pty child");
let prompt = completes_within(
Duration::from_secs(20),
"pty un-terminated prompt",
proc.wait_for_output(|tail| tail.contains("passphrase:"), Duration::from_secs(15)),
)
.await
.expect("the un-terminated prompt tail must match");
assert!(prompt.contains("passphrase:"), "got {prompt:?}");
proc.take_stdin()
.expect("pty stdin writer")
.write_line("open-sesame")
.await
.expect("answer the prompt");
let result = completes_within(
Duration::from_secs(20),
"pty wait_for_output continuation",
proc.output_string(),
)
.await
.expect("output");
assert!(
result.stdout().contains("unlocked:open-sesame"),
"the child must have consumed the answer and continued, got {:?}",
result.stdout()
);
}
#[cfg(unix)]
#[tokio::test]
#[ignore = "spawns a real pseudo-terminal"]
async fn pty_disables_echo_so_a_written_secret_is_not_echoed() {
let child = Command::new("sh").args(["-c", "read secret; echo done"]);
let mut proc = JobRunner::new()
.start(&child.use_pty().keep_stdin_open())
.await
.expect("start pty child");
let mut stdin = proc.take_stdin().expect("pty stdin writer");
stdin
.write_line("s3cr3t-passphrase")
.await
.expect("write the secret");
drop(stdin);
let result = completes_within(
Duration::from_secs(20),
"pty echo-off",
proc.output_string(),
)
.await
.expect("output");
assert!(
!result.stdout().contains("s3cr3t-passphrase"),
"echo must be disabled — the secret must not appear in the merged output, got {:?}",
result.stdout()
);
assert!(
result.stdout().contains("done"),
"the child still ran to completion, got {:?}",
result.stdout()
);
}
#[cfg(unix)]
#[tokio::test]
#[ignore = "spawns a real pseudo-terminal"]
async fn pty_size_sets_the_initial_window_and_resize_pty_delivers_a_new_one() {
use tokio_stream::StreamExt;
let child = Command::new("sh").args([
"-c",
"unset COLUMNS LINES; trap 'sleep 0.1; stty size' WINCH; stty size; while :; do sleep 0.2; done",
]);
let mut proc = JobRunner::new()
.start(&child.use_pty().pty_size(100, 30))
.await
.expect("start pty child");
let mut lines = proc.stdout_lines().expect("merged pty output stream");
let first = completes_within(
Duration::from_secs(20),
"pty initial size line",
lines.next(),
)
.await
.expect("an initial size line");
assert_eq!(
first.trim(),
"30 100",
"the child's initial terminal size must reflect pty_size(100, 30)"
);
let mut second = None;
for retry_attempt in 0..3 {
if retry_attempt > 0 {
tokio::time::sleep(Duration::from_millis(100)).await;
proc.resize_pty(119, 39)
.expect("live resize nudge on a running pty");
}
proc.resize_pty(120, 40)
.expect("live resize on a running pty");
let found = tokio::time::timeout(Duration::from_secs(5), async {
while let Some(line) = lines.next().await {
if line.trim() == "40 120" {
return Some(line);
}
}
None
})
.await;
if let Ok(Some(line)) = found {
second = Some(line);
break;
}
}
let second = second.expect("a post-resize size line (after retries)");
assert_eq!(
second.trim(),
"40 120",
"after resize_pty the child must observe the new terminal size via SIGWINCH"
);
drop(proc);
}
#[tokio::test]
#[ignore = "spawns a real child"]
async fn resize_pty_on_a_real_non_pty_run_is_unsupported() {
let mut proc = JobRunner::new()
.start(&sleeper())
.await
.expect("start plain child");
let err = proc
.resize_pty(120, 40)
.expect_err("a non-PTY run cannot be resized");
assert!(
matches!(err.reason(), processkit::ErrorReason::Unsupported { .. }),
"a non-PTY resize must be Unsupported, got {err:?}"
);
drop(proc);
}
#[tokio::test]
#[ignore = "spawns a real pseudo-terminal"]
async fn pty_child_stays_contained_and_is_reaped_on_drop() {
let proc = JobRunner::new()
.start(&sleeper().use_pty())
.await
.expect("start pty sleeper");
let pid = proc.pid().expect("a live pty child has a pid");
assert!(
proc.kills_tree_on_drop(),
"an own-group PTY handle must tear its tree down on drop"
);
assert!(pid_alive(pid), "the pty child is alive before drop");
drop(proc);
poll_until(
Duration::from_secs(15),
Duration::from_millis(100),
"the dropped PTY child's pid is reaped",
|| !pid_alive(pid),
)
.await;
}
#[tokio::test]
#[ignore = "spawns a real pseudo-terminal"]
async fn pty_events_stream_carries_the_full_lifecycle() {
use processkit::{Outcome, ProcessEvent};
use tokio_stream::StreamExt;
let child = if cfg!(windows) {
Command::new("powershell").args([
"-NoProfile",
"-NonInteractive",
"-Command",
"Write-Output pty-1; Write-Output pty-2",
])
} else {
Command::new("sh").args(["-c", "echo pty-1; echo pty-2"])
};
let mut run = child.use_pty().start().await.expect("start pty events run");
let pid = run.pid();
let mut events = run.events().expect("events");
let collect = async {
let mut names = Vec::new();
let mut started_pid = None;
let mut lines = Vec::new();
let mut exit_outcome = None;
while let Some(ev) = events.next().await {
names.push(ev.name());
match ev {
ProcessEvent::Started { pid } => started_pid = Some(pid),
ProcessEvent::Stdout(l) => lines.push(l.text().to_owned()),
ProcessEvent::Exited(o) => exit_outcome = Some(o),
_ => {}
}
}
(names, started_pid, lines, exit_outcome)
};
let outcome = completes_within(Duration::from_secs(20), "pty events lifecycle run", async {
tokio::join!(collect, run.finish())
})
.await;
let ((names, started_pid, lines, exit_outcome), finished) = outcome;
let finished = finished.expect("finish");
assert_eq!(names.first(), Some(&"started"), "Started leads the stream");
assert_eq!(names.last(), Some(&"exited"), "Exited ends the stream");
assert_eq!(
started_pid,
Some(pid),
"the Started event mirrors the handle's pid"
);
assert!(
!names.contains(&"stderr"),
"a merged PTY stream produces no separate stderr events, got {names:?}"
);
assert!(
lines.iter().any(|l| l.contains("pty-1")) && lines.iter().any(|l| l.contains("pty-2")),
"both stdout lines arrive as events over the master, got {lines:?}"
);
assert_eq!(
exit_outcome,
Some(Outcome::Exited(0)),
"the terminal Exited carries the run's outcome"
);
assert_eq!(finished.outcome, Outcome::Exited(0));
}