//! **`robot-remote`** — MOUNT holger-server-ui as a WIRE-REMOTE robot-drive target.
//!
//! This flips holger from a probe-only fleet member to a really-driven one: edda's
//! [`fleet`] harness can already drive an egui member's mounted
//! [`CmdJsonServer`](nornir_robotui::cmd_json::CmdJsonServer) over a byte stream (the
//! fat leg, `UnixEguiWire`), but the honest gap was per-app — "each app must MOUNT a
//! `CmdJsonServer` over its egui UI on a socket". holger-server-ui mounts it here, exactly the
//! way dwarves-demo did (`robot_remote.rs` + `serve_robot_remote`).
//!
//! ## What this is (REUSE, no second drive path)
//! * The command vocabulary is the ONE robotui `Action`/`Assert`/`Target`/`Step` set
//! carried by `cmd_json` — the SAME the co-located kittest robot suite
//! (`tests/robot_ui_app.rs`) drives through a [`RobotSession`]. Nothing new.
//! * The host is a [`RobotSession`] over holger-server-ui's OWN `egui_kittest` shell (the SAME
//! `Harness::build_state(|ctx, a| a.draw_ui(ui), …)` shape the robot tests build —
//! REUSE, not a twin), and the app's observable state IS
//! [`HolgerUiApp::state_json`](crate::app::HolgerUiApp::state_json) (no twinned oracle).
//! * The transport is an [`IoChannel`] over a real `AF_UNIX` [`UnixListener`]; edda's
//! reusable `UnixEguiWire` dials the exact socket [`serve_robot_remote`] binds.
//!
//! ## Member socket name
//! The fleet dials `<dir>/holger.sock` — the socket BASENAME is the member name
//! (`holger`). [`serve_robot_remote`] binds whatever path the `--robot-remote <socket>`
//! flag hands it; the fleet is the side that chooses the `holger.sock` basename.
//!
//! ## Concurrency (ROOT LAW #0)
//! The server is plain BLOCKING I/O pumped single-threaded — it spawns NOTHING. The
//! mount runs on the process's own thread (the CLI hands `main` over to it); a driver
//! is served sequentially via [`CmdJsonServer::serve`] until it hangs up (EOF).
//!
//! ## Scope
//! Feature-gated behind `robot-remote` and reached ONLY via the `--robot-remote
//! <socket>` CLI flag, so the SHIPPED default binary never mounts an always-on socket
//! (and never links the wire/kittest stack).
use std::io::{self, BufReader};
use std::os::unix::net::UnixListener;
use std::path::Path;
use std::sync::Arc;
use nornir_robotui::cmd_json::{CmdJsonServer, IoChannel};
use nornir_robotui::egui::Vec2;
use nornir_robotui::egui_kittest::Harness;
use nornir_robotui::{Observable, RobotSession};
use server_lib::exposed::fast_routes::FastRoutes;
use server_lib::LocalHolger;
use traits::{
ArchiveInfo, ArtifactEntry, ArtifactFormat, ArtifactId, HolgerObject, RepositoryBackendTrait,
};
use crate::app::HolgerUiApp;
use crate::data::UiData;
/// The holger operator shell wrapped as an [`Observable`] — the robot reads exactly what
/// the shell renders through the SAME [`HolgerUiApp::state_json`] the co-located robot
/// suite asserts (no twinned oracle). The WHOLE tabbed shell is mounted, so a driver can
/// read/act on every operator tab, not one pane.
struct UiApp(HolgerUiApp);
impl Observable for UiApp {
fn state_json(&self) -> serde_json::Value {
self.0.state_json()
}
}
/// A present-but-populated repository backend so the mounted shell lists real repos
/// (`repo_count > 0`) without depending on any one ecosystem's on-disk format. The
/// surface under test is the egui app + its `state_json` — the SAME stub shape the
/// co-located `tests/robot_ui_app.rs` uses.
struct StubRepo {
name: &'static str,
writable: bool,
}
impl RepositoryBackendTrait for StubRepo {
fn name(&self) -> &str {
self.name
}
fn format(&self) -> ArtifactFormat {
ArtifactFormat::Rust
}
fn is_writable(&self) -> bool {
self.writable
}
fn fetch(&self, _id: &ArtifactId) -> anyhow::Result<Option<Vec<u8>>> {
Ok(None)
}
fn put(&self, _id: &ArtifactId, _data: &[u8]) -> anyhow::Result<()> {
Ok(())
}
fn list(&self, _name_filter: Option<&str>, _limit: usize) -> anyhow::Result<Vec<ArtifactEntry>> {
Ok(Vec::new())
}
fn archive_files(&self, _prefix: Option<&str>) -> anyhow::Result<Vec<String>> {
Ok(Vec::new())
}
fn archive_info(&self) -> anyhow::Result<ArchiveInfo> {
Ok(ArchiveInfo {
file_count: 0,
total_uncompressed_bytes: 0,
archive_path: self.name.to_string(),
})
}
fn handle_http2_request(
&self,
_method: &str,
_suburl: &str,
_body: &[u8],
) -> anyhow::Result<(u16, Vec<(String, String)>, Vec<u8>)> {
Ok((404, Vec::new(), Vec::new()))
}
}
/// A real holger-server-ui app over a `LocalHolger` seeded with three repositories (one
/// writable → the server-derived profile is DYNAMIC, so every operator tab is reachable),
/// with the splash skipped so the robot starts on the tabs. Offline-safe: `LocalHolger`
/// is in-process, so mounting the server dials no network — the SAME headless build the
/// in-crate robot tests use.
fn seeded_app() -> HolgerUiApp {
let repos: Vec<(String, Arc<dyn RepositoryBackendTrait>)> = vec![
("rust-dev".to_string(), Arc::new(StubRepo { name: "rust-dev", writable: true })),
("cache".to_string(), Arc::new(StubRepo { name: "cache", writable: false })),
("sparring".to_string(), Arc::new(StubRepo { name: "sparring", writable: false })),
];
let holger: Arc<dyn HolgerObject> = Arc::new(LocalHolger::new(FastRoutes::new(repos)));
let data = UiData::new(holger).expect("holger-server-ui view-model runtime");
let mut app = HolgerUiApp::new(data);
app.skip_splash();
app
}
/// Build a [`CmdJsonServer`] over a [`RobotSession`] driving the WHOLE holger operator
/// shell through its real `egui_kittest` harness — the host side a driver dials. This is
/// the SAME `build_state(|ctx, a| a.draw_ui(ui), …)` harness the co-located robot suite
/// builds (REUSE), so mounting the server dials no network.
fn robot_session() -> CmdJsonServer<RobotSession<'static, UiApp>> {
// A generously sized canvas so the whole tab bar + the active pane lay out and are
// hit-testable (mirrors the kittest harness in `tests/robot_ui_app.rs`).
let harness = Harness::builder()
.with_size(Vec2::new(1100.0, 720.0))
.build_ui_state(|ui, app: &mut UiApp| app.0.draw_ui(ui), UiApp(seeded_app()));
let mut session = RobotSession::new(harness, "holger");
session.run(2); // settle the first layout so every tab is in the AccessKit tree
CmdJsonServer::new(session)
}
/// MOUNT the `CmdJsonServer` on a Unix socket at `path` and serve drivers on THIS (the
/// app's own) blocking thread — NO `thread::spawn` (ROOT LAW #0). One driver at a time,
/// sequentially: the fleet opens a fresh `CmdJsonClient` per tab drive, so
/// [`serve`](CmdJsonServer::serve) blocks answering that driver until it hangs up (EOF),
/// then the next connection is accepted. A per-connection I/O error is logged, not fatal.
/// Never returns except on an `accept` error.
///
/// The fleet dials `<dir>/holger.sock`; the caller passes that path here.
pub fn serve_robot_remote(path: &Path) -> io::Result<()> {
let _ = std::fs::remove_file(path); // clear a stale socket so the re-bind succeeds
let listener = UnixListener::bind(path)?;
let mut server = robot_session();
eprintln!("holger-server-ui: robot-remote server listening on {}", path.display());
loop {
let (stream, _) = listener.accept()?;
match stream.try_clone() {
Ok(rd) => {
let mut chan = IoChannel::new(BufReader::new(rd), stream);
if let Err(e) = server.serve(&mut chan) {
eprintln!("holger-server-ui: robot-remote connection ended: {e}");
}
}
Err(e) => eprintln!("holger-server-ui: robot-remote stream clone failed: {e}"),
}
}
}
// ── the REAL-SOCKET proof — holger driven wire-remote end-to-end ──────────────────
#[cfg(test)]
mod tests {
use super::*;
use nornir_robotui::cmd_json::{CmdJsonClient, CmdRequest, CmdResponse};
use nornir_robotui::{Action, Assert, Target};
use serde_json::json;
use std::os::unix::net::UnixStream;
/// The whole point: a driver drives the REAL holger operator egui shell OVER A REAL
/// UNIX SOCKET (no duplex, no container — an actual `AF_UNIX` stream). Single-threaded,
/// lockstep (ROOT LAW #0): connect into the listener backlog, accept, then pump the
/// host's `serve_one` between the client's `send` and `recv` — the app's own I/O task
/// without a thread.
///
/// RED-WHEN-BROKEN: a real tab-switch CLICK sent over the socket must move the REMOTE
/// shell's active tab and the wired-back `state_json` must reflect it; a broken mount,
/// a wrong socket, a dropped command, or a swallowed click all fail this. A stale
/// assertion must surface as `Asserted { pass: false }` with a reason.
#[test]
fn drives_the_holger_shell_over_a_real_unix_socket() {
let dir = tempfile::tempdir().unwrap();
// The member socket the fleet dials is `<dir>/holger.sock` (basename == member).
let sock = dir.path().join("holger.sock");
// Bind → connect (into the backlog) → accept — all single-threaded, no spawn.
let listener = UnixListener::bind(&sock).unwrap();
let client_stream = UnixStream::connect(&sock).unwrap();
let (server_stream, _) = listener.accept().unwrap();
let mut server = robot_session();
let mut server_chan = IoChannel::new(
BufReader::new(server_stream.try_clone().unwrap()),
server_stream,
);
let mut client = CmdJsonClient::new(IoChannel::new(
BufReader::new(client_stream.try_clone().unwrap()),
client_stream,
));
// A lockstep pump: send → host serves one → recv (stands in for the host's own
// I/O task without any thread).
macro_rules! call {
($req:expr) => {{
client.send(&$req).expect("send over the socket");
assert!(
server.serve_one(&mut server_chan).expect("host serves one"),
"host served a request over the socket",
);
client.recv().expect("recv over the socket")
}};
}
// Handshake travels the real socket.
assert!(
matches!(call!(CmdRequest::Ping), CmdResponse::Pong),
"ping/pong over a real AF_UNIX socket",
);
// The holger shell opens on the Status tab.
match call!(CmdRequest::ReadStateAt { pointer: "/tab".into() }) {
CmdResponse::StateAt { value } => {
assert_eq!(value, Some(json!("status")), "remote shell opens on Status")
}
other => panic!("want StateAt, got {other:?}"),
}
// locate-by-testid: the Graph tab resolves to a real node id remotely.
match call!(CmdRequest::Locate { target: Target::label("Graph") }) {
CmdResponse::Located { id } => {
assert!(id.is_some(), "the Graph tab is located over the wire")
}
other => panic!("want Located, got {other:?}"),
}
// Drive a REAL tab-switch CLICK over the socket.
match call!(CmdRequest::Act { action: Action::ClickLabel("Graph".into()) }) {
CmdResponse::Acted { ok: true, error: None } => {}
other => panic!("the tab click did not land over the wire: {other:?}"),
}
// The wired-back state proves the REMOTE holger UI actually changed.
match call!(CmdRequest::ReadStateAt { pointer: "/tab".into() }) {
CmdResponse::StateAt { value } => assert_eq!(
value,
Some(json!("graph")),
"the click moved the REMOTE holger active tab over the socket",
),
other => panic!("want StateAt, got {other:?}"),
}
// RED-WHEN-BROKEN: a stale assertion must surface as Asserted{pass:false} with a
// reason — not a silent pass, not a dropped connection.
match call!(CmdRequest::Assert {
assert: Assert::State { pointer: "/tab".into(), expected: json!("status") },
}) {
CmdResponse::Asserted { pass: false, reason: Some(r) } => {
assert!(!r.is_empty(), "the failure reason travels the wire")
}
other => panic!("a stale assertion must fail over the wire: {other:?}"),
}
// …and the TRUE post-click state passes over the same wire.
match call!(CmdRequest::Assert {
assert: Assert::State { pointer: "/tab".into(), expected: json!("graph") },
}) {
CmdResponse::Asserted { pass: true, reason: None } => {}
other => panic!("the true state must pass over the wire: {other:?}"),
}
#[cfg(feature = "testmatrix")]
nornir_testmatrix::functional_status(
"holger-server-ui",
"wire_remote_socket_drivable",
true,
"holger-server-ui MOUNTS a cmd_json::CmdJsonServer over its egui operator shell on a \
real AF_UNIX socket (basename `holger.sock` — the member name the fleet dials); \
a CmdJsonClient pings, locates + CLICKS the Graph tab, and the wired-back \
state_json shows the REMOTE active tab changed status→graph — holger flipped \
from probe-only to wire-driven (red-when-broken)",
);
}
/// The per-tab SCENARIO LIBRARY proof: EVERY recorded
/// [`Scenario`](nornir_robotui::scenario::Scenario) from
/// [`crate::scenarios::holger_tab_scenarios`] is driven, step by step, against the
/// LIVE mounted holger operator shell OVER A REAL UNIX SOCKET (the same
/// bind→connect→accept→lockstep-`serve_one` pattern the mount test above uses —
/// single-threaded, ROOT LAW #0). Each step is a wire [`CmdRequest::DriveStep`] (act
/// THEN assert), and we assert every step PASSES on the live shell.
///
/// RED-WHEN-BROKEN: a broken scenario (a wrong `ClickLabel`, a stale `state_json`
/// oracle, a removed/renamed tab, a swallowed click) fails its step's assertion, so
/// its `Stepped { pass: false, message }` collects into `failures` and the final
/// assert goes red with the exact tab+step+reason. The recorded tab set is ALSO
/// asserted equal to the shell's OWN `/tabs` roster, so a removed/renamed/added tab
/// (or a stale library) goes red. This proves the scenarios drive REAL holger, not a
/// mock.
#[test]
fn drives_every_recorded_tab_scenario_over_a_real_unix_socket() {
use crate::scenarios::{holger_tab_scenario_library, holger_tab_scenarios};
let dir = tempfile::tempdir().unwrap();
let sock = dir.path().join("holger-tab-scenarios.sock");
// Bind → connect (into the backlog) → accept — all single-threaded, no spawn.
let listener = UnixListener::bind(&sock).unwrap();
let client_stream = UnixStream::connect(&sock).unwrap();
let (server_stream, _) = listener.accept().unwrap();
let mut server = robot_session();
let mut server_chan = IoChannel::new(
BufReader::new(server_stream.try_clone().unwrap()),
server_stream,
);
let mut client = CmdJsonClient::new(IoChannel::new(
BufReader::new(client_stream.try_clone().unwrap()),
client_stream,
));
// Lockstep pump (stands in for the host's own I/O task without a thread).
macro_rules! call {
($req:expr) => {{
client.send(&$req).expect("send over the socket");
assert!(
server.serve_one(&mut server_chan).expect("host serves one"),
"host served a request over the socket",
);
client.recv().expect("recv over the socket")
}};
}
// Handshake first — the socket is live.
assert!(matches!(call!(CmdRequest::Ping), CmdResponse::Pong), "ping over the socket");
// The library is honest & complete: the recorded tab set (the slot ids) MUST
// equal the shell's OWN `/tabs` roster (a removed/renamed/added tab here goes red).
let lib = holger_tab_scenario_library();
let recorded: Vec<String> = lib.iter().map(|t| t.tab.to_string()).collect();
match call!(CmdRequest::ReadStateAt { pointer: "/tabs".into() }) {
CmdResponse::StateAt { value: Some(tabs) } => {
let live: Vec<String> = serde_json::from_value(tabs).expect("/tabs is a string list");
assert_eq!(
recorded, live,
"the recorded scenario library MUST cover exactly the shell's registered tabs, in order",
);
}
other => panic!("want the shell's /tabs list, got {other:?}"),
}
// Drive EVERY step of EVERY scenario over the wire; collect any failure.
let mut failures: Vec<String> = Vec::new();
let mut steps_driven = 0usize;
for scenario in holger_tab_scenarios() {
let tab = scenario.suite.clone();
for step in scenario.steps {
let label = step.label.clone();
match call!(CmdRequest::DriveStep { step }) {
CmdResponse::Stepped { pass: true, .. } => steps_driven += 1,
CmdResponse::Stepped { pass: false, message } => {
failures.push(format!("[{tab}] {label}: {message}"));
}
other => failures.push(format!("[{tab}] {label}: unexpected {other:?}")),
}
}
}
assert!(
failures.is_empty(),
"every recorded tab scenario must PASS on the live shell — {} step(s) failed:\n {}",
failures.len(),
failures.join("\n "),
);
assert_eq!(
steps_driven,
holger_tab_scenarios().iter().map(|s| s.steps.len()).sum::<usize>(),
"every recorded step was driven over the socket",
);
let real = lib.iter().filter(|t| !t.probe_only).count();
let probe_only = lib.iter().filter(|t| t.probe_only).count();
eprintln!(
"holger tab-scenario library: {} tabs, {} with a real interaction, {} probe-only, {} steps driven",
lib.len(), real, probe_only, steps_driven,
);
#[cfg(feature = "testmatrix")]
nornir_testmatrix::functional_status(
"holger-server-ui",
"per_tab_scenarios_drive_live_shell",
failures.is_empty(),
"the recorded per-tab robot SCENARIO LIBRARY (scenarios::holger_tab_scenarios) \
drives EVERY holger operator tab against the LIVE mounted egui shell over a real \
AF_UNIX socket (bind→connect→accept→lockstep serve_one; DriveStep per step): \
each tab is SELECTED (ClickLabel → /tab moved), asserted to have DREW, and \
asserted to fold its OWN state_json identity — Lineage + Tools also drive a REAL \
within-pane state change; the recorded set equals the shell's /tabs roster — the \
fleet's RobotDriveMember now has a specific-tab script, not a bare liveness probe \
(red-when-broken)",
);
}
}