use std::time::{Duration, Instant};
use processkit::Command;
use crate::common::*;
#[tokio::test]
#[ignore = "exercises the real spawn path (creates a process group)"]
async fn missing_working_directory_errors_clearly() {
let err = Command::new("echo")
.arg("hi")
.current_dir("does-not-exist-processkit-xyz")
.output_string()
.await
.expect_err("a missing cwd must error");
assert!(
!err.is_not_found(),
"a missing cwd is not a missing program: {err:?}"
);
assert!(
format!("{err}").contains("working directory does not exist"),
"message should name the cwd: {err}"
);
}
#[tokio::test]
#[ignore = "exercises the real spawn path (creates a process group)"]
async fn working_directory_that_is_a_file_errors_as_not_a_directory() {
let err = Command::new("echo")
.arg("hi")
.current_dir("Cargo.toml")
.output_string()
.await
.expect_err("a file as cwd must error");
assert!(!err.is_not_found(), "a file is found, not missing: {err:?}");
assert!(
format!("{err}").contains("is not a directory"),
"message should say not-a-directory: {err}"
);
}
#[tokio::test]
#[ignore = "exercises the real spawn path (creates a process group)"]
async fn missing_program_surfaces_not_found_with_searched_path() {
let err = Command::new("processkit-definitely-not-installed-424242")
.output_string()
.await
.expect_err("an unknown program must error");
assert!(
matches!(err, processkit::Error::NotFound { .. }),
"expected Error::NotFound, got {err:?}"
);
assert!(err.is_not_found(), "is_not_found() must be true: {err:?}");
let msg = err.to_string();
assert!(
msg.contains("not found on PATH"),
"message should name the PATH search: {msg}"
);
}
#[cfg(windows)]
#[tokio::test]
#[ignore = "exercises the real spawn path; writes a temp exe beside the test binary"]
async fn bare_name_in_the_application_directory_is_not_falsely_not_found() {
let app_dir = std::env::current_exe()
.expect("current_exe")
.parent()
.expect("exe has a parent dir")
.to_path_buf();
let unique = "pk_appdir_regression_probe_77321";
let dst = app_dir.join(format!("{unique}.exe"));
std::fs::copy(r"C:\Windows\System32\where.exe", &dst).expect("copy where.exe beside test exe");
let result = Command::new(unique).arg("/?").output_string().await;
let _ = std::fs::remove_file(&dst);
let result = result.expect("a program in the application dir must spawn, not error");
assert!(
result.is_success(),
"`where /?` exits 0; got {:?}",
result.code()
);
}
#[tokio::test]
#[ignore = "spawns a real subprocess"]
async fn output_string_captures_stdout() {
let result = two_line_echo().output_string().await.expect("run echo");
assert!(result.is_success(), "exit was {:?}", result.code());
assert!(
result.stdout().contains("first"),
"stdout: {:?}",
result.stdout()
);
assert!(
result.stdout().contains("second"),
"stdout: {:?}",
result.stdout()
);
}
#[tokio::test]
#[ignore = "spawns a real subprocess"]
async fn command_checked_and_run_unit_verbs() {
let result = two_line_echo()
.checked()
.await
.expect("checked on a zero exit");
assert!(result.is_success(), "exit was {:?}", result.code());
assert!(
result.stdout().contains("first"),
"stdout: {:?}",
result.stdout()
);
two_line_echo()
.run_unit()
.await
.expect("run_unit ok on a zero exit");
}
#[tokio::test]
#[ignore = "spawns a real subprocess"]
async fn stdout_null_makes_capture_verbs_error_but_discard_verbs_run() {
let err = two_line_echo()
.stdout(processkit::StdioMode::Null)
.output_string()
.await
.expect_err("output_string on a non-piped stdout must error (D5)");
match err {
processkit::Error::Io(e) => assert_eq!(e.kind(), std::io::ErrorKind::InvalidInput),
other => panic!("expected Io(InvalidInput), got {other:?}"),
}
let outcome = two_line_echo()
.stdout(processkit::StdioMode::Null)
.start()
.await
.expect("start")
.wait()
.await
.expect("wait() runs a stdout(Null) command fine");
assert_eq!(
outcome,
processkit::Outcome::Exited(0),
"the command still ran"
);
}
#[tokio::test]
#[ignore = "spawns a real subprocess"]
async fn stdout_tee_writes_to_the_sink_while_capturing() {
#[derive(Clone)]
struct SharedSink(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
impl tokio::io::AsyncWrite for SharedSink {
fn poll_write(
self: std::pin::Pin<&mut Self>,
_cx: &mut std::task::Context<'_>,
buf: &[u8],
) -> std::task::Poll<std::io::Result<usize>> {
self.0.lock().expect("sink mutex").extend_from_slice(buf);
std::task::Poll::Ready(Ok(buf.len()))
}
fn poll_flush(
self: std::pin::Pin<&mut Self>,
_cx: &mut std::task::Context<'_>,
) -> std::task::Poll<std::io::Result<()>> {
std::task::Poll::Ready(Ok(()))
}
fn poll_shutdown(
self: std::pin::Pin<&mut Self>,
_cx: &mut std::task::Context<'_>,
) -> std::task::Poll<std::io::Result<()>> {
std::task::Poll::Ready(Ok(()))
}
}
let sink = SharedSink(std::sync::Arc::new(std::sync::Mutex::new(Vec::new())));
let result = two_line_echo()
.stdout_tee(sink.clone())
.output_string()
.await
.expect("a stdout_tee run completes");
assert!(
result.stdout().contains("first") && result.stdout().contains("second"),
"capture must still see both lines: {:?}",
result.stdout()
);
let teed = String::from_utf8(sink.0.lock().expect("sink mutex").clone()).expect("tee is utf-8");
assert!(
teed.contains("first") && teed.contains("second"),
"tee sink must receive both lines: {teed:?}"
);
}
#[tokio::test]
#[ignore = "spawns a real subprocess"]
async fn run_trims_and_requires_success() {
let out = Command::new("cargo")
.arg("--version")
.run()
.await
.expect("cargo --version");
assert!(out.to_lowercase().contains("cargo"), "unexpected: {out}");
assert_eq!(out, out.trim_end());
}
#[tokio::test]
#[ignore = "spawns a real subprocess"]
async fn output_bytes_returns_raw_stdout() {
let result = two_line_echo().output_bytes().await.expect("run echo");
assert!(result.is_success());
let text = String::from_utf8_lossy(result.stdout());
assert!(text.contains("first") && text.contains("second"));
}
#[tokio::test]
#[ignore = "spawns a real subprocess fed stdin it never reads"]
async fn early_exiting_child_does_not_fail_a_large_stdin_feed() {
let big = "x".repeat(1024 * 1024);
let exits_zero = if cfg!(windows) {
Command::new("cmd").args(["/c", "exit", "0"])
} else {
Command::new("sh").args(["-c", "exit 0"])
};
let result = exits_zero
.stdin(processkit::Stdin::from_string(big))
.output_string()
.await
.expect("the stdin writer's broken pipe must not surface as Err");
assert!(result.is_success(), "result: {result:?}");
}
#[tokio::test]
#[ignore = "spawns a real stdin-reading subprocess on the bulk path"]
async fn untaken_keep_stdin_open_pipe_is_closed_by_bulk_verbs() {
let reads_stdin = if cfg!(windows) {
Command::new("cmd").args(["/c", "sort"])
} else {
Command::new("cat")
};
let start = std::time::Instant::now();
let result = reads_stdin
.keep_stdin_open()
.timeout(std::time::Duration::from_secs(20)) .output_string()
.await
.expect("run completes");
assert!(result.is_success(), "result: {result:?}");
assert!(
!result.timed_out(),
"the child must see EOF, not hang to the deadline: {result:?}"
);
assert!(
start.elapsed() < std::time::Duration::from_secs(15),
"bulk verb did not close the untaken stdin pipe (took {:?})",
start.elapsed()
);
}
#[tokio::test]
#[ignore = "spawns a real subprocess fed a failing stdin source"]
async fn failing_stdin_source_surfaces_as_error_stdin_on_a_successful_run() {
use processkit::Error;
use std::pin::Pin;
use std::task::{Context, Poll};
struct FailingReader;
impl tokio::io::AsyncRead for FailingReader {
fn poll_read(
self: Pin<&mut Self>,
_cx: &mut Context<'_>,
_buf: &mut tokio::io::ReadBuf<'_>,
) -> Poll<std::io::Result<()>> {
Poll::Ready(Err(std::io::Error::other("stdin source failed")))
}
}
let reads_stdin = if cfg!(windows) {
Command::new("cmd").args(["/c", "sort"])
} else {
Command::new("cat")
};
let err = reads_stdin
.stdin(processkit::Stdin::from_reader(FailingReader))
.output_string()
.await
.expect_err("a failed stdin writer on a successful run must surface as Error::Stdin");
assert!(matches!(err, Error::Stdin { .. }), "got: {err:?}");
}
#[tokio::test]
#[ignore = "spawns a real subprocess fed a panicking stdin source"]
async fn panicking_stdin_source_surfaces_as_error_stdin_not_silent_success() {
use processkit::Error;
use std::pin::Pin;
use std::task::{Context, Poll};
struct PanickingReader;
impl tokio::io::AsyncRead for PanickingReader {
fn poll_read(
self: Pin<&mut Self>,
_cx: &mut Context<'_>,
_buf: &mut tokio::io::ReadBuf<'_>,
) -> Poll<std::io::Result<()>> {
panic!("stdin source panicked");
}
}
let reads_stdin = if cfg!(windows) {
Command::new("cmd").args(["/c", "sort"])
} else {
Command::new("cat")
};
let err = reads_stdin
.stdin(processkit::Stdin::from_reader(PanickingReader))
.output_string()
.await
.expect_err("a panicking stdin writer on a successful run must surface as Error::Stdin");
assert!(matches!(err, Error::Stdin { .. }), "got: {err:?}");
}
#[tokio::test]
#[ignore = "spawns a real subprocess that exits non-zero while its stdin source fails"]
async fn nonzero_exit_wins_over_a_failing_stdin_source() {
use std::pin::Pin;
use std::task::{Context, Poll};
struct OneLineThenFail(bool);
impl tokio::io::AsyncRead for OneLineThenFail {
fn poll_read(
mut self: Pin<&mut Self>,
_cx: &mut Context<'_>,
buf: &mut tokio::io::ReadBuf<'_>,
) -> Poll<std::io::Result<()>> {
if self.0 {
Poll::Ready(Err(std::io::Error::other("stdin source failed")))
} else {
self.0 = true;
buf.put_slice(b"x\n");
Poll::Ready(Ok(()))
}
}
}
let nonzero = if cfg!(windows) {
Command::new("cmd").args(["/c", "findstr", "zzz-no-match"])
} else {
Command::new("grep").arg("zzz-no-match")
};
let result = nonzero
.stdin(processkit::Stdin::from_reader(OneLineThenFail(false)))
.output_string()
.await
.expect("a non-zero exit is a result on output_string, not Err(Stdin)");
assert_eq!(
result.code(),
Some(1),
"the real exit code is preserved: {result:?}"
);
assert!(!result.is_success());
}
#[tokio::test]
#[ignore = "spawns a real subprocess echoing 256 KiB through both pipes"]
async fn large_stdin_and_large_output_do_not_deadlock() {
let line = "0123456789abcdef".repeat(64); let big = format!("{line}\n").repeat(256); let echo_all = if cfg!(windows) {
Command::new("cmd").args(["/c", "findstr", "^^"])
} else {
Command::new("cat")
};
let result = echo_all
.stdin(processkit::Stdin::from_string(big.clone()))
.timeout(std::time::Duration::from_secs(60)) .output_string()
.await
.expect("echo run");
assert!(result.is_success(), "result: {result:?}");
assert_eq!(
result.stdout().lines().count(),
256,
"every line must round-trip"
);
}
#[tokio::test]
#[ignore = "spawns a real subprocess"]
async fn stdin_is_fed_to_the_child() {
let result = if cfg!(windows) {
Command::new("cmd")
.args(["/c", "sort"])
.stdin(processkit::Stdin::from_string("delta\nalpha\n"))
.output_string()
.await
.expect("run sort")
} else {
Command::new("cat")
.stdin(processkit::Stdin::from_string("hello stdin\n"))
.output_string()
.await
.expect("run cat")
};
assert!(result.is_success());
let expected = if cfg!(windows) {
"alpha"
} else {
"hello stdin"
};
assert!(
result.stdout().contains(expected),
"stdout: {:?}",
result.stdout()
);
}
#[tokio::test]
#[ignore = "spawns a real subprocess and waits for the timeout"]
async fn timeout_kills_and_flags() {
let result = sleeper()
.timeout(Duration::from_millis(300))
.output_string()
.await
.expect("timed run still returns a result");
assert!(result.timed_out(), "should be flagged as timed out");
assert!(!result.is_success());
}
#[tokio::test]
#[ignore = "spawns a real subprocess and waits for the timeout"]
async fn exit_code_surfaces_timeout_as_error() {
let err = sleeper()
.timeout(Duration::from_millis(300))
.exit_code()
.await
.expect_err("a timed-out run has no meaningful exit code");
assert!(
matches!(err, processkit::Error::Timeout { .. }),
"expected Error::Timeout, got {err:?}"
);
}
#[tokio::test]
#[ignore = "spawns a real subprocess that stalls; must not hang past the timeout"]
async fn first_line_honors_timeout_instead_of_hanging() {
let silent = if cfg!(windows) {
Command::new("powershell").args(["-NoProfile", "-Command", "Start-Sleep -Seconds 30"])
} else {
Command::new("sleep").arg("30")
};
let start = Instant::now();
let err = silent
.timeout(Duration::from_millis(300))
.first_line(|_| true)
.await
.expect_err("a stalled run should time out, not return Ok(None)");
assert!(
matches!(err, processkit::Error::Timeout { .. }),
"expected Error::Timeout, got {err:?}"
);
assert!(
start.elapsed() < Duration::from_secs(15),
"first_line did not honor the timeout (took {:?})",
start.elapsed()
);
}
#[tokio::test]
#[ignore = "spawns a real stdin-reading subprocess via first_line"]
async fn first_line_closes_an_untaken_keep_stdin_open_pipe() {
let filter = if cfg!(windows) {
Command::new("cmd").args(["/c", "sort"])
} else {
Command::new("cat")
};
let start = Instant::now();
let found = filter
.keep_stdin_open()
.first_line(|_| true)
.await
.expect("first_line completes");
assert_eq!(
found, None,
"an empty stdin filter emits nothing: {found:?}"
);
assert!(
start.elapsed() < Duration::from_secs(15),
"first_line hung on an untaken stdin pipe (took {:?})",
start.elapsed()
);
}
#[tokio::test]
#[ignore = "spawns real subprocesses"]
async fn probe_reads_real_exit_codes() {
let exits = |code: i32| {
if cfg!(windows) {
Command::new("cmd").args(["/c", "exit", &code.to_string()])
} else {
Command::new("sh").args(["-c", &format!("exit {code}")])
}
};
assert!(exits(0).probe().await.expect("exit 0 is a clean true"));
assert!(!exits(1).probe().await.expect("exit 1 is a clean false"));
assert!(
exits(2).probe().await.is_err(),
"any code other than 0/1 must be an error, not a silent bool"
);
}
#[tokio::test]
#[ignore = "spawns real subprocesses; ok_codes through the real verbs"]
async fn ok_codes_widens_success_through_output_string_and_bytes() {
let s = failing_exit(1)
.ok_codes([0, 1])
.output_string()
.await
.expect("run completes");
assert!(
s.is_success(),
"exit 1 is success under ok_codes([0,1]) (output_string)"
);
assert_eq!(s.code(), Some(1), "the raw code is still reported");
let b = failing_exit(1)
.ok_codes([0, 1])
.output_bytes()
.await
.expect("run completes");
assert!(
b.is_success(),
"exit 1 is success under ok_codes([0,1]) (output_bytes)"
);
assert_eq!(b.code(), Some(1));
let outside = failing_exit(2)
.ok_codes([0, 1])
.output_string()
.await
.expect("run completes");
assert!(
!outside.is_success(),
"exit 2 is outside ok_codes([0,1]) — still a failure"
);
}
#[tokio::test]
#[ignore = "spawns a real subprocess that overflows a fail-loud buffer"]
async fn fail_loud_buffer_surfaces_output_too_large() {
use processkit::OutputBufferPolicy;
let err = five_lines()
.output_buffer(OutputBufferPolicy::fail_loud(2))
.output_string()
.await
.expect_err("5 lines over a 2-line fail-loud cap must error");
match err {
processkit::Error::OutputTooLarge {
line_limit,
total_lines,
..
} => {
assert_eq!(
line_limit,
Some(2),
"the configured line cap is reported: {err:?}"
);
assert!(total_lines >= 5, "every line is counted: {total_lines}");
}
other => panic!("expected Error::OutputTooLarge, got {other:?}"),
}
let ok = two_line_echo()
.output_buffer(OutputBufferPolicy::fail_loud(10))
.output_string()
.await
.expect("2 lines under a 10-line cap is fine");
assert!(ok.is_success());
}
#[tokio::test]
#[ignore = "spawns a real subprocess whose output a bounded drop-policy truncates"]
async fn checking_verbs_reject_truncated_output_e2e() {
use processkit::OutputBufferPolicy;
let lenient = five_lines()
.output_buffer(OutputBufferPolicy::bounded(2))
.output_string()
.await
.expect("output_string stays lenient under a bounded drop policy");
assert!(lenient.is_success());
assert!(lenient.truncated(), "the bounded policy dropped lines");
let err = five_lines()
.output_buffer(OutputBufferPolicy::bounded(2))
.run()
.await
.expect_err("run must reject truncated stdout (B12)");
assert!(
matches!(
err,
processkit::Error::OutputTooLarge {
line_limit: Some(2),
..
}
),
"expected OutputTooLarge with the configured cap, got {err:?}"
);
}
#[cfg(windows)]
#[tokio::test]
#[ignore = "Windows has no signal tier: timeout_grace must degrade to a prompt atomic kill"]
async fn graceful_timeout_degrades_to_a_prompt_kill_on_windows() {
let start = Instant::now();
let result = sleeper() .timeout(Duration::from_millis(500))
.timeout_grace(Duration::from_secs(30))
.output_string()
.await
.expect("run completes");
assert!(result.timed_out(), "the deadline fired");
assert!(
start.elapsed() < Duration::from_secs(10),
"Windows must hard-kill promptly at the deadline, not wait the 30s grace (took {:?})",
start.elapsed()
);
}
#[tokio::test]
#[ignore = "spawns a real subprocess via the top-level free functions"]
async fn top_level_run_and_output() {
let v = processkit::run("cargo", ["--version"])
.await
.expect("run cargo --version");
assert!(v.to_lowercase().contains("cargo"), "unexpected: {v}");
let result = processkit::output_string("cargo", ["--version"])
.await
.expect("output cargo --version");
assert!(result.is_success());
assert!(result.stdout().to_lowercase().contains("cargo"));
}
#[tokio::test]
#[ignore = "spawns a real subprocess"]
async fn first_line_returns_none_when_the_stream_ends_without_a_match() {
let found = tokio::time::timeout(
Duration::from_secs(15),
two_line_echo().first_line(|l| l.contains("never-printed")),
)
.await
.expect("first_line must end when stdout closes, not hang")
.expect("run succeeds");
assert_eq!(found, None);
}