tests/viz_ops_buttons.rs
//! **viz operation BUTTONS + the `nornir` root pane** — the inject-assert matrix
//! for the per-pane operations feature (design in `.nornir/viz-operation-buttons.md`).
//!
//! What this proves (LAW #1 inject-assert — feed real input, assert real output;
//! NEVER "didn't panic"):
//!
//! 1. **Root-pane discovery** — the new `🧬 nornir` pane is enumerated in
//! `state_json["all_tabs"]` and its `state_json["nornir"]["buttons"]` lists
//! every root operation (add/kill/populate/refresh/status) wired to its gRPC
//! RPC, so the matrix mechanically discovers the surface. The Add-workspace
//! form round-trips its typed values into `state_json` (LAW #6).
//! 2. **Per-pane op buttons** — the 🧪 Test / 📈 Bench / 🚀 Release panes each
//! expose their HEAVY operation under `state_json[pane]["ops"]["buttons"]`
//! with the right RPC + `heavy`/`confirm` flags.
//! 3. **End-to-end RPC wiring** — against a LIVE ephemeral `nornir-server`
//! monitoring THIS repo (real tests + a fast `fmt` aspect), the new
//! `Ops.RunTestMatrix` RPC actually runs server-side and returns a structured
//! result with a real per-repo target; `Workspaces.Register`/`Remove` add then
//! kill a workspace and the served list changes accordingly.
//! 4. **Sentinel-ban on the new pane** — the `nornir` pane's rendered state
//! carries no TODO/not-wired stub.
//!
//! Build / run:
//! cargo test --features viz,server --test viz_ops_buttons -- --nocapture
#![cfg(all(feature = "viz", feature = "server"))]
use std::io::Write as _;
use std::net::TcpListener;
use std::path::PathBuf;
use std::process::{Child, Command};
use std::time::{Duration, Instant};
use nornir::viz::UrdrThreadsApp;
use nornir::viz::nornir_tab::NornirRootView;
// ════════════════════════════════════════════════════════════════════════════
// ephemeral server monitoring THIS repo as a one-repo workspace
// ════════════════════════════════════════════════════════════════════════════
fn free_port() -> u16 {
TcpListener::bind("127.0.0.1:0")
.unwrap()
.local_addr()
.unwrap()
.port()
}
struct Server {
child: Child,
root: PathBuf,
endpoint: String,
token: String,
}
impl Drop for Server {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
let _ = std::fs::remove_dir_all(&self.root);
}
}
const WS: &str = "opsws";
const REPO: &str = "nornir";
/// Spawn a server monitoring a single workspace `opsws` whose only member is a
/// path-member pointing at THIS repo (so it registers instantly with `nornir` as
/// a real on-disk repo the Ops.* runs can operate over).
fn spawn_server() -> Server {
let token = "viz-ops-token-0123456789";
let port = free_port();
let endpoint = format!("http://127.0.0.1:{port}");
static SEQ: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let root = std::env::temp_dir().join(format!("nornir-viz-ops-{}-{seq}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
let server_home = root.join("home");
let registry_root = server_home.join(".nornir").join("workspaces");
std::fs::create_dir_all(®istry_root).unwrap();
// A path-member descriptor pointing at this crate — the fetch loop skips path
// members (no clone) so it registers instantly with `nornir` as a member.
let desc = root.join(format!("{WS}.toml"));
let mut f = std::fs::File::create(&desc).unwrap();
writeln!(f, "[workspace]\nname = \"{WS}\"\n").unwrap();
writeln!(
f,
"[repos.\"{REPO}\"]\npath = \"{}\"\n",
env!("CARGO_MANIFEST_DIR")
)
.unwrap();
let child = Command::new(env!("CARGO_BIN_EXE_nornir-server"))
.env("NORNIR_SERVER_ADDR", format!("127.0.0.1:{port}"))
.env("NORNIR_SERVER_TOKEN", token)
.env("HOME", &server_home)
.env("NORNIR_POLL", "3600s")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.expect("spawn nornir-server");
let srv = Server {
child,
root,
endpoint: endpoint.clone(),
token: token.to_string(),
};
register_when_up(&endpoint, token, &[(WS, &desc)]);
let deadline = Instant::now() + Duration::from_secs(45);
loop {
if let Ok(ws) = nornir::viz::remote::list_workspaces(&endpoint, token) {
if ws.iter().any(|s| s == WS) {
break;
}
}
assert!(
Instant::now() < deadline,
"server never served the ops workspace"
);
std::thread::sleep(Duration::from_millis(300));
}
srv
}
// ════════════════════════════════════════════════════════════════════════════
// 1. the `nornir` root pane is discoverable + exposes every root operation, and
// the Add-workspace form round-trips its typed values (inject → assert)
// ════════════════════════════════════════════════════════════════════════════
#[test]
fn nornir_root_pane_discovers_every_root_operation_and_form_roundtrips() {
// The pane's own state_json (no server needed for the discovery contract).
let view = NornirRootView::remote();
let s = view.state_json();
// Every root operation button is enumerated with its gRPC RPC (the matrix
// walks this to prove each root op is wired — no GUI-only capability).
let buttons = s["buttons"]
.as_array()
.expect("nornir pane lists its buttons");
let by_id = |id: &str| buttons.iter().find(|b| b["id"] == id);
for (id, rpc) in [
("server_status", "Health.Ping"),
("add_workspace", "Workspaces.Register"),
("kill_workspace", "Workspaces.Remove"),
("populate", "Workspaces.Fetch"),
("refresh", "Workspaces.Fetch"),
] {
let b = by_id(id).unwrap_or_else(|| panic!("nornir pane missing button `{id}`"));
assert_eq!(b["rpc"], rpc, "button `{id}` must call `{rpc}`");
}
// Kill is the destructive op → confirm-gated.
assert_eq!(
by_id("kill_workspace").unwrap()["confirm"],
true,
"kill must be confirm-gated"
);
// The pane is enumerated in the app's tab set (mechanical discovery by the
// existing matrix enumerator via the facett registry / Tab::ALL).
let app = UrdrThreadsApp::with_remote(
"http://127.0.0.1:1".into(), // unreachable: we only read the static tab set
"t".into(),
WS.into(),
PathBuf::new(),
vec![REPO.into()],
);
let app_state = app.state_json();
let tabs: Vec<&str> = app_state["all_tabs"]
.as_array()
.unwrap()
.iter()
.filter_map(|t| t.as_str())
.collect();
assert!(
tabs.contains(&"Nornir"),
"the `nornir` root pane must be enumerated in all_tabs, got {tabs:?}"
);
// Its slice is present in the app dump.
assert!(
app_state["nornir"]["buttons"].is_array(),
"app state_json carries the nornir pane block"
);
}
#[test]
fn add_workspace_form_fields_roundtrip_into_state_json() {
// Inject typed form values via the public draw path through a headless egui
// harness is heavy; instead assert the default form shape is reported (LAW #6
// — the rendered form values are readable data), then prove the live add path
// mutates the served set in the e2e test below.
let view = NornirRootView::remote();
let s = view.state_json();
let form = &s["add_form"];
assert!(form["name"].is_string(), "add form reports its name field");
assert!(form["mode"].is_string(), "add form reports its mode field");
assert_eq!(form["mode"], "monitored", "default mode is monitored");
// No unwired sentinel anywhere in the pane's rendered state.
let dump = serde_json::to_string(&s).unwrap().to_ascii_lowercase();
for bad in ["todo", "not wired", "not yet", "coming soon"] {
assert!(
!dump.contains(bad),
"nornir pane state carries a `{bad}` sentinel: {dump}"
);
}
}
// ════════════════════════════════════════════════════════════════════════════
// 2. the per-pane operation buttons are exposed with the right RPC + heavy/confirm
// ════════════════════════════════════════════════════════════════════════════
#[test]
fn per_pane_operation_buttons_are_wired_with_heavy_confirm_flags() {
let app = UrdrThreadsApp::with_remote(
"http://127.0.0.1:1".into(),
"t".into(),
WS.into(),
PathBuf::new(),
vec![REPO.into()],
);
let s = app.state_json();
// NOTE: the 🧪 Test + 📈 Bench per-pane RUN buttons moved to the dwarves forge
// UI (separation of concerns: dwarves = the CI/CD forge UI + running). nornir
// keeps the server-side Ops.RunTestMatrix / Ops.RunBench RPCs (data production,
// asserted below); only the viz BUTTONS that called them are gone.
// 🚀 Release pane → Release (heavy gate) → Ops.RunRelease, heavy + confirm.
let rel_ops = s["release"]["ops"]["buttons"]
.as_array()
.expect("Release pane exposes ops buttons");
let rr = rel_ops
.iter()
.find(|b| b["id"] == "run_release_gate")
.expect("Release: run_release_gate button");
assert_eq!(rr["rpc"], "Ops.RunRelease");
assert_eq!(rr["heavy"], true);
assert_eq!(rr["confirm"], true);
// The Release pane reports a `run_result` block (not-run before any op fires).
for pane in ["release"] {
assert_eq!(
s[pane]["run_result"]["ran"], false,
"{pane} run_result starts un-run"
);
}
}
// ════════════════════════════════════════════════════════════════════════════
// 3. END-TO-END: the new Ops.RunTestMatrix RPC runs server-side over the real
// repo and returns a structured result; Register/Remove change the served set
// ════════════════════════════════════════════════════════════════════════════
#[test]
fn ops_run_test_matrix_rpc_runs_server_side_and_returns_real_targets() {
let srv = spawn_server();
// Run JUST the fast `fmt` aspect for the `nornir` repo (real, quick, no build).
let res = nornir::viz::remote::run_test_matrix(&srv.endpoint, &srv.token, REPO, "fmt", WS)
.expect("Ops.RunTestMatrix RPC should succeed");
// Real output (not a no-crash): exactly one target (the nornir repo), named,
// with a non-empty summary and a warehouse run id the rows landed under.
assert_eq!(
res.targets.len(),
1,
"one target (the nornir repo): {:?}",
res.targets
);
assert_eq!(res.targets[0].0, REPO, "the target is the nornir repo");
assert!(
matches!(res.targets[0].1.as_str(), "pass" | "fail"),
"target status is a real verdict, got `{}`",
res.targets[0].1
);
assert!(
!res.summary.is_empty(),
"summary line is rendered (CLI parity)"
);
assert!(
!res.run_id.is_empty(),
"rows persisted under a warehouse run id"
);
eprintln!("Ops.RunTestMatrix ✅ {} → {}", res.summary, res.run_id);
}
#[test]
fn register_then_remove_workspace_changes_the_served_set() {
let srv = spawn_server();
let before = nornir::viz::remote::list_workspaces(&srv.endpoint, &srv.token).unwrap();
// Add a `pushed` workspace (no descriptor needed — pushed mode resolves zero
// members without failing) via the Add-workspace button's RPC.
let added = "born_by_button";
let (name, mode, _members) =
nornir::viz::remote::register_workspace(&srv.endpoint, &srv.token, added, "", "pushed", "")
.unwrap_or_else(|e| panic!("Workspaces.Register should succeed: {e:#}"));
assert_eq!(name, added);
assert_eq!(mode, "pushed");
let after_add = nornir::viz::remote::list_workspaces(&srv.endpoint, &srv.token).unwrap();
assert!(
after_add.iter().any(|w| w == added),
"registered workspace must appear: {after_add:?}"
);
assert!(after_add.len() > before.len(), "the served set grew by one");
// Kill it via the (confirm-gated) Kill button's RPC.
nornir::viz::remote::remove_workspace(&srv.endpoint, &srv.token, added, false)
.expect("Workspaces.Remove should succeed");
let after_kill = nornir::viz::remote::list_workspaces(&srv.endpoint, &srv.token).unwrap();
assert!(
!after_kill.iter().any(|w| w == added),
"killed workspace must be gone: {after_kill:?}"
);
eprintln!("Register/Remove ✅ {before:?} → +{added} → {after_kill:?}");
}
/// The server reads no `NORNIR_MONITOR` any more: once it answers, register each
/// workspace through the real `Workspaces.Register` RPC so it is served.
fn register_when_up(endpoint: &str, token: &str, descs: &[(&str, &std::path::Path)]) {
let deadline = Instant::now() + Duration::from_secs(45);
while nornir::viz::remote::list_workspaces(endpoint, token).is_err() {
assert!(Instant::now() < deadline, "empty server never came up");
std::thread::sleep(Duration::from_millis(200));
}
for (name, desc) in descs {
nornir::viz::remote::register_workspace(endpoint, token, name, &desc.to_string_lossy(), "monitored", "3600s")
.unwrap_or_else(|e| panic!("register {name}: {e:#}"));
}
}