use std::io::Read;
use std::process::{Command, Stdio};
use std::sync::mpsc;
use std::thread;
use std::time::{Duration, Instant};
use veloxc::commands::dev::{
AppChild, BuildAction, BuildGate, BuildOutcome, BuildReaction, BuildWorker, DevCmd,
InputSource, classify_build, react_to,
};
fn spawn_sleeper() -> std::process::Child {
Command::new("sleep")
.arg("30")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("`sleep` must be on PATH for this test to mean anything")
}
#[cfg(target_os = "linux")]
fn process_exists(pid: u32) -> bool {
std::path::Path::new(&format!("/proc/{pid}")).exists()
}
#[cfg(not(target_os = "linux"))]
fn process_exists(_pid: u32) -> bool {
false
}
fn wait_until_gone(pid: u32) -> Duration {
let start = Instant::now();
while process_exists(pid) && start.elapsed() < Duration::from_secs(5) {
thread::sleep(Duration::from_millis(10));
}
start.elapsed()
}
#[test]
fn the_first_change_starts_a_build() {
let mut gate = BuildGate::new();
assert_eq!(gate.on_change(), BuildAction::Start);
assert!(gate.is_build_in_flight());
assert!(!gate.has_pending_change());
}
#[test]
fn a_burst_of_saves_while_compiling_costs_exactly_one_rebuild() {
let mut gate = BuildGate::new();
assert_eq!(gate.on_change(), BuildAction::Start, "first save compiles");
for i in 2..=10 {
assert_eq!(
gate.on_change(),
BuildAction::AlreadyInFlight,
"save {i} must not start a second cargo build"
);
}
gate.on_build_finished();
assert!(!gate.is_build_in_flight());
assert!(gate.take_pending(), "the burst is owed a follow-up build");
assert!(!gate.take_pending(), "and only one");
assert_eq!(gate.on_change(), BuildAction::Start);
}
#[test]
fn a_finished_build_with_no_pending_change_starts_nothing() {
let mut gate = BuildGate::new();
assert_eq!(gate.on_change(), BuildAction::Start);
gate.on_build_finished();
assert!(
!gate.take_pending(),
"nothing was saved during that compile"
);
assert!(!gate.is_build_in_flight());
}
#[test]
fn a_gate_can_cycle_indefinitely() {
let mut gate = BuildGate::new();
for round in 0..5 {
assert_eq!(gate.on_change(), BuildAction::Start, "round {round}");
assert_eq!(gate.on_change(), BuildAction::AlreadyInFlight);
gate.on_build_finished();
assert!(gate.take_pending());
assert_eq!(
gate.on_change(),
BuildAction::Start,
"round {round} follow-up"
);
gate.on_build_finished();
assert!(!gate.take_pending(), "round {round} left nothing owed");
}
assert!(!gate.is_build_in_flight());
}
#[test]
fn a_compile_error_recovers_on_the_next_save() {
let mut gate = BuildGate::new();
let ok = classify_build(true, String::new(), Duration::from_millis(800));
assert_eq!(
ok,
BuildOutcome::Compiled {
elapsed: Duration::from_millis(800)
}
);
assert_eq!(gate.on_change(), BuildAction::Start);
gate.on_build_finished();
let stderr = "error: expected `;`, found `}`".to_string();
let bad = classify_build(false, stderr.clone(), Duration::from_millis(300));
assert_eq!(
bad,
BuildOutcome::CompileFailed {
stderr: stderr.clone()
}
);
assert_eq!(gate.on_change(), BuildAction::Start);
gate.on_build_finished();
assert!(
!gate.is_build_in_flight(),
"a failed build must not wedge the gate"
);
assert_eq!(gate.on_change(), BuildAction::Start);
let fixed = classify_build(true, String::new(), Duration::from_millis(400));
assert_eq!(
fixed,
BuildOutcome::Compiled {
elapsed: Duration::from_millis(400)
}
);
gate.on_build_finished();
assert!(!gate.take_pending(), "recovery left nothing owed");
assert!(!gate.is_build_in_flight());
}
#[test]
fn a_failed_build_is_classified_as_a_compile_error() {
let outcome = classify_build(
false,
"error[E0308]: mismatched types".to_string(),
Duration::from_millis(1),
);
match outcome {
BuildOutcome::CompileFailed { stderr } => {
assert_eq!(stderr, "error[E0308]: mismatched types");
}
other => panic!("expected CompileFailed, got {other:?}"),
}
}
#[test]
fn a_cargo_launch_failure_is_not_rendered_as_a_compile_error() {
let outcome = BuildOutcome::InvokeFailed {
message: "No such file or directory (os error 2)".to_string(),
};
match react_to(&outcome) {
BuildReaction::ReportLaunchFailure { message } => {
assert_eq!(message, "No such file or directory (os error 2)");
}
other => panic!("a launch failure must not become {other:?}"),
}
}
#[test]
fn a_compile_error_is_never_reported_as_a_launch_failure() {
let outcome = classify_build(
false,
"error: expected `;`".to_string(),
Duration::from_millis(5),
);
assert_eq!(
react_to(&outcome),
BuildReaction::PrintBuildError {
stderr: "error: expected `;`".to_string()
}
);
}
#[test]
fn a_successful_build_reaction_carries_the_elapsed_time() {
let outcome = classify_build(true, String::new(), Duration::from_millis(1234));
assert_eq!(
react_to(&outcome),
BuildReaction::RunApp {
elapsed: Duration::from_millis(1234)
}
);
}
#[test]
fn dropping_the_guard_leaves_no_surviving_process() {
let child = Command::new("sleep")
.arg("30")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("spawn sleeper");
let pid = child.id();
let guard = AppChild::new(child);
if !process_exists(pid) {
return;
}
drop(guard);
let elapsed = wait_until_gone(pid);
assert!(
!process_exists(pid),
"pid {pid} is still in the process table {elapsed:?} after the guard was dropped"
);
}
#[test]
fn a_panic_in_the_dev_loop_still_reaps_the_app() {
let sleeper = Command::new("sleep")
.arg("30")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("spawn sleeper");
let pid = sleeper.id();
let result = std::panic::catch_unwind(|| {
let _guard = AppChild::new(sleeper);
panic!("simulated dev-server panic");
});
assert!(result.is_err(), "the panic must actually have unwound");
if !process_exists(pid) {
return;
}
wait_until_gone(pid);
assert!(
!process_exists(pid),
"pid {pid} survived an unwind: the guard did not run"
);
}
#[test]
fn shutdown_reaps_the_child_and_is_idempotent() {
let mut guard = AppChild::new(spawn_sleeper());
assert!(
!guard.has_exited().expect("try_wait on a live child"),
"a fresh sleeper must look alive"
);
let status = guard
.shutdown()
.expect("shutdown must report the reap it performed");
assert!(
!status.success(),
"a 30s sleeper killed immediately cannot have exited successfully"
);
assert!(
guard.shutdown().is_none(),
"a second shutdown must be a no-op, not a second reap"
);
assert!(
!guard
.has_exited()
.expect("an emptied guard cannot report an exit"),
"an emptied guard has nothing left to observe"
);
}
#[test]
fn a_freshly_reaped_child_no_longer_reports_as_exited() {
let mut guard = AppChild::new(spawn_sleeper());
guard.shutdown().expect("reap");
drop(guard);
}
const SLOW_BUILD: Duration = Duration::from_millis(700);
#[test]
fn the_dev_loop_stays_responsive_while_a_build_is_in_flight() {
let (tx, rx) = mpsc::channel::<DevCmd>();
let start = Instant::now();
let worker = BuildWorker::start_with(tx.clone(), move |tx, _slot| {
thread::sleep(SLOW_BUILD);
let _ = tx.send(DevCmd::Built(BuildOutcome::Compiled {
elapsed: SLOW_BUILD,
}));
});
let start_took = start.elapsed();
assert!(
start_took < Duration::from_millis(100),
"starting a build blocked the caller for {start_took:?}; \
the compile must run off the dev loop thread"
);
let sent_at = Instant::now();
tx.send(DevCmd::Reload).expect("send a keystroke");
let got = rx
.recv_timeout(Duration::from_millis(100))
.expect("the dev loop must wake on a keystroke while cargo is still compiling");
assert_eq!(got, DevCmd::Reload);
let keystroke_latency = sent_at.elapsed();
assert!(
keystroke_latency < SLOW_BUILD,
"the keystroke waited {keystroke_latency:?} for the build to finish"
);
let built = rx
.recv_timeout(Duration::from_secs(10))
.expect("the build must report its outcome on the dev loop's channel");
assert_eq!(
built,
DevCmd::Built(BuildOutcome::Compiled {
elapsed: SLOW_BUILD
})
);
drop(worker);
}
#[test]
fn dropping_a_build_worker_kills_and_reaps_its_process() {
let (tx, _rx) = mpsc::channel::<DevCmd>();
let (pid_tx, pid_rx) = mpsc::channel::<u32>();
let worker = BuildWorker::start_with(tx, move |_tx, slot| {
let mut child = Command::new("sleep")
.arg("30")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.expect("spawn a stand-in for cargo");
let pid = child.id();
let mut pipe = child.stderr.take();
*slot.lock().unwrap() = Some(child);
let _ = pid_tx.send(pid);
if let Some(p) = pipe.as_mut() {
let mut buf = Vec::new();
let _ = p.read_to_end(&mut buf);
}
});
let pid = pid_rx
.recv_timeout(Duration::from_secs(10))
.expect("the worker should have published its process");
assert!(
process_exists(pid),
"pid {pid} is not running to begin with"
);
drop(worker);
let elapsed = wait_until_gone(pid);
assert!(
!process_exists(pid),
"pid {pid} survived BuildWorker::drop ({elapsed:?}); \
the compile would still be holding the target-dir lock"
);
}
#[test]
fn a_loop_that_holds_its_own_sender_can_never_see_disconnected() {
let (tx, rx) = mpsc::channel::<DevCmd>();
let worker_tx = tx.clone();
thread::spawn(move || {
drop(worker_tx);
});
assert!(
matches!(
rx.recv_timeout(Duration::from_millis(150)),
Err(mpsc::RecvTimeoutError::Timeout)
),
"with the loop's own sender alive the channel must time out, not disconnect"
);
drop(tx);
assert!(
matches!(
rx.recv_timeout(Duration::from_millis(150)),
Err(mpsc::RecvTimeoutError::Disconnected)
),
"once the loop's own sender is dropped the channel must disconnect"
);
}
#[test]
fn input_exhaustion_is_reported_in_both_directions() {
let live = InputSource::from_handle(thread::spawn(|| thread::sleep(Duration::from_secs(30))));
let done = InputSource::from_handle(thread::spawn(|| {}));
let deadline = Instant::now() + Duration::from_secs(5);
while !done.is_exhausted() && Instant::now() < deadline {
thread::sleep(Duration::from_millis(5));
}
assert!(
done.is_exhausted(),
"a returned reader must report exhausted"
);
assert!(
!live.is_exhausted(),
"a reader blocked in a stdin read must not be reported as exhausted"
);
drop(live);
}