#![cfg(unix)]
use std::io::{BufRead, BufReader, Read, Write};
use std::net::TcpStream;
use std::process::{Command, Stdio};
use std::sync::mpsc;
use std::time::{Duration, Instant};
use sysinfo::{ProcessRefreshKind, RefreshKind, System};
use tempfile::tempdir;
const SALVOR_BIN: &str = env!("CARGO_BIN_EXE_salvor");
#[test]
#[ignore = "spawns a real ng serve/esbuild toolchain; wall-clock timing and a node/npm \
toolchain are not safe assumptions for a shared CI runner"]
fn dev_starts_both_processes_proxies_and_kill_reaps_both() {
let dir = tempdir().expect("tempdir");
let store_path = dir.path().join("salvor.db");
let mut child = Command::new(SALVOR_BIN)
.args(["--store", store_path.to_str().unwrap()])
.args(["serve", "--bind", "127.0.0.1:0", "--dev"])
.env("RUST_LOG", "off")
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("spawn salvor serve --dev");
let salvor_pid = child.id();
let stdout = child.stdout.take().expect("piped stdout");
let (api_addr, dev_port) = read_banners(stdout);
assert!(
wait_for_ng_child(salvor_pid, Duration::from_secs(5)).is_some(),
"ng serve should be a live child of pid {salvor_pid}"
);
let dev_addr = format!("127.0.0.1:{dev_port}");
wait_for_port(&dev_addr, Duration::from_secs(60));
let direct = http_get(&api_addr, "/v1/runs");
let proxied = http_get(&dev_addr, "/v1/runs");
assert!(
direct.contains("\"runs\":[]"),
"direct API response should list zero runs: {direct}"
);
assert!(
proxied.contains("\"runs\":[]"),
"the dev server's proxied response should match the direct one: {proxied}"
);
let pid_arg = salvor_pid.to_string();
let kill = Command::new(SALVOR_BIN)
.args(["serve", "--kill", &pid_arg])
.env("RUST_LOG", "off")
.output()
.expect("run salvor serve --kill");
assert!(
kill.status.success(),
"salvor serve --kill exits 0: {kill:?}"
);
let kill_stdout = String::from_utf8_lossy(&kill.stdout);
assert!(
kill_stdout.contains(&format!("killed pid {salvor_pid}")),
"kill output names the pid: {kill_stdout}"
);
let deadline = Instant::now() + Duration::from_secs(10);
loop {
let salvor_exited = child.try_wait().expect("try_wait").is_some();
let ng_gone = find_ng_child(salvor_pid).is_none();
if salvor_exited && ng_gone {
break;
}
assert!(
Instant::now() < deadline,
"salvor (exited={salvor_exited}) and/or ng serve (gone={ng_gone}) \
did not both exit in time"
);
std::thread::sleep(Duration::from_millis(100));
}
assert!(
TcpStream::connect(&api_addr).is_err(),
"port {api_addr} should be free after the kill"
);
}
fn read_banners(stdout: std::process::ChildStdout) -> (String, u16) {
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let mut reader = BufReader::new(stdout);
let mut line = String::new();
loop {
line.clear();
match reader.read_line(&mut line) {
Ok(0) | Err(_) => break,
Ok(_) => {
let _ = tx.send(line.clone());
}
}
}
});
let deadline = Instant::now() + Duration::from_secs(90);
let mut api_addr = None;
let mut dev_port = None;
while api_addr.is_none() || dev_port.is_none() {
let remaining = deadline.saturating_duration_since(Instant::now());
assert!(
!remaining.is_zero(),
"salvor --dev did not print both banners within 90s \
(api_addr={api_addr:?}, dev_port={dev_port:?})"
);
let line = rx
.recv_timeout(remaining)
.expect("salvor --dev exited before printing both banners");
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix("salvor control plane listening on http://") {
api_addr = Some(rest.to_owned());
} else if let Some(rest) = trimmed.strip_prefix("dev UI (hot reload): http://localhost:") {
let port_str = rest.split('/').next().unwrap_or("");
dev_port = port_str.parse::<u16>().ok();
}
}
(api_addr.unwrap(), dev_port.unwrap())
}
fn find_ng_child(parent_pid: u32) -> Option<u32> {
let specifics = ProcessRefreshKind::nothing().with_cmd(sysinfo::UpdateKind::Always);
let system = System::new_with_specifics(RefreshKind::nothing().with_processes(specifics));
system
.processes()
.values()
.find(|process| {
let is_child = process.parent().map(|pp| pp.as_u32()) == Some(parent_pid);
let looks_like_ng = process
.cmd()
.iter()
.any(|arg| arg.to_string_lossy().contains("ng serve"));
is_child && looks_like_ng
})
.map(|process| process.pid().as_u32())
}
fn wait_for_ng_child(parent_pid: u32, timeout: Duration) -> Option<u32> {
let deadline = Instant::now() + timeout;
loop {
if let Some(pid) = find_ng_child(parent_pid) {
return Some(pid);
}
if Instant::now() >= deadline {
return None;
}
std::thread::sleep(Duration::from_millis(100));
}
}
fn wait_for_port(addr: &str, timeout: Duration) {
let deadline = Instant::now() + timeout;
loop {
if TcpStream::connect(addr).is_ok() {
return;
}
assert!(
Instant::now() < deadline,
"{addr} never started accepting connections"
);
std::thread::sleep(Duration::from_millis(100));
}
}
fn http_get(host_port: &str, path: &str) -> String {
let mut stream = TcpStream::connect(host_port)
.unwrap_or_else(|error| panic!("connecting to {host_port}: {error}"));
stream
.set_read_timeout(Some(Duration::from_secs(5)))
.expect("set read timeout");
let host = host_port.split(':').next().unwrap_or("localhost");
write!(
stream,
"GET {path} HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\n\r\n"
)
.expect("write the request");
let mut response = String::new();
stream
.read_to_string(&mut response)
.expect("read the response");
response
}