179 lines
6.3 KiB
Rust
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(
|
|
"e_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;
|
|
}
|
|
}
|
|
}
|