crates/nornir-fleet/src/bin/fleet_mock_server.rs
//! **`fleet_mock_server`** — a TRIVIAL mounted app for proving [`LocalProcessSpinner`].
//!
//! This is the lightweight stand-in the spinner test launches instead of the five
//! real apps (spawning all of them in a unit test is too heavy / flaky — the
//! `run_local_fleet` example is the real-apps entry). It mounts a
//! [`CmdJsonServer`](nornir_robotui::cmd_json::CmdJsonServer) over a hand-rolled
//! [`CmdHost`] (a bare click-counter — NO egui / kittest, so it builds with the lean
//! `wire` feature and no dev-deps) on a Unix socket, mirroring dwarves-gui's
//! `--robot-remote <socket>` mount so the spinner drives it exactly like a real app.
//!
//! ## CLI (mirrors the real apps)
//! * `fleet_mock_server --robot-remote <socket>` — bind `<socket>`, serve drivers
//! sequentially until each hangs up (EOF). Own blocking thread, NO `thread::spawn`.
//! * `fleet_mock_server scenarios` — emit a tiny per-tab scenario library as the
//! SAME `serde_json` wire form the fleet loads (`<member>.scenarios.json`).
//!
//! ROOT LAW #0: plain blocking accept→serve loop; spawns nothing.
#[cfg(unix)]
fn main() {
use std::io::BufReader;
use std::os::unix::net::UnixListener;
use nornir_robotui::cmd_json::{CmdHost, CmdJsonServer, IoChannel};
use nornir_robotui::{Action, Assert, Scenario, Step as UiStep, Target};
use serde_json::{json, Value};
/// A bare click-counter host — the whole app surface the mock needs: a `Bump`
/// button that increments `/clicks`, and `state_json` a driver reads/asserts.
/// Implements [`CmdHost`] by hand (no egui), so a driver drives it over the wire
/// exactly like a real `RobotSession`-backed mount.
#[derive(Default)]
struct StubHost {
clicks: i64,
}
impl StubHost {
fn state_value(&self) -> Value {
json!({ "clicks": self.clicks })
}
}
impl CmdHost for StubHost {
fn locate(&self, _target: &Target) -> Option<u64> {
Some(1) // any button resolves — the mock has one control
}
fn act(&mut self, action: &Action) -> Result<(), String> {
match action {
Action::ClickButton(_) | Action::ClickLabel(_) | Action::ClickById(_) => {
self.clicks += 1;
Ok(())
}
_ => Ok(()),
}
}
fn assert(&self, assert: &Assert) -> Result<(), String> {
match assert {
Assert::None | Assert::Drew => Ok(()),
// A chained `.assert(a).assert(b)` arrives as one `All`. Evaluate
// EVERY clause — the catch-all below would otherwise turn a
// perfectly evaluatable chain into "mock host does not evaluate".
Assert::All(clauses) => {
let n = clauses.len();
for (i, c) in clauses.iter().enumerate() {
self.assert(c)
.map_err(|e| format!("clause {} of {n}: {e}", i + 1))?;
}
Ok(())
}
Assert::State { pointer, expected } => {
let got = self.state_value().pointer(pointer).cloned();
if got.as_ref() == Some(expected) {
Ok(())
} else {
Err(format!("expected {expected} at {pointer}, got {got:?}"))
}
}
other => Err(format!("mock host does not evaluate {other:?}")),
}
}
fn state(&self) -> Value {
self.state_value()
}
fn drive_step(&mut self, step: &UiStep) -> (bool, String) {
if let Err(e) = self.act(&step.action) {
return (false, e);
}
match self.assert(&step.assert) {
Ok(()) => (true, String::new()),
Err(e) => (false, e),
}
}
}
/// The mock's tiny recorded scenario library (the `scenarios` emit path): one
/// clean tab that bumps the counter to 1.
fn mock_scenarios() -> Vec<Scenario> {
vec![Scenario::new("ok_tab").step(
UiStep::new("bump → clicks == 1")
.action(Action::ClickButton("Bump".into()))
.assert(Assert::State { pointer: "/clicks".into(), expected: json!(1) }),
)]
}
/// MOUNT the server on `path` and serve drivers sequentially (one at a time)
/// until an `accept` error. Clears a stale socket first (re-bind clean).
fn serve(path: &std::path::Path) -> std::io::Result<()> {
let _ = std::fs::remove_file(path);
let listener = UnixListener::bind(path)?;
let mut server = CmdJsonServer::new(StubHost::default());
eprintln!("fleet_mock_server: listening on {}", path.display());
loop {
let (stream, _) = listener.accept()?;
let rd = stream.try_clone()?;
let mut chan = IoChannel::new(BufReader::new(rd), stream);
if let Err(e) = server.serve(&mut chan) {
eprintln!("fleet_mock_server: connection ended: {e}");
}
}
}
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
match a.as_str() {
"scenarios" => {
let json = serde_json::to_string_pretty(&mock_scenarios()).unwrap();
println!("{json}");
return;
}
"--robot-remote" => {
let Some(path) = args.next() else {
eprintln!("fleet_mock_server: --robot-remote needs a <socket> path");
std::process::exit(2);
};
if let Err(e) = serve(std::path::Path::new(&path)) {
eprintln!("fleet_mock_server: serve failed: {e}");
std::process::exit(1);
}
return;
}
other => {
eprintln!("fleet_mock_server: unknown arg `{other}`");
std::process::exit(2);
}
}
}
eprintln!("usage: fleet_mock_server (--robot-remote <socket> | scenarios)");
std::process::exit(2);
}
#[cfg(not(unix))]
fn main() {
eprintln!("fleet_mock_server: Unix-socket only");
std::process::exit(1);
}