crates/nornir-fleet/tests/local_process_spinner.rs
//! Proof that [`LocalProcessSpinner`] works against a REAL child process (no
//! container, no thread::spawn) — the spin mechanism the `run_local_fleet` example
//! drives the five real apps with. To keep this light and deterministic we launch a
//! TRIVIAL in-repo mock (`fleet_mock_server`, a `CmdJsonServer` over a bare
//! click-counter host) instead of the heavy real apps.
//!
//! It proves the full spin contract:
//! * spawn a child + bounded-poll until its socket answers a `Ping` → **Ready**;
//! * a `CmdJsonClient` drives one step over the LIVE socket and the state moves;
//! * a member whose child exits without binding is honestly **NOT Ready**;
//! * **Drop = teardown**: the child is killed + reaped and the socket file removed.
#![cfg(unix)]
use std::io::BufReader;
use std::os::unix::net::UnixStream;
use std::time::Duration;
use nornir_fleet::{LaunchSpec, LocalProcessSpinner};
use nornir_robotui::cmd_json::{CmdJsonClient, IoChannel};
use nornir_robotui::{Action, Assert, Step as UiStep};
use serde_json::json;
/// The trivial mock app the spinner launches — a `CmdJsonServer` on `--robot-remote
/// <socket>` (Cargo hands us its built path). NO hyphen in the name → the env var
/// resolves through `env!`.
const MOCK_BIN: &str = env!("CARGO_BIN_EXE_fleet_mock_server");
/// Open a driver client over a member's live socket (the SAME IoChannel + client the
/// fat leg uses).
fn client_for(path: &std::path::Path) -> CmdJsonClient<IoChannel<BufReader<UnixStream>, UnixStream>> {
let stream = UnixStream::connect(path).expect("connect to the live mock socket");
let reader = stream.try_clone().expect("clone read half");
CmdJsonClient::new(IoChannel::new(BufReader::new(reader), stream))
}
/// The happy path: spawn the mock, come Ready over a real socket, drive one step that
/// MOVES the remote state, then Drop and assert the child is gone + the socket removed.
#[test]
fn spinner_launches_pings_drives_one_step_and_tears_down() {
let dir = std::env::temp_dir().join(format!("fleet-spinner-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let socket;
let pid;
{
let mut spinner = LocalProcessSpinner::new(&dir);
let spec = LaunchSpec::new(MOCK_BIN, ["--robot-remote", "{socket}"])
.with_timeout(Duration::from_secs(10));
let ready = spinner.spin("mock", &spec).expect("spawn ok");
assert!(ready, "the mock bound its socket and answered a Ping → Ready");
assert!(spinner.is_ready("mock"));
assert_eq!(spinner.ready_members(), vec!["mock".to_string()]);
socket = spinner.socket_path("mock");
pid = spinner.pid("mock").expect("a tracked child pid");
assert!(socket.exists(), "the live socket file exists while spun");
// Drive one whole step over the LIVE socket — click bumps /clicks, and the
// wired-back state must reflect it (red-when-broken: no move → the step fails).
let mut client = client_for(&socket);
client.ping().expect("host answers ping");
let before = client.state_at("/clicks").expect("read /clicks");
assert_eq!(before, Some(json!(0)), "fresh mock starts at 0");
let (pass, msg) = client
.drive_step(
UiStep::new("bump → clicks == 1")
.action(Action::ClickButton("Bump".into()))
.assert(Assert::State { pointer: "/clicks".into(), expected: json!(1) }),
)
.expect("drive step over the wire");
assert!(pass, "the remote step passed: {msg}");
let after = client.state_at("/clicks").expect("read /clicks again");
assert_eq!(after, Some(json!(1)), "the driven step MOVED the remote state");
assert_ne!(before, after, "state moved (the spinner drove a real live app)");
// client drops → the server's serve() sees EOF and loops to accept again.
} // spinner drops here → teardown
// Teardown killed + reaped the child and removed the socket.
assert!(!socket.exists(), "Drop removed the member's socket file");
#[cfg(target_os = "linux")]
assert!(
!std::path::Path::new(&format!("/proc/{pid}")).exists(),
"Drop killed + reaped the child (pid {pid} is gone)"
);
let _ = std::fs::remove_dir_all(&dir);
}
/// A member whose child exits WITHOUT binding a socket is honestly NOT Ready (the
/// gate would then red — no false green), and teardown is still clean. The mock's
/// `scenarios` subcommand emits + exits immediately, never binding.
#[test]
fn spinner_reports_not_ready_when_the_child_never_binds() {
let dir = std::env::temp_dir().join(format!("fleet-spinner-noready-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let mut spinner = LocalProcessSpinner::new(&dir);
// `scenarios` prints its library and returns → the child exits before any bind.
let spec = LaunchSpec::new(MOCK_BIN, ["scenarios"]).with_timeout(Duration::from_secs(5));
let ready = spinner.spin("mock", &spec).expect("spawn ok");
assert!(!ready, "a child that exits without binding is NOT Ready");
assert!(!spinner.is_ready("mock"));
assert!(spinner.ready_members().is_empty());
assert!(!spinner.socket_path("mock").exists(), "no socket was ever bound");
let _ = std::fs::remove_dir_all(&dir);
}
/// A bogus program is the one hard spawn error (nothing was launched to track).
#[test]
fn spinner_errors_on_a_bogus_program() {
let dir = std::env::temp_dir().join(format!("fleet-spinner-bogus-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let mut spinner = LocalProcessSpinner::new(&dir);
let spec = LaunchSpec::new("/no/such/fleet/binary/at/all", ["--robot-remote", "{socket}"]);
assert!(spinner.spin("mock", &spec).is_err(), "spawning a missing program errors");
let _ = std::fs::remove_dir_all(&dir);
}