tests/viz_atom_walk.rs
//! **S2 — the ATOM-WALK (L0 oracle): drive the REAL viz through a kittest
//! `Harness` against an EMPTY remote server and assert NO error atom leaks into
//! the rendered AccessKit tree — on EVERY tab, including the 🧬 Nornir tab whose
//! CloneEvents/Jobs SUB-PANELS no per-button/per-tab enumeration covers.**
//!
//! Why remote (`--server`): the `workspace `` is not served` NotFound is a gRPC
//! error — it only fires in THIN mode (local mode never calls `Viz.CloneEvents` /
//! `Viz.Jobs`). So the walk spawns an empty `nornir-server`, points a remote
//! `Source` viz at it, and walks the live tree with `robotui::error_atoms`.
//!
//! This SUPERSEDES the `state_json`-based `viz_empty_registry_onboarding` test: it
//! sees the rendered SUB-PANEL atoms that `state_json` slices are blind to. It was
//! RED before the empty-workspace guard in `nornir_tab.rs` (the Nornir tab's
//! populate-status + jobs panels rendered the `is not served` error atom) and is
//! GREEN after.
//!
//! Run:
//! cargo test --features "viz,server" --test viz_atom_walk -- --test-threads=1
#![cfg(all(feature = "viz", feature = "server"))]
use std::net::TcpListener;
use std::path::PathBuf;
use std::process::{Child, Command};
use std::time::{Duration, Instant};
use egui_kittest::Harness;
use nornir::viz::UrdrThreadsApp;
use nornir::viz::control::VizCommand;
use nornir::viz::surfaces::viz_surfaces;
use nornir_robotui::error_atoms;
use uuid::Uuid;
// Every viz tab, by its `Tab::from_name` / `apply_command` name (app.rs Debug).
// Superseded as the walk enumerator by `viz_surfaces()` (S3c) but kept as a
// human-readable cross-check of the tab names.
#[allow(dead_code)]
const TABS: &[&str] = &[
"Nornir",
"Timeline",
"DepGraph",
"CallGraph",
"Architecture",
"Funnel",
"TimeTravel",
"LiveRun",
"Release",
"Knowledge",
"Warehouse",
"Mcp",
"Search",
"Gates",
"Security",
"Holger",
"Manual",
];
fn free_port() -> u16 {
TcpListener::bind("127.0.0.1:0")
.unwrap()
.local_addr()
.unwrap()
.port()
}
/// An ephemeral `nornir-server` with a FRESH EMPTY registry (no monitors, no
/// seeded workspace) — the exact fresh-server state that makes a remote viz call
/// `Viz.CloneEvents`/`Viz.Jobs` with an empty workspace.
struct EmptyServer {
child: Child,
root: PathBuf,
endpoint: String,
token: String,
}
impl Drop for EmptyServer {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
let _ = std::fs::remove_dir_all(&self.root);
}
}
fn spawn_empty_server() -> EmptyServer {
let token = "atom-walk-token-0123456789abcdef";
let port = free_port();
let endpoint = format!("http://127.0.0.1:{port}");
let root = std::env::temp_dir().join(format!(
"nornir-atom-walk-{}-{}",
std::process::id(),
Uuid::new_v4()
));
let _ = std::fs::remove_dir_all(&root);
let server_home = root.join("home");
// An empty registry dir — no workspaces served.
std::fs::create_dir_all(server_home.join(".nornir").join("workspaces")).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)
// No NORNIR_MONITOR ⇒ the registry stays empty.
.env("NORNIR_POLL", "3600s")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.expect("spawn nornir-server");
let srv = EmptyServer {
child,
root,
endpoint: endpoint.clone(),
token: token.to_string(),
};
// Wait until the server answers (list_workspaces returns Ok — empty is fine).
let deadline = Instant::now() + Duration::from_secs(45);
loop {
if nornir::viz::remote::list_workspaces(&endpoint, token).is_ok() {
break;
}
assert!(Instant::now() < deadline, "empty server never came up");
std::thread::sleep(Duration::from_millis(200));
}
srv
}
/// Build the REAL viz pointed at the empty remote server (thin `Source`), with NO
/// workspace selected — the fresh-server resting state.
fn remote_empty_app(srv: &EmptyServer) -> UrdrThreadsApp {
UrdrThreadsApp::with_remote(
srv.endpoint.clone(),
srv.token.clone(),
String::new(), // EMPTY workspace — the fresh-server state
PathBuf::new(),
Vec::new(),
)
}
#[test]
fn empty_server_every_tab_has_no_error_atom_in_the_live_tree() {
let srv = spawn_empty_server();
// Host the real viz in a kittest Harness — `draw_ui` is the same tick the live
// window runs. A generous size so every panel lays out (the Nornir tab's
// populate-status + jobs sub-panels in particular).
let mut harness: Harness<'_, UrdrThreadsApp> = Harness::builder()
.with_size(egui::Vec2::new(1400.0, 1000.0))
.build_ui_state(
|ui, app: &mut UrdrThreadsApp| app.draw_ui(ui),
remote_empty_app(&srv),
);
let mut offenders: Vec<String> = Vec::new();
// S3c: drive the PANE DISCOVERY REGISTRY (`viz_surfaces()`) instead of a
// hand-maintained tab list — one entry per `Tab::ALL` variant PLUS the 🧬 tab's
// `populate_status` / `jobs` SUB-PANELS, which no per-tab list covered before.
// Each surface carries its tab (where the atom-walk drives) and its stable id
// (the AccessKit `id_salt` at its render root) so the walk is error-clean per
// addressable surface in the empty state.
for surface in viz_surfaces() {
// ROBOT: drive to the surface's tab exactly like a live click, then pump a
// few frames so the remote RPCs resolve and the sub-panels render. (Sub-
// panels render on their parent tab — `populate_status`/`jobs` on 🧬 Nornir.)
harness.state_mut().apply_command(&VizCommand {
tab: Some(format!("{:?}", surface.tab)),
..Default::default()
});
for _ in 0..4 {
let _ = harness.run_ok();
}
let atoms = error_atoms(&harness);
if !atoms.is_empty() {
let kind = if surface.is_subpanel {
"sub-panel"
} else {
"tab"
};
offenders.push(format!(
"{kind} `{}` (tab {:?}): {atoms:?}",
surface.id, surface.tab
));
}
}
// Belt-and-braces: the registry MUST carry the two sub-panels (the whole reason
// S3c exists) — assert they were among the surfaces we just walked.
let walked: Vec<&str> = viz_surfaces().iter().map(|s| s.id).collect();
assert!(
walked.contains(&"Nornir.populate_status"),
"registry missing populate_status sub-panel"
);
assert!(
walked.contains(&"Nornir.jobs"),
"registry missing jobs sub-panel"
);
assert!(
offenders.is_empty(),
"empty-server viz leaked error atoms (each is a `is not served`/`Failed to \
load`/`unavailable` node the user would see):\n{}",
offenders.join("\n")
);
}
/// SENSITIVITY: the walk MUST be able to go RED. We never assert against a
/// non-empty workspace here (the empty server has none), so instead prove the
/// oracle catches a planted error atom — a UI that renders the exact NotFound text
/// makes `error_atoms` non-empty. This is the git-stash equivalent: it proves the
/// green above is not vacuous (the walk genuinely sees `is not served`).
#[test]
fn atom_walk_catches_a_planted_not_served_error_atom() {
let mut harness = Harness::new_ui_state(
|ui, _state: &mut ()| {
ui.label("everything is fine");
ui.colored_label(egui::Color32::RED, "✗ NotFound: workspace `` is not served");
},
(),
);
let _ = harness.run_ok();
let atoms = error_atoms(&harness);
assert!(
atoms.iter().any(|a| a.contains("is not served")),
"the walk MUST catch a planted `is not served` atom (proves it can go red): {atoms:?}"
);
}