use std::io::{BufRead, BufReader, Write};
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::sync::mpsc::{channel, Receiver};
use std::sync::Mutex;
use std::thread;
use std::time::{Duration, Instant};
static CHDIR_LOCK: Mutex<()> = Mutex::new(());
fn fixture_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/minimal-serve")
}
fn ferro_bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_ferro"))
}
fn default_timeout() -> Duration {
Duration::from_secs(5)
}
fn spawn_stdout_reader(child: &mut Child) -> Receiver<String> {
let stdout = child.stdout.take().expect("child stdout piped");
let (tx, rx) = channel::<String>();
thread::spawn(move || {
let reader = BufReader::new(stdout);
for line in reader.lines().map_while(Result::ok) {
if tx.send(line).is_err() {
break;
}
}
});
rx
}
fn wait_for_stdout_line(rx: &Receiver<String>, needle: &str, timeout: Duration) -> bool {
let deadline = Instant::now() + timeout;
while let Some(remaining) = deadline.checked_duration_since(Instant::now()) {
match rx.recv_timeout(remaining) {
Ok(line) => {
eprintln!("[test-stdout] {line}");
if line.contains(needle) {
return true;
}
}
Err(_) => return false,
}
}
false
}
#[cfg(unix)]
fn send_sigint(child: &Child) {
unsafe {
libc::kill(child.id() as i32, libc::SIGINT);
}
}
#[cfg(windows)]
fn send_sigint(child: &mut Child) {
let _ = child.kill();
}
fn kill_and_wait(mut child: Child, budget: Duration) -> bool {
let start = Instant::now();
let _ = child.kill();
loop {
match child.try_wait() {
Ok(Some(_)) => return true,
Ok(None) => {
if start.elapsed() > budget {
return false;
}
thread::sleep(Duration::from_millis(50));
}
Err(_) => return false,
}
}
}
#[test]
fn backend_only_shuts_down_cleanly() {
let _guard = CHDIR_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _ = default_timeout();
let mut child = Command::new(ferro_bin())
.args(["serve", "--backend-only", "--skip-types"])
.current_dir(fixture_dir())
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn ferro serve");
let rx = spawn_stdout_reader(&mut child);
assert!(
wait_for_stdout_line(&rx, "Backend server on", Duration::from_secs(15)),
"backend banner not seen"
);
let start = Instant::now();
#[cfg(unix)]
send_sigint(&child);
#[cfg(windows)]
send_sigint(&mut child);
let clean = kill_and_wait(child, Duration::from_secs(5));
assert!(clean, "ferro serve did not exit within 5s of SIGINT");
assert!(
start.elapsed() < Duration::from_secs(5),
"shutdown slower than budget: {:?}",
start.elapsed()
);
}
#[test]
fn r_key_in_no_watch_mode_triggers_one_rebuild() {
let _guard = CHDIR_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let tmp = tempfile::tempdir().expect("tempdir");
let pipe = tmp.path().join("trigger.pipe");
std::fs::write(&pipe, "").unwrap();
let mut child = Command::new(ferro_bin())
.args(["serve", "--backend-only", "--skip-types"])
.current_dir(fixture_dir())
.env("FERRO_SERVE_TEST_TRIGGER_PIPE", &pipe)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn ferro serve");
let rx = spawn_stdout_reader(&mut child);
assert!(
wait_for_stdout_line(&rx, "Backend server on", Duration::from_secs(15)),
"backend banner not seen"
);
std::fs::write(&pipe, "r").unwrap();
let saw = wait_for_stdout_line(&rx, "reload triggered (manual)", Duration::from_secs(5));
assert!(saw, "expected one manual reload line");
let dup = wait_for_stdout_line(&rx, "reload triggered", Duration::from_secs(2));
assert!(!dup, "unexpected duplicate reload trigger line");
#[cfg(unix)]
send_sigint(&child);
#[cfg(windows)]
send_sigint(&mut child);
assert!(
kill_and_wait(child, Duration::from_secs(5)),
"ferro serve did not exit within 5s of SIGINT"
);
}
#[test]
fn watch_mode_debounces_burst() {
let _guard = CHDIR_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let tmp = tempfile::tempdir().expect("tempdir");
let sandbox = tmp.path();
std::fs::create_dir_all(sandbox.join("src")).unwrap();
std::fs::copy(fixture_dir().join("Cargo.toml"), sandbox.join("Cargo.toml")).unwrap();
std::fs::copy(
fixture_dir().join("src/main.rs"),
sandbox.join("src/main.rs"),
)
.unwrap();
let mut child = Command::new(ferro_bin())
.args(["serve", "--backend-only", "--skip-types", "--watch"])
.current_dir(sandbox)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn ferro serve --watch");
let rx = spawn_stdout_reader(&mut child);
assert!(
wait_for_stdout_line(&rx, "Backend server on", Duration::from_secs(15)),
"backend banner not seen"
);
assert!(
wait_for_stdout_line(&rx, "enabled", Duration::from_secs(3)),
"watch banner did not say 'enabled'"
);
thread::sleep(Duration::from_millis(200));
let burst_start = Instant::now();
for i in 0..10 {
std::fs::write(
sandbox.join(format!("src/f{i}.rs")),
format!("// burst file {i}\nfn _x() {{}}"),
)
.unwrap();
}
std::fs::write(sandbox.join("Cargo.toml"), "# touched").unwrap();
assert!(
wait_for_stdout_line(
&rx,
"reload triggered (file change)",
Duration::from_secs(5)
),
"expected a debounced file-change reload line"
);
let elapsed = burst_start.elapsed();
assert!(
elapsed >= Duration::from_millis(400),
"debounce window too short: {elapsed:?}"
);
let drain_deadline = Instant::now() + Duration::from_secs(2);
let mut extra = 0usize;
while let Some(remaining) = drain_deadline.checked_duration_since(Instant::now()) {
match rx.recv_timeout(remaining) {
Ok(line) => {
eprintln!("[test-stdout] {line}");
if line.contains("reload triggered") {
extra += 1;
}
}
Err(_) => break,
}
}
let total = 1 + extra;
assert!(
total < 11,
"debouncer failed to coalesce: {total} events for 11 writes"
);
#[cfg(unix)]
send_sigint(&child);
#[cfg(windows)]
send_sigint(&mut child);
assert!(
kill_and_wait(child, Duration::from_secs(5)),
"ferro serve did not exit within 5s of SIGINT"
);
}
#[test]
fn non_tty_stdin_ignores_r_and_shows_banner() {
let _guard = CHDIR_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let mut child = Command::new(ferro_bin())
.args(["serve", "--backend-only", "--skip-types"])
.current_dir(fixture_dir())
.stdin(Stdio::piped()) .stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn ferro serve");
let rx = spawn_stdout_reader(&mut child);
assert!(
wait_for_stdout_line(&rx, "unavailable", Duration::from_secs(15)),
"non-TTY banner did not contain 'unavailable'"
);
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(b"r\n");
}
thread::sleep(Duration::from_millis(500));
let saw_reload = wait_for_stdout_line(&rx, "reload triggered", Duration::from_millis(500));
assert!(
!saw_reload,
"non-TTY stdin bytes must not produce a reload trigger"
);
#[cfg(unix)]
send_sigint(&child);
#[cfg(windows)]
send_sigint(&mut child);
assert!(
kill_and_wait(child, Duration::from_secs(5)),
"ferro serve did not exit within 5s of SIGINT"
);
}