tests/viz_remote.rs
//! Headless test matrix for the viz gRPC thin-client (`nornir::viz::remote`).
//!
//! Spins an **ephemeral `nornir-server`** over a temp `/tmp` warehouse with one
//! registered workspace, then drives the same client calls the viz makes — so the
//! workspace **picker**, **Warehouse** browser, and timeline data paths are
//! auto-tested without a GUI or a live server. Teardown removes the temp dir.
#![cfg(all(feature = "viz", feature = "server"))]
use std::io::Write;
use std::net::TcpListener;
use std::path::PathBuf;
use std::process::{Child, Command};
use std::time::{Duration, Instant};
fn free_port() -> u16 {
TcpListener::bind("127.0.0.1:0")
.unwrap()
.local_addr()
.unwrap()
.port()
}
/// Owns the spawned server; kills it + cleans the temp dir on drop.
struct Server {
child: Child,
root: PathBuf,
}
impl Drop for Server {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
let _ = std::fs::remove_dir_all(&self.root);
}
}
#[test]
fn viz_remote_matrix() {
let token = "viz-matrix-token-0123456789";
let port = free_port();
let endpoint = format!("http://127.0.0.1:{port}");
let root = std::env::temp_dir().join(format!("nornir-viz-matrix-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
// The registry root is home-derived (`<home>/.nornir/workspaces`); isolate
// the spawned server by giving it a temp HOME and pre-creating the registry
// root so the monitored role's "registry exists" gate opens.
let server_home = root.join("home");
let registry_root = server_home.join(".nornir").join("workspaces");
std::fs::create_dir_all(®istry_root).unwrap();
// Monitored workspace with a single LOCAL path member — the fetch loop skips
// path members (no clone, no scan), so the workspace registers instantly and
// its `builds/` warehouse comes up with the standard table layout.
let desc = root.join("ws.toml");
{
let mut f = std::fs::File::create(&desc).unwrap();
write!(
f,
"[workspace]\nname = \"matrixws\"\n\n[repos.self]\npath = \"{}\"\n",
env!("CARGO_MANIFEST_DIR")
)
.unwrap();
}
// 📦 Holger: point the server's embedded-holger data dir at an isolated temp
// dir so `Viz.HolgerRegistry` reads a fresh dev pair (no real `~/.nornir`
// archive). `read_registry` creates it on demand and lists the configured
// dev-pair repos (crates-io / cache / sparring) even when empty.
let holger_data = root.join("holger-cache");
let _server = Server {
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") // don't poll during the test
.env("NORNIR_HOLGER_DATA", &holger_data)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.expect("spawn nornir-server"),
root: root.clone(),
};
register_when_up(&endpoint, token, &[("matrixws", &desc)]);
// Readiness: retry the picker's own call until the server answers.
let deadline = Instant::now() + Duration::from_secs(30);
let mut last; // assigned on every loop iteration before the assert reads it
let workspaces = loop {
match nornir::viz::remote::list_workspaces(&endpoint, token) {
Ok(ws) if !ws.is_empty() => break ws,
Ok(_) => last = "empty list".into(),
Err(e) => last = format!("{e:#}"),
}
assert!(
Instant::now() < deadline,
"server never became ready: {last}"
);
std::thread::sleep(Duration::from_millis(300));
};
// ── matrix ────────────────────────────────────────────────────────────────
// 1. picker: Workspaces.List contains the registered workspace.
assert!(
workspaces.iter().any(|w| w == "matrixws"),
"picker list missing matrixws: {workspaces:?}"
);
// 2. Warehouse tab: Warehouse.Tables for that workspace returns the layout.
let tables = nornir::viz::remote::fetch_tables(&endpoint, token, "matrixws")
.expect("fetch_tables(matrixws)");
assert!(!tables.is_empty(), "no warehouse tables for matrixws");
// 3. scanning a table is Ok (rows may be empty on a fresh warehouse).
nornir::viz::remote::scan_table(&endpoint, token, &tables[0], 10, "matrixws")
.expect("scan_table");
// 4. NO implicit default route: an empty `nornir-workspace` header must ERROR
// (the server never auto-picks a workspace; the caller must select one).
let err = nornir::viz::remote::fetch_tables(&endpoint, token, "")
.expect_err("empty workspace header must error — no implicit default route");
let msg = format!("{err:#}");
assert!(
msg.contains("no workspace selected"),
"empty-header error should explain no workspace is selected, got: {msg}"
);
// 5. NEW remote read paths — Viz.ReleaseEvents / Viz.BakeoffResults /
// Viz.Knowledge must answer cleanly for the served workspace. The fresh
// warehouse has no release/bakeoff rows yet, so an EMPTY-but-Ok response is
// the correct outcome (proves the RPC + decode wire up, not a TODO note).
let rev = nornir::viz::remote::fetch_release_events(&endpoint, token, "matrixws")
.expect("Viz.ReleaseEvents RPC must succeed (empty is ok on a fresh warehouse)");
assert!(
rev.is_empty(),
"fresh warehouse should have no release_events yet"
);
let bake = nornir::viz::remote::fetch_bakeoff_results(&endpoint, token, "matrixws")
.expect("Viz.BakeoffResults RPC must succeed (empty is ok on a fresh warehouse)");
assert!(
bake.is_empty(),
"fresh warehouse should have no agent_model_runs yet"
);
// TestResults: Viz.TestResults must answer cleanly — proves the RPC and the
// wire decode are wired up (no "TODO" dead-end). Empty on a fresh warehouse.
let test_rows = nornir::viz::remote::fetch_test_results(&endpoint, token, "matrixws")
.expect("Viz.TestResults RPC must succeed (empty is ok on a fresh warehouse)");
assert!(
test_rows.is_empty(),
"fresh warehouse should have no test_results yet"
);
// BenchTelemetry: Viz.BenchTelemetry must answer cleanly with BOTH tables —
// proves the RPC + the {telemetry,runs} wire decode are wired up (kills the
// last "RPC TODO"). Both empty on a fresh warehouse.
let (telem, runs) = nornir::viz::remote::fetch_bench_live(&endpoint, token, "matrixws")
.expect("Viz.BenchTelemetry RPC must succeed (empty ok on fresh warehouse)");
assert!(
telem.is_empty() && runs.is_empty(),
"fresh warehouse has no bench telemetry yet"
);
// 📦 Holger registry (the fix): the thin viz has no local holger, so the
// pane fetches the server's registry over Viz.HolgerRegistry. The server runs
// `read_registry` on ITS holger-cache (isolated to the temp dir above) and
// ships the `Vec<RepoSummary>`. This is the path that REPLACES the empty
// `inject(Vec::new())` stub the remote pane used to show. With `embed-holger`
// the dev pair lists crates-io / cache / sparring (0 crates on a fresh dir);
// without it the server returns `[]` (the pane degrades, still no transport
// error). Either way the RPC + decode must wire up cleanly.
// The Holger registry RPC client (`fetch_holger_registry`) is compiled ONLY
// under `embed-holger`; gate the whole check so the default `cargo test
// --workspace` (no embed-holger) still compiles this test target.
#[cfg(feature = "embed-holger")]
{
let holger = nornir::viz::remote::fetch_holger_registry(&endpoint, token, "matrixws")
.expect("Viz.HolgerRegistry RPC must succeed (decodes a Vec<RepoSummary>)");
let names: Vec<&str> = holger.iter().map(|r| r.name.as_str()).collect();
assert_eq!(
names,
vec!["crates-io", "cache", "sparring"],
"remote pane must list the dev-pair repos, got {names:?}"
);
let sparring = holger
.iter()
.find(|r| r.name == "sparring")
.expect("/sparring row");
assert!(
sparring.writable,
"/sparring is a writable rehearsal registry"
);
}
// 📦 Holger APP-SIDE WIRING (the actual regression guard): build the REMOTE
// viz app the same way `nornir-viz --server …` does and assert its 📦 pane is
// wired to FETCH over Viz.HolgerRegistry — NOT the old empty
// `inject(Vec::new())` stub that showed "data dir: <injected>" +
// "no repositories configured". The honest label is "server <endpoint>", and
// (with embed-holger) the pane lists the dev-pair repos after the background
// fetch drains. Only meaningful when the pane is compiled in.
#[cfg(feature = "embed-holger")]
{
use nornir::viz::UrdrThreadsApp;
let mut app = UrdrThreadsApp::with_remote(
endpoint.clone(),
token.to_string(),
"matrixws".to_string(),
PathBuf::new(),
Vec::new(),
);
// The pane kicks off its registry fetch on first render; drive a few
// headless passes (with a short wait) until the background gRPC read
// drains into the pane.
let deadline = Instant::now() + Duration::from_secs(20);
let h = loop {
app.render_all_tabs_headless();
let st = app.state_json();
let h = st["holger"].clone();
// data dir must be honest (server addr), never the inject sentinel.
assert_eq!(
h["data_dir"],
format!("server {endpoint}"),
"remote pane must label the server endpoint, not <injected>"
);
assert_ne!(
h["data_dir"], "<injected>",
"the empty-inject stub is the bug"
);
if h["repo_count"].as_u64().unwrap_or(0) > 0 {
break h;
}
assert!(
Instant::now() < deadline,
"remote holger pane never populated: {h}"
);
std::thread::sleep(Duration::from_millis(200));
};
let repos: Vec<&str> = h["repositories"]
.as_array()
.expect("holger.repositories array")
.iter()
.filter_map(|r| r.as_str())
.collect();
assert_eq!(
repos,
vec!["crates-io", "cache", "sparring"],
"remote pane lists the dev-pair repos (not 'no repositories configured'), got {repos:?}"
);
}
}
/// THE COMPLETE LIVE MATRIX — every viz function × every registered workspace
/// (the `workspace_*` set is the normal baseline) + an `unknown-ws` negative
/// column. Any ✗ cell fails the test with its reason. Run:
/// NORNIR_SERVER=http://oden:7878 NORNIR_SERVER_TOKEN=… \
/// cargo test --release --features server,viz --test viz_remote live_full_matrix \
/// -- --ignored --nocapture
#[test]
#[ignore = "live: full matrix needs NORNIR_SERVER + NORNIR_SERVER_TOKEN pointing at a running server"]
fn live_full_matrix() {
use nornir::viz::remote as r;
let ep = std::env::var("NORNIR_SERVER").expect("set NORNIR_SERVER");
let tok = std::env::var("NORNIR_SERVER_TOKEN").expect("set NORNIR_SERVER_TOKEN");
// The normal baseline: ALL workspace_* registrations. Override via
// NORNIR_BASELINE=a,b,c when the fleet changes.
let baseline: Vec<String> = std::env::var("NORNIR_BASELINE")
.unwrap_or_else(|_| "holger,knut,njord,nornir-catalog,znippy".into())
.split(',')
.map(|s| s.trim().to_string())
.collect();
let mut grid: Vec<(String, String, Result<String, String>)> = Vec::new(); // (row, ws, cell)
let mut cell = |row: &str, ws: &str, res: Result<String, String>| {
grid.push((row.into(), ws.into(), res));
};
// Row 1 — picker baseline: Workspaces.List must equal the baseline exactly.
let listed = r::list_workspaces(&ep, &tok).expect("Workspaces.List");
{
let mut want = baseline.clone();
want.sort();
let mut got = listed.clone();
got.sort();
cell(
"1 picker=baseline",
"*",
if got == want {
Ok(format!("{} ws", got.len()))
} else {
Err(format!("got {got:?} want {want:?}"))
},
);
}
for ws in &baseline {
// Row 2 — Timeline (Timeline + TimeTravel tabs).
cell(
"2 timeline",
ws,
r::fetch_timeline(&ep, &tok, ws)
.map(|t| format!("{} lanes", t.lanes.len()))
.map_err(|e| format!("{e:#}")),
);
// Row 3 — Warehouse.Tables.
let tables = r::fetch_tables(&ep, &tok, ws).unwrap_or_default();
cell(
"3 tables",
ws,
if tables.len() >= 30 {
Ok(format!("{}", tables.len()))
} else {
Err(format!("only {} tables", tables.len()))
},
);
// Row 4 — Warehouse.Scan over EVERY table.
let mut errs = Vec::new();
let mut nonempty = 0;
for t in &tables {
match r::scan_table(&ep, &tok, t, 5, ws) {
Ok(p) => {
if !p.rows.is_empty() {
nonempty += 1;
}
}
Err(e) => errs.push(format!("{t}: {e:#}")),
}
}
cell(
"4 scan-all-tables",
ws,
if errs.is_empty() {
Ok(format!("{nonempty} non-empty"))
} else {
Err(errs.join("; "))
},
);
// (Row 5 — the Call-Graph data path over `symbol_facts` — left with the
// table's producer, Mímir v2; the lens answers not-wired.)
// Row 6 — Dep-Graph tab data.
cell(
"6 depgraph-data",
ws,
r::scan_table(&ep, &tok, "dep_graph_edges", 50, ws)
.map(|p| format!("{} rows", p.rows.len()))
.map_err(|e| format!("{e:#}")),
);
// Row 7 — chart numeric-column detection on git_heat_facts.
cell(
"7 chart-numeric",
ws,
r::scan_table(&ep, &tok, "git_heat_facts", 50, ws)
.map_err(|e| format!("{e:#}"))
.and_then(|p| {
let li = p.columns.iter().position(|c| c == "commits_total");
let ok = li
.map(|i| {
p.rows.iter().any(|row| {
row.get(i)
.map(|c| c.trim().parse::<f64>().is_ok())
.unwrap_or(false)
})
})
.unwrap_or(false);
if ok || p.rows.is_empty() {
Ok("numeric commits_total col".into())
} else {
Err("no numeric column detected".into())
}
}),
);
// Row 12 — Workspaces.Get (info panel) per workspace.
cell(
"12 ws-get",
ws,
r::get_workspace(&ep, &tok, ws)
.map(|i| format!("{} member(s), mode={}", i.members.len(), i.mode))
.map_err(|e| format!("{e:#}")),
);
// Row 13 — Search.Query (BM25): a near-universal term must not error.
cell(
"13 search",
ws,
r::search(&ep, &tok, "fn", "", "", 5, ws)
.map(|h| format!("{} hit(s)", h.len()))
.map_err(|e| format!("{e:#}")),
);
// Row 14 — Knowledge.SymbolLookup over symbol_facts.
cell(
"14 knowledge-lookup",
ws,
r::knowledge_lookup(&ep, &tok, "", "new", 5, ws)
.map(|s| format!("{} sym(s)", s.len()))
.map_err(|e| format!("{e:#}")),
);
// Row 15 — Index.Stats (search status panel).
cell(
"15 index-stats",
ws,
r::index_stats(&ep, &tok, ws)
.map(|(t, by)| format!("{t} docs, {} corpora", by.len()))
.map_err(|e| format!("{e:#}")),
);
// Row 16 — Knowledge.Callers (call-relationship query) must not error.
cell(
"16 knowledge-callers",
ws,
r::knowledge_calls(&ep, &tok, "", "main", true, 5, ws)
.map(|c| format!("{} caller-edge(s)", c.len()))
.map_err(|e| format!("{e:#}")),
);
// Row 17 — Vector.Search: server may lack an embedder → UNIMPLEMENTED is
// an ACCEPTABLE outcome (not a viz bug), real hits are a bonus.
let vrepo = if ws == "njord" { "njord" } else { ws.as_str() };
cell(
"17 vector-search",
ws,
match r::vector_search(&ep, &tok, vrepo, "open a file", 3, ws) {
Ok(h) => Ok(format!("{} hit(s)", h.len())),
Err(e) if format!("{e:#}").to_lowercase().contains("unimpl") => {
Ok("no embedder (ok)".into())
}
Err(e) => Err(format!("{e:#}")),
},
);
// Row 18 — Funnel.Show (the plan-DAG tab's data source); an empty funnel
// is fine, we only assert the RPC + FunnelView flattening don't error.
cell(
"18 funnel-show",
ws,
r::funnel_show(&ep, &tok, ws)
.map(|v| {
let nodes: usize = v.plans.iter().map(|p| p.nodes.len()).sum();
format!("{} plan(s), {} node(s)", v.plans.len(), nodes)
})
.map_err(|e| format!("{e:#}")),
);
// Row 19 — Viz.ReleaseEvents (🚀 Release tab + 📡 Live hydrate); a
// workspace with release history MUST return rows.
cell(
"19 release-events",
ws,
r::fetch_release_events(&ep, &tok, ws)
.map(|rows| format!("{} event(s)", rows.len()))
.map_err(|e| format!("{e:#}")),
);
// Row 20 — Viz.BakeoffResults (🏆 Leaderboard); empty is fine (not every
// workspace has run a bake-off), we assert the RPC + decode don't error.
cell(
"20 bakeoff-results",
ws,
r::fetch_bakeoff_results(&ep, &tok, ws)
.map(|rows| format!("{} run-row(s)", rows.len()))
.map_err(|e| format!("{e:#}")),
);
}
// Row 8 — switching: walk every workspace in sequence re-running the core
// calls (what switch_workspace() does), order-sensitive.
{
let mut errs = Vec::new();
for ws in baseline.iter().chain(baseline.iter().rev()) {
if let Err(e) = r::fetch_timeline(&ep, &tok, ws) {
errs.push(format!("{ws}: tl {e:#}"));
}
if let Err(e) = r::fetch_tables(&ep, &tok, ws) {
errs.push(format!("{ws}: tbl {e:#}"));
}
}
cell(
"8 switch-sequence",
"*",
if errs.is_empty() {
Ok("2×walk".into())
} else {
Err(errs.join("; "))
},
);
}
// Row 9 — Live Run stream: must connect and stay open (no run = silence).
{
let (tx, rx) = std::sync::mpsc::channel::<String>();
let (ep2, tok2) = (ep.clone(), tok.clone());
std::thread::spawn(move || {
if let Err(e) = r::stream_progress(&ep2, &tok2, "matrixws", |_| {}) {
let _ = tx.send(format!("{e:#}"));
}
});
cell(
"9 live-run-stream",
"*",
match rx.recv_timeout(std::time::Duration::from_secs(3)) {
Err(_) => Ok("open 3s".into()), // still streaming — good
Ok(e) => Err(e), // errored immediately
},
);
}
// Row 10 — CLI-driven (the same gRPC the viz uses, via the installed CLI).
{
let cli = std::env::var("NORNIR_CLI").unwrap_or_else(|_| "/usr/local/bin/nornir".into());
let out = std::process::Command::new(&cli)
.args(["workspace", "ls"])
.env("NORNIR_SERVER", &ep)
.env("NORNIR_SERVER_TOKEN", &tok)
.output();
cell(
"10 cli-workspace-ls",
"*",
match out {
Ok(o) if o.status.success() => {
let text = String::from_utf8_lossy(&o.stdout);
let missing: Vec<&String> = baseline
.iter()
.filter(|w| !text.contains(w.as_str()))
.collect();
if missing.is_empty() {
Ok("all listed".into())
} else {
Err(format!("missing {missing:?}"))
}
}
Ok(o) => Err(String::from_utf8_lossy(&o.stderr).to_string()),
Err(e) => Err(format!("{e}")),
},
);
}
// Negative column — unknown workspace must fail/empty CLEANLY (no hang).
{
let res = r::fetch_tables(&ep, &tok, "no-such-workspace-xyz");
cell(
"11 unknown-ws",
"no-such-workspace-xyz",
match res {
Ok(t) => Ok(format!("served default ({} tables)", t.len())),
Err(e) if format!("{e:#}").len() < 500 => Ok("clean error".into()),
Err(e) => Err(format!("ugly: {e:#}")),
},
);
}
// ── print the grid + verdict ────────────────────────────────────────────
eprintln!("\n══ VIZ FULL MATRIX ══");
let mut failures = 0;
for (row, ws, res) in &grid {
match res {
Ok(note) => eprintln!(" ✓ {row:<22} {ws:<18} {note}"),
Err(e) => {
failures += 1;
eprintln!(" ✗ {row:<22} {ws:<18} {e}");
}
}
}
eprintln!("══ {} cells, {} failures ══", grid.len(), failures);
assert_eq!(failures, 0, "{failures} matrix cell(s) failed");
}
/// 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:#}"));
}
}