dwarves-gui/src/robot_remote.rs
//! **`robot-remote`** — MOUNT dwarves as a WIRE-REMOTE robot-drive target.
//!
//! This closes the North-Star autotest "last mile" for the FIRST real app: 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, `EguiWire`), but the honest gap was per-app — "each app must MOUNT a
//! `CmdJsonServer` over its egui UI on a socket". dwarves-gui mounts it here.
//!
//! ## 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 a co-located [`RobotSession`] uses. Nothing new.
//! * The host is a [`RobotSession`] over the demo's OWN `egui_kittest` shell (the SAME
//! harness shape the wizard robot test in `main.rs` builds — REUSE, not a twin), and
//! the app's observable state IS `Facet::state_json` (no twinned oracle).
//! * The transport is an [`IoChannel`] over a real `AF_UNIX` [`UnixListener`] — the
//! Unix twin of `fleet`'s `TcpEguiWire`; edda's reusable `UnixEguiWire` dials the
//! exact socket [`serve_robot_remote`] binds.
//!
//! ## 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.
use std::io::{self, BufReader};
use std::os::unix::net::UnixListener;
use std::path::Path;
use nornir_robotui::cmd_json::{CmdJsonServer, IoChannel};
use nornir_robotui::RobotSession;
// The shell, its draw closure AND the sized off-screen surface are shared with the other
// drives (`crate::robot_mode` headless, `crate::robot_watch` visible) so every mode drives
// the SAME `ShellApp` identically.
use crate::robot_shell::{build_shell_session, ShellApp};
/// Build a [`CmdJsonServer`] over a [`RobotSession`] driving the WHOLE dwarves shell
/// through its real `egui_kittest` harness — the host side a driver dials. Offline-
/// safe construction (a dead forge endpoint, the FAT/embedded funnel, an out-of-repo
/// ledger root) — the SAME headless build the in-crate robot tests use, so mounting
/// the server dials no network.
fn robot_session() -> CmdJsonServer<RobotSession<'static, ShellApp>> {
// The offline-safe shell on the sized, settled off-screen surface — both owned by
// `robot_shell` (the load-bearing 3000pt width and WHY it is load-bearing are
// documented on `ROBOT_SURFACE`) so this mount and the `--robot-mode headless` tour
// drive ONE harness shape and cannot drift apart on the one number that decides
// whether the last tabs are clickable at all.
CmdJsonServer::new(build_shell_session("dwarves-gui"))
}
/// 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.
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!(
"dwarves-gui: 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!("dwarves-gui: robot-remote connection ended: {e}");
}
}
Err(e) => eprintln!("dwarves-gui: robot-remote stream clone failed: {e}"),
}
}
}
// ── the REAL-SOCKET proof — the first real app 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 dwarves 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_dwarves_shell_over_a_real_unix_socket() {
let dir = tempfile::tempdir().unwrap();
let sock = dir.path().join("dwarves-robot.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 shipped shell opens on the Lenses tab (the Mímir-family landing).
match call!(CmdRequest::ReadStateAt {
pointer: "/active_tab".into()
}) {
CmdResponse::StateAt { value } => {
assert_eq!(value, Some(json!("Lenses")), "remote shell opens on Lenses")
}
other => panic!("want StateAt, got {other:?}"),
}
// locate-by-testid: the Pipeline tab resolves to a real node id remotely.
match call!(CmdRequest::Locate {
target: Target::label("Pipeline")
}) {
CmdResponse::Located { id } => {
assert!(id.is_some(), "the Pipeline 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("Pipeline".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 dwarves UI actually changed.
match call!(CmdRequest::ReadStateAt {
pointer: "/active_tab".into()
}) {
CmdResponse::StateAt { value } => assert_eq!(
value,
Some(json!("Pipeline")),
"the click moved the REMOTE dwarves 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: "/active_tab".into(),
expected: json!("Warehouse")
},
}) {
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: "/active_tab".into(),
expected: json!("Pipeline")
},
}) {
CmdResponse::Asserted {
pass: true,
reason: None,
} => {}
other => panic!("the true state must pass over the wire: {other:?}"),
}
#[cfg(feature = "testmatrix")]
nornir_testmatrix::functional_status(
"dwarves-gui",
"wire_remote_socket_drivable",
true,
"dwarves-gui MOUNTS a cmd_json::CmdJsonServer over its egui shell on a real \
AF_UNIX socket; a CmdJsonClient pings, locates + CLICKS the Pipeline tab, and \
the wired-back state_json shows the REMOTE active tab changed Warehouse→Pipeline \
— the first real app driven wire-remote end-to-end (red-when-broken)",
);
}
/// The per-tab SCENARIO LIBRARY proof: EVERY recorded
/// [`Scenario`](nornir_robotui::scenario::Scenario) from
/// [`crate::scenarios::dwarves_tab_scenarios`] is driven, step by step, against the
/// LIVE mounted dwarves 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. This proves the scenarios drive
/// REAL dwarves, not a mock.
#[test]
fn drives_every_recorded_tab_scenario_over_a_real_unix_socket() {
use crate::scenarios::{dwarves_tab_scenario_library, dwarves_tab_scenarios};
let dir = tempfile::tempdir().unwrap();
let sock = dir.path().join("dwarves-tab-scenarios.sock");
let listener = UnixListener::bind(&sock).unwrap();
let client_stream = UnixStream::connect(&sock).unwrap();
let (server_stream, _) = listener.accept().unwrap();
// Build the offline shell under a HERMETIC env: hold `ENV_LOCK` and point TMPDIR +
// the constellation root at a SEPARATE empty tempdir (kept apart from the socket
// dir above, which must stay untouched) for exactly the app-build window. Without
// this the Release tab resolves the live `constellation_root()` (the real dev-box
// constellation) and reads `std::env::temp_dir()` for its convergence file, so a
// concurrent Release `scratch_tmp` test (which repoints TMPDIR at a scratch dir
// holding a SEEDED 1-node convergence) races in and the recorded scenario flaps.
// With TMPDIR isolated to an empty dir the tab deterministically reads NOTHING and
// says so (its named empty state), like every other offline tab. The Build Thing
// scene resolves ITS root from `$DWARVES_CONSTELLATION` (else the directories above
// the cwd), so that is pointed at the same empty dir: without it the scene finds
// whichever sibling checkouts this machine holds, and the scenario's "the empty
// root refuses" steps test the machine. The env is read only while the panes are
// constructed, so the lock is released — and env restored — the moment the shell
// is built, before any socket I/O.
let mut server = {
let _env = crate::ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let env_dir = tempfile::tempdir().unwrap();
let prev_tmp = std::env::var("TMPDIR").ok();
let prev_root = std::env::var("DWARVES_CONSTELLATION_ROOT").ok();
let prev_builds = std::env::var("DWARVES_CONSTELLATION").ok();
// SAFETY: single-threaded build window holding ENV_LOCK — no other lock-holding
// test reads env concurrently (the discipline `release.rs`'s `scratch_tmp` uses).
unsafe {
std::env::set_var("TMPDIR", env_dir.path());
std::env::set_var("DWARVES_CONSTELLATION_ROOT", env_dir.path());
std::env::set_var("DWARVES_CONSTELLATION", env_dir.path());
}
let session = robot_session();
unsafe {
match &prev_tmp {
Some(v) => std::env::set_var("TMPDIR", v),
None => std::env::remove_var("TMPDIR"),
}
match &prev_root {
Some(v) => std::env::set_var("DWARVES_CONSTELLATION_ROOT", v),
None => std::env::remove_var("DWARVES_CONSTELLATION_ROOT"),
}
match &prev_builds {
Some(v) => std::env::set_var("DWARVES_CONSTELLATION", v),
None => std::env::remove_var("DWARVES_CONSTELLATION"),
}
}
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 MUST equal the shell's
// OWN `/tabs` oracle (a removed/renamed/added tab here goes red).
let lib = dwarves_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 dwarves_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:?}")),
}
}
// Diagnostic: dump the live pane sub-oracle's top-level keys (helps keep the
// recorded identity oracles honest against the real shell).
let ptr = format!(
"/{}",
tab.to_ascii_lowercase()
.replace('~', "~0")
.replace('/', "~1")
);
if let CmdResponse::StateAt { value: Some(v) } = call!(CmdRequest::ReadStateAt {
pointer: ptr.clone()
}) {
let keys: Vec<String> = v
.as_object()
.map(|o| o.keys().cloned().collect())
.unwrap_or_default();
eprintln!("dwarves tab {tab:>10} {ptr} keys = {keys:?}");
}
}
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,
dwarves_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!(
"dwarves 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(
"dwarves-gui",
"per_tab_scenarios_drive_live_shell",
failures.is_empty(),
"the recorded per-tab robot SCENARIO LIBRARY (scenarios::dwarves_tab_scenarios) \
drives EVERY dwarves viz 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 → /active_tab moved), asserted to have DREW, \
and asserted to fold its OWN state_json identity — the fleet's RobotDriveMember \
now has a specific-tab script, not a bare liveness probe (red-when-broken)",
);
}
}