tests/solo_embeds_server.rs
//! **The `nornir-solo` proof** — the solo atom really does carry a server.
//!
//! `solo` (`nornir_build_thing::plugin::Role::Solo`) claims something a descriptor
//! cannot check: that ONE process puts the UI up with the server running inside
//! itself. The vacuous pass is obvious and cheap — declare the atom, add a feature,
//! ship a binary that starts no server, and every guard that reads the declaration
//! stays green forever. So nothing in this file reads the declaration.
//!
//! [`solo_binary_serves_real_grpc`] runs the BUILT `nornir-solo`, waits for the
//! marker it prints only after a real TCP connection to its bind address is
//! ACCEPTED, then makes a REAL `Health.Ping` gRPC call to that port and requires the
//! reply to carry this build's own `nornir::VERSION_LINE`.
//!
//! [`the_ui_atom_serves_nothing`] is the anti-vacuous anchor and the more important
//! half: the IDENTICAL probe against `nornir-ui` — the same source file, the same
//! flags, one feature less — must find NOTHING listening. Without it "a port
//! answered" could mean a stray `nornir-server` on the box, a leaked port from
//! another test, or a probe that never actually connected.
//!
//! Both are headless: `--robot-remote <socket>` hands the process to the fleet's
//! blocking Unix-socket drive mount instead of `eframe::run_native`, so no display
//! is needed and the process stays alive long enough to be probed. That is why the
//! applied tests are `#[cfg(all(feature = "solo", feature = "robot-remote"))]` —
//! and why [`ci_compiles_the_solo_proof`], which is ALWAYS compiled, exists.
//!
//! Run: `cargo test --features solo,robot-remote --test solo_embeds_server`.
use std::path::Path;
#[path = "support/ci_commands.rs"]
mod ci_commands;
use ci_commands::{ci_commands_listing, ci_runs};
fn repo_root() -> &'static Path {
Path::new(env!("CARGO_MANIFEST_DIR"))
}
/// **The anti-vacuum check**, always compiled.
///
/// The two behavioural tests below are cfg-gated on features that are OFF by
/// default, so a plain `cargo test` compiles them out and this file reads green
/// having asserted nothing about solo at all. That is the same trap
/// `tests/inotify_watch_wiring.rs` was written to close for `inotify`, and it is
/// closed the same way: CI must build the features, or this goes red.
///
/// A deliberately dumb substring check — the failure it exists to catch is "the
/// step was deleted", not "the step was reformatted".
#[test]
fn ci_compiles_the_solo_proof() {
assert!(
ci_runs("--features solo,robot-remote"),
"no CI COMMAND builds `solo,robot-remote`, so the only tests that prove the \
`nornir-solo` atom actually embeds a server are compiled out of every \
automatic run — the atom would be a declaration with nothing behind it, which \
is exactly the vacuous pass the solo role invites. Restore the \
`--features solo,robot-remote` line in .woodpecker.yml's build-test step; a \
`#` in front of it counts as gone (measured green over exactly that on \
2026-08-23, hence the command-list read). What CI does run:\n{}",
ci_commands_listing()
);
}
#[cfg(all(feature = "solo", feature = "robot-remote"))]
mod applied {
use std::io::{BufRead, BufReader};
use std::net::{SocketAddr, TcpStream};
use std::process::{Child, Command, Stdio};
use std::sync::mpsc;
use std::time::{Duration, Instant};
/// The exact marker `start_embedded_server` prints, and ONLY after a real TCP
/// connect to the bound address succeeded.
const MARKER: &str = "NORNIR-SOLO: gRPC listening on";
/// An OS-assigned free TCP port, released so the child can bind it. (Small
/// TOCTOU window; acceptable for a local test, and a lost race shows up as the
/// child failing to bind — never as a false pass, because nothing else in this
/// file treats "could not connect" as success for the solo leg.)
fn free_port() -> u16 {
let l = std::net::TcpListener::bind("127.0.0.1:0").expect("bind ephemeral");
l.local_addr().expect("local_addr").port()
}
/// A child process plus a reader thread pumping its stdout into a channel, so
/// the test can wait for a line with a deadline instead of blocking forever.
struct Spawned {
child: Child,
lines: mpsc::Receiver<String>,
}
impl Drop for Spawned {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
/// Launch one of the two GUI-source binaries HEADLESS (`--robot-remote`), with a
/// throwaway `$HOME` (so it never touches the caller's real `~/.nornir`, its
/// registry or its warehouse lock) and an explicit bind address.
fn spawn(exe: &str, addr: &SocketAddr, home: &std::path::Path) -> Spawned {
let sock = home.join("robot.sock");
let mut child = Command::new(exe)
.arg("--robot-remote")
.arg(&sock)
.env("HOME", home)
.env("NORNIR_SERVER_ADDR", addr.to_string())
// ≥16 chars — `resolve_bearer` rejects a shorter secret outright.
.env("NORNIR_SERVER_TOKEN", "solo-proof-token-0123456789")
// Never inherit the caller's pointer at a live server: a solo binary that
// silently proxied an already-running :7878 would pass this whole file.
.env_remove("NORNIR_SERVER")
.env_remove("NORNIR_WORKSPACE")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap_or_else(|e| panic!("spawn {exe}: {e}"));
let stdout = child.stdout.take().expect("piped stdout");
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
for line in BufReader::new(stdout).lines().map_while(Result::ok) {
eprintln!("[child] {line}");
if tx.send(line).is_err() {
break;
}
}
});
Spawned { child, lines: rx }
}
/// Wait up to `budget` for a stdout line containing `needle`.
fn wait_for_line(s: &Spawned, needle: &str, budget: Duration) -> Option<String> {
let deadline = Instant::now() + budget;
while let Some(left) = deadline.checked_duration_since(Instant::now()) {
match s.lines.recv_timeout(left.min(Duration::from_millis(500))) {
Ok(line) if line.contains(needle) => return Some(line),
Ok(_) => continue,
Err(mpsc::RecvTimeoutError::Timeout) => continue,
Err(mpsc::RecvTimeoutError::Disconnected) => return None,
}
}
None
}
fn tcp_answers(addr: &SocketAddr) -> bool {
TcpStream::connect_timeout(addr, Duration::from_millis(400)).is_ok()
}
/// A real gRPC `Health.Ping` against `addr`, returning `(status, version)`.
/// `Health.Ping` is the one un-intercepted RPC by contract, so this needs no
/// bearer and tests the SERVER, not the auth layer.
async fn ping(addr: &SocketAddr) -> (String, String) {
use nornir::server_grpc::pb;
let channel = tonic::transport::Channel::from_shared(format!("http://{addr}"))
.expect("uri")
.connect()
.await
.unwrap_or_else(|e| panic!("gRPC connect to {addr}: {e}"));
let reply = pb::health_client::HealthClient::new(channel)
.ping(tonic::Request::new(pb::Empty {}))
.await
.unwrap_or_else(|e| panic!("Health.Ping on {addr}: {e}"))
.into_inner();
(reply.status, reply.version)
}
/// ★ **THE SOLO PROOF — applied behaviour, not a declaration.**
#[test]
fn solo_binary_serves_real_grpc() {
let home = tempfile::tempdir().expect("throwaway HOME");
let addr: SocketAddr = format!("127.0.0.1:{}", free_port()).parse().expect("addr");
// Nothing must be listening BEFORE we start — otherwise a stray server on the
// box would satisfy every assertion below without nornir-solo doing anything.
assert!(
!tcp_answers(&addr),
"{addr} was already accepting connections before nornir-solo started — the \
probe cannot attribute a response to the solo binary"
);
let solo = spawn(env!("CARGO_BIN_EXE_nornir-solo"), &addr, home.path());
let marker = wait_for_line(&solo, MARKER, Duration::from_secs(120)).unwrap_or_else(|| {
panic!(
"nornir-solo never printed `{MARKER}` — it opened (or tried to open) a UI \
claiming a server it did not start"
)
});
assert!(
marker.contains(&addr.to_string()),
"the marker names a different address than the one we bound: {marker} (wanted {addr})"
);
// The marker is a claim; this is the measurement. A REAL gRPC call, over a
// REAL socket, into the REAL registered service stack of that process.
let rt = tokio::runtime::Runtime::new().expect("tokio runtime");
let (status, version) = rt.block_on(ping(&addr));
assert_eq!(
status, "ok",
"Health.Ping from the embedded server did not answer ok"
);
assert_eq!(
version,
nornir::VERSION_LINE,
"the embedded server reported a different identity than the binary it lives \
in — the response did not come from this process"
);
}
/// ★ **THE ANTI-VACUOUS ANCHOR.** Same source file, same flags, one feature
/// less: `nornir-ui` must serve NOTHING. If this ever passes-by-answering, the
/// test above is measuring something other than the `solo` feature.
#[test]
fn the_ui_atom_serves_nothing() {
let home = tempfile::tempdir().expect("throwaway HOME");
let addr: SocketAddr = format!("127.0.0.1:{}", free_port()).parse().expect("addr");
let ui = spawn(env!("CARGO_BIN_EXE_nornir-ui"), &addr, home.path());
// Give it at least as long as a solo boot would plausibly need to bind.
assert!(
wait_for_line(&ui, MARKER, Duration::from_secs(15)).is_none(),
"`nornir-ui` printed the solo listening marker — the `solo` cfg block is \
compiled into a build that does not declare the feature"
);
let deadline = Instant::now() + Duration::from_secs(10);
while Instant::now() < deadline {
assert!(
!tcp_answers(&addr),
"something is listening on {addr} after starting `nornir-ui`, which \
embeds no server — either the UI atom secretly starts one, or this \
probe is not measuring what it claims (which would make the solo \
test above vacuous too)"
);
std::thread::sleep(Duration::from_millis(250));
}
}
}