Files
2026-10-15 02:24:15 -03:00

179 lines
6.3 KiB
Rust

//! Shared helpers for the ACP integration tests.
//!
//! Each integration binary uses a different subset, so unused helpers in the
//! shared module are expected (test code only).
#![allow(dead_code)]
use std::path::PathBuf;
use std::sync::mpsc::Receiver;
use std::time::{Duration, Instant};
use notescraft_acp::{AgentCommand, AgentProcess, Event};
/// Locates the built `stub_agent` example next to the test binary.
#[must_use]
pub fn stub_path() -> PathBuf {
let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("/dev/null/nowhere"));
match exe.parent() {
Some(dir) => dir.join("../examples/stub_agent"),
None => PathBuf::from("stub_agent"),
}
}
/// Quotes a path for the shell-words parser (double quotes, POSIX rules).
#[must_use]
pub fn quote_for_command_line(path: &str) -> String {
let escaped = path.replace('\\', "\\\\").replace('"', "\\\"");
format!("\"{escaped}\"")
}
/// Builds an `AgentCommand` running the stub agent in `mode`.
#[must_use]
pub fn stub_command(mode: &str) -> AgentCommand {
AgentCommand::new(
&quote_for_command_line(&stub_path().display().to_string()),
std::env::temp_dir(),
vec![("STUB_AGENT_MODE".to_string(), mode.to_string()), ("NO_COLOR".to_string(), "1".to_string())],
)
.expect("stub agent command line must be valid")
}
/// Spawns the stub agent; panics (test only) when spawning fails.
#[must_use]
pub fn spawn_stub(mode: &str) -> AgentProcess {
match AgentProcess::spawn(stub_command(mode)) {
Ok(process) => process,
Err(err) => panic!("spawn stub agent failed: {err}"),
}
}
/// Waits for the next event, panicking (test only) after `timeout`.
#[must_use]
pub fn next_event(events: &Receiver<Event>, timeout: Duration) -> Event {
let deadline = Instant::now() + timeout;
loop {
match events.recv_timeout(Duration::from_millis(200)) {
Ok(event) => return event,
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
panic!("event channel disconnected before the expected event arrived")
}
}
assert!(Instant::now() < deadline, "timed out waiting for the next event");
}
}
/// Collects events until `predicate` matches, with a hard deadline.
///
/// Includes every event seen (also the matching one).
#[must_use]
pub fn collect_until(events: &Receiver<Event>, timeout: Duration, mut predicate: impl FnMut(&Event) -> bool) -> Vec<Event> {
let deadline = Instant::now() + timeout;
let mut collected = Vec::new();
loop {
match events.recv_timeout(Duration::from_millis(100)) {
Ok(event) => {
let done = predicate(&event);
collected.push(event);
if done {
return collected;
}
}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
panic!("event channel disconnected while collecting")
}
}
if Instant::now() >= deadline {
return collected;
}
}
}
/// Completes the handshake and returns the process plus session id.
///
/// # Panics
/// Panics (test only) when the handshake or session creation fails.
pub fn handshake(process: &AgentProcess) -> String {
match process.initialize_blocking(Duration::from_secs(10)) {
Ok(_) => {}
Err(err) => panic!("initialize failed: {err}"),
}
let vault = std::env::temp_dir();
match process.new_session_blocking(&vault, &[], Duration::from_secs(10)) {
Ok(session_id) => session_id,
Err(err) => panic!("session/new failed: {err}"),
}
}
/// Waits until the agent reports its pid on stderr and returns it.
pub fn stub_pid(process: &AgentProcess) -> u32 {
let deadline = Instant::now() + Duration::from_secs(10);
loop {
if let Some(pid) = process.stderr_tail().lines().find_map(parse_pid_line) {
return pid;
}
assert!(Instant::now() < deadline, "agent never printed its pid");
std::thread::sleep(Duration::from_millis(20));
}
}
/// Extracts the pid from a `PID <n>` stderr line.
fn parse_pid_line(line: &str) -> Option<u32> {
line.strip_prefix("PID ")?.trim().parse::<u32>().ok()
}
/// True while the process with `pid` still exists and is not a zombie.
///
/// Uses `ps` (no extra crate dependency); a reaped child is simply gone.
pub fn process_alive(pid: u32) -> bool {
let output = std::process::Command::new("ps").args(["-p", &pid.to_string(), "-o", "stat="]).output();
match output {
Ok(output) if output.status.success() => String::from_utf8_lossy(&output.stdout).split_whitespace().next().is_some_and(|state| !state.starts_with('Z')),
_ => false,
}
}
/// Waits for the process to disappear from the process table.
pub fn wait_until_gone(pid: u32, timeout: Duration) -> bool {
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
if !process_alive(pid) {
return true;
}
std::thread::sleep(Duration::from_millis(20));
}
!process_alive(pid)
}
/// Collects every event until the turn ends, answering permissions on the
/// way (so the stub can finish the turn).
pub fn collect_turn(process: &AgentProcess, allow: Option<&str>, timeout: Duration) -> Vec<Event> {
let mut collected = Vec::new();
let deadline = Instant::now() + timeout;
loop {
match process.events().recv_timeout(Duration::from_millis(100)) {
Ok(event) => {
let done = matches!(event, Event::PromptDone { .. });
if let Event::PermissionRequest { request_id, .. } = &event
&& let Err(err) = process.answer_permission(request_id, allow)
{
panic!("answer_permission failed: {err}");
}
collected.push(event);
if done {
return collected;
}
}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
panic!("event channel disconnected while collecting the turn")
}
}
if Instant::now() >= deadline {
return collected;
}
}
}